Skip to content

fix(parquet/metadata): fix BloomFilter memory recycling, doc contract, and benchmark#864

Open
Patzifist wants to merge 8 commits into
apache:mainfrom
Patzifist:fix/863-bloom-filter-pool-pollution
Open

fix(parquet/metadata): fix BloomFilter memory recycling, doc contract, and benchmark#864
Patzifist wants to merge 8 commits into
apache:mainfrom
Patzifist:fix/863-bloom-filter-pool-pollution

Conversation

@Patzifist

@Patzifist Patzifist commented Jun 25, 2026

Copy link
Copy Markdown

Rationale for this change

Previously, this package relied entirely on the Go Garbage Collector (runtime.AddCleanup) to return buffers back to the sync.Pool. Under heavy or concurrent load, this caused uncontrolled memory growth because new buffers were being allocated much faster than the GC could clean them up and return them to the pool.

To fix this memory leak, an explicit synchronous recycling model was introduced by calling defer r.recycle(bf) inside VisitColumnBloomFilter. This ensures immediate buffer reuse.

What changes are included in this PR?

  • parquet/metadata/bloom_filter.go:
    • Introduced explicit synchronous buffer recycling in VisitColumnBloomFilter to stop uncontrolled memory growth and ensure immediate pool reuse.

Are these changes tested?

Yes. Verified that the memory leak is resolved, added the regression test suite, and successfully ran the parallel benchmark:

go test -bench=^BenchmarkVisitColumnBloomFilter_SyncPool$ -run=^$ -benchmem ./parquet/metadata/...

Output:

BenchmarkVisitColumnBloomFilter_SyncPool-12        10284            108868 ns/op            7323 B/op         17 allocs/op
PASS

Are there any user-facing changes?

No. This is strictly an internal performance, stability, and memory footprint optimization.

@Patzifist Patzifist requested a review from zeroshade as a code owner June 25, 2026 12:58

@zeroshade zeroshade left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I ran some testing using your provided test harness configuration and it looks like your originally measured 13GB memory usage is an artifact of your test and not actually an issue in the code.

Your original test contained a make([]byte, 2048) inside the loop. Running that test on main produced ~2 MB/op, with a cumulative alloc of 13,016. The 13GB you measured is 65 * 100 * 2 MB, produced by the original test and not by this library.

If I run the test from this PR against main, I get the same reduction you did because the test in this PR hoists the slice allocation outside of the loop.

Another key point is that if BloomFilterOffset is 0, we trigger an early return and don't even go into the bloom-filter read/pool path at all (because we just return the raw bloom filter bytes as they existed). Your test uses BloomFilterOffset = 0 which means you're never actually exercising the pool path anyways in this test, proving that the original 13GB allocation was just the make which existed in the test and the reduction was solely due to hoisting it out of the loop in the test. Essentially, the allocation measurement test you're adding doesn't actually exercise any of the new code this PR adds.

If I create a proper test that uses a non-zero BloomFilterOffset against main, using a shared reader and sync.Pool recycling then I get about ~11 KB/op with a cumulative allocation of 72 MB. Even in the worst case scenario, removing the explicit returning to the pool and relying solely on the GC to recycle things, I still max out at only ~3GB, far from the 13GB you measured.

Can you try reproducing the memory allocation issue using:

  • A non-zero BloomFilterOffset so that we actually use the pool instead of just returning the raw bytes deserialized by thrift
  • Use the same test against both main and against your changes.

Given the analysis I've done and my own tests, I wasn't able to reproduce the original issue you reported in #863

That's not to say there isn't any issue here: because we rely on the GC finalizers to return the buffers, they don't get promptly returned under a burst and we end up over-allocating a bit with some churn (between 1.5 - 4 GB of churn vs ~75 MB if we promptly return the buffers to the pool). So there's definitely something we can fix here, but it's not a memory leak and capacity destruction as you originally thought

@@ -0,0 +1,162 @@
package metadata

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

this is missing the apache license header that is required.

Comment thread parquet/metadata/bloom_filter.go Outdated
Hasher() Hasher
CheckHash(hash uint64) bool
Size() int64
Close()

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

this is a breaking change that we shouldn't introduce and shouldn't be needed.

Comment thread parquet/metadata/bloom_filter.go Outdated
Comment on lines +59 to +62
var (
headerPool = make(chan []byte, 512)
filterPool = make(chan *memory.Buffer, 512)
)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

you're replacing the per-reader, allocator-aware sync.Pool with package level globals. You're bypassing the user-configured allocator (which means that if users are using the mallocator i.e. CGO/non-go memory, unreleased buffers will leak native memory rather than Go memory that can be reclaimed).

Comment thread parquet/metadata/bloom_filter.go Outdated
Comment on lines +300 to +305
func (b *blockSplitBloomFilter) Close() {
if b.cancelCleanup != nil {
b.cancelCleanup()
b.cancelCleanup = nil
}
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

this isn't actually called anywhere in the code except for in your new test, so this PR doesn't actually fix anything

@zeroshade

Copy link
Copy Markdown
Member

Ultimately, there is a measurable thing to fix here regarding the latency and churn of memory and how promptly we return the buffer to the pool. But we should improve that without breaking the public BloomFilter api, bypassing the reader's allocator, or introducing new process-global pools instead of the per-reader pool.

For reference, here's the test file I used:

const (
	reproGoroutines = 65
	reproIters      = 100
	reproOps        = reproGoroutines * reproIters
	reproBitsetSize = 1 * 1024 * 1024
	reproScratch    = 2 * 1024 * 1024
	reproReadSize   = int32(4096)
)

func reproHeaderBytes(t testing.TB) []byte {
	t.Helper()
	hb, err := thrift.NewThriftSerializer().Write(context.Background(), &format.BloomFilterHeader{
		NumBytes:    int32(reproBitsetSize),
		Algorithm:   &defaultAlgorithm,
		Hash:        &defaultHashStrategy,
		Compression: &defaultCompression,
	})
	if err != nil {
		t.Fatalf("serialize bloom filter header: %v", err)
	}
	return hb
}

// reproFileData lays out [8 byte prefix][thrift header][bitset] so a non-zero
// BloomFilterOffset points at the header.
func reproFileData(t testing.TB) []byte {
	t.Helper()
	fd := append(make([]byte, 8), reproHeaderBytes(t)...)
	return append(fd, make([]byte, reproBitsetSize)...)
}

func reproMeta(t testing.TB, bloomFilterOffset int64) *RowGroupMetaData {
	t.Helper()
	length := reproReadSize
	cmd := format.ColumnMetaData{
		Type:              format.Type_BYTE_ARRAY,
		Encodings:         []format.Encoding{format.Encoding_PLAIN},
		PathInSchema:      []string{"test_col"},
		Codec:             format.CompressionCodec_UNCOMPRESSED,
		BloomFilterOffset: &bloomFilterOffset,
		BloomFilterLength: &length,
	}
	rg := format.RowGroup{Columns: []*format.ColumnChunk{{MetaData: &cmd}}, TotalByteSize: 100, NumRows: 100}
	node, err := schema.NewPrimitiveNode("test_col", parquet.Repetition(format.FieldRepetitionType_REQUIRED),
		parquet.Type(format.Type_BYTE_ARRAY), -1, -1)
	if err != nil {
		t.Fatalf("create primitive node: %v", err)
	}
	root, err := schema.NewGroupNode("schema", parquet.Repetition(format.FieldRepetitionType_REPEATED),
		schema.FieldList{node}, -1)
	if err != nil {
		t.Fatalf("create group node: %v", err)
	}
	return NewRowGroupMetaData(&rg, schema.NewSchema(root), nil, nil)
}

func reproReader(fileData []byte, meta *RowGroupMetaData, pool *sync.Pool) *RowGroupBloomFilterReader {
	return &RowGroupBloomFilterReader{
		input:          bytes.NewReader(fileData),
		sourceFileSize: int64(len(fileData)),
		rgMeta:         meta,
		bufferPool:     pool,
	}
}

func reproPool() *sync.Pool {
	return &sync.Pool{New: func() any {
		buf := memory.NewResizableBuffer(memory.NewGoAllocator())
		runtime.SetFinalizer(buf, func(o *memory.Buffer) { o.Release() })
		return buf
	}}
}

// reproRecycle returns the bloom filter's buffer to the pool immediately, doing
// exactly what the reader's runtime.AddCleanup callback does (ResizeNoShrink(0)
// then Put), but synchronously. The registered cleanup is cancelled first so the
// same buffer is not returned twice.
func reproRecycle(bf BloomFilter, pool *sync.Pool) {
	b, ok := bf.(*blockSplitBloomFilter)
	if !ok || b.data == nil {
		return
	}
	if b.cancelCleanup != nil {
		b.cancelCleanup()
	}
	b.data.ResizeNoShrink(0)
	pool.Put(b.data)
}

func reproMeasure(t *testing.T, name string, run func() (read, nilRet int64)) (int64, float64) {
	t.Helper()
	runtime.GC()
	var before runtime.MemStats
	runtime.ReadMemStats(&before)

	start := time.Now()
	read, nilRet := run()
	elapsed := time.Since(start)

	var after runtime.MemStats
	runtime.ReadMemStats(&after)
	churnMB := float64(after.TotalAlloc-before.TotalAlloc) / 1024 / 1024
	t.Logf("%s\n    ops=%d filters-read=%d nil-returns=%d cumulative-alloc(churn)=%.1f MB mallocs=%d elapsed=%v",
		name, reproOps, read, nilRet, churnMB, after.Mallocs-before.Mallocs, elapsed)
	return read, churnMB
}

func reproRunParallel(work func() BloomFilter) (read, nilRet int64) {
	var wg sync.WaitGroup
	gate := make(chan struct{})
	for range reproGoroutines {
		wg.Add(1)
		go func() {
			defer wg.Done()
			<-gate
			for range reproIters {
				if work() != nil {
					atomic.AddInt64(&read, 1)
				} else {
					atomic.AddInt64(&nilRet, 1)
				}
			}
		}()
	}
	close(gate)
	wg.Wait()
	return read, nilRet
}

// Scenario A: the original reproducer. BloomFilterOffset=0 and a fresh 2 MiB file
// buffer allocated per iteration. Allocates ~13 GB while never reading a filter.
func TestBloomFilterPoolRepro_A_OriginalHarness(t *testing.T) {
	header := reproHeaderBytes(t)
	meta := reproMeta(t, 0)

	read, _ := reproMeasure(t, "A original harness: offset=0, fresh 2MiB file per iteration", func() (int64, int64) {
		var read, nilRet int64
		var wg sync.WaitGroup
		gate := make(chan struct{})
		for range reproGoroutines {
			wg.Add(1)
			go func() {
				defer wg.Done()
				pool := reproPool()
				<-gate
				for range reproIters {
					scratch := make([]byte, reproScratch)
					copy(scratch, header)
					bf, err := reproReader(scratch, meta, pool).GetColumnBloomFilter(0)
					if err != nil || bf == nil {
						atomic.AddInt64(&nilRet, 1)
						continue
					}
					atomic.AddInt64(&read, 1)
					reproRecycle(bf, pool)
				}
			}()
		}
		close(gate)
		wg.Wait()
		return read, nilRet
	})

	if read != 0 {
		t.Fatalf("expected 0 filters read with offset=0, got %d", read)
	}
}

// Scenario B: corrected harness. Non-zero offset (read path runs), shared reader,
// buffer returned to the pool promptly. The 1 MiB bitset buffer is reused.
func TestBloomFilterPoolRepro_B_SharedReaderPromptReturn(t *testing.T) {
	fileData := reproFileData(t)
	meta := reproMeta(t, 8)
	pool := reproPool()
	rdr := reproReader(fileData, meta, pool)

	bf, err := rdr.GetColumnBloomFilter(0)
	if err != nil || bf == nil {
		t.Fatalf("sanity read failed: bf=%v err=%v", bf, err)
	}
	if bf.Size() != int64(reproBitsetSize) {
		t.Fatalf("unexpected filter size: got %d want %d", bf.Size(), reproBitsetSize)
	}
	reproRecycle(bf, pool)

	read, churnMB := reproMeasure(t, "B shared reader + prompt return: offset>0", func() (int64, int64) {
		return reproRunParallel(func() BloomFilter {
			bf, err := rdr.GetColumnBloomFilter(0)
			if err != nil || bf == nil {
				return nil
			}
			reproRecycle(bf, pool)
			return bf
		})
	})

	if read != reproOps {
		t.Fatalf("expected %d filters read, got %d", reproOps, read)
	}
	if churnMB > 500 {
		t.Fatalf("pool not reusing buffers: churn %.1f MB (1 MiB x %d ops would be ~%d MB without reuse)",
			churnMB, reproOps, reproOps)
	}
}

// Scenario C: corrected harness using the reader's real return path
// (runtime.AddCleanup). GC-timing dependent; reported only.
func TestBloomFilterPoolRepro_C_SharedReaderCleanupOnly(t *testing.T) {
	fileData := reproFileData(t)
	meta := reproMeta(t, 8)
	pool := reproPool()
	rdr := reproReader(fileData, meta, pool)

	reproMeasure(t, "C shared reader + cleanup-only return: offset>0 (reader's real path)", func() (int64, int64) {
		return reproRunParallel(func() BloomFilter {
			bf, err := rdr.GetColumnBloomFilter(0)
			if err != nil {
				return nil
			}
			return bf
		})
	})
}

// Scenario D1: a new reader is built every iteration (as in the original harness)
// but the file bytes are shared and the offset is non-zero. A shared pool still
// recycles, so recreating the reader is not what defeated reuse.
func TestBloomFilterPoolRepro_D1_FreshReaderSharedPool(t *testing.T) {
	fileData := reproFileData(t)
	meta := reproMeta(t, 8)
	pool := reproPool()

	read, churnMB := reproMeasure(t, "D1 fresh reader + shared pool: offset>0, file shared, prompt return", func() (int64, int64) {
		return reproRunParallel(func() BloomFilter {
			bf, err := reproReader(fileData, meta, pool).GetColumnBloomFilter(0)
			if err != nil || bf == nil {
				return nil
			}
			reproRecycle(bf, pool)
			return bf
		})
	})

	if read != reproOps {
		t.Fatalf("expected %d filters read, got %d", reproOps, read)
	}
	if churnMB > 500 {
		t.Fatalf("fresh reader defeated shared pool reuse: churn %.1f MB", churnMB)
	}
}

// Scenario D2: a new pool is built per read (i.e. a new file.Reader per single
// read). This is the only configuration where per-reader pooling cannot help.
func TestBloomFilterPoolRepro_D2_FreshReaderFreshPool(t *testing.T) {
	fileData := reproFileData(t)
	meta := reproMeta(t, 8)

	reproMeasure(t, "D2 fresh reader + fresh pool per read: offset>0, file shared", func() (int64, int64) {
		return reproRunParallel(func() BloomFilter {
			bf, err := reproReader(fileData, meta, reproPool()).GetColumnBloomFilter(0)
			if err != nil {
				return nil
			}
			return bf
		})
	})
}

// BenchmarkBloomFilterPoolReuse reports allocs/op for the read path with the
// buffer returned to the pool. A reused 1 MiB bitset does not show up per op.
func BenchmarkBloomFilterPoolReuse(b *testing.B) {
	fileData := reproFileData(b)
	meta := reproMeta(b, 8)
	pool := reproPool()
	rdr := reproReader(fileData, meta, pool)

	b.ReportAllocs()
	b.ResetTimer()
	b.RunParallel(func(pb *testing.PB) {
		for pb.Next() {
			bf, err := rdr.GetColumnBloomFilter(0)
			if err != nil || bf == nil {
				continue
			}
			reproRecycle(bf, pool)
		}
	})
}

Running it locally myself with go test -bench I get the following results:

Test Case filters-read churn
A - original test (offset = 0) 0 13002.7 MB
B - shared reader + prompt return 6500 77.1 MB
C - cleanup-only (real path for main) 6500 3419.5 MB
D1 - fresh reader + shared pool 6500 39.0 MB
D2 - fresh reader + fresh pool 6500 6617.0 MB

Let's construct a good solution for getting more prompt returns to the pool without breaking the API or defeating the pool. Thoughts?

@Patzifist

Copy link
Copy Markdown
Author

Hi Zeroshade,
Currently, the upstream implementation of Parquet Bloom Filter reader eats up all available RAM on the server just for Bloom Filter metadata, leading to OOM panics under extreme concurrency.
I've designed a patch that solves this issue by bringing memory allocations down to a strictly bound steady-state, allowing us to safely repurpose that RAM for processing actual data page allocations and Row Group data instead.

Benchmark Results (64 Workers, 100 Iterations, Random NDV Sizes):
To validate the patch, I've written a concurrent benchmark mimicking varying filter sizes and heavy thread interleaving:

BenchmarkGetColumnBloomFilter_OriginalSyncPool-12 1 1176984718 ns/op 6280746824 B/op 233435 allocs/op
BenchmarkGetColumnBloomFilter_ChannelsPatch-12 1 1461299865 ns/op 705041384 B/op 209192 allocs/op

@Patzifist

Patzifist commented Jun 30, 2026

Copy link
Copy Markdown
Author

Test for original check

go test -bench=BenchmarkGetColumnBloomFilter_OriginalSyncPool -run=^$ -memprofile=mem.out
goos: linux
goarch: amd64
pkg: github.com/apache/arrow-go/v18/parquet/metadata
cpu: 13th Gen Intel(R) Core(TM) i7-1355U
BenchmarkGetColumnBloomFilter_OriginalSyncPool-12 1 1108550680 ns/op 5550647112 B/op 225956 allocs/op
PASS
ok github.com/apache/arrow-go/v18/parquet/metadata 1.262s

package metadata

import (
	"bytes"
	"context"
	"math/rand"
	"sync"
	"testing"
	"time"

	"github.com/apache/arrow-go/v18/arrow/memory"
	"github.com/apache/arrow-go/v18/parquet"
	format "github.com/apache/arrow-go/v18/parquet/internal/gen-go/parquet"
	"github.com/apache/arrow-go/v18/parquet/internal/thrift"
	"github.com/apache/arrow-go/v18/parquet/schema"
)

type fakeReader struct {
	*bytes.Reader
}

func (f *fakeReader) ReadAt(p []byte, off int64) (int, error) {
	return f.Reader.ReadAt(p, off)
}

func BenchmarkGetColumnBloomFilter_OriginalSyncPool(b *testing.B) {
	ctx := context.Background()

	const (
		workersCount        = 64
		iterationsPerWorker = 100
		maxSize             = 4 * 1024 * 1024
	)

	originalArrowPool := &sync.Pool{
		New: func() any {
			return memory.NewResizableBuffer(memory.NewGoAllocator())
		},
	}

	node, _ := schema.NewPrimitiveNode("test_col", parquet.Repetition(format.FieldRepetitionType_REQUIRED), parquet.Type(format.Type_BYTE_ARRAY), -1, -1)
	rootGroup, _ := schema.NewGroupNode("schema", parquet.Repetition(format.FieldRepetitionType_REPEATED), schema.FieldList{node}, -1)
	sc := schema.NewSchema(rootGroup)

	payload := make([]byte, maxSize)

	b.ResetTimer()
	b.ReportAllocs()

	for n := 0; n < b.N; n++ {
		var wg sync.WaitGroup
		startSignal := make(chan struct{})

		for w := 0; w < workersCount; w++ {
			wg.Add(1)
			go func(workerID int) {
				defer wg.Done()
				<-startSignal

				r := rand.New(rand.NewSource(time.Now().UnixNano() + int64(workerID)))

				threadRdr := &RowGroupBloomFilterReader{
					input:          &fakeReader{Reader: bytes.NewReader(payload)},
					sourceFileSize: int64(len(payload)),
					bufferPool:     originalArrowPool,
				}

				for i := 0; i < iterationsPerWorker; i++ {
					bloomFilterDataSize := int32(1*1024*1024 + r.Intn(200*1024))
					bloomFilterReadSize := bloomFilterDataSize + 4096

					header := format.BloomFilterHeader{
						NumBytes:    bloomFilterDataSize,
						Algorithm:   &defaultAlgorithm,
						Hash:        &defaultHashStrategy,
						Compression: &defaultCompression,
					}

					serializer := thrift.NewThriftSerializer()
					headerBytes, _ := serializer.Write(ctx, &header)

					var offset int64 = 8
					copy(payload[offset:], headerBytes)

					columnMetaData := format.ColumnMetaData{
						Type:              format.Type_BYTE_ARRAY,
						PathInSchema:      []string{"test_col"},
						Codec:             format.CompressionCodec_UNCOMPRESSED,
						BloomFilterOffset: &offset,
						BloomFilterLength: &bloomFilterReadSize,
					}
					thriftColumnChunk := format.ColumnChunk{MetaData: &columnMetaData}
					thriftRowGroup := format.RowGroup{Columns: []*format.ColumnChunk{&thriftColumnChunk}}
					meta := NewRowGroupMetaData(&thriftRowGroup, sc, nil, nil)

					threadRdr.rgMeta = meta

					bf, err := threadRdr.GetColumnBloomFilter(0)
					if err != nil {
						return
					}

					_ = bf
				}
			}(w)
		}

		close(startSignal)
		wg.Wait()
	}
}

go tool pprof -http=:8080 mem.out

github.com/apache/arrow-go/v18/parquet/metadata.(*RowGroupBloomFilterReader).GetColumnBloomFilter
/home/alekssmirnov/GolandProjects/arrow-go/parquet/metadata/bloom_filter.go

Total: 0 3.19GB (flat, cum) 99.86%
490 . . compression: *header.Compression,
491 . . }, nil
492 . . }
493 . .
494 . . headerBuf := r.bufferPool.Get().(*memory.Buffer)
495 . 2.37GB headerBuf.ResizeNoShrink(int(bloomFilterReadSize))
496 . . defer func() {
497 . . if headerBuf != nil {
498 . . headerBuf.ResizeNoShrink(0)
499 . . r.bufferPool.Put(headerBuf)
500 . . }
501 . . }()
502 . .
503 . . if _, err = sectionRdr.Read(headerBuf.Bytes()); err != nil {
504 . . return nil, err
505 . . }
506 . .
507 . . remaining, err := thrift.DeserializeThrift(&header, headerBuf.Bytes())
508 . . if err != nil {
509 . . return nil, err
510 . . }
511 . . headerSize := len(headerBuf.Bytes()) - int(remaining)
512 . .
513 . . if err = validateBloomFilterHeader(&header); err != nil {
514 . . return nil, err
515 . . }
516 . .
517 . . bloomFilterSz := header.NumBytes
518 . . var bitset []byte
519 . . if int(bloomFilterSz)+headerSize <= len(headerBuf.Bytes()) {
520 . . // bloom filter data is entirely contained in the buffer we just read
521 . . bitset = headerBuf.Bytes()[headerSize : headerSize+int(bloomFilterSz)]
522 . . } else {
523 . . buf := r.bufferPool.Get().(*memory.Buffer)
524 . 835.71MB buf.ResizeNoShrink(int(bloomFilterSz))
525 . . filterBytesInHeader := headerBuf.Len() - headerSize
526 . . if filterBytesInHeader > 0 {
527 . . copy(buf.Bytes(), headerBuf.Bytes()[headerSize:])
528 . . }
529 . .
530 . . if _, err = sectionRdr.Read(buf.Bytes()[filterBytesInHeader:]); err != nil {
531 . . return nil, err
532 . . }
533 . . bitset = buf.Bytes()
534 . . headerBuf.ResizeNoShrink(0)
535 . . r.bufferPool.Put(headerBuf)
536 . . headerBuf = buf
537 . . }
538 . .
539 . . bf := &blockSplitBloomFilter{
540 . . data: headerBuf,
541 . . bitset32: arrow.GetDatauint32,
542 . . hasher: xxhasher{},
543 . . algorithm: *header.Algorithm,
544 . . hashStrategy: *header.Hash,
545 . . compression: *header.Compression,
546 . . }
547 . . headerBuf = nil
548 . . addCleanup(bf, r.bufferPool)
549 . . return bf, nil
550 . . }
551 . .
552 . . type FileBloomFilterBuilder struct {
553 . . Schema *schema.Schema

@zeroshade

Copy link
Copy Markdown
Member

Thanks for the extra benchmark @Patzifist. Before we iterate on the implementation, I want to make sure we're measuring the right thing — I don't think these numbers show what they appear to.

What the benchmark is actually measuring

Both rows ran at b.N = 1 (the 1 immediately after the benchmark name). The entire 65 × 100 = 6,500-call concurrent stress loop runs inside a single benchmark iteration, so the testing framework divides the totals by N = 1. That means B/op and allocs/op are not "per GetColumnBloomFilter call" — they're the totals for one 6,500-call batch.

I confirmed this with the ReproduceProductionLeak benchmark in the PR by varying -benchtime:

-benchtime=1x    1    631 MB/op    209135 allocs/op
-benchtime=3x    3    318 MB/op    208677 allocs/op

allocs/op stays at ~209k regardless of N, which confirms the "op" is one whole batch. The real per-filter cost is ~209,000 / 6,500 ≈ ~32 allocs per actual filter read, not 209k.

Two related points:

  • B/op is derived from TotalAlloc, which is cumulative allocation (churn), not retained memory. It only ever increases, even as the GC frees everything, so by itself it can't demonstrate a leak or an OOM.
  • Taking the two rows at face value, the patched path is ~24% slower (1.461s vs 1.177s) for ~10% fewer allocations (209k vs 233k) — the opposite of the trade-off we'd want.

What I do agree is real

There is something worth fixing, and it's what we converged on earlier in the thread: today the bitset buffer is only returned to the pool via runtime.AddCleanup, which is GC-timing dependent. Under a burst those returns lag and cumulative churn climbs — I measure ~3.1 GB of churn on the cleanup-only path vs ~75 MB when the same buffer is returned to the pool promptly. The pool pollution point (small headers and MB-sized bitsets sharing one pool) is also legitimate. So "return the buffer to the per-reader pool more promptly" is a good goal.

What would help: a proper reproducer

To justify a change of this size, could you put together a benchmark that isolates the claim? Specifically:

  1. One op = one GetColumnBloomFilter call — use b.RunParallel with b.ResetTimer() / b.ReportAllocs() so B/op and allocs/op are genuinely per-call. The scenario B/C harness I posted above is a good starting template.
  2. Use a non-zero BloomFilterOffset so the read path actually runs — assert Size() returns the expected byte count. The original repro used offset = 0, which returns nil, nil before the pool, header, or bitset are ever touched.
  3. Keep allocations that aren't under test out of the measured loop — in the original harness the make([]byte, 2 MiB) per iteration was itself ~13 GB of the churn (6,500 × 2 MiB), unrelated to the pool.
  4. If the claim is OOM / unbounded growth, measure retained memoryHeapInuse/HeapAlloc sampled over time (or RSS), not TotalAlloc. A genuine leak shows up as live memory that doesn't drop after a runtime.GC().
  5. Compare against current main on equal footing — same allocator, and the same buffer-return discipline on both sides (either both rely on cleanup, or both return promptly).

If you can share a repro that shows per-call allocations and/or retained-memory growth on current main, that gives us a concrete target to optimize against and to verify any fix. Happy to review the harness once it's posted.

@zeroshade

Copy link
Copy Markdown
Member

Following up on my last comment with a concrete alternative direction. I measured the original path against a prompt-return path on current main (same 64×100 harness you posted, b.N=1, go1.26.4), splitting the profile into alloc_space (churn) vs inuse_space (live after the test's end-of-run GC):

Path alloc_space (churn) inuse_space (retained) time/batch
Current main (return via runtime.AddCleanup only) ~5.1 GB ~3.0 GB 0.49 s
Return the bitset buffer to the pool promptly ~0.40 GB ~83 MB 0.088 s

So the per-reader sync.Pool already reuses correctly — the only problem is when the buffer goes back. Today the bitset buffer is returned only when the BloomFilter is GC'd (addCleanupruntime.AddCleanup). Under a burst those cleanups lag, so Get() keeps allocating fresh ~1 MB buffers. Returning the same buffer synchronously collapses both churn (~13×) and retained memory (~36×) and runs ~5.5× faster. The remaining ~83 MB is ≈ concurrency × 1 MB — one live buffer per in-flight goroutine, which is exactly the bounded steady-state we're after.

The only change from your BenchmarkGetColumnBloomFilter_OriginalSyncPool that produces the second row is returning the buffer to the pool instead of _ = bf:

bf, err := threadRdr.GetColumnBloomFilter(0)
if err != nil {
    return
}

// return the bitset buffer to the pool immediately instead of waiting for GC
if b, ok := bf.(*blockSplitBloomFilter); ok && b.data != nil {
    if b.cancelCleanup != nil {
        b.cancelCleanup() // cancel the AddCleanup so the buffer isn't returned twice
    }
    b.data.ResizeNoShrink(0)
    originalArrowPool.Put(b.data)
}

Same harness, same pool, same allocator — only the return timing changes, and that alone accounts for the 5.1 GB → 0.40 GB / 3.0 GB → 83 MB difference. (There's also a real pool-pollution issue: file.Reader hands the bloom reader the same sync.Pool the column/record readers use, so MB-sized bitsets and small page buffers churn each other.)

Proposal

Keep GetColumnBloomFilter and its AddCleanup exactly as they are (back-compat + a GC safety net so nothing leaks if a caller retains the filter), and add an opt-in prompt-return path. Two possible shapes — I lean toward (A):

Option A — scoped accessor (preferred):

func (r *RowGroupBloomFilterReader) VisitColumnBloomFilter(i int, fn func(BloomFilter) error) error {
    bf, err := r.GetColumnBloomFilter(i)
    if err != nil || bf == nil {
        return err
    }
    defer r.recycle(bf) // cancelCleanup() + data.ResizeNoShrink(0) + pool.Put(data)
    return fn(bf)
}

The buffer returns to the pool the instant the callback ends — deterministic, lexically-scoped lifetime, hard to misuse.

Option B — explicit recycle:

bf, err := rg.GetColumnBloomFilter(i)
// ... use bf ...
rg.RecycleBloomFilter(bf) // cancels the cleanup, ResizeNoShrink(0), Put back to pool

More flexible for callers who can't wrap usage in a closure, but easier to forget (forgetting just falls back to today's GC behavior, so it's safe, only slower).

Both call the same helper that cancels the registered cleanup (so the buffer isn't double-returned) and puts it back. Either can pair with giving the bloom reader its own per-reader pool, separate from the page-buffer pool, to fix the pollution.

Why this over the current PR

The PR reaches a similar steady-state, but the way it gets there trips each of the constraints I called out earlier:

  • Public API break — it adds Close() to the BloomFilter interface; every external implementer/consumer breaks. The proposal adds a method on the reader and leaves BloomFilter untouched.
  • Allocator bypass — it reads headers into raw make([]byte, …); the proposal keeps everything in allocator-backed *memory.Buffers.
  • Process-global pools — it declares package-level headerPool/filterPool channels (currently unused, but the wrong direction); the proposal keeps strictly per-reader pools.
  • Removes the GC safety net — it drops AddCleanup on the read path, so a filter backed by a CGO/mallocator allocator leaks if the caller never calls Close(). The proposal keeps AddCleanup as the fallback.
  • Use-after-recycleClose() resets the buffer to full cap and pushes it into a shared pool while the returned filter's bitset32 still aliases that memory; a concurrent reader can grab and overwrite it. The scoped accessor bounds the lifetime so that can't happen.

Net: same bounded-memory result, no interface break, no allocator bypass, no global pools, and the existing safety net stays. Happy to put up a PR with Option A + the dedicated pool if that direction works for you.

Introduce BenchmarkVisitColumnBloomFilter_SyncPool to measure
the performance and memory allocations of visiting column Bloom filters
when utilizing a sync.Pool.

Benchmark results on Intel i7-1355U:
- Operations: 9330
- Speed: 114338 ns/op
- Memory: 13946 B/op
- Allocations: 17 allocs/op
@Patzifist

Copy link
Copy Markdown
Author

Based on your feedback, I have updated the PR to implement Option A (scoped accessor):

  1. Implemented VisitColumnBloomFilter: Added the scoped accessor method with a closure func(BloomFilter) error. It uses defer to promptly recycle the buffer right after the callback terminates.
  2. Deterministic recycling: The internal helper cancels the AddCleanup to prevent double-returning, shrinks the buffer using ResizeNoShrink(0), and safely returns it to the pool.
  3. Fixed the Benchmark: Rewrote the benchmark using b.RunParallel and proper isolated calls (b.ResetTimer() / b.ReportAllocs()) so that B/op and allocs/op now genuinely reflect a single filter read.

Please take a look when you have a moment!

Introduce BenchmarkVisitColumnBloomFilter_SyncPool to measure
the performance and memory allocations of visiting column Bloom filters
when utilizing a sync.Pool.

Benchmark results on Intel i7-1355U:
- Operations: 9330
- Speed: 114338 ns/op
- Memory: 13946 B/op
- Allocations: 17 allocs/op

@zeroshade zeroshade left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Thanks for reworking this to the scoped-accessor approach — this is a big improvement. It resolves the earlier concerns: GetColumnBloomFilter and its runtime.AddCleanup safety net are intact, the public BloomFilter interface is untouched (no Close()), the process-global pools and the make([]byte) allocator bypass are gone, and the benchmark now measures a single filter read. I built the branch and ran BenchmarkVisitColumnBloomFilter_SyncPool: ~17 allocs/op, ~3.8 KB/op — the ~1 MB bitset is reused across calls exactly as intended.

Two things need to be fixed before this can merge (both marked inline), plus a few minor items:

  • [blocker] Missing ASF license header on the new bloom_filter_benchmark_test.go. Apache RAT (dev/release/run_rat.sh) and the pre-commit license hook will fail CI.
  • [blocker] recycle returns the encrypted path's buffer to the pool, which pushes decrypted data into the shared pool and panics on the next reuse (nil allocator). Reproducer is inline.

Still open, but fine as a follow-up (not blocking this PR): the bloom reader still shares file.Reader.bufferPool with the column/record readers (parquet/file/file_reader.go), so the small-header vs MB-bitset pool pollution remains.

Details inline.

Comment thread parquet/metadata/bloom_filter.go Outdated
Comment thread parquet/metadata/bloom_filter.go
Comment thread parquet/metadata/bloom_filter_benchmark_test.go
Comment thread parquet/metadata/bloom_filter_benchmark_test.go Outdated
Comment thread parquet/metadata/bloom_filter_benchmark_test.go Outdated
- Fixed `RowGroupBloomFilterReader.recycle` to only put buffers back
  into the sync.Pool if they were allocated by it (checking `cancelCleanup`).
  This prevents pool corruption and panics on the encrypted path.
- Fixed `prepareTestHarness` in benchmarks to dynamically advance
  the payload offset and assign unique pointers, ensuring accurate
  multi-size testing instead of overwriting the same memory location.
- Corrected `BloomFilterLength` in benchmarks to prevent slice out of bounds panic.
@Patzifist Patzifist changed the title fix(parquet/metadata): Fix sync.Pool memory leak and capacity destruction in GetColumnBloomFilter fix(parquet/metadata): fix BloomFilter memory recycling, doc contract, and benchmark Jul 2, 2026

@zeroshade zeroshade left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Re-review of the latest commit (074bfed). Thanks for pushing this forward — the two prior blockers are resolved:

  • ✅ ASF license header added to bloom_filter_benchmark_test.go.
  • ✅ Encrypted path no longer pushes a nil-allocator buffer into the pool — recycle gates on cancelCleanup and Release()s otherwise (and Release() on a NewBufferBytes buffer is a safe no-op, since mem/parent are both nil).

Minor items also addressed: ✅ lifetime-contract doc comment on VisitColumnBloomFilter, ✅ dead _Channels benchmark removed, ✅ benchmark now gives each meta its own offset/size.

One new blocker in that same recycle gating (inline): it nils cancelCleanup instead of calling it, so the GC cleanup registered by addCleanup stays armed and returns the buffer to the pool a second time — a double-Put (same buffer handed to two goroutines) on Go 1.24+, and a finalizer nil-deref on Go ≤1.23. CI doesn't run the benchmark under -race, and the second Put only fires after a GC, so the suite stays green while the corruption stays latent.

Non-blocking: this PR adds VisitColumnBloomFilter but nothing in the library calls it, so the win only lands for callers who opt into the new API; GetColumnBloomFilter users still rely on GC recycling. Fine if that's the intent.

Details inline.

Comment thread parquet/metadata/bloom_filter.go
Comment thread parquet/metadata/bloom_filter.go Outdated
Patzifist and others added 2 commits July 5, 2026 22:59
Co-authored-by: Matt Topol <zotthewizard@gmail.com>
…BloomFilter` upper defer block back to the original `headerBuf.ResizeNoShrink(0)`. This guarantees that the resource recycling and array length scrubbing styles remain strictly uniform and consistent across the entire metadata codebase.
@Patzifist

Copy link
Copy Markdown
Author

Thank you for the guidance and the direct patch pointers, @zeroshade!
The PR is now fully streamlined and ready for your final review!

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