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 e117ecf8 perf(arrow/csv): avoid materializing rows before writing 
(#1190)
e117ecf8 is described below

commit e117ecf8e863a7a5fcba11eb2ba11cda4d157ddf
Author: Minh Vu <[email protected]>
AuthorDate: Wed Aug 26 22:16:14 2026 +0200

    perf(arrow/csv): avoid materializing rows before writing (#1190)
    
    ### Rationale for this change
    
    `Writer.Write` currently converts columns to strings, then copies every
    string header into a row-major `[][]string` before calling `WriteAll`.
    This allocates one `[]string` for every row. Large record batches spend
    a lot of memory on this temporary matrix.
    
    Decimal formatting also used `math/big.Float`, which adds extra work and
    allocations for normal decimal values.
    
    ### What changes are included in this PR?
    
    - Keep the converted strings in their existing column slices.
    - Reuse one row-sized `[]string` while writing each CSV record.
    - Format decimal values directly from their exact integer representation
    when the value fits the supported precision.
    - Keep the existing `math/big.Float` path for values outside the
    supported precision.
    - Preserve the existing flush and error behavior.
    
    For the existing 1,000-row, 16-column benchmark, medians from 6 runs on
    an Apple M1 Pro were:
    
    | | main | this PR | change |
    |---|---:|---:|---:|
    | time/op | 4.78 ms | 0.84 ms | -82.4% |
    | B/op | 2,292,279 | 432,927 | -81.1% |
    | allocs/op | 41,514 | 19,568 | -52.9% |
    
    Compared with the earlier version of this PR, the decimal formatting
    change reduces `B/op` by 78.5% and allocations by 51.7%:
    
    | | before decimal formatting | after decimal formatting | change |
    |---|---:|---:|---:|
    | B/op | 2,012,183 | 432,927 | -78.5% |
    | allocs/op | 40,515 | 19,568 | -51.7% |
    
    ### Are these changes tested?
    
    - `go test ./arrow/... -count=1`
    - `go test ./arrow/csv -run "^$" -bench "^BenchmarkWrite$" -benchmem
    -count=6`
    
    ### Are there any user-facing changes?
    
    No. The generated CSV output is unchanged.
---
 arrow/csv/transformer.go            |  2 +-
 arrow/csv/writer.go                 | 21 +++++++-----
 arrow/csv/writer_test.go            | 68 +++++++++++++++++++++++++++++++++++++
 arrow/decimal128/decimal128.go      | 39 ++++++++++++++++++---
 arrow/decimal128/decimal128_test.go | 31 +++++++++++++++++
 arrow/decimal256/decimal256.go      | 39 ++++++++++++++++++---
 arrow/decimal256/decimal256_test.go | 31 +++++++++++++++++
 7 files changed, 212 insertions(+), 19 deletions(-)

diff --git a/arrow/csv/transformer.go b/arrow/csv/transformer.go
index 9321a490..8073874d 100644
--- a/arrow/csv/transformer.go
+++ b/arrow/csv/transformer.go
@@ -34,7 +34,7 @@ func (w *Writer) transformColToStringArr(typ arrow.DataType, 
col arrow.Array, st
                        if len(result) != col.Len() {
                                return nil, fmt.Errorf("%w: custom type 
converter returned %d values for column with %d rows", arrow.ErrInvalid, 
len(result), col.Len())
                        }
-                       return result, nil
+                       return append([]string(nil), result...), nil
                }
        }
 
diff --git a/arrow/csv/writer.go b/arrow/csv/writer.go
index 5165d517..e14c3274 100644
--- a/arrow/csv/writer.go
+++ b/arrow/csv/writer.go
@@ -78,22 +78,27 @@ func (w *Writer) Write(record arrow.RecordBatch) error {
                }
        }
 
