Skip to content

Conversation

@daniel-adam-tfs
Copy link
Contributor

@daniel-adam-tfs daniel-adam-tfs commented Oct 24, 2025

Rationale for this change

Optimization of memory usage, enables the use of custom allocators when reading column data with both buffered and unbuffered readers.

  • Optimization of memory allocation and buffer management.
  • Enable the use of custom allocators when reading column data with both buffered and unbuffered readers.

What changes are included in this PR?

Page Reader Optimizations:

  • Unified V1 and V2 page reading with getPageBytesV1() and getPageBytesV2()
  • Added readOrStealData() to optimize buffer stealing vs direct reads
  • Strategic buffer reuse based on compression/encryption state

Custom Allocator Support:

  • Modified utils.NewBufferedReader to accept memory.Allocator parameter
  • Added Free() and Buffered() methods to BufferedReader interface
  • Added NewBytesBufferReader for allocator-backed byte buffers
  • ReaderProperties.GetStream() now uses the configured allocator for all buffer allocations

Are these changes tested?

  • All existing unit tests pass with refactored implementation
  • New TestDecryptColumns validates 8 encryption/compression configurations
  • Comprehensive benchmarks test V1/V2 pages, compression, and encryption
  • Memory leak tests in TestReaderPropsGetStreamWithAllocator and TestReaderPropsGetStreamBufferedWithAllocator verify proper cleanup

Are there any user-facing changes?

New Features:

  • Users can now provide custom allocators via ReaderProperties to control memory allocation during page reading

Breaking Changes:

  • utils.NewBufferedReader now requires a memory.Allocator parameter (pass nil for default)
  • BufferedReader interface adds Buffered() int and Free() methods

@daniel-adam-tfs daniel-adam-tfs force-pushed the feature/mem-allocator-in-readerprops branch from e3a38f7 to c7bee5e Compare October 30, 2025 15:12
require.NoError(t, err)

icr := col0.(*file.Int64ColumnChunkReader)
// require.NoError(t, icr.SeekToRow(3)) // TODO: this causes a panic currently
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If I uncomment this, then this causes a panic

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That shouldn't be panicing as the SeekToRow works correctly based on my last tests.... I'll see if i can debug this

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This still panics:

--- FAIL: TestDecryptColumns (0.00s)
    --- FAIL: TestDecryptColumns/DataPageV2_BufferedRead (0.00s)
panic: cipher: message authentication failed [recovered, repanicked]

goroutine 8 [running]:
testing.tRunner.func1.2({0x2899940, 0xc000054100})
	/usr/local/Cellar/go/1.25.6/libexec/src/testing/testing.go:1872 +0x237
testing.tRunner.func1()
	/usr/local/Cellar/go/1.25.6/libexec/src/testing/testing.go:1875 +0x35b
panic({0x2899940?, 0xc000054100?})
	/usr/local/Cellar/go/1.25.6/libexec/src/runtime/panic.go:783 +0x132
github.com/apache/arrow-go/v18/parquet/internal/encryption.(*aesDecryptor).Decrypt(0xc0002c58f0, {0xc000026800, 0x4000, 0x4000}, {0xc0002c5b10?, 0x600c00003ff20?, 0x44d3560?}, {0xc0002c5e90, 0xd, 0x10})
	/daniel-adam-tfs/arrow-go/parquet/internal/encryption/aes.go:262 +0x26d
github.com/apache/arrow-go/v18/parquet/internal/encryption.(*decryptor).Decrypt(0xc0003326c0?, {0xc000026800?, 0xc00003fd40?, 0x1397b05?})
	/daniel-adam-tfs/arrow-go/parquet/internal/encryption/decryptor.go:268 +0x45
github.com/apache/arrow-go/v18/parquet/file.(*serializedPageReader).readPageHeader(0xc0003326c0, {0x2aceb20, 0xc00003ff20}, 0xc0002ff740)
	/daniel-adam-tfs/arrow-go/parquet/file/page_reader.go:704 +0x139
