Skip to content

GPU graph construction: GraphBuildAccelerator SPI and an optional jvector-gpu module (TornadoVM + cuVS) - #783

Open
foosekis4j wants to merge 11 commits into
datastax:mainfrom
foosekis4j:gpu-graph-build
Open

foosekis4j wants to merge 11 commits into
datastax:mainfrom
foosekis4j:gpu-graph-build

Conversation

@foosekis4j

@foosekis4j foosekis4j commented Nov 3, 2026 •

Copy link
Copy Markdown

Summary

This PR adds a way to build a JVector graph index outside GraphIndexBuilder's incremental insertion, and an
optional module that uses it to build graphs on NVIDIA GPUs. Nothing changes unless the module is built and selected:
GraphIndexBuilder, search, writers and the on-disk format behave as before.

It is two commits, which can be reviewed (or split into two PRs) separately:

  1. GraphBuildAccelerator (core, ~460 lines, no new dependencies). An @Experimental service interface
    (name(), supports(builder, vectors, similarity), build(builder, vectors, similarity)), found with
    ServiceLoader through GraphBuildAccelerators.find() and selected with -Djvector.graph.accelerator=<name>. An
    accelerator computes every node's diversity-pruned neighbour list and installs it with new @Experimental methods
    on GraphIndexBuilder:

    • addGraphNodeWithNeighbors(level, node, neighbors), safe for distinct (node, level) pairs from many threads;
    • sampleGraphLevels(n), which draws levels as addGraphNode does, so a hierarchy keeps its distribution;
    • completeExternalBuild(entryNode);
    • getMaxDegree() and getAlpha().

    cleanup() then runs as usual; it skips refineFinalGraph for an external build. Bench (Grid) builds with the
    selected accelerator when supports() says it can, and exactly as before otherwise. TestExternalGraphBuild checks
    the contract with a brute-force reference accelerator.

  2. jvector-gpu (optional module, mvn -Pgpu). It implements the interface for NVIDIA GPUs with
    TornadoVM (GPU kernels written in Java) and
    cuVS: cuVS NN-Descent candidates, then diversity pruning with the builder's
    alpha on the GPU, using a tensor-core Gram matrix, and upper layers from the same pruning. Without a GPU or
    TornadoVM, or when the build would not fit in device or host memory (the host's cgroup limit included), supports() returns false and JVector builds
    on the CPU as usual.

GPU compaction, PQ and NVQ encoding, which an earlier version of this PR included, have moved out to a follow-up, so
this PR is only about graph construction.

Results

Same settings on both machines (M=32, efConstruction=100, overflow 1.2, alpha 1.2, hierarchy). Each build runs cold
in a fresh JVM, so GPU times include TornadoVM's kernel compilation and cuVS's start-up. Medians of 5 GPU and 3 CPU
builds (10M: 3 and 2), measured on this branch with TornadoVM 8.1.0. Recall@10 is from JVector's GraphSearcher
on each graph, at search beams 40 / 160.

RTX 4090 against JVector on an i9-13900K (32 threads):

dataset vectors JVector (CPU) jvector-gpu speed-up recall@10, beam 40 / 160: CPU GPU
Cohere Wikipedia v3, 10M 10,000,000 x 1024 2320 s 186 s 12.5x 0.854 / 0.954 0.868 / 0.964
Cohere Wikipedia v3, 1M 1,000,000 x 1024 179.5 s 7.5 s 23.8x 0.893 / 0.976 0.898 / 0.979
ada002-1M 982,790 x 1536 272.6 s 9.2 s 29.7x 0.961 / 0.992 0.964 / 0.994
glove-100-angular 1,183,514 x 100 70.5 s 6.4 s 11.0x 0.714 / 0.853 0.738 / 0.869
sift-128-euclidean 985,462 x 128 48.0 s 5.5 s 8.7x 0.943 / 0.994 0.954 / 0.997
ada002-100k 99,287 x 1536 16.3 s 1.9 s 8.5x 0.985 / 0.998 0.985 / 0.998
lastfm-64-dot 292,385 x 65 5.6 s 2.5 s 2.3x 0.938 / 0.988 0.933 / 0.987

A100 80 GB (PCIe) against JVector on the same server's host, 128 Arm Neoverse-N1 cores (aarch64, 128 threads):

dataset JVector (CPU) jvector-gpu speed-up recall@10, beam 40 / 160: CPU GPU
Cohere Wikipedia v3, 10M 1069 s 164 s 6.5x not measured not measured
Cohere Wikipedia v3, 1M 86.2 s 12.0 s 7.2x 0.894 / 0.977 0.898 / 0.979
ada002-1M 121.0 s 14.4 s 8.4x 0.961 / 0.992 0.964 / 0.994
glove-100-angular 41.6 s 9.8 s 4.3x 0.715 / 0.854 0.737 / 0.868
sift-128-euclidean 34.1 s 8.9 s 3.8x 0.943 / 0.995 0.954 / 0.997
ada002-100k 8.4 s 2.6 s 3.2x 0.984 / 0.998 0.985 / 0.998
lastfm-64-dot 6.0 s 3.5 s 1.7x 0.937 / 0.988 0.934 / 0.987
  • Recall is the same on both machines, and the GPU graphs are slightly better: at the same recall@10, JVector's
    searcher visits 4-16% fewer nodes on them than on its own graphs (on LastFM a search of the same beam reaches
    half a point less recall).
  • The A100's speed-ups are smaller because its CPU baseline is about twice as fast: JVector on 128 cores builds
    Cohere 1M in 86 s against 180 s on the i9. JVector's build stops getting faster past about 64 threads.
  • The 1M builds take longer on the A100 than on the 4090, mostly because installing the finished lists with
    addGraphNodeWithNeighbors takes 3.7 s on the 128-core host against 0.3 s on the i9 (Cohere 1M). That step runs
    on the CPU.
  • 10M x 1024 reads the vectors in place with MappedVectors. cuVS builds its candidates in 3 batches on the
    80 GB A100 and in 9 on the 24 GB 4090. The A100 build peaks at 32.6 GB of device memory, the 4090 build at 19.7 GB.

Trying it

export JAVA_HOME=<JDK 22+>
sdk install tornadovm 8.1.0-jdk22plus-cuda       # TornadoVM 8.1.0 with the CUDA backend
export TORNADOVM_HOME=$HOME/.sdkman/candidates/tornadovm/8.1.0-jdk22plus-cuda PATH=$TORNADOVM_HOME/bin:$PATH
mvn -Pgpu -pl jvector-gpu,jvector-examples -am package -DskipTests
jvector-gpu/run.sh check                         # GPU builds against JVector's CPU builds; prints OK
jvector-gpu/run.sh bench ada002-100k             # JVector's Bench with the graph built on the GPU
  • Dependencies: tornado-api and tornado-cuvs 8.1.0 come from Maven Central. They're provided, because the
    TornadoVM SDK supplies them at run time.
  • cuVS: installed from NVIDIA's Python wheels by jvector-gpu/cuvs-env.sh (about 2.1 GB, once), for x86_64 or
    aarch64.
  • Hardware: CUDA only, compute capability 8.0 or newer. Tested on an RTX 4090 (x86_64) and an A100 (aarch64).

Testing

  • TestExternalGraphBuild, plus the existing GraphIndexBuilderTest, TestGraphIndexBuilderConfig and
    TestOnDiskGraphIndexCompactor: 45 tests, all pass.
  • jvector-gpu host tests (no GPU needed): 13 tests, all pass, covering:
    • FP16 packing and normalisation;
    • scores against JVector's;
    • candidate selection;
    • batching and the device-memory budget;
    • the host-memory estimate and the decline when the host cannot hold the build, with cgroup limits (containers, memory-limited services) counted;
    • the fallback to the CPU build.
  • jvector-gpu/run.sh check on an RTX 4090 prints OK. It builds COSINE, DOT_PRODUCT and EUCLIDEAN graphs on the
    GPU and on the CPU and compares their recall.
  • mvn apache-rat:check passes with -Pgpu.

Notes and questions

  • Release note: docs/release notes/4.1.0/783.feature.md.
  • Where the module lives: would you rather keep it in this repository behind a profile, as here, or publish it
    separately and keep only the interface here?
  • OpenSearch: using the accelerator from OpenSearch's JVector plugin would be a small follow-up there, in its
    getGraph(), once a JVector release has the interface.
  • GCC 12 fix: a separate small PR fixes build_native_lib.sh, which on x86_64 needs GCC 12 for Highway's
    AVX3_SPR target and should check $CXX.

…builds

GraphIndexBuilder builds a graph by inserting nodes one at a time. GraphBuildAccelerator lets an
implementation build the same graph another way, for example on a GPU, and hand JVector the
result. Nothing changes unless one is installed and selected: the search path and the on-disk
format are untouched.

- GraphBuildAccelerator (@experimental): name(), supports(builder, vectors, similarity) and
  build(builder, vectors, similarity). GraphBuildAccelerators.find() loads the implementations with
  ServiceLoader and returns the one -Djvector.graph.accelerator=<name> selects, if any.
- GraphIndexBuilder, for builds from outside:
  - addGraphNodeWithNeighbors(level, node, neighbors) installs a node's diversity-pruned list in
    one layer; safe for distinct (node, level) pairs from many threads;
  - sampleGraphLevels(n) draws node levels as addGraphNode does, so a hierarchy keeps its
    distribution;
  - completeExternalBuild(entryNode) sets the entry point of a flat graph;
  - getMaxDegree() and getAlpha() expose the build parameters;
  - cleanup() skips refineFinalGraph for an external build, which already chose its lists.
- Bench (Grid) builds with the selected accelerator when supports() says it can, alongside the
  inline feature writes, and otherwise exactly as before.
- TestExternalGraphBuild: a reference accelerator (brute-force neighbors, JVector's own diversity
  pruning) builds flat and hierarchical graphs that search with recall@10 above 0.9, keep within
  the max degree and put the entry point on the top layer; find() returns nothing unless an
  accelerator is installed and selected.
…ornadoVM + cuVS)

