Repository: segmentio/parquet-go
Pull request #200 (2022-05-31 17:30:03)

Optimize binary packed encoding
This PR optimizes the encoding of values with DELTA_BINARY_PACKED.

These optimizations should have a wide effect since the encoding is used for DELTA_BYTE_ARRAY and DELTA_LENGTH_BYTE_ARRAY, the latter being the default encoding for string value in parquet.

The optimization comes in two forms:
* improve the pure Go implementation by working on 32 or 64 bits values instead of setting individual bits, making the algorithm O(numValues) instead of O(numValues*bitWidth)
* when available, leverage SIMD instructions of AVX2 to increase throughput further

# Description

The parquet format claims that delta encoding of integer values is derived from [Decoding billions of integers per second through vectorization](https://arxiv.org/pdf/1209.2137v5.pdf), tho after careful reading of the paper and related implementation in https://github.com/lemire/FastPFor, it appeared that there were many differences between the two.

First, encoding of values was not investigated in depth. The paper glanced over it and the FastPFor code had a trivial implementation. The paper focused on using SEE instructions, which left a lot of opportunities on the table to leverage AVX2 (e.g. using variable bit shifts of vector registers via VPSLLVD) and simplifies the algorithms (no need to write a custom version of for each bit width that values are getting packed into).

I ended having to reinvent an algorithm for generic vectorization of bit-packing, tho it applies only on bit width 0 to 16 (see `miniBlockPackInt32x3to16bits`). Since delta encoding is intended to encode values that are nearby in distance, I expect the current code to be effective in practice. Future research can be done to optimize delta encoding of larger deltas.

The parquet format differed in few other ways from the paper. For example, the paper uses vectorized prefix sum computation by performing deltas on groups of 4 integers, but the parquet format requires deltas to be computed between consecutive elements. As determined in the paper, after vectorizing the encoding of values, the next bottleneck was the computation of deltas, so I had to figure out an algorithm to optimize this process as well (see `blockDeltaInt32AVX2`).

The rest of the subroutines are optimizations that rely on the knowledge that blocks are of constant size. If we were to modify the block size in the future (or make it configurable), we could keep it a multiple of the current block size to be able to reuse the functions introduced in this PR.

Since there is a lot of added complexity here, I invested time in adding thorough testing, including to validate the behavior of subroutines of the algorithm.

# Benchmarks

I chose to invest time in optimizing DELTA_BINARY_PACKED because it showed up as the busiest code path and main bottleneck in many benchmarks, especially after the landing the optimizations from #199 

The following snapshots show the comparison of encoding throughput from the `main` branch:

> with `-tags=purego`
```
name                                             old time/op   new time/op    delta
Encode/DELTA_BINARY_PACKED/int32                   219µs ± 0%      69µs ± 0%   -68.42%  (p=0.000 n=10+9)
Encode/DELTA_BINARY_PACKED/int64                   229µs ±11%      70µs ± 0%   -69.38%  (p=0.000 n=10+9)
Encode/DELTA_LENGTH_BYTE_ARRAY/byte_array          352µs ± 0%     238µs ± 0%   -32.43%  (p=0.000 n=9+9)
Encode/DELTA_BYTE_ARRAY/byte_array                 690µs ± 0%     551µs ± 0%   -20.18%  (p=0.000 n=8+8)
Encode/DELTA_BYTE_ARRAY/fixed_length_byte_array    397µs ± 0%     359µs ± 0%    -9.58%  (p=0.000 n=10+9)

name                                             old speed     new speed      delta
Encode/DELTA_BINARY_PACKED/int32                 183MB/s ± 0%   578MB/s ± 0%  +216.68%  (p=0.000 n=10+9)
Encode/DELTA_BINARY_PACKED/int64                 350MB/s ±10%  1139MB/s ± 0%  +225.42%  (p=0.000 n=10+9)
Encode/DELTA_LENGTH_BYTE_ARRAY/byte_array        410MB/s ± 0%   607MB/s ± 0%   +48.00%  (p=0.000 n=9+9)
Encode/DELTA_BYTE_ARRAY/byte_array               210MB/s ± 0%   262MB/s ± 0%   +25.28%  (p=0.000 n=8+8)
Encode/DELTA_BYTE_ARRAY/fixed_length_byte_array  403MB/s ± 0%   445MB/s ± 0%   +10.59%  (p=0.000 n=10+9)

name                                             old value/s   new value/s    delta
Encode/DELTA_BINARY_PACKED/int32                   45.6M ± 0%    144.5M ± 0%  +216.68%  (p=0.000 n=10+9)
Encode/DELTA_BINARY_PACKED/int64                   43.8M ±10%    142.4M ± 0%  +225.42%  (p=0.000 n=10+9)
Encode/DELTA_LENGTH_BYTE_ARRAY/byte_array          28.4M ± 0%     42.0M ± 0%   +48.00%  (p=0.000 n=9+9)
Encode/DELTA_BYTE_ARRAY/byte_array                 14.5M ± 0%     18.2M ± 0%   +25.28%  (p=0.000 n=8+8)
Encode/DELTA_BYTE_ARRAY/fixed_length_byte_array    25.2M ± 0%     27.8M ± 0%   +10.58%  (p=0.000 n=10+9)
```

> with AVX2 optimized code paths
```
name                                             old time/op   new time/op    delta
Encode/DELTA_BINARY_PACKED/int32                   219µs ± 0%      10µs ± 0%    -95.57%  (p=0.000 n=10+8)
Encode/DELTA_BINARY_PACKED/int64                   229µs ±11%      29µs ± 0%    -87.57%  (p=0.000 n=10+10)
Encode/DELTA_LENGTH_BYTE_ARRAY/byte_array          352µs ± 0%     183µs ± 0%    -48.16%  (p=0.000 n=9+10)
Encode/DELTA_BYTE_ARRAY/byte_array                 690µs ± 0%     445µs ± 0%    -35.55%  (p=0.000 n=8+9)
Encode/DELTA_BYTE_ARRAY/fixed_length_byte_array    397µs ± 0%     277µs ± 0%    -30.21%  (p=0.000 n=10+8)

name                                             old speed     new speed      delta
Encode/DELTA_BINARY_PACKED/int32                 183MB/s ± 0%  4118MB/s ± 0%  +2155.74%  (p=0.000 n=10+8)
Encode/DELTA_BINARY_PACKED/int64                 350MB/s ±10%  2807MB/s ± 0%   +701.72%  (p=0.000 n=10+10)
Encode/DELTA_LENGTH_BYTE_ARRAY/byte_array        410MB/s ± 0%   792MB/s ± 0%    +92.90%  (p=0.000 n=9+10)
Encode/DELTA_BYTE_ARRAY/byte_array               210MB/s ± 0%   325MB/s ± 0%    +55.17%  (p=0.000 n=8+9)
Encode/DELTA_BYTE_ARRAY/fixed_length_byte_array  403MB/s ± 0%   577MB/s ± 0%    +43.29%  (p=0.000 n=10+8)

name                                             old value/s   new value/s    delta
Encode/DELTA_BINARY_PACKED/int32                   45.6M ± 0%   1029.5M ± 0%  +2155.76%  (p=0.000 n=10+8)
Encode/DELTA_BINARY_PACKED/int64                   43.8M ±10%    350.9M ± 0%   +701.71%  (p=0.000 n=10+10)
Encode/DELTA_LENGTH_BYTE_ARRAY/byte_array          28.4M ± 0%     54.7M ± 0%    +92.90%  (p=0.000 n=9+10)
Encode/DELTA_BYTE_ARRAY/byte_array                 14.5M ± 0%     22.5M ± 0%    +55.17%  (p=0.000 n=8+9)
Encode/DELTA_BYTE_ARRAY/fixed_length_byte_array    25.2M ± 0%     36.1M ± 0%    +43.29%  (p=0.000 n=10+8)
```

```
name                                          old time/op  new time/op  delta
GenericWriter/benchmarkRowType/go1.18         7.46µs ± 2%  7.37µs ± 1%    -1.23%  (p=0.007 n=10+10)
GenericWriter/booleanColumn/go1.18            1.24µs ± 4%  1.20µs ± 0%    -2.89%  (p=0.000 n=10+8)
GenericWriter/int32Column/go1.18              23.5µs ± 2%   2.4µs ± 0%   -89.78%  (p=0.000 n=8+9)
GenericWriter/int64Column/go1.18              2.26µs ± 0%  2.25µs ± 0%    -0.71%  (p=0.000 n=8+10)
GenericWriter/floatColumn/go1.18              2.01µs ± 0%  1.99µs ± 1%    -0.93%  (p=0.000 n=10+10)
GenericWriter/doubleColumn/go1.18             2.55µs ± 0%  2.56µs ± 1%      ~     (p=0.670 n=10+10)
GenericWriter/byteArrayColumn/go1.18          60.3µs ± 0%  46.1µs ± 1%   -23.48%  (p=0.000 n=9+10)
GenericWriter/fixedLenByteArrayColumn/go1.18  15.3µs ± 1%  15.3µs ± 1%      ~     (p=0.782 n=10+10)
GenericWriter/stringColumn/go1.18             36.6µs ± 1%  32.0µs ± 0%   -12.48%  (p=0.000 n=9+9)
GenericWriter/indexedStringColumn/go1.18       114µs ± 1%   113µs ± 1%    -0.57%  (p=0.028 n=9+10)
GenericWriter/uuidColumn/go1.18               44.3µs ± 0%  33.6µs ± 1%   -24.22%  (p=0.000 n=10+10)
GenericWriter/mapColumn/go1.18                 894µs ± 2%   891µs ± 2%      ~     (p=0.393 n=10+10)
GenericWriter/decimalColumn/go1.18            2.26µs ± 1%  2.24µs ± 0%    -0.51%  (p=0.017 n=10+10)
GenericWriter/contact/go1.18                  49.7µs ± 1%  49.6µs ± 1%      ~     (p=0.529 n=10+10)

name                                          old row/s    new row/s    delta
GenericWriter/benchmarkRowType/go1.18           134M ± 2%    136M ± 1%    +1.24%  (p=0.007 n=10+10)
GenericWriter/booleanColumn/go1.18              807M ± 4%    830M ± 0%    +2.89%  (p=0.000 n=10+9)
GenericWriter/int32Column/go1.18               42.6M ± 2%  416.4M ± 0%  +878.06%  (p=0.000 n=8+9)
GenericWriter/int64Column/go1.18                442M ± 0%    445M ± 0%    +0.73%  (p=0.000 n=8+10)
GenericWriter/floatColumn/go1.18                499M ± 0%    503M ± 1%    +0.94%  (p=0.000 n=10+10)
GenericWriter/doubleColumn/go1.18               392M ± 0%    391M ± 1%      ~     (p=0.739 n=10+10)
GenericWriter/byteArrayColumn/go1.18           16.6M ± 0%   21.7M ± 1%   +30.69%  (p=0.000 n=9+10)
GenericWriter/fixedLenByteArrayColumn/go1.18   65.4M ± 1%   65.3M ± 1%      ~     (p=0.796 n=10+10)
GenericWriter/stringColumn/go1.18              27.3M ± 1%   31.2M ± 0%   +14.25%  (p=0.000 n=9+9)
GenericWriter/indexedStringColumn/go1.18       8.77M ± 1%   8.82M ± 1%    +0.57%  (p=0.028 n=9+10)
GenericWriter/uuidColumn/go1.18                22.6M ± 0%   29.8M ± 1%   +31.96%  (p=0.000 n=10+10)
GenericWriter/mapColumn/go1.18                 1.12M ± 2%   1.12M ± 2%      ~     (p=0.393 n=10+10)
GenericWriter/decimalColumn/go1.18              443M ± 1%    446M ± 0%    +0.51%  (p=0.023 n=10+10)
GenericWriter/contact/go1.18                   20.1M ± 1%   20.2M ± 1%      ~     (p=0.529 n=10+10)
```

Please take a look and let me know if you have any questions.


Patch:
diff --git a/column_buffer.go b/column_buffer.go
index a5a3542..a4ff06c 100644
--- a/column_buffer.go
+++ b/column_buffer.go
@@ -792,16 +792,18 @@ func (col *booleanColumnBuffer) writeValues(rows array, size, offset uintptr) {
 		// First we attempt to write enough bits to align the number of values
 		// in the column buffer on 8 bytes. After this step the next bit should
 		// be written at the zero'th index of a byte of the buffer.
-		var b byte
-		for i < r {
-			v := *(*byte)(rows.index(i, size, offset))
-			b |= (v & 1) << uint(i)
-			i++
+		if r < 8 {
+			var b byte
+			for i < r {
+				v := *(*byte)(rows.index(i, size, offset))
+				b |= (v & 1) << uint(i)
+				i++
+			}
+			x := uint(col.numValues) / 8
+			y := uint(col.numValues) % 8
+			col.bits[x] |= (b << y) | (col.bits[x] & ^(0xFF << y))
+			col.numValues += int32(i)
 		}
-		x := uint(col.numValues) / 8
-		y := uint(col.numValues) % 8
-		col.bits[x] |= (b << y) | (col.bits[x] & ^(0xFF << y))
-		col.numValues += int32(i)
 
 		if n := rows.len - i; n >= 8 {
 			// At this stage, we know that that we have at least 8 bits to write
@@ -810,26 +812,13 @@ func (col *booleanColumnBuffer) writeValues(rows array, size, offset uintptr) {
 			// 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.
-			for j := i + (n/8)*8; i < j; 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))
-
-				col.bits[col.numValues/8] = (b0 & 1) |
-					((b1 & 1) << 1) |
-					((b2 & 1) << 2) |
-					((b3 & 1) << 3) |
-					((b4 & 1) << 4) |
-					((b5 & 1) << 5) |
-					((b6 & 1) << 6) |
-					((b7 & 1) << 7)
-				col.numValues += 8
+			section := array{
+				ptr: rows.index(i, size, 0),
+				len: ((rows.len - i) / 8) * 8,
 			}
+			writeValuesBool(col.bits[col.numValues/8:], section, size, offset)
+			col.numValues += int32(section.len)
+			i += section.len
 		}
 	}
 
@@ -939,14 +928,9 @@ func (col *int32ColumnBuffer) writeValues(rows array, size, offset uintptr) {
 	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]
-
-	values := col.values[n:]
-	for i := range values {
-		values[i] = *(*int32)(rows.index(i, size, offset))
-	}
+	writeValuesInt32(col.values[n:], rows, size, offset)
 }
 
 func (col *int32ColumnBuffer) ReadValuesAt(values []Value, offset int64) (n int, err error) {
@@ -1043,14 +1027,9 @@ func (col *int64ColumnBuffer) writeValues(rows array, size, offset uintptr) {
 	if n := len(col.values) + rows.len; n > cap(col.values) {
 		col.values = append(make([]int64, 0, max(n, 2*cap(col.values))), col.values...)
 	}
-
 	n := len(col.values)
 	col.values = col.values[:n+rows.len]
-
-	values := col.values[n:]
-	for i := range values {
-		values[i] = *(*int64)(rows.index(i, size, offset))
-	}
+	writeValuesInt64(col.values[n:], rows, size, offset)
 }
 
 func (col *int64ColumnBuffer) ReadValuesAt(values []Value, offset int64) (n int, err error) {
@@ -1241,14 +1220,9 @@ func (col *floatColumnBuffer) writeValues(rows array, size, offset uintptr) {
 	if n := len(col.values) + rows.len; n > cap(col.values) {
 		col.values = append(make([]float32, 0, max(n, 2*cap(col.values))), col.values...)
 	}
-
 	n := len(col.values)
 	col.values = col.values[:n+rows.len]
-
-	values := col.values[n:]
-	for i := range values {
-		values[i] = *(*float32)(rows.index(i, size, offset))
-	}
+	writeValuesFloat32(col.values[n:], rows, size, offset)
 }
 
 func (col *floatColumnBuffer) ReadValuesAt(values []Value, offset int64) (n int, err error) {
@@ -1345,14 +1319,9 @@ func (col *doubleColumnBuffer) writeValues(rows array, size, offset uintptr) {
 	if n := len(col.values) + rows.len; n > cap(col.values) {
 		col.values = append(make([]float64, 0, max(n, 2*cap(col.values))), col.values...)
 	}
-
 	n := len(col.values)
 	col.values = col.values[:n+rows.len]
-
-	values := col.values[n:]
-	for i := range values {
-		values[i] = *(*float64)(rows.index(i, size, offset))
-	}
+	writeValuesFloat64(col.values[n:], rows, size, offset)
 }
 
 func (col *doubleColumnBuffer) ReadValuesAt(values []Value, offset int64) (n int, err error) {
@@ -1627,21 +1596,15 @@ func (col *fixedLenByteArrayColumnBuffer) writeValues(rows array, size, offset u
 	}
 }
 
-func (col *fixedLenByteArrayColumnBuffer) writeValues128(rows array, size, offset uintptr) {
+func (col *fixedLenByteArrayColumnBuffer) writeValuesUint128(rows array, size, offset uintptr) {
 	c := cap(col.data)
 	n := len(col.data) + (16 * rows.len)
 	if c < n {
 		col.data = append(make([]byte, 0, max(n, 2*c)), col.data...)
 	}
-
 	firstIndex := len(col.data)
 	col.data = col.data[:len(col.data)+(16*rows.len)]
-
-	data := unsafe.Pointer(&col.data[firstIndex])
-	for i := 0; i < rows.len; i++ {
-		p := rows.index(i, size, offset)
-		*(*[16]byte)(unsafe.Add(data, i*16)) = *(*[16]byte)(p)
-	}
+	writeValuesUint128(col.data[firstIndex:], rows, size, offset)
 }
 
 func (col *fixedLenByteArrayColumnBuffer) ReadValuesAt(values []Value, offset int64) (n int, err error) {
@@ -1738,14 +1701,9 @@ func (col *uint32ColumnBuffer) writeValues(rows array, size, offset uintptr) {
 	if n := len(col.values) + rows.len; n > cap(col.values) {
 		col.values = append(make([]uint32, 0, max(n, 2*cap(col.values))), col.values...)
 	}
-
 	n := len(col.values)
 	col.values = col.values[:n+rows.len]
-
-	values := col.values[n:]
-	for i := range values {
-		values[i] = *(*uint32)(rows.index(i, size, offset))
-	}
+	writeValuesUint32(col.values[n:], rows, size, offset)
 }
 
 func (col *uint32ColumnBuffer) ReadValuesAt(values []Value, offset int64) (n int, err error) {
@@ -1842,14 +1800,9 @@ func (col *uint64ColumnBuffer) writeValues(rows array, size, offset uintptr) {
 	if n := len(col.values) + rows.len; n > cap(col.values) {
 		col.values = append(make([]uint64, 0, max(n, 2*cap(col.values))), col.values...)
 	}
-
 	n := len(col.values)
 	col.values = col.values[:n+rows.len]
-
-	values := col.values[n:]
-	for i := range values {
-		values[i] = *(*uint64)(rows.index(i, size, offset))
-	}
+	writeValuesUint64(col.values[n:], rows, 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
new file mode 100644
index 0000000..976791d
--- /dev/null
+++ b/column_buffer_amd64.go
@@ -0,0 +1,55 @@
+//go:build !purego
+
+package parquet
+
+import (
+	"unsafe"
+
+	"golang.org/x/sys/cpu"
+)
+
+var hasAVX2 = cpu.X86.HasAVX2
+
+//go:noescape
+func writeValuesBitpack(values unsafe.Pointer, rows array, size, offset uintptr)
+
+//go:noescape
+func writeValues32bits(values unsafe.Pointer, rows array, size, offset uintptr)
+
+//go:noescpae
+func writeValues64bits(values unsafe.Pointer, rows array, size, offset uintptr)
+
+//go:noescape
+func writeValues128bits(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)
+}
+
+func writeValuesInt32(values []int32, rows array, size, offset uintptr) {
+	writeValues32bits(*(*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)
+}
+
+func writeValuesUint32(values []uint32, rows array, size, offset uintptr) {
+	writeValues32bits(*(*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)
+}
+
+func writeValuesUint128(values []byte, rows array, size, offset uintptr) {
+	writeValues128bits(*(*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)
+}
+
+func writeValuesFloat64(values []float64, rows array, size, offset uintptr) {
+	writeValues64bits(*(*unsafe.Pointer)(unsafe.Pointer(&values)), rows, size, offset)
+}
diff --git a/column_buffer_amd64.s b/column_buffer_amd64.s
new file mode 100644
index 0000000..dbcdd98
--- /dev/null
+++ b/column_buffer_amd64.s
@@ -0,0 +1,268 @@
+//go:build !purego
+
+#include "textflag.h"
+
+// func writeValuesBitpack(values unsafe.Pointer, rows array, size, offset uintptr)
+TEXT ·writeValuesBitpack(SB), NOSPLIT, $0-40
+    MOVQ values_base+0(FP), AX
+    MOVQ rows_base+8(FP), BX
+    MOVQ rows_len+16(FP), CX
+    MOVQ size+24(FP), DX
+    MOVQ offset+32(FP), DI
+
+    CMPQ CX, $0
+    JNE init
+    RET
+init:
+    ADDQ DI, BX
+    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.
+    //
+    // If the boolean values do not have enough padding we must fallback to the
+    // scalar algorithm to be able to load single bytes from memory.
+    MOVQ DX, R8
+    SUBQ DI, R8
+    CMPQ R8, $4
+    JB loop
+
+    VPBROADCASTD size+24(FP), Y0
+    VPMULLD scale8x4<>(SB), Y0, Y0
+    VPCMPEQD Y1, Y1, Y1
+    VPCMPEQD Y2, Y2, Y2
+    VPCMPEQD Y3, Y3, Y3
+    VPSRLD $31, Y3, Y3
+avx2loop:
+    VPGATHERDD Y1, (BX)(Y0*1), Y4
+    VMOVDQU Y2, Y1
+    VPAND Y3, Y4, Y4
+    VPSLLD $31, Y4, Y4
+    VMOVMSKPS Y4, DI
+
+    MOVB DI, (AX)(SI*1)
+
+    LEAQ (BX)(DX*8), BX
+    INCQ SI
+    CMPQ SI, CX
+    JNE avx2loop
+    VZEROUPPER
+    RET
+loop:
+    LEAQ (BX)(DX*2), DI
+    MOVBQZX (BX), R8
+    MOVBQZX (BX)(DX*1), R9
+    MOVBQZX (DI), R10
+    MOVBQZX (DI)(DX*1), R11
+    LEAQ (BX)(DX*4), BX
+    LEAQ (DI)(DX*4), DI
+    MOVBQZX (BX), R12
+    MOVBQZX (BX)(DX*1), R13
+    MOVBQZX (DI), R14
+    MOVBQZX (DI)(DX*1), R15
+    LEAQ (BX)(DX*4), BX
+
+    ANDQ $1, R8
+    ANDQ $1, R9
+    ANDQ $1, R10
+    ANDQ $1, R11
+    ANDQ $1, R12
+    ANDQ $1, R13
+    ANDQ $1, R14
+    ANDQ $1, R15
+
+    SHLQ $1, R9
+    SHLQ $2, R10
+    SHLQ $3, R11
+    SHLQ $4, R12
+    SHLQ $5, R13
+    SHLQ $6, R14
+    SHLQ $7, R15
+
+    ORQ R9, R8
+    ORQ R11, R10
+    ORQ R13, R12
+    ORQ R15, R14
+    ORQ R10, R8
+    ORQ R12, R8
+    ORQ R14, R8
+
+    MOVB R8, (AX)(SI*1)
+
+    INCQ SI
+    CMPQ SI, CX
+    JNE loop
+    RET
+
+// func writeValues32bits(values unsafe.Pointer, rows array, size, offset uintptr)
+TEXT ·writeValues32bits(SB), NOSPLIT, $0-40
+    MOVQ values_base+0(FP), AX
+    MOVQ rows_base+8(FP), BX
+    MOVQ rows_len+16(FP), CX
+    MOVQ size+24(FP), DX
+
+    XORQ SI, SI
+    ADDQ offset+32(FP), BX
+
+    CMPQ CX, $0
+    JE done
+
+    CMPQ CX, $8
+    JB loop1x4
+
+    CMPB ·hasAVX2(SB), $0
+    JE loop1x4
+
+    MOVQ CX, DI
+    SHRQ $3, DI
+    SHLQ $3, DI
+
+    VPBROADCASTD size+24(FP), Y0
+    VPMULLD scale8x4<>(SB), Y0, Y0
+    VPCMPEQD Y1, Y1, Y1
+    VPCMPEQD Y2, Y2, Y2
+loop8x4:
+    VPGATHERDD Y1, (BX)(Y0*1), Y3
+    VMOVDQU Y3, (AX)(SI*4)
+    VMOVDQU Y2, Y1
+
+    LEAQ (BX)(DX*8), BX
+    ADDQ $8, SI
+    CMPQ SI, DI
+    JNE loop8x4
+    VZEROUPPER
+
+    CMPQ SI, CX
+    JE done
+
+loop1x4:
+    MOVL (BX), R8
+    MOVL R8, (AX)(SI*4)
+
+    ADDQ DX, BX
+    INCQ SI
+    CMPQ SI, CX
+    JNE loop1x4
+done:
+    RET
+
+// func writeValues64bits(values unsafe.Pointer, rows array, size, offset uintptr)
+TEXT ·writeValues64bits(SB), NOSPLIT, $0-40
+    MOVQ values_base+0(FP), AX
+    MOVQ rows_base+8(FP), BX
+    MOVQ rows_len+16(FP), CX
+    MOVQ size+24(FP), DX
+
+    XORQ SI, SI
+    ADDQ offset+32(FP), BX
+
+    CMPQ CX, $0
+    JE done
+
+    CMPQ CX, $4
+    JB loop1x8
+
+    CMPB ·hasAVX2(SB), $0
+    JE loop1x8
+
+    MOVQ CX, DI
+    SHRQ $2, DI
+    SHLQ $2, DI
+
+    VPBROADCASTQ size+24(FP), Y0
+    VPMULLD scale4x8<>(SB), Y0, Y0
+    VPCMPEQD Y1, Y1, Y1
+    VPCMPEQD Y2, Y2, Y2
+loop4x8:
+    VPGATHERQQ Y1, (BX)(Y0*1), Y3
+    VMOVDQU Y3, (AX)(SI*8)
+    VMOVDQU Y2, Y1
+
+    LEAQ (BX)(DX*4), BX
+    ADDQ $4, SI
+    CMPQ SI, DI
+    JNE loop4x8
+    VZEROUPPER
+
+    CMPQ SI, CX
+    JE done
+loop1x8:
+    MOVQ (BX), R8
+    MOVQ R8, (AX)(SI*8)
+
+    ADDQ DX, BX
+    INCQ SI
+    CMPQ SI, CX
+    JNE loop1x8
+done:
+    RET
+
+// func writeValues128bits(values unsafe.Pointer, rows array, size, offset uintptr)
+TEXT ·writeValues128bits(SB), NOSPLIT, $0-40
+    MOVQ values_base+0(FP), AX
+    MOVQ rows_base+8(FP), BX
+    MOVQ rows_len+16(FP), CX
+    MOVQ size+24(FP), DX
+
+    XORQ SI, SI
+    ADDQ offset+32(FP), BX
+
+    CMPQ CX, $0
+    JE done
+
+    CMPQ CX, $2
+    JB loop1x16
+
+    CMPB ·hasAVX2(SB), $0
+    JE loop1x16
+
+    MOVQ CX, DI
+    SHRQ $1, DI
+    SHLQ $1, DI
+loop2x16:
+    VMOVDQU (BX), X0
+    VMOVDQU (BX)(DX*1), X1
+
+    VMOVDQU X0, (AX)
+    VMOVDQU X1, 16(AX)
+
+    ADDQ $32, AX
+    LEAQ (BX)(DX*2), BX
+    ADDQ $2, SI
+    CMPQ SI, DI
+    JNE loop2x16
+    VZEROUPPER
+
+    CMPQ SI, CX
+    JE done
+loop1x16:
+    MOVOU (BX), X0
+    MOVOU X0, (AX)
+
+    ADDQ $16, AX
+    ADDQ DX, BX
+    INCQ SI
+    CMPQ SI, CX
+    JNE loop1x16
+done:
+    RET
+
+GLOBL scale8x4<>(SB), RODATA|NOPTR, $32
+DATA scale8x4<>+0(SB)/4,  $0
+DATA scale8x4<>+4(SB)/4,  $1
+DATA scale8x4<>+8(SB)/4,  $2
+DATA scale8x4<>+12(SB)/4, $3
+DATA scale8x4<>+16(SB)/4, $4
+DATA scale8x4<>+20(SB)/4, $5
+DATA scale8x4<>+24(SB)/4, $6
+DATA scale8x4<>+28(SB)/4, $7
+
+GLOBL scale4x8<>(SB), RODATA|NOPTR, $32
+DATA scale4x8<>+0(SB)/8,  $0
+DATA scale4x8<>+8(SB)/8,  $1
+DATA scale4x8<>+16(SB)/8, $2
+DATA scale4x8<>+24(SB)/8, $3
diff --git a/column_buffer_go18.go b/column_buffer_go18.go
index 6cbe9f8..10523a0 100644
--- a/column_buffer_go18.go
+++ b/column_buffer_go18.go
@@ -850,7 +850,7 @@ func (w *columnBufferWriter) writeRowsUUID(rows array, size, offset uintptr, lev
 
 	switch c := w.columns[levels.columnIndex].(type) {
 	case *fixedLenByteArrayColumnBuffer:
-		c.writeValues128(rows, size, offset)
+		c.writeValuesUint128(rows, size, offset)
 	default:
 		w.reset()
 		for i := 0; i < rows.len; i++ {
diff --git a/column_buffer_purego.go b/column_buffer_purego.go
new file mode 100644
index 0000000..dd4303c
--- /dev/null
+++ b/column_buffer_purego.go
@@ -0,0 +1,72 @@
+//go:build !amd64 || purego
+
+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))
+
+		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 writeValuesInt32(values []int32, rows array, size, offset uintptr) {
+	for i := range values {
+		values[i] = *(*int32)(rows.index(i, size, offset))
+	}
+}
+
+func writeValuesInt64(values []int64, rows array, size, offset uintptr) {
+	for i := range values {
+		values[i] = *(*int64)(rows.index(i, size, offset))
+	}
+}
+
+func writeValuesUint32(values []uint32, rows array, size, offset uintptr) {
+	for i := range values {
+		values[i] = *(*uint32)(rows.index(i, size, offset))
+	}
+}
+
+func writeValuesUint64(values []uint64, rows array, size, offset uintptr) {
+	for i := range values {
+		values[i] = *(*uint64)(rows.index(i, size, offset))
+	}
+}
+
+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)
+	}
+}
+
+func writeValuesFloat32(values []float32, rows array, size, offset uintptr) {
+	for i := range values {
+		values[i] = *(*float32)(rows.index(i, size, offset))
+	}
+}
+
+func writeValuesFloat64(values []float64, rows array, size, offset uintptr) {
+	for i := range values {
+		values[i] = *(*float64)(rows.index(i, size, offset))
+	}
+}
diff --git a/config.go b/config.go
index 9546dbd..079ec9a 100644
--- a/config.go
+++ b/config.go
@@ -136,7 +136,6 @@ type WriterConfig struct {
 	CreatedBy            string
 	ColumnPageBuffers    PageBufferPool
 	ColumnIndexSizeLimit int
-	PageBufferPool       PageBufferPool
 	PageBufferSize       int
 	WriteBufferSize      int
 	DataPageVersion      int
diff --git a/encoding/delta/binary_packed.go b/encoding/delta/binary_packed.go
index 6a7c7a2..8389b1a 100644
--- a/encoding/delta/binary_packed.go
+++ b/encoding/delta/binary_packed.go
@@ -9,6 +9,8 @@ import (
 	"github.com/segmentio/parquet-go/encoding"
 	"github.com/segmentio/parquet-go/format"
 	"github.com/segmentio/parquet-go/internal/bits"
+
+	. "math/bits"
 )
 
 const (
@@ -20,120 +22,261 @@ const (
 	//
 	// 65K+ values should be enough for any valid use case.
 	maxSupportedBlockSize = 65536
-)
 
-type BinaryPackedEncoding struct {
-	encoding.NotSupported
-}
+	maxHeaderLength32    = 4 * binary.MaxVarintLen64
+	maxMiniBlockLength32 = binary.MaxVarintLen64 + numMiniBlocks + (4 * blockSize)
 
-func (e *BinaryPackedEncoding) String() string {
-	return "DELTA_BINARY_PACKED"
-}
+	maxHeaderLength64    = 8 * binary.MaxVarintLen64
+	maxMiniBlockLength64 = binary.MaxVarintLen64 + numMiniBlocks + (8 * blockSize)
+)
 
-func (e *BinaryPackedEncoding) Encoding() format.Encoding {
-	return format.DeltaBinaryPacked
-}
+var (
+	encodeInt32 = encodeInt32Default
+	encodeInt64 = encodeInt64Default
+)
 
-func (e *BinaryPackedEncoding) EncodeInt32(dst, src []byte) ([]byte, error) {
-	if (len(src) % 4) != 0 {
-		return dst[:0], encoding.ErrEncodeInvalidInputSize(e, "INT64", len(src))
+func encodeInt32Default(dst []byte, src []int32) []byte {
+	totalValues := len(src)
+	firstValue := int32(0)
+	if totalValues > 0 {
+		firstValue = src[0]
 	}
-	return e.encodeInt32(dst[:0], bits.BytesToInt32(src))
-}
 
-func (e *BinaryPackedEncoding) EncodeInt64(dst, src []byte) ([]byte, error) {
-	if (len(src) % 8) != 0 {
-		return dst[:0], encoding.ErrEncodeInvalidInputSize(e, "INT64", len(src))
+	n := len(dst)
+	dst = resize(dst, n+maxHeaderLength32)
+	dst = dst[:n+encodeBinaryPackedHeader(dst[n:], blockSize, numMiniBlocks, totalValues, int64(firstValue))]
+
+	if totalValues < 2 {
+		return dst
 	}
-	return e.encodeInt64(dst[:0], bits.BytesToInt64(src))
-}
 
-func (e *BinaryPackedEncoding) encodeInt32(dst []byte, src []int32) ([]byte, error) {
-	return e.encode(dst, len(src), func(i int) int64 { return int64(src[i]) })
-}
+	lastValue := firstValue
+	for i := 1; i < len(src); i += blockSize {
+		block := [blockSize]int32{}
+		blockLength := copy(block[:], src[i:])
+
+		lastValue = blockDeltaInt32(&block, lastValue)
+		minDelta := blockMinInt32(&block)
+		blockSubInt32(&block, minDelta)
+		blockClearInt32(&block, blockLength)
+
+		bitWidths := [numMiniBlocks]byte{}
+		blockBitWidthsInt32(&bitWidths, &block)
 
-func (e *BinaryPackedEncoding) encodeInt64(dst []byte, src []int64) ([]byte, error) {
-	return e.encode(dst, len(src), func(i int) int64 { return src[i] })
+		n := len(dst)
+		dst = resize(dst, n+maxMiniBlockLength32+4)
+		n += encodeBlockHeader(dst[n:], int64(minDelta), bitWidths)
+
+		for i, bitWidth := range bitWidths {
+			if bitWidth != 0 {
+				miniBlock := (*[miniBlockSize]int32)(block[i*miniBlockSize:])
+				miniBlockPackInt32(dst[n:], miniBlock, uint(bitWidth))
+				n += (miniBlockSize * int(bitWidth)) / 8
+			}
+		}
+
+		dst = dst[:n]
+	}
+
+	return dst
 }
 
-func (e *BinaryPackedEncoding) encode(dst []byte, totalValues int, valueAt func(int) int64) ([]byte, error) {
+func encodeInt64Default(dst []byte, src []int64) []byte {
+	totalValues := len(src)
 	firstValue := int64(0)
 	if totalValues > 0 {
-		firstValue = valueAt(0)
+		firstValue = src[0]
 	}
-	dst = appendBinaryPackedHeader(dst, blockSize, numMiniBlocks, totalValues, firstValue)
+
+	n := len(dst)
+	dst = resize(dst, n+maxHeaderLength64)
+	dst = dst[:n+encodeBinaryPackedHeader(dst[n:], blockSize, numMiniBlocks, totalValues, firstValue)]
+
 	if totalValues < 2 {
-		return dst, nil
+		return dst
 	}
 
 	lastValue := firstValue
-	for i := 1; i < totalValues; {
-		block := make([]int64, blockSize)
-		n := blockSize
-		r := totalValues - i
-		if n > r {
-			n = r
+	for i := 1; i < len(src); i += blockSize {
+		block := [blockSize]int64{}
+		blockLength := copy(block[:], src[i:])
+
+		lastValue = blockDeltaInt64(&block, lastValue)
+		minDelta := blockMinInt64(&block)
+		blockSubInt64(&block, minDelta)
+		blockClearInt64(&block, blockLength)
+
+		bitWidths := [numMiniBlocks]byte{}
+		blockBitWidthsInt64(&bitWidths, &block)
+
+		n := len(dst)
+		dst = resize(dst, n+maxMiniBlockLength64+8)
+		n += encodeBlockHeader(dst[n:], minDelta, bitWidths)
+
+		for i, bitWidth := range bitWidths {
+			if bitWidth != 0 {
+				miniBlock := (*[miniBlockSize]int64)(block[i*miniBlockSize:])
+				miniBlockPackInt64(dst[n:], miniBlock, uint(bitWidth))
+				n += (miniBlockSize * int(bitWidth)) / 8
+			}
 		}
-		block = block[:n]
-		for j := range block {
-			block[j] = valueAt(i)
-			i++
+
+		dst = dst[:n]
+	}
+
+	return dst
+}
+
+func encodeBinaryPackedHeader(dst []byte, blockSize, numMiniBlocks, totalValues int, firstValue int64) (n int) {
+	n += binary.PutUvarint(dst[n:], uint64(blockSize))
+	n += binary.PutUvarint(dst[n:], uint64(numMiniBlocks))
+	n += binary.PutUvarint(dst[n:], uint64(totalValues))
+	n += binary.PutVarint(dst[n:], firstValue)
+	return n
+}
+
+func encodeBlockHeader(dst []byte, minDelta int64, bitWidths [numMiniBlocks]byte) (n int) {
+	n += binary.PutVarint(dst, int64(minDelta))
+	n += copy(dst[n:], bitWidths[:])
+	return n
+}
+
+func blockClearInt32(block *[blockSize]int32, blockLength int) {
+	if blockLength < blockSize {
+		clear := block[blockLength:]
+		for i := range clear {
+			clear[i] = 0
 		}
+	}
+}
 
-		for j, v := range block {
-			block[j], lastValue = v-lastValue, v
+func blockDeltaInt32(block *[blockSize]int32, lastValue int32) int32 {
+	for i, v := range block {
+		block[i], lastValue = v-lastValue, v
+	}
+	return lastValue
+}
+
+func blockMinInt32(block *[blockSize]int32) int32 {
+	min := block[0]
+	for _, v := range block[1:] {
+		if v < min {
+			min = v
 		}
+	}
+	return min
+}
 
-		minDelta := bits.MinInt64(block)
-		bits.SubInt64(block, minDelta)
-
-		// blockSize x 8: we store at most `blockSize` count of values, which
-		// might be up to 64 bits in length, which is why we multiple by 8.
-		//
-		// Technically we could size the buffer to a smaller size when the
-		// bit width requires less than 8 bytes per value, but it would cause
-		// the buffer to be put on the heap since the compiler wouldn't know
-		// how much stack space it needs in advance.
-		miniBlock := make([]byte, blockSize*8)
-		bitWidths := make([]byte, numMiniBlocks)
-		bitOffset := uint(0)
-		miniBlockLength := 0
-
-		for i := range bitWidths {
-			j := (i + 0) * miniBlockSize
-			k := (i + 1) * miniBlockSize
-
-			if k > len(block) {
-				k = len(block)
-			}
+func blockSubInt32(block *[blockSize]int32, value int32) {
+	for i := range block {
+		block[i] -= value
+	}
+}
 
-			bitWidth := uint(bits.MaxLen64(block[j:k]))
-			if bitWidth != 0 {
-				bitWidths[i] = byte(bitWidth)
-
-				for _, bits := range block[j:k] {
-					for b := uint(0); b < bitWidth; b++ {
-						x := bitOffset / 8
-						y := bitOffset % 8
-						miniBlock[x] |= byte(((bits >> b) & 1) << y)
-						bitOffset++
-					}
-				}
+func blockBitWidthsInt32(bitWidths *[numMiniBlocks]byte, block *[blockSize]int32) {
+	for i := range bitWidths {
+		j := (i + 0) * miniBlockSize
+		k := (i + 1) * miniBlockSize
+		bitWidth := 0
 
-				miniBlockLength += (miniBlockSize * int(bitWidth)) / 8
+		for _, v := range block[j:k] {
+			if n := Len32(uint32(v)); n > bitWidth {
+				bitWidth = n
 			}
+		}
 
-			if k == len(block) {
-				break
+		bitWidths[i] = byte(bitWidth)
+	}
+}
+
+func blockClearInt64(block *[blockSize]int64, blockLength int) {
+	if blockLength < blockSize {
+		clear := block[blockLength:]
+		for i := range clear {
+			clear[i] = 0
+		}
+	}
+}
+
+func blockDeltaInt64(block *[blockSize]int64, lastValue int64) int64 {
+	for i, v := range block {
+		block[i], lastValue = v-lastValue, v
+	}
+	return lastValue
+}
+
+func blockMinInt64(block *[blockSize]int64) int64 {
+	min := block[0]
+	for _, v := range block[1:] {
+		if v < min {
+			min = v
+		}
+	}
+	return min
+}
+
+func blockSubInt64(block *[blockSize]int64, value int64) {
+	for i := range block {
+		block[i] -= value
+	}
+}
+
+func blockBitWidthsInt64(bitWidths *[numMiniBlocks]byte, block *[blockSize]int64) {
+	for i := range bitWidths {
+		j := (i + 0) * miniBlockSize
+		k := (i + 1) * miniBlockSize
+		bitWidth := 0
+
+		for _, v := range block[j:k] {
+			if n := Len64(uint64(v)); n > bitWidth {
+				bitWidth = n
 			}
 		}
 
-		miniBlock = miniBlock[:miniBlockLength]
-		dst = appendBinaryPackedBlock(dst, int64(minDelta), bitWidths)
-		dst = append(dst, miniBlock...)
+		bitWidths[i] = byte(bitWidth)
+	}
+}
+
+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) {
+		clear := buf[len(buf):size]
+		for i := range clear {
+			clear[i] = 0
+		}
+	}
+	return buf[:size]
+}
+
+type BinaryPackedEncoding struct {
+	encoding.NotSupported
+}
+
+func (e *BinaryPackedEncoding) String() string {
+	return "DELTA_BINARY_PACKED"
+}
+
+func (e *BinaryPackedEncoding) Encoding() format.Encoding {
+	return format.DeltaBinaryPacked
+}
+
+func (e *BinaryPackedEncoding) EncodeInt32(dst, src []byte) ([]byte, error) {
+	if (len(src) % 4) != 0 {
+		return dst[:0], encoding.ErrEncodeInvalidInputSize(e, "INT64", len(src))
 	}
+	return encodeInt32(dst[:0], bytesToInt32(src)), nil
+}
 
-	return dst, nil
+func (e *BinaryPackedEncoding) EncodeInt64(dst, src []byte) ([]byte, error) {
+	if (len(src) % 8) != 0 {
+		return dst[:0], encoding.ErrEncodeInvalidInputSize(e, "INT64", len(src))
+	}
+	return encodeInt64(dst[:0], bytesToInt64(src)), nil
 }
 
 func (e *BinaryPackedEncoding) DecodeInt32(dst, src []byte) ([]byte, error) {
@@ -268,24 +411,6 @@ func (e *BinaryPackedEncoding) wrap(err error) error {
 	return err
 }
 
-func appendBinaryPackedHeader(dst []byte, blockSize, numMiniBlocks, totalValues int, firstValue int64) []byte {
-	b := [4 * binary.MaxVarintLen64]byte{}
-	n := 0
-	n += binary.PutUvarint(b[n:], uint64(blockSize))
-	n += binary.PutUvarint(b[n:], uint64(numMiniBlocks))
-	n += binary.PutUvarint(b[n:], uint64(totalValues))
-	n += binary.PutVarint(b[n:], firstValue)
-	return append(dst, b[:n]...)
-}
-
-func appendBinaryPackedBlock(dst []byte, minDelta int64, bitWidths []byte) []byte {
-	b := [binary.MaxVarintLen64]byte{}
-	n := binary.PutVarint(b[:], minDelta)
-	dst = append(dst, b[:n]...)
-	dst = append(dst, bitWidths...)
-	return dst
-}
-
 func decodeBinaryPackedHeader(src []byte) (blockSize, numMiniBlocks, totalValues int, firstValue int64, next []byte, err error) {
 	u := uint64(0)
 	n := 0
diff --git a/encoding/delta/binary_packed_amd64.go b/encoding/delta/binary_packed_amd64.go
new file mode 100644
index 0000000..b151285
--- /dev/null
+++ b/encoding/delta/binary_packed_amd64.go
@@ -0,0 +1,193 @@
+//go:build !purego
+
+package delta
+
+import (
+	"golang.org/x/sys/cpu"
+)
+
+func init() {
+	if cpu.X86.HasAVX2 {
+		encodeInt32 = encodeInt32AVX2
+		encodeInt64 = encodeInt64AVX2
+	}
+}
+
+//go:noescape
+func blockDeltaInt32AVX2(block *[blockSize]int32, lastValue int32) int32
+
+//go:noescape
+func blockMinInt32AVX2(block *[blockSize]int32) int32
+
+//go:noescape
+func blockSubInt32AVX2(block *[blockSize]int32, value int32)
+
+//go:noescape
+func blockBitWidthsInt32AVX2(bitWidths *[numMiniBlocks]byte, block *[blockSize]int32)
+
+//go:noescape
+func miniBlockPackInt32Default(dst *byte, src *[miniBlockSize]int32, bitWidth uint)
+
+//go:noescape
+func miniBlockPackInt32x1bitAVX2(dst *byte, src *[miniBlockSize]int32)
+
+//go:noescape
+func miniBlockPackInt32x2bitsAVX2(dst *byte, src *[miniBlockSize]int32)
+
+//go:noescape
+func miniBlockPackInt32x3to16bitsAVX2(dst *byte, src *[miniBlockSize]int32, bitWidth uint)
+
+//go:noescape
+func miniBlockPackInt32x32bitsAVX2(dst *byte, src *[miniBlockSize]int32)
+
+func miniBlockPackInt32(dst []byte, src *[miniBlockSize]int32, bitWidth uint) {
+	miniBlockPackInt32Default(&dst[0], src, bitWidth)
+}
+
+func miniBlockPackInt32AVX2(dst *byte, src *[miniBlockSize]int32, bitWidth uint) {
+	switch {
+	case bitWidth == 1:
+		miniBlockPackInt32x1bitAVX2(dst, src)
+	case bitWidth == 2:
+		miniBlockPackInt32x2bitsAVX2(dst, src)
+	case bitWidth == 32:
+		miniBlockPackInt32x32bitsAVX2(dst, src)
+	case bitWidth <= 16:
+		miniBlockPackInt32x3to16bitsAVX2(dst, src, bitWidth)
+	default:
+		miniBlockPackInt32Default(dst, src, bitWidth)
+	}
+}
+
+func encodeInt32AVX2(dst []byte, src []int32) []byte {
+	totalValues := len(src)
+	firstValue := int32(0)
+	if totalValues > 0 {
+		firstValue = src[0]
+	}
+
+	n := len(dst)
+	dst = resize(dst, n+maxHeaderLength32)
+	dst = dst[:n+encodeBinaryPackedHeader(dst[n:], blockSize, numMiniBlocks, totalValues, int64(firstValue))]
+
+	if totalValues < 2 {
+		return dst
+	}
+
+	lastValue := firstValue
+	for i := 1; i < len(src); i += blockSize {
+		block := [blockSize]int32{}
+		blockLength := copy(block[:], src[i:])
+
+		lastValue = blockDeltaInt32AVX2(&block, lastValue)
+		minDelta := blockMinInt32AVX2(&block)
+		blockSubInt32AVX2(&block, minDelta)
+		blockClearInt32(&block, blockLength)
+
+		bitWidths := [numMiniBlocks]byte{}
+		blockBitWidthsInt32AVX2(&bitWidths, &block)
+
+		n := len(dst)
+		dst = resize(dst, n+maxMiniBlockLength32+16)
+		n += encodeBlockHeader(dst[n:], int64(minDelta), bitWidths)
+
+		for i, bitWidth := range bitWidths {
+			if bitWidth != 0 {
+				miniBlock := (*[miniBlockSize]int32)(block[i*miniBlockSize:])
+				miniBlockPackInt32AVX2(&dst[n], miniBlock, uint(bitWidth))
+				n += (miniBlockSize * int(bitWidth)) / 8
+			}
+		}
+
+		dst = dst[:n]
+	}
+
+	return dst
+}
+
+//go:noescape
+func blockDeltaInt64AVX2(block *[blockSize]int64, lastValue int64) int64
+
+//go:noescape
+func blockMinInt64AVX2(block *[blockSize]int64) int64
+
+//go:noescape
+func blockSubInt64AVX2(block *[blockSize]int64, value int64)
+
+//go:noescape
+func blockBitWidthsInt64AVX2(bitWidths *[numMiniBlocks]byte, block *[blockSize]int64)
+
+//go:noescape
+func miniBlockPackInt64Default(dst *byte, src *[miniBlockSize]int64, bitWidth uint)
+
+//go:noescape
+func miniBlockPackInt64x1bitAVX2(dst *byte, src *[miniBlockSize]int64)
+
+//go:noescape
+func miniBlockPackInt64x2bitsAVX2(dst *byte, src *[miniBlockSize]int64)
+
+//go:noescape
+func miniBlockPackInt64x64bitsAVX2(dst *byte, src *[miniBlockSize]int64)
+
+func miniBlockPackInt64(dst []byte, src *[miniBlockSize]int64, bitWidth uint) {
+	miniBlockPackInt64Default(&dst[0], src, bitWidth)
+}
+
+func miniBlockPackInt64AVX2(dst *byte, src *[miniBlockSize]int64, bitWidth uint) {
+	switch {
+	case bitWidth == 1:
+		miniBlockPackInt64x1bitAVX2(dst, src)
+	case bitWidth == 2:
+		miniBlockPackInt64x2bitsAVX2(dst, src)
+	case bitWidth == 64:
+		miniBlockPackInt64x64bitsAVX2(dst, src)
+	default:
+		miniBlockPackInt64Default(dst, src, bitWidth)
+	}
+}
+
+func encodeInt64AVX2(dst []byte, src []int64) []byte {
+	totalValues := len(src)
+	firstValue := int64(0)
+	if totalValues > 0 {
+		firstValue = src[0]
+	}
+
+	n := len(dst)
+	dst = resize(dst, n+maxHeaderLength64)
+	dst = dst[:n+encodeBinaryPackedHeader(dst[n:], blockSize, numMiniBlocks, totalValues, int64(firstValue))]
+
+	if totalValues < 2 {
+		return dst
+	}
+
+	lastValue := firstValue
+	for i := 1; i < len(src); i += blockSize {
+		block := [blockSize]int64{}
+		blockLength := copy(block[:], src[i:])
+
+		lastValue = blockDeltaInt64AVX2(&block, lastValue)
+		minDelta := blockMinInt64AVX2(&block)
+		blockSubInt64AVX2(&block, minDelta)
+		blockClearInt64(&block, blockLength)
+
+		bitWidths := [numMiniBlocks]byte{}
+		blockBitWidthsInt64AVX2(&bitWidths, &block)
+
+		n := len(dst)
+		dst = resize(dst, n+maxMiniBlockLength64+16)
+		n += encodeBlockHeader(dst[n:], int64(minDelta), bitWidths)
+
+		for i, bitWidth := range bitWidths {
+			if bitWidth != 0 {
+				miniBlock := (*[miniBlockSize]int64)(block[i*miniBlockSize:])
+				miniBlockPackInt64AVX2(&dst[n], miniBlock, uint(bitWidth))
+				n += (miniBlockSize * int(bitWidth)) / 8
+			}
+		}
+
+		dst = dst[:n]
+	}
+
+	return dst
+}
diff --git a/encoding/delta/binary_packed_amd64.s b/encoding/delta/binary_packed_amd64.s
new file mode 100644
index 0000000..4e9a191
--- /dev/null
+++ b/encoding/delta/binary_packed_amd64.s
@@ -0,0 +1,825 @@
+//go:build !purego
+
+#include "textflag.h"
+
+#define blockSize 128
+#define numMiniBlocks 4
+#define miniBlockSize 32
+
+// -----------------------------------------------------------------------------
+// 32 bits
+// -----------------------------------------------------------------------------
+
+#define deltaInt32AVX2x8(baseAddr) \
+    VMOVDQU baseAddr, Y1    \ // [0,1,2,3,4,5,6,7]
+    VPERMD Y1, Y3, Y2       \ // [7,0,1,2,3,4,5,6]
+    VPBLENDD $1, Y0, Y2, Y2 \ // [x,0,1,2,3,4,5,6]
+    VPSUBD Y2, Y1, Y2       \ // [0,1,2,...] - [x,0,1,...]
+    VMOVDQU Y2, baseAddr    \
+    VPERMD Y1, Y3, Y0
+
+// func blockDeltaInt32AVX2(block *[blockSize]int32, lastValue int32) int32
+TEXT ·blockDeltaInt32AVX2(SB), NOSPLIT, $0-24
+    MOVQ block+0(FP), AX
+    MOVL 4*blockSize-4(AX), CX
+    MOVL CX, ret+16(FP)
+
+    VPBROADCASTD lastValue+8(FP), Y0
+    VMOVDQU deltaInt32Perm<>(SB), Y3
+
+    XORQ SI, SI
+loop:
+    deltaInt32AVX2x8(0(AX)(SI*4))
+    deltaInt32AVX2x8(32(AX)(SI*4))
+    deltaInt32AVX2x8(64(AX)(SI*4))
+    deltaInt32AVX2x8(96(AX)(SI*4))
+    ADDQ $32, SI
+    CMPQ SI, $blockSize
+    JNE loop
+    VZEROUPPER
+    RET
+
+GLOBL deltaInt32Perm<>(SB), RODATA|NOPTR, $32
+DATA deltaInt32Perm<>+0(SB)/4, $7
+DATA deltaInt32Perm<>+4(SB)/4, $0
+DATA deltaInt32Perm<>+8(SB)/4, $1
+DATA deltaInt32Perm<>+12(SB)/4, $2
+DATA deltaInt32Perm<>+16(SB)/4, $3
+DATA deltaInt32Perm<>+20(SB)/4, $4
+DATA deltaInt32Perm<>+24(SB)/4, $5
+DATA deltaInt32Perm<>+28(SB)/4, $6
+
+// func blockMinInt32AVX2(block *[blockSize]int32) int32
+TEXT ·blockMinInt32AVX2(SB), NOSPLIT, $0-16
+    MOVQ block+0(FP), AX
+    VPBROADCASTD (AX), Y15
+
+    VPMINSD 0(AX), Y15, Y0
+    VPMINSD 32(AX), Y15, Y1
+    VPMINSD 64(AX), Y15, Y2
+    VPMINSD 96(AX), Y15, Y3
+    VPMINSD 128(AX), Y15, Y4
+    VPMINSD 160(AX), Y15, Y5
+    VPMINSD 192(AX), Y15, Y6
+    VPMINSD 224(AX), Y15, Y7
+    VPMINSD 256(AX), Y15, Y8
+    VPMINSD 288(AX), Y15, Y9
+    VPMINSD 320(AX), Y15, Y10
+    VPMINSD 352(AX), Y15, Y11
+    VPMINSD 384(AX), Y15, Y12
+    VPMINSD 416(AX), Y15, Y13
+    VPMINSD 448(AX), Y15, Y14
+    VPMINSD 480(AX), Y15, Y15
+
+    VPMINSD Y1, Y0, Y0
+    VPMINSD Y3, Y2, Y2
+    VPMINSD Y5, Y4, Y4
+    VPMINSD Y7, Y6, Y6
+    VPMINSD Y9, Y8, Y8
+    VPMINSD Y11, Y10, Y10
+    VPMINSD Y13, Y12, Y12
+    VPMINSD Y15, Y14, Y14
+
+    VPMINSD Y2, Y0, Y0
+    VPMINSD Y6, Y4, Y4
+    VPMINSD Y10, Y8, Y8
+    VPMINSD Y14, Y12, Y12
+
+    VPMINSD Y4, Y0, Y0
+    VPMINSD Y12, Y8, Y8
+
+    VPMINSD Y8, Y0, Y0
+
+    VPERM2I128 $1, Y0, Y0, Y1
+    VPMINSD Y1, Y0, Y0
+
+    VPSHUFD $0b00011011, Y0, Y1
+    VPMINSD Y1, Y0, Y0
+    VZEROUPPER
+
+    MOVQ X0, CX
+    MOVL CX, BX
+    SHRQ $32, CX
+    CMPL CX, BX
+    CMOVLLT CX, BX
+    MOVL BX, ret+8(FP)
+    RET
+
+#define subInt32AVX2x32(baseAddr, offset) \
+    VMOVDQU offset+0(baseAddr), Y1      \
+    VMOVDQU offset+32(baseAddr), Y2     \
+    VMOVDQU offset+64(baseAddr), Y3     \
+    VMOVDQU offset+96(baseAddr), Y4     \
+    VPSUBD Y0, Y1, Y1                   \
+    VPSUBD Y0, Y2, Y2                   \
+    VPSUBD Y0, Y3, Y3                   \
+    VPSUBD Y0, Y4, Y4                   \
+    VMOVDQU Y1, offset+0(baseAddr)      \
+    VMOVDQU Y2, offset+32(baseAddr)     \
+    VMOVDQU Y3, offset+64(baseAddr)     \
+    VMOVDQU Y4, offset+96(baseAddr)
+
+// func blockSubInt32AVX2(block *[blockSize]int32, value int32)
+TEXT ·blockSubInt32AVX2(SB), NOSPLIT, $0-16
+    MOVQ block+0(FP), AX
+    VPBROADCASTD value+8(FP), Y0
+    subInt32AVX2x32(AX, 0)
+    subInt32AVX2x32(AX, 128)
+    subInt32AVX2x32(AX, 256)
+    subInt32AVX2x32(AX, 384)
+    VZEROUPPER
+    RET
+
+// func blockBitWidthsInt32AVX2(bitWidths *[numMiniBlocks]byte, block *[blockSize]int32)
+TEXT ·blockBitWidthsInt32AVX2(SB), NOSPLIT, $0-16
+    MOVQ bitWidths+0(FP), AX
+    MOVQ block+8(FP), BX
+
+    // AVX2 only has signed comparisons (and min/max), we emulate working on
+    // unsigned values by adding -2^31 to the values. Y5 is a vector of -2^31
+    // used to offset 8 packed 32 bits integers in other YMM registers where
+    // the block data are loaded.
+    VPCMPEQD Y5, Y5, Y5
+    VPSLLD $31, Y5, Y5
+
+    XORQ DI, DI
+loop:
+    VPBROADCASTD (BX), Y0 // max
+    VPADDD Y5, Y0, Y0
+
+    VMOVDQU (BX), Y1
+    VMOVDQU 32(BX), Y2
+    VMOVDQU 64(BX), Y3
+    VMOVDQU 96(BX), Y4
+
+    VPADDD Y5, Y1, Y1
+    VPADDD Y5, Y2, Y2
+    VPADDD Y5, Y3, Y3
+    VPADDD Y5, Y4, Y4
+
+    VPMAXSD Y2, Y1, Y1
+    VPMAXSD Y4, Y3, Y3
+    VPMAXSD Y3, Y1, Y1
+    VPMAXSD Y1, Y0, Y0
+
+    VPERM2I128 $1, Y0, Y0, Y1
+    VPMAXSD Y1, Y0, Y0
+
+    VPSHUFD $0b00011011, Y0, Y1
+    VPMAXSD Y1, Y0, Y0
+    VPSUBD Y5, Y0, Y0
+
+    MOVQ X0, CX
+    MOVL CX, DX
+    SHRQ $32, CX
+    CMPL CX, DX
+    CMOVLHI CX, DX
+
+    LZCNTL DX, DX
+    NEGL DX
+    ADDL $32, DX
+    MOVB DX, (AX)(DI*1)
+
+    ADDQ $128, BX
+    INCQ DI
+    CMPQ DI, $numMiniBlocks
+    JNE loop
+    VZEROUPPER
+    RET
+
+// miniBlockPackInt32Default is the generic implementation of the algorithm to
+// pack 32 bit integers into values of a given bit width (<=32).
+//
+// This algorithm is much slower than the vectorized versions, but is useful
+// as a reference implementation to run the tests against, and as fallback when
+// the code runs on a CPU which does not support the AVX2 instruction set.
+//
+// func miniBlockPackInt32Default(dst *byte, src *[miniBlockSize]int32, bitWidth uint)
+TEXT ·miniBlockPackInt32Default(SB), NOSPLIT, $0-24
+    MOVQ dst+0(FP), AX
+    MOVQ src+8(FP), BX
+    MOVQ bitWidth+16(FP), R9
+
+    XORQ DI, DI // bitOffset
+    XORQ SI, SI
+loop:
+    MOVQ DI, CX
+    MOVQ DI, DX
+
+    ANDQ $0b11111, CX // bitOffset % 32
+    SHRQ $5, DX       // bitOffset / 32
+
+    MOVLQZX (BX)(SI*4), R8
+    SHLQ CX, R8
+    ORQ R8, (AX)(DX*4)
+
+    ADDQ R9, DI
+    INCQ SI
+    CMPQ SI, $miniBlockSize
+    JNE loop
+    RET
+
+// miniBlockPackInt32x1bitAVX2 packs 32 bit integers into 1 bit values in the
+// the output buffer.
+//
+// The algorithm uses MOVMSKPS to extract the 8 relevant bits from the 8 values
+// packed in YMM registers, then combines 4 of these into a 32 bit word which
+// then gets written to the output. The result is 32 bits because each mini
+// block has 32 values (the block size is 128 and there are 4 mini blocks per
+// block).
+//
+// func miniBlockPackInt32x1bitAVX2(dst *byte, src *[miniBlockSize]int32)
+TEXT ·miniBlockPackInt32x1bitAVX2(SB), NOSPLIT, $0-16
+    MOVQ dst+0(FP), AX
+    MOVQ src+8(FP), BX
+
+    VMOVDQU 0(BX), Y0
+    VMOVDQU 32(BX), Y1
+    VMOVDQU 64(BX), Y2
+    VMOVDQU 96(BX), Y3
+
+    VPSLLD $31, Y0, Y0
+    VPSLLD $31, Y1, Y1
+    VPSLLD $31, Y2, Y2
+    VPSLLD $31, Y3, Y3
+
+    VMOVMSKPS Y0, R8
+    VMOVMSKPS Y1, R9
+    VMOVMSKPS Y2, R10
+    VMOVMSKPS Y3, R11
+
+    SHLL $8, R9
+    SHLL $16, R10
+    SHLL $24, R11
+
+    ORL R9, R8
+    ORL R10, R8
+    ORL R11, R8
+    MOVL R8, (AX)
+    VZEROUPPER
+    RET
+
+// miniBlockPackInt32x2bitsAVX2 implements an algorithm for packing 32 bit
+// integers into 2 bit values.
+//
+// The algorithm is derived from the one employed in miniBlockPackInt32x1bitAVX2
+// but needs to perform a bit extra work since MOVMSKPS can only extract one bit
+// per packed integer of each YMM vector. We run two passes to extract the two
+// bits needed to compose each item of the result, and merge the values by
+// interleaving the first and second bits with PDEP.
+//
+// func miniBlockPackInt32x2bitsAVX2(dst *byte, src *[miniBlockSize]int32)
+TEXT ·miniBlockPackInt32x2bitsAVX2(SB), NOSPLIT, $0-16
+    MOVQ dst+0(FP), AX
+    MOVQ src+8(FP), BX
+
+    VMOVDQU 0(BX), Y0
+    VMOVDQU 32(BX), Y1
+    VMOVDQU 64(BX), Y2
+    VMOVDQU 96(BX), Y3
+
+    VPSLLD $31, Y0, Y4
+    VPSLLD $31, Y1, Y5
+    VPSLLD $31, Y2, Y6
+    VPSLLD $31, Y3, Y7
+
+    VMOVMSKPS Y4, R8
+    VMOVMSKPS Y5, R9
+    VMOVMSKPS Y6, R10
+    VMOVMSKPS Y7, R11
+
+    SHLQ $8, R9
+    SHLQ $16, R10
+    SHLQ $24, R11
+    ORQ R9, R8
+    ORQ R10, R8
+    ORQ R11, R8
+
+    MOVQ $0x5555555555555555, DX // 0b010101...
+    PDEPQ DX, R8, R8
+
+    VPSLLD $30, Y0, Y8
+    VPSLLD $30, Y1, Y9
+    VPSLLD $30, Y2, Y10
+    VPSLLD $30, Y3, Y11
+
+    VMOVMSKPS Y8, R12
+    VMOVMSKPS Y9, R13
+    VMOVMSKPS Y10, R14
+    VMOVMSKPS Y11, R15
+
+    SHLQ $8, R13
+    SHLQ $16, R14
+    SHLQ $24, R15
+    ORQ R13, R12
+    ORQ R14, R12
+    ORQ R15, R12
+
+    MOVQ $0xAAAAAAAAAAAAAAAA, DI // 0b101010...
+    PDEPQ DI, R12, R12
+
+    ORQ R12, R8
+    MOVQ R8, (AX)
+    VZEROUPPER
+    RET
+
+// miniBlockPackInt32x32bitsAVX2 is a specialization of the bit packing logic
+// for 32 bit integers when the output bit width is also 32, in which case a
+// simple copy of the mini block to the output buffer produces the result.
+//
+// func miniBlockPackInt32x32bitsAVX2(dst *byte, src *[miniBlockSize]int32)
+TEXT ·miniBlockPackInt32x32bitsAVX2(SB), NOSPLIT, $0-16
+    MOVQ dst+0(FP), AX
+    MOVQ src+8(FP), BX
+    VMOVDQU 0(BX), Y0
+    VMOVDQU 32(BX), Y1
+    VMOVDQU 64(BX), Y2
+    VMOVDQU 96(BX), Y3
+    VMOVDQU Y0, 0(AX)
+    VMOVDQU Y1, 32(AX)
+    VMOVDQU Y2, 64(AX)
+    VMOVDQU Y3, 96(AX)
+    VZEROUPPER
+    RET
+
+// miniBlockPackInt32x3to16bitsAVX2 is the algorithm used to bit-pack 32 bit
+// integers into values of width 3 to 16 bits.
+//
+// This function is a small overhead due to having to initialize registers with
+// values that depend on the bit width. We measured this cost at ~10% throughput
+// in synthetic benchmarks compared to generating constant shifts and offsets
+// using a macro. Using a single function rather than generating one for each
+// bit width has the benefit of reducing the code size, which in practice can
+// also yield benefits like reducing CPU cache misses. Not using a macro also
+// has other advantages like providing accurate line number of stack traces and
+// enabling the use of breakpoints when debugging. Overall, this approach seemed
+// to be the right trade off between performance and maintainability.
+//
+// The algorithm treats chunks of 8 values in 4 iterations to process all 32
+// values of the mini block. Writes to the output buffer are aligned on 128 bits
+// since we may write up to 128 bits (8 x 16 bits). Padding is therefore
+// required in the output buffer to avoid triggering a segfault.
+// The encodeInt32AVX2 method adds enough padding when sizing the output buffer
+// to account for this requirement.
+//
+// We leverage the two lanes of YMM registers to work on two sets of 4 values
+// (in the sequence of VMOVDQU/VPSHUFD, VPAND, VPSLLQ, VPOR), resulting in having
+// two sets of bit-packed values in the lower 64 bits of each YMM lane.
+// The upper lane is then permuted into a lower lane to merge the two results,
+// which may not be aligned on byte boundaries so we shift the lower and upper
+// bits and compose two sets of 128 bits sequences (VPSLLQ, VPSRLQ, VBLENDPD),
+// merge them and write the 16 bytes result to the output buffer.
+TEXT ·miniBlockPackInt32x3to16bitsAVX2(SB), NOSPLIT, $0-24
+    MOVQ dst+0(FP), AX
+    MOVQ src+8(FP), BX
+
+    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 sixtyfour<>(SB), 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
+loop:
+    VMOVDQU (BX)(SI*4), 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 CX, AX
+    ADDQ $8, SI
+    CMPQ SI, $miniBlockSize
+    JNE loop
+    VZEROUPPER
+    RET
+
+GLOBL sixtyfour<>(SB), RODATA|NOPTR, $32
+DATA sixtyfour<>+0(SB)/8, $64
+DATA sixtyfour<>+8(SB)/8, $64
+DATA sixtyfour<>+16(SB)/8, $64
+DATA sixtyfour<>+24(SB)/8, $64
+
+// -----------------------------------------------------------------------------
+// 64 bits
+// -----------------------------------------------------------------------------
+
+#define deltaInt64AVX2x4(baseAddr)  \
+    VMOVDQU baseAddr, Y1            \ // [0,1,2,3]
+    VPERMQ $0b10010011, Y1, Y2      \ // [3,0,1,2]
+    VPBLENDD $3, Y0, Y2, Y2         \ // [x,0,1,2]
+    VPSUBQ Y2, Y1, Y2               \ // [0,1,2,3] - [x,0,1,2]
+    VMOVDQU Y2, baseAddr            \
+    VPERMQ $0b10010011, Y1, Y0
+
+// func blockDeltaInt64AVX2(block *[blockSize]int64, lastValue int64) int64
+TEXT ·blockDeltaInt64AVX2(SB), NOSPLIT, $0-24
+    MOVQ block+0(FP), AX
+    MOVQ 8*blockSize-8(AX), CX
+    MOVQ CX, ret+16(FP)
+
+    VPBROADCASTQ lastValue+8(FP), Y0
+    XORQ SI, SI
+loop:
+    deltaInt64AVX2x4((AX)(SI*8))
+    deltaInt64AVX2x4(32(AX)(SI*8))
+    deltaInt64AVX2x4(64(AX)(SI*8))
+    deltaInt64AVX2x4(96(AX)(SI*8))
+    ADDQ $16, SI
+    CMPQ SI, $blockSize
+    JNE loop
+    VZEROUPPER
+    RET
+
+// vpminsq is an emulation of the AVX-512 VPMINSQ instruction with AVX2.
+#define vpminsq(ones, tmp, arg2, arg1, ret) \
+    VPCMPGTQ arg1, arg2, tmp \
+    VPBLENDVB tmp, arg1, arg2, ret
+
+// func blockMinInt64AVX2(block *[blockSize]int64) int64
+TEXT ·blockMinInt64AVX2(SB), NOSPLIT, $0-16
+    MOVQ block+0(FP), AX
+    XORQ SI, SI
+    VPCMPEQQ Y9, Y9, Y9 // ones
+    VPBROADCASTQ (AX), Y0
+loop:
+    VMOVDQU 0(AX)(SI*8), Y1
+    VMOVDQU 32(AX)(SI*8), Y2
+    VMOVDQU 64(AX)(SI*8), Y3
+    VMOVDQU 96(AX)(SI*8), Y4
+    VMOVDQU 128(AX)(SI*8), Y5
+    VMOVDQU 160(AX)(SI*8), Y6
+    VMOVDQU 192(AX)(SI*8), Y7
+    VMOVDQU 224(AX)(SI*8), Y8
+
+    vpminsq(Y9, Y10, Y0, Y1, Y1)
+    vpminsq(Y9, Y11, Y0, Y2, Y2)
+    vpminsq(Y9, Y12, Y0, Y3, Y3)
+    vpminsq(Y9, Y13, Y0, Y4, Y4)
+    vpminsq(Y9, Y14, Y0, Y5, Y5)
+    vpminsq(Y9, Y15, Y0, Y6, Y6)
+    vpminsq(Y9, Y10, Y0, Y7, Y7)
+    vpminsq(Y9, Y11, Y0, Y8, Y8)
+
+    vpminsq(Y9, Y12, Y2, Y1, Y1)
+    vpminsq(Y9, Y13, Y4, Y3, Y3)
+    vpminsq(Y9, Y14, Y6, Y5, Y5)
+    vpminsq(Y9, Y15, Y8, Y7, Y7)
+
+    vpminsq(Y9, Y10, Y3, Y1, Y1)
+    vpminsq(Y9, Y11, Y7, Y5, Y5)
+    vpminsq(Y9, Y12, Y5, Y1, Y0)
+
+    ADDQ $32, SI
+    CMPQ SI, $blockSize
+    JNE loop
+
+    VPERM2I128 $1, Y0, Y0, Y1
+    vpminsq(Y9, Y10, Y1, Y0, Y0)
+
+    MOVQ X0, CX
+    VPEXTRQ $1, X0, BX
+    CMPQ CX, BX
+    CMOVQLT CX, BX
+    MOVQ BX, ret+8(FP)
+    VZEROUPPER
+    RET
+
+#define subInt64AVX2x32(baseAddr, offset) \
+    VMOVDQU offset+0(baseAddr), Y1      \
+    VMOVDQU offset+32(baseAddr), Y2     \
+    VMOVDQU offset+64(baseAddr), Y3     \
+    VMOVDQU offset+96(baseAddr), Y4     \
+    VMOVDQU offset+128(baseAddr), Y5    \
+    VMOVDQU offset+160(baseAddr), Y6    \
+    VMOVDQU offset+192(baseAddr), Y7    \
+    VMOVDQU offset+224(baseAddr), Y8    \
+    VPSUBQ Y0, Y1, Y1                   \
+    VPSUBQ Y0, Y2, Y2                   \
+    VPSUBQ Y0, Y3, Y3                   \
+    VPSUBQ Y0, Y4, Y4                   \
+    VPSUBQ Y0, Y5, Y5                   \
+    VPSUBQ Y0, Y6, Y6                   \
+    VPSUBQ Y0, Y7, Y7                   \
+    VPSUBQ Y0, Y8, Y8                   \
+    VMOVDQU Y1, offset+0(baseAddr)      \
+    VMOVDQU Y2, offset+32(baseAddr)     \
+    VMOVDQU Y3, offset+64(baseAddr)     \
+    VMOVDQU Y4, offset+96(baseAddr)     \
+    VMOVDQU Y5, offset+128(baseAddr)    \
+    VMOVDQU Y6, offset+160(baseAddr)    \
+    VMOVDQU Y7, offset+192(baseAddr)    \
+    VMOVDQU Y8, offset+224(baseAddr)
+
+// func blockSubInt64AVX2(block *[blockSize]int64, value int64)
+TEXT ·blockSubInt64AVX2(SB), NOSPLIT, $0-16
+    MOVQ block+0(FP), AX
+    VPBROADCASTQ value+8(FP), Y0
+    subInt64AVX2x32(AX, 0)
+    subInt64AVX2x32(AX, 256)
+    subInt64AVX2x32(AX, 512)
+    subInt64AVX2x32(AX, 768)
+    VZEROUPPER
+    RET
+
+// func blockBitWidthsInt64AVX2(bitWidths *[numMiniBlocks]byte, block *[blockSize]int64)
+TEXT ·blockBitWidthsInt64AVX2(SB), NOSPLIT, $0-16
+    MOVQ bitWidths+0(FP), AX
+    MOVQ block+8(FP), BX
+
+    // AVX2 only has signed comparisons (and min/max), we emulate working on
+    // unsigned values by adding -2^64 to the values. Y9 is a vector of -2^64
+    // used to offset 4 packed 64 bits integers in other YMM registers where
+    // the block data are loaded.
+    VPCMPEQQ Y9, Y9, Y9
+    VPSLLQ $63, Y9, Y9
+
+    XORQ DI, DI
+loop:
+    VPBROADCASTQ (BX), Y0 // max
+    VPADDQ Y9, Y0, Y0
+
+    VMOVDQU (BX), Y1
+    VMOVDQU 32(BX), Y2
+    VMOVDQU 64(BX), Y3
+    VMOVDQU 96(BX), Y4
+    VMOVDQU 128(BX), Y5
+    VMOVDQU 160(BX), Y6
+    VMOVDQU 192(BX), Y7
+    VMOVDQU 224(BX), Y8
+
+    VPADDQ Y9, Y1, Y1
+    VPADDQ Y9, Y2, Y2
+    VPADDQ Y9, Y3, Y3
+    VPADDQ Y9, Y4, Y4
+    VPADDQ Y9, Y5, Y5
+    VPADDQ Y9, Y6, Y6
+    VPADDQ Y9, Y7, Y7
+    VPADDQ Y9, Y8, Y8
+
+    VPMAXSQ Y2, Y1, Y1
+    VPMAXSQ Y4, Y3, Y3
+    VPMAXSQ Y6, Y5, Y5
+    VPMAXSQ Y8, Y7, Y7
+
+    VPMAXSQ Y3, Y1, Y1
+    VPMAXSQ Y7, Y5, Y5
+    VPMAXSQ Y5, Y1, Y1
+    VPMAXSQ Y1, Y0, Y0
+
+    VPERM2I128 $1, Y0, Y0, Y1
+    VPMAXSQ Y1, Y0, Y0
+    VPSUBQ Y9, Y0, Y0
+
+    MOVQ X0, CX
+    VPEXTRQ $1, X0, DX
+    CMPQ CX, DX
+    CMOVQHI CX, DX
+
+    LZCNTQ DX, DX
+    NEGQ DX
+    ADDQ $64, DX
+    MOVB DX, (AX)(DI*1)
+
+    ADDQ $256, BX
+    INCQ DI
+    CMPQ DI, $numMiniBlocks
+    JNE loop
+    VZEROUPPER
+    RET
+
+// miniBlockPackInt64Default is the generic implementation of the algorithm to
+// pack 64 bit integers into values of a given bit width (<=64).
+//
+// This algorithm is much slower than the vectorized versions, but is useful
+// as a reference implementation to run the tests against, and as fallback when
+// the code runs on a CPU which does not support the AVX2 instruction set.
+//
+// func miniBlockPackInt64Default(dst *byte, src *[miniBlockSize]int64, bitWidth uint)
+TEXT ·miniBlockPackInt64Default(SB), NOSPLIT, $0-24
+    MOVQ dst+0(FP), AX
+    MOVQ src+8(FP), BX
+    MOVQ bitWidth+16(FP), R10
+
+    XORQ R11, R11 // zero
+    XORQ DI, DI // bitOffset
+    XORQ SI, SI
+loop:
+    MOVQ DI, CX
+    MOVQ DI, DX
+
+    ANDQ $0b111111, CX // bitOffset % 64
+    SHRQ $6, DX        // bitOffset / 64
+
+    MOVQ (BX)(SI*8), R8
+    MOVQ R8, R9
+    SHLQ CX, R8
+    NEGQ CX
+    ADDQ $64, CX
+    SHRQ CX, R9
+    CMPQ CX, $64
+    CMOVQEQ R11, R9 // needed because shifting by more than 63 is undefined
+
+    ORQ R8, 0(AX)(DX*8)
+    ORQ R9, 8(AX)(DX*8)
+
+    ADDQ R10, DI
+    INCQ SI
+    CMPQ SI, $miniBlockSize
+    JNE loop
+    RET
+
+// func miniBlockPackInt64x1bitAVX2(dst *byte, src *[miniBlockSize]int64)
+TEXT ·miniBlockPackInt64x1bitAVX2(SB), NOSPLIT, $0-16
+    MOVQ dst+0(FP), AX
+    MOVQ src+8(FP), BX
+
+    VMOVDQU 0(BX), Y0
+    VMOVDQU 32(BX), Y1
+    VMOVDQU 64(BX), Y2
+    VMOVDQU 96(BX), Y3
+    VMOVDQU 128(BX), Y4
+    VMOVDQU 160(BX), Y5
+    VMOVDQU 192(BX), Y6
+    VMOVDQU 224(BX), Y7
+
+    VPSLLQ $63, Y0, Y0
+    VPSLLQ $63, Y1, Y1
+    VPSLLQ $63, Y2, Y2
+    VPSLLQ $63, Y3, Y3
+    VPSLLQ $63, Y4, Y4
+    VPSLLQ $63, Y5, Y5
+    VPSLLQ $63, Y6, Y6
+    VPSLLQ $63, Y7, Y7
+
+    VMOVMSKPD Y0, R8
+    VMOVMSKPD Y1, R9
+    VMOVMSKPD Y2, R10
+    VMOVMSKPD Y3, R11
+    VMOVMSKPD Y4, R12
+    VMOVMSKPD Y5, R13
+    VMOVMSKPD Y6, R14
+    VMOVMSKPD Y7, R15
+
+    SHLL $4, R9
+    SHLL $8, R10
+    SHLL $12, R11
+    SHLL $16, R12
+    SHLL $20, R13
+    SHLL $24, R14
+    SHLL $28, R15
+
+    ORL R9, R8
+    ORL R11, R10
+    ORL R13, R12
+    ORL R15, R14
+    ORL R10, R8
+    ORL R14, R12
+    ORL R12, R8
+
+    MOVL R8, (AX)
+    VZEROUPPER
+    RET
+
+// func miniBlockPackInt64x2bitsAVX2(dst *byte, src *[miniBlockSize]int64)
+TEXT ·miniBlockPackInt64x2bitsAVX2(SB), NOSPLIT, $0-16
+    MOVQ dst+0(FP), AX
+    MOVQ src+8(FP), BX
+
+    VMOVDQU 0(BX), Y8
+    VMOVDQU 32(BX), Y9
+    VMOVDQU 64(BX), Y10
+    VMOVDQU 96(BX), Y11
+    VMOVDQU 128(BX), Y12
+    VMOVDQU 160(BX), Y13
+    VMOVDQU 192(BX), Y14
+    VMOVDQU 224(BX), Y15
+
+    VPSLLQ $63, Y8, Y0
+    VPSLLQ $63, Y9, Y1
+    VPSLLQ $63, Y10, Y2
+    VPSLLQ $63, Y11, Y3
+    VPSLLQ $63, Y12, Y4
+    VPSLLQ $63, Y13, Y5
+    VPSLLQ $63, Y14, Y6
+    VPSLLQ $63, Y15, Y7
+
+    VMOVMSKPD Y0, R8
+    VMOVMSKPD Y1, R9
+    VMOVMSKPD Y2, R10
+    VMOVMSKPD Y3, R11
+    VMOVMSKPD Y4, R12
+    VMOVMSKPD Y5, R13
+    VMOVMSKPD Y6, R14
+    VMOVMSKPD Y7, R15
+
+    SHLQ $4, R9
+    SHLQ $8, R10
+    SHLQ $12, R11
+    SHLQ $16, R12
+    SHLQ $20, R13
+    SHLQ $24, R14
+    SHLQ $28, R15
+
+    ORQ R9, R8
+    ORQ R11, R10
+    ORQ R13, R12
+    ORQ R15, R14
+    ORQ R10, R8
+    ORQ R14, R12
+    ORQ R12, R8
+
+    MOVQ $0x5555555555555555, CX // 0b010101...
+    PDEPQ CX, R8, CX
+
+    VPSLLQ $62, Y8, Y8
+    VPSLLQ $62, Y9, Y9
+    VPSLLQ $62, Y10, Y10
+    VPSLLQ $62, Y11, Y11
+    VPSLLQ $62, Y12, Y12
+    VPSLLQ $62, Y13, Y13
+    VPSLLQ $62, Y14, Y14
+    VPSLLQ $62, Y15, Y15
+
+    VMOVMSKPD Y8, R8
+    VMOVMSKPD Y9, R9
+    VMOVMSKPD Y10, R10
+    VMOVMSKPD Y11, R11
+    VMOVMSKPD Y12, R12
+    VMOVMSKPD Y13, R13
+    VMOVMSKPD Y14, R14
+    VMOVMSKPD Y15, R15
+
+    SHLQ $4, R9
+    SHLQ $8, R10
+    SHLQ $12, R11
+    SHLQ $16, R12
+    SHLQ $20, R13
+    SHLQ $24, R14
+    SHLQ $28, R15
+
+    ORQ R9, R8
+    ORQ R11, R10
+    ORQ R13, R12
+    ORQ R15, R14
+    ORQ R10, R8
+    ORQ R14, R12
+    ORQ R12, R8
+
+    MOVQ $0xAAAAAAAAAAAAAAAA, DX // 0b101010...
+    PDEPQ DX, R8, DX
+    ORQ DX, CX
+    MOVQ CX, (AX)
+    VZEROUPPER
+    RET
+
+// func miniBlockPackInt64x64bitsAVX2(dst *byte, src *[miniBlockSize]int64)
+TEXT ·miniBlockPackInt64x64bitsAVX2(SB), NOSPLIT, $0-16
+    MOVQ dst+0(FP), AX
+    MOVQ src+8(FP), BX
+    VMOVDQU 0(BX), Y0
+    VMOVDQU 32(BX), Y1
+    VMOVDQU 64(BX), Y2
+    VMOVDQU 96(BX), Y3
+    VMOVDQU 128(BX), Y4
+    VMOVDQU 160(BX), Y5
+    VMOVDQU 192(BX), Y6
+    VMOVDQU 224(BX), Y7
+    VMOVDQU Y0, 0(AX)
+    VMOVDQU Y1, 32(AX)
+    VMOVDQU Y2, 64(AX)
+    VMOVDQU Y3, 96(AX)
+    VMOVDQU Y4, 128(AX)
+    VMOVDQU Y5, 160(AX)
+    VMOVDQU Y6, 192(AX)
+    VMOVDQU Y7, 224(AX)
+    VZEROUPPER
+    RET
diff --git a/encoding/delta/binary_packed_purego.go b/encoding/delta/binary_packed_purego.go
new file mode 100644
index 0000000..66d24d9
--- /dev/null
+++ b/encoding/delta/binary_packed_purego.go
@@ -0,0 +1,49 @@
+//go:build purego || !amd64
+
+package delta
+
+import (
+	"encoding/binary"
+)
+
+func miniBlockPackInt32(dst []byte, src *[miniBlockSize]int32, bitWidth uint) {
+	bitMask := uint32(1<<bitWidth) - 1
+	bitOffset := uint(0)
+
+	for _, value := range src {
+		i := bitOffset / 32
+		j := bitOffset % 32
+
+		lo := binary.LittleEndian.Uint32(dst[(i+0)*4:])
+		hi := binary.LittleEndian.Uint32(dst[(i+1)*4:])
+
+		lo |= (uint32(value) & bitMask) << j
+		hi |= (uint32(value) >> (32 - j))
+
+		binary.LittleEndian.PutUint32(dst[(i+0)*4:], lo)
+		binary.LittleEndian.PutUint32(dst[(i+1)*4:], hi)
+
+		bitOffset += bitWidth
+	}
+}
+
+func miniBlockPackInt64(dst []byte, src *[miniBlockSize]int64, bitWidth uint) {
+	bitMask := uint64(1<<bitWidth) - 1
+	bitOffset := uint(0)
+
+	for _, value := range src {
+		i := bitOffset / 64
+		j := bitOffset % 64
+
+		lo := binary.LittleEndian.Uint64(dst[(i+0)*8:])
+		hi := binary.LittleEndian.Uint64(dst[(i+1)*8:])
+
+		lo |= (uint64(value) & bitMask) << j
+		hi |= (uint64(value) >> (64 - j))
+
+		binary.LittleEndian.PutUint64(dst[(i+0)*8:], lo)
+		binary.LittleEndian.PutUint64(dst[(i+1)*8:], hi)
+
+		bitOffset += bitWidth
+	}
+}
diff --git a/encoding/delta/byte_array.go b/encoding/delta/byte_array.go
index 83ec707..98b9a3e 100644
--- a/encoding/delta/byte_array.go
+++ b/encoding/delta/byte_array.go
@@ -3,13 +3,16 @@ package delta
 import (
 	"bytes"
 	"fmt"
-	"math"
 
 	"github.com/segmentio/parquet-go/encoding"
 	"github.com/segmentio/parquet-go/encoding/plain"
 	"github.com/segmentio/parquet-go/format"
 )
 
+const (
+	maxLinearSearchPrefixLength = 128 // arbitrary
+)
+
 type ByteArrayEncoding struct {
 	encoding.NotSupported
 }
@@ -23,27 +26,64 @@ func (e *ByteArrayEncoding) Encoding() format.Encoding {
 }
 
 func (e *ByteArrayEncoding) EncodeByteArray(dst, src []byte) ([]byte, error) {
-	lastOffset := int32(0)
+	prefix := getInt32Buffer()
+	defer putInt32Buffer(prefix)
+
+	length := getInt32Buffer()
+	defer putInt32Buffer(length)
 
-	offset := getInt32Buffer()
-	defer putInt32Buffer(offset)
+	totalSize := 0
+	lastValue := ([]byte)(nil)
 
-	err := plain.RangeByteArrays(src, func(value []byte) error {
-		offset.values = append(offset.values, lastOffset)
-		lastOffset += 4 + int32(len(value))
-		return nil
-	})
-	if err != nil {
-		return dst[:0], encoding.Error(e, err)
+	for i := 0; i < len(src); {
+		r := len(src) - i
+		if r < plain.ByteArrayLengthSize {
+			return dst[:0], plain.ErrTooShort(r)
+		}
+		n := plain.ByteArrayLength(src[i:])
+		i += plain.ByteArrayLengthSize
+		r -= plain.ByteArrayLengthSize
+		if n > r {
+			return dst[:0], plain.ErrTooShort(n)
+		}
+		if n > plain.MaxByteArrayLength {
+			return dst[:0], plain.ErrTooLarge(n)
+		}
+		v := src[i : i+n : i+n]
+		p := 0
+
+		if len(v) <= maxLinearSearchPrefixLength {
+			p = linearSearchPrefixLength(lastValue, v)
+		} else {
+			p = binarySearchPrefixLength(len(lastValue)/2, lastValue, v)
+		}
+
+		prefix.values = append(prefix.values, int32(p))
+		length.values = append(length.values, int32(n-p))
+		lastValue = v
+		totalSize += n - p
+		i += n
 	}
 
-	return e.encode(dst[:0], len(offset.values), func(i int) []byte {
-		j := int(offset.values[i])
-		k := j + plain.ByteArrayLength(src[j:])
-		j += 4
-		k += 4
-		return src[j:k:k]
-	})
+	dst = dst[:0]
+	dst = encodeInt32(dst, prefix.values)
+	dst = encodeInt32(dst, length.values)
+	dst = resize(dst, len(dst)+totalSize)
+
+	b := dst[len(dst)-totalSize:]
+	i := plain.ByteArrayLengthSize
+	j := 0
+
+	_ = length.values[:len(prefix.values)]
+
+	for k, p := range prefix.values {
+		n := p + length.values[k]
+		j += copy(b[j:], src[i+int(p):i+int(n)])
+		i += plain.ByteArrayLengthSize
+		i += int(n)
+	}
+
+	return dst, nil
 }
 
 func (e *ByteArrayEncoding) EncodeFixedLenByteArray(dst, src []byte, size int) ([]byte, error) {
@@ -57,45 +97,40 @@ func (e *ByteArrayEncoding) EncodeFixedLenByteArray(dst, src []byte, size int) (
 	if (len(src) % size) != 0 {
 		return dst[:0], encoding.ErrEncodeInvalidInputSize(e, "FIXED_LEN_BYTE_ARRAY", len(src))
 	}
-	return e.encode(dst[:0], len(src)/size, func(i int) []byte {
-		j := (i + 0) * size
-		k := (i + 1) * size
-		return src[j:k:k]
-	})
-}
 
-func (e *ByteArrayEncoding) encode(dst []byte, numValues int, valueAt func(int) []byte) ([]byte, error) {
 	prefix := getInt32Buffer()
 	defer putInt32Buffer(prefix)
 
 	length := getInt32Buffer()
 	defer putInt32Buffer(length)
 
-	var lastValue []byte
-	for i := 0; i < numValues; i++ {
-		value := valueAt(i)
-		if len(value) > math.MaxInt32 {
-			return dst, encoding.Errorf(e, "byte array of length %d is too large to be encoded", len(value))
-		}
-		n := prefixLength(lastValue, value)
-		prefix.values = append(prefix.values, int32(n))
-		length.values = append(length.values, int32(len(value)-n))
-		lastValue = value
+	totalSize := 0
+	lastValue := ([]byte)(nil)
+
+	for i := size; i <= len(src); i += size {
+		v := src[i-size : i : i]
+		p := linearSearchPrefixLength(lastValue, v)
+		n := size - p
+		prefix.values = append(prefix.values, int32(p))
+		length.values = append(length.values, int32(n))
+		lastValue = v
+		totalSize += n
 	}
 
-	var binpack BinaryPackedEncoding
-	var err error
-	dst, err = binpack.encodeInt32(dst, prefix.values)
-	if err != nil {
-		return dst, err
-	}
-	dst, err = binpack.encodeInt32(dst, length.values)
-	if err != nil {
-		return dst, err
-	}
-	for i, p := range prefix.values {
-		dst = append(dst, valueAt(i)[p:]...)
+	dst = dst[:0]
+	dst = encodeInt32(dst, prefix.values)
+	dst = encodeInt32(dst, length.values)
+	dst = resize(dst, len(dst)+totalSize)
+
+	b := dst[len(dst)-totalSize:]
+	i := 0
+	j := 0
+
+	for _, p := range prefix.values {
+		j += copy(b[j:], src[i+int(p):i+size])
+		i += size
 	}
+
 	return dst, nil
 }
 
@@ -187,8 +222,11 @@ func (e *ByteArrayEncoding) decode(src []byte, observe func(prefix, suffix []byt
 	return nil
 }
 
-func prefixLength(base, data []byte) int {
-	return binarySearchPrefixLength(len(base)/2, base, data)
+func linearSearchPrefixLength(base, data []byte) (n int) {
+	for n < len(base) && n < len(data) && base[n] == data[n] {
+		n++
+	}
+	return n
 }
 
 func binarySearchPrefixLength(max int, base, data []byte) int {
diff --git a/encoding/delta/delta.go b/encoding/delta/delta.go
index e2168a1..25f235b 100644
--- a/encoding/delta/delta.go
+++ b/encoding/delta/delta.go
@@ -2,10 +2,7 @@ package delta
 
 import (
 	"sync"
-)
-
-const (
-	defaultBufferSize = 4096
+	"unsafe"
 )
 
 type int32Buffer struct {
@@ -36,3 +33,11 @@ func getInt32Buffer() *int32Buffer {
 func putInt32Buffer(b *int32Buffer) {
 	int32BufferPool.Put(b)
 }
+
+func bytesToInt32(b []byte) []int32 {
+	return unsafe.Slice(*(**int32)(unsafe.Pointer(&b)), len(b)/4)
+}
+
+func bytesToInt64(b []byte) []int64 {
+	return unsafe.Slice(*(**int64)(unsafe.Pointer(&b)), len(b)/8)
+}
diff --git a/encoding/delta/length_byte_array.go b/encoding/delta/length_byte_array.go
index 72380f4..bddd3ec 100644
--- a/encoding/delta/length_byte_array.go
+++ b/encoding/delta/length_byte_array.go
@@ -1,9 +1,6 @@
 package delta
 
 import (
-	"fmt"
-	"math"
-
 	"github.com/segmentio/parquet-go/encoding"
 	"github.com/segmentio/parquet-go/encoding/plain"
 	"github.com/segmentio/parquet-go/format"
@@ -22,33 +19,51 @@ func (e *LengthByteArrayEncoding) Encoding() format.Encoding {
 }
 
 func (e *LengthByteArrayEncoding) EncodeByteArray(dst, src []byte) ([]byte, error) {
-	return e.encodeByteArray(dst[:0], src)
+	dst, err := e.encodeByteArray(dst[:0], src)
+	if err != nil {
+		err = encoding.Error(e, err)
+	}
+	return dst, err
 }
 
 func (e *LengthByteArrayEncoding) encodeByteArray(dst, src []byte) ([]byte, error) {
-	b := getInt32Buffer()
-	defer putInt32Buffer(b)
+	length := getInt32Buffer()
+	defer putInt32Buffer(length)
+
+	totalSize := 0
 
-	err := plain.RangeByteArrays(src, func(value []byte) error {
-		if len(value) > math.MaxInt32 {
-			return fmt.Errorf("byte array of length %d is too large to be encoded", len(value))
+	for i := 0; i < len(src); {
+		r := len(src) - i
+		if r < plain.ByteArrayLengthSize {
+			return dst, plain.ErrTooShort(r)
 		}
-		b.values = append(b.values, int32(len(value)))
-		return nil
-	})
-	if err != nil {
-		return dst, encoding.Error(e, err)
+		n := plain.ByteArrayLength(src[i:])
+		i += plain.ByteArrayLengthSize
+		r -= plain.ByteArrayLengthSize
+		if n > r {
+			return dst, plain.ErrTooShort(n)
+		}
+		if n > plain.MaxByteArrayLength {
+			return dst, plain.ErrTooLarge(n)
+		}
+		length.values = append(length.values, int32(n))
+		totalSize += n
+		i += n
 	}
 
-	binpack := BinaryPackedEncoding{}
-	dst, err = binpack.encodeInt32(dst, b.values)
-	if err != nil {
-		return dst, encoding.Error(e, err)
+	dst = encodeInt32(dst, length.values)
+	dst = resize(dst, len(dst)+totalSize)
+
+	b := dst[len(dst)-totalSize:]
+	i := plain.ByteArrayLengthSize
+	j := 0
+
+	for _, n := range length.values {
+		j += copy(b[j:], src[i:i+int(n)])
+		i += plain.ByteArrayLengthSize
+		i += int(n)
 	}
-	plain.RangeByteArrays(src, func(value []byte) error {
-		dst = append(dst, value...)
-		return nil
-	})
+
 	return dst, nil
 }
 
diff --git a/encoding/plain/plain.go b/encoding/plain/plain.go
index 5a29fc6..e628e83 100644
--- a/encoding/plain/plain.go
+++ b/encoding/plain/plain.go
@@ -16,6 +16,7 @@ import (
 
 const (
 	ByteArrayLengthSize = 4
+	MaxByteArrayLength  = math.MaxInt32
 )
 
 type Encoding struct {
@@ -70,7 +71,7 @@ func (e *Encoding) EncodeDouble(dst, src []byte) ([]byte, error) {
 }
 
 func (e *Encoding) EncodeByteArray(dst []byte, src []byte) ([]byte, error) {
-	if err := RangeByteArrays(src, func([]byte) error { return nil }); err != nil {
+	if err := ValidateByteArrays(src); err != nil {
 		return dst[:0], encoding.Error(e, err)
 	}
 	return append(dst[:0], src...), nil
@@ -123,7 +124,7 @@ func (e *Encoding) DecodeDouble(dst, src []byte) ([]byte, error) {
 }
 
 func (e *Encoding) DecodeByteArray(dst, src []byte) ([]byte, error) {
-	if err := RangeByteArrays(src, func([]byte) error { return nil }); err != nil {
+	if err := ValidateByteArrays(src); err != nil {
 		return dst[:0], encoding.Error(e, err)
 	}
 	return append(dst[:0], src...), nil
@@ -231,6 +232,25 @@ func PutByteArrayLength(b []byte, n int) {
 	binary.LittleEndian.PutUint32(b, uint32(n))
 }
 
+type status int
+
+const (
+	ok status = iota
+	errTooShort
+	errTooLarge
+)
+
+func ValidateByteArrays(b []byte) error {
+	switch validateByteArrays(b) {
+	case errTooShort:
+		return ErrTooShort(len(b))
+	case errTooLarge:
+		return ErrTooLarge(len(b))
+	default: // ok
+		return nil
+	}
+}
+
 func RangeByteArrays(b []byte, do func([]byte) error) (err error) {
 	for len(b) > 0 {
 		var v []byte
@@ -245,12 +265,24 @@ func RangeByteArrays(b []byte, do func([]byte) error) (err error) {
 }
 
 func NextByteArray(b []byte) (v, r []byte, err error) {
-	if len(b) < 4 {
-		return nil, b, fmt.Errorf("input of length %d is too short to contain a PLAIN encoded byte array: %w", len(b), io.ErrUnexpectedEOF)
+	if len(b) < ByteArrayLengthSize {
+		return nil, b, ErrTooShort(len(b))
+	}
+	n := ByteArrayLength(b)
+	if n > (len(b) - ByteArrayLengthSize) {
+		return nil, b, ErrTooShort(len(b))
 	}
-	n := 4 + int(binary.LittleEndian.Uint32(b))
-	if n > len(b) {
-		return nil, b, fmt.Errorf("input of length %d is too short to contain a PLAIN encoded byte array of length %d: %w", len(b)-4, n-4, io.ErrUnexpectedEOF)
+	if n > MaxByteArrayLength {
+		return nil, b, ErrTooLarge(n)
 	}
-	return b[4:n:n], b[n:len(b):len(b)], nil
+	n += ByteArrayLengthSize
+	return b[ByteArrayLengthSize:n:n], b[n:len(b):len(b)], nil
+}
+
+func ErrTooShort(length int) error {
+	return fmt.Errorf("input of length %d is too short to contain a PLAIN encoded byte array value: %w", length, io.ErrUnexpectedEOF)
+}
+
+func ErrTooLarge(length int) error {
+	return fmt.Errorf("byte array of length %d is too large to be encoded", length)
 }
diff --git a/encoding/plain/plain_amd64.go b/encoding/plain/plain_amd64.go
new file mode 100644
index 0000000..96efec1
--- /dev/null
+++ b/encoding/plain/plain_amd64.go
@@ -0,0 +1,6 @@
+//go:build !purego
+
+package plain
+
+//go:noescape
+func validateByteArrays([]byte) status
diff --git a/encoding/plain/plain_amd64.s b/encoding/plain/plain_amd64.s
new file mode 100644
index 0000000..b8a6067
--- /dev/null
+++ b/encoding/plain/plain_amd64.s
@@ -0,0 +1,46 @@
+//go:build !purego
+
+#include "textflag.h"
+
+// validateByteArrays is implemented in assembly because it is the bottleneck in
+// many cases when encoding byte arrays. This version of the function yields
+// ~50% better throughput than the code generated by the Go compiler.
+//
+// func validateByteArrays([]byte) status
+TEXT ·validateByteArrays(SB), NOSPLIT, $0-32
+    MOVQ arg+0(FP), AX
+    MOVQ arg+8(FP), BX
+
+    CMPQ BX, $0
+    JE done
+
+    XORQ SI, SI
+loop:
+    MOVQ BX, CX
+    SUBQ SI, CX
+    CMPQ CX, $4
+    JB errTooShort
+
+    MOVLQZX (AX)(SI*1), DX
+    ADDQ $4, SI
+    SUBQ $4, CX
+    CMPQ DX, CX
+    JA errTooShort
+    CMPQ DX, $0x7FFFFFFF
+    JA errTooLarge
+
+    ADDQ DX, SI
+    CMPQ SI, BX
+    JNE loop
+done:
+    MOVQ $0, CX
+    MOVQ CX, ret+24(FP)
+    RET
+errTooShort:
+    MOVQ $1, CX
+    MOVQ CX, ret+24(FP)
+    RET
+errTooLarge:
+    MOVQ $2, CX
+    MOVQ CX, ret+32(FP)
+    RET
diff --git a/encoding/plain/plain_purego.go b/encoding/plain/plain_purego.go
new file mode 100644
index 0000000..4b4e7ad
--- /dev/null
+++ b/encoding/plain/plain_purego.go
@@ -0,0 +1,23 @@
+//go:build purego || !amd64
+
+package plain
+
+func validateByteArrays(b []byte) status {
+	for i := 0; i < len(b); {
+		r := len(b) - i
+		if r < ByteArrayLengthSize {
+			return errTooShort
+		}
+		n := ByteArrayLength(b[i:])
+		i += ByteArrayLengthSize
+		r -= ByteArrayLengthSize
+		if n > r {
+			return errTooShort
+		}
+		if n > MaxByteArrayLength {
+			return errTooLarge
+		}
+		i += n
+	}
+	return ok
+}
diff --git a/go.mod b/go.mod
index c149187..690e0d3 100644
--- a/go.mod
+++ b/go.mod
@@ -6,7 +6,7 @@ require (
 	github.com/andybalholm/brotli v1.0.3
 	github.com/google/uuid v1.3.0
 	github.com/hexops/gotextdiff v1.0.3
-	github.com/klauspost/compress v1.15.2
+	github.com/klauspost/compress v1.15.5
 	github.com/olekukonko/tablewriter v0.0.5
 	github.com/pierrec/lz4/v4 v4.1.9
 	github.com/segmentio/encoding v0.3.5
diff --git a/go.sum b/go.sum
index e5d4daa..dd857c4 100644
--- a/go.sum
+++ b/go.sum
@@ -4,8 +4,8 @@ github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
 github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
 github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
 github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
-github.com/klauspost/compress v1.15.2 h1:3WH+AG7s2+T8o3nrM/8u2rdqUEcQhmga7smjrT41nAw=
-github.com/klauspost/compress v1.15.2/go.mod h1:PhcZ0MbTNciWF3rruxRgKxI5NkcHHrHUDtV4Yw2GlzU=
+github.com/klauspost/compress v1.15.5 h1:qyCLMz2JCrKADihKOh9FxnW3houKeNsp2h5OEz0QSEA=
+github.com/klauspost/compress v1.15.5/go.mod h1:PhcZ0MbTNciWF3rruxRgKxI5NkcHHrHUDtV4Yw2GlzU=
 github.com/mattn/go-runewidth v0.0.9 h1:Lm995f3rfxdpd6TSmuVCHVb/QhupuXlYr8sCI/QdE+0=
 github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI=
 github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec=
diff --git a/internal/quick/quick.go b/internal/quick/quick.go
new file mode 100644
index 0000000..bce7ef0
--- /dev/null
+++ b/internal/quick/quick.go
@@ -0,0 +1,174 @@
+package quick
+
+import (
+	"fmt"
+	"math/rand"
+	"reflect"
+	"strings"
+)
+
+var DefaultConfig = Config{
+	Sizes: []int{
+		0, 1, 2, 3, 4, 5, 6, 7, 8, 9,
+		10, 11, 12, 13, 14, 15, 16, 17, 18, 19,
+		20, 21, 22, 23, 24, 25, 26, 27, 28, 29,
+		30, 31, 32, 33, 34, 35, 36, 37, 38, 39,
+		99, 100, 101,
+		127, 128, 129,
+		255, 256, 257,
+		1000, 1023, 1024, 1025,
+		2000, 2095, 2048, 2049,
+		4000, 4095, 4096, 4097,
+	},
+	Seed: 0,
+}
+
+// Check is inspired by the standard quick.Check package, but enhances the
+// API and tests arrays of larger sizes than the maximum of 50 hardcoded in
+// testing/quick.
+func Check(f interface{}) error {
+	return DefaultConfig.Check(f)
+}
+
+type Config struct {
+	Sizes []int
+	Seed  int64
+}
+
+func (c *Config) Check(f interface{}) error {
+	v := reflect.ValueOf(f)
+	r := rand.New(rand.NewSource(c.Seed))
+	t := v.Type().In(0)
+
+	makeValue := MakeValueFuncOf(t.Elem())
+	makeArray := func(n int) reflect.Value {
+		array := reflect.MakeSlice(t, n, n)
+		for i := 0; i < n; i++ {
+			makeValue(array.Index(i), r)
+		}
+		return array
+	}
+
+	if makeArray == nil {
+		panic("cannot run quick check on function with input of type " + v.Type().In(0).String())
+	}
+
+	for _, n := range c.Sizes {
+		for i := 0; i < 3; i++ {
+			in := makeArray(n)
+			ok := v.Call([]reflect.Value{in})
+			if !ok[0].Bool() {
+				return fmt.Errorf("test #%d: failed on input of size %d: %#v\n", i+1, n, in.Interface())
+			}
+		}
+	}
+	return nil
+
+}
+
+type MakeValueFunc func(reflect.Value, *rand.Rand)
+
+func MakeValueFuncOf(t reflect.Type) MakeValueFunc {
+	switch t.Kind() {
+	case reflect.Bool:
+		return func(v reflect.Value, r *rand.Rand) {
+			v.SetBool((r.Int() % 2) != 0)
+		}
+
+	case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
+		return func(v reflect.Value, r *rand.Rand) {
+			v.SetInt(r.Int63())
+		}
+
+	case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, reflect.Uintptr:
+		return func(v reflect.Value, r *rand.Rand) {
+			v.SetUint(r.Uint64())
+		}
+
+	case reflect.Float32, reflect.Float64:
+		return func(v reflect.Value, r *rand.Rand) {
+			v.SetFloat(r.Float64())
+		}
+
+	case reflect.String:
+		return func(v reflect.Value, r *rand.Rand) {
+			const characters = "1234567890qwertyuiopasdfghjklzxcvbnm"
+			s := new(strings.Builder)
+			n := r.Intn(10)
+			for i := 0; i < n; i++ {
+				s.WriteByte(characters[i])
+			}
+			v.SetString(s.String())
+		}
+
+	case reflect.Array:
+		makeElem := MakeValueFuncOf(t.Elem())
+		return func(v reflect.Value, r *rand.Rand) {
+			for i, n := 0, v.Len(); i < n; i++ {
+				makeElem(v.Index(i), r)
+			}
+		}
+
+	case reflect.Slice:
+		switch e := t.Elem(); e.Kind() {
+		case reflect.Uint8:
+			return func(v reflect.Value, r *rand.Rand) {
+				b := make([]byte, r.Intn(50))
+				r.Read(b)
+				v.SetBytes(b)
+			}
+		default:
+			makeElem := MakeValueFuncOf(t.Elem())
+			return func(v reflect.Value, r *rand.Rand) {
+				n := r.Intn(10)
+				s := reflect.MakeSlice(t, n, n)
+				for i := 0; i < n; i++ {
+					makeElem(s.Index(i), r)
+				}
+				v.Set(s)
+			}
+		}
+
+	case reflect.Map:
+		makeKey := MakeValueFuncOf(t.Key())
+		makeElem := MakeValueFuncOf(t.Elem())
+		return func(v reflect.Value, r *rand.Rand) {
+			m := reflect.MakeMap(t)
+			n := r.Intn(10)
+			k := reflect.New(t.Key()).Elem()
+			e := reflect.New(t.Elem()).Elem()
+			for i := 0; i < n; i++ {
+				makeKey(k, r)
+				makeElem(e, r)
+				m.SetMapIndex(k, e)
+			}
+			v.Set(m)
+		}
+
+	case reflect.Struct:
+		fields := make([]reflect.StructField, 0, t.NumField())
+		makeValues := make([]MakeValueFunc, 0, cap(fields))
+		for i, n := 0, cap(fields); i < n; i++ {
+			if f := t.Field(i); f.PkgPath == "" { // skip unexported fields
+				fields = append(fields, f)
+				makeValues = append(makeValues, MakeValueFuncOf(f.Type))
+			}
+		}
+		return func(v reflect.Value, r *rand.Rand) {
+			for i := range fields {
+				makeValues[i](v.FieldByIndex(fields[i].Index), r)
+			}
+		}
+
+	case reflect.Ptr:
+		t = t.Elem()
+		makeValue := MakeValueFuncOf(t)
+		return func(v reflect.Value, r *rand.Rand) {
+			v.Set(reflect.New(t))
+			makeValue(v.Elem(), r)
+		}
+
+	default:
+		panic("quick.Check does not support test values of type " + t.String())
+	}
+}
diff --git a/schema.go b/schema.go
index e985f7b..cd587fb 100644
--- a/schema.go
+++ b/schema.go
@@ -56,6 +56,7 @@ type Schema struct {
 //	decimal   | for int32, int64 and [n]byte types, use the parquet DECIMAL logical type
 //	date      | for int32 types use the DATE logical type
 //	timestamp | for int64 types use the TIMESTAMP logical type with millisecond precision
+//	split     | for float32/float64, use the BYTE_STREAM_SPLIT encoding
 //
 // The date logical type is an int32 value of the number of days since the unix epoch
 //
@@ -69,6 +70,10 @@ type Schema struct {
 // Invalid combination of struct tags and Go types, or repeating options will
 // cause the function to panic.
 //
+// As a special case, if the field tag is "-", the field is omitted from the schema
+// and the data will not be written into the parquet file(s).
+// Note that a field with name "-" can still be generated using the tag "-,".
+//
 // The schema name is the Go type name of the value.
 func SchemaOf(model interface{}) *Schema {
 	return schemaOf(dereference(reflect.TypeOf(model)))
@@ -314,10 +319,17 @@ func structFieldsOf(t reflect.Type) []reflect.StructField {
 
 func appendStructFields(t reflect.Type, fields []reflect.StructField, index []int, offset uintptr) []reflect.StructField {
 	for i, n := 0, t.NumField(); i < n; i++ {
+		f := t.Field(i)
+		if tag := f.Tag.Get("parquet"); tag != "" {
+			name, _ := split(tag)
+			if tag != "-," && name == "-" {
+				continue
+			}
+		}
+
 		fieldIndex := index[:len(index):len(index)]
 		fieldIndex = append(fieldIndex, i)
 
-		f := t.Field(i)
 		f.Offset += offset
 
 		if f.Anonymous {
@@ -391,7 +403,6 @@ func (f *structField) Value(base reflect.Value) reflect.Value {
 			return fieldByIndex(base, f.index)
 		}
 	}
-	return reflect.Value{}
 }
 
 func structFieldString(f reflect.StructField) string {
@@ -505,6 +516,14 @@ func makeStructField(f reflect.StructField) structField {
 				throwInvalidFieldTag(f, option)
 			}
 
+		case "split":
+			switch f.Type.Kind() {
+			case reflect.Float32, reflect.Float64:
+				setEncoding(&ByteStreamSplit)
+			default:
+				throwInvalidFieldTag(f, option)
+			}
+
 		case "list":
 			switch f.Type.Kind() {
 			case reflect.Slice:


Test patch:
diff --git a/bloom_test.go b/bloom_test.go
index 8ef0aa8..81f4c58 100644
--- a/bloom_test.go
+++ b/bloom_test.go
@@ -3,12 +3,12 @@ package parquet
 import (
 	"math/rand"
 	"testing"
-	"testing/quick"
 
 	"github.com/segmentio/parquet-go/bloom"
 	"github.com/segmentio/parquet-go/deprecated"
 	"github.com/segmentio/parquet-go/encoding/plain"
 	"github.com/segmentio/parquet-go/internal/bits"
+	"github.com/segmentio/parquet-go/internal/quick"
 )
 
 func TestSplitBlockFilter(t *testing.T) {
@@ -145,7 +145,7 @@ func TestSplitBlockFilter(t *testing.T) {
 
 	for _, test := range tests {
 		t.Run(test.scenario, func(t *testing.T) {
-			if err := quick.Check(test.function, nil); err != nil {
+			if err := quick.Check(test.function); err != nil {
 				t.Error(err)
 			}
 		})
diff --git a/buffer_go18_test.go b/buffer_go18_test.go
index 3d83e98..7848a52 100644
--- a/buffer_go18_test.go
+++ b/buffer_go18_test.go
@@ -9,7 +9,6 @@ import (
 	"math/rand"
 	"reflect"
 	"testing"
-	"testing/quick"
 
 	"github.com/segmentio/parquet-go"
 )
@@ -36,13 +35,13 @@ func TestGenericBuffer(t *testing.T) {
 	testGenericBuffer[nestedListColumn1](t)
 	testGenericBuffer[nestedListColumn](t)
 	testGenericBuffer[*contact](t)
+	testGenericBuffer[paddedBooleanColumn](t)
 }
 
 func testGenericBuffer[Row any](t *testing.T) {
-	var prng = rand.New(rand.NewSource(1))
 	var model Row
 	t.Run(reflect.TypeOf(model).Name(), func(t *testing.T) {
-		f := func(rows []Row) bool {
+		err := quickCheck(func(rows []Row) bool {
 			if len(rows) == 0 {
 				return true // TODO: fix support for parquet files with zero rows
 			}
@@ -51,8 +50,8 @@ func testGenericBuffer[Row any](t *testing.T) {
 				return false
 			}
 			return true
-		}
-		if err := quick.Check(f, &quick.Config{Rand: prng}); err != nil {
+		})
+		if err != nil {
 			t.Error(err)
 		}
 	})
@@ -110,6 +109,7 @@ func BenchmarkGenericBuffer(b *testing.B) {
 	benchmarkGenericBuffer[mapColumn](b)
 	benchmarkGenericBuffer[decimalColumn](b)
 	benchmarkGenericBuffer[contact](b)
+	benchmarkGenericBuffer[paddedBooleanColumn](b)
 }
 
 func benchmarkGenericBuffer[Row generator[Row]](b *testing.B) {
diff --git a/buffer_test.go b/buffer_test.go
index 77fa7e5..e1c4d62 100644
--- a/buffer_test.go
+++ b/buffer_test.go
@@ -10,7 +10,6 @@ import (
 	"sort"
 	"strconv"
 	"testing"
-	"testing/quick"
 
 	"github.com/segmentio/parquet-go"
 	"github.com/segmentio/parquet-go/encoding"
@@ -413,7 +412,7 @@ func TestBufferGenerateBloomFilters(t *testing.T) {
 		return true
 	}
 
-	if err := quick.Check(f, nil); err != nil {
+	if err := quickCheck(f); err != nil {
 		t.Error(err)
 	}
 }
diff --git a/column_test.go b/column_test.go
index b54db61..e2d7a7d 100644
--- a/column_test.go
+++ b/column_test.go
@@ -4,7 +4,6 @@ import (
 	"fmt"
 	"math/rand"
 	"testing"
-	"testing/quick"
 
 	"github.com/google/uuid"
 	"github.com/segmentio/parquet-go"
@@ -102,9 +101,7 @@ func TestColumnPageIndex(t *testing.T) {
 				},
 			} {
 				t.Run(test.scenario, func(t *testing.T) {
-					if err := quick.Check(test.function(t), &quick.Config{
-						Rand: rand.New(rand.NewSource(0)),
-					}); err != nil {
+					if err := quickCheck(test.function(t)); err != nil {
 						t.Error(err)
 					}
 				})
diff --git a/encoding/delta/binary_packed_amd64_test.go b/encoding/delta/binary_packed_amd64_test.go
new file mode 100644
index 0000000..d088531
--- /dev/null
+++ b/encoding/delta/binary_packed_amd64_test.go
@@ -0,0 +1,131 @@
+//go:build amd64 && !purego
+
+package delta
+
+import (
+	"testing"
+
+	"golang.org/x/sys/cpu"
+)
+
+func requireAVX2(t testing.TB) {
+	if !cpu.X86.HasAVX2 {
+		t.Skip("CPU does not support AVX2")
+	}
+}
+
+func TestBlockDeltaInt32AVX2(t *testing.T) {
+	requireAVX2(t)
+	testBlockDeltaInt32(t, blockDeltaInt32AVX2)
+}
+
+func TestBlockMinInt32AVX2(t *testing.T) {
+	requireAVX2(t)
+	testBlockMinInt32(t, blockMinInt32AVX2)
+}
+
+func TestBlockSubInt32AVX2(t *testing.T) {
+	requireAVX2(t)
+	testBlockSubInt32(t, blockSubInt32AVX2)
+}
+
+func TestBlockBitWidthsInt32AVX2(t *testing.T) {
+	requireAVX2(t)
+	testBlockBitWidthsInt32(t, blockBitWidthsInt32AVX2)
+}
+
+func TestMiniBlockPackInt32AVX2(t *testing.T) {
+	requireAVX2(t)
+	testMiniBlockPackInt32(t,
+		func(dst []byte, src *[miniBlockSize]int32, bitWidth uint) {
+			miniBlockPackInt32AVX2(&dst[0], src, bitWidth)
+		},
+	)
+}
+
+func BenchmarkBlockDeltaInt32AVX2(b *testing.B) {
+	requireAVX2(b)
+	benchmarkBlockDeltaInt32(b, blockDeltaInt32AVX2)
+}
+
+func BenchmarkBlockMinInt32AVX2(b *testing.B) {
+	requireAVX2(b)
+	benchmarkBlockMinInt32(b, blockMinInt32AVX2)
+}
+
+func BenchmarkBlockSubInt32AVX2(b *testing.B) {
+	requireAVX2(b)
+	benchmarkBlockSubInt32(b, blockSubInt32AVX2)
+}
+
+func BenchmarkBlockBitWidthsInt32AVX2(b *testing.B) {
+	requireAVX2(b)
+	benchmarkBlockBitWidthsInt32(b, blockBitWidthsInt32AVX2)
+}
+
+func BenchmarkMiniBlockPackInt32AVX2(b *testing.B) {
+	requireAVX2(b)
+	benchmarkMiniBlockPackInt32(b,
+		func(dst []byte, src *[miniBlockSize]int32, bitWidth uint) {
+			miniBlockPackInt32AVX2(&dst[0], src, bitWidth)
+		},
+	)
+}
+
+func TestBlockDeltaInt64AVX2(t *testing.T) {
+	requireAVX2(t)
+	testBlockDeltaInt64(t, blockDeltaInt64AVX2)
+}
+
+func TestBlockMinInt64AVX2(t *testing.T) {
+	requireAVX2(t)
+	testBlockMinInt64(t, blockMinInt64AVX2)
+}
+
+func TestBlockSubInt64AVX2(t *testing.T) {
+	requireAVX2(t)
+	testBlockSubInt64(t, blockSubInt64AVX2)
+}
+
+func TestBlockBitWidthsInt64AVX2(t *testing.T) {
+	requireAVX2(t)
+	testBlockBitWidthsInt64(t, blockBitWidthsInt64AVX2)
+}
+
+func TestMiniBlockPackInt64AVX2(t *testing.T) {
+	requireAVX2(t)
+	testMiniBlockPackInt64(t,
+		func(dst []byte, src *[miniBlockSize]int64, bitWidth uint) {
+			miniBlockPackInt64AVX2(&dst[0], src, bitWidth)
+		},
+	)
+}
+
+func BenchmarkBlockDeltaInt64AVX2(b *testing.B) {
+	requireAVX2(b)
+	benchmarkBlockDeltaInt64(b, blockDeltaInt64AVX2)
+}
+
+func BenchmarkBlockMinInt64AVX2(b *testing.B) {
+	requireAVX2(b)
+	benchmarkBlockMinInt64(b, blockMinInt64AVX2)
+}
+
+func BenchmarkBlockSubInt64AVX2(b *testing.B) {
+	requireAVX2(b)
+	benchmarkBlockSubInt64(b, blockSubInt64AVX2)
+}
+
+func BenchmarkBlockBitWidthsInt64AVX2(b *testing.B) {
+	requireAVX2(b)
+	benchmarkBlockBitWidthsInt64(b, blockBitWidthsInt64AVX2)
+}
+
+func BenchmarkMiniBlockPackInt64AVX2(b *testing.B) {
+	requireAVX2(b)
+	benchmarkMiniBlockPackInt64(b,
+		func(dst []byte, src *[miniBlockSize]int64, bitWidth uint) {
+			miniBlockPackInt64AVX2(&dst[0], src, bitWidth)
+		},
+	)
+}
diff --git a/encoding/delta/binary_packed_purego_test.go b/encoding/delta/binary_packed_purego_test.go
new file mode 100644
index 0000000..80e75c4
--- /dev/null
+++ b/encoding/delta/binary_packed_purego_test.go
@@ -0,0 +1,388 @@
+package delta
+
+import (
+	"bytes"
+	"fmt"
+	"math/bits"
+	"testing"
+)
+
+func maxLen32(miniBlock []int32) (maxLen int) {
+	for _, v := range miniBlock {
+		if n := bits.Len32(uint32(v)); n > maxLen {
+			maxLen = n
+		}
+	}
+	return maxLen
+}
+
+func maxLen64(miniBlock []int64) (maxLen int) {
+	for _, v := range miniBlock {
+		if n := bits.Len64(uint64(v)); n > maxLen {
+			maxLen = n
+		}
+	}
+	return maxLen
+}
+
+func TestBlockDeltaInt32(t *testing.T) {
+	testBlockDeltaInt32(t, blockDeltaInt32)
+}
+
+func testBlockDeltaInt32(t *testing.T, f func(*[blockSize]int32, int32) int32) {
+	t.Helper()
+	block := [blockSize]int32{}
+	for i := range block {
+		block[i] = int32(2 * (i + 1))
+	}
+	lastValue := f(&block, 0)
+	if lastValue != 2*blockSize {
+		t.Errorf("wrong last block value: want=%d got=%d", 2*blockSize, lastValue)
+	}
+	for i := range block {
+		j := int32(2 * (i + 0))
+		k := int32(2 * (i + 1))
+		if block[i] != (k - j) {
+			t.Errorf("wrong block delta at index %d: want=%d got=%d", i, k-j, block[i])
+		}
+	}
+}
+
+func TestBlockMinInt32(t *testing.T) {
+	testBlockMinInt32(t, blockMinInt32)
+}
+
+func testBlockMinInt32(t *testing.T, f func(*[blockSize]int32) int32) {
+	t.Helper()
+	block := [blockSize]int32{}
+	for i := range block {
+		block[i] = blockSize - int32(i)
+	}
+	if min := f(&block); min != 1 {
+		t.Errorf("wrong min block value: want=1 got=%d", min)
+	}
+}
+
+func TestBlockSubInt32(t *testing.T) {
+	testBlockSubInt32(t, blockSubInt32)
+}
+
+func testBlockSubInt32(t *testing.T, f func(*[blockSize]int32, int32)) {
+	t.Helper()
+	block := [blockSize]int32{}
+	for i := range block {
+		block[i] = int32(i)
+	}
+	f(&block, 1)
+	for i := range block {
+		if block[i] != int32(i-1) {
+			t.Errorf("wrong block value at index %d: want=%d got=%d", i, i-1, block[i])
+		}
+	}
+}
+
+func TestBlockBitWidthsInt32(t *testing.T) {
+	testBlockBitWidthsInt32(t, blockBitWidthsInt32)
+}
+
+func testBlockBitWidthsInt32(t *testing.T, f func(*[numMiniBlocks]byte, *[blockSize]int32)) {
+	t.Helper()
+	bitWidths := [numMiniBlocks]byte{}
+	block := [blockSize]int32{}
+	for i := range block {
+		block[i] = int32(i)
+	}
+	f(&bitWidths, &block)
+
+	want := [numMiniBlocks]byte{}
+	for i := range want {
+		j := (i + 0) * miniBlockSize
+		k := (i + 1) * miniBlockSize
+		want[i] = byte(maxLen32(block[j:k]))
+	}
+
+	if bitWidths != want {
+		t.Errorf("wrong bit widths: want=%d got=%d", want, bitWidths)
+	}
+}
+
+func TestMiniBlockPackInt32(t *testing.T) {
+	testMiniBlockPackInt32(t, miniBlockPackInt32)
+}
+
+func testMiniBlockPackInt32(t *testing.T, f func([]byte, *[miniBlockSize]int32, uint)) {
+	t.Helper()
+	for bitWidth := uint(1); bitWidth <= 32; bitWidth++ {
+		t.Run(fmt.Sprintf("bitWidth=%d", bitWidth), func(t *testing.T) {
+			got := [4*miniBlockSize + 32]byte{}
+			src := [miniBlockSize]int32{}
+			for i := range src {
+				src[i] = int32(i) & int32((1<<bitWidth)-1)
+			}
+
+			want := [4*miniBlockSize + 32]byte{}
+			bitOffset := uint(0)
+
+			for _, bits := range src {
+				for b := uint(0); b < bitWidth; b++ {
+					x := bitOffset / 8
+					y := bitOffset % 8
+					want[x] |= byte(((bits >> b) & 1) << y)
+					bitOffset++
+				}
+			}
+
+			f(got[:], &src, bitWidth)
+			n := (miniBlockSize * bitWidth) / 8
+
+			if !bytes.Equal(want[:n], got[:n]) {
+				t.Errorf("output mismatch: want=%08x got=%08x", want[:n], got[:n])
+			}
+		})
+	}
+}
+
+func BenchmarkBlockDeltaInt32(b *testing.B) {
+	benchmarkBlockDeltaInt32(b, blockDeltaInt32)
+}
+
+func benchmarkBlockDeltaInt32(b *testing.B, f func(*[blockSize]int32, int32) int32) {
+	b.SetBytes(4 * blockSize)
+	block := [blockSize]int32{}
+	for i := 0; i < b.N; i++ {
+		_ = f(&block, 0)
+	}
+}
+
+func BenchmarkBlockMinInt32(b *testing.B) {
+	benchmarkBlockMinInt32(b, blockMinInt32)
+}
+
+func benchmarkBlockMinInt32(b *testing.B, f func(*[blockSize]int32) int32) {
+	b.SetBytes(4 * blockSize)
+	block := [blockSize]int32{}
+	for i := 0; i < b.N; i++ {
+		_ = f(&block)
+	}
+}
+
+func BenchmarkBlockSubInt32(b *testing.B) {
+	benchmarkBlockSubInt32(b, blockSubInt32)
+}
+
+func benchmarkBlockSubInt32(b *testing.B, f func(*[blockSize]int32, int32)) {
+	b.SetBytes(4 * blockSize)
+	block := [blockSize]int32{}
+	for i := 0; i < b.N; i++ {
+		f(&block, 42)
+	}
+}
+
+func BenchmarkBlockBitWidthsInt32(b *testing.B) {
+	benchmarkBlockBitWidthsInt32(b, blockBitWidthsInt32)
+}
+
+func benchmarkBlockBitWidthsInt32(b *testing.B, f func(*[numMiniBlocks]byte, *[blockSize]int32)) {
+	b.SetBytes(4 * blockSize)
+	bitWidths := [numMiniBlocks]byte{}
+	block := [blockSize]int32{}
+	for i := 0; i < b.N; i++ {
+		f(&bitWidths, &block)
+	}
+}
+
+func BenchmarkMiniBlockPackInt32(b *testing.B) {
+	benchmarkMiniBlockPackInt32(b, miniBlockPackInt32)
+}
+
+func benchmarkMiniBlockPackInt32(b *testing.B, f func([]byte, *[miniBlockSize]int32, uint)) {
+	for bitWidth := uint(1); bitWidth <= 32; bitWidth++ {
+		b.Run(fmt.Sprintf("bitWidth=%d", bitWidth), func(b *testing.B) {
+			b.SetBytes(4 * miniBlockSize)
+			dst := [4*miniBlockSize + 32]byte{}
+			src := [miniBlockSize]int32{}
+			for i := 0; i < b.N; i++ {
+				f(dst[:], &src, bitWidth)
+			}
+		})
+	}
+}
+
+func TestBlockDeltaInt64(t *testing.T) {
+	testBlockDeltaInt64(t, blockDeltaInt64)
+}
+
+func testBlockDeltaInt64(t *testing.T, f func(*[blockSize]int64, int64) int64) {
+	t.Helper()
+	block := [blockSize]int64{}
+	for i := range block {
+		block[i] = int64(2 * (i + 1))
+	}
+	lastValue := f(&block, 0)
+	if lastValue != 2*blockSize {
+		t.Errorf("wrong last block value: want=%d got=%d", 2*blockSize, lastValue)
+	}
+	for i := range block {
+		j := int64(2 * (i + 0))
+		k := int64(2 * (i + 1))
+		if block[i] != (k - j) {
+			t.Errorf("wrong block delta at index %d: want=%d got=%d", i, k-j, block[i])
+		}
+	}
+}
+
+func TestBlockMinInt64(t *testing.T) {
+	testBlockMinInt64(t, blockMinInt64)
+}
+
+func testBlockMinInt64(t *testing.T, f func(*[blockSize]int64) int64) {
+	block := [blockSize]int64{}
+	for i := range block {
+		block[i] = blockSize - int64(i)
+	}
+	if min := f(&block); min != 1 {
+		t.Errorf("wrong min block value: want=1 got=%d", min)
+	}
+}
+
+func TestBlockSubInt64(t *testing.T) {
+	testBlockSubInt64(t, blockSubInt64)
+}
+
+func testBlockSubInt64(t *testing.T, f func(*[blockSize]int64, int64)) {
+	block := [blockSize]int64{}
+	for i := range block {
+		block[i] = int64(i)
+	}
+	f(&block, 1)
+	for i := range block {
+		if block[i] != int64(i-1) {
+			t.Errorf("wrong block value at index %d: want=%d got=%d", i, i-1, block[i])
+		}
+	}
+}
+
+func TestBlockBitWidthsInt64(t *testing.T) {
+	testBlockBitWidthsInt64(t, blockBitWidthsInt64)
+}
+
+func testBlockBitWidthsInt64(t *testing.T, f func(*[numMiniBlocks]byte, *[blockSize]int64)) {
+	bitWidths := [numMiniBlocks]byte{}
+	block := [blockSize]int64{}
+	for i := range block {
+		block[i] = int64(i)
+	}
+	f(&bitWidths, &block)
+
+	want := [numMiniBlocks]byte{}
+	for i := range want {
+		j := (i + 0) * miniBlockSize
+		k := (i + 1) * miniBlockSize
+		want[i] = byte(maxLen64(block[j:k]))
+	}
+
+	if bitWidths != want {
+		t.Errorf("wrong bit widths: want=%d got=%d", want, bitWidths)
+	}
+}
+
+func TestMiniBlockPackInt64(t *testing.T) {
+	testMiniBlockPackInt64(t, miniBlockPackInt64)
+}
+
+func testMiniBlockPackInt64(t *testing.T, f func([]byte, *[miniBlockSize]int64, uint)) {
+	for bitWidth := uint(1); bitWidth <= 64; bitWidth++ {
+		t.Run(fmt.Sprintf("bitWidth=%d", bitWidth), func(t *testing.T) {
+			got := [8*miniBlockSize + 64]byte{}
+			src := [miniBlockSize]int64{}
+			for i := range src {
+				src[i] = int64(i) & int64((1<<bitWidth)-1)
+			}
+
+			want := [8*miniBlockSize + 64]byte{}
+			bitOffset := uint(0)
+
+			for _, bits := range src {
+				for b := uint(0); b < bitWidth; b++ {
+					x := bitOffset / 8
+					y := bitOffset % 8
+					want[x] |= byte(((bits >> b) & 1) << y)
+					bitOffset++
+				}
+			}
+
+			f(got[:], &src, bitWidth)
+			n := (miniBlockSize * bitWidth) / 8
+
+			if !bytes.Equal(want[:n], got[:n]) {
+				t.Errorf("output mismatch: want=%08x got=%08x", want[:n], got[:n])
+			}
+		})
+	}
+}
+
+func BenchmarkBlockDeltaInt64(b *testing.B) {
+	benchmarkBlockDeltaInt64(b, blockDeltaInt64)
+}
+
+func benchmarkBlockDeltaInt64(b *testing.B, f func(*[blockSize]int64, int64) int64) {
+	b.SetBytes(8 * blockSize)
+	block := [blockSize]int64{}
+	for i := 0; i < b.N; i++ {
+		_ = f(&block, 0)
+	}
+}
+
+func BenchmarkBlockMinInt64(b *testing.B) {
+	benchmarkBlockMinInt64(b, blockMinInt64)
+}
+
+func benchmarkBlockMinInt64(b *testing.B, f func(*[blockSize]int64) int64) {
+	b.SetBytes(8 * blockSize)
+	block := [blockSize]int64{}
+	for i := 0; i < b.N; i++ {
+		_ = f(&block)
+	}
+}
+
+func BenchmarkBlockSubInt64(b *testing.B) {
+	benchmarkBlockSubInt64(b, blockSubInt64)
+}
+
+func benchmarkBlockSubInt64(b *testing.B, f func(*[blockSize]int64, int64)) {
+	b.SetBytes(8 * blockSize)
+	block := [blockSize]int64{}
+	for i := 0; i < b.N; i++ {
+		f(&block, 42)
+	}
+}
+
+func BenchmarkBlockBitWidthsInt64(b *testing.B) {
+	benchmarkBlockBitWidthsInt64(b, blockBitWidthsInt64)
+}
+
+func benchmarkBlockBitWidthsInt64(b *testing.B, f func(*[numMiniBlocks]byte, *[blockSize]int64)) {
+	b.SetBytes(8 * blockSize)
+	bitWidths := [numMiniBlocks]byte{}
+	block := [blockSize]int64{}
+	for i := 0; i < b.N; i++ {
+		f(&bitWidths, &block)
+	}
+}
+
+func BenchmarkMiniBlockPackInt64(b *testing.B) {
+	benchmarkMiniBlockPackInt64(b, miniBlockPackInt64)
+}
+
+func benchmarkMiniBlockPackInt64(b *testing.B, f func([]byte, *[miniBlockSize]int64, uint)) {
+	for bitWidth := uint(1); bitWidth <= 64; bitWidth++ {
+		b.Run(fmt.Sprintf("bitWidth=%d", bitWidth), func(b *testing.B) {
+			b.SetBytes(8 * miniBlockSize)
+			dst := [8*miniBlockSize + 64]byte{}
+			src := [miniBlockSize]int64{}
+			for i := 0; i < b.N; i++ {
+				f(dst[:], &src, bitWidth)
+			}
+		})
+	}
+}
diff --git a/encoding/delta/binary_packed_test.go b/encoding/delta/binary_packed_test.go
new file mode 100644
index 0000000..877b819
--- /dev/null
+++ b/encoding/delta/binary_packed_test.go
@@ -0,0 +1,42 @@
+//go:build amd64 && go1.18
+
+package delta_test
+
+import (
+	"fmt"
+	"testing"
+
+	"github.com/segmentio/parquet-go/encoding/delta"
+	"github.com/segmentio/parquet-go/encoding/test"
+)
+
+const (
+	encodeMinNumValues = 0
+	encodeMaxNumValues = 200
+)
+
+func TestEncodeInt32(t *testing.T) {
+	for bitWidth := uint(0); bitWidth <= 32; bitWidth++ {
+		t.Run(fmt.Sprintf("bitWidth=%d", bitWidth), func(t *testing.T) {
+			test.EncodeInt32(t,
+				new(delta.BinaryPackedEncoding),
+				encodeMinNumValues,
+				encodeMaxNumValues,
+				bitWidth,
+			)
+		})
+	}
+}
+
+func TestEncodeInt64(t *testing.T) {
+	for bitWidth := uint(0); bitWidth <= 64; bitWidth++ {
+		t.Run(fmt.Sprintf("bitWidth=%d", bitWidth), func(t *testing.T) {
+			test.EncodeInt64(t,
+				new(delta.BinaryPackedEncoding),
+				encodeMinNumValues,
+				encodeMaxNumValues,
+				bitWidth,
+			)
+		})
+	}
+}
diff --git a/encoding/delta/byte_array_test.go b/encoding/delta/byte_array_test.go
index 70bdb07..571a3da 100644
--- a/encoding/delta/byte_array_test.go
+++ b/encoding/delta/byte_array_test.go
@@ -5,7 +5,17 @@ import (
 	"testing"
 )
 
-func TestPrefixLength(t *testing.T) {
+func TestLinearSearchPrefixLength(t *testing.T) {
+	testSearchPrefixLength(t, linearSearchPrefixLength)
+}
+
+func TestBinarySearchPrefixLength(t *testing.T) {
+	testSearchPrefixLength(t, func(base, data []byte) int {
+		return binarySearchPrefixLength(len(base)/2, base, data)
+	})
+}
+
+func testSearchPrefixLength(t *testing.T, prefixLength func(base, data []byte) int) {
 	tests := []struct {
 		base string
 		data string
@@ -118,10 +128,20 @@ func TestPrefixLength(t *testing.T) {
 	}
 }
 
-func BenchmarkPrefixLength(b *testing.B) {
+func BenchmarkLinearSearchkPrefixLength(b *testing.B) {
+	benchmarkSearchPrefixLength(b, linearSearchPrefixLength)
+}
+
+func BenchmarkBinarySearchkPrefixLength(b *testing.B) {
+	benchmarkSearchPrefixLength(b, func(base, data []byte) int {
+		return binarySearchPrefixLength(len(base)/2, base, data)
+	})
+}
+
+func benchmarkSearchPrefixLength(b *testing.B, prefixLength func(base, data []byte) int) {
 	value := bytes.Repeat([]byte("0123456789"), 100)
 
 	for i := 0; i < b.N; i++ {
-		prefixLength(value, value)
+		_ = prefixLength(value, value)
 	}
 }
diff --git a/encoding/plain/plain_test.go b/encoding/plain/plain_test.go
index b6f09cf..fa610dd 100644
--- a/encoding/plain/plain_test.go
+++ b/encoding/plain/plain_test.go
@@ -32,3 +32,27 @@ func TestAppendBoolean(t *testing.T) {
 		t.Errorf("%08b\n", values)
 	}
 }
+
+func TestValidateByteArrays(t *testing.T) {
+	t.Run("ok", func(t *testing.T) {
+		var b []byte
+		b = plain.AppendByteArrayString(b, "Hello")
+		b = plain.AppendByteArrayString(b, "World")
+		b = plain.AppendByteArrayString(b, "!")
+
+		if err := plain.ValidateByteArrays(b); err != nil {
+			t.Error(err)
+		}
+	})
+
+	t.Run("errTooShort", func(t *testing.T) {
+		var b []byte
+		b = plain.AppendByteArrayString(b, "Hello")
+		b = plain.AppendByteArrayString(b, "World")
+		b = plain.AppendByteArrayString(b, "!")
+
+		if plain.ValidateByteArrays(b[:len(b)-1]) == nil {
+			t.Error("expected non-nil error")
+		}
+	})
+}
diff --git a/encoding/test/test_go17.go b/encoding/test/test_go17.go
new file mode 100644
index 0000000..b8cb5fe
--- /dev/null
+++ b/encoding/test/test_go17.go
@@ -0,0 +1,3 @@
+//go:build !go1.17
+
+package test
diff --git a/encoding/test/test.go b/encoding/test/test_go18.go
similarity index 71%
rename from encoding/test/test.go
rename to encoding/test/test_go18.go
index a022dce..187bef8 100644
--- a/encoding/test/test.go
+++ b/encoding/test/test_go18.go
@@ -10,6 +10,38 @@ import (
 	"github.com/segmentio/parquet-go/internal/unsafecast"
 )
 
+func EncodeInt32(t *testing.T, enc encoding.Encoding, min, max int, bitWidth uint) {
+	t.Helper()
+	encode(t, enc, min, max,
+		encoding.Encoding.EncodeInt32,
+		encoding.Encoding.DecodeInt32,
+		func(i int) int32 {
+			value := int32(i)
+			mask := int32((1 << bitWidth) - 1)
+			if (i % 2) != 0 {
+				value = -value
+			}
+			return value & mask
+		},
+	)
+}
+
+func EncodeInt64(t *testing.T, enc encoding.Encoding, min, max int, bitWidth uint) {
+	t.Helper()
+	encode(t, enc, min, max,
+		encoding.Encoding.EncodeInt64,
+		encoding.Encoding.DecodeInt64,
+		func(i int) int64 {
+			value := int64(i)
+			mask := int64((1 << bitWidth) - 1)
+			if (i % 2) != 0 {
+				value = -value
+			}
+			return value & mask
+		},
+	)
+}
+
 func EncodeFloat(t *testing.T, enc encoding.Encoding, min, max int) {
 	t.Helper()
 	encode(t, enc, min, max,
diff --git a/internal/bits/add_test.go b/internal/bits/add_test.go
index 04db284..d541732 100644
--- a/internal/bits/add_test.go
+++ b/internal/bits/add_test.go
@@ -5,10 +5,11 @@ import (
 	"testing"
 
 	"github.com/segmentio/parquet-go/internal/bits"
+	"github.com/segmentio/parquet-go/internal/quick"
 )
 
 func TestAddInt32(t *testing.T) {
-	err := quickCheck(func(values []int32) bool {
+	err := quick.Check(func(values []int32) bool {
 		const add = math.MaxInt32
 
 		valuesAndAdd := make([]int32, len(values))
@@ -32,7 +33,7 @@ func TestAddInt32(t *testing.T) {
 }
 
 func TestAddInt64(t *testing.T) {
-	err := quickCheck(func(values []int64) bool {
+	err := quick.Check(func(values []int64) bool {
 		const add = math.MaxInt64
 
 		valuesAndAdd := make([]int64, len(values))
diff --git a/internal/bits/bits_test.go b/internal/bits/bits_test.go
index 9612957..39f478e 100644
--- a/internal/bits/bits_test.go
+++ b/internal/bits/bits_test.go
@@ -2,8 +2,6 @@ package bits_test
 
 import (
 	"fmt"
-	"math/rand"
-	"reflect"
 	"testing"
 
 	"github.com/segmentio/parquet-go/internal/bits"
@@ -112,119 +110,3 @@ func forEachBenchmarkBufferSize(b *testing.B, f func(*testing.B, int)) {
 		})
 	}
 }
-
-// quickCheck is inspired by the standard quick.Check package, but enhances the
-// API and tests arrays of larger sizes than the maximum of 50 hardcoded in
-// testing/quick.
-func quickCheck(f interface{}) error {
-	v := reflect.ValueOf(f)
-	r := rand.New(rand.NewSource(0))
-
-	var makeArray func(int) interface{}
-	switch t := v.Type().In(0); t.Elem().Kind() {
-	case reflect.Bool:
-		makeArray = func(n int) interface{} {
-			v := make([]bool, n)
-			for i := range v {
-				v[i] = r.Int()%2 != 0
-			}
-			return v
-		}
-
-	case reflect.Int32:
-		makeArray = func(n int) interface{} {
-			v := make([]int32, n)
-			for i := range v {
-				v[i] = r.Int31()
-			}
-			return v
-		}
-
-	case reflect.Int64:
-		makeArray = func(n int) interface{} {
-			v := make([]int64, n)
-			for i := range v {
-				v[i] = r.Int63()
-			}
-			return v
-		}
-
-	case reflect.Uint32:
-		makeArray = func(n int) interface{} {
-			v := make([]uint32, n)
-			for i := range v {
-				v[i] = r.Uint32()
-			}
-			return v
-		}
-	case reflect.Uint64:
-		makeArray = func(n int) interface{} {
-			v := make([]uint64, n)
-			for i := range v {
-				v[i] = r.Uint64()
-			}
-			return v
-		}
-
-	case reflect.Float32:
-		makeArray = func(n int) interface{} {
-			v := make([]float32, n)
-			for i := range v {
-				v[i] = r.Float32()
-			}
-			return v
-		}
-
-	case reflect.Float64:
-		makeArray = func(n int) interface{} {
-			v := make([]float64, n)
-			for i := range v {
-				v[i] = r.Float64()
-			}
-			return v
-		}
-
-	case reflect.Uint8:
-		makeArray = func(n int) interface{} {
-			v := make([]byte, n)
-			r.Read(v)
-			return v
-		}
-
-	case reflect.Array:
-		e := t.Elem()
-		if e.Elem().Kind() == reflect.Uint8 && e.Len() == 16 {
-			makeArray = func(n int) interface{} {
-				v := make([]byte, n*16)
-				r.Read(v)
-				return bits.BytesToUint128(v)
-			}
-		}
-	}
-
-	if makeArray == nil {
-		panic("cannot run quick check on function with input of type " + v.Type().In(0).String())
-	}
-
-	for _, n := range [...]int{
-		0, 1, 2, 3, 4, 5, 6, 7, 8, 9,
-		10, 11, 12, 13, 14, 15, 16, 17, 18, 19,
-		20, 21, 22, 23, 24, 25, 26, 27, 28, 29,
-		30, 31, 32, 33, 34, 35, 36, 37, 38, 39,
-		99, 100, 101,
-		127, 128, 129,
-		255, 256, 257,
-		1000, 1023, 1024, 1025,
-		2000, 2095, 2048, 2049,
-		4000, 4095, 4096, 4097,
-	} {
-		for i := 0; i < 3; i++ {
-			in := makeArray(n)
-			ok := v.Call([]reflect.Value{reflect.ValueOf(in)})
-			if !ok[0].Bool() {
-				return fmt.Errorf("test #%d: failed on input of size %d: %#v\n", i+1, n, in)
-			}
-		}
-	}
-	return nil
-}
diff --git a/internal/bits/count_test.go b/internal/bits/count_test.go
index 0e25338..1add19c 100644
--- a/internal/bits/count_test.go
+++ b/internal/bits/count_test.go
@@ -5,10 +5,11 @@ import (
 	"testing"
 
 	"github.com/segmentio/parquet-go/internal/bits"
+	"github.com/segmentio/parquet-go/internal/quick"
 )
 
 func TestCountByte(t *testing.T) {
-	err := quickCheck(func(data []byte) bool {
+	err := quick.Check(func(data []byte) bool {
 		data = bytes.Repeat(data, 8)
 		for _, c := range data {
 			n1 := bytes.Count(data, []byte{c})
diff --git a/internal/bits/max_test.go b/internal/bits/max_test.go
index c0b1668..f03c64a 100644
--- a/internal/bits/max_test.go
+++ b/internal/bits/max_test.go
@@ -6,10 +6,11 @@ import (
 	"testing"
 
 	"github.com/segmentio/parquet-go/internal/bits"
+	"github.com/segmentio/parquet-go/internal/quick"
 )
 
 func TestMaxBool(t *testing.T) {
-	err := quickCheck(func(values []bool) bool {
+	err := quick.Check(func(values []bool) bool {
 		max := false
 		for _, v := range values {
 			if v {
@@ -36,7 +37,7 @@ func TestMaxBool(t *testing.T) {
 }
 
 func TestMaxInt32(t *testing.T) {
-	err := quickCheck(func(values []int32) bool {
+	err := quick.Check(func(values []int32) bool {
 		max := int32(0)
 		if len(values) > 0 {
 			max = values[0]
@@ -54,7 +55,7 @@ func TestMaxInt32(t *testing.T) {
 }
 
 func TestMaxInt64(t *testing.T) {
-	err := quickCheck(func(values []int64) bool {
+	err := quick.Check(func(values []int64) bool {
 		max := int64(0)
 		if len(values) > 0 {
 			max = values[0]
@@ -72,7 +73,7 @@ func TestMaxInt64(t *testing.T) {
 }
 
 func TestMaxUint32(t *testing.T) {
-	err := quickCheck(func(values []uint32) bool {
+	err := quick.Check(func(values []uint32) bool {
 		max := uint32(0)
 		if len(values) > 0 {
 			max = values[0]
@@ -90,7 +91,7 @@ func TestMaxUint32(t *testing.T) {
 }
 
 func TestMaxUint64(t *testing.T) {
-	err := quickCheck(func(values []uint64) bool {
+	err := quick.Check(func(values []uint64) bool {
 		max := uint64(0)
 		if len(values) > 0 {
 			max = values[0]
@@ -108,7 +109,7 @@ func TestMaxUint64(t *testing.T) {
 }
 
 func TestMaxFloat32(t *testing.T) {
-	err := quickCheck(func(values []float32) bool {
+	err := quick.Check(func(values []float32) bool {
 		max := float32(0)
 		if len(values) > 0 {
 			max = values[0]
@@ -126,7 +127,7 @@ func TestMaxFloat32(t *testing.T) {
 }
 
 func TestMaxFloat64(t *testing.T) {
-	err := quickCheck(func(values []float64) bool {
+	err := quick.Check(func(values []float64) bool {
 		max := float64(0)
 		if len(values) > 0 {
 			max = values[0]
@@ -144,7 +145,7 @@ func TestMaxFloat64(t *testing.T) {
 }
 
 func TestMaxFixedLenByteArray1(t *testing.T) {
-	err := quickCheck(func(values []byte) bool {
+	err := quick.Check(func(values []byte) bool {
 		max := [1]byte{}
 		if len(values) > 0 {
 			max[0] = values[0]
@@ -163,7 +164,7 @@ func TestMaxFixedLenByteArray1(t *testing.T) {
 }
 
 func TestMaxFixedLenByteArray16(t *testing.T) {
-	err := quickCheck(func(values [][16]byte) bool {
+	err := quick.Check(func(values [][16]byte) bool {
 		max := [16]byte{}
 		if len(values) > 0 {
 			max = values[0]
diff --git a/internal/bits/min_test.go b/internal/bits/min_test.go
index 2f175cf..3daff50 100644
--- a/internal/bits/min_test.go
+++ b/internal/bits/min_test.go
@@ -6,10 +6,11 @@ import (
 	"testing"
 
 	"github.com/segmentio/parquet-go/internal/bits"
+	"github.com/segmentio/parquet-go/internal/quick"
 )
 
 func TestMinBool(t *testing.T) {
-	err := quickCheck(func(values []bool) bool {
+	err := quick.Check(func(values []bool) bool {
 		min := len(values) > 0
 		for _, v := range values {
 			if !v {
@@ -36,7 +37,7 @@ func TestMinBool(t *testing.T) {
 }
 
 func TestMinInt32(t *testing.T) {
-	err := quickCheck(func(values []int32) bool {
+	err := quick.Check(func(values []int32) bool {
 		min := int32(0)
 		if len(values) > 0 {
 			min = values[0]
@@ -54,7 +55,7 @@ func TestMinInt32(t *testing.T) {
 }
 
 func TestMinInt64(t *testing.T) {
-	err := quickCheck(func(values []int64) bool {
+	err := quick.Check(func(values []int64) bool {
 		min := int64(0)
 		if len(values) > 0 {
 			min = values[0]
@@ -72,7 +73,7 @@ func TestMinInt64(t *testing.T) {
 }
 
 func TestMinUint32(t *testing.T) {
-	err := quickCheck(func(values []uint32) bool {
+	err := quick.Check(func(values []uint32) bool {
 		min := uint32(0)
 		if len(values) > 0 {
 			min = values[0]
@@ -90,7 +91,7 @@ func TestMinUint32(t *testing.T) {
 }
 
 func TestMinUint64(t *testing.T) {
-	err := quickCheck(func(values []uint64) bool {
+	err := quick.Check(func(values []uint64) bool {
 		min := uint64(0)
 		if len(values) > 0 {
 			min = values[0]
@@ -108,7 +109,7 @@ func TestMinUint64(t *testing.T) {
 }
 
 func TestMinFloat32(t *testing.T) {
-	err := quickCheck(func(values []float32) bool {
+	err := quick.Check(func(values []float32) bool {
 		min := float32(0)
 		if len(values) > 0 {
 			min = values[0]
@@ -126,7 +127,7 @@ func TestMinFloat32(t *testing.T) {
 }
 
 func TestMinFloat64(t *testing.T) {
-	err := quickCheck(func(values []float64) bool {
+	err := quick.Check(func(values []float64) bool {
 		min := float64(0)
 		if len(values) > 0 {
 			min = values[0]
@@ -144,7 +145,7 @@ func TestMinFloat64(t *testing.T) {
 }
 
 func TestMinFixedLenByteArray1(t *testing.T) {
-	err := quickCheck(func(values []byte) bool {
+	err := quick.Check(func(values []byte) bool {
 		min := [1]byte{}
 		if len(values) > 0 {
 			min[0] = values[0]
@@ -163,7 +164,7 @@ func TestMinFixedLenByteArray1(t *testing.T) {
 }
 
 func TestMinFixedLenByteArray16(t *testing.T) {
-	err := quickCheck(func(values [][16]byte) bool {
+	err := quick.Check(func(values [][16]byte) bool {
 		min := [16]byte{}
 		if len(values) > 0 {
 			min = values[0]
diff --git a/internal/bits/minmax_test.go b/internal/bits/minmax_test.go
index 9d8c3b9..c006e52 100644
--- a/internal/bits/minmax_test.go
+++ b/internal/bits/minmax_test.go
@@ -6,12 +6,13 @@ import (
 	"testing"
 
 	"github.com/segmentio/parquet-go/internal/bits"
+	"github.com/segmentio/parquet-go/internal/quick"
 )
 
 const scaleFactor = 6000
 
 func TestMinMaxBool(t *testing.T) {
-	err := quickCheck(func(values []bool) bool {
+	err := quick.Check(func(values []bool) bool {
 		min := len(values) > 0
 		max := false
 		for _, v := range values {
@@ -41,7 +42,7 @@ func TestMinMaxBool(t *testing.T) {
 }
 
 func TestMinMaxInt32(t *testing.T) {
-	err := quickCheck(func(values []int32) bool {
+	err := quick.Check(func(values []int32) bool {
 		min := int32(0)
 		max := int32(0)
 		if len(values) > 0 {
@@ -65,7 +66,7 @@ func TestMinMaxInt32(t *testing.T) {
 }
 
 func TestMinMaxInt64(t *testing.T) {
-	err := quickCheck(func(values []int64) bool {
+	err := quick.Check(func(values []int64) bool {
 		min := int64(0)
 		max := int64(0)
 		if len(values) > 0 {
@@ -89,7 +90,7 @@ func TestMinMaxInt64(t *testing.T) {
 }
 
 func TestMinMaxUint32(t *testing.T) {
-	err := quickCheck(func(values []uint32) bool {
+	err := quick.Check(func(values []uint32) bool {
 		min := uint32(0)
 		max := uint32(0)
 		if len(values) > 0 {
@@ -113,7 +114,7 @@ func TestMinMaxUint32(t *testing.T) {
 }
 
 func TestMinMaxUint64(t *testing.T) {
-	err := quickCheck(func(values []uint64) bool {
+	err := quick.Check(func(values []uint64) bool {
 		min := uint64(0)
 		max := uint64(0)
 		if len(values) > 0 {
@@ -137,7 +138,7 @@ func TestMinMaxUint64(t *testing.T) {
 }
 
 func TestMinMaxFloat32(t *testing.T) {
-	err := quickCheck(func(values []float32) bool {
+	err := quick.Check(func(values []float32) bool {
 		min := float32(0)
 		max := float32(0)
 		if len(values) > 0 {
@@ -161,7 +162,7 @@ func TestMinMaxFloat32(t *testing.T) {
 }
 
 func TestMinMaxFloat64(t *testing.T) {
-	err := quickCheck(func(values []float64) bool {
+	err := quick.Check(func(values []float64) bool {
 		min := float64(0)
 		max := float64(0)
 		if len(values) > 0 {
@@ -185,7 +186,7 @@ func TestMinMaxFloat64(t *testing.T) {
 }
 
 func TestMinMaxFixedLenByteArray1(t *testing.T) {
-	err := quickCheck(func(values []byte) bool {
+	err := quick.Check(func(values []byte) bool {
 		min := [1]byte{}
 		max := [1]byte{}
 		if len(values) > 0 {
@@ -210,7 +211,7 @@ func TestMinMaxFixedLenByteArray1(t *testing.T) {
 }
 
 func TestMinMaxFixedLenByteArray16(t *testing.T) {
-	err := quickCheck(func(values [][16]byte) bool {
+	err := quick.Check(func(values [][16]byte) bool {
 		min := [16]byte{}
 		max := [16]byte{}
 		if len(values) > 0 {
diff --git a/internal/bits/order_test.go b/internal/bits/order_test.go
index 2533658..e3cff27 100644
--- a/internal/bits/order_test.go
+++ b/internal/bits/order_test.go
@@ -6,6 +6,7 @@ import (
 	"testing"
 
 	"github.com/segmentio/parquet-go/internal/bits"
+	"github.com/segmentio/parquet-go/internal/quick"
 )
 
 type boolOrder []bool
@@ -115,7 +116,7 @@ func TestOrderOfBool(t *testing.T) {
 	check := func(values []bool) bool {
 		return checkOrdering(t, boolOrder(values), bits.OrderOfBool(values))
 	}
-	err := quickCheck(func(values []bool) bool {
+	err := quick.Check(func(values []bool) bool {
 		if !check(values) {
 			return false
 		}
@@ -138,7 +139,7 @@ func TestOrderOfInt32(t *testing.T) {
 	check := func(values []int32) bool {
 		return checkOrdering(t, int32Order(values), bits.OrderOfInt32(values))
 	}
-	err := quickCheck(func(values []int32) bool {
+	err := quick.Check(func(values []int32) bool {
 		if !check(values) {
 			return false
 		}
@@ -176,7 +177,7 @@ func TestOrderOfInt64(t *testing.T) {
 	check := func(values []int64) bool {
 		return checkOrdering(t, int64Order(values), bits.OrderOfInt64(values))
 	}
-	err := quickCheck(func(values []int64) bool {
+	err := quick.Check(func(values []int64) bool {
 		if !check(values) {
 			return false
 		}
@@ -210,7 +211,7 @@ func TestOrderOfUint32(t *testing.T) {
 	check := func(values []uint32) bool {
 		return checkOrdering(t, uint32Order(values), bits.OrderOfUint32(values))
 	}
-	err := quickCheck(func(values []uint32) bool {
+	err := quick.Check(func(values []uint32) bool {
 		if !check(values) {
 			return false
 		}
@@ -244,7 +245,7 @@ func TestOrderOfUint64(t *testing.T) {
 	check := func(values []uint64) bool {
 		return checkOrdering(t, uint64Order(values), bits.OrderOfUint64(values))
 	}
-	err := quickCheck(func(values []uint64) bool {
+	err := quick.Check(func(values []uint64) bool {
 		if !check(values) {
 			return false
 		}
@@ -278,7 +279,7 @@ func TestOrderOfFloat32(t *testing.T) {
 	check := func(values []float32) bool {
 		return checkOrdering(t, float32Order(values), bits.OrderOfFloat32(values))
 	}
-	err := quickCheck(func(values []float32) bool {
+	err := quick.Check(func(values []float32) bool {
 		if !check(values) {
 			return false
 		}
@@ -312,7 +313,7 @@ func TestOrderOfFloat64(t *testing.T) {
 	check := func(values []float64) bool {
 		return checkOrdering(t, float64Order(values), bits.OrderOfFloat64(values))
 	}
-	err := quickCheck(func(values []float64) bool {
+	err := quick.Check(func(values []float64) bool {
 		if !check(values) {
 			return false
 		}
@@ -346,7 +347,7 @@ func TestOrderOfBytes(t *testing.T) {
 	check := func(values [][]byte) bool {
 		return checkOrdering(t, bytesOrder(values), bits.OrderOfBytes(values))
 	}
-	err := quickCheck(func(values [][16]byte) bool {
+	err := quick.Check(func(values [][16]byte) bool {
 		slices := make([][]byte, len(values))
 		for i := range values {
 			slices[i] = values[i][:]
diff --git a/internal/bits/sub_test.go b/internal/bits/sub_test.go
index ec038fc..caa71a1 100644
--- a/internal/bits/sub_test.go
+++ b/internal/bits/sub_test.go
@@ -5,10 +5,11 @@ import (
 	"testing"
 
 	"github.com/segmentio/parquet-go/internal/bits"
+	"github.com/segmentio/parquet-go/internal/quick"
 )
 
 func TestSubInt32(t *testing.T) {
-	err := quickCheck(func(values []int32) bool {
+	err := quick.Check(func(values []int32) bool {
 		const sub = math.MaxInt32
 
 		valuesAndSub := make([]int32, len(values))
@@ -32,7 +33,7 @@ func TestSubInt32(t *testing.T) {
 }
 
 func TestSubInt64(t *testing.T) {
-	err := quickCheck(func(values []int64) bool {
+	err := quick.Check(func(values []int64) bool {
 		const sub = math.MaxInt64
 
 		valuesAndSub := make([]int64, len(values))
diff --git a/parquet_test.go b/parquet_test.go
index 134af2e..ba20232 100644
--- a/parquet_test.go
+++ b/parquet_test.go
@@ -13,11 +13,12 @@ import (
 	"github.com/google/uuid"
 	"github.com/segmentio/parquet-go"
 	"github.com/segmentio/parquet-go/deprecated"
+	"github.com/segmentio/parquet-go/internal/quick"
 )
 
 const (
 	benchmarkNumRows     = 10_000
-	benchmarkRowsPerStep = 100
+	benchmarkRowsPerStep = 1000
 )
 
 type benchmarkRowType struct {
@@ -31,6 +32,15 @@ func (row benchmarkRowType) generate(prng *rand.Rand) benchmarkRowType {
 	return row
 }
 
+type paddedBooleanColumn struct {
+	Value bool
+	_     [3]byte
+}
+
+func (row paddedBooleanColumn) generate(prng *rand.Rand) paddedBooleanColumn {
+	return paddedBooleanColumn{Value: prng.Int()%2 == 0}
+}
+
 type booleanColumn struct {
 	Value bool
 }
@@ -40,7 +50,7 @@ func (row booleanColumn) generate(prng *rand.Rand) booleanColumn {
 }
 
 type int32Column struct {
-	Value int32
+	Value int32 `parquet:",delta"`
 }
 
 func (row int32Column) generate(prng *rand.Rand) int32Column {
@@ -418,3 +428,14 @@ func generateString(r *rand.Rand, n int) string {
 	}
 	return b.String()
 }
+
+var quickCheckConfig = quick.Config{
+	Sizes: []int{
+		0, 1, 2, 3, 4, 5, 6, 7, 8, 9,
+		10, 20, 30, 40, 50, 123,
+	},
+}
+
+func quickCheck(f interface{}) error {
+	return quickCheckConfig.Check(f)
+}
diff --git a/reader_go18_test.go b/reader_go18_test.go
index a48e927..0bbb66c 100644
--- a/reader_go18_test.go
+++ b/reader_go18_test.go
@@ -10,7 +10,6 @@ import (
 	"math/rand"
 	"reflect"
 	"testing"
-	"testing/quick"
 
 	"github.com/segmentio/parquet-go"
 )
@@ -40,10 +39,9 @@ func TestGenericReader(t *testing.T) {
 }
 
 func testGenericReader[Row any](t *testing.T) {
-	var prng = rand.New(rand.NewSource(2))
 	var model Row
 	t.Run(reflect.TypeOf(model).Name(), func(t *testing.T) {
-		f := func(rows []Row) bool {
+		err := quickCheck(func(rows []Row) bool {
 			if len(rows) == 0 {
 				return true // TODO: fix support for parquet files with zero rows
 			}
@@ -52,8 +50,8 @@ func testGenericReader[Row any](t *testing.T) {
 				return false
 			}
 			return true
-		}
-		if err := quick.Check(f, &quick.Config{Rand: prng}); err != nil {
+		})
+		if err != nil {
 			t.Error(err)
 		}
 	})
diff --git a/reader_test.go b/reader_test.go
index a82c63a..78a5493 100644
--- a/reader_test.go
+++ b/reader_test.go
@@ -8,9 +8,9 @@ import (
 	"math/rand"
 	"reflect"
 	"testing"
-	"testing/quick"
 
 	"github.com/segmentio/parquet-go"
+	"github.com/segmentio/parquet-go/internal/quick"
 )
 
 func rowsOf(numRows int, model interface{}) rows {
@@ -21,11 +21,10 @@ func rowsOf(numRows int, model interface{}) rows {
 func randomRowsOf(prng *rand.Rand, numRows int, model interface{}) rows {
 	typ := reflect.TypeOf(model)
 	rows := make(rows, numRows)
+	makeValue := quick.MakeValueFuncOf(typ)
 	for i := range rows {
-		v, ok := quick.Value(typ, prng)
-		if !ok {
-			panic("cannot generate random value for test")
-		}
+		v := reflect.New(typ).Elem()
+		makeValue(v, prng)
 		rows[i] = v.Interface()
 	}
 	return rows
@@ -290,7 +289,7 @@ func TestReaderReadSubset(t *testing.T) {
 	type Point3D struct{ X, Y, Z int64 }
 	type Point2D struct{ X, Y int64 }
 
-	f := func(points3D []Point3D) bool {
+	err := quickCheck(func(points3D []Point3D) bool {
 		if len(points3D) == 0 {
 			return true
 		}
@@ -317,8 +316,8 @@ func TestReaderReadSubset(t *testing.T) {
 			}
 		}
 		return true
-	}
-	if err := quick.Check(f, nil); err != nil {
+	})
+	if err != nil {
 		t.Error(err)
 	}
 }
diff --git a/row_group_test.go b/row_group_test.go
index ba2245d..4474438 100644
--- a/row_group_test.go
+++ b/row_group_test.go
@@ -2,11 +2,9 @@ package parquet_test
 
 import (
 	"bytes"
-	"math/rand"
 	"reflect"
 	"sort"
 	"testing"
-	"testing/quick"
 
 	"github.com/segmentio/parquet-go"
 )
@@ -66,7 +64,7 @@ func TestSeekToRow(t *testing.T) {
 }
 
 func testSeekToRow(t *testing.T, newRowGroup func([]Person) parquet.RowGroup) {
-	f := func(people []Person) bool {
+	err := quickCheck(func(people []Person) bool {
 		if len(people) == 0 { // TODO: fix creation of empty parquet files
 			return true
 		}
@@ -97,10 +95,8 @@ func testSeekToRow(t *testing.T, newRowGroup func([]Person) parquet.RowGroup) {
 		}
 
 		return true
-	}
-	if err := quick.Check(f, &quick.Config{
-		Rand: rand.New(rand.NewSource(3)),
-	}); err != nil {
+	})
+	if err != nil {
 		t.Error(err)
 	}
 }
diff --git a/schema_test.go b/schema_test.go
index 50cb976..ea2c34b 100644
--- a/schema_test.go
+++ b/schema_test.go
@@ -52,6 +52,46 @@ func TestSchemaOf(t *testing.T) {
 		required binary first_name (STRING);
 		required binary last_name (STRING);
 	}
+}`,
+		},
+
+		{
+			value: new(struct {
+				Short float32 `parquet:"short,split"`
+				Long  float64 `parquet:"long,split"`
+			}),
+			print: `message {
+	required float short;
+	required double long;
+}`,
+		},
+
+		{
+			value: new(struct {
+				Inner struct {
+					FirstName          string `parquet:"first_name"`
+					ShouldNotBePresent string `parquet:"-"`
+				} `parquet:"inner,optional"`
+			}),
+			print: `message {
+	optional group inner {
+		required binary first_name (STRING);
+	}
+}`,
+		},
+
+		{
+			value: new(struct {
+				Inner struct {
+					FirstName    string `parquet:"first_name"`
+					MyNameIsDash string `parquet:"-,"`
+				} `parquet:"inner,optional"`
+			}),
+			print: `message {
+	optional group inner {
+		required binary first_name (STRING);
+		required binary - (STRING);
+	}
 }`,
 		},
 	}
diff --git a/writer_test.go b/writer_test.go
index 2fc53b0..c8655e3 100644
--- a/writer_test.go
+++ b/writer_test.go
@@ -7,7 +7,6 @@ import (
 	"os/exec"
 	"strings"
 	"testing"
-	"testing/quick"
 
 	"github.com/google/uuid"
 	"github.com/hexops/gotextdiff"
@@ -78,6 +77,13 @@ type timeseries struct {
 	Value     float64 `parquet:"value"`
 }
 
+type event struct {
+	Name     string  `parquet:"name,dict"`
+	Type     string  `parquet:"-"`
+	Value    float64 `parquet:"value"`
+	Category string  `parquet:"-"`
+}
+
 var writerTests = []struct {
 	scenario string
 	version  int
@@ -277,10 +283,10 @@ value 10: R:0 D:0 V:10.0
 		dump: `row group 0
 --------------------------------------------------------------------------------
 owner:              BINARY ZSTD DO:0 FPO:4 SZ:66/57/0.86 VC:2 ENC:DELTA_LENGTH_BYTE_ARRAY ST:[no stats for this column]
-ownerPhoneNumbers:  BINARY GZIP DO:0 FPO:70 SZ:162/112/0.69 VC:3 ENC:DELTA_LENGTH_BYTE_ARRAY,RLE ST:[no stats for this column]
+ownerPhoneNumbers:  BINARY GZIP DO:0 FPO:70 SZ:162/112/0.69 VC:3 ENC:RLE,DELTA_LENGTH_BYTE_ARRAY ST:[no stats for this column]
 contacts:
-.name:              BINARY UNCOMPRESSED DO:0 FPO:232 SZ:116/116/1.00 VC:3 ENC:DELTA_LENGTH_BYTE_ARRAY,RLE ST:[no stats for this column]
-.phoneNumber:       BINARY ZSTD DO:0 FPO:348 SZ:113/95/0.84 VC:3 ENC:DELTA_LENGTH_BYTE_ARRAY,RLE ST:[no stats for this column]
+.name:              BINARY UNCOMPRESSED DO:0 FPO:232 SZ:116/116/1.00 VC:3 ENC:RLE,DELTA_LENGTH_BYTE_ARRAY ST:[no stats for this column]
+.phoneNumber:       BINARY ZSTD DO:0 FPO:348 SZ:113/95/0.84 VC:3 ENC:RLE,DELTA_LENGTH_BYTE_ARRAY ST:[no stats for this column]
 
     owner TV=2 RL=0 DL=0
     ----------------------------------------------------------------------------
@@ -359,10 +365,10 @@ value 3: R:0 D:0 V:<null>
 		dump: `row group 0
 --------------------------------------------------------------------------------
 owner:              BINARY ZSTD DO:0 FPO:4 SZ:71/62/0.87 VC:2 ENC:DELTA_LENGTH_BYTE_ARRAY ST:[no stats for this column]
-ownerPhoneNumbers:  BINARY GZIP DO:0 FPO:75 SZ:156/106/0.68 VC:3 ENC:DELTA_LENGTH_BYTE_ARRAY,RLE ST:[no stats for this column]
+ownerPhoneNumbers:  BINARY GZIP DO:0 FPO:75 SZ:156/106/0.68 VC:3 ENC:RLE,DELTA_LENGTH_BYTE_ARRAY ST:[no stats for this column]
 contacts:
-.name:              BINARY UNCOMPRESSED DO:0 FPO:231 SZ:110/110/1.00 VC:3 ENC:DELTA_LENGTH_BYTE_ARRAY,RLE ST:[no stats for this column]
-.phoneNumber:       BINARY ZSTD DO:0 FPO:341 SZ:108/90/0.83 VC:3 ENC:DELTA_LENGTH_BYTE_ARRAY,RLE ST:[no stats for this column]
+.name:              BINARY UNCOMPRESSED DO:0 FPO:231 SZ:110/110/1.00 VC:3 ENC:RLE,DELTA_LENGTH_BYTE_ARRAY ST:[no stats for this column]
+.phoneNumber:       BINARY ZSTD DO:0 FPO:341 SZ:108/90/0.83 VC:3 ENC:RLE,DELTA_LENGTH_BYTE_ARRAY ST:[no stats for this column]
 
     owner TV=2 RL=0 DL=0
     ----------------------------------------------------------------------------
@@ -409,6 +415,40 @@ BINARY contacts.phoneNumber
 value 1: R:0 D:2 V:555 987 6543
 value 2: R:1 D:1 V:<null>
 value 3: R:0 D:0 V:<null>
+`,
+	},
+
+	{
+		scenario: "omit `-` fields",
+		version:  v1,
+		rows: []interface{}{
+			&event{Name: "customer1", Type: "request", Value: 42.0},
+			&event{Name: "customer2", Type: "access", Value: 1.0},
+		},
+		dump: `row group 0
+--------------------------------------------------------------------------------
+name:   BINARY UNCOMPRESSED DO:4 FPO:49 SZ:73/73/1.00 VC:2 ENC:PLAIN,RLE_DICTIONARY ST:[no stats for this column]
+value:  DOUBLE UNCOMPRESSED DO:0 FPO:77 SZ:39/39/1.00 VC:2 ENC:PLAIN ST:[no stats for this column]
+
+    name TV=2 RL=0 DL=0 DS: 2 DE:PLAIN
+    ----------------------------------------------------------------------------
+    page 0:                  DLE:RLE RLE:RLE VLE:RLE_DICTIONARY ST:[no stats for this column] CRC:[verified] SZ:5 VC:2
+
+    value TV=2 RL=0 DL=0
+    ----------------------------------------------------------------------------
+    page 0:                  DLE:RLE RLE:RLE VLE:PLAIN ST:[no stats for this column] CRC:[verified] SZ:16 VC:2
+
+BINARY name
+--------------------------------------------------------------------------------
+*** row group 1 of 1, values 1 to 2 ***
+value 1: R:0 D:0 V:customer1
+value 2: R:0 D:0 V:customer2
+
+DOUBLE value
+--------------------------------------------------------------------------------
+*** row group 1 of 1, values 1 to 2 ***
+value 1: R:0 D:0 V:42.0
+value 2: R:0 D:0 V:1.0
 `,
 	},
 }
@@ -474,7 +514,7 @@ func TestWriterGenerateBloomFilters(t *testing.T) {
 		LastName  utf8string `parquet:"last_name"`
 	}
 
-	f := func(rows []Person) bool {
+	err := quickCheck(func(rows []Person) bool {
 		if len(rows) == 0 { // TODO: support writing files with no rows
 			return true
 		}
@@ -529,9 +569,8 @@ func TestWriterGenerateBloomFilters(t *testing.T) {
 		}
 
 		return true
-	}
-
-	if err := quick.Check(f, nil); err != nil {
+	})
+	if err != nil {
 		t.Error(err)
 	}
 }