diff --git a/.gitattributes b/.gitattributes index a533bb4c5e..a4810ececb 100644 --- a/.gitattributes +++ b/.gitattributes @@ -19,3 +19,8 @@ * text eol=lf *.png binary core.autocrlf=false + +# FSST test fixtures are byte streams, not text: line-ending normalization would corrupt them. +parquet-column/src/test/resources/fsst/*.table binary +parquet-column/src/test/resources/fsst/*.codes binary +parquet-column/src/test/resources/fsst/interop/*.pages binary diff --git a/LICENSE b/LICENSE index 2c96440ccc..407b288bcb 100644 --- a/LICENSE +++ b/LICENSE @@ -206,3 +206,39 @@ Copyright: 2012-2014 Twitter Home page: https://github.com/twitter/elephant-bird License: http://www.apache.org/licenses/LICENSE-2.0 + +-------------------------------------------------------------------------------- + +This project includes code from the FSST project. parquet-column's FSST symbol +table trainer and compressor are a Java port of FSST's reference +implementation. + +* parquet-column's org.apache.parquet.column.values.symboltable.fsst package + is derived from FSST's libfsst.hpp and libfsst.cpp, at commit + 89f49c580c6388acf3b6ed2a49e1bfde6c05e616. + +Copyright: 2018-2020 CWI, TU Munich, FSU Jena +Home page: https://github.com/cwida/fsst +License: MIT License + +MIT License + +Copyright (c) 2018-2020, CWI, TU Munich, FSU Jena + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/parquet-column/pom.xml b/parquet-column/pom.xml index 8ca38ba4da..39e523e3b4 100644 --- a/parquet-column/pom.xml +++ b/parquet-column/pom.xml @@ -112,6 +112,12 @@ ${junit.version} test + + com.github.luben + zstd-jni + ${zstd-jni.version} + test + diff --git a/parquet-column/src/main/java/org/apache/parquet/column/Encoding.java b/parquet-column/src/main/java/org/apache/parquet/column/Encoding.java index 874c99fded..d2a4aab0c3 100644 --- a/parquet-column/src/main/java/org/apache/parquet/column/Encoding.java +++ b/parquet-column/src/main/java/org/apache/parquet/column/Encoding.java @@ -54,6 +54,8 @@ import org.apache.parquet.column.values.plain.PlainValuesReader.LongPlainValuesReader; import org.apache.parquet.column.values.rle.RunLengthBitPackingHybridValuesReader; import org.apache.parquet.column.values.rle.ZeroIntegerValuesReader; +import org.apache.parquet.column.values.symboltable.SymbolTable; +import org.apache.parquet.column.values.symboltable.SymbolTableValuesReader; import org.apache.parquet.io.ParquetDecodingException; /** @@ -253,6 +255,40 @@ public ValuesReader getDictionaryBasedValuesReader( public boolean usesDictionary() { return true; } + }, + + /** + * Values are replaced by codes over a table of the byte sequences that recur in the column, with + * one table per column chunk. The table's own representation decides the width of a code and how a + * byte that no symbol covers is escaped, so this one encoding covers every such representation and + * a reader has to read the table before it can commit to decoding the column. + *

+ * Not ratified: parquet-format issue #531. A writer will not produce this encoding unless it is + * turned on explicitly, and no file written by this library carries it yet, because the format has + * nowhere to put the table. + */ + FSST { + @Override + public ValuesReader getValuesReader(ColumnDescriptor descriptor, ValuesType valuesType) { + if (descriptor.getType() != BINARY) { + throw new ParquetDecodingException("Encoding FSST is only supported for type BINARY"); + } + return new SymbolTableValuesReader(); + } + + @Override + public boolean usesSymbolTable() { + return true; + } + + @Override + public ValuesReader getSymbolTableBasedValuesReader( + ColumnDescriptor descriptor, ValuesType valuesType, SymbolTable symbolTable) { + if (descriptor.getType() != BINARY) { + throw new ParquetDecodingException("Encoding FSST is only supported for type BINARY"); + } + return new SymbolTableValuesReader(() -> symbolTable); + } }; int getMaxLevel(ColumnDescriptor descriptor, ValuesType valuesType) { @@ -321,4 +357,25 @@ public ValuesReader getDictionaryBasedValuesReader( ColumnDescriptor descriptor, ValuesType valuesType, Dictionary dictionary) { throw new UnsupportedOperationException(this.name() + " is not dictionary based"); } + + /** + * @return whether this encoding requires a symbol table + */ + public boolean usesSymbolTable() { + return false; + } + + /** + * To read decoded values that require a symbol table + * + * @param descriptor the column to read + * @param valuesType the type of values + * @param symbolTable the symbol table for the chunk being read + * @return the proper values reader for the given column + * @throws UnsupportedOperationException if the encoding is not symbol table based + */ + public ValuesReader getSymbolTableBasedValuesReader( + ColumnDescriptor descriptor, ValuesType valuesType, SymbolTable symbolTable) { + throw new UnsupportedOperationException(this.name() + " is not symbol table based"); + } } diff --git a/parquet-column/src/main/java/org/apache/parquet/column/ParquetProperties.java b/parquet-column/src/main/java/org/apache/parquet/column/ParquetProperties.java index 8fe45e01ef..6c1b4f2ec4 100644 --- a/parquet-column/src/main/java/org/apache/parquet/column/ParquetProperties.java +++ b/parquet-column/src/main/java/org/apache/parquet/column/ParquetProperties.java @@ -39,6 +39,8 @@ import org.apache.parquet.column.values.factory.ValuesWriterFactory; import org.apache.parquet.column.values.rle.RunLengthBitPackingHybridEncoder; import org.apache.parquet.column.values.rle.RunLengthBitPackingHybridValuesWriter; +import org.apache.parquet.column.values.symboltable.SymbolTablePayload.OffsetEncoding; +import org.apache.parquet.column.values.symboltable.SymbolTableType; import org.apache.parquet.hadoop.metadata.CompressionCodecName; import org.apache.parquet.schema.MessageType; @@ -51,6 +53,8 @@ public class ParquetProperties { public static final int DEFAULT_DICTIONARY_PAGE_SIZE = DEFAULT_PAGE_SIZE; public static final boolean DEFAULT_IS_DICTIONARY_ENABLED = true; public static final boolean DEFAULT_IS_BYTE_STREAM_SPLIT_ENABLED = false; + public static final boolean DEFAULT_IS_FSST_ENABLED = false; + public static final OffsetEncoding DEFAULT_SYMBOL_TABLE_OFFSET_ENCODING = OffsetEncoding.DELTA_BINARY_PACKED; public static final WriterVersion DEFAULT_WRITER_VERSION = WriterVersion.PARQUET_1_0; public static final boolean DEFAULT_ESTIMATE_ROW_COUNT_FOR_PAGE_SIZE_CHECK = true; public static final int DEFAULT_MINIMUM_RECORD_COUNT_FOR_CHECK = 100; @@ -133,6 +137,8 @@ public static WriterVersion fromString(String name) { private final int pageRowCountLimit; private final boolean pageWriteChecksumEnabled; private final ColumnProperty byteStreamSplitEnabled; + private final ColumnProperty fsstEnabled; + private final OffsetEncoding symbolTableOffsetEncoding; private final Map extraMetaData; private final ColumnProperty statistics; private final ColumnProperty sizeStatistics; @@ -167,6 +173,8 @@ private ParquetProperties(Builder builder) { this.pageRowCountLimit = builder.pageRowCountLimit; this.pageWriteChecksumEnabled = builder.pageWriteChecksumEnabled; this.byteStreamSplitEnabled = builder.byteStreamSplitEnabled.build(); + this.fsstEnabled = builder.fsstEnabled.build(); + this.symbolTableOffsetEncoding = builder.symbolTableOffsetEncoding; this.extraMetaData = builder.extraMetaData; this.statistics = builder.statistics.build(); this.sizeStatistics = builder.sizeStatistics.build(); @@ -264,6 +272,37 @@ public boolean isByteStreamSplitEnabled(ColumnDescriptor column) { } } + /** + * Whether a symbol table encoding may be used for this column, which is only ever true for BINARY. + * + *

Off by default, and it has to stay off by default until the format carries a symbol table: + * see parquet-format issue #531. + */ + public boolean isFsstEnabled(ColumnDescriptor column) { + return getSymbolTableType(column) != null; + } + + /** + * The symbol table representation to write this column with, or null to not use one. + * + *