-       recs := make([][]string, record.NumRows())
-       for i := range recs {
-               recs[i] = make([]string, record.NumCols())
-       }
-
+       columns := make([][]string, record.NumCols())
        for j, col := range record.Columns() {
                rows, err := w.transformColToStringArr(w.schema.Field(j).Type, 
col, w.stringReplacer)
                if err != nil {
                        return err
                }
-               for i, row := range rows {
-                       recs[i][j] = row
+               columns[j] = rows
+       }
+
+       row := make([]string, record.NumCols())
+       for i := 0; i < int(record.NumRows()); i++ {
+               for j := range columns {
+                       row[j] = columns[j][i]
+               }
+               if err := w.w.Write(row); err != nil {
+                       return err
                }
        }
 
-       return w.w.WriteAll(recs)
+       w.w.Flush()
+       return w.w.Error()
 }
 
 // Flush writes any buffered data to the underlying csv Writer.
diff --git a/arrow/csv/writer_test.go b/arrow/csv/writer_test.go
index 17a09bcf..ba3af2dc 100644
--- a/arrow/csv/writer_test.go
+++ b/arrow/csv/writer_test.go
@@ -194,6 +194,39 @@ func TestCSVWriter(t *testing.T) {
        }
 }
 
+func TestCSVWriterWritesMultipleRecordBatches(t *testing.T) {
+       mem := memory.NewCheckedAllocator(memory.NewGoAllocator())
+       defer mem.AssertSize(t, 0)
+
+       schema := arrow.NewSchema([]arrow.Field{
+               {Name: "id", Type: arrow.PrimitiveTypes.Int32},
+               {Name: "label", Type: arrow.BinaryTypes.String},
+       }, nil)
+       builder := array.NewRecordBuilder(mem, schema)
+       defer builder.Release()
+
+       ids := builder.Field(0).(*array.Int32Builder)
+       labels := builder.Field(1).(*array.StringBuilder)
+       ids.AppendValues([]int32{1, 2}, nil)
+       labels.AppendValues([]string{"first, row", "line\nbreak"}, nil)
+       first := builder.NewRecordBatch()
+       defer first.Release()
+
+       ids.Append(3)
+       labels.Append("last")
+       second := builder.NewRecordBatch()
+       defer second.Release()
+
+       var output bytes.Buffer
+       writer := csv.NewWriter(&output, schema, csv.WithHeader(true))
+       require.NoError(t, writer.Write(first))
+       require.NoError(t, writer.Write(second))
+       require.NoError(t, writer.Flush())
+       require.NoError(t, writer.Error())
+
+       assert.Equal(t, "id,label\n1,\"first, 
row\"\n2,\"line\nbreak\"\n3,last\n", output.String())
+}
+
 func genTimestamps(unit arrow.TimeUnit) []arrow.Timestamp {
        out := []arrow.Timestamp{}
        for _, input := range []string{"2014-07-28 15:04:05", "2016-09-08 
15:04:05", "2021-09-18 15:04:05"} {
@@ -487,6 +520,41 @@ func TestCustomTypeConverterValidatesRowCount(t 
*testing.T) {
        }
 }
 
+func TestCSVWriterSupportsReusedCustomTypeConverterResult(t *testing.T) {
+       mem := memory.NewCheckedAllocator(memory.NewGoAllocator())
+       defer mem.AssertSize(t, 0)
+
+       schema := arrow.NewSchema([]arrow.Field{
+               {Name: "first", Type: arrow.PrimitiveTypes.Int32},
+               {Name: "second", Type: arrow.PrimitiveTypes.Int32},
+       }, nil)
+       builder := array.NewRecordBuilder(mem, schema)
+       defer builder.Release()
+       builder.Field(0).(*array.Int32Builder).AppendValues([]int32{1, 2}, nil)
+       builder.Field(1).(*array.Int32Builder).AppendValues([]int32{10, 20}, 
nil)
+       record := builder.NewRecordBatch()
+       defer record.Release()
+
+       result := make([]string, 2)
+       var output bytes.Buffer
+       writer := csv.NewWriter(&output, schema, 
csv.WithCustomTypeConverter(func(typ arrow.DataType, col arrow.Array) 
([]string, bool) {
+               if typ.ID() != arrow.INT32 {
+                       return nil, false
+               }
+
+               arr := col.(*array.Int32)
+               for i := 0; i < arr.Len(); i++ {
+                       result[i] = fmt.Sprintf("%d", arr.Value(i))
+               }
+               return result, true
+       }))
+
+       require.NoError(t, writer.Write(record))
+       require.NoError(t, writer.Flush())
+       require.NoError(t, writer.Error())
+       assert.Equal(t, "1,10\n2,20\n", output.String())
+}
+
 // TestParquetTestingCSVWriter tests that the CSV writer successfully convert 
arrow/parquet-testing files to CSV
 func TestParquetTestingCSVWriter(t *testing.T) {
        dir := os.Getenv("PARQUET_TEST_DATA")
diff --git a/arrow/decimal128/decimal128.go b/arrow/decimal128/decimal128.go
index 660c4131..fca8c799 100644
--- a/arrow/decimal128/decimal128.go
+++ b/arrow/decimal128/decimal128.go
@@ -22,6 +22,7 @@ import (
        "math"
        "math/big"
        "math/bits"
+       "strings"
 
        "github.com/apache/arrow-go/v18/arrow/internal/debug"
 )
@@ -526,13 +527,41 @@ func (n Num) FitsInPrecision(prec int32) bool {
 }
 
 func (n Num) ToString(scale int32) string {
-       f := (&big.Float{}).SetInt(n.BigInt())
-       if scale < 0 {
-               f.SetPrec(128).Mul(f, 
(&big.Float{}).SetInt(scaleMultipliers[-scale].BigInt()))
+       if scale < -MaxScale || scale > MaxScale {
+               panic("arrow/decimal128: scale out of range")
+       }
+
+       value := n.BigInt().String()
+       digits := len(value)
+       if value[0] == '-' {
+               digits--
+       }
+       if digits > MaxPrecision {
+               return n.ToBigFloat(scale).Text('f', int(scale))
+       }
+       if scale <= 0 {
+               if scale < 0 && value != "0" {
+                       value += strings.Repeat("0", int(-scale))
+               }
+               return value
+       }
+
+       negative := value[0] == '-'
+       if negative {
+               value = value[1:]
+       }
+       places := int(scale)
+       var result string
+       if len(value) <= places {
+               result = "0." + strings.Repeat("0", places-len(value)) + value
        } else {
-               f.SetPrec(128).Quo(f, 
(&big.Float{}).SetInt(scaleMultipliers[scale].BigInt()))
+               at := len(value) - places
+               result = value[:at] + "." + value[at:]
+       }
+       if negative {
+               return "-" + result
        }
-       return f.Text('f', int(scale))
+       return result
 }
 
 func GetScaleMultiplier(pow int) Num { return scaleMultipliers[pow] }
diff --git a/arrow/decimal128/decimal128_test.go 
b/arrow/decimal128/decimal128_test.go
index f9758988..ba5539f1 100644
--- a/arrow/decimal128/decimal128_test.go
+++ b/arrow/decimal128/decimal128_test.go
@@ -707,3 +707,34 @@ func TestFromStringDecimal128b(t *testing.T) {
        require.NoError(t, err)
        assert.Equal(t, decStr, num.ToString(19))
 }
+
+func TestToStringMatchesBigFloat(t *testing.T) {
+       values := []string{
+               "0",
+               "1",
+               "-1",
+               "9",
+               "-9",
+               "12345678901234567890123456789012345678",
+               "-12345678901234567890123456789012345678",
+               "170141183460469231731687303715884105727",
+               "-170141183460469231731687303715884105727",
+       }
+       scales := []int32{-38, -10, -1, 0, 1, 2, 10, 38}
+
+       for _, value := range values {
+               integer, ok := new(big.Int).SetString(value, 10)
+               require.True(t, ok)
+               number := decimal128.FromBigInt(integer)
+               for _, scale := range scales {
+                       want := number.ToBigFloat(scale).Text('f', int(scale))
+                       assert.Equalf(t, want, number.ToString(scale), 
"value=%s scale=%d", value, scale)
+               }
+       }
+}
+
+func TestToStringRejectsInvalidScale(t *testing.T) {
+       for _, scale := range []int32{-39, 39} {
+               assert.Panics(t, func() { decimal128.FromI64(1).ToString(scale) 
})
+       }
+}
diff --git a/arrow/decimal256/decimal256.go b/arrow/decimal256/decimal256.go
index 82c52a65..a473505f 100644
--- a/arrow/decimal256/decimal256.go
+++ b/arrow/decimal256/decimal256.go
@@ -22,6 +22,7 @@ import (
        "math"
        "math/big"
        "math/bits"
+       "strings"
 
        "github.com/apache/arrow-go/v18/arrow/decimal128"
        "github.com/apache/arrow-go/v18/arrow/internal/debug"
@@ -526,13 +527,41 @@ func (n Num) FitsInPrecision(prec int32) bool {
 }
 
 func (n Num) ToString(scale int32) string {
-       f := (&big.Float{}).SetInt(n.BigInt())
-       if scale < 0 {
-               f.SetPrec(256).Mul(f, 
(&big.Float{}).SetInt(scaleMultipliers[-scale].BigInt()))
+       if scale < -MaxScale || scale > MaxScale {
+               panic("arrow/decimal256: scale out of range")
+       }
+
+       value := n.BigInt().String()
+       digits := len(value)
+       if value[0] == '-' {
+               digits--
+       }
+       if digits > MaxPrecision {
+               return n.ToBigFloat(scale).Text('f', int(scale))
+       }
+       if scale <= 0 {
+               if scale < 0 && value != "0" {
+                       value += strings.Repeat("0", int(-scale))
+               }
+               return value
+       }
+
+       negative := value[0] == '-'
+       if negative {
+               value = value[1:]
+       }
+       places := int(scale)
+       var result string
+       if len(value) <= places {
+               result = "0." + strings.Repeat("0", places-len(value)) + value
        } else {
-               f.SetPrec(256).Quo(f, 
(&big.Float{}).SetInt(scaleMultipliers[scale].BigInt()))
+               at := len(value) - places
+               result = value[:at] + "." + value[at:]
+       }
+       if negative {
+               return "-" + result
        }
-       return f.Text('f', int(scale))
+       return result
 }
 
 func GetScaleMultiplier(pow int) Num { return scaleMultipliers[pow] }
diff --git a/arrow/decimal256/decimal256_test.go 
b/arrow/decimal256/decimal256_test.go
index a93cdfe4..0d1f14f2 100644
--- a/arrow/decimal256/decimal256_test.go
+++ b/arrow/decimal256/decimal256_test.go
@@ -590,6 +590,37 @@ func TestToString(t *testing.T) {
        assert.Equal(t, decStr+"0000", dec.ToString(-4))
 }
 
+func TestToStringMatchesBigFloat(t *testing.T) {
+       values := []string{
+               "0",
+               "1",
+               "-1",
+               "9",
+               "-9",
+               
"1234567890123456789012345678901234567890123456789012345678901234567890123456",
+               
"-1234567890123456789012345678901234567890123456789012345678901234567890123456",
+               
"57896044618658097711785492504343953926634992332820282019728792003956564819967",
+               
"-57896044618658097711785492504343953926634992332820282019728792003956564819967",
+       }
+       scales := []int32{-76, -10, -1, 0, 1, 2, 10, 38, 76}
+
+       for _, value := range values {
+               integer, ok := new(big.Int).SetString(value, 10)
+               assert.True(t, ok)
+               number := decimal256.FromBigInt(integer)
+               for _, scale := range scales {
+                       want := number.ToBigFloat(scale).Text('f', int(scale))
+                       assert.Equalf(t, want, number.ToString(scale), 
"value=%s scale=%d", value, scale)
+               }
+       }
+}
+
+func TestToStringRejectsInvalidScale(t *testing.T) {
+       for _, scale := range []int32{-77, 77} {
+               assert.Panics(t, func() { decimal256.FromI64(1).ToString(scale) 
})
+       }
+}
+
 // Test issues from GH-38395
 func TestHexFromString(t *testing.T) {
        const decStr = 
"11111111111111111111111111111111111111.00000000000000000000000000000000000000"

Reply via email to