Repository: segmentio/parquet-go
Pull request #210 (2022-06-03 23:56:19)

Optimize RLE encoding
This PR optimizes the RLE encoding, which is used when writing dictionary indexes, repetition and definition levels, and boolean columns.

The encoding of repetition and definition levels are employed when writing repeated or optional columns, in that regard those optimizations participate in completing the work to improve the throughput of writing optional columns (see https://github.com/segmentio/parquet-go/pull/205).

The bit packing for 32 bits integer values was adapted from the algorithm we have in the DELTA_BINARY_PACKED encoding (see https://github.com/segmentio/parquet-go/pull/200).

```
name                old time/op    new time/op     delta
Encode/RLE/boolean     994ns ± 0%      992ns ± 0%    -0.19%  (p=0.001 n=9+9)
Encode/RLE/levels     11.0µs ± 0%      1.9µs ± 0%   -82.44%  (p=0.000 n=9+9)
Encode/RLE/int32      34.8µs ± 0%      3.5µs ± 0%   -89.93%  (p=0.000 n=10+9)

name                old speed      new speed       delta
Encode/RLE/boolean  1.26GB/s ± 0%   1.26GB/s ± 0%    +0.19%  (p=0.000 n=9+9)
Encode/RLE/levels    908MB/s ± 0%   5172MB/s ± 0%  +469.53%  (p=0.000 n=9+9)
Encode/RLE/int32    1.15GB/s ± 0%  11.40GB/s ± 0%  +892.67%  (p=0.000 n=10+9)

name                old value/s    new value/s     delta
Encode/RLE/boolean     10.1G ± 0%      10.1G ± 0%    +0.19%  (p=0.000 n=9+9)
Encode/RLE/levels       908M ± 0%      5172M ± 0%  +469.53%  (p=0.000 n=9+9)
Encode/RLE/int32        287M ± 0%      2849M ± 0%  +892.67%  (p=0.000 n=10+9)
```

I find it interesting to see how byte throughput isn't necessarily what we should be optimizing for. Often times increasing byte throughput requires introducing a lot of complexity in the SIMD algorithms. The encoded value throughput is a lot more important, and tight packing of values in memory is very effective to improve it. The encoding if bit-packed boolean values runs at 10 billion values/s but has the lowest byte throughput, while the encoding of int32 values runs at _only_ 2.8 billion values/s despite having the highest byte throughput (because we read a lot more memory).

Let me know if you have any feedback.


Patch:
diff --git a/column_buffer.go b/column_buffer.go
index c74a507..4311b04 100644
--- a/column_buffer.go
+++ b/column_buffer.go
@@ -379,16 +379,10 @@ func (col *optionalColumnBuffer) writeValues(rows array, size, offset uintptr, l
 		col.rows = tmp
 	}
 
-	newRows := col.rows[i:]
 	if levels.definitionLevel != col.maxDefinitionLevel {
-		for i := range newRows {
-			newRows[i] = -1
-		}
+		broadcastValueInt32(col.rows[i:], -1)
 	} else {
-		rowIndex := int32(col.base.Len())
-		for i := range newRows {
-			newRows[i] = rowIndex + int32(i)
-		}
+		broadcastRangeInt32(col.rows[i:], int32(col.base.Len()))
 		col.base.writeValues(rows, size, offset, levels)
 	}
 }
@@ -877,20 +871,43 @@ func (col *booleanColumnBuffer) writeValues(rows array, size, offset uintptr, _
 			col.numValues += int32(i)
 		}
 
-		if n := rows.len - i; n >= 8 {
+		if n := ((rows.len - i) / 8) * 8; n > 0 {
 			// At this stage, we know that that we have at least 8 bits to write
 			// and the bits will be aligned on the address of a byte in the
 			// output buffer. We can work on 8 values per loop iteration,
 			// packing them into a single byte and writing it to the output
 			// buffer. This effectively reduces by 87.5% the number of memory
 			// stores that the program needs to perform to generate the values.
-			section := array{
-				ptr: rows.index(i, size, 0),
-				len: ((rows.len - i) / 8) * 8,
+			if optimize(n) {
+				section := array{
+					ptr: rows.index(i, size, 0),
+					len: n,
+				}
+				writeValuesBool(col.bits[col.numValues/8:], section, size, offset)
+				i += section.len
+			} else {
+				for j := col.numValues / 8; i < n; j++ {
+					b0 := *(*byte)(rows.index(i+0, size, offset))
+					b1 := *(*byte)(rows.index(i+1, size, offset))
+					b2 := *(*byte)(rows.index(i+2, size, offset))
+					b3 := *(*byte)(rows.index(i+3, size, offset))
+					b4 := *(*byte)(rows.index(i+4, size, offset))
+					b5 := *(*byte)(rows.index(i+5, size, offset))
+					b6 := *(*byte)(rows.index(i+6, size, offset))
+					b7 := *(*byte)(rows.index(i+7, size, offset))
+
+					col.bits[j] = (b0 & 1) |
+						((b1 & 1) << 1) |
+						((b2 & 1) << 2) |
+						((b3 & 1) << 3) |
+						((b4 & 1) << 4) |
+						((b5 & 1) << 5) |
+						((b6 & 1) << 6) |
+						((b7 & 1) << 7)
+					i += 8
+				}
 			}
-			writeValuesBool(col.bits[col.numValues/8:], section, size, offset)
-			col.numValues += int32(section.len)
-			i += section.len
+			col.numValues += int32(n)
 		}
 	}
 
@@ -996,9 +1013,17 @@ func (col *int32ColumnBuffer) writeValues(rows array, size, offset uintptr, _ co
 	if n := len(col.values) + rows.len; n > cap(col.values) {
 		col.values = append(make([]int32, 0, max(n, 2*cap(col.values))), col.values...)
 	}
+
 	n := len(col.values)
 	col.values = col.values[:n+rows.len]
-	writeValuesInt32(col.values[n:], rows, size, offset)
+
+	if values := col.values[n:]; optimize(len(values)) {
+		writeValuesInt32(values, rows, size, offset)
+	} else {
+		for i := range values {
+			values[i] = *(*int32)(rows.index(i, size, offset))
+		}
+	}
 }
 
 func (col *int32ColumnBuffer) ReadValuesAt(values []Value, offset int64) (n int, err error) {
@@ -1093,7 +1118,14 @@ func (col *int64ColumnBuffer) writeValues(rows array, size, offset uintptr, _ co
 	}
 	n := len(col.values)
 	col.values = col.values[:n+rows.len]
-	writeValuesInt64(col.values[n:], rows, size, offset)
+
+	if values := col.values[n:]; optimize(len(values)) {
+		writeValuesInt64(values, rows, size, offset)
+	} else {
+		for i := range values {
+			values[i] = *(*int64)(rows.index(i, size, offset))
+		}
+	}
 }
 
 func (col *int64ColumnBuffer) ReadValuesAt(values []Value, offset int64) (n int, err error) {
@@ -1282,7 +1314,14 @@ func (col *floatColumnBuffer) writeValues(rows array, size, offset uintptr, _ co
 	}
 	n := len(col.values)
 	col.values = col.values[:n+rows.len]
-	writeValuesFloat32(col.values[n:], rows, size, offset)
+
+	if values := col.values[n:]; optimize(len(values)) {
+		writeValuesFloat32(values, rows, size, offset)
+	} else {
+		for i := range values {
+			values[i] = *(*float32)(rows.index(i, size, offset))
+		}
+	}
 }
 
 func (col *floatColumnBuffer) ReadValuesAt(values []Value, offset int64) (n int, err error) {
@@ -1377,7 +1416,14 @@ func (col *doubleColumnBuffer) writeValues(rows array, size, offset uintptr, _ c
 	}
 	n := len(col.values)
 	col.values = col.values[:n+rows.len]
-	writeValuesFloat64(col.values[n:], rows, size, offset)
+
+	if values := col.values[n:]; optimize(len(values)) {
+		writeValuesFloat64(values, rows, size, offset)
+	} else {
+		for i := range values {
+			values[i] = *(*float64)(rows.index(i, size, offset))
+		}
+	}
 }
 
 func (col *doubleColumnBuffer) ReadValuesAt(values []Value, offset int64) (n int, err error) {
@@ -1653,10 +1699,9 @@ func (col *fixedLenByteArrayColumnBuffer) writeValues(rows array, size, offset u
 	col.data = col.data[:j]
 	newData := col.data[i:]
 
-	switch col.size {
-	case 16:
+	if optimize(rows.len) && col.size == 16 {
 		writeValuesUint128(newData, rows, size, offset)
-	default:
+	} else {
 		for i := 0; i < rows.len; i++ {
 			p := rows.index(i, size, offset)
 			copy(newData[i*col.size:], unsafe.Slice((*byte)(p), col.size))
@@ -1756,7 +1801,14 @@ func (col *uint32ColumnBuffer) writeValues(rows array, size, offset uintptr, _ c
 	}
 	n := len(col.values)
 	col.values = col.values[:n+rows.len]
-	writeValuesUint32(col.values[n:], rows, size, offset)
+
+	if values := col.values[n:]; optimize(len(values)) {
+		writeValuesUint32(values, rows, size, offset)
+	} else {
+		for i := range values {
+			values[i] = *(*uint32)(rows.index(i, size, offset))
+		}
+	}
 }
 
 func (col *uint32ColumnBuffer) ReadValuesAt(values []Value, offset int64) (n int, err error) {
@@ -1851,7 +1903,14 @@ func (col *uint64ColumnBuffer) writeValues(rows array, size, offset uintptr, _ c
 	}
 	n := len(col.values)
 	col.values = col.values[:n+rows.len]
-	writeValuesUint64(col.values[n:], rows, size, offset)
+
+	if values := col.values[n:]; optimize(len(values)) {
+		writeValuesUint64(values, rows, size, offset)
+	} else {
+		for i := range values {
+			values[i] = *(*uint64)(rows.index(i, size, offset))
+		}
+	}
 }
 
 func (col *uint64ColumnBuffer) ReadValuesAt(values []Value, offset int64) (n int, err error) {
diff --git a/column_buffer_amd64.go b/column_buffer_amd64.go
index 981bb67..566aca7 100644
--- a/column_buffer_amd64.go
+++ b/column_buffer_amd64.go
@@ -4,48 +4,67 @@ package parquet
 
 import (
 	"unsafe"
+
+	"golang.org/x/sys/cpu"
 )
 
+func broadcastValueInt32(dst []int32, src int8) {
+	memset(unsafe.Slice(*(**byte)(unsafe.Pointer(&dst)), len(dst)*4), byte(src))
+}
+
+//go:noescape
+func broadcastRangeInt32AVX2(dst []int32, base int32)
+
+func broadcastRangeInt32(dst []int32, base int32) {
+	if len(dst) >= minLenAVX2 && cpu.X86.HasAVX2 {
+		broadcastRangeInt32AVX2(dst, base)
+	} else {
+		for i := range dst {
+			dst[i] = base + int32(i)
+		}
+	}
+}
+
 //go:noescape
-func writeValuesBitpack(values unsafe.Pointer, rows array, size, offset uintptr)
+func writeValuesBitpackAVX2(values unsafe.Pointer, rows array, size, offset uintptr)
 
 //go:noescape
-func writeValues32bits(values unsafe.Pointer, rows array, size, offset uintptr)
+func writeValues32bitsAVX2(values unsafe.Pointer, rows array, size, offset uintptr)
 
 //go:noescpae
-func writeValues64bits(values unsafe.Pointer, rows array, size, offset uintptr)
+func writeValues64bitsAVX2(values unsafe.Pointer, rows array, size, offset uintptr)
 
 //go:noescape
-func writeValues128bits(values unsafe.Pointer, rows array, size, offset uintptr)
+func writeValues128bitsAVX2(values unsafe.Pointer, rows array, size, offset uintptr)
 
 func writeValuesBool(values []byte, rows array, size, offset uintptr) {
-	writeValuesBitpack(*(*unsafe.Pointer)(unsafe.Pointer(&values)), rows, size, offset)
+	writeValuesBitpackAVX2(*(*unsafe.Pointer)(unsafe.Pointer(&values)), rows, size, offset)
 }
 
 func writeValuesInt32(values []int32, rows array, size, offset uintptr) {
-	writeValues32bits(*(*unsafe.Pointer)(unsafe.Pointer(&values)), rows, size, offset)
+	writeValues32bitsAVX2(*(*unsafe.Pointer)(unsafe.Pointer(&values)), rows, size, offset)
 }
 
 func writeValuesInt64(values []int64, rows array, size, offset uintptr) {
-	writeValues64bits(*(*unsafe.Pointer)(unsafe.Pointer(&values)), rows, size, offset)
+	writeValues64bitsAVX2(*(*unsafe.Pointer)(unsafe.Pointer(&values)), rows, size, offset)
 }
 
 func writeValuesUint32(values []uint32, rows array, size, offset uintptr) {
-	writeValues32bits(*(*unsafe.Pointer)(unsafe.Pointer(&values)), rows, size, offset)
+	writeValues32bitsAVX2(*(*unsafe.Pointer)(unsafe.Pointer(&values)), rows, size, offset)
 }
 
 func writeValuesUint64(values []uint64, rows array, size, offset uintptr) {
-	writeValues64bits(*(*unsafe.Pointer)(unsafe.Pointer(&values)), rows, size, offset)
+	writeValues64bitsAVX2(*(*unsafe.Pointer)(unsafe.Pointer(&values)), rows, size, offset)
 }
 
 func writeValuesUint128(values []byte, rows array, size, offset uintptr) {
-	writeValues128bits(*(*unsafe.Pointer)(unsafe.Pointer(&values)), rows, size, offset)
+	writeValues128bitsAVX2(*(*unsafe.Pointer)(unsafe.Pointer(&values)), rows, size, offset)
 }
 
 func writeValuesFloat32(values []float32, rows array, size, offset uintptr) {
-	writeValues32bits(*(*unsafe.Pointer)(unsafe.Pointer(&values)), rows, size, offset)
+	writeValues32bitsAVX2(*(*unsafe.Pointer)(unsafe.Pointer(&values)), rows, size, offset)
 }
 
 func writeValuesFloat64(values []float64, rows array, size, offset uintptr) {
-	writeValues64bits(*(*unsafe.Pointer)(unsafe.Pointer(&values)), rows, size, offset)
+	writeValues64bitsAVX2(*(*unsafe.Pointer)(unsafe.Pointer(&values)), rows, size, offset)
 }
diff --git a/column_buffer_amd64.s b/column_buffer_amd64.s
index 671df5f..d848fa4 100644
--- a/column_buffer_amd64.s
+++ b/column_buffer_amd64.s
@@ -2,8 +2,58 @@
 
 #include "textflag.h"
 
-// func writeValuesBitpack(values unsafe.Pointer, rows array, size, offset uintptr)
-TEXT ·writeValuesBitpack(SB), NOSPLIT, $0-40
+// func broadcastRangeInt32AVX2(dst []int32, base int32)
+TEXT ·broadcastRangeInt32AVX2(SB), NOSPLIT, $0-32
+    MOVQ dst+0(FP), AX
+    MOVQ dst+8(FP), BX
+    MOVL base+24(FP), CX
+    XORQ SI, SI
+
+    CMPQ BX, $8
+    JB test1x4
+
+    VMOVDQU rangeInt32<>(SB), Y0         // [0,1,2,3,4,5,6,7]
+    VPBROADCASTD rangeInt32<>+32(SB), Y1 // [8,8,8,8,8,8,8,8]
+    VPBROADCASTD base+24(FP), Y2         // [base...]
+    VPADDD Y2, Y0, Y0                    // [base,base+1,...]
+
+    MOVQ BX, DI
+    SHRQ $3, DI
+    SHLQ $3, DI
+    JMP test8x4
+loop8x4:
+    VMOVDQU Y0, (AX)(SI*4)
+    VPADDD Y1, Y0, Y0
+    ADDQ $8, SI
+test8x4:
+    CMPQ SI, DI
+    JNE loop8x4
+    VZEROUPPER
+    JMP test1x4
+
+loop1x4:
+    INCQ SI
+    MOVL CX, DX
+    IMULL SI, DX
+    MOVL DX, -4(AX)(SI*4)
+test1x4:
+    CMPQ SI, BX
+    JNE loop1x4
+    RET
+
+GLOBL rangeInt32<>(SB), RODATA|NOPTR, $40
+DATA rangeInt32<>+0(SB)/4, $0
+DATA rangeInt32<>+4(SB)/4, $1
+DATA rangeInt32<>+8(SB)/4, $2
+DATA rangeInt32<>+12(SB)/4, $3
+DATA rangeInt32<>+16(SB)/4, $4
+DATA rangeInt32<>+20(SB)/4, $5
+DATA rangeInt32<>+24(SB)/4, $6
+DATA rangeInt32<>+28(SB)/4, $7
+DATA rangeInt32<>+32(SB)/4, $8
+
+// func writeValuesBitpackAVX2(values unsafe.Pointer, rows array, size, offset uintptr)
+TEXT ·writeValuesBitpackAVX2(SB), NOSPLIT, $0-40
     MOVQ values_base+0(FP), AX
     MOVQ rows_base+8(FP), BX
     MOVQ rows_len+16(FP), CX
@@ -18,9 +68,6 @@ init:
     SHRQ $3, CX
     XORQ SI, SI
 
-    CMPB ·hasAVX2(SB), $0
-    JE loop
-
     // Make sure `size - offset` is at least 4 bytes, otherwise VPGATHERDD
     // may read data beyond the end of the program memory and trigger a fault.
     //
@@ -98,8 +145,8 @@ loop:
     JNE loop
     RET
 
-// func writeValues32bits(values unsafe.Pointer, rows array, size, offset uintptr)
-TEXT ·writeValues32bits(SB), NOSPLIT, $0-40
+// func writeValues32bitsAVX2(values unsafe.Pointer, rows array, size, offset uintptr)
+TEXT ·writeValues32bitsAVX2(SB), NOSPLIT, $0-40
     MOVQ values_base+0(FP), AX
     MOVQ rows_base+8(FP), BX
     MOVQ rows_len+16(FP), CX
@@ -114,9 +161,6 @@ TEXT ·writeValues32bits(SB), NOSPLIT, $0-40
     CMPQ CX, $8
     JB loop1x4
 
-    CMPB ·hasAVX2(SB), $0
-    JE loop1x4
-
     MOVQ CX, DI
     SHRQ $3, DI
     SHLQ $3, DI
@@ -150,8 +194,8 @@ loop1x4:
 done:
     RET
 
-// func writeValues64bits(values unsafe.Pointer, rows array, size, offset uintptr)
-TEXT ·writeValues64bits(SB), NOSPLIT, $0-40
+// func writeValues64bitsAVX2(values unsafe.Pointer, rows array, size, offset uintptr)
+TEXT ·writeValues64bitsAVX2(SB), NOSPLIT, $0-40
     MOVQ values_base+0(FP), AX
     MOVQ rows_base+8(FP), BX
     MOVQ rows_len+16(FP), CX
@@ -166,9 +210,6 @@ TEXT ·writeValues64bits(SB), NOSPLIT, $0-40
     CMPQ CX, $4
     JB loop1x8
 
-    CMPB ·hasAVX2(SB), $0
-    JE loop1x8
-
     MOVQ CX, DI
     SHRQ $2, DI
     SHLQ $2, DI
@@ -201,8 +242,8 @@ loop1x8:
 done:
     RET
 
-// func writeValues128bits(values unsafe.Pointer, rows array, size, offset uintptr)
-TEXT ·writeValues128bits(SB), NOSPLIT, $0-40
+// func writeValues128bitsAVX2(values unsafe.Pointer, rows array, size, offset uintptr)
+TEXT ·writeValues128bitsAVX2(SB), NOSPLIT, $0-40
     MOVQ values_base+0(FP), AX
     MOVQ rows_base+8(FP), BX
     MOVQ rows_len+16(FP), CX
@@ -217,9 +258,6 @@ TEXT ·writeValues128bits(SB), NOSPLIT, $0-40
     CMPQ CX, $2
     JB loop1x16
 
-    CMPB ·hasAVX2(SB), $0
-    JE loop1x16
-
     MOVQ CX, DI
     SHRQ $1, DI
     SHLQ $1, DI
diff --git a/column_buffer_purego.go b/column_buffer_purego.go
index dd4303c..20c656c 100644
--- a/column_buffer_purego.go
+++ b/column_buffer_purego.go
@@ -2,71 +2,47 @@
 
 package parquet
 
-import "unsafe"
-
-func writeValuesBool(values []byte, rows array, size, offset uintptr) {
-	for i, j := 0, 0; i < rows.len; i += 8 {
-		b0 := *(*byte)(rows.index(i+0, size, offset))
-		b1 := *(*byte)(rows.index(i+1, size, offset))
-		b2 := *(*byte)(rows.index(i+2, size, offset))
-		b3 := *(*byte)(rows.index(i+3, size, offset))
-		b4 := *(*byte)(rows.index(i+4, size, offset))
-		b5 := *(*byte)(rows.index(i+5, size, offset))
-		b6 := *(*byte)(rows.index(i+6, size, offset))
-		b7 := *(*byte)(rows.index(i+7, size, offset))
+func broadcastValueInt32(dst []int32, src int8) {
+	value := 0x01010101 * int32(src)
+	for i := range dst {
+		dst[i] = value
+	}
+}
 
-		values[j] = (b0 & 1) |
-			((b1 & 1) << 1) |
-			((b2 & 1) << 2) |
-			((b3 & 1) << 3) |
-			((b4 & 1) << 4) |
-			((b5 & 1) << 5) |
-			((b6 & 1) << 6) |
-			((b7 & 1) << 7)
-		j++
+func broadcastRangeInt32(dst []int32, base int32) {
+	for i := range dst {
+		dst[i] = base + int32(i)
 	}
 }
 
+func writeValuesBool(values []byte, rows array, size, offset uintptr) {
+	panic("unreachable")
+}
+
 func writeValuesInt32(values []int32, rows array, size, offset uintptr) {
-	for i := range values {
-		values[i] = *(*int32)(rows.index(i, size, offset))
-	}
+	panic("unreachable")
 }
 
 func writeValuesInt64(values []int64, rows array, size, offset uintptr) {
-	for i := range values {
-		values[i] = *(*int64)(rows.index(i, size, offset))
-	}
+	panic("unreachable")
 }
 
 func writeValuesUint32(values []uint32, rows array, size, offset uintptr) {
-	for i := range values {
-		values[i] = *(*uint32)(rows.index(i, size, offset))
-	}
+	panic("unreachable")
 }
 
 func writeValuesUint64(values []uint64, rows array, size, offset uintptr) {
-	for i := range values {
-		values[i] = *(*uint64)(rows.index(i, size, offset))
-	}
+	panic("unreachable")
 }
 
 func writeValuesUint128(values []byte, rows array, size, offset uintptr) {
-	data := unsafe.Pointer(&values[0])
-	for i := 0; i < rows.len; i++ {
-		p := rows.index(i, size, offset)
-		*(*[16]byte)(unsafe.Add(data, i*16)) = *(*[16]byte)(p)
-	}
+	panic("unreachable")
 }
 
 func writeValuesFloat32(values []float32, rows array, size, offset uintptr) {
-	for i := range values {
-		values[i] = *(*float32)(rows.index(i, size, offset))
-	}
+	panic("unreachable")
 }
 
 func writeValuesFloat64(values []float64, rows array, size, offset uintptr) {
-	for i := range values {
-		values[i] = *(*float64)(rows.index(i, size, offset))
-	}
+	panic("unreachable")
 }
diff --git a/encoding/delta/binary_packed.go b/encoding/delta/binary_packed.go
index 8389b1a..f9b5f20 100644
--- a/encoding/delta/binary_packed.go
+++ b/encoding/delta/binary_packed.go
@@ -239,12 +239,9 @@ func blockBitWidthsInt64(bitWidths *[numMiniBlocks]byte, block *[blockSize]int64
 
 func resize(buf []byte, size int) []byte {
 	if cap(buf) < size {
-		newCap := 2 * cap(buf)
-		if newCap < size {
-			newCap = size
-		}
-		buf = append(make([]byte, 0, newCap), buf...)
-	} else if size > len(buf) {
+		return grow(buf, size)
+	}
+	if size > len(buf) {
 		clear := buf[len(buf):size]
 		for i := range clear {
 			clear[i] = 0
@@ -253,6 +250,16 @@ func resize(buf []byte, size int) []byte {
 	return buf[:size]
 }
 
+func grow(buf []byte, size int) []byte {
+	newCap := 2 * cap(buf)
+	if newCap < size {
+		newCap = size
+	}
+	newBuf := make([]byte, size, newCap)
+	copy(newBuf, buf)
+	return newBuf
+}
+
 type BinaryPackedEncoding struct {
 	encoding.NotSupported
 }
diff --git a/encoding/delta/binary_packed_amd64.s b/encoding/delta/binary_packed_amd64.s
index 00891df..830a2c5 100644
--- a/encoding/delta/binary_packed_amd64.s
+++ b/encoding/delta/binary_packed_amd64.s
@@ -372,11 +372,12 @@ TEXT ·miniBlockPackInt32x32bitsAVX2(SB), NOSPLIT, $0-16
 TEXT ·miniBlockPackInt32x3to16bitsAVX2(SB), NOSPLIT, $0-24
     MOVQ dst+0(FP), AX
     MOVQ src+8(FP), BX
+    MOVQ bitWidth+16(FP), CX
 
-    VPBROADCASTQ bitWidths+16(FP), Y6 // [1*bitWidth...]
-    VPSLLQ $1, Y6, Y7                 // [2*bitWidth...]
-    VPADDQ Y6, Y7, Y8                 // [3*bitWidth...]
-    VPSLLQ $2, Y6, Y9                 // [4*bitWidth...]
+    VPBROADCASTQ bitWidth+16(FP), Y6 // [1*bitWidth...]
+    VPSLLQ $1, Y6, Y7                // [2*bitWidth...]
+    VPADDQ Y6, Y7, Y8                // [3*bitWidth...]
+    VPSLLQ $2, Y6, Y9                // [4*bitWidth...]
 
     VPBROADCASTQ sixtyfour<>(SB), Y10
     VPSUBQ Y6, Y10, Y11 // [64-1*bitWidth...]
diff --git a/encoding/rle/rle.go b/encoding/rle/rle.go
index 1e65c09..a7e3363 100644
--- a/encoding/rle/rle.go
+++ b/encoding/rle/rle.go
@@ -6,6 +6,7 @@
 package rle
 
 import (
+	"bytes"
 	"encoding/binary"
 	"fmt"
 	"io"
@@ -96,7 +97,7 @@ func (e *Encoding) wrap(err error) error {
 }
 
 func encodeBits(dst, src []byte) ([]byte, error) {
-	if isZero(src) || isOnes(src) {
+	if len(src) == 0 || isZero(src) || isOnes(src) {
 		dst = appendUvarint(dst, uint64(8*len(src))<<1)
 		if len(src) > 0 {
 			dst = append(dst, src[0])
@@ -116,8 +117,7 @@ func encodeBits(dst, src []byte) ([]byte, error) {
 			}
 
 			if n := j - i; n > 1 {
-				dst = appendUvarint(dst, uint64(8*n)<<1)
-				dst = append(dst, src[i])
+				dst = appendRunLengthBits(dst, 8*n, src[i])
 				i = j
 				continue
 			}
@@ -130,14 +130,11 @@ func encodeBits(dst, src []byte) ([]byte, error) {
 			j++
 		}
 
-		n := j - i
-		if n > 1 && j < len(src) {
-			n--
+		if (j-i) > 1 && j < len(src) {
 			j--
 		}
 
-		dst = appendUvarint(dst, uint64(n)<<1|1)
-		dst = append(dst, src[i:j]...)
+		dst = appendBitPackedBits(dst, src[i:j])
 		i = j
 	}
 	return dst, nil
@@ -154,45 +151,27 @@ func encodeBytes(dst, src []byte, bitWidth uint) ([]byte, error) {
 		return appendUvarint(dst, uint64(len(src))<<1), nil
 	}
 
-	bitMask := uint64(1<<bitWidth) - 1
-	byteCount := bits.ByteCount(8 * bitWidth)
-
 	if len(src) >= 8 {
 		words := unsafe.Slice((*uint64)(unsafe.Pointer(&src[0])), len(src)/8)
 
 		for i := 0; i < len(words); {
 			j := i
-			pattern := broadcast8x8(words[i] & 0xFF)
+			pattern := broadcast8x1(words[i])
 
 			for j < len(words) && words[j] == pattern {
 				j++
 			}
 
 			if i < j {
-				dst = appendUvarint(dst, uint64(8*(j-i))<<1)
-				dst = append(dst, byte(pattern))
+				dst = appendRunLengthBytes(dst, 8*(j-i), byte(pattern))
 			} else {
 				j++
 
-				for j < len(words) && words[j] != broadcast8x8(words[j-1]) {
+				for j < len(words) && words[j] != broadcast8x1(words[j-1]) {
 					j++
 				}
 
-				dst = appendUvarint(dst, uint64(j-i)<<1|1)
-
-				for _, word := range words[i:j] {
-					word = (word & bitMask) |
-						(((word >> 8) & bitMask) << (1 * bitWidth)) |
-						(((word >> 16) & bitMask) << (2 * bitWidth)) |
-						(((word >> 24) & bitMask) << (3 * bitWidth)) |
-						(((word >> 32) & bitMask) << (4 * bitWidth)) |
-						(((word >> 40) & bitMask) << (5 * bitWidth)) |
-						(((word >> 48) & bitMask) << (6 * bitWidth)) |
-						(((word >> 56) & bitMask) << (7 * bitWidth))
-					bits := [8]byte{}
-					binary.LittleEndian.PutUint64(bits[:], word)
-					dst = append(dst, bits[:byteCount]...)
-				}
+				dst = appendBitPackedBytes(dst, words[i:j], bitWidth)
 			}
 
 			i = j
@@ -206,8 +185,7 @@ func encodeBytes(dst, src []byte, bitWidth uint) ([]byte, error) {
 			j++
 		}
 
-		dst = appendUvarint(dst, uint64(j-i)<<1)
-		dst = append(dst, byte(src[i]))
+		dst = appendRunLengthBytes(dst, j-i, src[i])
 		i = j
 	}
 
@@ -225,47 +203,23 @@ func encodeInt32(dst []byte, src []int32, bitWidth uint) ([]byte, error) {
 		return appendUvarint(dst, uint64(len(src))<<1), nil
 	}
 
-	bitMask := uint32(1<<bitWidth) - 1
-	byteCount := bits.ByteCount(8 * bitWidth)
-
 	if len(src) >= 8 {
 		words := unsafe.Slice((*[8]int32)(unsafe.Pointer(&src[0])), len(src)/8)
 
 		for i := 0; i < len(words); {
 			j := i
-			pattern := broadcast32x8(words[i][0])
+			pattern := broadcast8x4(words[i][0])
 
 			for j < len(words) && words[j] == pattern {
 				j++
 			}
 
 			if i < j {
-				dst = appendUvarint(dst, uint64(8*(j-i))<<1)
-				dst = appendInt32(dst, pattern[0], bitWidth)
+				dst = appendRunLengthInt32(dst, 8*(j-i), pattern[0], bitWidth)
 			} else {
-				j++
-
-				for j < len(words) && words[j] != broadcast32x8(words[j-1][0]) {
-					j++
-				}
-
-				dst = appendUvarint(dst, uint64(j-i)<<1|1)
-
-				for _, word := range words[i:j] {
-					bits := [9]uint32{}
-					bitOffset := uint(0)
-
-					for _, value := range word {
-						i := bitOffset / 32
-						j := bitOffset % 32
-						bits[i+0] |= (uint32(value) & bitMask) << j
-						bits[i+1] |= (uint32(value) >> (32 - j))
-						bitOffset += bitWidth
-					}
-
-					b := unsafe.Slice((*byte)(unsafe.Pointer(&bits[0])), byteCount)
-					dst = append(dst, b...)
-				}
+				j += 1
+				j += encodeInt32IndexEqual8Contiguous(words[j:])
+				dst = appendBitPackedInt32(dst, words[i:j], bitWidth)
 			}
 
 			i = j
@@ -279,8 +233,7 @@ func encodeInt32(dst []byte, src []int32, bitWidth uint) ([]byte, error) {
 			j++
 		}
 
-		dst = appendUvarint(dst, uint64(j-i)<<1)
-		dst = appendInt32(dst, src[i], bitWidth)
+		dst = appendRunLengthInt32(dst, j-i, src[i], bitWidth)
 		i = j
 	}
 
@@ -482,26 +435,68 @@ func appendUvarint(dst []byte, u uint64) []byte {
 	return append(dst, b[:n]...)
 }
 
-func appendInt32(dst []byte, v int32, bitWidth uint) []byte {
-	var b [4]byte
-	binary.LittleEndian.PutUint32(b[:], uint32(v))
-	return append(dst, b[:bits.ByteCount(bitWidth)]...)
+func appendRunLengthBits(dst []byte, count int, value byte) []byte {
+	return appendRunLengthBytes(dst, count, value)
+}
+
+func appendBitPackedBits(dst []byte, words []byte) []byte {
+	n := len(dst)
+	dst = resize(dst, n+binary.MaxVarintLen64+len(words))
+	n += binary.PutUvarint(dst[n:], uint64(len(words)<<1)|1)
+	n += copy(dst[n:], words)
+	return dst[:n]
+}
+
+func appendRunLengthBytes(dst []byte, count int, value byte) []byte {
+	n := len(dst)
+	dst = resize(dst, n+binary.MaxVarintLen64+1)
+	n += binary.PutUvarint(dst[n:], uint64(count)<<1)
+	dst[n] = value
+	return dst[:n+1]
+}
+
+func appendBitPackedBytes(dst []byte, words []uint64, bitWidth uint) []byte {
+	n := len(dst)
+	dst = resize(dst, n+binary.MaxVarintLen64+(len(words)*int(bitWidth))+8)
+	n += binary.PutUvarint(dst[n:], uint64(len(words)<<1)|1)
+	n += encodeBytesBitpack(dst[n:], words, bitWidth)
+	return dst[:n]
 }
 
-func broadcast8x8(v uint64) uint64 {
-	return v | v<<8 | v<<16 | v<<24 | v<<32 | v<<40 | v<<48 | v<<56
+func appendRunLengthInt32(dst []byte, count int, value int32, bitWidth uint) []byte {
+	n := len(dst)
+	dst = resize(dst, n+binary.MaxVarintLen64+4)
+	n += binary.PutUvarint(dst[n:], uint64(count)<<1)
+	binary.LittleEndian.PutUint32(dst[n:], uint32(value))
+	return dst[:n+int((bitWidth+7)/8)]
 }
 
-func broadcast32x8(v int32) [8]int32 {
+func appendBitPackedInt32(dst []byte, words [][8]int32, bitWidth uint) []byte {
+	n := len(dst)
+	dst = resize(dst, n+binary.MaxVarintLen64+(len(words)*int(bitWidth))+32)
+	n += binary.PutUvarint(dst[n:], uint64(len(words))<<1|1)
+	n += encodeInt32Bitpack(dst[n:], words, bitWidth)
+	return dst[:n]
+}
+
+func broadcast8x1(v uint64) uint64 {
+	return (v & 0xFF) * 0x0101010101010101
+}
+
+func broadcast8x4(v int32) [8]int32 {
 	return [8]int32{v, v, v, v, v, v, v, v}
 }
 
-func isOnes(data []byte) bool {
-	return bits.CountByte(data, 0xFF) == len(data)
+func count(data []byte, value byte) int {
+	return bytes.Count(data, []byte{value})
 }
 
 func isZero(data []byte) bool {
-	return bits.CountByte(data, 0) == len(data)
+	return count(data, 0x00) == len(data)
+}
+
+func isOnes(data []byte) bool {
+	return count(data, 0xFF) == len(data)
 }
 
 func isZeroInt8(data []int8) bool {
@@ -511,3 +506,44 @@ func isZeroInt8(data []int8) bool {
 func isZeroInt32(data []int32) bool {
 	return isZero(bits.Int32ToBytes(data))
 }
+
+func resize(buf []byte, size int) []byte {
+	if cap(buf) < size {
+		return grow(buf, size)
+	}
+	if size > len(buf) {
+		clear := buf[len(buf):size]
+		for i := range clear {
+			clear[i] = 0
+		}
+	}
+	return buf[:size]
+}
+
+func grow(buf []byte, size int) []byte {
+	newCap := 2 * cap(buf)
+	if newCap < size {
+		newCap = size
+	}
+	newBuf := make([]byte, size, newCap)
+	copy(newBuf, buf)
+	return newBuf
+}
+
+func encodeInt32BitpackDefault(dst []byte, src [][8]int32, bitWidth uint) int {
+	bits := unsafe.Slice((*uint32)(unsafe.Pointer(&dst[0])), len(dst)/4)
+	bitMask := uint32(1<<bitWidth) - 1
+	bitOffset := uint(0)
+
+	for k := range src {
+		for _, value := range src[k] {
+			i := bitOffset / 32
+			j := bitOffset % 32
+			bits[i+0] |= (uint32(value) & bitMask) << j
+			bits[i+1] |= (uint32(value) >> (32 - j))
+			bitOffset += bitWidth
+		}
+	}
+
+	return int(bitOffset / 8)
+}
diff --git a/encoding/rle/rle_amd64.go b/encoding/rle/rle_amd64.go
new file mode 100644
index 0000000..1c242f2
--- /dev/null
+++ b/encoding/rle/rle_amd64.go
@@ -0,0 +1,45 @@
+//go:build !purego
+
+package rle
+
+import (
+	"golang.org/x/sys/cpu"
+)
+
+var (
+	encodeInt32IndexEqual8Contiguous func(words [][8]int32) int
+	encodeInt32Bitpack               func(dst []byte, src [][8]int32, bitWidth uint) int
+)
+
+func init() {
+	switch {
+	case cpu.X86.HasAVX2:
+		encodeInt32IndexEqual8Contiguous = encodeInt32IndexEqual8ContiguousAVX2
+		encodeInt32Bitpack = encodeInt32BitpackAVX2
+	default:
+		encodeInt32IndexEqual8Contiguous = encodeInt32IndexEqual8ContiguousSSE
+	}
+}
+
+//go:noescape
+func encodeBytesBitpack(dst []byte, src []uint64, bitWidth uint) int
+
+//go:noescape
+func encodeInt32IndexEqual8ContiguousAVX2(words [][8]int32) int
+
+//go:noescape
+func encodeInt32IndexEqual8ContiguousSSE(words [][8]int32) int
+
+//go:noescape
+func encodeInt32Bitpack1to16bitsAVX2(dst []byte, src [][8]int32, bitWidth uint) int
+
+func encodeInt32BitpackAVX2(dst []byte, src [][8]int32, bitWidth uint) int {
+	switch {
+	case bitWidth == 0:
+		return 0
+	case bitWidth <= 16:
+		return encodeInt32Bitpack1to16bitsAVX2(dst, src, bitWidth)
+	default:
+		return encodeInt32BitpackDefault(dst, src, bitWidth)
+	}
+}
diff --git a/encoding/rle/rle_amd64.s b/encoding/rle/rle_amd64.s
new file mode 100644
index 0000000..69a10a0
--- /dev/null
+++ b/encoding/rle/rle_amd64.s
@@ -0,0 +1,152 @@
+//go:build !purego
+
+#include "textflag.h"
+
+GLOBL bitMasks<>(SB), RODATA|NOPTR, $64
+DATA bitMasks<>+0(SB)/8, $0b0000000100000001000000010000000100000001000000010000000100000001
+DATA bitMasks<>+8(SB)/8, $0b0000001100000011000000110000001100000011000000110000001100000011
+DATA bitMasks<>+16(SB)/8, $0b0000011100000111000001110000011100000111000001110000011100000111
+DATA bitMasks<>+24(SB)/8, $0b0000111100001111000011110000111100001111000011110000111100001111
+DATA bitMasks<>+32(SB)/8, $0b0001111100011111000111110001111100011111000111110001111100011111
+DATA bitMasks<>+40(SB)/8, $0b0011111100111111001111110011111100111111001111110011111100111111
+DATA bitMasks<>+48(SB)/8, $0b0111111101111111011111110111111101111111011111110111111101111111
+DATA bitMasks<>+56(SB)/8, $0b1111111111111111111111111111111111111111111111111111111111111111
+
+// func encodeBytesBitpack(dst []byte, src []uint64, bitWidth uint) int
+TEXT ·encodeBytesBitpack(SB), NOSPLIT, $0-64
+    MOVQ dst+0(FP), AX
+    MOVQ src+24(FP), BX
+    MOVQ src+32(FP), CX
+    MOVQ bitWidth+48(FP), DX
+    LEAQ bitMasks<>(SB), DI
+    MOVQ -8(DI)(DX*8), DI
+    XORQ SI, SI
+    JMP test
+loop:
+    MOVQ (BX)(SI*8), R8
+    PEXTQ DI, R8, R8
+    MOVQ R8, (AX)
+    ADDQ DX, AX
+    INCQ SI
+test:
+    CMPQ SI, CX
+    JNE loop
+done:
+    SUBQ dst+0(FP), AX
+    MOVQ AX, ret+56(FP)
+    RET
+
+// func encodeInt32IndexEqual8ContiguousAVX2(words [][8]int32) int
+TEXT ·encodeInt32IndexEqual8ContiguousAVX2(SB), NOSPLIT, $0-32
+    MOVQ words+0(FP), AX
+    MOVQ words+8(FP), BX
+    XORQ SI, SI
+    SHLQ $5, BX
+    JMP test
+loop:
+    VMOVDQU (AX)(SI*1), Y0
+    VPSHUFD $0, Y0, Y1
+    VPCMPEQD Y1, Y0, Y0
+    VMOVMSKPS Y0, CX
+    CMPL CX, $0xFF
+    JE done
+    ADDQ $32, SI
+test:
+    CMPQ SI, BX
+    JNE loop
+done:
+    VZEROUPPER
+    SHRQ $5, SI
+    MOVQ SI, ret+24(FP)
+    RET
+
+// func encodeInt32IndexEqual8ContiguousSSE(words [][8]int32) int
+TEXT ·encodeInt32IndexEqual8ContiguousSSE(SB), NOSPLIT, $0-32
+    MOVQ words+0(FP), AX
+    MOVQ words+8(FP), BX
+    XORQ SI, SI
+    SHLQ $5, BX
+    JMP test
+loop:
+    MOVOU (AX)(SI*1), X0
+    MOVOU 16(AX)(SI*1), X1
+    PSHUFD $0, X0, X2
+    PCMPEQL X2, X0
+    PCMPEQL X2, X1
+    MOVMSKPS X0, CX
+    MOVMSKPS X1, DX
+    ANDL DX, CX
+    CMPL CX, $0xF
+    JE done
+    ADDQ $32, SI
+test:
+    CMPQ SI, BX
+    JNE loop
+done:
+    SHRQ $5, SI
+    MOVQ SI, ret+24(FP)
+    RET
+
+// func encodeInt32Bitpack1to16bitsAVX2(dst []byte, src [][8]int32, bitWidth uint) int
+TEXT ·encodeInt32Bitpack1to16bitsAVX2(SB), NOSPLIT, $0-64
+    MOVQ dst+0(FP), AX
+    MOVQ src+24(FP), BX
+    MOVQ src+32(FP), CX
+    MOVQ bitWidth+48(FP), DX
+
+    MOVQ DX, X0
+    VPBROADCASTQ X0, Y6 // [1*bitWidth...]
+    VPSLLQ $1, Y6, Y7   // [2*bitWidth...]
+    VPADDQ Y6, Y7, Y8   // [3*bitWidth...]
+    VPSLLQ $2, Y6, Y9   // [4*bitWidth...]
+
+    MOVQ $64, DI
+    MOVQ DI, X1
+    VPBROADCASTQ X1, Y10
+    VPSUBQ Y6, Y10, Y11 // [64-1*bitWidth...]
+    VPSUBQ Y9, Y10, Y12 // [64-4*bitWidth...]
+    VPCMPEQQ Y4, Y4, Y4
+    VPSRLVQ Y11, Y4, Y4
+
+    VPXOR Y5, Y5, Y5
+    XORQ SI, SI
+    SHLQ $5, CX
+    JMP test
+loop:
+    VMOVDQU (BX)(SI*1), Y0
+    VPSHUFD $0b01010101, Y0, Y1
+    VPSHUFD $0b10101010, Y0, Y2
+    VPSHUFD $0b11111111, Y0, Y3
+
+    VPAND Y4, Y0, Y0
+    VPAND Y4, Y1, Y1
+    VPAND Y4, Y2, Y2
+    VPAND Y4, Y3, Y3
+
+    VPSLLVQ Y6, Y1, Y1
+    VPSLLVQ Y7, Y2, Y2
+    VPSLLVQ Y8, Y3, Y3
+
+    VPOR Y1, Y0, Y0
+    VPOR Y3, Y2, Y2
+    VPOR Y2, Y0, Y0
+
+    VPERMQ $0b00001010, Y0, Y1
+
+    VPSLLVQ X9, X1, X2
+    VPSRLQ X12, X1, X3
+    VBLENDPD $0b10, X3, X2, X1
+    VBLENDPD $0b10, X5, X0, X0
+    VPOR X1, X0, X0
+
+    VMOVDQU X0, (AX)
+
+    ADDQ DX, AX
+    ADDQ $32, SI
+test:
+    CMPQ SI, CX
+    JNE loop
+    VZEROUPPER
+    SUBQ dst+0(FP), AX
+    MOVQ AX, ret+56(FP)
+    RET
diff --git a/encoding/rle/rle_purego.go b/encoding/rle/rle_purego.go
new file mode 100644
index 0000000..0458686
--- /dev/null
+++ b/encoding/rle/rle_purego.go
@@ -0,0 +1,38 @@
+//go:build purego || !amd64
+
+package rle
+
+import (
+	"encoding/binary"
+)
+
+func encodeBytesBitpack(dst []byte, src []uint64, bitWidth uint) int {
+	bitMask := uint64(1<<bitWidth) - 1
+	n := 0
+
+	for _, word := range src {
+		word = (word & bitMask) |
+			(((word >> 8) & bitMask) << (1 * bitWidth)) |
+			(((word >> 16) & bitMask) << (2 * bitWidth)) |
+			(((word >> 24) & bitMask) << (3 * bitWidth)) |
+			(((word >> 32) & bitMask) << (4 * bitWidth)) |
+			(((word >> 40) & bitMask) << (5 * bitWidth)) |
+			(((word >> 48) & bitMask) << (6 * bitWidth)) |
+			(((word >> 56) & bitMask) << (7 * bitWidth))
+		binary.LittleEndian.PutUint64(dst[n:], word)
+		n += int(bitWidth)
+	}
+
+	return n
+}
+
+func encodeInt32IndexEqual8Contiguous(words [][8]int32) (n int) {
+	for n < len(words) && words[n] != broadcast8x4(words[n][0]) {
+		n++
+	}
+	return n
+}
+
+func encodeInt32Bitpack(dst []byte, src [][8]int32, bitWidth uint) int {
+	return encodeInt32BitpackDefault(dst, src, bitWidth)
+}
diff --git a/internal/bits/fill.go b/internal/bits/fill.go
deleted file mode 100644
index 110cb90..0000000
--- a/internal/bits/fill.go
+++ /dev/null
@@ -1,48 +0,0 @@
-package bits
-
-// Fill is an algorithm similar to the stdlib's bytes.Repeat, it writes repeated
-// copies of the source pattern to the destination, returning the number of
-// copies made.
-func Fill(dst []byte, dstWidth uint, src uint64, srcWidth uint) int {
-	n := BitCount(len(dst)) / dstWidth
-	k := srcWidth
-	if k > dstWidth {
-		k = dstWidth
-	}
-
-	if n >= 8 {
-		b := [64]byte{}
-		i := ByteCount(8 * dstWidth)
-		fillBits(b[:i], dstWidth, src, 8, k)
-		dst = dst[fillBytes(dst, b[:i]):]
-	}
-
-	if len(dst) > 0 {
-		for i := range dst {
-			dst[i] = 0
-		}
-		fillBits(dst, dstWidth, src, BitCount(len(dst))/dstWidth, k)
-	}
-
-	return int(n)
-}
-
-func fillBits(dst []byte, dstWidth uint, src uint64, n, k uint) {
-	for i := uint(0); i < n; i++ {
-		for j := uint(0); j < k; j++ {
-			index, shift := IndexShift8((i * dstWidth) + j)
-			dst[index] |= byte(((src >> j) & 1) << shift)
-		}
-	}
-}
-
-func fillBytes(b, v []byte) int {
-	n := copy(b, v)
-
-	for i := n; i < len(b); {
-		n += copy(b[i:], b[:i])
-		i *= 2
-	}
-
-	return n
-}
diff --git a/internal/bits/pack.go b/internal/bits/pack.go
deleted file mode 100644
index f56e9ee..0000000
--- a/internal/bits/pack.go
+++ /dev/null
@@ -1,46 +0,0 @@
-package bits
-
-// Pack copies words of size srcWidth (in bits) from the src buffer to words
-// of size dstWidth (in bits) in the dst buffer, returning the number of words
-// that were packed.
-//
-// When dstWidth is greater than srcWidth, the upper bits of the destination
-// word are set to zero.
-//
-// When srcWidth is greater than dstWith, the upper bits of the source are
-// discarded.
-//
-// The function always writes full bytes to dst, if the last word written does
-// not end on a byte boundary, the remaining bits are set to zero.
-//
-// The source and destination buffers must not overlap.
-func Pack(dst []byte, dstWidth uint, src []byte, srcWidth uint) int {
-	nSrc := BitCount(len(src)) / srcWidth
-	nDst := BitCount(len(dst)) / dstWidth
-
-	n := nSrc
-	if n > nDst {
-		n = nDst
-	}
-
-	k := srcWidth
-	if k > dstWidth {
-		k = dstWidth
-	}
-
-	dst = dst[:ByteCount(n*dstWidth)]
-	for i := range dst {
-		dst[i] = 0
-	}
-
-	for i := uint(0); i < n; i++ { // slow but correct
-		for j := uint(0); j < k; j++ {
-			srcIndex, srcShift := IndexShift8((i * srcWidth) + j)
-			dstIndex, dstShift := IndexShift8((i * dstWidth) + j)
-			bit := (src[srcIndex] >> srcShift) & 1
-			dst[dstIndex] |= bit << dstShift
-		}
-	}
-
-	return int(n)
-}
diff --git a/internal/bits/shift.go b/internal/bits/shift.go
deleted file mode 100644
index f33eb8a..0000000
--- a/internal/bits/shift.go
+++ /dev/null
@@ -1,13 +0,0 @@
-package bits
-
-func ShiftRight(data []byte, shift uint) {
-	if shift != 0 && len(data) != 0 {
-		mask := byte((1 << shift) - 1)
-
-		for i := 1; i < len(data); i++ {
-			data[i-1] = (data[i-1] >> shift) | ((data[i] & mask) << (8 - shift))
-		}
-
-		data[len(data)-1] >>= shift
-	}
-}
diff --git a/internal/bits/sub.go b/internal/bits/sub.go
deleted file mode 100644
index e6ceba3..0000000
--- a/internal/bits/sub.go
+++ /dev/null
@@ -1,5 +0,0 @@
-package bits
-
-func SubInt32(data []int32, value int32) { subInt32(data, value) }
-
-func SubInt64(data []int64, value int64) { subInt64(data, value) }
diff --git a/internal/bits/sub_amd64.go b/internal/bits/sub_amd64.go
deleted file mode 100644
index a6fc6b6..0000000
--- a/internal/bits/sub_amd64.go
+++ /dev/null
@@ -1,9 +0,0 @@
-//go:build !purego
-
-package bits
-
-//go:noescape
-func subInt32(data []int32, value int32)
-
-//go:noescape
-func subInt64(data []int64, value int64)
diff --git a/internal/bits/sub_amd64.s b/internal/bits/sub_amd64.s
deleted file mode 100644
index 259e8c6..0000000
--- a/internal/bits/sub_amd64.s
+++ /dev/null
@@ -1,81 +0,0 @@
-//go:build !purego
-
-#include "textflag.h"
-
-// func subInt32(data []int32, value int32)
-TEXT ·subInt32(SB), NOSPLIT, $-32
-    MOVQ data_base+0(FP), AX
-    MOVQ data_len+8(FP), CX
-    MOVL value+24(FP), BX
-    XORQ SI, SI
-
-    CMPQ CX, $0
-    JE done
-
-    CMPB ·hasAVX512(SB), $0
-    JE loop
-
-    CMPQ CX, $16
-    JB loop
-
-    MOVQ CX, DX
-    SHRQ $4, DX
-    SHLQ $4, DX
-
-    VPBROADCASTD BX, Z0
-loop16:
-    VMOVDQU32 (AX)(SI*4), Z1
-    VPSUBD Z0, Z1, Z1
-    VMOVDQU32 Z1, (AX)(SI*4)
-    ADDQ $16, SI
-    CMPQ SI, DX
-    JNE loop16
-
-    CMPQ SI, CX
-    JE done
-loop:
-    SUBL BX, (AX)(SI*4)
-    INCQ SI
-    CMPQ SI, CX
-    JNE loop
-done:
-    RET
-
-// func subInt64(data []int64, value int64)
-TEXT ·subInt64(SB), NOSPLIT, $-32
-    MOVQ data_base+0(FP), AX
-    MOVQ data_len+8(FP), CX
-    MOVQ value+24(FP), BX
-    XORQ SI, SI
-
-    CMPQ CX, $0
-    JE done
-
-    CMPB ·hasAVX512(SB), $0
-    JE loop
-
-    CMPQ CX, $8
-    JB loop
-
-    MOVQ CX, DX
-    SHRQ $3, DX
-    SHLQ $3, DX
-
-    VPBROADCASTQ BX, Z0
-loop8:
-    VMOVDQU64 (AX)(SI*8), Z1
-    VPSUBQ Z0, Z1, Z1
-    VMOVDQU64 Z1, (AX)(SI*8)
-    ADDQ $8, SI
-    CMPQ SI, DX
-    JNE loop8
-
-    CMPQ SI, CX
-    JE done
-loop:
-    SUBQ BX, (AX)(SI*8)
-    INCQ SI
-    CMPQ SI, CX
-    JNE loop
-done:
-    RET
diff --git a/internal/bits/sub_default.go b/internal/bits/sub_default.go
deleted file mode 100644
index 19f20db..0000000
--- a/internal/bits/sub_default.go
+++ /dev/null
@@ -1,15 +0,0 @@
-//go:build purego || !amd64
-
-package bits
-
-func subInt32(data []int32, value int32) {
-	for i := range data {
-		data[i] -= value
-	}
-}
-
-func subInt64(data []int64, value int64) {
-	for i := range data {
-		data[i] -= value
-	}
-}
diff --git a/level_amd64.go b/level_amd64.go
index 1ceec97..695e1c7 100644
--- a/level_amd64.go
+++ b/level_amd64.go
@@ -2,5 +2,17 @@
 
 package parquet
 
+import "golang.org/x/sys/cpu"
+
 //go:noescape
-func memset(dst []byte, src byte)
+func memsetAVX2(dst []byte, src byte)
+
+func memset(dst []byte, src byte) {
+	if len(dst) >= minLenAVX2 && cpu.X86.HasAVX2 {
+		memsetAVX2(dst, src)
+	} else {
+		for i := range dst {
+			dst[i] = src
+		}
+	}
+}
diff --git a/level_amd64.s b/level_amd64.s
index a1d62a4..4f071fd 100644
--- a/level_amd64.s
+++ b/level_amd64.s
@@ -2,8 +2,8 @@
 
 #include "textflag.h"
 
-// func memset(dst []byte, src byte)
-TEXT ·memset(SB), NOSPLIT, $0-32
+// func memsetAVX2(dst []byte, src byte)
+TEXT ·memsetAVX2(SB), NOSPLIT, $0-32
     MOVQ dst+0(FP), AX
     MOVQ dst+8(FP), BX
     MOVBQZX src+24(FP), CX
@@ -14,9 +14,6 @@ TEXT ·memset(SB), NOSPLIT, $0-32
     CMPQ BX, $64
     JB init8
 
-    CMPB ·hasAVX2(SB), $0
-    JE init8
-
     XORQ SI, SI
     MOVQ BX, DX
     SHRQ $6, DX
diff --git a/parquet_amd64.go b/parquet_amd64.go
index e36a2c1..df3f864 100644
--- a/parquet_amd64.go
+++ b/parquet_amd64.go
@@ -4,8 +4,18 @@ package parquet
 
 import "golang.org/x/sys/cpu"
 
+const (
+	// For very short inputs we are better off staying in Go code; since the
+	// functions get inlined the abstractions have zero overhead then.
+	minLenAVX2 = 8
+)
+
 var (
 	// This variable is used in x86 assembly source files to gate the use of
 	// AVX2 instructions depending on whether the CPU supports it.
 	hasAVX2 = cpu.X86.HasAVX2
 )
+
+func optimize(n int) bool {
+	return n >= minLenAVX2 && hasAVX2
+}
diff --git a/parquet_purego.go b/parquet_purego.go
new file mode 100644
index 0000000..d2a2b4e
--- /dev/null
+++ b/parquet_purego.go
@@ -0,0 +1,5 @@
+//go:build purego || !amd64
+
+package parquet
+
+func optimize(int) bool { return false }


Test patch:
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index 873dbb9..02abb92 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -19,10 +19,10 @@ jobs:
     runs-on: ${{ matrix.label }}
 
     steps:
-    - uses: actions/checkout@v2
+    - uses: actions/checkout@v3
 
     - name: Setup Go ${{ matrix.go }}
-      uses: actions/setup-go@v2
+      uses: actions/setup-go@v3
       with:
         go-version: ${{ matrix.go }}
 
@@ -36,12 +36,27 @@ jobs:
     runs-on: ubuntu-latest
 
     steps:
-    - uses: actions/checkout@v2
+    - uses: actions/checkout@v3
 
     - name: Setup Go ${{ matrix.go }}
-      uses: actions/setup-go@v2
+      uses: actions/setup-go@v3
       with:
-        go-version: '1.18.x'
+        go-version: 1.18.x
 
     - name: Validate formatting
       run: make format
+
+  # https://github.com/golangci/golangci-lint/issues/2649
+  # lint:
+  #   runs-on: ubuntu-latest
+  #   steps:
+  #     - uses: actions/checkout@v3
+
+  #     - uses: actions/setup-go@v3
+  #       with:
+  #         go-version: 1.18.x
+
+  #     - name: golangci-lint
+  #       uses: golangci/golangci-lint-action@v3
+  #       with:
+  #         version: latest
diff --git a/buffer_go18_test.go b/buffer_go18_test.go
index f5eef40..32a9110 100644
--- a/buffer_go18_test.go
+++ b/buffer_go18_test.go
@@ -37,6 +37,7 @@ func TestGenericBuffer(t *testing.T) {
 	testGenericBuffer[*contact](t)
 	testGenericBuffer[paddedBooleanColumn](t)
 	testGenericBuffer[optionalInt32Column](t)
+	testGenericBuffer[repeatedInt32Column](t)
 }
 
 func testGenericBuffer[Row any](t *testing.T) {
@@ -112,6 +113,7 @@ func BenchmarkGenericBuffer(b *testing.B) {
 	benchmarkGenericBuffer[contact](b)
 	benchmarkGenericBuffer[paddedBooleanColumn](b)
 	benchmarkGenericBuffer[optionalInt32Column](b)
+	benchmarkGenericBuffer[repeatedInt32Column](b)
 }
 
 func benchmarkGenericBuffer[Row generator[Row]](b *testing.B) {
diff --git a/column_buffer_test.go b/column_buffer_test.go
new file mode 100644
index 0000000..351369d
--- /dev/null
+++ b/column_buffer_test.go
@@ -0,0 +1,41 @@
+package parquet
+
+import "testing"
+
+func TestBroadcastValueInt32(t *testing.T) {
+	buf := make([]int32, 123)
+	broadcastValueInt32(buf, 0x0A)
+
+	for i, v := range buf {
+		if v != 0x0A0A0A0A {
+			t.Fatalf("wrong value at index %d: %v", i, v)
+		}
+	}
+}
+
+func TestBroadcastRangeInt32(t *testing.T) {
+	buf := make([]int32, 123)
+	broadcastRangeInt32(buf, 1)
+
+	for i, v := range buf {
+		if v != int32(1+i) {
+			t.Fatalf("wrong value at index %d: %v", i, v)
+		}
+	}
+}
+
+func BenchmarkBroadcastValueInt32(b *testing.B) {
+	buf := make([]int32, 1000)
+	for i := 0; i < b.N; i++ {
+		broadcastValueInt32(buf, -1)
+	}
+	b.SetBytes(4 * int64(len(buf)))
+}
+
+func BenchmarkBroadcastRangeInt32(b *testing.B) {
+	buf := make([]int32, 1000)
+	for i := 0; i < b.N; i++ {
+		broadcastRangeInt32(buf, 0)
+	}
+	b.SetBytes(4 * int64(len(buf)))
+}
diff --git a/encoding/rle/rle_amd64_test.go b/encoding/rle/rle_amd64_test.go
new file mode 100644
index 0000000..18082a2
--- /dev/null
+++ b/encoding/rle/rle_amd64_test.go
@@ -0,0 +1,22 @@
+//go:build go1.18 && !purego && amd64
+// +build go1.18,!purego,amd64
+
+package rle
+
+import "testing"
+
+func TestEncodeInt32IndexEqual8ContiguousAVX2(t *testing.T) {
+	testEncodeInt32IndexEqual8Contiguous(t, encodeInt32IndexEqual8ContiguousAVX2)
+}
+
+func TestEncodeInt32IndexEqual8ContiguousSSE(t *testing.T) {
+	testEncodeInt32IndexEqual8Contiguous(t, encodeInt32IndexEqual8ContiguousSSE)
+}
+
+func BenchmarkEncodeInt32IndexEqual8ContiguousAVX2(b *testing.B) {
+	benchmarkEncodeInt32IndexEqual8Contiguous(b, encodeInt32IndexEqual8ContiguousAVX2)
+}
+
+func BenchmarkEncodeInt32IndexEqual8ContiguousSSE(b *testing.B) {
+	benchmarkEncodeInt32IndexEqual8Contiguous(b, encodeInt32IndexEqual8ContiguousSSE)
+}
diff --git a/encoding/rle/rle_test.go b/encoding/rle/rle_test.go
index 50cfe54..3fa11a9 100644
--- a/encoding/rle/rle_test.go
+++ b/encoding/rle/rle_test.go
@@ -1,23 +1,64 @@
 //go:build go1.18
 // +build go1.18
 
-package rle_test
+package rle
 
 import (
 	"testing"
 
 	"github.com/segmentio/parquet-go/encoding/fuzz"
-	"github.com/segmentio/parquet-go/encoding/rle"
+	"github.com/segmentio/parquet-go/internal/quick"
 )
 
 func FuzzEncodeBoolean(f *testing.F) {
-	fuzz.EncodeBoolean(f, &rle.Encoding{BitWidth: 1})
+	fuzz.EncodeBoolean(f, &Encoding{BitWidth: 1})
 }
 
 func FuzzEncodeLevels(f *testing.F) {
-	fuzz.EncodeLevels(f, &rle.Encoding{BitWidth: 8})
+	fuzz.EncodeLevels(f, &Encoding{BitWidth: 8})
 }
 
 func FuzzEncodeInt32(f *testing.F) {
-	fuzz.EncodeInt32(f, &rle.Encoding{BitWidth: 32})
+	fuzz.EncodeInt32(f, &Encoding{BitWidth: 32})
+}
+
+func TestEncodeInt32IndexEqual8Contiguous(t *testing.T) {
+	testEncodeInt32IndexEqual8Contiguous(t, encodeInt32IndexEqual8Contiguous)
+}
+
+func testEncodeInt32IndexEqual8Contiguous(t *testing.T, f func([][8]int32) int) {
+	t.Helper()
+
+	err := quick.Check(func(words [][8]int32) bool {
+		want := 0
+
+		for want < len(words) && words[want] != broadcast8x4(words[want][0]) {
+			want++
+		}
+
+		if got := f(words); got != want {
+			t.Errorf("want=%d got=%d", want, got)
+			return false
+		}
+
+		return true
+	})
+	if err != nil {
+		t.Error(err)
+	}
+}
+
+func BenchmarkEncodeInt32IndexEqual8Contiguous(b *testing.B) {
+	benchmarkEncodeInt32IndexEqual8Contiguous(b, encodeInt32IndexEqual8Contiguous)
+}
+
+func benchmarkEncodeInt32IndexEqual8Contiguous(b *testing.B, f func([][8]int32) int) {
+	words := make([][8]int32, 1000)
+	for i := range words {
+		words[i][0] = 1
+	}
+	for i := 0; i < b.N; i++ {
+		_ = f(words)
+	}
+	b.SetBytes(32 * int64(len(words)))
 }
diff --git a/internal/bits/fill_test.go b/internal/bits/fill_test.go
deleted file mode 100644
index b1d5322..0000000
--- a/internal/bits/fill_test.go
+++ /dev/null
@@ -1,107 +0,0 @@
-package bits_test
-
-import (
-	"bytes"
-	"testing"
-
-	"github.com/segmentio/parquet-go/internal/bits"
-)
-
-func TestFill(t *testing.T) {
-	tests := [...]struct {
-		src      uint64
-		srcWidth uint
-		dstWidth uint
-		output   []byte
-	}{
-		{
-			src:      0b00000000,
-			srcWidth: 1,
-			dstWidth: 1,
-			output:   []byte{},
-		},
-
-		{
-			src:      0b00000000,
-			srcWidth: 1,
-			dstWidth: 1,
-			output:   []byte{0},
-		},
-
-		{
-			src:      0b00000001,
-			srcWidth: 1,
-			dstWidth: 1,
-			output:   []byte{0xFF, 0xFF, 0xFF, 0xFF},
-		},
-
-		{
-			src:      0b00000001,
-			srcWidth: 1,
-			dstWidth: 2,
-			output: []byte{
-				0b01010101,
-				0b01010101,
-				0b01010101,
-				0b01010101,
-			},
-		},
-
-		{
-			src:      0b00001111,
-			srcWidth: 5,
-			dstWidth: 8,
-			output: []byte{
-				0b00001111,
-				0b00001111,
-			},
-		},
-
-		{
-			src:      0b00000101,
-			srcWidth: 3,
-			dstWidth: 3,
-			output: []byte{
-				0b01101101, 0b11011011, 0b10110110,
-				0b01101101, 0b11011011, 0b10110110,
-			},
-		},
-
-		{
-			src:      0b00000101,
-			srcWidth: 3,
-			dstWidth: 8,
-			output: []byte{
-				0b00000101, 0b00000101, 0b00000101,
-				0b00000101, 0b00000101, 0b00000101,
-				0b00000101, 0b00000101, 0b00000101,
-				0b00000101, 0b00000101, 0b00000101,
-			},
-		},
-	}
-
-	for _, test := range tests {
-		t.Run("", func(t *testing.T) {
-			b := make([]byte, len(test.output))
-			n := bits.Fill(b, test.dstWidth, test.src, test.srcWidth)
-
-			if n != int(bits.BitCount(len(b))/test.dstWidth) {
-				t.Error("invalid length:", n)
-			}
-
-			if !bytes.Equal(b, test.output) {
-				t.Errorf("content mismatch:\n%08b\n%08b", test.output, b)
-			}
-		})
-	}
-}
-
-func BenchmarkFill(b *testing.B) {
-	buffer := make([]byte, 4096)
-
-	for i := 0; i < b.N; i++ {
-		bits.Fill(buffer, 8, 0x2a, 6)
-	}
-
-	b.SetBytes(int64(len(buffer)))
-}
diff --git a/internal/bits/pack_test.go b/internal/bits/pack_test.go
deleted file mode 100644
index da1f99e..0000000
--- a/internal/bits/pack_test.go
+++ /dev/null
@@ -1,222 +0,0 @@
-package bits_test
-
-import (
-	"bytes"
-	"testing"
-
-	"github.com/segmentio/parquet-go/internal/bits"
-)
-
-func TestPack(t *testing.T) {
-	tests := [...]struct {
-		scenario string
-		src      []byte
-		dst      []byte
-		packed   int
-		srcWidth uint
-		dstWidth uint
-	}{
-		{
-			scenario: "zero bits",
-			src:      nil,
-			dst:      nil,
-			packed:   0,
-			dstWidth: 4,
-			srcWidth: 1,
-		},
-
-		{
-			scenario: "1 bit words into 1 bit words",
-			src: []byte{
-				0b01010101, 0b10101010, 0b00001111, 0b11110000,
-			},
-			dst: []byte{
-				0b01010101, 0b10101010, 0b00001111, 0b11110000,
-			},
-			packed:   32,
-			dstWidth: 1,
-			srcWidth: 1,
-		},
-
-		{
-			scenario: "2 bits words into 2 bit words",
-			src: []byte{
-				0b01010101, 0b10101010, 0b00001111, 0b11110000,
-			},
-			dst: []byte{
-				0b01010101, 0b10101010, 0b00001111, 0b11110000,
-			},
-			packed:   16,
-			dstWidth: 2,
-			srcWidth: 2,
-		},
-
-		{
-			scenario: "4 bits words into 4 bit words",
-			src: []byte{
-				0b01010101, 0b10101010, 0b00001111, 0b11110000,
-			},
-			dst: []byte{
-				0b01010101, 0b10101010, 0b00001111, 0b11110000,
-			},
-			packed:   8,
-			dstWidth: 4,
-			srcWidth: 4,
-		},
-
-		{
-			scenario: "8 bits words into 8 bit words",
-			src: []byte{
-				0b01010101, 0b10101010, 0b00001111, 0b11110000,
-				0b00000000, 0b11111111, 0b11110000, 0b00001111,
-			},
-			dst: []byte{
-				0b01010101, 0b10101010, 0b00001111, 0b11110000,
-				0b00000000, 0b11111111, 0b11110000, 0b00001111,
-			},
-			packed:   8,
-			dstWidth: 8,
-			srcWidth: 8,
-		},
-
-		{
-			scenario: "3 bits words into 8 bits words",
-			src: []byte{
-				0b01010101, 0b00101010,
-			},
-			dst: []byte{
-				0b00000101, 0b00000010, 0b00000001, 0b00000101,
-				0b00000010,
-			},
-			packed:   5,
-			dstWidth: 8,
-			srcWidth: 3,
-		},
-
-		{
-			scenario: "5 bits words into 16 bits words",
-			src: []byte{
-				0b00011111, 0b11110101,
-			},
-			dst: []byte{
-				0b00011111, 0b00000000,
-				0b00001000, 0b00000000,
-				0b00011101, 0b00000000,
-			},
-			packed:   3,
-			dstWidth: 16,
-			srcWidth: 5,
-		},
-
-		{
-			scenario: "2 bits words into 32 bits words",
-			src: []byte{
-				0b10101010, 0b11110101,
-			},
-			dst: []byte{
-				0b00000010, 0b00000000, 0b00000000, 0b00000000,
-				0b00000010, 0b00000000, 0b00000000, 0b00000000,
-				0b00000010, 0b00000000, 0b00000000, 0b00000000,
-				0b00000010, 0b00000000, 0b00000000, 0b00000000,
-
-				0b00000001, 0b00000000, 0b00000000, 0b00000000,
-				0b00000001, 0b00000000, 0b00000000, 0b00000000,
-				0b00000011, 0b00000000, 0b00000000, 0b00000000,
-				0b00000011, 0b00000000, 0b00000000, 0b00000000,
-			},
-			packed:   8,
-			dstWidth: 32,
-			srcWidth: 2,
-		},
-	}
-
-	for _, test := range tests {
-		t.Run(test.scenario, func(t *testing.T) {
-			buffer := make([]byte, len(test.dst))
-			packed := bits.Pack(buffer, test.dstWidth, test.src, test.srcWidth)
-
-			if packed != test.packed {
-				t.Errorf("wrong number of words packed: want=%d got=%d", test.packed, packed)
-			}
-
-			if !bytes.Equal(buffer, test.dst) {
-				t.Errorf("contents mismatch\nwant: %08b\ngot:  %08b", test.dst, buffer)
-			} else {
-				// Allocate the inverse buffer to the full buffer size to verify
-				// that no extra bits get written at the end.
-				inverse := make([]byte, len(buffer))
-				inverted := bits.Pack(inverse, test.srcWidth, buffer, test.dstWidth)
-
-				if inverted != test.packed {
-					t.Errorf("wrong number of words inverted: want=%d got=%d", test.packed, inverted)
-				}
-
-				length := bits.ByteCount(uint(inverted) * test.srcWidth)
-				inverse = inverse[:length]
-
-				if (test.dstWidth % test.srcWidth) != 0 {
-					// The source width is not a multiple of the destination
-					// width, we copy the upper bits of the last byte of the
-					// source to ensure the comparison does not fail due to
-					// bytes that were not copied from the original input.
-					mask := (1 << (test.srcWidth % 8)) - 1
-					last := len(inverse) - 1
-					inverse[last] |= test.src[last] & ^byte(mask)
-				}
-
-				if !bytes.Equal(inverse, test.src) {
-					t.Errorf("contents mismatch\nwant: %08b\ngot:  %08b", test.src, inverse)
-				}
-			}
-		})
-	}
-}
-
-func BenchmarkPack(b *testing.B) {
-	src := []byte{
-		// 0:8
-		0b00000001, 0b00000010, 0b00000100, 0b00001000,
-		0b00010000, 0b00100000, 0b01000000, 0b10000000,
-
-		// 8:16
-		0b00000001, 0b00000010, 0b00000100, 0b00001000,
-		0b00010000, 0b00100000, 0b01000000, 0b10000000,
-
-		// 16:24
-		0b00000001, 0b00000010, 0b00000100, 0b00001000,
-		0b00010000, 0b00100000, 0b01000000, 0b10000000,
-
-		// 24:32
-		0b00000001, 0b00000010, 0b00000100, 0b00001000,
-		0b00010000, 0b00100000, 0b01000000, 0b10000000,
-
-		// 32:40
-		0b00000001, 0b00000010, 0b00000100, 0b00001000,
-		0b00010000, 0b00100000, 0b01000000, 0b10000000,
-
-		// 40:48
-		0b00000001, 0b00000010, 0b00000100, 0b00001000,
-		0b00010000, 0b00100000, 0b01000000, 0b10000000,
-
-		// 48:56
-		0b00000001, 0b00000010, 0b00000100, 0b00001000,
-		0b00010000, 0b00100000, 0b01000000, 0b10000000,
-
-		// 56:64
-		0b00000001, 0b00000010, 0b00000100, 0b00001000,
-		0b00010000, 0b00100000, 0b01000000, 0b10000000,
-	}
-
-	const extraSpace = 10
-	dst := make([]byte, 2*len(src)+extraSpace)
-
-	for i := 0; i < b.N; i++ {
-		n := bits.Pack(dst, 8, src, 4)
-
-		if n != 2*len(src) {
-			b.Errorf("wrong number of words packed: want=%d got=%d", 2*len(src), n)
-		}
-	}
-
-	b.SetBytes(int64(len(dst)))
-}
diff --git a/internal/bits/shift_test.go b/internal/bits/shift_test.go
deleted file mode 100644
index d0ddcbc..0000000
--- a/internal/bits/shift_test.go
+++ /dev/null
@@ -1,70 +0,0 @@
-package bits_test
-
-import (
-	"bytes"
-	"fmt"
-	"testing"
-
-	"github.com/segmentio/parquet-go/internal/bits"
-)
-
-func TestShiftRight(t *testing.T) {
-	tests := []struct {
-		shift  uint
-		input  []byte
-		output []byte
-	}{
-		{
-			shift:  0,
-			input:  []byte{0b00001111, 0b00001111, 0b00001111, 0b00001111},
-			output: []byte{0b00001111, 0b00001111, 0b00001111, 0b00001111},
-		},
-
-		{
-			shift:  1,
-			input:  []byte{0b00001111, 0b00001111, 0b00001111, 0b00001111},
-			output: []byte{0b10000111, 0b10000111, 0b10000111, 0b00000111},
-		},
-
-		{
-			shift:  2,
-			input:  []byte{0b00001111, 0b00001111, 0b00001111, 0b00001111},
-			output: []byte{0b11000011, 0b11000011, 0b11000011, 0b00000011},
-		},
-
-		{
-			shift:  3,
-			input:  []byte{0b00001111, 0b00001111, 0b00001111, 0b00001111},
-			output: []byte{0b11100001, 0b11100001, 0b11100001, 0b00000001},
-		},
-
-		{
-			shift:  4,
-			input:  []byte{0b00001111, 0b00001111, 0b00001111, 0b00001111},
-			output: []byte{0b11110000, 0b11110000, 0b11110000, 0b00000000},
-		},
-	}
-
-	buffer := []byte{}
-
-	for _, test := range tests {
-		t.Run(fmt.Sprintf("shift=%d", test.shift), func(t *testing.T) {
-			buffer = append(buffer[:0], test.input...)
-			bits.ShiftRight(buffer, test.shift)
-
-			if !bytes.Equal(buffer, test.output) {
-				t.Errorf("output mismatch:\n%08b\n%08b", test.output, buffer)
-			}
-		})
-	}
-}
-
-func BenchmarkShiftRight(b *testing.B) {
-	buffer := make([]byte, 4096)
-
-	for i := 0; i < b.N; i++ {
-		bits.ShiftRight(buffer, uint(i)%8)
-	}
-
-	b.SetBytes(int64(len(buffer)))
-}
diff --git a/internal/bits/sub_test.go b/internal/bits/sub_test.go
deleted file mode 100644
index caa71a1..0000000
--- a/internal/bits/sub_test.go
+++ /dev/null
@@ -1,75 +0,0 @@
-package bits_test
-
-import (
-	"math"
-	"testing"
-
-	"github.com/segmentio/parquet-go/internal/bits"
-	"github.com/segmentio/parquet-go/internal/quick"
-)
-
-func TestSubInt32(t *testing.T) {
-	err := quick.Check(func(values []int32) bool {
-		const sub = math.MaxInt32
-
-		valuesAndSub := make([]int32, len(values))
-		copy(valuesAndSub, values)
-		bits.SubInt32(valuesAndSub, sub)
-
-		for i := range values {
-			x := valuesAndSub[i]
-			y := values[i] - sub
-			if x != y {
-				t.Errorf("unexpected value at index %d: got=%d want=%d", i, x, y)
-				return false
-			}
-		}
-
-		return true
-	})
-	if err != nil {
-		t.Error(err)
-	}
-}
-
-func TestSubInt64(t *testing.T) {
-	err := quick.Check(func(values []int64) bool {
-		const sub = math.MaxInt64
-
-		valuesAndSub := make([]int64, len(values))
-		copy(valuesAndSub, values)
-		bits.SubInt64(valuesAndSub, sub)
-
-		for i := range values {
-			x := valuesAndSub[i]
-			y := values[i] - sub
-			if x != y {
-				t.Errorf("unexpected value at index %d: got=%d want=%d", i, x, y)
-				return false
-			}
-		}
-
-		return true
-	})
-	if err != nil {
-		t.Error(err)
-	}
-}
-
-func BenchmarkSubInt32(b *testing.B) {
-	forEachBenchmarkBufferSize(b, func(b *testing.B, bufferSize int) {
-		values := make([]int32, bufferSize/4)
-		for i := 0; i < b.N; i++ {
-			bits.SubInt32(values, 1)
-		}
-	})
-}
-
-func BenchmarkSubInt64(b *testing.B) {
-	forEachBenchmarkBufferSize(b, func(b *testing.B, bufferSize int) {
-		values := make([]int64, bufferSize/8)
-		for i := 0; i < b.N; i++ {
-			bits.SubInt64(values, 1)
-		}
-	})
-}
diff --git a/parquet_test.go b/parquet_test.go
index 4b1a7aa..5ffd6b9 100644
--- a/parquet_test.go
+++ b/parquet_test.go
@@ -183,6 +183,18 @@ func (row optionalInt32Column) generate(prng *rand.Rand) optionalInt32Column {
 	return optionalInt32Column{Value: prng.Int31n(100)}
 }
 
+type repeatedInt32Column struct {
+	Values []int32
+}
+
+func (row repeatedInt32Column) generate(prng *rand.Rand) repeatedInt32Column {
+	row.Values = make([]int32, prng.Intn(10))
+	for i := range row.Values {
+		row.Values[i] = prng.Int31n(10)
+	}
+	return row
+}
+
 type listColumn2 struct {
 	Value utf8string `parquet:",optional"`
 }
diff --git a/reader_go18_test.go b/reader_go18_test.go
index cf5f07a..64c3bc3 100644
--- a/reader_go18_test.go
+++ b/reader_go18_test.go
@@ -38,6 +38,7 @@ func TestGenericReader(t *testing.T) {
 	testGenericReader[*contact](t)
 	testGenericReader[paddedBooleanColumn](t)
 	testGenericReader[optionalInt32Column](t)
+	testGenericReader[repeatedInt32Column](t)
 }
 
 func testGenericReader[Row any](t *testing.T) {
diff --git a/writer_go18_test.go b/writer_go18_test.go
index d7b9208..851d6fd 100644
--- a/writer_go18_test.go
+++ b/writer_go18_test.go
@@ -28,6 +28,7 @@ func BenchmarkGenericWriter(b *testing.B) {
 	benchmarkGenericWriter[contact](b)
 	benchmarkGenericWriter[paddedBooleanColumn](b)
 	benchmarkGenericWriter[optionalInt32Column](b)
+	benchmarkGenericWriter[repeatedInt32Column](b)
 }
 
 func benchmarkGenericWriter[Row generator[Row]](b *testing.B) {