Skip to content

Experiment/cluster rescore prefetch 20260824 - #724

Draft
jshook wants to merge 43 commits into
mainfrom
experiment/cluster-rescore-prefetch-20260824
Draft

Experiment/cluster rescore prefetch 20260824#724
jshook wants to merge 43 commits into
mainfrom
experiment/cluster-rescore-prefetch-20260824

Conversation

@jshook

@jshook jshook commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

For experimental review only at this time.

This includes the SPLAT IO scheduling approach and is in active testing.

jshook and others added 30 commits August 12, 2026 16:13
Introduce an opt-in control surface a host can install on long-running
jvector operations, without touching any existing call site:

  - WorkStage / WorkLimiter / ProgressTracker: the admission + reporting
    primitives (throttle down, progress up).
  - ProgressLimiter: the single combined facet a host implements.
  - LeakyBucketLimiter: a default rate-limited WorkLimiter.

Purely additive. Nothing in the library calls these yet; they establish
the seam that host-driven operations (e.g. compaction) can later be
rewritten against.
Add a host-supplied destination abstraction so compaction output can be
written through a caller-owned channel instead of always allocating its
own file:

  - SeekableSink / FileChannelSeekableSink: a minimal seekable byte sink
    and its file-backed implementation.
  - CompactionDestination / FileCompactionDestination: the compaction
    output target, resolvable to a SeekableSink.

Purely additive interface types; no existing code is wired to them on
this branch.
Add ParallelExecutor, a minimal parallel-for abstraction that lets an
embedding host supply its own execution strategy -- its own pool, or
caller-runs on the calling thread -- instead of jvector reaching for
ForkJoinPool.commonPool():

  - forkJoin(pool): run on a caller-supplied ForkJoinPool
  - callerRuns():   run inline, no work escaping to a shared pool
  - forEachInt / forEach: the parallel-for entry points

Additive: a standalone interface with no consumers on this branch. It
establishes the execution seam that build, quantization, and compaction
paths can be rewritten against later.
Add RuntimeMode, a process-level switch (jvector.mode) separating
production from diagnostic runs. Unset is production; diagnostic-only
work (e.g. verification walks) sits behind this gate and must be opted
into explicitly.

Additive: a standalone type with no call sites on this branch. It
establishes the gate that diagnostic code paths can consult once wired.
Harden the read path for embedding hosts that manage their own mmap
lifecycle, where a stale offset or a close racing an in-flight read
faults the JVM (SIGSEGV) instead of throwing:

  - OnDiskGraphIndex: bound record reads. A node id outside the graph
    would become a wild offset into the mapped file (garbage, or a
    fault); requireValidNode() rejects it up front, and a stale/corrupt
    neighbor block now fails with IllegalStateException instead of
    consuming garbage ints.
  - ReaderSupplier / SimpleMappedReader: document the close() contract --
    the raw-release (immediate unmap -> SIGSEGV) vs coordinated
    (liveness handshake -> IllegalStateException) families -- so a host
    knows a supplier must not be closed until every vended reader is
    quiescent.

Non-breaking: internal bounds checks and documentation only, no
signature or call-path changes. The compactor-side memory safety
(drain-on-unwind, truncate-reused-outputs) stays with the compaction
work, where it lives.
use Objects.requireNonNull

Co-authored-by: Ashwin Krishna Kumar <nebulousmagneticwind@outlook.com>
Rework the progress SPI per review: startPhase(WorkStage) is now
ProgressTracker's single abstract method, and onProgress(completed,
total) lives on the returned PhaseScope, making the scope the
capability to report:

  - progress for a never-started phase is unrepresentable, and
    per-phase implementer state (bars, timers) lives in the scope
    instance instead of a map keyed by WorkStage
  - concurrent phases of the same stage are distinguished by scope
    identity
  - lambda ergonomics preserved: PhaseScope.close() defaults to a
    no-op, so a progress-only tracker is still one expression:
    stage -> (completed, total) -> ...

ProgressLimiter.acquire stays unscoped (throttling is aggregate rate,
not phase identity); the logging combinator now routes phase start,
progress, and completion through one scope wrapper.
The GitHub-applied Objects.requireNonNull suggestion in
FileCompactionDestination landed without the java.util.Objects import,
breaking compilation. Add the import, and keep the "path" message via
the two-arg form so the NPE stays as descriptive as the code it
replaced (matching the requireNonNull(sink, "sink") idiom in
ProgressLimiter).
Per review discussion: 'Target' reads as a static descriptor a caller
could construct up front and pass in, which is exactly the confusion
the API is meant to prevent. An OutputReservation is inherently live,
single-use, per-run state made against the destination — reserve() a
fresh region per compaction, commit(bodyLength) fulfills it, close
without commit releases it and discards partial output. The
destination stays stateless configuration an embedder builds once.

CompactionDestination.open() becomes reserve() to match.
Add ParallelExecutor.over(ExecutorService, int parallelism) so embedders
holding a plain ExecutorService get a one-liner alongside forkJoin(...)
and callerRuns(). Parallel streams cannot be hosted on a generic ES
(inside its workers they silently run on the common pool), so the
adapter chunks on the calling thread and submits, with:

  - bounded in-flight window (2x parallelism) on the stream paths and
    even range-splitting for forEachInt
  - nested use from a body degrading to inline execution instead of
    starving a bounded pool into deadlock
  - drain-before-unwind: on failure or interrupt, unstarted chunks
    skip via a cooperative abort flag and every started chunk is
    waited out, so the caller never unwinds beneath a running body.
    Future.cancel is deliberately unused: cancel(false) succeeds on a
    RUNNING FutureTask and get() then returns while the body still
    executes (caught by the new tests before it shipped)
  - a ForkJoinPool argument delegates to forkJoin(...), where
    whole-pipeline stream decomposition is strictly better

The class javadoc gains a 'Choosing a factory' section spelling out the
relative caveats of the three implementations, including that over(...)
distributes only the body while the source is traversed on the calling
thread.
buildBatches enumerated level-0 nodes via source.getNodes(0), which seeks and
reads a 4-byte id at every node's record offset — a full random disk scan of
the source graphs. When nothing has warmed the page cache first (notably a
full-precision compaction, which has no PQ retrain/pre-encode phase to stream
the source), that scan is the first cold access and dominates the run: on
cohere-10M 2-UNIFORM disk-cold it was ~22 min of a ~34 min compaction.

