perf(parquet): forward dictionary encoding through append compaction rewrite - #257
perf(parquet): forward dictionary encoding through append compaction rewrite#257SteNicholas wants to merge 2 commits into
Conversation
42bdc18 to
269f33f
Compare
|
LGTM |
269f33f to
92d8218
Compare
…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.
…STRING Addresses the review on apache#257. The compaction rewrite no longer turns `parquet.read.enable-dictionary-passthrough` on. It is a table option, off by default, and the rewrite only vetoes it - when the output is not Parquet, when `parquet.enable-dictionary` is false, or when a shredding writer is active - logging the reason at DEBUG for a table that did ask for it. Forwarding trades compaction CPU for output size, and a Parquet column chunk carries one dictionary, so a multi-file rewrite can keep the first input file's and write the rest of the row group plain; whether that trade is worth taking depends on the data, so it is left to the table. Restrict the reader to STRING. Parquet stores BINARY in the same BYTE_ARRAY leaf and dictionary-encodes it the same way, but no value accessor here can read a `dictionary(int32, binary)`: ColumnarUtils::GetView() asserts on it and returns an empty view in a release build, and LiteralConverter rejects it. Since the option applies to every read of the table, the gate gets that restriction, not the layout predicate - ArrowUtils::IsDictionaryLayoutRecoverableValueType() still accepts `utf8|binary`, which is what the writer can recover, so the writer keeps the capability for a future producer. Also from the review: use CastingUtils::Cast() instead of arrow::compute::Cast(); hold the Arrow pool adaptor in DataFileIndexWriter as a member declared before the index writers, so it outlives every buffer allocated through it; drop the batch schema cache in ParquetFormatWriter::ResolveBatchSchema(); read the two Parquet option names in core as local constants rather than through the format layer's headers. The changing-dictionary benchmark now rotates one alphabet and shifts the indices back by the same amount, so it writes the same logical column as its pair and the delta is the fallback rather than a difference in the data.
92d8218 to
3feaaa1
Compare
…STRING Addresses the review on apache#257. The compaction rewrite no longer turns `parquet.read.enable-dictionary-passthrough` on. It is a table option, off by default, and the rewrite only vetoes it - when the output is not Parquet, when `parquet.enable-dictionary` is false, or when a shredding writer is active - logging the reason at DEBUG for a table that did ask for it. Forwarding trades compaction CPU for output size, and a Parquet column chunk carries one dictionary, so a multi-file rewrite can keep the first input file's and write the rest of the row group plain; whether that trade is worth taking depends on the data, so it is left to the table. Restrict the reader to STRING. Parquet stores BINARY in the same BYTE_ARRAY leaf and dictionary-encodes it the same way, but no value accessor here can read a `dictionary(int32, binary)`: ColumnarUtils::GetView() asserts on it and returns an empty view in a release build, and LiteralConverter rejects it. Since the option applies to every read of the table, the gate gets that restriction, not the layout predicate - ArrowUtils::IsDictionaryLayoutRecoverableValueType() still accepts `utf8|binary`, which is what the writer can recover, so the writer keeps the capability for a future producer. Also from the review: use CastingUtils::Cast() instead of arrow::compute::Cast(); hold the Arrow pool adaptor in DataFileIndexWriter as a member declared before the index writers, so it outlives every buffer allocated through it; drop the batch schema cache in ParquetFormatWriter::ResolveBatchSchema(); read the two Parquet option names in core as local constants rather than through the format layer's headers. The changing-dictionary benchmark now rotates one alphabet and shifts the indices back by the same amount, so it writes the same logical column as its pair and the delta is the fallback rather than a difference in the data.
5a245dd to
ba3f2cd
Compare
…STRING Addresses the review on apache#257. The compaction rewrite no longer turns `parquet.read.enable-dictionary-passthrough` on. It is a table option, off by default, and the rewrite only vetoes it - when the output is not Parquet, when `parquet.enable-dictionary` is false, or when a shredding writer is active - logging the reason at DEBUG for a table that did ask for it. Forwarding trades compaction CPU for output size, and a Parquet column chunk carries one dictionary, so a multi-file rewrite can keep the first input file's and write the rest of the row group plain; whether that trade is worth taking depends on the data, so it is left to the table. Restrict the reader to STRING. Parquet stores BINARY in the same BYTE_ARRAY leaf and dictionary-encodes it the same way, but no value accessor here can read a `dictionary(int32, binary)`: ColumnarUtils::GetView() asserts on it and returns an empty view in a release build, and LiteralConverter rejects it. Since the option applies to every read of the table, the gate gets that restriction, not the layout predicate - ArrowUtils::IsDictionaryLayoutRecoverableValueType() still accepts `utf8|binary`, which is what the writer can recover, so the writer keeps the capability for a future producer. Also from the review: use CastingUtils::Cast() instead of arrow::compute::Cast(); hold the Arrow pool adaptor in DataFileIndexWriter as a member declared before the index writers, so it outlives every buffer allocated through it; drop the batch schema cache in ParquetFormatWriter::ResolveBatchSchema(); read the two Parquet option names in core as local constants rather than through the format layer's headers. The changing-dictionary benchmark now rotates one alphabet and shifts the indices back by the same amount, so it writes the same logical column as its pair and the delta is the fallback rather than a difference in the data.
ba3f2cd to
dc2c759
Compare
| // Whether `type` is a dictionary this can carry across the C data interface unchanged. The index | ||
| // width is part of the test because nothing in a layout reveals it; see | ||
| // ArrowUtils::IsDictionaryLayoutRecoverableValueType(). | ||
| bool IsResolvableDictionary(const arrow::DataType& type) { |
There was a problem hiding this comment.
Some format(AliORC), will lazy decoding exposes dictionary-encoded STRING columns as dictionary<int32, utf8>. This type is considered resolvable by IsResolvableDictionary(), so FlattenUnresolvableDictionaries() keeps it encoded during append-only compaction.
However, the non-Parquet veto only disables parquet.read.enable-dictionary-passthrough; it does not disable aliorc.read.enable-lazy-decoding. As a result, an AliORC dictionary batch can still reach OrcFormatWriter::AddBatch(), where it is imported using the logical utf8 schema. The actual ArrowArray child is dictionary-encoded, so the import will fail. Even if the dictionary type were recovered during import, OrcAdapter::WriteBatch() does not support arrow::Type::DICTIONARY.
| // batch whose dictionaries the schema does not declare, and it honours the contract by running | ||
| // FlattenUnresolvableDictionaries() first. Closing the hole instead of narrowing it needs the real | ||
| // `ArrowSchema` to reach the writer, which `FormatWriter::AddBatch(ArrowArray*)` drops. | ||
| Result<std::shared_ptr<arrow::DataType>> ArrowUtils::ResolveParquetDictionaryStructType( |
There was a problem hiding this comment.
See one function about parquet in framework is a little bit weird
Purpose
Linked issue: close #230
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. This forwards the encoding instead.
Reader.
parquet.read.enable-dictionary-passthroughmakesParquetFileBatchReaderrequestset_read_dictionaryfor non-nestedSTRINGcolumns 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. The file schema keeps reporting the logical type; only the emitted batches carry the dictionary.BINARYis deliberately not forwarded, although Parquet stores it in the sameBYTE_ARRAYleaf and dictionary-encodes it the same way: no value accessor here can read adictionary(int32, binary).ColumnarUtils::GetView()asserts on a dictionary whose values are neitherSTRINGnorLARGE_STRINGand returns an emptystring_viewin a release build, andLiteralConverterrejects it outright. Every consumer understands aSTRINGdictionary because the ORC reader has always produced one under lazy decoding; none was ever handed aBINARYone. Widening this needs those consumers first.Writer.
ParquetFormatWriterrecovers 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 onlydictionary(int32, utf8|binary)is recoverable —ArrowUtils::IsDictionaryLayoutRecoverableValueType(). That is the writer's capability and it matches Velox'sVARCHAR/VARBINARY; theSTRING-only restriction above belongs to the reader, not to the format, and is applied at the gate so the two do not get conflated.CompactRewritedecodes anything else per column, while the type is still known: the ORC reader'sdictionary(int64, large_utf8)under lazy decoding, dictionaries below the top level, non-binary value types. The rest stay encoded, so one awkward column does not cost the others their encoding. A dictionary holding nulls in its values is flattened at the writer — the one shapeparquet::arrowrejects outright.Gating. The option is off by default and the rewrite never turns it on; enabling it is the table's decision, taken after measuring.
GetDictionaryPassthroughVetoReason()only vetoes it — when the output is not Parquet, whenparquet.enable-dictionaryis false, or when a shredding plan is active — and logs the reason at DEBUG for a table that did ask for the passthrough. Setting the option therefore never makes a rewrite fail; at worst it has no effect. A file index configured on a forwarded column materializes that column alone.Known trade-off. 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 that row group. Output row groups are cut by
parquet.block.sizeand by the writer's memory limit, which are not aligned to input file boundaries. The rewritten data is unchanged, but the output file can be larger than one written from materialized values, and that column then loses eligibility for the next compaction round. This is why the option is opt-in rather than automatic, and it is documented incompaction.rst.Design follows Velox's
perf(parquet): Dictionary passthrough and selective flattening in Parquet writer(facebookincubator/velox#17986): per-column selective flatten, only VARCHAR/VARBINARY passed through, dictionaries with null values flattened, import schema reconciled per batch. It differs in two places. Velox still holds a typedVectorinside its writer, whereasFormatWriter::AddBatch(ArrowArray*)here receives an untyped array, so the flatten has to run one layer up, before the type is dropped. And Velox's reader can hand out a dictionary forVARBINARYbecause its consumers are type-erased overStringView, while the accessors here switch on the Arrow type id — hence the reader-sideSTRINGrestriction.Tests
Unit:
ArrowUtilsTest.TestResolveParquetDictionaryStructType— layout-derived resolution; rejects non-binary-like value types,large_utf8, and dictionaries below the top level; preserves a caller-declared dictionary type.ArrowUtilsTest.TestFlattenUnresolvableDictionaries— selective flatten:dictionary(int64, large_utf8)anddictionary(int64, utf8)decoded while theint32neighbour stays encoded; nested dictionary decoded; unchanged batch returned by identity; sliced batch keeps its offset.ReaderUtilsTest.TestApplyBitmapToReadBatchKeepsDictionaryEncoding— a deletion vector filters batches by slice +arrow::Concatenate; pins that the encoding survives it.DataFileIndexWriterTest.TestDictionaryEncodedIndexedColumnRoundTrip— bitmap index built from forwarded columns across two batches with different dictionaries, which also covers the writer-held Arrow pool.ParquetFileBatchReaderTest.TestDictionaryPassthrough— on / explicitly off / option absent / file without dictionary pages.ParquetFileBatchReaderTest.TestDictionaryPassthroughSkipsBinaryColumn—f8(STRING) is forwarded andf9(BINARY) is not, from the same file with the same physical type and pages.ParquetFileBatchReaderTest.TestDictionaryPassthroughSkipsFallbackToPlain— a column that falls back to plain inside one chunk is declined.ParquetFileBatchReaderTest.TestDictionaryPassthroughRequiresEveryRowGroup— first row group fully dictionary-encoded, second falls back; the whole column is declined.ParquetFormatWriterTest.TestWriteDictionaryEncodedColumn/ChangingAcrossBatches/WithNullsInDictionary/WithNullRows/WithDuplicateValues/EmptyBatch/OfBinaryColumn/OfUnsupportedTypeIsRejected.ParquetFormatWriterTest.TestGetEstimateLengthWithDictionaryBatches—GetEstimateLength()andReachTargetSize()still drive file rolling when batches arrive encoded.Integration:
AppendCompactionInteTest.TestAppendTableCompactionDictionaryPassthrough(Parquet + ORC) — the table opts in; asserts the input read types (sasdictionary(int32, utf8)on Parquet,idandbmaterialized,sasdictionary(int64, large_utf8)on ORC), a full read of those still-encoded files through the whole read path, the rewrite, that the output column has fallen back to plain, and a predicate read through the bitmap index.AppendCompactionInteTest.TestAppendTableCompactionDictionaryPassthroughDefaultOff— a table that says nothing about the option lands on the same rows and on a better encoded output, which is why it is not on by default.AppendCompactionInteTest.TestAppendTableCompactionDictionaryPassthroughVetoedByShredding— the option is set and vetoed; the input arrives encoded and the compacted column comes back dictionary-encoded end to end, which only a rewrite from materialized values produces.Benchmarks (
benchmark/parquet_format_benchmark.cpp):BM_ParquetWrite_String,BM_ParquetWrite_DictionaryStringIntoStringSchemaandBM_ParquetWrite_ChangingDictionaryStringIntoStringSchemawrite the same logical column at the same cardinality — the third rotates the alphabet and shifts the indices back by the same amount, so every batch brings a different dictionary over identical data. The second against the first is what forwarding buys; the third against the second is what the fallback costs in time; the third against the first is the output size a rewrite that materialized and rebuilt would have produced.parquet_format_benchmark_test.cpppins that premise. These are format-writer microbenchmarks and do not stand in for compaction time, CPU and peak memory on a real table — that measurement is what a user should make before enabling the option, which is why it ships off by default.API and Format
ArrowUtils::IsDictionaryLayoutRecoverableValueType()andArrowUtils::FlattenUnresolvableDictionaries().parquet.read.enable-dictionary-passthrough, defaultfalse. It is a read option, so it applies to every read of the table and not only to the compaction rewrite; an eligible column then reaches the consumer as an ArrowDictionaryArray.AppendOnlyFileStoreWritereads the two Parquet option names as local constants instead of including the format layer's headers, so core does not depend on symbols a replacement Parquet plugin need not export.Documentation
docs/source/user_guide/compaction.rst— a "Dictionary Passthrough" section: what it does, that it is opt-in and why, the read-option caveat, per-file eligibility and the vetoes, and a "Trade-off" subsection on the single dictionary per column chunk.docs/source/examples/benchmark.rst— the flat / shared-dictionary / changing-dictionary trio and what each pair measures.