One encoding covers every representation, so this is what decides the width of a code and how a + * byte that no symbol covers is escaped, and it is where a choice between representations attaches + * once there is more than one to choose from. Only single-byte codes are implemented. + */ + public SymbolTableType getSymbolTableType(ColumnDescriptor column) { + switch (column.getPrimitiveType().getPrimitiveTypeName()) { + case BINARY: + return fsstEnabled.getValue(column) ? SymbolTableType.FSST_8 : null; + default: + return null; + } + } + + /** How the per-value offsets into a symbol table page's code stream are written. */ + public OffsetEncoding getSymbolTableOffsetEncoding() { + return symbolTableOffsetEncoding; + } + public ByteBufferAllocator getAllocator() { return allocator; } @@ -455,6 +494,8 @@ public static class Builder { private int pageRowCountLimit = DEFAULT_PAGE_ROW_COUNT_LIMIT; private boolean pageWriteChecksumEnabled = DEFAULT_PAGE_WRITE_CHECKSUM_ENABLED; private final ColumnProperty.Builder byteStreamSplitEnabled; + private final ColumnProperty.Builder fsstEnabled; + private OffsetEncoding symbolTableOffsetEncoding = DEFAULT_SYMBOL_TABLE_OFFSET_ENCODING; private Map extraMetaData = new HashMap<>(); private final ColumnProperty.Builder statistics; private final ColumnProperty.Builder sizeStatistics; @@ -468,6 +509,7 @@ private Builder() { DEFAULT_IS_BYTE_STREAM_SPLIT_ENABLED ? ByteStreamSplitMode.FLOATING_POINT : ByteStreamSplitMode.NONE); + fsstEnabled = ColumnProperty.builder().withDefaultValue(DEFAULT_IS_FSST_ENABLED); bloomFilterEnabled = ColumnProperty.builder().withDefaultValue(DEFAULT_BLOOM_FILTER_ENABLED); bloomFilterNDVs = ColumnProperty.builder().withDefaultValue(null); bloomFilterFPPs = ColumnProperty.builder().withDefaultValue(DEFAULT_BLOOM_FILTER_FPP); @@ -504,6 +546,8 @@ private Builder(ParquetProperties toCopy) { this.numBloomFilterCandidates = ColumnProperty.builder(toCopy.numBloomFilterCandidates); this.maxBloomFilterBytes = toCopy.maxBloomFilterBytes; this.byteStreamSplitEnabled = ColumnProperty.builder(toCopy.byteStreamSplitEnabled); + this.fsstEnabled = ColumnProperty.builder(toCopy.fsstEnabled); + this.symbolTableOffsetEncoding = toCopy.symbolTableOffsetEncoding; this.extraMetaData = toCopy.extraMetaData; this.statistics = ColumnProperty.builder(toCopy.statistics); this.statisticsEnabled = toCopy.statisticsEnabled; @@ -573,6 +617,51 @@ public Builder withByteStreamSplitEncoding(String columnPath, boolean enable) { return this; } + /** + * Enable or disable the symbol table encoding for BINARY columns. + * + *

The encoding is not ratified and no file written with it is portable yet, because the format + * has nowhere to put the symbol table a column chunk's pages are compressed against: see + * parquet-format issue #531. Turning it on without supplying a values writer factory that knows + * where to keep the table fails when the first page is written rather than writing a file that + * cannot be read. + * + * @param enable whether the symbol table encoding should be enabled + * @return this builder for method chaining. + */ + public Builder withFsstEncoding(boolean enable) { + this.fsstEnabled.withDefaultValue(enable); + return this; + } + + /** + * Enable or disable the symbol table encoding for the specified column. + * + * @param columnPath the path of the column (dot-string) + * @param enable whether the symbol table encoding should be enabled + * @return this builder for method chaining. + */ + public Builder withFsstEncoding(String columnPath, boolean enable) { + this.fsstEnabled.withValue(columnPath, enable); + return this; + } + + /** + * Set how the per-value offsets into a symbol table page's code stream are written. + * + *

Delta packing by default. Writing them plain costs four bytes a value, which on text that + * the encoding halves is around a fifth of the page, so it gives away much of the ratio the + * encoding is there for; it is worth having only for a reader that wants the offsets addressable + * without decoding them. + * + * @param offsetEncoding how to write the offset section + * @return this builder for method chaining. + */ + public Builder withSymbolTableOffsetEncoding(OffsetEncoding offsetEncoding) { + this.symbolTableOffsetEncoding = Objects.requireNonNull(offsetEncoding, "offsetEncoding cannot be null"); + return this; + } + /** * Enable or disable BYTE_STREAM_SPLIT encoding for FLOAT, DOUBLE, INT32, INT64 and FIXED_LEN_BYTE_ARRAY columns. * diff --git a/parquet-column/src/main/java/org/apache/parquet/column/impl/ColumnReaderBase.java b/parquet-column/src/main/java/org/apache/parquet/column/impl/ColumnReaderBase.java index 2b3e47116c..298f7de831 100644 --- a/parquet-column/src/main/java/org/apache/parquet/column/impl/ColumnReaderBase.java +++ b/parquet-column/src/main/java/org/apache/parquet/column/impl/ColumnReaderBase.java @@ -39,9 +39,11 @@ import org.apache.parquet.column.page.DataPageV2; import org.apache.parquet.column.page.DictionaryPage; import org.apache.parquet.column.page.PageReader; +import org.apache.parquet.column.page.SymbolTablePage; import org.apache.parquet.column.values.RequiresPreviousReader; import org.apache.parquet.column.values.ValuesReader; import org.apache.parquet.column.values.rle.RunLengthBitPackingHybridDecoder; +import org.apache.parquet.column.values.symboltable.SymbolTable; import org.apache.parquet.io.ParquetDecodingException; import org.apache.parquet.io.api.Binary; import org.apache.parquet.io.api.PrimitiveConverter; @@ -138,6 +140,7 @@ public double getDouble() { private final long totalValueCount; private final PageReader pageReader; private final Dictionary dictionary; + private final SymbolTable symbolTable; private IntIterator repetitionLevelColumn; private IntIterator definitionLevelColumn; @@ -457,6 +460,8 @@ void writeValue() { if (dictionary != null && converter.hasDictionarySupport()) { converter.setDictionary(dictionary); } + SymbolTablePage symbolTablePage = pageReader.readSymbolTablePage(); + this.symbolTable = symbolTablePage == null ? null : symbolTablePage.decode(); this.totalValueCount = pageReader.getTotalValueCount(); if (totalValueCount <= 0) { throw new ParquetDecodingException("totalValueCount '" + totalValueCount + "' <= 0"); @@ -703,6 +708,12 @@ private void initDataReader(Encoding dataEncoding, ByteBufferInputStream in, int + " as the dictionary was missing for encoding " + dataEncoding); } this.dataColumn = dataEncoding.getDictionaryBasedValuesReader(path, VALUES, dictionary); + } else if (dataEncoding.usesSymbolTable()) { + if (symbolTable == null) { + throw new ParquetDecodingException("could not read page in col " + path + + " as the symbol table was missing for encoding " + dataEncoding); + } + this.dataColumn = dataEncoding.getSymbolTableBasedValuesReader(path, VALUES, symbolTable); } else { this.dataColumn = dataEncoding.getValuesReader(path, VALUES); } diff --git a/parquet-column/src/main/java/org/apache/parquet/column/impl/ColumnWriterBase.java b/parquet-column/src/main/java/org/apache/parquet/column/impl/ColumnWriterBase.java index 408627404d..6f33de1ed6 100644 --- a/parquet-column/src/main/java/org/apache/parquet/column/impl/ColumnWriterBase.java +++ b/parquet-column/src/main/java/org/apache/parquet/column/impl/ColumnWriterBase.java @@ -25,6 +25,7 @@ import org.apache.parquet.column.ParquetProperties; import org.apache.parquet.column.page.DictionaryPage; import org.apache.parquet.column.page.PageWriter; +import org.apache.parquet.column.page.SymbolTablePage; import org.apache.parquet.column.statistics.SizeStatistics; import org.apache.parquet.column.statistics.Statistics; import org.apache.parquet.column.statistics.geospatial.GeospatialStatistics; @@ -294,6 +295,17 @@ void finalizeColumnChunk() { dataColumn.resetDictionary(); } + final SymbolTablePage symbolTablePage = dataColumn.toSymbolTablePageAndClose(); + if (symbolTablePage != null) { + if (DEBUG) LOG.debug("write symbol table"); + try { + pageWriter.writeSymbolTablePage(symbolTablePage); + } catch (IOException e) { + throw new ParquetEncodingException("could not write symbol table page for " + path, e); + } + dataColumn.resetDictionary(); + } + collector.finalizeColumnChunk(); } catch (Throwable t) { statusManager.abort(); diff --git a/parquet-column/src/main/java/org/apache/parquet/column/page/PageReader.java b/parquet-column/src/main/java/org/apache/parquet/column/page/PageReader.java index 0b4321ca78..63c04c2af5 100644 --- a/parquet-column/src/main/java/org/apache/parquet/column/page/PageReader.java +++ b/parquet-column/src/main/java/org/apache/parquet/column/page/PageReader.java @@ -28,6 +28,13 @@ public interface PageReader { */ DictionaryPage readDictionaryPage(); + /** + * @return the symbol table page in that chunk or null if none + */ + default SymbolTablePage readSymbolTablePage() { + return null; + } + /** * @return the total number of values in the column chunk */ diff --git a/parquet-column/src/main/java/org/apache/parquet/column/page/PageWriter.java b/parquet-column/src/main/java/org/apache/parquet/column/page/PageWriter.java index 1d82db8c32..e184c8befd 100644 --- a/parquet-column/src/main/java/org/apache/parquet/column/page/PageWriter.java +++ b/parquet-column/src/main/java/org/apache/parquet/column/page/PageWriter.java @@ -226,6 +226,16 @@ default void writePageV2( */ void writeDictionaryPage(DictionaryPage dictionaryPage) throws IOException; + /** + * writes a symbol table page + * + * @param symbolTablePage the symbol table page containing the table data + * @throws IOException if there was an exception while writing + */ + default void writeSymbolTablePage(SymbolTablePage symbolTablePage) throws IOException { + throw new UnsupportedOperationException("writeSymbolTablePage is not implemented"); + } + /** * @param prefix a prefix header to add at every line * @return a string presenting a summary of how memory is used diff --git a/parquet-column/src/main/java/org/apache/parquet/column/page/SymbolTablePage.java b/parquet-column/src/main/java/org/apache/parquet/column/page/SymbolTablePage.java new file mode 100644 index 0000000000..b38476df30 --- /dev/null +++ b/parquet-column/src/main/java/org/apache/parquet/column/page/SymbolTablePage.java @@ -0,0 +1,92 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.parquet.column.page; + +import java.io.IOException; +import java.util.Objects; +import org.apache.parquet.bytes.BytesInput; +import org.apache.parquet.column.values.symboltable.SymbolTable; +import org.apache.parquet.column.values.symboltable.SymbolTableType; +import org.apache.parquet.column.values.symboltable.SymbolTables; +import org.apache.parquet.io.ParquetDecodingException; + +/** + * Data for a symbol table page. + * + *

A symbol table belongs to a column chunk the way a dictionary does: every page of the chunk is + * compressed against it, and it has to be readable before any of them. This page is that page's + * mirror, one per chunk instead of one per row of a data page. + */ +public class SymbolTablePage extends Page { + + private final BytesInput bytes; + private final SymbolTableType type; + + /** + * creates an uncompressed page + * + * @param bytes the content of the page + * @param type the symbol table representation + */ + public SymbolTablePage(BytesInput bytes, SymbolTableType type) { + this(bytes, (int) bytes.size(), type); + } + + /** + * creates a symbol table page + * + * @param bytes the (possibly compressed) content of the page + * @param uncompressedSize the size uncompressed + * @param type the symbol table representation + */ + public SymbolTablePage(BytesInput bytes, int uncompressedSize, SymbolTableType type) { + super(Math.toIntExact(bytes.size()), uncompressedSize); + this.bytes = Objects.requireNonNull(bytes, "bytes cannot be null"); + this.type = Objects.requireNonNull(type, "type cannot be null"); + } + + public BytesInput getBytes() { + return bytes; + } + + public SymbolTableType getType() { + return type; + } + + public SymbolTablePage copy() throws IOException { + return new SymbolTablePage(BytesInput.copy(bytes), getUncompressedSize(), type); + } + + /** + * @return the decoded symbol table + */ + public SymbolTable decode() { + try { + return SymbolTables.deserialize(type, bytes.toByteArray(), 0, (int) bytes.size()); + } catch (IOException e) { + throw new ParquetDecodingException("could not decode the symbol table", e); + } + } + + @Override + public String toString() { + return "SymbolTablePage [bytes.size=" + bytes.size() + ", type=" + type + ", uncompressedSize=" + + getUncompressedSize() + "]"; + } +} diff --git a/parquet-column/src/main/java/org/apache/parquet/column/values/ValuesWriter.java b/parquet-column/src/main/java/org/apache/parquet/column/values/ValuesWriter.java index ecea4a7520..5ab4bdc45b 100755 --- a/parquet-column/src/main/java/org/apache/parquet/column/values/ValuesWriter.java +++ b/parquet-column/src/main/java/org/apache/parquet/column/values/ValuesWriter.java @@ -21,6 +21,7 @@ import org.apache.parquet.bytes.BytesInput; import org.apache.parquet.column.Encoding; import org.apache.parquet.column.page.DictionaryPage; +import org.apache.parquet.column.page.SymbolTablePage; import org.apache.parquet.io.api.Binary; /** @@ -72,6 +73,17 @@ public DictionaryPage toDictPageAndClose() { return null; } + /** + * Returns the symbol table generated by this writer if one was created. + * As part of this operation the table is closed and will not have + * any new values written into it. + * + * @return the symbol table page or null if not symbol table based + */ + public SymbolTablePage toSymbolTablePageAndClose() { + return null; + } + /** * reset the dictionary when a new block starts */ diff --git a/parquet-column/src/main/java/org/apache/parquet/column/values/factory/DefaultV1ValuesWriterFactory.java b/parquet-column/src/main/java/org/apache/parquet/column/values/factory/DefaultV1ValuesWriterFactory.java index e0d12c2878..f56ce95c64 100644 --- a/parquet-column/src/main/java/org/apache/parquet/column/values/factory/DefaultV1ValuesWriterFactory.java +++ b/parquet-column/src/main/java/org/apache/parquet/column/values/factory/DefaultV1ValuesWriterFactory.java @@ -93,10 +93,13 @@ private ValuesWriter getFixedLenByteArrayValuesWriter(ColumnDescriptor path) { } private ValuesWriter getBinaryValuesWriter(ColumnDescriptor path) { - ValuesWriter fallbackWriter = new PlainValuesWriter( - parquetProperties.getInitialSlabSize(), - parquetProperties.getPageSizeThreshold(), - parquetProperties.getAllocator()); + ValuesWriter fallbackWriter = DefaultValuesWriterFactory.symbolTableWriterWithFallBack( + path, + parquetProperties, + new PlainValuesWriter( + parquetProperties.getInitialSlabSize(), + parquetProperties.getPageSizeThreshold(), + parquetProperties.getAllocator())); return DefaultValuesWriterFactory.dictWriterWithFallBack( path, parquetProperties, getEncodingForDictionaryPage(), getEncodingForDataPage(), fallbackWriter); } diff --git a/parquet-column/src/main/java/org/apache/parquet/column/values/factory/DefaultV2ValuesWriterFactory.java b/parquet-column/src/main/java/org/apache/parquet/column/values/factory/DefaultV2ValuesWriterFactory.java index c50b4e49c5..8185ad588a 100644 --- a/parquet-column/src/main/java/org/apache/parquet/column/values/factory/DefaultV2ValuesWriterFactory.java +++ b/parquet-column/src/main/java/org/apache/parquet/column/values/factory/DefaultV2ValuesWriterFactory.java @@ -105,10 +105,13 @@ private ValuesWriter getFixedLenByteArrayValuesWriter(ColumnDescriptor path) { } private ValuesWriter getBinaryValuesWriter(ColumnDescriptor path) { - ValuesWriter fallbackWriter = new DeltaByteArrayWriter( - parquetProperties.getInitialSlabSize(), - parquetProperties.getPageSizeThreshold(), - parquetProperties.getAllocator()); + ValuesWriter fallbackWriter = DefaultValuesWriterFactory.symbolTableWriterWithFallBack( + path, + parquetProperties, + new DeltaByteArrayWriter( + parquetProperties.getInitialSlabSize(), + parquetProperties.getPageSizeThreshold(), + parquetProperties.getAllocator())); return DefaultValuesWriterFactory.dictWriterWithFallBack( path, parquetProperties, getEncodingForDictionaryPage(), getEncodingForDataPage(), fallbackWriter); } diff --git a/parquet-column/src/main/java/org/apache/parquet/column/values/factory/DefaultValuesWriterFactory.java b/parquet-column/src/main/java/org/apache/parquet/column/values/factory/DefaultValuesWriterFactory.java index 4c03e6b65e..467737a5ca 100644 --- a/parquet-column/src/main/java/org/apache/parquet/column/values/factory/DefaultValuesWriterFactory.java +++ b/parquet-column/src/main/java/org/apache/parquet/column/values/factory/DefaultValuesWriterFactory.java @@ -25,6 +25,8 @@ import org.apache.parquet.column.values.ValuesWriter; import org.apache.parquet.column.values.dictionary.DictionaryValuesWriter; import org.apache.parquet.column.values.fallback.FallbackValuesWriter; +import org.apache.parquet.column.values.symboltable.SymbolTableType; +import org.apache.parquet.column.values.symboltable.SymbolTableValuesWriter; /** * Handles ValuesWriter creation statically based on the types of the columns and the writer version. @@ -103,6 +105,29 @@ static DictionaryValuesWriter dictionaryWriter( } } + /** + * Wraps a writer so that a symbol table encoding gets the first attempt at the column, falling back + * to the given writer for a chunk whose codes do not come out smaller than the values. + * + *

Returns the writer unchanged when the column is not configured for a symbol table, which is the + * default for every column. + */ + static ValuesWriter symbolTableWriterWithFallBack( + ColumnDescriptor path, ParquetProperties properties, ValuesWriter writerToFallBackTo) { + SymbolTableType type = properties.getSymbolTableType(path); + if (type == null) { + return writerToFallBackTo; + } + return FallbackValuesWriter.of( + new SymbolTableValuesWriter( + type, + properties.getSymbolTableOffsetEncoding(), + properties.getInitialSlabSize(), + properties.getPageSizeThreshold(), + properties.getAllocator()), + writerToFallBackTo); + } + static ValuesWriter dictWriterWithFallBack( ColumnDescriptor path, ParquetProperties parquetProperties, diff --git a/parquet-column/src/main/java/org/apache/parquet/column/values/fallback/FallbackValuesWriter.java b/parquet-column/src/main/java/org/apache/parquet/column/values/fallback/FallbackValuesWriter.java index 41fe484f37..a9402b0d42 100644 --- a/parquet-column/src/main/java/org/apache/parquet/column/values/fallback/FallbackValuesWriter.java +++ b/parquet-column/src/main/java/org/apache/parquet/column/values/fallback/FallbackValuesWriter.java @@ -21,6 +21,7 @@ import org.apache.parquet.bytes.BytesInput; import org.apache.parquet.column.Encoding; import org.apache.parquet.column.page.DictionaryPage; +import org.apache.parquet.column.page.SymbolTablePage; import org.apache.parquet.column.values.RequiresFallback; import org.apache.parquet.column.values.ValuesWriter; import org.apache.parquet.io.api.Binary; @@ -51,6 +52,8 @@ public static The decoder works on one value's slice of the code stream, which is what makes a value + * addressable without expanding the ones before it. + */ +public interface CodeStreamDecoder { + + /** + * How many bytes one value's codes expand to, so a caller can size a destination buffer. + * + *

Cheaper than expanding, because it reads the codes without copying symbol bytes. + */ + int expandedLength(byte[] codes, int codesOffset, int codesLength); + + /** + * Expands one value's codes. + * + * @param codes the code stream + * @param codesOffset where this value's codes start + * @param codesLength how many bytes of codes belong to this value + * @param destination where the value bytes are written + * @param destinationOffset where to write the value bytes + * @return the number of bytes written + */ + int expand(byte[] codes, int codesOffset, int codesLength, byte[] destination, int destinationOffset); +} diff --git a/parquet-column/src/main/java/org/apache/parquet/column/values/symboltable/CodeStreamEncoder.java b/parquet-column/src/main/java/org/apache/parquet/column/values/symboltable/CodeStreamEncoder.java new file mode 100644 index 0000000000..e06ca7ed0c --- /dev/null +++ b/parquet-column/src/main/java/org/apache/parquet/column/values/symboltable/CodeStreamEncoder.java @@ -0,0 +1,48 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.parquet.column.values.symboltable; + +/** + * Compresses values into a stream of codes over a symbol table. + * + *

Kept separate from the table itself because a table can be paired with more than one code + * stream framing, and because the encode side needs lookup structures that never reach a file. + */ +public interface CodeStreamEncoder { + + /** + * Upper bound on the compressed size of a value, so a caller can size a destination buffer. + * + *

Must account for escapes, which make the worst case larger than the input. + */ + int maxCompressedLength(int rawLength); + + /** + * Compresses one value. + * + * @param source the value bytes + * @param sourceOffset where the value starts + * @param sourceLength the value's length in bytes + * @param destination where the codes are written; must hold {@link #maxCompressedLength} more + * bytes at {@code destinationOffset} + * @param destinationOffset where to write the codes + * @return the number of bytes written + */ + int compress(byte[] source, int sourceOffset, int sourceLength, byte[] destination, int destinationOffset); +} diff --git a/parquet-column/src/main/java/org/apache/parquet/column/values/symboltable/SymbolTable.java b/parquet-column/src/main/java/org/apache/parquet/column/values/symboltable/SymbolTable.java new file mode 100644 index 0000000000..64f207b272 --- /dev/null +++ b/parquet-column/src/main/java/org/apache/parquet/column/values/symboltable/SymbolTable.java @@ -0,0 +1,54 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.parquet.column.values.symboltable; + +import org.apache.parquet.bytes.BytesInput; + +/** + * A table mapping codes to the byte sequences they stand for, in the form that is written to a file + * and read back from one. + * + *

This is the decode-side and serialization view of a table. It deliberately knows nothing about + * how the table was chosen or how values are compressed against it, so that a variant with wider + * codes or a differently built table is a new implementation here rather than a change to the + * writer and reader above it. + */ +public interface SymbolTable { + + SymbolTableType type(); + + /** Number of symbols, excluding the escape mechanism. */ + int symbolCount(); + + /** Length in bytes of the symbol with this code. */ + int symbolLength(int code); + + /** + * Appends the symbol with this code to {@code destination} at {@code position}. + * + * @return the number of bytes written + */ + int copySymbol(int code, byte[] destination, int position); + + /** The serialized table body, as it appears in a file. */ + BytesInput serialize(); + + /** A decoder for code streams written against this table. */ + CodeStreamDecoder decoder(); +} diff --git a/parquet-column/src/main/java/org/apache/parquet/column/values/symboltable/SymbolTablePayload.java b/parquet-column/src/main/java/org/apache/parquet/column/values/symboltable/SymbolTablePayload.java new file mode 100644 index 0000000000..be99940954 --- /dev/null +++ b/parquet-column/src/main/java/org/apache/parquet/column/values/symboltable/SymbolTablePayload.java @@ -0,0 +1,217 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.parquet.column.values.symboltable; + +import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import org.apache.parquet.bytes.ByteBufferInputStream; +import org.apache.parquet.column.values.delta.DeltaBinaryPackingValuesReader; +import org.apache.parquet.io.ParquetDecodingException; + +/** + * A data page body holding a code stream and the boundaries between its values. + * + *

The layout is the same for every symbol table codec: + * + *

+ *   [1B] offset encoding: 0 = PLAIN, 1 = DELTA_BINARY_PACKED
+ *   [4B] number of values, little-endian
+ *   [4B] byte length of the offset section, little-endian
+ *   [..] one end offset into the code stream per value
+ *   [..] the code stream
+ * 
+ * + *

Offsets are ends rather than starts, so a value's codes run from the previous end to its own + * and the first value starts at zero. That is what makes a value readable without expanding the + * ones in front of it, and it is why a reader that only wants the tenth value pays for the offsets + * alone. + * + *

The number of values restates a field the page header already carries. Both copies are read + * and the page header's is treated as the bound: a payload may describe fewer values than the page + * holds, never more. + * + *

This class is the read view. {@link SymbolTablePayloadWriter} produces the same layout. + */ +public final class SymbolTablePayload { + + /** + * How the offset section is encoded. + * + *

Delta encoding is worth having here rather than being a tuning knob: offsets grow with the + * page, so on a page whose values compress to a handful of bytes each, four bytes of offset per + * value can outweigh the codes they point at. + */ + public enum OffsetEncoding { + PLAIN(0), + DELTA_BINARY_PACKED(1); + + private final int value; + + OffsetEncoding(int value) { + this.value = value; + } + + /** The value written as the payload's first byte. */ + public int value() { + return value; + } + + static OffsetEncoding fromValue(int value) { + for (OffsetEncoding encoding : values()) { + if (encoding.value == value) { + return encoding; + } + } + throw new ParquetDecodingException("Unsupported symbol table offset encoding: " + value); + } + } + + /** Size of the fixed part: the offset encoding byte, the value count, the offset section length. */ + public static final int HEADER_SIZE = 9; + + private final int valueCount; + private final byte[] codes; + private final int codesOffset; + private final int[] endOffsets; + + private SymbolTablePayload(int valueCount, byte[] codes, int codesOffset, int[] endOffsets) { + this.valueCount = valueCount; + this.codes = codes; + this.codesOffset = codesOffset; + this.endOffsets = endOffsets; + } + + /** + * Reads a payload, consuming the rest of the stream. + * + * @param in positioned at the payload's first byte + * @param pageValueCount the value count from the page header, an upper bound on the payload's own + */ + public static SymbolTablePayload parse(ByteBufferInputStream in, int pageValueCount) throws IOException { + int length = in.available(); + if (length < HEADER_SIZE) { + throw new ParquetDecodingException( + "Symbol table page body is shorter than its " + HEADER_SIZE + "-byte header: " + length); + } + ByteBuffer header = in.slice(HEADER_SIZE).order(ByteOrder.LITTLE_ENDIAN); + OffsetEncoding offsetEncoding = OffsetEncoding.fromValue(header.get(header.position()) & 0xFF); + int valueCount = header.getInt(header.position() + 1); + int offsetSectionSize = header.getInt(header.position() + 5); + + if (valueCount < 0 || valueCount > pageValueCount) { + throw new ParquetDecodingException("Symbol table page describes " + valueCount + + " values, but the page header says the page holds " + pageValueCount); + } + if (offsetSectionSize < 0 || offsetSectionSize > length - HEADER_SIZE) { + throw new ParquetDecodingException("Invalid symbol table offset section length: " + offsetSectionSize); + } + + int[] endOffsets = readOffsets(in, offsetEncoding, valueCount, offsetSectionSize); + + int codeSectionSize = length - HEADER_SIZE - offsetSectionSize; + ByteBuffer codeSection = in.slice(codeSectionSize); + byte[] codes; + int codesOffset; + if (codeSection.hasArray()) { + codes = codeSection.array(); + codesOffset = codeSection.arrayOffset() + codeSection.position(); + } else { + codes = new byte[codeSectionSize]; + codeSection.get(codes); + codesOffset = 0; + } + + checkOffsets(endOffsets, codeSectionSize); + return new SymbolTablePayload(valueCount, codes, codesOffset, endOffsets); + } + + private static int[] readOffsets( + ByteBufferInputStream in, OffsetEncoding offsetEncoding, int valueCount, int offsetSectionSize) + throws IOException { + if (valueCount == 0) { + if (offsetSectionSize != 0) { + throw new ParquetDecodingException( + "Symbol table page holds no values but has a " + offsetSectionSize + "-byte offset section"); + } + return new int[0]; + } + int[] endOffsets = new int[valueCount]; + if (offsetEncoding == OffsetEncoding.PLAIN) { + long expected = (long) valueCount * Integer.BYTES; + if (offsetSectionSize != expected) { + throw new ParquetDecodingException("Symbol table PLAIN offset section is " + offsetSectionSize + + " bytes; expected " + expected + " for " + valueCount + " values"); + } + ByteBuffer offsets = in.slice(offsetSectionSize).order(ByteOrder.LITTLE_ENDIAN); + for (int i = 0; i < valueCount; i++) { + endOffsets[i] = offsets.getInt(offsets.position() + i * Integer.BYTES); + } + } else { + DeltaBinaryPackingValuesReader offsets = new DeltaBinaryPackingValuesReader(); + offsets.initFromPage(valueCount, in.sliceStream(offsetSectionSize)); + for (int i = 0; i < valueCount; i++) { + endOffsets[i] = offsets.readInteger(); + } + } + return endOffsets; + } + + /** + * Rejects offsets that do not describe a partition of the code section. + * + *

Every later read trusts these bounds, so they are checked once here rather than per value. + * A payload whose offsets stop short of the code section is rejected too: the leftover bytes + * would be unreachable, which means the payload is not the one the writer produced. + */ + private static void checkOffsets(int[] endOffsets, int codeSectionSize) { + int previous = 0; + for (int endOffset : endOffsets) { + if (endOffset < previous || endOffset > codeSectionSize) { + throw new ParquetDecodingException("Symbol table offsets are not monotonic within a " + codeSectionSize + + "-byte code section: " + previous + " then " + endOffset); + } + previous = endOffset; + } + if (previous != codeSectionSize) { + throw new ParquetDecodingException("Symbol table offsets end at " + previous + " but the code section is " + + codeSectionSize + " bytes"); + } + } + + /** How many values this payload holds, which may be fewer than the page holds. */ + public int valueCount() { + return valueCount; + } + + /** The array holding the code stream; {@link #codeStart} indexes into it. */ + public byte[] codes() { + return codes; + } + + /** Where this value's codes begin in {@link #codes}. */ + public int codeStart(int index) { + return codesOffset + (index == 0 ? 0 : endOffsets[index - 1]); + } + + /** How many bytes of codes belong to this value. */ + public int codeLength(int index) { + return index == 0 ? endOffsets[0] : endOffsets[index] - endOffsets[index - 1]; + } +} diff --git a/parquet-column/src/main/java/org/apache/parquet/column/values/symboltable/SymbolTablePayloadWriter.java b/parquet-column/src/main/java/org/apache/parquet/column/values/symboltable/SymbolTablePayloadWriter.java new file mode 100644 index 0000000000..f9efc7a617 --- /dev/null +++ b/parquet-column/src/main/java/org/apache/parquet/column/values/symboltable/SymbolTablePayloadWriter.java @@ -0,0 +1,128 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.parquet.column.values.symboltable; + +import org.apache.parquet.bytes.ByteBufferAllocator; +import org.apache.parquet.bytes.BytesInput; +import org.apache.parquet.bytes.CapacityByteArrayOutputStream; +import org.apache.parquet.column.values.ValuesWriter; +import org.apache.parquet.column.values.delta.DeltaBinaryPackingValuesWriter; +import org.apache.parquet.column.values.delta.DeltaBinaryPackingValuesWriterForInteger; +import org.apache.parquet.column.values.plain.PlainValuesWriter; +import org.apache.parquet.column.values.symboltable.SymbolTablePayload.OffsetEncoding; + +/** + * Builds the data page body {@link SymbolTablePayload} reads. + * + *

Holds the code stream and the offsets, so a codec's writer only has to compress values and + * hand the codes over. The layout is documented on the read side. + */ +public final class SymbolTablePayloadWriter implements AutoCloseable { + + private final OffsetEncoding offsetEncoding; + private final CapacityByteArrayOutputStream codes; + + /** + * The offset section, written through an existing encoder rather than by hand. + * + *

Plain int32 and delta binary packing are both already spelled out elsewhere in this module, + * and a second spelling of either would be a second thing to keep byte-compatible. + */ + private final ValuesWriter offsetWriter; + + private int valueCount; + + public SymbolTablePayloadWriter( + OffsetEncoding offsetEncoding, int initialSlabSize, int pageSize, ByteBufferAllocator allocator) { + this.offsetEncoding = offsetEncoding; + this.codes = new CapacityByteArrayOutputStream(initialSlabSize, pageSize, allocator); + this.offsetWriter = offsetEncoding == OffsetEncoding.PLAIN + ? new PlainValuesWriter(initialSlabSize, pageSize, allocator) + : new DeltaBinaryPackingValuesWriterForInteger( + DeltaBinaryPackingValuesWriter.DEFAULT_NUM_BLOCK_VALUES, + DeltaBinaryPackingValuesWriter.DEFAULT_NUM_MINIBLOCKS, + initialSlabSize, + pageSize, + allocator); + } + + /** Appends one value's codes and records where it ends. */ + public void addValue(byte[] valueCodes, int offset, int length) { + codes.write(valueCodes, offset, length); + long end = codes.size(); + if (end > Integer.MAX_VALUE) { + // The offsets are int32 on the wire, so this is a format limit rather than a Java one. + throw new IllegalStateException("Symbol table code stream exceeds 2 GB: " + end); + } + offsetWriter.writeInteger((int) end); + valueCount++; + } + + public int valueCount() { + return valueCount; + } + + /** Bytes buffered so far, excluding the fixed header, for a caller watching the page size. */ + public long bufferedSize() { + return codes.size() + offsetWriter.getBufferedSize(); + } + + public long allocatedSize() { + return codes.getCapacity() + offsetWriter.getAllocatedSize(); + } + + /** + * The finished page body. + * + *

A page holding no values gets an empty offset section rather than an encoder's empty-input + * output, because the offset encoding's own framing would otherwise be the only thing in the + * section and a reader has no values to spend it on. + */ + public BytesInput getBytes() { + BytesInput offsets = valueCount == 0 ? BytesInput.empty() : offsetWriter.getBytes(); + long offsetSectionSize = offsets.size(); + if (offsetSectionSize > Integer.MAX_VALUE) { + throw new IllegalStateException("Symbol table offset section exceeds 2 GB: " + offsetSectionSize); + } + byte[] header = new byte[SymbolTablePayload.HEADER_SIZE]; + header[0] = (byte) offsetEncoding.value(); + writeIntLittleEndian(header, 1, valueCount); + writeIntLittleEndian(header, 5, (int) offsetSectionSize); + return BytesInput.concat(BytesInput.from(header), offsets, BytesInput.from(codes)); + } + + private static void writeIntLittleEndian(byte[] destination, int position, int value) { + destination[position] = (byte) value; + destination[position + 1] = (byte) (value >>> 8); + destination[position + 2] = (byte) (value >>> 16); + destination[position + 3] = (byte) (value >>> 24); + } + + public void reset() { + codes.reset(); + offsetWriter.reset(); + valueCount = 0; + } + + @Override + public void close() { + codes.close(); + offsetWriter.close(); + } +} diff --git a/parquet-column/src/main/java/org/apache/parquet/column/values/symboltable/SymbolTableSource.java b/parquet-column/src/main/java/org/apache/parquet/column/values/symboltable/SymbolTableSource.java new file mode 100644 index 0000000000..3f8dca74bd --- /dev/null +++ b/parquet-column/src/main/java/org/apache/parquet/column/values/symboltable/SymbolTableSource.java @@ -0,0 +1,39 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.parquet.column.values.symboltable; + +/** + * Where a reader gets the symbol table a column chunk's pages were compressed against. + * + *

The write side's counterpart is + * {@link org.apache.parquet.column.values.ValuesWriter#toSymbolTablePageAndClose()}, which hands the + * table to the column's {@link org.apache.parquet.column.page.PageWriter} once per chunk. The table + * here arrives already deserialized, because which implementation to build from the bytes depends + * on the representation and that decision belongs in one place: {@link SymbolTables}. + */ +public interface SymbolTableSource { + + /** + * The table for the chunk being read. + * + *

Called before the first page. Implementations are expected to deserialize once and hand back + * the same table for every page of the chunk. + */ + SymbolTable getSymbolTable(); +} diff --git a/parquet-column/src/main/java/org/apache/parquet/column/values/symboltable/SymbolTableTrainer.java b/parquet-column/src/main/java/org/apache/parquet/column/values/symboltable/SymbolTableTrainer.java new file mode 100644 index 0000000000..85e570c95f --- /dev/null +++ b/parquet-column/src/main/java/org/apache/parquet/column/values/symboltable/SymbolTableTrainer.java @@ -0,0 +1,36 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.parquet.column.values.symboltable; + +/** + * Chooses a symbol table for a set of values. + * + *

This is the seam that separates how a table is chosen from how it is written and used. A + * different code width, or a different way of mining symbols out of the data, is a new + * implementation of this interface and of {@link SymbolTable}, with nothing above them changed. + */ +public interface SymbolTableTrainer { + + SymbolTableType type(); + + /** + * Trains a table on the given values, which the trainer may read in any order and more than once. + */ + TrainedSymbolTable train(ValueBuffer values); +} diff --git a/parquet-column/src/main/java/org/apache/parquet/column/values/symboltable/SymbolTableType.java b/parquet-column/src/main/java/org/apache/parquet/column/values/symboltable/SymbolTableType.java new file mode 100644 index 0000000000..85441ed986 --- /dev/null +++ b/parquet-column/src/main/java/org/apache/parquet/column/values/symboltable/SymbolTableType.java @@ -0,0 +1,66 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.parquet.column.values.symboltable; + +/** + * The representation of a symbol table, which fixes the width of a code in the code stream and the + * framing of an escaped literal. + * + *

A column's encoding does not identify the representation on its own: a single encoding covers + * every variant, and the type is carried with the table. A reader therefore has to read the table + * before it can commit to decoding the column, and may reject a type it does not implement. + * + *

The numeric values are not ratified. Only {@link #FSST_8} appears in the format proposal so + * far; {@link #FSST_16}'s value is this implementation's proposal and must be confirmed against + * parquet-format issue #531 before any file written with it is treated as portable. + */ +public enum SymbolTableType { + /** Single-byte codes, at most 255 symbols, escape marker 255 followed by one literal byte. */ + FSST_8(0, 1), + + /** Two-byte codes, at most 65535 symbols, escape marker 65535 followed by a literal. */ + FSST_16(1, 2); + + private final int typeValue; + private final int codeWidth; + + SymbolTableType(int typeValue, int codeWidth) { + this.typeValue = typeValue; + this.codeWidth = codeWidth; + } + + /** The value written to the symbol table page header. */ + public int typeValue() { + return typeValue; + } + + /** Width in bytes of one code in the code stream. */ + public int codeWidth() { + return codeWidth; + } + + public static SymbolTableType fromTypeValue(int typeValue) { + for (SymbolTableType type : values()) { + if (type.typeValue == typeValue) { + return type; + } + } + throw new IllegalArgumentException("Unsupported symbol table type: " + typeValue); + } +} diff --git a/parquet-column/src/main/java/org/apache/parquet/column/values/symboltable/SymbolTableValuesReader.java b/parquet-column/src/main/java/org/apache/parquet/column/values/symboltable/SymbolTableValuesReader.java new file mode 100644 index 0000000000..e53193b045 --- /dev/null +++ b/parquet-column/src/main/java/org/apache/parquet/column/values/symboltable/SymbolTableValuesReader.java @@ -0,0 +1,118 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.parquet.column.values.symboltable; + +import java.io.IOException; +import org.apache.parquet.bytes.ByteBufferInputStream; +import org.apache.parquet.column.values.ValuesReader; +import org.apache.parquet.io.ParquetDecodingException; +import org.apache.parquet.io.api.Binary; + +/** + * Reads the pages {@link SymbolTableValuesWriter} writes. + * + *

One reader serves every symbol table representation, for the same reason one writer does: the + * page body is the same shape whatever the table holds, and expanding a value's codes is the table's + * own business. Which representation a chunk used is carried with the table rather than by the + * column's encoding, so this reader learns it from the {@link SymbolTableSource} and never has to + * guess. + * + *

A value is addressable without expanding the ones before it, because the page records where + * each value's codes end. That is what makes {@link #skip} cost nothing and is the property the + * encoding exists for. + */ +public class SymbolTableValuesReader extends ValuesReader { + + private final SymbolTableSource source; + + private SymbolTable table; + private CodeStreamDecoder decoder; + private SymbolTablePayload payload; + private int index; + + /** + * A reader with nowhere to get its table from, which cannot decode a page. + * + *

The format does not carry a symbol table yet, so there is no place for a page reader to find + * one and nothing to hand this constructor. It exists so that the encoding is complete on the read + * side up to that one missing piece; supply a source and the reader works. See parquet-format issue + * #531. + */ + public SymbolTableValuesReader() { + this(null); + } + + public SymbolTableValuesReader(SymbolTableSource source) { + this.source = source; + } + + @Override + public void initFromPage(int valueCount, ByteBufferInputStream in) throws IOException { + if (table == null) { + if (source == null) { + throw new ParquetDecodingException( + "Cannot decode a symbol table encoded page without the chunk's symbol table, which the " + + "format does not carry yet: see parquet-format issue #531"); + } + // Once per chunk. The table outlives the page: every page of the chunk shares it. + table = source.getSymbolTable(); + decoder = table.decoder(); + } + payload = SymbolTablePayload.parse(in, valueCount); + index = 0; + } + + /** The representation of the table this reader is decoding against. */ + public SymbolTableType symbolTableType() { + return table == null ? null : table.type(); + } + + @Override + public Binary readBytes() { + checkHasValue(1); + byte[] codes = payload.codes(); + int offset = payload.codeStart(index); + int length = payload.codeLength(index); + index++; + byte[] value = new byte[decoder.expandedLength(codes, offset, length)]; + decoder.expand(codes, offset, length, value, 0); + return Binary.fromConstantByteArray(value); + } + + @Override + public void skip() { + skip(1); + } + + @Override + public void skip(int n) { + checkHasValue(n); + index += n; + } + + private void checkHasValue(int n) { + if (payload == null) { + throw new ParquetDecodingException("Symbol table reader used before a page was read"); + } + if (index + n > payload.valueCount()) { + throw new ParquetDecodingException("Read past the end of a symbol table page: asked for value " + + (index + n) + " of " + payload.valueCount()); + } + } +} diff --git a/parquet-column/src/main/java/org/apache/parquet/column/values/symboltable/SymbolTableValuesWriter.java b/parquet-column/src/main/java/org/apache/parquet/column/values/symboltable/SymbolTableValuesWriter.java new file mode 100644 index 0000000000..1833df4905 --- /dev/null +++ b/parquet-column/src/main/java/org/apache/parquet/column/values/symboltable/SymbolTableValuesWriter.java @@ -0,0 +1,212 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.parquet.column.values.symboltable; + +import org.apache.parquet.bytes.ByteBufferAllocator; +import org.apache.parquet.bytes.BytesInput; +import org.apache.parquet.column.Encoding; +import org.apache.parquet.column.page.SymbolTablePage; +import org.apache.parquet.column.values.RequiresFallback; +import org.apache.parquet.column.values.ValuesWriter; +import org.apache.parquet.column.values.symboltable.SymbolTablePayload.OffsetEncoding; +import org.apache.parquet.io.api.Binary; + +/** + * Writes binary values as codes over a symbol table trained on the data. + * + *

One writer serves every symbol table representation. The representation decides how a table is + * trained, how it is serialized and how a code stream is framed, and all three sit behind + * {@link SymbolTableTrainer}, {@link SymbolTable} and {@link CodeStreamEncoder}; nothing about them + * reaches this class beyond the {@link SymbolTableType} it is asked for. + * + *

What happens when

+ * + *

Values are buffered raw as they arrive. The table is trained at the first {@link #getBytes()}, + * on that page's values, and kept for the rest of the chunk: a table belongs to a column chunk, so + * a later page must not train its own. It is handed off through {@link #toSymbolTablePageAndClose()}, + * called once per chunk after every page has been written, which is also what keeps a chunk that + * falls back to another encoding from leaving a table behind that no page refers to. Training on + * the first page rather than on the whole chunk is deliberate — a page is already far more text + * than a trainer samples, and buffering the chunk to feed it would cost a second copy of the + * column. + * + *

Buffering is the reason values are held at all: a trainer reads them in an order of its own + * choosing and more than once, and a fallback to another encoding has to replay them. The cost is + * the page held twice, once raw and once as codes, which is what {@link #getAllocatedSize()} + * reports. + * + *

Falling back

+ * + *

Compressing a page can make it bigger — short values, or values that share nothing with the + * rest of the column. Wrapping this writer in a + * {@link org.apache.parquet.column.values.fallback.FallbackValuesWriter} over a plain writer is what + * makes the encoding safe to turn on: {@link #isCompressionSatisfying} answers the only question + * that matters, which is whether the codes came out smaller than the values. + */ +public class SymbolTableValuesWriter extends ValuesWriter implements RequiresFallback { + + private final SymbolTableType type; + private final ValueBuffer values; + private final SymbolTablePayloadWriter payload; + + /** The chunk's table, trained at the first {@link #getBytes()} and reused after that. */ + private TrainedSymbolTable trained; + + /** Scratch for one value's codes, grown as needed and reused across values. */ + private byte[] codes = new byte[0]; + + /** + * How many of the buffered values have been compressed into {@link #payload}. + * + *

Compression happens at {@link #getBytes()}, because it cannot start before the table exists. + * Counting what is already done rather than flagging it keeps a second call correct whether or not + * more values arrived in between. + */ + private int compressedCount; + + public SymbolTableValuesWriter( + SymbolTableType type, + OffsetEncoding offsetEncoding, + int initialSlabSize, + int pageSize, + ByteBufferAllocator allocator) { + this.type = type; + this.values = new ValueBuffer(); + this.payload = new SymbolTablePayloadWriter(offsetEncoding, initialSlabSize, pageSize, allocator); + } + + public SymbolTableType symbolTableType() { + return type; + } + + @Override + public void writeBytes(Binary v) { + values.add(v); + } + + /** + * The size of the values buffered for this page, counted as a plain page would count them. + * + *

Not the compressed size, which is not known until the table has been trained. A page boundary + * has to be decided while values are still arriving, so it is decided on what the page would cost + * unencoded — the same choice {@link org.apache.parquet.column.values.fallback.FallbackValuesWriter} + * makes, and for the same reason: a page sized by its compressed length is a page that becomes too + * big the moment the encoding is abandoned. + */ + @Override + public long getBufferedSize() { + return values.byteCount() + 4L * values.valueCount(); + } + + @Override + public BytesInput getBytes() { + if (trained == null) { + trained = SymbolTables.trainer(type).train(values); + } + compressBufferedValues(); + return payload.getBytes(); + } + + @Override + public SymbolTablePage toSymbolTablePageAndClose() { + return trained == null ? null : new SymbolTablePage(trained.table().serialize(), type); + } + + @Override + public Encoding getEncoding() { + return Encoding.FSST; + } + + @Override + public void reset() { + values.reset(); + payload.reset(); + compressedCount = 0; + } + + /** + * Drops the table, which is what a new column chunk needs. + * + *

Named for the dictionary because that is the only chunk-scoped state a values writer had + * before this one. In the file writer a values writer does not outlive its chunk, so this is + * belt-and-braces rather than the path that runs. + */ + @Override + public void resetDictionary() { + trained = null; + } + + @Override + public void close() { + payload.close(); + } + + @Override + public long getAllocatedSize() { + return values.allocatedSize() + payload.allocatedSize() + codes.length; + } + + @Override + public String memUsageString(String prefix) { + return String.format( + "%s %s{raw %d bytes, codes %d bytes}", prefix, type, values.byteCount(), payload.bufferedSize()); + } + + // RequiresFallback + + /** + * Never, because there is no state here that can run away. + * + *

This is the check a dictionary needs, where the encoding stops paying once the dictionary + * outgrows the data. A symbol table is bounded by its representation whatever the data does, so + * the only question worth asking is the one {@link #isCompressionSatisfying} asks, once, when + * there is an answer to it. + */ + @Override + public boolean shouldFallBack() { + return false; + } + + @Override + public boolean isCompressionSatisfying(long rawSize, long encodedSize) { + return encodedSize < rawSize; + } + + @Override + public void fallBackAllValuesTo(ValuesWriter writer) { + byte[] data = values.data(); + for (int i = 0; i < values.valueCount(); i++) { + writer.writeBytes(Binary.fromReusedByteArray(data, values.offset(i), values.length(i))); + } + } + + private void compressBufferedValues() { + CodeStreamEncoder encoder = trained.encoder(); + byte[] data = values.data(); + for (int i = compressedCount; i < values.valueCount(); i++) { + int length = values.length(i); + int bound = encoder.maxCompressedLength(length); + if (codes.length < bound) { + codes = new byte[Math.max(bound, codes.length * 2)]; + } + payload.addValue(codes, 0, encoder.compress(data, values.offset(i), length, codes, 0)); + } + compressedCount = values.valueCount(); + } +} diff --git a/parquet-column/src/main/java/org/apache/parquet/column/values/symboltable/SymbolTables.java b/parquet-column/src/main/java/org/apache/parquet/column/values/symboltable/SymbolTables.java new file mode 100644 index 0000000000..3ea11380b9 --- /dev/null +++ b/parquet-column/src/main/java/org/apache/parquet/column/values/symboltable/SymbolTables.java @@ -0,0 +1,60 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.parquet.column.values.symboltable; + +import org.apache.parquet.column.values.symboltable.fsst.Fsst8SymbolTable; +import org.apache.parquet.column.values.symboltable.fsst.FsstTrainer; +import org.apache.parquet.io.ParquetDecodingException; + +/** + * The one place that maps a symbol table representation to the code that implements it. + * + *

Both directions live here so that a new representation is a new implementation plus two lines + * of dispatch, and so that a reader's rejection of a representation it does not implement happens in + * one place with one message. + */ +public final class SymbolTables { + + private SymbolTables() {} + + /** A trainer producing tables of this representation. */ + public static SymbolTableTrainer trainer(SymbolTableType type) { + switch (type) { + case FSST_8: + return new FsstTrainer(); + default: + throw new IllegalArgumentException("No symbol table trainer for " + type); + } + } + + /** + * Rebuilds a table from a serialized body. + * + * @throws ParquetDecodingException if this implementation cannot read the representation, which a + * reader is allowed to do and is not the same as the file being corrupt + */ + public static SymbolTable deserialize(SymbolTableType type, byte[] body, int offset, int length) { + switch (type) { + case FSST_8: + return Fsst8SymbolTable.deserialize(body, offset, length); + default: + throw new ParquetDecodingException("Unsupported symbol table type: " + type); + } + } +} diff --git a/parquet-column/src/main/java/org/apache/parquet/column/values/symboltable/TrainedSymbolTable.java b/parquet-column/src/main/java/org/apache/parquet/column/values/symboltable/TrainedSymbolTable.java new file mode 100644 index 0000000000..234e6202ac --- /dev/null +++ b/parquet-column/src/main/java/org/apache/parquet/column/values/symboltable/TrainedSymbolTable.java @@ -0,0 +1,44 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.parquet.column.values.symboltable; + +/** + * What training produces: a table to write to the file, and an encoder that compresses against it. + * + *

The two come out together because the encode-side lookup structures are a byproduct of + * training and rebuilding them from the serialized table would be wasted work. + */ +public final class TrainedSymbolTable { + + private final SymbolTable table; + private final CodeStreamEncoder encoder; + + public TrainedSymbolTable(SymbolTable table, CodeStreamEncoder encoder) { + this.table = table; + this.encoder = encoder; + } + + public SymbolTable table() { + return table; + } + + public CodeStreamEncoder encoder() { + return encoder; + } +} diff --git a/parquet-column/src/main/java/org/apache/parquet/column/values/symboltable/ValueBuffer.java b/parquet-column/src/main/java/org/apache/parquet/column/values/symboltable/ValueBuffer.java new file mode 100644 index 0000000000..b6209c7f47 --- /dev/null +++ b/parquet-column/src/main/java/org/apache/parquet/column/values/symboltable/ValueBuffer.java @@ -0,0 +1,135 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.parquet.column.values.symboltable; + +import java.util.Arrays; +import org.apache.parquet.bytes.BytesInput; +import org.apache.parquet.io.api.Binary; + +/** + * The values of one page, held as a single byte array plus start offsets. + * + *

Training needs to read values in an order of its own choosing, and more than once, so the + * values have to be somewhere addressable when the page is closed. Holding them as one array rather + * than as a list of separately allocated values keeps that at one copy of the page with no + * per-value object, which matters because a writer is already buffering this data anyway. + * + *

The array carries eight bytes of zero padding past the last value. Symbol matching loads eight + * bytes at a time and deliberately reads past the end of a short value; the padding is what makes + * that read in-bounds, and zeros are what make it harmless, since no symbol can match beyond the + * value's own length once the load is masked. + */ +public class ValueBuffer { + + /** Slack past the last value, for eight-byte loads that overshoot. */ + public static final int TAIL_PADDING = 8; + + private static final int DEFAULT_INITIAL_CAPACITY = 64 * 1024; + private static final int DEFAULT_INITIAL_VALUES = 1024; + + private byte[] data; + private int size; + private int[] offsets; + private int valueCount; + + public ValueBuffer() { + this(DEFAULT_INITIAL_CAPACITY, DEFAULT_INITIAL_VALUES); + } + + public ValueBuffer(int initialCapacity, int initialValues) { + this.data = new byte[initialCapacity + TAIL_PADDING]; + this.offsets = new int[initialValues + 1]; + this.offsets[0] = 0; + } + + public void add(Binary value) { + int length = value.length(); + ensureCapacity(size + length); + ensureValues(valueCount + 1); + // Read through a duplicate so the value's own buffer position is left alone. This is the one + // copy of the value; taking the backing array instead would copy for slice-backed values. + value.toByteBuffer().duplicate().get(data, size, length); + size += length; + offsets[++valueCount] = size; + } + + public void add(byte[] source, int offset, int length) { + ensureCapacity(size + length); + ensureValues(valueCount + 1); + System.arraycopy(source, offset, data, size, length); + size += length; + offsets[++valueCount] = size; + } + + public int valueCount() { + return valueCount; + } + + /** Total length of all values, which is the size of the region {@link #data} holds. */ + public int byteCount() { + return size; + } + + /** Bytes held by the two arrays, which is what this buffer costs a writer's memory budget. */ + public long allocatedSize() { + return data.length + 4L * offsets.length; + } + + /** + * The backing array. Valid from 0 to {@link #byteCount()}, followed by {@link #TAIL_PADDING} zero + * bytes that a reader may load but must not interpret. + */ + public byte[] data() { + return data; + } + + public int offset(int index) { + return offsets[index]; + } + + public int length(int index) { + return offsets[index + 1] - offsets[index]; + } + + public BytesInput asBytesInput() { + return BytesInput.from(data, 0, size); + } + + public void reset() { + // Clear through the padding so the next round of eight-byte overshoot still reads zeros. + Arrays.fill(data, 0, Math.min(size + TAIL_PADDING, data.length), (byte) 0); + size = 0; + valueCount = 0; + } + + private void ensureCapacity(int required) { + if (required + TAIL_PADDING <= data.length) { + return; + } + int capacity = Math.max(data.length * 2, required + TAIL_PADDING); + data = Arrays.copyOf(data, capacity); + } + + private void ensureValues(int required) { + if (required < offsets.length) { + return; + } + offsets = Arrays.copyOf(offsets, Math.max(offsets.length * 2, required + 1)); + } +} diff --git a/parquet-column/src/main/java/org/apache/parquet/column/values/symboltable/fsst/Fsst8CodeStreamDecoder.java b/parquet-column/src/main/java/org/apache/parquet/column/values/symboltable/fsst/Fsst8CodeStreamDecoder.java new file mode 100644 index 0000000000..9aeb2766d1 --- /dev/null +++ b/parquet-column/src/main/java/org/apache/parquet/column/values/symboltable/fsst/Fsst8CodeStreamDecoder.java @@ -0,0 +1,93 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.parquet.column.values.symboltable.fsst; + +import org.apache.parquet.column.values.symboltable.CodeStreamDecoder; +import org.apache.parquet.io.ParquetDecodingException; + +/** + * Expands 8-bit FSST codes back into value bytes. + * + *

Decoding needs nothing but the table: each code either names a symbol to copy or announces that + * the next byte stands for itself. There is no state carried between values, which is what lets a + * reader expand one value without touching the ones before it. + */ +class Fsst8CodeStreamDecoder implements CodeStreamDecoder { + + private final byte[] symbolBytes; + private final int[] symbolOffsets; + private final int symbolCount; + + Fsst8CodeStreamDecoder(byte[] symbolBytes, int[] symbolOffsets, int symbolCount) { + this.symbolBytes = symbolBytes; + this.symbolOffsets = symbolOffsets; + this.symbolCount = symbolCount; + } + + @Override + public int expandedLength(byte[] codes, int codesOffset, int codesLength) { + int length = 0; + int position = codesOffset; + int end = codesOffset + codesLength; + while (position < end) { + int code = codes[position++] & 0xFF; + if (code == Fsst8SymbolTable.ESCAPE) { + requireLiteral(position, end); + position++; + length++; + } else { + length += symbolLength(code); + } + } + return length; + } + + @Override + public int expand(byte[] codes, int codesOffset, int codesLength, byte[] destination, int destinationOffset) { + int written = destinationOffset; + int position = codesOffset; + int end = codesOffset + codesLength; + while (position < end) { + int code = codes[position++] & 0xFF; + if (code == Fsst8SymbolTable.ESCAPE) { + requireLiteral(position, end); + destination[written++] = codes[position++]; + } else { + int length = symbolLength(code); + System.arraycopy(symbolBytes, symbolOffsets[code], destination, written, length); + written += length; + } + } + return written - destinationOffset; + } + + private int symbolLength(int code) { + if (code >= symbolCount) { + throw new ParquetDecodingException( + "FSST value references symbol code " + code + " but the table holds " + symbolCount + " symbols"); + } + return symbolOffsets[code + 1] - symbolOffsets[code]; + } + + private static void requireLiteral(int position, int end) { + if (position >= end) { + throw new ParquetDecodingException("FSST value ends with an escape and no literal byte"); + } + } +} diff --git a/parquet-column/src/main/java/org/apache/parquet/column/values/symboltable/fsst/Fsst8CodeStreamEncoder.java b/parquet-column/src/main/java/org/apache/parquet/column/values/symboltable/fsst/Fsst8CodeStreamEncoder.java new file mode 100644 index 0000000000..5aba4b4fbd --- /dev/null +++ b/parquet-column/src/main/java/org/apache/parquet/column/values/symboltable/fsst/Fsst8CodeStreamEncoder.java @@ -0,0 +1,120 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.parquet.column.values.symboltable.fsst; + +import static org.apache.parquet.column.values.symboltable.fsst.FsstCodes.CODE_BASE; +import static org.apache.parquet.column.values.symboltable.fsst.FsstCodes.ICL_FREE; + +import org.apache.parquet.column.values.symboltable.CodeStreamEncoder; + +/** + * Compresses value bytes into 8-bit FSST codes. + * + *

Two things here are not free choices. The first is chunking: a value is compressed 511 bytes at + * a time, with the table's separator byte written just past each chunk. That bound is what the + * reference implementation's vectorized compressor uses, and matching it is what keeps the code + * stream identical whichever compressor produced it. Since no symbol contains the separator, no + * match can reach across a chunk boundary, so the boundary is also what makes the eight-byte read at + * the end of a chunk safe without a shorter tail path. + * + *

The compression loop is a port of FSST's reference implementation by Peter Boncz, Viktor Leis + * and Thomas Neumann (CWI / TU Munich), MIT licensed, at https://github.com/cwida/fsst, commit + * 89f49c580c6388acf3b6ed2a49e1bfde6c05e616. Its three hand-tuned variants differ only in branch + * layout and produce the same bytes, so one is enough here. + * + *

The second is the code translation. The trainer numbers symbols in an order that lets the + * compressor skip work, and a file numbers them by length. So every code leaves this class through + * the permutation between the two; an escape passes through untouched, since it is a fixed marker in + * both orders rather than a symbol. + */ +class Fsst8CodeStreamEncoder implements CodeStreamEncoder { + + /** Bytes compressed at a time, matching the reference implementation's vectorized compressor. */ + private static final int CHUNK_SIZE = 511; + + /** Room past the chunk for the separator byte and for an eight-byte read at the chunk's end. */ + private static final int CHUNK_PADDING = 8; + + private final FsstSymbolTable table; + private final byte[] fileCodeForTrainerCode; + private final byte[] chunk = new byte[CHUNK_SIZE + CHUNK_PADDING]; + + Fsst8CodeStreamEncoder(FsstSymbolTable table, Fsst8SymbolTable fileTable) { + this.table = table; + this.fileCodeForTrainerCode = fileTable.encodeCodeMap(); + } + + @Override + public int maxCompressedLength(int rawLength) { + // Every byte could escape, which costs the marker plus the byte itself. + return 2 * rawLength; + } + + @Override + public int compress(byte[] source, int sourceOffset, int sourceLength, byte[] destination, int destinationOffset) { + int written = destinationOffset; + int consumed = 0; + while (consumed < sourceLength) { + int chunkLength = Math.min(CHUNK_SIZE, sourceLength - consumed); + System.arraycopy(source, sourceOffset + consumed, chunk, 0, chunkLength); + // Bytes past the separator keep whatever a longer previous chunk left there. That is harmless, + // and is what the reference implementation does: a match reaching past the separator would + // have to contain it, and no symbol does. + chunk[chunkLength] = (byte) table.terminator; + written = compressChunk(chunkLength, destination, written); + consumed += chunkLength; + } + return written - destinationOffset; + } + + /** Compresses one chunk out of {@link #chunk}, returning the new write position. */ + private int compressChunk(int chunkLength, byte[] destination, int writePosition) { + char[] shortCodes = table.shortCodes; + long[] hashValues = table.hashValues; + long[] hashDescriptors = table.hashDescriptors; + byte[] codeMap = fileCodeForTrainerCode; + int byteLimit = table.byteLimit; + int written = writePosition; + int position = 0; + while (position < chunkLength) { + long word = FsstCodes.loadWordUnchecked(chunk, position); + int shortCode = shortCodes[(int) (word & 0xFFFF)]; + int bucket = FsstCodes.hashBucket(word); + long descriptor = hashDescriptors[bucket]; + if (descriptor < ICL_FREE && hashValues[bucket] == FsstCodes.maskWord(word, descriptor)) { + // A symbol of three bytes or more. + destination[written++] = codeMap[FsstCodes.code(descriptor)]; + position += FsstCodes.length(descriptor); + } else if ((shortCode & 0xFF) < byteLimit) { + // A two-byte symbol, and the miss above rules out a longer one starting here. + destination[written++] = codeMap[shortCode & 0xFF]; + position += 2; + } else if ((shortCode & CODE_BASE) != 0) { + // No symbol matches, so the byte stands for itself behind the escape marker. + destination[written++] = (byte) Fsst8SymbolTable.ESCAPE; + destination[written++] = (byte) word; + position += 1; + } else { + destination[written++] = codeMap[shortCode & 0xFF]; + position += 1; + } + } + return written; + } +} diff --git a/parquet-column/src/main/java/org/apache/parquet/column/values/symboltable/fsst/Fsst8SymbolTable.java b/parquet-column/src/main/java/org/apache/parquet/column/values/symboltable/fsst/Fsst8SymbolTable.java new file mode 100644 index 0000000000..0f8a86359e --- /dev/null +++ b/parquet-column/src/main/java/org/apache/parquet/column/values/symboltable/fsst/Fsst8SymbolTable.java @@ -0,0 +1,213 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.parquet.column.values.symboltable.fsst; + +import java.util.Arrays; +import org.apache.parquet.bytes.BytesInput; +import org.apache.parquet.column.values.symboltable.CodeStreamDecoder; +import org.apache.parquet.column.values.symboltable.SymbolTable; +import org.apache.parquet.column.values.symboltable.SymbolTableType; +import org.apache.parquet.io.ParquetDecodingException; + +/** + * An 8-bit FSST symbol table in the form a file carries it. + * + *

The codes here are not the codes the trainer works with. The trainer numbers symbols in an order + * that suits compression, and the format numbers them in order of length, shortest first, so that a + * reader can rebuild the table from a length histogram without storing a length per symbol. The + * permutation between the two is built once, when a trained table is converted, and is what the + * encoder applies to every code it emits. + * + *

Serialized body: one byte holding the symbol count, then eight bytes counting the symbols of + * each length from one to eight, then the symbol bytes end to end in that same order. At most 255 + * symbols of at most 8 bytes each, so at most 2049 bytes. That layout is single-byte-code only, which + * is why the 16-bit variant needs a table format of its own rather than an extra field here. + */ +public class Fsst8SymbolTable implements SymbolTable { + + /** Code standing for one literal byte, which follows it in the code stream. */ + public static final int ESCAPE = 0xFF; + + static final int MAX_SYMBOL_LENGTH = 8; + + /** Symbol count plus the eight histogram entries. */ + static final int HEADER_SIZE = 9; + + /** Largest serialized body: the header plus 255 symbols of 8 bytes. */ + static final int MAX_SERIALIZED_SIZE = HEADER_SIZE + FsstCodes.MAX_SYMBOLS * MAX_SYMBOL_LENGTH; + + /** Symbol bytes end to end, in code order. */ + private final byte[] symbolBytes; + + /** Where each symbol starts in {@link #symbolBytes}, with one extra entry for the end. */ + private final int[] symbolOffsets; + + private final int symbolCount; + + /** + * Maps a trainer code to the code written to a file, or {@link #ESCAPE} where the trainer has no + * such symbol. Null on a table read back from a file, which never encodes. + */ + private final byte[] encodeCodeMap; + + private Fsst8SymbolTable(byte[] symbolBytes, int[] symbolOffsets, int symbolCount, byte[] encodeCodeMap) { + this.symbolBytes = symbolBytes; + this.symbolOffsets = symbolOffsets; + this.symbolCount = symbolCount; + this.encodeCodeMap = encodeCodeMap; + } + + /** + * Converts a trained table into the file representation, renumbering symbols into length order. + * + *

Symbols of the same length keep their relative order from the trainer, which is what makes the + * renumbering reproducible. + */ + static Fsst8SymbolTable of(FsstSymbolTable trained) { + int symbolCount = trained.symbolCount; + if (symbolCount > FsstCodes.MAX_SYMBOLS) { + throw new IllegalArgumentException( + "FSST8 holds at most " + FsstCodes.MAX_SYMBOLS + " symbols, got " + symbolCount); + } + // A counting sort by length, which is stable and needs no comparator. + int[] countByLength = new int[MAX_SYMBOL_LENGTH + 1]; + for (int code = 0; code < symbolCount; code++) { + countByLength[FsstCodes.length(trained.symbolDescriptors[code])]++; + } + int[] nextCodeForLength = new int[MAX_SYMBOL_LENGTH + 2]; + int[] nextOffsetForLength = new int[MAX_SYMBOL_LENGTH + 2]; + int runningCode = 0; + int runningOffset = 0; + for (int length = 1; length <= MAX_SYMBOL_LENGTH; length++) { + nextCodeForLength[length] = runningCode; + nextOffsetForLength[length] = runningOffset; + runningCode += countByLength[length]; + runningOffset += countByLength[length] * length; + } + + byte[] symbolBytes = new byte[runningOffset]; + int[] symbolOffsets = new int[symbolCount + 1]; + byte[] encodeCodeMap = new byte[FsstCodes.MAX_SYMBOLS]; + Arrays.fill(encodeCodeMap, (byte) ESCAPE); + + for (int trainerCode = 0; trainerCode < symbolCount; trainerCode++) { + int length = FsstCodes.length(trained.symbolDescriptors[trainerCode]); + int fileCode = nextCodeForLength[length]++; + int offset = nextOffsetForLength[length]; + nextOffsetForLength[length] += length; + long value = trained.symbolValues[trainerCode]; + for (int i = 0; i < length; i++) { + symbolBytes[offset + i] = (byte) (value >>> (i * 8)); + } + symbolOffsets[fileCode] = offset; + encodeCodeMap[trainerCode] = (byte) fileCode; + } + symbolOffsets[symbolCount] = runningOffset; + return new Fsst8SymbolTable(symbolBytes, symbolOffsets, symbolCount, encodeCodeMap); + } + + /** Reads a table back from the bytes a file carries. */ + public static Fsst8SymbolTable deserialize(byte[] body, int offset, int length) { + if (length < HEADER_SIZE || length > MAX_SERIALIZED_SIZE) { + throw new ParquetDecodingException("Invalid FSST symbol table body size: " + length); + } + int symbolCount = body[offset] & 0xFF; + int[] histogram = new int[MAX_SYMBOL_LENGTH]; + int histogramSum = 0; + int expectedSymbolBytes = 0; + for (int i = 0; i < MAX_SYMBOL_LENGTH; i++) { + histogram[i] = body[offset + 1 + i] & 0xFF; + histogramSum += histogram[i]; + expectedSymbolBytes += histogram[i] * (i + 1); + } + // Validate before allocating, so a corrupt header cannot ask for a large buffer. + if (histogramSum != symbolCount) { + throw new ParquetDecodingException("FSST length histogram sums to " + histogramSum + + " but the table declares " + symbolCount + " symbols"); + } + if (expectedSymbolBytes != length - HEADER_SIZE) { + throw new ParquetDecodingException("FSST symbol bytes are " + (length - HEADER_SIZE) + + " bytes but the length histogram accounts for " + expectedSymbolBytes); + } + + byte[] symbolBytes = new byte[expectedSymbolBytes]; + System.arraycopy(body, offset + HEADER_SIZE, symbolBytes, 0, expectedSymbolBytes); + int[] symbolOffsets = new int[symbolCount + 1]; + int code = 0; + int position = 0; + for (int length1 = 1; length1 <= MAX_SYMBOL_LENGTH; length1++) { + for (int i = 0; i < histogram[length1 - 1]; i++) { + symbolOffsets[code++] = position; + position += length1; + } + } + symbolOffsets[symbolCount] = position; + return new Fsst8SymbolTable(symbolBytes, symbolOffsets, symbolCount, null); + } + + @Override + public SymbolTableType type() { + return SymbolTableType.FSST_8; + } + + @Override + public int symbolCount() { + return symbolCount; + } + + @Override + public int symbolLength(int code) { + return symbolOffsets[code + 1] - symbolOffsets[code]; + } + + @Override + public int copySymbol(int code, byte[] destination, int position) { + int length = symbolLength(code); + System.arraycopy(symbolBytes, symbolOffsets[code], destination, position, length); + return length; + } + + @Override + public BytesInput serialize() { + int[] histogram = new int[MAX_SYMBOL_LENGTH]; + for (int code = 0; code < symbolCount; code++) { + histogram[symbolLength(code) - 1]++; + } + byte[] body = new byte[HEADER_SIZE + symbolBytes.length]; + body[0] = (byte) symbolCount; + for (int i = 0; i < MAX_SYMBOL_LENGTH; i++) { + body[1 + i] = (byte) histogram[i]; + } + System.arraycopy(symbolBytes, 0, body, HEADER_SIZE, symbolBytes.length); + return BytesInput.from(body); + } + + @Override + public CodeStreamDecoder decoder() { + return new Fsst8CodeStreamDecoder(symbolBytes, symbolOffsets, symbolCount); + } + + /** The trainer-code to file-code map, for the encoder paired with this table. */ + byte[] encodeCodeMap() { + if (encodeCodeMap == null) { + throw new IllegalStateException("a symbol table read from a file cannot encode"); + } + return encodeCodeMap; + } +} diff --git a/parquet-column/src/main/java/org/apache/parquet/column/values/symboltable/fsst/FsstCodes.java b/parquet-column/src/main/java/org/apache/parquet/column/values/symboltable/fsst/FsstCodes.java new file mode 100644 index 0000000000..05b629b993 --- /dev/null +++ b/parquet-column/src/main/java/org/apache/parquet/column/values/symboltable/fsst/FsstCodes.java @@ -0,0 +1,161 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.parquet.column.values.symboltable.fsst; + +/** + * Constants and bit-field helpers for the FSST symbol representation. + * + *

This file is a Java port of parts of the FSST reference implementation by Peter Boncz, + * Viktor Leis and Thomas Neumann (CWI / TU Munich), distributed under the MIT license and + * available at https://github.com/cwida/fsst. The port follows commit + * 89f49c580c6388acf3b6ed2a49e1bfde6c05e616. Encoding decisions are reproduced exactly, because + * the symbol table a writer produces has to match what other implementations produce for the + * same input. + * + *

A symbol is a byte sequence of length 1 to 8, held as a little-endian {@code long} together + * with a packed descriptor called {@code icl}: {@code (length << 28) | (code << 16) | + * ignoredBits}, where {@code ignoredBits} is {@code (8 - length) * 8}. The descriptor is kept in + * a {@code long} rather than an {@code int} on purpose: at length 8 the length field alone is + * {@code 8 << 28}, which overflows a signed 32-bit integer. Because every descriptor fits in 32 + * bits, signed {@code long} comparison of two descriptors matches the unsigned comparison the + * reference implementation performs. + */ +final class FsstCodes { + + private FsstCodes() {} + + /** Maximum number of bytes in one symbol. */ + static final int MAX_SYMBOL_LENGTH = 8; + + /** Width of the length field in the packed values held by the code lookup tables. */ + static final int LEN_BITS = 12; + + static final int CODE_BITS = 9; + + /** Codes below this value are pseudo codes standing for an escaped single byte. */ + static final int CODE_BASE = 256; + + /** One past the highest representable code; also marks a symbol with no code assigned yet. */ + static final int CODE_MAX = 1 << CODE_BITS; + + static final int CODE_MASK = CODE_MAX - 1; + + /** Number of buckets in the symbol hash table, which holds symbols of three bytes or more. */ + static final int HASH_TAB_SIZE = 1 << 10; + + /** Descriptor value marking a free hash bucket: length 15 and an unassigned code. */ + static final long ICL_FREE = (15L << 28) | ((long) CODE_MASK << 16); + + /** Highest number of symbols a table may hold, so that every code fits in one byte. */ + static final int MAX_SYMBOLS = 255; + + private static final long HASH_PRIME = 2971215073L; + private static final int HASH_SHIFT = 15; + + /** + * The reference implementation's hash, used both for symbol lookup and to drive the sampler. + * + *

The shift must be unsigned. For symbol lookup the input is the next three bytes, so the + * product cannot reach the sign bit and the distinction does not arise; the sampler chains this + * function over full 64-bit values, where a signed shift would send the whole sequence down a + * different path and produce a different symbol table. + */ + static long hash(long w) { + long product = w * HASH_PRIME; + return product ^ (product >>> HASH_SHIFT); + } + + /** Packs a symbol descriptor from a code and a length. */ + static long icl(int code, int length) { + return ((long) length << 28) | ((long) code << 16) | ((long) (MAX_SYMBOL_LENGTH - length) * 8); + } + + static int length(long icl) { + return (int) (icl >>> 28); + } + + static int code(long icl) { + return (int) ((icl >>> 16) & CODE_MASK); + } + + /** Number of high bits to clear in an input word before comparing it against a symbol. */ + static int ignoredBits(long icl) { + return (int) (icl & 0xFF); + } + + /** The symbol's first byte, as an unsigned value. */ + static int first(long val) { + return (int) (val & 0xFF); + } + + /** The symbol's first two bytes, as an unsigned value. */ + static int first2(long val) { + return (int) (val & 0xFFFF); + } + + /** Hash bucket for a symbol, keyed on its first three bytes. */ + static int hashBucket(long val) { + return (int) (hash(val & 0xFFFFFF) & (HASH_TAB_SIZE - 1)); + } + + /** Builds the little-endian word for the first {@code length} bytes at {@code offset}. */ + static long loadSymbolBytes(byte[] src, int offset, int length) { + long val = 0; + for (int i = 0; i < length; i++) { + val |= (long) (src[offset + i] & 0xFF) << (i * 8); + } + return val; + } + + /** + * Reads eight bytes little-endian, substituting zeros past {@code limit}. + * + *

The reference implementation reads eight bytes unconditionally and relies on the input + * buffer being padded. Java cannot read out of bounds, so the tail is zero-filled here; callers + * that must reproduce the reference behaviour byte for byte pad their input instead of relying + * on this. + */ + static long loadWord(byte[] src, int offset, int limit) { + long val = 0; + int n = Math.min(MAX_SYMBOL_LENGTH, limit - offset); + for (int i = 0; i < n; i++) { + val |= (long) (src[offset + i] & 0xFF) << (i * 8); + } + return val; + } + + /** + * Reads eight bytes little-endian with no bounds reasoning beyond the array itself. + * + *

For the compressor's inner loop, which works out of a buffer padded past its logical end so + * that the read is always in bounds. A byte-array view is used rather than eight shifts because + * this is the single hottest read in the codec. + */ + static long loadWordUnchecked(byte[] src, int offset) { + return (long) LITTLE_ENDIAN_LONG.get(src, offset); + } + + private static final java.lang.invoke.VarHandle LITTLE_ENDIAN_LONG = + java.lang.invoke.MethodHandles.byteArrayViewVarHandle(long[].class, java.nio.ByteOrder.LITTLE_ENDIAN); + + /** Clears the high bits of {@code word} that a symbol with this descriptor ignores. */ + static long maskWord(long word, long icl) { + return word & (-1L >>> ignoredBits(icl)); + } +} diff --git a/parquet-column/src/main/java/org/apache/parquet/column/values/symboltable/fsst/FsstCounters.java b/parquet-column/src/main/java/org/apache/parquet/column/values/symboltable/fsst/FsstCounters.java new file mode 100644 index 0000000000..fca687996b --- /dev/null +++ b/parquet-column/src/main/java/org/apache/parquet/column/values/symboltable/fsst/FsstCounters.java @@ -0,0 +1,162 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.parquet.column.values.symboltable.fsst; + +import static org.apache.parquet.column.values.symboltable.fsst.FsstCodes.CODE_MAX; + +import java.util.Arrays; + +/** + * Occurrence counters used while training a symbol table: how often each symbol occurs, and how + * often each ordered pair of symbols occurs back to back. + * + *

Java port of the 64-bit {@code Counters} from the FSST reference implementation by Peter Boncz, + * Viktor Leis and Thomas Neumann (CWI / TU Munich), MIT licensed, at https://github.com/cwida/fsst, + * commit 89f49c580c6388acf3b6ed2a49e1bfde6c05e616. + * + *

Each counter is split into a low byte and a high part, and the pair counter's high part is only + * four bits wide, two counters to a byte. That is not only a space optimization to be simplified + * away: four bits saturate, so the split decides which candidate symbols survive a training round + * and therefore which table comes out. The reference implementation has a wider variant for 32-bit + * platforms that produces different tables; this follows the 64-bit variant, which is what other + * implementations run. + * + *

The high part is incremented when the low byte wraps from zero rather than when it saturates, + * which is what makes a nonzero high part equivalent to a nonzero count and lets the scans below + * skip runs of empty counters eight bytes at a time. + * + *

Those scans deliberately read past the counter they were asked about. The reference + * implementation lets the read spill into whichever array is laid out next; here each array carries + * eight bytes of zero padding instead. The two agree: a spilled read only changes the result if + * every valid counter it covers is zero, and in that case the scan has already advanced past the end + * of the range and returns zero either way. + */ +final class FsstCounters { + + /** Slack for scans that read eight bytes from the last counter. */ + private static final int PADDING = 8; + + private final byte[] count1High = new byte[CODE_MAX + PADDING]; + private final byte[] count1Low = new byte[CODE_MAX + PADDING]; + private final byte[] count2High = new byte[CODE_MAX * (CODE_MAX / 2) + PADDING]; + private final byte[] count2Low = new byte[CODE_MAX * CODE_MAX + PADDING]; + + /** Position reached by the most recent scan, which the caller must adopt. */ + private int scanPosition; + + void reset() { + Arrays.fill(count1High, (byte) 0); + Arrays.fill(count1Low, (byte) 0); + Arrays.fill(count2High, (byte) 0); + Arrays.fill(count2Low, (byte) 0); + } + + void count1Set(int position, int value) { + count1Low[position] = (byte) (value & 255); + count1High[position] = (byte) (value >>> 8); + } + + void count1Increment(int position) { + // Post-increment on a byte array wraps, and yields the value before the increment, so this + // raises the high part exactly when the low byte wraps back to zero. + if (count1Low[position]++ == 0) { + count1High[position]++; + } + } + + void count2Increment(int first, int second) { + if (count2Low[first * CODE_MAX + second]++ == 0) { + // Add one to the four-bit counter, in the low or the high nibble according to parity. + count2High[first * (CODE_MAX / 2) + (second >> 1)] += (byte) (1 << ((second & 1) << 2)); + } + } + + /** + * Reads the counter for a single symbol, skipping forward over empty counters. + * + * @return the count, or zero once the scan leaves the range. Either way the caller must adopt + * {@link #scanPosition()} as its new position. + */ + int count1Next(int position) { + long high = loadLittleEndianLong(count1High, position); + int zeroBytes = (high != 0) ? (Long.numberOfTrailingZeros(high) >>> 3) : 7; + high = (high >>> (zeroBytes << 3)) & 255; + position += zeroBytes; + scanPosition = position; + if (position >= CODE_MAX || high == 0) { + return 0; + } + int low = count1Low[position] & 0xFF; + if (low != 0) { + high--; // the high part was raised early + } + return (int) ((high << 8) + low); + } + + /** + * Reads the counter for a pair of symbols, skipping forward over empty counters. + * + * @return the count, or zero once the scan leaves the range. Either way the caller must adopt + * {@link #scanPosition()} as its new second position. + */ + int count2Next(int first, int second) { + long high = loadLittleEndianLong(count2High, first * (CODE_MAX / 2) + (second >> 1)); + high >>>= ((second & 1) << 2); // an odd position starts halfway into its byte + int zeroNibbles = (high != 0) ? (Long.numberOfTrailingZeros(high) >>> 2) : (15 - (second & 1)); + high = (high >>> (zeroNibbles << 2)) & 15; + second += zeroNibbles; + scanPosition = second; + if (second >= CODE_MAX || high == 0) { + return 0; + } + int low = count2Low[first * CODE_MAX + second] & 0xFF; + if (low != 0) { + high--; // the high part was raised early + } + return (int) ((high << 8) + low); + } + + int scanPosition() { + return scanPosition; + } + + /** Size of the buffer {@link #backupSingleCounts} needs. */ + static int backupSize() { + return 2 * CODE_MAX; + } + + /** Saves the single-symbol counters, so the best round's counts can be brought back. */ + void backupSingleCounts(byte[] buffer) { + System.arraycopy(count1High, 0, buffer, 0, CODE_MAX); + System.arraycopy(count1Low, 0, buffer, CODE_MAX, CODE_MAX); + } + + void restoreSingleCounts(byte[] buffer) { + System.arraycopy(buffer, 0, count1High, 0, CODE_MAX); + System.arraycopy(buffer, CODE_MAX, count1Low, 0, CODE_MAX); + } + + private static long loadLittleEndianLong(byte[] source, int offset) { + long value = 0; + for (int i = 0; i < 8; i++) { + value |= (long) (source[offset + i] & 0xFF) << (i * 8); + } + return value; + } +} diff --git a/parquet-column/src/main/java/org/apache/parquet/column/values/symboltable/fsst/FsstSymbolTable.java b/parquet-column/src/main/java/org/apache/parquet/column/values/symboltable/fsst/FsstSymbolTable.java new file mode 100644 index 0000000000..1ea80548f3 --- /dev/null +++ b/parquet-column/src/main/java/org/apache/parquet/column/values/symboltable/fsst/FsstSymbolTable.java @@ -0,0 +1,267 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.parquet.column.values.symboltable.fsst; + +import static org.apache.parquet.column.values.symboltable.fsst.FsstCodes.CODE_BASE; +import static org.apache.parquet.column.values.symboltable.fsst.FsstCodes.CODE_MASK; +import static org.apache.parquet.column.values.symboltable.fsst.FsstCodes.CODE_MAX; +import static org.apache.parquet.column.values.symboltable.fsst.FsstCodes.HASH_TAB_SIZE; +import static org.apache.parquet.column.values.symboltable.fsst.FsstCodes.ICL_FREE; +import static org.apache.parquet.column.values.symboltable.fsst.FsstCodes.LEN_BITS; +import static org.apache.parquet.column.values.symboltable.fsst.FsstCodes.MAX_SYMBOLS; +import static org.apache.parquet.column.values.symboltable.fsst.FsstCodes.MAX_SYMBOL_LENGTH; + +/** + * The mutable symbol table the FSST trainer builds and the compressor reads. + * + *

Java port of {@code SymbolTable} from the FSST reference implementation by Peter Boncz, + * Viktor Leis and Thomas Neumann (CWI / TU Munich), MIT licensed, at + * https://github.com/cwida/fsst, commit 89f49c580c6388acf3b6ed2a49e1bfde6c05e616. + * + *

Symbols are held in parallel {@code long} arrays rather than as objects. The reference + * implementation copies symbols by value throughout; parallel arrays reproduce that without the + * aliasing that an array of mutable objects would introduce, and keep the inner loops free of + * pointer chasing. The two code lookup tables use {@code char}, which is Java's only unsigned + * 16-bit type and so matches the reference implementation's {@code u16} without masking. + * + *

Two representations of a code appear here and must not be confused. The lookup tables hold + * {@code (length << 12) | code}; a symbol descriptor holds {@code (length << 28) | (code << 16) | + * ignoredBits}. + */ +final class FsstSymbolTable { + + /** Code for a 2-byte pattern, else the pseudo code for its escaped first byte. */ + final char[] shortCodes = new char[65536]; + + /** Code for a 1-byte symbol, else its escaped pseudo code. Not needed after {@link #finish}. */ + final char[] byteCodes = new char[256]; + + /** Symbol bytes, little-endian. Indices below {@link FsstCodes#CODE_BASE} are pseudo symbols. */ + final long[] symbolValues = new long[CODE_MAX]; + + /** Symbol descriptors, parallel to {@link #symbolValues}. */ + final long[] symbolDescriptors = new long[CODE_MAX]; + + /** Symbols of three bytes or more, replicated here to avoid an indirection. */ + final long[] hashValues = new long[HASH_TAB_SIZE]; + + final long[] hashDescriptors = new long[HASH_TAB_SIZE]; + + /** Count of symbols of each byte length, indexed by length minus one. */ + final int[] lengthHistogram = new int[FsstCodes.CODE_BITS]; + + int symbolCount; + + /** Codes at or above this value may have a longer suffix; only meaningful after {@link #finish}. */ + int suffixLimit = CODE_MAX; + + /** Codes at or above this value are one byte long; only meaningful after {@link #finish}. */ + int byteLimit; + + /** A 1-byte symbol usable as a separator during compression. */ + int terminator; + + FsstSymbolTable() { + for (int i = 0; i < 256; i++) { + // Pseudo symbols: one per byte value, standing for that byte escaped. + symbolValues[i] = i; + symbolDescriptors[i] = FsstCodes.icl(i | (1 << LEN_BITS), 1); + } + long unused = FsstCodes.icl(CODE_MASK, 1); + for (int i = 256; i < CODE_MAX; i++) { + symbolValues[i] = 0; + symbolDescriptors[i] = unused; + } + for (int i = 0; i < HASH_TAB_SIZE; i++) { + hashDescriptors[i] = ICL_FREE; + } + for (int i = 0; i < 256; i++) { + byteCodes[i] = (char) ((1 << LEN_BITS) | i); + } + for (int i = 0; i < 65536; i++) { + shortCodes[i] = (char) ((1 << LEN_BITS) | (i & 255)); + } + } + + /** Empties the table, touching only the positions that were used. */ + void clear() { + java.util.Arrays.fill(lengthHistogram, 0); + for (int i = CODE_BASE; i < CODE_BASE + symbolCount; i++) { + long val = symbolValues[i]; + int length = FsstCodes.length(symbolDescriptors[i]); + if (length == 1) { + int b = FsstCodes.first(val); + byteCodes[b] = (char) ((1 << LEN_BITS) | b); + } else if (length == 2) { + int b2 = FsstCodes.first2(val); + shortCodes[b2] = (char) ((1 << LEN_BITS) | (b2 & 255)); + } else { + int idx = FsstCodes.hashBucket(val); + hashValues[idx] = 0; + hashDescriptors[idx] = ICL_FREE; + } + } + symbolCount = 0; + } + + private boolean hashInsert(long val, long icl) { + int idx = FsstCodes.hashBucket(val); + if (hashDescriptors[idx] < ICL_FREE) { + return false; // bucket taken + } + hashDescriptors[idx] = icl; + hashValues[idx] = FsstCodes.maskWord(val, icl); + return true; + } + + /** Copies another table over this one, so the best round's table can be kept aside. */ + void copyFrom(FsstSymbolTable other) { + System.arraycopy(other.shortCodes, 0, shortCodes, 0, shortCodes.length); + System.arraycopy(other.byteCodes, 0, byteCodes, 0, byteCodes.length); + System.arraycopy(other.symbolValues, 0, symbolValues, 0, symbolValues.length); + System.arraycopy(other.symbolDescriptors, 0, symbolDescriptors, 0, symbolDescriptors.length); + System.arraycopy(other.hashValues, 0, hashValues, 0, hashValues.length); + System.arraycopy(other.hashDescriptors, 0, hashDescriptors, 0, hashDescriptors.length); + System.arraycopy(other.lengthHistogram, 0, lengthHistogram, 0, lengthHistogram.length); + symbolCount = other.symbolCount; + suffixLimit = other.suffixLimit; + byteLimit = other.byteLimit; + terminator = other.terminator; + } + + /** Adds a symbol, returning false when its hash bucket is already taken. */ + boolean add(long val, long icl) { + int length = FsstCodes.length(icl); + int code = CODE_BASE + symbolCount; + long descriptor = FsstCodes.icl(code, length); + if (length == 1) { + byteCodes[FsstCodes.first(val)] = (char) (code + (1 << LEN_BITS)); + } else if (length == 2) { + shortCodes[FsstCodes.first2(val)] = (char) (code + (2 << LEN_BITS)); + } else if (!hashInsert(val, descriptor)) { + return false; + } + symbolValues[code] = val; + symbolDescriptors[code] = descriptor; + symbolCount++; + lengthHistogram[length - 1]++; + return true; + } + + /** Returns the code of the longest symbol matching the input at {@code position}. */ + int findLongestSymbol(byte[] input, int position, int end) { + int length = Math.min(MAX_SYMBOL_LENGTH, end - position); + long val = FsstCodes.loadSymbolBytes(input, position, length); + long icl = FsstCodes.icl(CODE_MAX, length); + int idx = FsstCodes.hashBucket(val); + if (hashDescriptors[idx] <= icl && hashValues[idx] == FsstCodes.maskWord(val, hashDescriptors[idx])) { + return FsstCodes.code(hashDescriptors[idx]); + } + if (length >= 2) { + int code = shortCodes[FsstCodes.first2(val)] & CODE_MASK; + if (code >= CODE_BASE) { + return code; + } + } + return byteCodes[FsstCodes.first(val)] & CODE_MASK; + } + + /** + * Renumbers codes into a single byte each and groups symbols by length. + * + *

Named {@code finalize} in the reference implementation; renamed here because that name is + * reserved on {@link Object}. Afterwards real codes occupy {@code [0, symbolCount)} grouped by + * length as 2,3,4,5,6,7,8 then 1, two-byte symbols with no longer suffix come first so the + * compressor can take a shortcut, escapes in {@link #shortCodes} carry the eighth bit, and + * {@link #byteCodes} is folded into {@link #shortCodes} so the compressor never consults it. + * + *

The reference implementation also supports zero-terminated input, which Parquet never + * produces because values carry an explicit length. That mode is fixed off here, so terms that + * depend on it fall away. + */ + void finish() { + if (symbolCount > MAX_SYMBOLS) { + throw new IllegalStateException("FSST symbol table holds " + symbolCount + " symbols, at most " + + MAX_SYMBOLS + " can be renumbered into one byte each"); + } + int[] newCode = new int[256]; + int[] runningSum = new int[8]; + byteLimit = symbolCount - lengthHistogram[0]; + + runningSum[0] = byteLimit; // 1-byte codes sort highest + runningSum[1] = 0; + for (int i = 1; i < 7; i++) { + runningSum[i + 1] = runningSum[i] + lengthHistogram[i]; + } + + suffixLimit = runningSum[1]; + newCode[0] = 0; + symbolValues[0] = symbolValues[CODE_BASE]; + symbolDescriptors[0] = symbolDescriptors[CODE_BASE]; + + for (int i = 0, j = runningSum[2]; i < symbolCount; i++) { + long val = symbolValues[CODE_BASE + i]; + int length = FsstCodes.length(symbolDescriptors[CODE_BASE + i]); + // For 2-byte symbols, scan for a longer symbol sharing the same first two bytes. The scan + // bound doubles as the answer: clearing it both records the find and ends the loop, exactly + // as the reference implementation does. + int scan = (length == 2) ? symbolCount : 0; + if (scan != 0) { + int first2 = FsstCodes.first2(val); + for (int k = 0; k < scan; k++) { + long otherIcl = symbolDescriptors[CODE_BASE + k]; + if (k != i + && FsstCodes.length(otherIcl) > 1 + && first2 == FsstCodes.first2(symbolValues[CODE_BASE + k])) { + scan = 0; + } + } + newCode[i] = (scan != 0) ? suffixLimit++ : --j; + } else { + newCode[i] = runningSum[length - 1]++; + } + symbolValues[newCode[i]] = val; + symbolDescriptors[newCode[i]] = FsstCodes.icl(newCode[i], length); + } + + for (int i = 0; i < 256; i++) { + if ((byteCodes[i] & CODE_MASK) >= CODE_BASE) { + byteCodes[i] = (char) (newCode[byteCodes[i] & 0xFF] + (1 << LEN_BITS)); + } else { + byteCodes[i] = (char) (511 + (1 << LEN_BITS)); + } + } + + for (int i = 0; i < 65536; i++) { + if ((shortCodes[i] & CODE_MASK) >= CODE_BASE) { + shortCodes[i] = (char) (newCode[shortCodes[i] & 0xFF] + (shortCodes[i] & (15 << LEN_BITS))); + } else { + shortCodes[i] = byteCodes[i & 0xFF]; + } + } + + for (int i = 0; i < HASH_TAB_SIZE; i++) { + if (hashDescriptors[i] < ICL_FREE) { + int renumbered = newCode[FsstCodes.code(hashDescriptors[i]) & 0xFF]; + hashValues[i] = symbolValues[renumbered]; + hashDescriptors[i] = symbolDescriptors[renumbered]; + } + } + } +} diff --git a/parquet-column/src/main/java/org/apache/parquet/column/values/symboltable/fsst/FsstTrainer.java b/parquet-column/src/main/java/org/apache/parquet/column/values/symboltable/fsst/FsstTrainer.java new file mode 100644 index 0000000000..1bde3d3dc5 --- /dev/null +++ b/parquet-column/src/main/java/org/apache/parquet/column/values/symboltable/fsst/FsstTrainer.java @@ -0,0 +1,410 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.parquet.column.values.symboltable.fsst; + +import static org.apache.parquet.column.values.symboltable.fsst.FsstCodes.CODE_BASE; +import static org.apache.parquet.column.values.symboltable.fsst.FsstCodes.CODE_MASK; +import static org.apache.parquet.column.values.symboltable.fsst.FsstCodes.CODE_MAX; +import static org.apache.parquet.column.values.symboltable.fsst.FsstCodes.ICL_FREE; +import static org.apache.parquet.column.values.symboltable.fsst.FsstCodes.MAX_SYMBOLS; +import static org.apache.parquet.column.values.symboltable.fsst.FsstCodes.MAX_SYMBOL_LENGTH; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import org.apache.parquet.column.values.symboltable.SymbolTableTrainer; +import org.apache.parquet.column.values.symboltable.SymbolTableType; +import org.apache.parquet.column.values.symboltable.TrainedSymbolTable; +import org.apache.parquet.column.values.symboltable.ValueBuffer; + +/** + * Chooses an FSST symbol table for a set of values. + * + *

Java port of the training half of the FSST reference implementation by Peter Boncz, Viktor Leis + * and Thomas Neumann (CWI / TU Munich), MIT licensed, at https://github.com/cwida/fsst, commit + * 89f49c580c6388acf3b6ed2a49e1bfde6c05e616. The algorithm is reproduced rather than reinterpreted: + * a writer that mines symbols differently still produces readable files, but it produces different + * tables, and then the compression ratio can no longer be compared against another implementation + * to tell a correct port from a subtly broken one. + * + *

Training runs five rounds over a sample of the data. Each round compresses the sample with the + * table built so far, counting how often each symbol occurs and how often each ordered pair of + * symbols occurs back to back; frequent pairs are then concatenated into candidate symbols, scored + * by how many bytes they would save, and the best 255 become the next round's table. Rounds use a + * growing fraction of the sample, and the table kept at the end is the round that scored best rather + * than the last one. + * + *

One departure is deliberate and is documented at {@link #CANDIDATE_ORDER}. + */ +public class FsstTrainer implements SymbolTableTrainer { + + /** Bytes of sample the trainer aims for. */ + static final int SAMPLE_TARGET = 1 << 14; + + /** Worst-case sample size, which also bounds the score of a round. */ + static final int SAMPLE_MAX_SIZE = 2 * SAMPLE_TARGET; + + /** Length of one run of bytes taken from a value into the sample. */ + static final int SAMPLE_LINE = 512; + + /** Seed of the sampler's hash chain, from the reference implementation. */ + private static final long SAMPLE_SEED = 4637947; + + /** + * Order candidates are offered to the table in: best score first, and among equal scores the + * numerically smaller symbol, then the shorter one. + * + *

The reference implementation collects candidates in a hash set and drains them through a + * heap, so when two candidates tie on both score and numeric value the winner depends on the hash + * set's iteration order, which its own standard library does not fix. That last tie is possible, + * because a shorter symbol whose trailing bytes are zero has the same numeric value as a longer + * one. Breaking it on length makes this port's output a function of the input alone, which is what + * lets a table be compared against another implementation's at all. Any table this produces is + * readable by any reader; only the tie-breaking rule would have to be agreed to make two writers + * agree byte for byte. + */ + private static final Comparator CANDIDATE_ORDER = Comparator.comparingLong( + (Candidate candidate) -> candidate.gain) + .reversed() + .thenComparing((left, right) -> Long.compareUnsigned(left.value, right.value)) + .thenComparingInt(candidate -> candidate.length); + + private final FsstCounters counters = new FsstCounters(); + private final byte[] bestCounters = new byte[FsstCounters.backupSize()]; + private final Map candidates = new HashMap<>(); + private final ValueBuffer sample = new ValueBuffer(SAMPLE_MAX_SIZE, SAMPLE_MAX_SIZE / SAMPLE_LINE); + + /** Fraction of the sample, out of 128, that the current round compresses. */ + private int sampleFraction; + + @Override + public SymbolTableType type() { + return SymbolTableType.FSST_8; + } + + @Override + public TrainedSymbolTable train(ValueBuffer values) { + FsstSymbolTable table = buildSymbolTable(makeSample(values)); + Fsst8SymbolTable fileTable = Fsst8SymbolTable.of(table); + return new TrainedSymbolTable(fileTable, new Fsst8CodeStreamEncoder(table, fileTable)); + } + + /** + * Draws a sample of at least {@link #SAMPLE_TARGET} bytes as runs taken from randomly chosen + * values, or returns the values themselves when there are fewer bytes than that. + * + *

The choice of runs is driven by a chain of the same hash the symbol lookup uses, seeded by a + * constant, so it is already reproducible and is ported as it stands. Replacing it with a + * different sampler, however reasonable, would change the table for the same input. + */ + private ValueBuffer makeSample(ValueBuffer values) { + int valueCount = values.valueCount(); + if (valueCount == 0 || values.byteCount() < SAMPLE_TARGET) { + return values; + } + sample.reset(); + long random = FsstCodes.hash(SAMPLE_SEED); + int lineLimit = valueCount + SAMPLE_MAX_SIZE / SAMPLE_LINE; + while (sample.byteCount() < SAMPLE_TARGET && sample.valueCount() < lineLimit) { + // Choose a value, skipping forward over empty ones. + random = FsstCodes.hash(random); + int index = (int) Long.remainderUnsigned(random, valueCount); + while (values.length(index) == 0) { + if (++index == valueCount) { + index = 0; + } + } + // Choose one of its runs. + int runCount = 1 + ((values.length(index) - 1) / SAMPLE_LINE); + random = FsstCodes.hash(random); + int runStart = SAMPLE_LINE * (int) Long.remainderUnsigned(random, runCount); + int runLength = Math.min(values.length(index) - runStart, SAMPLE_LINE); + sample.add(values.data(), values.offset(index) + runStart, runLength); + } + return sample; + } + + private FsstSymbolTable buildSymbolTable(ValueBuffer sampleValues) { + FsstSymbolTable table = new FsstSymbolTable(); + FsstSymbolTable best = new FsstSymbolTable(); + int bestGain = -SAMPLE_MAX_SIZE; // the score if every byte had to be escaped + table.terminator = chooseTerminator(sampleValues); + + for (sampleFraction = 8; ; sampleFraction += 30) { + counters.reset(); + int gain = compressCount(table, sampleValues); + if (gain >= bestGain) { + counters.backupSingleCounts(bestCounters); + best.copyFrom(table); + bestGain = gain; + } + if (sampleFraction >= 128) { + break; // five rounds, at fractions 8, 38, 68, 98 and 128 + } + makeTable(table); + } + + // Rebuild the best round's table from the counts that round produced. The fraction is 128 here, + // so this pass only re-ranks the symbols it already has and creates no new ones. + counters.restoreSingleCounts(bestCounters); + makeTable(best); + best.finish(); + return best; + } + + /** + * Picks the least frequent byte as the terminator, preferring the lowest such byte. + * + *

The terminator is appended to each run the compressor works on, which is what lets the + * compressor read eight bytes at a time without a bounds check: a symbol containing the + * terminator is never in the table, so a match cannot run past the end of the value. + */ + private static int chooseTerminator(ValueBuffer values) { + int[] byteHistogram = new int[256]; + byte[] data = values.data(); + for (int i = 0; i < values.valueCount(); i++) { + int end = values.offset(i) + values.length(i); + for (int position = values.offset(i); position < end; position++) { + byteHistogram[data[position] & 0xFF]++; + } + } + int terminator = 256; + int minimum = SAMPLE_MAX_SIZE; + for (int i = 255; i >= 0; i--) { + if (byteHistogram[i] > minimum) { + continue; + } + terminator = i; + minimum = byteHistogram[i]; + } + return terminator; + } + + /** + * Compresses the sample with the table as it stands, counting symbols and symbol pairs, and + * returns the number of bytes the table saves over escaping everything. + */ + private int compressCount(FsstSymbolTable table, ValueBuffer values) { + int gain = 0; + for (int index = 0; index < values.valueCount(); index++) { + int position = values.offset(index); + int end = position + values.length(index); + if (sampleFraction < 128 && randomFraction(index) > sampleFraction) { + continue; // earlier rounds skip most of the sample, which roughly halves the work + } + if (position >= end) { + continue; + } + byte[] data = values.data(); + int start = position; + int code1 = table.findLongestSymbol(data, position, end); + position += FsstCodes.length(table.symbolDescriptors[code1]); + gain += FsstCodes.length(table.symbolDescriptors[code1]) - (1 + escapeCost(code1)); + while (true) { + // Count the symbol as it stands, that is, the option of not extending it. + counters.count1Increment(code1); + // As an alternative, count just its first byte, unless that is the same thing. + if (FsstCodes.length(table.symbolDescriptors[code1]) != 1) { + counters.count1Increment(data[start] & 0xFF); + } + if (position == end) { + break; + } + + start = position; + int code2; + if (position < end - 7) { + // Eight bytes are available, so the three lookups can be done without bounds checks. + long word = FsstCodes.loadSymbolBytes(data, position, MAX_SYMBOL_LENGTH); + int bucket = FsstCodes.hashBucket(word); + long bucketDescriptor = table.hashDescriptors[bucket]; + code2 = table.shortCodes[FsstCodes.first2(word)] & CODE_MASK; + word = FsstCodes.maskWord(word, bucketDescriptor); + if (bucketDescriptor < ICL_FREE && table.hashValues[bucket] == word) { + code2 = FsstCodes.code(bucketDescriptor); + position += FsstCodes.length(bucketDescriptor); + } else if (code2 >= CODE_BASE) { + position += 2; + } else { + code2 = table.byteCodes[FsstCodes.first(word)] & CODE_MASK; + position += 1; + } + } else { + code2 = table.findLongestSymbol(data, position, end); + position += FsstCodes.length(table.symbolDescriptors[code2]); + } + + gain += (position - start) - (1 + escapeCost(code2)); + + if (sampleFraction < 128) { // the last round does not need pair counts + // Count the pair, that is, the option of concatenating the two symbols. + counters.count2Increment(code1, code2); + // As an alternative, count extending by just the next byte, unless that is the same thing. + if ((position - start) > 1) { + counters.count2Increment(code1, data[start] & 0xFF); + } + } + code1 = code2; + } + } + return gain; + } + + /** A value between 1 and 128, fixed for a given value index and round. */ + private int randomFraction(int index) { + return 1 + (int) (FsstCodes.hash((long) (index + 1) * sampleFraction) & 127); + } + + /** Cost in bytes a code adds beyond the one byte it always costs: one more if it escapes. */ + private static int escapeCost(int code) { + return code < CODE_BASE ? 1 : 0; + } + + /** + * Replaces the table with the best-scoring candidates from the counts just gathered. + * + *

Candidates are the symbols already in the table, the concatenation of each counted pair, and + * each symbol extended by one byte. Single-byte symbols are scored eight times higher than their + * frequency warrants, which the reference implementation notes both lowers the escape rate and + * speeds up compression and decompression. + */ + private void makeTable(FsstSymbolTable table) { + candidates.clear(); + + // Force the terminator into the table by making it look like the most frequent symbol. + int terminatorPosition = table.symbolCount != 0 ? CODE_BASE : table.terminator; + counters.count1Set(terminatorPosition, 65535); + + int positionLimit = CODE_BASE + table.symbolCount; + for (int pos1 = 0; pos1 < positionLimit; pos1++) { + int count1 = counters.count1Next(pos1); + pos1 = counters.scanPosition(); // the scan skips empty counters + if (count1 == 0) { + continue; + } + long value1 = table.symbolValues[pos1]; + int length1 = FsstCodes.length(table.symbolDescriptors[pos1]); + addOrIncrement(value1, length1, (length1 == 1 ? 8L : 1L) * count1); + + if (sampleFraction >= 128 // the last round does not create new symbols + || length1 == MAX_SYMBOL_LENGTH // this symbol cannot be extended + || FsstCodes.first(value1) == table.terminator) { // and none may contain the terminator + continue; + } + for (int pos2 = 0; pos2 < positionLimit; pos2++) { + int count2 = counters.count2Next(pos1, pos2); + pos2 = counters.scanPosition(); + if (count2 == 0) { + continue; + } + long value2 = table.symbolValues[pos2]; + if (FsstCodes.first(value2) != table.terminator) { + int length2 = FsstCodes.length(table.symbolDescriptors[pos2]); + addOrIncrement(concatenate(value1, length1, value2), concatenatedLength(length1, length2), count2); + } + } + } + + List ranked = new ArrayList<>(candidates.values()); + ranked.sort(CANDIDATE_ORDER); + table.clear(); + for (Candidate candidate : ranked) { + if (table.symbolCount >= MAX_SYMBOLS) { + break; + } + // A candidate whose hash bucket is taken is dropped rather than retried, as upstream does. + table.add(candidate.value, FsstCodes.icl(CODE_MASK, candidate.length)); + } + } + + /** + * Records a candidate symbol, or adds to the score of one already recorded. + * + *

Rare candidates are dropped, on a threshold that grows with the round. Upstream notes this + * improves the compression ratio as well as the speed of training. + */ + private void addOrIncrement(long value, int length, long count) { + if (count < (5L * sampleFraction) / 128) { + return; + } + Candidate candidate = new Candidate(value, length); + Candidate existing = candidates.get(candidate); + if (existing != null) { + existing.gain += count * length; + } else { + candidate.gain = count * length; + candidates.put(candidate, candidate); + } + } + + /** + * Joins two symbols into one, truncated to the maximum symbol length. + * + *

The shift is safe because a symbol already at the maximum length is never extended, so + * {@code firstLength} is at most seven here. That matters more in Java than in C: a shift count of + * 64 would be taken modulo 64 and quietly leave the value unshifted. + */ + private static long concatenate(long first, int firstLength, long second) { + return (second << (8 * firstLength)) | first; + } + + private static int concatenatedLength(int firstLength, int secondLength) { + return Math.min(firstLength + secondLength, MAX_SYMBOL_LENGTH); + } + + /** + * A symbol under consideration and the bytes it would save. + * + *

Identity is the symbol, not the score, so that repeated proposals of the same symbol + * accumulate. Hashing on the value alone matches upstream and stays consistent with equality. + */ + private static final class Candidate { + private final long value; + private final int length; + private long gain; + + Candidate(long value, int length) { + this.value = value; + this.length = length; + } + + @Override + public boolean equals(Object other) { + if (!(other instanceof Candidate)) { + return false; + } + Candidate that = (Candidate) other; + return value == that.value && length == that.length; + } + + @Override + public int hashCode() { + return Long.hashCode(value); + } + } + + static { + // Guards the assumption the position arithmetic in makeTable relies on. + if (CODE_BASE + MAX_SYMBOLS >= CODE_MAX) { + throw new AssertionError("symbol positions must stay below " + CODE_MAX); + } + } +} diff --git a/parquet-column/src/test/java/org/apache/parquet/column/TestParquetProperties.java b/parquet-column/src/test/java/org/apache/parquet/column/TestParquetProperties.java index 6d51f67cb0..109ef64375 100644 --- a/parquet-column/src/test/java/org/apache/parquet/column/TestParquetProperties.java +++ b/parquet-column/src/test/java/org/apache/parquet/column/TestParquetProperties.java @@ -24,6 +24,8 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; +import org.apache.parquet.column.values.symboltable.SymbolTablePayload.OffsetEncoding; +import org.apache.parquet.column.values.symboltable.SymbolTableType; import org.apache.parquet.schema.MessageType; import org.apache.parquet.schema.MessageTypeParser; import org.junit.jupiter.api.BeforeEach; @@ -122,6 +124,62 @@ public void withCompressionCodec_nullCodec_throwsNullPointerException() { .hasMessage("codec cannot be null"); } + @Test + public void fsst_isOffUnlessAskedFor() { + ParquetProperties props = ParquetProperties.builder().build(); + assertThat(props.isFsstEnabled(colA)).isFalse(); + assertThat(props.getSymbolTableType(colA)).isNull(); + } + + @Test + public void fsst_appliesToBinaryColumnsOnly() { + ParquetProperties props = + ParquetProperties.builder().withFsstEncoding(true).build(); + assertThat(props.getSymbolTableType(colA)).isEqualTo(SymbolTableType.FSST_8); + assertThat(props.isFsstEnabled(colB)).isFalse(); + assertThat(props.isFsstEnabled(colC)).isFalse(); + } + + @Test + public void fsst_canBeSetForOneColumn() { + ParquetProperties props = + ParquetProperties.builder().withFsstEncoding("col_a", true).build(); + assertThat(props.isFsstEnabled(colA)).isTrue(); + assertThat(ParquetProperties.builder() + .withFsstEncoding(true) + .withFsstEncoding("col_a", false) + .build() + .isFsstEnabled(colA)) + .isFalse(); + } + + @Test + public void symbolTableOffsets_areDeltaPackedUnlessAskedOtherwise() { + assertThat(ParquetProperties.builder().build().getSymbolTableOffsetEncoding()) + .isEqualTo(OffsetEncoding.DELTA_BINARY_PACKED); + assertThat(ParquetProperties.builder() + .withSymbolTableOffsetEncoding(OffsetEncoding.PLAIN) + .build() + .getSymbolTableOffsetEncoding()) + .isEqualTo(OffsetEncoding.PLAIN); + assertThatThrownBy(() -> ParquetProperties.builder().withSymbolTableOffsetEncoding(null)) + .isInstanceOf(NullPointerException.class) + .hasMessage("offsetEncoding cannot be null"); + } + + @Test + public void copyBuilder_preservesSymbolTableSettings() { + ParquetProperties original = ParquetProperties.builder() + .withFsstEncoding("col_a", true) + .withSymbolTableOffsetEncoding(OffsetEncoding.PLAIN) + .build(); + + ParquetProperties copy = ParquetProperties.copy(original).build(); + + assertThat(copy.isFsstEnabled(colA)).isTrue(); + assertThat(copy.getSymbolTableOffsetEncoding()).isEqualTo(OffsetEncoding.PLAIN); + } + @Test public void copyBuilder_preservesColumnCodecAndLevel() { ParquetProperties original = ParquetProperties.builder() diff --git a/parquet-column/src/test/java/org/apache/parquet/column/page/mem/MemPageReader.java b/parquet-column/src/test/java/org/apache/parquet/column/page/mem/MemPageReader.java index c44a7b644c..0805c4c5cb 100644 --- a/parquet-column/src/test/java/org/apache/parquet/column/page/mem/MemPageReader.java +++ b/parquet-column/src/test/java/org/apache/parquet/column/page/mem/MemPageReader.java @@ -23,6 +23,7 @@ import org.apache.parquet.column.page.DataPage; import org.apache.parquet.column.page.DictionaryPage; import org.apache.parquet.column.page.PageReader; +import org.apache.parquet.column.page.SymbolTablePage; import org.apache.parquet.io.ParquetDecodingException; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -33,12 +34,22 @@ public class MemPageReader implements PageReader { private final long totalValueCount; private final Iterator pages; private final DictionaryPage dictionaryPage; + private final SymbolTablePage symbolTablePage; public MemPageReader(long totalValueCount, Iterator pages, DictionaryPage dictionaryPage) { + this(totalValueCount, pages, dictionaryPage, null); + } + + public MemPageReader( + long totalValueCount, + Iterator pages, + DictionaryPage dictionaryPage, + SymbolTablePage symbolTablePage) { super(); this.pages = Objects.requireNonNull(pages, "pages cannot be null"); this.totalValueCount = totalValueCount; this.dictionaryPage = dictionaryPage; + this.symbolTablePage = symbolTablePage; } @Override @@ -61,4 +72,9 @@ public DataPage readPage() { public DictionaryPage readDictionaryPage() { return dictionaryPage; } + + @Override + public SymbolTablePage readSymbolTablePage() { + return symbolTablePage; + } } diff --git a/parquet-column/src/test/java/org/apache/parquet/column/page/mem/MemPageStore.java b/parquet-column/src/test/java/org/apache/parquet/column/page/mem/MemPageStore.java index f5b66fd88b..8981371222 100644 --- a/parquet-column/src/test/java/org/apache/parquet/column/page/mem/MemPageStore.java +++ b/parquet-column/src/test/java/org/apache/parquet/column/page/mem/MemPageStore.java @@ -62,7 +62,11 @@ public PageReader getPageReader(ColumnDescriptor descriptor) { } List pages = new ArrayList<>(pageWriter.getPages()); LOG.debug("initialize page reader with {} values and {} pages", pageWriter.getTotalValueCount(), pages.size()); - return new MemPageReader(pageWriter.getTotalValueCount(), pages.iterator(), pageWriter.getDictionaryPage()); + return new MemPageReader( + pageWriter.getTotalValueCount(), + pages.iterator(), + pageWriter.getDictionaryPage(), + pageWriter.getSymbolTablePage()); } @Override diff --git a/parquet-column/src/test/java/org/apache/parquet/column/page/mem/MemPageWriter.java b/parquet-column/src/test/java/org/apache/parquet/column/page/mem/MemPageWriter.java index 1594c119fd..8a4710d2e4 100644 --- a/parquet-column/src/test/java/org/apache/parquet/column/page/mem/MemPageWriter.java +++ b/parquet-column/src/test/java/org/apache/parquet/column/page/mem/MemPageWriter.java @@ -30,6 +30,7 @@ import org.apache.parquet.column.page.DataPageV2; import org.apache.parquet.column.page.DictionaryPage; import org.apache.parquet.column.page.PageWriter; +import org.apache.parquet.column.page.SymbolTablePage; import org.apache.parquet.column.statistics.SizeStatistics; import org.apache.parquet.column.statistics.Statistics; import org.apache.parquet.column.statistics.geospatial.GeospatialStatistics; @@ -42,6 +43,7 @@ public class MemPageWriter implements PageWriter { private final List pages = new ArrayList<>(); private DictionaryPage dictionaryPage; + private SymbolTablePage symbolTablePage; private long memSize = 0; private long totalValueCount = 0; @@ -157,6 +159,10 @@ public DictionaryPage getDictionaryPage() { return dictionaryPage; } + public SymbolTablePage getSymbolTablePage() { + return symbolTablePage; + } + public long getTotalValueCount() { return totalValueCount; } @@ -180,6 +186,15 @@ public void writeDictionaryPage(DictionaryPage dictionaryPage) throws IOExceptio dictionaryPage.getDictionarySize()); } + @Override + public void writeSymbolTablePage(SymbolTablePage symbolTablePage) throws IOException { + if (this.symbolTablePage != null) { + throw new ParquetEncodingException("Only one symbol table page per block"); + } + this.memSize += symbolTablePage.getBytes().size(); + this.symbolTablePage = symbolTablePage.copy(); + } + @Override public String memUsageString(String prefix) { return String.format("%s %,d bytes", prefix, memSize); diff --git a/parquet-column/src/test/java/org/apache/parquet/column/values/factory/DefaultValuesWriterFactoryTest.java b/parquet-column/src/test/java/org/apache/parquet/column/values/factory/DefaultValuesWriterFactoryTest.java index 436c13834f..c242f1c96e 100644 --- a/parquet-column/src/test/java/org/apache/parquet/column/values/factory/DefaultValuesWriterFactoryTest.java +++ b/parquet-column/src/test/java/org/apache/parquet/column/values/factory/DefaultValuesWriterFactoryTest.java @@ -48,6 +48,7 @@ import org.apache.parquet.column.values.plain.FixedLenByteArrayPlainValuesWriter; import org.apache.parquet.column.values.plain.PlainValuesWriter; import org.apache.parquet.column.values.rle.RunLengthBitPackingHybridValuesWriter; +import org.apache.parquet.column.values.symboltable.SymbolTableValuesWriter; import org.apache.parquet.schema.LogicalTypeAnnotation; import org.apache.parquet.schema.PrimitiveType; import org.apache.parquet.schema.PrimitiveType.PrimitiveTypeName; @@ -631,6 +632,70 @@ private void testFloatingPoint_WithByteStreamSplitAndDictionary( PlainValuesWriter.class); } + @Test + public void testBinary_WithSymbolTable_AndDictionary() { + // The symbol table takes the columns the dictionary gives up on, and hands back the ones whose codes + // are no smaller than the values, so it sits between the two. + for (WriterVersion version : WriterVersion.values()) { + ValuesWriter writer = getDefaultFactory(ParquetProperties.builder() + .withWriterVersion(version) + .withFsstEncoding(true) + .build()) + .newValuesWriter(createColumnDescriptor(BINARY)); + validateWriterType(writer, FallbackValuesWriter.class); + FallbackValuesWriter outer = (FallbackValuesWriter) writer; + validateWriterType(outer.initialWriter, PlainBinaryDictionaryValuesWriter.class); + validateNestedSymbolTableWriter(outer.fallBackWriter, version); + } + } + + @Test + public void testBinary_WithSymbolTable_WithoutDictionary() { + for (WriterVersion version : WriterVersion.values()) { + ValuesWriter writer = getDefaultFactory(ParquetProperties.builder() + .withWriterVersion(version) + .withDictionaryEncoding(false) + .withFsstEncoding(true) + .build()) + .newValuesWriter(createColumnDescriptor(BINARY)); + validateNestedSymbolTableWriter(writer, version); + } + } + + @Test + public void testBinary_WithSymbolTable_PerColumn() { + ParquetProperties properties = ParquetProperties.builder() + .withDictionaryEncoding(false) + .withFsstEncoding("colA", true) + .build(); + ValuesWriterFactory factory = getDefaultFactory(properties); + validateNestedSymbolTableWriter( + factory.newValuesWriter(createColumnDescriptor(BINARY, "colA")), WriterVersion.PARQUET_1_0); + doTestValueWriter(createColumnDescriptor(BINARY, "colB"), properties, PlainValuesWriter.class); + } + + @Test + public void testSymbolTable_LeavesOtherTypesAlone() { + // The encoding is defined for byte arrays, so turning it on for every column must not reach a column + // it cannot encode. + ParquetProperties properties = ParquetProperties.builder() + .withDictionaryEncoding(false) + .withFsstEncoding(true) + .build(); + doTestValueWriter(createColumnDescriptor(INT32), properties, PlainValuesWriter.class); + doTestValueWriter(createColumnDescriptor(FLOAT), properties, PlainValuesWriter.class); + doTestValueWriter(createColumnDescriptor(BOOLEAN), properties, BooleanPlainValuesWriter.class); + } + + private void validateNestedSymbolTableWriter(ValuesWriter writer, WriterVersion version) { + validateWriterType(writer, FallbackValuesWriter.class); + FallbackValuesWriter fallback = (FallbackValuesWriter) writer; + validateWriterType(fallback.initialWriter, SymbolTableValuesWriter.class); + validateWriterType( + fallback.fallBackWriter, + version == WriterVersion.PARQUET_1_0 ? PlainValuesWriter.class : DeltaByteArrayWriter.class); + } + private void validateFactory( ValuesWriterFactory factory, PrimitiveTypeName typeName, diff --git a/parquet-column/src/test/java/org/apache/parquet/column/values/symboltable/SymbolTableEndToEndTest.java b/parquet-column/src/test/java/org/apache/parquet/column/values/symboltable/SymbolTableEndToEndTest.java new file mode 100644 index 0000000000..17c5183e41 --- /dev/null +++ b/parquet-column/src/test/java/org/apache/parquet/column/values/symboltable/SymbolTableEndToEndTest.java @@ -0,0 +1,360 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.parquet.column.values.symboltable; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.Iterator; +import java.util.List; +import org.apache.parquet.column.ColumnDescriptor; +import org.apache.parquet.column.ColumnReader; +import org.apache.parquet.column.ColumnWriter; +import org.apache.parquet.column.Encoding; +import org.apache.parquet.column.ParquetProperties; +import org.apache.parquet.column.impl.ColumnReadStoreImpl; +import org.apache.parquet.column.impl.ColumnWriteStoreV1; +import org.apache.parquet.column.page.DataPage; +import org.apache.parquet.column.page.DataPageV1; +import org.apache.parquet.column.page.PageReadStore; +import org.apache.parquet.column.page.PageReader; +import org.apache.parquet.column.page.mem.MemPageReader; +import org.apache.parquet.column.page.mem.MemPageStore; +import org.apache.parquet.column.page.mem.MemPageWriter; +import org.apache.parquet.column.values.symboltable.SymbolTablePayload.OffsetEncoding; +import org.apache.parquet.example.DummyRecordConverter; +import org.apache.parquet.io.ParquetDecodingException; +import org.apache.parquet.io.api.Binary; +import org.apache.parquet.schema.MessageType; +import org.apache.parquet.schema.MessageTypeParser; +import org.junit.jupiter.api.Test; + +/** + * Values through the column machinery, not just through the values writer/reader pair: a + * {@link MemPageStore}, a real {@link ColumnWriteStoreV1}, a real {@link ColumnReadStoreImpl}, the + * transport built in {@code column/page} and {@code column/impl} carrying the table between them. + * + *

{@link SymbolTableValuesRoundTripTest} already covers the writer and reader in isolation; what + * only this level can catch is a table that never reaches the writer's chunk-finalize hook, or a + * reader built before the table it needs exists. + */ +public class SymbolTableEndToEndTest { + + private static final ColumnDescriptor REQUIRED_BINARY = + requiredBinaryColumn().getColumnDescription(new String[] {"foo", "bar"}); + + private static MessageType requiredBinaryColumn() { + return MessageTypeParser.parseMessageType("message msg { required group foo { required binary bar; } }"); + } + + private static List binaries(String... values) { + List result = new ArrayList<>(); + for (String value : values) { + result.add(Binary.fromString(value)); + } + return result; + } + + /** Each word repeated enough times that FSST beats the delta-byte-array fallback it competes with. */ + private static List repeatedBinaries(String... words) { + List result = new ArrayList<>(); + for (int i = 0; i < 100; i++) { + for (String word : words) { + result.add(Binary.fromString(word)); + } + } + return result; + } + + private static ColumnWriteStoreV1 fsstWriteStore(MemPageStore memPageStore) { + return fsstWriteStore(memPageStore, OffsetEncoding.DELTA_BINARY_PACKED); + } + + private static ColumnWriteStoreV1 fsstWriteStore(MemPageStore memPageStore, OffsetEncoding offsetEncoding) { + return new ColumnWriteStoreV1( + memPageStore, + ParquetProperties.builder() + .withDictionaryEncoding(false) + .withFsstEncoding(true) + .withSymbolTableOffsetEncoding(offsetEncoding) + .build()); + } + + private static void writeChunk(ColumnWriteStoreV1 store, ColumnDescriptor path, List values) { + ColumnWriter writer = store.getColumnWriter(path); + for (Binary value : values) { + writer.write(value, 0, 0); + store.endRecord(); + } + store.flush(); + } + + private static ColumnReader columnReader(MemPageStore memPageStore, ColumnDescriptor path, MessageType schema) { + return new ColumnReadStoreImpl(memPageStore, new DummyRecordConverter(schema).getRootConverter(), schema, null) + .getColumnReader(path); + } + + /** Reads every value of a required column, in order. */ + private static List readBinaries(MemPageStore memPageStore, ColumnDescriptor path, MessageType schema) { + ColumnReader reader = columnReader(memPageStore, path, schema); + List read = new ArrayList<>(); + long count = reader.getTotalValueCount(); + for (long i = 0; i < count; i++) { + read.add(reader.getBinary()); + reader.consume(); + } + return read; + } + + private static MemPageWriter pageWriterFor(MemPageStore memPageStore, ColumnDescriptor path) { + return (MemPageWriter) memPageStore.getPageWriter(path); + } + + @Test + public void aHighCardinalityColumnKeepsTheEncodingAndPublishesATable() { + MessageType schema = requiredBinaryColumn(); + ColumnDescriptor path = REQUIRED_BINARY; + List values = new ArrayList<>(); + for (int i = 0; i < 2000; i++) { + values.add(Binary.fromString("https://example.com/catalogue/item/" + i + "?ref=newsletter")); + } + + MemPageStore memPageStore = new MemPageStore(values.size()); + writeChunk(fsstWriteStore(memPageStore), path, values); + + MemPageWriter pageWriter = pageWriterFor(memPageStore, path); + assertThat(pageWriter.getSymbolTablePage()) + .as("a chunk that wins with FSST publishes a table") + .isNotNull(); + for (DataPage page : pageWriter.getPages()) { + assertThat(((DataPageV1) page).getValueEncoding()).isEqualTo(Encoding.FSST); + } + + assertThat(readBinaries(memPageStore, path, schema)).isEqualTo(values); + } + + @Test + public void multiplePagesShareOneTable() { + MessageType schema = requiredBinaryColumn(); + ColumnDescriptor path = REQUIRED_BINARY; + String[] themes = {"warehouse-inventory", "flight-departure", "clinical-observation", "seismic-reading"}; + List values = new ArrayList<>(); + for (String theme : themes) { + for (int i = 0; i < 400; i++) { + values.add(Binary.fromString(theme + "/" + i)); + } + } + + MemPageStore memPageStore = new MemPageStore(values.size()); + ColumnWriteStoreV1 store = new ColumnWriteStoreV1( + memPageStore, + ParquetProperties.builder() + .withDictionaryEncoding(false) + .withFsstEncoding(true) + .withPageSize(2048) + .withMinRowCountForPageSizeCheck(1) + .build()); + writeChunk(store, path, values); + + MemPageWriter pageWriter = pageWriterFor(memPageStore, path); + assertThat(pageWriter.getPages().size()) + .as("the chunk crossed the page-size threshold") + .isGreaterThan(1); + assertThat(pageWriter.getSymbolTablePage()) + .as("one table for the whole chunk") + .isNotNull(); + + assertThat(readBinaries(memPageStore, path, schema)).isEqualTo(values); + } + + @Test + public void nullsAndEmptyStringsSurviveAnOptionalColumn() { + MessageType schema = MessageTypeParser.parseMessageType("message msg { optional binary foo; }"); + ColumnDescriptor path = schema.getColumns().get(0); + + List present = new ArrayList<>(); + for (int i = 0; i < 300; i++) { + present.add(Binary.fromString(i % 7 == 0 ? "" : "value-" + (i % 11))); + } + + MemPageStore memPageStore = new MemPageStore(present.size() * 2); + ColumnWriteStoreV1 store = fsstWriteStore(memPageStore); + ColumnWriter writer = store.getColumnWriter(path); + List expected = new ArrayList<>(); + for (int i = 0; i < present.size(); i++) { + if (i % 3 == 0) { + writer.writeNull(0, 0); + expected.add(null); + } else { + Binary value = present.get(i); + writer.write(value, 0, 1); + expected.add(value); + } + store.endRecord(); + } + store.flush(); + + ColumnReader reader = columnReader(memPageStore, path, schema); + List read = new ArrayList<>(); + long count = reader.getTotalValueCount(); + for (long i = 0; i < count; i++) { + read.add(reader.getCurrentDefinitionLevel() == 0 ? null : reader.getBinary()); + reader.consume(); + } + assertThat(read).isEqualTo(expected); + } + + @Test + public void aColumnThatExpandsFallsBackAndPublishesNoTable() { + MessageType schema = requiredBinaryColumn(); + ColumnDescriptor path = REQUIRED_BINARY; + List values = new ArrayList<>(); + for (int i = 0; i < 256; i++) { + values.add(Binary.fromConstantByteArray(new byte[] {(byte) i})); + } + + MemPageStore memPageStore = new MemPageStore(values.size()); + writeChunk(fsstWriteStore(memPageStore, OffsetEncoding.PLAIN), path, values); + + MemPageWriter pageWriter = pageWriterFor(memPageStore, path); + assertThat(pageWriter.getSymbolTablePage()) + .as("a chunk that fell back publishes no table") + .isNull(); + for (DataPage page : pageWriter.getPages()) { + assertThat(((DataPageV1) page).getValueEncoding()).isEqualTo(Encoding.PLAIN); + } + + assertThat(readBinaries(memPageStore, path, schema)).isEqualTo(values); + } + + @Test + public void aColumnCanBeReadTwiceAndSkippedAtSeveralPositions() { + MessageType schema = requiredBinaryColumn(); + ColumnDescriptor path = REQUIRED_BINARY; + List values = + binaries("alpha", "", "alphabet", "beta", "betamax", "gamma", "gamma-ray", "delta", "delta-force"); + + MemPageStore memPageStore = new MemPageStore(values.size()); + writeChunk(fsstWriteStore(memPageStore), path, values); + + // Read the whole column once, from the top. + assertThat(readBinaries(memPageStore, path, schema)).isEqualTo(values); + + // Read it again from the same store, skipping every other value this time. + ColumnReader reader = columnReader(memPageStore, path, schema); + for (int i = 0; i < values.size(); i++) { + if (i % 2 == 0) { + reader.skip(); + } else { + assertThat(reader.getBinary()).as("at " + i).isEqualTo(values.get(i)); + } + reader.consume(); + } + } + + @Test + public void bothOffsetEncodingsRoundTripTheSameValues() { + MessageType schema = requiredBinaryColumn(); + ColumnDescriptor path = REQUIRED_BINARY; + List values = binaries("one", "two", "three", "four", "five", "six", "seven"); + + for (OffsetEncoding offsetEncoding : OffsetEncoding.values()) { + MemPageStore memPageStore = new MemPageStore(values.size()); + writeChunk(fsstWriteStore(memPageStore, offsetEncoding), path, values); + + assertThat(readBinaries(memPageStore, path, schema)) + .as("offsets " + offsetEncoding) + .isEqualTo(values); + } + } + + /** + * Two independent chunks, each with its own {@link MemPageStore}, standing in for two row groups: + * {@code MemPageWriter.writeSymbolTablePage} rejects a second table on the same block, so a + * literal {@code resetDictionary()} mid-chunk cannot be driven through this substrate — but two + * chunks with disjoint vocabularies prove the same thing a row-group boundary needs, and more + * visibly: a table carried over into the wrong chunk decodes garbage, not just a bigger page. + */ + @Test + public void twoRowGroupsEachTrainTheirOwnTable() throws IOException { + MessageType schema = requiredBinaryColumn(); + ColumnDescriptor path = REQUIRED_BINARY; + List firstRowGroup = repeatedBinaries("alpha", "alphabet", "alpine", "alpaca"); + List secondRowGroup = repeatedBinaries("zeta", "zenith", "zephyr", "zodiac"); + + MemPageStore first = new MemPageStore(firstRowGroup.size()); + writeChunk(fsstWriteStore(first), path, firstRowGroup); + MemPageStore second = new MemPageStore(secondRowGroup.size()); + writeChunk(fsstWriteStore(second), path, secondRowGroup); + + assertThat(readBinaries(first, path, schema)).isEqualTo(firstRowGroup); + assertThat(readBinaries(second, path, schema)).isEqualTo(secondRowGroup); + + byte[] firstTable = + pageWriterFor(first, path).getSymbolTablePage().getBytes().toByteArray(); + byte[] secondTable = + pageWriterFor(second, path).getSymbolTablePage().getBytes().toByteArray(); + assertThat(secondTable).as("each row group trains its own table").isNotEqualTo(firstTable); + } + + /** + * The negative control the plan asks for: strip the symbol table out of the page reader the way a + * corrupt or truncated read might, and confirm the column reader refuses to guess rather than + * silently decoding as if the encoding were something else. + * + *

{@link org.apache.parquet.column.impl.ColumnReaderImpl}'s constructor calls {@code consume()} + * to prime the first value, so the failure happens at {@code getColumnReader(path)} itself, not on + * some later value read. + */ + @Test + public void aReaderCannotSilentlyProceedWhenTheTableIsMissing() { + MessageType schema = requiredBinaryColumn(); + ColumnDescriptor path = REQUIRED_BINARY; + List values = repeatedBinaries("alpha", "alphabet", "alpine", "alpaca"); + MemPageStore memPageStore = new MemPageStore(values.size()); + writeChunk(fsstWriteStore(memPageStore), path, values); + + MemPageWriter pageWriter = pageWriterFor(memPageStore, path); + assertThat(pageWriter.getSymbolTablePage()) + .as("the column actually used the encoding") + .isNotNull(); + List pages = pageWriter.getPages(); + + PageReadStore strippedStore = new PageReadStore() { + @Override + public PageReader getPageReader(ColumnDescriptor descriptor) { + Iterator iterator = new ArrayList<>(pages).iterator(); + return new MemPageReader( + pageWriter.getTotalValueCount(), iterator, pageWriter.getDictionaryPage(), null); + } + + @Override + public long getRowCount() { + return memPageStore.getRowCount(); + } + }; + + assertThatThrownBy(() -> new ColumnReadStoreImpl( + strippedStore, new DummyRecordConverter(schema).getRootConverter(), schema, null) + .getColumnReader(path)) + .isInstanceOf(ParquetDecodingException.class); + } +} diff --git a/parquet-column/src/test/java/org/apache/parquet/column/values/symboltable/SymbolTableInteropTest.java b/parquet-column/src/test/java/org/apache/parquet/column/values/symboltable/SymbolTableInteropTest.java new file mode 100644 index 0000000000..162ef9a8fd --- /dev/null +++ b/parquet-column/src/test/java/org/apache/parquet/column/values/symboltable/SymbolTableInteropTest.java @@ -0,0 +1,357 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.parquet.column.values.symboltable; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.io.BufferedReader; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.io.UncheckedIOException; +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import org.apache.parquet.bytes.ByteBufferInputStream; +import org.apache.parquet.bytes.HeapByteBufferAllocator; +import org.apache.parquet.column.values.delta.DeltaBinaryPackingValuesReader; +import org.apache.parquet.column.values.delta.DeltaBinaryPackingValuesWriter; +import org.apache.parquet.column.values.delta.DeltaBinaryPackingValuesWriterForInteger; +import org.apache.parquet.column.values.symboltable.SymbolTablePayload.OffsetEncoding; +import org.apache.parquet.io.api.Binary; +import org.junit.jupiter.api.Test; + +/** + * Requires this implementation to decode symbol table pages another implementation wrote. + * + *

The round trip and reference comparison tests in this package both start from bytes this code + * produced: one proves the decoder undoes the encoder, the other proves the trainer picks the same + * symbols as the reference implementation. Neither can catch a page layout that is self-consistent + * and different from everyone else's -- a swapped header field, an off-by-one in the offset section, + * an escape read as a code. Only bytes from a foreign writer catch that, which is what these + * fixtures are. + * + *

They come from the C++ implementation's Parquet interop file, and their provenance, layout and + * per-case coverage are documented in the header of {@code src/test/resources/fsst/interop/ + * expected.txt}. The expected values there were taken from that file's conventionally-encoded + * columns, which hold the same values as the encoded ones, so nothing in the fixtures depends on any + * FSST decoder being right. + */ +public class SymbolTableInteropTest { + + private static final String DIRECTORY = "/fsst/interop/"; + + /** One case's symbol table and the bodies of its data pages, as the fixture files hold them. */ + private static final class Chunk { + final SymbolTableType type; + final byte[] table; + final List pages; + + Chunk(SymbolTableType type, byte[] table, List pages) { + this.type = type; + this.table = table; + this.pages = pages; + } + + SymbolTableValuesReader reader() { + SymbolTable symbolTable = SymbolTables.deserialize(type, table, 0, table.length); + return new SymbolTableValuesReader(() -> symbolTable); + } + } + + /** What one line of {@code expected.txt} asserts: how many values, and which ones. */ + private static final class Expectation { + final int pageCount; + final int count; + final String digest; + + Expectation(int pageCount, int count, String digest) { + this.pageCount = pageCount; + this.count = count; + this.digest = digest; + } + } + + private static final List CASES = + List.of("urls", "nulls-and-empties", "escapes", "escapes-binary", "plain-offsets"); + + @Test + public void everyCaseDecodesToWhatTheReferenceColumnsHold() throws IOException { + Map expected = readExpectations(); + for (String name : CASES) { + Chunk chunk = readChunk(name); + Expectation whole = expectation(expected, name); + assertThat(chunk.pages.size()).as(name + ": page count").isEqualTo(whole.pageCount); + + SymbolTableValuesReader reader = chunk.reader(); + List all = new ArrayList<>(); + for (int page = 0; page < chunk.pages.size(); page++) { + String pageName = name + "." + page; + Expectation pageExpectation = expectation(expected, pageName); + List values = decodePage(reader, chunk.pages.get(page), pageExpectation.count); + assertThat(digest(values)).as(pageName).isEqualTo(pageExpectation.digest); + all.addAll(values); + } + assertThat(all.size()).as(name + ": value count").isEqualTo(whole.count); + assertThat(digest(all)).as(name).isEqualTo(whole.digest); + } + } + + @Test + public void bothOffsetSectionEncodingsAreCovered() throws IOException { + // A writer with few values per page has nothing to gain from packing the offsets, so the file + // contains pages of each kind and a reader has to tolerate both. Read from the page bodies + // rather than through the reader, so that a reader which ignored the byte could not hide it. + Map pages = new HashMap<>(); + for (String name : CASES) { + for (byte[] page : readChunk(name).pages) { + OffsetEncoding encoding = page[0] == 0 ? OffsetEncoding.PLAIN : OffsetEncoding.DELTA_BINARY_PACKED; + assertThat(page[0]).as(name + ": offset encoding byte").isIn((byte) 0, (byte) 1); + pages.merge(encoding, 1, Integer::sum); + } + } + assertThat((int) pages.get(OffsetEncoding.PLAIN)) + .as("pages with a plain offset array") + .isEqualTo(4); + assertThat((int) pages.get(OffsetEncoding.DELTA_BINARY_PACKED)) + .as("pages with a packed offset array") + .isEqualTo(26); + } + + @Test + public void theReaderLearnsTheRepresentationFromTheTable() throws IOException { + Chunk chunk = readChunk("urls"); + SymbolTableValuesReader reader = chunk.reader(); + reader.initFromPage(30, stream(chunk.pages.get(0))); + assertThat(reader.symbolTableType()).isEqualTo(SymbolTableType.FSST_8); + } + + @Test + public void skippingLandsOnTheSameValueAsReadingWould() throws IOException { + Chunk chunk = readChunk("escapes"); + byte[] page = chunk.pages.get(0); + int count = expectation(readExpectations(), "escapes.0").count; + List straight = decodePage(chunk.reader(), page, count); + + for (int skipped = 0; skipped <= count; skipped++) { + SymbolTableValuesReader reader = chunk.reader(); + reader.initFromPage(count, stream(page)); + reader.skip(skipped); + for (int i = skipped; i < count; i++) { + assertThat(reader.readBytes()) + .as("skipped " + skipped + ", value " + i) + .isEqualTo(Binary.fromConstantByteArray(straight.get(i))); + } + } + } + + /** + * The offset section, in the encode direction: {@link #bothOffsetSectionEncodingsAreCovered} only + * proves this implementation can read a packed section the C++ writer produced. It does not prove + * a packed section this implementation writes would be the bytes another reader expects, since a + * delta-binary-packed stream has framing choices (miniblock count, bit widths) a byte-compatible + * decoder does not have to make the same way. Decoding each fixture's packed offsets and + * re-encoding them through the same path {@link SymbolTablePayloadWriter} uses settles that: if the + * two differ, our writer picked a different framing for the same values, not the same one. + */ + @Test + public void packedOffsetsReEncodeToTheFixturesOwnBytes() throws IOException { + boolean sawAPackedSection = false; + for (String name : CASES) { + Chunk chunk = readChunk(name); + for (int page = 0; page < chunk.pages.size(); page++) { + byte[] body = chunk.pages.get(page); + String label = name + "." + page; + int valueCount = readIntLittleEndian(body, 1); + int offsetSectionSize = readIntLittleEndian(body, 5); + if (body[0] != OffsetEncoding.DELTA_BINARY_PACKED.value() || offsetSectionSize == 0) { + continue; + } + sawAPackedSection = true; + + byte[] rawOffsets = new byte[offsetSectionSize]; + System.arraycopy(body, SymbolTablePayload.HEADER_SIZE, rawOffsets, 0, offsetSectionSize); + + int[] endOffsets = decodeDeltaPackedOffsets(rawOffsets, valueCount); + byte[] reEncoded = encodeDeltaPackedOffsets(endOffsets); + + assertThat(reEncoded).as(label + ": re-encoded offset bytes").isEqualTo(rawOffsets); + } + } + assertThat(sawAPackedSection) + .as("the fixtures actually exercise the packed offset path") + .isTrue(); + } + + private static int[] decodeDeltaPackedOffsets(byte[] rawOffsets, int valueCount) throws IOException { + DeltaBinaryPackingValuesReader reader = new DeltaBinaryPackingValuesReader(); + reader.initFromPage(valueCount, stream(rawOffsets)); + int[] endOffsets = new int[valueCount]; + for (int i = 0; i < valueCount; i++) { + endOffsets[i] = reader.readInteger(); + } + return endOffsets; + } + + private static byte[] encodeDeltaPackedOffsets(int[] endOffsets) throws IOException { + try (DeltaBinaryPackingValuesWriterForInteger writer = new DeltaBinaryPackingValuesWriterForInteger( + DeltaBinaryPackingValuesWriter.DEFAULT_NUM_BLOCK_VALUES, + DeltaBinaryPackingValuesWriter.DEFAULT_NUM_MINIBLOCKS, + 64 * 1024, + 64 * 1024, + HeapByteBufferAllocator.getInstance())) { + for (int endOffset : endOffsets) { + writer.writeInteger(endOffset); + } + return writer.getBytes().toByteArray(); + } + } + + private static int readIntLittleEndian(byte[] bytes, int pos) { + return (bytes[pos] & 0xFF) + | ((bytes[pos + 1] & 0xFF) << 8) + | ((bytes[pos + 2] & 0xFF) << 16) + | ((bytes[pos + 3] & 0xFF) << 24); + } + + private static List decodePage(SymbolTableValuesReader reader, byte[] page, int count) throws IOException { + reader.initFromPage(count, stream(page)); + List values = new ArrayList<>(count); + for (int i = 0; i < count; i++) { + values.add(reader.readBytes().getBytes()); + } + return values; + } + + private static ByteBufferInputStream stream(byte[] page) { + return ByteBufferInputStream.wrap(ByteBuffer.wrap(page)); + } + + private static String digest(List values) { + MessageDigest sha256; + try { + sha256 = MessageDigest.getInstance("SHA-256"); + } catch (NoSuchAlgorithmException e) { + throw new AssertionError(e); + } + for (byte[] value : values) { + sha256.update(new byte[] { + (byte) (value.length >>> 24), + (byte) (value.length >>> 16), + (byte) (value.length >>> 8), + (byte) value.length + }); + sha256.update(value); + } + StringBuilder hex = new StringBuilder(); + for (byte b : sha256.digest()) { + hex.append(Character.forDigit((b >> 4) & 0xF, 16)).append(Character.forDigit(b & 0xF, 16)); + } + return hex.toString(); + } + + private static Chunk readChunk(String name) throws IOException { + byte[] bytes = resource(name + ".pages"); + int pos = 0; + SymbolTableType type = SymbolTableType.fromTypeValue(bytes[pos++]); + int tableLength = readInt(bytes, pos); + pos += 4; + byte[] table = new byte[tableLength]; + System.arraycopy(bytes, pos, table, 0, tableLength); + pos += tableLength; + int pageCount = readInt(bytes, pos); + pos += 4; + List pages = new ArrayList<>(pageCount); + for (int i = 0; i < pageCount; i++) { + int length = readInt(bytes, pos); + pos += 4; + byte[] page = new byte[length]; + System.arraycopy(bytes, pos, page, 0, length); + pos += length; + pages.add(page); + } + assertThat(pos).as(name + ": bytes left over in the fixture").isEqualTo(bytes.length); + return new Chunk(type, table, pages); + } + + private static int readInt(byte[] bytes, int pos) { + return ((bytes[pos] & 0xFF) << 24) + | ((bytes[pos + 1] & 0xFF) << 16) + | ((bytes[pos + 2] & 0xFF) << 8) + | (bytes[pos + 3] & 0xFF); + } + + private static Map readExpectations() throws IOException { + Map expectations = new HashMap<>(); + try (BufferedReader reader = + new BufferedReader(new InputStreamReader(open("expected.txt"), StandardCharsets.UTF_8))) { + String line; + while ((line = reader.readLine()) != null) { + if (line.isEmpty() || line.startsWith("#")) { + continue; + } + String[] fields = line.split(" "); + if (fields.length == 4) { + // A whole chunk: name, page count, value count, digest. + expectations.put( + fields[0], + new Expectation(Integer.parseInt(fields[1]), Integer.parseInt(fields[2]), fields[3])); + } else { + // One page of a chunk: name, value count, digest. + expectations.put(fields[0], new Expectation(-1, Integer.parseInt(fields[1]), fields[2])); + } + } + } + return expectations; + } + + private static Expectation expectation(Map expected, String name) { + Expectation expectation = expected.get(name); + if (expectation == null) { + throw new AssertionError("No expectation for " + name + " in " + DIRECTORY + "expected.txt"); + } + return expectation; + } + + private static byte[] resource(String name) throws IOException { + try (InputStream in = open(name)) { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + byte[] buffer = new byte[8192]; + int read; + while ((read = in.read(buffer)) > 0) { + out.write(buffer, 0, read); + } + return out.toByteArray(); + } + } + + private static InputStream open(String name) { + InputStream in = SymbolTableInteropTest.class.getResourceAsStream(DIRECTORY + name); + if (in == null) { + throw new UncheckedIOException(new IOException("Missing test resource " + DIRECTORY + name)); + } + return in; + } +} diff --git a/parquet-column/src/test/java/org/apache/parquet/column/values/symboltable/SymbolTablePayloadTest.java b/parquet-column/src/test/java/org/apache/parquet/column/values/symboltable/SymbolTablePayloadTest.java new file mode 100644 index 0000000000..9c14ef9bbb --- /dev/null +++ b/parquet-column/src/test/java/org/apache/parquet/column/values/symboltable/SymbolTablePayloadTest.java @@ -0,0 +1,247 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.parquet.column.values.symboltable; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.util.ArrayList; +import java.util.List; +import org.apache.parquet.bytes.ByteBufferInputStream; +import org.apache.parquet.bytes.HeapByteBufferAllocator; +import org.apache.parquet.column.values.symboltable.SymbolTablePayload.OffsetEncoding; +import org.apache.parquet.io.ParquetDecodingException; +import org.junit.jupiter.api.Test; + +/** + * The data page body's framing: what a writer produces byte for byte, and what a reader refuses. + * + *

The framing is the part of the encoding that two implementations have to agree on exactly, so + * the write-side assertions here are on bytes rather than on a round trip. + */ +public class SymbolTablePayloadTest { + + private static final int SLAB_SIZE = 64; + private static final int PAGE_SIZE = 1 << 20; + + private static SymbolTablePayloadWriter writer(OffsetEncoding offsetEncoding) { + return new SymbolTablePayloadWriter( + offsetEncoding, SLAB_SIZE, PAGE_SIZE, HeapByteBufferAllocator.getInstance()); + } + + private static byte[] write(OffsetEncoding offsetEncoding, byte[]... values) throws IOException { + try (SymbolTablePayloadWriter writer = writer(offsetEncoding)) { + for (byte[] value : values) { + writer.addValue(value, 0, value.length); + } + assertThat(writer.valueCount()).isEqualTo(values.length); + return writer.getBytes().toByteArray(); + } + } + + private static SymbolTablePayload parse(byte[] body, int pageValueCount) throws IOException { + return SymbolTablePayload.parse(ByteBufferInputStream.wrap(ByteBuffer.wrap(body)), pageValueCount); + } + + /** Reads back every value, so a bound that is off by one shows up as wrong bytes. */ + private static List valuesOf(SymbolTablePayload payload) { + List values = new ArrayList<>(); + for (int i = 0; i < payload.valueCount(); i++) { + byte[] value = new byte[payload.codeLength(i)]; + System.arraycopy(payload.codes(), payload.codeStart(i), value, 0, value.length); + values.add(value); + } + return values; + } + + @Test + public void writesThePlainLayoutByteForByte() throws IOException { + byte[] body = write(OffsetEncoding.PLAIN, bytes(1, 2, 3), bytes(4), bytes(5, 6)); + + ByteBuffer expected = ByteBuffer.allocate(9 + 12 + 6).order(ByteOrder.LITTLE_ENDIAN); + expected.put((byte) 0); // PLAIN offsets + expected.putInt(3); // number of values + expected.putInt(12); // offset section length + expected.putInt(3); // end of the first value + expected.putInt(4); + expected.putInt(6); + expected.put(bytes(1, 2, 3, 4, 5, 6)); + assertThat(body).isEqualTo(expected.array()); + } + + @Test + public void writesTheDeltaLayoutWithTheSameHeaderShape() throws IOException { + byte[] body = write(OffsetEncoding.DELTA_BINARY_PACKED, bytes(1, 2, 3), bytes(4), bytes(5, 6)); + + ByteBuffer header = ByteBuffer.wrap(body, 0, 9).order(ByteOrder.LITTLE_ENDIAN); + assertThat(header.get() & 0xFF).isEqualTo(1); + assertThat(header.getInt()).isEqualTo(3); + int offsetSectionSize = header.getInt(); + assertThat(offsetSectionSize).isEqualTo(body.length - 9 - 6); + assertThat(java.util.Arrays.copyOfRange(body, body.length - 6, body.length)) + .isEqualTo(bytes(1, 2, 3, 4, 5, 6)); + } + + @Test + public void roundTripsBothOffsetEncodings() throws IOException { + for (OffsetEncoding offsetEncoding : OffsetEncoding.values()) { + byte[] body = write(offsetEncoding, bytes(1, 2, 3), bytes(4), bytes(5, 6)); + SymbolTablePayload payload = parse(body, 3); + + assertThat(payload.valueCount()).isEqualTo(3); + List values = valuesOf(payload); + assertThat(values.get(0)).isEqualTo(bytes(1, 2, 3)); + assertThat(values.get(1)).isEqualTo(bytes(4)); + assertThat(values.get(2)).isEqualTo(bytes(5, 6)); + } + } + + /** + * Values of zero length are ordinary: two offsets that are equal, not a special case. + */ + @Test + public void roundTripsValuesWithNoCodes() throws IOException { + for (OffsetEncoding offsetEncoding : OffsetEncoding.values()) { + byte[] body = write(offsetEncoding, bytes(), bytes(7), bytes(), bytes()); + SymbolTablePayload payload = parse(body, 4); + + assertThat(payload.valueCount()).isEqualTo(4); + List values = valuesOf(payload); + assertThat(values.get(0)).isEmpty(); + assertThat(values.get(1)).isEqualTo(bytes(7)); + assertThat(values.get(2)).isEmpty(); + assertThat(values.get(3)).isEmpty(); + } + } + + @Test + public void aPageWithNoValuesIsJustAHeader() throws IOException { + for (OffsetEncoding offsetEncoding : OffsetEncoding.values()) { + byte[] body = write(offsetEncoding); + assertThat(body.length).isEqualTo(SymbolTablePayload.HEADER_SIZE); + assertThat(parse(body, 0).valueCount()).isEqualTo(0); + } + } + + /** + * Why delta offsets are the default rather than a knob: on a page of short values the plain + * section is a large fraction of the payload, and almost all of it is the same increment repeated. + */ + @Test + public void deltaOffsetsCostFarLessThanPlainOnesOnAPageOfShortValues() throws IOException { + byte[][] values = new byte[2000][]; + for (int i = 0; i < values.length; i++) { + values[i] = bytes(i & 0xFF, (i >>> 8) & 0xFF, 0x2C, 0x2E); + } + int plain = write(OffsetEncoding.PLAIN, values).length; + int delta = write(OffsetEncoding.DELTA_BINARY_PACKED, values).length; + + int codeBytes = values.length * 4; + assertThat(plain).isEqualTo(9 + values.length * 4 + codeBytes); + assertThat(delta - codeBytes) + .as("delta offsets should cost a small fraction of plain ones, but the payloads were " + delta + " and " + + plain) + .isLessThan((plain - codeBytes) / 8); + } + + @Test + public void rejectsABodyShorterThanTheHeader() { + assertThatThrownBy(() -> parse(new byte[8], 1)) + .isInstanceOf(ParquetDecodingException.class) + .hasMessageContaining("shorter than its 9-byte header"); + } + + @Test + public void rejectsAnUnknownOffsetEncoding() throws IOException { + byte[] body = write(OffsetEncoding.PLAIN, bytes(1)); + body[0] = 2; + assertThatThrownBy(() -> parse(body, 1)).isInstanceOf(ParquetDecodingException.class); + } + + @Test + public void rejectsMoreValuesThanThePageHeaderDeclares() throws IOException { + byte[] body = write(OffsetEncoding.PLAIN, bytes(1), bytes(2)); + assertThatThrownBy(() -> parse(body, 1)).isInstanceOf(ParquetDecodingException.class); + } + + @Test + public void rejectsAnOffsetSectionLongerThanTheBody() throws IOException { + byte[] body = write(OffsetEncoding.PLAIN, bytes(1)); + putInt(body, 5, 1000); + assertThatThrownBy(() -> parse(body, 1)).isInstanceOf(ParquetDecodingException.class); + } + + @Test + public void rejectsAPlainOffsetSectionOfTheWrongLength() throws IOException { + byte[] body = write(OffsetEncoding.PLAIN, bytes(1, 2), bytes(3)); + // Claim one value's worth of offsets while the section holds two. + putInt(body, 1, 1); + assertThatThrownBy(() -> parse(body, 2)).isInstanceOf(ParquetDecodingException.class); + } + + @Test + public void rejectsOffsetsThatGoBackwards() throws IOException { + byte[] body = write(OffsetEncoding.PLAIN, bytes(1, 2), bytes(3)); + putInt(body, 9, 3); // the first value ends past where the second one does + putInt(body, 13, 1); + assertThatThrownBy(() -> parse(body, 2)).isInstanceOf(ParquetDecodingException.class); + } + + @Test + public void rejectsOffsetsThatLeaveCodeBytesUnreachable() throws IOException { + byte[] body = write(OffsetEncoding.PLAIN, bytes(1, 2), bytes(3)); + putInt(body, 13, 2); // the last value's codes are dropped + assertThatThrownBy(() -> parse(body, 2)).isInstanceOf(ParquetDecodingException.class); + } + + @Test + public void rejectsOffsetsThatRunPastTheCodeSection() throws IOException { + byte[] body = write(OffsetEncoding.PLAIN, bytes(1, 2), bytes(3)); + putInt(body, 13, 4); + assertThatThrownBy(() -> parse(body, 2)).isInstanceOf(ParquetDecodingException.class); + } + + @Test + public void rejectsAnEmptyPageCarryingAnOffsetSection() throws IOException { + ByteArrayOutputStream body = new ByteArrayOutputStream(); + byte[] header = new byte[9]; + header[0] = (byte) OffsetEncoding.DELTA_BINARY_PACKED.value(); + putInt(header, 1, 0); + putInt(header, 5, 4); + body.write(header); + body.write(new byte[4]); + assertThatThrownBy(() -> parse(body.toByteArray(), 0)).isInstanceOf(ParquetDecodingException.class); + } + + private static void putInt(byte[] destination, int position, int value) { + ByteBuffer.wrap(destination).order(ByteOrder.LITTLE_ENDIAN).putInt(position, value); + } + + private static byte[] bytes(int... values) { + byte[] result = new byte[values.length]; + for (int i = 0; i < values.length; i++) { + result[i] = (byte) values[i]; + } + return result; + } +} diff --git a/parquet-column/src/test/java/org/apache/parquet/column/values/symboltable/SymbolTableValuesRoundTripTest.java b/parquet-column/src/test/java/org/apache/parquet/column/values/symboltable/SymbolTableValuesRoundTripTest.java new file mode 100644 index 0000000000..47f7a1eec0 --- /dev/null +++ b/parquet-column/src/test/java/org/apache/parquet/column/values/symboltable/SymbolTableValuesRoundTripTest.java @@ -0,0 +1,502 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.parquet.column.values.symboltable; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Random; +import org.apache.parquet.bytes.ByteBufferInputStream; +import org.apache.parquet.bytes.BytesInput; +import org.apache.parquet.bytes.HeapByteBufferAllocator; +import org.apache.parquet.column.ColumnDescriptor; +import org.apache.parquet.column.Encoding; +import org.apache.parquet.column.ValuesType; +import org.apache.parquet.column.page.SymbolTablePage; +import org.apache.parquet.column.values.ValuesReader; +import org.apache.parquet.column.values.ValuesWriter; +import org.apache.parquet.column.values.fallback.FallbackValuesWriter; +import org.apache.parquet.column.values.plain.BinaryPlainValuesReader; +import org.apache.parquet.column.values.plain.PlainValuesWriter; +import org.apache.parquet.column.values.symboltable.SymbolTablePayload.OffsetEncoding; +import org.apache.parquet.io.ParquetDecodingException; +import org.apache.parquet.io.api.Binary; +import org.apache.parquet.schema.PrimitiveType.PrimitiveTypeName; +import org.junit.jupiter.api.Test; + +/** + * The writer and reader as a pair: values in, the same values out, over the seams that a symbol table + * has to cross. + * + *

The table lives with the column chunk while a page is what a writer produces, and that mismatch + * is where the interesting cases are — a table trained on the first page and used by the fifth, a + * chunk that abandons the encoding after the table has already been trained, and a reader that has to + * be handed the table from outside the page it is reading. + */ +public class SymbolTableValuesRoundTripTest { + + private static final int SLAB_SIZE = 1024; + private static final int PAGE_SIZE = 1 << 20; + + /** + * Stands in for wherever the deserialized {@link SymbolTablePage} ends up. + * + *

Deserializing on every {@link #getSymbolTable()} call is deliberate: it means the reader is + * decoding against a table rebuilt from bytes rather than against the trainer's own object. + */ + private static final class SymbolTableRelay implements SymbolTableSource { + + private SymbolTableType type; + private byte[] body; + + void receive(SymbolTablePage page) throws IOException { + this.type = page.getType(); + this.body = page.getBytes().toByteArray(); + } + + @Override + public SymbolTable getSymbolTable() { + return SymbolTables.deserialize(type, body, 0, body.length); + } + } + + private static SymbolTableValuesWriter writer(OffsetEncoding offsetEncoding) { + return new SymbolTableValuesWriter( + SymbolTableType.FSST_8, offsetEncoding, SLAB_SIZE, PAGE_SIZE, HeapByteBufferAllocator.getInstance()); + } + + private static List binaries(String... values) { + List result = new ArrayList<>(); + for (String value : values) { + result.add(Binary.fromString(value)); + } + return result; + } + + /** Writes each page, reads each page back, and returns what came out. */ + private static List> roundTrip(List> pages, OffsetEncoding offsetEncoding) + throws IOException { + SymbolTableRelay relay = new SymbolTableRelay(); + List bodies = new ArrayList<>(); + try (SymbolTableValuesWriter writer = writer(offsetEncoding)) { + for (List page : pages) { + for (Binary value : page) { + writer.writeBytes(value); + } + assertThat(writer.getEncoding()).isEqualTo(Encoding.FSST); + bodies.add(writer.getBytes().toByteArray()); + writer.reset(); + } + SymbolTablePage tablePage = writer.toSymbolTablePageAndClose(); + assertThat(tablePage).as("one table for the whole chunk").isNotNull(); + relay.receive(tablePage); + } + + SymbolTableValuesReader reader = new SymbolTableValuesReader(relay); + List> read = new ArrayList<>(); + for (int i = 0; i < pages.size(); i++) { + int valueCount = pages.get(i).size(); + reader.initFromPage(valueCount, ByteBufferInputStream.wrap(ByteBuffer.wrap(bodies.get(i)))); + List page = new ArrayList<>(); + for (int v = 0; v < valueCount; v++) { + page.add(reader.readBytes()); + } + read.add(page); + } + return read; + } + + private static void assertRoundTrips(List values) throws IOException { + for (OffsetEncoding offsetEncoding : OffsetEncoding.values()) { + assertThat(roundTrip(Arrays.asList(values), offsetEncoding)) + .as("offsets " + offsetEncoding) + .isEqualTo(Arrays.asList(values)); + } + } + + @Test + public void roundTripsTextThatSharesSubstrings() throws IOException { + List values = new ArrayList<>(); + for (int i = 0; i < 500; i++) { + values.add(Binary.fromString("https://example.com/catalogue/item/" + i + "?ref=newsletter")); + } + assertRoundTrips(values); + } + + @Test + public void roundTripsAPageWithNoValues() throws IOException { + assertRoundTrips(binaries()); + } + + @Test + public void roundTripsASingleValue() throws IOException { + assertRoundTrips(binaries("only")); + } + + @Test + public void roundTripsEmptyStrings() throws IOException { + assertRoundTrips(binaries("", "", "", "")); + } + + @Test + public void roundTripsEmptyStringsMixedWithText() throws IOException { + assertRoundTrips(binaries("", "alpha", "", "", "alphabet", "a", "")); + } + + /** Every byte value, including the one the code space reserves for an escape. */ + @Test + public void roundTripsAllTwoHundredAndFiftySixByteValues() throws IOException { + List values = new ArrayList<>(); + for (int i = 0; i < 256; i++) { + values.add(Binary.fromConstantByteArray(new byte[] {(byte) i})); + } + values.add(Binary.fromConstantByteArray(allByteValues())); + assertRoundTrips(values); + } + + /** High-entropy bytes, which is the input that forces the escape path to carry the page. */ + @Test + public void roundTripsIncompressibleBytes() throws IOException { + Random random = new Random(20260908L); + List values = new ArrayList<>(); + for (int i = 0; i < 200; i++) { + byte[] value = new byte[1 + random.nextInt(40)]; + random.nextBytes(value); + values.add(Binary.fromConstantByteArray(value)); + } + assertRoundTrips(values); + } + + /** Values far longer than the longest symbol a table can hold. */ + @Test + public void roundTripsValuesLongerThanAnySymbol() throws IOException { + List values = new ArrayList<>(); + StringBuilder builder = new StringBuilder(); + for (int i = 0; i < 400; i++) { + builder.append("repetitionrepetition"); + values.add(Binary.fromString(builder.toString())); + } + assertRoundTrips(values); + } + + /** + * A table trained on the first page decodes the pages that follow it. + * + *

The pages deliberately drift: page one has none of the text page four is made of, so a table + * retrained per page would give a smaller page four, and a reader given only the first page's table + * would decode it wrongly. The point of the assertion is that the second thing does not happen. + */ + @Test + public void oneTableTrainedOnTheFirstPageServesLaterPages() throws IOException { + List> pages = new ArrayList<>(); + String[] themes = {"warehouse-inventory", "flight-departure", "clinical-observation", "seismic-reading"}; + for (int page = 0; page < themes.length; page++) { + List values = new ArrayList<>(); + for (int i = 0; i < 300; i++) { + values.add(Binary.fromString(themes[page] + "/" + i)); + } + pages.add(values); + } + for (OffsetEncoding offsetEncoding : OffsetEncoding.values()) { + assertThat(roundTrip(pages, offsetEncoding)) + .as("offsets " + offsetEncoding) + .isEqualTo(pages); + } + } + + /** A new chunk trains a new table, which is what a row group boundary needs. */ + @Test + public void resetDictionaryTrainsAgain() throws IOException { + try (SymbolTableValuesWriter writer = writer(OffsetEncoding.DELTA_BINARY_PACKED)) { + for (Binary value : binaries("alpha", "alphabet", "alpine")) { + writer.writeBytes(value); + } + writer.getBytes(); + writer.reset(); + SymbolTablePage first = writer.toSymbolTablePageAndClose(); + assertThat(first).as("the first chunk trained a table").isNotNull(); + + writer.resetDictionary(); + assertThat(writer.toSymbolTablePageAndClose()) + .as("nothing has been trained yet for the new chunk") + .isNull(); + + for (Binary value : binaries("zeta", "zenith", "zephyr")) { + writer.writeBytes(value); + } + writer.getBytes(); + SymbolTablePage second = writer.toSymbolTablePageAndClose(); + assertThat(second).as("the second chunk trained its own table").isNotNull(); + assertThat(second.getBytes().toByteArray()) + .as("a table trained on different values") + .isNotEqualTo(first.getBytes().toByteArray()); + } + } + + @Test + public void skipReachesTheSameValuesAsReading() throws IOException { + List values = binaries("alpha", "", "alphabet", "beta", "betamax", "gamma", "gamma-ray", "delta"); + SymbolTableRelay relay = new SymbolTableRelay(); + byte[] body; + try (SymbolTableValuesWriter writer = writer(OffsetEncoding.DELTA_BINARY_PACKED)) { + for (Binary value : values) { + writer.writeBytes(value); + } + body = writer.getBytes().toByteArray(); + relay.receive(writer.toSymbolTablePageAndClose()); + } + + for (int start = 0; start < values.size(); start++) { + SymbolTableValuesReader reader = new SymbolTableValuesReader(relay); + reader.initFromPage(values.size(), ByteBufferInputStream.wrap(ByteBuffer.wrap(body))); + reader.skip(start); + for (int i = start; i < values.size(); i++) { + assertThat(reader.readBytes()).as("from " + start + " at " + i).isEqualTo(values.get(i)); + } + } + + // One value at a time, which is the overload a column reader calls for a null. + SymbolTableValuesReader reader = new SymbolTableValuesReader(relay); + reader.initFromPage(values.size(), ByteBufferInputStream.wrap(ByteBuffer.wrap(body))); + for (int i = 0; i < values.size(); i++) { + if (i % 2 == 0) { + reader.skip(); + } else { + assertThat(reader.readBytes()).isEqualTo(values.get(i)); + } + } + } + + @Test + public void readingPastTheEndOfAPageIsRejected() throws IOException { + SymbolTableRelay relay = new SymbolTableRelay(); + byte[] body; + try (SymbolTableValuesWriter writer = writer(OffsetEncoding.DELTA_BINARY_PACKED)) { + writer.writeBytes(Binary.fromString("one")); + body = writer.getBytes().toByteArray(); + relay.receive(writer.toSymbolTablePageAndClose()); + } + SymbolTableValuesReader reader = new SymbolTableValuesReader(relay); + reader.initFromPage(1, ByteBufferInputStream.wrap(ByteBuffer.wrap(body))); + reader.readBytes(); + assertThatThrownBy(reader::readBytes).isInstanceOf(ParquetDecodingException.class); + assertThatThrownBy(reader::skip).isInstanceOf(ParquetDecodingException.class); + } + + @Test + public void aReaderWithNoTableSaysSoRatherThanFailingLater() { + SymbolTableValuesReader reader = new SymbolTableValuesReader(); + assertThat(reader.symbolTableType()).isNull(); + assertThatThrownBy(() -> reader.initFromPage(1, ByteBufferInputStream.wrap(ByteBuffer.wrap(new byte[9])))) + .isInstanceOf(ParquetDecodingException.class) + .hasMessageContaining("#531"); + } + + @Test + public void theEncodingHandsOutTheReaderForBinaryOnly() { + assertThat(Encoding.FSST.getValuesReader(descriptor(PrimitiveTypeName.BINARY), ValuesType.VALUES)) + .isInstanceOf(SymbolTableValuesReader.class); + for (PrimitiveTypeName type : new PrimitiveTypeName[] { + PrimitiveTypeName.INT32, PrimitiveTypeName.INT64, PrimitiveTypeName.FIXED_LEN_BYTE_ARRAY + }) { + assertThatThrownBy(() -> Encoding.FSST.getValuesReader(descriptor(type), ValuesType.VALUES)) + .isInstanceOf(ParquetDecodingException.class); + } + } + + /** + * A page the encoding makes bigger is written plain instead, and the values still come back. + * + *

Single bytes with four-byte offsets: the codes are as long as the values and the offsets cost + * what a plain page's lengths cost, so the nine-byte header alone decides it. That is the + * configuration to fall back from, and it is also the argument against writing offsets plain — the + * same page with packed offsets is smaller than plain and keeps the encoding. + * + *

Also the regression test for a fixed publish-timing defect: a chunk that falls back must not + * leave a symbol table page behind that no page refers to. + */ + @Test + public void aPageTheEncodingWouldGrowIsWrittenPlain() throws IOException { + List values = new ArrayList<>(); + for (int i = 0; i < 256; i++) { + values.add(Binary.fromConstantByteArray(new byte[] {(byte) i})); + } + + assertThat(fallbackEncodingFor(values, OffsetEncoding.PLAIN)).isEqualTo(Encoding.PLAIN); + assertThat(fallbackEncodingFor(values, OffsetEncoding.DELTA_BINARY_PACKED)) + .isEqualTo(Encoding.FSST); + } + + /** + * Runs a page through the fallback wrapper, reads it back with whatever encoding it chose, and + * checks that a symbol table page was published if and only if the encoding kept FSST. + */ + private static Encoding fallbackEncodingFor(List values, OffsetEncoding offsetEncoding) throws IOException { + SymbolTableRelay relay = new SymbolTableRelay(); + Encoding encoding; + byte[] body; + SymbolTablePage tablePage; + try (FallbackValuesWriter writer = FallbackValuesWriter.of( + writer(offsetEncoding), + new PlainValuesWriter(SLAB_SIZE, PAGE_SIZE, HeapByteBufferAllocator.getInstance()))) { + for (Binary value : values) { + writer.writeBytes(value); + } + body = writer.getBytes().toByteArray(); + encoding = writer.getEncoding(); + tablePage = writer.toSymbolTablePageAndClose(); + } + + if (encoding == Encoding.PLAIN) { + assertThat(tablePage) + .as("a chunk that fell back publishes no table") + .isNull(); + } else { + assertThat(tablePage) + .as("a chunk that kept the encoding publishes its table") + .isNotNull(); + relay.receive(tablePage); + } + + ValuesReader reader = + encoding == Encoding.PLAIN ? new BinaryPlainValuesReader() : new SymbolTableValuesReader(relay); + reader.initFromPage(values.size(), ByteBufferInputStream.wrap(ByteBuffer.wrap(body))); + for (Binary value : values) { + assertThat(reader.readBytes()).isEqualTo(value); + } + return encoding; + } + + /** Replaying buffered values into another writer must not depend on the encoding having run. */ + @Test + public void fallingBackReplaysEveryValue() throws IOException { + List values = binaries("alpha", "", "beta", "gamma"); + List replayed = new ArrayList<>(); + ValuesWriter collector = new CollectingValuesWriter(replayed); + try (SymbolTableValuesWriter writer = writer(OffsetEncoding.DELTA_BINARY_PACKED)) { + for (Binary value : values) { + writer.writeBytes(value); + } + writer.fallBackAllValuesTo(collector); + } + assertThat(replayed).isEqualTo(values); + } + + @Test + public void aTableIsPublishedOnceAndRebuiltFromItsBytes() throws IOException { + SymbolTableRelay relay = new SymbolTableRelay(); + SymbolTablePage tablePage; + try (SymbolTableValuesWriter writer = writer(OffsetEncoding.DELTA_BINARY_PACKED)) { + for (int i = 0; i < 200; i++) { + writer.writeBytes(Binary.fromString("measurement-" + i)); + } + writer.getBytes(); + tablePage = writer.toSymbolTablePageAndClose(); + } + assertThat(tablePage.getType()).isEqualTo(SymbolTableType.FSST_8); + relay.receive(tablePage); + SymbolTable first = relay.getSymbolTable(); + assertThat(first.type()).isEqualTo(SymbolTableType.FSST_8); + assertThat(first.symbolCount()).as("a table was trained").isPositive(); + assertThat(relay.getSymbolTable().symbolCount()).isEqualTo(first.symbolCount()); + } + + @Test + public void theWriterReportsWhatItIsHoldingAndReleasesItOnReset() throws IOException { + try (SymbolTableValuesWriter writer = writer(OffsetEncoding.DELTA_BINARY_PACKED)) { + assertThat(writer.getBufferedSize()).isEqualTo(0); + writer.writeBytes(Binary.fromString("alpha")); + assertThat(writer.getBufferedSize()).isEqualTo(5 + 4); + writer.getBytes(); + writer.reset(); + assertThat(writer.getBufferedSize()).isEqualTo(0); + assertThat(writer.getAllocatedSize()).isPositive(); + assertThat(writer.memUsageString("x")).startsWith("x "); + assertThat(writer.symbolTableType()).isEqualTo(SymbolTableType.FSST_8); + // The only fallback question is asked once, and never by the writer itself. + assertThat(writer.isCompressionSatisfying(100, 99)).isTrue(); + assertThat(writer.isCompressionSatisfying(100, 100)).isFalse(); + assertThat(writer.shouldFallBack()).isFalse(); + } + } + + private static byte[] allByteValues() { + byte[] all = new byte[256]; + for (int i = 0; i < all.length; i++) { + all[i] = (byte) i; + } + return all; + } + + private static ColumnDescriptor descriptor(PrimitiveTypeName type) { + return new ColumnDescriptor(new String[] {"column"}, type, 0, 0); + } + + /** Records what a fallback replay hands it, so the replay can be compared value by value. */ + private static final class CollectingValuesWriter extends ValuesWriter { + + private final List values; + + CollectingValuesWriter(List values) { + this.values = values; + } + + @Override + public void writeBytes(Binary v) { + // Copy, because a replay is allowed to hand over a view of a buffer it will reuse. + values.add(Binary.fromString(new String(v.getBytes(), StandardCharsets.UTF_8))); + } + + @Override + public long getBufferedSize() { + return 0; + } + + @Override + public BytesInput getBytes() { + return BytesInput.empty(); + } + + @Override + public Encoding getEncoding() { + return Encoding.PLAIN; + } + + @Override + public void reset() { + values.clear(); + } + + @Override + public long getAllocatedSize() { + return 0; + } + + @Override + public String memUsageString(String prefix) { + return prefix; + } + } +} diff --git a/parquet-column/src/test/java/org/apache/parquet/column/values/symboltable/benchmark/BenchmarkSymbolTableEncoding.java b/parquet-column/src/test/java/org/apache/parquet/column/values/symboltable/benchmark/BenchmarkSymbolTableEncoding.java new file mode 100644 index 0000000000..1d3fc6bce8 --- /dev/null +++ b/parquet-column/src/test/java/org/apache/parquet/column/values/symboltable/benchmark/BenchmarkSymbolTableEncoding.java @@ -0,0 +1,391 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.parquet.column.values.symboltable.benchmark; + +import com.carrotsearch.junitbenchmarks.BenchmarkOptions; +import com.carrotsearch.junitbenchmarks.BenchmarkRule; +import com.carrotsearch.junitbenchmarks.annotation.AxisRange; +import com.carrotsearch.junitbenchmarks.annotation.BenchmarkMethodChart; +import com.github.luben.zstd.Zstd; +import java.io.IOException; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Random; +import org.apache.parquet.bytes.ByteBufferInputStream; +import org.apache.parquet.bytes.BytesInput; +import org.apache.parquet.bytes.DirectByteBufferAllocator; +import org.apache.parquet.column.Encoding; +import org.apache.parquet.column.page.DictionaryPage; +import org.apache.parquet.column.values.ValuesReader; +import org.apache.parquet.column.values.ValuesWriter; +import org.apache.parquet.column.values.deltalengthbytearray.DeltaLengthByteArrayValuesReader; +import org.apache.parquet.column.values.deltalengthbytearray.DeltaLengthByteArrayValuesWriter; +import org.apache.parquet.column.values.deltastrings.DeltaByteArrayReader; +import org.apache.parquet.column.values.deltastrings.DeltaByteArrayWriter; +import org.apache.parquet.column.values.dictionary.DictionaryValuesReader; +import org.apache.parquet.column.values.dictionary.DictionaryValuesWriter.PlainBinaryDictionaryValuesWriter; +import org.apache.parquet.column.values.dictionary.PlainValuesDictionary.PlainBinaryDictionary; +import org.apache.parquet.column.values.symboltable.SymbolTable; +import org.apache.parquet.column.values.symboltable.SymbolTablePayload.OffsetEncoding; +import org.apache.parquet.column.values.symboltable.SymbolTableType; +import org.apache.parquet.column.values.symboltable.SymbolTableValuesReader; +import org.apache.parquet.column.values.symboltable.SymbolTableValuesWriter; +import org.apache.parquet.column.values.symboltable.SymbolTables; +import org.apache.parquet.io.api.Binary; +import org.junit.Rule; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.migrationsupport.rules.EnableRuleMigrationSupport; + +/** + * Compares FSST against the encodings a text column falls back to today, with and without a zstd + * second pass, since the layered configuration is the one a real column ever ships with. + * + *

Every {@code *WithoutZstd}/{@code *WithZstd} pair isolates encode from decode: the bytes a + * decode benchmark reads are built once outside the timed rounds, and the side channel each + * encoding needs (a symbol table or a dictionary page) is decoded once as well, matching how a real + * reader amortizes it across every page of a chunk. + * + *

Ratios are not a JUnitBenchmarks metric, so they are printed once, up front, rather than folded + * into a timed round. + */ +@EnableRuleMigrationSupport +@AxisRange(min = 0, max = 1) +@BenchmarkMethodChart(filePrefix = "benchmark-symboltable-encoding") +public class BenchmarkSymbolTableEncoding { + + @Rule + public org.junit.rules.TestRule benchmarkRun = new BenchmarkRule(); + + private static final int INITIAL_SLAB_SIZE = 64 * 1024; + private static final int PAGE_SIZE = 4 * 1024 * 1024; + + private static final Binary[] VALUES = buildCorpus(); + private static final long RAW_BYTES = totalRawBytes(VALUES); + + // FSST + private static final byte[] FSST_TABLE_BYTES; + private static final byte[] FSST_PAYLOAD_BYTES; + private static final byte[] FSST_PAYLOAD_ZSTD; + private static final SymbolTable FSST_TABLE; + + // DELTA_LENGTH_BYTE_ARRAY + private static final byte[] DLBA_PAYLOAD_BYTES; + private static final byte[] DLBA_PAYLOAD_ZSTD; + + // DELTA_BYTE_ARRAY + private static final byte[] DBA_PAYLOAD_BYTES; + private static final byte[] DBA_PAYLOAD_ZSTD; + + // RLE_DICTIONARY + private static final byte[] DICT_PAGE_BYTES; + private static final int DICT_SIZE; + private static final byte[] DICT_INDEX_BYTES; + private static final byte[] DICT_INDEX_ZSTD; + private static final DictionaryPage DICTIONARY_PAGE; + + static { + try { + SymbolTableValuesWriter fsstWriter = new SymbolTableValuesWriter( + SymbolTableType.FSST_8, + OffsetEncoding.DELTA_BINARY_PACKED, + INITIAL_SLAB_SIZE, + PAGE_SIZE, + new DirectByteBufferAllocator()); + writeAll(fsstWriter, VALUES); + FSST_PAYLOAD_BYTES = fsstWriter.getBytes().toByteArray(); + FSST_TABLE_BYTES = fsstWriter.toSymbolTablePageAndClose().getBytes().toByteArray(); + FSST_TABLE = SymbolTables.deserialize(SymbolTableType.FSST_8, FSST_TABLE_BYTES, 0, FSST_TABLE_BYTES.length); + FSST_PAYLOAD_ZSTD = Zstd.compress(FSST_PAYLOAD_BYTES); + + DeltaLengthByteArrayValuesWriter dlbaWriter = + new DeltaLengthByteArrayValuesWriter(INITIAL_SLAB_SIZE, PAGE_SIZE, new DirectByteBufferAllocator()); + writeAll(dlbaWriter, VALUES); + DLBA_PAYLOAD_BYTES = dlbaWriter.getBytes().toByteArray(); + DLBA_PAYLOAD_ZSTD = Zstd.compress(DLBA_PAYLOAD_BYTES); + + DeltaByteArrayWriter dbaWriter = + new DeltaByteArrayWriter(INITIAL_SLAB_SIZE, PAGE_SIZE, new DirectByteBufferAllocator()); + writeAll(dbaWriter, VALUES); + DBA_PAYLOAD_BYTES = dbaWriter.getBytes().toByteArray(); + DBA_PAYLOAD_ZSTD = Zstd.compress(DBA_PAYLOAD_BYTES); + + PlainBinaryDictionaryValuesWriter dictWriter = new PlainBinaryDictionaryValuesWriter( + Integer.MAX_VALUE, Encoding.RLE_DICTIONARY, Encoding.PLAIN, new DirectByteBufferAllocator()); + writeAll(dictWriter, VALUES); + DICT_INDEX_BYTES = dictWriter.getBytes().toByteArray(); + DictionaryPage dictPage = dictWriter.toDictPageAndClose(); + DICT_PAGE_BYTES = dictPage.getBytes().toByteArray(); + DICT_SIZE = dictPage.getDictionarySize(); + DICTIONARY_PAGE = new DictionaryPage(BytesInput.from(DICT_PAGE_BYTES), DICT_SIZE, Encoding.PLAIN); + DICT_INDEX_ZSTD = Zstd.compress(DICT_INDEX_BYTES); + + report(); + } catch (IOException e) { + throw new ExceptionInInitializerError(e); + } + } + + /** + * Text with the shared structure real string columns have: repeated URL templates and repeated + * vocabulary, the case FSST and dictionary encoding both exist for. Random alphanumeric data, used + * elsewhere in this module, would flatter nothing and make the comparison meaningless. + */ + private static Binary[] buildCorpus() { + String[] words = { + "alpha", + "bravo", + "charlie", + "delta", + "echo", + "foxtrot", + "golf", + "hotel", + "india", + "juliet", + "kilo", + "lima", + "mike", + "november", + "oscar", + "papa" + }; + Random random = new Random(42); + List values = new ArrayList<>(); + for (int i = 0; i < 15000; i++) { + values.add("https://www.example.com/products/widget-" + i + "/reviews?page=" + (i % 37) + "&sort=" + + (i % 5 == 0 ? "asc" : "desc")); + } + for (int i = 0; i < 15000; i++) { + StringBuilder builder = new StringBuilder(); + int wordCount = 6 + random.nextInt(6); + for (int j = 0; j < wordCount; j++) { + if (j > 0) { + builder.append(' '); + } + builder.append(words[random.nextInt(words.length)]); + } + builder.append(" order-id=").append(i); + values.add(builder.toString()); + } + Collections.shuffle(values, random); + Binary[] binaries = new Binary[values.size()]; + for (int i = 0; i < binaries.length; i++) { + binaries[i] = Binary.fromString(values.get(i)); + } + return binaries; + } + + private static long totalRawBytes(Binary[] values) { + long total = 0; + for (Binary value : values) { + total += value.length(); + } + return total; + } + + private static void writeAll(ValuesWriter writer, Binary[] values) { + for (Binary value : values) { + writer.writeBytes(value); + } + } + + private static void readAll(ValuesReader reader, ByteBufferInputStream stream, int count) throws IOException { + reader.initFromPage(count, stream); + for (int i = 0; i < count; i++) { + reader.readBytes(); + } + } + + private static void report() { + System.out.printf( + "%-20s %12s %14s %14s %8s %8s%n", "encoding", "raw", "encoded", "encoded+zstd", "ratio", "ratio+zstd"); + reportOne( + "FSST", + FSST_TABLE_BYTES.length + FSST_PAYLOAD_BYTES.length, + FSST_TABLE_BYTES.length + FSST_PAYLOAD_ZSTD.length); + reportOne("DELTA_LENGTH_BYTE_ARRAY", DLBA_PAYLOAD_BYTES.length, DLBA_PAYLOAD_ZSTD.length); + reportOne("DELTA_BYTE_ARRAY", DBA_PAYLOAD_BYTES.length, DBA_PAYLOAD_ZSTD.length); + reportOne( + "RLE_DICTIONARY", + DICT_PAGE_BYTES.length + DICT_INDEX_BYTES.length, + DICT_PAGE_BYTES.length + DICT_INDEX_ZSTD.length); + } + + private static void reportOne(String name, long encoded, long encodedZstd) { + System.out.printf( + "%-20s %12d %14d %14d %8.3f %8.3f%n", + name, RAW_BYTES, encoded, encodedZstd, (double) encoded / RAW_BYTES, (double) encodedZstd / RAW_BYTES); + } + + // FSST + + @BenchmarkOptions(benchmarkRounds = 10, warmupRounds = 3) + @Test + public void fsstEncodeWithoutZstd() { + SymbolTableValuesWriter writer = new SymbolTableValuesWriter( + SymbolTableType.FSST_8, + OffsetEncoding.DELTA_BINARY_PACKED, + INITIAL_SLAB_SIZE, + PAGE_SIZE, + new DirectByteBufferAllocator()); + writeAll(writer, VALUES); + writer.getBytes(); + writer.toSymbolTablePageAndClose(); + } + + @BenchmarkOptions(benchmarkRounds = 10, warmupRounds = 3) + @Test + public void fsstEncodeWithZstd() throws IOException { + SymbolTableValuesWriter writer = new SymbolTableValuesWriter( + SymbolTableType.FSST_8, + OffsetEncoding.DELTA_BINARY_PACKED, + INITIAL_SLAB_SIZE, + PAGE_SIZE, + new DirectByteBufferAllocator()); + writeAll(writer, VALUES); + byte[] payload = writer.getBytes().toByteArray(); + byte[] table = writer.toSymbolTablePageAndClose().getBytes().toByteArray(); + Zstd.compress(payload); + Zstd.compress(table); + } + + @BenchmarkOptions(benchmarkRounds = 10, warmupRounds = 3) + @Test + public void fsstDecodeWithoutZstd() throws IOException { + SymbolTableValuesReader reader = new SymbolTableValuesReader(() -> FSST_TABLE); + readAll(reader, BytesInput.from(FSST_PAYLOAD_BYTES).toInputStream(), VALUES.length); + } + + @BenchmarkOptions(benchmarkRounds = 10, warmupRounds = 3) + @Test + public void fsstDecodeWithZstd() throws IOException { + byte[] payload = Zstd.decompress(FSST_PAYLOAD_ZSTD, FSST_PAYLOAD_BYTES.length); + SymbolTableValuesReader reader = new SymbolTableValuesReader(() -> FSST_TABLE); + readAll(reader, BytesInput.from(payload).toInputStream(), VALUES.length); + } + + // DELTA_LENGTH_BYTE_ARRAY + + @BenchmarkOptions(benchmarkRounds = 10, warmupRounds = 3) + @Test + public void deltaLengthByteArrayEncodeWithoutZstd() { + DeltaLengthByteArrayValuesWriter writer = + new DeltaLengthByteArrayValuesWriter(INITIAL_SLAB_SIZE, PAGE_SIZE, new DirectByteBufferAllocator()); + writeAll(writer, VALUES); + writer.getBytes(); + } + + @BenchmarkOptions(benchmarkRounds = 10, warmupRounds = 3) + @Test + public void deltaLengthByteArrayEncodeWithZstd() throws IOException { + DeltaLengthByteArrayValuesWriter writer = + new DeltaLengthByteArrayValuesWriter(INITIAL_SLAB_SIZE, PAGE_SIZE, new DirectByteBufferAllocator()); + writeAll(writer, VALUES); + Zstd.compress(writer.getBytes().toByteArray()); + } + + @BenchmarkOptions(benchmarkRounds = 10, warmupRounds = 3) + @Test + public void deltaLengthByteArrayDecodeWithoutZstd() throws IOException { + readAll( + new DeltaLengthByteArrayValuesReader(), + BytesInput.from(DLBA_PAYLOAD_BYTES).toInputStream(), + VALUES.length); + } + + @BenchmarkOptions(benchmarkRounds = 10, warmupRounds = 3) + @Test + public void deltaLengthByteArrayDecodeWithZstd() throws IOException { + byte[] payload = Zstd.decompress(DLBA_PAYLOAD_ZSTD, DLBA_PAYLOAD_BYTES.length); + readAll(new DeltaLengthByteArrayValuesReader(), BytesInput.from(payload).toInputStream(), VALUES.length); + } + + // DELTA_BYTE_ARRAY + + @BenchmarkOptions(benchmarkRounds = 10, warmupRounds = 3) + @Test + public void deltaByteArrayEncodeWithoutZstd() { + DeltaByteArrayWriter writer = + new DeltaByteArrayWriter(INITIAL_SLAB_SIZE, PAGE_SIZE, new DirectByteBufferAllocator()); + writeAll(writer, VALUES); + writer.getBytes(); + } + + @BenchmarkOptions(benchmarkRounds = 10, warmupRounds = 3) + @Test + public void deltaByteArrayEncodeWithZstd() throws IOException { + DeltaByteArrayWriter writer = + new DeltaByteArrayWriter(INITIAL_SLAB_SIZE, PAGE_SIZE, new DirectByteBufferAllocator()); + writeAll(writer, VALUES); + Zstd.compress(writer.getBytes().toByteArray()); + } + + @BenchmarkOptions(benchmarkRounds = 10, warmupRounds = 3) + @Test + public void deltaByteArrayDecodeWithoutZstd() throws IOException { + readAll(new DeltaByteArrayReader(), BytesInput.from(DBA_PAYLOAD_BYTES).toInputStream(), VALUES.length); + } + + @BenchmarkOptions(benchmarkRounds = 10, warmupRounds = 3) + @Test + public void deltaByteArrayDecodeWithZstd() throws IOException { + byte[] payload = Zstd.decompress(DBA_PAYLOAD_ZSTD, DBA_PAYLOAD_BYTES.length); + readAll(new DeltaByteArrayReader(), BytesInput.from(payload).toInputStream(), VALUES.length); + } + + // RLE_DICTIONARY + + @BenchmarkOptions(benchmarkRounds = 10, warmupRounds = 3) + @Test + public void rleDictionaryEncodeWithoutZstd() { + PlainBinaryDictionaryValuesWriter writer = new PlainBinaryDictionaryValuesWriter( + Integer.MAX_VALUE, Encoding.RLE_DICTIONARY, Encoding.PLAIN, new DirectByteBufferAllocator()); + writeAll(writer, VALUES); + writer.getBytes(); + writer.toDictPageAndClose(); + } + + @BenchmarkOptions(benchmarkRounds = 10, warmupRounds = 3) + @Test + public void rleDictionaryEncodeWithZstd() throws IOException { + PlainBinaryDictionaryValuesWriter writer = new PlainBinaryDictionaryValuesWriter( + Integer.MAX_VALUE, Encoding.RLE_DICTIONARY, Encoding.PLAIN, new DirectByteBufferAllocator()); + writeAll(writer, VALUES); + byte[] index = writer.getBytes().toByteArray(); + byte[] dict = writer.toDictPageAndClose().getBytes().toByteArray(); + Zstd.compress(index); + Zstd.compress(dict); + } + + @BenchmarkOptions(benchmarkRounds = 10, warmupRounds = 3) + @Test + public void rleDictionaryDecodeWithoutZstd() throws IOException { + DictionaryValuesReader reader = new DictionaryValuesReader(new PlainBinaryDictionary(DICTIONARY_PAGE)); + readAll(reader, BytesInput.from(DICT_INDEX_BYTES).toInputStream(), VALUES.length); + } + + @BenchmarkOptions(benchmarkRounds = 10, warmupRounds = 3) + @Test + public void rleDictionaryDecodeWithZstd() throws IOException { + byte[] index = Zstd.decompress(DICT_INDEX_ZSTD, DICT_INDEX_BYTES.length); + DictionaryValuesReader reader = new DictionaryValuesReader(new PlainBinaryDictionary(DICTIONARY_PAGE)); + readAll(reader, BytesInput.from(index).toInputStream(), VALUES.length); + } +} diff --git a/parquet-column/src/test/java/org/apache/parquet/column/values/symboltable/fsst/FsstCodecRoundTripTest.java b/parquet-column/src/test/java/org/apache/parquet/column/values/symboltable/fsst/FsstCodecRoundTripTest.java new file mode 100644 index 0000000000..76b93ff5ac --- /dev/null +++ b/parquet-column/src/test/java/org/apache/parquet/column/values/symboltable/fsst/FsstCodecRoundTripTest.java @@ -0,0 +1,233 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.parquet.column.values.symboltable.fsst; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; +import java.util.Random; +import org.apache.parquet.column.values.symboltable.CodeStreamDecoder; +import org.apache.parquet.column.values.symboltable.CodeStreamEncoder; +import org.apache.parquet.column.values.symboltable.SymbolTable; +import org.apache.parquet.column.values.symboltable.SymbolTableType; +import org.apache.parquet.column.values.symboltable.TrainedSymbolTable; +import org.apache.parquet.column.values.symboltable.ValueBuffer; +import org.junit.jupiter.api.Test; + +/** + * Round trips the FSST codec over inputs chosen to reach the places the port could be wrong: the + * chunk boundary, the escape path, the byte values a signed Java byte gets wrong, and a table read + * back from its serialized form rather than the one training produced. + */ +public class FsstCodecRoundTripTest { + + /** Trains on the values, compresses each one, expands it again and requires the bytes back. */ + private static Result roundTrip(List values) throws IOException { + ValueBuffer buffer = new ValueBuffer(); + for (byte[] value : values) { + buffer.add(value, 0, value.length); + } + TrainedSymbolTable trained = new FsstTrainer().train(buffer); + SymbolTable table = trained.table(); + CodeStreamEncoder encoder = trained.encoder(); + + // Decode through a table read back from bytes, not the one training produced, so the + // serialization and the renumbering are both under test. + byte[] serialized = table.serialize().toByteArray(); + SymbolTable reread = Fsst8SymbolTable.deserialize(serialized, 0, serialized.length); + assertThat(reread.type()).isEqualTo(SymbolTableType.FSST_8); + assertThat(reread.symbolCount()).isEqualTo(table.symbolCount()); + assertThat(reread.serialize().toByteArray()).isEqualTo(serialized); + CodeStreamDecoder decoder = reread.decoder(); + + int codeBytes = 0; + int rawBytes = 0; + for (int i = 0; i < values.size(); i++) { + byte[] value = values.get(i); + byte[] codes = new byte[encoder.maxCompressedLength(value.length)]; + int codeLength = encoder.compress(buffer.data(), buffer.offset(i), buffer.length(i), codes, 0); + assertThat(codeLength).as("compressed past the declared bound").isLessThanOrEqualTo(codes.length); + + assertThat(decoder.expandedLength(codes, 0, codeLength)) + .as("expandedLength disagrees with expand on value " + i) + .isEqualTo(value.length); + byte[] expanded = new byte[value.length]; + int written = decoder.expand(codes, 0, codeLength, expanded, 0); + assertThat(written).as("wrong expanded length for value " + i).isEqualTo(value.length); + assertThat(expanded) + .as("value " + i + " did not survive the round trip") + .isEqualTo(value); + + codeBytes += codeLength; + rawBytes += value.length; + } + return new Result(table, rawBytes, codeBytes); + } + + private static final class Result { + final SymbolTable table; + final int rawBytes; + final int codeBytes; + + Result(SymbolTable table, int rawBytes, int codeBytes) { + this.table = table; + this.rawBytes = rawBytes; + this.codeBytes = codeBytes; + } + } + + private static List strings(String... values) { + List result = new ArrayList<>(); + for (String value : values) { + result.add(value.getBytes(StandardCharsets.UTF_8)); + } + return result; + } + + @Test + public void compressesRepetitiveTextAndGetsItBack() throws IOException { + List values = new ArrayList<>(); + for (int i = 0; i < 2000; i++) { + values.add(("https://www.example.com/products/widget-" + i + "/reviews?page=" + (i % 7)) + .getBytes(StandardCharsets.UTF_8)); + } + Result result = roundTrip(values); + assertThat(result.table.symbolCount()) + .as("the table should hold symbols") + .isPositive(); + // A corpus this repetitive is the case the encoding exists for; if it does not shrink here the + // trainer is not finding the shared substrings. + assertThat(result.codeBytes * 2) + .as("expected well under half the bytes, got " + result.codeBytes + " of " + result.rawBytes) + .isLessThan(result.rawBytes); + } + + @Test + public void handlesEveryByteValue() throws IOException { + List values = new ArrayList<>(); + for (int i = 0; i < 256; i++) { + byte[] value = new byte[16]; + for (int j = 0; j < value.length; j++) { + value[j] = (byte) ((i + j) & 0xFF); + } + values.add(value); + } + roundTrip(values); + } + + @Test + public void handlesValuesLongerThanOneChunk() throws IOException { + // 511 bytes is the chunk the compressor works in, so a value has to be driven across it. + List values = new ArrayList<>(); + for (int length : new int[] {509, 510, 511, 512, 513, 1021, 1022, 1023, 4096}) { + StringBuilder builder = new StringBuilder(); + while (builder.length() < length) { + builder.append("the quick brown fox jumps over the lazy dog "); + } + values.add(builder.substring(0, length).getBytes(StandardCharsets.UTF_8)); + } + roundTrip(values); + } + + @Test + public void handlesIncompressibleBytes() throws IOException { + // Random bytes give the trainer nothing to work with, so nearly every byte escapes. + Random random = new Random(20260908); + List values = new ArrayList<>(); + for (int i = 0; i < 500; i++) { + byte[] value = new byte[1 + random.nextInt(64)]; + random.nextBytes(value); + values.add(value); + } + Result result = roundTrip(values); + assertThat(result.codeBytes) + .as("escaping should cost bytes, not save them") + .isGreaterThanOrEqualTo(result.rawBytes); + } + + @Test + public void handlesEmptyAndTinyInputs() throws IOException { + roundTrip(new ArrayList<>()); + roundTrip(strings("")); + roundTrip(strings("a")); + roundTrip(strings("", "", "")); + roundTrip(strings("a", "", "bb", "", "ccc")); + } + + @Test + public void handlesAValueMadeOnlyOfOneRepeatedByte() throws IOException { + List values = new ArrayList<>(); + for (int i = 0; i < 100; i++) { + byte[] value = new byte[300]; + java.util.Arrays.fill(value, (byte) 0); + values.add(value); + } + roundTrip(values); + } + + @Test + public void trainsOnACorpusLargerThanTheSample() throws IOException { + // Past 16 KiB the trainer samples rather than reading everything, which is a different path. + Random random = new Random(4637947); + String[] words = {"alpha", "bravo", "charlie", "delta", "echo", "foxtrot", "golf", "hotel"}; + List values = new ArrayList<>(); + for (int i = 0; i < 5000; i++) { + StringBuilder builder = new StringBuilder(); + for (int j = 0; j < 8; j++) { + builder.append(words[random.nextInt(words.length)]).append(' '); + } + values.add(builder.toString().getBytes(StandardCharsets.UTF_8)); + } + Result result = roundTrip(values); + assertThat(result.codeBytes * 2) + .as("expected well under half the bytes, got " + result.codeBytes + " of " + result.rawBytes) + .isLessThan(result.rawBytes); + } + + @Test + public void symbolTableStaysWithinItsFormatLimits() throws IOException { + Random random = new Random(1); + List values = new ArrayList<>(); + for (int i = 0; i < 3000; i++) { + byte[] value = new byte[32]; + for (int j = 0; j < value.length; j++) { + value[j] = (byte) ('a' + random.nextInt(26)); + } + values.add(value); + } + Result result = roundTrip(values); + assertThat(result.table.symbolCount()).isLessThanOrEqualTo(FsstCodes.MAX_SYMBOLS); + for (int code = 0; code < result.table.symbolCount(); code++) { + int length = result.table.symbolLength(code); + assertThat(length).as("symbol " + code).isBetween(1, 8); + } + // The serialized table must be in length order, which is what lets a reader rebuild it from the + // length histogram alone. + for (int code = 1; code < result.table.symbolCount(); code++) { + assertThat(result.table.symbolLength(code - 1)) + .as("symbols are not in length order at code " + code) + .isLessThanOrEqualTo(result.table.symbolLength(code)); + } + int size = (int) result.table.serialize().size(); + assertThat(size).as("serialized table size").isBetween(9, 2049); + } +} diff --git a/parquet-column/src/test/java/org/apache/parquet/column/values/symboltable/fsst/FsstReferenceComparisonTest.java b/parquet-column/src/test/java/org/apache/parquet/column/values/symboltable/fsst/FsstReferenceComparisonTest.java new file mode 100644 index 0000000000..09ff85c6cd --- /dev/null +++ b/parquet-column/src/test/java/org/apache/parquet/column/values/symboltable/fsst/FsstReferenceComparisonTest.java @@ -0,0 +1,248 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.parquet.column.values.symboltable.fsst; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.ArrayList; +import java.util.List; +import org.apache.parquet.column.values.symboltable.CodeStreamEncoder; +import org.apache.parquet.column.values.symboltable.TrainedSymbolTable; +import org.apache.parquet.column.values.symboltable.ValueBuffer; +import org.junit.jupiter.api.Test; + +/** + * Requires this implementation to produce the same symbol table and the same code bytes as the + * reference implementation, for corpora chosen to reach the places a port can drift. + * + *

This is the test that makes the port trustworthy. A round trip only proves the decoder undoes + * whatever the encoder did, so a trainer that picks worse symbols, or numbers them differently, + * passes it while writing files no other implementation would have written. Comparing against bytes + * the reference produced catches both. + * + *

The fixtures under {@code src/test/resources/fsst} hold, per corpus, the serialized symbol table + * and the concatenated code bytes, produced by the reference implementation. The corpora themselves + * are not stored: they are generated here from the arithmetic below, which is simple enough to + * restate in any language, and each one's digest is asserted so a drifting generator fails as a + * generator rather than as a codec. + */ +public class FsstReferenceComparisonTest { + + /** + * The generator both sides use, spelled out rather than taken from a library so that regenerating + * the fixtures from another language gives the same bytes. + */ + private static final class Lcg { + private int state; + + Lcg(int seed) { + this.state = seed & 0x7FFFFFFF; + } + + int next() { + state = (state * 1103515245 + 12345) & 0x7FFFFFFF; + return state; + } + + int below(int bound) { + return next() % bound; + } + + int nextByte() { + return (next() >> 16) & 0xFF; + } + } + + private static final String[] WORDS = { + "alpha", "bravo", "charlie", "delta", "echo", "foxtrot", "golf", + "hotel", "india", "juliet", "kilo", "lima", "mike", "november" + }; + + /** Ordinary text with heavy shared structure: what the encoding is for. */ + private static List urls() { + List values = new ArrayList<>(); + for (int i = 0; i < 200; i++) { + values.add(ascii("https://www.example.com/products/widget-" + i + "/reviews?page=" + (i % 7))); + } + return values; + } + + /** Past the sample target, so the trainer samples instead of reading everything. */ + private static List sampled() { + Lcg random = new Lcg(1); + List values = new ArrayList<>(); + for (int i = 0; i < 1200; i++) { + StringBuilder builder = new StringBuilder(); + for (int j = 0; j < 9; j++) { + if (j > 0) { + builder.append(' '); + } + builder.append(WORDS[random.below(WORDS.length)]); + } + values.add(ascii(builder + " id=" + i)); + } + return values; + } + + /** Nothing to compress, so nearly every byte escapes and the table saturates. */ + private static List binary() { + Lcg random = new Lcg(2); + List values = new ArrayList<>(); + for (int i = 0; i < 200; i++) { + // The length is drawn before any of the bytes, which is the order the fixtures were made in. + int length = 1 + random.below(64); + byte[] value = new byte[length]; + for (int j = 0; j < length; j++) { + value[j] = (byte) random.nextByte(); + } + values.add(value); + } + return values; + } + + /** Every byte value, including the ones a signed Java byte gets wrong. */ + private static List allBytes() { + List values = new ArrayList<>(); + for (int i = 0; i < 256; i++) { + byte[] value = new byte[16]; + for (int j = 0; j < value.length; j++) { + value[j] = (byte) ((i + j) & 0xFF); + } + values.add(value); + } + return values; + } + + /** Values straddling the 511-byte chunk the compressor works in. */ + private static List chunks() { + String base = "the quick brown fox jumps over the lazy dog "; + List values = new ArrayList<>(); + for (int length : new int[] {509, 510, 511, 512, 513, 1021, 1022, 1023, 4096}) { + StringBuilder builder = new StringBuilder(); + while (builder.length() < length) { + builder.append(base); + } + values.add(ascii(builder.substring(0, length))); + } + return values; + } + + private static List empties() { + List values = new ArrayList<>(); + for (String value : new String[] {"", "a", "", "bb", "", "ccc", ""}) { + values.add(ascii(value)); + } + return values; + } + + private static byte[] ascii(String value) { + return value.getBytes(StandardCharsets.ISO_8859_1); + } + + @Test + public void matchesTheReferenceOnSharedStructure() throws IOException { + check("urls", urls(), "f3f5706428f9100812a29c36d865faf7298bd84b0bdec2d4090f1a5aee0f2f7d"); + } + + @Test + public void matchesTheReferenceWhenTheTrainerSamples() throws IOException { + check("sampled", sampled(), "f1517b97140990c898ce71a9acf144c4fa670afb4e391d4682972710965e6f64"); + } + + @Test + public void matchesTheReferenceOnIncompressibleBytes() throws IOException { + check("binary", binary(), "3b1a118902ff50b0359663084ac76190d0e865c6ce3774c5085187ebebdd38a6"); + } + + @Test + public void matchesTheReferenceOnEveryByteValue() throws IOException { + check("allbytes", allBytes(), "db4288e84084c52f1dbb79b88715529fa50c4ddd4159307c562c1f93e8521d0d"); + } + + @Test + public void matchesTheReferenceAcrossTheChunkBoundary() throws IOException { + check("chunks", chunks(), "9163babc5f91c5bd2564a64ee6f40f0968595fc93e66e89db8001e8636f9e083"); + } + + @Test + public void matchesTheReferenceOnEmptyAndTinyValues() throws IOException { + check("empties", empties(), "807d87de83260feea2276cabc85fe028f9c35d439a05d6eb50689e4873c8945b"); + } + + private void check(String corpus, List values, String expectedDigest) throws IOException { + ValueBuffer buffer = new ValueBuffer(); + for (byte[] value : values) { + buffer.add(value, 0, value.length); + } + assertThat(sha256(buffer.data(), buffer.byteCount())) + .as(corpus + ": the generated corpus does not match the one the fixtures were made from") + .isEqualTo(expectedDigest); + + TrainedSymbolTable trained = new FsstTrainer().train(buffer); + assertThat(trained.table().serialize().toByteArray()) + .as(corpus + ": symbol table differs from the reference implementation") + .isEqualTo(resource(corpus + ".table")); + + CodeStreamEncoder encoder = trained.encoder(); + ByteArrayOutputStream codes = new ByteArrayOutputStream(); + for (int i = 0; i < values.size(); i++) { + byte[] output = new byte[encoder.maxCompressedLength(buffer.length(i))]; + int length = encoder.compress(buffer.data(), buffer.offset(i), buffer.length(i), output, 0); + codes.write(output, 0, length); + } + assertThat(codes.toByteArray()) + .as(corpus + ": code bytes differ from the reference implementation") + .isEqualTo(resource(corpus + ".codes")); + } + + private static byte[] resource(String name) throws IOException { + try (InputStream in = FsstReferenceComparisonTest.class.getResourceAsStream("/fsst/" + name)) { + if (in == null) { + throw new IOException("missing test resource /fsst/" + name); + } + ByteArrayOutputStream out = new ByteArrayOutputStream(); + byte[] chunk = new byte[8192]; + int read; + while ((read = in.read(chunk)) > 0) { + out.write(chunk, 0, read); + } + return out.toByteArray(); + } + } + + private static String sha256(byte[] data, int length) { + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + digest.update(data, 0, length); + StringBuilder hex = new StringBuilder(); + for (byte b : digest.digest()) { + hex.append(Character.forDigit((b >> 4) & 0xF, 16)).append(Character.forDigit(b & 0xF, 16)); + } + return hex.toString(); + } catch (NoSuchAlgorithmException e) { + throw new AssertionError(e); + } + } +} diff --git a/parquet-column/src/test/resources/fsst/allbytes.codes b/parquet-column/src/test/resources/fsst/allbytes.codes new file mode 100644 index 0000000000..58c5ac43f4 Binary files /dev/null and b/parquet-column/src/test/resources/fsst/allbytes.codes differ diff --git a/parquet-column/src/test/resources/fsst/allbytes.table b/parquet-column/src/test/resources/fsst/allbytes.table new file mode 100644 index 0000000000..befdbb621e Binary files /dev/null and b/parquet-column/src/test/resources/fsst/allbytes.table differ diff --git a/parquet-column/src/test/resources/fsst/binary.codes b/parquet-column/src/test/resources/fsst/binary.codes new file mode 100644 index 0000000000..c26c88f548 Binary files /dev/null and b/parquet-column/src/test/resources/fsst/binary.codes differ diff --git a/parquet-column/src/test/resources/fsst/binary.table b/parquet-column/src/test/resources/fsst/binary.table new file mode 100644 index 0000000000..9d4584df97 Binary files /dev/null and b/parquet-column/src/test/resources/fsst/binary.table differ diff --git a/parquet-column/src/test/resources/fsst/chunks.codes b/parquet-column/src/test/resources/fsst/chunks.codes new file mode 100644 index 0000000000..851fa256b7 --- /dev/null +++ b/parquet-column/src/test/resources/fsst/chunks.codes @@ -0,0 +1,5 @@ +       ÿv  ÿv +  ÿv   ÿv    ÿv    ÿv  ÿr + ÿl ÿzÿy ÿx ÿg  +  ÿv +ÿr  ÿrÿwÿn  ÿzÿy  \ No newline at end of file diff --git a/parquet-column/src/test/resources/fsst/chunks.table b/parquet-column/src/test/resources/fsst/chunks.table new file mode 100644 index 0000000000..e7f2f3cbf4 Binary files /dev/null and b/parquet-column/src/test/resources/fsst/chunks.table differ diff --git a/parquet-column/src/test/resources/fsst/empties.codes b/parquet-column/src/test/resources/fsst/empties.codes new file mode 100644 index 0000000000..2c59794a1f --- /dev/null +++ b/parquet-column/src/test/resources/fsst/empties.codes @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/parquet-column/src/test/resources/fsst/empties.table b/parquet-column/src/test/resources/fsst/empties.table new file mode 100644 index 0000000000..3385247f7c Binary files /dev/null and b/parquet-column/src/test/resources/fsst/empties.table differ diff --git a/parquet-column/src/test/resources/fsst/interop/escapes-binary.pages b/parquet-column/src/test/resources/fsst/interop/escapes-binary.pages new file mode 100644 index 0000000000..9b3625002e Binary files /dev/null and b/parquet-column/src/test/resources/fsst/interop/escapes-binary.pages differ diff --git a/parquet-column/src/test/resources/fsst/interop/escapes.pages b/parquet-column/src/test/resources/fsst/interop/escapes.pages new file mode 100644 index 0000000000..44548edf57 Binary files /dev/null and b/parquet-column/src/test/resources/fsst/interop/escapes.pages differ diff --git a/parquet-column/src/test/resources/fsst/interop/expected.txt b/parquet-column/src/test/resources/fsst/interop/expected.txt new file mode 100644 index 0000000000..2630d88822 --- /dev/null +++ b/parquet-column/src/test/resources/fsst/interop/expected.txt @@ -0,0 +1,74 @@ +# What the FSST-encoded columns of the Parquet interop file decode to. +# +# Source: data/fsst.parquet from apache/parquet-testing pull request 121, at commit +# a3e866d12b5999d053d7184d914257128526a72e of CurtHagenlocher/parquet-testing, branch +# fsst-test-data. The file was written by a C++ implementation, not by this one, so it is +# what makes the page layout in this package a shared format rather than a private one. +# +# The file holds six BYTE_ARRAY columns over four row groups. Two are written with +# DELTA_LENGTH_BYTE_ARRAY and hold the same values as the encoded ones, which is how the +# expected values below were obtained: the reference columns were decoded, and the encoded +# columns were required to agree with them value for value before anything was written here. +# The 8-bit columns of row groups 0, 1 and 3 hold the same bytes as their binary +# counterparts, so those row groups appear once; row group 2 appears twice because its two +# flavours differ. The 16-bit columns are not extracted, since nothing reads them yet. +# +# One .pages file per case, holding the chunk's symbol table and the body of each of its +# data pages, big-endian throughout: +# +# [1B] symbol table representation, 0 for the 8-bit one +# [4B] symbol table body length, then the body +# [4B] page count, then per page: [4B] body length, then the body +# +# A page body is what this package's page layout defines and what the reader is handed: the +# offset encoding byte, the value count, the offset section length, the offsets, the codes. +# Definition and repetition levels are not included; the row group with nulls contributes only +# its non-null values, which is what the page itself carries. +# +# Below, a line without a dot is a whole chunk -- name, page count, value count, digest -- and +# a line "case.n" is that chunk's nth page. The digest is SHA-256 over every value in order, +# each preceded by its length as four big-endian bytes. +# +# Which case covers what: +# +# urls 400 high-cardinality URLs over 14 pages sharing one table: the happy path +# nulls-and-empties zero-length values, and pages whose value count is short of the row count +# escapes values carrying bytes the table does not cover, so the escape code is used +# escapes-binary the same row group's non-UTF-8 flavour, which trains a different table +# plain-offsets few values per page, which is where the writer chooses a plain offset array +# +urls 14 400 46d500390735c3ed984981afc27d090cbf0d58ac914875cddcb3583503555cac +urls.0 30 3c3ced3e8be83e9e0af1d957a6976ac3b3cdcf7b2b02e3e7dfc8bafeea20a683 +urls.1 30 797e8d348c6a654f7c6b4ae8ecc0eac17db955d02e41b4bbbe95806a63ecb28a +urls.2 30 91317b7b611e235f06dbbc9226e2fc7fe5eb5a0d617ded65a793f9e134bcbc68 +urls.3 30 3fcf81288ccbe1cbafd0fcf02dcd4b8719fef6a3396017c1f2dc5a9bb81b06e1 +urls.4 30 830ecabc837dc20573ecb13d9027c5f0a9b27e7faaee507746257f466123dd75 +urls.5 30 be69bb6486a00be0d21f9d34dbdc3282a6e7a538e7e6acae773f46bd996951c4 +urls.6 30 52821bd775d18b33368577efd4ea3c0df2a480eb9ccfdfdec7498d73c8665810 +urls.7 30 235928cd75e654a6f860dc29bc08be799ed6a2b3ac51ea3dd45a9395c3149516 +urls.8 30 613392ad6b0d1230b3168a20685f822a9d972d292d6a249e53afe473ef39ab86 +urls.9 30 adeed260fa95409e6b2a65341a7242abe2cb39f310e8a074a9e14635470bc2ff +urls.10 30 e844d10cdde55dae5f0c4dd81b952f2bbc6dc32cbf9c42c2a92e01194def3763 +urls.11 30 9a8548e3e48816a74f2f9f88b1d9e4c7f94183e47f8fff09e964d639b651f672 +urls.12 30 2f51699e677188d9619fc722d490b73a6f2945f8f77ac25ccebd229a774e0c4d +urls.13 10 2d74a5aa1ae1c67af058de3b87a070c7074894b8714e0036f978f4c5900bb982 +nulls-and-empties 4 205 3b215b89472eaeba6617c2095747b387d281515d3595e54b9693d63fdad51384 +nulls-and-empties.0 56 594ddf869a94a45d10a69db7f7980c14f73f7f666ebff91dd57e6398d403080b +nulls-and-empties.1 57 783da2c2700596d20de807e1f22ebffeb1987ef412adff05abca66d1795b49bd +nulls-and-empties.2 56 6a643038567e2bf6c18fc612f8378af9881ccd7ca71c8bc89fdc0665597d8e52 +nulls-and-empties.3 36 592fede8404912a570ced7c9f89bbe1c54b5e8723ac7f2a28ce714c5dd6deee3 +escapes 4 200 774ca683fa2d5197f4520443b6e380e2fb1fe1a866b776d02cd1a1229431aaea +escapes.0 52 627b643ca267777e9e1ed642e98aed9a3ab835cc95ecffac45e7f67fc27448e1 +escapes.1 52 1ea0e8039979774336f24990cd9226bcb75349b2677d42589d81825d1e01c3d8 +escapes.2 52 934c74b0018f91c75c8c670f9aaa8c5eb460848c9a7eeb0701cbfbd8e489fa37 +escapes.3 44 4b4224e6879f12d3537d7f5b50b6e52341451814e373c7d5433efd6ee3a087a7 +escapes-binary 4 200 584ff5cb26ccbea4ccb673914063feb2e1304ad0e8e7e0fc397b8bd3049b4fdc +escapes-binary.0 64 1ee07765528b660f266c1f55133d7922b551529352a15e72b442baf7cb94c784 +escapes-binary.1 64 f6a6c74ffff9e33bb579517172acc9eed88dbb266f4b7384a43b2320709b453a +escapes-binary.2 64 b272a8031ea6b589aed2fbc0f325ea5f80ef27bdf621ed515e3bb9807a16cfc7 +escapes-binary.3 8 7ed64f1e651cbd5c4ec6e57baf076ed09497c1167df46197d17b08e69fba17d7 +plain-offsets 4 16 0751792bf177aed99ed68e659f0ce52005f3fb5a7f8b572ceeb5bb82b0bc2aca +plain-offsets.0 5 e61b2d20741eaa207f4493a63308543fa51d343df29938716975835c3204094f +plain-offsets.1 5 2e6b4f68912a3a29d2286bd75dac3734a3e7f36d478ceed3925fdeaa05a37ded +plain-offsets.2 5 1249bea2cd8c9649b35fa2d29c1c866c1952bdd849a6ef993dc97153284d6826 +plain-offsets.3 1 0654548570d1e478330069f559fdcb1833e85c07696fb35ac9293645f1575318 diff --git a/parquet-column/src/test/resources/fsst/interop/nulls-and-empties.pages b/parquet-column/src/test/resources/fsst/interop/nulls-and-empties.pages new file mode 100644 index 0000000000..3396f5ec7c Binary files /dev/null and b/parquet-column/src/test/resources/fsst/interop/nulls-and-empties.pages differ diff --git a/parquet-column/src/test/resources/fsst/interop/plain-offsets.pages b/parquet-column/src/test/resources/fsst/interop/plain-offsets.pages new file mode 100644 index 0000000000..f1b61fa3d4 Binary files /dev/null and b/parquet-column/src/test/resources/fsst/interop/plain-offsets.pages differ diff --git a/parquet-column/src/test/resources/fsst/interop/urls.pages b/parquet-column/src/test/resources/fsst/interop/urls.pages new file mode 100644 index 0000000000..6006ba28f0 Binary files /dev/null and b/parquet-column/src/test/resources/fsst/interop/urls.pages differ diff --git a/parquet-column/src/test/resources/fsst/sampled.codes b/parquet-column/src/test/resources/fsst/sampled.codes new file mode 100644 index 0000000000..37f79f504d --- /dev/null +++ b/parquet-column/src/test/resources/fsst/sampled.codes @@ -0,0 +1 @@ +:?:LG3=M8A?@LD?3M><=A?<6E3>A89LB=<839=:?;?<6C3=A><=96HL7:8<8ALDLG32?M?<=<67@LCLDLE893=:KA=;?@6NLE67:?A>9LD?A3=:=M8@89K3$@6B8:6D6D3=M6NI@=@>3;89>@?3:>9>@?M?93!=;K;LH><38<><6B=9?3<6H8A6B6H3+K;LB?;K;>3&:>:=9?<6C3>:LELD=;>3A>;K98@36BK3M>A8<><6H38ALG6H=A>3@>@>;36NK:=A=AK3:6NF@K;=M3LNLELD6HL7:>M3=9?;LC=;67A>M=;=A6C3>M8ALNLNJ39LNLNLB=@3-8;8@LB8AL7/@6NOM6NIA3LNJ96B?;K3;89?AK;>M3*?9=:LG>;?3M=:=MKMLD3>A6HLE8;?3@8;><=M?A36HK;8A3=9?MLC8@83M?;?9>96C3)=AK;6DLCK3;83%A?;6B3LB=:?:?A>:=93?:LC=M8AL7:?:>A>:KM36NF;K@6HL7@=:LNO9=@3K:K<=:?<83MK;8A6ELN78:?;>9>;K3A8M3898:?@>@K30;LD89>@?96H8:=:K<=93=:=9=:?A67:8:>@6E=3'@LE=M6G6H3?9><=:LH=3@?@6B>:KA32K9>32:=MK@6B=@32=;LEK@LCK32;>:6G6C6B32LH6GLE?9?32@>MKA>9LD32LNI;?<6B672MK<><6B89316DLG6D?;=319?@8M?<>31;=AKA>A671;LE>:?M=<31LDLD6B>;671@=M>;LH?@316BLG=ALEK319>:LD><=936DKM>;6E67$M?<8A8:67;>M?@8;=<3 LB?;8<=9=39?:=:=3;8:K:?@?938<=98A8;K398:>M6G6G3=@KMLE6NL7@=:=;3=@LDK;>M=3<89LB8@?;3>@?;?:=M=3:K@KM3LHLCLE89>3#;>;8;LB=M3?@?M?:=9L7A?A6D?3.96G6C>M3K:KM8@?AL79>:LEK@>:3?:=:=9?;L7M=936NFMK@KMK3@=;6G6D>M3-LH=;3*8A><6C8A67;8;>;>A6B3K:>@K:LHL7;LE83,9>:K@8@=:3LD6D?:>M=39LE6NLGK;3)LB>9=A89679LCLG=M?983";><=;=:?93"8;6E6C>;>3"9=:=9?M6H3"8M?@=;KA?3"MLHLN?@6G3"LH>;6E6GK3"<8M=:KALN7K<8@>A=967AKA8;?;?<3?9K;=<=M>3A8;K98A893?<=;=396NOA?A?:36D=<>:=:830ALGLCLE?<3KM?;8@6E>3M?96G>;8A38:8A6C?;?3@K;6H6B6C3KMLH>98:K9?A3=M>@KM6D>3;=@=@>@8<3=<=96D6G679>96GLNLD3?9>@>M6DK3:6GKMKMK938MK@LCLEK3<>M>;=M?<38ALB?@LG839>96E=:6E38@?@LD6CL7M?M>:?9=93=:=:>:K;K39KMK;K@K;3$LH>;=A>M?3$M>@6G8A?A3$K:6D=:K:=3$:6G=;=:=<>3$;=A>;6CK93$?9=AL7$<8A896B?;3$6D=A6NK9?3$9LNFM?9KA38;>A>;>9LE6N7>:=@K9>;>3:8:89>:=<3!LB?9?96NI39?9?:KM8:3KA?:=:6BL7@898M>AKM3=98:K96H>3M8:?@LG>M3+?9?:8A?;67&;8:>9K98<3LHK;8<=AK3:6B?:>MLD3LC89=@=9K3<8:8@8:K@3K@?;=M8M67#9?:8A?;?3 <8@>9=:LH3 >A><6GLB?3 @K9LGLDLN7 K@LG=@8<67 <=9KMK<6N7LNIA>9K;?3;KA3-K9LCLG=@?M>;3KMK@=93!=<8;LD=9>3!<>;LE8<>A3!K<6B?AKA?3!9KMK@LCK<3!>A6G?;=9>3!:8ALD6B?@3!8<=:><6H83!;=:6HLD>A3!>@=98<8M83!MK@=MKALG8M?3M6DLH?@8;3=@>9?A>@67@?@=:=M6H3>M8:8AKML7;6B?AK@>93KM8ALDK9K3;K:>3;6B?:6NJA3LD=96C8A=3@LH=989LCLB=3MK@?MLG?<3LN?M=:=<=30AK<=A?96G3=96C=3:LB=;8M8<38@6GLG>@>3M?A6B=;6C3=A?M?M?@83M?<6H>:K@3>M8:?:KM=3A?9K@LNJ93?3;>@6C?;?<3LC6DLC6EK3;6GLDKM8<36N?;>AKA>3A?:8@>9K@38M?;K3@KA6G6G6D3=;6DLC=:67@8M6G=@6G3KALBLD>MK3;8@8MLB=M326E=:?M?M?31:89LE6D6H36ELN?A?A>3M6D8MLBK:3=M?<6C>@>3A=A8ALHLB?3"<=:3LEK<=<8967ALNO:>9><3+8<>;>A6C>3+A=A=9>:8@3+898<6CLC>3+M>;K@8:LC3+K;=A=A>:67+A8MKA>M67+M?AK9K@?3+<8@?;LNJ:3&8@>M=A=M=3&<=@=@K98A3&=A8:?<6D67&9KM?@8@6N7&=;8A6B6G?3&;K9LDLNLB3&K@LE8@?A83&:89><8MLB3&6GLH>@=AL7&@6NKAKM=:36H?M><=:83@?@6C>;>;3(K:=;=A?AL7.;K;8A?A6D36H=A?A6H83@=98:KMKA38<8MK<8M=398M?9L79=@?@=M=93LB=:LNOA83@?<=@>A?@3-6H6DKA>:>3/@8:=AK;?93K<6BLN?A83MK@6B=M?A3*K@=M8:=:8936D>A896NF;3)6D?@8;8:L798@6H>A=@3LD8AK:8A83%<8@6DK<=938ALE?;6NF3:?M=;89KM3LB=;?@8MK3A=ALG?@LH38;LD896NF3A>@>M8;>936DLH?@8;67<89K@K:LH3>9>;8:?AK3;?;8;>39LC89>A893>AKM8M=9670MKA?:>MLB?3M=A6G=;3#8@LB6HLB>3#@>989LH=M3#?MLNF:6H=3#96D>A?<8;3#>@K;K@LC83#AKA?ALE8@3#6B=M>;LGL7#<6G?9>@8:3=;=A>;?A67:8MKM8;=A3?@LC?@=:K3@KM>:K;?M3?9LE?9=MK3:LH6HKAK93K;LD=:8@L7ALHLCK@8932LBK<8:6H83198:>MLG=<38<=;8;LNK3ALELG=:6D3LH6D>;=@>3M=;89K<6G38A8A?;=AK3"A8:KA=;LD3>ALNJM>;?3;=ALNO9>:3=A=MLEKM83$@?<8<6D?;3K@?:8<>:=3@6G6E6CK<3 =M6GLE>;K3@=A=ALD6N7!8M?:6H=:LNFAK398;=:?:K93+K9KM?ALC83&M>;3KMLE6C?9>3<6HLDLN?M36E>M6C6G=3A=:8A6NO:36CLG6H6NL7#M=MK98MLG3>:KM>9KAK3;6CK936EKMK9?:>3<=:?9=9>:3>;?;>;=@83A8<>;?:=M3(=;>;LD36NO;K<8@?39><>:8;=;3K@=<>A=9L7<6B6C8:>:3>MK@LB=:?3M6D3LE8A?9=:>3@LE6GK<6E3(?3(M=;?;=<6G3(=A8@LE>:>3(;6CLEK:>@3(6D?9=:K9>3(<><8:LDLN7(?M=;LH6E>3(;6HLHLNFA3(LNI;8:=@83(A=@?MK9?@3.KM>A=<8:>3.MK;LE>:>@3.=;6DKM6C=3.;=:6HLH?A3.8A=MK3.:=:6D6CK93.?;?A>:LD>3.@LE?A8<=<3LH>:=96NF3<>@LD>9>:36G>9LELG67A89LELNK<3K;><>@KA83<898;?M8:3?@6C8:6BL70:6HK96E6B389>;=;>;L7MKM8<8A?:3LH>;=:LCL7M6D=MK98A3=:KA?9=@67A?@3K@=:KA?A6E3LH6E>M6C>39=MK9LC67A8A6C3KAK@?@?@>3:6GLNOM6E3LD=A=A?ML79>ALBK:8A3=A6NKA=:>A=<6E=:67@?MK:=M6D3?96E8:K;>39=M32?AK:><=;K31:K9K<8:LD3?:6DKAKML7@>;>:3=;6H8:89=3:=9KA?@8;3><=98;6HL7"MKM8@=:>93=<8@89>M3=989?AKA>3<8<6H=MLG3 LG>9?;>@>3<>9=@6D>@3!=A=;=9K;>3<=A3=;LG?M?<67@6D>;K@KA3LH6B=9LG?3M6G>3&<=A=:8@6D3K<6H?@=M>39LE=A>:LD3=:=9?@K:?3A>@LE>M6D3LBLC8@=:83#A?M><6CKA3=@>:><8;>3AK9LE8:=MK9?;3(K;LD=9LE=3.;6NF@?98@3>M>9=:6C=3MLN7K9>9?;8:67;K;8@K3/:6G=@3-6B?9?M8M>3-9LB=9>:?;3-=M8;LE=M67-9=;?M>:8<3->M89LH6DL7-AK<8:K3-;KA6E8A>:3/8@K@KA=A67/;?MLE=A=@3/>@6B=:KA?3/M8;?;KMLG3/6H>;83/M=@>;=@K<3/LCK<=9K9?3/@8M=9?@8:38A6GK:KA=3@LBLG6H=3@6N?<6HLN7=;=<8;>:?3<>:6E=<>A36NIM>M6B830:K@?@=:?A38<6NK<8@=39K@K:=3M>9=9LC6C3>9LNI98:K3A8@?9>A8@3=M8;=@K:8@LC?3A8@8@=@8:36E>:6E>@67@=:?9=:?:3*6CKM6BK9L7*AK:6NLGK@3*K;?A?AKM>3*M8A>M>@LD3*>96E>ALCK3*9=;8:8:6G3*6N?A?98;67*ALG><6GK3*M>M8A8@=@32?9>;>;=@?31M8:6BK:?3:6DLD6C><38;6NJ98AL7@>:3KA?@?;LC83";LH=;=;=A36CK9K:LD>3:6DLBK9?M3?:6B6GK;83$<8<8@LG>M36NI@LC=:>39=A6C=@LH3 8M?:KA3!LDK<6HLHK3@K96G8;?M3K9K:=@8<839?M?<8@>3M6B6B=MK;3+8:=@?A?A83&M?M3KMLEK:?A?3<>A6ELD>:38@8@KA><6793=@?<>A?@?3#<>@K;=9LE83@=@6ELD=93?<=ALB=:L7@8;8M>@KA36B>;LG8:L7;?A8:=9><3(8;KA=:=<67.A6N76N?A89?M67MK9?:89>A3,>;LH=AK@K3,:>@LD8A=A=A>93,K;>M6BK983,;=MLH?9><3,?<>;=9=A>3,9K@>@>:=A3,8A6BLG?9L7,96E89>A6H3*?@=@=M?A67;?;?:K:=A36E>:?@LG67@>@89LNK@3K@6NLC><>@8<67A89LC?3@>A?M=;LG36D6CLC8ML7%:KALD=M=;3KA6HLH>ML7:8:=9=;K@3?:?M8<6HK3<=:KM6D6E3=M?98M>ALB6B3=@>;8@89>3M?A6C?<6D3)K@?@8M?:=3)@=9KA8M=@3)>@?;8M6D=3)M6GLE=@LC3)?9>M89>;>3)MKMLGKAKA3)8M=A=3)M=9?;6NI@3)=98;8@K:>3)M8:K:6DK;3LE8:KM?9>3M=;?<6H8M3?@LE8:>A=3A?;LN?9?@3K@>98:K<67MLD6G?@?:38M?9>MLE3=A?;?9=;=3@=:3>9?@LNF<67@KM?MKA6C38;?;8:8;?3;KA6E><6H3LNKM?3M6H>M8M=:>9><=3%:67%@><8<=@8:3%89>@6DLC?3%9LCKA3%K;>;LB=3$A>9K@><8:3K;LC=:8@67@>9LE6B6E3 LC6NLH?;>3@>@8;?ALC3!>A8MLC8;>3:K9>M=9LB38:=A=<6NK3M?@?@898;3KM?MKAK:?3@?;6NF@8<3+?M6G=:?ML7&;KM6D3LDKALC=<679>;?<6E8;38@K9>:8:=3@6C=A8;6D3=<8A679?:KM8ALD3K;=:?;>M=3:>M?;89>:3(K989?;8:K3.A?9LG89=M383M=:LEK:?@3=<8M>@6G?3@8;K396CKM>@>M3=M6NO;8;?3<>@6E6C>@3-6H=:LB6G67/@6C89>9><3?9=:8<>:8396BLC8;?93*=9LBLH>967;=@8M?;LD3>A?39K:8;6B=;3K96CLD=3<=A3)K9?<=@>M67M=A?@?;6H3=M?;>:=@>3%@?MK98;LD3LB6B6G>A83A89LG>A893>;KA>A=@>3;>9LB><6G3LBLG?MK;>3<>;?MK<>;=:>:>3;8;=;?9?@3=M?M>@?<67MLC>9?ALC3>AK9K@=M83MK@6C6B>;3?@6E>;K:83A=:8;?9>M38M8@>M>M>30A=<6HLE6G3=M?A?@6E83936NJ;=989>3A6E=;=<8M83A>@8;K9LH3?A=96H>;L7'A6C67:>M6D8@6N7=;?@K@8:?3:=A?MLH8<38@8@=:>;?3MLC8@KA=A3>@67@LE=9LB6D326BK:LNI:831:6GK;=M>;3=AK96C6D83M=MLH>M?;3>:>;?9LB3>@8;LG?9=3"M6HLG6G>A3?<6E=9=MK;LE6H3LBLB=;6G>3$@K9>A8398AK9K;><3 ?A8;?9>:K3A6BLD?<=@3!8A?A89?ML7<8:?<=A8<3>:>M=AK;>3A?:?96H=<3?ALD>ALB>39LG6GLHKA3+=@>@LB=@389K<6D>9L7:?96E?A>93LEKM?9LG67:>@?M6G8A3>;K@8:6G=3#;6B=3:K;>96H8A3>MK@LNO@?3:LD3(=:8@=<6B67.MLNOM89=@3LD=<=@8A3-8@K9=A6NK3/:>9LCLDK:3K3:?@=:8AK93K:?989?<67@>A8MKM6D83@6D6B898@3)LC?M6B6NO3ALG>@8<=:K30<6B?:>@LH30=M>M?;LB=3098MLB?;><30=<>;LE>;=30:8@LDK@>;308@>9?:>9830;>9K:LBK<30LH?M=<6C830;>@KA>A=A=;LC3KMK;36G>:?A>:>:3=;?9?ALBK3A6E6B=;K938A?:893LELE=M6E=3AK9=9LEKM3?98A8A6DK3A=@=:LHLE83;KA?A8AK936B898@?:>3;>:KMK@LH3>;LD6NLNK3:6D>A36GLB>@?;K3@K9?MLG><32LG>;=MLH8319=:LNO<8<3LH=MLNI983@K3:8@8@LE>93>;>:=@8MK3"9KA>9K:=<38M8<=96B=3AK@K:6B?;3>:896DLH>3$:6C>;?9K93?@8;K;8:83@K;K:K:LE3 ?@=:LD=:K39K@>M8@?:3!6NJ9?A?<>3:6GK:3+8<=A6HK@83&M6GLE=;LB3?:?:>9K:?39=98M=M6C3?98:6NJA=3#@6C?A8<6D3>A>;3>M=96G3LHK:8<6B83:K;?<89LC3'K96H?<8;?3':?M=M=A=;3'=;>A6E8:K3'A8A=;?<8A3'=M=AK<8@K3'M?9>:6CK;3'KA?MLB?:>3'ALB>A=9>:3'LH=;K96DK3'96E=@=A6N7*>:LNO@=M?39LH6B?@><3>;K@6E>;K3;K9=@LBLN7>;?<8MK;83,<=96ELB6C3K9>96D>9=3;6D=:KAK93)K:8M=A?ML7:?:><6DKA38:>A=M=M?3%:=@6HLC=M3=MLNLB6EL7:=M>9LCKA6B3K@=:>;?:679>96D>A=:3K9LG89=@?3@?:8<8<>:36E>@=@KA>3@6D?96D6E38;K9=:=:=3M8@6G?A>A3=MK;6C?9830<=A6NJ;?<3>:?M>:6CL7:?9?:?@?:3LD>M8AK9=3A?@?3'A>AK:8:>M3LE>@?@?A6D=<3?@?98A8M>3A6DLELG6N72LBKALCK9K32@89?9=:K932K9898:LD=32@KA6CK9KM328@=A>M=98;=@32>@8;><8;K32<8:LH6NK;322=98M6G>A6721;6ELD8;=;32K:KAK:LH832MK;8M?<>:32LBLH=9?M832MLGLNKMK932K;=@LH6NL72"96DLGKM=A32>@=;6NJ9672;LNI;6E?935LE6D6DK;835MLB?<6G?M35K9=;>@?ML75MLD8:6GK@35K9K9?:K9>35A6N?@8M6C35K98M>ALG=M356B>MKALGK35<6G8;32?A?M=:=M?32A6H?MK:>@32+LN?A?A>A832&;>:>;>MLE32=M?:LH=@83296B?@>:8:32=<89K9=@32LNO:8ALH?32#9=9LNJA6B328@KMK:K:K32@?M?@=@8M32KMK9>;6B?32M>;K:8@32?;K@?;KA67296D8@K;8@326DLH8A=ML72;>;>:>A?;32=9LD>:>:?32@>98@>9?A32LH8;=98A>32@LNF;6EK932-K;LE6G8A>32/;?M=MKA?<32LD8:LD?A>329LH=:KA8932*?@8A>9=M=32M=9>@KAK;672,@LG>@8M=<32>;LCLNO:=32A32)6B>;8:K@=32;K<6B>:=<32LGK<=ALCLG32LB?@=;8A>32;>ALG>ALD32K@8A896B?32:?@K;LH6G32=ALNF;K9672M>ALH8<>A326CK9832A?:6NLEK;32K98A6NKA=<6D328A8:8;8;672;LCLC>A=M326NI:8M8983209K9LBLEKM32LBK96B6D832;?@=@=@>@32K:>:=98M?329?A=A8M8<32=@6DLH=9>32:>989=@?<328:8@8:><672'@?@=MLELG32KALCK@>9=32@=96C?A>M32?M6E=9K<672<6D>9>AK<31=98A?@=9>@316D6NJ<>@?31@>31A=<>@=AK@316BLC=9?M>3131@>M8@LG8<31K:LELELNL719LCKMK96C31>M8A?;=@831"@6GLH>:?@31LD=98;>9671@?98@>;LE31KA=31$;LH6H8@8<318<6NI;8;>31MLD89?9=:31 6GLE=A=@L719=A8<6G=A31!LD=;8:=@L71:6NK<=;6D31=;6C>:=@K319>ALD>MLE31?96HLN?A>31M?:6H=M831&ALDKM>;K9318@8@KAK9831M8M>9?@6G31K@8;LD6DK31;K:=M6C8A31LEK<89KA>31#;><6D8M8M34K@674M8A>@8;6C34>A6G>@8;>34;?9K@LG6C34K@=:6NO<>34@?;K:6CKA34LNI;LGLNF34:6HK:LC>:34?@?M=@6E?34MK@6C>;8A31?@K;6D>@>31AK<8A>9?;316H8;>M8M671A6N?9K@6C318A?;?@LEK3198:?;?;KA31-6E?MK9?A=31/M>M?:?;8@31?;LH?;>@K31M=98:LNIA31*6G=;?@LHL71M>9K;=;?@31=@6G=A6C=@8A31><>;=@8@831,:=@6E31)K9>@8@=9>31:=@>9LNF@316G?;>9?@L71%9>@>;KM893189=:?31M6G=;K:K<31KM8ALN?:>31MLB>A=:6G316NLB6B?AL71M6HLNI<=@31LCK;6B=@?3196E>@K;89316D>;?M89>31M=96NJ<>M31=9LNO@=9>96BK:318@6BLD=:=31:8M6B=MKA31K@8@8<8MLE31K:?A?@6C=31:6HK;?98M31=;=AK@KA831AK<8M31>:?ALN?:6E><318;8A>M6B=31':6N?A>@=931>@8;LH><>319?M?;LB=M3189>M6NK9671 \ No newline at end of file diff --git a/parquet-column/src/test/resources/fsst/sampled.table b/parquet-column/src/test/resources/fsst/sampled.table new file mode 100644 index 0000000000..699c4c918a Binary files /dev/null and b/parquet-column/src/test/resources/fsst/sampled.table differ diff --git a/parquet-column/src/test/resources/fsst/urls.codes b/parquet-column/src/test/resources/fsst/urls.codes new file mode 100644 index 0000000000..83b7673660 --- /dev/null +++ b/parquet-column/src/test/resources/fsst/urls.codes @@ -0,0 +1,39 @@ + !"  !# !$ !% !&  !' + !(  !)  !* !+ !, !,  !, + !,  !,  !, !, !, !,  !, + !"  !#  !$ !% !& !'  !( + !)  !*  !+ !" !# !$  !% + !&  !'  !( !) !* !+  ! " + ! #  ! $  ! % ! & ! ' ! (  ! ) + ! *  ! +  ! +" ! +# ! +$ ! +%  ! +& + ! +'  ! +(  ! +) ! +* ! ++ ! "  ! # + ! $  ! %  ! & ! ' ! ( ! )  ! * + ! +  ! "  ! # ! $ ! % ! &  ! ' + ! (  ! )  ! * ! + !" !#  !$ + !%  !&  !' !( !) !*  !+ + !"  !#  !$ !% !& !'  !( + !)  !*  !+ !" !# !$  !% + !&  !'  !( !) !* !+  !" + !#  !$  !% !& !' !(  !) + !*  !+  !" !# !$ !%  !& + !'  !(  !) !* !+ !"  !# + !$  !%  !& !' !( !)  !* + !+  !"  !# !$ !% !&  !' + !(  !)  !* !+ !" !#  !$ + !%  !&  !' !( !) !*  !+ + !"  !#  !$ !% !& !'  !( + !)  !*  !+ !" !# !$  !% + !&  !'  !( !) !* !+  !" + !#  !$  !% !& !' !(  !) + !*  !+  !" !# !$ !%  !& + !'  !(  !) !* !+ \ No newline at end of file diff --git a/parquet-column/src/test/resources/fsst/urls.table b/parquet-column/src/test/resources/fsst/urls.table new file mode 100644 index 0000000000..756af8fdaf Binary files /dev/null and b/parquet-column/src/test/resources/fsst/urls.table differ diff --git a/parquet-hadoop/src/main/java/org/apache/parquet/format/converter/ParquetMetadataConverter.java b/parquet-hadoop/src/main/java/org/apache/parquet/format/converter/ParquetMetadataConverter.java index f6ee73bbc0..2a3816d78d 100644 --- a/parquet-hadoop/src/main/java/org/apache/parquet/format/converter/ParquetMetadataConverter.java +++ b/parquet-hadoop/src/main/java/org/apache/parquet/format/converter/ParquetMetadataConverter.java @@ -127,6 +127,7 @@ import org.apache.parquet.internal.hadoop.metadata.IndexReference; import org.apache.parquet.io.InvalidFileOffsetException; import org.apache.parquet.io.ParquetDecodingException; +import org.apache.parquet.io.ParquetEncodingException; import org.apache.parquet.io.api.Binary; import org.apache.parquet.schema.ColumnOrder.ColumnOrderName; import org.apache.parquet.schema.GroupType; @@ -767,6 +768,13 @@ public org.apache.parquet.column.Encoding getEncoding(Encoding encoding) { } public Encoding getEncoding(org.apache.parquet.column.Encoding encoding) { + if (encoding == org.apache.parquet.column.Encoding.FSST) { + // The format has no FSST value and no place for the symbol table a chunk's pages are compressed + // against, so a footer cannot describe such a column: see parquet-format issue #531. + throw new ParquetEncodingException( + "Encoding FSST cannot be written to a Parquet footer: the format does not define it yet. " + + "See parquet-format issue #531."); + } return Encoding.valueOf(encoding.name()); } diff --git a/parquet-hadoop/src/main/java/org/apache/parquet/hadoop/ParquetOutputFormat.java b/parquet-hadoop/src/main/java/org/apache/parquet/hadoop/ParquetOutputFormat.java index 4db288f455..62cf3f157f 100644 --- a/parquet-hadoop/src/main/java/org/apache/parquet/hadoop/ParquetOutputFormat.java +++ b/parquet-hadoop/src/main/java/org/apache/parquet/hadoop/ParquetOutputFormat.java @@ -36,6 +36,7 @@ import org.apache.hadoop.mapreduce.lib.output.FileOutputFormat; import org.apache.parquet.column.ParquetProperties; import org.apache.parquet.column.ParquetProperties.WriterVersion; +import org.apache.parquet.column.values.symboltable.SymbolTablePayload.OffsetEncoding; import org.apache.parquet.crypto.FileEncryptionProperties; import org.apache.parquet.hadoop.ParquetFileWriter.Mode; import org.apache.parquet.hadoop.api.WriteSupport; @@ -83,6 +84,13 @@ * # To enable/disable BYTE_STREAM_SPLIT encoding * parquet.enable.bytestreamsplit=false # true to enable BYTE_STREAM_SPLIT encoding * + * # To enable/disable the FSST symbol table encoding for BINARY columns. Not ratified: no file + * # written with it is readable, see parquet-format issue #531 + * parquet.enable.fsst=false # true to enable the symbol table encoding + * + * # How a symbol table page's per-value offsets are written: PLAIN or DELTA_BINARY_PACKED + * parquet.fsst.offset.encoding=DELTA_BINARY_PACKED + * * # To enable/disable summary metadata aggregation at the end of a MR job * # The default is true (enabled) * parquet.enable.summary-metadata=true # false to disable summary aggregation @@ -141,6 +149,8 @@ public static enum JobSummaryLevel { public static final String DICTIONARY_PAGE_SIZE = "parquet.dictionary.page.size"; public static final String ENABLE_DICTIONARY = "parquet.enable.dictionary"; public static final String ENABLE_BYTE_STREAM_SPLIT = "parquet.enable.bytestreamsplit"; + public static final String ENABLE_FSST = "parquet.enable.fsst"; + public static final String FSST_OFFSET_ENCODING = "parquet.fsst.offset.encoding"; public static final String VALIDATION = "parquet.validation"; public static final String WRITER_VERSION = "parquet.writer.version"; public static final String MEMORY_POOL_RATIO = "parquet.memory.pool.ratio"; @@ -288,6 +298,15 @@ public static boolean getByteStreamSplitEnabled(Configuration configuration) { ENABLE_BYTE_STREAM_SPLIT, ParquetProperties.DEFAULT_IS_BYTE_STREAM_SPLIT_ENABLED); } + public static boolean getFsstEnabled(Configuration configuration) { + return configuration.getBoolean(ENABLE_FSST, ParquetProperties.DEFAULT_IS_FSST_ENABLED); + } + + public static OffsetEncoding getFsstOffsetEncoding(Configuration configuration) { + return OffsetEncoding.valueOf( + configuration.get(FSST_OFFSET_ENCODING, ParquetProperties.DEFAULT_SYMBOL_TABLE_OFFSET_ENCODING.name())); + } + public static int getMinRowCountForPageSizeCheck(Configuration configuration) { return configuration.getInt( MIN_ROW_COUNT_FOR_PAGE_SIZE_CHECK, ParquetProperties.DEFAULT_MINIMUM_RECORD_COUNT_FOR_CHECK); @@ -522,6 +541,8 @@ public RecordWriter getRecordWriter(Configuration conf, Path file, Comp .withDictionaryPageSize(getDictionaryPageSize(conf)) .withDictionaryEncoding(getEnableDictionary(conf)) .withByteStreamSplitEncoding(getByteStreamSplitEnabled(conf)) + .withFsstEncoding(getFsstEnabled(conf)) + .withSymbolTableOffsetEncoding(getFsstOffsetEncoding(conf)) .withWriterVersion(getWriterVersion(conf)) .estimateRowCountForPageSizeCheck(getEstimatePageSizeCheck(conf)) .withMinRowCountForPageSizeCheck(getMinRowCountForPageSizeCheck(conf)) diff --git a/parquet-hadoop/src/test/java/org/apache/parquet/format/converter/TestParquetMetadataConverter.java b/parquet-hadoop/src/test/java/org/apache/parquet/format/converter/TestParquetMetadataConverter.java index f5222de828..e841e2db4c 100644 --- a/parquet-hadoop/src/test/java/org/apache/parquet/format/converter/TestParquetMetadataConverter.java +++ b/parquet-hadoop/src/test/java/org/apache/parquet/format/converter/TestParquetMetadataConverter.java @@ -127,6 +127,7 @@ import org.apache.parquet.internal.column.columnindex.ColumnIndexBuilder; import org.apache.parquet.internal.column.columnindex.OffsetIndex; import org.apache.parquet.internal.column.columnindex.OffsetIndexBuilder; +import org.apache.parquet.io.ParquetEncodingException; import org.apache.parquet.io.api.Binary; import org.apache.parquet.schema.ColumnOrder; import org.apache.parquet.schema.LogicalTypeAnnotation; @@ -542,10 +543,21 @@ public void testLogicalToConvertedTypeConversion() { .isEqualTo(ConvertedType.MAP_KEY_VALUE); } + @Test + public void testFsstCannotBeWrittenToAFooter() { + assertThatThrownBy(() -> new ParquetMetadataConverter().getEncoding(org.apache.parquet.column.Encoding.FSST)) + .isInstanceOf(ParquetEncodingException.class) + .hasMessageContaining("#531"); + } + @Test public void testEnumEquivalence() { ParquetMetadataConverter parquetMetadataConverter = new ParquetMetadataConverter(); for (org.apache.parquet.column.Encoding encoding : org.apache.parquet.column.Encoding.values()) { + if (encoding == org.apache.parquet.column.Encoding.FSST) { + // The format has no value to convert it to yet: see testFsstCannotBeWrittenToAFooter. + continue; + } assertThat(parquetMetadataConverter.getEncoding(parquetMetadataConverter.getEncoding(encoding))) .isEqualTo(encoding); } @@ -832,6 +844,9 @@ public void testEncodingsOrder() { Set columnEncodings = new HashSet<>(Arrays.asList(org.apache.parquet.column.Encoding.values())); + // The format has no FSST value, so it has no ordinal to order and cannot be converted at all: + // see testFsstCannotBeWrittenToAFooter. + columnEncodings.remove(org.apache.parquet.column.Encoding.FSST); // Assert that the encodings are returned in ascending ordinal order List formatEncodings = diff --git a/pom.xml b/pom.xml index ec53d3d721..ccddd23ecd 100644 --- a/pom.xml +++ b/pom.xml @@ -572,6 +572,12 @@ **/dependency-reduced-pom.xml **/*.rej **/src/main/thrift/parquet-format.version + + **/src/test/resources/fsst/*.table + **/src/test/resources/fsst/*.codes + + **/src/test/resources/fsst/interop/*.pages + **/src/test/resources/fsst/interop/expected.txt