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 ad7a9338 perf(arrow/ipc): reuse record encoder scratch (#1221)
ad7a9338 is described below

commit ad7a933828725d03a889199696ab9876cb93f50d
Author: Minh Vu <[email protected]>
AuthorDate: Fri Aug 28 23:25:21 2026 +0200

    perf(arrow/ipc): reuse record encoder scratch (#1221)
    
    ## Summary
    
    - Keep one `recordEncoder` on `Writer` and `FileWriter`.
    - Reuse the `fields`, `meta`, and `variadicCounts` backing arrays.
    - Restore the encoder recursion depth when resetting.
    - Add a benchmark for repeated writes with 1, 16, and 64 columns.
    - Add a regression test for resetting after a nested encode error.
    
    ## Benchmark
    
    **Command**
    
    ```text
    go test ./arrow/ipc -run '^$' -bench '^BenchmarkWriterRecordEncoderReuse$' 
-benchmem -benchtime=100ms -count=3
    ```
    
    **Machine:** Apple M1 Pro, arm64
    **Go:** 1.26.3
    
    The benchmark writes one warm-up batch before timing repeated writes.
    Each width was tested with 16, 256, and 4096 rows. The allocation
    results were the same across those row counts.
    
    | Columns | Before | After |
    | --- | ---: | ---: |
    | 1 | 1,624 B/op, 21 allocs/op | 1,408 B/op, 18 allocs/op |
    | 16 | 8,072 B/op, 33 allocs/op | 6,656 B/op, 26 allocs/op |
    | 64 | 31,240 B/op, 41 allocs/op | 25,984 B/op, 32 allocs/op |
    
    Timing was noisy on this machine, so the allocation reduction is the
    main result.
    
    ## Tests
    
    - `go test ./arrow/ipc`
    - `go test ./arrow/...`
---
 arrow/ipc/file_writer.go           |  28 ++++++--
 arrow/ipc/writer.go                |  49 +++++++++-----
 arrow/ipc/writer_benchmark_test.go |  81 +++++++++++++++++++++++
 arrow/ipc/writer_test.go           | 131 +++++++++++++++++++++++++++++++++++++
 4 files changed, 265 insertions(+), 24 deletions(-)

diff --git a/arrow/ipc/file_writer.go b/arrow/ipc/file_writer.go
index 89c98999..ffba57c1 100644
--- a/arrow/ipc/file_writer.go
+++ b/arrow/ipc/file_writer.go
@@ -256,6 +256,7 @@ type FileWriter struct {
        codec           flatbuf.CompressionType
        compressNP      int
        compressors     []compressor
+       encoder         *recordEncoder
        minSpaceSavings float64
 
        // map of the last written dictionaries by id
@@ -286,7 +287,25 @@ func NewFileWriter(w io.Writer, opts ...Option) 
(*FileWriter, error) {
        return &f, err
 }
 
+func (f *FileWriter) getRecordEncoder() *recordEncoder {
+       if f.encoder == nil {
+               f.encoder = newRecordEncoder(
+                       f.mem,
+                       0,
+                       kMaxNestingDepth,
+                       true,
+                       f.codec,
+                       f.compressNP,
+                       f.minSpaceSavings,
+                       f.compressors,
+               )
+       }
+       return f.encoder
+}
+
 func (f *FileWriter) Close() error {
+       defer func() { f.encoder = nil }()
+
        if f.closed {
                return f.closeErr
        }
@@ -334,13 +353,8 @@ func (f *FileWriter) Write(rec arrow.RecordBatch) error {
                return fmt.Errorf("arrow/ipc: could not write header: %w", err)
        }
 
-       const allow64b = true
-       var (
-               data = Payload{msg: MessageRecordBatch}
-               enc  = newRecordEncoder(
-                       f.mem, 0, kMaxNestingDepth, allow64b, f.codec, 
f.compressNP, f.minSpaceSavings, f.compressors,
-               )
-       )
+       data := Payload{msg: MessageRecordBatch}
+       enc := f.getRecordEncoder()
        defer data.Release()
 
        err := writeDictionaryPayloads(f.mem, rec, true, false, &f.mapper, 
f.lastWrittenDicts, f.pw, enc)
diff --git a/arrow/ipc/writer.go b/arrow/ipc/writer.go
index a771849f..a1c768ba 100644
--- a/arrow/ipc/writer.go
+++ b/arrow/ipc/writer.go
@@ -96,6 +96,7 @@ type Writer struct {
        codec           flatbuf.CompressionType
        compressNP      int
        compressors     []compressor
+       encoder         *recordEncoder
        minSpaceSavings float64
 
        // map of the last written dictionaries by id
@@ -137,7 +138,25 @@ func NewWriter(w io.Writer, opts ...Option) *Writer {
        }
 }
 
+func (w *Writer) getRecordEncoder() *recordEncoder {
+       if w.encoder == nil {
+               w.encoder = newRecordEncoder(
+                       w.mem,
+                       0,
+                       kMaxNestingDepth,
+                       true,
+                       w.codec,
+                       w.compressNP,
+                       w.minSpaceSavings,
+                       w.compressors,
+               )
+       }
+       return w.encoder
+}
+
 func (w *Writer) Close() error {
+       defer func() { w.encoder = nil }()
+
        if w.err != nil {
                return w.closeAfterFailure()
        }
@@ -211,20 +230,8 @@ func (w *Writer) Write(rec arrow.RecordBatch) (err error) {
                return errInconsistentSchema
        }
 
-       const allow64b = true
-       var (
-               data = Payload{msg: MessageRecordBatch}
-               enc  = newRecordEncoder(
-                       w.mem,
-                       0,
-                       kMaxNestingDepth,
-                       allow64b,
-                       w.codec,
-                       w.compressNP,
-                       w.minSpaceSavings,
-                       w.compressors,
-               )
-       )
+       data := Payload{msg: MessageRecordBatch}
+       enc := w.getRecordEncoder()
        defer data.Release()
 
        err = writeDictionaryPayloads(w.mem, rec, false, w.emitDictDeltas, 
&w.mapper, w.lastWrittenDicts, w.pw, enc)
@@ -368,6 +375,7 @@ type recordEncoder struct {
        variadicCounts []int64
 
        depth           int64
+       maxDepth        int64
        start           int64
        allow64b        bool
        codec           flatbuf.CompressionType
@@ -390,6 +398,7 @@ func newRecordEncoder(
                mem:             mem,
                start:           startOffset,
                depth:           maxDepth,
+               maxDepth:        maxDepth,
                allow64b:        allow64b,
                codec:           codec,
                compressNP:      compressNP,
@@ -409,9 +418,11 @@ func (w *recordEncoder) shouldCompress(uncompressed, 
compressed int) bool {
 }
 
 func (w *recordEncoder) reset() {
+       w.depth = w.maxDepth
        w.start = 0
-       w.fields = make([]fieldMetadata, 0)
-       w.variadicCounts = nil
+       w.fields = w.fields[:0]
+       w.meta = w.meta[:0]
+       w.variadicCounts = w.variadicCounts[:0]
 }
 
 func (w *recordEncoder) getCompressor(id int) compressor {
@@ -550,7 +561,11 @@ func (w *recordEncoder) encode(p *Payload, rec 
arrow.RecordBatch) error {
        // position for the start of a buffer relative to the passed frame of 
reference.
        // may be 0 or some other position in an address space.
        offset := w.start
-       w.meta = make([]bufferMetadata, len(p.body))
+       if cap(w.meta) < len(p.body) {
+               w.meta = make([]bufferMetadata, len(p.body))
+       } else {
+               w.meta = w.meta[:len(p.body)]
+       }
 
        // construct the metadata for the record batch header
        for i, buf := range p.body {
diff --git a/arrow/ipc/writer_benchmark_test.go 
b/arrow/ipc/writer_benchmark_test.go
new file mode 100644
index 00000000..3567ec4f
--- /dev/null
+++ b/arrow/ipc/writer_benchmark_test.go
@@ -0,0 +1,81 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package ipc
+
+import (
+       "fmt"
+       "io"
+       "testing"
+
+       "github.com/apache/arrow-go/v18/arrow"
+       "github.com/apache/arrow-go/v18/arrow/array"
+       "github.com/apache/arrow-go/v18/arrow/memory"
+)
+
+func benchmarkRecordBatch(numColumns, numRows int) arrow.RecordBatch {
+       fields := make([]arrow.Field, numColumns)
+       columns := make([]arrow.Array, numColumns)
+       values := make([]int32, numRows)
+       for i := range values {
+               values[i] = int32(i)
+       }
+
+       for i := range fields {
+               fields[i] = arrow.Field{Name: fmt.Sprintf("col%d", i), Type: 
arrow.PrimitiveTypes.Int32}
+
+               builder := array.NewInt32Builder(memory.DefaultAllocator)
+               builder.AppendValues(values, nil)
+               columns[i] = builder.NewArray()
+               builder.Release()
+       }
+
+       schema := arrow.NewSchema(fields, nil)
+       record := array.NewRecordBatch(schema, columns, int64(numRows))
+       for _, column := range columns {
+               column.Release()
+       }
+       return record
+}
+
+func BenchmarkWriterRecordEncoderReuse(b *testing.B) {
+       for _, numColumns := range []int{1, 16, 64} {
+               for _, numRows := range []int{16, 256, 4096} {
+                       b.Run(fmt.Sprintf("%dcols/%drows", numColumns, 
numRows), func(b *testing.B) {
+                               record := benchmarkRecordBatch(numColumns, 
numRows)
+                               defer record.Release()
+
+                               writer := NewWriter(io.Discard, 
WithSchema(record.Schema()))
+                               if err := writer.Write(record); err != nil {
+                                       b.Fatal(err)
+                               }
+
+                               b.ReportAllocs()
+                               b.SetBytes(int64(numColumns * numRows * 
arrow.Int32SizeBytes))
+                               b.ResetTimer()
+                               for i := 0; i < b.N; i++ {
+                                       if err := writer.Write(record); err != 
nil {
+                                               b.Fatal(err)
+                                       }
+                               }
+                               b.StopTimer()
+                               if err := writer.Close(); err != nil {
+                                       b.Fatal(err)
+                               }
+                       })
+               }
+       }
+}
diff --git a/arrow/ipc/writer_test.go b/arrow/ipc/writer_test.go
index e49c4f54..88b2dc6d 100644
--- a/arrow/ipc/writer_test.go
+++ b/arrow/ipc/writer_test.go
@@ -69,6 +69,20 @@ type failingWriter struct {
        err error
 }
 
+type closeFailWriter struct {
+       bytes.Buffer
+       err  error
+       fail bool
+}
+
+func (w *closeFailWriter) Write(p []byte) (int, error) {
+       if w.fail {
+               return 0, w.err
+       }
+
+       return w.Buffer.Write(p)
+}
+
 func (shortWriteWriter) Write(p []byte) (int, error) {
        return len(p) - 1, io.ErrShortWrite
 }
@@ -118,6 +132,39 @@ func TestWriterCloseFailureIsTerminal(t *testing.T) {
        require.Equal(t, 1, payloadWriter.closeCall)
 }
 
+func TestWriterCloseReleasesRecordEncoder(t *testing.T) {
+       schema := arrow.NewSchema([]arrow.Field{{Name: "col", Type: 
arrow.PrimitiveTypes.Int32}}, nil)
+       builder := array.NewRecordBuilder(memory.DefaultAllocator, schema)
+       defer builder.Release()
+       record := builder.NewRecordBatch()
+       defer record.Release()
+
+       var output bytes.Buffer
+       writer := NewWriter(&output, WithSchema(schema))
+       require.NoError(t, writer.Write(record))
+       require.NotNil(t, writer.encoder)
+
+       require.NoError(t, writer.Close())
+       require.Nil(t, writer.encoder)
+}
+
+func TestWriterCloseAfterFailureReleasesRecordEncoder(t *testing.T) {
+       schema := arrow.NewSchema([]arrow.Field{{Name: "col", Type: 
arrow.PrimitiveTypes.Int32}}, nil)
+       builder := array.NewRecordBuilder(memory.DefaultAllocator, schema)
+       defer builder.Release()
+       record := builder.NewRecordBatch()
+       defer record.Release()
+
+       want := errors.New("payload failed")
+       payloadWriter := &failingPayloadWriter{err: want, failAfter: 2}
+       writer := NewWriterWithPayloadWriter(payloadWriter, WithSchema(schema))
+       require.ErrorIs(t, writer.Write(record), want)
+       require.NotNil(t, writer.encoder)
+
+       require.ErrorIs(t, writer.Close(), want)
+       require.Nil(t, writer.encoder)
+}
+
 func TestFileWriterCloseFailureIsTerminal(t *testing.T) {
        schema := arrow.NewSchema([]arrow.Field{{Name: "col", Type: 
arrow.PrimitiveTypes.Int32}}, nil)
        want := errors.New("write failed")
@@ -128,6 +175,42 @@ func TestFileWriterCloseFailureIsTerminal(t *testing.T) {
        require.ErrorIs(t, writer.Close(), want)
 }
 
+func TestFileWriterCloseReleasesRecordEncoder(t *testing.T) {
+       schema := arrow.NewSchema([]arrow.Field{{Name: "col", Type: 
arrow.PrimitiveTypes.Int32}}, nil)
+       builder := array.NewRecordBuilder(memory.DefaultAllocator, schema)
+       defer builder.Release()
+       record := builder.NewRecordBatch()
+       defer record.Release()
+
+       var output bytes.Buffer
+       writer, err := NewFileWriter(&output, WithSchema(schema))
+       require.NoError(t, err)
+       require.NoError(t, writer.Write(record))
+       require.NotNil(t, writer.encoder)
+
+       require.NoError(t, writer.Close())
+       require.Nil(t, writer.encoder)
+}
+
+func TestFileWriterCloseFailureReleasesRecordEncoder(t *testing.T) {
+       schema := arrow.NewSchema([]arrow.Field{{Name: "col", Type: 
arrow.PrimitiveTypes.Int32}}, nil)
+       builder := array.NewRecordBuilder(memory.DefaultAllocator, schema)
+       defer builder.Release()
+       record := builder.NewRecordBatch()
+       defer record.Release()
+
+       want := errors.New("close failed")
+       output := &closeFailWriter{err: want}
+       writer, err := NewFileWriter(output, WithSchema(schema))
+       require.NoError(t, err)
+       require.NoError(t, writer.Write(record))
+       require.NotNil(t, writer.encoder)
+
+       output.fail = true
+       require.ErrorIs(t, writer.Close(), want)
+       require.Nil(t, writer.encoder)
+}
+
 func TestWriterSchemaFailureIsTerminal(t *testing.T) {
        schema := arrow.NewSchema([]arrow.Field{{Name: "col", Type: 
arrow.PrimitiveTypes.Int32}}, nil)
        builder := array.NewRecordBuilder(memory.DefaultAllocator, schema)
@@ -827,3 +910,51 @@ func TestVariadicCountsNotAccumulatedAcrossEncode(t 
*testing.T) {
                p.Release()
        }
 }
+
+func TestRecordEncoderResetRestoresDepth(t *testing.T) {
+       mem := memory.NewCheckedAllocator(memory.DefaultAllocator)
+       defer mem.AssertSize(t, 0)
+
+       childBuilder := array.NewInt32Builder(mem)
+       childBuilder.Append(1)
+       child := childBuilder.NewArray()
+       childBuilder.Release()
+       defer child.Release()
+
+       structArr, err := array.NewStructArrayWithFields(
+               []arrow.Array{child},
+               []arrow.Field{{Name: "value", Type: 
arrow.PrimitiveTypes.Int32}},
+       )
+       require.NoError(t, err)
+       defer structArr.Release()
+
+       structSchema := arrow.NewSchema([]arrow.Field{{
+               Name: "struct",
+               Type: structArr.DataType(),
+       }}, nil)
+       structRecord := array.NewRecordBatch(structSchema, 
[]arrow.Array{structArr}, 1)
+       defer structRecord.Release()
+
+       enc := newRecordEncoder(mem, 0, 1, true, -1, 1, 0, nil)
+       var nestedPayload Payload
+       require.ErrorIs(t, enc.encode(&nestedPayload, structRecord), 
errMaxRecursion)
+       nestedPayload.Release()
+
+       enc.reset()
+
+       simpleSchema := arrow.NewSchema([]arrow.Field{{
+               Name: "value",
+               Type: arrow.PrimitiveTypes.Int32,
+       }}, nil)
+       simpleBuilder := array.NewInt32Builder(mem)
+       simpleBuilder.Append(1)
+       simple := simpleBuilder.NewArray()
+       simpleBuilder.Release()
+       defer simple.Release()
+       simpleRecord := array.NewRecordBatch(simpleSchema, 
[]arrow.Array{simple}, 1)
+       defer simpleRecord.Release()
+
+       var simplePayload Payload
+       require.NoError(t, enc.encode(&simplePayload, simpleRecord))
+       simplePayload.Release()
+}

Reply via email to