Repository: segmentio/parquet-go
Pull request #199 (2022-05-26 21:20:06)

Optimize generic types (2)
This PR adds vector optimizations for routines that write to column buffers.

Comparisons to using the pure Go versions of the algorithms:
```
name                                          old time/op  new time/op  delta
GenericBuffer/benchmarkRowType/go1.18         1.49µs ± 0%  0.75µs ± 0%   -50.06%  (p=0.000 n=10+9)
GenericBuffer/booleanColumn/go1.18             589ns ± 0%   316ns ± 0%   -46.39%  (p=0.000 n=9+9)
GenericBuffer/int32Column/go1.18               576ns ± 0%   235ns ± 0%   -59.16%  (p=0.000 n=9+10)
GenericBuffer/int64Column/go1.18               554ns ± 0%   305ns ± 0%   -44.96%  (p=0.000 n=9+9)
GenericBuffer/floatColumn/go1.18               616ns ± 0%   235ns ± 0%   -61.77%  (p=0.000 n=10+10)
GenericBuffer/doubleColumn/go1.18              616ns ± 0%   305ns ± 0%   -50.45%  (p=0.000 n=9+8)
GenericBuffer/byteArrayColumn/go1.18          9.91µs ± 1%  9.94µs ± 2%      ~     (p=0.781 n=10+10)
GenericBuffer/fixedLenByteArrayColumn/go1.18  5.45µs ± 0%  5.50µs ± 0%    +1.00%  (p=0.000 n=10+9)
GenericBuffer/stringColumn/go1.18             6.21µs ± 0%  6.26µs ± 0%    +0.75%  (p=0.000 n=9+8)
GenericBuffer/indexedStringColumn/go1.18      40.2µs ± 0%  40.1µs ± 1%      ~     (p=0.370 n=8+9)
GenericBuffer/uuidColumn/go1.18                888ns ± 0%   391ns ± 0%   -56.03%  (p=0.000 n=10+10)
GenericBuffer/mapColumn/go1.18                 693µs ± 0%   705µs ± 1%    +1.71%  (p=0.000 n=9+10)
GenericBuffer/decimalColumn/go1.18             577ns ± 0%   306ns ± 0%   -46.87%  (p=0.000 n=10+10)
GenericBuffer/contact/go1.18                  13.0µs ± 1%  12.9µs ± 0%    -0.60%  (p=0.002 n=10+10)
GenericBuffer/paddedBooleanColumn/go1.18       590ns ± 0%   222ns ± 0%   -62.34%  (p=0.000 n=10+10)

name                                          old row/s    new row/s    delta
GenericBuffer/benchmarkRowType/go1.18           669M ± 0%   1340M ± 0%  +100.25%  (p=0.000 n=10+9)
GenericBuffer/booleanColumn/go1.18             1.70G ± 0%   3.17G ± 0%   +86.53%  (p=0.000 n=9+9)
GenericBuffer/int32Column/go1.18               1.74G ± 0%   4.25G ± 0%  +144.85%  (p=0.000 n=9+10)
GenericBuffer/int64Column/go1.18               1.80G ± 0%   3.28G ± 0%   +81.69%  (p=0.000 n=9+9)
GenericBuffer/floatColumn/go1.18               1.62G ± 0%   4.25G ± 0%  +161.58%  (p=0.000 n=10+10)
GenericBuffer/doubleColumn/go1.18              1.62G ± 0%   3.28G ± 0%  +101.82%  (p=0.000 n=9+8)
GenericBuffer/byteArrayColumn/go1.18            101M ± 1%    101M ± 2%      ~     (p=0.796 n=10+10)
GenericBuffer/fixedLenByteArrayColumn/go1.18    184M ± 0%    182M ± 0%    -0.99%  (p=0.000 n=10+9)
GenericBuffer/stringColumn/go1.18               161M ± 0%    160M ± 0%    -0.74%  (p=0.000 n=9+8)
GenericBuffer/indexedStringColumn/go1.18       24.9M ± 0%   24.9M ± 1%      ~     (p=0.370 n=8+9)
GenericBuffer/uuidColumn/go1.18                1.13G ± 0%   2.56G ± 0%  +127.41%  (p=0.000 n=10+10)
GenericBuffer/mapColumn/go1.18                 1.44M ± 0%   1.42M ± 1%    -1.68%  (p=0.000 n=9+10)
GenericBuffer/decimalColumn/go1.18             1.73G ± 0%   3.27G ± 0%   +88.23%  (p=0.000 n=10+10)
GenericBuffer/contact/go1.18                   76.8M ± 1%   77.3M ± 0%    +0.60%  (p=0.002 n=10+10)
GenericBuffer/paddedBooleanColumn/go1.18       1.70G ± 0%   4.50G ± 0%  +165.56%  (p=0.000 n=10+10)
```

I also refactored the tests that were using `quick.Check`, the function did not ignore unexported struct fields (even if they were named `_`) which caused panics when it attempted to set the values through reflection. I moved the code from `internal/bits` to `internal/quick` and made it more configurable to use throughout the project.


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/dictionary.go b/dictionary.go
index 5214c3a..fd852f5 100644
--- a/dictionary.go
+++ b/dictionary.go
@@ -1034,17 +1034,22 @@ type indexedPageValues struct {
 }
 
 func (r *indexedPageValues) ReadValues(values []Value) (n int, err error) {
-	var v Value
-	for n < len(values) && r.offset < len(r.page.values) {
-		v = r.page.typ.dict.Index(r.page.values[r.offset])
-		v.columnIndex = r.page.columnIndex
+	dict := r.page.typ.dict
+	pageValues := r.page.values
+	columnIndex := r.page.columnIndex
+
+	for n < len(values) && r.offset < len(pageValues) {
+		v := dict.Index(pageValues[r.offset])
+		v.columnIndex = columnIndex
 		values[n] = v
 		r.offset++
 		n++
 	}
-	if r.offset == len(r.page.values) {
+
+	if r.offset == len(pageValues) {
 		err = io.EOF
 	}
+
 	return n, err
 }
 
diff --git a/encoding.go b/encoding.go
index 580432e..40ce70f 100644
--- a/encoding.go
+++ b/encoding.go
@@ -84,8 +84,11 @@ var (
 )
 
 func isDictionaryEncoding(encoding encoding.Encoding) bool {
-	enc := encoding.Encoding()
-	return enc == format.PlainDictionary || enc == format.RLEDictionary
+	return isDictionaryFormat(encoding.Encoding())
+}
+
+func isDictionaryFormat(encoding format.Encoding) bool {
+	return encoding == format.PlainDictionary || encoding == format.RLEDictionary
 }
 
 // LookupEncoding returns the parquet encoding associated with the given code.
diff --git a/file.go b/file.go
index 7dd667f..eff129b 100644
--- a/file.go
+++ b/file.go
@@ -467,83 +467,23 @@ func (f *filePages) ReadPage() (Page, error) {
 		if err := f.decoder.Decode(header); err != nil {
 			return nil, err
 		}
-
-		if cap(f.dataPage.data) < int(header.CompressedPageSize) {
-			f.dataPage.data = make([]byte, header.CompressedPageSize)
-		} else {
-			f.dataPage.data = f.dataPage.data[:header.CompressedPageSize]
-		}
-
-		if cap(f.dataPage.values) < int(header.UncompressedPageSize) {
-			f.dataPage.values = make([]byte, 0, header.UncompressedPageSize)
-		}
-
-		if _, err := io.ReadFull(f.rbuf, f.dataPage.data); err != nil {
+		if err := f.readPage(header, f.dataPage, f.rbuf); err != nil {
 			return nil, err
 		}
 
-		if header.CRC != 0 {
-			headerChecksum := uint32(header.CRC)
-			bufferChecksum := crc32.ChecksumIEEE(f.dataPage.data)
-
-			if headerChecksum != bufferChecksum {
-				// The parquet specs indicate that corruption errors could be
-				// handled gracefully by skipping pages, tho this may not always
-				// be practical. Depending on how the pages are consumed,
-				// missing rows may cause unpredictable behaviors in algorithms.
-				//
-				// For now, we assume these errors to be fatal, but we may
-				// revisit later and improve error handling to be more resilient
-				// to data corruption.
-				return nil, fmt.Errorf("crc32 checksum mismatch in page %d of column %q: 0x%08X != 0x%08X: %w",
-					f.index,
-					f.columnPath(),
-					headerChecksum,
-					bufferChecksum,
-					ErrCorrupted,
-				)
-			}
-		}
-
-		var column = f.chunk.column
 		var page Page
 		var err error
 
 		switch header.Type {
 		case format.DataPageV2:
-			if header.DataPageHeaderV2 == nil {
-				err = ErrMissingPageHeader
-			} else {
-				page, err = column.decodeDataPageV2(DataPageHeaderV2{header.DataPageHeaderV2}, f.dataPage)
-			}
-
+			page, err = f.readDataPageV2(header)
 		case format.DataPage:
-			if header.DataPageHeader == nil {
-				err = ErrMissingPageHeader
-			} else {
-				page, err = column.decodeDataPageV1(DataPageHeaderV1{header.DataPageHeader}, f.dataPage)
-			}
-
+			page, err = f.readDataPageV1(header)
 		case format.DictionaryPage:
 			// Sometimes parquet files do not have the dictionary page offset
 			// recorded in the column metadata. We account for this by lazily
-			// checking whether the first page is a dictionary page.
-			if header.DictionaryPageHeader == nil {
-				err = ErrMissingPageHeader
-			} else if f.index > 0 {
-				err = ErrUnexpectedDictionaryPage
-			} else {
-				f.dictPage, _ = dictPagePool.Get().(*dictPage)
-				if f.dictPage == nil {
-					f.dictPage = new(dictPage)
-				}
-				f.dataPage.dictionary, err = column.decodeDictionary(
-					DictionaryPageHeader{header.DictionaryPageHeader},
-					f.dataPage,
-					f.dictPage,
-				)
-			}
-
+			// reading dictionary pages when we encounter them.
+			err = f.readDictionaryPage(header, f.dataPage)
 		default:
 			err = fmt.Errorf("cannot read values of type %s from page", header.Type)
 		}
@@ -575,6 +515,115 @@ func (f *filePages) ReadPage() (Page, error) {
 	}
 }
 
+func (f *filePages) readDictionary() error {
+	chunk := io.NewSectionReader(f.chunk.file, f.baseOffset, f.chunk.chunk.MetaData.TotalCompressedSize)
+	rbuf := acquireReadBuffer(chunk)
+	defer releaseReadBuffer(rbuf)
+
+	decoder := thrift.NewDecoder(f.protocol.NewReader(rbuf))
+	header := new(format.PageHeader)
+
+	if err := decoder.Decode(header); err != nil {
+		return err
+	}
+
+	page := acquireDataPage()
+	defer releaseDataPage(page)
+
+	if err := f.readPage(header, page, rbuf); err != nil {
+		return err
+	}
+
+	return f.readDictionaryPage(header, page)
+}
+
+func (f *filePages) readDictionaryPage(header *format.PageHeader, page *dataPage) (err error) {
+	if header.DictionaryPageHeader == nil {
+		return ErrMissingPageHeader
+	}
+	if f.index > 0 {
+		return ErrUnexpectedDictionaryPage
+	}
+	f.dictPage, _ = dictPagePool.Get().(*dictPage)
+	if f.dictPage == nil {
+		f.dictPage = new(dictPage)
+	}
+	f.dataPage.dictionary, err = f.chunk.column.decodeDictionary(
+		DictionaryPageHeader{header.DictionaryPageHeader},
+		page,
+		f.dictPage,
+	)
+	return err
+}
+
+func (f *filePages) readDataPageV1(header *format.PageHeader) (Page, error) {
+	if header.DataPageHeader == nil {
+		return nil, ErrMissingPageHeader
+	}
+	if isDictionaryFormat(header.DataPageHeader.Encoding) && f.dataPage.dictionary == nil {
+		if err := f.readDictionary(); err != nil {
+			return nil, err
+		}
+	}
+	return f.chunk.column.decodeDataPageV1(DataPageHeaderV1{header.DataPageHeader}, f.dataPage)
+}
+
+func (f *filePages) readDataPageV2(header *format.PageHeader) (Page, error) {
+	if header.DataPageHeaderV2 == nil {
+		return nil, ErrMissingPageHeader
+	}
+	if isDictionaryFormat(header.DataPageHeaderV2.Encoding) && f.dataPage.dictionary == nil {
+		// If the program seeked to a row passed the first page, the dictionary
+		// page may not have been seen, in which case we have to lazily load it
+		// from the beginning of column chunk.
+		if err := f.readDictionary(); err != nil {
+			return nil, err
+		}
+	}
+	return f.chunk.column.decodeDataPageV2(DataPageHeaderV2{header.DataPageHeaderV2}, f.dataPage)
+}
+
+func (f *filePages) readPage(header *format.PageHeader, page *dataPage, reader *bufio.Reader) error {
+	compressedPageSize, uncompressedPageSize := int(header.CompressedPageSize), int(header.UncompressedPageSize)
+
+	if cap(page.data) < compressedPageSize {
+		page.data = make([]byte, compressedPageSize)
+	} else {
+		page.data = page.data[:compressedPageSize]
+	}
+	if cap(page.values) < uncompressedPageSize {
+		page.values = make([]byte, 0, uncompressedPageSize)
+	}
+
+	if _, err := io.ReadFull(reader, page.data); err != nil {
+		return err
+	}
+
+	if header.CRC != 0 {
+		headerChecksum := uint32(header.CRC)
+		bufferChecksum := crc32.ChecksumIEEE(page.data)
+
+		if headerChecksum != bufferChecksum {
+			// The parquet specs indicate that corruption errors could be
+			// handled gracefully by skipping pages, tho this may not always
+			// be practical. Depending on how the pages are consumed,
+			// missing rows may cause unpredictable behaviors in algorithms.
+			//
+			// For now, we assume these errors to be fatal, but we may
+			// revisit later and improve error handling to be more resilient
+			// to data corruption.
+			return fmt.Errorf("crc32 checksum mismatch in page of column %q: want=0x%08X got=0x%08X: %w",
+				f.columnPath(),
+				headerChecksum,
+				bufferChecksum,
+				ErrCorrupted,
+			)
+		}
+	}
+
+	return nil
+}
+
 func (f *filePages) SeekToRow(rowIndex int64) (err error) {
 	if f.chunk == nil {
 		return io.ErrClosedPipe
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..d1b3b01 100644
--- a/schema.go
+++ b/schema.go
@@ -69,6 +69,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 +318,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 +402,6 @@ func (f *structField) Value(base reflect.Value) reflect.Value {
 			return fieldByIndex(base, f.index)
 		}
 	}
-	return reflect.Value{}
 }
 
 func structFieldString(f reflect.StructField) string {


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/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..1b4e5d7 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
 }
@@ -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 9449e52..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)
 	}
 }
@@ -352,3 +351,112 @@ func TestReaderSeekToRow(t *testing.T) {
 		}
 	}
 }
+
+func TestSeekToRowNoDict(t *testing.T) {
+	type rowType struct {
+		Name utf8string `parquet:","` // no dictionary encoding
+	}
+
+	// write samples to in-memory buffer
+	buf := new(bytes.Buffer)
+	schema := parquet.SchemaOf(new(rowType))
+	w := parquet.NewWriter(buf, schema)
+	sample := rowType{
+		Name: "foo1",
+	}
+	// write two rows
+	w.Write(sample)
+	sample.Name = "foo2"
+	w.Write(sample)
+	w.Close()
+
+	// create reader
+	r := parquet.NewReader(bytes.NewReader(buf.Bytes()))
+
+	// read second row
+	r.SeekToRow(1)
+	row := new(rowType)
+	err := r.Read(row)
+	if err != nil {
+		t.Fatalf("reading row: %v", err)
+	}
+	// fmt.Println(&sample, row)
+	if *row != sample {
+		t.Fatalf("read != write")
+	}
+}
+
+func TestSeekToRowReadAll(t *testing.T) {
+	type rowType struct {
+		Name utf8string `parquet:",dict"`
+	}
+
+	// write samples to in-memory buffer
+	buf := new(bytes.Buffer)
+	schema := parquet.SchemaOf(new(rowType))
+	w := parquet.NewWriter(buf, schema)
+	sample := rowType{
+		Name: "foo1",
+	}
+	// write two rows
+	w.Write(sample)
+	sample.Name = "foo2"
+	w.Write(sample)
+	w.Close()
+
+	// create reader
+	r := parquet.NewReader(bytes.NewReader(buf.Bytes()))
+
+	// read first row
+	r.SeekToRow(0)
+	row := new(rowType)
+	err := r.Read(row)
+	if err != nil {
+		t.Fatalf("reading row: %v", err)
+	}
+	// read second row
+	r.SeekToRow(1)
+	row = new(rowType)
+	err = r.Read(row)
+	if err != nil {
+		t.Fatalf("reading row: %v", err)
+	}
+	// fmt.Println(&sample, row)
+	if *row != sample {
+		t.Fatalf("read != write")
+	}
+}
+
+func TestSeekToRowDictReadSecond(t *testing.T) {
+	type rowType struct {
+		Name utf8string `parquet:",dict"`
+	}
+
+	// write samples to in-memory buffer
+	buf := new(bytes.Buffer)
+	schema := parquet.SchemaOf(new(rowType))
+	w := parquet.NewWriter(buf, schema)
+	sample := rowType{
+		Name: "foo1",
+	}
+	// write two rows
+	w.Write(sample)
+	sample.Name = "foo2"
+	w.Write(sample)
+	w.Close()
+
+	// create reader
+	r := parquet.NewReader(bytes.NewReader(buf.Bytes()))
+
+	// read second row
+	r.SeekToRow(1)
+	row := new(rowType)
+	err := r.Read(row)
+	if err != nil {
+		t.Fatalf("reading row: %v", err)
+	}
+	// fmt.Println(&sample, row)
+	if *row != sample {
+		t.Fatalf("read != write")
+	}
+}
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..3a3231e 100644
--- a/schema_test.go
+++ b/schema_test.go
@@ -52,6 +52,35 @@ func TestSchemaOf(t *testing.T) {
 		required binary first_name (STRING);
 		required binary last_name (STRING);
 	}
+}`,
+		},
+
+		{
+			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)
 	}
 }