-
Notifications
You must be signed in to change notification settings - Fork 155
Parallel Write performance improvements #695
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
c4bb017
9ee6ab4
26bc437
219aa73
76e3edb
51f5732
bc076be
07d3ace
1f24c98
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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()`. | ||
Large diffs are not rendered by default.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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<ImmutableGraphIndex.View> viewPerThread; | ||
| private final ThreadLocal<ByteBuffer> bufferPerThread; | ||
| private final CopyOnWriteArrayList<ImmutableGraphIndex.View> 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(() -> { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Although it can be a twist, we should start trying to remove usage of ThreadLocal where possible. Of course, this pushes us closer to explicit task structuring, but there are caveats for virtual threads with ThreadLocal which re not ideal.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. (ah noticed the change was for comments only, sorry if superfluous) |
||
| 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<Void> 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(); | ||
| }); | ||
|
|
||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If we include benchmark data in docs, we need to qualify the test scenario with system specs:
CPU/RAM/Storage type and layout