Skip to content

Compactor: add block merge fan-in to parallelise the compaction merge - #7920

Open
alexqyle wants to merge 4 commits into
cortexproject:masterfrom
alexqyle:compactor-block-merge-fan-in
Open

alexqyle wants to merge 4 commits into
cortexproject:masterfrom
alexqyle:compactor-block-merge-fan-in

Conversation

@alexqyle

@alexqyle alexqyle commented Nov 7, 2026

Copy link
Copy Markdown
Contributor

What this changes

A partitioning compaction builds one series set per source block and merges them all in a single flat merge. Advancing that merge reads the winning block's series entry off disk, and every one of those reads is issued from the one goroutine that advances the merge heap. The disk therefore sees a queue depth near one however much IOPS the volume has provisioned, and a compaction over a large number of blocks ends up bound by read latency rather than by disk capacity. Provisioning more IOPS does not help it.

This adds -compactor.block-merge-fan-in, which groups the per-block series sets into contiguous subtrees before the final merge. Each subtree is wrapped in a backgrounChunkSeriesSet, so it advances in its own goroutine and the reads behind the subtrees overlap.

Disabled by default (0), which keeps the existing single flat merge unchanged.

Benchmark

BenchmarkMergeFanIn injects latency per set advance to model the index read that backs a merge advance. 64 sets, 1024 series replicated 3 ways, so 3136 advances per op.

go test -run XXX -bench BenchmarkMergeFanIn -benchtime 5x ./pkg/compactor/
latency fan-in merge goroutines ns/op ns/advance vs flat
0 0 (off) 1 7,519,760 2,398 —
0 4 16 8,696,905 2,773 0.86x
0 8 8 9,024,411 2,878 0.83x
0 16 4 8,390,016 2,675 0.90x
1ms 0 (off) 1 3,451,540,907 1,100,619 —
1ms 16 4 914,257,427 291,536 3.8x
1ms 8 8 496,123,534 158,203 6.9x
1ms 4 16 287,013,931 91,522 12.0x

goos: linux, goarch: amd64, cpu: AMD EPYC 7R13, 8 cores.

The ns/advance column is the mechanism. A flat merge pays the full 1.10 ms per advance because every read queues behind one goroutine; 16 goroutines bring that to 91.5 us without changing the read volume at all. Speedup tracks roughly 0.75x the goroutine count, and the shortfall is the root merge's own serial handoff.

Note the fan-in is inverted against goroutine count: a smaller fan-in produces more subtrees, so fan_in=4 over 64 sets gives 16 goroutines.

With no read latency to hide, the change costs 10-17%, which is why it defaults to off. A workload whose blocks fit in page cache should leave it off. The flag is worth setting when a compaction merges many blocks off a volume with real read latency.

Two measurement notes baked into the benchmark, since both bit me while writing it:

  • time.Sleep cannot resolve latencies below roughly 100 us, and pinning to one CPU inflates every sleep. Either makes all rows collapse onto the timer floor and look identical. The benchmark reports ns/advance so a row whose sleeps never resolved to the requested latency is visible rather than silently misleading.
  • merge_goroutines reports 1 for the flat case. A flat merge has 64 inputs but one goroutine advancing them, and the concurrency is what matters.

Correctness

TestMergeSubtreesMatchesFlatMerge asserts the grouped merge yields identical labels and samples to the flat merge, across fan-ins 2, 3, 4 and 8 over 9 sets, so both the evenly-divided case and the short trailing group are covered.

It compares samples flattened across chunk boundaries on purpose: a merge tree may lay the same data out in different chunks than a flat merge, which changes the output block's chunk count but not its data. The fixture gives a given timestamp the same value in every replica, which is how replicas present a series; with conflicting values at one timestamp, flat and tree order would legitimately differ and asserting equality would be testing undefined behaviour.

TestMergeSubtreesDisabled pins that fan-in 0, 1 and negative return the sets untouched. TestMergeSubtreesGrouping pins the subtree counts.

Verified with a sabotage check: changing the grouping loop bound so it drops the trailing partial group fails exactly the expected subtests, while leaving fan_in=3 passing since 9 divides by 3.

Memory stays comparable to the flat merge. The existing 1000-series lookahead is split across subtrees (1000/subtrees, floor 8) rather than given to each one, so the buffered series count does not grow with the subtree count.

