This is an automated email from the ASF dual-hosted git repository.
jbonofre pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/arrow-java.git
The following commit(s) were added to refs/heads/main by this push:
new 2ab1b023d GH-192: Make LZ4 frame compression and decompression at
least 10x faster with lz4-java (#1420)
2ab1b023d is described below
commit 2ab1b023dc97c60bcb34ef10f7268b41d7a2de75
Author: Andy Grove <[email protected]>
AuthorDate: Mon Nov 2 11:45:16 2026 -0700
GH-192: Make LZ4 frame compression and decompression at least 10x faster
with lz4-java (#1420)
## What's Changed
`Lz4CompressionCodec` uses commons-compress's pure-Java LZ4, which is
slow enough that compressing an 8 MiB buffer of sequential `int64`
values takes several minutes (#192). This changes the codec to use
[lz4-java](https://github.com/yawkat/lz4-java), the JNI bindings to
liblz4 that Spark, Kafka and others use, while keeping every frame it
could read before readable.
**Compression.** Each buffer is written as one LZ4 frame of independent
blocks of up to 4 MiB, without checksums, compressed with lz4-java
directly from the buffer's memory. Every LZ4 frame decoder reads frames
with independent blocks: liblz4 (Arrow C++, pyarrow), `lz4_flex`
(arrow-rs), lz4-java, and commons-compress.
**Decompression.** A frame with independent blocks is decompressed with
lz4-java directly into the output buffer. That covers frames from this
codec, from arrow-rs, and from the previous version of this codec. The
new decoder accepts any block size, block and content checksums (which
it verifies), and a content size. It checks the frame's block lengths,
and that they can hold the recorded decompressed length, before
allocating for it.
A frame whose blocks are linked, or that uses a dictionary, still goes
through commons-compress. lz4-java cannot decode a block that refers to
earlier blocks, and Arrow C++ writes linked blocks: liblz4's default
`LZ4F_preferences_t` is `LZ4F_blockLinked`, so pyarrow 26 writes every
buffer larger than 64 KiB as linked 64 KiB blocks. The LZ4 files in
arrow-testing are small enough that liblz4 writes them as single,
independent blocks, so the integration tests would not catch this.
`TestLz4CompressionCodec` covers linked frames written by
commons-compress instead.
**Dependency.** This adds `at.yawk.lz4:lz4-java` 1.12.1, the maintained
fork of `org.lz4:lz4-java`. The original artifact's last code release,
1.8.0, has several published advisories (for example CVE-2025-12183 and
CVE-2025-66566), and its 1.8.1 is a relocation POM that points to the
fork. Spark moved to the fork in 4.2. Both artifacts use the
`net.jpountz` packages and the `org.lz4.java` module name, and the codec
uses only APIs present in both, so an application that pins
`org.lz4:lz4-java` instead still works. The codec asks for lz4-java's
JNI implementation and falls back to its Java one only when the native
library cannot load, because `LZ4Factory.fastestInstance()` settles for
Java whenever lz4-java is not loaded by the system class loader.
### Performance
`CompressionCodecBenchmarks` (new, in `performance`) on an Apple M3
Ultra, JDK 17, 8 MiB buffers:
| Benchmark | LZ4_FRAME | ZSTD |
| --- | ---: | ---: |
| compress, sequential `int64` | 8.5 ms | 12.5 ms |
| compress, random decimal text | 9.9 ms | 44.6 ms |
| decompress, sequential `int64` | 2.1 ms | 4.8 ms |
| decompress, random decimal text | 1.9 ms | 8.5 ms |
The previous codec is too slow for the same JMH run. From a simple
timing harness on the same machine:
| Input | Previous compress | New compress | Previous decompress | New
decompress |
| --- | ---: | ---: | ---: | ---: |
| 8 MiB sequential `int64` | 278 s | 7.8 ms | 93.5 ms | 2.2 ms |
| 8 MiB of `i % 1000` as text | 17.9 ms | 0.3 ms | 3.3 ms | 0.3 ms |
| 8 MiB random bytes | 243 ms | 0.5 ms | (stored) | (stored) |
| 1 MiB random decimal text | 38.5 s | 1.4 ms | 33.3 ms | 0.3 ms |
### Compression ratio
On the inputs above, the ratio is unchanged except for random decimal
text, where commons-compress's more thorough match search reached 1.61
and lz4-java's fast compressor reaches 1.10. The new output matches what
Arrow C++ writes by default: pyarrow's LZ4 codec also reaches 1.10 on
the same kind of data, and 1.63 at compression level 9.
`CommonsCompressionFactory.createCodec(LZ4_FRAME, level)` ignores the
level today. Mapping it to lz4-java's high-compression mode would give
users who want the better ratio a way to ask for it; I left that out to
keep this change focused, and can add it here or in a follow-up.
### Testing
- The existing `TestCompressionCodec` and
`TestArrowReaderWriterWithCompression` pass unchanged, including the
decompressed-length check from #1207.
- `TestLz4CompressionCodec` (new):
- round-trips buffers across block boundaries, including one whose first
block is incompressible and stored, and checks that commons-compress and
lz4-java's `LZ4FrameInputStream` both read the frames this codec writes;
- decompresses frames written by commons-compress for every block size,
with linked and independent blocks, with and without checksums, and a
frame written by lz4-java with a content size;
- rejects corrupt frames (descriptor, block and content checksums, a
missing end mark, blocks that run past the frame or exceed the maximum
block size, undecodable blocks, a wrong content size, and recorded
lengths that are too long or too short), and releases everything it
allocated.
- I also decoded pyarrow 26 output (buffers over 64 KiB, linked blocks)
by hand to confirm that lz4-java rejects linked frames and
commons-compress reads them, which is why the fallback stays.
### AI usage
The code, tests and benchmark in this PR were written with an AI coding
assistant. The frame behavior described above (pyarrow's linked blocks,
lz4-java's rejection of them) and the benchmark numbers come from
running them.
Closes #192.
---
compression/pom.xml | 5 +
compression/src/main/java/module-info.java | 1 +
.../arrow/compression/Lz4CompressionCodec.java | 323 +++++++++++++++++++--
.../arrow/compression/TestLz4CompressionCodec.java | 268 +++++++++++++++++
performance/pom.xml | 4 +
.../compression/CompressionCodecBenchmarks.java | 127 ++++++++
tools/src/shade/LICENSE.txt | 79 +++++
7 files changed, 775 insertions(+), 32 deletions(-)
diff --git a/compression/pom.xml b/compression/pom.xml
index f03fde247..fe3ed5e05 100644
--- a/compression/pom.xml
+++ b/compression/pom.xml
@@ -57,5 +57,10 @@ under the License.
<artifactId>zstd-jni</artifactId>
<version>1.5.7-25</version>
</dependency>
+ <dependency>
+ <groupId>at.yawk.lz4</groupId>
+ <artifactId>lz4-java</artifactId>
+ <version>1.12.1</version>
+ </dependency>
</dependencies>
</project>
diff --git a/compression/src/main/java/module-info.java
b/compression/src/main/java/module-info.java
index 113a1dba9..e43835500 100644
--- a/compression/src/main/java/module-info.java
+++ b/compression/src/main/java/module-info.java
@@ -24,6 +24,7 @@ module org.apache.arrow.compression {
requires org.apache.arrow.memory.core;
requires org.apache.arrow.vector;
requires org.apache.commons.compress;
+ requires org.lz4.java;
// Also defined under META-INF/services to support non-modular applications
provides CompressionCodec.Factory with
diff --git
a/compression/src/main/java/org/apache/arrow/compression/Lz4CompressionCodec.java
b/compression/src/main/java/org/apache/arrow/compression/Lz4CompressionCodec.java
index f268e815f..18cad8dbb 100644
---
a/compression/src/main/java/org/apache/arrow/compression/Lz4CompressionCodec.java
+++
b/compression/src/main/java/org/apache/arrow/compression/Lz4CompressionCodec.java
@@ -20,47 +20,259 @@ import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
-import java.io.OutputStream;
+import net.jpountz.lz4.LZ4Compressor;
+import net.jpountz.lz4.LZ4Exception;
+import net.jpountz.lz4.LZ4Factory;
+import net.jpountz.lz4.LZ4SafeDecompressor;
+import net.jpountz.xxhash.XXHash32;
+import net.jpountz.xxhash.XXHashFactory;
import org.apache.arrow.memory.ArrowBuf;
import org.apache.arrow.memory.BufferAllocator;
+import org.apache.arrow.memory.util.MemoryUtil;
import org.apache.arrow.util.Preconditions;
import org.apache.arrow.vector.compression.AbstractCompressionCodec;
import org.apache.arrow.vector.compression.CompressionUtil;
import
org.apache.commons.compress.compressors.lz4.FramedLZ4CompressorInputStream;
-import
org.apache.commons.compress.compressors.lz4.FramedLZ4CompressorOutputStream;
import org.apache.commons.compress.utils.IOUtils;
-/** Compression codec for the LZ4 algorithm. */
+/**
+ * Compression codec for the LZ4 frame format.
+ *
+ * <p>Each buffer is written as one frame of independent blocks of up to 4
MiB, without checksums,
+ * which every LZ4 frame decoder reads. Compression, and the decompression of
frames whose blocks
+ * are independent, use lz4-java on the buffers' memory directly. A frame
whose blocks are linked,
+ * as Arrow C++ writes for a buffer larger than 64 KiB, or that uses a
dictionary, is decompressed
+ * with commons-compress, since lz4-java cannot decode a block that refers to
earlier ones.
+ */
public class Lz4CompressionCodec extends AbstractCompressionCodec {
+ private static final long PREFIX =
CompressionUtil.SIZE_OF_UNCOMPRESSED_LENGTH;
+
+ private static final int MAGIC = 0x184D2204;
+
+ // The frame descriptor this codec writes: FLG (version 01, independent
blocks), BD (4 MiB
+ // maximum block size) and HC, the second byte of the xxHash32 of FLG and BD
with a zero seed.
+ private static final byte WRITE_FLG = 0x60;
+ private static final byte WRITE_BD = 0x70;
+ private static final byte WRITE_HC = 0x73;
+ private static final int WRITE_BLOCK_SIZE = 4 * 1024 * 1024;
+
+ private static final int FLG_VERSION_MASK = 0xC0;
+ private static final int FLG_VERSION = 0x40;
+ private static final int FLG_INDEPENDENT_BLOCKS = 0x20;
+ private static final int FLG_BLOCK_CHECKSUM = 0x10;
+ private static final int FLG_CONTENT_SIZE = 0x08;
+ private static final int FLG_CONTENT_CHECKSUM = 0x04;
+ private static final int FLG_RESERVED = 0x02;
+ private static final int FLG_DICTIONARY_ID = 0x01;
+ private static final int BD_RESERVED = 0x8F;
+
+ // A block length's high bit marks a block stored uncompressed. A zero
length ends the frame.
+ private static final int UNCOMPRESSED_BLOCK = 0x80000000;
+
+ // An LZ4 block cannot decompress to more than 255 times its compressed size.
+ private static final long MAX_COMPRESSION_RATIO = 255;
+
@Override
protected ArrowBuf doCompress(BufferAllocator allocator, ArrowBuf
uncompressedBuffer) {
- Preconditions.checkArgument(
- uncompressedBuffer.writerIndex() <= Integer.MAX_VALUE,
- "The uncompressed buffer size exceeds the integer limit %s.",
- Integer.MAX_VALUE);
+ LZ4Compressor compressor = Lz4.COMPRESSOR;
+ long length = uncompressedBuffer.writerIndex();
+ long fullBlocks = length / WRITE_BLOCK_SIZE;
+ int lastBlock = (int) (length % WRITE_BLOCK_SIZE);
+ long bound =
+ PREFIX
+ + 7
+ + fullBlocks * (Integer.BYTES +
compressor.maxCompressedLength(WRITE_BLOCK_SIZE))
+ + (lastBlock > 0 ? Integer.BYTES +
compressor.maxCompressedLength(lastBlock) : 0)
+ + Integer.BYTES;
+ ArrowBuf frame = allocator.buffer(bound);
+ try {
+ setIntLE(frame, PREFIX, MAGIC);
+ frame.setByte(PREFIX + 4, WRITE_FLG);
+ frame.setByte(PREFIX + 5, WRITE_BD);
+ frame.setByte(PREFIX + 6, WRITE_HC);
+ long at = PREFIX + 7;
+ for (long from = 0; from < length; ) {
+ int n = (int) Math.min(WRITE_BLOCK_SIZE, length - from);
+ int room = compressor.maxCompressedLength(n);
+ int packed =
+ compressor.compress(
+ uncompressedBuffer.nioBuffer(from, n),
+ 0,
+ n,
+ frame.nioBuffer(at + Integer.BYTES, room),
+ 0,
+ room);
+ if (packed < n) {
+ setIntLE(frame, at, packed);
+ at += Integer.BYTES + packed;
+ } else {
+ setIntLE(frame, at, n | UNCOMPRESSED_BLOCK);
+ frame.setBytes(at + Integer.BYTES, uncompressedBuffer, from, n);
+ at += Integer.BYTES + n;
+ }
+ from += n;
+ }
+ setIntLE(frame, at, 0);
+ frame.writerIndex(at + Integer.BYTES);
+ return frame;
+ } catch (RuntimeException | Error e) {
+ frame.close();
+ throw e;
+ }
+ }
- byte[] inBytes = new byte[(int) uncompressedBuffer.writerIndex()];
- uncompressedBuffer.getBytes(/* index= */ 0, inBytes);
- ByteArrayOutputStream baos = new ByteArrayOutputStream();
- try (InputStream in = new ByteArrayInputStream(inBytes);
- OutputStream out = new FramedLZ4CompressorOutputStream(baos)) {
- IOUtils.copy(in, out);
- } catch (IOException e) {
- throw new RuntimeException(e);
+ @Override
+ protected ArrowBuf doDecompress(BufferAllocator allocator, ArrowBuf
compressedBuffer) {
+ long length = readUncompressedLength(compressedBuffer);
+ long end = compressedBuffer.writerIndex();
+ if (end - PREFIX < 7 || getIntLE(compressedBuffer, PREFIX) != MAGIC) {
+ return decompressWithCommonsCompress(allocator, compressedBuffer);
+ }
+ int flg = compressedBuffer.getByte(PREFIX + 4) & 0xFF;
+ int bd = compressedBuffer.getByte(PREFIX + 5) & 0xFF;
+ int blockSizeId = (bd >>> 4) & 0x07;
+ boolean contentChecksum = (flg & FLG_CONTENT_CHECKSUM) != 0;
+ if ((flg & FLG_VERSION_MASK) != FLG_VERSION
+ || (flg & (FLG_RESERVED | FLG_DICTIONARY_ID)) != 0
+ || (flg & FLG_INDEPENDENT_BLOCKS) == 0
+ || (bd & BD_RESERVED) != 0
+ || blockSizeId < 4
+ || (contentChecksum && length > Integer.MAX_VALUE)) {
+ return decompressWithCommonsCompress(allocator, compressedBuffer);
}
+ boolean blockChecksum = (flg & FLG_BLOCK_CHECKSUM) != 0;
+ int maxBlockSize = 1 << (8 + 2 * blockSizeId);
- byte[] outBytes = baos.toByteArray();
+ long at = PREFIX + 6;
+ if ((flg & FLG_CONTENT_SIZE) != 0) {
+ if (end - at < Long.BYTES + 1) {
+ throw corrupt("the frame descriptor is cut off");
+ }
+ long contentSize = getLongLE(compressedBuffer, at);
+ if (contentSize != length) {
+ throw corrupt(
+ "the frame records a content size of "
+ + contentSize
+ + " bytes, but the buffer's decompressed length is "
+ + length);
+ }
+ at += Long.BYTES;
+ }
+ int descriptorLength = (int) (at - (PREFIX + 4));
+ int headerChecksum =
+ (Lz4.XXHASH.hash(
+ compressedBuffer.nioBuffer(PREFIX + 4, descriptorLength),
+ 0,
+ descriptorLength,
+ 0)
+ >>> 8)
+ & 0xFF;
+ if ((compressedBuffer.getByte(at) & 0xFF) != headerChecksum) {
+ throw corrupt("the frame descriptor's checksum does not match");
+ }
+ at += 1;
- ArrowBuf compressedBuffer =
- allocator.buffer(CompressionUtil.SIZE_OF_UNCOMPRESSED_LENGTH +
outBytes.length);
- compressedBuffer.setBytes(CompressionUtil.SIZE_OF_UNCOMPRESSED_LENGTH,
outBytes);
- compressedBuffer.writerIndex(CompressionUtil.SIZE_OF_UNCOMPRESSED_LENGTH +
outBytes.length);
- return compressedBuffer;
+ // Check the blocks' lengths, and that they can hold the decompressed
length, before
+ // allocating for it.
+ long blocksStart = at;
+ long maxDecompressedLength = 0;
+ while (true) {
+ if (end - at < Integer.BYTES) {
+ throw corrupt("the frame ends before its end mark");
+ }
+ int word = getIntLE(compressedBuffer, at);
+ at += Integer.BYTES;
+ if (word == 0) {
+ break;
+ }
+ int n = word & ~UNCOMPRESSED_BLOCK;
+ if (n > maxBlockSize) {
+ throw corrupt("a block of " + n + " bytes is larger than the frame's
maximum block size");
+ }
+ if (n > end - at) {
+ throw corrupt("a block of " + n + " bytes runs past the end of the
frame");
+ }
+ maxDecompressedLength +=
+ (word & UNCOMPRESSED_BLOCK) != 0 ? n : Math.min(maxBlockSize, n *
MAX_COMPRESSION_RATIO);
+ at += n;
+ if (blockChecksum) {
+ if (end - at < Integer.BYTES) {
+ throw corrupt("the frame ends before a block's checksum");
+ }
+ at += Integer.BYTES;
+ }
+ }
+ if (contentChecksum && end - at < Integer.BYTES) {
+ throw corrupt("the frame ends before its content checksum");
+ }
+ if (length < 0 || length > maxDecompressedLength) {
+ throw corrupt(
+ "the frame decompresses to at most "
+ + maxDecompressedLength
+ + " bytes, not its recorded decompressed length of "
+ + length);
+ }
+
+ ArrowBuf decompressedBuffer = allocator.buffer(length);
+ try {
+ LZ4SafeDecompressor decompressor = Lz4.DECOMPRESSOR;
+ at = blocksStart;
+ long to = 0;
+ while (true) {
+ int word = getIntLE(compressedBuffer, at);
+ at += Integer.BYTES;
+ if (word == 0) {
+ break;
+ }
+ int n = word & ~UNCOMPRESSED_BLOCK;
+ if (blockChecksum
+ && Lz4.XXHASH.hash(compressedBuffer.nioBuffer(at, n), 0, n, 0)
+ != getIntLE(compressedBuffer, at + n)) {
+ throw corrupt("a block's checksum does not match");
+ }
+ if ((word & UNCOMPRESSED_BLOCK) != 0) {
+ if (n > length - to) {
+ throw corrupt(
+ "the frame holds more than its recorded decompressed length of
" + length);
+ }
+ decompressedBuffer.setBytes(to, compressedBuffer, at, n);
+ to += n;
+ } else {
+ int room = (int) Math.min(maxBlockSize, length - to);
+ try {
+ to +=
+ decompressor.decompress(
+ compressedBuffer.nioBuffer(at, n),
+ 0,
+ n,
+ decompressedBuffer.nioBuffer(to, room),
+ 0,
+ room);
+ } catch (LZ4Exception e) {
+ throw corrupt("a block does not decompress within " + room + "
bytes", e);
+ }
+ }
+ at += n + (blockChecksum ? Integer.BYTES : 0);
+ }
+ if (to != length) {
+ throw wrongLength(length, to);
+ }
+ if (contentChecksum
+ && Lz4.XXHASH.hash(decompressedBuffer.nioBuffer(0, (int) length), 0,
(int) length, 0)
+ != getIntLE(compressedBuffer, at)) {
+ throw corrupt("the frame's content checksum does not match");
+ }
+ decompressedBuffer.writerIndex(length);
+ return decompressedBuffer;
+ } catch (RuntimeException | Error e) {
+ decompressedBuffer.close();
+ throw e;
+ }
}
- @Override
- protected ArrowBuf doDecompress(BufferAllocator allocator, ArrowBuf
compressedBuffer) {
+ private ArrowBuf decompressWithCommonsCompress(
+ BufferAllocator allocator, ArrowBuf compressedBuffer) {
Preconditions.checkArgument(
compressedBuffer.writerIndex() <= Integer.MAX_VALUE,
"The compressed buffer size exceeds the integer limit %s",
@@ -68,10 +280,8 @@ public class Lz4CompressionCodec extends
AbstractCompressionCodec {
long decompressedLength = readUncompressedLength(compressedBuffer);
- byte[] inBytes =
- new byte
- [(int) (compressedBuffer.writerIndex() -
CompressionUtil.SIZE_OF_UNCOMPRESSED_LENGTH)];
- compressedBuffer.getBytes(CompressionUtil.SIZE_OF_UNCOMPRESSED_LENGTH,
inBytes);
+ byte[] inBytes = new byte[(int) (compressedBuffer.writerIndex() - PREFIX)];
+ compressedBuffer.getBytes(PREFIX, inBytes);
ByteArrayOutputStream out = new ByteArrayOutputStream((int)
decompressedLength);
try (InputStream in = new FramedLZ4CompressorInputStream(new
ByteArrayInputStream(inBytes))) {
IOUtils.copy(in, out);
@@ -81,11 +291,7 @@ public class Lz4CompressionCodec extends
AbstractCompressionCodec {
byte[] outBytes = out.toByteArray();
if (outBytes.length != decompressedLength) {
- throw new RuntimeException(
- "Expected != actual decompressed length: "
- + decompressedLength
- + " != "
- + outBytes.length);
+ throw wrongLength(decompressedLength, outBytes.length);
}
ArrowBuf decompressedBuffer = allocator.buffer(outBytes.length);
decompressedBuffer.setBytes(/* index= */ 0, outBytes);
@@ -97,4 +303,57 @@ public class Lz4CompressionCodec extends
AbstractCompressionCodec {
public CompressionUtil.CodecType getCodecType() {
return CompressionUtil.CodecType.LZ4_FRAME;
}
+
+ private static RuntimeException corrupt(String reason) {
+ return new IllegalArgumentException("Corrupt LZ4 frame: " + reason);
+ }
+
+ private static RuntimeException corrupt(String reason, Throwable cause) {
+ return new IllegalArgumentException("Corrupt LZ4 frame: " + reason, cause);
+ }
+
+ private static RuntimeException wrongLength(long expected, long actual) {
+ return new RuntimeException(
+ "Expected != actual decompressed length: " + expected + " != " +
actual);
+ }
+
+ private static int getIntLE(ArrowBuf buf, long index) {
+ int value = buf.getInt(index);
+ return MemoryUtil.LITTLE_ENDIAN ? value : Integer.reverseBytes(value);
+ }
+
+ private static long getLongLE(ArrowBuf buf, long index) {
+ long value = buf.getLong(index);
+ return MemoryUtil.LITTLE_ENDIAN ? value : Long.reverseBytes(value);
+ }
+
+ private static void setIntLE(ArrowBuf buf, long index, int value) {
+ buf.setInt(index, MemoryUtil.LITTLE_ENDIAN ? value :
Integer.reverseBytes(value));
+ }
+
+ /** lz4-java's implementations, loaded on first use. */
+ private static final class Lz4 {
+ // fastestInstance() picks the JNI implementations only when lz4-java was
loaded by the system
+ // class loader, and otherwise settles for the Java ones, so ask for the
JNI ones directly.
+ static final LZ4Factory FACTORY = lz4Factory();
+ static final LZ4Compressor COMPRESSOR = FACTORY.fastCompressor();
+ static final LZ4SafeDecompressor DECOMPRESSOR = FACTORY.safeDecompressor();
+ static final XXHash32 XXHASH = xxHashFactory().hash32();
+
+ private static LZ4Factory lz4Factory() {
+ try {
+ return LZ4Factory.nativeInstance();
+ } catch (LinkageError | RuntimeException e) {
+ return LZ4Factory.fastestJavaInstance();
+ }
+ }
+
+ private static XXHashFactory xxHashFactory() {
+ try {
+ return XXHashFactory.nativeInstance();
+ } catch (LinkageError | RuntimeException e) {
+ return XXHashFactory.fastestJavaInstance();
+ }
+ }
+ }
}
diff --git
a/compression/src/test/java/org/apache/arrow/compression/TestLz4CompressionCodec.java
b/compression/src/test/java/org/apache/arrow/compression/TestLz4CompressionCodec.java
new file mode 100644
index 000000000..37068e696
--- /dev/null
+++
b/compression/src/test/java/org/apache/arrow/compression/TestLz4CompressionCodec.java
@@ -0,0 +1,268 @@
+/*
+ * 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.arrow.compression;
+
+import static org.junit.jupiter.api.Assertions.assertArrayEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+import java.util.Random;
+import java.util.function.Consumer;
+import java.util.stream.Stream;
+import net.jpountz.lz4.LZ4FrameInputStream;
+import net.jpountz.lz4.LZ4FrameOutputStream;
+import org.apache.arrow.memory.ArrowBuf;
+import org.apache.arrow.memory.BufferAllocator;
+import org.apache.arrow.memory.RootAllocator;
+import org.apache.arrow.vector.compression.CompressionCodec;
+import org.apache.arrow.vector.compression.CompressionUtil;
+import
org.apache.commons.compress.compressors.lz4.FramedLZ4CompressorInputStream;
+import
org.apache.commons.compress.compressors.lz4.FramedLZ4CompressorOutputStream;
+import
org.apache.commons.compress.compressors.lz4.FramedLZ4CompressorOutputStream.BlockSize;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.MethodSource;
+import org.junit.jupiter.params.provider.ValueSource;
+
+/** Test cases for {@link Lz4CompressionCodec}'s LZ4 frames. */
+class TestLz4CompressionCodec {
+ private static final int MIB = 1024 * 1024;
+
+ // Long enough for several 64 KiB blocks.
+ private static final int OTHER_WRITER_LENGTH = 200 * 1024;
+
+ private final CompressionCodec codec = new Lz4CompressionCodec();
+ private BufferAllocator allocator;
+
+ @BeforeEach
+ void init() {
+ allocator = new RootAllocator(Integer.MAX_VALUE);
+ }
+
+ @AfterEach
+ void terminate() {
+ allocator.close();
+ }
+
+ @ParameterizedTest
+ @ValueSource(ints = {1, 1000, 64 * 1024 + 1, 4 * MIB, 4 * MIB + 1, 9 * MIB})
+ void testRoundTrip(int length) throws IOException {
+ checkRoundTrip(compressible(length));
+ }
+
+ @Test
+ void testRoundTripWithStoredBlock() throws IOException {
+ // The first 4 MiB block is incompressible, so it is stored, while the
rest compresses.
+ byte[] data = new byte[9 * MIB];
+ byte[] noise = new byte[4 * MIB];
+ new Random(42).nextBytes(noise);
+ System.arraycopy(noise, 0, data, 0, noise.length);
+ checkRoundTrip(data);
+ }
+
+ /** Round-trips {@code data}, and checks that other decoders read the frame
it compresses to. */
+ private void checkRoundTrip(byte[] data) throws IOException {
+ ArrowBuf compressed = codec.compress(allocator, toArrowBuf(data));
+ // A buffer that does not compress is sent as it is, without a frame.
+ if (compressed.getLong(0) != CompressionUtil.NO_COMPRESSION_LENGTH) {
+ byte[] frame = new byte[(int) (compressed.writerIndex() - Long.BYTES)];
+ compressed.getBytes(Long.BYTES, frame);
+ try (InputStream in = new FramedLZ4CompressorInputStream(new
ByteArrayInputStream(frame))) {
+ assertArrayEquals(data, in.readAllBytes());
+ }
+ try (InputStream in = new LZ4FrameInputStream(new
ByteArrayInputStream(frame))) {
+ assertArrayEquals(data, in.readAllBytes());
+ }
+ }
+ assertArrayEquals(data, decompress(compressed));
+ }
+
+ static Stream<Arguments> otherWriters() {
+ List<Arguments> writers = new ArrayList<>();
+ for (BlockSize blockSize : BlockSize.values()) {
+ for (boolean linked : new boolean[] {false, true}) {
+ for (boolean checksums : new boolean[] {false, true}) {
+ FramedLZ4CompressorOutputStream.Parameters parameters =
+ new FramedLZ4CompressorOutputStream.Parameters(
+ blockSize, checksums, checksums, linked);
+ writers.add(
+ Arguments.of(
+ "commons-compress " + parameters,
+ (Writer) out -> new FramedLZ4CompressorOutputStream(out,
parameters)));
+ }
+ }
+ }
+ writers.add(
+ Arguments.of(
+ "lz4-java with content size and block checksums",
+ (Writer)
+ out ->
+ new LZ4FrameOutputStream(
+ out,
+ LZ4FrameOutputStream.BLOCKSIZE.SIZE_64KB,
+ OTHER_WRITER_LENGTH,
+ LZ4FrameOutputStream.FLG.Bits.BLOCK_INDEPENDENCE,
+ LZ4FrameOutputStream.FLG.Bits.BLOCK_CHECKSUM,
+ LZ4FrameOutputStream.FLG.Bits.CONTENT_SIZE)));
+ return writers.stream();
+ }
+
+ @ParameterizedTest(name = "{0}")
+ @MethodSource("otherWriters")
+ void testDecompressOtherWriters(String name, Writer writer) throws
IOException {
+ byte[] data = repetitive(OTHER_WRITER_LENGTH);
+ assertArrayEquals(data, decompress(frameOf(data, writer)));
+ }
+
+ static Stream<Arguments> corruptions() {
+ // Offsets are into the buffer: the 8-byte length prefix, then the frame,
whose descriptor
+ // is FLG, BD and HC after the 4-byte magic number. The first block's
length follows at 15.
+ return Stream.of(
+ corruption("a wrong descriptor checksum", buf -> buf.setByte(14,
buf.getByte(14) ^ 1)),
+ corruption("no end mark", buf -> buf.writerIndex(buf.writerIndex() -
Integer.BYTES)),
+ corruption("a block that runs past the frame", buf -> buf.setInt(15, 1
<< 20)),
+ corruption("a block larger than the maximum", buf -> buf.setInt(15, 5
* MIB)),
+ corruption("compressed bytes that do not decode", buf ->
buf.setLong(19, -1L)),
+ corruption("a recorded length that the frame cannot hold", buf ->
buf.setLong(0, MIB)),
+ corruption("a recorded length that is too short", buf ->
buf.setLong(0, 1000)));
+ }
+
+ private static Arguments corruption(String name, Consumer<ArrowBuf> corrupt)
{
+ return Arguments.of(name, corrupt);
+ }
+
+ @ParameterizedTest(name = "{0}")
+ @MethodSource("corruptions")
+ void testDecompressCorruptFrame(String name, Consumer<ArrowBuf> corrupt) {
+ ArrowBuf compressed = codec.compress(allocator,
toArrowBuf(compressible(4096)));
+ corrupt.accept(compressed);
+ assertThrows(RuntimeException.class, () -> codec.decompress(allocator,
compressed));
+ compressed.close();
+ }
+
+ @Test
+ void testDecompressWrongChecksums() throws IOException {
+ byte[] data = compressible(4096);
+ Writer writer =
+ out ->
+ new FramedLZ4CompressorOutputStream(
+ out,
+ new FramedLZ4CompressorOutputStream.Parameters(BlockSize.K64,
true, true, false));
+ // The frame ends with a block's checksum, the end mark and the content
checksum.
+ ArrowBuf block = frameOf(data, writer);
+ block.setByte(block.writerIndex() - 12, block.getByte(block.writerIndex()
- 12) ^ 1);
+ RuntimeException e =
+ assertThrows(RuntimeException.class, () -> codec.decompress(allocator,
block));
+ assertTrue(e.getMessage().contains("block's checksum"), e.getMessage());
+ block.close();
+
+ ArrowBuf content = frameOf(data, writer);
+ content.setByte(content.writerIndex() - 1,
content.getByte(content.writerIndex() - 1) ^ 1);
+ e = assertThrows(RuntimeException.class, () -> codec.decompress(allocator,
content));
+ assertTrue(e.getMessage().contains("content checksum"), e.getMessage());
+ content.close();
+ }
+
+ @Test
+ void testDecompressWrongContentSize() throws IOException {
+ byte[] data = compressible(4096);
+ ArrowBuf compressed =
+ frameOf(
+ data,
+ out ->
+ new LZ4FrameOutputStream(
+ out,
+ LZ4FrameOutputStream.BLOCKSIZE.SIZE_64KB,
+ data.length,
+ LZ4FrameOutputStream.FLG.Bits.BLOCK_INDEPENDENCE,
+ LZ4FrameOutputStream.FLG.Bits.CONTENT_SIZE));
+ compressed.setLong(0, data.length - 1);
+ RuntimeException e =
+ assertThrows(RuntimeException.class, () -> codec.decompress(allocator,
compressed));
+ assertTrue(e.getMessage().contains("content size"), e.getMessage());
+ compressed.close();
+ }
+
+ /** Opens a stream that writes an LZ4 frame. */
+ @FunctionalInterface
+ interface Writer {
+ OutputStream open(OutputStream out) throws IOException;
+ }
+
+ /** {@code data} compressed by {@code writer}, behind Arrow's
uncompressed-length prefix. */
+ private ArrowBuf frameOf(byte[] data, Writer writer) throws IOException {
+ ByteArrayOutputStream frame = new ByteArrayOutputStream();
+ try (OutputStream out = writer.open(frame)) {
+ out.write(data);
+ }
+ byte[] bytes = frame.toByteArray();
+ ArrowBuf buf = allocator.buffer(Long.BYTES + bytes.length);
+ buf.setLong(0, data.length);
+ buf.setBytes(Long.BYTES, bytes);
+ buf.writerIndex(Long.BYTES + bytes.length);
+ return buf;
+ }
+
+ private byte[] decompress(ArrowBuf compressed) {
+ try (ArrowBuf decompressed = codec.decompress(allocator, compressed)) {
+ byte[] bytes = new byte[(int) decompressed.writerIndex()];
+ decompressed.getBytes(0, bytes);
+ return bytes;
+ }
+ }
+
+ private ArrowBuf toArrowBuf(byte[] data) {
+ ArrowBuf buf = allocator.buffer(data.length);
+ buf.setBytes(0, data);
+ buf.writerIndex(data.length);
+ return buf;
+ }
+
+ /**
+ * Decimal numbers that repeat every thousand, for commons-compress, whose
compressor takes
+ * seconds per 100 KiB of input with many short matches, such as {@link
#compressible}'s.
+ */
+ private static byte[] repetitive(int length) {
+ StringBuilder text = new StringBuilder(length + 8);
+ for (int i = 0; text.length() < length; i++) {
+ text.append(i % 1000).append(',');
+ }
+ return Arrays.copyOf(text.toString().getBytes(StandardCharsets.US_ASCII),
length);
+ }
+
+ /** Random decimal numbers as text, which compress well but not trivially. */
+ private static byte[] compressible(int length) {
+ Random random = new Random(length);
+ StringBuilder text = new StringBuilder(length + 8);
+ while (text.length() < length) {
+ text.append(random.nextInt(100_000)).append(',');
+ }
+ return Arrays.copyOf(text.toString().getBytes(StandardCharsets.US_ASCII),
length);
+ }
+}
diff --git a/performance/pom.xml b/performance/pom.xml
index 744df7e62..a6491048b 100644
--- a/performance/pom.xml
+++ b/performance/pom.xml
@@ -86,6 +86,10 @@ under the License.
<groupId>org.apache.arrow</groupId>
<artifactId>arrow-algorithm</artifactId>
</dependency>
+ <dependency>
+ <groupId>org.apache.arrow</groupId>
+ <artifactId>arrow-compression</artifactId>
+ </dependency>
</dependencies>
<build>
diff --git
a/performance/src/main/java/org/apache/arrow/compression/CompressionCodecBenchmarks.java
b/performance/src/main/java/org/apache/arrow/compression/CompressionCodecBenchmarks.java
new file mode 100644
index 000000000..3d2e476a9
--- /dev/null
+++
b/performance/src/main/java/org/apache/arrow/compression/CompressionCodecBenchmarks.java
@@ -0,0 +1,127 @@
+/*
+ * 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.arrow.compression;
+
+import java.nio.charset.StandardCharsets;
+import java.util.Random;
+import java.util.concurrent.TimeUnit;
+import org.apache.arrow.memory.ArrowBuf;
+import org.apache.arrow.memory.BufferAllocator;
+import org.apache.arrow.memory.RootAllocator;
+import org.apache.arrow.vector.compression.CompressionCodec;
+import org.apache.arrow.vector.compression.CompressionUtil;
+import org.openjdk.jmh.annotations.Benchmark;
+import org.openjdk.jmh.annotations.BenchmarkMode;
+import org.openjdk.jmh.annotations.Level;
+import org.openjdk.jmh.annotations.Mode;
+import org.openjdk.jmh.annotations.OutputTimeUnit;
+import org.openjdk.jmh.annotations.Param;
+import org.openjdk.jmh.annotations.Scope;
+import org.openjdk.jmh.annotations.Setup;
+import org.openjdk.jmh.annotations.State;
+import org.openjdk.jmh.annotations.TearDown;
+import org.openjdk.jmh.runner.Runner;
+import org.openjdk.jmh.runner.RunnerException;
+import org.openjdk.jmh.runner.options.Options;
+import org.openjdk.jmh.runner.options.OptionsBuilder;
+
+/** Benchmarks for the {@link CompressionCodec}s of {@link
CommonsCompressionFactory}. */
+@State(Scope.Benchmark)
+public class CompressionCodecBenchmarks {
+ // checkstyle:off: MissingJavadocMethod
+
+ private static final int BUFFER_LENGTH = 8 * 1024 * 1024;
+
+ @Param({"LZ4_FRAME", "ZSTD"})
+ public CompressionUtil.CodecType codecType;
+
+ /** {@code int64}: little-endian longs that count up. {@code text}: random
decimal numbers. */
+ @Param({"int64", "text"})
+ public String data;
+
+ private BufferAllocator allocator;
+
+ private CompressionCodec codec;
+
+ private ArrowBuf uncompressed;
+
+ private ArrowBuf compressed;
+
+ /** Setup benchmarks. */
+ @Setup(Level.Trial)
+ public void prepare() {
+ allocator = new RootAllocator();
+ codec = CommonsCompressionFactory.INSTANCE.createCodec(codecType);
+ uncompressed = allocator.buffer(BUFFER_LENGTH);
+ if (data.equals("int64")) {
+ for (int i = 0; i < BUFFER_LENGTH / Long.BYTES; i++) {
+ uncompressed.setLong((long) i * Long.BYTES, i);
+ }
+ } else {
+ Random random = new Random(0);
+ StringBuilder text = new StringBuilder(BUFFER_LENGTH + 8);
+ while (text.length() < BUFFER_LENGTH) {
+ text.append(random.nextInt(100_000)).append(',');
+ }
+ uncompressed.setBytes(
+ 0, text.substring(0,
BUFFER_LENGTH).getBytes(StandardCharsets.US_ASCII));
+ }
+ uncompressed.writerIndex(BUFFER_LENGTH);
+ compressed = compress();
+ }
+
+ /** Tear down benchmarks. */
+ @TearDown(Level.Trial)
+ public void tearDown() {
+ compressed.close();
+ uncompressed.close();
+ allocator.close();
+ }
+
+ private ArrowBuf compress() {
+ // compress() releases its input, so give it a reference of its own.
+ uncompressed.getReferenceManager().retain();
+ return codec.compress(allocator, uncompressed);
+ }
+
+ @Benchmark
+ @BenchmarkMode(Mode.AverageTime)
+ @OutputTimeUnit(TimeUnit.MILLISECONDS)
+ public void compressBenchmark() {
+ compress().close();
+ }
+
+ @Benchmark
+ @BenchmarkMode(Mode.AverageTime)
+ @OutputTimeUnit(TimeUnit.MILLISECONDS)
+ public void decompressBenchmark() {
+ // decompress() releases its input, so give it a reference of its own.
+ compressed.getReferenceManager().retain();
+ codec.decompress(allocator, compressed).close();
+ }
+
+ public static void main(String[] args) throws RunnerException {
+ Options opt =
+ new OptionsBuilder()
+ .include(CompressionCodecBenchmarks.class.getSimpleName())
+ .forks(1)
+ .build();
+
+ new Runner(opt).run();
+ }
+ // checkstyle:on: MissingJavadocMethod
+}
diff --git a/tools/src/shade/LICENSE.txt b/tools/src/shade/LICENSE.txt
index eef614e7f..cfed4924d 100644
--- a/tools/src/shade/LICENSE.txt
+++ b/tools/src/shade/LICENSE.txt
@@ -329,3 +329,82 @@ License text:
| LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
| OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
| WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+
+--------------------------------------------------------------------------------
+
+This binary artifact contains lz4-java 1.12.1.
+
+Copyright: Copyright 2020 Adrien Grand and the lz4-java contributors.
+Home page: https://github.com/yawkat/lz4-java
+License: https://www.apache.org/licenses/LICENSE-2.0
+
+--------------------------------------------------------------------------------
+
+This binary artifact contains LZ4 1.10.0, in the native libraries of lz4-java.
+
+Copyright: Copyright (c) 2011-2020, Yann Collet
+Home page: https://github.com/lz4/lz4
+License: BSD 2-Clause License
+License text:
+
+| LZ4 Library
+| Copyright (c) 2011-2020, Yann Collet
+| All rights reserved.
+|
+| Redistribution and use in source and binary forms, with or without
modification,
+| are permitted provided that the following conditions are met:
+|
+| * Redistributions of source code must retain the above copyright notice, this
+| list of conditions and the following disclaimer.
+|
+| * Redistributions in binary form must reproduce the above copyright notice,
this
+| list of conditions and the following disclaimer in the documentation and/or
+| other materials provided with the distribution.
+|
+| THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
AND
+| ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+| WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+| DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
FOR
+| ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
DAMAGES
+| (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
+| LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND
ON
+| ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+| (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
+| SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+
+--------------------------------------------------------------------------------
+
+This binary artifact contains xxHash 0.6.5, in the native libraries of
lz4-java.
+
+Copyright: Copyright (C) 2012-2016, Yann Collet
+Home page: https://github.com/Cyan4973/xxHash
+License: BSD 2-Clause License
+License text:
+
+| xxHash - Extremely Fast Hash algorithm
+| Copyright (C) 2012-2016, Yann Collet.
+|
+| BSD 2-Clause License (http://www.opensource.org/licenses/bsd-license.php)
+|
+| Redistribution and use in source and binary forms, with or without
+| modification, are permitted provided that the following conditions are
+| met:
+|
+| * Redistributions of source code must retain the above copyright
+| notice, this list of conditions and the following disclaimer.
+| * Redistributions in binary form must reproduce the above
+| copyright notice, this list of conditions and the following disclaimer
+| in the documentation and/or other materials provided with the
+| distribution.
+|
+| THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+| "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+| LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+| A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+| OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+| SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+| LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+| DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+| THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+| (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+| OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.