github.com/apache/arrow-go/v18/parquet/file.(*serializedPageReader).Next(0xc0003326c0)
	/daniel-adam-tfs/arrow-go/parquet/file/page_reader.go:798 +0xdd
github.com/apache/arrow-go/v18/parquet/file.(*serializedPageReader).SeekToPageWithRow(0xc0003326c0, 0x3)
	/daniel-adam-tfs/arrow-go/parquet/file/page_reader.go:749 +0x186
github.com/apache/arrow-go/v18/parquet/file.(*columnChunkReader).SeekToRow(0xc0002fa780, 0x3)
	/daniel-adam-tfs/arrow-go/parquet/file/column_reader.go:584 +0x2a
github.com/apache/arrow-go/v18/parquet/file_test.checkDecryptedValues(0xc000326380, 0xc000210870, 0xc0003283f0)
	/daniel-adam-tfs/arrow-go/parquet/file/column_reader_test.go:867 +0x591
github.com/apache/arrow-go/v18/parquet/file_test.TestDecryptColumns.func1(0xc000326380)
	/daniel-adam-tfs/arrow-go/parquet/file/column_reader_test.go:964 +0x18a
testing.tRunner(0xc000326380, 0xc0002fe680)
	/usr/local/Cellar/go/1.25.6/libexec/src/testing/testing.go:1934 +0xea
created by testing.(*T).Run in goroutine 7
	/usr/local/Cellar/go/1.25.6/libexec/src/testing/testing.go:1997 +0x465
FAIL	github.com/apache/arrow-go/v18/parquet/file	1.922s

For each of the cases defined, doesn't matter whether it is a V1 or V2 page, or if is it compressed or not, or if BufferedStream is used or not. SeekToRow works in the unencrypted case, so this is again encryption related.

Copy link
Contributor Author

@daniel-adam-tfs daniel-adam-tfs Jan 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It seems to be related to this line:

