From 433b1bdd115871c52092426a3bd233a5e03f95f9 Mon Sep 17 00:00:00 2001 From: Prateek Gaur Date: Tue, 21 Apr 2026 00:15:09 +0000 Subject: [PATCH 01/14] Add PFOR encoding core implementation Implements the PFOR (Patched Frame of Reference) integer compression encoding for INT32 and INT64 columns in the pfor package: - PforConstants: header/vector sizes, max exceptions (65535) - PforEncoderDecoder: histogram-based cost model for optimal bit width - PforValuesWriter: IntPforValuesWriter + LongPforValuesWriter with vector-buffered encoding and interleaved page layout - PforValuesReader: abstract base with lazy per-vector decoding - PforValuesReaderForInt: INT32 decoder using BytePacker - PforValuesReaderForLong: INT64 decoder using BytePackerForLong --- .../column/values/pfor/PforConstants.java | 81 ++++ .../values/pfor/PforEncoderDecoder.java | 161 +++++++ .../column/values/pfor/PforValuesReader.java | 151 ++++++ .../values/pfor/PforValuesReaderForInt.java | 142 ++++++ .../values/pfor/PforValuesReaderForLong.java | 147 ++++++ .../column/values/pfor/PforValuesWriter.java | 445 ++++++++++++++++++ 6 files changed, 1127 insertions(+) create mode 100644 parquet-column/src/main/java/org/apache/parquet/column/values/pfor/PforConstants.java create mode 100644 parquet-column/src/main/java/org/apache/parquet/column/values/pfor/PforEncoderDecoder.java create mode 100644 parquet-column/src/main/java/org/apache/parquet/column/values/pfor/PforValuesReader.java create mode 100644 parquet-column/src/main/java/org/apache/parquet/column/values/pfor/PforValuesReaderForInt.java create mode 100644 parquet-column/src/main/java/org/apache/parquet/column/values/pfor/PforValuesReaderForLong.java create mode 100644 parquet-column/src/main/java/org/apache/parquet/column/values/pfor/PforValuesWriter.java diff --git a/parquet-column/src/main/java/org/apache/parquet/column/values/pfor/PforConstants.java b/parquet-column/src/main/java/org/apache/parquet/column/values/pfor/PforConstants.java new file mode 100644 index 0000000000..81afe98c07 --- /dev/null +++ b/parquet-column/src/main/java/org/apache/parquet/column/values/pfor/PforConstants.java @@ -0,0 +1,81 @@ +/* + * 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.pfor; + +import org.apache.parquet.Preconditions; + +/** + * Constants for the PFOR (Patched Frame of Reference) encoding. + * + *

PFOR encoding compresses integer columns (INT32/INT64) by: + *

    + *
  1. Subtracting the minimum value (Frame of Reference)
  2. + *
  3. Choosing an optimal bit width via a cost model
  4. + *
  5. Bit-packing the deltas at the chosen width
  6. + *
  7. Storing outlier values (exceptions) separately with their positions
  8. + *
+ */ +public final class PforConstants { + + private PforConstants() { + // Utility class + } + + // Page header fields (7 bytes total) + public static final int PFOR_PACKING_MODE_FOR = 0; + public static final int PFOR_HEADER_SIZE = 7; + + public static final int DEFAULT_VECTOR_SIZE = 1024; + public static final int DEFAULT_VECTOR_SIZE_LOG = 10; + + // Capped at 15 (vectorSize=32768) because num_exceptions is uint16, + // so vectorSize must not exceed 65535 to avoid overflow when all values are exceptions. + static final int MAX_LOG_VECTOR_SIZE = 15; + static final int MIN_LOG_VECTOR_SIZE = 3; + + // Maximum exceptions per vector (uint16) + public static final int MAX_EXCEPTIONS = 65535; + + // Per-vector metadata sizes in bytes + // INT32: frame_of_reference(4) + bit_width(1) + num_exceptions(2) = 7 + public static final int INT32_VECTOR_INFO_SIZE = 7; + // INT64: frame_of_reference(8) + bit_width(1) + num_exceptions(2) = 11 + public static final int INT64_VECTOR_INFO_SIZE = 11; + + // Value byte widths + public static final int INT32_VALUE_BYTE_WIDTH = 4; + public static final int INT64_VALUE_BYTE_WIDTH = 8; + + /** Validates vector size: must be a power of 2 in [2^MIN_LOG .. 2^MAX_LOG]. */ + static int validateVectorSize(int vectorSize) { + Preconditions.checkArgument( + vectorSize > 0 && (vectorSize & (vectorSize - 1)) == 0, + "Vector size must be a power of 2, got: %s", + vectorSize); + int logSize = Integer.numberOfTrailingZeros(vectorSize); + Preconditions.checkArgument( + logSize >= MIN_LOG_VECTOR_SIZE && logSize <= MAX_LOG_VECTOR_SIZE, + "Vector size log2 must be between %s and %s, got: %s (vectorSize=%s)", + MIN_LOG_VECTOR_SIZE, + MAX_LOG_VECTOR_SIZE, + logSize, + vectorSize); + return vectorSize; + } +} diff --git a/parquet-column/src/main/java/org/apache/parquet/column/values/pfor/PforEncoderDecoder.java b/parquet-column/src/main/java/org/apache/parquet/column/values/pfor/PforEncoderDecoder.java new file mode 100644 index 0000000000..1c56eb38f4 --- /dev/null +++ b/parquet-column/src/main/java/org/apache/parquet/column/values/pfor/PforEncoderDecoder.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.pfor; + +/** + * Core PFOR encoding/decoding logic with histogram-based cost model. + * + *

The cost model selects the optimal bit width by evaluating: + *

+ * total_cost(b) = num_elements * b + num_exceptions(b) * (16 + value_bits)
+ * 
+ * where {@code value_bits} is 32 for INT32 and 64 for INT64, and + * {@code num_exceptions(b)} is the count of deltas requiring more than {@code b} bits. + */ +public final class PforEncoderDecoder { + + private PforEncoderDecoder() { + // Utility class + } + + /** Result of the optimal bit width search. */ + public static final class BitWidthResult { + public final int bitWidth; + public final int numExceptions; + + BitWidthResult(int bitWidth, int numExceptions) { + this.bitWidth = bitWidth; + this.numExceptions = numExceptions; + } + } + + /** + * Find the optimal bit width for packing INT32 unsigned deltas. + * + *

Builds a histogram of bits required per delta, then evaluates each + * candidate bit width from 0 to 32 using the cost model. + * + * @param deltas unsigned deltas (values[] - min), treated as unsigned int + * @param numElements number of elements + * @return the optimal bit width and resulting number of exceptions + */ + public static BitWidthResult findOptimalBitWidthForInt(int[] deltas, int numElements) { + // Histogram: bitsHist[b] = count of deltas that need exactly b bits + int[] bitsHist = new int[33]; // 0..32 + for (int i = 0; i < numElements; i++) { + bitsHist[bitWidthForInt(deltas[i])]++; + } + + // Exception cost per exception: position(16 bits) + value(32 bits) = 48 bits + final long exceptionBitsPerValue = 16 + 32; + + long bestCost = Long.MAX_VALUE; + int bestBitWidth = 0; + int bestExceptions = 0; + + // exceptionsAbove[b] = number of deltas requiring > b bits + int exceptionsAbove = numElements; // at b=0, all nonzero deltas might be exceptions + // Actually: deltas requiring > 0 bits = all deltas with bitsRequired > 0 + // We need to track cumulative: exceptionsAbove starts at numElements - bitsHist[0] + // But let's compute it properly by starting from b=0. + // At b=0, only deltas requiring 0 bits (i.e., delta==0) are NOT exceptions. + // Correction: at candidate bit_width = b, values needing bitsRequired > b are exceptions. + // bitsRequired(0) = 0, so delta==0 needs 0 bits. At b=0, exceptions = values with bitsRequired > 0. + exceptionsAbove = numElements - bitsHist[0]; + + for (int b = 0; b <= 32; b++) { + long packingCost = (long) numElements * b; + long exceptionCost = (long) exceptionsAbove * exceptionBitsPerValue; + long totalCost = packingCost + exceptionCost; + + if (totalCost < bestCost) { + bestCost = totalCost; + bestBitWidth = b; + bestExceptions = exceptionsAbove; + } + + // Move to next candidate: values requiring exactly (b+1) bits are no longer exceptions + if (b < 32) { + exceptionsAbove -= bitsHist[b + 1]; + } + } + + return new BitWidthResult(bestBitWidth, bestExceptions); + } + + /** + * Find the optimal bit width for packing INT64 unsigned deltas. + * + * @param deltas unsigned deltas (values[] - min), treated as unsigned long + * @param numElements number of elements + * @return the optimal bit width and resulting number of exceptions + */ + public static BitWidthResult findOptimalBitWidthForLong(long[] deltas, int numElements) { + // Histogram: bitsHist[b] = count of deltas that need exactly b bits + int[] bitsHist = new int[65]; // 0..64 + for (int i = 0; i < numElements; i++) { + bitsHist[bitWidthForLong(deltas[i])]++; + } + + // Exception cost per exception: position(16 bits) + value(64 bits) = 80 bits + final long exceptionBitsPerValue = 16 + 64; + + long bestCost = Long.MAX_VALUE; + int bestBitWidth = 0; + int bestExceptions = 0; + + int exceptionsAbove = numElements - bitsHist[0]; + + for (int b = 0; b <= 64; b++) { + long packingCost = (long) numElements * b; + long exceptionCost = (long) exceptionsAbove * exceptionBitsPerValue; + long totalCost = packingCost + exceptionCost; + + if (totalCost < bestCost) { + bestCost = totalCost; + bestBitWidth = b; + bestExceptions = exceptionsAbove; + } + + if (b < 64) { + exceptionsAbove -= bitsHist[b + 1]; + } + } + + return new BitWidthResult(bestBitWidth, bestExceptions); + } + + /** + * Returns the number of bits required to represent an unsigned int value. + * Returns 0 for value == 0. + */ + public static int bitWidthForInt(int value) { + if (value == 0) return 0; + return 32 - Integer.numberOfLeadingZeros(value); + } + + /** + * Returns the number of bits required to represent an unsigned long value. + * Returns 0 for value == 0. + */ + public static int bitWidthForLong(long value) { + if (value == 0) return 0; + return 64 - Long.numberOfLeadingZeros(value); + } +} diff --git a/parquet-column/src/main/java/org/apache/parquet/column/values/pfor/PforValuesReader.java b/parquet-column/src/main/java/org/apache/parquet/column/values/pfor/PforValuesReader.java new file mode 100644 index 0000000000..25275b8179 --- /dev/null +++ b/parquet-column/src/main/java/org/apache/parquet/column/values/pfor/PforValuesReader.java @@ -0,0 +1,151 @@ +/* + * 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.pfor; + +import static org.apache.parquet.column.values.pfor.PforConstants.*; + +import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import org.apache.parquet.bytes.ByteBufferInputStream; +import org.apache.parquet.column.values.ValuesReader; +import org.apache.parquet.io.ParquetDecodingException; + +/** + * Abstract base class for PFOR values readers with lazy per-vector decoding. + * + *

Reads PFOR-encoded values from the interleaved page layout: + *

+ * ┌─────────┬──────────────────────┬──────────────┬──────────────┬─────┐
+ * │ Header  │ Offset Array         │ Vector 0     │ Vector 1     │ ... │
+ * │ 7 bytes │ 4B × numVectors │ (interleaved)│ (interleaved)│     │
+ * └─────────┴──────────────────────┴──────────────┴──────────────┴─────┘
+ * 
+ * + *

Each vector is decoded lazily on first access. Skipping values does not + * trigger decoding of intermediate vectors. + */ +abstract class PforValuesReader extends ValuesReader { + + protected int vectorSize; + protected int totalCount; + protected int numVectors; + protected int currentIndex; + protected int currentVectorIndex; + protected int valueByteWidth; + + protected int[] vectorOffsets; + protected ByteBuffer vectorsData; + protected int offsetArraySize; + + PforValuesReader() { + this.currentIndex = 0; + this.totalCount = 0; + this.currentVectorIndex = -1; + } + + @Override + public void initFromPage(int valuesCount, ByteBufferInputStream stream) + throws ParquetDecodingException, IOException { + ByteBuffer headerBuf = stream.slice(PFOR_HEADER_SIZE).order(ByteOrder.LITTLE_ENDIAN); + int packingMode = headerBuf.get() & 0xFF; + int logVectorSize = headerBuf.get() & 0xFF; + int valueBW = headerBuf.get() & 0xFF; + int numElements = headerBuf.getInt(); + + if (packingMode != PFOR_PACKING_MODE_FOR) { + throw new ParquetDecodingException("Unsupported PFOR packing mode: " + packingMode); + } + if (logVectorSize < MIN_LOG_VECTOR_SIZE || logVectorSize > MAX_LOG_VECTOR_SIZE) { + throw new ParquetDecodingException("Invalid PFOR log vector size: " + logVectorSize + + ", must be between " + MIN_LOG_VECTOR_SIZE + " and " + MAX_LOG_VECTOR_SIZE); + } + if (valueBW != INT32_VALUE_BYTE_WIDTH && valueBW != INT64_VALUE_BYTE_WIDTH) { + throw new ParquetDecodingException( + "Invalid PFOR value byte width: " + valueBW + ", must be 4 or 8"); + } + if (numElements < 0) { + throw new ParquetDecodingException("Invalid PFOR element count: " + numElements); + } + + this.vectorSize = 1 << logVectorSize; + this.totalCount = numElements; + this.valueByteWidth = valueBW; + this.numVectors = (numElements + vectorSize - 1) / vectorSize; + this.currentIndex = 0; + this.currentVectorIndex = -1; + + this.offsetArraySize = numVectors * Integer.BYTES; + ByteBuffer offsetBuf = stream.slice(offsetArraySize).order(ByteOrder.LITTLE_ENDIAN); + this.vectorOffsets = new int[numVectors]; + for (int v = 0; v < numVectors; v++) { + vectorOffsets[v] = offsetBuf.getInt(); + } + + // Slice remaining bytes into a 0-based view so decodeVector can use + // absolute get methods (vectorsData.get(pos)) directly. + int remainingBytes = (int) stream.available(); + ByteBuffer rawSlice = stream.slice(remainingBytes); + this.vectorsData = rawSlice.slice().order(ByteOrder.LITTLE_ENDIAN); + + allocateDecodedBuffer(vectorSize); + } + + protected int getVectorLength(int vectorIdx) { + if (vectorIdx < numVectors - 1) { + return vectorSize; + } + // Last vector may be partial + int lastVectorLen = totalCount % vectorSize; + return lastVectorLen == 0 ? vectorSize : lastVectorLen; + } + + // Offsets in the page are relative to the compression body (after header), + // but vectorsData starts after the offset array, so adjust. + protected int getVectorDataPosition(int vectorIdx) { + return vectorOffsets[vectorIdx] - offsetArraySize; + } + + @Override + public void skip() { + skip(1); + } + + @Override + public void skip(int n) { + if (n < 0 || currentIndex + n > totalCount) { + throw new ParquetDecodingException(String.format( + "Cannot skip this many elements. Current index: %d. Skip %d. Total count: %d", + currentIndex, n, totalCount)); + } + currentIndex += n; + } + + protected void ensureVectorDecoded() { + int vectorIdx = currentIndex / vectorSize; + if (vectorIdx != currentVectorIndex) { + decodeVector(vectorIdx); + currentVectorIndex = vectorIdx; + } + } + + protected abstract void allocateDecodedBuffer(int capacity); + + protected abstract void decodeVector(int vectorIdx); +} diff --git a/parquet-column/src/main/java/org/apache/parquet/column/values/pfor/PforValuesReaderForInt.java b/parquet-column/src/main/java/org/apache/parquet/column/values/pfor/PforValuesReaderForInt.java new file mode 100644 index 0000000000..420a188098 --- /dev/null +++ b/parquet-column/src/main/java/org/apache/parquet/column/values/pfor/PforValuesReaderForInt.java @@ -0,0 +1,142 @@ +/* + * 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.pfor; + +import static org.apache.parquet.column.values.pfor.PforConstants.*; + +import java.nio.ByteBuffer; +import org.apache.parquet.column.values.bitpacking.BytePacker; +import org.apache.parquet.column.values.bitpacking.Packer; +import org.apache.parquet.io.ParquetDecodingException; + +/** + * PFOR values reader for INT32 type with lazy per-vector decoding. + * + *

Reads PFOR-encoded int values from the interleaved page layout. + * Each vector is decoded on first access using BytePacker-based unpacking. + * + *

Per-vector format: + *

+ * PforVectorInfo (7B): frame_of_reference(4) + bit_width(1) + num_exceptions(2)
+ * PackedValues: ceil(N * bit_width / 8) bytes
+ * ExceptionPositions: num_exceptions * 2 bytes
+ * ExceptionValues: num_exceptions * 4 bytes
+ * 
+ */ +public class PforValuesReaderForInt extends PforValuesReader { + + private int[] decodedValues; + + public PforValuesReaderForInt() { + super(); + } + + @Override + protected void allocateDecodedBuffer(int capacity) { + this.decodedValues = new int[capacity]; + } + + @Override + public int readInteger() { + if (currentIndex >= totalCount) { + throw new ParquetDecodingException("PFOR int data was already exhausted."); + } + ensureVectorDecoded(); + int indexInVector = currentIndex % vectorSize; + currentIndex++; + return decodedValues[indexInVector]; + } + + @Override + protected void decodeVector(int vectorIdx) { + int vectorLen = getVectorLength(vectorIdx); + int pos = getVectorDataPosition(vectorIdx); + + // Read PforVectorInfo (7 bytes) + int frameOfReference = getIntLE(vectorsData, pos); + int bitWidth = vectorsData.get(pos + 4) & 0xFF; + int numExceptions = getShortLE(vectorsData, pos + 5) & 0xFFFF; + pos += INT32_VECTOR_INFO_SIZE; + + // Unpack bit-packed deltas + int[] deltas = new int[vectorLen]; + if (bitWidth > 0) { + pos = unpackIntsWithBytePacker(vectorsData, pos, deltas, vectorLen, bitWidth); + } + + // Add frame of reference to reconstruct values + for (int i = 0; i < vectorLen; i++) { + decodedValues[i] = deltas[i] + frameOfReference; + } + + // Overwrite exception slots with their original values + if (numExceptions > 0) { + int[] excPositions = new int[numExceptions]; + for (int e = 0; e < numExceptions; e++) { + excPositions[e] = getShortLE(vectorsData, pos) & 0xFFFF; + pos += Short.BYTES; + } + for (int e = 0; e < numExceptions; e++) { + decodedValues[excPositions[e]] = getIntLE(vectorsData, pos); + pos += Integer.BYTES; + } + } + } + + /** Unpack bit-packed ints in groups of 8, returns position after packed data. */ + private int unpackIntsWithBytePacker(ByteBuffer buf, int pos, int[] output, int count, int bitWidth) { + BytePacker packer = Packer.LITTLE_ENDIAN.newBytePacker(bitWidth); + int numFullGroups = count / 8; + int remaining = count % 8; + + for (int g = 0; g < numFullGroups; g++) { + packer.unpack8Values(buf, pos, output, g * 8); + pos += bitWidth; + } + + if (remaining > 0) { + int totalPackedBytes = (count * bitWidth + 7) / 8; + int alreadyRead = numFullGroups * bitWidth; + int partialBytes = totalPackedBytes - alreadyRead; + + byte[] padded = new byte[bitWidth]; + for (int i = 0; i < partialBytes; i++) { + padded[i] = buf.get(pos + i); + } + + int[] temp = new int[8]; + packer.unpack8Values(padded, 0, temp, 0); + System.arraycopy(temp, 0, output, numFullGroups * 8, remaining); + pos += partialBytes; + } + + return pos; + } + + private static int getShortLE(ByteBuffer buf, int pos) { + return (buf.get(pos) & 0xFF) | ((buf.get(pos + 1) & 0xFF) << 8); + } + + private static int getIntLE(ByteBuffer buf, int pos) { + return (buf.get(pos) & 0xFF) + | ((buf.get(pos + 1) & 0xFF) << 8) + | ((buf.get(pos + 2) & 0xFF) << 16) + | ((buf.get(pos + 3) & 0xFF) << 24); + } +} diff --git a/parquet-column/src/main/java/org/apache/parquet/column/values/pfor/PforValuesReaderForLong.java b/parquet-column/src/main/java/org/apache/parquet/column/values/pfor/PforValuesReaderForLong.java new file mode 100644 index 0000000000..91ac244369 --- /dev/null +++ b/parquet-column/src/main/java/org/apache/parquet/column/values/pfor/PforValuesReaderForLong.java @@ -0,0 +1,147 @@ +/* + * 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.pfor; + +import static org.apache.parquet.column.values.pfor.PforConstants.*; + +import java.nio.ByteBuffer; +import org.apache.parquet.column.values.bitpacking.BytePackerForLong; +import org.apache.parquet.column.values.bitpacking.Packer; +import org.apache.parquet.io.ParquetDecodingException; + +/** + * PFOR values reader for INT64 type with lazy per-vector decoding. + * + *

Reads PFOR-encoded long values from the interleaved page layout. + * Each vector is decoded on first access using BytePackerForLong-based unpacking. + * + *

Per-vector format: + *

+ * PforVectorInfo (11B): frame_of_reference(8) + bit_width(1) + num_exceptions(2)
+ * PackedValues: ceil(N * bit_width / 8) bytes
+ * ExceptionPositions: num_exceptions * 2 bytes
+ * ExceptionValues: num_exceptions * 8 bytes
+ * 
+ */ +public class PforValuesReaderForLong extends PforValuesReader { + + private long[] decodedValues; + + public PforValuesReaderForLong() { + super(); + } + + @Override + protected void allocateDecodedBuffer(int capacity) { + this.decodedValues = new long[capacity]; + } + + @Override + public long readLong() { + if (currentIndex >= totalCount) { + throw new ParquetDecodingException("PFOR long data was already exhausted."); + } + ensureVectorDecoded(); + int indexInVector = currentIndex % vectorSize; + currentIndex++; + return decodedValues[indexInVector]; + } + + @Override + protected void decodeVector(int vectorIdx) { + int vectorLen = getVectorLength(vectorIdx); + int pos = getVectorDataPosition(vectorIdx); + + // Read PforVectorInfo (11 bytes) + long frameOfReference = getLongLE(vectorsData, pos); + int bitWidth = vectorsData.get(pos + 8) & 0xFF; + int numExceptions = getShortLE(vectorsData, pos + 9) & 0xFFFF; + pos += INT64_VECTOR_INFO_SIZE; + + // Unpack bit-packed deltas + long[] deltas = new long[vectorLen]; + if (bitWidth > 0) { + pos = unpackLongsWithBytePacker(vectorsData, pos, deltas, vectorLen, bitWidth); + } + + // Add frame of reference to reconstruct values + for (int i = 0; i < vectorLen; i++) { + decodedValues[i] = deltas[i] + frameOfReference; + } + + // Overwrite exception slots with their original values + if (numExceptions > 0) { + int[] excPositions = new int[numExceptions]; + for (int e = 0; e < numExceptions; e++) { + excPositions[e] = getShortLE(vectorsData, pos) & 0xFFFF; + pos += Short.BYTES; + } + for (int e = 0; e < numExceptions; e++) { + decodedValues[excPositions[e]] = getLongLE(vectorsData, pos); + pos += Long.BYTES; + } + } + } + + private int unpackLongsWithBytePacker(ByteBuffer buf, int pos, long[] output, int count, int bitWidth) { + BytePackerForLong packer = Packer.LITTLE_ENDIAN.newBytePackerForLong(bitWidth); + int numFullGroups = count / 8; + int remaining = count % 8; + + for (int g = 0; g < numFullGroups; g++) { + packer.unpack8Values(buf, pos, output, g * 8); + pos += bitWidth; + } + + // Last group might have fewer than 8 values; zero-pad and unpack, + // but only advance pos by the actual bytes in the page. + if (remaining > 0) { + int totalPackedBytes = (count * bitWidth + 7) / 8; + int alreadyRead = numFullGroups * bitWidth; + int partialBytes = totalPackedBytes - alreadyRead; + + byte[] padded = new byte[bitWidth]; + for (int i = 0; i < partialBytes; i++) { + padded[i] = buf.get(pos + i); + } + + long[] temp = new long[8]; + packer.unpack8Values(padded, 0, temp, 0); + System.arraycopy(temp, 0, output, numFullGroups * 8, remaining); + pos += partialBytes; + } + + return pos; + } + + private static int getShortLE(ByteBuffer buf, int pos) { + return (buf.get(pos) & 0xFF) | ((buf.get(pos + 1) & 0xFF) << 8); + } + + private static long getLongLE(ByteBuffer buf, int pos) { + return (buf.get(pos) & 0xFFL) + | ((buf.get(pos + 1) & 0xFFL) << 8) + | ((buf.get(pos + 2) & 0xFFL) << 16) + | ((buf.get(pos + 3) & 0xFFL) << 24) + | ((buf.get(pos + 4) & 0xFFL) << 32) + | ((buf.get(pos + 5) & 0xFFL) << 40) + | ((buf.get(pos + 6) & 0xFFL) << 48) + | ((buf.get(pos + 7) & 0xFFL) << 56); + } +} diff --git a/parquet-column/src/main/java/org/apache/parquet/column/values/pfor/PforValuesWriter.java b/parquet-column/src/main/java/org/apache/parquet/column/values/pfor/PforValuesWriter.java new file mode 100644 index 0000000000..ab39301dad --- /dev/null +++ b/parquet-column/src/main/java/org/apache/parquet/column/values/pfor/PforValuesWriter.java @@ -0,0 +1,445 @@ +/* + * 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.pfor; + +import static org.apache.parquet.column.values.pfor.PforConstants.*; + +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.util.ArrayList; +import java.util.List; +import org.apache.parquet.bytes.ByteBufferAllocator; +import org.apache.parquet.bytes.BytesInput; +import org.apache.parquet.bytes.CapacityByteArrayOutputStream; +import org.apache.parquet.column.Encoding; +import org.apache.parquet.column.values.ValuesWriter; +import org.apache.parquet.column.values.bitpacking.BytePacker; +import org.apache.parquet.column.values.bitpacking.BytePackerForLong; +import org.apache.parquet.column.values.bitpacking.Packer; + +/** + * PFOR (Patched Frame of Reference) values writer for INT32 and INT64 columns. + * + *

PFOR compresses integer columns by subtracting the minimum value (FOR), + * selecting an optimal bit width via a histogram-based cost model, bit-packing + * the deltas, and storing outlier values (exceptions) separately. + * + *

Writing is incremental: values are buffered in a fixed-size vector buffer, + * and each full vector is encoded and flushed to the output stream immediately. + * On {@link #getBytes()}, any remaining partial vector is flushed, and the + * final page bytes are assembled. + * + *

Interleaved Page Layout: + *

+ * ┌─────────┬──────────────────────┬──────────────┬──────────────┬─────┐
+ * │ Header  │ Offset Array         │ Vector 0     │ Vector 1     │ ... │
+ * │ 7 bytes │ 4B × numVectors │ (interleaved)│ (interleaved)│     │
+ * └─────────┴──────────────────────┴──────────────┴──────────────┴─────┘
+ * 
+ * + *

Each vector contains interleaved: + * PforVectorInfo(7B/11B) + PackedValues + ExceptionPositions + ExceptionValues + */ +public abstract class PforValuesWriter extends ValuesWriter { + + protected final int initialCapacity; + protected final int pageSize; + protected final ByteBufferAllocator allocator; + protected final int vectorSize; + protected final int logVectorSize; + + PforValuesWriter(int initialCapacity, int pageSize, ByteBufferAllocator allocator, int vectorSize) { + PforConstants.validateVectorSize(vectorSize); + this.initialCapacity = initialCapacity; + this.pageSize = pageSize; + this.allocator = allocator; + this.vectorSize = vectorSize; + this.logVectorSize = Integer.numberOfTrailingZeros(vectorSize); + } + + @Override + public Encoding getEncoding() { + return Encoding.PFOR; + } + + /** INT32 writer. Buffers one vector at a time, encodes and flushes when full. */ + public static class IntPforValuesWriter extends PforValuesWriter { + private final int[] vectorBuffer; + private int bufferCount; + private int totalCount; + private CapacityByteArrayOutputStream encodedVectors; + private final List vectorByteSizes; + + public IntPforValuesWriter(int initialCapacity, int pageSize, ByteBufferAllocator allocator) { + this(initialCapacity, pageSize, allocator, DEFAULT_VECTOR_SIZE); + } + + public IntPforValuesWriter(int initialCapacity, int pageSize, ByteBufferAllocator allocator, int vectorSize) { + super(initialCapacity, pageSize, allocator, vectorSize); + this.vectorBuffer = new int[vectorSize]; + this.bufferCount = 0; + this.totalCount = 0; + this.encodedVectors = new CapacityByteArrayOutputStream(initialCapacity, pageSize, allocator); + this.vectorByteSizes = new ArrayList<>(); + } + + @Override + public void writeInteger(int v) { + vectorBuffer[bufferCount++] = v; + totalCount++; + if (bufferCount == vectorSize) { + encodeAndFlushVector(bufferCount); + bufferCount = 0; + } + } + + private void encodeAndFlushVector(int vectorLen) { + // Find minimum value (frame of reference) + int minValue = vectorBuffer[0]; + for (int i = 1; i < vectorLen; i++) { + if (vectorBuffer[i] < minValue) { + minValue = vectorBuffer[i]; + } + } + + // Compute unsigned deltas + int[] deltas = new int[vectorLen]; + for (int i = 0; i < vectorLen; i++) { + deltas[i] = vectorBuffer[i] - minValue; + } + + // Find optimal bit width via cost model + PforEncoderDecoder.BitWidthResult result = PforEncoderDecoder.findOptimalBitWidthForInt(deltas, vectorLen); + int bitWidth = result.bitWidth; + int numExceptions = result.numExceptions; + + // Collect exceptions: values whose delta doesn't fit in bitWidth bits + short[] excPositions = new short[numExceptions]; + int[] excValues = new int[numExceptions]; + int excIdx = 0; + + if (numExceptions > 0) { + int mask = (bitWidth == 32) ? -1 : (1 << bitWidth) - 1; + for (int i = 0; i < vectorLen; i++) { + if (Integer.compareUnsigned(deltas[i], mask) > 0) { + excPositions[excIdx] = (short) i; + excValues[excIdx] = vectorBuffer[i]; // original value, not delta + excIdx++; + deltas[i] = 0; // placeholder in packed data + } + } + } + + long startSize = encodedVectors.size(); + + // PforVectorInfo: frame_of_reference(4) + bit_width(1) + num_exceptions(2) = 7B + ByteBuffer vectorInfo = ByteBuffer.allocate(INT32_VECTOR_INFO_SIZE).order(ByteOrder.LITTLE_ENDIAN); + vectorInfo.putInt(minValue); + vectorInfo.put((byte) bitWidth); + vectorInfo.putShort((short) numExceptions); + encodedVectors.write(vectorInfo.array(), 0, INT32_VECTOR_INFO_SIZE); + + // Pack deltas + if (bitWidth > 0) { + packIntsWithBytePacker(deltas, vectorLen, bitWidth); + } + + // Exception positions then values + if (numExceptions > 0) { + ByteBuffer excPosBuf = + ByteBuffer.allocate(numExceptions * Short.BYTES).order(ByteOrder.LITTLE_ENDIAN); + for (int i = 0; i < numExceptions; i++) { + excPosBuf.putShort(excPositions[i]); + } + encodedVectors.write(excPosBuf.array(), 0, numExceptions * Short.BYTES); + + ByteBuffer excValBuf = + ByteBuffer.allocate(numExceptions * Integer.BYTES).order(ByteOrder.LITTLE_ENDIAN); + for (int i = 0; i < numExceptions; i++) { + excValBuf.putInt(excValues[i]); + } + encodedVectors.write(excValBuf.array(), 0, numExceptions * Integer.BYTES); + } + + vectorByteSizes.add((int) (encodedVectors.size() - startSize)); + } + + private void packIntsWithBytePacker(int[] values, int count, int bitWidth) { + BytePacker packer = Packer.LITTLE_ENDIAN.newBytePacker(bitWidth); + int numFullGroups = count / 8; + int remaining = count % 8; + byte[] packed = new byte[bitWidth]; + + for (int g = 0; g < numFullGroups; g++) { + packer.pack8Values(values, g * 8, packed, 0); + encodedVectors.write(packed, 0, bitWidth); + } + + // Partial last group: pack 8 values (zero-padded), but only write + // ceil(count * bitWidth / 8) - alreadyWritten bytes per spec. + if (remaining > 0) { + int[] padded = new int[8]; + System.arraycopy(values, numFullGroups * 8, padded, 0, remaining); + packer.pack8Values(padded, 0, packed, 0); + int totalPackedBytes = (count * bitWidth + 7) / 8; + int alreadyWritten = numFullGroups * bitWidth; + encodedVectors.write(packed, 0, totalPackedBytes - alreadyWritten); + } + } + + @Override + public long getBufferedSize() { + return encodedVectors.size() + (long) bufferCount * Integer.BYTES; + } + + @Override + public BytesInput getBytes() { + if (totalCount == 0) { + return BytesInput.empty(); + } + + if (bufferCount > 0) { + encodeAndFlushVector(bufferCount); + bufferCount = 0; + } + + int numVectors = vectorByteSizes.size(); + + // Header: packing_mode(1) + log_vector_size(1) + value_byte_width(1) + num_elements(4) = 7B + ByteBuffer header = ByteBuffer.allocate(PFOR_HEADER_SIZE).order(ByteOrder.LITTLE_ENDIAN); + header.put((byte) PFOR_PACKING_MODE_FOR); + header.put((byte) logVectorSize); + header.put((byte) INT32_VALUE_BYTE_WIDTH); + header.putInt(totalCount); + + int offsetArraySize = numVectors * Integer.BYTES; + ByteBuffer offsets = ByteBuffer.allocate(offsetArraySize).order(ByteOrder.LITTLE_ENDIAN); + int currentOffset = offsetArraySize; + for (int v = 0; v < numVectors; v++) { + offsets.putInt(currentOffset); + currentOffset += vectorByteSizes.get(v); + } + + return BytesInput.concat( + BytesInput.from(header.array()), BytesInput.from(offsets.array()), BytesInput.from(encodedVectors)); + } + + @Override + public void reset() { + bufferCount = 0; + totalCount = 0; + encodedVectors.reset(); + vectorByteSizes.clear(); + } + + @Override + public void close() { + encodedVectors.close(); + } + + @Override + public long getAllocatedSize() { + return (long) vectorBuffer.length * Integer.BYTES + encodedVectors.getCapacity(); + } + + @Override + public String memUsageString(String prefix) { + return String.format( + "%s IntPforValuesWriter %d values, %d bytes allocated", prefix, totalCount, getAllocatedSize()); + } + } + + /** INT64 writer. Same structure as IntPforValuesWriter but uses longs. */ + public static class LongPforValuesWriter extends PforValuesWriter { + private final long[] vectorBuffer; + private int bufferCount; + private int totalCount; + private CapacityByteArrayOutputStream encodedVectors; + private final List vectorByteSizes; + + public LongPforValuesWriter(int initialCapacity, int pageSize, ByteBufferAllocator allocator) { + this(initialCapacity, pageSize, allocator, DEFAULT_VECTOR_SIZE); + } + + public LongPforValuesWriter(int initialCapacity, int pageSize, ByteBufferAllocator allocator, int vectorSize) { + super(initialCapacity, pageSize, allocator, vectorSize); + this.vectorBuffer = new long[vectorSize]; + this.bufferCount = 0; + this.totalCount = 0; + this.encodedVectors = new CapacityByteArrayOutputStream(initialCapacity, pageSize, allocator); + this.vectorByteSizes = new ArrayList<>(); + } + + @Override + public void writeLong(long v) { + vectorBuffer[bufferCount++] = v; + totalCount++; + if (bufferCount == vectorSize) { + encodeAndFlushVector(bufferCount); + bufferCount = 0; + } + } + + private void encodeAndFlushVector(int vectorLen) { + long minValue = vectorBuffer[0]; + for (int i = 1; i < vectorLen; i++) { + if (vectorBuffer[i] < minValue) { + minValue = vectorBuffer[i]; + } + } + + long[] deltas = new long[vectorLen]; + for (int i = 0; i < vectorLen; i++) { + deltas[i] = vectorBuffer[i] - minValue; + } + + PforEncoderDecoder.BitWidthResult result = PforEncoderDecoder.findOptimalBitWidthForLong(deltas, vectorLen); + int bitWidth = result.bitWidth; + int numExceptions = result.numExceptions; + + short[] excPositions = new short[numExceptions]; + long[] excValues = new long[numExceptions]; + int excIdx = 0; + + if (numExceptions > 0) { + long mask = (bitWidth == 64) ? -1L : (1L << bitWidth) - 1L; + for (int i = 0; i < vectorLen; i++) { + if (Long.compareUnsigned(deltas[i], mask) > 0) { + excPositions[excIdx] = (short) i; + excValues[excIdx] = vectorBuffer[i]; // original value + excIdx++; + deltas[i] = 0; // placeholder + } + } + } + + long startSize = encodedVectors.size(); + + // PforVectorInfo: frame_of_reference(8) + bit_width(1) + num_exceptions(2) = 11B + ByteBuffer vectorInfo = ByteBuffer.allocate(INT64_VECTOR_INFO_SIZE).order(ByteOrder.LITTLE_ENDIAN); + vectorInfo.putLong(minValue); + vectorInfo.put((byte) bitWidth); + vectorInfo.putShort((short) numExceptions); + encodedVectors.write(vectorInfo.array(), 0, INT64_VECTOR_INFO_SIZE); + + if (bitWidth > 0) { + packLongsWithBytePacker(deltas, vectorLen, bitWidth); + } + + if (numExceptions > 0) { + ByteBuffer excPosBuf = + ByteBuffer.allocate(numExceptions * Short.BYTES).order(ByteOrder.LITTLE_ENDIAN); + for (int i = 0; i < numExceptions; i++) { + excPosBuf.putShort(excPositions[i]); + } + encodedVectors.write(excPosBuf.array(), 0, numExceptions * Short.BYTES); + + ByteBuffer excValBuf = + ByteBuffer.allocate(numExceptions * Long.BYTES).order(ByteOrder.LITTLE_ENDIAN); + for (int i = 0; i < numExceptions; i++) { + excValBuf.putLong(excValues[i]); + } + encodedVectors.write(excValBuf.array(), 0, numExceptions * Long.BYTES); + } + + vectorByteSizes.add((int) (encodedVectors.size() - startSize)); + } + + private void packLongsWithBytePacker(long[] values, int count, int bitWidth) { + BytePackerForLong packer = Packer.LITTLE_ENDIAN.newBytePackerForLong(bitWidth); + int numFullGroups = count / 8; + int remaining = count % 8; + byte[] packed = new byte[bitWidth]; + + for (int g = 0; g < numFullGroups; g++) { + packer.pack8Values(values, g * 8, packed, 0); + encodedVectors.write(packed, 0, bitWidth); + } + + if (remaining > 0) { + long[] padded = new long[8]; + System.arraycopy(values, numFullGroups * 8, padded, 0, remaining); + packer.pack8Values(padded, 0, packed, 0); + int totalPackedBytes = (count * bitWidth + 7) / 8; + int alreadyWritten = numFullGroups * bitWidth; + encodedVectors.write(packed, 0, totalPackedBytes - alreadyWritten); + } + } + + @Override + public long getBufferedSize() { + return encodedVectors.size() + (long) bufferCount * Long.BYTES; + } + + @Override + public BytesInput getBytes() { + if (totalCount == 0) { + return BytesInput.empty(); + } + + if (bufferCount > 0) { + encodeAndFlushVector(bufferCount); + bufferCount = 0; + } + + int numVectors = vectorByteSizes.size(); + + ByteBuffer header = ByteBuffer.allocate(PFOR_HEADER_SIZE).order(ByteOrder.LITTLE_ENDIAN); + header.put((byte) PFOR_PACKING_MODE_FOR); + header.put((byte) logVectorSize); + header.put((byte) INT64_VALUE_BYTE_WIDTH); + header.putInt(totalCount); + + int offsetArraySize = numVectors * Integer.BYTES; + ByteBuffer offsets = ByteBuffer.allocate(offsetArraySize).order(ByteOrder.LITTLE_ENDIAN); + int currentOffset = offsetArraySize; + for (int v = 0; v < numVectors; v++) { + offsets.putInt(currentOffset); + currentOffset += vectorByteSizes.get(v); + } + + return BytesInput.concat( + BytesInput.from(header.array()), BytesInput.from(offsets.array()), BytesInput.from(encodedVectors)); + } + + @Override + public void reset() { + bufferCount = 0; + totalCount = 0; + encodedVectors.reset(); + vectorByteSizes.clear(); + } + + @Override + public void close() { + encodedVectors.close(); + } + + @Override + public long getAllocatedSize() { + return (long) vectorBuffer.length * Long.BYTES + encodedVectors.getCapacity(); + } + + @Override + public String memUsageString(String prefix) { + return String.format( + "%s LongPforValuesWriter %d values, %d bytes allocated", prefix, totalCount, getAllocatedSize()); + } + } +} From 6304bbee1bb8a51efc7ee87810be0427ff2efa83 Mon Sep 17 00:00:00 2001 From: Prateek Gaur Date: Tue, 21 Apr 2026 00:15:22 +0000 Subject: [PATCH 02/14] Integrate PFOR encoding into parquet-java framework Wires PFOR encoding into the parquet-java read/write pipeline: - Encoding.java: add PFOR enum with INT32/INT64 reader dispatch - ParquetProperties.java: add pforEnabled column property with isPforEnabled() and builder methods withPforEncoding() - DefaultV2ValuesWriterFactory.java: PFOR takes priority over BYTE_STREAM_SPLIT and DELTA_BINARY_PACKED for INT32/INT64 - ParquetMetadataConverter.java: guard for PFOR until thrift spec is merged upstream --- .../org/apache/parquet/column/Encoding.java | 23 ++++++++++ .../parquet/column/ParquetProperties.java | 46 +++++++++++++++++++ .../factory/DefaultV2ValuesWriterFactory.java | 15 +++++- .../converter/ParquetMetadataConverter.java | 4 ++ 4 files changed, 86 insertions(+), 2 deletions(-) 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..3241c3121f 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 @@ -36,6 +36,8 @@ import org.apache.parquet.column.values.bytestreamsplit.ByteStreamSplitValuesReaderForInteger; import org.apache.parquet.column.values.bytestreamsplit.ByteStreamSplitValuesReaderForLong; import org.apache.parquet.column.values.delta.DeltaBinaryPackingValuesReader; +import org.apache.parquet.column.values.pfor.PforValuesReaderForInt; +import org.apache.parquet.column.values.pfor.PforValuesReaderForLong; import org.apache.parquet.column.values.deltalengthbytearray.DeltaLengthByteArrayValuesReader; import org.apache.parquet.column.values.deltastrings.DeltaByteArrayReader; import org.apache.parquet.column.values.dictionary.DictionaryValuesReader; @@ -147,6 +149,27 @@ public ValuesReader getValuesReader(ColumnDescriptor descriptor, ValuesType valu } }, + /** + * PFOR (Patched Frame of Reference) encoding for INT32 and INT64 types. + * Compresses integer columns by subtracting the minimum value, selecting an + * optimal bit width via a cost model, bit-packing the deltas, and storing + * outlier values as exceptions. + */ + PFOR { + @Override + public ValuesReader getValuesReader(ColumnDescriptor descriptor, ValuesType valuesType) { + switch (descriptor.getType()) { + case INT32: + return new PforValuesReaderForInt(); + case INT64: + return new PforValuesReaderForLong(); + default: + throw new ParquetDecodingException( + "PFOR encoding is only supported for INT32 and INT64, not " + descriptor.getType()); + } + } + }, + /** * @deprecated This is no longer used, and has been replaced by {@link #RLE} * which is combination of bit packing and rle 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 f29214b458..4036d35e34 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 @@ -50,6 +50,7 @@ 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_PFOR_ENABLED = false; 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; @@ -132,6 +133,7 @@ public static WriterVersion fromString(String name) { private final int pageRowCountLimit; private final boolean pageWriteChecksumEnabled; private final ColumnProperty byteStreamSplitEnabled; + private final ColumnProperty pforEnabled; private final Map extraMetaData; private final ColumnProperty statistics; private final ColumnProperty sizeStatistics; @@ -164,6 +166,7 @@ private ParquetProperties(Builder builder) { this.pageRowCountLimit = builder.pageRowCountLimit; this.pageWriteChecksumEnabled = builder.pageWriteChecksumEnabled; this.byteStreamSplitEnabled = builder.byteStreamSplitEnabled.build(); + this.pforEnabled = builder.pforEnabled.build(); this.extraMetaData = builder.extraMetaData; this.statistics = builder.statistics.build(); this.sizeStatistics = builder.sizeStatistics.build(); @@ -259,6 +262,23 @@ public boolean isByteStreamSplitEnabled(ColumnDescriptor column) { } } + /** + * Check if PFOR encoding is enabled for the given column. + * PFOR encoding is only supported for INT32 and INT64 types. + * + * @param column the column descriptor + * @return true if PFOR encoding is enabled for this column + */ + public boolean isPforEnabled(ColumnDescriptor column) { + switch (column.getPrimitiveType().getPrimitiveTypeName()) { + case INT32: + case INT64: + return pforEnabled.getValue(column); + default: + return false; + } + } + public ByteBufferAllocator getAllocator() { return allocator; } @@ -416,6 +436,7 @@ 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 pforEnabled; private Map extraMetaData = new HashMap<>(); private final ColumnProperty.Builder statistics; private final ColumnProperty.Builder sizeStatistics; @@ -427,6 +448,7 @@ private Builder() { DEFAULT_IS_BYTE_STREAM_SPLIT_ENABLED ? ByteStreamSplitMode.FLOATING_POINT : ByteStreamSplitMode.NONE); + pforEnabled = ColumnProperty.builder().withDefaultValue(DEFAULT_IS_PFOR_ENABLED); bloomFilterEnabled = ColumnProperty.builder().withDefaultValue(DEFAULT_BLOOM_FILTER_ENABLED); bloomFilterNDVs = ColumnProperty.builder().withDefaultValue(null); bloomFilterFPPs = ColumnProperty.builder().withDefaultValue(DEFAULT_BLOOM_FILTER_FPP); @@ -457,6 +479,7 @@ private Builder(ParquetProperties toCopy) { this.numBloomFilterCandidates = ColumnProperty.builder(toCopy.numBloomFilterCandidates); this.maxBloomFilterBytes = toCopy.maxBloomFilterBytes; this.byteStreamSplitEnabled = ColumnProperty.builder(toCopy.byteStreamSplitEnabled); + this.pforEnabled = ColumnProperty.builder(toCopy.pforEnabled); this.extraMetaData = toCopy.extraMetaData; this.statistics = ColumnProperty.builder(toCopy.statistics); this.sizeStatistics = ColumnProperty.builder(toCopy.sizeStatistics); @@ -534,6 +557,29 @@ public Builder withExtendedByteStreamSplitEncoding(boolean enable) { return this; } + /** + * Enable or disable PFOR encoding for INT32 and INT64 columns. + * + * @param enable whether PFOR encoding should be enabled + * @return this builder for method chaining. + */ + public Builder withPforEncoding(boolean enable) { + this.pforEnabled.withDefaultValue(enable); + return this; + } + + /** + * Enable or disable PFOR encoding for the specified column. + * + * @param columnPath the path of the column (dot-string) + * @param enable whether PFOR encoding should be enabled + * @return this builder for method chaining. + */ + public Builder withPforEncoding(String columnPath, boolean enable) { + this.pforEnabled.withValue(columnPath, enable); + return this; + } + /** * Set the Parquet format dictionary page size. * 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..5891d943b5 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 @@ -27,6 +27,7 @@ import org.apache.parquet.column.values.ValuesWriter; import org.apache.parquet.column.values.bytestreamsplit.ByteStreamSplitValuesWriter; import org.apache.parquet.column.values.delta.DeltaBinaryPackingValuesWriterForInteger; +import org.apache.parquet.column.values.pfor.PforValuesWriter; import org.apache.parquet.column.values.delta.DeltaBinaryPackingValuesWriterForLong; import org.apache.parquet.column.values.deltastrings.DeltaByteArrayWriter; import org.apache.parquet.column.values.plain.FixedLenByteArrayPlainValuesWriter; @@ -115,7 +116,12 @@ private ValuesWriter getBinaryValuesWriter(ColumnDescriptor path) { private ValuesWriter getInt32ValuesWriter(ColumnDescriptor path) { final ValuesWriter fallbackWriter; - if (parquetProperties.isByteStreamSplitEnabled(path)) { + if (this.parquetProperties.isPforEnabled(path)) { + fallbackWriter = new PforValuesWriter.IntPforValuesWriter( + parquetProperties.getInitialSlabSize(), + parquetProperties.getPageSizeThreshold(), + parquetProperties.getAllocator()); + } else if (parquetProperties.isByteStreamSplitEnabled(path)) { fallbackWriter = new ByteStreamSplitValuesWriter.IntegerByteStreamSplitValuesWriter( parquetProperties.getInitialSlabSize(), parquetProperties.getPageSizeThreshold(), @@ -132,7 +138,12 @@ private ValuesWriter getInt32ValuesWriter(ColumnDescriptor path) { private ValuesWriter getInt64ValuesWriter(ColumnDescriptor path) { final ValuesWriter fallbackWriter; - if (parquetProperties.isByteStreamSplitEnabled(path)) { + if (this.parquetProperties.isPforEnabled(path)) { + fallbackWriter = new PforValuesWriter.LongPforValuesWriter( + parquetProperties.getInitialSlabSize(), + parquetProperties.getPageSizeThreshold(), + parquetProperties.getAllocator()); + } else if (parquetProperties.isByteStreamSplitEnabled(path)) { fallbackWriter = new ByteStreamSplitValuesWriter.LongByteStreamSplitValuesWriter( parquetProperties.getInitialSlabSize(), parquetProperties.getPageSizeThreshold(), 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 60150439a6..7ee76063d6 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 @@ -748,6 +748,10 @@ public org.apache.parquet.column.Encoding getEncoding(Encoding encoding) { } public Encoding getEncoding(org.apache.parquet.column.Encoding encoding) { + // PFOR encoding is not yet part of the parquet-format specification + if (encoding == org.apache.parquet.column.Encoding.PFOR) { + throw new IllegalArgumentException("PFOR encoding is not yet supported in the parquet-format specification"); + } return Encoding.valueOf(encoding.name()); } From ee0b108f9f5ef40a59662b89262c01e1dc03ce53 Mon Sep 17 00:00:00 2001 From: Prateek Gaur Date: Tue, 21 Apr 2026 00:25:15 +0000 Subject: [PATCH 03/14] Add PFOR encoding unit tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 64 tests covering: - PforEncoderDecoderTest: bit width utilities and histogram-based cost model - PforBitPackingTest: round-trip correctness across bit widths 0-64, partial groups, page header format - PforValuesEndToEndTest: full writer→reader pipeline including reset/reuse, skip, edge cases, random data --- .../values/pfor/PforBitPackingTest.java | 269 +++++++++++ .../values/pfor/PforEncoderDecoderTest.java | 206 ++++++++ .../values/pfor/PforValuesEndToEndTest.java | 446 ++++++++++++++++++ 3 files changed, 921 insertions(+) create mode 100644 parquet-column/src/test/java/org/apache/parquet/column/values/pfor/PforBitPackingTest.java create mode 100644 parquet-column/src/test/java/org/apache/parquet/column/values/pfor/PforEncoderDecoderTest.java create mode 100644 parquet-column/src/test/java/org/apache/parquet/column/values/pfor/PforValuesEndToEndTest.java diff --git a/parquet-column/src/test/java/org/apache/parquet/column/values/pfor/PforBitPackingTest.java b/parquet-column/src/test/java/org/apache/parquet/column/values/pfor/PforBitPackingTest.java new file mode 100644 index 0000000000..20732f787c --- /dev/null +++ b/parquet-column/src/test/java/org/apache/parquet/column/values/pfor/PforBitPackingTest.java @@ -0,0 +1,269 @@ +/* + * 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.pfor; + +import static org.junit.Assert.*; + +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import org.apache.parquet.bytes.ByteBufferInputStream; +import org.apache.parquet.bytes.BytesInput; +import org.apache.parquet.bytes.DirectByteBufferAllocator; +import org.junit.Test; + +/** + * Tests focused on PFOR bit-packing correctness across different bit widths + * and vector sizes. + */ +public class PforBitPackingTest { + + // Round-trip helper that verifies bit-packing for int values in a given range + private void verifyIntRoundTrip(int[] values) throws Exception { + int capacity = Math.max(256, values.length * 8); + PforValuesWriter.IntPforValuesWriter writer = new PforValuesWriter.IntPforValuesWriter( + capacity, capacity, new DirectByteBufferAllocator()); + + for (int v : values) { + writer.writeInteger(v); + } + + BytesInput bytes = writer.getBytes(); + PforValuesReaderForInt reader = new PforValuesReaderForInt(); + reader.initFromPage(values.length, ByteBufferInputStream.wrap(bytes.toByteBuffer())); + + for (int i = 0; i < values.length; i++) { + assertEquals("Mismatch at index " + i, values[i], reader.readInteger()); + } + + writer.close(); + } + + private void verifyLongRoundTrip(long[] values) throws Exception { + int capacity = Math.max(512, values.length * 16); + PforValuesWriter.LongPforValuesWriter writer = new PforValuesWriter.LongPforValuesWriter( + capacity, capacity, new DirectByteBufferAllocator()); + + for (long v : values) { + writer.writeLong(v); + } + + BytesInput bytes = writer.getBytes(); + PforValuesReaderForLong reader = new PforValuesReaderForLong(); + reader.initFromPage(values.length, ByteBufferInputStream.wrap(bytes.toByteBuffer())); + + for (int i = 0; i < values.length; i++) { + assertEquals("Mismatch at index " + i, values[i], reader.readLong()); + } + + writer.close(); + } + + // ========== INT32 Bit Width Coverage ========== + + @Test + public void testIntBitWidth0() throws Exception { + // All same value → bitWidth=0, no packed bytes + int[] values = new int[100]; + java.util.Arrays.fill(values, 777); + verifyIntRoundTrip(values); + } + + @Test + public void testIntBitWidth1() throws Exception { + // Values: base + {0, 1} + int[] values = new int[100]; + for (int i = 0; i < 100; i++) { + values[i] = 1000 + (i % 2); + } + verifyIntRoundTrip(values); + } + + @Test + public void testIntBitWidth8() throws Exception { + int[] values = new int[1024]; + for (int i = 0; i < 1024; i++) { + values[i] = 5000 + (i % 256); + } + verifyIntRoundTrip(values); + } + + @Test + public void testIntBitWidth16() throws Exception { + int[] values = new int[1024]; + for (int i = 0; i < 1024; i++) { + values[i] = i; + } + verifyIntRoundTrip(values); + } + + @Test + public void testIntBitWidth32() throws Exception { + // Full range int values + int[] values = {Integer.MIN_VALUE, -1, 0, 1, Integer.MAX_VALUE, + 0x7FFFFFFF, 0x40000000, -2147483648}; + verifyIntRoundTrip(values); + } + + @Test + public void testIntPartialGroup() throws Exception { + // 13 values: 1 full group of 8 + 5 remaining + int[] values = new int[13]; + for (int i = 0; i < 13; i++) { + values[i] = i * 100; + } + verifyIntRoundTrip(values); + } + + @Test + public void testIntExactlyOneGroup() throws Exception { + // Exactly 8 values + int[] values = {10, 20, 30, 40, 50, 60, 70, 80}; + verifyIntRoundTrip(values); + } + + @Test + public void testIntSevenValues() throws Exception { + // Less than one full group + int[] values = {1, 2, 3, 4, 5, 6, 7}; + verifyIntRoundTrip(values); + } + + // ========== INT64 Bit Width Coverage ========== + + @Test + public void testLongBitWidth0() throws Exception { + long[] values = new long[100]; + java.util.Arrays.fill(values, 123456789L); + verifyLongRoundTrip(values); + } + + @Test + public void testLongBitWidth1() throws Exception { + long[] values = new long[100]; + for (int i = 0; i < 100; i++) { + values[i] = 1_000_000L + (i % 2); + } + verifyLongRoundTrip(values); + } + + @Test + public void testLongBitWidth32() throws Exception { + long[] values = new long[1024]; + for (int i = 0; i < 1024; i++) { + values[i] = (long) i * 1_000_000L; + } + verifyLongRoundTrip(values); + } + + @Test + public void testLongBitWidth64() throws Exception { + long[] values = {Long.MIN_VALUE, Long.MAX_VALUE, 0L, -1L, 1L}; + verifyLongRoundTrip(values); + } + + @Test + public void testLongPartialGroup() throws Exception { + long[] values = new long[13]; + for (int i = 0; i < 13; i++) { + values[i] = i * 100_000L; + } + verifyLongRoundTrip(values); + } + + // ========== Page Header Verification ========== + + @Test + public void testIntPageHeaderFormat() throws Exception { + int[] values = new int[100]; + for (int i = 0; i < 100; i++) { + values[i] = i; + } + + PforValuesWriter.IntPforValuesWriter writer = new PforValuesWriter.IntPforValuesWriter( + 1024, 1024, new DirectByteBufferAllocator()); + for (int v : values) { + writer.writeInteger(v); + } + + ByteBuffer buf = writer.getBytes().toByteBuffer().order(ByteOrder.LITTLE_ENDIAN); + + // Verify header + assertEquals("packing_mode", 0, buf.get(0) & 0xFF); + assertEquals("log_vector_size", 10, buf.get(1) & 0xFF); + assertEquals("value_byte_width", 4, buf.get(2) & 0xFF); + + buf.position(3); + assertEquals("num_elements", 100, buf.getInt()); + + writer.close(); + } + + @Test + public void testLongPageHeaderFormat() throws Exception { + long[] values = new long[100]; + for (int i = 0; i < 100; i++) { + values[i] = i; + } + + PforValuesWriter.LongPforValuesWriter writer = new PforValuesWriter.LongPforValuesWriter( + 1024, 1024, new DirectByteBufferAllocator()); + for (long v : values) { + writer.writeLong(v); + } + + ByteBuffer buf = writer.getBytes().toByteBuffer().order(ByteOrder.LITTLE_ENDIAN); + + assertEquals("packing_mode", 0, buf.get(0) & 0xFF); + assertEquals("log_vector_size", 10, buf.get(1) & 0xFF); + assertEquals("value_byte_width", 8, buf.get(2) & 0xFF); + + buf.position(3); + assertEquals("num_elements", 100, buf.getInt()); + + writer.close(); + } + + // ========== Exception Handling ========== + + @Test + public void testIntManyExceptions() throws Exception { + // More than half are outliers — cost model should widen bit width + int[] values = new int[100]; + for (int i = 0; i < 100; i++) { + values[i] = (i % 2 == 0) ? i : 1_000_000 + i; + } + verifyIntRoundTrip(values); + } + + @Test + public void testIntAllExceptionsScenario() throws Exception { + // Very wide range but few values: might make everything exceptions or wide pack + int[] values = {0, Integer.MAX_VALUE, Integer.MIN_VALUE, 42, -42}; + verifyIntRoundTrip(values); + } + + @Test + public void testLongManyExceptions() throws Exception { + long[] values = new long[100]; + for (int i = 0; i < 100; i++) { + values[i] = (i % 2 == 0) ? i : Long.MAX_VALUE - i; + } + verifyLongRoundTrip(values); + } +} diff --git a/parquet-column/src/test/java/org/apache/parquet/column/values/pfor/PforEncoderDecoderTest.java b/parquet-column/src/test/java/org/apache/parquet/column/values/pfor/PforEncoderDecoderTest.java new file mode 100644 index 0000000000..fa8ed98249 --- /dev/null +++ b/parquet-column/src/test/java/org/apache/parquet/column/values/pfor/PforEncoderDecoderTest.java @@ -0,0 +1,206 @@ +/* + * 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.pfor; + +import static org.junit.Assert.*; + +import org.junit.Test; + +/** + * Tests for PFOR cost model and bit width utilities. + */ +public class PforEncoderDecoderTest { + + // ========== Bit Width Tests ========== + + @Test + public void testBitWidthForInt() { + assertEquals(0, PforEncoderDecoder.bitWidthForInt(0)); + assertEquals(1, PforEncoderDecoder.bitWidthForInt(1)); + assertEquals(2, PforEncoderDecoder.bitWidthForInt(2)); + assertEquals(2, PforEncoderDecoder.bitWidthForInt(3)); + assertEquals(3, PforEncoderDecoder.bitWidthForInt(4)); + assertEquals(8, PforEncoderDecoder.bitWidthForInt(255)); + assertEquals(9, PforEncoderDecoder.bitWidthForInt(256)); + assertEquals(16, PforEncoderDecoder.bitWidthForInt(65535)); + assertEquals(31, PforEncoderDecoder.bitWidthForInt(Integer.MAX_VALUE)); + // Unsigned: -1 == 0xFFFFFFFF → 32 bits + assertEquals(32, PforEncoderDecoder.bitWidthForInt(-1)); + } + + @Test + public void testBitWidthForLong() { + assertEquals(0, PforEncoderDecoder.bitWidthForLong(0L)); + assertEquals(1, PforEncoderDecoder.bitWidthForLong(1L)); + assertEquals(2, PforEncoderDecoder.bitWidthForLong(2L)); + assertEquals(2, PforEncoderDecoder.bitWidthForLong(3L)); + assertEquals(3, PforEncoderDecoder.bitWidthForLong(4L)); + assertEquals(8, PforEncoderDecoder.bitWidthForLong(255L)); + assertEquals(9, PforEncoderDecoder.bitWidthForLong(256L)); + assertEquals(16, PforEncoderDecoder.bitWidthForLong(65535L)); + assertEquals(31, PforEncoderDecoder.bitWidthForLong((long) Integer.MAX_VALUE)); + assertEquals(63, PforEncoderDecoder.bitWidthForLong(Long.MAX_VALUE)); + // Unsigned: -1 == 0xFFFFFFFFFFFFFFFF → 64 bits + assertEquals(64, PforEncoderDecoder.bitWidthForLong(-1L)); + } + + // ========== Cost Model Tests: INT32 ========== + + @Test + public void testOptimalBitWidthAllIdentical() { + // All deltas are 0 → bit_width=0, no exceptions + int[] deltas = new int[1024]; + PforEncoderDecoder.BitWidthResult result = PforEncoderDecoder.findOptimalBitWidthForInt(deltas, 1024); + assertEquals(0, result.bitWidth); + assertEquals(0, result.numExceptions); + } + + @Test + public void testOptimalBitWidthNoOutliers() { + // Deltas 0..255 → all fit in 8 bits, no exceptions + int[] deltas = new int[256]; + for (int i = 0; i < 256; i++) { + deltas[i] = i; + } + PforEncoderDecoder.BitWidthResult result = PforEncoderDecoder.findOptimalBitWidthForInt(deltas, 256); + assertEquals(8, result.bitWidth); + assertEquals(0, result.numExceptions); + } + + @Test + public void testOptimalBitWidthSingleOutlier() { + // 1023 values fit in 8 bits (0..255), 1 outlier at 100000 + // Cost at bw=8: 1024*8 + 0 = 8192 + // Cost at bw=17: 1024*17 + 0 = 17408 + // Cost at bw=0: 1024*0 + 1024*48 = 49152 + // Single outlier should still pick bw=8: cost=1024*8 + 1*48 = 8240 + int[] deltas = new int[1024]; + for (int i = 0; i < 1023; i++) { + deltas[i] = i % 256; + } + deltas[1023] = 100000; + PforEncoderDecoder.BitWidthResult result = PforEncoderDecoder.findOptimalBitWidthForInt(deltas, 1024); + assertEquals(8, result.bitWidth); + assertEquals(1, result.numExceptions); + } + + @Test + public void testOptimalBitWidthManyOutliers() { + // All values need 32 bits → bit_width=32, no exceptions + int[] deltas = new int[100]; + for (int i = 0; i < 100; i++) { + deltas[i] = Integer.MAX_VALUE - i; + } + PforEncoderDecoder.BitWidthResult result = PforEncoderDecoder.findOptimalBitWidthForInt(deltas, 100); + assertEquals(31, result.bitWidth); + assertEquals(0, result.numExceptions); + } + + @Test + public void testOptimalBitWidthSingleElement() { + int[] deltas = {42}; + PforEncoderDecoder.BitWidthResult result = PforEncoderDecoder.findOptimalBitWidthForInt(deltas, 1); + assertEquals(0, result.numExceptions); + assertTrue(result.bitWidth >= 6); // 42 needs 6 bits + } + + @Test + public void testOptimalBitWidthAllZeros() { + int[] deltas = new int[512]; + PforEncoderDecoder.BitWidthResult result = PforEncoderDecoder.findOptimalBitWidthForInt(deltas, 512); + assertEquals(0, result.bitWidth); + assertEquals(0, result.numExceptions); + } + + // ========== Cost Model Tests: INT64 ========== + + @Test + public void testOptimalBitWidthLongAllIdentical() { + long[] deltas = new long[1024]; + PforEncoderDecoder.BitWidthResult result = PforEncoderDecoder.findOptimalBitWidthForLong(deltas, 1024); + assertEquals(0, result.bitWidth); + assertEquals(0, result.numExceptions); + } + + @Test + public void testOptimalBitWidthLongNoOutliers() { + long[] deltas = new long[256]; + for (int i = 0; i < 256; i++) { + deltas[i] = i; + } + PforEncoderDecoder.BitWidthResult result = PforEncoderDecoder.findOptimalBitWidthForLong(deltas, 256); + assertEquals(8, result.bitWidth); + assertEquals(0, result.numExceptions); + } + + @Test + public void testOptimalBitWidthLongSingleOutlier() { + long[] deltas = new long[1024]; + for (int i = 0; i < 1023; i++) { + deltas[i] = i % 256; + } + deltas[1023] = 10_000_000_000L; + PforEncoderDecoder.BitWidthResult result = PforEncoderDecoder.findOptimalBitWidthForLong(deltas, 1024); + assertEquals(8, result.bitWidth); + assertEquals(1, result.numExceptions); + } + + @Test + public void testOptimalBitWidthLongLargeValues() { + long[] deltas = new long[100]; + for (int i = 0; i < 100; i++) { + deltas[i] = Long.MAX_VALUE - i; + } + PforEncoderDecoder.BitWidthResult result = PforEncoderDecoder.findOptimalBitWidthForLong(deltas, 100); + assertEquals(63, result.bitWidth); + assertEquals(0, result.numExceptions); + } + + // ========== Cost Model Sanity Checks ========== + + @Test + public void testCostModelPrefersFewerExceptions() { + // With 50% outliers, the cost model should widen bit width rather than + // storing half the values as exceptions + int[] deltas = new int[100]; + for (int i = 0; i < 50; i++) { + deltas[i] = i; // 0..49 fit in 6 bits + } + for (int i = 50; i < 100; i++) { + deltas[i] = 1000 + i; // need ~10 bits + } + PforEncoderDecoder.BitWidthResult result = PforEncoderDecoder.findOptimalBitWidthForInt(deltas, 100); + // Should choose to pack everything (10-11 bits) rather than 50 exceptions + assertEquals(0, result.numExceptions); + } + + @Test + public void testCostModelNeverExceedsMaxBitWidth() { + int[] deltas = {-1}; // 0xFFFFFFFF, needs 32 bits unsigned + PforEncoderDecoder.BitWidthResult result = PforEncoderDecoder.findOptimalBitWidthForInt(deltas, 1); + assertTrue(result.bitWidth <= 32); + } + + @Test + public void testCostModelLongNeverExceedsMaxBitWidth() { + long[] deltas = {-1L}; // 0xFFFFFFFFFFFFFFFF, needs 64 bits unsigned + PforEncoderDecoder.BitWidthResult result = PforEncoderDecoder.findOptimalBitWidthForLong(deltas, 1); + assertTrue(result.bitWidth <= 64); + } +} diff --git a/parquet-column/src/test/java/org/apache/parquet/column/values/pfor/PforValuesEndToEndTest.java b/parquet-column/src/test/java/org/apache/parquet/column/values/pfor/PforValuesEndToEndTest.java new file mode 100644 index 0000000000..d1c9aa80fd --- /dev/null +++ b/parquet-column/src/test/java/org/apache/parquet/column/values/pfor/PforValuesEndToEndTest.java @@ -0,0 +1,446 @@ +/* + * 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.pfor; + +import static org.junit.Assert.*; + +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.junit.Test; + +/** + * End-to-end tests for PFOR encoding and decoding pipeline. + * Tests the full writer → serialized bytes → reader round-trip. + */ +public class PforValuesEndToEndTest { + + private static final int DEFAULT_VECTOR_SIZE = PforConstants.DEFAULT_VECTOR_SIZE; + + // ========== INT32 Helper ========== + + private void roundTripInt(int[] values) throws Exception { + roundTripInt(values, DEFAULT_VECTOR_SIZE); + } + + private void roundTripInt(int[] values, int vectorSize) throws Exception { + PforValuesWriter.IntPforValuesWriter writer = null; + try { + int capacity = Math.max(256, values.length * 8); + writer = new PforValuesWriter.IntPforValuesWriter( + capacity, capacity, new DirectByteBufferAllocator(), vectorSize); + + for (int v : values) { + writer.writeInteger(v); + } + + assertEquals(Encoding.PFOR, writer.getEncoding()); + + BytesInput input = writer.getBytes(); + PforValuesReaderForInt reader = new PforValuesReaderForInt(); + reader.initFromPage(values.length, ByteBufferInputStream.wrap(input.toByteBuffer())); + + for (int i = 0; i < values.length; i++) { + assertEquals("Value mismatch at index " + i, values[i], reader.readInteger()); + } + } finally { + if (writer != null) { + writer.reset(); + writer.close(); + } + } + } + + // ========== INT64 Helper ========== + + private void roundTripLong(long[] values) throws Exception { + roundTripLong(values, DEFAULT_VECTOR_SIZE); + } + + private void roundTripLong(long[] values, int vectorSize) throws Exception { + PforValuesWriter.LongPforValuesWriter writer = null; + try { + int capacity = Math.max(512, values.length * 16); + writer = new PforValuesWriter.LongPforValuesWriter( + capacity, capacity, new DirectByteBufferAllocator(), vectorSize); + + for (long v : values) { + writer.writeLong(v); + } + + assertEquals(Encoding.PFOR, writer.getEncoding()); + + BytesInput input = writer.getBytes(); + PforValuesReaderForLong reader = new PforValuesReaderForLong(); + reader.initFromPage(values.length, ByteBufferInputStream.wrap(input.toByteBuffer())); + + for (int i = 0; i < values.length; i++) { + assertEquals("Value mismatch at index " + i, values[i], reader.readLong()); + } + } finally { + if (writer != null) { + writer.reset(); + writer.close(); + } + } + } + + // ========== INT32 Tests ========== + + @Test + public void testIntSimpleSequence() throws Exception { + int[] values = new int[100]; + for (int i = 0; i < 100; i++) { + values[i] = i; + } + roundTripInt(values); + } + + @Test + public void testIntAllIdentical() throws Exception { + int[] values = new int[1024]; + java.util.Arrays.fill(values, 42); + roundTripInt(values); + } + + @Test + public void testIntAllZeros() throws Exception { + int[] values = new int[1024]; + roundTripInt(values); + } + + @Test + public void testIntSingleElement() throws Exception { + roundTripInt(new int[]{12345}); + } + + @Test + public void testIntNegativeValues() throws Exception { + int[] values = {-100, -50, -1, 0, 1, 50, 100}; + roundTripInt(values); + } + + @Test + public void testIntMinMaxValues() throws Exception { + int[] values = {Integer.MIN_VALUE, Integer.MAX_VALUE, 0, -1, 1}; + roundTripInt(values); + } + + @Test + public void testIntAlternatingMinMax() throws Exception { + int[] values = new int[100]; + for (int i = 0; i < 100; i++) { + values[i] = (i % 2 == 0) ? Integer.MIN_VALUE : Integer.MAX_VALUE; + } + roundTripInt(values); + } + + @Test + public void testIntExactOneVector() throws Exception { + int[] values = new int[1024]; + for (int i = 0; i < 1024; i++) { + values[i] = i * 3; + } + roundTripInt(values); + } + + @Test + public void testIntMultipleVectors() throws Exception { + int[] values = new int[3000]; + for (int i = 0; i < 3000; i++) { + values[i] = i * 7 - 10000; + } + roundTripInt(values); + } + + @Test + public void testIntPartialLastVector() throws Exception { + // 1025 values = 1 full vector + 1 partial + int[] values = new int[1025]; + for (int i = 0; i < 1025; i++) { + values[i] = i; + } + roundTripInt(values); + } + + @Test + public void testIntWithOutliers() throws Exception { + // Mostly small values with a few large outliers + int[] values = new int[1024]; + for (int i = 0; i < 1024; i++) { + values[i] = i % 100; + } + values[0] = 1_000_000; + values[512] = -1_000_000; + values[1023] = Integer.MAX_VALUE; + roundTripInt(values); + } + + @Test + public void testIntLargeRandom() throws Exception { + Random rng = new Random(42); + int[] values = new int[10000]; + for (int i = 0; i < 10000; i++) { + values[i] = rng.nextInt(); + } + roundTripInt(values); + } + + @Test + public void testIntSmallVectorSize() throws Exception { + int[] values = new int[100]; + for (int i = 0; i < 100; i++) { + values[i] = i * 11; + } + roundTripInt(values, 8); // smallest valid vector size + } + + @Test + public void testIntTpcdsLikeDateKeys() throws Exception { + // Simulate TPC-DS date dimension keys: mostly sequential with a few gaps + int[] values = new int[2048]; + for (int i = 0; i < 2048; i++) { + values[i] = 2450815 + i + (i % 100 == 0 ? 100 : 0); + } + roundTripInt(values); + } + + // ========== INT64 Tests ========== + + @Test + public void testLongSimpleSequence() throws Exception { + long[] values = new long[100]; + for (int i = 0; i < 100; i++) { + values[i] = i; + } + roundTripLong(values); + } + + @Test + public void testLongAllIdentical() throws Exception { + long[] values = new long[1024]; + java.util.Arrays.fill(values, 999999999999L); + roundTripLong(values); + } + + @Test + public void testLongAllZeros() throws Exception { + long[] values = new long[1024]; + roundTripLong(values); + } + + @Test + public void testLongSingleElement() throws Exception { + roundTripLong(new long[]{Long.MAX_VALUE}); + } + + @Test + public void testLongNegativeValues() throws Exception { + long[] values = {-100_000_000_000L, -1L, 0L, 1L, 100_000_000_000L}; + roundTripLong(values); + } + + @Test + public void testLongMinMaxValues() throws Exception { + long[] values = {Long.MIN_VALUE, Long.MAX_VALUE, 0L, -1L, 1L}; + roundTripLong(values); + } + + @Test + public void testLongMultipleVectors() throws Exception { + long[] values = new long[3000]; + for (int i = 0; i < 3000; i++) { + values[i] = (long) i * 1_000_000L - 1_500_000_000L; + } + roundTripLong(values); + } + + @Test + public void testLongPartialLastVector() throws Exception { + long[] values = new long[1025]; + for (int i = 0; i < 1025; i++) { + values[i] = i * 17L; + } + roundTripLong(values); + } + + @Test + public void testLongWithOutliers() throws Exception { + long[] values = new long[1024]; + for (int i = 0; i < 1024; i++) { + values[i] = i % 100; + } + values[0] = Long.MAX_VALUE; + values[512] = Long.MIN_VALUE; + values[1023] = 10_000_000_000_000L; + roundTripLong(values); + } + + @Test + public void testLongLargeRandom() throws Exception { + Random rng = new Random(42); + long[] values = new long[10000]; + for (int i = 0; i < 10000; i++) { + values[i] = rng.nextLong(); + } + roundTripLong(values); + } + + @Test + public void testLongSmallVectorSize() throws Exception { + long[] values = new long[100]; + for (int i = 0; i < 100; i++) { + values[i] = i * 1_000_000L; + } + roundTripLong(values, 8); + } + + // ========== Writer Reset/Reuse ========== + + @Test + public void testIntWriterReset() throws Exception { + PforValuesWriter.IntPforValuesWriter writer = new PforValuesWriter.IntPforValuesWriter( + 1024, 1024, new DirectByteBufferAllocator()); + + // First batch + for (int i = 0; i < 100; i++) { + writer.writeInteger(i); + } + BytesInput bytes1 = writer.getBytes(); + assertTrue(bytes1.size() > 0); + + // Reset and second batch + writer.reset(); + for (int i = 0; i < 50; i++) { + writer.writeInteger(i * 2); + } + BytesInput bytes2 = writer.getBytes(); + assertTrue(bytes2.size() > 0); + + // Verify second batch reads correctly + PforValuesReaderForInt reader = new PforValuesReaderForInt(); + reader.initFromPage(50, ByteBufferInputStream.wrap(bytes2.toByteBuffer())); + for (int i = 0; i < 50; i++) { + assertEquals(i * 2, reader.readInteger()); + } + + writer.close(); + } + + @Test + public void testLongWriterReset() throws Exception { + PforValuesWriter.LongPforValuesWriter writer = new PforValuesWriter.LongPforValuesWriter( + 1024, 1024, new DirectByteBufferAllocator()); + + for (int i = 0; i < 100; i++) { + writer.writeLong(i * 1000L); + } + writer.getBytes(); + + writer.reset(); + for (int i = 0; i < 50; i++) { + writer.writeLong(i * 2000L); + } + BytesInput bytes2 = writer.getBytes(); + + PforValuesReaderForLong reader = new PforValuesReaderForLong(); + reader.initFromPage(50, ByteBufferInputStream.wrap(bytes2.toByteBuffer())); + for (int i = 0; i < 50; i++) { + assertEquals(i * 2000L, reader.readLong()); + } + + writer.close(); + } + + // ========== Reader Skip Tests ========== + + @Test + public void testIntSkip() throws Exception { + int[] values = new int[2048]; + for (int i = 0; i < 2048; i++) { + values[i] = i; + } + + PforValuesWriter.IntPforValuesWriter writer = new PforValuesWriter.IntPforValuesWriter( + 4096, 4096, new DirectByteBufferAllocator()); + for (int v : values) { + writer.writeInteger(v); + } + BytesInput bytes = writer.getBytes(); + + PforValuesReaderForInt reader = new PforValuesReaderForInt(); + reader.initFromPage(2048, ByteBufferInputStream.wrap(bytes.toByteBuffer())); + + // Skip first 1000 + reader.skip(1000); + assertEquals(1000, reader.readInteger()); + assertEquals(1001, reader.readInteger()); + + // Skip more + reader.skip(500); + assertEquals(1502, reader.readInteger()); + + writer.close(); + } + + @Test + public void testLongSkip() throws Exception { + long[] values = new long[2048]; + for (int i = 0; i < 2048; i++) { + values[i] = i * 100L; + } + + PforValuesWriter.LongPforValuesWriter writer = new PforValuesWriter.LongPforValuesWriter( + 4096, 4096, new DirectByteBufferAllocator()); + for (long v : values) { + writer.writeLong(v); + } + BytesInput bytes = writer.getBytes(); + + PforValuesReaderForLong reader = new PforValuesReaderForLong(); + reader.initFromPage(2048, ByteBufferInputStream.wrap(bytes.toByteBuffer())); + + reader.skip(1000); + assertEquals(100000L, reader.readLong()); + + writer.close(); + } + + // ========== Empty Input ========== + + @Test + public void testIntEmptyInput() throws Exception { + PforValuesWriter.IntPforValuesWriter writer = new PforValuesWriter.IntPforValuesWriter( + 256, 256, new DirectByteBufferAllocator()); + BytesInput bytes = writer.getBytes(); + assertEquals(0, bytes.size()); + writer.close(); + } + + @Test + public void testLongEmptyInput() throws Exception { + PforValuesWriter.LongPforValuesWriter writer = new PforValuesWriter.LongPforValuesWriter( + 256, 256, new DirectByteBufferAllocator()); + BytesInput bytes = writer.getBytes(); + assertEquals(0, bytes.size()); + writer.close(); + } +} From 9ab7651921e5144eae5c04fd5089ad2b0375a99c Mon Sep 17 00:00:00 2001 From: Prateek Gaur Date: Tue, 21 Apr 2026 00:40:12 +0000 Subject: [PATCH 04/14] Add PFOR encoding benchmark Benchmarks encode/decode throughput for int32/int64 across 8 data distributions inspired by Snowflake's NumericComprBenchmark: constant, sequential, small range, high-base-small-range (timestamps), with outliers (exception path), random, TPC-DS date keys, TPC-DS quantity. Uses junit-benchmarks (matches existing delta encoding benchmarks). Prints compression ratios for all distributions during setup. Excluded from normal test runs by surefire's benchmark exclusion. --- .../pfor/benchmark/BenchmarkPforEncoding.java | 540 ++++++++++++++++++ 1 file changed, 540 insertions(+) create mode 100644 parquet-column/src/test/java/org/apache/parquet/column/values/pfor/benchmark/BenchmarkPforEncoding.java diff --git a/parquet-column/src/test/java/org/apache/parquet/column/values/pfor/benchmark/BenchmarkPforEncoding.java b/parquet-column/src/test/java/org/apache/parquet/column/values/pfor/benchmark/BenchmarkPforEncoding.java new file mode 100644 index 0000000000..31312ec914 --- /dev/null +++ b/parquet-column/src/test/java/org/apache/parquet/column/values/pfor/benchmark/BenchmarkPforEncoding.java @@ -0,0 +1,540 @@ +/* + * 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.pfor.benchmark; + +import com.carrotsearch.junitbenchmarks.BenchmarkOptions; +import com.carrotsearch.junitbenchmarks.BenchmarkRule; +import com.carrotsearch.junitbenchmarks.annotation.AxisRange; +import com.carrotsearch.junitbenchmarks.annotation.BenchmarkMethodChart; +import java.io.IOException; +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.values.pfor.PforValuesReaderForInt; +import org.apache.parquet.column.values.pfor.PforValuesReaderForLong; +import org.apache.parquet.column.values.pfor.PforValuesWriter; +import org.junit.BeforeClass; +import org.junit.Rule; +import org.junit.Test; + +/** + * PFOR encoding/decoding benchmarks for INT32 and INT64. + * + *

Data distributions are inspired by Snowflake's NumericComprBenchmark.cpp, + * covering key archetypes that exercise PFOR's cost model differently: + *

+ * + *

Excluded from normal test runs via surefire's benchmark exclusion pattern. + */ +@AxisRange(min = 0, max = 1) +@BenchmarkMethodChart(filePrefix = "benchmark-pfor-encoding") +public class BenchmarkPforEncoding { + + private static final int NUM_VALUES = 500_000; + + @Rule + public org.junit.rules.TestRule benchmarkRun = new BenchmarkRule(); + + // ========== Pre-computed data ========== + private static int[] intConstant; + private static int[] intSequential; + private static int[] intSmallRange; + private static int[] intHighBaseSmallRange; + private static int[] intWithOutliers; + private static int[] intRandom; + private static int[] intTpcdsSoldDateSk; + private static int[] intTpcdsQuantity; + + private static long[] longConstant; + private static long[] longSequential; + private static long[] longSmallRange; + private static long[] longHighBaseSmallRange; + private static long[] longWithOutliers; + private static long[] longRandom; + private static long[] longTpcdsSoldDateSk; + + // Pre-encoded bytes for decode benchmarks + private static byte[] intConstantBytes; + private static byte[] intSequentialBytes; + private static byte[] intSmallRangeBytes; + private static byte[] intHighBaseSmallRangeBytes; + private static byte[] intWithOutliersBytes; + private static byte[] intRandomBytes; + private static byte[] intTpcdsSoldDateSkBytes; + private static byte[] intTpcdsQuantityBytes; + + private static byte[] longConstantBytes; + private static byte[] longSequentialBytes; + private static byte[] longSmallRangeBytes; + private static byte[] longHighBaseSmallRangeBytes; + private static byte[] longWithOutliersBytes; + private static byte[] longRandomBytes; + private static byte[] longTpcdsSoldDateSkBytes; + + @BeforeClass + public static void prepare() throws IOException { + // Generate INT32 distributions + intConstant = genIntConstant(NUM_VALUES); + intSequential = genIntSequential(NUM_VALUES); + intSmallRange = genIntSmallRange(NUM_VALUES); + intHighBaseSmallRange = genIntHighBaseSmallRange(NUM_VALUES); + intWithOutliers = genIntWithOutliers(NUM_VALUES); + intRandom = genIntRandom(NUM_VALUES); + intTpcdsSoldDateSk = genIntTpcdsSoldDateSk(NUM_VALUES); + intTpcdsQuantity = genIntTpcdsQuantity(NUM_VALUES); + + // Generate INT64 distributions + longConstant = genLongConstant(NUM_VALUES); + longSequential = genLongSequential(NUM_VALUES); + longSmallRange = genLongSmallRange(NUM_VALUES); + longHighBaseSmallRange = genLongHighBaseSmallRange(NUM_VALUES); + longWithOutliers = genLongWithOutliers(NUM_VALUES); + longRandom = genLongRandom(NUM_VALUES); + longTpcdsSoldDateSk = genLongTpcdsSoldDateSk(NUM_VALUES); + + // Pre-encode for decode benchmarks + intConstantBytes = encodeInts(intConstant); + intSequentialBytes = encodeInts(intSequential); + intSmallRangeBytes = encodeInts(intSmallRange); + intHighBaseSmallRangeBytes = encodeInts(intHighBaseSmallRange); + intWithOutliersBytes = encodeInts(intWithOutliers); + intRandomBytes = encodeInts(intRandom); + intTpcdsSoldDateSkBytes = encodeInts(intTpcdsSoldDateSk); + intTpcdsQuantityBytes = encodeInts(intTpcdsQuantity); + + longConstantBytes = encodeLongs(longConstant); + longSequentialBytes = encodeLongs(longSequential); + longSmallRangeBytes = encodeLongs(longSmallRange); + longHighBaseSmallRangeBytes = encodeLongs(longHighBaseSmallRange); + longWithOutliersBytes = encodeLongs(longWithOutliers); + longRandomBytes = encodeLongs(longRandom); + longTpcdsSoldDateSkBytes = encodeLongs(longTpcdsSoldDateSk); + + // Print compression ratios + System.out.println("=== PFOR Compression Ratios (compressed / uncompressed) ==="); + printIntRatio("Constant", intConstantBytes, intConstant.length); + printIntRatio("Sequential", intSequentialBytes, intSequential.length); + printIntRatio("SmallRange", intSmallRangeBytes, intSmallRange.length); + printIntRatio("HighBaseSmallRange", intHighBaseSmallRangeBytes, intHighBaseSmallRange.length); + printIntRatio("WithOutliers", intWithOutliersBytes, intWithOutliers.length); + printIntRatio("Random", intRandomBytes, intRandom.length); + printIntRatio("TpcdsSoldDateSk", intTpcdsSoldDateSkBytes, intTpcdsSoldDateSk.length); + printIntRatio("TpcdsQuantity", intTpcdsQuantityBytes, intTpcdsQuantity.length); + System.out.println("---"); + printLongRatio("Constant", longConstantBytes, longConstant.length); + printLongRatio("Sequential", longSequentialBytes, longSequential.length); + printLongRatio("SmallRange", longSmallRangeBytes, longSmallRange.length); + printLongRatio("HighBaseSmallRange", longHighBaseSmallRangeBytes, longHighBaseSmallRange.length); + printLongRatio("WithOutliers", longWithOutliersBytes, longWithOutliers.length); + printLongRatio("Random", longRandomBytes, longRandom.length); + printLongRatio("TpcdsSoldDateSk", longTpcdsSoldDateSkBytes, longTpcdsSoldDateSk.length); + } + + // ========== INT32 Encode Benchmarks ========== + + @BenchmarkOptions(benchmarkRounds = 20, warmupRounds = 4) + @Test + public void encodeIntConstant() throws IOException { + benchmarkIntEncode(intConstant); + } + + @BenchmarkOptions(benchmarkRounds = 20, warmupRounds = 4) + @Test + public void encodeIntSequential() throws IOException { + benchmarkIntEncode(intSequential); + } + + @BenchmarkOptions(benchmarkRounds = 20, warmupRounds = 4) + @Test + public void encodeIntSmallRange() throws IOException { + benchmarkIntEncode(intSmallRange); + } + + @BenchmarkOptions(benchmarkRounds = 20, warmupRounds = 4) + @Test + public void encodeIntHighBaseSmallRange() throws IOException { + benchmarkIntEncode(intHighBaseSmallRange); + } + + @BenchmarkOptions(benchmarkRounds = 20, warmupRounds = 4) + @Test + public void encodeIntWithOutliers() throws IOException { + benchmarkIntEncode(intWithOutliers); + } + + @BenchmarkOptions(benchmarkRounds = 20, warmupRounds = 4) + @Test + public void encodeIntRandom() throws IOException { + benchmarkIntEncode(intRandom); + } + + @BenchmarkOptions(benchmarkRounds = 20, warmupRounds = 4) + @Test + public void encodeIntTpcdsSoldDateSk() throws IOException { + benchmarkIntEncode(intTpcdsSoldDateSk); + } + + @BenchmarkOptions(benchmarkRounds = 20, warmupRounds = 4) + @Test + public void encodeIntTpcdsQuantity() throws IOException { + benchmarkIntEncode(intTpcdsQuantity); + } + + // ========== INT32 Decode Benchmarks ========== + + @BenchmarkOptions(benchmarkRounds = 20, warmupRounds = 4) + @Test + public void decodeIntConstant() throws IOException { + benchmarkIntDecode(intConstantBytes, intConstant.length); + } + + @BenchmarkOptions(benchmarkRounds = 20, warmupRounds = 4) + @Test + public void decodeIntSequential() throws IOException { + benchmarkIntDecode(intSequentialBytes, intSequential.length); + } + + @BenchmarkOptions(benchmarkRounds = 20, warmupRounds = 4) + @Test + public void decodeIntSmallRange() throws IOException { + benchmarkIntDecode(intSmallRangeBytes, intSmallRange.length); + } + + @BenchmarkOptions(benchmarkRounds = 20, warmupRounds = 4) + @Test + public void decodeIntHighBaseSmallRange() throws IOException { + benchmarkIntDecode(intHighBaseSmallRangeBytes, intHighBaseSmallRange.length); + } + + @BenchmarkOptions(benchmarkRounds = 20, warmupRounds = 4) + @Test + public void decodeIntWithOutliers() throws IOException { + benchmarkIntDecode(intWithOutliersBytes, intWithOutliers.length); + } + + @BenchmarkOptions(benchmarkRounds = 20, warmupRounds = 4) + @Test + public void decodeIntRandom() throws IOException { + benchmarkIntDecode(intRandomBytes, intRandom.length); + } + + @BenchmarkOptions(benchmarkRounds = 20, warmupRounds = 4) + @Test + public void decodeIntTpcdsSoldDateSk() throws IOException { + benchmarkIntDecode(intTpcdsSoldDateSkBytes, intTpcdsSoldDateSk.length); + } + + @BenchmarkOptions(benchmarkRounds = 20, warmupRounds = 4) + @Test + public void decodeIntTpcdsQuantity() throws IOException { + benchmarkIntDecode(intTpcdsQuantityBytes, intTpcdsQuantity.length); + } + + // ========== INT64 Encode Benchmarks ========== + + @BenchmarkOptions(benchmarkRounds = 20, warmupRounds = 4) + @Test + public void encodeLongConstant() throws IOException { + benchmarkLongEncode(longConstant); + } + + @BenchmarkOptions(benchmarkRounds = 20, warmupRounds = 4) + @Test + public void encodeLongSequential() throws IOException { + benchmarkLongEncode(longSequential); + } + + @BenchmarkOptions(benchmarkRounds = 20, warmupRounds = 4) + @Test + public void encodeLongSmallRange() throws IOException { + benchmarkLongEncode(longSmallRange); + } + + @BenchmarkOptions(benchmarkRounds = 20, warmupRounds = 4) + @Test + public void encodeLongHighBaseSmallRange() throws IOException { + benchmarkLongEncode(longHighBaseSmallRange); + } + + @BenchmarkOptions(benchmarkRounds = 20, warmupRounds = 4) + @Test + public void encodeLongWithOutliers() throws IOException { + benchmarkLongEncode(longWithOutliers); + } + + @BenchmarkOptions(benchmarkRounds = 20, warmupRounds = 4) + @Test + public void encodeLongRandom() throws IOException { + benchmarkLongEncode(longRandom); + } + + @BenchmarkOptions(benchmarkRounds = 20, warmupRounds = 4) + @Test + public void encodeLongTpcdsSoldDateSk() throws IOException { + benchmarkLongEncode(longTpcdsSoldDateSk); + } + + // ========== INT64 Decode Benchmarks ========== + + @BenchmarkOptions(benchmarkRounds = 20, warmupRounds = 4) + @Test + public void decodeLongConstant() throws IOException { + benchmarkLongDecode(longConstantBytes, longConstant.length); + } + + @BenchmarkOptions(benchmarkRounds = 20, warmupRounds = 4) + @Test + public void decodeLongSequential() throws IOException { + benchmarkLongDecode(longSequentialBytes, longSequential.length); + } + + @BenchmarkOptions(benchmarkRounds = 20, warmupRounds = 4) + @Test + public void decodeLongSmallRange() throws IOException { + benchmarkLongDecode(longSmallRangeBytes, longSmallRange.length); + } + + @BenchmarkOptions(benchmarkRounds = 20, warmupRounds = 4) + @Test + public void decodeLongHighBaseSmallRange() throws IOException { + benchmarkLongDecode(longHighBaseSmallRangeBytes, longHighBaseSmallRange.length); + } + + @BenchmarkOptions(benchmarkRounds = 20, warmupRounds = 4) + @Test + public void decodeLongWithOutliers() throws IOException { + benchmarkLongDecode(longWithOutliersBytes, longWithOutliers.length); + } + + @BenchmarkOptions(benchmarkRounds = 20, warmupRounds = 4) + @Test + public void decodeLongRandom() throws IOException { + benchmarkLongDecode(longRandomBytes, longRandom.length); + } + + @BenchmarkOptions(benchmarkRounds = 20, warmupRounds = 4) + @Test + public void decodeLongTpcdsSoldDateSk() throws IOException { + benchmarkLongDecode(longTpcdsSoldDateSkBytes, longTpcdsSoldDateSk.length); + } + + // ========== Benchmark Helpers ========== + + private void benchmarkIntEncode(int[] values) throws IOException { + int capacity = Math.max(256, values.length * 8); + PforValuesWriter.IntPforValuesWriter writer = + new PforValuesWriter.IntPforValuesWriter(capacity, capacity, new DirectByteBufferAllocator()); + for (int v : values) { + writer.writeInteger(v); + } + writer.getBytes(); + writer.close(); + } + + private void benchmarkIntDecode(byte[] encoded, int numValues) throws IOException { + PforValuesReaderForInt reader = new PforValuesReaderForInt(); + reader.initFromPage(numValues, + ByteBufferInputStream.wrap(java.nio.ByteBuffer.wrap(encoded))); + for (int i = 0; i < numValues; i++) { + reader.readInteger(); + } + } + + private void benchmarkLongEncode(long[] values) throws IOException { + int capacity = Math.max(512, values.length * 16); + PforValuesWriter.LongPforValuesWriter writer = + new PforValuesWriter.LongPforValuesWriter(capacity, capacity, new DirectByteBufferAllocator()); + for (long v : values) { + writer.writeLong(v); + } + writer.getBytes(); + writer.close(); + } + + private void benchmarkLongDecode(byte[] encoded, int numValues) throws IOException { + PforValuesReaderForLong reader = new PforValuesReaderForLong(); + reader.initFromPage(numValues, + ByteBufferInputStream.wrap(java.nio.ByteBuffer.wrap(encoded))); + for (int i = 0; i < numValues; i++) { + reader.readLong(); + } + } + + // ========== Data Generators ========== + + private static int[] genIntConstant(int n) { + int[] v = new int[n]; + java.util.Arrays.fill(v, 42); + return v; + } + + private static int[] genIntSequential(int n) { + int[] v = new int[n]; + for (int i = 0; i < n; i++) v[i] = i; + return v; + } + + private static int[] genIntSmallRange(int n) { + int[] v = new int[n]; + Random rng = new Random(12345); + for (int i = 0; i < n; i++) v[i] = 100000 + rng.nextInt(100001); + return v; + } + + private static int[] genIntHighBaseSmallRange(int n) { + int[] v = new int[n]; + Random rng = new Random(12345); + for (int i = 0; i < n; i++) v[i] = 1704067200 + rng.nextInt(1001); + return v; + } + + private static int[] genIntWithOutliers(int n) { + int[] v = new int[n]; + Random rng = new Random(42); + for (int i = 0; i < n; i++) v[i] = 1000 + rng.nextInt(256); + // ~1% outliers + int numOutliers = Math.max(1, n / 100); + for (int i = 0; i < numOutliers; i++) { + v[rng.nextInt(n)] = Integer.MAX_VALUE / 2 + i; + } + return v; + } + + private static int[] genIntRandom(int n) { + int[] v = new int[n]; + Random rng = new Random(99); + for (int i = 0; i < n; i++) v[i] = rng.nextInt(); + return v; + } + + private static int[] genIntTpcdsSoldDateSk(int n) { + int[] v = new int[n]; + Random rng = new Random(12345); + for (int i = 0; i < n; i++) v[i] = 2450815 + rng.nextInt(1821); + return v; + } + + private static int[] genIntTpcdsQuantity(int n) { + int[] v = new int[n]; + Random rng = new Random(12345); + for (int i = 0; i < n; i++) { + v[i] = (rng.nextInt(100) < 90) ? (1 + rng.nextInt(10)) : (11 + rng.nextInt(90)); + } + return v; + } + + private static long[] genLongConstant(int n) { + long[] v = new long[n]; + java.util.Arrays.fill(v, 42L); + return v; + } + + private static long[] genLongSequential(int n) { + long[] v = new long[n]; + for (int i = 0; i < n; i++) v[i] = i; + return v; + } + + private static long[] genLongSmallRange(int n) { + long[] v = new long[n]; + Random rng = new Random(12345); + for (int i = 0; i < n; i++) v[i] = 100000L + rng.nextInt(100001); + return v; + } + + private static long[] genLongHighBaseSmallRange(int n) { + long[] v = new long[n]; + Random rng = new Random(12345); + for (int i = 0; i < n; i++) v[i] = 1704067200L + rng.nextInt(1001); + return v; + } + + private static long[] genLongWithOutliers(int n) { + long[] v = new long[n]; + Random rng = new Random(42); + for (int i = 0; i < n; i++) v[i] = 1000L + rng.nextInt(256); + int numOutliers = Math.max(1, n / 100); + for (int i = 0; i < numOutliers; i++) { + v[rng.nextInt(n)] = Long.MAX_VALUE / 2 + i; + } + return v; + } + + private static long[] genLongRandom(int n) { + long[] v = new long[n]; + Random rng = new Random(99); + for (int i = 0; i < n; i++) v[i] = rng.nextLong(); + return v; + } + + private static long[] genLongTpcdsSoldDateSk(int n) { + long[] v = new long[n]; + Random rng = new Random(12345); + for (int i = 0; i < n; i++) v[i] = 2450815L + rng.nextInt(1821); + return v; + } + + // ========== Encoding Helpers ========== + + private static byte[] encodeInts(int[] values) throws IOException { + int capacity = Math.max(256, values.length * 8); + PforValuesWriter.IntPforValuesWriter writer = + new PforValuesWriter.IntPforValuesWriter(capacity, capacity, new DirectByteBufferAllocator()); + for (int v : values) { + writer.writeInteger(v); + } + byte[] result = writer.getBytes().toByteArray(); + writer.close(); + return result; + } + + private static byte[] encodeLongs(long[] values) throws IOException { + int capacity = Math.max(512, values.length * 16); + PforValuesWriter.LongPforValuesWriter writer = + new PforValuesWriter.LongPforValuesWriter(capacity, capacity, new DirectByteBufferAllocator()); + for (long v : values) { + writer.writeLong(v); + } + byte[] result = writer.getBytes().toByteArray(); + writer.close(); + return result; + } + + private static void printIntRatio(String name, byte[] encoded, int numValues) { + double ratio = 100.0 * encoded.length / (numValues * 4); + System.out.printf(" INT32 %-25s: %6d bytes -> %6d bytes (%.1f%%)\n", + name, numValues * 4, encoded.length, ratio); + } + + private static void printLongRatio(String name, byte[] encoded, int numValues) { + double ratio = 100.0 * encoded.length / (numValues * 8); + System.out.printf(" INT64 %-25s: %6d bytes -> %6d bytes (%.1f%%)\n", + name, numValues * 8, encoded.length, ratio); + } +} From c10fa78dbd8ab548f094d935edde6713ae49ccd8 Mon Sep 17 00:00:00 2001 From: Prateek Gaur Date: Wed, 3 Jun 2026 18:57:20 +0000 Subject: [PATCH 05/14] Reduce per-vector allocations and harden reader validation Writer: - Pre-allocate reusable buffers (deltasBuffer, excPosBuffer, excValBuffer, metadataBuf, packBuf, packPadBuf) in constructor instead of allocating new arrays on every encodeAndFlushVector call - Replace ByteBuffer.allocate().order(LITTLE_ENDIAN) with manual byte shifts into reusable metadataBuf for vector info and exception writes - Emit valid header for totalCount==0 (reader can distinguish empty page from missing encoding) instead of BytesInput.empty() Reader: - Add numElements > valuesCount validation (handles nullable columns where page row count > encoded values) - Move getShortLE/getIntLE/getLongLE from private static in concrete readers to protected static in PforValuesReader base class --- .../column/values/pfor/PforValuesReader.java | 26 +++ .../values/pfor/PforValuesReaderForInt.java | 10 - .../values/pfor/PforValuesReaderForLong.java | 14 -- .../column/values/pfor/PforValuesWriter.java | 184 +++++++++++------- 4 files changed, 137 insertions(+), 97 deletions(-) diff --git a/parquet-column/src/main/java/org/apache/parquet/column/values/pfor/PforValuesReader.java b/parquet-column/src/main/java/org/apache/parquet/column/values/pfor/PforValuesReader.java index 25275b8179..8f891235ad 100644 --- a/parquet-column/src/main/java/org/apache/parquet/column/values/pfor/PforValuesReader.java +++ b/parquet-column/src/main/java/org/apache/parquet/column/values/pfor/PforValuesReader.java @@ -83,6 +83,10 @@ public void initFromPage(int valuesCount, ByteBufferInputStream stream) if (numElements < 0) { throw new ParquetDecodingException("Invalid PFOR element count: " + numElements); } + if (numElements > valuesCount) { + throw new ParquetDecodingException( + "PFOR header element count " + numElements + " exceeds page valuesCount " + valuesCount); + } this.vectorSize = 1 << logVectorSize; this.totalCount = numElements; @@ -148,4 +152,26 @@ protected void ensureVectorDecoded() { protected abstract void allocateDecodedBuffer(int capacity); protected abstract void decodeVector(int vectorIdx); + + protected static int getShortLE(ByteBuffer buf, int pos) { + return (buf.get(pos) & 0xFF) | ((buf.get(pos + 1) & 0xFF) << 8); + } + + protected static int getIntLE(ByteBuffer buf, int pos) { + return (buf.get(pos) & 0xFF) + | ((buf.get(pos + 1) & 0xFF) << 8) + | ((buf.get(pos + 2) & 0xFF) << 16) + | ((buf.get(pos + 3) & 0xFF) << 24); + } + + protected static long getLongLE(ByteBuffer buf, int pos) { + return (buf.get(pos) & 0xFFL) + | ((buf.get(pos + 1) & 0xFFL) << 8) + | ((buf.get(pos + 2) & 0xFFL) << 16) + | ((buf.get(pos + 3) & 0xFFL) << 24) + | ((buf.get(pos + 4) & 0xFFL) << 32) + | ((buf.get(pos + 5) & 0xFFL) << 40) + | ((buf.get(pos + 6) & 0xFFL) << 48) + | ((buf.get(pos + 7) & 0xFFL) << 56); + } } diff --git a/parquet-column/src/main/java/org/apache/parquet/column/values/pfor/PforValuesReaderForInt.java b/parquet-column/src/main/java/org/apache/parquet/column/values/pfor/PforValuesReaderForInt.java index 420a188098..b5d9fec57e 100644 --- a/parquet-column/src/main/java/org/apache/parquet/column/values/pfor/PforValuesReaderForInt.java +++ b/parquet-column/src/main/java/org/apache/parquet/column/values/pfor/PforValuesReaderForInt.java @@ -129,14 +129,4 @@ private int unpackIntsWithBytePacker(ByteBuffer buf, int pos, int[] output, int return pos; } - private static int getShortLE(ByteBuffer buf, int pos) { - return (buf.get(pos) & 0xFF) | ((buf.get(pos + 1) & 0xFF) << 8); - } - - private static int getIntLE(ByteBuffer buf, int pos) { - return (buf.get(pos) & 0xFF) - | ((buf.get(pos + 1) & 0xFF) << 8) - | ((buf.get(pos + 2) & 0xFF) << 16) - | ((buf.get(pos + 3) & 0xFF) << 24); - } } diff --git a/parquet-column/src/main/java/org/apache/parquet/column/values/pfor/PforValuesReaderForLong.java b/parquet-column/src/main/java/org/apache/parquet/column/values/pfor/PforValuesReaderForLong.java index 91ac244369..3afbf0943b 100644 --- a/parquet-column/src/main/java/org/apache/parquet/column/values/pfor/PforValuesReaderForLong.java +++ b/parquet-column/src/main/java/org/apache/parquet/column/values/pfor/PforValuesReaderForLong.java @@ -130,18 +130,4 @@ private int unpackLongsWithBytePacker(ByteBuffer buf, int pos, long[] output, in return pos; } - private static int getShortLE(ByteBuffer buf, int pos) { - return (buf.get(pos) & 0xFF) | ((buf.get(pos + 1) & 0xFF) << 8); - } - - private static long getLongLE(ByteBuffer buf, int pos) { - return (buf.get(pos) & 0xFFL) - | ((buf.get(pos + 1) & 0xFFL) << 8) - | ((buf.get(pos + 2) & 0xFFL) << 16) - | ((buf.get(pos + 3) & 0xFFL) << 24) - | ((buf.get(pos + 4) & 0xFFL) << 32) - | ((buf.get(pos + 5) & 0xFFL) << 40) - | ((buf.get(pos + 6) & 0xFFL) << 48) - | ((buf.get(pos + 7) & 0xFFL) << 56); - } } diff --git a/parquet-column/src/main/java/org/apache/parquet/column/values/pfor/PforValuesWriter.java b/parquet-column/src/main/java/org/apache/parquet/column/values/pfor/PforValuesWriter.java index ab39301dad..8cc5bd5e6f 100644 --- a/parquet-column/src/main/java/org/apache/parquet/column/values/pfor/PforValuesWriter.java +++ b/parquet-column/src/main/java/org/apache/parquet/column/values/pfor/PforValuesWriter.java @@ -86,6 +86,14 @@ public static class IntPforValuesWriter extends PforValuesWriter { private CapacityByteArrayOutputStream encodedVectors; private final List vectorByteSizes; + // Reusable per-vector buffers to avoid allocations on every encodeAndFlushVector call + private final int[] deltasBuffer; + private final short[] excPosBuffer; + private final int[] excValBuffer; + private final byte[] metadataBuf; + private final byte[] packBuf; + private final int[] packPadBuf; + public IntPforValuesWriter(int initialCapacity, int pageSize, ByteBufferAllocator allocator) { this(initialCapacity, pageSize, allocator, DEFAULT_VECTOR_SIZE); } @@ -97,6 +105,12 @@ public IntPforValuesWriter(int initialCapacity, int pageSize, ByteBufferAllocato this.totalCount = 0; this.encodedVectors = new CapacityByteArrayOutputStream(initialCapacity, pageSize, allocator); this.vectorByteSizes = new ArrayList<>(); + this.deltasBuffer = new int[vectorSize]; + this.excPosBuffer = new short[vectorSize]; + this.excValBuffer = new int[vectorSize]; + this.metadataBuf = new byte[INT32_VECTOR_INFO_SIZE]; + this.packBuf = new byte[Integer.SIZE]; // max bit width for int = 32 bytes + this.packPadBuf = new int[8]; } @Override @@ -118,30 +132,26 @@ private void encodeAndFlushVector(int vectorLen) { } } - // Compute unsigned deltas - int[] deltas = new int[vectorLen]; + // Compute unsigned deltas into reusable buffer for (int i = 0; i < vectorLen; i++) { - deltas[i] = vectorBuffer[i] - minValue; + deltasBuffer[i] = vectorBuffer[i] - minValue; } // Find optimal bit width via cost model - PforEncoderDecoder.BitWidthResult result = PforEncoderDecoder.findOptimalBitWidthForInt(deltas, vectorLen); + PforEncoderDecoder.BitWidthResult result = PforEncoderDecoder.findOptimalBitWidthForInt(deltasBuffer, vectorLen); int bitWidth = result.bitWidth; int numExceptions = result.numExceptions; // Collect exceptions: values whose delta doesn't fit in bitWidth bits - short[] excPositions = new short[numExceptions]; - int[] excValues = new int[numExceptions]; int excIdx = 0; - if (numExceptions > 0) { int mask = (bitWidth == 32) ? -1 : (1 << bitWidth) - 1; for (int i = 0; i < vectorLen; i++) { - if (Integer.compareUnsigned(deltas[i], mask) > 0) { - excPositions[excIdx] = (short) i; - excValues[excIdx] = vectorBuffer[i]; // original value, not delta + if (Integer.compareUnsigned(deltasBuffer[i], mask) > 0) { + excPosBuffer[excIdx] = (short) i; + excValBuffer[excIdx] = vectorBuffer[i]; excIdx++; - deltas[i] = 0; // placeholder in packed data + deltasBuffer[i] = 0; } } } @@ -149,32 +159,37 @@ private void encodeAndFlushVector(int vectorLen) { long startSize = encodedVectors.size(); // PforVectorInfo: frame_of_reference(4) + bit_width(1) + num_exceptions(2) = 7B - ByteBuffer vectorInfo = ByteBuffer.allocate(INT32_VECTOR_INFO_SIZE).order(ByteOrder.LITTLE_ENDIAN); - vectorInfo.putInt(minValue); - vectorInfo.put((byte) bitWidth); - vectorInfo.putShort((short) numExceptions); - encodedVectors.write(vectorInfo.array(), 0, INT32_VECTOR_INFO_SIZE); + metadataBuf[0] = (byte) (minValue & 0xFF); + metadataBuf[1] = (byte) ((minValue >>> 8) & 0xFF); + metadataBuf[2] = (byte) ((minValue >>> 16) & 0xFF); + metadataBuf[3] = (byte) ((minValue >>> 24) & 0xFF); + metadataBuf[4] = (byte) bitWidth; + metadataBuf[5] = (byte) (numExceptions & 0xFF); + metadataBuf[6] = (byte) ((numExceptions >>> 8) & 0xFF); + encodedVectors.write(metadataBuf, 0, INT32_VECTOR_INFO_SIZE); // Pack deltas if (bitWidth > 0) { - packIntsWithBytePacker(deltas, vectorLen, bitWidth); + packIntsWithBytePacker(deltasBuffer, vectorLen, bitWidth); } // Exception positions then values if (numExceptions > 0) { - ByteBuffer excPosBuf = - ByteBuffer.allocate(numExceptions * Short.BYTES).order(ByteOrder.LITTLE_ENDIAN); for (int i = 0; i < numExceptions; i++) { - excPosBuf.putShort(excPositions[i]); + int pos = excPosBuffer[i] & 0xFFFF; + metadataBuf[0] = (byte) (pos & 0xFF); + metadataBuf[1] = (byte) ((pos >>> 8) & 0xFF); + encodedVectors.write(metadataBuf, 0, Short.BYTES); } - encodedVectors.write(excPosBuf.array(), 0, numExceptions * Short.BYTES); - ByteBuffer excValBuf = - ByteBuffer.allocate(numExceptions * Integer.BYTES).order(ByteOrder.LITTLE_ENDIAN); for (int i = 0; i < numExceptions; i++) { - excValBuf.putInt(excValues[i]); + int val = excValBuffer[i]; + metadataBuf[0] = (byte) (val & 0xFF); + metadataBuf[1] = (byte) ((val >>> 8) & 0xFF); + metadataBuf[2] = (byte) ((val >>> 16) & 0xFF); + metadataBuf[3] = (byte) ((val >>> 24) & 0xFF); + encodedVectors.write(metadataBuf, 0, Integer.BYTES); } - encodedVectors.write(excValBuf.array(), 0, numExceptions * Integer.BYTES); } vectorByteSizes.add((int) (encodedVectors.size() - startSize)); @@ -184,22 +199,21 @@ private void packIntsWithBytePacker(int[] values, int count, int bitWidth) { BytePacker packer = Packer.LITTLE_ENDIAN.newBytePacker(bitWidth); int numFullGroups = count / 8; int remaining = count % 8; - byte[] packed = new byte[bitWidth]; for (int g = 0; g < numFullGroups; g++) { - packer.pack8Values(values, g * 8, packed, 0); - encodedVectors.write(packed, 0, bitWidth); + packer.pack8Values(values, g * 8, packBuf, 0); + encodedVectors.write(packBuf, 0, bitWidth); } - // Partial last group: pack 8 values (zero-padded), but only write - // ceil(count * bitWidth / 8) - alreadyWritten bytes per spec. if (remaining > 0) { - int[] padded = new int[8]; - System.arraycopy(values, numFullGroups * 8, padded, 0, remaining); - packer.pack8Values(padded, 0, packed, 0); + System.arraycopy(values, numFullGroups * 8, packPadBuf, 0, remaining); + for (int i = remaining; i < 8; i++) { + packPadBuf[i] = 0; + } + packer.pack8Values(packPadBuf, 0, packBuf, 0); int totalPackedBytes = (count * bitWidth + 7) / 8; int alreadyWritten = numFullGroups * bitWidth; - encodedVectors.write(packed, 0, totalPackedBytes - alreadyWritten); + encodedVectors.write(packBuf, 0, totalPackedBytes - alreadyWritten); } } @@ -210,10 +224,6 @@ public long getBufferedSize() { @Override public BytesInput getBytes() { - if (totalCount == 0) { - return BytesInput.empty(); - } - if (bufferCount > 0) { encodeAndFlushVector(bufferCount); bufferCount = 0; @@ -228,6 +238,10 @@ public BytesInput getBytes() { header.put((byte) INT32_VALUE_BYTE_WIDTH); header.putInt(totalCount); + if (totalCount == 0) { + return BytesInput.from(header.array()); + } + int offsetArraySize = numVectors * Integer.BYTES; ByteBuffer offsets = ByteBuffer.allocate(offsetArraySize).order(ByteOrder.LITTLE_ENDIAN); int currentOffset = offsetArraySize; @@ -273,6 +287,14 @@ public static class LongPforValuesWriter extends PforValuesWriter { private CapacityByteArrayOutputStream encodedVectors; private final List vectorByteSizes; + // Reusable per-vector buffers + private final long[] deltasBuffer; + private final short[] excPosBuffer; + private final long[] excValBuffer; + private final byte[] metadataBuf; + private final byte[] packBuf; + private final long[] packPadBuf; + public LongPforValuesWriter(int initialCapacity, int pageSize, ByteBufferAllocator allocator) { this(initialCapacity, pageSize, allocator, DEFAULT_VECTOR_SIZE); } @@ -284,6 +306,12 @@ public LongPforValuesWriter(int initialCapacity, int pageSize, ByteBufferAllocat this.totalCount = 0; this.encodedVectors = new CapacityByteArrayOutputStream(initialCapacity, pageSize, allocator); this.vectorByteSizes = new ArrayList<>(); + this.deltasBuffer = new long[vectorSize]; + this.excPosBuffer = new short[vectorSize]; + this.excValBuffer = new long[vectorSize]; + this.metadataBuf = new byte[INT64_VECTOR_INFO_SIZE]; + this.packBuf = new byte[Long.SIZE]; // max bit width for long = 64 bytes + this.packPadBuf = new long[8]; } @Override @@ -304,27 +332,23 @@ private void encodeAndFlushVector(int vectorLen) { } } - long[] deltas = new long[vectorLen]; for (int i = 0; i < vectorLen; i++) { - deltas[i] = vectorBuffer[i] - minValue; + deltasBuffer[i] = vectorBuffer[i] - minValue; } - PforEncoderDecoder.BitWidthResult result = PforEncoderDecoder.findOptimalBitWidthForLong(deltas, vectorLen); + PforEncoderDecoder.BitWidthResult result = PforEncoderDecoder.findOptimalBitWidthForLong(deltasBuffer, vectorLen); int bitWidth = result.bitWidth; int numExceptions = result.numExceptions; - short[] excPositions = new short[numExceptions]; - long[] excValues = new long[numExceptions]; int excIdx = 0; - if (numExceptions > 0) { long mask = (bitWidth == 64) ? -1L : (1L << bitWidth) - 1L; for (int i = 0; i < vectorLen; i++) { - if (Long.compareUnsigned(deltas[i], mask) > 0) { - excPositions[excIdx] = (short) i; - excValues[excIdx] = vectorBuffer[i]; // original value + if (Long.compareUnsigned(deltasBuffer[i], mask) > 0) { + excPosBuffer[excIdx] = (short) i; + excValBuffer[excIdx] = vectorBuffer[i]; excIdx++; - deltas[i] = 0; // placeholder + deltasBuffer[i] = 0; } } } @@ -332,30 +356,43 @@ private void encodeAndFlushVector(int vectorLen) { long startSize = encodedVectors.size(); // PforVectorInfo: frame_of_reference(8) + bit_width(1) + num_exceptions(2) = 11B - ByteBuffer vectorInfo = ByteBuffer.allocate(INT64_VECTOR_INFO_SIZE).order(ByteOrder.LITTLE_ENDIAN); - vectorInfo.putLong(minValue); - vectorInfo.put((byte) bitWidth); - vectorInfo.putShort((short) numExceptions); - encodedVectors.write(vectorInfo.array(), 0, INT64_VECTOR_INFO_SIZE); + metadataBuf[0] = (byte) (minValue & 0xFF); + metadataBuf[1] = (byte) ((minValue >>> 8) & 0xFF); + metadataBuf[2] = (byte) ((minValue >>> 16) & 0xFF); + metadataBuf[3] = (byte) ((minValue >>> 24) & 0xFF); + metadataBuf[4] = (byte) ((minValue >>> 32) & 0xFF); + metadataBuf[5] = (byte) ((minValue >>> 40) & 0xFF); + metadataBuf[6] = (byte) ((minValue >>> 48) & 0xFF); + metadataBuf[7] = (byte) ((minValue >>> 56) & 0xFF); + metadataBuf[8] = (byte) bitWidth; + metadataBuf[9] = (byte) (numExceptions & 0xFF); + metadataBuf[10] = (byte) ((numExceptions >>> 8) & 0xFF); + encodedVectors.write(metadataBuf, 0, INT64_VECTOR_INFO_SIZE); if (bitWidth > 0) { - packLongsWithBytePacker(deltas, vectorLen, bitWidth); + packLongsWithBytePacker(deltasBuffer, vectorLen, bitWidth); } if (numExceptions > 0) { - ByteBuffer excPosBuf = - ByteBuffer.allocate(numExceptions * Short.BYTES).order(ByteOrder.LITTLE_ENDIAN); for (int i = 0; i < numExceptions; i++) { - excPosBuf.putShort(excPositions[i]); + int pos = excPosBuffer[i] & 0xFFFF; + metadataBuf[0] = (byte) (pos & 0xFF); + metadataBuf[1] = (byte) ((pos >>> 8) & 0xFF); + encodedVectors.write(metadataBuf, 0, Short.BYTES); } - encodedVectors.write(excPosBuf.array(), 0, numExceptions * Short.BYTES); - ByteBuffer excValBuf = - ByteBuffer.allocate(numExceptions * Long.BYTES).order(ByteOrder.LITTLE_ENDIAN); for (int i = 0; i < numExceptions; i++) { - excValBuf.putLong(excValues[i]); + long val = excValBuffer[i]; + metadataBuf[0] = (byte) (val & 0xFF); + metadataBuf[1] = (byte) ((val >>> 8) & 0xFF); + metadataBuf[2] = (byte) ((val >>> 16) & 0xFF); + metadataBuf[3] = (byte) ((val >>> 24) & 0xFF); + metadataBuf[4] = (byte) ((val >>> 32) & 0xFF); + metadataBuf[5] = (byte) ((val >>> 40) & 0xFF); + metadataBuf[6] = (byte) ((val >>> 48) & 0xFF); + metadataBuf[7] = (byte) ((val >>> 56) & 0xFF); + encodedVectors.write(metadataBuf, 0, Long.BYTES); } - encodedVectors.write(excValBuf.array(), 0, numExceptions * Long.BYTES); } vectorByteSizes.add((int) (encodedVectors.size() - startSize)); @@ -365,20 +402,21 @@ private void packLongsWithBytePacker(long[] values, int count, int bitWidth) { BytePackerForLong packer = Packer.LITTLE_ENDIAN.newBytePackerForLong(bitWidth); int numFullGroups = count / 8; int remaining = count % 8; - byte[] packed = new byte[bitWidth]; for (int g = 0; g < numFullGroups; g++) { - packer.pack8Values(values, g * 8, packed, 0); - encodedVectors.write(packed, 0, bitWidth); + packer.pack8Values(values, g * 8, packBuf, 0); + encodedVectors.write(packBuf, 0, bitWidth); } if (remaining > 0) { - long[] padded = new long[8]; - System.arraycopy(values, numFullGroups * 8, padded, 0, remaining); - packer.pack8Values(padded, 0, packed, 0); + System.arraycopy(values, numFullGroups * 8, packPadBuf, 0, remaining); + for (int i = remaining; i < 8; i++) { + packPadBuf[i] = 0; + } + packer.pack8Values(packPadBuf, 0, packBuf, 0); int totalPackedBytes = (count * bitWidth + 7) / 8; int alreadyWritten = numFullGroups * bitWidth; - encodedVectors.write(packed, 0, totalPackedBytes - alreadyWritten); + encodedVectors.write(packBuf, 0, totalPackedBytes - alreadyWritten); } } @@ -389,10 +427,6 @@ public long getBufferedSize() { @Override public BytesInput getBytes() { - if (totalCount == 0) { - return BytesInput.empty(); - } - if (bufferCount > 0) { encodeAndFlushVector(bufferCount); bufferCount = 0; @@ -406,6 +440,10 @@ public BytesInput getBytes() { header.put((byte) INT64_VALUE_BYTE_WIDTH); header.putInt(totalCount); + if (totalCount == 0) { + return BytesInput.from(header.array()); + } + int offsetArraySize = numVectors * Integer.BYTES; ByteBuffer offsets = ByteBuffer.allocate(offsetArraySize).order(ByteOrder.LITTLE_ENDIAN); int currentOffset = offsetArraySize; From 1aa3aa4f5ec3f21079e555acc9542932e3b9b289 Mon Sep 17 00:00:00 2001 From: Prateek Gaur Date: Wed, 3 Jun 2026 18:58:37 +0000 Subject: [PATCH 06/14] Add adversarial tests for PFOR reader validation Tests cover: - Bad packing mode, log vector size out of range, bad value byte width - Negative num_elements, numElements > valuesCount - Header-only page, truncated offset array, truncated vector data - Corrupted offset pointing past buffer end - Skip past end, negative skip, read past end - Skip across vector boundaries (correctness check) --- .../values/pfor/PforAdversarialTest.java | 336 ++++++++++++++++++ 1 file changed, 336 insertions(+) create mode 100644 parquet-column/src/test/java/org/apache/parquet/column/values/pfor/PforAdversarialTest.java diff --git a/parquet-column/src/test/java/org/apache/parquet/column/values/pfor/PforAdversarialTest.java b/parquet-column/src/test/java/org/apache/parquet/column/values/pfor/PforAdversarialTest.java new file mode 100644 index 0000000000..15d3cc499e --- /dev/null +++ b/parquet-column/src/test/java/org/apache/parquet/column/values/pfor/PforAdversarialTest.java @@ -0,0 +1,336 @@ +/* + * 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.pfor; + +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; + +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import org.apache.parquet.bytes.ByteBufferInputStream; +import org.apache.parquet.bytes.BytesInput; +import org.apache.parquet.bytes.DirectByteBufferAllocator; +import org.apache.parquet.io.ParquetDecodingException; +import org.junit.Test; + +/** + * Adversarial tests for PFOR readers: feed malformed page bytes and assert the reader + * fails cleanly rather than crashing, producing silent garbage, or hanging. + * + *

Covers both explicitly-validated cases (ParquetDecodingException with message) + * and currently-unvalidated cases (IndexOutOfBoundsException or BufferUnderflowException + * from the underlying ByteBuffer). + */ +public class PforAdversarialTest { + + private static final int VECTOR_SIZE = PforConstants.DEFAULT_VECTOR_SIZE; + + // --------------------------------------------------------------------------- + // Helpers + // --------------------------------------------------------------------------- + + private static byte[] validIntPage(int valueCount, int vectorSize) throws Exception { + PforValuesWriter.IntPforValuesWriter writer = null; + try { + int cap = Math.max(512, valueCount * 8); + writer = new PforValuesWriter.IntPforValuesWriter( + cap, cap, new DirectByteBufferAllocator(), vectorSize); + for (int i = 0; i < valueCount; i++) { + writer.writeInteger(i * 7 + 3); + } + BytesInput bi = writer.getBytes(); + ByteBuffer bb = bi.toByteBuffer(); + byte[] out = new byte[bb.remaining()]; + bb.duplicate().get(out); + return out; + } finally { + if (writer != null) { + writer.reset(); + writer.close(); + } + } + } + + private static byte[] validLongPage(int valueCount, int vectorSize) throws Exception { + PforValuesWriter.LongPforValuesWriter writer = null; + try { + int cap = Math.max(512, valueCount * 16); + writer = new PforValuesWriter.LongPforValuesWriter( + cap, cap, new DirectByteBufferAllocator(), vectorSize); + for (int i = 0; i < valueCount; i++) { + writer.writeLong((long) i * 13 + 5); + } + BytesInput bi = writer.getBytes(); + ByteBuffer bb = bi.toByteBuffer(); + byte[] out = new byte[bb.remaining()]; + bb.duplicate().get(out); + return out; + } finally { + if (writer != null) { + writer.reset(); + writer.close(); + } + } + } + + private static byte[] mutate(byte[] original, int offset, byte value) { + byte[] copy = original.clone(); + copy[offset] = value; + return copy; + } + + private static byte[] truncate(byte[] original, int newLen) { + byte[] copy = new byte[newLen]; + System.arraycopy(original, 0, copy, 0, newLen); + return copy; + } + + private static void initIntReader(byte[] page, int valuesCount) throws Exception { + PforValuesReaderForInt reader = new PforValuesReaderForInt(); + reader.initFromPage(valuesCount, ByteBufferInputStream.wrap(ByteBuffer.wrap(page))); + reader.readInteger(); + } + + private static void initLongReader(byte[] page, int valuesCount) throws Exception { + PforValuesReaderForLong reader = new PforValuesReaderForLong(); + reader.initFromPage(valuesCount, ByteBufferInputStream.wrap(ByteBuffer.wrap(page))); + reader.readLong(); + } + + // --------------------------------------------------------------------------- + // Sanity: valid pages decode cleanly + // --------------------------------------------------------------------------- + + @Test + public void sanityBaselineDecodesClean() throws Exception { + byte[] page = validIntPage(2048, VECTOR_SIZE); + PforValuesReaderForInt reader = new PforValuesReaderForInt(); + reader.initFromPage(2048, ByteBufferInputStream.wrap(ByteBuffer.wrap(page))); + for (int i = 0; i < 2048; i++) { + reader.readInteger(); + } + } + + @Test + public void sanityBaselineLongDecodesClean() throws Exception { + byte[] page = validLongPage(2048, VECTOR_SIZE); + PforValuesReaderForLong reader = new PforValuesReaderForLong(); + reader.initFromPage(2048, ByteBufferInputStream.wrap(ByteBuffer.wrap(page))); + for (int i = 0; i < 2048; i++) { + reader.readLong(); + } + } + + // --------------------------------------------------------------------------- + // Header validation + // --------------------------------------------------------------------------- + + @Test + public void rejectsBadPackingMode() throws Exception { + byte[] page = validIntPage(1024, VECTOR_SIZE); + byte[] bad = mutate(page, 0, (byte) 99); + assertThrows(ParquetDecodingException.class, () -> initIntReader(bad, 1024)); + } + + @Test + public void rejectsLogVectorSizeTooLarge() throws Exception { + byte[] page = validIntPage(1024, VECTOR_SIZE); + byte[] bad = mutate(page, 1, (byte) 16); // MAX_LOG_VECTOR_SIZE is 15 + assertThrows(ParquetDecodingException.class, () -> initIntReader(bad, 1024)); + } + + @Test + public void rejectsLogVectorSizeTooSmall() throws Exception { + byte[] page = validIntPage(1024, VECTOR_SIZE); + byte[] bad = mutate(page, 1, (byte) 2); // MIN_LOG_VECTOR_SIZE is 3 + assertThrows(ParquetDecodingException.class, () -> initIntReader(bad, 1024)); + } + + @Test + public void rejectsBadValueByteWidth() throws Exception { + byte[] page = validIntPage(1024, VECTOR_SIZE); + byte[] bad = mutate(page, 2, (byte) 3); // must be 4 or 8 + assertThrows(ParquetDecodingException.class, () -> initIntReader(bad, 1024)); + } + + @Test + public void rejectsNegativeNumElements() throws Exception { + byte[] page = validIntPage(1024, VECTOR_SIZE); + // Overwrite num_elements (bytes 3-6) with -1 (0xFFFFFFFF) + byte[] bad = page.clone(); + bad[3] = (byte) 0xFF; + bad[4] = (byte) 0xFF; + bad[5] = (byte) 0xFF; + bad[6] = (byte) 0xFF; + assertThrows(ParquetDecodingException.class, () -> initIntReader(bad, 1024)); + } + + @Test + public void rejectsNumElementsGreaterThanValuesCount() throws Exception { + byte[] page = validIntPage(1024, VECTOR_SIZE); + // page header says 1024 elements but we pass valuesCount=500 + assertThrows(ParquetDecodingException.class, () -> initIntReader(page, 500)); + } + + // --------------------------------------------------------------------------- + // Truncation / corruption + // --------------------------------------------------------------------------- + + @Test + public void rejectsHeaderOnlyPage() { + byte[] page = new byte[PforConstants.PFOR_HEADER_SIZE]; + page[0] = (byte) PforConstants.PFOR_PACKING_MODE_FOR; + page[1] = (byte) PforConstants.DEFAULT_VECTOR_SIZE_LOG; + page[2] = (byte) PforConstants.INT32_VALUE_BYTE_WIDTH; + // num_elements = 100 in LE + page[3] = 100; + page[4] = 0; + page[5] = 0; + page[6] = 0; + + try { + initIntReader(page, 100); + fail("Expected exception for header-only page"); + } catch (Throwable t) { + assertNotNull(t); + } + } + + @Test + public void rejectsPageTruncatedMidOffsetArray() throws Exception { + byte[] page = validIntPage(2048, VECTOR_SIZE); + // Truncate inside the offset array (header=7 + partial offsets) + byte[] bad = truncate(page, PforConstants.PFOR_HEADER_SIZE + 2); + try { + initIntReader(bad, 2048); + fail("Expected exception for page truncated mid offset array"); + } catch (Throwable t) { + assertNotNull(t); + } + } + + @Test + public void rejectsPageTruncatedMidVectorData() throws Exception { + byte[] page = validIntPage(2048, VECTOR_SIZE); + // Keep header + offset array but truncate vector data + int offsetArrayEnd = PforConstants.PFOR_HEADER_SIZE + 2 * Integer.BYTES; + byte[] bad = truncate(page, offsetArrayEnd + 3); + try { + initIntReader(bad, 2048); + fail("Expected exception for page truncated mid vector data"); + } catch (Throwable t) { + assertNotNull(t); + } + } + + @Test + public void rejectsCorruptedOffsetPointingPastEnd() throws Exception { + byte[] page = validIntPage(1024, VECTOR_SIZE); + // The offset array starts at byte 7. Overwrite first offset to point past buffer end. + byte[] bad = page.clone(); + int hugeOffset = page.length * 2; + bad[7] = (byte) (hugeOffset & 0xFF); + bad[8] = (byte) ((hugeOffset >>> 8) & 0xFF); + bad[9] = (byte) ((hugeOffset >>> 16) & 0xFF); + bad[10] = (byte) ((hugeOffset >>> 24) & 0xFF); + try { + initIntReader(bad, 1024); + fail("Expected exception for corrupted offset"); + } catch (Throwable t) { + assertNotNull(t); + } + } + + // --------------------------------------------------------------------------- + // Skip/read bounds + // --------------------------------------------------------------------------- + + @Test + public void rejectsSkipPastEnd() throws Exception { + byte[] page = validIntPage(100, 8); + PforValuesReaderForInt reader = new PforValuesReaderForInt(); + reader.initFromPage(100, ByteBufferInputStream.wrap(ByteBuffer.wrap(page))); + assertThrows(ParquetDecodingException.class, () -> reader.skip(101)); + } + + @Test + public void rejectsNegativeSkip() throws Exception { + byte[] page = validIntPage(100, 8); + PforValuesReaderForInt reader = new PforValuesReaderForInt(); + reader.initFromPage(100, ByteBufferInputStream.wrap(ByteBuffer.wrap(page))); + assertThrows(ParquetDecodingException.class, () -> reader.skip(-1)); + } + + @Test + public void rejectsReadPastEnd() throws Exception { + byte[] page = validIntPage(10, 8); + PforValuesReaderForInt reader = new PforValuesReaderForInt(); + reader.initFromPage(10, ByteBufferInputStream.wrap(ByteBuffer.wrap(page))); + for (int i = 0; i < 10; i++) { + reader.readInteger(); + } + assertThrows(ParquetDecodingException.class, reader::readInteger); + } + + @Test + public void rejectsLongReadPastEnd() throws Exception { + byte[] page = validLongPage(10, 8); + PforValuesReaderForLong reader = new PforValuesReaderForLong(); + reader.initFromPage(10, ByteBufferInputStream.wrap(ByteBuffer.wrap(page))); + for (int i = 0; i < 10; i++) { + reader.readLong(); + } + assertThrows(ParquetDecodingException.class, reader::readLong); + } + + // --------------------------------------------------------------------------- + // Skip across vector boundaries works correctly + // --------------------------------------------------------------------------- + + @Test + public void skipAcrossVectorBoundary() throws Exception { + int vectorSize = 8; + int count = 30; + byte[] page = validIntPage(count, vectorSize); + PforValuesReaderForInt reader = new PforValuesReaderForInt(); + reader.initFromPage(count, ByteBufferInputStream.wrap(ByteBuffer.wrap(page))); + + // Skip past first two vectors (16 values), read from third + reader.skip(16); + int val = reader.readInteger(); + // Expected: 16 * 7 + 3 = 115 + assertTrue("Value after skip should be 115, got: " + val, val == 115); + } + + @Test + public void skipAcrossVectorBoundaryLong() throws Exception { + int vectorSize = 8; + int count = 30; + byte[] page = validLongPage(count, vectorSize); + PforValuesReaderForLong reader = new PforValuesReaderForLong(); + reader.initFromPage(count, ByteBufferInputStream.wrap(ByteBuffer.wrap(page))); + + reader.skip(16); + long val = reader.readLong(); + // Expected: 16 * 13 + 5 = 213 + assertTrue("Value after skip should be 213, got: " + val, val == 213L); + } +} From c14371d7adceba8c2067151c5dc0384eebb8a6fe Mon Sep 17 00:00:00 2001 From: Prateek Gaur Date: Wed, 3 Jun 2026 19:04:06 +0000 Subject: [PATCH 07/14] Eliminate per-vector allocations in PFOR readers Pre-allocate reusable decode buffers (deltasBuffer, excPositionsBuffer, unpackPadBuf, unpackTempBuf) in allocateDecodedBuffer instead of allocating new arrays on every decodeVector call. Mirrors the writer-side improvement from the previous commit. --- .../values/pfor/PforValuesReaderForInt.java | 38 +++++++++++------- .../values/pfor/PforValuesReaderForLong.java | 39 ++++++++++++------- 2 files changed, 50 insertions(+), 27 deletions(-) diff --git a/parquet-column/src/main/java/org/apache/parquet/column/values/pfor/PforValuesReaderForInt.java b/parquet-column/src/main/java/org/apache/parquet/column/values/pfor/PforValuesReaderForInt.java index b5d9fec57e..d91d421a1d 100644 --- a/parquet-column/src/main/java/org/apache/parquet/column/values/pfor/PforValuesReaderForInt.java +++ b/parquet-column/src/main/java/org/apache/parquet/column/values/pfor/PforValuesReaderForInt.java @@ -43,6 +43,12 @@ public class PforValuesReaderForInt extends PforValuesReader { private int[] decodedValues; + // Reusable per-vector decode buffers + private int[] deltasBuffer; + private int[] excPositionsBuffer; + private byte[] unpackPadBuf; + private int[] unpackTempBuf; + public PforValuesReaderForInt() { super(); } @@ -50,6 +56,10 @@ public PforValuesReaderForInt() { @Override protected void allocateDecodedBuffer(int capacity) { this.decodedValues = new int[capacity]; + this.deltasBuffer = new int[capacity]; + this.excPositionsBuffer = new int[capacity]; + this.unpackPadBuf = new byte[Integer.SIZE]; // max bit width = 32 bytes + this.unpackTempBuf = new int[8]; } @Override @@ -74,32 +84,33 @@ protected void decodeVector(int vectorIdx) { int numExceptions = getShortLE(vectorsData, pos + 5) & 0xFFFF; pos += INT32_VECTOR_INFO_SIZE; - // Unpack bit-packed deltas - int[] deltas = new int[vectorLen]; + // Unpack bit-packed deltas into reusable buffer if (bitWidth > 0) { - pos = unpackIntsWithBytePacker(vectorsData, pos, deltas, vectorLen, bitWidth); + pos = unpackIntsWithBytePacker(vectorsData, pos, deltasBuffer, vectorLen, bitWidth); + } else { + for (int i = 0; i < vectorLen; i++) { + deltasBuffer[i] = 0; + } } // Add frame of reference to reconstruct values for (int i = 0; i < vectorLen; i++) { - decodedValues[i] = deltas[i] + frameOfReference; + decodedValues[i] = deltasBuffer[i] + frameOfReference; } // Overwrite exception slots with their original values if (numExceptions > 0) { - int[] excPositions = new int[numExceptions]; for (int e = 0; e < numExceptions; e++) { - excPositions[e] = getShortLE(vectorsData, pos) & 0xFFFF; + excPositionsBuffer[e] = getShortLE(vectorsData, pos) & 0xFFFF; pos += Short.BYTES; } for (int e = 0; e < numExceptions; e++) { - decodedValues[excPositions[e]] = getIntLE(vectorsData, pos); + decodedValues[excPositionsBuffer[e]] = getIntLE(vectorsData, pos); pos += Integer.BYTES; } } } - /** Unpack bit-packed ints in groups of 8, returns position after packed data. */ private int unpackIntsWithBytePacker(ByteBuffer buf, int pos, int[] output, int count, int bitWidth) { BytePacker packer = Packer.LITTLE_ENDIAN.newBytePacker(bitWidth); int numFullGroups = count / 8; @@ -115,14 +126,15 @@ private int unpackIntsWithBytePacker(ByteBuffer buf, int pos, int[] output, int int alreadyRead = numFullGroups * bitWidth; int partialBytes = totalPackedBytes - alreadyRead; - byte[] padded = new byte[bitWidth]; for (int i = 0; i < partialBytes; i++) { - padded[i] = buf.get(pos + i); + unpackPadBuf[i] = buf.get(pos + i); + } + for (int i = partialBytes; i < bitWidth; i++) { + unpackPadBuf[i] = 0; } - int[] temp = new int[8]; - packer.unpack8Values(padded, 0, temp, 0); - System.arraycopy(temp, 0, output, numFullGroups * 8, remaining); + packer.unpack8Values(unpackPadBuf, 0, unpackTempBuf, 0); + System.arraycopy(unpackTempBuf, 0, output, numFullGroups * 8, remaining); pos += partialBytes; } diff --git a/parquet-column/src/main/java/org/apache/parquet/column/values/pfor/PforValuesReaderForLong.java b/parquet-column/src/main/java/org/apache/parquet/column/values/pfor/PforValuesReaderForLong.java index 3afbf0943b..e8c8fe4f1e 100644 --- a/parquet-column/src/main/java/org/apache/parquet/column/values/pfor/PforValuesReaderForLong.java +++ b/parquet-column/src/main/java/org/apache/parquet/column/values/pfor/PforValuesReaderForLong.java @@ -43,6 +43,12 @@ public class PforValuesReaderForLong extends PforValuesReader { private long[] decodedValues; + // Reusable per-vector decode buffers + private long[] deltasBuffer; + private int[] excPositionsBuffer; + private byte[] unpackPadBuf; + private long[] unpackTempBuf; + public PforValuesReaderForLong() { super(); } @@ -50,6 +56,10 @@ public PforValuesReaderForLong() { @Override protected void allocateDecodedBuffer(int capacity) { this.decodedValues = new long[capacity]; + this.deltasBuffer = new long[capacity]; + this.excPositionsBuffer = new int[capacity]; + this.unpackPadBuf = new byte[Long.SIZE]; // max bit width = 64 bytes + this.unpackTempBuf = new long[8]; } @Override @@ -74,26 +84,28 @@ protected void decodeVector(int vectorIdx) { int numExceptions = getShortLE(vectorsData, pos + 9) & 0xFFFF; pos += INT64_VECTOR_INFO_SIZE; - // Unpack bit-packed deltas - long[] deltas = new long[vectorLen]; + // Unpack bit-packed deltas into reusable buffer if (bitWidth > 0) { - pos = unpackLongsWithBytePacker(vectorsData, pos, deltas, vectorLen, bitWidth); + pos = unpackLongsWithBytePacker(vectorsData, pos, deltasBuffer, vectorLen, bitWidth); + } else { + for (int i = 0; i < vectorLen; i++) { + deltasBuffer[i] = 0; + } } // Add frame of reference to reconstruct values for (int i = 0; i < vectorLen; i++) { - decodedValues[i] = deltas[i] + frameOfReference; + decodedValues[i] = deltasBuffer[i] + frameOfReference; } // Overwrite exception slots with their original values if (numExceptions > 0) { - int[] excPositions = new int[numExceptions]; for (int e = 0; e < numExceptions; e++) { - excPositions[e] = getShortLE(vectorsData, pos) & 0xFFFF; + excPositionsBuffer[e] = getShortLE(vectorsData, pos) & 0xFFFF; pos += Short.BYTES; } for (int e = 0; e < numExceptions; e++) { - decodedValues[excPositions[e]] = getLongLE(vectorsData, pos); + decodedValues[excPositionsBuffer[e]] = getLongLE(vectorsData, pos); pos += Long.BYTES; } } @@ -109,21 +121,20 @@ private int unpackLongsWithBytePacker(ByteBuffer buf, int pos, long[] output, in pos += bitWidth; } - // Last group might have fewer than 8 values; zero-pad and unpack, - // but only advance pos by the actual bytes in the page. if (remaining > 0) { int totalPackedBytes = (count * bitWidth + 7) / 8; int alreadyRead = numFullGroups * bitWidth; int partialBytes = totalPackedBytes - alreadyRead; - byte[] padded = new byte[bitWidth]; for (int i = 0; i < partialBytes; i++) { - padded[i] = buf.get(pos + i); + unpackPadBuf[i] = buf.get(pos + i); + } + for (int i = partialBytes; i < bitWidth; i++) { + unpackPadBuf[i] = 0; } - long[] temp = new long[8]; - packer.unpack8Values(padded, 0, temp, 0); - System.arraycopy(temp, 0, output, numFullGroups * 8, remaining); + packer.unpack8Values(unpackPadBuf, 0, unpackTempBuf, 0); + System.arraycopy(unpackTempBuf, 0, output, numFullGroups * 8, remaining); pos += partialBytes; } From 680b9a9f26c0f4de2dcbcc48aae2a5cf3aa4c471 Mon Sep 17 00:00:00 2001 From: Prateek Gaur Date: Wed, 3 Jun 2026 19:36:51 +0000 Subject: [PATCH 08/14] Update empty-input tests for header-always-emitted behavior getBytes() now emits a valid 7-byte header even when totalCount==0, so the reader can distinguish an empty PFOR page from a missing encoding. Update assertions from size==0 to size==PFOR_HEADER_SIZE. --- .../parquet/column/values/pfor/PforValuesEndToEndTest.java | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/parquet-column/src/test/java/org/apache/parquet/column/values/pfor/PforValuesEndToEndTest.java b/parquet-column/src/test/java/org/apache/parquet/column/values/pfor/PforValuesEndToEndTest.java index d1c9aa80fd..d1fa34a90c 100644 --- a/parquet-column/src/test/java/org/apache/parquet/column/values/pfor/PforValuesEndToEndTest.java +++ b/parquet-column/src/test/java/org/apache/parquet/column/values/pfor/PforValuesEndToEndTest.java @@ -431,7 +431,8 @@ public void testIntEmptyInput() throws Exception { PforValuesWriter.IntPforValuesWriter writer = new PforValuesWriter.IntPforValuesWriter( 256, 256, new DirectByteBufferAllocator()); BytesInput bytes = writer.getBytes(); - assertEquals(0, bytes.size()); + // Empty page still emits a valid 7-byte header (numElements=0) + assertEquals(PforConstants.PFOR_HEADER_SIZE, bytes.size()); writer.close(); } @@ -440,7 +441,7 @@ public void testLongEmptyInput() throws Exception { PforValuesWriter.LongPforValuesWriter writer = new PforValuesWriter.LongPforValuesWriter( 256, 256, new DirectByteBufferAllocator()); BytesInput bytes = writer.getBytes(); - assertEquals(0, bytes.size()); + assertEquals(PforConstants.PFOR_HEADER_SIZE, bytes.size()); writer.close(); } } From e0bc4c7dabe649bc61b220de5621ec8f71632ba2 Mon Sep 17 00:00:00 2001 From: Prateek Gaur Date: Wed, 26 Aug 2026 02:51:09 +0000 Subject: [PATCH 09/14] Apply spotless formatting to the PFOR sources They were added unformatted, so spotless:check fails on the branch as it stands. --- .../org/apache/parquet/column/Encoding.java | 4 +-- .../factory/DefaultV2ValuesWriterFactory.java | 2 +- .../column/values/pfor/PforValuesReader.java | 7 ++--- .../values/pfor/PforValuesReaderForInt.java | 1 - .../values/pfor/PforValuesReaderForLong.java | 1 - .../column/values/pfor/PforValuesWriter.java | 6 ++-- .../values/pfor/PforAdversarialTest.java | 7 ++--- .../values/pfor/PforBitPackingTest.java | 19 ++++++------- .../values/pfor/PforEncoderDecoderTest.java | 4 +-- .../values/pfor/PforValuesEndToEndTest.java | 28 +++++++++---------- .../pfor/benchmark/BenchmarkPforEncoding.java | 15 ++++------ 11 files changed, 43 insertions(+), 51 deletions(-) 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 3241c3121f..280aa417e3 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 @@ -36,8 +36,6 @@ import org.apache.parquet.column.values.bytestreamsplit.ByteStreamSplitValuesReaderForInteger; import org.apache.parquet.column.values.bytestreamsplit.ByteStreamSplitValuesReaderForLong; import org.apache.parquet.column.values.delta.DeltaBinaryPackingValuesReader; -import org.apache.parquet.column.values.pfor.PforValuesReaderForInt; -import org.apache.parquet.column.values.pfor.PforValuesReaderForLong; import org.apache.parquet.column.values.deltalengthbytearray.DeltaLengthByteArrayValuesReader; import org.apache.parquet.column.values.deltastrings.DeltaByteArrayReader; import org.apache.parquet.column.values.dictionary.DictionaryValuesReader; @@ -47,6 +45,8 @@ import org.apache.parquet.column.values.dictionary.PlainValuesDictionary.PlainFloatDictionary; import org.apache.parquet.column.values.dictionary.PlainValuesDictionary.PlainIntegerDictionary; import org.apache.parquet.column.values.dictionary.PlainValuesDictionary.PlainLongDictionary; +import org.apache.parquet.column.values.pfor.PforValuesReaderForInt; +import org.apache.parquet.column.values.pfor.PforValuesReaderForLong; import org.apache.parquet.column.values.plain.BinaryPlainValuesReader; import org.apache.parquet.column.values.plain.BooleanPlainValuesReader; import org.apache.parquet.column.values.plain.FixedLenByteArrayPlainValuesReader; 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 5891d943b5..b215e0fe14 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 @@ -27,9 +27,9 @@ import org.apache.parquet.column.values.ValuesWriter; import org.apache.parquet.column.values.bytestreamsplit.ByteStreamSplitValuesWriter; import org.apache.parquet.column.values.delta.DeltaBinaryPackingValuesWriterForInteger; -import org.apache.parquet.column.values.pfor.PforValuesWriter; import org.apache.parquet.column.values.delta.DeltaBinaryPackingValuesWriterForLong; import org.apache.parquet.column.values.deltastrings.DeltaByteArrayWriter; +import org.apache.parquet.column.values.pfor.PforValuesWriter; import org.apache.parquet.column.values.plain.FixedLenByteArrayPlainValuesWriter; import org.apache.parquet.column.values.plain.PlainValuesWriter; import org.apache.parquet.column.values.rle.RunLengthBitPackingHybridValuesWriter; diff --git a/parquet-column/src/main/java/org/apache/parquet/column/values/pfor/PforValuesReader.java b/parquet-column/src/main/java/org/apache/parquet/column/values/pfor/PforValuesReader.java index 8f891235ad..61d3d5b085 100644 --- a/parquet-column/src/main/java/org/apache/parquet/column/values/pfor/PforValuesReader.java +++ b/parquet-column/src/main/java/org/apache/parquet/column/values/pfor/PforValuesReader.java @@ -73,12 +73,11 @@ public void initFromPage(int valuesCount, ByteBufferInputStream stream) throw new ParquetDecodingException("Unsupported PFOR packing mode: " + packingMode); } if (logVectorSize < MIN_LOG_VECTOR_SIZE || logVectorSize > MAX_LOG_VECTOR_SIZE) { - throw new ParquetDecodingException("Invalid PFOR log vector size: " + logVectorSize - + ", must be between " + MIN_LOG_VECTOR_SIZE + " and " + MAX_LOG_VECTOR_SIZE); + throw new ParquetDecodingException("Invalid PFOR log vector size: " + logVectorSize + ", must be between " + + MIN_LOG_VECTOR_SIZE + " and " + MAX_LOG_VECTOR_SIZE); } if (valueBW != INT32_VALUE_BYTE_WIDTH && valueBW != INT64_VALUE_BYTE_WIDTH) { - throw new ParquetDecodingException( - "Invalid PFOR value byte width: " + valueBW + ", must be 4 or 8"); + throw new ParquetDecodingException("Invalid PFOR value byte width: " + valueBW + ", must be 4 or 8"); } if (numElements < 0) { throw new ParquetDecodingException("Invalid PFOR element count: " + numElements); diff --git a/parquet-column/src/main/java/org/apache/parquet/column/values/pfor/PforValuesReaderForInt.java b/parquet-column/src/main/java/org/apache/parquet/column/values/pfor/PforValuesReaderForInt.java index d91d421a1d..810c16976b 100644 --- a/parquet-column/src/main/java/org/apache/parquet/column/values/pfor/PforValuesReaderForInt.java +++ b/parquet-column/src/main/java/org/apache/parquet/column/values/pfor/PforValuesReaderForInt.java @@ -140,5 +140,4 @@ private int unpackIntsWithBytePacker(ByteBuffer buf, int pos, int[] output, int return pos; } - } diff --git a/parquet-column/src/main/java/org/apache/parquet/column/values/pfor/PforValuesReaderForLong.java b/parquet-column/src/main/java/org/apache/parquet/column/values/pfor/PforValuesReaderForLong.java index e8c8fe4f1e..375176e724 100644 --- a/parquet-column/src/main/java/org/apache/parquet/column/values/pfor/PforValuesReaderForLong.java +++ b/parquet-column/src/main/java/org/apache/parquet/column/values/pfor/PforValuesReaderForLong.java @@ -140,5 +140,4 @@ private int unpackLongsWithBytePacker(ByteBuffer buf, int pos, long[] output, in return pos; } - } diff --git a/parquet-column/src/main/java/org/apache/parquet/column/values/pfor/PforValuesWriter.java b/parquet-column/src/main/java/org/apache/parquet/column/values/pfor/PforValuesWriter.java index 8cc5bd5e6f..432714d57a 100644 --- a/parquet-column/src/main/java/org/apache/parquet/column/values/pfor/PforValuesWriter.java +++ b/parquet-column/src/main/java/org/apache/parquet/column/values/pfor/PforValuesWriter.java @@ -138,7 +138,8 @@ private void encodeAndFlushVector(int vectorLen) { } // Find optimal bit width via cost model - PforEncoderDecoder.BitWidthResult result = PforEncoderDecoder.findOptimalBitWidthForInt(deltasBuffer, vectorLen); + PforEncoderDecoder.BitWidthResult result = + PforEncoderDecoder.findOptimalBitWidthForInt(deltasBuffer, vectorLen); int bitWidth = result.bitWidth; int numExceptions = result.numExceptions; @@ -336,7 +337,8 @@ private void encodeAndFlushVector(int vectorLen) { deltasBuffer[i] = vectorBuffer[i] - minValue; } - PforEncoderDecoder.BitWidthResult result = PforEncoderDecoder.findOptimalBitWidthForLong(deltasBuffer, vectorLen); + PforEncoderDecoder.BitWidthResult result = + PforEncoderDecoder.findOptimalBitWidthForLong(deltasBuffer, vectorLen); int bitWidth = result.bitWidth; int numExceptions = result.numExceptions; diff --git a/parquet-column/src/test/java/org/apache/parquet/column/values/pfor/PforAdversarialTest.java b/parquet-column/src/test/java/org/apache/parquet/column/values/pfor/PforAdversarialTest.java index 15d3cc499e..299625da9f 100644 --- a/parquet-column/src/test/java/org/apache/parquet/column/values/pfor/PforAdversarialTest.java +++ b/parquet-column/src/test/java/org/apache/parquet/column/values/pfor/PforAdversarialTest.java @@ -24,7 +24,6 @@ import static org.junit.Assert.fail; import java.nio.ByteBuffer; -import java.nio.ByteOrder; import org.apache.parquet.bytes.ByteBufferInputStream; import org.apache.parquet.bytes.BytesInput; import org.apache.parquet.bytes.DirectByteBufferAllocator; @@ -51,8 +50,7 @@ private static byte[] validIntPage(int valueCount, int vectorSize) throws Except PforValuesWriter.IntPforValuesWriter writer = null; try { int cap = Math.max(512, valueCount * 8); - writer = new PforValuesWriter.IntPforValuesWriter( - cap, cap, new DirectByteBufferAllocator(), vectorSize); + writer = new PforValuesWriter.IntPforValuesWriter(cap, cap, new DirectByteBufferAllocator(), vectorSize); for (int i = 0; i < valueCount; i++) { writer.writeInteger(i * 7 + 3); } @@ -73,8 +71,7 @@ private static byte[] validLongPage(int valueCount, int vectorSize) throws Excep PforValuesWriter.LongPforValuesWriter writer = null; try { int cap = Math.max(512, valueCount * 16); - writer = new PforValuesWriter.LongPforValuesWriter( - cap, cap, new DirectByteBufferAllocator(), vectorSize); + writer = new PforValuesWriter.LongPforValuesWriter(cap, cap, new DirectByteBufferAllocator(), vectorSize); for (int i = 0; i < valueCount; i++) { writer.writeLong((long) i * 13 + 5); } diff --git a/parquet-column/src/test/java/org/apache/parquet/column/values/pfor/PforBitPackingTest.java b/parquet-column/src/test/java/org/apache/parquet/column/values/pfor/PforBitPackingTest.java index 20732f787c..d7feb8b2ce 100644 --- a/parquet-column/src/test/java/org/apache/parquet/column/values/pfor/PforBitPackingTest.java +++ b/parquet-column/src/test/java/org/apache/parquet/column/values/pfor/PforBitPackingTest.java @@ -36,8 +36,8 @@ public class PforBitPackingTest { // Round-trip helper that verifies bit-packing for int values in a given range private void verifyIntRoundTrip(int[] values) throws Exception { int capacity = Math.max(256, values.length * 8); - PforValuesWriter.IntPforValuesWriter writer = new PforValuesWriter.IntPforValuesWriter( - capacity, capacity, new DirectByteBufferAllocator()); + PforValuesWriter.IntPforValuesWriter writer = + new PforValuesWriter.IntPforValuesWriter(capacity, capacity, new DirectByteBufferAllocator()); for (int v : values) { writer.writeInteger(v); @@ -56,8 +56,8 @@ private void verifyIntRoundTrip(int[] values) throws Exception { private void verifyLongRoundTrip(long[] values) throws Exception { int capacity = Math.max(512, values.length * 16); - PforValuesWriter.LongPforValuesWriter writer = new PforValuesWriter.LongPforValuesWriter( - capacity, capacity, new DirectByteBufferAllocator()); + PforValuesWriter.LongPforValuesWriter writer = + new PforValuesWriter.LongPforValuesWriter(capacity, capacity, new DirectByteBufferAllocator()); for (long v : values) { writer.writeLong(v); @@ -115,8 +115,7 @@ public void testIntBitWidth16() throws Exception { @Test public void testIntBitWidth32() throws Exception { // Full range int values - int[] values = {Integer.MIN_VALUE, -1, 0, 1, Integer.MAX_VALUE, - 0x7FFFFFFF, 0x40000000, -2147483648}; + int[] values = {Integer.MIN_VALUE, -1, 0, 1, Integer.MAX_VALUE, 0x7FFFFFFF, 0x40000000, -2147483648}; verifyIntRoundTrip(values); } @@ -195,8 +194,8 @@ public void testIntPageHeaderFormat() throws Exception { values[i] = i; } - PforValuesWriter.IntPforValuesWriter writer = new PforValuesWriter.IntPforValuesWriter( - 1024, 1024, new DirectByteBufferAllocator()); + PforValuesWriter.IntPforValuesWriter writer = + new PforValuesWriter.IntPforValuesWriter(1024, 1024, new DirectByteBufferAllocator()); for (int v : values) { writer.writeInteger(v); } @@ -221,8 +220,8 @@ public void testLongPageHeaderFormat() throws Exception { values[i] = i; } - PforValuesWriter.LongPforValuesWriter writer = new PforValuesWriter.LongPforValuesWriter( - 1024, 1024, new DirectByteBufferAllocator()); + PforValuesWriter.LongPforValuesWriter writer = + new PforValuesWriter.LongPforValuesWriter(1024, 1024, new DirectByteBufferAllocator()); for (long v : values) { writer.writeLong(v); } diff --git a/parquet-column/src/test/java/org/apache/parquet/column/values/pfor/PforEncoderDecoderTest.java b/parquet-column/src/test/java/org/apache/parquet/column/values/pfor/PforEncoderDecoderTest.java index fa8ed98249..c8958d7219 100644 --- a/parquet-column/src/test/java/org/apache/parquet/column/values/pfor/PforEncoderDecoderTest.java +++ b/parquet-column/src/test/java/org/apache/parquet/column/values/pfor/PforEncoderDecoderTest.java @@ -180,10 +180,10 @@ public void testCostModelPrefersFewerExceptions() { // storing half the values as exceptions int[] deltas = new int[100]; for (int i = 0; i < 50; i++) { - deltas[i] = i; // 0..49 fit in 6 bits + deltas[i] = i; // 0..49 fit in 6 bits } for (int i = 50; i < 100; i++) { - deltas[i] = 1000 + i; // need ~10 bits + deltas[i] = 1000 + i; // need ~10 bits } PforEncoderDecoder.BitWidthResult result = PforEncoderDecoder.findOptimalBitWidthForInt(deltas, 100); // Should choose to pack everything (10-11 bits) rather than 50 exceptions diff --git a/parquet-column/src/test/java/org/apache/parquet/column/values/pfor/PforValuesEndToEndTest.java b/parquet-column/src/test/java/org/apache/parquet/column/values/pfor/PforValuesEndToEndTest.java index d1fa34a90c..d20a4e8c74 100644 --- a/parquet-column/src/test/java/org/apache/parquet/column/values/pfor/PforValuesEndToEndTest.java +++ b/parquet-column/src/test/java/org/apache/parquet/column/values/pfor/PforValuesEndToEndTest.java @@ -129,7 +129,7 @@ public void testIntAllZeros() throws Exception { @Test public void testIntSingleElement() throws Exception { - roundTripInt(new int[]{12345}); + roundTripInt(new int[] {12345}); } @Test @@ -249,7 +249,7 @@ public void testLongAllZeros() throws Exception { @Test public void testLongSingleElement() throws Exception { - roundTripLong(new long[]{Long.MAX_VALUE}); + roundTripLong(new long[] {Long.MAX_VALUE}); } @Test @@ -317,8 +317,8 @@ public void testLongSmallVectorSize() throws Exception { @Test public void testIntWriterReset() throws Exception { - PforValuesWriter.IntPforValuesWriter writer = new PforValuesWriter.IntPforValuesWriter( - 1024, 1024, new DirectByteBufferAllocator()); + PforValuesWriter.IntPforValuesWriter writer = + new PforValuesWriter.IntPforValuesWriter(1024, 1024, new DirectByteBufferAllocator()); // First batch for (int i = 0; i < 100; i++) { @@ -347,8 +347,8 @@ public void testIntWriterReset() throws Exception { @Test public void testLongWriterReset() throws Exception { - PforValuesWriter.LongPforValuesWriter writer = new PforValuesWriter.LongPforValuesWriter( - 1024, 1024, new DirectByteBufferAllocator()); + PforValuesWriter.LongPforValuesWriter writer = + new PforValuesWriter.LongPforValuesWriter(1024, 1024, new DirectByteBufferAllocator()); for (int i = 0; i < 100; i++) { writer.writeLong(i * 1000L); @@ -379,8 +379,8 @@ public void testIntSkip() throws Exception { values[i] = i; } - PforValuesWriter.IntPforValuesWriter writer = new PforValuesWriter.IntPforValuesWriter( - 4096, 4096, new DirectByteBufferAllocator()); + PforValuesWriter.IntPforValuesWriter writer = + new PforValuesWriter.IntPforValuesWriter(4096, 4096, new DirectByteBufferAllocator()); for (int v : values) { writer.writeInteger(v); } @@ -408,8 +408,8 @@ public void testLongSkip() throws Exception { values[i] = i * 100L; } - PforValuesWriter.LongPforValuesWriter writer = new PforValuesWriter.LongPforValuesWriter( - 4096, 4096, new DirectByteBufferAllocator()); + PforValuesWriter.LongPforValuesWriter writer = + new PforValuesWriter.LongPforValuesWriter(4096, 4096, new DirectByteBufferAllocator()); for (long v : values) { writer.writeLong(v); } @@ -428,8 +428,8 @@ public void testLongSkip() throws Exception { @Test public void testIntEmptyInput() throws Exception { - PforValuesWriter.IntPforValuesWriter writer = new PforValuesWriter.IntPforValuesWriter( - 256, 256, new DirectByteBufferAllocator()); + PforValuesWriter.IntPforValuesWriter writer = + new PforValuesWriter.IntPforValuesWriter(256, 256, new DirectByteBufferAllocator()); BytesInput bytes = writer.getBytes(); // Empty page still emits a valid 7-byte header (numElements=0) assertEquals(PforConstants.PFOR_HEADER_SIZE, bytes.size()); @@ -438,8 +438,8 @@ public void testIntEmptyInput() throws Exception { @Test public void testLongEmptyInput() throws Exception { - PforValuesWriter.LongPforValuesWriter writer = new PforValuesWriter.LongPforValuesWriter( - 256, 256, new DirectByteBufferAllocator()); + PforValuesWriter.LongPforValuesWriter writer = + new PforValuesWriter.LongPforValuesWriter(256, 256, new DirectByteBufferAllocator()); BytesInput bytes = writer.getBytes(); assertEquals(PforConstants.PFOR_HEADER_SIZE, bytes.size()); writer.close(); diff --git a/parquet-column/src/test/java/org/apache/parquet/column/values/pfor/benchmark/BenchmarkPforEncoding.java b/parquet-column/src/test/java/org/apache/parquet/column/values/pfor/benchmark/BenchmarkPforEncoding.java index 31312ec914..2d0769fab4 100644 --- a/parquet-column/src/test/java/org/apache/parquet/column/values/pfor/benchmark/BenchmarkPforEncoding.java +++ b/parquet-column/src/test/java/org/apache/parquet/column/values/pfor/benchmark/BenchmarkPforEncoding.java @@ -25,7 +25,6 @@ import java.io.IOException; 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.values.pfor.PforValuesReaderForInt; import org.apache.parquet.column.values.pfor.PforValuesReaderForLong; @@ -359,8 +358,7 @@ private void benchmarkIntEncode(int[] values) throws IOException { private void benchmarkIntDecode(byte[] encoded, int numValues) throws IOException { PforValuesReaderForInt reader = new PforValuesReaderForInt(); - reader.initFromPage(numValues, - ByteBufferInputStream.wrap(java.nio.ByteBuffer.wrap(encoded))); + reader.initFromPage(numValues, ByteBufferInputStream.wrap(java.nio.ByteBuffer.wrap(encoded))); for (int i = 0; i < numValues; i++) { reader.readInteger(); } @@ -379,8 +377,7 @@ private void benchmarkLongEncode(long[] values) throws IOException { private void benchmarkLongDecode(byte[] encoded, int numValues) throws IOException { PforValuesReaderForLong reader = new PforValuesReaderForLong(); - reader.initFromPage(numValues, - ByteBufferInputStream.wrap(java.nio.ByteBuffer.wrap(encoded))); + reader.initFromPage(numValues, ByteBufferInputStream.wrap(java.nio.ByteBuffer.wrap(encoded))); for (int i = 0; i < numValues; i++) { reader.readLong(); } @@ -528,13 +525,13 @@ private static byte[] encodeLongs(long[] values) throws IOException { private static void printIntRatio(String name, byte[] encoded, int numValues) { double ratio = 100.0 * encoded.length / (numValues * 4); - System.out.printf(" INT32 %-25s: %6d bytes -> %6d bytes (%.1f%%)\n", - name, numValues * 4, encoded.length, ratio); + System.out.printf( + " INT32 %-25s: %6d bytes -> %6d bytes (%.1f%%)\n", name, numValues * 4, encoded.length, ratio); } private static void printLongRatio(String name, byte[] encoded, int numValues) { double ratio = 100.0 * encoded.length / (numValues * 8); - System.out.printf(" INT64 %-25s: %6d bytes -> %6d bytes (%.1f%%)\n", - name, numValues * 8, encoded.length, ratio); + System.out.printf( + " INT64 %-25s: %6d bytes -> %6d bytes (%.1f%%)\n", name, numValues * 8, encoded.length, ratio); } } From 60f784351b2dccc8655dc357637f270523132205 Mon Sep 17 00:00:00 2001 From: Prateek Gaur Date: Wed, 26 Aug 2026 02:52:10 +0000 Subject: [PATCH 10/14] Validate PFOR vector info and exception positions before decoding Bit width, exception count, and exception positions all came off the wire and sized reads and writes unchecked, so a corrupt page raised raw index errors. --- .../column/values/pfor/PforConstants.java | 4 + .../column/values/pfor/PforValuesReader.java | 45 +++++- .../values/pfor/PforValuesReaderForInt.java | 7 +- .../values/pfor/PforValuesReaderForLong.java | 7 +- .../values/pfor/PforAdversarialTest.java | 144 ++++++++++++++++++ 5 files changed, 201 insertions(+), 6 deletions(-) diff --git a/parquet-column/src/main/java/org/apache/parquet/column/values/pfor/PforConstants.java b/parquet-column/src/main/java/org/apache/parquet/column/values/pfor/PforConstants.java index 81afe98c07..a0f0f24222 100644 --- a/parquet-column/src/main/java/org/apache/parquet/column/values/pfor/PforConstants.java +++ b/parquet-column/src/main/java/org/apache/parquet/column/values/pfor/PforConstants.java @@ -52,6 +52,10 @@ private PforConstants() { // Maximum exceptions per vector (uint16) public static final int MAX_EXCEPTIONS = 65535; + // The bit width occupies bits 0..6 of its byte; bit 7 is reserved and must be + // masked off before the width is used or range-checked. + public static final int BIT_WIDTH_MASK = 0x7F; + // Per-vector metadata sizes in bytes // INT32: frame_of_reference(4) + bit_width(1) + num_exceptions(2) = 7 public static final int INT32_VECTOR_INFO_SIZE = 7; diff --git a/parquet-column/src/main/java/org/apache/parquet/column/values/pfor/PforValuesReader.java b/parquet-column/src/main/java/org/apache/parquet/column/values/pfor/PforValuesReader.java index 61d3d5b085..bf29e0f814 100644 --- a/parquet-column/src/main/java/org/apache/parquet/column/values/pfor/PforValuesReader.java +++ b/parquet-column/src/main/java/org/apache/parquet/column/values/pfor/PforValuesReader.java @@ -49,6 +49,7 @@ abstract class PforValuesReader extends ValuesReader { protected int currentIndex; protected int currentVectorIndex; protected int valueByteWidth; + protected int vectorInfoSize; protected int[] vectorOffsets; protected ByteBuffer vectorsData; @@ -90,6 +91,7 @@ public void initFromPage(int valuesCount, ByteBufferInputStream stream) this.vectorSize = 1 << logVectorSize; this.totalCount = numElements; this.valueByteWidth = valueBW; + this.vectorInfoSize = valueBW == INT32_VALUE_BYTE_WIDTH ? INT32_VECTOR_INFO_SIZE : INT64_VECTOR_INFO_SIZE; this.numVectors = (numElements + vectorSize - 1) / vectorSize; this.currentIndex = 0; this.currentVectorIndex = -1; @@ -120,9 +122,48 @@ protected int getVectorLength(int vectorIdx) { } // Offsets in the page are relative to the compression body (after header), - // but vectorsData starts after the offset array, so adjust. + // but vectorsData starts after the offset array, so adjust. The offset came off + // the wire, so it has to leave room for the vector info it points at. protected int getVectorDataPosition(int vectorIdx) { - return vectorOffsets[vectorIdx] - offsetArraySize; + int pos = vectorOffsets[vectorIdx] - offsetArraySize; + if (pos < 0 || pos + vectorInfoSize > vectorsData.limit()) { + throw new ParquetDecodingException("PFOR vector " + vectorIdx + " offset " + + vectorOffsets[vectorIdx] + " is outside a page body of " + vectorsData.limit() + + " bytes"); + } + return pos; + } + + /** + * Checks a vector's header fields against the vector they describe and the bytes + * that remain. All three come off the wire and size every read and write that + * follows, including the writes into the fixed-size decode buffers. + * + * @param pos position of the packed values, that is, just past the vector info + */ + protected void checkVectorInfo(int pos, int bitWidth, int numExceptions, int vectorLen) { + int maxBitWidth = valueByteWidth * Byte.SIZE; + if (bitWidth > maxBitWidth) { + throw new ParquetDecodingException("PFOR bit width " + bitWidth + " exceeds " + maxBitWidth); + } + if (numExceptions > vectorLen) { + throw new ParquetDecodingException( + "PFOR vector has " + numExceptions + " exceptions but only " + vectorLen + " elements"); + } + long needed = ((long) vectorLen * bitWidth + 7) / 8 + (long) numExceptions * (Short.BYTES + valueByteWidth); + long remaining = vectorsData.limit() - (long) pos; + if (needed > remaining) { + throw new ParquetDecodingException( + "PFOR vector needs " + needed + " bytes but only " + remaining + " remain"); + } + } + + /** Exception positions index the decode buffer, so one past the end is a bad write. */ + protected static void checkExceptionPosition(int position, int vectorLen) { + if (position >= vectorLen) { + throw new ParquetDecodingException( + "PFOR exception position " + position + " is outside a vector of " + vectorLen + " elements"); + } } @Override diff --git a/parquet-column/src/main/java/org/apache/parquet/column/values/pfor/PforValuesReaderForInt.java b/parquet-column/src/main/java/org/apache/parquet/column/values/pfor/PforValuesReaderForInt.java index 810c16976b..edc1bb9e0e 100644 --- a/parquet-column/src/main/java/org/apache/parquet/column/values/pfor/PforValuesReaderForInt.java +++ b/parquet-column/src/main/java/org/apache/parquet/column/values/pfor/PforValuesReaderForInt.java @@ -80,9 +80,10 @@ protected void decodeVector(int vectorIdx) { // Read PforVectorInfo (7 bytes) int frameOfReference = getIntLE(vectorsData, pos); - int bitWidth = vectorsData.get(pos + 4) & 0xFF; + int bitWidth = vectorsData.get(pos + 4) & BIT_WIDTH_MASK; int numExceptions = getShortLE(vectorsData, pos + 5) & 0xFFFF; pos += INT32_VECTOR_INFO_SIZE; + checkVectorInfo(pos, bitWidth, numExceptions, vectorLen); // Unpack bit-packed deltas into reusable buffer if (bitWidth > 0) { @@ -101,7 +102,9 @@ protected void decodeVector(int vectorIdx) { // Overwrite exception slots with their original values if (numExceptions > 0) { for (int e = 0; e < numExceptions; e++) { - excPositionsBuffer[e] = getShortLE(vectorsData, pos) & 0xFFFF; + int position = getShortLE(vectorsData, pos) & 0xFFFF; + checkExceptionPosition(position, vectorLen); + excPositionsBuffer[e] = position; pos += Short.BYTES; } for (int e = 0; e < numExceptions; e++) { diff --git a/parquet-column/src/main/java/org/apache/parquet/column/values/pfor/PforValuesReaderForLong.java b/parquet-column/src/main/java/org/apache/parquet/column/values/pfor/PforValuesReaderForLong.java index 375176e724..729d2c9c6c 100644 --- a/parquet-column/src/main/java/org/apache/parquet/column/values/pfor/PforValuesReaderForLong.java +++ b/parquet-column/src/main/java/org/apache/parquet/column/values/pfor/PforValuesReaderForLong.java @@ -80,9 +80,10 @@ protected void decodeVector(int vectorIdx) { // Read PforVectorInfo (11 bytes) long frameOfReference = getLongLE(vectorsData, pos); - int bitWidth = vectorsData.get(pos + 8) & 0xFF; + int bitWidth = vectorsData.get(pos + 8) & BIT_WIDTH_MASK; int numExceptions = getShortLE(vectorsData, pos + 9) & 0xFFFF; pos += INT64_VECTOR_INFO_SIZE; + checkVectorInfo(pos, bitWidth, numExceptions, vectorLen); // Unpack bit-packed deltas into reusable buffer if (bitWidth > 0) { @@ -101,7 +102,9 @@ protected void decodeVector(int vectorIdx) { // Overwrite exception slots with their original values if (numExceptions > 0) { for (int e = 0; e < numExceptions; e++) { - excPositionsBuffer[e] = getShortLE(vectorsData, pos) & 0xFFFF; + int position = getShortLE(vectorsData, pos) & 0xFFFF; + checkExceptionPosition(position, vectorLen); + excPositionsBuffer[e] = position; pos += Short.BYTES; } for (int e = 0; e < numExceptions; e++) { diff --git a/parquet-column/src/test/java/org/apache/parquet/column/values/pfor/PforAdversarialTest.java b/parquet-column/src/test/java/org/apache/parquet/column/values/pfor/PforAdversarialTest.java index 299625da9f..c8a632f426 100644 --- a/parquet-column/src/test/java/org/apache/parquet/column/values/pfor/PforAdversarialTest.java +++ b/parquet-column/src/test/java/org/apache/parquet/column/values/pfor/PforAdversarialTest.java @@ -18,6 +18,7 @@ */ package org.apache.parquet.column.values.pfor; +import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertThrows; import static org.junit.Assert.assertTrue; @@ -42,6 +43,11 @@ public class PforAdversarialTest { private static final int VECTOR_SIZE = PforConstants.DEFAULT_VECTOR_SIZE; + // The five-element pages below hold one vector, so the page is a 7-byte header, + // a single 4-byte offset, and then that vector's info. + private static final int VECTOR_START = PforConstants.PFOR_HEADER_SIZE + Integer.BYTES; + private static final int OUTLIER_VECTOR_LEN = 5; + // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- @@ -88,6 +94,80 @@ private static byte[] validLongPage(int valueCount, int vectorSize) throws Excep } } + // One value far above the cluster, which is the shape the cost model stores as an + // exception; a low outlier would become the frame of reference instead. + private static final int[] OUTLIER_INTS = {100, 101, 102, 103, 50000}; + + // An INT64 exception costs 80 bits, so the outlier has to sit further out still: + // 50,000 would be cheaper to pack at full width than to store as an exception. + private static final long[] OUTLIER_LONGS = {100L, 101L, 102L, 103L, 100L + (1L << 40)}; + + private static byte[] outlierIntPage() throws Exception { + PforValuesWriter.IntPforValuesWriter writer = null; + try { + writer = new PforValuesWriter.IntPforValuesWriter(512, 512, new DirectByteBufferAllocator(), 8); + for (int value : OUTLIER_INTS) { + writer.writeInteger(value); + } + return toBytes(writer.getBytes()); + } finally { + if (writer != null) { + writer.reset(); + writer.close(); + } + } + } + + private static byte[] outlierLongPage() throws Exception { + PforValuesWriter.LongPforValuesWriter writer = null; + try { + writer = new PforValuesWriter.LongPforValuesWriter(512, 512, new DirectByteBufferAllocator(), 8); + for (long value : OUTLIER_LONGS) { + writer.writeLong(value); + } + return toBytes(writer.getBytes()); + } finally { + if (writer != null) { + writer.reset(); + writer.close(); + } + } + } + + private static byte[] toBytes(BytesInput bytes) throws Exception { + ByteBuffer bb = bytes.toByteBuffer(); + byte[] out = new byte[bb.remaining()]; + bb.duplicate().get(out); + return out; + } + + private static int bitWidthOffset(int vectorInfoSize) { + return VECTOR_START + vectorInfoSize - 3; + } + + private static int numExceptionsOffset(int vectorInfoSize) { + return VECTOR_START + vectorInfoSize - 2; + } + + // Offset of the first stored exception position: past the vector info and the + // packed deltas, whose length depends on the width the writer chose. + private static int exceptionPositionOffset(byte[] page, int vectorInfoSize) { + int bitWidth = page[bitWidthOffset(vectorInfoSize)] & PforConstants.BIT_WIDTH_MASK; + int packedBytes = (OUTLIER_VECTOR_LEN * bitWidth + 7) / 8; + return VECTOR_START + vectorInfoSize + packedBytes; + } + + private static int shortLE(byte[] page, int pos) { + return (page[pos] & 0xFF) | ((page[pos + 1] & 0xFF) << 8); + } + + private static byte[] putShortLE(byte[] original, int pos, int value) { + byte[] copy = original.clone(); + copy[pos] = (byte) (value & 0xFF); + copy[pos + 1] = (byte) ((value >>> 8) & 0xFF); + return copy; + } + private static byte[] mutate(byte[] original, int offset, byte value) { byte[] copy = original.clone(); copy[offset] = value; @@ -256,6 +336,70 @@ public void rejectsCorruptedOffsetPointingPastEnd() throws Exception { } } + // --------------------------------------------------------------------------- + // Per-vector info validation + // --------------------------------------------------------------------------- + + @Test + public void sanityOutlierPagesCarryAnException() throws Exception { + assertTrue( + "the tests below relocate a stored exception, so the int page must have one", + shortLE(outlierIntPage(), numExceptionsOffset(PforConstants.INT32_VECTOR_INFO_SIZE)) > 0); + assertTrue( + "the tests below relocate a stored exception, so the long page must have one", + shortLE(outlierLongPage(), numExceptionsOffset(PforConstants.INT64_VECTOR_INFO_SIZE)) > 0); + } + + @Test + public void rejectsExceptionCountAboveVectorLength() throws Exception { + byte[] page = outlierIntPage(); + byte[] bad = putShortLE(page, numExceptionsOffset(PforConstants.INT32_VECTOR_INFO_SIZE), 6); + assertThrows(ParquetDecodingException.class, () -> initIntReader(bad, OUTLIER_VECTOR_LEN)); + } + + @Test + public void rejectsExceptionPositionPastEndOfVector() throws Exception { + byte[] page = outlierIntPage(); + byte[] bad = putShortLE(page, exceptionPositionOffset(page, PforConstants.INT32_VECTOR_INFO_SIZE), 100); + assertThrows(ParquetDecodingException.class, () -> initIntReader(bad, OUTLIER_VECTOR_LEN)); + } + + @Test + public void rejectsExceptionPositionPastEndOfVectorLong() throws Exception { + byte[] page = outlierLongPage(); + byte[] bad = putShortLE(page, exceptionPositionOffset(page, PforConstants.INT64_VECTOR_INFO_SIZE), 100); + assertThrows(ParquetDecodingException.class, () -> initLongReader(bad, OUTLIER_VECTOR_LEN)); + } + + @Test + public void rejectsBitWidthAboveValueWidth() throws Exception { + byte[] page = outlierIntPage(); + byte[] bad = mutate(page, bitWidthOffset(PforConstants.INT32_VECTOR_INFO_SIZE), (byte) 33); + assertThrows(ParquetDecodingException.class, () -> initIntReader(bad, OUTLIER_VECTOR_LEN)); + } + + @Test + public void rejectsExceptionValuesTruncated() throws Exception { + byte[] page = outlierIntPage(); + byte[] bad = truncate(page, page.length - Integer.BYTES); + assertThrows(ParquetDecodingException.class, () -> initIntReader(bad, OUTLIER_VECTOR_LEN)); + } + + // Bit 7 of the bit width byte is reserved, so a writer that sets it must not + // change what a reader decodes. + @Test + public void ignoresReservedBitInBitWidth() throws Exception { + byte[] page = outlierIntPage(); + int at = bitWidthOffset(PforConstants.INT32_VECTOR_INFO_SIZE); + byte[] withReservedBit = mutate(page, at, (byte) (page[at] | 0x80)); + + PforValuesReaderForInt reader = new PforValuesReaderForInt(); + reader.initFromPage(OUTLIER_VECTOR_LEN, ByteBufferInputStream.wrap(ByteBuffer.wrap(withReservedBit))); + for (int expected : OUTLIER_INTS) { + assertEquals(expected, reader.readInteger()); + } + } + // --------------------------------------------------------------------------- // Skip/read bounds // --------------------------------------------------------------------------- From eafec1e69cdbd9be7d14143fbf0a2e8ab8cc41e3 Mon Sep 17 00:00:00 2001 From: Prateek Gaur Date: Sat, 5 Sep 2026 01:18:40 +0000 Subject: [PATCH 11/14] Add a configuration key for PFOR encoding parquet.enable.pfor turns PFOR on for INT32 and INT64 columns, following how parquet.enable.bytestreamsplit exposes BYTE_STREAM_SPLIT: a constant, a getter reading the key against the ParquetProperties default, an entry in the class documentation, and a line in the properties the record writer builds. The default is unchanged, so PFOR stays off unless a job asks for it. Also wraps the long line the formatter rejects in ParquetMetadataConverter. --- .../converter/ParquetMetadataConverter.java | 3 +- .../parquet/hadoop/ParquetOutputFormat.java | 9 ++ .../parquet/hadoop/TestPforConfiguration.java | 85 +++++++++++++++++++ 3 files changed, 96 insertions(+), 1 deletion(-) create mode 100644 parquet-hadoop/src/test/java/org/apache/parquet/hadoop/TestPforConfiguration.java 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 7ee76063d6..2d4c5ed9af 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 @@ -750,7 +750,8 @@ public org.apache.parquet.column.Encoding getEncoding(Encoding encoding) { public Encoding getEncoding(org.apache.parquet.column.Encoding encoding) { // PFOR encoding is not yet part of the parquet-format specification if (encoding == org.apache.parquet.column.Encoding.PFOR) { - throw new IllegalArgumentException("PFOR encoding is not yet supported in the parquet-format specification"); + throw new IllegalArgumentException( + "PFOR encoding is not yet supported in the parquet-format specification"); } 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 868ae634c1..41f690f517 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 @@ -83,6 +83,9 @@ * # To enable/disable BYTE_STREAM_SPLIT encoding * parquet.enable.bytestreamsplit=false # true to enable BYTE_STREAM_SPLIT encoding * + * # To enable/disable PFOR encoding for INT32 and INT64 columns + * parquet.enable.pfor=false # true to enable PFOR encoding + * * # 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 +144,7 @@ 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_PFOR = "parquet.enable.pfor"; 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"; @@ -279,6 +283,10 @@ public static boolean getByteStreamSplitEnabled(Configuration configuration) { ENABLE_BYTE_STREAM_SPLIT, ParquetProperties.DEFAULT_IS_BYTE_STREAM_SPLIT_ENABLED); } + public static boolean getPforEnabled(Configuration configuration) { + return configuration.getBoolean(ENABLE_PFOR, ParquetProperties.DEFAULT_IS_PFOR_ENABLED); + } + public static int getMinRowCountForPageSizeCheck(Configuration configuration) { return configuration.getInt( MIN_ROW_COUNT_FOR_PAGE_SIZE_CHECK, ParquetProperties.DEFAULT_MINIMUM_RECORD_COUNT_FOR_CHECK); @@ -513,6 +521,7 @@ public RecordWriter getRecordWriter(Configuration conf, Path file, Comp .withDictionaryPageSize(getDictionaryPageSize(conf)) .withDictionaryEncoding(getEnableDictionary(conf)) .withByteStreamSplitEncoding(getByteStreamSplitEnabled(conf)) + .withPforEncoding(getPforEnabled(conf)) .withWriterVersion(getWriterVersion(conf)) .estimateRowCountForPageSizeCheck(getEstimatePageSizeCheck(conf)) .withMinRowCountForPageSizeCheck(getMinRowCountForPageSizeCheck(conf)) diff --git a/parquet-hadoop/src/test/java/org/apache/parquet/hadoop/TestPforConfiguration.java b/parquet-hadoop/src/test/java/org/apache/parquet/hadoop/TestPforConfiguration.java new file mode 100644 index 0000000000..3e161e5f54 --- /dev/null +++ b/parquet-hadoop/src/test/java/org/apache/parquet/hadoop/TestPforConfiguration.java @@ -0,0 +1,85 @@ +/* + * 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.hadoop; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import org.apache.hadoop.conf.Configuration; +import org.apache.parquet.column.ColumnDescriptor; +import org.apache.parquet.column.ParquetProperties; +import org.apache.parquet.schema.MessageType; +import org.apache.parquet.schema.MessageTypeParser; +import org.junit.Test; + +public class TestPforConfiguration { + + private static final MessageType SCHEMA = + MessageTypeParser.parseMessageType("message m { required int32 i; required double d; }"); + + private static ColumnDescriptor intColumn() { + return SCHEMA.getColumns().get(0); + } + + private static ColumnDescriptor doubleColumn() { + return SCHEMA.getColumns().get(1); + } + + @Test + public void testDefault() throws Exception { + Configuration conf = new Configuration(); + // PFOR is off unless a job asks for it + assertEquals(ParquetProperties.DEFAULT_IS_PFOR_ENABLED, ParquetOutputFormat.getPforEnabled(conf)); + } + + @Test + public void testTheKeyNameIsTheDocumentedOne() throws Exception { + // This string is the public surface; the class javadoc documents it + assertEquals("parquet.enable.pfor", ParquetOutputFormat.ENABLE_PFOR); + } + + @Test + public void testSetTrue() throws Exception { + Configuration conf = new Configuration(); + conf.setBoolean(ParquetOutputFormat.ENABLE_PFOR, true); + assertTrue(ParquetOutputFormat.getPforEnabled(conf)); + } + + @Test + public void testSetFalse() throws Exception { + Configuration conf = new Configuration(); + conf.setBoolean(ParquetOutputFormat.ENABLE_PFOR, false); + assertFalse(ParquetOutputFormat.getPforEnabled(conf)); + } + + @Test + public void testTheKeyReachesTheWriterProperties() throws Exception { + Configuration conf = new Configuration(); + conf.setBoolean(ParquetOutputFormat.ENABLE_PFOR, true); + + ParquetProperties props = ParquetProperties.builder() + .withPforEncoding(ParquetOutputFormat.getPforEnabled(conf)) + .build(); + + assertTrue(props.isPforEnabled(intColumn())); + // PFOR encodes INT32 and INT64 only, whatever the configuration says + assertFalse(props.isPforEnabled(doubleColumn())); + } +} From 0e68bce2aa460cd60f3bc155291462d6c5acd517 Mon Sep 17 00:00:00 2001 From: Prateek Gaur Date: Fri, 4 Sep 2026 17:03:36 +0000 Subject: [PATCH 12/14] Add an optional delta mode to the PFOR encoding A PFOR vector can now hold the differences between its successive values instead of the values, chosen per vector by costing both with the same model and keeping the cheaper one. Bit 7 of the bit width byte carries the choice, and a delta vector stores its own first value between the vector info and the packed residuals, so it still decodes without reading the vector before it. Differencing and the prefix sum are both modular, and exception values in a delta vector are differences, so the reader patches them in before summing. The decision runs a sampled estimate first and drops the mode where the estimate cannot beat the plain cost, which skips writing the differences out and searching them. The mode is on by default and can be turned off globally or per column with ParquetProperties.withPforDeltaEncoding; the writer keeps whichever mode costs fewer bits, so leaving it on cannot make a page larger. --- .../parquet/column/ParquetProperties.java | 43 ++ .../factory/DefaultV2ValuesWriterFactory.java | 6 +- .../column/values/pfor/PforConstants.java | 26 +- .../values/pfor/PforEncoderDecoder.java | 321 +++++++-- .../column/values/pfor/PforValuesReader.java | 20 +- .../values/pfor/PforValuesReaderForInt.java | 42 +- .../values/pfor/PforValuesReaderForLong.java | 42 +- .../column/values/pfor/PforValuesWriter.java | 182 +++-- .../values/pfor/PforAdversarialTest.java | 136 +++- .../column/values/pfor/PforDeltaModeTest.java | 672 ++++++++++++++++++ 10 files changed, 1347 insertions(+), 143 deletions(-) create mode 100644 parquet-column/src/test/java/org/apache/parquet/column/values/pfor/PforDeltaModeTest.java 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 4036d35e34..6ca2dea037 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 @@ -51,6 +51,7 @@ public class ParquetProperties { 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_PFOR_ENABLED = false; + public static final boolean DEFAULT_IS_PFOR_DELTA_ENABLED = true; 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; @@ -134,6 +135,7 @@ public static WriterVersion fromString(String name) { private final boolean pageWriteChecksumEnabled; private final ColumnProperty byteStreamSplitEnabled; private final ColumnProperty pforEnabled; + private final ColumnProperty pforDeltaEnabled; private final Map extraMetaData; private final ColumnProperty statistics; private final ColumnProperty sizeStatistics; @@ -167,6 +169,7 @@ private ParquetProperties(Builder builder) { this.pageWriteChecksumEnabled = builder.pageWriteChecksumEnabled; this.byteStreamSplitEnabled = builder.byteStreamSplitEnabled.build(); this.pforEnabled = builder.pforEnabled.build(); + this.pforDeltaEnabled = builder.pforDeltaEnabled.build(); this.extraMetaData = builder.extraMetaData; this.statistics = builder.statistics.build(); this.sizeStatistics = builder.sizeStatistics.build(); @@ -279,6 +282,17 @@ public boolean isPforEnabled(ColumnDescriptor column) { } } + /** + * Check whether a PFOR writer may encode a vector as the differences between its + * successive values. Only consulted where PFOR itself is enabled. + * + * @param column the column descriptor + * @return true if the PFOR delta mode is enabled for this column + */ + public boolean isPforDeltaEnabled(ColumnDescriptor column) { + return pforDeltaEnabled.getValue(column); + } + public ByteBufferAllocator getAllocator() { return allocator; } @@ -437,6 +451,7 @@ public static class Builder { private boolean pageWriteChecksumEnabled = DEFAULT_PAGE_WRITE_CHECKSUM_ENABLED; private final ColumnProperty.Builder byteStreamSplitEnabled; private final ColumnProperty.Builder pforEnabled; + private final ColumnProperty.Builder pforDeltaEnabled; private Map extraMetaData = new HashMap<>(); private final ColumnProperty.Builder statistics; private final ColumnProperty.Builder sizeStatistics; @@ -449,6 +464,7 @@ private Builder() { ? ByteStreamSplitMode.FLOATING_POINT : ByteStreamSplitMode.NONE); pforEnabled = ColumnProperty.builder().withDefaultValue(DEFAULT_IS_PFOR_ENABLED); + pforDeltaEnabled = ColumnProperty.builder().withDefaultValue(DEFAULT_IS_PFOR_DELTA_ENABLED); bloomFilterEnabled = ColumnProperty.builder().withDefaultValue(DEFAULT_BLOOM_FILTER_ENABLED); bloomFilterNDVs = ColumnProperty.builder().withDefaultValue(null); bloomFilterFPPs = ColumnProperty.builder().withDefaultValue(DEFAULT_BLOOM_FILTER_FPP); @@ -480,6 +496,7 @@ private Builder(ParquetProperties toCopy) { this.maxBloomFilterBytes = toCopy.maxBloomFilterBytes; this.byteStreamSplitEnabled = ColumnProperty.builder(toCopy.byteStreamSplitEnabled); this.pforEnabled = ColumnProperty.builder(toCopy.pforEnabled); + this.pforDeltaEnabled = ColumnProperty.builder(toCopy.pforDeltaEnabled); this.extraMetaData = toCopy.extraMetaData; this.statistics = ColumnProperty.builder(toCopy.statistics); this.sizeStatistics = ColumnProperty.builder(toCopy.sizeStatistics); @@ -580,6 +597,32 @@ public Builder withPforEncoding(String columnPath, boolean enable) { return this; } + /** + * Enable or disable the PFOR delta mode, in which a vector may be encoded as the + * differences between its successive values rather than as the values. Enabled by + * default: the writer costs both and keeps the cheaper, per vector, so leaving it on + * cannot make a page larger than leaving it off, only slower to write. + * + * @param enable whether the PFOR delta mode should be enabled + * @return this builder for method chaining. + */ + public Builder withPforDeltaEncoding(boolean enable) { + this.pforDeltaEnabled.withDefaultValue(enable); + return this; + } + + /** + * Enable or disable the PFOR delta mode for the specified column. + * + * @param columnPath the path of the column (dot-string) + * @param enable whether the PFOR delta mode should be enabled + * @return this builder for method chaining. + */ + public Builder withPforDeltaEncoding(String columnPath, boolean enable) { + this.pforDeltaEnabled.withValue(columnPath, enable); + return this; + } + /** * Set the Parquet format dictionary page size. * 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 b215e0fe14..e3f9c7827f 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 @@ -120,7 +120,8 @@ private ValuesWriter getInt32ValuesWriter(ColumnDescriptor path) { fallbackWriter = new PforValuesWriter.IntPforValuesWriter( parquetProperties.getInitialSlabSize(), parquetProperties.getPageSizeThreshold(), - parquetProperties.getAllocator()); + parquetProperties.getAllocator(), + parquetProperties.isPforDeltaEnabled(path)); } else if (parquetProperties.isByteStreamSplitEnabled(path)) { fallbackWriter = new ByteStreamSplitValuesWriter.IntegerByteStreamSplitValuesWriter( parquetProperties.getInitialSlabSize(), @@ -142,7 +143,8 @@ private ValuesWriter getInt64ValuesWriter(ColumnDescriptor path) { fallbackWriter = new PforValuesWriter.LongPforValuesWriter( parquetProperties.getInitialSlabSize(), parquetProperties.getPageSizeThreshold(), - parquetProperties.getAllocator()); + parquetProperties.getAllocator(), + parquetProperties.isPforDeltaEnabled(path)); } else if (parquetProperties.isByteStreamSplitEnabled(path)) { fallbackWriter = new ByteStreamSplitValuesWriter.LongByteStreamSplitValuesWriter( parquetProperties.getInitialSlabSize(), diff --git a/parquet-column/src/main/java/org/apache/parquet/column/values/pfor/PforConstants.java b/parquet-column/src/main/java/org/apache/parquet/column/values/pfor/PforConstants.java index a0f0f24222..992536b0b7 100644 --- a/parquet-column/src/main/java/org/apache/parquet/column/values/pfor/PforConstants.java +++ b/parquet-column/src/main/java/org/apache/parquet/column/values/pfor/PforConstants.java @@ -27,9 +27,15 @@ *

    *
  1. Subtracting the minimum value (Frame of Reference)
  2. *
  3. Choosing an optimal bit width via a cost model
  4. - *
  5. Bit-packing the deltas at the chosen width
  6. + *
  7. Bit-packing the residuals at the chosen width
  8. *
  9. Storing outlier values (exceptions) separately with their positions
  10. *
+ * + *

A writer may first replace the values of a vector with the differences + * between successive values -- the delta mode -- and run all of the above on + * those instead. The choice is recorded per vector in bit 7 of the bit width + * byte, and such a vector carries its own first value so that it still decodes + * without the vector before it. */ public final class PforConstants { @@ -52,9 +58,23 @@ private PforConstants() { // Maximum exceptions per vector (uint16) public static final int MAX_EXCEPTIONS = 65535; - // The bit width occupies bits 0..6 of its byte; bit 7 is reserved and must be - // masked off before the width is used or range-checked. + // The bit width occupies bits 0..6 of its byte and must be masked off before it + // is used or range-checked; bit 7 says the vector holds differences. + // + // The width takes seven bits rather than six because its range is 0..64 + // inclusive, and 64 does not fit in six: masking with six bits would read an + // INT64 vector whose residuals need the full 64 bits as width 0, which has no + // packed bytes and no exceptions, so the misreading looks like a constant + // vector and neither a size mismatch nor an error reveals it. public static final int BIT_WIDTH_MASK = 0x7F; + public static final int DELTA_FLAG = 0x80; + + // A delta vector stores its first value between its info block and its packed + // residuals, so its header is that much longer. + public static int vectorInfoSize(int valueByteWidth, boolean delta) { + int base = valueByteWidth == INT32_VALUE_BYTE_WIDTH ? INT32_VECTOR_INFO_SIZE : INT64_VECTOR_INFO_SIZE; + return delta ? base + valueByteWidth : base; + } // Per-vector metadata sizes in bytes // INT32: frame_of_reference(4) + bit_width(1) + num_exceptions(2) = 7 diff --git a/parquet-column/src/main/java/org/apache/parquet/column/values/pfor/PforEncoderDecoder.java b/parquet-column/src/main/java/org/apache/parquet/column/values/pfor/PforEncoderDecoder.java index 1c56eb38f4..28e0bc6388 100644 --- a/parquet-column/src/main/java/org/apache/parquet/column/values/pfor/PforEncoderDecoder.java +++ b/parquet-column/src/main/java/org/apache/parquet/column/values/pfor/PforEncoderDecoder.java @@ -26,7 +26,11 @@ * total_cost(b) = num_elements * b + num_exceptions(b) * (16 + value_bits) * * where {@code value_bits} is 32 for INT32 and 64 for INT64, and - * {@code num_exceptions(b)} is the count of deltas requiring more than {@code b} bits. + * {@code num_exceptions(b)} is the count of residuals requiring more than {@code b} bits. + * + *

The same model decides the delta mode: a vector is costed as it stands and + * again as the differences between its successive values, and the cheaper of the + * two wins. See {@link #chooseVectorPlanForInt}. */ public final class PforEncoderDecoder { @@ -38,13 +42,54 @@ private PforEncoderDecoder() { public static final class BitWidthResult { public final int bitWidth; public final int numExceptions; + /** What the winning width costs, in bits, under the cost model above. */ + public final long costBits; + + BitWidthResult(int bitWidth, int numExceptions, long costBits) { + this.bitWidth = bitWidth; + this.numExceptions = numExceptions; + this.costBits = costBits; + } + } + + /** + * How one vector is to be encoded: whether to difference it first, and the frame, + * width and exception count that follow from that choice. + * + *

The frame and start value are held as longs for both physical types; an INT32 + * writer narrows them back to int, which is lossless because they came from ints. + */ + public static final class VectorPlan { + public final boolean delta; + public final long frameOfReference; + /** The vector's first value, meaningful only when {@link #delta} is set. */ + public final long startValue; + + public final int bitWidth; + public final int numExceptions; + public final long costBits; - BitWidthResult(int bitWidth, int numExceptions) { + VectorPlan( + boolean delta, long frameOfReference, long startValue, int bitWidth, int numExceptions, long costBits) { + this.delta = delta; + this.frameOfReference = frameOfReference; + this.startValue = startValue; this.bitWidth = bitWidth; this.numExceptions = numExceptions; + this.costBits = costBits; } } + // Exception cost: position(16 bits) + one full-width value. + private static final long INT32_EXCEPTION_BITS = 16 + 32; + private static final long INT64_EXCEPTION_BITS = 16 + 64; + + /** + * Enough of a sample to place a distribution across the width bins, and few enough + * that the pass is a fraction of the one it is deciding against. + */ + private static final int DELTA_SAMPLE_TARGET = 128; + /** * Find the optimal bit width for packing INT32 unsigned deltas. * @@ -62,41 +107,7 @@ public static BitWidthResult findOptimalBitWidthForInt(int[] deltas, int numElem bitsHist[bitWidthForInt(deltas[i])]++; } - // Exception cost per exception: position(16 bits) + value(32 bits) = 48 bits - final long exceptionBitsPerValue = 16 + 32; - - long bestCost = Long.MAX_VALUE; - int bestBitWidth = 0; - int bestExceptions = 0; - - // exceptionsAbove[b] = number of deltas requiring > b bits - int exceptionsAbove = numElements; // at b=0, all nonzero deltas might be exceptions - // Actually: deltas requiring > 0 bits = all deltas with bitsRequired > 0 - // We need to track cumulative: exceptionsAbove starts at numElements - bitsHist[0] - // But let's compute it properly by starting from b=0. - // At b=0, only deltas requiring 0 bits (i.e., delta==0) are NOT exceptions. - // Correction: at candidate bit_width = b, values needing bitsRequired > b are exceptions. - // bitsRequired(0) = 0, so delta==0 needs 0 bits. At b=0, exceptions = values with bitsRequired > 0. - exceptionsAbove = numElements - bitsHist[0]; - - for (int b = 0; b <= 32; b++) { - long packingCost = (long) numElements * b; - long exceptionCost = (long) exceptionsAbove * exceptionBitsPerValue; - long totalCost = packingCost + exceptionCost; - - if (totalCost < bestCost) { - bestCost = totalCost; - bestBitWidth = b; - bestExceptions = exceptionsAbove; - } - - // Move to next candidate: values requiring exactly (b+1) bits are no longer exceptions - if (b < 32) { - exceptionsAbove -= bitsHist[b + 1]; - } - } - - return new BitWidthResult(bestBitWidth, bestExceptions); + return bestFromHistogram(bitsHist, 32, numElements, INT32_EXCEPTION_BITS); } /** @@ -113,32 +124,248 @@ public static BitWidthResult findOptimalBitWidthForLong(long[] deltas, int numEl bitsHist[bitWidthForLong(deltas[i])]++; } - // Exception cost per exception: position(16 bits) + value(64 bits) = 80 bits - final long exceptionBitsPerValue = 16 + 64; + return bestFromHistogram(bitsHist, 64, numElements, INT64_EXCEPTION_BITS); + } + /** + * Walk the candidate widths over a histogram of required widths and return the + * cheapest. + * + *

{@code bitsHist[b]} counts the residuals needing exactly {@code b} bits, so + * the residuals needing more than {@code b} -- the exceptions at that candidate -- + * are what remains above it, and one subtraction per step keeps that count. Ties + * keep the narrower width, since the first candidate to reach a cost wins. + * + * @param maxBits the physical type's width, the widest candidate + * @param exceptionBitsPerValue what one exception costs: its position and its value + */ + private static BitWidthResult bestFromHistogram( + int[] bitsHist, int maxBits, int numElements, long exceptionBitsPerValue) { long bestCost = Long.MAX_VALUE; int bestBitWidth = 0; int bestExceptions = 0; + // At candidate width b, residuals needing more than b bits are exceptions. + // bitsRequired(0) is 0, so at b = 0 that is everything except the zeros. int exceptionsAbove = numElements - bitsHist[0]; - for (int b = 0; b <= 64; b++) { - long packingCost = (long) numElements * b; - long exceptionCost = (long) exceptionsAbove * exceptionBitsPerValue; - long totalCost = packingCost + exceptionCost; - + for (int b = 0; b <= maxBits; b++) { + long totalCost = (long) numElements * b + (long) exceptionsAbove * exceptionBitsPerValue; if (totalCost < bestCost) { bestCost = totalCost; bestBitWidth = b; bestExceptions = exceptionsAbove; } - - if (b < 64) { + if (b < maxBits) { exceptionsAbove -= bitsHist[b + 1]; } } - return new BitWidthResult(bestBitWidth, bestExceptions); + return new BitWidthResult(bestBitWidth, bestExceptions, bestCost); + } + + /** + * Decide how to encode one INT32 vector: as it stands, or as its differences. + * + *

Both transforms are costed with the same model and the cheaper one wins, so the + * mode is a per-vector decision rather than a per-column one. It has to be: a column + * is rarely all one shape, and differencing costs bits on any stretch whose successive + * values are not close. + * + * @param values the vector + * @param numElements element count, greater than 0 + * @param deltaScratch scratch for numElements differences. On return it holds the + * differences if the plan chose the delta mode, and is clobbered either way. + * @param deltaEnabled whether the delta mode may be chosen at all + */ + public static VectorPlan chooseVectorPlanForInt( + int[] values, int numElements, int[] deltaScratch, boolean deltaEnabled) { + VectorPlan raw = searchForInt(values, numElements); + + // One element has no difference to take, and a vector already packing at width 0 + // cannot be improved on. + if (!deltaEnabled || numElements < 2 || raw.bitWidth == 0) { + return raw; + } + + // A delta vector carries its own first value, so it starts one full-width value + // behind whatever its differences pack to. + final long startValueBits = 32; + + // Estimate the mode before paying for it, and drop it here if the estimate cannot + // reach the incumbent. What that skips is the whole of the rest of the mode: the + // pass that writes the differences out, and the search over them. The estimate is + // deliberately loose, so it declines only where the two modes are more than a + // sampling error apart, which is where the choice matters least. + if (estimateDeltaCostBitsForInt(values, numElements) + startValueBits >= raw.costBits) { + return raw; + } + + computeDeltasForInt(values, numElements, deltaScratch); + VectorPlan delta = searchForInt(deltaScratch, numElements); + long deltaCost = delta.costBits + startValueBits; + if (deltaCost >= raw.costBits) { + return raw; + } + return new VectorPlan(true, delta.frameOfReference, values[0], delta.bitWidth, delta.numExceptions, deltaCost); + } + + /** Decide how to encode one INT64 vector. See {@link #chooseVectorPlanForInt}. */ + public static VectorPlan chooseVectorPlanForLong( + long[] values, int numElements, long[] deltaScratch, boolean deltaEnabled) { + VectorPlan raw = searchForLong(values, numElements); + + if (!deltaEnabled || numElements < 2 || raw.bitWidth == 0) { + return raw; + } + + final long startValueBits = 64; + if (estimateDeltaCostBitsForLong(values, numElements) + startValueBits >= raw.costBits) { + return raw; + } + + computeDeltasForLong(values, numElements, deltaScratch); + VectorPlan delta = searchForLong(deltaScratch, numElements); + long deltaCost = delta.costBits + startValueBits; + if (deltaCost >= raw.costBits) { + return raw; + } + return new VectorPlan(true, delta.frameOfReference, values[0], delta.bitWidth, delta.numExceptions, deltaCost); + } + + /** + * Take the frame of an INT32 vector and cost the widths over it, without writing the + * residuals out: they are needed once here, for their widths, and again by the caller + * only once the plan is settled. + */ + private static VectorPlan searchForInt(int[] source, int numElements) { + int frame = source[0]; + for (int i = 1; i < numElements; i++) { + if (source[i] < frame) { + frame = source[i]; + } + } + + int[] bitsHist = new int[33]; + for (int i = 0; i < numElements; i++) { + bitsHist[bitWidthForInt(source[i] - frame)]++; + } + + BitWidthResult best = bestFromHistogram(bitsHist, 32, numElements, INT32_EXCEPTION_BITS); + return new VectorPlan(false, frame, 0, best.bitWidth, best.numExceptions, best.costBits); + } + + /** See {@link #searchForInt}. */ + private static VectorPlan searchForLong(long[] source, int numElements) { + long frame = source[0]; + for (int i = 1; i < numElements; i++) { + if (source[i] < frame) { + frame = source[i]; + } + } + + int[] bitsHist = new int[65]; + for (int i = 0; i < numElements; i++) { + bitsHist[bitWidthForLong(source[i] - frame)]++; + } + + BitWidthResult best = bestFromHistogram(bitsHist, 64, numElements, INT64_EXCEPTION_BITS); + return new VectorPlan(false, frame, 0, best.bitWidth, best.numExceptions, best.costBits); + } + + /** + * Fill {@code deltas} with the backward differences of {@code values}. + * + *

{@code deltas[0]} is 0: the first value travels in the plan's start value, and + * giving slot 0 a real difference would mean either a shorter packed run or a value + * that is not a difference sitting in the width histogram. Zero costs the bit width + * and distorts nothing. + * + *

The subtraction wraps, which is what makes the round trip exact for a column + * that spans the type's range; the reader's prefix sum wraps the same way. A vector + * with negative differences needs nothing special: the frame is the minimum of the + * differences, so subtracting it makes every residual non-negative, the same + * mechanism a plain vector uses for negative values. + */ + public static void computeDeltasForInt(int[] values, int numElements, int[] deltas) { + deltas[0] = 0; + for (int i = 1; i < numElements; i++) { + deltas[i] = values[i] - values[i - 1]; + } + } + + /** See {@link #computeDeltasForInt}. */ + public static void computeDeltasForLong(long[] values, int numElements, long[] deltas) { + deltas[0] = 0; + for (int i = 1; i < numElements; i++) { + deltas[i] = values[i] - values[i - 1]; + } + } + + /** + * Estimate what packing the differences of an INT32 vector would cost, in bits. + * + *

The full decision needs the differences written out and searched, which is most + * of what encoding a vector costs. This reaches an answer good enough to decline the + * mode from a strided sample, without writing anything. + * + *

The sample is of widths, not of a span. A gate on the span of the differences + * was tried first and had to go: a sawtooth is a tight cluster of small positive + * differences with a handful of large negative ones, so its span is as wide as its + * raw span while its cost is a fraction of it. Feeding widths to the same cost model + * the search uses keeps that shape, because the model can trade a wide bin against a + * patch. + * + *

Zigzagging is what lets a histogram stand in for a frame search that has not + * run. Differences in [-k, k] zigzag into [0, 2k], and a frame at -k maps them onto + * the same [0, 2k], so for a range that straddles zero evenly the estimated width is + * the width the search would find. Where the range leans one way the estimate runs a + * bit or two wide. + */ + static long estimateDeltaCostBitsForInt(int[] values, int numElements) { + int stride = Math.max(1, numElements / DELTA_SAMPLE_TARGET); + int[] bitsHist = new int[33]; + int sampled = 0; + for (int i = stride; i < numElements; i += stride) { + bitsHist[bitWidthForInt(zigZagInt(values[i] - values[i - 1]))]++; + sampled++; + } + if (sampled == 0) { + return 0; + } + + long sampleCost = bestFromHistogram(bitsHist, 32, sampled, INT32_EXCEPTION_BITS).costBits; + // Scale to the whole vector. Both terms of the model are per element -- a width + // costs its bits every element, an exception costs its slot every time it occurs -- + // so the sample cost scales with the count. + return sampleCost * numElements / sampled; + } + + /** See {@link #estimateDeltaCostBitsForInt}. */ + static long estimateDeltaCostBitsForLong(long[] values, int numElements) { + int stride = Math.max(1, numElements / DELTA_SAMPLE_TARGET); + int[] bitsHist = new int[65]; + int sampled = 0; + for (int i = stride; i < numElements; i += stride) { + bitsHist[bitWidthForLong(zigZagLong(values[i] - values[i - 1]))]++; + sampled++; + } + if (sampled == 0) { + return 0; + } + + long sampleCost = bestFromHistogram(bitsHist, 64, sampled, INT64_EXCEPTION_BITS).costBits; + return sampleCost * numElements / sampled; + } + + /** Maps a signed value onto an unsigned one of about the same magnitude. */ + static int zigZagInt(int value) { + return (value << 1) ^ (value >> 31); + } + + /** See {@link #zigZagInt}. */ + static long zigZagLong(long value) { + return (value << 1) ^ (value >> 63); } /** diff --git a/parquet-column/src/main/java/org/apache/parquet/column/values/pfor/PforValuesReader.java b/parquet-column/src/main/java/org/apache/parquet/column/values/pfor/PforValuesReader.java index bf29e0f814..6936f4c0a1 100644 --- a/parquet-column/src/main/java/org/apache/parquet/column/values/pfor/PforValuesReader.java +++ b/parquet-column/src/main/java/org/apache/parquet/column/values/pfor/PforValuesReader.java @@ -139,7 +139,8 @@ protected int getVectorDataPosition(int vectorIdx) { * that remain. All three come off the wire and size every read and write that * follows, including the writes into the fixed-size decode buffers. * - * @param pos position of the packed values, that is, just past the vector info + * @param pos position of the packed values, that is, just past the vector info and, + * in a delta vector, just past the start value that follows it */ protected void checkVectorInfo(int pos, int bitWidth, int numExceptions, int vectorLen) { int maxBitWidth = valueByteWidth * Byte.SIZE; @@ -158,6 +159,23 @@ protected void checkVectorInfo(int pos, int bitWidth, int numExceptions, int vec } } + /** + * Checks that a delta vector's start value is inside the page. + * + *

These bytes need a bound of their own because the header bound was satisfied + * before the delta flag was known. Without it the start value is read from past the + * end of the page, and the residual bound is then checked from an offset that has + * already moved past the end. + * + * @param pos position of the start value, that is, just past the vector info + */ + protected void checkStartValueRoom(int pos) { + if (pos + valueByteWidth > vectorsData.limit()) { + throw new ParquetDecodingException("PFOR delta vector needs " + valueByteWidth + + " bytes for its start value but only " + (vectorsData.limit() - pos) + " remain"); + } + } + /** Exception positions index the decode buffer, so one past the end is a bad write. */ protected static void checkExceptionPosition(int position, int vectorLen) { if (position >= vectorLen) { diff --git a/parquet-column/src/main/java/org/apache/parquet/column/values/pfor/PforValuesReaderForInt.java b/parquet-column/src/main/java/org/apache/parquet/column/values/pfor/PforValuesReaderForInt.java index edc1bb9e0e..30d8bb7e3a 100644 --- a/parquet-column/src/main/java/org/apache/parquet/column/values/pfor/PforValuesReaderForInt.java +++ b/parquet-column/src/main/java/org/apache/parquet/column/values/pfor/PforValuesReaderForInt.java @@ -34,6 +34,7 @@ *

Per-vector format: *

  * PforVectorInfo (7B): frame_of_reference(4) + bit_width(1) + num_exceptions(2)
+ * StartValue: 4 bytes, only when bit 7 of bit_width is set
  * PackedValues: ceil(N * bit_width / 8) bytes
  * ExceptionPositions: num_exceptions * 2 bytes
  * ExceptionValues: num_exceptions * 4 bytes
@@ -44,7 +45,7 @@ public class PforValuesReaderForInt extends PforValuesReader {
   private int[] decodedValues;
 
   // Reusable per-vector decode buffers
-  private int[] deltasBuffer;
+  private int[] residualsBuffer;
   private int[] excPositionsBuffer;
   private byte[] unpackPadBuf;
   private int[] unpackTempBuf;
@@ -56,7 +57,7 @@ public PforValuesReaderForInt() {
   @Override
   protected void allocateDecodedBuffer(int capacity) {
     this.decodedValues = new int[capacity];
-    this.deltasBuffer = new int[capacity];
+    this.residualsBuffer = new int[capacity];
     this.excPositionsBuffer = new int[capacity];
     this.unpackPadBuf = new byte[Integer.SIZE]; // max bit width = 32 bytes
     this.unpackTempBuf = new int[8];
@@ -80,26 +81,37 @@ protected void decodeVector(int vectorIdx) {
 
     // Read PforVectorInfo (7 bytes)
     int frameOfReference = getIntLE(vectorsData, pos);
-    int bitWidth = vectorsData.get(pos + 4) & BIT_WIDTH_MASK;
+    int bitWidthByte = vectorsData.get(pos + 4) & 0xFF;
+    int bitWidth = bitWidthByte & BIT_WIDTH_MASK;
+    boolean delta = (bitWidthByte & DELTA_FLAG) != 0;
     int numExceptions = getShortLE(vectorsData, pos + 5) & 0xFFFF;
     pos += INT32_VECTOR_INFO_SIZE;
+
+    // A delta vector stores its own first value, which is what lets it decode without
+    // the vector before it -- the property the whole mode exists for.
+    int startValue = 0;
+    if (delta) {
+      checkStartValueRoom(pos);
+      startValue = getIntLE(vectorsData, pos);
+      pos += INT32_VALUE_BYTE_WIDTH;
+    }
     checkVectorInfo(pos, bitWidth, numExceptions, vectorLen);
 
-    // Unpack bit-packed deltas into reusable buffer
+    // Unpack bit-packed residuals into reusable buffer
     if (bitWidth > 0) {
-      pos = unpackIntsWithBytePacker(vectorsData, pos, deltasBuffer, vectorLen, bitWidth);
+      pos = unpackIntsWithBytePacker(vectorsData, pos, residualsBuffer, vectorLen, bitWidth);
     } else {
       for (int i = 0; i < vectorLen; i++) {
-        deltasBuffer[i] = 0;
+        residualsBuffer[i] = 0;
       }
     }
 
-    // Add frame of reference to reconstruct values
+    // Add frame of reference
     for (int i = 0; i < vectorLen; i++) {
-      decodedValues[i] = deltasBuffer[i] + frameOfReference;
+      decodedValues[i] = residualsBuffer[i] + frameOfReference;
     }
 
-    // Overwrite exception slots with their original values
+    // Overwrite exception slots with their unreduced values
     if (numExceptions > 0) {
       for (int e = 0; e < numExceptions; e++) {
         int position = getShortLE(vectorsData, pos) & 0xFFFF;
@@ -112,6 +124,18 @@ protected void decodeVector(int vectorIdx) {
         pos += Integer.BYTES;
       }
     }
+
+    // Everything above produced differences in a delta vector, so sum them. This has to
+    // come after the patch: an exception there is a difference like any other, and
+    // summing first would carry its zero placeholder into every value that follows it.
+    // The addition wraps, matching how the writer took the differences.
+    if (delta) {
+      int acc = startValue;
+      for (int i = 0; i < vectorLen; i++) {
+        acc += decodedValues[i];
+        decodedValues[i] = acc;
+      }
+    }
   }
 
   private int unpackIntsWithBytePacker(ByteBuffer buf, int pos, int[] output, int count, int bitWidth) {
diff --git a/parquet-column/src/main/java/org/apache/parquet/column/values/pfor/PforValuesReaderForLong.java b/parquet-column/src/main/java/org/apache/parquet/column/values/pfor/PforValuesReaderForLong.java
index 729d2c9c6c..4f6e41da6d 100644
--- a/parquet-column/src/main/java/org/apache/parquet/column/values/pfor/PforValuesReaderForLong.java
+++ b/parquet-column/src/main/java/org/apache/parquet/column/values/pfor/PforValuesReaderForLong.java
@@ -34,6 +34,7 @@
  * 

Per-vector format: *

  * PforVectorInfo (11B): frame_of_reference(8) + bit_width(1) + num_exceptions(2)
+ * StartValue: 8 bytes, only when bit 7 of bit_width is set
  * PackedValues: ceil(N * bit_width / 8) bytes
  * ExceptionPositions: num_exceptions * 2 bytes
  * ExceptionValues: num_exceptions * 8 bytes
@@ -44,7 +45,7 @@ public class PforValuesReaderForLong extends PforValuesReader {
   private long[] decodedValues;
 
   // Reusable per-vector decode buffers
-  private long[] deltasBuffer;
+  private long[] residualsBuffer;
   private int[] excPositionsBuffer;
   private byte[] unpackPadBuf;
   private long[] unpackTempBuf;
@@ -56,7 +57,7 @@ public PforValuesReaderForLong() {
   @Override
   protected void allocateDecodedBuffer(int capacity) {
     this.decodedValues = new long[capacity];
-    this.deltasBuffer = new long[capacity];
+    this.residualsBuffer = new long[capacity];
     this.excPositionsBuffer = new int[capacity];
     this.unpackPadBuf = new byte[Long.SIZE]; // max bit width = 64 bytes
     this.unpackTempBuf = new long[8];
@@ -80,26 +81,37 @@ protected void decodeVector(int vectorIdx) {
 
     // Read PforVectorInfo (11 bytes)
     long frameOfReference = getLongLE(vectorsData, pos);
-    int bitWidth = vectorsData.get(pos + 8) & BIT_WIDTH_MASK;
+    int bitWidthByte = vectorsData.get(pos + 8) & 0xFF;
+    int bitWidth = bitWidthByte & BIT_WIDTH_MASK;
+    boolean delta = (bitWidthByte & DELTA_FLAG) != 0;
     int numExceptions = getShortLE(vectorsData, pos + 9) & 0xFFFF;
     pos += INT64_VECTOR_INFO_SIZE;
+
+    // A delta vector stores its own first value, which is what lets it decode without
+    // the vector before it -- the property the whole mode exists for.
+    long startValue = 0;
+    if (delta) {
+      checkStartValueRoom(pos);
+      startValue = getLongLE(vectorsData, pos);
+      pos += INT64_VALUE_BYTE_WIDTH;
+    }
     checkVectorInfo(pos, bitWidth, numExceptions, vectorLen);
 
-    // Unpack bit-packed deltas into reusable buffer
+    // Unpack bit-packed residuals into reusable buffer
     if (bitWidth > 0) {
-      pos = unpackLongsWithBytePacker(vectorsData, pos, deltasBuffer, vectorLen, bitWidth);
+      pos = unpackLongsWithBytePacker(vectorsData, pos, residualsBuffer, vectorLen, bitWidth);
     } else {
       for (int i = 0; i < vectorLen; i++) {
-        deltasBuffer[i] = 0;
+        residualsBuffer[i] = 0;
       }
     }
 
-    // Add frame of reference to reconstruct values
+    // Add frame of reference
     for (int i = 0; i < vectorLen; i++) {
-      decodedValues[i] = deltasBuffer[i] + frameOfReference;
+      decodedValues[i] = residualsBuffer[i] + frameOfReference;
     }
 
-    // Overwrite exception slots with their original values
+    // Overwrite exception slots with their unreduced values
     if (numExceptions > 0) {
       for (int e = 0; e < numExceptions; e++) {
         int position = getShortLE(vectorsData, pos) & 0xFFFF;
@@ -112,6 +124,18 @@ protected void decodeVector(int vectorIdx) {
         pos += Long.BYTES;
       }
     }
+
+    // Everything above produced differences in a delta vector, so sum them. This has to
+    // come after the patch: an exception there is a difference like any other, and
+    // summing first would carry its zero placeholder into every value that follows it.
+    // The addition wraps, matching how the writer took the differences.
+    if (delta) {
+      long acc = startValue;
+      for (int i = 0; i < vectorLen; i++) {
+        acc += decodedValues[i];
+        decodedValues[i] = acc;
+      }
+    }
   }
 
   private int unpackLongsWithBytePacker(ByteBuffer buf, int pos, long[] output, int count, int bitWidth) {
diff --git a/parquet-column/src/main/java/org/apache/parquet/column/values/pfor/PforValuesWriter.java b/parquet-column/src/main/java/org/apache/parquet/column/values/pfor/PforValuesWriter.java
index 432714d57a..80f7c2de96 100644
--- a/parquet-column/src/main/java/org/apache/parquet/column/values/pfor/PforValuesWriter.java
+++ b/parquet-column/src/main/java/org/apache/parquet/column/values/pfor/PforValuesWriter.java
@@ -38,7 +38,12 @@
  *
  * 

PFOR compresses integer columns by subtracting the minimum value (FOR), * selecting an optimal bit width via a histogram-based cost model, bit-packing - * the deltas, and storing outlier values (exceptions) separately. + * the residuals, and storing outlier values (exceptions) separately. + * + *

Per vector, the writer costs the values as they stand and again as the + * differences between successive values, and keeps the cheaper of the two -- the + * delta mode. A vector in that mode sets bit 7 of its bit width byte and stores + * its own first value, so it still decodes without the vector before it. * *

Writing is incremental: values are buffered in a fixed-size vector buffer, * and each full vector is encoded and flushed to the output stream immediately. @@ -54,7 +59,7 @@ *

* *

Each vector contains interleaved: - * PforVectorInfo(7B/11B) + PackedValues + ExceptionPositions + ExceptionValues + * PforVectorInfo(7B/11B) + StartValue(0B/4B/8B) + PackedValues + ExceptionPositions + ExceptionValues */ public abstract class PforValuesWriter extends ValuesWriter { @@ -63,14 +68,18 @@ public abstract class PforValuesWriter extends ValuesWriter { protected final ByteBufferAllocator allocator; protected final int vectorSize; protected final int logVectorSize; + /** Whether a vector may be encoded as differences; see {@link PforEncoderDecoder#chooseVectorPlanForInt}. */ + protected final boolean deltaEnabled; - PforValuesWriter(int initialCapacity, int pageSize, ByteBufferAllocator allocator, int vectorSize) { + PforValuesWriter( + int initialCapacity, int pageSize, ByteBufferAllocator allocator, int vectorSize, boolean deltaEnabled) { PforConstants.validateVectorSize(vectorSize); this.initialCapacity = initialCapacity; this.pageSize = pageSize; this.allocator = allocator; this.vectorSize = vectorSize; this.logVectorSize = Integer.numberOfTrailingZeros(vectorSize); + this.deltaEnabled = deltaEnabled; } @Override @@ -87,7 +96,8 @@ public static class IntPforValuesWriter extends PforValuesWriter { private final List vectorByteSizes; // Reusable per-vector buffers to avoid allocations on every encodeAndFlushVector call - private final int[] deltasBuffer; + private final int[] residualsBuffer; + private final int[] deltaScratch; private final short[] excPosBuffer; private final int[] excValBuffer; private final byte[] metadataBuf; @@ -95,17 +105,32 @@ public static class IntPforValuesWriter extends PforValuesWriter { private final int[] packPadBuf; public IntPforValuesWriter(int initialCapacity, int pageSize, ByteBufferAllocator allocator) { - this(initialCapacity, pageSize, allocator, DEFAULT_VECTOR_SIZE); + this(initialCapacity, pageSize, allocator, DEFAULT_VECTOR_SIZE, true); + } + + public IntPforValuesWriter( + int initialCapacity, int pageSize, ByteBufferAllocator allocator, boolean deltaEnabled) { + this(initialCapacity, pageSize, allocator, DEFAULT_VECTOR_SIZE, deltaEnabled); } public IntPforValuesWriter(int initialCapacity, int pageSize, ByteBufferAllocator allocator, int vectorSize) { - super(initialCapacity, pageSize, allocator, vectorSize); + this(initialCapacity, pageSize, allocator, vectorSize, true); + } + + public IntPforValuesWriter( + int initialCapacity, + int pageSize, + ByteBufferAllocator allocator, + int vectorSize, + boolean deltaEnabled) { + super(initialCapacity, pageSize, allocator, vectorSize, deltaEnabled); this.vectorBuffer = new int[vectorSize]; this.bufferCount = 0; this.totalCount = 0; this.encodedVectors = new CapacityByteArrayOutputStream(initialCapacity, pageSize, allocator); this.vectorByteSizes = new ArrayList<>(); - this.deltasBuffer = new int[vectorSize]; + this.residualsBuffer = new int[vectorSize]; + this.deltaScratch = new int[vectorSize]; this.excPosBuffer = new short[vectorSize]; this.excValBuffer = new int[vectorSize]; this.metadataBuf = new byte[INT32_VECTOR_INFO_SIZE]; @@ -124,35 +149,33 @@ public void writeInteger(int v) { } private void encodeAndFlushVector(int vectorLen) { - // Find minimum value (frame of reference) - int minValue = vectorBuffer[0]; - for (int i = 1; i < vectorLen; i++) { - if (vectorBuffer[i] < minValue) { - minValue = vectorBuffer[i]; - } - } + PforEncoderDecoder.VectorPlan plan = + PforEncoderDecoder.chooseVectorPlanForInt(vectorBuffer, vectorLen, deltaScratch, deltaEnabled); + + // In the delta mode everything below runs on the differences the plan left in the + // scratch buffer, and the vector's first value travels in its header instead. + int[] source = plan.delta ? deltaScratch : vectorBuffer; + int frameOfReference = (int) plan.frameOfReference; + int bitWidth = plan.bitWidth; + int numExceptions = plan.numExceptions; - // Compute unsigned deltas into reusable buffer for (int i = 0; i < vectorLen; i++) { - deltasBuffer[i] = vectorBuffer[i] - minValue; + residualsBuffer[i] = source[i] - frameOfReference; } - // Find optimal bit width via cost model - PforEncoderDecoder.BitWidthResult result = - PforEncoderDecoder.findOptimalBitWidthForInt(deltasBuffer, vectorLen); - int bitWidth = result.bitWidth; - int numExceptions = result.numExceptions; - - // Collect exceptions: values whose delta doesn't fit in bitWidth bits + // Collect exceptions: residuals that don't fit in bitWidth bits int excIdx = 0; if (numExceptions > 0) { int mask = (bitWidth == 32) ? -1 : (1 << bitWidth) - 1; for (int i = 0; i < vectorLen; i++) { - if (Integer.compareUnsigned(deltasBuffer[i], mask) > 0) { + if (Integer.compareUnsigned(residualsBuffer[i], mask) > 0) { excPosBuffer[excIdx] = (short) i; - excValBuffer[excIdx] = vectorBuffer[i]; + // Never a residual: what the packed stream would have carried had it fitted, + // which in a delta vector is the difference. The reader patches it in before + // the prefix sum, so a patched difference is summed like any other. + excValBuffer[excIdx] = source[i]; excIdx++; - deltasBuffer[i] = 0; + residualsBuffer[i] = 0; } } } @@ -160,18 +183,28 @@ private void encodeAndFlushVector(int vectorLen) { long startSize = encodedVectors.size(); // PforVectorInfo: frame_of_reference(4) + bit_width(1) + num_exceptions(2) = 7B - metadataBuf[0] = (byte) (minValue & 0xFF); - metadataBuf[1] = (byte) ((minValue >>> 8) & 0xFF); - metadataBuf[2] = (byte) ((minValue >>> 16) & 0xFF); - metadataBuf[3] = (byte) ((minValue >>> 24) & 0xFF); - metadataBuf[4] = (byte) bitWidth; + metadataBuf[0] = (byte) (frameOfReference & 0xFF); + metadataBuf[1] = (byte) ((frameOfReference >>> 8) & 0xFF); + metadataBuf[2] = (byte) ((frameOfReference >>> 16) & 0xFF); + metadataBuf[3] = (byte) ((frameOfReference >>> 24) & 0xFF); + metadataBuf[4] = (byte) (bitWidth | (plan.delta ? DELTA_FLAG : 0)); metadataBuf[5] = (byte) (numExceptions & 0xFF); metadataBuf[6] = (byte) ((numExceptions >>> 8) & 0xFF); encodedVectors.write(metadataBuf, 0, INT32_VECTOR_INFO_SIZE); - // Pack deltas + // The start value sits between the info block and the packed residuals + if (plan.delta) { + int startValue = (int) plan.startValue; + metadataBuf[0] = (byte) (startValue & 0xFF); + metadataBuf[1] = (byte) ((startValue >>> 8) & 0xFF); + metadataBuf[2] = (byte) ((startValue >>> 16) & 0xFF); + metadataBuf[3] = (byte) ((startValue >>> 24) & 0xFF); + encodedVectors.write(metadataBuf, 0, INT32_VALUE_BYTE_WIDTH); + } + + // Pack residuals if (bitWidth > 0) { - packIntsWithBytePacker(deltasBuffer, vectorLen, bitWidth); + packIntsWithBytePacker(residualsBuffer, vectorLen, bitWidth); } // Exception positions then values @@ -289,7 +322,8 @@ public static class LongPforValuesWriter extends PforValuesWriter { private final List vectorByteSizes; // Reusable per-vector buffers - private final long[] deltasBuffer; + private final long[] residualsBuffer; + private final long[] deltaScratch; private final short[] excPosBuffer; private final long[] excValBuffer; private final byte[] metadataBuf; @@ -297,17 +331,32 @@ public static class LongPforValuesWriter extends PforValuesWriter { private final long[] packPadBuf; public LongPforValuesWriter(int initialCapacity, int pageSize, ByteBufferAllocator allocator) { - this(initialCapacity, pageSize, allocator, DEFAULT_VECTOR_SIZE); + this(initialCapacity, pageSize, allocator, DEFAULT_VECTOR_SIZE, true); + } + + public LongPforValuesWriter( + int initialCapacity, int pageSize, ByteBufferAllocator allocator, boolean deltaEnabled) { + this(initialCapacity, pageSize, allocator, DEFAULT_VECTOR_SIZE, deltaEnabled); } public LongPforValuesWriter(int initialCapacity, int pageSize, ByteBufferAllocator allocator, int vectorSize) { - super(initialCapacity, pageSize, allocator, vectorSize); + this(initialCapacity, pageSize, allocator, vectorSize, true); + } + + public LongPforValuesWriter( + int initialCapacity, + int pageSize, + ByteBufferAllocator allocator, + int vectorSize, + boolean deltaEnabled) { + super(initialCapacity, pageSize, allocator, vectorSize, deltaEnabled); this.vectorBuffer = new long[vectorSize]; this.bufferCount = 0; this.totalCount = 0; this.encodedVectors = new CapacityByteArrayOutputStream(initialCapacity, pageSize, allocator); this.vectorByteSizes = new ArrayList<>(); - this.deltasBuffer = new long[vectorSize]; + this.residualsBuffer = new long[vectorSize]; + this.deltaScratch = new long[vectorSize]; this.excPosBuffer = new short[vectorSize]; this.excValBuffer = new long[vectorSize]; this.metadataBuf = new byte[INT64_VECTOR_INFO_SIZE]; @@ -326,31 +375,27 @@ public void writeLong(long v) { } private void encodeAndFlushVector(int vectorLen) { - long minValue = vectorBuffer[0]; - for (int i = 1; i < vectorLen; i++) { - if (vectorBuffer[i] < minValue) { - minValue = vectorBuffer[i]; - } - } + PforEncoderDecoder.VectorPlan plan = + PforEncoderDecoder.chooseVectorPlanForLong(vectorBuffer, vectorLen, deltaScratch, deltaEnabled); + + long[] source = plan.delta ? deltaScratch : vectorBuffer; + long frameOfReference = plan.frameOfReference; + int bitWidth = plan.bitWidth; + int numExceptions = plan.numExceptions; for (int i = 0; i < vectorLen; i++) { - deltasBuffer[i] = vectorBuffer[i] - minValue; + residualsBuffer[i] = source[i] - frameOfReference; } - PforEncoderDecoder.BitWidthResult result = - PforEncoderDecoder.findOptimalBitWidthForLong(deltasBuffer, vectorLen); - int bitWidth = result.bitWidth; - int numExceptions = result.numExceptions; - int excIdx = 0; if (numExceptions > 0) { long mask = (bitWidth == 64) ? -1L : (1L << bitWidth) - 1L; for (int i = 0; i < vectorLen; i++) { - if (Long.compareUnsigned(deltasBuffer[i], mask) > 0) { + if (Long.compareUnsigned(residualsBuffer[i], mask) > 0) { excPosBuffer[excIdx] = (short) i; - excValBuffer[excIdx] = vectorBuffer[i]; + excValBuffer[excIdx] = source[i]; excIdx++; - deltasBuffer[i] = 0; + residualsBuffer[i] = 0; } } } @@ -358,21 +403,34 @@ private void encodeAndFlushVector(int vectorLen) { long startSize = encodedVectors.size(); // PforVectorInfo: frame_of_reference(8) + bit_width(1) + num_exceptions(2) = 11B - metadataBuf[0] = (byte) (minValue & 0xFF); - metadataBuf[1] = (byte) ((minValue >>> 8) & 0xFF); - metadataBuf[2] = (byte) ((minValue >>> 16) & 0xFF); - metadataBuf[3] = (byte) ((minValue >>> 24) & 0xFF); - metadataBuf[4] = (byte) ((minValue >>> 32) & 0xFF); - metadataBuf[5] = (byte) ((minValue >>> 40) & 0xFF); - metadataBuf[6] = (byte) ((minValue >>> 48) & 0xFF); - metadataBuf[7] = (byte) ((minValue >>> 56) & 0xFF); - metadataBuf[8] = (byte) bitWidth; + metadataBuf[0] = (byte) (frameOfReference & 0xFF); + metadataBuf[1] = (byte) ((frameOfReference >>> 8) & 0xFF); + metadataBuf[2] = (byte) ((frameOfReference >>> 16) & 0xFF); + metadataBuf[3] = (byte) ((frameOfReference >>> 24) & 0xFF); + metadataBuf[4] = (byte) ((frameOfReference >>> 32) & 0xFF); + metadataBuf[5] = (byte) ((frameOfReference >>> 40) & 0xFF); + metadataBuf[6] = (byte) ((frameOfReference >>> 48) & 0xFF); + metadataBuf[7] = (byte) ((frameOfReference >>> 56) & 0xFF); + metadataBuf[8] = (byte) (bitWidth | (plan.delta ? DELTA_FLAG : 0)); metadataBuf[9] = (byte) (numExceptions & 0xFF); metadataBuf[10] = (byte) ((numExceptions >>> 8) & 0xFF); encodedVectors.write(metadataBuf, 0, INT64_VECTOR_INFO_SIZE); + if (plan.delta) { + long startValue = plan.startValue; + metadataBuf[0] = (byte) (startValue & 0xFF); + metadataBuf[1] = (byte) ((startValue >>> 8) & 0xFF); + metadataBuf[2] = (byte) ((startValue >>> 16) & 0xFF); + metadataBuf[3] = (byte) ((startValue >>> 24) & 0xFF); + metadataBuf[4] = (byte) ((startValue >>> 32) & 0xFF); + metadataBuf[5] = (byte) ((startValue >>> 40) & 0xFF); + metadataBuf[6] = (byte) ((startValue >>> 48) & 0xFF); + metadataBuf[7] = (byte) ((startValue >>> 56) & 0xFF); + encodedVectors.write(metadataBuf, 0, INT64_VALUE_BYTE_WIDTH); + } + if (bitWidth > 0) { - packLongsWithBytePacker(deltasBuffer, vectorLen, bitWidth); + packLongsWithBytePacker(residualsBuffer, vectorLen, bitWidth); } if (numExceptions > 0) { diff --git a/parquet-column/src/test/java/org/apache/parquet/column/values/pfor/PforAdversarialTest.java b/parquet-column/src/test/java/org/apache/parquet/column/values/pfor/PforAdversarialTest.java index c8a632f426..151641576a 100644 --- a/parquet-column/src/test/java/org/apache/parquet/column/values/pfor/PforAdversarialTest.java +++ b/parquet-column/src/test/java/org/apache/parquet/column/values/pfor/PforAdversarialTest.java @@ -18,7 +18,6 @@ */ package org.apache.parquet.column.values.pfor; -import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertThrows; import static org.junit.Assert.assertTrue; @@ -134,6 +133,85 @@ private static byte[] outlierLongPage() throws Exception { } } + // A delta vector has to be worth choosing before the writer will write one: over 64 + // values a step of 1000 takes the width from 16 bits to 10, which more than pays for + // the 32-bit start value. + private static final int DELTA_VECTOR_LEN = 64; + + private static byte[] deltaIntPage() throws Exception { + PforValuesWriter.IntPforValuesWriter writer = null; + try { + writer = new PforValuesWriter.IntPforValuesWriter( + 512, 512, new DirectByteBufferAllocator(), DELTA_VECTOR_LEN); + for (int i = 0; i < DELTA_VECTOR_LEN; i++) { + writer.writeInteger(1_000_000 + i * 1000); + } + return requireDeltaVector(toBytes(writer.getBytes()), PforConstants.INT32_VECTOR_INFO_SIZE); + } finally { + if (writer != null) { + writer.reset(); + writer.close(); + } + } + } + + private static byte[] deltaLongPage() throws Exception { + PforValuesWriter.LongPforValuesWriter writer = null; + try { + writer = new PforValuesWriter.LongPforValuesWriter( + 512, 512, new DirectByteBufferAllocator(), DELTA_VECTOR_LEN); + for (int i = 0; i < DELTA_VECTOR_LEN; i++) { + writer.writeLong(1_700_000_000_000L + (long) i * 100_000); + } + return requireDeltaVector(toBytes(writer.getBytes()), PforConstants.INT64_VECTOR_INFO_SIZE); + } finally { + if (writer != null) { + writer.reset(); + writer.close(); + } + } + } + + // A delta vector with one difference far outside the cluster, so it carries an + // exception whose position the tests below can corrupt. + private static byte[] deltaOutlierIntPage() throws Exception { + PforValuesWriter.IntPforValuesWriter writer = null; + try { + writer = new PforValuesWriter.IntPforValuesWriter( + 512, 512, new DirectByteBufferAllocator(), DELTA_VECTOR_LEN); + for (int i = 0; i < DELTA_VECTOR_LEN; i++) { + writer.writeInteger(500 + i * 3 + (i >= 40 ? 5_000_000 : 0)); + } + return requireDeltaVector(toBytes(writer.getBytes()), PforConstants.INT32_VECTOR_INFO_SIZE); + } finally { + if (writer != null) { + writer.reset(); + writer.close(); + } + } + } + + // The tests that corrupt a delta vector only mean something if the writer chose the + // mode in the first place. + private static byte[] requireDeltaVector(byte[] page, int vectorInfoSize) { + int bitWidthByte = page[bitWidthOffset(vectorInfoSize)] & 0xFF; + if ((bitWidthByte & PforConstants.DELTA_FLAG) == 0) { + fail("expected the writer to choose the delta mode for this vector"); + } + return page; + } + + private static int numExceptionsOfFirstVector(byte[] page) { + return shortLE(page, numExceptionsOffset(PforConstants.INT32_VECTOR_INFO_SIZE)); + } + + // Past the vector info, the start value, and the packed residuals. + private static int deltaExceptionPositionOffset(byte[] page, int vectorInfoSize) { + int bitWidth = page[bitWidthOffset(vectorInfoSize)] & PforConstants.BIT_WIDTH_MASK; + int packedBytes = (DELTA_VECTOR_LEN * bitWidth + 7) / 8; + return VECTOR_START + vectorInfoSize + Integer.BYTES + packedBytes; + } + private static byte[] toBytes(BytesInput bytes) throws Exception { ByteBuffer bb = bytes.toByteBuffer(); byte[] out = new byte[bb.remaining()]; @@ -385,19 +463,57 @@ public void rejectsExceptionValuesTruncated() throws Exception { assertThrows(ParquetDecodingException.class, () -> initIntReader(bad, OUTLIER_VECTOR_LEN)); } - // Bit 7 of the bit width byte is reserved, so a writer that sets it must not - // change what a reader decodes. + // Bit 7 of the bit width byte is the delta flag, so a reader cannot ignore it: with + // the bit set, the four bytes after the vector info are the start value and the + // residuals begin further along. This page has no room for that, and saying so is + // the only safe reading -- decoding it as if the bit were absent would silently + // return values the writer never wrote. @Test - public void ignoresReservedBitInBitWidth() throws Exception { + public void rejectsDeltaFlagOnAVectorWithoutRoomForIt() throws Exception { byte[] page = outlierIntPage(); int at = bitWidthOffset(PforConstants.INT32_VECTOR_INFO_SIZE); - byte[] withReservedBit = mutate(page, at, (byte) (page[at] | 0x80)); + byte[] withDeltaFlag = mutate(page, at, (byte) (page[at] | PforConstants.DELTA_FLAG)); + assertThrows(ParquetDecodingException.class, () -> initIntReader(withDeltaFlag, OUTLIER_VECTOR_LEN)); + } - PforValuesReaderForInt reader = new PforValuesReaderForInt(); - reader.initFromPage(OUTLIER_VECTOR_LEN, ByteBufferInputStream.wrap(ByteBuffer.wrap(withReservedBit))); - for (int expected : OUTLIER_INTS) { - assertEquals(expected, reader.readInteger()); - } + // A delta vector's start value is bounded separately from its residuals, because the + // header bound was checked before the flag was known. + @Test + public void rejectsDeltaVectorWithTruncatedStartValue() throws Exception { + byte[] page = deltaIntPage(); + // Keep the vector info and two of the start value's four bytes. + byte[] bad = truncate(page, VECTOR_START + PforConstants.INT32_VECTOR_INFO_SIZE + 2); + ParquetDecodingException e = + assertThrows(ParquetDecodingException.class, () -> initIntReader(bad, DELTA_VECTOR_LEN)); + assertTrue(e.getMessage(), e.getMessage().contains("start value")); + } + + @Test + public void rejectsDeltaVectorWithTruncatedStartValueLong() throws Exception { + byte[] page = deltaLongPage(); + byte[] bad = truncate(page, VECTOR_START + PforConstants.INT64_VECTOR_INFO_SIZE + 3); + ParquetDecodingException e = + assertThrows(ParquetDecodingException.class, () -> initLongReader(bad, DELTA_VECTOR_LEN)); + assertTrue(e.getMessage(), e.getMessage().contains("start value")); + } + + // Nothing stops a corrupt page from claiming a width the value type cannot hold, and + // the flag must not smuggle one past the check. + @Test + public void rejectsBitWidthAboveValueWidthInADeltaVector() throws Exception { + byte[] page = deltaIntPage(); + int at = bitWidthOffset(PforConstants.INT32_VECTOR_INFO_SIZE); + byte[] bad = mutate(page, at, (byte) (PforConstants.DELTA_FLAG | 33)); + assertThrows(ParquetDecodingException.class, () -> initIntReader(bad, DELTA_VECTOR_LEN)); + } + + @Test + public void rejectsExceptionPositionPastEndOfDeltaVector() throws Exception { + byte[] page = deltaOutlierIntPage(); + assertTrue("the outlier is stored as an exception", numExceptionsOfFirstVector(page) >= 1); + int at = deltaExceptionPositionOffset(page, PforConstants.INT32_VECTOR_INFO_SIZE); + byte[] bad = putShortLE(page, at, 100); + assertThrows(ParquetDecodingException.class, () -> initIntReader(bad, DELTA_VECTOR_LEN)); } // --------------------------------------------------------------------------- diff --git a/parquet-column/src/test/java/org/apache/parquet/column/values/pfor/PforDeltaModeTest.java b/parquet-column/src/test/java/org/apache/parquet/column/values/pfor/PforDeltaModeTest.java new file mode 100644 index 0000000000..46ffc85e44 --- /dev/null +++ b/parquet-column/src/test/java/org/apache/parquet/column/values/pfor/PforDeltaModeTest.java @@ -0,0 +1,672 @@ +/* + * 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.pfor; + +import static org.apache.parquet.column.values.pfor.PforConstants.BIT_WIDTH_MASK; +import static org.apache.parquet.column.values.pfor.PforConstants.DELTA_FLAG; +import static org.apache.parquet.column.values.pfor.PforConstants.INT32_VECTOR_INFO_SIZE; +import static org.apache.parquet.column.values.pfor.PforConstants.INT64_VECTOR_INFO_SIZE; +import static org.apache.parquet.column.values.pfor.PforConstants.PFOR_HEADER_SIZE; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import java.nio.ByteBuffer; +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.ColumnDescriptor; +import org.apache.parquet.column.ParquetProperties; +import org.apache.parquet.column.values.ValuesWriter; +import org.apache.parquet.schema.MessageType; +import org.apache.parquet.schema.MessageTypeParser; +import org.junit.Test; + +/** + * Tests for the PFOR delta mode: the writer's per-vector choice between packing the values + * and packing their differences, and the reader's prefix sum over the latter. + * + *

The mode is visible on the wire in bit 7 of a vector's bit width byte and in the start + * value that follows the vector info when that bit is set, so the tests here assert on those + * bytes as well as on round trips. + */ +public class PforDeltaModeTest { + + // --------------------------------------------------------------------------- + // Page building and reading + // --------------------------------------------------------------------------- + + private static byte[] intPage(int[] values, int vectorSize, boolean deltaEnabled) throws Exception { + PforValuesWriter.IntPforValuesWriter writer = null; + try { + int cap = Math.max(1024, values.length * 8); + writer = new PforValuesWriter.IntPforValuesWriter( + cap, cap, new DirectByteBufferAllocator(), vectorSize, deltaEnabled); + for (int v : values) { + writer.writeInteger(v); + } + return toBytes(writer.getBytes()); + } finally { + if (writer != null) { + writer.reset(); + writer.close(); + } + } + } + + private static byte[] longPage(long[] values, int vectorSize, boolean deltaEnabled) throws Exception { + PforValuesWriter.LongPforValuesWriter writer = null; + try { + int cap = Math.max(1024, values.length * 16); + writer = new PforValuesWriter.LongPforValuesWriter( + cap, cap, new DirectByteBufferAllocator(), vectorSize, deltaEnabled); + for (long v : values) { + writer.writeLong(v); + } + return toBytes(writer.getBytes()); + } finally { + if (writer != null) { + writer.reset(); + writer.close(); + } + } + } + + private static byte[] toBytes(BytesInput bytes) throws Exception { + ByteBuffer bb = bytes.toByteBuffer(); + byte[] out = new byte[bb.remaining()]; + bb.duplicate().get(out); + return out; + } + + private static int[] decodeInts(byte[] page, int count) throws Exception { + PforValuesReaderForInt reader = new PforValuesReaderForInt(); + reader.initFromPage(count, ByteBufferInputStream.wrap(ByteBuffer.wrap(page))); + int[] out = new int[count]; + for (int i = 0; i < count; i++) { + out[i] = reader.readInteger(); + } + return out; + } + + private static long[] decodeLongs(byte[] page, int count) throws Exception { + PforValuesReaderForLong reader = new PforValuesReaderForLong(); + reader.initFromPage(count, ByteBufferInputStream.wrap(ByteBuffer.wrap(page))); + long[] out = new long[count]; + for (int i = 0; i < count; i++) { + out[i] = reader.readLong(); + } + return out; + } + + private static void assertIntRoundTrip(int[] values, int vectorSize, boolean deltaEnabled) throws Exception { + int[] decoded = decodeInts(intPage(values, vectorSize, deltaEnabled), values.length); + for (int i = 0; i < values.length; i++) { + assertEquals("value at " + i, values[i], decoded[i]); + } + } + + private static void assertLongRoundTrip(long[] values, int vectorSize, boolean deltaEnabled) throws Exception { + long[] decoded = decodeLongs(longPage(values, vectorSize, deltaEnabled), values.length); + for (int i = 0; i < values.length; i++) { + assertEquals("value at " + i, values[i], decoded[i]); + } + } + + // --------------------------------------------------------------------------- + // Wire inspection. Offsets in the page are relative to the start of the offset + // array, which begins right after the fixed header. + // --------------------------------------------------------------------------- + + private static int intLE(byte[] page, int pos) { + return (page[pos] & 0xFF) + | ((page[pos + 1] & 0xFF) << 8) + | ((page[pos + 2] & 0xFF) << 16) + | ((page[pos + 3] & 0xFF) << 24); + } + + private static long longLE(byte[] page, int pos) { + return (intLE(page, pos) & 0xFFFFFFFFL) | ((long) intLE(page, pos + 4) << 32); + } + + private static int vectorPos(byte[] page, int vectorIdx) { + return PFOR_HEADER_SIZE + intLE(page, PFOR_HEADER_SIZE + vectorIdx * Integer.BYTES); + } + + private static int bitWidthByte(byte[] page, int vectorIdx, int vectorInfoSize) { + return page[vectorPos(page, vectorIdx) + vectorInfoSize - 3] & 0xFF; + } + + private static boolean intDeltaFlag(byte[] page, int vectorIdx) { + return (bitWidthByte(page, vectorIdx, INT32_VECTOR_INFO_SIZE) & DELTA_FLAG) != 0; + } + + private static boolean longDeltaFlag(byte[] page, int vectorIdx) { + return (bitWidthByte(page, vectorIdx, INT64_VECTOR_INFO_SIZE) & DELTA_FLAG) != 0; + } + + private static int intNumExceptions(byte[] page, int vectorIdx) { + int pos = vectorPos(page, vectorIdx) + INT32_VECTOR_INFO_SIZE - 2; + return (page[pos] & 0xFF) | ((page[pos + 1] & 0xFF) << 8); + } + + private static int longNumExceptions(byte[] page, int vectorIdx) { + int pos = vectorPos(page, vectorIdx) + INT64_VECTOR_INFO_SIZE - 2; + return (page[pos] & 0xFF) | ((page[pos + 1] & 0xFF) << 8); + } + + // --------------------------------------------------------------------------- + // Data shapes + // --------------------------------------------------------------------------- + + private static int[] monotoneInts(int count, int base, int step) { + int[] values = new int[count]; + for (int i = 0; i < count; i++) { + values[i] = base + i * step; + } + return values; + } + + private static long[] monotoneLongs(int count, long base, long step) { + long[] values = new long[count]; + for (int i = 0; i < count; i++) { + values[i] = base + i * step; + } + return values; + } + + // --------------------------------------------------------------------------- + // The mode is chosen where it pays, and shows up on the wire + // --------------------------------------------------------------------------- + + @Test + public void monotoneIntsAreWrittenAsDifferences() throws Exception { + int[] values = monotoneInts(1024, 1_000_000, 7); + byte[] page = intPage(values, 1024, true); + + assertTrue("delta flag", intDeltaFlag(page, 0)); + // Bit 7 is the flag, so the width has to be read out from under it. + assertEquals(3, bitWidthByte(page, 0, INT32_VECTOR_INFO_SIZE) & BIT_WIDTH_MASK); + // The start value sits between the vector info and the packed residuals. + assertEquals(values[0], intLE(page, vectorPos(page, 0) + INT32_VECTOR_INFO_SIZE)); + assertIntRoundTrip(values, 1024, true); + + byte[] plain = intPage(values, 1024, false); + assertFalse("delta declined when disabled", intDeltaFlag(plain, 0)); + assertTrue("differences pack smaller: " + page.length + " vs " + plain.length, page.length < plain.length); + } + + @Test + public void monotoneLongsAreWrittenAsDifferences() throws Exception { + long[] values = monotoneLongs(1024, 1_700_000_000_000L, 7); + byte[] page = longPage(values, 1024, true); + + assertTrue("delta flag", longDeltaFlag(page, 0)); + assertEquals(3, bitWidthByte(page, 0, INT64_VECTOR_INFO_SIZE) & BIT_WIDTH_MASK); + assertEquals(values[0], longLE(page, vectorPos(page, 0) + INT64_VECTOR_INFO_SIZE)); + assertLongRoundTrip(values, 1024, true); + + byte[] plain = longPage(values, 1024, false); + assertFalse(longDeltaFlag(plain, 0)); + assertTrue("differences pack smaller: " + page.length + " vs " + plain.length, page.length < plain.length); + } + + @Test + public void descendingValuesAreWrittenAsDifferences() throws Exception { + // Every difference is negative, so the frame of reference is negative and the + // residuals are what the reader adds it back to before summing. + int[] values = monotoneInts(1024, 5_000_000, -11); + byte[] page = intPage(values, 1024, true); + assertTrue(intDeltaFlag(page, 0)); + assertIntRoundTrip(values, 1024, true); + } + + @Test + public void nearMonotoneValuesRoundTrip() throws Exception { + Random rnd = new Random(1234); + int[] values = new int[2000]; + values[0] = 42; + for (int i = 1; i < values.length; i++) { + values[i] = values[i - 1] + rnd.nextInt(40) - 5; + } + assertIntRoundTrip(values, 1024, true); + } + + @Test + public void modeIsDecidedPerVector() throws Exception { + // One monotone vector followed by one that is not: the choice has to differ + // between them, which is why the flag lives in the vector info. + Random rnd = new Random(99); + int[] values = new int[128]; + for (int i = 0; i < 64; i++) { + values[i] = 500_000 + i * 3; + } + for (int i = 64; i < 128; i++) { + values[i] = rnd.nextInt(); + } + + byte[] page = intPage(values, 64, true); + assertTrue("monotone vector uses the mode", intDeltaFlag(page, 0)); + assertFalse("random vector does not", intDeltaFlag(page, 1)); + assertIntRoundTrip(values, 64, true); + } + + @Test + public void randomValuesDeclineTheMode() throws Exception { + Random rnd = new Random(7); + int[] values = new int[1024]; + for (int i = 0; i < values.length; i++) { + values[i] = rnd.nextInt(); + } + byte[] page = intPage(values, 1024, true); + assertFalse(intDeltaFlag(page, 0)); + assertIntRoundTrip(values, 1024, true); + } + + @Test + public void constantVectorStaysPlain() throws Exception { + // A vector already packing at width 0 cannot be improved on, and differencing it + // would only add a start value. + int[] values = new int[1024]; + java.util.Arrays.fill(values, -7); + byte[] page = intPage(values, 1024, true); + assertFalse(intDeltaFlag(page, 0)); + assertEquals(0, bitWidthByte(page, 0, INT32_VECTOR_INFO_SIZE) & BIT_WIDTH_MASK); + assertIntRoundTrip(values, 1024, true); + } + + // --------------------------------------------------------------------------- + // Wrapping. Both the differencing and the sum are modular, so values that step + // across the ends of the range have to come back unchanged. + // --------------------------------------------------------------------------- + + @Test + public void intSequenceWrappingPastMaxRoundTrips() throws Exception { + int[] values = new int[1024]; + int v = Integer.MAX_VALUE - 200; + for (int i = 0; i < values.length; i++) { + values[i] = v++; + } + assertIntRoundTrip(values, 1024, true); + } + + @Test + public void longSequenceWrappingPastMaxRoundTrips() throws Exception { + long[] values = new long[1024]; + long v = Long.MAX_VALUE - 200; + for (int i = 0; i < values.length; i++) { + values[i] = v++; + } + assertLongRoundTrip(values, 1024, true); + } + + @Test + public void alternatingExtremesRoundTrip() throws Exception { + int[] values = new int[1024]; + for (int i = 0; i < values.length; i++) { + values[i] = (i % 2 == 0) ? Integer.MIN_VALUE : Integer.MAX_VALUE; + } + assertIntRoundTrip(values, 1024, true); + } + + @Test + public void alternatingLongExtremesRoundTrip() throws Exception { + long[] values = new long[1024]; + for (int i = 0; i < values.length; i++) { + values[i] = (i % 2 == 0) ? Long.MIN_VALUE : Long.MAX_VALUE; + } + assertLongRoundTrip(values, 1024, true); + } + + @Test + public void differencesAreTakenUnsigned() throws Exception { + int[] deltas = new int[2]; + PforEncoderDecoder.computeDeltasForInt(new int[] {Integer.MIN_VALUE, Integer.MAX_VALUE}, 2, deltas); + assertEquals(0, deltas[0]); + assertEquals(-1, deltas[1]); // 0xFFFFFFFF, the difference taken modulo 2^32 + + long[] longDeltas = new long[2]; + PforEncoderDecoder.computeDeltasForLong(new long[] {Long.MIN_VALUE, Long.MAX_VALUE}, 2, longDeltas); + assertEquals(0L, longDeltas[0]); + assertEquals(-1L, longDeltas[1]); + } + + // --------------------------------------------------------------------------- + // Exceptions in a delta vector hold differences, so the patch has to land before + // the prefix sum. Patching after it would leave every later value short by the + // difference between the exception and the placeholder that stood in for it. + // --------------------------------------------------------------------------- + + @Test + public void deltaVectorWithExceptionsRoundTrips() throws Exception { + int[] values = monotoneInts(1024, 100_000, 3); + for (int i = 500; i < values.length; i++) { + values[i] += 40_000_000; // one difference far outside the cluster + } + + byte[] page = intPage(values, 1024, true); + assertTrue("delta flag", intDeltaFlag(page, 0)); + assertTrue("the jump is stored as an exception", intNumExceptions(page, 0) >= 1); + assertIntRoundTrip(values, 1024, true); + } + + @Test + public void deltaVectorWithExceptionsRoundTripsForLongs() throws Exception { + long[] values = monotoneLongs(1024, 1_700_000_000_000L, 5); + for (int i = 700; i < values.length; i++) { + values[i] += 1L << 45; + } + + byte[] page = longPage(values, 1024, true); + assertTrue(longDeltaFlag(page, 0)); + assertTrue("the jump is stored as an exception", longNumExceptions(page, 0) >= 1); + assertLongRoundTrip(values, 1024, true); + } + + @Test + public void severalExceptionsInOneDeltaVectorRoundTrip() throws Exception { + int[] values = monotoneInts(1024, 0, 4); + int bump = 0; + for (int i = 0; i < values.length; i++) { + if (i == 10 || i == 300 || i == 301 || i == 1023) { + bump += 30_000_000; + } + values[i] += bump; + } + byte[] page = intPage(values, 1024, true); + assertTrue(intDeltaFlag(page, 0)); + assertTrue(intNumExceptions(page, 0) >= 2); + assertIntRoundTrip(values, 1024, true); + } + + // --------------------------------------------------------------------------- + // Vector sizes, partial vectors, and reads that do not start at a boundary + // --------------------------------------------------------------------------- + + @Test + public void allVectorSizesRoundTrip() throws Exception { + for (int log = 3; log <= 12; log++) { + int vectorSize = 1 << log; + int[] ints = monotoneInts(1000, 3_000_000, 9); + long[] longs = monotoneLongs(1000, 9_000_000_000L, 9); + assertIntRoundTrip(ints, vectorSize, true); + assertLongRoundTrip(longs, vectorSize, true); + } + } + + @Test + public void partialLastDeltaVectorRoundTrips() throws Exception { + // 130 values over vectors of 64 leaves a last vector of 2, which still carries + // its own start value. + assertIntRoundTrip(monotoneInts(130, 77, 6), 64, true); + assertLongRoundTrip(monotoneLongs(130, 77L, 6), 64, true); + } + + @Test + public void twoValueDeltaVectorRoundTrips() throws Exception { + assertIntRoundTrip(new int[] {5, 9}, 8, true); + assertLongRoundTrip(new long[] {5L, 9L}, 8, true); + } + + @Test + public void singleValueVectorHasNoDifferenceToTake() throws Exception { + byte[] page = intPage(new int[] {12345}, 8, true); + assertFalse(intDeltaFlag(page, 0)); + assertIntRoundTrip(new int[] {12345}, 8, true); + } + + @Test + public void skipInsideAndAcrossDeltaVectors() throws Exception { + int[] values = monotoneInts(300, 1_000, 4); + byte[] page = intPage(values, 64, true); + assertTrue(intDeltaFlag(page, 0)); + + PforValuesReaderForInt reader = new PforValuesReaderForInt(); + reader.initFromPage(values.length, ByteBufferInputStream.wrap(ByteBuffer.wrap(page))); + + for (int i = 0; i < 10; i++) { + assertEquals(values[i], reader.readInteger()); + } + reader.skip(45); // stops inside vector 0, resumes inside the same vector + for (int i = 55; i < 60; i++) { + assertEquals(values[i], reader.readInteger()); + } + reader.skip(128); // crosses two whole vectors without decoding them + for (int i = 188; i < 300; i++) { + assertEquals("value at " + i, values[i], reader.readInteger()); + } + } + + @Test + public void skipOneAtATimeThroughDeltaVectors() throws Exception { + long[] values = monotoneLongs(200, 50L, 3); + byte[] page = longPage(values, 32, true); + PforValuesReaderForLong reader = new PforValuesReaderForLong(); + reader.initFromPage(values.length, ByteBufferInputStream.wrap(ByteBuffer.wrap(page))); + for (int i = 0; i < values.length; i++) { + if (i % 2 == 0) { + reader.skip(); + } else { + assertEquals(values[i], reader.readLong()); + } + } + } + + // --------------------------------------------------------------------------- + // The decision itself + // --------------------------------------------------------------------------- + + @Test + public void planTakesDifferencesForMonotoneInts() throws Exception { + int[] values = monotoneInts(1024, 1_000_000, 7); + PforEncoderDecoder.VectorPlan plan = + PforEncoderDecoder.chooseVectorPlanForInt(values, values.length, new int[values.length], true); + + assertTrue(plan.delta); + assertEquals(values[0], plan.startValue); + // The differences are 0 then 7 repeated, so the frame is 0 and 7 needs 3 bits. + assertEquals(0, plan.frameOfReference); + assertEquals(3, plan.bitWidth); + assertEquals(0, plan.numExceptions); + } + + @Test + public void planTakesDifferencesForMonotoneLongs() throws Exception { + long[] values = monotoneLongs(1024, 1_700_000_000_000L, 7); + PforEncoderDecoder.VectorPlan plan = + PforEncoderDecoder.chooseVectorPlanForLong(values, values.length, new long[values.length], true); + + assertTrue(plan.delta); + assertEquals(values[0], plan.startValue); + assertEquals(0, plan.frameOfReference); + assertEquals(3, plan.bitWidth); + } + + @Test + public void planKeepsValuesWhenTheModeIsDisabled() throws Exception { + int[] values = monotoneInts(1024, 1_000_000, 7); + PforEncoderDecoder.VectorPlan plan = + PforEncoderDecoder.chooseVectorPlanForInt(values, values.length, new int[values.length], false); + assertFalse(plan.delta); + assertEquals(0L, plan.startValue); + } + + @Test + public void planPaysForTheStartValue() throws Exception { + // Eight values stepping by 100: differencing takes the width from 10 bits to 7, + // which saves 24 bits over the vector -- less than the 32 the start value costs. + int[] shortRun = {0, 100, 200, 300, 400, 500, 600, 700}; + PforEncoderDecoder.VectorPlan plan = + PforEncoderDecoder.chooseVectorPlanForInt(shortRun, shortRun.length, new int[shortRun.length], true); + assertFalse("start value is not paid for over 8 values", plan.delta); + + // The same step over a whole vector saves far more than it costs. + int[] longRun = monotoneInts(1024, 0, 100); + PforEncoderDecoder.VectorPlan longPlan = + PforEncoderDecoder.chooseVectorPlanForInt(longRun, longRun.length, new int[longRun.length], true); + assertTrue(longPlan.delta); + } + + @Test + public void planDeclinesDifferencesForRandomValues() throws Exception { + Random rnd = new Random(31); + int[] values = new int[1024]; + for (int i = 0; i < values.length; i++) { + values[i] = rnd.nextInt(); + } + PforEncoderDecoder.VectorPlan plan = + PforEncoderDecoder.chooseVectorPlanForInt(values, values.length, new int[values.length], true); + assertFalse(plan.delta); + } + + @Test + public void planReportsTheCheaperCost() throws Exception { + int[] values = monotoneInts(1024, 1_000_000, 7); + PforEncoderDecoder.VectorPlan delta = + PforEncoderDecoder.chooseVectorPlanForInt(values, values.length, new int[values.length], true); + PforEncoderDecoder.VectorPlan plain = + PforEncoderDecoder.chooseVectorPlanForInt(values, values.length, new int[values.length], false); + assertTrue("delta cost " + delta.costBits + " vs plain " + plain.costBits, delta.costBits < plain.costBits); + // The reported cost carries the start value, so it is what the two modes were + // compared on rather than the width alone. + assertEquals(1024L * 3 + 32, delta.costBits); + } + + // --------------------------------------------------------------------------- + // Fuzz + // --------------------------------------------------------------------------- + + @Test + public void randomWalksRoundTrip() throws Exception { + for (long seed = 0; seed < 20; seed++) { + Random rnd = new Random(seed); + int count = 1 + rnd.nextInt(3000); + int vectorSize = 1 << (3 + rnd.nextInt(8)); + int stepRange = 1 << (1 + rnd.nextInt(30)); + + int[] ints = new int[count]; + long[] longs = new long[count]; + ints[0] = rnd.nextInt(); + longs[0] = rnd.nextLong(); + for (int i = 1; i < count; i++) { + ints[i] = ints[i - 1] + rnd.nextInt(stepRange) - stepRange / 2; + longs[i] = longs[i - 1] + rnd.nextInt(stepRange) - stepRange / 2; + } + + assertIntRoundTrip(ints, vectorSize, true); + assertLongRoundTrip(longs, vectorSize, true); + } + } + + @Test + public void enablingTheModeNeverChangesWhatIsRead() throws Exception { + for (long seed = 100; seed < 110; seed++) { + Random rnd = new Random(seed); + int count = 1 + rnd.nextInt(500); + int[] values = new int[count]; + values[0] = rnd.nextInt(1000); + for (int i = 1; i < count; i++) { + values[i] = values[i - 1] + rnd.nextInt(200) - 50; + } + int[] withDelta = decodeInts(intPage(values, 128, true), count); + int[] withoutDelta = decodeInts(intPage(values, 128, false), count); + for (int i = 0; i < count; i++) { + assertEquals(values[i], withDelta[i]); + assertEquals(values[i], withoutDelta[i]); + } + } + } + + // --------------------------------------------------------------------------- + // The writer-side property, and the path from it to the bytes + // --------------------------------------------------------------------------- + + private static final MessageType SCHEMA = MessageTypeParser.parseMessageType("message m { required int32 c; }"); + + private static ColumnDescriptor column() { + return SCHEMA.getColumns().get(0); + } + + @Test + public void theModeIsOnByDefault() { + // Leaving it on cannot make a page larger, because the writer keeps whichever mode + // costs fewer bits. + assertTrue(ParquetProperties.builder().build().isPforDeltaEnabled(column())); + assertTrue(ParquetProperties.DEFAULT_IS_PFOR_DELTA_ENABLED); + } + + @Test + public void theModeCanBeTurnedOffGloballyAndPerColumn() { + ParquetProperties off = + ParquetProperties.builder().withPforDeltaEncoding(false).build(); + assertFalse(off.isPforDeltaEnabled(column())); + + ParquetProperties offForOneColumn = + ParquetProperties.builder().withPforDeltaEncoding("c", false).build(); + assertFalse(offForOneColumn.isPforDeltaEnabled(column())); + } + + @Test + public void copyingThePropertiesKeepsTheSetting() { + ParquetProperties off = + ParquetProperties.builder().withPforDeltaEncoding(false).build(); + assertFalse(ParquetProperties.copy(off).build().isPforDeltaEnabled(column())); + } + + @Test + public void thePropertyReachesTheBytes() throws Exception { + int[] values = monotoneInts(1024, 1_000_000, 7); + + byte[] withMode = pforPage(values, true); + assertTrue("property on, flag set", intDeltaFlag(withMode, 0)); + + byte[] withoutMode = pforPage(values, false); + assertFalse("property off, flag clear", intDeltaFlag(withoutMode, 0)); + + int[] decoded = decodeInts(withoutMode, values.length); + for (int i = 0; i < values.length; i++) { + assertEquals(values[i], decoded[i]); + } + } + + // Goes through the writer factory rather than constructing the PFOR writer directly, + // so the property is read where a real writer reads it. + private static byte[] pforPage(int[] values, boolean deltaEnabled) throws Exception { + ParquetProperties props = ParquetProperties.builder() + .withWriterVersion(ParquetProperties.WriterVersion.PARQUET_2_0) + .withDictionaryEncoding(false) + .withPforEncoding(true) + .withPforDeltaEncoding(deltaEnabled) + .withAllocator(new DirectByteBufferAllocator()) + .build(); + + ValuesWriter writer = props.newValuesWriter(column()); + try { + for (int v : values) { + writer.writeInteger(v); + } + assertEquals(org.apache.parquet.column.Encoding.PFOR, writer.getEncoding()); + return toBytes(writer.getBytes()); + } finally { + writer.reset(); + writer.close(); + } + } +} From 2380751c6f88ab801b3c03a5be8c6d9b8ee983ef Mon Sep 17 00:00:00 2001 From: Prateek Gaur Date: Sat, 5 Sep 2026 01:28:16 +0000 Subject: [PATCH 13/14] Add a configuration key for the PFOR delta mode parquet.enable.pfor.delta allows a PFOR vector to hold the differences between its successive values, and is only consulted where PFOR itself is enabled. It follows parquet.enable.pfor at the same four sites in ParquetOutputFormat, and the default is unchanged, so the mode stays on wherever PFOR is. The key is what lets a job configured only through a Configuration decline differencing; with parquet.enable.pfor alone it could turn PFOR on but would always get the mode the cost model preferred. --- .../parquet/hadoop/ParquetOutputFormat.java | 10 +++++++ .../parquet/hadoop/TestPforConfiguration.java | 26 ++++++++++++++++--- 2 files changed, 32 insertions(+), 4 deletions(-) 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 41f690f517..26d40d2cdc 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 @@ -86,6 +86,10 @@ * # To enable/disable PFOR encoding for INT32 and INT64 columns * parquet.enable.pfor=false # true to enable PFOR encoding * + * # To allow a PFOR vector to hold the differences between its successive values + * # Only consulted where PFOR itself is enabled + * parquet.enable.pfor.delta=true # false to keep every PFOR vector on absolute values + * * # 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 @@ -145,6 +149,7 @@ public static enum JobSummaryLevel { 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_PFOR = "parquet.enable.pfor"; + public static final String ENABLE_PFOR_DELTA = "parquet.enable.pfor.delta"; 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"; @@ -287,6 +292,10 @@ public static boolean getPforEnabled(Configuration configuration) { return configuration.getBoolean(ENABLE_PFOR, ParquetProperties.DEFAULT_IS_PFOR_ENABLED); } + public static boolean getPforDeltaEnabled(Configuration configuration) { + return configuration.getBoolean(ENABLE_PFOR_DELTA, ParquetProperties.DEFAULT_IS_PFOR_DELTA_ENABLED); + } + 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 +531,7 @@ public RecordWriter getRecordWriter(Configuration conf, Path file, Comp .withDictionaryEncoding(getEnableDictionary(conf)) .withByteStreamSplitEncoding(getByteStreamSplitEnabled(conf)) .withPforEncoding(getPforEnabled(conf)) + .withPforDeltaEncoding(getPforDeltaEnabled(conf)) .withWriterVersion(getWriterVersion(conf)) .estimateRowCountForPageSizeCheck(getEstimatePageSizeCheck(conf)) .withMinRowCountForPageSizeCheck(getMinRowCountForPageSizeCheck(conf)) diff --git a/parquet-hadoop/src/test/java/org/apache/parquet/hadoop/TestPforConfiguration.java b/parquet-hadoop/src/test/java/org/apache/parquet/hadoop/TestPforConfiguration.java index 3e161e5f54..b5426bbdf3 100644 --- a/parquet-hadoop/src/test/java/org/apache/parquet/hadoop/TestPforConfiguration.java +++ b/parquet-hadoop/src/test/java/org/apache/parquet/hadoop/TestPforConfiguration.java @@ -45,40 +45,58 @@ private static ColumnDescriptor doubleColumn() { @Test public void testDefault() throws Exception { Configuration conf = new Configuration(); - // PFOR is off unless a job asks for it + // PFOR is off unless a job asks for it, and the delta mode is on wherever PFOR is assertEquals(ParquetProperties.DEFAULT_IS_PFOR_ENABLED, ParquetOutputFormat.getPforEnabled(conf)); + assertEquals(ParquetProperties.DEFAULT_IS_PFOR_DELTA_ENABLED, ParquetOutputFormat.getPforDeltaEnabled(conf)); } @Test - public void testTheKeyNameIsTheDocumentedOne() throws Exception { - // This string is the public surface; the class javadoc documents it + public void testTheKeyNamesAreTheDocumentedOnes() throws Exception { + // These strings are the public surface; the class javadoc documents them assertEquals("parquet.enable.pfor", ParquetOutputFormat.ENABLE_PFOR); + assertEquals("parquet.enable.pfor.delta", ParquetOutputFormat.ENABLE_PFOR_DELTA); } @Test public void testSetTrue() throws Exception { Configuration conf = new Configuration(); conf.setBoolean(ParquetOutputFormat.ENABLE_PFOR, true); + conf.setBoolean(ParquetOutputFormat.ENABLE_PFOR_DELTA, true); assertTrue(ParquetOutputFormat.getPforEnabled(conf)); + assertTrue(ParquetOutputFormat.getPforDeltaEnabled(conf)); } @Test public void testSetFalse() throws Exception { Configuration conf = new Configuration(); conf.setBoolean(ParquetOutputFormat.ENABLE_PFOR, false); + conf.setBoolean(ParquetOutputFormat.ENABLE_PFOR_DELTA, false); assertFalse(ParquetOutputFormat.getPforEnabled(conf)); + assertFalse(ParquetOutputFormat.getPforDeltaEnabled(conf)); } @Test - public void testTheKeyReachesTheWriterProperties() throws Exception { + public void testTheDeltaModeCanBeTurnedOffWhilePforStaysOn() throws Exception { Configuration conf = new Configuration(); conf.setBoolean(ParquetOutputFormat.ENABLE_PFOR, true); + conf.setBoolean(ParquetOutputFormat.ENABLE_PFOR_DELTA, false); + assertTrue(ParquetOutputFormat.getPforEnabled(conf)); + assertFalse(ParquetOutputFormat.getPforDeltaEnabled(conf)); + } + + @Test + public void testTheKeysReachTheWriterProperties() throws Exception { + Configuration conf = new Configuration(); + conf.setBoolean(ParquetOutputFormat.ENABLE_PFOR, true); + conf.setBoolean(ParquetOutputFormat.ENABLE_PFOR_DELTA, false); ParquetProperties props = ParquetProperties.builder() .withPforEncoding(ParquetOutputFormat.getPforEnabled(conf)) + .withPforDeltaEncoding(ParquetOutputFormat.getPforDeltaEnabled(conf)) .build(); assertTrue(props.isPforEnabled(intColumn())); + assertFalse(props.isPforDeltaEnabled(intColumn())); // PFOR encodes INT32 and INT64 only, whatever the configuration says assertFalse(props.isPforEnabled(doubleColumn())); } From 0234a3b9067da48644464013759842e853a5395a Mon Sep 17 00:00:00 2001 From: Prateek Gaur Date: Sat, 5 Sep 2026 05:38:48 +0000 Subject: [PATCH 14/14] Search for the PFOR frame of reference instead of taking the minimum The frame was the vector minimum, which leaves one value far below a tight cluster forcing a bit width wide enough to reach it. Exceptions could not help, because with the frame at the minimum every residual is non-negative and only values above the packed window ever exceed it. The frame is now searched, and may sit anywhere in the column's type. A value below it wraps under the modular subtraction to a residual too large for the width, which is the same unsigned test a value above the window fails, so it becomes an ordinary exception carrying its unreduced value. There is no sign, no direction, and no second kind of exception. Nothing changes on the wire or in the reader: the frame already travels in the vector info at full width, and the reader only adds it back before patching. Pages written this way were always readable. The search matches the C++ and Rust implementations bucket for bucket, so the three writers agree on the frame for the same input. It walks min and max, returns early on a constant vector, then builds 256 shift-bucketed counts in the same pass as the width histogram. The minimum is costed exactly as candidate zero, and a sliding window over the buckets is seeded with that cost, so the scan can decline a winner but never accept a loser. Only a winning window pays the second walk that lowers the frame onto the smallest real value it covers, and that frame is costed exactly and taken only if it is strictly cheaper. The delta mode searches the frame of the differences too, which is what turns a constant-step column into a width-0 vector with the leading zero difference as its one exception. PforFrameSearchTest covers the cluster-plus-outlier shapes, patching on both sides of the window, the type extremes, every vector size, a frame searched per vector, and randomized shapes held against an independently computed minimum-frame cost so the search can never lose. Six assertions in PforDeltaModeTest move to the cheaper answers the search now finds. --- .../column/values/pfor/PforConstants.java | 3 +- .../values/pfor/PforEncoderDecoder.java | 278 ++++++++- .../column/values/pfor/PforValuesWriter.java | 6 +- .../column/values/pfor/PforDeltaModeTest.java | 35 +- .../values/pfor/PforFrameSearchTest.java | 551 ++++++++++++++++++ 5 files changed, 842 insertions(+), 31 deletions(-) create mode 100644 parquet-column/src/test/java/org/apache/parquet/column/values/pfor/PforFrameSearchTest.java diff --git a/parquet-column/src/main/java/org/apache/parquet/column/values/pfor/PforConstants.java b/parquet-column/src/main/java/org/apache/parquet/column/values/pfor/PforConstants.java index 992536b0b7..2e12188ba2 100644 --- a/parquet-column/src/main/java/org/apache/parquet/column/values/pfor/PforConstants.java +++ b/parquet-column/src/main/java/org/apache/parquet/column/values/pfor/PforConstants.java @@ -25,7 +25,8 @@ * *

PFOR encoding compresses integer columns (INT32/INT64) by: *

    - *
  1. Subtracting the minimum value (Frame of Reference)
  2. + *
  3. Subtracting a frame of reference: any lower bound on the vector, chosen so the + * residuals pack narrowly, and not necessarily the minimum
  4. *
  5. Choosing an optimal bit width via a cost model
  6. *
  7. Bit-packing the residuals at the chosen width
  8. *
  9. Storing outlier values (exceptions) separately with their positions
  10. diff --git a/parquet-column/src/main/java/org/apache/parquet/column/values/pfor/PforEncoderDecoder.java b/parquet-column/src/main/java/org/apache/parquet/column/values/pfor/PforEncoderDecoder.java index 28e0bc6388..3e20b51c40 100644 --- a/parquet-column/src/main/java/org/apache/parquet/column/values/pfor/PforEncoderDecoder.java +++ b/parquet-column/src/main/java/org/apache/parquet/column/values/pfor/PforEncoderDecoder.java @@ -31,6 +31,11 @@ *

    The same model decides the delta mode: a vector is costed as it stands and * again as the differences between its successive values, and the cheaper of the * two wins. See {@link #chooseVectorPlanForInt}. + * + *

    It also decides the frame of reference, which is any lower bound on the vector + * rather than its minimum: a window placed where the values cluster can be narrower + * than one anchored at an outlier below them, and the values it leaves out are patched + * like any other exception. See {@link #searchForInt}. */ public final class PforEncoderDecoder { @@ -234,43 +239,286 @@ public static VectorPlan chooseVectorPlanForLong( } /** - * Take the frame of an INT32 vector and cost the widths over it, without writing the - * residuals out: they are needed once here, for their widths, and again by the caller - * only once the plan is settled. + * Bucket count the frame search works at, as a shift and as a count. 256 buckets keep + * the window scan in {@link #scanFrameWindow} at about two passes' worth of work over a + * 1024-value vector. + */ + private static final int FRAME_SEARCH_BITS = 8; + + private static final int FRAME_SEARCH_BUCKETS = 1 << FRAME_SEARCH_BITS; + + /** A run of frame search buckets: the offsets in {@code [start << shift, end << shift)}. */ + private static final class FrameWindow { + final int start; + final int end; + + FrameWindow(int start, int end) { + this.start = start; + this.end = end; + } + } + + /** + * Choose a frame of reference for an INT32 vector and the width that suits it. + * + *

    The frame PFOR has always used is the minimum, which makes every exception an + * overshoot: one value far below the cluster drags the whole packed window down with it + * and nothing can patch it back. Treating the frame as a free parameter instead -- any + * lower bound, not the lowest -- lets the window sit where the values actually are and + * patch on both sides. A value below the frame wraps, in the modular subtraction the + * writer already does, to a huge offset that fails the same unsigned width test as a + * value above the window, so it becomes an exception and is patched back with its + * unreduced value. There is no sign or direction to track. + * + *

    Nothing on the wire changes: the frame field already holds a full-width value and + * a reader only ever adds it. The whole cost is this search, which is why a reader that + * predates it still reads what this writer produces. + * + *

    The search is approximate by design. An exact answer needs the values sorted; + * instead the range is bucketed with a shift and for each candidate width a window is + * slid over the bucket counts. Only whole buckets count as covered, so the exception + * estimate is an upper bound, never optimistic. The minimum as a frame is always among + * the candidates and it alone is costed from a real histogram, so the search can never + * do worse than the width search alone would have. */ private static VectorPlan searchForInt(int[] source, int numElements) { - int frame = source[0]; + int min = source[0]; + int max = source[0]; for (int i = 1; i < numElements; i++) { - if (source[i] < frame) { - frame = source[i]; + if (source[i] < min) { + min = source[i]; + } else if (source[i] > max) { + max = source[i]; } } + // The range is an unsigned quantity even though its ends are signed: it spans the + // whole type when they sit at the extremes, and the subtraction wraps to say so. + int range = max - min; + if (range == 0) { + // A constant vector is already at the floor and min/max has just proved it + // constant. Worth its own exit for more than the saved pass: a run of equal values + // sends every element to one histogram bin, where the read-modify-write serializes. + return new VectorPlan(false, min, 0, 0, 0, 0); + } + + int rangeBits = bitWidthForInt(range); + int shift = rangeBits > FRAME_SEARCH_BITS ? rangeBits - FRAME_SEARCH_BITS : 0; + + // One walk serves both halves of the search: the width histogram costs the minimum + // as a frame, the bucket counts cost every other frame. They are gathered together + // because each needs the same offset. int[] bitsHist = new int[33]; + int[] counts = new int[FRAME_SEARCH_BUCKETS + 1]; for (int i = 0; i < numElements; i++) { - bitsHist[bitWidthForInt(source[i] - frame)]++; + int offset = source[i] - min; + bitsHist[bitWidthForInt(offset)]++; + counts[offset >>> shift]++; } + // Candidate 0: the minimum, which is what PFOR has always done. Costed + // unconditionally, and from a real histogram, so the search cannot regress here. BitWidthResult best = bestFromHistogram(bitsHist, 32, numElements, INT32_EXCEPTION_BITS); - return new VectorPlan(false, frame, 0, best.bitWidth, best.numExceptions, best.costBits); + VectorPlan minPlan = new VectorPlan(false, min, 0, best.bitWidth, best.numExceptions, best.costBits); + + // Already at width 0, with a handful of patches carrying the rest, and nothing a + // frame can do about that. This is not the same as having no exceptions: trading a + // narrower width for a few patches is the whole point of a frame above the minimum, + // so an exception-free choice is where the search starts, not a reason to skip it. + if (best.bitWidth == 0) { + return minPlan; + } + + int numBuckets = (range >>> shift) + 1; + FrameWindow window = + scanFrameWindow(counts, numBuckets, shift, 32, numElements, INT32_EXCEPTION_BITS, best.costBits); + if (window == null) { + return minPlan; + } + + // Lower the frame from the boundary of the winning window onto the smallest value the + // window actually covers. Bucket boundaries stand 2^shift apart, which on a wide + // column is thousands, and a cluster sitting just above one would otherwise pay those + // bits for nothing. + // + // A walk of its own, rather than per-bucket minima kept by the pass above: tracking + // them there costs every vector a compare and a store per element, including the + // vectors where the scan finds nothing and the minima are thrown away. Here only a + // vector whose search has already won pays, and it pays one traversal. + int windowLo = window.start << shift; + // A window reaching the last bucket has no upper edge to test against: that edge + // would be numBuckets << shift, one past the range whenever the offsets span the + // whole type. + boolean boundedAbove = window.end < numBuckets; + int windowHi = boundedAbove ? window.end << shift : 0; + + int frameOffset = 0; + boolean coversAnything = false; + for (int i = 0; i < numElements; i++) { + int offset = source[i] - min; + if (Integer.compareUnsigned(offset, windowLo) < 0 + || (boundedAbove && Integer.compareUnsigned(offset, windowHi) >= 0)) { + continue; + } + if (!coversAnything || Integer.compareUnsigned(offset, frameOffset) < 0) { + frameOffset = offset; + coversAnything = true; + } + } + if (!coversAnything || frameOffset == 0) { + return minPlan; + } + + // Cost the winning frame exactly. This pass is not bookkeeping -- it is where the + // width and the exception count are decided. The scan works at bucket granularity and + // so cannot see a window narrower than one bucket, which is exactly where the answers + // worth having tend to be: a sawtooth spanning 12 bits has buckets 16 wide, and no + // scan over them resolves the 0-bit window its few patches leave behind. + int scanFrame = min + frameOffset; + int[] exactHist = new int[33]; + for (int i = 0; i < numElements; i++) { + exactHist[bitWidthForInt(source[i] - scanFrame)]++; + } + BitWidthResult exact = bestFromHistogram(exactHist, 32, numElements, INT32_EXCEPTION_BITS); + if (exact.costBits >= best.costBits) { + return minPlan; + } + return new VectorPlan(false, scanFrame, 0, exact.bitWidth, exact.numExceptions, exact.costBits); } /** See {@link #searchForInt}. */ private static VectorPlan searchForLong(long[] source, int numElements) { - long frame = source[0]; + long min = source[0]; + long max = source[0]; for (int i = 1; i < numElements; i++) { - if (source[i] < frame) { - frame = source[i]; + if (source[i] < min) { + min = source[i]; + } else if (source[i] > max) { + max = source[i]; } } + long range = max - min; + if (range == 0) { + return new VectorPlan(false, min, 0, 0, 0, 0); + } + + int rangeBits = bitWidthForLong(range); + int shift = rangeBits > FRAME_SEARCH_BITS ? rangeBits - FRAME_SEARCH_BITS : 0; + int[] bitsHist = new int[65]; + int[] counts = new int[FRAME_SEARCH_BUCKETS + 1]; for (int i = 0; i < numElements; i++) { - bitsHist[bitWidthForLong(source[i] - frame)]++; + long offset = source[i] - min; + bitsHist[bitWidthForLong(offset)]++; + counts[(int) (offset >>> shift)]++; } BitWidthResult best = bestFromHistogram(bitsHist, 64, numElements, INT64_EXCEPTION_BITS); - return new VectorPlan(false, frame, 0, best.bitWidth, best.numExceptions, best.costBits); + VectorPlan minPlan = new VectorPlan(false, min, 0, best.bitWidth, best.numExceptions, best.costBits); + if (best.bitWidth == 0) { + return minPlan; + } + + int numBuckets = (int) (range >>> shift) + 1; + FrameWindow window = + scanFrameWindow(counts, numBuckets, shift, 64, numElements, INT64_EXCEPTION_BITS, best.costBits); + if (window == null) { + return minPlan; + } + + long windowLo = (long) window.start << shift; + boolean boundedAbove = window.end < numBuckets; + long windowHi = boundedAbove ? (long) window.end << shift : 0; + + long frameOffset = 0; + boolean coversAnything = false; + for (int i = 0; i < numElements; i++) { + long offset = source[i] - min; + if (Long.compareUnsigned(offset, windowLo) < 0 + || (boundedAbove && Long.compareUnsigned(offset, windowHi) >= 0)) { + continue; + } + if (!coversAnything || Long.compareUnsigned(offset, frameOffset) < 0) { + frameOffset = offset; + coversAnything = true; + } + } + if (!coversAnything || frameOffset == 0) { + return minPlan; + } + + long scanFrame = min + frameOffset; + int[] exactHist = new int[65]; + for (int i = 0; i < numElements; i++) { + exactHist[bitWidthForLong(source[i] - scanFrame)]++; + } + BitWidthResult exact = bestFromHistogram(exactHist, 64, numElements, INT64_EXCEPTION_BITS); + if (exact.costBits >= best.costBits) { + return minPlan; + } + return new VectorPlan(false, scanFrame, 0, exact.bitWidth, exact.numExceptions, exact.costBits); + } + + /** + * Slide a window of {@code 2^w} offsets over the bucket counts, for each candidate + * width in turn, and return where it costs least. + * + *

    Widths below the bucket size cannot be resolved at this granularity, and once one + * window spans every bucket there are no exceptions left to remove, so only the + * {@link #FRAME_SEARCH_BITS} or so widths in between are scanned: fixed work, and none + * of it touching the data again. + * + *

    What comes out is a frame, not a width. Only whole buckets count as covered, so + * {@code w} here is an upper bound on the width the frame really needs and the + * exception count an upper bound too; the caller's exact pass is what turns the frame + * into a plan. + * + * @param incumbentCost the cost to beat, so a window only registers if it beats the + * minimum as a frame. That skips the rest of the search entirely on a column the + * frame cannot help, which is most of them, and it errs in the conservative + * direction: the scan over-counts exceptions, so it can decline a frame whose exact + * cost would have won, but it cannot accept one that loses. + * @return the winning window, or null if none beat {@code incumbentCost} + */ + private static FrameWindow scanFrameWindow( + int[] counts, + int numBuckets, + int shift, + int maxBits, + int numElements, + long exceptionBitsPerValue, + long incumbentCost) { + int[] prefix = new int[numBuckets + 1]; + for (int b = 0; b < numBuckets; b++) { + prefix[b + 1] = prefix[b] + counts[b]; + } + + int bestStart = -1; + int bestEnd = 0; + long bestCost = incumbentCost; + for (int w = shift; w <= maxBits; w++) { + // Buckets that fit under a width of w. The exponent stays small -- there are at + // most FRAME_SEARCH_BUCKETS buckets, so the loop leaves as soon as w - shift + // reaches FRAME_SEARCH_BITS -- but it is clamped rather than shifted, because a + // shift count of 64 or more is not a shift at all in Java. + int k = (w - shift) >= FRAME_SEARCH_BITS ? numBuckets : (int) Math.min(1L << (w - shift), numBuckets); + for (int s = 0; s < numBuckets; s++) { + int end = Math.min(s + k, numBuckets); + long exceptions = numElements - (prefix[end] - prefix[s]); + long cost = (long) numElements * w + exceptions * exceptionBitsPerValue; + if (cost < bestCost) { + bestCost = cost; + bestStart = s; + bestEnd = end; + } + } + if (k >= numBuckets) { + break; // one window already spans the data + } + } + + return bestStart < 0 ? null : new FrameWindow(bestStart, bestEnd); } /** @@ -283,8 +531,8 @@ private static VectorPlan searchForLong(long[] source, int numElements) { * *

    The subtraction wraps, which is what makes the round trip exact for a column * that spans the type's range; the reader's prefix sum wraps the same way. A vector - * with negative differences needs nothing special: the frame is the minimum of the - * differences, so subtracting it makes every residual non-negative, the same + * with negative differences needs nothing special: the frame is a lower bound on the + * differences, so subtracting it leaves residuals the packed width can hold, the same * mechanism a plain vector uses for negative values. */ public static void computeDeltasForInt(int[] values, int numElements, int[] deltas) { diff --git a/parquet-column/src/main/java/org/apache/parquet/column/values/pfor/PforValuesWriter.java b/parquet-column/src/main/java/org/apache/parquet/column/values/pfor/PforValuesWriter.java index 80f7c2de96..2de5f07263 100644 --- a/parquet-column/src/main/java/org/apache/parquet/column/values/pfor/PforValuesWriter.java +++ b/parquet-column/src/main/java/org/apache/parquet/column/values/pfor/PforValuesWriter.java @@ -36,9 +36,11 @@ /** * PFOR (Patched Frame of Reference) values writer for INT32 and INT64 columns. * - *

    PFOR compresses integer columns by subtracting the minimum value (FOR), + *

    PFOR compresses integer columns by subtracting a frame of reference (FOR), * selecting an optimal bit width via a histogram-based cost model, bit-packing - * the residuals, and storing outlier values (exceptions) separately. + * the residuals, and storing outlier values (exceptions) separately. The frame is + * searched for rather than taken to be the vector minimum, so a value below it is + * an exception just as a value above the packed window is. * *

    Per vector, the writer costs the values as they stand and again as the * differences between successive values, and keeps the cheaper of the two -- the diff --git a/parquet-column/src/test/java/org/apache/parquet/column/values/pfor/PforDeltaModeTest.java b/parquet-column/src/test/java/org/apache/parquet/column/values/pfor/PforDeltaModeTest.java index 46ffc85e44..70080ccf1f 100644 --- a/parquet-column/src/test/java/org/apache/parquet/column/values/pfor/PforDeltaModeTest.java +++ b/parquet-column/src/test/java/org/apache/parquet/column/values/pfor/PforDeltaModeTest.java @@ -202,8 +202,11 @@ public void monotoneIntsAreWrittenAsDifferences() throws Exception { byte[] page = intPage(values, 1024, true); assertTrue("delta flag", intDeltaFlag(page, 0)); - // Bit 7 is the flag, so the width has to be read out from under it. - assertEquals(3, bitWidthByte(page, 0, INT32_VECTOR_INFO_SIZE) & BIT_WIDTH_MASK); + // Bit 7 is the flag, so the width has to be read out from under it. Every difference + // here is 7 except the leading 0, and the frame search sits the frame on 7 and patches + // that one, which leaves nothing to pack. + assertEquals(0, bitWidthByte(page, 0, INT32_VECTOR_INFO_SIZE) & BIT_WIDTH_MASK); + assertEquals(1, intNumExceptions(page, 0)); // The start value sits between the vector info and the packed residuals. assertEquals(values[0], intLE(page, vectorPos(page, 0) + INT32_VECTOR_INFO_SIZE)); assertIntRoundTrip(values, 1024, true); @@ -219,7 +222,8 @@ public void monotoneLongsAreWrittenAsDifferences() throws Exception { byte[] page = longPage(values, 1024, true); assertTrue("delta flag", longDeltaFlag(page, 0)); - assertEquals(3, bitWidthByte(page, 0, INT64_VECTOR_INFO_SIZE) & BIT_WIDTH_MASK); + assertEquals(0, bitWidthByte(page, 0, INT64_VECTOR_INFO_SIZE) & BIT_WIDTH_MASK); + assertEquals(1, longNumExceptions(page, 0)); assertEquals(values[0], longLE(page, vectorPos(page, 0) + INT64_VECTOR_INFO_SIZE)); assertLongRoundTrip(values, 1024, true); @@ -481,10 +485,12 @@ public void planTakesDifferencesForMonotoneInts() throws Exception { assertTrue(plan.delta); assertEquals(values[0], plan.startValue); - // The differences are 0 then 7 repeated, so the frame is 0 and 7 needs 3 bits. - assertEquals(0, plan.frameOfReference); - assertEquals(3, plan.bitWidth); - assertEquals(0, plan.numExceptions); + // The differences are 0 then 7 repeated. A frame of 7 packs the run at width 0 and + // leaves the leading 0 as the one exception, which costs less than charging every + // value the 3 bits that a frame at the minimum would need. + assertEquals(7, plan.frameOfReference); + assertEquals(0, plan.bitWidth); + assertEquals(1, plan.numExceptions); } @Test @@ -495,8 +501,9 @@ public void planTakesDifferencesForMonotoneLongs() throws Exception { assertTrue(plan.delta); assertEquals(values[0], plan.startValue); - assertEquals(0, plan.frameOfReference); - assertEquals(3, plan.bitWidth); + assertEquals(7, plan.frameOfReference); + assertEquals(0, plan.bitWidth); + assertEquals(1, plan.numExceptions); } @Test @@ -510,8 +517,9 @@ public void planKeepsValuesWhenTheModeIsDisabled() throws Exception { @Test public void planPaysForTheStartValue() throws Exception { - // Eight values stepping by 100: differencing takes the width from 10 bits to 7, - // which saves 24 bits over the vector -- less than the 32 the start value costs. + // Eight values stepping by 100. Differencing packs them at width 0 with the leading + // difference patched, 48 bits in all, and the start value costs 32 more -- exactly + // what the values cost as they stand, so the tie keeps the values. int[] shortRun = {0, 100, 200, 300, 400, 500, 600, 700}; PforEncoderDecoder.VectorPlan plan = PforEncoderDecoder.chooseVectorPlanForInt(shortRun, shortRun.length, new int[shortRun.length], true); @@ -545,8 +553,9 @@ public void planReportsTheCheaperCost() throws Exception { PforEncoderDecoder.chooseVectorPlanForInt(values, values.length, new int[values.length], false); assertTrue("delta cost " + delta.costBits + " vs plain " + plain.costBits, delta.costBits < plain.costBits); // The reported cost carries the start value, so it is what the two modes were - // compared on rather than the width alone. - assertEquals(1024L * 3 + 32, delta.costBits); + // compared on rather than the width alone: nothing packed, one exception at 16 bits + // of position and 32 of value, and the 32-bit start value. + assertEquals((16 + 32) + 32L, delta.costBits); } // --------------------------------------------------------------------------- diff --git a/parquet-column/src/test/java/org/apache/parquet/column/values/pfor/PforFrameSearchTest.java b/parquet-column/src/test/java/org/apache/parquet/column/values/pfor/PforFrameSearchTest.java new file mode 100644 index 0000000000..cc72649aff --- /dev/null +++ b/parquet-column/src/test/java/org/apache/parquet/column/values/pfor/PforFrameSearchTest.java @@ -0,0 +1,551 @@ +/* + * 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.pfor; + +import static org.apache.parquet.column.values.pfor.PforConstants.BIT_WIDTH_MASK; +import static org.apache.parquet.column.values.pfor.PforConstants.INT32_VECTOR_INFO_SIZE; +import static org.apache.parquet.column.values.pfor.PforConstants.INT64_VECTOR_INFO_SIZE; +import static org.apache.parquet.column.values.pfor.PforConstants.PFOR_HEADER_SIZE; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +import java.nio.ByteBuffer; +import java.util.Random; +import org.apache.parquet.bytes.ByteBufferInputStream; +import org.apache.parquet.bytes.BytesInput; +import org.apache.parquet.bytes.DirectByteBufferAllocator; +import org.junit.Test; + +/** + * Tests for the searched frame of reference: the frame a vector carries is any lower bound + * on its values, not necessarily their minimum. + * + *

    What that buys is a packed window sitting where the values cluster, with the values + * outside it patched. A value below the frame needs no special handling on either side: the + * writer's subtraction is modular, so it wraps to an offset the packed width cannot hold and + * fails the same unsigned test as a value above the window, and the exception carries the + * unreduced value. So the tests here assert both halves -- that the frame does move off the + * minimum where that is cheaper, and that values on both sides of the window round trip. + * + *

    Nothing on the wire changed, which is the other thing worth pinning: the frame field + * always held a full-width value and a reader only adds it, so these pages are readable by + * any reader that could read a minimum-framed one. + */ +public class PforFrameSearchTest { + + private static final long INT32_EXCEPTION_BITS = 16 + 32; + private static final long INT64_EXCEPTION_BITS = 16 + 64; + + // --------------------------------------------------------------------------- + // Pages, round trips and wire inspection + // --------------------------------------------------------------------------- + + private static byte[] intPage(int[] values, int vectorSize) throws Exception { + PforValuesWriter.IntPforValuesWriter writer = null; + try { + int cap = Math.max(1024, values.length * 8); + writer = new PforValuesWriter.IntPforValuesWriter( + cap, cap, new DirectByteBufferAllocator(), vectorSize, true); + for (int v : values) { + writer.writeInteger(v); + } + return toBytes(writer.getBytes()); + } finally { + if (writer != null) { + writer.reset(); + writer.close(); + } + } + } + + private static byte[] longPage(long[] values, int vectorSize) throws Exception { + PforValuesWriter.LongPforValuesWriter writer = null; + try { + int cap = Math.max(1024, values.length * 16); + writer = new PforValuesWriter.LongPforValuesWriter( + cap, cap, new DirectByteBufferAllocator(), vectorSize, true); + for (long v : values) { + writer.writeLong(v); + } + return toBytes(writer.getBytes()); + } finally { + if (writer != null) { + writer.reset(); + writer.close(); + } + } + } + + private static byte[] toBytes(BytesInput bytes) throws Exception { + ByteBuffer bb = bytes.toByteBuffer(); + byte[] out = new byte[bb.remaining()]; + bb.duplicate().get(out); + return out; + } + + private static void assertIntRoundTrip(int[] values, int vectorSize) throws Exception { + byte[] page = intPage(values, vectorSize); + PforValuesReaderForInt reader = new PforValuesReaderForInt(); + reader.initFromPage(values.length, ByteBufferInputStream.wrap(ByteBuffer.wrap(page))); + for (int i = 0; i < values.length; i++) { + assertEquals("value at " + i, values[i], reader.readInteger()); + } + } + + private static void assertLongRoundTrip(long[] values, int vectorSize) throws Exception { + byte[] page = longPage(values, vectorSize); + PforValuesReaderForLong reader = new PforValuesReaderForLong(); + reader.initFromPage(values.length, ByteBufferInputStream.wrap(ByteBuffer.wrap(page))); + for (int i = 0; i < values.length; i++) { + assertEquals("value at " + i, values[i], reader.readLong()); + } + } + + private static int intLE(byte[] page, int pos) { + return (page[pos] & 0xFF) + | ((page[pos + 1] & 0xFF) << 8) + | ((page[pos + 2] & 0xFF) << 16) + | ((page[pos + 3] & 0xFF) << 24); + } + + private static long longLE(byte[] page, int pos) { + return (intLE(page, pos) & 0xFFFFFFFFL) | ((long) intLE(page, pos + 4) << 32); + } + + private static int vectorPos(byte[] page, int vectorIdx) { + return PFOR_HEADER_SIZE + intLE(page, PFOR_HEADER_SIZE + vectorIdx * Integer.BYTES); + } + + /** The frame is the first field of a vector's info block. */ + private static int intFrame(byte[] page, int vectorIdx) { + return intLE(page, vectorPos(page, vectorIdx)); + } + + private static long longFrame(byte[] page, int vectorIdx) { + return longLE(page, vectorPos(page, vectorIdx)); + } + + private static int intBitWidth(byte[] page, int vectorIdx) { + return page[vectorPos(page, vectorIdx) + INT32_VECTOR_INFO_SIZE - 3] & BIT_WIDTH_MASK; + } + + private static int intNumExceptions(byte[] page, int vectorIdx) { + int pos = vectorPos(page, vectorIdx) + INT32_VECTOR_INFO_SIZE - 2; + return (page[pos] & 0xFF) | ((page[pos + 1] & 0xFF) << 8); + } + + private static int longNumExceptions(byte[] page, int vectorIdx) { + int pos = vectorPos(page, vectorIdx) + INT64_VECTOR_INFO_SIZE - 2; + return (page[pos] & 0xFF) | ((page[pos + 1] & 0xFF) << 8); + } + + // --------------------------------------------------------------------------- + // What the minimum as a frame would have cost, computed independently of the + // code under test, so the search can be held to never losing against it. + // --------------------------------------------------------------------------- + + private static long minFrameCostInt(int[] values) { + int min = values[0]; + for (int v : values) { + if (v < min) { + min = v; + } + } + int[] hist = new int[33]; + for (int v : values) { + hist[PforEncoderDecoder.bitWidthForInt(v - min)]++; + } + return bestCost(hist, 32, values.length, INT32_EXCEPTION_BITS); + } + + private static long minFrameCostLong(long[] values) { + long min = values[0]; + for (long v : values) { + if (v < min) { + min = v; + } + } + int[] hist = new int[65]; + for (long v : values) { + hist[PforEncoderDecoder.bitWidthForLong(v - min)]++; + } + return bestCost(hist, 64, values.length, INT64_EXCEPTION_BITS); + } + + private static long bestCost(int[] hist, int maxBits, int numElements, long exceptionBits) { + long best = Long.MAX_VALUE; + long exceptionsAbove = numElements - hist[0]; + for (int b = 0; b <= maxBits; b++) { + best = Math.min(best, (long) numElements * b + exceptionsAbove * exceptionBits); + if (b < maxBits) { + exceptionsAbove -= hist[b + 1]; + } + } + return best; + } + + // --------------------------------------------------------------------------- + // Data shapes + // --------------------------------------------------------------------------- + + /** A tight cluster with one value far below it: the case the minimum cannot handle. */ + private static int[] clusterWithLowOutlier(int count, int base, int spread, int outlier) { + Random rnd = new Random(7); + int[] values = new int[count]; + for (int i = 0; i < count; i++) { + values[i] = base + rnd.nextInt(spread); + } + values[count / 2] = outlier; + return values; + } + + private static long[] clusterWithLowOutlierLongs(int count, long base, int spread, long outlier) { + Random rnd = new Random(7); + long[] values = new long[count]; + for (int i = 0; i < count; i++) { + values[i] = base + rnd.nextInt(spread); + } + values[count / 2] = outlier; + return values; + } + + // --------------------------------------------------------------------------- + // The frame moves off the minimum where that is cheaper + // --------------------------------------------------------------------------- + + @Test + public void frameSitsOnTheClusterAndNotOnTheOutlier() { + int[] values = clusterWithLowOutlier(1024, 1_000_000, 16, 0); + PforEncoderDecoder.VectorPlan plan = + PforEncoderDecoder.chooseVectorPlanForInt(values, values.length, new int[values.length], false); + + // The minimum is 0, so a frame there would charge every value the 20 bits that + // 1,000,015 needs. The frame lands on the cluster instead: four bits of spread, and + // the one value below the window becomes an exception. + assertTrue("frame " + plan.frameOfReference + " is above the minimum", plan.frameOfReference >= 1_000_000); + assertEquals(4, plan.bitWidth); + assertEquals(1, plan.numExceptions); + assertTrue( + "searched cost " + plan.costBits + " beats the minimum's " + minFrameCostInt(values), + plan.costBits < minFrameCostInt(values)); + } + + @Test + public void frameSitsOnTheClusterForLongs() { + long[] values = clusterWithLowOutlierLongs(1024, 1_700_000_000_000L, 16, 0); + PforEncoderDecoder.VectorPlan plan = + PforEncoderDecoder.chooseVectorPlanForLong(values, values.length, new long[values.length], false); + + assertTrue("frame " + plan.frameOfReference, plan.frameOfReference >= 1_700_000_000_000L); + assertEquals(4, plan.bitWidth); + assertEquals(1, plan.numExceptions); + assertTrue(plan.costBits < minFrameCostLong(values)); + } + + @Test + public void oneRepeatedValueAndAnOutlierPacksAtWidthZero() { + // The window has no width at all: every value but one is the same, so the frame is that + // value and the outlier is patched. A frame at the minimum would pay 23 bits a value. + int[] values = new int[1024]; + for (int i = 0; i < values.length; i++) { + values[i] = 5_000_000; + } + values[100] = 0; + + PforEncoderDecoder.VectorPlan plan = + PforEncoderDecoder.chooseVectorPlanForInt(values, values.length, new int[values.length], false); + assertEquals(5_000_000, plan.frameOfReference); + assertEquals(0, plan.bitWidth); + assertEquals(1, plan.numExceptions); + assertEquals(INT32_EXCEPTION_BITS, plan.costBits); + } + + @Test + public void aFrameOnTheClusterIsWrittenToThePage() throws Exception { + int[] values = clusterWithLowOutlier(1024, 1_000_000, 16, 0); + byte[] page = intPage(values, 1024); + + // The searched frame is what the page carries, in the field that always held it. + assertTrue("wire frame " + intFrame(page, 0), intFrame(page, 0) >= 1_000_000); + assertEquals(4, intBitWidth(page, 0)); + assertEquals(1, intNumExceptions(page, 0)); + assertIntRoundTrip(values, 1024); + } + + @Test + public void theSearchDeclinesOnAUniformColumn() { + // Nothing clusters, so no window can pay for the values it leaves out and the frame + // stays where PFOR has always put it. + Random rnd = new Random(11); + int[] values = new int[1024]; + int min = Integer.MAX_VALUE; + for (int i = 0; i < values.length; i++) { + values[i] = rnd.nextInt(); + min = Math.min(min, values[i]); + } + + PforEncoderDecoder.VectorPlan plan = + PforEncoderDecoder.chooseVectorPlanForInt(values, values.length, new int[values.length], false); + assertEquals(min, plan.frameOfReference); + assertEquals(minFrameCostInt(values), plan.costBits); + } + + @Test + public void aConstantVectorNeedsNoWidthAndNoPatches() throws Exception { + int[] values = new int[1024]; + for (int i = 0; i < values.length; i++) { + values[i] = -42; + } + PforEncoderDecoder.VectorPlan plan = + PforEncoderDecoder.chooseVectorPlanForInt(values, values.length, new int[values.length], false); + assertEquals(-42, plan.frameOfReference); + assertEquals(0, plan.bitWidth); + assertEquals(0, plan.numExceptions); + assertEquals(0, plan.costBits); + assertIntRoundTrip(values, 1024); + } + + // --------------------------------------------------------------------------- + // Patching on both sides of the window + // --------------------------------------------------------------------------- + + @Test + public void valuesBelowAndAboveTheWindowAreBothPatched() throws Exception { + int[] values = clusterWithLowOutlier(1024, 1_000_000, 16, 0); + values[900] = 1_100_000; // and one above + + PforEncoderDecoder.VectorPlan plan = + PforEncoderDecoder.chooseVectorPlanForInt(values, values.length, new int[values.length], false); + assertTrue("frame " + plan.frameOfReference, plan.frameOfReference >= 1_000_000); + assertEquals(4, plan.bitWidth); + assertEquals("one below the window and one above it", 2, plan.numExceptions); + assertIntRoundTrip(values, 1024); + } + + @Test + public void valuesBelowAndAboveTheWindowAreBothPatchedForLongs() throws Exception { + long[] values = clusterWithLowOutlierLongs(1024, 1_700_000_000_000L, 16, 0); + values[900] = 1_800_000_000_000L; + + PforEncoderDecoder.VectorPlan plan = + PforEncoderDecoder.chooseVectorPlanForLong(values, values.length, new long[values.length], false); + assertEquals(2, plan.numExceptions); + assertLongRoundTrip(values, 1024); + } + + @Test + public void manyValuesBelowTheFrameRoundTrip() throws Exception { + // A tenth of the vector sits below the cluster, spread out, so patching has to handle + // a crowd of below-frame values and not just one. + Random rnd = new Random(19); + int[] values = new int[1024]; + for (int i = 0; i < values.length; i++) { + values[i] = (i % 10 == 0) ? rnd.nextInt(1_000_000) : 8_000_000 + rnd.nextInt(8); + } + assertIntRoundTrip(values, 1024); + } + + @Test + public void theTypeExtremesRoundTripWithASearchedFrame() throws Exception { + int[] ints = new int[1024]; + for (int i = 0; i < ints.length; i++) { + ints[i] = (i % 3 == 0) ? Integer.MIN_VALUE : (i % 3 == 1) ? Integer.MAX_VALUE : 0; + } + assertIntRoundTrip(ints, 1024); + + long[] longs = new long[1024]; + for (int i = 0; i < longs.length; i++) { + longs[i] = (i % 3 == 0) ? Long.MIN_VALUE : (i % 3 == 1) ? Long.MAX_VALUE : 0; + } + assertLongRoundTrip(longs, 1024); + + // A cluster at the top of the range, which is where the window has no upper edge to + // test against and the search has to say so rather than compute one. + int[] atTheTop = new int[1024]; + for (int i = 0; i < atTheTop.length; i++) { + atTheTop[i] = Integer.MAX_VALUE - (i & 7); + } + atTheTop[500] = Integer.MIN_VALUE; + assertIntRoundTrip(atTheTop, 1024); + } + + @Test + public void everyVectorSizeRoundTrips() throws Exception { + for (int log = 3; log <= 12; log++) { + int size = 1 << log; + int[] values = clusterWithLowOutlier(3 * size + 5, 1_000_000, 16, 0); + assertIntRoundTrip(values, size); + } + } + + @Test + public void eachVectorSearchesItsOwnFrame() throws Exception { + // Two vectors with unrelated clusters: the frames are per vector, so neither drags the + // other's window. + int[] values = new int[2048]; + for (int i = 0; i < 1024; i++) { + values[i] = 1_000_000 + (i & 15); + } + for (int i = 1024; i < 2048; i++) { + values[i] = -5_000_000 + (i & 15); + } + values[10] = 0; + values[2000] = 0; + + byte[] page = intPage(values, 1024); + assertTrue(intFrame(page, 0) >= 1_000_000); + assertTrue(intFrame(page, 1) <= -5_000_000); + assertIntRoundTrip(values, 1024); + } + + // --------------------------------------------------------------------------- + // The search can never lose to the minimum + // --------------------------------------------------------------------------- + + @Test + public void theSearchNeverCostsMoreThanTheMinimumWould() { + for (long seed = 0; seed < 60; seed++) { + Random rnd = new Random(seed); + int count = 1 + rnd.nextInt(2000); + int shape = (int) (seed % 6); + int[] values = new int[count]; + for (int i = 0; i < count; i++) { + switch (shape) { + case 0: + values[i] = rnd.nextInt(); + break; + case 1: + values[i] = 1_000_000 + rnd.nextInt(64); + break; + case 2: + values[i] = rnd.nextInt(4) == 0 ? rnd.nextInt() : 500_000 + rnd.nextInt(8); + break; + case 3: + values[i] = -rnd.nextInt(1 << 20); + break; + case 4: + values[i] = rnd.nextBoolean() ? Integer.MIN_VALUE : Integer.MAX_VALUE; + break; + default: + values[i] = 77; + break; + } + } + + PforEncoderDecoder.VectorPlan plan = + PforEncoderDecoder.chooseVectorPlanForInt(values, count, new int[count], false); + assertTrue( + "seed " + seed + ": searched " + plan.costBits + " vs minimum " + minFrameCostInt(values), + plan.costBits <= minFrameCostInt(values)); + } + } + + @Test + public void theSearchNeverCostsMoreThanTheMinimumWouldForLongs() { + for (long seed = 0; seed < 60; seed++) { + Random rnd = new Random(seed); + int count = 1 + rnd.nextInt(2000); + int shape = (int) (seed % 4); + long[] values = new long[count]; + for (int i = 0; i < count; i++) { + switch (shape) { + case 0: + values[i] = rnd.nextLong(); + break; + case 1: + values[i] = 1_700_000_000_000L + rnd.nextInt(64); + break; + case 2: + values[i] = rnd.nextInt(4) == 0 ? rnd.nextLong() : -900_000_000_000L + rnd.nextInt(8); + break; + default: + values[i] = rnd.nextBoolean() ? Long.MIN_VALUE : Long.MAX_VALUE; + break; + } + } + + PforEncoderDecoder.VectorPlan plan = + PforEncoderDecoder.chooseVectorPlanForLong(values, count, new long[count], false); + assertTrue("seed " + seed, plan.costBits <= minFrameCostLong(values)); + } + } + + @Test + public void randomShapesRoundTripWhateverTheFrame() throws Exception { + for (long seed = 0; seed < 40; seed++) { + Random rnd = new Random(seed); + int count = 1 + rnd.nextInt(2500); + int[] ints = new int[count]; + long[] longs = new long[count]; + for (int i = 0; i < count; i++) { + boolean outlier = rnd.nextInt(20) == 0; + ints[i] = outlier ? rnd.nextInt() : 3_000_000 + rnd.nextInt(32); + longs[i] = outlier ? rnd.nextLong() : 3_000_000_000_000L + rnd.nextInt(32); + } + assertIntRoundTrip(ints, 1024); + assertLongRoundTrip(longs, 1024); + } + } + + // --------------------------------------------------------------------------- + // The frame search and the delta mode compose + // --------------------------------------------------------------------------- + + @Test + public void differencesGetASearchedFrameToo() throws Exception { + // A run of equal steps with one jump in it. The differences are one value repeated + // plus the jump and the leading zero, so their frame is the step and both of those are + // patched -- which is only reachable because the frame may sit above the minimum. + int[] values = new int[1024]; + for (int i = 1; i < values.length; i++) { + values[i] = values[i - 1] + 7; + } + for (int i = 500; i < values.length; i++) { + values[i] += 1000; + } + + PforEncoderDecoder.VectorPlan plan = + PforEncoderDecoder.chooseVectorPlanForInt(values, values.length, new int[values.length], true); + assertTrue("delta mode", plan.delta); + assertEquals(7, plan.frameOfReference); + assertEquals(0, plan.bitWidth); + assertEquals("the leading zero and the jump", 2, plan.numExceptions); + assertIntRoundTrip(values, 1024); + } + + @Test + public void theSearchWorksAtBucketGranularityAndSaysSoByDeclining() throws Exception { + // The buckets divide the vector's range, so a value far above the cluster coarsens + // them: here the range is 2,000,000,000, which puts the bucket width at 8,388,608 and + // sweeps the low outlier into the same bucket as the cluster a million above it. No + // window can then separate them and the search declines, leaving the frame at the + // minimum -- the answer PFOR has always given, which is the floor this search + // guarantees rather than a defect. It is worth pinning because the same approximation + // is what the C++ and Rust encoders make, so a file written by any of them holds the + // same frame. + int[] values = clusterWithLowOutlier(1024, 1_000_000, 16, 0); + values[900] = 2_000_000_000; + + PforEncoderDecoder.VectorPlan plan = + PforEncoderDecoder.chooseVectorPlanForInt(values, values.length, new int[values.length], false); + assertEquals(0, plan.frameOfReference); + assertEquals(minFrameCostInt(values), plan.costBits); + assertIntRoundTrip(values, 1024); + } +}