Skip to content

Compaction: cell-walk optimizations, sharded compaction sessions, and the output’s format version - #798

Open
dian-lun-lin wants to merge 6 commits into
mainfrom
compaction-shard
Open

dian-lun-lin wants to merge 6 commits into
mainfrom
compaction-shard

Conversation

@dian-lun-lin

Copy link
Copy Markdown
Contributor

Three commits on the on-disk graph compactor, one per topic: sharded compaction, the output's format version, and the cell-walk optimizations that make large merges practical.

1. Sharded compaction sessions

Cassandra's sharded compaction writes K output sstables per compaction, each over a token range. Running K independent compactions over the same N sources repeats everything that does not depend on the shard, codebook training above all, and loses recall: a hash shard keeps 1/K of each node's same-source edges and nothing replaced them (at K=64 a node kept 0.5 of its 32, and the output was nearly bipartite between the sources).

// liveNodes: the live nodes of ALL the shards together
var compactor = new OnDiskGraphIndexCompactor(sources, liveNodes, VectorSimilarityFunction.COSINE, null);
var session = compactor.sharded();
List<OrdinalMapper> shard0 = session.compactShard(shard0LiveNodes, output0);   // per source, the shard's nodes
List<OrdinalMapper> shard1 = session.compactShard(shard1LiveNodes, output1);
  • sharded() opens a session; each compactShard merges one output with that shard's nodes as its live set and returns the shard's ordinal mappers. Shards may become known one at a time.
  • The inline and sidecar codebooks and the wide diversity code are trained once, on the live nodes of every shard, by the first shard that needs them, and adopted by the rest (QuantizationCompactionStrategy.adoptCompressor).
  • A source with under half its nodes live is joined with itself like another source: one more target of the level-0 cell walk, one more graph search at the upper levels. This restores the same-source edges a shard loses.
  • A merge over a slice of the sources can leave their top levels without a live node; the output's hierarchy now ends at the highest level that still has one (previously No live nodes found at maxLevel).

Measured on tessera-2M, two 1M sources (1024-d, cosine, degree 32) into K hash shards, total wall time and recall@10 over all shards, 8 cores:

K one compaction per shard one session rebuild of every shard
4 454 s, 0.781 566 s, 0.825 876 s, 0.836
16 981 s, 0.823 567 s, 0.894 946 s, 0.903
64 1203 s, 0.875 627 s, 0.945 886 s, 0.947

The session's cost is flat in K and its recall within a point of a rebuild. At K=4 the same-source join costs more than the shared training saves; from K=16 it wins on both. ShardExp in benchmarks-jmh is the experiment (see CompactorBenchmark.md).

2. withVersion chooses the output's on-disk format version

The compactor wrote OnDiskGraphIndex.CURRENT_VERSION only. withVersion(int) names the version of the output, any one GraphIndexFormatFactory knows; the header follows the version's format, the footer is written only for footer-based versions (5+), and a version that cannot hold the output is rejected up front (fused PQ needs 6, upper layers need 4+). Source versions are detected from their files and may differ from the output's. A session writes every shard at its compactor's version.

3. Cell-walk optimizations for large merges

The level-0 cell walk and the passes around it, tuned for merges whose store is larger than memory:

  • Range sizing from the heap and from measurement. The walk's ranges were bounded by fixed budgets and a pair estimate several times too high, so the store was swept several times more often than needed. Budgets now scale with the heap, and after a source's first range the pairs per node come from what that range measured.
  • Record announce-ahead. Source records read in region order are announced to the page cache 64 nodes ahead (POSIX_MADV_WILLNEED on the native reader), instead of one demand fault at a time.
  • The hub map by position. Level-1 neighbours are positions in the level, resolved once; the two per-node tables of the whole merge are gone, and cells and region ordinals no longer depend on batch completion order.
  • Release when level 0 is done. The hub map, cell tables and every worker's scratch are freed before the upper layers are written.
  • Direct I/O for a store larger than memory. When the store does not fit beside the heap, each sweep reads runs of cells into per-thread aligned buffers through a direct-I/O descriptor instead of thrashing the page cache (measured on a 1.5 TB store against 400 GB: a third of the machine in reclaim through the mapping).

The walk's budgets, announce-ahead and direct-read decision have package-private setters for the tests only; each has a default the compactor derives from the machine or the sources.

Tests

TestOnDiskGraphIndexCompactor: direct reads and fixed budgets produce the same merge; sharded sessions over full-precision and fused sources (membership, ordinals, vectors, edge mix, rejection of a non-live node); the empty-top-level merge; withVersion round-trips at every version through the versioned loader and checks the rejections. 27 tests.

The level-0 cell walk and the passes around it, tuned for merges whose store
is larger than memory, and the compactor's settings kept its own.

Range sizing. The walk's range was bounded by a fixed 3 GiB of query vectors
and a pair budget capped at 8 GiB, with the pairs per node estimated once from
the cell sizes; that estimate overstates the count several times, so every
range was several times smaller than its budget allowed and the store was
swept correspondingly more often. The query budget now scales with the heap
(a sixteenth of it, at least 3 GiB), the pair budget's cap is 32 GiB, and
after a source's first range the pairs per node come from what that range
measured, with the busiest target's pair array kept under half the array
limit even if the next range doubles them.

Record announce-ahead. A source record is announced to the page cache a fixed
number of nodes ahead of its read (ReaderSupplier.announce, POSIX_MADV_WILLNEED
on the native reader, a positional read elsewhere): in the level-0 pass for
batches whose ordinals are scattered by the region order, and in the
pre-encode's wide path, which reads the source at random and had no readahead.
Without the announcement each read is a demand fault served one at a time.

The hub map by position, sized by its hubs. Level-1 neighbours are stored as
positions in the level, resolved once when the hub map is built, so the beams
walk positions and never look an id up; a scored hub maps to its cell through
one position-indexed table. The two tables that held one entry per node of the
merge are gone: ids are resolved by binary search over the level's sorted node
list, which the merged upper layers now keep in ascending (source, node) order,
and that order also makes the cells and the region ordinals independent of
which batch finished first. The beam reuses per-thread arrays and rescans for
its worst slot only after replacing it.

Release when level 0 is done. The upper layers are written from the merged
arrays, so the hub map's level-1 mapping and vectors and the cell position
tables go before that pass instead of in the final cleanup; the scratches of
all worker threads are closed, not only the calling thread's.

Direct I/O for a store larger than memory. Every range of the walk reads the
targets' store once from end to end. When the store fits beside the heap the
mapping serves the later passes from memory; when it does not, each mapped
page read evicts a resident page and the kernel reclaims synchronously inside
the reading threads. The walk now decides by size and in the direct case
reads each run of cells into a per-thread aligned buffer through a second,
direct-I/O descriptor on the output file, decoding rows from the buffer;
nothing touches the page cache. The pre-encoded cache learns its path and the
aligned run read; the wide decoder decodes from a buffer.

The walk's budgets, the announce-ahead distance and the direct-read decision
have package-private setters for the tests only: each has a default the
compactor derives from the machine or the sources, and a caller is not
expected to know better.

Claude-Session: https://claude.ai/code/session_016pmoQbpUFKvQKpvRc7Xvjz
sharded() opens a sharded compaction of the compactor's sources: each
compactShard(shardLiveNodes, path) call merges one output, with that shard's
nodes as its live set, and returns the shard's ordinal mappers, so a writer
can merge each shard as it learns its membership. What does not depend on the
shard is done once: the inline and sidecar codebooks and the wide diversity
code are trained on the whole compaction and adopted by every shard
(QuantizationCompactionStrategy.adoptCompressor).

A shard keeps 1/M of every source, so a node kept only 1/M of its same-source
edges and nothing replaced them: measured on 2 x 1M vectors into 64 shards, a
node kept 0.5 of its 32 same-source edges and the output was nearly bipartite.
A source whose live nodes are a minority (below half) is therefore joined with
itself like another source: one more target of the level-0 cell walk, one more
graph search at the upper levels. Prefetching a whole source window is skipped
when less than an eighth of it is live.

A compaction over a slice of the sources' nodes can also leave the sources'
top levels without a live node, and the merge failed with "No live nodes found
at maxLevel". The output's hierarchy now ends at the highest merged level that
still has a live node, with the entry node taken from it.

