Skip to content

Commit b4fa97f

Browse files
authored
Merge branch 'main' into codex/vector-pk-tables
2 parents a332131 + 404e5d9 commit b4fa97f

42 files changed

Lines changed: 3997 additions & 127 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎benchmark/parquet_format_benchmark.cpp‎

Lines changed: 152 additions & 1 deletion
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
}
@@ -265,14 +275,38 @@ Result<std::shared_ptr<arrow::Array>> MakeDictionaryColumn(
265275
return array;
266276
}
267277

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.
269280
Result<std::shared_ptr<arrow::Array>> MakeDictionaryStringColumn(int64_t num_rows, int64_t offset,
270281
int64_t cardinality) {
271282
PAIMON_ASSIGN_OR_RAISE(std::shared_ptr<arrow::Array> values,
272283
MakeStringColumn(cardinality, /*offset=*/0, cardinality));
273284
return MakeDictionaryColumn(values, num_rows, offset);
274285
}
275286

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+
276310
// INT32 values: is_base_binary_like excludes them, so arrow densifies before writing. The
277311
// dictionary holds the same `i * 7` values MakeInt32Column emits inline, so the two are directly
278312
// comparable.
@@ -422,6 +456,18 @@ BatchFactory SingleColumnBatch(const ColumnFactory& make_column) {
422456
};
423457
}
424458

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+
425471
Result<std::shared_ptr<arrow::Array>> MakeNullableFlatBatch(
426472
const std::shared_ptr<arrow::Schema>& schema, int64_t offset, int64_t rows, int64_t null_pct) {
427473
PAIMON_ASSIGN_OR_RAISE(std::shared_ptr<arrow::Array> ids, MakeInt64Column(rows, offset));
@@ -766,6 +812,27 @@ const ReadFixture& DoubleFixture() {
766812
return ColumnFixture("double", DoubleSchema(), &MakeDoubleColumn);
767813
}
768814

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+
769836
// The same data with dictionary encoding off, giving the read side a plain baseline.
770837
const ReadFixture& PlainFlatFixture() {
771838
return GetFixture("flat_plain", [] {
@@ -930,6 +997,43 @@ void BM_ParquetWrite_DictionaryString(::benchmark::State& state) {
930997
kRowsPerBatch, /*options=*/{});
931998
}
932999

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+
9331037
// The same axis on an INTEGER dictionary, which arrow cannot direct-write - is_base_binary_like
9341038
// excludes int32, so it densifies first. Its baseline is BM_ParquetWrite_FlatInt32 at the same
9351039
// 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)
11971301
/*selection_bitmap=*/std::nullopt, /*options=*/{}, kReadBatchSize);
11981302
}
11991303

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+
12001320
// arg: decimal precision, the read side of BM_ParquetWrite_Decimal. Precision picks the physical
12011321
// type - INT32, INT64 or FIXED_LEN_BYTE_ARRAY, since ParquetWriterBuilder enables
12021322
// store_decimal_as_integer - and the three take different paths back to Decimal128Array.
@@ -1272,6 +1392,24 @@ BENCHMARK(BM_ParquetWrite_DictionaryString)
12721392
->Arg(10000)
12731393
->Unit(benchmark::kMillisecond)
12741394
->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();
12751413
BENCHMARK(BM_ParquetWrite_DictionaryInt32)
12761414
->ArgName("cardinality")
12771415
->Arg(10)
@@ -1402,6 +1540,19 @@ BENCHMARK_CAPTURE(BM_ParquetRead_Encoding, dictionary, true)
14021540
BENCHMARK_CAPTURE(BM_ParquetRead_Encoding, plain, false)
14031541
->Unit(benchmark::kMillisecond)
14041542
->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();
14051556
BENCHMARK(BM_ParquetRead_Decimal)
14061557
->ArgName("precision")
14071558
->Arg(9)

0 commit comments

Comments
 (0)