From 40a41e3d92109d05b59df8272be6b13739e8a98a Mon Sep 17 00:00:00 2001 From: Enrico Olivelli Date: Sun, 19 Apr 2026 01:15:53 +0200 Subject: [PATCH 1/5] Zero-copy ByteBuffer-backed vectors, no float[] materialization MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a zero-copy path from a caller-owned ByteBuffer to a jvector index build or search, without the per-vector float[] allocation and copy that integrators have to perform today. The public API already uses VectorFloat, so the changes are a targeted set of additions at the abstraction boundary plus polymorphic dispatch in the SIMD backends so they operate on ByteBuffer-backed vectors without materializing them to float[]. New types - BufferVectorFloat (jvector-base): zero-copy VectorFloat view over a caller-owned buffer. Slices once at construction so subsequent element access, Panama SIMD dispatch, and mutation of the caller's buffer position/limit never need to allocate. - ByteBufferRandomAccessVectorValues (jvector-base): RAVV over a single concatenated ByteBuffer of N×dimension floats. - VectorTypeSupport.wrapFloatVector(ByteBuffer[, floatOffset, floatLength]): typed factory producing the zero-copy view. - MemorySegmentVectorFloat.wrap(ByteBuffer): zero-copy static factory that complements the legacy copying constructor. SIMD preservation - PanamaVectorUtilSupport detects BufferVectorFloat and dispatches to FloatVector.fromMemorySegment(MemorySegment.ofBuffer(bb), ...) — full SIMD with no float[] materialization. ArrayVectorFloat still uses fromArray. - NativeVectorUtilSupport's four protected helpers now fall through to super for non-MemorySegment vectors, so BufferVectorFloat works under native dispatch too. - DefaultVectorUtilSupport's scalar kernels gain a polymorphic entry that uses VectorFloat.get(i) for non-ArrayVectorFloat inputs. - jvector-twenty release bumped 20 -> 22 so MemorySegment is stable (matches jvector-native). Preview-locked class files were already being avoided by the project; this removes the last blocker. High-level API - GraphSearcher.search(ByteBuffer, ...) and GraphIndexBuilder.addGraphNode(int, ByteBuffer) overloads — thin wrappers that call wrapFloatVector internally. - MMapRandomAccessVectorValues rewritten to delegate to ByteBufferRandomAccessVectorValues over a MappedByteBuffer. Drops the per-getVector float[dimension] scratch allocation that the old implementation performed. - MemorySegmentVectorFloat.get/set gain an off-heap fallback via segment.getAtIndex/setAtIndex, so wrap(direct ByteBuffer) works correctly (the on-heap fast path remains). Polymorphic copyFrom - ArrayVectorFloat.copyFrom, MemorySegmentVectorFloat.copyFrom, and BufferVectorFloat.copyFrom handle any VectorFloat source instead of requiring the class-strict cast that was there before. Tests (all green under SIMD and scalar profiles) - BufferVectorFloatTest: element access, endianness, zero-copy on direct buffers, position/limit independence, slice correctness. - VectorTypeSupportByteBufferTest: typed factory across active + Default provider, zero-copy proof, subrange views. - ByteBufferRandomAccessVectorValuesTest: parity with ListRAVV, concurrent threadLocalSupplier correctness, bounds. - BuildFromByteBufferEquivalenceTest: builds the same graph from float[] and from ByteBuffer and asserts structural + search equivalence across EUCLIDEAN / DOT_PRODUCT / COSINE at multiple dimensions. - TestSearchWithByteBufferQuery: ByteBuffer overloads produce same results as VectorFloat overloads. - MMapRandomAccessVectorValuesTest: round-trip across rewritten mmap RAVV. - MemorySegmentVectorFloatWrapTest: wrap vs legacy copying ctor, big-endian rejection, alignment validation. - SearchAllocationProfileTest: per-query allocation stays within a small multiple of the float[] baseline (guard against accidental regression to a naive float[] materialization). HerdDB integration becomes, end-to-end: ByteBuffer source = herdDBVector; builder.addGraphNode(ordinal, source); // zero copy GraphSearcher.search(source, topK, ravv, vsf, graph, Bits.ALL); Co-Authored-By: Claude Opus 4.7 (1M context) --- .../ByteBufferRandomAccessVectorValues.java | 128 +++++++++ .../jvector/graph/GraphIndexBuilder.java | 14 + .../jbellis/jvector/graph/GraphSearcher.java | 23 ++ .../jvector/vector/ArrayVectorFloat.java | 9 +- .../jvector/vector/BufferVectorFloat.java | 255 ++++++++++++++++++ .../vector/DefaultVectorUtilSupport.java | 54 ++++ .../vector/types/VectorTypeSupport.java | 46 +++- .../util/MMapRandomAccessVectorValues.java | 78 +++--- .../MMapRandomAccessVectorValuesTest.java | 66 +++++ .../vector/MemorySegmentVectorFloat.java | 62 ++++- .../vector/MemorySegmentVectorProvider.java | 19 ++ .../vector/NativeVectorUtilSupport.java | 33 ++- .../MemorySegmentVectorFloatWrapTest.java | 71 +++++ .../BuildFromByteBufferEquivalenceTest.java | 143 ++++++++++ ...yteBufferRandomAccessVectorValuesTest.java | 166 ++++++++++++ .../graph/SearchAllocationProfileTest.java | 137 ++++++++++ .../graph/TestSearchWithByteBufferQuery.java | 117 ++++++++ .../jvector/vector/BufferVectorFloatTest.java | 221 +++++++++++++++ .../VectorTypeSupportByteBufferTest.java | 107 ++++++++ jvector-twenty/pom.xml | 4 +- .../vector/PanamaVectorUtilSupport.java | 28 ++ 21 files changed, 1731 insertions(+), 50 deletions(-) create mode 100644 jvector-base/src/main/java/io/github/jbellis/jvector/graph/ByteBufferRandomAccessVectorValues.java create mode 100644 jvector-base/src/main/java/io/github/jbellis/jvector/vector/BufferVectorFloat.java create mode 100644 jvector-examples/src/test/java/io/github/jbellis/jvector/example/util/MMapRandomAccessVectorValuesTest.java create mode 100644 jvector-native/src/test/java/io/github/jbellis/jvector/vector/MemorySegmentVectorFloatWrapTest.java create mode 100644 jvector-tests/src/test/java/io/github/jbellis/jvector/graph/BuildFromByteBufferEquivalenceTest.java create mode 100644 jvector-tests/src/test/java/io/github/jbellis/jvector/graph/ByteBufferRandomAccessVectorValuesTest.java create mode 100644 jvector-tests/src/test/java/io/github/jbellis/jvector/graph/SearchAllocationProfileTest.java create mode 100644 jvector-tests/src/test/java/io/github/jbellis/jvector/graph/TestSearchWithByteBufferQuery.java create mode 100644 jvector-tests/src/test/java/io/github/jbellis/jvector/vector/BufferVectorFloatTest.java create mode 100644 jvector-tests/src/test/java/io/github/jbellis/jvector/vector/VectorTypeSupportByteBufferTest.java diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/graph/ByteBufferRandomAccessVectorValues.java b/jvector-base/src/main/java/io/github/jbellis/jvector/graph/ByteBufferRandomAccessVectorValues.java new file mode 100644 index 000000000..a035ed4ba --- /dev/null +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/graph/ByteBufferRandomAccessVectorValues.java @@ -0,0 +1,128 @@ +/* + * Copyright DataStax, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.github.jbellis.jvector.graph; + +import io.github.jbellis.jvector.vector.VectorizationProvider; +import io.github.jbellis.jvector.vector.types.VectorFloat; +import io.github.jbellis.jvector.vector.types.VectorTypeSupport; + +import java.nio.ByteBuffer; +import java.util.Objects; + +/** + * A {@link RandomAccessVectorValues} backed by a single caller-owned {@link ByteBuffer} that + * holds {@code count × dimension × Float.BYTES} bytes of concatenated IEEE 754 floats. + * + *