if oidx == nil {
if _, err = section.Seek(p.dataOffset-p.baseOffset, io.SeekStart); err != nil {
return err
}

I think this is intended to skip past the dictionary page to the data page. But for some reason after this Seek the

view = p.cryptoCtx.MetaDecryptor.Decrypt(view)

call fails. The offset to the data page is correct, so I'm thinking that some internal state of the decryptor or the page reader is affected by skip past the parsing of the dictionary page.

But I'll remove the SeekToRow and park this for now in #566. Because we are benchmarking various formats + readers of unencrypted data, so I wanna make some optimization (including this PR, which according to my benchmarks speeds things up a little).

@daniel-adam-tfs
Copy link
Contributor Author

Alright, decryption should be correct now. Let me rebase this (...and try remember what was happening here 😄 )

@daniel-adam-tfs daniel-adam-tfs force-pushed the feature/mem-allocator-in-readerprops branch 2 times, most recently from b9587e2 to 28b112c Compare January 26, 2026 14:39
@daniel-adam-tfs
Copy link
Contributor Author

By stealing the buffers and conditionally reusing the buffers based on the encryption/compression I got a decent improvement mostly in the allocated memory:

ReadInt32 benchmark:

goos: darwin
goarch: amd64
pkg: github.com/apache/arrow-go/v18/parquet/file
cpu: Intel(R) Core(TM) i5-1038NG7 CPU @ 2.00GHz
                                  │ bench/BenchmarkReadInt32.main.txt │ bench/BenchmarkReadInt32.opt4.txt │
                                  │              sec/op               │   sec/op     vs base              │
ReadInt32/V1Page-8                                        29.48m ± 2%   27.43m ± 2%  -6.97% (p=0.001 n=7)
ReadInt32/V2Page-8                                        28.59m ± 1%   27.59m ± 6%       ~ (p=0.053 n=7)
ReadInt32/V1PageSnappy-8                                  29.27m ± 1%   28.76m ± 2%  -1.74% (p=0.001 n=7)
ReadInt32/V2PageSnappy-8                                  29.25m ± 2%   28.55m ± 1%  -2.38% (p=0.001 n=7)
ReadInt32/V1PageEncrypted-8                               30.39m ± 2%   28.49m ± 1%  -6.25% (p=0.001 n=7)
ReadInt32/V2PageEncrypted-8                               30.48m ± 1%   28.67m ± 2%  -5.91% (p=0.001 n=7)
ReadInt32/V1PageSnappyEncrypted-8                         30.42m ± 1%   29.60m ± 1%  -2.71% (p=0.001 n=7)
ReadInt32/V2PageSnappyEncrypted-8                         30.34m ± 1%   29.52m ± 2%  -2.73% (p=0.001 n=7)
geomean                                                   29.77m        28.57m       -4.04%

                                  │ bench/BenchmarkReadInt32.main.txt │  bench/BenchmarkReadInt32.opt4.txt  │
                                  │               B/op                │     B/op      vs base               │
ReadInt32/V1Page-8                                      18.670Mi ± 0%   5.386Mi ± 0%  -71.15% (p=0.001 n=7)
ReadInt32/V2Page-8                                      13.357Mi ± 0%   5.386Mi ± 0%  -59.67% (p=0.001 n=7)
ReadInt32/V1PageSnappy-8                                13.045Mi ± 0%   8.433Mi ± 0%  -35.35% (p=0.001 n=7)
ReadInt32/V2PageSnappy-8                                13.045Mi ± 0%   8.433Mi ± 0%  -35.35% (p=0.001 n=7)
ReadInt32/V1PageEncrypted-8                             22.241Mi ± 0%   8.957Mi ± 0%  -59.73% (p=0.001 n=7)
ReadInt32/V2PageEncrypted-8                             22.241Mi ± 0%   8.957Mi ± 0%  -59.73% (p=0.001 n=7)
ReadInt32/V1PageSnappyEncrypted-8                        16.30Mi ± 0%   11.69Mi ± 0%  -28.28% (p=0.001 n=7)
ReadInt32/V2PageSnappyEncrypted-8                        16.30Mi ± 0%   11.69Mi ± 0%  -28.28% (p=0.001 n=7)
geomean                                                  16.53Mi        8.305Mi       -49.76%

                                  │ bench/BenchmarkReadInt32.main.txt │ bench/BenchmarkReadInt32.opt4.txt │
                                  │             allocs/op             │  allocs/op   vs base              │
ReadInt32/V1Page-8                                        8.739k ± 0%   8.635k ± 0%  -1.19% (p=0.001 n=7)
ReadInt32/V2Page-8                                        8.698k ± 0%   8.634k ± 0%  -0.74% (p=0.001 n=7)
ReadInt32/V1PageSnappy-8                                  8.717k ± 0%   8.655k ± 0%  -0.71% (p=0.001 n=7)
ReadInt32/V2PageSnappy-8                                  8.717k ± 0%   8.655k ± 0%  -0.71% (p=0.001 n=7)
ReadInt32/V1PageEncrypted-8                              10.100k ± 0%   9.996k ± 0%  -1.03% (p=0.001 n=7)
ReadInt32/V2PageEncrypted-8                              10.100k ± 0%   9.996k ± 0%  -1.03% (p=0.001 n=7)
ReadInt32/V1PageSnappyEncrypted-8                         10.08k ± 0%   10.02k ± 0%  -0.61% (p=0.001 n=7)
ReadInt32/V2PageSnappyEncrypted-8                         10.08k ± 0%   10.02k ± 0%  -0.61% (p=0.001 n=7)
geomean                                                   9.378k        9.301k       -0.83%

ReadInt32Buffered benchmark:

goos: darwin
goarch: amd64
pkg: github.com/apache/arrow-go/v18/parquet/file
cpu: Intel(R) Core(TM) i5-1038NG7 CPU @ 2.00GHz
                                          │ bench/BenchmarkReadInt32Buffered.main.txt │ bench/BenchmarkReadInt32Buffered.opt4.txt │
                                          │                  sec/op                   │       sec/op         vs base              │
ReadInt32Buffered/V1Page-8                                                29.11m ± 2%           27.53m ± 3%  -5.43% (p=0.001 n=7)
ReadInt32Buffered/V2Page-8                                                28.58m ± 6%           27.59m ± 3%  -3.45% (p=0.001 n=7)
ReadInt32Buffered/V1PageSnappy-8                                          29.06m ± 6%           28.66m ± 1%  -1.37% (p=0.007 n=7)
ReadInt32Buffered/V2PageSnappy-8                                          29.01m ± 1%           28.45m ± 1%  -1.92% (p=0.001 n=7)
ReadInt32Buffered/V1PageEncrypted-8                                       30.22m ± 1%           28.67m ± 1%  -5.12% (p=0.001 n=7)
ReadInt32Buffered/V2PageEncrypted-8                                       30.34m ± 1%           28.63m ± 1%  -5.64% (p=0.001 n=7)
ReadInt32Buffered/V1PageSnappyEncrypted-8                                 30.03m ± 3%           29.40m ± 1%  -2.08% (p=0.038 n=7)
ReadInt32Buffered/V2PageSnappyEncrypted-8                                 29.83m ± 2%           29.88m ± 1%       ~ (p=0.902 n=7)
geomean                                                                   29.52m                28.59m       -3.13%

                                          │ bench/BenchmarkReadInt32Buffered.main.txt │ bench/BenchmarkReadInt32Buffered.opt4.txt │
                                          │                   B/op                    │        B/op         vs base               │
ReadInt32Buffered/V1Page-8                                              15.545Mi ± 0%         5.640Mi ± 0%  -63.72% (p=0.001 n=7)
ReadInt32Buffered/V2Page-8                                              10.231Mi ± 0%         5.640Mi ± 0%  -44.87% (p=0.001 n=7)
ReadInt32Buffered/V1PageSnappy-8                                        10.232Mi ± 0%         7.203Mi ± 0%  -29.60% (p=0.001 n=7)
ReadInt32Buffered/V2PageSnappy-8                                        10.232Mi ± 0%         7.203Mi ± 0%  -29.60% (p=0.001 n=7)
ReadInt32Buffered/V1PageEncrypted-8                                     19.115Mi ± 0%         9.211Mi ± 0%  -51.81% (p=0.001 n=7)
ReadInt32Buffered/V2PageEncrypted-8                                     19.115Mi ± 0%         9.211Mi ± 0%  -51.81% (p=0.001 n=7)
ReadInt32Buffered/V1PageSnappyEncrypted-8                                13.49Mi ± 0%         10.46Mi ± 0%  -22.45% (p=0.001 n=7)
ReadInt32Buffered/V2PageSnappyEncrypted-8                                13.49Mi ± 0%         11.95Mi ± 0%  -11.45% (p=0.001 n=7)
geomean                                                                  13.51Mi              8.042Mi       -40.45%

                                          │ bench/BenchmarkReadInt32Buffered.main.txt │ bench/BenchmarkReadInt32Buffered.opt4.txt │
                                          │                 allocs/op                 │      allocs/op       vs base              │
ReadInt32Buffered/V1Page-8                                                8.738k ± 0%           8.655k ± 0%  -0.95% (p=0.001 n=7)
ReadInt32Buffered/V2Page-8                                                8.696k ± 0%           8.655k ± 0%  -0.47% (p=0.001 n=7)
ReadInt32Buffered/V1PageSnappy-8                                          8.716k ± 0%           8.665k ± 0%  -0.59% (p=0.001 n=7)
ReadInt32Buffered/V2PageSnappy-8                                          8.716k ± 0%           8.665k ± 0%  -0.59% (p=0.001 n=7)
ReadInt32Buffered/V1PageEncrypted-8                                       10.10k ± 0%           10.02k ± 0%  -0.81% (p=0.001 n=7)
ReadInt32Buffered/V2PageEncrypted-8                                       10.10k ± 0%           10.02k ± 0%  -0.82% (p=0.001 n=7)
ReadInt32Buffered/V1PageSnappyEncrypted-8                                 10.08k ± 0%           10.03k ± 0%  -0.50% (p=0.001 n=7)
ReadInt32Buffered/V2PageSnappyEncrypted-8                                 10.08k ± 0%           10.04k ± 0%  -0.40% (p=0.001 n=7)
geomean                                                                   9.377k                9.317k       -0.64%

@daniel-adam-tfs daniel-adam-tfs force-pushed the feature/mem-allocator-in-readerprops branch from 28b112c to 636a05d Compare January 26, 2026 15:03
@daniel-adam-tfs daniel-adam-tfs marked this pull request as ready for review January 27, 2026 10:13

func (r *byteReader) Buffered() int { return len(r.buf) - r.pos }

func (r *byteReader) Free() {}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should Free do r.r = nil and r.buf = nil ?

}

