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 17ae3305 perf(arrow/array): pack validity values in batches (#1185)
17ae3305 is described below

commit 17ae3305e5f868e3354c0ec8d597a072e79f4bd4
Author: Minh Vu <[email protected]>
AuthorDate: Fri Aug 14 18:06:45 2026 +0200

    perf(arrow/array): pack validity values in batches (#1185)
    
    ### Rationale for this change
    
    Appending an explicit `[]bool` validity slice currently updates the
    bitmap and null count one value at a time. This is a noticeable part of
    `AppendValues` for narrow types.
    
    ### What changes are included in this PR?
    
    - handle the unaligned prefix and trailing values individually
    - pack aligned validity values eight at a time
    - count nulls once per packed byte
    - add coverage for all starting bit offsets, different lengths, and
    stale neighboring bits
    - add helper and builder benchmarks
    
    Benchmarks append 65,536 values on an Apple M1 Pro. Values are medians
    from three runs.
    
    | Benchmark | Before | After | Change |
    | --- | ---: | ---: | ---: |
    | Int8 / all valid | 68.9 us | 19.6 us | 3.51x |
    | Int8 / 50% null | 89.9 us | 20.4 us | 4.41x |
    | Int64 / all valid | 87.9 us | 31.7 us | 2.77x |
    | Int64 / 50% null | 103.0 us | 33.6 us | 3.06x |
    | Boolean / all valid | 212.0 us | 162.0 us | 1.31x |
    | Boolean / 50% null | 225.0 us | 163.3 us | 1.38x |
    | String / all valid | 538.1 us | 471.4 us | 1.14x |
    | String / 50% null | 553.9 us | 476.0 us | 1.16x |
    
    No allocations are added.
    
    ### Are these changes tested?
    
    Yes.
    
    - `go test ./arrow/...`
    - `go test -race ./arrow/array`
    - `go vet ./arrow/array`
    
    ### Are there any user-facing changes?
    
    No. This only changes how builder validity bitmaps are populated.
---
 arrow/array/builder.go                         |  83 +++++++++++----
 arrow/array/builder_test.go                    |  57 ++++++++++
 arrow/array/builder_validity_benchmark_test.go | 138 +++++++++++++++++++++++++
 3 files changed, 260 insertions(+), 18 deletions(-)

diff --git a/arrow/array/builder.go b/arrow/array/builder.go
index 2e0500af..cdcd35cd 100644
--- a/arrow/array/builder.go
+++ b/arrow/array/builder.go
@@ -18,6 +18,7 @@ package array
 
 import (
        "fmt"
+       "math/bits"
        "sync/atomic"
 
        "github.com/apache/arrow-go/v18/arrow"
@@ -196,32 +197,78 @@ func (b *builder) unsafeAppendBoolsToBitmap(valid []bool, 
length int) {
                return
        }
 
+       validLength := len(valid)
        byteOffset := b.length / 8
-       bitOffset := byte(b.length % 8)
        nullBitmap := b.nullBitmap.Bytes()
-       bitSet := nullBitmap[byteOffset]
-
-       for _, v := range valid {
-               if bitOffset == 8 {
-                       bitOffset = 0
-                       nullBitmap[byteOffset] = bitSet
-                       byteOffset++
-                       bitSet = nullBitmap[byteOffset]
-               }
+       bitOffset := b.length % 8
 
-               if v {
-                       bitSet |= bitutil.BitMask[bitOffset]
-               } else {
-                       bitSet &= bitutil.FlippedBitMask[bitOffset]
-                       b.nulls++
+       if bitOffset != 0 {
+               bitSet := nullBitmap[byteOffset]
+               prefixLength := min(8-bitOffset, len(valid))
+               for i, v := range valid[:prefixLength] {
+                       if v {
+                               bitSet |= bitutil.BitMask[bitOffset+i]
+                       } else {
+                               bitSet &= bitutil.FlippedBitMask[bitOffset+i]
+                               b.nulls++
+                       }
                }
-               bitOffset++
+               nullBitmap[byteOffset] = bitSet
+               valid = valid[prefixLength:]
+               byteOffset++
        }
 
-       if bitOffset != 0 {
+       for len(valid) >= 8 {
+               bitSet := packValidityByte(valid)
                nullBitmap[byteOffset] = bitSet
+               b.nulls += 8 - bits.OnesCount8(bitSet)
+               valid = valid[8:]
+               byteOffset++
+       }
+
+       if len(valid) != 0 {
+               bitSet := nullBitmap[byteOffset]
+               for i, v := range valid {
+                       if v {
+                               bitSet |= bitutil.BitMask[i]
+                       } else {
+                               bitSet &= bitutil.FlippedBitMask[i]
+                               b.nulls++
+                       }
+               }
+               nullBitmap[byteOffset] = bitSet
+       }
+       b.length += validLength
+}
+
+func packValidityByte(valid []bool) byte {
+       valid = valid[:8]
+       var packed byte
+       if valid[0] {
+               packed |= 1 << 0
+       }
+       if valid[1] {
+               packed |= 1 << 1
+       }
+       if valid[2] {
+               packed |= 1 << 2
+       }
+       if valid[3] {
+               packed |= 1 << 3
+       }
+       if valid[4] {
+               packed |= 1 << 4
+       }
+       if valid[5] {
+               packed |= 1 << 5
+       }
+       if valid[6] {
+               packed |= 1 << 6
+       }
+       if valid[7] {
+               packed |= 1 << 7
        }
-       b.length += len(valid)
+       return packed
 }
 
 // unsafeSetValid sets the next length bits to valid in the validity bitmap.
diff --git a/arrow/array/builder_test.go b/arrow/array/builder_test.go
index 045317dc..166a6a4c 100644
--- a/arrow/array/builder_test.go
+++ b/arrow/array/builder_test.go
@@ -19,6 +19,7 @@ package array
 import (
        "testing"
 
+       "github.com/apache/arrow-go/v18/arrow/bitutil"
        "github.com/apache/arrow-go/v18/arrow/internal/testing/tools"
        "github.com/apache/arrow-go/v18/arrow/memory"
        "github.com/stretchr/testify/assert"
@@ -56,6 +57,62 @@ func TestBuilder_UnsafeSetValid(t *testing.T) {
        assert.Equal(t, []byte{0xe0, 0xff, 0x3f, 0}, ab.nullBitmap.Bytes())
 }
 
+func TestBuilder_UnsafeAppendBoolsToBitmap(t *testing.T) {
+       patterns := []struct {
+               name  string
+               valid func(int) bool
+       }{
+               {"all valid", func(int) bool { return true }},
+               {"all null", func(int) bool { return false }},
+               {"alternating", func(i int) bool { return i%2 == 0 }},
+               {"one in three null", func(i int) bool { return i%3 != 0 }},
+       }
+
+       for _, pattern := range patterns {
+               for offset := 0; offset < 8; offset++ {
+                       for length := 1; length <= 33; length++ {
+                               b := &builder{mem: memory.NewGoAllocator()}
+                               b.init(48)
+                               for i := range b.nullBitmap.Bytes() {
+                                       b.nullBitmap.Bytes()[i] = byte(0x5a + 
i*31)
+                               }
+
+                               expectedBitmap := append([]byte(nil), 
b.nullBitmap.Bytes()...)
+                               valid := make([]bool, length)
+                               expectedNulls := offset - 
bitutil.CountSetBits(expectedBitmap, 0, offset)
+                               for i := range valid {
+                                       valid[i] = pattern.valid(i)
+                                       if valid[i] {
+                                               bitutil.SetBit(expectedBitmap, 
offset+i)
+                                       } else {
+                                               
bitutil.ClearBit(expectedBitmap, offset+i)
+                                               expectedNulls++
+                                       }
+                               }
+
+                               b.length = offset
+                               b.nulls = offset - 
bitutil.CountSetBits(b.nullBitmap.Bytes(), 0, offset)
+                               b.unsafeAppendBoolsToBitmap(valid, len(valid))
+
+                               assert.Equal(t, offset+length, b.Len(), "%s, 
offset=%d, length=%d", pattern.name, offset, length)
+                               assert.Equal(t, expectedNulls, b.NullN(), "%s, 
offset=%d, length=%d", pattern.name, offset, length)
+                               assert.Equal(t, expectedBitmap, 
b.nullBitmap.Bytes(), "%s, offset=%d, length=%d", pattern.name, offset, length)
+                               b.nullBitmap.Release()
+                       }
+               }
+       }
+}
+
+func TestPackValidityByte(t *testing.T) {
+       for want := 0; want < 1<<8; want++ {
+               valid := make([]bool, 8)
+               for i := range valid {
+                       valid[i] = want&(1<<i) != 0
+               }
+               assert.Equal(t, byte(want), packValidityByte(valid), 
"want=%08b", want)
+       }
+}
+
 func TestBuilder_resize(t *testing.T) {
        b := &builder{mem: memory.NewGoAllocator()}
        n := 64
diff --git a/arrow/array/builder_validity_benchmark_test.go 
b/arrow/array/builder_validity_benchmark_test.go
new file mode 100644
index 00000000..bb1a9403
--- /dev/null
+++ b/arrow/array/builder_validity_benchmark_test.go
@@ -0,0 +1,138 @@
+// 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 array
+
+import (
+       "fmt"
+       "testing"
+
+       "github.com/apache/arrow-go/v18/arrow/memory"
+)
+
+func BenchmarkAppendBoolsToBitmap(b *testing.B) {
+       const length = 65536
+       patterns := []struct {
+               name  string
+               valid []bool
+       }{
+               {"all-valid", makeValidityBenchmarkValues(length, func(int) 
bool { return true })},
+               {"1pct-null", makeValidityBenchmarkValues(length, func(i int) 
bool { return i%100 != 0 })},
+               {"10pct-null", makeValidityBenchmarkValues(length, func(i int) 
bool { return i%10 != 0 })},
+               {"50pct-null", makeValidityBenchmarkValues(length, func(i int) 
bool { return i%2 == 0 })},
+               {"runs", makeValidityBenchmarkValues(length, func(i int) bool { 
return i%128 < 64 })},
+       }
+
+       for _, pattern := range patterns {
+               for offset := 0; offset < 8; offset++ {
+                       b.Run(fmt.Sprintf("%s/offset=%d", pattern.name, 
offset), func(b *testing.B) {
+                               var bldr builder
+                               bldr.mem = memory.DefaultAllocator
+                               bldr.init(length + offset)
+                               defer bldr.nullBitmap.Release()
+                               bldr.length = offset
+
+                               b.ReportAllocs()
+                               b.ResetTimer()
+                               for i := 0; i < b.N; i++ {
+                                       bldr.length = offset
+                                       bldr.nulls = 0
+                                       
bldr.unsafeAppendBoolsToBitmap(pattern.valid, len(pattern.valid))
+                               }
+                       })
+               }
+       }
+}
+
+func BenchmarkAppendValuesWithValidity(b *testing.B) {
+       const length = 65536
+       patterns := []struct {
+               name  string
+               valid []bool
+       }{
+               {"all-valid", makeValidityBenchmarkValues(length, func(int) 
bool { return true })},
+               {"10pct-null", makeValidityBenchmarkValues(length, func(i int) 
bool { return i%10 != 0 })},
+               {"50pct-null", makeValidityBenchmarkValues(length, func(i int) 
bool { return i%2 == 0 })},
+       }
+       int8Values := make([]int8, length)
+       int64Values := make([]int64, length)
+       boolValues := makeValidityBenchmarkValues(length, func(i int) bool { 
return i%2 == 0 })
+       stringValues := make([]string, length)
+       for i := range stringValues {
+               stringValues[i] = "x"
+       }
+
+       for _, pattern := range patterns {
+               b.Run("int8/"+pattern.name, func(b *testing.B) {
+                       benchmarkAppendValues(b, func() (func(), func()) {
+                               bldr := NewInt8Builder(memory.DefaultAllocator)
+                               bldr.Reserve(length)
+                               return func() {
+                                       bldr.AppendValues(int8Values, 
pattern.valid)
+                               }, bldr.Release
+                       })
+               })
+               b.Run("int64/"+pattern.name, func(b *testing.B) {
+                       benchmarkAppendValues(b, func() (func(), func()) {
+                               bldr := NewInt64Builder(memory.DefaultAllocator)
+                               bldr.Reserve(length)
+                               return func() {
+                                       bldr.AppendValues(int64Values, 
pattern.valid)
+                               }, bldr.Release
+                       })
+               })
+               b.Run("boolean/"+pattern.name, func(b *testing.B) {
+                       benchmarkAppendValues(b, func() (func(), func()) {
+                               bldr := 
NewBooleanBuilder(memory.DefaultAllocator)
+                               bldr.Reserve(length)
+                               return func() {
+                                       bldr.AppendValues(boolValues, 
pattern.valid)
+                               }, bldr.Release
+                       })
+               })
+               b.Run("string/"+pattern.name, func(b *testing.B) {
+                       benchmarkAppendValues(b, func() (func(), func()) {
+                               bldr := 
NewStringBuilder(memory.DefaultAllocator)
+                               bldr.Reserve(length)
+                               bldr.ReserveData(length)
+                               return func() {
+                                       bldr.AppendValues(stringValues, 
pattern.valid)
+                               }, bldr.Release
+                       })
+               })
+       }
+}
+
+func benchmarkAppendValues(b *testing.B, setup func() (appendValues, release 
func())) {
+       b.ReportAllocs()
+       b.ResetTimer()
+       for i := 0; i < b.N; i++ {
+               b.StopTimer()
+               appendValues, release := setup()
+               b.StartTimer()
+               appendValues()
+               b.StopTimer()
+               release()
+       }
+}
+
+func makeValidityBenchmarkValues(length int, valid func(int) bool) []bool {
+       values := make([]bool, length)
+       for i := range values {
+               values[i] = valid(i)
+       }
+       return values
+}

Reply via email to