ShardExp (benchmarks-jmh) is the experiment behind the numbers: it builds N
source graphs over slices of a dataset, produces K output shards by per-shard
merge, by one session, or by a rebuild of every shard from its vectors, and
reports recall@10 of the merged top-10 against the ground truth with wall and
CPU time per shard. CompactorBenchmark.md describes how to run it.

On tessera 2M (two 1M sources, degree 32) into 64 shards: 627 s and
recall@10 0.945 in one session, against 1182 s and 0.875 for 64 per-shard
merges and 886 s and 0.947 for 64 rebuilds.

Claude-Session: https://claude.ai/code/session_016pmoQbpUFKvQKpvRc7Xvjz
The compactor wrote OnDiskGraphIndex.CURRENT_VERSION only; CompactWriter had no
version parameter. withVersion(int) now names the version of the output, any
one GraphIndexFormatFactory knows, and the writer follows it: the header is
built through the version's format as before, the footer is written only for
footer-based versions (5 and above), and a version that cannot hold the output
is rejected up front (fused PQ needs 6, upper layers need 4 or above). The
sources' versions are still detected from their files and may differ from the
output's. A sharded compaction writes every shard at the session's version.

testCompactWithVersion round-trips a hierarchical merge at 4, 5 and 6 and a
single-layer merge at 2 to 6 through the versioned loader, and checks the
rejections.

Claude-Session: https://claude.ai/code/session_016pmoQbpUFKvQKpvRc7Xvjz
@github-actions

Copy link
Copy Markdown
Contributor

Before you submit for review:

  • Does your PR follow guidelines from CONTRIBUTIONS.md?
  • Did you summarize what this PR does clearly and concisely?
  • Did you include performance data for changes which may be performance impacting?
  • Did you include useful docs for any user-facing changes or features?
  • Did you include useful javadocs for developer oriented changes, explaining new concepts or key changes?
  • Did you rebase your branch onto the latest main for regression testing and PR submission?
  • Did you trigger regression testing via Run Bench Main and review results?
  • Did you adhere to the code formatting guidelines (TBD)
  • Did you group your changes for easy review, providing meaningful descriptions for each commit?
  • Did you ensure that all files contain the correct copyright header?
  • Did you add documentation for this feature to the release notes directory?

If you did not complete any of these, then please explain below.

@dian-lun-lin

Copy link
Copy Markdown
Contributor Author

@pkolaczk This PR addresses the sharding. Could you take a look at the new sharding support API?

@MarkWolters MarkWolters added enhancement New feature or request 4.1.0 Release Label labels Nov 12, 2026
@dian-lun-lin
dian-lun-lin marked this pull request as ready for review November 12, 2026 19:24

@MarkWolters MarkWolters left a comment

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.

Found a couple of issues I'd like to see addressed before merging but overall looks really good!

The direct-I/O path aligned offsets, lengths and buffers to 4 KB and only
checked that the direct open succeeded. The JDK requires alignment to the
file store's block size, so on a store with 64 KB blocks (XFS or btrfs on
64 KB-page kernels, HFS+, NFS) the first read failed and the compaction
aborted with "Channel position ... is not a multiple of the block size".

PreEncodedCodeCache now takes the alignment from the file store (at least
4 KB, a power of two up to 1 MB), probes one aligned read when it opens the
direct channel, and falls back to the mapping on any failure; allocateAligned
and directBufferBytes use the cache's alignment. Verified on a loop-mounted
XFS volume with 64 KB blocks: the old code fails as reported, the new code
reads and a full merge runs with direct I/O.
A strategy that implemented only retrain and compressor worked in compact()
and failed on the first compactShard(); every strategy must now say how it
adopts a shared compressor, so the gap shows at compile time.
The join of a sparse source with itself was on in every compactor, so an
ordinary compact() over a source with under half its nodes live also got
it: an extra cell-walk target and upper-level search, the largest source
losing its retained-only path, and every worker's candidate scratch sized
for one more search. It was measured for shards only. An ordinary
compaction now never joins a source with itself; a shard's compactor joins
sources below half live, as before. The candidate scratch counts the extra
search only when some source is joined with itself. The package-private
setter, which no test used, is gone.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

4.1.0 Release Label enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants