|
51 | 51 | #include "arrow/c/helpers.h" |
52 | 52 | #include "arrow/util/bit_util.h" |
53 | 53 | #include "benchmark/benchmark.h" |
| 54 | +#include "fmt/format.h" |
54 | 55 | #include "paimon/common/utils/arrow/arrow_input_stream_adapter.h" |
55 | 56 | #include "paimon/common/utils/arrow/mem_utils.h" |
56 | 57 | #include "paimon/common/utils/arrow/status_utils.h" |
@@ -104,6 +105,10 @@ constexpr int32_t kWriteBatchSize = 1024; |
104 | 105 | constexpr int64_t kPageSizeBytes = 64 * 1024; |
105 | 106 | // Four row groups per read fixture, so row-group pruning and page pruning are both in play. |
106 | 107 | 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; |
107 | 112 | constexpr int64_t kStringCardinality = 1'000; |
108 | 113 | // Few enough distinct values that arrow keeps the column dictionary-encoded for the whole file, |
109 | 114 | // 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) { |
160 | 165 | return arrow::schema({MakeField("amount", arrow::decimal128(precision, 4), 0)}); |
161 | 166 | } |
162 | 167 |
|
| 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 | + |
163 | 173 | std::shared_ptr<arrow::Schema> DoubleSchema() { |
164 | 174 | return arrow::schema({MakeField("value", arrow::float64(), 0)}); |
165 | 175 | } |
@@ -265,14 +275,38 @@ Result<std::shared_ptr<arrow::Array>> MakeDictionaryColumn( |
265 | 275 | return array; |
266 | 276 | } |
267 | 277 |
|
268 | | -// STRING values: binary-like, so arrow can write the indices directly. |
| 278 | +// STRING values: binary-like, so arrow can write the indices directly. Every batch is built over |
| 279 | +// the same alphabet, the shape a single input file produces. |
269 | 280 | Result<std::shared_ptr<arrow::Array>> MakeDictionaryStringColumn(int64_t num_rows, int64_t offset, |
270 | 281 | int64_t cardinality) { |
271 | 282 | PAIMON_ASSIGN_OR_RAISE(std::shared_ptr<arrow::Array> values, |
272 | 283 | MakeStringColumn(cardinality, /*offset=*/0, cardinality)); |
273 | 284 | return MakeDictionaryColumn(values, num_rows, offset); |
274 | 285 | } |
275 | 286 |
|
| 287 | +// The same, except every batch carries its own dictionary - the shape a compaction rewrite |
| 288 | +// produces, where each input file supplies one. A Parquet column chunk holds a single dictionary, |
| 289 | +// so arrow keeps the first and falls back to plain encoding for the rest of the row group |
| 290 | +// (column_writer.cc, `dictionary->Equals(*preserved_dictionary_)`). |
| 291 | +// |
| 292 | +// The decoded column is byte for byte what MakeDictionaryStringColumn produces: same values, same |
| 293 | +// order, same widths, same cardinality. Only the dictionary object differs, so the delta between |
| 294 | +// the two benchmarks is the cost of the fallback and nothing else. Generating a fresh alphabet per |
| 295 | +// batch instead would change the data as well - different strings, different widths, a different |
| 296 | +// global cardinality - and the comparison would measure all of that at once. |
| 297 | +Result<std::shared_ptr<arrow::Array>> MakeChangingDictionaryStringColumn(int64_t num_rows, |
| 298 | + int64_t offset, |
| 299 | + int64_t cardinality) { |
| 300 | + // The alphabet is rotated by one position per batch and the indices are shifted the other way, |
| 301 | + // which cancels: index `(offset + cardinality - rotation + i) % cardinality` into an alphabet |
| 302 | + // whose entry `j` is `value_<(j + rotation) % cardinality>` is `value_<(offset + i) % |
| 303 | + // cardinality>` either way. `+ cardinality` keeps the shifted offset non-negative. |
| 304 | + const int64_t rotation = num_rows > 0 ? (offset / num_rows) % cardinality : 0; |
| 305 | + PAIMON_ASSIGN_OR_RAISE(std::shared_ptr<arrow::Array> values, |
| 306 | + MakeStringColumn(cardinality, rotation, cardinality)); |
| 307 | + return MakeDictionaryColumn(values, num_rows, offset + cardinality - rotation); |
| 308 | +} |
| 309 | + |
276 | 310 | // INT32 values: is_base_binary_like excludes them, so arrow densifies before writing. The |
277 | 311 | // dictionary holds the same `i * 7` values MakeInt32Column emits inline, so the two are directly |
278 | 312 | // comparable. |
@@ -422,6 +456,18 @@ BatchFactory SingleColumnBatch(const ColumnFactory& make_column) { |
422 | 456 | }; |
423 | 457 | } |
424 | 458 |
|
| 459 | +// The same, but the batch is typed by the column rather than by the schema, so it can carry an |
| 460 | +// encoding the schema does not declare. That is the shape a compaction rewrite produces: the file |
| 461 | +// writer is built from the table's logical schema while the reader forwards whatever encoding the |
| 462 | +// input file already had, leaving the writer to recover it from the batch. |
| 463 | +BatchFactory SingleEncodedColumnBatch(const ColumnFactory& make_column) { |
| 464 | + return [make_column](const std::shared_ptr<arrow::Schema>& schema, int64_t offset, |
| 465 | + int64_t rows) -> Result<std::shared_ptr<arrow::Array>> { |
| 466 | + PAIMON_ASSIGN_OR_RAISE(std::shared_ptr<arrow::Array> column, make_column(rows, offset)); |
| 467 | + return MakeStructArray({schema->field(0)->WithType(column->type())}, {column}); |
| 468 | + }; |
| 469 | +} |
| 470 | + |
425 | 471 | Result<std::shared_ptr<arrow::Array>> MakeNullableFlatBatch( |
426 | 472 | const std::shared_ptr<arrow::Schema>& schema, int64_t offset, int64_t rows, int64_t null_pct) { |
427 | 473 | PAIMON_ASSIGN_OR_RAISE(std::shared_ptr<arrow::Array> ids, MakeInt64Column(rows, offset)); |
@@ -766,6 +812,27 @@ const ReadFixture& DoubleFixture() { |
766 | 812 | return ColumnFixture("double", DoubleSchema(), &MakeDoubleColumn); |
767 | 813 | } |
768 | 814 |
|
| 815 | +// A one-column STRING file at a chosen cardinality, written with a reduced dictionary page limit |
| 816 | +// so both regimes the passthrough gate distinguishes are reachable within a 100K-row file. Under |
| 817 | +// the limit every data page stays dictionary-encoded and the gate lets the column through; over |
| 818 | +// it the writer emits the dictionary page it has and encodes the rest as plain, which is the case |
| 819 | +// the gate has to decline. At kDictionaryPageSizeBytes a row group holds roughly 4K distinct |
| 820 | +// `value_<n>` entries before overflowing, so cardinality alone picks the regime. |
| 821 | +const ReadFixture& StringFixture(int64_t cardinality) { |
| 822 | + const std::string key = fmt::format("string_{}", cardinality); |
| 823 | + return GetFixture(key, [key, cardinality] { |
| 824 | + std::map<std::string, std::string> options; |
| 825 | + options[paimon::parquet::PARQUET_DICTIONARY_PAGE_SIZE] = |
| 826 | + std::to_string(kDictionaryPageSizeBytes); |
| 827 | + return std::make_unique<ReadFixture>( |
| 828 | + key + ".parquet", StringSchema(), |
| 829 | + SingleColumnBatch([cardinality](int64_t rows, int64_t offset) { |
| 830 | + return MakeStringColumn(rows, offset, cardinality); |
| 831 | + }), |
| 832 | + options); |
| 833 | + }); |
| 834 | +} |
| 835 | + |
769 | 836 | // The same data with dictionary encoding off, giving the read side a plain baseline. |
770 | 837 | const ReadFixture& PlainFlatFixture() { |
771 | 838 | return GetFixture("flat_plain", [] { |
@@ -930,6 +997,43 @@ void BM_ParquetWrite_DictionaryString(::benchmark::State& state) { |
930 | 997 | kRowsPerBatch, /*options=*/{}); |
931 | 998 | } |
932 | 999 |
|
| 1000 | +// arg: dictionary cardinality. The shape the append compaction rewrite actually produces, and the |
| 1001 | +// one BM_ParquetWrite_DictionaryString does not cover: there the schema itself is a DictionaryType, |
| 1002 | +// here the writer is built from a plain STRING schema - as a rewrite builds it, from the table's |
| 1003 | +// logical schema - and the batch arrives dictionary-encoded anyway. The delta against |
| 1004 | +// BM_ParquetWrite_String at the same cardinality is what the passthrough buys on the write side, |
| 1005 | +// including the per-batch schema fixup that recovers the encoding from the batch layout. |
| 1006 | +// |
| 1007 | +// One alphabet for the whole file, so the writer keeps writing indices: the favourable half of the |
| 1008 | +// passthrough. BM_ParquetWrite_ChangingDictionaryStringIntoStringSchema is the other half, and a |
| 1009 | +// rewrite of several input files lands between the two. |
| 1010 | +void BM_ParquetWrite_DictionaryStringIntoStringSchema(::benchmark::State& state) { |
| 1011 | + const int64_t cardinality = state.range(0); |
| 1012 | + RunWriteBenchmark(state, StringSchema(), |
| 1013 | + SingleEncodedColumnBatch([cardinality](int64_t rows, int64_t offset) { |
| 1014 | + return MakeDictionaryStringColumn(rows, offset, cardinality); |
| 1015 | + }), |
| 1016 | + kRowsPerBatch, /*options=*/{}, kDefaultCompression); |
| 1017 | +} |
| 1018 | + |
| 1019 | +// arg: dictionary cardinality. The same write, except every batch brings its own dictionary, as |
| 1020 | +// the input files of a compaction do, which is the cost of forwarding an encoding the writer |
| 1021 | +// cannot reuse. All three of BM_ParquetWrite_String, |
| 1022 | +// BM_ParquetWrite_DictionaryStringIntoStringSchema and this one write the same logical column at |
| 1023 | +// the same cardinality, so the triple reads directly: the middle one against the first is what the |
| 1024 | +// passthrough buys, this one against the middle is what the fallback costs in time, and this one |
| 1025 | +// against the first is the output size a rewrite that materialized and rebuilt would have produced. |
| 1026 | +// Row groups here are cut the way they are in production - by size and by the writer's memory |
| 1027 | +// limit, not at batch boundaries. |
| 1028 | +void BM_ParquetWrite_ChangingDictionaryStringIntoStringSchema(::benchmark::State& state) { |
| 1029 | + const int64_t cardinality = state.range(0); |
| 1030 | + RunWriteBenchmark(state, StringSchema(), |
| 1031 | + SingleEncodedColumnBatch([cardinality](int64_t rows, int64_t offset) { |
| 1032 | + return MakeChangingDictionaryStringColumn(rows, offset, cardinality); |
| 1033 | + }), |
| 1034 | + kRowsPerBatch, /*options=*/{}, kDefaultCompression); |
| 1035 | +} |
| 1036 | + |
933 | 1037 | // The same axis on an INTEGER dictionary, which arrow cannot direct-write - is_base_binary_like |
934 | 1038 | // excludes int32, so it densifies first. Its baseline is BM_ParquetWrite_FlatInt32 at the same |
935 | 1039 | // cardinality, not the String case: only the flat INT32 control holds value, width and encoding |
@@ -1197,6 +1301,22 @@ void BM_ParquetRead_Encoding(::benchmark::State& state, bool enable_dictionary) |
1197 | 1301 | /*selection_bitmap=*/std::nullopt, /*options=*/{}, kReadBatchSize); |
1198 | 1302 | } |
1199 | 1303 |
|
| 1304 | +// args: string cardinality, and whether the parquet dictionary passthrough is on. With it on, a |
| 1305 | +// column the file stores dictionary-encoded end to end is handed back as a DictionaryArray instead |
| 1306 | +// of one materialized value per row, so the pair at a fixed cardinality is what the read half of |
| 1307 | +// the compaction rewrite saves. At a cardinality high enough that the writer fell back to plain, |
| 1308 | +// the gate declines and the two runs measure the same work - a divergence there means the gate |
| 1309 | +// stopped looking at the data page encodings and started trusting the dictionary page. |
| 1310 | +void BM_ParquetRead_DictionaryPassthrough(::benchmark::State& state) { |
| 1311 | + const int64_t cardinality = state.range(0); |
| 1312 | + const bool enable_passthrough = state.range(1) != 0; |
| 1313 | + std::map<std::string, std::string> options; |
| 1314 | + options[paimon::parquet::PARQUET_READ_ENABLE_DICTIONARY_PASSTHROUGH] = |
| 1315 | + enable_passthrough ? "true" : "false"; |
| 1316 | + RunReadBenchmark(state, StringFixture(cardinality), StringSchema(), /*predicate=*/nullptr, |
| 1317 | + /*selection_bitmap=*/std::nullopt, options, kReadBatchSize); |
| 1318 | +} |
| 1319 | + |
1200 | 1320 | // arg: decimal precision, the read side of BM_ParquetWrite_Decimal. Precision picks the physical |
1201 | 1321 | // type - INT32, INT64 or FIXED_LEN_BYTE_ARRAY, since ParquetWriterBuilder enables |
1202 | 1322 | // store_decimal_as_integer - and the three take different paths back to Decimal128Array. |
@@ -1272,6 +1392,24 @@ BENCHMARK(BM_ParquetWrite_DictionaryString) |
1272 | 1392 | ->Arg(10000) |
1273 | 1393 | ->Unit(benchmark::kMillisecond) |
1274 | 1394 | ->UseRealTime(); |
| 1395 | +// Same cardinality axis as BM_ParquetWrite_String and BM_ParquetWrite_StringNoDictionary, which |
| 1396 | +// are its baselines: the three have to line up point for point or the low/medium/high comparison |
| 1397 | +// cannot be made. |
| 1398 | +BENCHMARK(BM_ParquetWrite_DictionaryStringIntoStringSchema) |
| 1399 | + ->ArgName("cardinality") |
| 1400 | + ->Arg(10) |
| 1401 | + ->Arg(1000) |
| 1402 | + ->Arg(kRowsPerFile) |
| 1403 | + ->Unit(benchmark::kMillisecond) |
| 1404 | + ->UseRealTime(); |
| 1405 | +// Same axis again, so the three points can be read against the run above them. |
| 1406 | +BENCHMARK(BM_ParquetWrite_ChangingDictionaryStringIntoStringSchema) |
| 1407 | + ->ArgName("cardinality") |
| 1408 | + ->Arg(10) |
| 1409 | + ->Arg(1000) |
| 1410 | + ->Arg(kRowsPerFile) |
| 1411 | + ->Unit(benchmark::kMillisecond) |
| 1412 | + ->UseRealTime(); |
1275 | 1413 | BENCHMARK(BM_ParquetWrite_DictionaryInt32) |
1276 | 1414 | ->ArgName("cardinality") |
1277 | 1415 | ->Arg(10) |
@@ -1402,6 +1540,19 @@ BENCHMARK_CAPTURE(BM_ParquetRead_Encoding, dictionary, true) |
1402 | 1540 | BENCHMARK_CAPTURE(BM_ParquetRead_Encoding, plain, false) |
1403 | 1541 | ->Unit(benchmark::kMillisecond) |
1404 | 1542 | ->UseRealTime(); |
| 1543 | +// The same cardinality axis the write cases use, so the read and write halves of a rewrite can be |
| 1544 | +// added up at each point. At kRowsPerFile every value is distinct, which overflows |
| 1545 | +// kDictionaryPageSizeBytes and is the point where the gate has to decline. |
| 1546 | +BENCHMARK(BM_ParquetRead_DictionaryPassthrough) |
| 1547 | + ->ArgNames({"cardinality", "passthrough"}) |
| 1548 | + ->Args({10, 0}) |
| 1549 | + ->Args({10, 1}) |
| 1550 | + ->Args({1000, 0}) |
| 1551 | + ->Args({1000, 1}) |
| 1552 | + ->Args({kRowsPerFile, 0}) |
| 1553 | + ->Args({kRowsPerFile, 1}) |
| 1554 | + ->Unit(benchmark::kMillisecond) |
| 1555 | + ->UseRealTime(); |
1405 | 1556 | BENCHMARK(BM_ParquetRead_Decimal) |
1406 | 1557 | ->ArgName("precision") |
1407 | 1558 | ->Arg(9) |
|
0 commit comments