Skip to content

Commit 08c67ff

Browse files
committed
feat: Introduce FetchInto method for zero-allocation batch fetch operations
- Added FetchInto method to Segment interface for optimized data retrieval using a pre-allocated FetchArena. - Implemented FetchInto in various segment types (diskann, flat, memtable) to reduce allocations during batch fetches. - Enhanced metadata handling with UnmarshalBinaryInto for efficient deserialization into pre-allocated maps. - Updated benchmarks to evaluate performance improvements with FetchArena pooling. - Added new search options: WithMetadata and WithPayload for more granular control over returned data. - Introduced FetchArena struct to manage pre-allocated scratch space for fetch operations, significantly reducing memory allocations.
1 parent c416384 commit 08c67ff

13 files changed

Lines changed: 1169 additions & 131 deletions

File tree

benchmark_test/baseline.txt

Lines changed: 124 additions & 121 deletions
Large diffs are not rendered by default.

benchmark_test/fast_bench_test.go

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,95 @@ func BenchmarkFastSearch(b *testing.B) {
123123
}
124124
}
125125

126+
// BenchmarkFastSearchWithData benchmarks search WITH metadata/vectors returned.
127+
// This exercises the FetchInto/Clone path which is the allocation hotspot.
128+
func BenchmarkFastSearchWithData(b *testing.B) {
129+
ctx := context.Background()
130+
131+
fixtureName := "uniform_128d_50k"
132+
if testing.Short() {
133+
fixtureName = "uniform_128d_10k"
134+
}
135+
136+
if !FixtureExists(fixtureName) {
137+
b.Skipf("fixture %q not found", fixtureName)
138+
}
139+
140+
db, err := OpenFixture(ctx, fixtureName)
141+
if err != nil {
142+
b.Fatalf("open fixture: %v", err)
143+
}
144+
defer db.Close()
145+
146+
data, err := LoadFixtureData(fixtureName)
147+
if err != nil {
148+
b.Fatalf("load fixture data: %v", err)
149+
}
150+
151+
queries := data.Queries
152+
const k = 10
153+
154+
// Test different data inclusion patterns
155+
// Default includes metadata+payload, WithoutData excludes all, WithVector adds vector
156+
b.Run("id_only", func(b *testing.B) {
157+
b.ReportAllocs()
158+
b.ResetTimer()
159+
160+
for i := 0; i < b.N; i++ {
161+
q := queries[i%len(queries)]
162+
results, err := db.Search(ctx, q, k, vecgo.WithoutData())
163+
if err != nil {
164+
b.Fatal(err)
165+
}
166+
if len(results) == 0 {
167+
b.Fatal("no results")
168+
}
169+
}
170+
171+
b.StopTimer()
172+
b.ReportMetric(float64(b.N)/b.Elapsed().Seconds(), "qps")
173+
})
174+
175+
b.Run("with_metadata_payload", func(b *testing.B) {
176+
// Default behavior - includes metadata and payload
177+
b.ReportAllocs()
178+
b.ResetTimer()
179+
180+
for i := 0; i < b.N; i++ {
181+
q := queries[i%len(queries)]
182+
results, err := db.Search(ctx, q, k)
183+
if err != nil {
184+
b.Fatal(err)
185+
}
186+
if len(results) == 0 {
187+
b.Fatal("no results")
188+
}
189+
}
190+
191+
b.StopTimer()
192+
b.ReportMetric(float64(b.N)/b.Elapsed().Seconds(), "qps")
193+
})
194+
195+
b.Run("with_vector", func(b *testing.B) {
196+
b.ReportAllocs()
197+
b.ResetTimer()
198+
199+
for i := 0; i < b.N; i++ {
200+
q := queries[i%len(queries)]
201+
results, err := db.Search(ctx, q, k, vecgo.WithVector())
202+
if err != nil {
203+
b.Fatal(err)
204+
}
205+
if len(results) == 0 {
206+
b.Fatal("no results")
207+
}
208+
}
209+
210+
b.StopTimer()
211+
b.ReportMetric(float64(b.N)/b.Elapsed().Seconds(), "qps")
212+
})
213+
}
214+
126215
// BenchmarkFastBatchSearch benchmarks batch search performance.
127216
func BenchmarkFastBatchSearch(b *testing.B) {
128217
ctx := context.Background()

internal/engine/search.go

Lines changed: 26 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1035,25 +1035,47 @@ func (e *Engine) SearchIter(ctx context.Context, q []float32, k int, opts ...fun
10351035
s.Results[i+k].ID = s.ScratchForeignIDs[k]
10361036
}
10371037
} else {
1038-
batch, fErr := seg.Fetch(ctx, s.ScratchIDs, cols)
1038+
// Use FetchInto with pooled arena for efficient deserialization.
1039+
// Arena provides pre-sized maps and backing arrays to reduce allocations
1040+
// during unmarshaling. We then COPY to user-owned memory for safe ownership.
1041+
arena := segment.GetFetchArena()
1042+
arena.Reset(countBatch)
1043+
arena.EnsureCapacity(countBatch, e.dim)
1044+
arena.SetDimension(e.dim)
1045+
1046+
batch, fErr := seg.FetchInto(ctx, s.ScratchIDs, cols, arena)
10391047
if fErr != nil {
1048+
segment.PutFetchArena(arena)
10401049
err = fErr
10411050
yield(model.Candidate{}, err)
10421051
return
10431052
}
10441053

1054+
// Copy arena data to user-owned memory for safe ownership.
1055+
// Arena is returned to pool immediately after copy.
10451056
for k := 0; k < countBatch; k++ {
10461057
s.Results[i+k].ID = batch.ID(k)
10471058
if options.IncludeVector {
1048-
s.Results[i+k].Vector = batch.Vector(k)
1059+
// Clone vector - user needs to own this
1060+
if src := batch.Vector(k); src != nil {
1061+
s.Results[i+k].Vector = slices.Clone(src)
1062+
}
10491063
}
10501064
if options.IncludeMetadata {
1051-
s.Results[i+k].Metadata = batch.Metadata(k)
1065+
// Clone metadata map - user needs to own this
1066+
if src := batch.Metadata(k); src != nil {
1067+
s.Results[i+k].Metadata = src.Clone()
1068+
}
10521069
}
10531070
if options.IncludePayload {
1054-
s.Results[i+k].Payload = batch.Payload(k)
1071+
// Clone payload - user needs to own this
1072+
if src := batch.Payload(k); src != nil {
1073+
s.Results[i+k].Payload = slices.Clone(src)
1074+
}
10551075
}
10561076
}
1077+
// Return arena to pool immediately - all data has been copied
1078+
segment.PutFetchArena(arena)
10571079
}
10581080
}
10591081
i = j

internal/searcher/searcher.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -284,6 +284,9 @@ func (s *Searcher) Reset() {
284284
s.QueryBitmap.Clear()
285285
}
286286

287+
// Note: FetchArena is managed by the engine via sync.Pool, not by Searcher.
288+
// This avoids an import cycle (segment -> searcher -> segment).
289+
287290
s.OpsPerformed = 0
288291
s.FilterGateStats.Reset()
289292
}

internal/segment/diskann/segment.go

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -856,6 +856,115 @@ func (s *Segment) Fetch(ctx context.Context, rows []uint32, cols []string) (segm
856856
return batch, nil
857857
}
858858

859+
// FetchInto resolves RowIDs to payload columns using a pre-allocated arena.
860+
// This is the zero-allocation hot path for batch fetch operations.
861+
func (s *Segment) FetchInto(ctx context.Context, rows []uint32, cols []string, arena *segment.FetchArena) (*segment.SimpleRecordBatch, error) {
862+
fetchVectors := cols == nil
863+
fetchMetadata := cols == nil
864+
fetchPayloads := cols == nil
865+
866+
if cols != nil {
867+
fetchVectors = false
868+
for _, c := range cols {
869+
switch c {
870+
case "vector":
871+
fetchVectors = true
872+
case "metadata":
873+
fetchMetadata = true
874+
case "payload":
875+
fetchPayloads = true
876+
}
877+
}
878+
}
879+
880+
dim := int(s.header.Dim)
881+
batchSize := len(rows)
882+
883+
// Ensure arena has capacity and reset for this batch
884+
arena.EnsureCapacity(batchSize, dim)
885+
arena.Reset(batchSize)
886+
arena.SetDimension(dim)
887+
888+
// Pre-size slices
889+
arena.IDs = arena.IDs[:batchSize]
890+
if fetchVectors {
891+
arena.Vectors = arena.Vectors[:batchSize]
892+
}
893+
if fetchMetadata {
894+
arena.Metadatas = arena.Metadatas[:batchSize]
895+
}
896+
if fetchPayloads {
897+
arena.Payloads = arena.Payloads[:batchSize]
898+
}
899+
900+
for i, rowID := range rows {
901+
// Periodic context check (every 64 rows)
902+
if i&63 == 0 {
903+
select {
904+
case <-ctx.Done():
905+
return nil, ctx.Err()
906+
default:
907+
}
908+
}
909+
910+
if rowID >= s.header.RowCount {
911+
return nil, fmt.Errorf("rowID %d out of bounds", rowID)
912+
}
913+
914+
// Fetch ID
915+
if id, ok := s.GetID(ctx, rowID); ok {
916+
arena.IDs[i] = id
917+
} else {
918+
return nil, fmt.Errorf("failed to get ID for row %d", rowID)
919+
}
920+
921+
// Fetch Vector
922+
if fetchVectors {
923+
vec, err := s.Get(ctx, rowID)
924+
if err != nil {
925+
return nil, err
926+
}
927+
// Use arena's backing array
928+
dst := arena.AllocVectorSlice(i)
929+
copy(dst, vec)
930+
arena.Vectors[i] = dst
931+
}
932+
933+
// Fetch Metadata (zero-alloc via pooled map)
934+
if fetchMetadata {
935+
md, err := s.readMetadata(ctx, rowID)
936+
if err != nil {
937+
return nil, err
938+
}
939+
// Copy into arena's pooled metadata map
940+
if md != nil {
941+
pooledMd := arena.AcquireMetadata(i)
942+
for k, v := range md {
943+
(*pooledMd)[k] = v
944+
}
945+
arena.Metadatas[i] = *pooledMd
946+
}
947+
}
948+
949+
// Fetch Payload
950+
// TODO: Optimize with arena.PayloadBacking for batch reads
951+
if fetchPayloads && s.payloadBlob != nil && rowID < s.payloadCount {
952+
start := s.payloadOffsets[rowID]
953+
end := s.payloadOffsets[rowID+1]
954+
size := end - start
955+
dataOffset := 4 + uint64(s.payloadCount+1)*8 + start
956+
957+
p := make([]byte, size)
958+
if _, err := s.payloadBlob.ReadAt(ctx, p, int64(dataOffset)); err != nil {
959+
return nil, err
960+
}
961+
arena.Payloads[i] = p
962+
}
963+
}
964+
965+
return arena.BuildRecordBatch(fetchVectors, fetchMetadata, fetchPayloads), nil
966+
}
967+
859968
func (s *Segment) FetchIDs(ctx context.Context, rows []uint32, dst []model.ID) error {
860969
if len(dst) != len(rows) {
861970
return fmt.Errorf("dst length mismatch")

0 commit comments

Comments
 (0)