This is an automated email from the ASF dual-hosted git repository.
viirya pushed a commit to branch branch-4.1
in repository https://gitbox.apache.org/repos/asf/spark.git
The following commit(s) were added to refs/heads/branch-4.1 by this push:
new c522b20ac883 [SPARK-57383][4.1][SQL][PYTHON] Honor configured Arrow
zstd compression level when writing Arrow batches
c522b20ac883 is described below
commit c522b20ac883a3e1c612e61256daf53f953b5601
Author: Liang-Chi Hsieh <[email protected]>
AuthorDate: Thu Jun 11 12:42:50 2026 -0700
[SPARK-57383][4.1][SQL][PYTHON] Honor configured Arrow zstd compression
level when writing Arrow batches
### What changes were proposed in this pull request?
Backport of #56444 (commit e33017a9c62) to branch-4.1.
This fixes a bug where the zstd compression level configured via
`spark.sql.execution.arrow.compression.zstd.level` was silently ignored
everywhere Arrow batches are compressed. The affected code constructed `new
ZstdCompressionCodec(level)` only to read its codec type, then rebuilt the
codec through `CompressionCodec.Factory.INSTANCE.createCodec(codecType)`. The
codec type enum does not carry a level, so that single-argument factory
overload always builds a codec at the zstd default [...]
Compared to the master patch, this backport fixes one additional copy of
the broken pattern: on branch-4.1 `GroupedPythonArrowInput` still has its own
codec construction in `createUnloaderForGroup` (the SPARK-55328 deduplication
that makes it reuse `PythonArrowInput.codec` is master/4.2-only). The four
fixed sites are:
- `ArrowConverters.ArrowBatchIterator`
- `PythonArrowInput`
- `GroupedPythonArrowInput` (branch-4.1 only; folded into
`PythonArrowInput` on master)
- `CoGroupedArrowPythonRunner`
All four now use the shared `ArrowCompressionUtils.createCompressionCodec`
helper, which constructs the level-carrying codec instance directly. The level
only matters on the write side; the read side looks up the codec by the type
recorded in the IPC message, so reads are unaffected and the on-wire format is
unchanged.
### Why are the changes needed?
Users tuning `spark.sql.execution.arrow.compression.zstd.level` for Python
UDF exchange or `df.toArrow()` got no effect at all: every level compressed
identically at the default level 3, with no error or warning. Released
4.1.0/4.1.1/4.1.2 are affected.
### Does this PR introduce _any_ user-facing change?
Yes. The configured zstd level now actually takes effect; previously all
levels behaved like the default level 3.
### How was this patch tested?
`ArrowCompressionUtilsSuite`, brought over with the backport. The
regression test compresses the same compressible-but-varying batch at zstd
level -5 and level 19 and asserts level 19 produces a strictly smaller payload;
against the old codec construction it fails with byte-identical sizes at both
levels. Verified the suite passes on this branch.
### Was this patch authored or co-authored using generative AI tooling?
Generated-by: Claude Code
Closes #56451 from viirya/SPARK-57383-branch-4.1.
Authored-by: Liang-Chi Hsieh <[email protected]>
Signed-off-by: Liang-Chi Hsieh <[email protected]>
---
.../execution/arrow/ArrowCompressionUtils.scala | 50 ++++++++++++
.../sql/execution/arrow/ArrowConverters.scala | 21 +-----
.../python/CoGroupedArrowPythonRunner.scala | 23 +-----
.../sql/execution/python/PythonArrowInput.scala | 41 ++--------
.../arrow/ArrowCompressionUtilsSuite.scala | 88 ++++++++++++++++++++++
5 files changed, 150 insertions(+), 73 deletions(-)
diff --git
a/sql/core/src/main/scala/org/apache/spark/sql/execution/arrow/ArrowCompressionUtils.scala
b/sql/core/src/main/scala/org/apache/spark/sql/execution/arrow/ArrowCompressionUtils.scala
new file mode 100644
index 000000000000..19b84d1cd5d8
--- /dev/null
+++
b/sql/core/src/main/scala/org/apache/spark/sql/execution/arrow/ArrowCompressionUtils.scala
@@ -0,0 +1,50 @@
+/*
+ * 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.spark.sql.execution.arrow
+
+import org.apache.arrow.compression.{Lz4CompressionCodec, ZstdCompressionCodec}
+import org.apache.arrow.vector.compression.{CompressionCodec,
NoCompressionCodec}
+
+import org.apache.spark.SparkException
+
+private[sql] object ArrowCompressionUtils {
+
+ /**
+ * Creates the write-side Arrow [[CompressionCodec]] for the codec selected
by
+ * `spark.sql.execution.arrow.compression.codec`, honoring
+ * `spark.sql.execution.arrow.compression.zstd.level` for zstd.
+ *
+ * The codec instance must be constructed directly rather than through
+ * `CompressionCodec.Factory.INSTANCE.createCodec(codecType)`: the codec
type enum does not
+ * 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.
+ */
+ def createCompressionCodec(
+ codecName: String,
+ zstdCompressionLevel: Int): CompressionCodec = {
+ codecName match {
+ case "none" => NoCompressionCodec.INSTANCE
+ case "zstd" => new ZstdCompressionCodec(zstdCompressionLevel)
+ case "lz4" => new Lz4CompressionCodec()
+ case other =>
+ throw SparkException.internalError(
+ s"Unsupported Arrow compression codec: $other. Supported values:
none, zstd, lz4")
+ }
+ }
+}
diff --git
a/sql/core/src/main/scala/org/apache/spark/sql/execution/arrow/ArrowConverters.scala
b/sql/core/src/main/scala/org/apache/spark/sql/execution/arrow/ArrowConverters.scala
index 3d435ac016ed..a1b51afb9ded 100644
---
a/sql/core/src/main/scala/org/apache/spark/sql/execution/arrow/ArrowConverters.scala
+++
b/sql/core/src/main/scala/org/apache/spark/sql/execution/arrow/ArrowConverters.scala
@@ -24,15 +24,12 @@ import scala.collection.mutable.ArrayBuffer
import scala.jdk.CollectionConverters._
import scala.util.control.NonFatal
-import org.apache.arrow.compression.{Lz4CompressionCodec, ZstdCompressionCodec}
import org.apache.arrow.flatbuf.MessageHeader
import org.apache.arrow.memory.BufferAllocator
import org.apache.arrow.vector._
-import org.apache.arrow.vector.compression.{CompressionCodec,
NoCompressionCodec}
import org.apache.arrow.vector.ipc.{ArrowStreamReader, ArrowStreamWriter,
ReadChannel, WriteChannel}
import org.apache.arrow.vector.ipc.message.{ArrowRecordBatch, IpcOption,
MessageSerializer}
-import org.apache.spark.SparkException
import org.apache.spark.TaskContext
import org.apache.spark.internal.Logging
import org.apache.spark.network.util.JavaUtils
@@ -99,22 +96,8 @@ private[sql] object ArrowConverters extends Logging {
protected val root = VectorSchemaRoot.create(arrowSchema, allocator)
// Create compression codec based on config
- private val compressionCodecName = SQLConf.get.arrowCompressionCodec
- private val codec = compressionCodecName match {
- case "none" => NoCompressionCodec.INSTANCE
- case "zstd" =>
- val compressionLevel = SQLConf.get.arrowZstdCompressionLevel
- val factory = CompressionCodec.Factory.INSTANCE
- val codecType = new
ZstdCompressionCodec(compressionLevel).getCodecType()
- factory.createCodec(codecType)
- case "lz4" =>
- val factory = CompressionCodec.Factory.INSTANCE
- val codecType = new Lz4CompressionCodec().getCodecType()
- factory.createCodec(codecType)
- case other =>
- throw SparkException.internalError(
- s"Unsupported Arrow compression codec: $other. Supported values:
none, zstd, lz4")
- }
+ private val codec = ArrowCompressionUtils.createCompressionCodec(
+ SQLConf.get.arrowCompressionCodec, SQLConf.get.arrowZstdCompressionLevel)
protected val unloader = new VectorUnloader(root, true, codec, true)
protected val arrowWriter = ArrowWriter.create(root)
diff --git
a/sql/core/src/main/scala/org/apache/spark/sql/execution/python/CoGroupedArrowPythonRunner.scala
b/sql/core/src/main/scala/org/apache/spark/sql/execution/python/CoGroupedArrowPythonRunner.scala
index 7f6efbae8881..32fd2054fdd2 100644
---
a/sql/core/src/main/scala/org/apache/spark/sql/execution/python/CoGroupedArrowPythonRunner.scala
+++
b/sql/core/src/main/scala/org/apache/spark/sql/execution/python/CoGroupedArrowPythonRunner.scala
@@ -20,14 +20,12 @@ package org.apache.spark.sql.execution.python
import java.io.DataOutputStream
import java.util
-import org.apache.arrow.compression.{Lz4CompressionCodec, ZstdCompressionCodec}
import org.apache.arrow.vector.{VectorSchemaRoot, VectorUnloader}
-import org.apache.arrow.vector.compression.{CompressionCodec,
NoCompressionCodec}
-import org.apache.spark.{SparkEnv, SparkException, TaskContext}
+import org.apache.spark.{SparkEnv, TaskContext}
import org.apache.spark.api.python.{BasePythonRunner, ChainedPythonFunctions,
PythonRDD, PythonWorker}
import org.apache.spark.sql.catalyst.InternalRow
-import org.apache.spark.sql.execution.arrow.ArrowWriterWrapper
+import org.apache.spark.sql.execution.arrow.{ArrowCompressionUtils,
ArrowWriterWrapper}
import org.apache.spark.sql.execution.metric.SQLMetric
import org.apache.spark.sql.internal.SQLConf
import org.apache.spark.sql.types.StructType
@@ -86,21 +84,8 @@ class CoGroupedArrowPythonRunner(
// Helper method to create VectorUnloader with compression
private def createUnloader(root: VectorSchemaRoot): VectorUnloader = {
- val codec = compressionCodecName match {
- case "none" => NoCompressionCodec.INSTANCE
- case "zstd" =>
- val compressionLevel = SQLConf.get.arrowZstdCompressionLevel
- val factory = CompressionCodec.Factory.INSTANCE
- val codecType = new
ZstdCompressionCodec(compressionLevel).getCodecType()
- factory.createCodec(codecType)
- case "lz4" =>
- val factory = CompressionCodec.Factory.INSTANCE
- val codecType = new Lz4CompressionCodec().getCodecType()
- factory.createCodec(codecType)
- case other =>
- throw SparkException.internalError(
- s"Unsupported Arrow compression codec: $other. Supported values:
none, zstd, lz4")
- }
+ val codec = ArrowCompressionUtils.createCompressionCodec(
+ compressionCodecName, SQLConf.get.arrowZstdCompressionLevel)
new VectorUnloader(root, true, codec, true)
}
diff --git
a/sql/core/src/main/scala/org/apache/spark/sql/execution/python/PythonArrowInput.scala
b/sql/core/src/main/scala/org/apache/spark/sql/execution/python/PythonArrowInput.scala
index f77b0a9342b0..a81052bbe0b2 100644
---
a/sql/core/src/main/scala/org/apache/spark/sql/execution/python/PythonArrowInput.scala
+++
b/sql/core/src/main/scala/org/apache/spark/sql/execution/python/PythonArrowInput.scala
@@ -19,18 +19,16 @@ package org.apache.spark.sql.execution.python
import java.io.DataOutputStream
import java.nio.channels.Channels
-import org.apache.arrow.compression.{Lz4CompressionCodec, ZstdCompressionCodec}
import org.apache.arrow.vector.{VectorSchemaRoot, VectorUnloader}
-import org.apache.arrow.vector.compression.{CompressionCodec,
NoCompressionCodec}
import org.apache.arrow.vector.ipc.ArrowStreamWriter
import org.apache.arrow.vector.ipc.WriteChannel
import org.apache.arrow.vector.ipc.message.MessageSerializer
-import org.apache.spark.{SparkEnv, SparkException, TaskContext}
+import org.apache.spark.{SparkEnv, TaskContext}
import org.apache.spark.api.python.{BasePythonRunner, PythonRDD, PythonWorker}
import org.apache.spark.sql.catalyst.InternalRow
import org.apache.spark.sql.execution.arrow
-import org.apache.spark.sql.execution.arrow.{ArrowWriter, ArrowWriterWrapper}
+import org.apache.spark.sql.execution.arrow.{ArrowCompressionUtils,
ArrowWriter, ArrowWriterWrapper}
import org.apache.spark.sql.execution.metric.SQLMetric
import org.apache.spark.sql.internal.SQLConf
import org.apache.spark.sql.types.StructType
@@ -77,22 +75,8 @@ private[python] trait PythonArrowInput[IN] { self:
BasePythonRunner[IN, _] =>
protected val root = VectorSchemaRoot.create(arrowSchema, allocator)
// Create compression codec based on config
- private val compressionCodecName = SQLConf.get.arrowCompressionCodec
- private val codec = compressionCodecName match {
- case "none" => NoCompressionCodec.INSTANCE
- case "zstd" =>
- val compressionLevel = SQLConf.get.arrowZstdCompressionLevel
- val factory = CompressionCodec.Factory.INSTANCE
- val codecType = new ZstdCompressionCodec(compressionLevel).getCodecType()
- factory.createCodec(codecType)
- case "lz4" =>
- val factory = CompressionCodec.Factory.INSTANCE
- val codecType = new Lz4CompressionCodec().getCodecType()
- factory.createCodec(codecType)
- case other =>
- throw SparkException.internalError(
- s"Unsupported Arrow compression codec: $other. Supported values: none,
zstd, lz4")
- }
+ private val codec = ArrowCompressionUtils.createCompressionCodec(
+ SQLConf.get.arrowCompressionCodec, SQLConf.get.arrowZstdCompressionLevel)
protected val unloader = new VectorUnloader(root, true, codec, true)
protected var writer: ArrowStreamWriter = _
@@ -276,21 +260,8 @@ private[python] trait GroupedPythonArrowInput { self:
RowInputArrowPythonRunner
// Helper method to create VectorUnloader with compression for grouped
operations
private def createUnloaderForGroup(root: VectorSchemaRoot): VectorUnloader =
{
- val codec = SQLConf.get.arrowCompressionCodec match {
- case "none" => NoCompressionCodec.INSTANCE
- case "zstd" =>
- val compressionLevel = SQLConf.get.arrowZstdCompressionLevel
- val factory = CompressionCodec.Factory.INSTANCE
- val codecType = new
ZstdCompressionCodec(compressionLevel).getCodecType()
- factory.createCodec(codecType)
- case "lz4" =>
- val factory = CompressionCodec.Factory.INSTANCE
- val codecType = new Lz4CompressionCodec().getCodecType()
- factory.createCodec(codecType)
- case other =>
- throw SparkException.internalError(
- s"Unsupported Arrow compression codec: $other. Supported values:
none, zstd, lz4")
- }
+ val codec = ArrowCompressionUtils.createCompressionCodec(
+ SQLConf.get.arrowCompressionCodec, SQLConf.get.arrowZstdCompressionLevel)
new VectorUnloader(root, true, codec, true)
}
protected override def newWriter(
diff --git
a/sql/core/src/test/scala/org/apache/spark/sql/execution/arrow/ArrowCompressionUtilsSuite.scala
b/sql/core/src/test/scala/org/apache/spark/sql/execution/arrow/ArrowCompressionUtilsSuite.scala
new file mode 100644
index 000000000000..50a980dcf37a
--- /dev/null
+++
b/sql/core/src/test/scala/org/apache/spark/sql/execution/arrow/ArrowCompressionUtilsSuite.scala
@@ -0,0 +1,88 @@
+/*
+ * 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.spark.sql.execution.arrow
+
+import java.io.ByteArrayOutputStream
+import java.nio.channels.Channels
+
+import org.apache.arrow.vector.{VarCharVector, VectorSchemaRoot,
VectorUnloader}
+import org.apache.arrow.vector.compression.NoCompressionCodec
+import org.apache.arrow.vector.ipc.WriteChannel
+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
+
+class ArrowCompressionUtilsSuite extends SparkFunSuite {
+
+ // Serializes one Arrow record batch compressed at the given zstd level and
returns its size.
+ private def compressedSize(level: Int): Int = {
+ val sparkSchema = StructType(Seq(StructField("str_col", StringType)))
+ val arrowSchema = ArrowUtils.toArrowSchema(sparkSchema, "UTC", true, false)
+ val allocator =
+ ArrowUtils.rootAllocator.newChildAllocator(this.getClass.getSimpleName,
0, Long.MaxValue)
+ val root = VectorSchemaRoot.create(arrowSchema, allocator)
+ try {
+ root.allocateNew()
+ val strVector = root.getVector("str_col").asInstanceOf[VarCharVector]
+ // Compressible but non-trivial corpus: shared structure with per-row
variation, so
+ // different zstd levels produce measurably different output sizes.
+ (0 until 2000).foreach { i =>
+ val value =
+ s"[email protected],record-${i % 97},payload-${i *
2654435761L}".getBytes("UTF-8")
+ strVector.setSafe(i, value, 0, value.length)
+ }
+ root.setRowCount(2000)
+ val codec = ArrowCompressionUtils.createCompressionCodec("zstd", level)
+ val recordBatch = new VectorUnloader(root, true, codec,
true).getRecordBatch()
+ try {
+ val out = new ByteArrayOutputStream()
+ MessageSerializer.serialize(new
WriteChannel(Channels.newChannel(out)), recordBatch)
+ out.size()
+ } finally {
+ recordBatch.close()
+ }
+ } finally {
+ root.close()
+ allocator.close()
+ }
+ }
+
+ test("SPARK-57383: zstd compression level is honored by
createCompressionCodec") {
+ // Regression test: the codec used to be rebuilt through the
single-argument factory
+ // overload, which silently dropped the level, so every configured level
compressed at the
+ // zstd default. Compress the same batch at an ultra-fast negative level
and at a high level
+ // and assert the high level yields a strictly smaller payload.
+ val fastSize = compressedSize(-5)
+ val highSize = compressedSize(19)
+ assert(highSize < fastSize,
+ s"zstd level 19 should compress smaller than level -5, " +
+ s"got level 19 -> $highSize bytes vs level -5 -> $fastSize bytes; " +
+ "equal sizes mean the configured level is being ignored")
+ }
+
+ test("codec name 'none' maps to the no-op codec and unknown names fail") {
+ assert(ArrowCompressionUtils.createCompressionCodec("none", 3) ===
+ NoCompressionCodec.INSTANCE)
+ val e = intercept[SparkException] {
+ ArrowCompressionUtils.createCompressionCodec("snappy", 3)
+ }
+ assert(e.getMessage.contains("Unsupported Arrow compression codec:
snappy"))
+ }
+}
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]