Skip to content

perf: implement batch execution AND/OR/NAND - #479

Open
cheb0 wants to merge 2 commits into
329-lid-bitmapsfrom
329-batch-execution
Open

perf: implement batch execution AND/OR/NAND#479
cheb0 wants to merge 2 commits into
329-lid-bitmapsfrom
329-batch-execution

Conversation

@cheb0

@cheb0 cheb0 commented Jul 29, 2026

Copy link
Copy Markdown
Collaborator

Description

Batch execution allows to iterate over inverted index batch by batch instead of lid by lid.

Batches are either of slice or roaring bitmap type. When batches are intersected/unioned they are converted to bitmaps.

Enabling

  • query AST should suffice (AND/OR/AND NOT/range)
  • range node can have at most 5 tids resolved
  • iteration cost is evaluated and must be at least of configured number (freqs are required in fraction)

Iteration cost evaluation allows to enable batching when it's really worth it. In that case we know some good amount of LID blocks will be directly used as bitmaps and not be converted.

Results

  • Searches - I'd say most queries are unaffected except those which iterate over millions of postings.
Query env Total cold, ms   hot, ms   cold (branch), ms   hot (branch), ms   cold diff hot diff
service:large-service prod 220827 16.32 ±3.45 0.54 ±0.10 16.81 ±6.27 0.7 ±0.09 3% 29.6%
service:large-service AND level:6 prod 111809 24.9 ±3.25 6.75 ±0.12 18.42 ±1.76 2.56 ±0.49 -26% -62.1%
service:small-service AND level:3 prod 349 35.75 ±1.03 2.07 ±0.13 38.12 ±1.89 2.37 ±0.12 6.6% 14.5%
service:srv* AND level:[0 to 3] prod 214 51.54 ±1.94 8.49 ±0.15 51.67 ±1.54 9.43 ±0.19 0.3% 11.1%
request_host:large-host AND response_status:200 lb 1058473 47.01 ±2.74 21.16 ±0.36 22.23 ±1.69 5.09 ±0.25 -52.7% -75.9%
request_host:large-host AND response_status:500 lb 2 14.91 ±0.33 0.31 ±0.03 16.62 ±2.67 0.48 ±0.15 11.5% 54.8%
request_host:large-host AND request_method:POST AND geoip_country:RU lb 485486 66.71 ±5.18 30.11 ±0.17 25.66 ±2.96 5.18 ±0.70 -61.5% -82.8%
(response_status:504 or response_status:502) and (cluster_name:cl1 or cluster_name:cl2) lb 2996 49.42 ±2.28 2.2 ±0.06 44.76 ±2.00 1.66 ±0.03 -9.4% -24.5%
k8s_service_name:medium-service and request_host:medium-host lb 357675 21.1 ±0.66 5.31 ±0.13 17.55 ±1.31 2.02 ±0.10 -16.8% -62%
(request_host:small-host or request_host:small-host2) and hostname:some-server lb 25 19.72 ±0.69 0.42 ±0.03 18.66 ±0.73 0.45 ±0.04 -5.4% 7.1%
NOT response_status:200 lb 192352 32.85 ±3.50 13.19 ±0.20 45.88 ±2.08 27.34 ±0.72 39.7% 107.3%
request_host:large-host AND NOT response_status:200 lb 6747 69.38 ±2.03 17.32 ±0.15 22.18 ±2.43 4.22 ±1.12 -68% -75.6%
request_host:small-host AND NOT (request_method:post or request_method:get) lb 0 43.58 ±2.40 17.62 ±0.26 17.32 ±0.75 2.77 ±0.25 -60.3% -84.3%
  • Histograms - behave like searches but improvement is lower since we read MIDs
Query env Total cold, ms   hot, ms   cold (branch), ms   hot (branch), ms   cold diff hot diff
service:large-service AND level:[4 to 6] | hist 60s prod 218423 95.5 ±3.71 12.59 ±0.41 87.72 ±5.60 6.97 ±1.43 -8.1% -44.6%
request_host:large-host AND response_status:200 | hist 60s lb 1058473 136.62 ±5.49 27.33 ±1.49 112.97 ±4.36 11.1 ±0.98 -17.3% -59.4%
  • Aggs - Mostly aggs do not use batching, but there is an overhead from allocating batches in LID cursors.
Query env Total cold, ms   hot, ms   cold (branch), ms   hot (branch), ms   cold diff hot diff
|service:marketing-actions-api | by k8s_pod prod 112107 256.22 ±4.80 173.08 ±3.03 294.39 ±29.50 216.32 ±24.66 14.9% 25%
request_host:api.ozon.ru | by remote_addr lb 1065220 2307.86 ±107.43 2148.07 ±127.33 2830.23 ±473.64 2591.46 ±388.58 22.6% 20.6%

Major problems

