dongjoon-hyun commented on code in PR #59922:
URL: https://github.com/apache/spark/pull/59922#discussion_r4418702347
##########
sql/core/src/test/scala/org/apache/spark/sql/execution/arrow/ArrowCompressionUtilsSuite.scala:
##########
@@ -85,4 +101,110 @@ class ArrowCompressionUtilsSuite extends SparkFunSuite {
}
assert(e.getMessage.contains("Unsupported Arrow compression codec:
snappy"))
}
+
+ // Random bytes with about 2% of them zeroed. Pure random bytes only make
zstd fill its whole
+ // destination when one byte value happens to be frequent enough (more than
about n / 128 + 4
+ // occurrences) for zstd to try Huffman-coding the literals and give up.
Zeroing some bytes
+ // makes that the usual case instead of a property of the seed.
+ private def noisyBytes(random: Random, n: Int): Array[Byte] = {
+ val bytes = random.nextBytes(n)
+ (0 until n).foreach { i => if (random.nextInt(50) == 0) bytes(i) = 0 }
+ bytes
+ }
+
+ // Runs `compressRaw` into a buffer that is followed by sentinel bytes, and
returns whether the
+ // sentinel was overwritten, i.e. whether zstd wrote past the end of the
buffer.
+ private def writesPastEnd(
+ compressRaw: (BufferAllocator, ArrowBuf) => ArrowBuf,
+ data: Array[Byte]): Boolean = {
+ val guardSize = 64
+ val guardByte = 0x5A.toByte
+ var requestedSize = 0L
+ val guardedAllocator = new RootAllocator(Long.MaxValue) {
+ override def buffer(size: Long): ArrowBuf = {
+ requestedSize = size
+ val buf = super.buffer(size + guardSize)
+ buf.setBytes(size, Array.fill(guardSize)(guardByte))
+ buf
+ }
+ }
+ val inputAllocator =
+ ArrowUtils.rootAllocator.newChildAllocator(this.getClass.getSimpleName,
0, Long.MaxValue)
+ try {
+ val input = inputAllocator.buffer(data.length)
+ val out = try {
+ input.setBytes(0, data)
+ input.writerIndex(data.length)
+ compressRaw(guardedAllocator, input)
+ } finally {
+ input.close()
+ }
+ try {
+ val guard = new Array[Byte](guardSize)
+ out.getBytes(requestedSize, guard)
+ !guard.forall(_ == guardByte)
+ } finally {
+ out.close()
+ }
+ } finally {
+ guardedAllocator.close()
+ inputAllocator.close()
+ }
+ }
+
+ test("SPARK-60707: zstd compression does not write past the end of its
output buffer") {
+ val bounded = new RawBoundedZstdCompressionCodec
+ val stock = new RawStockZstdCompressionCodec
+ val random = new Random(0)
+ Seq(1, 7, 100, 280, 1000, 4096, 65536, 262144).foreach { n =>
Review Comment:
**1. [Low] Only the 280- and 1000-byte inputs of this loop can reach the end
of the buffer**
The control below settles the seed question. I replayed this test's exact
input sequence with zstd-jni 1.5.7-23 alone (`java.util.Random` behind
`scala.util.Random`, no Arrow or Spark code): for every seed from 0 to 299, at
least 83 of the 100 inputs of 500 bytes overrun with Arrow 19's capacity, and
this PR's capacity never does.
This loop is different. These are the seeds, out of 0 to 299, whose input at
each size overruns with Arrow 19's capacity:
| n | 1 | 7 | 100 | 280 | 1000 | 4096 | 65536 | 262144 |
|---|---|---|---|---|---|---|---|---|
| Seeds that overrun | 0 | 0 | 0 | 124 | 47 | 0 | 0 | 0 |
With seed 0, only n = 280 overruns. For independent `noisyBytes(n)` inputs,
the overrun shows up only between about 280 and 1000 bytes: 93 of 300 seeds at
280, 279 at 500, 285 at 700, 42 at 1000, 1 at 1200, and none at 250 and below
or at 1500 and above.
So six of the eight sizes check a capacity that zstd never gets close to,
and "makes that the usual case" on line 108 holds only around 500 bytes. They
do no harm as smoke checks, but a later change that broke the capacity only for
large buffers would still pass here. Could the comment say which sizes can
reach the end, or could the loop use sizes inside that range, such as 300, 500
and 700?
##########
sql/core/src/test/scala/org/apache/spark/sql/execution/arrow/ArrowCompressionUtilsSuite.scala:
##########
@@ -85,4 +101,110 @@ class ArrowCompressionUtilsSuite extends SparkFunSuite {
}
assert(e.getMessage.contains("Unsupported Arrow compression codec:
snappy"))
}
+
+ // Random bytes with about 2% of them zeroed. Pure random bytes only make
zstd fill its whole
+ // destination when one byte value happens to be frequent enough (more than
about n / 128 + 4
+ // occurrences) for zstd to try Huffman-coding the literals and give up.
Zeroing some bytes
+ // makes that the usual case instead of a property of the seed.
+ private def noisyBytes(random: Random, n: Int): Array[Byte] = {
+ val bytes = random.nextBytes(n)
+ (0 until n).foreach { i => if (random.nextInt(50) == 0) bytes(i) = 0 }
+ bytes
+ }
+
+ // Runs `compressRaw` into a buffer that is followed by sentinel bytes, and
returns whether the
+ // sentinel was overwritten, i.e. whether zstd wrote past the end of the
buffer.
+ private def writesPastEnd(
+ compressRaw: (BufferAllocator, ArrowBuf) => ArrowBuf,
+ data: Array[Byte]): Boolean = {
+ val guardSize = 64
+ val guardByte = 0x5A.toByte
+ var requestedSize = 0L
+ val guardedAllocator = new RootAllocator(Long.MaxValue) {
+ override def buffer(size: Long): ArrowBuf = {
+ requestedSize = size
+ val buf = super.buffer(size + guardSize)
+ buf.setBytes(size, Array.fill(guardSize)(guardByte))
+ buf
+ }
+ }
+ val inputAllocator =
+ ArrowUtils.rootAllocator.newChildAllocator(this.getClass.getSimpleName,
0, Long.MaxValue)
+ try {
+ val input = inputAllocator.buffer(data.length)
+ val out = try {
+ input.setBytes(0, data)
+ input.writerIndex(data.length)
+ compressRaw(guardedAllocator, input)
+ } finally {
+ input.close()
+ }
+ try {
+ val guard = new Array[Byte](guardSize)
+ out.getBytes(requestedSize, guard)
+ !guard.forall(_ == guardByte)
+ } finally {
+ out.close()
+ }
+ } finally {
+ guardedAllocator.close()
+ inputAllocator.close()
+ }
+ }
+
+ test("SPARK-60707: zstd compression does not write past the end of its
output buffer") {
+ val bounded = new RawBoundedZstdCompressionCodec
+ val stock = new RawStockZstdCompressionCodec
+ val random = new Random(0)
+ Seq(1, 7, 100, 280, 1000, 4096, 65536, 262144).foreach { n =>
+ assert(!writesPastEnd(bounded.compressRaw, noisyBytes(random, n)),
+ s"zstd wrote past the end of its output buffer (input size $n)")
+ }
+ // At 500 bytes a large share of these inputs make Arrow's own codec
overrun the buffer.
+ val inputs = Seq.fill(100)(noisyBytes(random, 500))
+ inputs.foreach { data =>
+ assert(!writesPastEnd(bounded.compressRaw, data),
+ "zstd wrote past the end of its output buffer (input size 500)")
+ }
+ // Control: the inputs above do trigger the overrun in Arrow's own codec,
so the checks above
+ // can fail. If this starts failing, Arrow has fixed the codec and
BoundedZstdCompressionCodec
+ // can be removed.
Review Comment:
**2. [Low] The control can also fail after a zstd-jni upgrade, not only
after an Arrow fix**
The control passes because, for these inputs, zstd tries to Huffman-code the
literals, gives up, and on the way writes up to the end of the capacity it was
given. That depends on zstd's literal heuristics, such as the `(n >> 7) + 4`
gate in `HUF_compress_internal`, which are internals that zstd does not promise
to keep. A zstd-jni upgrade that changes them can make all 100 inputs stop
short of the end while Spark is still on arrow-java 19.0. The test would then
fail in the zstd-jni upgrade PR, and this comment would point at the wrong fix:
removing `BoundedZstdCompressionCodec` would bring back the overstated capacity
even though these inputs no longer show it.
Could the comment name both causes? For example:
```scala
// Control: the inputs above do trigger the overrun in Arrow's own
codec, so the checks above
// can fail. This depends on how zstd compresses literals. If it starts
failing after an Arrow
// upgrade, Arrow has fixed the codec and BoundedZstdCompressionCodec
can be removed; if it
// starts failing after a zstd-jni upgrade, pick inputs that still
overrun instead.
```
##########
sql/core/src/main/scala/org/apache/spark/sql/execution/columnar/ArrowCachedBatchSerializer.scala:
##########
@@ -1005,7 +985,7 @@ private class InternalRowToArrowCachedBatchIterator(
compressionCodecName: String,
compressionLevel: Int) extends Iterator[ArrowCachedBatch] {
- private val compressionCodec =
ArrowCachedBatchSerializer.createCompressionCodec(
+ private val compressionCodec = ArrowCompressionUtils.createCompressionCodec(
Review Comment:
**3. [Low] The PR description still describes the first commit**
Since 23dc6e39c9f, three parts of the description no longer match the code,
and the merge script copies the description into the commit message:
- "`ArrowCachedBatchSerializer.createCompressionCodec` was a second copy of
the same codec factory; it now delegates to
`ArrowCompressionUtils.createCompressionCodec`": the method is gone, and both
iterators here call `ArrowCompressionUtils.createCompressionCodec` directly.
Its level test in `ArrowCachedBatchSerializerSuite` is gone with it, as a copy
of the SPARK-57383 test.
- The guard test "compresses random (incompressible) inputs of several sizes
from 1 byte to 256 KB" and fails against the stock codec with "zstd wrote past
the end of a 572 byte output buffer (input size 500)": the inputs are now
`noisyBytes`, with 100 of them at 500 bytes and a control on the stock codec,
and that message no longer exists. By my replay, if `doCompress` went back to
Arrow 19's capacity, the test would now fail first at n = 280, with "zstd wrote
past the end of its output buffer (input size 280)".
- "`ArrowCachedBatchSerializerSuite` passed locally before
`ArrowCachedBatchSerializer` was changed to delegate to
`ArrowCompressionUtils`; after that change it is covered by CI": there is no
delegation any more.
##########
sql/core/src/test/scala/org/apache/spark/sql/execution/arrow/ArrowCompressionUtilsSuite.scala:
##########
@@ -85,4 +101,110 @@ class ArrowCompressionUtilsSuite extends SparkFunSuite {
}
assert(e.getMessage.contains("Unsupported Arrow compression codec:
snappy"))
}
+
+ // Random bytes with about 2% of them zeroed. Pure random bytes only make
zstd fill its whole
+ // destination when one byte value happens to be frequent enough (more than
about n / 128 + 4
+ // occurrences) for zstd to try Huffman-coding the literals and give up.
Zeroing some bytes
+ // makes that the usual case instead of a property of the seed.
+ private def noisyBytes(random: Random, n: Int): Array[Byte] = {
+ val bytes = random.nextBytes(n)
+ (0 until n).foreach { i => if (random.nextInt(50) == 0) bytes(i) = 0 }
+ bytes
+ }
+
+ // Runs `compressRaw` into a buffer that is followed by sentinel bytes, and
returns whether the
+ // sentinel was overwritten, i.e. whether zstd wrote past the end of the
buffer.
+ private def writesPastEnd(
+ compressRaw: (BufferAllocator, ArrowBuf) => ArrowBuf,
+ data: Array[Byte]): Boolean = {
+ val guardSize = 64
+ val guardByte = 0x5A.toByte
+ var requestedSize = 0L
+ val guardedAllocator = new RootAllocator(Long.MaxValue) {
+ override def buffer(size: Long): ArrowBuf = {
+ requestedSize = size
+ val buf = super.buffer(size + guardSize)
+ buf.setBytes(size, Array.fill(guardSize)(guardByte))
+ buf
+ }
+ }
+ val inputAllocator =
+ ArrowUtils.rootAllocator.newChildAllocator(this.getClass.getSimpleName,
0, Long.MaxValue)
+ try {
+ val input = inputAllocator.buffer(data.length)
+ val out = try {
+ input.setBytes(0, data)
+ input.writerIndex(data.length)
+ compressRaw(guardedAllocator, input)
+ } finally {
+ input.close()
+ }
+ try {
+ val guard = new Array[Byte](guardSize)
+ out.getBytes(requestedSize, guard)
+ !guard.forall(_ == guardByte)
+ } finally {
+ out.close()
+ }
+ } finally {
+ guardedAllocator.close()
+ inputAllocator.close()
+ }
Review Comment:
**4. [Nit] `writesPastEnd` still closes its allocators in a plain `finally`**
The round-trip test now keeps the first error with
`Utils.tryWithSafeFinally`, but this helper has the same pattern. If
`compressRaw` throws after allocating its output and leaves it open,
`guardedAllocator.close()` throws "Memory was leaked by query" in place of the
original exception. `inputAllocator.close()` is then skipped, so a child of the
shared `ArrowUtils.rootAllocator` stays open for the rest of the JVM.
Today only the zstd error branch of `doCompress` can fail after the
allocation, and it closes the buffer, so this matters only for a regression in
`doCompress`, for example a wrong `writerIndex` that throws
`IndexOutOfBoundsException` before the buffer is returned. For example:
```scala
Utils.tryWithSafeFinally {
...
} {
Utils.tryWithSafeFinally(guardedAllocator.close())(inputAllocator.close())
}
```
##########
sql/core/src/test/scala/org/apache/spark/sql/execution/arrow/ArrowCompressionUtilsSuite.scala:
##########
@@ -85,4 +101,110 @@ class ArrowCompressionUtilsSuite extends SparkFunSuite {
}
assert(e.getMessage.contains("Unsupported Arrow compression codec:
snappy"))
}
+
+ // Random bytes with about 2% of them zeroed. Pure random bytes only make
zstd fill its whole
+ // destination when one byte value happens to be frequent enough (more than
about n / 128 + 4
+ // occurrences) for zstd to try Huffman-coding the literals and give up.
Zeroing some bytes
+ // makes that the usual case instead of a property of the seed.
+ private def noisyBytes(random: Random, n: Int): Array[Byte] = {
+ val bytes = random.nextBytes(n)
+ (0 until n).foreach { i => if (random.nextInt(50) == 0) bytes(i) = 0 }
+ bytes
+ }
+
+ // Runs `compressRaw` into a buffer that is followed by sentinel bytes, and
returns whether the
+ // sentinel was overwritten, i.e. whether zstd wrote past the end of the
buffer.
+ private def writesPastEnd(
+ compressRaw: (BufferAllocator, ArrowBuf) => ArrowBuf,
+ data: Array[Byte]): Boolean = {
+ val guardSize = 64
+ val guardByte = 0x5A.toByte
+ var requestedSize = 0L
+ val guardedAllocator = new RootAllocator(Long.MaxValue) {
+ override def buffer(size: Long): ArrowBuf = {
+ requestedSize = size
+ val buf = super.buffer(size + guardSize)
+ buf.setBytes(size, Array.fill(guardSize)(guardByte))
+ buf
+ }
+ }
+ val inputAllocator =
+ ArrowUtils.rootAllocator.newChildAllocator(this.getClass.getSimpleName,
0, Long.MaxValue)
+ try {
+ val input = inputAllocator.buffer(data.length)
+ val out = try {
+ input.setBytes(0, data)
+ input.writerIndex(data.length)
+ compressRaw(guardedAllocator, input)
+ } finally {
+ input.close()
+ }
+ try {
+ val guard = new Array[Byte](guardSize)
+ out.getBytes(requestedSize, guard)
+ !guard.forall(_ == guardByte)
+ } finally {
+ out.close()
+ }
+ } finally {
+ guardedAllocator.close()
+ inputAllocator.close()
+ }
+ }
+
+ test("SPARK-60707: zstd compression does not write past the end of its
output buffer") {
+ val bounded = new RawBoundedZstdCompressionCodec
+ val stock = new RawStockZstdCompressionCodec
+ val random = new Random(0)
+ Seq(1, 7, 100, 280, 1000, 4096, 65536, 262144).foreach { n =>
+ assert(!writesPastEnd(bounded.compressRaw, noisyBytes(random, n)),
+ s"zstd wrote past the end of its output buffer (input size $n)")
+ }
+ // At 500 bytes a large share of these inputs make Arrow's own codec
overrun the buffer.
+ val inputs = Seq.fill(100)(noisyBytes(random, 500))
+ inputs.foreach { data =>
+ assert(!writesPastEnd(bounded.compressRaw, data),
+ "zstd wrote past the end of its output buffer (input size 500)")
+ }
+ // Control: the inputs above do trigger the overrun in Arrow's own codec,
so the checks above
+ // can fail. If this starts failing, Arrow has fixed the codec and
BoundedZstdCompressionCodec
+ // can be removed.
+ assert(inputs.exists(writesPastEnd(stock.compressRaw, _)),
+ "none of the inputs made Arrow's ZstdCompressionCodec write past its
output buffer")
+ }
+
+ test("SPARK-60707: zstd codec round-trips compressible and incompressible
buffers") {
+ val allocator =
+ ArrowUtils.rootAllocator.newChildAllocator(this.getClass.getSimpleName,
0, Long.MaxValue)
+ val codec = ArrowCompressionUtils.createCompressionCodec("zstd", 3)
+ val random = new Random(1)
+ Utils.tryWithSafeFinally {
+ // The write paths get the bounded codec, not Arrow's own.
+ assert(codec.isInstanceOf[BoundedZstdCompressionCodec])
+ Seq(1, 100, 4096, 100000).foreach { n =>
+ val compressible = Array.tabulate[Byte](n)(i => (i % 7).toByte)
+ val incompressible = random.nextBytes(n)
+ Seq(compressible, incompressible).foreach { expected =>
+ val input = allocator.buffer(n)
+ input.setBytes(0, expected)
+ input.writerIndex(n)
+ val compressed = codec.compress(allocator, input)
+ // The buffer starts with the uncompressed length, or -1 if Arrow
kept the input as is
+ // because zstd did not make it smaller. Check which path each case
takes.
+ val expectZstdFrame = (expected eq compressible) && n >= 100
+ assert(compressed.getLong(0) === (if (expectZstdFrame) n.toLong else
-1L))
Review Comment:
**5. [Nit] The prefix check reads the length in native byte order**
`ArrowBuf.getLong` reads in native order (`MemoryUtil.getLong`, which is
`Unsafe.getLong`). `AbstractCompressionCodec.writeUncompressedLength` writes
the prefix little-endian on every platform: it applies `Long.reverseBytes` when
`MemoryUtil.LITTLE_ENDIAN` is false. On a big-endian JVM, the three zstd-frame
cases would read `Long.reverseBytes(n)` and fail here, although the round trip
itself works. The `-1` cases read the same either way. If big-endian matters,
reading the prefix as little-endian keeps the check portable (with `import
java.nio.ByteOrder`):
```scala
val prefix = compressed.nioBuffer(0,
8).order(ByteOrder.LITTLE_ENDIAN).getLong(0)
assert(prefix === (if (expectZstdFrame) n.toLong else -1L))
```
##########
sql/core/src/test/scala/org/apache/spark/sql/execution/arrow/ArrowCompressionUtilsSuite.scala:
##########
@@ -28,9 +32,21 @@ import org.apache.arrow.vector.ipc.message.MessageSerializer
import org.apache.spark.{SparkException, SparkFunSuite}
import org.apache.spark.sql.types.{StringType, StructField, StructType}
import org.apache.spark.sql.util.ArrowUtils
+import org.apache.spark.util.Utils
class ArrowCompressionUtilsSuite extends SparkFunSuite {
+ // Expose the protected `doCompress` so that a test can look at the raw
output buffer.
+ private class RawBoundedZstdCompressionCodec extends
BoundedZstdCompressionCodec(3) {
+ def compressRaw(allocator: BufferAllocator, in: ArrowBuf): ArrowBuf =
doCompress(allocator, in)
+ }
+
+ // Arrow's own codec, which up to arrow-java 19.0 gives zstd a destination
capacity that is 8
+ // bytes too large.
+ private class RawStockZstdCompressionCodec extends ZstdCompressionCodec(3) {
+ def compressRaw(allocator: BufferAllocator, in: ArrowBuf): ArrowBuf =
doCompress(allocator, in)
+ }
Review Comment:
**7. [Nit] The two `Raw*` codec classes sit far from the test that uses
them**
Only the SPARK-60707 guard test uses `RawBoundedZstdCompressionCodec` and
`RawStockZstdCompressionCodec`. Elsewhere in this suite, a helper sits right
above the test that uses it: `compressedSize` above the SPARK-57383 test, and
`noisyBytes` and `writesPastEnd` above the guard test. Moving these two classes
next to `writesPastEnd` would keep everything the guard test needs in one
place, which is also what gets deleted together on Arrow 20.0.0.
##########
sql/core/src/main/scala/org/apache/spark/sql/execution/arrow/ArrowCompressionUtils.scala:
##########
@@ -34,17 +36,50 @@ private[sql] object ArrowCompressionUtils {
* carry a compression level, so that factory overload always builds a codec
at the default
* level, silently dropping the configured one. The level only matters on
the write side; the
* read side looks up the codec by the type recorded in the IPC message, so
it is unaffected.
+ *
+ * For zstd it must also be a [[BoundedZstdCompressionCodec]], which neither
factory overload
+ * returns, not even `createCodec(codecType, compressionLevel)`, which does
keep the level.
*/
def createCompressionCodec(
codecName: String,
zstdCompressionLevel: Int): CompressionCodec = {
codecName match {
case "none" => NoCompressionCodec.INSTANCE
- case "zstd" => new ZstdCompressionCodec(zstdCompressionLevel)
+ case "zstd" => new BoundedZstdCompressionCodec(zstdCompressionLevel)
case "lz4" => new Lz4CompressionCodec()
case other =>
throw SparkException.internalError(
s"Unsupported Arrow compression codec: $other. Supported values:
none, zstd, lz4")
}
}
}
+
+/**
+ * A [[ZstdCompressionCodec]] that never lets zstd write past its output
buffer. arrow-java up to
+ * 19.0 passes `compressBound(len) + 8` as the destination capacity while
pointing zstd 8 bytes
+ * into a buffer of that size, so zstd can overrun it by up to 8 bytes; here
the capacity is
+ * `compressBound(len)`. Remove once on Arrow 20.0.0 (apache/arrow-java#1119).
+ */
+private[sql] class BoundedZstdCompressionCodec(compressionLevel: Int)
+ extends ZstdCompressionCodec(compressionLevel) {
+
+ override protected def doCompress(
+ allocator: BufferAllocator,
+ uncompressedBuffer: ArrowBuf): ArrowBuf = {
+ val srcSize = uncompressedBuffer.writerIndex()
+ val maxSize = Zstd.compressBound(srcSize)
+ val compressedBuffer =
allocator.buffer(CompressionUtil.SIZE_OF_UNCOMPRESSED_LENGTH + maxSize)
+ val bytesWritten = Zstd.compressUnsafe(
+ compressedBuffer.memoryAddress() +
CompressionUtil.SIZE_OF_UNCOMPRESSED_LENGTH,
+ maxSize,
+ uncompressedBuffer.memoryAddress(),
+ srcSize,
+ compressionLevel)
+ if (Zstd.isError(bytesWritten)) {
+ compressedBuffer.close()
+ throw new RuntimeException("Error compressing: " +
Zstd.getErrorName(bytesWritten))
Review Comment:
**6. [Nit] `doCompress` throws a plain `RuntimeException`**
This line is copied from Arrow, while the rest of this file reports internal
errors with `SparkException.internalError`. With `compressBound(srcSize)` as
the capacity, zstd cannot run out of room, so an error here is unexpected and
fits an internal error. Keeping Arrow's line unchanged has a point too, since
it keeps the class easy to compare with upstream until it is removed. Either
way is fine with me.
```suggestion
throw SparkException.internalError("Error compressing: " +
Zstd.getErrorName(bytesWritten))
```
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: [email protected]
For queries about this service, please contact Infrastructure at:
[email protected]
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]