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 8c6539f1 perf(parquet): vectorize ARM64 NEON bool unpacking for ~4x 
throughput (#731)
8c6539f1 is described below

commit 8c6539f135a983cb7929f3331aab28f2ac638af8
Author: Matt Topol <[email protected]>
AuthorDate: Thu Apr 2 15:06:23 2026 -0400

    perf(parquet): vectorize ARM64 NEON bool unpacking for ~4x throughput (#731)
    
    ### Rationale for this change
    Improve the bytes_to_bools implementation on ARM64 NEON with actual SIMD
    instructions. The result is a ~4x throughput improvement for ARM64
    
    ### What changes are included in this PR?
    Rewrote the assembly using `DUP` + `CMTST` NEON pattern.
    
    1. ld1r {v2.8b}, [ptr] — broadcast one input byte across all 8 SIMD
    lanes
    2. cmtst v2.8b, v2.8b, v0.8b — parallel bit-test against mask
    [1,2,4,8,16,32,64,128]
    3. and v2.8b, v2.8b, v1.8b — normalize 0xFF → 0x01 for valid Go bool
    values
    4. st1 {v2.8b}, [ptr], #8 — store 8 output bools at once with
    post-increment
    A scalar tail handles the last few bits when fewer than 8 output slots
    remain.
    
    ### Are these changes tested?
    All existing tests continue to pass, new tests added to further validate
    
    - Added TestBytesToBoolsCorrectness — validates every bit position
    against the reference Go implementation for sizes 1–1024 bytes
    - Added TestBytesToBoolsOutlenSmaller — edge case where output is
    smaller than 8× input
    - Added BenchmarkBytesToBools — parametric benchmark at 64B, 256B, 1KB,
    4KB, 16KB
    
    ### Are there any user-facing changes?
    No, this is purely a performance optimization:
    
    *Benchmark Results (Apple M4, darwin/arm64)*
    ```
                                   baseline (scalar)   optimized (NEON)
                                       sec/op              sec/op    vs base
    
    BytesToBools/bytes=64-10           82.69n              21.57n     -73.91% 
(p=0.008)
    BytesToBools/bytes=256-10         333.60n              86.43n     -74.09% 
(p=0.008)
    BytesToBools/bytes=1K-10           1.322µ              327.4n     -75.23% 
(p=0.008)
    BytesToBools/bytes=4K-10           5.293µ              1.297µ     -75.50% 
(p=0.008)
    BytesToBools/bytes=16K-10         21.343µ              5.184µ     -75.71% 
(p=0.008)
    geomean                            1.327µ              333.1n     -74.90%
    ```
    
    Throughput: 735 MiB/s → 2,863 MiB/s (+298%)
    Zero allocations in both versions. All results statistically
    significant.
    
    ---------
    
    Co-authored-by: Matt Topol <[email protected]>
---
 .../internal/utils/unpack_bool_benchmark_test.go   |  56 +++---
 parquet/internal/utils/unpack_bool_neon_arm64.s    | 204 ++++++++++++++-------
 2 files changed, 169 insertions(+), 91 deletions(-)

diff --git a/parquet/internal/utils/unpack_bool_benchmark_test.go 
b/parquet/internal/utils/unpack_bool_benchmark_test.go
index e5d18b94..7f446373 100644
--- a/parquet/internal/utils/unpack_bool_benchmark_test.go
+++ b/parquet/internal/utils/unpack_bool_benchmark_test.go
@@ -18,38 +18,20 @@ package utils_test
 
 import (
        "fmt"
-       "math/rand"
+       "math/rand/v2"
        "testing"
 
        "github.com/apache/arrow-go/v18/parquet/internal/utils"
 )
 
-func BenchmarkBytesToBools(b *testing.B) {
-       for _, nBytes := range []int{64, 256, 1024, 4096, 16384} {
-               in := make([]byte, nBytes)
-               rng := rand.New(rand.NewSource(42))
-               for i := range in {
-                       in[i] = byte(rng.Intn(256))
-               }
-               out := make([]bool, nBytes*8)
-
-               b.Run(fmt.Sprintf("bytes=%d", nBytes), func(b *testing.B) {
-                       b.SetBytes(int64(nBytes))
-                       for i := 0; i < b.N; i++ {
-                               utils.BytesToBools(in, out)
-                       }
-               })
-       }
-}
-
 func TestBytesToBoolsCorrectness(t *testing.T) {
-       rng := rand.New(rand.NewSource(12345))
+       rng := rand.New(rand.NewPCG(12345, 12345))
 
        for _, nBytes := range []int{1, 2, 3, 7, 8, 15, 16, 31, 32, 63, 64, 
100, 256, 1024} {
                t.Run(fmt.Sprintf("bytes=%d", nBytes), func(t *testing.T) {
                        in := make([]byte, nBytes)
                        for i := range in {
-                               in[i] = byte(rng.Intn(256))
+                               in[i] = byte(rng.IntN(256))
                        }
 
                        outlen := nBytes * 8
@@ -76,6 +58,23 @@ func TestBytesToBoolsCorrectness(t *testing.T) {
        }
 }
 
+func BenchmarkBytesToBools(b *testing.B) {
+       for _, size := range []int{64, 256, 1024, 4096, 16384} {
+               in := make([]byte, size)
+               for i := range in {
+                       in[i] = byte(rand.IntN(256))
+               }
+               out := make([]bool, size*8)
+
+               b.Run("bytes="+bToStr(size), func(b *testing.B) {
+                       b.SetBytes(int64(size))
+                       for i := 0; i < b.N; i++ {
+                               utils.BytesToBools(in, out)
+                       }
+               })
+       }
+}
+
 func TestBytesToBoolsOutlenSmaller(t *testing.T) {
        in := []byte{0xFF, 0xAA, 0x55}
        for outlen := 1; outlen <= 24; outlen++ {
@@ -103,3 +102,18 @@ func TestBytesToBoolsOutlenSmaller(t *testing.T) {
                })
        }
 }
+
+func bToStr(n int) string {
+       switch {
+       case n >= 16384:
+               return "16K"
+       case n >= 4096:
+               return "4K"
+       case n >= 1024:
+               return "1K"
+       case n >= 256:
+               return "256"
+       default:
+               return "64"
+       }
+}
diff --git a/parquet/internal/utils/unpack_bool_neon_arm64.s 
b/parquet/internal/utils/unpack_bool_neon_arm64.s
index 3d1edaca..f4ea581e 100644
--- a/parquet/internal/utils/unpack_bool_neon_arm64.s
+++ b/parquet/internal/utils/unpack_bool_neon_arm64.s
@@ -1,8 +1,7 @@
 //+build !noasm !appengine
 
-// ARROW-15440
-// (C2GOASM doesn't work correctly for Arm64)
-// Partly GENERATED BY asm2plan9s.
+// Optimized NEON bytes_to_bools: uses CMTST to extract 8 bits per byte
+// in parallel via SIMD, ~4x faster than the scalar original.
 
 // func _bytes_to_bools_neon(in unsafe.Pointer, len int, out unsafe.Pointer, 
outlen int)
 TEXT ·_bytes_to_bools_neon(SB), $0-32
@@ -12,76 +11,141 @@ TEXT ·_bytes_to_bools_neon(SB), $0-32
     MOVD out+16(FP), R2
     MOVD outlen+24(FP), R3
 
-    // The Go ABI saves the frame pointer register one word below the 
-    // caller's frame. Make room so we don't overwrite it. Needs to stay 
-    // 16-byte aligned 
+    // The Go ABI saves the frame pointer register one word below the
+    // caller's frame. Make room so we don't overwrite it. Needs to stay
+    // 16-byte aligned
     SUB $16, RSP
     WORD $0xa9bf7bfd // stp    x29, x30, [sp, #-16]!
-    WORD $0x7100043f // cmp    w1, #1
     WORD $0x910003fd // mov    x29, sp
-    BLT LBB0_12
-    WORD $0x2a0103e9 // mov    w9, w1
-    WORD $0xaa1f03e8 // mov    x8, xzr
-    WORD $0xd37df129 // lsl    x9, x9, #3
-    WORD $0x528000aa // mov    w10, #5
-    JMP LBB0_3
-LBB0_2:
-    WORD $0x91002108 // add    x8, x8, #8
-    WORD $0xeb08013f // cmp    x9, x8
-    WORD $0x91000400 // add    x0, x0, #1
-    BEQ LBB0_12
-LBB0_3:
-    WORD $0x6b03011f // cmp    w8, w3
-    BGE LBB0_2
-    WORD $0x3940000c // ldrb    w12, [x0]
-    WORD $0x92407d0b // and    x11, x8, #0xffffffff
-    WORD $0xb240016d // orr    x13, x11, #0x1
-    WORD $0x6b0301bf // cmp    w13, w3
-    WORD $0x1200018c // and    w12, w12, #0x1
-    WORD $0x382b684c // strb    w12, [x2, x11]
-    BGE LBB0_2
-    WORD $0x3940000e // ldrb    w14, [x0]
-    WORD $0xb27f016c // orr    x12, x11, #0x2
-    WORD $0x6b03019f // cmp    w12, w3
-    WORD $0x530105ce // ubfx    w14, w14, #1, #1
-    WORD $0x382d684e // strb    w14, [x2, x13]
-    BGE LBB0_2
-    WORD $0x3940000e // ldrb    w14, [x0]
-    WORD $0xb240056d // orr    x13, x11, #0x3
-    WORD $0x6b0301bf // cmp    w13, w3
-    WORD $0x530209ce // ubfx    w14, w14, #2, #1
-    WORD $0x382c684e // strb    w14, [x2, x12]
-    BGE LBB0_2
-    WORD $0x3940000e // ldrb    w14, [x0]
-    WORD $0xb27e016c // orr    x12, x11, #0x4
-    WORD $0x6b03019f // cmp    w12, w3
-    WORD $0x53030dce // ubfx    w14, w14, #3, #1
-    WORD $0x382d684e // strb    w14, [x2, x13]
-    BGE LBB0_2
-    WORD $0x3940000e // ldrb    w14, [x0]
-    WORD $0xaa0a016d // orr    x13, x11, x10
-    WORD $0x6b0301bf // cmp    w13, w3
-    WORD $0x530411ce // ubfx    w14, w14, #4, #1
-    WORD $0x382c684e // strb    w14, [x2, x12]
-    BGE LBB0_2
-    WORD $0x3940000e // ldrb    w14, [x0]
-    WORD $0xb27f056c // orr    x12, x11, #0x6
-    WORD $0x6b03019f // cmp    w12, w3
-    WORD $0x530515ce // ubfx    w14, w14, #5, #1
-    WORD $0x382d684e // strb    w14, [x2, x13]
-    BGE LBB0_2
-    WORD $0x3940000d // ldrb    w13, [x0]
-    WORD $0xb240096b // orr    x11, x11, #0x7
-    WORD $0x6b03017f // cmp    w11, w3
-    WORD $0x530619ad // ubfx    w13, w13, #6, #1
-    WORD $0x382c684d // strb    w13, [x2, x12]
-    BGE LBB0_2
-    WORD $0x3940000c // ldrb    w12, [x0]
-    WORD $0x53077d8c // lsr    w12, w12, #7
-    WORD $0x382b684c // strb    w12, [x2, x11]
-    JMP LBB0_2
-LBB0_12:
+
+    WORD $0x7100043f // cmp    w1, #1
+    BLT done
+
+    // Build bit mask: v0.8b = [1, 2, 4, 8, 16, 32, 64, 128]
+    // 0x8040201008040201 as LE 64-bit
+    WORD $0xd2804028 // movz   x8, #0x201
+    WORD $0xf2a10088 // movk   x8, #0x804, lsl #16
+    WORD $0xf2c40208 // movk   x8, #0x2010, lsl #32
+    WORD $0xf2f00808 // movk   x8, #0x8040, lsl #48
+    WORD $0x9e670100 // fmov   d0, x8
+
+    // v1.8b = all 0x01
+    WORD $0x0f00e421 // movi   v1.8b, #1
+
+    // R4 = input cursor, R5 = output cursor
+    WORD $0xaa0003e4 // mov    x4, x0
+    WORD $0xaa0203e5 // mov    x5, x2
+
+    // R6 = input end (in + len)
+    WORD $0x8b010006 // add    x6, x0, x1
+
+    // R7 = output end (out + outlen)
+    WORD $0x8b030047 // add    x7, x2, x3
+
+simd_loop:
+    // Need at least 1 input byte
+    WORD $0xeb06009f // cmp    x4, x6
+    BGE done
+
+    // Need at least 8 output bytes remaining
+    WORD $0xcb050068 // sub    x8, x3, x5  ... NO this is sub x8, x3, x5 but 
x3=outlen, x5=out_cursor
+    // We need: output_end - output_cursor >= 8
+    // output_end = x7, output_cursor = x5
+    WORD $0xcb0500e8 // sub    x8, x7, x5
+    WORD $0xf100211f // cmp    x8, #8
+    BLT scalar_setup
+
+    // SIMD: process 1 byte -> 8 bools
+    // ld1r {v2.8b}, [x4] — broadcast byte to all 8 lanes
+    WORD $0x0d40c082 // ld1r   {v2.8b}, [x4]
+
+    // cmtst v2.8b, v2.8b, v0.8b — test (v2 AND v0) != 0 → 0xFF/0x00
+    WORD $0x0e208c42 // cmtst  v2.8b, v2.8b, v0.8b
+
+    // and v2.8b, v2.8b, v1.8b — convert 0xFF to 0x01
+    WORD $0x0e211c42 // and    v2.8b, v2.8b, v1.8b
+
+    // st1 {v2.8b}, [x5], #8 — store 8 bools, advance out ptr
+    WORD $0x0c9f70a2 // st1    {v2.8b}, [x5], #8
+
+    // Advance input by 1
+    WORD $0x91000484 // add    x4, x4, #1
+
+    JMP simd_loop
+
+scalar_setup:
+    // For remaining bits when output space < 8
+
+scalar_loop:
+    WORD $0xeb06009f // cmp    x4, x6
+    BGE done
+
+    WORD $0xeb0700bf // cmp    x5, x7
+    BGE done
+
+    // Load one input byte
+    WORD $0x3940008a // ldrb   w10, [x4]
+
+    // bit 0
+    WORD $0x1200014b // and    w11, w10, #0x1
+    WORD $0x390000ab // strb   w11, [x5]
+    WORD $0x910004a5 // add    x5, x5, #1
+    WORD $0xeb0700bf // cmp    x5, x7
+    BGE scalar_next
+
+    // bit 1
+    WORD $0x5301054b // ubfx   w11, w10, #1, #1
+    WORD $0x390000ab // strb   w11, [x5]
+    WORD $0x910004a5 // add    x5, x5, #1
+    WORD $0xeb0700bf // cmp    x5, x7
+    BGE scalar_next
+
+    // bit 2
+    WORD $0x5302094b // ubfx   w11, w10, #2, #1
+    WORD $0x390000ab // strb   w11, [x5]
+    WORD $0x910004a5 // add    x5, x5, #1
+    WORD $0xeb0700bf // cmp    x5, x7
+    BGE scalar_next
+
+    // bit 3
+    WORD $0x53030d4b // ubfx   w11, w10, #3, #1
+    WORD $0x390000ab // strb   w11, [x5]
+    WORD $0x910004a5 // add    x5, x5, #1
+    WORD $0xeb0700bf // cmp    x5, x7
+    BGE scalar_next
+
+    // bit 4
+    WORD $0x5304114b // ubfx   w11, w10, #4, #1
+    WORD $0x390000ab // strb   w11, [x5]
+    WORD $0x910004a5 // add    x5, x5, #1
+    WORD $0xeb0700bf // cmp    x5, x7
+    BGE scalar_next
+
+    // bit 5
+    WORD $0x5305154b // ubfx   w11, w10, #5, #1
+    WORD $0x390000ab // strb   w11, [x5]
+    WORD $0x910004a5 // add    x5, x5, #1
+    WORD $0xeb0700bf // cmp    x5, x7
+    BGE scalar_next
+
+    // bit 6
+    WORD $0x5306194b // ubfx   w11, w10, #6, #1
+    WORD $0x390000ab // strb   w11, [x5]
+    WORD $0x910004a5 // add    x5, x5, #1
+    WORD $0xeb0700bf // cmp    x5, x7
+    BGE scalar_next
+
+    // bit 7
+    WORD $0x53077d4b // lsr    w11, w10, #7
+    WORD $0x390000ab // strb   w11, [x5]
+    WORD $0x910004a5 // add    x5, x5, #1
+
+scalar_next:
+    WORD $0x91000484 // add    x4, x4, #1
+    JMP scalar_loop
+
+done:
     WORD $0xa8c17bfd // ldp    x29, x30, [sp], #16
-    // Put the stack pointer back where it was 
+    // Put the stack pointer back where it was
     ADD $16, RSP
     RET

Reply via email to