Repository: segmentio/parquet-go
Pull request #223 (2022-06-08 19:15:51)

Internal bits remove unused code
This PR is a second step towards removing the `internal/bits` package https://github.com/segmentio/parquet-go/issues/221

The changes here remove unused code and unnecessary abstractions. See comments inline for details.


Patch:
diff --git a/bloom/filter.go b/bloom/filter.go
index 623b4a5..f4699b9 100644
--- a/bloom/filter.go
+++ b/bloom/filter.go
@@ -4,8 +4,6 @@ import (
 	"io"
 	"sync"
 	"unsafe"
-
-	"github.com/segmentio/parquet-go/internal/bits"
 )
 
 // Filter is an interface representing read-only bloom filters where programs
@@ -47,9 +45,9 @@ func MakeSplitBlockFilter(data []byte) SplitBlockFilter {
 //	f := make(bloom.SplitBlockFilter, bloom.NumSplitBlocksOf(n, 10))
 //
 func NumSplitBlocksOf(numValues int64, bitsPerValue uint) int {
-	numBytes := bits.ByteCount(uint(numValues) * bitsPerValue)
+	numBytes := ((uint(numValues) * bitsPerValue) + 7) / 8
 	numBlocks := (numBytes + (BlockSize - 1)) / BlockSize
-	return numBlocks
+	return int(numBlocks)
 }
 
 // Reset clears the content of the filter f.
diff --git a/buffer.go b/buffer.go
index 472e67d..1251a83 100644
--- a/buffer.go
+++ b/buffer.go
@@ -62,17 +62,17 @@ func (buf *Buffer) configure(schema *Schema) {
 		nullOrdering := nullsGoLast
 		columnIndex := int(leaf.columnIndex)
 		columnType := leaf.node.Type()
-		bufferSize := buf.config.ColumnBufferSize
+		bufferCap := buf.config.ColumnBufferCapacity
 		dictionary := (Dictionary)(nil)
 		encoding := encodingOf(leaf.node)
 
 		if isDictionaryEncoding(encoding) {
-			bufferSize /= 2
-			dictionary = columnType.NewDictionary(columnIndex, 0, make([]byte, 0, bufferSize))
+			dictBuffer := make([]byte, 0, columnType.EstimateSize(bufferCap))
+			dictionary = columnType.NewDictionary(columnIndex, 0, dictBuffer)
 			columnType = dictionary.Type()
 		}
 
-		column := columnType.NewColumnBuffer(columnIndex, bufferSize)
+		column := columnType.NewColumnBuffer(columnIndex, bufferCap)
 		switch {
 		case leaf.maxRepetitionLevel > 0:
 			column = newRepeatedColumnBuffer(column, leaf.maxRepetitionLevel, leaf.maxDefinitionLevel, nullOrdering)
diff --git a/column_buffer.go b/column_buffer.go
index db5d062..80b81e7 100644
--- a/column_buffer.go
+++ b/column_buffer.go
@@ -9,7 +9,6 @@ import (
 
 	"github.com/segmentio/parquet-go/deprecated"
 	"github.com/segmentio/parquet-go/encoding/plain"
-	"github.com/segmentio/parquet-go/internal/bits"
 	"github.com/segmentio/parquet-go/internal/unsafecast"
 )
 
@@ -727,11 +726,11 @@ func (col *repeatedColumnBuffer) ReadValuesAt(values []Value, offset int64) (int
 
 type booleanColumnBuffer struct{ booleanPage }
 
-func newBooleanColumnBuffer(typ Type, columnIndex int16, bufferSize int) *booleanColumnBuffer {
+func newBooleanColumnBuffer(typ Type, columnIndex int16, numValues int32) *booleanColumnBuffer {
 	return &booleanColumnBuffer{
 		booleanPage: booleanPage{
 			typ:         typ,
-			bits:        make([]byte, 0, bufferSize),
+			bits:        make([]byte, 0, numValues),
 			columnIndex: ^columnIndex,
 		},
 	}
@@ -817,7 +816,7 @@ func (col *booleanColumnBuffer) WriteValues(values []Value) (int, error) {
 }
 
 func (col *booleanColumnBuffer) writeValues(rows array, size, offset uintptr, _ columnLevels) {
-	numBytes := bits.ByteCount(uint(col.numValues) + uint(rows.len))
+	numBytes := byteCount(uint(col.numValues) + uint(rows.len))
 	if cap(col.bits) < numBytes {
 		col.bits = append(make([]byte, 0, 2*cap(col.bits)), col.bits...)
 	}
@@ -891,7 +890,7 @@ func (col *booleanColumnBuffer) writeValues(rows array, size, offset uintptr, _
 		i++
 	}
 
-	col.bits = col.bits[:bits.ByteCount(uint(col.numValues))]
+	col.bits = col.bits[:byteCount(uint(col.numValues))]
 }
 
 func (col *booleanColumnBuffer) ReadValuesAt(values []Value, offset int64) (n int, err error) {
@@ -916,11 +915,11 @@ func (col *booleanColumnBuffer) ReadValuesAt(values []Value, offset int64) (n in
 
 type int32ColumnBuffer struct{ int32Page }
 
-func newInt32ColumnBuffer(typ Type, columnIndex int16, bufferSize int) *int32ColumnBuffer {
+func newInt32ColumnBuffer(typ Type, columnIndex int16, numValues int32) *int32ColumnBuffer {
 	return &int32ColumnBuffer{
 		int32Page: int32Page{
 			typ:         typ,
-			values:      make([]int32, 0, bufferSize/4),
+			values:      make([]int32, 0, numValues),
 			columnIndex: ^columnIndex,
 		},
 	}
@@ -1018,11 +1017,11 @@ func (col *int32ColumnBuffer) ReadValuesAt(values []Value, offset int64) (n int,
 
 type int64ColumnBuffer struct{ int64Page }
 
-func newInt64ColumnBuffer(typ Type, columnIndex int16, bufferSize int) *int64ColumnBuffer {
+func newInt64ColumnBuffer(typ Type, columnIndex int16, numValues int32) *int64ColumnBuffer {
 	return &int64ColumnBuffer{
 		int64Page: int64Page{
 			typ:         typ,
-			values:      make([]int64, 0, bufferSize/8),
+			values:      make([]int64, 0, numValues),
 			columnIndex: ^columnIndex,
 		},
 	}
@@ -1119,11 +1118,11 @@ func (col *int64ColumnBuffer) ReadValuesAt(values []Value, offset int64) (n int,
 
 type int96ColumnBuffer struct{ int96Page }
 
-func newInt96ColumnBuffer(typ Type, columnIndex int16, bufferSize int) *int96ColumnBuffer {
+func newInt96ColumnBuffer(typ Type, columnIndex int16, numValues int32) *int96ColumnBuffer {
 	return &int96ColumnBuffer{
 		int96Page: int96Page{
 			typ:         typ,
-			values:      make([]deprecated.Int96, 0, bufferSize/12),
+			values:      make([]deprecated.Int96, 0, numValues),
 			columnIndex: ^columnIndex,
 		},
 	}
@@ -1212,11 +1211,11 @@ func (col *int96ColumnBuffer) ReadValuesAt(values []Value, offset int64) (n int,
 
 type floatColumnBuffer struct{ floatPage }
 
-func newFloatColumnBuffer(typ Type, columnIndex int16, bufferSize int) *floatColumnBuffer {
+func newFloatColumnBuffer(typ Type, columnIndex int16, numValues int32) *floatColumnBuffer {
 	return &floatColumnBuffer{
 		floatPage: floatPage{
 			typ:         typ,
-			values:      make([]float32, 0, bufferSize/4),
+			values:      make([]float32, 0, numValues),
 			columnIndex: ^columnIndex,
 		},
 	}
@@ -1313,11 +1312,11 @@ func (col *floatColumnBuffer) ReadValuesAt(values []Value, offset int64) (n int,
 
 type doubleColumnBuffer struct{ doublePage }
 
-func newDoubleColumnBuffer(typ Type, columnIndex int16, bufferSize int) *doubleColumnBuffer {
+func newDoubleColumnBuffer(typ Type, columnIndex int16, numValues int32) *doubleColumnBuffer {
 	return &doubleColumnBuffer{
 		doublePage: doublePage{
 			typ:         typ,
-			values:      make([]float64, 0, bufferSize/8),
+			values:      make([]float64, 0, numValues),
 			columnIndex: ^columnIndex,
 		},
 	}
@@ -1417,14 +1416,14 @@ type byteArrayColumnBuffer struct {
 	offsets []uint32
 }
 
-func newByteArrayColumnBuffer(typ Type, columnIndex int16, bufferSize int) *byteArrayColumnBuffer {
+func newByteArrayColumnBuffer(typ Type, columnIndex int16, numValues int32) *byteArrayColumnBuffer {
 	return &byteArrayColumnBuffer{
 		byteArrayPage: byteArrayPage{
 			typ:         typ,
-			values:      make([]byte, 0, bufferSize/2),
+			values:      make([]byte, 0, typ.EstimateSize(int(numValues))),
 			columnIndex: ^columnIndex,
 		},
-		offsets: make([]uint32, 0, bufferSize/8),
+		offsets: make([]uint32, 0, numValues),
 	}
 }
 
@@ -1461,7 +1460,7 @@ func (col *byteArrayColumnBuffer) Dictionary() Dictionary { return nil }
 func (col *byteArrayColumnBuffer) Pages() Pages { return onePage(col.Page()) }
 
 func (col *byteArrayColumnBuffer) Page() BufferedPage {
-	if len(col.offsets) > 0 && bits.OrderOfUint32(col.offsets) < 1 { // unordered?
+	if len(col.offsets) > 0 && orderOfUint32(col.offsets) < 1 { // unordered?
 		values := make([]byte, 0, len(col.values)) // TODO: pool this buffer?
 
 		for _, offset := range col.offsets {
@@ -1566,13 +1565,13 @@ type fixedLenByteArrayColumnBuffer struct {
 	tmp []byte
 }
 
-func newFixedLenByteArrayColumnBuffer(typ Type, columnIndex int16, bufferSize int) *fixedLenByteArrayColumnBuffer {
+func newFixedLenByteArrayColumnBuffer(typ Type, columnIndex int16, numValues int32) *fixedLenByteArrayColumnBuffer {
 	size := typ.Length()
 	return &fixedLenByteArrayColumnBuffer{
 		fixedLenByteArrayPage: fixedLenByteArrayPage{
 			typ:         typ,
 			size:        size,
-			data:        make([]byte, 0, bufferSize),
+			data:        make([]byte, 0, typ.EstimateSize(int(numValues))),
 			columnIndex: ^columnIndex,
 		},
 		tmp: make([]byte, size),
@@ -1691,11 +1690,11 @@ func (col *fixedLenByteArrayColumnBuffer) ReadValuesAt(values []Value, offset in
 
 type uint32ColumnBuffer struct{ uint32Page }
 
-func newUint32ColumnBuffer(typ Type, columnIndex int16, bufferSize int) *uint32ColumnBuffer {
+func newUint32ColumnBuffer(typ Type, columnIndex int16, numValues int32) *uint32ColumnBuffer {
 	return &uint32ColumnBuffer{
 		uint32Page: uint32Page{
 			typ:         typ,
-			values:      make([]uint32, 0, bufferSize/4),
+			values:      make([]uint32, 0, numValues),
 			columnIndex: ^columnIndex,
 		},
 	}
@@ -1792,11 +1791,11 @@ func (col *uint32ColumnBuffer) ReadValuesAt(values []Value, offset int64) (n int
 
 type uint64ColumnBuffer struct{ uint64Page }
 
-func newUint64ColumnBuffer(typ Type, columnIndex int16, bufferSize int) *uint64ColumnBuffer {
+func newUint64ColumnBuffer(typ Type, columnIndex int16, numValues int32) *uint64ColumnBuffer {
 	return &uint64ColumnBuffer{
 		uint64Page: uint64Page{
 			typ:         typ,
-			values:      make([]uint64, 0, bufferSize/8),
+			values:      make([]uint64, 0, numValues),
 			columnIndex: ^columnIndex,
 		},
 	}
@@ -1893,11 +1892,11 @@ func (col *uint64ColumnBuffer) ReadValuesAt(values []Value, offset int64) (n int
 
 type be128ColumnBuffer struct{ be128Page }
 
-func newBE128ColumnBuffer(typ Type, columnIndex int16, bufferSize int) *be128ColumnBuffer {
+func newBE128ColumnBuffer(typ Type, columnIndex int16, numValues int32) *be128ColumnBuffer {
 	return &be128ColumnBuffer{
 		be128Page: be128Page{
 			typ:         typ,
-			values:      make([][16]byte, 0, bufferSize/16),
+			values:      make([][16]byte, 0, numValues),
 			columnIndex: ^columnIndex,
 		},
 	}
diff --git a/column_buffer_amd64.go b/column_buffer_amd64.go
index 0bd7879..8c34fa8 100644
--- a/column_buffer_amd64.go
+++ b/column_buffer_amd64.go
@@ -5,12 +5,13 @@ package parquet
 import (
 	"unsafe"
 
+	"github.com/segmentio/parquet-go/internal/bytealg"
 	"github.com/segmentio/parquet-go/internal/unsafecast"
 	"golang.org/x/sys/cpu"
 )
 
 func broadcastValueInt32(dst []int32, src int8) {
-	memset(unsafecast.Int32ToBytes(dst), byte(src))
+	bytealg.Broadcast(unsafecast.Int32ToBytes(dst), byte(src))
 }
 
 //go:noescape
diff --git a/column_index.go b/column_index.go
index 3e1ee7a..efbc9eb 100644
--- a/column_index.go
+++ b/column_index.go
@@ -4,7 +4,6 @@ import (
 	"github.com/segmentio/parquet-go/deprecated"
 	"github.com/segmentio/parquet-go/encoding/plain"
 	"github.com/segmentio/parquet-go/format"
-	"github.com/segmentio/parquet-go/internal/bits"
 	"github.com/segmentio/parquet-go/internal/unsafecast"
 )
 
@@ -324,8 +323,8 @@ func (i *booleanColumnIndexer) ColumnIndex() format.ColumnIndex {
 	return i.columnIndex(
 		splitFixedLenByteArrays(unsafecast.BoolToBytes(i.minValues), 1),
 		splitFixedLenByteArrays(unsafecast.BoolToBytes(i.maxValues), 1),
-		bits.OrderOfBool(i.minValues),
-		bits.OrderOfBool(i.maxValues),
+		orderOfBool(i.minValues),
+		orderOfBool(i.maxValues),
 	)
 }
 
@@ -355,8 +354,8 @@ func (i *int32ColumnIndexer) ColumnIndex() format.ColumnIndex {
 	return i.columnIndex(
 		splitFixedLenByteArrays(unsafecast.Int32ToBytes(i.minValues), 4),
 		splitFixedLenByteArrays(unsafecast.Int32ToBytes(i.maxValues), 4),
-		bits.OrderOfInt32(i.minValues),
-		bits.OrderOfInt32(i.maxValues),
+		orderOfInt32(i.minValues),
+		orderOfInt32(i.maxValues),
 	)
 }
 
@@ -386,8 +385,8 @@ func (i *int64ColumnIndexer) ColumnIndex() format.ColumnIndex {
 	return i.columnIndex(
 		splitFixedLenByteArrays(unsafecast.Int64ToBytes(i.minValues), 8),
 		splitFixedLenByteArrays(unsafecast.Int64ToBytes(i.maxValues), 8),
-		bits.OrderOfInt64(i.minValues),
-		bits.OrderOfInt64(i.maxValues),
+		orderOfInt64(i.minValues),
+		orderOfInt64(i.maxValues),
 	)
 }
 
@@ -448,8 +447,8 @@ func (i *floatColumnIndexer) ColumnIndex() format.ColumnIndex {
 	return i.columnIndex(
 		splitFixedLenByteArrays(unsafecast.Float32ToBytes(i.minValues), 4),
 		splitFixedLenByteArrays(unsafecast.Float32ToBytes(i.maxValues), 4),
-		bits.OrderOfFloat32(i.minValues),
-		bits.OrderOfFloat32(i.maxValues),
+		orderOfFloat32(i.minValues),
+		orderOfFloat32(i.maxValues),
 	)
 }
 
@@ -479,8 +478,8 @@ func (i *doubleColumnIndexer) ColumnIndex() format.ColumnIndex {
 	return i.columnIndex(
 		splitFixedLenByteArrays(unsafecast.Float64ToBytes(i.minValues), 8),
 		splitFixedLenByteArrays(unsafecast.Float64ToBytes(i.maxValues), 8),
-		bits.OrderOfFloat64(i.minValues),
-		bits.OrderOfFloat64(i.maxValues),
+		orderOfFloat64(i.minValues),
+		orderOfFloat64(i.maxValues),
 	)
 }
 
@@ -519,8 +518,8 @@ func (i *byteArrayColumnIndexer) ColumnIndex() format.ColumnIndex {
 	return i.columnIndex(
 		minValues,
 		maxValues,
-		bits.OrderOfBytes(minValues),
-		bits.OrderOfBytes(maxValues),
+		orderOfBytes(minValues),
+		orderOfBytes(maxValues),
 	)
 }
 
@@ -565,8 +564,8 @@ func (i *fixedLenByteArrayColumnIndexer) ColumnIndex() format.ColumnIndex {
 	return i.columnIndex(
 		minValues,
 		maxValues,
-		bits.OrderOfBytes(minValues),
-		bits.OrderOfBytes(maxValues),
+		orderOfBytes(minValues),
+		orderOfBytes(maxValues),
 	)
 }
 
@@ -596,8 +595,8 @@ func (i *uint32ColumnIndexer) ColumnIndex() format.ColumnIndex {
 	return i.columnIndex(
 		splitFixedLenByteArrays(unsafecast.Uint32ToBytes(i.minValues), 4),
 		splitFixedLenByteArrays(unsafecast.Uint32ToBytes(i.maxValues), 4),
-		bits.OrderOfUint32(i.minValues),
-		bits.OrderOfUint32(i.maxValues),
+		orderOfUint32(i.minValues),
+		orderOfUint32(i.maxValues),
 	)
 }
 
@@ -627,8 +626,8 @@ func (i *uint64ColumnIndexer) ColumnIndex() format.ColumnIndex {
 	return i.columnIndex(
 		splitFixedLenByteArrays(unsafecast.Uint64ToBytes(i.minValues), 8),
 		splitFixedLenByteArrays(unsafecast.Uint64ToBytes(i.maxValues), 8),
-		bits.OrderOfUint64(i.minValues),
-		bits.OrderOfUint64(i.maxValues),
+		orderOfUint64(i.minValues),
+		orderOfUint64(i.maxValues),
 	)
 }
 
@@ -664,8 +663,8 @@ func (i *be128ColumnIndexer) ColumnIndex() format.ColumnIndex {
 	return i.columnIndex(
 		minValues,
 		maxValues,
-		bits.OrderOfBytes(minValues),
-		bits.OrderOfBytes(maxValues),
+		orderOfBytes(minValues),
+		orderOfBytes(maxValues),
 	)
 }
 
diff --git a/config.go b/config.go
index 079ec9a..6cdddb0 100644
--- a/config.go
+++ b/config.go
@@ -10,8 +10,8 @@ import (
 const (
 	DefaultCreatedBy            = "github.com/segmentio/parquet-go"
 	DefaultColumnIndexSizeLimit = 16
-	DefaultColumnBufferSize     = 1 * 1024 * 1024
-	DefaultPageBufferSize       = 1 * 1024 * 1024
+	DefaultColumnBufferCapacity = 16 * 1024
+	DefaultPageBufferSize       = 256 * 1024
 	DefaultWriteBufferSize      = 32 * 1024
 	DefaultDataPageVersion      = 2
 	DefaultDataPageStatistics   = false
@@ -223,20 +223,20 @@ func (c *WriterConfig) Validate() error {
 // directly as argument to the NewBuffer function when needed, for example:
 //
 //	buffer := parquet.NewBuffer(&parquet.RowGroupConfig{
-//		ColumnBufferSize: 8 * 1024 * 1024,
+//		ColumnBufferCapacity: 10_000,
 //	})
 //
 type RowGroupConfig struct {
-	ColumnBufferSize int
-	SortingColumns   []SortingColumn
-	Schema           *Schema
+	ColumnBufferCapacity int
+	SortingColumns       []SortingColumn
+	Schema               *Schema
 }
 
 // DefaultRowGroupConfig returns a new RowGroupConfig value initialized with the
 // default row group configuration.
 func DefaultRowGroupConfig() *RowGroupConfig {
 	return &RowGroupConfig{
-		ColumnBufferSize: DefaultColumnBufferSize,
+		ColumnBufferCapacity: DefaultColumnBufferCapacity,
 	}
 }
 
@@ -255,7 +255,7 @@ func NewRowGroupConfig(options ...RowGroupOption) (*RowGroupConfig, error) {
 func (c *RowGroupConfig) Validate() error {
 	const baseName = "parquet.(*RowGroupConfig)."
 	return errorInvalidConfiguration(
-		validatePositiveInt(baseName+"ColumnBufferSize", c.ColumnBufferSize),
+		validatePositiveInt(baseName+"ColumnBufferCapacity", c.ColumnBufferCapacity),
 	)
 }
 
@@ -267,9 +267,9 @@ func (c *RowGroupConfig) Apply(options ...RowGroupOption) {
 
 func (c *RowGroupConfig) ConfigureRowGroup(config *RowGroupConfig) {
 	*config = RowGroupConfig{
-		ColumnBufferSize: coalesceInt(c.ColumnBufferSize, config.ColumnBufferSize),
-		SortingColumns:   coalesceSortingColumns(c.SortingColumns, config.SortingColumns),
-		Schema:           coalesceSchema(c.Schema, config.Schema),
+		ColumnBufferCapacity: coalesceInt(c.ColumnBufferCapacity, config.ColumnBufferCapacity),
+		SortingColumns:       coalesceSortingColumns(c.SortingColumns, config.SortingColumns),
+		Schema:               coalesceSchema(c.Schema, config.Schema),
 	}
 }
 
@@ -325,7 +325,7 @@ func SkipBloomFilters(skip bool) FileOption {
 // read and write pages rather than controlling the space used by the encoded
 // representation on disk.
 //
-// Defaults to 1 MiB.
+// Defaults to 256KiB.
 func PageBufferSize(size int) WriterOption {
 	return writerOption(func(config *WriterConfig) { config.PageBufferSize = size })
 }
@@ -335,7 +335,7 @@ func PageBufferSize(size int) WriterOption {
 // Setting the writer buffer size to zero deactivates buffering, all writes are
 // immediately sent to the output io.Writer.
 //
-// Defaults to 32 KiB.
+// Defaults to 32KiB.
 func WriteBufferSize(size int) WriterOption {
 	return writerOption(func(config *WriterConfig) { config.WriteBufferSize = size })
 }
@@ -425,12 +425,12 @@ func Compression(codec compress.Codec) WriterOption {
 	return writerOption(func(config *WriterConfig) { config.Compression = codec })
 }
 
-// ColumnBufferSize creates a configuration option which defines the size of
+// ColumnBufferCapacity creates a configuration option which defines the size of
 // row group column buffers.
 //
-// Defaults to 1 MiB.
-func ColumnBufferSize(size int) RowGroupOption {
-	return rowGroupOption(func(config *RowGroupConfig) { config.ColumnBufferSize = size })
+// Defaults to 16384.
+func ColumnBufferCapacity(size int) RowGroupOption {
+	return rowGroupOption(func(config *RowGroupConfig) { config.ColumnBufferCapacity = size })
 }
 
 // SortingColumns creates a configuration option which defines the sorting order
diff --git a/dictionary.go b/dictionary.go
index 3404f8d..4bd0ec8 100644
--- a/dictionary.go
+++ b/dictionary.go
@@ -2,13 +2,12 @@ package parquet
 
 import (
 	"io"
-	. "math/bits"
+	"math/bits"
 	"unsafe"
 
 	"github.com/segmentio/parquet-go/deprecated"
 	"github.com/segmentio/parquet-go/encoding"
 	"github.com/segmentio/parquet-go/encoding/plain"
-	"github.com/segmentio/parquet-go/internal/bits"
 	"github.com/segmentio/parquet-go/internal/unsafecast"
 )
 
@@ -95,17 +94,17 @@ func newBooleanDictionary(typ Type, columnIndex int16, numValues int32, values [
 	for i := int32(0); i < numValues && indexOfFalse < 0 && indexOfTrue < 0; i += 8 {
 		v := values[i]
 		if v != 0x00 {
-			indexOfTrue = i + int32(TrailingZeros8(v))
+			indexOfTrue = i + int32(bits.TrailingZeros8(v))
 		}
 		if v != 0xFF {
-			indexOfFalse = i + int32(TrailingZeros8(^v))
+			indexOfFalse = i + int32(bits.TrailingZeros8(^v))
 		}
 	}
 
 	return &booleanDictionary{
 		booleanPage: booleanPage{
 			typ:         typ,
-			bits:        values[:bits.ByteCount(uint(numValues))],
+			bits:        values[:byteCount(uint(numValues))],
 			numValues:   numValues,
 			columnIndex: ^columnIndex,
 		},
@@ -1079,8 +1078,8 @@ func newIndexedType(typ Type, dict Dictionary) *indexedType {
 	return &indexedType{Type: typ, dict: dict}
 }
 
-func (t *indexedType) NewColumnBuffer(columnIndex, bufferSize int) ColumnBuffer {
-	return newIndexedColumnBuffer(t, makeColumnIndex(columnIndex), bufferSize)
+func (t *indexedType) NewColumnBuffer(columnIndex, numValues int) ColumnBuffer {
+	return newIndexedColumnBuffer(t, makeColumnIndex(columnIndex), makeNumValues(numValues))
 }
 
 func (t *indexedType) NewPage(columnIndex, numValues int, data []byte) Page {
@@ -1215,11 +1214,11 @@ func (r *indexedPageValues) ReadValues(values []Value) (n int, err error) {
 // builds a page of indexes into a parent dictionary when values are written.
 type indexedColumnBuffer struct{ indexedPage }
 
-func newIndexedColumnBuffer(typ *indexedType, columnIndex int16, bufferSize int) *indexedColumnBuffer {
+func newIndexedColumnBuffer(typ *indexedType, columnIndex int16, numValues int32) *indexedColumnBuffer {
 	return &indexedColumnBuffer{
 		indexedPage: indexedPage{
 			typ:         typ,
-			values:      make([]int32, 0, bufferSize/4),
+			values:      make([]int32, 0, numValues),
 			columnIndex: ^columnIndex,
 		},
 	}
diff --git a/encoding/bitpacked/bitpacked.go b/encoding/bitpacked/bitpacked.go
index b8d2a54..4a3ff47 100644
--- a/encoding/bitpacked/bitpacked.go
+++ b/encoding/bitpacked/bitpacked.go
@@ -3,7 +3,6 @@ package bitpacked
 import (
 	"github.com/segmentio/parquet-go/encoding"
 	"github.com/segmentio/parquet-go/format"
-	"github.com/segmentio/parquet-go/internal/bits"
 )
 
 type Encoding struct {
@@ -41,7 +40,7 @@ func encodeLevels(dst, src []byte, bitWidth uint) ([]byte, error) {
 		return append(dst[:0], 0), nil
 	}
 
-	n := bits.ByteCount(bitWidth) * len(src)
+	n := ((int(bitWidth) * len(src)) + 7) / 8
 	c := n + 1
 
 	if cap(dst) < c {
@@ -74,7 +73,7 @@ func decodeLevels(dst, src []byte, bitWidth uint) ([]byte, error) {
 		return append(dst[:0], 0), nil
 	}
 
-	numBits := bits.BitCount(len(src))
+	numBits := 8 * uint(len(src))
 	numValues := int(numBits / bitWidth)
 	if (numBits % bitWidth) != 0 {
 		numValues++
diff --git a/encoding/delta/binary_packed.go b/encoding/delta/binary_packed.go
index f9b5f20..16217cf 100644
--- a/encoding/delta/binary_packed.go
+++ b/encoding/delta/binary_packed.go
@@ -5,12 +5,10 @@ import (
 	"fmt"
 	"io"
 	"math"
+	"math/bits"
 
 	"github.com/segmentio/parquet-go/encoding"
 	"github.com/segmentio/parquet-go/format"
-	"github.com/segmentio/parquet-go/internal/bits"
-
-	. "math/bits"
 )
 
 const (
@@ -180,7 +178,7 @@ func blockBitWidthsInt32(bitWidths *[numMiniBlocks]byte, block *[blockSize]int32
 		bitWidth := 0
 
 		for _, v := range block[j:k] {
-			if n := Len32(uint32(v)); n > bitWidth {
+			if n := bits.Len32(uint32(v)); n > bitWidth {
 				bitWidth = n
 			}
 		}
@@ -228,7 +226,7 @@ func blockBitWidthsInt64(bitWidths *[numMiniBlocks]byte, block *[blockSize]int64
 		bitWidth := 0
 
 		for _, v := range block[j:k] {
-			if n := Len64(uint64(v)); n > bitWidth {
+			if n := bits.Len64(uint64(v)); n > bitWidth {
 				bitWidth = n
 			}
 		}
@@ -391,7 +389,10 @@ func (e *BinaryPackedEncoding) decode(src []byte, observe func(int64)) ([]byte,
 			}
 		}
 
-		bits.AddInt64(block, minDelta)
+		for i := range block {
+			block[i] += minDelta
+		}
+
 		block[0] += lastValue
 		for i := 1; i < len(block); i++ {
 			block[i] += block[i-1]
diff --git a/encoding/rle/dictionary.go b/encoding/rle/dictionary.go
index e98e730..3b0dcdb 100644
--- a/encoding/rle/dictionary.go
+++ b/encoding/rle/dictionary.go
@@ -1,9 +1,10 @@
 package rle
 
 import (
+	"math/bits"
+
 	"github.com/segmentio/parquet-go/encoding"
 	"github.com/segmentio/parquet-go/format"
-	"github.com/segmentio/parquet-go/internal/bits"
 	"github.com/segmentio/parquet-go/internal/unsafecast"
 )
 
@@ -24,7 +25,7 @@ func (e *DictionaryEncoding) EncodeInt32(dst, src []byte) ([]byte, error) {
 		return dst[:0], encoding.ErrEncodeInvalidInputSize(e, "INT32", len(src))
 	}
 	src32 := unsafecast.BytesToInt32(src)
-	bitWidth := bits.MaxLen32(src32)
+	bitWidth := maxLenInt32(src32)
 	dst = append(dst[:0], byte(bitWidth))
 	dst, err := encodeInt32(dst, src32, uint(bitWidth))
 	return dst, e.wrap(err)
@@ -50,3 +51,12 @@ func clearInt32(data []int32) {
 		data[i] = 0
 	}
 }
+
+func maxLenInt32(data []int32) (max int) {
+	for _, v := range data {
+		if n := bits.Len32(uint32(v)); n > max {
+			max = n
+		}
+	}
+	return max
+}
diff --git a/encoding/rle/rle.go b/encoding/rle/rle.go
index 6e2fc79..dd5007e 100644
--- a/encoding/rle/rle.go
+++ b/encoding/rle/rle.go
@@ -6,7 +6,6 @@
 package rle
 
 import (
-	"bytes"
 	"encoding/binary"
 	"fmt"
 	"io"
@@ -14,7 +13,7 @@ import (
 
 	"github.com/segmentio/parquet-go/encoding"
 	"github.com/segmentio/parquet-go/format"
-	"github.com/segmentio/parquet-go/internal/bits"
+	"github.com/segmentio/parquet-go/internal/bytealg"
 	"github.com/segmentio/parquet-go/internal/unsafecast"
 )
 
@@ -287,7 +286,7 @@ func decodeBytes(dst, src []byte, bitWidth uint) ([]byte, error) {
 	}
 
 	bitMask := uint64(1<<bitWidth) - 1
-	byteCount := bits.ByteCount(8 * bitWidth)
+	byteCount := byteCount(8 * bitWidth)
 
 	for i := 0; i < len(src); {
 		u, n := binary.Uvarint(src[i:])
@@ -355,8 +354,8 @@ func decodeInt32(dst, src []byte, bitWidth uint) ([]byte, error) {
 	}
 
 	bitMask := uint64(1<<bitWidth) - 1
-	byteCount1 := bits.ByteCount(1 * bitWidth)
-	byteCount8 := bits.ByteCount(8 * bitWidth)
+	byteCount1 := byteCount(1 * bitWidth)
+	byteCount8 := byteCount(8 * bitWidth)
 
 	for i := 0; i < len(src); {
 		u, n := binary.Uvarint(src[i:])
@@ -488,16 +487,16 @@ func broadcast8x4(v int32) [8]int32 {
 	return [8]int32{v, v, v, v, v, v, v, v}
 }
 
-func count(data []byte, value byte) int {
-	return bytes.Count(data, []byte{value})
+func byteCount(numBits uint) int {
+	return int((numBits + 7) / 8)
 }
 
 func isZero(data []byte) bool {
-	return count(data, 0x00) == len(data)
+	return bytealg.Count(data, 0x00) == len(data)
 }
 
 func isOnes(data []byte) bool {
-	return count(data, 0xFF) == len(data)
+	return bytealg.Count(data, 0xFF) == len(data)
 }
 
 func resize(buf []byte, size int) []byte {
diff --git a/internal/bits/add.go b/internal/bits/add.go
deleted file mode 100644
index d439c79..0000000
--- a/internal/bits/add.go
+++ /dev/null
@@ -1,5 +0,0 @@
-package bits
-
-func AddInt32(data []int32, value int32) { addInt32(data, value) }
-
-func AddInt64(data []int64, value int64) { addInt64(data, value) }
diff --git a/internal/bits/add_amd64.go b/internal/bits/add_amd64.go
deleted file mode 100644
index 8a55b36..0000000
--- a/internal/bits/add_amd64.go
+++ /dev/null
@@ -1,9 +0,0 @@
-//go:build !purego
-
-package bits
-
-//go:noescape
-func addInt32(data []int32, value int32)
-
-//go:noescape
-func addInt64(data []int64, value int64)
diff --git a/internal/bits/add_amd64.s b/internal/bits/add_amd64.s
deleted file mode 100644
index e7fac62..0000000
--- a/internal/bits/add_amd64.s
+++ /dev/null
@@ -1,79 +0,0 @@
-//go:build !purego
-
-#include "textflag.h"
-
-// func addInt32(data []int32, value int32)
-TEXT ·addInt32(SB), NOSPLIT, $-32
-    MOVQ data_base+0(FP), AX
-    MOVQ data_len+8(FP), CX
-    MOVL value+24(FP), BX
-    XORQ SI, SI
-
-    CMPQ CX, $0
-    JE done
-
-    CMPB ·hasAVX512(SB), $0
-    JE loop
-
-    CMPQ CX, $16
-    JB loop
-
-    MOVQ CX, DX
-    SHRQ $4, DX
-    SHLQ $4, DX
-
-    VPBROADCASTD BX, Z0
-loop16:
-    VPADDD (AX)(SI*4), Z0, Z1
-    VMOVDQU32 Z1, (AX)(SI*4)
-    ADDQ $16, SI
-    CMPQ SI, DX
-    JNE loop16
-
-    CMPQ SI, CX
-    JE done
-loop:
-    ADDL BX, (AX)(SI*4)
-    INCQ SI
-    CMPQ SI, CX
-    JNE loop
-done:
-    RET
-
-// func addInt64(data []int64, value int64)
-TEXT ·addInt64(SB), NOSPLIT, $-32
-    MOVQ data_base+0(FP), AX
-    MOVQ data_len+8(FP), CX
-    MOVQ value+24(FP), BX
-    XORQ SI, SI
-
-    CMPQ CX, $0
-    JE done
-
-    CMPB ·hasAVX512(SB), $0
-    JE loop
-
-    CMPQ CX, $8
-    JB loop
-
-    MOVQ CX, DX
-    SHRQ $3, DX
-    SHLQ $3, DX
-
-    VPBROADCASTQ BX, Z0
-loop8:
-    VPADDQ (AX)(SI*8), Z0, Z1
-    VMOVDQU64 Z1, (AX)(SI*8)
-    ADDQ $8, SI
-    CMPQ SI, DX
-    JNE loop8
-
-    CMPQ SI, CX
-    JE done
-loop:
-    ADDQ BX, (AX)(SI*8)
-    INCQ SI
-    CMPQ SI, CX
-    JNE loop
-done:
-    RET
diff --git a/internal/bits/add_default.go b/internal/bits/add_default.go
deleted file mode 100644
index c5850ef..0000000
--- a/internal/bits/add_default.go
+++ /dev/null
@@ -1,15 +0,0 @@
-//go:build purego || !amd64
-
-package bits
-
-func addInt32(data []int32, value int32) {
-	for i := range data {
-		data[i] += value
-	}
-}
-
-func addInt64(data []int64, value int64) {
-	for i := range data {
-		data[i] += value
-	}
-}
diff --git a/internal/bits/bits.go b/internal/bits/bits.go
deleted file mode 100644
index a1182b5..0000000
--- a/internal/bits/bits.go
+++ /dev/null
@@ -1,63 +0,0 @@
-package bits
-
-import (
-	"math/bits"
-)
-
-type uint128 = [16]byte
-
-func BitCount(count int) uint {
-	return 8 * uint(count)
-}
-
-func ByteCount(count uint) int {
-	return int((count + 7) / 8)
-}
-
-func Round(count uint) uint {
-	return BitCount(ByteCount(count))
-}
-
-func NearestPowerOfTwo(v int) int {
-	return int(NearestPowerOfTwo64(uint64(v)))
-}
-
-func NearestPowerOfTwo32(v uint32) uint32 {
-	return 1 << uint(bits.Len32(v-1))
-}
-
-func NearestPowerOfTwo64(v uint64) uint64 {
-	return 1 << uint(bits.Len64(v-1))
-}
-
-func Len8(i int8) int {
-	return bits.Len8(uint8(i))
-}
-
-func Len16(i int16) int {
-	return bits.Len16(uint16(i))
-}
-
-func Len32(i int32) int {
-	return bits.Len32(uint32(i))
-}
-
-func Len64(i int64) int {
-	return bits.Len64(uint64(i))
-}
-
-func IndexShift8(bitIndex uint) (index, shift uint) {
-	return bitIndex / 8, bitIndex % 8
-}
-
-func IndexShift16(bitIndex uint) (index, shift uint) {
-	return bitIndex / 16, bitIndex % 16
-}
-
-func IndexShift32(bitIndex uint) (index, shift uint) {
-	return bitIndex / 32, bitIndex % 32
-}
-
-func IndexShift64(bitIndex uint) (index, shift uint) {
-	return bitIndex / 64, bitIndex % 64
-}
diff --git a/internal/bits/bits_amd64.go b/internal/bits/bits_amd64.go
deleted file mode 100644
index 22f0979..0000000
--- a/internal/bits/bits_amd64.go
+++ /dev/null
@@ -1,51 +0,0 @@
-package bits
-
-import (
-	"golang.org/x/sys/cpu"
-)
-
-// All functions require at least the F and VL instruction sets (Foundation and
-// Vector Length extensions). Note that Foundation is a requirement of AVX-512,
-// and usually includes Vector Length extensions as well, so we could simply be
-// testing HasAVX512 but we leave the other checks to be more explicit about our
-// intentions, and maybe be more prepared for unconventional implementations.
-var hasAVX512 = cpu.X86.HasAVX512 &&
-	cpu.X86.HasAVX512F &&
-	cpu.X86.HasAVX512VL
-
-// For min/max functions over big-endian 128 bits values, we need the follwing
-// instructions from the DQ set:
-// * VPBROADCASTQ (with 64 bits source register)
-// * VBROADCASTI64X2
-var hasAVX512MinMaxBE128 = hasAVX512 &&
-	cpu.X86.HasAVX512DQ
-
-// For min/max functions over boolean values, we need the following instructions
-// from the VPOPCNTDQ set:
-// * VPOPCNTDQ
-var hasAVX512MinMaxBool = hasAVX512 &&
-	cpu.X86.HasAVX512VPOPCNTDQ
-
-// These use AVX-512 instructions in the minBool algorithm relies operations
-// that are avilable in the AVX512BW extension:
-// * VPCMPUB
-// * KMOVQ
-var hasAVX512MinBool = hasAVX512 &&
-	cpu.X86.HasAVX512BW
-
-// These use AVX-512 instructions in the maxBool algorithm relies operations
-// that are avilable in the AVX512BW extension:
-// * VPCMPUB
-// * KMOVQ
-var hasAVX512MaxBool = hasAVX512 &&
-	cpu.X86.HasAVX512BW
-
-// These use AVX-512 instructions in the countByte algorithm relies operations
-// that are avilable in the AVX512BW extension:
-// * VPCMPUB
-// * KMOVQ
-//
-// Note that the function will fallback to an AVX2 version if those instructions
-// are not available.
-var hasAVX512CountByte = hasAVX512 &&
-	cpu.X86.HasAVX512BW
diff --git a/internal/bits/bits_amd64.s b/internal/bits/bits_amd64.s
deleted file mode 100644
index fd33784..0000000
--- a/internal/bits/bits_amd64.s
+++ /dev/null
@@ -1,54 +0,0 @@
-//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/bits_go18.go b/internal/bits/bits_go18.go
deleted file mode 100644
index e0d9baf..0000000
--- a/internal/bits/bits_go18.go
+++ /dev/null
@@ -1,7 +0,0 @@
-//go:build go1.18
-
-package bits
-
-type ordered interface {
-	int8 | int16 | int32 | int64 | uint8 | uint16 | uint32 | uint64 | float32 | float64
-}
diff --git a/internal/bits/count.go b/internal/bits/count.go
deleted file mode 100644
index b93057b..0000000
--- a/internal/bits/count.go
+++ /dev/null
@@ -1,3 +0,0 @@
-package bits
-
-func CountByte(data []byte, value byte) int { return countByte(data, value) }
diff --git a/internal/bits/max.go b/internal/bits/max.go
deleted file mode 100644
index da4b4cd..0000000
--- a/internal/bits/max.go
+++ /dev/null
@@ -1,42 +0,0 @@
-package bits
-
-import "bytes"
-
-func MaxBool(data []bool) (max bool) { return maxBool(data) }
-
-func MaxInt32(data []int32) (max int32) { return maxInt32(data) }
-
-func MaxInt64(data []int64) (max int64) { return maxInt64(data) }
-
-func MaxUint32(data []uint32) (max uint32) { return maxUint32(data) }
-
-func MaxUint64(data []uint64) (max uint64) { return maxUint64(data) }
-
-func MaxFloat32(data []float32) (max float32) { return maxFloat32(data) }
-
-func MaxFloat64(data []float64) (max float64) { return maxFloat64(data) }
-
-func MaxFixedLenByteArray(size int, data []byte) (max []byte) {
-	if size == 16 {
-		return maxBE128(data)
-	}
-	return maxFixedLenByteArray(size, data)
-}
-
-func maxFixedLenByteArray(size int, data []byte) (max []byte) {
-	if len(data) > 0 {
-		max = data[:size]
-
-		for i, j := size, 2*size; j <= len(data); {
-			item := data[i:j]
-
-			if bytes.Compare(item, max) > 0 {
-				max = item
-			}
-
-			i += size
-			j += size
-		}
-	}
-	return max
-}
diff --git a/internal/bits/max_amd64.h b/internal/bits/max_amd64.h
deleted file mode 100644
index db00dca..0000000
--- a/internal/bits/max_amd64.h
+++ /dev/null
@@ -1,53 +0,0 @@
-#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 \
-    KMOVB K1, R1 \
-    KMOVB K2, R2 \
-    MOVB R2, R3 \
-    SHLB $1, R3 \
-    ANDB R3, R1 \
-    ORB R2, R1 \
-    ANDB M, R1 \
-    MOVB R1, R2 \
-    SHRB $1, R2 \
-    ORB R2, R1 \
-    KMOVB 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) MOVB $0b10101010, M
-
-#endif
diff --git a/internal/bits/max_go18.go b/internal/bits/max_go18.go
deleted file mode 100644
index c6257bb..0000000
--- a/internal/bits/max_go18.go
+++ /dev/null
@@ -1,61 +0,0 @@
-//go:build go1.18 && (purego || !amd64)
-
-package bits
-
-import (
-	"encoding/binary"
-
-	"github.com/segmentio/parquet-go/internal/unsafecast"
-)
-
-func maxBool(data []bool) bool {
-	return len(data) > 0 && !boolEqualAll(data, false)
-}
-
-func maxInt32(data []int32) int32 { return max(data) }
-
-func maxInt64(data []int64) int64 { return max(data) }
-
-func maxUint32(data []uint32) uint32 { return max(data) }
-
-func maxUint64(data []uint64) uint64 { return max(data) }
-
-func maxFloat32(data []float32) float32 { return max(data) }
-
-func maxFloat64(data []float64) float64 { return max(data) }
-
-func max[T ordered](data []T) (max T) {
-	if len(data) > 0 {
-		max = data[0]
-
-		for _, value := range data[1:len(data):len(data)] {
-			if value > max {
-				max = value
-			}
-		}
-	}
-	return max
-}
-
-func maxBE128(data []byte) (min []byte) {
-	if len(data) > 0 {
-		be128 := unsafecast.Slice[uint128](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/maxlen.go b/internal/bits/maxlen.go
deleted file mode 100644
index fdf883b..0000000
--- a/internal/bits/maxlen.go
+++ /dev/null
@@ -1,41 +0,0 @@
-package bits
-
-func MaxLen8(data []int8) int {
-	max := 0
-	for _, v := range data {
-		if n := Len8(v); n > max {
-			max = n
-		}
-	}
-	return max
-}
-
-func MaxLen16(data []int16) int {
-	max := 0
-	for _, v := range data {
-		if n := Len16(v); n > max {
-			max = n
-		}
-	}
-	return max
-}
-
-func MaxLen32(data []int32) int {
-	max := 0
-	for _, v := range data {
-		if n := Len32(v); n > max {
-			max = n
-		}
-	}
-	return max
-}
-
-func MaxLen64(data []int64) int {
-	max := 0
-	for _, v := range data {
-		if n := Len64(v); n > max {
-			max = n
-		}
-	}
-	return max
-}
diff --git a/internal/bits/min.go b/internal/bits/min.go
deleted file mode 100644
index fa97d9d..0000000
--- a/internal/bits/min.go
+++ /dev/null
@@ -1,44 +0,0 @@
-package bits
-
-import (
-	"bytes"
-)
-
-func MinBool(data []bool) (min bool) { return minBool(data) }
-
-func MinInt32(data []int32) (min int32) { return minInt32(data) }
-
-func MinInt64(data []int64) (min int64) { return minInt64(data) }
-
-func MinUint32(data []uint32) (min uint32) { return minUint32(data) }
-
-func MinUint64(data []uint64) (min uint64) { return minUint64(data) }
-
-func MinFloat32(data []float32) (min float32) { return minFloat32(data) }
-
-func MinFloat64(data []float64) (min float64) { return minFloat64(data) }
-
-func MinFixedLenByteArray(size int, data []byte) (min []byte) {
-	if size == 16 {
-		return minBE128(data)
-	}
-	return minFixedLenByteArray(size, data)
-}
-
-func minFixedLenByteArray(size int, data []byte) (min []byte) {
-	if len(data) > 0 {
-		min = data[:size]
-
-		for i, j := size, 2*size; j <= len(data); {
-			item := data[i:j]
-
-			if bytes.Compare(item, min) < 0 {
-				min = item
-			}
-
-			i += size
-			j += size
-		}
-	}
-	return min
-}
diff --git a/internal/bits/min_amd64.h b/internal/bits/min_amd64.h
deleted file mode 100644
index 65659cd..0000000
--- a/internal/bits/min_amd64.h
+++ /dev/null
@@ -1,53 +0,0 @@
-#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 \
-    KMOVB K1, R1 \
-    KMOVB K2, R2 \
-    MOVB R2, R3 \
-    SHLB $1, R3 \
-    ANDB R3, R1 \
-    ORB R2, R1 \
-    ANDB M, R1 \
-    MOVB R1, R2 \
-    SHRB $1, R2 \
-    ORB R2, R1 \
-    KMOVB 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) MOVB $0b10101010, M
-
-#endif
diff --git a/internal/bits/min_go18.go b/internal/bits/min_go18.go
deleted file mode 100644
index 1b8c454..0000000
--- a/internal/bits/min_go18.go
+++ /dev/null
@@ -1,59 +0,0 @@
-//go:build go1.18 && (purego || !amd64)
-
-package bits
-
-import (
-	"encoding/binary"
-
-	"github.com/segmentio/parquet-go/internal/unsafecast"
-)
-
-func minBool(data []bool) bool { return boolEqualAll(data, true) }
-
-func minInt32(data []int32) int32 { return min(data) }
-
-func minInt64(data []int64) int64 { return min(data) }
-
-func minUint32(data []uint32) uint32 { return min(data) }
-
-func minUint64(data []uint64) uint64 { return min(data) }
-
-func minFloat32(data []float32) float32 { return min(data) }
-
-func minFloat64(data []float64) float64 { return min(data) }
-
-func min[T ordered](data []T) (min T) {
-	if len(data) > 0 {
-		min = data[0]
-
-		for _, value := range data[1:len(data):len(data)] {
-			if value < min {
-				min = value
-			}
-		}
-	}
-	return min
-}
-
-func minBE128(data []byte) (min []byte) {
-	if len(data) > 0 {
-		be128 := unsafecast.Slice[uint128](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
deleted file mode 100644
index a9cfcbe..0000000
--- a/internal/bits/minmax.go
+++ /dev/null
@@ -1,48 +0,0 @@
-package bits
-
-import (
-	"bytes"
-)
-
-func MinMaxBool(data []bool) (min, max bool) { return minMaxBool(data) }
-
-func MinMaxInt32(data []int32) (min, max int32) { return minMaxInt32(data) }
-
-func MinMaxInt64(data []int64) (min, max int64) { return minMaxInt64(data) }
-
-func MinMaxUint32(data []uint32) (min, max uint32) { return minMaxUint32(data) }
-
-func MinMaxUint64(data []uint64) (min, max uint64) { return minMaxUint64(data) }
-
-func MinMaxFloat32(data []float32) (min, max float32) { return minMaxFloat32(data) }
-
-func MinMaxFloat64(data []float64) (min, max float64) { return minMaxFloat64(data) }
-
-func MinMaxFixedLenByteArray(size int, data []byte) (min, max []byte) {
-	if size == 16 {
-		return minMaxBE128(data)
-	}
-	return minMaxFixedLenByteArray(size, data)
-}
-
-func minMaxFixedLenByteArray(size int, data []byte) (min, max []byte) {
-	if len(data) > 0 {
-		min = data[:size]
-		max = data[:size]
-
-		for i, j := size, 2*size; j <= len(data); {
-			item := data[i:j]
-
-			if bytes.Compare(item, min) < 0 {
-				min = item
-			}
-			if bytes.Compare(item, max) > 0 {
-				max = item
-			}
-
-			i += size
-			j += size
-		}
-	}
-	return min, max
-}
diff --git a/internal/bits/minmax_go18.go b/internal/bits/minmax_go18.go
deleted file mode 100644
index 79a43a1..0000000
--- a/internal/bits/minmax_go18.go
+++ /dev/null
@@ -1,96 +0,0 @@
-//go:build go1.18 && (purego || !amd64)
-
-package bits
-
-import (
-	"encoding/binary"
-
-	"github.com/segmentio/parquet-go/internal/unsafecast"
-)
-
-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) { return minmax(data) }
-
-func minMaxInt64(data []int64) (min, max int64) { return minmax(data) }
-
-func minMaxUint32(data []uint32) (min, max uint32) { return minmax(data) }
-
-func minMaxUint64(data []uint64) (min, max uint64) { return minmax(data) }
-
-func minMaxFloat32(data []float32) (min, max float32) { return minmax(data) }
-
-func minMaxFloat64(data []float64) (min, max float64) { return minmax(data) }
-
-func minmax[T ordered](data []T) (min, max T) {
-	if len(data) > 0 {
-		min = data[0]
-		max = data[0]
-
-		for _, v := range data[1:len(data):len(data)] {
-			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 := unsafecast.Slice[uint128](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/order_go18.go b/internal/bits/order_go18.go
deleted file mode 100644
index e308fe7..0000000
--- a/internal/bits/order_go18.go
+++ /dev/null
@@ -1,43 +0,0 @@
-//go:build go1.18 && (purego || !amd64)
-
-package bits
-
-func orderOfInt32(data []int32) int { return orderOfValues(data) }
-
-func orderOfInt64(data []int64) int { return orderOfValues(data) }
-
-func orderOfUint32(data []uint32) int { return orderOfValues(data) }
-
-func orderOfUint64(data []uint64) int { return orderOfValues(data) }
-
-func orderOfFloat32(data []float32) int { return orderOfValues(data) }
-
-func orderOfFloat64(data []float64) int { return orderOfValues(data) }
-
-func orderOfValues[T ordered](data []T) int {
-	if valuesAreInAscendingOrder(data) {
-		return +1
-	}
-	if valuesAreInDescendingOrder(data) {
-		return -1
-	}
-	return 0
-}
-
-func valuesAreInAscendingOrder[T ordered](data []T) bool {
-	for i := len(data) - 1; i > 0; i-- {
-		if data[i-1] > data[i] {
-			return false
-		}
-	}
-	return true
-}
-
-func valuesAreInDescendingOrder[T ordered](data []T) bool {
-	for i := len(data) - 1; i > 0; i-- {
-		if data[i-1] < data[i] {
-			return false
-		}
-	}
-	return true
-}
diff --git a/internal/bytealg/broadcast_amd64.go b/internal/bytealg/broadcast_amd64.go
new file mode 100644
index 0000000..407f1cf
--- /dev/null
+++ b/internal/bytealg/broadcast_amd64.go
@@ -0,0 +1,17 @@
+//go:build !purego
+
+package bytealg
+
+//go:noescape
+func broadcastAVX2(dst []byte, src byte)
+
+// Broadcast writes the src value to all bytes of dst.
+func Broadcast(dst []byte, src byte) {
+	if len(dst) >= 8 && hasAVX2 {
+		broadcastAVX2(dst, src)
+	} else {
+		for i := range dst {
+			dst[i] = src
+		}
+	}
+}
diff --git a/level_amd64.s b/internal/bytealg/broadcast_amd64.s
similarity index 89%
rename from level_amd64.s
rename to internal/bytealg/broadcast_amd64.s
index 872f49c..241a658 100644
--- a/level_amd64.s
+++ b/internal/bytealg/broadcast_amd64.s
@@ -2,8 +2,8 @@
 
 #include "textflag.h"
 
-// func memsetAVX2(dst []byte, src byte)
-TEXT ·memsetAVX2(SB), NOSPLIT, $0-25
+// func broadcastAVX2(dst []byte, src byte)
+TEXT ·broadcastAVX2(SB), NOSPLIT, $0-25
     MOVQ dst_base+0(FP), AX
     MOVQ dst_len+8(FP), BX
     MOVBQZX src+24(FP), CX
diff --git a/level_purego.go b/internal/bytealg/broadcast_purego.go
similarity index 56%
rename from level_purego.go
rename to internal/bytealg/broadcast_purego.go
index d72ff6c..fd858e0 100644
--- a/level_purego.go
+++ b/internal/bytealg/broadcast_purego.go
@@ -1,8 +1,8 @@
 //go:build purego || !amd64
 
-package parquet
+package bytealg
 
-func memset(dst []byte, src byte) {
+func Broadcast(dst []byte, src byte) {
 	for i := range dst {
 		dst[i] = src
 	}
diff --git a/internal/bytealg/bytealg.go b/internal/bytealg/bytealg.go
new file mode 100644
index 0000000..0698df2
--- /dev/null
+++ b/internal/bytealg/bytealg.go
@@ -0,0 +1,2 @@
+// Package bytealg contains optimized algorithms operating on byte slices.
+package bytealg
diff --git a/internal/bytealg/bytealg_amd64.go b/internal/bytealg/bytealg_amd64.go
new file mode 100644
index 0000000..cbbcf15
--- /dev/null
+++ b/internal/bytealg/bytealg_amd64.go
@@ -0,0 +1,17 @@
+//go:build !purego
+
+package bytealg
+
+import "golang.org/x/sys/cpu"
+
+var (
+	hasAVX2 = cpu.X86.HasAVX2
+	// These use AVX-512 instructions in the countByte algorithm relies
+	// operations that are avilable in the AVX512BW extension:
+	// * VPCMPUB
+	// * KMOVQ
+	//
+	// Note that the function will fallback to an AVX2 version if those
+	// instructions are not available.
+	hasAVX512Count = cpu.X86.HasAVX512VL && cpu.X86.HasAVX512BW
+)
diff --git a/internal/bits/count_amd64.go b/internal/bytealg/count_amd64.go
similarity index 94%
rename from internal/bits/count_amd64.go
rename to internal/bytealg/count_amd64.go
index b8cc9e0..c426356 100644
--- a/internal/bits/count_amd64.go
+++ b/internal/bytealg/count_amd64.go
@@ -1,6 +1,6 @@
 //go:build !purego
 
-package bits
+package bytealg
 
 // 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
@@ -25,4 +25,4 @@ package bits
 //
 //
 //go:noescape
-func countByte(data []byte, value byte) int
+func Count(data []byte, value byte) int
diff --git a/internal/bits/count_amd64.s b/internal/bytealg/count_amd64.s
similarity index 87%
rename from internal/bits/count_amd64.s
rename to internal/bytealg/count_amd64.s
index 0ce065f..fe27189 100644
--- a/internal/bits/count_amd64.s
+++ b/internal/bytealg/count_amd64.s
@@ -2,8 +2,8 @@
 
 #include "textflag.h"
 
-// func countByte(data []byte, value byte) int
-TEXT ·countByte(SB), NOSPLIT, $0-40
+// func Count(data []byte, value byte) int
+TEXT ·Count(SB), NOSPLIT, $0-40
     MOVQ data_base+0(FP), AX
     MOVQ data_len+8(FP), CX
     MOVQ value+24(FP), BX
@@ -11,23 +11,20 @@ TEXT ·countByte(SB), NOSPLIT, $0-40
     ADDQ AX, CX // end
     XORQ SI, SI // count
 
-    CMPQ DX, $0
-    JE done
-
     CMPQ DX, $256
-    JB loop
+    JB test
+
+    CMPB ·hasAVX2(SB), $0
+    JE test
 
     XORQ R12, R12
     XORQ R13, R13
     XORQ R14, R14
     XORQ R15, R15
 
-    CMPB ·hasAVX512CountByte(SB), $0
+    CMPB ·hasAVX512Count(SB), $0
     JE initAVX2
 
-    CMPQ DX, $256
-    JB initAVX2
-
     SHRQ $8, DX
     SHLQ $8, DX
     ADDQ AX, DX
@@ -61,6 +58,7 @@ loopAVX512:
     ADDQ R13, SI
     ADDQ R15, SI
     JMP doneAVX
+
 initAVX2:
     SHRQ $6, DX
     SHLQ $6, DX
@@ -82,10 +80,11 @@ loopAVX2:
     JNE loopAVX2
     ADDQ R14, SI
     ADDQ R15, SI
+
 doneAVX:
     VZEROUPPER
-    CMPQ AX, CX
-    JE done
+    JMP test
+
 loop:
     MOVQ SI, DI
     INCQ DI
@@ -93,6 +92,7 @@ loop:
     CMPB BX, R8
     CMOVQEQ DI, SI
     INCQ AX
+test:
     CMPQ AX, CX
     JNE loop
 done:
diff --git a/internal/bits/count_default.go b/internal/bytealg/count_purego.go
similarity index 60%
rename from internal/bits/count_default.go
rename to internal/bytealg/count_purego.go
index edf5782..de35ee4 100644
--- a/internal/bits/count_default.go
+++ b/internal/bytealg/count_purego.go
@@ -1,9 +1,9 @@
 //go:build purego || !amd64
 
-package bits
+package bytealg
 
 import "bytes"
 
-func countByte(data []byte, value byte) int {
+func Count(data []byte, value byte) int {
 	return bytes.Count(data, []byte{value})
 }
diff --git a/level.go b/level.go
index 911a946..6247504 100644
--- a/level.go
+++ b/level.go
@@ -1,9 +1,9 @@
 package parquet
 
-import "github.com/segmentio/parquet-go/internal/bits"
+import "github.com/segmentio/parquet-go/internal/bytealg"
 
 func countLevelsEqual(levels []byte, value byte) int {
-	return bits.CountByte(levels, value)
+	return bytealg.Count(levels, value)
 }
 
 func countLevelsNotEqual(levels []byte, value byte) int {
@@ -22,6 +22,6 @@ func appendLevel(levels []byte, value byte, count int) []byte {
 		levels = levels[:n]
 	}
 
-	memset(levels[i:], value)
+	bytealg.Broadcast(levels[i:], value)
 	return levels
 }
diff --git a/level_amd64.go b/level_amd64.go
deleted file mode 100644
index 695e1c7..0000000
--- a/level_amd64.go
+++ /dev/null
@@ -1,18 +0,0 @@
-//go:build !purego
-
-package parquet
-
-import "golang.org/x/sys/cpu"
-
-//go:noescape
-func memsetAVX2(dst []byte, src byte)
-
-func memset(dst []byte, src byte) {
-	if len(dst) >= minLenAVX2 && cpu.X86.HasAVX2 {
-		memsetAVX2(dst, src)
-	} else {
-		for i := range dst {
-			dst[i] = src
-		}
-	}
-}
diff --git a/null.go b/null.go
index 2dc0019..89090c3 100644
--- a/null.go
+++ b/null.go
@@ -3,11 +3,11 @@
 package parquet
 
 import (
-	"bytes"
 	"reflect"
 	"unsafe"
 
 	"github.com/segmentio/parquet-go/deprecated"
+	"github.com/segmentio/parquet-go/internal/bytealg"
 	"github.com/segmentio/parquet-go/internal/unsafecast"
 )
 
@@ -33,7 +33,7 @@ func nullIndex[T comparable](bits []uint64, rows array, size, offset uintptr) {
 }
 
 func nullIndexStruct(bits []uint64, rows array, size, offset uintptr) {
-	memset(unsafecast.Slice[byte](bits), 0xFF)
+	bytealg.Broadcast(unsafecast.Slice[byte](bits), 0xFF)
 }
 
 func nullIndexFuncOf(t reflect.Type) nullIndexFunc {
@@ -101,7 +101,7 @@ func nullIndexFuncOfByteArray(n int) nullIndexFunc {
 		for i := 0; i < rows.len; i++ {
 			p := (*byte)(rows.index(i, size, offset))
 			b := unsafe.Slice(p, n)
-			if bytes.Count(b, []byte{0}) != n {
+			if !isZero(b) {
 				x := uint(i) / 64
 				y := uint(i) % 64
 				bits[x] |= 1 << y
@@ -109,3 +109,12 @@ func nullIndexFuncOfByteArray(n int) nullIndexFunc {
 		}
 	}
 }
+
+func isZero(b []byte) bool {
+	for _, c := range b {
+		if c != 0 {
+			return false
+		}
+	}
+	return true
+}
diff --git a/internal/bits/order.go b/order.go
similarity index 65%
rename from internal/bits/order.go
rename to order.go
index 49e5a1c..05fb76a 100644
--- a/internal/bits/order.go
+++ b/order.go
@@ -1,4 +1,4 @@
-package bits
+package parquet
 
 import (
 	"bytes"
@@ -6,7 +6,7 @@ import (
 	"github.com/segmentio/parquet-go/internal/unsafecast"
 )
 
-func OrderOfBool(data []bool) int {
+func orderOfBool(data []bool) int {
 	switch len(data) {
 	case 0, 1:
 		return 0
@@ -49,61 +49,7 @@ func streakOfFalse(data []bool) int {
 	return len(data)
 }
 
-func OrderOfInt32(data []int32) int {
-	switch len(data) {
-	case 0, 1:
-		return 0
-	default:
-		return orderOfInt32(data)
-	}
-}
-
-func OrderOfInt64(data []int64) int {
-	switch len(data) {
-	case 0, 1:
-		return 0
-	default:
-		return orderOfInt64(data)
-	}
-}
-
-func OrderOfUint32(data []uint32) int {
-	switch len(data) {
-	case 0, 1:
-		return 0
-	default:
-		return orderOfUint32(data)
-	}
-}
-
-func OrderOfUint64(data []uint64) int {
-	switch len(data) {
-	case 0, 1:
-		return 0
-	default:
-		return orderOfUint64(data)
-	}
-}
-
-func OrderOfFloat32(data []float32) int {
-	switch len(data) {
-	case 0, 1:
-		return 0
-	default:
-		return orderOfFloat32(data)
-	}
-}
-
-func OrderOfFloat64(data []float64) int {
-	switch len(data) {
-	case 0, 1:
-		return 0
-	default:
-		return orderOfFloat64(data)
-	}
-}
-
-func OrderOfBytes(data [][]byte) int {
+func orderOfBytes(data [][]byte) int {
 	switch len(data) {
 	case 0, 1:
 		return 0
diff --git a/internal/bits/order_amd64.go b/order_amd64.go
similarity index 95%
rename from internal/bits/order_amd64.go
rename to order_amd64.go
index 4e5a10d..8ac597d 100644
--- a/internal/bits/order_amd64.go
+++ b/order_amd64.go
@@ -1,6 +1,6 @@
 //go:build !purego
 
-package bits
+package parquet
 
 //go:noescape
 func orderOfInt32(data []int32) int
diff --git a/internal/bits/order_amd64.s b/order_amd64.s
similarity index 96%
rename from internal/bits/order_amd64.s
rename to order_amd64.s
index 44d9a5c..651d251 100644
--- a/internal/bits/order_amd64.s
+++ b/order_amd64.s
@@ -41,7 +41,10 @@ TEXT ·orderOfInt32(SB), NOSPLIT, $-32
     XORQ SI, SI
     XORQ DI, DI
 
-    CMPB ·hasAVX512(SB), $0
+    CMPQ R9, $2
+    JB undefined
+
+    CMPB ·hasAVX512VL(SB), $0
     JE test
 
     CMPQ R9, $16
@@ -122,7 +125,10 @@ TEXT ·orderOfInt64(SB), NOSPLIT, $-32
     XORQ SI, SI
     XORQ DI, DI
 
-    CMPB ·hasAVX512(SB), $0
+    CMPQ R9, $2
+    JB undefined
+
+    CMPB ·hasAVX512VL(SB), $0
     JE test
 
     CMPQ R9, $8
@@ -203,7 +209,10 @@ TEXT ·orderOfUint32(SB), NOSPLIT, $-32
     XORQ SI, SI
     XORQ DI, DI
 
-    CMPB ·hasAVX512(SB), $0
+    CMPQ R9, $2
+    JB undefined
+
+    CMPB ·hasAVX512VL(SB), $0
     JE test
 
     CMPQ R9, $16
@@ -284,7 +293,10 @@ TEXT ·orderOfUint64(SB), NOSPLIT, $-32
     XORQ SI, SI
     XORQ DI, DI
 
-    CMPB ·hasAVX512(SB), $0
+    CMPQ R9, $2
+    JB undefined
+
+    CMPB ·hasAVX512VL(SB), $0
     JE test
 
     CMPQ R9, $8
@@ -365,7 +377,10 @@ TEXT ·orderOfFloat32(SB), NOSPLIT, $-32
     XORQ SI, SI
     XORQ DI, DI
 
-    CMPB ·hasAVX512(SB), $0
+    CMPQ R9, $2
+    JB undefined
+
+    CMPB ·hasAVX512VL(SB), $0
     JE test
 
     CMPQ R9, $16
@@ -450,7 +465,10 @@ TEXT ·orderOfFloat64(SB), NOSPLIT, $-32
     XORQ SI, SI
     XORQ DI, DI
 
-    CMPB ·hasAVX512(SB), $0
+    CMPQ R9, $2
+    JB undefined
+
+    CMPB ·hasAVX512VL(SB), $0
     JE test
 
     CMPQ R9, $8
diff --git a/internal/bits/order_default.go b/order_purego.go
similarity index 69%
rename from internal/bits/order_default.go
rename to order_purego.go
index efe0ac7..2011455 100644
--- a/internal/bits/order_default.go
+++ b/order_purego.go
@@ -1,31 +1,38 @@
-//go:build !go1.18 && (purego || !amd64)
+//go:build purego || !amd64
 
-package bits
+package parquet
 
-// generics please! :'(
+// -----------------------------------------------------------------------------
+// TODO: use generics versions of the these functions to reduce the amount of
+// code to maintain when we drop compatilibty with Go version older than 1.18.
+// -----------------------------------------------------------------------------
 
 func orderOfInt32(data []int32) int {
-	if int32AreInAscendingOrder(data) {
-		return +1
-	}
-	if int32AreInDescendingOrder(data) {
-		return -1
+	if len(data) > 1 {
+		if int32AreInAscendingOrder(data) {
+			return +1
+		}
+		if int32AreInDescendingOrder(data) {
+			return -1
+		}
 	}
 	return 0
 }
 
 func orderOfInt64(data []int64) int {
-	if int64AreInAscendingOrder(data) {
-		return +1
-	}
-	if int64AreInDescendingOrder(data) {
-		return -1
+	if len(data) > 1 {
+		if int64AreInAscendingOrder(data) {
+			return +1
+		}
+		if int64AreInDescendingOrder(data) {
+			return -1
+		}
 	}
 	return 0
 }
 
 func orderOfUint32(data []uint32) int {
-	if len(data) > 0 {
+	if len(data) > 1 {
 		if uint32AreInAscendingOrder(data) {
 			return +1
 		}
@@ -37,31 +44,37 @@ func orderOfUint32(data []uint32) int {
 }
 
 func orderOfUint64(data []uint64) int {
-	if uint64AreInAscendingOrder(data) {
-		return +1
-	}
-	if uint64AreInDescendingOrder(data) {
-		return -1
+	if len(data) > 1 {
+		if uint64AreInAscendingOrder(data) {
+			return +1
+		}
+		if uint64AreInDescendingOrder(data) {
+			return -1
+		}
 	}
 	return 0
 }
 
 func orderOfFloat32(data []float32) int {
-	if float32AreInAscendingOrder(data) {
-		return +1
-	}
-	if float32AreInDescendingOrder(data) {
-		return -1
+	if len(data) > 1 {
+		if float32AreInAscendingOrder(data) {
+			return +1
+		}
+		if float32AreInDescendingOrder(data) {
+			return -1
+		}
 	}
 	return 0
 }
 
 func orderOfFloat64(data []float64) int {
-	if float64AreInAscendingOrder(data) {
-		return +1
-	}
-	if float64AreInDescendingOrder(data) {
-		return -1
+	if len(data) > 1 {
+		if float64AreInAscendingOrder(data) {
+			return +1
+		}
+		if float64AreInDescendingOrder(data) {
+			return -1
+		}
 	}
 	return 0
 }
diff --git a/page.go b/page.go
index 1a1ca86..302a7e5 100644
--- a/page.go
+++ b/page.go
@@ -8,7 +8,6 @@ import (
 
 	"github.com/segmentio/parquet-go/deprecated"
 	"github.com/segmentio/parquet-go/encoding/plain"
-	"github.com/segmentio/parquet-go/internal/bits"
 	"github.com/segmentio/parquet-go/internal/unsafecast"
 )
 
@@ -467,7 +466,7 @@ type booleanPage struct {
 func newBooleanPage(typ Type, columnIndex int16, numValues int32, values []byte) *booleanPage {
 	return &booleanPage{
 		typ:         typ,
-		bits:        values[:bits.ByteCount(uint(numValues))],
+		bits:        values[:byteCount(uint(numValues))],
 		numValues:   numValues,
 		columnIndex: ^columnIndex,
 	}
@@ -621,11 +620,11 @@ func (page *int32Page) Values() ValueReader { return &int32PageValues{page: page
 
 func (page *int32Page) Buffer() BufferedPage { return page }
 
-func (page *int32Page) min() int32 { return bits.MinInt32(page.values) }
+func (page *int32Page) min() int32 { return minInt32(page.values) }
 
-func (page *int32Page) max() int32 { return bits.MaxInt32(page.values) }
+func (page *int32Page) max() int32 { return maxInt32(page.values) }
 
-func (page *int32Page) bounds() (min, max int32) { return bits.MinMaxInt32(page.values) }
+func (page *int32Page) bounds() (min, max int32) { return boundsInt32(page.values) }
 
 func (page *int32Page) Bounds() (min, max Value, ok bool) {
 	if ok = len(page.values) > 0; ok {
@@ -696,11 +695,11 @@ func (page *int64Page) Values() ValueReader { return &int64PageValues{page: page
 
 func (page *int64Page) Buffer() BufferedPage { return page }
 
-func (page *int64Page) min() int64 { return bits.MinInt64(page.values) }
+func (page *int64Page) min() int64 { return minInt64(page.values) }
 
-func (page *int64Page) max() int64 { return bits.MaxInt64(page.values) }
+func (page *int64Page) max() int64 { return maxInt64(page.values) }
 
-func (page *int64Page) bounds() (min, max int64) { return bits.MinMaxInt64(page.values) }
+func (page *int64Page) bounds() (min, max int64) { return boundsInt64(page.values) }
 
 func (page *int64Page) Bounds() (min, max Value, ok bool) {
 	if ok = len(page.values) > 0; ok {
@@ -848,11 +847,11 @@ func (page *floatPage) Values() ValueReader { return &floatPageValues{page: page
 
 func (page *floatPage) Buffer() BufferedPage { return page }
 
-func (page *floatPage) min() float32 { return bits.MinFloat32(page.values) }
+func (page *floatPage) min() float32 { return minFloat32(page.values) }
 
-func (page *floatPage) max() float32 { return bits.MaxFloat32(page.values) }
+func (page *floatPage) max() float32 { return maxFloat32(page.values) }
 
-func (page *floatPage) bounds() (min, max float32) { return bits.MinMaxFloat32(page.values) }
+func (page *floatPage) bounds() (min, max float32) { return boundsFloat32(page.values) }
 
 func (page *floatPage) Bounds() (min, max Value, ok bool) {
 	if ok = len(page.values) > 0; ok {
@@ -923,11 +922,11 @@ func (page *doublePage) Values() ValueReader { return &doublePageValues{page: pa
 
 func (page *doublePage) Buffer() BufferedPage { return page }
 
-func (page *doublePage) min() float64 { return bits.MinFloat64(page.values) }
+func (page *doublePage) min() float64 { return minFloat64(page.values) }
 
-func (page *doublePage) max() float64 { return bits.MaxFloat64(page.values) }
+func (page *doublePage) max() float64 { return maxFloat64(page.values) }
 
-func (page *doublePage) bounds() (min, max float64) { return bits.MinMaxFloat64(page.values) }
+func (page *doublePage) bounds() (min, max float64) { return boundsFloat64(page.values) }
 
 func (page *doublePage) Bounds() (min, max Value, ok bool) {
 	if ok = len(page.values) > 0; ok {
@@ -1176,16 +1175,12 @@ func (page *fixedLenByteArrayPage) Values() ValueReader {
 
 func (page *fixedLenByteArrayPage) Buffer() BufferedPage { return page }
 
-func (page *fixedLenByteArrayPage) min() []byte {
-	return bits.MinFixedLenByteArray(page.size, page.data)
-}
+func (page *fixedLenByteArrayPage) min() []byte { return minFixedLenByteArray(page.data, page.size) }
 
-func (page *fixedLenByteArrayPage) max() []byte {
-	return bits.MaxFixedLenByteArray(page.size, page.data)
-}
+func (page *fixedLenByteArrayPage) max() []byte { return maxFixedLenByteArray(page.data, page.size) }
 
 func (page *fixedLenByteArrayPage) bounds() (min, max []byte) {
-	return bits.MinMaxFixedLenByteArray(page.size, page.data)
+	return boundsFixedLenByteArray(page.data, page.size)
 }
 
 func (page *fixedLenByteArrayPage) Bounds() (min, max Value, ok bool) {
@@ -1265,11 +1260,11 @@ func (page *uint32Page) Values() ValueReader { return &uint32PageValues{page: pa
 
 func (page *uint32Page) Buffer() BufferedPage { return page }
 
-func (page *uint32Page) min() uint32 { return bits.MinUint32(page.values) }
+func (page *uint32Page) min() uint32 { return minUint32(page.values) }
 
-func (page *uint32Page) max() uint32 { return bits.MaxUint32(page.values) }
+func (page *uint32Page) max() uint32 { return maxUint32(page.values) }
 
-func (page *uint32Page) bounds() (min, max uint32) { return bits.MinMaxUint32(page.values) }
+func (page *uint32Page) bounds() (min, max uint32) { return boundsUint32(page.values) }
 
 func (page *uint32Page) Bounds() (min, max Value, ok bool) {
 	if ok = len(page.values) > 0; ok {
@@ -1340,11 +1335,11 @@ func (page *uint64Page) Values() ValueReader { return &uint64PageValues{page: pa
 
 func (page *uint64Page) Buffer() BufferedPage { return page }
 
-func (page *uint64Page) min() uint64 { return bits.MinUint64(page.values) }
+func (page *uint64Page) min() uint64 { return minUint64(page.values) }
 
-func (page *uint64Page) max() uint64 { return bits.MaxUint64(page.values) }
+func (page *uint64Page) max() uint64 { return maxUint64(page.values) }
 
-func (page *uint64Page) bounds() (min, max uint64) { return bits.MinMaxUint64(page.values) }
+func (page *uint64Page) bounds() (min, max uint64) { return boundsUint64(page.values) }
 
 func (page *uint64Page) Bounds() (min, max Value, ok bool) {
 	if ok = len(page.values) > 0; ok {
@@ -1421,17 +1416,11 @@ func (page *be128Page) Values() ValueReader { return &be128PageValues{page: page
 
 func (page *be128Page) Buffer() BufferedPage { return page }
 
-func (page *be128Page) min() []byte {
-	return bits.MinFixedLenByteArray(16, page.Data())
-}
+func (page *be128Page) min() []byte { return minBE128(page.values) }
 
-func (page *be128Page) max() []byte {
-	return bits.MaxFixedLenByteArray(16, page.Data())
-}
+func (page *be128Page) max() []byte { return maxBE128(page.values) }
 
-func (page *be128Page) bounds() (min, max []byte) {
-	return bits.MinMaxFixedLenByteArray(16, page.Data())
-}
+func (page *be128Page) bounds() (min, max []byte) { return boundsBE128(page.values) }
 
 func (page *be128Page) Bounds() (min, max Value, ok bool) {
 	if ok = len(page.values) > 0; ok {
diff --git a/page_bounds.go b/page_bounds.go
new file mode 100644
index 0000000..8278e25
--- /dev/null
+++ b/page_bounds.go
@@ -0,0 +1,25 @@
+package parquet
+
+import "bytes"
+
+func boundsFixedLenByteArray(data []byte, size int) (min, max []byte) {
+	if len(data) > 0 {
+		min = data[:size]
+		max = data[:size]
+
+		for i, j := size, 2*size; j <= len(data); {
+			item := data[i:j]
+
+			if bytes.Compare(item, min) < 0 {
+				min = item
+			}
+			if bytes.Compare(item, max) > 0 {
+				max = item
+			}
+
+			i += size
+			j += size
+		}
+	}
+	return min, max
+}
diff --git a/internal/bits/minmax_amd64.go b/page_bounds_amd64.go
similarity index 59%
rename from internal/bits/minmax_amd64.go
rename to page_bounds_amd64.go
index 677af2c..8360382 100644
--- a/internal/bits/minmax_amd64.go
+++ b/page_bounds_amd64.go
@@ -1,6 +1,6 @@
 //go:build !purego
 
-package bits
+package parquet
 
 // 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
@@ -13,10 +13,10 @@ package bits
 // the min-max search, compared to calling the min and max functions separately:
 //
 // name                 old time/op    new time/op    delta
-// MinMaxInt64/10240KiB    590µs ±15%     330µs ±10%  -44.01%  (p=0.000 n=10+10)
+// BoundsInt64/10240KiB    590µs ±15%     330µs ±10%  -44.01%  (p=0.000 n=10+10)
 //
 // name                 old speed      new speed      delta
-// MinMaxInt64/10240KiB 17.9GB/s ±13%  31.8GB/s ±11%  +78.13%  (p=0.000 n=10+10)
+// BoundsInt64/10240KiB 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
@@ -28,10 +28,10 @@ package bits
 // to yield better throughput when the data resides in CPU caches:
 //
 // name             old time/op    new time/op    delta
-// MinMaxInt64/4KiB   52.1ns ± 0%    46.2ns ± 1%  -12.65%  (p=0.000 n=10+10)
+// BoundsInt64/4KiB   52.1ns ± 0%    46.2ns ± 1%  -12.65%  (p=0.000 n=10+10)
 //
 // name             old speed      new speed      delta
-// MinMaxInt64/4KiB 78.6GB/s ± 0%  88.6GB/s ± 1%  +11.23%  (p=0.000 n=10+10)
+// BoundsInt64/4KiB 78.6GB/s ± 0%  88.6GB/s ± 1%  +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
@@ -39,96 +39,87 @@ package bits
 // 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
+const combinedBoundsThreshold = 1 * 1024 * 1024
 
 //go:noescape
-func combinedMinMaxBool(data []bool) (min, max bool)
+func combinedBoundsBool(data []bool) (min, max bool)
 
 //go:noescape
-func combinedMinMaxInt32(data []int32) (min, max int32)
+func combinedBoundsInt32(data []int32) (min, max int32)
 
 //go:noescape
-func combinedMinMaxInt64(data []int64) (min, max int64)
+func combinedBoundsInt64(data []int64) (min, max int64)
 
 //go:noescape
-func combinedMinMaxUint32(data []uint32) (min, max uint32)
+func combinedBoundsUint32(data []uint32) (min, max uint32)
 
 //go:noescape
-func combinedMinMaxUint64(data []uint64) (min, max uint64)
+func combinedBoundsUint64(data []uint64) (min, max uint64)
 
 //go:noescape
-func combinedMinMaxFloat32(data []float32) (min, max float32)
+func combinedBoundsFloat32(data []float32) (min, max float32)
 
 //go:noescape
-func combinedMinMaxFloat64(data []float64) (min, max float64)
+func combinedBoundsFloat64(data []float64) (min, max float64)
 
 //go:noescape
-func combinedMinMaxBE128(data []byte) (min, max []byte)
+func combinedBoundsBE128(data [][16]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)
+func boundsInt32(data []int32) (min, max int32) {
+	if 4*len(data) >= combinedBoundsThreshold {
+		return combinedBoundsInt32(data)
 	}
 	min = minInt32(data)
 	max = maxInt32(data)
 	return
 }
 
-func minMaxInt64(data []int64) (min, max int64) {
-	if 8*len(data) >= combinedMinMaxThreshold {
-		return combinedMinMaxInt64(data)
+func boundsInt64(data []int64) (min, max int64) {
+	if 8*len(data) >= combinedBoundsThreshold {
+		return combinedBoundsInt64(data)
 	}
 	min = minInt64(data)
 	max = maxInt64(data)
 	return
 }
 
-func minMaxUint32(data []uint32) (min, max uint32) {
-	if 4*len(data) >= combinedMinMaxThreshold {
-		return combinedMinMaxUint32(data)
+func boundsUint32(data []uint32) (min, max uint32) {
+	if 4*len(data) >= combinedBoundsThreshold {
+		return combinedBoundsUint32(data)
 	}
 	min = minUint32(data)
 	max = maxUint32(data)
 	return
 }
 
-func minMaxUint64(data []uint64) (min, max uint64) {
-	if 8*len(data) >= combinedMinMaxThreshold {
-		return combinedMinMaxUint64(data)
+func boundsUint64(data []uint64) (min, max uint64) {
+	if 8*len(data) >= combinedBoundsThreshold {
+		return combinedBoundsUint64(data)
 	}
 	min = minUint64(data)
 	max = maxUint64(data)
 	return
 }
 
-func minMaxFloat32(data []float32) (min, max float32) {
-	if 4*len(data) >= combinedMinMaxThreshold {
-		return combinedMinMaxFloat32(data)
+func boundsFloat32(data []float32) (min, max float32) {
+	if 4*len(data) >= combinedBoundsThreshold {
+		return combinedBoundsFloat32(data)
 	}
 	min = minFloat32(data)
 	max = maxFloat32(data)
 	return
 }
 
-func minMaxFloat64(data []float64) (min, max float64) {
-	if 8*len(data) >= combinedMinMaxThreshold {
-		return combinedMinMaxFloat64(data)
+func boundsFloat64(data []float64) (min, max float64) {
+	if 8*len(data) >= combinedBoundsThreshold {
+		return combinedBoundsFloat64(data)
 	}
 	min = minFloat64(data)
 	max = maxFloat64(data)
 	return
 }
 
-func minMaxBE128(data []byte) (min, max []byte) {
+func boundsBE128(data [][16]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
diff --git a/internal/bits/minmax_amd64.s b/page_bounds_amd64.s
similarity index 68%
rename from internal/bits/minmax_amd64.s
rename to page_bounds_amd64.s
index e5a814b..c6d172a 100644
--- a/internal/bits/minmax_amd64.s
+++ b/page_bounds_amd64.s
@@ -1,104 +1,60 @@
 //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
+#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
+
+// func combinedBoundsInt32(data []int32) (min, max int32)
+TEXT ·combinedBoundsInt32(SB), NOSPLIT, $-32
     MOVQ data_base+0(FP), AX
     MOVQ data_len+8(FP), CX
     XORQ R8, R8
@@ -110,7 +66,7 @@ TEXT ·combinedMinMaxInt32(SB), NOSPLIT, $-32
     MOVLQZX (AX), R8 // min
     MOVLQZX (AX), R9 // max
 
-    CMPB ·hasAVX512(SB), $0
+    CMPB ·hasAVX512VL(SB), $0
     JE loop
 
     CMPQ CX, $32
@@ -181,8 +137,8 @@ done:
     MOVL R9, max+28(FP)
     RET
 
-// func combinedMinMaxInt64(data []int64) (min, max int64)
-TEXT ·combinedMinMaxInt64(SB), NOSPLIT, $-40
+// func combinedBoundsInt64(data []int64) (min, max int64)
+TEXT ·combinedBoundsInt64(SB), NOSPLIT, $-40
     MOVQ data_base+0(FP), AX
     MOVQ data_len+8(FP), CX
     XORQ R8, R8
@@ -194,7 +150,7 @@ TEXT ·combinedMinMaxInt64(SB), NOSPLIT, $-40
     MOVQ (AX), R8 // min
     MOVQ (AX), R9 // max
 
-    CMPB ·hasAVX512(SB), $0
+    CMPB ·hasAVX512VL(SB), $0
     JE loop
 
     CMPQ CX, $16
@@ -256,8 +212,8 @@ done:
     MOVQ R9, max+32(FP)
     RET
 
-// func combinedMinMaxUint32(data []uint32) (min, max uint32)
-TEXT ·combinedMinMaxUint32(SB), NOSPLIT, $-32
+// func combinedBoundsUint32(data []uint32) (min, max uint32)
+TEXT ·combinedBoundsUint32(SB), NOSPLIT, $-32
     MOVQ data_base+0(FP), AX
     MOVQ data_len+8(FP), CX
     XORQ R8, R8
@@ -269,7 +225,7 @@ TEXT ·combinedMinMaxUint32(SB), NOSPLIT, $-32
     MOVLQZX (AX), R8 // min
     MOVLQZX (AX), R9 // max
 
-    CMPB ·hasAVX512(SB), $0
+    CMPB ·hasAVX512VL(SB), $0
     JE loop
 
     CMPQ CX, $32
@@ -340,8 +296,8 @@ done:
     MOVL R9, max+28(FP)
     RET
 
-// func combinedMinMaxUint64(data []uint64) (min, max uint64)
-TEXT ·combinedMinMaxUint64(SB), NOSPLIT, $-40
+// func combinedBoundsUint64(data []uint64) (min, max uint64)
+TEXT ·combinedBoundsUint64(SB), NOSPLIT, $-40
     MOVQ data_base+0(FP), AX
     MOVQ data_len+8(FP), CX
     XORQ R8, R8
@@ -353,7 +309,7 @@ TEXT ·combinedMinMaxUint64(SB), NOSPLIT, $-40
     MOVQ (AX), R8 // min
     MOVQ (AX), R9 // max
 
-    CMPB ·hasAVX512(SB), $0
+    CMPB ·hasAVX512VL(SB), $0
     JE loop
 
     CMPQ CX, $16
@@ -415,8 +371,8 @@ done:
     MOVQ R9, max+32(FP)
     RET
 
-// func combinedMinMaxFloat32(data []float32) (min, max float32)
-TEXT ·combinedMinMaxFloat32(SB), NOSPLIT, $-32
+// func combinedBoundsFloat32(data []float32) (min, max float32)
+TEXT ·combinedBoundsFloat32(SB), NOSPLIT, $-32
     MOVQ data_base+0(FP), AX
     MOVQ data_len+8(FP), CX
     XORQ R8, R8
@@ -432,7 +388,7 @@ TEXT ·combinedMinMaxFloat32(SB), NOSPLIT, $-32
     MOVQ R8, X0
     MOVQ R9, X1
 
-    CMPB ·hasAVX512(SB), $0
+    CMPB ·hasAVX512VL(SB), $0
     JE loop
 
     CMPQ CX, $32
@@ -512,8 +468,8 @@ done:
     MOVL R9, max+28(FP)
     RET
 
-// func combinedMinMaxFloat64(data []float64) (min, max float64)
-TEXT ·combinedMinMaxFloat64(SB), NOSPLIT, $-40
+// func combinedBoundsFloat64(data []float64) (min, max float64)
+TEXT ·combinedBoundsFloat64(SB), NOSPLIT, $-40
     MOVQ data_base+0(FP), AX
     MOVQ data_len+8(FP), CX
     XORQ R8, R8
@@ -529,7 +485,7 @@ TEXT ·combinedMinMaxFloat64(SB), NOSPLIT, $-40
     MOVQ R8, X0
     MOVQ R9, X1
 
-    CMPB ·hasAVX512(SB), $0
+    CMPB ·hasAVX512VL(SB), $0
     JE loop
 
     CMPQ CX, $16
@@ -593,54 +549,3 @@ 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/page_bounds_purego.go
similarity index 53%
rename from internal/bits/minmax_default.go
rename to page_bounds_purego.go
index a9e3981..0b5a247 100644
--- a/internal/bits/minmax_default.go
+++ b/page_bounds_purego.go
@@ -1,37 +1,12 @@
-//go:build !go1.18 && (purego || !amd64)
+//go:build purego || !amd64
 
-package bits
+package parquet
 
 import (
 	"encoding/binary"
-
-	"github.com/segmentio/parquet-go/internal/unsafecast"
 )
 
-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) {
+func boundsInt32(data []int32) (min, max int32) {
 	if len(data) > 0 {
 		min = data[0]
 		max = data[0]
@@ -48,7 +23,7 @@ func minMaxInt32(data []int32) (min, max int32) {
 	return min, max
 }
 
-func minMaxInt64(data []int64) (min, max int64) {
+func boundsInt64(data []int64) (min, max int64) {
 	if len(data) > 0 {
 		min = data[0]
 		max = data[0]
@@ -65,7 +40,7 @@ func minMaxInt64(data []int64) (min, max int64) {
 	return min, max
 }
 
-func minMaxUint32(data []uint32) (min, max uint32) {
+func boundsUint32(data []uint32) (min, max uint32) {
 	if len(data) > 0 {
 		min = data[0]
 		max = data[0]
@@ -82,7 +57,7 @@ func minMaxUint32(data []uint32) (min, max uint32) {
 	return min, max
 }
 
-func minMaxUint64(data []uint64) (min, max uint64) {
+func boundsUint64(data []uint64) (min, max uint64) {
 	if len(data) > 0 {
 		min = data[0]
 		max = data[0]
@@ -99,7 +74,7 @@ func minMaxUint64(data []uint64) (min, max uint64) {
 	return min, max
 }
 
-func minMaxFloat32(data []float32) (min, max float32) {
+func boundsFloat32(data []float32) (min, max float32) {
 	if len(data) > 0 {
 		min = data[0]
 		max = data[0]
@@ -116,7 +91,7 @@ func minMaxFloat32(data []float32) (min, max float32) {
 	return min, max
 }
 
-func minMaxFloat64(data []float64) (min, max float64) {
+func boundsFloat64(data []float64) (min, max float64) {
 	if len(data) > 0 {
 		min = data[0]
 		max = data[0]
@@ -133,21 +108,20 @@ func minMaxFloat64(data []float64) (min, max float64) {
 	return min, max
 }
 
-func minMaxBE128(data []byte) (min, max []byte) {
+func boundsBE128(data [][16]byte) (min, max []byte) {
 	if len(data) > 0 {
-		be128 := unsafecast.BytesToUint128(data)
-		minHi := binary.BigEndian.Uint64(be128[0][:8])
+		minHi := binary.BigEndian.Uint64(data[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:])
+		for i := 1; i < len(data); i++ {
+			hi := binary.BigEndian.Uint64(data[i][:8])
+			lo := binary.BigEndian.Uint64(data[i][8:])
 			switch {
 			case hi < minHi:
 				minHi, minIndex = hi, i
 			case hi == minHi:
-				minLo := binary.BigEndian.Uint64(be128[minIndex][8:])
+				minLo := binary.BigEndian.Uint64(data[minIndex][8:])
 				if lo < minLo {
 					minHi, minIndex = hi, i
 				}
@@ -156,14 +130,14 @@ func minMaxBE128(data []byte) (min, max []byte) {
 			case hi > maxHi:
 				maxHi, maxIndex = hi, i
 			case hi == maxHi:
-				maxLo := binary.BigEndian.Uint64(be128[maxIndex][8:])
+				maxLo := binary.BigEndian.Uint64(data[maxIndex][8:])
 				if lo > maxLo {
 					maxHi, maxIndex = hi, i
 				}
 			}
 		}
-		min = be128[minIndex][:]
-		max = be128[maxIndex][:]
+		min = data[minIndex][:]
+		max = data[maxIndex][:]
 	}
 	return min, max
 }
diff --git a/page_max.go b/page_max.go
new file mode 100644
index 0000000..f6afecf
--- /dev/null
+++ b/page_max.go
@@ -0,0 +1,23 @@
+package parquet
+
+import (
+	"bytes"
+)
+
+func maxFixedLenByteArray(data []byte, size int) (max []byte) {
+	if len(data) > 0 {
+		max = data[:size]
+
+		for i, j := size, 2*size; j <= len(data); {
+			item := data[i:j]
+
+			if bytes.Compare(item, max) > 0 {
+				max = item
+			}
+
+			i += size
+			j += size
+		}
+	}
+	return max
+}
diff --git a/internal/bits/max_amd64.go b/page_max_amd64.go
similarity index 78%
rename from internal/bits/max_amd64.go
rename to page_max_amd64.go
index ee71a7e..2ac1de2 100644
--- a/internal/bits/max_amd64.go
+++ b/page_max_amd64.go
@@ -1,9 +1,6 @@
 //go:build !purego
 
-package bits
-
-//go:noescape
-func maxBool(data []bool) bool
+package parquet
 
 //go:noescape
 func maxInt32(data []int32) int32
@@ -23,5 +20,5 @@ func maxFloat32(data []float32) float32
 //go:noescape
 func maxFloat64(data []float64) float64
 
-// go:noescape
-func maxBE128(data []byte) []byte
+//go:noescape
+func maxBE128(data [][16]byte) []byte
diff --git a/internal/bits/max_amd64.s b/page_max_amd64.s
similarity index 81%
rename from internal/bits/max_amd64.s
rename to page_max_amd64.s
index f5d6b50..afac16c 100644
--- a/internal/bits/max_amd64.s
+++ b/page_max_amd64.s
@@ -1,63 +1,6 @@
 //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 ·hasAVX512MaxBool(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
@@ -70,7 +13,7 @@ TEXT ·maxInt32(SB), NOSPLIT, $-28
     XORQ SI, SI
     MOVLQZX (AX), BX
 
-    CMPB ·hasAVX512(SB), $0
+    CMPB ·hasAVX512VL(SB), $0
     JE loop
 
     CMPQ CX, $32
@@ -132,7 +75,7 @@ TEXT ·maxInt64(SB), NOSPLIT, $-32
     XORQ SI, SI
     MOVQ (AX), BX
 
-    CMPB ·hasAVX512(SB), $0
+    CMPB ·hasAVX512VL(SB), $0
     JE loop
 
     CMPQ CX, $32
@@ -193,7 +136,7 @@ TEXT ·maxUint32(SB), NOSPLIT, $-28
     XORQ SI, SI
     MOVLQZX (AX), BX
 
-    CMPB ·hasAVX512(SB), $0
+    CMPB ·hasAVX512VL(SB), $0
     JE loop
 
     CMPQ CX, $32
@@ -255,7 +198,7 @@ TEXT ·maxUint64(SB), NOSPLIT, $-32
     XORQ SI, SI
     MOVQ (AX), BX
 
-    CMPB ·hasAVX512(SB), $0
+    CMPB ·hasAVX512VL(SB), $0
     JE loop
 
     CMPQ CX, $32
@@ -319,7 +262,7 @@ TEXT ·maxFloat32(SB), NOSPLIT, $-28
     MOVLQZX (AX), BX
     MOVQ BX, X0
 
-    CMPB ·hasAVX512(SB), $0
+    CMPB ·hasAVX512VL(SB), $0
     JE loop
 
     CMPQ CX, $64
@@ -392,7 +335,7 @@ TEXT ·maxFloat64(SB), NOSPLIT, $-32
     MOVQ (AX), BX
     MOVQ BX, X0
 
-    CMPB ·hasAVX512(SB), $0
+    CMPB ·hasAVX512VL(SB), $0
     JE loop
 
     CMPQ CX, $32
@@ -444,13 +387,63 @@ done:
     MOVQ BX, ret+24(FP)
     RET
 
-// func maxBE128(data []byte) []byte
+// 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 \
+    KMOVB K1, R1 \
+    KMOVB K2, R2 \
+    MOVB R2, R3 \
+    SHLB $1, R3 \
+    ANDB R3, R1 \
+    ORB R2, R1 \
+    ANDB M, R1 \
+    MOVB R1, R2 \
+    SHRB $1, R2 \
+    ORB R2, R1 \
+    KMOVB 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) MOVB $0b10101010, M
+
+// func maxBE128(data [][16]byte) []byte
 TEXT ·maxBE128(SB), NOSPLIT, $-48
     MOVQ data_base+0(FP), AX
     MOVQ data_len+8(FP), CX
     CMPQ CX, $0
     JE null
 
+    SHLQ $4, CX
     MOVQ CX, DX // len
     MOVQ AX, BX // max
     ADDQ AX, CX // end
@@ -588,13 +581,14 @@ next:
     CMPQ AX, CX
     JB loop
 done:
-    MOVQ BX, ret+24(FP)
-    MOVQ $16, ret+32(FP)
-    MOVQ $16, ret+40(FP)
+    MOVQ BX, ret_base+24(FP)
+    MOVQ $16, ret_len+32(FP)
+    MOVQ $16, ret_cap+40(FP)
     RET
 null:
     XORQ BX, BX
-    MOVQ BX, min+24(FP)
-    MOVQ BX, min+32(FP)
-    MOVQ BX, min+40(FP)
+    MOVQ BX, ret_base+24(FP)
+    MOVQ BX, ret_len+32(FP)
+    MOVQ BX, ret_cap+40(FP)
     RET
+
diff --git a/internal/bits/max_default.go b/page_max_purego.go
similarity index 64%
rename from internal/bits/max_default.go
rename to page_max_purego.go
index b5a8ffc..e3f8288 100644
--- a/internal/bits/max_default.go
+++ b/page_max_purego.go
@@ -1,16 +1,13 @@
-//go:build !go1.18 && (purego || !amd64)
+//go:build purego || !amd64
 
-package bits
+package parquet
 
-import (
-	"encoding/binary"
+import "encoding/binary"
 
-	"github.com/segmentio/parquet-go/internal/unsafecast"
-)
-
-func maxBool(data []bool) (max bool) {
-	return len(data) > 0 && !boolEqualAll(data, false)
-}
+// -----------------------------------------------------------------------------
+// TODO: use generics versions of the these functions to reduce the amount of
+// code to maintain when we drop compatilibty with Go version older than 1.18.
+// -----------------------------------------------------------------------------
 
 func maxInt32(data []int32) (max int32) {
 	if len(data) > 0 {
@@ -90,25 +87,24 @@ func maxFloat64(data []float64) (max float64) {
 	return max
 }
 
-func maxBE128(data []byte) (min []byte) {
+func maxBE128(data [][16]byte) (min []byte) {
 	if len(data) > 0 {
-		be128 := unsafecast.BytesToUint128(data)
-		m := binary.BigEndian.Uint64(be128[0][:8])
+		m := binary.BigEndian.Uint64(data[0][:8])
 		j := 0
-		for i := 1; i < len(be128); i++ {
-			x := binary.BigEndian.Uint64(be128[i][:8])
+		for i := 1; i < len(data); i++ {
+			x := binary.BigEndian.Uint64(data[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:])
+				y := binary.BigEndian.Uint64(data[i][8:])
+				n := binary.BigEndian.Uint64(data[j][8:])
 				if y > n {
 					m, j = x, i
 				}
 			}
 		}
-		min = be128[j][:]
+		min = data[j][:]
 	}
 	return min
 }
diff --git a/page_min.go b/page_min.go
new file mode 100644
index 0000000..2bc185a
--- /dev/null
+++ b/page_min.go
@@ -0,0 +1,23 @@
+package parquet
+
+import (
+	"bytes"
+)
+
+func minFixedLenByteArray(data []byte, size int) (min []byte) {
+	if len(data) > 0 {
+		min = data[:size]
+
+		for i, j := size, 2*size; j <= len(data); {
+			item := data[i:j]
+
+			if bytes.Compare(item, min) < 0 {
+				min = item
+			}
+
+			i += size
+			j += size
+		}
+	}
+	return min
+}
diff --git a/internal/bits/min_amd64.go b/page_min_amd64.go
similarity index 78%
rename from internal/bits/min_amd64.go
rename to page_min_amd64.go
index 2700db3..c9fd654 100644
--- a/internal/bits/min_amd64.go
+++ b/page_min_amd64.go
@@ -1,9 +1,6 @@
 //go:build !purego
 
-package bits
-
-//go:noescape
-func minBool(data []bool) bool
+package parquet
 
 //go:noescape
 func minInt32(data []int32) int32
@@ -23,5 +20,5 @@ func minFloat32(data []float32) float32
 //go:noescape
 func minFloat64(data []float64) float64
 
-// go:noescape
-func minBE128(data []byte) []byte
+//go:noescape
+func minBE128(data [][16]byte) []byte
diff --git a/internal/bits/min_amd64.s b/page_min_amd64.s
similarity index 80%
rename from internal/bits/min_amd64.s
rename to page_min_amd64.s
index 5c4aa12..73e2bbb 100644
--- a/internal/bits/min_amd64.s
+++ b/page_min_amd64.s
@@ -1,64 +1,6 @@
 //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 ·hasAVX512MinBool(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
@@ -71,7 +13,7 @@ TEXT ·minInt32(SB), NOSPLIT, $-28
     XORQ SI, SI
     MOVLQZX (AX), BX
 
-    CMPB ·hasAVX512(SB), $0
+    CMPB ·hasAVX512VL(SB), $0
     JE loop
 
     CMPQ CX, $32
@@ -133,7 +75,7 @@ TEXT ·minInt64(SB), NOSPLIT, $-32
     XORQ SI, SI
     MOVQ (AX), BX
 
-    CMPB ·hasAVX512(SB), $0
+    CMPB ·hasAVX512VL(SB), $0
     JE loop
 
     CMPQ CX, $32
@@ -194,7 +136,7 @@ TEXT ·minUint32(SB), NOSPLIT, $-28
     XORQ SI, SI
     MOVLQZX (AX), BX
 
-    CMPB ·hasAVX512(SB), $0
+    CMPB ·hasAVX512VL(SB), $0
     JE loop
 
     CMPQ CX, $32
@@ -256,7 +198,7 @@ TEXT ·minUint64(SB), NOSPLIT, $-32
     XORQ SI, SI
     MOVQ (AX), BX
 
-    CMPB ·hasAVX512(SB), $0
+    CMPB ·hasAVX512VL(SB), $0
     JE loop
 
     CMPQ CX, $32
@@ -320,7 +262,7 @@ TEXT ·minFloat32(SB), NOSPLIT, $-28
     MOVLQZX (AX), BX
     MOVQ BX, X0
 
-    CMPB ·hasAVX512(SB), $0
+    CMPB ·hasAVX512VL(SB), $0
     JE loop
 
     CMPQ CX, $64
@@ -393,7 +335,7 @@ TEXT ·minFloat64(SB), NOSPLIT, $-32
     MOVQ (AX), BX
     MOVQ BX, X0
 
-    CMPB ·hasAVX512(SB), $0
+    CMPB ·hasAVX512VL(SB), $0
     JE loop
 
     CMPQ CX, $32
@@ -445,13 +387,63 @@ done:
     MOVQ BX, ret+24(FP)
     RET
 
-// func minBE128(data []byte) []byte
+// 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 \
+    KMOVB K1, R1 \
+    KMOVB K2, R2 \
+    MOVB R2, R3 \
+    SHLB $1, R3 \
+    ANDB R3, R1 \
+    ORB R2, R1 \
+    ANDB M, R1 \
+    MOVB R1, R2 \
+    SHRB $1, R2 \
+    ORB R2, R1 \
+    KMOVB 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) MOVB $0b10101010, M
+
+// func minBE128(data [][16]byte) []byte
 TEXT ·minBE128(SB), NOSPLIT, $-48
     MOVQ data_base+0(FP), AX
     MOVQ data_len+8(FP), CX
     CMPQ CX, $0
     JE null
 
+    SHLQ $4, CX
     MOVQ CX, DX // len
     MOVQ AX, BX // min
     ADDQ AX, CX // end
@@ -584,13 +576,13 @@ next:
     CMPQ AX, CX
     JB loop
 done:
-    MOVQ BX, ret+24(FP)
-    MOVQ $16, ret+32(FP)
-    MOVQ $16, ret+40(FP)
+    MOVQ BX, ret_base+24(FP)
+    MOVQ $16, ret_len+32(FP)
+    MOVQ $16, ret_cap+40(FP)
     RET
 null:
     XORQ BX, BX
-    MOVQ BX, min+24(FP)
-    MOVQ BX, min+32(FP)
-    MOVQ BX, min+40(FP)
+    MOVQ BX, ret_base+24(FP)
+    MOVQ BX, ret_len+32(FP)
+    MOVQ BX, ret_cap+40(FP)
     RET
diff --git a/internal/bits/min_default.go b/page_min_purego.go
similarity index 64%
rename from internal/bits/min_default.go
rename to page_min_purego.go
index a74ed03..e68afbb 100644
--- a/internal/bits/min_default.go
+++ b/page_min_purego.go
@@ -1,16 +1,13 @@
-//go:build !go1.18 && (purego || !amd64)
+//go:build purego || !amd64
 
-package bits
+package parquet
 
-import (
-	"encoding/binary"
+import "encoding/binary"
 
-	"github.com/segmentio/parquet-go/internal/unsafecast"
-)
-
-func minBool(data []bool) (min bool) {
-	return boolEqualAll(data, true)
-}
+// -----------------------------------------------------------------------------
+// TODO: use generics versions of the these functions to reduce the amount of
+// code to maintain when we drop compatilibty with Go version older than 1.18.
+// -----------------------------------------------------------------------------
 
 func minInt32(data []int32) (min int32) {
 	if len(data) > 0 {
@@ -90,25 +87,24 @@ func minFloat64(data []float64) (min float64) {
 	return min
 }
 
-func minBE128(data []byte) (min []byte) {
+func minBE128(data [][16]byte) (min []byte) {
 	if len(data) > 0 {
-		be128 := unsafecast.BytesToUint128(data)
-		m := binary.BigEndian.Uint64(be128[0][:8])
+		m := binary.BigEndian.Uint64(data[0][:8])
 		j := 0
-		for i := 1; i < len(be128); i++ {
-			x := binary.BigEndian.Uint64(be128[i][:8])
+		for i := 1; i < len(data); i++ {
+			x := binary.BigEndian.Uint64(data[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:])
+				y := binary.BigEndian.Uint64(data[i][8:])
+				n := binary.BigEndian.Uint64(data[j][8:])
 				if y < n {
 					m, j = x, i
 				}
 			}
 		}
-		min = be128[j][:]
+		min = data[j][:]
 	}
 	return min
 }
diff --git a/parquet.go b/parquet.go
index e0cf8fb..37dc0b5 100644
--- a/parquet.go
+++ b/parquet.go
@@ -31,3 +31,7 @@ func max(a, b int) int {
 	}
 	return b
 }
+
+func byteCount(numBits uint) int {
+	return int((numBits + 7) / 8)
+}
diff --git a/parquet_amd64.go b/parquet_amd64.go
index 2ffbd06..b2a6868 100644
--- a/parquet_amd64.go
+++ b/parquet_amd64.go
@@ -16,6 +16,11 @@ var (
 	hasAVX2     = cpu.X86.HasAVX2
 	hasAVX512F  = cpu.X86.HasAVX512F
 	hasAVX512VL = cpu.X86.HasAVX512F && cpu.X86.HasAVX512VL
+	// For min/max functions over big-endian 128 bits values, we need the
+	// follwing instructions from the DQ set:
+	// * VPBROADCASTQ (with 64 bits source register)
+	// * VBROADCASTI64X2
+	hasAVX512MinMaxBE128 = hasAVX512F && cpu.X86.HasAVX512DQ
 )
 
 func optimize(n int) bool {
diff --git a/type.go b/type.go
index 65f29af..dd3bdf1 100644
--- a/type.go
+++ b/type.go
@@ -3,12 +3,12 @@ package parquet
 import (
 	"bytes"
 	"fmt"
+	"math/bits"
 	"time"
 
 	"github.com/segmentio/parquet-go/deprecated"
 	"github.com/segmentio/parquet-go/encoding"
 	"github.com/segmentio/parquet-go/format"
-	"github.com/segmentio/parquet-go/internal/bits"
 )
 
 // Kind is an enumeration type representing the physical types supported by the
@@ -62,6 +62,12 @@ type Type interface {
 	// For other types, the value is zero.
 	Length() int
 
+	// Returns an estimation of the number of bytes required to hold the given
+	// number of values of this type in memory.
+	//
+	// The method returns zero for group types.
+	EstimateSize(numValues int) int64
+
 	// Compares two values and returns a negative integer if a < b, positive if
 	// a > b, or zero if a == b.
 	//
@@ -121,18 +127,16 @@ type Type interface {
 	// accumulating values in memory for (relative to the parent schema),
 	// and the size of their memory buffer.
 	//
-	// The buffer size is given in bytes, because we want to control memory
-	// consumption of the application, which is simpler to achieve with buffer
-	// size expressed in bytes rather than number of elements.
-	//
-	// Note that the buffer size is not a hard limit, it defines the initial
-	// capacity of the column buffer, but may grow as needed. Programs can use
-	// the Size method of the column buffer (or the parent row group, when
-	// relevant) to determine how many bytes are being used, and perform a flush
-	// of the buffers to a storage layer.
+	// The application may give an estimate of the number of values it expects
+	// to write to the buffer as second argument. This estimate helps set the
+	// initialize buffer capacity but is not a hard limit, the underlying memory
+	// buffer will grown as needed to allow more values to be written. Programs
+	// may use the Size method of the column buffer (or the parent row group,
+	// when relevant) to determine how many bytes are being used, and perform a
+	// flush of the buffers to a storage layer.
 	//
 	// The method panics if it is called on a group type.
-	NewColumnBuffer(columnIndex, bufferSize int) ColumnBuffer
+	NewColumnBuffer(columnIndex, numValues int) ColumnBuffer
 
 	// Creates a dictionary holding values of this type.
 	//
@@ -232,6 +236,7 @@ type booleanType struct{}
 func (t booleanType) String() string                           { return "BOOLEAN" }
 func (t booleanType) Kind() Kind                               { return Boolean }
 func (t booleanType) Length() int                              { return 1 }
+func (t booleanType) EstimateSize(n int) int64                 { return (int64(n) + 7) / 8 }
 func (t booleanType) Compare(a, b Value) int                   { return compareBool(a.Boolean(), b.Boolean()) }
 func (t booleanType) ColumnOrder() *format.ColumnOrder         { return &typeDefinedColumnOrder }
 func (t booleanType) LogicalType() *format.LogicalType         { return nil }
@@ -242,8 +247,8 @@ func (t booleanType) NewColumnIndexer(sizeLimit int) ColumnIndexer {
 	return newBooleanColumnIndexer()
 }
 
-func (t booleanType) NewColumnBuffer(columnIndex, bufferSize int) ColumnBuffer {
-	return newBooleanColumnBuffer(t, makeColumnIndex(columnIndex), bufferSize)
+func (t booleanType) NewColumnBuffer(columnIndex, numValues int) ColumnBuffer {
+	return newBooleanColumnBuffer(t, makeColumnIndex(columnIndex), makeNumValues(numValues))
 }
 
 func (t booleanType) NewDictionary(columnIndex, numValues int, data []byte) Dictionary {
@@ -267,6 +272,7 @@ type int32Type struct{}
 func (t int32Type) String() string                           { return "INT32" }
 func (t int32Type) Kind() Kind                               { return Int32 }
 func (t int32Type) Length() int                              { return 32 }
+func (t int32Type) EstimateSize(n int) int64                 { return 4 * int64(n) }
 func (t int32Type) Compare(a, b Value) int                   { return compareInt32(a.Int32(), b.Int32()) }
 func (t int32Type) ColumnOrder() *format.ColumnOrder         { return &typeDefinedColumnOrder }
 func (t int32Type) LogicalType() *format.LogicalType         { return nil }
@@ -277,8 +283,8 @@ func (t int32Type) NewColumnIndexer(sizeLimit int) ColumnIndexer {
 	return newInt32ColumnIndexer()
 }
 
-func (t int32Type) NewColumnBuffer(columnIndex, bufferSize int) ColumnBuffer {
-	return newInt32ColumnBuffer(t, makeColumnIndex(columnIndex), bufferSize)
+func (t int32Type) NewColumnBuffer(columnIndex, numValues int) ColumnBuffer {
+	return newInt32ColumnBuffer(t, makeColumnIndex(columnIndex), makeNumValues(numValues))
 }
 
 func (t int32Type) NewDictionary(columnIndex, numValues int, data []byte) Dictionary {
@@ -302,6 +308,7 @@ type int64Type struct{}
 func (t int64Type) String() string                           { return "INT64" }
 func (t int64Type) Kind() Kind                               { return Int64 }
 func (t int64Type) Length() int                              { return 64 }
+func (t int64Type) EstimateSize(n int) int64                 { return 8 * int64(n) }
 func (t int64Type) Compare(a, b Value) int                   { return compareInt64(a.Int64(), b.Int64()) }
 func (t int64Type) ColumnOrder() *format.ColumnOrder         { return &typeDefinedColumnOrder }
 func (t int64Type) LogicalType() *format.LogicalType         { return nil }
@@ -312,8 +319,8 @@ func (t int64Type) NewColumnIndexer(sizeLimit int) ColumnIndexer {
 	return newInt64ColumnIndexer()
 }
 
-func (t int64Type) NewColumnBuffer(columnIndex, bufferSize int) ColumnBuffer {
-	return newInt64ColumnBuffer(t, makeColumnIndex(columnIndex), bufferSize)
+func (t int64Type) NewColumnBuffer(columnIndex, numValues int) ColumnBuffer {
+	return newInt64ColumnBuffer(t, makeColumnIndex(columnIndex), makeNumValues(numValues))
 }
 
 func (t int64Type) NewDictionary(columnIndex, numValues int, data []byte) Dictionary {
@@ -338,6 +345,7 @@ func (t int96Type) String() string { return "INT96" }
 
 func (t int96Type) Kind() Kind                               { return Int96 }
 func (t int96Type) Length() int                              { return 96 }
+func (t int96Type) EstimateSize(n int) int64                 { return 12 * int64(n) }
 func (t int96Type) Compare(a, b Value) int                   { return compareInt96(a.Int96(), b.Int96()) }
 func (t int96Type) ColumnOrder() *format.ColumnOrder         { return &typeDefinedColumnOrder }
 func (t int96Type) LogicalType() *format.LogicalType         { return nil }
@@ -348,8 +356,8 @@ func (t int96Type) NewColumnIndexer(sizeLimit int) ColumnIndexer {
 	return newInt96ColumnIndexer()
 }
 
-func (t int96Type) NewColumnBuffer(columnIndex, bufferSize int) ColumnBuffer {
-	return newInt96ColumnBuffer(t, makeColumnIndex(columnIndex), bufferSize)
+func (t int96Type) NewColumnBuffer(columnIndex, numValues int) ColumnBuffer {
+	return newInt96ColumnBuffer(t, makeColumnIndex(columnIndex), makeNumValues(numValues))
 }
 
 func (t int96Type) NewDictionary(columnIndex, numValues int, data []byte) Dictionary {
@@ -373,6 +381,7 @@ type floatType struct{}
 func (t floatType) String() string                           { return "FLOAT" }
 func (t floatType) Kind() Kind                               { return Float }
 func (t floatType) Length() int                              { return 32 }
+func (t floatType) EstimateSize(n int) int64                 { return 4 * int64(n) }
 func (t floatType) Compare(a, b Value) int                   { return compareFloat32(a.Float(), b.Float()) }
 func (t floatType) ColumnOrder() *format.ColumnOrder         { return &typeDefinedColumnOrder }
 func (t floatType) LogicalType() *format.LogicalType         { return nil }
@@ -383,8 +392,8 @@ func (t floatType) NewColumnIndexer(sizeLimit int) ColumnIndexer {
 	return newFloatColumnIndexer()
 }
 
-func (t floatType) NewColumnBuffer(columnIndex, bufferSize int) ColumnBuffer {
-	return newFloatColumnBuffer(t, makeColumnIndex(columnIndex), bufferSize)
+func (t floatType) NewColumnBuffer(columnIndex, numValues int) ColumnBuffer {
+	return newFloatColumnBuffer(t, makeColumnIndex(columnIndex), makeNumValues(numValues))
 }
 
 func (t floatType) NewDictionary(columnIndex, numValues int, data []byte) Dictionary {
@@ -408,6 +417,7 @@ type doubleType struct{}
 func (t doubleType) String() string                           { return "DOUBLE" }
 func (t doubleType) Kind() Kind                               { return Double }
 func (t doubleType) Length() int                              { return 64 }
+func (t doubleType) EstimateSize(n int) int64                 { return 8 * int64(n) }
 func (t doubleType) Compare(a, b Value) int                   { return compareFloat64(a.Double(), b.Double()) }
 func (t doubleType) ColumnOrder() *format.ColumnOrder         { return &typeDefinedColumnOrder }
 func (t doubleType) LogicalType() *format.LogicalType         { return nil }
@@ -418,8 +428,8 @@ func (t doubleType) NewColumnIndexer(sizeLimit int) ColumnIndexer {
 	return newDoubleColumnIndexer()
 }
 
-func (t doubleType) NewColumnBuffer(columnIndex, bufferSize int) ColumnBuffer {
-	return newDoubleColumnBuffer(t, makeColumnIndex(columnIndex), bufferSize)
+func (t doubleType) NewColumnBuffer(columnIndex, numValues int) ColumnBuffer {
+	return newDoubleColumnBuffer(t, makeColumnIndex(columnIndex), makeNumValues(numValues))
 }
 
 func (t doubleType) NewDictionary(columnIndex, numValues int, data []byte) Dictionary {
@@ -443,6 +453,7 @@ type byteArrayType struct{}
 func (t byteArrayType) String() string                           { return "BYTE_ARRAY" }
 func (t byteArrayType) Kind() Kind                               { return ByteArray }
 func (t byteArrayType) Length() int                              { return 0 }
+func (t byteArrayType) EstimateSize(n int) int64                 { return 10 * int64(n) }
 func (t byteArrayType) Compare(a, b Value) int                   { return bytes.Compare(a.ByteArray(), b.ByteArray()) }
 func (t byteArrayType) ColumnOrder() *format.ColumnOrder         { return &typeDefinedColumnOrder }
 func (t byteArrayType) LogicalType() *format.LogicalType         { return nil }
@@ -453,8 +464,8 @@ func (t byteArrayType) NewColumnIndexer(sizeLimit int) ColumnIndexer {
 	return newByteArrayColumnIndexer(sizeLimit)
 }
 
-func (t byteArrayType) NewColumnBuffer(columnIndex, bufferSize int) ColumnBuffer {
-	return newByteArrayColumnBuffer(t, makeColumnIndex(columnIndex), bufferSize)
+func (t byteArrayType) NewColumnBuffer(columnIndex, numValues int) ColumnBuffer {
+	return newByteArrayColumnBuffer(t, makeColumnIndex(columnIndex), makeNumValues(numValues))
 }
 
 func (t byteArrayType) NewDictionary(columnIndex, numValues int, data []byte) Dictionary {
@@ -483,6 +494,8 @@ func (t fixedLenByteArrayType) Kind() Kind { return FixedLenByteArray }
 
 func (t fixedLenByteArrayType) Length() int { return t.length }
 
+func (t fixedLenByteArrayType) EstimateSize(n int) int64 { return int64(t.length) * int64(n) }
+
 func (t fixedLenByteArrayType) Compare(a, b Value) int {
 	return bytes.Compare(a.ByteArray(), b.ByteArray())
 }
@@ -499,8 +512,8 @@ func (t fixedLenByteArrayType) NewColumnIndexer(sizeLimit int) ColumnIndexer {
 	return newFixedLenByteArrayColumnIndexer(t.length, sizeLimit)
 }
 
-func (t fixedLenByteArrayType) NewColumnBuffer(columnIndex, bufferSize int) ColumnBuffer {
-	return newFixedLenByteArrayColumnBuffer(t, makeColumnIndex(columnIndex), bufferSize)
+func (t fixedLenByteArrayType) NewColumnBuffer(columnIndex, numValues int) ColumnBuffer {
+	return newFixedLenByteArrayColumnBuffer(t, makeColumnIndex(columnIndex), makeNumValues(numValues))
 }
 
 func (t fixedLenByteArrayType) NewDictionary(columnIndex, numValues int, data []byte) Dictionary {
@@ -538,6 +551,8 @@ func (t be128Type) Kind() Kind { return FixedLenByteArray }
 
 func (t be128Type) Length() int { return 16 }
 
+func (t be128Type) EstimateSize(n int) int64 { return 16 * int64(n) }
+
 func (t be128Type) Compare(a, b Value) int {
 	return compareBE128((*[16]byte)(a.ByteArray()), (*[16]byte)(b.ByteArray()))
 }
@@ -554,8 +569,8 @@ func (t be128Type) NewColumnIndexer(sizeLimit int) ColumnIndexer {
 	return newBE128ColumnIndexer()
 }
 
-func (t be128Type) NewColumnBuffer(columnIndex, bufferSize int) ColumnBuffer {
-	return newBE128ColumnBuffer(t, makeColumnIndex(columnIndex), bufferSize)
+func (t be128Type) NewColumnBuffer(columnIndex, numValues int) ColumnBuffer {
+	return newBE128ColumnBuffer(t, makeColumnIndex(columnIndex), makeNumValues(numValues))
 }
 
 func (t be128Type) NewDictionary(columnIndex, numValues int, data []byte) Dictionary {
@@ -644,6 +659,8 @@ func (t *intType) Kind() Kind {
 
 func (t *intType) Length() int { return int(t.BitWidth) }
 
+func (t *intType) EstimateSize(n int) int64 { return int64(t.BitWidth/8) * int64(n) }
+
 func (t *intType) Compare(a, b Value) int {
 	if t.BitWidth == 64 {
 		i1 := a.Int64()
@@ -681,7 +698,7 @@ func (t *intType) LogicalType() *format.LogicalType {
 }
 
 func (t *intType) ConvertedType() *deprecated.ConvertedType {
-	convertedType := bits.Len8(int8(t.BitWidth)/8) - 1 // 8=>0, 16=>1, 32=>2, 64=>4
+	convertedType := bits.Len8(uint8(t.BitWidth)/8) - 1 // 8=>0, 16=>1, 32=>2, 64=>4
 	if t.IsSigned {
 		convertedType += int(deprecated.Int8)
 	} else {
@@ -706,18 +723,18 @@ func (t *intType) NewColumnIndexer(sizeLimit int) ColumnIndexer {
 	}
 }
 
-func (t *intType) NewColumnBuffer(columnIndex, bufferSize int) ColumnBuffer {
+func (t *intType) NewColumnBuffer(columnIndex, numValues int) ColumnBuffer {
 	if t.IsSigned {
 		if t.BitWidth == 64 {
-			return newInt64ColumnBuffer(t, makeColumnIndex(columnIndex), bufferSize)
+			return newInt64ColumnBuffer(t, makeColumnIndex(columnIndex), makeNumValues(numValues))
 		} else {
-			return newInt32ColumnBuffer(t, makeColumnIndex(columnIndex), bufferSize)
+			return newInt32ColumnBuffer(t, makeColumnIndex(columnIndex), makeNumValues(numValues))
 		}
 	} else {
 		if t.BitWidth == 64 {
-			return newUint64ColumnBuffer(t, makeColumnIndex(columnIndex), bufferSize)
+			return newUint64ColumnBuffer(t, makeColumnIndex(columnIndex), makeNumValues(numValues))
 		} else {
-			return newUint32ColumnBuffer(t, makeColumnIndex(columnIndex), bufferSize)
+			return newUint32ColumnBuffer(t, makeColumnIndex(columnIndex), makeNumValues(numValues))
 		}
 	}
 }
@@ -817,6 +834,8 @@ func (t *stringType) Kind() Kind { return ByteArray }
 
 func (t *stringType) Length() int { return 0 }
 
+func (t *stringType) EstimateSize(n int) int64 { return 10 * int64(n) }
+
 func (t *stringType) Compare(a, b Value) int {
 	return bytes.Compare(a.ByteArray(), b.ByteArray())
 }
@@ -845,8 +864,8 @@ func (t *stringType) NewDictionary(columnIndex, numValues int, data []byte) Dict
 	return newByteArrayDictionary(t, makeColumnIndex(columnIndex), makeNumValues(numValues), data)
 }
 
-func (t *stringType) NewColumnBuffer(columnIndex, bufferSize int) ColumnBuffer {
-	return newByteArrayColumnBuffer(t, makeColumnIndex(columnIndex), bufferSize)
+func (t *stringType) NewColumnBuffer(columnIndex, numValues int) ColumnBuffer {
+	return newByteArrayColumnBuffer(t, makeColumnIndex(columnIndex), makeNumValues(numValues))
 }
 
 func (t *stringType) NewPage(columnIndex, numValues int, data []byte) Page {
@@ -874,6 +893,8 @@ func (t *uuidType) Kind() Kind { return FixedLenByteArray }
 
 func (t *uuidType) Length() int { return 16 }
 
+func (t *uuidType) EstimateSize(n int) int64 { return 16 * int64(n) }
+
 func (t *uuidType) Compare(a, b Value) int {
 	return compareBE128((*[16]byte)(a.ByteArray()), (*[16]byte)(b.ByteArray()))
 }
@@ -896,8 +917,8 @@ func (t *uuidType) NewDictionary(columnIndex, numValues int, data []byte) Dictio
 	return newBE128Dictionary(t, makeColumnIndex(columnIndex), makeNumValues(numValues), data)
 }
 
-func (t *uuidType) NewColumnBuffer(columnIndex, bufferSize int) ColumnBuffer {
-	return newBE128ColumnBuffer(t, makeColumnIndex(columnIndex), bufferSize)
+func (t *uuidType) NewColumnBuffer(columnIndex, numValues int) ColumnBuffer {
+	return newBE128ColumnBuffer(t, makeColumnIndex(columnIndex), makeNumValues(numValues))
 }
 
 func (t *uuidType) NewPage(columnIndex, numValues int, data []byte) Page {
@@ -925,6 +946,8 @@ func (t *enumType) Kind() Kind { return ByteArray }
 
 func (t *enumType) Length() int { return 0 }
 
+func (t *enumType) EstimateSize(n int) int64 { return 10 * int64(n) }
+
 func (t *enumType) Compare(a, b Value) int {
 	return bytes.Compare(a.ByteArray(), b.ByteArray())
 }
@@ -953,8 +976,8 @@ func (t *enumType) NewDictionary(columnIndex, numValues int, data []byte) Dictio
 	return newByteArrayDictionary(t, makeColumnIndex(columnIndex), makeNumValues(numValues), data)
 }
 
-func (t *enumType) NewColumnBuffer(columnIndex, bufferSize int) ColumnBuffer {
-	return newByteArrayColumnBuffer(t, makeColumnIndex(columnIndex), bufferSize)
+func (t *enumType) NewColumnBuffer(columnIndex, numValues int) ColumnBuffer {
+	return newByteArrayColumnBuffer(t, makeColumnIndex(columnIndex), makeNumValues(numValues))
 }
 
 func (t *enumType) NewPage(columnIndex, numValues int, data []byte) Page {
@@ -982,6 +1005,8 @@ func (t *jsonType) Kind() Kind { return ByteArray }
 
 func (t *jsonType) Length() int { return 0 }
 
+func (t *jsonType) EstimateSize(n int) int64 { return 10 * int64(n) }
+
 func (t *jsonType) Compare(a, b Value) int {
 	return bytes.Compare(a.ByteArray(), b.ByteArray())
 }
@@ -1010,8 +1035,8 @@ func (t *jsonType) NewDictionary(columnIndex, numValues int, data []byte) Dictio
 	return newByteArrayDictionary(t, makeColumnIndex(columnIndex), makeNumValues(numValues), data)
 }
 
-func (t *jsonType) NewColumnBuffer(columnIndex, bufferSize int) ColumnBuffer {
-	return newByteArrayColumnBuffer(t, makeColumnIndex(columnIndex), bufferSize)
+func (t *jsonType) NewColumnBuffer(columnIndex, numValues int) ColumnBuffer {
+	return newByteArrayColumnBuffer(t, makeColumnIndex(columnIndex), makeNumValues(numValues))
 }
 
 func (t *jsonType) NewPage(columnIndex, numValues int, data []byte) Page {
@@ -1039,6 +1064,8 @@ func (t *bsonType) Kind() Kind { return ByteArray }
 
 func (t *bsonType) Length() int { return 0 }
 
+func (t *bsonType) EstimateSize(n int) int64 { return 10 * int64(n) }
+
 func (t *bsonType) Compare(a, b Value) int {
 	return bytes.Compare(a.ByteArray(), b.ByteArray())
 }
@@ -1067,8 +1094,8 @@ func (t *bsonType) NewDictionary(columnIndex, numValues int, data []byte) Dictio
 	return newByteArrayDictionary(t, makeColumnIndex(columnIndex), makeNumValues(numValues), data)
 }
 
-func (t *bsonType) NewColumnBuffer(columnIndex, bufferSize int) ColumnBuffer {
-	return newByteArrayColumnBuffer(t, makeColumnIndex(columnIndex), bufferSize)
+func (t *bsonType) NewColumnBuffer(columnIndex, numValues int) ColumnBuffer {
+	return newByteArrayColumnBuffer(t, makeColumnIndex(columnIndex), makeNumValues(numValues))
 }
 
 func (t *bsonType) NewPage(columnIndex, numValues int, data []byte) Page {
@@ -1096,6 +1123,8 @@ func (t *dateType) Kind() Kind { return Int32 }
 
 func (t *dateType) Length() int { return 32 }
 
+func (t *dateType) EstimateSize(n int) int64 { return 4 * int64(n) }
+
 func (t *dateType) Compare(a, b Value) int { return compareInt32(a.Int32(), b.Int32()) }
 
 func (t *dateType) ColumnOrder() *format.ColumnOrder {
@@ -1116,8 +1145,8 @@ func (t *dateType) NewColumnIndexer(sizeLimit int) ColumnIndexer {
 	return newInt32ColumnIndexer()
 }
 
-func (t *dateType) NewColumnBuffer(columnIndex, bufferSize int) ColumnBuffer {
-	return newInt32ColumnBuffer(t, makeColumnIndex(columnIndex), bufferSize)
+func (t *dateType) NewColumnBuffer(columnIndex, numValues int) ColumnBuffer {
+	return newInt32ColumnBuffer(t, makeColumnIndex(columnIndex), makeNumValues(numValues))
 }
 
 func (t *dateType) NewDictionary(columnIndex, numValues int, data []byte) Dictionary {
@@ -1209,6 +1238,14 @@ func (t *timeType) Length() int {
 	}
 }
 
+func (t *timeType) EstimateSize(n int) int64 {
+	if t.useInt32() {
+		return 4 * int64(n)
+	} else {
+		return 8 * int64(n)
+	}
+}
+
 func (t *timeType) Compare(a, b Value) int {
 	if t.useInt32() {
 		return compareInt32(a.Int32(), b.Int32())
@@ -1252,11 +1289,11 @@ func (t *timeType) NewColumnIndexer(sizeLimit int) ColumnIndexer {
 	}
 }
 
-func (t *timeType) NewColumnBuffer(columnIndex, bufferSize int) ColumnBuffer {
+func (t *timeType) NewColumnBuffer(columnIndex, numValues int) ColumnBuffer {
 	if t.useInt32() {
-		return newInt32ColumnBuffer(t, makeColumnIndex(columnIndex), bufferSize)
+		return newInt32ColumnBuffer(t, makeColumnIndex(columnIndex), makeNumValues(numValues))
 	} else {
-		return newInt64ColumnBuffer(t, makeColumnIndex(columnIndex), bufferSize)
+		return newInt64ColumnBuffer(t, makeColumnIndex(columnIndex), makeNumValues(numValues))
 	}
 }
 
@@ -1307,6 +1344,8 @@ func (t *timestampType) Kind() Kind { return Int64 }
 
 func (t *timestampType) Length() int { return 64 }
 
+func (t *timestampType) EstimateSize(n int) int64 { return 8 * int64(n) }
+
 func (t *timestampType) Compare(a, b Value) int { return compareInt64(a.Int64(), b.Int64()) }
 
 func (t *timestampType) ColumnOrder() *format.ColumnOrder { return &typeDefinedColumnOrder }
@@ -1332,8 +1371,8 @@ func (t *timestampType) NewColumnIndexer(sizeLimit int) ColumnIndexer {
 	return newInt64ColumnIndexer()
 }
 
-func (t *timestampType) NewColumnBuffer(columnIndex, bufferSize int) ColumnBuffer {
-	return newInt64ColumnBuffer(t, makeColumnIndex(columnIndex), bufferSize)
+func (t *timestampType) NewColumnBuffer(columnIndex, numValues int) ColumnBuffer {
+	return newInt64ColumnBuffer(t, makeColumnIndex(columnIndex), makeNumValues(numValues))
 }
 
 func (t *timestampType) NewDictionary(columnIndex, numValues int, data []byte) Dictionary {
@@ -1371,6 +1410,8 @@ func (t *listType) Kind() Kind { panic("cannot call Kind on parquet LIST type")
 
 func (t *listType) Length() int { return 0 }
 
+func (t *listType) EstimateSize(int) int64 { return 0 }
+
 func (t *listType) Compare(Value, Value) int { panic("cannot compare values on parquet LIST type") }
 
 func (t *listType) ColumnOrder() *format.ColumnOrder { return nil }
@@ -1433,6 +1474,8 @@ func (t *mapType) Kind() Kind { panic("cannot call Kind on parquet MAP type") }
 
 func (t *mapType) Length() int { return 0 }
 
+func (t *mapType) EstimateSize(int) int64 { return 0 }
+
 func (t *mapType) Compare(Value, Value) int { panic("cannot compare values on parquet MAP type") }
 
 func (t *mapType) ColumnOrder() *format.ColumnOrder { return nil }
@@ -1479,6 +1522,8 @@ func (t *nullType) Kind() Kind { return -1 }
 
 func (t *nullType) Length() int { return 0 }
 
+func (t *nullType) EstimateSize(int) int64 { return 0 }
+
 func (t *nullType) Compare(Value, Value) int { panic("cannot compare values on parquet NULL type") }
 
 func (t *nullType) ColumnOrder() *format.ColumnOrder { return nil }
@@ -1553,6 +1598,8 @@ func (groupType) Decode(_, _ []byte, _ encoding.Encoding) ([]byte, error) {
 
 func (groupType) Length() int { return 0 }
 
+func (groupType) EstimateSize(int) int64 { return 0 }
+
 func (groupType) ColumnOrder() *format.ColumnOrder { return nil }
 
 func (groupType) PhysicalType() *format.Type { return nil }
diff --git a/writer.go b/writer.go
index e82dd6a..aecac13 100644
--- a/writer.go
+++ b/writer.go
@@ -7,6 +7,7 @@ import (
 	"fmt"
 	"hash/crc32"
 	"io"
+	"math"
 	"math/bits"
 	"sort"
 
@@ -927,7 +928,10 @@ func (c *writerColumn) resizeBloomFilter(numValues int64) {
 }
 
 func (c *writerColumn) newColumnBuffer() ColumnBuffer {
-	column := c.columnType.NewColumnBuffer(int(c.bufferIndex), int(c.bufferSize))
+	columnBufferCapacity := sort.Search(math.MaxInt32, func(i int) bool {
+		return c.columnType.EstimateSize(i) >= int64(c.bufferSize)
+	})
+	column := c.columnType.NewColumnBuffer(int(c.bufferIndex), columnBufferCapacity)
 	switch {
 	case c.maxRepetitionLevel > 0:
 		column = newRepeatedColumnBuffer(column, c.maxRepetitionLevel, c.maxDefinitionLevel, nullsGoLast)


Test patch:
diff --git a/buffer_test.go b/buffer_test.go
index bb62989..924b278 100644
--- a/buffer_test.go
+++ b/buffer_test.go
@@ -206,7 +206,7 @@ func TestBuffer(t *testing.T) {
 
 									options := []parquet.RowGroupOption{
 										schema,
-										parquet.ColumnBufferSize(1024),
+										parquet.ColumnBufferCapacity(100),
 									}
 									if ordering.sorting != nil {
 										options = append(options, parquet.SortingColumns(ordering.sorting))
diff --git a/encoding/encoding_test.go b/encoding/encoding_test.go
index cb4424d..800f42f 100644
--- a/encoding/encoding_test.go
+++ b/encoding/encoding_test.go
@@ -5,6 +5,7 @@ import (
 	"encoding/binary"
 	"io"
 	"math"
+	"math/bits"
 	"math/rand"
 	"testing"
 	"time"
@@ -16,7 +17,6 @@ import (
 	"github.com/segmentio/parquet-go/encoding/delta"
 	"github.com/segmentio/parquet-go/encoding/plain"
 	"github.com/segmentio/parquet-go/encoding/rle"
-	"github.com/segmentio/parquet-go/internal/bits"
 	"github.com/segmentio/parquet-go/internal/unsafecast"
 )
 
@@ -306,7 +306,7 @@ func testLevelsEncoding(t *testing.T, e encoding.Encoding) {
 	values := []byte{}
 
 	for _, test := range levelsTests {
-		setBitWidth(e, bits.MaxLen8(unsafecast.BytesToInt8(test)))
+		setBitWidth(e, maxLenInt8(unsafecast.BytesToInt8(test)))
 
 		t.Run("", func(t *testing.T) {
 			var err error
@@ -325,7 +325,7 @@ func testInt32Encoding(t *testing.T, e encoding.Encoding) {
 	values := []byte{}
 
 	for _, test := range int32Tests {
-		setBitWidth(e, bits.MaxLen32(test))
+		setBitWidth(e, maxLenInt32(test))
 
 		t.Run("", func(t *testing.T) {
 			var err error
@@ -345,7 +345,7 @@ func testInt64Encoding(t *testing.T, e encoding.Encoding) {
 	values := []byte{}
 
 	for _, test := range int64Tests {
-		setBitWidth(e, bits.MaxLen64(test))
+		setBitWidth(e, maxLenInt64(test))
 
 		t.Run("", func(t *testing.T) {
 			var err error
@@ -583,7 +583,7 @@ func benchmarkEncodeLevels(b *testing.B, e encoding.Encoding) {
 	testCanEncodeLevels(b, e)
 	buffer := make([]byte, 0)
 	values := generateLevelValues(benchmarkNumValues, newRand())
-	setBitWidth(e, bits.MaxLen8(unsafecast.BytesToInt8(values)))
+	setBitWidth(e, maxLenInt8(unsafecast.BytesToInt8(values)))
 
 	reportThroughput(b, benchmarkNumValues, len(values), func() {
 		benchmarkZeroAllocsPerRun(b, func() {
@@ -596,7 +596,7 @@ func benchmarkEncodeInt32(b *testing.B, e encoding.Encoding) {
 	testCanEncodeInt32(b, e)
 	buffer := make([]byte, 0)
 	values := generateInt32Values(benchmarkNumValues, newRand())
-	setBitWidth(e, bits.MaxLen32(unsafecast.BytesToInt32(values)))
+	setBitWidth(e, maxLenInt32(unsafecast.BytesToInt32(values)))
 
 	reportThroughput(b, benchmarkNumValues, len(values), func() {
 		benchmarkZeroAllocsPerRun(b, func() {
@@ -609,7 +609,7 @@ func benchmarkEncodeInt64(b *testing.B, e encoding.Encoding) {
 	testCanEncodeInt64(b, e)
 	buffer := make([]byte, 0)
 	values := generateInt64Values(benchmarkNumValues, newRand())
-	setBitWidth(e, bits.MaxLen64(unsafecast.BytesToInt64(values)))
+	setBitWidth(e, maxLenInt64(unsafecast.BytesToInt64(values)))
 
 	reportThroughput(b, benchmarkNumValues, len(values), func() {
 		benchmarkZeroAllocsPerRun(b, func() {
@@ -733,7 +733,7 @@ func benchmarkDecodeLevels(b *testing.B, e encoding.Encoding) {
 	testCanEncodeLevels(b, e)
 	values := generateLevelValues(benchmarkNumValues, newRand())
 	output := make([]byte, 0)
-	setBitWidth(e, bits.MaxLen8(unsafecast.BytesToInt8(values)))
+	setBitWidth(e, maxLenInt8(unsafecast.BytesToInt8(values)))
 	buffer, _ := e.EncodeLevels(nil, values)
 
 	reportThroughput(b, benchmarkNumValues, len(values), func() {
@@ -747,7 +747,7 @@ func benchmarkDecodeInt32(b *testing.B, e encoding.Encoding) {
 	testCanEncodeInt32(b, e)
 	values := generateInt32Values(benchmarkNumValues, newRand())
 	output := make([]byte, 0)
-	setBitWidth(e, bits.MaxLen32(unsafecast.BytesToInt32(values)))
+	setBitWidth(e, maxLenInt32(unsafecast.BytesToInt32(values)))
 	buffer, _ := e.EncodeInt32(nil, values)
 
 	reportThroughput(b, benchmarkNumValues, len(values), func() {
@@ -761,7 +761,7 @@ func benchmarkDecodeInt64(b *testing.B, e encoding.Encoding) {
 	testCanEncodeInt64(b, e)
 	values := generateInt64Values(benchmarkNumValues, newRand())
 	output := make([]byte, 0)
-	setBitWidth(e, bits.MaxLen64(unsafecast.BytesToInt64(values)))
+	setBitWidth(e, maxLenInt64(unsafecast.BytesToInt64(values)))
 	buffer, _ := e.EncodeInt64(nil, values)
 
 	reportThroughput(b, benchmarkNumValues, len(values), func() {
@@ -904,3 +904,33 @@ func generateFixedLenByteArrayValues(n int, r *rand.Rand, size int) []byte {
 	io.ReadFull(r, values)
 	return values
 }
+
+func maxLenInt8(data []int8) int {
+	max := 0
+	for _, v := range data {
+		if n := bits.Len8(uint8(v)); n > max {
+			max = n
+		}
+	}
+	return max
+}
+
+func maxLenInt32(data []int32) int {
+	max := 0
+	for _, v := range data {
+		if n := bits.Len32(uint32(v)); n > max {
+			max = n
+		}
+	}
+	return max
+}
+
+func maxLenInt64(data []int64) int {
+	max := 0
+	for _, v := range data {
+		if n := bits.Len64(uint64(v)); n > max {
+			max = n
+		}
+	}
+	return max
+}
diff --git a/internal/bits/add_test.go b/internal/bits/add_test.go
deleted file mode 100644
index d541732..0000000
--- a/internal/bits/add_test.go
+++ /dev/null
@@ -1,75 +0,0 @@
-package bits_test
-
-import (
-	"math"
-	"testing"
-
-	"github.com/segmentio/parquet-go/internal/bits"
-	"github.com/segmentio/parquet-go/internal/quick"
-)
-
-func TestAddInt32(t *testing.T) {
-	err := quick.Check(func(values []int32) bool {
-		const add = math.MaxInt32
-
-		valuesAndAdd := make([]int32, len(values))
-		copy(valuesAndAdd, values)
-		bits.AddInt32(valuesAndAdd, add)
-
-		for i := range values {
-			x := valuesAndAdd[i]
-			y := values[i] + add
-			if x != y {
-				t.Errorf("unexpected value at index %d: got=%d want=%d", i, x, y)
-				return false
-			}
-		}
-
-		return true
-	})
-	if err != nil {
-		t.Error(err)
-	}
-}
-
-func TestAddInt64(t *testing.T) {
-	err := quick.Check(func(values []int64) bool {
-		const add = math.MaxInt64
-
-		valuesAndAdd := make([]int64, len(values))
-		copy(valuesAndAdd, values)
-		bits.AddInt64(valuesAndAdd, add)
-
-		for i := range values {
-			x := valuesAndAdd[i]
-			y := values[i] + add
-			if x != y {
-				t.Errorf("unexpected value at index %d: got=%d want=%d", i, x, y)
-				return false
-			}
-		}
-
-		return true
-	})
-	if err != nil {
-		t.Error(err)
-	}
-}
-
-func BenchmarkAddInt32(b *testing.B) {
-	forEachBenchmarkBufferSize(b, func(b *testing.B, bufferSize int) {
-		values := make([]int32, bufferSize/4)
-		for i := 0; i < b.N; i++ {
-			bits.AddInt32(values, 1)
-		}
-	})
-}
-
-func BenchmarkAddInt64(b *testing.B) {
-	forEachBenchmarkBufferSize(b, func(b *testing.B, bufferSize int) {
-		values := make([]int64, bufferSize/8)
-		for i := 0; i < b.N; i++ {
-			bits.AddInt64(values, 1)
-		}
-	})
-}
diff --git a/internal/bits/bits_test.go b/internal/bits/bits_test.go
deleted file mode 100644
index 39f478e..0000000
--- a/internal/bits/bits_test.go
+++ /dev/null
@@ -1,112 +0,0 @@
-package bits_test
-
-import (
-	"fmt"
-	"testing"
-
-	"github.com/segmentio/parquet-go/internal/bits"
-)
-
-func TestNearestPowerOfTwo(t *testing.T) {
-	for _, test := range []struct {
-		input  uint32
-		output uint32
-	}{
-		{input: 0, output: 0},
-		{input: 1, output: 1},
-		{input: 2, output: 2},
-		{input: 3, output: 4},
-		{input: 4, output: 4},
-		{input: 5, output: 8},
-		{input: 6, output: 8},
-		{input: 7, output: 8},
-		{input: 8, output: 8},
-		{input: 30, output: 32},
-	} {
-		t.Run(fmt.Sprintf("NearestPowerOfTwo(%d)", test.input), func(t *testing.T) {
-			if nextPow2 := bits.NearestPowerOfTwo32(test.input); nextPow2 != test.output {
-				t.Errorf("wrong 32 bits value: want=%d got=%d", test.output, nextPow2)
-			}
-			if nextPow2 := bits.NearestPowerOfTwo64(uint64(test.input)); nextPow2 != uint64(test.output) {
-				t.Errorf("wrong 64 bits value: want=%d got=%d", test.output, nextPow2)
-			}
-		})
-	}
-}
-
-func TestBitCount(t *testing.T) {
-	for _, test := range []struct {
-		bytes int
-		bits  uint
-	}{
-		{bytes: 0, bits: 0},
-		{bytes: 1, bits: 8},
-		{bytes: 2, bits: 16},
-		{bytes: 3, bits: 24},
-		{bytes: 4, bits: 32},
-		{bytes: 5, bits: 40},
-		{bytes: 6, bits: 48},
-	} {
-		t.Run(fmt.Sprintf("BitCount(%d)", test.bytes), func(t *testing.T) {
-			if bits := bits.BitCount(test.bytes); bits != test.bits {
-				t.Errorf("wrong bit count: want=%d got=%d", test.bits, bits)
-			}
-		})
-	}
-}
-
-func TestByteCount(t *testing.T) {
-	for _, test := range []struct {
-		bits  uint
-		bytes int
-	}{
-		{bits: 0, bytes: 0},
-		{bits: 1, bytes: 1},
-		{bits: 7, bytes: 1},
-		{bits: 8, bytes: 1},
-		{bits: 9, bytes: 2},
-		{bits: 30, bytes: 4},
-		{bits: 63, bytes: 8},
-	} {
-		t.Run(fmt.Sprintf("ByteCount(%d)", test.bits), func(t *testing.T) {
-			if bytes := bits.ByteCount(test.bits); bytes != test.bytes {
-				t.Errorf("wrong byte count: want=%d got=%d", test.bytes, bytes)
-			}
-		})
-	}
-}
-
-func TestRound(t *testing.T) {
-	for _, test := range []struct {
-		bits  uint
-		round uint
-	}{
-		{bits: 0, round: 0},
-		{bits: 1, round: 8},
-		{bits: 8, round: 8},
-		{bits: 9, round: 16},
-		{bits: 30, round: 32},
-		{bits: 63, round: 64},
-	} {
-		t.Run(fmt.Sprintf("Round(%d)", test.bits), func(t *testing.T) {
-			if round := bits.Round(test.bits); round != test.round {
-				t.Errorf("wrong rounded bit count: want=%d got=%d", test.round, round)
-			}
-		})
-	}
-}
-
-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)
-		})
-	}
-}
diff --git a/internal/bits/maxlen_test.go b/internal/bits/maxlen_test.go
deleted file mode 100644
index 9d7a582..0000000
--- a/internal/bits/maxlen_test.go
+++ /dev/null
@@ -1,119 +0,0 @@
-package bits_test
-
-import (
-	"testing"
-
-	"github.com/segmentio/parquet-go/internal/bits"
-)
-
-func TestMaxLen8(t *testing.T) {
-	for _, test := range []struct {
-		data   []int8
-		maxlen int
-	}{
-		{
-			data:   nil,
-			maxlen: 0,
-		},
-
-		{
-			data:   []int8{0, 0, 0, 0, 0},
-			maxlen: 0,
-		},
-
-		{
-			data:   []int8{10, 0, -1, 0},
-			maxlen: 8,
-		},
-	} {
-		t.Run("", func(t *testing.T) {
-			if maxlen := bits.MaxLen8(test.data); maxlen != test.maxlen {
-				t.Errorf("want=%d got=%d", test.maxlen, maxlen)
-			}
-		})
-	}
-}
-
-func TestMaxLen16(t *testing.T) {
-	for _, test := range []struct {
-		data   []int16
-		maxlen int
-	}{
-		{
-			data:   nil,
-			maxlen: 0,
-		},
-
-		{
-			data:   []int16{0, 0, 0, 0, 0},
-			maxlen: 0,
-		},
-
-		{
-			data:   []int16{10, 0, -1, 0},
-			maxlen: 16,
-		},
-	} {
-		t.Run("", func(t *testing.T) {
-			if maxlen := bits.MaxLen16(test.data); maxlen != test.maxlen {
-				t.Errorf("want=%d got=%d", test.maxlen, maxlen)
-			}
-		})
-	}
-}
-
-func TestMaxLen32(t *testing.T) {
-	for _, test := range []struct {
-		data   []int32
-		maxlen int
-	}{
-		{
-			data:   nil,
-			maxlen: 0,
-		},
-
-		{
-			data:   []int32{0, 0, 0, 0, 0},
-			maxlen: 0,
-		},
-
-		{
-			data:   []int32{0x00000010, 0x0000F000, 0x00000990, 0x00000000},
-			maxlen: 16,
-		},
-	} {
-		t.Run("", func(t *testing.T) {
-			if maxlen := bits.MaxLen32(test.data); maxlen != test.maxlen {
-				t.Errorf("want=%d got=%d", test.maxlen, maxlen)
-			}
-		})
-	}
-}
-
-func TestMaxLen64(t *testing.T) {
-	for _, test := range []struct {
-		data   []int64
-		maxlen int
-	}{
-		{
-			data:   nil,
-			maxlen: 0,
-		},
-
-		{
-			data:   []int64{0, 0, 0, 0, 0},
-			maxlen: 0,
-		},
-
-		{
-			data:   []int64{0x00000010, 0x0000F000, 0x00000990, 0x00000000},
-			maxlen: 16,
-		},
-	} {
-		t.Run("", func(t *testing.T) {
-			if maxlen := bits.MaxLen64(test.data); maxlen != test.maxlen {
-				t.Errorf("want=%d got=%d", test.maxlen, maxlen)
-			}
-		})
-	}
-}
diff --git a/level_test.go b/internal/bytealg/broadcast_test.go
similarity index 73%
rename from level_test.go
rename to internal/bytealg/broadcast_test.go
index 050748e..a0188e8 100644
--- a/level_test.go
+++ b/internal/bytealg/broadcast_test.go
@@ -1,11 +1,13 @@
-package parquet
+package bytealg_test
 
 import (
 	"fmt"
 	"testing"
+
+	"github.com/segmentio/parquet-go/internal/bytealg"
 )
 
-func TestMemset(t *testing.T) {
+func TestBroadcast(t *testing.T) {
 	const N = 100_0000
 	buffer := make([]byte, N)
 
@@ -17,7 +19,7 @@ func TestMemset(t *testing.T) {
 				b[i] = 0
 			}
 
-			memset(b, 42)
+			bytealg.Broadcast(b, 42)
 
 			for i, c := range b {
 				if c != 42 {
@@ -28,13 +30,13 @@ func TestMemset(t *testing.T) {
 	}
 }
 
-func BenchmarkMemset(b *testing.B) {
+func BenchmarkBroadcast(b *testing.B) {
 	for _, size := range []int{0, 10, 100, 1000, 10_000} {
 		b.Run(fmt.Sprintf("size=%d", size), func(b *testing.B) {
 			data := make([]byte, size)
 
 			for i := 0; i < b.N; i++ {
-				memset(data, 1)
+				bytealg.Broadcast(data, 1)
 			}
 
 			b.SetBytes(int64(size))
diff --git a/internal/bytealg/bytealg_test.go b/internal/bytealg/bytealg_test.go
new file mode 100644
index 0000000..1dacc1b
--- /dev/null
+++ b/internal/bytealg/bytealg_test.go
@@ -0,0 +1,21 @@
+package bytealg_test
+
+import (
+	"fmt"
+	"testing"
+)
+
+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)
+		})
+	}
+}
diff --git a/internal/bits/count_test.go b/internal/bytealg/count_test.go
similarity index 73%
rename from internal/bits/count_test.go
rename to internal/bytealg/count_test.go
index 1add19c..123a99a 100644
--- a/internal/bits/count_test.go
+++ b/internal/bytealg/count_test.go
@@ -1,19 +1,19 @@
-package bits_test
+package bytealg_test
 
 import (
 	"bytes"
 	"testing"
 
-	"github.com/segmentio/parquet-go/internal/bits"
+	"github.com/segmentio/parquet-go/internal/bytealg"
 	"github.com/segmentio/parquet-go/internal/quick"
 )
 
-func TestCountByte(t *testing.T) {
+func TestCount(t *testing.T) {
 	err := quick.Check(func(data []byte) bool {
 		data = bytes.Repeat(data, 8)
 		for _, c := range data {
 			n1 := bytes.Count(data, []byte{c})
-			n2 := bits.CountByte(data, c)
+			n2 := bytealg.Count(data, c)
 			if n1 != n2 {
 				t.Errorf("got=%d want=%d", n2, n1)
 				return false
@@ -26,14 +26,14 @@ func TestCountByte(t *testing.T) {
 	}
 }
 
-func BenchmarkCountByte(b *testing.B) {
+func BenchmarkCount(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)
+			bytealg.Count(data, 0)
 		}
 	})
 }
diff --git a/internal/bits/order_test.go b/order_test.go
similarity index 88%
rename from internal/bits/order_test.go
rename to order_test.go
index e3cff27..d5de149 100644
--- a/internal/bits/order_test.go
+++ b/order_test.go
@@ -1,11 +1,10 @@
-package bits_test
+package parquet
 
 import (
 	"bytes"
 	"sort"
 	"testing"
 
-	"github.com/segmentio/parquet-go/internal/bits"
 	"github.com/segmentio/parquet-go/internal/quick"
 )
 
@@ -57,20 +56,14 @@ func (v bytesOrder) Len() int           { return len(v) }
 func (v bytesOrder) Less(i, j int) bool { return bytes.Compare(v[i], v[j]) < 0 }
 func (v bytesOrder) Swap(i, j int)      { v[i], v[j] = v[j], v[i] }
 
-const (
-	ascending  = "ascending"
-	descending = "descending"
-	undefined  = "undefined"
-)
-
 func orderingName(ordering int) string {
 	switch {
 	case isAscending(ordering):
-		return ascending
+		return "ascending"
 	case isDescending(ordering):
-		return descending
+		return "descending"
 	default:
-		return undefined
+		return "undefined"
 	}
 }
 
@@ -95,17 +88,17 @@ func checkOrdering(t *testing.T, set sort.Interface, ordering int) bool {
 	switch {
 	case isOrdered(set):
 		if !isAscending(ordering) {
-			t.Errorf("got=%s want=%s", orderingName(ordering), ascending)
+			t.Errorf("got=%s want=ascending", orderingName(ordering))
 			return false
 		}
 	case isOrdered(sort.Reverse(set)):
 		if !isDescending(ordering) {
-			t.Errorf("got=%s want=%s", orderingName(ordering), descending)
+			t.Errorf("got=%s want=descending", orderingName(ordering))
 			return false
 		}
 	default:
 		if !isUndefined(ordering) {
-			t.Errorf("got=%s want=%s", orderingName(ordering), undefined)
+			t.Errorf("got=%s want=undefined", orderingName(ordering))
 			return false
 		}
 	}
@@ -114,7 +107,7 @@ func checkOrdering(t *testing.T, set sort.Interface, ordering int) bool {
 
 func TestOrderOfBool(t *testing.T) {
 	check := func(values []bool) bool {
-		return checkOrdering(t, boolOrder(values), bits.OrderOfBool(values))
+		return checkOrdering(t, boolOrder(values), orderOfBool(values))
 	}
 	err := quick.Check(func(values []bool) bool {
 		if !check(values) {
@@ -137,7 +130,7 @@ func TestOrderOfBool(t *testing.T) {
 
 func TestOrderOfInt32(t *testing.T) {
 	check := func(values []int32) bool {
-		return checkOrdering(t, int32Order(values), bits.OrderOfInt32(values))
+		return checkOrdering(t, int32Order(values), orderOfInt32(values))
 	}
 	err := quick.Check(func(values []int32) bool {
 		if !check(values) {
@@ -175,7 +168,7 @@ func TestOrderOfInt32(t *testing.T) {
 
 func TestOrderOfInt64(t *testing.T) {
 	check := func(values []int64) bool {
-		return checkOrdering(t, int64Order(values), bits.OrderOfInt64(values))
+		return checkOrdering(t, int64Order(values), orderOfInt64(values))
 	}
 	err := quick.Check(func(values []int64) bool {
 		if !check(values) {
@@ -209,7 +202,7 @@ func TestOrderOfInt64(t *testing.T) {
 
 func TestOrderOfUint32(t *testing.T) {
 	check := func(values []uint32) bool {
-		return checkOrdering(t, uint32Order(values), bits.OrderOfUint32(values))
+		return checkOrdering(t, uint32Order(values), orderOfUint32(values))
 	}
 	err := quick.Check(func(values []uint32) bool {
 		if !check(values) {
@@ -243,7 +236,7 @@ func TestOrderOfUint32(t *testing.T) {
 
 func TestOrderOfUint64(t *testing.T) {
 	check := func(values []uint64) bool {
-		return checkOrdering(t, uint64Order(values), bits.OrderOfUint64(values))
+		return checkOrdering(t, uint64Order(values), orderOfUint64(values))
 	}
 	err := quick.Check(func(values []uint64) bool {
 		if !check(values) {
@@ -277,7 +270,7 @@ func TestOrderOfUint64(t *testing.T) {
 
 func TestOrderOfFloat32(t *testing.T) {
 	check := func(values []float32) bool {
-		return checkOrdering(t, float32Order(values), bits.OrderOfFloat32(values))
+		return checkOrdering(t, float32Order(values), orderOfFloat32(values))
 	}
 	err := quick.Check(func(values []float32) bool {
 		if !check(values) {
@@ -311,7 +304,7 @@ func TestOrderOfFloat32(t *testing.T) {
 
 func TestOrderOfFloat64(t *testing.T) {
 	check := func(values []float64) bool {
-		return checkOrdering(t, float64Order(values), bits.OrderOfFloat64(values))
+		return checkOrdering(t, float64Order(values), orderOfFloat64(values))
 	}
 	err := quick.Check(func(values []float64) bool {
 		if !check(values) {
@@ -345,7 +338,7 @@ func TestOrderOfFloat64(t *testing.T) {
 
 func TestOrderOfBytes(t *testing.T) {
 	check := func(values [][]byte) bool {
-		return checkOrdering(t, bytesOrder(values), bits.OrderOfBytes(values))
+		return checkOrdering(t, bytesOrder(values), orderOfBytes(values))
 	}
 	err := quick.Check(func(values [][16]byte) bool {
 		slices := make([][]byte, len(values))
@@ -374,7 +367,7 @@ func BenchmarkOrderOfBool(b *testing.B) {
 	forEachBenchmarkBufferSize(b, func(b *testing.B, bufferSize int) {
 		values := make([]bool, bufferSize/1)
 		for i := 0; i < b.N; i++ {
-			bits.OrderOfBool(values)
+			orderOfBool(values)
 		}
 	})
 }
@@ -383,7 +376,7 @@ func BenchmarkOrderOfInt32(b *testing.B) {
 	forEachBenchmarkBufferSize(b, func(b *testing.B, bufferSize int) {
 		values := make([]int32, bufferSize/4)
 		for i := 0; i < b.N; i++ {
-			bits.OrderOfInt32(values)
+			orderOfInt32(values)
 		}
 	})
 }
@@ -392,7 +385,7 @@ func BenchmarkOrderOfInt64(b *testing.B) {
 	forEachBenchmarkBufferSize(b, func(b *testing.B, bufferSize int) {
 		values := make([]int64, bufferSize/8)
 		for i := 0; i < b.N; i++ {
-			bits.OrderOfInt64(values)
+			orderOfInt64(values)
 		}
 	})
 }
@@ -401,7 +394,7 @@ func BenchmarkOrderOfUint32(b *testing.B) {
 	forEachBenchmarkBufferSize(b, func(b *testing.B, bufferSize int) {
 		values := make([]uint32, bufferSize/4)
 		for i := 0; i < b.N; i++ {
-			bits.OrderOfUint32(values)
+			orderOfUint32(values)
 		}
 	})
 }
@@ -410,7 +403,7 @@ func BenchmarkOrderOfUint64(b *testing.B) {
 	forEachBenchmarkBufferSize(b, func(b *testing.B, bufferSize int) {
 		values := make([]uint64, bufferSize/8)
 		for i := 0; i < b.N; i++ {
-			bits.OrderOfUint64(values)
+			orderOfUint64(values)
 		}
 	})
 }
@@ -419,7 +412,7 @@ func BenchmarkOrderOfFloat32(b *testing.B) {
 	forEachBenchmarkBufferSize(b, func(b *testing.B, bufferSize int) {
 		values := make([]float32, bufferSize/4)
 		for i := 0; i < b.N; i++ {
-			bits.OrderOfFloat32(values)
+			orderOfFloat32(values)
 		}
 	})
 }
@@ -428,7 +421,7 @@ func BenchmarkOrderOfFloat64(b *testing.B) {
 	forEachBenchmarkBufferSize(b, func(b *testing.B, bufferSize int) {
 		values := make([]float64, bufferSize/8)
 		for i := 0; i < b.N; i++ {
-			bits.OrderOfFloat64(values)
+			orderOfFloat64(values)
 		}
 	})
 }
@@ -441,7 +434,7 @@ func BenchmarkOrderOfBytes(b *testing.B) {
 			values[i] = data[i*16 : (i+1)*16]
 		}
 		for i := 0; i < b.N; i++ {
-			bits.OrderOfBytes(values)
+			orderOfBytes(values)
 		}
 	})
 }
diff --git a/internal/bits/minmax_test.go b/page_bounds_test.go
similarity index 68%
rename from internal/bits/minmax_test.go
rename to page_bounds_test.go
index d275a90..0713cb3 100644
--- a/internal/bits/minmax_test.go
+++ b/page_bounds_test.go
@@ -1,48 +1,30 @@
-package bits_test
+package parquet
 
 import (
 	"bytes"
+	"fmt"
 	"math/rand"
 	"testing"
 
-	"github.com/segmentio/parquet-go/internal/bits"
 	"github.com/segmentio/parquet-go/internal/quick"
-	"github.com/segmentio/parquet-go/internal/unsafecast"
 )
 
-const scaleFactor = 6000
-
-func TestMinMaxBool(t *testing.T) {
-	err := quick.Check(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)
-	}
+var benchmarkBufferSizes = [...]int{
+	4 * 1024,
+	256 * 1024,
+	2048 * 1024,
+}
 
-	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 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)
+		})
 	}
 }
 
-func TestMinMaxInt32(t *testing.T) {
+func TestBoundsInt32(t *testing.T) {
 	err := quick.Check(func(values []int32) bool {
 		min := int32(0)
 		max := int32(0)
@@ -58,7 +40,7 @@ func TestMinMaxInt32(t *testing.T) {
 				}
 			}
 		}
-		minValue, maxValue := bits.MinMaxInt32(values)
+		minValue, maxValue := boundsInt32(values)
 		return min == minValue && max == maxValue
 	})
 	if err != nil {
@@ -66,7 +48,7 @@ func TestMinMaxInt32(t *testing.T) {
 	}
 }
 
-func TestMinMaxInt64(t *testing.T) {
+func TestBoundsInt64(t *testing.T) {
 	err := quick.Check(func(values []int64) bool {
 		min := int64(0)
 		max := int64(0)
@@ -82,7 +64,7 @@ func TestMinMaxInt64(t *testing.T) {
 				}
 			}
 		}
-		minValue, maxValue := bits.MinMaxInt64(values)
+		minValue, maxValue := boundsInt64(values)
 		return min == minValue && max == maxValue
 	})
 	if err != nil {
@@ -90,7 +72,7 @@ func TestMinMaxInt64(t *testing.T) {
 	}
 }
 
-func TestMinMaxUint32(t *testing.T) {
+func TestBoundsUint32(t *testing.T) {
 	err := quick.Check(func(values []uint32) bool {
 		min := uint32(0)
 		max := uint32(0)
@@ -106,7 +88,7 @@ func TestMinMaxUint32(t *testing.T) {
 				}
 			}
 		}
-		minValue, maxValue := bits.MinMaxUint32(values)
+		minValue, maxValue := boundsUint32(values)
 		return min == minValue && max == maxValue
 	})
 	if err != nil {
@@ -114,7 +96,7 @@ func TestMinMaxUint32(t *testing.T) {
 	}
 }
 
-func TestMinMaxUint64(t *testing.T) {
+func TestBoundsUint64(t *testing.T) {
 	err := quick.Check(func(values []uint64) bool {
 		min := uint64(0)
 		max := uint64(0)
@@ -130,7 +112,7 @@ func TestMinMaxUint64(t *testing.T) {
 				}
 			}
 		}
-		minValue, maxValue := bits.MinMaxUint64(values)
+		minValue, maxValue := boundsUint64(values)
 		return min == minValue && max == maxValue
 	})
 	if err != nil {
@@ -138,7 +120,7 @@ func TestMinMaxUint64(t *testing.T) {
 	}
 }
 
-func TestMinMaxFloat32(t *testing.T) {
+func TestBoundsFloat32(t *testing.T) {
 	err := quick.Check(func(values []float32) bool {
 		min := float32(0)
 		max := float32(0)
@@ -154,7 +136,7 @@ func TestMinMaxFloat32(t *testing.T) {
 				}
 			}
 		}
-		minValue, maxValue := bits.MinMaxFloat32(values)
+		minValue, maxValue := boundsFloat32(values)
 		return min == minValue && max == maxValue
 	})
 	if err != nil {
@@ -162,7 +144,7 @@ func TestMinMaxFloat32(t *testing.T) {
 	}
 }
 
-func TestMinMaxFloat64(t *testing.T) {
+func TestBoundsFloat64(t *testing.T) {
 	err := quick.Check(func(values []float64) bool {
 		min := float64(0)
 		max := float64(0)
@@ -178,7 +160,7 @@ func TestMinMaxFloat64(t *testing.T) {
 				}
 			}
 		}
-		minValue, maxValue := bits.MinMaxFloat64(values)
+		minValue, maxValue := boundsFloat64(values)
 		return min == minValue && max == maxValue
 	})
 	if err != nil {
@@ -186,23 +168,23 @@ func TestMinMaxFloat64(t *testing.T) {
 	}
 }
 
-func TestMinMaxFixedLenByteArray1(t *testing.T) {
-	err := quick.Check(func(values []byte) bool {
-		min := [1]byte{}
-		max := [1]byte{}
+func TestBoundsBE128(t *testing.T) {
+	err := quick.Check(func(values [][16]byte) bool {
+		min := [16]byte{}
+		max := [16]byte{}
 		if len(values) > 0 {
-			min[0] = values[0]
-			max[0] = values[0]
+			min = values[0]
+			max = values[0]
 			for _, v := range values[1:] {
-				if v < min[0] {
-					min[0] = v
+				if bytes.Compare(v[:], min[:]) < 0 {
+					min = v
 				}
-				if v > max[0] {
-					max[0] = v
+				if bytes.Compare(v[:], max[:]) > 0 {
+					max = v
 				}
 			}
 		}
-		minValue, maxValue := bits.MinMaxFixedLenByteArray(1, values)
+		minValue, maxValue := boundsBE128(values)
 		return (len(values) == 0 && minValue == nil && maxValue == nil) ||
 			(bytes.Equal(min[:], minValue) && bytes.Equal(max[:], maxValue))
 	})
@@ -211,23 +193,23 @@ func TestMinMaxFixedLenByteArray1(t *testing.T) {
 	}
 }
 
-func TestMinMaxFixedLenByteArray16(t *testing.T) {
-	err := quick.Check(func(values [][16]byte) bool {
-		min := [16]byte{}
-		max := [16]byte{}
+func TestBoundsFixedLenByteArray(t *testing.T) {
+	err := quick.Check(func(values []byte) bool {
+		min := [1]byte{}
+		max := [1]byte{}
 		if len(values) > 0 {
-			min = values[0]
-			max = values[0]
+			min[0] = values[0]
+			max[0] = values[0]
 			for _, v := range values[1:] {
-				if bytes.Compare(v[:], min[:]) < 0 {
-					min = v
+				if v < min[0] {
+					min[0] = v
 				}
-				if bytes.Compare(v[:], max[:]) > 0 {
-					max = v
+				if v > max[0] {
+					max[0] = v
 				}
 			}
 		}
-		minValue, maxValue := bits.MinMaxFixedLenByteArray(16, unsafecast.Uint128ToBytes(values))
+		minValue, maxValue := boundsFixedLenByteArray(values, 1)
 		return (len(values) == 0 && minValue == nil && maxValue == nil) ||
 			(bytes.Equal(min[:], minValue) && bytes.Equal(max[:], maxValue))
 	})
@@ -236,19 +218,7 @@ func TestMinMaxFixedLenByteArray16(t *testing.T) {
 	}
 }
 
-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) {
+func BenchmarkBoundsInt32(b *testing.B) {
 	forEachBenchmarkBufferSize(b, func(b *testing.B, bufferSize int) {
 		values := make([]int32, bufferSize/4)
 		prng := rand.New(rand.NewSource(1))
@@ -256,12 +226,12 @@ func BenchmarkMinMaxInt32(b *testing.B) {
 			values[i] = prng.Int31()
 		}
 		for i := 0; i < b.N; i++ {
-			bits.MinMaxInt32(values)
+			boundsInt32(values)
 		}
 	})
 }
 
-func BenchmarkMinMaxInt64(b *testing.B) {
+func BenchmarkBoundsInt64(b *testing.B) {
 	forEachBenchmarkBufferSize(b, func(b *testing.B, bufferSize int) {
 		values := make([]int64, bufferSize/8)
 		prng := rand.New(rand.NewSource(1))
@@ -269,12 +239,12 @@ func BenchmarkMinMaxInt64(b *testing.B) {
 			values[i] = prng.Int63()
 		}
 		for i := 0; i < b.N; i++ {
-			bits.MinMaxInt64(values)
+			boundsInt64(values)
 		}
 	})
 }
 
-func BenchmarkMinMaxUint32(b *testing.B) {
+func BenchmarkBoundsUint32(b *testing.B) {
 	forEachBenchmarkBufferSize(b, func(b *testing.B, bufferSize int) {
 		values := make([]uint32, bufferSize/4)
 		prng := rand.New(rand.NewSource(1))
@@ -282,12 +252,12 @@ func BenchmarkMinMaxUint32(b *testing.B) {
 			values[i] = prng.Uint32()
 		}
 		for i := 0; i < b.N; i++ {
-			bits.MinMaxUint32(values)
+			boundsUint32(values)
 		}
 	})
 }
 
-func BenchmarkMinMaxUint64(b *testing.B) {
+func BenchmarkBoundsUint64(b *testing.B) {
 	forEachBenchmarkBufferSize(b, func(b *testing.B, bufferSize int) {
 		values := make([]uint64, bufferSize/8)
 		prng := rand.New(rand.NewSource(1))
@@ -295,12 +265,12 @@ func BenchmarkMinMaxUint64(b *testing.B) {
 			values[i] = prng.Uint64()
 		}
 		for i := 0; i < b.N; i++ {
-			bits.MinMaxUint64(values)
+			boundsUint64(values)
 		}
 	})
 }
 
-func BenchmarkMinMaxFloat32(b *testing.B) {
+func BenchmarkBoundsFloat32(b *testing.B) {
 	forEachBenchmarkBufferSize(b, func(b *testing.B, bufferSize int) {
 		values := make([]float32, bufferSize/4)
 		prng := rand.New(rand.NewSource(1))
@@ -308,12 +278,12 @@ func BenchmarkMinMaxFloat32(b *testing.B) {
 			values[i] = prng.Float32()
 		}
 		for i := 0; i < b.N; i++ {
-			bits.MinMaxFloat32(values)
+			boundsFloat32(values)
 		}
 	})
 }
 
-func BenchmarkMinMaxFloat64(b *testing.B) {
+func BenchmarkBoundsFloat64(b *testing.B) {
 	forEachBenchmarkBufferSize(b, func(b *testing.B, bufferSize int) {
 		values := make([]float64, bufferSize/8)
 		prng := rand.New(rand.NewSource(1))
@@ -321,18 +291,31 @@ func BenchmarkMinMaxFloat64(b *testing.B) {
 			values[i] = prng.Float64()
 		}
 		for i := 0; i < b.N; i++ {
-			bits.MinMaxFloat64(values)
+			boundsFloat64(values)
+		}
+	})
+}
+
+func BenchmarkBoundsBE128(b *testing.B) {
+	forEachBenchmarkBufferSize(b, func(b *testing.B, bufferSize int) {
+		values := make([][16]byte, bufferSize)
+		prng := rand.New(rand.NewSource(1))
+		for i := range values {
+			prng.Read(values[i][:])
+		}
+		for i := 0; i < b.N; i++ {
+			boundsBE128(values)
 		}
 	})
 }
 
-func BenchmarkMinMaxFixedLenByteArray(b *testing.B) {
+func BenchmarkBoundsFixedLenByteArray(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)
+			boundsFixedLenByteArray(values, 32)
 		}
 	})
 }
diff --git a/internal/bits/max_test.go b/page_max_test.go
similarity index 76%
rename from internal/bits/max_test.go
rename to page_max_test.go
index a07100c..d2cd827 100644
--- a/internal/bits/max_test.go
+++ b/page_max_test.go
@@ -1,42 +1,13 @@
-package bits_test
+package parquet
 
 import (
 	"bytes"
 	"math/rand"
 	"testing"
 
-	"github.com/segmentio/parquet-go/internal/bits"
 	"github.com/segmentio/parquet-go/internal/quick"
-	"github.com/segmentio/parquet-go/internal/unsafecast"
 )
 
-func TestMaxBool(t *testing.T) {
-	err := quick.Check(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 := quick.Check(func(values []int32) bool {
 		max := int32(0)
@@ -48,7 +19,7 @@ func TestMaxInt32(t *testing.T) {
 				}
 			}
 		}
-		return max == bits.MaxInt32(values)
+		return max == maxInt32(values)
 	})
 	if err != nil {
 		t.Error(err)
@@ -66,7 +37,7 @@ func TestMaxInt64(t *testing.T) {
 				}
 			}
 		}
-		return max == bits.MaxInt64(values)
+		return max == maxInt64(values)
 	})
 	if err != nil {
 		t.Error(err)
@@ -84,7 +55,7 @@ func TestMaxUint32(t *testing.T) {
 				}
 			}
 		}
-		return max == bits.MaxUint32(values)
+		return max == maxUint32(values)
 	})
 	if err != nil {
 		t.Error(err)
@@ -102,7 +73,7 @@ func TestMaxUint64(t *testing.T) {
 				}
 			}
 		}
-		return max == bits.MaxUint64(values)
+		return max == maxUint64(values)
 	})
 	if err != nil {
 		t.Error(err)
@@ -120,7 +91,7 @@ func TestMaxFloat32(t *testing.T) {
 				}
 			}
 		}
-		return max == bits.MaxFloat32(values)
+		return max == maxFloat32(values)
 	})
 	if err != nil {
 		t.Error(err)
@@ -138,25 +109,25 @@ func TestMaxFloat64(t *testing.T) {
 				}
 			}
 		}
-		return max == bits.MaxFloat64(values)
+		return max == maxFloat64(values)
 	})
 	if err != nil {
 		t.Error(err)
 	}
 }
 
-func TestMaxFixedLenByteArray1(t *testing.T) {
-	err := quick.Check(func(values []byte) bool {
-		max := [1]byte{}
+func TestMaxBE128(t *testing.T) {
+	err := quick.Check(func(values [][16]byte) bool {
+		max := [16]byte{}
 		if len(values) > 0 {
-			max[0] = values[0]
+			max = values[0]
 			for _, v := range values[1:] {
-				if v > max[0] {
-					max[0] = v
+				if bytes.Compare(v[:], max[:]) > 0 {
+					max = v
 				}
 			}
 		}
-		ret := bits.MaxFixedLenByteArray(1, values)
+		ret := maxBE128(values)
 		return (len(values) == 0 && ret == nil) || bytes.Equal(max[:], ret)
 	})
 	if err != nil {
@@ -164,18 +135,18 @@ func TestMaxFixedLenByteArray1(t *testing.T) {
 	}
 }
 
-func TestMaxFixedLenByteArray16(t *testing.T) {
-	err := quick.Check(func(values [][16]byte) bool {
-		max := [16]byte{}
+func TestMaxFixedLenByteArray(t *testing.T) {
+	err := quick.Check(func(values []byte) bool {
+		max := [1]byte{}
 		if len(values) > 0 {
-			max = values[0]
+			max[0] = values[0]
 			for _, v := range values[1:] {
-				if bytes.Compare(v[:], max[:]) > 0 {
-					max = v
+				if v > max[0] {
+					max[0] = v
 				}
 			}
 		}
-		ret := bits.MaxFixedLenByteArray(16, unsafecast.Uint128ToBytes(values))
+		ret := maxFixedLenByteArray(values, 1)
 		return (len(values) == 0 && ret == nil) || bytes.Equal(max[:], ret)
 	})
 	if err != nil {
@@ -183,15 +154,6 @@ func TestMaxFixedLenByteArray16(t *testing.T) {
 	}
 }
 
-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)
@@ -200,7 +162,7 @@ func BenchmarkMaxInt32(b *testing.B) {
 			values[i] = prng.Int31()
 		}
 		for i := 0; i < b.N; i++ {
-			bits.MaxInt32(values)
+			maxInt32(values)
 		}
 	})
 }
@@ -213,7 +175,7 @@ func BenchmarkMaxInt64(b *testing.B) {
 			values[i] = prng.Int63()
 		}
 		for i := 0; i < b.N; i++ {
-			bits.MaxInt64(values)
+			maxInt64(values)
 		}
 	})
 }
@@ -226,7 +188,7 @@ func BenchmarkMaxUint32(b *testing.B) {
 			values[i] = prng.Uint32()
 		}
 		for i := 0; i < b.N; i++ {
-			bits.MaxUint32(values)
+			maxUint32(values)
 		}
 	})
 }
@@ -239,7 +201,7 @@ func BenchmarkMaxUint64(b *testing.B) {
 			values[i] = prng.Uint64()
 		}
 		for i := 0; i < b.N; i++ {
-			bits.MaxUint64(values)
+			maxUint64(values)
 		}
 	})
 }
@@ -252,7 +214,7 @@ func BenchmarkMaxFloat32(b *testing.B) {
 			values[i] = prng.Float32()
 		}
 		for i := 0; i < b.N; i++ {
-			bits.MaxFloat32(values)
+			maxFloat32(values)
 		}
 	})
 }
@@ -265,7 +227,20 @@ func BenchmarkMaxFloat64(b *testing.B) {
 			values[i] = prng.Float64()
 		}
 		for i := 0; i < b.N; i++ {
-			bits.MaxFloat64(values)
+			maxFloat64(values)
+		}
+	})
+}
+
+func BenchmarkMaxBE128(b *testing.B) {
+	forEachBenchmarkBufferSize(b, func(b *testing.B, bufferSize int) {
+		values := make([][16]byte, bufferSize)
+		prng := rand.New(rand.NewSource(1))
+		for i := range values {
+			prng.Read(values[i][:])
+		}
+		for i := 0; i < b.N; i++ {
+			maxBE128(values)
 		}
 	})
 }
@@ -276,7 +251,7 @@ func BenchmarkMaxFixedLenByteArray(b *testing.B) {
 		prng := rand.New(rand.NewSource(1))
 		prng.Read(values)
 		for i := 0; i < b.N; i++ {
-			bits.MaxFixedLenByteArray(16, values)
+			maxFixedLenByteArray(values, 32)
 		}
 	})
 }
diff --git a/internal/bits/min_test.go b/page_min_test.go
similarity index 75%
rename from internal/bits/min_test.go
rename to page_min_test.go
index 9e99c6d..6ac2edb 100644
--- a/internal/bits/min_test.go
+++ b/page_min_test.go
@@ -1,42 +1,13 @@
-package bits_test
+package parquet
 
 import (
 	"bytes"
 	"math/rand"
 	"testing"
 
-	"github.com/segmentio/parquet-go/internal/bits"
 	"github.com/segmentio/parquet-go/internal/quick"
-	"github.com/segmentio/parquet-go/internal/unsafecast"
 )
 
-func TestMinBool(t *testing.T) {
-	err := quick.Check(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 := quick.Check(func(values []int32) bool {
 		min := int32(0)
@@ -48,7 +19,7 @@ func TestMinInt32(t *testing.T) {
 				}
 			}
 		}
-		return min == bits.MinInt32(values)
+		return min == minInt32(values)
 	})
 	if err != nil {
 		t.Error(err)
@@ -66,7 +37,7 @@ func TestMinInt64(t *testing.T) {
 				}
 			}
 		}
-		return min == bits.MinInt64(values)
+		return min == minInt64(values)
 	})
 	if err != nil {
 		t.Error(err)
@@ -84,7 +55,7 @@ func TestMinUint32(t *testing.T) {
 				}
 			}
 		}
-		return min == bits.MinUint32(values)
+		return min == minUint32(values)
 	})
 	if err != nil {
 		t.Error(err)
@@ -102,7 +73,7 @@ func TestMinUint64(t *testing.T) {
 				}
 			}
 		}
-		return min == bits.MinUint64(values)
+		return min == minUint64(values)
 	})
 	if err != nil {
 		t.Error(err)
@@ -120,7 +91,7 @@ func TestMinFloat32(t *testing.T) {
 				}
 			}
 		}
-		return min == bits.MinFloat32(values)
+		return min == minFloat32(values)
 	})
 	if err != nil {
 		t.Error(err)
@@ -138,25 +109,25 @@ func TestMinFloat64(t *testing.T) {
 				}
 			}
 		}
-		return min == bits.MinFloat64(values)
+		return min == minFloat64(values)
 	})
 	if err != nil {
 		t.Error(err)
 	}
 }
 
-func TestMinFixedLenByteArray1(t *testing.T) {
-	err := quick.Check(func(values []byte) bool {
-		min := [1]byte{}
+func TestMinBE128(t *testing.T) {
+	err := quick.Check(func(values [][16]byte) bool {
+		min := [16]byte{}
 		if len(values) > 0 {
-			min[0] = values[0]
+			min = values[0]
 			for _, v := range values[1:] {
-				if v < min[0] {
-					min[0] = v
+				if bytes.Compare(v[:], min[:]) < 0 {
+					min = v
 				}
 			}
 		}
-		ret := bits.MinFixedLenByteArray(1, values)
+		ret := minBE128(values)
 		return (len(values) == 0 && ret == nil) || bytes.Equal(min[:], ret)
 	})
 	if err != nil {
@@ -164,18 +135,18 @@ func TestMinFixedLenByteArray1(t *testing.T) {
 	}
 }
 
-func TestMinFixedLenByteArray16(t *testing.T) {
-	err := quick.Check(func(values [][16]byte) bool {
-		min := [16]byte{}
+func TestMinFixedLenByteArray(t *testing.T) {
+	err := quick.Check(func(values []byte) bool {
+		min := [1]byte{}
 		if len(values) > 0 {
-			min = values[0]
+			min[0] = values[0]
 			for _, v := range values[1:] {
-				if bytes.Compare(v[:], min[:]) < 0 {
-					min = v
+				if v < min[0] {
+					min[0] = v
 				}
 			}
 		}
-		ret := bits.MinFixedLenByteArray(16, unsafecast.Uint128ToBytes(values))
+		ret := minFixedLenByteArray(values, 1)
 		return (len(values) == 0 && ret == nil) || bytes.Equal(min[:], ret)
 	})
 	if err != nil {
@@ -183,18 +154,6 @@ func TestMinFixedLenByteArray16(t *testing.T) {
 	}
 }
 
-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)
@@ -203,7 +162,7 @@ func BenchmarkMinInt32(b *testing.B) {
 			values[i] = prng.Int31()
 		}
 		for i := 0; i < b.N; i++ {
-			bits.MinInt32(values)
+			minInt32(values)
 		}
 	})
 }
@@ -216,7 +175,7 @@ func BenchmarkMinInt64(b *testing.B) {
 			values[i] = prng.Int63()
 		}
 		for i := 0; i < b.N; i++ {
-			bits.MinInt64(values)
+			minInt64(values)
 		}
 	})
 }
@@ -229,7 +188,7 @@ func BenchmarkMinUint32(b *testing.B) {
 			values[i] = prng.Uint32()
 		}
 		for i := 0; i < b.N; i++ {
-			bits.MinUint32(values)
+			minUint32(values)
 		}
 	})
 }
@@ -242,7 +201,7 @@ func BenchmarkMinUint64(b *testing.B) {
 			values[i] = prng.Uint64()
 		}
 		for i := 0; i < b.N; i++ {
-			bits.MinUint64(values)
+			minUint64(values)
 		}
 	})
 }
@@ -255,7 +214,7 @@ func BenchmarkMinFloat32(b *testing.B) {
 			values[i] = prng.Float32()
 		}
 		for i := 0; i < b.N; i++ {
-			bits.MinFloat32(values)
+			minFloat32(values)
 		}
 	})
 }
@@ -268,7 +227,20 @@ func BenchmarkMinFloat64(b *testing.B) {
 			values[i] = prng.Float64()
 		}
 		for i := 0; i < b.N; i++ {
-			bits.MinFloat64(values)
+			minFloat64(values)
+		}
+	})
+}
+
+func BenchmarkMinBE128(b *testing.B) {
+	forEachBenchmarkBufferSize(b, func(b *testing.B, bufferSize int) {
+		values := make([][16]byte, bufferSize)
+		prng := rand.New(rand.NewSource(1))
+		for i := range values {
+			prng.Read(values[i][:])
+		}
+		for i := 0; i < b.N; i++ {
+			minBE128(values)
 		}
 	})
 }
@@ -279,7 +251,7 @@ func BenchmarkMinFixedLenByteArray(b *testing.B) {
 		prng := rand.New(rand.NewSource(1))
 		prng.Read(values)
 		for i := 0; i < b.N; i++ {
-			bits.MinFixedLenByteArray(16, values)
+			minFixedLenByteArray(values, 32)
 		}
 	})
 }