There are problems I found while working on batch execution. Can be partially addressed in future.

  • LIDBatch - interface dispatch overhead is now present on inverted index which has partially affected hot queries performance.
  • roaring bitmap doesn't have NextGEQ (single function)
  • Batches can grow very large, i.e. level:[5 to 7] (nodeOrBatchedMulti) can yield a large batch of size more than LID block. Truncating a batch can cost CPU and increase query execution time.
  • Batches needs to be allocated
  • Batch truncation takes CPU time. Current execution model is same as for LIDs which means we need truncation when do OR/AND NOT. It's possible to redo the model with full materialization. It will sacrifice early exit and block skipping but can be more performant with current inverted index sort order.

  • I have read and followed all requirements in CONTRIBUTING.md;
  • I used LLM/AI assistance to make this pull request;

@cheb0
cheb0 force-pushed the 329-batch-execution branch 3 times, most recently from 5891414 to 85278eb Compare July 29, 2026 08:49
@cheb0
cheb0 marked this pull request as ready for review July 31, 2026 05:59
@cheb0
cheb0 force-pushed the 329-batch-execution branch from 85278eb to c5804e0 Compare July 31, 2026 09:10
@eguguchkin eguguchkin added this to the v0.77.0 milestone Aug 3, 2026
@dkharms dkharms added the performance Features or improvements that positively affect seq-db performance label Aug 6, 2026
@cheb0
cheb0 force-pushed the 329-batch-execution branch from c5804e0 to 28330f0 Compare August 13, 2026 05:27
@cheb0 cheb0 changed the title perf: batch execution AND/OR/NAND perf: implement batch execution AND/OR/NAND Aug 13, 2026
@cheb0
cheb0 force-pushed the 329-batch-execution branch from 28330f0 to 1116aa2 Compare August 13, 2026 05:36
@codecov-commenter

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 75.27734% with 156 lines in your changes missing coverage. Please review.
✅ Project coverage is 71.54%. Comparing base (b35168b) to head (d2eef53).

Files with missing lines Patch % Lines
node/node_static.go 0.00% 48 Missing ⚠️
frac/processor/batch_eval_tree.go 75.40% 21 Missing and 9 partials ⚠️
node/util.go 48.78% 21 Missing ⚠️
node/node_or.go 77.41% 13 Missing and 1 partial ⚠️
frac/processor/search.go 84.61% 6 Missing and 6 partials ⚠️
frac/active_index.go 38.46% 8 Missing ⚠️
cmd/seq-db/seq-db.go 0.00% 7 Missing ⚠️
node/batch_ops.go 94.91% 3 Missing and 3 partials ⚠️
frac/sealed_index.go 88.57% 2 Missing and 2 partials ⚠️
node/batch.go 95.65% 1 Missing and 1 partial ⚠️
... and 2 more
Additional details and impacted files
@@                 Coverage Diff                 @@
##           329-lid-bitmaps     #479      +/-   ##
===================================================
+ Coverage            71.49%   71.54%   +0.05%     
===================================================
  Files                  235      238       +3     
  Lines                19328    19858     +530     
===================================================
+ Hits                 13818    14208     +390     
- Misses                4465     4590     +125     
- Partials              1045     1060      +15     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@github-actions

Copy link
Copy Markdown
Contributor

🔴 Performance Degradation

Some benchmarks have degraded compared to the previous run.
Click on Show table button to see full list of degraded benchmarks.

Show table
Name Previous Current Ratio Verdict
Indexer-4 a73114 c61b6a
678752766.00 B/op 762523594.00 B/op 1.12 🔴
Sealing_NoSort-4 a73114 c61b6a
5335.00 allocs/op 8308.00 allocs/op 1.56 🔴
Sealing_WithSort-4 a73114 c61b6a
5389.00 allocs/op 8381.00 allocs/op 1.56 🔴

@eguguchkin
eguguchkin requested review from forshev and removed request for dkharms August 20, 2026 08:34
Comment thread config/config.go
Enabled bool `config:"enabled"`
// CostThreshold is the minimum estimated non-batched execution cost required to enable batch-at-a-time query
// evaluation. Suggestion is to use value which is greater than 3 x LID block size.
CostThreshold int `config:"cost_threshold" default:"50000"`

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.

Default 50000 contradicts the comment right above:

3 x LID block size

Comment thread node/util.go
}

