This is an automated email from the ASF dual-hosted git repository.
zeroshade pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/arrow-go.git
The following commit(s) were added to refs/heads/main by this push:
new 7c13a602 perf: zstd implement Pooling + DecodeAll with no new reader.
(#1331)
7c13a602 is described below
commit 7c13a602d47616d45ccbbb3f845f3ca44a5926b6
Author: Samuele Resca <[email protected]>
AuthorDate: Mon Oct 12 15:02:16 2026 +0100
perf: zstd implement Pooling + DecodeAll with no new reader. (#1331)
### Rationale for this change
Performance improvement on the zstd path.
### What changes are included in this PR?
- zstd: decoders come from a `sync.Pool` instead of being created for
every record batch, and each compressed buffer is decoded with one
`DecodeAll` call straight into the destination buffer (decoder built
with `WithDecoderConcurrency(1)` and `WithDecodeAllCapLimit(true))`
instead of `Reset(bytes.NewReader)` + `io.ReadFull` on a streaming
decoder.
- lz4 : no functional change. It keeps its original per-batch
`lz4.Reader`; the library already pools its large block buffers
internally. (Only a Decompress method wrapper was added.)
### Are these changes tested?
Added tests + benchmark for zstd (the changed path) - every case
improves
```
SMALL buffers (4 KiB per column)
time : -70.5% to -78.3% in all 8 cases
1 col, 1 batch (stream) 9.89 us -> 2.92 us
1 col, 64 batches (stream) 592.9 us -> 128.7 us
16 cols, 64 batches (file) 3.67 ms -> 894 us
B/op : -60% (1 col, 1 batch), -64% (1 col, 64 batches), -22% (16 cols)
allocs/op : -45% (1 col, 1 batch) to -70% (16 cols, 64 batches)
LARGE buffers
per column time B/op
allocs/op
16 KiB 77.6 us -> 24.0 us (-69%) 306.9 KiB -> 157.1 KiB (-49%)
184 -> 78 (-58%)
256 KiB 796.7 us -> 191.7 us (-76%) 20.6 MiB -> 2.05 MiB (-90%)
185 -> 81 (-56%)
4 MiB 4.63 ms -> 3.05 ms (-34%) 51.1 MiB -> 32.1 MiB (-37%)
189 -> 79 (-58%)
```
### Are there any user-facing changes?
Strictness change for zstd. A buffer whose decompressed size differs
from the uncompressed-length prefix now returns an error. This matches
other arrow libraries (e.g.:
[arrow-cpp](https://github.com/apache/arrow/blob/f3b46fb7d4b8f5fb3fc459860038f0ed8b02bba5/cpp/src/arrow/ipc/reader.cc#L571-L575))
---
arrow/ipc/compression.go | 83 +++++--
arrow/ipc/compression_test.go | 288 +++++++++++++++++++++++++
arrow/ipc/file_reader.go | 4 +-
arrow/ipc/reader_compression_benchmark_test.go | 154 +++++++++++++
4 files changed, 515 insertions(+), 14 deletions(-)
diff --git a/arrow/ipc/compression.go b/arrow/ipc/compression.go
index abdf6313..81bd6fe6 100644
--- a/arrow/ipc/compression.go
+++ b/arrow/ipc/compression.go
@@ -17,7 +17,10 @@
package ipc
import (
+ "bytes"
+ "fmt"
"io"
+ "sync"
"github.com/apache/arrow-go/v18/arrow/internal/debug"
"github.com/apache/arrow-go/v18/arrow/internal/flatbuf"
@@ -81,27 +84,87 @@ func getCompressor(codec flatbuf.CompressionType)
compressor {
}
type decompressor interface {
- io.Reader
- Reset(io.Reader)
+ // Decompress decodes src into dst, which must be sized to the exact
+ // uncompressed length.
+ Decompress(dst, src []byte) error
+ // Close releases the decompressor; it must not be used afterwards.
+ // Implementations may return it to a pool, so calling Close twice hands
+ // the same decompressor to two owners.
Close()
}
+var zstdDecompressorPool = sync.Pool{
+ New: func() any {
+ // WithDecoderConcurrency(1): Each pooled decoder is used by
one goroutine at a time, so a single
+ // block decoder is enough. The default would create up to four
that
+ // could never run in parallel;
+ //
+ // WithDecodeAllCapLimit(true): The cap limit bounds DecodeAll
to cap(dst), so a frame claiming a
+ // larger content size can't allocate beyond the buffer the
caller
+ // sized from the uncompressed length prefix.
+ dec, err := zstd.NewReader(nil, zstd.WithDecoderConcurrency(1),
zstd.WithDecodeAllCapLimit(true))
+ if err != nil {
+ panic(err)
+ }
+ return &zstdDecompressor{Decoder: dec}
+ },
+}
+
type zstdDecompressor struct {
*zstd.Decoder
}
-func (z *zstdDecompressor) Reset(r io.Reader) {
- if err := z.Decoder.Reset(r); err != nil {
- panic(err)
+func (z *zstdDecompressor) Decompress(dst, src []byte) error {
+ if len(dst) == 0 {
+ return nil
}
+
+ // The decoder was created with WithDecodeAllCapLimit, so it decodes
into
+ // dst's own capacity and fails rather than allocating a larger slice.
+ out, err := z.DecodeAll(src, dst[:0])
+ if err != nil {
+ return err
+ }
+ // Catch cases where the prefix says fewer bytes than the content, but
the content fits in the dst's spare capacity
+ if len(out) != len(dst) {
+ return fmt.Errorf("arrow/ipc: zstd decompressed to %d bytes,
expected %d", len(out), len(dst))
+ }
+ return nil
}
+// Close returns the decoder to the pool. z.Decoder.Close() is deliberately
+// not called: zstd.NewReader(nil) starts no goroutines, so a pooled decoder
+// only holds GC-reclaimable state and there is nothing to tear down. Closing
+// it would make the decoder unusable for the next borrower.
func (z *zstdDecompressor) Close() {
- z.Decoder.Close()
+ zstdDecompressorPool.Put(z)
}
type lz4Decompressor struct {
*lz4.Reader
+ extra [1]byte // probe buffer for trailing data
+}
+
+func (z *lz4Decompressor) Decompress(dst, src []byte) error {
+ if len(dst) == 0 {
+ return nil
+ }
+
+ z.Reset(bytes.NewReader(src))
+ if _, err := io.ReadFull(z.Reader, dst); err != nil {
+ return err
+ }
+ // ReadFull stops once dst is full, so probe for one more byte: if
there is
+ // one, the frame decompresses to more than the length prefix claims,
and we
+ // reject it rather than silently truncate (mirrors the zstd path).
+ n, err := z.Read(z.extra[:])
+ if n > 0 {
+ return fmt.Errorf("arrow/ipc: lz4 decompressed to more than the
expected %d bytes", len(dst))
+ }
+ if err != io.EOF {
+ return err
+ }
+ return nil
}
func (z *lz4Decompressor) Close() {
@@ -111,13 +174,9 @@ func (z *lz4Decompressor) Close() {
func getDecompressor(codec flatbuf.CompressionType) decompressor {
switch codec {
case flatbuf.CompressionTypeLZ4_FRAME:
- return &lz4Decompressor{lz4.NewReader(nil)}
+ return &lz4Decompressor{Reader: lz4.NewReader(nil)}
case flatbuf.CompressionTypeZSTD:
- dec, err := zstd.NewReader(nil)
- if err != nil {
- panic(err)
- }
- return &zstdDecompressor{dec}
+ return zstdDecompressorPool.Get().(*zstdDecompressor)
}
return nil
}
diff --git a/arrow/ipc/compression_test.go b/arrow/ipc/compression_test.go
new file mode 100644
index 00000000..afc997f6
--- /dev/null
+++ b/arrow/ipc/compression_test.go
@@ -0,0 +1,288 @@
+// 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 ipc
+
+import (
+ "bytes"
+ "encoding/binary"
+ "errors"
+ "fmt"
+ "math/rand"
+ "runtime"
+ "sync"
+ "testing"
+
+ "github.com/apache/arrow-go/v18/arrow/array"
+ "github.com/apache/arrow-go/v18/arrow/internal/flatbuf"
+ "github.com/klauspost/compress/zstd"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+var testCodecs = []struct {
+ name string
+ codec flatbuf.CompressionType
+ opt Option
+}{
+ {"zstd", flatbuf.CompressionTypeZSTD, WithZstd()},
+ {"lz4", flatbuf.CompressionTypeLZ4_FRAME, WithLZ4()},
+}
+
+func compressBuffer(t *testing.T, codec flatbuf.CompressionType, src []byte)
[]byte {
+ t.Helper()
+
+ var out bytes.Buffer
+ c := getCompressor(codec)
+ c.Reset(&out)
+ _, err := c.Write(src)
+ require.NoError(t, err)
+ require.NoError(t, c.Close())
+ return out.Bytes()
+}
+
+// compressibleBytes returns n deterministic bytes drawn from a small alphabet,
+// so they compress but aren't trivially uniform.
+func compressibleBytes(n int, seed int64) []byte {
+ rng := rand.New(rand.NewSource(seed))
+ out := make([]byte, n)
+ for i := range out {
+ out[i] = byte(rng.Intn(16))
+ }
+ return out
+}
+
+func TestDecompressorRoundTrip(t *testing.T) {
+ for _, tc := range testCodecs {
+ t.Run(tc.name, func(t *testing.T) {
+ dec := getDecompressor(tc.codec)
+ defer dec.Close()
+
+ for i, n := range []int{1, 100, 4096, 300_000, 17,
300_000} {
+ want := compressibleBytes(n, int64(i))
+ got := make([]byte, n)
+ require.NoError(t, dec.Decompress(got,
compressBuffer(t, tc.codec, want)), "size %d", n)
+ assert.Equal(t, want, got, "size %d", n)
+ }
+ })
+ }
+}
+
+func TestDecompressorEmptyDestination(t *testing.T) {
+ for _, tc := range testCodecs {
+ t.Run(tc.name, func(t *testing.T) {
+ dec := getDecompressor(tc.codec)
+ defer dec.Close()
+ assert.NoError(t, dec.Decompress(nil, nil))
+ assert.NoError(t, dec.Decompress([]byte{}, []byte{1, 2,
3}))
+ })
+ }
+}
+
+func TestDecompressorSizeMismatch(t *testing.T) {
+ want := compressibleBytes(1000, 1)
+
+ for _, tc := range testCodecs {
+ t.Run(tc.name, func(t *testing.T) {
+ dec := getDecompressor(tc.codec)
+ defer dec.Close()
+ compressed := compressBuffer(t, tc.codec, want)
+
+ // the uncompressed length prefix claims more than the
frame holds
+ assert.Error(t, dec.Decompress(make([]byte,
len(want)+1), compressed))
+
+ // and the decoder is still usable afterwards
+ got := make([]byte, len(want))
+ require.NoError(t, dec.Decompress(got, compressed))
+ assert.Equal(t, want, got)
+ })
+ }
+
+ // zstd frames declare their content size, so a prefix that is too small
+ // is caught instead of silently truncating.
+ t.Run("zstd/short-destination", func(t *testing.T) {
+ dec := getDecompressor(flatbuf.CompressionTypeZSTD)
+ defer dec.Close()
+ assert.Error(t, dec.Decompress(make([]byte, len(want)-1),
compressBuffer(t, flatbuf.CompressionTypeZSTD, want)))
+ })
+}
+
+func TestZstdDecompressorBoundedByDestination(t *testing.T) {
+ // 64 MiB of zeros compresses to a few bytes
+ compressed := compressBuffer(t, flatbuf.CompressionTypeZSTD,
make([]byte, 64<<20))
+ require.Less(t, len(compressed), 64<<10)
+
+ dec := getDecompressor(flatbuf.CompressionTypeZSTD)
+ defer dec.Close()
+
+ dst := make([]byte, 16)
+ err := dec.Decompress(dst, compressed)
+ require.Error(t, err)
+ assert.True(t, errors.Is(err, zstd.ErrDecoderSizeExceeded), "unexpected
error: %v", err)
+}
+
+// A frame header can declare the content size up front. A hostile frame can
+// declare far more than it holds, hoping the decoder allocates that much
+// before it ever reads the (tiny) body. The cap limit has to reject it up
+// front, without allocating: check both the error and the allocation, since a
+// decoder that allocated first and failed afterwards would return an error
too.
+func TestZstdDecompressorRejectsHugeDeclaredSizeWithoutAllocating(t
*testing.T) {
+ const declared = 64 << 20
+
+ // Hand-built 17-byte zstd frame that is well formed but lies about its
+ // size: the header declares 64 MiB of content, the frame holds one
byte.
+ frame := []byte{0x28, 0xb5, 0x2f, 0xfd, 0xe0}
+ frame = binary.LittleEndian.AppendUint64(frame, declared)
+ frame = append(frame, 0x09, 0x00, 0x00, 0x41)
+
+ var hdr zstd.Header
+ require.NoError(t, hdr.Decode(frame))
+ require.True(t, hdr.HasFCS)
+ require.Equal(t, uint64(declared), hdr.FrameContentSize)
+
+ dec := getDecompressor(flatbuf.CompressionTypeZSTD)
+ defer dec.Close()
+
+ // warm up, so decoder state that is created lazily isn't counted below
+ dst := make([]byte, 100)
+ require.NoError(t, dec.Decompress(dst, compressBuffer(t,
flatbuf.CompressionTypeZSTD, compressibleBytes(100, 3))))
+
+ // collect garbage first so GC bookkeeping stays out of the measured
window
+ runtime.GC()
+
+ var before, after runtime.MemStats
+ runtime.ReadMemStats(&before)
+ err := dec.Decompress(dst, frame)
+ runtime.ReadMemStats(&after)
+
+ require.Error(t, err)
+ assert.True(t, errors.Is(err, zstd.ErrDecoderSizeExceeded), "unexpected
error: %v", err)
+ assert.Less(t, after.TotalAlloc-before.TotalAlloc, uint64(1<<20),
+ "decoding a frame declaring %d bytes allocated too much",
declared)
+}
+
+// Reader destinations are allocated in 64-byte aligned blocks, so they usually
+// have spare capacity past their length (e.g. len 100 has cap 128). Decoding
+// must fill exactly len(dst) and reject frames of any other size, including
+// ones that would fit in the spare capacity.
+func TestZstdDecompressorSpareCapacity(t *testing.T) {
+ const n, capacity = 100, 128
+
+ dec := getDecompressor(flatbuf.CompressionTypeZSTD)
+ defer dec.Close()
+
+ compress := func(size int) []byte {
+ return compressBuffer(t, flatbuf.CompressionTypeZSTD,
bytes.Repeat([]byte{0xAB}, size))
+ }
+
+ t.Run("valid frame fills dst and leaves the spare capacity untouched",
func(t *testing.T) {
+ dst := make([]byte, n, capacity)
+ require.NoError(t, dec.Decompress(dst, compress(n)))
+ assert.Equal(t, bytes.Repeat([]byte{0xAB}, n), dst)
+ assert.Equal(t, make([]byte, capacity-n), dst[n:capacity])
+ })
+
+ t.Run("frame larger than dst but within the spare capacity is
rejected", func(t *testing.T) {
+ dst := make([]byte, n, capacity)
+ assert.Error(t, dec.Decompress(dst, compress(capacity-1)))
+ })
+
+ t.Run("frame larger than the spare capacity is rejected", func(t
*testing.T) {
+ dst := make([]byte, n, capacity)
+ err := dec.Decompress(dst, compress(capacity+1))
+ require.Error(t, err)
+ assert.True(t, errors.Is(err, zstd.ErrDecoderSizeExceeded),
"unexpected error: %v", err)
+ })
+
+ t.Run("decoder is still usable after the rejected frames", func(t
*testing.T) {
+ dst := make([]byte, n, capacity)
+ require.NoError(t, dec.Decompress(dst, compress(n)))
+ assert.Equal(t, bytes.Repeat([]byte{0xAB}, n), dst)
+ })
+}
+
+func TestDecompressorRecoversAfterTruncatedInput(t *testing.T) {
+ want := compressibleBytes(4096, 2)
+
+ for _, tc := range testCodecs {
+ t.Run(tc.name, func(t *testing.T) {
+ dec := getDecompressor(tc.codec)
+ defer dec.Close()
+ compressed := compressBuffer(t, tc.codec, want)
+
+ // lz4 frames are written without checksums, so flipped
bytes can go
+ // undetected; a truncated frame is always an error.
+ truncated := compressed[:len(compressed)/2]
+ assert.Error(t, dec.Decompress(make([]byte, len(want)),
truncated))
+
+ got := make([]byte, len(want))
+ require.NoError(t, dec.Decompress(got, compressed))
+ assert.Equal(t, want, got)
+ })
+ }
+}
+
+func TestReadCompressedConcurrently(t *testing.T) {
+ for _, tc := range testCodecs {
+ t.Run(tc.name, func(t *testing.T) {
+ rec := benchmarkRecordBatch(4, 2048)
+ defer rec.Release()
+
+ var buf bytes.Buffer
+ w := NewWriter(&buf, WithSchema(rec.Schema()), tc.opt)
+ for range 8 {
+ require.NoError(t, w.Write(rec))
+ }
+ require.NoError(t, w.Close())
+ data := buf.Bytes()
+
+ var wg sync.WaitGroup
+ errs := make(chan error, 16)
+ for range 16 {
+ wg.Go(func() {
+ for range 10 {
+ rdr, err :=
NewReader(bytes.NewReader(data))
+ if err != nil {
+ errs <- err
+ return
+ }
+ n := 0
+ for rdr.Next() {
+ if
!array.RecordEqual(rec, rdr.RecordBatch()) {
+ errs <-
fmt.Errorf("record %d differs", n)
+ }
+ n++
+ }
+ err = rdr.Err()
+ rdr.Release()
+ if err == nil && n != 8 {
+ err = fmt.Errorf("read
%d batches, want 8", n)
+ }
+ if err != nil {
+ errs <- err
+ return
+ }
+ }
+ })
+ }
+ wg.Wait()
+ close(errs)
+ for err := range errs {
+ assert.NoError(t, err)
+ }
+ })
+ }
+}
diff --git a/arrow/ipc/file_reader.go b/arrow/ipc/file_reader.go
index aba57365..afcc20b3 100644
--- a/arrow/ipc/file_reader.go
+++ b/arrow/ipc/file_reader.go
@@ -601,8 +601,8 @@ func (src *ipcSource) buffer(i int) *memory.Buffer {
if uncompressedSize != -1 {
raw = memory.NewResizableBuffer(src.mem)
raw.Resize(int(uncompressedSize))
- src.codec.Reset(bytes.NewReader(body[8:]))
- if _, err := io.ReadFull(src.codec, raw.Bytes()); err
!= nil {
+ if err := src.codec.Decompress(raw.Bytes(), body[8:]);
err != nil {
+ raw.Release()
panic(err)
}
} else {
diff --git a/arrow/ipc/reader_compression_benchmark_test.go
b/arrow/ipc/reader_compression_benchmark_test.go
new file mode 100644
index 00000000..cfa174b9
--- /dev/null
+++ b/arrow/ipc/reader_compression_benchmark_test.go
@@ -0,0 +1,154 @@
+// 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 ipc
+
+import (
+ "bytes"
+ "fmt"
+ "io"
+ "testing"
+
+ "github.com/apache/arrow-go/v18/arrow"
+)
+
+func writeBenchmarkStream(b *testing.B, rec arrow.RecordBatch, numBatches int,
opts ...Option) []byte {
+ b.Helper()
+
+ var buf bytes.Buffer
+ w := NewWriter(&buf, append([]Option{WithSchema(rec.Schema())},
opts...)...)
+ for range numBatches {
+ if err := w.Write(rec); err != nil {
+ b.Fatal(err)
+ }
+ }
+ if err := w.Close(); err != nil {
+ b.Fatal(err)
+ }
+ return buf.Bytes()
+}
+
+func writeBenchmarkFile(b *testing.B, rec arrow.RecordBatch, numBatches int,
opts ...Option) []byte {
+ b.Helper()
+
+ var buf bytes.Buffer
+ w, err := NewFileWriter(&buf,
append([]Option{WithSchema(rec.Schema())}, opts...)...)
+ if err != nil {
+ b.Fatal(err)
+ }
+ for range numBatches {
+ if err := w.Write(rec); err != nil {
+ b.Fatal(err)
+ }
+ }
+ if err := w.Close(); err != nil {
+ b.Fatal(err)
+ }
+ return buf.Bytes()
+}
+
+// BenchmarkReaderCompressed measures reading IPC data whose body buffers are
+// compressed, for both the stream Reader and the FileReader.
+func BenchmarkReaderCompressed(b *testing.B) {
+ const numRows = 1024
+
+ codecs := []struct {
+ name string
+ opt Option
+ }{
+ {name: "none"},
+ {name: "zstd", opt: WithZstd()},
+ {name: "lz4", opt: WithLZ4()},
+ }
+
+ for _, codec := range codecs {
+ for _, numColumns := range []int{1, 16} {
+ for _, numBatches := range []int{1, 64} {
+ b.Run(fmt.Sprintf("codec=%s/%dcols/%dbatches",
codec.name, numColumns, numBatches), func(b *testing.B) {
+ rec := benchmarkRecordBatch(numColumns,
numRows)
+ defer rec.Release()
+
+ var opts []Option
+ if codec.opt != nil {
+ opts = append(opts, codec.opt)
+ }
+
+ rawBytes := int64(numColumns * numRows
* arrow.Int32SizeBytes * numBatches)
+
+ b.Run("stream", func(b *testing.B) {
+ data := writeBenchmarkStream(b,
rec, numBatches, opts...)
+
+ b.ReportAllocs()
+ b.SetBytes(rawBytes)
+ for b.Loop() {
+ rdr, err :=
NewReader(bytes.NewReader(data))
+ if err != nil {
+ b.Fatal(err)
+ }
+ n := 0
+ for rdr.Next() {
+ n++
+ }
+ err = rdr.Err()
+ rdr.Release()
+ if err != nil {
+ b.Fatal(err)
+ }
+ if n != numBatches {
+ b.Fatalf("read
%d batches, want %d", n, numBatches)
+ }
+ }
+ b.StopTimer()
+
b.ReportMetric(float64(len(data))/float64(rawBytes), "size/raw")
+ })
+
+ b.Run("file", func(b *testing.B) {
+ data := writeBenchmarkFile(b,
rec, numBatches, opts...)
+
+ b.ReportAllocs()
+ b.SetBytes(rawBytes)
+ for b.Loop() {
+ rdr, err :=
NewFileReader(bytes.NewReader(data))
+ if err != nil {
+ b.Fatal(err)
+ }
+ n := 0
+ for {
+ _, err =
rdr.Read()
+ if err != nil {
+ break
+ }
+ n++
+ }
+ closeErr := rdr.Close()
+ if err != io.EOF {
+ b.Fatal(err)
+ }
+ if closeErr != nil {
+
b.Fatal(closeErr)
+ }
+ if n != numBatches {
+ b.Fatalf("read
%d batches, want %d", n, numBatches)
+ }
+ }
+ b.StopTimer()
+
b.ReportMetric(float64(len(data))/float64(rawBytes), "size/raw")
+ })
+ })
+ }
+ }
+ }
+}