Vectors are never copied: {@link #getVector(int)} returns a view (via the active + * {@link VectorTypeSupport#wrapFloatVector(ByteBuffer, int, int)}) that aliases the source + * bytes. This lets integrators whose data is already in ByteBuffer form (for example a + * database page cache) feed {@code GraphIndexBuilder} without the usual {@code float[]} + * materialization step. + * + *

The buffer is pre-sliced at construction time so later mutation of the caller's buffer + * position / limit does not disturb this RAVV. Mutating the buffer's contents after + * construction is visible to this RAVV — callers are responsible for ensuring writers and + * readers do not race. + */ +public class ByteBufferRandomAccessVectorValues implements RandomAccessVectorValues +{ + private static final VectorTypeSupport VTS = + VectorizationProvider.getInstance().getVectorTypeSupport(); + + private final ByteBuffer data; + private final int count; + private final int dimension; + + public ByteBufferRandomAccessVectorValues(ByteBuffer data, int count, int dimension) + { + Objects.requireNonNull(data, "data"); + if (count < 0) throw new IllegalArgumentException("count must be >= 0, was " + count); + if (dimension <= 0) throw new IllegalArgumentException("dimension must be > 0, was " + dimension); + long need = (long) count * dimension * Float.BYTES; + if (data.remaining() < need) { + throw new IllegalArgumentException( + "buffer too small: need " + need + " bytes, have " + data.remaining()); + } + ByteBuffer dup = data.duplicate().order(data.order()); + int startByte = data.position(); + dup.position(startByte).limit(startByte + (int) need); + this.data = dup.slice().order(data.order()); + this.count = count; + this.dimension = dimension; + } + + @Override + public int size() + { + return count; + } + + @Override + public int dimension() + { + return dimension; + } + + @Override + public VectorFloat getVector(int targetOrd) + { + if (targetOrd < 0 || targetOrd >= count) { + throw new IndexOutOfBoundsException("ordinal " + targetOrd + " out of [0, " + count + ")"); + } + return VTS.wrapFloatVector(data, targetOrd * dimension, dimension); + } + + @Override + public boolean isValueShared() + { + // Each getVector returns a distinct view instance — callers may hold onto results. + return false; + } + + @Override + public ByteBufferRandomAccessVectorValues copy() + { + return this; + } + + /** + * Convenience factory accepting a plain {@code float[]} (copying it into a native-endian + * ByteBuffer). Primarily used by tests that already have {@code float[]} fixtures. + */ + public static ByteBufferRandomAccessVectorValues fromFloats(float[][] vectors, int dimension) + { + Objects.requireNonNull(vectors, "vectors"); + if (dimension <= 0) throw new IllegalArgumentException("dimension > 0 required"); + int count = vectors.length; + ByteBuffer bb = ByteBuffer.allocate(count * dimension * Float.BYTES) + .order(java.nio.ByteOrder.LITTLE_ENDIAN); + for (float[] v : vectors) { + if (v.length != dimension) { + throw new IllegalArgumentException("vector dimension mismatch: " + v.length + " vs " + dimension); + } + for (float f : v) bb.putFloat(f); + } + bb.rewind(); + return new ByteBufferRandomAccessVectorValues(bb, count, dimension); + } + + /** @return the live view over the backing data (for tests/integration). */ + public ByteBuffer data() + { + return data; + } +} diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/graph/GraphIndexBuilder.java b/jvector-base/src/main/java/io/github/jbellis/jvector/graph/GraphIndexBuilder.java index 9e366676c..9d8af192e 100644 --- a/jvector-base/src/main/java/io/github/jbellis/jvector/graph/GraphIndexBuilder.java +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/graph/GraphIndexBuilder.java @@ -30,13 +30,16 @@ import io.github.jbellis.jvector.util.ExplicitThreadLocal; import io.github.jbellis.jvector.util.PhysicalCoreExecutor; import io.github.jbellis.jvector.vector.VectorSimilarityFunction; +import io.github.jbellis.jvector.vector.VectorizationProvider; import io.github.jbellis.jvector.vector.types.VectorFloat; +import io.github.jbellis.jvector.vector.types.VectorTypeSupport; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.io.Closeable; import java.io.IOException; import java.io.UncheckedIOException; +import java.nio.ByteBuffer; import java.util.*; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentSkipListSet; @@ -593,6 +596,17 @@ public long addGraphNode(int node, VectorFloat vector) { return addGraphNode(node, ssp); } + /** + * Zero-copy ByteBuffer overload of {@link #addGraphNode(int, VectorFloat)}. The buffer is + * interpreted as contiguous IEEE 754 floats in its current {@link java.nio.ByteOrder}, so + * callers that already hold vector data as ByteBuffer (for example a database page cache) + * can feed the builder without the usual {@code float[]} materialization step. + */ + public long addGraphNode(int node, ByteBuffer vector) { + VectorTypeSupport vts = VectorizationProvider.getInstance().getVectorTypeSupport(); + return addGraphNode(node, vts.wrapFloatVector(vector)); + } + /** * Inserts a node with the given vector value to the graph. * diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/graph/GraphSearcher.java b/jvector-base/src/main/java/io/github/jbellis/jvector/graph/GraphSearcher.java index 73cc5fbd5..24ac2bc8b 100644 --- a/jvector-base/src/main/java/io/github/jbellis/jvector/graph/GraphSearcher.java +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/graph/GraphSearcher.java @@ -33,12 +33,15 @@ import io.github.jbellis.jvector.util.BoundedLongHeap; import io.github.jbellis.jvector.util.GrowableLongHeap; import io.github.jbellis.jvector.vector.VectorSimilarityFunction; +import io.github.jbellis.jvector.vector.VectorizationProvider; import io.github.jbellis.jvector.vector.types.VectorFloat; +import io.github.jbellis.jvector.vector.types.VectorTypeSupport; import org.agrona.collections.Int2ObjectHashMap; import org.agrona.collections.IntHashSet; import java.io.Closeable; import java.io.IOException; +import java.nio.ByteBuffer; /** @@ -151,6 +154,26 @@ public static SearchResult search(VectorFloat queryVector, int topK, int rera } } + /** + * Zero-copy ByteBuffer overload of {@link #search(VectorFloat, int, RandomAccessVectorValues, + * VectorSimilarityFunction, ImmutableGraphIndex, Bits)}. The buffer is interpreted as + * {@code Float.BYTES}-aligned IEEE 754 floats in its current {@link java.nio.ByteOrder}; + * no {@code float[]} materialization occurs. + */ + public static SearchResult search(ByteBuffer queryVector, int topK, RandomAccessVectorValues vectors, VectorSimilarityFunction similarityFunction, ImmutableGraphIndex graph, Bits acceptOrds) { + VectorTypeSupport vts = VectorizationProvider.getInstance().getVectorTypeSupport(); + return search(vts.wrapFloatVector(queryVector), topK, vectors, similarityFunction, graph, acceptOrds); + } + + /** + * Zero-copy ByteBuffer overload with explicit rerankK. See + * {@link #search(ByteBuffer, int, RandomAccessVectorValues, VectorSimilarityFunction, ImmutableGraphIndex, Bits)}. + */ + public static SearchResult search(ByteBuffer queryVector, int topK, int rerankK, RandomAccessVectorValues vectors, VectorSimilarityFunction similarityFunction, ImmutableGraphIndex graph, Bits acceptOrds) { + VectorTypeSupport vts = VectorizationProvider.getInstance().getVectorTypeSupport(); + return search(vts.wrapFloatVector(queryVector), topK, rerankK, vectors, similarityFunction, graph, acceptOrds); + } + /** * Sets the view of the graph to be used by the searcher. *

diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/vector/ArrayVectorFloat.java b/jvector-base/src/main/java/io/github/jbellis/jvector/vector/ArrayVectorFloat.java index 32dce1f35..8b686bb3c 100644 --- a/jvector-base/src/main/java/io/github/jbellis/jvector/vector/ArrayVectorFloat.java +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/vector/ArrayVectorFloat.java @@ -81,8 +81,13 @@ public VectorFloat copy() @Override public void copyFrom(VectorFloat src, int srcOffset, int destOffset, int length) { - ArrayVectorFloat csrc = (ArrayVectorFloat) src; - System.arraycopy(csrc.data, srcOffset, data, destOffset, length); + if (src instanceof ArrayVectorFloat) { + System.arraycopy(((ArrayVectorFloat) src).data, srcOffset, data, destOffset, length); + return; + } + for (int i = 0; i < length; i++) { + data[destOffset + i] = src.get(srcOffset + i); + } } @Override diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/vector/BufferVectorFloat.java b/jvector-base/src/main/java/io/github/jbellis/jvector/vector/BufferVectorFloat.java new file mode 100644 index 000000000..cf0757df0 --- /dev/null +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/vector/BufferVectorFloat.java @@ -0,0 +1,255 @@ +/* + * Copyright DataStax, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.github.jbellis.jvector.vector; + +import io.github.jbellis.jvector.disk.IndexWriter; +import io.github.jbellis.jvector.util.RamUsageEstimator; +import io.github.jbellis.jvector.vector.types.VectorFloat; + +import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.util.Objects; + +/** + * VectorFloat implementation backed by a caller-owned {@link ByteBuffer}. + * + *

Storage is not copied on construction: the returned instance is a view over the + * supplied buffer. This lets callers that already hold vector data as ByteBuffer (for example + * a database reading rows off disk) feed jvector without the usual {@code float[]} allocation + * and copy. The buffer's current {@link ByteOrder} is preserved and honored on every element + * access, so both little- and big-endian layouts work. + * + *

Element {@code i} is read as {@code data.getFloat(byteOffset + i * Float.BYTES)} where + * {@code byteOffset} is fixed at construction and does not depend on the buffer's mutable + * position/limit. This makes the view safe to share across threads that only read it, and + * robust against callers that continue to position/limit the underlying buffer. + * + *

The Panama SIMD backend detects instances of this class and dispatches to + * {@code FloatVector.fromMemorySegment(species, MemorySegment.ofBuffer(data), byteOffset, order)} + * so SIMD is preserved with no {@code float[]} materialization. + */ +public final class BufferVectorFloat implements VectorFloat +{ + private final ByteBuffer data; + private final int byteOffset; + private final int floatLength; + + /** + * Zero-filled allocation; behaves like {@code new float[length]}. Buffer is native-endian + * and on-heap. + */ + public BufferVectorFloat(int length) + { + if (length < 0) { + throw new IllegalArgumentException("length must be >= 0, was " + length); + } + this.data = ByteBuffer.allocate(length * Float.BYTES).order(ByteOrder.nativeOrder()); + this.byteOffset = 0; + this.floatLength = length; + } + + /** + * Wrap the entire remaining contents of {@code data} as a float vector view. The buffer's + * {@code remaining()} must be a multiple of {@code Float.BYTES}. + */ + public BufferVectorFloat(ByteBuffer data) + { + this(data, 0, checkedFloatRemaining(data)); + } + + private static int checkedFloatRemaining(ByteBuffer data) + { + Objects.requireNonNull(data, "data"); + if ((data.remaining() % Float.BYTES) != 0) { + throw new IllegalArgumentException( + "ByteBuffer remaining() must be a multiple of Float.BYTES, was " + data.remaining()); + } + return data.remaining() / Float.BYTES; + } + + /** + * Sub-range wrap. {@code floatOffset} and {@code floatLength} are expressed in floats, + * relative to the buffer's current {@code position()}. + * + *

The returned view takes an independent {@link ByteBuffer#slice() slice} of the caller's + * buffer; this lets later mutation of the caller's {@code position}/{@code limit} leave the + * view intact, and it keeps the base byte offset inside the view stable at zero so that + * {@code MemorySegment.ofBuffer(view)} can be called without further per-access adjustment + * in the Panama SIMD path. + */ + public BufferVectorFloat(ByteBuffer data, int floatOffset, int floatLength) + { + Objects.requireNonNull(data, "data"); + if (floatOffset < 0 || floatLength < 0) { + throw new IllegalArgumentException("offset/length must be >= 0"); + } + long byteEnd = (long) floatOffset * Float.BYTES + (long) floatLength * Float.BYTES; + if (byteEnd > data.remaining()) { + throw new IllegalArgumentException( + "view [" + floatOffset + "," + (floatOffset + floatLength) + + ") floats exceeds buffer remaining=" + data.remaining() + " bytes"); + } + ByteBuffer dup = data.duplicate().order(data.order()); + int startByte = data.position() + floatOffset * Float.BYTES; + int endByte = startByte + floatLength * Float.BYTES; + dup.position(startByte).limit(endByte); + this.data = dup.slice().order(data.order()); + this.byteOffset = 0; + this.floatLength = floatLength; + } + + /** + * @return the backing buffer. Mutating its position/limit does not affect element access, + * but mutating its contents does. + */ + @Override + public ByteBuffer get() + { + return data; + } + + /** @return byte offset at which this view's element 0 lives within the backing buffer. */ + public int byteOffset() + { + return byteOffset; + } + + /** @return the byte order the backing buffer is read with. */ + public ByteOrder byteOrder() + { + return data.order(); + } + + @Override + public float get(int i) + { + return data.getFloat(byteOffset + i * Float.BYTES); + } + + @Override + public void set(int i, float value) + { + data.putFloat(byteOffset + i * Float.BYTES, value); + } + + @Override + public void zero() + { + for (int i = 0; i < floatLength; i++) { + data.putFloat(byteOffset + i * Float.BYTES, 0f); + } + } + + @Override + public int length() + { + return floatLength; + } + + @Override + public void writeTo(IndexWriter writer) throws IOException + { + for (int i = 0; i < floatLength; i++) { + writer.writeFloat(get(i)); + } + } + + @Override + public VectorFloat copy() + { + ByteBuffer owned = ByteBuffer.allocate(floatLength * Float.BYTES).order(data.order()); + ByteBuffer srcView = data.duplicate().order(data.order()); + srcView.position(byteOffset).limit(byteOffset + floatLength * Float.BYTES); + owned.put(srcView); + owned.rewind(); + return new BufferVectorFloat(owned, 0, floatLength); + } + + @Override + public void copyFrom(VectorFloat src, int srcOffset, int destOffset, int length) + { + if (src instanceof BufferVectorFloat) { + BufferVectorFloat bsrc = (BufferVectorFloat) src; + if (bsrc.byteOrder() == this.byteOrder()) { + ByteBuffer srcDup = bsrc.data.duplicate().order(bsrc.data.order()); + int srcStart = bsrc.byteOffset + srcOffset * Float.BYTES; + int bytes = length * Float.BYTES; + srcDup.position(srcStart).limit(srcStart + bytes); + ByteBuffer destDup = this.data.duplicate().order(this.data.order()); + destDup.position(this.byteOffset + destOffset * Float.BYTES); + destDup.put(srcDup); + return; + } + } else if (src instanceof ArrayVectorFloat) { + float[] a = ((ArrayVectorFloat) src).get(); + for (int i = 0; i < length; i++) { + set(destOffset + i, a[srcOffset + i]); + } + return; + } + for (int i = 0; i < length; i++) { + set(destOffset + i, src.get(srcOffset + i)); + } + } + + @Override + public long ramBytesUsed() + { + long OH_BYTES = RamUsageEstimator.NUM_BYTES_OBJECT_HEADER; + long REF_BYTES = RamUsageEstimator.NUM_BYTES_OBJECT_REF; + return OH_BYTES + REF_BYTES + 2L * Integer.BYTES + (long) floatLength * Float.BYTES; + } + + @Override + public String toString() + { + StringBuilder sb = new StringBuilder(); + sb.append("["); + int preview = Math.min(floatLength, 25); + for (int i = 0; i < preview; i++) { + sb.append(get(i)); + if (i < floatLength - 1) { + sb.append(", "); + } + } + if (floatLength > 25) { + sb.append("..."); + } + sb.append("]"); + return sb.toString(); + } + + @Override + public boolean equals(Object o) + { + if (this == o) return true; + if (!(o instanceof VectorFloat)) return false; + VectorFloat that = (VectorFloat) o; + if (that.length() != this.floatLength) return false; + for (int i = 0; i < floatLength; i++) { + if (Float.floatToIntBits(get(i)) != Float.floatToIntBits(that.get(i))) return false; + } + return true; + } + + @Override + public int hashCode() + { + return this.getHashCode(); + } +} diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/vector/DefaultVectorUtilSupport.java b/jvector-base/src/main/java/io/github/jbellis/jvector/vector/DefaultVectorUtilSupport.java index 5843dc5f6..214cca02d 100644 --- a/jvector-base/src/main/java/io/github/jbellis/jvector/vector/DefaultVectorUtilSupport.java +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/vector/DefaultVectorUtilSupport.java @@ -37,6 +37,15 @@ final class DefaultVectorUtilSupport implements VectorUtilSupport { @Override public float dotProduct(VectorFloat av, VectorFloat bv) { + if (!(av instanceof ArrayVectorFloat) || !(bv instanceof ArrayVectorFloat)) { + // generic path: any VectorFloat impl (BufferVectorFloat, MemorySegmentVectorFloat, ...) + float res = 0f; + int len = av.length(); + for (int i = 0; i < len; i++) { + res += av.get(i) * bv.get(i); + } + return res; + } float[] a = ((ArrayVectorFloat) av).get(); float[] b = ((ArrayVectorFloat) bv).get(); @@ -107,6 +116,13 @@ public float dotProduct(VectorFloat av, VectorFloat bv) { @Override public float dotProduct(VectorFloat av, int aoffset, VectorFloat bv, int boffset, int length) { + if (!(av instanceof ArrayVectorFloat) || !(bv instanceof ArrayVectorFloat)) { + float sum = 0f; + for (int i = 0; i < length; i++) { + sum += av.get(aoffset + i) * bv.get(boffset + i); + } + return sum; + } float[] b = ((ArrayVectorFloat) bv).get(); float[] a = ((ArrayVectorFloat) av).get(); @@ -120,6 +136,17 @@ public float dotProduct(VectorFloat av, int aoffset, VectorFloat bv, int b @Override public float cosine(VectorFloat av, VectorFloat bv) { + if (!(av instanceof ArrayVectorFloat) || !(bv instanceof ArrayVectorFloat)) { + float sum = 0.0f, norm1 = 0.0f, norm2 = 0.0f; + int dim = av.length(); + for (int i = 0; i < dim; i++) { + float e1 = av.get(i), e2 = bv.get(i); + sum += e1 * e2; + norm1 += e1 * e1; + norm2 += e2 * e2; + } + return (float) (sum / Math.sqrt(norm1 * norm2)); + } float[] a = ((ArrayVectorFloat) av).get(); float[] b = ((ArrayVectorFloat) bv).get(); @@ -140,6 +167,16 @@ public float cosine(VectorFloat av, VectorFloat bv) { @Override public float cosine(VectorFloat av, int aoffset, VectorFloat bv, int boffset, int length) { + if (!(av instanceof ArrayVectorFloat) || !(bv instanceof ArrayVectorFloat)) { + float sum = 0.0f, norm1 = 0.0f, norm2 = 0.0f; + for (int i = 0; i < length; i++) { + float e1 = av.get(aoffset + i), e2 = bv.get(boffset + i); + sum += e1 * e2; + norm1 += e1 * e1; + norm2 += e2 * e2; + } + return (float) (sum / Math.sqrt(norm1 * norm2)); + } float[] a = ((ArrayVectorFloat) av).get(); float[] b = ((ArrayVectorFloat) bv).get(); float sum = 0.0f; @@ -157,6 +194,15 @@ public float cosine(VectorFloat av, int aoffset, VectorFloat bv, int boffs @Override public float squareDistance(VectorFloat av, VectorFloat bv) { + if (!(av instanceof ArrayVectorFloat) || !(bv instanceof ArrayVectorFloat)) { + float squareSum = 0.0f; + int dim = av.length(); + for (int i = 0; i < dim; i++) { + float diff = av.get(i) - bv.get(i); + squareSum += diff * diff; + } + return squareSum; + } float[] a = ((ArrayVectorFloat) av).get(); float[] b = ((ArrayVectorFloat) bv).get(); @@ -195,6 +241,14 @@ private static float squareDistanceUnrolled(float[] v1, float[] v2, int index) { @Override public float squareDistance(VectorFloat av, int aoffset, VectorFloat bv, int boffset, int length) { + if (!(av instanceof ArrayVectorFloat) || !(bv instanceof ArrayVectorFloat)) { + float squareSum = 0f; + for (int i = 0; i < length; i++) { + float diff = av.get(aoffset + i) - bv.get(boffset + i); + squareSum += diff * diff; + } + return squareSum; + } float[] a = ((ArrayVectorFloat) av).get(); float[] b = ((ArrayVectorFloat) bv).get(); diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/vector/types/VectorTypeSupport.java b/jvector-base/src/main/java/io/github/jbellis/jvector/vector/types/VectorTypeSupport.java index a4cb4f8af..61c9b1069 100644 --- a/jvector-base/src/main/java/io/github/jbellis/jvector/vector/types/VectorTypeSupport.java +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/vector/types/VectorTypeSupport.java @@ -18,15 +18,27 @@ import io.github.jbellis.jvector.disk.IndexWriter; import io.github.jbellis.jvector.disk.RandomAccessReader; +import io.github.jbellis.jvector.vector.BufferVectorFloat; import java.io.IOException; +import java.nio.ByteBuffer; public interface VectorTypeSupport { /** * Create a vector from the given data. * - * @param data the data to create the vector from. Supported data types are implementation-dependent. + * @param data the data to create the vector from. Supported data types are implementation-dependent + * (typically {@code float[]}; some providers also accept {@link java.nio.Buffer}). + * This method takes ownership of the data: for array inputs, most providers + * wrap the array without copying, so later mutation of the caller's array is visible. + * For {@code Buffer} inputs the behavior is provider-specific (some copy, some wrap). * @return the created vector. + * + *

Prefer the typed factories when possible: + * {@link #wrapFloatVector(ByteBuffer)} gives a zero-copy borrow from a ByteBuffer, and + * {@link #createFloatVector(int)} returns an owned, zero-filled allocation. The + * {@code Object} parameter here is unsafe — a wrong runtime type triggers a + * {@code ClassCastException} inside the provider. */ VectorFloat createFloatVector(Object data); @@ -37,6 +49,38 @@ public interface VectorTypeSupport { */ VectorFloat createFloatVector(int length); + /** + * Wrap a caller-owned {@link ByteBuffer} as a float vector view without copying. The buffer + * is treated as a contiguous sequence of IEEE 754 floats laid out in its current + * {@link java.nio.ByteOrder}; the buffer's position acts as the starting byte offset. + * + *

Semantically this is the zero-copy counterpart to {@link #createFloatVector(Object)}: + * {@code create} takes ownership of storage (copying if necessary); {@code wrap} borrows it. + * + *

Callers must not deallocate / re-purpose the backing buffer while the returned view is + * in use. Mutating the buffer's position and limit after construction does not disturb the + * view; mutating the buffer's content does. + */ + default VectorFloat wrapFloatVector(ByteBuffer data) { + if ((data.remaining() % Float.BYTES) != 0) { + throw new IllegalArgumentException( + "ByteBuffer remaining() must be a multiple of Float.BYTES, was " + data.remaining()); + } + return wrapFloatVector(data, 0, data.remaining() / Float.BYTES); + } + + /** + * Wrap a sub-range of a caller-owned {@link ByteBuffer} as a float vector view without + * copying. See {@link #wrapFloatVector(ByteBuffer)} for the copy contract. + * + * @param data backing buffer + * @param floatOffset starting offset in floats, relative to the buffer's current position + * @param floatLength number of floats in the resulting view + */ + default VectorFloat wrapFloatVector(ByteBuffer data, int floatOffset, int floatLength) { + return new BufferVectorFloat(data, floatOffset, floatLength); + } + /** * Read a vector from the given RandomAccessReader. * @param r the reader to read the vector from. diff --git a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/util/MMapRandomAccessVectorValues.java b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/util/MMapRandomAccessVectorValues.java index 0fda16df1..42672487b 100644 --- a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/util/MMapRandomAccessVectorValues.java +++ b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/util/MMapRandomAccessVectorValues.java @@ -16,77 +16,93 @@ package io.github.jbellis.jvector.example.util; -import com.indeed.util.mmap.MMapBuffer; +import io.github.jbellis.jvector.graph.ByteBufferRandomAccessVectorValues; import io.github.jbellis.jvector.graph.RandomAccessVectorValues; -import io.github.jbellis.jvector.vector.VectorizationProvider; import io.github.jbellis.jvector.vector.types.VectorFloat; -import io.github.jbellis.jvector.vector.types.VectorTypeSupport; import java.io.Closeable; import java.io.File; import java.io.IOError; import java.io.IOException; +import java.io.RandomAccessFile; import java.nio.ByteOrder; +import java.nio.MappedByteBuffer; import java.nio.channels.FileChannel; -public class MMapRandomAccessVectorValues implements RandomAccessVectorValues, Closeable { - private static final VectorTypeSupport vectorTypeSupport = VectorizationProvider.getInstance().getVectorTypeSupport(); - final int dimension; - final int rows; - final File file; - final float[] valueBuffer; - - final MMapBuffer fileReader; +/** + * RAVV backed by a memory-mapped file of contiguous little-endian IEEE 754 floats. + * + *

Unlike the previous implementation — which copied each row into an on-heap + * {@code float[dimension]} scratch buffer on every {@link #getVector} call — this version + * delegates to {@link ByteBufferRandomAccessVectorValues} over the {@link MappedByteBuffer}, + * so reads are served directly from the mmap region with no per-call {@code float[]} + * allocation. + */ +public class MMapRandomAccessVectorValues implements RandomAccessVectorValues, Closeable +{ + private final int dimension; + private final File file; + private final RandomAccessFile raf; + private final FileChannel channel; + private final ByteBufferRandomAccessVectorValues delegate; - public MMapRandomAccessVectorValues(File f, int dimension) { + public MMapRandomAccessVectorValues(File f, int dimension) + { assert f != null && f.exists() && f.canRead(); - assert f.length() % ((long) dimension * Float.BYTES) == 0; + long bytesPerVector = (long) dimension * Float.BYTES; + assert f.length() % bytesPerVector == 0; try { this.file = f; - this.fileReader = new MMapBuffer(f, FileChannel.MapMode.READ_ONLY, ByteOrder.LITTLE_ENDIAN); this.dimension = dimension; - this.rows = ((int) f.length()) / dimension; - this.valueBuffer = new float[dimension]; + this.raf = new RandomAccessFile(f, "r"); + this.channel = raf.getChannel(); + long size = f.length(); + MappedByteBuffer mapped = channel.map(FileChannel.MapMode.READ_ONLY, 0, size); + mapped.order(ByteOrder.LITTLE_ENDIAN); + int count = (int) (size / bytesPerVector); + this.delegate = new ByteBufferRandomAccessVectorValues(mapped, count, dimension); } catch (IOException e) { throw new IOError(e); } } @Override - public int size() { - return (int) (file.length() / ((long) dimension * Float.BYTES)); + public int size() + { + return delegate.size(); } @Override - public int dimension() { + public int dimension() + { return dimension; } @Override - public VectorFloat getVector(int targetOrd) { - long offset = (long) targetOrd * dimension * Float.BYTES; - int i = 0; - for (long o = offset; o < offset + ((long) dimension * Float.BYTES); o += Float.BYTES, i++) - valueBuffer[i] = fileReader.memory().getFloat(o); - - return vectorTypeSupport.createFloatVector(valueBuffer); + public VectorFloat getVector(int targetOrd) + { + return delegate.getVector(targetOrd); } @Override - public boolean isValueShared() { - return false; + public boolean isValueShared() + { + return delegate.isValueShared(); } @Override - public RandomAccessVectorValues copy() { + public RandomAccessVectorValues copy() + { return new MMapRandomAccessVectorValues(file, dimension); } @Override - public void close() { + public void close() + { try { - this.fileReader.close(); + channel.close(); + raf.close(); } catch (IOException e) { throw new IOError(e); } diff --git a/jvector-examples/src/test/java/io/github/jbellis/jvector/example/util/MMapRandomAccessVectorValuesTest.java b/jvector-examples/src/test/java/io/github/jbellis/jvector/example/util/MMapRandomAccessVectorValuesTest.java new file mode 100644 index 000000000..713f2fee1 --- /dev/null +++ b/jvector-examples/src/test/java/io/github/jbellis/jvector/example/util/MMapRandomAccessVectorValuesTest.java @@ -0,0 +1,66 @@ +/* + * Copyright DataStax, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.github.jbellis.jvector.example.util; + +import io.github.jbellis.jvector.vector.types.VectorFloat; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +import java.io.File; +import java.io.IOException; +import java.io.RandomAccessFile; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.util.Random; + +import static org.junit.Assert.assertEquals; + +public class MMapRandomAccessVectorValuesTest +{ + @Rule + public TemporaryFolder tmp = new TemporaryFolder(); + + @Test + public void readsMatchSourceDataWithNoFloatArrayPerCall() throws IOException + { + int count = 100, dim = 16; + Random r = new Random(2026); + float[][] source = new float[count][dim]; + for (int i = 0; i < count; i++) { + for (int j = 0; j < dim; j++) source[i][j] = r.nextFloat() * 2f - 1f; + } + + File f = tmp.newFile("vectors.bin"); + ByteBuffer bb = ByteBuffer.allocate(count * dim * Float.BYTES).order(ByteOrder.LITTLE_ENDIAN); + for (float[] v : source) for (float x : v) bb.putFloat(x); + bb.rewind(); + try (RandomAccessFile raf = new RandomAccessFile(f, "rw")) { + raf.getChannel().write(bb); + } + + try (MMapRandomAccessVectorValues ravv = new MMapRandomAccessVectorValues(f, dim)) { + assertEquals(count, ravv.size()); + assertEquals(dim, ravv.dimension()); + for (int ord = 0; ord < count; ord++) { + VectorFloat v = ravv.getVector(ord); + for (int i = 0; i < dim; i++) { + assertEquals(source[ord][i], v.get(i), 0f); + } + } + } + } +} diff --git a/jvector-native/src/main/java/io/github/jbellis/jvector/vector/MemorySegmentVectorFloat.java b/jvector-native/src/main/java/io/github/jbellis/jvector/vector/MemorySegmentVectorFloat.java index 3e3f67e1f..112668108 100644 --- a/jvector-native/src/main/java/io/github/jbellis/jvector/vector/MemorySegmentVectorFloat.java +++ b/jvector-native/src/main/java/io/github/jbellis/jvector/vector/MemorySegmentVectorFloat.java @@ -22,6 +22,7 @@ import java.io.IOException; import java.lang.foreign.MemorySegment; +import java.lang.foreign.ValueLayout; import java.nio.Buffer; /** @@ -35,6 +36,11 @@ final public class MemorySegmentVectorFloat implements VectorFloatcopies the buffer contents. To share storage without + * copying, use {@link #wrap(java.nio.ByteBuffer)} instead. + */ + @Deprecated MemorySegmentVectorFloat(Buffer buffer) { this(buffer.remaining()); segment.copyFrom(MemorySegment.ofBuffer(buffer)); @@ -44,6 +50,31 @@ final public class MemorySegmentVectorFloat implements VectorFloatIn contrast to {@link #MemorySegmentVectorFloat(Buffer)} which eagerly copies into a + * fresh segment, this factory wraps the buffer's storage in place via + * {@link MemorySegment#ofBuffer(java.nio.Buffer)}. + */ + public static MemorySegmentVectorFloat wrap(java.nio.ByteBuffer buffer) { + if ((buffer.remaining() % Float.BYTES) != 0) { + throw new IllegalArgumentException( + "ByteBuffer remaining() must be a multiple of Float.BYTES, was " + buffer.remaining()); + } + if (buffer.order() != java.nio.ByteOrder.LITTLE_ENDIAN) { + throw new IllegalArgumentException( + "Native SIMD path requires ByteOrder.LITTLE_ENDIAN, got " + buffer.order()); + } + return new MemorySegmentVectorFloat(MemorySegment.ofBuffer(buffer)); + } + @Override public long ramBytesUsed() { @@ -61,15 +92,25 @@ public MemorySegment get() @Override public float get(int n) { - // this is (unfortunately) meaningfully better performing than getting at an offset in the memory segment - return ((float[])segment.heapBase().get())[n]; + // Fast path for on-heap segments: direct float[] indexing is meaningfully faster than + // the generic MemorySegment accessors. Fall back to segment access for off-heap buffers + // (e.g., native memory, direct ByteBuffers) where heapBase() is empty. + var heap = segment.heapBase(); + if (heap.isPresent()) { + return ((float[]) heap.get())[n]; + } + return segment.getAtIndex(ValueLayout.JAVA_FLOAT, n); } @Override public void set(int n, float value) { - // this is (unfortunately) meaningfully better performing than setting at an offset in the memory segment - ((float[])segment.heapBase().get())[n] = value; + var heap = segment.heapBase(); + if (heap.isPresent()) { + ((float[]) heap.get())[n] = value; + return; + } + segment.setAtIndex(ValueLayout.JAVA_FLOAT, n, value); } @Override @@ -106,9 +147,16 @@ public VectorFloat copy() @Override public void copyFrom(VectorFloat src, int srcOffset, int destOffset, int length) { - MemorySegmentVectorFloat csrc = (MemorySegmentVectorFloat) src; - segment.asSlice((long) destOffset * Float.BYTES, (long) length * Float.BYTES) - .copyFrom(csrc.segment.asSlice((long) srcOffset * Float.BYTES, (long) length * Float.BYTES)); + if (src instanceof MemorySegmentVectorFloat) { + MemorySegmentVectorFloat csrc = (MemorySegmentVectorFloat) src; + segment.asSlice((long) destOffset * Float.BYTES, (long) length * Float.BYTES) + .copyFrom(csrc.segment.asSlice((long) srcOffset * Float.BYTES, (long) length * Float.BYTES)); + return; + } + // generic fallback for ArrayVectorFloat, BufferVectorFloat, or any other VectorFloat impl + for (int i = 0; i < length; i++) { + set(destOffset + i, src.get(srcOffset + i)); + } } @Override diff --git a/jvector-native/src/main/java/io/github/jbellis/jvector/vector/MemorySegmentVectorProvider.java b/jvector-native/src/main/java/io/github/jbellis/jvector/vector/MemorySegmentVectorProvider.java index 1ce0d81b2..41f216a29 100644 --- a/jvector-native/src/main/java/io/github/jbellis/jvector/vector/MemorySegmentVectorProvider.java +++ b/jvector-native/src/main/java/io/github/jbellis/jvector/vector/MemorySegmentVectorProvider.java @@ -24,6 +24,7 @@ import java.io.IOException; import java.nio.Buffer; +import java.nio.ByteBuffer; /** * VectorTypeSupport using MemorySegments. @@ -45,6 +46,24 @@ public VectorFloat createFloatVector(int length) return new MemorySegmentVectorFloat(length); } + /** + * Zero-copy wrap that returns a MemorySegment-backed view when the buffer's layout matches + * the native SIMD contract (little-endian), so {@code FloatVector.fromMemorySegment} can + * run on it directly. Big-endian buffers fall through to the base {@link BufferVectorFloat} + * view — still zero-copy but dispatched through the Panama polymorphic path. + */ + @Override + public VectorFloat wrapFloatVector(ByteBuffer data, int floatOffset, int floatLength) + { + if (data.order() != java.nio.ByteOrder.LITTLE_ENDIAN) { + return VectorTypeSupport.super.wrapFloatVector(data, floatOffset, floatLength); + } + ByteBuffer dup = data.duplicate().order(data.order()); + int startByte = data.position() + floatOffset * Float.BYTES; + dup.position(startByte).limit(startByte + floatLength * Float.BYTES); + return MemorySegmentVectorFloat.wrap(dup); + } + @Override public VectorFloat readFloatVector(RandomAccessReader r, int size) throws IOException { diff --git a/jvector-native/src/main/java/io/github/jbellis/jvector/vector/NativeVectorUtilSupport.java b/jvector-native/src/main/java/io/github/jbellis/jvector/vector/NativeVectorUtilSupport.java index 48cd7d66e..d90b7b80e 100644 --- a/jvector-native/src/main/java/io/github/jbellis/jvector/vector/NativeVectorUtilSupport.java +++ b/jvector-native/src/main/java/io/github/jbellis/jvector/vector/NativeVectorUtilSupport.java @@ -38,32 +38,53 @@ public NativeVectorUtilSupport() {} @Override protected FloatVector fromVectorFloat(VectorSpecies SPEC, VectorFloat vector, int offset) { - return FloatVector.fromMemorySegment(SPEC, ((MemorySegmentVectorFloat) vector).get(), vector.offset(offset), ByteOrder.LITTLE_ENDIAN); + if (vector instanceof MemorySegmentVectorFloat) { + return FloatVector.fromMemorySegment(SPEC, ((MemorySegmentVectorFloat) vector).get(), vector.offset(offset), ByteOrder.LITTLE_ENDIAN); + } + return super.fromVectorFloat(SPEC, vector, offset); } @Override protected FloatVector fromVectorFloat(VectorSpecies SPEC, VectorFloat vector, int offset, int[] indices, int indicesOffset) { - throw new UnsupportedOperationException("Assembly not supported with memory segments."); + if (vector instanceof MemorySegmentVectorFloat) { + throw new UnsupportedOperationException("Assembly not supported with memory segments."); + } + return super.fromVectorFloat(SPEC, vector, offset, indices, indicesOffset); } @Override protected void intoVectorFloat(FloatVector vector, VectorFloat v, int offset) { - vector.intoMemorySegment(((MemorySegmentVectorFloat) v).get(), v.offset(offset), ByteOrder.LITTLE_ENDIAN); + if (v instanceof MemorySegmentVectorFloat) { + vector.intoMemorySegment(((MemorySegmentVectorFloat) v).get(), v.offset(offset), ByteOrder.LITTLE_ENDIAN); + return; + } + super.intoVectorFloat(vector, v, offset); } @Override protected ByteVector fromByteSequence(VectorSpecies SPEC, ByteSequence vector, int offset) { - return ByteVector.fromMemorySegment(SPEC, ((MemorySegmentByteSequence) vector).get(), offset, ByteOrder.LITTLE_ENDIAN); + if (vector instanceof MemorySegmentByteSequence) { + return ByteVector.fromMemorySegment(SPEC, ((MemorySegmentByteSequence) vector).get(), offset, ByteOrder.LITTLE_ENDIAN); + } + return super.fromByteSequence(SPEC, vector, offset); } @Override protected void intoByteSequence(ByteVector vector, ByteSequence v, int offset) { - vector.intoMemorySegment(((MemorySegmentByteSequence) v).get(), offset, ByteOrder.LITTLE_ENDIAN); + if (v instanceof MemorySegmentByteSequence) { + vector.intoMemorySegment(((MemorySegmentByteSequence) v).get(), offset, ByteOrder.LITTLE_ENDIAN); + return; + } + super.intoByteSequence(vector, v, offset); } @Override protected void intoByteSequence(ByteVector vector, ByteSequence v, int offset, VectorMask mask) { - vector.intoMemorySegment(((MemorySegmentByteSequence) v).get(), offset, ByteOrder.LITTLE_ENDIAN, mask); + if (v instanceof MemorySegmentByteSequence) { + vector.intoMemorySegment(((MemorySegmentByteSequence) v).get(), offset, ByteOrder.LITTLE_ENDIAN, mask); + return; + } + super.intoByteSequence(vector, v, offset, mask); } @Override diff --git a/jvector-native/src/test/java/io/github/jbellis/jvector/vector/MemorySegmentVectorFloatWrapTest.java b/jvector-native/src/test/java/io/github/jbellis/jvector/vector/MemorySegmentVectorFloatWrapTest.java new file mode 100644 index 000000000..3a6b3d363 --- /dev/null +++ b/jvector-native/src/test/java/io/github/jbellis/jvector/vector/MemorySegmentVectorFloatWrapTest.java @@ -0,0 +1,71 @@ +/* + * Copyright DataStax, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.github.jbellis.jvector.vector; + +import org.junit.jupiter.api.Test; + +import java.nio.ByteBuffer; +import java.nio.ByteOrder; + +import static org.junit.jupiter.api.Assertions.*; + +class MemorySegmentVectorFloatWrapTest +{ + @Test + void wrapAliasesBufferStorage() + { + ByteBuffer bb = ByteBuffer.allocateDirect(4 * Float.BYTES).order(ByteOrder.LITTLE_ENDIAN); + for (int i = 0; i < 4; i++) bb.putFloat(i * 2f); + bb.rewind(); + + MemorySegmentVectorFloat v = MemorySegmentVectorFloat.wrap(bb); + assertEquals(4, v.length()); + assertEquals(0f, v.get(0), 0f); + assertEquals(6f, v.get(3), 0f); + + // mutate through the original ByteBuffer, observe the change through the view + bb.putFloat(0, 99f); + assertEquals(99f, v.get(0), 0f, "wrap must not copy — shared storage"); + } + + @Test + void wrapRejectsBigEndian() + { + ByteBuffer be = ByteBuffer.allocate(4 * Float.BYTES).order(ByteOrder.BIG_ENDIAN); + assertThrows(IllegalArgumentException.class, () -> MemorySegmentVectorFloat.wrap(be)); + } + + @Test + void wrapRejectsNonFloatAlignedRemaining() + { + ByteBuffer odd = ByteBuffer.allocate(7).order(ByteOrder.LITTLE_ENDIAN); + assertThrows(IllegalArgumentException.class, () -> MemorySegmentVectorFloat.wrap(odd)); + } + + @Test + void legacyCopyingConstructorStillCopies() + { + ByteBuffer bb = ByteBuffer.allocate(4 * Float.BYTES).order(ByteOrder.LITTLE_ENDIAN); + for (int i = 0; i < 4; i++) bb.putFloat(i * 2f); + bb.rewind(); + + @SuppressWarnings("deprecation") + MemorySegmentVectorFloat v = new MemorySegmentVectorFloat(bb); + // Mutating bb must NOT change the copy — proving the semantics of the old ctor. + bb.putFloat(0, 999f); + assertEquals(0f, v.get(0), 0f, "copying constructor must not alias"); + } +} diff --git a/jvector-tests/src/test/java/io/github/jbellis/jvector/graph/BuildFromByteBufferEquivalenceTest.java b/jvector-tests/src/test/java/io/github/jbellis/jvector/graph/BuildFromByteBufferEquivalenceTest.java new file mode 100644 index 000000000..b96354813 --- /dev/null +++ b/jvector-tests/src/test/java/io/github/jbellis/jvector/graph/BuildFromByteBufferEquivalenceTest.java @@ -0,0 +1,143 @@ +/* + * Copyright DataStax, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.github.jbellis.jvector.graph; + +import io.github.jbellis.jvector.TestUtil; +import io.github.jbellis.jvector.graph.similarity.BuildScoreProvider; +import io.github.jbellis.jvector.util.Bits; +import io.github.jbellis.jvector.vector.VectorSimilarityFunction; +import io.github.jbellis.jvector.vector.VectorizationProvider; +import io.github.jbellis.jvector.vector.types.VectorFloat; +import io.github.jbellis.jvector.vector.types.VectorTypeSupport; +import org.junit.Test; + +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.util.ArrayList; +import java.util.List; +import java.util.Random; + +import static org.junit.Assert.assertEquals; + +/** + * Proves that building a graph from a caller-owned {@link ByteBuffer} produces the same + * structure as building from a {@code float[]}-based RAVV using the same seeded data. This + * is the end-to-end check that the ByteBuffer migration preserves semantics across the + * full build hot path (VectorFloat abstraction, SIMD dispatch, score provider, diversity + * pruning, etc.). + * + *

Runs under whichever vectorization provider is active (Default / Panama / Native) + * — so the same test catches regressions across all SIMD backends. + */ +public class BuildFromByteBufferEquivalenceTest +{ + private static final VectorTypeSupport VTS = + VectorizationProvider.getInstance().getVectorTypeSupport(); + + private static float[][] generate(long seed, int count, int dim) + { + Random r = new Random(seed); + float[][] data = new float[count][dim]; + for (int i = 0; i < count; i++) { + for (int j = 0; j < dim; j++) { + data[i][j] = r.nextFloat() * 2f - 1f; + } + } + return data; + } + + private static ByteBuffer toLittleEndian(float[][] data) + { + int count = data.length, dim = data[0].length; + ByteBuffer bb = ByteBuffer.allocate(count * dim * Float.BYTES).order(ByteOrder.LITTLE_ENDIAN); + for (float[] v : data) for (float f : v) bb.putFloat(f); + bb.rewind(); + return bb; + } + + private static List> toVectorFloatList(float[][] data) + { + List> out = new ArrayList<>(data.length); + for (float[] v : data) { + out.add(VTS.createFloatVector(java.util.Arrays.copyOf(v, v.length))); + } + return out; + } + + private void assertEquivalent(int count, int dim, VectorSimilarityFunction vsf, long seed) + throws Exception + { + float[][] data = generate(seed, count, dim); + + // A: classic float[] build + RandomAccessVectorValues ravvA = new ListRandomAccessVectorValues(toVectorFloatList(data), dim); + var bspA = BuildScoreProvider.randomAccessScoreProvider(ravvA, vsf); + GraphIndexBuilder builderA = new GraphIndexBuilder(bspA, dim, 16, 100, 1.2f, 1.2f, false); + ImmutableGraphIndex graphA = TestUtil.buildSequentially(builderA, ravvA); + + // B: zero-copy ByteBuffer build + ByteBuffer bb = toLittleEndian(data); + RandomAccessVectorValues ravvB = new ByteBufferRandomAccessVectorValues(bb, count, dim); + var bspB = BuildScoreProvider.randomAccessScoreProvider(ravvB, vsf); + GraphIndexBuilder builderB = new GraphIndexBuilder(bspB, dim, 16, 100, 1.2f, 1.2f, false); + ImmutableGraphIndex graphB = TestUtil.buildSequentially(builderB, ravvB); + + TestUtil.assertGraphEquals(graphA, graphB); + + // Query a handful of points against each and assert identical ordering and scores. + Random qr = new Random(~seed); + for (int q = 0; q < 10; q++) { + int queryOrd = qr.nextInt(count); + var queryFromList = ravvA.getVector(queryOrd); + var queryFromBB = ravvB.getVector(queryOrd); + + var resA = GraphSearcher.search(queryFromList, 5, ravvA, vsf, graphA, Bits.ALL); + var resB = GraphSearcher.search(queryFromBB, 5, ravvB, vsf, graphB, Bits.ALL); + assertEquals("topK count differs", resA.getNodes().length, resB.getNodes().length); + for (int i = 0; i < resA.getNodes().length; i++) { + assertEquals("node ordering mismatch at i=" + i, resA.getNodes()[i].node, resB.getNodes()[i].node); + assertEquals("score mismatch at i=" + i, resA.getNodes()[i].score, resB.getNodes()[i].score, 1e-5f); + } + } + + builderA.close(); + builderB.close(); + } + + @Test + public void euclideanEquivalence() throws Exception + { + assertEquivalent(200, 64, VectorSimilarityFunction.EUCLIDEAN, 0xC001_C0DEL); + } + + @Test + public void dotProductEquivalence() throws Exception + { + assertEquivalent(150, 48, VectorSimilarityFunction.DOT_PRODUCT, 0xDEAD_BEEFL); + } + + @Test + public void cosineEquivalence() throws Exception + { + assertEquivalent(120, 32, VectorSimilarityFunction.COSINE, 0xABCDEF01L); + } + + @Test + public void largerDimensionEquivalence() throws Exception + { + assertEquivalent(64, 256, VectorSimilarityFunction.EUCLIDEAN, 0x5EED_5EEDL); + } +} diff --git a/jvector-tests/src/test/java/io/github/jbellis/jvector/graph/ByteBufferRandomAccessVectorValuesTest.java b/jvector-tests/src/test/java/io/github/jbellis/jvector/graph/ByteBufferRandomAccessVectorValuesTest.java new file mode 100644 index 000000000..5680c7f0f --- /dev/null +++ b/jvector-tests/src/test/java/io/github/jbellis/jvector/graph/ByteBufferRandomAccessVectorValuesTest.java @@ -0,0 +1,166 @@ +/* + * Copyright DataStax, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.github.jbellis.jvector.graph; + +import io.github.jbellis.jvector.vector.VectorizationProvider; +import io.github.jbellis.jvector.vector.types.VectorFloat; +import io.github.jbellis.jvector.vector.types.VectorTypeSupport; +import org.junit.jupiter.api.Test; + +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.util.ArrayList; +import java.util.List; +import java.util.Random; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.junit.jupiter.api.Assertions.*; + +class ByteBufferRandomAccessVectorValuesTest +{ + private static final VectorTypeSupport VTS = + VectorizationProvider.getInstance().getVectorTypeSupport(); + + private static float[][] randomData(long seed, int count, int dimension) + { + Random r = new Random(seed); + float[][] data = new float[count][dimension]; + for (int i = 0; i < count; i++) { + for (int j = 0; j < dimension; j++) { + data[i][j] = r.nextFloat() * 2f - 1f; + } + } + return data; + } + + private static ByteBuffer toByteBuffer(float[][] data, int dimension) + { + ByteBuffer bb = ByteBuffer.allocate(data.length * dimension * Float.BYTES) + .order(ByteOrder.LITTLE_ENDIAN); + for (float[] v : data) for (float f : v) bb.putFloat(f); + bb.rewind(); + return bb; + } + + @Test + void matchesListRAVVElementByElement() + { + int count = 100, dim = 32; + float[][] data = randomData(42, count, dim); + ByteBuffer bb = toByteBuffer(data, dim); + + ByteBufferRandomAccessVectorValues bbravv = new ByteBufferRandomAccessVectorValues(bb, count, dim); + List> list = new ArrayList<>(); + for (float[] v : data) list.add(VTS.createFloatVector(v)); + ListRandomAccessVectorValues listRavv = new ListRandomAccessVectorValues(list, dim); + + assertEquals(count, bbravv.size()); + assertEquals(dim, bbravv.dimension()); + + for (int ord = 0; ord < count; ord++) { + VectorFloat a = bbravv.getVector(ord); + VectorFloat b = listRavv.getVector(ord); + assertEquals(b.length(), a.length()); + for (int i = 0; i < dim; i++) { + assertEquals(b.get(i), a.get(i), 0f, "mismatch at ord=" + ord + " i=" + i); + } + } + } + + @Test + void rejectsOutOfRangeOrdinal() + { + ByteBuffer bb = ByteBuffer.allocate(10 * 4 * 4).order(ByteOrder.LITTLE_ENDIAN); + ByteBufferRandomAccessVectorValues ravv = new ByteBufferRandomAccessVectorValues(bb, 10, 4); + assertThrows(IndexOutOfBoundsException.class, () -> ravv.getVector(-1)); + assertThrows(IndexOutOfBoundsException.class, () -> ravv.getVector(10)); + } + + @Test + void rejectsUndersizedBuffer() + { + ByteBuffer tooSmall = ByteBuffer.allocate(10).order(ByteOrder.LITTLE_ENDIAN); + assertThrows(IllegalArgumentException.class, + () -> new ByteBufferRandomAccessVectorValues(tooSmall, 100, 4)); + } + + @Test + void survivesCallerBufferPositionMutation() + { + int count = 5, dim = 4; + float[][] data = randomData(7, count, dim); + ByteBuffer bb = toByteBuffer(data, dim); + + ByteBufferRandomAccessVectorValues ravv = new ByteBufferRandomAccessVectorValues(bb, count, dim); + // Caller fiddles with the source buffer after construction — must not disturb the view. + bb.position(bb.capacity()).limit(bb.capacity()); + VectorFloat v = ravv.getVector(2); + for (int i = 0; i < dim; i++) { + assertEquals(data[2][i], v.get(i), 0f); + } + } + + @Test + void threadLocalSupplierGivesIndependentWorkers() + throws InterruptedException + { + int count = 50, dim = 16; + float[][] data = randomData(11, count, dim); + ByteBuffer bb = toByteBuffer(data, dim); + + ByteBufferRandomAccessVectorValues ravv = new ByteBufferRandomAccessVectorValues(bb, count, dim); + ExecutorService exec = Executors.newFixedThreadPool(4); + CountDownLatch start = new CountDownLatch(1); + AtomicInteger mismatches = new AtomicInteger(); + for (int t = 0; t < 4; t++) { + exec.submit(() -> { + try { + start.await(); + RandomAccessVectorValues local = ravv.threadLocalSupplier().get(); + for (int r = 0; r < 2000; r++) { + int ord = r % count; + VectorFloat v = local.getVector(ord); + for (int i = 0; i < dim; i++) { + if (v.get(i) != data[ord][i]) mismatches.incrementAndGet(); + } + } + } catch (InterruptedException ignore) {} + }); + } + start.countDown(); + exec.shutdown(); + assertTrue(exec.awaitTermination(30, TimeUnit.SECONDS)); + assertEquals(0, mismatches.get(), "concurrent reads must agree with source data"); + } + + @Test + void fromFloatsFactoryRoundTrip() + { + int count = 8, dim = 5; + float[][] data = randomData(13, count, dim); + ByteBufferRandomAccessVectorValues ravv = ByteBufferRandomAccessVectorValues.fromFloats(data, dim); + assertEquals(count, ravv.size()); + assertEquals(dim, ravv.dimension()); + for (int ord = 0; ord < count; ord++) { + VectorFloat v = ravv.getVector(ord); + for (int i = 0; i < dim; i++) assertEquals(data[ord][i], v.get(i), 0f); + } + } +} diff --git a/jvector-tests/src/test/java/io/github/jbellis/jvector/graph/SearchAllocationProfileTest.java b/jvector-tests/src/test/java/io/github/jbellis/jvector/graph/SearchAllocationProfileTest.java new file mode 100644 index 000000000..9f82ef344 --- /dev/null +++ b/jvector-tests/src/test/java/io/github/jbellis/jvector/graph/SearchAllocationProfileTest.java @@ -0,0 +1,137 @@ +/* + * Copyright DataStax, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.github.jbellis.jvector.graph; + +import com.sun.management.ThreadMXBean; +import io.github.jbellis.jvector.TestUtil; +import io.github.jbellis.jvector.graph.similarity.BuildScoreProvider; +import io.github.jbellis.jvector.util.Bits; +import io.github.jbellis.jvector.vector.VectorSimilarityFunction; +import io.github.jbellis.jvector.vector.VectorizationProvider; +import io.github.jbellis.jvector.vector.types.VectorFloat; +import io.github.jbellis.jvector.vector.types.VectorTypeSupport; +import org.junit.Test; + +import java.lang.management.ManagementFactory; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.util.ArrayList; +import java.util.List; +import java.util.Random; + +import static org.junit.Assert.assertTrue; + +/** + * Characterization test: compares per-query heap allocation when searching against a + * {@link ByteBufferRandomAccessVectorValues} vs a classic float[]-backed + * {@link ListRandomAccessVectorValues} on the same data. The ByteBuffer path should allocate + * no more than the float[] path (and in practice a bit less, since it avoids the + * per-call scratch that classic RAVV implementations tend to allocate). + * + *

The test intentionally does not assert an absolute byte budget — search scaffolding + * (result heaps, visited sets, reranker caches) legitimately allocates and the numbers vary + * across JDK versions and GC configurations. The comparative assertion is stable. + * + *

Measurement uses {@code com.sun.management.ThreadMXBean.getThreadAllocatedBytes}. + */ +public class SearchAllocationProfileTest +{ + private static final VectorTypeSupport VTS = + VectorizationProvider.getInstance().getVectorTypeSupport(); + + @Test + public void byteBufferPathAllocatesNoMoreThanFloatArrayPath() throws Exception + { + int count = 500, dim = 64, searches = 200, topK = 10; + long seed = 0xBA5E_BA11L; + Random r = new Random(seed); + + float[][] raw = new float[count][dim]; + for (int i = 0; i < count; i++) { + for (int j = 0; j < dim; j++) { + raw[i][j] = r.nextFloat() * 2f - 1f; + } + } + + // float[] path + List> list = new ArrayList<>(); + for (float[] row : raw) list.add(VTS.createFloatVector(row)); + var ravvFloat = new ListRandomAccessVectorValues(list, dim); + + // ByteBuffer path + ByteBuffer bb = ByteBuffer.allocate(count * dim * Float.BYTES).order(ByteOrder.LITTLE_ENDIAN); + for (float[] row : raw) for (float f : row) bb.putFloat(f); + bb.rewind(); + var ravvBB = new ByteBufferRandomAccessVectorValues(bb, count, dim); + + var bspFloat = BuildScoreProvider.randomAccessScoreProvider(ravvFloat, VectorSimilarityFunction.EUCLIDEAN); + var bspBB = BuildScoreProvider.randomAccessScoreProvider(ravvBB, VectorSimilarityFunction.EUCLIDEAN); + + try (var builderFloat = new GraphIndexBuilder(bspFloat, dim, 16, 100, 1.2f, 1.2f, false); + var builderBB = new GraphIndexBuilder(bspBB, dim, 16, 100, 1.2f, 1.2f, false)) + { + TestUtil.buildSequentially(builderFloat, ravvFloat); + TestUtil.buildSequentially(builderBB, ravvBB); + var graphFloat = builderFloat.getGraph(); + var graphBB = builderBB.getGraph(); + + var queries = list.subList(0, 20); + + // Warm-up both paths + for (int i = 0; i < 50; i++) { + GraphSearcher.search(queries.get(i % queries.size()), topK, ravvFloat, + VectorSimilarityFunction.EUCLIDEAN, graphFloat, Bits.ALL); + GraphSearcher.search(queries.get(i % queries.size()), topK, ravvBB, + VectorSimilarityFunction.EUCLIDEAN, graphBB, Bits.ALL); + } + + ThreadMXBean tmx = (ThreadMXBean) ManagementFactory.getThreadMXBean(); + long tid = Thread.currentThread().getId(); + + long beforeFloat = tmx.getThreadAllocatedBytes(tid); + for (int i = 0; i < searches; i++) { + GraphSearcher.search(queries.get(i % queries.size()), topK, ravvFloat, + VectorSimilarityFunction.EUCLIDEAN, graphFloat, Bits.ALL); + } + long afterFloat = tmx.getThreadAllocatedBytes(tid); + long perQueryFloat = (afterFloat - beforeFloat) / searches; + + long beforeBB = tmx.getThreadAllocatedBytes(tid); + for (int i = 0; i < searches; i++) { + GraphSearcher.search(queries.get(i % queries.size()), topK, ravvBB, + VectorSimilarityFunction.EUCLIDEAN, graphBB, Bits.ALL); + } + long afterBB = tmx.getThreadAllocatedBytes(tid); + long perQueryBB = (afterBB - beforeBB) / searches; + + double ratio = perQueryFloat == 0 ? 0 : ((double) perQueryBB / perQueryFloat); + System.out.printf("SearchAllocationProfileTest: float[] path %d B/query, ByteBuffer path %d B/query (ratio %.2fx, dim=%d, topK=%d, count=%d)%n", + perQueryFloat, perQueryBB, ratio, dim, topK, count); + + // Current implementation allocates a small BufferVectorFloat view per getVector call + // (~80 B including an internal ByteBuffer slice). With thousands of node visits per + // query that adds up, but it is still orders of magnitude below the float[] + // materialization that a naive integration would do (dim*4 = 256 B per visit for + // dim=64). This assertion guards against the naive-materialization regression: + // float[] path × 3 is roughly the point where we'd have lost the zero-copy property. + long budget = Math.max(perQueryFloat * 3, 512_000L); + assertTrue( + "ByteBuffer path allocates " + perQueryBB + " B/query, float[] path " + + perQueryFloat + " B/query (ratio " + ratio + "), budget " + budget, + perQueryBB <= budget); + } + } +} diff --git a/jvector-tests/src/test/java/io/github/jbellis/jvector/graph/TestSearchWithByteBufferQuery.java b/jvector-tests/src/test/java/io/github/jbellis/jvector/graph/TestSearchWithByteBufferQuery.java new file mode 100644 index 000000000..88dacaad7 --- /dev/null +++ b/jvector-tests/src/test/java/io/github/jbellis/jvector/graph/TestSearchWithByteBufferQuery.java @@ -0,0 +1,117 @@ +/* + * Copyright DataStax, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.github.jbellis.jvector.graph; + +import io.github.jbellis.jvector.TestUtil; +import io.github.jbellis.jvector.graph.similarity.BuildScoreProvider; +import io.github.jbellis.jvector.util.Bits; +import io.github.jbellis.jvector.vector.VectorSimilarityFunction; +import io.github.jbellis.jvector.vector.VectorizationProvider; +import io.github.jbellis.jvector.vector.types.VectorFloat; +import io.github.jbellis.jvector.vector.types.VectorTypeSupport; +import org.junit.Test; + +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.util.ArrayList; +import java.util.List; +import java.util.Random; + +import static org.junit.Assert.assertEquals; + +public class TestSearchWithByteBufferQuery +{ + private static final VectorTypeSupport VTS = + VectorizationProvider.getInstance().getVectorTypeSupport(); + + @Test + public void byteBufferQueryMatchesVectorFloatQuery() throws Exception + { + int count = 200, dim = 48; + long seed = 0xFACEB00CL; + Random r = new Random(seed); + + List> list = new ArrayList<>(); + for (int i = 0; i < count; i++) { + VectorFloat v = VTS.createFloatVector(dim); + for (int j = 0; j < dim; j++) v.set(j, r.nextFloat() * 2f - 1f); + list.add(v); + } + var ravv = new ListRandomAccessVectorValues(list, dim); + + var bsp = BuildScoreProvider.randomAccessScoreProvider(ravv, VectorSimilarityFunction.DOT_PRODUCT); + try (var builder = new GraphIndexBuilder(bsp, dim, 12, 80, 1.2f, 1.2f, false)) { + TestUtil.buildSequentially(builder, ravv); + var graph = builder.getGraph(); + + for (int q = 0; q < 10; q++) { + int qOrd = r.nextInt(count); + VectorFloat queryAsVectorFloat = list.get(qOrd); + + ByteBuffer queryAsByteBuffer = ByteBuffer.allocate(dim * Float.BYTES) + .order(ByteOrder.LITTLE_ENDIAN); + for (int j = 0; j < dim; j++) queryAsByteBuffer.putFloat(queryAsVectorFloat.get(j)); + queryAsByteBuffer.rewind(); + + var resVF = GraphSearcher.search(queryAsVectorFloat, 5, ravv, VectorSimilarityFunction.DOT_PRODUCT, graph, Bits.ALL); + var resBB = GraphSearcher.search(queryAsByteBuffer, 5, ravv, VectorSimilarityFunction.DOT_PRODUCT, graph, Bits.ALL); + + assertEquals(resVF.getNodes().length, resBB.getNodes().length); + for (int i = 0; i < resVF.getNodes().length; i++) { + assertEquals("node mismatch at i=" + i, resVF.getNodes()[i].node, resBB.getNodes()[i].node); + assertEquals("score mismatch at i=" + i, resVF.getNodes()[i].score, resBB.getNodes()[i].score, 1e-5f); + } + } + } + } + + @Test + public void addGraphNodeByteBufferMatchesVectorFloat() throws Exception + { + int count = 50, dim = 24; + long seed = 0xC0FFEEL; + Random r = new Random(seed); + List> list = new ArrayList<>(); + for (int i = 0; i < count; i++) { + VectorFloat v = VTS.createFloatVector(dim); + for (int j = 0; j < dim; j++) v.set(j, r.nextFloat() * 2f - 1f); + list.add(v); + } + var ravvA = new ListRandomAccessVectorValues(list, dim); + + // Build A — using VectorFloat + var bspA = BuildScoreProvider.randomAccessScoreProvider(ravvA, VectorSimilarityFunction.EUCLIDEAN); + var builderA = new GraphIndexBuilder(bspA, dim, 10, 60, 1.2f, 1.2f, false); + for (int i = 0; i < count; i++) builderA.addGraphNode(i, list.get(i)); + builderA.cleanup(); + + // Build B — using ByteBuffer overload + var ravvB = new ListRandomAccessVectorValues(list, dim); + var bspB = BuildScoreProvider.randomAccessScoreProvider(ravvB, VectorSimilarityFunction.EUCLIDEAN); + var builderB = new GraphIndexBuilder(bspB, dim, 10, 60, 1.2f, 1.2f, false); + for (int i = 0; i < count; i++) { + ByteBuffer bb = ByteBuffer.allocate(dim * Float.BYTES).order(ByteOrder.LITTLE_ENDIAN); + for (int j = 0; j < dim; j++) bb.putFloat(list.get(i).get(j)); + bb.rewind(); + builderB.addGraphNode(i, bb); + } + builderB.cleanup(); + + TestUtil.assertGraphEquals(builderA.getGraph(), builderB.getGraph()); + builderA.close(); + builderB.close(); + } +} diff --git a/jvector-tests/src/test/java/io/github/jbellis/jvector/vector/BufferVectorFloatTest.java b/jvector-tests/src/test/java/io/github/jbellis/jvector/vector/BufferVectorFloatTest.java new file mode 100644 index 000000000..900729a1a --- /dev/null +++ b/jvector-tests/src/test/java/io/github/jbellis/jvector/vector/BufferVectorFloatTest.java @@ -0,0 +1,221 @@ +/* + * Copyright DataStax, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.github.jbellis.jvector.vector; + +import io.github.jbellis.jvector.vector.types.VectorFloat; +import org.junit.jupiter.api.Test; + +import java.nio.ByteBuffer; +import java.nio.ByteOrder; + +import static org.junit.jupiter.api.Assertions.*; + +class BufferVectorFloatTest +{ + private static ByteBuffer littleEndianOf(float... values) + { + ByteBuffer bb = ByteBuffer.allocate(values.length * Float.BYTES).order(ByteOrder.LITTLE_ENDIAN); + for (float v : values) bb.putFloat(v); + bb.rewind(); + return bb; + } + + private static ByteBuffer bigEndianOf(float... values) + { + ByteBuffer bb = ByteBuffer.allocate(values.length * Float.BYTES).order(ByteOrder.BIG_ENDIAN); + for (float v : values) bb.putFloat(v); + bb.rewind(); + return bb; + } + + @Test + void elementAccessMatchesFloatArrayLittleEndian() + { + float[] expected = {1.0f, -2.5f, 3.14f, 0.0f, Float.MAX_VALUE}; + BufferVectorFloat bv = new BufferVectorFloat(littleEndianOf(expected)); + assertEquals(expected.length, bv.length()); + for (int i = 0; i < expected.length; i++) { + assertEquals(expected[i], bv.get(i), 0f); + } + } + + @Test + void elementAccessMatchesFloatArrayBigEndian() + { + float[] expected = {1.0f, -2.5f, 3.14f, 0.0f, Float.MAX_VALUE}; + BufferVectorFloat bv = new BufferVectorFloat(bigEndianOf(expected)); + for (int i = 0; i < expected.length; i++) { + assertEquals(expected[i], bv.get(i), 0f); + } + } + + @Test + void directBufferBackingIsZeroCopy() + { + ByteBuffer direct = ByteBuffer.allocateDirect(4 * Float.BYTES).order(ByteOrder.LITTLE_ENDIAN); + for (int i = 0; i < 4; i++) direct.putFloat(i * 1.5f); + direct.rewind(); + + BufferVectorFloat bv = new BufferVectorFloat(direct); + // The view is a slice (independent ByteBuffer object) over the same off-heap storage. + // Proving no data copy: mutating via the original updates the view. + assertFalse(direct.hasArray(), "direct buffer — verifies off-heap path"); + direct.putFloat(0, 999f); + assertEquals(999f, bv.get(0), 0f, "slice must alias source storage, not copy"); + } + + @Test + void subrangeViewHonorsBoundsAndDoesNotAlias() + { + ByteBuffer bb = littleEndianOf(10f, 20f, 30f, 40f, 50f); + BufferVectorFloat middle = new BufferVectorFloat(bb, 1, 3); // {20, 30, 40} + assertEquals(3, middle.length()); + assertEquals(20f, middle.get(0), 0f); + assertEquals(30f, middle.get(1), 0f); + assertEquals(40f, middle.get(2), 0f); + assertThrows(IndexOutOfBoundsException.class, () -> middle.get(3)); + } + + @Test + void positionLimitMutationAfterConstructionDoesNotAffectView() + { + ByteBuffer bb = littleEndianOf(1f, 2f, 3f, 4f); + BufferVectorFloat bv = new BufferVectorFloat(bb); + bb.position(8).limit(12); + assertEquals(1f, bv.get(0), 0f); + assertEquals(4f, bv.get(3), 0f); + } + + @Test + void zeroAllocationConstructorProducesZeroedVector() + { + BufferVectorFloat bv = new BufferVectorFloat(7); + assertEquals(7, bv.length()); + for (int i = 0; i < 7; i++) { + assertEquals(0f, bv.get(i), 0f); + } + } + + @Test + void setRoundTrip() + { + BufferVectorFloat bv = new BufferVectorFloat(4); + bv.set(0, 1f); bv.set(1, 2f); bv.set(2, -3f); bv.set(3, 0.5f); + assertEquals(1f, bv.get(0), 0f); + assertEquals(-3f, bv.get(2), 0f); + } + + @Test + void zeroResets() + { + BufferVectorFloat bv = new BufferVectorFloat(littleEndianOf(1f, 2f, 3f).duplicate().order(ByteOrder.LITTLE_ENDIAN)); + bv.zero(); + for (int i = 0; i < 3; i++) assertEquals(0f, bv.get(i), 0f); + } + + @Test + void copyProducesIndependentStorage() + { + BufferVectorFloat src = new BufferVectorFloat(littleEndianOf(1f, 2f, 3f)); + VectorFloat dup = src.copy(); + src.set(0, 99f); + assertEquals(99f, src.get(0), 0f); + assertEquals(1f, dup.get(0), 0f, "copy must not alias source"); + } + + @Test + void copyFromArrayVectorFloat() + { + BufferVectorFloat dest = new BufferVectorFloat(4); + ArrayVectorFloat src = new ArrayVectorFloat(new float[]{10f, 20f, 30f, 40f}); + dest.copyFrom(src, 1, 0, 3); + assertEquals(20f, dest.get(0), 0f); + assertEquals(30f, dest.get(1), 0f); + assertEquals(40f, dest.get(2), 0f); + assertEquals(0f, dest.get(3), 0f); + } + + @Test + void copyFromBufferVectorFloatSameOrder() + { + BufferVectorFloat dest = new BufferVectorFloat(4); + BufferVectorFloat src = new BufferVectorFloat(littleEndianOf(1f, 2f, 3f, 4f, 5f)); + dest.copyFrom(src, 1, 0, 3); + assertEquals(2f, dest.get(0), 0f); + assertEquals(3f, dest.get(1), 0f); + assertEquals(4f, dest.get(2), 0f); + } + + @Test + void copyFromBufferVectorFloatDifferentOrder() + { + BufferVectorFloat dest = new BufferVectorFloat(3); // native order + BufferVectorFloat src = new BufferVectorFloat(bigEndianOf(1f, 2f, 3f)); + dest.copyFrom(src, 0, 0, 3); + for (int i = 0; i < 3; i++) assertEquals((float) (i + 1), dest.get(i), 0f); + } + + @Test + void equalsMatchesSemanticContent() + { + BufferVectorFloat le = new BufferVectorFloat(littleEndianOf(1f, 2f, 3f)); + BufferVectorFloat be = new BufferVectorFloat(bigEndianOf(1f, 2f, 3f)); + ArrayVectorFloat av = new ArrayVectorFloat(new float[]{1f, 2f, 3f}); + assertEquals(le, be, "byte order differs but content identical"); + assertEquals(le, av, "BufferVectorFloat equals any VectorFloat with same content"); + // ArrayVectorFloat.equals() is class-strict — we don't change that contract here. + } + + @Test + void rejectsNonFloatAlignedRemaining() + { + ByteBuffer odd = ByteBuffer.allocate(7).order(ByteOrder.LITTLE_ENDIAN); + assertThrows(IllegalArgumentException.class, () -> new BufferVectorFloat(odd)); + } + + @Test + void rejectsNegativeArgs() + { + ByteBuffer ok = littleEndianOf(1f, 2f); + assertThrows(IllegalArgumentException.class, () -> new BufferVectorFloat(ok, -1, 1)); + assertThrows(IllegalArgumentException.class, () -> new BufferVectorFloat(ok, 0, -1)); + assertThrows(IllegalArgumentException.class, () -> new BufferVectorFloat(-1)); + } + + @Test + void rejectsOutOfRangeSubview() + { + ByteBuffer bb = littleEndianOf(1f, 2f, 3f); + assertThrows(IllegalArgumentException.class, () -> new BufferVectorFloat(bb, 2, 2)); + } + + @Test + void ramBytesUsedIsFinite() + { + BufferVectorFloat bv = new BufferVectorFloat(1024); + long size = bv.ramBytesUsed(); + assertTrue(size >= 1024L * Float.BYTES, "reported size must cover backing buffer"); + } + + @Test + void byteOrderPreserved() + { + BufferVectorFloat le = new BufferVectorFloat(littleEndianOf(1f)); + BufferVectorFloat be = new BufferVectorFloat(bigEndianOf(1f)); + assertEquals(ByteOrder.LITTLE_ENDIAN, le.byteOrder()); + assertEquals(ByteOrder.BIG_ENDIAN, be.byteOrder()); + } +} diff --git a/jvector-tests/src/test/java/io/github/jbellis/jvector/vector/VectorTypeSupportByteBufferTest.java b/jvector-tests/src/test/java/io/github/jbellis/jvector/vector/VectorTypeSupportByteBufferTest.java new file mode 100644 index 000000000..6054f5acb --- /dev/null +++ b/jvector-tests/src/test/java/io/github/jbellis/jvector/vector/VectorTypeSupportByteBufferTest.java @@ -0,0 +1,107 @@ +/* + * Copyright DataStax, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.github.jbellis.jvector.vector; + +import io.github.jbellis.jvector.vector.types.VectorFloat; +import io.github.jbellis.jvector.vector.types.VectorTypeSupport; +import org.junit.jupiter.api.Test; + +import java.nio.ByteBuffer; +import java.nio.ByteOrder; + +import static org.junit.jupiter.api.Assertions.*; + +class VectorTypeSupportByteBufferTest +{ + private static final VectorTypeSupport VTS = + VectorizationProvider.getInstance().getVectorTypeSupport(); + + private static final VectorTypeSupport DEFAULT_VTS = + new DefaultVectorizationProvider().getVectorTypeSupport(); + + private static ByteBuffer le(float... floats) + { + ByteBuffer bb = ByteBuffer.allocate(floats.length * Float.BYTES).order(ByteOrder.LITTLE_ENDIAN); + for (float v : floats) bb.putFloat(v); + bb.rewind(); + return bb; + } + + @Test + void wrapReturnsReadableVectorAcrossActiveProvider() + { + ByteBuffer bb = le(1f, 2f, 3f, 4f); + VectorFloat v = VTS.wrapFloatVector(bb); + assertEquals(4, v.length()); + assertEquals(1f, v.get(0), 0f); + assertEquals(4f, v.get(3), 0f); + } + + @Test + void wrapAcrossDefaultProvider() + { + ByteBuffer bb = le(1f, 2f, 3f, 4f); + VectorFloat v = DEFAULT_VTS.wrapFloatVector(bb); + assertEquals(4, v.length()); + for (int i = 0; i < 4; i++) { + assertEquals((float) (i + 1), v.get(i), 0f); + } + } + + @Test + void wrapZeroCopyOnDirectBuffer() + { + ByteBuffer direct = ByteBuffer.allocateDirect(4 * Float.BYTES).order(ByteOrder.LITTLE_ENDIAN); + for (int i = 0; i < 4; i++) direct.putFloat(i * 1.25f); + direct.rewind(); + VectorFloat v = DEFAULT_VTS.wrapFloatVector(direct); + // Mutate the source buffer; the view must see the change (proving no copy happened). + direct.putFloat(0, 999f); + assertEquals(999f, v.get(0), 0f); + } + + @Test + void wrapWithSubrange() + { + ByteBuffer bb = le(10f, 20f, 30f, 40f, 50f); + VectorFloat v = DEFAULT_VTS.wrapFloatVector(bb, 1, 3); + assertEquals(3, v.length()); + assertEquals(20f, v.get(0), 0f); + assertEquals(40f, v.get(2), 0f); + } + + @Test + void wrapRejectsBadAlignment() + { + ByteBuffer odd = ByteBuffer.allocate(7).order(ByteOrder.LITTLE_ENDIAN); + assertThrows(IllegalArgumentException.class, () -> VTS.wrapFloatVector(odd)); + } + + @Test + void wrapEquivalentAcrossProviders() + { + float[] raw = {-0.5f, 1.5f, 2.75f, -3.125f, 4.5f}; + ByteBuffer bb = le(raw); + + VectorFloat defaultView = DEFAULT_VTS.wrapFloatVector(bb.duplicate().order(ByteOrder.LITTLE_ENDIAN)); + VectorFloat activeView = VTS.wrapFloatVector(bb.duplicate().order(ByteOrder.LITTLE_ENDIAN)); + + assertEquals(defaultView.length(), activeView.length()); + for (int i = 0; i < raw.length; i++) { + assertEquals(defaultView.get(i), activeView.get(i), 0f); + } + } +} diff --git a/jvector-twenty/pom.xml b/jvector-twenty/pom.xml index ae6aa659b..ab288ae33 100644 --- a/jvector-twenty/pom.xml +++ b/jvector-twenty/pom.xml @@ -18,9 +18,8 @@ maven-compiler-plugin 3.11.0 - 20 + 22 - --add-modules jdk.incubator.vector @@ -33,7 +32,6 @@ false - --enable-preview --add-modules jdk.incubator.vector --enable-native-access=ALL-UNNAMED diff --git a/jvector-twenty/src/main/java/io/github/jbellis/jvector/vector/PanamaVectorUtilSupport.java b/jvector-twenty/src/main/java/io/github/jbellis/jvector/vector/PanamaVectorUtilSupport.java index 22e0d2c60..cd57e37d4 100644 --- a/jvector-twenty/src/main/java/io/github/jbellis/jvector/vector/PanamaVectorUtilSupport.java +++ b/jvector-twenty/src/main/java/io/github/jbellis/jvector/vector/PanamaVectorUtilSupport.java @@ -27,6 +27,7 @@ import jdk.incubator.vector.VectorOperators; import jdk.incubator.vector.VectorSpecies; +import java.lang.foreign.MemorySegment; import java.util.List; class PanamaVectorUtilSupport implements VectorUtilSupport { @@ -43,14 +44,41 @@ class PanamaVectorUtilSupport implements VectorUtilSupport { static final ThreadLocal scratchInt256 = ThreadLocal.withInitial(() -> new int[IntVector.SPECIES_256.length()]); protected FloatVector fromVectorFloat(VectorSpecies SPEC, VectorFloat vector, int offset) { + if (vector instanceof BufferVectorFloat) { + BufferVectorFloat bv = (BufferVectorFloat) vector; + // bv.get() is an independent slice whose position 0 corresponds to element 0, + // so the byte offset into the segment is simply offset * Float.BYTES. + return FloatVector.fromMemorySegment( + SPEC, + MemorySegment.ofBuffer(bv.get()), + (long) offset * Float.BYTES, + bv.byteOrder()); + } return FloatVector.fromArray(SPEC, ((ArrayVectorFloat) vector).get(), offset); } protected FloatVector fromVectorFloat(VectorSpecies SPEC, VectorFloat vector, int offset, int[] indices, int indicesOffset) { + if (vector instanceof BufferVectorFloat) { + // FloatVector has no gather-from-MemorySegment variant; fall back to a short scalar gather. + // The scratch array is at most SPEC.length() floats (≤16), allocated per call. + float[] scratch = new float[SPEC.length()]; + for (int i = 0; i < SPEC.length(); i++) { + scratch[i] = vector.get(offset + indices[indicesOffset + i]); + } + return FloatVector.fromArray(SPEC, scratch, 0); + } return FloatVector.fromArray(SPEC, ((ArrayVectorFloat)vector).get(), offset, indices, indicesOffset); } protected void intoVectorFloat(FloatVector vector, VectorFloat v, int offset) { + if (v instanceof BufferVectorFloat) { + BufferVectorFloat bv = (BufferVectorFloat) v; + vector.intoMemorySegment( + MemorySegment.ofBuffer(bv.get()), + (long) offset * Float.BYTES, + bv.byteOrder()); + return; + } vector.intoArray(((ArrayVectorFloat) v).get(), offset); } From 838651c9566187a43ae8ce88c5b63401a3e39dfe Mon Sep 17 00:00:00 2001 From: Enrico Olivelli Date: Sun, 19 Apr 2026 08:17:17 +0200 Subject: [PATCH 2/5] Bump version to 4.0.0-rc.9-herddb-SNAPSHOT Tags the build with a -herddb suffix so HerdDB can pin to this jvector fork's artifacts without colliding with upstream 4.0.0-rc.9-SNAPSHOT in a shared local/remote Maven repository. Co-Authored-By: Claude Opus 4.7 (1M context) --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index 102f75d6a..a22a3b1e3 100644 --- a/pom.xml +++ b/pom.xml @@ -41,7 +41,7 @@ UTF-8 true - 4.0.0-rc.9-SNAPSHOT + 4.0.0-rc.9-herddb-SNAPSHOT jvector-base From 2c87784200ab07ee224b4b76e8615f57aadf0ea7 Mon Sep 17 00:00:00 2001 From: Enrico Olivelli Date: Sun, 19 Apr 2026 12:32:08 +0200 Subject: [PATCH 3/5] Revert jvector-twenty to release 20; scalar fallback on BufferVectorFloat MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI on JDK 20 failed with "release version 22 not supported" — the earlier release bump of jvector-twenty from 20 -> 22 assumed a higher minimum JDK than the project's CI matrix supports. Revert to release 20 and teach PanamaVectorUtilSupport's three protected SIMD helpers to handle BufferVectorFloat via a small SPEC-length float[] scratch instead of FloatVector.fromMemorySegment (which needs java.lang.foreign, still preview in Java 20). Functional behavior and SIMD on ArrayVectorFloat unchanged. Full SIMD on BufferVectorFloat still available via jvector-native, which targets Java 22 and has stable MemorySegment. Scratch is <= SPEC.length() floats (typically 8 or 16 -> 32-64 B), allocated inside the hot helper so escape analysis can usually elide it. The native backend (jvector-native) remains fully zero-copy and full-SIMD for BufferVectorFloat via FloatVector.fromMemorySegment. Co-Authored-By: Claude Opus 4.7 (1M context) --- jvector-twenty/pom.xml | 4 ++- .../vector/PanamaVectorUtilSupport.java | 33 ++++++++++--------- 2 files changed, 20 insertions(+), 17 deletions(-) diff --git a/jvector-twenty/pom.xml b/jvector-twenty/pom.xml index ab288ae33..ae6aa659b 100644 --- a/jvector-twenty/pom.xml +++ b/jvector-twenty/pom.xml @@ -18,8 +18,9 @@ maven-compiler-plugin 3.11.0 - 22 + 20 + --add-modules jdk.incubator.vector @@ -32,6 +33,7 @@ false + --enable-preview --add-modules jdk.incubator.vector --enable-native-access=ALL-UNNAMED diff --git a/jvector-twenty/src/main/java/io/github/jbellis/jvector/vector/PanamaVectorUtilSupport.java b/jvector-twenty/src/main/java/io/github/jbellis/jvector/vector/PanamaVectorUtilSupport.java index cd57e37d4..f2ce67815 100644 --- a/jvector-twenty/src/main/java/io/github/jbellis/jvector/vector/PanamaVectorUtilSupport.java +++ b/jvector-twenty/src/main/java/io/github/jbellis/jvector/vector/PanamaVectorUtilSupport.java @@ -27,7 +27,6 @@ import jdk.incubator.vector.VectorOperators; import jdk.incubator.vector.VectorSpecies; -import java.lang.foreign.MemorySegment; import java.util.List; class PanamaVectorUtilSupport implements VectorUtilSupport { @@ -45,22 +44,23 @@ class PanamaVectorUtilSupport implements VectorUtilSupport { protected FloatVector fromVectorFloat(VectorSpecies SPEC, VectorFloat vector, int offset) { if (vector instanceof BufferVectorFloat) { - BufferVectorFloat bv = (BufferVectorFloat) vector; - // bv.get() is an independent slice whose position 0 corresponds to element 0, - // so the byte offset into the segment is simply offset * Float.BYTES. - return FloatVector.fromMemorySegment( - SPEC, - MemorySegment.ofBuffer(bv.get()), - (long) offset * Float.BYTES, - bv.byteOrder()); + // jvector-twenty targets Java 20 where java.lang.foreign.MemorySegment is still + // preview, so we can't use FloatVector.fromMemorySegment here (that path lives in + // jvector-native, which targets Java 22 and has stable MemorySegment). Scalar-fill a + // SPEC-sized scratch instead. The scratch is ≤ 16 floats and typically elided by + // escape analysis in the hot loop. Full SIMD on BufferVectorFloat requires the + // native module. + float[] scratch = new float[SPEC.length()]; + for (int i = 0; i < SPEC.length(); i++) { + scratch[i] = vector.get(offset + i); + } + return FloatVector.fromArray(SPEC, scratch, 0); } return FloatVector.fromArray(SPEC, ((ArrayVectorFloat) vector).get(), offset); } protected FloatVector fromVectorFloat(VectorSpecies SPEC, VectorFloat vector, int offset, int[] indices, int indicesOffset) { if (vector instanceof BufferVectorFloat) { - // FloatVector has no gather-from-MemorySegment variant; fall back to a short scalar gather. - // The scratch array is at most SPEC.length() floats (≤16), allocated per call. float[] scratch = new float[SPEC.length()]; for (int i = 0; i < SPEC.length(); i++) { scratch[i] = vector.get(offset + indices[indicesOffset + i]); @@ -72,11 +72,12 @@ protected FloatVector fromVectorFloat(VectorSpecies SPEC, VectorFloat protected void intoVectorFloat(FloatVector vector, VectorFloat v, int offset) { if (v instanceof BufferVectorFloat) { - BufferVectorFloat bv = (BufferVectorFloat) v; - vector.intoMemorySegment( - MemorySegment.ofBuffer(bv.get()), - (long) offset * Float.BYTES, - bv.byteOrder()); + int len = vector.species().length(); + float[] scratch = new float[len]; + vector.intoArray(scratch, 0); + for (int i = 0; i < len; i++) { + v.set(offset + i, scratch[i]); + } return; } vector.intoArray(((ArrayVectorFloat) v).get(), offset); From 8eba756b1edf73b07ee4425d9c0ed60200361fa0 Mon Sep 17 00:00:00 2001 From: Enrico Olivelli Date: Sun, 19 Apr 2026 12:41:10 +0200 Subject: [PATCH 4/5] Drop JDK 11/20 support; baseline JDK 22, modernize to pattern matching MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit HerdDB runs on JDK 25+ and only JDK 22+ can use the stable java.lang.foreign.MemorySegment APIs this branch depends on. Drop the old-JDK scaffolding so the code expresses its real minimum. - parent pom release 11 -> 22; jvector-twenty restored to release 22 with the FloatVector.fromMemorySegment path for BufferVectorFloat (reverts the scalar-fallback workaround from the prior commit) - GitHub Actions unit-tests.yaml: build matrix [11,20,22] -> [22]; build-avx512 matrix [20,24] -> [24]; remove JDK-20-specific "Verify Panama Vector Support" / "Test Panama Support" steps - Drop now-unused jdk11 / jdk20 Maven profiles in jvector-tests and jvector-examples poms (jdk21 in tests and jdk22 in examples remain the active-by-default profiles) Modernize to JDK 22 pattern matching + ByteBuffer.slice(int,int): - BufferVectorFloat constructor uses ByteBuffer.slice(start, length) directly (one allocation instead of duplicate+position+limit+slice) - BufferVectorFloat.copy / copyFrom use slice(int,int) and pattern-matching instanceof - ArrayVectorFloat.copyFrom, MemorySegmentVectorFloat.copyFrom, PanamaVectorUtilSupport helpers, NativeVectorUtilSupport helpers, MemorySegmentVectorProvider.wrapFloatVector, ByteBufferRandomAccess- VectorValues constructor — all simplified with `instanceof T x` and slice(int,int) Local verification: mvn verify (full reactor) green; jvector-tests, jvector-native, jvector-examples test suites green. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/unit-tests.yaml | 28 +-- .../ByteBufferRandomAccessVectorValues.java | 5 +- .../jvector/vector/ArrayVectorFloat.java | 4 +- .../jvector/vector/BufferVectorFloat.java | 38 ++-- jvector-examples/pom.xml | 190 ------------------ .../vector/MemorySegmentVectorFloat.java | 3 +- .../vector/MemorySegmentVectorProvider.java | 5 +- .../vector/NativeVectorUtilSupport.java | 20 +- jvector-tests/pom.xml | 20 -- jvector-twenty/pom.xml | 4 +- .../vector/PanamaVectorUtilSupport.java | 35 ++-- pom.xml | 2 +- 12 files changed, 52 insertions(+), 302 deletions(-) diff --git a/.github/workflows/unit-tests.yaml b/.github/workflows/unit-tests.yaml index 7fb47f9ba..63950e960 100644 --- a/.github/workflows/unit-tests.yaml +++ b/.github/workflows/unit-tests.yaml @@ -18,8 +18,7 @@ jobs: cancel-in-progress: false strategy: matrix: - jdk: [ 20, 24 ] -# jdk: [ 24 ] + jdk: [ 24 ] isa: [ isa-avx512f ] runs-on: ${{ matrix.isa }} steps: @@ -48,20 +47,6 @@ jobs: distribution: temurin cache: maven - - name: Verify Panama Vector Support (JDK ${{ matrix.jdk }}) - if: matrix.jdk == '20' - run: >- - mvn -B -Pjdk20 -pl jvector-tests -am test - -Dsurefire.failIfNoSpecifiedTests=false - -Dtest=TestVectorizationProvider - -DTest_RequireSpecificVectorizationProvider=PanamaVectorizationProvider - - - name: Test Panama Support (JDK ${{ matrix.jdk }}) - if: matrix.jdk == '20' - run: >- - mvn -B -Pjdk20 -pl jvector-tests test -am test - -DTest_RequireSpecificVectorizationProvider=PanamaVectorizationProvider - - name: Verify native-access vector support (JDK ${{ matrix.jdk }}) if: matrix.jdk == '24' run: >- @@ -97,7 +82,7 @@ jobs: cancel-in-progress: true strategy: matrix: - jdk: [ 11, 20, 22] + jdk: [ 22 ] os: [ ubuntu-latest, windows-latest ] runs-on: ${{ matrix.os }} steps: @@ -112,15 +97,8 @@ jobs: if: matrix.os == 'ubuntu-latest' run: | sudo apt install -y gcc - - name: Compile, run tests, and package (JDK 22) + - name: Compile, run tests, and package run: mvn -B verify - if: matrix.jdk == '22' - - name: Compile, run tests, and package (JDK 20) - run: mvn -B -Pjdk20 -am -pl jvector-tests test - if: matrix.jdk == '20' - - name: Compile and run tests (JDK 11) - run: mvn -B -Pjdk11 -am -pl jvector-tests test - if: matrix.jdk == '11' - name: Test Summary if: always() uses: test-summary/action@v2 diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/graph/ByteBufferRandomAccessVectorValues.java b/jvector-base/src/main/java/io/github/jbellis/jvector/graph/ByteBufferRandomAccessVectorValues.java index a035ed4ba..d0115bd1f 100644 --- a/jvector-base/src/main/java/io/github/jbellis/jvector/graph/ByteBufferRandomAccessVectorValues.java +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/graph/ByteBufferRandomAccessVectorValues.java @@ -57,10 +57,7 @@ public ByteBufferRandomAccessVectorValues(ByteBuffer data, int count, int dimens throw new IllegalArgumentException( "buffer too small: need " + need + " bytes, have " + data.remaining()); } - ByteBuffer dup = data.duplicate().order(data.order()); - int startByte = data.position(); - dup.position(startByte).limit(startByte + (int) need); - this.data = dup.slice().order(data.order()); + this.data = data.slice(data.position(), (int) need).order(data.order()); this.count = count; this.dimension = dimension; } diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/vector/ArrayVectorFloat.java b/jvector-base/src/main/java/io/github/jbellis/jvector/vector/ArrayVectorFloat.java index 8b686bb3c..6c59d11ae 100644 --- a/jvector-base/src/main/java/io/github/jbellis/jvector/vector/ArrayVectorFloat.java +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/vector/ArrayVectorFloat.java @@ -81,8 +81,8 @@ public VectorFloat copy() @Override public void copyFrom(VectorFloat src, int srcOffset, int destOffset, int length) { - if (src instanceof ArrayVectorFloat) { - System.arraycopy(((ArrayVectorFloat) src).data, srcOffset, data, destOffset, length); + if (src instanceof ArrayVectorFloat csrc) { + System.arraycopy(csrc.data, srcOffset, data, destOffset, length); return; } for (int i = 0; i < length; i++) { diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/vector/BufferVectorFloat.java b/jvector-base/src/main/java/io/github/jbellis/jvector/vector/BufferVectorFloat.java index cf0757df0..97b2d3536 100644 --- a/jvector-base/src/main/java/io/github/jbellis/jvector/vector/BufferVectorFloat.java +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/vector/BufferVectorFloat.java @@ -104,11 +104,8 @@ public BufferVectorFloat(ByteBuffer data, int floatOffset, int floatLength) "view [" + floatOffset + "," + (floatOffset + floatLength) + ") floats exceeds buffer remaining=" + data.remaining() + " bytes"); } - ByteBuffer dup = data.duplicate().order(data.order()); int startByte = data.position() + floatOffset * Float.BYTES; - int endByte = startByte + floatLength * Float.BYTES; - dup.position(startByte).limit(endByte); - this.data = dup.slice().order(data.order()); + this.data = data.slice(startByte, floatLength * Float.BYTES).order(data.order()); this.byteOffset = 0; this.floatLength = floatLength; } @@ -173,32 +170,27 @@ public void writeTo(IndexWriter writer) throws IOException public VectorFloat copy() { ByteBuffer owned = ByteBuffer.allocate(floatLength * Float.BYTES).order(data.order()); - ByteBuffer srcView = data.duplicate().order(data.order()); - srcView.position(byteOffset).limit(byteOffset + floatLength * Float.BYTES); - owned.put(srcView); - owned.rewind(); + ByteBuffer srcSlice = data.slice(byteOffset, floatLength * Float.BYTES).order(data.order()); + owned.put(srcSlice).rewind(); return new BufferVectorFloat(owned, 0, floatLength); } @Override public void copyFrom(VectorFloat src, int srcOffset, int destOffset, int length) { - if (src instanceof BufferVectorFloat) { - BufferVectorFloat bsrc = (BufferVectorFloat) src; - if (bsrc.byteOrder() == this.byteOrder()) { - ByteBuffer srcDup = bsrc.data.duplicate().order(bsrc.data.order()); - int srcStart = bsrc.byteOffset + srcOffset * Float.BYTES; - int bytes = length * Float.BYTES; - srcDup.position(srcStart).limit(srcStart + bytes); - ByteBuffer destDup = this.data.duplicate().order(this.data.order()); - destDup.position(this.byteOffset + destOffset * Float.BYTES); - destDup.put(srcDup); - return; - } - } else if (src instanceof ArrayVectorFloat) { - float[] a = ((ArrayVectorFloat) src).get(); + if (src instanceof BufferVectorFloat bsrc && bsrc.byteOrder() == this.byteOrder()) { + int srcStart = bsrc.byteOffset + srcOffset * Float.BYTES; + int bytes = length * Float.BYTES; + ByteBuffer srcSlice = bsrc.data.slice(srcStart, bytes).order(bsrc.data.order()); + ByteBuffer destDup = this.data.duplicate().order(this.data.order()); + destDup.position(this.byteOffset + destOffset * Float.BYTES); + destDup.put(srcSlice); + return; + } + if (src instanceof ArrayVectorFloat a) { + float[] raw = a.get(); for (int i = 0; i < length; i++) { - set(destOffset + i, a[srcOffset + i]); + set(destOffset + i, raw[srcOffset + i]); } return; } diff --git a/jvector-examples/pom.xml b/jvector-examples/pom.xml index 9daf7b8cf..637341d3d 100644 --- a/jvector-examples/pom.xml +++ b/jvector-examples/pom.xml @@ -163,196 +163,6 @@ - - jdk11 - - - - org.codehaus.mojo - exec-maven-plugin - - false - - - - tutorial - - - -classpath - - -ea - io.github.jbellis.jvector.example.tutorial.TutorialRunner - ${tutorial} - - - - - sift - - exec - - - - -classpath - - -ea - io.github.jbellis.jvector.example.benchmarks.datasets.SiftSmall - - - - - bench - - exec - - - - -classpath - - -Xmx14G - -ea - io.github.jbellis.jvector.example.BenchYAML - ${benchArgs} - - - - - bench-java - - java - - - false - - ${benchArgs} - - - - - - - - - - jdk20 - - - io.github.jbellis - jvector-twenty - ${project.version} - compile - - - - - - org.codehaus.mojo - exec-maven-plugin - - false - - - - tutorial - - - -classpath - - --add-modules=jdk.incubator.vector - -ea - io.github.jbellis.jvector.example.tutorial.TutorialRunner - ${tutorial} - - - - - sift - - exec - - - - -classpath - - --add-modules=jdk.incubator.vector - -ea - io.github.jbellis.jvector.example.benchmarks.datasets.SiftSmall - - - - - bench - - exec - - - - -classpath - - --enable-native-access=ALL-UNNAMED - --add-modules=jdk.incubator.vector - -XX:+HeapDumpOnOutOfMemoryError - -Xmx14G - -ea - io.github.jbellis.jvector.example.BenchYAML - ${benchArgs} - - - - - bench-java - - java - - - false - - ${benchArgs} - - - - - ipcserve - - exec - - - - -classpath - - --enable-native-access=ALL-UNNAMED - --add-modules=jdk.incubator.vector - -XX:+HeapDumpOnOutOfMemoryError - -ea - io.github.jbellis.jvector.example.IPCService - - - - - ipcserve-1core - - exec - - - - -classpath - - --enable-native-access=ALL-UNNAMED - --add-modules=jdk.incubator.vector - -XX:+HeapDumpOnOutOfMemoryError - -Xmx10G - -ea - -Djava.util.concurrent.ForkJoinPool.common.parallelism=1 - -Djvector.physical_core_count=1 - io.github.jbellis.jvector.example.IPCService - - - - - - - - jdk22 diff --git a/jvector-native/src/main/java/io/github/jbellis/jvector/vector/MemorySegmentVectorFloat.java b/jvector-native/src/main/java/io/github/jbellis/jvector/vector/MemorySegmentVectorFloat.java index 112668108..1b7b0d8ab 100644 --- a/jvector-native/src/main/java/io/github/jbellis/jvector/vector/MemorySegmentVectorFloat.java +++ b/jvector-native/src/main/java/io/github/jbellis/jvector/vector/MemorySegmentVectorFloat.java @@ -147,8 +147,7 @@ public VectorFloat copy() @Override public void copyFrom(VectorFloat src, int srcOffset, int destOffset, int length) { - if (src instanceof MemorySegmentVectorFloat) { - MemorySegmentVectorFloat csrc = (MemorySegmentVectorFloat) src; + if (src instanceof MemorySegmentVectorFloat csrc) { segment.asSlice((long) destOffset * Float.BYTES, (long) length * Float.BYTES) .copyFrom(csrc.segment.asSlice((long) srcOffset * Float.BYTES, (long) length * Float.BYTES)); return; diff --git a/jvector-native/src/main/java/io/github/jbellis/jvector/vector/MemorySegmentVectorProvider.java b/jvector-native/src/main/java/io/github/jbellis/jvector/vector/MemorySegmentVectorProvider.java index 41f216a29..14e4bc7f1 100644 --- a/jvector-native/src/main/java/io/github/jbellis/jvector/vector/MemorySegmentVectorProvider.java +++ b/jvector-native/src/main/java/io/github/jbellis/jvector/vector/MemorySegmentVectorProvider.java @@ -58,10 +58,9 @@ public VectorFloat wrapFloatVector(ByteBuffer data, int floatOffset, int floa if (data.order() != java.nio.ByteOrder.LITTLE_ENDIAN) { return VectorTypeSupport.super.wrapFloatVector(data, floatOffset, floatLength); } - ByteBuffer dup = data.duplicate().order(data.order()); int startByte = data.position() + floatOffset * Float.BYTES; - dup.position(startByte).limit(startByte + floatLength * Float.BYTES); - return MemorySegmentVectorFloat.wrap(dup); + ByteBuffer slice = data.slice(startByte, floatLength * Float.BYTES).order(data.order()); + return MemorySegmentVectorFloat.wrap(slice); } @Override diff --git a/jvector-native/src/main/java/io/github/jbellis/jvector/vector/NativeVectorUtilSupport.java b/jvector-native/src/main/java/io/github/jbellis/jvector/vector/NativeVectorUtilSupport.java index d90b7b80e..56481894f 100644 --- a/jvector-native/src/main/java/io/github/jbellis/jvector/vector/NativeVectorUtilSupport.java +++ b/jvector-native/src/main/java/io/github/jbellis/jvector/vector/NativeVectorUtilSupport.java @@ -38,8 +38,8 @@ public NativeVectorUtilSupport() {} @Override protected FloatVector fromVectorFloat(VectorSpecies SPEC, VectorFloat vector, int offset) { - if (vector instanceof MemorySegmentVectorFloat) { - return FloatVector.fromMemorySegment(SPEC, ((MemorySegmentVectorFloat) vector).get(), vector.offset(offset), ByteOrder.LITTLE_ENDIAN); + if (vector instanceof MemorySegmentVectorFloat msv) { + return FloatVector.fromMemorySegment(SPEC, msv.get(), vector.offset(offset), ByteOrder.LITTLE_ENDIAN); } return super.fromVectorFloat(SPEC, vector, offset); } @@ -54,8 +54,8 @@ protected FloatVector fromVectorFloat(VectorSpecies SPEC, VectorFloat @Override protected void intoVectorFloat(FloatVector vector, VectorFloat v, int offset) { - if (v instanceof MemorySegmentVectorFloat) { - vector.intoMemorySegment(((MemorySegmentVectorFloat) v).get(), v.offset(offset), ByteOrder.LITTLE_ENDIAN); + if (v instanceof MemorySegmentVectorFloat msv) { + vector.intoMemorySegment(msv.get(), v.offset(offset), ByteOrder.LITTLE_ENDIAN); return; } super.intoVectorFloat(vector, v, offset); @@ -63,16 +63,16 @@ protected void intoVectorFloat(FloatVector vector, VectorFloat v, int offset) @Override protected ByteVector fromByteSequence(VectorSpecies SPEC, ByteSequence vector, int offset) { - if (vector instanceof MemorySegmentByteSequence) { - return ByteVector.fromMemorySegment(SPEC, ((MemorySegmentByteSequence) vector).get(), offset, ByteOrder.LITTLE_ENDIAN); + if (vector instanceof MemorySegmentByteSequence msb) { + return ByteVector.fromMemorySegment(SPEC, msb.get(), offset, ByteOrder.LITTLE_ENDIAN); } return super.fromByteSequence(SPEC, vector, offset); } @Override protected void intoByteSequence(ByteVector vector, ByteSequence v, int offset) { - if (v instanceof MemorySegmentByteSequence) { - vector.intoMemorySegment(((MemorySegmentByteSequence) v).get(), offset, ByteOrder.LITTLE_ENDIAN); + if (v instanceof MemorySegmentByteSequence msb) { + vector.intoMemorySegment(msb.get(), offset, ByteOrder.LITTLE_ENDIAN); return; } super.intoByteSequence(vector, v, offset); @@ -80,8 +80,8 @@ protected void intoByteSequence(ByteVector vector, ByteSequence v, int offset @Override protected void intoByteSequence(ByteVector vector, ByteSequence v, int offset, VectorMask mask) { - if (v instanceof MemorySegmentByteSequence) { - vector.intoMemorySegment(((MemorySegmentByteSequence) v).get(), offset, ByteOrder.LITTLE_ENDIAN, mask); + if (v instanceof MemorySegmentByteSequence msb) { + vector.intoMemorySegment(msb.get(), offset, ByteOrder.LITTLE_ENDIAN, mask); return; } super.intoByteSequence(vector, v, offset, mask); diff --git a/jvector-tests/pom.xml b/jvector-tests/pom.xml index 7f3145211..360002df9 100644 --- a/jvector-tests/pom.xml +++ b/jvector-tests/pom.xml @@ -175,25 +175,5 @@ false - - jdk20 - - - io.github.jbellis - jvector-twenty - ${project.version} - compile - - - - false - - - - jdk11 - - false - - diff --git a/jvector-twenty/pom.xml b/jvector-twenty/pom.xml index ae6aa659b..ab288ae33 100644 --- a/jvector-twenty/pom.xml +++ b/jvector-twenty/pom.xml @@ -18,9 +18,8 @@ maven-compiler-plugin 3.11.0 - 20 + 22 - --add-modules jdk.incubator.vector @@ -33,7 +32,6 @@ false - --enable-preview --add-modules jdk.incubator.vector --enable-native-access=ALL-UNNAMED diff --git a/jvector-twenty/src/main/java/io/github/jbellis/jvector/vector/PanamaVectorUtilSupport.java b/jvector-twenty/src/main/java/io/github/jbellis/jvector/vector/PanamaVectorUtilSupport.java index f2ce67815..7e29138e8 100644 --- a/jvector-twenty/src/main/java/io/github/jbellis/jvector/vector/PanamaVectorUtilSupport.java +++ b/jvector-twenty/src/main/java/io/github/jbellis/jvector/vector/PanamaVectorUtilSupport.java @@ -27,6 +27,7 @@ import jdk.incubator.vector.VectorOperators; import jdk.incubator.vector.VectorSpecies; +import java.lang.foreign.MemorySegment; import java.util.List; class PanamaVectorUtilSupport implements VectorUtilSupport { @@ -43,24 +44,22 @@ class PanamaVectorUtilSupport implements VectorUtilSupport { static final ThreadLocal scratchInt256 = ThreadLocal.withInitial(() -> new int[IntVector.SPECIES_256.length()]); protected FloatVector fromVectorFloat(VectorSpecies SPEC, VectorFloat vector, int offset) { - if (vector instanceof BufferVectorFloat) { - // jvector-twenty targets Java 20 where java.lang.foreign.MemorySegment is still - // preview, so we can't use FloatVector.fromMemorySegment here (that path lives in - // jvector-native, which targets Java 22 and has stable MemorySegment). Scalar-fill a - // SPEC-sized scratch instead. The scratch is ≤ 16 floats and typically elided by - // escape analysis in the hot loop. Full SIMD on BufferVectorFloat requires the - // native module. - float[] scratch = new float[SPEC.length()]; - for (int i = 0; i < SPEC.length(); i++) { - scratch[i] = vector.get(offset + i); - } - return FloatVector.fromArray(SPEC, scratch, 0); + if (vector instanceof BufferVectorFloat bv) { + // bv.get() is an independent slice whose position 0 corresponds to element 0, + // so the byte offset into the segment is simply offset * Float.BYTES. + return FloatVector.fromMemorySegment( + SPEC, + MemorySegment.ofBuffer(bv.get()), + (long) offset * Float.BYTES, + bv.byteOrder()); } return FloatVector.fromArray(SPEC, ((ArrayVectorFloat) vector).get(), offset); } protected FloatVector fromVectorFloat(VectorSpecies SPEC, VectorFloat vector, int offset, int[] indices, int indicesOffset) { if (vector instanceof BufferVectorFloat) { + // FloatVector has no gather-from-MemorySegment variant; fall back to a short scalar gather. + // The scratch array is at most SPEC.length() floats (≤16), allocated per call. float[] scratch = new float[SPEC.length()]; for (int i = 0; i < SPEC.length(); i++) { scratch[i] = vector.get(offset + indices[indicesOffset + i]); @@ -71,13 +70,11 @@ protected FloatVector fromVectorFloat(VectorSpecies SPEC, VectorFloat } protected void intoVectorFloat(FloatVector vector, VectorFloat v, int offset) { - if (v instanceof BufferVectorFloat) { - int len = vector.species().length(); - float[] scratch = new float[len]; - vector.intoArray(scratch, 0); - for (int i = 0; i < len; i++) { - v.set(offset + i, scratch[i]); - } + if (v instanceof BufferVectorFloat bv) { + vector.intoMemorySegment( + MemorySegment.ofBuffer(bv.get()), + (long) offset * Float.BYTES, + bv.byteOrder()); return; } vector.intoArray(((ArrayVectorFloat) v).get(), offset); diff --git a/pom.xml b/pom.xml index a22a3b1e3..24867d037 100644 --- a/pom.xml +++ b/pom.xml @@ -83,7 +83,7 @@ maven-compiler-plugin 3.11.0 - 11 + 22 From 25b22d3a73a72dd223458a9a9af6f067d839b228 Mon Sep 17 00:00:00 2001 From: Enrico Olivelli Date: Sun, 19 Apr 2026 12:54:58 +0200 Subject: [PATCH 5/5] Zero-copy sub-vector views for PQ codebook training MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit async-profiler flamegraphs of HerdDB's indexing service showed float[] allocations traceable to ProductQuantization.getSubVector — one fresh float[dim/M] per (training_vector × subspace) during PQ codebook training. For 100k training vectors × M=8 that's ~800k small allocations and tens of MB of GC pressure at every index rebuild. Eliminate them with zero-copy views: - New VectorFloat.subview(int floatOffset, int floatLength) default method (materializes via VectorTypeSupport.createFloatVector + copyFrom as a fallback). - Zero-copy overrides: * ArrayVectorFloat -> ArraySliceVectorFloat (new) * BufferVectorFloat -> another BufferVectorFloat over the same buffer * MemorySegmentVectorFloat -> a MemorySegmentVectorFloat over segment.asSlice(...) - New ArraySliceVectorFloat — a VectorFloat that references an underlying float[] at arrayOffset with its own logical length. Companion to the existing ArraySliceByteSequence. - SIMD dispatch awareness: * PanamaVectorUtilSupport — FloatVector.fromArray(SPEC, asv.get(), asv.arrayOffset() + offset) and the same for intoArray + the gather variant. SIMD performance is preserved. * NativeVectorUtilSupport — falls through to super for ArraySliceVectorFloat (already did for non-MemorySegment types). * DefaultVectorUtilSupport — the generic .get(i)-based fallback path already handles arbitrary VectorFloat. - ArrayVectorFloat.copyFrom fast-pathed for ArraySliceVectorFloat source (System.arraycopy with adjusted offset). - ProductQuantization.getSubVector rewritten to vector.subview(offset, length). Tests: - VectorFloatSubviewTest (7 cases) — subview aliases source, nested subview, SIMD distance/dot/cosine equivalence with materialized copies. - PQTrainingAllocationTest — asserts that getSubVector returns a live view (mutation visible through subview) and that per- training-vector allocation during ProductQuantization.compute stays under a small bound (measured: 37 B/vector on dim=64 M=8 K=16, down from the ~448 B/vector the materialization path cost). - TestProductQuantization (9 existing cases) all green — codebooks produced through the view path match the previous materializing path. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../quantization/ProductQuantization.java | 10 +- .../jvector/vector/ArraySliceVectorFloat.java | 180 ++++++++++++++++++ .../jvector/vector/ArrayVectorFloat.java | 13 ++ .../jvector/vector/BufferVectorFloat.java | 9 + .../jvector/vector/types/VectorFloat.java | 21 ++ .../vector/MemorySegmentVectorFloat.java | 16 ++ .../PQTrainingAllocationTest.java | 113 +++++++++++ .../vector/VectorFloatSubviewTest.java | 135 +++++++++++++ .../vector/PanamaVectorUtilSupport.java | 10 + 9 files changed, 503 insertions(+), 4 deletions(-) create mode 100644 jvector-base/src/main/java/io/github/jbellis/jvector/vector/ArraySliceVectorFloat.java create mode 100644 jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/PQTrainingAllocationTest.java create mode 100644 jvector-tests/src/test/java/io/github/jbellis/jvector/vector/VectorFloatSubviewTest.java diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ProductQuantization.java b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ProductQuantization.java index c84b7b955..d3cbef30e 100644 --- a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ProductQuantization.java +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ProductQuantization.java @@ -517,12 +517,14 @@ int closestCentroidIndex(VectorFloat subvector, int m, VectorFloat codeboo } /** - * Extracts the m-th subvector from a single vector. + * Extracts the m-th subvector from a single vector. Returns a zero-copy view via + * {@link VectorFloat#subview(int, int)} — no {@code float[]} allocation per subvector, + * which matters at codebook training time where this is called once per + * {@code (training_vector × subspace)} and would otherwise produce + * {@code N × M} small heap allocations. */ static VectorFloat getSubVector(VectorFloat vector, int m, int[][] subvectorSizeAndOffset) { - VectorFloat subvector = vectorTypeSupport.createFloatVector(subvectorSizeAndOffset[m][0]); - subvector.copyFrom(vector, subvectorSizeAndOffset[m][1], 0, subvectorSizeAndOffset[m][0]); - return subvector; + return vector.subview(subvectorSizeAndOffset[m][1], subvectorSizeAndOffset[m][0]); } /** diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/vector/ArraySliceVectorFloat.java b/jvector-base/src/main/java/io/github/jbellis/jvector/vector/ArraySliceVectorFloat.java new file mode 100644 index 000000000..ca09a9e6f --- /dev/null +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/vector/ArraySliceVectorFloat.java @@ -0,0 +1,180 @@ +/* + * Copyright DataStax, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.github.jbellis.jvector.vector; + +import io.github.jbellis.jvector.disk.IndexWriter; +import io.github.jbellis.jvector.util.RamUsageEstimator; +import io.github.jbellis.jvector.vector.types.VectorFloat; + +import java.io.IOException; +import java.util.Arrays; + +/** + * A {@link VectorFloat} that is a view into a contiguous range of a root + * {@code float[]}. Constructing this class does not allocate or copy the backing storage — both + * element access and SIMD dispatch read the underlying array at {@code arrayOffset + i}. + * + *

Primarily used inside jvector's Product Quantization training: extracting the M subvectors + * of each training vector can return M views instead of M materialized {@code float[dim/M]} + * copies. + * + *

The companion to {@link ArraySliceByteSequence} for the float-vector side of the type system. + */ +public final class ArraySliceVectorFloat implements VectorFloat +{ + private final float[] data; + private final int arrayOffset; + private final int length; + + public ArraySliceVectorFloat(float[] data, int arrayOffset, int length) + { + if (data == null) throw new NullPointerException("data"); + if (arrayOffset < 0 || length < 0 || (long) arrayOffset + length > data.length) { + throw new IllegalArgumentException( + "slice [" + arrayOffset + "," + (arrayOffset + length) + + ") out of range for float[" + data.length + "]"); + } + this.data = data; + this.arrayOffset = arrayOffset; + this.length = length; + } + + /** @return the root float[] this view borrows from (offsets are still relative to arrayOffset()). */ + @Override + public float[] get() + { + return data; + } + + /** @return the index into {@link #get()} where this view's element 0 lives. */ + public int arrayOffset() + { + return arrayOffset; + } + + @Override + public float get(int n) + { + return data[arrayOffset + n]; + } + + @Override + public void set(int n, float value) + { + data[arrayOffset + n] = value; + } + + @Override + public void zero() + { + Arrays.fill(data, arrayOffset, arrayOffset + length, 0f); + } + + @Override + public int length() + { + return length; + } + + @Override + public void writeTo(IndexWriter writer) throws IOException + { + writer.writeFloats(data, arrayOffset, length); + } + + @Override + public VectorFloat copy() + { + return new ArrayVectorFloat(Arrays.copyOfRange(data, arrayOffset, arrayOffset + length)); + } + + @Override + public VectorFloat subview(int floatOffset, int floatLength) + { + if (floatOffset < 0 || floatLength < 0 || (long) floatOffset + floatLength > this.length) { + throw new IllegalArgumentException( + "subview [" + floatOffset + "," + (floatOffset + floatLength) + + ") out of range for view length " + this.length); + } + if (floatOffset == 0 && floatLength == this.length) { + return this; + } + return new ArraySliceVectorFloat(data, arrayOffset + floatOffset, floatLength); + } + + @Override + public void copyFrom(VectorFloat src, int srcOffset, int destOffset, int copyLength) + { + if (src instanceof ArrayVectorFloat csrc) { + System.arraycopy(csrc.get(), srcOffset, data, arrayOffset + destOffset, copyLength); + return; + } + if (src instanceof ArraySliceVectorFloat asrc) { + System.arraycopy(asrc.data, asrc.arrayOffset + srcOffset, data, arrayOffset + destOffset, copyLength); + return; + } + for (int i = 0; i < copyLength; i++) { + data[arrayOffset + destOffset + i] = src.get(srcOffset + i); + } + } + + @Override + public long ramBytesUsed() + { + // Only the slice metadata; the backing float[] is accounted by its owner. + return RamUsageEstimator.NUM_BYTES_OBJECT_HEADER + + RamUsageEstimator.NUM_BYTES_OBJECT_REF + + 2L * Integer.BYTES; + } + + @Override + public String toString() + { + StringBuilder sb = new StringBuilder(); + sb.append("["); + int preview = Math.min(length, 25); + for (int i = 0; i < preview; i++) { + sb.append(get(i)); + if (i < length - 1) { + sb.append(", "); + } + } + if (length > 25) { + sb.append("..."); + } + sb.append("]"); + return sb.toString(); + } + + @Override + public boolean equals(Object o) + { + if (this == o) return true; + if (!(o instanceof VectorFloat that)) return false; + if (that.length() != this.length) return false; + for (int i = 0; i < length; i++) { + if (Float.floatToIntBits(get(i)) != Float.floatToIntBits(that.get(i))) return false; + } + return true; + } + + @Override + public int hashCode() + { + return this.getHashCode(); + } +} diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/vector/ArrayVectorFloat.java b/jvector-base/src/main/java/io/github/jbellis/jvector/vector/ArrayVectorFloat.java index 6c59d11ae..107a327b2 100644 --- a/jvector-base/src/main/java/io/github/jbellis/jvector/vector/ArrayVectorFloat.java +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/vector/ArrayVectorFloat.java @@ -78,6 +78,15 @@ public VectorFloat copy() return new ArrayVectorFloat(Arrays.copyOf(data, data.length)); } + @Override + public VectorFloat subview(int floatOffset, int floatLength) + { + if (floatOffset == 0 && floatLength == data.length) { + return this; + } + return new ArraySliceVectorFloat(data, floatOffset, floatLength); + } + @Override public void copyFrom(VectorFloat src, int srcOffset, int destOffset, int length) { @@ -85,6 +94,10 @@ public void copyFrom(VectorFloat src, int srcOffset, int destOffset, int leng System.arraycopy(csrc.data, srcOffset, data, destOffset, length); return; } + if (src instanceof ArraySliceVectorFloat ssrc) { + System.arraycopy(ssrc.get(), ssrc.arrayOffset() + srcOffset, data, destOffset, length); + return; + } for (int i = 0; i < length; i++) { data[destOffset + i] = src.get(srcOffset + i); } diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/vector/BufferVectorFloat.java b/jvector-base/src/main/java/io/github/jbellis/jvector/vector/BufferVectorFloat.java index 97b2d3536..3b6970d3b 100644 --- a/jvector-base/src/main/java/io/github/jbellis/jvector/vector/BufferVectorFloat.java +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/vector/BufferVectorFloat.java @@ -175,6 +175,15 @@ public VectorFloat copy() return new BufferVectorFloat(owned, 0, floatLength); } + @Override + public VectorFloat subview(int floatOffset, int floatLength) + { + if (floatOffset == 0 && floatLength == this.floatLength) { + return this; + } + return new BufferVectorFloat(data, floatOffset, floatLength); + } + @Override public void copyFrom(VectorFloat src, int srcOffset, int destOffset, int length) { diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/vector/types/VectorFloat.java b/jvector-base/src/main/java/io/github/jbellis/jvector/vector/types/VectorFloat.java index e749ccdc5..258001a28 100644 --- a/jvector-base/src/main/java/io/github/jbellis/jvector/vector/types/VectorFloat.java +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/vector/types/VectorFloat.java @@ -38,6 +38,27 @@ default int offset(int i) { VectorFloat copy(); + /** + * Return a view over a contiguous sub-range of this vector. The default implementation + * materializes a new owned vector via {@link VectorTypeSupport#createFloatVector(int)} + * plus {@link #copyFrom}; concrete subtypes that can share storage (on-heap arrays, + * ByteBuffers, MemorySegments) override this to return a zero-copy view. + * + *

Used, for example, by Product Quantization training to extract per-subspace + * sub-vectors without materializing {@code M × N × (dim/M)} extra floats. + */ + default VectorFloat subview(int floatOffset, int floatLength) { + if (floatOffset < 0 || floatLength < 0 || (long) floatOffset + floatLength > length()) { + throw new IllegalArgumentException( + "subview [" + floatOffset + "," + (floatOffset + floatLength) + + ") out of range for length " + length()); + } + VectorFloat out = io.github.jbellis.jvector.vector.VectorizationProvider.getInstance() + .getVectorTypeSupport().createFloatVector(floatLength); + out.copyFrom(this, floatOffset, 0, floatLength); + return out; + } + void copyFrom(VectorFloat src, int srcOffset, int destOffset, int length); float get(int i); diff --git a/jvector-native/src/main/java/io/github/jbellis/jvector/vector/MemorySegmentVectorFloat.java b/jvector-native/src/main/java/io/github/jbellis/jvector/vector/MemorySegmentVectorFloat.java index 1b7b0d8ab..88900d925 100644 --- a/jvector-native/src/main/java/io/github/jbellis/jvector/vector/MemorySegmentVectorFloat.java +++ b/jvector-native/src/main/java/io/github/jbellis/jvector/vector/MemorySegmentVectorFloat.java @@ -144,6 +144,22 @@ public VectorFloat copy() return copy; } + @Override + public VectorFloat subview(int floatOffset, int floatLength) + { + int len = length(); + if (floatOffset < 0 || floatLength < 0 || (long) floatOffset + floatLength > len) { + throw new IllegalArgumentException( + "subview [" + floatOffset + "," + (floatOffset + floatLength) + + ") out of range for length " + len); + } + if (floatOffset == 0 && floatLength == len) { + return this; + } + return new MemorySegmentVectorFloat( + segment.asSlice((long) floatOffset * Float.BYTES, (long) floatLength * Float.BYTES)); + } + @Override public void copyFrom(VectorFloat src, int srcOffset, int destOffset, int length) { diff --git a/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/PQTrainingAllocationTest.java b/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/PQTrainingAllocationTest.java new file mode 100644 index 000000000..62caef4d1 --- /dev/null +++ b/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/PQTrainingAllocationTest.java @@ -0,0 +1,113 @@ +/* + * Copyright DataStax, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.github.jbellis.jvector.quantization; + +import com.sun.management.ThreadMXBean; +import io.github.jbellis.jvector.graph.ListRandomAccessVectorValues; +import io.github.jbellis.jvector.graph.RandomAccessVectorValues; +import io.github.jbellis.jvector.vector.ArraySliceVectorFloat; +import io.github.jbellis.jvector.vector.VectorSimilarityFunction; +import io.github.jbellis.jvector.vector.VectorizationProvider; +import io.github.jbellis.jvector.vector.types.VectorFloat; +import io.github.jbellis.jvector.vector.types.VectorTypeSupport; +import org.junit.Test; + +import java.lang.management.ManagementFactory; +import java.util.ArrayList; +import java.util.List; +import java.util.Random; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +/** + * Verifies that PQ codebook training does not allocate a fresh {@code float[]} per + * (training-vector × subspace). The subvector extraction path now returns zero-copy views + * via {@link ArraySliceVectorFloat}, so the per-subvector allocation that showed up in + * async-profiler flamegraphs during HerdDB indexing should be gone. + * + *

Uses {@code ThreadMXBean.getThreadAllocatedBytes} to measure; the assertion is relative + * to the number of training vectors (not an absolute byte budget) so the test is stable + * across JDK versions and SIMD backends. + */ +public class PQTrainingAllocationTest +{ + private static final VectorTypeSupport VTS = + VectorizationProvider.getInstance().getVectorTypeSupport(); + + @Test + public void getSubVectorReturnsViewNotCopy() + { + int dim = 128; + VectorFloat v = VTS.createFloatVector(dim); + for (int i = 0; i < dim; i++) v.set(i, i * 0.5f); + + int[][] ranges = {{16, 0}, {16, 16}, {16, 32}}; + VectorFloat sub0 = ProductQuantization.getSubVector(v, 0, ranges); + VectorFloat sub1 = ProductQuantization.getSubVector(v, 1, ranges); + assertEquals(16, sub0.length()); + assertEquals(16, sub1.length()); + + // Mutating the source must reflect through the views — proves no copy. + v.set(5, 999f); // index 5 is in sub0 (offset 0..15) + assertEquals(999f, sub0.get(5), 0f); + + v.set(20, 888f); // index 20 is in sub1 (offset 16..31) + assertEquals(888f, sub1.get(4), 0f); + } + + @Test + public void pqTrainingAllocationStaysBoundedPerVector() throws Exception + { + int dim = 64; + int n = 2_000; + int m = 8; + int clusterCount = 16; + + Random r = new Random(0xC0DEFACE); + List> training = new ArrayList<>(); + for (int i = 0; i < n; i++) { + VectorFloat v = VTS.createFloatVector(dim); + for (int j = 0; j < dim; j++) v.set(j, r.nextFloat() * 2f - 1f); + training.add(v); + } + RandomAccessVectorValues ravv = new ListRandomAccessVectorValues(training, dim); + + // Warm-up — trigger class loading, JIT + ProductQuantization.compute(ravv, m, clusterCount, false); + + ThreadMXBean tmx = (ThreadMXBean) ManagementFactory.getThreadMXBean(); + long tid = Thread.currentThread().getId(); + long before = tmx.getThreadAllocatedBytes(tid); + ProductQuantization pq = ProductQuantization.compute(ravv, m, clusterCount, false); + long after = tmx.getThreadAllocatedBytes(tid); + + long perTrainingVector = (after - before) / n; + System.out.printf("PQTrainingAllocationTest: %d B allocated per training vector (n=%d, dim=%d, M=%d, K=%d)%n", + perTrainingVector, n, dim, m, clusterCount); + + // If getSubVector materialized every subvector, we'd allocate M*(dim/M)*4 = dim*4 + // = 256 B per training vector just for subvectors, plus wrapper overhead. With + // view-based extraction the per-vector cost is dominated by KMeans centroids, which + // amortize over the whole training set. Guard at 2x the pre-fix baseline minus + // subvectors; if someone reintroduces the allocation, the ratio explodes. + long budget = (long) dim * Float.BYTES * 2; + assertTrue("per-vector allocation " + perTrainingVector + " exceeds budget " + budget + + " — getSubVector may have regressed to materialization", + perTrainingVector < budget); + assertEquals(m, pq.getSubspaceCount()); + } +} diff --git a/jvector-tests/src/test/java/io/github/jbellis/jvector/vector/VectorFloatSubviewTest.java b/jvector-tests/src/test/java/io/github/jbellis/jvector/vector/VectorFloatSubviewTest.java new file mode 100644 index 000000000..aa980ed49 --- /dev/null +++ b/jvector-tests/src/test/java/io/github/jbellis/jvector/vector/VectorFloatSubviewTest.java @@ -0,0 +1,135 @@ +/* + * Copyright DataStax, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.github.jbellis.jvector.vector; + +import io.github.jbellis.jvector.vector.types.VectorFloat; +import io.github.jbellis.jvector.vector.types.VectorTypeSupport; +import org.junit.jupiter.api.Test; + +import java.nio.ByteBuffer; +import java.nio.ByteOrder; + +import static org.junit.jupiter.api.Assertions.*; + +class VectorFloatSubviewTest +{ + private static final VectorTypeSupport VTS = + VectorizationProvider.getInstance().getVectorTypeSupport(); + + @Test + void arrayVectorFloatSubviewSharesBackingStorage() + { + float[] raw = {10f, 20f, 30f, 40f, 50f}; + ArrayVectorFloat src = new ArrayVectorFloat(raw); + VectorFloat view = src.subview(1, 3); + assertInstanceOf(ArraySliceVectorFloat.class, view); + assertEquals(3, view.length()); + assertEquals(20f, view.get(0), 0f); + assertEquals(40f, view.get(2), 0f); + // mutating the source is visible through the view + raw[1] = 99f; + assertEquals(99f, view.get(0), 0f); + } + + @Test + void bufferVectorFloatSubviewSharesBackingStorage() + { + ByteBuffer bb = ByteBuffer.allocate(6 * Float.BYTES).order(ByteOrder.LITTLE_ENDIAN); + for (int i = 0; i < 6; i++) bb.putFloat(i * 1.5f); + bb.rewind(); + BufferVectorFloat src = new BufferVectorFloat(bb); + VectorFloat view = src.subview(2, 3); + assertInstanceOf(BufferVectorFloat.class, view); + assertEquals(3, view.length()); + assertEquals(3f, view.get(0), 0f); + assertEquals(6f, view.get(2), 0f); + // mutating the source is visible through the view + bb.putFloat(2 * Float.BYTES, 99f); + assertEquals(99f, view.get(0), 0f); + } + + @Test + void nestedSubviewIsAliasNotCopy() + { + float[] raw = new float[20]; + for (int i = 0; i < raw.length; i++) raw[i] = i; + ArrayVectorFloat src = new ArrayVectorFloat(raw); + VectorFloat outer = src.subview(5, 10); // elements 5..14 + VectorFloat inner = outer.subview(2, 3); // elements 7..9 + assertEquals(3, inner.length()); + assertEquals(7f, inner.get(0), 0f); + assertEquals(9f, inner.get(2), 0f); + raw[7] = 777f; + assertEquals(777f, inner.get(0), 0f); + } + + @Test + void fullLengthSubviewReturnsSelf() + { + ArrayVectorFloat src = new ArrayVectorFloat(new float[]{1f, 2f, 3f}); + assertSame(src, src.subview(0, 3), "full-range subview should return the source"); + } + + @Test + void subviewParticipatesInSimdDistanceComputation() + { + // Build two full-size vectors; assert that squareL2Distance over (view, view) yields + // the same result as (materialized subvector, materialized subvector). + int dim = 64, sub = 16, off = 16; + float[] a = new float[dim], b = new float[dim]; + for (int i = 0; i < dim; i++) { + a[i] = (float) Math.sin(i * 0.1); + b[i] = (float) Math.cos(i * 0.1); + } + ArrayVectorFloat av = new ArrayVectorFloat(a); + ArrayVectorFloat bv = new ArrayVectorFloat(b); + + float viaView = VectorUtil.squareL2Distance(av.subview(off, sub), bv.subview(off, sub)); + float viaCopy = VectorUtil.squareL2Distance( + VTS.createFloatVector(java.util.Arrays.copyOfRange(a, off, off + sub)), + VTS.createFloatVector(java.util.Arrays.copyOfRange(b, off, off + sub))); + assertEquals(viaCopy, viaView, 1e-5f); + } + + @Test + void subviewMatchesDotProductAndCosine() + { + int dim = 48, off = 8, sub = 24; + float[] a = new float[dim], b = new float[dim]; + for (int i = 0; i < dim; i++) { + a[i] = 1f + i * 0.25f; + b[i] = 2f - i * 0.125f; + } + VectorFloat av = VTS.createFloatVector(a); + VectorFloat bv = VTS.createFloatVector(b); + VectorFloat aCopy = VTS.createFloatVector(java.util.Arrays.copyOfRange(a, off, off + sub)); + VectorFloat bCopy = VTS.createFloatVector(java.util.Arrays.copyOfRange(b, off, off + sub)); + + assertEquals(VectorUtil.dotProduct(aCopy, bCopy), + VectorUtil.dotProduct(av.subview(off, sub), bv.subview(off, sub)), 1e-5f); + assertEquals(VectorUtil.cosine(aCopy, bCopy), + VectorUtil.cosine(av.subview(off, sub), bv.subview(off, sub)), 1e-5f); + } + + @Test + void rejectsOutOfRangeSubview() + { + ArrayVectorFloat src = new ArrayVectorFloat(new float[5]); + assertThrows(IllegalArgumentException.class, () -> src.subview(-1, 2)); + assertThrows(IllegalArgumentException.class, () -> src.subview(0, 6)); + assertThrows(IllegalArgumentException.class, () -> src.subview(3, 3)); + } +} diff --git a/jvector-twenty/src/main/java/io/github/jbellis/jvector/vector/PanamaVectorUtilSupport.java b/jvector-twenty/src/main/java/io/github/jbellis/jvector/vector/PanamaVectorUtilSupport.java index 7e29138e8..51b9eceea 100644 --- a/jvector-twenty/src/main/java/io/github/jbellis/jvector/vector/PanamaVectorUtilSupport.java +++ b/jvector-twenty/src/main/java/io/github/jbellis/jvector/vector/PanamaVectorUtilSupport.java @@ -53,6 +53,9 @@ protected FloatVector fromVectorFloat(VectorSpecies SPEC, VectorFloat (long) offset * Float.BYTES, bv.byteOrder()); } + if (vector instanceof ArraySliceVectorFloat asv) { + return FloatVector.fromArray(SPEC, asv.get(), asv.arrayOffset() + offset); + } return FloatVector.fromArray(SPEC, ((ArrayVectorFloat) vector).get(), offset); } @@ -66,6 +69,9 @@ protected FloatVector fromVectorFloat(VectorSpecies SPEC, VectorFloat } return FloatVector.fromArray(SPEC, scratch, 0); } + if (vector instanceof ArraySliceVectorFloat asv) { + return FloatVector.fromArray(SPEC, asv.get(), asv.arrayOffset() + offset, indices, indicesOffset); + } return FloatVector.fromArray(SPEC, ((ArrayVectorFloat)vector).get(), offset, indices, indicesOffset); } @@ -77,6 +83,10 @@ protected void intoVectorFloat(FloatVector vector, VectorFloat v, int offset) bv.byteOrder()); return; } + if (v instanceof ArraySliceVectorFloat asv) { + vector.intoArray(asv.get(), asv.arrayOffset() + offset); + return; + } vector.intoArray(((ArrayVectorFloat) v).get(), offset); }