An optional module, built only with -Pgpu, that implements GraphBuildAccelerator for NVIDIA GPUs
through TornadoVM (Java GPU kernels) and NVIDIA cuVS. Without -Pgpu nothing changes; with it but
without a GPU, every entry point falls back to JVector's CPU code.

- TornadoGraphBuildAccelerator ("tornadovm"): cuVS NN-Descent candidates, diversity pruning with
  the builder's alpha on the GPU (tensor-core Gram matrix), upper layers from the same pruning, then
  addGraphNodeWithNeighbors and completeExternalBuild. COSINE, DOT_PRODUCT and EUCLIDEAN, any
  dimension (padded to 16), M <= 64.
- GpuProductQuantization: k-means++ training and encoding on the GPU; with JVector's codebooks the
  codes are identical to the CPU's.
- GpuCompaction: merges on-disk segments by rebuilding the graph on the GPU; ordinalMappers() as
  OnDiskGraphIndexCompactor.
- GpuNvq: NVQ encoding on the GPU (8-bit).
- GpuBuildCheck (run.sh check): every feature against JVector on synthetic data; prints OK.
- Benchmarks (run.sh bench|cpu|pq|compact|nvq|wikipedia|groundtruth|phases), cuvs-env.sh (cuVS
  from NVIDIA's wheels), preflight.sh, and a README with results.
- Host tests (no GPU): candidate width, packing, fallbacks to the CPU code.

Wikipedia 1M x 1024 (Cohere embed-multilingual-v3), RTX 4090, cold build: GPU 17.4 s (median of
4, 16.0-19.0 s) vs JVector 181.7 s on the CPU; recall@10 higher at search beams 40-160, 0.005
lower at beam 20.
@github-actions

github-actions Bot commented Nov 3, 2026

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.

@foosekis4j

Copy link
Copy Markdown
Author

Added the release note as docs/release notes/4.1.0/783.feature.md (fb6267f).

The gpu module is only listed under the gpu profile, so the RAT check of
the default build does not skip it as a sub-project and reads every file
in it; the two files without a header failed the check.
@dian-lun-lin

Copy link
Copy Markdown
Contributor

The compaction part is in-core only, right? As I read it, GpuCompaction loads every live vector into memory, rebuilds the graph from scratch on the GPU, and writes the result out. Our compaction is designed for the opposite case: large datasets or small machines, where the merge doesn’t fit in memory and has to run out of core. In that case, do we fall back to the CPU compactor?

…compiled

PackedVectors.of first packs the dataset (often a memory-mapped file) and then
the upper layers' rows (native memory). The hot loop read the rows through the
MemorySegment, so C2 specialised it for the first segment class; the second
call hit a class-check trap and, after repeated recompile/deoptimise rounds,
the loop was left in C1 profiled code with every pool thread updating the
same profile counters. Packing ~31k upper-layer rows of 1024 dimensions took
7-9 s, about half of a 1M-vector build.

Each row is now copied to a per-thread float[] with MemorySegment.copy, which
behaves the same for every segment class, and packed from the array. The
packed output is identical.

Cohere Wikipedia 1M x 1024: build 17.1 s -> 8.0 s; ada002-1M x 1536:
17.5 s -> 9.8 s (RTX 4090, TornadoVM 8.0.0), recall unchanged.
@foosekis4j

Copy link
Copy Markdown
Author

Pushed f1ba7ac, a performance fix in PackedVectors.

The FP16 packing loop is JIT-compiled first for the memory-mapped dataset. When the upper layers then pass rows in native memory, a class check in the compiled loop fails. C2 then went through rounds of recompiling and deoptimising, and the loop ended up in C1 profiled code. Packing the ~31k upper-layer rows took 7-9 s at 1024-1536 dimensions, about half of a 1M build. Each row is now copied to a float[] with MemorySegment.copy and packed from there, with identical output.

Cold builds on the same RTX 4090 / i9-13900K, same settings as in the description (M=32, efConstruction 100, alpha 1.2, hierarchy):

before after JVector (CPU)
Cohere Wikipedia v3, 1M x 1024 17.1 s 7.8 s 179 s
ada002-1M, 982,790 x 1536 17.5 s 9.8 s 273 s

Recall is unchanged. On Cohere 1M, recall@10 at beam 40 / 80 is 0.898 / 0.952 for the GPU graph and 0.893 / 0.949 for JVector's. run.sh check prints OK, and the 12 host tests pass.

@foosekis4j

Copy link
Copy Markdown
Author

The compaction part is in-core only, right? As I read it, GpuCompaction loads every live vector into memory, rebuilds the graph from scratch on the GPU, and writes the result out. Our compaction is designed for the opposite case: large datasets or small machines, where the merge doesn’t fit in memory and has to run out of core. In that case, do we fall back to the CPU compactor?

Yes, GpuCompaction is in-core: it materialises every live vector on the heap, rebuilds from scratch on the GPU, and writes the result. It's meant for the case where the live set fits in host memory and in the GPU's FP16 budget (n × d × 2 bytes; 10M × 1024 fits on a 24 GB card). It does not replace your compactor's out-of-core design.

To answer the fallback question precisely: it falls back to OnDiskGraphIndexCompactor only when the GPU build declines (no device, or not enough device memory). It doesn't check host memory first, so a merge larger than the heap would fail with an OOM instead of falling back. I'll fix that. The size checks will run before any vector is loaded, against a host-memory budget as well as the device budget, and anything that doesn't fit goes to OnDiskGraphIndexCompactor untouched. The rebuild will also read the live vectors from one off-heap region (or a temporary memory-mapped file) instead of heap objects, which removes one full copy.

For the large-dataset case, a GPU should help your compactor rather than replace it: keep ts streaming cell join and its in-place merge, and offload the batched pieces. If it would help, I can split GpuCompaction out of this PR so the PR covers only graph construction.

… PQ and NVQ out

GpuCompaction rebuilds a merge in core: it needs the live vectors in host
memory and the merged set in device memory. That suits small and medium
merges but not OnDiskGraphIndexCompactor's out-of-core design, so it moves to
a follow-up together with GPU PQ and NVQ encoding, where it can fall back to
the out-of-core compactor before loading anything. This change keeps the
GraphBuildAccelerator SPI and the GPU graph build.

Removed: GpuCompaction, GpuProductQuantization, PqKernels, GpuNvq,
NvqKernels, their benches (CompactionBench, PqBench, NvqBench, WritePhases)
and TestGpuCompactionAndPqHost; GpuBuildCheck checks the graph build only;
run.sh, README and the release note follow.
…e-edge merge

GpuPrune.prune copied each batch into the TornadoVM arrays and read the keep
flags back element by element on one thread, while the GPU waited. Batches are
now laid out in host arrays by other threads while the GPU prunes the previous
batch, moved with bulk MemorySegment copies, and their results applied in
parallel behind it. A node is in one batch only, so the stages touch disjoint
rows. The reverse-edge merge's two sequential passes are parallel, with each
node's reverse list sorted by source so the result is the old one.

Output is unchanged: built from exact candidates, the adjacency is identical to
the previous version's (ada002-100k and sift-128, widths 48 and 96).
RTX 4090, prune phase / whole build: Cohere Wikipedia 1M x 1024 2.56 -> 2.12 s /
8.0 -> 7.6 s; ada002-1M 3.54 -> 2.96 s / 10.3 -> 9.7 s; sift-128 2.99 -> 1.83 s /
6.7 -> 5.6 s.
…machine's architecture

TornadoVM 8.1.0 (released 2026-11-06) has performance fixes; tornado-api and
tornado-cuvs 8.1.0 are on Maven Central and the SDK on SDKMAN
(8.1.0-jdk22plus-cuda). preflight.sh requires 8.1.0.

cuvs-env.sh downloaded x86_64 wheels only; it now asks pip for the manylinux
wheels of `uname -m`, so the module also sets up on aarch64 servers (checked on
an A100 with a Neoverse-N1 host).
…(TornadoVM 8.1.0)

Medians of 5 GPU and 3 CPU cold builds on an RTX 4090 against an i9-13900K,
seven datasets from LastFM (2.3x) to ada002-1M (29.7x) and Cohere Wikipedia
10M x 1024 (186 s against 2320 s), with recall@10 of both graphs at search
beams 40 and 160. Replaces the October tables measured on TornadoVM 7.x/8.0.0.
supports() checked only the device budget. A build that fits the GPU but not
the host (a large dataset on a small machine) failed partway with an
OutOfMemoryError or a failed native allocation instead of falling back to the
CPU build.

HostMemory estimates what the build holds at its peak from n, dim and the max
degree alone: on the heap, the candidate and pruned lists and the builder's
lists (~1.9 KB per node at M=32); in native memory, the FP16 rows, cuVS's
output, the upper layers' vectors and, unless the vectors are a MappedVectors
file, their float32 copy. supports() declines, and logs why, when the heap
part exceeds the free heap or the native part plus the heap still to grow
exceeds MemAvailable. -Djvector.gpu.hostMemory replaces both with one budget.

The estimate errs high (it counts the FP16 rows and cuVS's output together,
which a batched build never holds at once). Cohere Wikipedia 1M and 10M x 1024
still build on the GPU on a 62 GB host with run.sh's 48 GB heap.
@foosekis4j

Copy link
Copy Markdown
Author

Following up on this: I moved GpuCompaction (and GPU PQ/NVQ) out of this PR in 0787ab9, so it now covers only graph construction. Compaction comes back in a follow-up PR that decides from the sizes before it reads any vector and hands any merge that doesn't fit to OnDiskGraphIndexCompactor untouched.

The same question applied to the build, so 7f8f0ce adds a host-memory check to supports(). Before, it checked only the device budget, so on a small host a large build could fail partway instead of falling back to GraphIndexBuilder. Now HostMemory estimates the build's peak from n, dim and the max degree alone. On the heap that is about 1.9 KB per node at M=32. In native memory it is the FP16 rows, cuVS's output and, unless the vectors are a MappedVectors file, their float32 copy. If that exceeds the free heap or the host's available memory, the accelerator declines, logs why, and JVector builds on the CPU as usual. -Djvector.gpu.hostMemory=48GB sets a fixed budget instead. Cohere Wikipedia 1M and 10M x 1024 still build on the GPU on a 62 GB host (7.6 s and 188.5 s).

The host-memory check compared the build's native memory with MemAvailable,
which is the machine's: in a container or a memory-limited service, a build
that fits the machine but not the cgroup passed the check and then hit the
limit. availableNative() now also takes the least headroom of the process's
cgroup and each ancestor (cgroup v2 memory.max / memory.current / memory.stat,
or v1 memory.limit_in_bytes / memory.usage_in_bytes / memory.stat): each limit
less the usage that is not page cache, which the kernel reclaims under the
limit as MemAvailable counts it reclaimable.

Found while running GPU compactions under systemd-run MemoryMax=8G: HostMemory
saw the 62 GB host's free memory, not the 8 GB limit.
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