func (b *batcherNode) NextBatch() LIDBatch {
batch := b.batch[:0]

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 little bit error-prone because at first I thought that batcherNode can wrap any other node, not only the root node (incorrect usage will lead to corruptions).

Maybe we should rename this type to specify that this type must wrap the root?

Comment thread node/node.go
@@ -14,9 +14,9 @@ type Node interface {
type BatchedNode interface {
fmt.Stringer
// NextBatch returns next batch. Returns nil when exhausted.

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.

It returns emptyBatch, not nil. Same for NextBatchGeq comment.

Comment thread frac/processor/search.go

lidsBatch := lidsBuf[:n]
total += n
remaining -= n

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.

We can silently drop some batches when lids that are mapped to the same id are returned.

Here we substract the lids count but here we perform deduplication.

Here is the small reproduction (it's LLM written but it is straight to the point):

package processor

import (
	"context"
	"testing"

	"github.com/stretchr/testify/require"

	"github.com/ozontech/seq-db/metric/stopwatch"
	"github.com/ozontech/seq-db/node"
	"github.com/ozontech/seq-db/seq"
)

type stubIDsIndex struct {
	ids []seq.ID
}

func (s *stubIDsIndex) LessOrEqual(lid seq.LID, id seq.ID) bool { panic("not used") }
func (s *stubIDsIndex) GetMID(lid seq.LID) seq.MID              { return s.ids[lid].MID }
func (s *stubIDsIndex) GetRID(lid seq.LID) seq.RID              { return s.ids[lid].RID }
func (s *stubIDsIndex) Len() int                                { return len(s.ids) }

func (s *stubIDsIndex) GetMIDs(lids []node.LID, out []seq.MID) []seq.MID {
	for _, lid := range lids {
		out = append(out, s.ids[lid.Unpack()].MID)
	}
	return out
}

func (s *stubIDsIndex) GetRIDs(lids []node.LID, out []seq.RID) []seq.RID {
	for _, lid := range lids {
		out = append(out, s.ids[lid.Unpack()].RID)
	}
	return out
}

func TestIterateEvalTreeDuplicateIDsAtBatchBoundary(t *testing.T) {
	// LID 0 is unused; IDs descend as LID grows; LIDs 1 and 2 share one seq.ID.
	idx := &stubIDsIndex{ids: []seq.ID{
		1: {MID: 600, RID: 1},
		2: {MID: 600, RID: 1}, // duplicate of LID 1
		3: {MID: 500, RID: 1},
		4: {MID: 400, RID: 1},
		5: {MID: 300, RID: 1},
		6: {MID: 200, RID: 1},
	}}

	// Single batch with 6 LIDs, longer than the limit.
	evalTree := node.NewStaticBatched([]uint32{1, 2, 3, 4, 5, 6}, seq.DocsOrderDesc.IsReverse())

	params := SearchParams{
		Limit: 3,
		Order: seq.DocsOrderDesc,
	}

	total, ids, _, _, err := iterateEvalTree(context.Background(), params, idx, evalTree, nil, stopwatch.New())
	require.NoError(t, err)

	// 6 LIDs exist and the limit is not yet satisfied after the duplicate,
	// so the search must keep scanning: expected IDs are 600, 500, 400.
	got := make([]seq.MID, 0, len(ids))
	for _, id := range ids {
		got = append(got, id.ID.MID)
	}
	require.Equal(t, []seq.MID{600, 500, 400}, got)
	require.Equal(t, 4, total) // 4 LIDs scanned to produce 3 distinct IDs
}

Comment thread frac/sealed_index.go

tokenTable := ti.tokenTableLoader.Load()
for i, tid := range tids {
if tid == 0 {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

nit: This shouldn't really happen. If it does, there's a bug somewhere. Better to panic here than to silently ignore it, right?

Comment thread frac/sealed_index.go
return tids, nil
}

func (ti *sealedTokenIndex) GetFreqsByTIDs(tids []uint32, field string) []uint32 {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

nit: It seems like it would be more optimal to fetch both the token and its frequency in a single block load inside GetTIDsByTokenExpr() — but that's more complicated than fetching them separately.

Comment thread node/batch.go
if len(dst) == 0 || len(tmp) == 0 {
return 0
}
n := it.it.NextMany(tmp[:min(len(dst), len(tmp))])

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

I don't like that here — and in other implementations — tmp affects the size of dst. That makes responsibility blurry. Let's call NextMany multiple times if tmp is smaller than dst.

Comment thread node/node_static.go
n.ptr = len(n.data)
}

if n.batch.IsEmpty() {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Looks like an unnecessary check here

Comment thread node/node_static.go
n.ptr = -1
}

if n.batch.IsEmpty() {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Looks like an unnecessary check here

Comment thread node/util.go

func (b *batcherNode) NextBatchGeq(nextID LID) LIDBatch {
batch := b.batch[:0]
polled := 0

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Same here — batch and pooled don't really make sense in this context

}
childCosts[i] = c
}
if len(childCosts) != 2 {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

nit: Looks like a second check for 'not'. Is it definitely needed here?

if err != nil {
return 0, err
}
if len(tids) > maxBatchedTIDsPerLeaf {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

I just don't fully understand the criteria for using batches here

return 0, nil
}

freqs := ti.GetFreqsByTIDs(tids, field)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

What do you think — could this method be merged with GetTIDsByTokenExpr in the future to extract data in a single pass?

return nil, errBatchingUnsupported
}

cache := make(leafTIDsCache)

@eguguchkin eguguchkin Aug 21, 2026

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Just thinking out loud — maybe in the future we could create an intermediate structure for the query execution plan, like AST → PlanTree, and this populated plan would contain all the TIDs, costs, and logical nodes, which would then be used to build the LIDs processing tree. That way we wouldn't have to make the same index lookups (GetTIDsByTokenExpr) repeatedly through the cache.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

performance Features or improvements that positively affect seq-db performance

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants