diff --git a/docs/release notes/4.1.0/695.performance.md b/docs/release notes/4.1.0/695.performance.md
new file mode 100644
index 000000000..87c019168
--- /dev/null
+++ b/docs/release notes/4.1.0/695.performance.md
@@ -0,0 +1,98 @@
+### Fully Asynchronous Parallel Graph Index Writes
+
+**Description**
+`OnDiskParallelGraphIndexWriter` (introduced in #608) parallelizes serialization of
+Level-0 (L0) node records to disk using Java's `AsynchronousFileChannel`. Although it
+already used the async channel API, each write was previously submitted and immediately
+blocked on via `Future.get()` before the next one began — writing a node's ordinal, then
+each feature, then its neighbor list as a sequence of blocking round-trips rather than a
+truly asynchronous pipeline. This PR removes that bottleneck so parallel writes take full
+advantage of async I/O:
+
+- **Fast path** (no pre-written features — the common case): each task now packs its
+ entire ordinal range into a single contiguous `ByteBuffer` and issues one
+ `channel.write()` call for the whole range, instead of one blocking write per node field.
+- **Legacy path** (some features already placed on disk via `writeFeaturesInline()`): each
+ task identifies the contiguous byte spans it still owns, submits every write for its
+ entire range up front, and only then waits on the collected futures — letting the OS
+ schedule the full I/O workload instead of alternating write-then-wait per span.
+- The old per-thread scratch `ByteBuffer`, sized to a single record, forced writes to
+ serialize at the buffer level regardless of channel concurrency. It has been removed;
+ each task now allocates its own range-sized (fast path) or per-region (legacy path)
+ buffer instead.
+
+**Also in this PR:** a `parallelGraphConstruction` boolean was added to
+`ConstructionParameters`, letting BenchYAML / AutoBenchYAML test configs opt into
+`OnDiskParallelGraphIndexWriter` for index construction instead of the default serial
+`OnDiskGraphIndexWriter`, without any code changes.
+
+**Performance**
+Example run writing with NVQ + FUSED_ADC features. Before this change, parallel writes
+were ~4x-8x faster than sequential:
+```
+Sequential write: 6074.18 ms
+Parallel write: 1373.73 ms
+Speedup: 4.42x
+```
+After this change, the same comparison shows ~24x-32x speedups:
+```
+Sequential write: 20147.29 ms
+Parallel write: 627.52 ms
+Speedup: 32.11x
+```
+
+**How to Enable**
+
+*Programmatic API* — no change; `OnDiskParallelGraphIndexWriter.Builder` (available since
+#608) is used the same way as before. The throughput improvement is automatic:
+
+```java
+// Serial (existing) path
+var writer = new OnDiskGraphIndexWriter.Builder(graph, outputPath)
+ .withMapper(ordinalMapper)
+ .with(new InlineVectors(dimension))
+ .build();
+
+// Parallel path
+var writer = new OnDiskParallelGraphIndexWriter.Builder(graph, outputPath)
+ .withMapper(ordinalMapper)
+ .with(new InlineVectors(dimension))
+ // Optional tuning:
+ .withParallelWorkerThreads(0) // 0 = use available processors
+ .withParallelDirectBuffers(false) // true = off-heap ByteBuffers
+ .build();
+
+writer.write(featureStateSuppliers);
+writer.close();
+```
+
+To supply a shared, externally-managed executor (e.g. to bound total thread count across
+concurrent builds):
+
+```java
+ExecutorService ioPool = Executors.newFixedThreadPool(16);
+var writer = new OnDiskParallelGraphIndexWriter.Builder(graph, outputPath)
+ .withMapper(ordinalMapper)
+ .with(new InlineVectors(dimension))
+ .withExecutor(ioPool) // caller is responsible for shutdown
+ .build();
+```
+
+*BenchYAML / AutoBenchYAML* — new in this PR: add the following field under
+`construction` in any index-parameter YAML config file:
+
+```yaml
+construction:
+ parallelGraphConstruction: Yes # default: No
+```
+
+When set to `Yes`, `Grid` uses `OnDiskParallelGraphIndexWriter` for the on-disk build path.
+The field is optional and defaults to `No` (serial writes) if omitted, so existing config
+files require no changes.
+
+**Notes**
+- The writer produces output in the same on-disk format as `OnDiskGraphIndexWriter`; indexes
+ written with either class are interchangeable and loaded with `OnDiskGraphIndex.load()`.
+ This PR changes only the internal write scheduling, not the on-disk format.
+- Write tasks perform blocking file I/O. For best performance supply an I/O-sized thread pool
+ (thread count ≥ logical cores) rather than a compute-sized pool when using `withExecutor()`.
diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/graph/disk/NodeRecordTask.java b/jvector-base/src/main/java/io/github/jbellis/jvector/graph/disk/NodeRecordTask.java
index 7e756dab9..f58a9deb5 100644
--- a/jvector-base/src/main/java/io/github/jbellis/jvector/graph/disk/NodeRecordTask.java
+++ b/jvector-base/src/main/java/io/github/jbellis/jvector/graph/disk/NodeRecordTask.java
@@ -24,35 +24,62 @@
import java.io.IOException;
import java.nio.ByteBuffer;
import java.nio.channels.AsynchronousFileChannel;
+import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.concurrent.Callable;
-import java.util.concurrent.ExecutionException;
+import java.util.concurrent.Future;
import java.util.function.IntFunction;
/**
- * A task that writes L0 records for a range of nodes directly to disk using synchronous position-based writes.
+ * A task that writes L0 records for a range of nodes to disk via an AsynchronousFileChannel.
*
- * This task is designed to be executed in a thread pool, with each worker thread
- * owning its own ImmutableGraphIndex.View for thread-safe neighbor iteration.
- * Each task processes a contiguous range of ordinals to reduce task creation overhead.
+ * Each task processes a contiguous range of ordinals. Two execution paths exist:
*
- * This writes directly to the AsynchronousFileChannel using position-based writes with writeFully
- * to ensure all bytes are written before returning. This eliminates race conditions where the OS
- * buffer cache hasn't flushed data before subsequent reads occur.
+ * Fast path (no pre-written features): all records in the range are built into one
+ * contiguous {@link ByteBuffer} and written with a single {@code channel.write()} call.
+ *
+ * Legacy path (pre-written features present): some feature regions in each node record
+ * were written to disk ahead of time via {@code writeFeaturesInline()}. Those byte ranges must
+ * not be overwritten. {@link #callLegacy()} builds one growing in-memory buffer for the whole
+ * task range, but skips writing anything into it for a pre-written (gap) feature — the buffer
+ * ends up holding only owned bytes, packed contiguously. A run of owned bytes is sliced off and
+ * flushed as a single non-blocking write only when a gap is reached (or at the end of the task),
+ * so a run commonly spans many nodes rather than being flushed per-node: the seam
+ * between one node's trailing neighbor section and the next node's leading ordinal is never
+ * itself a gap, so runs only break where a {@link FeatureId} is actually pre-written, regardless
+ * of node boundaries. All writes for the entire task are submitted before any
+ * {@code Future.get()} call, so the OS sees the full I/O workload and can schedule it efficiently.
+ *
+ * Understanding {@code hasPrewrittenFeatures}: this flag is derived from the
+ * {@code featureStateSuppliers} map passed to {@code write()}. It does not involve
+ * any read-before-write. The mechanism is purely contractual: a client that calls
+ * {@code writeFeaturesInline(ordinal, stateMap)} before {@code write(featureStateSuppliers)}
+ * simply omits those {@link FeatureId}s from the suppliers map.
+ * {@code featureStateSuppliers.get(featureId) == null} is the signal "those bytes are already
+ * on disk — do not touch them." The flag is computed once at construction time so the
+ * per-node hot path pays no overhead checking it.
+ *
+ * FUTURE IMPROVEMENT: when {@code writeFeaturesInline} support is removed, {@code hasPrewrittenFeatures}
+ * will always be {@code false}, the legacy path ({@link #callLegacy()}) and all related helpers
+ * can be deleted, and this class becomes the clean single-path fast path only.
*/
class NodeRecordTask implements Callable {
- private final int startOrdinal; // Inclusive
- private final int endOrdinal; // Exclusive
+ private final int startOrdinal;
+ private final int endOrdinal;
private final OrdinalMapper ordinalMapper;
private final ImmutableGraphIndex graph;
private final ImmutableGraphIndex.View view;
private final List inlineFeatures;
private final Map> featureStateSuppliers;
private final int recordSize;
- private final long baseOffset; // Base file offset for L0 (offsets calculated per-ordinal)
+ private final long baseOffset;
private final AsynchronousFileChannel channel;
- private final ByteBuffer buffer; // Thread-local buffer for building record components
+ private final boolean useDirectBuffers;
+
+ // FUTURE IMPROVEMENT: when writeFeaturesInline is removed this flag is always false,
+ // the callLegacy() branch disappears, and this field can be deleted entirely.
+ private final boolean hasPrewrittenFeatures;
NodeRecordTask(int startOrdinal,
int endOrdinal,
@@ -64,7 +91,7 @@ class NodeRecordTask implements Callable {
int recordSize,
long baseOffset,
AsynchronousFileChannel channel,
- ByteBuffer buffer) {
+ boolean useDirectBuffers) {
this.startOrdinal = startOrdinal;
this.endOrdinal = endOrdinal;
this.ordinalMapper = ordinalMapper;
@@ -75,128 +102,299 @@ class NodeRecordTask implements Callable {
this.recordSize = recordSize;
this.baseOffset = baseOffset;
this.channel = channel;
- this.buffer = buffer;
+ this.useDirectBuffers = useDirectBuffers;
+ // Null supplier for any inline feature means the caller omitted it from the write()
+ // suppliers map, signalling that writeFeaturesInline() already placed that data on disk.
+ // FUTURE IMPROVEMENT: remove this field once writeFeaturesInline support is dropped.
+ this.hasPrewrittenFeatures = inlineFeatures.stream()
+ .anyMatch(f -> featureStateSuppliers.get(f.id()) == null);
}
- /**
- * Writes a buffer fully to the channel at the specified position.
- * Ensures all bytes are written by looping until the buffer is empty.
- * This is critical for correctness as AsynchronousFileChannel.write() may not write all bytes in one call.
- *
- * @param channel the channel to write to
- * @param buffer the buffer to write (will be fully consumed)
- * @param position the file position to write at
- * @throws IOException if an I/O error occurs
- * @throws ExecutionException if the write operation fails
- * @throws InterruptedException if interrupted while waiting for write completion
- */
- private static void writeFully(AsynchronousFileChannel channel, ByteBuffer buffer, long position)
- throws IOException, ExecutionException, InterruptedException {
- long currentPosition = position;
- while (buffer.hasRemaining()) {
- int written = channel.write(buffer, currentPosition).get();
- if (written < 0) {
- throw new IOException("Channel closed while writing");
- }
- currentPosition += written;
+ @Override
+ public Void call() throws Exception {
+ // FUTURE IMPROVEMENT: once writeFeaturesInline is removed this dispatch goes away —
+ // callBatched() becomes the only execution path.
+ if (hasPrewrittenFeatures) {
+ callLegacy();
+ } else {
+ callBatched();
}
+ return null;
}
- @Override
- public Void call() throws Exception {
- // Reuse writer and buffer across all ordinals in this range
- var writer = new ByteBufferIndexWriter(buffer);
+ // -------------------------------------------------------------------------
+ // Fast path: one contiguous buffer for the entire ordinal range, one write.
+ // -------------------------------------------------------------------------
+
+ private void callBatched() throws Exception {
+ int rangeSize = endOrdinal - startOrdinal;
+ ByteBuffer rangeBuffer = useDirectBuffers
+ ? ByteBuffer.allocateDirect(rangeSize * recordSize)
+ : ByteBuffer.allocate(rangeSize * recordSize);
+ rangeBuffer.order(java.nio.ByteOrder.BIG_ENDIAN);
+
+ // ByteBufferIndexWriter clears the buffer on construction; since it was just
+ // allocated that is a no-op, but it sets initialPosition = 0 as required.
+ var writer = new ByteBufferIndexWriter(rangeBuffer);
for (int newOrdinal = startOrdinal; newOrdinal < endOrdinal; newOrdinal++) {
- var originalOrdinal = ordinalMapper.newToOld(newOrdinal);
- long recordOffset = baseOffset + (long) newOrdinal * recordSize;
- long currentPosition = recordOffset;
+ buildFullRecord(writer, newOrdinal);
+ }
- // Reset buffer for this ordinal
- writer.reset();
+ // One channel.write() for the entire task range — one syscall, one OS I/O request
+ // in the common case. writeAllFully() guarantees the buffer is fully drained even
+ // if the OS reports a short write (e.g. disk full).
+ rangeBuffer.flip();
+ writeAllFully(List.of(new PendingWrite(rangeBuffer, baseOffset + (long) startOrdinal * recordSize)));
+ }
- // Write node ordinal
- writer.writeInt(newOrdinal);
- ByteBuffer ordinalData = writer.cloneBuffer();
- writeFully(channel, ordinalData, currentPosition);
- currentPosition += Integer.BYTES;
+ // -------------------------------------------------------------------------
+ // Legacy path: handle pre-written feature regions.
+ //
+ // Pre-written bytes must not be overwritten. A single growing buffer is built for the
+ // whole task range, same as callBatched(), except nothing is written into it for a
+ // pre-written (gap) feature -- the buffer ends up holding only owned bytes, packed
+ // contiguously. A run of owned bytes is sliced off the buffer and queued as a single
+ // non-blocking write only when a gap is reached (or at the end of the task), so runs
+ // commonly span many nodes rather than being flushed per-node: the seam between one
+ // node's trailing neighbor section and the next node's leading ordinal is never itself
+ // a gap, so runs only break where a FeatureId is actually pre-written. ALL writes for
+ // the entire task are submitted before any Future.get() call, letting the OS pipeline
+ // them.
+ //
+ // FUTURE IMPROVEMENT: delete this method entirely once writeFeaturesInline support is
+ // removed. The fast path handles everything.
+ // -------------------------------------------------------------------------
+
+ private void callLegacy() throws Exception {
+ int rangeSize = endOrdinal - startOrdinal;
+ ByteBuffer rangeBuffer = useDirectBuffers
+ ? ByteBuffer.allocateDirect(rangeSize * recordSize)
+ : ByteBuffer.allocate(rangeSize * recordSize);
+ rangeBuffer.order(java.nio.ByteOrder.BIG_ENDIAN);
+ var writer = new ByteBufferIndexWriter(rangeBuffer);
+
+ List pending = new ArrayList<>();
+ // Buffer-relative start of the run currently being accumulated, and the file
+ // position that buffer offset corresponds to. Advanced past both the flushed run
+ // and the skipped gap every time a gap is hit.
+ int runStart = 0;
+ long runFilePosition = baseOffset + (long) startOrdinal * recordSize;
+
+ for (int newOrdinal = startOrdinal; newOrdinal < endOrdinal; newOrdinal++) {
+ var originalOrdinal = ordinalMapper.newToOld(newOrdinal);
- // Handle OMITTED nodes (holes in ordinal space)
if (originalOrdinal == OrdinalMapper.OMITTED) {
- // Write placeholder: zeros for features and empty neighbor list
- writer.reset();
+ // OMITTED nodes are holes in the ordinal space. writeFeaturesInline() is
+ // never called for them, so nothing here is a gap: the whole record extends
+ // the current run with no flush needed.
+ writer.writeInt(newOrdinal);
for (var feature : inlineFeatures) {
- // Write zeros for missing features
- for (int i = 0; i < feature.featureSize(); i++) {
- writer.writeByte(0);
- }
+ for (int i = 0; i < feature.featureSize(); i++) writer.writeByte(0);
}
- ByteBuffer featureData = writer.cloneBuffer();
- writeFully(channel, featureData, currentPosition);
- currentPosition += featureData.remaining();
-
- // Write empty neighbor list
- writer.reset();
writer.writeInt(0); // neighbor count
- for (int n = 0; n < graph.getDegree(0); n++) {
- writer.writeInt(-1); // padding
- }
- ByteBuffer neighborData = writer.cloneBuffer();
- writeFully(channel, neighborData, currentPosition);
- } else {
- // Validate node exists
- if (!graph.containsNode(originalOrdinal)) {
- throw new IllegalStateException(
- String.format("Ordinal mapper mapped new ordinal %s to non-existing node %s",
- newOrdinal, originalOrdinal));
- }
+ for (int n = 0; n < graph.getDegree(0); n++) writer.writeInt(-1);
+ continue;
+ }
- // Write inline features (skip if supplier is null - feature was pre-written)
- for (var feature : inlineFeatures) {
- var supplier = featureStateSuppliers.get(feature.id());
- if (supplier != null) {
- // Feature not pre-written, write it now
- writer.reset();
- feature.writeInline(writer, supplier.apply(originalOrdinal));
- ByteBuffer featureData = writer.cloneBuffer();
- writeFully(channel, featureData, currentPosition);
- }
- // Skip to next feature position (whether we wrote it or not)
- currentPosition += feature.featureSize();
- }
+ if (!graph.containsNode(originalOrdinal)) {
+ throw new IllegalStateException(String.format(
+ "Ordinal mapper mapped new ordinal %d to non-existing node %d",
+ newOrdinal, originalOrdinal));
+ }
- // Write neighbors
- writer.reset();
- var neighbors = view.getNeighborsIterator(0, originalOrdinal);
- if (neighbors.size() > graph.getDegree(0)) {
- throw new IllegalStateException(
- String.format("Node %d has more neighbors %d than the graph's max degree %d -- run Builder.cleanup()!",
- originalOrdinal, neighbors.size(), graph.getDegree(0)));
- }
+ // Ordinal: always owned.
+ writer.writeInt(newOrdinal);
- writer.writeInt(neighbors.size());
- int n = 0;
- for (; n < neighbors.size(); n++) {
- var newNeighborOrdinal = ordinalMapper.oldToNew(neighbors.nextInt());
- if (newNeighborOrdinal < 0 || newNeighborOrdinal > ordinalMapper.maxOrdinal()) {
- throw new IllegalStateException(
- String.format("Neighbor ordinal out of bounds: %d/%d",
- newNeighborOrdinal, ordinalMapper.maxOrdinal()));
+ for (var feature : inlineFeatures) {
+ var supplier = featureStateSuppliers.get(feature.id());
+ if (supplier != null) {
+ // Owned: extend the current run directly.
+ feature.writeInline(writer, supplier.apply(originalOrdinal));
+ } else {
+ // Pre-written gap: flush the run accumulated so far (which may span
+ // multiple earlier nodes), then skip the gap in the file without
+ // writing anything into rangeBuffer for it.
+ int runEnd = rangeBuffer.position();
+ if (runEnd > runStart) {
+ pending.add(new PendingWrite(sliceRange(rangeBuffer, runStart, runEnd), runFilePosition));
}
- writer.writeInt(newNeighborOrdinal);
+ runFilePosition += (runEnd - runStart) + feature.featureSize();
+ runStart = runEnd;
}
+ }
- // Pad to max degree
- for (; n < graph.getDegree(0); n++) {
- writer.writeInt(-1);
+ // Neighbor section: always owned — extends the current run.
+ var neighbors = view.getNeighborsIterator(0, originalOrdinal);
+ if (neighbors.size() > graph.getDegree(0)) {
+ throw new IllegalStateException(String.format(
+ "Node %d has more neighbors %d than max degree %d -- run Builder.cleanup()!",
+ originalOrdinal, neighbors.size(), graph.getDegree(0)));
+ }
+ writer.writeInt(neighbors.size());
+ int n = 0;
+ for (; n < neighbors.size(); n++) {
+ int newNeighbor = ordinalMapper.oldToNew(neighbors.nextInt());
+ if (newNeighbor < 0 || newNeighbor > ordinalMapper.maxOrdinal()) {
+ throw new IllegalStateException(String.format(
+ "Neighbor ordinal out of bounds: %d/%d",
+ newNeighbor, ordinalMapper.maxOrdinal()));
}
+ writer.writeInt(newNeighbor);
+ }
+ for (; n < graph.getDegree(0); n++) writer.writeInt(-1);
+ }
+
+ // Final trailing run.
+ int runEnd = rangeBuffer.position();
+ if (runEnd > runStart) {
+ pending.add(new PendingWrite(sliceRange(rangeBuffer, runStart, runEnd), runFilePosition));
+ }
- ByteBuffer neighborData = writer.cloneBuffer();
- writeFully(channel, neighborData, currentPosition);
+ writeAllFully(pending);
+ }
+
+ /**
+ * Returns an independent, ready-to-read view over {@code buf}'s backing storage covering
+ * {@code [start, end)}. The view shares memory with {@code buf} but has its own position
+ * and limit, so later writes into {@code buf} at other offsets don't affect it — safe to
+ * hand off to a concurrent {@code channel.write()} while {@code buf} keeps being appended to.
+ */
+ private static ByteBuffer sliceRange(ByteBuffer buf, int start, int end) {
+ ByteBuffer dup = buf.duplicate();
+ dup.limit(end);
+ dup.position(start);
+ return dup.slice();
+ }
+
+ // -------------------------------------------------------------------------
+ // Shared helpers
+ // -------------------------------------------------------------------------
+
+ /**
+ * Writes a complete node record (ordinal + all features + neighbors) sequentially
+ * into {@code writer}. Called only from {@link #callBatched()}, where all feature
+ * suppliers are guaranteed non-null.
+ */
+ private void buildFullRecord(ByteBufferIndexWriter writer, int newOrdinal) throws Exception {
+ var originalOrdinal = ordinalMapper.newToOld(newOrdinal);
+ writer.writeInt(newOrdinal);
+
+ if (originalOrdinal == OrdinalMapper.OMITTED) {
+ for (var feature : inlineFeatures) {
+ for (int i = 0; i < feature.featureSize(); i++) writer.writeByte(0);
}
+ writer.writeInt(0);
+ for (int n = 0; n < graph.getDegree(0); n++) writer.writeInt(-1);
+ } else {
+ if (!graph.containsNode(originalOrdinal)) {
+ throw new IllegalStateException(String.format(
+ "Ordinal mapper mapped new ordinal %d to non-existing node %d",
+ newOrdinal, originalOrdinal));
+ }
+ for (var feature : inlineFeatures) {
+ feature.writeInline(writer, featureStateSuppliers.get(feature.id()).apply(originalOrdinal));
+ }
+ var neighbors = view.getNeighborsIterator(0, originalOrdinal);
+ if (neighbors.size() > graph.getDegree(0)) {
+ throw new IllegalStateException(String.format(
+ "Node %d has more neighbors %d than max degree %d -- run Builder.cleanup()!",
+ originalOrdinal, neighbors.size(), graph.getDegree(0)));
+ }
+ writer.writeInt(neighbors.size());
+ int n = 0;
+ for (; n < neighbors.size(); n++) {
+ int newNeighbor = ordinalMapper.oldToNew(neighbors.nextInt());
+ if (newNeighbor < 0 || newNeighbor > ordinalMapper.maxOrdinal()) {
+ throw new IllegalStateException(String.format(
+ "Neighbor ordinal out of bounds: %d/%d",
+ newNeighbor, ordinalMapper.maxOrdinal()));
+ }
+ writer.writeInt(newNeighbor);
+ }
+ for (; n < graph.getDegree(0); n++) writer.writeInt(-1);
}
+ }
- return null;
+ /** A not-yet-fully-written buffer destined for a fixed file offset. */
+ private static final class PendingWrite {
+ final ByteBuffer buffer;
+ final long position;
+
+ PendingWrite(ByteBuffer buffer, long position) {
+ this.buffer = buffer;
+ this.position = position;
+ }
}
-}
+ /**
+ * Caps how many writes this task submits to the channel before joining any of them.
+ *
+ * On platforms without a native async file-I/O backend wired into NIO2 (macOS and most
+ * POSIX systems get {@code sun.nio.ch.SimpleAsynchronousFileChannelImpl}; only Windows gets
+ * true IOCP), {@code AsynchronousFileChannel.write()} is emulated by handing the write to an
+ * executor that is observed to spin up a fresh native thread per outstanding call rather
+ * than reusing a small, bounded pool. {@link #callLegacy()}'s per-task write count can be
+ * O(nodes) — unlike {@link #callBatched()}'s O(1) — so submitting an entire task's writes
+ * before joining any of them can create thousands of simultaneously outstanding writes per
+ * task, times however many tasks are running concurrently. In practice this has been
+ * observed to exhaust the OS thread limit ({@code OutOfMemoryError: unable to create native
+ * thread}, {@code pthread_create failed (EAGAIN)}) on a large write. This bound keeps the
+ * number of writes any single task has outstanding at once small and constant, regardless
+ * of how many nodes the task covers.
+ */
+ private static final int MAX_IN_FLIGHT_WRITES = 32;
+
+ /**
+ * Submits {@code wave} to the channel in chunks of at most {@link #MAX_IN_FLIGHT_WRITES},
+ * joining each chunk before submitting the next. Within a chunk, every write is submitted
+ * before any is joined, preserving pipelining at a bounded scale; see
+ * {@link #MAX_IN_FLIGHT_WRITES} for why an unbounded submit-everything-first approach is
+ * unsafe on some platforms.
+ */
+ private void writeAllFully(List wave) throws Exception {
+ for (int chunkStart = 0; chunkStart < wave.size(); chunkStart += MAX_IN_FLIGHT_WRITES) {
+ int chunkEnd = Math.min(chunkStart + MAX_IN_FLIGHT_WRITES, wave.size());
+ writeChunkFully(wave.subList(chunkStart, chunkEnd));
+ }
+ }
+
+ /**
+ * Submits every write in {@code chunk} without blocking, then joins them all.
+ * {@code AsynchronousFileChannel.write()} is only guaranteed to write "up to" the
+ * buffer's remaining bytes in a single call (see
+ * {@link AsynchronousFileChannel#write(ByteBuffer, long)}); a short write is rare for a
+ * regular file but can happen (disk full, quota limits, an
+ * oversized single write). Any write that completes short is resubmitted for its
+ * remaining bytes — which the channel has already advanced the buffer's position past —
+ * as a follow-up chunk.
+ *
+ * In the common case (no short writes) this runs exactly one submit-all/join-all round,
+ * so the pipelining {@link #callLegacy()} and {@link #callBatched()} rely on is preserved;
+ * the retry loop only does extra work on the rare short-write path.
+ */
+ private void writeChunkFully(List chunk) throws Exception {
+ while (!chunk.isEmpty()) {
+ List> futures = new ArrayList<>(chunk.size());
+ for (var w : chunk) {
+ futures.add(channel.write(w.buffer, w.position));
+ }
+
+ List retry = new ArrayList<>();
+ for (int i = 0; i < chunk.size(); i++) {
+ int written = futures.get(i).get();
+ if (written < 0) {
+ throw new IOException("Channel closed during write");
+ }
+ var w = chunk.get(i);
+ if (w.buffer.hasRemaining()) {
+ if (written == 0) {
+ throw new IOException("Channel made no progress writing at position " + w.position);
+ }
+ retry.add(new PendingWrite(w.buffer, w.position + written));
+ }
+ }
+ chunk = retry;
+ }
+ }
+}
diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/graph/disk/ParallelGraphWriter.java b/jvector-base/src/main/java/io/github/jbellis/jvector/graph/disk/ParallelGraphWriter.java
index 3475a4660..bc6cd3d2c 100644
--- a/jvector-base/src/main/java/io/github/jbellis/jvector/graph/disk/ParallelGraphWriter.java
+++ b/jvector-base/src/main/java/io/github/jbellis/jvector/graph/disk/ParallelGraphWriter.java
@@ -22,7 +22,6 @@
import io.github.jbellis.jvector.graph.disk.feature.FeatureId;
import java.io.IOException;
-import java.nio.ByteBuffer;
import java.nio.channels.AsynchronousFileChannel;
import java.nio.file.Path;
import java.nio.file.StandardOpenOption;
@@ -67,11 +66,14 @@ class ParallelGraphWriter implements AutoCloseable {
private final ExecutorService executor;
private final boolean ownsExecutor;
private final ThreadLocal viewPerThread;
- private final ThreadLocal bufferPerThread;
private final CopyOnWriteArrayList allViews = new CopyOnWriteArrayList<>();
private final int recordSize;
private final Path filePath;
private final int taskMultiplier;
+ // Passed through to NodeRecordTask so each task allocates the right buffer type.
+ // FUTURE IMPROVEMENT: when the legacy path is removed, each task allocates exactly
+ // one range-sized buffer, making the direct/heap distinction more impactful and cleaner.
+ private final boolean useDirectBuffers;
private static final AtomicInteger threadCounter = new AtomicInteger(0);
/**
@@ -143,6 +145,7 @@ public ParallelGraphWriter(RandomAccessWriter writer,
this.graph = graph;
this.filePath = Objects.requireNonNull(filePath);
this.taskMultiplier = config.taskMultiplier;
+ this.useDirectBuffers = config.useDirectBuffers;
if (externalExecutor != null) {
this.executor = externalExecutor;
this.ownsExecutor = false;
@@ -163,23 +166,17 @@ public ParallelGraphWriter(RandomAccessWriter writer,
+ Integer.BYTES // neighbor count
+ graph.getDegree(0) * Integer.BYTES; // neighbors + padding
- // Thread-local views for safe neighbor iteration
- // CopyOnWriteArrayList handles concurrent additions safely
+ // Thread-local views for safe neighbor iteration.
+ // CopyOnWriteArrayList handles concurrent additions safely.
this.viewPerThread = ThreadLocal.withInitial(() -> {
var view = graph.getView();
allViews.add(view);
return view;
});
-
- // Thread-local buffers to avoid allocation overhead
- // Use BIG_ENDIAN to match Java DataOutput specification
- final int bufferSize = recordSize;
- final boolean useDirect = config.useDirectBuffers;
- this.bufferPerThread = ThreadLocal.withInitial(() -> {
- ByteBuffer buffer = useDirect ? ByteBuffer.allocateDirect(bufferSize) : ByteBuffer.allocate(bufferSize);
- buffer.order(java.nio.ByteOrder.BIG_ENDIAN);
- return buffer;
- });
+ // FUTURE IMPROVEMENT: the old per-thread scratch buffer (bufferPerThread) is removed.
+ // Each task now allocates its own range-sized buffer in the fast path, or per-region
+ // buffers in the legacy path. The thread-local was sized to a single record and forced
+ // sub-record writes; the new approach eliminates that bottleneck entirely.
}
/**
@@ -239,23 +236,19 @@ public void writeL0Records(OrdinalMapper ordinalMapper,
final int end = endOrdinal;
Future future = executor.submit(() -> {
- var view = viewPerThread.get();
- var buffer = bufferPerThread.get();
-
var task = new NodeRecordTask(
- start, // Start of range (inclusive)
- end, // End of range (exclusive)
+ start, // range start (inclusive)
+ end, // range end (exclusive)
ordinalMapper,
graph,
- view,
+ viewPerThread.get(),
inlineFeatures,
featureStateSuppliers,
recordSize,
- baseOffset, // Base offset (task calculates per-ordinal offsets)
- channel, // Async file channel for position-based writes
- buffer // Thread-local buffer
+ baseOffset,
+ channel,
+ useDirectBuffers // each task allocates its own buffer(s)
);
-
return task.call();
});
diff --git a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/AutoBenchYAML.java b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/AutoBenchYAML.java
index 24c39ae47..ef1d36b89 100644
--- a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/AutoBenchYAML.java
+++ b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/AutoBenchYAML.java
@@ -148,15 +148,16 @@ public static void main(String[] args) throws IOException {
List datasetResults = Grid.runAllAndCollectResults(ds,
config.construction.useSavedIndexIfExists,
- config.construction.outDegree,
+ config.construction.isParallelGraphConstruction(),
+ config.construction.outDegree,
config.construction.efConstruction,
- config.construction.neighborOverflow,
+ config.construction.neighborOverflow,
config.construction.addHierarchy,
config.construction.refineFinalGraph,
- config.construction.getFeatureSets(),
+ config.construction.getFeatureSets(),
config.construction.getCompressorParameters(),
- config.search.getCompressorParameters(),
- config.search.topKOverquery,
+ config.search.getCompressorParameters(),
+ config.search.topKOverquery,
config.search.useSearchPruning);
results.addAll(datasetResults);
diff --git a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/BenchYAML.java b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/BenchYAML.java
index e066a34dc..1ae5bb439 100644
--- a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/BenchYAML.java
+++ b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/BenchYAML.java
@@ -126,6 +126,7 @@ public static void main(String[] args) throws IOException {
Grid.runAll(ds,
config.construction.useSavedIndexIfExists,
+ config.construction.isParallelGraphConstruction(),
config.construction.outDegree,
config.construction.efConstruction,
config.construction.neighborOverflow,
diff --git a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/Grid.java b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/Grid.java
index 8f45df2a0..b9287fffe 100644
--- a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/Grid.java
+++ b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/Grid.java
@@ -97,6 +97,7 @@ public static Double getIndexBuildTimeSeconds(String datasetName) {
static void runAll(DataSet ds,
boolean enableIndexCache,
+ boolean useParallelConstruction,
List mGrid,
List efConstructionGrid,
List neighborOverflowGrid,
@@ -129,7 +130,7 @@ static void runAll(DataSet ds,
for (int efC : efConstructionGrid) {
for (var bc : buildCompressors) {
runOneGraph(cache, featureSets, M, efC, neighborOverflow, addHierarchy, refineFinalGraph,
- bc, compressionGrid, topKGrid, usePruningGrid, artifacts, ds, workDir);
+ bc, compressionGrid, topKGrid, usePruningGrid, artifacts, ds, workDir, useParallelConstruction);
}
}
}
@@ -174,6 +175,7 @@ static void runAll(DataSet ds,
{
runAll(ds,
enableIndexCache,
+ false, // legacy callers always use serial construction
mGrid,
efConstructionGrid,
neighborOverflowGrid,
@@ -225,7 +227,8 @@ static void runOneGraph(OnDiskGraphIndexCache cache,
List usePruningGrid,
RunArtifacts artifacts,
DataSet ds,
- Path workDirectory) throws IOException
+ Path workDirectory,
+ boolean useParallelConstruction) throws IOException
{
// Prepare to collect index construction metrics for reporting....
var constructionMetrics = new ConstructionMetrics();
@@ -288,7 +291,7 @@ static void runOneGraph(OnDiskGraphIndexCache cache,
// At least one index needs to be built (b/c not in cache or cache is disabled)
// We pass the handles map so buildOnDisk knows exactly where to write
var newIndexes = buildOnDisk(missing, M, efConstruction, neighborOverflow, addHierarchy, refineFinalGraph,
- ds, outputDir, buildCompressorObj, handles, constructionMetrics);
+ ds, outputDir, buildCompressorObj, handles, constructionMetrics, useParallelConstruction);
indexes.putAll(newIndexes);
}
}
@@ -374,7 +377,8 @@ private static Map, ImmutableGraphIndex> buildOnDisk(List exten
Path outputDir,
VectorCompressor> buildCompressor,
Map, OnDiskGraphIndexCache.WriteHandle> handles,
- ConstructionMetrics constructionMetrics) throws IOException
+ ConstructionMetrics constructionMetrics,
+ boolean useParallelConstruction) throws IOException
{
Files.createDirectories(outputDir);
@@ -388,9 +392,9 @@ private static Map, ImmutableGraphIndex> buildOnDisk(List exten
GraphIndexBuilder builder = new GraphIndexBuilder(bsp, floatVectors.dimension(), M, efConstruction, neighborOverflow, 1.2f, addHierarchy, refineFinalGraph);
// use the inline vectors index as the score provider for graph construction
- Map, OnDiskGraphIndexWriter> writers = new HashMap<>();
+ Map, RandomAccessOnDiskGraphIndexWriter> writers = new HashMap<>();
Map, Map>> suppliers = new HashMap<>();
- OnDiskGraphIndexWriter scoringWriter = null;
+ RandomAccessOnDiskGraphIndexWriter scoringWriter = null;
int n = 0;
for (var features : featureSets) {
// if we are using index caching, use cache names instead of tmp names for index files....
@@ -400,8 +404,8 @@ private static Map, ImmutableGraphIndex> buildOnDisk(List exten
} else {
graphPath = outputDir.resolve("graph" + n++);
}
- var bws = builderWithSuppliers(features, builder.getGraph(), graphPath, floatVectors, pq.getCompressor(), constructionMetrics);
- var writer = bws.builder.build();
+ var bws = builderWithSuppliers(features, builder.getGraph(), graphPath, floatVectors, pq.getCompressor(), constructionMetrics, useParallelConstruction);
+ var writer = bws.writer;
writers.put(features, writer);
suppliers.put(features, bws.suppliers);
if (features.contains(FeatureId.INLINE_VECTORS) || features.contains(FeatureId.NVQ_VECTORS)) {
@@ -483,14 +487,35 @@ private static BuilderWithSuppliers builderWithSuppliers(Set features
Path outPath,
RandomAccessVectorValues floatVectors,
ProductQuantization pq,
- ConstructionMetrics constructionMetrics)
- throws FileNotFoundException
+ ConstructionMetrics constructionMetrics,
+ boolean useParallelConstruction)
+ throws IOException
{
var identityMapper = new OrdinalMapper.IdentityMapper(floatVectors.size() - 1);
- var builder = new OnDiskGraphIndexWriter.Builder(onHeapGraph, outPath);
- builder.withMapper(identityMapper);
-
Map> suppliers = new EnumMap<>(FeatureId.class);
+
+ RandomAccessOnDiskGraphIndexWriter writer;
+ if (useParallelConstruction) {
+ var builder = new OnDiskParallelGraphIndexWriter.Builder(onHeapGraph, outPath);
+ builder.withMapper(identityMapper);
+ addFeaturesToBuilder(builder, features, onHeapGraph, floatVectors, pq, constructionMetrics, suppliers);
+ writer = builder.build();
+ } else {
+ var builder = new OnDiskGraphIndexWriter.Builder(onHeapGraph, outPath);
+ builder.withMapper(identityMapper);
+ addFeaturesToBuilder(builder, features, onHeapGraph, floatVectors, pq, constructionMetrics, suppliers);
+ writer = builder.build();
+ }
+ return new BuilderWithSuppliers(writer, suppliers);
+ }
+
+ private static void addFeaturesToBuilder(AbstractGraphIndexWriter.Builder, ?> builder,
+ Set features,
+ ImmutableGraphIndex onHeapGraph,
+ RandomAccessVectorValues floatVectors,
+ ProductQuantization pq,
+ ConstructionMetrics constructionMetrics,
+ Map> suppliers) {
for (var featureId : features) {
switch (featureId) {
case INLINE_VECTORS:
@@ -513,10 +538,8 @@ private static BuilderWithSuppliers builderWithSuppliers(Set features
builder.with(new NVQ(nvq));
suppliers.put(FeatureId.NVQ_VECTORS, ordinal -> new NVQ.State(nvq.encode(floatVectors.getVector(ordinal))));
break;
-
}
}
- return new BuilderWithSuppliers(builder, suppliers);
}
public static void setDiagnosticLevel(int diagLevel) {
@@ -539,11 +562,11 @@ private static DiagnosticLevel getDiagnosticLevel() {
}
private static class BuilderWithSuppliers {
- public final OnDiskGraphIndexWriter.Builder builder;
+ public final RandomAccessOnDiskGraphIndexWriter writer;
public final Map> suppliers;
- public BuilderWithSuppliers(OnDiskGraphIndexWriter.Builder builder, Map> suppliers) {
- this.builder = builder;
+ public BuilderWithSuppliers(RandomAccessOnDiskGraphIndexWriter writer, Map> suppliers) {
+ this.writer = writer;
this.suppliers = suppliers;
}
}
@@ -594,8 +617,8 @@ private static Map, ImmutableGraphIndex> buildInMemory(List ext
continue;
}
var graphPath = testDirectory.resolve("graph" + n++);
- var bws = builderWithSuppliers(features, onHeapGraph, graphPath, floatVectors, null, null);
- try (var writer = bws.builder.build()) {
+ var bws = builderWithSuppliers(features, onHeapGraph, graphPath, floatVectors, null, null, false);
+ try (var writer = bws.writer) {
start = System.nanoTime();
writer.write(bws.suppliers);
System.out.format("Wrote %s in %.2fs%n", features, (System.nanoTime() - start) / 1_000_000_000.0);
@@ -812,6 +835,7 @@ private static Map ordered(Object... kv) {
public static List runAllAndCollectResults(
DataSet ds,
boolean enableIndexCache,
+ boolean useParallelConstruction,
List mGrid,
List efConstructionGrid,
List neighborOverflowGrid,
@@ -904,7 +928,7 @@ public static List runAllAndCollectResults(
// At least one index needs to be built (b/c not in cache or cache is disabled)
// We pass the handles map so buildOnDisk knows exactly where to write
var newIndexes = buildOnDisk(missing, m, ef, neighborOverflow, addHierarchy, refineFinalGraph,
- ds, outputDir, compressor, handles, null);
+ ds, outputDir, compressor, handles, null, useParallelConstruction);
indexes.putAll(newIndexes);
}
diff --git a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/HelloVectorWorld.java b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/HelloVectorWorld.java
index ea4752e4b..9bcf6843e 100644
--- a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/HelloVectorWorld.java
+++ b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/HelloVectorWorld.java
@@ -47,6 +47,7 @@ public static void main(String[] args) throws IOException {
// Run
Grid.runAll(ds,
config.construction.useSavedIndexIfExists,
+ config.construction.isParallelGraphConstruction(),
config.construction.outDegree,
config.construction.efConstruction,
config.construction.neighborOverflow,
diff --git a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/yaml/ConstructionParameters.java b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/yaml/ConstructionParameters.java
index 5177fdf4a..24b61f51c 100644
--- a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/yaml/ConstructionParameters.java
+++ b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/yaml/ConstructionParameters.java
@@ -32,6 +32,11 @@ public class ConstructionParameters extends CommonParameters {
public List reranking;
public List fusedGraph;
public Boolean useSavedIndexIfExists;
+ public Boolean parallelGraphConstruction;
+
+ public boolean isParallelGraphConstruction() {
+ return Boolean.TRUE.equals(parallelGraphConstruction);
+ }
public List> getFeatureSets() {
List> featureSets = null;
diff --git a/jvector-examples/src/main/java/io/github/jbellis/jvector/graph/disk/ParallelWriteExample.java b/jvector-examples/src/main/java/io/github/jbellis/jvector/graph/disk/ParallelWriteExample.java
index f3728234c..596bfe7a2 100644
--- a/jvector-examples/src/main/java/io/github/jbellis/jvector/graph/disk/ParallelWriteExample.java
+++ b/jvector-examples/src/main/java/io/github/jbellis/jvector/graph/disk/ParallelWriteExample.java
@@ -31,35 +31,78 @@
import io.github.jbellis.jvector.quantization.NVQuantization;
import io.github.jbellis.jvector.quantization.PQVectors;
import io.github.jbellis.jvector.quantization.ProductQuantization;
+import io.github.jbellis.jvector.vector.VectorSimilarityFunction;
import java.io.IOException;
+import java.io.UncheckedIOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Arrays;
import java.util.EnumMap;
import java.util.Map;
import java.util.function.IntFunction;
+import java.util.stream.IntStream;
import static io.github.jbellis.jvector.quantization.KMeansPlusPlusClusterer.UNWEIGHTED;
/**
- * Example demonstrating how to use parallel writes with OnDiskGraphIndexWriter.
+ * Example demonstrating how to use {@link OnDiskParallelGraphIndexWriter} and comparing its
+ * two internal write strategies against the sequential {@link OnDiskGraphIndexWriter}.
*
- * Usage patterns:
+ * {@code OnDiskParallelGraphIndexWriter} parallelizes serialization of Level-0 node records
+ * using {@link java.nio.channels.AsynchronousFileChannel}. Which of its two write paths runs
+ * (see {@link NodeRecordTask}) is determined entirely by whether every feature supplier is
+ * still present in the map passed to {@code write()}:
*
- * // Sequential (default):
+ * // Sequential (default) — single-threaded baseline:
* var writer = new OnDiskGraphIndexWriter.Builder(graph, outputPath)
* .with(inlineVectors)
* .build();
* writer.write(featureSuppliers);
*
- * // Parallel:
- * var writer = new OnDiskGraphIndexWriter.Builder(graph, outputPath)
+ * // Parallel, batched path — every feature supplier is passed directly to write();
+ * // each task packs its whole node range into one buffer and issues one channel write:
+ * var writer = new OnDiskParallelGraphIndexWriter.Builder(graph, outputPath)
* .with(inlineVectors)
- * .withParallelWrites(true) // Enable parallel writes
* .build();
* writer.write(featureSuppliers);
+ *
+ * // Parallel, legacy path — a plain (non-fused) feature is pre-written per node via
+ * // writeFeaturesInline() and then omitted from the map passed to write(); each task then
+ * // owns only the bytes not already on disk and issues one non-blocking write per owned span:
+ * var writer = new OnDiskParallelGraphIndexWriter.Builder(graph, outputPath)
+ * .with(plainInlineFeature)
+ * .build();
+ * for (int ordinal = 0; ordinal < graph.size(0); ordinal++) {
+ * writer.writeFeaturesInline(ordinal, Map.of(plainInlineFeature.id(), stateFor(ordinal)));
+ * }
+ * writer.write(Map.of());
*
+ *
+ * Note: a fused feature (one whose {@code Feature.isFused()} is {@code true}, e.g.
+ * {@code FusedPQ}) can never be fully pre-written this way. {@code write()} always requires a
+ * supplier for it regardless of what NodeRecordTask needs, because
+ * {@code AbstractGraphIndexWriter.writeSparseLevels()} writes the fused source feature for the
+ * hierarchy's higher layers directly — a separate code path from the L0 records NodeRecordTask
+ * owns, with no {@code writeFeaturesInline()} equivalent. Omitting a fused feature's supplier
+ * throws {@code IllegalStateException: Supplier for feature ... not found}. The benchmarks below
+ * demonstrate the correct pattern: pre-write the plain feature (NVQ_VECTORS) and keep supplying
+ * the fused one (FUSED_PQ) to {@code write()}.
+ *
+ * Two benchmark methods exercise this:
+ *
+ * - {@link #benchmarkPlainWrites} — sequential vs. parallel-batched, both writing a single,
+ * already-fully-built graph with every supplier passed directly to {@code write()}.
+ * - {@link #benchmarkInterleavedWrites} — sequential vs. parallel-legacy, both fed
+ * {@code writeFeaturesInline()} calls interleaved with graph construction itself, mirroring
+ * {@code Grid.buildOnDisk}'s {@code writers.forEach(...)} pattern: every writer receives its
+ * feature state as each node is added, regardless of which one finalizes sequentially vs.
+ * asynchronously. This is the realistic shape of the {@code writeFeaturesInline()} use case.
+ * Calling it in an isolated, single-threaded pass over an already-built graph instead is an
+ * anti-pattern that pays 10x+ the cost for no reason — see
+ * {@code BufferedRandomAccessWriter.seek()}, which flushes and reseeks the underlying file
+ * on every call.
+ *
*/
public class ParallelWriteExample {
@@ -223,12 +266,19 @@ private static void verifyIndicesIdentical(OnDiskGraphIndex index1, OnDiskGraphI
}
/**
- * Benchmark comparison between sequential and parallel writes using NVQ + FUSED_ADC features.
- * This matches the configuration used in Grid.buildOnDisk for realistic performance testing.
+ * Benchmark comparison on an already-fully-built graph using NVQ + FUSED_ADC features:
+ * sequential writes via {@link OnDiskGraphIndexWriter} vs. parallel writes via
+ * {@link OnDiskParallelGraphIndexWriter} taking its batched path (see {@link NodeRecordTask}):
+ * every feature supplier is passed straight to {@code write()}, so each task builds its
+ * whole node range into one buffer and issues a single {@code channel.write()} call.
+ *
+ * Both writers see the graph only after it's completely built — this isolates the cost of
+ * the write strategy itself from graph construction. Contrast with
+ * {@link #benchmarkInterleavedWrites}, which measures the legacy/pre-write pattern.
*/
- public static void benchmarkComparison(ImmutableGraphIndex graph,
+ public static void benchmarkPlainWrites(ImmutableGraphIndex graph,
Path sequentialPath,
- Path parallelPath,
+ Path parallelBatchedPath,
RandomAccessVectorValues floatVectors,
PQVectors pqVectors) throws IOException {
@@ -265,11 +315,13 @@ public static void benchmarkComparison(ImmutableGraphIndex graph,
view.close();
}
long sequentialTime = System.nanoTime() - sequentialStart;
- System.out.printf("Sequential write: %.2f ms%n", sequentialTime / 1_000_000.0);
+ System.out.printf("Sequential write: %.2f ms%n", sequentialTime / 1_000_000.0);
- // Parallel write
- long parallelStart = System.nanoTime();
- try (var writer = new OnDiskParallelGraphIndexWriter.Builder(graph, parallelPath)
+ // Parallel write — batched path: every feature supplier is passed directly to write(),
+ // so NodeRecordTask.callBatched() packs each task's whole node range into one buffer
+ // and issues a single channel.write() for it.
+ long parallelBatchedStart = System.nanoTime();
+ try (var writer = new OnDiskParallelGraphIndexWriter.Builder(graph, parallelBatchedPath)
.with(nvqFeature)
.with(fusedPQFeature)
.withMapper(identityMapper)
@@ -283,23 +335,124 @@ public static void benchmarkComparison(ImmutableGraphIndex graph,
writer.write(writeSuppliers);
view.close();
}
- long parallelTime = System.nanoTime() - parallelStart;
+ long parallelBatchedTime = System.nanoTime() - parallelBatchedStart;
+ System.out.printf("Parallel write (batched): %.2f ms%n", parallelBatchedTime / 1_000_000.0);
+ System.out.printf("Speedup (batched vs sequential): %.2fx%n", (double) sequentialTime / parallelBatchedTime);
+ }
+
+ /**
+ * Benchmark comparison of the legacy/pre-write pattern, built the way {@code Grid.buildOnDisk}
+ * actually uses it: {@code NVQ_VECTORS} is written via {@code writeFeaturesInline()}
+ * interleaved with graph construction itself, not as an isolated pass afterward.
+ *
+ * One {@link GraphIndexBuilder} is shared by both a sequential ({@link OnDiskGraphIndexWriter})
+ * and a parallel-legacy ({@link OnDiskParallelGraphIndexWriter}) writer. Both writers are
+ * registered before construction starts (mirroring {@code Grid.builderWithSuppliers} being
+ * called before the incremental-build loop), and both receive a {@code writeFeaturesInline()}
+ * call for every node inside the same parallel construction stream — mirroring
+ * {@code Grid.buildOnDisk}'s {@code writers.forEach(...)} pattern, which feeds every writer
+ * regardless of whether it will finalize sequentially or asynchronously. {@code FUSED_PQ}
+ * can't be pre-written (see the class Javadoc), so it stays in the map passed to the final
+ * {@code write()} call for both writers.
+ *
+ * Since both writers are fed from the same construction pass, the graph-build-plus-pre-write
+ * cost is a single, genuinely shared number — it's added once to each writer's total rather
+ * than measured or estimated separately. The two {@code write()} finalize calls are timed one
+ * after the other (not concurrently), so each writer's finalize cost is cleanly attributable
+ * instead of contending with the other for I/O.
+ */
+ public static void benchmarkInterleavedWrites(RandomAccessVectorValues floatVectors,
+ VectorSimilarityFunction similarityFunction,
+ PQVectors pqVectors,
+ int M,
+ int efConstruction,
+ float neighborOverflow,
+ float alpha,
+ boolean addHierarchy,
+ boolean refineFinalGraph,
+ Path sequentialPath,
+ Path parallelLegacyPath) throws IOException {
+
+ int nSubVectors = floatVectors.dimension() == 2 ? 1 : 2;
+ var nvq = NVQuantization.compute(floatVectors, nSubVectors);
+ var pq = pqVectors.getCompressor();
+ var identityMapper = new OrdinalMapper.IdentityMapper(floatVectors.size() - 1);
- System.out.printf("Parallel write: %.2f ms%n", parallelTime / 1_000_000.0);
- System.out.printf("Speedup: %.2fx%n", (double) sequentialTime / parallelTime);
+ var bsp = BuildScoreProvider.pqBuildScoreProvider(similarityFunction, pqVectors);
+ var builder = new GraphIndexBuilder(bsp, floatVectors.dimension(), M, efConstruction,
+ neighborOverflow, alpha, addHierarchy, refineFinalGraph);
+ var onHeapGraph = builder.getGraph();
+
+ try (var sequentialWriter = new OnDiskGraphIndexWriter.Builder(onHeapGraph, sequentialPath)
+ .with(new NVQ(nvq))
+ .with(new FusedPQ(onHeapGraph.maxDegree(), pq))
+ .withMapper(identityMapper)
+ .build();
+ var parallelLegacyWriter = new OnDiskParallelGraphIndexWriter.Builder(onHeapGraph, parallelLegacyPath)
+ .with(new NVQ(nvq))
+ .with(new FusedPQ(onHeapGraph.maxDegree(), pq))
+ .withMapper(identityMapper)
+ .build()) {
+
+ // Interleave NVQ pre-writes for BOTH writers into the same construction pass,
+ // mirroring Grid.buildOnDisk's writers.forEach(...) per-node loop.
+ System.out.println("Building graph with interleaved feature writes (Grid.buildOnDisk pattern)...");
+ long interleavedBuildStart = System.nanoTime();
+ var vv = floatVectors.threadLocalSupplier();
+ IntStream.range(0, floatVectors.size()).parallel().forEach(node -> {
+ Feature.State nvqState = new NVQ.State(nvq.encode(floatVectors.getVector(node)));
+ Map stateMap = Map.of(FeatureId.NVQ_VECTORS, nvqState);
+ try {
+ sequentialWriter.writeFeaturesInline(node, stateMap);
+ parallelLegacyWriter.writeFeaturesInline(node, stateMap);
+ } catch (IOException e) {
+ throw new UncheckedIOException(e);
+ }
+ builder.addGraphNode(node, vv.get().getVector(node));
+ });
+ builder.cleanup();
+ long interleavedBuildTime = System.nanoTime() - interleavedBuildStart;
+ System.out.printf("Graph build + interleaved pre-write: %.2f ms%n", interleavedBuildTime / 1_000_000.0);
+
+ // Finalize each writer separately (sequentially, not concurrently) so each one's
+ // write() time is cleanly attributable rather than contending with the other for I/O.
+ try (var view = onHeapGraph.getView()) {
+ long seqWriteStart = System.nanoTime();
+ sequentialWriter.write(Map.of(FeatureId.FUSED_PQ, ordinal -> new FusedPQ.State(view, pqVectors, ordinal)));
+ long seqWriteTime = System.nanoTime() - seqWriteStart;
+ System.out.printf("Sequential write() (finalize): %.2f ms%n", seqWriteTime / 1_000_000.0);
+
+ long legacyWriteStart = System.nanoTime();
+ parallelLegacyWriter.write(Map.of(FeatureId.FUSED_PQ, ordinal -> new FusedPQ.State(view, pqVectors, ordinal)));
+ long legacyWriteTime = System.nanoTime() - legacyWriteStart;
+ System.out.printf("Parallel (legacy) write() (finalize): %.2f ms%n", legacyWriteTime / 1_000_000.0);
+
+ long sequentialTotal = interleavedBuildTime + seqWriteTime;
+ long legacyTotal = interleavedBuildTime + legacyWriteTime;
+ System.out.printf("%nSequential total (build+prewrite+write): %.2f ms%n", sequentialTotal / 1_000_000.0);
+ System.out.printf("Parallel (legacy) total (build+prewrite+write): %.2f ms%n", legacyTotal / 1_000_000.0);
+ System.out.printf("Speedup (legacy finalize vs sequential finalize): %.2fx%n", (double) seqWriteTime / legacyWriteTime);
+ System.out.printf("Speedup (legacy total vs sequential total): %.2fx%n", (double) sequentialTotal / legacyTotal);
+ }
+ } finally {
+ builder.close();
+ }
}
/**
- * Main method to run a benchmark test of sequential vs parallel writes.
- *
+ * Main method to run two benchmark comparisons: sequential vs. parallel-batched writes on
+ * an already-built graph ({@link #benchmarkPlainWrites}), and sequential vs. parallel-legacy
+ * writes with feature data interleaved into graph construction, Grid.java-style
+ * ({@link #benchmarkInterleavedWrites}).
+ *
* Usage: java ParallelWriteExample [dataset-name]
- *
- * Example: java ParallelWriteExample cohere-english-v3-100k
- *
- * If no dataset is provided, uses "cohere-english-v3-100k" by default.
+ *
+ * Example: java ParallelWriteExample cohere-english-v3-1M
+ *
+ * If no dataset is provided, uses "cohere-english-v3-1M" by default.
*/
public static void main(String[] args) throws IOException {
- String datasetName = args.length > 0 ? args[0] : "cohere-english-v3-100k";
+ String datasetName = args.length > 0 ? args[0] : "cohere-english-v3-1M";
System.out.println("Loading dataset: " + datasetName);
DataSet ds = DataSets.loadDataSet(datasetName).orElseThrow(
@@ -312,7 +465,7 @@ public static void main(String[] args) throws IOException {
// Build PQ compression (matching Grid.buildOnDisk pattern)
System.out.println("Computing PQ compression...");
int pqM = floatVectors.dimension() / 8; // m = dimension / 8
- boolean centerData = ds.getSimilarityFunction() == io.github.jbellis.jvector.vector.VectorSimilarityFunction.EUCLIDEAN;
+ boolean centerData = ds.getSimilarityFunction() == VectorSimilarityFunction.EUCLIDEAN;
var pq = ProductQuantization.compute(floatVectors, pqM, 256, centerData, UNWEIGHTED);
var pqVectors = (PQVectors) pq.encodeAll(floatVectors);
System.out.printf("PQ compression: %d subspaces, 256 clusters%n", pqM);
@@ -325,59 +478,67 @@ public static void main(String[] args) throws IOException {
boolean addHierarchy = true;
boolean refineFinalGraph = true;
- System.out.printf("Building graph with PQ-compressed vectors (M=%d, efConstruction=%d)...%n", M, efConstruction);
- long buildStart = System.nanoTime();
-
- var bsp = BuildScoreProvider.pqBuildScoreProvider(ds.getSimilarityFunction(), pqVectors);
- var builder = new GraphIndexBuilder(bsp, floatVectors.dimension(), M, efConstruction,
- neighborOverflow, alpha, addHierarchy, refineFinalGraph);
-
- // Build graph using parallel construction for much better performance
- var graph = builder.build(floatVectors);
- long buildTime = System.nanoTime() - buildStart;
- System.out.printf("Graph built in %.2fs%n", buildTime / 1_000_000_000.0);
- System.out.printf("Graph has %d nodes%n", graph.size(0));
-
- // Create temporary paths for writing
Path tempDir = Files.createTempDirectory("parallel-write-test");
Path sequentialPath = tempDir.resolve("graph-sequential");
- Path parallelPath = tempDir.resolve("graph-parallel");
+ Path parallelBatchedPath = tempDir.resolve("graph-parallel-batched");
+ Path sequentialInterleavedPath = tempDir.resolve("graph-sequential-interleaved");
+ Path parallelLegacyInterleavedPath = tempDir.resolve("graph-parallel-legacy-interleaved");
try {
- System.out.println("\n=== Testing Write Performance ===");
+ // === Graph A: built once, plain (no writer involved during construction) ===
+ System.out.printf("%nBuilding Graph A (plain) with PQ-compressed vectors (M=%d, efConstruction=%d)...%n", M, efConstruction);
+ long buildStart = System.nanoTime();
+ var bsp = BuildScoreProvider.pqBuildScoreProvider(ds.getSimilarityFunction(), pqVectors);
+ var graphABuilder = new GraphIndexBuilder(bsp, floatVectors.dimension(), M, efConstruction,
+ neighborOverflow, alpha, addHierarchy, refineFinalGraph);
+ var graphA = graphABuilder.build(floatVectors);
+ long buildTime = System.nanoTime() - buildStart;
+ System.out.printf("Graph A built in %.2fs (%d nodes)%n", buildTime / 1_000_000_000.0, graphA.size(0));
+ graphABuilder.close();
+
+ System.out.println("\n=== Graph A: sequential vs. parallel (batched) ===");
+ benchmarkPlainWrites(graphA, sequentialPath, parallelBatchedPath, floatVectors, pqVectors);
- // Run benchmark comparison
- benchmarkComparison(graph, sequentialPath, parallelPath, floatVectors, pqVectors);
-
- // Report file sizes
long seqSize = Files.size(sequentialPath);
- long parSize = Files.size(parallelPath);
- System.out.printf("%nFile sizes: Sequential=%.2f MB, Parallel=%.2f MB%n",
- seqSize / 1024.0 / 1024.0,
- parSize / 1024.0 / 1024.0);
-
- // === Read Phase: Load and verify both indices ===
- System.out.println("\n=== Testing Read Correctness ===");
- System.out.println("Loading sequential index...");
- OnDiskGraphIndex sequentialIndex = OnDiskGraphIndex.load(ReaderSupplierFactory.open(sequentialPath));
- System.out.println("Loading parallel index...");
- OnDiskGraphIndex parallelIndex = OnDiskGraphIndex.load(ReaderSupplierFactory.open(parallelPath));
-
- // Verify that both indices are identical
- verifyIndicesIdentical(sequentialIndex, parallelIndex);
+ long batchedSize = Files.size(parallelBatchedPath);
+ System.out.printf("%nFile sizes: Sequential=%.2f MB, Parallel(batched)=%.2f MB%n",
+ seqSize / 1024.0 / 1024.0, batchedSize / 1024.0 / 1024.0);
+
+ System.out.println("\n=== Testing Read Correctness (Graph A) ===");
+ try (var sequentialIndex = OnDiskGraphIndex.load(ReaderSupplierFactory.open(sequentialPath));
+ var parallelBatchedIndex = OnDiskGraphIndex.load(ReaderSupplierFactory.open(parallelBatchedPath))) {
+ verifyIndicesIdentical(sequentialIndex, parallelBatchedIndex);
+ }
- // Close the loaded indices
- sequentialIndex.close();
- parallelIndex.close();
+ // === Graph B: built once via feature writes interleaved into construction, fed to
+ // both writers from the same pass (Grid.buildOnDisk pattern) ===
+ System.out.println("\n=== Graph B: sequential vs. parallel (legacy), interleaved pre-write ===");
+ benchmarkInterleavedWrites(floatVectors, ds.getSimilarityFunction(), pqVectors,
+ M, efConstruction, neighborOverflow, alpha, addHierarchy, refineFinalGraph,
+ sequentialInterleavedPath, parallelLegacyInterleavedPath);
+
+ long seqInterleavedSize = Files.size(sequentialInterleavedPath);
+ long legacyInterleavedSize = Files.size(parallelLegacyInterleavedPath);
+ System.out.printf("%nFile sizes: Sequential(interleaved)=%.2f MB, Parallel(legacy,interleaved)=%.2f MB%n",
+ seqInterleavedSize / 1024.0 / 1024.0, legacyInterleavedSize / 1024.0 / 1024.0);
+
+ // Both Graph B writers serialized the same shared graph object (see
+ // benchmarkInterleavedWrites), so the strict structural check applies here too.
+ System.out.println("\n=== Testing Read Correctness (Graph B) ===");
+ try (var sequentialInterleavedIndex = OnDiskGraphIndex.load(ReaderSupplierFactory.open(sequentialInterleavedPath));
+ var parallelLegacyInterleavedIndex = OnDiskGraphIndex.load(ReaderSupplierFactory.open(parallelLegacyInterleavedPath))) {
+ verifyIndicesIdentical(sequentialInterleavedIndex, parallelLegacyInterleavedIndex);
+ }
} finally {
// Cleanup
- builder.close();
Files.deleteIfExists(sequentialPath);
- Files.deleteIfExists(parallelPath);
+ Files.deleteIfExists(parallelBatchedPath);
+ Files.deleteIfExists(sequentialInterleavedPath);
+ Files.deleteIfExists(parallelLegacyInterleavedPath);
Files.deleteIfExists(tempDir);
}
- System.out.println("\n✅ Test complete - sequential and parallel writes produce identical results!");
+ System.out.println("\n✅ Test complete - sequential and parallel (batched and legacy) writes produce identical results!");
}
}
diff --git a/jvector-examples/yaml-configs/index-parameters/cap-10M.yml b/jvector-examples/yaml-configs/index-parameters/cap-6M.yml
similarity index 98%
rename from jvector-examples/yaml-configs/index-parameters/cap-10M.yml
rename to jvector-examples/yaml-configs/index-parameters/cap-6M.yml
index c4c285d18..98157a7d4 100644
--- a/jvector-examples/yaml-configs/index-parameters/cap-10M.yml
+++ b/jvector-examples/yaml-configs/index-parameters/cap-6M.yml
@@ -1,7 +1,7 @@
yamlSchemaVersion: 1
onDiskIndexVersion: 6
-dataset: cap-10M
+dataset: cap-6M
construction:
outDegree: [32]
diff --git a/jvector-examples/yaml-configs/index-parameters/default.yml b/jvector-examples/yaml-configs/index-parameters/default.yml
index b56e27ed0..a82d80191 100644
--- a/jvector-examples/yaml-configs/index-parameters/default.yml
+++ b/jvector-examples/yaml-configs/index-parameters/default.yml
@@ -21,6 +21,7 @@ construction:
reranking:
- NVQ
useSavedIndexIfExists: No # If yes, the system will attempt to locate a cached copy of the index instead of constructing
+ parallelGraphConstruction: No # If yes, uses OnDiskParallelGraphIndexWriter for L0 parallel writes (experimental)
search:
topKOverquery:
diff --git a/jvector-examples/yaml-configs/index-parameters/dpr-gemma-10M.yml b/jvector-examples/yaml-configs/index-parameters/dpr-gemma-10M.yml
index f2370a063..52a65f3d7 100644
--- a/jvector-examples/yaml-configs/index-parameters/dpr-gemma-10M.yml
+++ b/jvector-examples/yaml-configs/index-parameters/dpr-gemma-10M.yml
@@ -1,7 +1,7 @@
yamlSchemaVersion: 1
onDiskIndexVersion: 6
-dataset: dpr-gemma-10M
+dataset: dpr-gemma-10m
construction:
outDegree: [32]
diff --git a/jvector-examples/yaml-configs/index-parameters/dpr-gemma-1M.yml b/jvector-examples/yaml-configs/index-parameters/dpr-gemma-1M.yml
index 96e92556e..2b9c1f2c1 100644
--- a/jvector-examples/yaml-configs/index-parameters/dpr-gemma-1M.yml
+++ b/jvector-examples/yaml-configs/index-parameters/dpr-gemma-1M.yml
@@ -1,7 +1,7 @@
yamlSchemaVersion: 1
onDiskIndexVersion: 6
-dataset: dpr-gemma-1M
+dataset: dpr-gemma-1m
construction:
outDegree: [32]
@@ -21,6 +21,7 @@ construction:
reranking:
- NVQ
useSavedIndexIfExists: No
+ parallelGraphConstruction: Yes
search:
topKOverquery: