Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 33 additions & 3 deletions index/builder.go
Original file line number Diff line number Diff line change
Expand Up @@ -275,6 +275,11 @@ type Builder struct {
id string

finishCalled bool

// postingsPool reuses postingsBuilder instances across shard builds,
// retaining their map and slice allocations to avoid repeated
// memclr/madvise overhead.
postingsPool sync.Pool
Comment on lines +278 to +282
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'm kinda surprised this pool has a hit rate. Under what conditions did you test this? Did you have smaller shard size?

The kubernetes repo is a handful of shards right? With concurrency the opportunity to re-use an older postingsPool before garbage collection has had a chance to free stuff from sync.Pool seems low?

Maybe the GC just doesn't free as often as I would expect from sync.Pool.

If you are just testing this with all the normal defaults (including GOGC being on, etc) and its working cool lets use this optimization. If you are tweaking the defaults somehow, then we could probably do better with maintaining our own free list instead of relying on sync.Pool

Copy link
Copy Markdown
Contributor Author

@clemlesne clemlesne Mar 20, 2026

Choose a reason for hiding this comment

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

Default Parallelism is 4, so up to 4 shards build concurrently. sync.Pool has per-P caching — each goroutine gets/puts on its own P cache without contention. After a goroutine finishes and Puts, the next goroutine scheduled on that P Gets the cached builder. Worst case (GC clears pool between shards) = fresh allocation, identical to no pool.

Tested with normal defaults (GOGC=100, no tuning).

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.

Do you get hits outside of benchmarks though? Mind testing end-to-end with something like zoekt-git-index (or some other zoekt binary) and adding in a log line or something on if we successfully get a value out of this sync.Pool.

I agree this is useful optimization, especially for much larger repos or the shard size is tuned down so we have more shards. However, I have a suspicion in practice sync.Pool for this has a bad hitrate since GC would of run by the time sync.Pool.Get is called. So we would rather need to implement our own free list than just plugging in sync.Pool to get the benefit in practice.

But this is all a suspicion of mine, would be great to get confirmation.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Instrumented getPostingsBuilder with a log line and tested end-to-end with zoekt-git-index on kubernetes (23K files, 162 MB):

Shard size Shards Gets Hits Misses Hit rate
100 MB (default) 3 7 2 5 29%
10 MB ~25 51 30 21 59%

Your suspicion is partially confirmed — at default shard size (few large shards), the hit rate is poor. But with smaller shards it reaches 59%, and the pool never hurts (miss = fresh allocation = status quo).

Since Go 1.13, sync.Pool uses a victim cache that survives one extra GC cycle, so objects aren't cleared immediately — they get ~2 cycles. The 29% at 100 MB shards reflects that shard builds are still long enough for 2+ GC cycles to pass.

I think keeping the pool is the right call:

  • No downside on miss — same as no pool
  • 59% at 10 MB shards is meaningful, especially for users tuning shard size down
  • Larger repos with more shards will also see higher hit rates
  • Zero config — no sizing decisions like a channel-based free list would require

Separately, I added a sparse index (asciiPopulated []uint32) that tracks populated ASCII array slots. This makes reset() and writePostings iterate only ~275K populated entries instead of scanning all 2M slots, while still retaining all postingList allocations for pool reuse. The warm-path benchmark confirms identical B/op (0.55 MB) and allocs/op (22,978) to the original.

}

type finishedShard struct {
Expand Down Expand Up @@ -984,7 +989,9 @@ func (b *Builder) buildShard(todo []*Document, nextShardNum int) (*finishedShard
}
}

return b.writeShard(name, shardBuilder)
result, err := b.writeShard(name, shardBuilder)
b.returnPostingsBuilders(shardBuilder)
return result, err
}

// CheckMemoryUsage checks the memory usage of the process and writes a memory profile if the heap usage exceeds the
Expand Down Expand Up @@ -1018,14 +1025,37 @@ func (b *Builder) CheckMemoryUsage() {
}
}

func (b *Builder) getPostingsBuilder() *postingsBuilder {
if pb, ok := b.postingsPool.Get().(*postingsBuilder); ok {
pb.reset()
return pb
}
return newPostingsBuilder(b.opts.ShardMax)
}

// returnPostingsBuilders returns both postings builders from sb to the
// pool and nils the fields so any subsequent misuse crashes obviously.
func (b *Builder) returnPostingsBuilders(sb *ShardBuilder) {
if sb.contentPostings != nil {
b.postingsPool.Put(sb.contentPostings)
sb.contentPostings = nil
}
if sb.namePostings != nil {
b.postingsPool.Put(sb.namePostings)
sb.namePostings = nil
}
}

func (b *Builder) newShardBuilder() (*ShardBuilder, error) {
desc := b.opts.RepositoryDescription
desc.HasSymbols = !b.opts.DisableCTags && b.opts.CTagsPath != ""
desc.SubRepoMap = b.opts.SubRepositories
desc.IndexOptions = b.opts.GetHash()

shardBuilder, err := NewShardBuilder(&desc)
if err != nil {
content := b.getPostingsBuilder()
name := b.getPostingsBuilder()
shardBuilder := newShardBuilderWithPostings(content, name)
if err := shardBuilder.setRepository(&desc); err != 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.

an error here is super rare, is it worth complicating matter and doing a put after this?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Agreed, removed in ad39bc9.

return nil, err
}
shardBuilder.IndexTime = b.indexTime
Expand Down
4 changes: 2 additions & 2 deletions index/index_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ func testShardBuilder(tb testing.TB, repo *zoekt.Repository, docs ...Document) *
func testShardBuilderCompound(t *testing.T, repos []*zoekt.Repository, docs [][]Document) *ShardBuilder {
t.Helper()

b := newShardBuilder()
b := newShardBuilder(0)
b.indexFormatVersion = NextIndexFormatVersion

if len(repos) != len(docs) {
Expand Down Expand Up @@ -2144,7 +2144,7 @@ func TestMetadata(t *testing.T) {
}

func TestRepoWithMetadata(t *testing.T) {
sb := newShardBuilder()
sb := newShardBuilder(0)
sb.repoList = []zoekt.Repository{
{
Name: "repo1",
Expand Down
4 changes: 2 additions & 2 deletions index/merge.go
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,7 @@ func merge(ds ...*indexData) (*ShardBuilder, error) {
return ds[i].repoMetaData[0].GetPriority() > ds[j].repoMetaData[0].GetPriority()
})

sb := newShardBuilder()
sb := newShardBuilder(0)
sb.indexFormatVersion = NextIndexFormatVersion

for _, d := range ds {
Expand Down Expand Up @@ -246,7 +246,7 @@ func explode(dstDir string, f IndexFile, ibFuncs ...shardBuilderFunc) (map[strin
}
}

sb = newShardBuilder()
sb = newShardBuilder(0)
sb.indexFormatVersion = IndexFormatVersion
if err := sb.setRepository(&d.repoMetaData[repoID]); err != nil {
return shardNames, err
Expand Down
150 changes: 150 additions & 0 deletions index/postings_bench_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
package index

import (
"bytes"
"fmt"
"io/fs"
"os"
"path/filepath"
"testing"
)

// Set ZOEKT_BENCH_REPO to a source tree (e.g. a kubernetes checkout) to enable.
//
// git clone --depth=1 https://github.com/kubernetes/kubernetes /tmp/k8s
// ZOEKT_BENCH_REPO=/tmp/k8s go test ./index/ -bench=BenchmarkPostings -benchmem -count=5 -timeout=600s

func requireBenchRepo(b *testing.B) string {
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.

FYI we have some infra here to download copies of code for these sorts of tests internal/e2e/e2e_rank_test.go. Not sure if worth using here. But if there is a way to maybe share ideas / infra with that... that would be great.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Good pointer, thanks. Will look into aligning with e2e_rank_test.go infra in a follow-up.

b.Helper()
dir := os.Getenv("ZOEKT_BENCH_REPO")
if dir == "" {
b.Skip("ZOEKT_BENCH_REPO not set")
}
return dir
}

// loadRepoFiles walks dir and returns file contents, skipping binary files,
// empty files, and anything over 1 MB. Returns at most maxFiles entries.
func loadRepoFiles(b *testing.B, dir string, maxFiles int) [][]byte {
b.Helper()
var files [][]byte
err := filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return nil
}
if d.IsDir() {
switch d.Name() {
case ".git", "vendor", "node_modules":
return filepath.SkipDir
}
return nil
}
if len(files) >= maxFiles {
return filepath.SkipAll
}
info, err := d.Info()
if err != nil || info.Size() == 0 || info.Size() > 1<<20 {
return nil
}
data, err := os.ReadFile(path)
if err != nil {
return nil
}
if bytes.IndexByte(data, 0) >= 0 {
return nil // binary
}
files = append(files, data)
return nil
})
if err != nil {
b.Fatalf("walking repo: %v", err)
}
if len(files) == 0 {
b.Fatal("no files found in repo")
}
return files
}

func totalSize(files [][]byte) int64 {
var n int64
for _, f := range files {
n += int64(len(f))
}
return n
}

// BenchmarkPostings_NewSearchableString measures the core hot path: trigram
// extraction, map lookups, delta encoding, and per-trigram slice growth.
// Sub-benchmarks vary corpus size to show scaling with map size.
func BenchmarkPostings_NewSearchableString(b *testing.B) {
dir := requireBenchRepo(b)
allFiles := loadRepoFiles(b, dir, 50_000)
b.Logf("loaded %d files, %.1f MB", len(allFiles), float64(totalSize(allFiles))/(1<<20))

for _, n := range []int{1_000, 5_000, len(allFiles)} {
n = min(n, len(allFiles))
files := allFiles[:n]
size := totalSize(files)

b.Run(fmt.Sprintf("files=%d", n), func(b *testing.B) {
b.ReportAllocs()
for b.Loop() {
pb := newPostingsBuilder(defaultShardMax)
for _, data := range files {
_, _, _ = pb.newSearchableString(data, nil)
}
}
b.ReportMetric(float64(size), "input-bytes/op")
})
}
}

// BenchmarkPostings_Reuse measures the warm path: building postings with a
// reset (pooled) postingsBuilder that retains its map and slice allocations
// from a previous shard build.
func BenchmarkPostings_Reuse(b *testing.B) {
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 don't think this accurately measures the actual impact on reuse. This is since we won't be calling newPostingsBuilder in a hot loop. Instead it will be called once per shard.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

You're right — the Reuse benchmark measures allocation reduction potential, not realistic shard-building throughput. The real end-to-end impact measured via hyperfine on kubernetes:

Benchmark 1 (before): 11.373 s ± 0.174 s
Benchmark 2 (after):   7.866 s ± 0.378 s  (1.45x faster, no ctags)

Benchmark 1 (before): 20.478 s ± 0.465 s
Benchmark 2 (after):  16.588 s ± 0.119 s  (1.23x faster, with ctags)

dir := requireBenchRepo(b)
allFiles := loadRepoFiles(b, dir, 50_000)
size := totalSize(allFiles)
b.Logf("loaded %d files, %.1f MB", len(allFiles), float64(size)/(1<<20))

// Warm up the builder so it has allocated map entries and slices.
pb := newPostingsBuilder(defaultShardMax)
for _, data := range allFiles {
_, _, _ = pb.newSearchableString(data, nil)
}

b.ResetTimer()
b.ReportAllocs()
for b.Loop() {
pb.reset()
for _, data := range allFiles {
_, _, _ = pb.newSearchableString(data, nil)
}
}
b.ReportMetric(float64(size), "input-bytes/op")
}

// BenchmarkPostings_WritePostings measures the marshaling path: sorting ngram
// keys and writing varint-encoded posting lists.
func BenchmarkPostings_WritePostings(b *testing.B) {
dir := requireBenchRepo(b)
allFiles := loadRepoFiles(b, dir, 50_000)

pb := newPostingsBuilder(defaultShardMax)
for _, data := range allFiles {
_, _, _ = pb.newSearchableString(data, nil)
}
b.Logf("built %d unique ngrams from %d files, %.1f MB", len(pb.postings), len(allFiles), float64(totalSize(allFiles))/(1<<20))

buf := &bytes.Buffer{}
b.ResetTimer()
b.ReportAllocs()
for b.Loop() {
buf.Reset()
w := &writer{w: buf}
var ngramText, charOffsets, endRunes simpleSection
var postings compoundSection
writePostings(w, pb, &ngramText, &charOffsets, &postings, &endRunes)
}
}
Loading
Loading