Skip to content

Commit 94a8def

Browse files
committed
MB-72082: parallelize document build + training stats
Change-Id: If6d3a443c3e0916fcce6bc96e9cc7f3d47b8a643 Reviewed-on: https://review.couchbase.org/c/cbft/+/247864 Tested-by: <thejas.orkombu@couchbase.com> Reviewed-by: gautham <gautham.k@couchbase.com> Well-Formed: Build Bot <build@couchbase.com>
1 parent 09e8a03 commit 94a8def

4 files changed

Lines changed: 174 additions & 38 deletions

File tree

pindex_bleve.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -423,6 +423,9 @@ type trainer interface {
423423
wait() <-chan struct{}
424424
// cleanup any resources
425425
close() error
426+
427+
// a way to write out the training stats in JSON format
428+
WriteJSON(w io.Writer)
426429
}
427430

428431
func NewBleveDestEx(path string, bindex bleve.Index,
@@ -2426,6 +2429,8 @@ var prefixPIndexStoreStats = []byte(`{"pindexStoreStats":`)
24262429

24272430
var prefixCopyPartitionStats = []byte(`,"copyPartitionStats":`)
24282431

2432+
var prefixTrainingStats = []byte(`,"trainingStats":`)
2433+
24292434
var prefixHerderStats = []byte(`,"herderStats":`)
24302435

24312436
func writeHerderStatsJSON(w io.Writer) {
@@ -2587,6 +2592,13 @@ func (t *BleveDest) Stats(w io.Writer) (err error) {
25872592

25882593
t.copyStats.WriteJSON(w)
25892594

2595+
if t.trainingSampler != nil {
2596+
_, err = w.Write(prefixTrainingStats)
2597+
if err != nil {
2598+
return err
2599+
}
2600+
t.trainingSampler.WriteJSON(w)
2601+
}
25902602
_, err = w.Write(prefixHerderStats)
25912603
if err != nil {
25922604
return err

trainer_stats.go

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
// Copyright 2026-Present Couchbase, Inc.
2+
//
3+
// Use of this software is governed by the Business Source License included
4+
// in the file licenses/BSL-Couchbase.txt. As of the Change Date specified
5+
// in that file, in accordance with the Business Source License, use of this
6+
// software will be governed by the Apache License, Version 2.0, included in
7+
// the file licenses/APL2.txt.
8+
9+
//go:build vectors
10+
// +build vectors
11+
12+
package cbft
13+
14+
import (
15+
"fmt"
16+
"io"
17+
"sync/atomic"
18+
"time"
19+
20+
"github.com/couchbase/cbgt"
21+
)
22+
23+
// TrainingStats tracks the time spent in each phase of the vector index
24+
// sampling/training flow. The results are accumulated in nanoseconds, and
25+
// written out in milliseconds JSON format via WriteJSON.
26+
type TrainingStats struct {
27+
TotKVFetchTimeInNs uint64
28+
TotDocBuildTimeInNs uint64
29+
TotBatchTrainTimeInNs uint64
30+
TotTrainedIndexBuildTimeInNs uint64
31+
TotTrainedIndexCopyTimeInNs uint64
32+
}
33+
34+
func (s *TrainingStats) WriteJSON(w io.Writer) {
35+
toMs := func(ns uint64) float64 {
36+
return float64(ns) / float64(time.Millisecond)
37+
}
38+
39+
stats := fmt.Sprintf(`{"TotTrainedIndexBuildTimeInMs":%.3f`, toMs(atomic.LoadUint64(&s.TotTrainedIndexBuildTimeInNs)))
40+
stats += fmt.Sprintf(`,"TotKVFetchTimeInMs":%.3f`, toMs(atomic.LoadUint64(&s.TotKVFetchTimeInNs)))
41+
stats += fmt.Sprintf(`,"TotDocBuildTimeInMs":%.3f`, toMs(atomic.LoadUint64(&s.TotDocBuildTimeInNs)))
42+
stats += fmt.Sprintf(`,"TotBatchTrainTimeInMs":%.3f`, toMs(atomic.LoadUint64(&s.TotBatchTrainTimeInNs)))
43+
stats += fmt.Sprintf(`,"TotTrainedIndexCopyTimeInMs":%.3f`, toMs(atomic.LoadUint64(&s.TotTrainedIndexCopyTimeInNs)))
44+
w.Write([]byte(stats))
45+
w.Write(cbgt.JsonCloseBrace)
46+
}

trainer_vector.go

Lines changed: 113 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import (
2424
"strconv"
2525
"strings"
2626
"sync"
27+
"sync/atomic"
2728
"time"
2829

2930
"github.com/blevesearch/bleve/v2"
@@ -52,6 +53,13 @@ const (
5253
defaultNumSamplesPerCentroid = minSamplesPerCentroid
5354

5455
trainerWaitOnKV = 10 * time.Second
56+
57+
// sampleChannelBuffer decouples the KV scanner goroutines from the
58+
// BuildDocumentEx workers so neither side stalls on the other.
59+
sampleChannelBuffer = 256
60+
// sampleBuildWorkers is the number of goroutines that run BuildDocumentEx
61+
// in parallel inside trainOnSamples.
62+
sampleBuildWorkers = 4
5563
)
5664

5765
type vectorIndexTrainer struct {
@@ -65,6 +73,8 @@ type vectorIndexTrainer struct {
6573
worker *samplingWorker
6674
bleveDest *BleveDest
6775

76+
trainStats TrainingStats
77+
6878
doneCh chan struct{}
6979
closeCh chan struct{}
7080
}
@@ -113,6 +123,10 @@ func initTrainer(bleveDest *BleveDest, kvconfig map[string]interface{}) trainer
113123
return nil
114124
}
115125

126+
func (t *vectorIndexTrainer) WriteJSON(w io.Writer) {
127+
t.trainStats.WriteJSON(w)
128+
}
129+
116130
func (t *vectorIndexTrainer) wait() <-chan struct{} {
117131
return t.doneCh
118132
}
@@ -156,7 +170,7 @@ func (r *samplingWorkerRegistry) getOrCreateWorker(indexName string) (*samplingW
156170
if !ok {
157171
worker = &samplingWorker{
158172
ref: 1,
159-
sampleCh: make(chan *sample, 1),
173+
sampleCh: make(chan *sample, sampleChannelBuffer),
160174
doneCh: make(chan struct{}),
161175
closeCh: make(chan struct{}),
162176
copyCh: make(chan struct{}),
@@ -191,9 +205,10 @@ type sample struct {
191205

192206
// samplingWorkerRunConfig holds the inputs for configuring a samplingWorker obj.
193207
type samplingWorkerRunConfig struct {
194-
params *samplingParams
195-
vecIndex bleve.TrainableIndex
196-
cluster kvCluster
208+
params *samplingParams
209+
vecIndex bleve.TrainableIndex
210+
cluster kvCluster
211+
trainStats *TrainingStats
197212
}
198213

199214
type samplingParams struct {
@@ -287,6 +302,7 @@ func (w *samplingWorker) configure(c *samplingWorkerRunConfig) {
287302
w.vecIndex = c.vecIndex
288303
w.params = c.params
289304
w.cluster = c.cluster
305+
w.trainStats = c.trainStats
290306
}
291307

292308
// run performs the sampling run: opens a sampling scan per collection, reads
@@ -321,19 +337,29 @@ func (w *samplingWorker) run() {
321337
default:
322338
}
323339

340+
fetchStart := time.Now()
324341
doc, err := iterators[i].Next()
325342
if err != nil {
326343
errs[i] = err
327344
return
328345
}
346+
atomic.AddUint64(&w.trainStats.TotKVFetchTimeInNs, uint64(time.Since(fetchStart)))
347+
329348
if doc == nil {
330349
// iterator exhausted before reaching sampleLimit
331350
continue
332351
}
333-
w.sampleCh <- &sample{
352+
353+
select {
354+
case w.sampleCh <- &sample{
334355
cid: i,
335356
id: doc.ID,
336357
value: doc.Val,
358+
}:
359+
case <-w.closeCh:
360+
log.Warnf("trainer_vector: stopped sampling for collection %s "+
361+
"due to close signal", w.params.collectionNames[i])
362+
return
337363
}
338364
}
339365
}(i)
@@ -501,12 +527,13 @@ func (t *vectorIndexTrainer) computeSampleLimitsForSources(agent *gocbcore.Agent
501527

502528
// trainOnSamples consumes samples from ch, builds Bleve documents with
503529
// collection scope/UID in extras[sample.cid], indexes them into a training
504-
// batch, and calls vecIndex.Train(batch) when doneCh is closed. returns error
530+
// batch, and calls vecIndex.Train(batch) once all samples are consumed.
531+
// sampleBuildWorkers goroutines run BuildDocumentEx in parallel; a single
532+
// serializer goroutine calls batch.Index to avoid races on the batch.
505533
func (t *vectorIndexTrainer) trainOnSamples(ch chan *sample, defaultType string,
506-
extras [][]byte, vecIndex bleve.TrainableIndex, doneCh chan struct{}) error {
534+
extras [][]byte, vecIndex bleve.TrainableIndex) error {
507535
batch := vecIndex.NewBatch()
508536

509-
// set the centroid count for the training process
510537
trainingParams := &index.TrainingParams{
511538
NumCentroids: t.numCentroids,
512539
}
@@ -515,43 +542,88 @@ func (t *vectorIndexTrainer) trainOnSamples(ch chan *sample, defaultType string,
515542
return err
516543
}
517544
batch.SetInternal([]byte(index.TrainingKey), bytes)
545+
trainStart := time.Now()
518546
err = vecIndex.Train(batch)
519547
if err != nil {
520548
return err
521549
}
550+
atomic.AddUint64(&t.trainStats.TotBatchTrainTimeInNs, uint64(time.Since(trainStart)))
522551

523-
for {
524-
select {
525-
case <-doneCh:
526-
select {
527-
case <-t.closeCh:
528-
log.Warnf("trainer_vector: stopped training for %s due to close signal",
529-
t.partitionName)
530-
return fmt.Errorf("training stopped due to close signal")
531-
default:
532-
}
552+
type builtDoc struct {
553+
key string
554+
doc *BleveDocument
555+
}
556+
docCh := make(chan builtDoc, sampleBuildWorkers*4)
533557

534-
err := vecIndex.Train(batch)
535-
if err != nil {
536-
return err
537-
}
538-
return nil
539-
case sample := <-ch:
540-
if sample != nil {
541-
doc, key, err := t.bleveDest.bleveDocConfig.BuildDocumentEx(
542-
[]byte(sample.id), sample.value, defaultType,
543-
cbgt.DEST_EXTRAS_TYPE_GOCBCORE_SCOPE_COLLECTION, nil, extras[sample.cid])
544-
if err != nil {
545-
return err
546-
}
558+
var (
559+
wg sync.WaitGroup
560+
buildErr error
561+
errOnce sync.Once
562+
)
547563

548-
err = batch.Index(string(key), doc)
549-
if err != nil {
550-
return err
564+
for i := 0; i < sampleBuildWorkers; i++ {
565+
wg.Add(1)
566+
go func() {
567+
defer wg.Done()
568+
for {
569+
select {
570+
case <-t.closeCh:
571+
return
572+
case s, ok := <-ch:
573+
if !ok || s == nil {
574+
return
575+
}
576+
buildStart := time.Now()
577+
doc, key, err := t.bleveDest.bleveDocConfig.BuildDocumentEx(
578+
[]byte(s.id), s.value, defaultType,
579+
cbgt.DEST_EXTRAS_TYPE_GOCBCORE_SCOPE_COLLECTION, nil, extras[s.cid])
580+
if err != nil {
581+
errOnce.Do(func() { buildErr = err })
582+
return
583+
}
584+
atomic.AddUint64(&t.trainStats.TotDocBuildTimeInNs, uint64(time.Since(buildStart)))
585+
586+
select {
587+
case docCh <- builtDoc{key: string(key), doc: doc}:
588+
case <-t.closeCh:
589+
return
590+
}
551591
}
552592
}
593+
}()
594+
}
595+
596+
// wait for all the documents to be indexed into the batch
597+
go func() {
598+
wg.Wait()
599+
close(docCh)
600+
}()
601+
602+
for bd := range docCh {
603+
indexStart := time.Now()
604+
err := batch.Index(bd.key, bd.doc)
605+
if err != nil {
606+
return err
553607
}
608+
atomic.AddUint64(&t.trainStats.TotBatchTrainTimeInNs, uint64(time.Since(indexStart)))
609+
}
610+
611+
if buildErr != nil {
612+
return buildErr
554613
}
614+
615+
select {
616+
case <-t.closeCh:
617+
log.Warnf("trainer_vector: stopped training for %s due to close signal",
618+
t.partitionName)
619+
return fmt.Errorf("training stopped due to close signal")
620+
default:
621+
}
622+
623+
trainStart = time.Now()
624+
err = vecIndex.Train(batch)
625+
atomic.AddUint64(&t.trainStats.TotBatchTrainTimeInNs, uint64(time.Since(trainStart)))
626+
return err
555627
}
556628

557629
// trainedIndexConfig carries everything needed to create and train a trained index.
@@ -573,7 +645,9 @@ type trainedIndexConfig struct {
573645
// is closed so other BleveDests can copy the trained index.
574646
func (t *vectorIndexTrainer) createTrainedIndex(cfg *trainedIndexConfig) error {
575647
var err error
648+
samplingStart := time.Now()
576649
defer func() {
650+
atomic.AddUint64(&t.trainStats.TotTrainedIndexBuildTimeInNs, uint64(time.Since(samplingStart)))
577651
cfg.worker.success = (err == nil)
578652
close(cfg.worker.copyCh)
579653
}()
@@ -654,15 +728,16 @@ func (t *vectorIndexTrainer) createTrainedIndex(cfg *trainedIndexConfig) error {
654728
scopeName: cfg.scopeName,
655729
sourceName: t.bleveDest.sourceName,
656730
},
657-
cluster: &gocbClusterAdapter{cluster: cluster},
731+
cluster: &gocbClusterAdapter{cluster: cluster},
732+
trainStats: &t.trainStats,
658733
})
659734
}
660735

661736
go cfg.worker.run()
662737

663738
// single training thread
664739
return t.trainOnSamples(cfg.worker.sampleCh, cfg.defaultType, extraOpts,
665-
cfg.vecIndex, cfg.worker.doneCh)
740+
cfg.vecIndex)
666741
}
667742

668743
func (t *vectorIndexTrainer) markTrainingComplete(index bleve.TrainableIndex) error {
@@ -821,6 +896,7 @@ func (t *vectorIndexTrainer) acquireSamples() {
821896
return
822897
}
823898

899+
start := time.Now()
824900
if ic, ok := worker.vecIndex.(bleve.IndexFileCopyable); ok && worker.success {
825901
dest, ok := t.bleveDest.bindex.(bleve.IndexFileCopyable)
826902
if !ok {
@@ -835,8 +911,8 @@ func (t *vectorIndexTrainer) acquireSamples() {
835911
}
836912
log.Printf("trainer_vector: finished copying trained index from"+
837913
" source partition %s", worker.vecIndex.Name())
914+
atomic.AddUint64(&t.trainStats.TotTrainedIndexCopyTimeInNs, uint64(time.Since(start)))
838915
}
839-
840916
}
841917

842918
// trainedIndexWriter implements the writer used when copying the trained index

trainer_vector_test.go

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -411,6 +411,7 @@ func newWorkerWithMockCluster(cluster kvCluster, collections []string, limits []
411411
scopeName: "testScope",
412412
sourceName: "testBucket",
413413
},
414+
trainStats: &TrainingStats{},
414415
})
415416
return w
416417
}
@@ -559,6 +560,7 @@ func TestSamplingWorkerRunStopsOnCloseCh(t *testing.T) {
559560
collectionNames: []string{"col1"},
560561
sampleLimit: []int{100},
561562
},
563+
trainStats: &TrainingStats{},
562564
})
563565

564566
go w.run()
@@ -648,7 +650,7 @@ func newTestWorkerAndConfig(t *testing.T, tr *vectorIndexTrainer, vecIndex bleve
648650
scopeName: params.scopeName,
649651
collectionNames: params.collectionNames,
650652
initClusterCallback: func() error {
651-
worker.configure(&samplingWorkerRunConfig{cluster: cluster, params: params})
653+
worker.configure(&samplingWorkerRunConfig{cluster: cluster, params: params, trainStats: &tr.trainStats})
652654
return nil
653655
},
654656
}

0 commit comments

Comments
 (0)