Repository navigation
Compaction: cell-walk optimizations, sharded compaction sessions, and the output’s format version - #798
Open
dian-lun-lin wants to merge 6 commits into
Open
Compaction: cell-walk optimizations, sharded compaction sessions, and the output’s format version#798dian-lun-lin wants to merge 6 commits into
dian-lun-lin wants to merge 6 commits into
Conversation
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
Contributor
|
Before you submit for review:
If you did not complete any of these, then please explain below. |
Contributor
Author
|
@pkolaczk This PR addresses the sharding. Could you take a look at the new sharding support API? |
dian-lun-lin
marked this pull request as ready for review
November 12, 2026 19:24
dian-lun-lin
requested review from
MarkWolters,
ashkrisk,
jshook,
r-devulap,
tlwillke and
vasimuddinmd
as code owners
November 12, 2026 19:24
MarkWolters
requested changes
Nov 12, 2026
MarkWolters
left a comment
Contributor
There was a problem hiding this comment.
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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).
sharded()opens a session; eachcompactShardmerges 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.QuantizationCompactionStrategy.adoptCompressor).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:
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.
ShardExpin benchmarks-jmh is the experiment (seeCompactorBenchmark.md).2.
withVersionchooses the output's on-disk format versionThe compactor wrote
OnDiskGraphIndex.CURRENT_VERSIONonly.withVersion(int)names the version of the output, any oneGraphIndexFormatFactoryknows; 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:
POSIX_MADV_WILLNEEDon the native reader), instead of one demand fault at a time.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;withVersionround-trips at every version through the versioned loader and checks the rejections. 27 tests.