The compactor already holds liveNodes (a FixedBitSet per source) with exactly
the live ordinals, so enumerate from it and skip the scan entirely. Also drops
dead nodes up front instead of per-batch.

cohere-10M 2-UNIFORM full precision, disk-cold: 2038s -> 658s (3.1x), recall
0.6525 unchanged (identical output — same node set, just enumerated in memory).
Upper layers keep getNodes (already in-memory, no disk scan). 12/12
TestOnDiskGraphIndexCompactor pass.
Full-precision compaction descends the hierarchy from the global entry node on
every cross-source search, reading a full inline vector (getVectorInto) per hop
— a disk fault per hop at >RAM scale. Seeding warm-starts each L0 search from
entry points near the query: node u's search of source t starts from the
members of u's finished same-source neighbors' merged edges that land in t
(near u), skipping the descent. Only where candidates come from changes;
diversity, scores, and output are unaffected. Always on for full precision;
fused compaction is untouched (it already scores hops via RAM-resident ADC
codes, so seeding gains nothing there).

The win is read locality — the beam explores the target neighborhood instead
of descending from afar.

A finished node's merged edges are read back from the output file (which
already holds them) via CompactWriter.neighborCountFileOffset + a small
per-thread read channel, rather than a heap adjacency mirror. Per-node heap is
the done-flag + two ordinal maps (~12 B/node) instead of ~142 with a
degree-wide mirror (142 GB -> 12 GB at 1B). The done-flag is published after
the record is written, so an output-file read of a flagged node always sees
committed data.

Includes GraphSearcher.initializeWithSeeds and a CompactorBenchmark fix for
FULLPRECISION recall measurement in COMPACT mode (was dereferencing the
in-memory base vectors, which COMPACT mode does not load).
…e propagation

Process L0 sources in ascending live-size order with a barrier between
sources, and have each node search only the sources larger than its own.
The reverse direction of every source pair is supplied by propagation
instead of a search: when a node's beam finds a match in a larger source,
the searcher is offered into the match's bounded reverse-candidate list,
and the match's diversity selection later unions those offers with its
retained same-source edges. Similarity is symmetric and offers carry exact
scores, so propagated candidates score identically to what the target's own
search would have produced — only where candidates come from changes;
diversity, record format, and the write path are untouched.

The larger side of every pair therefore runs no cross-source searches,
which is the dominant search population when source sizes are skewed (the
LSM-typical merge shape). Upper layers keep full symmetric searches: they
are a small fraction of search volume and serve as the query's entry
structure, so they stay at maximum quality.

Memory cost is one transient reverse-candidate buffer during L0, linear in
node count, freed before upper layers are compacted.
…st source

A node of the largest source runs no forward cross-source searches, so when
it also received no reverse candidates its candidate set is exactly its
retained same-source edges — and re-running diversity selection over an
already-diversity-selected edge set is a fixed point. Skip selection for
such nodes entirely: filter dead neighbors, remap ordinals, and write the
record directly, preserving the source adjacency and its order. The record
bytes are still fully regenerated: the inline vector is read fresh and
per-neighbor fused codes come from the post-retrain code cache (or the
retrained compressor in the no-cache fallback), so codebook retraining is
unaffected. Neighbor vectors are read only in that no-cache fallback;
otherwise the only read is the node's own record.

The path fires most under skewed merges, where reverse offers concentrate
on hub nodes near the smaller sources' data and leave a meaningful
fraction of the largest source untouched.
FusedCompactionStrategy pre-encodes every live node's fused code into a single
MappedByteBuffer. That buffer is int-indexed, so it cannot exceed 2 GiB, which
capped the cache at floor(Integer.MAX_VALUE / codeSize) nodes. The cap scales
inversely with dimension, since codeSize grows with it, and it applies to the
total node count across all partitions being compacted rather than to any single
index. Past the cap the pass was skipped entirely and compaction fell back to
re-encoding per edge.

That fallback is not a constant-factor penalty. A fused graph stores each node's
neighbours' codes inline, so codes are needed once per edge rather than once per
node: the pre-encode path is O(N) parallel encodes, while the fallback is
O(N * degree) plus a gather of each neighbour's full float vector.

Split the region across several mappings, each holding a whole number of codes so
no code straddles a boundary. PreEncodedCodeCache owns the arithmetic and the
per-thread views; call sites index by ordinal only. Also fixes a latent int
overflow in the refine path, where newOrd * cacheCodeSize was computed as
int * int and wrapped at the same boundary.

TestPreEncodedCodeCache covers the chunked path directly: production chunks are
1 GiB, so every existing compaction test builds graphs far too small to reach a
second chunk, leaving the boundary arithmetic otherwise unexercised.
…d at ~134M nodes

ReverseCandidateBuffer backs its per-node candidate pools with flat arrays indexed
by ordinal * slots. That product is computed in int, so allocation wraps negative
once the merged graph exceeds floor(2^31 / REVERSE_CANDIDATE_SLOTS) total nodes
(~134M at 16 slots) and setupCrossLink dies with NegativeArraySizeException. The
cap applies to the combined node count of all sources in the merge, so it binds
exactly where cross-linking matters most: one large mature segment absorbing small
flushes.