Notes for review

  • CompactionLifecycleCallbackFactory gained a blockMergeFanIn int parameter. Existing tests needed no change, since they construct the callback with field names and assign the factory by type.
  • NewBackgroundChunkSeriesSet keeps its signature and delegates to a new NewBackgroundChunkSeriesSetWithBuffer, so current callers are unaffected.
  • docs/ and schemas/cortex-config-schema.json are regenerated output from the doc-generator, for make check-doc.
  • The CHANGELOG entry carries #TODO for the PR number; I'll follow up with the real number once this is open.
  • golangci-lint was not available in my environment, so lint coverage here is go vet and gofmt -l. The full ./pkg/compactor/ suite passes.

@alexqyle
alexqyle requested a review from a team as a code owner November 7, 2026 00:03
@alexqyle
alexqyle requested a review from yeya24 November 7, 2026 00:03
A partitioning compaction builds one series set per source block and merges
them all in a single flat merge. Advancing that merge reads the winning
block's series entry off disk, and every one of those reads is issued from
the one goroutine that advances the merge heap, so the disk sees a queue
depth near one however much IOPS the volume has provisioned. A compaction
over a large number of blocks is then bound by read latency rather than by
disk capacity, and provisioning more IOPS does not help it.

Add -compactor.block-merge-fan-in to group the per-block series sets into
contiguous subtrees before the final merge. Each subtree is wrapped in a
background chunk series set, so it advances in its own goroutine and the
reads behind the subtrees overlap. The flag is disabled by default (0),
which keeps the single flat merge unchanged.

BenchmarkMergeFanIn measures the effect. 64 sets, 1024 series replicated
3 ways, so 3136 set advances per op, with latency injected per advance:

  latency  fan-in  merge goroutines      ns/op   ns/advance   vs flat
  0        0 (off)                1    7519760         2398        --
  0        4                     16    8696905         2773     0.86x
  0        8                      8    9024411         2878     0.83x
  0       16                      4    8390016         2675     0.90x
  1ms      0 (off)                1 3451540907      1100619        --
  1ms     16                      4  914257427       291536      3.8x
  1ms      8                      8  496123534       158203      6.9x
  1ms      4                     16  287013931        91522     12.0x

The ns/advance column is the mechanism: a flat merge pays the full 1.10 ms
per advance because every read queues behind one goroutine, and 16 goroutines
bring that to 91.5 us without changing the read volume at all. Speedup tracks
roughly 0.75x the goroutine count; the shortfall is the root merge's own
serial handoff. Note the fan-in is inverted against goroutine count, since a
smaller fan-in produces more subtrees.

With no read latency to hide, the change costs 10-17%, which is why it is off
by default. A workload whose blocks fit in page cache should leave it off.

time.Sleep cannot resolve latencies below roughly 100us, so the benchmark
reports ns/advance to show the latency a row actually achieved rather than
the latency it asked for.

TestMergeSubtreesMatchesFlatMerge asserts the grouped merge yields identical
labels and samples to the flat merge across several fan-ins, including one
that leaves a short final group. It compares samples flattened across chunk
boundaries on purpose: a merge tree may lay the same data out in different
chunks, which changes the output block's chunk count but not its data.

Signed-off-by: Alex Le <leqiyue@amazon.com>
Signed-off-by: Alex Le <leqiyue@amazon.com>
@alexqyle
alexqyle force-pushed the compactor-block-merge-fan-in branch from 77fb910 to 1e8b5d7 Compare November 7, 2026 00:13
# Number of per-block series sets merged per subtree during compaction. Each
# subtree advances in its own goroutine, so a group of blocks larger than this
# value no longer issues every index read from a single goroutine. Only
# affects partitioning compaction. 0 disables it and keeps one flat merge over

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Is it possible for this to work for even non partitioning compaction?

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.

We can. The original code is mainly copied from Prometheus. We can either change Prometheus or create new populator in cortex for non partitioning

golangci-lint's modernize linter rejects the three-clause loops, and the
package's existing tests already use the range form.

Signed-off-by: Alex Le <leqiyue@amazon.com>
make lint runs faillint with sync/atomic blocklisted in favour of
go.uber.org/atomic, which this package already uses elsewhere. The Int64
Add and Load API is the same and its zero value is usable.

Signed-off-by: Alex Le <leqiyue@amazon.com>

This branch has not been deployed

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

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants