Repository: segmentio/parquet-go
Pull request #59 (2022-02-07 19:54:28)

[O11Y-197] optimize min/max algorithms on x86 with AVX-512 support
I've started this a couple weeks ago but didn't find the time to wrap up the work until today, so just submitting now.

This PR revisits the implementations of a couple of internal algorithms that we use when creating parquet files, using vectorized AVX-512 instructions to improve the throughput of those operations on parquet columns.

The assembly code can be complex in places, but the work being done is very simple in general, we look for min/max values in an arrays of primitive types. I've made sure to invest in thorough testing to minimize the risk of seeing regressions introduced by those optimizations.

For what it's worth, those are the measured improvements on some of the benchmarks I added in the PR:

```
name                             old time/op    new time/op      delta
MinMaxBool/4KiB                    1.23µs ± 0%      0.04µs ± 0%    -96.79%  (p=0.000 n=8+10)
MinMaxBool/256KiB                  77.6µs ± 0%       3.3µs ± 0%    -95.73%  (p=0.000 n=10+9)
MinMaxBool/2048KiB                  624µs ± 0%        64µs ± 8%    -89.78%  (p=0.000 n=9+10)
MinMaxInt32/4KiB                    640ns ± 0%        44ns ± 0%    -93.11%  (p=0.000 n=9+10)
MinMaxInt32/256KiB                 40.7µs ± 0%       3.3µs ± 0%    -91.84%  (p=0.000 n=10+9)
MinMaxInt32/2048KiB                 327µs ± 0%        60µs ± 7%    -81.57%  (p=0.000 n=10+10)
MinMaxInt64/4KiB                    331ns ± 0%        45ns ± 0%    -86.40%  (p=0.000 n=9+8)
MinMaxInt64/256KiB                 20.9µs ± 0%       3.4µs ± 0%    -83.87%  (p=0.000 n=10+9)
MinMaxInt64/2048KiB                 169µs ± 0%        62µs ± 8%    -63.28%  (p=0.000 n=9+10)
MinMaxUint32/4KiB                   905ns ± 0%        44ns ± 0%    -95.14%  (p=0.000 n=9+8)
MinMaxUint32/256KiB                58.1µs ± 0%       3.3µs ± 0%    -94.31%  (p=0.000 n=10+9)
MinMaxUint32/2048KiB                467µs ± 0%        59µs ± 8%    -87.32%  (p=0.000 n=10+10)
MinMaxUint64/4KiB                   450ns ± 0%        46ns ± 0%    -89.81%  (p=0.000 n=10+8)
MinMaxUint64/256KiB                29.1µs ± 0%       3.4µs ± 0%    -88.42%  (p=0.000 n=10+9)
MinMaxUint64/2048KiB                233µs ± 0%        61µs ± 7%    -73.85%  (p=0.000 n=9+10)
MinMaxFloat32/4KiB                  819ns ± 0%        53ns ± 3%    -93.49%  (p=0.000 n=10+10)
MinMaxFloat32/256KiB               52.5µs ± 0%       3.7µs ± 2%    -92.87%  (p=0.000 n=8+10)
MinMaxFloat32/2048KiB               440µs ± 0%        62µs ± 6%    -85.83%  (p=0.000 n=10+10)
MinMaxFloat64/4KiB                  475ns ± 1%        54ns ± 2%    -88.71%  (p=0.000 n=10+10)
MinMaxFloat64/256KiB               29.2µs ± 0%       3.8µs ± 2%    -87.11%  (p=0.000 n=9+10)
MinMaxFloat64/2048KiB               235µs ± 0%        60µs ± 5%    -74.31%  (p=0.000 n=9+10)
MinMaxFixedLenByteArray/4KiB        544ns ± 0%       259ns ± 0%    -52.38%  (p=0.000 n=10+9)
MinMaxFixedLenByteArray/256KiB     34.1µs ± 0%      14.3µs ± 0%    -58.05%  (p=0.000 n=10+10)
MinMaxFixedLenByteArray/2048KiB     274µs ± 0%       155µs ± 5%    -43.54%  (p=0.000 n=10+10)

name                             old speed      new speed        delta
MinMaxBool/4KiB                  3.33GB/s ± 0%  103.77GB/s ± 0%  +3017.58%  (p=0.000 n=8+10)
MinMaxBool/256KiB                3.38GB/s ± 0%   79.14GB/s ± 0%  +2243.14%  (p=0.000 n=10+9)
MinMaxBool/2048KiB               3.36GB/s ± 0%   32.93GB/s ± 9%   +879.85%  (p=0.000 n=9+10)
MinMaxInt32/4KiB                 6.40GB/s ± 0%   92.97GB/s ± 0%  +1351.86%  (p=0.000 n=9+10)
MinMaxInt32/256KiB               6.44GB/s ± 0%   78.94GB/s ± 0%  +1125.56%  (p=0.000 n=10+9)
MinMaxInt32/2048KiB              6.42GB/s ± 0%   34.87GB/s ± 6%   +443.25%  (p=0.000 n=10+10)
MinMaxInt64/4KiB                 12.4GB/s ± 0%    91.0GB/s ± 0%   +635.50%  (p=0.000 n=9+8)
MinMaxInt64/256KiB               12.5GB/s ± 0%    77.7GB/s ± 0%   +519.81%  (p=0.000 n=10+9)
MinMaxInt64/2048KiB              12.4GB/s ± 0%    33.9GB/s ± 8%   +172.81%  (p=0.000 n=9+10)
MinMaxUint32/4KiB                4.53GB/s ± 0%   93.23GB/s ± 0%  +1958.77%  (p=0.000 n=8+10)
MinMaxUint32/256KiB              4.51GB/s ± 0%   79.20GB/s ± 0%  +1656.67%  (p=0.000 n=10+9)
MinMaxUint32/2048KiB             4.49GB/s ± 0%   35.48GB/s ± 7%   +689.68%  (p=0.000 n=10+10)
MinMaxUint64/4KiB                9.09GB/s ± 0%   89.22GB/s ± 0%   +881.23%  (p=0.000 n=10+8)
MinMaxUint64/256KiB              9.02GB/s ± 0%   77.87GB/s ± 0%   +763.67%  (p=0.000 n=10+8)
MinMaxUint64/2048KiB             8.99GB/s ± 0%   34.40GB/s ± 7%   +282.81%  (p=0.000 n=9+10)
MinMaxFloat32/4KiB               5.00GB/s ± 0%   76.92GB/s ± 3%  +1437.50%  (p=0.000 n=10+10)
MinMaxFloat32/256KiB             5.00GB/s ± 0%   70.05GB/s ± 2%  +1301.80%  (p=0.000 n=8+10)
MinMaxFloat32/2048KiB            4.77GB/s ± 0%   33.71GB/s ± 6%   +606.78%  (p=0.000 n=10+10)
MinMaxFloat64/4KiB               8.63GB/s ± 1%   76.43GB/s ± 2%   +786.13%  (p=0.000 n=10+10)
MinMaxFloat64/256KiB             8.97GB/s ± 0%   69.56GB/s ± 2%   +675.73%  (p=0.000 n=9+10)
MinMaxFloat64/2048KiB            8.94GB/s ± 0%   34.84GB/s ± 5%   +289.74%  (p=0.000 n=9+10)
MinMaxFixedLenByteArray/4KiB     7.53GB/s ± 0%   15.82GB/s ± 0%   +110.00%  (p=0.000 n=10+9)
MinMaxFixedLenByteArray/256KiB   7.69GB/s ± 0%   18.32GB/s ± 0%   +138.40%  (p=0.000 n=10+10)
MinMaxFixedLenByteArray/2048KiB  7.66GB/s ± 0%   13.59GB/s ± 5%    +77.30%  (p=0.000 n=10+10)
```

achille-roussel: > What's the use case for finding the max on 128 bit big endian? What Parquet data type does that correspond to?

I'm planning on using this for columns holding UUIDs. This would correspond to the FIXED_LEN_BYTE_ARRAY(16) type in parquet, with the UUID annotation https://github.com/apache/parquet-format/blob/master/LogicalTypes.md#uuid

Patch:
diff --git a/internal/bits/bits_amd64.go b/internal/bits/bits_amd64.go
new file mode 100644
index 0000000..4509639
--- /dev/null
+++ b/internal/bits/bits_amd64.go
@@ -0,0 +1,10 @@
+package bits
+
+import "golang.org/x/sys/cpu"
+
+var hasAVX512 = cpu.X86.HasAVX512 &&
+	cpu.X86.HasAVX512F &&
+	cpu.X86.HasAVX512VL
+
+var hasAVX512MinMaxBool = hasAVX512 &&
+	cpu.X86.HasAVX512VPOPCNTDQ
diff --git a/internal/bits/bits_amd64.s b/internal/bits/bits_amd64.s
new file mode 100644
index 0000000..fd33784
--- /dev/null
+++ b/internal/bits/bits_amd64.s
@@ -0,0 +1,54 @@
+//go:build !purego
+
+#include "textflag.h"
+
+#define bswap128lo 0x08080A0B0C0D0E0F
+#define bswap128hi 0x0001020304050607
+
+DATA bswap128+0(SB)/8, $bswap128lo
+DATA bswap128+8(SB)/8, $bswap128hi
+DATA bswap128+16(SB)/8, $bswap128lo
+DATA bswap128+24(SB)/8, $bswap128hi
+DATA bswap128+32(SB)/8, $bswap128lo
+DATA bswap128+40(SB)/8, $bswap128hi
+DATA bswap128+48(SB)/8, $bswap128lo
+DATA bswap128+56(SB)/8, $bswap128hi
+GLOBL bswap128(SB), RODATA|NOPTR, $64
+
+DATA indexes128+0(SB)/8, $0
+DATA indexes128+8(SB)/8, $0
+DATA indexes128+16(SB)/8, $1
+DATA indexes128+24(SB)/8, $1
+DATA indexes128+32(SB)/8, $2
+DATA indexes128+40(SB)/8, $2
+DATA indexes128+48(SB)/8, $3
+DATA indexes128+56(SB)/8, $3
+GLOBL indexes128(SB), RODATA|NOPTR, $64
+
+DATA swap64+0(SB)/8, $4
+DATA swap64+8(SB)/8, $5
+DATA swap64+16(SB)/8, $6
+DATA swap64+24(SB)/8, $7
+DATA swap64+32(SB)/8, $2
+DATA swap64+40(SB)/8, $3
+DATA swap64+48(SB)/8, $0
+DATA swap64+56(SB)/8, $1
+GLOBL swap64(SB), RODATA|NOPTR, $64
+
+DATA swap32+0(SB)/4, $8
+DATA swap32+4(SB)/4, $9
+DATA swap32+8(SB)/4, $10
+DATA swap32+12(SB)/4, $11
+DATA swap32+16(SB)/4, $12
+DATA swap32+20(SB)/4, $13
+DATA swap32+24(SB)/4, $14
+DATA swap32+28(SB)/4, $15
+DATA swap32+32(SB)/4, $4
+DATA swap32+36(SB)/4, $5
+DATA swap32+40(SB)/4, $6
+DATA swap32+44(SB)/4, $7
+DATA swap32+48(SB)/4, $2
+DATA swap32+52(SB)/4, $3
+DATA swap32+56(SB)/4, $0
+DATA swap32+60(SB)/4, $1
+GLOBL swap32(SB), RODATA|NOPTR, $64
diff --git a/internal/bits/count.go b/internal/bits/count.go
new file mode 100644
index 0000000..b93057b
--- /dev/null
+++ b/internal/bits/count.go
@@ -0,0 +1,3 @@
+package bits
+
+func CountByte(data []byte, value byte) int { return countByte(data, value) }
diff --git a/internal/bits/count_amd64.go b/internal/bits/count_amd64.go
new file mode 100644
index 0000000..b8cc9e0
--- /dev/null
+++ b/internal/bits/count_amd64.go
@@ -0,0 +1,28 @@
+//go:build !purego
+
+package bits
+
+// This function is similar to using the standard bytes.Count function with a
+// one-byte separator. However, the implementation makes use of AVX-512 when
+// possible, which yields measurable throughput improvements:
+//
+// name       old time/op    new time/op    delta
+// CountByte    82.5ns ± 0%    43.9ns ± 0%  -46.74%  (p=0.000 n=10+10)
+//
+// name       old speed      new speed      delta
+// CountByte  49.6GB/s ± 0%  93.2GB/s ± 0%  +87.74%  (p=0.000 n=10+10)
+//
+//
+// On systems that do not have AVX-512, the AVX2 version of the code is also
+// optimized to make use of multiple register lanes, which gives a bit better
+// throughput than the standard library function:
+//
+// name       old time/op    new time/op    delta
+// CountByte    82.5ns ± 0%    61.0ns ± 0%  -26.04%  (p=0.000 n=10+10)
+//
+// name       old speed      new speed      delta
+// CountByte  49.6GB/s ± 0%  67.1GB/s ± 0%  +35.21%  (p=0.000 n=10+10)
+//
+//
+//go:noescape
+func countByte(data []byte, value byte) int
diff --git a/internal/bits/count_amd64.s b/internal/bits/count_amd64.s
new file mode 100644
index 0000000..502e9df
--- /dev/null
+++ b/internal/bits/count_amd64.s
@@ -0,0 +1,100 @@
+//go:build !purego
+
+#include "textflag.h"
+
+// func countByte(data []byte, value byte) int
+TEXT ·countByte(SB), NOSPLIT, $0-40
+    MOVQ data_base+0(FP), AX
+    MOVQ data_len+8(FP), CX
+    MOVQ value+24(FP), BX
+    MOVQ CX, DX // len
+    ADDQ AX, CX // end
+    XORQ SI, SI // count
+
+    CMPQ DX, $0
+    JE done
+
+    CMPQ DX, $256
+    JB loop
+
+    XORQ R12, R12
+    XORQ R13, R13
+    XORQ R14, R14
+    XORQ R15, R15
+
+    CMPB ·hasAVX512(SB), $0
+    JE initAVX2
+
+    CMPQ DX, $256
+    JB initAVX2
+
+    SHRQ $8, DX
+    SHLQ $8, DX
+    ADDQ AX, DX
+    VPBROADCASTB value+24(FP), Z0
+loopAVX512:
+    VMOVDQU64 (AX), Z1
+    VMOVDQU64 64(AX), Z2
+    VMOVDQU64 128(AX), Z3
+    VMOVDQU64 192(AX), Z4
+    VPCMPUB $0, Z0, Z1, K1
+    VPCMPUB $0, Z0, Z2, K2
+    VPCMPUB $0, Z0, Z3, K3
+    VPCMPUB $0, Z0, Z4, K4
+    KMOVQ K1, R8
+    KMOVQ K2, R9
+    KMOVQ K3, R10
+    KMOVQ K4, R11
+    POPCNTQ R8, R8
+    POPCNTQ R9, R9
+    POPCNTQ R10, R10
+    POPCNTQ R11, R11
+    ADDQ R8, R12
+    ADDQ R9, R13
+    ADDQ R10, R14
+    ADDQ R11, R15
+    ADDQ $256, AX
+    CMPQ AX, DX
+    JNE loopAVX512
+    ADDQ R12, R13
+    ADDQ R14, R15
+    ADDQ R13, SI
+    ADDQ R15, SI
+    JMP doneAVX
+initAVX2:
+    SHRQ $6, DX
+    SHLQ $6, DX
+    ADDQ AX, DX
+    VPBROADCASTB value+24(FP), Y0
+loopAVX2:
+    VMOVDQU (AX), Y1
+    VMOVDQU 32(AX), Y2
+    VPCMPEQB Y0, Y1, Y1
+    VPCMPEQB Y0, Y2, Y2
+    VPMOVMSKB Y1, R12
+    VPMOVMSKB Y2, R13
+    POPCNTL R12, R12
+    POPCNTL R13, R13
+    ADDQ R12, R14
+    ADDQ R13, R15
+    ADDQ $64, AX
+    CMPQ AX, DX
+    JNE loopAVX2
+    ADDQ R14, SI
+    ADDQ R15, SI
+doneAVX:
+    VZEROUPPER
+    CMPQ AX, CX
+    JE done
+loop:
+    MOVQ SI, DI
+    INCQ DI
+    MOVB (AX), R8
+    CMPB BX, R8
+    CMOVQEQ DI, SI
+    INCQ AX
+    CMPQ AX, CX
+    JNE loop
+done:
+    MOVQ SI, ret+32(FP)
+    RET
diff --git a/internal/bits/count_default.go b/internal/bits/count_default.go
new file mode 100644
index 0000000..edf5782
--- /dev/null
+++ b/internal/bits/count_default.go
@@ -0,0 +1,9 @@
+//go:build purego || !amd64
+
+package bits
+
+import "bytes"
+
+func countByte(data []byte, value byte) int {
+	return bytes.Count(data, []byte{value})
+}
diff --git a/internal/bits/max.go b/internal/bits/max.go
index 1747f60..da4b4cd 100644
--- a/internal/bits/max.go
+++ b/internal/bits/max.go
@@ -2,89 +2,28 @@ package bits
 
 import "bytes"
 
-func MaxBool(data []bool) (max bool) {
-	return !boolEqualAll(data, false)
-}
-
-func MaxInt32(data []int32) (max int32) {
-	if len(data) > 0 {
-		max = data[0]
-
-		for _, value := range data {
-			if value > max {
-				max = value
-			}
-		}
-	}
-	return max
-}
-
-func MaxInt64(data []int64) (max int64) {
-	if len(data) > 0 {
-		max = data[0]
+func MaxBool(data []bool) (max bool) { return maxBool(data) }
 
-		for _, value := range data {
-			if value > max {
-				max = value
-			}
-		}
-	}
-	return max
-}
-
-func MaxUint32(data []uint32) (max uint32) {
-	if len(data) > 0 {
-		max = data[0]
-
-		for _, value := range data {
-			if value > max {
-				max = value
-			}
-		}
-	}
-	return max
-}
+func MaxInt32(data []int32) (max int32) { return maxInt32(data) }
 
-func MaxUint64(data []uint64) (max uint64) {
-	if len(data) > 0 {
-		max = data[0]
+func MaxInt64(data []int64) (max int64) { return maxInt64(data) }
 
-		for _, value := range data {
-			if value > max {
-				max = value
-			}
-		}
-	}
-	return max
-}
+func MaxUint32(data []uint32) (max uint32) { return maxUint32(data) }
 
-func MaxFloat32(data []float32) (max float32) {
-	if len(data) > 0 {
-		max = data[0]
+func MaxUint64(data []uint64) (max uint64) { return maxUint64(data) }
 
-		for _, value := range data {
-			if value > max {
-				max = value
-			}
-		}
-	}
-	return max
-}
+func MaxFloat32(data []float32) (max float32) { return maxFloat32(data) }
 
-func MaxFloat64(data []float64) (max float64) {
-	if len(data) > 0 {
-		max = data[0]
+func MaxFloat64(data []float64) (max float64) { return maxFloat64(data) }
 
-		for _, value := range data {
-			if value > max {
-				max = value
-			}
-		}
+func MaxFixedLenByteArray(size int, data []byte) (max []byte) {
+	if size == 16 {
+		return maxBE128(data)
 	}
-	return max
+	return maxFixedLenByteArray(size, data)
 }
 
-func MaxFixedLenByteArray(size int, data []byte) (max []byte) {
+func maxFixedLenByteArray(size int, data []byte) (max []byte) {
 	if len(data) > 0 {
 		max = data[:size]
 
diff --git a/internal/bits/max_amd64.go b/internal/bits/max_amd64.go
new file mode 100644
index 0000000..ee71a7e
--- /dev/null
+++ b/internal/bits/max_amd64.go
@@ -0,0 +1,27 @@
+//go:build !purego
+
+package bits
+
+//go:noescape
+func maxBool(data []bool) bool
+
+//go:noescape
+func maxInt32(data []int32) int32
+
+//go:noescape
+func maxInt64(data []int64) int64
+
+//go:noescape
+func maxUint32(data []uint32) uint32
+
+//go:noescape
+func maxUint64(data []uint64) uint64
+
+//go:noescape
+func maxFloat32(data []float32) float32
+
+//go:noescape
+func maxFloat64(data []float64) float64
+
+// go:noescape
+func maxBE128(data []byte) []byte
diff --git a/internal/bits/max_amd64.h b/internal/bits/max_amd64.h
new file mode 100644
index 0000000..d940109
--- /dev/null
+++ b/internal/bits/max_amd64.h
@@ -0,0 +1,53 @@
+#ifndef PARQUET_GO_INTERNAL_BITS_MAX_AMD64
+#define PARQUET_GO_INTERNAL_BITS_MAX_AMD64
+
+// vpmaxu128 is a macro comparing unsigned 128 bits values held in the
+// `srcValues` and `maxValues` vectors. The `srcIndexes` and `maxIndexes`
+// vectors contain the indexes of elements in the value vectors. Remaining
+// K and R arguments are mask and general purpose registers needed to hold
+// temporary values during the computation. The last M argument is a mask
+// generated by vpmaxu128mask.
+//
+// The routine uses AVX-512 instructions (VPCMPUQ, VPBLENDMQ) to implement
+// the comparison of 128 bits values. The values are expected to be stored
+// in the vectors as a little-endian pair of two consecutive quad words.
+//
+// The results are written to the `maxValues` and `maxIndexes` vectors,
+// overwriting the inputs. `srcValues` and `srcIndexes` are read-only
+// parameters.
+//
+// At a high level, for two pairs of quad words formaxg two 128 bits values
+// A and B, the test implemented by this macro is:
+//
+//   A[1] > B[1] || (A[1] == B[1] && A[0] > B[0])
+//
+// Values in the source vector that evaluate to true on this expression are
+// written to the vector of maximum values, and their indexes are written to
+// the vector of indexes.
+#define vpmaxu128(srcValues, srcIndexes, maxValues, maxIndexes, K1, K2, R1, R2, R3, M) \
+    VPCMPUQ $0, maxValues, srcValues, K1 \
+    VPCMPUQ $6, maxValues, srcValues, K2 \
+    KMOVQ K1, R1 \
+    KMOVQ K2, R2 \
+    MOVQ R2, R3 \
+    SHLQ $1, R3 \
+    ANDQ R3, R1 \
+    ORQ R2, R1 \
+    ANDQ M, R1 \
+    MOVQ R1, R2 \
+    SHRQ $1, R2 \
+    ORQ R2, R1 \
+    KMOVQ R1, K1 \
+    VPBLENDMQ srcValues, maxValues, K1, maxValues \
+    VPBLENDMQ srcIndexes, maxIndexes, K1, maxIndexes
+
+// vpmaxu128mask is a macro used to initialize the mask passed as last argument
+// to vpmaxu128. The argument M is intended to be a general purpose register.
+//
+// The bit mask is used to merge the results of the "greater than" and "equal"
+// comparison that are performed on each lane of maximum vectors. The upper bits
+// are used to compute results of the operation to determine which of the pairs
+// of quad words representing the 128 bits elements are the maximums.
+#define vpmaxu128mask(M) MOVQ $0b10101010, M
+
+#endif
diff --git a/internal/bits/max_amd64.s b/internal/bits/max_amd64.s
new file mode 100644
index 0000000..eb94f33
--- /dev/null
+++ b/internal/bits/max_amd64.s
@@ -0,0 +1,600 @@
+//go:build !purego
+
+#include "textflag.h"
+#include "max_amd64.h"
+
+// func maxBool(data []bool) bool
+TEXT ·maxBool(SB), NOSPLIT, $-25
+    MOVQ data_base+0(FP), AX
+    MOVQ data_len+8(FP), CX
+
+    CMPQ CX, $0
+    JE false
+    XORQ SI, SI
+
+    CMPB ·hasAVX512(SB), $0
+    JE loop
+
+    CMPQ CX, $128
+    JB loop
+
+    MOVQ CX, DX
+    SHRQ $7, DX
+    SHLQ $7, DX
+
+    VPXORQ Z0, Z0, Z0
+    VPXORQ Z1, Z1, Z1
+loop128:
+    VMOVDQU64 (AX)(SI*1), Z2
+    VMOVDQU64 64(AX)(SI*1), Z3
+    VPORQ Z2, Z0, Z0
+    VPORQ Z3, Z1, Z1
+    ADDQ $128, SI
+    CMPQ SI, DX
+    JNE loop128
+
+    VPXORQ Z2, Z2, Z2
+    VPORQ Z1, Z0, Z0
+    VPCMPUB $0, Z0, Z2, K1
+    VZEROUPPER
+
+    KMOVQ K1, DI
+    POPCNTQ DI, DI
+    CMPQ DI, $64
+    JNE true
+
+    CMPQ SI, CX
+    JE false
+loop:
+    MOVBQZX (AX)(SI*1), DX
+    CMPQ DX, $0
+    JNE true
+    INCQ SI
+    CMPQ SI, CX
+    JNE loop
+false:
+    MOVB $0, ret+24(FP)
+    RET
+true:
+    MOVB $1, ret+24(FP)
+    RET
+
+// func maxInt32(data []int32) int32
+TEXT ·maxInt32(SB), NOSPLIT, $-28
+    MOVQ data_base+0(FP), AX
+    MOVQ data_len+8(FP), CX
+    XORQ BX, BX
+
+    CMPQ CX, $0
+    JE done
+    XORQ SI, SI
+    MOVLQZX (AX), BX
+
+    CMPB ·hasAVX512(SB), $0
+    JE loop
+
+    CMPQ CX, $32
+    JB loop
+
+    MOVQ CX, DI
+    SHRQ $5, DI
+    SHLQ $5, DI
+    VPBROADCASTD (AX), Z0
+loop32:
+    VMOVDQU32 (AX)(SI*4), Z1
+    VMOVDQU32 64(AX)(SI*4), Z2
+    VPMAXSD Z1, Z0, Z0
+    VPMAXSD Z2, Z0, Z0
+    ADDQ $32, SI
+    CMPQ SI, DI
+    JNE loop32
+
+    VMOVDQU32 swap32+0(SB), Z1
+    VPERMI2D Z0, Z0, Z1
+    VPMAXSD Y1, Y0, Y0
+
+    VMOVDQU32 swap32+32(SB), Y1
+    VPERMI2D Y0, Y0, Y1
+    VPMAXSD X1, X0, X0
+
+    VMOVDQU32 swap32+48(SB), X1
+    VPERMI2D X0, X0, X1
+    VPMAXSD X1, X0, X0
+    VZEROUPPER
+
+    MOVQ X0, DX
+    MOVL DX, BX
+    SHRQ $32, DX
+    CMPL DX, BX
+    CMOVLGT DX, BX
+
+    CMPQ SI, CX
+    JE done
+loop:
+    MOVLQZX (AX)(SI*4), DX
+    CMPL DX, BX
+    CMOVLGT DX, BX
+    INCQ SI
+    CMPQ SI, CX
+    JNE loop
+done:
+    MOVL BX, ret+24(FP)
+    RET
+
+// func maxInt64(data []int64) int64
+TEXT ·maxInt64(SB), NOSPLIT, $-32
+    MOVQ data_base+0(FP), AX
+    MOVQ data_len+8(FP), CX
+    XORQ BX, BX
+
+    CMPQ CX, $0
+    JE done
+    XORQ SI, SI
+    MOVQ (AX), BX
+
+    CMPB ·hasAVX512(SB), $0
+    JE loop
+
+    CMPQ CX, $32
+    JB loop
+
+    MOVQ CX, DI
+    SHRQ $5, DI
+    SHLQ $5, DI
+    VPBROADCASTQ (AX), Z0
+loop32:
+    VMOVDQU64 (AX)(SI*8), Z1
+    VMOVDQU64 64(AX)(SI*8), Z2
+    VMOVDQU64 128(AX)(SI*8), Z3
+    VMOVDQU64 192(AX)(SI*8), Z4
+    VPMAXSQ Z1, Z2, Z5
+    VPMAXSQ Z3, Z4, Z6
+    VPMAXSQ Z5, Z6, Z1
+    VPMAXSQ Z1, Z0, Z0
+    ADDQ $32, SI
+    CMPQ SI, DI
+    JNE loop32
+
+    VMOVDQU32 swap32+0(SB), Z1
+    VPERMI2D Z0, Z0, Z1
+    VPMAXSQ Y1, Y0, Y0
+
+    VMOVDQU32 swap32+32(SB), Y1
+    VPERMI2D Y0, Y0, Y1
+    VPMAXSQ X1, X0, X0
+
+    VMOVDQU32 swap32+48(SB), X1
+    VPERMI2D X0, X0, X1
+    VPMAXSQ X1, X0, X0
+    VZEROUPPER
+
+    MOVQ X0, BX
+    CMPQ SI, CX
+    JE done
+loop:
+    MOVQ (AX)(SI*8), DX
+    CMPQ DX, BX
+    CMOVQGT DX, BX
+    INCQ SI
+    CMPQ SI, CX
+    JNE loop
+done:
+    MOVQ BX, ret+24(FP)
+    RET
+
+// func maxUint32(data []int32) int32
+TEXT ·maxUint32(SB), NOSPLIT, $-28
+    MOVQ data_base+0(FP), AX
+    MOVQ data_len+8(FP), CX
+    XORQ BX, BX
+
+    CMPQ CX, $0
+    JE done
+    XORQ SI, SI
+    MOVLQZX (AX), BX
+
+    CMPB ·hasAVX512(SB), $0
+    JE loop
+
+    CMPQ CX, $32
+    JB loop
+
+    MOVQ CX, DI
+    SHRQ $5, DI
+    SHLQ $5, DI
+    VPBROADCASTD (AX), Z0
+loop32:
+    VMOVDQU32 (AX)(SI*4), Z1
+    VMOVDQU32 64(AX)(SI*4), Z2
+    VPMAXUD Z1, Z0, Z0
+    VPMAXUD Z2, Z0, Z0
+    ADDQ $32, SI
+    CMPQ SI, DI
+    JNE loop32
+
+    VMOVDQU32 swap32+0(SB), Z1
+    VPERMI2D Z0, Z0, Z1
+    VPMAXUD Y1, Y0, Y0
+
+    VMOVDQU32 swap32+32(SB), Y1
+    VPERMI2D Y0, Y0, Y1
+    VPMAXUD X1, X0, X0
+
+    VMOVDQU32 swap32+48(SB), X1
+    VPERMI2D X0, X0, X1
+    VPMAXUD X1, X0, X0
+    VZEROUPPER
+
+    MOVQ X0, DX
+    MOVL DX, BX
+    SHRQ $32, DX
+    CMPL DX, BX
+    CMOVLHI DX, BX
+
+    CMPQ SI, CX
+    JE done
+loop:
+    MOVLQZX (AX)(SI*4), DX
+    CMPL DX, BX
+    CMOVLHI DX, BX
+    INCQ SI
+    CMPQ SI, CX
+    JNE loop
+done:
+    MOVL BX, ret+24(FP)
+    RET
+
+// func maxUint64(data []uint64) uint64
+TEXT ·maxUint64(SB), NOSPLIT, $-32
+    MOVQ data_base+0(FP), AX
+    MOVQ data_len+8(FP), CX
+    XORQ BX, BX
+
+    CMPQ CX, $0
+    JE done
+    XORQ SI, SI
+    MOVQ (AX), BX
+
+    CMPB ·hasAVX512(SB), $0
+    JE loop
+
+    CMPQ CX, $32
+    JB loop
+
+    MOVQ CX, DI
+    SHRQ $5, DI
+    SHLQ $5, DI
+    VPBROADCASTQ (AX), Z0
+loop32:
+    VMOVDQU64 (AX)(SI*8), Z1
+    VMOVDQU64 64(AX)(SI*8), Z2
+    VMOVDQU64 128(AX)(SI*8), Z3
+    VMOVDQU64 192(AX)(SI*8), Z4
+    VPMAXUQ Z1, Z2, Z5
+    VPMAXUQ Z3, Z4, Z6
+    VPMAXUQ Z5, Z6, Z1
+    VPMAXUQ Z1, Z0, Z0
+    ADDQ $32, SI
+    CMPQ SI, DI
+    JNE loop32
+
+    VMOVDQU32 swap32+0(SB), Z1
+    VPERMI2D Z0, Z0, Z1
+    VPMAXUQ Y1, Y0, Y0
+
+    VMOVDQU32 swap32+32(SB), Y1
+    VPERMI2D Y0, Y0, Y1
+    VPMAXUQ X1, X0, X0
+
+    VMOVDQU32 swap32+48(SB), X1
+    VPERMI2D X0, X0, X1
+    VPMAXUQ X1, X0, X0
+    VZEROUPPER
+
+    MOVQ X0, BX
+    CMPQ SI, CX
+    JE done
+loop:
+    MOVQ (AX)(SI*8), DX
+    CMPQ DX, BX
+    CMOVQHI DX, BX
+    INCQ SI
+    CMPQ SI, CX
+    JNE loop
+done:
+    MOVQ BX, ret+24(FP)
+    RET
+
+// func maxFloat32(data []float32) float32
+TEXT ·maxFloat32(SB), NOSPLIT, $-28
+    MOVQ data_base+0(FP), AX
+    MOVQ data_len+8(FP), CX
+    XORQ BX, BX
+
+    CMPQ CX, $0
+    JE done
+    XORPS X0, X0
+    XORPS X1, X1
+    XORQ SI, SI
+    MOVLQZX (AX), BX
+    MOVQ BX, X0
+
+    CMPB ·hasAVX512(SB), $0
+    JE loop
+
+    CMPQ CX, $64
+    JB loop
+
+    MOVQ CX, DI
+    SHRQ $6, DI
+    SHLQ $6, DI
+    VPBROADCASTD (AX), Z0
+loop64:
+    VMOVDQU32 (AX)(SI*4), Z1
+    VMOVDQU32 64(AX)(SI*4), Z2
+    VMOVDQU32 128(AX)(SI*4), Z3
+    VMOVDQU32 192(AX)(SI*4), Z4
+    VMAXPS Z1, Z2, Z5
+    VMAXPS Z3, Z4, Z6
+    VMAXPS Z5, Z6, Z1
+    VMAXPS Z1, Z0, Z0
+    ADDQ $64, SI
+    CMPQ SI, DI
+    JNE loop64
+
+    VMOVDQU32 swap32+0(SB), Z1
+    VPERMI2D Z0, Z0, Z1
+    VMAXPS Y1, Y0, Y0
+
+    VMOVDQU32 swap32+32(SB), Y1
+    VPERMI2D Y0, Y0, Y1
+    VMAXPS X1, X0, X0
+
+    VMOVDQU32 swap32+48(SB), X1
+    VPERMI2D X0, X0, X1
+    VMAXPS X1, X0, X0
+    VZEROUPPER
+
+    MOVAPS X0, X1
+    PSRLQ $32, X1
+    MOVQ X0, BX
+    MOVQ X1, DX
+    UCOMISS X0, X1
+    CMOVLHI DX, BX
+
+    CMPQ SI, CX
+    JE done
+    MOVQ BX, X0
+loop:
+    MOVLQZX (AX)(SI*4), DX
+    MOVQ DX, X1
+    UCOMISS X0, X1
+    CMOVLHI DX, BX
+    MOVQ BX, X0
+    INCQ SI
+    CMPQ SI, CX
+    JNE loop
+done:
+    MOVL BX, ret+24(FP)
+    RET
+
+// func maxFloat64(data []float64) float64
+TEXT ·maxFloat64(SB), NOSPLIT, $-32
+    MOVQ data_base+0(FP), AX
+    MOVQ data_len+8(FP), CX
+    XORQ BX, BX
+
+    CMPQ CX, $0
+    JE done
+    XORPD X0, X0
+    XORPD X1, X1
+    XORQ SI, SI
+    MOVQ (AX), BX
+    MOVQ BX, X0
+
+    CMPB ·hasAVX512(SB), $0
+    JE loop
+
+    CMPQ CX, $32
+    JB loop
+
+    MOVQ CX, DI
+    SHRQ $5, DI
+    SHLQ $5, DI
+    VPBROADCASTQ (AX), Z0
+loop32:
+    VMOVDQU64 (AX)(SI*8), Z1
+    VMOVDQU64 64(AX)(SI*8), Z2
+    VMOVDQU64 128(AX)(SI*8), Z3
+    VMOVDQU64 192(AX)(SI*8), Z4
+    VMAXPD Z1, Z2, Z5
+    VMAXPD Z3, Z4, Z6
+    VMAXPD Z5, Z6, Z1
+    VMAXPD Z1, Z0, Z0
+    ADDQ $32, SI
+    CMPQ SI, DI
+    JNE loop32
+
+    VMOVDQU64 swap32+0(SB), Z1
+    VPERMI2D Z0, Z0, Z1
+    VMAXPD Y1, Y0, Y0
+
+    VMOVDQU64 swap32+32(SB), Y1
+    VPERMI2D Y0, Y0, Y1
+    VMAXPD X1, X0, X0
+
+    VMOVDQU64 swap32+48(SB), X1
+    VPERMI2D X0, X0, X1
+    VMAXPD X1, X0, X0
+    VZEROUPPER
+
+    MOVQ X0, BX
+    CMPQ SI, CX
+    JE done
+loop:
+    MOVQ (AX)(SI*8), DX
+    MOVQ DX, X1
+    UCOMISD X0, X1
+    CMOVQHI DX, BX
+    MOVQ BX, X0
+    INCQ SI
+    CMPQ SI, CX
+    JNE loop
+done:
+    MOVQ BX, ret+24(FP)
+    RET
+
+// func maxBE128(data []byte) []byte
+TEXT ·maxBE128(SB), NOSPLIT, $-48
+    MOVQ data_base+0(FP), AX
+    MOVQ data_len+8(FP), CX
+    CMPQ CX, $0
+    JE null
+
+    MOVQ CX, DX // len
+    MOVQ AX, BX // max
+    ADDQ AX, CX // end
+
+    CMPQ DX, $256
+    JB loop
+
+    CMPQ ·hasAVX512(SB), $0
+    JB loop
+
+    // Z19 holds a vector of the count by which we increment the vectors of
+    // swap at each loop iteration.
+    MOVQ $16, DI
+    VPBROADCASTQ DI, Z19
+
+    // Z31 holds the shuffle mask used to convert 128 bits elements from big to
+    // little endian so we can apply vectorized comparison instructions.
+    VMOVDQU64 bswap128(SB), Z31
+
+    // These vectors hold four lanes of maximum values found in the input.
+    VBROADCASTI64X2 (AX), Z0
+    VPSHUFB Z31, Z0, Z0
+    VMOVDQU64 Z0, Z5
+    VMOVDQU64 Z0, Z10
+    VMOVDQU64 Z0, Z15
+
+    // These vectors hold four lanes of swap of maximum values.
+    //
+    // We initialize them at zero because we broadcast the first value of the
+    // input in the vectors that track the maximums of each lane; in other
+    // words, we assume the maximum value is at the first offset and work our
+    // way up from there.
+    VPXORQ Z2, Z2, Z2
+    VPXORQ Z7, Z7, Z7
+    VPXORQ Z12, Z12, Z12
+    VPXORQ Z17, Z17, Z17
+
+    // These vectors are used to compute the swap of maximum values held
+    // in [Z1, Z5, Z10, Z15]. Each vector holds a contiguous sequence of
+    // swap; for example, Z3 is initialized with [0, 1, 2, 3]. At each
+    // loop iteration, the swap are incremented by the number of elements
+    // consumed from the input (4x4=16).
+    VMOVDQU64 indexes128(SB), Z3
+    VPXORQ Z8, Z8, Z8
+    VPXORQ Z13, Z13, Z13
+    VPXORQ Z18, Z18, Z18
+    MOVQ $4, DI
+    VPBROADCASTQ DI, Z1
+    VPADDQ Z1, Z3, Z8
+    VPADDQ Z1, Z8, Z13
+    VPADDQ Z1, Z13, Z18
+
+    // This bit mask is used to merge the results of the "less than" and "equal"
+    // comparison that we perform on each lane of maximum vectors. We use the
+    // upper bits to compute four results of the operation which determines
+    // which of the pair of quad words representing the 128 bits elements is the
+    // maximum.
+    vpmaxu128mask(DI)
+    SHRQ $8, DX
+    SHLQ $8, DX
+    ADDQ AX, DX
+loop16:
+    // Compute 4x4 maximum values in vector registers, along with their swap
+    // in the input array.
+    VMOVDQU64 (AX), Z1
+    VMOVDQU64 64(AX), Z6
+    VMOVDQU64 128(AX), Z11
+    VMOVDQU64 192(AX), Z16
+    VPSHUFB Z31, Z1, Z1
+    VPSHUFB Z31, Z6, Z6
+    VPSHUFB Z31, Z11, Z11
+    VPSHUFB Z31, Z16, Z16
+    vpmaxu128(Z1, Z3, Z0, Z2, K1, K2, R8, R9, R10, DI)
+    vpmaxu128(Z6, Z8, Z5, Z7, K3, K4, R11, R12, R13, DI)
+    vpmaxu128(Z11, Z13, Z10, Z12, K1, K2, R8, R9, R10, DI)
+    vpmaxu128(Z16, Z18, Z15, Z17, K3, K4, R11, R12, R13, DI)
+    VPADDQ Z19, Z3, Z3
+    VPADDQ Z19, Z8, Z8
+    VPADDQ Z19, Z13, Z13
+    VPADDQ Z19, Z18, Z18
+    ADDQ $256, AX
+    CMPQ AX, DX
+    JB loop16
+
+    // After the loop completed, we need to merge the lanes that each contain
+    // 4 maximum values (so 16 total candidate at this stage). The results are
+    // reduced into 4 candidates in Z0, with their swap in Z2.
+    vpmaxu128(Z10, Z12, Z0, Z2, K1, K2, R8, R9, R10, DI)
+    vpmaxu128(Z15, Z17, Z5, Z7, K3, K4, R11, R12, R13, DI)
+    vpmaxu128(Z5, Z7, Z0, Z2, K1, K2, R8, R9, R10, DI)
+
+    // Further reduce the results by swapping the upper and lower parts of the
+    // vector registers, and comparing them to determaxe which values are the
+    // smallest. We compare 2x2 values at this step, then 2x1 values at the next
+    // to find the index of the maximum.
+    VMOVDQU64 swap64+0(SB), Z1
+    VMOVDQU64 swap64+0(SB), Z3
+    VPERMI2Q Z0, Z0, Z1
+    VPERMI2Q Z2, Z2, Z3
+    vpmaxu128(Y1, Y3, Y0, Y2, K1, K2, R8, R9, R10, DI)
+
+    VMOVDQU64 swap64+32(SB), Y1
+    VMOVDQU64 swap64+32(SB), Y3
+    VPERMI2Q Y0, Y0, Y1
+    VPERMI2Q Y2, Y2, Y3
+    vpmaxu128(X1, X3, X0, X2, K1, K2, R8, R9, R10, DI)
+    VZEROUPPER
+
+    // Extract the index of the maximum value computed in the lower 64 bits of
+    // X2 and position the BX pointer at the index of the maximum value.
+    MOVQ X2, DX
+    SHLQ $4, DX
+    ADDQ DX, BX
+    CMPQ AX, CX
+    JE done
+
+    // Unless the input was aligned on 256 bytes, we need to perform a few more
+    // iterations on the remaining elements.
+    //
+    // This loop is also taken if the CPU has no support for AVX-512.
+loop:
+    MOVBEQQ (AX), R8
+    MOVBEQQ (BX), R9
+    CMPQ R8, R9
+    JA more
+    JB next
+    MOVBEQQ 8(AX), R8
+    MOVBEQQ 8(BX), R9
+    CMPQ R8, R9
+    JAE next
+more:
+    MOVQ AX, BX
+next:
+    ADDQ $16, AX
+    CMPQ AX, CX
+    JB loop
+done:
+    MOVQ BX, ret+24(FP)
+    MOVQ $16, ret+32(FP)
+    MOVQ $16, ret+40(FP)
+    RET
+null:
+    XORQ BX, BX
+    MOVQ BX, min+24(FP)
+    MOVQ BX, min+32(FP)
+    MOVQ BX, min+40(FP)
+    RET
diff --git a/internal/bits/max_default.go b/internal/bits/max_default.go
new file mode 100644
index 0000000..f0c203f
--- /dev/null
+++ b/internal/bits/max_default.go
@@ -0,0 +1,110 @@
+//go:build purego || !amd64
+
+package bits
+
+import "encoding/binary"
+
+func maxBool(data []bool) (max bool) {
+	return len(data) > 0 && !boolEqualAll(data, false)
+}
+
+func maxInt32(data []int32) (max int32) {
+	if len(data) > 0 {
+		max = data[0]
+
+		for _, value := range data {
+			if value > max {
+				max = value
+			}
+		}
+	}
+	return max
+}
+
+func maxInt64(data []int64) (max int64) {
+	if len(data) > 0 {
+		max = data[0]
+
+		for _, value := range data {
+			if value > max {
+				max = value
+			}
+		}
+	}
+	return max
+}
+
+func maxUint32(data []uint32) (max uint32) {
+	if len(data) > 0 {
+		max = data[0]
+
+		for _, value := range data {
+			if value > max {
+				max = value
+			}
+		}
+	}
+	return max
+}
+
+func maxUint64(data []uint64) (max uint64) {
+	if len(data) > 0 {
+		max = data[0]
+
+		for _, value := range data {
+			if value > max {
+				max = value
+			}
+		}
+	}
+	return max
+}
+
+func maxFloat32(data []float32) (max float32) {
+	if len(data) > 0 {
+		max = data[0]
+
+		for _, value := range data {
+			if value > max {
+				max = value
+			}
+		}
+	}
+	return max
+}
+
+func maxFloat64(data []float64) (max float64) {
+	if len(data) > 0 {
+		max = data[0]
+
+		for _, value := range data {
+			if value > max {
+				max = value
+			}
+		}
+	}
+	return max
+}
+
+func maxBE128(data []byte) (min []byte) {
+	if len(data) > 0 {
+		be128 := BytesToUint128(data)
+		m := binary.BigEndian.Uint64(be128[0][:8])
+		j := 0
+		for i := 1; i < len(be128); i++ {
+			x := binary.BigEndian.Uint64(be128[i][:8])
+			switch {
+			case x > m:
+				m, j = x, i
+			case x == m:
+				y := binary.BigEndian.Uint64(be128[i][8:])
+				n := binary.BigEndian.Uint64(be128[j][8:])
+				if y > n {
+					m, j = x, i
+				}
+			}
+		}
+		min = be128[j][:]
+	}
+	return min
+}
diff --git a/internal/bits/min.go b/internal/bits/min.go
index f4c4c5f..fa97d9d 100644
--- a/internal/bits/min.go
+++ b/internal/bits/min.go
@@ -1,90 +1,31 @@
 package bits
 
-import "bytes"
+import (
+	"bytes"
+)
 
-func MinBool(data []bool) (min bool) {
-	return boolEqualAll(data, true)
-}
-
-func MinInt32(data []int32) (min int32) {
-	if len(data) > 0 {
-		min = data[0]
-
-		for _, value := range data {
-			if value < min {
-				min = value
-			}
-		}
-	}
-	return min
-}
-
-func MinInt64(data []int64) (min int64) {
-	if len(data) > 0 {
-		min = data[0]
+func MinBool(data []bool) (min bool) { return minBool(data) }
 
-		for _, value := range data {
-			if value < min {
-				min = value
-			}
-		}
-	}
-	return min
-}
-
-func MinUint32(data []uint32) (min uint32) {
-	if len(data) > 0 {
-		min = data[0]
-
-		for _, value := range data {
-			if value < min {
-				min = value
-			}
-		}
-	}
-	return min
-}
+func MinInt32(data []int32) (min int32) { return minInt32(data) }
 
-func MinUint64(data []uint64) (min uint64) {
-	if len(data) > 0 {
-		min = data[0]
+func MinInt64(data []int64) (min int64) { return minInt64(data) }
 
-		for _, value := range data {
-			if value < min {
-				min = value
-			}
-		}
-	}
-	return min
-}
+func MinUint32(data []uint32) (min uint32) { return minUint32(data) }
 
-func MinFloat32(data []float32) (min float32) {
-	if len(data) > 0 {
-		min = data[0]
+func MinUint64(data []uint64) (min uint64) { return minUint64(data) }
 
-		for _, value := range data {
-			if value < min {
-				min = value
-			}
-		}
-	}
-	return min
-}
+func MinFloat32(data []float32) (min float32) { return minFloat32(data) }
 
-func MinFloat64(data []float64) (min float64) {
-	if len(data) > 0 {
-		min = data[0]
+func MinFloat64(data []float64) (min float64) { return minFloat64(data) }
 
-		for _, value := range data {
-			if value < min {
-				min = value
-			}
-		}
+func MinFixedLenByteArray(size int, data []byte) (min []byte) {
+	if size == 16 {
+		return minBE128(data)
 	}
-	return min
+	return minFixedLenByteArray(size, data)
 }
 
-func MinFixedLenByteArray(size int, data []byte) (min []byte) {
+func minFixedLenByteArray(size int, data []byte) (min []byte) {
 	if len(data) > 0 {
 		min = data[:size]
 
diff --git a/internal/bits/min_amd64.go b/internal/bits/min_amd64.go
new file mode 100644
index 0000000..2700db3
--- /dev/null
+++ b/internal/bits/min_amd64.go
@@ -0,0 +1,27 @@
+//go:build !purego
+
+package bits
+
+//go:noescape
+func minBool(data []bool) bool
+
+//go:noescape
+func minInt32(data []int32) int32
+
+//go:noescape
+func minInt64(data []int64) int64
+
+//go:noescape
+func minUint32(data []uint32) uint32
+
+//go:noescape
+func minUint64(data []uint64) uint64
+
+//go:noescape
+func minFloat32(data []float32) float32
+
+//go:noescape
+func minFloat64(data []float64) float64
+
+// go:noescape
+func minBE128(data []byte) []byte
diff --git a/internal/bits/min_amd64.h b/internal/bits/min_amd64.h
new file mode 100644
index 0000000..b394355
--- /dev/null
+++ b/internal/bits/min_amd64.h
@@ -0,0 +1,53 @@
+#ifndef PARQUET_GO_INTERNAL_BITS_MIN_AMD64
+#define PARQUET_GO_INTERNAL_BITS_MIN_AMD64
+
+// vpminu128 is a macro comparing unsigned 128 bits values held in the
+// `srcValues` and `minValues` vectors. The `srcIndexes` and `minIndexes`
+// vectors contain the indexes of elements in the value vectors. Remaining
+// K and R arguments are mask and general purpose registers needed to hold
+// temporary values during the computation. The last M argument is a mask
+// generated by vpminu128mask.
+//
+// The routine uses AVX-512 instructions (VPCMPUQ, VPBLENDMQ) to implement
+// the comparison of 128 bits values. The values are expected to be stored
+// in the vectors as a little-endian pair of two consecutive quad words.
+//
+// The results are written to the `minValues` and `minIndexes` vectors,
+// overwriting the inputs. `srcValues` and `srcIndexes` are read-only
+// parameters.
+//
+// At a high level, for two pairs of quad words forming two 128 bits values
+// A and B, the test implemented by this macro is:
+//
+//   A[1] < B[1] || (A[1] == B[1] && A[0] < B[0])
+//
+// Values in the source vector that evalute to true on this expression are
+// written to the vector of minimum values, and their indexes are written to
+// the vector of indexes.
+#define vpminu128(srcValues, srcIndexes, minValues, minIndexes, K1, K2, R1, R2, R3, M) \
+    VPCMPUQ $0, minValues, srcValues, K1 \
+    VPCMPUQ $1, minValues, srcValues, K2 \
+    KMOVQ K1, R1 \
+    KMOVQ K2, R2 \
+    MOVQ R2, R3 \
+    SHLQ $1, R3 \
+    ANDQ R3, R1 \
+    ORQ R2, R1 \
+    ANDQ M, R1 \
+    MOVQ R1, R2 \
+    SHRQ $1, R2 \
+    ORQ R2, R1 \
+    KMOVQ R1, K1 \
+    VPBLENDMQ srcValues, minValues, K1, minValues \
+    VPBLENDMQ srcIndexes, minIndexes, K1, minIndexes
+
+// vpminu128mask is a macro used to initialize the mask passed as last argument
+// to vpminu128. The argument M is intended to be a general purpose register.
+//
+// The bit mask is used to merge the results of the "less than" and "equal"
+// comparison that are performed on each lane of minimum vectors. The upper bits
+// are used to compute results of the operation to determines which of the pairs
+// of quad words representing the 128 bits elements are the minimums.
+#define vpminu128mask(M) MOVQ $0b10101010, M
+
+#endif
diff --git a/internal/bits/min_amd64.s b/internal/bits/min_amd64.s
new file mode 100644
index 0000000..e3b0622
--- /dev/null
+++ b/internal/bits/min_amd64.s
@@ -0,0 +1,596 @@
+//go:build !purego
+
+#include "textflag.h"
+#include "min_amd64.h"
+
+// func minBool(data []bool) bool
+TEXT ·minBool(SB), NOSPLIT, $-25
+    MOVQ data_base+0(FP), AX
+    MOVQ data_len+8(FP), CX
+
+    CMPQ CX, $0
+    JE false
+    XORQ SI, SI
+
+    CMPB ·hasAVX512(SB), $0
+    JE loop
+
+    CMPQ CX, $128
+    JB loop
+
+    MOVQ CX, DX
+    SHRQ $7, DX
+    SHLQ $7, DX
+
+    MOVQ $0x0101010101010101, DI
+    VPBROADCASTQ DI, Z0
+    VPBROADCASTQ DI, Z1
+loop128:
+    VMOVDQU64 (AX)(SI*1), Z2
+    VMOVDQU64 64(AX)(SI*1), Z3
+    VPANDQ Z2, Z0, Z0
+    VPANDQ Z3, Z1, Z1
+    ADDQ $128, SI
+    CMPQ SI, DX
+    JNE loop128
+
+    VPBROADCASTQ DI, Z2
+    VPANDQ Z1, Z0, Z0
+    VPCMPUB $0, Z0, Z2, K1
+    VZEROUPPER
+
+    KMOVQ K1, DI
+    POPCNTQ DI, DI
+    CMPQ DI, $64
+    JNE false
+
+    CMPQ SI, CX
+    JE true
+loop:
+    MOVBQZX (AX)(SI*1), DX
+    CMPQ DX, $0
+    JE false
+    INCQ SI
+    CMPQ SI, CX
+    JNE loop
+true:
+    MOVB $1, ret+24(FP)
+    RET
+false:
+    MOVB $0, ret+24(FP)
+    RET
+
+// func minInt32(data []int32) int32
+TEXT ·minInt32(SB), NOSPLIT, $-28
+    MOVQ data_base+0(FP), AX
+    MOVQ data_len+8(FP), CX
+    XORQ BX, BX
+
+    CMPQ CX, $0
+    JE done
+    XORQ SI, SI
+    MOVLQZX (AX), BX
+
+    CMPB ·hasAVX512(SB), $0
+    JE loop
+
+    CMPQ CX, $32
+    JB loop
+
+    MOVQ CX, DI
+    SHRQ $5, DI
+    SHLQ $5, DI
+    VPBROADCASTD (AX), Z0
+loop32:
+    VMOVDQU32 (AX)(SI*4), Z1
+    VMOVDQU32 64(AX)(SI*4), Z2
+    VPMINSD Z1, Z0, Z0
+    VPMINSD Z2, Z0, Z0
+    ADDQ $32, SI
+    CMPQ SI, DI
+    JNE loop32
+
+    VMOVDQU32 swap32+0(SB), Z1
+    VPERMI2D Z0, Z0, Z1
+    VPMINSD Y1, Y0, Y0
+
+    VMOVDQU32 swap32+32(SB), Y1
+    VPERMI2D Y0, Y0, Y1
+    VPMINSD X1, X0, X0
+
+    VMOVDQU32 swap32+48(SB), X1
+    VPERMI2D X0, X0, X1
+    VPMINSD X1, X0, X0
+    VZEROUPPER
+
+    MOVQ X0, DX
+    MOVL DX, BX
+    SHRQ $32, DX
+    CMPL DX, BX
+    CMOVLLT DX, BX
+
+    CMPQ SI, CX
+    JE done
+loop:
+    MOVLQZX (AX)(SI*4), DX
+    CMPL DX, BX
+    CMOVLLT DX, BX
+    INCQ SI
+    CMPQ SI, CX
+    JNE loop
+done:
+    MOVL BX, ret+24(FP)
+    RET
+
+// func minInt64(data []int64) int64
+TEXT ·minInt64(SB), NOSPLIT, $-32
+    MOVQ data_base+0(FP), AX
+    MOVQ data_len+8(FP), CX
+    XORQ BX, BX
+
+    CMPQ CX, $0
+    JE done
+    XORQ SI, SI
+    MOVQ (AX), BX
+
+    CMPB ·hasAVX512(SB), $0
+    JE loop
+
+    CMPQ CX, $32
+    JB loop
+
+    MOVQ CX, DI
+    SHRQ $5, DI
+    SHLQ $5, DI
+    VPBROADCASTQ (AX), Z0
+loop32:
+    VMOVDQU64 (AX)(SI*8), Z1
+    VMOVDQU64 64(AX)(SI*8), Z2
+    VMOVDQU64 128(AX)(SI*8), Z3
+    VMOVDQU64 192(AX)(SI*8), Z4
+    VPMINSQ Z1, Z2, Z5
+    VPMINSQ Z3, Z4, Z6
+    VPMINSQ Z5, Z6, Z1
+    VPMINSQ Z1, Z0, Z0
+    ADDQ $32, SI
+    CMPQ SI, DI
+    JNE loop32
+
+    VMOVDQU32 swap32+0(SB), Z1
+    VPERMI2D Z0, Z0, Z1
+    VPMINSQ Y1, Y0, Y0
+
+    VMOVDQU32 swap32+32(SB), Y1
+    VPERMI2D Y0, Y0, Y1
+    VPMINSQ X1, X0, X0
+
+    VMOVDQU32 swap32+48(SB), X1
+    VPERMI2D X0, X0, X1
+    VPMINSQ X1, X0, X0
+    VZEROUPPER
+
+    MOVQ X0, BX
+    CMPQ SI, CX
+    JE done
+loop:
+    MOVQ (AX)(SI*8), DX
+    CMPQ DX, BX
+    CMOVQLT DX, BX
+    INCQ SI
+    CMPQ SI, CX
+    JNE loop
+done:
+    MOVQ BX, ret+24(FP)
+    RET
+
+// func minUint32(data []int32) int32
+TEXT ·minUint32(SB), NOSPLIT, $-28
+    MOVQ data_base+0(FP), AX
+    MOVQ data_len+8(FP), CX
+    XORQ BX, BX
+
+    CMPQ CX, $0
+    JE done
+    XORQ SI, SI
+    MOVLQZX (AX), BX
+
+    CMPB ·hasAVX512(SB), $0
+    JE loop
+
+    CMPQ CX, $32
+    JB loop
+
+    MOVQ CX, DI
+    SHRQ $5, DI
+    SHLQ $5, DI
+    VPBROADCASTD (AX), Z0
+loop32:
+    VMOVDQU32 (AX)(SI*4), Z1
+    VMOVDQU32 64(AX)(SI*4), Z2
+    VPMINUD Z1, Z0, Z0
+    VPMINUD Z2, Z0, Z0
+    ADDQ $32, SI
+    CMPQ SI, DI
+    JNE loop32
+
+    VMOVDQU32 swap32+0(SB), Z1
+    VPERMI2D Z0, Z0, Z1
+    VPMINUD Y1, Y0, Y0
+
+    VMOVDQU32 swap32+32(SB), Y1
+    VPERMI2D Y0, Y0, Y1
+    VPMINUD X1, X0, X0
+
+    VMOVDQU32 swap32+48(SB), X1
+    VPERMI2D X0, X0, X1
+    VPMINUD X1, X0, X0
+    VZEROUPPER
+
+    MOVQ X0, DX
+    MOVL DX, BX
+    SHRQ $32, DX
+    CMPL DX, BX
+    CMOVLCS DX, BX
+
+    CMPQ SI, CX
+    JE done
+loop:
+    MOVLQZX (AX)(SI*4), DX
+    CMPL DX, BX
+    CMOVLCS DX, BX
+    INCQ SI
+    CMPQ SI, CX
+    JNE loop
+done:
+    MOVL BX, ret+24(FP)
+    RET
+
+// func minUint64(data []uint64) uint64
+TEXT ·minUint64(SB), NOSPLIT, $-32
+    MOVQ data_base+0(FP), AX
+    MOVQ data_len+8(FP), CX
+    XORQ BX, BX
+
+    CMPQ CX, $0
+    JE done
+    XORQ SI, SI
+    MOVQ (AX), BX
+
+    CMPB ·hasAVX512(SB), $0
+    JE loop
+
+    CMPQ CX, $32
+    JB loop
+
+    MOVQ CX, DI
+    SHRQ $5, DI
+    SHLQ $5, DI
+    VPBROADCASTQ (AX), Z0
+loop32:
+    VMOVDQU64 (AX)(SI*8), Z1
+    VMOVDQU64 64(AX)(SI*8), Z2
+    VMOVDQU64 128(AX)(SI*8), Z3
+    VMOVDQU64 192(AX)(SI*8), Z4
+    VPMINUQ Z1, Z2, Z5
+    VPMINUQ Z3, Z4, Z6
+    VPMINUQ Z5, Z6, Z1
+    VPMINUQ Z1, Z0, Z0
+    ADDQ $32, SI
+    CMPQ SI, DI
+    JNE loop32
+
+    VMOVDQU32 swap32+0(SB), Z1
+    VPERMI2D Z0, Z0, Z1
+    VPMINUQ Y1, Y0, Y0
+
+    VMOVDQU32 swap32+32(SB), Y1
+    VPERMI2D Y0, Y0, Y1
+    VPMINUQ X1, X0, X0
+
+    VMOVDQU32 swap32+48(SB), X1
+    VPERMI2D X0, X0, X1
+    VPMINUQ X1, X0, X0
+    VZEROUPPER
+
+    MOVQ X0, BX
+    CMPQ SI, CX
+    JE done
+loop:
+    MOVQ (AX)(SI*8), DX
+    CMPQ DX, BX
+    CMOVQCS DX, BX
+    INCQ SI
+    CMPQ SI, CX
+    JNE loop
+done:
+    MOVQ BX, ret+24(FP)
+    RET
+
+// func minFloat32(data []float32) float32
+TEXT ·minFloat32(SB), NOSPLIT, $-28
+    MOVQ data_base+0(FP), AX
+    MOVQ data_len+8(FP), CX
+    XORQ BX, BX
+
+    CMPQ CX, $0
+    JE done
+    XORPS X0, X0
+    XORPS X1, X1
+    XORQ SI, SI
+    MOVLQZX (AX), BX
+    MOVQ BX, X0
+
+    CMPB ·hasAVX512(SB), $0
+    JE loop
+
+    CMPQ CX, $64
+    JB loop
+
+    MOVQ CX, DI
+    SHRQ $6, DI
+    SHLQ $6, DI
+    VPBROADCASTD (AX), Z0
+loop64:
+    VMOVDQU32 (AX)(SI*4), Z1
+    VMOVDQU32 64(AX)(SI*4), Z2
+    VMOVDQU32 128(AX)(SI*4), Z3
+    VMOVDQU32 192(AX)(SI*4), Z4
+    VMINPS Z1, Z2, Z5
+    VMINPS Z3, Z4, Z6
+    VMINPS Z5, Z6, Z1
+    VMINPS Z1, Z0, Z0
+    ADDQ $64, SI
+    CMPQ SI, DI
+    JNE loop64
+
+    VMOVDQU32 swap32+0(SB), Z1
+    VPERMI2D Z0, Z0, Z1
+    VMINPS Y1, Y0, Y0
+
+    VMOVDQU32 swap32+32(SB), Y1
+    VPERMI2D Y0, Y0, Y1
+    VMINPS X1, X0, X0
+
+    VMOVDQU32 swap32+48(SB), X1
+    VPERMI2D X0, X0, X1
+    VMINPS X1, X0, X0
+    VZEROUPPER
+
+    MOVAPS X0, X1
+    PSRLQ $32, X1
+    MOVQ X0, BX
+    MOVQ X1, DX
+    UCOMISS X0, X1
+    CMOVLCS DX, BX
+
+    CMPQ SI, CX
+    JE done
+    MOVQ BX, X0
+loop:
+    MOVLQZX (AX)(SI*4), DX
+    MOVQ DX, X1
+    UCOMISS X0, X1
+    CMOVLCS DX, BX
+    MOVQ BX, X0
+    INCQ SI
+    CMPQ SI, CX
+    JNE loop
+done:
+    MOVL BX, ret+24(FP)
+    RET
+
+// func minFloat64(data []float64) float64
+TEXT ·minFloat64(SB), NOSPLIT, $-32
+    MOVQ data_base+0(FP), AX
+    MOVQ data_len+8(FP), CX
+    XORQ BX, BX
+
+    CMPQ CX, $0
+    JE done
+    XORPD X0, X0
+    XORPD X1, X1
+    XORQ SI, SI
+    MOVQ (AX), BX
+    MOVQ BX, X0
+
+    CMPB ·hasAVX512(SB), $0
+    JE loop
+
+    CMPQ CX, $32
+    JB loop
+
+    MOVQ CX, DI
+    SHRQ $5, DI
+    SHLQ $5, DI
+    VPBROADCASTQ (AX), Z0
+loop32:
+    VMOVDQU64 (AX)(SI*8), Z1
+    VMOVDQU64 64(AX)(SI*8), Z2
+    VMOVDQU64 128(AX)(SI*8), Z3
+    VMOVDQU64 192(AX)(SI*8), Z4
+    VMINPD Z1, Z2, Z5
+    VMINPD Z3, Z4, Z6
+    VMINPD Z5, Z6, Z1
+    VMINPD Z1, Z0, Z0
+    ADDQ $32, SI
+    CMPQ SI, DI
+    JNE loop32
+
+    VMOVDQU64 swap32+0(SB), Z1
+    VPERMI2D Z0, Z0, Z1
+    VMINPD Y1, Y0, Y0
+
+    VMOVDQU64 swap32+32(SB), Y1
+    VPERMI2D Y0, Y0, Y1
+    VMINPD X1, X0, X0
+
+    VMOVDQU64 swap32+48(SB), X1
+    VPERMI2D X0, X0, X1
+    VMINPD X1, X0, X0
+    VZEROUPPER
+
+    MOVQ X0, BX
+    CMPQ SI, CX
+    JE done
+loop:
+    MOVQ (AX)(SI*8), DX
+    MOVQ DX, X1
+    UCOMISD X0, X1
+    CMOVQCS DX, BX
+    MOVQ BX, X0
+    INCQ SI
+    CMPQ SI, CX
+    JNE loop
+done:
+    MOVQ BX, ret+24(FP)
+    RET
+
+// func minBE128(data []byte) []byte
+TEXT ·minBE128(SB), NOSPLIT, $-48
+    MOVQ data_base+0(FP), AX
+    MOVQ data_len+8(FP), CX
+    CMPQ CX, $0
+    JE null
+
+    MOVQ CX, DX // len
+    MOVQ AX, BX // min
+    ADDQ AX, CX // end
+
+    CMPQ DX, $256
+    JB loop
+
+    CMPQ ·hasAVX512(SB), $0
+    JB loop
+
+    // Z19 holds a vector of the count by which we increment the vectors of
+    // swap at each loop iteration.
+    MOVQ $16, DI
+    VPBROADCASTQ DI, Z19
+
+    // Z31 holds the shuffle mask used to convert 128 bits elements from big to
+    // little endian so we can apply vectorized comparison instructions.
+    VMOVDQU64 bswap128(SB), Z31
+
+    // These vectors hold four lanes of minimum values found in the input.
+    VBROADCASTI64X2 (AX), Z0
+    VPSHUFB Z31, Z0, Z0
+    VMOVDQU64 Z0, Z5
+    VMOVDQU64 Z0, Z10
+    VMOVDQU64 Z0, Z15
+
+    // These vectors hold four lanes of swap of minimum values.
+    //
+    // We initialize them at zero because we broadcast the first value of the
+    // input in the vectors that track the minimums of each lane; in other
+    // words, we assume the minimum value is at the first offset and work our
+    // way up from there.
+    VPXORQ Z2, Z2, Z2
+    VPXORQ Z7, Z7, Z7
+    VPXORQ Z12, Z12, Z12
+    VPXORQ Z17, Z17, Z17
+
+    // These vectors are used to compute the swap of minimum values held
+    // in [Z1, Z5, Z10, Z15]. Each vector holds a contiguous sequence of
+    // swap; for example, Z3 is initialized with [0, 1, 2, 3]. At each
+    // loop iteration, the swap are incremented by the number of elements
+    // consumed from the input (4x4=16).
+    VMOVDQU64 indexes128(SB), Z3
+    VPXORQ Z8, Z8, Z8
+    VPXORQ Z13, Z13, Z13
+    VPXORQ Z18, Z18, Z18
+    MOVQ $4, DI
+    VPBROADCASTQ DI, Z1
+    VPADDQ Z1, Z3, Z8
+    VPADDQ Z1, Z8, Z13
+    VPADDQ Z1, Z13, Z18
+
+    vpminu128mask(DI)
+    SHRQ $8, DX
+    SHLQ $8, DX
+    ADDQ AX, DX
+loop16:
+    // Compute 4x4 minimum values in vector registers, along with their swap
+    // in the input array.
+    VMOVDQU64 (AX), Z1
+    VMOVDQU64 64(AX), Z6
+    VMOVDQU64 128(AX), Z11
+    VMOVDQU64 192(AX), Z16
+    VPSHUFB Z31, Z1, Z1
+    VPSHUFB Z31, Z6, Z6
+    VPSHUFB Z31, Z11, Z11
+    VPSHUFB Z31, Z16, Z16
+    vpminu128(Z1, Z3, Z0, Z2, K1, K2, R8, R9, R10, DI)
+    vpminu128(Z6, Z8, Z5, Z7, K3, K4, R11, R12, R13, DI)
+    vpminu128(Z11, Z13, Z10, Z12, K1, K2, R8, R9, R10, DI)
+    vpminu128(Z16, Z18, Z15, Z17, K3, K4, R11, R12, R13, DI)
+    VPADDQ Z19, Z3, Z3
+    VPADDQ Z19, Z8, Z8
+    VPADDQ Z19, Z13, Z13
+    VPADDQ Z19, Z18, Z18
+    ADDQ $256, AX
+    CMPQ AX, DX
+    JB loop16
+
+    // After the loop completed, we need to merge the lanes that each contain
+    // 4 minimum values (so 16 total candidate at this stage). The results are
+    // reduced into 4 candidates in Z0, with their swap in Z2.
+    vpminu128(Z10, Z12, Z0, Z2, K1, K2, R8, R9, R10, DI)
+    vpminu128(Z15, Z17, Z5, Z7, K3, K4, R11, R12, R13, DI)
+    vpminu128(Z5, Z7, Z0, Z2, K1, K2, R8, R9, R10, DI)
+
+    // Further reduce the results by swapping the upper and lower parts of the
+    // vector registers, and comparing them to determine which values are the
+    // smallest. We compare 2x2 values at this step, then 2x1 values at the next
+    // to find the index of the minimum.
+    VMOVDQU64 swap64+0(SB), Z1
+    VMOVDQU64 swap64+0(SB), Z3
+    VPERMI2Q Z0, Z0, Z1
+    VPERMI2Q Z2, Z2, Z3
+    vpminu128(Y1, Y3, Y0, Y2, K1, K2, R8, R9, R10, DI)
+
+    VMOVDQU64 swap64+32(SB), Y1
+    VMOVDQU64 swap64+32(SB), Y3
+    VPERMI2Q Y0, Y0, Y1
+    VPERMI2Q Y2, Y2, Y3
+    vpminu128(X1, X3, X0, X2, K1, K2, R8, R9, R10, DI)
+    VZEROUPPER
+
+    // Extract the index of the minimum value computed in the lower 64 bits of
+    // X2 and position the BX pointer at the index of the minimum value.
+    MOVQ X2, DX
+    SHLQ $4, DX
+    ADDQ DX, BX
+    CMPQ AX, CX
+    JE done
+
+    // Unless the input was aligned on 256 bytes, we need to perform a few more
+    // iterations on the remaining elements.
+    //
+    // This loop is also take if the CPU has no support for AVX-512.
+loop:
+    MOVBEQQ (AX), R8
+    MOVBEQQ (BX), R9
+    CMPQ R8, R9
+    JB less
+    JA next
+    MOVBEQQ 8(AX), R8
+    MOVBEQQ 8(BX), R9
+    CMPQ R8, R9
+    JAE next
+less:
+    MOVQ AX, BX
+next:
+    ADDQ $16, AX
+    CMPQ AX, CX
+    JB loop
+done:
+    MOVQ BX, ret+24(FP)
+    MOVQ $16, ret+32(FP)
+    MOVQ $16, ret+40(FP)
+    RET
+null:
+    XORQ BX, BX
+    MOVQ BX, min+24(FP)
+    MOVQ BX, min+32(FP)
+    MOVQ BX, min+40(FP)
+    RET
diff --git a/internal/bits/min_default.go b/internal/bits/min_default.go
new file mode 100644
index 0000000..55c4ea9
--- /dev/null
+++ b/internal/bits/min_default.go
@@ -0,0 +1,110 @@
+//go:build purego || !amd64
+
+package bits
+
+import "encoding/binary"
+
+func minBool(data []bool) (min bool) {
+	return boolEqualAll(data, true)
+}
+
+func minInt32(data []int32) (min int32) {
+	if len(data) > 0 {
+		min = data[0]
+
+		for _, value := range data {
+			if value < min {
+				min = value
+			}
+		}
+	}
+	return min
+}
+
+func minInt64(data []int64) (min int64) {
+	if len(data) > 0 {
+		min = data[0]
+
+		for _, value := range data {
+			if value < min {
+				min = value
+			}
+		}
+	}
+	return min
+}
+
+func minUint32(data []uint32) (min uint32) {
+	if len(data) > 0 {
+		min = data[0]
+
+		for _, value := range data {
+			if value < min {
+				min = value
+			}
+		}
+	}
+	return min
+}
+
+func minUint64(data []uint64) (min uint64) {
+	if len(data) > 0 {
+		min = data[0]
+
+		for _, value := range data {
+			if value < min {
+				min = value
+			}
+		}
+	}
+	return min
+}
+
+func minFloat32(data []float32) (min float32) {
+	if len(data) > 0 {
+		min = data[0]
+
+		for _, value := range data {
+			if value < min {
+				min = value
+			}
+		}
+	}
+	return min
+}
+
+func minFloat64(data []float64) (min float64) {
+	if len(data) > 0 {
+		min = data[0]
+
+		for _, value := range data {
+			if value < min {
+				min = value
+			}
+		}
+	}
+	return min
+}
+
+func minBE128(data []byte) (min []byte) {
+	if len(data) > 0 {
+		be128 := BytesToUint128(data)
+		m := binary.BigEndian.Uint64(be128[0][:8])
+		j := 0
+		for i := 1; i < len(be128); i++ {
+			x := binary.BigEndian.Uint64(be128[i][:8])
+			switch {
+			case x < m:
+				m, j = x, i
+			case x == m:
+				y := binary.BigEndian.Uint64(be128[i][8:])
+				n := binary.BigEndian.Uint64(be128[j][8:])
+				if y < n {
+					m, j = x, i
+				}
+			}
+		}
+		min = be128[j][:]
+	}
+	return min
+}
diff --git a/internal/bits/minmax.go b/internal/bits/minmax.go
index 2520c67..a9cfcbe 100644
--- a/internal/bits/minmax.go
+++ b/internal/bits/minmax.go
@@ -4,132 +4,28 @@ import (
 	"bytes"
 )
 
-func boolEqualAll(data []bool, value bool) bool {
-	for i := range data {
-		if data[i] != value {
-			return false
-		}
-	}
-	return true
-}
-
-func MinMaxBool(data []bool) (min, max bool) {
-	if len(data) > 0 {
-		switch {
-		case boolEqualAll(data, true):
-			min, max = true, true
-		case boolEqualAll(data, false):
-			min, max = false, false
-		default:
-			min, max = false, true
-		}
-	}
-	return min, max
-}
-
-func MinMaxInt32(data []int32) (min, max int32) {
-	if len(data) > 0 {
-		min = data[0]
-		max = data[0]
-
-		for _, v := range data[1:] {
-			if v < min {
-				min = v
-			}
-			if v > max {
-				max = v
-			}
-		}
-	}
-	return min, max
-}
-
-func MinMaxInt64(data []int64) (min, max int64) {
-	if len(data) > 0 {
-		min = data[0]
-		max = data[0]
-
-		for _, v := range data[1:] {
-			if v < min {
-				min = v
-			}
-			if v > max {
-				max = v
-			}
-		}
-	}
-	return min, max
-}
-
-func MinMaxUint32(data []uint32) (min, max uint32) {
-	if len(data) > 0 {
-		min = data[0]
-		max = data[0]
+func MinMaxBool(data []bool) (min, max bool) { return minMaxBool(data) }
 
-		for _, v := range data[1:] {
-			if v < min {
-				min = v
-			}
-			if v > max {
-				max = v
-			}
-		}
-	}
-	return min, max
-}
+func MinMaxInt32(data []int32) (min, max int32) { return minMaxInt32(data) }
 
-func MinMaxUint64(data []uint64) (min, max uint64) {
-	if len(data) > 0 {
-		min = data[0]
-		max = data[0]
+func MinMaxInt64(data []int64) (min, max int64) { return minMaxInt64(data) }
 
-		for _, v := range data[1:] {
-			if v < min {
-				min = v
-			}
-			if v > max {
-				max = v
-			}
-		}
-	}
-	return min, max
-}
+func MinMaxUint32(data []uint32) (min, max uint32) { return minMaxUint32(data) }
 
-func MinMaxFloat32(data []float32) (min, max float32) {
-	if len(data) > 0 {
-		min = data[0]
-		max = data[0]
+func MinMaxUint64(data []uint64) (min, max uint64) { return minMaxUint64(data) }
 
-		for _, v := range data[1:] {
-			if v < min {
-				min = v
-			}
-			if v > max {
-				max = v
-			}
-		}
-	}
-	return min, max
-}
+func MinMaxFloat32(data []float32) (min, max float32) { return minMaxFloat32(data) }
 
-func MinMaxFloat64(data []float64) (min, max float64) {
-	if len(data) > 0 {
-		min = data[0]
-		max = data[0]
+func MinMaxFloat64(data []float64) (min, max float64) { return minMaxFloat64(data) }
 
-		for _, v := range data[1:] {
-			if v < min {
-				min = v
-			}
-			if v > max {
-				max = v
-			}
-		}
+func MinMaxFixedLenByteArray(size int, data []byte) (min, max []byte) {
+	if size == 16 {
+		return minMaxBE128(data)
 	}
-	return min, max
+	return minMaxFixedLenByteArray(size, data)
 }
 
-func MinMaxFixedLenByteArray(size int, data []byte) (min, max []byte) {
+func minMaxFixedLenByteArray(size int, data []byte) (min, max []byte) {
 	if len(data) > 0 {
 		min = data[:size]
 		max = data[:size]
diff --git a/internal/bits/minmax_amd64.go b/internal/bits/minmax_amd64.go
new file mode 100644
index 0000000..e890eb5
--- /dev/null
+++ b/internal/bits/minmax_amd64.go
@@ -0,0 +1,139 @@
+//go:build !purego
+
+package bits
+
+// The min-max algorithms combine looking for the min and max values in a single
+// pass over the data. While the behavior is the same as calling functions to
+// look for the min and max values independently, doing both operations at the
+// same time means that we only load the data from memory once. When working on
+// large arrays the algorithms are limited by memory bandwidth, computing both
+// the min and max together shrinks by half the amount of data read from memory.
+//
+// The following benchmarks results were highlighting the benefits of combining
+// the min-max search, compared to calling the min and max functions separately:
+//
+// name                 old time/op    new time/op    delta
+// MinMaxInt64/10240KB     590µs ±15%     330µs ±10%  -44.01%  (p=0.000 n=10+10)
+//
+// name                 old speed      new speed      delta
+// MinMaxInt64/10240KB  17.9GB/s ±13%  31.8GB/s ±11%  +78.13%  (p=0.000 n=10+10)
+//
+// As expected, since the functions are memory-bound in those cases, and load
+// half as much data, we see significant improvements. The gains are not 2x because
+// running more AVX-512 instructions in the tight loops causes more contention
+// on CPU ports.
+//
+//
+// As all things are always trade offs, using min/max functions independently
+// actually yields better throughput when the data resides in CPU caches:
+//
+// name             old time/op    new time/op    delta
+// MinMaxInt64/4KB    46.2ns ± 1%    52.1ns ± 0%  +12.65%  (p=0.000 n=10+10)
+//
+// name             old speed      new speed      delta
+// MinMaxInt64/4KB  88.6GB/s ± 1%  78.6GB/s ± 0%  -11.23%  (p=0.000 n=10+10)
+//
+// The probable explanation is that in those cases the algorithms are not
+// memory-bound anymore, but limited by contention on CPU ports, and the
+// individual min/max functions are able to better parallelize the work due
+// to running less instructions per loop. The performance starts to equalize
+// around 256KiB, and degrade beyond 1MiB, so we use this threshold to determine
+// which approach to prefer.
+const combinedMinMaxThreshold = 1 * 1024 * 1024
+
+//go:noescape
+func combinedMinMaxBool(data []bool) (min, max bool)
+
+//go:noescape
+func combinedMinMaxInt32(data []int32) (min, max int32)
+
+//go:noescape
+func combinedMinMaxInt64(data []int64) (min, max int64)
+
+//go:noescape
+func combinedMinMaxUint32(data []uint32) (min, max uint32)
+
+//go:noescape
+func combinedMinMaxUint64(data []uint64) (min, max uint64)
+
+//go:noescape
+func combinedMinMaxFloat32(data []float32) (min, max float32)
+
+//go:noescape
+func combinedMinMaxFloat64(data []float64) (min, max float64)
+
+//go:noescape
+func combinedMinMaxBE128(data []byte) (min, max []byte)
+
+func minMaxBool(data []bool) (min, max bool) {
+	if 1*len(data) >= combinedMinMaxThreshold {
+		return combinedMinMaxBool(data)
+	}
+	min = minBool(data)
+	max = maxBool(data)
+	return
+}
+
+func minMaxInt32(data []int32) (min, max int32) {
+	if 4*len(data) >= combinedMinMaxThreshold {
+		return combinedMinMaxInt32(data)
+	}
+	min = minInt32(data)
+	max = maxInt32(data)
+	return
+}
+
+func minMaxInt64(data []int64) (min, max int64) {
+	if 8*len(data) >= combinedMinMaxThreshold {
+		return combinedMinMaxInt64(data)
+	}
+	min = minInt64(data)
+	max = maxInt64(data)
+	return
+}
+
+func minMaxUint32(data []uint32) (min, max uint32) {
+	if 4*len(data) >= combinedMinMaxThreshold {
+		return combinedMinMaxUint32(data)
+	}
+	min = minUint32(data)
+	max = maxUint32(data)
+	return
+}
+
+func minMaxUint64(data []uint64) (min, max uint64) {
+	if 8*len(data) >= combinedMinMaxThreshold {
+		return combinedMinMaxUint64(data)
+	}
+	min = minUint64(data)
+	max = maxUint64(data)
+	return
+}
+
+func minMaxFloat32(data []float32) (min, max float32) {
+	if 4*len(data) >= combinedMinMaxThreshold {
+		return combinedMinMaxFloat32(data)
+	}
+	min = minFloat32(data)
+	max = maxFloat32(data)
+	return
+}
+
+func minMaxFloat64(data []float64) (min, max float64) {
+	if 8*len(data) >= combinedMinMaxThreshold {
+		return combinedMinMaxFloat64(data)
+	}
+	min = minFloat64(data)
+	max = maxFloat64(data)
+	return
+}
+
+func minMaxBE128(data []byte) (min, max []byte) {
+	// TODO: min/max BE128 is really complex to vectorize, and the returns
+	// were barely better than doing the min and max independently, for all
+	// input sizes. We should revisit if we find ways to improve the min or
+	// max algorithms which can be transposed to the combined version.
+	min = minBE128(data)
+	max = maxBE128(data)
+	return
+}
diff --git a/internal/bits/minmax_amd64.s b/internal/bits/minmax_amd64.s
new file mode 100644
index 0000000..e5a814b
--- /dev/null
+++ b/internal/bits/minmax_amd64.s
@@ -0,0 +1,646 @@
+//go:build !purego
+
+#include "textflag.h"
+#include "min_amd64.h"
+#include "max_amd64.h"
+
+// func combinedMinMaxBool(data []bool) (min, max bool)
+TEXT ·combinedMinMaxBool(SB), NOSPLIT, $-26
+    MOVQ data_base+0(FP), AX
+    MOVQ data_len+8(FP), CX
+    // Initialize the registers holding the min and max values to false because
+    // the input may be empty, in which case the function must return min=false
+    // and max=false.
+    XORQ R8, R8 // min
+    XORQ R9, R9 // max
+
+    CMPQ CX, $0
+    JE done
+    // At the start, assume min=true and max=false; if we encounter false values
+    // the min will be set to false, and vice verso for true and max.
+    MOVQ $1, R8
+    MOVQ $0, R10 // false
+    MOVQ $1, R11 // true
+    XORQ SI, SI
+
+    CMPB ·hasAVX512MinMaxBool(SB), $0
+    JE loop
+
+    CMPQ CX, $128
+    JB loop
+
+    MOVQ DX, DX
+    MOVQ CX, DI
+    SHRQ $7, DI
+    SHLQ $7, DI
+
+    VPXORQ Z0, Z0, Z0
+    VPXORQ Z1, Z1, Z1
+loop128:
+    // This is the same core logic as the maxBool function; we're intersted in
+    // knowing if all values were 0 or 1, which determine the three possible
+    // states of the result:
+    //
+    // * min=false max=false
+    // * min=false max=true
+    // * min=true  max=true
+    //
+    VMOVDQU64 (AX)(SI*1), Z2
+    VMOVDQU64 64(AX)(SI*1), Z3
+    VPOPCNTQ Z2, Z2
+    VPOPCNTQ Z3, Z3
+    VPADDQ Z2, Z0, Z0
+    VPADDQ Z3, Z1, Z1
+    ADDQ $128, SI
+    CMPQ SI, DI
+    JNE loop128
+
+    VPADDQ Z1, Z0, Z0
+
+    VMOVDQU64 swap64+0(SB), Z1
+    VPERMI2Q Z0, Z0, Z1
+    VPADDQ Y1, Y0, Y0
+
+    VMOVDQU64 swap64+32(SB), Y1
+    VPERMI2Q Y0, Y0, Y1
+    VPADDQ X1, X0, X0
+
+    VMOVDQU64 swap64+48(SB), X1
+    VPERMI2Q X0, X0, X1
+    VPEXTRQ $1, X1, R12
+    VZEROUPPER
+
+    MOVQ X0, R13
+    ADDQ R13, R12
+
+    CMPQ R12, $0 // all false?
+    CMOVQEQ R10, R8
+    CMOVQEQ R10, R9
+    CMOVQNE R11, R9
+    CMPQ R12, DX // all true?
+    CMOVQNE R10, R8
+    CMOVQEQ R11, R8
+    CMOVQEQ R11, R9
+
+    CMPQ SI, CX
+    JE done
+loop:
+    MOVBQZX (AX)(SI*1), DX
+    CMPQ DX, $0
+    CMOVQEQ R10, R8
+    CMOVQNE R11, R9
+    INCQ SI
+    CMPQ SI, CX
+    JNE loop
+done:
+    MOVB R8, min+24(FP)
+    MOVB R9, max+25(FP)
+    RET
+
+// func combinedMinMaxInt32(data []int32) (min, max int32)
+TEXT ·combinedMinMaxInt32(SB), NOSPLIT, $-32
+    MOVQ data_base+0(FP), AX
+    MOVQ data_len+8(FP), CX
+    XORQ R8, R8
+    XORQ R9, R9
+
+    CMPQ CX, $0
+    JE done
+    XORQ SI, SI
+    MOVLQZX (AX), R8 // min
+    MOVLQZX (AX), R9 // max
+
+    CMPB ·hasAVX512(SB), $0
+    JE loop
+
+    CMPQ CX, $32
+    JB loop
+
+    MOVQ CX, DI
+    SHRQ $5, DI
+    SHLQ $5, DI
+    VPBROADCASTD (AX), Z0
+    VPBROADCASTD (AX), Z3
+loop32:
+    VMOVDQU32 (AX)(SI*4), Z1
+    VMOVDQU32 64(AX)(SI*4), Z2
+    VPMINSD Z1, Z0, Z0
+    VPMINSD Z2, Z0, Z0
+    VPMAXSD Z1, Z3, Z3
+    VPMAXSD Z2, Z3, Z3
+    ADDQ $32, SI
+    CMPQ SI, DI
+    JNE loop32
+
+    VMOVDQU32 swap32+0(SB), Z1
+    VMOVDQU32 swap32+0(SB), Z2
+    VPERMI2D Z0, Z0, Z1
+    VPERMI2D Z3, Z3, Z2
+    VPMINSD Y1, Y0, Y0
+    VPMAXSD Y2, Y3, Y3
+
+    VMOVDQU32 swap32+32(SB), Y1
+    VMOVDQU32 swap32+32(SB), Y2
+    VPERMI2D Y0, Y0, Y1
+    VPERMI2D Y3, Y3, Y2
+    VPMINSD X1, X0, X0
+    VPMAXSD X2, X3, X3
+
+    VMOVDQU32 swap32+48(SB), X1
+    VMOVDQU32 swap32+48(SB), X2
+    VPERMI2D X0, X0, X1
+    VPERMI2D X3, X3, X2
+    VPMINSD X1, X0, X0
+    VPMAXSD X2, X3, X3
+    VZEROUPPER
+
+    MOVQ X0, BX
+    MOVQ X3, DX
+    MOVL BX, R8
+    MOVL DX, R9
+    SHRQ $32, BX
+    SHRQ $32, DX
+    CMPL BX, R8
+    CMOVLLT BX, R8
+    CMPL DX, R9
+    CMOVLGT DX, R9
+
+    CMPQ SI, CX
+    JE done
+loop:
+    MOVLQZX (AX)(SI*4), DX
+    CMPL DX, R8
+    CMOVLLT DX, R8
+    CMPL DX, R9
+    CMOVLGT DX, R9
+    INCQ SI
+    CMPQ SI, CX
+    JNE loop
+done:
+    MOVL R8, min+24(FP)
+    MOVL R9, max+28(FP)
+    RET
+
+// func combinedMinMaxInt64(data []int64) (min, max int64)
+TEXT ·combinedMinMaxInt64(SB), NOSPLIT, $-40
+    MOVQ data_base+0(FP), AX
+    MOVQ data_len+8(FP), CX
+    XORQ R8, R8
+    XORQ R9, R9
+
+    CMPQ CX, $0
+    JE done
+    XORQ SI, SI
+    MOVQ (AX), R8 // min
+    MOVQ (AX), R9 // max
+
+    CMPB ·hasAVX512(SB), $0
+    JE loop
+
+    CMPQ CX, $16
+    JB loop
+
+    MOVQ CX, DI
+    SHRQ $4, DI
+    SHLQ $4, DI
+    VPBROADCASTQ (AX), Z0
+    VPBROADCASTQ (AX), Z3
+loop16:
+    VMOVDQU64 (AX)(SI*8), Z1
+    VMOVDQU64 64(AX)(SI*8), Z2
+    VPMINSQ Z1, Z0, Z0
+    VPMINSQ Z2, Z0, Z0
+    VPMAXSQ Z1, Z3, Z3
+    VPMAXSQ Z2, Z3, Z3
+    ADDQ $16, SI
+    CMPQ SI, DI
+    JNE loop16
+
+    VMOVDQU32 swap32+0(SB), Z1
+    VMOVDQU32 swap32+0(SB), Z2
+    VPERMI2D Z0, Z0, Z1
+    VPERMI2D Z3, Z3, Z2
+    VPMINSQ Y1, Y0, Y0
+    VPMAXSQ Y2, Y3, Y3
+
+    VMOVDQU32 swap32+32(SB), Y1
+    VMOVDQU32 swap32+32(SB), Y2
+    VPERMI2D Y0, Y0, Y1
+    VPERMI2D Y3, Y3, Y2
+    VPMINSQ X1, X0, X0
+    VPMAXSQ X2, X3, X3
+
+    VMOVDQU32 swap32+48(SB), X1
+    VMOVDQU32 swap32+48(SB), X2
+    VPERMI2D X0, X0, X1
+    VPERMI2D X3, X3, X2
+    VPMINSQ X1, X0, X0
+    VPMAXSQ X2, X3, X3
+    VZEROUPPER
+
+    MOVQ X0, R8
+    MOVQ X3, R9
+    CMPQ SI, CX
+    JE done
+loop:
+    MOVQ (AX)(SI*8), DX
+    CMPQ DX, R8
+    CMOVQLT DX, R8
+    CMPQ DX, R9
+    CMOVQGT DX, R9
+    INCQ SI
+    CMPQ SI, CX
+    JNE loop
+done:
+    MOVQ R8, min+24(FP)
+    MOVQ R9, max+32(FP)
+    RET
+
+// func combinedMinMaxUint32(data []uint32) (min, max uint32)
+TEXT ·combinedMinMaxUint32(SB), NOSPLIT, $-32
+    MOVQ data_base+0(FP), AX
+    MOVQ data_len+8(FP), CX
+    XORQ R8, R8
+    XORQ R9, R9
+
+    CMPQ CX, $0
+    JE done
+    XORQ SI, SI
+    MOVLQZX (AX), R8 // min
+    MOVLQZX (AX), R9 // max
+
+    CMPB ·hasAVX512(SB), $0
+    JE loop
+
+    CMPQ CX, $32
+    JB loop
+
+    MOVQ CX, DI
+    SHRQ $5, DI
+    SHLQ $5, DI
+    VPBROADCASTD (AX), Z0
+    VPBROADCASTD (AX), Z3
+loop32:
+    VMOVDQU32 (AX)(SI*4), Z1
+    VMOVDQU32 64(AX)(SI*4), Z2
+    VPMINUD Z1, Z0, Z0
+    VPMINUD Z2, Z0, Z0
+    VPMAXUD Z1, Z3, Z3
+    VPMAXUD Z2, Z3, Z3
+    ADDQ $32, SI
+    CMPQ SI, DI
+    JNE loop32
+
+    VMOVDQU32 swap32+0(SB), Z1
+    VMOVDQU32 swap32+0(SB), Z2
+    VPERMI2D Z0, Z0, Z1
+    VPERMI2D Z3, Z3, Z2
+    VPMINUD Y1, Y0, Y0
+    VPMAXUD Y2, Y3, Y3
+
+    VMOVDQU32 swap32+32(SB), Y1
+    VMOVDQU32 swap32+32(SB), Y2
+    VPERMI2D Y0, Y0, Y1
+    VPERMI2D Y3, Y3, Y2
+    VPMINUD X1, X0, X0
+    VPMAXUD X2, X3, X3
+
+    VMOVDQU32 swap32+48(SB), X1
+    VMOVDQU32 swap32+48(SB), X2
+    VPERMI2D X0, X0, X1
+    VPERMI2D X3, X3, X2
+    VPMINUD X1, X0, X0
+    VPMAXUD X2, X3, X3
+    VZEROUPPER
+
+    MOVQ X0, BX
+    MOVQ X3, DX
+    MOVL BX, R8
+    MOVL DX, R9
+    SHRQ $32, BX
+    SHRQ $32, DX
+    CMPL BX, R8
+    CMOVLCS BX, R8
+    CMPL DX, R9
+    CMOVLHI DX, R9
+
+    CMPQ SI, CX
+    JE done
+loop:
+    MOVLQZX (AX)(SI*4), DX
+    CMPL DX, R8
+    CMOVLCS DX, R8
+    CMPL DX, R9
+    CMOVLHI DX, R9
+    INCQ SI
+    CMPQ SI, CX
+    JNE loop
+done:
+    MOVL R8, min+24(FP)
+    MOVL R9, max+28(FP)
+    RET
+
+// func combinedMinMaxUint64(data []uint64) (min, max uint64)
+TEXT ·combinedMinMaxUint64(SB), NOSPLIT, $-40
+    MOVQ data_base+0(FP), AX
+    MOVQ data_len+8(FP), CX
+    XORQ R8, R8
+    XORQ R9, R9
+
+    CMPQ CX, $0
+    JE done
+    XORQ SI, SI
+    MOVQ (AX), R8 // min
+    MOVQ (AX), R9 // max
+
+    CMPB ·hasAVX512(SB), $0
+    JE loop
+
+    CMPQ CX, $16
+    JB loop
+
+    MOVQ CX, DI
+    SHRQ $4, DI
+    SHLQ $4, DI
+    VPBROADCASTQ (AX), Z0
+    VPBROADCASTQ (AX), Z3
+loop16:
+    VMOVDQU64 (AX)(SI*8), Z1
+    VMOVDQU64 64(AX)(SI*8), Z2
+    VPMINUQ Z1, Z0, Z0
+    VPMINUQ Z2, Z0, Z0
+    VPMAXUQ Z1, Z3, Z3
+    VPMAXUQ Z2, Z3, Z3
+    ADDQ $16, SI
+    CMPQ SI, DI
+    JNE loop16
+
+    VMOVDQU32 swap32+0(SB), Z1
+    VMOVDQU32 swap32+0(SB), Z2
+    VPERMI2D Z0, Z0, Z1
+    VPERMI2D Z3, Z3, Z2
+    VPMINUQ Y1, Y0, Y0
+    VPMAXUQ Y2, Y3, Y3
+
+    VMOVDQU32 swap32+32(SB), Y1
+    VMOVDQU32 swap32+32(SB), Y2
+    VPERMI2D Y0, Y0, Y1
+    VPERMI2D Y3, Y3, Y2
+    VPMINUQ X1, X0, X0
+    VPMAXUQ X2, X3, X3
+
+    VMOVDQU32 swap32+48(SB), X1
+    VMOVDQU32 swap32+48(SB), X2
+    VPERMI2D X0, X0, X1
+    VPERMI2D X3, X3, X2
+    VPMINUQ X1, X0, X0
+    VPMAXUQ X2, X3, X3
+    VZEROUPPER
+
+    MOVQ X0, R8
+    MOVQ X3, R9
+    CMPQ SI, CX
+    JE done
+loop:
+    MOVQ (AX)(SI*8), DX
+    CMPQ DX, R8
+    CMOVQCS DX, R8
+    CMPQ DX, R9
+    CMOVQHI DX, R9
+    INCQ SI
+    CMPQ SI, CX
+    JNE loop
+done:
+    MOVQ R8, min+24(FP)
+    MOVQ R9, max+32(FP)
+    RET
+
+// func combinedMinMaxFloat32(data []float32) (min, max float32)
+TEXT ·combinedMinMaxFloat32(SB), NOSPLIT, $-32
+    MOVQ data_base+0(FP), AX
+    MOVQ data_len+8(FP), CX
+    XORQ R8, R8
+    XORQ R9, R9
+
+    CMPQ CX, $0
+    JE done
+    XORPS X0, X0
+    XORPS X1, X1
+    XORQ SI, SI
+    MOVLQZX (AX), R8 // min
+    MOVLQZX (AX), R9 // max
+    MOVQ R8, X0
+    MOVQ R9, X1
+
+    CMPB ·hasAVX512(SB), $0
+    JE loop
+
+    CMPQ CX, $32
+    JB loop
+
+    MOVQ CX, DI
+    SHRQ $5, DI
+    SHLQ $5, DI
+    VPBROADCASTD (AX), Z0
+    VPBROADCASTD (AX), Z3
+loop32:
+    VMOVDQU32 (AX)(SI*4), Z1
+    VMOVDQU32 64(AX)(SI*4), Z2
+    VMINPS Z1, Z0, Z0
+    VMINPS Z2, Z0, Z0
+    VMAXPS Z1, Z3, Z3
+    VMAXPS Z2, Z3, Z3
+    ADDQ $32, SI
+    CMPQ SI, DI
+    JNE loop32
+
+    VMOVDQU32 swap32+0(SB), Z1
+    VMOVDQU32 swap32+0(SB), Z2
+    VPERMI2D Z0, Z0, Z1
+    VPERMI2D Z3, Z3, Z2
+    VMINPS Y1, Y0, Y0
+    VMAXPS Y2, Y3, Y3
+
+    VMOVDQU32 swap32+32(SB), Y1
+    VMOVDQU32 swap32+32(SB), Y2
+    VPERMI2D Y0, Y0, Y1
+    VPERMI2D Y3, Y3, Y2
+    VMINPS X1, X0, X0
+    VMAXPS X2, X3, X3
+
+    VMOVDQU32 swap32+48(SB), X1
+    VMOVDQU32 swap32+48(SB), X2
+    VPERMI2D X0, X0, X1
+    VPERMI2D X3, X3, X2
+    VMINPS X1, X0, X0
+    VMAXPS X2, X3, X3
+    VZEROUPPER
+
+    MOVAPS X0, X1
+    MOVAPS X3, X2
+
+    PSRLQ $32, X1
+    MOVQ X0, R8
+    MOVQ X1, R10
+    UCOMISS X0, X1
+    CMOVLCS R10, R8
+
+    PSRLQ $32, X2
+    MOVQ X3, R9
+    MOVQ X2, R11
+    UCOMISS X3, X2
+    CMOVLHI R11, R9
+
+    CMPQ SI, CX
+    JE done
+    MOVQ R8, X0
+    MOVQ R9, X1
+loop:
+    MOVLQZX (AX)(SI*4), DX
+    MOVQ DX, X2
+    UCOMISS X0, X2
+    CMOVLCS DX, R8
+    UCOMISS X1, X2
+    CMOVLHI DX, R9
+    MOVQ R8, X0
+    MOVQ R9, X1
+    INCQ SI
+    CMPQ SI, CX
+    JNE loop
+done:
+    MOVL R8, min+24(FP)
+    MOVL R9, max+28(FP)
+    RET
+
+// func combinedMinMaxFloat64(data []float64) (min, max float64)
+TEXT ·combinedMinMaxFloat64(SB), NOSPLIT, $-40
+    MOVQ data_base+0(FP), AX
+    MOVQ data_len+8(FP), CX
+    XORQ R8, R8
+    XORQ R9, R9
+
+    CMPQ CX, $0
+    JE done
+    XORPD X0, X0
+    XORPD X1, X1
+    XORQ SI, SI
+    MOVQ (AX), R8 // min
+    MOVQ (AX), R9 // max
+    MOVQ R8, X0
+    MOVQ R9, X1
+
+    CMPB ·hasAVX512(SB), $0
+    JE loop
+
+    CMPQ CX, $16
+    JB loop
+
+    MOVQ CX, DI
+    SHRQ $4, DI
+    SHLQ $4, DI
+    VPBROADCASTQ (AX), Z0
+    VPBROADCASTQ (AX), Z3
+loop16:
+    VMOVDQU64 (AX)(SI*8), Z1
+    VMOVDQU64 64(AX)(SI*8), Z2
+    VMINPD Z1, Z0, Z0
+    VMINPD Z2, Z0, Z0
+    VMAXPD Z1, Z3, Z3
+    VMAXPD Z2, Z3, Z3
+    ADDQ $16, SI
+    CMPQ SI, DI
+    JNE loop16
+
+    VMOVDQU64 swap32+0(SB), Z1
+    VMOVDQU64 swap32+0(SB), Z2
+    VPERMI2D Z0, Z0, Z1
+    VPERMI2D Z3, Z3, Z2
+    VMINPD Y1, Y0, Y0
+    VMAXPD Y2, Y3, Y3
+
+    VMOVDQU64 swap32+32(SB), Y1
+    VMOVDQU64 swap32+32(SB), Y2
+    VPERMI2D Y0, Y0, Y1
+    VPERMI2D Y3, Y3, Y2
+    VMINPD X1, X0, X0
+    VMAXPD X2, X3, X3
+
+    VMOVDQU64 swap32+48(SB), X1
+    VMOVDQU64 swap32+48(SB), X2
+    VPERMI2D X0, X0, X1
+    VPERMI2D X3, X3, X2
+    VMINPD X1, X0, X0
+    VMAXPD X2, X3, X1
+    VZEROUPPER
+
+    MOVQ X0, R8
+    MOVQ X1, R9
+    CMPQ SI, CX
+    JE done
+loop:
+    MOVQ (AX)(SI*8), DX
+    MOVQ DX, X2
+    UCOMISD X0, X2
+    CMOVQCS DX, R8
+    UCOMISD X1, X2
+    CMOVQHI DX, R9
+    MOVQ R8, X0
+    MOVQ R9, X1
+    INCQ SI
+    CMPQ SI, CX
+    JNE loop
+done:
+    MOVQ R8, min+24(FP)
+    MOVQ R9, max+32(FP)
+    RET
+
+// func combinedMinMaxBE128(data []byte) (min, max []byte)
+TEXT ·combinedMinMaxBE128(SB), NOSPLIT, $-72
+    MOVQ data_base+0(FP), AX
+    MOVQ data_len+8(FP), CX
+    CMPQ CX, $0
+    JE null
+    MOVQ AX, R8 // min
+    MOVQ AX, R9 // max
+    ADDQ AX, CX // end
+loop:
+    MOVBEQQ (AX), R10
+    MOVBEQQ (R8), R11
+    MOVBEQQ (R9), R12
+    CMPQ R10, R11
+    JA testmax
+    JB setmin
+    MOVBEQQ 8(AX), R13
+    MOVBEQQ 8(R8), R11
+    CMPQ R11, R13
+    JAE testmax
+setmin:
+    MOVQ AX, R8
+testmax:
+    CMPQ R10, R12
+    JA setmax
+    JB next
+    MOVBEQQ 8(AX), R13
+    MOVBEQQ 8(R9), R12
+    CMPQ R12, R13
+    JAE next
+setmax:
+    MOVQ AX, R9
+next:
+    ADDQ $16, AX
+    CMPQ AX, CX
+    JNE loop
+done:
+    MOVQ R8, min+24(FP)
+    MOVQ $16, min+32(FP)
+    MOVQ $16, min+40(FP)
+    MOVQ R9, max+48(FP)
+    MOVQ $16, max+56(FP)
+    MOVQ $16, max+64(FP)
+    RET
+null:
+    VPXOR X0, X0, X0
+    VMOVDQU X0, ret+24(FP)
+    VMOVDQU X0, ret+40(FP)
+    VMOVDQU X0, ret+56(FP)
+    RET
diff --git a/internal/bits/minmax_default.go b/internal/bits/minmax_default.go
new file mode 100644
index 0000000..eea9cdb
--- /dev/null
+++ b/internal/bits/minmax_default.go
@@ -0,0 +1,165 @@
+//go:build purego || !amd64
+
+package bits
+
+import "encoding/binary"
+
+func boolEqualAll(data []bool, value bool) bool {
+	for _, v := range data {
+		if v != value {
+			return false
+		}
+	}
+	return len(data) > 0
+}
+
+func minMaxBool(data []bool) (min, max bool) {
+	if len(data) > 0 {
+		switch {
+		case boolEqualAll(data, true):
+			min, max = true, true
+		case boolEqualAll(data, false):
+			min, max = false, false
+		default:
+			min, max = false, true
+		}
+	}
+	return min, max
+}
+
+func minMaxInt32(data []int32) (min, max int32) {
+	if len(data) > 0 {
+		min = data[0]
+		max = data[0]
+
+		for _, v := range data[1:] {
+			if v < min {
+				min = v
+			}
+			if v > max {
+				max = v
+			}
+		}
+	}
+	return min, max
+}
+
+func minMaxInt64(data []int64) (min, max int64) {
+	if len(data) > 0 {
+		min = data[0]
+		max = data[0]
+
+		for _, v := range data[1:] {
+			if v < min {
+				min = v
+			}
+			if v > max {
+				max = v
+			}
+		}
+	}
+	return min, max
+}
+
+func minMaxUint32(data []uint32) (min, max uint32) {
+	if len(data) > 0 {
+		min = data[0]
+		max = data[0]
+
+		for _, v := range data[1:] {
+			if v < min {
+				min = v
+			}
+			if v > max {
+				max = v
+			}
+		}
+	}
+	return min, max
+}
+
+func minMaxUint64(data []uint64) (min, max uint64) {
+	if len(data) > 0 {
+		min = data[0]
+		max = data[0]
+
+		for _, v := range data[1:] {
+			if v < min {
+				min = v
+			}
+			if v > max {
+				max = v
+			}
+		}
+	}
+	return min, max
+}
+
+func minMaxFloat32(data []float32) (min, max float32) {
+	if len(data) > 0 {
+		min = data[0]
+		max = data[0]
+
+		for _, v := range data[1:] {
+			if v < min {
+				min = v
+			}
+			if v > max {
+				max = v
+			}
+		}
+	}
+	return min, max
+}
+
+func minMaxFloat64(data []float64) (min, max float64) {
+	if len(data) > 0 {
+		min = data[0]
+		max = data[0]
+
+		for _, v := range data[1:] {
+			if v < min {
+				min = v
+			}
+			if v > max {
+				max = v
+			}
+		}
+	}
+	return min, max
+}
+
+func minMaxBE128(data []byte) (min, max []byte) {
+	if len(data) > 0 {
+		be128 := BytesToUint128(data)
+		minHi := binary.BigEndian.Uint64(be128[0][:8])
+		maxHi := minHi
+		minIndex := 0
+		maxIndex := 0
+		for i := 1; i < len(be128); i++ {
+			hi := binary.BigEndian.Uint64(be128[i][:8])
+			lo := binary.BigEndian.Uint64(be128[i][8:])
+			switch {
+			case hi < minHi:
+				minHi, minIndex = hi, i
+			case hi == minHi:
+				minLo := binary.BigEndian.Uint64(be128[minIndex][8:])
+				if lo < minLo {
+					minHi, minIndex = hi, i
+				}
+			}
+			switch {
+			case hi > maxHi:
+				maxHi, maxIndex = hi, i
+			case hi == maxHi:
+				maxLo := binary.BigEndian.Uint64(be128[maxIndex][8:])
+				if lo > maxLo {
+					maxHi, maxIndex = hi, i
+				}
+			}
+		}
+		min = be128[minIndex][:]
+		max = be128[maxIndex][:]
+	}
+	return min, max
+}
diff --git a/internal/bits/unsafe.go b/internal/bits/unsafe.go
index 8466acd..ee54e13 100644
--- a/internal/bits/unsafe.go
+++ b/internal/bits/unsafe.go
@@ -46,6 +46,10 @@ func Uint64ToBytes(data []uint64) []byte {
 	return unsafe.Slice(*(**byte)(unsafe.Pointer(&data)), 8*len(data))
 }
 
+func Uint128ToBytes(data [][16]byte) []byte {
+	return unsafe.Slice(*(**byte)(unsafe.Pointer(&data)), 16*len(data))
+}
+
 func Uint32ToInt32(data []uint32) []int32 {
 	return unsafe.Slice(*(**int32)(unsafe.Pointer(&data)), len(data))
 }
@@ -54,6 +58,10 @@ func Uint64ToInt64(data []uint64) []int64 {
 	return unsafe.Slice(*(**int64)(unsafe.Pointer(&data)), len(data))
 }
 
+func BytesToBool(data []byte) []bool {
+	return unsafe.Slice(*(**bool)(unsafe.Pointer(&data)), len(data))
+}
+
 func BytesToInt8(data []byte) []int8 {
 	return unsafe.Slice(*(**int8)(unsafe.Pointer(&data)), len(data))
 }
@@ -78,6 +86,10 @@ func BytesToUint64(data []byte) []uint64 {
 	return unsafe.Slice(*(**uint64)(unsafe.Pointer(&data)), len(data)/8)
 }
 
+func BytesToUint128(data []byte) [][16]byte {
+	return unsafe.Slice(*(**[16]byte)(unsafe.Pointer(&data)), len(data)/16)
+}
+
 func BytesToFloat32(data []byte) []float32 {
 	return unsafe.Slice(*(**float32)(unsafe.Pointer(&data)), len(data)/4)
 }
@@ -86,10 +98,6 @@ func BytesToFloat64(data []byte) []float64 {
 	return unsafe.Slice(*(**float64)(unsafe.Pointer(&data)), len(data)/8)
 }
 
-func BytesToBE128(data []byte) [][16]byte {
-	return unsafe.Slice(*(**[16]byte)(unsafe.Pointer(&data)), len(data)/16)
-}
-
 func BytesToString(data []byte) string {
 	return *(*string)(unsafe.Pointer(&data))
 }
diff --git a/page.go b/page.go
index aad21ae..db2fafd 100644
--- a/page.go
+++ b/page.go
@@ -228,7 +228,7 @@ func errPageBoundsOutOfRange(i, j, n int64) error {
 }
 
 func countLevelsEqual(levels []int8, value int8) int {
-	return bytes.Count(bits.Int8ToBytes(levels), []byte{byte(value)})
+	return bits.CountByte(bits.Int8ToBytes(levels), byte(value))
 }
 
 func countLevelsNotEqual(levels []int8, value int8) int {


Test patch:
diff --git a/internal/bits/bits_test.go b/internal/bits/bits_test.go
index 19de093..d758f56 100644
--- a/internal/bits/bits_test.go
+++ b/internal/bits/bits_test.go
@@ -2,6 +2,8 @@ package bits_test
 
 import (
 	"fmt"
+	"math/rand"
+	"reflect"
 	"testing"
 
 	"github.com/segmentio/parquet-go/internal/bits"
@@ -95,3 +97,132 @@ func TestRound(t *testing.T) {
 		})
 	}
 }
+
+var benchmarkBufferSizes = [...]int{
+	4 * 1024,
+	256 * 1024,
+	2048 * 1024,
+}
+
+func forEachBenchmarkBufferSize(b *testing.B, f func(*testing.B, int)) {
+	for _, bufferSize := range benchmarkBufferSizes {
+		b.Run(fmt.Sprintf("%dKiB", bufferSize/1024), func(b *testing.B) {
+			b.SetBytes(int64(bufferSize))
+			f(b, bufferSize)
+		})
+	}
+}
+
+// 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,
+	} {
+		in := makeArray(n)
+		ok := v.Call([]reflect.Value{reflect.ValueOf(in)})
+		if !ok[0].Bool() {
+			return fmt.Errorf("failed on input of size %d: %#v\n", n, in)
+		}
+	}
+	return nil
+}
diff --git a/internal/bits/count_test.go b/internal/bits/count_test.go
new file mode 100644
index 0000000..0e25338
--- /dev/null
+++ b/internal/bits/count_test.go
@@ -0,0 +1,38 @@
+package bits_test
+
+import (
+	"bytes"
+	"testing"
+
+	"github.com/segmentio/parquet-go/internal/bits"
+)
+
+func TestCountByte(t *testing.T) {
+	err := quickCheck(func(data []byte) bool {
+		data = bytes.Repeat(data, 8)
+		for _, c := range data {
+			n1 := bytes.Count(data, []byte{c})
+			n2 := bits.CountByte(data, c)
+			if n1 != n2 {
+				t.Errorf("got=%d want=%d", n2, n1)
+				return false
+			}
+		}
+		return true
+	})
+	if err != nil {
+		t.Error(err)
+	}
+}
+
+func BenchmarkCountByte(b *testing.B) {
+	forEachBenchmarkBufferSize(b, func(b *testing.B, bufferSize int) {
+		data := make([]byte, bufferSize)
+		for i := range data {
+			data[i] = byte(i)
+		}
+		for i := 0; i < b.N; i++ {
+			bits.CountByte(data, 0)
+		}
+	})
+}
diff --git a/internal/bits/max_test.go b/internal/bits/max_test.go
new file mode 100644
index 0000000..c0b1668
--- /dev/null
+++ b/internal/bits/max_test.go
@@ -0,0 +1,280 @@
+package bits_test
+
+import (
+	"bytes"
+	"math/rand"
+	"testing"
+
+	"github.com/segmentio/parquet-go/internal/bits"
+)
+
+func TestMaxBool(t *testing.T) {
+	err := quickCheck(func(values []bool) bool {
+		max := false
+		for _, v := range values {
+			if v {
+				max = true
+				break
+			}
+		}
+		return max == bits.MaxBool(values)
+	})
+	if err != nil {
+		t.Error(err)
+	}
+
+	values := make([]bool, 200)
+	if bits.MaxBool(values) {
+		t.Error("max value must be false when all input values are false")
+	}
+	for i := range values {
+		values[i] = true
+	}
+	if !bits.MaxBool(values) {
+		t.Error("max value must be true when all input values are true")
+	}
+}
+
+func TestMaxInt32(t *testing.T) {
+	err := quickCheck(func(values []int32) bool {
+		max := int32(0)
+		if len(values) > 0 {
+			max = values[0]
+			for _, v := range values[1:] {
+				if v > max {
+					max = v
+				}
+			}
+		}
+		return max == bits.MaxInt32(values)
+	})
+	if err != nil {
+		t.Error(err)
+	}
+}
+
+func TestMaxInt64(t *testing.T) {
+	err := quickCheck(func(values []int64) bool {
+		max := int64(0)
+		if len(values) > 0 {
+			max = values[0]
+			for _, v := range values[1:] {
+				if v > max {
+					max = v
+				}
+			}
+		}
+		return max == bits.MaxInt64(values)
+	})
+	if err != nil {
+		t.Error(err)
+	}
+}
+
+func TestMaxUint32(t *testing.T) {
+	err := quickCheck(func(values []uint32) bool {
+		max := uint32(0)
+		if len(values) > 0 {
+			max = values[0]
+			for _, v := range values[1:] {
+				if v > max {
+					max = v
+				}
+			}
+		}
+		return max == bits.MaxUint32(values)
+	})
+	if err != nil {
+		t.Error(err)
+	}
+}
+
+func TestMaxUint64(t *testing.T) {
+	err := quickCheck(func(values []uint64) bool {
+		max := uint64(0)
+		if len(values) > 0 {
+			max = values[0]
+			for _, v := range values[1:] {
+				if v > max {
+					max = v
+				}
+			}
+		}
+		return max == bits.MaxUint64(values)
+	})
+	if err != nil {
+		t.Error(err)
+	}
+}
+
+func TestMaxFloat32(t *testing.T) {
+	err := quickCheck(func(values []float32) bool {
+		max := float32(0)
+		if len(values) > 0 {
+			max = values[0]
+			for _, v := range values[1:] {
+				if v > max {
+					max = v
+				}
+			}
+		}
+		return max == bits.MaxFloat32(values)
+	})
+	if err != nil {
+		t.Error(err)
+	}
+}
+
+func TestMaxFloat64(t *testing.T) {
+	err := quickCheck(func(values []float64) bool {
+		max := float64(0)
+		if len(values) > 0 {
+			max = values[0]
+			for _, v := range values[1:] {
+				if v > max {
+					max = v
+				}
+			}
+		}
+		return max == bits.MaxFloat64(values)
+	})
+	if err != nil {
+		t.Error(err)
+	}
+}
+
+func TestMaxFixedLenByteArray1(t *testing.T) {
+	err := quickCheck(func(values []byte) bool {
+		max := [1]byte{}
+		if len(values) > 0 {
+			max[0] = values[0]
+			for _, v := range values[1:] {
+				if v > max[0] {
+					max[0] = v
+				}
+			}
+		}
+		ret := bits.MaxFixedLenByteArray(1, values)
+		return (len(values) == 0 && ret == nil) || bytes.Equal(max[:], ret)
+	})
+	if err != nil {
+		t.Error(err)
+	}
+}
+
+func TestMaxFixedLenByteArray16(t *testing.T) {
+	err := quickCheck(func(values [][16]byte) bool {
+		max := [16]byte{}
+		if len(values) > 0 {
+			max = values[0]
+			for _, v := range values[1:] {
+				if bytes.Compare(v[:], max[:]) > 0 {
+					max = v
+				}
+			}
+		}
+		ret := bits.MaxFixedLenByteArray(16, bits.Uint128ToBytes(values))
+		return (len(values) == 0 && ret == nil) || bytes.Equal(max[:], ret)
+	})
+	if err != nil {
+		t.Error(err)
+	}
+}
+
+func BenchmarkMaxBool(b *testing.B) {
+	forEachBenchmarkBufferSize(b, func(b *testing.B, bufferSize int) {
+		values := make([]bool, bufferSize/1)
+		for i := 0; i < b.N; i++ {
+			bits.MaxBool(values)
+		}
+	})
+}
+
+func BenchmarkMaxInt32(b *testing.B) {
+	forEachBenchmarkBufferSize(b, func(b *testing.B, bufferSize int) {
+		values := make([]int32, bufferSize/4)
+		prng := rand.New(rand.NewSource(1))
+		for i := range values {
+			values[i] = prng.Int31()
+		}
+		for i := 0; i < b.N; i++ {
+			bits.MaxInt32(values)
+		}
+	})
+}
+
+func BenchmarkMaxInt64(b *testing.B) {
+	forEachBenchmarkBufferSize(b, func(b *testing.B, bufferSize int) {
+		values := make([]int64, bufferSize/8)
+		prng := rand.New(rand.NewSource(1))
+		for i := range values {
+			values[i] = prng.Int63()
+		}
+		for i := 0; i < b.N; i++ {
+			bits.MaxInt64(values)
+		}
+	})
+}
+
+func BenchmarkMaxUint32(b *testing.B) {
+	forEachBenchmarkBufferSize(b, func(b *testing.B, bufferSize int) {
+		values := make([]uint32, bufferSize/4)
+		prng := rand.New(rand.NewSource(1))
+		for i := range values {
+			values[i] = prng.Uint32()
+		}
+		for i := 0; i < b.N; i++ {
+			bits.MaxUint32(values)
+		}
+	})
+}
+
+func BenchmarkMaxUint64(b *testing.B) {
+	forEachBenchmarkBufferSize(b, func(b *testing.B, bufferSize int) {
+		values := make([]uint64, bufferSize/8)
+		prng := rand.New(rand.NewSource(1))
+		for i := range values {
+			values[i] = prng.Uint64()
+		}
+		for i := 0; i < b.N; i++ {
+			bits.MaxUint64(values)
+		}
+	})
+}
+
+func BenchmarkMaxFloat32(b *testing.B) {
+	forEachBenchmarkBufferSize(b, func(b *testing.B, bufferSize int) {
+		values := make([]float32, bufferSize/4)
+		prng := rand.New(rand.NewSource(1))
+		for i := range values {
+			values[i] = prng.Float32()
+		}
+		for i := 0; i < b.N; i++ {
+			bits.MaxFloat32(values)
+		}
+	})
+}
+
+func BenchmarkMaxFloat64(b *testing.B) {
+	forEachBenchmarkBufferSize(b, func(b *testing.B, bufferSize int) {
+		values := make([]float64, bufferSize/8)
+		prng := rand.New(rand.NewSource(1))
+		for i := range values {
+			values[i] = prng.Float64()
+		}
+		for i := 0; i < b.N; i++ {
+			bits.MaxFloat64(values)
+		}
+	})
+}
+
+func BenchmarkMaxFixedLenByteArray(b *testing.B) {
+	forEachBenchmarkBufferSize(b, func(b *testing.B, bufferSize int) {
+		values := make([]byte, bufferSize)
+		prng := rand.New(rand.NewSource(1))
+		prng.Read(values)
+		for i := 0; i < b.N; i++ {
+			bits.MaxFixedLenByteArray(16, values)
+		}
+	})
+}
diff --git a/internal/bits/min_test.go b/internal/bits/min_test.go
new file mode 100644
index 0000000..2f175cf
--- /dev/null
+++ b/internal/bits/min_test.go
@@ -0,0 +1,283 @@
+package bits_test
+
+import (
+	"bytes"
+	"math/rand"
+	"testing"
+
+	"github.com/segmentio/parquet-go/internal/bits"
+)
+
+func TestMinBool(t *testing.T) {
+	err := quickCheck(func(values []bool) bool {
+		min := len(values) > 0
+		for _, v := range values {
+			if !v {
+				min = false
+				break
+			}
+		}
+		return min == bits.MinBool(values)
+	})
+	if err != nil {
+		t.Error(err)
+	}
+
+	values := make([]bool, 200)
+	if bits.MinBool(values) {
+		t.Error("min value must be false when all input values are false")
+	}
+	for i := range values {
+		values[i] = true
+	}
+	if !bits.MinBool(values) {
+		t.Error("min value must be true when all input values are true")
+	}
+}
+
+func TestMinInt32(t *testing.T) {
+	err := quickCheck(func(values []int32) bool {
+		min := int32(0)
+		if len(values) > 0 {
+			min = values[0]
+			for _, v := range values[1:] {
+				if v < min {
+					min = v
+				}
+			}
+		}
+		return min == bits.MinInt32(values)
+	})
+	if err != nil {
+		t.Error(err)
+	}
+}
+
+func TestMinInt64(t *testing.T) {
+	err := quickCheck(func(values []int64) bool {
+		min := int64(0)
+		if len(values) > 0 {
+			min = values[0]
+			for _, v := range values[1:] {
+				if v < min {
+					min = v
+				}
+			}
+		}
+		return min == bits.MinInt64(values)
+	})
+	if err != nil {
+		t.Error(err)
+	}
+}
+
+func TestMinUint32(t *testing.T) {
+	err := quickCheck(func(values []uint32) bool {
+		min := uint32(0)
+		if len(values) > 0 {
+			min = values[0]
+			for _, v := range values[1:] {
+				if v < min {
+					min = v
+				}
+			}
+		}
+		return min == bits.MinUint32(values)
+	})
+	if err != nil {
+		t.Error(err)
+	}
+}
+
+func TestMinUint64(t *testing.T) {
+	err := quickCheck(func(values []uint64) bool {
+		min := uint64(0)
+		if len(values) > 0 {
+			min = values[0]
+			for _, v := range values[1:] {
+				if v < min {
+					min = v
+				}
+			}
+		}
+		return min == bits.MinUint64(values)
+	})
+	if err != nil {
+		t.Error(err)
+	}
+}
+
+func TestMinFloat32(t *testing.T) {
+	err := quickCheck(func(values []float32) bool {
+		min := float32(0)
+		if len(values) > 0 {
+			min = values[0]
+			for _, v := range values[1:] {
+				if v < min {
+					min = v
+				}
+			}
+		}
+		return min == bits.MinFloat32(values)
+	})
+	if err != nil {
+		t.Error(err)
+	}
+}
+
+func TestMinFloat64(t *testing.T) {
+	err := quickCheck(func(values []float64) bool {
+		min := float64(0)
+		if len(values) > 0 {
+			min = values[0]
+			for _, v := range values[1:] {
+				if v < min {
+					min = v
+				}
+			}
+		}
+		return min == bits.MinFloat64(values)
+	})
+	if err != nil {
+		t.Error(err)
+	}
+}
+
+func TestMinFixedLenByteArray1(t *testing.T) {
+	err := quickCheck(func(values []byte) bool {
+		min := [1]byte{}
+		if len(values) > 0 {
+			min[0] = values[0]
+			for _, v := range values[1:] {
+				if v < min[0] {
+					min[0] = v
+				}
+			}
+		}
+		ret := bits.MinFixedLenByteArray(1, values)
+		return (len(values) == 0 && ret == nil) || bytes.Equal(min[:], ret)
+	})
+	if err != nil {
+		t.Error(err)
+	}
+}
+
+func TestMinFixedLenByteArray16(t *testing.T) {
+	err := quickCheck(func(values [][16]byte) bool {
+		min := [16]byte{}
+		if len(values) > 0 {
+			min = values[0]
+			for _, v := range values[1:] {
+				if bytes.Compare(v[:], min[:]) < 0 {
+					min = v
+				}
+			}
+		}
+		ret := bits.MinFixedLenByteArray(16, bits.Uint128ToBytes(values))
+		return (len(values) == 0 && ret == nil) || bytes.Equal(min[:], ret)
+	})
+	if err != nil {
+		t.Error(err)
+	}
+}
+
+func BenchmarkMinBool(b *testing.B) {
+	forEachBenchmarkBufferSize(b, func(b *testing.B, bufferSize int) {
+		values := make([]bool, bufferSize/1)
+		for i := range values {
+			values[i] = true
+		}
+		for i := 0; i < b.N; i++ {
+			bits.MinBool(values)
+		}
+	})
+}
+
+func BenchmarkMinInt32(b *testing.B) {
+	forEachBenchmarkBufferSize(b, func(b *testing.B, bufferSize int) {
+		values := make([]int32, bufferSize/4)
+		prng := rand.New(rand.NewSource(1))
+		for i := range values {
+			values[i] = prng.Int31()
+		}
+		for i := 0; i < b.N; i++ {
+			bits.MinInt32(values)
+		}
+	})
+}
+
+func BenchmarkMinInt64(b *testing.B) {
+	forEachBenchmarkBufferSize(b, func(b *testing.B, bufferSize int) {
+		values := make([]int64, bufferSize/8)
+		prng := rand.New(rand.NewSource(1))
+		for i := range values {
+			values[i] = prng.Int63()
+		}
+		for i := 0; i < b.N; i++ {
+			bits.MinInt64(values)
+		}
+	})
+}
+
+func BenchmarkMinUint32(b *testing.B) {
+	forEachBenchmarkBufferSize(b, func(b *testing.B, bufferSize int) {
+		values := make([]uint32, bufferSize/4)
+		prng := rand.New(rand.NewSource(1))
+		for i := range values {
+			values[i] = prng.Uint32()
+		}
+		for i := 0; i < b.N; i++ {
+			bits.MinUint32(values)
+		}
+	})
+}
+
+func BenchmarkMinUint64(b *testing.B) {
+	forEachBenchmarkBufferSize(b, func(b *testing.B, bufferSize int) {
+		values := make([]uint64, bufferSize/8)
+		prng := rand.New(rand.NewSource(1))
+		for i := range values {
+			values[i] = prng.Uint64()
+		}
+		for i := 0; i < b.N; i++ {
+			bits.MinUint64(values)
+		}
+	})
+}
+
+func BenchmarkMinFloat32(b *testing.B) {
+	forEachBenchmarkBufferSize(b, func(b *testing.B, bufferSize int) {
+		values := make([]float32, bufferSize/4)
+		prng := rand.New(rand.NewSource(1))
+		for i := range values {
+			values[i] = prng.Float32()
+		}
+		for i := 0; i < b.N; i++ {
+			bits.MinFloat32(values)
+		}
+	})
+}
+
+func BenchmarkMinFloat64(b *testing.B) {
+	forEachBenchmarkBufferSize(b, func(b *testing.B, bufferSize int) {
+		values := make([]float64, bufferSize/8)
+		prng := rand.New(rand.NewSource(1))
+		for i := range values {
+			values[i] = prng.Float64()
+		}
+		for i := 0; i < b.N; i++ {
+			bits.MinFloat64(values)
+		}
+	})
+}
+
+func BenchmarkMinFixedLenByteArray(b *testing.B) {
+	forEachBenchmarkBufferSize(b, func(b *testing.B, bufferSize int) {
+		values := make([]byte, bufferSize)
+		prng := rand.New(rand.NewSource(1))
+		prng.Read(values)
+		for i := 0; i < b.N; i++ {
+			bits.MinFixedLenByteArray(16, values)
+		}
+	})
+}
diff --git a/internal/bits/minmax_test.go b/internal/bits/minmax_test.go
new file mode 100644
index 0000000..9d8c3b9
--- /dev/null
+++ b/internal/bits/minmax_test.go
@@ -0,0 +1,336 @@
+package bits_test
+
+import (
+	"bytes"
+	"math/rand"
+	"testing"
+
+	"github.com/segmentio/parquet-go/internal/bits"
+)
+
+const scaleFactor = 6000
+
+func TestMinMaxBool(t *testing.T) {
+	err := quickCheck(func(values []bool) bool {
+		min := len(values) > 0
+		max := false
+		for _, v := range values {
+			if v {
+				max = true
+			} else {
+				min = false
+			}
+		}
+		minValue, maxValue := bits.MinMaxBool(values)
+		return min == minValue && max == maxValue
+	})
+	if err != nil {
+		t.Error(err)
+	}
+
+	values := make([]bool, 200)
+	if minValue, maxValue := bits.MinMaxBool(values); minValue || maxValue {
+		t.Error("min and max values must be false when all input values are false")
+	}
+	for i := range values {
+		values[i] = true
+	}
+	if minValue, maxValue := bits.MinMaxBool(values); !minValue || !maxValue {
+		t.Error("min and max values must be true when all input values are true")
+	}
+}
+
+func TestMinMaxInt32(t *testing.T) {
+	err := quickCheck(func(values []int32) bool {
+		min := int32(0)
+		max := int32(0)
+		if len(values) > 0 {
+			min = values[0]
+			max = values[0]
+			for _, v := range values[1:] {
+				if v < min {
+					min = v
+				}
+				if v > max {
+					max = v
+				}
+			}
+		}
+		minValue, maxValue := bits.MinMaxInt32(values)
+		return min == minValue && max == maxValue
+	})
+	if err != nil {
+		t.Error(err)
+	}
+}
+
+func TestMinMaxInt64(t *testing.T) {
+	err := quickCheck(func(values []int64) bool {
+		min := int64(0)
+		max := int64(0)
+		if len(values) > 0 {
+			min = values[0]
+			max = values[0]
+			for _, v := range values[1:] {
+				if v < min {
+					min = v
+				}
+				if v > max {
+					max = v
+				}
+			}
+		}
+		minValue, maxValue := bits.MinMaxInt64(values)
+		return min == minValue && max == maxValue
+	})
+	if err != nil {
+		t.Error(err)
+	}
+}
+
+func TestMinMaxUint32(t *testing.T) {
+	err := quickCheck(func(values []uint32) bool {
+		min := uint32(0)
+		max := uint32(0)
+		if len(values) > 0 {
+			min = values[0]
+			max = values[0]
+			for _, v := range values[1:] {
+				if v < min {
+					min = v
+				}
+				if v > max {
+					max = v
+				}
+			}
+		}
+		minValue, maxValue := bits.MinMaxUint32(values)
+		return min == minValue && max == maxValue
+	})
+	if err != nil {
+		t.Error(err)
+	}
+}
+
+func TestMinMaxUint64(t *testing.T) {
+	err := quickCheck(func(values []uint64) bool {
+		min := uint64(0)
+		max := uint64(0)
+		if len(values) > 0 {
+			min = values[0]
+			max = values[0]
+			for _, v := range values[1:] {
+				if v < min {
+					min = v
+				}
+				if v > max {
+					max = v
+				}
+			}
+		}
+		minValue, maxValue := bits.MinMaxUint64(values)
+		return min == minValue && max == maxValue
+	})
+	if err != nil {
+		t.Error(err)
+	}
+}
+
+func TestMinMaxFloat32(t *testing.T) {
+	err := quickCheck(func(values []float32) bool {
+		min := float32(0)
+		max := float32(0)
+		if len(values) > 0 {
+			min = values[0]
+			max = values[0]
+			for _, v := range values[1:] {
+				if v < min {
+					min = v
+				}
+				if v > max {
+					max = v
+				}
+			}
+		}
+		minValue, maxValue := bits.MinMaxFloat32(values)
+		return min == minValue && max == maxValue
+	})
+	if err != nil {
+		t.Error(err)
+	}
+}
+
+func TestMinMaxFloat64(t *testing.T) {
+	err := quickCheck(func(values []float64) bool {
+		min := float64(0)
+		max := float64(0)
+		if len(values) > 0 {
+			min = values[0]
+			max = values[0]
+			for _, v := range values[1:] {
+				if v < min {
+					min = v
+				}
+				if v > max {
+					max = v
+				}
+			}
+		}
+		minValue, maxValue := bits.MinMaxFloat64(values)
+		return min == minValue && max == maxValue
+	})
+	if err != nil {
+		t.Error(err)
+	}
+}
+
+func TestMinMaxFixedLenByteArray1(t *testing.T) {
+	err := quickCheck(func(values []byte) bool {
+		min := [1]byte{}
+		max := [1]byte{}
+		if len(values) > 0 {
+			min[0] = values[0]
+			max[0] = values[0]
+			for _, v := range values[1:] {
+				if v < min[0] {
+					min[0] = v
+				}
+				if v > max[0] {
+					max[0] = v
+				}
+			}
+		}
+		minValue, maxValue := bits.MinMaxFixedLenByteArray(1, values)
+		return (len(values) == 0 && minValue == nil && maxValue == nil) ||
+			(bytes.Equal(min[:], minValue) && bytes.Equal(max[:], maxValue))
+	})
+	if err != nil {
+		t.Error(err)
+	}
+}
+
+func TestMinMaxFixedLenByteArray16(t *testing.T) {
+	err := quickCheck(func(values [][16]byte) bool {
+		min := [16]byte{}
+		max := [16]byte{}
+		if len(values) > 0 {
+			min = values[0]
+			max = values[0]
+			for _, v := range values[1:] {
+				if bytes.Compare(v[:], min[:]) < 0 {
+					min = v
+				}
+				if bytes.Compare(v[:], max[:]) > 0 {
+					max = v
+				}
+			}
+		}
+		minValue, maxValue := bits.MinMaxFixedLenByteArray(16, bits.Uint128ToBytes(values))
+		return (len(values) == 0 && minValue == nil && maxValue == nil) ||
+			(bytes.Equal(min[:], minValue) && bytes.Equal(max[:], maxValue))
+	})
+	if err != nil {
+		t.Error(err)
+	}
+}
+
+func BenchmarkMinMaxBool(b *testing.B) {
+	forEachBenchmarkBufferSize(b, func(b *testing.B, bufferSize int) {
+		values := make([]bool, bufferSize/1)
+		for i := range values {
+			values[i] = true
+		}
+		for i := 0; i < b.N; i++ {
+			bits.MinMaxBool(values)
+		}
+	})
+}
+
+func BenchmarkMinMaxInt32(b *testing.B) {
+	forEachBenchmarkBufferSize(b, func(b *testing.B, bufferSize int) {
+		values := make([]int32, bufferSize/4)
+		prng := rand.New(rand.NewSource(1))
+		for i := range values {
+			values[i] = prng.Int31()
+		}
+		for i := 0; i < b.N; i++ {
+			bits.MinMaxInt32(values)
+		}
+	})
+}
+
+func BenchmarkMinMaxInt64(b *testing.B) {
+	forEachBenchmarkBufferSize(b, func(b *testing.B, bufferSize int) {
+		values := make([]int64, bufferSize/8)
+		prng := rand.New(rand.NewSource(1))
+		for i := range values {
+			values[i] = prng.Int63()
+		}
+		for i := 0; i < b.N; i++ {
+			bits.MinMaxInt64(values)
+		}
+	})
+}
+
+func BenchmarkMinMaxUint32(b *testing.B) {
+	forEachBenchmarkBufferSize(b, func(b *testing.B, bufferSize int) {
+		values := make([]uint32, bufferSize/4)
+		prng := rand.New(rand.NewSource(1))
+		for i := range values {
+			values[i] = prng.Uint32()
+		}
+		for i := 0; i < b.N; i++ {
+			bits.MinMaxUint32(values)
+		}
+	})
+}
+
+func BenchmarkMinMaxUint64(b *testing.B) {
+	forEachBenchmarkBufferSize(b, func(b *testing.B, bufferSize int) {
+		values := make([]uint64, bufferSize/8)
+		prng := rand.New(rand.NewSource(1))
+		for i := range values {
+			values[i] = prng.Uint64()
+		}
+		for i := 0; i < b.N; i++ {
+			bits.MinMaxUint64(values)
+		}
+	})
+}
+
+func BenchmarkMinMaxFloat32(b *testing.B) {
+	forEachBenchmarkBufferSize(b, func(b *testing.B, bufferSize int) {
+		values := make([]float32, bufferSize/4)
+		prng := rand.New(rand.NewSource(1))
+		for i := range values {
+			values[i] = prng.Float32()
+		}
+		for i := 0; i < b.N; i++ {
+			bits.MinMaxFloat32(values)
+		}
+	})
+}
+
+func BenchmarkMinMaxFloat64(b *testing.B) {
+	forEachBenchmarkBufferSize(b, func(b *testing.B, bufferSize int) {
+		values := make([]float64, bufferSize/8)
+		prng := rand.New(rand.NewSource(1))
+		for i := range values {
+			values[i] = prng.Float64()
+		}
+		for i := 0; i < b.N; i++ {
+			bits.MinMaxFloat64(values)
+		}
+	})
+}
+
+func BenchmarkMinMaxFixedLenByteArray(b *testing.B) {
+	forEachBenchmarkBufferSize(b, func(b *testing.B, bufferSize int) {
+		values := make([]byte, bufferSize)
+		prng := rand.New(rand.NewSource(1))
+		prng.Read(values)
+		for i := 0; i < b.N; i++ {
+			bits.MinMaxFixedLenByteArray(16, values)
+		}
+	})
+}