Split the candidate arrays into fixed-power-of-two chunks of ordinals, mirroring
the pre-encode cache chunking; chunk lookup is a shift and mask, and the per-node
counts array stays flat since it is one int per ordinal. Chunk size is a
constructor parameter so a boundary-crossing test can drive it far below the
production 32M ordinals per chunk.
The compactor can assign merged ordinals itself, numbering each source's live
nodes in PQ-code order under the retrained codebook (setSimilarityOrdinals;
the mapping actually used is exposed via effectiveRemappers). Processing
order = ordinal order = write order, so records are written sequentially and
similar vectors land in adjacent records - consecutive cross-source searches
then revisit the pages the previous search faulted in, and the output's
layout carries the same locality to the next compaction and to search time.
Reverse-candidate slot blocks are allocated lazily on first offer and freed
on consumption, so the buffer tracks not-yet-consumed touched targets rather
than node count.
Under similarity ordinals, consecutive nodes are near-twin queries, so they
share one anchor search per target source. The anchor's exact-ranked result
list serves its followers: each member rescores the list against its own
query and skips its search when the triangle inequality certifies that no
point outside the list can reach its top-K (memberKth <= ThetaD - delta, in
the metric's angular/Euclidean distance). Failing certificates first extend
the anchor via search resume in small steps; only then does the member run
its own search and become the new anchor. Valid for normalized DOT_PRODUCT,
COSINE, and EUCLIDEAN, where the underlying distance is a metric; other
similarities take the plain search path. Eliminates ~2/3 of cross-source
searches on real workloads with recall preserved by construction.
Above-RAM merges are bound by read latency, not device throughput: best-first
search keeps one demand read in flight per thread, and the diversity gather
faults up to degree vectors serially per node. ReaderSupplier.willNeed (a
posix_fadvise WILLNEED on a dedicated descriptor in MemorySegmentReader)
starts asynchronous page-cache fetches without blocking, so a single thread
can keep several reads in flight. Two call sites: FrontierPrefetchingView
mirrors the searcher's candidate queue and hints its top entries - the
search's actual next expansions - and gatherFromSameSource batch-hints every
retained neighbor's record before the read loop. Hints are advisory only;
results are unchanged.
The resident reverse-candidate buffer holds a slot block for every touched
target until consumption; offers onto the largest (last-processed) source
accumulate for the whole run - tens of GB at hundreds of millions of nodes,
and the structure that breaks memory-bounded compaction of billion-node
datasets. Offers now append to small per-band write buffers spilled
sequentially to per-band files and are replayed once, through the same
dedup and top-slots selection, when the band's group is processed. Bands are
keyed by (target source, ordinal band): a source's offers are complete
exactly when its own group starts, so bands never mix sources. Peak memory
becomes O(band width), independent of node count; offers pass through disk
once as sequential I/O.
Compacting one source is the similarity re-sort / dead-node rewrite
primitive: no cross-source linking runs, every node takes the retained-only
fast path, and the output is the same graph laid out in similarity-ordinal
order.
A node now offers itself only to the cross-source neighbors its own
diversity selection kept - the reverse-edge rule of single-graph Vamana
insertion - instead of to every node its searches surfaced. The offer store,
slot semantics, and the target's single deferred selection are unchanged;
only the admission rule narrows, cutting offer volume by the
candidates-to-kept ratio and shrinking spill traffic and slot-cap pressure
by the same factor.
…nment

The quantization strategy is created before compactGraphImpl runs, and its
CompactionContext snapshots the caller's remappers at that point. When
similarity ordinals activate, the compactor swaps in the new mappers - but
the strategy kept the stale snapshot, so the fused pre-encode pass placed
every code at the caller-proposed ordinal while records, vectors, and
adjacency were written at the similarity-assigned ones. Every inline
neighbor-code copy then fetched the wrong node's code, ADC scores were
noise, and search on the merged index collapsed (recall 0.02 vs 0.77 on
dpr-gemma-1m 2/TIERED FUSEDPQ; restored to 0.77 with this fix).

The strategy must exist before retrain() but the similarity mappers can
only be built after it (they order by the retrained PQ's code prefixes),
so the context cannot simply be created later. Instead the compactor
re-snapshots its state through a new onRemappersUpdated hook immediately
after the swap. The sidecar strategy adopts it too: its writeSidecar
emits codes in newToOld order, which had the same misalignment.
Compaction chose its own concurrency: a ForkJoinPool it defaulted to
PhysicalCoreExecutor.pool(), an ExecutorCompletionService window it sized
itself, Arrays.parallelSort onto the common pool, and a codebook retrain that
called ProductQuantization.refine with two more jvector-picked pools. An
embedder that had bounded an operation to its own thread budget could not
override any of it.

Everything now runs through the ParallelExecutor the caller supplies, so how
the iteration is distributed is the host's decision:

- OnDiskGraphIndexCompactor takes a ParallelExecutor plus a stated
  parallelism. The ForkJoinPool constructors remain as thin delegates over
  ParallelExecutor.forkJoin(), so existing callers are unaffected.
- taskWindowSize no longer sizes an in-flight window -- the executor owns
  that -- and survives only to pick task granularity.
- CompactionContext carries the executor so strategy fan-out (code
  pre-encode, sidecar encode) is bound the same way.
- CompactionSort replaces Arrays.parallelSort, which takes no pool and always
  decomposes onto the common pool.
- ProductQuantization.refine gains a ParallelExecutor overload; the
  ForkJoinPool overload delegates through forkJoin(), so index-build
  decomposition is bit-for-bit unchanged.

Dropping the completion-service window means batch results are consumed on the
worker that produced them rather than on an orchestrating thread. The L0
record path is safe unlocked: each record goes to its own ordinal's offset via
a positional write, the ranges are disjoint by construction, WriteResult
already holds a cloned buffer, and doneFlag is an AtomicIntegerArray. The
upper-layer path takes a lock, because writeUpperLayerNode appends through the
sequential writer and accumulates level-1 feature records in an ArrayList.

Draining between L0 source groups is still a barrier, which the cross-link
offers depend on: forEach blocks until the whole batch list settles.
compact(Path) always wrote a standalone file at offset 0, so an embedder that
keeps graphs inside its own container had to compact to a temp file and copy
the body in afterwards.

compact(CompactionDestination) reserves an output region from the embedder and
writes the graph body directly into it, after whatever header the embedder
reserved. CompactWriter already carried a startOffset parameter that nothing
ever set to anything but 0; it is now threaded from the reservation, through
the writer, and into refinement's read-back of the merged graph.

The compactor forces before commit(): the reservation is specified to be
fulfilled only once the body is durable, and only the compactor knows when its
last write landed. commit() reports the body length measured after
onAfterClose has truncated the transient pre-encode cache section away, or it
would count that scratch space as part of the graph.

A failure leaves the reservation uncommitted, which is the signal the embedder
discards the partial output on. compact(Path) delegates via toFile() and so
now also deletes its partial file on failure and fsyncs before returning,
neither of which it did before.
A compaction runs long enough that a host needs to show it moving, and writes
hard enough that a host may need to pace it against foreground traffic.
Neither was expressible: the compactor only logged.

setProgressLimiter installs the embedder's control surface, defaulting to
ProgressLimiter.UNLIMITED, which behaves exactly as if no SPI were installed.
CompactionStage names the seven phases -- ordinal assignment, codebook
retrain, code pre-encode, base layer, upper layers, refinement, sidecar -- so
a host can render progress without knowing the compactor's internals.
CompactionContext carries the limiter so strategy phases report on the same
terms.

Byte admission goes in front of the base-layer and sidecar writes, which are
the bulk of the output. Admission is per batch rather than per record: one
blocking call amortized over ~128 nodes instead of a limiter round-trip per
record. An interrupt while throttled aborts the compaction rather than writing
through the limit, and restores the interrupt flag so the embedder's own
cancellation still observes it.

PhaseScope specifies monotonically non-decreasing progress and is written for
a single orchestrating caller, but during a fan-out there is no orchestrating
thread: an unguarded addAndGet-then-report lets a worker that counted 200
deliver before the worker that counted 150. Advancing the counter and
delivering the report happen under one lock, which keeps reports both ordered
and serialized.

Phase coverage is asserted per phase rather than by watching threads. An
escape from the injected executor bypasses it entirely -- no thread the test
observes ever misbehaves, the executor simply never gets asked to do the work
-- so the test correlates executor entry with the open phase instead.
Above-RAM merges on the deployed xlink-integration tree are read-latency
bound, exactly as 985bfe1 predicted. Measured 2026-08-23 on a 1B-row merge
(baselines.ibm_datapile_1b_default, 495 GB host):

    md0 113,136 reads/s at a 4.00 KB mean request size, 99.1% util
    CPU 4.4% user, 50.1% iowait
    compaction byte throughput (15 min): 0.000 MiB/s
    ~31k-batch merges: 39-46 batches/min, vs 5,387-27,530/min on 2026-08-21

40 of 96 RUNNABLE threads sat in the read path -- 31 in
RebufferingInputStream.readFully under FusedPQ$PackedNeighbors.readInto,
reached from gatherFromOtherSource -> clusterSearchL0 -> extendAnchor.

Nothing here changes default behaviour. Each knob defaults to what the tree
already did; the point is that three of them were unreachable and one was
invisible, so the regime could not be measured or tuned without a rebuild.

  jvector.compaction.frontierPrefetch=<int>       (default 3, as before)
      FrontierPrefetchingView's class javadoc has always documented this
      property. The code never read it -- WIDTH was a hardcoded 3. Now it is
      honoured, clamped to SHADOW_CAP; 0 disables hinting for a baseline arm.
      The 3 was "the measured knee" on a cache-resident working set, where a
      displaced hint is pure waste. Above RAM a hint that lands removes a full
      device stall, so the knee moves out with the miss rate -- and 3 hints per
      thread cannot supply the queue depth the device wants.

  jvector.compaction.batchPrefetchDensity=<long>  (default 8, as before)
      computeBaseBatch warms the batch's own L0 records only when the ordinals
      form a dense range (span <= 8x batch). Under similarity ordering they
      often do not, and a declined prefetch was indistinguishable from one that
      ran. Now tunable (0 disables, negative makes it unconditional) and
      counted: batchPrefetchIssued / batchPrefetchDeclined.

  jvector.compaction.crossSourceSeedPrefetch=true (new behaviour, default ON)
      The only batch hint possible in the cross-source path. gatherFromSameSource
      hints every candidate before reading them because the candidate list is
      known up front; gatherFromOtherSource had no hint at all, and the comment
      in computeBaseBatch says why -- "search reads into other sources are
      data-dependent and stay demand-faulted". But the SEED set is known before
      any read: it is the exact entry points the beam expands first. Hinting
      them asynchronously puts several reads in flight before
      initializeWithSeeds blocks on the first. Counted as seedHints.
      This is the one default change; set false for a baseline arm.

  jvector.disk.adviseRandom=false                 (default true, as before)
      MADV_RANDOM is right for search -- a point lookup reads one record and
      stops -- but it means there is NO FALLBACK: with readahead off, every
      access the targeted warming misses is exactly one page-sized read that
      coalesces with nothing. That is the 4.00 KB mean request size above.
      EXPERIMENTAL and process-wide, so it affects search mappings too; it is
      an A/B knob, not a production default. The principled fix is per-reader
      advice, a compaction-time supplier that declines it while search keeps it.

Also reordered SHADOW_CAP above WIDTH so the clamp does not depend on
compile-time constant folding for its correctness.

NOT done here, and worth knowing: 670f558 ("prefetch source graphs into the
page cache before bulk phases", on origin/source-pretouch) is still not an
ancestor of this branch. Cherry-picking it conflicts in all five files because
985bfe1 later rebuilt the same infrastructure. Its ReaderSupplier.prefetch /
OnDiskGraphIndex.prefetchL0Records primitives DID survive into this tree -- what
did not is any call site that warms a source before the bulk phases. Adding one
is the next experiment, and its "skip when sources exceed MemAvailable" guard
needs rethinking first: it self-disables in precisely the above-RAM regime that
motivates it. A windowed warm, sized to the batch rather than the file, is the
shape the ReaderSupplier.prefetch javadoc already recommends.

jvector-base and jvector-native both compile.
Restores the call site 670f558 added and this branch never inherited. Its
primitives DID survive -- OnDiskGraphIndex.prefetchL0Records and
ReaderSupplier.prefetch are both here -- but nothing warms a source before the
phases that sweep it, so every bulk read faults a page at a time against
MADV_RANDOM mappings with kernel readahead disabled.

The guard is windowed, and deliberately NOT 670f558's rule. That commit skips
the pass when the sources exceed MemAvailable, which self-disables in exactly
the above-RAM regime that motivates it: on the 1B-row merge measured
2026-08-23 the sources are hundreds of GB against a few hundred GB of cache,
so the whole-file test fails and the pass never runs. Here the operator caps
pretouched ordinals per source instead, warming as much as the cache can
actually hold rather than choosing between everything and nothing.

  jvector.compaction.sourcePretouchMaxNodes     default 0 (OFF)
      Per-source ordinal cap. 0 disables, -1 warms the whole source.
  jvector.compaction.sourcePretouchWindowNodes  default 1048576
      Window size, so transient cache demand stays proportional to the window
      rather than the file -- the shape ReaderSupplier.prefetch's javadoc
      already recommends.

Off by default because whether it pays depends on the consuming phase's ACCESS
ORDER, not just on size, and the two bulk phases differ:

  CODE_PRE_ENCODE sweeps every live node in order, so a warmed window is still
  resident when the sweep reaches it.
  PQ_RETRAIN samples randomly across the source, so on an over-capacity source
  the head is evicted before it is sampled and the pass is pure cost.

Measure per phase before enabling. That distinction is the reason this is a
knob rather than a default, and it is the part 670f558's size-only guard
could not express.

Windows stream one at a time rather than fanning out across sources:
prefetchL0Records is synchronous, and overlapping streams on one device turns
the sequential access this exists to create back into something the elevator
has to sort. Whether per-source parallelism wins on a wide array (this host is
5 NVMe behind md0) is worth measuring, which is why the window is a knob.

Accounted to the PQ_RETRAIN phase scope rather than given its own
CompactionStage: that enum is the host-visible phase surface -- Cassandra
reports it as sai_vector_compaction_phase -- and a new constant would change
what hosts see for a pass that is off by default.

Best-effort throughout: prefetchL0Records is a no-op on suppliers that cannot
warm, a window failure logs and moves on, and nothing here can fail a
compaction. TestOnDiskGraphIndexCompactor 19/19 pass.
gatherFromOtherSource's clusterMode branch is the only cross-source path with no
batch hint. The seed hint covers the SEEDED branch; FrontierPrefetchingView hints
the searcher's frontier, not the exact-rescore reads that follow it. Two loops in
clusterMode do hold their candidate list before the first read:

  - clusterSearchL0's member-rescore loop, over the unscored suffix of the anchor
    list, whose node ids are all known when the loop starts
  - extendAnchor's rescore loop, where resume() has already produced the complete
    SearchResult

Both are the shape gatherFromSameSource already batch-hints; this branch simply
never had it. Hints are advisory and asynchronous, so issuing them back-to-back
puts several reads in flight before the first getVectorInto blocks.

Measured 2026-08-24 on a 1B-row table during a collapsed 126.9M-ordinal merge: 38
of 94 RUNNABLE compaction threads were in clusterSearchL0 and 1 was in
gatherFromSameSource -- the existing batch hint covers the branch carrying 1 of 39
threads. Two prior arms (crossSourceSeedPrefetch, frontierPrefetch=32) failed to
move that merge because neither reaches this path.

OFF by default so enabling it is the single variable in an A/B. The hint count is
logged unconditionally whenever the cluster path ran, so a zero is visible evidence
the gate is off or the hints are not reaching the device -- silence is what hid the
ReaderSupplier default-no-op for two full test cycles.
Three changes, all needed before the next round of arms can be trusted.

1. jvector.compaction.clusterSearch (default true) gates clusterSearchUsable().
   Until now there was no way to switch the cluster path off, so the hypothesis
   that it drives the above-RAM collapse could be supported but never falsified.

   The measurement that motivates it, from 183 compactor reports spanning
   2026-08-21..24 on a 1B-row table and 1.18 BILLION anchor searches: the
   certification fast path succeeded on 0.0032% of nodes, and resumes-per-node was
   exactly 1.98 in every single report. CLUSTER_MARGIN/CLUSTER_EXT_STEP = 32/16 = 2,
   so every node exhausts the margin, fails to certify, and runs the full fallback
   search anyway -- having first exact-rescored up to 32 results, each a full vector
   read. The rate was uniform across healthy and collapsed merges and across merge
   sizes, so this is deterministic overhead, not a mistuning.

2. The batch-progress line now reports ordinals as well as batches. Ordinals per
   batch varies from ~125 to 4,096 between merges, so batch rates are not comparable
   across them; reading them as if they were cost this investigation an 8x error in
   its headline collapse figure.

3. clusterRescoreReads counts the exact rescores the cluster path performs -- every
   one a full vector read -- and it is logged with the certification rate whenever
   the cluster path ran. This makes the cost visible per merge instead of inferred
   from iostat.
jshook added 13 commits August 26, 2026 00:45
ParallelExecutor.forkJoin let a body failure throw straight out of the
parallel stream. A fork/join tree propagates that up through join() as soon
as one branch throws, so the call could return while sibling bodies were
still running on pool workers. For an embedder that owns the memory those
bodies read -- a mapped file it unmaps once the call returns -- that is a
use-after-unmap SIGSEGV, not an exception it can catch.

ForkJoinParallelExecutor wraps the body: a failure is recorded rather than
thrown, later elements short-circuit, the stream always completes normally,
and the recorded failure is rethrown after join(). Because the stream
completed normally, join() returning means every subtask finished.

The interface javadoc now states one drain guarantee that every
implementation holds, in place of the old per-factory caveat list. Three
tests cover it; reverting to the naive implementation fails two of them.
Three compactor fan-outs handed the ParallelExecutor a Stream of coarse
tasks: the similarity-ordinal pass (four quarter-million-node tasks per
source), code pre-encode (640 chunks), and the L0/upper batch runner.
forEach(Stream) gives an executor no element count, so one that batches
stream elements for the fine-grained case cannot split these evenly. With
Cassandra's executor batching 32 elements per worker, the ordinal pass ran
on ONE thread and pre-encode on half the pool.

Measured on the live node the night it was switched on: the ordinal pass
took 814s of a 17.8-minute 4M-node merge (76% of the merge; the searches
were 15%) and 55 minutes of a 137-minute 16M-node merge. Merge throughput
at identical shape went from 1.0-1.5 to 4.4-5.0 min per million nodes.

All three now dispatch through forEachInt over the list, which hands the
executor the count. That is the contract working as written -- the
implementation decides how to distribute, and can only do so when it knows
how much there is. No stream dispatch remains in graph/disk.
Dispatching the ordinal pass by index (ab90f90) let the executor split it
evenly, but the pass only handed over four tasks per source: a fixed
quarter-million-node window on ~1M-node sources. Four tasks can occupy four
workers however they are split, and sources run one after another.

Measured after ab90f90 on a 40-thread pool: 380s for 6.95M ordinals across
7 sources -- 54s per source, which is 993K nodes on 4 threads at the
measured 205us/node to within 6%. The executor was no longer the limit; the
caller's granularity was.

The window is now derived from the stated parallelism so every source
produces several tasks per worker, with a floor that keeps the per-task
view open and prefetch call amortized.
…prefetch lead

The above-RAM merge measured on 2026-08-23 (113k reads/s at a 4.00 KB mean
request, 50% iowait, 31 of 40 threads in readInto) is concurrency-bound before
it is device-bound. These are the knob-level changes the I/O cost model in
vector_merge_splat_design.md s8 calls for, ahead of any restructuring. Each has
an off switch so a baseline arm is one property.

  jvector.compaction.batchOwnRecordHints=true
      computeBaseBatch: a batch whose own-record ordinals are too sparse for one
      range prefetch now hints every live record via willNeedL0Record (async)
      instead of issuing nothing and demand-faulting one page per record.
      Counted as ownRecordHints.

  jvector.compaction.outputSyncBytes=64MiB (0 disables)
      The L0 write consumer calls FileChannel.force(false) every 64 MiB of
      base-layer output, bounding the dirty-page debt so the merge's scattered
      source reads do not push its own output pages out on the eviction path.
      Counted as outputSyncs.

  jvector.disk.prefetchLeadBytes=16MiB (0 disables)
      MemorySegmentReader.Supplier.prefetch keeps a WILLNEED lead ahead of its
      synchronous 64 KB read cursor, advised in 1 MiB steps, so the pretouch
      stream consumes reads already in flight instead of riding the kernel's
      128 KB readahead ramp on one thread.

Also logs the base-layer hint counters after L0 ("Base-layer IO hints: ..."):
they were incremented before and never reported.

Tests: MemorySegmentReaderTest covers the lead's clamping at both ends of the
file; TestOnDiskGraphIndexCompactor asserts a declined batch falls back to
per-record hints.
Every stage of a compaction now reports through the host's ProgressLimiter and
is logged and timed, uniformly, at the one point all of them pass through.

StageInstrumentation wraps the installed limiter (or UNLIMITED) in the
compactor: each phase logs "Stage X started", its total when first known, one
line per 10% of progress, and "completed: c/t units in N ms"; time spent
blocked in acquire is accumulated; and each run ends with one line —
"Compaction stage times: A=..ms B=..ms | wall, in-stage, throttle wait
(blocked admissions)" — the where-the-time-goes table, emitted by the merge.
The delegate sees exactly the calls it saw before.

Stages that were not observable, or not stages at all:

  SOURCE_PRETOUCH  new stage; was accounted inside PQ_RETRAIN. Reports warmed
                   ordinals per window.
  PQ_RETRAIN       opened and closed with no report in between. The strategy
                   and retrainer now take the scope (retrain(vsf, scope)):
                   sample extraction is the first half, refinement the second.
                   The sidecar path's retrain ran under no stage; it is now
                   PQ_RETRAIN too.
  SIMILARITY_ORDINALS  likewise reported nothing; now reports live nodes
                   encoded per window against the live total.
  FINALIZE         new stage around onAfterLevels + writeFooter; the final
                   force in commit() is logged with its duration.

The host's onProgress is its cancellation checkpoint, so PQ_RETRAIN and
SIMILARITY_ORDINALS — minutes to hours at the sizes measured — become
stoppable where they were not. A stage name is one tag value on the host's
phase metric; nothing host-side needs to change.

Tests: TestStageInstrumentation (delegate transparency, log lines, summary,
close-once, throttle accounting); TestOnDiskGraphIndexCompactor asserts every
stage of a run starts, reports, and reaches its stated total, and that the
ordinal pass reports the live node count.
…inal section

Step 1 of vector_merge_splat_design.md s7: construction emits the index's
base-layer structure as a token stream, an additive section of the index file,
so a later merge can derive its plan by one sequential scan instead of
random-access reads of payload-interleaved records.

Grammar, per ordinal in address order: a NODE prefix (bit 7 set, bit 6 live,
bits 0-5 the node's highest level) then the ordinal as an unsigned varint
delta from the previous NODE; then one NB prefix per base-layer edge followed
by the neighbour as a zigzag varint of (neighbour - node). A dead ordinal is a
NODE with the live bit clear and no NB tokens. Under similarity-ordered
ordinals neighbours sit at nearby ordinals and the deltas are short, which is
what makes the stream small; a raw encoding (prefix + 4-byte int) exists to
measure the delta form against.

Layout: [graph body][section][trailer][footer]. The trailer - section length
and magic - sits immediately before the footer's header copy, so the footer
stays last: a reader that has headerOffset looks 12 bytes before it, one that
does not never touches the section, and every other byte of the index is
where it was. Verified byte-for-byte in
TestOnDiskGraphIndex.testTokenStreamSectionIsAdditive.

Emitters:

  AbstractGraphIndexWriter.writeFooter  every in-memory-graph writer, so a
      host's flush and rebuild paths get the section with no change; it lands
      inside the length the host measures from the writer's position.
  OnDiskGraphIndexCompactor.emitTokenStream  stage TOKEN_STREAM, at the end of
      the run: refinement rewrites base-layer adjacency after the footer is
      written and the pre-encode cache is mapped past the projected end until
      onAfterClose, so the finished output is the only correct source. One
      sequential read-back of the base layer in 1M-node prefetch windows, then
      the footer rewritten behind the section.

Reader: OnDiskGraphIndex.tokenStreamSection() / openTokenStream(), discovered
in loadFromFooter; files without a section load as before.

Knobs: jvector.tokenStream (default true; Builder.withTokenStream),
jvector.tokenStream.encoding (delta | raw), jvector.compaction.tokenStream
(default true). Each emitter logs nodes, edges, bytes, B/edge and the
raw-equivalent size.

Measured on the unit-test graphs (random vectors, not similarity-ordered):
2.3-3.0 B/edge delta against 5.0 raw.

Tests: TestNodeTokenStream (both encodings round-trip, strict locator, strict
encoder); the writer parity test above; the sequential writer with and without
a hierarchy; the compactor with refinement off and on.
Step 2 of vector_merge_splat_design.md s7: the merge derives its plan from
the sources' token streams instead of reading every source vector.

The plan is a sort of live nodes on a per-node key, and the key in use was
the leading bytes of each vector's PQ code under the codebook retrained for
that merge - a value that does not exist until merge time and so can never be
carried by a source's stream. SimilarityKey replaces it with one every index
computes identically at construction, from the vector alone: the sign bits of
32 Gaussian random projections drawn from a fixed seed. Near vectors agree on
a hyperplane with probability 1 - theta/pi, so they share their leading bits
and sort together, the leading bits dominating the order the way the leading
PQ subspaces did. Streams of every generation compare.

Token stream v2 carries the key as a 4-byte field after each NODE's ordinal
when the header names a key function; v1 streams (no keys) still load.
Writers compute it from the inline-vector supplier they already receive at
write() - both host paths pass one; NVQ-only or separated-vector graphs get no
keys. The compactor computes it from the output's vectors during the
read-back it already does.

The ordinal pass, per source: if the stream carries this key function, one
sequential decode of a few bytes per node, no record touched, no encoding;
otherwise the previous windowed vector sweep computing the same key. The
default key is therefore lsh; jvector.compaction.similarityKey=pq restores
the previous key (vector sweep only) for an A/B. The pass logs "keys from
stream for k of S sources".

Batches are the plan's per-source windows read off by one forward scan of
newToSrc/newToOld (planWindow), replacing the per-source sort of (new, old)
pairs.

What changed on purpose: the ordinal order itself differs from the PQ-prefix
order. Locality under lsh against pq is a measurement for a real merge, not
something the unit tests decide.

Tests: the plan from stream keys equals the plan from vectors exactly, dead
nodes included (testPlanFromStreamMatchesPlanFromVectors); planWindow equals
the sort-based construction; keys in every written stream equal the key of
the stored vector (assertTokenStreamMatches); v2 round-trips with keys in
both encodings; a hand-written v1 section decodes; SimilarityKey is
deterministic across instances and negation flips every bit; the
similarity-ordinals recall test passes under the new key.
Step 3 of vector_merge_splat_design.md s7. Before a source's base-layer
batches run, the source is swept once in its own ordinal order and every
live node's vector and base-layer edges are spilled into bands of the merged
ordinal space; the batches then read each node's own record from its band
instead of seeking into the source.

Where this departs from the design's s5: similarity ordinals are assigned
source by source, so one source's live nodes occupy one contiguous range of
the merged space and its bands are its own. The distribute therefore runs
per source, inside the existing source loop - scratch is one source's
records, released when its batches finish, not a payload-sized area - and
the inter-source reverse-offer barrier is untouched. The pre-encode fold-in
is not done: the code cache must be complete before the first source's
batches run, which a per-source distribute cannot provide.

BandStore: records are appended under a per-band lock to the band of the
node's new ordinal (jvector.compaction.bandNodes, default 256K, clamped so a
band file stays under 1 GiB), so distribute windows run across the pool.
Record: int old | float[dim] | int count | int[degree] - the fused codes are
not copied, the output's come from the pre-encode cache, so the spill is
about 40% of a fused record. A slot index (new - start -> slot) is kept
while distributing; bands are memory-mapped lazily for reads, unmapped and
deleted on close.

Consumers: the three places that read a node's own record - processBaseNode's
vector, gatherFromSameSource's adjacency, the retained-only path - take it
from the band when the node's source is the one being processed; the batch's
own-record prefetch and hints are skipped, since they would warm pages no
longer read. Same-source neighbours' vectors, cross-source searches and
diversity vectors still read the sources.

Stage DISTRIBUTE, one phase per source, nested inside BASE_LAYER. Logs per
source: records, bytes, bands, bytes/record, wall, MB/s; on release: bands
mapped, vectors and edge lists served, scratch freed. The base-layer IO line
gains "bands: enabled= ownRecordsFromBands= spilled=".

jvector.compaction.bandStaged (default true; requires similarity ordinals)
disables it.

Tests: testBandStagedOutputMatchesDirect - with a single-threaded executor
the band-staged output is byte-identical to the direct path's, every live
node's own record came from its band, and no spill remains. TestBandStore
covers concurrent distribute, dead nodes, multi-band lookup, close-deletes,
and the file-size clamp.
testBandStagedOutputMatchesDirect asserted byte equality between the
band-staged and direct outputs. It is seed-dependent: under randomized seed
529FE7ED801CAAD3 the files differ inside the header - the retrained PQ
codebook's centroid floats - and two runs of the direct path under the same
seed differ there too. The codebook refinement is not byte-deterministic
(float summation order); nothing the band path touches is affected by it,
and every decoded quantity was identical on both arms in every run.

The test now compares what the band path can influence: node count, every
node's base-layer edges, similarity key and stored vector, and the
upper-layer edges, plus the spill-removed check. Passes under the failing
seed and a fresh one.
…all experiment

Step 4 of vector_merge_splat_design.md s7: candidate scoring from PQ codes
instead of candidate vector reads, as a selectable mode, with the recall
experiment that decides whether it becomes the default.

AdcScorer (quantization): asymmetric distance computation against a code
the caller supplies. setQuery builds the per-query partial-sum table over
every (subspace, centroid) once; similarityTo(code) is M table lookups. Same
formulas as the PQVectors decoders - verified equal to
precomputedScoreFunctionFor for dot product, Euclidean and cosine - but over
a code handed in, so the merge can score from its pre-encode cache, which
holds every live node's code under the retrained codebook keyed by output
ordinal.

jvector.compaction.candidateScoring=adc (default exact). With it no
candidate record is read at all: same-source neighbours are scored from
their cached codes against the node's exact vector; cross-source results are
rescored from their codes instead of their vectors; the diversity pass
compares candidates decoded from their codes. The node's own vector stays
exact. The cluster-search path is disabled under adc - it rescores against
the anchor's query and certifies on exact distances - so every cross-source
candidate comes from the cold search and is code-scored. Requires fused PQ
(the cache); without it the mode logs and scores exactly. Counted as
codeScores and decodes on the base-layer log.

Recall on the unit-test sources (3 x 256 random 32-dim vectors, PQ 8x256,
recall@10 over 20 queries, one seed): exact 1.000, adc 0.965, with 30,718
code scores and 35,158 decodes standing in for that many record reads.
Random uniform vectors are the least quantizer-friendly data there is; the
number that decides the default is the same experiment on real data. The
default stays exact.

Tests: TestAdcScorer (decoder parity, decode restores the centroid);
testAdcScoringRecallAgainstExact runs both modes on the same sources and
queries, checks the code path was taken, and guards recall at 0.15 below
exact.
Step 5 of vector_merge_splat_design.md s7.

Barrier. WritebackAdvisor (base) is the host-interface durability_barrier
at band granularity; SyncFileRangeAdvisor (jvector-native, FFM) implements
it with sync_file_range(SYNC_FILE_RANGE_WRITE), which queues writeback of
exactly the given range and returns, found by reflection the way
MemorySegmentReader is. In the base-layer write consumer the batch that
completes a band - per-band batch totals from the plan; a batch straddling
two bands counts for both - hints the band's output range. With the advisor
present the step-0 whole-file fdatasync cadence yields to it: it was
blocking and flushed everything. jvector.compaction.bandWriteback.

Pretouch. KeyBlockIndex per keyed source - the unsigned min/max
SimilarityKey of each run of keyBlockNodes (4096) ordinals - is built during
the ordinal pass from the same stream decode that supplies the plan's keys;
band key ranges fall out of the sorted plan. The first batch of each band
claims it and warms, in every larger source, the blocks whose extent
overlaps the band's key range, consecutive blocks merged into runs, capped
at bandPretouchMaxNodes (1M) per target. A target where more than
bandPretouchMaxOverlap (0.5) of the blocks overlap is arrival-ordered, not
key-clustered, and is skipped as unclustered rather than warmed whole. This
is the design's s5.7 window argument in code: first-generation sources cost
nothing and gain nothing; a merge output, piecewise key-sorted, is a window
per band.

Measured on the unit-test sources: first generation 3 bands, 3 targets
skipped as unclustered, 0 warmed, 3 writeback hints (native); second
generation - a merge output re-merged with a fresh source, 64-node bands and
blocks - 16 bands, 960 target ordinals warmed (~240 of 768 per searching
band), 1 skipped.

Logged after L0: "Band barriers: writebackHints= (advisor=) ; band pretouch:
bands= nodes= skippedUnclustered=".

Tests: TestKeyBlockIndex; SyncFileRangeAdvisorTest (native links, the base
factory finds it, hints a written range); testBandBarrierAndPretouch across
both generations.
…cency

Step 6 of vector_merge_splat_design.md s7: the cross-source beam search
walks a source's base-layer adjacency in memory, built from its token
stream, and scores every visited node from the pre-encode cache - the search
reads no record.

ResidentGraph: one source's base-layer adjacency from one sequential stream
decode, degree + 1 ints per node (count, then edges, -1 padded) in 256 MB
chunks. Its View serves level 0 from the arrays and delegates everything
else - upper-level edges, already resident in OnDiskGraphIndex, the entry
node, liveness, containment - to the source's own view. GraphSearcher.Builder
takes a View, so that is the whole integration surface.

jvector.compaction.residentSearch (default off). Sources go resident
largest-first within residentGraphMaxBytes (8 GiB) - the largest sources are
the search targets - and each thread holds a searcher per resident source.
A search over one scores each visited node with AdcScorer against its cached
code under the retrained codebook; results are then rescored per
candidateScoring: exact is one record read per result rather than per hop,
adc is none. The cluster path is off for resident sources. Requires fused PQ
and sources that carry a token stream; any other source is searched on disk
as before. Logged after the layers: searches, resident sources, adjacency
bytes.

Parity: the resident adjacency equals the on-disk base layer at every
level, and a search over the resident view with the same score function as
the on-disk view returns identical results - node for node, score for score,
same visited count - for 20 queries (TestResidentGraph). Recall on the
unit-test sources (recall@10, 20 queries): on-disk 0.995, resident 0.995,
768 searches over 3 resident sources.

With residentSearch=true and candidateScoring=adc the base layer's reads are
the distribute sweep and cache lookups, nothing else. Default off, like adc:
the 2 x 2 experiment on real data decides.
…, mapped read-back

The experiment steps 4 and 6 deferred to real data: residentSearch x
candidateScoring on the same four flush sstables (3.97M nodes, dim 384,
fused PQ 96x256), recall against brute-force ground truth. Results and the
setup are in vector_merge_splat_design.md s8.8: recall@10 exact 0.9520,
adc 0.9475, resident 0.9485, both 0.9485 - half a point where the synthetic
vectors showed 3.5-5.5.

MergeExperiment (jvector-examples): retrofit copies an SAI Terms component,
drops the container trailer and appends the token stream; gt samples queries
from the sources and brute-forces top-K over all of them in one pass; arm
merges with the two switches set and reports recall, wall, and the process's
own block IO (/proc/self/io), so the daemon's traffic on the same array does
not count.

TokenStreamRetrofit: the compactor's end-of-run emit, factored out so it can
be applied to an existing index - what turns a first-generation source into
one the plan, the resident search and the key-window pretouch can use. The
compactor delegates to it. The read-back now goes through the mapped reader:
the RandomAccessFile-backed one turned every neighbour list into ~35 read(2)
calls, 25 us a node, 98-115 s per 4M-node arm; a 1M-node retrofit went from
31.7 s to 5.8 s.

OnDiskGraphIndex.discoverTokenStream finds the section for a graph loaded by
header offset inside a container that has its own trailer after the graph's
footer (a raw SAI component). Public setters for the three per-merge
switches: setCandidateScoringFromCodes, setResidentSearch, setBandStaged.
@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.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants