From f0f08a5c9b16ad216bb4d14c0de605331894998c Mon Sep 17 00:00:00 2001 From: Andrey Butusov Date: Thu, 30 Apr 2026 22:00:30 +0300 Subject: [PATCH 1/5] engine: add fan-out benchmark suite for shard traversal ops Signed-off-by: Andrey Butusov --- pkg/local_object_storage/engine/bench_test.go | 394 ++++++++++++++++++ .../engine/engine_test.go | 42 -- 2 files changed, 394 insertions(+), 42 deletions(-) create mode 100644 pkg/local_object_storage/engine/bench_test.go diff --git a/pkg/local_object_storage/engine/bench_test.go b/pkg/local_object_storage/engine/bench_test.go new file mode 100644 index 0000000000..3d5077fa63 --- /dev/null +++ b/pkg/local_object_storage/engine/bench_test.go @@ -0,0 +1,394 @@ +package engine + +import ( + "errors" + "fmt" + "os" + "os/exec" + "sync" + "testing" + "time" + + objectcore "github.com/nspcc-dev/neofs-node/pkg/core/object" + "github.com/nspcc-dev/neofs-node/pkg/local_object_storage/shard" + cid "github.com/nspcc-dev/neofs-sdk-go/container/id" + cidtest "github.com/nspcc-dev/neofs-sdk-go/container/id/test" + "github.com/nspcc-dev/neofs-sdk-go/object" + oid "github.com/nspcc-dev/neofs-sdk-go/object/id" + oidtest "github.com/nspcc-dev/neofs-sdk-go/object/id/test" +) + +const ( + benchmarkObjectsPerShard = 1000 + benchmarkRawMatchesPerShard = 16 + benchmarkExistsMissCount = 256 +) + +var benchmarkShardCounts = []int{1, 2, 4, 8, 16, 32} + +type benchmarkEngineFixture struct { + engine *StorageEngine + shards []shardWrapper + shardCount int +} + +type searchBenchmarkScenario struct { + container cid.ID + hitFS []objectcore.SearchFilter + hitCursor *objectcore.SearchCursor + hitAttrs []string + missFS []objectcore.SearchFilter + missCursor *objectcore.SearchCursor + missAttrs []string +} + +type collectRawBenchmarkScenario struct { + container cid.ID + attr string + hitValue []byte + missValue []byte +} + +type existsBenchmarkScenario struct { + hitFirst oid.Address + hitLast oid.Address + misses []oid.Address +} + +func BenchmarkSearch(b *testing.B) { + forEachBenchmarkFixture(b, func(b *testing.B, _ int, fx *benchmarkEngineFixture) { + sc := prepareSearchBenchmarkScenario(b, fx) + b.Run("hit", func(b *testing.B) { + benchmarkSearch(b, fx, sc.container, sc.hitFS, sc.hitAttrs, sc.hitCursor) + }) + b.Run("miss", func(b *testing.B) { + benchmarkSearch(b, fx, sc.container, sc.missFS, sc.missAttrs, sc.missCursor) + }) + }) +} + +func BenchmarkListWithCursor(b *testing.B) { + forEachBenchmarkFixture(b, func(b *testing.B, _ int, fx *benchmarkEngineFixture) { + prepareListBenchmarkScenario(b, fx) + for _, batchSize := range []uint32{1, 10, 100} { + b.Run(fmt.Sprintf("batch=%d", batchSize), func(b *testing.B) { + benchmarkListWithCursor(b, fx, batchSize) + }) + } + }) +} + +func BenchmarkCollectRawWithAttribute(b *testing.B) { + forEachBenchmarkFixture(b, func(b *testing.B, shardCount int, fx *benchmarkEngineFixture) { + sc := prepareCollectRawBenchmarkScenario(b, fx) + b.Run("hit", func(b *testing.B) { + b.ReportAllocs() + for b.Loop() { + dropCaches(b) + ids, err := fx.engine.collectRawWithAttribute(sc.container, sc.attr, sc.hitValue) + if err != nil { + b.Fatal(err) + } + if len(ids) != shardCount*benchmarkRawMatchesPerShard { + b.Fatalf("unexpected hit result len %d", len(ids)) + } + } + }) + b.Run("miss", func(b *testing.B) { + b.ReportAllocs() + for b.Loop() { + dropCaches(b) + ids, err := fx.engine.collectRawWithAttribute(sc.container, sc.attr, sc.missValue) + if err != nil { + b.Fatal(err) + } + if len(ids) != 0 { + b.Fatalf("unexpected miss result len %d", len(ids)) + } + } + }) + }) +} + +func BenchmarkExistsPhysical(b *testing.B) { + forEachBenchmarkFixture(b, func(b *testing.B, _ int, fx *benchmarkEngineFixture) { + sc := prepareExistsBenchmarkScenario(b, fx) + b.Run("hit-first", func(b *testing.B) { + benchmarkExists(b, fx, []oid.Address{sc.hitFirst}, true) + }) + b.Run("hit-last", func(b *testing.B) { + benchmarkExists(b, fx, []oid.Address{sc.hitLast}, true) + }) + b.Run("miss-rotating", func(b *testing.B) { + benchmarkExists(b, fx, sc.misses, false) + }) + }) +} + +func benchmarkSearch(b *testing.B, fx *benchmarkEngineFixture, container cid.ID, fs []objectcore.SearchFilter, attrs []string, cursor *objectcore.SearchCursor) { + b.ReportAllocs() + for b.Loop() { + dropCaches(b) + _, nextCursor, err := fx.engine.Search(container, fs, attrs, cursor, 1000) + if err != nil { + b.Fatal(err) + } + if len(nextCursor) != 0 { + b.Fatalf("unexpected cursor len %d", len(nextCursor)) + } + } +} + +func benchmarkListWithCursor(b *testing.B, fx *benchmarkEngineFixture, batchSize uint32) { + b.ReportAllocs() + for b.Loop() { + dropCaches(b) + var cursor *Cursor + var err error + for { + _, cursor, err = fx.engine.ListWithCursor(batchSize, cursor) + if errors.Is(err, ErrEndOfListing) { + break + } + if err != nil { + b.Fatal(err) + } + } + } +} + +func benchmarkExists(b *testing.B, fx *benchmarkEngineFixture, addrs []oid.Address, expected bool) { + var i int + b.ReportAllocs() + for b.Loop() { + ok, err := fx.engine.existsPhysical(addrs[i%len(addrs)]) + if err != nil { + b.Fatal(err) + } + if ok != expected { + b.Fatalf("unexpected exists=%t expected=%t", ok, expected) + } + i++ + } +} + +func newBenchmarkEngineFixture(tb testing.TB, shardCount int) *benchmarkEngineFixture { + engine := benchmarkNewEngineWithShardNum(tb, shardCount) + return &benchmarkEngineFixture{ + engine: engine, + shards: engine.unsortedShards(), + shardCount: shardCount, + } +} + +func forEachBenchmarkFixture(b *testing.B, bench func(*testing.B, int, *benchmarkEngineFixture)) { + for _, shardCount := range benchmarkShardCounts { + b.Run(fmt.Sprintf("shards=%d", shardCount), func(b *testing.B) { + fx := newBenchmarkEngineFixture(b, shardCount) + b.Cleanup(func() { + _ = fx.engine.Close() + }) + bench(b, shardCount, fx) + }) + } +} + +func prepareSearchBenchmarkScenario(tb testing.TB, fx *benchmarkEngineFixture) searchBenchmarkScenario { + started := time.Now() + defer func() { + tb.Logf("search setup wall=%s shards=%d objects_per_shard=%d", time.Since(started), fx.shardCount, benchmarkObjectsPerShard) + }() + + const searchAttr = "bench_search_key" + const hitValue = "value-hit" + const missValue = "value-miss" + + sc := searchBenchmarkScenario{ + container: cidtest.ID(), + } + + var wg sync.WaitGroup + errCh := make(chan error, len(fx.shards)) + + for shardIdx, sh := range fx.shards { + wg.Add(1) + go func(shardIdx int, sh shardWrapper) { + defer wg.Done() + for objIdx := range benchmarkObjectsPerShard { + obj := generateObjectWithCID(sc.container) + obj.SetAttributes( + object.NewAttribute(searchAttr, fmt.Sprintf("value-%02d-%03d", shardIdx, objIdx)), + ) + + if shardIdx == 0 && objIdx == 0 { + obj.SetAttributes( + object.NewAttribute(searchAttr, hitValue), + ) + } + + if err := sh.Put(obj, nil); err != nil { + errCh <- err + return + } + } + }(shardIdx, sh) + } + + wg.Wait() + close(errCh) + for err := range errCh { + if err != nil { + tb.Fatal(err) + } + } + + sc.hitFS, sc.hitAttrs, sc.hitCursor = benchmarkSearchQuery(tb, searchAttr, hitValue) + sc.missFS, sc.missAttrs, sc.missCursor = benchmarkSearchQuery(tb, searchAttr, missValue) + return sc +} + +func prepareListBenchmarkScenario(tb testing.TB, fx *benchmarkEngineFixture) { + started := time.Now() + defer func() { + tb.Logf("list setup wall=%s shards=%d objects_per_shard=%d", time.Since(started), fx.shardCount, benchmarkObjectsPerShard) + }() + + var wg sync.WaitGroup + errCh := make(chan error, len(fx.shards)) + + for _, sh := range fx.shards { + wg.Add(1) + go func(sh shardWrapper) { + defer wg.Done() + for range benchmarkObjectsPerShard { + obj := generateObjectWithCID(cidtest.ID()) + if err := sh.Put(obj, nil); err != nil { + errCh <- err + return + } + } + }(sh) + } + + wg.Wait() + close(errCh) + for err := range errCh { + if err != nil { + tb.Fatal(err) + } + } +} + +func prepareCollectRawBenchmarkScenario(tb testing.TB, fx *benchmarkEngineFixture) collectRawBenchmarkScenario { + started := time.Now() + defer func() { + tb.Logf("collectRaw setup wall=%s shards=%d matches_per_shard=%d", time.Since(started), fx.shardCount, benchmarkRawMatchesPerShard) + }() + + sc := collectRawBenchmarkScenario{ + container: cidtest.ID(), + attr: "bench_raw_group", + hitValue: []byte("target"), + missValue: []byte("missing"), + } + + var wg sync.WaitGroup + errCh := make(chan error, len(fx.shards)) + + for _, sh := range fx.shards { + wg.Add(1) + go func(sh shardWrapper) { + defer wg.Done() + for range benchmarkRawMatchesPerShard { + obj := generateObjectWithCID(sc.container) + obj.SetAttributes( + object.NewAttribute(sc.attr, string(sc.hitValue)), + ) + + if err := sh.Put(obj, nil); err != nil { + errCh <- err + return + } + } + }(sh) + } + + wg.Wait() + close(errCh) + for err := range errCh { + if err != nil { + tb.Fatal(err) + } + } + + return sc +} + +func prepareExistsBenchmarkScenario(tb testing.TB, fx *benchmarkEngineFixture) existsBenchmarkScenario { + started := time.Now() + defer func() { + tb.Logf("exists setup wall=%s shards=%d miss_count=%d", time.Since(started), fx.shardCount, benchmarkExistsMissCount) + }() + + sc := existsBenchmarkScenario{ + misses: make([]oid.Address, benchmarkExistsMissCount), + } + + for i := range sc.misses { + sc.misses[i] = oidtest.Address() + } + + sc.hitFirst = benchmarkExistsObject(tb, fx.engine, true) + sc.hitLast = benchmarkExistsObject(tb, fx.engine, false) + return sc +} + +func benchmarkNewEngineWithShardNum(tb testing.TB, shardCount int) *StorageEngine { + shards := make([]*shard.Shard, 0, shardCount) + for i := range shardCount { + shards = append(shards, testNewShard(tb, i)) + } + return testNewEngineWithShards(shards...) +} + +func benchmarkSearchQuery(tb testing.TB, attr, val string) ([]objectcore.SearchFilter, []string, *objectcore.SearchCursor) { + fs := object.SearchFilters{} + fs.AddFilter(attr, val, object.MatchStringEqual) + + attrs := []string{attr} + resFS, cursor, err := objectcore.PreprocessSearchQuery(fs, attrs, "") + if err != nil { + tb.Fatal(err) + } + return resFS, attrs, cursor +} + +func dropCaches(b *testing.B) { + b.StopTimer() + + _ = exec.Command("sync").Run() + err := os.WriteFile("/proc/sys/vm/drop_caches", []byte("3"), 0200) + if err != nil { + b.Skipf("can't drop caches: %v", err) + } + + b.StartTimer() +} + +func benchmarkExistsObject(tb testing.TB, engine *StorageEngine, first bool) oid.Address { + obj := generateObjectWithCID(cidtest.ID()) + sorted := engine.sortedShards(obj.GetID()) + if len(sorted) == 0 { + tb.Fatal("no shards") + } + + target := sorted[len(sorted)-1] + if first { + target = sorted[0] + } + + if err := target.Put(obj, nil); err != nil { + tb.Fatal(err) + } + return obj.Address() +} diff --git a/pkg/local_object_storage/engine/engine_test.go b/pkg/local_object_storage/engine/engine_test.go index e9c7f18493..c3ce9fa8a5 100644 --- a/pkg/local_object_storage/engine/engine_test.go +++ b/pkg/local_object_storage/engine/engine_test.go @@ -20,7 +20,6 @@ import ( "github.com/nspcc-dev/neofs-sdk-go/checksum" apistatus "github.com/nspcc-dev/neofs-sdk-go/client/status" cid "github.com/nspcc-dev/neofs-sdk-go/container/id" - cidtest "github.com/nspcc-dev/neofs-sdk-go/container/id/test" "github.com/nspcc-dev/neofs-sdk-go/object" oid "github.com/nspcc-dev/neofs-sdk-go/object/id" oidtest "github.com/nspcc-dev/neofs-sdk-go/object/id/test" @@ -39,47 +38,6 @@ func (s epochState) CurrentEpoch() uint64 { return s.e } -func BenchmarkExists(b *testing.B) { - b.Run("2 shards", func(b *testing.B) { - benchmarkExists(b, 2) - }) - b.Run("4 shards", func(b *testing.B) { - benchmarkExists(b, 4) - }) - b.Run("8 shards", func(b *testing.B) { - benchmarkExists(b, 8) - }) -} - -func benchmarkExists(b *testing.B, shardNum int) { - shards := make([]*shard.Shard, shardNum) - for i := range shardNum { - shards[i] = testNewShard(b, i) - } - - e := testNewEngineWithShards(shards...) - b.Cleanup(func() { - _ = e.Close() - }) - - addr := oidtest.Address() - for range 100 { - obj := generateObjectWithCID(cidtest.ID()) - err := e.Put(obj, nil) - if err != nil { - b.Fatal(err) - } - } - - b.ReportAllocs() - for b.Loop() { - ok, err := e.existsPhysical(addr) - if err != nil || ok { - b.Fatalf("%t %v", ok, err) - } - } -} - func testNewEngineWithShards(shards ...*shard.Shard) *StorageEngine { engine := New(WithObjectPutRetryTimeout(100 * time.Millisecond)) From d24787b19d3d43e57228ec28b271747c4ac12528 Mon Sep 17 00:00:00 2001 From: Andrey Butusov Date: Thu, 30 Apr 2026 23:04:56 +0300 Subject: [PATCH 2/5] engine: parallelize shard fan-out in `Search` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ``` goos: linux goarch: amd64 pkg: github.com/nspcc-dev/neofs-node/pkg/local_object_storage/engine cpu: AMD Ryzen 7 PRO 4750U with Radeon Graphics │ oldSearch.txt │ newSearch.txt │ │ sec/op │ sec/op vs base │ Search/shards=1/hit-16 48.88µ ± 6% 50.74µ ± 10% ~ (p=0.123 n=10) Search/shards=1/miss-16 36.03µ ± 6% 35.30µ ± 11% ~ (p=0.280 n=10) Search/shards=2/hit-16 53.09µ ± 4% 57.43µ ± 7% +8.18% (p=0.000 n=10) Search/shards=2/miss-16 43.79µ ± 6% 43.71µ ± 6% ~ (p=0.912 n=10) Search/shards=4/hit-16 68.60µ ± 13% 65.60µ ± 5% -4.37% (p=0.029 n=10) Search/shards=4/miss-16 53.67µ ± 6% 56.82µ ± 8% ~ (p=0.052 n=10) Search/shards=8/hit-16 88.29µ ± 22% 129.48µ ± 20% +46.65% (p=0.002 n=10) Search/shards=8/miss-16 74.98µ ± 8% 111.05µ ± 12% +48.10% (p=0.000 n=10) Search/shards=16/hit-16 136.2µ ± 7% 175.9µ ± 11% +29.08% (p=0.001 n=10) Search/shards=16/miss-16 117.6µ ± 4% 166.6µ ± 7% +41.65% (p=0.000 n=10) Search/shards=32/hit-16 211.0µ ± 12% 223.4µ ± 9% ~ (p=0.280 n=10) Search/shards=32/miss-16 192.2µ ± 2% 207.6µ ± 5% +8.06% (p=0.000 n=10) geomean 79.61µ 91.12µ +14.45% │ oldSearch.txt │ newSearch.txt │ │ B/op │ B/op vs base │ Search/shards=1/hit-16 2.023Ki ± 0% 2.023Ki ± 0% ~ (p=1.000 n=10) ¹ Search/shards=1/miss-16 1.211Ki ± 0% 1.211Ki ± 0% ~ (p=1.000 n=10) ¹ Search/shards=2/hit-16 3.391Ki ± 0% 3.391Ki ± 0% ~ (p=1.000 n=10) ¹ Search/shards=2/miss-16 2.516Ki ± 0% 2.500Ki ± 0% -0.62% (p=0.000 n=10) Search/shards=4/hit-16 6.062Ki ± 0% 5.938Ki ± 0% -2.06% (p=0.000 n=10) Search/shards=4/miss-16 5.188Ki ± 0% 5.047Ki ± 0% -2.71% (p=0.000 n=10) Search/shards=8/hit-16 11.38Ki ± 0% 12.61Ki ± 0% +10.85% (p=0.000 n=10) Search/shards=8/miss-16 10.48Ki ± 0% 11.72Ki ± 0% +11.77% (p=0.000 n=10) Search/shards=16/hit-16 22.12Ki ± 0% 24.61Ki ± 0% +11.23% (p=0.000 n=10) Search/shards=16/miss-16 21.23Ki ± 0% 23.72Ki ± 0% +11.70% (p=0.000 n=10) Search/shards=32/hit-16 43.53Ki ± 0% 48.61Ki ± 0% +11.67% (p=0.000 n=10) Search/shards=32/miss-16 42.64Ki ± 0% 47.72Ki ± 0% +11.91% (p=0.000 n=10) geomean 7.996Ki 8.406Ki +5.12% ¹ all samples are equal │ oldSearch.txt │ newSearch.txt │ │ allocs/op │ allocs/op vs base │ Search/shards=1/hit-16 43.00 ± 0% 43.00 ± 0% ~ (p=1.000 n=10) ¹ Search/shards=1/miss-16 24.00 ± 0% 24.00 ± 0% ~ (p=1.000 n=10) ¹ Search/shards=2/hit-16 70.00 ± 0% 69.00 ± 0% -1.43% (p=0.000 n=10) Search/shards=2/miss-16 50.00 ± 0% 49.00 ± 0% -2.00% (p=0.000 n=10) Search/shards=4/hit-16 118.0 ± 0% 116.0 ± 0% -1.69% (p=0.000 n=10) Search/shards=4/miss-16 98.00 ± 0% 96.00 ± 0% -2.04% (p=0.000 n=10) Search/shards=8/hit-16 211.0 ± 0% 239.0 ± 0% +13.27% (p=0.000 n=10) Search/shards=8/miss-16 191.0 ± 0% 219.0 ± 0% +14.66% (p=0.000 n=10) Search/shards=16/hit-16 396.0 ± 0% 452.0 ± 0% +14.14% (p=0.000 n=10) Search/shards=16/miss-16 376.0 ± 0% 432.0 ± 0% +14.89% (p=0.000 n=10) Search/shards=32/hit-16 765.0 ± 0% 877.0 ± 0% +14.64% (p=0.000 n=10) Search/shards=32/miss-16 745.0 ± 0% 857.0 ± 0% +15.03% (p=0.000 n=10) geomean 151.2 160.8 +6.33% ¹ all samples are equal ``` Refs #3911. Signed-off-by: Andrey Butusov --- pkg/local_object_storage/engine/select.go | 81 +++++++++++++++++++++++ 1 file changed, 81 insertions(+) diff --git a/pkg/local_object_storage/engine/select.go b/pkg/local_object_storage/engine/select.go index 25e910b9e7..c57be6efc4 100644 --- a/pkg/local_object_storage/engine/select.go +++ b/pkg/local_object_storage/engine/select.go @@ -4,6 +4,8 @@ import ( "bytes" "errors" "fmt" + "slices" + "sync" objectcore "github.com/nspcc-dev/neofs-node/pkg/core/object" "github.com/nspcc-dev/neofs-sdk-go/client" @@ -12,6 +14,16 @@ import ( oid "github.com/nspcc-dev/neofs-sdk-go/object/id" ) +const ( + searchParallelThreshold = 4 +) + +type shardSearchResult struct { + items []client.SearchResultItem + more bool + err error +} + // selectOld selects the objects from local storage that match select parameters. // // Returns any error encountered that did not allow to completely select the objects. @@ -69,6 +81,14 @@ func (e *StorageEngine) Search(cnr cid.ID, fs []objectcore.SearchFilter, attrs [ if len(shs) == 0 { return nil, nil, nil } + if len(shs) <= searchParallelThreshold { + return e.searchSequential(cnr, fs, attrs, cursor, count, shs) + } + + return e.searchParallel(cnr, fs, attrs, cursor, count, shs) +} + +func (e *StorageEngine) searchSequential(cnr cid.ID, fs []objectcore.SearchFilter, attrs []string, cursor *objectcore.SearchCursor, count uint16, shs []shardWrapper) ([]client.SearchResultItem, []byte, error) { items, nextCursor, err := shs[0].Search(cnr, fs, attrs, cursor, count) if err != nil { e.reportShardError(shs[0], "could not select objects from shard", err) @@ -86,6 +106,67 @@ func (e *StorageEngine) Search(cnr cid.ID, fs []objectcore.SearchFilter, attrs [ } sets, mores = append(sets, items), append(mores, nextCursor != nil) } + + return finalizeSearch(fs, attrs, count, sets, mores) +} + +func (e *StorageEngine) searchParallel(cnr cid.ID, fs []objectcore.SearchFilter, attrs []string, cursor *objectcore.SearchCursor, count uint16, shs []shardWrapper) ([]client.SearchResultItem, []byte, error) { + results := make([]shardSearchResult, len(shs)) + workers := (len(shs) + searchParallelThreshold - 1) / searchParallelThreshold + chunkSize := (len(shs) + workers - 1) / workers + + var wg sync.WaitGroup + wg.Add(workers) + + for w := range workers { + start := w * chunkSize + end := start + chunkSize + end = min(end, len(shs)) + + go func(start, end int) { + defer wg.Done() + + for idx := start; idx < end; idx++ { + var cClone *objectcore.SearchCursor + if cursor != nil { + cClone = &objectcore.SearchCursor{ + PrimaryKeysPrefix: slices.Clone(cursor.PrimaryKeysPrefix), + PrimarySeekKey: slices.Clone(cursor.PrimarySeekKey), + } + } + + items, nextCursor, err := shs[idx].Search(cnr, fs, attrs, cClone, count) + results[idx] = shardSearchResult{ + items: items, + more: nextCursor != nil, + err: err, + } + } + }(start, end) + } + + wg.Wait() + + sets := make([][]client.SearchResultItem, 0, len(shs)) + mores := make([]bool, 0, len(shs)) + + for i := range shs { + if results[i].err != nil { + e.reportShardError(shs[i], "could not select objects from shard", results[i].err) + continue + } + sets = append(sets, results[i].items) + mores = append(mores, results[i].more) + } + + if len(sets) == 0 { + return nil, nil, nil + } + + return finalizeSearch(fs, attrs, count, sets, mores) +} + +func finalizeSearch(fs []objectcore.SearchFilter, attrs []string, count uint16, sets [][]client.SearchResultItem, mores []bool) ([]client.SearchResultItem, []byte, error) { var ( firstAttr string firstFilter *object.SearchFilter From d0777979b585c04e41f8563ecb22a22233622868 Mon Sep 17 00:00:00 2001 From: Andrey Butusov Date: Tue, 5 May 2026 13:46:54 +0300 Subject: [PATCH 3/5] engine: parallelize shard fan-out in `ListWithCursor` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ``` goos: linux goarch: amd64 pkg: github.com/nspcc-dev/neofs-node/pkg/local_object_storage/engine cpu: AMD Ryzen 7 PRO 4750U with Radeon Graphics │ oldListWithCursor.txt │ newListWithCursor.txt │ │ sec/op │ sec/op vs base │ ListWithCursor/shards=1/batch=1-16 4.781m ± 14% 5.169m ± 6% ~ (p=0.105 n=10) ListWithCursor/shards=1/batch=10-16 2.472m ± 10% 2.513m ± 8% ~ (p=0.971 n=10) ListWithCursor/shards=1/batch=100-16 2.463m ± 11% 2.324m ± 5% -5.65% (p=0.011 n=10) ListWithCursor/shards=2/batch=1-16 22.60m ± 8% 23.39m ± 11% ~ (p=0.280 n=10) ListWithCursor/shards=2/batch=10-16 12.83m ± 8% 13.00m ± 12% ~ (p=0.436 n=10) ListWithCursor/shards=2/batch=100-16 10.93m ± 7% 11.79m ± 19% ~ (p=0.280 n=10) ListWithCursor/shards=4/batch=1-16 100.1m ± 4% 100.2m ± 4% ~ (p=0.971 n=10) ListWithCursor/shards=4/batch=10-16 65.16m ± 15% 65.32m ± 6% ~ (p=0.436 n=10) ListWithCursor/shards=4/batch=100-16 63.27m ± 6% 61.84m ± 3% ~ (p=0.280 n=10) ListWithCursor/shards=8/batch=1-16 416.7m ± 6% 651.9m ± 4% +56.44% (p=0.000 n=10) ListWithCursor/shards=8/batch=10-16 289.9m ± 6% 241.7m ± 4% -16.63% (p=0.000 n=10) ListWithCursor/shards=8/batch=100-16 279.2m ± 2% 189.8m ± 5% -31.99% (p=0.000 n=10) ListWithCursor/shards=16/batch=1-16 1.654 ± 6% 2.190 ± 4% +32.41% (p=0.000 n=10) ListWithCursor/shards=16/batch=10-16 1188.3m ± 7% 732.7m ± 6% -38.34% (p=0.000 n=10) ListWithCursor/shards=16/batch=100-16 1168.2m ± 5% 516.9m ± 2% -55.75% (p=0.000 n=10) ListWithCursor/shards=32/batch=1-16 6.247 ± 5% 6.176 ± 12% ~ (p=0.796 n=10) ListWithCursor/shards=32/batch=10-16 4.521 ± 1% 1.791 ± 9% -60.38% (p=0.000 n=10) ListWithCursor/shards=32/batch=100-16 4.387 ± 1% 1.269 ± 9% -71.08% (p=0.000 n=10) geomean 138.8m 116.3m -16.18% │ oldListWithCursor.txt │ newListWithCursor.txt │ │ B/op │ B/op vs base │ ListWithCursor/shards=1/batch=1-16 1.952Mi ± 0% 2.090Mi ± 0% +7.04% (p=0.000 n=10) ListWithCursor/shards=1/batch=10-16 1.089Mi ± 0% 1.103Mi ± 0% +1.29% (p=0.000 n=10) ListWithCursor/shards=1/batch=100-16 1.008Mi ± 0% 1.010Mi ± 0% +0.15% (p=0.001 n=10) ListWithCursor/shards=2/batch=1-16 7.010Mi ± 0% 7.347Mi ± 0% +4.80% (p=0.000 n=10) ListWithCursor/shards=2/batch=10-16 4.468Mi ± 0% 4.500Mi ± 0% +0.72% (p=0.001 n=10) ListWithCursor/shards=2/batch=100-16 4.127Mi ± 0% 4.131Mi ± 0% +0.09% (p=0.000 n=10) ListWithCursor/shards=4/batch=1-16 25.45Mi ± 0% 26.43Mi ± 0% +3.85% (p=0.000 n=10) ListWithCursor/shards=4/batch=10-16 17.09Mi ± 0% 17.19Mi ± 0% +0.58% (p=0.000 n=10) ListWithCursor/shards=4/batch=100-16 15.76Mi ± 0% 15.78Mi ± 0% +0.09% (p=0.001 n=10) ListWithCursor/shards=8/batch=1-16 96.65Mi ± 0% 108.08Mi ± 0% +11.83% (p=0.000 n=10) ListWithCursor/shards=8/batch=10-16 66.73Mi ± 0% 67.88Mi ± 0% +1.72% (p=0.000 n=10) ListWithCursor/shards=8/batch=100-16 61.41Mi ± 0% 61.52Mi ± 0% ~ (p=0.165 n=10) ListWithCursor/shards=16/batch=1-16 378.6Mi ± 0% 424.8Mi ± 0% +12.20% (p=0.000 n=10) ListWithCursor/shards=16/batch=10-16 264.3Mi ± 0% 268.9Mi ± 0% +1.71% (p=0.000 n=10) ListWithCursor/shards=16/batch=100-16 242.9Mi ± 0% 243.1Mi ± 0% ~ (p=0.912 n=10) ListWithCursor/shards=32/batch=1-16 1.464Gi ± 0% 1.644Gi ± 0% +12.28% (p=0.000 n=10) ListWithCursor/shards=32/batch=10-16 1.029Gi ± 0% 1.044Gi ± 0% +1.44% (p=0.000 n=10) ListWithCursor/shards=32/batch=100-16 966.7Mi ± 0% 964.8Mi ± 0% -0.19% (p=0.000 n=10) geomean 38.05Mi 39.28Mi +3.24% │ oldListWithCursor.txt │ newListWithCursor.txt │ │ allocs/op │ allocs/op vs base │ ListWithCursor/shards=1/batch=1-16 48.78k ± 0% 50.78k ± 0% +4.10% (p=0.000 n=10) ListWithCursor/shards=1/batch=10-16 28.19k ± 0% 28.39k ± 0% +0.70% (p=0.000 n=10) ListWithCursor/shards=1/batch=100-16 26.13k ± 0% 26.14k ± 0% +0.07% (p=0.001 n=10) ListWithCursor/shards=2/batch=1-16 164.3k ± 0% 168.3k ± 0% +2.45% (p=0.000 n=10) ListWithCursor/shards=2/batch=10-16 108.5k ± 0% 108.8k ± 0% +0.34% (p=0.001 n=10) ListWithCursor/shards=2/batch=100-16 100.6k ± 0% 100.6k ± 0% +0.05% (p=0.000 n=10) ListWithCursor/shards=4/batch=1-16 581.8k ± 0% 589.9k ± 0% +1.39% (p=0.000 n=10) ListWithCursor/shards=4/batch=10-16 422.4k ± 0% 423.3k ± 1% +0.20% (p=0.009 n=10) ListWithCursor/shards=4/batch=100-16 392.9k ± 0% 392.9k ± 0% +0.01% (p=0.020 n=10) ListWithCursor/shards=8/batch=1-16 2.168M ± 0% 2.283M ± 0% +5.29% (p=0.000 n=10) ListWithCursor/shards=8/batch=10-16 1.661M ± 0% 1.673M ± 1% +0.70% (p=0.000 n=10) ListWithCursor/shards=8/batch=100-16 1.549M ± 0% 1.550M ± 0% ~ (p=0.353 n=10) ListWithCursor/shards=16/batch=1-16 8.343M ± 0% 8.761M ± 0% +5.01% (p=0.000 n=10) ListWithCursor/shards=16/batch=10-16 6.600M ± 0% 6.638M ± 0% +0.57% (p=0.000 n=10) ListWithCursor/shards=16/batch=100-16 6.163M ± 0% 6.160M ± 0% ~ (p=0.315 n=10) ListWithCursor/shards=32/batch=1-16 32.71M ± 0% 34.31M ± 0% +4.88% (p=0.000 n=10) ListWithCursor/shards=32/batch=10-16 26.36M ± 0% 26.40M ± 0% +0.16% (p=0.000 n=10) ListWithCursor/shards=32/batch=100-16 24.61M ± 0% 24.49M ± 0% -0.49% (p=0.000 n=10) geomean 925.8k 938.7k +1.40% ``` Refs #3911. Signed-off-by: Andrey Butusov --- pkg/local_object_storage/engine/list.go | 59 ++++++++++++++++++++++--- 1 file changed, 54 insertions(+), 5 deletions(-) diff --git a/pkg/local_object_storage/engine/list.go b/pkg/local_object_storage/engine/list.go index 72371e03b9..dd5113682b 100644 --- a/pkg/local_object_storage/engine/list.go +++ b/pkg/local_object_storage/engine/list.go @@ -1,6 +1,8 @@ package engine import ( + "sync" + objectcore "github.com/nspcc-dev/neofs-node/pkg/core/object" "github.com/nspcc-dev/neofs-node/pkg/local_object_storage/shard" cid "github.com/nspcc-dev/neofs-sdk-go/container/id" @@ -12,6 +14,13 @@ import ( // cursor. Use nil cursor object to start listing again. var ErrEndOfListing = shard.ErrEndOfListing +const listParallelThreshold = 4 + +type shardListResult struct { + items []objectcore.AddressWithAttributes + err error +} + // Cursor is a type for continuous object listing. It's returned from // [StorageEngine.ListWithCursor] and can be reused as a parameter for it for // subsequent requests. @@ -63,14 +72,19 @@ func (e *StorageEngine) ListWithCursor(count uint32, cursor *Cursor, attrs ...st var result, buf []objectcore.AddressWithAttributes cnr, obj := cursor.shardCursor.ContainerID(), cursor.shardCursor.LastObjectID() - for _, sh := range shards { - cursor.shardCursor.Reset(cnr, obj) - res, _, err := sh.ListWithCursor(int(count), cursor.shardCursor, attrs...) - if err != nil || len(res) == 0 { + var shardResults []shardListResult + if len(shards) <= listParallelThreshold { + shardResults = listSequential(shards, int(count), cnr, obj, attrs...) + } else { + shardResults = listParallel(shards, int(count), cnr, obj, attrs...) + } + + for i := range shards { + if shardResults[i].err != nil || len(shardResults[i].items) == 0 { continue } prev := result - result = mergeListResults(buf, result, res, sh.ID().String(), int(count)) + result = mergeListResults(buf, result, shardResults[i].items, shards[i].ID().String(), int(count)) if prev != nil { buf = prev[:0] } @@ -85,6 +99,41 @@ func (e *StorageEngine) ListWithCursor(count uint32, cursor *Cursor, attrs ...st return result, cursor, nil } +func listSequential(shards []shardWrapper, count int, cnr cid.ID, obj oid.ID, attrs ...string) []shardListResult { + res := make([]shardListResult, len(shards)) + crs := shard.NewCursor(cnr, obj) + for i := range shards { + crs.Reset(cnr, obj) + res[i].items, _, res[i].err = shards[i].ListWithCursor(count, crs, attrs...) + } + return res +} + +func listParallel(shards []shardWrapper, count int, cnr cid.ID, obj oid.ID, attrs ...string) []shardListResult { + res := make([]shardListResult, len(shards)) + workers := (len(shards) + listParallelThreshold - 1) / listParallelThreshold + chunkSize := (len(shards) + workers - 1) / workers + + var wg sync.WaitGroup + wg.Add(workers) + for w := range workers { + start := w * chunkSize + end := start + chunkSize + end = min(end, len(shards)) + + go func(start, end int) { + defer wg.Done() + for i := start; i < end; i++ { + crs := shard.NewCursor(cnr, obj) + res[i].items, _, res[i].err = shards[i].ListWithCursor(count, crs, attrs...) + } + }(start, end) + } + + wg.Wait() + return res +} + // mergeListResults merges a sorted accumulated result with a new sorted slice // of items from a single shard into a single sorted deduplicated slice of at // most count items. Objects present on multiple shards have their ShardIDs merged. From d557900517c37fa8af1cd427078e6e16e921213d Mon Sep 17 00:00:00 2001 From: Andrey Butusov Date: Tue, 5 May 2026 13:47:11 +0300 Subject: [PATCH 4/5] engine: parallelize shard fan-out in `collectRawWithAttribute` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ``` goos: linux goarch: amd64 pkg: github.com/nspcc-dev/neofs-node/pkg/local_object_storage/engine cpu: AMD Ryzen 7 PRO 4750U with Radeon Graphics │ oldCollectRawWithAttribute.txt │ newCollectRawWithAttribute.txt │ │ sec/op │ sec/op vs base │ CollectRawWithAttribute/shards=1/hit-16 32.11µ ± 20% 33.57µ ± 11% ~ (p=0.684 n=10) CollectRawWithAttribute/shards=1/miss-16 25.01µ ± 12% 27.01µ ± 9% ~ (p=0.089 n=10) CollectRawWithAttribute/shards=2/hit-16 45.45µ ± 23% 43.15µ ± 9% ~ (p=0.353 n=10) CollectRawWithAttribute/shards=2/miss-16 31.49µ ± 10% 34.40µ ± 14% +9.22% (p=0.007 n=10) CollectRawWithAttribute/shards=4/hit-16 54.68µ ± 11% 51.38µ ± 8% ~ (p=0.280 n=10) CollectRawWithAttribute/shards=4/miss-16 38.40µ ± 4% 39.04µ ± 9% ~ (p=0.912 n=10) CollectRawWithAttribute/shards=8/hit-16 76.90µ ± 13% 127.11µ ± 19% +65.29% (p=0.000 n=10) CollectRawWithAttribute/shards=8/miss-16 53.04µ ± 12% 86.91µ ± 9% +63.86% (p=0.000 n=10) CollectRawWithAttribute/shards=16/hit-16 132.4µ ± 11% 215.7µ ± 17% +62.94% (p=0.000 n=10) CollectRawWithAttribute/shards=16/miss-16 76.21µ ± 10% 117.97µ ± 12% +54.81% (p=0.000 n=10) CollectRawWithAttribute/shards=32/hit-16 278.8µ ± 2% 451.5µ ± 10% +61.96% (p=0.000 n=10) CollectRawWithAttribute/shards=32/miss-16 124.8µ ± 2% 172.9µ ± 5% +38.52% (p=0.000 n=10) geomean 62.36µ 79.01µ +26.71% │ oldCollectRawWithAttribute.txt │ newCollectRawWithAttribute.txt │ │ B/op │ B/op vs base │ CollectRawWithAttribute/shards=1/hit-16 2.172Ki ± 0% 2.219Ki ± 0% +2.16% (p=0.000 n=10) CollectRawWithAttribute/shards=1/miss-16 728.0 ± 0% 776.0 ± 0% +6.59% (p=0.000 n=10) CollectRawWithAttribute/shards=2/hit-16 4.422Ki ± 0% 4.500Ki ± 0% +1.77% (p=0.000 n=10) CollectRawWithAttribute/shards=2/miss-16 1.500Ki ± 0% 1.578Ki ± 0% +5.21% (p=0.000 n=10) CollectRawWithAttribute/shards=4/hit-16 8.891Ki ± 0% 9.047Ki ± 0% +1.76% (p=0.000 n=10) CollectRawWithAttribute/shards=4/miss-16 3.047Ki ± 0% 3.203Ki ± 0% +5.13% (p=0.000 n=10) CollectRawWithAttribute/shards=8/hit-16 17.89Ki ± 0% 18.56Ki ± 0% +3.76% (p=0.000 n=10) CollectRawWithAttribute/shards=8/miss-16 6.141Ki ± 0% 6.812Ki ± 1% +10.94% (p=0.000 n=10) CollectRawWithAttribute/shards=16/hit-16 35.95Ki ± 0% 37.34Ki ± 0% +3.87% (p=0.000 n=10) CollectRawWithAttribute/shards=16/miss-16 12.45Ki ± 0% 13.84Ki ± 1% +11.17% (p=0.000 n=10) CollectRawWithAttribute/shards=32/hit-16 72.20Ki ± 0% 74.97Ki ± 0% +3.83% (p=0.000 n=10) CollectRawWithAttribute/shards=32/miss-16 25.20Ki ± 0% 27.97Ki ± 0% +10.97% (p=0.000 n=10) geomean 7.349Ki 7.757Ki +5.54% │ oldCollectRawWithAttribute.txt │ newCollectRawWithAttribute.txt │ │ allocs/op │ allocs/op vs base │ CollectRawWithAttribute/shards=1/hit-16 22.00 ± 0% 23.00 ± 0% +4.55% (p=0.000 n=10) CollectRawWithAttribute/shards=1/miss-16 16.00 ± 0% 17.00 ± 0% +6.25% (p=0.000 n=10) CollectRawWithAttribute/shards=2/hit-16 44.00 ± 0% 45.00 ± 0% +2.27% (p=0.000 n=10) CollectRawWithAttribute/shards=2/miss-16 33.00 ± 0% 34.00 ± 0% +3.03% (p=0.000 n=10) CollectRawWithAttribute/shards=4/hit-16 85.00 ± 0% 86.00 ± 0% +1.18% (p=0.000 n=10) CollectRawWithAttribute/shards=4/miss-16 64.00 ± 0% 65.00 ± 0% +1.56% (p=0.000 n=10) CollectRawWithAttribute/shards=8/hit-16 167.0 ± 0% 173.0 ± 0% +3.59% (p=0.000 n=10) CollectRawWithAttribute/shards=8/miss-16 125.0 ± 0% 131.0 ± 0% +4.80% (p=0.000 n=10) CollectRawWithAttribute/shards=16/hit-16 328.0 ± 0% 338.0 ± 0% +3.05% (p=0.000 n=10) CollectRawWithAttribute/shards=16/miss-16 246.0 ± 0% 256.0 ± 0% +4.07% (p=0.000 n=10) CollectRawWithAttribute/shards=32/hit-16 649.0 ± 0% 667.0 ± 0% +2.77% (p=0.000 n=10) CollectRawWithAttribute/shards=32/miss-16 487.0 ± 0% 505.0 ± 0% +3.70% (p=0.000 n=10) geomean 103.3 106.8 +3.39% ``` Refs #3911. Signed-off-by: Andrey Butusov --- pkg/local_object_storage/engine/select.go | 63 +++++++++++++++++++---- 1 file changed, 54 insertions(+), 9 deletions(-) diff --git a/pkg/local_object_storage/engine/select.go b/pkg/local_object_storage/engine/select.go index c57be6efc4..56a7fd2ca6 100644 --- a/pkg/local_object_storage/engine/select.go +++ b/pkg/local_object_storage/engine/select.go @@ -16,6 +16,7 @@ import ( const ( searchParallelThreshold = 4 + collectRawParallelThreshold = 4 ) type shardSearchResult struct { @@ -24,6 +25,11 @@ type shardSearchResult struct { err error } +type shardCollectRawResult struct { + ids []oid.ID + err error +} + // selectOld selects the objects from local storage that match select parameters. // // Returns any error encountered that did not allow to completely select the objects. @@ -192,21 +198,60 @@ func finalizeSearch(fs []objectcore.SearchFilter, attrs []string, count uint16, } func (e *StorageEngine) collectRawWithAttribute(cnr cid.ID, attr string, val []byte) ([]oid.ID, error) { - var ( - err error - shards = e.unsortedShards() - ids = make([][]oid.ID, len(shards)) - ) + shards := e.unsortedShards() + if len(shards) == 0 { + return nil, nil + } - for i, sh := range shards { - ids[i], err = sh.CollectRawWithAttribute(cnr, attr, val) - if err != nil { - return nil, fmt.Errorf("shard %s: %w", sh.ID(), err) + var results []shardCollectRawResult + if len(shards) <= collectRawParallelThreshold { + results = collectRawWithAttributeSequential(cnr, attr, val, shards) + } else { + results = collectRawWithAttributeParallel(cnr, attr, val, shards) + } + + ids := make([][]oid.ID, len(shards)) + for i := range shards { + if results[i].err != nil { + return nil, fmt.Errorf("shard %s: %w", shards[i].ID(), results[i].err) } + ids[i] = results[i].ids } return mergeOIDs(ids), nil } +func collectRawWithAttributeSequential(cnr cid.ID, attr string, val []byte, shards []shardWrapper) []shardCollectRawResult { + res := make([]shardCollectRawResult, len(shards)) + for i := range shards { + res[i].ids, res[i].err = shards[i].CollectRawWithAttribute(cnr, attr, val) + } + return res +} + +func collectRawWithAttributeParallel(cnr cid.ID, attr string, val []byte, shards []shardWrapper) []shardCollectRawResult { + res := make([]shardCollectRawResult, len(shards)) + workers := (len(shards) + collectRawParallelThreshold - 1) / collectRawParallelThreshold + chunkSize := (len(shards) + workers - 1) / workers + + var wg sync.WaitGroup + wg.Add(workers) + for w := range workers { + start := w * chunkSize + end := start + chunkSize + end = min(end, len(shards)) + + go func(start, end int) { + defer wg.Done() + for i := start; i < end; i++ { + res[i].ids, res[i].err = shards[i].CollectRawWithAttribute(cnr, attr, val) + } + }(start, end) + } + + wg.Wait() + return res +} + // mergeOIDs merges given set of lists of object IDs into a single flat list. // ids are expected to be sorted and the result contains no duplicates from // different original lists (slices are expected to not contain any inner From 43b88abd2da1404eb1bf4c532d6e9229928a77f8 Mon Sep 17 00:00:00 2001 From: Andrey Butusov Date: Tue, 12 May 2026 11:13:05 +0300 Subject: [PATCH 5/5] engine: parallelize shard fan-out in `existsPhysical` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ``` goos: linux goarch: amd64 pkg: github.com/nspcc-dev/neofs-node/pkg/local_object_storage/engine cpu: AMD Ryzen 7 PRO 4750U with Radeon Graphics │ oldExists.txt │ newExists.txt │ │ sec/op │ sec/op vs base │ ExistsPhysical/shards=1/hit-first-16 13.85µ ± 21% 13.52µ ± 27% ~ (p=0.796 n=10) ExistsPhysical/shards=1/hit-last-16 11.34µ ± 50% 13.79µ ± 23% ~ (p=0.105 n=10) ExistsPhysical/shards=1/miss-rotating-16 4.191µ ± 36% 4.606µ ± 35% ~ (p=0.853 n=10) ExistsPhysical/shards=2/hit-first-16 12.64µ ± 55% 10.13µ ± 80% ~ (p=0.190 n=10) ExistsPhysical/shards=2/hit-last-16 11.04µ ± 26% 15.62µ ± 10% +41.54% (p=0.000 n=10) ExistsPhysical/shards=2/miss-rotating-16 6.044µ ± 38% 6.196µ ± 41% ~ (p=0.971 n=10) ExistsPhysical/shards=4/hit-first-16 15.12µ ± 16% 24.47µ ± 34% +61.84% (p=0.000 n=10) ExistsPhysical/shards=4/hit-last-16 15.67µ ± 22% 26.55µ ± 12% +69.46% (p=0.000 n=10) ExistsPhysical/shards=4/miss-rotating-16 8.755µ ± 36% 19.668µ ± 58% +124.66% (p=0.000 n=10) ExistsPhysical/shards=8/hit-first-16 13.54µ ± 29% 42.07µ ± 19% +210.74% (p=0.000 n=10) ExistsPhysical/shards=8/hit-last-16 20.66µ ± 11% 39.36µ ± 15% +90.54% (p=0.000 n=10) ExistsPhysical/shards=8/miss-rotating-16 12.43µ ± 19% 40.62µ ± 11% +226.85% (p=0.000 n=10) ExistsPhysical/shards=16/hit-first-16 16.12µ ± 32% 69.75µ ± 12% +332.78% (p=0.000 n=10) ExistsPhysical/shards=16/hit-last-16 31.13µ ± 16% 69.13µ ± 13% +122.06% (p=0.000 n=10) ExistsPhysical/shards=16/miss-rotating-16 22.81µ ± 6% 59.29µ ± 15% +159.96% (p=0.000 n=10) ExistsPhysical/shards=32/hit-first-16 21.34µ ± 24% 109.14µ ± 8% +411.42% (p=0.000 n=10) ExistsPhysical/shards=32/hit-last-16 55.95µ ± 47% 103.06µ ± 20% +84.20% (p=0.000 n=10) ExistsPhysical/shards=32/miss-rotating-16 46.36µ ± 22% 91.30µ ± 15% +96.93% (p=0.000 n=10) geomean 15.46µ 29.02µ +87.73% │ oldExists.txt │ newExists.txt │ │ B/op │ B/op vs base │ ExistsPhysical/shards=1/hit-first-16 1.391Ki ± 0% 1.391Ki ± 0% ~ (p=1.000 n=10) ¹ ExistsPhysical/shards=1/hit-last-16 1.391Ki ± 0% 1.391Ki ± 0% ~ (p=1.000 n=10) ¹ ExistsPhysical/shards=1/miss-rotating-16 432.0 ± 0% 432.0 ± 0% ~ (p=1.000 n=10) ¹ ExistsPhysical/shards=2/hit-first-16 1.492Ki ± 0% 1.492Ki ± 0% ~ (p=1.000 n=10) ¹ ExistsPhysical/shards=2/hit-last-16 1.812Ki ± 0% 1.812Ki ± 0% ~ (p=1.000 n=10) ¹ ExistsPhysical/shards=2/miss-rotating-16 864.0 ± 0% 864.0 ± 0% ~ (p=1.000 n=10) ¹ ExistsPhysical/shards=4/hit-first-16 1.695Ki ± 0% 2.922Ki ± 0% +72.35% (p=0.000 n=10) ExistsPhysical/shards=4/hit-last-16 2.656Ki ± 0% 2.922Ki ± 0% +10.00% (p=0.000 n=10) ExistsPhysical/shards=4/miss-rotating-16 1.688Ki ± 0% 1.953Ki ± 0% +15.74% (p=0.000 n=10) ExistsPhysical/shards=8/hit-first-16 2.102Ki ± 0% 4.903Ki ± 1% +133.32% (p=0.000 n=10) ExistsPhysical/shards=8/hit-last-16 4.344Ki ± 0% 4.860Ki ± 0% +11.89% (p=0.000 n=10) ExistsPhysical/shards=8/miss-rotating-16 3.375Ki ± 0% 3.891Ki ± 0% +15.28% (p=0.000 n=10) ExistsPhysical/shards=16/hit-first-16 3.039Ki ± 0% 8.860Ki ± 0% +191.55% (p=0.000 n=10) ExistsPhysical/shards=16/hit-last-16 7.844Ki ± 0% 8.860Ki ± 0% +12.96% (p=0.000 n=10) ExistsPhysical/shards=16/miss-rotating-16 6.875Ki ± 0% 7.899Ki ± 2% +14.90% (p=0.000 n=10) ExistsPhysical/shards=32/hit-first-16 4.914Ki ± 0% 16.991Ki ± 3% +245.77% (p=0.000 n=10) ExistsPhysical/shards=32/hit-last-16 14.84Ki ± 0% 17.16Ki ± 2% +15.61% (p=0.000 n=10) ExistsPhysical/shards=32/miss-rotating-16 13.88Ki ± 0% 16.06Ki ± 1% +15.77% (p=0.000 n=10) geomean 2.733Ki 3.558Ki +30.20% ¹ all samples are equal │ oldExists.txt │ newExists.txt │ │ allocs/op │ allocs/op vs base │ ExistsPhysical/shards=1/hit-first-16 31.00 ± 0% 31.00 ± 0% ~ (p=1.000 n=10) ¹ ExistsPhysical/shards=1/hit-last-16 31.00 ± 0% 31.00 ± 0% ~ (p=1.000 n=10) ¹ ExistsPhysical/shards=1/miss-rotating-16 7.000 ± 0% 7.000 ± 0% ~ (p=1.000 n=10) ¹ ExistsPhysical/shards=2/hit-first-16 32.00 ± 0% 32.00 ± 0% ~ (p=1.000 n=10) ¹ ExistsPhysical/shards=2/hit-last-16 36.00 ± 0% 36.00 ± 0% ~ (p=1.000 n=10) ¹ ExistsPhysical/shards=2/miss-rotating-16 12.00 ± 0% 12.00 ± 0% ~ (p=1.000 n=10) ¹ ExistsPhysical/shards=4/hit-first-16 33.00 ± 0% 49.00 ± 0% +48.48% (p=0.000 n=10) ExistsPhysical/shards=4/hit-last-16 45.00 ± 0% 49.00 ± 0% +8.89% (p=0.000 n=10) ExistsPhysical/shards=4/miss-rotating-16 21.00 ± 0% 25.00 ± 0% +19.05% (p=0.000 n=10) ExistsPhysical/shards=8/hit-first-16 34.00 ± 0% 68.00 ± 0% +100.00% (p=0.000 n=10) ExistsPhysical/shards=8/hit-last-16 62.00 ± 0% 68.00 ± 0% +9.68% (p=0.000 n=10) ExistsPhysical/shards=8/miss-rotating-16 38.00 ± 0% 44.00 ± 0% +15.79% (p=0.000 n=10) ExistsPhysical/shards=16/hit-first-16 35.00 ± 0% 105.00 ± 0% +200.00% (p=0.000 n=10) ExistsPhysical/shards=16/hit-last-16 95.00 ± 0% 105.00 ± 0% +10.53% (p=0.000 n=10) ExistsPhysical/shards=16/miss-rotating-16 71.00 ± 0% 81.00 ± 0% +14.08% (p=0.000 n=10) ExistsPhysical/shards=32/hit-first-16 36.00 ± 0% 178.00 ± 1% +394.44% (p=0.000 n=10) ExistsPhysical/shards=32/hit-last-16 160.0 ± 0% 178.0 ± 1% +11.25% (p=0.000 n=10) ExistsPhysical/shards=32/miss-rotating-16 136.0 ± 0% 154.0 ± 0% +13.24% (p=0.000 n=10) geomean 38.97 50.73 +30.17% ¹ all samples are equal ``` Refs #3911. Signed-off-by: Andrey Butusov --- pkg/local_object_storage/engine/exists.go | 69 +++++++++++++++++++++-- 1 file changed, 65 insertions(+), 4 deletions(-) diff --git a/pkg/local_object_storage/engine/exists.go b/pkg/local_object_storage/engine/exists.go index 98804759f6..01f741b1c6 100644 --- a/pkg/local_object_storage/engine/exists.go +++ b/pkg/local_object_storage/engine/exists.go @@ -2,6 +2,7 @@ package engine import ( "errors" + "sync" ierrors "github.com/nspcc-dev/neofs-node/internal/errors" "github.com/nspcc-dev/neofs-node/pkg/local_object_storage/shard" @@ -9,21 +10,36 @@ import ( oid "github.com/nspcc-dev/neofs-sdk-go/object/id" ) +const ( + existsParallelThreshold = 4 +) + +type shardExistsResult struct { + exists bool + err error +} + func (e *StorageEngine) existsPhysical(addr oid.Address) (bool, error) { if e.metrics != nil { defer elapsed(e.metrics.AddExistsDuration)() } - for _, sh := range e.sortedShards(addr.Object()) { + shs := e.sortedShards(addr.Object()) + if len(shs) < existsParallelThreshold { + return e.existsPhysicalSequential(addr, shs) + } + return e.existsPhysicalParallel(addr, shs) +} + +func (e *StorageEngine) existsPhysicalSequential(addr oid.Address, shs []shardWrapper) (bool, error) { + for _, sh := range shs { exists, err := sh.Exists(addr, false) if err != nil { if shard.IsErrObjectExpired(err) { return true, nil } - if errors.Is(err, apistatus.ErrObjectAlreadyRemoved) || - errors.Is(err, ierrors.ErrParentObject) || - errors.Is(err, apistatus.ErrObjectNotFound) { + if isObjectPresenceStatus(err) { return false, err } @@ -38,3 +54,48 @@ func (e *StorageEngine) existsPhysical(addr oid.Address) (bool, error) { return false, nil } + +func (e *StorageEngine) existsPhysicalParallel(addr oid.Address, shs []shardWrapper) (bool, error) { + results := make([]shardExistsResult, len(shs)) + workers := (len(shs) + listParallelThreshold - 1) / listParallelThreshold + chunkSize := (len(shs) + workers - 1) / workers + + var wg sync.WaitGroup + wg.Add(workers) + for w := range workers { + start := w * chunkSize + end := start + chunkSize + end = min(end, len(shs)) + + go func(start, end int) { + defer wg.Done() + for i := start; i < end; i++ { + results[i].exists, results[i].err = shs[i].Exists(addr, false) + } + }(start, end) + } + wg.Wait() + + for i, res := range results { + if res.err != nil { + if shard.IsErrObjectExpired(res.err) { + return true, nil + } + if isObjectPresenceStatus(res.err) { + return false, res.err + } + e.reportShardError(shs[i], "could not check existence of object in shard", res.err) + continue + } + if res.exists { + return true, nil + } + } + return false, nil +} + +func isObjectPresenceStatus(err error) bool { + return errors.Is(err, apistatus.ErrObjectAlreadyRemoved) || + errors.Is(err, ierrors.ErrParentObject) || + errors.Is(err, apistatus.ErrObjectNotFound) +}