|
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 | } |
@@ -422,6 +432,18 @@ BatchFactory SingleColumnBatch(const ColumnFactory& make_column) { |
422 | 432 | }; |
423 | 433 | } |
424 | 434 |
|
| 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 | + |
425 | 447 | Result<std::shared_ptr<arrow::Array>> MakeNullableFlatBatch( |
426 | 448 | const std::shared_ptr<arrow::Schema>& schema, int64_t offset, int64_t rows, int64_t null_pct) { |
427 | 449 | PAIMON_ASSIGN_OR_RAISE(std::shared_ptr<arrow::Array> ids, MakeInt64Column(rows, offset)); |
@@ -766,6 +788,27 @@ const ReadFixture& DoubleFixture() { |
766 | 788 | return ColumnFixture("double", DoubleSchema(), &MakeDoubleColumn); |
767 | 789 | } |
768 | 790 |
|
| 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 | + |
769 | 812 | // The same data with dictionary encoding off, giving the read side a plain baseline. |
770 | 813 | const ReadFixture& PlainFlatFixture() { |
771 | 814 | return GetFixture("flat_plain", [] { |
@@ -930,6 +973,21 @@ void BM_ParquetWrite_DictionaryString(::benchmark::State& state) { |
930 | 973 | kRowsPerBatch, /*options=*/{}); |
931 | 974 | } |
932 | 975 |
|
| 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 | + |
933 | 991 | // The same axis on an INTEGER dictionary, which arrow cannot direct-write - is_base_binary_like |
934 | 992 | // excludes int32, so it densifies first. Its baseline is BM_ParquetWrite_FlatInt32 at the same |
935 | 993 | // 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) |
1197 | 1255 | /*selection_bitmap=*/std::nullopt, /*options=*/{}, kReadBatchSize); |
1198 | 1256 | } |
1199 | 1257 |
|
| 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 | + |
1200 | 1274 | // arg: decimal precision, the read side of BM_ParquetWrite_Decimal. Precision picks the physical |
1201 | 1275 | // type - INT32, INT64 or FIXED_LEN_BYTE_ARRAY, since ParquetWriterBuilder enables |
1202 | 1276 | // store_decimal_as_integer - and the three take different paths back to Decimal128Array. |
@@ -1272,6 +1346,16 @@ BENCHMARK(BM_ParquetWrite_DictionaryString) |
1272 | 1346 | ->Arg(10000) |
1273 | 1347 | ->Unit(benchmark::kMillisecond) |
1274 | 1348 | ->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(); |
1275 | 1359 | BENCHMARK(BM_ParquetWrite_DictionaryInt32) |
1276 | 1360 | ->ArgName("cardinality") |
1277 | 1361 | ->Arg(10) |
@@ -1402,6 +1486,19 @@ BENCHMARK_CAPTURE(BM_ParquetRead_Encoding, dictionary, true) |
1402 | 1486 | BENCHMARK_CAPTURE(BM_ParquetRead_Encoding, plain, false) |
1403 | 1487 | ->Unit(benchmark::kMillisecond) |
1404 | 1488 | ->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(); |
1405 | 1502 | BENCHMARK(BM_ParquetRead_Decimal) |
1406 | 1503 | ->ArgName("precision") |
1407 | 1504 | ->Arg(9) |
|
0 commit comments