if r.fileDecryptor == nil {
stream.Free()
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should we just put defer stream.Free() at line 121 instead?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Couldn't think of an simple way of doing it. The most robust way is something this:

shouldFreeStream := true
takeStream := func(s BufferedReaderV2) BufferedReaderV2 {
  shouldFreeStream = false
  return s
}

And use takeStream when stream is assigned and returned and check shouldFreeStream in the deferred function.

Comment on lines 53 to 54
// Buffered returns the number of bytes already read and stored in the buffer
Buffered() int
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this will potentially break other users, we instead need to create a new interface the embeds this one so that we don't break other consumers. Something like adding to internal/utils/buf_reader.go

type BufferedReaderV2 interface {
    BufferedReader

    Buffered() int
    Free()
}

@daniel-adam-tfs daniel-adam-tfs force-pushed the feature/mem-allocator-in-readerprops branch from d274400 to 3458513 Compare January 28, 2026 16:28
@@ -451,12 +477,17 @@ func NewPageReader(r parquet.BufferedReader, nrows int64, compressType compress.
func (p *serializedPageReader) Reset(r parquet.BufferedReader, nrows int64, compressType compress.Compression, ctx *CryptoContext) {
Copy link
Contributor Author

@daniel-adam-tfs daniel-adam-tfs Jan 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If I could change the signature of Reset of the PageReader interface to use parquet.BufferedReaderV2 I could skip the whole bufferedReaderV2Adapter shenanigans, but NewPageReader and RecordReader.SetPageReader are public API. If they get ever removed from public API then we can get rid of bufferedReaderV2Adapter as well.

…nsive benchmarks

- Refactor page reader to unify V1/V2 page reading logic with better buffer management
- Implement readOrStealData to optimize buffered vs direct reads
- Reduce buffer allocations by strategically reusing decompress/data buffers

Benchmark infrastructure improvements:
- Add TestMain with setup/teardown for shared benchmark files
- Create 8 benchmark file variants (V1/V2, plain/snappy, encrypted/unencrypted)
- Add BenchmarkReadInt32 and BenchmarkReadInt32Buffered with sub-benchmarks
- Move file creation to shared setup to eliminate per-benchmark overhead
- Introduce BufferedReaderV2 interface extending BufferedReader with Buffered()
  and Free() methods.
- Enables explicit resource management and buffer introspection for readers using
  custom allocators.
- Update GetStreamV2 to return BufferedReaderV2, allowing callers to free
  allocated buffers when done.
- Keep BufferedReader and BufferedReader for backwards compatibility
@daniel-adam-tfs daniel-adam-tfs force-pushed the feature/mem-allocator-in-readerprops branch from 3458513 to 68ebe43 Compare January 29, 2026 09:32
@daniel-adam-tfs
Copy link
Contributor Author

daniel-adam-tfs commented Jan 29, 2026

I've rebased it, so it has the fix and test from yesterday. But let me add some additional tests, since going by the coverage report not all code branches in the new functions is covered.

@daniel-adam-tfs daniel-adam-tfs marked this pull request as draft January 29, 2026 14:27
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants