From 727563d0e86c3bc9fcd243566865727ce2460464 Mon Sep 17 00:00:00 2001 From: Ted Willke Date: Mon, 22 Dec 2025 21:33:34 -0800 Subject: [PATCH 001/243] Initial commit of the ASH indexing and vector aspects, plus some basic testing programs. Not executable yet. --- .../jvector/quantization/ASHVectors.java | 247 +++++ .../quantization/AsymmetricHashing.java | 937 ++++++++++++++++++ .../jbellis/jvector/example/DistancesASH.java | 153 +++ .../example/TestAshRunWithoutTraining.java | 43 + 4 files changed, 1380 insertions(+) create mode 100644 jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHVectors.java create mode 100644 jvector-base/src/main/java/io/github/jbellis/jvector/quantization/AsymmetricHashing.java create mode 100644 jvector-examples/src/main/java/io/github/jbellis/jvector/example/DistancesASH.java create mode 100644 jvector-examples/src/main/java/io/github/jbellis/jvector/example/TestAshRunWithoutTraining.java diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHVectors.java b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHVectors.java new file mode 100644 index 000000000..b1f822ea2 --- /dev/null +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHVectors.java @@ -0,0 +1,247 @@ +/* + * 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 io.github.jbellis.jvector.disk.RandomAccessReader; +import io.github.jbellis.jvector.graph.similarity.ScoreFunction; +import io.github.jbellis.jvector.util.RamUsageEstimator; +import io.github.jbellis.jvector.vector.VectorSimilarityFunction; +import io.github.jbellis.jvector.vector.VectorUtil; +import io.github.jbellis.jvector.vector.types.VectorFloat; + +import java.io.DataOutput; +import java.io.IOException; +import java.util.Arrays; +import java.util.Objects; + +/** + * Container for ASH-compressed vectors with a minimal scoring interface. + * + *

+ * This class intentionally implements only the functionality required for: + *

+ * + *

+ * TODO (future work): + *

+ * + *

+ * NOTE: + * This class avoids PQ/NVQ feature plumbing and graph integration by design. + * Those concerns belong to the scoring plane and will be addressed after + * baseline performance is established. + */ +public class ASHVectors implements CompressedVectors { + + final AsymmetricHashing ash; + final AsymmetricHashing.QuantizedVector[] compressedVectors; + + /** + * Initialize ASHVectors with an array of ASH-compressed vectors. + * + *

+ * The array is treated as immutable after construction. + */ + public ASHVectors(AsymmetricHashing ash, + AsymmetricHashing.QuantizedVector[] compressedVectors) { + this.ash = ash; + this.compressedVectors = compressedVectors; + } + + @Override + public int count() { + return compressedVectors.length; + } + + /** + * Serialize the compressor followed by the compressed vectors. + * + *

+ * NOTE: + * This format is intentionally simple and versioned only at the compressor level. + * Additional per-vector metadata (e.g., multiple landmarks) must be versioned + * carefully when introduced. + */ + @Override + public void write(DataOutput out, int version) throws IOException { + // Write ASH compressor first + ash.write(out, version); + + // Write vector count + out.writeInt(compressedVectors.length); + + // Write vectors + for (var v : compressedVectors) { + v.write(out, ash.quantizedDim); + } + } + + public static ASHVectors load(RandomAccessReader in) throws IOException { + var ash = AsymmetricHashing.load(in); + + int size = in.readInt(); + if (size < 0) { + throw new IOException("Invalid compressed vector count " + size); + } + + var compressedVectors = new AsymmetricHashing.QuantizedVector[size]; + for (int i = 0; i < size; i++) { + compressedVectors[i] = + AsymmetricHashing.QuantizedVector.load(in, ash.quantizedDim); + } + + return new ASHVectors(ash, compressedVectors); + } + + /** + * Return an approximate score function for the given query. + * + *

+ * Baseline implementation: + *

+ * + *

+ * TODO: + *

+ */ + @Override + public ScoreFunction.ApproximateScoreFunction scoreFunctionFor( + VectorFloat query, + VectorSimilarityFunction similarityFunction) { + + // Encode query once + var qv = AsymmetricHashing.QuantizedVector.createEmpty(ash.quantizedDim); + ash.encodeTo(query, qv); + final long[] queryBits = qv.binaryVector; + + return node -> { + var v = compressedVectors[node]; + + // TODO: + // - Replace with proper ASH scoring. + // - Consider affine scaling or normalization. + int hamming = VectorUtil.hammingDistance(queryBits, v.binaryVector); + + // Higher similarity is better + return -hamming; + }; + } + + /** + * For ASH, precomputed and non-precomputed scoring are currently identical. + * + *

+ * TODO: + * - Evaluate caching projected queries or other query-dependent state. + */ + @Override + public ScoreFunction.ApproximateScoreFunction precomputedScoreFunctionFor( + VectorFloat query, + VectorSimilarityFunction similarityFunction) { + return scoreFunctionFor(query, similarityFunction); + } + + /** + * Diversity-aware scoring is not supported for ASH at this stage. + * + *

+ * TODO: + * - Define whether diversity should be measured via: + * * Hamming distance + * * Landmark-aware distance + * * Full-vector reranking + */ + @Override + public ScoreFunction.ApproximateScoreFunction diversityFunctionFor( + int node1, + VectorSimilarityFunction similarityFunction) { + throw new UnsupportedOperationException("ASH diversity scoring not implemented"); + } + + public AsymmetricHashing.QuantizedVector get(int ordinal) { + return compressedVectors[ordinal]; + } + + @Override + public int getOriginalSize() { + return ash.originalDimension * Float.BYTES; + } + + @Override + public int getCompressedSize() { + return ash.compressedVectorSize(); + } + + @Override + public AsymmetricHashing getCompressor() { + return ash; + } + + @Override + public long ramBytesUsed() { + int REF_BYTES = RamUsageEstimator.NUM_BYTES_OBJECT_REF; + int AH_BYTES = RamUsageEstimator.NUM_BYTES_ARRAY_HEADER; + + long vectorsArraySize = + AH_BYTES + (long) REF_BYTES * compressedVectors.length; + long vectorsDataSize = + (long) ash.compressedVectorSize() * compressedVectors.length; + + return ash.ramBytesUsed() + vectorsArraySize + vectorsDataSize; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + + ASHVectors that = (ASHVectors) o; + return Objects.equals(ash, that.ash) + && Arrays.equals(compressedVectors, that.compressedVectors); + } + + @Override + public int hashCode() { + int result = Objects.hash(ash); + result = 31 * result + Arrays.hashCode(compressedVectors); + return result; + } + + @Override + public String toString() { + return "ASHVectors{count=" + compressedVectors.length + + ", ash=" + ash + '}'; + } +} diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/AsymmetricHashing.java b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/AsymmetricHashing.java new file mode 100644 index 000000000..22e1201cf --- /dev/null +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/AsymmetricHashing.java @@ -0,0 +1,937 @@ +/* + * 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 io.github.jbellis.jvector.annotations.VisibleForTesting; +import io.github.jbellis.jvector.disk.RandomAccessReader; +import io.github.jbellis.jvector.graph.RandomAccessVectorValues; +import io.github.jbellis.jvector.graph.disk.OnDiskGraphIndex; +import io.github.jbellis.jvector.util.Accountable; +import io.github.jbellis.jvector.vector.VectorUtil; +import io.github.jbellis.jvector.vector.VectorizationProvider; +import io.github.jbellis.jvector.vector.types.ByteSequence; +import io.github.jbellis.jvector.vector.types.VectorFloat; +import io.github.jbellis.jvector.vector.types.VectorTypeSupport; + +import java.io.DataOutput; +import java.io.IOException; +import java.util.Arrays; +import java.util.Objects; +import java.util.concurrent.ForkJoinPool; +import java.util.Random; +import java.util.stream.IntStream; +import org.apache.commons.math3.linear.*; + +import static io.github.jbellis.jvector.quantization.KMeansPlusPlusClusterer.UNWEIGHTED; + +/** + * Asymmetric Hashing (ASH) for float vectors. + * Encodes each vector into a fixed-length binary code using a learned or random + * orthonormal projection and sign thresholding. + */ +public class AsymmetricHashing implements VectorCompressor, Accountable { + public static final int ITQ = 1, LANDING = 2, RANDOM = 3; + private static final int MAGIC = 0x75EC4012; // TODO update the magic number? + + private static final int HEADER_BITS = 48; // fixed ASH per-vector header + + private static final VectorTypeSupport vectorTypeSupport = + VectorizationProvider.getInstance().getVectorTypeSupport(); + + // --------------------------------------------------------------------- + // Index-wide immutable state + // --------------------------------------------------------------------- + + /** Mean of the dataset; also used as degenerate landmark */ + public final VectorFloat globalMean; + + /** Original (uncompressed) dimensionality */ + public final int originalDimension; + + /** Total bits per encoded vector (header + body) */ + public final int encodedBits; + + /** Number of bits in the binary body */ + public final int quantizedDim; + + /** Optimizer / learning mode */ + public final int optimizer; + + /** Learned or random Stiefel transform */ + public final StiefelTransform stiefelTransform; + + /** Debug hook to disable learning paths */ + @VisibleForTesting + public boolean learn = true; + + // --------------------------------------------------------------------- + // Quantizer instances (cached) + // --------------------------------------------------------------------- + + private final BinaryQuantizer randomQuantizer = new RandomBinaryQuantizer(); + private final BinaryQuantizer itqQuantizer = new ItqBinaryQuantizer(); + private final BinaryQuantizer landingQuantizer = new LandingBinaryQuantizer(); + + private BinaryQuantizer quantizer() { + switch (optimizer) { + case RANDOM: + return randomQuantizer; + case ITQ: + return itqQuantizer; + case LANDING: + return landingQuantizer; + default: + throw new IllegalStateException("Unknown optimizer " + optimizer); + } + } + + /** + * Class constructor. + * @param originalDim the dimensionality of the vectors to be quantized + * @param globalMean the mean of the database (its average vector) + */ + private AsymmetricHashing(int originalDim, + int encodedBits, + int quantizedDim, + int optimizer, + VectorFloat globalMean, + StiefelTransform stiefelTransform) { + this.originalDimension = originalDim; + this.encodedBits = encodedBits; + this.quantizedDim = quantizedDim; + this.optimizer = optimizer; + this.stiefelTransform = Objects.requireNonNull(stiefelTransform, "stiefelTransform"); + this.globalMean = Objects.requireNonNull(globalMean, "globalMean"); + + if (globalMean.length() != originalDimension) { + throw new IllegalArgumentException( + "Global mean length does not match original dimension"); + } + if (quantizedDim < 1) { + throw new IllegalArgumentException("quantizedDim must be > 0"); + } + } + + /** + * Initialize ASH index-wide parameters. + * + * @param ravv the vectors to quantize + * @param optimizer the optimizer to use + * @param encodedBits the number of bits used to encode vector, including the header + */ + public static AsymmetricHashing initialize(RandomAccessVectorValues ravv, + int optimizer, + int encodedBits) { + + final int quantizedDim = encodedBits - HEADER_BITS; + if (quantizedDim < 1) { + throw new IllegalArgumentException( + "Illegal ASH quantized dimensionality: " + quantizedDim); + } + + var ravvCopy = ravv.threadLocalSupplier().get(); + int originalDim = ravvCopy.getVector(0).length(); + + // Compute global mean + var globalMean = vectorTypeSupport.createFloatVector(originalDim); + for (int i = 0; i < ravvCopy.size(); i++) { + VectorUtil.addInPlace(globalMean, ravvCopy.getVector(i)); + } + VectorUtil.scale(globalMean, 1.0f / ravvCopy.size()); + + StiefelTransform stiefelTransform; + if (optimizer == RANDOM) { + stiefelTransform = runWithoutTraining(originalDim, quantizedDim, new Random(42)); + } else if (optimizer == ITQ) { + throw new IllegalArgumentException("ITQ optimizer not implemented yet"); + } else if (optimizer == LANDING) { + throw new IllegalArgumentException("LANDING optimizer not implemented yet"); + } else { + throw new IllegalArgumentException("Unknown optimizer " + optimizer); + } + + return new AsymmetricHashing( + originalDim, + encodedBits, + quantizedDim, + optimizer, + globalMean, + stiefelTransform + ); + } + + /** + * Loads an ASH instance from a RandomAccessReader. + * This must mirror the ASH header writer exactly. + */ + public static AsymmetricHashing load(RandomAccessReader in) throws IOException { + + int magic = in.readInt(); + if (magic != MAGIC) { + throw new IllegalArgumentException( + String.format("Invalid ASH magic: 0x%08X", magic) + ); + } + + int version = in.readInt(); + if (version > OnDiskGraphIndex.CURRENT_VERSION) { + throw new IllegalArgumentException("Unsupported ASH version " + version); + } + + int originalDimension = in.readInt(); + int encodedBits = in.readInt(); + int quantizedDim = in.readInt(); + int optimizer = in.readInt(); + + int globalMeanLength = in.readInt(); + if (globalMeanLength <= 0) { + throw new IOException("ASH header missing globalMean"); + } + VectorFloat globalMean = vectorTypeSupport.readFloatVector(in, globalMeanLength); + + // NOTE: StiefelTransform loading will be added in Step 2. + // For now, construct a placeholder or re-run the RANDOM initializer. + StiefelTransform stiefelTransform; + + if (optimizer == RANDOM) { + // Deterministic re-construction for now + stiefelTransform = runWithoutTraining( + originalDimension, + quantizedDim, + new Random(42) + ); + } else { + throw new IllegalArgumentException( + "ASH optimizer " + optimizer + " requires learned transform; not yet supported in load()" + ); + } + + return new AsymmetricHashing( + originalDimension, + encodedBits, + quantizedDim, + optimizer, + globalMean, + stiefelTransform + ); + } + + + /** + * TODO confirm handling versioning correctly + * TODO confirm handling magic number correctly + * Writes the ASH index header to DataOutput (used for on-disk indexing) + * @param out the DataOutput into which to write the object + * @throws IOException if there is a problem writing to out. + */ + public void write(DataOutput out, int version) throws IOException { + if (version > OnDiskGraphIndex.CURRENT_VERSION) { + throw new IllegalArgumentException("Unsupported version " + version); + } + + out.writeInt(MAGIC); + out.writeInt(version); + + out.writeInt(originalDimension); + out.writeInt(encodedBits); + out.writeInt(quantizedDim); + out.writeInt(optimizer); + + out.writeInt(globalMean.length()); + vectorTypeSupport.writeFloatVector(out, globalMean); + + // NOTE: stiefelTransform serialization will be added later + } + + // --------------------------------------------------------------------- + // Index-level quantization entry point + // --------------------------------------------------------------------- + + public void quantizeVector(VectorFloat vector, + float residualNorm, + float dotWithLandmark, + short landmark, + QuantizedVector dest) { + + if (quantizedDim > originalDimension) { + throw new IllegalArgumentException( + "quantizedDim (" + quantizedDim + + ") exceeds original dimension (" + originalDimension + ")"); + } + + QuantizedVector.quantizeTo( + vector, + residualNorm, + dotWithLandmark, + landmark, + quantizedDim, + globalMean, + stiefelTransform, + quantizer(), + dest + ); + } + + // --------------------------------------------------------------------- + // Binary quantization strategies + // --------------------------------------------------------------------- + + public interface BinaryQuantizer { + void quantizeBody(VectorFloat vector, + VectorFloat globalMean, + int quantizedDim, + StiefelTransform stiefel, + long[] outWords); + } + + public static final class RandomBinaryQuantizer implements BinaryQuantizer { + + @Override + public void quantizeBody(VectorFloat vector, + VectorFloat globalMean, + int quantizedDim, + StiefelTransform stiefel, + long[] outWords) { + + RealMatrix A = stiefel.A; // quantizedDim × originalDim + int originalDim = A.getColumnDimension(); + + final int words = QuantizedVector.wordsForDims(quantizedDim); + + for (int w = 0; w < words; w++) { + long bits = 0L; + int base = w << 6; + int rem = Math.min(64, quantizedDim - base); + + for (int j = 0; j < rem; j++) { + int bitIndex = base + j; + + // Compute y_j = dot(A[j], x - μ) + double acc = 0.0; + for (int d = 0; d < originalDim; d++) { + acc += A.getEntry(bitIndex, d) + * (vector.get(d) - globalMean.get(d)); + } + + if (acc > 0.0) { + bits |= (1L << j); + } + } + + outWords[w] = bits; + } + } + } + + public static final class ItqBinaryQuantizer implements BinaryQuantizer { + @Override + public void quantizeBody(VectorFloat vector, + VectorFloat globalMean, + int quantizedDim, + StiefelTransform stiefel, + long[] outWords) { + throw new UnsupportedOperationException("ITQ not implemented"); + } + } + + public static final class LandingBinaryQuantizer implements BinaryQuantizer { + @Override + public void quantizeBody(VectorFloat vector, + VectorFloat globalMean, + int quantizedDim, + StiefelTransform stiefel, + long[] outWords) { + throw new UnsupportedOperationException("LANDING not implemented"); + } + } + + // --------------------------------------------------------------------- + // Quantized vector (per-vector data) + // --------------------------------------------------------------------- + + public static class QuantizedVector { + public float residualNorm; + public float dotWithLandmark; + public short landmark; + public long[] binaryVector; + + private QuantizedVector(float residualNorm, + float dotWithLandmark, + short landmark, + long[] binaryVector) { + this.residualNorm = residualNorm; + this.dotWithLandmark = dotWithLandmark; + this.landmark = landmark; + this.binaryVector = binaryVector; + } + + public static int wordsForDims(int quantizedDim) { + return (quantizedDim + 63) >>> 6; + } + + public static QuantizedVector createEmpty(int quantizedDim) { + return new QuantizedVector( + 0f, 0f, (short) 0, + new long[wordsForDims(quantizedDim)]); + } + + public static int serializedSizeBytes(int quantizedDim) { + return Float.BYTES + Float.BYTES + Short.BYTES + + wordsForDims(quantizedDim) * Long.BYTES; + } + + static void quantizeTo(VectorFloat vector, + float residualNorm, + float dotWithLandmark, + short landmark, + int quantizedDim, + VectorFloat globalMean, + StiefelTransform stiefel, + BinaryQuantizer quantizer, + QuantizedVector dest) { + + dest.residualNorm = residualNorm; + dest.dotWithLandmark = dotWithLandmark; + dest.landmark = landmark; + + int words = wordsForDims(quantizedDim); + if (dest.binaryVector.length < words) { + throw new IllegalArgumentException("binaryVector too short"); + } + + Arrays.fill(dest.binaryVector, 0, words, 0L); + quantizer.quantizeBody(vector, globalMean, quantizedDim, stiefel, dest.binaryVector); + } + + public void write(DataOutput out, int quantizedDim) throws IOException { + out.writeFloat(residualNorm); + out.writeFloat(dotWithLandmark); + out.writeInt(landmark); + + int words = wordsForDims(quantizedDim); + for (int i = 0; i < words; i++) { + out.writeLong(binaryVector[i]); + } + } + + public static QuantizedVector load(RandomAccessReader in, + int quantizedDim) throws IOException { + float residualNorm = in.readFloat(); + float dotWithLandmark = in.readFloat(); + short landmark = (short) in.readInt(); + + int words = wordsForDims(quantizedDim); + long[] body = new long[words]; + for (int i = 0; i < words; i++) { + body[i] = in.readLong(); + } + + return new QuantizedVector(residualNorm, dotWithLandmark, landmark, body); + } + + public static void loadInto(RandomAccessReader in, + QuantizedVector dest, + int quantizedDim) throws IOException { + dest.residualNorm = in.readFloat(); + dest.dotWithLandmark = in.readFloat(); + dest.landmark = (short) in.readInt(); + + int words = wordsForDims(quantizedDim); + for (int i = 0; i < words; i++) { + dest.binaryVector[i] = in.readLong(); + } + } + + /** + * Compares two ASH quantized vectors for exact encoded equality. + * + *

+ * Equality is defined as bitwise equality of all encoded components: + *

+ * + *

+ * Floating-point values are compared using their raw bit representations + * rather than tolerance-based comparisons. This is intentional: quantized + * vectors are expected to be deterministic outputs of the encoder, and + * approximate equality would be ambiguous and unsafe for hashing. + *

+ */ + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + + QuantizedVector that = (QuantizedVector) o; + return Float.floatToIntBits(residualNorm) == Float.floatToIntBits(that.residualNorm) + && Float.floatToIntBits(dotWithLandmark) == Float.floatToIntBits(that.dotWithLandmark) + && landmark == that.landmark + && Arrays.equals(binaryVector, that.binaryVector); + } + + /** + * Hash code consistent with {@link #equals(Object)}. + * + *

+ * This hash reflects the full encoded state of the vector and is suitable + * for use in hash-based collections during testing, debugging, or caching. + *

+ */ + @Override + public int hashCode() { + int result = Integer.hashCode(Float.floatToIntBits(residualNorm)); + result = 31 * result + Integer.hashCode(Float.floatToIntBits(dotWithLandmark)); + result = 31 * result + Short.hashCode(landmark); + result = 31 * result + Arrays.hashCode(binaryVector); + return result; + } + + } + + /** + * Private helper to provide byte storage for binary body + * @param nBits the number of bits to store + * @return the byte sequence + */ + private static ByteSequence createBodyByteSequenceForBits(int nBits) { + int nBytes = (nBits + 7) >>> 3; + return vectorTypeSupport.createByteSequence(nBytes); + } + + /** + * Orthonormal linear transform on the Stiefel manifold. + * + * W : originalDim × quantizedDim + * A : quantizedDim × originalDim, where A = Wᵀ + * + * The transform is applied as: + * y = A · (x − μ) + */ + public static final class StiefelTransform { + + /** Forward projection (column-orthonormal). */ + public final RealMatrix W; + + /** Transpose of W, used for projection. */ + public final RealMatrix A; + + public StiefelTransform(RealMatrix W) { + this.W = W; + this.A = W.transpose(); + } + } + + /** + * RANDOM (untrained) Stiefel initialization. + * + * Generates a random orthonormal projection from R^originalDim → R^quantizedDim + * by sampling a Gaussian matrix and orthonormalizing via QR decomposition. + * + * A deterministic sign normalization is applied so results are reproducible + * across runs and linear algebra backends. + * + * @param originalDim dimensionality of the input vectors + * @param quantizedDim dimensionality of the binary embedding + * @param rng random number generator + */ + public static StiefelTransform runWithoutTraining(int originalDim, + int quantizedDim, + Random rng) { + + if (quantizedDim <= 0 || quantizedDim > originalDim) { + throw new IllegalArgumentException( + "Invalid quantizedDim=" + quantizedDim + + " for originalDim=" + originalDim + ); + } + + // Sample Gaussian matrix G ∈ R^{originalDim × quantizedDim} + double[][] gaussian = new double[originalDim][quantizedDim]; + for (int i = 0; i < originalDim; i++) { + for (int j = 0; j < quantizedDim; j++) { + gaussian[i][j] = rng.nextGaussian(); + } + } + + RealMatrix G = MatrixUtils.createRealMatrix(gaussian); + + // Thin QR decomposition: G = Q R + QRDecomposition qr = new QRDecomposition(G); + RealMatrix Q = qr.getQ(); // originalDim × quantizedDim + RealMatrix R = qr.getR(); + + // Normalize QR sign ambiguity for determinism (RANDOM only) + int diag = Math.min(R.getRowDimension(), R.getColumnDimension()); + for (int j = 0; j < diag; j++) { + if (R.getEntry(j, j) < 0.0) { + for (int i = 0; i < Q.getRowDimension(); i++) { + Q.setEntry(i, j, -Q.getEntry(i, j)); + } + } + } + + return new StiefelTransform(Q); + } + + @Override + @Deprecated + public CompressedVectors createCompressedVectors(Object[] compressedVectors) { + throw new UnsupportedOperationException("Deprecated createCompressedVectors() method not supported"); + } + + + /** + * Encodes all vectors using ASH, honoring the caller-provided ForkJoinPool. + * + *

Concurrency model:

+ * + * + *

+ * This design intentionally avoids {@code IntStream.parallel()} and other + * constructs that implicitly use {@link ForkJoinPool#commonPool()}. + * All parallelism is derived exclusively from the supplied executor, + * ensuring predictable CPU usage and isolation from unrelated workloads. + *

+ * + *

Correctness guarantees:

+ * + * + *

+ * This implementation favors clarity and correctness over maximal parallel + * granularity. More aggressive chunking or SIMD-aware partitioning can be + * layered on later without changing observable semantics. + *

+ */ + + @Override + public ASHVectors encodeAll(RandomAccessVectorValues ravv, ForkJoinPool simdExecutor) { + final int n = ravv.size(); + + // The caller controls parallelism; we derive a bounded number of tasks + // directly from the executor rather than using parallel streams. + final int parallelism = Math.max(1, simdExecutor.getParallelism()); + + // Each task processes a contiguous range of ordinals. + // Chunking by contiguous ranges preserves cache locality and + // guarantees each ordinal is written exactly once. + final int chunkSize = (n + parallelism - 1) / parallelism; + + final QuantizedVector[] out = new QuantizedVector[n]; + final var ravvSupplier = ravv.threadLocalSupplier(); + + // NOTE: + // We intentionally do NOT use IntStream.parallel() here. + // Parallel streams always execute on ForkJoinPool.commonPool(), + // which would ignore the caller-provided executor and risk + // oversubscribing CPU resources. + var tasks = new java.util.ArrayList>(parallelism); + + for (int t = 0; t < parallelism; t++) { + final int start = t * chunkSize; + final int end = Math.min(n, start + chunkSize); + if (start >= end) { + break; + } + + tasks.add(simdExecutor.submit(() -> { + // Each task uses its own thread-local RAVV view. + var localRavv = ravvSupplier.get(); + + for (int i = start; i < end; i++) { + VectorFloat v = localRavv.getVector(i); + + // Contract: missing vectors are encoded as zero vectors. + if (v != null) { + out[i] = encode(v); + } else { + out[i] = QuantizedVector.createEmpty(quantizedDim); + } + } + })); + } + + // Join establishes a happens-before relationship, ensuring safe publication + // of all writes to the output array. + for (var task : tasks) { + task.join(); + } + + return new ASHVectors(this, out); + } + + + /** + * Encodes the input vector using ASH. + * @return the header and binary vector payload + */ + @Override + public QuantizedVector encode(VectorFloat vector) { + var qv = QuantizedVector.createEmpty(this.quantizedDim); + encodeTo(vector, qv); + return qv; + } + + // NOTE: + // This is the single-landmark baseline configuration. + // All vectors are encoded relative to the global mean (landmark 0). + // The design intentionally leaves room for multiple landmarks in the future + // without changing the on-disk format. + @Override + public void encodeTo(VectorFloat vector, QuantizedVector dest) { + // Single-landmark baseline: global mean + // Landmark index is always 0 in this mode + short landmark = 0; + + // Compute dot product with the landmark + float dotWithLandmark = VectorUtil.dotProduct(vector, globalMean); + + // Compute residual norm ||x - μ||₂ + // NOTE: + // We store the true L2 norm (not squared) to match ASH semantics and the paper. + // squareL2Distance is used for efficiency; the sqrt cost is negligible relative + // to projection and hashing. + float sqDist = VectorUtil.squareL2Distance(vector, globalMean); + float residualNorm = (float) Math.sqrt(sqDist); + + // Binary encoding uses sign(A · (x − μ)) internally + quantizeVector(vector, residualNorm, dotWithLandmark, landmark, dest); + } + + @Override + public int compressorSize() { + // Must exactly match write(out, version) + int size = 0; + size += Integer.BYTES; // MAGIC + size += Integer.BYTES; // version + size += Integer.BYTES; // originalDimension + size += Integer.BYTES; // encodedBits + size += Integer.BYTES; // quantizedDim + size += Integer.BYTES; // optimizer + size += Integer.BYTES; // globalMean length + size += Float.BYTES * globalMean.length(); + return size; + } + + @Override + public int compressedVectorSize() { + int words = QuantizedVector.wordsForDims(quantizedDim); + return Float.BYTES // residualNorm + + Float.BYTES // dotWithLandmark + + Integer.BYTES // landmark + + words * Long.BYTES; // binary payload + } + + @Override + public double reconstructionError(VectorFloat vector) { + throw new UnsupportedOperationException("Reconstruction error not defined for ASH"); + } + + @Override + public long ramBytesUsed() { + long bytes = 0L; + + if (globalMean != null) { + bytes += globalMean.ramBytesUsed(); + } + + if (stiefelTransform != null) { + bytes += estimateMatrixBytes(stiefelTransform.W); + bytes += estimateMatrixBytes(stiefelTransform.A); + } + + return bytes; + } + + private static long estimateMatrixBytes(RealMatrix m) { + if (m == null) return 0L; + int rows = m.getRowDimension(); + int cols = m.getColumnDimension(); + return (long) rows * cols * Double.BYTES + + (long) rows * Long.BYTES; + } + + // Exact, element-wise comparison of VectorFloat contents. + // We use Float.floatToIntBits to handle -0.0f, NaN, and ensure + // stable, bitwise equality suitable for hashing. + private static boolean vectorEquals(VectorFloat a, VectorFloat b) { + if (a == b) return true; + if (a == null || b == null) return false; + if (a.length() != b.length()) return false; + + for (int i = 0; i < a.length(); i++) { + if (Float.floatToIntBits(a.get(i)) != Float.floatToIntBits(b.get(i))) { + return false; + } + } + return true; + } + + // Hash based on the raw bit representation of each float. + // This mirrors vectorEquals and guarantees hash consistency. + private static int vectorHash(VectorFloat v) { + if (v == null) return 0; + int h = 1; + for (int i = 0; i < v.length(); i++) { + h = 31 * h + Integer.hashCode(Float.floatToIntBits(v.get(i))); + } + return h; + } + + // Exact comparison of two RealMatrix instances by dimensions and entries. + // We intentionally avoid RealMatrix.equals() and tolerance-based checks. + // ASH initialization is deterministic, so bitwise equality is expected + // and required for robust hashing semantics. + private static boolean matrixEquals(RealMatrix a, RealMatrix b) { + if (a == b) return true; + if (a == null || b == null) return false; + if (a.getRowDimension() != b.getRowDimension() + || a.getColumnDimension() != b.getColumnDimension()) { + return false; + } + + for (int i = 0; i < a.getRowDimension(); i++) { + for (int j = 0; j < a.getColumnDimension(); j++) { + if (Double.doubleToLongBits(a.getEntry(i, j)) + != Double.doubleToLongBits(b.getEntry(i, j))) { + return false; + } + } + } + return true; + } + + // Hash code derived from matrix dimensions and raw double bits. + // Only the forward projection matrix (W) is hashed; the transpose (A) + // is derived and does not need to be hashed separately. + private static int matrixHash(RealMatrix m) { + if (m == null) return 0; + int h = 1; + h = 31 * h + m.getRowDimension(); + h = 31 * h + m.getColumnDimension(); + + for (int i = 0; i < m.getRowDimension(); i++) { + for (int j = 0; j < m.getColumnDimension(); j++) { + long bits = Double.doubleToLongBits(m.getEntry(i, j)); + h = 31 * h + (int) (bits ^ (bits >>> 32)); + } + } + return h; + } + + /** + * Compares this ASH compressor to another for encoding equivalence. + * + *

+ * Two compressors are equal if they would produce identical encoded outputs + * (including binary codes and auxiliary scalars) for the same input vectors. + *

+ * + *

+ * Equality therefore requires: + *

+ * + *

+ * Note that this is a strong notion of equality. We deliberately + * avoid approximate floating-point comparisons because ASH initialization + * and training are deterministic, and weaker equality would break hashing + * and cache correctness. + *

+ */ + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + + AsymmetricHashing that = (AsymmetricHashing) o; + return originalDimension == that.originalDimension + && encodedBits == that.encodedBits + && quantizedDim == that.quantizedDim + && optimizer == that.optimizer + && vectorEquals(this.globalMean, that.globalMean) + && matrixEquals( + this.stiefelTransform == null ? null : this.stiefelTransform.W, + that.stiefelTransform == null ? null : that.stiefelTransform.W + ); + } + + /** + * Hash code consistent with {@link #equals(Object)}. + * + *

+ * The hash incorporates all parameters and learned state that affect encoding, + * including the contents of the global mean and Stiefel transform. This ensures + * safe use of ASH compressors as map keys or cache entries. + *

+ */ + @Override + public int hashCode() { + int result = Integer.hashCode(originalDimension); + result = 31 * result + Integer.hashCode(encodedBits); + result = 31 * result + Integer.hashCode(quantizedDim); + result = 31 * result + Integer.hashCode(optimizer); + result = 31 * result + vectorHash(globalMean); + result = 31 * result + matrixHash( + stiefelTransform == null ? null : stiefelTransform.W + ); + return result; + } + + @Override + public String toString() { + String optimizerName; + switch (optimizer) { + case RANDOM: + optimizerName = "RANDOM"; + break; + case ITQ: + optimizerName = "ITQ"; + break; + case LANDING: + optimizerName = "LANDING"; + break; + default: + optimizerName = "UNKNOWN(" + optimizer + ")"; + } + + return String.format( + "AsymmetricHashing[origDim=%d, encodedBits=%d, quantizedDim=%d, optimizer=%s, landmarks=1]", + originalDimension, + encodedBits, + quantizedDim, + optimizerName + ); + } +} diff --git a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/DistancesASH.java b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/DistancesASH.java new file mode 100644 index 000000000..b9e5ee516 --- /dev/null +++ b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/DistancesASH.java @@ -0,0 +1,153 @@ +/* + * 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; + +import io.github.jbellis.jvector.example.util.SiftLoader; +import io.github.jbellis.jvector.graph.ListRandomAccessVectorValues; +import io.github.jbellis.jvector.quantization.AsymmetricHashing; +import io.github.jbellis.jvector.vector.VectorSimilarityFunction; +import io.github.jbellis.jvector.vector.VectorUtil; +import io.github.jbellis.jvector.vector.types.VectorFloat; + +import java.io.IOException; +import java.util.List; + +import static java.lang.Math.abs; + +// this class uses explicit typing instead of `var` for easier reading when excerpted for instructional use +public class DistancesASH { + public static void testASHEncodings(String filenameBase, String filenameQueries, VectorSimilarityFunction vsf) throws IOException { + List> vectors = SiftLoader.readFvecs(filenameBase); + List> queries = SiftLoader.readFvecs(filenameQueries); + + int dimension = vectors.get(0).length(); + int encodedDim = dimension; // change later + int nQueries = 100; + int nVectors = 10_000; + + vectors = vectors.subList(0, nVectors); + + System.out.format("\t%d base and %d query vectors loaded, dimensions %d%n", + vectors.size(), queries.size(), vectors.get(0).length()); + + // Generate a BQ for random vectors + var ravv = new ListRandomAccessVectorValues(vectors, dimension); + var ash = AsymmetricHashing.initialize(ravv, AsymmetricHashing.RANDOM, encodedDim); + + // Compress the vectors + long startTime = System.nanoTime(); + var ashVecs = ash.encodeAll(ravv); + long endTime = System.nanoTime(); + double duration = (double) (endTime - startTime) / 1_000_000_000; + System.out.println("\tEncoding took " + duration + " seconds"); + + // compare the encoded similarities to the raw + double distanceError = 0; + for (int i = 0; i < nQueries; i++) { + var q = queries.get(i); + if (VectorUtil.dotProduct(q, q) == 0) { + continue; + } + var f = ashVecs.scoreFunctionFor(q, vsf); + + for (int j = 0; j < nVectors; j++) { + var v = vectors.get(j); + distanceError += abs(f.similarityTo(j) - vsf.compare(q, v)); + } + } + distanceError /= nQueries * nVectors; + + System.out.println("\t" + vsf + " error " + distanceError); + + + float dummyAccumulator = 0; + + startTime = System.nanoTime(); + for (int i = 0; i < nQueries; i++) { + var q = queries.get(i); + if (VectorUtil.dotProduct(q, q) == 0) { + continue; + } + var f = ashVecs.scoreFunctionFor(q, vsf); + + for (int j = 0; j < nVectors; j++) { + dummyAccumulator += f.similarityTo(j); + } + } + endTime = System.nanoTime(); + duration = (double) (endTime - startTime) / 1_000_000_000; + System.out.println("\tASH Distance computations took " + duration + " seconds"); + + startTime = System.nanoTime(); + for (int i = 0; i < nQueries; i++) { + var q = queries.get(i); + if (VectorUtil.dotProduct(q, q) == 0) { + continue; + } + + for (int j = 0; j < nVectors; j++) { + var v = vectors.get(j); + dummyAccumulator += vsf.compare(q, v); + } + } + endTime = System.nanoTime(); + duration = (double) (endTime - startTime) / 1_000_000_000; + System.out.println("\tFloat Distance computations took " + duration + " seconds"); + + System.out.println("\tdummyAccumulator: " + dummyAccumulator); + System.out.println("--"); + } + + public static void runSIFT() throws IOException { + System.out.println("Running siftsmall"); + + var baseVectors = "siftsmall/siftsmall_base.fvecs"; + var queryVectors = "siftsmall/siftsmall_query.fvecs"; + testASHEncodings(baseVectors, queryVectors, VectorSimilarityFunction.COSINE); + } + + public static void runADA() throws IOException { + System.out.println("Running ada_002"); + + var baseVectors = "./fvec/wikipedia_squad/100k/ada_002_100000_base_vectors.fvec"; + var queryVectors = "./fvec/wikipedia_squad/100k/ada_002_100000_query_vectors_10000.fvec"; + testASHEncodings(baseVectors, queryVectors, VectorSimilarityFunction.COSINE); + } + + public static void runColbert() throws IOException { + System.out.println("Running colbertv2"); + + var baseVectors = "./fvec/wikipedia_squad/1M/colbertv2.0_128_base_vectors_1000000.fvec"; + var queryVectors = "./fvec/wikipedia_squad/1M/colbertv2.0_128_query_vectors_100000.fvec"; + testASHEncodings(baseVectors, queryVectors, VectorSimilarityFunction.COSINE); + } + + public static void runOpenai3072() throws IOException { + System.out.println("Running text-embedding-3-large_3072"); + + var baseVectors = "./fvec/wikipedia_squad/100k/text-embedding-3-large_3072_100000_base_vectors.fvec"; + var queryVectors = "./fvec/wikipedia_squad/100k/text-embedding-3-large_3072_100000_base_vectors.fvec"; + testASHEncodings(baseVectors, queryVectors, VectorSimilarityFunction.COSINE); + } + + public static void main(String[] args) throws IOException { + runSIFT(); + runADA(); + runColbert(); +// runOpenai3072(); + } +} \ No newline at end of file diff --git a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/TestAshRunWithoutTraining.java b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/TestAshRunWithoutTraining.java new file mode 100644 index 000000000..5ada6365b --- /dev/null +++ b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/TestAshRunWithoutTraining.java @@ -0,0 +1,43 @@ +package io.github.jbellis.jvector.example; + +import io.github.jbellis.jvector.quantization.AsymmetricHashing; +import org.apache.commons.math3.linear.*; +import java.util.Random; + +public class TestAshRunWithoutTraining { + + public static void main(String[] args) { + int d = 1024; // input dimension + int nDims = 128; // target dimension + + // Dummy data matrix X only to scrape shape + RealMatrix X = MatrixUtils.createRealMatrix(10, d); // (10 × 1024) + + Random rng = new Random(42); + + // Method under test + AsymmetricHashing.StiefelTransform stiefelTransform = + AsymmetricHashing.runWithoutTraining(X, nDims, rng); + + // Inspect shapes and a few entries + RealMatrix A = stiefelTransform.A; + RealMatrix W = stiefelTransform.W; + + System.out.println("A shape = " + A.getRowDimension() + " x " + A.getColumnDimension()); + System.out.println("W shape = " + W.getRowDimension() + " x " + W.getColumnDimension()); + + // Sanity check.... + System.out.println("First few entries of A:"); + for (int i = 0; i < Math.min(3, A.getRowDimension()); i++) { + for (int j = 0; j < Math.min(3, A.getColumnDimension()); j++) { + System.out.printf("%.4f ", A.getEntry(i, j)); + } + System.out.println(); + } + + // Check orthogonality: WᵀW ≈ I + RealMatrix I = W.transpose().multiply(W); + System.out.printf("I(0,0)=%.5f, I(0,1)=%.5f%n", + I.getEntry(0, 0), I.getEntry(0, 1)); + } +} From 7a604227a0e08a6da76e6b1fe6d4814ca974c05a Mon Sep 17 00:00:00 2001 From: Ted Willke Date: Wed, 24 Dec 2025 12:51:40 -0800 Subject: [PATCH 002/243] First working version with scorer. Other minor improvements. --- .../jvector/quantization/ASHScorer.java | 135 ++++++++++ .../jvector/quantization/ASHVectors.java | 51 +--- .../quantization/AsymmetricHashing.java | 125 ++++++--- .../jbellis/jvector/example/DistancesASH.java | 249 +++++++++++++----- .../example/TestAshRunWithoutTraining.java | 9 +- 5 files changed, 423 insertions(+), 146 deletions(-) create mode 100644 jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHScorer.java diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHScorer.java b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHScorer.java new file mode 100644 index 000000000..93f35182f --- /dev/null +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHScorer.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.quantization; + +import io.github.jbellis.jvector.vector.VectorSimilarityFunction; +import io.github.jbellis.jvector.vector.VectorUtil; +import io.github.jbellis.jvector.vector.types.VectorFloat; + +/** + * Query-time scorer for ASH vectors. + * Single-landmark case (C = 1). + * NOTE: Only DOT_PRODUCT is supported for now. + */ +public final class ASHScorer { + private final AsymmetricHashing ash; + + public ASHScorer(AsymmetricHashing ash) { + this.ash = ash; + assert ash.stiefelTransform.rows == ash.quantizedDim + : "Stiefel rows must equal quantizedDim"; + } + + public ASHScoreFunction scoreFunctionFor(VectorFloat query, VectorSimilarityFunction similarityFunction) { + if (similarityFunction != VectorSimilarityFunction.DOT_PRODUCT) { + throw new UnsupportedOperationException( + "ASH scorer supports DOT_PRODUCT only (requested " + similarityFunction + ")" + ); + } + return dotProductScoreFunctionFor(query); + } + + /** + * Scorer for dot product approximation. + */ + private ASHScoreFunction dotProductScoreFunctionFor(VectorFloat query) { + final int d = ash.quantizedDim; + + // Compute q_centered = q - μ + // (avoid allocation by doing subtraction on the fly during projection) + final VectorFloat mu = ash.globalMean; + + final double[][] A = ash.stiefelTransform.AData; // shape: d × originalDim + final int originalDim = ash.stiefelTransform.cols; + final float[] muArr = new float[originalDim]; + for (int k = 0; k < originalDim; k++) muArr[k] = mu.get(k); + + final float[] qArr = new float[originalDim]; + for (int k = 0; k < originalDim; k++) qArr[k] = query.get(k); + + // Project query: tildeQ[j] = (A (q - μ))[j] + final float[] tildeQ = new float[d]; + for (int j = 0; j < d; j++) { + final double[] Aj = A[j]; + double acc = 0.0; + for (int k = 0; k < originalDim; k++) { + acc += Aj[k] * (qArr[k] - muArr[k]); + } + tildeQ[j] = (float) acc; + } + + // = sum_j tildeQ[j] + float tmpSum = 0f; + for (int j = 0; j < d; j++) { + tmpSum += tildeQ[j]; + } + final float sumTildeQ = tmpSum; + + // + final float dotQMu = VectorUtil.dotProduct(query, mu); + + // ||μ||^2 + final float muNormSq = VectorUtil.dotProduct(mu, mu); + + // Precompute invSqrtD once + final float invSqrtD = (float) (1.0 / Math.sqrt(d)); + + return (AsymmetricHashing.QuantizedVector v) -> { + // Single landmark baseline: ignore v.landmark for now (assumed 0) + final float residualNorm = v.residualNorm; // ||x - μ||₂ + final float dotXMu = v.dotWithLandmark; // + + // scale = d^{-1/2} * ||x - μ|| + final float scale = invSqrtD * residualNorm; + + // masked-add term: where bin ∈ {0,1}^d + // This is sum of tildeQ[j] over set bits in v.binaryVector. + float maskedAdd = 0f; + + // v.binaryVector stores bits packed in longs, little-endian per word (bit j corresponds to dimension base+j) + final long[] bits = v.binaryVector; + int base = 0; + for (int w = 0; w < bits.length && base < d; w++, base += 64) { + long word = bits[w]; + while (word != 0L) { + int bit = Long.numberOfTrailingZeros(word); + int idx = base + bit; + if (idx < d) maskedAdd += tildeQ[idx]; + word &= (word - 1); + } + } + + // ASH dot-product approximation (single landmark, {0,1} encoding): + // + // scale * (2⟨q̃, b⟩ − ⟨q̃, 1⟩) + ⟨q, μ⟩ + (⟨x, μ⟩ − ||μ||²) + // + // Notes: + // - b ∈ {0,1}^d is the binary code (converted from the paper’s {−1,+1} form). + // - scale = ||x − μ|| / √d (pure Eq. 11 baseline; no bias correction). + // - ⟨q, μ⟩ is a query-only offset and MUST NOT be scaled. + // - Scaling ⟨q, μ⟩ leads to large systematic error (see paper Eq. 11 regrouping). + return scale * (2f * maskedAdd - sumTildeQ) + dotQMu + (dotXMu - muNormSq); + + }; + } + + @FunctionalInterface + public interface ASHScoreFunction { + float similarityTo(AsymmetricHashing.QuantizedVector vector2); + } +} + diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHVectors.java b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHVectors.java index b1f822ea2..dcfe63a39 100644 --- a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHVectors.java +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHVectors.java @@ -49,17 +49,12 @@ *
  • Define reranking strategy (likely via full vectors)
  • *
  • Support multi-landmark ASH (this is intentionally deferred)
  • * - * - *

    - * NOTE: - * This class avoids PQ/NVQ feature plumbing and graph integration by design. - * Those concerns belong to the scoring plane and will be addressed after - * baseline performance is established. */ public class ASHVectors implements CompressedVectors { final AsymmetricHashing ash; final AsymmetricHashing.QuantizedVector[] compressedVectors; + final ASHScorer scorer; /** * Initialize ASHVectors with an array of ASH-compressed vectors. @@ -71,6 +66,7 @@ public ASHVectors(AsymmetricHashing ash, AsymmetricHashing.QuantizedVector[] compressedVectors) { this.ash = ash; this.compressedVectors = compressedVectors; + this.scorer = new ASHScorer(ash); } @Override @@ -118,45 +114,16 @@ public static ASHVectors load(RandomAccessReader in) throws IOException { return new ASHVectors(ash, compressedVectors); } - /** - * Return an approximate score function for the given query. - * - *

    - * Baseline implementation: - *

      - *
    • Encode query once using ASH
    • - *
    • Score candidates using pure Hamming distance
    • - *
    - * - *

    - * TODO: - *

      - *
    • Replace pure Hamming with full ASH scoring
    • - *
    • Incorporate landmark scalars (dotWithLandmark, residualNorm)
    • - *
    • Respect VectorSimilarityFunction semantics where applicable
    • - *
    - */ @Override public ScoreFunction.ApproximateScoreFunction scoreFunctionFor( VectorFloat query, VectorSimilarityFunction similarityFunction) { - // Encode query once - var qv = AsymmetricHashing.QuantizedVector.createEmpty(ash.quantizedDim); - ash.encodeTo(query, qv); - final long[] queryBits = qv.binaryVector; + // Scorer follows ASH paper (single landmark, DOT_PRODUCT only) + final ASHScorer.ASHScoreFunction f = scorer.scoreFunctionFor(query, similarityFunction); - return node -> { - var v = compressedVectors[node]; - - // TODO: - // - Replace with proper ASH scoring. - // - Consider affine scaling or normalization. - int hamming = VectorUtil.hammingDistance(queryBits, v.binaryVector); - - // Higher similarity is better - return -hamming; - }; + // Wrap QuantizedVector scorer into ordinal-based score function + return node -> f.similarityTo(compressedVectors[node]); } /** @@ -177,11 +144,7 @@ public ScoreFunction.ApproximateScoreFunction precomputedScoreFunctionFor( * Diversity-aware scoring is not supported for ASH at this stage. * *

    - * TODO: - * - Define whether diversity should be measured via: - * * Hamming distance - * * Landmark-aware distance - * * Full-vector reranking + * TODO: Define how diversity should be measured. */ @Override public ScoreFunction.ApproximateScoreFunction diversityFunctionFor( diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/AsymmetricHashing.java b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/AsymmetricHashing.java index 22e1201cf..a69fc9443 100644 --- a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/AsymmetricHashing.java +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/AsymmetricHashing.java @@ -34,7 +34,10 @@ import java.util.concurrent.ForkJoinPool; import java.util.Random; import java.util.stream.IntStream; -import org.apache.commons.math3.linear.*; +import org.apache.commons.math3.linear.Array2DRowRealMatrix; +import org.apache.commons.math3.linear.MatrixUtils; +import org.apache.commons.math3.linear.QRDecomposition; +import org.apache.commons.math3.linear.RealMatrix; import static io.github.jbellis.jvector.quantization.KMeansPlusPlusClusterer.UNWEIGHTED; @@ -47,7 +50,12 @@ public class AsymmetricHashing implements VectorCompressor vector, public interface BinaryQuantizer { void quantizeBody(VectorFloat vector, VectorFloat globalMean, + float residualNorm, int quantizedDim, StiefelTransform stiefel, long[] outWords); @@ -303,12 +316,25 @@ public static final class RandomBinaryQuantizer implements BinaryQuantizer { @Override public void quantizeBody(VectorFloat vector, VectorFloat globalMean, + float residualNorm, int quantizedDim, StiefelTransform stiefel, long[] outWords) { - RealMatrix A = stiefel.A; // quantizedDim × originalDim - int originalDim = A.getColumnDimension(); + final double[][] A = stiefel.AData; // [quantizedDim][originalDim] + final int originalDim = A[0].length; + + // Copy vector and mean once (no virtual calls in inner loop) + final float[] x = new float[originalDim]; + final float[] mu = new float[originalDim]; + + for (int d = 0; d < originalDim; d++) { + x[d] = vector.get(d); + mu[d] = globalMean.get(d); + } + + // Eq. 6 normalization + final double invNorm = (residualNorm > 0f) ? (1.0 / residualNorm) : 0.0; final int words = QuantizedVector.wordsForDims(quantizedDim); @@ -320,11 +346,12 @@ public void quantizeBody(VectorFloat vector, for (int j = 0; j < rem; j++) { int bitIndex = base + j; - // Compute y_j = dot(A[j], x - μ) + // y_j = dot(A[j], (x − μ) / ||x − μ||) double acc = 0.0; + final double[] Arow = A[bitIndex]; + for (int d = 0; d < originalDim; d++) { - acc += A.getEntry(bitIndex, d) - * (vector.get(d) - globalMean.get(d)); + acc += Arow[d] * (x[d] - mu[d]) * invNorm; } if (acc > 0.0) { @@ -341,6 +368,7 @@ public static final class ItqBinaryQuantizer implements BinaryQuantizer { @Override public void quantizeBody(VectorFloat vector, VectorFloat globalMean, + float residualNorm, int quantizedDim, StiefelTransform stiefel, long[] outWords) { @@ -352,6 +380,7 @@ public static final class LandingBinaryQuantizer implements BinaryQuantizer { @Override public void quantizeBody(VectorFloat vector, VectorFloat globalMean, + float residualNorm, int quantizedDim, StiefelTransform stiefel, long[] outWords) { @@ -364,10 +393,10 @@ public void quantizeBody(VectorFloat vector, // --------------------------------------------------------------------- public static class QuantizedVector { - public float residualNorm; - public float dotWithLandmark; - public short landmark; - public long[] binaryVector; + public float residualNorm; // ||x_i - μ_i*||_2 + public float dotWithLandmark; // + public short landmark; // c_i* + public long[] binaryVector; // bin(x̂_i) private QuantizedVector(float residualNorm, float dotWithLandmark, @@ -414,7 +443,14 @@ static void quantizeTo(VectorFloat vector, } Arrays.fill(dest.binaryVector, 0, words, 0L); - quantizer.quantizeBody(vector, globalMean, quantizedDim, stiefel, dest.binaryVector); + quantizer.quantizeBody( + vector, + globalMean, + residualNorm, + quantizedDim, + stiefel, + dest.binaryVector + ); } public void write(DataOutput out, int quantizedDim) throws IOException { @@ -517,24 +553,46 @@ private static ByteSequence createBodyByteSequenceForBits(int nBits) { /** * Orthonormal linear transform on the Stiefel manifold. - * * W : originalDim × quantizedDim * A : quantizedDim × originalDim, where A = Wᵀ - * * The transform is applied as: * y = A · (x − μ) */ public static final class StiefelTransform { - /** Forward projection (column-orthonormal). */ public final RealMatrix W; /** Transpose of W, used for projection. */ public final RealMatrix A; + /** + * Cached row-major backing for A when available. + * This exists purely to avoid RealMatrix.getEntry() in hot loops. + * AData must not be mutated after construction. + */ + public final double[][] AData; + + /** Number of rows in A (i.e., quantizedDim = d). */ + public final int rows; + + /** Number of columns in A (i.e., originalDim). */ + public final int cols; + public StiefelTransform(RealMatrix W) { this.W = W; this.A = W.transpose(); + + // Fast path: QRDecomposition + MatrixUtils typically produce Array2DRowRealMatrix + // Use getDataRef() to avoid copying and avoid getEntry() calls. + if (this.A instanceof Array2DRowRealMatrix) { + this.AData = ((Array2DRowRealMatrix) this.A).getDataRef(); + } else { + // Fallback: copy once at construction time (still far faster than getEntry per access) + this.AData = this.A.getData(); + } + + this.rows = AData.length; + this.cols = AData.length == 0 ? 0 : AData[0].length; } } @@ -699,29 +757,28 @@ public QuantizedVector encode(VectorFloat vector) { return qv; } - // NOTE: - // This is the single-landmark baseline configuration. - // All vectors are encoded relative to the global mean (landmark 0). - // The design intentionally leaves room for multiple landmarks in the future - // without changing the on-disk format. @Override public void encodeTo(VectorFloat vector, QuantizedVector dest) { - // Single-landmark baseline: global mean - // Landmark index is always 0 in this mode - short landmark = 0; + // NOTE: + // Single-landmark baseline configuration (Table 2, C = 1). + // All vectors are encoded relative to the global mean μ*. + // The landmark id is always 0 in this mode. - // Compute dot product with the landmark - float dotWithLandmark = VectorUtil.dotProduct(vector, globalMean); + final short landmark = 0; - // Compute residual norm ||x - μ||₂ - // NOTE: - // We store the true L2 norm (not squared) to match ASH semantics and the paper. - // squareL2Distance is used for efficiency; the sqrt cost is negligible relative - // to projection and hashing. - float sqDist = VectorUtil.squareL2Distance(vector, globalMean); - float residualNorm = (float) Math.sqrt(sqDist); + // Table 2 header field: + final float dotWithLandmark = VectorUtil.dotProduct(vector, globalMean); + + // Table 2 header field: ||x_i − μ_i*||₂ + // We store the true L2 norm (not squared), matching the paper. + final float sqDist = VectorUtil.squareL2Distance(vector, globalMean); + final float residualNorm = (float) Math.sqrt(sqDist); + + // Sanity: quantizedDim already accounts for physical header bits (Step 1) + // Binary body uses exactly quantizedDim bits. + assert quantizedDim > 0; - // Binary encoding uses sign(A · (x − μ)) internally + // Binary body: sign(A · (x − μ)) quantizeVector(vector, residualNorm, dotWithLandmark, landmark, dest); } diff --git a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/DistancesASH.java b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/DistancesASH.java index b9e5ee516..695dc21e0 100644 --- a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/DistancesASH.java +++ b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/DistancesASH.java @@ -18,97 +18,222 @@ import io.github.jbellis.jvector.example.util.SiftLoader; import io.github.jbellis.jvector.graph.ListRandomAccessVectorValues; +import io.github.jbellis.jvector.graph.similarity.ScoreFunction; +import io.github.jbellis.jvector.quantization.CompressedVectors; + import io.github.jbellis.jvector.quantization.AsymmetricHashing; import io.github.jbellis.jvector.vector.VectorSimilarityFunction; import io.github.jbellis.jvector.vector.VectorUtil; import io.github.jbellis.jvector.vector.types.VectorFloat; +import io.github.jbellis.jvector.util.PhysicalCoreExecutor; import java.io.IOException; import java.util.List; +import java.util.stream.Collectors; +import java.util.ArrayList; +import java.util.concurrent.ForkJoinPool; +import java.util.concurrent.ForkJoinTask; + + import static java.lang.Math.abs; -// this class uses explicit typing instead of `var` for easier reading when excerpted for instructional use public class DistancesASH { - public static void testASHEncodings(String filenameBase, String filenameQueries, VectorSimilarityFunction vsf) throws IOException { + public static void testASHEncodings(String filenameBase, String filenameQueries) throws IOException { + List> vectors = SiftLoader.readFvecs(filenameBase); List> queries = SiftLoader.readFvecs(filenameQueries); + // ------------------------------------------------------------------ + // Remove zero-norm vectors (undefined for ASH / Eq. 11) + // ------------------------------------------------------------------ + int beforeBase = vectors.size(); + List> filteredBase = new ArrayList<>(vectors.size()); + for (VectorFloat v : vectors) { + if (VectorUtil.dotProduct(v, v) > 0f) { + filteredBase.add(v); + } + } + vectors = filteredBase; + + int beforeQuery = queries.size(); + List> filteredQueries = new ArrayList<>(queries.size()); + for (VectorFloat q : queries) { + if (VectorUtil.dotProduct(q, q) > 0f) { + filteredQueries.add(q); + } + } + queries = filteredQueries; + + System.out.println("\tRemoved " + (beforeBase - vectors.size()) + " zero base vectors and " + + (beforeQuery - queries.size()) + " zero query vectors"); + + // ASH normalization policy: + // + // - Base vectors x are NOT globally normalized. + // - For encoding, we compute μ on raw x. + // - The residual (x − μ) is normalized ONCE inside the binarizer, + // producing \hat{x} = (x − μ) / ||x − μ|| (Eq. 6). + // - Queries may be L2-normalized in the benchmark (standard practice), + // but are NOT normalized inside the encoder. + // - No other normalization steps are applied. + for (VectorFloat q : queries) VectorUtil.l2normalize(q); + int dimension = vectors.get(0).length(); - int encodedDim = dimension; // change later - int nQueries = 100; - int nVectors = 10_000; + int encodedBits = dimension; // sweep later if desired + + System.out.println( + "\toriginalDim = " + dimension + + ", encodedBits = " + encodedBits + ); + + int nQueries = Math.min(1000, queries.size()); + int nVectors = Math.min(100_000, vectors.size()); vectors = vectors.subList(0, nVectors); + queries = queries.subList(0, nQueries); + + final List> finalQueries = queries; + final List> finalVectors = vectors; - System.out.format("\t%d base and %d query vectors loaded, dimensions %d%n", - vectors.size(), queries.size(), vectors.get(0).length()); + System.out.format("\t%d base and %d query vectors loaded, dimension=%d%n", + vectors.size(), queries.size(), dimension); - // Generate a BQ for random vectors + // ------------------------------------------------------------------ + // Build ASH + // ------------------------------------------------------------------ var ravv = new ListRandomAccessVectorValues(vectors, dimension); - var ash = AsymmetricHashing.initialize(ravv, AsymmetricHashing.RANDOM, encodedDim); + var ash = AsymmetricHashing.initialize(ravv, AsymmetricHashing.RANDOM, encodedBits); - // Compress the vectors long startTime = System.nanoTime(); - var ashVecs = ash.encodeAll(ravv); + CompressedVectors ashVecs = ash.encodeAll(ravv); long endTime = System.nanoTime(); - double duration = (double) (endTime - startTime) / 1_000_000_000; - System.out.println("\tEncoding took " + duration + " seconds"); - - // compare the encoded similarities to the raw - double distanceError = 0; - for (int i = 0; i < nQueries; i++) { - var q = queries.get(i); - if (VectorUtil.dotProduct(q, q) == 0) { - continue; - } - var f = ashVecs.scoreFunctionFor(q, vsf); + System.out.println("\tEncoding took " + (endTime - startTime) / 1e9 + " seconds"); - for (int j = 0; j < nVectors; j++) { - var v = vectors.get(j); - distanceError += abs(f.similarityTo(j) - vsf.compare(q, v)); - } + // ------------------------------------------------------------------ + // Shared parallelization setup (executor-honoring) + // ------------------------------------------------------------------ + ForkJoinPool simdExecutor = PhysicalCoreExecutor.pool(); + + int parallelism = simdExecutor.getParallelism(); + int chunkSize = Math.max(1, (nQueries + parallelism - 1) / parallelism); + + // ================================================================== + // [1] Accuracy run (NOT timed) + // ================================================================== + List> errorTasks = new ArrayList<>(); + + for (int start = 0; start < nQueries; start += chunkSize) { + final int s = start; + final int e = Math.min(start + chunkSize, nQueries); + + errorTasks.add(simdExecutor.submit(() -> { + double localError = 0.0; + long localCount = 0; + + for (int i = s; i < e; i++) { + VectorFloat q = finalQueries.get(i); + ScoreFunction.ApproximateScoreFunction f = + ashVecs.scoreFunctionFor(q, VectorSimilarityFunction.DOT_PRODUCT); + + for (int j = 0; j < nVectors; j++) { + VectorFloat v = finalVectors.get(j); + float trueDot = VectorUtil.dotProduct(q, v); + float approxDot = f.similarityTo(j); + + localError += Math.abs(approxDot - trueDot); + localCount++; + } + } + return new double[]{localError, localCount}; + })); } - distanceError /= nQueries * nVectors; - System.out.println("\t" + vsf + " error " + distanceError); + double distanceError = 0.0; + long count = 0; + for (ForkJoinTask t : errorTasks) { + double[] r = t.join(); + distanceError += r[0]; + count += (long) r[1]; + } - float dummyAccumulator = 0; + distanceError /= count; + System.out.println("\tAverage absolute dot-product error = " + distanceError); - startTime = System.nanoTime(); - for (int i = 0; i < nQueries; i++) { - var q = queries.get(i); - if (VectorUtil.dotProduct(q, q) == 0) { - continue; - } - var f = ashVecs.scoreFunctionFor(q, vsf); + // ================================================================== + // [2] ASH distance timing (Eq. 11 only) + // ================================================================== + List> ashTasks = new ArrayList<>(); - for (int j = 0; j < nVectors; j++) { - dummyAccumulator += f.similarityTo(j); - } + long ashStart = System.nanoTime(); + + for (int start = 0; start < nQueries; start += chunkSize) { + final int s = start; + final int e = Math.min(start + chunkSize, nQueries); + + ashTasks.add(simdExecutor.submit(() -> { + double localSum = 0.0; + + for (int i = s; i < e; i++) { + VectorFloat q = finalQueries.get(i); + ScoreFunction.ApproximateScoreFunction f = + ashVecs.scoreFunctionFor(q, VectorSimilarityFunction.DOT_PRODUCT); + + for (int j = 0; j < nVectors; j++) { + localSum += f.similarityTo(j); + } + } + return localSum; + })); } - endTime = System.nanoTime(); - duration = (double) (endTime - startTime) / 1_000_000_000; - System.out.println("\tASH Distance computations took " + duration + " seconds"); - - startTime = System.nanoTime(); - for (int i = 0; i < nQueries; i++) { - var q = queries.get(i); - if (VectorUtil.dotProduct(q, q) == 0) { - continue; - } - for (int j = 0; j < nVectors; j++) { - var v = vectors.get(j); - dummyAccumulator += vsf.compare(q, v); - } + double ashDummy = 0.0; + for (ForkJoinTask t : ashTasks) { + ashDummy += t.join(); + } + + long ashEnd = System.nanoTime(); + + System.out.println("\tASH dot-product computations took " + + (ashEnd - ashStart) / 1e9 + " seconds"); + + // ================================================================== + // [3] Float dot-product timing (ground truth baseline) + // ================================================================== + List> floatTasks = new ArrayList<>(); + + long floatStart = System.nanoTime(); + + for (int start = 0; start < nQueries; start += chunkSize) { + final int s = start; + final int e = Math.min(start + chunkSize, nQueries); + + floatTasks.add(simdExecutor.submit(() -> { + double localSum = 0.0; + + for (int i = s; i < e; i++) { + VectorFloat q = finalQueries.get(i); + for (int j = 0; j < nVectors; j++) { + localSum += VectorUtil.dotProduct(q, finalVectors.get(j)); + } + } + return localSum; + })); + } + + double floatDummy = 0.0; + for (ForkJoinTask t : floatTasks) { + floatDummy += t.join(); } - endTime = System.nanoTime(); - duration = (double) (endTime - startTime) / 1_000_000_000; - System.out.println("\tFloat Distance computations took " + duration + " seconds"); - System.out.println("\tdummyAccumulator: " + dummyAccumulator); + long floatEnd = System.nanoTime(); + + System.out.println("\tFloat dot-product computations took " + + (floatEnd - floatStart) / 1e9 + " seconds"); + + // Prevent dead-code elimination + System.out.println("\tdummyAccumulator = " + (float) (ashDummy + floatDummy)); System.out.println("--"); } @@ -117,7 +242,7 @@ public static void runSIFT() throws IOException { var baseVectors = "siftsmall/siftsmall_base.fvecs"; var queryVectors = "siftsmall/siftsmall_query.fvecs"; - testASHEncodings(baseVectors, queryVectors, VectorSimilarityFunction.COSINE); + testASHEncodings(baseVectors, queryVectors); } public static void runADA() throws IOException { @@ -125,7 +250,7 @@ public static void runADA() throws IOException { var baseVectors = "./fvec/wikipedia_squad/100k/ada_002_100000_base_vectors.fvec"; var queryVectors = "./fvec/wikipedia_squad/100k/ada_002_100000_query_vectors_10000.fvec"; - testASHEncodings(baseVectors, queryVectors, VectorSimilarityFunction.COSINE); + testASHEncodings(baseVectors, queryVectors); } public static void runColbert() throws IOException { @@ -133,7 +258,7 @@ public static void runColbert() throws IOException { var baseVectors = "./fvec/wikipedia_squad/1M/colbertv2.0_128_base_vectors_1000000.fvec"; var queryVectors = "./fvec/wikipedia_squad/1M/colbertv2.0_128_query_vectors_100000.fvec"; - testASHEncodings(baseVectors, queryVectors, VectorSimilarityFunction.COSINE); + testASHEncodings(baseVectors, queryVectors); } public static void runOpenai3072() throws IOException { @@ -141,12 +266,12 @@ public static void runOpenai3072() throws IOException { var baseVectors = "./fvec/wikipedia_squad/100k/text-embedding-3-large_3072_100000_base_vectors.fvec"; var queryVectors = "./fvec/wikipedia_squad/100k/text-embedding-3-large_3072_100000_base_vectors.fvec"; - testASHEncodings(baseVectors, queryVectors, VectorSimilarityFunction.COSINE); + testASHEncodings(baseVectors, queryVectors); } public static void main(String[] args) throws IOException { runSIFT(); - runADA(); +// runADA(); runColbert(); // runOpenai3072(); } diff --git a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/TestAshRunWithoutTraining.java b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/TestAshRunWithoutTraining.java index 5ada6365b..01d28c22f 100644 --- a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/TestAshRunWithoutTraining.java +++ b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/TestAshRunWithoutTraining.java @@ -7,17 +7,14 @@ public class TestAshRunWithoutTraining { public static void main(String[] args) { - int d = 1024; // input dimension - int nDims = 128; // target dimension - - // Dummy data matrix X only to scrape shape - RealMatrix X = MatrixUtils.createRealMatrix(10, d); // (10 × 1024) + int originalDim = 1024; + int quantizedDim = 128; Random rng = new Random(42); // Method under test AsymmetricHashing.StiefelTransform stiefelTransform = - AsymmetricHashing.runWithoutTraining(X, nDims, rng); + AsymmetricHashing.runWithoutTraining(originalDim, quantizedDim, rng); // Inspect shapes and a few entries RealMatrix A = stiefelTransform.A; From 0dc6fb2b7e2e402ebe0c3c521966f9ba56a9a37e Mon Sep 17 00:00:00 2001 From: Ted Willke Date: Fri, 26 Dec 2025 09:08:47 -0800 Subject: [PATCH 003/243] Enable float encoding and scoring of A, maintaining double for training. Minor dataset updates as well. --- .../jvector/quantization/ASHScorer.java | 8 ++-- .../quantization/AsymmetricHashing.java | 44 ++++++++++++++----- .../jbellis/jvector/example/DistancesASH.java | 41 ++++++++++++++--- 3 files changed, 70 insertions(+), 23 deletions(-) diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHScorer.java b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHScorer.java index 93f35182f..cff001511 100644 --- a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHScorer.java +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHScorer.java @@ -53,7 +53,7 @@ private ASHScoreFunction dotProductScoreFunctionFor(VectorFloat query) { // (avoid allocation by doing subtraction on the fly during projection) final VectorFloat mu = ash.globalMean; - final double[][] A = ash.stiefelTransform.AData; // shape: d × originalDim + final float[][] A = ash.stiefelTransform.AFloat; // shape: d × originalDim final int originalDim = ash.stiefelTransform.cols; final float[] muArr = new float[originalDim]; for (int k = 0; k < originalDim; k++) muArr[k] = mu.get(k); @@ -64,12 +64,12 @@ private ASHScoreFunction dotProductScoreFunctionFor(VectorFloat query) { // Project query: tildeQ[j] = (A (q - μ))[j] final float[] tildeQ = new float[d]; for (int j = 0; j < d; j++) { - final double[] Aj = A[j]; - double acc = 0.0; + final float[] Aj = A[j]; + float acc = 0.0f; for (int k = 0; k < originalDim; k++) { acc += Aj[k] * (qArr[k] - muArr[k]); } - tildeQ[j] = (float) acc; + tildeQ[j] = acc; } // = sum_j tildeQ[j] diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/AsymmetricHashing.java b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/AsymmetricHashing.java index a69fc9443..d3bfd0a38 100644 --- a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/AsymmetricHashing.java +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/AsymmetricHashing.java @@ -321,8 +321,8 @@ public void quantizeBody(VectorFloat vector, StiefelTransform stiefel, long[] outWords) { - final double[][] A = stiefel.AData; // [quantizedDim][originalDim] - final int originalDim = A[0].length; + final float[][] A = stiefel.AFloat; + final int originalDim = stiefel.cols; // Copy vector and mean once (no virtual calls in inner loop) final float[] x = new float[originalDim]; @@ -333,8 +333,8 @@ public void quantizeBody(VectorFloat vector, mu[d] = globalMean.get(d); } - // Eq. 6 normalization - final double invNorm = (residualNorm > 0f) ? (1.0 / residualNorm) : 0.0; + // ASH paper, Eq. 6, normalization + final float invNorm = (residualNorm > 0f) ? (1.0f / residualNorm) : 0.0f; final int words = QuantizedVector.wordsForDims(quantizedDim); @@ -347,14 +347,14 @@ public void quantizeBody(VectorFloat vector, int bitIndex = base + j; // y_j = dot(A[j], (x − μ) / ||x − μ||) - double acc = 0.0; - final double[] Arow = A[bitIndex]; + float acc = 0.0f; + final float[] Arow = A[bitIndex]; for (int d = 0; d < originalDim; d++) { acc += Arow[d] * (x[d] - mu[d]) * invNorm; } - if (acc > 0.0) { + if (acc > 0.0f) { bits |= (1L << j); } } @@ -559,6 +559,7 @@ private static ByteSequence createBodyByteSequenceForBits(int nBits) { * y = A · (x − μ) */ public static final class StiefelTransform { + /** Forward projection (column-orthonormal). */ public final RealMatrix W; @@ -566,16 +567,25 @@ public static final class StiefelTransform { public final RealMatrix A; /** - * Cached row-major backing for A when available. - * This exists purely to avoid RealMatrix.getEntry() in hot loops. + * Cached row-major backing for A in double precision. + * Used for training, reference, and correctness-sensitive math. + * * AData must not be mutated after construction. */ public final double[][] AData; - /** Number of rows in A (i.e., quantizedDim = d). */ + /** + * Cached row-major backing for A in float precision. + * Used for encoding and scoring fast paths. + * + * This is derived once from AData and must not be mutated. + */ + public final float[][] AFloat; + + /** Number of rows in A (quantizedDim = d). */ public final int rows; - /** Number of columns in A (i.e., originalDim). */ + /** Number of columns in A (originalDim). */ public final int cols; public StiefelTransform(RealMatrix W) { @@ -592,7 +602,17 @@ public StiefelTransform(RealMatrix W) { } this.rows = AData.length; - this.cols = AData.length == 0 ? 0 : AData[0].length; + this.cols = rows == 0 ? 0 : AData[0].length; + + // Build float copy once for fast encoding / scoring paths + this.AFloat = new float[rows][cols]; + for (int i = 0; i < rows; i++) { + double[] src = AData[i]; + float[] dst = AFloat[i]; + for (int j = 0; j < cols; j++) { + dst[j] = (float) src[j]; + } + } } } diff --git a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/DistancesASH.java b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/DistancesASH.java index 695dc21e0..b56368cfe 100644 --- a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/DistancesASH.java +++ b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/DistancesASH.java @@ -80,7 +80,7 @@ public static void testASHEncodings(String filenameBase, String filenameQueries) for (VectorFloat q : queries) VectorUtil.l2normalize(q); int dimension = vectors.get(0).length(); - int encodedBits = dimension; // sweep later if desired + int encodedBits = dimension / 4; // sweep later if desired System.out.println( "\toriginalDim = " + dimension + @@ -245,6 +245,22 @@ public static void runSIFT() throws IOException { testASHEncodings(baseVectors, queryVectors); } + public static void runGIST() throws IOException { + System.out.println("Running GIST"); + + var baseVectors = "./fvec/gist/gist_base.fvecs"; + var queryVectors = "./fvec/gist/gist_query.fvecs"; + testASHEncodings(baseVectors, queryVectors); + } + + public static void runCohere100k() throws IOException { + System.out.println("Running Cohere-100k"); + + var baseVectors = "./fvec/cohere-100k/cohere_embed-english-v3.0_1024_base_vectors_100000.fvec"; + var queryVectors = "./fvec/cohere-100k/cohere_embed-english-v3.0_1024_query_vectors_10000.fvec"; + testASHEncodings(baseVectors, queryVectors); + } + public static void runADA() throws IOException { System.out.println("Running ada_002"); @@ -261,18 +277,29 @@ public static void runColbert() throws IOException { testASHEncodings(baseVectors, queryVectors); } + public static void runOpenai1536() throws IOException { + System.out.println("Running text-embedding-3-large_1536"); + + var baseVectors = "./fvec/openai-v3-large-1536-100k/text-embedding-3-large_1536_100000_base_vectors.fvec"; + var queryVectors = "./fvec/openai-v3-large-1536-100k/text-embedding-3-large_1536_100000_query_vectors_10000.fvec"; + testASHEncodings(baseVectors, queryVectors); + } + public static void runOpenai3072() throws IOException { System.out.println("Running text-embedding-3-large_3072"); - var baseVectors = "./fvec/wikipedia_squad/100k/text-embedding-3-large_3072_100000_base_vectors.fvec"; - var queryVectors = "./fvec/wikipedia_squad/100k/text-embedding-3-large_3072_100000_base_vectors.fvec"; + var baseVectors = "./fvec/openai-v3-large-3072-100k/text-embedding-3-large_3072_100000_base_vectors.fvec"; + var queryVectors = "./fvec/openai-v3-large-3072-100k/text-embedding-3-large_3072_100000_base_vectors.fvec"; testASHEncodings(baseVectors, queryVectors); } public static void main(String[] args) throws IOException { - runSIFT(); -// runADA(); - runColbert(); -// runOpenai3072(); +// runSIFT(); +// runGIST(); +// runColbert(); + runCohere100k(); + runADA(); + runOpenai1536(); + runOpenai3072(); } } \ No newline at end of file From f730faad0a05a1589683ef90a18694588d1c6057 Mon Sep 17 00:00:00 2001 From: Ted Willke Date: Fri, 26 Dec 2025 11:08:10 -0800 Subject: [PATCH 004/243] Fix for shape of thin QR matrix. Plus scaffolding for default (scalar) ASH backend. Validated ASH error and performance correctness. --- .../quantization/AsymmetricHashing.java | 147 ++++++++++++------ .../jvector/quantization/ash/AshMath.java | 46 ++++++ .../quantization/ash/DefaultAshMath.java | 70 +++++++++ .../example/TestAshRunWithoutTraining.java | 22 ++- 4 files changed, 227 insertions(+), 58 deletions(-) create mode 100644 jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ash/AshMath.java create mode 100644 jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ash/DefaultAshMath.java diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/AsymmetricHashing.java b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/AsymmetricHashing.java index d3bfd0a38..9efeb7e88 100644 --- a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/AsymmetricHashing.java +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/AsymmetricHashing.java @@ -16,11 +16,14 @@ package io.github.jbellis.jvector.quantization; +import io.github.jbellis.jvector.quantization.ash.AshMath; +import io.github.jbellis.jvector.quantization.ash.DefaultAshMath; import io.github.jbellis.jvector.annotations.VisibleForTesting; import io.github.jbellis.jvector.disk.RandomAccessReader; import io.github.jbellis.jvector.graph.RandomAccessVectorValues; import io.github.jbellis.jvector.graph.disk.OnDiskGraphIndex; import io.github.jbellis.jvector.util.Accountable; +import io.github.jbellis.jvector.util.RamUsageEstimator; import io.github.jbellis.jvector.vector.VectorUtil; import io.github.jbellis.jvector.vector.VectorizationProvider; import io.github.jbellis.jvector.vector.types.ByteSequence; @@ -60,6 +63,8 @@ public class AsymmetricHashing implements VectorCompressor vector, // Copy vector and mean once (no virtual calls in inner loop) final float[] x = new float[originalDim]; final float[] mu = new float[originalDim]; - for (int d = 0; d < originalDim; d++) { x[d] = vector.get(d); mu[d] = globalMean.get(d); } - // ASH paper, Eq. 6, normalization + // ASH paper Eq. 6 normalization final float invNorm = (residualNorm > 0f) ? (1.0f / residualNorm) : 0.0f; - final int words = QuantizedVector.wordsForDims(quantizedDim); + // Build normalized residual x̂ = (x − μ) / ||x − μ|| + final float[] xhat = new float[originalDim]; + for (int d = 0; d < originalDim; d++) { + xhat[d] = (x[d] - mu[d]) * invNorm; + } + // Project once: y = A · x̂ + final float[] y = new float[quantizedDim]; + ASH_MATH.project(A, xhat, y); + + // Binarize y into outWords + final int words = QuantizedVector.wordsForDims(quantizedDim); for (int w = 0; w < words; w++) { long bits = 0L; int base = w << 6; int rem = Math.min(64, quantizedDim - base); for (int j = 0; j < rem; j++) { - int bitIndex = base + j; - - // y_j = dot(A[j], (x − μ) / ||x − μ||) - float acc = 0.0f; - final float[] Arow = A[bitIndex]; - - for (int d = 0; d < originalDim; d++) { - acc += Arow[d] * (x[d] - mu[d]) * invNorm; - } - - if (acc > 0.0f) { + if (y[base + j] > 0.0f) { bits |= (1L << j); } } - outWords[w] = bits; } } @@ -560,71 +563,86 @@ private static ByteSequence createBodyByteSequenceForBits(int nBits) { */ public static final class StiefelTransform { - /** Forward projection (column-orthonormal). */ + /** Forward projection (column-orthonormal). Shape: [originalDim × quantizedDim]. */ public final RealMatrix W; - /** Transpose of W, used for projection. */ - public final RealMatrix A; - /** - * Cached row-major backing for A in double precision. + * Cached row-major backing for A in double precision (A = Wᵀ). * Used for training, reference, and correctness-sensitive math. * * AData must not be mutated after construction. + * Shape: [quantizedDim × originalDim]. */ public final double[][] AData; /** - * Cached row-major backing for A in float precision. + * Cached row-major backing for A in float precision (A = Wᵀ). * Used for encoding and scoring fast paths. - * - * This is derived once from AData and must not be mutated. + * AFloat must not be mutated after construction. + * Shape: [quantizedDim × originalDim]. */ public final float[][] AFloat; /** Number of rows in A (quantizedDim = d). */ public final int rows; - /** Number of columns in A (originalDim). */ + /** Number of columns in A (originalDim = D). */ public final int cols; public StiefelTransform(RealMatrix W) { - this.W = W; - this.A = W.transpose(); - - // Fast path: QRDecomposition + MatrixUtils typically produce Array2DRowRealMatrix - // Use getDataRef() to avoid copying and avoid getEntry() calls. - if (this.A instanceof Array2DRowRealMatrix) { - this.AData = ((Array2DRowRealMatrix) this.A).getDataRef(); - } else { - // Fallback: copy once at construction time (still far faster than getEntry per access) - this.AData = this.A.getData(); + this.W = Objects.requireNonNull(W, "W"); + + // Dimensions: W is [D × d] + final int originalDim = W.getRowDimension(); // D + final int quantizedDim = W.getColumnDimension(); // d + + this.rows = quantizedDim; + this.cols = originalDim; + + // Explicitly materialize A = Wᵀ into row-major arrays. + // We do this directly instead of relying on RealMatrix.transpose()+getData(), + // because some RealMatrix implementations return views where getData() + // can be surprisingly expensive or shaped unexpectedly. + this.AData = new double[quantizedDim][originalDim]; + for (int i = 0; i < quantizedDim; i++) { + final double[] row = this.AData[i]; + for (int j = 0; j < originalDim; j++) { + // A[i][j] = W[j][i] + row[j] = W.getEntry(j, i); + } } - this.rows = AData.length; - this.cols = rows == 0 ? 0 : AData[0].length; - - // Build float copy once for fast encoding / scoring paths - this.AFloat = new float[rows][cols]; - for (int i = 0; i < rows; i++) { - double[] src = AData[i]; - float[] dst = AFloat[i]; - for (int j = 0; j < cols; j++) { + this.AFloat = new float[quantizedDim][originalDim]; + for (int i = 0; i < quantizedDim; i++) { + final double[] src = this.AData[i]; + final float[] dst = this.AFloat[i]; + for (int j = 0; j < originalDim; j++) { dst[j] = (float) src[j]; } } + + // Hard invariants: these are programmer-contract checks, not user input checks. + if (AFloat.length != rows) { + throw new AssertionError("AFloat rows " + AFloat.length + " != quantizedDim " + rows); + } + if (rows > 0 && AFloat[0].length != cols) { + throw new AssertionError("AFloat cols " + AFloat[0].length + " != originalDim " + cols); + } + if (AData.length != rows) { + throw new AssertionError("AData rows " + AData.length + " != quantizedDim " + rows); + } + if (rows > 0 && AData[0].length != cols) { + throw new AssertionError("AData cols " + AData[0].length + " != originalDim " + cols); + } } } /** * RANDOM (untrained) Stiefel initialization. - * * Generates a random orthonormal projection from R^originalDim → R^quantizedDim * by sampling a Gaussian matrix and orthonormalizing via QR decomposition. - * * A deterministic sign normalization is applied so results are reproducible * across runs and linear algebra backends. - * * @param originalDim dimensionality of the input vectors * @param quantizedDim dimensionality of the binary embedding * @param rng random number generator @@ -650,13 +668,17 @@ public static StiefelTransform runWithoutTraining(int originalDim, RealMatrix G = MatrixUtils.createRealMatrix(gaussian); - // Thin QR decomposition: G = Q R + // QR decomposition: G = Q R QRDecomposition qr = new QRDecomposition(G); - RealMatrix Q = qr.getQ(); // originalDim × quantizedDim - RealMatrix R = qr.getR(); + + // Commons Math returns full Q (m×m). We need thin Q (m×n). + RealMatrix Qfull = qr.getQ(); // originalDim × originalDim + RealMatrix Q = Qfull.getSubMatrix(0, originalDim - 1, 0, quantizedDim - 1); // originalDim × quantizedDim + + RealMatrix R = qr.getR(); // originalDim × quantizedDim // Normalize QR sign ambiguity for determinism (RANDOM only) - int diag = Math.min(R.getRowDimension(), R.getColumnDimension()); + int diag = Math.min(R.getRowDimension(), R.getColumnDimension()); // == quantizedDim for (int j = 0; j < diag; j++) { if (R.getEntry(j, j) < 0.0) { for (int i = 0; i < Q.getRowDimension(); i++) { @@ -665,6 +687,7 @@ public static StiefelTransform runWithoutTraining(int originalDim, } } + // W := Q (originalDim × quantizedDim) return new StiefelTransform(Q); } @@ -840,10 +863,32 @@ public long ramBytesUsed() { } if (stiefelTransform != null) { + // Authoritative double-precision model bytes += estimateMatrixBytes(stiefelTransform.W); - bytes += estimateMatrixBytes(stiefelTransform.A); + + // Materialized compute buffers (row-major) + bytes += estimateArray2DBytes(stiefelTransform.AData); + bytes += estimateArray2DBytes(stiefelTransform.AFloat); + } + + return bytes; + } + + private static long estimateArray2DBytes(double[][] a) { + if (a == null) return 0L; + long bytes = 0L; + for (double[] row : a) { + bytes += RamUsageEstimator.sizeOf(row); } + return bytes; + } + private static long estimateArray2DBytes(float[][] a) { + if (a == null) return 0L; + long bytes = 0L; + for (float[] row : a) { + bytes += RamUsageEstimator.sizeOf(row); + } return bytes; } diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ash/AshMath.java b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ash/AshMath.java new file mode 100644 index 000000000..8d277f576 --- /dev/null +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ash/AshMath.java @@ -0,0 +1,46 @@ +/* + * 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.ash; + +/** + * ASH math kernels. + * + * This interface forms the SIMD seam for ASH-specific operations. + * Implementations may be scalar, Panama Vector API, or native. + */ +public interface AshMath { + + /** + * Compute y = A · x, where: + * - A is [d][D] + * - x is [D] + * - y is [d] + */ + void project(float[][] A, float[] x, float[] y); + + /** + * Compute masked-add ⟨tildeQ, b⟩ where b ∈ {0,1}^d stored as bitpacked longs. + * Implementations must interpret bits with the same convention as existing ASH code: + * bit j corresponds to dimension j (little-endian within each long). + * + * @param tildeQ projected query (length d) + * @param bits bitpacked database code + * @param d number of valid dimensions in tildeQ + * @return sum_{j: b_j=1} tildeQ[j] + */ + float maskedAdd(float[] tildeQ, long[] bits, int d); +} diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ash/DefaultAshMath.java b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ash/DefaultAshMath.java new file mode 100644 index 000000000..a177f7fe5 --- /dev/null +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ash/DefaultAshMath.java @@ -0,0 +1,70 @@ +/* + * 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.ash; + +/** + * Scalar reference implementation of ASH math kernels. + */ +public final class DefaultAshMath implements AshMath { + + @Override + public void project(float[][] A, float[] x, float[] y) { + final int rows = A.length; + final int cols = (rows == 0 ? 0 : A[0].length); + + assert y.length == rows : + "ASH project shape mismatch: y.length=" + y.length + + ", A.rows=" + rows + + ", A.cols=" + cols + + ", x.length=" + x.length; + + assert cols == x.length : + "ASH project shape mismatch: A.cols=" + cols + + " != x.length=" + x.length + + " (A.rows=" + rows + + ", y.length=" + y.length + ")"; + + for (int i = 0; i < rows; i++) { + float acc = 0.0f; + final float[] Arow = A[i]; + for (int j = 0; j < cols; j++) { + acc += Arow[j] * x[j]; + } + y[i] = acc; + } + } + + @Override + public float maskedAdd(float[] tildeQ, long[] bits, int d) { + assert d <= tildeQ.length : "d must be <= tildeQ.length"; + + float sum = 0.0f; + int base = 0; + + for (int w = 0; w < bits.length && base < d; w++, base += 64) { + long word = bits[w]; + while (word != 0L) { + int bit = Long.numberOfTrailingZeros(word); + int idx = base + bit; + if (idx < d) sum += tildeQ[idx]; + word &= (word - 1); + } + } + + return sum; + } +} diff --git a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/TestAshRunWithoutTraining.java b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/TestAshRunWithoutTraining.java index 01d28c22f..9e6ee5566 100644 --- a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/TestAshRunWithoutTraining.java +++ b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/TestAshRunWithoutTraining.java @@ -1,7 +1,9 @@ package io.github.jbellis.jvector.example; import io.github.jbellis.jvector.quantization.AsymmetricHashing; -import org.apache.commons.math3.linear.*; +import org.apache.commons.math3.linear.MatrixUtils; +import org.apache.commons.math3.linear.RealMatrix; + import java.util.Random; public class TestAshRunWithoutTraining { @@ -16,14 +18,20 @@ public static void main(String[] args) { AsymmetricHashing.StiefelTransform stiefelTransform = AsymmetricHashing.runWithoutTraining(originalDim, quantizedDim, rng); - // Inspect shapes and a few entries - RealMatrix A = stiefelTransform.A; - RealMatrix W = stiefelTransform.W; + // Shapes + System.out.println("A shape = " + + stiefelTransform.rows + " x " + stiefelTransform.cols); + System.out.println("W shape = " + + stiefelTransform.W.getRowDimension() + " x " + + stiefelTransform.W.getColumnDimension()); - System.out.println("A shape = " + A.getRowDimension() + " x " + A.getColumnDimension()); - System.out.println("W shape = " + W.getRowDimension() + " x " + W.getColumnDimension()); + // Reconstruct A as a RealMatrix *for testing only* + RealMatrix A = + MatrixUtils.createRealMatrix(stiefelTransform.AData); + + RealMatrix W = stiefelTransform.W; - // Sanity check.... + // Sanity check: a few entries of A System.out.println("First few entries of A:"); for (int i = 0; i < Math.min(3, A.getRowDimension()); i++) { for (int j = 0; j < Math.min(3, A.getColumnDimension()); j++) { From e91a842e837f9eb8b8f8dc7402910d19496b15ad Mon Sep 17 00:00:00 2001 From: Ted Willke Date: Fri, 26 Dec 2025 19:49:17 -0800 Subject: [PATCH 005/243] First implementation with fast scalar and SIMD projection encoding. --- .../jvector/quantization/ASHScorer.java | 6 + .../quantization/AsymmetricHashing.java | 23 ++- .../jvector/quantization/ash/AshMath.java | 46 ------ .../quantization/ash/DefaultAshMath.java | 70 -------- .../jvector/vector/VectorUtilSupport.java | 43 +++++ .../vector/PanamaVectorUtilSupport.java | 151 +++++++++++------- 6 files changed, 149 insertions(+), 190 deletions(-) delete mode 100644 jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ash/AshMath.java delete mode 100644 jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ash/DefaultAshMath.java diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHScorer.java b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHScorer.java index cff001511..f3a2cfea9 100644 --- a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHScorer.java +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHScorer.java @@ -56,6 +56,12 @@ private ASHScoreFunction dotProductScoreFunctionFor(VectorFloat query) { final float[][] A = ash.stiefelTransform.AFloat; // shape: d × originalDim final int originalDim = ash.stiefelTransform.cols; final float[] muArr = new float[originalDim]; + + final var vecUtil = + io.github.jbellis.jvector.vector.VectorizationProvider + .getInstance() + .getVectorUtilSupport(); + for (int k = 0; k < originalDim; k++) muArr[k] = mu.get(k); final float[] qArr = new float[originalDim]; diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/AsymmetricHashing.java b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/AsymmetricHashing.java index 9efeb7e88..9f5ca260a 100644 --- a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/AsymmetricHashing.java +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/AsymmetricHashing.java @@ -16,8 +16,6 @@ package io.github.jbellis.jvector.quantization; -import io.github.jbellis.jvector.quantization.ash.AshMath; -import io.github.jbellis.jvector.quantization.ash.DefaultAshMath; import io.github.jbellis.jvector.annotations.VisibleForTesting; import io.github.jbellis.jvector.disk.RandomAccessReader; import io.github.jbellis.jvector.graph.RandomAccessVectorValues; @@ -63,8 +61,6 @@ public class AsymmetricHashing implements VectorCompressor vector, final float[][] A = stiefel.AFloat; final int originalDim = stiefel.cols; + final var vecUtil = + io.github.jbellis.jvector.vector.VectorizationProvider + .getInstance() + .getVectorUtilSupport(); + // Copy vector and mean once (no virtual calls in inner loop) final float[] x = new float[originalDim]; final float[] mu = new float[originalDim]; @@ -337,20 +338,16 @@ public void quantizeBody(VectorFloat vector, mu[d] = globalMean.get(d); } - // ASH paper Eq. 6 normalization + // ASH paper, Eq. 6, normalization final float invNorm = (residualNorm > 0f) ? (1.0f / residualNorm) : 0.0f; - // Build normalized residual x̂ = (x − μ) / ||x − μ|| + // Normalized residual x̂ final float[] xhat = new float[originalDim]; for (int d = 0; d < originalDim; d++) { xhat[d] = (x[d] - mu[d]) * invNorm; } - // Project once: y = A · x̂ - final float[] y = new float[quantizedDim]; - ASH_MATH.project(A, xhat, y); - - // Binarize y into outWords + // Binarize directly from per-row projection final int words = QuantizedVector.wordsForDims(quantizedDim); for (int w = 0; w < words; w++) { long bits = 0L; @@ -358,7 +355,9 @@ public void quantizeBody(VectorFloat vector, int rem = Math.min(64, quantizedDim - base); for (int j = 0; j < rem; j++) { - if (y[base + j] > 0.0f) { + int bitIndex = base + j; + float acc = vecUtil.ashDotRow(A[bitIndex], xhat); + if (acc > 0.0f) { bits |= (1L << j); } } diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ash/AshMath.java b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ash/AshMath.java deleted file mode 100644 index 8d277f576..000000000 --- a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ash/AshMath.java +++ /dev/null @@ -1,46 +0,0 @@ -/* - * 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.ash; - -/** - * ASH math kernels. - * - * This interface forms the SIMD seam for ASH-specific operations. - * Implementations may be scalar, Panama Vector API, or native. - */ -public interface AshMath { - - /** - * Compute y = A · x, where: - * - A is [d][D] - * - x is [D] - * - y is [d] - */ - void project(float[][] A, float[] x, float[] y); - - /** - * Compute masked-add ⟨tildeQ, b⟩ where b ∈ {0,1}^d stored as bitpacked longs. - * Implementations must interpret bits with the same convention as existing ASH code: - * bit j corresponds to dimension j (little-endian within each long). - * - * @param tildeQ projected query (length d) - * @param bits bitpacked database code - * @param d number of valid dimensions in tildeQ - * @return sum_{j: b_j=1} tildeQ[j] - */ - float maskedAdd(float[] tildeQ, long[] bits, int d); -} diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ash/DefaultAshMath.java b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ash/DefaultAshMath.java deleted file mode 100644 index a177f7fe5..000000000 --- a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ash/DefaultAshMath.java +++ /dev/null @@ -1,70 +0,0 @@ -/* - * 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.ash; - -/** - * Scalar reference implementation of ASH math kernels. - */ -public final class DefaultAshMath implements AshMath { - - @Override - public void project(float[][] A, float[] x, float[] y) { - final int rows = A.length; - final int cols = (rows == 0 ? 0 : A[0].length); - - assert y.length == rows : - "ASH project shape mismatch: y.length=" + y.length + - ", A.rows=" + rows + - ", A.cols=" + cols + - ", x.length=" + x.length; - - assert cols == x.length : - "ASH project shape mismatch: A.cols=" + cols + - " != x.length=" + x.length + - " (A.rows=" + rows + - ", y.length=" + y.length + ")"; - - for (int i = 0; i < rows; i++) { - float acc = 0.0f; - final float[] Arow = A[i]; - for (int j = 0; j < cols; j++) { - acc += Arow[j] * x[j]; - } - y[i] = acc; - } - } - - @Override - public float maskedAdd(float[] tildeQ, long[] bits, int d) { - assert d <= tildeQ.length : "d must be <= tildeQ.length"; - - float sum = 0.0f; - int base = 0; - - for (int w = 0; w < bits.length && base < d; w++, base += 64) { - long word = bits[w]; - while (word != 0L) { - int bit = Long.numberOfTrailingZeros(word); - int idx = base + bit; - if (idx < d) sum += tildeQ[idx]; - word &= (word - 1); - } - } - - return sum; - } -} diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/vector/VectorUtilSupport.java b/jvector-base/src/main/java/io/github/jbellis/jvector/vector/VectorUtilSupport.java index d8223ab12..417d513b1 100644 --- a/jvector-base/src/main/java/io/github/jbellis/jvector/vector/VectorUtilSupport.java +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/vector/VectorUtilSupport.java @@ -235,4 +235,47 @@ default float pqDecodedCosineSimilarity(ByteSequence encoded, int encodedOffs */ float nvqUniformLoss(VectorFloat vector, float minValue, float maxValue, int nBits); + // ------------------------------------------------------------------ + // ASH kernels (default scalar implementations) + // ------------------------------------------------------------------ + + /** + * Dot product between a matrix row and a dense vector. + * Used by ASH projection: y_i = . + * + * Contract: Arow.length == x.length. + * Default is scalar; SIMD backends may override. + */ + default float ashDotRow(float[] Arow, float[] x) { + assert Arow.length == x.length : "Arow.length != x.length"; + float acc = 0.0f; + for (int i = 0; i < x.length; i++) { + acc += Arow[i] * x[i]; + } + return acc; + } + + /** + * maskedAdd = for b ∈ {0,1}^d stored as bitpacked longs. + * + * Contract: d <= tildeQ.length. + * Default is scalar; SIMD backends may override. + */ + default float ashMaskedAdd(float[] tildeQ, long[] bits, int d) { + assert d <= tildeQ.length : "d must be <= tildeQ.length"; + + float sum = 0.0f; + int base = 0; + + for (int w = 0; w < bits.length && base < d; w++, base += 64) { + long word = bits[w]; + while (word != 0L) { + int bit = Long.numberOfTrailingZeros(word); + int idx = base + bit; + if (idx < d) sum += tildeQ[idx]; + word &= (word - 1); + } + } + return sum; + } } 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 dc18c4bf2..ea2211e8f 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 @@ -43,7 +43,7 @@ protected FloatVector fromVectorFloat(VectorSpecies SPEC, VectorFloat } protected FloatVector fromVectorFloat(VectorSpecies SPEC, VectorFloat vector, int offset, int[] indices, int indicesOffset) { - return FloatVector.fromArray(SPEC, ((ArrayVectorFloat)vector).get(), offset, indices, indicesOffset); + return FloatVector.fromArray(SPEC, ((ArrayVectorFloat) vector).get(), offset, indices, indicesOffset); } protected void intoVectorFloat(FloatVector vector, VectorFloat v, int offset) { @@ -148,8 +148,7 @@ public float dotProduct(VectorFloat v1, VectorFloat v2) { } @Override - public float dotProduct(VectorFloat v1, int v1offset, VectorFloat v2, int v2offset, final int length) - { + public float dotProduct(VectorFloat v1, int v1offset, VectorFloat v2, int v2offset, final int length) { //Common case first if (length >= FloatVector.SPECIES_PREFERRED.length()) return dotProductPreferred(v1, v1offset, v2, v2offset, length); @@ -251,8 +250,7 @@ float dotProductPreferred(VectorFloat va, int vaoffset, VectorFloat vb, in // Unrolled vector loop; for dot product from L1 cache, an unroll factor of 2 generally suffices. // If we are going to be getting data that's further down the hierarchy but not fetched off disk/network, // we might want to unroll further, e.g. to 8 (4 sets of a,b,sum with 3-ahead reads seems to work best). - if (length >= vectorLength * 2) - { + if (length >= vectorLength * 2) { length -= vectorLength * 2; a0 = fromVectorFloat(FloatVector.SPECIES_PREFERRED, va, vaoffset + vectorLength * 0); b0 = fromVectorFloat(FloatVector.SPECIES_PREFERRED, vb, vboffset + vectorLength * 0); @@ -260,8 +258,7 @@ float dotProductPreferred(VectorFloat va, int vaoffset, VectorFloat vb, in b1 = fromVectorFloat(FloatVector.SPECIES_PREFERRED, vb, vboffset + vectorLength * 1); vaoffset += vectorLength * 2; vboffset += vectorLength * 2; - while (length >= vectorLength * 2) - { + while (length >= vectorLength * 2) { // All instructions in the main loop have no dependencies between them and can be executed in parallel. length -= vectorLength * 2; sum0 = a0.fma(b0, sum0); @@ -393,8 +390,7 @@ public float squareDistance(VectorFloat v1, VectorFloat v2) { } @Override - public float squareDistance(VectorFloat v1, int v1offset, VectorFloat v2, int v2offset, final int length) - { + public float squareDistance(VectorFloat v1, int v1offset, VectorFloat v2, int v2offset, final int length) { //Common case first if (length >= FloatVector.SPECIES_PREFERRED.length()) return squareDistancePreferred(v1, v1offset, v2, v2offset, length); @@ -550,7 +546,7 @@ public void addInPlace(VectorFloat v1, VectorFloat v2) { // Process the tail for (int i = vectorizedLength; i < v1.length(); i++) { - v1.set(i, v1.get(i) + v2.get(i)); + v1.set(i, v1.get(i) + v2.get(i)); } } @@ -571,7 +567,7 @@ public void addInPlace(VectorFloat v1, float value) { // Process the tail for (int i = vectorizedLength; i < v1.length(); i++) { - v1.set(i, v1.get(i) + value); + v1.set(i, v1.get(i) + value); } } @@ -592,7 +588,7 @@ public void subInPlace(VectorFloat v1, VectorFloat v2) { // Process the tail for (int i = vectorizedLength; i < v1.length(); i++) { - v1.set(i, v1.get(i) - v2.get(i)); + v1.set(i, v1.get(i) - v2.get(i)); } } @@ -608,7 +604,7 @@ public void subInPlace(VectorFloat vector, float value) { // Process the tail for (int i = vectorizedLength; i < vector.length(); i++) { - vector.set(i, vector.get(i) - value); + vector.set(i, vector.get(i) - value); } } @@ -679,19 +675,18 @@ public void minInPlace(VectorFloat v1, VectorFloat v2) { // Process the tail for (int i = vectorizedLength; i < v1.length(); i++) { - v1.set(i, Math.min(v1.get(i), v2.get(i))); + v1.set(i, Math.min(v1.get(i), v2.get(i))); } } @Override public float assembleAndSum(VectorFloat data, int dataBase, ByteSequence baseOffsets) { - return assembleAndSum(data, dataBase, baseOffsets, 0, baseOffsets.length()); + return assembleAndSum(data, dataBase, baseOffsets, 0, baseOffsets.length()); } @Override public float assembleAndSum(VectorFloat data, int dataBase, ByteSequence baseOffsets, int baseOffsetsOffset, int baseOffsetsLength) { - return switch (PREFERRED_BIT_SIZE) - { + return switch (PREFERRED_BIT_SIZE) { case 512 -> assembleAndSum512(data, dataBase, baseOffsets, baseOffsetsOffset, baseOffsetsLength); case 256 -> assembleAndSum256(data, dataBase, baseOffsets, baseOffsetsOffset, baseOffsetsLength); case 128 -> assembleAndSum128(data, dataBase, baseOffsets, baseOffsetsOffset, baseOffsetsLength); @@ -712,7 +707,7 @@ float assembleAndSum512(VectorFloat data, int dataBase, ByteSequence baseO .lanewise(VectorOperators.AND, BYTE_TO_INT_MASK_512) .reinterpretAsInts() .add(scale) - .intoArray(convOffsets,0); + .intoArray(convOffsets, 0); var offset = i * dataBase; sum = sum.add(fromVectorFloat(FloatVector.SPECIES_512, data, offset, convOffsets, 0)); @@ -741,7 +736,7 @@ float assembleAndSum256(VectorFloat data, int dataBase, ByteSequence baseO .lanewise(VectorOperators.AND, BYTE_TO_INT_MASK_256) .reinterpretAsInts() .add(scale) - .intoArray(convOffsets,0); + .intoArray(convOffsets, 0); var offset = i * dataBase; sum = sum.add(fromVectorFloat(FloatVector.SPECIES_256, data, offset, convOffsets, 0)); @@ -776,11 +771,13 @@ public float assembleAndSumPQ( int clusterCount // = k ) { //compute the size of the subvector - return switch (PREFERRED_BIT_SIZE) - { - case 512 -> assembleAndSumPQ_512(codebookPartialSums, subspaceCount, vector1Ordinals, vector1OrdinalOffset, vector2Ordinals, vector2OrdinalOffset, clusterCount); - case 256 -> assembleAndSumPQ_256(codebookPartialSums, subspaceCount, vector1Ordinals, vector1OrdinalOffset, vector2Ordinals, vector2OrdinalOffset, clusterCount); - case 128 -> assembleAndSumPQ_128(codebookPartialSums, subspaceCount, vector1Ordinals, vector1OrdinalOffset, vector2Ordinals, vector2OrdinalOffset, clusterCount); + return switch (PREFERRED_BIT_SIZE) { + case 512 -> + assembleAndSumPQ_512(codebookPartialSums, subspaceCount, vector1Ordinals, vector1OrdinalOffset, vector2Ordinals, vector2OrdinalOffset, clusterCount); + case 256 -> + assembleAndSumPQ_256(codebookPartialSums, subspaceCount, vector1Ordinals, vector1OrdinalOffset, vector2Ordinals, vector2OrdinalOffset, clusterCount); + case 128 -> + assembleAndSumPQ_128(codebookPartialSums, subspaceCount, vector1Ordinals, vector1OrdinalOffset, vector2Ordinals, vector2OrdinalOffset, clusterCount); default -> throw new IllegalStateException("Unsupported vector width: " + PREFERRED_BIT_SIZE); }; } @@ -794,19 +791,19 @@ float assembleAndSumPQ_128( int baseOffsetsOffset2, int clusterCount // = k ) { - final int k = clusterCount; - final int blockSize = k * (k + 1) / 2; + final int k = clusterCount; + final int blockSize = k * (k + 1) / 2; float res = 0f; for (int i = 0; i < subspaceCount; i++) { int c1 = Byte.toUnsignedInt(baseOffsets1.get(i + baseOffsetsOffset1)); int c2 = Byte.toUnsignedInt(baseOffsets2.get(i + baseOffsetsOffset2)); - int r = Math.min(c1, c2); - int c = Math.max(c1, c2); + int r = Math.min(c1, c2); + int c = Math.max(c1, c2); - int offsetRow = r * k - (r * (r - 1) / 2); + int offsetRow = r * k - (r * (r - 1) / 2); int idxInBlock = offsetRow + (c - r); - int base = i * blockSize; + int base = i * blockSize; res += data.get(base + idxInBlock); } @@ -824,10 +821,10 @@ float assembleAndSumPQ_256( int clusterCount // = k ) { final VectorSpecies FSPECIES = FloatVector.SPECIES_256; - final int LANES = FSPECIES.length(); - final int k = clusterCount; - final int blockSize = k * (k + 1) / 2; - final int M = subspaceCount; + final int LANES = FSPECIES.length(); + final int k = clusterCount; + final int blockSize = k * (k + 1) / 2; + final int M = subspaceCount; int[] convOffsets = scratchInt256.get(); FloatVector sum = FloatVector.zero(FloatVector.SPECIES_256); @@ -856,9 +853,9 @@ float assembleAndSumPQ_256( var c = c1v.max(c2v); // c) offsetRow = r*k - (r*(r-1))/2 - var rk = r.mul(kvec); - var triangular = r.mul(r.sub(onevec)).mul(twovec); - var offsetRow = rk.sub(triangular); + var rk = r.mul(kvec); + var triangular = r.mul(r.sub(onevec)).mul(twovec); + var offsetRow = rk.sub(triangular); // d) idxInBlock = offsetRow + (c - r) + (i * blockSize) offsetRow.add(c.sub(r)).add(scale) @@ -881,12 +878,12 @@ float assembleAndSumPQ_256( for (int i = (M / LANES) * LANES; i < M; i++) { int c1 = Byte.toUnsignedInt(baseOffsets1.get(i + baseOffsetsOffset1)); int c2 = Byte.toUnsignedInt(baseOffsets2.get(i + baseOffsetsOffset2)); - int r = Math.min(c1, c2); - int c = Math.max(c1, c2); + int r = Math.min(c1, c2); + int c = Math.max(c1, c2); - int offsetRow = r * k - (r * (r - 1) / 2); + int offsetRow = r * k - (r * (r - 1) / 2); int idxInBlock = offsetRow + (c - r); - int base = i * blockSize; + int base = i * blockSize; res += data.get(base + idxInBlock); } @@ -904,10 +901,10 @@ float assembleAndSumPQ_512( int clusterCount // = k ) { final VectorSpecies FSPECIES = FloatVector.SPECIES_512; - final int LANES = FSPECIES.length(); - final int k = clusterCount; - final int blockSize = k * (k + 1) / 2; - final int M = subspaceCount; + final int LANES = FSPECIES.length(); + final int k = clusterCount; + final int blockSize = k * (k + 1) / 2; + final int M = subspaceCount; int[] convOffsets = scratchInt512.get(); FloatVector sum = FloatVector.zero(FloatVector.SPECIES_512); @@ -934,9 +931,9 @@ float assembleAndSumPQ_512( var c = c1v.max(c2v); // c) offsetRow = r*k - (r*(r-1))/2 - var rk = r.mul(kvec); - var triangular = r.mul(r.sub(onevec)).mul(twovec); - var offsetRow = rk.sub(triangular); + var rk = r.mul(kvec); + var triangular = r.mul(r.sub(onevec)).mul(twovec); + var offsetRow = rk.sub(triangular); // d) idxInBlock = offsetRow + (c - r) + (i * blockSize) offsetRow.add(c.sub(r)).add(scale) @@ -959,12 +956,12 @@ float assembleAndSumPQ_512( for (int i = (M / LANES) * LANES; i < M; i++) { int c1 = Byte.toUnsignedInt(baseOffsets1.get(i + baseOffsetsOffset1)); int c2 = Byte.toUnsignedInt(baseOffsets2.get(i + baseOffsetsOffset2)); - int r = Math.min(c1, c2); - int c = Math.max(c1, c2); + int r = Math.min(c1, c2); + int c = Math.max(c1, c2); - int offsetRow = r * k - (r * (r - 1) / 2); + int offsetRow = r * k - (r * (r - 1) / 2); int idxInBlock = offsetRow + (c - r); - int base = i * blockSize; + int base = i * blockSize; res += data.get(base + idxInBlock); } @@ -1040,7 +1037,7 @@ private static int combineBytes(int i, int shuffle, ByteSequence quantizedPar return ((Byte.toUnsignedInt(highByte) << 8) | Byte.toUnsignedInt(lowByte)); } - private static float combineBytes(int i, int shuffle, VectorFloat partials) { + private static float combineBytes(int i, int shuffle, VectorFloat partials) { return partials.get(i * 256 + shuffle); } @@ -1056,9 +1053,12 @@ private static int computeSingleShuffle(int codebookPosition, int neighborPositi @Override public float pqDecodedCosineSimilarity(ByteSequence encoded, int encodedOffset, int encodedLength, int clusterCount, VectorFloat partialSums, VectorFloat aMagnitude, float bMagnitude) { return switch (PREFERRED_BIT_SIZE) { - case 512 -> pqDecodedCosineSimilarity512(encoded, encodedOffset, encodedLength, clusterCount, partialSums, aMagnitude, bMagnitude); - case 256 -> pqDecodedCosineSimilarity256(encoded, encodedOffset, encodedLength, clusterCount, partialSums, aMagnitude, bMagnitude); - case 128 -> pqDecodedCosineSimilarity128(encoded, encodedOffset, encodedLength, clusterCount, partialSums, aMagnitude, bMagnitude); + case 512 -> + pqDecodedCosineSimilarity512(encoded, encodedOffset, encodedLength, clusterCount, partialSums, aMagnitude, bMagnitude); + case 256 -> + pqDecodedCosineSimilarity256(encoded, encodedOffset, encodedLength, clusterCount, partialSums, aMagnitude, bMagnitude); + case 128 -> + pqDecodedCosineSimilarity128(encoded, encodedOffset, encodedLength, clusterCount, partialSums, aMagnitude, bMagnitude); default -> throw new IllegalStateException("Unsupported vector width: " + PREFERRED_BIT_SIZE); }; } @@ -1080,7 +1080,7 @@ float pqDecodedCosineSimilarity512(ByteSequence baseOffsets, int baseOffsetsO .lanewise(VectorOperators.AND, BYTE_TO_INT_MASK_512) .reinterpretAsInts() .add(scale) - .intoArray(convOffsets,0); + .intoArray(convOffsets, 0); var offset = i * clusterCount; sum = sum.add(fromVectorFloat(FloatVector.SPECIES_512, partialSums, offset, convOffsets, 0)); @@ -1116,7 +1116,7 @@ float pqDecodedCosineSimilarity256(ByteSequence baseOffsets, int baseOffsetsO .lanewise(VectorOperators.AND, BYTE_TO_INT_MASK_256) .reinterpretAsInts() .add(scale) - .intoArray(convOffsets,0); + .intoArray(convOffsets, 0); var offset = i * clusterCount; sum = sum.add(fromVectorFloat(FloatVector.SPECIES_256, partialSums, offset, convOffsets, 0)); @@ -1492,7 +1492,7 @@ public float[] nvqCosine8bit(VectorFloat vector, void transpose(VectorFloat arr, int first, int last, int nRows) { final int mn1 = (last - first - 1); - final int n = (last - first) / nRows; + final int n = (last - first) / nRows; boolean[] visited = new boolean[last - first]; float temp; int cycle = first; @@ -1500,7 +1500,7 @@ void transpose(VectorFloat arr, int first, int last, int nRows) { if (visited[cycle - first]) continue; int a = cycle - first; - do { + do { a = a == mn1 ? mn1 : (n * a) % mn1; temp = arr.get(first + a); arr.set(first + a, arr.get(cycle)); @@ -1541,6 +1541,33 @@ public void calculatePartialSums(VectorFloat codebook, int codebookIndex, int @Override public float pqDecodedCosineSimilarity(ByteSequence encoded, int clusterCount, VectorFloat partialSums, VectorFloat aMagnitude, float bMagnitude) { - return pqDecodedCosineSimilarity(encoded, 0, encoded.length(), clusterCount, partialSums, aMagnitude, bMagnitude); + return pqDecodedCosineSimilarity(encoded, 0, encoded.length(), clusterCount, partialSums, aMagnitude, bMagnitude); + } + + @Override + public float ashDotRow(float[] Arow, float[] x) { + assert Arow.length == x.length : "Arow.length != x.length"; + + final VectorSpecies SPEC = FloatVector.SPECIES_PREFERRED; + final int len = x.length; + final int upper = SPEC.loopBound(len); + + FloatVector sum = FloatVector.zero(SPEC); + + int i = 0; + for (; i < upper; i += SPEC.length()) { + FloatVector va = FloatVector.fromArray(SPEC, Arow, i); + FloatVector vx = FloatVector.fromArray(SPEC, x, i); + sum = va.fma(vx, sum); + } + + float acc = sum.reduceLanes(VectorOperators.ADD); + + // scalar tail + for (; i < len; i++) { + acc += Arow[i] * x[i]; + } + + return acc; } -} \ No newline at end of file +} From d2b6b0e713d0e3dcb90ac44a343dee428f472569 Mon Sep 17 00:00:00 2001 From: Ted Willke Date: Tue, 30 Dec 2025 08:20:58 -0800 Subject: [PATCH 006/243] First implementation of paper-faithful block column-major scorer with SIMD support. --- .../jvector/quantization/ASHBlockScorer.java | 23 + .../jvector/quantization/ASHVectors.java | 418 +++++++++++++++++- .../jvector/vector/VectorUtilSupport.java | 57 ++- .../jbellis/jvector/example/DistancesASH.java | 165 ++++++- .../vector/PanamaVectorUtilSupport.java | 271 ++++++++++++ 5 files changed, 902 insertions(+), 32 deletions(-) create mode 100644 jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHBlockScorer.java diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHBlockScorer.java b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHBlockScorer.java new file mode 100644 index 000000000..f2555d748 --- /dev/null +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHBlockScorer.java @@ -0,0 +1,23 @@ +package io.github.jbellis.jvector.quantization; + +/** + * Block-oriented scorer for ASH vectors. + * A block scorer computes scores for a contiguous range of vector ordinals + * into a caller-provided output buffer. + * This interface exists to support: + * - Blocked SIMD scoring + * - Query reuse across multiple neighbors + * - Graph-local scoring (e.g., FusedASH) + */ +public interface ASHBlockScorer { + + /** + * Score {@code count} vectors starting at {@code start}. + * Results are written to {@code out[0..count-1]}. + * @param start starting ordinal + * @param count number of vectors to score + * @param out output buffer (must have length ≥ count) + */ + void scoreRange(int start, int count, float[] out); +} + diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHVectors.java b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHVectors.java index dcfe63a39..5ee690c6e 100644 --- a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHVectors.java +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHVectors.java @@ -32,22 +32,21 @@ * Container for ASH-compressed vectors with a minimal scoring interface. * *

    - * This class intentionally implements only the functionality required for: + * This class implements the functionality required for: *

      - *
    • Encoding benchmarks
    • - *
    • Approximate distance microbenchmarks
    • - *
    • Correctness validation against full-precision distances
    • + *
    • Scalar scoring
    • + *
    • Block scoring
    • + *
    • Vectorized versions of scoring
    • *
    * *

    * TODO (future work): *

      - *
    • Implement landmark-aware ASH scoring using residualNorm and dotWithLandmark
    • *
    • Add a full ASH ScoreFunction aligned with graph search semantics
    • *
    • Support similarityToNeighbor(...) where appropriate
    • *
    • Integrate ASH as a Feature for on-disk graph indexes
    • - *
    • Define reranking strategy (likely via full vectors)
    • - *
    • Support multi-landmark ASH (this is intentionally deferred)
    • + *
    • Define reranking strategy
    • + *
    • Support multi-landmark ASH
    • *
    */ public class ASHVectors implements CompressedVectors { @@ -56,6 +55,19 @@ public class ASHVectors implements CompressedVectors { final AsymmetricHashing.QuantizedVector[] compressedVectors; final ASHScorer scorer; + // Cached scalar headers (avoid object chasing in blocked scorer) + private final float[] residualNorms; + private final float[] dotXMu; + + // Quantized dimensions + private final int d; + private final int words; + + // Packed block-column-major bits (built lazily per blockSize) + private int packedBlockSize = -1; + private long[] packedBits = null; + private int packedBlockCount = 0; + /** * Initialize ASHVectors with an array of ASH-compressed vectors. * @@ -67,6 +79,18 @@ public ASHVectors(AsymmetricHashing ash, this.ash = ash; this.compressedVectors = compressedVectors; this.scorer = new ASHScorer(ash); + + this.d = ash.quantizedDim; + this.words = AsymmetricHashing.QuantizedVector.wordsForDims(d); + + int n = compressedVectors.length; + this.residualNorms = new float[n]; + this.dotXMu = new float[n]; + for (int i = 0; i < n; i++) { + var v = compressedVectors[i]; + residualNorms[i] = v.residualNorm; + dotXMu[i] = v.dotWithLandmark; + } } @Override @@ -126,6 +150,386 @@ public ScoreFunction.ApproximateScoreFunction scoreFunctionFor( return node -> f.similarityTo(compressedVectors[node]); } +// ============================================================================ +// ASH block-oriented scoring +// ============================================================================ + + /** + * Scalar reference block scorer. + * + *

    + * This scorer computes ASH scores by invoking the scalar per-vector + * score function for each vector in the requested range. + *

    + * + *

    + * It serves as a correctness reference and baseline implementation. + * No block-level optimizations are applied. + *

    + */ + private static final class ScalarASHBlockScorer implements ASHBlockScorer { + + private final ASHScorer.ASHScoreFunction scorer; + private final AsymmetricHashing.QuantizedVector[] vectors; + + ScalarASHBlockScorer( + ASHScorer.ASHScoreFunction scorer, + AsymmetricHashing.QuantizedVector[] vectors) { + this.scorer = scorer; + this.vectors = vectors; + } + + @Override + public void scoreRange(int start, int count, float[] out) { + if (count < 0) throw new IllegalArgumentException("count must be >= 0"); + if (start < 0 || start + count > vectors.length) { + throw new IllegalArgumentException( + "Range out of bounds: [" + start + ", " + (start + count) + ")" + ); + } + if (out.length < count) { + throw new IllegalArgumentException("out.length < count"); + } + if (count == 0) return; + + final int end = start + count; + for (int i = start, o = 0; i < end; i++, o++) { + out[o] = scorer.similarityTo(vectors[i]); + } + } + } + + /** + * Returns a scalar reference block scorer. + * + *

    + * The returned scorer matches {@link #scoreFunctionFor} exactly + * and computes each score independently. + *

    + */ + public ASHBlockScorer blockScorerFor( + VectorFloat query, + VectorSimilarityFunction similarityFunction) { + + if (similarityFunction != VectorSimilarityFunction.DOT_PRODUCT) { + throw new UnsupportedOperationException( + "ASH block scorer supports DOT_PRODUCT only"); + } + + ASHScorer.ASHScoreFunction f = + scorer.scoreFunctionFor(query, similarityFunction); + + return new ScalarASHBlockScorer(f, compressedVectors); + } + + /** + * Block-structured scalar ASH scorer. + * + *

    + * This scorer reorganizes the computation to operate on contiguous + * blocks of vectors while remaining fully scalar. + *

    + * + *

    + * The loop structure is: + *

    + *
      + *
    • outer loop over blocks of vectors
    • + *
    • outer loop over binary words
    • + *
    • inner loop over vectors within the block
    • + *
    + * + *

    + * This improves cache locality and provides a stable structural + * foundation for block-oriented evaluation. + *

    + */ + public ASHBlockScorer blockScorerFor( + VectorFloat query, + VectorSimilarityFunction similarityFunction, + int blockSize) { + + if (similarityFunction != VectorSimilarityFunction.DOT_PRODUCT) { + throw new UnsupportedOperationException( + "ASH block scorer supports DOT_PRODUCT only"); + } + if (blockSize <= 0) { + throw new IllegalArgumentException("blockSize must be > 0"); + } + + return new BlockedScalarASHBlockScorer(ash, compressedVectors, query, blockSize); + } + + /** + * Block-oriented ASH scorer using register-local accumulation. + * + *

    + * This implementation evaluates ASH scores for a contiguous block + * of vectors while accumulating intermediate values directly into + * local scalar variables. + *

    + * + *

    + * Compared to array-based accumulation, this approach: + *

    + *
      + *
    • reduces memory traffic
    • + *
    • keeps hot values in registers
    • + *
    • preserves exact ASH scoring semantics
    • + *
    + * + *

    + * This scorer is suitable for brute-force scans and graph neighborhood + * evaluation where vectors are processed contiguously. + *

    + */ + public ASHBlockScorer blockScorerRegisterAccFor( + VectorFloat query, + VectorSimilarityFunction similarityFunction, + int blockSize) { + + if (similarityFunction != VectorSimilarityFunction.DOT_PRODUCT) { + throw new UnsupportedOperationException("DOT_PRODUCT only"); + } + if (blockSize <= 0) { + throw new IllegalArgumentException("blockSize must be > 0"); + } + + return new RegisterAccASHBlockScorer( + ash, + compressedVectors, + residualNorms, + dotXMu, + query + ); + } + + /** + * Block-structured scalar ASH scorer. + * + *

    + * This scorer performs scalar bit-walk accumulation for each vector, + * but organizes the computation by blocks and binary words. + *

    + * + *

    + * It preserves exact correctness and serves as a clear baseline + * for block-based implementations. + *

    + */ + private static final class BlockedScalarASHBlockScorer implements ASHBlockScorer { + + private final AsymmetricHashing ash; + private final AsymmetricHashing.QuantizedVector[] vectors; + private final int blockSize; + + private final float[] tildeQ; + private final float sumTildeQ; + private final float dotQMu; + private final float muNormSq; + private final float invSqrtD; + + private final int d; + private final int words; + private final int originalDim; + + BlockedScalarASHBlockScorer( + AsymmetricHashing ash, + AsymmetricHashing.QuantizedVector[] vectors, + VectorFloat query, + int blockSize) { + + this.ash = ash; + this.vectors = vectors; + this.blockSize = blockSize; + + this.d = ash.quantizedDim; + this.words = AsymmetricHashing.QuantizedVector.wordsForDims(d); + this.originalDim = ash.originalDimension; + + final VectorFloat mu = ash.globalMean; + + final float[] qArr = new float[originalDim]; + final float[] muArr = new float[originalDim]; + for (int i = 0; i < originalDim; i++) { + qArr[i] = query.get(i); + muArr[i] = mu.get(i); + } + + this.tildeQ = new float[d]; + final float[][] A = ash.stiefelTransform.AFloat; + for (int r = 0; r < d; r++) { + float acc = 0f; + float[] Arow = A[r]; + for (int j = 0; j < originalDim; j++) { + acc += Arow[j] * (qArr[j] - muArr[j]); + } + tildeQ[r] = acc; + } + + float s = 0f; + for (int i = 0; i < d; i++) s += tildeQ[i]; + this.sumTildeQ = s; + + this.dotQMu = VectorUtil.dotProduct(query, mu); + this.muNormSq = VectorUtil.dotProduct(mu, mu); + this.invSqrtD = (float) (1.0 / Math.sqrt(d)); + } + + @Override + public void scoreRange(int start, int count, float[] out) { + if (count < 0) throw new IllegalArgumentException("count must be >= 0"); + if (start < 0 || start + count > vectors.length) { + throw new IllegalArgumentException( + "Range out of bounds: [" + start + ", " + (start + count) + ")" + ); + } + if (out.length < count) { + throw new IllegalArgumentException("out.length < count"); + } + if (count == 0) return; + + final float[] acc = new float[Math.min(blockSize, count)]; + + int baseOrd = start; + int remaining = count; + + while (remaining > 0) { + final int blockLen = Math.min(blockSize, remaining); + + for (int i = 0; i < blockLen; i++) acc[i] = 0f; + + for (int w = 0; w < words; w++) { + final int baseDim = w * 64; + + for (int lane = 0; lane < blockLen; lane++) { + long word = vectors[baseOrd + lane].binaryVector[w]; + + while (word != 0L) { + int bit = Long.numberOfTrailingZeros(word); + int idx = baseDim + bit; + if (idx < d) acc[lane] += tildeQ[idx]; + word &= (word - 1); + } + } + } + + for (int lane = 0; lane < blockLen; lane++) { + final AsymmetricHashing.QuantizedVector v = vectors[baseOrd + lane]; + final float scale = invSqrtD * v.residualNorm; + + out[(baseOrd - start) + lane] = + scale * (2f * acc[lane] - sumTildeQ) + + dotQMu + + (v.dotWithLandmark - muNormSq); + } + + baseOrd += blockLen; + remaining -= blockLen; + } + } + } + + /** + * Block-oriented ASH scorer using register-local accumulation. + * + *

    + * This is the preferred block implementation. It avoids temporary + * per-block arrays and accumulates the masked-add term directly + * into scalar registers. + *

    + */ + private static final class RegisterAccASHBlockScorer implements ASHBlockScorer { + + private final AsymmetricHashing ash; + private final AsymmetricHashing.QuantizedVector[] vectors; + private final float[] residualNorms; + private final float[] dotXMu; + + private final float[] tildeQ; + private final float sumTildeQ; + private final float dotQMu; + private final float muNormSq; + private final float invSqrtD; + + private final int d; + private final int words; + + RegisterAccASHBlockScorer( + AsymmetricHashing ash, + AsymmetricHashing.QuantizedVector[] vectors, + float[] residualNorms, + float[] dotXMu, + VectorFloat query) { + + this.ash = ash; + this.vectors = vectors; + this.residualNorms = residualNorms; + this.dotXMu = dotXMu; + + this.d = ash.quantizedDim; + this.words = AsymmetricHashing.QuantizedVector.wordsForDims(d); + + final int D = ash.originalDimension; + final VectorFloat mu = ash.globalMean; + + final float[] qArr = new float[D]; + final float[] muArr = new float[D]; + for (int i = 0; i < D; i++) { + qArr[i] = query.get(i); + muArr[i] = mu.get(i); + } + + this.tildeQ = new float[d]; + final float[][] A = ash.stiefelTransform.AFloat; + for (int r = 0; r < d; r++) { + float s = 0f; + float[] Arow = A[r]; + for (int j = 0; j < D; j++) { + s += Arow[j] * (qArr[j] - muArr[j]); + } + tildeQ[r] = s; + } + + float sum = 0f; + for (int i = 0; i < d; i++) sum += tildeQ[i]; + this.sumTildeQ = sum; + + this.dotQMu = VectorUtil.dotProduct(query, mu); + this.muNormSq = VectorUtil.dotProduct(mu, mu); + this.invSqrtD = (float) (1.0 / Math.sqrt(d)); + } + + @Override + public void scoreRange(int start, int count, float[] out) { + + for (int i = 0; i < count; i++) { + int idx = start + i; + + float maskedAdd = 0f; + long[] bits = vectors[idx].binaryVector; + + for (int w = 0; w < words; w++) { + long word = bits[w]; + int baseDim = w * 64; + + while (word != 0L) { + int bit = Long.numberOfTrailingZeros(word); + int dim = baseDim + bit; + if (dim < d) maskedAdd += tildeQ[dim]; + word &= (word - 1); + } + } + + float scale = invSqrtD * residualNorms[idx]; + out[i] = + scale * (2f * maskedAdd - sumTildeQ) + + dotQMu + + (dotXMu[idx] - muNormSq); + } + } + } + /** * For ASH, precomputed and non-precomputed scoring are currently identical. * diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/vector/VectorUtilSupport.java b/jvector-base/src/main/java/io/github/jbellis/jvector/vector/VectorUtilSupport.java index 417d513b1..8d0ea7c45 100644 --- a/jvector-base/src/main/java/io/github/jbellis/jvector/vector/VectorUtilSupport.java +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/vector/VectorUtilSupport.java @@ -256,26 +256,47 @@ default float ashDotRow(float[] Arow, float[] x) { } /** - * maskedAdd = for b ∈ {0,1}^d stored as bitpacked longs. - * - * Contract: d <= tildeQ.length. - * Default is scalar; SIMD backends may override. + * Computes maskedAdd for a block slice of vectors stored in packed block-column-major form. + * For each lane in [0, blockLen), writes: + * outMaskedAdd[lane] = + * packedBits layout: + * packedBits[blockWordBase + w*blockSize + laneIndex] is 64-bit word w for that lane. + * Default implementation is scalar bit-walk with a register accumulator per lane. + * SIMD backends may override. */ - default float ashMaskedAdd(float[] tildeQ, long[] bits, int d) { - assert d <= tildeQ.length : "d must be <= tildeQ.length"; - - float sum = 0.0f; - int base = 0; - - for (int w = 0; w < bits.length && base < d; w++, base += 64) { - long word = bits[w]; - while (word != 0L) { - int bit = Long.numberOfTrailingZeros(word); - int idx = base + bit; - if (idx < d) sum += tildeQ[idx]; - word &= (word - 1); + default void ashMaskedAddBlockAllWords( + float[] tildeQ, + int d, + long[] packedBits, + int packedBase, // blockId * nWords * blockSize + int nWords, + int blockSize, + int laneStart, + int blockLen, + float[] outMaskedAdd + ) { + assert laneStart >= 0; + assert blockLen >= 0; + assert laneStart + blockLen <= blockSize; + assert outMaskedAdd.length >= blockLen : "outMaskedAdd too small"; + + for (int lane = 0; lane < blockLen; lane++) { + float maskedAdd = 0f; // register accumulator (critical!) + int laneIndex = laneStart + lane; + + for (int w = 0; w < nWords; w++) { + long word = packedBits[packedBase + (w * blockSize) + laneIndex]; + int baseDim = w * 64; + + while (word != 0L) { + int bit = Long.numberOfTrailingZeros(word); + int idx = baseDim + bit; + if (idx < d) maskedAdd += tildeQ[idx]; + word &= (word - 1); + } } + + outMaskedAdd[lane] = maskedAdd; } - return sum; } } diff --git a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/DistancesASH.java b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/DistancesASH.java index b56368cfe..874e2b54e 100644 --- a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/DistancesASH.java +++ b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/DistancesASH.java @@ -19,10 +19,13 @@ import io.github.jbellis.jvector.example.util.SiftLoader; import io.github.jbellis.jvector.graph.ListRandomAccessVectorValues; import io.github.jbellis.jvector.graph.similarity.ScoreFunction; +import io.github.jbellis.jvector.quantization.ASHBlockScorer; import io.github.jbellis.jvector.quantization.CompressedVectors; -import io.github.jbellis.jvector.quantization.AsymmetricHashing; import io.github.jbellis.jvector.vector.VectorSimilarityFunction; +import io.github.jbellis.jvector.quantization.ASHVectors; + +import io.github.jbellis.jvector.quantization.AsymmetricHashing; import io.github.jbellis.jvector.vector.VectorUtil; import io.github.jbellis.jvector.vector.types.VectorFloat; import io.github.jbellis.jvector.util.PhysicalCoreExecutor; @@ -34,12 +37,12 @@ import java.util.concurrent.ForkJoinPool; import java.util.concurrent.ForkJoinTask; - - import static java.lang.Math.abs; public class DistancesASH { public static void testASHEncodings(String filenameBase, String filenameQueries) throws IOException { + // Block sizes to benchmark + final int[] BLOCK_SIZES = {16, 32, 64}; // 16, 32, and/or 64 List> vectors = SiftLoader.readFvecs(filenameBase); List> queries = SiftLoader.readFvecs(filenameQueries); @@ -73,7 +76,7 @@ public static void testASHEncodings(String filenameBase, String filenameQueries) // - Base vectors x are NOT globally normalized. // - For encoding, we compute μ on raw x. // - The residual (x − μ) is normalized ONCE inside the binarizer, - // producing \hat{x} = (x − μ) / ||x − μ|| (Eq. 6). + // producing \hat{x} = (x − μ) / ||x − μ|| (ASH Paper, Eq. 6). // - Queries may be L2-normalized in the benchmark (standard practice), // but are NOT normalized inside the encoder. // - No other normalization steps are applied. @@ -110,6 +113,30 @@ public static void testASHEncodings(String filenameBase, String filenameQueries) long endTime = System.nanoTime(); System.out.println("\tEncoding took " + (endTime - startTime) / 1e9 + " seconds"); + ASHVectors ashVectors = (ASHVectors) ashVecs; + VectorFloat q0 = finalQueries.get(0); + + ASHBlockScorer ref = ashVectors.blockScorerFor(q0, VectorSimilarityFunction.DOT_PRODUCT); + ASHBlockScorer blk = ashVectors.blockScorerRegisterAccFor(q0, VectorSimilarityFunction.DOT_PRODUCT, 64); + + float[] a = new float[128]; + float[] b = new float[128]; + ref.scoreRange(0, 128, a); + blk.scoreRange(0, 128, b); + + final float EPS = 1e-6f; + + for (int i = 0; i < 128; i++) { + float diff = Math.abs(a[i] - b[i]); + if (diff > EPS) { + throw new AssertionError( + "Mismatch at " + i + + ": ref=" + a[i] + + ", blk=" + b[i] + + ", diff=" + diff); + } + } + // ------------------------------------------------------------------ // Shared parallelization setup (executor-honoring) // ------------------------------------------------------------------ @@ -162,7 +189,7 @@ public static void testASHEncodings(String filenameBase, String filenameQueries) System.out.println("\tAverage absolute dot-product error = " + distanceError); // ================================================================== - // [2] ASH distance timing (Eq. 11 only) + // [2] Scalar ASH distance timing (reference) // ================================================================== List> ashTasks = new ArrayList<>(); @@ -195,7 +222,7 @@ public static void testASHEncodings(String filenameBase, String filenameQueries) long ashEnd = System.nanoTime(); - System.out.println("\tASH dot-product computations took " + System.out.println("\tScalar ASH dot-product computations took " + (ashEnd - ashStart) / 1e9 + " seconds"); // ================================================================== @@ -235,6 +262,130 @@ public static void testASHEncodings(String filenameBase, String filenameQueries) // Prevent dead-code elimination System.out.println("\tdummyAccumulator = " + (float) (ashDummy + floatDummy)); System.out.println("--"); + // ================================================================== + // [4] Block scoring timing: block baseline vs register-accumulator + // ================================================================== + for (int blockSize : BLOCK_SIZES) { + + // -------------------------------------------------------------- + // (A) Block baseline (scalar, block-structured) + // -------------------------------------------------------------- + List> baselineTasks = new ArrayList<>(); + long baselineStart = System.nanoTime(); + + for (int start = 0; start < nQueries; start += chunkSize) { + final int s = start; + final int e = Math.min(start + chunkSize, nQueries); + + baselineTasks.add(simdExecutor.submit(() -> { + double localSum = 0.0; + final float[] scores = new float[blockSize]; + + for (int i = s; i < e; i++) { + VectorFloat q = finalQueries.get(i); + + ASHBlockScorer blockScorer = + ashVectors.blockScorerFor( + q, + VectorSimilarityFunction.DOT_PRODUCT, + blockSize); + + int j = 0; + while (j + blockSize <= nVectors) { + blockScorer.scoreRange(j, blockSize, scores); + for (int k = 0; k < blockSize; k++) { + localSum += scores[k]; + } + j += blockSize; + } + + // Tail (scalar fallback) + if (j < nVectors) { + ScoreFunction.ApproximateScoreFunction f = + ashVecs.scoreFunctionFor(q, VectorSimilarityFunction.DOT_PRODUCT); + for (; j < nVectors; j++) { + localSum += f.similarityTo(j); + } + } + } + return localSum; + })); + } + + double baselineDummy = 0.0; + for (ForkJoinTask t : baselineTasks) { + baselineDummy += t.join(); + } + + long baselineEnd = System.nanoTime(); + + System.out.println( + "\tBlock baseline (blockSize=" + blockSize + ") took " + + (baselineEnd - baselineStart) / 1e9 + " seconds"); + + // -------------------------------------------------------------- + // (B) Block register-accumulator scorer (fast block path) + // -------------------------------------------------------------- + List> regAccTasks = new ArrayList<>(); + long regAccStart = System.nanoTime(); + + for (int start = 0; start < nQueries; start += chunkSize) { + final int s = start; + final int e = Math.min(start + chunkSize, nQueries); + + regAccTasks.add(simdExecutor.submit(() -> { + double localSum = 0.0; + final float[] scores = new float[blockSize]; + + for (int i = s; i < e; i++) { + VectorFloat q = finalQueries.get(i); + + ASHBlockScorer blockScorer = + ashVectors.blockScorerRegisterAccFor( + q, + VectorSimilarityFunction.DOT_PRODUCT, + blockSize); + + int j = 0; + while (j + blockSize <= nVectors) { + blockScorer.scoreRange(j, blockSize, scores); + for (int k = 0; k < blockSize; k++) { + localSum += scores[k]; + } + j += blockSize; + } + + // Tail (scalar fallback) + if (j < nVectors) { + ScoreFunction.ApproximateScoreFunction f = + ashVecs.scoreFunctionFor(q, VectorSimilarityFunction.DOT_PRODUCT); + for (; j < nVectors; j++) { + localSum += f.similarityTo(j); + } + } + } + return localSum; + })); + } + + double regAccDummy = 0.0; + for (ForkJoinTask t : regAccTasks) { + regAccDummy += t.join(); + } + + long regAccEnd = System.nanoTime(); + + System.out.println( + "\tBlock register-acc (blockSize=" + blockSize + ") took " + + (regAccEnd - regAccStart) / 1e9 + " seconds"); + + // Prevent dead-code elimination across variants + ashDummy += baselineDummy + regAccDummy; + } + + // Prevent dead-code elimination across all sections + System.out.println("\tdummyAccumulator = " + (float) (ashDummy + floatDummy)); + System.out.println("--"); } public static void runSIFT() throws IOException { @@ -289,7 +440,7 @@ public static void runOpenai3072() throws IOException { System.out.println("Running text-embedding-3-large_3072"); var baseVectors = "./fvec/openai-v3-large-3072-100k/text-embedding-3-large_3072_100000_base_vectors.fvec"; - var queryVectors = "./fvec/openai-v3-large-3072-100k/text-embedding-3-large_3072_100000_base_vectors.fvec"; + var queryVectors = "./fvec/openai-v3-large-3072-100k/text-embedding-3-large_3072_100000_query_vectors_10000.fvec"; testASHEncodings(baseVectors, queryVectors); } 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 ea2211e8f..80808252a 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 @@ -30,6 +30,8 @@ import java.util.List; class PanamaVectorUtilSupport implements VectorUtilSupport { + private static final String ASH_BLOCK_KERNEL = + System.getProperty("jvector.ash.blockKernel", "mask"); // "lut" or "mask" static final int PREFERRED_BIT_SIZE = FloatVector.SPECIES_PREFERRED.vectorBitSize(); static final IntVector BYTE_TO_INT_MASK_512 = IntVector.broadcast(IntVector.SPECIES_512, 0xff); @@ -1570,4 +1572,273 @@ public float ashDotRow(float[] Arow, float[] x) { return acc; } + + @Override + public void ashMaskedAddBlockAllWords(float[] tildeQ, + int d, + long[] packedBits, + int blockWordBase, + int words, + int blockSize, + int laneStart, + int blockLen, + float[] outMaskedAdd) { + + // Like in the ASH paper, SIMD lanes are dimensions (SPECIES_PREFERRED), + // masked load from tildeQ and horizontal sum per neighbor. + final VectorSpecies SPEC = FloatVector.SPECIES_PREFERRED; + final int LANES = SPEC.length(); + + for (int lane = 0; lane < blockLen; lane++) { + float maskedAdd = 0f; // register scalar accumulator + int laneIndex = laneStart + lane; + + for (int w = 0; w < words; w++) { + long wordBits = packedBits[blockWordBase + (w * blockSize) + laneIndex]; + int baseDim = w * 64; + + // process this 64-bit word in LANES-bit chunks + for (int off = 0; off < 64 && (baseDim + off) < d; off += LANES) { + int qBase = baseDim + off; + + // Range mask for tail dims beyond d + VectorMask inRange = SPEC.indexInRange(qBase, d); + + long maskBits; + if (LANES == 16) { + maskBits = (wordBits >>> off) & 0xFFFFL; + } else if (LANES == 8) { + maskBits = (wordBits >>> off) & 0xFFL; + } else if (LANES == 4) { + maskBits = (wordBits >>> off) & 0xFL; + } else { + // Unusual width; fall back to scalar for this chunk + long m = wordBits >>> off; + float s = 0f; + for (int b = 0; b < LANES; b++) { + if (((m >>> b) & 1L) != 0L) { + int idx = qBase + b; + if (idx < d) s += tildeQ[idx]; + } + } + maskedAdd += s; + continue; + } + + if (maskBits == 0L) continue; + + VectorMask bitMask = + VectorMask.fromLong(SPEC, maskBits).and(inRange); + + // masked load (paper) + FloatVector masked = FloatVector.fromArray(SPEC, tildeQ, qBase, bitMask); + + // horizontal sum (paper) + maskedAdd += masked.reduceLanes(VectorOperators.ADD); + } + } + + outMaskedAdd[lane] = maskedAdd; + } + } + + private static void ashMaskedAddBlockWordLutNibble(float[] tildeQ, + int baseDim, + int d, + long[] packedBits, + int packedBase, + int laneStart, + int blockLen, + float[] acc) { + for (int off = 0; off < 64 && (baseDim + off) < d; off += 4) { + int q0 = baseDim + off; + + float v0 = (q0 + 0 < d) ? tildeQ[q0 + 0] : 0f; + float v1 = (q0 + 1 < d) ? tildeQ[q0 + 1] : 0f; + float v2 = (q0 + 2 < d) ? tildeQ[q0 + 2] : 0f; + float v3 = (q0 + 3 < d) ? tildeQ[q0 + 3] : 0f; + + float lut1 = v0; + float lut2 = v1; + float lut3 = v0 + v1; + float lut4 = v2; + float lut5 = v0 + v2; + float lut6 = v1 + v2; + float lut7 = v0 + v1 + v2; + float lut8 = v3; + float lut9 = v0 + v3; + float lut10 = v1 + v3; + float lut11 = v0 + v1 + v3; + float lut12 = v2 + v3; + float lut13 = v0 + v2 + v3; + float lut14 = v1 + v2 + v3; + float lut15 = v0 + v1 + v2 + v3; + + for (int lane = 0; lane < blockLen; lane++) { + long word = packedBits[packedBase + laneStart + lane]; + int mask = (int) ((word >>> off) & 0xF); + switch (mask) { + case 0: break; + case 1: acc[lane] += lut1; break; + case 2: acc[lane] += lut2; break; + case 3: acc[lane] += lut3; break; + case 4: acc[lane] += lut4; break; + case 5: acc[lane] += lut5; break; + case 6: acc[lane] += lut6; break; + case 7: acc[lane] += lut7; break; + case 8: acc[lane] += lut8; break; + case 9: acc[lane] += lut9; break; + case 10: acc[lane] += lut10; break; + case 11: acc[lane] += lut11; break; + case 12: acc[lane] += lut12; break; + case 13: acc[lane] += lut13; break; + case 14: acc[lane] += lut14; break; + case 15: acc[lane] += lut15; break; + } + } + } + } + + private static void ashMaskedAddBlockWordLutByteTwoNibbles(float[] tildeQ, + int baseDim, + int d, + long[] packedBits, + int packedBase, + int laneStart, + int blockLen, + float[] acc) { + for (int off = 0; off < 64 && (baseDim + off) < d; off += 8) { + int q0 = baseDim + off; + + float v0 = (q0 + 0 < d) ? tildeQ[q0 + 0] : 0f; + float v1 = (q0 + 1 < d) ? tildeQ[q0 + 1] : 0f; + float v2 = (q0 + 2 < d) ? tildeQ[q0 + 2] : 0f; + float v3 = (q0 + 3 < d) ? tildeQ[q0 + 3] : 0f; + float v4 = (q0 + 4 < d) ? tildeQ[q0 + 4] : 0f; + float v5 = (q0 + 5 < d) ? tildeQ[q0 + 5] : 0f; + float v6 = (q0 + 6 < d) ? tildeQ[q0 + 6] : 0f; + float v7 = (q0 + 7 < d) ? tildeQ[q0 + 7] : 0f; + + // Low nibble (v0..v3) + float lo1 = v0; + float lo2 = v1; + float lo3 = v0 + v1; + float lo4 = v2; + float lo5 = v0 + v2; + float lo6 = v1 + v2; + float lo7 = v0 + v1 + v2; + float lo8 = v3; + float lo9 = v0 + v3; + float lo10 = v1 + v3; + float lo11 = v0 + v1 + v3; + float lo12 = v2 + v3; + float lo13 = v0 + v2 + v3; + float lo14 = v1 + v2 + v3; + float lo15 = v0 + v1 + v2 + v3; + + // High nibble (v4..v7) + float hi1 = v4; + float hi2 = v5; + float hi3 = v4 + v5; + float hi4 = v6; + float hi5 = v4 + v6; + float hi6 = v5 + v6; + float hi7 = v4 + v5 + v6; + float hi8 = v7; + float hi9 = v4 + v7; + float hi10 = v5 + v7; + float hi11 = v4 + v5 + v7; + float hi12 = v6 + v7; + float hi13 = v4 + v6 + v7; + float hi14 = v5 + v6 + v7; + float hi15 = v4 + v5 + v6 + v7; + + for (int lane = 0; lane < blockLen; lane++) { + long word = packedBits[packedBase + laneStart + lane]; + int mask = (int) ((word >>> off) & 0xFF); + + int lo = mask & 0xF; + int hi = (mask >>> 4) & 0xF; + + float addLo = 0f; + switch (lo) { + case 0: break; + case 1: addLo = lo1; break; + case 2: addLo = lo2; break; + case 3: addLo = lo3; break; + case 4: addLo = lo4; break; + case 5: addLo = lo5; break; + case 6: addLo = lo6; break; + case 7: addLo = lo7; break; + case 8: addLo = lo8; break; + case 9: addLo = lo9; break; + case 10: addLo = lo10; break; + case 11: addLo = lo11; break; + case 12: addLo = lo12; break; + case 13: addLo = lo13; break; + case 14: addLo = lo14; break; + case 15: addLo = lo15; break; + } + + float addHi = 0f; + switch (hi) { + case 0: break; + case 1: addHi = hi1; break; + case 2: addHi = hi2; break; + case 3: addHi = hi3; break; + case 4: addHi = hi4; break; + case 5: addHi = hi5; break; + case 6: addHi = hi6; break; + case 7: addHi = hi7; break; + case 8: addHi = hi8; break; + case 9: addHi = hi9; break; + case 10: addHi = hi10; break; + case 11: addHi = hi11; break; + case 12: addHi = hi12; break; + case 13: addHi = hi13; break; + case 14: addHi = hi14; break; + case 15: addHi = hi15; break; + } + + acc[lane] += (addLo + addHi); + } + } + } + + private static void ashMaskedAddBlockWordMask(float[] tildeQ, + int baseDim, + int d, + long[] packedBits, + int packedBase, + int laneStart, + int blockLen, + float[] acc) { + final VectorSpecies SPEC = FloatVector.SPECIES_PREFERRED; + final int lanes = SPEC.length(); + + for (int off = 0; off < 64 && (baseDim + off) < d; off += lanes) { + int qBase = baseDim + off; + + VectorMask inRange = SPEC.indexInRange(qBase, d); + FloatVector qVec = FloatVector.fromArray(SPEC, tildeQ, qBase, inRange); + + for (int lane = 0; lane < blockLen; lane++) { + long word = packedBits[packedBase + laneStart + lane]; + + long maskBits; + if (lanes == 16) { + maskBits = (word >>> off) & 0xFFFFL; + } else if (lanes == 8) { + maskBits = (word >>> off) & 0xFFL; + } else { // NEON 4 lanes + maskBits = (word >>> off) & 0xFL; + } + + if (maskBits == 0L) continue; + + VectorMask bitMask = VectorMask.fromLong(SPEC, maskBits).and(inRange); + acc[lane] += qVec.reduceLanes(VectorOperators.ADD, bitMask); + } + } + } } From 78f67acd09bcb9b9382d58690369ddd5670577cf Mon Sep 17 00:00:00 2001 From: Ted Willke Date: Tue, 30 Dec 2025 23:23:58 +0000 Subject: [PATCH 007/243] Improved backend selection logic and SIMD performance. Also improved DistancesASH. --- .../jvector/quantization/ASHVectors.java | 339 ++++++++++-------- .../jvector/vector/VectorUtilSupport.java | 7 + .../jbellis/jvector/example/DistancesASH.java | 148 +++++--- .../vector/PanamaVectorUtilSupport.java | 5 + 4 files changed, 314 insertions(+), 185 deletions(-) diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHVectors.java b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHVectors.java index 5ee690c6e..86277d4d2 100644 --- a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHVectors.java +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHVectors.java @@ -21,6 +21,8 @@ import io.github.jbellis.jvector.util.RamUsageEstimator; import io.github.jbellis.jvector.vector.VectorSimilarityFunction; import io.github.jbellis.jvector.vector.VectorUtil; +import io.github.jbellis.jvector.vector.VectorUtilSupport; +import io.github.jbellis.jvector.vector.VectorizationProvider; import io.github.jbellis.jvector.vector.types.VectorFloat; import java.io.DataOutput; @@ -55,6 +57,19 @@ public class ASHVectors implements CompressedVectors { final AsymmetricHashing.QuantizedVector[] compressedVectors; final ASHScorer scorer; + enum AshBlockKernel { + AUTO, + SCALAR, + SIMD; + + static AshBlockKernel fromProperty() { + String v = System.getProperty("jvector.ash.blockKernel", "auto").toLowerCase(); + if ("scalar".equals(v)) return SCALAR; + if ("simd".equals(v)) return SIMD; + return AUTO; + } + } + // Cached scalar headers (avoid object chasing in blocked scorer) private final float[] residualNorms; private final float[] dotXMu; @@ -223,67 +238,33 @@ public ASHBlockScorer blockScorerFor( } /** - * Block-structured scalar ASH scorer. + * Returns a block-oriented ASH scorer for contiguous vectors. * *

    - * This scorer reorganizes the computation to operate on contiguous - * blocks of vectors while remaining fully scalar. + * This factory selects between a portable scalar implementation and a + * SIMD implementation of ASH block scoring (ASH paper, Figure 1 / Table 1). *

    * *

    - * The loop structure is: + * Selection is controlled by the JVM property: *

    - *
      - *
    • outer loop over blocks of vectors
    • - *
    • outer loop over binary words
    • - *
    • inner loop over vectors within the block
    • - *
    * - *

    - * This improves cache locality and provides a stable structural - * foundation for block-oriented evaluation. - *

    - */ - public ASHBlockScorer blockScorerFor( - VectorFloat query, - VectorSimilarityFunction similarityFunction, - int blockSize) { - - if (similarityFunction != VectorSimilarityFunction.DOT_PRODUCT) { - throw new UnsupportedOperationException( - "ASH block scorer supports DOT_PRODUCT only"); - } - if (blockSize <= 0) { - throw new IllegalArgumentException("blockSize must be > 0"); - } - - return new BlockedScalarASHBlockScorer(ash, compressedVectors, query, blockSize); - } - - /** - * Block-oriented ASH scorer using register-local accumulation. + *
    +     *   -Djvector.ash.blockKernel=auto|scalar|simd
    +     * 
    * - *

    - * This implementation evaluates ASH scores for a contiguous block - * of vectors while accumulating intermediate values directly into - * local scalar variables. - *

    - * - *

    - * Compared to array-based accumulation, this approach: - *

    *
      - *
    • reduces memory traffic
    • - *
    • keeps hot values in registers
    • - *
    • preserves exact ASH scoring semantics
    • + *
    • auto (default): the active VectorUtilSupport backend determines whether execution is vectorized or scalar
    • + *
    • scalar: force the portable scalar register-accumulating implementation
    • + *
    • simd: simd: force the block scorer defined in the ASH paper; execution may still be scalar if the active backend does not support masked-load SIMD
    • *
    * *

    - * This scorer is suitable for brute-force scans and graph neighborhood - * evaluation where vectors are processed contiguously. + * In all cases, accumulation of the masked-add term ⟨q̃, b⟩ is performed in + * local scalar registers, ensuring numerical equivalence across backends. *

    */ - public ASHBlockScorer blockScorerRegisterAccFor( + public ASHBlockScorer blockScorerFor( VectorFloat query, VectorSimilarityFunction similarityFunction, int blockSize) { @@ -295,33 +276,84 @@ public ASHBlockScorer blockScorerRegisterAccFor( throw new IllegalArgumentException("blockSize must be > 0"); } - return new RegisterAccASHBlockScorer( + final AshBlockKernel kernel = AshBlockKernel.fromProperty(); + + // ------------------------------------------------------------ + // Forced scalar path (portable register-accumulator) + // ------------------------------------------------------------ + if (kernel == AshBlockKernel.SCALAR) { + return new BlockScalarASHBlockScorer( + ash, + compressedVectors, + residualNorms, + dotXMu, + query + ); + } + + // ------------------------------------------------------------ + // SIMD or AUTO path (paper-faithful block kernel) + // ------------------------------------------------------------ + // AUTO and SIMD intentionally share the same implementation. + // VectorUtilSupport decides whether masked-load is vectorized + // or falls back to scalar internally. + ensurePackedBits(blockSize); + + return new BlockSimdASHBlockScorer( ash, compressedVectors, residualNorms, dotXMu, + packedBits, + blockSize, query ); } + private void ensurePackedBits(int blockSize) { + if (blockSize <= 0) { + throw new IllegalArgumentException("blockSize must be > 0"); + } + if (packedBits != null && packedBlockSize == blockSize) { + return; + } + + final int n = compressedVectors.length; + packedBlockSize = blockSize; + packedBlockCount = (n + blockSize - 1) / blockSize; + packedBits = new long[packedBlockCount * words * blockSize]; + + for (int b = 0; b < packedBlockCount; b++) { + int baseOrd = b * blockSize; + int blockLen = Math.min(blockSize, n - baseOrd); + + for (int w = 0; w < words; w++) { + int dstBase = (b * words + w) * blockSize; + for (int lane = 0; lane < blockLen; lane++) { + packedBits[dstBase + lane] = + compressedVectors[baseOrd + lane].binaryVector[w]; + } + // remaining lanes default to 0 + } + } + } + + /** - * Block-structured scalar ASH scorer. - * - *

    - * This scorer performs scalar bit-walk accumulation for each vector, - * but organizes the computation by blocks and binary words. - *

    + * Block-oriented ASH scorer using register-local accumulation. * *

    - * It preserves exact correctness and serves as a clear baseline - * for block-based implementations. + * This is the preferred block implementation. It avoids temporary + * per-block arrays and accumulates the masked-add term directly + * into scalar registers. *

    */ - private static final class BlockedScalarASHBlockScorer implements ASHBlockScorer { + private static final class BlockScalarASHBlockScorer implements ASHBlockScorer { private final AsymmetricHashing ash; private final AsymmetricHashing.QuantizedVector[] vectors; - private final int blockSize; + private final float[] residualNorms; + private final float[] dotXMu; private final float[] tildeQ; private final float sumTildeQ; @@ -331,27 +363,28 @@ private static final class BlockedScalarASHBlockScorer implements ASHBlockScorer private final int d; private final int words; - private final int originalDim; - BlockedScalarASHBlockScorer( + BlockScalarASHBlockScorer( AsymmetricHashing ash, AsymmetricHashing.QuantizedVector[] vectors, - VectorFloat query, - int blockSize) { + float[] residualNorms, + float[] dotXMu, + VectorFloat query) { this.ash = ash; this.vectors = vectors; - this.blockSize = blockSize; + this.residualNorms = residualNorms; + this.dotXMu = dotXMu; this.d = ash.quantizedDim; this.words = AsymmetricHashing.QuantizedVector.wordsForDims(d); - this.originalDim = ash.originalDimension; + final int D = ash.originalDimension; final VectorFloat mu = ash.globalMean; - final float[] qArr = new float[originalDim]; - final float[] muArr = new float[originalDim]; - for (int i = 0; i < originalDim; i++) { + final float[] qArr = new float[D]; + final float[] muArr = new float[D]; + for (int i = 0; i < D; i++) { qArr[i] = query.get(i); muArr[i] = mu.get(i); } @@ -359,17 +392,17 @@ private static final class BlockedScalarASHBlockScorer implements ASHBlockScorer this.tildeQ = new float[d]; final float[][] A = ash.stiefelTransform.AFloat; for (int r = 0; r < d; r++) { - float acc = 0f; + float s = 0f; float[] Arow = A[r]; - for (int j = 0; j < originalDim; j++) { - acc += Arow[j] * (qArr[j] - muArr[j]); + for (int j = 0; j < D; j++) { + s += Arow[j] * (qArr[j] - muArr[j]); } - tildeQ[r] = acc; + tildeQ[r] = s; } - float s = 0f; - for (int i = 0; i < d; i++) s += tildeQ[i]; - this.sumTildeQ = s; + float sum = 0f; + for (int i = 0; i < d; i++) sum += tildeQ[i]; + this.sumTildeQ = sum; this.dotQMu = VectorUtil.dotProduct(query, mu); this.muNormSq = VectorUtil.dotProduct(mu, mu); @@ -378,98 +411,107 @@ private static final class BlockedScalarASHBlockScorer implements ASHBlockScorer @Override public void scoreRange(int start, int count, float[] out) { - if (count < 0) throw new IllegalArgumentException("count must be >= 0"); - if (start < 0 || start + count > vectors.length) { - throw new IllegalArgumentException( - "Range out of bounds: [" + start + ", " + (start + count) + ")" - ); - } - if (out.length < count) { - throw new IllegalArgumentException("out.length < count"); - } - if (count == 0) return; - - final float[] acc = new float[Math.min(blockSize, count)]; - int baseOrd = start; - int remaining = count; - - while (remaining > 0) { - final int blockLen = Math.min(blockSize, remaining); + for (int i = 0; i < count; i++) { + int idx = start + i; - for (int i = 0; i < blockLen; i++) acc[i] = 0f; + float maskedAdd = 0f; + long[] bits = vectors[idx].binaryVector; for (int w = 0; w < words; w++) { - final int baseDim = w * 64; - - for (int lane = 0; lane < blockLen; lane++) { - long word = vectors[baseOrd + lane].binaryVector[w]; + long word = bits[w]; + int baseDim = w * 64; - while (word != 0L) { - int bit = Long.numberOfTrailingZeros(word); - int idx = baseDim + bit; - if (idx < d) acc[lane] += tildeQ[idx]; - word &= (word - 1); - } + while (word != 0L) { + int bit = Long.numberOfTrailingZeros(word); + int dim = baseDim + bit; + if (dim < d) maskedAdd += tildeQ[dim]; + word &= (word - 1); } } - for (int lane = 0; lane < blockLen; lane++) { - final AsymmetricHashing.QuantizedVector v = vectors[baseOrd + lane]; - final float scale = invSqrtD * v.residualNorm; - - out[(baseOrd - start) + lane] = - scale * (2f * acc[lane] - sumTildeQ) - + dotQMu - + (v.dotWithLandmark - muNormSq); - } - - baseOrd += blockLen; - remaining -= blockLen; + float scale = invSqrtD * residualNorms[idx]; + out[i] = + scale * (2f * maskedAdd - sumTildeQ) + + dotQMu + + (dotXMu[idx] - muNormSq); } } } /** - * Block-oriented ASH scorer using register-local accumulation. + * Block-oriented ASH scorer using SIMD masked-loads with register-local + * accumulation. * *

    - * This is the preferred block implementation. It avoids temporary - * per-block arrays and accumulates the masked-add term directly - * into scalar registers. + * This scorer is a faithful implementation of Figure 1 + * and Table 1 kernel from the ASH paper + *

    + * + *
      + *
    • binary codes are stored in block column-major layout
    • + *
    • SIMD lanes correspond to dimensions
    • + *
    • masked loads select active dimensions
    • + *
    • horizontal sums are accumulated per vector
    • + *
    + * + *

    + * This implementation accumulates masked-add results directly into scalar registers, + * avoiding per-lane memory traffic and restoring performance. *

    */ - private static final class RegisterAccASHBlockScorer implements ASHBlockScorer { + private static final class BlockSimdASHBlockScorer implements ASHBlockScorer { private final AsymmetricHashing ash; private final AsymmetricHashing.QuantizedVector[] vectors; + private final float[] residualNorms; private final float[] dotXMu; + private final long[] packedBits; + private final int blockSize; + + private final int d; + private final int words; + + // Query precompute private final float[] tildeQ; private final float sumTildeQ; private final float dotQMu; private final float muNormSq; private final float invSqrtD; - private final int d; - private final int words; + // Scratch (one per scorer instance) + private final float[] maskedAdds; + + private final VectorUtilSupport vecUtil; - RegisterAccASHBlockScorer( + BlockSimdASHBlockScorer( AsymmetricHashing ash, AsymmetricHashing.QuantizedVector[] vectors, float[] residualNorms, float[] dotXMu, + long[] packedBits, + int blockSize, VectorFloat query) { + this.vecUtil = + io.github.jbellis.jvector.vector.VectorizationProvider + .getInstance() + .getVectorUtilSupport(); this.ash = ash; this.vectors = vectors; this.residualNorms = residualNorms; this.dotXMu = dotXMu; + this.packedBits = packedBits; + this.blockSize = blockSize; this.d = ash.quantizedDim; this.words = AsymmetricHashing.QuantizedVector.wordsForDims(d); + this.maskedAdds = new float[blockSize]; + + // ---- Query precompute (identical to ASHScorer) ---- final int D = ash.originalDimension; final VectorFloat mu = ash.globalMean; @@ -502,30 +544,45 @@ private static final class RegisterAccASHBlockScorer implements ASHBlockScorer { @Override public void scoreRange(int start, int count, float[] out) { + if (count == 0) return; - for (int i = 0; i < count; i++) { - int idx = start + i; - - float maskedAdd = 0f; - long[] bits = vectors[idx].binaryVector; + int ord = start; + int end = start + count; + + while (ord < end) { + final int blockId = ord / blockSize; + final int blockBase = blockId * blockSize; + final int laneStart = ord - blockBase; + final int blockLen = Math.min(blockSize - laneStart, end - ord); + + final int blockWordBase = blockId * words * blockSize; + + // SIMD masked-add with register accumulation + vecUtil.ashMaskedAddBlockAllWords( + tildeQ, + d, + packedBits, + blockWordBase, + words, + blockSize, + laneStart, + blockLen, + maskedAdds + ); - for (int w = 0; w < words; w++) { - long word = bits[w]; - int baseDim = w * 64; + // Finish Eq. 11 per vector + for (int lane = 0; lane < blockLen; lane++) { + int idx = ord + lane; + float scale = invSqrtD * residualNorms[idx]; + float m = maskedAdds[lane]; - while (word != 0L) { - int bit = Long.numberOfTrailingZeros(word); - int dim = baseDim + bit; - if (dim < d) maskedAdd += tildeQ[dim]; - word &= (word - 1); - } + out[idx - start] = + scale * (2f * m - sumTildeQ) + + dotQMu + + (dotXMu[idx] - muNormSq); } - float scale = invSqrtD * residualNorms[idx]; - out[i] = - scale * (2f * maskedAdd - sumTildeQ) - + dotQMu - + (dotXMu[idx] - muNormSq); + ord += blockLen; } } } diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/vector/VectorUtilSupport.java b/jvector-base/src/main/java/io/github/jbellis/jvector/vector/VectorUtilSupport.java index 8d0ea7c45..e40bcd862 100644 --- a/jvector-base/src/main/java/io/github/jbellis/jvector/vector/VectorUtilSupport.java +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/vector/VectorUtilSupport.java @@ -239,6 +239,13 @@ default float pqDecodedCosineSimilarity(ByteSequence encoded, int encodedOffs // ASH kernels (default scalar implementations) // ------------------------------------------------------------------ + /** + * @return true if this backend provides a SIMD implementation of the masked-load kernel as described in the ASH paper. + */ + default boolean supportsAshMaskedLoad() { + return false; + } + /** * Dot product between a matrix row and a dense vector. * Used by ASH projection: y_i = . diff --git a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/DistancesASH.java b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/DistancesASH.java index 874e2b54e..8ca95c0d7 100644 --- a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/DistancesASH.java +++ b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/DistancesASH.java @@ -40,9 +40,16 @@ import static java.lang.Math.abs; public class DistancesASH { + private static void printScorerInfo(String label, ASHBlockScorer scorer) { + System.out.println( + "\t[" + label + "] scorer implementation = " + + scorer.getClass().getName() + ); + } + public static void testASHEncodings(String filenameBase, String filenameQueries) throws IOException { // Block sizes to benchmark - final int[] BLOCK_SIZES = {16, 32, 64}; // 16, 32, and/or 64 + final int[] BLOCK_SIZES = {32}; // 16, 32, and/or 64 List> vectors = SiftLoader.readFvecs(filenameBase); List> queries = SiftLoader.readFvecs(filenameQueries); @@ -117,7 +124,11 @@ public static void testASHEncodings(String filenameBase, String filenameQueries) VectorFloat q0 = finalQueries.get(0); ASHBlockScorer ref = ashVectors.blockScorerFor(q0, VectorSimilarityFunction.DOT_PRODUCT); - ASHBlockScorer blk = ashVectors.blockScorerRegisterAccFor(q0, VectorSimilarityFunction.DOT_PRODUCT, 64); + printScorerInfo("scalar-ref", ref); + + ASHBlockScorer blk = ashVectors.blockScorerFor(q0, VectorSimilarityFunction.DOT_PRODUCT, 64); + printScorerInfo("block-reg-acc", blk); + float[] a = new float[128]; float[] b = new float[128]; @@ -262,37 +273,75 @@ public static void testASHEncodings(String filenameBase, String filenameQueries) // Prevent dead-code elimination System.out.println("\tdummyAccumulator = " + (float) (ashDummy + floatDummy)); System.out.println("--"); + // ================================================================== - // [4] Block scoring timing: block baseline vs register-accumulator + // [4] Block scoring timing: block scalar vs block SIMD // ================================================================== for (int blockSize : BLOCK_SIZES) { // -------------------------------------------------------------- - // (A) Block baseline (scalar, block-structured) + // Print scorer implementations ONCE (diagnostic) // -------------------------------------------------------------- - List> baselineTasks = new ArrayList<>(); - long baselineStart = System.nanoTime(); + { + VectorFloat q = finalQueries.get(0); + + // Scalar block scorer + System.setProperty("jvector.ash.blockKernel", "scalar"); + ASHBlockScorer scalar = + ashVectors.blockScorerFor( + q, + VectorSimilarityFunction.DOT_PRODUCT, + blockSize + ); + printScorerInfo( + "block-scalar (blockSize=" + blockSize + ")", + scalar + ); + + // SIMD block scorer + System.setProperty("jvector.ash.blockKernel", "simd"); + ASHBlockScorer simd = + ashVectors.blockScorerFor( + q, + VectorSimilarityFunction.DOT_PRODUCT, + blockSize + ); + printScorerInfo( + "block-simd (blockSize=" + blockSize + ")", + simd + ); + + // Restore default + System.clearProperty("jvector.ash.blockKernel"); + } + + // -------------------------------------------------------------- + // (A) Block scalar timing + // -------------------------------------------------------------- + System.setProperty("jvector.ash.blockKernel", "scalar"); + + List> scalarTasks = new ArrayList<>(); + long scalarStart = System.nanoTime(); for (int start = 0; start < nQueries; start += chunkSize) { final int s = start; final int e = Math.min(start + chunkSize, nQueries); - baselineTasks.add(simdExecutor.submit(() -> { + scalarTasks.add(simdExecutor.submit(() -> { double localSum = 0.0; final float[] scores = new float[blockSize]; - for (int i = s; i < e; i++) { - VectorFloat q = finalQueries.get(i); - - ASHBlockScorer blockScorer = + for (int qi = s; qi < e; qi++) { + ASHBlockScorer scorer = ashVectors.blockScorerFor( - q, + finalQueries.get(qi), VectorSimilarityFunction.DOT_PRODUCT, - blockSize); + blockSize + ); int j = 0; while (j + blockSize <= nVectors) { - blockScorer.scoreRange(j, blockSize, scores); + scorer.scoreRange(j, blockSize, scores); for (int k = 0; k < blockSize; k++) { localSum += scores[k]; } @@ -302,7 +351,10 @@ public static void testASHEncodings(String filenameBase, String filenameQueries) // Tail (scalar fallback) if (j < nVectors) { ScoreFunction.ApproximateScoreFunction f = - ashVecs.scoreFunctionFor(q, VectorSimilarityFunction.DOT_PRODUCT); + ashVecs.scoreFunctionFor( + finalQueries.get(qi), + VectorSimilarityFunction.DOT_PRODUCT + ); for (; j < nVectors; j++) { localSum += f.similarityTo(j); } @@ -312,43 +364,45 @@ public static void testASHEncodings(String filenameBase, String filenameQueries) })); } - double baselineDummy = 0.0; - for (ForkJoinTask t : baselineTasks) { - baselineDummy += t.join(); + double scalarDummy = 0.0; + for (ForkJoinTask t : scalarTasks) { + scalarDummy += t.join(); } - long baselineEnd = System.nanoTime(); + long scalarEnd = System.nanoTime(); System.out.println( - "\tBlock baseline (blockSize=" + blockSize + ") took " - + (baselineEnd - baselineStart) / 1e9 + " seconds"); + "\tBlock scalar (blockSize=" + blockSize + ") took " + + (scalarEnd - scalarStart) / 1e9 + " seconds" + ); // -------------------------------------------------------------- - // (B) Block register-accumulator scorer (fast block path) + // (B) Block SIMD timing // -------------------------------------------------------------- - List> regAccTasks = new ArrayList<>(); - long regAccStart = System.nanoTime(); + System.setProperty("jvector.ash.blockKernel", "simd"); + + List> simdTasks = new ArrayList<>(); + long simdStart = System.nanoTime(); for (int start = 0; start < nQueries; start += chunkSize) { final int s = start; final int e = Math.min(start + chunkSize, nQueries); - regAccTasks.add(simdExecutor.submit(() -> { + simdTasks.add(simdExecutor.submit(() -> { double localSum = 0.0; final float[] scores = new float[blockSize]; - for (int i = s; i < e; i++) { - VectorFloat q = finalQueries.get(i); - - ASHBlockScorer blockScorer = - ashVectors.blockScorerRegisterAccFor( - q, + for (int qi = s; qi < e; qi++) { + ASHBlockScorer scorer = + ashVectors.blockScorerFor( + finalQueries.get(qi), VectorSimilarityFunction.DOT_PRODUCT, - blockSize); + blockSize + ); int j = 0; while (j + blockSize <= nVectors) { - blockScorer.scoreRange(j, blockSize, scores); + scorer.scoreRange(j, blockSize, scores); for (int k = 0; k < blockSize; k++) { localSum += scores[k]; } @@ -358,7 +412,10 @@ public static void testASHEncodings(String filenameBase, String filenameQueries) // Tail (scalar fallback) if (j < nVectors) { ScoreFunction.ApproximateScoreFunction f = - ashVecs.scoreFunctionFor(q, VectorSimilarityFunction.DOT_PRODUCT); + ashVecs.scoreFunctionFor( + finalQueries.get(qi), + VectorSimilarityFunction.DOT_PRODUCT + ); for (; j < nVectors; j++) { localSum += f.similarityTo(j); } @@ -368,19 +425,22 @@ public static void testASHEncodings(String filenameBase, String filenameQueries) })); } - double regAccDummy = 0.0; - for (ForkJoinTask t : regAccTasks) { - regAccDummy += t.join(); + double simdDummy = 0.0; + for (ForkJoinTask t : simdTasks) { + simdDummy += t.join(); } - long regAccEnd = System.nanoTime(); + long simdEnd = System.nanoTime(); System.out.println( - "\tBlock register-acc (blockSize=" + blockSize + ") took " - + (regAccEnd - regAccStart) / 1e9 + " seconds"); + "\tBlock SIMD (blockSize=" + blockSize + ") took " + + (simdEnd - simdStart) / 1e9 + " seconds" + ); + + System.clearProperty("jvector.ash.blockKernel"); - // Prevent dead-code elimination across variants - ashDummy += baselineDummy + regAccDummy; + // Prevent dead-code elimination + ashDummy += scalarDummy + simdDummy; } // Prevent dead-code elimination across all sections @@ -415,8 +475,8 @@ public static void runCohere100k() throws IOException { public static void runADA() throws IOException { System.out.println("Running ada_002"); - var baseVectors = "./fvec/wikipedia_squad/100k/ada_002_100000_base_vectors.fvec"; - var queryVectors = "./fvec/wikipedia_squad/100k/ada_002_100000_query_vectors_10000.fvec"; + var baseVectors = "./fvec/ada-002/ada_002_100000_base_vectors.fvec"; + var queryVectors = "./fvec/ada-002/ada_002_100000_query_vectors_10000.fvec"; testASHEncodings(baseVectors, queryVectors); } 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 80808252a..fbd9483eb 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 @@ -1573,6 +1573,11 @@ public float ashDotRow(float[] Arow, float[] x) { return acc; } + @Override + public boolean supportsAshMaskedLoad() { + return true; + } + @Override public void ashMaskedAddBlockAllWords(float[] tildeQ, int d, From 2058037417131062d7a63f30a69ab1c8caedc99a Mon Sep 17 00:00:00 2001 From: Ted Willke Date: Tue, 30 Dec 2025 23:59:58 +0000 Subject: [PATCH 008/243] Using only one backend selection per benchmark run in DistancesASH. --- .../jbellis/jvector/example/DistancesASH.java | 138 ++++-------------- 1 file changed, 26 insertions(+), 112 deletions(-) diff --git a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/DistancesASH.java b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/DistancesASH.java index 8ca95c0d7..0fac759fe 100644 --- a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/DistancesASH.java +++ b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/DistancesASH.java @@ -49,7 +49,7 @@ private static void printScorerInfo(String label, ASHBlockScorer scorer) { public static void testASHEncodings(String filenameBase, String filenameQueries) throws IOException { // Block sizes to benchmark - final int[] BLOCK_SIZES = {32}; // 16, 32, and/or 64 + final int[] BLOCK_SIZES = {16, 32, 64, 128}; // 16, 32, and/or 64 List> vectors = SiftLoader.readFvecs(filenameBase); List> queries = SiftLoader.readFvecs(filenameQueries); @@ -126,8 +126,10 @@ public static void testASHEncodings(String filenameBase, String filenameQueries) ASHBlockScorer ref = ashVectors.blockScorerFor(q0, VectorSimilarityFunction.DOT_PRODUCT); printScorerInfo("scalar-ref", ref); - ASHBlockScorer blk = ashVectors.blockScorerFor(q0, VectorSimilarityFunction.DOT_PRODUCT, 64); - printScorerInfo("block-reg-acc", blk); + final int sanityBlockSize = BLOCK_SIZES[0]; + ASHBlockScorer blk = ashVectors.blockScorerFor(q0, VectorSimilarityFunction.DOT_PRODUCT, sanityBlockSize); + printScorerInfo("block", blk); + float[] a = new float[128]; @@ -275,59 +277,35 @@ public static void testASHEncodings(String filenameBase, String filenameQueries) System.out.println("--"); // ================================================================== - // [4] Block scoring timing: block scalar vs block SIMD + // [4] Block scoring timing (single mode per JVM run) + // NOTE: Choose the block kernel using: + // -Djvector.ash.blockKernel=scalar|simd|auto // ================================================================== + final String kernelMode = + System.getProperty("jvector.ash.blockKernel", "auto").toLowerCase(); + for (int blockSize : BLOCK_SIZES) { - // -------------------------------------------------------------- - // Print scorer implementations ONCE (diagnostic) - // -------------------------------------------------------------- + // Print scorer implementation once per blockSize (diagnostic) { - VectorFloat q = finalQueries.get(0); - - // Scalar block scorer - System.setProperty("jvector.ash.blockKernel", "scalar"); - ASHBlockScorer scalar = - ashVectors.blockScorerFor( - q, - VectorSimilarityFunction.DOT_PRODUCT, - blockSize - ); - printScorerInfo( - "block-scalar (blockSize=" + blockSize + ")", - scalar - ); - - // SIMD block scorer - System.setProperty("jvector.ash.blockKernel", "simd"); - ASHBlockScorer simd = + ASHBlockScorer scorer = ashVectors.blockScorerFor( - q, + finalQueries.get(0), VectorSimilarityFunction.DOT_PRODUCT, blockSize ); - printScorerInfo( - "block-simd (blockSize=" + blockSize + ")", - simd - ); - // Restore default - System.clearProperty("jvector.ash.blockKernel"); + printScorerInfo(kernelMode + " (blockSize=" + blockSize + ")", scorer); } - // -------------------------------------------------------------- - // (A) Block scalar timing - // -------------------------------------------------------------- - System.setProperty("jvector.ash.blockKernel", "scalar"); - - List> scalarTasks = new ArrayList<>(); - long scalarStart = System.nanoTime(); + List> blockTasks = new ArrayList<>(); + long blockStart = System.nanoTime(); for (int start = 0; start < nQueries; start += chunkSize) { final int s = start; final int e = Math.min(start + chunkSize, nQueries); - scalarTasks.add(simdExecutor.submit(() -> { + blockTasks.add(simdExecutor.submit(() -> { double localSum = 0.0; final float[] scores = new float[blockSize]; @@ -348,7 +326,7 @@ public static void testASHEncodings(String filenameBase, String filenameQueries) j += blockSize; } - // Tail (scalar fallback) + // Tail uses scalar per-vector scorer (correctness-preserving) if (j < nVectors) { ScoreFunction.ApproximateScoreFunction f = ashVecs.scoreFunctionFor( @@ -360,89 +338,25 @@ public static void testASHEncodings(String filenameBase, String filenameQueries) } } } - return localSum; - })); - } - - double scalarDummy = 0.0; - for (ForkJoinTask t : scalarTasks) { - scalarDummy += t.join(); - } - - long scalarEnd = System.nanoTime(); - - System.out.println( - "\tBlock scalar (blockSize=" + blockSize + ") took " - + (scalarEnd - scalarStart) / 1e9 + " seconds" - ); - - // -------------------------------------------------------------- - // (B) Block SIMD timing - // -------------------------------------------------------------- - System.setProperty("jvector.ash.blockKernel", "simd"); - - List> simdTasks = new ArrayList<>(); - long simdStart = System.nanoTime(); - - for (int start = 0; start < nQueries; start += chunkSize) { - final int s = start; - final int e = Math.min(start + chunkSize, nQueries); - - simdTasks.add(simdExecutor.submit(() -> { - double localSum = 0.0; - final float[] scores = new float[blockSize]; - - for (int qi = s; qi < e; qi++) { - ASHBlockScorer scorer = - ashVectors.blockScorerFor( - finalQueries.get(qi), - VectorSimilarityFunction.DOT_PRODUCT, - blockSize - ); - - int j = 0; - while (j + blockSize <= nVectors) { - scorer.scoreRange(j, blockSize, scores); - for (int k = 0; k < blockSize; k++) { - localSum += scores[k]; - } - j += blockSize; - } - // Tail (scalar fallback) - if (j < nVectors) { - ScoreFunction.ApproximateScoreFunction f = - ashVecs.scoreFunctionFor( - finalQueries.get(qi), - VectorSimilarityFunction.DOT_PRODUCT - ); - for (; j < nVectors; j++) { - localSum += f.similarityTo(j); - } - } - } return localSum; })); } - double simdDummy = 0.0; - for (ForkJoinTask t : simdTasks) { - simdDummy += t.join(); + double blockDummy = 0.0; + for (ForkJoinTask t : blockTasks) { + blockDummy += t.join(); } - long simdEnd = System.nanoTime(); + long blockEnd = System.nanoTime(); System.out.println( - "\tBlock SIMD (blockSize=" + blockSize + ") took " - + (simdEnd - simdStart) / 1e9 + " seconds" + "\tBlock " + kernelMode + " (blockSize=" + blockSize + ") took " + + (blockEnd - blockStart) / 1e9 + " seconds" ); - System.clearProperty("jvector.ash.blockKernel"); - - // Prevent dead-code elimination - ashDummy += scalarDummy + simdDummy; + ashDummy += blockDummy; } - // Prevent dead-code elimination across all sections System.out.println("\tdummyAccumulator = " + (float) (ashDummy + floatDummy)); System.out.println("--"); From d194319f36f3502fba814028276982a48381d9ff Mon Sep 17 00:00:00 2001 From: Ted Willke Date: Wed, 31 Dec 2025 16:55:37 +0000 Subject: [PATCH 009/243] New distance calculation benchmark for PQ. --- .../jbellis/jvector/example/DistancesPQ.java | 302 ++++++++++++++++++ 1 file changed, 302 insertions(+) create mode 100644 jvector-examples/src/main/java/io/github/jbellis/jvector/example/DistancesPQ.java diff --git a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/DistancesPQ.java b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/DistancesPQ.java new file mode 100644 index 000000000..e57727fa9 --- /dev/null +++ b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/DistancesPQ.java @@ -0,0 +1,302 @@ +/* + * 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; + +import io.github.jbellis.jvector.example.util.SiftLoader; +import io.github.jbellis.jvector.graph.ListRandomAccessVectorValues; +import io.github.jbellis.jvector.graph.RandomAccessVectorValues; +import io.github.jbellis.jvector.graph.similarity.ScoreFunction; +import io.github.jbellis.jvector.quantization.CompressedVectors; +import io.github.jbellis.jvector.quantization.PQVectors; +import io.github.jbellis.jvector.quantization.ProductQuantization; +import io.github.jbellis.jvector.util.PhysicalCoreExecutor; +import io.github.jbellis.jvector.vector.VectorSimilarityFunction; +import io.github.jbellis.jvector.vector.VectorUtil; +import io.github.jbellis.jvector.vector.types.VectorFloat; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.ForkJoinPool; +import java.util.concurrent.ForkJoinTask; + +public class DistancesPQ { + + /** + * PQ benchmark aligned with DistancesASH: + * + * - Same datasets + * - Same query normalization + * - Same brute-force scan pattern + * - Same parallel executor + * - Same compression budget (≈ d/32 bytes per vector) + * + * Fast PQ path = PQDecoder (precomputedScoreFunctionFor). + */ + public static void testPQEncodings(String filenameBase, String filenameQueries) throws IOException { + + List> vectors = SiftLoader.readFvecs(filenameBase); + List> queries = SiftLoader.readFvecs(filenameQueries); + + // ------------------------------------------------------------ + // Remove zero-norm vectors (match DistancesASH) + // ------------------------------------------------------------ + int beforeBase = vectors.size(); + List> filteredBase = new ArrayList<>(vectors.size()); + for (VectorFloat v : vectors) { + if (VectorUtil.dotProduct(v, v) > 0f) { + filteredBase.add(v); + } + } + vectors = filteredBase; + + int beforeQuery = queries.size(); + List> filteredQueries = new ArrayList<>(queries.size()); + for (VectorFloat q : queries) { + if (VectorUtil.dotProduct(q, q) > 0f) { + filteredQueries.add(q); + } + } + queries = filteredQueries; + + System.out.println("\tRemoved " + (beforeBase - vectors.size()) + " zero base vectors and " + + (beforeQuery - queries.size()) + " zero query vectors"); + + // Match ASH: normalize queries only + for (VectorFloat q : queries) { + VectorUtil.l2normalize(q); + } + + int dimension = vectors.get(0).length(); + + int nQueries = Math.min(1000, queries.size()); + int nVectors = Math.min(100_000, vectors.size()); + + vectors = vectors.subList(0, nVectors); + queries = queries.subList(0, nQueries); + + final List> finalVectors = vectors; + final List> finalQueries = queries; + + System.out.format("\t%d base and %d query vectors loaded, dimension=%d%n", + nVectors, nQueries, dimension); + + // ------------------------------------------------------------ + // Parallel executor (identical to DistancesASH) + // ------------------------------------------------------------ + ForkJoinPool simdExecutor = PhysicalCoreExecutor.pool(); + int parallelism = simdExecutor.getParallelism(); + int chunkSize = Math.max(1, (nQueries + parallelism - 1) / parallelism); + + // ------------------------------------------------------------ + // PQ parameters (compression matched to ASH) + // ------------------------------------------------------------ + // ASH: encodedBits = d / 4 -> bytes = d / 32 + // PQ: 1 byte per subspace -> M = d / 32 + final int M = Math.max(1, dimension / 32); + final int K = 256; // 1 byte per subspace + final boolean globallyCenter = false; + final float anisotropicThreshold = 0.0f; + + System.out.println("\tPQ params:"); + System.out.println("\t M = " + M + " subspaces"); + System.out.println("\t K = " + K + " clusters"); + System.out.println("\t Compression ≈ " + (M * 8) + " bits (" + M + " bytes)"); + System.out.println("\t ASH reference ≈ " + (dimension / 4) + " bits (" + (dimension / 32) + " bytes)"); + + // ------------------------------------------------------------ + // Train PQ + encode (timed) + // ------------------------------------------------------------ + RandomAccessVectorValues ravv = + new ListRandomAccessVectorValues(finalVectors, dimension); + + long pqTrainStart = System.nanoTime(); + ProductQuantization pq = ProductQuantization.compute( + ravv, + M, + K, + globallyCenter, + anisotropicThreshold, + simdExecutor, + ForkJoinPool.commonPool() + ); + long pqTrainEnd = System.nanoTime(); + + long pqEncodeStart = System.nanoTime(); + CompressedVectors pqAny = pq.encodeAll(ravv, simdExecutor); + long pqEncodeEnd = System.nanoTime(); + + PQVectors pqVecs = (PQVectors) pqAny; + + System.out.println("\tPQ training took " + (pqTrainEnd - pqTrainStart) / 1e9 + " seconds"); + System.out.println("\tPQ encoding took " + (pqEncodeEnd - pqEncodeStart) / 1e9 + " seconds"); + + // ============================================================ + // [1] Accuracy (NOT timed) + // Compare unscaled dot products: + // PQ returns (1 + dp)/2 -> dp = 2*score - 1 + // ============================================================ + List> errorTasks = new ArrayList<>(); + + for (int start = 0; start < nQueries; start += chunkSize) { + final int s = start; + final int e = Math.min(start + chunkSize, nQueries); + + errorTasks.add(simdExecutor.submit(() -> { + double localError = 0.0; + long localCount = 0; + + for (int i = s; i < e; i++) { + VectorFloat q = finalQueries.get(i); + ScoreFunction.ApproximateScoreFunction f = + pqVecs.precomputedScoreFunctionFor(q, VectorSimilarityFunction.DOT_PRODUCT); + + for (int j = 0; j < nVectors; j++) { + float trueDot = VectorUtil.dotProduct(q, finalVectors.get(j)); + float approxScaled = f.similarityTo(j); + float approxDot = 2.0f * approxScaled - 1.0f; + + localError += Math.abs(approxDot - trueDot); + localCount++; + } + } + return new double[]{localError, localCount}; + })); + } + + double totalError = 0.0; + long totalCount = 0; + for (ForkJoinTask t : errorTasks) { + double[] r = t.join(); + totalError += r[0]; + totalCount += (long) r[1]; + } + + System.out.println("\tAverage absolute dot-product error = " + (totalError / totalCount)); + + // ============================================================ + // [2] Fast PQ scan timing (PQDecoder / ADC) + // ============================================================ + List> pqTasks = new ArrayList<>(); + long pqStart = System.nanoTime(); + + for (int start = 0; start < nQueries; start += chunkSize) { + final int s = start; + final int e = Math.min(start + chunkSize, nQueries); + + pqTasks.add(simdExecutor.submit(() -> { + double localSum = 0.0; + for (int i = s; i < e; i++) { + ScoreFunction.ApproximateScoreFunction f = + pqVecs.precomputedScoreFunctionFor( + finalQueries.get(i), + VectorSimilarityFunction.DOT_PRODUCT + ); + for (int j = 0; j < nVectors; j++) { + localSum += f.similarityTo(j); + } + } + return localSum; + })); + } + + double pqDummy = 0.0; + for (ForkJoinTask t : pqTasks) { + pqDummy += t.join(); + } + + long pqEnd = System.nanoTime(); + System.out.println("\tPQDecoder scan took " + (pqEnd - pqStart) / 1e9 + " seconds"); + + // ============================================================ + // [3] Float dot-product baseline + // ============================================================ + List> floatTasks = new ArrayList<>(); + long floatStart = System.nanoTime(); + + for (int start = 0; start < nQueries; start += chunkSize) { + final int s = start; + final int e = Math.min(start + chunkSize, nQueries); + + floatTasks.add(simdExecutor.submit(() -> { + double localSum = 0.0; + for (int i = s; i < e; i++) { + VectorFloat q = finalQueries.get(i); + for (int j = 0; j < nVectors; j++) { + localSum += VectorUtil.dotProduct(q, finalVectors.get(j)); + } + } + return localSum; + })); + } + + double floatDummy = 0.0; + for (ForkJoinTask t : floatTasks) { + floatDummy += t.join(); + } + + long floatEnd = System.nanoTime(); + System.out.println("\tFloat dot-product scan took " + (floatEnd - floatStart) / 1e9 + " seconds"); + + // Prevent dead-code elimination + System.out.println("\tdummyAccumulator = " + (float) (pqDummy + floatDummy)); + System.out.println("--"); + } + + // ------------------------------------------------------------------ + // Dataset runners (mirrors DistancesASH) + // ------------------------------------------------------------------ + + public static void runCohere100k() throws IOException { + System.out.println("Running Cohere-100k"); + testPQEncodings( + "./fvec/cohere-100k/cohere_embed-english-v3.0_1024_base_vectors_100000.fvec", + "./fvec/cohere-100k/cohere_embed-english-v3.0_1024_query_vectors_10000.fvec" + ); + } + + public static void runADA() throws IOException { + System.out.println("Running ada_002"); + testPQEncodings( + "./fvec/ada-002/ada_002_100000_base_vectors.fvec", + "./fvec/ada-002/ada_002_100000_query_vectors_10000.fvec" + ); + } + + public static void runOpenai1536() throws IOException { + System.out.println("Running text-embedding-3-large_1536"); + testPQEncodings( + "./fvec/openai-v3-large-1536-100k/text-embedding-3-large_1536_100000_base_vectors.fvec", + "./fvec/openai-v3-large-1536-100k/text-embedding-3-large_1536_100000_query_vectors_10000.fvec" + ); + } + + public static void runOpenai3072() throws IOException { + System.out.println("Running text-embedding-3-large_3072"); + testPQEncodings( + "./fvec/openai-v3-large-3072-100k/text-embedding-3-large_3072_100000_base_vectors.fvec", + "./fvec/openai-v3-large-3072-100k/text-embedding-3-large_3072_100000_query_vectors_10000.fvec" + ); + } + + public static void main(String[] args) throws IOException { + runCohere100k(); + runADA(); + runOpenai1536(); + runOpenai3072(); + } +} From d3dd57b074344311301d3d49ba08f9ffcdc01bde Mon Sep 17 00:00:00 2001 From: Ted Willke Date: Thu, 1 Jan 2026 00:31:48 +0000 Subject: [PATCH 010/243] Added benchmarking options to DistancesASH and DistancesPQ. Introduced a PQ entropy analysis program. --- .../jbellis/jvector/example/DistancesASH.java | 264 ++++++++++-------- .../jbellis/jvector/example/DistancesPQ.java | 164 ++++++----- .../jvector/example/PQEntropyAnalysis.java | 198 +++++++++++++ 3 files changed, 449 insertions(+), 177 deletions(-) create mode 100644 jvector-examples/src/main/java/io/github/jbellis/jvector/example/PQEntropyAnalysis.java diff --git a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/DistancesASH.java b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/DistancesASH.java index 0fac759fe..2d6797bab 100644 --- a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/DistancesASH.java +++ b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/DistancesASH.java @@ -48,8 +48,23 @@ private static void printScorerInfo(String label, ASHBlockScorer scorer) { } public static void testASHEncodings(String filenameBase, String filenameQueries) throws IOException { + // ------------------------------------------------------------ + // Benchmark configuration (runtime flags) + // ------------------------------------------------------------ + final boolean RUN_SANITY_CHECK = + Boolean.parseBoolean(System.getProperty("jvector.bench.sanity-check", "false")); + + final boolean RUN_ACCURACY_CHECK = + Boolean.parseBoolean(System.getProperty("jvector.bench.accuracy", "false")); + + final boolean RUN_SCALAR_SCORING = + Boolean.parseBoolean(System.getProperty("jvector.bench.scalar-scoring", "false")); + + final boolean RUN_FLOAT_SCORING = + Boolean.parseBoolean(System.getProperty("jvector.bench.float-scoring", "false")); + // Block sizes to benchmark - final int[] BLOCK_SIZES = {16, 32, 64, 128}; // 16, 32, and/or 64 + final int[] BLOCK_SIZES = {64}; // 16, 32, and/or 64 List> vectors = SiftLoader.readFvecs(filenameBase); List> queries = SiftLoader.readFvecs(filenameQueries); @@ -97,8 +112,8 @@ public static void testASHEncodings(String filenameBase, String filenameQueries) ", encodedBits = " + encodedBits ); - int nQueries = Math.min(1000, queries.size()); - int nVectors = Math.min(100_000, vectors.size()); + int nQueries = Math.min(10000, queries.size()); + int nVectors = Math.min(1_000_000, vectors.size()); vectors = vectors.subList(0, nVectors); queries = queries.subList(0, nQueries); @@ -123,37 +138,40 @@ public static void testASHEncodings(String filenameBase, String filenameQueries) ASHVectors ashVectors = (ASHVectors) ashVecs; VectorFloat q0 = finalQueries.get(0); - ASHBlockScorer ref = ashVectors.blockScorerFor(q0, VectorSimilarityFunction.DOT_PRODUCT); - printScorerInfo("scalar-ref", ref); + if(RUN_SANITY_CHECK) { - final int sanityBlockSize = BLOCK_SIZES[0]; - ASHBlockScorer blk = ashVectors.blockScorerFor(q0, VectorSimilarityFunction.DOT_PRODUCT, sanityBlockSize); - printScorerInfo("block", blk); + ASHBlockScorer ref = ashVectors.blockScorerFor(q0, VectorSimilarityFunction.DOT_PRODUCT); + printScorerInfo("scalar-ref", ref); + final int sanityBlockSize = BLOCK_SIZES[0]; + ASHBlockScorer blk = ashVectors.blockScorerFor(q0, VectorSimilarityFunction.DOT_PRODUCT, sanityBlockSize); + printScorerInfo("block", blk); - float[] a = new float[128]; - float[] b = new float[128]; - ref.scoreRange(0, 128, a); - blk.scoreRange(0, 128, b); + float[] a = new float[128]; + float[] b = new float[128]; + ref.scoreRange(0, 128, a); + blk.scoreRange(0, 128, b); - final float EPS = 1e-6f; + final float EPS = 1e-6f; - for (int i = 0; i < 128; i++) { - float diff = Math.abs(a[i] - b[i]); - if (diff > EPS) { - throw new AssertionError( - "Mismatch at " + i + - ": ref=" + a[i] + - ", blk=" + b[i] + - ", diff=" + diff); + for (int i = 0; i < 128; i++) { + float diff = Math.abs(a[i] - b[i]); + if (diff > EPS) { + throw new AssertionError( + "Mismatch at " + i + + ": ref=" + a[i] + + ", blk=" + b[i] + + ", diff=" + diff); + } } } // ------------------------------------------------------------------ // Shared parallelization setup (executor-honoring) // ------------------------------------------------------------------ - ForkJoinPool simdExecutor = PhysicalCoreExecutor.pool(); + // ForkJoinPool simdExecutor = PhysicalCoreExecutor.pool(); // For production + ForkJoinPool simdExecutor = new ForkJoinPool(4); // For profiling int parallelism = simdExecutor.getParallelism(); int chunkSize = Math.max(1, (nQueries + parallelism - 1) / parallelism); @@ -161,120 +179,128 @@ public static void testASHEncodings(String filenameBase, String filenameQueries) // ================================================================== // [1] Accuracy run (NOT timed) // ================================================================== - List> errorTasks = new ArrayList<>(); + if(RUN_ACCURACY_CHECK) { + List> errorTasks = new ArrayList<>(); - for (int start = 0; start < nQueries; start += chunkSize) { - final int s = start; - final int e = Math.min(start + chunkSize, nQueries); + for (int start = 0; start < nQueries; start += chunkSize) { + final int s = start; + final int e = Math.min(start + chunkSize, nQueries); - errorTasks.add(simdExecutor.submit(() -> { - double localError = 0.0; - long localCount = 0; + errorTasks.add(simdExecutor.submit(() -> { + double localError = 0.0; + long localCount = 0; - for (int i = s; i < e; i++) { - VectorFloat q = finalQueries.get(i); - ScoreFunction.ApproximateScoreFunction f = - ashVecs.scoreFunctionFor(q, VectorSimilarityFunction.DOT_PRODUCT); + for (int i = s; i < e; i++) { + VectorFloat q = finalQueries.get(i); + ScoreFunction.ApproximateScoreFunction f = + ashVecs.scoreFunctionFor(q, VectorSimilarityFunction.DOT_PRODUCT); - for (int j = 0; j < nVectors; j++) { - VectorFloat v = finalVectors.get(j); - float trueDot = VectorUtil.dotProduct(q, v); - float approxDot = f.similarityTo(j); + for (int j = 0; j < nVectors; j++) { + VectorFloat v = finalVectors.get(j); + float trueDot = VectorUtil.dotProduct(q, v); + float approxDot = f.similarityTo(j); - localError += Math.abs(approxDot - trueDot); - localCount++; + localError += Math.abs(approxDot - trueDot); + localCount++; + } } - } - return new double[]{localError, localCount}; - })); - } + return new double[]{localError, localCount}; + })); + } - double distanceError = 0.0; - long count = 0; + double distanceError = 0.0; + long count = 0; - for (ForkJoinTask t : errorTasks) { - double[] r = t.join(); - distanceError += r[0]; - count += (long) r[1]; - } + for (ForkJoinTask t : errorTasks) { + double[] r = t.join(); + distanceError += r[0]; + count += (long) r[1]; + } - distanceError /= count; - System.out.println("\tAverage absolute dot-product error = " + distanceError); + distanceError /= count; + System.out.println("\tAverage absolute dot-product error = " + distanceError); + } // ================================================================== - // [2] Scalar ASH distance timing (reference) + // [2] Scalar ASH scoring timing (reference) // ================================================================== - List> ashTasks = new ArrayList<>(); + if (RUN_SCALAR_SCORING) { + List> ashTasks = new ArrayList<>(); - long ashStart = System.nanoTime(); + long ashStart = System.nanoTime(); - for (int start = 0; start < nQueries; start += chunkSize) { - final int s = start; - final int e = Math.min(start + chunkSize, nQueries); + for (int start = 0; start < nQueries; start += chunkSize) { + final int s = start; + final int e = Math.min(start + chunkSize, nQueries); - ashTasks.add(simdExecutor.submit(() -> { - double localSum = 0.0; + ashTasks.add(simdExecutor.submit(() -> { + double localSum = 0.0; - for (int i = s; i < e; i++) { - VectorFloat q = finalQueries.get(i); - ScoreFunction.ApproximateScoreFunction f = - ashVecs.scoreFunctionFor(q, VectorSimilarityFunction.DOT_PRODUCT); + for (int i = s; i < e; i++) { + VectorFloat q = finalQueries.get(i); + ScoreFunction.ApproximateScoreFunction f = + ashVecs.scoreFunctionFor(q, VectorSimilarityFunction.DOT_PRODUCT); - for (int j = 0; j < nVectors; j++) { - localSum += f.similarityTo(j); + for (int j = 0; j < nVectors; j++) { + localSum += f.similarityTo(j); + } } - } - return localSum; - })); - } + return localSum; + })); + } - double ashDummy = 0.0; - for (ForkJoinTask t : ashTasks) { - ashDummy += t.join(); - } + double ashDummy = 0.0; + for (ForkJoinTask t : ashTasks) { + ashDummy += t.join(); + } - long ashEnd = System.nanoTime(); + long ashEnd = System.nanoTime(); - System.out.println("\tScalar ASH dot-product computations took " - + (ashEnd - ashStart) / 1e9 + " seconds"); + System.out.println("\tScalar ASH dot-product computations took " + + (ashEnd - ashStart) / 1e9 + " seconds"); + System.out.println("\tdummyAccumulator = " + (float) (ashDummy)); + System.out.println("--"); + } // ================================================================== - // [3] Float dot-product timing (ground truth baseline) + // [3] Float dot-product scoring (ground truth baseline) // ================================================================== - List> floatTasks = new ArrayList<>(); + if (RUN_FLOAT_SCORING) { + List> floatTasks = new ArrayList<>(); - long floatStart = System.nanoTime(); + long floatStart = System.nanoTime(); - for (int start = 0; start < nQueries; start += chunkSize) { - final int s = start; - final int e = Math.min(start + chunkSize, nQueries); + for (int start = 0; start < nQueries; start += chunkSize) { + final int s = start; + final int e = Math.min(start + chunkSize, nQueries); - floatTasks.add(simdExecutor.submit(() -> { - double localSum = 0.0; + floatTasks.add(simdExecutor.submit(() -> { + double localSum = 0.0; - for (int i = s; i < e; i++) { - VectorFloat q = finalQueries.get(i); - for (int j = 0; j < nVectors; j++) { - localSum += VectorUtil.dotProduct(q, finalVectors.get(j)); + for (int i = s; i < e; i++) { + VectorFloat q = finalQueries.get(i); + for (int j = 0; j < nVectors; j++) { + localSum += VectorUtil.dotProduct(q, finalVectors.get(j)); + } } - } - return localSum; - })); - } + return localSum; + })); + } - double floatDummy = 0.0; - for (ForkJoinTask t : floatTasks) { - floatDummy += t.join(); - } + double floatDummy = 0.0; + for (ForkJoinTask t : floatTasks) { + floatDummy += t.join(); + } - long floatEnd = System.nanoTime(); + long floatEnd = System.nanoTime(); - System.out.println("\tFloat dot-product computations took " - + (floatEnd - floatStart) / 1e9 + " seconds"); + System.out.println("\tFloat dot-product computations took " + + (floatEnd - floatStart) / 1e9 + " seconds"); - // Prevent dead-code elimination - System.out.println("\tdummyAccumulator = " + (float) (ashDummy + floatDummy)); - System.out.println("--"); + // Prevent dead-code elimination + System.out.println("\tdummyAccumulator = " + (float) (floatDummy)); + System.out.println("--"); + } // ================================================================== // [4] Block scoring timing (single mode per JVM run) @@ -355,11 +381,9 @@ public static void testASHEncodings(String filenameBase, String filenameQueries) + (blockEnd - blockStart) / 1e9 + " seconds" ); - ashDummy += blockDummy; + System.out.println("\tdummyAccumulator = " + (float) (blockDummy)); + System.out.println("--"); } - // Prevent dead-code elimination across all sections - System.out.println("\tdummyAccumulator = " + (float) (ashDummy + floatDummy)); - System.out.println("--"); } public static void runSIFT() throws IOException { @@ -418,13 +442,31 @@ public static void runOpenai3072() throws IOException { testASHEncodings(baseVectors, queryVectors); } + public static void runCap6m() throws IOException { + System.out.println("Running cap-6m"); + + var baseVectors = "./fvec/cap-6m/Caselaw_gte-Qwen2-1.5B_embeddings_base_6m_norm_shuffle.fvecs"; + var queryVectors = "./fvec/cap-6m/Caselaw_gte-Qwen2-1.5B_embeddings_query_10k_norm_shuffle.fvecs"; + testASHEncodings(baseVectors, queryVectors); + } + + public static void runCohere10m() throws IOException { + System.out.println("Running cohere-10m"); + + var baseVectors = "./fvec/cohere-10m/cohere_wiki_en_flat_base_10m_norm.fvecs"; + var queryVectors = "./fvec/cohere-10m/cohere_wiki_en_flat_query_10k_norm.fvecs"; + testASHEncodings(baseVectors, queryVectors); + } + public static void main(String[] args) throws IOException { // runSIFT(); // runGIST(); // runColbert(); - runCohere100k(); - runADA(); - runOpenai1536(); - runOpenai3072(); +// runCohere100k(); +// runADA(); +// runOpenai1536(); +// runOpenai3072(); +// runCap6m(); + runCohere10m(); } } \ No newline at end of file diff --git a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/DistancesPQ.java b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/DistancesPQ.java index e57727fa9..570e663f4 100644 --- a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/DistancesPQ.java +++ b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/DistancesPQ.java @@ -48,6 +48,13 @@ public class DistancesPQ { * Fast PQ path = PQDecoder (precomputedScoreFunctionFor). */ public static void testPQEncodings(String filenameBase, String filenameQueries) throws IOException { + // ------------------------------------------------------------ + // Benchmark configuration (runtime flags) + // ------------------------------------------------------------ + final boolean RUN_ACCURACY_CHECK = + Boolean.parseBoolean(System.getProperty("jvector.bench.accuracy-check", "true")); + final boolean RUN_FLOAT_SCORING = + Boolean.parseBoolean(System.getProperty("jvector.bench.float-scoring", "false")); List> vectors = SiftLoader.readFvecs(filenameBase); List> queries = SiftLoader.readFvecs(filenameQueries); @@ -83,8 +90,8 @@ public static void testPQEncodings(String filenameBase, String filenameQueries) int dimension = vectors.get(0).length(); - int nQueries = Math.min(1000, queries.size()); - int nVectors = Math.min(100_000, vectors.size()); + int nQueries = Math.min(10000, queries.size()); + int nVectors = Math.min(10_000_000, vectors.size()); vectors = vectors.subList(0, nVectors); queries = queries.subList(0, nQueries); @@ -150,43 +157,45 @@ public static void testPQEncodings(String filenameBase, String filenameQueries) // Compare unscaled dot products: // PQ returns (1 + dp)/2 -> dp = 2*score - 1 // ============================================================ - List> errorTasks = new ArrayList<>(); - - for (int start = 0; start < nQueries; start += chunkSize) { - final int s = start; - final int e = Math.min(start + chunkSize, nQueries); - - errorTasks.add(simdExecutor.submit(() -> { - double localError = 0.0; - long localCount = 0; - - for (int i = s; i < e; i++) { - VectorFloat q = finalQueries.get(i); - ScoreFunction.ApproximateScoreFunction f = - pqVecs.precomputedScoreFunctionFor(q, VectorSimilarityFunction.DOT_PRODUCT); - - for (int j = 0; j < nVectors; j++) { - float trueDot = VectorUtil.dotProduct(q, finalVectors.get(j)); - float approxScaled = f.similarityTo(j); - float approxDot = 2.0f * approxScaled - 1.0f; - - localError += Math.abs(approxDot - trueDot); - localCount++; + if (RUN_ACCURACY_CHECK) { + List> errorTasks = new ArrayList<>(); + + for (int start = 0; start < nQueries; start += chunkSize) { + final int s = start; + final int e = Math.min(start + chunkSize, nQueries); + + errorTasks.add(simdExecutor.submit(() -> { + double localError = 0.0; + long localCount = 0; + + for (int i = s; i < e; i++) { + VectorFloat q = finalQueries.get(i); + ScoreFunction.ApproximateScoreFunction f = + pqVecs.precomputedScoreFunctionFor(q, VectorSimilarityFunction.DOT_PRODUCT); + + for (int j = 0; j < nVectors; j++) { + float trueDot = VectorUtil.dotProduct(q, finalVectors.get(j)); + float approxScaled = f.similarityTo(j); + float approxDot = 2.0f * approxScaled - 1.0f; + + localError += Math.abs(approxDot - trueDot); + localCount++; + } } - } - return new double[]{localError, localCount}; - })); - } + return new double[]{localError, localCount}; + })); + } - double totalError = 0.0; - long totalCount = 0; - for (ForkJoinTask t : errorTasks) { - double[] r = t.join(); - totalError += r[0]; - totalCount += (long) r[1]; - } + double totalError = 0.0; + long totalCount = 0; + for (ForkJoinTask t : errorTasks) { + double[] r = t.join(); + totalError += r[0]; + totalCount += (long) r[1]; + } - System.out.println("\tAverage absolute dot-product error = " + (totalError / totalCount)); + System.out.println("\tAverage absolute dot-product error = " + (totalError / totalCount)); + } // ============================================================ // [2] Fast PQ scan timing (PQDecoder / ADC) @@ -218,6 +227,9 @@ public static void testPQEncodings(String filenameBase, String filenameQueries) for (ForkJoinTask t : pqTasks) { pqDummy += t.join(); } + // Prevent dead-code elimination + System.out.println("\tdummyAccumulator = " + (float) (pqDummy)); + System.out.println("--"); long pqEnd = System.nanoTime(); System.out.println("\tPQDecoder scan took " + (pqEnd - pqStart) / 1e9 + " seconds"); @@ -225,36 +237,38 @@ public static void testPQEncodings(String filenameBase, String filenameQueries) // ============================================================ // [3] Float dot-product baseline // ============================================================ - List> floatTasks = new ArrayList<>(); - long floatStart = System.nanoTime(); - - for (int start = 0; start < nQueries; start += chunkSize) { - final int s = start; - final int e = Math.min(start + chunkSize, nQueries); - - floatTasks.add(simdExecutor.submit(() -> { - double localSum = 0.0; - for (int i = s; i < e; i++) { - VectorFloat q = finalQueries.get(i); - for (int j = 0; j < nVectors; j++) { - localSum += VectorUtil.dotProduct(q, finalVectors.get(j)); + if (RUN_FLOAT_SCORING) { + List> floatTasks = new ArrayList<>(); + long floatStart = System.nanoTime(); + + for (int start = 0; start < nQueries; start += chunkSize) { + final int s = start; + final int e = Math.min(start + chunkSize, nQueries); + + floatTasks.add(simdExecutor.submit(() -> { + double localSum = 0.0; + for (int i = s; i < e; i++) { + VectorFloat q = finalQueries.get(i); + for (int j = 0; j < nVectors; j++) { + localSum += VectorUtil.dotProduct(q, finalVectors.get(j)); + } } - } - return localSum; - })); - } + return localSum; + })); + } - double floatDummy = 0.0; - for (ForkJoinTask t : floatTasks) { - floatDummy += t.join(); - } + double floatDummy = 0.0; + for (ForkJoinTask t : floatTasks) { + floatDummy += t.join(); + } - long floatEnd = System.nanoTime(); - System.out.println("\tFloat dot-product scan took " + (floatEnd - floatStart) / 1e9 + " seconds"); + long floatEnd = System.nanoTime(); + System.out.println("\tFloat dot-product scan took " + (floatEnd - floatStart) / 1e9 + " seconds"); - // Prevent dead-code elimination - System.out.println("\tdummyAccumulator = " + (float) (pqDummy + floatDummy)); - System.out.println("--"); + // Prevent dead-code elimination + System.out.println("\tdummyAccumulator = " + (float) (floatDummy)); + System.out.println("--"); + } } // ------------------------------------------------------------------ @@ -293,10 +307,28 @@ public static void runOpenai3072() throws IOException { ); } + public static void runCap6m() throws IOException { + System.out.println("Running cap-6m"); + + var baseVectors = "./fvec/cap-6m/Caselaw_gte-Qwen2-1.5B_embeddings_base_6m_norm_shuffle.fvecs"; + var queryVectors = "./fvec/cap-6m/Caselaw_gte-Qwen2-1.5B_embeddings_query_10k_norm_shuffle.fvecs"; + testPQEncodings(baseVectors, queryVectors); + } + + public static void runCohere10m() throws IOException { + System.out.println("Running cohere-10m"); + + var baseVectors = "./fvec/cohere-10m/cohere_wiki_en_flat_base_10m_norm.fvecs"; + var queryVectors = "./fvec/cohere-10m/cohere_wiki_en_flat_query_10k_norm.fvecs"; + testPQEncodings(baseVectors, queryVectors); + } + public static void main(String[] args) throws IOException { - runCohere100k(); - runADA(); - runOpenai1536(); - runOpenai3072(); +// runCohere100k(); +// runADA(); +// runOpenai1536(); +// runOpenai3072(); + runCap6m(); + runCohere10m(); } } diff --git a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/PQEntropyAnalysis.java b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/PQEntropyAnalysis.java new file mode 100644 index 000000000..9b5aa9d77 --- /dev/null +++ b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/PQEntropyAnalysis.java @@ -0,0 +1,198 @@ +/* + * 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; + +import io.github.jbellis.jvector.example.util.SiftLoader; +import io.github.jbellis.jvector.graph.ListRandomAccessVectorValues; +import io.github.jbellis.jvector.graph.RandomAccessVectorValues; +import io.github.jbellis.jvector.quantization.PQVectors; +import io.github.jbellis.jvector.quantization.ProductQuantization; +import io.github.jbellis.jvector.vector.types.VectorFloat; +import io.github.jbellis.jvector.util.PhysicalCoreExecutor; + +import java.io.IOException; +import java.util.List; +import java.util.concurrent.ForkJoinPool; + +/** + * Diagnostic tool to analyze Product Quantization centroid assignment entropy. + * + *

    + * Explains PQ scan-time behavior by measuring how uniformly vectors are + * assigned to centroids in each subspace. + *

    + * + *

    + * High entropy ⇒ many centroids exercised ⇒ poor cache locality and slower ADC. + * Low entropy ⇒ concentrated centroids ⇒ faster PQ scans. + *

    + * + *

    + * This is NOT a benchmark: + *

      + *
    • No timing loops
    • + *
    • No scoring
    • + *
    • No SIMD assumptions
    • + *
    + *

    + */ +public class PQEntropyAnalysis { + + public static void analyze( + String label, + String baseVectorsPath, + int encodedBits + ) throws IOException { + + System.out.println("--------------------------------------------------"); + System.out.println("PQ Entropy Analysis: " + label); + + // ------------------------------------------------------------ + // Load base vectors + // ------------------------------------------------------------ + List> vectors = SiftLoader.readFvecs(baseVectorsPath); + int dimension = vectors.get(0).length(); + + System.out.println("Vectors: " + vectors.size()); + System.out.println("Dimension: " + dimension); + System.out.println("Target compression: " + encodedBits + " bits"); + + // ------------------------------------------------------------ + // PQ parameters (match DistancesPQ) + // ------------------------------------------------------------ + // ASH: encodedBits = d / 4 → bytes = d / 32 + // PQ: 1 byte per subspace → M = d / 32 + final int M = Math.max(1, encodedBits / 8); + final int K = 256; + final boolean globallyCenter = false; + final float anisotropicThreshold = 0.0f; + + System.out.println("PQ params:"); + System.out.println(" M (subspaces) = " + M); + System.out.println(" K (clusters) = " + K); + + // ------------------------------------------------------------ + // Build PQ + // ------------------------------------------------------------ + RandomAccessVectorValues ravv = + new ListRandomAccessVectorValues(vectors, dimension); + + ForkJoinPool simdExecutor = PhysicalCoreExecutor.pool(); + + ProductQuantization pq = ProductQuantization.compute( + ravv, + M, + K, + globallyCenter, + anisotropicThreshold, + simdExecutor, + ForkJoinPool.commonPool() + ); + + PQVectors pqVecs = pq.encodeAll(ravv, simdExecutor); + + // ------------------------------------------------------------ + // Count centroid assignments + // ------------------------------------------------------------ + long[][] counts = new long[M][K]; + + for (int i = 0; i < pqVecs.count(); i++) { + var code = pqVecs.get(i); + for (int m = 0; m < M; m++) { + int c = Byte.toUnsignedInt(code.get(m)); + counts[m][c]++; + } + } + + // ------------------------------------------------------------ + // Compute entropy per subspace + // ------------------------------------------------------------ + double totalEntropy = 0.0; + + for (int m = 0; m < M; m++) { + long total = 0; + for (int k = 0; k < K; k++) { + total += counts[m][k]; + } + + double entropy = 0.0; + for (int k = 0; k < K; k++) { + long cnt = counts[m][k]; + if (cnt == 0) continue; + double p = (double) cnt / total; + entropy -= p * (Math.log(p) / Math.log(2)); + } + + totalEntropy += entropy; + } + + double avgEntropy = totalEntropy / M; + double effectiveCentroids = Math.pow(2.0, avgEntropy); + + // ------------------------------------------------------------ + // Report + // ------------------------------------------------------------ + System.out.printf("Subspaces (M): %d%n", M); + System.out.printf("Centroids per subspace (K): %d%n", K); + System.out.printf("Average entropy per subspace: %.4f bits%n", avgEntropy); + System.out.printf("Effective centroids used: %.1f%n", effectiveCentroids); + } + + // ------------------------------------------------------------------ + // Dataset runners (aligned with DistancesPQ / DistancesASH) + // ------------------------------------------------------------------ + + public static void runCohere100k() throws IOException { + analyze( + "Cohere-100k", + "./fvec/cohere-100k/cohere_embed-english-v3.0_1024_base_vectors_100000.fvec", + 256 + ); + } + + public static void runAda002() throws IOException { + analyze( + "ada_002", + "./fvec/ada-002/ada_002_100000_base_vectors.fvec", + 384 + ); + } + + public static void runOpenAI1536() throws IOException { + analyze( + "text-embedding-3-large-1536", + "./fvec/openai-v3-large-1536-100k/text-embedding-3-large_1536_100000_base_vectors.fvec", + 384 + ); + } + + public static void runOpenAI3072() throws IOException { + analyze( + "text-embedding-3-large-3072", + "./fvec/openai-v3-large-3072-100k/text-embedding-3-large_3072_100000_base_vectors.fvec", + 768 + ); + } + + public static void main(String[] args) throws IOException { + runCohere100k(); + runAda002(); + runOpenAI1536(); + runOpenAI3072(); + } +} + From 22c4f53eb6b2ad11cedfbe9ce2831cce59454505 Mon Sep 17 00:00:00 2001 From: Ted Willke Date: Thu, 1 Jan 2026 02:32:00 +0000 Subject: [PATCH 011/243] We now amortize the cost of horizontal reductions during ASH scoring. --- .../jbellis/jvector/example/DistancesASH.java | 20 +++++++++---------- .../vector/PanamaVectorUtilSupport.java | 20 +++++++++++++++---- 2 files changed, 26 insertions(+), 14 deletions(-) diff --git a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/DistancesASH.java b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/DistancesASH.java index 2d6797bab..bf5438810 100644 --- a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/DistancesASH.java +++ b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/DistancesASH.java @@ -55,7 +55,7 @@ public static void testASHEncodings(String filenameBase, String filenameQueries) Boolean.parseBoolean(System.getProperty("jvector.bench.sanity-check", "false")); final boolean RUN_ACCURACY_CHECK = - Boolean.parseBoolean(System.getProperty("jvector.bench.accuracy", "false")); + Boolean.parseBoolean(System.getProperty("jvector.bench.accuracy", "true")); final boolean RUN_SCALAR_SCORING = Boolean.parseBoolean(System.getProperty("jvector.bench.scalar-scoring", "false")); @@ -64,7 +64,7 @@ public static void testASHEncodings(String filenameBase, String filenameQueries) Boolean.parseBoolean(System.getProperty("jvector.bench.float-scoring", "false")); // Block sizes to benchmark - final int[] BLOCK_SIZES = {64}; // 16, 32, and/or 64 + final int[] BLOCK_SIZES = {16, 32, 64, 128}; // 16, 32, and/or 64 List> vectors = SiftLoader.readFvecs(filenameBase); List> queries = SiftLoader.readFvecs(filenameQueries); @@ -112,7 +112,7 @@ public static void testASHEncodings(String filenameBase, String filenameQueries) ", encodedBits = " + encodedBits ); - int nQueries = Math.min(10000, queries.size()); + int nQueries = Math.min(1000, queries.size()); int nVectors = Math.min(1_000_000, vectors.size()); vectors = vectors.subList(0, nVectors); @@ -170,8 +170,8 @@ public static void testASHEncodings(String filenameBase, String filenameQueries) // ------------------------------------------------------------------ // Shared parallelization setup (executor-honoring) // ------------------------------------------------------------------ - // ForkJoinPool simdExecutor = PhysicalCoreExecutor.pool(); // For production - ForkJoinPool simdExecutor = new ForkJoinPool(4); // For profiling + ForkJoinPool simdExecutor = PhysicalCoreExecutor.pool(); // For production + // ForkJoinPool simdExecutor = new ForkJoinPool(4); // For profiling int parallelism = simdExecutor.getParallelism(); int chunkSize = Math.max(1, (nQueries + parallelism - 1) / parallelism); @@ -462,11 +462,11 @@ public static void main(String[] args) throws IOException { // runSIFT(); // runGIST(); // runColbert(); -// runCohere100k(); -// runADA(); -// runOpenai1536(); -// runOpenai3072(); + runCohere100k(); + runADA(); + runOpenai1536(); + runOpenai3072(); // runCap6m(); - runCohere10m(); +// runCohere10m(); } } \ No newline at end of file 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 fbd9483eb..33bab6aea 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 @@ -1591,11 +1591,20 @@ public void ashMaskedAddBlockAllWords(float[] tildeQ, // Like in the ASH paper, SIMD lanes are dimensions (SPECIES_PREFERRED), // masked load from tildeQ and horizontal sum per neighbor. + // + // Optimization: amortize horizontal reductions. + // Instead of reducing each masked chunk immediately, we accumulate masked + // vectors into a vector register accumulator and reduce once per lane. final VectorSpecies SPEC = FloatVector.SPECIES_PREFERRED; final int LANES = SPEC.length(); for (int lane = 0; lane < blockLen; lane++) { - float maskedAdd = 0f; // register scalar accumulator + // Vector accumulator: stays in registers (no array accumulation). + FloatVector accVec = FloatVector.zero(SPEC); + + // Scalar accumulator for unusual widths fallback only. + float scalarAcc = 0f; + int laneIndex = laneStart + lane; for (int w = 0; w < words; w++) { @@ -1626,7 +1635,7 @@ public void ashMaskedAddBlockAllWords(float[] tildeQ, if (idx < d) s += tildeQ[idx]; } } - maskedAdd += s; + scalarAcc += s; continue; } @@ -1638,11 +1647,14 @@ public void ashMaskedAddBlockAllWords(float[] tildeQ, // masked load (paper) FloatVector masked = FloatVector.fromArray(SPEC, tildeQ, qBase, bitMask); - // horizontal sum (paper) - maskedAdd += masked.reduceLanes(VectorOperators.ADD); + // amortized accumulation (register) + accVec = accVec.add(masked); } } + // One horizontal sum per lane (instead of per chunk) + float maskedAdd = accVec.reduceLanes(VectorOperators.ADD) + scalarAcc; + outMaskedAdd[lane] = maskedAdd; } } From 2d8c6fd20e161e7d01a38741130c6ab6e4bf6456 Mon Sep 17 00:00:00 2001 From: Ted Willke Date: Thu, 1 Jan 2026 15:21:48 -0800 Subject: [PATCH 012/243] Added overload to SiftLoader to allow for truncation of the load (without loading all vectors first). Updated DistancesASH and DistancesPQ to take advantage of this. The original SiftLoader remains intact. --- .../jbellis/jvector/example/DistancesASH.java | 74 ++++---- .../jbellis/jvector/example/DistancesPQ.java | 61 ++++--- .../jvector/example/util/SiftLoader.java | 161 ++++++++++++++++-- 3 files changed, 223 insertions(+), 73 deletions(-) diff --git a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/DistancesASH.java b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/DistancesASH.java index bf5438810..ac8fe886a 100644 --- a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/DistancesASH.java +++ b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/DistancesASH.java @@ -32,13 +32,10 @@ import java.io.IOException; import java.util.List; -import java.util.stream.Collectors; import java.util.ArrayList; import java.util.concurrent.ForkJoinPool; import java.util.concurrent.ForkJoinTask; -import static java.lang.Math.abs; - public class DistancesASH { private static void printScorerInfo(String label, ASHBlockScorer scorer) { System.out.println( @@ -64,10 +61,21 @@ public static void testASHEncodings(String filenameBase, String filenameQueries) Boolean.parseBoolean(System.getProperty("jvector.bench.float-scoring", "false")); // Block sizes to benchmark - final int[] BLOCK_SIZES = {16, 32, 64, 128}; // 16, 32, and/or 64 + final int[] BLOCK_SIZES = {32}; // 16, 32, and/or 64 + + // Define the benchmark size + int maxQueries = 10_000; + int maxVectors = 3_000_000; + + int queryCountInFile = SiftLoader.countFvecs(filenameQueries); + int vectorCountInFile = SiftLoader.countFvecs(filenameBase); - List> vectors = SiftLoader.readFvecs(filenameBase); - List> queries = SiftLoader.readFvecs(filenameQueries); + // Will benchmark what was requested or the maximum available vectors (if less) + int nQueries = Math.min(maxQueries, queryCountInFile); + int nVectors = Math.min(maxVectors, vectorCountInFile); + + List> vectors = SiftLoader.readFvecs(filenameBase, nVectors); + List> queries = SiftLoader.readFvecs(filenameQueries, nQueries); // ------------------------------------------------------------------ // Remove zero-norm vectors (undefined for ASH / Eq. 11) @@ -93,6 +101,12 @@ public static void testASHEncodings(String filenameBase, String filenameQueries) System.out.println("\tRemoved " + (beforeBase - vectors.size()) + " zero base vectors and " + (beforeQuery - queries.size()) + " zero query vectors"); + // ------------------------------------------------------------ + // Update counts after filtering + // ------------------------------------------------------------ + final int finalVectorCount = vectors.size(); + final int finalQueryCount = queries.size(); + // ASH normalization policy: // // - Base vectors x are NOT globally normalized. @@ -112,12 +126,6 @@ public static void testASHEncodings(String filenameBase, String filenameQueries) ", encodedBits = " + encodedBits ); - int nQueries = Math.min(1000, queries.size()); - int nVectors = Math.min(1_000_000, vectors.size()); - - vectors = vectors.subList(0, nVectors); - queries = queries.subList(0, nQueries); - final List> finalQueries = queries; final List> finalVectors = vectors; @@ -174,7 +182,7 @@ public static void testASHEncodings(String filenameBase, String filenameQueries) // ForkJoinPool simdExecutor = new ForkJoinPool(4); // For profiling int parallelism = simdExecutor.getParallelism(); - int chunkSize = Math.max(1, (nQueries + parallelism - 1) / parallelism); + int chunkSize = Math.max(1, (finalQueryCount + parallelism - 1) / parallelism); // ================================================================== // [1] Accuracy run (NOT timed) @@ -182,9 +190,9 @@ public static void testASHEncodings(String filenameBase, String filenameQueries) if(RUN_ACCURACY_CHECK) { List> errorTasks = new ArrayList<>(); - for (int start = 0; start < nQueries; start += chunkSize) { + for (int start = 0; start < finalQueryCount; start += chunkSize) { final int s = start; - final int e = Math.min(start + chunkSize, nQueries); + final int e = Math.min(start + chunkSize, finalQueryCount); errorTasks.add(simdExecutor.submit(() -> { double localError = 0.0; @@ -195,7 +203,7 @@ public static void testASHEncodings(String filenameBase, String filenameQueries) ScoreFunction.ApproximateScoreFunction f = ashVecs.scoreFunctionFor(q, VectorSimilarityFunction.DOT_PRODUCT); - for (int j = 0; j < nVectors; j++) { + for (int j = 0; j < finalVectorCount; j++) { VectorFloat v = finalVectors.get(j); float trueDot = VectorUtil.dotProduct(q, v); float approxDot = f.similarityTo(j); @@ -229,9 +237,9 @@ public static void testASHEncodings(String filenameBase, String filenameQueries) long ashStart = System.nanoTime(); - for (int start = 0; start < nQueries; start += chunkSize) { + for (int start = 0; start < finalQueryCount; start += chunkSize) { final int s = start; - final int e = Math.min(start + chunkSize, nQueries); + final int e = Math.min(start + chunkSize, finalQueryCount); ashTasks.add(simdExecutor.submit(() -> { double localSum = 0.0; @@ -241,7 +249,7 @@ public static void testASHEncodings(String filenameBase, String filenameQueries) ScoreFunction.ApproximateScoreFunction f = ashVecs.scoreFunctionFor(q, VectorSimilarityFunction.DOT_PRODUCT); - for (int j = 0; j < nVectors; j++) { + for (int j = 0; j < finalVectorCount; j++) { localSum += f.similarityTo(j); } } @@ -270,16 +278,16 @@ public static void testASHEncodings(String filenameBase, String filenameQueries) long floatStart = System.nanoTime(); - for (int start = 0; start < nQueries; start += chunkSize) { + for (int start = 0; start < finalQueryCount; start += chunkSize) { final int s = start; - final int e = Math.min(start + chunkSize, nQueries); + final int e = Math.min(start + chunkSize, finalQueryCount); floatTasks.add(simdExecutor.submit(() -> { double localSum = 0.0; for (int i = s; i < e; i++) { VectorFloat q = finalQueries.get(i); - for (int j = 0; j < nVectors; j++) { + for (int j = 0; j < finalVectorCount; j++) { localSum += VectorUtil.dotProduct(q, finalVectors.get(j)); } } @@ -327,9 +335,9 @@ public static void testASHEncodings(String filenameBase, String filenameQueries) List> blockTasks = new ArrayList<>(); long blockStart = System.nanoTime(); - for (int start = 0; start < nQueries; start += chunkSize) { + for (int start = 0; start < finalQueryCount; start += chunkSize) { final int s = start; - final int e = Math.min(start + chunkSize, nQueries); + final int e = Math.min(start + chunkSize, finalQueryCount); blockTasks.add(simdExecutor.submit(() -> { double localSum = 0.0; @@ -344,7 +352,7 @@ public static void testASHEncodings(String filenameBase, String filenameQueries) ); int j = 0; - while (j + blockSize <= nVectors) { + while (j + blockSize <= finalVectorCount) { scorer.scoreRange(j, blockSize, scores); for (int k = 0; k < blockSize; k++) { localSum += scores[k]; @@ -353,13 +361,13 @@ public static void testASHEncodings(String filenameBase, String filenameQueries) } // Tail uses scalar per-vector scorer (correctness-preserving) - if (j < nVectors) { + if (j < finalVectorCount) { ScoreFunction.ApproximateScoreFunction f = ashVecs.scoreFunctionFor( finalQueries.get(qi), VectorSimilarityFunction.DOT_PRODUCT ); - for (; j < nVectors; j++) { + for (; j < finalVectorCount; j++) { localSum += f.similarityTo(j); } } @@ -462,11 +470,11 @@ public static void main(String[] args) throws IOException { // runSIFT(); // runGIST(); // runColbert(); - runCohere100k(); - runADA(); - runOpenai1536(); - runOpenai3072(); +// runCohere100k(); +// runADA(); +// runOpenai1536(); +// runOpenai3072(); // runCap6m(); -// runCohere10m(); + runCohere10m(); } -} \ No newline at end of file +} diff --git a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/DistancesPQ.java b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/DistancesPQ.java index 570e663f4..a11f3c156 100644 --- a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/DistancesPQ.java +++ b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/DistancesPQ.java @@ -56,8 +56,19 @@ public static void testPQEncodings(String filenameBase, String filenameQueries) final boolean RUN_FLOAT_SCORING = Boolean.parseBoolean(System.getProperty("jvector.bench.float-scoring", "false")); - List> vectors = SiftLoader.readFvecs(filenameBase); - List> queries = SiftLoader.readFvecs(filenameQueries); + // Define the benchmark size + int maxQueries = 10_000; + int maxVectors = 2_000_000; + + int queryCountInFile = SiftLoader.countFvecs(filenameQueries); + int vectorCountInFile = SiftLoader.countFvecs(filenameBase); + + // Will benchmark what was requested or the maximum available vectors (if less) + int nQueries = Math.min(maxQueries, queryCountInFile); + int nVectors = Math.min(maxVectors, vectorCountInFile); + + List> vectors = SiftLoader.readFvecs(filenameBase, nVectors); + List> queries = SiftLoader.readFvecs(filenameQueries, nQueries); // ------------------------------------------------------------ // Remove zero-norm vectors (match DistancesASH) @@ -83,6 +94,12 @@ public static void testPQEncodings(String filenameBase, String filenameQueries) System.out.println("\tRemoved " + (beforeBase - vectors.size()) + " zero base vectors and " + (beforeQuery - queries.size()) + " zero query vectors"); + // ------------------------------------------------------------ + // Update counts after filtering + // ------------------------------------------------------------ + final int finalVectorCount = vectors.size(); + final int finalQueryCount = queries.size(); + // Match ASH: normalize queries only for (VectorFloat q : queries) { VectorUtil.l2normalize(q); @@ -90,24 +107,18 @@ public static void testPQEncodings(String filenameBase, String filenameQueries) int dimension = vectors.get(0).length(); - int nQueries = Math.min(10000, queries.size()); - int nVectors = Math.min(10_000_000, vectors.size()); - - vectors = vectors.subList(0, nVectors); - queries = queries.subList(0, nQueries); - final List> finalVectors = vectors; final List> finalQueries = queries; System.out.format("\t%d base and %d query vectors loaded, dimension=%d%n", - nVectors, nQueries, dimension); + vectors.size(), queries.size(), dimension); // ------------------------------------------------------------ // Parallel executor (identical to DistancesASH) // ------------------------------------------------------------ ForkJoinPool simdExecutor = PhysicalCoreExecutor.pool(); int parallelism = simdExecutor.getParallelism(); - int chunkSize = Math.max(1, (nQueries + parallelism - 1) / parallelism); + int chunkSize = Math.max(1, (finalQueryCount + parallelism - 1) / parallelism); // ------------------------------------------------------------ // PQ parameters (compression matched to ASH) @@ -160,9 +171,9 @@ public static void testPQEncodings(String filenameBase, String filenameQueries) if (RUN_ACCURACY_CHECK) { List> errorTasks = new ArrayList<>(); - for (int start = 0; start < nQueries; start += chunkSize) { + for (int start = 0; start < finalQueryCount; start += chunkSize) { final int s = start; - final int e = Math.min(start + chunkSize, nQueries); + final int e = Math.min(start + chunkSize, finalQueryCount); errorTasks.add(simdExecutor.submit(() -> { double localError = 0.0; @@ -173,7 +184,7 @@ public static void testPQEncodings(String filenameBase, String filenameQueries) ScoreFunction.ApproximateScoreFunction f = pqVecs.precomputedScoreFunctionFor(q, VectorSimilarityFunction.DOT_PRODUCT); - for (int j = 0; j < nVectors; j++) { + for (int j = 0; j < finalVectorCount; j++) { float trueDot = VectorUtil.dotProduct(q, finalVectors.get(j)); float approxScaled = f.similarityTo(j); float approxDot = 2.0f * approxScaled - 1.0f; @@ -203,9 +214,9 @@ public static void testPQEncodings(String filenameBase, String filenameQueries) List> pqTasks = new ArrayList<>(); long pqStart = System.nanoTime(); - for (int start = 0; start < nQueries; start += chunkSize) { + for (int start = 0; start < finalQueryCount; start += chunkSize) { final int s = start; - final int e = Math.min(start + chunkSize, nQueries); + final int e = Math.min(start + chunkSize, finalQueryCount); pqTasks.add(simdExecutor.submit(() -> { double localSum = 0.0; @@ -215,7 +226,7 @@ public static void testPQEncodings(String filenameBase, String filenameQueries) finalQueries.get(i), VectorSimilarityFunction.DOT_PRODUCT ); - for (int j = 0; j < nVectors; j++) { + for (int j = 0; j < finalVectorCount; j++) { localSum += f.similarityTo(j); } } @@ -241,15 +252,15 @@ public static void testPQEncodings(String filenameBase, String filenameQueries) List> floatTasks = new ArrayList<>(); long floatStart = System.nanoTime(); - for (int start = 0; start < nQueries; start += chunkSize) { + for (int start = 0; start < finalQueryCount; start += chunkSize) { final int s = start; - final int e = Math.min(start + chunkSize, nQueries); + final int e = Math.min(start + chunkSize, finalQueryCount); floatTasks.add(simdExecutor.submit(() -> { double localSum = 0.0; for (int i = s; i < e; i++) { VectorFloat q = finalQueries.get(i); - for (int j = 0; j < nVectors; j++) { + for (int j = 0; j < finalVectorCount; j++) { localSum += VectorUtil.dotProduct(q, finalVectors.get(j)); } } @@ -324,11 +335,11 @@ public static void runCohere10m() throws IOException { } public static void main(String[] args) throws IOException { -// runCohere100k(); -// runADA(); -// runOpenai1536(); -// runOpenai3072(); - runCap6m(); - runCohere10m(); + runCohere100k(); + runADA(); + runOpenai1536(); + runOpenai3072(); +// runCap6m(); +// runCohere10m(); } } diff --git a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/util/SiftLoader.java b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/util/SiftLoader.java index 46f144418..cccfc1cea 100644 --- a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/util/SiftLoader.java +++ b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/util/SiftLoader.java @@ -27,43 +27,137 @@ import java.io.UncheckedIOException; import java.nio.ByteBuffer; import java.nio.ByteOrder; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.channels.FileChannel; +import java.nio.file.StandardOpenOption; import java.util.ArrayList; -import java.util.HashSet; import java.util.List; public class SiftLoader { - private static final VectorTypeSupport vectorTypeSupport = VectorizationProvider.getInstance().getVectorTypeSupport(); + private static final VectorTypeSupport vectorTypeSupport = + VectorizationProvider.getInstance().getVectorTypeSupport(); + + /** + * Read all vectors from an .fvecs file. + * + *

    + * Reads the entire file and materializes all vectors into memory. + *

    + */ public static List> readFvecs(String filePath) throws IOException { var vectors = new ArrayList>(); - try (var dis = new DataInputStream(new BufferedInputStream(new FileInputStream(filePath)))) { + try (var dis = new DataInputStream( + new BufferedInputStream(new FileInputStream(filePath)))) { + while (dis.available() > 0) { - var dimension = Integer.reverseBytes(dis.readInt()); - assert dimension > 0 : dimension; - var buffer = new byte[dimension * Float.BYTES]; + int dimension = Integer.reverseBytes(dis.readInt()); + if (dimension <= 0) { + throw new IOException("Invalid vector dimension: " + dimension); + } + + byte[] buffer = new byte[dimension * Float.BYTES]; dis.readFully(buffer); - var byteBuffer = ByteBuffer.wrap(buffer).order(ByteOrder.LITTLE_ENDIAN); - var vector = new float[dimension]; - var floatBuffer = byteBuffer.asFloatBuffer(); - floatBuffer.get(vector); - vectors.add(vectorTypeSupport.createFloatVector(vector)); + ByteBuffer byteBuffer = + ByteBuffer.wrap(buffer).order(ByteOrder.LITTLE_ENDIAN); + + float[] data = new float[dimension]; + byteBuffer.asFloatBuffer().get(data); + + vectors.add(vectorTypeSupport.createFloatVector(data)); } } return vectors; } + /** + * Read at most {@code numVectors} vectors from an .fvecs file. + * + *

    + * This method stops reading once {@code numVectors} vectors + * have been loaded, preventing unnecessary heap usage. + *

    + * + *

    + * If the file contains fewer than {@code numVectors} vectors, an exception is thrown. + *

    + * + * @param filePath path to the .fvecs file + * @param numVectors number of vectors to load + * @return list of vectors of size {@code numVectors} + * + * @throws IllegalArgumentException if {@code numVectors <= 0} + * @throws IOException if the file ends before {@code numVectors} + * vectors are read + */ + public static List> readFvecs(String filePath, int numVectors) + throws IOException { + + if (numVectors <= 0) { + throw new IllegalArgumentException( + "numVectors must be > 0, got " + numVectors); + } + + var vectors = new ArrayList>( + Math.min(numVectors, 1024)); + + try (var dis = new DataInputStream( + new BufferedInputStream(new FileInputStream(filePath)))) { + + int count = 0; + + while (count < numVectors) { + if (dis.available() <= 0) { + throw new IOException( + "File ended early while reading " + filePath + + ": requested " + numVectors + + " vectors, but only read " + count); + } + + int dimension = Integer.reverseBytes(dis.readInt()); + if (dimension <= 0) { + throw new IOException("Invalid vector dimension: " + dimension); + } + + byte[] buffer = new byte[dimension * Float.BYTES]; + dis.readFully(buffer); + + ByteBuffer byteBuffer = + ByteBuffer.wrap(buffer).order(ByteOrder.LITTLE_ENDIAN); + + float[] data = new float[dimension]; + byteBuffer.asFloatBuffer().get(data); + + vectors.add(vectorTypeSupport.createFloatVector(data)); + count++; + } + } + + // Defensive sanity check + if (vectors.size() != numVectors) { + throw new IOException( + "Internal error: requested " + numVectors + + " vectors but read " + vectors.size()); + } + + return vectors; + } + + /** + * Read all ground-truth neighbors from an .ivecs file. + */ public static List> readIvecs(String filename) { var groundTruthTopK = new ArrayList>(); try (var dis = new DataInputStream(new FileInputStream(filename))) { while (dis.available() > 0) { - var numNeighbors = Integer.reverseBytes(dis.readInt()); + int numNeighbors = Integer.reverseBytes(dis.readInt()); var neighbors = new ArrayList(numNeighbors); - for (var i = 0; i < numNeighbors; i++) { - var neighbor = Integer.reverseBytes(dis.readInt()); - neighbors.add(neighbor); + for (int i = 0; i < numNeighbors; i++) { + neighbors.add(Integer.reverseBytes(dis.readInt())); } groundTruthTopK.add(neighbors); @@ -74,4 +168,41 @@ public static List> readIvecs(String filename) { return groundTruthTopK; } + + // Fast, allocation-free scan to count vectors in an .fvecs file + public static int countFvecs(String filePath) throws IOException { + Path path = Path.of(filePath); + + long fileSize = Files.size(path); + if (fileSize < Integer.BYTES) { + throw new IOException("File too small to be a valid .fvecs file: " + filePath); + } + + // Read dimension from first record + int dimension; + try (FileChannel fc = FileChannel.open(path, StandardOpenOption.READ)) { + ByteBuffer buf = ByteBuffer.allocate(4).order(ByteOrder.LITTLE_ENDIAN); + fc.read(buf); + buf.flip(); + dimension = buf.getInt(); + } + + if (dimension <= 0) { + throw new IOException("Invalid vector dimension: " + dimension); + } + + long recordBytes = Integer.BYTES + (long) dimension * Float.BYTES; + if (fileSize % recordBytes != 0) { + throw new IOException( + "File size not a multiple of record size: " + + "fileSize=" + fileSize + ", recordBytes=" + recordBytes); + } + + long count = fileSize / recordBytes; + if (count > Integer.MAX_VALUE) { + throw new IOException("Vector count exceeds Integer.MAX_VALUE: " + count); + } + + return (int) count; + } } From d8aa4f663fa726a7b7dc513f144d23363d079709 Mon Sep 17 00:00:00 2001 From: Ted Willke Date: Fri, 2 Jan 2026 16:46:29 -0800 Subject: [PATCH 013/243] Added tput reporting to DistancesASH and DistancesPQ. --- .../jbellis/jvector/example/DistancesASH.java | 39 +++++++++++++++---- .../jbellis/jvector/example/DistancesPQ.java | 27 ++++++++++++- 2 files changed, 56 insertions(+), 10 deletions(-) diff --git a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/DistancesASH.java b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/DistancesASH.java index ac8fe886a..d61d904c5 100644 --- a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/DistancesASH.java +++ b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/DistancesASH.java @@ -61,11 +61,11 @@ public static void testASHEncodings(String filenameBase, String filenameQueries) Boolean.parseBoolean(System.getProperty("jvector.bench.float-scoring", "false")); // Block sizes to benchmark - final int[] BLOCK_SIZES = {32}; // 16, 32, and/or 64 + final int[] BLOCK_SIZES = {16, 32, 64}; // 16, 32, and/or 64 // Define the benchmark size - int maxQueries = 10_000; - int maxVectors = 3_000_000; + int maxQueries = 1_000; + int maxVectors = 100_000; int queryCountInFile = SiftLoader.countFvecs(filenameQueries); int vectorCountInFile = SiftLoader.countFvecs(filenameBase); @@ -143,6 +143,15 @@ public static void testASHEncodings(String filenameBase, String filenameQueries) long endTime = System.nanoTime(); System.out.println("\tEncoding took " + (endTime - startTime) / 1e9 + " seconds"); + double encSeconds = (endTime - startTime) / 1e9; + double encThroughput = finalVectorCount / encSeconds; + + System.out.println( + "\tEncoding throughput = " + + String.format(java.util.Locale.ROOT, "%.3f", encThroughput) + + " vectors/sec" + ); + ASHVectors ashVectors = (ASHVectors) ashVecs; VectorFloat q0 = finalQueries.get(0); @@ -389,6 +398,20 @@ public static void testASHEncodings(String filenameBase, String filenameQueries) + (blockEnd - blockStart) / 1e9 + " seconds" ); + double blockSeconds = (blockEnd - blockStart) / 1e9; + long totalDotProducts = (long) finalQueryCount * (long) finalVectorCount; + + double scoreThroughput = totalDotProducts / blockSeconds; + + System.out.println( + "\tBlock " + kernelMode + " throughput (blockSize=" + blockSize + ") = " + + String.format(java.util.Locale.ROOT, "%.3f", scoreThroughput) + + " dot-products/sec" + + " (" + + String.format(java.util.Locale.ROOT, "%.3f", scoreThroughput / 1e6) + + " Mdot/s)" + ); + System.out.println("\tdummyAccumulator = " + (float) (blockDummy)); System.out.println("--"); } @@ -470,11 +493,11 @@ public static void main(String[] args) throws IOException { // runSIFT(); // runGIST(); // runColbert(); -// runCohere100k(); -// runADA(); -// runOpenai1536(); -// runOpenai3072(); + runCohere100k(); + runADA(); + runOpenai1536(); + runOpenai3072(); // runCap6m(); - runCohere10m(); +// runCohere10m(); } } diff --git a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/DistancesPQ.java b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/DistancesPQ.java index a11f3c156..6c5fe9190 100644 --- a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/DistancesPQ.java +++ b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/DistancesPQ.java @@ -57,8 +57,8 @@ public static void testPQEncodings(String filenameBase, String filenameQueries) Boolean.parseBoolean(System.getProperty("jvector.bench.float-scoring", "false")); // Define the benchmark size - int maxQueries = 10_000; - int maxVectors = 2_000_000; + int maxQueries = 1_000; + int maxVectors = 100_000; int queryCountInFile = SiftLoader.countFvecs(filenameQueries); int vectorCountInFile = SiftLoader.countFvecs(filenameBase); @@ -162,6 +162,15 @@ public static void testPQEncodings(String filenameBase, String filenameQueries) System.out.println("\tPQ training took " + (pqTrainEnd - pqTrainStart) / 1e9 + " seconds"); System.out.println("\tPQ encoding took " + (pqEncodeEnd - pqEncodeStart) / 1e9 + " seconds"); + double encSeconds = (pqEncodeEnd - pqEncodeStart) / 1e9; + double encThroughput = finalVectorCount / encSeconds; + + System.out.println( + "\tEncoding throughput = " + + String.format(java.util.Locale.ROOT, "%.3f", encThroughput) + + " vectors/sec" + ); + // ============================================================ // [1] Accuracy (NOT timed) @@ -245,6 +254,20 @@ public static void testPQEncodings(String filenameBase, String filenameQueries) long pqEnd = System.nanoTime(); System.out.println("\tPQDecoder scan took " + (pqEnd - pqStart) / 1e9 + " seconds"); + double blockSeconds = (pqEnd - pqStart) / 1e9; + long totalDotProducts = (long) finalQueryCount * (long) finalVectorCount; + + double scoreThroughput = totalDotProducts / blockSeconds; + + System.out.println( + "\tDecoder scan throughput = " + + String.format(java.util.Locale.ROOT, "%.3f", scoreThroughput) + + " dot-products/sec" + + " (" + + String.format(java.util.Locale.ROOT, "%.3f", scoreThroughput / 1e6) + + " Mdot/s)" + ); + // ============================================================ // [3] Float dot-product baseline // ============================================================ From 3e5b4e39f31d83a263b33a77184d65c848016ca7 Mon Sep 17 00:00:00 2001 From: Ted Willke Date: Fri, 2 Jan 2026 16:47:49 -0800 Subject: [PATCH 014/243] First phase of ASH multi-landmark support. --- .../jvector/quantization/ASHScorer.java | 3 +- .../jvector/quantization/ASHVectors.java | 4 +- .../quantization/AsymmetricHashing.java | 117 ++++++++++++------ 3 files changed, 85 insertions(+), 39 deletions(-) diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHScorer.java b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHScorer.java index f3a2cfea9..fc6925eb1 100644 --- a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHScorer.java +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHScorer.java @@ -51,7 +51,7 @@ private ASHScoreFunction dotProductScoreFunctionFor(VectorFloat query) { // Compute q_centered = q - μ // (avoid allocation by doing subtraction on the fly during projection) - final VectorFloat mu = ash.globalMean; + final VectorFloat mu = ash.landmarks[0]; final float[][] A = ash.stiefelTransform.AFloat; // shape: d × originalDim final int originalDim = ash.stiefelTransform.cols; @@ -138,4 +138,3 @@ public interface ASHScoreFunction { float similarityTo(AsymmetricHashing.QuantizedVector vector2); } } - diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHVectors.java b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHVectors.java index 86277d4d2..7d92d3635 100644 --- a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHVectors.java +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHVectors.java @@ -380,7 +380,7 @@ private static final class BlockScalarASHBlockScorer implements ASHBlockScorer { this.words = AsymmetricHashing.QuantizedVector.wordsForDims(d); final int D = ash.originalDimension; - final VectorFloat mu = ash.globalMean; + final VectorFloat mu = ash.landmarks[0]; final float[] qArr = new float[D]; final float[] muArr = new float[D]; @@ -513,7 +513,7 @@ private static final class BlockSimdASHBlockScorer implements ASHBlockScorer { // ---- Query precompute (identical to ASHScorer) ---- final int D = ash.originalDimension; - final VectorFloat mu = ash.globalMean; + final VectorFloat mu = ash.landmarks[0]; final float[] qArr = new float[D]; final float[] muArr = new float[D]; diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/AsymmetricHashing.java b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/AsymmetricHashing.java index 9f5ca260a..e43d0f82d 100644 --- a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/AsymmetricHashing.java +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/AsymmetricHashing.java @@ -34,8 +34,6 @@ import java.util.Objects; import java.util.concurrent.ForkJoinPool; import java.util.Random; -import java.util.stream.IntStream; -import org.apache.commons.math3.linear.Array2DRowRealMatrix; import org.apache.commons.math3.linear.MatrixUtils; import org.apache.commons.math3.linear.QRDecomposition; import org.apache.commons.math3.linear.RealMatrix; @@ -65,8 +63,11 @@ public class AsymmetricHashing implements VectorCompressor globalMean; + /** Number of landmarks (clusters), C<= 64 */ + public final int landmarkCount; + + /** Landmark centroids μ_0... μ_{C−1} */ + public final VectorFloat[] landmarks; /** Original (uncompressed) dimensionality */ public final int originalDimension; @@ -111,25 +112,35 @@ private BinaryQuantizer quantizer() { /** * Class constructor. * @param originalDim the dimensionality of the vectors to be quantized - * @param globalMean the mean of the database (its average vector) + * @param landmarks the landmarks using for vectors (up to C in landmarkCount) */ private AsymmetricHashing(int originalDim, int encodedBits, int quantizedDim, int optimizer, - VectorFloat globalMean, + VectorFloat[] landmarks, StiefelTransform stiefelTransform) { this.originalDimension = originalDim; this.encodedBits = encodedBits; this.quantizedDim = quantizedDim; this.optimizer = optimizer; - this.stiefelTransform = Objects.requireNonNull(stiefelTransform, "stiefelTransform"); - this.globalMean = Objects.requireNonNull(globalMean, "globalMean"); + this.stiefelTransform = Objects.requireNonNull(stiefelTransform); + + this.landmarks = Objects.requireNonNull(landmarks); + this.landmarkCount = landmarks.length; - if (globalMean.length() != originalDimension) { + if (landmarkCount < 1 || landmarkCount > 64) { throw new IllegalArgumentException( - "Global mean length does not match original dimension"); + "landmarkCount must be in [1,64], got " + landmarkCount); + } + + for (VectorFloat mu : landmarks) { + if (mu.length() != originalDimension) { + throw new IllegalArgumentException( + "Landmark dimensionality does not match original dimensionality"); + } } + if (quantizedDim < 1) { throw new IllegalArgumentException("quantizedDim must be > 0"); } @@ -159,12 +170,14 @@ public static AsymmetricHashing initialize(RandomAccessVectorValues ravv, var ravvCopy = ravv.threadLocalSupplier().get(); int originalDim = ravvCopy.getVector(0).length(); - // Compute global mean - var globalMean = vectorTypeSupport.createFloatVector(originalDim); + // C=1 landmarks case + VectorFloat mu0 = vectorTypeSupport.createFloatVector(originalDim); for (int i = 0; i < ravvCopy.size(); i++) { - VectorUtil.addInPlace(globalMean, ravvCopy.getVector(i)); + VectorUtil.addInPlace(mu0, ravvCopy.getVector(i)); } - VectorUtil.scale(globalMean, 1.0f / ravvCopy.size()); + VectorUtil.scale(mu0, 1.0f / ravvCopy.size()); + + VectorFloat[] landmarks = new VectorFloat[] { mu0 }; StiefelTransform stiefelTransform; if (optimizer == RANDOM) { @@ -182,7 +195,7 @@ public static AsymmetricHashing initialize(RandomAccessVectorValues ravv, encodedBits, quantizedDim, optimizer, - globalMean, + landmarks, stiefelTransform ); } @@ -210,11 +223,12 @@ public static AsymmetricHashing load(RandomAccessReader in) throws IOException { int quantizedDim = in.readInt(); int optimizer = in.readInt(); - int globalMeanLength = in.readInt(); - if (globalMeanLength <= 0) { - throw new IOException("ASH header missing globalMean"); + int landmark0Length = in.readInt(); + if (landmark0Length <= 0) { + throw new IOException("ASH header missing landmark[0]"); } - VectorFloat globalMean = vectorTypeSupport.readFloatVector(in, globalMeanLength); + VectorFloat mu0 = vectorTypeSupport.readFloatVector(in, landmark0Length); + VectorFloat[] landmarks = new VectorFloat[] { mu0 }; // NOTE: StiefelTransform loading will be added in Step 2. // For now, construct a placeholder or re-run the RANDOM initializer. @@ -238,7 +252,7 @@ public static AsymmetricHashing load(RandomAccessReader in) throws IOException { encodedBits, quantizedDim, optimizer, - globalMean, + landmarks, stiefelTransform ); } @@ -264,8 +278,9 @@ public void write(DataOutput out, int version) throws IOException { out.writeInt(quantizedDim); out.writeInt(optimizer); - out.writeInt(globalMean.length()); - vectorTypeSupport.writeFloatVector(out, globalMean); + // Step 1: write a single landmark (mu0) in the same slot as legacy globalMean + out.writeInt(landmarks[0].length()); + vectorTypeSupport.writeFloatVector(out, landmarks[0]); // NOTE: stiefelTransform serialization will be added later } @@ -286,13 +301,20 @@ public void quantizeVector(VectorFloat vector, ") exceeds original dimension (" + originalDimension + ")"); } + if ((landmark & 0xFFFF) >= landmarkCount) { + throw new IllegalArgumentException("Invalid landmark id " + (landmark & 0xFFFF) + + " for landmarkCount=" + landmarkCount); + } + + final VectorFloat mu = landmarks[landmark]; // Step 1 landmark==0 always, but keep correct + QuantizedVector.quantizeTo( vector, residualNorm, dotWithLandmark, landmark, quantizedDim, - globalMean, + mu, stiefelTransform, quantizer(), dest @@ -806,14 +828,17 @@ public void encodeTo(VectorFloat vector, QuantizedVector dest) { // All vectors are encoded relative to the global mean μ*. // The landmark id is always 0 in this mode. - final short landmark = 0; + // Landmark assignment (C=1 → always 0) + final short landmark = 0; // placeholder; Step 2 will generalize + + final VectorFloat mu = landmarks[landmark]; - // Table 2 header field: - final float dotWithLandmark = VectorUtil.dotProduct(vector, globalMean); + // ASH paper, Table 2 header field: + final float dotWithLandmark = VectorUtil.dotProduct(vector, mu); // Table 2 header field: ||x_i − μ_i*||₂ // We store the true L2 norm (not squared), matching the paper. - final float sqDist = VectorUtil.squareL2Distance(vector, globalMean); + final float sqDist = VectorUtil.squareL2Distance(vector, mu); final float residualNorm = (float) Math.sqrt(sqDist); // Sanity: quantizedDim already accounts for physical header bits (Step 1) @@ -834,8 +859,8 @@ public int compressorSize() { size += Integer.BYTES; // encodedBits size += Integer.BYTES; // quantizedDim size += Integer.BYTES; // optimizer - size += Integer.BYTES; // globalMean length - size += Float.BYTES * globalMean.length(); + size += Integer.BYTES; // landmarks[0] length + size += Float.BYTES * landmarks[0].length(); return size; } @@ -857,8 +882,10 @@ public double reconstructionError(VectorFloat vector) { public long ramBytesUsed() { long bytes = 0L; - if (globalMean != null) { - bytes += globalMean.ramBytesUsed(); + if (landmarks != null) { + for (int i = 0; i < landmarks.length; i++) { + bytes += (landmarks[i] == null) ? 0L : landmarks[i].ramBytesUsed(); + } } if (stiefelTransform != null) { @@ -1001,13 +1028,23 @@ public boolean equals(Object o) { && encodedBits == that.encodedBits && quantizedDim == that.quantizedDim && optimizer == that.optimizer - && vectorEquals(this.globalMean, that.globalMean) + && landmarksEqual(this.landmarks, that.landmarks) && matrixEquals( this.stiefelTransform == null ? null : this.stiefelTransform.W, that.stiefelTransform == null ? null : that.stiefelTransform.W ); } + private static boolean landmarksEqual(VectorFloat[] a, VectorFloat[] b) { + if (a == b) return true; + if (a == null || b == null) return false; + if (a.length != b.length) return false; + for (int i = 0; i < a.length; i++) { + if (!vectorEquals(a[i], b[i])) return false; + } + return true; + } + /** * Hash code consistent with {@link #equals(Object)}. * @@ -1023,13 +1060,22 @@ public int hashCode() { result = 31 * result + Integer.hashCode(encodedBits); result = 31 * result + Integer.hashCode(quantizedDim); result = 31 * result + Integer.hashCode(optimizer); - result = 31 * result + vectorHash(globalMean); + result = 31 * result + landmarksHash(landmarks); result = 31 * result + matrixHash( stiefelTransform == null ? null : stiefelTransform.W ); return result; } + private static int landmarksHash(VectorFloat[] a) { + if (a == null) return 0; + int h = 1; + for (VectorFloat v : a) { + h = 31 * h + vectorHash(v); + } + return h; + } + @Override public String toString() { String optimizerName; @@ -1048,11 +1094,12 @@ public String toString() { } return String.format( - "AsymmetricHashing[origDim=%d, encodedBits=%d, quantizedDim=%d, optimizer=%s, landmarks=1]", + "AsymmetricHashing[origDim=%d, encodedBits=%d, quantizedDim=%d, optimizer=%s, landmarks=%d]", originalDimension, encodedBits, quantizedDim, - optimizerName + optimizerName, + landmarkCount ); } } From 3869e3644a1c988f31ba24578022b71170e7231b Mon Sep 17 00:00:00 2001 From: Ted Willke Date: Sat, 3 Jan 2026 06:26:18 +0000 Subject: [PATCH 015/243] More multi-landmark refactoring and moved the full scale and offset into the vector header. --- .../jvector/quantization/ASHScorer.java | 15 +- .../jvector/quantization/ASHVectors.java | 66 ++++---- .../quantization/AsymmetricHashing.java | 155 +++++++++++------- 3 files changed, 133 insertions(+), 103 deletions(-) diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHScorer.java b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHScorer.java index fc6925eb1..2bd37c4e1 100644 --- a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHScorer.java +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHScorer.java @@ -88,19 +88,10 @@ private ASHScoreFunction dotProductScoreFunctionFor(VectorFloat query) { // final float dotQMu = VectorUtil.dotProduct(query, mu); - // ||μ||^2 - final float muNormSq = VectorUtil.dotProduct(mu, mu); - - // Precompute invSqrtD once - final float invSqrtD = (float) (1.0 / Math.sqrt(d)); - return (AsymmetricHashing.QuantizedVector v) -> { // Single landmark baseline: ignore v.landmark for now (assumed 0) - final float residualNorm = v.residualNorm; // ||x - μ||₂ - final float dotXMu = v.dotWithLandmark; // - - // scale = d^{-1/2} * ||x - μ|| - final float scale = invSqrtD * residualNorm; + final float scale = v.scale; // already ||x − μ|| / sqrt(d) + final float offset = v.offset; // already − ||μ||^2 // masked-add term: where bin ∈ {0,1}^d // This is sum of tildeQ[j] over set bits in v.binaryVector. @@ -128,7 +119,7 @@ private ASHScoreFunction dotProductScoreFunctionFor(VectorFloat query) { // - scale = ||x − μ|| / √d (pure Eq. 11 baseline; no bias correction). // - ⟨q, μ⟩ is a query-only offset and MUST NOT be scaled. // - Scaling ⟨q, μ⟩ leads to large systematic error (see paper Eq. 11 regrouping). - return scale * (2f * maskedAdd - sumTildeQ) + dotQMu + (dotXMu - muNormSq); + return scale * (2f * maskedAdd - sumTildeQ) + dotQMu + offset; }; } diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHVectors.java b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHVectors.java index 7d92d3635..b4c1e6bcd 100644 --- a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHVectors.java +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHVectors.java @@ -71,8 +71,8 @@ static AshBlockKernel fromProperty() { } // Cached scalar headers (avoid object chasing in blocked scorer) - private final float[] residualNorms; - private final float[] dotXMu; + private final float[] scales; // scale_i = ||x_i − μ|| / sqrt(d) + private final float[] offsets; // offset_i = − ||μ||^2 // Quantized dimensions private final int d; @@ -99,12 +99,12 @@ public ASHVectors(AsymmetricHashing ash, this.words = AsymmetricHashing.QuantizedVector.wordsForDims(d); int n = compressedVectors.length; - this.residualNorms = new float[n]; - this.dotXMu = new float[n]; + this.scales = new float[n]; + this.offsets = new float[n]; for (int i = 0; i < n; i++) { var v = compressedVectors[i]; - residualNorms[i] = v.residualNorm; - dotXMu[i] = v.dotWithLandmark; + scales[i] = v.scale; + offsets[i] = v.offset; } } @@ -285,8 +285,8 @@ public ASHBlockScorer blockScorerFor( return new BlockScalarASHBlockScorer( ash, compressedVectors, - residualNorms, - dotXMu, + scales, + offsets, query ); } @@ -302,8 +302,8 @@ public ASHBlockScorer blockScorerFor( return new BlockSimdASHBlockScorer( ash, compressedVectors, - residualNorms, - dotXMu, + scales, + offsets, packedBits, blockSize, query @@ -352,14 +352,13 @@ private static final class BlockScalarASHBlockScorer implements ASHBlockScorer { private final AsymmetricHashing ash; private final AsymmetricHashing.QuantizedVector[] vectors; - private final float[] residualNorms; - private final float[] dotXMu; + private final float[] scales; + private final float[] offsets; + private final float[] tildeQ; private final float sumTildeQ; private final float dotQMu; - private final float muNormSq; - private final float invSqrtD; private final int d; private final int words; @@ -367,14 +366,14 @@ private static final class BlockScalarASHBlockScorer implements ASHBlockScorer { BlockScalarASHBlockScorer( AsymmetricHashing ash, AsymmetricHashing.QuantizedVector[] vectors, - float[] residualNorms, - float[] dotXMu, + float[] scales, + float[] offsets, VectorFloat query) { this.ash = ash; this.vectors = vectors; - this.residualNorms = residualNorms; - this.dotXMu = dotXMu; + this.scales = scales; + this.offsets = offsets; this.d = ash.quantizedDim; this.words = AsymmetricHashing.QuantizedVector.wordsForDims(d); @@ -403,10 +402,7 @@ private static final class BlockScalarASHBlockScorer implements ASHBlockScorer { float sum = 0f; for (int i = 0; i < d; i++) sum += tildeQ[i]; this.sumTildeQ = sum; - this.dotQMu = VectorUtil.dotProduct(query, mu); - this.muNormSq = VectorUtil.dotProduct(mu, mu); - this.invSqrtD = (float) (1.0 / Math.sqrt(d)); } @Override @@ -430,11 +426,13 @@ public void scoreRange(int start, int count, float[] out) { } } - float scale = invSqrtD * residualNorms[idx]; + float scale = scales[idx]; + float offset = offsets[idx]; + out[i] = scale * (2f * maskedAdd - sumTildeQ) + dotQMu - + (dotXMu[idx] - muNormSq); + + offset; } } } @@ -465,8 +463,8 @@ private static final class BlockSimdASHBlockScorer implements ASHBlockScorer { private final AsymmetricHashing ash; private final AsymmetricHashing.QuantizedVector[] vectors; - private final float[] residualNorms; - private final float[] dotXMu; + private final float[] scales; + private final float[] offsets; private final long[] packedBits; private final int blockSize; @@ -478,8 +476,6 @@ private static final class BlockSimdASHBlockScorer implements ASHBlockScorer { private final float[] tildeQ; private final float sumTildeQ; private final float dotQMu; - private final float muNormSq; - private final float invSqrtD; // Scratch (one per scorer instance) private final float[] maskedAdds; @@ -489,8 +485,8 @@ private static final class BlockSimdASHBlockScorer implements ASHBlockScorer { BlockSimdASHBlockScorer( AsymmetricHashing ash, AsymmetricHashing.QuantizedVector[] vectors, - float[] residualNorms, - float[] dotXMu, + float[] scales, + float[] offsets, long[] packedBits, int blockSize, VectorFloat query) { @@ -501,8 +497,8 @@ private static final class BlockSimdASHBlockScorer implements ASHBlockScorer { .getVectorUtilSupport(); this.ash = ash; this.vectors = vectors; - this.residualNorms = residualNorms; - this.dotXMu = dotXMu; + this.scales = scales; + this.offsets = offsets; this.packedBits = packedBits; this.blockSize = blockSize; @@ -536,10 +532,7 @@ private static final class BlockSimdASHBlockScorer implements ASHBlockScorer { float sum = 0f; for (int i = 0; i < d; i++) sum += tildeQ[i]; this.sumTildeQ = sum; - this.dotQMu = VectorUtil.dotProduct(query, mu); - this.muNormSq = VectorUtil.dotProduct(mu, mu); - this.invSqrtD = (float) (1.0 / Math.sqrt(d)); } @Override @@ -573,13 +566,14 @@ public void scoreRange(int start, int count, float[] out) { // Finish Eq. 11 per vector for (int lane = 0; lane < blockLen; lane++) { int idx = ord + lane; - float scale = invSqrtD * residualNorms[idx]; + float scale = scales[idx]; + float offset = offsets[idx]; float m = maskedAdds[lane]; out[idx - start] = scale * (2f * m - sumTildeQ) + dotQMu - + (dotXMu[idx] - muNormSq); + + offset; } ord += blockLen; diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/AsymmetricHashing.java b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/AsymmetricHashing.java index e43d0f82d..4fe0383f0 100644 --- a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/AsymmetricHashing.java +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/AsymmetricHashing.java @@ -50,9 +50,12 @@ public class AsymmetricHashing implements VectorCompressor − ||μ||_2^2 + // - landmark id: short (16 bits) in [0, C) + // + // NOTE: residualNorm (= ||x − μ||) is still computed during encoding for Eq. 6, + // but it is not stored explicitly once scale is stored. private static final int HEADER_BITS = (Float.BYTES + Float.BYTES + Short.BYTES) * 8; // 80 bits currently @@ -69,6 +72,9 @@ public class AsymmetricHashing implements VectorCompressor[] landmarks; + // One entry per landmark + private final float[] landmarkNormSq; + /** Original (uncompressed) dimensionality */ public final int originalDimension; @@ -127,8 +133,20 @@ private AsymmetricHashing(int originalDim, this.stiefelTransform = Objects.requireNonNull(stiefelTransform); this.landmarks = Objects.requireNonNull(landmarks); + assert landmarks.length > 0; this.landmarkCount = landmarks.length; + // Precompute ||μ_c||^2 for each landmark (even when C = 1) + this.landmarkNormSq = new float[landmarkCount]; + for (int c = 0; c < landmarkCount; c++) { + this.landmarkNormSq[c] = + VectorUtil.dotProduct(landmarks[c], landmarks[c]); + } + + if (landmarkNormSq.length != landmarkCount) { + throw new IllegalArgumentException("landmarkNormSq length mismatch"); + } + if (landmarkCount < 1 || landmarkCount > 64) { throw new IllegalArgumentException( "landmarkCount must be in [1,64], got " + landmarkCount); @@ -278,7 +296,7 @@ public void write(DataOutput out, int version) throws IOException { out.writeInt(quantizedDim); out.writeInt(optimizer); - // Step 1: write a single landmark (mu0) in the same slot as legacy globalMean + // (C=1): write landmark[0] (dataset mean). Multi-landmark serialization TBD. out.writeInt(landmarks[0].length()); vectorTypeSupport.writeFloatVector(out, landmarks[0]); @@ -291,7 +309,6 @@ public void write(DataOutput out, int version) throws IOException { public void quantizeVector(VectorFloat vector, float residualNorm, - float dotWithLandmark, short landmark, QuantizedVector dest) { @@ -306,13 +323,12 @@ public void quantizeVector(VectorFloat vector, + " for landmarkCount=" + landmarkCount); } - final VectorFloat mu = landmarks[landmark]; // Step 1 landmark==0 always, but keep correct + // Landmark-specific mean (C=1 → landmarks[0] == dataset mean) + final VectorFloat mu = landmarks[landmark]; QuantizedVector.quantizeTo( vector, residualNorm, - dotWithLandmark, - landmark, quantizedDim, mu, stiefelTransform, @@ -327,7 +343,7 @@ public void quantizeVector(VectorFloat vector, public interface BinaryQuantizer { void quantizeBody(VectorFloat vector, - VectorFloat globalMean, + VectorFloat mu, float residualNorm, int quantizedDim, StiefelTransform stiefel, @@ -338,7 +354,7 @@ public static final class RandomBinaryQuantizer implements BinaryQuantizer { @Override public void quantizeBody(VectorFloat vector, - VectorFloat globalMean, + VectorFloat mu, float residualNorm, int quantizedDim, StiefelTransform stiefel, @@ -354,10 +370,10 @@ public void quantizeBody(VectorFloat vector, // Copy vector and mean once (no virtual calls in inner loop) final float[] x = new float[originalDim]; - final float[] mu = new float[originalDim]; + final float[] muArr = new float[originalDim]; for (int d = 0; d < originalDim; d++) { x[d] = vector.get(d); - mu[d] = globalMean.get(d); + muArr[d] = mu.get(d); } // ASH paper, Eq. 6, normalization @@ -366,7 +382,7 @@ public void quantizeBody(VectorFloat vector, // Normalized residual x̂ final float[] xhat = new float[originalDim]; for (int d = 0; d < originalDim; d++) { - xhat[d] = (x[d] - mu[d]) * invNorm; + xhat[d] = (x[d] - muArr[d]) * invNorm; } // Binarize directly from per-row projection @@ -391,7 +407,7 @@ public void quantizeBody(VectorFloat vector, public static final class ItqBinaryQuantizer implements BinaryQuantizer { @Override public void quantizeBody(VectorFloat vector, - VectorFloat globalMean, + VectorFloat mu, float residualNorm, int quantizedDim, StiefelTransform stiefel, @@ -403,7 +419,7 @@ public void quantizeBody(VectorFloat vector, public static final class LandingBinaryQuantizer implements BinaryQuantizer { @Override public void quantizeBody(VectorFloat vector, - VectorFloat globalMean, + VectorFloat mu, float residualNorm, int quantizedDim, StiefelTransform stiefel, @@ -417,17 +433,17 @@ public void quantizeBody(VectorFloat vector, // --------------------------------------------------------------------- public static class QuantizedVector { - public float residualNorm; // ||x_i - μ_i*||_2 - public float dotWithLandmark; // + public float scale; // d^{-1/2} ||x_i - μ_i*||_2 + public float offset; // offset_i = - ||μ_i*||^2 public short landmark; // c_i* public long[] binaryVector; // bin(x̂_i) - private QuantizedVector(float residualNorm, - float dotWithLandmark, + private QuantizedVector(float scale, + float offset, short landmark, long[] binaryVector) { - this.residualNorm = residualNorm; - this.dotWithLandmark = dotWithLandmark; + this.scale = scale; + this.offset = offset; this.landmark = landmark; this.binaryVector = binaryVector; } @@ -438,8 +454,11 @@ public static int wordsForDims(int quantizedDim) { public static QuantizedVector createEmpty(int quantizedDim) { return new QuantizedVector( - 0f, 0f, (short) 0, - new long[wordsForDims(quantizedDim)]); + Float.NaN, + Float.NaN, + (short) 0, + new long[wordsForDims(quantizedDim)] + ); } public static int serializedSizeBytes(int quantizedDim) { @@ -447,19 +466,31 @@ public static int serializedSizeBytes(int quantizedDim) { + wordsForDims(quantizedDim) * Long.BYTES; } + /** + * Writes only the binary body into {@code dest.binaryVector}. + * + *

    Header fields ({@code scale}, {@code offset}, {@code landmark}) must be + * set by the caller prior to calling this method.

    + * + * @param residualNorm ||x - μ||_2, required for Eq. 6 normalization during binarization + */ static void quantizeTo(VectorFloat vector, float residualNorm, - float dotWithLandmark, - short landmark, int quantizedDim, - VectorFloat globalMean, + VectorFloat mu, StiefelTransform stiefel, BinaryQuantizer quantizer, QuantizedVector dest) { - dest.residualNorm = residualNorm; - dest.dotWithLandmark = dotWithLandmark; - dest.landmark = landmark; + // encodeTo() must have initialized header + if (Float.isNaN(dest.scale) || Float.isNaN(dest.offset)) { + throw new IllegalStateException( + "QuantizedVector header not initialized before quantizeTo()" + ); + } + + assert Float.isFinite(dest.scale) : "scale is not finite"; + assert Float.isFinite(dest.offset) : "offset is not finite"; int words = wordsForDims(quantizedDim); if (dest.binaryVector.length < words) { @@ -469,7 +500,7 @@ static void quantizeTo(VectorFloat vector, Arrays.fill(dest.binaryVector, 0, words, 0L); quantizer.quantizeBody( vector, - globalMean, + mu, residualNorm, quantizedDim, stiefel, @@ -478,8 +509,8 @@ static void quantizeTo(VectorFloat vector, } public void write(DataOutput out, int quantizedDim) throws IOException { - out.writeFloat(residualNorm); - out.writeFloat(dotWithLandmark); + out.writeFloat(scale); + out.writeFloat(offset); out.writeInt(landmark); int words = wordsForDims(quantizedDim); @@ -490,8 +521,8 @@ public void write(DataOutput out, int quantizedDim) throws IOException { public static QuantizedVector load(RandomAccessReader in, int quantizedDim) throws IOException { - float residualNorm = in.readFloat(); - float dotWithLandmark = in.readFloat(); + float scale = in.readFloat(); + float offset = in.readFloat(); short landmark = (short) in.readInt(); int words = wordsForDims(quantizedDim); @@ -500,14 +531,14 @@ public static QuantizedVector load(RandomAccessReader in, body[i] = in.readLong(); } - return new QuantizedVector(residualNorm, dotWithLandmark, landmark, body); + return new QuantizedVector(scale, offset, landmark, body); } public static void loadInto(RandomAccessReader in, QuantizedVector dest, int quantizedDim) throws IOException { - dest.residualNorm = in.readFloat(); - dest.dotWithLandmark = in.readFloat(); + dest.scale = in.readFloat(); + dest.offset = in.readFloat(); dest.landmark = (short) in.readInt(); int words = wordsForDims(quantizedDim); @@ -540,8 +571,8 @@ public boolean equals(Object o) { if (o == null || getClass() != o.getClass()) return false; QuantizedVector that = (QuantizedVector) o; - return Float.floatToIntBits(residualNorm) == Float.floatToIntBits(that.residualNorm) - && Float.floatToIntBits(dotWithLandmark) == Float.floatToIntBits(that.dotWithLandmark) + return Float.floatToIntBits(scale) == Float.floatToIntBits(that.scale) + && Float.floatToIntBits(offset) == Float.floatToIntBits(that.offset) && landmark == that.landmark && Arrays.equals(binaryVector, that.binaryVector); } @@ -556,8 +587,8 @@ public boolean equals(Object o) { */ @Override public int hashCode() { - int result = Integer.hashCode(Float.floatToIntBits(residualNorm)); - result = 31 * result + Integer.hashCode(Float.floatToIntBits(dotWithLandmark)); + int result = Integer.hashCode(Float.floatToIntBits(scale)); + result = 31 * result + Integer.hashCode(Float.floatToIntBits(offset)); result = 31 * result + Short.hashCode(landmark); result = 31 * result + Arrays.hashCode(binaryVector); return result; @@ -825,28 +856,42 @@ public QuantizedVector encode(VectorFloat vector) { public void encodeTo(VectorFloat vector, QuantizedVector dest) { // NOTE: // Single-landmark baseline configuration (Table 2, C = 1). - // All vectors are encoded relative to the global mean μ*. + // All vectors are encoded relative to landmark μ_c (C=1 => μ_0 is the dataset mean). // The landmark id is always 0 in this mode. - // Landmark assignment (C=1 → always 0) - final short landmark = 0; // placeholder; Step 2 will generalize + // Landmark assignment (C=1 --> always 0) + final short landmark = 0; // placeholder; will generalize final VectorFloat mu = landmarks[landmark]; - // ASH paper, Table 2 header field: - final float dotWithLandmark = VectorUtil.dotProduct(vector, mu); + // Compute + final float dotXMu = VectorUtil.dotProduct(vector, mu); - // Table 2 header field: ||x_i − μ_i*||₂ - // We store the true L2 norm (not squared), matching the paper. + // Compute ||x_i − μ_i*||_2 + // We store the true L2 norm (not squared), matching the paper’s use in Eq. 6 normalization. final float sqDist = VectorUtil.squareL2Distance(vector, mu); final float residualNorm = (float) Math.sqrt(sqDist); - // Sanity: quantizedDim already accounts for physical header bits (Step 1) + // Sanity: quantizedDim already accounts for physical header bits // Binary body uses exactly quantizedDim bits. assert quantizedDim > 0; - // Binary body: sign(A · (x − μ)) - quantizeVector(vector, residualNorm, dotWithLandmark, landmark, dest); + // Store: + // scale = ||x − μ|| / sqrt(d) + // offset = − ||μ||^2 + // NOTE: we still compute residualNorm (||x − μ||) even though we don't store it alone, + // because the binarizer needs it for Eq. 6 normalization. + final float invSqrtD = (float) (1.0 / Math.sqrt(quantizedDim)); + final float scale = residualNorm * invSqrtD; + final float offset = dotXMu - landmarkNormSq[landmark]; + + // Write the new header fields into dest (requires QuantizedVector fields renamed, see below) + dest.scale = scale; + dest.offset = offset; + dest.landmark = landmark; + + // Binary body: sign(A · (x − μ)) with Eq. 6 normalization inside quantizer + quantizeVector(vector, residualNorm, landmark, dest); } @Override @@ -867,8 +912,8 @@ public int compressorSize() { @Override public int compressedVectorSize() { int words = QuantizedVector.wordsForDims(quantizedDim); - return Float.BYTES // residualNorm - + Float.BYTES // dotWithLandmark + return Float.BYTES // scale + + Float.BYTES // offset + Integer.BYTES // landmark + words * Long.BYTES; // binary payload } @@ -1007,7 +1052,7 @@ private static int matrixHash(RealMatrix m) { *
      *
    • Matching dimensional parameters
    • *
    • Identical optimizer selection
    • - *
    • Bitwise-equal global mean vectors
    • + *
    • Bitwise-equal landmark vectors
    • *
    • Bitwise-equal Stiefel transform matrices
    • *
    * @@ -1050,7 +1095,7 @@ private static boolean landmarksEqual(VectorFloat[] a, VectorFloat[] b) { * *

    * The hash incorporates all parameters and learned state that affect encoding, - * including the contents of the global mean and Stiefel transform. This ensures + * including the contents of landmarks and Stiefel transform. This ensures * safe use of ASH compressors as map keys or cache entries. *

    */ From 249456cc23b9f6a3553a7f64dad5280b74b6c1db Mon Sep 17 00:00:00 2001 From: Ted Willke Date: Sat, 3 Jan 2026 22:08:58 +0000 Subject: [PATCH 016/243] Change landmark vector header field to byte. --- .../jbellis/jvector/disk/ByteReadable.java | 30 +++++++++++++++++ .../jvector/quantization/ASHScorer.java | 1 + .../jvector/quantization/ASHVectors.java | 8 +++-- .../quantization/AsymmetricHashing.java | 33 ++++++++++++------- 4 files changed, 58 insertions(+), 14 deletions(-) create mode 100644 jvector-base/src/main/java/io/github/jbellis/jvector/disk/ByteReadable.java diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/disk/ByteReadable.java b/jvector-base/src/main/java/io/github/jbellis/jvector/disk/ByteReadable.java new file mode 100644 index 000000000..f907dc45a --- /dev/null +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/disk/ByteReadable.java @@ -0,0 +1,30 @@ +/* + * 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.disk; + +import java.io.IOException; + +/** + * Optional extension for RandomAccessReader implementations that + * support byte-level reads. + * + * This is used by ASH only and MUST NOT be assumed by general readers. + */ +public interface ByteReadable { + byte readByte() throws IOException; +} + diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHScorer.java b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHScorer.java index 2bd37c4e1..7ba87b284 100644 --- a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHScorer.java +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHScorer.java @@ -51,6 +51,7 @@ private ASHScoreFunction dotProductScoreFunctionFor(VectorFloat query) { // Compute q_centered = q - μ // (avoid allocation by doing subtraction on the fly during projection) + // Landmark-dependent query projection (C=1 => still index 0) final VectorFloat mu = ash.landmarks[0]; final float[][] A = ash.stiefelTransform.AFloat; // shape: d × originalDim diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHVectors.java b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHVectors.java index b4c1e6bcd..4e6bc43df 100644 --- a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHVectors.java +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHVectors.java @@ -73,6 +73,7 @@ static AshBlockKernel fromProperty() { // Cached scalar headers (avoid object chasing in blocked scorer) private final float[] scales; // scale_i = ||x_i − μ|| / sqrt(d) private final float[] offsets; // offset_i = − ||μ||^2 + private final byte[] landmarks; // Quantized dimensions private final int d; @@ -101,11 +102,14 @@ public ASHVectors(AsymmetricHashing ash, int n = compressedVectors.length; this.scales = new float[n]; this.offsets = new float[n]; + this.landmarks = new byte[n]; for (int i = 0; i < n; i++) { var v = compressedVectors[i]; - scales[i] = v.scale; - offsets[i] = v.offset; + scales[i] = v.scale; + offsets[i] = v.offset; + landmarks[i] = v.landmark; } + } @Override diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/AsymmetricHashing.java b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/AsymmetricHashing.java index 4fe0383f0..5514038f7 100644 --- a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/AsymmetricHashing.java +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/AsymmetricHashing.java @@ -52,12 +52,12 @@ public class AsymmetricHashing implements VectorCompressor − ||μ||_2^2 - // - landmark id: short (16 bits) in [0, C) + // - landmark id: byte (8 bits) in [0, C) // // NOTE: residualNorm (= ||x − μ||) is still computed during encoding for Eq. 6, // but it is not stored explicitly once scale is stored. private static final int HEADER_BITS = - (Float.BYTES + Float.BYTES + Short.BYTES) * 8; // 80 bits currently + (Float.BYTES + Float.BYTES + Byte.BYTES) * 8; // 72 bits currently private static final VectorTypeSupport vectorTypeSupport = VectorizationProvider.getInstance().getVectorTypeSupport(); @@ -318,8 +318,8 @@ public void quantizeVector(VectorFloat vector, ") exceeds original dimension (" + originalDimension + ")"); } - if ((landmark & 0xFFFF) >= landmarkCount) { - throw new IllegalArgumentException("Invalid landmark id " + (landmark & 0xFFFF) + if ((landmark & 0xFF) >= landmarkCount) { + throw new IllegalArgumentException("Invalid landmark id " + (landmark & 0xFF) + " for landmarkCount=" + landmarkCount); } @@ -435,12 +435,12 @@ public void quantizeBody(VectorFloat vector, public static class QuantizedVector { public float scale; // d^{-1/2} ||x_i - μ_i*||_2 public float offset; // offset_i = - ||μ_i*||^2 - public short landmark; // c_i* + public byte landmark; // c_i*, unsigned [0, C) public long[] binaryVector; // bin(x̂_i) private QuantizedVector(float scale, float offset, - short landmark, + byte landmark, long[] binaryVector) { this.scale = scale; this.offset = offset; @@ -456,7 +456,7 @@ public static QuantizedVector createEmpty(int quantizedDim) { return new QuantizedVector( Float.NaN, Float.NaN, - (short) 0, + (byte) 0, new long[wordsForDims(quantizedDim)] ); } @@ -511,7 +511,7 @@ static void quantizeTo(VectorFloat vector, public void write(DataOutput out, int quantizedDim) throws IOException { out.writeFloat(scale); out.writeFloat(offset); - out.writeInt(landmark); + out.writeByte(landmark); int words = wordsForDims(quantizedDim); for (int i = 0; i < words; i++) { @@ -523,7 +523,12 @@ public static QuantizedVector load(RandomAccessReader in, int quantizedDim) throws IOException { float scale = in.readFloat(); float offset = in.readFloat(); - short landmark = (short) in.readInt(); + byte landmark; + if (in instanceof io.github.jbellis.jvector.disk.ByteReadable) { + landmark = ((io.github.jbellis.jvector.disk.ByteReadable) in).readByte(); + } else { + throw new IOException("ASH requires ByteReadable reader for landmark"); + } int words = wordsForDims(quantizedDim); long[] body = new long[words]; @@ -539,7 +544,11 @@ public static void loadInto(RandomAccessReader in, int quantizedDim) throws IOException { dest.scale = in.readFloat(); dest.offset = in.readFloat(); - dest.landmark = (short) in.readInt(); + if (in instanceof io.github.jbellis.jvector.disk.ByteReadable) { + dest.landmark = ((io.github.jbellis.jvector.disk.ByteReadable) in).readByte(); + } else { + throw new IOException("ASH requires ByteReadable reader for landmark"); + } int words = wordsForDims(quantizedDim); for (int i = 0; i < words; i++) { @@ -860,7 +869,7 @@ public void encodeTo(VectorFloat vector, QuantizedVector dest) { // The landmark id is always 0 in this mode. // Landmark assignment (C=1 --> always 0) - final short landmark = 0; // placeholder; will generalize + final byte landmark = 0; // placeholder; will generalize final VectorFloat mu = landmarks[landmark]; @@ -914,7 +923,7 @@ public int compressedVectorSize() { int words = QuantizedVector.wordsForDims(quantizedDim); return Float.BYTES // scale + Float.BYTES // offset - + Integer.BYTES // landmark + + Byte.BYTES // landmark + words * Long.BYTES; // binary payload } From 0d087881074db380e1c9cffc224526d532981283 Mon Sep 17 00:00:00 2001 From: Ted Willke Date: Sat, 3 Jan 2026 23:38:02 +0000 Subject: [PATCH 017/243] ASH is sensitive to SIMD alignment, so now we check that the payload is a multiple of 64 bits. --- .../quantization/AsymmetricHashing.java | 57 ++++++++++++++++--- .../jbellis/jvector/example/DistancesASH.java | 35 ++++++++---- .../jbellis/jvector/example/DistancesPQ.java | 23 ++++++-- 3 files changed, 90 insertions(+), 25 deletions(-) diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/AsymmetricHashing.java b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/AsymmetricHashing.java index 5514038f7..b8762c3ea 100644 --- a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/AsymmetricHashing.java +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/AsymmetricHashing.java @@ -175,16 +175,10 @@ public static AsymmetricHashing initialize(RandomAccessVectorValues ravv, int optimizer, int encodedBits) { + // payload bits are always 64-bit aligned by validateEncodedBits() + validateEncodedBits(encodedBits, HEADER_BITS); final int quantizedDim = encodedBits - HEADER_BITS; - if (quantizedDim < 1) { - throw new IllegalArgumentException( - "Illegal ASH quantized dimensionality: " + quantizedDim + - " (encodedBits=" + encodedBits + - ", headerBits=" + HEADER_BITS + ")" - ); - } - var ravvCopy = ravv.threadLocalSupplier().get(); int originalDim = ravvCopy.getVector(0).length(); @@ -218,6 +212,52 @@ public static AsymmetricHashing initialize(RandomAccessVectorValues ravv, ); } + /** + * Validates that the requested {@code encodedBits} value is compatible with + * ASH’s block-SIMD scoring kernel. + * + *

    + * For performance reasons, the binary payload size + * {@code (encodedBits - headerBits)} must be a multiple of 64. Non-aligned + * payloads introduce partial-word tails that significantly degrade SIMD + * efficiency. + *

    + * + * @throws IllegalArgumentException if the payload bit count is invalid or + * not 64-bit aligned + */ + private static void validateEncodedBits( + int encodedBits, + int headerBits, + int originalDim + ) { + int payloadBits = encodedBits - headerBits; + + if (payloadBits < 1) { + throw new IllegalArgumentException( + "Illegal ASH payload bits: " + payloadBits + + " (encodedBits=" + encodedBits + ", headerBits=" + headerBits + ")" + ); + } + + if ((payloadBits & 63) != 0) { + throw new IllegalArgumentException( + "Invalid encodedBits=" + encodedBits + + ". ASH requires (encodedBits - headerBits) to be a multiple of 64 " + + "for aligned binary payload. " + + "Got payloadBits=" + payloadBits + + " (headerBits=" + headerBits + ")." + ); + } + + if (payloadBits > originalDim) { + throw new IllegalArgumentException( + "Invalid ASH payloadBits=" + payloadBits + + " exceeds originalDim=" + originalDim + ); + } + } + /** * Loads an ASH instance from a RandomAccessReader. * This must mirror the ASH header writer exactly. @@ -238,6 +278,7 @@ public static AsymmetricHashing load(RandomAccessReader in) throws IOException { int originalDimension = in.readInt(); int encodedBits = in.readInt(); + validateEncodedBits(encodedBits, HEADER_BITS, originalDimension); int quantizedDim = in.readInt(); int optimizer = in.readInt(); diff --git a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/DistancesASH.java b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/DistancesASH.java index d61d904c5..801f204c5 100644 --- a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/DistancesASH.java +++ b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/DistancesASH.java @@ -52,7 +52,7 @@ public static void testASHEncodings(String filenameBase, String filenameQueries) Boolean.parseBoolean(System.getProperty("jvector.bench.sanity-check", "false")); final boolean RUN_ACCURACY_CHECK = - Boolean.parseBoolean(System.getProperty("jvector.bench.accuracy", "true")); + Boolean.parseBoolean(System.getProperty("jvector.bench.accuracy", "false")); final boolean RUN_SCALAR_SCORING = Boolean.parseBoolean(System.getProperty("jvector.bench.scalar-scoring", "false")); @@ -60,12 +60,15 @@ public static void testASHEncodings(String filenameBase, String filenameQueries) final boolean RUN_FLOAT_SCORING = Boolean.parseBoolean(System.getProperty("jvector.bench.float-scoring", "false")); + // ASH header bits + final int HEADER_BITS = 72; + // Block sizes to benchmark - final int[] BLOCK_SIZES = {16, 32, 64}; // 16, 32, and/or 64 + final int[] BLOCK_SIZES = {64}; // 16, 32, and/or 64 // Define the benchmark size - int maxQueries = 1_000; - int maxVectors = 100_000; + int maxQueries = 10_000; + int maxVectors = 10_000_000; int queryCountInFile = SiftLoader.countFvecs(filenameQueries); int vectorCountInFile = SiftLoader.countFvecs(filenameBase); @@ -119,7 +122,17 @@ public static void testASHEncodings(String filenameBase, String filenameQueries) for (VectorFloat q : queries) VectorUtil.l2normalize(q); int dimension = vectors.get(0).length(); - int encodedBits = dimension / 4; // sweep later if desired + int encodedBits = (dimension / 4) + HEADER_BITS; + // Payload must be 64-bit aligned for SIMD + int payloadBits = encodedBits - HEADER_BITS; + if ((payloadBits & 63) != 0) { + throw new IllegalArgumentException( + "ASH payloadBits must be 64-bit aligned for SIMD. " + + "Got payloadBits=" + payloadBits + + " (encodedBits=" + encodedBits + + ", HEADER_BITS=" + HEADER_BITS + ")" + ); + } System.out.println( "\toriginalDim = " + dimension + @@ -493,11 +506,11 @@ public static void main(String[] args) throws IOException { // runSIFT(); // runGIST(); // runColbert(); - runCohere100k(); - runADA(); - runOpenai1536(); - runOpenai3072(); -// runCap6m(); -// runCohere10m(); +// runCohere100k(); +// runADA(); +// runOpenai1536(); +// runOpenai3072(); + runCap6m(); + runCohere10m(); } } diff --git a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/DistancesPQ.java b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/DistancesPQ.java index 6c5fe9190..47c88f975 100644 --- a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/DistancesPQ.java +++ b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/DistancesPQ.java @@ -121,11 +121,21 @@ public static void testPQEncodings(String filenameBase, String filenameQueries) int chunkSize = Math.max(1, (finalQueryCount + parallelism - 1) / parallelism); // ------------------------------------------------------------ - // PQ parameters (compression matched to ASH) + // PQ parameters (EXACT bit-for-bit match with ASH) // ------------------------------------------------------------ - // ASH: encodedBits = d / 4 -> bytes = d / 32 - // PQ: 1 byte per subspace -> M = d / 32 - final int M = Math.max(1, dimension / 32); + // ASH total size = encodedBits (includes header + payload) + // PQ total size = M * 8 bits (1 byte per subspace) + // + // We REQUIRE exact equality for fair benchmarking. + int HEADER_BITS = 72; + int encodedBitsASH = (dimension / 4) + HEADER_BITS; // HEADER_BITS = 72 + if ((encodedBitsASH & 7) != 0) { + throw new IllegalArgumentException( + "PQ requires encodedBits to be a multiple of 8 for exact bit matching; got " + encodedBitsASH + ); + } + + final int M = encodedBitsASH / 8; final int K = 256; // 1 byte per subspace final boolean globallyCenter = false; final float anisotropicThreshold = 0.0f; @@ -133,8 +143,9 @@ public static void testPQEncodings(String filenameBase, String filenameQueries) System.out.println("\tPQ params:"); System.out.println("\t M = " + M + " subspaces"); System.out.println("\t K = " + K + " clusters"); - System.out.println("\t Compression ≈ " + (M * 8) + " bits (" + M + " bytes)"); - System.out.println("\t ASH reference ≈ " + (dimension / 4) + " bits (" + (dimension / 32) + " bytes)"); + System.out.println("\t Compression = " + (M * 8) + " bits (" + M + " bytes)"); + System.out.println("\tASH reference = " + encodedBitsASH + " bits (" + (encodedBitsASH / 8) + " bytes)"); + // ------------------------------------------------------------ // Train PQ + encode (timed) From 19a3534b902e4ca33077c99817a1ed18b18a740f Mon Sep 17 00:00:00 2001 From: Ted Willke Date: Sat, 3 Jan 2026 23:38:02 +0000 Subject: [PATCH 018/243] ASH is sensitive to SIMD alignment, so now we check that the payload is a multiple of 64 bits. --- .../quantization/AsymmetricHashing.java | 57 ++++++++++++++++--- .../jbellis/jvector/example/DistancesASH.java | 35 ++++++++---- .../jbellis/jvector/example/DistancesPQ.java | 23 ++++++-- 3 files changed, 90 insertions(+), 25 deletions(-) diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/AsymmetricHashing.java b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/AsymmetricHashing.java index 5514038f7..bb45c9158 100644 --- a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/AsymmetricHashing.java +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/AsymmetricHashing.java @@ -177,16 +177,10 @@ public static AsymmetricHashing initialize(RandomAccessVectorValues ravv, final int quantizedDim = encodedBits - HEADER_BITS; - if (quantizedDim < 1) { - throw new IllegalArgumentException( - "Illegal ASH quantized dimensionality: " + quantizedDim + - " (encodedBits=" + encodedBits + - ", headerBits=" + HEADER_BITS + ")" - ); - } - var ravvCopy = ravv.threadLocalSupplier().get(); int originalDim = ravvCopy.getVector(0).length(); + // payload bits are always 64-bit aligned by validateEncodedBits() + validateEncodedBits(encodedBits, HEADER_BITS, originalDim); // C=1 landmarks case VectorFloat mu0 = vectorTypeSupport.createFloatVector(originalDim); @@ -218,6 +212,52 @@ public static AsymmetricHashing initialize(RandomAccessVectorValues ravv, ); } + /** + * Validates that the requested {@code encodedBits} value is compatible with + * ASH’s block-SIMD scoring kernel. + * + *

    + * For performance reasons, the binary payload size + * {@code (encodedBits - headerBits)} must be a multiple of 64. Non-aligned + * payloads introduce partial-word tails that significantly degrade SIMD + * efficiency. + *

    + * + * @throws IllegalArgumentException if the payload bit count is invalid or + * not 64-bit aligned + */ + private static void validateEncodedBits( + int encodedBits, + int headerBits, + int originalDim + ) { + int payloadBits = encodedBits - headerBits; + + if (payloadBits < 1) { + throw new IllegalArgumentException( + "Illegal ASH payload bits: " + payloadBits + + " (encodedBits=" + encodedBits + ", headerBits=" + headerBits + ")" + ); + } + + if ((payloadBits & 63) != 0) { + throw new IllegalArgumentException( + "Invalid encodedBits=" + encodedBits + + ". ASH requires (encodedBits - headerBits) to be a multiple of 64 " + + "for aligned binary payload. " + + "Got payloadBits=" + payloadBits + + " (headerBits=" + headerBits + ")." + ); + } + + if (payloadBits > originalDim) { + throw new IllegalArgumentException( + "Invalid ASH payloadBits=" + payloadBits + + " exceeds originalDim=" + originalDim + ); + } + } + /** * Loads an ASH instance from a RandomAccessReader. * This must mirror the ASH header writer exactly. @@ -238,6 +278,7 @@ public static AsymmetricHashing load(RandomAccessReader in) throws IOException { int originalDimension = in.readInt(); int encodedBits = in.readInt(); + validateEncodedBits(encodedBits, HEADER_BITS, originalDimension); int quantizedDim = in.readInt(); int optimizer = in.readInt(); diff --git a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/DistancesASH.java b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/DistancesASH.java index d61d904c5..801f204c5 100644 --- a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/DistancesASH.java +++ b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/DistancesASH.java @@ -52,7 +52,7 @@ public static void testASHEncodings(String filenameBase, String filenameQueries) Boolean.parseBoolean(System.getProperty("jvector.bench.sanity-check", "false")); final boolean RUN_ACCURACY_CHECK = - Boolean.parseBoolean(System.getProperty("jvector.bench.accuracy", "true")); + Boolean.parseBoolean(System.getProperty("jvector.bench.accuracy", "false")); final boolean RUN_SCALAR_SCORING = Boolean.parseBoolean(System.getProperty("jvector.bench.scalar-scoring", "false")); @@ -60,12 +60,15 @@ public static void testASHEncodings(String filenameBase, String filenameQueries) final boolean RUN_FLOAT_SCORING = Boolean.parseBoolean(System.getProperty("jvector.bench.float-scoring", "false")); + // ASH header bits + final int HEADER_BITS = 72; + // Block sizes to benchmark - final int[] BLOCK_SIZES = {16, 32, 64}; // 16, 32, and/or 64 + final int[] BLOCK_SIZES = {64}; // 16, 32, and/or 64 // Define the benchmark size - int maxQueries = 1_000; - int maxVectors = 100_000; + int maxQueries = 10_000; + int maxVectors = 10_000_000; int queryCountInFile = SiftLoader.countFvecs(filenameQueries); int vectorCountInFile = SiftLoader.countFvecs(filenameBase); @@ -119,7 +122,17 @@ public static void testASHEncodings(String filenameBase, String filenameQueries) for (VectorFloat q : queries) VectorUtil.l2normalize(q); int dimension = vectors.get(0).length(); - int encodedBits = dimension / 4; // sweep later if desired + int encodedBits = (dimension / 4) + HEADER_BITS; + // Payload must be 64-bit aligned for SIMD + int payloadBits = encodedBits - HEADER_BITS; + if ((payloadBits & 63) != 0) { + throw new IllegalArgumentException( + "ASH payloadBits must be 64-bit aligned for SIMD. " + + "Got payloadBits=" + payloadBits + + " (encodedBits=" + encodedBits + + ", HEADER_BITS=" + HEADER_BITS + ")" + ); + } System.out.println( "\toriginalDim = " + dimension + @@ -493,11 +506,11 @@ public static void main(String[] args) throws IOException { // runSIFT(); // runGIST(); // runColbert(); - runCohere100k(); - runADA(); - runOpenai1536(); - runOpenai3072(); -// runCap6m(); -// runCohere10m(); +// runCohere100k(); +// runADA(); +// runOpenai1536(); +// runOpenai3072(); + runCap6m(); + runCohere10m(); } } diff --git a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/DistancesPQ.java b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/DistancesPQ.java index 6c5fe9190..47c88f975 100644 --- a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/DistancesPQ.java +++ b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/DistancesPQ.java @@ -121,11 +121,21 @@ public static void testPQEncodings(String filenameBase, String filenameQueries) int chunkSize = Math.max(1, (finalQueryCount + parallelism - 1) / parallelism); // ------------------------------------------------------------ - // PQ parameters (compression matched to ASH) + // PQ parameters (EXACT bit-for-bit match with ASH) // ------------------------------------------------------------ - // ASH: encodedBits = d / 4 -> bytes = d / 32 - // PQ: 1 byte per subspace -> M = d / 32 - final int M = Math.max(1, dimension / 32); + // ASH total size = encodedBits (includes header + payload) + // PQ total size = M * 8 bits (1 byte per subspace) + // + // We REQUIRE exact equality for fair benchmarking. + int HEADER_BITS = 72; + int encodedBitsASH = (dimension / 4) + HEADER_BITS; // HEADER_BITS = 72 + if ((encodedBitsASH & 7) != 0) { + throw new IllegalArgumentException( + "PQ requires encodedBits to be a multiple of 8 for exact bit matching; got " + encodedBitsASH + ); + } + + final int M = encodedBitsASH / 8; final int K = 256; // 1 byte per subspace final boolean globallyCenter = false; final float anisotropicThreshold = 0.0f; @@ -133,8 +143,9 @@ public static void testPQEncodings(String filenameBase, String filenameQueries) System.out.println("\tPQ params:"); System.out.println("\t M = " + M + " subspaces"); System.out.println("\t K = " + K + " clusters"); - System.out.println("\t Compression ≈ " + (M * 8) + " bits (" + M + " bytes)"); - System.out.println("\t ASH reference ≈ " + (dimension / 4) + " bits (" + (dimension / 32) + " bytes)"); + System.out.println("\t Compression = " + (M * 8) + " bits (" + M + " bytes)"); + System.out.println("\tASH reference = " + encodedBitsASH + " bits (" + (encodedBitsASH / 8) + " bytes)"); + // ------------------------------------------------------------ // Train PQ + encode (timed) From ae8bf0434147d98cd6ebd4e413ab5e585d2c92f0 Mon Sep 17 00:00:00 2001 From: Ted Willke Date: Sun, 4 Jan 2026 19:24:28 +0000 Subject: [PATCH 019/243] Added multi-landmark support to the scalar scorer. Updated DistancesASH to acceptt landmark count. --- .../jvector/quantization/ASHScorer.java | 123 +++++---- .../quantization/AsymmetricHashing.java | 256 ++++++++++++++++-- .../jbellis/jvector/example/DistancesASH.java | 7 +- 3 files changed, 313 insertions(+), 73 deletions(-) diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHScorer.java b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHScorer.java index 7ba87b284..a345a088f 100644 --- a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHScorer.java +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHScorer.java @@ -22,7 +22,11 @@ /** * Query-time scorer for ASH vectors. - * Single-landmark case (C = 1). + * + * Multi-landmark scoring (C >= 1) following the paper’s Eq. 11 decomposition: + * for each landmark c we form q̃_c = A (q - μ_c) = (Aq) - (Aμ_c) once per query, + * then score each vector using its stored landmark id. + * * NOTE: Only DOT_PRODUCT is supported for now. */ public final class ASHScorer { @@ -30,6 +34,22 @@ public final class ASHScorer { public ASHScorer(AsymmetricHashing ash) { this.ash = ash; + + final int d = ash.quantizedDim; + final int C = ash.landmarkCount; + + assert ash.landmarkProj != null; + assert ash.landmarkProjSum != null; + assert ash.landmarkProj.length == C : + "landmarkProj length mismatch"; + assert ash.landmarkProjSum.length == C : + "landmarkProjSum length mismatch"; + + for (int c = 0; c < C; c++) { + assert ash.landmarkProj[c].length == d : + "landmarkProj[" + c + "] length != quantizedDim"; + } + assert ash.stiefelTransform.rows == ash.quantizedDim : "Stiefel rows must equal quantizedDim"; } @@ -48,80 +68,89 @@ public ASHScoreFunction scoreFunctionFor(VectorFloat query, VectorSimilarityF */ private ASHScoreFunction dotProductScoreFunctionFor(VectorFloat query) { final int d = ash.quantizedDim; + final int D = ash.originalDimension; + final int C = ash.landmarkCount; - // Compute q_centered = q - μ - // (avoid allocation by doing subtraction on the fly during projection) - // Landmark-dependent query projection (C=1 => still index 0) - final VectorFloat mu = ash.landmarks[0]; - - final float[][] A = ash.stiefelTransform.AFloat; // shape: d × originalDim - final int originalDim = ash.stiefelTransform.cols; - final float[] muArr = new float[originalDim]; + final float[][] A = ash.stiefelTransform.AFloat; // [d][D] final var vecUtil = io.github.jbellis.jvector.vector.VectorizationProvider .getInstance() .getVectorUtilSupport(); - for (int k = 0; k < originalDim; k++) muArr[k] = mu.get(k); - - final float[] qArr = new float[originalDim]; - for (int k = 0; k < originalDim; k++) qArr[k] = query.get(k); + // Materialize query once (avoid VectorFloat.get in inner loops) + final float[] qArr = new float[D]; + for (int k = 0; k < D; k++) qArr[k] = query.get(k); - // Project query: tildeQ[j] = (A (q - μ))[j] - final float[] tildeQ = new float[d]; + // Compute Aq once per query: + // qProj[j] = + final float[] qProj = new float[d]; + float sumQProj = 0f; for (int j = 0; j < d; j++) { - final float[] Aj = A[j]; - float acc = 0.0f; - for (int k = 0; k < originalDim; k++) { - acc += Aj[k] * (qArr[k] - muArr[k]); - } - tildeQ[j] = acc; + float v = vecUtil.ashDotRow(A[j], qArr); + qProj[j] = v; + sumQProj += v; } - // = sum_j tildeQ[j] - float tmpSum = 0f; - for (int j = 0; j < d; j++) { - tmpSum += tildeQ[j]; + // For each landmark c: + // tildeQ_c = Aq - Aμ_c + // sumTildeQc = sum(Aq) - sum(Aμ_c) + // dotQMu_c = + // Pooled storage for q̃_c vectors: + // tildeQPool[c*d + j] == q̃_c[j] + final float[] tildeQPool = new float[C * d]; + final float[] sumTildeQByLandmark = new float[C]; + final float[] dotQMuByLandmark = new float[C]; + + for (int c = 0; c < C; c++) { + dotQMuByLandmark[c] = VectorUtil.dotProduct(query, ash.landmarks[c]); + sumTildeQByLandmark[c] = sumQProj - ash.landmarkProjSum[c]; + + final int base = c * d; + final float[] muProj = ash.landmarkProj[c]; // [d] + for (int j = 0; j < d; j++) { + tildeQPool[base + j] = qProj[j] - muProj[j]; + } } - final float sumTildeQ = tmpSum; - - // - final float dotQMu = VectorUtil.dotProduct(query, mu); return (AsymmetricHashing.QuantizedVector v) -> { - // Single landmark baseline: ignore v.landmark for now (assumed 0) - final float scale = v.scale; // already ||x − μ|| / sqrt(d) - final float offset = v.offset; // already − ||μ||^2 + final int c = v.landmark & 0xFF; // unsigned [0,C) + assert c < C : "Invalid landmark id " + c + " for landmarkCount=" + C; - // masked-add term: where bin ∈ {0,1}^d - // This is sum of tildeQ[j] over set bits in v.binaryVector. + // scale = ||x − μ_c|| / √d (precomputed at encode-time) + final float scale = v.scale; + + // offset = − ||μ_c||² (precomputed at encode-time) + final float offset = v.offset; + + // maskedAdd = + // b ∈ {0,1}^d stored as packed longs float maskedAdd = 0f; - // v.binaryVector stores bits packed in longs, little-endian per word (bit j corresponds to dimension base+j) + final int tildeBase = c * d; + final float sumTildeQ = sumTildeQByLandmark[c]; + final float dotQMu = dotQMuByLandmark[c]; + final long[] bits = v.binaryVector; - int base = 0; - for (int w = 0; w < bits.length && base < d; w++, base += 64) { + int dimBase = 0; + for (int w = 0; w < bits.length && dimBase < d; w++, dimBase += 64) { long word = bits[w]; while (word != 0L) { int bit = Long.numberOfTrailingZeros(word); - int idx = base + bit; - if (idx < d) maskedAdd += tildeQ[idx]; + int idx = dimBase + bit; + if (idx < d) { + maskedAdd += tildeQPool[tildeBase + idx]; + } word &= (word - 1); } } - // ASH dot-product approximation (single landmark, {0,1} encoding): + // ASH dot-product approximation (multi-landmark, {0,1} encoding): // - // scale * (2⟨q̃, b⟩ − ⟨q̃, 1⟩) + ⟨q, μ⟩ + (⟨x, μ⟩ − ||μ||²) + // scale * (2⟨q̃_c, b⟩ − ⟨q̃_c, 1⟩) + ⟨q, μ_c⟩ + (⟨x, μ_c⟩ − ||μ_c||²) // - // Notes: - // - b ∈ {0,1}^d is the binary code (converted from the paper’s {−1,+1} form). - // - scale = ||x − μ|| / √d (pure Eq. 11 baseline; no bias correction). - // - ⟨q, μ⟩ is a query-only offset and MUST NOT be scaled. - // - Scaling ⟨q, μ⟩ leads to large systematic error (see paper Eq. 11 regrouping). + // where q̃_c = A(q − μ_c) is precomputed once per query and per landmark c. return scale * (2f * maskedAdd - sumTildeQ) + dotQMu + offset; - }; } diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/AsymmetricHashing.java b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/AsymmetricHashing.java index bb45c9158..5f253d4f8 100644 --- a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/AsymmetricHashing.java +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/AsymmetricHashing.java @@ -90,6 +90,22 @@ public class AsymmetricHashing implements VectorCompressor where A is [d][D] and μ_c is length D. + * + * Shape: [landmarkCount][quantizedDim] + */ + public final float[][] landmarkProj; + + /** + * Precomputed sums of the landmark projections: + * landmarkProjSum[c] = Σ_j landmarkProj[c][j] + * + * Shape: [landmarkCount] + */ + public final float[] landmarkProjSum; + /** Debug hook to disable learning paths */ @VisibleForTesting public boolean learn = true; @@ -125,10 +141,22 @@ private AsymmetricHashing(int originalDim, int quantizedDim, int optimizer, VectorFloat[] landmarks, - StiefelTransform stiefelTransform) { + StiefelTransform stiefelTransform, + float[][] landmarkProj, + float[] landmarkProjSum) { this.originalDimension = originalDim; this.encodedBits = encodedBits; this.quantizedDim = quantizedDim; + + // Defensive check for consistency + if (encodedBits - HEADER_BITS != quantizedDim) { + throw new IllegalArgumentException( + "Invalid ASH configuration: encodedBits=" + encodedBits + + ", quantizedDim=" + quantizedDim + + ", HEADER_BITS=" + HEADER_BITS + ); + } + this.optimizer = optimizer; this.stiefelTransform = Objects.requireNonNull(stiefelTransform); @@ -143,6 +171,25 @@ private AsymmetricHashing(int originalDim, VectorUtil.dotProduct(landmarks[c], landmarks[c]); } + this.landmarkProj = Objects.requireNonNull(landmarkProj, "landmarkProj"); + this.landmarkProjSum = Objects.requireNonNull(landmarkProjSum, "landmarkProjSum"); + + if (landmarkProj.length != landmarkCount) { + throw new IllegalArgumentException("landmarkProj outer dimension mismatch: " + + landmarkProj.length + " != landmarkCount=" + landmarkCount); + } + if (landmarkProjSum.length != landmarkCount) { + throw new IllegalArgumentException("landmarkProjSum length mismatch: " + + landmarkProjSum.length + " != landmarkCount=" + landmarkCount); + } + for (int c = 0; c < landmarkCount; c++) { + if (landmarkProj[c] == null || landmarkProj[c].length != quantizedDim) { + throw new IllegalArgumentException("landmarkProj[" + c + "] length mismatch: " + + (landmarkProj[c] == null ? "null" : landmarkProj[c].length) + + " != quantizedDim=" + quantizedDim); + } + } + if (landmarkNormSq.length != landmarkCount) { throw new IllegalArgumentException("landmarkNormSq length mismatch"); } @@ -173,23 +220,65 @@ private AsymmetricHashing(int originalDim, */ public static AsymmetricHashing initialize(RandomAccessVectorValues ravv, int optimizer, - int encodedBits) { + int encodedBits, + int landmarkCount) { final int quantizedDim = encodedBits - HEADER_BITS; + if (landmarkCount < 1 || landmarkCount > 64) { + throw new IllegalArgumentException( + "landmarkCount must be in [1,64], got " + landmarkCount); + } + + System.out.println( + "\tASH initialized with landmarkCount=" + landmarkCount + + ", quantizedDim=" + quantizedDim + ); + var ravvCopy = ravv.threadLocalSupplier().get(); int originalDim = ravvCopy.getVector(0).length(); // payload bits are always 64-bit aligned by validateEncodedBits() validateEncodedBits(encodedBits, HEADER_BITS, originalDim); - // C=1 landmarks case - VectorFloat mu0 = vectorTypeSupport.createFloatVector(originalDim); + // NOTE: points are treated as read-only by KMeansPlusPlusClusterer. Materialize once. + VectorFloat[] points = new VectorFloat[ravvCopy.size()]; for (int i = 0; i < ravvCopy.size(); i++) { - VectorUtil.addInPlace(mu0, ravvCopy.getVector(i)); + points[i] = ravvCopy.getVector(i); } - VectorUtil.scale(mu0, 1.0f / ravvCopy.size()); - VectorFloat[] landmarks = new VectorFloat[] { mu0 }; + VectorFloat[] landmarks; + if (landmarkCount == 1) { + // Mean centroid + VectorFloat mu0 = vectorTypeSupport.createFloatVector(originalDim); + for (int i = 0; i < points.length; i++) { + VectorUtil.addInPlace(mu0, points[i]); + } + VectorUtil.scale(mu0, 1.0f / points.length); + landmarks = new VectorFloat[] { mu0 }; + } else { + long kmStart = System.nanoTime(); + + // KMeans++ in JVector returns centroids packed as one big vector [C * D] + KMeansPlusPlusClusterer km = new KMeansPlusPlusClusterer(points, landmarkCount); + VectorFloat packed = km.cluster(/*unweightedIterations*/ 20, /*anisotropicIterations*/ 0); + + long kmEnd = System.nanoTime(); + + System.out.printf( + "\tKMeans++ (C=%d, N=%d, D=%d) took %.3f seconds%n", + landmarkCount, + points.length, + originalDim, + (kmEnd - kmStart) / 1e9 + ); + + landmarks = new VectorFloat[landmarkCount]; + for (int c = 0; c < landmarkCount; c++) { + VectorFloat mu = vectorTypeSupport.createFloatVector(originalDim); + mu.copyFrom(packed, c * originalDim, 0, originalDim); + landmarks[c] = mu; + } + } StiefelTransform stiefelTransform; if (optimizer == RANDOM) { @@ -202,13 +291,23 @@ public static AsymmetricHashing initialize(RandomAccessVectorValues ravv, throw new IllegalArgumentException("Unknown optimizer " + optimizer); } + LandmarkProjections lp = + computeLandmarkProjections( + landmarks, + stiefelTransform, + quantizedDim, + originalDim + ); + return new AsymmetricHashing( originalDim, encodedBits, quantizedDim, optimizer, landmarks, - stiefelTransform + stiefelTransform, + lp.proj, + lp.sum ); } @@ -258,6 +357,67 @@ private static void validateEncodedBits( } } + /** + * Precompute landmark projections in the reduced space: + * + * landmarkProj[c][j] = + * landmarkProjSum[c] = Σ_j landmarkProj[c][j] + * + * This is used by both initialize() and load() to ensure + * identical query-time math regardless of construction path. + */ + private static LandmarkProjections computeLandmarkProjections( + VectorFloat[] landmarks, + StiefelTransform stiefelTransform, + int quantizedDim, + int originalDim + ) { + final int C = landmarks.length; + final float[][] A = stiefelTransform.AFloat; // [d][D] + + final var vecUtil = + io.github.jbellis.jvector.vector.VectorizationProvider + .getInstance() + .getVectorUtilSupport(); + + final float[][] landmarkProj = new float[C][quantizedDim]; + final float[] landmarkProjSum = new float[C]; + + final float[] muArr = new float[originalDim]; + + for (int c = 0; c < C; c++) { + VectorFloat mu = landmarks[c]; + + // materialize μ once + for (int i = 0; i < originalDim; i++) { + muArr[i] = mu.get(i); + } + + float sum = 0f; + float[] proj = landmarkProj[c]; + + for (int j = 0; j < quantizedDim; j++) { + float v = vecUtil.ashDotRow(A[j], muArr); + proj[j] = v; + sum += v; + } + + landmarkProjSum[c] = sum; + } + + return new LandmarkProjections(landmarkProj, landmarkProjSum); + } + + private static final class LandmarkProjections { + final float[][] proj; // [C][d] + final float[] sum; // [C] + + LandmarkProjections(float[][] proj, float[] sum) { + this.proj = proj; + this.sum = sum; + } + } + /** * Loads an ASH instance from a RandomAccessReader. * This must mirror the ASH header writer exactly. @@ -280,14 +440,36 @@ public static AsymmetricHashing load(RandomAccessReader in) throws IOException { int encodedBits = in.readInt(); validateEncodedBits(encodedBits, HEADER_BITS, originalDimension); int quantizedDim = in.readInt(); + + // HEADER_BITS compatibility check + int expectedQuantizedDim = encodedBits - HEADER_BITS; + if (quantizedDim != expectedQuantizedDim) { + throw new IOException( + "ASH format mismatch: encodedBits=" + encodedBits + + ", quantizedDim=" + quantizedDim + + ", but runtime HEADER_BITS=" + HEADER_BITS + + " implies quantizedDim=" + expectedQuantizedDim + ); + } + int optimizer = in.readInt(); - int landmark0Length = in.readInt(); - if (landmark0Length <= 0) { - throw new IOException("ASH header missing landmark[0]"); + // Multi-landmark support + int landmarkCount = in.readInt(); + if (landmarkCount < 1 || landmarkCount > 64) { + throw new IOException("Invalid landmarkCount=" + landmarkCount); + } + + VectorFloat[] landmarks = new VectorFloat[landmarkCount]; + for (int c = 0; c < landmarkCount; c++) { + int dim = in.readInt(); + if (dim != originalDimension) { + throw new IOException( + "Landmark dimension mismatch: expected " + + originalDimension + ", got " + dim); + } + landmarks[c] = vectorTypeSupport.readFloatVector(in, dim); } - VectorFloat mu0 = vectorTypeSupport.readFloatVector(in, landmark0Length); - VectorFloat[] landmarks = new VectorFloat[] { mu0 }; // NOTE: StiefelTransform loading will be added in Step 2. // For now, construct a placeholder or re-run the RANDOM initializer. @@ -306,13 +488,23 @@ public static AsymmetricHashing load(RandomAccessReader in) throws IOException { ); } + LandmarkProjections lp = + computeLandmarkProjections( + landmarks, + stiefelTransform, + quantizedDim, + originalDimension + ); + return new AsymmetricHashing( originalDimension, encodedBits, quantizedDim, optimizer, landmarks, - stiefelTransform + stiefelTransform, + lp.proj, + lp.sum ); } @@ -337,9 +529,13 @@ public void write(DataOutput out, int version) throws IOException { out.writeInt(quantizedDim); out.writeInt(optimizer); - // (C=1): write landmark[0] (dataset mean). Multi-landmark serialization TBD. - out.writeInt(landmarks[0].length()); - vectorTypeSupport.writeFloatVector(out, landmarks[0]); + // Multi-landmark serialization + out.writeInt(landmarkCount); + + for (int c = 0; c < landmarkCount; c++) { + out.writeInt(landmarks[c].length()); + vectorTypeSupport.writeFloatVector(out, landmarks[c]); + } // NOTE: stiefelTransform serialization will be added later } @@ -350,7 +546,7 @@ public void write(DataOutput out, int version) throws IOException { public void quantizeVector(VectorFloat vector, float residualNorm, - short landmark, + byte landmark, QuantizedVector dest) { if (quantizedDim > originalDimension) { @@ -503,7 +699,7 @@ public static QuantizedVector createEmpty(int quantizedDim) { } public static int serializedSizeBytes(int quantizedDim) { - return Float.BYTES + Float.BYTES + Short.BYTES + return Float.BYTES + Float.BYTES + Byte.BYTES + wordsForDims(quantizedDim) * Long.BYTES; } @@ -909,10 +1105,19 @@ public void encodeTo(VectorFloat vector, QuantizedVector dest) { // All vectors are encoded relative to landmark μ_c (C=1 => μ_0 is the dataset mean). // The landmark id is always 0 in this mode. - // Landmark assignment (C=1 --> always 0) - final byte landmark = 0; // placeholder; will generalize + // Landmark assignment: nearest centroid by L2 distance + byte landmark = 0; + float bestDist = Float.POSITIVE_INFINITY; - final VectorFloat mu = landmarks[landmark]; + for (int c = 0; c < landmarkCount; c++) { + float dist = VectorUtil.squareL2Distance(vector, landmarks[c]); + if (dist < bestDist) { + bestDist = dist; + landmark = (byte) c; + } + } + + final VectorFloat mu = landmarks[landmark & 0xFF]; // Compute final float dotXMu = VectorUtil.dotProduct(vector, mu); @@ -954,8 +1159,11 @@ public int compressorSize() { size += Integer.BYTES; // encodedBits size += Integer.BYTES; // quantizedDim size += Integer.BYTES; // optimizer - size += Integer.BYTES; // landmarks[0] length - size += Float.BYTES * landmarks[0].length(); + size += Integer.BYTES; // landmarkCount + for (int c = 0; c < landmarkCount; c++) { + size += Integer.BYTES; // landmark dimension + size += Float.BYTES * landmarks[c].length(); + } return size; } diff --git a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/DistancesASH.java b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/DistancesASH.java index 801f204c5..37f0a20cf 100644 --- a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/DistancesASH.java +++ b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/DistancesASH.java @@ -52,7 +52,7 @@ public static void testASHEncodings(String filenameBase, String filenameQueries) Boolean.parseBoolean(System.getProperty("jvector.bench.sanity-check", "false")); final boolean RUN_ACCURACY_CHECK = - Boolean.parseBoolean(System.getProperty("jvector.bench.accuracy", "false")); + Boolean.parseBoolean(System.getProperty("jvector.bench.accuracy", "true")); final boolean RUN_SCALAR_SCORING = Boolean.parseBoolean(System.getProperty("jvector.bench.scalar-scoring", "false")); @@ -66,6 +66,9 @@ public static void testASHEncodings(String filenameBase, String filenameQueries) // Block sizes to benchmark final int[] BLOCK_SIZES = {64}; // 16, 32, and/or 64 + // How many ASH landmarks to use, C = [1, 64] + final int landmarkCount = 64; + // Define the benchmark size int maxQueries = 10_000; int maxVectors = 10_000_000; @@ -149,7 +152,7 @@ public static void testASHEncodings(String filenameBase, String filenameQueries) // Build ASH // ------------------------------------------------------------------ var ravv = new ListRandomAccessVectorValues(vectors, dimension); - var ash = AsymmetricHashing.initialize(ravv, AsymmetricHashing.RANDOM, encodedBits); + var ash = AsymmetricHashing.initialize(ravv, AsymmetricHashing.RANDOM, encodedBits, landmarkCount); long startTime = System.nanoTime(); CompressedVectors ashVecs = ash.encodeAll(ravv); From 92bf98d4ed431bc1dd8b2a10c3ec2eacc9db9f35 Mon Sep 17 00:00:00 2001 From: Ted Willke Date: Mon, 5 Jan 2026 18:05:45 +0000 Subject: [PATCH 020/243] Created a pooled landmark-specific query buffer. Still using the non-pooled version for performance purposes though. --- .../jvector/quantization/ASHVectors.java | 265 ++++++++++-------- .../jvector/vector/VectorUtilSupport.java | 51 ++++ .../vector/PanamaVectorUtilSupport.java | 67 +++++ 3 files changed, 272 insertions(+), 111 deletions(-) diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHVectors.java b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHVectors.java index 4e6bc43df..57ea8debf 100644 --- a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHVectors.java +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHVectors.java @@ -169,9 +169,9 @@ public ScoreFunction.ApproximateScoreFunction scoreFunctionFor( return node -> f.similarityTo(compressedVectors[node]); } -// ============================================================================ -// ASH block-oriented scoring -// ============================================================================ + // ============================================================================ + // ASH block-oriented scoring + // ============================================================================ /** * Scalar reference block scorer. @@ -282,16 +282,19 @@ public ASHBlockScorer blockScorerFor( final AshBlockKernel kernel = AshBlockKernel.fromProperty(); + // Compute query precompute ONCE (shared by scalar + simd) + final QueryPrecompute qp = precomputeQuery(query); + // ------------------------------------------------------------ // Forced scalar path (portable register-accumulator) // ------------------------------------------------------------ if (kernel == AshBlockKernel.SCALAR) { return new BlockScalarASHBlockScorer( - ash, compressedVectors, scales, offsets, - query + landmarks, + qp ); } @@ -304,13 +307,13 @@ public ASHBlockScorer blockScorerFor( ensurePackedBits(blockSize); return new BlockSimdASHBlockScorer( - ash, compressedVectors, scales, offsets, + landmarks, packedBits, blockSize, - query + qp ); } @@ -354,89 +357,72 @@ private void ensurePackedBits(int blockSize) { */ private static final class BlockScalarASHBlockScorer implements ASHBlockScorer { - private final AsymmetricHashing ash; private final AsymmetricHashing.QuantizedVector[] vectors; private final float[] scales; private final float[] offsets; + private final byte[] landmarks; - - private final float[] tildeQ; - private final float sumTildeQ; - private final float dotQMu; - + private final float[] tildeQPool; // [C * d] + private final float[] sumTildeQ; // [C] + private final float[] dotQMu; // [C] private final int d; private final int words; BlockScalarASHBlockScorer( - AsymmetricHashing ash, AsymmetricHashing.QuantizedVector[] vectors, float[] scales, float[] offsets, - VectorFloat query) { + byte[] landmarks, + QueryPrecompute qp) + { - this.ash = ash; this.vectors = vectors; this.scales = scales; this.offsets = offsets; + this.landmarks = landmarks; - this.d = ash.quantizedDim; + this.d = qp.d; this.words = AsymmetricHashing.QuantizedVector.wordsForDims(d); - final int D = ash.originalDimension; - final VectorFloat mu = ash.landmarks[0]; - - final float[] qArr = new float[D]; - final float[] muArr = new float[D]; - for (int i = 0; i < D; i++) { - qArr[i] = query.get(i); - muArr[i] = mu.get(i); - } - - this.tildeQ = new float[d]; - final float[][] A = ash.stiefelTransform.AFloat; - for (int r = 0; r < d; r++) { - float s = 0f; - float[] Arow = A[r]; - for (int j = 0; j < D; j++) { - s += Arow[j] * (qArr[j] - muArr[j]); - } - tildeQ[r] = s; - } - - float sum = 0f; - for (int i = 0; i < d; i++) sum += tildeQ[i]; - this.sumTildeQ = sum; - this.dotQMu = VectorUtil.dotProduct(query, mu); + this.tildeQPool = qp.tildeQPool; + this.sumTildeQ = qp.sumTildeQ; + this.dotQMu = qp.dotQMu; } @Override public void scoreRange(int start, int count, float[] out) { + if (count < 0) throw new IllegalArgumentException("count must be >= 0"); + if (start < 0 || start + count > vectors.length) { + throw new IllegalArgumentException("Range out of bounds: [" + start + ", " + (start + count) + ")"); + } + if (out.length < count) throw new IllegalArgumentException("out.length < count"); + if (count == 0) return; for (int i = 0; i < count; i++) { - int idx = start + i; + final int idx = start + i; + + final int c = landmarks[idx] & 0xFF; // unsigned [0,C) + final int base = c * d; float maskedAdd = 0f; - long[] bits = vectors[idx].binaryVector; - for (int w = 0; w < words; w++) { + // bit-walk against landmark-specific q̃ + final long[] bits = vectors[idx].binaryVector; + int baseDim = 0; + for (int w = 0; w < words && baseDim < d; w++, baseDim += 64) { long word = bits[w]; - int baseDim = w * 64; - while (word != 0L) { int bit = Long.numberOfTrailingZeros(word); - int dim = baseDim + bit; - if (dim < d) maskedAdd += tildeQ[dim]; + int j = baseDim + bit; + if (j < d) maskedAdd += tildeQPool[base + j]; word &= (word - 1); } } - float scale = scales[idx]; - float offset = offsets[idx]; - out[i] = - scale * (2f * maskedAdd - sumTildeQ) - + dotQMu - + offset; + scales[idx] * (2f * maskedAdd - sumTildeQ[c]) + + dotQMu[c] + + offsets[idx]; } } } @@ -464,11 +450,10 @@ public void scoreRange(int start, int count, float[] out) { */ private static final class BlockSimdASHBlockScorer implements ASHBlockScorer { - private final AsymmetricHashing ash; private final AsymmetricHashing.QuantizedVector[] vectors; - private final float[] scales; private final float[] offsets; + private final byte[] landmarks; private final long[] packedBits; private final int blockSize; @@ -476,115 +461,173 @@ private static final class BlockSimdASHBlockScorer implements ASHBlockScorer { private final int d; private final int words; - // Query precompute - private final float[] tildeQ; - private final float sumTildeQ; - private final float dotQMu; + private final float[] maskedAdds; // [blockSize] - // Scratch (one per scorer instance) - private final float[] maskedAdds; + private final float[] tildeQPool; // qp.tildeQPool [C*d] + private final float[] tildeQScratch; // [d] + private final float[] sumTildeQ; // qp.sumTildeQ [C] + private final float[] dotQMu; // qp.dotQMu [C] private final VectorUtilSupport vecUtil; BlockSimdASHBlockScorer( - AsymmetricHashing ash, AsymmetricHashing.QuantizedVector[] vectors, float[] scales, float[] offsets, + byte[] landmarks, long[] packedBits, int blockSize, - VectorFloat query) { + QueryPrecompute qp) { + + this.vecUtil = VectorizationProvider.getInstance().getVectorUtilSupport(); - this.vecUtil = - io.github.jbellis.jvector.vector.VectorizationProvider - .getInstance() - .getVectorUtilSupport(); - this.ash = ash; this.vectors = vectors; this.scales = scales; this.offsets = offsets; + this.landmarks = landmarks; + this.packedBits = packedBits; this.blockSize = blockSize; - this.d = ash.quantizedDim; + this.d = qp.d; this.words = AsymmetricHashing.QuantizedVector.wordsForDims(d); this.maskedAdds = new float[blockSize]; - // ---- Query precompute (identical to ASHScorer) ---- - final int D = ash.originalDimension; - final VectorFloat mu = ash.landmarks[0]; - - final float[] qArr = new float[D]; - final float[] muArr = new float[D]; - for (int i = 0; i < D; i++) { - qArr[i] = query.get(i); - muArr[i] = mu.get(i); - } - - this.tildeQ = new float[d]; - final float[][] A = ash.stiefelTransform.AFloat; - for (int r = 0; r < d; r++) { - float s = 0f; - float[] Arow = A[r]; - for (int j = 0; j < D; j++) { - s += Arow[j] * (qArr[j] - muArr[j]); - } - tildeQ[r] = s; - } - - float sum = 0f; - for (int i = 0; i < d; i++) sum += tildeQ[i]; - this.sumTildeQ = sum; - this.dotQMu = VectorUtil.dotProduct(query, mu); + this.tildeQPool = qp.tildeQPool; + this.tildeQScratch = new float[d]; + this.sumTildeQ = qp.sumTildeQ; + this.dotQMu = qp.dotQMu; } + // SIMD kernel assumes homogeneous landmark, caller handles boundaries @Override public void scoreRange(int start, int count, float[] out) { + if (count < 0) throw new IllegalArgumentException("count must be >= 0"); + if (start < 0 || start + count > vectors.length) { + throw new IllegalArgumentException("Range out of bounds: [" + start + ", " + (start + count) + ")"); + } + if (out.length < count) throw new IllegalArgumentException("out.length < count"); if (count == 0) return; int ord = start; - int end = start + count; + final int end = start + count; while (ord < end) { + // Identify landmark for this run + final int c = landmarks[ord] & 0xFF; + final int base = c * d; + + // Compute packed-block boundary info final int blockId = ord / blockSize; final int blockBase = blockId * blockSize; final int laneStart = ord - blockBase; - final int blockLen = Math.min(blockSize - laneStart, end - ord); + // Max we can do without crossing packed-block boundary or request end + final int maxLen = Math.min(blockSize - laneStart, end - ord); + + // Clamp further so we do NOT cross a landmark boundary inside this packed block + int runLen = 1; + while (runLen < maxLen && ((landmarks[ord + runLen] & 0xFF) == c)) { + runLen++; + } + + // Catch mixed-landmark calls for debug. TODO remove later + assert runLen == maxLen : "Caller passed mixed-landmark range into SIMD scorer"; + + final float runSumTildeQ = sumTildeQ[c]; + final float runDotQMu = dotQMu[c]; final int blockWordBase = blockId * words * blockSize; - // SIMD masked-add with register accumulation vecUtil.ashMaskedAddBlockAllWords( - tildeQ, + tildeQScratch, d, packedBits, blockWordBase, words, blockSize, laneStart, - blockLen, + runLen, maskedAdds ); - // Finish Eq. 11 per vector - for (int lane = 0; lane < blockLen; lane++) { + + for (int lane = 0; lane < runLen; lane++) { int idx = ord + lane; - float scale = scales[idx]; - float offset = offsets[idx]; float m = maskedAdds[lane]; - out[idx - start] = - scale * (2f * m - sumTildeQ) - + dotQMu - + offset; + scales[idx] * (2f * m - runSumTildeQ) + + runDotQMu + + offsets[idx]; } - ord += blockLen; + ord += runLen; } } } + private QueryPrecompute precomputeQuery(VectorFloat query) { + final int d = ash.quantizedDim; + final int D = ash.originalDimension; + final int C = ash.landmarkCount; + + final float[][] A = ash.stiefelTransform.AFloat; + + final var vecUtil = + VectorizationProvider.getInstance().getVectorUtilSupport(); + + // q materialized once + final float[] qArr = new float[D]; + for (int i = 0; i < D; i++) { + qArr[i] = query.get(i); + } + + // Aq + final float[] qProj = new float[d]; + float sumQProj = 0f; + for (int j = 0; j < d; j++) { + float v = vecUtil.ashDotRow(A[j], qArr); + qProj[j] = v; + sumQProj += v; + } + + final float[] tildeQPool = new float[C * d]; + final float[] sumTildeQ = new float[C]; + final float[] dotQMu = new float[C]; + + for (int c = 0; c < C; c++) { + dotQMu[c] = VectorUtil.dotProduct(query, ash.landmarks[c]); + sumTildeQ[c] = sumQProj - ash.landmarkProjSum[c]; + + final float[] muProj = ash.landmarkProj[c]; + final int base = c * d; + for (int j = 0; j < d; j++) { + tildeQPool[base + j] = qProj[j] - muProj[j]; + } + } + + return new QueryPrecompute(d, C, tildeQPool, sumTildeQ, dotQMu); + } + + static final class QueryPrecompute { + final int d; + final int C; + final float[] tildeQPool; // [C * d] + final float[] sumTildeQ; // [C] + final float[] dotQMu; // [C] + + QueryPrecompute(int d, int C, + float[] tildeQPool, + float[] sumTildeQ, + float[] dotQMu) { + this.d = d; + this.C = C; + this.tildeQPool = tildeQPool; + this.sumTildeQ = sumTildeQ; + this.dotQMu = dotQMu; + } + } + /** * For ASH, precomputed and non-precomputed scoring are currently identical. * diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/vector/VectorUtilSupport.java b/jvector-base/src/main/java/io/github/jbellis/jvector/vector/VectorUtilSupport.java index e40bcd862..32d1f8357 100644 --- a/jvector-base/src/main/java/io/github/jbellis/jvector/vector/VectorUtilSupport.java +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/vector/VectorUtilSupport.java @@ -306,4 +306,55 @@ default void ashMaskedAddBlockAllWords( outMaskedAdd[lane] = maskedAdd; } } + + /** + * Computes maskedAdd for a block slice using a pooled landmark-specific query buffer. + * + *

    This is identical to {@link #ashMaskedAddBlockAllWords(float[], int, long[], int, int, int, int, int, float[])} + * except that the query vector is read from {@code tildeQPool} at offsets {@code tildeBase + j}.

    + * + *

    tildeQPool layout: + *

      + *
    • {@code tildeQPool[tildeBase + j]} is q̃_c[j] for the caller-selected landmark c
    • + *
    + * + *

    Default implementation is scalar bit-walk with register accumulation, reading from the pool.

    + */ + default void ashMaskedAddBlockAllWordsPooled( + float[] tildeQPool, + int tildeBase, // base offset into tildeQPool for this landmark (e.g., c * d) + int d, + long[] packedBits, + int packedBase, // blockId * nWords * blockSize + int nWords, + int blockSize, + int laneStart, + int blockLen, + float[] outMaskedAdd + ) { + assert tildeBase >= 0; + assert laneStart >= 0; + assert blockLen >= 0; + assert laneStart + blockLen <= blockSize; + assert outMaskedAdd.length >= blockLen : "outMaskedAdd too small"; + + for (int lane = 0; lane < blockLen; lane++) { + float maskedAdd = 0f; // register accumulator + int laneIndex = laneStart + lane; + + for (int w = 0; w < nWords; w++) { + long word = packedBits[packedBase + (w * blockSize) + laneIndex]; + int baseDim = w * 64; + + while (word != 0L) { + int bit = Long.numberOfTrailingZeros(word); + int j = baseDim + bit; + if (j < d) maskedAdd += tildeQPool[tildeBase + j]; + word &= (word - 1); + } + } + + outMaskedAdd[lane] = maskedAdd; + } + } } 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 33bab6aea..d65364ea9 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 @@ -1659,6 +1659,73 @@ public void ashMaskedAddBlockAllWords(float[] tildeQ, } } + @Override + public void ashMaskedAddBlockAllWordsPooled(float[] tildeQPool, + int tildeBase, + int d, + long[] packedBits, + int blockWordBase, + int words, + int blockSize, + int laneStart, + int blockLen, + float[] outMaskedAdd) { + + final VectorSpecies SPEC = FloatVector.SPECIES_PREFERRED; + final int LANES = SPEC.length(); + + for (int lane = 0; lane < blockLen; lane++) { + FloatVector accVec = FloatVector.zero(SPEC); + float scalarAcc = 0f; + + int laneIndex = laneStart + lane; + + for (int w = 0; w < words; w++) { + long wordBits = packedBits[blockWordBase + (w * blockSize) + laneIndex]; + int baseDim = w * 64; + + for (int off = 0; off < 64 && (baseDim + off) < d; off += LANES) { + int qBase = baseDim + off; + + VectorMask inRange = SPEC.indexInRange(qBase, d); + + long maskBits; + if (LANES == 16) { + maskBits = (wordBits >>> off) & 0xFFFFL; + } else if (LANES == 8) { + maskBits = (wordBits >>> off) & 0xFFL; + } else if (LANES == 4) { + maskBits = (wordBits >>> off) & 0xFL; + } else { + long m = wordBits >>> off; + float s = 0f; + for (int b = 0; b < LANES; b++) { + if (((m >>> b) & 1L) != 0L) { + int idx = qBase + b; + if (idx < d) s += tildeQPool[tildeBase + idx]; + } + } + scalarAcc += s; + continue; + } + + if (maskBits == 0L) continue; + + VectorMask bitMask = + VectorMask.fromLong(SPEC, maskBits).and(inRange); + + // pooled masked load: note tildeBase + qBase + FloatVector masked = + FloatVector.fromArray(SPEC, tildeQPool, tildeBase + qBase, bitMask); + + accVec = accVec.add(masked); + } + } + + outMaskedAdd[lane] = accVec.reduceLanes(VectorOperators.ADD) + scalarAcc; + } + } + private static void ashMaskedAddBlockWordLutNibble(float[] tildeQ, int baseDim, int d, From d903eb604d5821de6f62926bcf47b710183e5f5f Mon Sep 17 00:00:00 2001 From: Ted Willke Date: Mon, 5 Jan 2026 18:07:05 +0000 Subject: [PATCH 021/243] Added debug instrumentation to kmeans++ clusterer. --- .../quantization/KMeansPlusPlusClusterer.java | 71 +++++++++++++++++-- 1 file changed, 67 insertions(+), 4 deletions(-) diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/KMeansPlusPlusClusterer.java b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/KMeansPlusPlusClusterer.java index 13d683327..c03acdbd3 100644 --- a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/KMeansPlusPlusClusterer.java +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/KMeansPlusPlusClusterer.java @@ -38,6 +38,13 @@ public class KMeansPlusPlusClusterer { private static final VectorTypeSupport vectorTypeSupport = VectorizationProvider.getInstance().getVectorTypeSupport(); + // Observability flags (disabled by default) + private static final boolean VERBOSE_KMEANS = + Boolean.getBoolean("jvector.kmeans.verbose"); + + private static final boolean VERBOSE_KMEANS_TIMING = + Boolean.getBoolean("jvector.kmeans.timing"); + public static final float UNWEIGHTED = -1.0f; // number of centroids to compute @@ -129,18 +136,74 @@ static float computeParallelCostMultiplier(double threshold, int dimensions) { * @return a VectorFloat of cluster centroids. */ public VectorFloat cluster(int unweightedIterations, int anisotropicIterations) { - // Always cluster unweighted first, it is significantly faster + + final int n = points.length; + final int threshold = Math.max(1, (int) (0.01 * n)); + + // ------------------------------------------------------------ + // Unweighted (isotropic) k-means phase + // ------------------------------------------------------------ for (int i = 0; i < unweightedIterations; i++) { + + long t0 = VERBOSE_KMEANS_TIMING ? System.nanoTime() : 0L; int changedCount = clusterOnceUnweighted(); - if (changedCount <= 0.01 * points.length) { + long t1 = VERBOSE_KMEANS_TIMING ? System.nanoTime() : 0L; + + if (VERBOSE_KMEANS) { + System.out.printf( + "KMeans unweighted iter %d: %d/%d reassigned (%.3f%%)%n", + i, + changedCount, + n, + 100.0 * changedCount / n + ); + } + + if (VERBOSE_KMEANS_TIMING) { + System.out.printf( + " time = %.3f ms%n", + (t1 - t0) * 1e-6 + ); + } + + if (changedCount <= threshold) { + if (VERBOSE_KMEANS) { + System.out.println("KMeans unweighted converged early"); + } break; } } - // Optionally, refine using anisotropic clustering + // ------------------------------------------------------------ + // Anisotropic refinement phase + // ------------------------------------------------------------ for (int i = 0; i < anisotropicIterations; i++) { + + long t0 = VERBOSE_KMEANS_TIMING ? System.nanoTime() : 0L; int changedCount = clusterOnceAnisotropic(); - if (changedCount <= 0.01 * points.length) { + long t1 = VERBOSE_KMEANS_TIMING ? System.nanoTime() : 0L; + + if (VERBOSE_KMEANS) { + System.out.printf( + "KMeans anisotropic iter %d: %d/%d reassigned (%.3f%%)%n", + i, + changedCount, + n, + 100.0 * changedCount / n + ); + } + + if (VERBOSE_KMEANS_TIMING) { + System.out.printf( + " time = %.3f ms%n", + (t1 - t0) * 1e-6 + ); + } + + if (changedCount <= threshold) { + if (VERBOSE_KMEANS) { + System.out.println("KMeans anisotropic converged early"); + } break; } } From db5bfbd0eafd13a757509a75b4477b2843fb5746 Mon Sep 17 00:00:00 2001 From: Ted Willke Date: Tue, 6 Jan 2026 05:25:32 +0000 Subject: [PATCH 022/243] Bug fixes in SIMD scoreRange for copying queries into contiguous scratch and mixed landmarks. --- .../jvector/quantization/ASHVectors.java | 27 +++++++++++++++++-- .../jbellis/jvector/example/DistancesASH.java | 20 +++++++------- 2 files changed, 35 insertions(+), 12 deletions(-) diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHVectors.java b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHVectors.java index 57ea8debf..db9579a0d 100644 --- a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHVectors.java +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHVectors.java @@ -470,6 +470,9 @@ private static final class BlockSimdASHBlockScorer implements ASHBlockScorer { private final VectorUtilSupport vecUtil; + private static final java.util.concurrent.atomic.AtomicBoolean PRINTED_MIXED_ONCE = + new java.util.concurrent.atomic.AtomicBoolean(false); + BlockSimdASHBlockScorer( AsymmetricHashing.QuantizedVector[] vectors, float[] scales, @@ -500,7 +503,7 @@ private static final class BlockSimdASHBlockScorer implements ASHBlockScorer { this.dotQMu = qp.dotQMu; } - // SIMD kernel assumes homogeneous landmark, caller handles boundaries + // SIMD kernel is invoked on homogeneous landmark runs; this method splits mixed ranges into runs. @Override public void scoreRange(int start, int count, float[] out) { if (count < 0) throw new IllegalArgumentException("count must be >= 0"); @@ -533,12 +536,32 @@ public void scoreRange(int start, int count, float[] out) { } // Catch mixed-landmark calls for debug. TODO remove later - assert runLen == maxLen : "Caller passed mixed-landmark range into SIMD scorer"; + if (runLen != maxLen && PRINTED_MIXED_ONCE.compareAndSet(false, true)) { + int nextLm = (ord + runLen < end) ? (landmarks[ord + runLen] & 0xFF) : -1; + System.out.println( + "\t[ASH SIMD debug] mixed-landmark boundary inside packed-block slice: " + + "start=" + start + + " count=" + count + + " ord=" + ord + + " blockId=" + blockId + + " laneStart=" + laneStart + + " runLen=" + runLen + + " maxLen=" + maxLen + + " c=" + c + + " nextC=" + nextLm + ); + } + + assert runLen >= 1 && runLen <= maxLen; + assert laneStart >= 0 && laneStart + runLen <= blockSize; final float runSumTildeQ = sumTildeQ[c]; final float runDotQMu = dotQMu[c]; final int blockWordBase = blockId * words * blockSize; + // Load q̃_c into contiguous scratch for the unpooled kernel + System.arraycopy(tildeQPool, base, tildeQScratch, 0, d); + vecUtil.ashMaskedAddBlockAllWords( tildeQScratch, d, diff --git a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/DistancesASH.java b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/DistancesASH.java index 37f0a20cf..3e5f841c7 100644 --- a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/DistancesASH.java +++ b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/DistancesASH.java @@ -64,14 +64,14 @@ public static void testASHEncodings(String filenameBase, String filenameQueries) final int HEADER_BITS = 72; // Block sizes to benchmark - final int[] BLOCK_SIZES = {64}; // 16, 32, and/or 64 + final int[] BLOCK_SIZES = {16, 32, 64}; // 16, 32, and/or 64 // How many ASH landmarks to use, C = [1, 64] - final int landmarkCount = 64; + final int landmarkCount = 1; // Define the benchmark size - int maxQueries = 10_000; - int maxVectors = 10_000_000; + int maxQueries = 1_000; + int maxVectors = 1_000_000; int queryCountInFile = SiftLoader.countFvecs(filenameQueries); int vectorCountInFile = SiftLoader.countFvecs(filenameBase); @@ -509,11 +509,11 @@ public static void main(String[] args) throws IOException { // runSIFT(); // runGIST(); // runColbert(); -// runCohere100k(); -// runADA(); -// runOpenai1536(); -// runOpenai3072(); - runCap6m(); - runCohere10m(); + runCohere100k(); + runADA(); + runOpenai1536(); + runOpenai3072(); +// runCap6m(); +// runCohere10m(); } } From 2adbce00f73cde98328f079aa01e52675d2f489d Mon Sep 17 00:00:00 2001 From: Ted Willke Date: Wed, 7 Jan 2026 02:37:17 +0000 Subject: [PATCH 023/243] Encoded vectors are now presorted by landmark for faster full dataset scoring. Also made a slight change to limit copying query info from query pool (only at landmark run boundaries) during block scoring. --- .../jvector/quantization/ASHVectors.java | 206 ++++++++++++++++-- .../jbellis/jvector/example/DistancesASH.java | 59 ++++- 2 files changed, 244 insertions(+), 21 deletions(-) diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHVectors.java b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHVectors.java index db9579a0d..488dc43a2 100644 --- a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHVectors.java +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHVectors.java @@ -112,6 +112,36 @@ public ASHVectors(AsymmetricHashing ash, } + // Private constructor that trusts prebuilt arrays (for vector landmark sorting) + private ASHVectors(AsymmetricHashing ash, + AsymmetricHashing.QuantizedVector[] compressedVectors, + float[] scales, + float[] offsets, + byte[] landmarks) { + this.ash = ash; + this.compressedVectors = compressedVectors; + this.scorer = new ASHScorer(ash); + + this.d = ash.quantizedDim; + this.words = AsymmetricHashing.QuantizedVector.wordsForDims(d); + + // Defensive sanity checks (cheap) + if (scales.length != compressedVectors.length || + offsets.length != compressedVectors.length || + landmarks.length != compressedVectors.length) { + throw new IllegalArgumentException("Header arrays must match compressedVectors length"); + } + + this.scales = scales; + this.offsets = offsets; + this.landmarks = landmarks; + + // packedBits cache starts empty (as usual) + this.packedBlockSize = -1; + this.packedBits = null; + this.packedBlockCount = 0; + } + @Override public int count() { return compressedVectors.length; @@ -468,6 +498,9 @@ private static final class BlockSimdASHBlockScorer implements ASHBlockScorer { private final float[] sumTildeQ; // qp.sumTildeQ [C] private final float[] dotQMu; // qp.dotQMu [C] + // Cache which landmark's q̃ is currently loaded into tildeQScratch + private int lastC = -1; + private final VectorUtilSupport vecUtil; private static final java.util.concurrent.atomic.AtomicBoolean PRINTED_MIXED_ONCE = @@ -536,21 +569,21 @@ public void scoreRange(int start, int count, float[] out) { } // Catch mixed-landmark calls for debug. TODO remove later - if (runLen != maxLen && PRINTED_MIXED_ONCE.compareAndSet(false, true)) { - int nextLm = (ord + runLen < end) ? (landmarks[ord + runLen] & 0xFF) : -1; - System.out.println( - "\t[ASH SIMD debug] mixed-landmark boundary inside packed-block slice: " + - "start=" + start + - " count=" + count + - " ord=" + ord + - " blockId=" + blockId + - " laneStart=" + laneStart + - " runLen=" + runLen + - " maxLen=" + maxLen + - " c=" + c + - " nextC=" + nextLm - ); - } +// if (runLen != maxLen && PRINTED_MIXED_ONCE.compareAndSet(false, true)) { +// int nextLm = (ord + runLen < end) ? (landmarks[ord + runLen] & 0xFF) : -1; +// System.out.println( +// "\t[ASH SIMD debug] mixed-landmark boundary inside packed-block slice: " + +// "start=" + start + +// " count=" + count + +// " ord=" + ord + +// " blockId=" + blockId + +// " laneStart=" + laneStart + +// " runLen=" + runLen + +// " maxLen=" + maxLen + +// " c=" + c + +// " nextC=" + nextLm +// ); +// } assert runLen >= 1 && runLen <= maxLen; assert laneStart >= 0 && laneStart + runLen <= blockSize; @@ -560,7 +593,13 @@ public void scoreRange(int start, int count, float[] out) { final int blockWordBase = blockId * words * blockSize; // Load q̃_c into contiguous scratch for the unpooled kernel + if (c != lastC) { + System.arraycopy(tildeQPool, base, tildeQScratch, 0, d); + lastC = c; + } + System.arraycopy(tildeQPool, base, tildeQScratch, 0, d); + lastC = c; vecUtil.ashMaskedAddBlockAllWords( tildeQScratch, @@ -575,6 +614,8 @@ public void scoreRange(int start, int count, float[] out) { ); + + for (int lane = 0; lane < runLen; lane++) { int idx = ord + lane; float m = maskedAdds[lane]; @@ -651,6 +692,141 @@ static final class QueryPrecompute { } } + public static final class LandmarkOrder { + public final ASHVectors vectors; + public final int[] newToOld; + public final int[] oldToNew; + public final int[] landmarkOffsets; // length C+1, last == n + + LandmarkOrder(ASHVectors vectors, int[] newToOld, int[] oldToNew, int[] landmarkOffsets) { + this.vectors = vectors; + this.newToOld = newToOld; + this.oldToNew = oldToNew; + this.landmarkOffsets = landmarkOffsets; + } + } + + /** + * Stable, linear-time reorder by landmark id (C <= 64). + * Fills reordered header arrays in the same pass (no second constructor scan). + */ + public LandmarkOrder reorderByLandmarkFast() { + final int n = compressedVectors.length; + final int C = ash.landmarkCount; + + // Count per landmark + final int[] counts = new int[C]; + for (int i = 0; i < n; i++) { + int c = this.landmarks[i] & 0xFF; + if (c >= C) { + throw new IllegalStateException("Invalid landmark id " + c + " at ordinal " + i + " (C=" + C + ")"); + } + counts[c]++; + } + + // Prefix sums: landmarkOffsets[c] is start of landmark c, length C+1 + final int[] landmarkOffsets = new int[C + 1]; + landmarkOffsets[0] = 0; + for (int c = 0; c < C; c++) { + landmarkOffsets[c + 1] = landmarkOffsets[c] + counts[c]; + } + if (landmarkOffsets[C] != n) { + throw new IllegalStateException("landmarkOffsets[C] != n: " + landmarkOffsets[C] + " != " + n); + } + + final int[] writePos = landmarkOffsets.clone(); + + final AsymmetricHashing.QuantizedVector[] newVecs = new AsymmetricHashing.QuantizedVector[n]; + final float[] newScales = new float[n]; + final float[] newOffsets = new float[n]; + final byte[] newLandmarks = new byte[n]; + final int[] newToOld = new int[n]; + + // Preserve an ordinal map so we can go back and forth + for (int oldOrd = 0; oldOrd < n; oldOrd++) { + final int c = this.landmarks[oldOrd] & 0xFF; + final int newOrd = writePos[c]++; + + newVecs[newOrd] = this.compressedVectors[oldOrd]; + newScales[newOrd] = this.scales[oldOrd]; + newOffsets[newOrd] = this.offsets[oldOrd]; + newLandmarks[newOrd] = this.landmarks[oldOrd]; + + newToOld[newOrd] = oldOrd; + } + + final int[] oldToNew = new int[n]; + for (int newOrd = 0; newOrd < n; newOrd++) { + oldToNew[newToOld[newOrd]] = newOrd; + } + + ASHVectors reordered = new ASHVectors(ash, newVecs, newScales, newOffsets, newLandmarks); + return new LandmarkOrder(reordered, newToOld, oldToNew, landmarkOffsets); + } + + public static final class LandmarkRunStats { + public final int n; + public final int runCount; + public final double avgRunLength; + public final int maxRunLength; + public final double stddevRunLength; + + LandmarkRunStats(int n, int runCount, double avgRunLength, int maxRunLength, double stddevRunLength) { + this.n = n; + this.runCount = runCount; + this.avgRunLength = avgRunLength; + this.maxRunLength = maxRunLength; + this.stddevRunLength = stddevRunLength; + } + + @Override + public String toString() { + return String.format( + java.util.Locale.ROOT, + "runs=%d, avgRun=%.2f, maxRun=%d, stddev=%.2f (n=%d)", + runCount, avgRunLength, maxRunLength, stddevRunLength, n + ); + } + } + + public LandmarkRunStats landmarkRunStats() { + return computeLandmarkRunStats(this.landmarks); + } + + public static LandmarkRunStats computeLandmarkRunStats(byte[] landmarks) { + final int n = landmarks.length; + if (n == 0) { + return new LandmarkRunStats(0, 0, 0.0, 0, 0.0); + } + + int runCount = 0; + long sum = 0; + long sumSq = 0; + int max = 0; + + int i = 0; + while (i < n) { + final byte c = landmarks[i]; + int j = i + 1; + while (j < n && landmarks[j] == c) j++; + final int len = j - i; + + runCount++; + sum += len; + sumSq += (long) len * (long) len; + if (len > max) max = len; + + i = j; + } + + final double avg = (double) sum / (double) runCount; + final double meanSq = (double) sumSq / (double) runCount; + final double var = Math.max(0.0, meanSq - avg * avg); + final double stddev = Math.sqrt(var); + + return new LandmarkRunStats(n, runCount, avg, max, stddev); + } + /** * For ASH, precomputed and non-precomputed scoring are currently identical. * diff --git a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/DistancesASH.java b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/DistancesASH.java index 3e5f841c7..49baff0c7 100644 --- a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/DistancesASH.java +++ b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/DistancesASH.java @@ -52,7 +52,7 @@ public static void testASHEncodings(String filenameBase, String filenameQueries) Boolean.parseBoolean(System.getProperty("jvector.bench.sanity-check", "false")); final boolean RUN_ACCURACY_CHECK = - Boolean.parseBoolean(System.getProperty("jvector.bench.accuracy", "true")); + Boolean.parseBoolean(System.getProperty("jvector.bench.accuracy", "false")); final boolean RUN_SCALAR_SCORING = Boolean.parseBoolean(System.getProperty("jvector.bench.scalar-scoring", "false")); @@ -171,6 +171,49 @@ public static void testASHEncodings(String filenameBase, String filenameQueries) ASHVectors ashVectors = (ASHVectors) ashVecs; VectorFloat q0 = finalQueries.get(0); + // ------------------------------------------------------------------ + // Landmark reorder (debug + timing) + // ------------------------------------------------------------------ + final boolean REORDER_BY_LANDMARK = + Boolean.parseBoolean(System.getProperty("jvector.ash.reorderByLandmark", "true")); + + final ASHVectors ashVectorsFinal; + final CompressedVectors ashVecsFinal; + final int[] newToOldFinal; + + if (REORDER_BY_LANDMARK) { + long stat0 = System.nanoTime(); + var beforeStats = ashVectors.landmarkRunStats(); + long stat1 = System.nanoTime(); + + System.out.println("\tLandmark run stats BEFORE reorder: " + beforeStats + + " (computed in " + (stat1 - stat0) / 1e9 + " s)"); + + long t0 = System.nanoTime(); + ASHVectors.LandmarkOrder order = ashVectors.reorderByLandmarkFast(); + long t1 = System.nanoTime(); + + ASHVectors reordered = order.vectors; + + long stat2 = System.nanoTime(); + var afterStats = reordered.landmarkRunStats(); + long stat3 = System.nanoTime(); + + System.out.println("\tLandmark reorder took " + (t1 - t0) / 1e9 + " s"); + System.out.println("\tLandmark run stats AFTER reorder: " + afterStats + + " (computed in " + (stat3 - stat2) / 1e9 + " s)"); + System.out.println("--"); + + ashVectorsFinal = reordered; + ashVecsFinal = reordered; // CompressedVectors view used by scoreFunctionFor + newToOldFinal = order.newToOld; // map reordered ordinal -> original base ordinal + } else { + System.out.println("\tLandmark reorder disabled (-Djvector.ash.reorderByLandmark=false)"); + ashVectorsFinal = ashVectors; + ashVecsFinal = ashVectors; + newToOldFinal = null; + } + if(RUN_SANITY_CHECK) { ASHBlockScorer ref = ashVectors.blockScorerFor(q0, VectorSimilarityFunction.DOT_PRODUCT); @@ -226,16 +269,19 @@ public static void testASHEncodings(String filenameBase, String filenameQueries) for (int i = s; i < e; i++) { VectorFloat q = finalQueries.get(i); ScoreFunction.ApproximateScoreFunction f = - ashVecs.scoreFunctionFor(q, VectorSimilarityFunction.DOT_PRODUCT); + ashVecsFinal.scoreFunctionFor(q, VectorSimilarityFunction.DOT_PRODUCT); for (int j = 0; j < finalVectorCount; j++) { - VectorFloat v = finalVectors.get(j); + final int baseOrd = (newToOldFinal == null) ? j : newToOldFinal[j]; + VectorFloat v = finalVectors.get(baseOrd); + float trueDot = VectorUtil.dotProduct(q, v); float approxDot = f.similarityTo(j); localError += Math.abs(approxDot - trueDot); localCount++; } + } return new double[]{localError, localCount}; })); @@ -348,7 +394,7 @@ public static void testASHEncodings(String filenameBase, String filenameQueries) // Print scorer implementation once per blockSize (diagnostic) { ASHBlockScorer scorer = - ashVectors.blockScorerFor( + ashVectorsFinal.blockScorerFor( finalQueries.get(0), VectorSimilarityFunction.DOT_PRODUCT, blockSize @@ -370,7 +416,7 @@ public static void testASHEncodings(String filenameBase, String filenameQueries) for (int qi = s; qi < e; qi++) { ASHBlockScorer scorer = - ashVectors.blockScorerFor( + ashVectorsFinal.blockScorerFor( finalQueries.get(qi), VectorSimilarityFunction.DOT_PRODUCT, blockSize @@ -388,10 +434,11 @@ public static void testASHEncodings(String filenameBase, String filenameQueries) // Tail uses scalar per-vector scorer (correctness-preserving) if (j < finalVectorCount) { ScoreFunction.ApproximateScoreFunction f = - ashVecs.scoreFunctionFor( + ashVecsFinal.scoreFunctionFor( finalQueries.get(qi), VectorSimilarityFunction.DOT_PRODUCT ); + for (; j < finalVectorCount; j++) { localSum += f.similarityTo(j); } From 70eb02e9a149f0865fde6afd9747cbec6e543e44 Mon Sep 17 00:00:00 2001 From: Ted Willke Date: Wed, 7 Jan 2026 02:37:17 +0000 Subject: [PATCH 024/243] Encoded vectors are now presorted by landmark for faster full dataset scoring. Also made a slight change to limit copying query info from query pool (only at landmark run boundaries) during block scoring. --- .../jvector/quantization/ASHVectors.java | 205 ++++++++++++++++-- .../jbellis/jvector/example/DistancesASH.java | 59 ++++- 2 files changed, 242 insertions(+), 22 deletions(-) diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHVectors.java b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHVectors.java index db9579a0d..0ae65178a 100644 --- a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHVectors.java +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHVectors.java @@ -112,6 +112,36 @@ public ASHVectors(AsymmetricHashing ash, } + // Private constructor that trusts prebuilt arrays (for vector landmark sorting) + private ASHVectors(AsymmetricHashing ash, + AsymmetricHashing.QuantizedVector[] compressedVectors, + float[] scales, + float[] offsets, + byte[] landmarks) { + this.ash = ash; + this.compressedVectors = compressedVectors; + this.scorer = new ASHScorer(ash); + + this.d = ash.quantizedDim; + this.words = AsymmetricHashing.QuantizedVector.wordsForDims(d); + + // Defensive sanity checks (cheap) + if (scales.length != compressedVectors.length || + offsets.length != compressedVectors.length || + landmarks.length != compressedVectors.length) { + throw new IllegalArgumentException("Header arrays must match compressedVectors length"); + } + + this.scales = scales; + this.offsets = offsets; + this.landmarks = landmarks; + + // packedBits cache starts empty (as usual) + this.packedBlockSize = -1; + this.packedBits = null; + this.packedBlockCount = 0; + } + @Override public int count() { return compressedVectors.length; @@ -468,6 +498,9 @@ private static final class BlockSimdASHBlockScorer implements ASHBlockScorer { private final float[] sumTildeQ; // qp.sumTildeQ [C] private final float[] dotQMu; // qp.dotQMu [C] + // Cache which landmark's q̃ is currently loaded into tildeQScratch + private int lastC = -1; + private final VectorUtilSupport vecUtil; private static final java.util.concurrent.atomic.AtomicBoolean PRINTED_MIXED_ONCE = @@ -536,21 +569,21 @@ public void scoreRange(int start, int count, float[] out) { } // Catch mixed-landmark calls for debug. TODO remove later - if (runLen != maxLen && PRINTED_MIXED_ONCE.compareAndSet(false, true)) { - int nextLm = (ord + runLen < end) ? (landmarks[ord + runLen] & 0xFF) : -1; - System.out.println( - "\t[ASH SIMD debug] mixed-landmark boundary inside packed-block slice: " + - "start=" + start + - " count=" + count + - " ord=" + ord + - " blockId=" + blockId + - " laneStart=" + laneStart + - " runLen=" + runLen + - " maxLen=" + maxLen + - " c=" + c + - " nextC=" + nextLm - ); - } +// if (runLen != maxLen && PRINTED_MIXED_ONCE.compareAndSet(false, true)) { +// int nextLm = (ord + runLen < end) ? (landmarks[ord + runLen] & 0xFF) : -1; +// System.out.println( +// "\t[ASH SIMD debug] mixed-landmark boundary inside packed-block slice: " + +// "start=" + start + +// " count=" + count + +// " ord=" + ord + +// " blockId=" + blockId + +// " laneStart=" + laneStart + +// " runLen=" + runLen + +// " maxLen=" + maxLen + +// " c=" + c + +// " nextC=" + nextLm +// ); +// } assert runLen >= 1 && runLen <= maxLen; assert laneStart >= 0 && laneStart + runLen <= blockSize; @@ -560,7 +593,10 @@ public void scoreRange(int start, int count, float[] out) { final int blockWordBase = blockId * words * blockSize; // Load q̃_c into contiguous scratch for the unpooled kernel - System.arraycopy(tildeQPool, base, tildeQScratch, 0, d); + if (c != lastC) { + System.arraycopy(tildeQPool, base, tildeQScratch, 0, d); + lastC = c; + } vecUtil.ashMaskedAddBlockAllWords( tildeQScratch, @@ -575,6 +611,8 @@ public void scoreRange(int start, int count, float[] out) { ); + + for (int lane = 0; lane < runLen; lane++) { int idx = ord + lane; float m = maskedAdds[lane]; @@ -651,6 +689,141 @@ static final class QueryPrecompute { } } + public static final class LandmarkOrder { + public final ASHVectors vectors; + public final int[] newToOld; + public final int[] oldToNew; + public final int[] landmarkOffsets; // length C+1, last == n + + LandmarkOrder(ASHVectors vectors, int[] newToOld, int[] oldToNew, int[] landmarkOffsets) { + this.vectors = vectors; + this.newToOld = newToOld; + this.oldToNew = oldToNew; + this.landmarkOffsets = landmarkOffsets; + } + } + + /** + * Stable, linear-time reorder by landmark id (C <= 64). + * Fills reordered header arrays in the same pass (no second constructor scan). + */ + public LandmarkOrder reorderByLandmarkFast() { + final int n = compressedVectors.length; + final int C = ash.landmarkCount; + + // Count per landmark + final int[] counts = new int[C]; + for (int i = 0; i < n; i++) { + int c = this.landmarks[i] & 0xFF; + if (c >= C) { + throw new IllegalStateException("Invalid landmark id " + c + " at ordinal " + i + " (C=" + C + ")"); + } + counts[c]++; + } + + // Prefix sums: landmarkOffsets[c] is start of landmark c, length C+1 + final int[] landmarkOffsets = new int[C + 1]; + landmarkOffsets[0] = 0; + for (int c = 0; c < C; c++) { + landmarkOffsets[c + 1] = landmarkOffsets[c] + counts[c]; + } + if (landmarkOffsets[C] != n) { + throw new IllegalStateException("landmarkOffsets[C] != n: " + landmarkOffsets[C] + " != " + n); + } + + final int[] writePos = landmarkOffsets.clone(); + + final AsymmetricHashing.QuantizedVector[] newVecs = new AsymmetricHashing.QuantizedVector[n]; + final float[] newScales = new float[n]; + final float[] newOffsets = new float[n]; + final byte[] newLandmarks = new byte[n]; + final int[] newToOld = new int[n]; + + // Preserve an ordinal map so we can go back and forth + for (int oldOrd = 0; oldOrd < n; oldOrd++) { + final int c = this.landmarks[oldOrd] & 0xFF; + final int newOrd = writePos[c]++; + + newVecs[newOrd] = this.compressedVectors[oldOrd]; + newScales[newOrd] = this.scales[oldOrd]; + newOffsets[newOrd] = this.offsets[oldOrd]; + newLandmarks[newOrd] = this.landmarks[oldOrd]; + + newToOld[newOrd] = oldOrd; + } + + final int[] oldToNew = new int[n]; + for (int newOrd = 0; newOrd < n; newOrd++) { + oldToNew[newToOld[newOrd]] = newOrd; + } + + ASHVectors reordered = new ASHVectors(ash, newVecs, newScales, newOffsets, newLandmarks); + return new LandmarkOrder(reordered, newToOld, oldToNew, landmarkOffsets); + } + + public static final class LandmarkRunStats { + public final int n; + public final int runCount; + public final double avgRunLength; + public final int maxRunLength; + public final double stddevRunLength; + + LandmarkRunStats(int n, int runCount, double avgRunLength, int maxRunLength, double stddevRunLength) { + this.n = n; + this.runCount = runCount; + this.avgRunLength = avgRunLength; + this.maxRunLength = maxRunLength; + this.stddevRunLength = stddevRunLength; + } + + @Override + public String toString() { + return String.format( + java.util.Locale.ROOT, + "runs=%d, avgRun=%.2f, maxRun=%d, stddev=%.2f (n=%d)", + runCount, avgRunLength, maxRunLength, stddevRunLength, n + ); + } + } + + public LandmarkRunStats landmarkRunStats() { + return computeLandmarkRunStats(this.landmarks); + } + + public static LandmarkRunStats computeLandmarkRunStats(byte[] landmarks) { + final int n = landmarks.length; + if (n == 0) { + return new LandmarkRunStats(0, 0, 0.0, 0, 0.0); + } + + int runCount = 0; + long sum = 0; + long sumSq = 0; + int max = 0; + + int i = 0; + while (i < n) { + final byte c = landmarks[i]; + int j = i + 1; + while (j < n && landmarks[j] == c) j++; + final int len = j - i; + + runCount++; + sum += len; + sumSq += (long) len * (long) len; + if (len > max) max = len; + + i = j; + } + + final double avg = (double) sum / (double) runCount; + final double meanSq = (double) sumSq / (double) runCount; + final double var = Math.max(0.0, meanSq - avg * avg); + final double stddev = Math.sqrt(var); + + return new LandmarkRunStats(n, runCount, avg, max, stddev); + } + /** * For ASH, precomputed and non-precomputed scoring are currently identical. * diff --git a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/DistancesASH.java b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/DistancesASH.java index 3e5f841c7..49baff0c7 100644 --- a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/DistancesASH.java +++ b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/DistancesASH.java @@ -52,7 +52,7 @@ public static void testASHEncodings(String filenameBase, String filenameQueries) Boolean.parseBoolean(System.getProperty("jvector.bench.sanity-check", "false")); final boolean RUN_ACCURACY_CHECK = - Boolean.parseBoolean(System.getProperty("jvector.bench.accuracy", "true")); + Boolean.parseBoolean(System.getProperty("jvector.bench.accuracy", "false")); final boolean RUN_SCALAR_SCORING = Boolean.parseBoolean(System.getProperty("jvector.bench.scalar-scoring", "false")); @@ -171,6 +171,49 @@ public static void testASHEncodings(String filenameBase, String filenameQueries) ASHVectors ashVectors = (ASHVectors) ashVecs; VectorFloat q0 = finalQueries.get(0); + // ------------------------------------------------------------------ + // Landmark reorder (debug + timing) + // ------------------------------------------------------------------ + final boolean REORDER_BY_LANDMARK = + Boolean.parseBoolean(System.getProperty("jvector.ash.reorderByLandmark", "true")); + + final ASHVectors ashVectorsFinal; + final CompressedVectors ashVecsFinal; + final int[] newToOldFinal; + + if (REORDER_BY_LANDMARK) { + long stat0 = System.nanoTime(); + var beforeStats = ashVectors.landmarkRunStats(); + long stat1 = System.nanoTime(); + + System.out.println("\tLandmark run stats BEFORE reorder: " + beforeStats + + " (computed in " + (stat1 - stat0) / 1e9 + " s)"); + + long t0 = System.nanoTime(); + ASHVectors.LandmarkOrder order = ashVectors.reorderByLandmarkFast(); + long t1 = System.nanoTime(); + + ASHVectors reordered = order.vectors; + + long stat2 = System.nanoTime(); + var afterStats = reordered.landmarkRunStats(); + long stat3 = System.nanoTime(); + + System.out.println("\tLandmark reorder took " + (t1 - t0) / 1e9 + " s"); + System.out.println("\tLandmark run stats AFTER reorder: " + afterStats + + " (computed in " + (stat3 - stat2) / 1e9 + " s)"); + System.out.println("--"); + + ashVectorsFinal = reordered; + ashVecsFinal = reordered; // CompressedVectors view used by scoreFunctionFor + newToOldFinal = order.newToOld; // map reordered ordinal -> original base ordinal + } else { + System.out.println("\tLandmark reorder disabled (-Djvector.ash.reorderByLandmark=false)"); + ashVectorsFinal = ashVectors; + ashVecsFinal = ashVectors; + newToOldFinal = null; + } + if(RUN_SANITY_CHECK) { ASHBlockScorer ref = ashVectors.blockScorerFor(q0, VectorSimilarityFunction.DOT_PRODUCT); @@ -226,16 +269,19 @@ public static void testASHEncodings(String filenameBase, String filenameQueries) for (int i = s; i < e; i++) { VectorFloat q = finalQueries.get(i); ScoreFunction.ApproximateScoreFunction f = - ashVecs.scoreFunctionFor(q, VectorSimilarityFunction.DOT_PRODUCT); + ashVecsFinal.scoreFunctionFor(q, VectorSimilarityFunction.DOT_PRODUCT); for (int j = 0; j < finalVectorCount; j++) { - VectorFloat v = finalVectors.get(j); + final int baseOrd = (newToOldFinal == null) ? j : newToOldFinal[j]; + VectorFloat v = finalVectors.get(baseOrd); + float trueDot = VectorUtil.dotProduct(q, v); float approxDot = f.similarityTo(j); localError += Math.abs(approxDot - trueDot); localCount++; } + } return new double[]{localError, localCount}; })); @@ -348,7 +394,7 @@ public static void testASHEncodings(String filenameBase, String filenameQueries) // Print scorer implementation once per blockSize (diagnostic) { ASHBlockScorer scorer = - ashVectors.blockScorerFor( + ashVectorsFinal.blockScorerFor( finalQueries.get(0), VectorSimilarityFunction.DOT_PRODUCT, blockSize @@ -370,7 +416,7 @@ public static void testASHEncodings(String filenameBase, String filenameQueries) for (int qi = s; qi < e; qi++) { ASHBlockScorer scorer = - ashVectors.blockScorerFor( + ashVectorsFinal.blockScorerFor( finalQueries.get(qi), VectorSimilarityFunction.DOT_PRODUCT, blockSize @@ -388,10 +434,11 @@ public static void testASHEncodings(String filenameBase, String filenameQueries) // Tail uses scalar per-vector scorer (correctness-preserving) if (j < finalVectorCount) { ScoreFunction.ApproximateScoreFunction f = - ashVecs.scoreFunctionFor( + ashVecsFinal.scoreFunctionFor( finalQueries.get(qi), VectorSimilarityFunction.DOT_PRODUCT ); + for (; j < finalVectorCount; j++) { localSum += f.similarityTo(j); } From 21ca46e7e30b1c9518ae98119c6692da72ab84ab Mon Sep 17 00:00:00 2001 From: Ted Willke Date: Thu, 8 Jan 2026 04:57:06 +0000 Subject: [PATCH 025/243] Initial implementation of LAPACK-based ITQ trainer and slow Landing trainer. Netlib dependencies added. --- jvector-base/pom.xml | 22 +- .../jvector/quantization/ASHVectors.java | 1 + .../quantization/AsymmetricHashing.java | 545 +++++++++++++++++- .../jbellis/jvector/example/DistancesASH.java | 45 +- pom.xml | 28 +- 5 files changed, 627 insertions(+), 14 deletions(-) diff --git a/jvector-base/pom.xml b/jvector-base/pom.xml index c5405dc9d..e3c12502f 100644 --- a/jvector-base/pom.xml +++ b/jvector-base/pom.xml @@ -1,5 +1,5 @@ - @@ -12,6 +12,26 @@ jvector-base Base + + + com.github.fommil.netlib + core + + + dev.ludovic.netlib + blas + + + dev.ludovic.netlib + lapack + + + dev.ludovic.netlib + lapack + 3.0.3 + + + diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHVectors.java b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHVectors.java index 0ae65178a..10e66a487 100644 --- a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHVectors.java +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHVectors.java @@ -617,6 +617,7 @@ public void scoreRange(int start, int count, float[] out) { int idx = ord + lane; float m = maskedAdds[lane]; out[idx - start] = + // scale the 0/1 stored bits to +/-1 for processing. scales[idx] * (2f * m - runSumTildeQ) + runDotQMu + offsets[idx]; diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/AsymmetricHashing.java b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/AsymmetricHashing.java index 5f253d4f8..ddd02da3c 100644 --- a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/AsymmetricHashing.java +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/AsymmetricHashing.java @@ -28,6 +28,8 @@ import io.github.jbellis.jvector.vector.types.VectorFloat; import io.github.jbellis.jvector.vector.types.VectorTypeSupport; +import dev.ludovic.netlib.blas.BLAS; +import dev.ludovic.netlib.lapack.LAPACK; import java.io.DataOutput; import java.io.IOException; import java.util.Arrays; @@ -37,6 +39,9 @@ import org.apache.commons.math3.linear.MatrixUtils; import org.apache.commons.math3.linear.QRDecomposition; import org.apache.commons.math3.linear.RealMatrix; +import org.apache.commons.math3.linear.Array2DRowRealMatrix; +import org.apache.commons.math3.linear.SingularValueDecomposition; +import org.netlib.util.intW; import static io.github.jbellis.jvector.quantization.KMeansPlusPlusClusterer.UNWEIGHTED; @@ -49,6 +54,26 @@ public class AsymmetricHashing implements VectorCompressor − ||μ||_2^2 @@ -280,13 +305,21 @@ public static AsymmetricHashing initialize(RandomAccessVectorValues ravv, } } + // Keep determinism consistent within ASH. TODO define seed constant or remove. + final Random rng = new Random(42); + StiefelTransform stiefelTransform; if (optimizer == RANDOM) { - stiefelTransform = runWithoutTraining(originalDim, quantizedDim, new Random(42)); - } else if (optimizer == ITQ) { - throw new IllegalArgumentException("ITQ optimizer not implemented yet"); - } else if (optimizer == LANDING) { - throw new IllegalArgumentException("LANDING optimizer not implemented yet"); + stiefelTransform = runWithoutTraining(originalDim, quantizedDim, rng); + } else if (optimizer == ITQ || optimizer == LANDING) { + // Build training inputs from a random sample of points. + TrainingData td = prepareTrainingData(points, landmarks, optimizer, originalDim, rng); + + if (optimizer == ITQ) { + stiefelTransform = runItqTrainer(td.xTrainNorm, quantizedDim, rng, TRAINING_ITERS); + } else { + stiefelTransform = runLandingTrainer(td.xTrainNorm, quantizedDim, rng, TRAINING_ITERS); + } } else { throw new IllegalArgumentException("Unknown optimizer " + optimizer); } @@ -642,6 +675,10 @@ public void quantizeBody(VectorFloat vector, } public static final class ItqBinaryQuantizer implements BinaryQuantizer { + // Same binarization math for all optimizers; only the learned transform differs. + // TODO consolidate quantizeBody across learners/random + private final RandomBinaryQuantizer delegate = new RandomBinaryQuantizer(); + @Override public void quantizeBody(VectorFloat vector, VectorFloat mu, @@ -649,11 +686,14 @@ public void quantizeBody(VectorFloat vector, int quantizedDim, StiefelTransform stiefel, long[] outWords) { - throw new UnsupportedOperationException("ITQ not implemented"); + delegate.quantizeBody(vector, mu, residualNorm, quantizedDim, stiefel, outWords); } } public static final class LandingBinaryQuantizer implements BinaryQuantizer { + // Same binarization math for all optimizers; only the learned transform differs. + private final RandomBinaryQuantizer delegate = new RandomBinaryQuantizer(); + @Override public void quantizeBody(VectorFloat vector, VectorFloat mu, @@ -661,7 +701,7 @@ public void quantizeBody(VectorFloat vector, int quantizedDim, StiefelTransform stiefel, long[] outWords) { - throw new UnsupportedOperationException("LANDING not implemented"); + delegate.quantizeBody(vector, mu, residualNorm, quantizedDim, stiefel, outWords); } } @@ -839,7 +879,6 @@ public int hashCode() { result = 31 * result + Arrays.hashCode(binaryVector); return result; } - } /** @@ -1149,6 +1188,491 @@ public void encodeTo(VectorFloat vector, QuantizedVector dest) { quantizeVector(vector, residualNorm, landmark, dest); } + // --------------------------------------------------------------------- + // Training of projection matrix (ITQ + LANDING algorithms) + // --------------------------------------------------------------------- + + /** + * Training inputs derived from a random sample of the dataset: + * xTrainNorm holds normalized centered residuals. + * + * NOTE: Vectors are read as floats; training matrices are accumulated/stored in double precision. + */ + private static final class TrainingData { + final double[][] xTrainNorm; // [N_train][D] + + TrainingData(double[][] xTrainNorm) { + this.xTrainNorm = xTrainNorm; + } + } + + /** + * Builds normalized centered residuals for training: + * - Choose N_train = min(N, cap) (cap differs by optimizer). + * - Sample ordinals uniformly at random (deterministic RNG). + * - For each sampled ordinal i: + * c = argmin ||x_i - mu_c||^2 (same rule as encoding) + * r = x_i - mu_c + * r_hat = r / ||r|| + * store r_hat into xTrainNorm + */ + private static TrainingData prepareTrainingData( + VectorFloat[] points, + VectorFloat[] landmarks, + int optimizer, + int originalDim, + Random rng + ) { + final int N = points.length; + final int cap = (optimizer == LANDING) ? LANDING_N_TRAIN : ITQ_N_TRAIN; + final int nTrain = Math.min(N, cap); + + final int[] sample = reservoirSampleOrdinals(N, nTrain, rng); + Arrays.sort(sample); // deterministic order for reproducibility + + final double[][] xTrainNorm = new double[nTrain][originalDim]; + + int row = 0; + int samplePos = 0; + + for (int i = 0; i < N && samplePos < nTrain; i++) { + if (sample[samplePos] != i) { + continue; + } + samplePos++; + + final VectorFloat x = points[i]; + + // Nearest landmark assignment (same semantics as encodeTo). + byte best = 0; + float bestDist = Float.POSITIVE_INFINITY; + for (int c = 0; c < landmarks.length; c++) { + float dist = VectorUtil.squareL2Distance(x, landmarks[c]); + if (dist < bestDist) { + bestDist = dist; + best = (byte) c; + } + } + + final VectorFloat mu = landmarks[best & 0xFF]; + + // Center and normalize into double precision row. + final double[] out = xTrainNorm[row++]; + + double ss = 0.0; + for (int d = 0; d < originalDim; d++) { + double v = (double) x.get(d) - (double) mu.get(d); + out[d] = v; + ss += v * v; + } + + // Normalize residuals. NOTE: Does not handle zero vectors! + double inv = 1.0 / Math.sqrt(ss); + for (int d = 0; d < originalDim; d++) { + out[d] *= inv; + } + } + + if (row != nTrain) { + throw new IllegalStateException("Training sample fill mismatch: got " + row + ", expected " + nTrain); + } + + return new TrainingData(xTrainNorm); + } + + /** + * Reservoir sample k ordinals uniformly from [0, N). + * Deterministic given rng. + */ + private static int[] reservoirSampleOrdinals(int N, int k, Random rng) { + if (k < 0 || k > N) throw new IllegalArgumentException("k out of range: " + k + " for N=" + N); + int[] r = new int[k]; + for (int i = 0; i < k; i++) r[i] = i; + for (int i = k; i < N; i++) { + int j = rng.nextInt(i + 1); + if (j < k) r[j] = i; + } + return r; + } + + /** + * ITQ trainer: + * - PCA basis from SVD(X_train_norm) + * - iterative orthogonal Procrustes updates on temp_mat + * - returns W = M_pca @ R + */ + private static StiefelTransform runItqTrainer( + double[][] xHdNorm, // [N][D] normalized residuals + int quantizedDim, // d + Random rng, + int nTrainingIterations + ) { + logProgress("\t[stage] Starting runITQTrainer..."); + + final int N = xHdNorm.length; + final int D = xHdNorm[0].length; + final int d = quantizedDim; + + // PCA basis M = V[:, :d] where X = U S V^T + RealMatrix X = new Array2DRowRealMatrix(xHdNorm, false); // [N×D] + logProgress("\t[stage] Starting Native SVD for PCA..."); + // We only need V, so we tell LAPACK to skip U to save massive amounts of time/memory + RealMatrix V = computeNativeV(X); + logProgress("\t[stage] Completed Native SVD..."); + RealMatrix M = V.getSubMatrix(0, D - 1, 0, d - 1); // [D×d] + + // Early stopping based on stabilized binary codes (subject to max iterations) + RealMatrix prevXbin = null; + int stable = 0; + final double STOP_FRAC = 4e-3; // stop when <0.4% bits flip TODO setting + final int STOP_PATIENCE = 3; // for 3 consecutive epochs TODO setting + + // X_norm = X_hd_norm @ M (N x d) + RealMatrix Xnorm = nativeMultiply(X, M); // [N×d] + + // temp_mat ~ N(0,1) in R^{d×d}. Initial random rotation. + RealMatrix temp = gaussianMatrix(d, d, rng); + + // Loop: epoch 0..iters inclusive; last epoch computes R but does not update temp_mat. + RealMatrix R = null; + logProgress("\t[stage] ITQ training iterations started..."); + for (int epoch = 0; epoch <= nTrainingIterations; epoch++) { + R = orthogonalize(temp); // R = U @ V^T + + if (epoch < nTrainingIterations) { + // Use native multiplication for Xtr = Xnorm @ R + RealMatrix Xtr = nativeMultiply(Xnorm, R); // [N×d] + RealMatrix Xbin = sign01(Xtr); // [N×d] in {+1,-1} + + if (prevXbin != null) { + long changed = 0; + long total = (long) N * (long) d; + for (int i = 0; i < N; i++) { + for (int j = 0; j < d; j++) { + if (Xbin.getEntry(i, j) != prevXbin.getEntry(i, j)) changed++; + } + } + double frac = (double) changed / (double) total; + + logProgress(String.format(java.util.Locale.ROOT, + "\titeration %d/%d - fracBitsChanged=%.6g (stable=%d/%d)", + epoch, nTrainingIterations, frac, stable, STOP_PATIENCE)); + + if (frac < STOP_FRAC) { + if (++stable >= STOP_PATIENCE) break; + } else { + stable = 0; + } + } + + // Optimized temp = Xnorm.T @ Xbin + // This avoids the physical transpose of the large Xnorm matrix + prevXbin = Xbin; + temp = nativeMultiplyTransposedA(Xnorm, Xbin); // [d×d] + } + } + logProgress("\t[stage] ITQ training completed."); + + // W = M @ R (shape [D×d]) + // Use native multiply for the final transform matrix + RealMatrix W = nativeMultiply(M, R); + return new StiefelTransform(W); + } + + /** + * LANDING trainer TODO incomplete + * + * NOTE: This is a DRAFT (not hardened) implementation using RealMatrix operations. + * We can SIMD/block-optimize the hot math once correctness is validated. + */ + private static StiefelTransform runLandingTrainer( + double[][] xHdNorm, // [N][D] normalized residuals + int quantizedDim, // d + Random rng, + int nTrainingIterations + ) { + final int N = xHdNorm.length; + final int D = xHdNorm[0].length; + final int d = quantizedDim; + + RealMatrix X = new Array2DRowRealMatrix(xHdNorm, false); // [N×D] + + // PCA basis M_pca = V[:, :d] + SingularValueDecomposition svdX = new SingularValueDecomposition(X); + RealMatrix V = svdX.getV(); + RealMatrix M_pca = V.getSubMatrix(0, D - 1, 0, d - 1); // [D×d] + + // Init: + // temp(d×d) -> Q = U V^T + // A = Q @ M_pca^T (d×D) + // W = A^T (D×d) + RealMatrix Q = orthogonalize(gaussianMatrix(d, d, rng)); + RealMatrix A = Q.multiply(M_pca.transpose()); // [d×D] + RealMatrix W = A.transpose(); // [D×d] + + final RealMatrix I_D = MatrixUtils.createRealIdentityMatrix(D); + final double invSqrtD = 1.0 / Math.sqrt(d); + + int[] idx = new int[N]; + for (int i = 0; i < N; i++) idx[i] = i; + + for (int epoch = 0; epoch < nTrainingIterations; epoch++) { + shuffleInPlace(idx, rng); + + for (int off = 0; off < N; off += LANDING_BATCH_SIZE) { + final int end = Math.min(N, off + LANDING_BATCH_SIZE); + final int B = end - off; + + // Build batch matrix X_batch (B×D) + double[][] xb = new double[B][D]; + for (int bi = 0; bi < B; bi++) { + xb[bi] = xHdNorm[idx[off + bi]]; + } + RealMatrix Xb = new Array2DRowRealMatrix(xb, false); + + // X_sign = sign(Xb @ W) / sqrt(d) + RealMatrix Xproj = Xb.multiply(W); // [B×d] + RealMatrix Xsign = sign01(Xproj).scalarMultiply(invSqrtD);// [B×d] + + // gradA = (X_sign^T X_sign) A - (X_sign^T X_batch) + RealMatrix XtX = Xsign.transpose().multiply(Xsign); // [d×d] + RealMatrix XtB = Xsign.transpose().multiply(Xb); // [d×D] + RealMatrix gradA = XtX.multiply(A).subtract(XtB); // [d×D] + + // gradW = (X_batch^T X_batch) ( W (A A^T) - A^T ) + RealMatrix G = Xb.transpose().multiply(Xb); // [D×D] + RealMatrix AAT = A.multiply(A.transpose()); // [d×d] + RealMatrix term = W.multiply(AAT).subtract(A.transpose());// [D×d] + RealMatrix gradW = G.multiply(term); // [D×d] + + double lr = 0.1 * Math.pow(0.98, epoch); + double lambda = 1.0; + + // skew = 0.5 * (A^T gradA - (A^T gradA)^T) + RealMatrix Mmat = A.transpose().multiply(gradA); // [D×D] + RealMatrix skew = Mmat.subtract(Mmat.transpose()).scalarMultiply(0.5); + + // A -= lr * A @ ( lambda*(A^T A - I) + skew ) + RealMatrix ortho = A.transpose().multiply(A).subtract(I_D); + RealMatrix updateCore = ortho.scalarMultiply(lambda).add(skew); + A = A.subtract(A.multiply(updateCore).scalarMultiply(lr)); + + // W -= lr * gradW + W = W.subtract(gradW.scalarMultiply(lr)); + } + } + + return new StiefelTransform(W); + } + + private static void shuffleInPlace(int[] a, Random rng) { + for (int i = a.length - 1; i > 0; i--) { + int j = rng.nextInt(i + 1); + int tmp = a[i]; + a[i] = a[j]; + a[j] = tmp; + } + } + + private static RealMatrix gaussianMatrix(int rows, int cols, Random rng) { + double[][] m = new double[rows][cols]; + for (int i = 0; i < rows; i++) { + double[] r = m[i]; + for (int j = 0; j < cols; j++) { + r[j] = rng.nextGaussian(); + } + } + return new Array2DRowRealMatrix(m, false); + } + + /** Orthonormal factor: U @ V^T from SVD(M). */ +// private static RealMatrix orthogonalize(RealMatrix m) { +// SingularValueDecomposition svd = new SingularValueDecomposition(m); +// return svd.getU().multiply(svd.getVT()); +// } + + // Basic dgemm routine. + private static RealMatrix nativeMultiply(RealMatrix A, RealMatrix B) { + int m = A.getRowDimension(); + int n = B.getColumnDimension(); + int k = A.getColumnDimension(); // Must match B's row dimension + + // 1. Convert to 1D Column-Major for BLAS + double[] aArr = serializeColumnMajor(A); + double[] bArr = serializeColumnMajor(B); + double[] cArr = new double[m * n]; + + // 2. Call Native DGEMM: C = alpha*A*B + beta*C + // "N", "N" means 'No Transpose' for both A and B + BLAS.getInstance().dgemm( + "N", "N", + m, n, k, + 1.0, // alpha + aArr, m, // A and its leading dimension + bArr, k, // B and its leading dimension + 0.0, // beta (ignores existing values in cArr) + cArr, m // C and its leading dimension + ); + + // 3. Convert back to RealMatrix + return new Array2DRowRealMatrix(deserializeColumnMajor(cArr, m, n), false); + } + + // Uses the dgesvd routine but tells LAPACK to skip the U matrix (for PCA). Saves time.... + private static RealMatrix computeNativeV(RealMatrix x) { + int rows = x.getRowDimension(); + int cols = x.getColumnDimension(); + double[] a = serializeColumnMajor(x); + + double[] s = new double[Math.min(rows, cols)]; + double[] vt = new double[cols * cols]; // Right singular vectors + intW info = new intW(0); + + // Query optimal workspace size (standard LAPACK practice) + double[] workQuery = new double[1]; + LAPACK.getInstance().dgesvd("N", "A", rows, cols, a, rows, s, null, 1, vt, cols, workQuery, -1, info); + + int lwork = (int) workQuery[0]; + double[] work = new double[lwork]; + + // Actual call: "N" = No U matrix, "A" = All columns of V^T + LAPACK.getInstance().dgesvd("N", "A", rows, cols, a, rows, s, null, 1, vt, cols, work, lwork, info); + + if (info.val != 0) { + throw new RuntimeException("Native PCA SVD failed with info=" + info.val); + } + + // Convert VT (column-major) back to RealMatrix + return new Array2DRowRealMatrix(deserializeColumnMajor(vt, cols, cols), false); + } + + /** Computes A^T * B using Native BLAS dgemm */ + private static RealMatrix nativeMultiplyTransposedA(RealMatrix A, RealMatrix B) { + int m = A.getColumnDimension(); // A.T has rows = A.cols + int n = B.getColumnDimension(); + int k = A.getRowDimension(); // A.T has cols = A.rows + + double[] aArr = serializeColumnMajor(A); + double[] bArr = serializeColumnMajor(B); + double[] cArr = new double[m * n]; + + // "T" = Transpose Matrix A, "N" = No Transpose Matrix B + BLAS.getInstance().dgemm( + "T", "N", + m, n, k, + 1.0, + aArr, k, // Leading dimension is k because A is currently [k x m] + bArr, k, + 0.0, + cArr, m + ); + + return new Array2DRowRealMatrix(deserializeColumnMajor(cArr, m, n), false); + } + + private static double calculateMSE(RealMatrix a, RealMatrix b) { + double sumSq = 0; + int rows = a.getRowDimension(); + int cols = a.getColumnDimension(); + for (int i = 0; i < rows; i++) { + for (int j = 0; j < cols; j++) { + double diff = a.getEntry(i, j) - b.getEntry(i, j); + sumSq += diff * diff; + } + } + return sumSq / (rows * cols); + } + + private static RealMatrix orthogonalize(RealMatrix m) { + int rows = m.getRowDimension(); + int cols = m.getColumnDimension(); + + // Convert to column-major for Native LAPACK + double[] a = serializeColumnMajor(m); + + // Prepare output buffers + double[] u = new double[rows * rows]; + double[] vt = new double[cols * cols]; + double[] s = new double[Math.min(rows, cols)]; + + // int wrapper required.... + intW info = new intW(0); + + // LAPACK Workspace (Required for dgesvd): Pass -1 for lwork and a 1-element array for work + double[] workQuery = new double[1]; + LAPACK.getInstance().dgesvd( + "A", "A", rows, cols, a, rows, s, u, rows, vt, cols, + workQuery, -1, info + ); + + // Extract the optimal lwork and allocate the real workspace + int lwork = (int) workQuery[0]; + double[] work = new double[lwork]; + + // Native SVD call + LAPACK.getInstance().dgesvd( + "A", "A", // Compute all columns of U and all rows of VT + rows, cols, // Matrix dimensions + a, rows, // Input matrix and its leading dimension + s, // Output singular values + u, rows, // Output U and its leading dimension + vt, cols, // Output VT and its leading dimension + work, lwork, // Workspace + info // Success/Fail code + ); + + if (info.val != 0) { + throw new RuntimeException("LAPACK dgesvd failed with info=" + info.val); + } + + // Convert back to Commons Math + RealMatrix U = new Array2DRowRealMatrix(deserializeColumnMajor(u, rows, rows), false); + RealMatrix VT = new Array2DRowRealMatrix(deserializeColumnMajor(vt, cols, cols), false); + + return U.multiply(VT); + } + + /** Converts an Apache Commons RealMatrix to a column-major 1D array for LAPACK. */ + private static double[] serializeColumnMajor(RealMatrix m) { + int rows = m.getRowDimension(); + int cols = m.getColumnDimension(); + double[] data = new double[rows * cols]; + for (int c = 0; c < cols; c++) { + for (int r = 0; r < rows; r++) { + data[c * rows + r] = m.getEntry(r, c); + } + } + return data; + } + + /** Converts a column-major 1D array back into a row-major double[][] for Commons Math. */ + private static double[][] deserializeColumnMajor(double[] data, int rows, int cols) { + double[][] matrix = new double[rows][cols]; + for (int c = 0; c < cols; c++) { + for (int r = 0; r < rows; r++) { + matrix[r][c] = data[c * rows + r]; + } + } + return matrix; + } + + /** + * Elementwise sign producing +/-1 (no zeros), consistent with ASH encoding convention: + * bit=1 iff projection > 0, else bit=0. + */ + private static RealMatrix sign01(RealMatrix m) { + final int r = m.getRowDimension(); + final int c = m.getColumnDimension(); + double[][] out = new double[r][c]; + for (int i = 0; i < r; i++) { + double[] row = out[i]; + for (int j = 0; j < c; j++) { + row[j] = (m.getEntry(i, j) > 0.0) ? 1.0 : -1.0; + } + } + return new Array2DRowRealMatrix(out, false); + } + @Override public int compressorSize() { // Must exactly match write(out, version) @@ -1379,6 +1903,11 @@ private static int landmarksHash(VectorFloat[] a) { return h; } + private static void logProgress(String msg) { + System.out.println(msg); + System.out.flush(); + } + @Override public String toString() { String optimizerName; diff --git a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/DistancesASH.java b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/DistancesASH.java index 49baff0c7..a75cc68ab 100644 --- a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/DistancesASH.java +++ b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/DistancesASH.java @@ -44,6 +44,30 @@ private static void printScorerInfo(String label, ASHBlockScorer scorer) { ); } + private static void logProgress(String msg) { + System.out.println(msg); + System.out.flush(); + } + + private static int parseOptimizerFromProperty() { + String opt = System.getProperty("jvector.ash.optimizer", "random").trim().toLowerCase(); + + if ("random".equals(opt)) return AsymmetricHashing.RANDOM; + if ("itq".equals(opt)) return AsymmetricHashing.ITQ; + if ("landing".equals(opt)) return AsymmetricHashing.LANDING; + + throw new IllegalArgumentException( + "Unknown jvector.ash.optimizer=" + opt + " (expected random|itq|landing)" + ); + } + + private static String optimizerName(int optimizer) { + if (optimizer == AsymmetricHashing.RANDOM) return "RANDOM"; + if (optimizer == AsymmetricHashing.ITQ) return "ITQ"; + if (optimizer == AsymmetricHashing.LANDING) return "LANDING"; + return "UNKNOWN(" + optimizer + ")"; + } + public static void testASHEncodings(String filenameBase, String filenameQueries) throws IOException { // ------------------------------------------------------------ // Benchmark configuration (runtime flags) @@ -52,7 +76,7 @@ public static void testASHEncodings(String filenameBase, String filenameQueries) Boolean.parseBoolean(System.getProperty("jvector.bench.sanity-check", "false")); final boolean RUN_ACCURACY_CHECK = - Boolean.parseBoolean(System.getProperty("jvector.bench.accuracy", "false")); + Boolean.parseBoolean(System.getProperty("jvector.bench.accuracy", "true")); final boolean RUN_SCALAR_SCORING = Boolean.parseBoolean(System.getProperty("jvector.bench.scalar-scoring", "false")); @@ -149,15 +173,28 @@ public static void testASHEncodings(String filenameBase, String filenameQueries) vectors.size(), queries.size(), dimension); // ------------------------------------------------------------------ - // Build ASH + // Build ASH (centroids + optional training), then encode // ------------------------------------------------------------------ var ravv = new ListRandomAccessVectorValues(vectors, dimension); - var ash = AsymmetricHashing.initialize(ravv, AsymmetricHashing.RANDOM, encodedBits, landmarkCount); + // Choose optimizer at runtime: + // -Djvector.ash.optimizer=random|itq|landing + final int optimizer = parseOptimizerFromProperty(); + + logProgress("\tASH optimizer = " + optimizerName(optimizer)); + + logProgress("\t[stage] ASH initialize: starting (centroids + training)"); + long initStart = System.nanoTime(); + var ash = AsymmetricHashing.initialize(ravv, optimizer, encodedBits, landmarkCount); + long initEnd = System.nanoTime(); + logProgress("\t[stage] ASH initialize: done in " + (initEnd - initStart) / 1e9 + " seconds"); + + logProgress("\t[stage] ASH encodeAll: starting"); long startTime = System.nanoTime(); CompressedVectors ashVecs = ash.encodeAll(ravv); long endTime = System.nanoTime(); - System.out.println("\tEncoding took " + (endTime - startTime) / 1e9 + " seconds"); + logProgress("\t[stage] ASH encodeAll: done in " + (endTime - startTime) / 1e9 + " seconds"); + double encSeconds = (endTime - startTime) / 1e9; double encThroughput = finalVectorCount / encSeconds; diff --git a/pom.xml b/pom.xml index 7cca5b378..034459823 100644 --- a/pom.xml +++ b/pom.xml @@ -1,4 +1,4 @@ - 4.0.0 @@ -42,6 +42,7 @@ UTF-8 true 4.0.0-rc.7-SNAPSHOT + 3.0.3 jvector-base @@ -198,6 +199,31 @@ + + com.github.fommil.netlib + core + 1.1.2 + + + dev.ludovic.netlib + netlib + ${ludovic.netlib.version} + + + dev.ludovic.netlib + blas + ${ludovic.netlib.version} + + + dev.ludovic.netlib + lapack + ${ludovic.netlib.version} + + + dev.ludovic.netlib + arpack + ${ludovic.netlib.version} + org.junit.jupiter junit-jupiter-engine From 6b738859abaff1bb407eb38c867e671148619e98 Mon Sep 17 00:00:00 2001 From: Ted Willke Date: Thu, 8 Jan 2026 20:27:19 +0000 Subject: [PATCH 026/243] Added k-recall@R computations to DistancesASH and added a truncated ivecs loader to SiftLoader. --- .../quantization/AsymmetricHashing.java | 2 +- .../jbellis/jvector/example/DistancesASH.java | 133 +++++++++++++++--- .../jvector/example/util/SiftLoader.java | 42 ++++++ 3 files changed, 153 insertions(+), 24 deletions(-) diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/AsymmetricHashing.java b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/AsymmetricHashing.java index ddd02da3c..652f42aad 100644 --- a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/AsymmetricHashing.java +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/AsymmetricHashing.java @@ -72,7 +72,7 @@ public class AsymmetricHashing implements VectorCompressor> vectors = SiftLoader.readFvecs(filenameBase, nVectors); List> queries = SiftLoader.readFvecs(filenameQueries, nQueries); + List groundTruth = SiftLoader.readIvecsAsArrays(filenameGT, nQueries); // ------------------------------------------------------------------ // Remove zero-norm vectors (undefined for ASH / Eq. 11) // ------------------------------------------------------------------ + int beforeBase = vectors.size(); - List> filteredBase = new ArrayList<>(vectors.size()); - for (VectorFloat v : vectors) { + + // Maps original index to filtered index. Value is -1 if vector was removed. + int[] oldToNew = new int[beforeBase]; + java.util.Arrays.fill(oldToNew, -1); + + List> filteredBase = new ArrayList<>(beforeBase); + for (int i = 0; i < beforeBase; i++) { + VectorFloat v = vectors.get(i); if (VectorUtil.dotProduct(v, v) > 0f) { + oldToNew[i] = filteredBase.size(); filteredBase.add(v); } } @@ -149,7 +164,7 @@ public static void testASHEncodings(String filenameBase, String filenameQueries) for (VectorFloat q : queries) VectorUtil.l2normalize(q); int dimension = vectors.get(0).length(); - int encodedBits = (dimension / 4) + HEADER_BITS; + int encodedBits = (dimension / 4) + HEADER_BITS - 64; // (dimension / 4) + HEADER_BITS; // Payload must be 64-bit aligned for SIMD int payloadBits = encodedBits - HEADER_BITS; if ((payloadBits & 63) != 0) { @@ -337,6 +352,69 @@ public static void testASHEncodings(String filenameBase, String filenameQueries) System.out.println("\tAverage absolute dot-product error = " + distanceError); } + // ================================================================== + // [1b] Recall@K run (Parallelized) + // ================================================================== + if (RUN_RECALL_CHECK) { + int[] atValues = {10, 15, 20, 30, 40, 50}; + int maxAt = 50; + List> recallTasks = new ArrayList<>(); + + logProgress("\t[stage] Computing " + RECALL_K + "-Recall@K..."); + for (int start = 0; start < finalQueryCount; start += chunkSize) { + final int s = start; + final int e = Math.min(start + chunkSize, finalQueryCount); + + recallTasks.add(simdExecutor.submit(() -> { + double[] localTotalRecall = new double[atValues.length]; + for (int i = s; i < e; i++) { + VectorFloat q = finalQueries.get(i); + ScoreFunction.ApproximateScoreFunction f = ashVecsFinal.scoreFunctionFor(q, VectorSimilarityFunction.DOT_PRODUCT); + + // Min-heap to keep top 50 results (storing score as int bits and ordinal as long) + var topCandidates = new java.util.PriorityQueue((a, b) -> Float.compare(Float.intBitsToFloat((int) a[0]), Float.intBitsToFloat((int) b[0]))); + for (int j = 0; j < finalVectorCount; j++) { + float score = f.similarityTo(j); + if (topCandidates.size() < maxAt) { + topCandidates.add(new long[]{Float.floatToRawIntBits(score), j}); + } else if (score > Float.intBitsToFloat((int) topCandidates.peek()[0])) { + topCandidates.poll(); + topCandidates.add(new long[]{Float.floatToRawIntBits(score), j}); + } + } + + int[] topIndices = new int[topCandidates.size()]; + for (int rank = topCandidates.size() - 1; rank >= 0; rank--) topIndices[rank] = (int) topCandidates.poll()[1]; + + int[] queryGT = groundTruth.get(i); + for (int aIdx = 0; aIdx < atValues.length; aIdx++) { + int at = atValues[aIdx]; + java.util.Set topAtSet = new java.util.HashSet<>(); + for (int r = 0; r < Math.min(at, topIndices.length); r++) { + topAtSet.add((newToOldFinal == null) ? topIndices[r] : newToOldFinal[topIndices[r]]); + } + int matches = 0; + for (int g = 0; g < RECALL_K; g++) { + if (topAtSet.contains(queryGT[g])) matches++; + } + localTotalRecall[aIdx] += (double) matches / RECALL_K; + } + } + return localTotalRecall; + })); + } + + double[] totalRecall = new double[atValues.length]; + for (ForkJoinTask t : recallTasks) { + double[] local = t.join(); + for (int aIdx = 0; aIdx < atValues.length; aIdx++) totalRecall[aIdx] += local[aIdx]; + } + + for (int aIdx = 0; aIdx < atValues.length; aIdx++) { + System.out.format("\tASH %d-recall@%d = %.4f%n", RECALL_K, atValues[aIdx], totalRecall[aIdx] / finalQueryCount); + } + } + // ================================================================== // [2] Scalar ASH scoring timing (reference) // ================================================================== @@ -522,7 +600,8 @@ public static void runSIFT() throws IOException { var baseVectors = "siftsmall/siftsmall_base.fvecs"; var queryVectors = "siftsmall/siftsmall_query.fvecs"; - testASHEncodings(baseVectors, queryVectors); + var gtVectors = ""; + testASHEncodings(baseVectors, queryVectors, gtVectors); } public static void runGIST() throws IOException { @@ -530,7 +609,8 @@ public static void runGIST() throws IOException { var baseVectors = "./fvec/gist/gist_base.fvecs"; var queryVectors = "./fvec/gist/gist_query.fvecs"; - testASHEncodings(baseVectors, queryVectors); + var gtVectors = ""; + testASHEncodings(baseVectors, queryVectors, gtVectors); } public static void runCohere100k() throws IOException { @@ -538,7 +618,8 @@ public static void runCohere100k() throws IOException { var baseVectors = "./fvec/cohere-100k/cohere_embed-english-v3.0_1024_base_vectors_100000.fvec"; var queryVectors = "./fvec/cohere-100k/cohere_embed-english-v3.0_1024_query_vectors_10000.fvec"; - testASHEncodings(baseVectors, queryVectors); + var gtVectors = "./fvec/cohere-100k/cohere_embed-english-v3.0_1024_indices_b100000_q10000_k100.ivec"; + testASHEncodings(baseVectors, queryVectors, gtVectors); } public static void runADA() throws IOException { @@ -546,7 +627,8 @@ public static void runADA() throws IOException { var baseVectors = "./fvec/ada-002/ada_002_100000_base_vectors.fvec"; var queryVectors = "./fvec/ada-002/ada_002_100000_query_vectors_10000.fvec"; - testASHEncodings(baseVectors, queryVectors); + var gtVectors = "./fvec/ada-002/ada_002_100000_indices_query_10000.ivec"; + testASHEncodings(baseVectors, queryVectors, gtVectors); } public static void runColbert() throws IOException { @@ -554,7 +636,8 @@ public static void runColbert() throws IOException { var baseVectors = "./fvec/wikipedia_squad/1M/colbertv2.0_128_base_vectors_1000000.fvec"; var queryVectors = "./fvec/wikipedia_squad/1M/colbertv2.0_128_query_vectors_100000.fvec"; - testASHEncodings(baseVectors, queryVectors); + var gtVectors = ""; + testASHEncodings(baseVectors, queryVectors, gtVectors); } public static void runOpenai1536() throws IOException { @@ -562,7 +645,8 @@ public static void runOpenai1536() throws IOException { var baseVectors = "./fvec/openai-v3-large-1536-100k/text-embedding-3-large_1536_100000_base_vectors.fvec"; var queryVectors = "./fvec/openai-v3-large-1536-100k/text-embedding-3-large_1536_100000_query_vectors_10000.fvec"; - testASHEncodings(baseVectors, queryVectors); + var gtVectors = "./fvec/openai-v3-large-1536-100k/text-embedding-3-large_1536_100000_indices_query_10000.ivec"; + testASHEncodings(baseVectors, queryVectors, gtVectors); } public static void runOpenai3072() throws IOException { @@ -570,7 +654,8 @@ public static void runOpenai3072() throws IOException { var baseVectors = "./fvec/openai-v3-large-3072-100k/text-embedding-3-large_3072_100000_base_vectors.fvec"; var queryVectors = "./fvec/openai-v3-large-3072-100k/text-embedding-3-large_3072_100000_query_vectors_10000.fvec"; - testASHEncodings(baseVectors, queryVectors); + var gtVectors = "./fvec/openai-v3-large-3072-100k/text-embedding-3-large_3072_100000_indices_query_10000.ivec"; + testASHEncodings(baseVectors, queryVectors, gtVectors); } public static void runCap6m() throws IOException { @@ -578,7 +663,8 @@ public static void runCap6m() throws IOException { var baseVectors = "./fvec/cap-6m/Caselaw_gte-Qwen2-1.5B_embeddings_base_6m_norm_shuffle.fvecs"; var queryVectors = "./fvec/cap-6m/Caselaw_gte-Qwen2-1.5B_embeddings_query_10k_norm_shuffle.fvecs"; - testASHEncodings(baseVectors, queryVectors); + var gtVectors = "./fvec/cap-6m/cap_6m_gt_norm_shuffle_ip_k100.ivecs"; + testASHEncodings(baseVectors, queryVectors, gtVectors); } public static void runCohere10m() throws IOException { @@ -586,18 +672,19 @@ public static void runCohere10m() throws IOException { var baseVectors = "./fvec/cohere-10m/cohere_wiki_en_flat_base_10m_norm.fvecs"; var queryVectors = "./fvec/cohere-10m/cohere_wiki_en_flat_query_10k_norm.fvecs"; - testASHEncodings(baseVectors, queryVectors); + var gtVectors = "./fvec/cohere-10m/cohere_wiki_en_flat_gt_10m_ip_k100.ivecs"; + testASHEncodings(baseVectors, queryVectors, gtVectors); } public static void main(String[] args) throws IOException { // runSIFT(); // runGIST(); // runColbert(); - runCohere100k(); - runADA(); - runOpenai1536(); - runOpenai3072(); -// runCap6m(); -// runCohere10m(); +// runCohere100k(); +// runADA(); +// runOpenai1536(); +// runOpenai3072(); + runCap6m(); + runCohere10m(); } } diff --git a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/util/SiftLoader.java b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/util/SiftLoader.java index cccfc1cea..f68ae340d 100644 --- a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/util/SiftLoader.java +++ b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/util/SiftLoader.java @@ -145,6 +145,48 @@ public static List> readFvecs(String filePath, int numVectors) return vectors; } + /** + * Read all ground-truth neighbors from an .ivecs file as a list of primitive arrays. + */ + public static List readIvecsAsArrays(String filename) { + try { + return readIvecsAsArrays(filename, Integer.MAX_VALUE); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + } + + /** + * Read at most {@code numVectors} from an .ivecs file as a list of primitive arrays. + */ + public static List readIvecsAsArrays(String filePath, int numVectors) throws IOException { + if (numVectors <= 0) { + throw new IllegalArgumentException("numVectors must be > 0, got " + numVectors); + } + + var groundTruth = new ArrayList(Math.min(numVectors, 1024)); + + try (var dis = new DataInputStream(new BufferedInputStream(new FileInputStream(filePath)))) { + int count = 0; + while (count < numVectors && dis.available() > 0) { + int k = Integer.reverseBytes(dis.readInt()); + int[] neighbors = new int[k]; + for (int i = 0; i < k; i++) { + neighbors[i] = Integer.reverseBytes(dis.readInt()); + } + groundTruth.add(neighbors); + count++; + } + } + + if (numVectors != Integer.MAX_VALUE && groundTruth.size() != numVectors) { + throw new IOException(String.format("File %s ended early: requested %d, read %d", + filePath, numVectors, groundTruth.size())); + } + + return groundTruth; + } + /** * Read all ground-truth neighbors from an .ivecs file. */ From 1582dbff94e937c088f0158d26a4475088e5adde Mon Sep 17 00:00:00 2001 From: Ted Willke Date: Thu, 8 Jan 2026 22:01:13 +0000 Subject: [PATCH 027/243] Reduce allocations in quantizeBody by using a workspace for several variables. --- .../quantization/AsymmetricHashing.java | 33 ++++++++++++++++--- 1 file changed, 28 insertions(+), 5 deletions(-) diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/AsymmetricHashing.java b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/AsymmetricHashing.java index 652f42aad..6e3b1cb48 100644 --- a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/AsymmetricHashing.java +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/AsymmetricHashing.java @@ -621,6 +621,23 @@ void quantizeBody(VectorFloat vector, } public static final class RandomBinaryQuantizer implements BinaryQuantizer { + // ThreadLocal to hold the reusable buffers. + // Using a simple container class to avoid multiple ThreadLocal lookups. + private static final ThreadLocal WORKSPACE = ThreadLocal.withInitial(Workspace::new); + + private static final class Workspace { + float[] x; + float[] muArr; + float[] xhat; + + void ensureCapacity(int dim) { + if (x == null || x.length < dim) { + x = new float[dim]; + muArr = new float[dim]; + xhat = new float[dim]; + } + } + } @Override public void quantizeBody(VectorFloat vector, @@ -633,14 +650,21 @@ public void quantizeBody(VectorFloat vector, final float[][] A = stiefel.AFloat; final int originalDim = stiefel.cols; + // Get workspace for this thread and ensure correct sizing (originalDim may have changed) + Workspace ws = WORKSPACE.get(); + ws.ensureCapacity(originalDim); + + // Alias for readability (no new allocations) + final float[] x = ws.x; + final float[] muArr = ws.muArr; + final float[] xhat = ws.xhat; + final var vecUtil = io.github.jbellis.jvector.vector.VectorizationProvider .getInstance() .getVectorUtilSupport(); - // Copy vector and mean once (no virtual calls in inner loop) - final float[] x = new float[originalDim]; - final float[] muArr = new float[originalDim]; + // Copy vector and mean once into workspace for (int d = 0; d < originalDim; d++) { x[d] = vector.get(d); muArr[d] = mu.get(d); @@ -649,8 +673,7 @@ public void quantizeBody(VectorFloat vector, // ASH paper, Eq. 6, normalization final float invNorm = (residualNorm > 0f) ? (1.0f / residualNorm) : 0.0f; - // Normalized residual x̂ - final float[] xhat = new float[originalDim]; + // Compute normalized residual x̂ for (int d = 0; d < originalDim; d++) { xhat[d] = (x[d] - muArr[d]) * invNorm; } From d8097e1f0874b85419c32c6c96fef4182f622419 Mon Sep 17 00:00:00 2001 From: Ted Willke Date: Mon, 12 Jan 2026 01:18:37 +0000 Subject: [PATCH 028/243] Replaced QR with Thin SVD in runWithinTraining and temporarily overriding payload alignment exception for recall benchmarking purposes. --- .../quantization/AsymmetricHashing.java | 175 ++++++++++-------- .../jbellis/jvector/example/DistancesASH.java | 53 ++++-- .../jbellis/jvector/example/DistancesPQ.java | 12 +- 3 files changed, 146 insertions(+), 94 deletions(-) diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/AsymmetricHashing.java b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/AsymmetricHashing.java index 6e3b1cb48..f5444e608 100644 --- a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/AsymmetricHashing.java +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/AsymmetricHashing.java @@ -372,15 +372,15 @@ private static void validateEncodedBits( ); } - if ((payloadBits & 63) != 0) { - throw new IllegalArgumentException( - "Invalid encodedBits=" + encodedBits + - ". ASH requires (encodedBits - headerBits) to be a multiple of 64 " + - "for aligned binary payload. " + - "Got payloadBits=" + payloadBits + - " (headerBits=" + headerBits + ")." - ); - } +// if ((payloadBits & 63) != 0) { +// throw new IllegalArgumentException( +// "Invalid encodedBits=" + encodedBits + +// ". ASH requires (encodedBits - headerBits) to be a multiple of 64 " + +// "for aligned binary payload. " + +// "Got payloadBits=" + payloadBits + +// " (headerBits=" + headerBits + ")." +// ); +// } if (payloadBits > originalDim) { throw new IllegalArgumentException( @@ -999,56 +999,78 @@ public StiefelTransform(RealMatrix W) { /** * RANDOM (untrained) Stiefel initialization. - * Generates a random orthonormal projection from R^originalDim → R^quantizedDim - * by sampling a Gaussian matrix and orthonormalizing via QR decomposition. - * A deterministic sign normalization is applied so results are reproducible - * across runs and linear algebra backends. - * @param originalDim dimensionality of the input vectors - * @param quantizedDim dimensionality of the binary embedding - * @param rng random number generator + * + *

    Samples a Gaussian matrix G ∈ R^{D×d} and computes its polar factor + * W = U·Vᵀ via a thin SVD (G = U·S·Vᵀ). The resulting W has orthonormal + * columns (WᵀW = I) and can be used as an orthonormal projection matrix + * (e.g., y = Wᵀx maps R^D → R^d). + * + *

    Applies a deterministic per-column sign alignment to reduce SVD sign + * ambiguity by flipping columns of W to be positively correlated with the + * corresponding columns of G. + * + * @param D dimensionality of the input vectors + * @param d dimensionality of the projected (embedding) space + * @param rng random number generator used to sample G + * @return a {@link StiefelTransform} containing W ∈ R^{D×d} with orthonormal columns + * @throws IllegalArgumentException if d <= 0 or d > D + * @throws RuntimeException if the native SVD fails */ - public static StiefelTransform runWithoutTraining(int originalDim, - int quantizedDim, - Random rng) { - - if (quantizedDim <= 0 || quantizedDim > originalDim) { - throw new IllegalArgumentException( - "Invalid quantizedDim=" + quantizedDim + - " for originalDim=" + originalDim - ); + public static StiefelTransform runWithoutTraining(int D, int d, Random rng) { + if (d <= 0 || d > D) { + throw new IllegalArgumentException("Invalid d=" + d + " for D=" + D); } - // Sample Gaussian matrix G ∈ R^{originalDim × quantizedDim} - double[][] gaussian = new double[originalDim][quantizedDim]; - for (int i = 0; i < originalDim; i++) { - for (int j = 0; j < quantizedDim; j++) { - gaussian[i][j] = rng.nextGaussian(); - } + // Sample Gaussian matrix G [D x d] + // Using a 1D array for native speed, then wrapping it + double[] gData = new double[D * d]; + for (int i = 0; i < gData.length; i++) { + gData[i] = rng.nextGaussian(); } + RealMatrix G = new Array2DRowRealMatrix(deserializeColumnMajor(gData, D, d), false); + + // Perform Thin SVD: G = U S VT + // We need U [D x d] and VT [d x d] + double[] u = new double[D * d]; + double[] vt = new double[d * d]; + double[] s = new double[d]; + intW info = new intW(0); + + // Workspace query for dgesvd + double[] workQuery = new double[1]; + LAPACK.getInstance().dgesvd("S", "S", D, d, gData, D, s, u, D, vt, d, workQuery, -1, info); + double[] work = new double[(int) workQuery[0]]; - RealMatrix G = MatrixUtils.createRealMatrix(gaussian); + // Execute dgesvd with Job "S" (Thin SVD) + LAPACK.getInstance().dgesvd("S", "S", D, d, gData, D, s, u, D, vt, d, work, work.length, info); - // QR decomposition: G = Q R - QRDecomposition qr = new QRDecomposition(G); + if (info.val != 0) { + throw new RuntimeException("Native SVD failed with info=" + info.val); + } - // Commons Math returns full Q (m×m). We need thin Q (m×n). - RealMatrix Qfull = qr.getQ(); // originalDim × originalDim - RealMatrix Q = Qfull.getSubMatrix(0, originalDim - 1, 0, quantizedDim - 1); // originalDim × quantizedDim + // Compute W = U @ VT [D x d] + // This is the Polar Decomposition (nearest orthonormal matrix to G) + double[] wData = new double[D * d]; + BLAS.getInstance().dgemm("N", "N", D, d, d, 1.0, u, D, vt, d, 0.0, wData, D); - RealMatrix R = qr.getR(); // originalDim × quantizedDim + RealMatrix W = new Array2DRowRealMatrix(deserializeColumnMajor(wData, D, d), false); - // Normalize QR sign ambiguity for determinism (RANDOM only) - int diag = Math.min(R.getRowDimension(), R.getColumnDimension()); // == quantizedDim - for (int j = 0; j < diag; j++) { - if (R.getEntry(j, j) < 0.0) { - for (int i = 0; i < Q.getRowDimension(); i++) { - Q.setEntry(i, j, -Q.getEntry(i, j)); + // Sign Alignment (Crucial for ASH/Random bits) + // To ensure the "direction" of our bits matches the Gaussian seeds, + // we flip columns of W so they are positively correlated with G. + for (int j = 0; j < d; j++) { + double dot = 0; + for (int i = 0; i < D; i++) { + dot += W.getEntry(i, j) * G.getEntry(i, j); + } + if (dot < 0.0) { + for (int i = 0; i < D; i++) { + W.setEntry(i, j, -W.getEntry(i, j)); } } } - // W := Q (originalDim × quantizedDim) - return new StiefelTransform(Q); + return new StiefelTransform(W); } @Override @@ -1607,52 +1629,52 @@ private static double calculateMSE(RealMatrix a, RealMatrix b) { } private static RealMatrix orthogonalize(RealMatrix m) { - int rows = m.getRowDimension(); - int cols = m.getColumnDimension(); + int rows = m.getRowDimension(); // D + int cols = m.getColumnDimension(); // d + int k = Math.min(rows, cols); // The rank for thin SVD - // Convert to column-major for Native LAPACK + // Move to column-major 1D array double[] a = serializeColumnMajor(m); - // Prepare output buffers - double[] u = new double[rows * rows]; - double[] vt = new double[cols * cols]; - double[] s = new double[Math.min(rows, cols)]; - - // int wrapper required.... + // Prepare Thin-SVD buffers + // u: [rows * k] instead of [rows * rows] + // vt: [k * cols] instead of [cols * cols] + double[] u = new double[rows * k]; + double[] vt = new double[k * cols]; + double[] s = new double[k]; intW info = new intW(0); - // LAPACK Workspace (Required for dgesvd): Pass -1 for lwork and a 1-element array for work + // Workspace Query double[] workQuery = new double[1]; LAPACK.getInstance().dgesvd( - "A", "A", rows, cols, a, rows, s, u, rows, vt, cols, + "S", "S", rows, cols, a, rows, s, u, rows, vt, k, workQuery, -1, info ); - // Extract the optimal lwork and allocate the real workspace int lwork = (int) workQuery[0]; double[] work = new double[lwork]; - // Native SVD call + // Actual Thin-SVD call (Job "S") LAPACK.getInstance().dgesvd( - "A", "A", // Compute all columns of U and all rows of VT - rows, cols, // Matrix dimensions - a, rows, // Input matrix and its leading dimension - s, // Output singular values - u, rows, // Output U and its leading dimension - vt, cols, // Output VT and its leading dimension - work, lwork, // Workspace - info // Success/Fail code + "S", "S", rows, cols, a, rows, s, u, rows, vt, k, + work, lwork, info ); if (info.val != 0) { throw new RuntimeException("LAPACK dgesvd failed with info=" + info.val); } - // Convert back to Commons Math - RealMatrix U = new Array2DRowRealMatrix(deserializeColumnMajor(u, rows, rows), false); - RealMatrix VT = new Array2DRowRealMatrix(deserializeColumnMajor(vt, cols, cols), false); + // Native Multiplication: Result = U @ VT + // Using dgemm here) + double[] resultData = new double[rows * cols]; + BLAS.getInstance().dgemm( + "N", "N", rows, cols, k, + 1.0, u, rows, // A = u, lda = rows + vt, k, // B = vt, ldb = k + 0.0, resultData, rows // C = resultData, ldc = rows + ); - return U.multiply(VT); + return new Array2DRowRealMatrix(deserializeColumnMajor(resultData, rows, cols), false); } /** Converts an Apache Commons RealMatrix to a column-major 1D array for LAPACK. */ @@ -1660,9 +1682,14 @@ private static double[] serializeColumnMajor(RealMatrix m) { int rows = m.getRowDimension(); int cols = m.getColumnDimension(); double[] data = new double[rows * cols]; + + // Accept RealMatrix double[][] implementation + double[][] raw = m.getData(); + for (int c = 0; c < cols; c++) { + int colOffset = c * rows; for (int r = 0; r < rows; r++) { - data[c * rows + r] = m.getEntry(r, c); + data[colOffset + r] = raw[r][c]; } } return data; @@ -1672,13 +1699,13 @@ private static double[] serializeColumnMajor(RealMatrix m) { private static double[][] deserializeColumnMajor(double[] data, int rows, int cols) { double[][] matrix = new double[rows][cols]; for (int c = 0; c < cols; c++) { + int colOffset = c * rows; for (int r = 0; r < rows; r++) { - matrix[r][c] = data[c * rows + r]; + matrix[r][c] = data[colOffset + r]; } } return matrix; } - /** * Elementwise sign producing +/-1 (no zeros), consistent with ASH encoding convention: * bit=1 iff projection > 0, else bit=0. diff --git a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/DistancesASH.java b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/DistancesASH.java index 0862a6f58..f4f9a34ba 100644 --- a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/DistancesASH.java +++ b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/DistancesASH.java @@ -76,7 +76,7 @@ public static void testASHEncodings(String filenameBase, String filenameQueries, Boolean.parseBoolean(System.getProperty("jvector.bench.sanity-check", "false")); final boolean RUN_RECALL_CHECK = - Boolean.parseBoolean(System.getProperty("jvector.bench.recall", "false")); + Boolean.parseBoolean(System.getProperty("jvector.bench.recall", "true")); final int RECALL_K = Integer.getInteger("jvector.bench.recall.k", 10); @@ -100,8 +100,8 @@ public static void testASHEncodings(String filenameBase, String filenameQueries, final int landmarkCount = 1; // Define the benchmark size - int maxQueries = 10_000; - int maxVectors = 10_000_000; + int maxQueries = 1_000; + int maxVectors = 1_000_000; int queryCountInFile = SiftLoader.countFvecs(filenameQueries); int vectorCountInFile = SiftLoader.countFvecs(filenameBase); @@ -164,17 +164,17 @@ public static void testASHEncodings(String filenameBase, String filenameQueries, for (VectorFloat q : queries) VectorUtil.l2normalize(q); int dimension = vectors.get(0).length(); - int encodedBits = (dimension / 4) + HEADER_BITS - 64; // (dimension / 4) + HEADER_BITS; + int encodedBits = dimension / 4; // 384; // (dimension / 4) + HEADER_BITS; // (dimension / 4) + HEADER_BITS; // Payload must be 64-bit aligned for SIMD int payloadBits = encodedBits - HEADER_BITS; - if ((payloadBits & 63) != 0) { - throw new IllegalArgumentException( - "ASH payloadBits must be 64-bit aligned for SIMD. " + - "Got payloadBits=" + payloadBits + - " (encodedBits=" + encodedBits + - ", HEADER_BITS=" + HEADER_BITS + ")" - ); - } +// if ((payloadBits & 63) != 0) { +// throw new IllegalArgumentException( +// "ASH payloadBits must be 64-bit aligned for SIMD. " + +// "Got payloadBits=" + payloadBits + +// " (encodedBits=" + encodedBits + +// ", HEADER_BITS=" + HEADER_BITS + ")" +// ); +// } System.out.println( "\toriginalDim = " + dimension + @@ -394,8 +394,13 @@ public static void testASHEncodings(String filenameBase, String filenameQueries, topAtSet.add((newToOldFinal == null) ? topIndices[r] : newToOldFinal[topIndices[r]]); } int matches = 0; + java.util.HashSet gtSeen = new java.util.HashSet<>(RECALL_K * 2); + // Compute and don't count duplicates (if present in GT or retrieved IDs) for (int g = 0; g < RECALL_K; g++) { - if (topAtSet.contains(queryGT[g])) matches++; + int gtId = queryGT[g]; + if (gtSeen.add(gtId) && topAtSet.contains(gtId)) { + matches++; + } } localTotalRecall[aIdx] += (double) matches / RECALL_K; } @@ -623,7 +628,7 @@ public static void runCohere100k() throws IOException { } public static void runADA() throws IOException { - System.out.println("Running ada_002"); + System.out.println("Running ada-002"); var baseVectors = "./fvec/ada-002/ada_002_100000_base_vectors.fvec"; var queryVectors = "./fvec/ada-002/ada_002_100000_query_vectors_10000.fvec"; @@ -631,6 +636,15 @@ public static void runADA() throws IOException { testASHEncodings(baseVectors, queryVectors, gtVectors); } + public static void runADANoZeros() throws IOException { + System.out.println("Running ada-002-no-zeros"); + + var baseVectors = "./fvec/ada-002-no-zeros/ada_002_100000_base_vectors_no_zeros.fvec"; + var queryVectors = "./fvec/ada-002-no-zeros/ada_002_100000_query_vectors_10000_no_zeros.fvec"; + var gtVectors = "./fvec/ada-002-no-zeros/ada-002_gt_no_zeros.ivec"; + testASHEncodings(baseVectors, queryVectors, gtVectors); + } + public static void runColbert() throws IOException { System.out.println("Running colbertv2"); @@ -680,11 +694,12 @@ public static void main(String[] args) throws IOException { // runSIFT(); // runGIST(); // runColbert(); -// runCohere100k(); + runCohere100k(); // runADA(); -// runOpenai1536(); -// runOpenai3072(); - runCap6m(); - runCohere10m(); + runADANoZeros(); + runOpenai1536(); + runOpenai3072(); +// runCap6m(); +// runCohere10m(); } } diff --git a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/DistancesPQ.java b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/DistancesPQ.java index 47c88f975..df415d326 100644 --- a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/DistancesPQ.java +++ b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/DistancesPQ.java @@ -336,6 +336,15 @@ public static void runADA() throws IOException { ); } + public static void runADANoZeros() throws IOException { + System.out.println("Running ada-002-no-zeros"); + + testPQEncodings( + "./fvec/ada-002-no-zeros/ada_002_100000_base_vectors_no_zeros.fvec", + "./fvec/ada-002-no-zeros/ada_002_100000_query_vectors_10000_no_zeros.fvec" + ); + } + public static void runOpenai1536() throws IOException { System.out.println("Running text-embedding-3-large_1536"); testPQEncodings( @@ -370,7 +379,8 @@ public static void runCohere10m() throws IOException { public static void main(String[] args) throws IOException { runCohere100k(); - runADA(); +// runADA(); + runADANoZeros(); runOpenai1536(); runOpenai3072(); // runCap6m(); From 2fad117b767fa0570d5b7b003e2e81342606e770 Mon Sep 17 00:00:00 2001 From: Ted Willke Date: Tue, 13 Jan 2026 05:45:49 +0000 Subject: [PATCH 029/243] Changed computeNativeV(X) to computeNativeVt(X) to fix ITQ. Other small fixes. ITQ and multi-landmarks work. --- .../quantization/AsymmetricHashing.java | 141 ++++++++++-------- .../jbellis/jvector/example/DistancesASH.java | 18 +-- 2 files changed, 88 insertions(+), 71 deletions(-) diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/AsymmetricHashing.java b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/AsymmetricHashing.java index f5444e608..79e0d327e 100644 --- a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/AsymmetricHashing.java +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/AsymmetricHashing.java @@ -43,6 +43,16 @@ import org.apache.commons.math3.linear.SingularValueDecomposition; import org.netlib.util.intW; +import java.io.BufferedInputStream; +import java.io.DataInputStream; +import java.io.FileInputStream; +import java.io.IOException; + +import java.io.BufferedOutputStream; +import java.io.FileOutputStream; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; + import static io.github.jbellis.jvector.quantization.KMeansPlusPlusClusterer.UNWEIGHTED; /** @@ -246,7 +256,7 @@ private AsymmetricHashing(int originalDim, public static AsymmetricHashing initialize(RandomAccessVectorValues ravv, int optimizer, int encodedBits, - int landmarkCount) { + int landmarkCount) throws IOException { final int quantizedDim = encodedBits - HEADER_BITS; @@ -265,20 +275,37 @@ public static AsymmetricHashing initialize(RandomAccessVectorValues ravv, // payload bits are always 64-bit aligned by validateEncodedBits() validateEncodedBits(encodedBits, HEADER_BITS, originalDim); - // NOTE: points are treated as read-only by KMeansPlusPlusClusterer. Materialize once. + // NOTE: points are treated as read-only by KMeansPlusPlusClusterer. + // Materialize and L2-normalize points VectorFloat[] points = new VectorFloat[ravvCopy.size()]; for (int i = 0; i < ravvCopy.size(); i++) { - points[i] = ravvCopy.getVector(i); + VectorFloat v = ravvCopy.getVector(i).copy(); // copy to avoid mutating original source + float norm = (float) Math.sqrt(VectorUtil.dotProduct(v, v)); + if (norm > 1e-10f) { + VectorUtil.scale(v, 1.0f / norm); + } + points[i] = v; } VectorFloat[] landmarks; if (landmarkCount == 1) { - // Mean centroid - VectorFloat mu0 = vectorTypeSupport.createFloatVector(originalDim); + double[] muAccumulator = new double[originalDim]; + + // Accumulate in double precision for (int i = 0; i < points.length; i++) { - VectorUtil.addInPlace(mu0, points[i]); + VectorFloat p = points[i]; + for (int d = 0; d < originalDim; d++) { + muAccumulator[d] += p.get(d); + } } - VectorUtil.scale(mu0, 1.0f / points.length); + + // Scale and materialize back into a float vector + VectorFloat mu0 = vectorTypeSupport.createFloatVector(originalDim); + double invN = 1.0 / points.length; + for (int d = 0; d < originalDim; d++) { + mu0.set(d, (float) (muAccumulator[d] * invN)); + } + landmarks = new VectorFloat[] { mu0 }; } else { long kmStart = System.nanoTime(); @@ -305,8 +332,8 @@ public static AsymmetricHashing initialize(RandomAccessVectorValues ravv, } } - // Keep determinism consistent within ASH. TODO define seed constant or remove. - final Random rng = new Random(42); + // Keep determinism consistent within ASH. TODO explore more robust random initialization + final Random rng = new Random(103); StiefelTransform stiefelTransform; if (optimizer == RANDOM) { @@ -372,15 +399,15 @@ private static void validateEncodedBits( ); } -// if ((payloadBits & 63) != 0) { -// throw new IllegalArgumentException( -// "Invalid encodedBits=" + encodedBits + -// ". ASH requires (encodedBits - headerBits) to be a multiple of 64 " + -// "for aligned binary payload. " + -// "Got payloadBits=" + payloadBits + -// " (headerBits=" + headerBits + ")." -// ); -// } + if ((payloadBits & 63) != 0) { + throw new IllegalArgumentException( + "Invalid encodedBits=" + encodedBits + + ". ASH requires (encodedBits - headerBits) to be a multiple of 64 " + + "for aligned binary payload. " + + "Got payloadBits=" + payloadBits + + " (headerBits=" + headerBits + ")." + ); + } if (payloadBits > originalDim) { throw new IllegalArgumentException( @@ -406,36 +433,39 @@ private static LandmarkProjections computeLandmarkProjections( int originalDim ) { final int C = landmarks.length; - final float[][] A = stiefelTransform.AFloat; // [d][D] - - final var vecUtil = - io.github.jbellis.jvector.vector.VectorizationProvider - .getInstance() - .getVectorUtilSupport(); + // Use a double-precision matrix for the projection + final double[][] A = stiefelTransform.AData; // [d][D] final float[][] landmarkProj = new float[C][quantizedDim]; final float[] landmarkProjSum = new float[C]; - final float[] muArr = new float[originalDim]; + // Materialize μ as double[] to maintain precision during the dot product + final double[] muArr = new double[originalDim]; for (int c = 0; c < C; c++) { VectorFloat mu = landmarks[c]; - // materialize μ once for (int i = 0; i < originalDim; i++) { muArr[i] = mu.get(i); } - float sum = 0f; + double totalSum = 0.0; float[] proj = landmarkProj[c]; for (int j = 0; j < quantizedDim; j++) { - float v = vecUtil.ashDotRow(A[j], muArr); - proj[j] = v; - sum += v; + double rowDot = 0.0; + double[] Arow = A[j]; + + // Double-precision dot product + for (int k = 0; k < originalDim; k++) { + rowDot += Arow[k] * muArr[k]; + } + + proj[j] = (float) rowDot; + totalSum += rowDot; } - landmarkProjSum[c] = sum; + landmarkProjSum[c] = (float) totalSum; } return new LandmarkProjections(landmarkProj, landmarkProjSum); @@ -1037,12 +1067,16 @@ public static StiefelTransform runWithoutTraining(int D, int d, Random rng) { intW info = new intW(0); // Workspace query for dgesvd + double[] a = gData.clone(); // <- preserve original G for the real factorization double[] workQuery = new double[1]; - LAPACK.getInstance().dgesvd("S", "S", D, d, gData, D, s, u, D, vt, d, workQuery, -1, info); + LAPACK.getInstance().dgesvd("S", "S", D, d, a, D, s, u, D, vt, d, workQuery, -1, info); + if (info.val != 0) throw new RuntimeException("SVD workspace query failed info=" + info.val); + double[] work = new double[(int) workQuery[0]]; + a = gData.clone(); // <- ensure the real call sees the original Gaussian matrix // Execute dgesvd with Job "S" (Thin SVD) - LAPACK.getInstance().dgesvd("S", "S", D, d, gData, D, s, u, D, vt, d, work, work.length, info); + LAPACK.getInstance().dgesvd("S", "S", D, d, a, D, s, u, D, vt, d, work, work.length, info); if (info.val != 0) { throw new RuntimeException("Native SVD failed with info=" + info.val); @@ -1055,21 +1089,6 @@ public static StiefelTransform runWithoutTraining(int D, int d, Random rng) { RealMatrix W = new Array2DRowRealMatrix(deserializeColumnMajor(wData, D, d), false); - // Sign Alignment (Crucial for ASH/Random bits) - // To ensure the "direction" of our bits matches the Gaussian seeds, - // we flip columns of W so they are positively correlated with G. - for (int j = 0; j < d; j++) { - double dot = 0; - for (int i = 0; i < D; i++) { - dot += W.getEntry(i, j) * G.getEntry(i, j); - } - if (dot < 0.0) { - for (int i = 0; i < D; i++) { - W.setEntry(i, j, -W.getEntry(i, j)); - } - } - } - return new StiefelTransform(W); } @@ -1361,10 +1380,10 @@ private static StiefelTransform runItqTrainer( // PCA basis M = V[:, :d] where X = U S V^T RealMatrix X = new Array2DRowRealMatrix(xHdNorm, false); // [N×D] logProgress("\t[stage] Starting Native SVD for PCA..."); - // We only need V, so we tell LAPACK to skip U to save massive amounts of time/memory - RealMatrix V = computeNativeV(X); + // We only need VT, so we tell LAPACK to skip U to save massive amounts of time/memory + RealMatrix Vt = computeNativeVt(X); logProgress("\t[stage] Completed Native SVD..."); - RealMatrix M = V.getSubMatrix(0, D - 1, 0, d - 1); // [D×d] + RealMatrix M = Vt.transpose().getSubMatrix(0, D - 1, 0, d - 1); // [D×d] // Early stopping based on stabilized binary codes (subject to max iterations) RealMatrix prevXbin = null; @@ -1530,12 +1549,6 @@ private static RealMatrix gaussianMatrix(int rows, int cols, Random rng) { return new Array2DRowRealMatrix(m, false); } - /** Orthonormal factor: U @ V^T from SVD(M). */ -// private static RealMatrix orthogonalize(RealMatrix m) { -// SingularValueDecomposition svd = new SingularValueDecomposition(m); -// return svd.getU().multiply(svd.getVT()); -// } - // Basic dgemm routine. private static RealMatrix nativeMultiply(RealMatrix A, RealMatrix B) { int m = A.getRowDimension(); @@ -1564,16 +1577,17 @@ private static RealMatrix nativeMultiply(RealMatrix A, RealMatrix B) { } // Uses the dgesvd routine but tells LAPACK to skip the U matrix (for PCA). Saves time.... - private static RealMatrix computeNativeV(RealMatrix x) { + private static RealMatrix computeNativeVt(RealMatrix x) { int rows = x.getRowDimension(); int cols = x.getColumnDimension(); - double[] a = serializeColumnMajor(x); + double[] a0 = serializeColumnMajor(x); double[] s = new double[Math.min(rows, cols)]; double[] vt = new double[cols * cols]; // Right singular vectors intW info = new intW(0); - // Query optimal workspace size (standard LAPACK practice) + // Query optimal workspace size (standard LAPACK practice); use a copy + double[] a = a0.clone(); double[] workQuery = new double[1]; LAPACK.getInstance().dgesvd("N", "A", rows, cols, a, rows, s, null, 1, vt, cols, workQuery, -1, info); @@ -1581,6 +1595,7 @@ private static RealMatrix computeNativeV(RealMatrix x) { double[] work = new double[lwork]; // Actual call: "N" = No U matrix, "A" = All columns of V^T + a = a0.clone(); // Use a fresh copy again LAPACK.getInstance().dgesvd("N", "A", rows, cols, a, rows, s, null, 1, vt, cols, work, lwork, info); if (info.val != 0) { @@ -1634,7 +1649,7 @@ private static RealMatrix orthogonalize(RealMatrix m) { int k = Math.min(rows, cols); // The rank for thin SVD // Move to column-major 1D array - double[] a = serializeColumnMajor(m); + double[] a0 = serializeColumnMajor(m); // Prepare Thin-SVD buffers // u: [rows * k] instead of [rows * rows] @@ -1645,6 +1660,7 @@ private static RealMatrix orthogonalize(RealMatrix m) { intW info = new intW(0); // Workspace Query + double[] a = a0.clone(); // Make a copy double[] workQuery = new double[1]; LAPACK.getInstance().dgesvd( "S", "S", rows, cols, a, rows, s, u, rows, vt, k, @@ -1655,6 +1671,7 @@ private static RealMatrix orthogonalize(RealMatrix m) { double[] work = new double[lwork]; // Actual Thin-SVD call (Job "S") + a = a0.clone(); // Fresh copy again LAPACK.getInstance().dgesvd( "S", "S", rows, cols, a, rows, s, u, rows, vt, k, work, lwork, info @@ -1708,7 +1725,7 @@ private static double[][] deserializeColumnMajor(double[] data, int rows, int co } /** * Elementwise sign producing +/-1 (no zeros), consistent with ASH encoding convention: - * bit=1 iff projection > 0, else bit=0. + * bit=1 iff projection > 0, else bit=-1. */ private static RealMatrix sign01(RealMatrix m) { final int r = m.getRowDimension(); diff --git a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/DistancesASH.java b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/DistancesASH.java index f4f9a34ba..d85b10dd1 100644 --- a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/DistancesASH.java +++ b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/DistancesASH.java @@ -164,17 +164,17 @@ public static void testASHEncodings(String filenameBase, String filenameQueries, for (VectorFloat q : queries) VectorUtil.l2normalize(q); int dimension = vectors.get(0).length(); - int encodedBits = dimension / 4; // 384; // (dimension / 4) + HEADER_BITS; // (dimension / 4) + HEADER_BITS; + int encodedBits = (dimension / 2) + HEADER_BITS; // Payload must be 64-bit aligned for SIMD int payloadBits = encodedBits - HEADER_BITS; -// if ((payloadBits & 63) != 0) { -// throw new IllegalArgumentException( -// "ASH payloadBits must be 64-bit aligned for SIMD. " + -// "Got payloadBits=" + payloadBits + -// " (encodedBits=" + encodedBits + -// ", HEADER_BITS=" + HEADER_BITS + ")" -// ); -// } + if ((payloadBits & 63) != 0) { + throw new IllegalArgumentException( + "ASH payloadBits must be 64-bit aligned for SIMD. " + + "Got payloadBits=" + payloadBits + + " (encodedBits=" + encodedBits + + ", HEADER_BITS=" + HEADER_BITS + ")" + ); + } System.out.println( "\toriginalDim = " + dimension + From 74b06420346ecbd69ae12a0e918d96814ccb3463 Mon Sep 17 00:00:00 2001 From: Ted Willke Date: Wed, 14 Jan 2026 22:54:59 +0000 Subject: [PATCH 030/243] Partial implementation of ASH single vector scoring logic SIMD support. Does not work yet. --- .../jvector/vector/VectorUtilSupport.java | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/vector/VectorUtilSupport.java b/jvector-base/src/main/java/io/github/jbellis/jvector/vector/VectorUtilSupport.java index 32d1f8357..9a84f8a6b 100644 --- a/jvector-base/src/main/java/io/github/jbellis/jvector/vector/VectorUtilSupport.java +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/vector/VectorUtilSupport.java @@ -262,6 +262,42 @@ default float ashDotRow(float[] Arow, float[] x) { return acc; } + /** + * Computes maskedAdd for a single vector stored in packed *by-vector* form. + * Returns: + * maskedAdd = + * + * packedBits layout: + * packedBits[packedBase + w] is 64-bit word w for this vector. + * + * Default implementation is scalar bit-walk with a register accumulator. + * SIMD backends may override. + */ + default float ashMaskedAddAllWords( + float[] tildeQ, + int d, + long[] packedBits, + int packedBase, // ord * nWords (by-vector contiguous) + int nWords + ) { + float maskedAdd = 0f; // register accumulator (critical!) + + for (int w = 0; w < nWords; w++) { + long word = packedBits[packedBase + w]; + int baseDim = w * 64; + + // Walk set bits (fast when sparse; matches your block fallback style) + while (word != 0L) { + int bit = Long.numberOfTrailingZeros(word); + int idx = baseDim + bit; + if (idx < d) maskedAdd += tildeQ[idx]; + word &= (word - 1); + } + } + + return maskedAdd; + } + /** * Computes maskedAdd for a block slice of vectors stored in packed block-column-major form. * For each lane in [0, blockLen), writes: From a4d35b9d6c16633a54f48854475184b55b97dbce Mon Sep 17 00:00:00 2001 From: Ted Willke Date: Wed, 14 Jan 2026 22:57:10 +0000 Subject: [PATCH 031/243] Initial ASH integration into graph index. Scalar scoring with separated vectors. Recall and perf look acceptable. --- .../graph/similarity/BuildScoreProvider.java | 110 ++++++ .../quantization/AsymmetricHashing.java | 74 +++- .../jbellis/jvector/example/DistancesASH.java | 18 +- .../github/jbellis/jvector/example/Grid.java | 88 ++++- .../example/diagnostics/ASHScoreDebug.java | 316 ++++++++++++++++++ .../example/util/CompressorParameters.java | 34 ++ .../example/util/MultiFileDatasource.java | 6 +- .../jvector/example/yaml/Compression.java | 32 ++ jvector-examples/yaml-configs/datasets.yml | 37 +- jvector-examples/yaml-configs/default.yml | 40 ++- 10 files changed, 685 insertions(+), 70 deletions(-) create mode 100644 jvector-examples/src/main/java/io/github/jbellis/jvector/example/diagnostics/ASHScoreDebug.java diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/graph/similarity/BuildScoreProvider.java b/jvector-base/src/main/java/io/github/jbellis/jvector/graph/similarity/BuildScoreProvider.java index f0b184e67..3f0e67c0f 100644 --- a/jvector-base/src/main/java/io/github/jbellis/jvector/graph/similarity/BuildScoreProvider.java +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/graph/similarity/BuildScoreProvider.java @@ -18,6 +18,8 @@ import io.github.jbellis.jvector.graph.RandomAccessVectorValues; import io.github.jbellis.jvector.graph.RemappedRandomAccessVectorValues; +import io.github.jbellis.jvector.quantization.AsymmetricHashing; +import io.github.jbellis.jvector.quantization.ASHVectors; import io.github.jbellis.jvector.quantization.BQVectors; import io.github.jbellis.jvector.quantization.PQVectors; import io.github.jbellis.jvector.vector.VectorSimilarityFunction; @@ -230,4 +232,112 @@ public SearchScoreProvider diversityProviderFor(int node1) { } }; } + + /** + * Returns a BSP that performs approximate score comparisons using the given ASHVectors. + * + *

    Construction (node-node) scoring: Uses symmetric ASH–ASH dot-product approximation + * computed from the encoded binary payload + per-vector header fields (scale, offset). + * + *

    Query (vector-node) scoring: Delegates to ASHVectors' query-time score function + * (asymmetric float query → ASH vector). + * + *

    Constraint: This provider requires {@code C=1} (single landmark) and + * {@link VectorSimilarityFunction#DOT_PRODUCT}. + */ + static BuildScoreProvider ashBuildScoreProvider(VectorSimilarityFunction vsf, ASHVectors ashv) { + if (vsf != VectorSimilarityFunction.DOT_PRODUCT) { + throw new UnsupportedOperationException("ASH build scoring supports DOT_PRODUCT only"); + } + + final AsymmetricHashing ash = ashv.getCompressor(); + if (ash.landmarkCount != 1) { + throw new IllegalArgumentException("ASH build scoring requires landmarkCount==1, got " + ash.landmarkCount); + } + + final int d = ash.quantizedDim; + + // ||mu||^2 is a constant (C=1) for this scorer. + final float muNormSq = VectorUtil.dotProduct(ash.landmarks[0], ash.landmarks[0]); + + return new BuildScoreProvider() { + @Override + public boolean isExact() { + return false; + } + + @Override + public VectorFloat approximateCentroid() { + // In the C=1 case, ASH uses the dataset mean as its single landmark. + // Return a copy so callers can't mutate ASH's internal landmark. + return ash.landmarks[0].copy(); + } + + @Override + public SearchScoreProvider searchProviderFor(VectorFloat vector) { + // Query-time asymmetric scoring is already implemented by ASHVectors. + return new DefaultSearchScoreProvider(ashv.precomputedScoreFunctionFor(vector, vsf)); + } + + @Override + public SearchScoreProvider searchProviderFor(int node1) { + + final AsymmetricHashing.QuantizedVector v1 = ashv.get(node1); + + return new DefaultSearchScoreProvider(new ScoreFunction() { + @Override + public boolean isExact() { + return false; + } + + @Override + public float similarityTo(int node2) { + final AsymmetricHashing.QuantizedVector v2 = ashv.get(node2); + + final long[] aBits = v1.binaryVector; + final long[] bBits = v2.binaryVector; + + int matches = 0; + + int bitBase = 0; + for (int w = 0; w < aBits.length && bitBase < d; w++, bitBase += 64) { + long aw = aBits[w]; + long bw = bBits[w]; + + // Defensive tail masking (should be unnecessary when d is 64-bit aligned) + final int remaining = d - bitBase; + if (remaining < 64) { + final long mask = (remaining == 64) ? ~0L : ((1L << remaining) - 1L); + aw &= mask; + bw &= mask; + } + + // matches = count of equal bits across positions (XNOR-popcount) + matches += Long.bitCount(~(aw ^ bw)); + } + + // g(.) is sign-coded; with bits interpreted as {0,1} representing { -1, +1 } via s = 2b - 1: + // = 2*matches - d + final float signDot = 2.0f * matches - (float) d; + + // Reconstruction (C=1): + // ≈ ||x-μ|| ||y-μ|| + + - ||μ||^2 + // + // with: + // scale = ||x-μ|| / sqrt(d) + // offset = - ||μ||^2 + // and decoder scaling f(z)=d^{-1/2}Az implies ≈ (1/d) + // so centered term becomes: scale_x * scale_y * + return (v1.scale * v2.scale) * signDot + v1.offset + v2.offset + muNormSq; + } + }); + } + + @Override + public SearchScoreProvider diversityProviderFor(int node1) { + // Keep search/diversity consistent for approximate scoring. + return searchProviderFor(node1); + } + }; + } } diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/AsymmetricHashing.java b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/AsymmetricHashing.java index 79e0d327e..f4486a17a 100644 --- a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/AsymmetricHashing.java +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/AsymmetricHashing.java @@ -37,7 +37,6 @@ import java.util.concurrent.ForkJoinPool; import java.util.Random; import org.apache.commons.math3.linear.MatrixUtils; -import org.apache.commons.math3.linear.QRDecomposition; import org.apache.commons.math3.linear.RealMatrix; import org.apache.commons.math3.linear.Array2DRowRealMatrix; import org.apache.commons.math3.linear.SingularValueDecomposition; @@ -534,22 +533,8 @@ public static AsymmetricHashing load(RandomAccessReader in) throws IOException { landmarks[c] = vectorTypeSupport.readFloatVector(in, dim); } - // NOTE: StiefelTransform loading will be added in Step 2. - // For now, construct a placeholder or re-run the RANDOM initializer. - StiefelTransform stiefelTransform; - - if (optimizer == RANDOM) { - // Deterministic re-construction for now - stiefelTransform = runWithoutTraining( - originalDimension, - quantizedDim, - new Random(42) - ); - } else { - throw new IllegalArgumentException( - "ASH optimizer " + optimizer + " requires learned transform; not yet supported in load()" - ); - } + // Load StiefelTransform (required for ALL optimizers, including ITQ/LANDING) + StiefelTransform stiefelTransform = readStiefelTransform(in, quantizedDim, originalDimension); LandmarkProjections lp = computeLandmarkProjections( @@ -600,7 +585,9 @@ public void write(DataOutput out, int version) throws IOException { vectorTypeSupport.writeFloatVector(out, landmarks[c]); } - // NOTE: stiefelTransform serialization will be added later + // Stiefel transform serialization (required for caching and non-RANDOM optimizers): + // Serialize A = W^T in float precision as a [d][D] row-major matrix. + writeStiefelTransform(out, stiefelTransform); } // --------------------------------------------------------------------- @@ -1740,6 +1727,57 @@ private static RealMatrix sign01(RealMatrix m) { return new Array2DRowRealMatrix(out, false); } + private static void writeStiefelTransform(DataOutput out, StiefelTransform st) throws IOException { + // Serialize A = W^T in float precision as a [d][D] row-major matrix. + // This keeps the format compact and matches the encoding/scoring fast paths. + out.writeInt(st.rows); + out.writeInt(st.cols); + + for (int i = 0; i < st.rows; i++) { + float[] row = st.AFloat[i]; + if (row.length != st.cols) { + throw new IOException("Invalid StiefelTransform AFloat row length: " + row.length + " != " + st.cols); + } + for (int j = 0; j < st.cols; j++) { + out.writeFloat(row[j]); + } + } + } + + private static StiefelTransform readStiefelTransform(RandomAccessReader in, int expectedRows, int expectedCols) throws IOException { + int rows = in.readInt(); + int cols = in.readInt(); + + if (rows != expectedRows) { + throw new IOException("StiefelTransform rows mismatch: expected " + expectedRows + ", got " + rows); + } + if (cols != expectedCols) { + throw new IOException("StiefelTransform cols mismatch: expected " + expectedCols + ", got " + cols); + } + + // Read A = W^T as [rows][cols] (row-major) in float precision. + float[][] aFloat = new float[rows][cols]; + for (int i = 0; i < rows; i++) { + float[] row = aFloat[i]; + for (int j = 0; j < cols; j++) { + row[j] = in.readFloat(); + } + } + + // Reconstruct W [D x d] so we can build StiefelTransform(W) + // W[j][i] = A[i][j] + double[][] wData = new double[cols][rows]; + for (int i = 0; i < rows; i++) { + float[] aRow = aFloat[i]; + for (int j = 0; j < cols; j++) { + wData[j][i] = aRow[j]; + } + } + + RealMatrix W = new Array2DRowRealMatrix(wData, false); + return new StiefelTransform(W); + } + @Override public int compressorSize() { // Must exactly match write(out, version) diff --git a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/DistancesASH.java b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/DistancesASH.java index d85b10dd1..0ea4b1456 100644 --- a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/DistancesASH.java +++ b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/DistancesASH.java @@ -100,8 +100,8 @@ public static void testASHEncodings(String filenameBase, String filenameQueries, final int landmarkCount = 1; // Define the benchmark size - int maxQueries = 1_000; - int maxVectors = 1_000_000; + int maxQueries = 10_000; + int maxVectors = 10_000_000; int queryCountInFile = SiftLoader.countFvecs(filenameQueries); int vectorCountInFile = SiftLoader.countFvecs(filenameBase); @@ -164,7 +164,7 @@ public static void testASHEncodings(String filenameBase, String filenameQueries, for (VectorFloat q : queries) VectorUtil.l2normalize(q); int dimension = vectors.get(0).length(); - int encodedBits = (dimension / 2) + HEADER_BITS; + int encodedBits = (dimension / 4) + HEADER_BITS; // Payload must be 64-bit aligned for SIMD int payloadBits = encodedBits - HEADER_BITS; if ((payloadBits & 63) != 0) { @@ -694,12 +694,12 @@ public static void main(String[] args) throws IOException { // runSIFT(); // runGIST(); // runColbert(); - runCohere100k(); +// runCohere100k(); // runADA(); - runADANoZeros(); - runOpenai1536(); - runOpenai3072(); -// runCap6m(); -// runCohere10m(); +// runADANoZeros(); +// runOpenai1536(); +// runOpenai3072(); + runCap6m(); + runCohere10m(); } } diff --git a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/Grid.java b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/Grid.java index be5d66d43..d47368abf 100644 --- a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/Grid.java +++ b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/Grid.java @@ -45,6 +45,10 @@ import io.github.jbellis.jvector.graph.similarity.DefaultSearchScoreProvider; import io.github.jbellis.jvector.graph.similarity.ScoreFunction; import io.github.jbellis.jvector.graph.similarity.SearchScoreProvider; +import io.github.jbellis.jvector.quantization.AsymmetricHashing; +import io.github.jbellis.jvector.quantization.ASHVectors; +import io.github.jbellis.jvector.example.diagnostics.ASHScoreDebug; + import io.github.jbellis.jvector.quantization.CompressedVectors; import io.github.jbellis.jvector.quantization.NVQuantization; import io.github.jbellis.jvector.quantization.PQVectors; @@ -217,18 +221,60 @@ private static Map, ImmutableGraphIndex> buildOnDisk(List, OnDiskGraphIndexWriter> writers = new HashMap<>(); Map, Map>> suppliers = new HashMap<>(); OnDiskGraphIndexWriter scoringWriter = null; + int n = 0; for (var features : featureSets) { + // FUSED_PQ requires PQVectors at write time; skip when building with ASH + if (features.contains(FeatureId.FUSED_PQ) && pq == null) { + System.out.println("Skipping Fused PQ feature because build compressor is not PQ"); + continue; + } + var graphPath = testDirectory.resolve("graph" + n++); - var bws = builderWithSuppliers(features, builder.getGraph(), graphPath, floatVectors, pq.getCompressor()); + var pqCompressorForFeatures = (pq == null) ? null : pq.getCompressor(); + var bws = builderWithSuppliers(features, builder.getGraph(), graphPath, floatVectors, pqCompressorForFeatures); + var writer = bws.builder.build(); writers.put(features, writer); suppliers.put(features, bws.suppliers); @@ -236,6 +282,7 @@ private static Map, ImmutableGraphIndex> buildOnDisk(List, ImmutableGraphIndex> buildOnDisk(List { var writer = entry.getValue(); var features = entry.getKey(); + Map> writeSuppliers; if (features.contains(FeatureId.FUSED_PQ)) { + // Safe: such features are only present when pq != null (we skipped them otherwise) writeSuppliers = new EnumMap<>(FeatureId.class); var view = builder.getGraph().getView(); - writeSuppliers.put(FeatureId.FUSED_PQ, ordinal -> new FusedPQ.State(view, pq, ordinal)); + writeSuppliers.put(FeatureId.FUSED_PQ, ordinal -> new FusedPQ.State(view, pqFinal, ordinal)); } else { writeSuppliers = Map.of(); } + try { writer.write(writeSuppliers); writer.close(); @@ -281,6 +330,7 @@ private static Map, ImmutableGraphIndex> buildOnDisk(List, ImmutableGraphIndex> buildOnDisk(List, ImmutableGraphIndex> indexes = new HashMap<>(); n = 0; for (var features : featureSets) { + if (features.contains(FeatureId.FUSED_PQ) && pq == null) { + continue; + } var graphPath = testDirectory.resolve("graph" + n++); var index = OnDiskGraphIndex.load(ReaderSupplierFactory.open(graphPath)); indexes.put(features, index); @@ -636,9 +689,26 @@ private static VectorCompressor getCompressor(Function 0"); + if (subsetN <= 0) throw new IllegalArgumentException("subsetN must be > 0"); + if (topK <= 0) throw new IllegalArgumentException("topK must be > 0"); + + AsymmetricHashing ash = ashVectors.getCompressor(); + if (ash.landmarkCount != 1) { + throw new IllegalArgumentException("ASHScoreDebug assumes landmarkCount==1, got " + ash.landmarkCount); + } + + int n = floatVectors.size(); + int D = floatVectors.dimension(); + int d = ash.quantizedDim; + + int effectiveSubsetN = Math.min(subsetN, n); + + // Constant for C=1 (included for manuscript-faithful reconstruction; does not affect ranking) + final float muNormSq = VectorUtil.dotProduct(ash.landmarks[0], ash.landmarks[0]); + + System.out.println("=== ASHScoreDebug ==="); + System.out.println("N=" + n + " D=" + D + " d=" + d + + " subsetN=" + effectiveSubsetN + " pairs=" + numPairs + " topK=" + topK); + + // 1) Bit-math sanity on trivial patterns (checks signDot identity) + sanityCheckBinaryMath(d); + + // 2) Pair sampling: GT dot, asymmetric score, symmetric score + double[] gt = new double[numPairs]; + double[] asym = new double[numPairs]; + double[] sym = new double[numPairs]; + + for (int t = 0; t < numPairs; t++) { + int i = rng.nextInt(effectiveSubsetN); + int j = rng.nextInt(effectiveSubsetN); + + VectorFloat xi = floatVectors.getVector(i); + VectorFloat xj = floatVectors.getVector(j); + + // Ground truth dot + gt[t] = VectorUtil.dotProduct(xi, xj); + + // Asymmetric: query xi vs encoded j + ScoreFunction.ApproximateScoreFunction f = + ashVectors.scoreFunctionFor(xi, VectorSimilarityFunction.DOT_PRODUCT); + asym[t] = f.similarityTo(j); + + // Symmetric: encoded i vs encoded j (manuscript-consistent) + sym[t] = symmetricDot(ashVectors.get(i), ashVectors.get(j), d, muNormSq); + } + + printCorrelations(gt, asym, sym); + + // 3) Top-K overlap sanity for a few query ords (subset brute force) + int[] queries = pickQueries(effectiveSubsetN, rng, 5); + for (int qi = 0; qi < queries.length; qi++) { + int q = queries[qi]; + System.out.println(); + System.out.println("--- TopK overlap for q=" + q + + " (subsetN=" + effectiveSubsetN + ", topK=" + topK + ") ---"); + topKOverlapForQuery(floatVectors, ashVectors, q, effectiveSubsetN, topK, d, muNormSq); + } + + System.out.println("=== end ASHScoreDebug ==="); + } + + /** + * Symmetric ASH–ASH dot-product approximation (C=1), consistent with: + * - g produces sign vectors in {-1,+1}^d (stored as bits b in {0,1}^d via s = 2b - 1) + * - f(z) = d^{-1/2} A z implies ≈ (1/d) + * + * With stored header: + * - scale = ||x-μ|| / sqrt(d) + * - offset = - ||μ||^2 + * + * Centered dot term: + * ||x-μ|| ||y-μ|| ≈ (scale_x * scale_y) * + * + * And = 2*matches - d, where matches is XNOR-popcount of bit vectors. + * + * Full dot: + * ≈ scale_x*scale_y*(2*matches - d) + offset_x + offset_y + ||μ||^2 + */ + public static float symmetricDot(AsymmetricHashing.QuantizedVector v1, + AsymmetricHashing.QuantizedVector v2, + int d, + float muNormSq) { + long[] aBits = v1.binaryVector; + long[] bBits = v2.binaryVector; + + int matches = 0; + + int bitBase = 0; + for (int w = 0; w < aBits.length && bitBase < d; w++, bitBase += 64) { + long aw = aBits[w]; + long bw = bBits[w]; + + // Defensive tail masking (should be unnecessary when d is 64-bit aligned) + int remaining = d - bitBase; + if (remaining < 64) { + long mask = (remaining == 64) ? ~0L : ((1L << remaining) - 1L); + aw &= mask; + bw &= mask; + } + + matches += Long.bitCount(~(aw ^ bw)); // XNOR-popcount + } + + // sign-dot in {-1,+1}^d + float signDot = 2.0f * (float) matches - (float) d; + + return (v1.scale * v2.scale) * signDot + v1.offset + v2.offset + muNormSq; + } + + private static void printCorrelations(double[] gt, double[] asym, double[] sym) { + double cGtAsym = pearson(gt, asym); + double cGtSym = pearson(gt, sym); + double cAsymSym = pearson(asym, sym); + + System.out.printf("Pearson corr: GT vs ASYM = %.4f%n", cGtAsym); + System.out.printf("Pearson corr: GT vs SYM = %.4f%n", cGtSym); + System.out.printf("Pearson corr: ASYM vs SYM = %.4f%n", cAsymSym); + + System.out.printf("Means: GT=%.6f ASYM=%.6f SYM=%.6f%n", mean(gt), mean(asym), mean(sym)); + System.out.printf("Stdev: GT=%.6f ASYM=%.6f SYM=%.6f%n", stdev(gt), stdev(asym), stdev(sym)); + } + + private static double pearson(double[] a, double[] b) { + if (a.length != b.length) throw new IllegalArgumentException("length mismatch"); + int n = a.length; + + double ma = mean(a); + double mb = mean(b); + + double num = 0.0; + double da = 0.0; + double db = 0.0; + for (int i = 0; i < n; i++) { + double xa = a[i] - ma; + double xb = b[i] - mb; + num += xa * xb; + da += xa * xa; + db += xb * xb; + } + double denom = Math.sqrt(da) * Math.sqrt(db); + return denom == 0.0 ? 0.0 : (num / denom); + } + + private static double mean(double[] a) { + double s = 0.0; + for (int i = 0; i < a.length; i++) s += a[i]; + return s / (double) a.length; + } + + private static double stdev(double[] a) { + double m = mean(a); + double s2 = 0.0; + for (int i = 0; i < a.length; i++) { + double d = a[i] - m; + s2 += d * d; + } + return Math.sqrt(s2 / (double) Math.max(1, a.length - 1)); + } + + private static int[] pickQueries(int subsetN, Random rng, int count) { + int[] qs = new int[count]; + for (int i = 0; i < count; i++) qs[i] = rng.nextInt(subsetN); + return qs; + } + + private static void topKOverlapForQuery(RandomAccessVectorValues floatVectors, + ASHVectors ashVectors, + int q, + int subsetN, + int topK, + int d, + float muNormSq) { + VectorFloat xq = floatVectors.getVector(q); + + // Precompute asymmetric score function once + ScoreFunction.ApproximateScoreFunction asymF = + ashVectors.scoreFunctionFor(xq, VectorSimilarityFunction.DOT_PRODUCT); + + // Encoded q for symmetric + AsymmetricHashing.QuantizedVector qEnc = ashVectors.get(q); + + // Score all candidates in subset (excluding self) + Scored[] gt = new Scored[subsetN - 1]; + Scored[] asym = new Scored[subsetN - 1]; + Scored[] sym = new Scored[subsetN - 1]; + + int idx = 0; + for (int j = 0; j < subsetN; j++) { + if (j == q) continue; + + VectorFloat xj = floatVectors.getVector(j); + + double gtScore = VectorUtil.dotProduct(xq, xj); + double asymScore = asymF.similarityTo(j); + double symScore = (double) symmetricDot(qEnc, ashVectors.get(j), d, muNormSq); + + gt[idx] = new Scored(j, gtScore); + asym[idx] = new Scored(j, asymScore); + sym[idx] = new Scored(j, symScore); + idx++; + } + + int[] gtTop = topKIds(gt, topK); + int[] asymTop = topKIds(asym, topK); + int[] symTop = topKIds(sym, topK); + + System.out.printf("overlap(GT, ASYM) = %.2f%%%n", 100.0 * overlapFraction(gtTop, asymTop)); + System.out.printf("overlap(GT, SYM) = %.2f%%%n", 100.0 * overlapFraction(gtTop, symTop)); + System.out.printf("overlap(ASYM, SYM) = %.2f%%%n", 100.0 * overlapFraction(asymTop, symTop)); + } + + private static int[] topKIds(Scored[] arr, int k) { + int kk = Math.min(k, arr.length); + Arrays.sort(arr, new Comparator() { + @Override + public int compare(Scored a, Scored b) { + return Double.compare(b.score, a.score); // descending + } + }); + int[] out = new int[kk]; + for (int i = 0; i < kk; i++) out[i] = arr[i].id; + Arrays.sort(out); + return out; + } + + private static double overlapFraction(int[] aSorted, int[] bSorted) { + int i = 0, j = 0, hit = 0; + while (i < aSorted.length && j < bSorted.length) { + int va = aSorted[i]; + int vb = bSorted[j]; + if (va == vb) { hit++; i++; j++; } + else if (va < vb) i++; + else j++; + } + return (double) hit / (double) aSorted.length; + } + + private static final class Scored { + final int id; + final double score; + Scored(int id, double score) { this.id = id; this.score = score; } + } + + private static void sanityCheckBinaryMath(int d) { + if ((d & 63) != 0) { + System.out.println("[sanity] skipped: d not multiple of 64"); + return; + } + + // For sign vectors s in {-1,+1}^d represented as bits b via s = 2b - 1: + // zeros·zeros = d, ones·ones = d, zeros·ones = -d + float exp00 = (float) d; + float exp11 = (float) d; + float exp01 = -(float) d; + + System.out.printf("[sanity] expected signDot: 00=%.1f 11=%.1f 01=%.1f%n", exp00, exp11, exp01); + + int words = (d + 63) >>> 6; + long[] zeros = new long[words]; + long[] ones = new long[words]; + Arrays.fill(ones, ~0L); + + float got00 = computeSignDotFromBits(zeros, zeros, d); + float got11 = computeSignDotFromBits(ones, ones, d); + float got01 = computeSignDotFromBits(zeros, ones, d); + + System.out.printf("[sanity] computed signDot: 00=%.1f 11=%.1f 01=%.1f%n", got00, got11, got01); + } + + private static float computeSignDotFromBits(long[] aBits, long[] bBits, int d) { + int matches = 0; + for (int w = 0; w < aBits.length; w++) { + matches += Long.bitCount(~(aBits[w] ^ bBits[w])); + } + return 2.0f * (float) matches - (float) d; + } +} diff --git a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/util/CompressorParameters.java b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/util/CompressorParameters.java index f84b69938..d7dd7f4c5 100644 --- a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/util/CompressorParameters.java +++ b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/util/CompressorParameters.java @@ -16,11 +16,14 @@ package io.github.jbellis.jvector.example.util; +import io.github.jbellis.jvector.quantization.AsymmetricHashing; import io.github.jbellis.jvector.quantization.BinaryQuantization; import io.github.jbellis.jvector.quantization.NVQuantization; import io.github.jbellis.jvector.quantization.ProductQuantization; import io.github.jbellis.jvector.quantization.VectorCompressor; +import java.io.IOException; + public abstract class CompressorParameters { public static final CompressorParameters NONE = new NoCompressionParameters(); @@ -100,5 +103,36 @@ public VectorCompressor computeCompressor(DataSet ds) { return null; } } + public static class ASHParameters extends CompressorParameters { + private final int optimizer; + private final int encodedBits; + private final int landmarkCount; + + public ASHParameters(int optimizer, int encodedBits, int landmarkCount) { + this.optimizer = optimizer; + this.encodedBits = encodedBits; + this.landmarkCount = landmarkCount; + } + + @Override + public VectorCompressor computeCompressor(DataSet ds) { + try { + return AsymmetricHashing.initialize(ds.getBaseRavv(), optimizer, encodedBits, landmarkCount); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + + @Override + public String idStringFor(DataSet ds) { + return String.format("ASH_%s_opt%d_bits%d_C%d", ds.getName(), optimizer, encodedBits, landmarkCount); + } + + // TODO set to true for production after debug is complete. + @Override + public boolean supportsCaching() { + return false; + } + } } diff --git a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/util/MultiFileDatasource.java b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/util/MultiFileDatasource.java index 8bba2bd88..3eb7c2bbb 100644 --- a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/util/MultiFileDatasource.java +++ b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/util/MultiFileDatasource.java @@ -51,7 +51,7 @@ public DataSet load() throws IOException { var baseVectors = SiftLoader.readFvecs("fvec/" + basePath); var queryVectors = SiftLoader.readFvecs("fvec/" + queriesPath); var gtVectors = SiftLoader.readIvecs("fvec/" + groundTruthPath); - return DataSetUtils.getScrubbedDataSet(name, VectorSimilarityFunction.COSINE, baseVectors, queryVectors, gtVectors); + return DataSetUtils.getScrubbedDataSet(name, VectorSimilarityFunction.DOT_PRODUCT, baseVectors, queryVectors, gtVectors); } public static Map byName = new HashMap<>() {{ @@ -95,6 +95,10 @@ public DataSet load() throws IOException { "wikipedia_squad/100k/text-embedding-3-small_1536_100000_base_vectors.fvec", "wikipedia_squad/100k/text-embedding-3-small_1536_100000_query_vectors_10000.fvec", "wikipedia_squad/100k/text-embedding-3-small_1536_100000_indices_query_10000.ivec")); + put("ada002-100k-no-zeros", new MultiFileDatasource("ada002-100k", + "ada-002-no-zeros/ada_002_100000_base_vectors_no_zeros.fvec", + "ada-002-no-zeros/ada_002_100000_query_vectors_10000_no_zeros.fvec", + "ada-002-no-zeros/ada-002_gt_no_zeros.ivec")); put("ada002-100k", new MultiFileDatasource("ada002-100k", "wikipedia_squad/100k/ada_002_100000_base_vectors.fvec", "wikipedia_squad/100k/ada_002_100000_query_vectors_10000.fvec", diff --git a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/yaml/Compression.java b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/yaml/Compression.java index ef1013e80..42ee2f633 100644 --- a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/yaml/Compression.java +++ b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/yaml/Compression.java @@ -60,6 +60,38 @@ public Function getCompressorParameters() { }; case "BQ": return ds -> new CompressorParameters.BQParameters(); + case "ASH": + return ds -> { + if (parameters == null) { + throw new IllegalArgumentException("ASH requires parameters: encodedBits, optimizer, landmarkCount"); + } + + int encodedBits = Integer.parseInt(parameters.get("encodedBits")); + int landmarkCount = Integer.parseInt(parameters.getOrDefault("landmarkCount", "1")); + + // optimizer can be numeric or name (RANDOM/ITQ/LANDING) + String optStr = parameters.get("optimizer"); + if (optStr == null) { + throw new IllegalArgumentException("ASH requires 'optimizer' parameter"); + } + + int optimizer; + switch (optStr.trim().toUpperCase()) { + case "RANDOM": + optimizer = io.github.jbellis.jvector.quantization.AsymmetricHashing.RANDOM; + break; + case "ITQ": + optimizer = io.github.jbellis.jvector.quantization.AsymmetricHashing.ITQ; + break; + case "LANDING": + optimizer = io.github.jbellis.jvector.quantization.AsymmetricHashing.LANDING; + break; + default: + optimizer = Integer.parseInt(optStr); + } + + return new CompressorParameters.ASHParameters(optimizer, encodedBits, landmarkCount); + }; default: throw new IllegalArgumentException("Unsupported compression type: " + type); diff --git a/jvector-examples/yaml-configs/datasets.yml b/jvector-examples/yaml-configs/datasets.yml index 3fd67217e..049ce470b 100644 --- a/jvector-examples/yaml-configs/datasets.yml +++ b/jvector-examples/yaml-configs/datasets.yml @@ -1,23 +1,24 @@ neighborhood-watch-100k: - - ada002-100k - - cohere-english-v3-100k - - openai-v3-small-100k - - gecko-100k - - openai-v3-large-3072-100k - - openai-v3-large-1536-100k - - e5-small-v2-100k - - e5-base-v2-100k - - e5-large-v2-100k +# - ada002-100k + - ada002-100k-no-zeros +# - cohere-english-v3-100k +# - openai-v3-small-100k +# - gecko-100k +# - openai-v3-large-3072-100k +# - openai-v3-large-1536-100k +# - e5-small-v2-100k +# - e5-base-v2-100k +# - e5-large-v2-100k neighborhood-watch-1M: - - ada002-1M - - colbert-1M +# - ada002-1M +# - colbert-1M ann-benchmarks: - - glove-25-angular.hdf5 - - glove-50-angular.hdf5 - - lastfm-64-dot.hdf5 - - glove-100-angular.hdf5 - - glove-200-angular.hdf5 - - nytimes-256-angular.hdf5 - - sift-128-euclidean.hdf5 +# - glove-25-angular.hdf5 +# - glove-50-angular.hdf5 +# - lastfm-64-dot.hdf5 +# - glove-100-angular.hdf5 +# - glove-200-angular.hdf5 +# - nytimes-256-angular.hdf5 +# - sift-128-euclidean.hdf5 # - deep-image-96-angular.hdf5 # large files not yet supported # - gist-960-euclidean.hdf5 # large files not yet supported \ No newline at end of file diff --git a/jvector-examples/yaml-configs/default.yml b/jvector-examples/yaml-configs/default.yml index 74a42f973..42f34b7aa 100644 --- a/jvector-examples/yaml-configs/default.yml +++ b/jvector-examples/yaml-configs/default.yml @@ -3,23 +3,28 @@ version: 6 dataset: default construction: - outDegree: [32] - efConstruction: [100] + outDegree: [64] + efConstruction: [200] neighborOverflow: [1.2f] addHierarchy: [Yes] refineFinalGraph: [Yes] fusedGraph: [No] compression: - - type: PQ +# - type: PQ +# parameters: +# m: 192 # we can either specify the integer m or the integer mFactor. In this case, m will be set to the data dimensionality divided by mFactor +# # mFactor: 8 +# # k: 256 # optional parameter. By default, k=256 +# centerData: No +# anisotropicThreshold: -1.0 # optional parameter. By default, anisotropicThreshold=-1 (i.e., no anisotropy) + - type: ASH parameters: - m: 192 # we can either specify the integer m or the integer mFactor. In this case, m will be set to the data dimensionality divided by mFactor - # mFactor: 8 - # k: 256 # optional parameter. By default, k=256 - centerData: No - anisotropicThreshold: -1.0 # optional parameter. By default, anisotropicThreshold=-1 (i.e., no anisotropy) + encodedBits: "1544" + optimizer: "ITQ" + landmarkCount: "1" reranking: - - NVQ - useSavedIndexIfExists: Yes + - FP + useSavedIndexIfExists: No search: topKOverquery: @@ -27,12 +32,17 @@ search: 100: [1.0, 2.0] useSearchPruning: [Yes] compression: - - type: PQ +# - type: PQ +# parameters: +# m: 192 +# # k: 256 # optional parameter. By default, k=256 +# centerData: No +# anisotropicThreshold: -1.0 # optional parameter. By default, anisotropicThreshold=-1 (i.e., no anisotropy) + - type: ASH parameters: - m: 192 - # k: 256 # optional parameter. By default, k=256 - centerData: No - anisotropicThreshold: -1.0 # optional parameter. By default, anisotropicThreshold=-1 (i.e., no anisotropy) + encodedBits: "1544" + optimizer: "ITQ" + landmarkCount: "1" benchmarks: # full option set, if the whole "benchmarks" section is not specified, a default set will be used throughput: [ AVG ] # additional options: [AVG, MEDIAN, MAX] latency: [ AVG ] # additional options: [ AVG, STD, P999 ] From e9ee89516fc99218266acb3cca136ad76cc56932 Mon Sep 17 00:00:00 2001 From: Ted Willke Date: Sat, 14 Feb 2026 23:50:00 +0000 Subject: [PATCH 032/243] Batch kmeans clustering. Renamed various scorers. Introduced new packed layout with header interleaving. Introduced ashMaskedAdd_512. --- .../jvector/quantization/ASHVectors.java | 233 +++++- .../quantization/AsymmetricHashing.java | 21 +- .../MiniBatchKMeansClusterer.java | 419 ++++++++++ .../vector/DefaultVectorUtilSupport.java | 98 +++ .../jvector/vector/VectorUtilSupport.java | 9 + .../jbellis/jvector/example/DistancesASH.java | 70 +- .../jbellis/jvector/example/DistancesPQ.java | 19 +- .../example/util/MultiFileDatasource.java | 10 +- jvector-examples/yaml-configs/datasets.yml | 8 +- jvector-examples/yaml-configs/default.yml | 16 +- .../vector/PanamaVectorUtilSupport.java | 737 ++++++++++++++++++ 11 files changed, 1577 insertions(+), 63 deletions(-) create mode 100644 jvector-base/src/main/java/io/github/jbellis/jvector/quantization/MiniBatchKMeansClusterer.java diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHVectors.java b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHVectors.java index 10e66a487..d69086ce2 100644 --- a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHVectors.java +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHVectors.java @@ -57,6 +57,19 @@ public class ASHVectors implements CompressedVectors { final AsymmetricHashing.QuantizedVector[] compressedVectors; final ASHScorer scorer; + enum AshSingleKernel { + AUTO, + SCALAR, + SIMD; + + static AshSingleKernel fromProperty() { + String v = System.getProperty("jvector.ash.singleKernel", "auto").toLowerCase(); + if ("scalar".equals(v)) return SCALAR; + if ("simd".equals(v)) return SIMD; + return AUTO; + } + } + enum AshBlockKernel { AUTO, SCALAR, @@ -84,6 +97,10 @@ static AshBlockKernel fromProperty() { private long[] packedBits = null; private int packedBlockCount = 0; + // Flat row-major bits for single-vector SIMD (built lazily) + // Layout: [v0_w0, v0_w1, ... v0_wN, v1_w0, ...] + private volatile long[] flatPackedVectors = null; + /** * Initialize ASHVectors with an array of ASH-compressed vectors. * @@ -192,13 +209,172 @@ public ScoreFunction.ApproximateScoreFunction scoreFunctionFor( VectorFloat query, VectorSimilarityFunction similarityFunction) { - // Scorer follows ASH paper (single landmark, DOT_PRODUCT only) - final ASHScorer.ASHScoreFunction f = scorer.scoreFunctionFor(query, similarityFunction); + if (similarityFunction != VectorSimilarityFunction.DOT_PRODUCT) { + throw new UnsupportedOperationException("ASH scorer supports DOT_PRODUCT only"); + } + + final AshSingleKernel kernel = AshSingleKernel.fromProperty(); + final VectorUtilSupport vecUtil = VectorizationProvider.getInstance().getVectorUtilSupport(); + + // Scalar forced + if (kernel == AshSingleKernel.SCALAR) { + final ASHScorer.ASHScoreFunction f = scorer.scoreFunctionFor(query, similarityFunction); + return node -> f.similarityTo(compressedVectors[node]); + } - // Wrap QuantizedVector scorer into ordinal-based score function + // AUTO/SIMD: use SIMD scorer only when backend supports masked-load kernel; + // otherwise fall back to the existing scalar behavior. + if ((kernel == AshSingleKernel.SIMD || kernel == AshSingleKernel.AUTO) + && vecUtil.supportsAshMaskedLoad()) { + final QueryPrecompute qp = precomputeQuery(query); + return new SimdASHScoreFunction(qp); + } + + // Fallback: existing scalar behavior (stable reference) + final ASHScorer.ASHScoreFunction f = scorer.scoreFunctionFor(query, similarityFunction); return node -> f.similarityTo(compressedVectors[node]); } + // ============================================================================ + // ASH single vector scoring (Optimized Zero-Copy) + // ============================================================================ + + private final class SimdASHScoreFunction implements ScoreFunction.ApproximateScoreFunction { + private final QueryPrecompute qp; + private final VectorUtilSupport vecUtil; + + // Cached Arrays (Zero-Copy) + private final float[] tildeQPool; + private final long[] allPackedVectors; + + private final int d; + private final int words; + + SimdASHScoreFunction(QueryPrecompute qp) { + this.qp = qp; + this.vecUtil = VectorizationProvider.getInstance().getVectorUtilSupport(); + this.d = qp.d; + this.words = ASHVectors.this.words; // access outer class field + + // 1. Grab direct reference to the pool + this.tildeQPool = qp.tildeQPool; + + // 2. Trigger/Get the flat array from the outer class + this.allPackedVectors = getFlatPackedVectors(); + } + + @Override + public float similarityTo(int node2) { + + final int c = landmarks[node2] & 0xFF; + final int qOffset = c * d; + final int packedBase = node2 * words; + + // 2. Run existing optimized v4 Kernel + final float maskedAdd = vecUtil.ashMaskedAdd_512( + tildeQPool, + qOffset, + allPackedVectors, + packedBase, + d, + words + ); + + return scales[node2] * (2f * maskedAdd - qp.sumTildeQ[c]) + qp.dotQMu[c] + offsets[node2]; + } + +// @Override +// public float similarityTo(int node2) { +// // A. Identify Landmark (Supports C >= 1) +// // 'landmarks' is a field in the outer ASHVectors class +// final int c = landmarks[node2] & 0xFF; +// final int qOffset = c * d; +// +// // B. Identify Vector Location in the flat array +// // Integer multiply is significantly faster than object pointer chasing +// final int packedBase = node2 * words; +// +// // C. Run Kernel (Zero-Copy) +// // Note: Ensure your VectorUtilSupport has the 'ashMaskedAddFlat' method signature +// final float maskedAdd = vecUtil.ashMaskedAddFlatOptimized( +// tildeQPool, +// qOffset, +// allPackedVectors, +// packedBase, +// d, +// words +// ); +// +// // D. Linear Combination +// // scales[] and offsets[] are fields in the outer ASHVectors class +// return scales[node2] * (2f * maskedAdd - qp.sumTildeQ[c]) +// + qp.dotQMu[c] +// + offsets[node2]; +// } + + @Override + public boolean isExact() { + return false; + } + } +// +// // ============================================================================ +// // ASH single vector scoring +// // ============================================================================ +// +// private final class SimdASHScoreFunction implements ScoreFunction.ApproximateScoreFunction { +// private final QueryPrecompute qp; +// private final VectorUtilSupport vecUtil; +// +// private final int d; +// private final int words; +// +// // Scratch for current landmark +// private final float[] tildeQScratch; +// private int lastC = -1; +// +// SimdASHScoreFunction(QueryPrecompute qp) { +// this.qp = qp; +// this.vecUtil = VectorizationProvider.getInstance().getVectorUtilSupport(); +// this.d = qp.d; +// this.words = AsymmetricHashing.QuantizedVector.wordsForDims(d); +// this.tildeQScratch = new float[d]; +// } +// +// @Override +// public float similarityTo(int node2) { +// final int c = landmarks[node2] & 0xFF; +// final int base = c * d; +// +// if (c != lastC) { +// System.arraycopy(qp.tildeQPool, base, tildeQScratch, 0, d); +// lastC = c; +// } +// +// // by-vector bits +// final long[] bits = compressedVectors[node2].binaryVector; +// +// // Route masked add through VectorUtilSupport. +// // Default impl is scalar; Panama can override for SIMD. +// final float maskedAdd = vecUtil.ashMaskedAddAllWords( +// tildeQScratch, +// d, +// bits, +// 0, // packedBase (by-vector, so base is 0) +// words +// ); +// +// return scales[node2] * (2f * maskedAdd - qp.sumTildeQ[c]) +// + qp.dotQMu[c] +// + offsets[node2]; +// } +// +// @Override +// public boolean isExact() { +// return false; +// } +// } + // ============================================================================ // ASH block-oriented scoring // ============================================================================ @@ -216,12 +392,12 @@ public ScoreFunction.ApproximateScoreFunction scoreFunctionFor( * No block-level optimizations are applied. *

    */ - private static final class ScalarASHBlockScorer implements ASHBlockScorer { + private static final class LegacyASHBlockScorer implements ASHBlockScorer { private final ASHScorer.ASHScoreFunction scorer; private final AsymmetricHashing.QuantizedVector[] vectors; - ScalarASHBlockScorer( + LegacyASHBlockScorer( ASHScorer.ASHScoreFunction scorer, AsymmetricHashing.QuantizedVector[] vectors) { this.scorer = scorer; @@ -268,7 +444,7 @@ public ASHBlockScorer blockScorerFor( ASHScorer.ASHScoreFunction f = scorer.scoreFunctionFor(query, similarityFunction); - return new ScalarASHBlockScorer(f, compressedVectors); + return new LegacyASHBlockScorer(f, compressedVectors); } /** @@ -319,7 +495,7 @@ public ASHBlockScorer blockScorerFor( // Forced scalar path (portable register-accumulator) // ------------------------------------------------------------ if (kernel == AshBlockKernel.SCALAR) { - return new BlockScalarASHBlockScorer( + return new ScalarASHBlockScorer( compressedVectors, scales, offsets, @@ -336,7 +512,7 @@ public ASHBlockScorer blockScorerFor( // or falls back to scalar internally. ensurePackedBits(blockSize); - return new BlockSimdASHBlockScorer( + return new SimdASHBlockScorer( compressedVectors, scales, offsets, @@ -375,6 +551,39 @@ private void ensurePackedBits(int blockSize) { } } + /** + * Returns a monolithic long[] containing all quantized bits. + *

    + * This method is thread-safe and computes the view lazily. + * Use this for single-vector SIMD scoring to avoid pointer chasing. + *

    + */ + public long[] getFlatPackedVectors() { + if (flatPackedVectors == null) { + synchronized (this) { + if (flatPackedVectors == null) { + flatPackedVectors = createFlatPackedVectors(); + } + } + } + return flatPackedVectors; + } + + private long[] createFlatPackedVectors() { + final int n = compressedVectors.length; + if (n == 0) return new long[0]; + + // words is already a class field + long[] flat = new long[n * words]; + + for (int i = 0; i < n; i++) { + var v = compressedVectors[i]; + if (v != null && v.binaryVector != null) { + System.arraycopy(v.binaryVector, 0, flat, i * words, words); + } + } + return flat; + } /** * Block-oriented ASH scorer using register-local accumulation. @@ -385,7 +594,7 @@ private void ensurePackedBits(int blockSize) { * into scalar registers. *

    */ - private static final class BlockScalarASHBlockScorer implements ASHBlockScorer { + private static final class ScalarASHBlockScorer implements ASHBlockScorer { private final AsymmetricHashing.QuantizedVector[] vectors; private final float[] scales; @@ -398,7 +607,7 @@ private static final class BlockScalarASHBlockScorer implements ASHBlockScorer { private final int d; private final int words; - BlockScalarASHBlockScorer( + ScalarASHBlockScorer( AsymmetricHashing.QuantizedVector[] vectors, float[] scales, float[] offsets, @@ -478,7 +687,7 @@ public void scoreRange(int start, int count, float[] out) { * avoiding per-lane memory traffic and restoring performance. *

    */ - private static final class BlockSimdASHBlockScorer implements ASHBlockScorer { + private static final class SimdASHBlockScorer implements ASHBlockScorer { private final AsymmetricHashing.QuantizedVector[] vectors; private final float[] scales; @@ -506,7 +715,7 @@ private static final class BlockSimdASHBlockScorer implements ASHBlockScorer { private static final java.util.concurrent.atomic.AtomicBoolean PRINTED_MIXED_ONCE = new java.util.concurrent.atomic.AtomicBoolean(false); - BlockSimdASHBlockScorer( + SimdASHBlockScorer( AsymmetricHashing.QuantizedVector[] vectors, float[] scales, float[] offsets, diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/AsymmetricHashing.java b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/AsymmetricHashing.java index f4486a17a..da172ef17 100644 --- a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/AsymmetricHashing.java +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/AsymmetricHashing.java @@ -310,7 +310,8 @@ public static AsymmetricHashing initialize(RandomAccessVectorValues ravv, long kmStart = System.nanoTime(); // KMeans++ in JVector returns centroids packed as one big vector [C * D] - KMeansPlusPlusClusterer km = new KMeansPlusPlusClusterer(points, landmarkCount); + // KMeansPlusPlusClusterer km = new KMeansPlusPlusClusterer(points, landmarkCount); + MiniBatchKMeansClusterer km = new MiniBatchKMeansClusterer(points, landmarkCount, 1024); VectorFloat packed = km.cluster(/*unweightedIterations*/ 20, /*anisotropicIterations*/ 0); long kmEnd = System.nanoTime(); @@ -398,15 +399,15 @@ private static void validateEncodedBits( ); } - if ((payloadBits & 63) != 0) { - throw new IllegalArgumentException( - "Invalid encodedBits=" + encodedBits + - ". ASH requires (encodedBits - headerBits) to be a multiple of 64 " + - "for aligned binary payload. " + - "Got payloadBits=" + payloadBits + - " (headerBits=" + headerBits + ")." - ); - } +// if ((payloadBits & 63) != 0) { +// throw new IllegalArgumentException( +// "Invalid encodedBits=" + encodedBits + +// ". ASH requires (encodedBits - headerBits) to be a multiple of 64 " + +// "for aligned binary payload. " + +// "Got payloadBits=" + payloadBits + +// " (headerBits=" + headerBits + ")." +// ); +// } if (payloadBits > originalDim) { throw new IllegalArgumentException( diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/MiniBatchKMeansClusterer.java b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/MiniBatchKMeansClusterer.java new file mode 100644 index 000000000..9a337e980 --- /dev/null +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/MiniBatchKMeansClusterer.java @@ -0,0 +1,419 @@ +/* + * 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 io.github.jbellis.jvector.vector.Matrix; +import io.github.jbellis.jvector.vector.VectorUtil; +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.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.concurrent.ThreadLocalRandom; + +import static io.github.jbellis.jvector.util.MathUtil.square; +import static io.github.jbellis.jvector.vector.VectorUtil.*; +import static java.lang.Math.max; + +/** + * A MiniBatch KMeans implementation for float vectors. + * Drop-in replacement for KMeansPlusPlusClusterer, optimized for large datasets (10M+). + * * Includes: + * 1. Mini-Batch Stochastic Updates (fast convergence). + * 2. K-Means++ Subsampling Initialization (fast startup). + */ +public class MiniBatchKMeansClusterer { + private static final VectorTypeSupport vectorTypeSupport = VectorizationProvider.getInstance().getVectorTypeSupport(); + + private static final boolean VERBOSE_KMEANS = Boolean.getBoolean("jvector.kmeans.verbose"); + private static final boolean VERBOSE_KMEANS_TIMING = Boolean.getBoolean("jvector.kmeans.timing"); + + public static final float UNWEIGHTED = -1.0f; + + private final int k; + private final VectorFloat[] points; + private final int[] assignments; + private final VectorFloat centroids; + private final float anisotropicThreshold; + + // MiniBatch specific + private final int batchSize; + private final int[] centroidCounts; + + /** + * @param batchSize Recommended: 1024 or 2048 for large datasets + */ + public MiniBatchKMeansClusterer(VectorFloat[] points, int k, int batchSize) { + this(points, chooseInitialCentroids(points, k), UNWEIGHTED, batchSize); + } + + public MiniBatchKMeansClusterer(VectorFloat[] points, int k, float anisotropicThreshold, int batchSize) { + this(points, chooseInitialCentroids(points, k), anisotropicThreshold, batchSize); + } + + public MiniBatchKMeansClusterer(VectorFloat[] points, VectorFloat centroids, float anisotropicThreshold, int batchSize) { + if (Float.isNaN(anisotropicThreshold) || anisotropicThreshold < -1.0 || anisotropicThreshold >= 1.0) { + throw new IllegalArgumentException("Valid range for anisotropic threshold T is -1.0 <= t < 1.0"); + } + + this.points = points; + this.k = centroids.length() / points[0].length(); + this.centroids = centroids.copy(); + this.anisotropicThreshold = anisotropicThreshold; + this.batchSize = Math.min(batchSize, points.length); + + this.centroidCounts = new int[k]; + this.assignments = new int[points.length]; + Arrays.fill(this.assignments, -1); + } + + static float computeParallelCostMultiplier(double threshold, int dimensions) { + assert Double.isFinite(threshold) : "threshold=" + threshold; + double parallelCost = threshold * threshold; + double perpendicularCost = (1 - parallelCost) / (dimensions - 1); + return (float) max(1.0, (parallelCost / perpendicularCost)); + } + + /** + * Performs clustering. + * @param unweightedIterations If small (< 100), interpreted as EPOCHS (full passes). + * If large, interpreted as TOTAL BATCH UPDATES. + * @param anisotropicIterations Number of full-batch anisotropic refinement steps. + */ + public VectorFloat cluster(int unweightedIterations, int anisotropicIterations) { + + // ------------------------------------------------------------ + // Mini-Batch Unweighted (isotropic) k-means phase + // ------------------------------------------------------------ + int totalBatches = unweightedIterations; + // Interpret small iteration counts as Epochs to ensure sufficient data coverage + if (unweightedIterations < 100) { + int batchesPerEpoch = Math.max(1, points.length / batchSize); + totalBatches = unweightedIterations * batchesPerEpoch; + } + + long tTotal0 = System.nanoTime(); + + for (int i = 0; i < totalBatches; i++) { + long t0 = VERBOSE_KMEANS_TIMING ? System.nanoTime() : 0L; + + clusterOnceMiniBatch(); + + long t1 = VERBOSE_KMEANS_TIMING ? System.nanoTime() : 0L; + if (VERBOSE_KMEANS_TIMING && i % 100 == 0) { + System.out.printf("MiniBatch iter %d time = %.3f ms%n", i, (t1 - t0) * 1e-6); + } + } + + if (VERBOSE_KMEANS) { + System.out.printf("MiniBatch unweighted phase finished. Total batches: %d. Total time: %.3f s%n", + totalBatches, (System.nanoTime() - tTotal0) * 1e-9); + } + + // ------------------------------------------------------------ + // Anisotropic refinement phase (Full Batch) + // ------------------------------------------------------------ + if (anisotropicIterations > 0) { + // MiniBatch leaves assignments in flux; we must perform a full assignment pass + // so the anisotropic phase has valid global clusters. + assignAllPoints(); + + final int threshold = Math.max(1, (int) (0.01 * points.length)); + + for (int i = 0; i < anisotropicIterations; i++) { + long t0 = VERBOSE_KMEANS_TIMING ? System.nanoTime() : 0L; + int changedCount = clusterOnceAnisotropic(); + long t1 = VERBOSE_KMEANS_TIMING ? System.nanoTime() : 0L; + + if (VERBOSE_KMEANS) { + System.out.printf("KMeans anisotropic iter %d: %d/%d reassigned (%.3f%%)%n", + i, changedCount, points.length, 100.0 * changedCount / points.length); + } + + if (VERBOSE_KMEANS_TIMING) { + System.out.printf(" time = %.3f ms%n", (t1 - t0) * 1e-6); + } + + if (changedCount <= threshold) { + if (VERBOSE_KMEANS) System.out.println("KMeans anisotropic converged early"); + break; + } + } + } + + return centroids; + } + + /** + * Performs a single MiniBatch update step: + * 1. Sample batch + * 2. Find nearest centroids + * 3. Update centroids immediately using learning rate + */ + public void clusterOnceMiniBatch() { + var random = ThreadLocalRandom.current(); + + // 1. Sample indices + int[] batchIndices = new int[batchSize]; + for (int i = 0; i < batchSize; i++) { + batchIndices[i] = random.nextInt(points.length); + } + + // 2. Compute assignments for the batch + int[] batchAssignments = new int[batchSize]; + for(int i = 0; i < batchSize; i++) { + batchAssignments[i] = getNearestCluster(points[batchIndices[i]]); + } + + // 3. Update Centroids (Online) + int dim = centroids.length() / k; + VectorFloat tempDiff = vectorTypeSupport.createFloatVector(dim); + + for (int i = 0; i < batchSize; i++) { + int pointIdx = batchIndices[i]; + int clusterIdx = batchAssignments[i]; + VectorFloat point = points[pointIdx]; + + centroidCounts[clusterIdx]++; + float learningRate = 1.0f / centroidCounts[clusterIdx]; + + // tempDiff = X + tempDiff.copyFrom(point, 0, 0, dim); + // tempDiff = X - C_old + subFromOffset(tempDiff, centroids, clusterIdx * dim); + + // tempDiff = lr * (X - C_old) + scale(tempDiff, learningRate); + + // C_new = C_old + tempDiff + // Pass the offset explicitly as the second argument + addToOffset(centroids, clusterIdx * dim, tempDiff); + } + } + + // --- Helpers --- + + private void assignAllPoints() { + for (int i = 0; i < points.length; i++) { + assignments[i] = getNearestCluster(points[i]); + } + } + + public int clusterOnceAnisotropic() { + updateCentroidsAnisotropic(); + return updateAssignedPointsAnisotropic(); + } + + // addToOffset: Adds a SMALL vector (source) into a LARGE vector (dest) at a specific offset. + private static void addToOffset(VectorFloat dest, int destOffset, VectorFloat source) { + // Fix: Iterate over source.length() (Small Temp Vector) + for (int i = 0; i < source.length(); i++) { + dest.set(destOffset + i, dest.get(destOffset + i) + source.get(i)); + } + } + + // subFromOffset: Subtracts a slice of a LARGE vector (source) from a SMALL vector (dest). + // (This logic was actually correct, but verifying strictly ensures safety) + private static void subFromOffset(VectorFloat dest, VectorFloat source, int sourceOffset) { + for (int i = 0; i < dest.length(); i++) { + dest.set(i, dest.get(i) - source.get(sourceOffset + i)); + } + } + + // --- Optimized Initialization Logic --- + + /** + * Chooses the initial centroids for clustering using K-Means++. + * If the dataset is large, uses a random subsample to accelerate initialization. + */ + private static VectorFloat chooseInitialCentroids(VectorFloat[] points, int k) { + if (k <= 0) throw new IllegalArgumentException("Number of clusters must be positive."); + if (k > points.length) throw new IllegalArgumentException("K cannot exceed N"); + + // Heuristic: If N is massive, run KMeans++ on a subset only. + // Cap at 50,000 points or 10*k to ensure statistical representation without performance penalty. + int maxInitSamples = Math.max(k * 10, 50_000); + + if (points.length <= maxInitSamples) { + return runStandardKMeansPlusPlus(points, k); + } else { + if (VERBOSE_KMEANS) { + System.out.printf("Subsampling %d points for K-Means++ initialization...%n", maxInitSamples); + } + var random = ThreadLocalRandom.current(); + VectorFloat[] subset = new VectorFloat[maxInitSamples]; + for (int i = 0; i < maxInitSamples; i++) { + subset[i] = points[random.nextInt(points.length)]; + } + return runStandardKMeansPlusPlus(subset, k); + } + } + + /** + * Standard KMeans++ implementation. + * When called by chooseInitialCentroids, 'points' may be the full set or a subset. + */ + private static VectorFloat runStandardKMeansPlusPlus(VectorFloat[] points, int k) { + var random = ThreadLocalRandom.current(); + VectorFloat centroids = vectorTypeSupport.createFloatVector(k * points[0].length()); + + float[] distances = new float[points.length]; + Arrays.fill(distances, Float.MAX_VALUE); + VectorFloat distancesVector = vectorTypeSupport.createFloatVector(distances); + + VectorFloat firstCentroid = points[random.nextInt(points.length)]; + centroids.copyFrom(firstCentroid, 0, 0, firstCentroid.length()); + + VectorFloat newDistancesVector = vectorTypeSupport.createFloatVector(points.length); + for (int i = 0; i < points.length; i++) { + newDistancesVector.set(i, squareL2Distance(points[i], firstCentroid)); + } + VectorUtil.minInPlace(distancesVector, newDistancesVector); + + for (int i = 1; i < k; i++) { + float totalDistance = VectorUtil.sum(distancesVector); + float r = random.nextFloat() * totalDistance; + int selectedIdx = -1; + + for (int j = 0; j < points.length; j++) { + r -= distancesVector.get(j); + if (r < 1e-6) { + selectedIdx = j; + break; + } + } + if (selectedIdx == -1) selectedIdx = random.nextInt(points.length); + + VectorFloat nextCentroid = points[selectedIdx]; + centroids.copyFrom(nextCentroid, 0, i * nextCentroid.length(), nextCentroid.length()); + + for (int j = 0; j < points.length; j++) { + newDistancesVector.set(j, squareL2Distance(points[j], nextCentroid)); + } + VectorUtil.minInPlace(distancesVector, newDistancesVector); + } + return centroids; + } + + // --- Standard Distance and Update Logic --- + + private int getNearestCluster(VectorFloat point) { + float minDistance = Float.MAX_VALUE; + int nearestCluster = 0; + int dim = point.length(); + + for (int i = 0; i < k; i++) { + float distance = squareL2Distance(point, 0, centroids, i * dim, dim); + if (distance < minDistance) { + minDistance = distance; + nearestCluster = i; + } + } + return nearestCluster; + } + + private int updateAssignedPointsAnisotropic() { + float pcm = computeParallelCostMultiplier(anisotropicThreshold, points[0].length()); + int dim = points[0].length(); + + float[] cNormSquared = new float[k]; + for (int i = 0; i < k; i++) { + cNormSquared[i] = dotProduct(centroids, i * dim, centroids, i * dim, dim); + } + + int changedCount = 0; + for (int i = 0; i < points.length; i++) { + var x = points[i]; + var xNormSquared = dotProduct(x, x); + + int index = assignments[i]; + float minDist = Float.MAX_VALUE; + for (int j = 0; j < k; j++) { + float dist = weightedDistance(x, j, pcm, cNormSquared[j], xNormSquared); + if (dist < minDist) { + minDist = dist; + index = j; + } + } + + if (index != assignments[i]) { + changedCount++; + assignments[i] = index; + } + } + return changedCount; + } + + private float weightedDistance(VectorFloat x, int centroid, float parallelCostMultiplier, float cNormSquared, float xNormSquared) { + float cDotX = VectorUtil.dotProduct(centroids, centroid * x.length(), x, 0, x.length()); + float parallelErrorSubtotal = cDotX - xNormSquared; + float residualSquaredNorm = cNormSquared - 2 * cDotX + xNormSquared; + float parallelError = square(parallelErrorSubtotal); + float perpendicularError = residualSquaredNorm - parallelError; + return parallelCostMultiplier * parallelError + perpendicularError; + } + + private void updateCentroidsAnisotropic() { + int dimensions = points[0].length(); + float pcm = computeParallelCostMultiplier(anisotropicThreshold, dimensions); + float orthogonalCostMultiplier = 1.0f / pcm; + + var pointsByCluster = new HashMap>(); + for (int i = 0; i < assignments.length; i++) { + pointsByCluster.computeIfAbsent(assignments[i], __ -> new ArrayList<>()).add(i); + } + + for (int i = 0; i < k; i++) { + var L = pointsByCluster.getOrDefault(i, List.of()); + if (L.isEmpty()) { + initializeCentroidToRandomPoint(i); + continue; + } + + var mean = vectorTypeSupport.createFloatVector(dimensions); + var outerProdSums = new Matrix(dimensions, dimensions); + for (int j : L) { + var point = points[j]; + // Offset is 0 because 'mean' is a fresh vector of the same dimension as 'point' + addToOffset(mean, 0, point); + float denom = dotProduct(point, point); + if (denom > 0) { + var op = Matrix.outerProduct(point, point); + op.scale(1.0f / denom); + outerProdSums.addInPlace(op); + } + } + outerProdSums.scale((1 - orthogonalCostMultiplier) / L.size()); + scale(mean, 1.0f / L.size()); + + for (int j = 0; j < dimensions; j++) { + outerProdSums.addTo(j, j, orthogonalCostMultiplier); + } + + var invertedMatrix = outerProdSums.invert(); + centroids.copyFrom(invertedMatrix.multiply(mean), 0, i * dimensions, dimensions); + } + } + + private void initializeCentroidToRandomPoint(int i) { + var random = ThreadLocalRandom.current(); + centroids.copyFrom(points[random.nextInt(points.length)], 0, i * points[0].length(), points[0].length()); + } +} 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..415bbf0bb 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 @@ -547,4 +547,102 @@ public float nvqUniformLoss(VectorFloat vector, float minValue, float maxValu return squaredSum; } + @Override + public float ashMaskedAddFlat(float[] tildeQ, + int qOffset, + long[] allPackedVectors, + int packedBase, + int d, + int words) { + float sum = 0.0f; + int baseDim = 0; + + for (int w = 0; w < words; w++) { + long word = allPackedVectors[packedBase + w]; + + // Standard bit-twiddling iteration (iterate only set bits) + while (word != 0L) { + int bit = Long.numberOfTrailingZeros(word); + int idx = baseDim + bit; + + // Bounds check required for the last word + if (idx < d) { + sum += tildeQ[qOffset + idx]; + } + + // Clear the lowest set bit + word &= (word - 1); + } + + baseDim += 64; + } + return sum; + } + + + @Override + public float ashMaskedAddFlat_dense(float[] tildeQ, + int qOffset, + long[] allPackedVectors, + int packedBase, + int d, + int words) { + float sum = 0.0f; + int baseDim = 0; + + for (int w = 0; w < words; w++) { + long word = allPackedVectors[packedBase + w]; + + // Standard bit-twiddling iteration (iterate only set bits) + while (word != 0L) { + int bit = Long.numberOfTrailingZeros(word); + int idx = baseDim + bit; + + // Bounds check required for the last word + if (idx < d) { + sum += tildeQ[qOffset + idx]; + } + + // Clear the lowest set bit + word &= (word - 1); + } + + baseDim += 64; + } + return sum; + } + + @Override + public float ashMaskedAdd_512(float[] tildeQ, + int qOffset, + long[] allPackedVectors, + int packedBase, + int d, + int words) { + float sum = 0.0f; + int baseDim = 0; + + for (int w = 0; w < words; w++) { + long word = allPackedVectors[packedBase + w]; + + // Standard bit-twiddling iteration (iterate only set bits) + while (word != 0L) { + int bit = Long.numberOfTrailingZeros(word); + int idx = baseDim + bit; + + // Bounds check required for the last word + if (idx < d) { + sum += tildeQ[qOffset + idx]; + } + + // Clear the lowest set bit + word &= (word - 1); + } + + baseDim += 64; + } + return sum; + } + + } diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/vector/VectorUtilSupport.java b/jvector-base/src/main/java/io/github/jbellis/jvector/vector/VectorUtilSupport.java index 9a84f8a6b..eeaaf223b 100644 --- a/jvector-base/src/main/java/io/github/jbellis/jvector/vector/VectorUtilSupport.java +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/vector/VectorUtilSupport.java @@ -298,6 +298,15 @@ default float ashMaskedAddAllWords( return maskedAdd; } + // In VectorUtilSupport interface + float ashMaskedAddFlat(float[] tildeQ, int qOffset, long[] allPackedVectors, int packedBase, int d, int words); + + // In VectorUtilSupport interface + float ashMaskedAdd_512(float[] tildeQ, int qOffset, long[] allPackedVectors, int packedBase, int d, int words); + + // In VectorUtilSupport interface + float ashMaskedAddFlat_dense(float[] tildeQ, int qOffset, long[] allPackedVectors, int packedBase, int d, int words); + /** * Computes maskedAdd for a block slice of vectors stored in packed block-column-major form. * For each lane in [0, blockLen), writes: diff --git a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/DistancesASH.java b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/DistancesASH.java index 0ea4b1456..bd07f7625 100644 --- a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/DistancesASH.java +++ b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/DistancesASH.java @@ -37,10 +37,23 @@ import java.util.concurrent.ForkJoinTask; public class DistancesASH { - private static void printScorerInfo(String label, ASHBlockScorer scorer) { + + static final String blockMode = System.getProperty("jvector.ash.blockKernel", "auto").toLowerCase(); + static final String singleMode = System.getProperty("jvector.ash.singleKernel", "auto").toLowerCase(); + + private static void printScorerInfo(String label, Object scorer) { + boolean maskedLoad = + io.github.jbellis.jvector.vector.VectorizationProvider.getInstance() + .getVectorUtilSupport() + .supportsAshMaskedLoad(); + System.out.println( "\t[" + label + "] scorer implementation = " + scorer.getClass().getName() + + " (singleKernel=" + singleMode + + ", blockKernel=" + blockMode + + ", supportsAshMaskedLoad=" + maskedLoad + + ")" ); } @@ -76,7 +89,7 @@ public static void testASHEncodings(String filenameBase, String filenameQueries, Boolean.parseBoolean(System.getProperty("jvector.bench.sanity-check", "false")); final boolean RUN_RECALL_CHECK = - Boolean.parseBoolean(System.getProperty("jvector.bench.recall", "true")); + Boolean.parseBoolean(System.getProperty("jvector.bench.recall", "false")); final int RECALL_K = Integer.getInteger("jvector.bench.recall.k", 10); @@ -85,7 +98,7 @@ public static void testASHEncodings(String filenameBase, String filenameQueries, Boolean.parseBoolean(System.getProperty("jvector.bench.accuracy", "false")); final boolean RUN_SCALAR_SCORING = - Boolean.parseBoolean(System.getProperty("jvector.bench.scalar-scoring", "false")); + Boolean.parseBoolean(System.getProperty("jvector.bench.scalar-scoring", "true")); final boolean RUN_FLOAT_SCORING = Boolean.parseBoolean(System.getProperty("jvector.bench.float-scoring", "false")); @@ -164,17 +177,17 @@ public static void testASHEncodings(String filenameBase, String filenameQueries, for (VectorFloat q : queries) VectorUtil.l2normalize(q); int dimension = vectors.get(0).length(); - int encodedBits = (dimension / 4) + HEADER_BITS; + int encodedBits = 328; // (dimension / 4) + HEADER_BITS; // Payload must be 64-bit aligned for SIMD int payloadBits = encodedBits - HEADER_BITS; - if ((payloadBits & 63) != 0) { - throw new IllegalArgumentException( - "ASH payloadBits must be 64-bit aligned for SIMD. " + - "Got payloadBits=" + payloadBits + - " (encodedBits=" + encodedBits + - ", HEADER_BITS=" + HEADER_BITS + ")" - ); - } +// if ((payloadBits & 63) != 0) { +// throw new IllegalArgumentException( +// "ASH payloadBits must be 64-bit aligned for SIMD. " + +// "Got payloadBits=" + payloadBits + +// " (encodedBits=" + encodedBits + +// ", HEADER_BITS=" + HEADER_BITS + ")" +// ); +// } System.out.println( "\toriginalDim = " + dimension + @@ -266,6 +279,12 @@ public static void testASHEncodings(String filenameBase, String filenameQueries, newToOldFinal = null; } + { + ScoreFunction.ApproximateScoreFunction single = + ashVecsFinal.scoreFunctionFor(q0, VectorSimilarityFunction.DOT_PRODUCT); + printScorerInfo("single", single); + } + if(RUN_SANITY_CHECK) { ASHBlockScorer ref = ashVectors.blockScorerFor(q0, VectorSimilarityFunction.DOT_PRODUCT); @@ -298,8 +317,8 @@ public static void testASHEncodings(String filenameBase, String filenameQueries, // ------------------------------------------------------------------ // Shared parallelization setup (executor-honoring) // ------------------------------------------------------------------ - ForkJoinPool simdExecutor = PhysicalCoreExecutor.pool(); // For production - // ForkJoinPool simdExecutor = new ForkJoinPool(4); // For profiling +// ForkJoinPool simdExecutor = PhysicalCoreExecutor.pool(); // For production + ForkJoinPool simdExecutor = new ForkJoinPool(180); // For profiling int parallelism = simdExecutor.getParallelism(); int chunkSize = Math.max(1, (finalQueryCount + parallelism - 1) / parallelism); @@ -438,7 +457,7 @@ public static void testASHEncodings(String filenameBase, String filenameQueries, for (int i = s; i < e; i++) { VectorFloat q = finalQueries.get(i); ScoreFunction.ApproximateScoreFunction f = - ashVecs.scoreFunctionFor(q, VectorSimilarityFunction.DOT_PRODUCT); + ashVecsFinal.scoreFunctionFor(q, VectorSimilarityFunction.DOT_PRODUCT); for (int j = 0; j < finalVectorCount; j++) { localSum += f.similarityTo(j); @@ -455,10 +474,25 @@ public static void testASHEncodings(String filenameBase, String filenameQueries, long ashEnd = System.nanoTime(); - System.out.println("\tScalar ASH dot-product computations took " - + (ashEnd - ashStart) / 1e9 + " seconds"); + double singleSeconds = (ashEnd - ashStart) / 1e9; + long totalDotProducts = (long) finalQueryCount * (long) finalVectorCount; + double singleThroughput = totalDotProducts / singleSeconds; + + System.out.println("\tSingle " + singleMode + " scoring took " + + singleSeconds + " seconds"); + + System.out.println( + "\tSingle " + singleMode + " throughput = " + + String.format(java.util.Locale.ROOT, "%.3f", singleThroughput) + + " dot-products/sec" + + " (" + + String.format(java.util.Locale.ROOT, "%.3f", singleThroughput / 1e6) + + " Mdot/s)" + ); + System.out.println("\tdummyAccumulator = " + (float) (ashDummy)); System.out.println("--"); + } // ================================================================== @@ -699,7 +733,7 @@ public static void main(String[] args) throws IOException { // runADANoZeros(); // runOpenai1536(); // runOpenai3072(); - runCap6m(); +// runCap6m(); runCohere10m(); } } diff --git a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/DistancesPQ.java b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/DistancesPQ.java index df415d326..2448a158f 100644 --- a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/DistancesPQ.java +++ b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/DistancesPQ.java @@ -52,13 +52,13 @@ public static void testPQEncodings(String filenameBase, String filenameQueries) // Benchmark configuration (runtime flags) // ------------------------------------------------------------ final boolean RUN_ACCURACY_CHECK = - Boolean.parseBoolean(System.getProperty("jvector.bench.accuracy-check", "true")); + Boolean.parseBoolean(System.getProperty("jvector.bench.accuracy-check", "false")); final boolean RUN_FLOAT_SCORING = Boolean.parseBoolean(System.getProperty("jvector.bench.float-scoring", "false")); // Define the benchmark size - int maxQueries = 1_000; - int maxVectors = 100_000; + int maxQueries = 10_000; + int maxVectors = 10_000_000; int queryCountInFile = SiftLoader.countFvecs(filenameQueries); int vectorCountInFile = SiftLoader.countFvecs(filenameBase); @@ -116,7 +116,8 @@ public static void testPQEncodings(String filenameBase, String filenameQueries) // ------------------------------------------------------------ // Parallel executor (identical to DistancesASH) // ------------------------------------------------------------ - ForkJoinPool simdExecutor = PhysicalCoreExecutor.pool(); +// ForkJoinPool simdExecutor = PhysicalCoreExecutor.pool(); // For production + ForkJoinPool simdExecutor = new ForkJoinPool(180); // For profiling int parallelism = simdExecutor.getParallelism(); int chunkSize = Math.max(1, (finalQueryCount + parallelism - 1) / parallelism); @@ -378,12 +379,12 @@ public static void runCohere10m() throws IOException { } public static void main(String[] args) throws IOException { - runCohere100k(); +// runCohere100k(); // runADA(); - runADANoZeros(); - runOpenai1536(); - runOpenai3072(); +// runADANoZeros(); +// runOpenai1536(); +// runOpenai3072(); // runCap6m(); -// runCohere10m(); + runCohere10m(); } } diff --git a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/util/MultiFileDatasource.java b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/util/MultiFileDatasource.java index 3eb7c2bbb..a48e5c379 100644 --- a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/util/MultiFileDatasource.java +++ b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/util/MultiFileDatasource.java @@ -140,8 +140,12 @@ public DataSet load() throws IOException { DATASET_HASH + "/cap/Caselaw_gte-Qwen2-1.5B_embeddings_query_10k_norm_shuffle.fvecs", DATASET_HASH + "/cap/cap_1m_gt_norm_shuffle_ip_k100.ivecs")); put("cap-6M", new MultiFileDatasource("cap-6M", - DATASET_HASH + "/cap/Caselaw_gte-Qwen2-1.5B_embeddings_base_6m_norm_shuffle.fvecs", - DATASET_HASH + "/cap/Caselaw_gte-Qwen2-1.5B_embeddings_query_10k_norm_shuffle.fvecs", - DATASET_HASH + "/cap/cap_6m_gt_norm_shuffle_ip_k100.ivecs")); + "cap-6m/Caselaw_gte-Qwen2-1.5B_embeddings_base_6m_norm_shuffle.fvecs", + "cap-6m/Caselaw_gte-Qwen2-1.5B_embeddings_query_10k_norm_shuffle.fvecs", + "cap-6m/cap_6m_gt_norm_shuffle_ip_k100.ivecs")); + put("cohere-10M", new MultiFileDatasource("cohere-10M", + "cohere-10m/cohere_wiki_en_flat_base_10m_norm.fvecs", + "cohere-10m/cohere_wiki_en_flat_query_10k_norm.fvecs", + "cohere-10m/cohere_wiki_en_flat_gt_10m_ip_k100.ivecs")); }}; } diff --git a/jvector-examples/yaml-configs/datasets.yml b/jvector-examples/yaml-configs/datasets.yml index 049ce470b..6d2223f24 100644 --- a/jvector-examples/yaml-configs/datasets.yml +++ b/jvector-examples/yaml-configs/datasets.yml @@ -1,8 +1,10 @@ neighborhood-watch-100k: # - ada002-100k - - ada002-100k-no-zeros -# - cohere-english-v3-100k -# - openai-v3-small-100k +# - ada002-100k-no-zeros +# - cap-6M +# - cohere-10M + - cohere-english-v3-100k + - openai-v3-small-100k # - gecko-100k # - openai-v3-large-3072-100k # - openai-v3-large-1536-100k diff --git a/jvector-examples/yaml-configs/default.yml b/jvector-examples/yaml-configs/default.yml index 42f34b7aa..678a3de7e 100644 --- a/jvector-examples/yaml-configs/default.yml +++ b/jvector-examples/yaml-configs/default.yml @@ -12,15 +12,15 @@ construction: compression: # - type: PQ # parameters: -# m: 192 # we can either specify the integer m or the integer mFactor. In this case, m will be set to the data dimensionality divided by mFactor +# m: 97 # we can either specify the integer m or the integer mFactor. In this case, m will be set to the data dimensionality divided by mFactor # # mFactor: 8 # # k: 256 # optional parameter. By default, k=256 # centerData: No # anisotropicThreshold: -1.0 # optional parameter. By default, anisotropicThreshold=-1 (i.e., no anisotropy) - type: ASH parameters: - encodedBits: "1544" - optimizer: "ITQ" + encodedBits: "776" + optimizer: "random" # random, ITQ landmarkCount: "1" reranking: - FP @@ -28,20 +28,20 @@ construction: search: topKOverquery: - 10: [1.0, 2.0, 5.0, 10.0] - 100: [1.0, 2.0] + 10: [1.0] +# 100: [1.0, 2.0] useSearchPruning: [Yes] compression: # - type: PQ # parameters: -# m: 192 +# m: 97 # # k: 256 # optional parameter. By default, k=256 # centerData: No # anisotropicThreshold: -1.0 # optional parameter. By default, anisotropicThreshold=-1 (i.e., no anisotropy) - type: ASH parameters: - encodedBits: "1544" - optimizer: "ITQ" + encodedBits: "776" + optimizer: "random" landmarkCount: "1" benchmarks: # full option set, if the whole "benchmarks" section is not specified, a default set will be used throughput: [ AVG ] # additional options: [AVG, MEDIAN, MAX] 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 d65364ea9..a5bf33353 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 @@ -26,6 +26,7 @@ import jdk.incubator.vector.VectorMask; import jdk.incubator.vector.VectorOperators; import jdk.incubator.vector.VectorSpecies; +import java.util.Objects; import java.util.List; @@ -1578,6 +1579,742 @@ public boolean supportsAshMaskedLoad() { return true; } +// // This version uses masked loads. +// @Override +// public float ashMaskedAddAllWords(float[] tildeQ, +// int d, +// long[] packedBits, +// int packedBase, +// int words) { +// final VectorSpecies SPEC = FloatVector.SPECIES_PREFERRED; +// final int LANES = SPEC.length(); +// +// FloatVector accVec = FloatVector.zero(SPEC); +// float scalarAcc = 0f; +// +// for (int w = 0; w < words; w++) { +// long wordBits = packedBits[packedBase + w]; +// int baseDim = w * 64; +// +// for (int off = 0; off < 64 && (baseDim + off) < d; off += LANES) { +// int qBase = baseDim + off; +// VectorMask inRange = SPEC.indexInRange(qBase, d); +// +// long maskBits; +// if (LANES == 16) maskBits = (wordBits >>> off) & 0xFFFFL; +// else if (LANES == 8) maskBits = (wordBits >>> off) & 0xFFL; +// else if (LANES == 4) maskBits = (wordBits >>> off) & 0xFL; +// else { +// // unusual width fallback +// long m = wordBits >>> off; +// float s = 0f; +// for (int b = 0; b < LANES; b++) { +// if (((m >>> b) & 1L) != 0L) { +// int idx = qBase + b; +// if (idx < d) s += tildeQ[idx]; +// } +// } +// scalarAcc += s; +// continue; +// } +// +// if (maskBits == 0L) continue; +// +// VectorMask bitMask = VectorMask.fromLong(SPEC, maskBits).and(inRange); +// FloatVector masked = FloatVector.fromArray(SPEC, tildeQ, qBase, bitMask); +// accVec = accVec.add(masked); +// } +// } +// +// return accVec.reduceLanes(VectorOperators.ADD) + scalarAcc; +// } + + @Override + public float ashMaskedAddFlat(float[] tildeQ, + int qOffset, + long[] allPackedVectors, + int packedBase, + int d, + int words) { + final VectorSpecies SPEC = FloatVector.SPECIES_PREFERRED; + final int LANES = SPEC.length(); + + // 4 Accumulators to hide instruction latency (Pipeline depth ~4) + FloatVector acc0 = FloatVector.zero(SPEC); + FloatVector acc1 = FloatVector.zero(SPEC); + FloatVector acc2 = FloatVector.zero(SPEC); + FloatVector acc3 = FloatVector.zero(SPEC); + + // Pre-calculate mask extraction constants based on LANES + long laneMask; + if (LANES == 16) laneMask = 0xFFFFL; // AVX-512 + else if (LANES == 8) laneMask = 0xFFL; // AVX2 + else if (LANES == 4) laneMask = 0xFL; // NEON / SSE + else laneMask = (1L << LANES) - 1; + + // Safe limit for Fast Path + int safeWords = d / 64; + + // --- HOT LOOP --- + for (int w = 0; w < safeWords; w++) { + long wordBits = allPackedVectors[packedBase + w]; + if (wordBits == 0) continue; // Branch prediction handles this well + + int baseDim = w * 64; + + // Iterate 64 bits in steps of LANES (e.g., 0, 8, 16, 24... for AVX2) + // We unroll manually inside the loop using modulo to pick accumulators + // The JIT is smart enough to unroll this loop completely since LANES is constant. + int accumulatorIdx = 0; + + for (int off = 0; off < 64; off += LANES) { + // Shift and mask + long m = (wordBits >>> off) & laneMask; + + if (m != 0) { + VectorMask bitMask = VectorMask.fromLong(SPEC, m); + FloatVector loaded = FloatVector.fromArray(SPEC, tildeQ, qOffset + baseDim + off); + + // Round-robin distribution to accumulators to break dependency chains + switch (accumulatorIdx & 3) { // (idx % 4) + case 0 -> acc0 = acc0.add(loaded, bitMask); + case 1 -> acc1 = acc1.add(loaded, bitMask); + case 2 -> acc2 = acc2.add(loaded, bitMask); + case 3 -> acc3 = acc3.add(loaded, bitMask); + } + } + accumulatorIdx++; + } + } + + // --- TAIL LOOP (Robust Bounds) --- + // Handles the edge case where d is not a multiple of 64 + for (int w = safeWords; w < words; w++) { + long wordBits = allPackedVectors[packedBase + w]; + if (wordBits == 0) continue; + + int baseDim = w * 64; + + for (int off = 0; off < 64; off += LANES) { + int localIdx = baseDim + off; + if (localIdx >= d) break; + + long m = (wordBits >>> off) & laneMask; + if (m == 0) continue; + + VectorMask bitMask = VectorMask.fromLong(SPEC, m); + int absIdx = qOffset + localIdx; + + if (localIdx + LANES <= d) { + // Safe unmasked load + acc0 = acc0.add(FloatVector.fromArray(SPEC, tildeQ, absIdx), bitMask); + } else { + // Boundary masked load + VectorMask rangeMask = SPEC.indexInRange(absIdx, qOffset + d); + acc0 = acc0.add(FloatVector.fromArray(SPEC, tildeQ, absIdx, rangeMask), + bitMask.and(rangeMask)); + } + } + } + + // Reduce all accumulators + return acc0.add(acc1).add(acc2).add(acc3).reduceLanes(VectorOperators.ADD); + } + +// @Override +// public float ashMaskedAddFlat(float[] tildeQ, +// int qOffset, +// long[] allPackedVectors, +// int packedBase, +// int d, +// int words) { +// final VectorSpecies SPEC = FloatVector.SPECIES_PREFERRED; +// +// FloatVector acc0 = FloatVector.zero(SPEC); +// FloatVector acc1 = FloatVector.zero(SPEC); +// FloatVector acc2 = FloatVector.zero(SPEC); +// FloatVector acc3 = FloatVector.zero(SPEC); +// +// int safeWords = d / 64; +// +// // --- HOT LOOP (Unrolled & Safe) --- +// for (int w = 0; w < safeWords; w++) { +// long wordBits = allPackedVectors[packedBase + w]; +// if (wordBits == 0) continue; +// +// int baseDim = w * 64; +// +// // Unroll 0 +// long m0 = wordBits & 0xFFFFL; +// if (m0 != 0) { +// acc0 = acc0.add(FloatVector.fromArray(SPEC, tildeQ, qOffset + baseDim), +// VectorMask.fromLong(SPEC, m0)); +// } +// +// // Unroll 1 +// long m1 = (wordBits >>> 16) & 0xFFFFL; +// if (m1 != 0) { +// acc1 = acc1.add(FloatVector.fromArray(SPEC, tildeQ, qOffset + baseDim + 16), +// VectorMask.fromLong(SPEC, m1)); +// } +// +// // Unroll 2 +// long m2 = (wordBits >>> 32) & 0xFFFFL; +// if (m2 != 0) { +// acc2 = acc2.add(FloatVector.fromArray(SPEC, tildeQ, qOffset + baseDim + 32), +// VectorMask.fromLong(SPEC, m2)); +// } +// +// // Unroll 3 +// long m3 = (wordBits >>> 48) & 0xFFFFL; +// if (m3 != 0) { +// acc3 = acc3.add(FloatVector.fromArray(SPEC, tildeQ, qOffset + baseDim + 48), +// VectorMask.fromLong(SPEC, m3)); +// } +// } +// +// // --- TAIL LOOP (Robust Bounds) --- +// for (int w = safeWords; w < words; w++) { +// long wordBits = allPackedVectors[packedBase + w]; +// if (wordBits == 0) continue; +// +// int baseDim = w * 64; +// +// for (int off = 0; off < 64; off += 16) { +// int localIdx = baseDim + off; +// if (localIdx >= d) break; +// +// long maskBits = (wordBits >>> off) & 0xFFFFL; +// if (maskBits == 0) continue; +// +// VectorMask bitMask = VectorMask.fromLong(SPEC, maskBits); +// int absIdx = qOffset + localIdx; +// +// if (localIdx + 16 <= d) { +// acc0 = acc0.add(FloatVector.fromArray(SPEC, tildeQ, absIdx), bitMask); +// } else { +// VectorMask rangeMask = SPEC.indexInRange(absIdx, qOffset + d); +// acc0 = acc0.add(FloatVector.fromArray(SPEC, tildeQ, absIdx, rangeMask), +// bitMask.and(rangeMask)); +// } +// } +// } +// +// return acc0.add(acc1).add(acc2).add(acc3).reduceLanes(VectorOperators.ADD); +// } + + public float ashMaskedAddFlatOptimizedv1(float[] tildeQ, + int qOffset, + long[] allPackedVectors, + int packedBase, + int d, + int words) { + final VectorSpecies SPEC = FloatVector.SPECIES_PREFERRED; + final int LANES = SPEC.length(); + + FloatVector acc0 = FloatVector.zero(SPEC); + FloatVector acc1 = FloatVector.zero(SPEC); + FloatVector acc2 = FloatVector.zero(SPEC); + FloatVector acc3 = FloatVector.zero(SPEC); + + int safeWords = d / 64; + + // --- OPTIMIZATION 1: Specialized AVX-512 Loop (16 Lanes) --- + // This covers the vast majority of server workloads. + // We unroll the 64-bit word processing into exactly 4 blocks. + if (LANES == 16) { + for (int w = 0; w < safeWords; w++) { + long wordBits = allPackedVectors[packedBase + w]; + if (wordBits == 0) continue; + + int baseDim = w * 64; + + // Block 0: Bits 0-15 + // Note: casting to (int) acts as a fast mask for the low bits + long m0 = wordBits & 0xFFFF; + if (m0 != 0) { + VectorMask mask = VectorMask.fromLong(SPEC, m0); + FloatVector loaded = FloatVector.fromArray(SPEC, tildeQ, qOffset + baseDim); + // OPTIMIZATION 2: Blend (Zero-out) + Unmasked Add + // "blend" with 0.0f where mask is unset (not()) + acc0 = acc0.add(loaded.blend(0.0f, mask.not())); + } + + // Block 1: Bits 16-31 + long m1 = (wordBits >>> 16) & 0xFFFF; + if (m1 != 0) { + VectorMask mask = VectorMask.fromLong(SPEC, m1); + FloatVector loaded = FloatVector.fromArray(SPEC, tildeQ, qOffset + baseDim + 16); + acc1 = acc1.add(loaded.blend(0.0f, mask.not())); + } + + // Block 2: Bits 32-47 + long m2 = (wordBits >>> 32) & 0xFFFF; + if (m2 != 0) { + VectorMask mask = VectorMask.fromLong(SPEC, m2); + FloatVector loaded = FloatVector.fromArray(SPEC, tildeQ, qOffset + baseDim + 32); + acc2 = acc2.add(loaded.blend(0.0f, mask.not())); + } + + // Block 3: Bits 48-63 + long m3 = (wordBits >>> 48); // No mask needed for high bits shift + if (m3 != 0) { + VectorMask mask = VectorMask.fromLong(SPEC, m3); + FloatVector loaded = FloatVector.fromArray(SPEC, tildeQ, qOffset + baseDim + 48); + acc3 = acc3.add(loaded.blend(0.0f, mask.not())); + } + } + } + // --- OPTIMIZATION 3: Generic / AVX2 Loop --- + else { + long laneMask = (1L << LANES) - 1; + for (int w = 0; w < safeWords; w++) { + long wordBits = allPackedVectors[packedBase + w]; + if (wordBits == 0) continue; + + int baseDim = w * 64; + int accumulatorIdx = 0; + + // Manual step loop to avoid modulo in switch + for (int off = 0; off < 64; off += LANES) { + long m = (wordBits >>> off) & laneMask; + if (m != 0) { + VectorMask mask = VectorMask.fromLong(SPEC, m); + FloatVector loaded = FloatVector.fromArray(SPEC, tildeQ, qOffset + baseDim + off); + + // Simple Round-Robin without switch/modulo + if (accumulatorIdx == 0) acc0 = acc0.add(loaded.blend(0.0f, mask.not())); + else if (accumulatorIdx == 1) acc1 = acc1.add(loaded.blend(0.0f, mask.not())); + else if (accumulatorIdx == 2) acc2 = acc2.add(loaded.blend(0.0f, mask.not())); + else { + acc3 = acc3.add(loaded.blend(0.0f, mask.not())); + accumulatorIdx = -1; // Reset for next increment + } + } + accumulatorIdx++; + } + } + } + + // --- TAIL LOOP (Keep your original robust logic here) --- + // Your original tail logic handles bounds checking correctly. + // Since this is the "cold" path, optimization matters less. + for (int w = safeWords; w < words; w++) { + // ... (Keep your original Tail Loop implementation) ... + } + + return acc0.add(acc1).add(acc2).add(acc3).reduceLanes(VectorOperators.ADD); + } + + public float ashMaskedAddFlatOptimizedv2(float[] tildeQ, + int qOffset, + long[] allPackedVectors, + int packedBase, + int d, + int words) { + final VectorSpecies SPEC = FloatVector.SPECIES_PREFERRED; + final int LANES = SPEC.length(); + + // Expansion to 8 accumulators to saturate 32 ZMM registers and hide memory latency + FloatVector acc0 = FloatVector.zero(SPEC); + FloatVector acc1 = FloatVector.zero(SPEC); + FloatVector acc2 = FloatVector.zero(SPEC); + FloatVector acc3 = FloatVector.zero(SPEC); + FloatVector acc4 = FloatVector.zero(SPEC); + FloatVector acc5 = FloatVector.zero(SPEC); + FloatVector acc6 = FloatVector.zero(SPEC); + FloatVector acc7 = FloatVector.zero(SPEC); + + int safeWords = d / 64; + + if (LANES == 16) { + // Process 2 words at a time (128 floats) to fill the 8-way unroll + int w = 0; + for (; w <= safeWords - 2; w += 2) { + int baseIdx = qOffset + (w << 6); + + // Word 1 (acc0-acc3) + long w0 = allPackedVectors[packedBase + w]; + if (w0 != 0) { + long m0 = w0 & 0xFFFF; + if (m0 != 0) acc0 = acc0.add(FloatVector.fromArray(SPEC, tildeQ, baseIdx, VectorMask.fromLong(SPEC, m0))); + long m1 = (w0 >>> 16) & 0xFFFF; + if (m1 != 0) acc1 = acc1.add(FloatVector.fromArray(SPEC, tildeQ, baseIdx + 16, VectorMask.fromLong(SPEC, m1))); + long m2 = (w0 >>> 32) & 0xFFFF; + if (m2 != 0) acc2 = acc2.add(FloatVector.fromArray(SPEC, tildeQ, baseIdx + 32, VectorMask.fromLong(SPEC, m2))); + long m3 = (w0 >>> 48); + if (m3 != 0) acc3 = acc3.add(FloatVector.fromArray(SPEC, tildeQ, baseIdx + 48, VectorMask.fromLong(SPEC, m3))); + } + + // Word 2 (acc4-acc7) + long w1 = allPackedVectors[packedBase + w + 1]; + int nextBaseIdx = baseIdx + 64; + if (w1 != 0) { + long m4 = w1 & 0xFFFF; + if (m4 != 0) acc4 = acc4.add(FloatVector.fromArray(SPEC, tildeQ, nextBaseIdx, VectorMask.fromLong(SPEC, m4))); + long m5 = (w1 >>> 16) & 0xFFFF; + if (m5 != 0) acc5 = acc5.add(FloatVector.fromArray(SPEC, tildeQ, nextBaseIdx + 16, VectorMask.fromLong(SPEC, m5))); + long m6 = (w1 >>> 32) & 0xFFFF; + if (m6 != 0) acc6 = acc6.add(FloatVector.fromArray(SPEC, tildeQ, nextBaseIdx + 32, VectorMask.fromLong(SPEC, m6))); + long m7 = (w1 >>> 48); + if (m7 != 0) acc7 = acc7.add(FloatVector.fromArray(SPEC, tildeQ, nextBaseIdx + 48, VectorMask.fromLong(SPEC, m7))); + } + } + + // Clean up remaining safe word if odd count + for (; w < safeWords; w++) { + int baseIdx = qOffset + (w << 6); + long wordBits = allPackedVectors[packedBase + w]; + if (wordBits != 0) { + long m0 = wordBits & 0xFFFF; + if (m0 != 0) acc0 = acc0.add(FloatVector.fromArray(SPEC, tildeQ, baseIdx, VectorMask.fromLong(SPEC, m0))); + long m1 = (wordBits >>> 16) & 0xFFFF; + if (m1 != 0) acc1 = acc1.add(FloatVector.fromArray(SPEC, tildeQ, baseIdx + 16, VectorMask.fromLong(SPEC, m1))); + long m2 = (wordBits >>> 32) & 0xFFFF; + if (m2 != 0) acc2 = acc2.add(FloatVector.fromArray(SPEC, tildeQ, baseIdx + 32, VectorMask.fromLong(SPEC, m2))); + long m3 = (wordBits >>> 48); + if (m3 != 0) acc3 = acc3.add(FloatVector.fromArray(SPEC, tildeQ, baseIdx + 48, VectorMask.fromLong(SPEC, m3))); + } + } + } else { + // Fallback for AVX2/Generic environments + long laneMask = (1L << LANES) - 1; + for (int w = 0; w < safeWords; w++) { + long wordBits = allPackedVectors[packedBase + w]; + if (wordBits == 0) continue; + int baseDim = w * 64; + for (int off = 0; off < 64; off += LANES) { + long m = (wordBits >>> off) & laneMask; + if (m != 0) { + // Use masked add directly to avoid blend/not overhead + acc0 = acc0.add(FloatVector.fromArray(SPEC, tildeQ, qOffset + baseDim + off), VectorMask.fromLong(SPEC, m)); + } + } + } + } + + // Tail loop for non-word-aligned dimensions + for (int w = safeWords; w < words; w++) { + long wordBits = allPackedVectors[packedBase + w]; + if (wordBits == 0) continue; + int baseDim = w * 64; + int remaining = d - baseDim; + for (int i = 0; i < remaining; i++) { + if ((wordBits & (1L << i)) != 0) { + // Scalar fallback for the absolute tail + // (Could be vectorized with a length-limited mask if performance here is critical) + acc0 = acc0.add(FloatVector.broadcast(SPEC, tildeQ[qOffset + baseDim + i]).blend(0, SPEC.indexInRange(0, 1).not())); + } + } + } + + // Horizontal reduction of the 8 accumulators + FloatVector res0 = acc0.add(acc1).add(acc2).add(acc3); + FloatVector res1 = acc4.add(acc5).add(acc6).add(acc7); + return res0.add(res1).reduceLanes(VectorOperators.ADD); + } + + public float ashMaskedAddFlatOptimizedv3(float[] tildeQ, int qOffset, long[] allPackedVectors, int packedBase, int d, int words) { + final VectorSpecies SPEC = FloatVector.SPECIES_PREFERRED; + + // 6 Accumulators: Optimized to fit in registers without spilling to cache + FloatVector acc0 = FloatVector.zero(SPEC), acc1 = FloatVector.zero(SPEC), acc2 = FloatVector.zero(SPEC); + FloatVector acc3 = FloatVector.zero(SPEC), acc4 = FloatVector.zero(SPEC), acc5 = FloatVector.zero(SPEC); + + int safeWords = d / 64; + for (int w = 0; w < safeWords; w++) { + int baseIdx = qOffset + (w << 6); + long word = allPackedVectors[packedBase + w]; + + // Direct Masked Add: Collapses 'blend' + 'add' into a single AVX-512 instruction + acc0 = acc0.add(FloatVector.fromArray(SPEC, tildeQ, baseIdx), VectorMask.fromLong(SPEC, word & 0xFFFF)); + acc1 = acc1.add(FloatVector.fromArray(SPEC, tildeQ, baseIdx + 16), VectorMask.fromLong(SPEC, (word >>> 16) & 0xFFFF)); + acc2 = acc2.add(FloatVector.fromArray(SPEC, tildeQ, baseIdx + 32), VectorMask.fromLong(SPEC, (word >>> 32) & 0xFFFF)); + acc3 = acc3.add(FloatVector.fromArray(SPEC, tildeQ, baseIdx + 48), VectorMask.fromLong(SPEC, (word >>> 48))); + } + + // Horizontal reduction + return acc0.add(acc1).add(acc2).add(acc3).add(acc4).add(acc5).reduceLanes(VectorOperators.ADD); + } + + public float ashMaskedAddFlatOptimizedv4(float[] tildeQ, int qOffset, long[] allPackedVectors, int packedBase, int d, int words) { + final VectorSpecies SPEC = FloatVector.SPECIES_PREFERRED; + + // STRICT ADHERENCE TO V3 LEARNINGS: 6 Accumulators to prevent register spilling + FloatVector acc0 = FloatVector.zero(SPEC), acc1 = FloatVector.zero(SPEC), acc2 = FloatVector.zero(SPEC); + FloatVector acc3 = FloatVector.zero(SPEC), acc4 = FloatVector.zero(SPEC), acc5 = FloatVector.zero(SPEC); + + int safeWords = d / 64; + int w = 0; + + // --- NEW OPTIMIZATION: 3-Word Unroll --- + // This processes 192 dimensions per loop while staying within the 6-accumulator limit. + for (; w <= safeWords - 3; w += 3) { + int baseIdx = qOffset + (w << 6); + + // Word 0 -> acc0, acc1, acc2, acc3 (Uses 4 sub-blocks) + long wd0 = allPackedVectors[packedBase + w]; + acc0 = acc0.add(FloatVector.fromArray(SPEC, tildeQ, baseIdx), VectorMask.fromLong(SPEC, wd0 & 0xFFFF)); + acc1 = acc1.add(FloatVector.fromArray(SPEC, tildeQ, baseIdx + 16), VectorMask.fromLong(SPEC, (wd0 >>> 16) & 0xFFFF)); + acc2 = acc2.add(FloatVector.fromArray(SPEC, tildeQ, baseIdx + 32), VectorMask.fromLong(SPEC, (wd0 >>> 32) & 0xFFFF)); + acc3 = acc3.add(FloatVector.fromArray(SPEC, tildeQ, baseIdx + 48), VectorMask.fromLong(SPEC, (wd0 >>> 48))); + + // Word 1 -> acc4, acc5, acc0, acc1 (Interleaves to reuse registers) + long wd1 = allPackedVectors[packedBase + w + 1]; + int base1 = baseIdx + 64; + acc4 = acc4.add(FloatVector.fromArray(SPEC, tildeQ, base1), VectorMask.fromLong(SPEC, wd1 & 0xFFFF)); + acc5 = acc5.add(FloatVector.fromArray(SPEC, tildeQ, base1 + 16), VectorMask.fromLong(SPEC, (wd1 >>> 16) & 0xFFFF)); + acc0 = acc0.add(FloatVector.fromArray(SPEC, tildeQ, base1 + 32), VectorMask.fromLong(SPEC, (wd1 >>> 32) & 0xFFFF)); + acc1 = acc1.add(FloatVector.fromArray(SPEC, tildeQ, base1 + 48), VectorMask.fromLong(SPEC, (wd1 >>> 48))); + + // Word 2 -> acc2, acc3, acc4, acc5 + long wd2 = allPackedVectors[packedBase + w + 2]; + int base2 = baseIdx + 128; + acc2 = acc2.add(FloatVector.fromArray(SPEC, tildeQ, base2), VectorMask.fromLong(SPEC, wd2 & 0xFFFF)); + acc3 = acc3.add(FloatVector.fromArray(SPEC, tildeQ, base2 + 16), VectorMask.fromLong(SPEC, (wd2 >>> 16) & 0xFFFF)); + acc4 = acc4.add(FloatVector.fromArray(SPEC, tildeQ, base2 + 32), VectorMask.fromLong(SPEC, (wd2 >>> 32) & 0xFFFF)); + acc5 = acc5.add(FloatVector.fromArray(SPEC, tildeQ, base2 + 48), VectorMask.fromLong(SPEC, (wd2 >>> 48))); + } + + // Standard v3 cleanup for remaining words + for (; w < safeWords; w++) { + int baseIdx = qOffset + (w << 6); + long word = allPackedVectors[packedBase + w]; + acc0 = acc0.add(FloatVector.fromArray(SPEC, tildeQ, baseIdx), VectorMask.fromLong(SPEC, word & 0xFFFF)); + acc1 = acc1.add(FloatVector.fromArray(SPEC, tildeQ, baseIdx + 16), VectorMask.fromLong(SPEC, (word >>> 16) & 0xFFFF)); + acc2 = acc2.add(FloatVector.fromArray(SPEC, tildeQ, baseIdx + 32), VectorMask.fromLong(SPEC, (word >>> 32) & 0xFFFF)); + acc3 = acc3.add(FloatVector.fromArray(SPEC, tildeQ, baseIdx + 48), VectorMask.fromLong(SPEC, (word >>> 48))); + } + + // Balanced Reduction Tree (Better than linear add) + + FloatVector sum01 = acc0.add(acc1); + FloatVector sum23 = acc2.add(acc3); + FloatVector sum45 = acc4.add(acc5); + return sum01.add(sum23).add(sum45).reduceLanes(VectorOperators.ADD); + } + + public float ashMaskedAdd_512(float[] tildeQ, int qOffset, long[] allPackedVectors, int packedBase, int d, int words) { + final VectorSpecies SPEC = FloatVector.SPECIES_PREFERRED; // Assumed 512-bit (16 floats) + int safeWords = d / 64; + + // 1. Determine Unroll Strategy based on d + // We aim for 8 accumulators for large d, but 384 remains at 6 as requested. + int numAcc = (d >= 512) ? 8 : (d == 384 ? 6 : (d >= 128 ? 4 : 2)); + + // Initialize required accumulators + FloatVector acc0 = FloatVector.zero(SPEC), acc1 = FloatVector.zero(SPEC); + FloatVector acc2 = null, acc3 = null, acc4 = null, acc5 = null, acc6 = null, acc7 = null; + + if (numAcc >= 4) { acc2 = FloatVector.zero(SPEC); acc3 = FloatVector.zero(SPEC); } + if (numAcc >= 6) { acc4 = FloatVector.zero(SPEC); acc5 = FloatVector.zero(SPEC); } + if (numAcc >= 8) { acc6 = FloatVector.zero(SPEC); acc7 = FloatVector.zero(SPEC); } + + int w = 0; + + // 2. Main Unrolled Loop + // For d=384, this uses your 3-word unroll. + // For d >= 512, we use a 2-word unroll across 8 registers for better throughput. + int unrollFactor = (d == 384) ? 3 : (d >= 512 ? 2 : 1); + + for (; w <= safeWords - unrollFactor; w += unrollFactor) { + int baseIdx = qOffset + (w << 6); + + // Word 0 Processing + long wd0 = allPackedVectors[packedBase + w]; + acc0 = acc0.add(FloatVector.fromArray(SPEC, tildeQ, baseIdx), VectorMask.fromLong(SPEC, wd0 & 0xFFFF)); + acc1 = acc1.add(FloatVector.fromArray(SPEC, tildeQ, baseIdx + 16), VectorMask.fromLong(SPEC, (wd0 >>> 16) & 0xFFFF)); + acc2 = acc2.add(FloatVector.fromArray(SPEC, tildeQ, baseIdx + 32), VectorMask.fromLong(SPEC, (wd0 >>> 32) & 0xFFFF)); + acc3 = acc3.add(FloatVector.fromArray(SPEC, tildeQ, baseIdx + 48), VectorMask.fromLong(SPEC, (wd0 >>> 48))); + + if (unrollFactor >= 2) { + long wd1 = allPackedVectors[packedBase + w + 1]; + int b1 = baseIdx + 64; + if (numAcc == 6) { // Special Case for d=384 Interleave + acc4 = acc4.add(FloatVector.fromArray(SPEC, tildeQ, b1), VectorMask.fromLong(SPEC, wd1 & 0xFFFF)); + acc5 = acc5.add(FloatVector.fromArray(SPEC, tildeQ, b1 + 16), VectorMask.fromLong(SPEC, (wd1 >>> 16) & 0xFFFF)); + acc0 = acc0.add(FloatVector.fromArray(SPEC, tildeQ, b1 + 32), VectorMask.fromLong(SPEC, (wd1 >>> 32) & 0xFFFF)); + acc1 = acc1.add(FloatVector.fromArray(SPEC, tildeQ, b1 + 48), VectorMask.fromLong(SPEC, (wd1 >>> 48))); + } else { // Standard 8-accumulator path + acc4 = acc4.add(FloatVector.fromArray(SPEC, tildeQ, b1), VectorMask.fromLong(SPEC, wd1 & 0xFFFF)); + acc5 = acc5.add(FloatVector.fromArray(SPEC, tildeQ, b1 + 16), VectorMask.fromLong(SPEC, (wd1 >>> 16) & 0xFFFF)); + acc6 = acc6.add(FloatVector.fromArray(SPEC, tildeQ, b1 + 32), VectorMask.fromLong(SPEC, (wd1 >>> 32) & 0xFFFF)); + acc7 = acc7.add(FloatVector.fromArray(SPEC, tildeQ, b1 + 48), VectorMask.fromLong(SPEC, (wd1 >>> 48))); + } + } + + if (unrollFactor == 3) { // Tail of the d=384 case + long wd2 = allPackedVectors[packedBase + w + 2]; + int b2 = baseIdx + 128; + acc2 = acc2.add(FloatVector.fromArray(SPEC, tildeQ, b2), VectorMask.fromLong(SPEC, wd2 & 0xFFFF)); + acc3 = acc3.add(FloatVector.fromArray(SPEC, tildeQ, b2 + 16), VectorMask.fromLong(SPEC, (wd2 >>> 16) & 0xFFFF)); + acc4 = acc4.add(FloatVector.fromArray(SPEC, tildeQ, b2 + 32), VectorMask.fromLong(SPEC, (wd2 >>> 32) & 0xFFFF)); + acc5 = acc5.add(FloatVector.fromArray(SPEC, tildeQ, b2 + 48), VectorMask.fromLong(SPEC, (wd2 >>> 48))); + } + } + + // 3. Cleanup Loop + for (; w < safeWords; w++) { + int baseIdx = qOffset + (w << 6); + long wd = allPackedVectors[packedBase + w]; + acc0 = acc0.add(FloatVector.fromArray(SPEC, tildeQ, baseIdx), VectorMask.fromLong(SPEC, wd & 0xFFFF)); + acc1 = acc1.add(FloatVector.fromArray(SPEC, tildeQ, baseIdx + 16), VectorMask.fromLong(SPEC, (wd >>> 16) & 0xFFFF)); + if (numAcc >= 4) { + acc2 = acc2.add(FloatVector.fromArray(SPEC, tildeQ, baseIdx + 32), VectorMask.fromLong(SPEC, (wd >>> 32) & 0xFFFF)); + acc3 = acc3.add(FloatVector.fromArray(SPEC, tildeQ, baseIdx + 48), VectorMask.fromLong(SPEC, (wd >>> 48))); + } + } + + // 4. Dynamic Reduction Tree + return performReduction(acc0, acc1, acc2, acc3, acc4, acc5, acc6, acc7, numAcc); + } + + private float performReduction(FloatVector a0, FloatVector a1, FloatVector a2, FloatVector a3, + FloatVector a4, FloatVector a5, FloatVector a6, FloatVector a7, int count) { + FloatVector res; + if (count == 8) { + FloatVector s01 = a0.add(a1), s23 = a2.add(a3), s45 = a4.add(a5), s67 = a6.add(a7); + res = s01.add(s23).add(s45.add(s67)); + } else if (count == 6) { + res = a0.add(a1).add(a2.add(a3)).add(a4.add(a5)); + } else if (count == 4) { + res = a0.add(a1).add(a2.add(a3)); + } else { + res = a0.add(a1); + } + return res.reduceLanes(VectorOperators.ADD); + } + + // This version is optimized for dense masks and uses a masked load, plain add. + // It is branchless and fully unrolled. + // It only works for 64-bit aligned payloads and 16 lanes (AVX512). + public float ashMaskedAddFlat_dense(float[] tildeQ, + int qOffset, + long[] allPackedVectors, + int packedBase, + int d, + int words_not_used) { // TODO words is not used in this version + final VectorSpecies SPEC = FloatVector.SPECIES_PREFERRED; + final int LANES = SPEC.length(); + + // Typical case: d is 256/384 => multiple of 64 + // If you can guarantee that, you can delete all tail handling. + final int words = d >>> 6; + + Objects.checkFromIndexSize(qOffset, d, tildeQ.length); + Objects.checkFromIndexSize(packedBase, words, allPackedVectors.length); + + // 4 accumulators is fine for AVX-512; keep it. + FloatVector acc0 = FloatVector.zero(SPEC); + FloatVector acc1 = FloatVector.zero(SPEC); + FloatVector acc2 = FloatVector.zero(SPEC); + FloatVector acc3 = FloatVector.zero(SPEC); + + if (LANES == 16) { + for (int w = 0, qBase = qOffset; w < words; w++, qBase += 64) { + long bits = allPackedVectors[packedBase + w]; + + // Build masks from shifted bits (no laneMask needed) + VectorMask k0 = VectorMask.fromLong(SPEC, bits); + VectorMask k1 = VectorMask.fromLong(SPEC, bits >>> 16); + VectorMask k2 = VectorMask.fromLong(SPEC, bits >>> 32); + VectorMask k3 = VectorMask.fromLong(SPEC, bits >>> 48); + + // Masked load (zeroing) + unmasked add: branchless + acc0 = acc0.add(FloatVector.fromArray(SPEC, tildeQ, qBase + 0, k0)); + acc1 = acc1.add(FloatVector.fromArray(SPEC, tildeQ, qBase + 16, k1)); + acc2 = acc2.add(FloatVector.fromArray(SPEC, tildeQ, qBase + 32, k2)); + acc3 = acc3.add(FloatVector.fromArray(SPEC, tildeQ, qBase + 48, k3)); + } + } else { + // Generic fallback (still branchless and no laneMask) + for (int w = 0, qBase = qOffset; w < words; w++, qBase += 64) { + long bits = allPackedVectors[packedBase + w]; + for (int off = 0; off < 64; off += LANES) { + VectorMask k = VectorMask.fromLong(SPEC, bits >>> off); + acc0 = acc0.add(FloatVector.fromArray(SPEC, tildeQ, qBase + off, k)); + } + } + } + + return acc0.add(acc1).add(acc2).add(acc3).reduceLanes(VectorOperators.ADD); + } + + // This approach uses masked adds instead of masked loads, and two accumulators (since this is slow) + @Override + public float ashMaskedAddAllWords(float[] tildeQ, + int d, + long[] packedBits, + int packedBase, + int words) { + final VectorSpecies SPEC = FloatVector.SPECIES_PREFERRED; + final int LANES = SPEC.length(); + final int upperBoundSafe = d - LANES; + + // Two accumulators to hide floating-point addition latency (4 cycles) + FloatVector acc1 = FloatVector.zero(SPEC); + FloatVector acc2 = FloatVector.zero(SPEC); + float scalarAcc = 0f; + + for (int w = 0; w < words; w++) { + long wordBits = packedBits[packedBase + w]; + // REMOVED: if (wordBits == 0) continue; (useless for dense data) + + int baseDim = w * 64; + + // Toggle to alternate between acc1 and acc2 + boolean toggle = false; + + for (int off = 0; off < 64; off += LANES) { + int qBase = baseDim + off; + + // Keep the fast integer check! + if (qBase >= d) break; + + long maskBits; + if (LANES == 16) maskBits = (wordBits >>> off) & 0xFFFFL; + else if (LANES == 8) maskBits = (wordBits >>> off) & 0xFFL; + else if (LANES == 4) maskBits = (wordBits >>> off) & 0xFL; + else { + // Fallback for unusual widths + long m = wordBits >>> off; + for (int b = 0; b < LANES; b++) { + if (((m >>> b) & 1L) != 0L) { + int idx = qBase + b; + if (idx < d) scalarAcc += tildeQ[idx]; + } + } + continue; + } + + // Still worth checking if the local chunk is 0 to skip the ADD + if (maskBits == 0L) continue; + + VectorMask bitMask = VectorMask.fromLong(SPEC, maskBits); + FloatVector loaded; + + // Fast Unmasked Load (Still critical for dense data) + if (qBase <= upperBoundSafe) { + loaded = FloatVector.fromArray(SPEC, tildeQ, qBase); + } else { + VectorMask rangeMask = SPEC.indexInRange(qBase, d); + loaded = FloatVector.fromArray(SPEC, tildeQ, qBase, bitMask.and(rangeMask)); + } + + // Alternate accumulators to parallelize the ADDs + if (!toggle) { + acc1 = acc1.add(loaded, bitMask); + } else { + acc2 = acc2.add(loaded, bitMask); + } + toggle = !toggle; + } + } + + // Merge the two pipelines + return acc1.add(acc2).reduceLanes(VectorOperators.ADD) + scalarAcc; + } + @Override public void ashMaskedAddBlockAllWords(float[] tildeQ, int d, From 2f4e02d5d7b89a62f53ecce555da341703ba4c4a Mon Sep 17 00:00:00 2001 From: Ted Willke Date: Sun, 15 Feb 2026 01:29:00 +0000 Subject: [PATCH 033/243] Additional cleanup for the merge of latest main into branch. --- .../jvector/quantization/ASHVectors.java | 4 ++-- .../quantization/AsymmetricHashing.java | 12 +++++------ .../benchmarks/datasets/DataSetLoaderMFD.java | 2 +- .../jvector/example/util/SiftLoader.java | 4 +++- jvector-examples/yaml-configs/default.yml | 20 +++++++++---------- 5 files changed, 22 insertions(+), 20 deletions(-) diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHVectors.java b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHVectors.java index d69086ce2..9759322ad 100644 --- a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHVectors.java +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHVectors.java @@ -16,6 +16,7 @@ package io.github.jbellis.jvector.quantization; +import io.github.jbellis.jvector.disk.IndexWriter; import io.github.jbellis.jvector.disk.RandomAccessReader; import io.github.jbellis.jvector.graph.similarity.ScoreFunction; import io.github.jbellis.jvector.util.RamUsageEstimator; @@ -25,7 +26,6 @@ import io.github.jbellis.jvector.vector.VectorizationProvider; import io.github.jbellis.jvector.vector.types.VectorFloat; -import java.io.DataOutput; import java.io.IOException; import java.util.Arrays; import java.util.Objects; @@ -174,7 +174,7 @@ public int count() { * carefully when introduced. */ @Override - public void write(DataOutput out, int version) throws IOException { + public void write(IndexWriter out, int version) throws IOException { // Write ASH compressor first ash.write(out, version); diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/AsymmetricHashing.java b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/AsymmetricHashing.java index da172ef17..06960e69d 100644 --- a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/AsymmetricHashing.java +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/AsymmetricHashing.java @@ -17,6 +17,7 @@ package io.github.jbellis.jvector.quantization; import io.github.jbellis.jvector.annotations.VisibleForTesting; +import io.github.jbellis.jvector.disk.IndexWriter; import io.github.jbellis.jvector.disk.RandomAccessReader; import io.github.jbellis.jvector.graph.RandomAccessVectorValues; import io.github.jbellis.jvector.graph.disk.OnDiskGraphIndex; @@ -30,7 +31,6 @@ import dev.ludovic.netlib.blas.BLAS; import dev.ludovic.netlib.lapack.LAPACK; -import java.io.DataOutput; import java.io.IOException; import java.util.Arrays; import java.util.Objects; @@ -561,11 +561,11 @@ public static AsymmetricHashing load(RandomAccessReader in) throws IOException { /** * TODO confirm handling versioning correctly * TODO confirm handling magic number correctly - * Writes the ASH index header to DataOutput (used for on-disk indexing) - * @param out the DataOutput into which to write the object + * Writes the ASH index header to IndexWriter (used for on-disk indexing) + * @param out the IndexWriter into which to write the object * @throws IOException if there is a problem writing to out. */ - public void write(DataOutput out, int version) throws IOException { + public void write(IndexWriter out, int version) throws IOException { if (version > OnDiskGraphIndex.CURRENT_VERSION) { throw new IllegalArgumentException("Unsupported version " + version); } @@ -826,7 +826,7 @@ static void quantizeTo(VectorFloat vector, ); } - public void write(DataOutput out, int quantizedDim) throws IOException { + public void write(IndexWriter out, int quantizedDim) throws IOException { out.writeFloat(scale); out.writeFloat(offset); out.writeByte(landmark); @@ -1728,7 +1728,7 @@ private static RealMatrix sign01(RealMatrix m) { return new Array2DRowRealMatrix(out, false); } - private static void writeStiefelTransform(DataOutput out, StiefelTransform st) throws IOException { + private static void writeStiefelTransform(IndexWriter out, StiefelTransform st) throws IOException { // Serialize A = W^T in float precision as a [d][D] row-major matrix. // This keeps the format compact and matches the encoding/scoring fast paths. out.writeInt(st.rows); diff --git a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/benchmarks/datasets/DataSetLoaderMFD.java b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/benchmarks/datasets/DataSetLoaderMFD.java index b20c0db50..769c1cfae 100644 --- a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/benchmarks/datasets/DataSetLoaderMFD.java +++ b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/benchmarks/datasets/DataSetLoaderMFD.java @@ -152,7 +152,7 @@ public DataSet load() { var baseVectors = SiftLoader.readFvecs("fvec/" + basePath); var queryVectors = SiftLoader.readFvecs("fvec/" + queriesPath); var gtVectors = SiftLoader.readIvecs("fvec/" + groundTruthPath); - return DataSetUtils.getScrubbedDataSet(name, VectorSimilarityFunction.COSINE, baseVectors, queryVectors, gtVectors); + return DataSetUtils.getScrubbedDataSet(name, VectorSimilarityFunction.DOT_PRODUCT, baseVectors, queryVectors, gtVectors); } public static Map byName = new HashMap<>() {{ diff --git a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/util/SiftLoader.java b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/util/SiftLoader.java index f68ae340d..24d5f9e81 100644 --- a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/util/SiftLoader.java +++ b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/util/SiftLoader.java @@ -46,7 +46,7 @@ public class SiftLoader { * Reads the entire file and materializes all vectors into memory. *

    */ - public static List> readFvecs(String filePath) throws IOException { + public static List> readFvecs(String filePath) { var vectors = new ArrayList>(); try (var dis = new DataInputStream( new BufferedInputStream(new FileInputStream(filePath)))) { @@ -68,6 +68,8 @@ public static List> readFvecs(String filePath) throws IOException vectors.add(vectorTypeSupport.createFloatVector(data)); } + } catch (IOException ex) { + throw new UncheckedIOException(ex); } return vectors; } diff --git a/jvector-examples/yaml-configs/default.yml b/jvector-examples/yaml-configs/default.yml index 1ffa2d4e4..789d14651 100644 --- a/jvector-examples/yaml-configs/default.yml +++ b/jvector-examples/yaml-configs/default.yml @@ -32,17 +32,17 @@ search: # 100: [1.0, 2.0] useSearchPruning: [Yes] compression: -# - type: PQ -# parameters: -# m: 97 -# # k: 256 # optional parameter. By default, k=256 -# centerData: No -# anisotropicThreshold: -1.0 # optional parameter. By default, anisotropicThreshold=-1 (i.e., no anisotropy) - - type: ASH + - type: PQ parameters: - encodedBits: "776" - optimizer: "random" - landmarkCount: "1" + m: 97 + # k: 256 # optional parameter. By default, k=256 + centerData: No + anisotropicThreshold: -1.0 # optional parameter. By default, anisotropicThreshold=-1 (i.e., no anisotropy) +# - type: ASH +# parameters: +# encodedBits: "776" +# optimizer: "random" +# landmarkCount: "1" benchmarks: # full option set, if the whole "benchmarks" section is not specified, a default set will be used throughput: [ AVG ] # additional options: [AVG, MEDIAN, MAX] latency: [ AVG ] # additional options: [ AVG, STD, P999 ] From a71359b7e778dfe003aeee495ef2f5809b8ba127 Mon Sep 17 00:00:00 2001 From: Ted Willke Date: Thu, 5 Mar 2026 00:39:43 +0000 Subject: [PATCH 034/243] Extend csv logging to include build and search compressor information. --- .../github/jbellis/jvector/example/Grid.java | 10 +++++++++- .../example/reporting/ExperimentsSchemaV1.java | 18 ++++++++++++++++++ .../example/reporting/RunArtifacts.java | 4 ++++ 3 files changed, 31 insertions(+), 1 deletion(-) diff --git a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/Grid.java b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/Grid.java index 89fd7da83..97a28f01c 100644 --- a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/Grid.java +++ b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/Grid.java @@ -247,6 +247,10 @@ static void runOneGraph(OnDiskGraphIndexCache cache, buildCompressorObj = getCompressor(buildCompressor, ds, constructionMetrics, Phase.INDEX, buildQuantType); } + // Used in logging + String buildCompressorString = + (buildCompressorObj == null) ? "None" : String.valueOf(buildCompressorObj); + Map, ImmutableGraphIndex> indexes = new HashMap<>(); if (buildCompressorObj == null) { indexes = buildInMemory(featureSets, M, efConstruction, neighborOverflow, addHierarchy, refineFinalGraph, ds, workDirectory); @@ -323,7 +327,7 @@ static void runOneGraph(OnDiskGraphIndexCache cache, try (var cs = new ConfiguredSystem(ds, index, cv, featureSetForIndex)) { testConfiguration(cs, topKGrid, usePruningGrid, M, efConstruction, neighborOverflow, addHierarchy, refineFinalGraph, featureSetForIndex, - artifacts, constructionMetrics, workDirectory); + buildCompressorString, artifacts, constructionMetrics, workDirectory); } catch (Exception e) { throw new RuntimeException(e); } @@ -606,6 +610,7 @@ private static void testConfiguration(ConfiguredSystem cs, boolean addHierarchy, boolean refineFinalGraph, Set featureSetForIndex, + String buildCompressorString, RunArtifacts artifacts, ConstructionMetrics constructionMetrics, Path testDirectory) { @@ -674,6 +679,7 @@ private static void testConfiguration(ConfiguredSystem cs, printer.printRow(overquery, displayResults); // Apply log selection and write CSV row + String searchCompressorString = (cs.cv == null) ? "None" : String.valueOf(cs.cv.getCompressor()); var logOutputs = logSel.apply(results, logResolved); artifacts.logRow( cs.ds.getName(), @@ -683,6 +689,8 @@ private static void testConfiguration(ConfiguredSystem cs, addHierarchy, refineFinalGraph, featureSetForIndex, + buildCompressorString, + searchCompressorString, usePruning, topK, overquery, diff --git a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/reporting/ExperimentsSchemaV1.java b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/reporting/ExperimentsSchemaV1.java index d8b07b578..6d554e873 100644 --- a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/reporting/ExperimentsSchemaV1.java +++ b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/reporting/ExperimentsSchemaV1.java @@ -32,6 +32,18 @@ public final class ExperimentsSchemaV1 { private ExperimentsSchemaV1() {} + static String csvEscape(String s) { + if (s == null) return ""; + boolean needsQuotes = + s.indexOf(',') >= 0 || + s.indexOf('"') >= 0 || + s.indexOf('\n') >= 0 || + s.indexOf('\r') >= 0; + + if (!needsQuotes) return s; + return "\"" + s.replace("\"", "\"\"") + "\""; + } + public static List fixedColumns() { return List.of( "schema_version", @@ -45,6 +57,8 @@ public static List fixedColumns() { "addHierarchy", "refineFinalGraph", "feature_set", + "build_compressor", + "search_compressor", "usePruning", "topK", "overquery", @@ -65,6 +79,8 @@ public static Map fixedValues(RunContext run, boolean addHierarchy, boolean refineFinalGraph, Set featureSet, + String buildCompressorString, + String searchCompressorString, boolean usePruning, int topK, double overquery, @@ -82,6 +98,8 @@ public static Map fixedValues(RunContext run, fixed.put("addHierarchy", Boolean.toString(addHierarchy)); fixed.put("refineFinalGraph", Boolean.toString(refineFinalGraph)); fixed.put("feature_set", featureSet == null ? "" : featureSet.toString()); + fixed.put("build_compressor", csvEscape(buildCompressorString)); + fixed.put("search_compressor", csvEscape(searchCompressorString)); fixed.put("usePruning", Boolean.toString(usePruning)); fixed.put("topK", Integer.toString(topK)); diff --git a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/reporting/RunArtifacts.java b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/reporting/RunArtifacts.java index 0857c0226..77e180f4f 100644 --- a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/reporting/RunArtifacts.java +++ b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/reporting/RunArtifacts.java @@ -192,6 +192,8 @@ public void logRow(String datasetName, boolean addHierarchy, boolean refineFinalGraph, Set featureSetForIndex, + String buildCompressorString, + String searchCompressorString, boolean usePruning, int topK, double overquery, @@ -210,6 +212,8 @@ public void logRow(String datasetName, addHierarchy, refineFinalGraph, featureSetForIndex, + buildCompressorString, + searchCompressorString, usePruning, topK, overquery, From df7cb7e233d28cd0902c1f32f5d5644215974f84 Mon Sep 17 00:00:00 2001 From: Ted Willke Date: Tue, 24 Mar 2026 00:03:46 +0000 Subject: [PATCH 035/243] Keep ITQ in flat column-major buffers and compute Xbin in row blocks to reduce conversions and memory traffic without changing training behavior. Iterations are ~2.6X faster. --- .../quantization/AsymmetricHashing.java | 567 ++++++++++-------- 1 file changed, 312 insertions(+), 255 deletions(-) diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/AsymmetricHashing.java b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/AsymmetricHashing.java index 06960e69d..34b765fed 100644 --- a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/AsymmetricHashing.java +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/AsymmetricHashing.java @@ -1023,10 +1023,6 @@ public StiefelTransform(RealMatrix W) { * columns (WᵀW = I) and can be used as an orthonormal projection matrix * (e.g., y = Wᵀx maps R^D → R^d). * - *

    Applies a deterministic per-column sign alignment to reduce SVD sign - * ambiguity by flipping columns of W to be positively correlated with the - * corresponding columns of G. - * * @param D dimensionality of the input vectors * @param d dimensionality of the projected (embedding) space * @param rng random number generator used to sample G @@ -1039,44 +1035,14 @@ public static StiefelTransform runWithoutTraining(int D, int d, Random rng) { throw new IllegalArgumentException("Invalid d=" + d + " for D=" + D); } - // Sample Gaussian matrix G [D x d] - // Using a 1D array for native speed, then wrapping it + // gData is interpreted as a [D x d] column-major matrix by LAPACK. double[] gData = new double[D * d]; for (int i = 0; i < gData.length; i++) { gData[i] = rng.nextGaussian(); } - RealMatrix G = new Array2DRowRealMatrix(deserializeColumnMajor(gData, D, d), false); - - // Perform Thin SVD: G = U S VT - // We need U [D x d] and VT [d x d] - double[] u = new double[D * d]; - double[] vt = new double[d * d]; - double[] s = new double[d]; - intW info = new intW(0); - - // Workspace query for dgesvd - double[] a = gData.clone(); // <- preserve original G for the real factorization - double[] workQuery = new double[1]; - LAPACK.getInstance().dgesvd("S", "S", D, d, a, D, s, u, D, vt, d, workQuery, -1, info); - if (info.val != 0) throw new RuntimeException("SVD workspace query failed info=" + info.val); - - double[] work = new double[(int) workQuery[0]]; - - a = gData.clone(); // <- ensure the real call sees the original Gaussian matrix - // Execute dgesvd with Job "S" (Thin SVD) - LAPACK.getInstance().dgesvd("S", "S", D, d, a, D, s, u, D, vt, d, work, work.length, info); - - if (info.val != 0) { - throw new RuntimeException("Native SVD failed with info=" + info.val); - } - - // Compute W = U @ VT [D x d] - // This is the Polar Decomposition (nearest orthonormal matrix to G) - double[] wData = new double[D * d]; - BLAS.getInstance().dgemm("N", "N", D, d, d, 1.0, u, D, vt, d, 0.0, wData, D); + double[] wData = orthogonalize(gData, D, d); RealMatrix W = new Array2DRowRealMatrix(deserializeColumnMajor(wData, D, d), false); - return new StiefelTransform(W); } @@ -1365,45 +1331,42 @@ private static StiefelTransform runItqTrainer( final int D = xHdNorm[0].length; final int d = quantizedDim; + // Convert training data once to flat column-major for BLAS/LAPACK. + double[] xCol = serializeColumnMajor(xHdNorm); + // PCA basis M = V[:, :d] where X = U S V^T - RealMatrix X = new Array2DRowRealMatrix(xHdNorm, false); // [N×D] logProgress("\t[stage] Starting Native SVD for PCA..."); - // We only need VT, so we tell LAPACK to skip U to save massive amounts of time/memory - RealMatrix Vt = computeNativeVt(X); + double[] vtCol = computeNativeVt(xCol, N, D); // [D x D], column-major logProgress("\t[stage] Completed Native SVD..."); - RealMatrix M = Vt.transpose().getSubMatrix(0, D - 1, 0, d - 1); // [D×d] + double[] mCol = extractPcaBasis(vtCol, D, d); // [D x d], column-major // Early stopping based on stabilized binary codes (subject to max iterations) - RealMatrix prevXbin = null; + double[] prevXbinCol = null; int stable = 0; - final double STOP_FRAC = 4e-3; // stop when <0.4% bits flip TODO setting - final int STOP_PATIENCE = 3; // for 3 consecutive epochs TODO setting + final double STOP_FRAC = 4e-3; + final int STOP_PATIENCE = 3; - // X_norm = X_hd_norm @ M (N x d) - RealMatrix Xnorm = nativeMultiply(X, M); // [N×d] + // X_norm = X_hd_norm @ M => [N x d], column-major + double[] xnormCol = nativeMultiply(xCol, N, D, mCol, d); - // temp_mat ~ N(0,1) in R^{d×d}. Initial random rotation. - RealMatrix temp = gaussianMatrix(d, d, rng); + // Initial random rotation seed + double[] tempCol = gaussianMatrix(d, d, rng); // Loop: epoch 0..iters inclusive; last epoch computes R but does not update temp_mat. - RealMatrix R = null; + double[] rCol = null; logProgress("\t[stage] ITQ training iterations started..."); + var startTime = System.nanoTime(); for (int epoch = 0; epoch <= nTrainingIterations; epoch++) { - R = orthogonalize(temp); // R = U @ V^T + rCol = orthogonalize(tempCol, d, d); // R = U @ V^T, [d x d] if (epoch < nTrainingIterations) { - // Use native multiplication for Xtr = Xnorm @ R - RealMatrix Xtr = nativeMultiply(Xnorm, R); // [N×d] - RealMatrix Xbin = sign01(Xtr); // [N×d] in {+1,-1} + // Xtr = Xnorm @ R, then binarize in-place to become Xbin. + double[] xbinCol = nativeMultiply(xnormCol, N, d, rCol, d); + + long changed = sign01InPlace(xbinCol, prevXbinCol); - if (prevXbin != null) { - long changed = 0; + if (prevXbinCol != null) { long total = (long) N * (long) d; - for (int i = 0; i < N; i++) { - for (int j = 0; j < d; j++) { - if (Xbin.getEntry(i, j) != prevXbin.getEntry(i, j)) changed++; - } - } double frac = (double) changed / (double) total; logProgress(String.format(java.util.Locale.ROOT, @@ -1411,31 +1374,42 @@ private static StiefelTransform runItqTrainer( epoch, nTrainingIterations, frac, stable, STOP_PATIENCE)); if (frac < STOP_FRAC) { - if (++stable >= STOP_PATIENCE) break; + if (++stable >= STOP_PATIENCE) { + break; + } } else { stable = 0; } } - // Optimized temp = Xnorm.T @ Xbin - // This avoids the physical transpose of the large Xnorm matrix - prevXbin = Xbin; - temp = nativeMultiplyTransposedA(Xnorm, Xbin); // [d×d] + prevXbinCol = xbinCol; + tempCol = nativeMultiplyTransposedA(xnormCol, N, d, xbinCol, d); } } - logProgress("\t[stage] ITQ training completed."); + var loopTime = (System.nanoTime() - startTime) / 1e9; + logProgress("\t[stage] ITQ training completed in " + loopTime + " seconds."); + + // W = M @ R => [D x d], column-major + double[] wCol = nativeMultiply(mCol, D, d, rCol, d); - // W = M @ R (shape [D×d]) - // Use native multiply for the final transform matrix - RealMatrix W = nativeMultiply(M, R); + RealMatrix W = new Array2DRowRealMatrix(deserializeColumnMajor(wCol, D, d), false); return new StiefelTransform(W); } /** + * * LANDING trainer TODO incomplete * * NOTE: This is a DRAFT (not hardened) implementation using RealMatrix operations. * We can SIMD/block-optimize the hot math once correctness is validated. + * + * Row-oriented implementation of the paper's stochastic updates: + * - A is stored as the decoder matrix [D x d] (paper's A) + * - W is stored as the encoder-transpose [D x d], so Xb * W gives + * row-wise projections equivalent to W^T x in the paper + * + * The update equations implemented are the row-oriented forms of + * Equations (22)-(25) from the paper. */ private static StiefelTransform runLandingTrainer( double[][] xHdNorm, // [N][D] normalized residuals @@ -1443,289 +1417,372 @@ private static StiefelTransform runLandingTrainer( Random rng, int nTrainingIterations ) { + logProgress("\t[stage] Starting runLandingTrainer..."); + final int N = xHdNorm.length; final int D = xHdNorm[0].length; final int d = quantizedDim; - RealMatrix X = new Array2DRowRealMatrix(xHdNorm, false); // [N×D] + final double invD = 1.0 / d; + final double invSqrtD = 1.0 / Math.sqrt(d); + final double lambda = 1.0; - // PCA basis M_pca = V[:, :d] - SingularValueDecomposition svdX = new SingularValueDecomposition(X); - RealMatrix V = svdX.getV(); - RealMatrix M_pca = V.getSubMatrix(0, D - 1, 0, d - 1); // [D×d] + // ------------------------------------------------------------------ + // ITQ-style initialization from PCA basis P and random orthogonal R. + // + // xHdNorm is row-stacked [N x D]. The top right singular vectors of X + // are the top left singular vectors of X^T used in the paper. + // ------------------------------------------------------------------ + double[] xCol = serializeColumnMajor(xHdNorm); - // Init: - // temp(d×d) -> Q = U V^T - // A = Q @ M_pca^T (d×D) - // W = A^T (D×d) - RealMatrix Q = orthogonalize(gaussianMatrix(d, d, rng)); - RealMatrix A = Q.multiply(M_pca.transpose()); // [d×D] - RealMatrix W = A.transpose(); // [D×d] + logProgress("\t[stage] Starting Native SVD for LANDING PCA..."); + double[] vtCol = computeNativeVt(xCol, N, D); // [D x D], column-major + logProgress("\t[stage] Completed Native SVD for LANDING PCA..."); - final RealMatrix I_D = MatrixUtils.createRealIdentityMatrix(D); - final double invSqrtD = 1.0 / Math.sqrt(d); + double[] pCol = extractPcaBasis(vtCol, D, d); // [D x d], column-major + double[] rCol = orthogonalize(gaussianMatrix(d, d, rng), d, d); // [d x d] + + // Stored row-oriented decoder A and encoder-transpose W both start from PR, + // matching the ITQ-style initialization used elsewhere in this class. + double[] initCol = nativeMultiply(pCol, D, d, rCol, d); // [D x d] + RealMatrix A = new Array2DRowRealMatrix(deserializeColumnMajor(initCol, D, d), false); + RealMatrix W = A.copy(); + + // I_d for the Stiefel penalty A^T A - I_d + double[][] eye = new double[d][d]; + for (int i = 0; i < d; i++) { + eye[i][i] = 1.0; + } + final RealMatrix I_d = new Array2DRowRealMatrix(eye, false); int[] idx = new int[N]; - for (int i = 0; i < N; i++) idx[i] = i; + for (int i = 0; i < N; i++) { + idx[i] = i; + } + logProgress("\t[stage] LANDING training iterations started..."); for (int epoch = 0; epoch < nTrainingIterations; epoch++) { - shuffleInPlace(idx, rng); + // Fisher-Yates shuffle + for (int i = idx.length - 1; i > 0; i--) { + int j = rng.nextInt(i + 1); + int tmp = idx[i]; + idx[i] = idx[j]; + idx[j] = tmp; + } + + final double lr = 0.1 * Math.pow(0.98, epoch); for (int off = 0; off < N; off += LANDING_BATCH_SIZE) { final int end = Math.min(N, off + LANDING_BATCH_SIZE); final int B = end - off; - // Build batch matrix X_batch (B×D) - double[][] xb = new double[B][D]; + // Build batch matrix Xb [B x D] without copying row contents. + double[][] xb = new double[B][]; for (int bi = 0; bi < B; bi++) { xb[bi] = xHdNorm[idx[off + bi]]; } RealMatrix Xb = new Array2DRowRealMatrix(xb, false); - // X_sign = sign(Xb @ W) / sqrt(d) - RealMatrix Xproj = Xb.multiply(W); // [B×d] - RealMatrix Xsign = sign01(Xproj).scalarMultiply(invSqrtD);// [B×d] - - // gradA = (X_sign^T X_sign) A - (X_sign^T X_batch) - RealMatrix XtX = Xsign.transpose().multiply(Xsign); // [d×d] - RealMatrix XtB = Xsign.transpose().multiply(Xb); // [d×D] - RealMatrix gradA = XtX.multiply(A).subtract(XtB); // [d×D] + // Z = sign(Xb * W) in {+1, -1}^{B x d}, unscaled. + RealMatrix Xproj = Xb.multiply(W); // [B x d] + double[][] projData = (Xproj instanceof Array2DRowRealMatrix) + ? ((Array2DRowRealMatrix) Xproj).getDataRef() + : Xproj.getData(); + + double[][] zData = new double[B][d]; + for (int i = 0; i < B; i++) { + double[] src = projData[i]; + double[] dst = zData[i]; + for (int j = 0; j < d; j++) { + dst[j] = (src[j] > 0.0) ? 1.0 : -1.0; + } + } + RealMatrix Z = new Array2DRowRealMatrix(zData, false); // [B x d] + + // Batch statistics + RealMatrix ZtZ = Z.transpose().multiply(Z); // [d x d] + RealMatrix XtZ = Xb.transpose().multiply(Z); // [D x d] + RealMatrix XtX = Xb.transpose().multiply(Xb); // [D x D] + RealMatrix AtA = A.transpose().multiply(A); // [d x d] + + // Row-oriented gradA = d^-1 * A * (Z^T Z) - d^-1/2 * X^T Z + RealMatrix gradA = A.multiply(ZtZ).scalarMultiply(invD) + .subtract(XtZ.scalarMultiply(invSqrtD)); + + // Row-oriented for stored W = (paper W)^T: + // gradW = (X^T X) * ( d^-1 * W * (A^T A) - d^-1/2 * A ) + RealMatrix gradW = XtX.multiply( + W.multiply(AtA).scalarMultiply(invD) + .subtract(A.scalarMultiply(invSqrtD)) + ); - // gradW = (X_batch^T X_batch) ( W (A A^T) - A^T ) - RealMatrix G = Xb.transpose().multiply(Xb); // [D×D] - RealMatrix AAT = A.multiply(A.transpose()); // [d×d] - RealMatrix term = W.multiply(AAT).subtract(A.transpose());// [D×d] - RealMatrix gradW = G.multiply(term); // [D×d] + // Λ(A) = skew(gradA * A^T) * A + λ * A * (A^T A - I) + RealMatrix skewBase = gradA.multiply(A.transpose()); // [D x D] + RealMatrix skew = skewBase.subtract(skewBase.transpose()).scalarMultiply(0.5); + RealMatrix ortho = AtA.subtract(I_d); // [d x d] + RealMatrix landing = skew.multiply(A) + .add(A.multiply(ortho).scalarMultiply(lambda)); - double lr = 0.1 * Math.pow(0.98, epoch); - double lambda = 1.0; + A = A.subtract(landing.scalarMultiply(lr)); + W = W.subtract(gradW.scalarMultiply(lr)); + } + } - // skew = 0.5 * (A^T gradA - (A^T gradA)^T) - RealMatrix Mmat = A.transpose().multiply(gradA); // [D×D] - RealMatrix skew = Mmat.subtract(Mmat.transpose()).scalarMultiply(0.5); + logProgress("\t[stage] LANDING training completed."); - // A -= lr * A @ ( lambda*(A^T A - I) + skew ) - RealMatrix ortho = A.transpose().multiply(A).subtract(I_D); - RealMatrix updateCore = ortho.scalarMultiply(lambda).add(skew); - A = A.subtract(A.multiply(updateCore).scalarMultiply(lr)); + // StiefelTransform expects the decoder matrix [D x d]. + return new StiefelTransform(A); + } - // W -= lr * gradW - W = W.subtract(gradW.scalarMultiply(lr)); + /** + * Samples a Gaussian matrix in flat column-major storage. + * + * Values are assigned in row-major logical order. + */ + private static double[] gaussianMatrix(int rows, int cols, Random rng) { + double[] data = new double[rows * cols]; + for (int r = 0; r < rows; r++) { + int dst = r; + for (int c = 0; c < cols; c++, dst += rows) { + data[dst] = rng.nextGaussian(); } } + return data; + } - return new StiefelTransform(W); + /** Converts a row-major double[][] to a flat column-major array for BLAS/LAPACK. */ + private static double[] serializeColumnMajor(double[][] matrix) { + int rows = matrix.length; + int cols = matrix[0].length; + double[] data = new double[rows * cols]; + + for (int r = 0; r < rows; r++) { + double[] row = matrix[r]; + int dst = r; + for (int c = 0; c < cols; c++, dst += rows) { + data[dst] = row[c]; + } + } + return data; } - private static void shuffleInPlace(int[] a, Random rng) { - for (int i = a.length - 1; i > 0; i--) { - int j = rng.nextInt(i + 1); - int tmp = a[i]; - a[i] = a[j]; - a[j] = tmp; + /** Converts a RealMatrix to a flat column-major array for BLAS/LAPACK. */ + private static double[] serializeColumnMajor(RealMatrix m) { + int rows = m.getRowDimension(); + int cols = m.getColumnDimension(); + double[] data = new double[rows * cols]; + + if (m instanceof Array2DRowRealMatrix) { + double[][] raw = ((Array2DRowRealMatrix) m).getDataRef(); + for (int r = 0; r < rows; r++) { + double[] row = raw[r]; + int dst = r; + for (int c = 0; c < cols; c++, dst += rows) { + data[dst] = row[c]; + } + } + return data; } + + for (int r = 0; r < rows; r++) { + int dst = r; + for (int c = 0; c < cols; c++, dst += rows) { + data[dst] = m.getEntry(r, c); + } + } + return data; } - private static RealMatrix gaussianMatrix(int rows, int cols, Random rng) { - double[][] m = new double[rows][cols]; - for (int i = 0; i < rows; i++) { - double[] r = m[i]; - for (int j = 0; j < cols; j++) { - r[j] = rng.nextGaussian(); + /** Converts a flat column-major array back into a row-major double[][] for Commons Math. */ + private static double[][] deserializeColumnMajor(double[] data, int rows, int cols) { + double[][] matrix = new double[rows][cols]; + for (int r = 0; r < rows; r++) { + double[] row = matrix[r]; + int src = r; + for (int c = 0; c < cols; c++, src += rows) { + row[c] = data[src]; } } - return new Array2DRowRealMatrix(m, false); + return matrix; } - // Basic dgemm routine. - private static RealMatrix nativeMultiply(RealMatrix A, RealMatrix B) { - int m = A.getRowDimension(); - int n = B.getColumnDimension(); - int k = A.getColumnDimension(); // Must match B's row dimension + /** + * Extracts M = V[:, :d] from VT, where VT is [dim x dim] in flat column-major layout. + * Returns M as [dim x d], also in flat column-major layout. + */ + private static double[] extractPcaBasis(double[] vtCol, int dim, int d) { + double[] mCol = new double[dim * d]; + + for (int col = 0; col < d; col++) { + int dstOffset = col * dim; + for (int row = 0; row < dim; row++) { + // M(row, col) = V(row, col) = VT(col, row) + mCol[dstOffset + row] = vtCol[col + row * dim]; + } + } + + return mCol; + } - // 1. Convert to 1D Column-Major for BLAS - double[] aArr = serializeColumnMajor(A); - double[] bArr = serializeColumnMajor(B); - double[] cArr = new double[m * n]; + /** Native DGEMM for flat column-major matrices: C = A @ B. */ + private static double[] nativeMultiply(double[] aCol, int m, int k, double[] bCol, int n) { + double[] cCol = new double[m * n]; - // 2. Call Native DGEMM: C = alpha*A*B + beta*C - // "N", "N" means 'No Transpose' for both A and B BLAS.getInstance().dgemm( "N", "N", m, n, k, - 1.0, // alpha - aArr, m, // A and its leading dimension - bArr, k, // B and its leading dimension - 0.0, // beta (ignores existing values in cArr) - cArr, m // C and its leading dimension + 1.0, + aCol, m, + bCol, k, + 0.0, + cCol, m ); - // 3. Convert back to RealMatrix - return new Array2DRowRealMatrix(deserializeColumnMajor(cArr, m, n), false); + return cCol; } - // Uses the dgesvd routine but tells LAPACK to skip the U matrix (for PCA). Saves time.... - private static RealMatrix computeNativeVt(RealMatrix x) { - int rows = x.getRowDimension(); - int cols = x.getColumnDimension(); - double[] a0 = serializeColumnMajor(x); + /** Native DGEMM for flat column-major matrices: C = A^T @ B. */ + private static double[] nativeMultiplyTransposedA( + double[] aCol, + int rowsA, + int colsA, + double[] bCol, + int colsB + ) { + double[] cCol = new double[colsA * colsB]; + + BLAS.getInstance().dgemm( + "T", "N", + colsA, colsB, rowsA, + 1.0, + aCol, rowsA, + bCol, rowsA, + 0.0, + cCol, colsA + ); + return cCol; + } + + /** + * Uses LAPACK dgesvd but skips U and returns VT in flat column-major layout. + * Input X must be [rows x cols], flat column-major. + */ + private static double[] computeNativeVt(double[] xCol, int rows, int cols) { double[] s = new double[Math.min(rows, cols)]; - double[] vt = new double[cols * cols]; // Right singular vectors + double[] vt = new double[cols * cols]; intW info = new intW(0); - // Query optimal workspace size (standard LAPACK practice); use a copy - double[] a = a0.clone(); + // Workspace query + double[] a = xCol.clone(); // dgesvd overwrites input double[] workQuery = new double[1]; LAPACK.getInstance().dgesvd("N", "A", rows, cols, a, rows, s, null, 1, vt, cols, workQuery, -1, info); + if (info.val != 0) { + throw new RuntimeException("Native PCA SVD workspace query failed with info=" + info.val); + } + int lwork = (int) workQuery[0]; double[] work = new double[lwork]; - // Actual call: "N" = No U matrix, "A" = All columns of V^T - a = a0.clone(); // Use a fresh copy again + // Actual call + a = xCol.clone(); LAPACK.getInstance().dgesvd("N", "A", rows, cols, a, rows, s, null, 1, vt, cols, work, lwork, info); if (info.val != 0) { throw new RuntimeException("Native PCA SVD failed with info=" + info.val); } - // Convert VT (column-major) back to RealMatrix - return new Array2DRowRealMatrix(deserializeColumnMajor(vt, cols, cols), false); + return vt; } - /** Computes A^T * B using Native BLAS dgemm */ - private static RealMatrix nativeMultiplyTransposedA(RealMatrix A, RealMatrix B) { - int m = A.getColumnDimension(); // A.T has rows = A.cols - int n = B.getColumnDimension(); - int k = A.getRowDimension(); // A.T has cols = A.rows - - double[] aArr = serializeColumnMajor(A); - double[] bArr = serializeColumnMajor(B); - double[] cArr = new double[m * n]; - - // "T" = Transpose Matrix A, "N" = No Transpose Matrix B - BLAS.getInstance().dgemm( - "T", "N", - m, n, k, - 1.0, - aArr, k, // Leading dimension is k because A is currently [k x m] - bArr, k, - 0.0, - cArr, m - ); - - return new Array2DRowRealMatrix(deserializeColumnMajor(cArr, m, n), false); - } - - private static double calculateMSE(RealMatrix a, RealMatrix b) { - double sumSq = 0; - int rows = a.getRowDimension(); - int cols = a.getColumnDimension(); - for (int i = 0; i < rows; i++) { - for (int j = 0; j < cols; j++) { - double diff = a.getEntry(i, j) - b.getEntry(i, j); - sumSq += diff * diff; - } - } - return sumSq / (rows * cols); - } - - private static RealMatrix orthogonalize(RealMatrix m) { - int rows = m.getRowDimension(); // D - int cols = m.getColumnDimension(); // d - int k = Math.min(rows, cols); // The rank for thin SVD - - // Move to column-major 1D array - double[] a0 = serializeColumnMajor(m); + /** + * Orthogonalizes a flat column-major matrix using thin SVD and returns U @ VT + * in flat column-major layout. + */ + private static double[] orthogonalize(double[] mCol, int rows, int cols) { + int k = Math.min(rows, cols); - // Prepare Thin-SVD buffers - // u: [rows * k] instead of [rows * rows] - // vt: [k * cols] instead of [cols * cols] double[] u = new double[rows * k]; double[] vt = new double[k * cols]; double[] s = new double[k]; intW info = new intW(0); - // Workspace Query - double[] a = a0.clone(); // Make a copy + // Workspace query + double[] a = mCol.clone(); // dgesvd overwrites input double[] workQuery = new double[1]; LAPACK.getInstance().dgesvd( - "S", "S", rows, cols, a, rows, s, u, rows, vt, k, - workQuery, -1, info + "S", "S", + rows, cols, + a, rows, + s, + u, rows, + vt, k, + workQuery, -1, + info ); + if (info.val != 0) { + throw new RuntimeException("LAPACK dgesvd workspace query failed with info=" + info.val); + } + int lwork = (int) workQuery[0]; double[] work = new double[lwork]; - // Actual Thin-SVD call (Job "S") - a = a0.clone(); // Fresh copy again + // Actual call + a = mCol.clone(); LAPACK.getInstance().dgesvd( - "S", "S", rows, cols, a, rows, s, u, rows, vt, k, - work, lwork, info + "S", "S", + rows, cols, + a, rows, + s, + u, rows, + vt, k, + work, lwork, + info ); if (info.val != 0) { throw new RuntimeException("LAPACK dgesvd failed with info=" + info.val); } - // Native Multiplication: Result = U @ VT - // Using dgemm here) - double[] resultData = new double[rows * cols]; - BLAS.getInstance().dgemm( - "N", "N", rows, cols, k, - 1.0, u, rows, // A = u, lda = rows - vt, k, // B = vt, ldb = k - 0.0, resultData, rows // C = resultData, ldc = rows - ); - - return new Array2DRowRealMatrix(deserializeColumnMajor(resultData, rows, cols), false); + return nativeMultiply(u, rows, k, vt, cols); } - /** Converts an Apache Commons RealMatrix to a column-major 1D array for LAPACK. */ - private static double[] serializeColumnMajor(RealMatrix m) { + /** + * Thin wrapper retained so the LANDING stub continues to compile + * without changing callers or method names. + */ + private static RealMatrix orthogonalize(RealMatrix m) { int rows = m.getRowDimension(); int cols = m.getColumnDimension(); - double[] data = new double[rows * cols]; + double[] qCol = orthogonalize(serializeColumnMajor(m), rows, cols); + return new Array2DRowRealMatrix(deserializeColumnMajor(qCol, rows, cols), false); + } - // Accept RealMatrix double[][] implementation - double[][] raw = m.getData(); + /** + * Binarizes values in-place to +/-1 and, if prevBinCol is non-null, counts + * how many entries changed relative to the previous binarized matrix. + */ + private static long sign01InPlace(double[] valuesCol, double[] prevBinCol) { + long changed = 0L; - for (int c = 0; c < cols; c++) { - int colOffset = c * rows; - for (int r = 0; r < rows; r++) { - data[colOffset + r] = raw[r][c]; + if (prevBinCol == null) { + for (int i = 0; i < valuesCol.length; i++) { + valuesCol[i] = (valuesCol[i] > 0.0) ? 1.0 : -1.0; } + return 0L; } - return data; - } - /** Converts a column-major 1D array back into a row-major double[][] for Commons Math. */ - private static double[][] deserializeColumnMajor(double[] data, int rows, int cols) { - double[][] matrix = new double[rows][cols]; - for (int c = 0; c < cols; c++) { - int colOffset = c * rows; - for (int r = 0; r < rows; r++) { - matrix[r][c] = data[colOffset + r]; - } - } - return matrix; - } - /** - * Elementwise sign producing +/-1 (no zeros), consistent with ASH encoding convention: - * bit=1 iff projection > 0, else bit=-1. - */ - private static RealMatrix sign01(RealMatrix m) { - final int r = m.getRowDimension(); - final int c = m.getColumnDimension(); - double[][] out = new double[r][c]; - for (int i = 0; i < r; i++) { - double[] row = out[i]; - for (int j = 0; j < c; j++) { - row[j] = (m.getEntry(i, j) > 0.0) ? 1.0 : -1.0; + for (int i = 0; i < valuesCol.length; i++) { + double bin = (valuesCol[i] > 0.0) ? 1.0 : -1.0; + if (bin != prevBinCol[i]) { + changed++; } + valuesCol[i] = bin; } - return new Array2DRowRealMatrix(out, false); + + return changed; } private static void writeStiefelTransform(IndexWriter out, StiefelTransform st) throws IOException { From 7ba8e7e482fb0dd848fac17668c17ec2f1c7eb71 Mon Sep 17 00:00:00 2001 From: Ted Willke Date: Tue, 14 Apr 2026 05:55:06 +0000 Subject: [PATCH 036/243] Latest work on SIMD masked adds for ASH. --- .../jbellis/jvector/example/DistancesFP.java | 754 ++++++++++++++++++ .../yaml-configs/ada002-100k-TEMP.yml | 38 + jvector-examples/yaml-configs/cap-1M.yml | 72 ++ jvector-examples/yaml-configs/cap-6M.yml | 47 ++ jvector-examples/yaml-configs/cohere-10M.yml | 47 ++ .../yaml-configs/cohere-english-v3-1M.yml | 72 ++ jvector-examples/yaml-configs/dpr-10M.yml | 47 ++ jvector-examples/yaml-configs/dpr-1M.yml | 72 ++ .../yaml-configs/dpr-gemma-1M.yml | 72 ++ jvector-examples/yaml-configs/esmc-600-1M.yml | 72 ++ jvector-examples/yaml-configs/esmc-600-6M.yml | 72 ++ jvector-examples/yaml-configs/gecko-100k.yml | 72 ++ jvector-examples/yaml-configs/mpnet-1M.yml | 72 ++ .../yaml-configs/openai-v3-small-100k.yml | 72 ++ .../yaml-configs/personahub-1M.yml | 72 ++ jvector-examples/yaml-configs/tahoe-x1-1M.yml | 73 ++ .../vector/PanamaVectorUtilSupport.java | 430 +--------- 17 files changed, 1728 insertions(+), 428 deletions(-) create mode 100644 jvector-examples/src/main/java/io/github/jbellis/jvector/example/DistancesFP.java create mode 100644 jvector-examples/yaml-configs/ada002-100k-TEMP.yml create mode 100644 jvector-examples/yaml-configs/cap-1M.yml create mode 100644 jvector-examples/yaml-configs/cap-6M.yml create mode 100644 jvector-examples/yaml-configs/cohere-10M.yml create mode 100644 jvector-examples/yaml-configs/cohere-english-v3-1M.yml create mode 100644 jvector-examples/yaml-configs/dpr-10M.yml create mode 100644 jvector-examples/yaml-configs/dpr-1M.yml create mode 100644 jvector-examples/yaml-configs/dpr-gemma-1M.yml create mode 100644 jvector-examples/yaml-configs/esmc-600-1M.yml create mode 100644 jvector-examples/yaml-configs/esmc-600-6M.yml create mode 100644 jvector-examples/yaml-configs/gecko-100k.yml create mode 100644 jvector-examples/yaml-configs/mpnet-1M.yml create mode 100644 jvector-examples/yaml-configs/openai-v3-small-100k.yml create mode 100644 jvector-examples/yaml-configs/personahub-1M.yml create mode 100644 jvector-examples/yaml-configs/tahoe-x1-1M.yml diff --git a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/DistancesFP.java b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/DistancesFP.java new file mode 100644 index 000000000..f763673e2 --- /dev/null +++ b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/DistancesFP.java @@ -0,0 +1,754 @@ +/* + * 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; + +import io.github.jbellis.jvector.example.util.SiftLoader; +import io.github.jbellis.jvector.graph.ListRandomAccessVectorValues; +import io.github.jbellis.jvector.graph.similarity.ScoreFunction; +import io.github.jbellis.jvector.quantization.ASHBlockScorer; +import io.github.jbellis.jvector.quantization.ASHVectors; +import io.github.jbellis.jvector.quantization.AsymmetricHashing; +import io.github.jbellis.jvector.quantization.CompressedVectors; +import io.github.jbellis.jvector.vector.VectorSimilarityFunction; +import io.github.jbellis.jvector.vector.VectorUtil; +import io.github.jbellis.jvector.vector.types.VectorFloat; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.ForkJoinPool; +import java.util.concurrent.ForkJoinTask; + +public class DistancesFP { + + static final String blockMode = System.getProperty("jvector.ash.blockKernel", "auto").toLowerCase(); + static final String singleMode = System.getProperty("jvector.ash.singleKernel", "auto").toLowerCase(); + + private static void printScorerInfo(String label, Object scorer) { + boolean maskedLoad = + io.github.jbellis.jvector.vector.VectorizationProvider.getInstance() + .getVectorUtilSupport() + .supportsAshMaskedLoad(); + + System.out.println( + "\t[" + label + "] scorer implementation = " + + scorer.getClass().getName() + + " (singleKernel=" + singleMode + + ", blockKernel=" + blockMode + + ", supportsAshMaskedLoad=" + maskedLoad + + ")" + ); + } + + private static void logProgress(String msg) { + System.out.println(msg); + System.out.flush(); + } + + private static int parseOptimizerFromProperty() { + String opt = System.getProperty("jvector.ash.optimizer", "random").trim().toLowerCase(); + + if ("random".equals(opt)) return AsymmetricHashing.RANDOM; + if ("itq".equals(opt)) return AsymmetricHashing.ITQ; + if ("landing".equals(opt)) return AsymmetricHashing.LANDING; + + throw new IllegalArgumentException( + "Unknown jvector.ash.optimizer=" + opt + " (expected random|itq|landing)" + ); + } + + private static String optimizerName(int optimizer) { + if (optimizer == AsymmetricHashing.RANDOM) return "RANDOM"; + if (optimizer == AsymmetricHashing.ITQ) return "ITQ"; + if (optimizer == AsymmetricHashing.LANDING) return "LANDING"; + return "UNKNOWN(" + optimizer + ")"; + } + + public static void testASHEncodings(String filenameBase, String filenameQueries, String filenameGT) throws IOException { + // ------------------------------------------------------------ + // Benchmark configuration (runtime flags) + // ------------------------------------------------------------ + final boolean RUN_SANITY_CHECK = + Boolean.parseBoolean(System.getProperty("jvector.bench.sanity-check", "false")); + + final boolean RUN_RECALL_CHECK = + Boolean.parseBoolean(System.getProperty("jvector.bench.recall", "false")); + + final int RECALL_K = + Integer.getInteger("jvector.bench.recall.k", 10); + + final boolean RUN_ACCURACY_CHECK = + Boolean.parseBoolean(System.getProperty("jvector.bench.accuracy", "false")); + + final boolean RUN_SCALAR_SCORING = + Boolean.parseBoolean(System.getProperty("jvector.bench.scalar-scoring", "true")); + + final boolean RUN_FLOAT_SCORING = + Boolean.parseBoolean(System.getProperty("jvector.bench.float-scoring", "true")); + + // ASH header bits + final int HEADER_BITS = 72; + + // Block sizes to benchmark + final int[] BLOCK_SIZES = {32}; // 16, 32, and/or 64 + + // How many ASH landmarks to use, C = [1, 64] + final int landmarkCount = 1; + + // Define the benchmark size + int maxQueries = 10_000; + int maxVectors = 10_000_000; + + int queryCountInFile = SiftLoader.countFvecs(filenameQueries); + int vectorCountInFile = SiftLoader.countFvecs(filenameBase); + + // Will benchmark what was requested or the maximum available vectors (if less) + int nQueries = Math.min(maxQueries, queryCountInFile); + int nVectors = Math.min(maxVectors, vectorCountInFile); + + List> vectors = SiftLoader.readFvecs(filenameBase, nVectors); + List> queries = SiftLoader.readFvecs(filenameQueries, nQueries); + List groundTruth = SiftLoader.readIvecsAsArrays(filenameGT, nQueries); + + // ------------------------------------------------------------------ + // Remove zero-norm vectors (undefined for ASH / Eq. 11) + // ------------------------------------------------------------------ + + int beforeBase = vectors.size(); + + // Maps original index to filtered index. Value is -1 if vector was removed. + int[] oldToNew = new int[beforeBase]; + java.util.Arrays.fill(oldToNew, -1); + + List> filteredBase = new ArrayList<>(beforeBase); + for (int i = 0; i < beforeBase; i++) { + VectorFloat v = vectors.get(i); + if (VectorUtil.dotProduct(v, v) > 0f) { + oldToNew[i] = filteredBase.size(); + filteredBase.add(v); + } + } + vectors = filteredBase; + + int beforeQuery = queries.size(); + List> filteredQueries = new ArrayList<>(queries.size()); + for (VectorFloat q : queries) { + if (VectorUtil.dotProduct(q, q) > 0f) { + filteredQueries.add(q); + } + } + queries = filteredQueries; + + System.out.println("\tRemoved " + (beforeBase - vectors.size()) + " zero base vectors and " + + (beforeQuery - queries.size()) + " zero query vectors"); + + // ------------------------------------------------------------ + // Update counts after filtering + // ------------------------------------------------------------ + final int finalVectorCount = vectors.size(); + final int finalQueryCount = queries.size(); + + // ASH normalization policy: + // + // - Base vectors x are NOT globally normalized. + // - For encoding, we compute μ on raw x. + // - The residual (x − μ) is normalized ONCE inside the binarizer, + // producing \hat{x} = (x − μ) / ||x − μ|| (ASH Paper, Eq. 6). + // - Queries may be L2-normalized in the benchmark (standard practice), + // but are NOT normalized inside the encoder. + // - No other normalization steps are applied. + for (VectorFloat q : queries) VectorUtil.l2normalize(q); + + int dimension = vectors.get(0).length(); + int encodedBits = 776; // (dimension / 4) + HEADER_BITS; + // Payload must be 64-bit aligned for SIMD + int payloadBits = encodedBits - HEADER_BITS; +// if ((payloadBits & 63) != 0) { +// throw new IllegalArgumentException( +// "ASH payloadBits must be 64-bit aligned for SIMD. " + +// "Got payloadBits=" + payloadBits + +// " (encodedBits=" + encodedBits + +// ", HEADER_BITS=" + HEADER_BITS + ")" +// ); +// } + + System.out.println( + "\toriginalDim = " + dimension + + ", encodedBits = " + encodedBits + ); + + final List> finalQueries = queries; + final List> finalVectors = vectors; + + System.out.format("\t%d base and %d query vectors loaded, dimension=%d%n", + vectors.size(), queries.size(), dimension); + + // ------------------------------------------------------------------ + // Build ASH (centroids + optional training), then encode + // ------------------------------------------------------------------ + var ravv = new ListRandomAccessVectorValues(vectors, dimension); + + // Choose optimizer at runtime: + // -Djvector.ash.optimizer=random|itq|landing + final int optimizer = parseOptimizerFromProperty(); + + logProgress("\tASH optimizer = " + optimizerName(optimizer)); + + logProgress("\t[stage] ASH initialize: starting (centroids + training)"); + long initStart = System.nanoTime(); + var ash = AsymmetricHashing.initialize(ravv, optimizer, encodedBits, landmarkCount); + long initEnd = System.nanoTime(); + logProgress("\t[stage] ASH initialize: done in " + (initEnd - initStart) / 1e9 + " seconds"); + + logProgress("\t[stage] ASH encodeAll: starting"); + long startTime = System.nanoTime(); + CompressedVectors ashVecs = ash.encodeAll(ravv); + long endTime = System.nanoTime(); + logProgress("\t[stage] ASH encodeAll: done in " + (endTime - startTime) / 1e9 + " seconds"); + + + double encSeconds = (endTime - startTime) / 1e9; + double encThroughput = finalVectorCount / encSeconds; + + System.out.println( + "\tEncoding throughput = " + + String.format(java.util.Locale.ROOT, "%.3f", encThroughput) + + " vectors/sec" + ); + + ASHVectors ashVectors = (ASHVectors) ashVecs; + VectorFloat q0 = finalQueries.get(0); + + // ------------------------------------------------------------------ + // Landmark reorder (debug + timing) + // ------------------------------------------------------------------ + final boolean REORDER_BY_LANDMARK = + Boolean.parseBoolean(System.getProperty("jvector.ash.reorderByLandmark", "true")); + + final ASHVectors ashVectorsFinal; + final CompressedVectors ashVecsFinal; + final int[] newToOldFinal; + + if (REORDER_BY_LANDMARK) { + long stat0 = System.nanoTime(); + var beforeStats = ashVectors.landmarkRunStats(); + long stat1 = System.nanoTime(); + + System.out.println("\tLandmark run stats BEFORE reorder: " + beforeStats + + " (computed in " + (stat1 - stat0) / 1e9 + " s)"); + + long t0 = System.nanoTime(); + ASHVectors.LandmarkOrder order = ashVectors.reorderByLandmarkFast(); + long t1 = System.nanoTime(); + + ASHVectors reordered = order.vectors; + + long stat2 = System.nanoTime(); + var afterStats = reordered.landmarkRunStats(); + long stat3 = System.nanoTime(); + + System.out.println("\tLandmark reorder took " + (t1 - t0) / 1e9 + " s"); + System.out.println("\tLandmark run stats AFTER reorder: " + afterStats + + " (computed in " + (stat3 - stat2) / 1e9 + " s)"); + System.out.println("--"); + + ashVectorsFinal = reordered; + ashVecsFinal = reordered; // CompressedVectors view used by scoreFunctionFor + newToOldFinal = order.newToOld; // map reordered ordinal -> original base ordinal + } else { + System.out.println("\tLandmark reorder disabled (-Djvector.ash.reorderByLandmark=false)"); + ashVectorsFinal = ashVectors; + ashVecsFinal = ashVectors; + newToOldFinal = null; + } + + { + ScoreFunction.ApproximateScoreFunction single = + ashVecsFinal.scoreFunctionFor(q0, VectorSimilarityFunction.DOT_PRODUCT); + printScorerInfo("single", single); + } + + if(RUN_SANITY_CHECK) { + + ASHBlockScorer ref = ashVectors.blockScorerFor(q0, VectorSimilarityFunction.DOT_PRODUCT); + printScorerInfo("scalar-ref", ref); + + final int sanityBlockSize = BLOCK_SIZES[0]; + ASHBlockScorer blk = ashVectors.blockScorerFor(q0, VectorSimilarityFunction.DOT_PRODUCT, sanityBlockSize); + printScorerInfo("block", blk); + + + float[] a = new float[128]; + float[] b = new float[128]; + ref.scoreRange(0, 128, a); + blk.scoreRange(0, 128, b); + + final float EPS = 1e-6f; + + for (int i = 0; i < 128; i++) { + float diff = Math.abs(a[i] - b[i]); + if (diff > EPS) { + throw new AssertionError( + "Mismatch at " + i + + ": ref=" + a[i] + + ", blk=" + b[i] + + ", diff=" + diff); + } + } + } + + // ------------------------------------------------------------------ + // Shared parallelization setup (executor-honoring) + // ------------------------------------------------------------------ +// ForkJoinPool simdExecutor = PhysicalCoreExecutor.pool(); // For production + ForkJoinPool simdExecutor = new ForkJoinPool(180); // For profiling + + int parallelism = simdExecutor.getParallelism(); + int chunkSize = Math.max(1, (finalQueryCount + parallelism - 1) / parallelism); + + // ================================================================== + // [1] Accuracy run (NOT timed) + // ================================================================== + if(RUN_ACCURACY_CHECK) { + List> errorTasks = new ArrayList<>(); + + for (int start = 0; start < finalQueryCount; start += chunkSize) { + final int s = start; + final int e = Math.min(start + chunkSize, finalQueryCount); + + errorTasks.add(simdExecutor.submit(() -> { + double localError = 0.0; + long localCount = 0; + + for (int i = s; i < e; i++) { + VectorFloat q = finalQueries.get(i); + ScoreFunction.ApproximateScoreFunction f = + ashVecsFinal.scoreFunctionFor(q, VectorSimilarityFunction.DOT_PRODUCT); + + for (int j = 0; j < finalVectorCount; j++) { + final int baseOrd = (newToOldFinal == null) ? j : newToOldFinal[j]; + VectorFloat v = finalVectors.get(baseOrd); + + float trueDot = VectorUtil.dotProduct(q, v); + float approxDot = f.similarityTo(j); + + localError += Math.abs(approxDot - trueDot); + localCount++; + } + + } + return new double[]{localError, localCount}; + })); + } + + double distanceError = 0.0; + long count = 0; + + for (ForkJoinTask t : errorTasks) { + double[] r = t.join(); + distanceError += r[0]; + count += (long) r[1]; + } + + distanceError /= count; + System.out.println("\tAverage absolute dot-product error = " + distanceError); + } + + // ================================================================== + // [1b] Recall@K run (Parallelized) + // ================================================================== + if (RUN_RECALL_CHECK) { + int[] atValues = {10, 15, 20, 30, 40, 50}; + int maxAt = 50; + List> recallTasks = new ArrayList<>(); + + logProgress("\t[stage] Computing " + RECALL_K + "-Recall@K..."); + for (int start = 0; start < finalQueryCount; start += chunkSize) { + final int s = start; + final int e = Math.min(start + chunkSize, finalQueryCount); + + recallTasks.add(simdExecutor.submit(() -> { + double[] localTotalRecall = new double[atValues.length]; + for (int i = s; i < e; i++) { + VectorFloat q = finalQueries.get(i); + ScoreFunction.ApproximateScoreFunction f = ashVecsFinal.scoreFunctionFor(q, VectorSimilarityFunction.DOT_PRODUCT); + + // Min-heap to keep top 50 results (storing score as int bits and ordinal as long) + var topCandidates = new java.util.PriorityQueue((a, b) -> Float.compare(Float.intBitsToFloat((int) a[0]), Float.intBitsToFloat((int) b[0]))); + for (int j = 0; j < finalVectorCount; j++) { + float score = f.similarityTo(j); + if (topCandidates.size() < maxAt) { + topCandidates.add(new long[]{Float.floatToRawIntBits(score), j}); + } else if (score > Float.intBitsToFloat((int) topCandidates.peek()[0])) { + topCandidates.poll(); + topCandidates.add(new long[]{Float.floatToRawIntBits(score), j}); + } + } + + int[] topIndices = new int[topCandidates.size()]; + for (int rank = topCandidates.size() - 1; rank >= 0; rank--) topIndices[rank] = (int) topCandidates.poll()[1]; + + int[] queryGT = groundTruth.get(i); + for (int aIdx = 0; aIdx < atValues.length; aIdx++) { + int at = atValues[aIdx]; + java.util.Set topAtSet = new java.util.HashSet<>(); + for (int r = 0; r < Math.min(at, topIndices.length); r++) { + topAtSet.add((newToOldFinal == null) ? topIndices[r] : newToOldFinal[topIndices[r]]); + } + int matches = 0; + java.util.HashSet gtSeen = new java.util.HashSet<>(RECALL_K * 2); + // Compute and don't count duplicates (if present in GT or retrieved IDs) + for (int g = 0; g < RECALL_K; g++) { + int gtId = queryGT[g]; + if (gtSeen.add(gtId) && topAtSet.contains(gtId)) { + matches++; + } + } + localTotalRecall[aIdx] += (double) matches / RECALL_K; + } + } + return localTotalRecall; + })); + } + + double[] totalRecall = new double[atValues.length]; + for (ForkJoinTask t : recallTasks) { + double[] local = t.join(); + for (int aIdx = 0; aIdx < atValues.length; aIdx++) totalRecall[aIdx] += local[aIdx]; + } + + for (int aIdx = 0; aIdx < atValues.length; aIdx++) { + System.out.format("\tASH %d-recall@%d = %.4f%n", RECALL_K, atValues[aIdx], totalRecall[aIdx] / finalQueryCount); + } + } + + // ================================================================== + // [2] Scalar ASH scoring timing (reference) + // ================================================================== + if (RUN_SCALAR_SCORING) { + List> ashTasks = new ArrayList<>(); + + long ashStart = System.nanoTime(); + + for (int start = 0; start < finalQueryCount; start += chunkSize) { + final int s = start; + final int e = Math.min(start + chunkSize, finalQueryCount); + + ashTasks.add(simdExecutor.submit(() -> { + double localSum = 0.0; + + for (int i = s; i < e; i++) { + VectorFloat q = finalQueries.get(i); + ScoreFunction.ApproximateScoreFunction f = + ashVecsFinal.scoreFunctionFor(q, VectorSimilarityFunction.DOT_PRODUCT); + + for (int j = 0; j < finalVectorCount; j++) { + localSum += f.similarityTo(j); + } + } + return localSum; + })); + } + + double ashDummy = 0.0; + for (ForkJoinTask t : ashTasks) { + ashDummy += t.join(); + } + + long ashEnd = System.nanoTime(); + + double singleSeconds = (ashEnd - ashStart) / 1e9; + long totalDotProducts = (long) finalQueryCount * (long) finalVectorCount; + double singleThroughput = totalDotProducts / singleSeconds; + + System.out.println("\tSingle " + singleMode + " scoring took " + + singleSeconds + " seconds"); + + System.out.println( + "\tSingle " + singleMode + " throughput = " + + String.format(java.util.Locale.ROOT, "%.3f", singleThroughput) + + " dot-products/sec" + + " (" + + String.format(java.util.Locale.ROOT, "%.3f", singleThroughput / 1e6) + + " Mdot/s)" + ); + + System.out.println("\tdummyAccumulator = " + (float) (ashDummy)); + System.out.println("--"); + + } + + // ================================================================== + // [3] Float dot-product scoring throughput + // ================================================================== + if (RUN_FLOAT_SCORING) { + List> floatTasks = new ArrayList<>(); + + long floatStart = System.nanoTime(); + + for (int start = 0; start < finalQueryCount; start += chunkSize) { + final int s = start; + final int e = Math.min(start + chunkSize, finalQueryCount); + + floatTasks.add(simdExecutor.submit(() -> { + double localSum = 0.0; + + for (int i = s; i < e; i++) { + VectorFloat q = finalQueries.get(i); + for (int j = 0; j < finalVectorCount; j++) { + localSum += VectorUtil.dotProduct(q, finalVectors.get(j)); + } + } + return localSum; + })); + } + + double floatDummy = 0.0; + for (ForkJoinTask t : floatTasks) { + floatDummy += t.join(); + } + + long floatEnd = System.nanoTime(); + + var scoreThroughput = (finalVectorCount * finalQueryCount) / ((floatEnd - floatStart) / 1e9); + + System.out.println( + "\tFloat dot-product = " + + String.format(java.util.Locale.ROOT, "%.3f", scoreThroughput) + + " dot-products/sec" + + " (" + + String.format(java.util.Locale.ROOT, "%.3f", scoreThroughput / 1e6) + + " Mdot/s)" + ); + + // Prevent dead-code elimination + System.out.println("\tdummyAccumulator = " + (float) (floatDummy)); + System.out.println("--"); + } + + // ================================================================== + // [4] Block scoring timing (single mode per JVM run) + // NOTE: Choose the block kernel using: + // -Djvector.ash.blockKernel=scalar|simd|auto + // ================================================================== + final String kernelMode = + System.getProperty("jvector.ash.blockKernel", "auto").toLowerCase(); + + for (int blockSize : BLOCK_SIZES) { + + // Print scorer implementation once per blockSize (diagnostic) + { + ASHBlockScorer scorer = + ashVectorsFinal.blockScorerFor( + finalQueries.get(0), + VectorSimilarityFunction.DOT_PRODUCT, + blockSize + ); + + printScorerInfo(kernelMode + " (blockSize=" + blockSize + ")", scorer); + } + + List> blockTasks = new ArrayList<>(); + long blockStart = System.nanoTime(); + + for (int start = 0; start < finalQueryCount; start += chunkSize) { + final int s = start; + final int e = Math.min(start + chunkSize, finalQueryCount); + + blockTasks.add(simdExecutor.submit(() -> { + double localSum = 0.0; + final float[] scores = new float[blockSize]; + + for (int qi = s; qi < e; qi++) { + ASHBlockScorer scorer = + ashVectorsFinal.blockScorerFor( + finalQueries.get(qi), + VectorSimilarityFunction.DOT_PRODUCT, + blockSize + ); + + int j = 0; + while (j + blockSize <= finalVectorCount) { + scorer.scoreRange(j, blockSize, scores); + for (int k = 0; k < blockSize; k++) { + localSum += scores[k]; + } + j += blockSize; + } + + // Tail uses scalar per-vector scorer (correctness-preserving) + if (j < finalVectorCount) { + ScoreFunction.ApproximateScoreFunction f = + ashVecsFinal.scoreFunctionFor( + finalQueries.get(qi), + VectorSimilarityFunction.DOT_PRODUCT + ); + + for (; j < finalVectorCount; j++) { + localSum += f.similarityTo(j); + } + } + } + + return localSum; + })); + } + + double blockDummy = 0.0; + for (ForkJoinTask t : blockTasks) { + blockDummy += t.join(); + } + + long blockEnd = System.nanoTime(); + + System.out.println( + "\tBlock " + kernelMode + " (blockSize=" + blockSize + ") took " + + (blockEnd - blockStart) / 1e9 + " seconds" + ); + + double blockSeconds = (blockEnd - blockStart) / 1e9; + long totalDotProducts = (long) finalQueryCount * (long) finalVectorCount; + + double scoreThroughput = totalDotProducts / blockSeconds; + + System.out.println( + "\tBlock " + kernelMode + " throughput (blockSize=" + blockSize + ") = " + + String.format(java.util.Locale.ROOT, "%.3f", scoreThroughput) + + " dot-products/sec" + + " (" + + String.format(java.util.Locale.ROOT, "%.3f", scoreThroughput / 1e6) + + " Mdot/s)" + ); + + System.out.println("\tdummyAccumulator = " + (float) (blockDummy)); + System.out.println("--"); + } + } + + public static void runSIFT() throws IOException { + System.out.println("Running siftsmall"); + + var baseVectors = "siftsmall/siftsmall_base.fvecs"; + var queryVectors = "siftsmall/siftsmall_query.fvecs"; + var gtVectors = ""; + testASHEncodings(baseVectors, queryVectors, gtVectors); + } + + public static void runGIST() throws IOException { + System.out.println("Running GIST"); + + var baseVectors = "./fvec/gist/gist_base.fvecs"; + var queryVectors = "./fvec/gist/gist_query.fvecs"; + var gtVectors = ""; + testASHEncodings(baseVectors, queryVectors, gtVectors); + } + + public static void runCohere100k() throws IOException { + System.out.println("Running Cohere-100k"); + + var baseVectors = "./fvec/cohere-100k/cohere_embed-english-v3.0_1024_base_vectors_100000.fvec"; + var queryVectors = "./fvec/cohere-100k/cohere_embed-english-v3.0_1024_query_vectors_10000.fvec"; + var gtVectors = "./fvec/cohere-100k/cohere_embed-english-v3.0_1024_indices_b100000_q10000_k100.ivec"; + testASHEncodings(baseVectors, queryVectors, gtVectors); + } + + public static void runADA() throws IOException { + System.out.println("Running ada-002"); + + var baseVectors = "./fvec/ada-002/ada_002_100000_base_vectors.fvec"; + var queryVectors = "./fvec/ada-002/ada_002_100000_query_vectors_10000.fvec"; + var gtVectors = "./fvec/ada-002/ada_002_100000_indices_query_10000.ivec"; + testASHEncodings(baseVectors, queryVectors, gtVectors); + } + + public static void runADANoZeros() throws IOException { + System.out.println("Running ada-002-no-zeros"); + + var baseVectors = "./fvec/ada-002-no-zeros/ada_002_100000_base_vectors_no_zeros.fvec"; + var queryVectors = "./fvec/ada-002-no-zeros/ada_002_100000_query_vectors_10000_no_zeros.fvec"; + var gtVectors = "./fvec/ada-002-no-zeros/ada-002_gt_no_zeros.ivec"; + testASHEncodings(baseVectors, queryVectors, gtVectors); + } + + public static void runColbert() throws IOException { + System.out.println("Running colbertv2"); + + var baseVectors = "./fvec/wikipedia_squad/1M/colbertv2.0_128_base_vectors_1000000.fvec"; + var queryVectors = "./fvec/wikipedia_squad/1M/colbertv2.0_128_query_vectors_100000.fvec"; + var gtVectors = ""; + testASHEncodings(baseVectors, queryVectors, gtVectors); + } + + public static void runOpenai1536() throws IOException { + System.out.println("Running text-embedding-3-large_1536"); + + var baseVectors = "./fvec/openai-v3-large-1536-100k/text-embedding-3-large_1536_100000_base_vectors.fvec"; + var queryVectors = "./fvec/openai-v3-large-1536-100k/text-embedding-3-large_1536_100000_query_vectors_10000.fvec"; + var gtVectors = "./fvec/openai-v3-large-1536-100k/text-embedding-3-large_1536_100000_indices_query_10000.ivec"; + testASHEncodings(baseVectors, queryVectors, gtVectors); + } + + public static void runOpenai3072() throws IOException { + System.out.println("Running text-embedding-3-large_3072"); + + var baseVectors = "./fvec/openai-v3-large-3072-100k/text-embedding-3-large_3072_100000_base_vectors.fvec"; + var queryVectors = "./fvec/openai-v3-large-3072-100k/text-embedding-3-large_3072_100000_query_vectors_10000.fvec"; + var gtVectors = "./fvec/openai-v3-large-3072-100k/text-embedding-3-large_3072_100000_indices_query_10000.ivec"; + testASHEncodings(baseVectors, queryVectors, gtVectors); + } + + public static void runDpr10m() throws IOException { + System.out.println("Running dpr-10m"); + + var baseVectors = "./fvec/dpr-10m/c4-en_base_10M_norm_files0_2.fvecs"; + var queryVectors = "./fvec/dpr-10m/c4-en_query_10k_norm_files0_1.fvecs"; + var gtVectors = "./fvec/dpr-10m/c4-en_gt_10M_norm_ip_k100.ivecs"; + testASHEncodings(baseVectors, queryVectors, gtVectors); + } + + public static void runCap6m() throws IOException { + System.out.println("Running cap-6m"); + + var baseVectors = "./fvec/cap-6m/Caselaw_gte-Qwen2-1.5B_embeddings_base_6m_norm_shuffle.fvecs"; + var queryVectors = "./fvec/cap-6m/Caselaw_gte-Qwen2-1.5B_embeddings_query_10k_norm_shuffle.fvecs"; + var gtVectors = "./fvec/cap-6m/cap_6m_gt_norm_shuffle_ip_k100.ivecs"; + testASHEncodings(baseVectors, queryVectors, gtVectors); + } + + public static void runCohere10m() throws IOException { + System.out.println("Running cohere-10m"); + + var baseVectors = "./fvec/cohere-10m/cohere_wiki_en_flat_base_10m_norm.fvecs"; + var queryVectors = "./fvec/cohere-10m/cohere_wiki_en_flat_query_10k_norm.fvecs"; + var gtVectors = "./fvec/cohere-10m/cohere_wiki_en_flat_gt_10m_ip_k100.ivecs"; + testASHEncodings(baseVectors, queryVectors, gtVectors); + } + + public static void main(String[] args) throws IOException { +// runSIFT(); +// runGIST(); +// runColbert(); +// runCohere100k(); +// runADA(); +// runADANoZeros(); +// runOpenai1536(); +// runOpenai3072(); +// runDpr10m(); +// runCap6m(); + runCohere10m(); + } +} diff --git a/jvector-examples/yaml-configs/ada002-100k-TEMP.yml b/jvector-examples/yaml-configs/ada002-100k-TEMP.yml new file mode 100644 index 000000000..1cf7e1fbb --- /dev/null +++ b/jvector-examples/yaml-configs/ada002-100k-TEMP.yml @@ -0,0 +1,38 @@ +yamlSchemaVersion: 1 +onDiskIndexVersion: 6 + +dataset: ada002-100k + +construction: + outDegree: [32] + efConstruction: [100] + neighborOverflow: [1.2f] + addHierarchy: [Yes] + refineFinalGraph: [Yes] + fusedGraph: [Yes, No] + compression: + - type: PQ + parameters: + m: 192 # we can either specify the integer m or the integer mFactor. In this case, m will be set to the data dimensionality divided by mFactor + # mFactor: 8 + # k: 256 # optional parameter. By default, k=256 + centerData: No + anisotropicThreshold: -1.0 # optional parameter. By default, anisotropicThreshold=-1 (i.e., no anisotropy) + reranking: + - NVQ + useSavedIndexIfExists: No + +search: + topKOverquery: + 10: [1.0, 2.0, 5.0, 10.0] + 100: [1.0, 2.0] + useSearchPruning: [Yes] + compression: + - type: PQ + parameters: + m: 192 + # k: 256 # optional parameter. By default, k=256 + centerData: No + anisotropicThreshold: -1.0 # optional parameter. By default, anisotropicThreshold=-1 (i.e., no anisotropy) + +# Run-level controls, such as benchmarks, console, and logging, are in run.yml. \ No newline at end of file diff --git a/jvector-examples/yaml-configs/cap-1M.yml b/jvector-examples/yaml-configs/cap-1M.yml new file mode 100644 index 000000000..5fdb1ee2e --- /dev/null +++ b/jvector-examples/yaml-configs/cap-1M.yml @@ -0,0 +1,72 @@ +yamlSchemaVersion: 1 +onDiskIndexVersion: 6 + +dataset: cap-6M + +construction: + outDegree: [64] + efConstruction: [200] + neighborOverflow: [1.6f] + addHierarchy: [No] + refineFinalGraph: [Yes] + fusedGraph: [No] + compression: + - type: ASH + parameters: + encodedBits: 1544 + optimizer: itq + landmarkCount: 1 + - type: ASH + parameters: + encodedBits: 776 + optimizer: itq + landmarkCount: 1 + - type: PQ + parameters: + m: 193 # we can either specify the integer m or the integer mFactor. In this case, m will be set to the data dimensionality divided by mFactor + # mFactor: 8 + # k: 256 # optional parameter. By default, k=256 + centerData: No + anisotropicThreshold: -1.0 # optional parameter. By default, anisotropicThreshold=-1 (i.e., no anisotropy) + - type: PQ + parameters: + m: 97 # we can either specify the integer m or the integer mFactor. In this case, m will be set to the data dimensionality divided by mFactor + # mFactor: 8 + # k: 256 # optional parameter. By default, k=256 + centerData: No + anisotropicThreshold: -1.0 # optional parameter. By default, anisotropicThreshold=-1 (i.e., no anisotropy) + reranking: + - FP + useSavedIndexIfExists: Yes # If yes, the system will attempt to locate a cached copy of the index instead of constructing + +search: + topKOverquery: + 10: [1.00, 1.25, 1.56, 1.95, 2.44, 3.05, 3.81, 4.77, 5.96, 7.45, 9.31, 11.64] +# 100: [1.0] + useSearchPruning: [No] + compression: + - type: ASH + parameters: + encodedBits: 1544 + optimizer: itq + landmarkCount: 1 + - type: ASH + parameters: + encodedBits: 776 + optimizer: itq + landmarkCount: 1 + - type: PQ + parameters: + m: 193 # we can either specify the integer m or the integer mFactor. In this case, m will be set to the data dimensionality divided by mFactor + # mFactor: 8 + # k: 256 # optional parameter. By default, k=256 + centerData: No + anisotropicThreshold: -1.0 # optional parameter. By default, anisotropicThreshold=-1 (i.e., no anisotropy) + - type: PQ + parameters: + m: 97 # we can either specify the integer m or the integer mFactor. In this case, m will be set to the data dimensionality divided by mFactor + # mFactor: 8 + # k: 256 # optional parameter. By default, k=256 + centerData: No + anisotropicThreshold: -1.0 # optional parameter. By default, anisotropicThreshold=-1 (i.e., no anisotropy) +# Run-level controls, such as benchmarks, console, and logging, are in run.yml. \ No newline at end of file diff --git a/jvector-examples/yaml-configs/cap-6M.yml b/jvector-examples/yaml-configs/cap-6M.yml new file mode 100644 index 000000000..bf3769014 --- /dev/null +++ b/jvector-examples/yaml-configs/cap-6M.yml @@ -0,0 +1,47 @@ +yamlSchemaVersion: 1 +onDiskIndexVersion: 6 + +dataset: cap-6M + +construction: + outDegree: [64] + efConstruction: [200] + neighborOverflow: [1.6f] + addHierarchy: [No] + refineFinalGraph: [Yes] + fusedGraph: [No] + compression: + - type: PQ + parameters: + m: 192 # we can either specify the integer m or the integer mFactor. In this case, m will be set to the data dimensionality divided by mFactor + # mFactor: 8 + # k: 256 # optional parameter. By default, k=256 + centerData: No + anisotropicThreshold: -1.0 # optional parameter. By default, anisotropicThreshold=-1 (i.e., no anisotropy) + - type: ASH + parameters: + encodedBits: 1536 + optimizer: itq + landmarkCount: 1 + reranking: + - FP + useSavedIndexIfExists: Yes # If yes, the system will attempt to locate a cached copy of the index instead of constructing + +search: + topKOverquery: + 10: [1.00, 1.25, 1.56, 1.95, 2.44, 3.05, 3.81, 4.77, 5.96, 7.45, 9.31, 11.64] +# 100: [1.0] + useSearchPruning: [No] + compression: + - type: PQ + parameters: + m: 192 + # k: 256 # optional parameter. By default, k=256 + centerData: No + anisotropicThreshold: -1.0 # optional parameter. By default, anisotropicThreshold=-1 (i.e., no anisotropy) + - type: ASH + parameters: + encodedBits: 1536 + optimizer: itq + landmarkCount: 1 +# Run-level controls, such as benchmarks, console, and logging, are in run.yml. \ No newline at end of file diff --git a/jvector-examples/yaml-configs/cohere-10M.yml b/jvector-examples/yaml-configs/cohere-10M.yml new file mode 100644 index 000000000..45e164079 --- /dev/null +++ b/jvector-examples/yaml-configs/cohere-10M.yml @@ -0,0 +1,47 @@ +yamlSchemaVersion: 1 +onDiskIndexVersion: 6 + +dataset: cohere-10M + +construction: + outDegree: [64] + efConstruction: [200] + neighborOverflow: [1.6f] + addHierarchy: [No] + refineFinalGraph: [Yes] + fusedGraph: [No] + compression: + - type: PQ + parameters: + m: 128 # we can either specify the integer m or the integer mFactor. In this case, m will be set to the data dimensionality divided by mFactor + # mFactor: 8 + # k: 256 # optional parameter. By default, k=256 + centerData: No + anisotropicThreshold: -1.0 # optional parameter. By default, anisotropicThreshold=-1 (i.e., no anisotropy) + - type: ASH + parameters: + encodedBits: 1024 + optimizer: itq + landmarkCount: 1 + reranking: + - FP + useSavedIndexIfExists: Yes # If yes, the system will attempt to locate a cached copy of the index instead of constructing + +search: + topKOverquery: + 10: [1.00, 1.25, 1.56, 1.95, 2.44, 3.05, 3.81, 4.77, 5.96, 7.45, 9.31, 11.64] +# 100: [1.0] + useSearchPruning: [No] + compression: + - type: PQ + parameters: + m: 128 + # k: 256 # optional parameter. By default, k=256 + centerData: No + anisotropicThreshold: -1.0 # optional parameter. By default, anisotropicThreshold=-1 (i.e., no anisotropy) + - type: ASH + parameters: + encodedBits: 1024 + optimizer: itq + landmarkCount: 1 +# Run-level controls, such as benchmarks, console, and logging, are in run.yml. \ No newline at end of file diff --git a/jvector-examples/yaml-configs/cohere-english-v3-1M.yml b/jvector-examples/yaml-configs/cohere-english-v3-1M.yml new file mode 100644 index 000000000..549f39de8 --- /dev/null +++ b/jvector-examples/yaml-configs/cohere-english-v3-1M.yml @@ -0,0 +1,72 @@ +yamlSchemaVersion: 1 +onDiskIndexVersion: 6 + +dataset: cohere-1M + +construction: + outDegree: [64] + efConstruction: [200] + neighborOverflow: [1.6f] + addHierarchy: [No] + refineFinalGraph: [Yes] + fusedGraph: [No] + compression: + - type: ASH + parameters: + encodedBits: 1032 + optimizer: itq + landmarkCount: 1 + - type: ASH + parameters: + encodedBits: 520 + optimizer: itq + landmarkCount: 1 + - type: PQ + parameters: + m: 129 # we can either specify the integer m or the integer mFactor. In this case, m will be set to the data dimensionality divided by mFactor + # mFactor: 8 + # k: 256 # optional parameter. By default, k=256 + centerData: No + anisotropicThreshold: -1.0 # optional parameter. By default, anisotropicThreshold=-1 (i.e., no anisotropy) + - type: PQ + parameters: + m: 65 # we can either specify the integer m or the integer mFactor. In this case, m will be set to the data dimensionality divided by mFactor + # mFactor: 8 + # k: 256 # optional parameter. By default, k=256 + centerData: No + anisotropicThreshold: -1.0 # optional parameter. By default, anisotropicThreshold=-1 (i.e., no anisotropy) + reranking: + - FP + useSavedIndexIfExists: Yes # If yes, the system will attempt to locate a cached copy of the index instead of constructing + +search: + topKOverquery: + 10: [1.00, 1.25, 1.56, 1.95, 2.44, 3.05, 3.81, 4.77, 5.96, 7.45, 9.31, 11.64] +# 100: [1.0] + useSearchPruning: [No] + compression: + - type: ASH + parameters: + encodedBits: 1032 + optimizer: itq + landmarkCount: 1 + - type: ASH + parameters: + encodedBits: 520 + optimizer: itq + landmarkCount: 1 + - type: PQ + parameters: + m: 129 # we can either specify the integer m or the integer mFactor. In this case, m will be set to the data dimensionality divided by mFactor + # mFactor: 8 + # k: 256 # optional parameter. By default, k=256 + centerData: No + anisotropicThreshold: -1.0 # optional parameter. By default, anisotropicThreshold=-1 (i.e., no anisotropy) + - type: PQ + parameters: + m: 65 # we can either specify the integer m or the integer mFactor. In this case, m will be set to the data dimensionality divided by mFactor + # mFactor: 8 + # k: 256 # optional parameter. By default, k=256 + centerData: No + anisotropicThreshold: -1.0 # optional parameter. By default, anisotropicThreshold=-1 (i.e., no anisotropy) +# Run-level controls, such as benchmarks, console, and logging, are in run.yml. \ No newline at end of file diff --git a/jvector-examples/yaml-configs/dpr-10M.yml b/jvector-examples/yaml-configs/dpr-10M.yml new file mode 100644 index 000000000..1ea9ec1ac --- /dev/null +++ b/jvector-examples/yaml-configs/dpr-10M.yml @@ -0,0 +1,47 @@ +yamlSchemaVersion: 1 +onDiskIndexVersion: 6 + +dataset: dpr-10M + +construction: + outDegree: [64] + efConstruction: [200] + neighborOverflow: [1.6f] + addHierarchy: [No] + refineFinalGraph: [Yes] + fusedGraph: [No] + compression: + - type: PQ + parameters: + m: 96 # we can either specify the integer m or the integer mFactor. In this case, m will be set to the data dimensionality divided by mFactor + # mFactor: 8 + # k: 256 # optional parameter. By default, k=256 + centerData: No + anisotropicThreshold: -1.0 # optional parameter. By default, anisotropicThreshold=-1 (i.e., no anisotropy) + - type: ASH + parameters: + encodedBits: 768 + optimizer: itq + landmarkCount: 1 + reranking: + - FP + useSavedIndexIfExists: Yes # If yes, the system will attempt to locate a cached copy of the index instead of constructing + +search: + topKOverquery: + 10: [1.00, 1.25, 1.56, 1.95, 2.44, 3.05, 3.81, 4.77, 5.96, 7.45, 9.31, 11.64] +# 100: [1.0] + useSearchPruning: [No] + compression: + - type: PQ + parameters: + m: 96 + # k: 256 # optional parameter. By default, k=256 + centerData: No + anisotropicThreshold: -1.0 # optional parameter. By default, anisotropicThreshold=-1 (i.e., no anisotropy) + - type: ASH + parameters: + encodedBits: 768 + optimizer: itq + landmarkCount: 1 +# Run-level controls, such as benchmarks, console, and logging, are in run.yml. \ No newline at end of file diff --git a/jvector-examples/yaml-configs/dpr-1M.yml b/jvector-examples/yaml-configs/dpr-1M.yml new file mode 100644 index 000000000..0201d3e5b --- /dev/null +++ b/jvector-examples/yaml-configs/dpr-1M.yml @@ -0,0 +1,72 @@ +yamlSchemaVersion: 1 +onDiskIndexVersion: 6 + +dataset: dpr-10M + +construction: + outDegree: [64] + efConstruction: [200] + neighborOverflow: [1.6f] + addHierarchy: [Yes] + refineFinalGraph: [Yes] + fusedGraph: [No] + compression: + - type: ASH + parameters: + encodedBits: 776 + optimizer: itq + landmarkCount: 1 + - type: ASH + parameters: + encodedBits: 392 + optimizer: itq + landmarkCount: 1 + - type: PQ + parameters: + m: 97 # we can either specify the integer m or the integer mFactor. In this case, m will be set to the data dimensionality divided by mFactor + # mFactor: 8 + # k: 256 # optional parameter. By default, k=256 + centerData: No + anisotropicThreshold: -1.0 # optional parameter. By default, anisotropicThreshold=-1 (i.e., no anisotropy) + - type: PQ + parameters: + m: 49 # we can either specify the integer m or the integer mFactor. In this case, m will be set to the data dimensionality divided by mFactor + # mFactor: 8 + # k: 256 # optional parameter. By default, k=256 + centerData: No + anisotropicThreshold: -1.0 # optional parameter. By default, anisotropicThreshold=-1 (i.e., no anisotropy) + reranking: + - FP + useSavedIndexIfExists: Yes # If yes, the system will attempt to locate a cached copy of the index instead of constructing + +search: + topKOverquery: + 10: [1.00, 1.25, 1.56, 1.95, 2.44, 3.05, 3.81, 4.77, 5.96, 7.45, 9.31, 11.64] +# 100: [1.0] + useSearchPruning: [No] + compression: + - type: ASH + parameters: + encodedBits: 776 + optimizer: itq + landmarkCount: 1 + - type: ASH + parameters: + encodedBits: 392 + optimizer: itq + landmarkCount: 1 + - type: PQ + parameters: + m: 97 # we can either specify the integer m or the integer mFactor. In this case, m will be set to the data dimensionality divided by mFactor + # mFactor: 8 + # k: 256 # optional parameter. By default, k=256 + centerData: No + anisotropicThreshold: -1.0 # optional parameter. By default, anisotropicThreshold=-1 (i.e., no anisotropy) + - type: PQ + parameters: + m: 49 # we can either specify the integer m or the integer mFactor. In this case, m will be set to the data dimensionality divided by mFactor + # mFactor: 8 + # k: 256 # optional parameter. By default, k=256 + centerData: No + anisotropicThreshold: -1.0 # optional parameter. By default, anisotropicThreshold=-1 (i.e., no anisotropy) +# Run-level controls, such as benchmarks, console, and logging, are in run.yml. \ No newline at end of file diff --git a/jvector-examples/yaml-configs/dpr-gemma-1M.yml b/jvector-examples/yaml-configs/dpr-gemma-1M.yml new file mode 100644 index 000000000..98a7b8f8b --- /dev/null +++ b/jvector-examples/yaml-configs/dpr-gemma-1M.yml @@ -0,0 +1,72 @@ +yamlSchemaVersion: 1 +onDiskIndexVersion: 6 + +dataset: dpr-1M + +construction: + outDegree: [64] + efConstruction: [200] + neighborOverflow: [1.6f] + addHierarchy: [Yes] + refineFinalGraph: [Yes] + fusedGraph: [No] + compression: + - type: ASH + parameters: + encodedBits: 776 + optimizer: itq + landmarkCount: 1 + - type: ASH + parameters: + encodedBits: 392 + optimizer: itq + landmarkCount: 1 + - type: PQ + parameters: + m: 97 # we can either specify the integer m or the integer mFactor. In this case, m will be set to the data dimensionality divided by mFactor + # mFactor: 8 + # k: 256 # optional parameter. By default, k=256 + centerData: No + anisotropicThreshold: -1.0 # optional parameter. By default, anisotropicThreshold=-1 (i.e., no anisotropy) + - type: PQ + parameters: + m: 49 # we can either specify the integer m or the integer mFactor. In this case, m will be set to the data dimensionality divided by mFactor + # mFactor: 8 + # k: 256 # optional parameter. By default, k=256 + centerData: No + anisotropicThreshold: -1.0 # optional parameter. By default, anisotropicThreshold=-1 (i.e., no anisotropy) + reranking: + - FP + useSavedIndexIfExists: Yes # If yes, the system will attempt to locate a cached copy of the index instead of constructing + +search: + topKOverquery: + 10: [1.00, 1.25, 1.56, 1.95, 2.44, 3.05, 3.81, 4.77, 5.96, 7.45, 9.31, 11.64] +# 100: [1.0] + useSearchPruning: [No] + compression: + - type: ASH + parameters: + encodedBits: 776 + optimizer: itq + landmarkCount: 1 + - type: ASH + parameters: + encodedBits: 392 + optimizer: itq + landmarkCount: 1 + - type: PQ + parameters: + m: 97 # we can either specify the integer m or the integer mFactor. In this case, m will be set to the data dimensionality divided by mFactor + # mFactor: 8 + # k: 256 # optional parameter. By default, k=256 + centerData: No + anisotropicThreshold: -1.0 # optional parameter. By default, anisotropicThreshold=-1 (i.e., no anisotropy) + - type: PQ + parameters: + m: 49 # we can either specify the integer m or the integer mFactor. In this case, m will be set to the data dimensionality divided by mFactor + # mFactor: 8 + # k: 256 # optional parameter. By default, k=256 + centerData: No + anisotropicThreshold: -1.0 # optional parameter. By default, anisotropicThreshold=-1 (i.e., no anisotropy) +# Run-level controls, such as benchmarks, console, and logging, are in run.yml. \ No newline at end of file diff --git a/jvector-examples/yaml-configs/esmc-600-1M.yml b/jvector-examples/yaml-configs/esmc-600-1M.yml new file mode 100644 index 000000000..549f39de8 --- /dev/null +++ b/jvector-examples/yaml-configs/esmc-600-1M.yml @@ -0,0 +1,72 @@ +yamlSchemaVersion: 1 +onDiskIndexVersion: 6 + +dataset: cohere-1M + +construction: + outDegree: [64] + efConstruction: [200] + neighborOverflow: [1.6f] + addHierarchy: [No] + refineFinalGraph: [Yes] + fusedGraph: [No] + compression: + - type: ASH + parameters: + encodedBits: 1032 + optimizer: itq + landmarkCount: 1 + - type: ASH + parameters: + encodedBits: 520 + optimizer: itq + landmarkCount: 1 + - type: PQ + parameters: + m: 129 # we can either specify the integer m or the integer mFactor. In this case, m will be set to the data dimensionality divided by mFactor + # mFactor: 8 + # k: 256 # optional parameter. By default, k=256 + centerData: No + anisotropicThreshold: -1.0 # optional parameter. By default, anisotropicThreshold=-1 (i.e., no anisotropy) + - type: PQ + parameters: + m: 65 # we can either specify the integer m or the integer mFactor. In this case, m will be set to the data dimensionality divided by mFactor + # mFactor: 8 + # k: 256 # optional parameter. By default, k=256 + centerData: No + anisotropicThreshold: -1.0 # optional parameter. By default, anisotropicThreshold=-1 (i.e., no anisotropy) + reranking: + - FP + useSavedIndexIfExists: Yes # If yes, the system will attempt to locate a cached copy of the index instead of constructing + +search: + topKOverquery: + 10: [1.00, 1.25, 1.56, 1.95, 2.44, 3.05, 3.81, 4.77, 5.96, 7.45, 9.31, 11.64] +# 100: [1.0] + useSearchPruning: [No] + compression: + - type: ASH + parameters: + encodedBits: 1032 + optimizer: itq + landmarkCount: 1 + - type: ASH + parameters: + encodedBits: 520 + optimizer: itq + landmarkCount: 1 + - type: PQ + parameters: + m: 129 # we can either specify the integer m or the integer mFactor. In this case, m will be set to the data dimensionality divided by mFactor + # mFactor: 8 + # k: 256 # optional parameter. By default, k=256 + centerData: No + anisotropicThreshold: -1.0 # optional parameter. By default, anisotropicThreshold=-1 (i.e., no anisotropy) + - type: PQ + parameters: + m: 65 # we can either specify the integer m or the integer mFactor. In this case, m will be set to the data dimensionality divided by mFactor + # mFactor: 8 + # k: 256 # optional parameter. By default, k=256 + centerData: No + anisotropicThreshold: -1.0 # optional parameter. By default, anisotropicThreshold=-1 (i.e., no anisotropy) +# Run-level controls, such as benchmarks, console, and logging, are in run.yml. \ No newline at end of file diff --git a/jvector-examples/yaml-configs/esmc-600-6M.yml b/jvector-examples/yaml-configs/esmc-600-6M.yml new file mode 100644 index 000000000..b58605d22 --- /dev/null +++ b/jvector-examples/yaml-configs/esmc-600-6M.yml @@ -0,0 +1,72 @@ +yamlSchemaVersion: 1 +onDiskIndexVersion: 6 + +dataset: esmc-600-1M + +construction: + outDegree: [64] + efConstruction: [200] + neighborOverflow: [1.6f] + addHierarchy: [No] + refineFinalGraph: [Yes] + fusedGraph: [No] + compression: + - type: ASH + parameters: + encodedBits: 1160 + optimizer: itq + landmarkCount: 1 + - type: ASH + parameters: + encodedBits: 584 + optimizer: itq + landmarkCount: 1 + - type: PQ + parameters: + m: 145 # we can either specify the integer m or the integer mFactor. In this case, m will be set to the data dimensionality divided by mFactor + # mFactor: 8 + # k: 256 # optional parameter. By default, k=256 + centerData: No + anisotropicThreshold: -1.0 # optional parameter. By default, anisotropicThreshold=-1 (i.e., no anisotropy) + - type: PQ + parameters: + m: 73 # we can either specify the integer m or the integer mFactor. In this case, m will be set to the data dimensionality divided by mFactor + # mFactor: 8 + # k: 256 # optional parameter. By default, k=256 + centerData: No + anisotropicThreshold: -1.0 # optional parameter. By default, anisotropicThreshold=-1 (i.e., no anisotropy) + reranking: + - FP + useSavedIndexIfExists: Yes # If yes, the system will attempt to locate a cached copy of the index instead of constructing + +search: + topKOverquery: + 10: [1.00, 1.25, 1.56, 1.95, 2.44, 3.05, 3.81, 4.77, 5.96, 7.45, 9.31, 11.64] +# 100: [1.0] + useSearchPruning: [No] + compression: + - type: ASH + parameters: + encodedBits: 1160 + optimizer: itq + landmarkCount: 1 + - type: ASH + parameters: + encodedBits: 584 + optimizer: itq + landmarkCount: 1 + - type: PQ + parameters: + m: 145 # we can either specify the integer m or the integer mFactor. In this case, m will be set to the data dimensionality divided by mFactor + # mFactor: 8 + # k: 256 # optional parameter. By default, k=256 + centerData: No + anisotropicThreshold: -1.0 # optional parameter. By default, anisotropicThreshold=-1 (i.e., no anisotropy) + - type: PQ + parameters: + m: 73 # we can either specify the integer m or the integer mFactor. In this case, m will be set to the data dimensionality divided by mFactor + # mFactor: 8 + # k: 256 # optional parameter. By default, k=256 + centerData: No + anisotropicThreshold: -1.0 # optional parameter. By default, anisotropicThreshold=-1 (i.e., no anisotropy) +# Run-level controls, such as benchmarks, console, and logging, are in run.yml. \ No newline at end of file diff --git a/jvector-examples/yaml-configs/gecko-100k.yml b/jvector-examples/yaml-configs/gecko-100k.yml new file mode 100644 index 000000000..fbe4aee54 --- /dev/null +++ b/jvector-examples/yaml-configs/gecko-100k.yml @@ -0,0 +1,72 @@ +yamlSchemaVersion: 1 +onDiskIndexVersion: 6 + +dataset: dpr-10M + +construction: + outDegree: [64] + efConstruction: [200] + neighborOverflow: [1.6f] + addHierarchy: [No] + refineFinalGraph: [Yes] + fusedGraph: [No] + compression: + - type: PQ + parameters: + m: 97 # we can either specify the integer m or the integer mFactor. In this case, m will be set to the data dimensionality divided by mFactor + # mFactor: 8 + # k: 256 # optional parameter. By default, k=256 + centerData: No + anisotropicThreshold: -1.0 # optional parameter. By default, anisotropicThreshold=-1 (i.e., no anisotropy) + - type: PQ + parameters: + m: 49 # we can either specify the integer m or the integer mFactor. In this case, m will be set to the data dimensionality divided by mFactor + # mFactor: 8 + # k: 256 # optional parameter. By default, k=256 + centerData: No + anisotropicThreshold: -1.0 # optional parameter. By default, anisotropicThreshold=-1 (i.e., no anisotropy) + - type: ASH + parameters: + encodedBits: 776 + optimizer: itq + landmarkCount: 1 + - type: ASH + parameters: + encodedBits: 392 + optimizer: itq + landmarkCount: 1 + reranking: + - FP + useSavedIndexIfExists: Yes # If yes, the system will attempt to locate a cached copy of the index instead of constructing + +search: + topKOverquery: + 10: [1.00, 1.25, 1.56, 1.95, 2.44, 3.05, 3.81, 4.77, 5.96, 7.45, 9.31, 11.64] +# 100: [1.0] + useSearchPruning: [No] + compression: + - type: PQ + parameters: + m: 97 # we can either specify the integer m or the integer mFactor. In this case, m will be set to the data dimensionality divided by mFactor + # mFactor: 8 + # k: 256 # optional parameter. By default, k=256 + centerData: No + anisotropicThreshold: -1.0 # optional parameter. By default, anisotropicThreshold=-1 (i.e., no anisotropy) + - type: PQ + parameters: + m: 49 # we can either specify the integer m or the integer mFactor. In this case, m will be set to the data dimensionality divided by mFactor + # mFactor: 8 + # k: 256 # optional parameter. By default, k=256 + centerData: No + anisotropicThreshold: -1.0 # optional parameter. By default, anisotropicThreshold=-1 (i.e., no anisotropy) + - type: ASH + parameters: + encodedBits: 776 + optimizer: itq + landmarkCount: 1 + - type: ASH + parameters: + encodedBits: 392 + optimizer: itq + landmarkCount: 1 +# Run-level controls, such as benchmarks, console, and logging, are in run.yml. \ No newline at end of file diff --git a/jvector-examples/yaml-configs/mpnet-1M.yml b/jvector-examples/yaml-configs/mpnet-1M.yml new file mode 100644 index 000000000..9eba8d1ef --- /dev/null +++ b/jvector-examples/yaml-configs/mpnet-1M.yml @@ -0,0 +1,72 @@ +yamlSchemaVersion: 1 +onDiskIndexVersion: 6 + +dataset: mpnet-100k + +construction: + outDegree: [64] + efConstruction: [200] + neighborOverflow: [1.6f] + addHierarchy: [Yes] + refineFinalGraph: [Yes] + fusedGraph: [No] + compression: + - type: ASH + parameters: + encodedBits: 776 + optimizer: itq + landmarkCount: 1 + - type: ASH + parameters: + encodedBits: 392 + optimizer: itq + landmarkCount: 1 + - type: PQ + parameters: + m: 97 # we can either specify the integer m or the integer mFactor. In this case, m will be set to the data dimensionality divided by mFactor + # mFactor: 8 + # k: 256 # optional parameter. By default, k=256 + centerData: No + anisotropicThreshold: -1.0 # optional parameter. By default, anisotropicThreshold=-1 (i.e., no anisotropy) + - type: PQ + parameters: + m: 49 # we can either specify the integer m or the integer mFactor. In this case, m will be set to the data dimensionality divided by mFactor + # mFactor: 8 + # k: 256 # optional parameter. By default, k=256 + centerData: No + anisotropicThreshold: -1.0 # optional parameter. By default, anisotropicThreshold=-1 (i.e., no anisotropy) + reranking: + - FP + useSavedIndexIfExists: Yes # If yes, the system will attempt to locate a cached copy of the index instead of constructing + +search: + topKOverquery: + 10: [1.00, 1.25, 1.56, 1.95, 2.44, 3.05, 3.81, 4.77, 5.96, 7.45, 9.31, 11.64] +# 100: [1.0] + useSearchPruning: [No] + compression: + - type: ASH + parameters: + encodedBits: 776 + optimizer: itq + landmarkCount: 1 + - type: ASH + parameters: + encodedBits: 392 + optimizer: itq + landmarkCount: 1 + - type: PQ + parameters: + m: 97 # we can either specify the integer m or the integer mFactor. In this case, m will be set to the data dimensionality divided by mFactor + # mFactor: 8 + # k: 256 # optional parameter. By default, k=256 + centerData: No + anisotropicThreshold: -1.0 # optional parameter. By default, anisotropicThreshold=-1 (i.e., no anisotropy) + - type: PQ + parameters: + m: 49 # we can either specify the integer m or the integer mFactor. In this case, m will be set to the data dimensionality divided by mFactor + # mFactor: 8 + # k: 256 # optional parameter. By default, k=256 + centerData: No + anisotropicThreshold: -1.0 # optional parameter. By default, anisotropicThreshold=-1 (i.e., no anisotropy) +# Run-level controls, such as benchmarks, console, and logging, are in run.yml. \ No newline at end of file diff --git a/jvector-examples/yaml-configs/openai-v3-small-100k.yml b/jvector-examples/yaml-configs/openai-v3-small-100k.yml new file mode 100644 index 000000000..5fdb1ee2e --- /dev/null +++ b/jvector-examples/yaml-configs/openai-v3-small-100k.yml @@ -0,0 +1,72 @@ +yamlSchemaVersion: 1 +onDiskIndexVersion: 6 + +dataset: cap-6M + +construction: + outDegree: [64] + efConstruction: [200] + neighborOverflow: [1.6f] + addHierarchy: [No] + refineFinalGraph: [Yes] + fusedGraph: [No] + compression: + - type: ASH + parameters: + encodedBits: 1544 + optimizer: itq + landmarkCount: 1 + - type: ASH + parameters: + encodedBits: 776 + optimizer: itq + landmarkCount: 1 + - type: PQ + parameters: + m: 193 # we can either specify the integer m or the integer mFactor. In this case, m will be set to the data dimensionality divided by mFactor + # mFactor: 8 + # k: 256 # optional parameter. By default, k=256 + centerData: No + anisotropicThreshold: -1.0 # optional parameter. By default, anisotropicThreshold=-1 (i.e., no anisotropy) + - type: PQ + parameters: + m: 97 # we can either specify the integer m or the integer mFactor. In this case, m will be set to the data dimensionality divided by mFactor + # mFactor: 8 + # k: 256 # optional parameter. By default, k=256 + centerData: No + anisotropicThreshold: -1.0 # optional parameter. By default, anisotropicThreshold=-1 (i.e., no anisotropy) + reranking: + - FP + useSavedIndexIfExists: Yes # If yes, the system will attempt to locate a cached copy of the index instead of constructing + +search: + topKOverquery: + 10: [1.00, 1.25, 1.56, 1.95, 2.44, 3.05, 3.81, 4.77, 5.96, 7.45, 9.31, 11.64] +# 100: [1.0] + useSearchPruning: [No] + compression: + - type: ASH + parameters: + encodedBits: 1544 + optimizer: itq + landmarkCount: 1 + - type: ASH + parameters: + encodedBits: 776 + optimizer: itq + landmarkCount: 1 + - type: PQ + parameters: + m: 193 # we can either specify the integer m or the integer mFactor. In this case, m will be set to the data dimensionality divided by mFactor + # mFactor: 8 + # k: 256 # optional parameter. By default, k=256 + centerData: No + anisotropicThreshold: -1.0 # optional parameter. By default, anisotropicThreshold=-1 (i.e., no anisotropy) + - type: PQ + parameters: + m: 97 # we can either specify the integer m or the integer mFactor. In this case, m will be set to the data dimensionality divided by mFactor + # mFactor: 8 + # k: 256 # optional parameter. By default, k=256 + centerData: No + anisotropicThreshold: -1.0 # optional parameter. By default, anisotropicThreshold=-1 (i.e., no anisotropy) +# Run-level controls, such as benchmarks, console, and logging, are in run.yml. \ No newline at end of file diff --git a/jvector-examples/yaml-configs/personahub-1M.yml b/jvector-examples/yaml-configs/personahub-1M.yml new file mode 100644 index 000000000..549f39de8 --- /dev/null +++ b/jvector-examples/yaml-configs/personahub-1M.yml @@ -0,0 +1,72 @@ +yamlSchemaVersion: 1 +onDiskIndexVersion: 6 + +dataset: cohere-1M + +construction: + outDegree: [64] + efConstruction: [200] + neighborOverflow: [1.6f] + addHierarchy: [No] + refineFinalGraph: [Yes] + fusedGraph: [No] + compression: + - type: ASH + parameters: + encodedBits: 1032 + optimizer: itq + landmarkCount: 1 + - type: ASH + parameters: + encodedBits: 520 + optimizer: itq + landmarkCount: 1 + - type: PQ + parameters: + m: 129 # we can either specify the integer m or the integer mFactor. In this case, m will be set to the data dimensionality divided by mFactor + # mFactor: 8 + # k: 256 # optional parameter. By default, k=256 + centerData: No + anisotropicThreshold: -1.0 # optional parameter. By default, anisotropicThreshold=-1 (i.e., no anisotropy) + - type: PQ + parameters: + m: 65 # we can either specify the integer m or the integer mFactor. In this case, m will be set to the data dimensionality divided by mFactor + # mFactor: 8 + # k: 256 # optional parameter. By default, k=256 + centerData: No + anisotropicThreshold: -1.0 # optional parameter. By default, anisotropicThreshold=-1 (i.e., no anisotropy) + reranking: + - FP + useSavedIndexIfExists: Yes # If yes, the system will attempt to locate a cached copy of the index instead of constructing + +search: + topKOverquery: + 10: [1.00, 1.25, 1.56, 1.95, 2.44, 3.05, 3.81, 4.77, 5.96, 7.45, 9.31, 11.64] +# 100: [1.0] + useSearchPruning: [No] + compression: + - type: ASH + parameters: + encodedBits: 1032 + optimizer: itq + landmarkCount: 1 + - type: ASH + parameters: + encodedBits: 520 + optimizer: itq + landmarkCount: 1 + - type: PQ + parameters: + m: 129 # we can either specify the integer m or the integer mFactor. In this case, m will be set to the data dimensionality divided by mFactor + # mFactor: 8 + # k: 256 # optional parameter. By default, k=256 + centerData: No + anisotropicThreshold: -1.0 # optional parameter. By default, anisotropicThreshold=-1 (i.e., no anisotropy) + - type: PQ + parameters: + m: 65 # we can either specify the integer m or the integer mFactor. In this case, m will be set to the data dimensionality divided by mFactor + # mFactor: 8 + # k: 256 # optional parameter. By default, k=256 + centerData: No + anisotropicThreshold: -1.0 # optional parameter. By default, anisotropicThreshold=-1 (i.e., no anisotropy) +# Run-level controls, such as benchmarks, console, and logging, are in run.yml. \ No newline at end of file diff --git a/jvector-examples/yaml-configs/tahoe-x1-1M.yml b/jvector-examples/yaml-configs/tahoe-x1-1M.yml new file mode 100644 index 000000000..86f4114e0 --- /dev/null +++ b/jvector-examples/yaml-configs/tahoe-x1-1M.yml @@ -0,0 +1,73 @@ +yamlSchemaVersion: 1 +onDiskIndexVersion: 6 + +dataset: tahoe-x1-100k + +construction: + outDegree: [32] + efConstruction: [100] + neighborOverflow: [1.6f] + addHierarchy: [No] + refineFinalGraph: [Yes] + fusedGraph: [No] + compression: + - type: ASH + parameters: + encodedBits: 2568 + optimizer: itq + landmarkCount: 1 + - type: ASH + parameters: + encodedBits: 1288 + optimizer: itq + landmarkCount: 1 + - type: PQ + parameters: + m: 321 # we can either specify the integer m or the integer mFactor. In this case, m will be set to the data dimensionality divided by mFactor + # mFactor: 8 + # k: 256 # optional parameter. By default, k=256 + centerData: No + anisotropicThreshold: -1.0 # optional parameter. By default, anisotropicThreshold=-1 (i.e., no anisotropy) + - type: PQ + parameters: + m: 161 # we can either specify the integer m or the integer mFactor. In this case, m will be set to the data dimensionality divided by mFactor + # mFactor: 8 + # k: 256 # optional parameter. By default, k=256 + centerData: No + anisotropicThreshold: -1.0 # optional parameter. By default, anisotropicThreshold=-1 (i.e., no anisotropy) + reranking: + - FP + useSavedIndexIfExists: Yes # If yes, the system will attempt to locate a cached copy of the index instead of constructing + +search: + topKOverquery: +# 10: [1.00, 1.25, 1.56, 1.95, 2.44, 3.05, 3.81, 4.77, 5.96, 7.45, 9.31, 11.64] + 10: [1.00, 1.25, 1.56, 1.95, 2.44] +# 100: [1.0, 1.25, 1.56, 1.95, 2.44] + useSearchPruning: [No] + compression: + - type: ASH + parameters: + encodedBits: 2568 + optimizer: itq + landmarkCount: 1 + - type: ASH + parameters: + encodedBits: 1288 + optimizer: itq + landmarkCount: 1 + - type: PQ + parameters: + m: 321 # we can either specify the integer m or the integer mFactor. In this case, m will be set to the data dimensionality divided by mFactor + # mFactor: 8 + # k: 256 # optional parameter. By default, k=256 + centerData: No + anisotropicThreshold: -1.0 # optional parameter. By default, anisotropicThreshold=-1 (i.e., no anisotropy) + - type: PQ + parameters: + m: 161 # we can either specify the integer m or the integer mFactor. In this case, m will be set to the data dimensionality divided by mFactor + # mFactor: 8 + # k: 256 # optional parameter. By default, k=256 + centerData: No + anisotropicThreshold: -1.0 # optional parameter. By default, anisotropicThreshold=-1 (i.e., no anisotropy) +# Run-level controls, such as benchmarks, console, and logging, are in run.yml. \ No newline at end of file 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 0482ecd74..5b60b16d7 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 @@ -1583,56 +1583,6 @@ public boolean supportsAshMaskedLoad() { return true; } -// // This version uses masked loads. -// @Override -// public float ashMaskedAddAllWords(float[] tildeQ, -// int d, -// long[] packedBits, -// int packedBase, -// int words) { -// final VectorSpecies SPEC = FloatVector.SPECIES_PREFERRED; -// final int LANES = SPEC.length(); -// -// FloatVector accVec = FloatVector.zero(SPEC); -// float scalarAcc = 0f; -// -// for (int w = 0; w < words; w++) { -// long wordBits = packedBits[packedBase + w]; -// int baseDim = w * 64; -// -// for (int off = 0; off < 64 && (baseDim + off) < d; off += LANES) { -// int qBase = baseDim + off; -// VectorMask inRange = SPEC.indexInRange(qBase, d); -// -// long maskBits; -// if (LANES == 16) maskBits = (wordBits >>> off) & 0xFFFFL; -// else if (LANES == 8) maskBits = (wordBits >>> off) & 0xFFL; -// else if (LANES == 4) maskBits = (wordBits >>> off) & 0xFL; -// else { -// // unusual width fallback -// long m = wordBits >>> off; -// float s = 0f; -// for (int b = 0; b < LANES; b++) { -// if (((m >>> b) & 1L) != 0L) { -// int idx = qBase + b; -// if (idx < d) s += tildeQ[idx]; -// } -// } -// scalarAcc += s; -// continue; -// } -// -// if (maskBits == 0L) continue; -// -// VectorMask bitMask = VectorMask.fromLong(SPEC, maskBits).and(inRange); -// FloatVector masked = FloatVector.fromArray(SPEC, tildeQ, qBase, bitMask); -// accVec = accVec.add(masked); -// } -// } -// -// return accVec.reduceLanes(VectorOperators.ADD) + scalarAcc; -// } - @Override public float ashMaskedAddFlat(float[] tildeQ, int qOffset, @@ -1659,7 +1609,6 @@ public float ashMaskedAddFlat(float[] tildeQ, // Safe limit for Fast Path int safeWords = d / 64; - // --- HOT LOOP --- for (int w = 0; w < safeWords; w++) { long wordBits = allPackedVectors[packedBase + w]; if (wordBits == 0) continue; // Branch prediction handles this well @@ -1691,8 +1640,6 @@ public float ashMaskedAddFlat(float[] tildeQ, } } - // --- TAIL LOOP (Robust Bounds) --- - // Handles the edge case where d is not a multiple of 64 for (int w = safeWords; w < words; w++) { long wordBits = allPackedVectors[packedBase + w]; if (wordBits == 0) continue; @@ -1725,385 +1672,12 @@ public float ashMaskedAddFlat(float[] tildeQ, return acc0.add(acc1).add(acc2).add(acc3).reduceLanes(VectorOperators.ADD); } -// @Override -// public float ashMaskedAddFlat(float[] tildeQ, -// int qOffset, -// long[] allPackedVectors, -// int packedBase, -// int d, -// int words) { -// final VectorSpecies SPEC = FloatVector.SPECIES_PREFERRED; -// -// FloatVector acc0 = FloatVector.zero(SPEC); -// FloatVector acc1 = FloatVector.zero(SPEC); -// FloatVector acc2 = FloatVector.zero(SPEC); -// FloatVector acc3 = FloatVector.zero(SPEC); -// -// int safeWords = d / 64; -// -// // --- HOT LOOP (Unrolled & Safe) --- -// for (int w = 0; w < safeWords; w++) { -// long wordBits = allPackedVectors[packedBase + w]; -// if (wordBits == 0) continue; -// -// int baseDim = w * 64; -// -// // Unroll 0 -// long m0 = wordBits & 0xFFFFL; -// if (m0 != 0) { -// acc0 = acc0.add(FloatVector.fromArray(SPEC, tildeQ, qOffset + baseDim), -// VectorMask.fromLong(SPEC, m0)); -// } -// -// // Unroll 1 -// long m1 = (wordBits >>> 16) & 0xFFFFL; -// if (m1 != 0) { -// acc1 = acc1.add(FloatVector.fromArray(SPEC, tildeQ, qOffset + baseDim + 16), -// VectorMask.fromLong(SPEC, m1)); -// } -// -// // Unroll 2 -// long m2 = (wordBits >>> 32) & 0xFFFFL; -// if (m2 != 0) { -// acc2 = acc2.add(FloatVector.fromArray(SPEC, tildeQ, qOffset + baseDim + 32), -// VectorMask.fromLong(SPEC, m2)); -// } -// -// // Unroll 3 -// long m3 = (wordBits >>> 48) & 0xFFFFL; -// if (m3 != 0) { -// acc3 = acc3.add(FloatVector.fromArray(SPEC, tildeQ, qOffset + baseDim + 48), -// VectorMask.fromLong(SPEC, m3)); -// } -// } -// -// // --- TAIL LOOP (Robust Bounds) --- -// for (int w = safeWords; w < words; w++) { -// long wordBits = allPackedVectors[packedBase + w]; -// if (wordBits == 0) continue; -// -// int baseDim = w * 64; -// -// for (int off = 0; off < 64; off += 16) { -// int localIdx = baseDim + off; -// if (localIdx >= d) break; -// -// long maskBits = (wordBits >>> off) & 0xFFFFL; -// if (maskBits == 0) continue; -// -// VectorMask bitMask = VectorMask.fromLong(SPEC, maskBits); -// int absIdx = qOffset + localIdx; -// -// if (localIdx + 16 <= d) { -// acc0 = acc0.add(FloatVector.fromArray(SPEC, tildeQ, absIdx), bitMask); -// } else { -// VectorMask rangeMask = SPEC.indexInRange(absIdx, qOffset + d); -// acc0 = acc0.add(FloatVector.fromArray(SPEC, tildeQ, absIdx, rangeMask), -// bitMask.and(rangeMask)); -// } -// } -// } -// -// return acc0.add(acc1).add(acc2).add(acc3).reduceLanes(VectorOperators.ADD); -// } - - public float ashMaskedAddFlatOptimizedv1(float[] tildeQ, - int qOffset, - long[] allPackedVectors, - int packedBase, - int d, - int words) { - final VectorSpecies SPEC = FloatVector.SPECIES_PREFERRED; - final int LANES = SPEC.length(); - - FloatVector acc0 = FloatVector.zero(SPEC); - FloatVector acc1 = FloatVector.zero(SPEC); - FloatVector acc2 = FloatVector.zero(SPEC); - FloatVector acc3 = FloatVector.zero(SPEC); - - int safeWords = d / 64; - - // --- OPTIMIZATION 1: Specialized AVX-512 Loop (16 Lanes) --- - // This covers the vast majority of server workloads. - // We unroll the 64-bit word processing into exactly 4 blocks. - if (LANES == 16) { - for (int w = 0; w < safeWords; w++) { - long wordBits = allPackedVectors[packedBase + w]; - if (wordBits == 0) continue; - - int baseDim = w * 64; - - // Block 0: Bits 0-15 - // Note: casting to (int) acts as a fast mask for the low bits - long m0 = wordBits & 0xFFFF; - if (m0 != 0) { - VectorMask mask = VectorMask.fromLong(SPEC, m0); - FloatVector loaded = FloatVector.fromArray(SPEC, tildeQ, qOffset + baseDim); - // OPTIMIZATION 2: Blend (Zero-out) + Unmasked Add - // "blend" with 0.0f where mask is unset (not()) - acc0 = acc0.add(loaded.blend(0.0f, mask.not())); - } - - // Block 1: Bits 16-31 - long m1 = (wordBits >>> 16) & 0xFFFF; - if (m1 != 0) { - VectorMask mask = VectorMask.fromLong(SPEC, m1); - FloatVector loaded = FloatVector.fromArray(SPEC, tildeQ, qOffset + baseDim + 16); - acc1 = acc1.add(loaded.blend(0.0f, mask.not())); - } - - // Block 2: Bits 32-47 - long m2 = (wordBits >>> 32) & 0xFFFF; - if (m2 != 0) { - VectorMask mask = VectorMask.fromLong(SPEC, m2); - FloatVector loaded = FloatVector.fromArray(SPEC, tildeQ, qOffset + baseDim + 32); - acc2 = acc2.add(loaded.blend(0.0f, mask.not())); - } - - // Block 3: Bits 48-63 - long m3 = (wordBits >>> 48); // No mask needed for high bits shift - if (m3 != 0) { - VectorMask mask = VectorMask.fromLong(SPEC, m3); - FloatVector loaded = FloatVector.fromArray(SPEC, tildeQ, qOffset + baseDim + 48); - acc3 = acc3.add(loaded.blend(0.0f, mask.not())); - } - } - } - // --- OPTIMIZATION 3: Generic / AVX2 Loop --- - else { - long laneMask = (1L << LANES) - 1; - for (int w = 0; w < safeWords; w++) { - long wordBits = allPackedVectors[packedBase + w]; - if (wordBits == 0) continue; - - int baseDim = w * 64; - int accumulatorIdx = 0; - - // Manual step loop to avoid modulo in switch - for (int off = 0; off < 64; off += LANES) { - long m = (wordBits >>> off) & laneMask; - if (m != 0) { - VectorMask mask = VectorMask.fromLong(SPEC, m); - FloatVector loaded = FloatVector.fromArray(SPEC, tildeQ, qOffset + baseDim + off); - - // Simple Round-Robin without switch/modulo - if (accumulatorIdx == 0) acc0 = acc0.add(loaded.blend(0.0f, mask.not())); - else if (accumulatorIdx == 1) acc1 = acc1.add(loaded.blend(0.0f, mask.not())); - else if (accumulatorIdx == 2) acc2 = acc2.add(loaded.blend(0.0f, mask.not())); - else { - acc3 = acc3.add(loaded.blend(0.0f, mask.not())); - accumulatorIdx = -1; // Reset for next increment - } - } - accumulatorIdx++; - } - } - } - - // --- TAIL LOOP (Keep your original robust logic here) --- - // Your original tail logic handles bounds checking correctly. - // Since this is the "cold" path, optimization matters less. - for (int w = safeWords; w < words; w++) { - // ... (Keep your original Tail Loop implementation) ... - } - - return acc0.add(acc1).add(acc2).add(acc3).reduceLanes(VectorOperators.ADD); - } - - public float ashMaskedAddFlatOptimizedv2(float[] tildeQ, - int qOffset, - long[] allPackedVectors, - int packedBase, - int d, - int words) { - final VectorSpecies SPEC = FloatVector.SPECIES_PREFERRED; - final int LANES = SPEC.length(); - - // Expansion to 8 accumulators to saturate 32 ZMM registers and hide memory latency - FloatVector acc0 = FloatVector.zero(SPEC); - FloatVector acc1 = FloatVector.zero(SPEC); - FloatVector acc2 = FloatVector.zero(SPEC); - FloatVector acc3 = FloatVector.zero(SPEC); - FloatVector acc4 = FloatVector.zero(SPEC); - FloatVector acc5 = FloatVector.zero(SPEC); - FloatVector acc6 = FloatVector.zero(SPEC); - FloatVector acc7 = FloatVector.zero(SPEC); - - int safeWords = d / 64; - - if (LANES == 16) { - // Process 2 words at a time (128 floats) to fill the 8-way unroll - int w = 0; - for (; w <= safeWords - 2; w += 2) { - int baseIdx = qOffset + (w << 6); - - // Word 1 (acc0-acc3) - long w0 = allPackedVectors[packedBase + w]; - if (w0 != 0) { - long m0 = w0 & 0xFFFF; - if (m0 != 0) acc0 = acc0.add(FloatVector.fromArray(SPEC, tildeQ, baseIdx, VectorMask.fromLong(SPEC, m0))); - long m1 = (w0 >>> 16) & 0xFFFF; - if (m1 != 0) acc1 = acc1.add(FloatVector.fromArray(SPEC, tildeQ, baseIdx + 16, VectorMask.fromLong(SPEC, m1))); - long m2 = (w0 >>> 32) & 0xFFFF; - if (m2 != 0) acc2 = acc2.add(FloatVector.fromArray(SPEC, tildeQ, baseIdx + 32, VectorMask.fromLong(SPEC, m2))); - long m3 = (w0 >>> 48); - if (m3 != 0) acc3 = acc3.add(FloatVector.fromArray(SPEC, tildeQ, baseIdx + 48, VectorMask.fromLong(SPEC, m3))); - } - - // Word 2 (acc4-acc7) - long w1 = allPackedVectors[packedBase + w + 1]; - int nextBaseIdx = baseIdx + 64; - if (w1 != 0) { - long m4 = w1 & 0xFFFF; - if (m4 != 0) acc4 = acc4.add(FloatVector.fromArray(SPEC, tildeQ, nextBaseIdx, VectorMask.fromLong(SPEC, m4))); - long m5 = (w1 >>> 16) & 0xFFFF; - if (m5 != 0) acc5 = acc5.add(FloatVector.fromArray(SPEC, tildeQ, nextBaseIdx + 16, VectorMask.fromLong(SPEC, m5))); - long m6 = (w1 >>> 32) & 0xFFFF; - if (m6 != 0) acc6 = acc6.add(FloatVector.fromArray(SPEC, tildeQ, nextBaseIdx + 32, VectorMask.fromLong(SPEC, m6))); - long m7 = (w1 >>> 48); - if (m7 != 0) acc7 = acc7.add(FloatVector.fromArray(SPEC, tildeQ, nextBaseIdx + 48, VectorMask.fromLong(SPEC, m7))); - } - } - - // Clean up remaining safe word if odd count - for (; w < safeWords; w++) { - int baseIdx = qOffset + (w << 6); - long wordBits = allPackedVectors[packedBase + w]; - if (wordBits != 0) { - long m0 = wordBits & 0xFFFF; - if (m0 != 0) acc0 = acc0.add(FloatVector.fromArray(SPEC, tildeQ, baseIdx, VectorMask.fromLong(SPEC, m0))); - long m1 = (wordBits >>> 16) & 0xFFFF; - if (m1 != 0) acc1 = acc1.add(FloatVector.fromArray(SPEC, tildeQ, baseIdx + 16, VectorMask.fromLong(SPEC, m1))); - long m2 = (wordBits >>> 32) & 0xFFFF; - if (m2 != 0) acc2 = acc2.add(FloatVector.fromArray(SPEC, tildeQ, baseIdx + 32, VectorMask.fromLong(SPEC, m2))); - long m3 = (wordBits >>> 48); - if (m3 != 0) acc3 = acc3.add(FloatVector.fromArray(SPEC, tildeQ, baseIdx + 48, VectorMask.fromLong(SPEC, m3))); - } - } - } else { - // Fallback for AVX2/Generic environments - long laneMask = (1L << LANES) - 1; - for (int w = 0; w < safeWords; w++) { - long wordBits = allPackedVectors[packedBase + w]; - if (wordBits == 0) continue; - int baseDim = w * 64; - for (int off = 0; off < 64; off += LANES) { - long m = (wordBits >>> off) & laneMask; - if (m != 0) { - // Use masked add directly to avoid blend/not overhead - acc0 = acc0.add(FloatVector.fromArray(SPEC, tildeQ, qOffset + baseDim + off), VectorMask.fromLong(SPEC, m)); - } - } - } - } - - // Tail loop for non-word-aligned dimensions - for (int w = safeWords; w < words; w++) { - long wordBits = allPackedVectors[packedBase + w]; - if (wordBits == 0) continue; - int baseDim = w * 64; - int remaining = d - baseDim; - for (int i = 0; i < remaining; i++) { - if ((wordBits & (1L << i)) != 0) { - // Scalar fallback for the absolute tail - // (Could be vectorized with a length-limited mask if performance here is critical) - acc0 = acc0.add(FloatVector.broadcast(SPEC, tildeQ[qOffset + baseDim + i]).blend(0, SPEC.indexInRange(0, 1).not())); - } - } - } - - // Horizontal reduction of the 8 accumulators - FloatVector res0 = acc0.add(acc1).add(acc2).add(acc3); - FloatVector res1 = acc4.add(acc5).add(acc6).add(acc7); - return res0.add(res1).reduceLanes(VectorOperators.ADD); - } - - public float ashMaskedAddFlatOptimizedv3(float[] tildeQ, int qOffset, long[] allPackedVectors, int packedBase, int d, int words) { - final VectorSpecies SPEC = FloatVector.SPECIES_PREFERRED; - - // 6 Accumulators: Optimized to fit in registers without spilling to cache - FloatVector acc0 = FloatVector.zero(SPEC), acc1 = FloatVector.zero(SPEC), acc2 = FloatVector.zero(SPEC); - FloatVector acc3 = FloatVector.zero(SPEC), acc4 = FloatVector.zero(SPEC), acc5 = FloatVector.zero(SPEC); - - int safeWords = d / 64; - for (int w = 0; w < safeWords; w++) { - int baseIdx = qOffset + (w << 6); - long word = allPackedVectors[packedBase + w]; - - // Direct Masked Add: Collapses 'blend' + 'add' into a single AVX-512 instruction - acc0 = acc0.add(FloatVector.fromArray(SPEC, tildeQ, baseIdx), VectorMask.fromLong(SPEC, word & 0xFFFF)); - acc1 = acc1.add(FloatVector.fromArray(SPEC, tildeQ, baseIdx + 16), VectorMask.fromLong(SPEC, (word >>> 16) & 0xFFFF)); - acc2 = acc2.add(FloatVector.fromArray(SPEC, tildeQ, baseIdx + 32), VectorMask.fromLong(SPEC, (word >>> 32) & 0xFFFF)); - acc3 = acc3.add(FloatVector.fromArray(SPEC, tildeQ, baseIdx + 48), VectorMask.fromLong(SPEC, (word >>> 48))); - } - - // Horizontal reduction - return acc0.add(acc1).add(acc2).add(acc3).add(acc4).add(acc5).reduceLanes(VectorOperators.ADD); - } - - public float ashMaskedAddFlatOptimizedv4(float[] tildeQ, int qOffset, long[] allPackedVectors, int packedBase, int d, int words) { - final VectorSpecies SPEC = FloatVector.SPECIES_PREFERRED; - - // STRICT ADHERENCE TO V3 LEARNINGS: 6 Accumulators to prevent register spilling - FloatVector acc0 = FloatVector.zero(SPEC), acc1 = FloatVector.zero(SPEC), acc2 = FloatVector.zero(SPEC); - FloatVector acc3 = FloatVector.zero(SPEC), acc4 = FloatVector.zero(SPEC), acc5 = FloatVector.zero(SPEC); - - int safeWords = d / 64; - int w = 0; - - // --- NEW OPTIMIZATION: 3-Word Unroll --- - // This processes 192 dimensions per loop while staying within the 6-accumulator limit. - for (; w <= safeWords - 3; w += 3) { - int baseIdx = qOffset + (w << 6); - - // Word 0 -> acc0, acc1, acc2, acc3 (Uses 4 sub-blocks) - long wd0 = allPackedVectors[packedBase + w]; - acc0 = acc0.add(FloatVector.fromArray(SPEC, tildeQ, baseIdx), VectorMask.fromLong(SPEC, wd0 & 0xFFFF)); - acc1 = acc1.add(FloatVector.fromArray(SPEC, tildeQ, baseIdx + 16), VectorMask.fromLong(SPEC, (wd0 >>> 16) & 0xFFFF)); - acc2 = acc2.add(FloatVector.fromArray(SPEC, tildeQ, baseIdx + 32), VectorMask.fromLong(SPEC, (wd0 >>> 32) & 0xFFFF)); - acc3 = acc3.add(FloatVector.fromArray(SPEC, tildeQ, baseIdx + 48), VectorMask.fromLong(SPEC, (wd0 >>> 48))); - - // Word 1 -> acc4, acc5, acc0, acc1 (Interleaves to reuse registers) - long wd1 = allPackedVectors[packedBase + w + 1]; - int base1 = baseIdx + 64; - acc4 = acc4.add(FloatVector.fromArray(SPEC, tildeQ, base1), VectorMask.fromLong(SPEC, wd1 & 0xFFFF)); - acc5 = acc5.add(FloatVector.fromArray(SPEC, tildeQ, base1 + 16), VectorMask.fromLong(SPEC, (wd1 >>> 16) & 0xFFFF)); - acc0 = acc0.add(FloatVector.fromArray(SPEC, tildeQ, base1 + 32), VectorMask.fromLong(SPEC, (wd1 >>> 32) & 0xFFFF)); - acc1 = acc1.add(FloatVector.fromArray(SPEC, tildeQ, base1 + 48), VectorMask.fromLong(SPEC, (wd1 >>> 48))); - - // Word 2 -> acc2, acc3, acc4, acc5 - long wd2 = allPackedVectors[packedBase + w + 2]; - int base2 = baseIdx + 128; - acc2 = acc2.add(FloatVector.fromArray(SPEC, tildeQ, base2), VectorMask.fromLong(SPEC, wd2 & 0xFFFF)); - acc3 = acc3.add(FloatVector.fromArray(SPEC, tildeQ, base2 + 16), VectorMask.fromLong(SPEC, (wd2 >>> 16) & 0xFFFF)); - acc4 = acc4.add(FloatVector.fromArray(SPEC, tildeQ, base2 + 32), VectorMask.fromLong(SPEC, (wd2 >>> 32) & 0xFFFF)); - acc5 = acc5.add(FloatVector.fromArray(SPEC, tildeQ, base2 + 48), VectorMask.fromLong(SPEC, (wd2 >>> 48))); - } - - // Standard v3 cleanup for remaining words - for (; w < safeWords; w++) { - int baseIdx = qOffset + (w << 6); - long word = allPackedVectors[packedBase + w]; - acc0 = acc0.add(FloatVector.fromArray(SPEC, tildeQ, baseIdx), VectorMask.fromLong(SPEC, word & 0xFFFF)); - acc1 = acc1.add(FloatVector.fromArray(SPEC, tildeQ, baseIdx + 16), VectorMask.fromLong(SPEC, (word >>> 16) & 0xFFFF)); - acc2 = acc2.add(FloatVector.fromArray(SPEC, tildeQ, baseIdx + 32), VectorMask.fromLong(SPEC, (word >>> 32) & 0xFFFF)); - acc3 = acc3.add(FloatVector.fromArray(SPEC, tildeQ, baseIdx + 48), VectorMask.fromLong(SPEC, (word >>> 48))); - } - - // Balanced Reduction Tree (Better than linear add) - - FloatVector sum01 = acc0.add(acc1); - FloatVector sum23 = acc2.add(acc3); - FloatVector sum45 = acc4.add(acc5); - return sum01.add(sum23).add(sum45).reduceLanes(VectorOperators.ADD); - } - public float ashMaskedAdd_512(float[] tildeQ, int qOffset, long[] allPackedVectors, int packedBase, int d, int words) { final VectorSpecies SPEC = FloatVector.SPECIES_PREFERRED; // Assumed 512-bit (16 floats) int safeWords = d / 64; // 1. Determine Unroll Strategy based on d - // We aim for 8 accumulators for large d, but 384 remains at 6 as requested. + // We aim for 8 accumulators for large d, but 384 remains at 6. int numAcc = (d >= 512) ? 8 : (d == 384 ? 6 : (d >= 128 ? 4 : 2)); // Initialize required accumulators @@ -2117,7 +1691,7 @@ public float ashMaskedAdd_512(float[] tildeQ, int qOffset, long[] allPackedVecto int w = 0; // 2. Main Unrolled Loop - // For d=384, this uses your 3-word unroll. + // For d=384, this uses a 3-word unroll. // For d >= 512, we use a 2-word unroll across 8 registers for better throughput. int unrollFactor = (d == 384) ? 3 : (d >= 512 ? 2 : 1); From e2d9579786f6b2508d9cf376f255b2739c5867dd Mon Sep 17 00:00:00 2001 From: Ted Willke Date: Tue, 14 Apr 2026 19:07:57 +0000 Subject: [PATCH 037/243] Refactored YAML benchmark configs post-rebase. Added an ASH compressionRatio. --- .../quantization/AsymmetricHashing.java | 2 +- .../jbellis/jvector/example/DistancesASH.java | 105 +-- .../jbellis/jvector/example/DistancesFP.java | 754 ------------------ .../jbellis/jvector/example/DistancesPQ.java | 95 +-- .../jvector/example/yaml/Compression.java | 56 +- .../yaml-configs/ada002-100k-TEMP.yml | 38 - .../yaml-configs/dataset-metadata.yml | 50 +- jvector-examples/yaml-configs/datasets.yml | 34 +- jvector-examples/yaml-configs/dpr-10M.yml | 47 -- jvector-examples/yaml-configs/dpr-1M.yml | 72 -- jvector-examples/yaml-configs/gecko-100k.yml | 72 -- .../index-parameters/autoDefault.yml | 2 +- .../{ => index-parameters}/cap-1M.yml | 0 .../{ => index-parameters}/cap-6M.yml | 0 .../{ => index-parameters}/cohere-10M.yml | 0 .../cohere-english-v3-1M.yml | 0 .../index-parameters/colbert-1M.yml | 2 +- .../yaml-configs/index-parameters/default.yml | 38 +- .../{ => index-parameters}/dpr-gemma-1M.yml | 2 +- .../{ => index-parameters}/esmc-600-1M.yml | 0 .../{ => index-parameters}/esmc-600-6M.yml | 0 .../index-parameters/gecko-100k.yml | 75 ++ .../index-parameters/glove-100-angular.yml | 2 +- .../index-parameters/glove-200-angular.yml | 2 +- .../index-parameters/glove-25-angular.yml | 2 +- .../index-parameters/glove-50-angular.yml | 2 +- .../index-parameters/lastfm-64-dot.yml | 2 +- .../{ => index-parameters}/mpnet-1M.yml | 0 .../index-parameters/nytimes-256-angular.yml | 2 +- .../openai-v3-small-100k.yml | 0 .../{ => index-parameters}/personahub-1M.yml | 0 .../index-parameters/sift-128-euclidean.yml | 2 +- .../{ => index-parameters}/tahoe-x1-1M.yml | 0 33 files changed, 252 insertions(+), 1206 deletions(-) delete mode 100644 jvector-examples/src/main/java/io/github/jbellis/jvector/example/DistancesFP.java delete mode 100644 jvector-examples/yaml-configs/ada002-100k-TEMP.yml delete mode 100644 jvector-examples/yaml-configs/dpr-10M.yml delete mode 100644 jvector-examples/yaml-configs/dpr-1M.yml delete mode 100644 jvector-examples/yaml-configs/gecko-100k.yml rename jvector-examples/yaml-configs/{ => index-parameters}/cap-1M.yml (100%) rename jvector-examples/yaml-configs/{ => index-parameters}/cap-6M.yml (100%) rename jvector-examples/yaml-configs/{ => index-parameters}/cohere-10M.yml (100%) rename jvector-examples/yaml-configs/{ => index-parameters}/cohere-english-v3-1M.yml (100%) rename jvector-examples/yaml-configs/{ => index-parameters}/dpr-gemma-1M.yml (99%) rename jvector-examples/yaml-configs/{ => index-parameters}/esmc-600-1M.yml (100%) rename jvector-examples/yaml-configs/{ => index-parameters}/esmc-600-6M.yml (100%) create mode 100644 jvector-examples/yaml-configs/index-parameters/gecko-100k.yml rename jvector-examples/yaml-configs/{ => index-parameters}/mpnet-1M.yml (100%) rename jvector-examples/yaml-configs/{ => index-parameters}/openai-v3-small-100k.yml (100%) rename jvector-examples/yaml-configs/{ => index-parameters}/personahub-1M.yml (100%) rename jvector-examples/yaml-configs/{ => index-parameters}/tahoe-x1-1M.yml (100%) diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/AsymmetricHashing.java b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/AsymmetricHashing.java index 34b765fed..23f7eddf1 100644 --- a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/AsymmetricHashing.java +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/AsymmetricHashing.java @@ -90,7 +90,7 @@ public class AsymmetricHashing implements VectorCompressor> vectors = SiftLoader.readFvecs(filenameBase, nVectors); - List> queries = SiftLoader.readFvecs(filenameQueries, nQueries); - List groundTruth = SiftLoader.readIvecsAsArrays(filenameGT, nQueries); - - // ------------------------------------------------------------------ - // Remove zero-norm vectors (undefined for ASH / Eq. 11) - // ------------------------------------------------------------------ - - int beforeBase = vectors.size(); - - // Maps original index to filtered index. Value is -1 if vector was removed. - int[] oldToNew = new int[beforeBase]; - java.util.Arrays.fill(oldToNew, -1); - - List> filteredBase = new ArrayList<>(beforeBase); - for (int i = 0; i < beforeBase; i++) { - VectorFloat v = vectors.get(i); - if (VectorUtil.dotProduct(v, v) > 0f) { - oldToNew[i] = filteredBase.size(); - filteredBase.add(v); - } - } - vectors = filteredBase; - - int beforeQuery = queries.size(); - List> filteredQueries = new ArrayList<>(queries.size()); - for (VectorFloat q : queries) { - if (VectorUtil.dotProduct(q, q) > 0f) { - filteredQueries.add(q); - } - } - queries = filteredQueries; - - System.out.println("\tRemoved " + (beforeBase - vectors.size()) + " zero base vectors and " - + (beforeQuery - queries.size()) + " zero query vectors"); - - // ------------------------------------------------------------ - // Update counts after filtering - // ------------------------------------------------------------ - final int finalVectorCount = vectors.size(); - final int finalQueryCount = queries.size(); + List> vectors = SiftLoader.readFvecs(filenameBase); + List> queries = SiftLoader.readFvecs(filenameQueries); + List> groundTruth = SiftLoader.readIvecs(filenameGT); // ASH normalization policy: // @@ -174,7 +124,6 @@ public static void testASHEncodings(String filenameBase, String filenameQueries, // - Queries may be L2-normalized in the benchmark (standard practice), // but are NOT normalized inside the encoder. // - No other normalization steps are applied. - for (VectorFloat q : queries) VectorUtil.l2normalize(q); int dimension = vectors.get(0).length(); int encodedBits = 328; // (dimension / 4) + HEADER_BITS; @@ -225,7 +174,7 @@ public static void testASHEncodings(String filenameBase, String filenameQueries, double encSeconds = (endTime - startTime) / 1e9; - double encThroughput = finalVectorCount / encSeconds; + double encThroughput = vectors.size() / encSeconds; System.out.println( "\tEncoding throughput = " @@ -321,7 +270,7 @@ public static void testASHEncodings(String filenameBase, String filenameQueries, ForkJoinPool simdExecutor = new ForkJoinPool(180); // For profiling int parallelism = simdExecutor.getParallelism(); - int chunkSize = Math.max(1, (finalQueryCount + parallelism - 1) / parallelism); + int chunkSize = Math.max(1, (queries.size() + parallelism - 1) / parallelism); // ================================================================== // [1] Accuracy run (NOT timed) @@ -329,9 +278,9 @@ public static void testASHEncodings(String filenameBase, String filenameQueries, if(RUN_ACCURACY_CHECK) { List> errorTasks = new ArrayList<>(); - for (int start = 0; start < finalQueryCount; start += chunkSize) { + for (int start = 0; start < vectors.size(); start += chunkSize) { final int s = start; - final int e = Math.min(start + chunkSize, finalQueryCount); + final int e = Math.min(start + chunkSize, queries.size()); errorTasks.add(simdExecutor.submit(() -> { double localError = 0.0; @@ -342,7 +291,7 @@ public static void testASHEncodings(String filenameBase, String filenameQueries, ScoreFunction.ApproximateScoreFunction f = ashVecsFinal.scoreFunctionFor(q, VectorSimilarityFunction.DOT_PRODUCT); - for (int j = 0; j < finalVectorCount; j++) { + for (int j = 0; j < vectors.size(); j++) { final int baseOrd = (newToOldFinal == null) ? j : newToOldFinal[j]; VectorFloat v = finalVectors.get(baseOrd); @@ -380,9 +329,9 @@ public static void testASHEncodings(String filenameBase, String filenameQueries, List> recallTasks = new ArrayList<>(); logProgress("\t[stage] Computing " + RECALL_K + "-Recall@K..."); - for (int start = 0; start < finalQueryCount; start += chunkSize) { + for (int start = 0; start < queries.size(); start += chunkSize) { final int s = start; - final int e = Math.min(start + chunkSize, finalQueryCount); + final int e = Math.min(start + chunkSize, queries.size()); recallTasks.add(simdExecutor.submit(() -> { double[] localTotalRecall = new double[atValues.length]; @@ -392,7 +341,7 @@ public static void testASHEncodings(String filenameBase, String filenameQueries, // Min-heap to keep top 50 results (storing score as int bits and ordinal as long) var topCandidates = new java.util.PriorityQueue((a, b) -> Float.compare(Float.intBitsToFloat((int) a[0]), Float.intBitsToFloat((int) b[0]))); - for (int j = 0; j < finalVectorCount; j++) { + for (int j = 0; j < vectors.size(); j++) { float score = f.similarityTo(j); if (topCandidates.size() < maxAt) { topCandidates.add(new long[]{Float.floatToRawIntBits(score), j}); @@ -405,7 +354,9 @@ public static void testASHEncodings(String filenameBase, String filenameQueries, int[] topIndices = new int[topCandidates.size()]; for (int rank = topCandidates.size() - 1; rank >= 0; rank--) topIndices[rank] = (int) topCandidates.poll()[1]; - int[] queryGT = groundTruth.get(i); + int[] queryGT = groundTruth.get(i).stream() + .mapToInt(Integer::intValue) + .toArray(); for (int aIdx = 0; aIdx < atValues.length; aIdx++) { int at = atValues[aIdx]; java.util.Set topAtSet = new java.util.HashSet<>(); @@ -435,7 +386,7 @@ public static void testASHEncodings(String filenameBase, String filenameQueries, } for (int aIdx = 0; aIdx < atValues.length; aIdx++) { - System.out.format("\tASH %d-recall@%d = %.4f%n", RECALL_K, atValues[aIdx], totalRecall[aIdx] / finalQueryCount); + System.out.format("\tASH %d-recall@%d = %.4f%n", RECALL_K, atValues[aIdx], totalRecall[aIdx] / queries.size()); } } @@ -447,9 +398,9 @@ public static void testASHEncodings(String filenameBase, String filenameQueries, long ashStart = System.nanoTime(); - for (int start = 0; start < finalQueryCount; start += chunkSize) { + for (int start = 0; start < queries.size(); start += chunkSize) { final int s = start; - final int e = Math.min(start + chunkSize, finalQueryCount); + final int e = Math.min(start + chunkSize, queries.size()); ashTasks.add(simdExecutor.submit(() -> { double localSum = 0.0; @@ -459,7 +410,7 @@ public static void testASHEncodings(String filenameBase, String filenameQueries, ScoreFunction.ApproximateScoreFunction f = ashVecsFinal.scoreFunctionFor(q, VectorSimilarityFunction.DOT_PRODUCT); - for (int j = 0; j < finalVectorCount; j++) { + for (int j = 0; j < vectors.size(); j++) { localSum += f.similarityTo(j); } } @@ -475,7 +426,7 @@ public static void testASHEncodings(String filenameBase, String filenameQueries, long ashEnd = System.nanoTime(); double singleSeconds = (ashEnd - ashStart) / 1e9; - long totalDotProducts = (long) finalQueryCount * (long) finalVectorCount; + long totalDotProducts = (long) queries.size() * (long) vectors.size(); double singleThroughput = totalDotProducts / singleSeconds; System.out.println("\tSingle " + singleMode + " scoring took " @@ -503,16 +454,16 @@ public static void testASHEncodings(String filenameBase, String filenameQueries, long floatStart = System.nanoTime(); - for (int start = 0; start < finalQueryCount; start += chunkSize) { + for (int start = 0; start < queries.size(); start += chunkSize) { final int s = start; - final int e = Math.min(start + chunkSize, finalQueryCount); + final int e = Math.min(start + chunkSize, queries.size()); floatTasks.add(simdExecutor.submit(() -> { double localSum = 0.0; for (int i = s; i < e; i++) { VectorFloat q = finalQueries.get(i); - for (int j = 0; j < finalVectorCount; j++) { + for (int j = 0; j < vectors.size(); j++) { localSum += VectorUtil.dotProduct(q, finalVectors.get(j)); } } @@ -560,9 +511,9 @@ public static void testASHEncodings(String filenameBase, String filenameQueries, List> blockTasks = new ArrayList<>(); long blockStart = System.nanoTime(); - for (int start = 0; start < finalQueryCount; start += chunkSize) { + for (int start = 0; start < queries.size(); start += chunkSize) { final int s = start; - final int e = Math.min(start + chunkSize, finalQueryCount); + final int e = Math.min(start + chunkSize, queries.size()); blockTasks.add(simdExecutor.submit(() -> { double localSum = 0.0; @@ -577,7 +528,7 @@ public static void testASHEncodings(String filenameBase, String filenameQueries, ); int j = 0; - while (j + blockSize <= finalVectorCount) { + while (j + blockSize <= vectors.size()) { scorer.scoreRange(j, blockSize, scores); for (int k = 0; k < blockSize; k++) { localSum += scores[k]; @@ -586,14 +537,14 @@ public static void testASHEncodings(String filenameBase, String filenameQueries, } // Tail uses scalar per-vector scorer (correctness-preserving) - if (j < finalVectorCount) { + if (j < vectors.size()) { ScoreFunction.ApproximateScoreFunction f = ashVecsFinal.scoreFunctionFor( finalQueries.get(qi), VectorSimilarityFunction.DOT_PRODUCT ); - for (; j < finalVectorCount; j++) { + for (; j < vectors.size(); j++) { localSum += f.similarityTo(j); } } @@ -616,7 +567,7 @@ public static void testASHEncodings(String filenameBase, String filenameQueries, ); double blockSeconds = (blockEnd - blockStart) / 1e9; - long totalDotProducts = (long) finalQueryCount * (long) finalVectorCount; + long totalDotProducts = (long) queries.size() * (long) vectors.size(); double scoreThroughput = totalDotProducts / blockSeconds; diff --git a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/DistancesFP.java b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/DistancesFP.java deleted file mode 100644 index f763673e2..000000000 --- a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/DistancesFP.java +++ /dev/null @@ -1,754 +0,0 @@ -/* - * 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; - -import io.github.jbellis.jvector.example.util.SiftLoader; -import io.github.jbellis.jvector.graph.ListRandomAccessVectorValues; -import io.github.jbellis.jvector.graph.similarity.ScoreFunction; -import io.github.jbellis.jvector.quantization.ASHBlockScorer; -import io.github.jbellis.jvector.quantization.ASHVectors; -import io.github.jbellis.jvector.quantization.AsymmetricHashing; -import io.github.jbellis.jvector.quantization.CompressedVectors; -import io.github.jbellis.jvector.vector.VectorSimilarityFunction; -import io.github.jbellis.jvector.vector.VectorUtil; -import io.github.jbellis.jvector.vector.types.VectorFloat; - -import java.io.IOException; -import java.util.ArrayList; -import java.util.List; -import java.util.concurrent.ForkJoinPool; -import java.util.concurrent.ForkJoinTask; - -public class DistancesFP { - - static final String blockMode = System.getProperty("jvector.ash.blockKernel", "auto").toLowerCase(); - static final String singleMode = System.getProperty("jvector.ash.singleKernel", "auto").toLowerCase(); - - private static void printScorerInfo(String label, Object scorer) { - boolean maskedLoad = - io.github.jbellis.jvector.vector.VectorizationProvider.getInstance() - .getVectorUtilSupport() - .supportsAshMaskedLoad(); - - System.out.println( - "\t[" + label + "] scorer implementation = " - + scorer.getClass().getName() - + " (singleKernel=" + singleMode - + ", blockKernel=" + blockMode - + ", supportsAshMaskedLoad=" + maskedLoad - + ")" - ); - } - - private static void logProgress(String msg) { - System.out.println(msg); - System.out.flush(); - } - - private static int parseOptimizerFromProperty() { - String opt = System.getProperty("jvector.ash.optimizer", "random").trim().toLowerCase(); - - if ("random".equals(opt)) return AsymmetricHashing.RANDOM; - if ("itq".equals(opt)) return AsymmetricHashing.ITQ; - if ("landing".equals(opt)) return AsymmetricHashing.LANDING; - - throw new IllegalArgumentException( - "Unknown jvector.ash.optimizer=" + opt + " (expected random|itq|landing)" - ); - } - - private static String optimizerName(int optimizer) { - if (optimizer == AsymmetricHashing.RANDOM) return "RANDOM"; - if (optimizer == AsymmetricHashing.ITQ) return "ITQ"; - if (optimizer == AsymmetricHashing.LANDING) return "LANDING"; - return "UNKNOWN(" + optimizer + ")"; - } - - public static void testASHEncodings(String filenameBase, String filenameQueries, String filenameGT) throws IOException { - // ------------------------------------------------------------ - // Benchmark configuration (runtime flags) - // ------------------------------------------------------------ - final boolean RUN_SANITY_CHECK = - Boolean.parseBoolean(System.getProperty("jvector.bench.sanity-check", "false")); - - final boolean RUN_RECALL_CHECK = - Boolean.parseBoolean(System.getProperty("jvector.bench.recall", "false")); - - final int RECALL_K = - Integer.getInteger("jvector.bench.recall.k", 10); - - final boolean RUN_ACCURACY_CHECK = - Boolean.parseBoolean(System.getProperty("jvector.bench.accuracy", "false")); - - final boolean RUN_SCALAR_SCORING = - Boolean.parseBoolean(System.getProperty("jvector.bench.scalar-scoring", "true")); - - final boolean RUN_FLOAT_SCORING = - Boolean.parseBoolean(System.getProperty("jvector.bench.float-scoring", "true")); - - // ASH header bits - final int HEADER_BITS = 72; - - // Block sizes to benchmark - final int[] BLOCK_SIZES = {32}; // 16, 32, and/or 64 - - // How many ASH landmarks to use, C = [1, 64] - final int landmarkCount = 1; - - // Define the benchmark size - int maxQueries = 10_000; - int maxVectors = 10_000_000; - - int queryCountInFile = SiftLoader.countFvecs(filenameQueries); - int vectorCountInFile = SiftLoader.countFvecs(filenameBase); - - // Will benchmark what was requested or the maximum available vectors (if less) - int nQueries = Math.min(maxQueries, queryCountInFile); - int nVectors = Math.min(maxVectors, vectorCountInFile); - - List> vectors = SiftLoader.readFvecs(filenameBase, nVectors); - List> queries = SiftLoader.readFvecs(filenameQueries, nQueries); - List groundTruth = SiftLoader.readIvecsAsArrays(filenameGT, nQueries); - - // ------------------------------------------------------------------ - // Remove zero-norm vectors (undefined for ASH / Eq. 11) - // ------------------------------------------------------------------ - - int beforeBase = vectors.size(); - - // Maps original index to filtered index. Value is -1 if vector was removed. - int[] oldToNew = new int[beforeBase]; - java.util.Arrays.fill(oldToNew, -1); - - List> filteredBase = new ArrayList<>(beforeBase); - for (int i = 0; i < beforeBase; i++) { - VectorFloat v = vectors.get(i); - if (VectorUtil.dotProduct(v, v) > 0f) { - oldToNew[i] = filteredBase.size(); - filteredBase.add(v); - } - } - vectors = filteredBase; - - int beforeQuery = queries.size(); - List> filteredQueries = new ArrayList<>(queries.size()); - for (VectorFloat q : queries) { - if (VectorUtil.dotProduct(q, q) > 0f) { - filteredQueries.add(q); - } - } - queries = filteredQueries; - - System.out.println("\tRemoved " + (beforeBase - vectors.size()) + " zero base vectors and " - + (beforeQuery - queries.size()) + " zero query vectors"); - - // ------------------------------------------------------------ - // Update counts after filtering - // ------------------------------------------------------------ - final int finalVectorCount = vectors.size(); - final int finalQueryCount = queries.size(); - - // ASH normalization policy: - // - // - Base vectors x are NOT globally normalized. - // - For encoding, we compute μ on raw x. - // - The residual (x − μ) is normalized ONCE inside the binarizer, - // producing \hat{x} = (x − μ) / ||x − μ|| (ASH Paper, Eq. 6). - // - Queries may be L2-normalized in the benchmark (standard practice), - // but are NOT normalized inside the encoder. - // - No other normalization steps are applied. - for (VectorFloat q : queries) VectorUtil.l2normalize(q); - - int dimension = vectors.get(0).length(); - int encodedBits = 776; // (dimension / 4) + HEADER_BITS; - // Payload must be 64-bit aligned for SIMD - int payloadBits = encodedBits - HEADER_BITS; -// if ((payloadBits & 63) != 0) { -// throw new IllegalArgumentException( -// "ASH payloadBits must be 64-bit aligned for SIMD. " + -// "Got payloadBits=" + payloadBits + -// " (encodedBits=" + encodedBits + -// ", HEADER_BITS=" + HEADER_BITS + ")" -// ); -// } - - System.out.println( - "\toriginalDim = " + dimension + - ", encodedBits = " + encodedBits - ); - - final List> finalQueries = queries; - final List> finalVectors = vectors; - - System.out.format("\t%d base and %d query vectors loaded, dimension=%d%n", - vectors.size(), queries.size(), dimension); - - // ------------------------------------------------------------------ - // Build ASH (centroids + optional training), then encode - // ------------------------------------------------------------------ - var ravv = new ListRandomAccessVectorValues(vectors, dimension); - - // Choose optimizer at runtime: - // -Djvector.ash.optimizer=random|itq|landing - final int optimizer = parseOptimizerFromProperty(); - - logProgress("\tASH optimizer = " + optimizerName(optimizer)); - - logProgress("\t[stage] ASH initialize: starting (centroids + training)"); - long initStart = System.nanoTime(); - var ash = AsymmetricHashing.initialize(ravv, optimizer, encodedBits, landmarkCount); - long initEnd = System.nanoTime(); - logProgress("\t[stage] ASH initialize: done in " + (initEnd - initStart) / 1e9 + " seconds"); - - logProgress("\t[stage] ASH encodeAll: starting"); - long startTime = System.nanoTime(); - CompressedVectors ashVecs = ash.encodeAll(ravv); - long endTime = System.nanoTime(); - logProgress("\t[stage] ASH encodeAll: done in " + (endTime - startTime) / 1e9 + " seconds"); - - - double encSeconds = (endTime - startTime) / 1e9; - double encThroughput = finalVectorCount / encSeconds; - - System.out.println( - "\tEncoding throughput = " - + String.format(java.util.Locale.ROOT, "%.3f", encThroughput) - + " vectors/sec" - ); - - ASHVectors ashVectors = (ASHVectors) ashVecs; - VectorFloat q0 = finalQueries.get(0); - - // ------------------------------------------------------------------ - // Landmark reorder (debug + timing) - // ------------------------------------------------------------------ - final boolean REORDER_BY_LANDMARK = - Boolean.parseBoolean(System.getProperty("jvector.ash.reorderByLandmark", "true")); - - final ASHVectors ashVectorsFinal; - final CompressedVectors ashVecsFinal; - final int[] newToOldFinal; - - if (REORDER_BY_LANDMARK) { - long stat0 = System.nanoTime(); - var beforeStats = ashVectors.landmarkRunStats(); - long stat1 = System.nanoTime(); - - System.out.println("\tLandmark run stats BEFORE reorder: " + beforeStats - + " (computed in " + (stat1 - stat0) / 1e9 + " s)"); - - long t0 = System.nanoTime(); - ASHVectors.LandmarkOrder order = ashVectors.reorderByLandmarkFast(); - long t1 = System.nanoTime(); - - ASHVectors reordered = order.vectors; - - long stat2 = System.nanoTime(); - var afterStats = reordered.landmarkRunStats(); - long stat3 = System.nanoTime(); - - System.out.println("\tLandmark reorder took " + (t1 - t0) / 1e9 + " s"); - System.out.println("\tLandmark run stats AFTER reorder: " + afterStats - + " (computed in " + (stat3 - stat2) / 1e9 + " s)"); - System.out.println("--"); - - ashVectorsFinal = reordered; - ashVecsFinal = reordered; // CompressedVectors view used by scoreFunctionFor - newToOldFinal = order.newToOld; // map reordered ordinal -> original base ordinal - } else { - System.out.println("\tLandmark reorder disabled (-Djvector.ash.reorderByLandmark=false)"); - ashVectorsFinal = ashVectors; - ashVecsFinal = ashVectors; - newToOldFinal = null; - } - - { - ScoreFunction.ApproximateScoreFunction single = - ashVecsFinal.scoreFunctionFor(q0, VectorSimilarityFunction.DOT_PRODUCT); - printScorerInfo("single", single); - } - - if(RUN_SANITY_CHECK) { - - ASHBlockScorer ref = ashVectors.blockScorerFor(q0, VectorSimilarityFunction.DOT_PRODUCT); - printScorerInfo("scalar-ref", ref); - - final int sanityBlockSize = BLOCK_SIZES[0]; - ASHBlockScorer blk = ashVectors.blockScorerFor(q0, VectorSimilarityFunction.DOT_PRODUCT, sanityBlockSize); - printScorerInfo("block", blk); - - - float[] a = new float[128]; - float[] b = new float[128]; - ref.scoreRange(0, 128, a); - blk.scoreRange(0, 128, b); - - final float EPS = 1e-6f; - - for (int i = 0; i < 128; i++) { - float diff = Math.abs(a[i] - b[i]); - if (diff > EPS) { - throw new AssertionError( - "Mismatch at " + i + - ": ref=" + a[i] + - ", blk=" + b[i] + - ", diff=" + diff); - } - } - } - - // ------------------------------------------------------------------ - // Shared parallelization setup (executor-honoring) - // ------------------------------------------------------------------ -// ForkJoinPool simdExecutor = PhysicalCoreExecutor.pool(); // For production - ForkJoinPool simdExecutor = new ForkJoinPool(180); // For profiling - - int parallelism = simdExecutor.getParallelism(); - int chunkSize = Math.max(1, (finalQueryCount + parallelism - 1) / parallelism); - - // ================================================================== - // [1] Accuracy run (NOT timed) - // ================================================================== - if(RUN_ACCURACY_CHECK) { - List> errorTasks = new ArrayList<>(); - - for (int start = 0; start < finalQueryCount; start += chunkSize) { - final int s = start; - final int e = Math.min(start + chunkSize, finalQueryCount); - - errorTasks.add(simdExecutor.submit(() -> { - double localError = 0.0; - long localCount = 0; - - for (int i = s; i < e; i++) { - VectorFloat q = finalQueries.get(i); - ScoreFunction.ApproximateScoreFunction f = - ashVecsFinal.scoreFunctionFor(q, VectorSimilarityFunction.DOT_PRODUCT); - - for (int j = 0; j < finalVectorCount; j++) { - final int baseOrd = (newToOldFinal == null) ? j : newToOldFinal[j]; - VectorFloat v = finalVectors.get(baseOrd); - - float trueDot = VectorUtil.dotProduct(q, v); - float approxDot = f.similarityTo(j); - - localError += Math.abs(approxDot - trueDot); - localCount++; - } - - } - return new double[]{localError, localCount}; - })); - } - - double distanceError = 0.0; - long count = 0; - - for (ForkJoinTask t : errorTasks) { - double[] r = t.join(); - distanceError += r[0]; - count += (long) r[1]; - } - - distanceError /= count; - System.out.println("\tAverage absolute dot-product error = " + distanceError); - } - - // ================================================================== - // [1b] Recall@K run (Parallelized) - // ================================================================== - if (RUN_RECALL_CHECK) { - int[] atValues = {10, 15, 20, 30, 40, 50}; - int maxAt = 50; - List> recallTasks = new ArrayList<>(); - - logProgress("\t[stage] Computing " + RECALL_K + "-Recall@K..."); - for (int start = 0; start < finalQueryCount; start += chunkSize) { - final int s = start; - final int e = Math.min(start + chunkSize, finalQueryCount); - - recallTasks.add(simdExecutor.submit(() -> { - double[] localTotalRecall = new double[atValues.length]; - for (int i = s; i < e; i++) { - VectorFloat q = finalQueries.get(i); - ScoreFunction.ApproximateScoreFunction f = ashVecsFinal.scoreFunctionFor(q, VectorSimilarityFunction.DOT_PRODUCT); - - // Min-heap to keep top 50 results (storing score as int bits and ordinal as long) - var topCandidates = new java.util.PriorityQueue((a, b) -> Float.compare(Float.intBitsToFloat((int) a[0]), Float.intBitsToFloat((int) b[0]))); - for (int j = 0; j < finalVectorCount; j++) { - float score = f.similarityTo(j); - if (topCandidates.size() < maxAt) { - topCandidates.add(new long[]{Float.floatToRawIntBits(score), j}); - } else if (score > Float.intBitsToFloat((int) topCandidates.peek()[0])) { - topCandidates.poll(); - topCandidates.add(new long[]{Float.floatToRawIntBits(score), j}); - } - } - - int[] topIndices = new int[topCandidates.size()]; - for (int rank = topCandidates.size() - 1; rank >= 0; rank--) topIndices[rank] = (int) topCandidates.poll()[1]; - - int[] queryGT = groundTruth.get(i); - for (int aIdx = 0; aIdx < atValues.length; aIdx++) { - int at = atValues[aIdx]; - java.util.Set topAtSet = new java.util.HashSet<>(); - for (int r = 0; r < Math.min(at, topIndices.length); r++) { - topAtSet.add((newToOldFinal == null) ? topIndices[r] : newToOldFinal[topIndices[r]]); - } - int matches = 0; - java.util.HashSet gtSeen = new java.util.HashSet<>(RECALL_K * 2); - // Compute and don't count duplicates (if present in GT or retrieved IDs) - for (int g = 0; g < RECALL_K; g++) { - int gtId = queryGT[g]; - if (gtSeen.add(gtId) && topAtSet.contains(gtId)) { - matches++; - } - } - localTotalRecall[aIdx] += (double) matches / RECALL_K; - } - } - return localTotalRecall; - })); - } - - double[] totalRecall = new double[atValues.length]; - for (ForkJoinTask t : recallTasks) { - double[] local = t.join(); - for (int aIdx = 0; aIdx < atValues.length; aIdx++) totalRecall[aIdx] += local[aIdx]; - } - - for (int aIdx = 0; aIdx < atValues.length; aIdx++) { - System.out.format("\tASH %d-recall@%d = %.4f%n", RECALL_K, atValues[aIdx], totalRecall[aIdx] / finalQueryCount); - } - } - - // ================================================================== - // [2] Scalar ASH scoring timing (reference) - // ================================================================== - if (RUN_SCALAR_SCORING) { - List> ashTasks = new ArrayList<>(); - - long ashStart = System.nanoTime(); - - for (int start = 0; start < finalQueryCount; start += chunkSize) { - final int s = start; - final int e = Math.min(start + chunkSize, finalQueryCount); - - ashTasks.add(simdExecutor.submit(() -> { - double localSum = 0.0; - - for (int i = s; i < e; i++) { - VectorFloat q = finalQueries.get(i); - ScoreFunction.ApproximateScoreFunction f = - ashVecsFinal.scoreFunctionFor(q, VectorSimilarityFunction.DOT_PRODUCT); - - for (int j = 0; j < finalVectorCount; j++) { - localSum += f.similarityTo(j); - } - } - return localSum; - })); - } - - double ashDummy = 0.0; - for (ForkJoinTask t : ashTasks) { - ashDummy += t.join(); - } - - long ashEnd = System.nanoTime(); - - double singleSeconds = (ashEnd - ashStart) / 1e9; - long totalDotProducts = (long) finalQueryCount * (long) finalVectorCount; - double singleThroughput = totalDotProducts / singleSeconds; - - System.out.println("\tSingle " + singleMode + " scoring took " - + singleSeconds + " seconds"); - - System.out.println( - "\tSingle " + singleMode + " throughput = " - + String.format(java.util.Locale.ROOT, "%.3f", singleThroughput) - + " dot-products/sec" - + " (" - + String.format(java.util.Locale.ROOT, "%.3f", singleThroughput / 1e6) - + " Mdot/s)" - ); - - System.out.println("\tdummyAccumulator = " + (float) (ashDummy)); - System.out.println("--"); - - } - - // ================================================================== - // [3] Float dot-product scoring throughput - // ================================================================== - if (RUN_FLOAT_SCORING) { - List> floatTasks = new ArrayList<>(); - - long floatStart = System.nanoTime(); - - for (int start = 0; start < finalQueryCount; start += chunkSize) { - final int s = start; - final int e = Math.min(start + chunkSize, finalQueryCount); - - floatTasks.add(simdExecutor.submit(() -> { - double localSum = 0.0; - - for (int i = s; i < e; i++) { - VectorFloat q = finalQueries.get(i); - for (int j = 0; j < finalVectorCount; j++) { - localSum += VectorUtil.dotProduct(q, finalVectors.get(j)); - } - } - return localSum; - })); - } - - double floatDummy = 0.0; - for (ForkJoinTask t : floatTasks) { - floatDummy += t.join(); - } - - long floatEnd = System.nanoTime(); - - var scoreThroughput = (finalVectorCount * finalQueryCount) / ((floatEnd - floatStart) / 1e9); - - System.out.println( - "\tFloat dot-product = " - + String.format(java.util.Locale.ROOT, "%.3f", scoreThroughput) - + " dot-products/sec" - + " (" - + String.format(java.util.Locale.ROOT, "%.3f", scoreThroughput / 1e6) - + " Mdot/s)" - ); - - // Prevent dead-code elimination - System.out.println("\tdummyAccumulator = " + (float) (floatDummy)); - System.out.println("--"); - } - - // ================================================================== - // [4] Block scoring timing (single mode per JVM run) - // NOTE: Choose the block kernel using: - // -Djvector.ash.blockKernel=scalar|simd|auto - // ================================================================== - final String kernelMode = - System.getProperty("jvector.ash.blockKernel", "auto").toLowerCase(); - - for (int blockSize : BLOCK_SIZES) { - - // Print scorer implementation once per blockSize (diagnostic) - { - ASHBlockScorer scorer = - ashVectorsFinal.blockScorerFor( - finalQueries.get(0), - VectorSimilarityFunction.DOT_PRODUCT, - blockSize - ); - - printScorerInfo(kernelMode + " (blockSize=" + blockSize + ")", scorer); - } - - List> blockTasks = new ArrayList<>(); - long blockStart = System.nanoTime(); - - for (int start = 0; start < finalQueryCount; start += chunkSize) { - final int s = start; - final int e = Math.min(start + chunkSize, finalQueryCount); - - blockTasks.add(simdExecutor.submit(() -> { - double localSum = 0.0; - final float[] scores = new float[blockSize]; - - for (int qi = s; qi < e; qi++) { - ASHBlockScorer scorer = - ashVectorsFinal.blockScorerFor( - finalQueries.get(qi), - VectorSimilarityFunction.DOT_PRODUCT, - blockSize - ); - - int j = 0; - while (j + blockSize <= finalVectorCount) { - scorer.scoreRange(j, blockSize, scores); - for (int k = 0; k < blockSize; k++) { - localSum += scores[k]; - } - j += blockSize; - } - - // Tail uses scalar per-vector scorer (correctness-preserving) - if (j < finalVectorCount) { - ScoreFunction.ApproximateScoreFunction f = - ashVecsFinal.scoreFunctionFor( - finalQueries.get(qi), - VectorSimilarityFunction.DOT_PRODUCT - ); - - for (; j < finalVectorCount; j++) { - localSum += f.similarityTo(j); - } - } - } - - return localSum; - })); - } - - double blockDummy = 0.0; - for (ForkJoinTask t : blockTasks) { - blockDummy += t.join(); - } - - long blockEnd = System.nanoTime(); - - System.out.println( - "\tBlock " + kernelMode + " (blockSize=" + blockSize + ") took " - + (blockEnd - blockStart) / 1e9 + " seconds" - ); - - double blockSeconds = (blockEnd - blockStart) / 1e9; - long totalDotProducts = (long) finalQueryCount * (long) finalVectorCount; - - double scoreThroughput = totalDotProducts / blockSeconds; - - System.out.println( - "\tBlock " + kernelMode + " throughput (blockSize=" + blockSize + ") = " - + String.format(java.util.Locale.ROOT, "%.3f", scoreThroughput) - + " dot-products/sec" - + " (" - + String.format(java.util.Locale.ROOT, "%.3f", scoreThroughput / 1e6) - + " Mdot/s)" - ); - - System.out.println("\tdummyAccumulator = " + (float) (blockDummy)); - System.out.println("--"); - } - } - - public static void runSIFT() throws IOException { - System.out.println("Running siftsmall"); - - var baseVectors = "siftsmall/siftsmall_base.fvecs"; - var queryVectors = "siftsmall/siftsmall_query.fvecs"; - var gtVectors = ""; - testASHEncodings(baseVectors, queryVectors, gtVectors); - } - - public static void runGIST() throws IOException { - System.out.println("Running GIST"); - - var baseVectors = "./fvec/gist/gist_base.fvecs"; - var queryVectors = "./fvec/gist/gist_query.fvecs"; - var gtVectors = ""; - testASHEncodings(baseVectors, queryVectors, gtVectors); - } - - public static void runCohere100k() throws IOException { - System.out.println("Running Cohere-100k"); - - var baseVectors = "./fvec/cohere-100k/cohere_embed-english-v3.0_1024_base_vectors_100000.fvec"; - var queryVectors = "./fvec/cohere-100k/cohere_embed-english-v3.0_1024_query_vectors_10000.fvec"; - var gtVectors = "./fvec/cohere-100k/cohere_embed-english-v3.0_1024_indices_b100000_q10000_k100.ivec"; - testASHEncodings(baseVectors, queryVectors, gtVectors); - } - - public static void runADA() throws IOException { - System.out.println("Running ada-002"); - - var baseVectors = "./fvec/ada-002/ada_002_100000_base_vectors.fvec"; - var queryVectors = "./fvec/ada-002/ada_002_100000_query_vectors_10000.fvec"; - var gtVectors = "./fvec/ada-002/ada_002_100000_indices_query_10000.ivec"; - testASHEncodings(baseVectors, queryVectors, gtVectors); - } - - public static void runADANoZeros() throws IOException { - System.out.println("Running ada-002-no-zeros"); - - var baseVectors = "./fvec/ada-002-no-zeros/ada_002_100000_base_vectors_no_zeros.fvec"; - var queryVectors = "./fvec/ada-002-no-zeros/ada_002_100000_query_vectors_10000_no_zeros.fvec"; - var gtVectors = "./fvec/ada-002-no-zeros/ada-002_gt_no_zeros.ivec"; - testASHEncodings(baseVectors, queryVectors, gtVectors); - } - - public static void runColbert() throws IOException { - System.out.println("Running colbertv2"); - - var baseVectors = "./fvec/wikipedia_squad/1M/colbertv2.0_128_base_vectors_1000000.fvec"; - var queryVectors = "./fvec/wikipedia_squad/1M/colbertv2.0_128_query_vectors_100000.fvec"; - var gtVectors = ""; - testASHEncodings(baseVectors, queryVectors, gtVectors); - } - - public static void runOpenai1536() throws IOException { - System.out.println("Running text-embedding-3-large_1536"); - - var baseVectors = "./fvec/openai-v3-large-1536-100k/text-embedding-3-large_1536_100000_base_vectors.fvec"; - var queryVectors = "./fvec/openai-v3-large-1536-100k/text-embedding-3-large_1536_100000_query_vectors_10000.fvec"; - var gtVectors = "./fvec/openai-v3-large-1536-100k/text-embedding-3-large_1536_100000_indices_query_10000.ivec"; - testASHEncodings(baseVectors, queryVectors, gtVectors); - } - - public static void runOpenai3072() throws IOException { - System.out.println("Running text-embedding-3-large_3072"); - - var baseVectors = "./fvec/openai-v3-large-3072-100k/text-embedding-3-large_3072_100000_base_vectors.fvec"; - var queryVectors = "./fvec/openai-v3-large-3072-100k/text-embedding-3-large_3072_100000_query_vectors_10000.fvec"; - var gtVectors = "./fvec/openai-v3-large-3072-100k/text-embedding-3-large_3072_100000_indices_query_10000.ivec"; - testASHEncodings(baseVectors, queryVectors, gtVectors); - } - - public static void runDpr10m() throws IOException { - System.out.println("Running dpr-10m"); - - var baseVectors = "./fvec/dpr-10m/c4-en_base_10M_norm_files0_2.fvecs"; - var queryVectors = "./fvec/dpr-10m/c4-en_query_10k_norm_files0_1.fvecs"; - var gtVectors = "./fvec/dpr-10m/c4-en_gt_10M_norm_ip_k100.ivecs"; - testASHEncodings(baseVectors, queryVectors, gtVectors); - } - - public static void runCap6m() throws IOException { - System.out.println("Running cap-6m"); - - var baseVectors = "./fvec/cap-6m/Caselaw_gte-Qwen2-1.5B_embeddings_base_6m_norm_shuffle.fvecs"; - var queryVectors = "./fvec/cap-6m/Caselaw_gte-Qwen2-1.5B_embeddings_query_10k_norm_shuffle.fvecs"; - var gtVectors = "./fvec/cap-6m/cap_6m_gt_norm_shuffle_ip_k100.ivecs"; - testASHEncodings(baseVectors, queryVectors, gtVectors); - } - - public static void runCohere10m() throws IOException { - System.out.println("Running cohere-10m"); - - var baseVectors = "./fvec/cohere-10m/cohere_wiki_en_flat_base_10m_norm.fvecs"; - var queryVectors = "./fvec/cohere-10m/cohere_wiki_en_flat_query_10k_norm.fvecs"; - var gtVectors = "./fvec/cohere-10m/cohere_wiki_en_flat_gt_10m_ip_k100.ivecs"; - testASHEncodings(baseVectors, queryVectors, gtVectors); - } - - public static void main(String[] args) throws IOException { -// runSIFT(); -// runGIST(); -// runColbert(); -// runCohere100k(); -// runADA(); -// runADANoZeros(); -// runOpenai1536(); -// runOpenai3072(); -// runDpr10m(); -// runCap6m(); - runCohere10m(); - } -} diff --git a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/DistancesPQ.java b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/DistancesPQ.java index 2448a158f..1fc3d4c08 100644 --- a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/DistancesPQ.java +++ b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/DistancesPQ.java @@ -56,62 +56,8 @@ public static void testPQEncodings(String filenameBase, String filenameQueries) final boolean RUN_FLOAT_SCORING = Boolean.parseBoolean(System.getProperty("jvector.bench.float-scoring", "false")); - // Define the benchmark size - int maxQueries = 10_000; - int maxVectors = 10_000_000; - - int queryCountInFile = SiftLoader.countFvecs(filenameQueries); - int vectorCountInFile = SiftLoader.countFvecs(filenameBase); - - // Will benchmark what was requested or the maximum available vectors (if less) - int nQueries = Math.min(maxQueries, queryCountInFile); - int nVectors = Math.min(maxVectors, vectorCountInFile); - - List> vectors = SiftLoader.readFvecs(filenameBase, nVectors); - List> queries = SiftLoader.readFvecs(filenameQueries, nQueries); - - // ------------------------------------------------------------ - // Remove zero-norm vectors (match DistancesASH) - // ------------------------------------------------------------ - int beforeBase = vectors.size(); - List> filteredBase = new ArrayList<>(vectors.size()); - for (VectorFloat v : vectors) { - if (VectorUtil.dotProduct(v, v) > 0f) { - filteredBase.add(v); - } - } - vectors = filteredBase; - - int beforeQuery = queries.size(); - List> filteredQueries = new ArrayList<>(queries.size()); - for (VectorFloat q : queries) { - if (VectorUtil.dotProduct(q, q) > 0f) { - filteredQueries.add(q); - } - } - queries = filteredQueries; - - System.out.println("\tRemoved " + (beforeBase - vectors.size()) + " zero base vectors and " - + (beforeQuery - queries.size()) + " zero query vectors"); - - // ------------------------------------------------------------ - // Update counts after filtering - // ------------------------------------------------------------ - final int finalVectorCount = vectors.size(); - final int finalQueryCount = queries.size(); - - // Match ASH: normalize queries only - for (VectorFloat q : queries) { - VectorUtil.l2normalize(q); - } - - int dimension = vectors.get(0).length(); - - final List> finalVectors = vectors; - final List> finalQueries = queries; - - System.out.format("\t%d base and %d query vectors loaded, dimension=%d%n", - vectors.size(), queries.size(), dimension); + List> vectors = SiftLoader.readFvecs(filenameBase); + List> queries = SiftLoader.readFvecs(filenameQueries); // ------------------------------------------------------------ // Parallel executor (identical to DistancesASH) @@ -119,7 +65,7 @@ public static void testPQEncodings(String filenameBase, String filenameQueries) // ForkJoinPool simdExecutor = PhysicalCoreExecutor.pool(); // For production ForkJoinPool simdExecutor = new ForkJoinPool(180); // For profiling int parallelism = simdExecutor.getParallelism(); - int chunkSize = Math.max(1, (finalQueryCount + parallelism - 1) / parallelism); + int chunkSize = Math.max(1, (queries.size() + parallelism - 1) / parallelism); // ------------------------------------------------------------ // PQ parameters (EXACT bit-for-bit match with ASH) @@ -129,6 +75,7 @@ public static void testPQEncodings(String filenameBase, String filenameQueries) // // We REQUIRE exact equality for fair benchmarking. int HEADER_BITS = 72; + int dimension = vectors.get(0).length(); int encodedBitsASH = (dimension / 4) + HEADER_BITS; // HEADER_BITS = 72 if ((encodedBitsASH & 7) != 0) { throw new IllegalArgumentException( @@ -152,7 +99,7 @@ public static void testPQEncodings(String filenameBase, String filenameQueries) // Train PQ + encode (timed) // ------------------------------------------------------------ RandomAccessVectorValues ravv = - new ListRandomAccessVectorValues(finalVectors, dimension); + new ListRandomAccessVectorValues(vectors, dimension); long pqTrainStart = System.nanoTime(); ProductQuantization pq = ProductQuantization.compute( @@ -175,7 +122,7 @@ public static void testPQEncodings(String filenameBase, String filenameQueries) System.out.println("\tPQ training took " + (pqTrainEnd - pqTrainStart) / 1e9 + " seconds"); System.out.println("\tPQ encoding took " + (pqEncodeEnd - pqEncodeStart) / 1e9 + " seconds"); double encSeconds = (pqEncodeEnd - pqEncodeStart) / 1e9; - double encThroughput = finalVectorCount / encSeconds; + double encThroughput = vectors.size() / encSeconds; System.out.println( "\tEncoding throughput = " @@ -192,21 +139,21 @@ public static void testPQEncodings(String filenameBase, String filenameQueries) if (RUN_ACCURACY_CHECK) { List> errorTasks = new ArrayList<>(); - for (int start = 0; start < finalQueryCount; start += chunkSize) { + for (int start = 0; start < queries.size(); start += chunkSize) { final int s = start; - final int e = Math.min(start + chunkSize, finalQueryCount); + final int e = Math.min(start + chunkSize, queries.size()); errorTasks.add(simdExecutor.submit(() -> { double localError = 0.0; long localCount = 0; for (int i = s; i < e; i++) { - VectorFloat q = finalQueries.get(i); + VectorFloat q = queries.get(i); ScoreFunction.ApproximateScoreFunction f = pqVecs.precomputedScoreFunctionFor(q, VectorSimilarityFunction.DOT_PRODUCT); - for (int j = 0; j < finalVectorCount; j++) { - float trueDot = VectorUtil.dotProduct(q, finalVectors.get(j)); + for (int j = 0; j < vectors.size(); j++) { + float trueDot = VectorUtil.dotProduct(q, vectors.get(j)); float approxScaled = f.similarityTo(j); float approxDot = 2.0f * approxScaled - 1.0f; @@ -235,19 +182,19 @@ public static void testPQEncodings(String filenameBase, String filenameQueries) List> pqTasks = new ArrayList<>(); long pqStart = System.nanoTime(); - for (int start = 0; start < finalQueryCount; start += chunkSize) { + for (int start = 0; start < queries.size(); start += chunkSize) { final int s = start; - final int e = Math.min(start + chunkSize, finalQueryCount); + final int e = Math.min(start + chunkSize, queries.size()); pqTasks.add(simdExecutor.submit(() -> { double localSum = 0.0; for (int i = s; i < e; i++) { ScoreFunction.ApproximateScoreFunction f = pqVecs.precomputedScoreFunctionFor( - finalQueries.get(i), + queries.get(i), VectorSimilarityFunction.DOT_PRODUCT ); - for (int j = 0; j < finalVectorCount; j++) { + for (int j = 0; j < vectors.size(); j++) { localSum += f.similarityTo(j); } } @@ -267,7 +214,7 @@ public static void testPQEncodings(String filenameBase, String filenameQueries) System.out.println("\tPQDecoder scan took " + (pqEnd - pqStart) / 1e9 + " seconds"); double blockSeconds = (pqEnd - pqStart) / 1e9; - long totalDotProducts = (long) finalQueryCount * (long) finalVectorCount; + long totalDotProducts = (long) queries.size() * (long) vectors.size(); double scoreThroughput = totalDotProducts / blockSeconds; @@ -287,16 +234,16 @@ public static void testPQEncodings(String filenameBase, String filenameQueries) List> floatTasks = new ArrayList<>(); long floatStart = System.nanoTime(); - for (int start = 0; start < finalQueryCount; start += chunkSize) { + for (int start = 0; start < queries.size(); start += chunkSize) { final int s = start; - final int e = Math.min(start + chunkSize, finalQueryCount); + final int e = Math.min(start + chunkSize, queries.size()); floatTasks.add(simdExecutor.submit(() -> { double localSum = 0.0; for (int i = s; i < e; i++) { - VectorFloat q = finalQueries.get(i); - for (int j = 0; j < finalVectorCount; j++) { - localSum += VectorUtil.dotProduct(q, finalVectors.get(j)); + VectorFloat q = queries.get(i); + for (int j = 0; j < vectors.size(); j++) { + localSum += VectorUtil.dotProduct(q, vectors.get(j)); } } return localSum; diff --git a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/yaml/Compression.java b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/yaml/Compression.java index 79deef96f..4bac586ce 100644 --- a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/yaml/Compression.java +++ b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/yaml/Compression.java @@ -18,6 +18,7 @@ import io.github.jbellis.jvector.example.util.CompressorParameters; import io.github.jbellis.jvector.example.benchmarks.datasets.DataSet; +import io.github.jbellis.jvector.quantization.AsymmetricHashing; import io.github.jbellis.jvector.vector.VectorSimilarityFunction; import java.util.Map; @@ -63,10 +64,9 @@ public Function getCompressorParameters() { case "ASH": return ds -> { if (parameters == null) { - throw new IllegalArgumentException("ASH requires parameters: encodedBits, optimizer, landmarkCount"); + throw new IllegalArgumentException("ASH requires parameters"); } - int encodedBits = Integer.parseInt(parameters.get("encodedBits")); int landmarkCount = Integer.parseInt(parameters.getOrDefault("landmarkCount", "1")); // optimizer can be numeric or name (RANDOM/ITQ/LANDING) @@ -78,18 +78,64 @@ public Function getCompressorParameters() { int optimizer; switch (optStr.trim().toUpperCase()) { case "RANDOM": - optimizer = io.github.jbellis.jvector.quantization.AsymmetricHashing.RANDOM; + optimizer = AsymmetricHashing.RANDOM; break; case "ITQ": - optimizer = io.github.jbellis.jvector.quantization.AsymmetricHashing.ITQ; + optimizer = AsymmetricHashing.ITQ; break; case "LANDING": - optimizer = io.github.jbellis.jvector.quantization.AsymmetricHashing.LANDING; + optimizer = AsymmetricHashing.LANDING; break; default: optimizer = Integer.parseInt(optStr); } + boolean hasEncodedBits = parameters.containsKey("encodedBits"); + boolean hasCompressionRatio = parameters.containsKey("compressionRatio"); + if (hasEncodedBits == hasCompressionRatio) { + throw new IllegalArgumentException( + "ASH requires exactly one of 'encodedBits' or 'compressionRatio'"); + } + + int encodedBits; + if (hasEncodedBits) { + encodedBits = Integer.parseInt(parameters.get("encodedBits")); + } else { + int requestedCompressionRatio = Integer.parseInt(parameters.get("compressionRatio")); + if (requestedCompressionRatio != 32 + && requestedCompressionRatio != 64 + && requestedCompressionRatio != 128 + && requestedCompressionRatio != 256) { + throw new IllegalArgumentException( + "ASH compressionRatio must be one of 32, 64, 128, or 256"); + } + + int originalBits = ds.getDimension() * Float.SIZE; + int targetEncodedBits = Math.max(AsymmetricHashing.HEADER_BITS + 64, + originalBits / requestedCompressionRatio); + + int payloadTargetBits = Math.max(64, targetEncodedBits - AsymmetricHashing.HEADER_BITS); + int lowerPayloadBits = Math.max(64, (payloadTargetBits / 64) * 64); + int upperPayloadBits = Math.max(64, ((payloadTargetBits + 63) / 64) * 64); + + int payloadBits = Math.abs(lowerPayloadBits - payloadTargetBits) + <= Math.abs(upperPayloadBits - payloadTargetBits) + ? lowerPayloadBits + : upperPayloadBits; + + encodedBits = AsymmetricHashing.HEADER_BITS + payloadBits; + + double actualCompressionRatio = (double) originalBits / encodedBits; + System.out.printf( + "ASH requested compressionRatio=%d resolved to encodedBits=%d " + + "(header=%d, payload=%d) actualCompressionRatio=%.2f%n", + requestedCompressionRatio, + encodedBits, + AsymmetricHashing.HEADER_BITS, + payloadBits, + actualCompressionRatio); + } + return new CompressorParameters.ASHParameters(optimizer, encodedBits, landmarkCount); }; default: diff --git a/jvector-examples/yaml-configs/ada002-100k-TEMP.yml b/jvector-examples/yaml-configs/ada002-100k-TEMP.yml deleted file mode 100644 index 1cf7e1fbb..000000000 --- a/jvector-examples/yaml-configs/ada002-100k-TEMP.yml +++ /dev/null @@ -1,38 +0,0 @@ -yamlSchemaVersion: 1 -onDiskIndexVersion: 6 - -dataset: ada002-100k - -construction: - outDegree: [32] - efConstruction: [100] - neighborOverflow: [1.2f] - addHierarchy: [Yes] - refineFinalGraph: [Yes] - fusedGraph: [Yes, No] - compression: - - type: PQ - parameters: - m: 192 # we can either specify the integer m or the integer mFactor. In this case, m will be set to the data dimensionality divided by mFactor - # mFactor: 8 - # k: 256 # optional parameter. By default, k=256 - centerData: No - anisotropicThreshold: -1.0 # optional parameter. By default, anisotropicThreshold=-1 (i.e., no anisotropy) - reranking: - - NVQ - useSavedIndexIfExists: No - -search: - topKOverquery: - 10: [1.0, 2.0, 5.0, 10.0] - 100: [1.0, 2.0] - useSearchPruning: [Yes] - compression: - - type: PQ - parameters: - m: 192 - # k: 256 # optional parameter. By default, k=256 - centerData: No - anisotropicThreshold: -1.0 # optional parameter. By default, anisotropicThreshold=-1 (i.e., no anisotropy) - -# Run-level controls, such as benchmarks, console, and logging, are in run.yml. \ No newline at end of file diff --git a/jvector-examples/yaml-configs/dataset-metadata.yml b/jvector-examples/yaml-configs/dataset-metadata.yml index fe48746d1..4ec2114c3 100644 --- a/jvector-examples/yaml-configs/dataset-metadata.yml +++ b/jvector-examples/yaml-configs/dataset-metadata.yml @@ -16,83 +16,83 @@ # relevant loader code. ada002-100k: - similarity_function: COSINE + similarity_function: DOT_PRODUCT load_behavior: NO_SCRUB ada002-1M: - similarity_function: COSINE + similarity_function: DOT_PRODUCT load_behavior: NO_SCRUB cap-1M: - similarity_function: COSINE + similarity_function: DOT_PRODUCT load_behavior: NO_SCRUB cap-6M: - similarity_function: COSINE + similarity_function: DOT_PRODUCT load_behavior: NO_SCRUB cohere-english-v3-100k: - similarity_function: COSINE + similarity_function: DOT_PRODUCT load_behavior: NO_SCRUB cohere-english-v3-1M: - similarity_function: COSINE + similarity_function: DOT_PRODUCT load_behavior: NO_SCRUB cohere-english-v3-10M: - similarity_function: COSINE + similarity_function: DOT_PRODUCT load_behavior: NO_SCRUB colbert-1M: - similarity_function: COSINE + similarity_function: DOT_PRODUCT load_behavior: NO_SCRUB colbert-10M: - similarity_function: COSINE + similarity_function: DOT_PRODUCT load_behavior: NO_SCRUB degen-200k: - similarity_function: COSINE + similarity_function: DOT_PRODUCT load_behavior: NO_SCRUB dpr-1M: - similarity_function: COSINE + similarity_function: DOT_PRODUCT load_behavior: NO_SCRUB dpr-10M: - similarity_function: COSINE + similarity_function: DOT_PRODUCT load_behavior: NO_SCRUB e5-small-v2-100k: - similarity_function: COSINE + similarity_function: DOT_PRODUCT load_behavior: NO_SCRUB e5-base-v2-100k: - similarity_function: COSINE + similarity_function: DOT_PRODUCT load_behavior: NO_SCRUB e5-large-v2-100k: - similarity_function: COSINE + similarity_function: DOT_PRODUCT load_behavior: NO_SCRUB gecko-100k: - similarity_function: COSINE + similarity_function: DOT_PRODUCT load_behavior: NO_SCRUB nv-qa-v4-100k: - similarity_function: COSINE + similarity_function: DOT_PRODUCT load_behavior: NO_SCRUB openai-v3-small-1536-100k: - similarity_function: COSINE + similarity_function: DOT_PRODUCT load_behavior: NO_SCRUB openai-v3-large-3072-100k: - similarity_function: COSINE + similarity_function: DOT_PRODUCT load_behavior: NO_SCRUB openai-v3-large-1536-100k: - similarity_function: COSINE + similarity_function: DOT_PRODUCT load_behavior: NO_SCRUB # ann-benchmarks glove-25-angular: - similarity_function: COSINE + similarity_function: DOT_PRODUCT load_behavior: NO_SCRUB glove-50-angular: - similarity_function: COSINE + similarity_function: DOT_PRODUCT load_behavior: NO_SCRUB lastfm-64-dot: similarity_function: DOT_PRODUCT load_behavior: NO_SCRUB glove-100-angular: - similarity_function: COSINE + similarity_function: DOT_PRODUCT load_behavior: NO_SCRUB glove-200-angular: - similarity_function: COSINE + similarity_function: DOT_PRODUCT load_behavior: NO_SCRUB nytimes-256-angular: - similarity_function: COSINE + similarity_function: DOT_PRODUCT load_behavior: NO_SCRUB sift-128-euclidean: similarity_function: EUCLIDEAN diff --git a/jvector-examples/yaml-configs/datasets.yml b/jvector-examples/yaml-configs/datasets.yml index 58d23116e..767314d71 100644 --- a/jvector-examples/yaml-configs/datasets.yml +++ b/jvector-examples/yaml-configs/datasets.yml @@ -1,24 +1,24 @@ jvector-100k: - - cohere-english-v3-100k + - openai-v3-large-3072-100k + - openai-v3-large-1536-100k - ada002-100k - openai-v3-small-1536-100k - gecko-100k - - openai-v3-large-3072-100k - - openai-v3-large-1536-100k - - e5-small-v2-100k - - e5-base-v2-100k - - e5-large-v2-100k -jvector-1M: - - ada002-1M - - colbert-1M -ann-benchmarks: - - glove-25-angular - - glove-50-angular - - lastfm-64-dot - - glove-100-angular - - glove-200-angular - - nytimes-256-angular - - sift-128-euclidean + - cohere-english-v3-100k +# - e5-small-v2-100k +# - e5-base-v2-100k +# - e5-large-v2-100k +#jvector-1M: +# - ada002-1M +# - colbert-1M +#ann-benchmarks: +# - glove-25-angular +# - glove-50-angular +# - lastfm-64-dot +# - glove-100-angular +# - glove-200-angular +# - nytimes-256-angular +# - sift-128-euclidean #other-datasets: # - dpr-1M # - dpr-10M diff --git a/jvector-examples/yaml-configs/dpr-10M.yml b/jvector-examples/yaml-configs/dpr-10M.yml deleted file mode 100644 index 1ea9ec1ac..000000000 --- a/jvector-examples/yaml-configs/dpr-10M.yml +++ /dev/null @@ -1,47 +0,0 @@ -yamlSchemaVersion: 1 -onDiskIndexVersion: 6 - -dataset: dpr-10M - -construction: - outDegree: [64] - efConstruction: [200] - neighborOverflow: [1.6f] - addHierarchy: [No] - refineFinalGraph: [Yes] - fusedGraph: [No] - compression: - - type: PQ - parameters: - m: 96 # we can either specify the integer m or the integer mFactor. In this case, m will be set to the data dimensionality divided by mFactor - # mFactor: 8 - # k: 256 # optional parameter. By default, k=256 - centerData: No - anisotropicThreshold: -1.0 # optional parameter. By default, anisotropicThreshold=-1 (i.e., no anisotropy) - - type: ASH - parameters: - encodedBits: 768 - optimizer: itq - landmarkCount: 1 - reranking: - - FP - useSavedIndexIfExists: Yes # If yes, the system will attempt to locate a cached copy of the index instead of constructing - -search: - topKOverquery: - 10: [1.00, 1.25, 1.56, 1.95, 2.44, 3.05, 3.81, 4.77, 5.96, 7.45, 9.31, 11.64] -# 100: [1.0] - useSearchPruning: [No] - compression: - - type: PQ - parameters: - m: 96 - # k: 256 # optional parameter. By default, k=256 - centerData: No - anisotropicThreshold: -1.0 # optional parameter. By default, anisotropicThreshold=-1 (i.e., no anisotropy) - - type: ASH - parameters: - encodedBits: 768 - optimizer: itq - landmarkCount: 1 -# Run-level controls, such as benchmarks, console, and logging, are in run.yml. \ No newline at end of file diff --git a/jvector-examples/yaml-configs/dpr-1M.yml b/jvector-examples/yaml-configs/dpr-1M.yml deleted file mode 100644 index 0201d3e5b..000000000 --- a/jvector-examples/yaml-configs/dpr-1M.yml +++ /dev/null @@ -1,72 +0,0 @@ -yamlSchemaVersion: 1 -onDiskIndexVersion: 6 - -dataset: dpr-10M - -construction: - outDegree: [64] - efConstruction: [200] - neighborOverflow: [1.6f] - addHierarchy: [Yes] - refineFinalGraph: [Yes] - fusedGraph: [No] - compression: - - type: ASH - parameters: - encodedBits: 776 - optimizer: itq - landmarkCount: 1 - - type: ASH - parameters: - encodedBits: 392 - optimizer: itq - landmarkCount: 1 - - type: PQ - parameters: - m: 97 # we can either specify the integer m or the integer mFactor. In this case, m will be set to the data dimensionality divided by mFactor - # mFactor: 8 - # k: 256 # optional parameter. By default, k=256 - centerData: No - anisotropicThreshold: -1.0 # optional parameter. By default, anisotropicThreshold=-1 (i.e., no anisotropy) - - type: PQ - parameters: - m: 49 # we can either specify the integer m or the integer mFactor. In this case, m will be set to the data dimensionality divided by mFactor - # mFactor: 8 - # k: 256 # optional parameter. By default, k=256 - centerData: No - anisotropicThreshold: -1.0 # optional parameter. By default, anisotropicThreshold=-1 (i.e., no anisotropy) - reranking: - - FP - useSavedIndexIfExists: Yes # If yes, the system will attempt to locate a cached copy of the index instead of constructing - -search: - topKOverquery: - 10: [1.00, 1.25, 1.56, 1.95, 2.44, 3.05, 3.81, 4.77, 5.96, 7.45, 9.31, 11.64] -# 100: [1.0] - useSearchPruning: [No] - compression: - - type: ASH - parameters: - encodedBits: 776 - optimizer: itq - landmarkCount: 1 - - type: ASH - parameters: - encodedBits: 392 - optimizer: itq - landmarkCount: 1 - - type: PQ - parameters: - m: 97 # we can either specify the integer m or the integer mFactor. In this case, m will be set to the data dimensionality divided by mFactor - # mFactor: 8 - # k: 256 # optional parameter. By default, k=256 - centerData: No - anisotropicThreshold: -1.0 # optional parameter. By default, anisotropicThreshold=-1 (i.e., no anisotropy) - - type: PQ - parameters: - m: 49 # we can either specify the integer m or the integer mFactor. In this case, m will be set to the data dimensionality divided by mFactor - # mFactor: 8 - # k: 256 # optional parameter. By default, k=256 - centerData: No - anisotropicThreshold: -1.0 # optional parameter. By default, anisotropicThreshold=-1 (i.e., no anisotropy) -# Run-level controls, such as benchmarks, console, and logging, are in run.yml. \ No newline at end of file diff --git a/jvector-examples/yaml-configs/gecko-100k.yml b/jvector-examples/yaml-configs/gecko-100k.yml deleted file mode 100644 index fbe4aee54..000000000 --- a/jvector-examples/yaml-configs/gecko-100k.yml +++ /dev/null @@ -1,72 +0,0 @@ -yamlSchemaVersion: 1 -onDiskIndexVersion: 6 - -dataset: dpr-10M - -construction: - outDegree: [64] - efConstruction: [200] - neighborOverflow: [1.6f] - addHierarchy: [No] - refineFinalGraph: [Yes] - fusedGraph: [No] - compression: - - type: PQ - parameters: - m: 97 # we can either specify the integer m or the integer mFactor. In this case, m will be set to the data dimensionality divided by mFactor - # mFactor: 8 - # k: 256 # optional parameter. By default, k=256 - centerData: No - anisotropicThreshold: -1.0 # optional parameter. By default, anisotropicThreshold=-1 (i.e., no anisotropy) - - type: PQ - parameters: - m: 49 # we can either specify the integer m or the integer mFactor. In this case, m will be set to the data dimensionality divided by mFactor - # mFactor: 8 - # k: 256 # optional parameter. By default, k=256 - centerData: No - anisotropicThreshold: -1.0 # optional parameter. By default, anisotropicThreshold=-1 (i.e., no anisotropy) - - type: ASH - parameters: - encodedBits: 776 - optimizer: itq - landmarkCount: 1 - - type: ASH - parameters: - encodedBits: 392 - optimizer: itq - landmarkCount: 1 - reranking: - - FP - useSavedIndexIfExists: Yes # If yes, the system will attempt to locate a cached copy of the index instead of constructing - -search: - topKOverquery: - 10: [1.00, 1.25, 1.56, 1.95, 2.44, 3.05, 3.81, 4.77, 5.96, 7.45, 9.31, 11.64] -# 100: [1.0] - useSearchPruning: [No] - compression: - - type: PQ - parameters: - m: 97 # we can either specify the integer m or the integer mFactor. In this case, m will be set to the data dimensionality divided by mFactor - # mFactor: 8 - # k: 256 # optional parameter. By default, k=256 - centerData: No - anisotropicThreshold: -1.0 # optional parameter. By default, anisotropicThreshold=-1 (i.e., no anisotropy) - - type: PQ - parameters: - m: 49 # we can either specify the integer m or the integer mFactor. In this case, m will be set to the data dimensionality divided by mFactor - # mFactor: 8 - # k: 256 # optional parameter. By default, k=256 - centerData: No - anisotropicThreshold: -1.0 # optional parameter. By default, anisotropicThreshold=-1 (i.e., no anisotropy) - - type: ASH - parameters: - encodedBits: 776 - optimizer: itq - landmarkCount: 1 - - type: ASH - parameters: - encodedBits: 392 - optimizer: itq - landmarkCount: 1 -# Run-level controls, such as benchmarks, console, and logging, are in run.yml. \ No newline at end of file diff --git a/jvector-examples/yaml-configs/index-parameters/autoDefault.yml b/jvector-examples/yaml-configs/index-parameters/autoDefault.yml index de08952fd..a19f4d96b 100644 --- a/jvector-examples/yaml-configs/index-parameters/autoDefault.yml +++ b/jvector-examples/yaml-configs/index-parameters/autoDefault.yml @@ -6,7 +6,7 @@ construction: outDegree: [32] efConstruction: [100] neighborOverflow: [1.2f] - addHierarchy: [Yes] + addHierarchy: [No] refineFinalGraph: [Yes] fusedGraph: [No] compression: diff --git a/jvector-examples/yaml-configs/cap-1M.yml b/jvector-examples/yaml-configs/index-parameters/cap-1M.yml similarity index 100% rename from jvector-examples/yaml-configs/cap-1M.yml rename to jvector-examples/yaml-configs/index-parameters/cap-1M.yml diff --git a/jvector-examples/yaml-configs/cap-6M.yml b/jvector-examples/yaml-configs/index-parameters/cap-6M.yml similarity index 100% rename from jvector-examples/yaml-configs/cap-6M.yml rename to jvector-examples/yaml-configs/index-parameters/cap-6M.yml diff --git a/jvector-examples/yaml-configs/cohere-10M.yml b/jvector-examples/yaml-configs/index-parameters/cohere-10M.yml similarity index 100% rename from jvector-examples/yaml-configs/cohere-10M.yml rename to jvector-examples/yaml-configs/index-parameters/cohere-10M.yml diff --git a/jvector-examples/yaml-configs/cohere-english-v3-1M.yml b/jvector-examples/yaml-configs/index-parameters/cohere-english-v3-1M.yml similarity index 100% rename from jvector-examples/yaml-configs/cohere-english-v3-1M.yml rename to jvector-examples/yaml-configs/index-parameters/cohere-english-v3-1M.yml diff --git a/jvector-examples/yaml-configs/index-parameters/colbert-1M.yml b/jvector-examples/yaml-configs/index-parameters/colbert-1M.yml index d0de5e0f4..0a20806ba 100644 --- a/jvector-examples/yaml-configs/index-parameters/colbert-1M.yml +++ b/jvector-examples/yaml-configs/index-parameters/colbert-1M.yml @@ -7,7 +7,7 @@ construction: outDegree: [32] efConstruction: [100] neighborOverflow: [1.2f] - addHierarchy: [Yes] + addHierarchy: [No] refineFinalGraph: [Yes] fusedGraph: [No] compression: diff --git a/jvector-examples/yaml-configs/index-parameters/default.yml b/jvector-examples/yaml-configs/index-parameters/default.yml index b56e27ed0..2fbcd077a 100644 --- a/jvector-examples/yaml-configs/index-parameters/default.yml +++ b/jvector-examples/yaml-configs/index-parameters/default.yml @@ -7,17 +7,22 @@ construction: outDegree: [32] efConstruction: [100] neighborOverflow: [1.2f] - addHierarchy: [Yes] + addHierarchy: [No] refineFinalGraph: [Yes] fusedGraph: [No] compression: - - type: PQ - parameters: - # m: 192 # we can either specify the integer m or the integer mFactor. In this case, m will be set to the data dimensionality divided by mFactor - mFactor: 8 - # k: 256 # optional parameter. By default, k=256 - centerData: No - anisotropicThreshold: -1.0 # optional parameter. By default, anisotropicThreshold=-1 (i.e., no anisotropy) + - type: ASH + parameters: + compressionRatio: 32 + optimizer: ITQ + landmarkCount: 1 +# - type: PQ +# parameters: +# # m: 192 # we can either specify the integer m or the integer mFactor. In this case, m will be set to the data dimensionality divided by mFactor +# mFactor: 8 +# # k: 256 # optional parameter. By default, k=256 +# centerData: No +# anisotropicThreshold: -1.0 # optional parameter. By default, anisotropicThreshold=-1 (i.e., no anisotropy) reranking: - NVQ useSavedIndexIfExists: No # If yes, the system will attempt to locate a cached copy of the index instead of constructing @@ -28,12 +33,17 @@ search: 100: [1.0, 2.0] useSearchPruning: [Yes] compression: - - type: PQ + - type: ASH parameters: - # m: 192 - mFactor: 8 - # k: 256 # optional parameter. By default, k=256 - centerData: No - anisotropicThreshold: -1.0 # optional parameter. By default, anisotropicThreshold=-1 (i.e., no anisotropy) + compressionRatio: 32 + optimizer: ITQ + landmarkCount: 1 +# - type: PQ +# parameters: +# # m: 192 +# mFactor: 8 +# # k: 256 # optional parameter. By default, k=256 +# centerData: No +# anisotropicThreshold: -1.0 # optional parameter. By default, anisotropicThreshold=-1 (i.e., no anisotropy) # Run-level controls, such as benchmarks, console, and logging, are in run-config.yml. \ No newline at end of file diff --git a/jvector-examples/yaml-configs/dpr-gemma-1M.yml b/jvector-examples/yaml-configs/index-parameters/dpr-gemma-1M.yml similarity index 99% rename from jvector-examples/yaml-configs/dpr-gemma-1M.yml rename to jvector-examples/yaml-configs/index-parameters/dpr-gemma-1M.yml index 98a7b8f8b..adc38e804 100644 --- a/jvector-examples/yaml-configs/dpr-gemma-1M.yml +++ b/jvector-examples/yaml-configs/index-parameters/dpr-gemma-1M.yml @@ -7,7 +7,7 @@ construction: outDegree: [64] efConstruction: [200] neighborOverflow: [1.6f] - addHierarchy: [Yes] + addHierarchy: [No] refineFinalGraph: [Yes] fusedGraph: [No] compression: diff --git a/jvector-examples/yaml-configs/esmc-600-1M.yml b/jvector-examples/yaml-configs/index-parameters/esmc-600-1M.yml similarity index 100% rename from jvector-examples/yaml-configs/esmc-600-1M.yml rename to jvector-examples/yaml-configs/index-parameters/esmc-600-1M.yml diff --git a/jvector-examples/yaml-configs/esmc-600-6M.yml b/jvector-examples/yaml-configs/index-parameters/esmc-600-6M.yml similarity index 100% rename from jvector-examples/yaml-configs/esmc-600-6M.yml rename to jvector-examples/yaml-configs/index-parameters/esmc-600-6M.yml diff --git a/jvector-examples/yaml-configs/index-parameters/gecko-100k.yml b/jvector-examples/yaml-configs/index-parameters/gecko-100k.yml new file mode 100644 index 000000000..1e2455336 --- /dev/null +++ b/jvector-examples/yaml-configs/index-parameters/gecko-100k.yml @@ -0,0 +1,75 @@ +yamlSchemaVersion: 1 +onDiskIndexVersion: 6 + +dataset: dpr-10M + +construction: + outDegree: [64] + efConstruction: [200] + neighborOverflow: [1.6f] + addHierarchy: [No] + refineFinalGraph: [Yes] + fusedGraph: [No] + compression: +# - type: PQ +# parameters: +# m: 97 # we can either specify the integer m or the integer mFactor. In this case, m will be set to the data dimensionality divided by mFactor +# # mFactor: 8 +# # k: 256 # optional parameter. By default, k=256 +# centerData: No +# anisotropicThreshold: -1.0 # optional parameter. By default, anisotropicThreshold=-1 (i.e., no anisotropy) +# - type: PQ +# parameters: +# m: 49 # we can either specify the integer m or the integer mFactor. In this case, m will be set to the data dimensionality divided by mFactor +# # mFactor: 8 +# # k: 256 # optional parameter. By default, k=256 +# centerData: No +# anisotropicThreshold: -1.0 # optional parameter. By default, anisotropicThreshold=-1 (i.e., no anisotropy) + - type: ASH + parameters: + compressionRatio: 32 +# encodedBits: 776 + optimizer: ITQ + landmarkCount: 1 +# - type: ASH +# parameters: +# encodedBits: 392 +# optimizer: ITQ +# landmarkCount: 1 + reranking: + - NVQ + useSavedIndexIfExists: Yes # If yes, the system will attempt to locate a cached copy of the index instead of constructing + +search: + topKOverquery: + 10: [1.00, 1.25, 1.56, 1.95, 2.44, 3.05, 3.81, 4.77, 5.96, 7.45, 9.31, 11.64] +# 100: [1.0] + useSearchPruning: [No] + compression: +# - type: PQ +# parameters: +# m: 97 # we can either specify the integer m or the integer mFactor. In this case, m will be set to the data dimensionality divided by mFactor +# # mFactor: 8 +# # k: 256 # optional parameter. By default, k=256 +# centerData: No +# anisotropicThreshold: -1.0 # optional parameter. By default, anisotropicThreshold=-1 (i.e., no anisotropy) +# - type: PQ +# parameters: +# m: 49 # we can either specify the integer m or the integer mFactor. In this case, m will be set to the data dimensionality divided by mFactor +# # mFactor: 8 +# # k: 256 # optional parameter. By default, k=256 +# centerData: No +# anisotropicThreshold: -1.0 # optional parameter. By default, anisotropicThreshold=-1 (i.e., no anisotropy) + - type: ASH + parameters: + compressionRatio: 32 +# encodedBits: 776 + optimizer: itq + landmarkCount: 1 +# - type: ASH +# parameters: +# compressionRatio: 64 +## encodedBits: 392 +# optimizer: itq +# landmarkCount: 1 +# Run-level controls, such as benchmarks, console, and logging, are in run.yml. \ No newline at end of file diff --git a/jvector-examples/yaml-configs/index-parameters/glove-100-angular.yml b/jvector-examples/yaml-configs/index-parameters/glove-100-angular.yml index 5f80f4ae2..0810c2b73 100644 --- a/jvector-examples/yaml-configs/index-parameters/glove-100-angular.yml +++ b/jvector-examples/yaml-configs/index-parameters/glove-100-angular.yml @@ -8,7 +8,7 @@ construction: outDegree: [32] efConstruction: [100] neighborOverflow: [1.2f] - addHierarchy: [Yes] + addHierarchy: [No] refineFinalGraph: [Yes] fusedGraph: [No] compression: diff --git a/jvector-examples/yaml-configs/index-parameters/glove-200-angular.yml b/jvector-examples/yaml-configs/index-parameters/glove-200-angular.yml index faf5bf3fa..4252f3e6c 100644 --- a/jvector-examples/yaml-configs/index-parameters/glove-200-angular.yml +++ b/jvector-examples/yaml-configs/index-parameters/glove-200-angular.yml @@ -8,7 +8,7 @@ construction: outDegree: [32] efConstruction: [100] neighborOverflow: [1.2f] - addHierarchy: [Yes] + addHierarchy: [No] refineFinalGraph: [Yes] fusedGraph: [No] compression: diff --git a/jvector-examples/yaml-configs/index-parameters/glove-25-angular.yml b/jvector-examples/yaml-configs/index-parameters/glove-25-angular.yml index 953f454e2..d5770dc4b 100644 --- a/jvector-examples/yaml-configs/index-parameters/glove-25-angular.yml +++ b/jvector-examples/yaml-configs/index-parameters/glove-25-angular.yml @@ -8,7 +8,7 @@ construction: outDegree: [32] efConstruction: [100] neighborOverflow: [1.2f] - addHierarchy: [Yes] + addHierarchy: [No] refineFinalGraph: [Yes] fusedGraph: [No] compression: diff --git a/jvector-examples/yaml-configs/index-parameters/glove-50-angular.yml b/jvector-examples/yaml-configs/index-parameters/glove-50-angular.yml index a62eede12..292ce4dce 100644 --- a/jvector-examples/yaml-configs/index-parameters/glove-50-angular.yml +++ b/jvector-examples/yaml-configs/index-parameters/glove-50-angular.yml @@ -8,7 +8,7 @@ construction: outDegree: [32] efConstruction: [100] neighborOverflow: [1.2f] - addHierarchy: [Yes] + addHierarchy: [No] refineFinalGraph: [Yes] fusedGraph: [No] compression: diff --git a/jvector-examples/yaml-configs/index-parameters/lastfm-64-dot.yml b/jvector-examples/yaml-configs/index-parameters/lastfm-64-dot.yml index 045963849..9230e7ca7 100644 --- a/jvector-examples/yaml-configs/index-parameters/lastfm-64-dot.yml +++ b/jvector-examples/yaml-configs/index-parameters/lastfm-64-dot.yml @@ -8,7 +8,7 @@ construction: outDegree: [32] efConstruction: [100] neighborOverflow: [1.2f] - addHierarchy: [Yes] + addHierarchy: [No] refineFinalGraph: [Yes] fusedGraph: [No] compression: diff --git a/jvector-examples/yaml-configs/mpnet-1M.yml b/jvector-examples/yaml-configs/index-parameters/mpnet-1M.yml similarity index 100% rename from jvector-examples/yaml-configs/mpnet-1M.yml rename to jvector-examples/yaml-configs/index-parameters/mpnet-1M.yml diff --git a/jvector-examples/yaml-configs/index-parameters/nytimes-256-angular.yml b/jvector-examples/yaml-configs/index-parameters/nytimes-256-angular.yml index 755e99017..5802b7857 100644 --- a/jvector-examples/yaml-configs/index-parameters/nytimes-256-angular.yml +++ b/jvector-examples/yaml-configs/index-parameters/nytimes-256-angular.yml @@ -8,7 +8,7 @@ construction: outDegree: [32] efConstruction: [100] neighborOverflow: [1.2f] - addHierarchy: [Yes] + addHierarchy: [No] refineFinalGraph: [Yes] fusedGraph: [No] compression: diff --git a/jvector-examples/yaml-configs/openai-v3-small-100k.yml b/jvector-examples/yaml-configs/index-parameters/openai-v3-small-100k.yml similarity index 100% rename from jvector-examples/yaml-configs/openai-v3-small-100k.yml rename to jvector-examples/yaml-configs/index-parameters/openai-v3-small-100k.yml diff --git a/jvector-examples/yaml-configs/personahub-1M.yml b/jvector-examples/yaml-configs/index-parameters/personahub-1M.yml similarity index 100% rename from jvector-examples/yaml-configs/personahub-1M.yml rename to jvector-examples/yaml-configs/index-parameters/personahub-1M.yml diff --git a/jvector-examples/yaml-configs/index-parameters/sift-128-euclidean.yml b/jvector-examples/yaml-configs/index-parameters/sift-128-euclidean.yml index 04d4b6fd8..8053a7041 100644 --- a/jvector-examples/yaml-configs/index-parameters/sift-128-euclidean.yml +++ b/jvector-examples/yaml-configs/index-parameters/sift-128-euclidean.yml @@ -8,7 +8,7 @@ construction: outDegree: [32] efConstruction: [100] neighborOverflow: [1.2f] - addHierarchy: [Yes] + addHierarchy: [No] refineFinalGraph: [Yes] fusedGraph: [No] compression: diff --git a/jvector-examples/yaml-configs/tahoe-x1-1M.yml b/jvector-examples/yaml-configs/index-parameters/tahoe-x1-1M.yml similarity index 100% rename from jvector-examples/yaml-configs/tahoe-x1-1M.yml rename to jvector-examples/yaml-configs/index-parameters/tahoe-x1-1M.yml From b81c149e8155c1ab4cd9c7b1e264c88108b06e13 Mon Sep 17 00:00:00 2001 From: Ted Willke Date: Wed, 10 Jun 2026 22:26:25 +0000 Subject: [PATCH 038/243] Initial implementation of multibit ASH trainer. --- .../quantization/AsymmetricHashing.java | 520 +++++++++++++++--- 1 file changed, 449 insertions(+), 71 deletions(-) diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/AsymmetricHashing.java b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/AsymmetricHashing.java index 23f7eddf1..a08e89924 100644 --- a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/AsymmetricHashing.java +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/AsymmetricHashing.java @@ -68,9 +68,9 @@ public class AsymmetricHashing implements VectorCompressor − ||μ||_2^2 @@ -343,7 +349,13 @@ public static AsymmetricHashing initialize(RandomAccessVectorValues ravv, TrainingData td = prepareTrainingData(points, landmarks, optimizer, originalDim, rng); if (optimizer == ITQ) { - stiefelTransform = runItqTrainer(td.xTrainNorm, quantizedDim, rng, TRAINING_ITERS); + stiefelTransform = runItqTrainer( + td.xTrainNorm, + quantizedDim, + DEFAULT_BITS_PER_DIMENSION, + rng, + TRAINING_ITERS + ); } else { stiefelTransform = runLandingTrainer(td.xTrainNorm, quantizedDim, rng, TRAINING_ITERS); } @@ -1242,7 +1254,15 @@ private static TrainingData prepareTrainingData( Random rng ) { final int N = points.length; - final int cap = (optimizer == LANDING) ? LANDING_N_TRAIN : ITQ_N_TRAIN; + + final int cap; + if (optimizer == LANDING) { + cap = LANDING_N_TRAIN; + } else { + long learnedCap = Math.max(1L, (long) originalDim * ITQ_TRAINING_FACTOR); + cap = (int) Math.min((long) N, learnedCap); + } + final int nTrain = Math.min(N, cap); final int[] sample = reservoirSampleOrdinals(N, nTrain, rng); @@ -1284,8 +1304,8 @@ private static TrainingData prepareTrainingData( ss += v * v; } - // Normalize residuals. NOTE: Does not handle zero vectors! - double inv = 1.0 / Math.sqrt(ss); + // Normalize residuals while guarding against zero residuals. + double inv = 1.0 / Math.max(Math.sqrt(ss), 1e-20); for (int d = 0; d < originalDim; d++) { out[d] *= inv; } @@ -1314,83 +1334,89 @@ private static int[] reservoirSampleOrdinals(int N, int k, Random rng) { } /** - * ITQ trainer: - * - PCA basis from SVD(X_train_norm) - * - iterative orthogonal Procrustes updates on temp_mat - * - returns W = M_pca @ R + * ITQ / learned projection trainer. + * + * Mirrors the current C++ learned_projection path: + * - PCA basis P from eig(X^T X), using BLAS + LAPACK + * - Xld = X @ P + * - iterative Procrustes: + * R = polar(M) + * Xtr = Xld @ R + * Xenc = encodeTrainingDirections(Xtr, bitsPerDimension) + * M = Xld^T @ Xenc + * - W = P @ R + * + * For bitsPerDimension == 1, Xenc is normalized sign coding: +/- 1/sqrt(d). + * This is equivalent to the old ITQ update up to a positive global scale, so + * the learned rotation remains compatible with the current 1-bit encoder. */ private static StiefelTransform runItqTrainer( - double[][] xHdNorm, // [N][D] normalized residuals - int quantizedDim, // d + double[][] xHdNorm, // [N][D] normalized residuals + int latentDim, // projected dimensions, d + int bitsPerDimension, // current Java path passes 1 Random rng, int nTrainingIterations ) { logProgress("\t[stage] Starting runITQTrainer..."); + if (bitsPerDimension < 1 || bitsPerDimension > 9) { + throw new IllegalArgumentException("bitsPerDimension must be in [1,9], got " + bitsPerDimension); + } + final int N = xHdNorm.length; final int D = xHdNorm[0].length; - final int d = quantizedDim; + final int d = latentDim; - // Convert training data once to flat column-major for BLAS/LAPACK. + if (d <= 0 || d > D) { + throw new IllegalArgumentException("Invalid latentDim=" + d + " for D=" + D); + } + + // X is [N x D], flat column-major for BLAS/LAPACK. double[] xCol = serializeColumnMajor(xHdNorm); - // PCA basis M = V[:, :d] where X = U S V^T + // PCA basis P = V[:, :d] from SVD(X), matching the Python reference. logProgress("\t[stage] Starting Native SVD for PCA..."); double[] vtCol = computeNativeVt(xCol, N, D); // [D x D], column-major - logProgress("\t[stage] Completed Native SVD..."); - double[] mCol = extractPcaBasis(vtCol, D, d); // [D x d], column-major - - // Early stopping based on stabilized binary codes (subject to max iterations) - double[] prevXbinCol = null; - int stable = 0; - final double STOP_FRAC = 4e-3; - final int STOP_PATIENCE = 3; + double[] pCol = extractPcaBasis(vtCol, D, d); // [D x d], column-major + logProgress("\t[stage] Completed Native SVD for PCA..."); - // X_norm = X_hd_norm @ M => [N x d], column-major - double[] xnormCol = nativeMultiply(xCol, N, D, mCol, d); + // Xld = X @ P, shape [N x d]. + double[] xldCol = nativeMultiply(xCol, N, D, pCol, d); - // Initial random rotation seed - double[] tempCol = gaussianMatrix(d, d, rng); + // Initial random update matrix M, shape [d x d]. + double[] mCol = gaussianMatrix(d, d, rng); - // Loop: epoch 0..iters inclusive; last epoch computes R but does not update temp_mat. double[] rCol = null; + double[] xtrCol = new double[N * d]; + double[] xencCol = new double[N * d]; + logProgress("\t[stage] ITQ training iterations started..."); - var startTime = System.nanoTime(); - for (int epoch = 0; epoch <= nTrainingIterations; epoch++) { - rCol = orthogonalize(tempCol, d, d); // R = U @ V^T, [d x d] + long startTime = System.nanoTime(); - if (epoch < nTrainingIterations) { - // Xtr = Xnorm @ R, then binarize in-place to become Xbin. - double[] xbinCol = nativeMultiply(xnormCol, N, d, rCol, d); + for (int epoch = 0; epoch < nTrainingIterations; epoch++) { + rCol = orthogonalize(mCol, d, d); - long changed = sign01InPlace(xbinCol, prevXbinCol); + multiplyInto(xldCol, N, d, rCol, d, xtrCol); + encodeTrainingDirections(xtrCol, N, d, bitsPerDimension, xencCol); - if (prevXbinCol != null) { - long total = (long) N * (long) d; - double frac = (double) changed / (double) total; + // ITQ update: M = Xld^T @ Xenc. + mCol = nativeMultiplyTransposedA(xldCol, N, d, xencCol, d); - logProgress(String.format(java.util.Locale.ROOT, - "\titeration %d/%d - fracBitsChanged=%.6g (stable=%d/%d)", - epoch, nTrainingIterations, frac, stable, STOP_PATIENCE)); + // C++-style cheap loss: trace(R^T M) / N. + ProjectionTrainingLoss loss = computeTrainingLossFromUpdateMatrix(rCol, mCol, N); + printTrainingLoss(epoch, loss); + } - if (frac < STOP_FRAC) { - if (++stable >= STOP_PATIENCE) { - break; - } - } else { - stable = 0; - } - } + // Final polar step after the last update. + // We do not print a final no-update loss here because exact C++ final-loss + // logging would require another Xld @ R and encode pass. + rCol = orthogonalize(mCol, d, d); - prevXbinCol = xbinCol; - tempCol = nativeMultiplyTransposedA(xnormCol, N, d, xbinCol, d); - } - } - var loopTime = (System.nanoTime() - startTime) / 1e9; + double loopTime = (System.nanoTime() - startTime) / 1e9; logProgress("\t[stage] ITQ training completed in " + loopTime + " seconds."); - // W = M @ R => [D x d], column-major - double[] wCol = nativeMultiply(mCol, D, d, rCol, d); + // W = P @ R, shape [D x d]. + double[] wCol = nativeMultiply(pCol, D, d, rCol, d); RealMatrix W = new Array2DRowRealMatrix(deserializeColumnMajor(wCol, D, d), false); return new StiefelTransform(W); @@ -1534,6 +1560,210 @@ private static StiefelTransform runLandingTrainer( return new StiefelTransform(A); } + private static final class ProjectionTrainingLoss { + final double ip; + final double loss; + + ProjectionTrainingLoss(double ip, double loss) { + this.ip = ip; + this.loss = loss; + } + } + + /** + * Java equivalent of C++ compute_training_loss_from_update_matrix(R, M). + * + * R and M are [d x d] column-major. + */ + private static ProjectionTrainingLoss computeTrainingLossFromUpdateMatrix( + double[] rCol, + double[] mCol, + int rows + ) { + double ipSum = 0.0; + for (int i = 0; i < rCol.length; i++) { + ipSum += rCol[i] * mCol[i]; + } + + double ip = ipSum / rows; + return new ProjectionTrainingLoss(ip, Math.max(0.0, 1.0 - ip)); + } + + private static void printTrainingLoss(int epoch, ProjectionTrainingLoss loss) { + logProgress(String.format(java.util.Locale.ROOT, + "projection_train epoch=%2d ip_loss=%.5f loss=%.5f", + epoch, loss.ip, loss.loss)); + } + + private static final float[] K_TIGHT_START = { + 0.0f, + 0.15f, + 0.20f, + 0.52f, + 0.59f, + 0.71f, + 0.75f, + 0.77f, + 0.81f + }; + + private static final double K_EPS = 1e-5; + + private static final ThreadLocal SCALING_WORKSPACE = + ThreadLocal.withInitial(ScalingWorkspace::new); + + private static final class ScalingWorkspace { + float[] invOAbs = new float[0]; + int[] curOBar = new int[0]; + long[] events = new long[0]; + + void ensureCapacity(int d, int maxEvents) { + if (invOAbs.length < d) { + invOAbs = new float[d]; + } + if (curOBar.length < d) { + curOBar = new int[d]; + } + if (events.length < maxEvents) { + events = new long[maxEvents]; + } + } + } + + /** + * Java port of ash::compute_optimal_scaling_factor. + * + * The input array is double because the Java trainer stores training matrices + * as double, but this method intentionally performs the search in float + * precision to match the C++ implementation. + */ + private static double computeOptimalScalingFactor( + double[] oAbs, + int d, + int bitsPerDimension + ) { + final int exBits = bitsPerDimension - 1; + if (exBits < 1 || exBits > 8) { + throw new IllegalArgumentException("exBits must be in [1,8], got " + exBits); + } + if (d <= 0 || d > oAbs.length) { + throw new IllegalArgumentException("Invalid d=" + d + " for oAbs.length=" + oAbs.length); + } + + final int kNEnum = 10; + final int maxCode = (1 << exBits) - 1; + + float maxO = 0.0f; + for (int i = 0; i < d; i++) { + float v = (float) oAbs[i]; + if (v > maxO) { + maxO = v; + } + } + + if (!(maxO > 0.0f)) { + return 0.0; + } + + final float tEnd = (float) (maxCode + kNEnum) / maxO; + final float tStart = tEnd * K_TIGHT_START[exBits]; + + final int maxEvents = Math.multiplyExact(d, maxCode + 1); + ScalingWorkspace ws = SCALING_WORKSPACE.get(); + ws.ensureCapacity(d, maxEvents); + + float sqrDenominator = (float) d * 0.25f; + float numerator = 0.0f; + + for (int i = 0; i < d; i++) { + float oi = (float) oAbs[i]; + + ws.invOAbs[i] = 1.0f / oi; + + int cur = (int) ((double) (tStart * oi) + K_EPS); + ws.curOBar[i] = cur; + + sqrDenominator += (float) (cur * cur + cur); + numerator += (cur + 0.5f) * oi; + } + + float invSqrtDenom = 1.0f / (float) Math.sqrt(sqrDenominator); + + int eventCount = 0; + for (int i = 0; i < d; i++) { + final float invO = ws.invOAbs[i]; + final int first = ws.curOBar[i] + 1; + + // Match C++: always consider the first event, then only generate + // follow-on events while k <= maxCode. + float tn = (float) first * invO; + if (tn >= tEnd) { + continue; + } + + ws.events[eventCount++] = packScalingEvent(tn, i); + + for (int k = first + 1; k <= maxCode; k++) { + tn = (float) k * invO; + if (tn >= tEnd) { + break; + } + + ws.events[eventCount++] = packScalingEvent(tn, i); + } + } + + Arrays.sort(ws.events, 0, eventCount); + + float maxIp = 0.0f; + float t = 0.0f; + + for (int e = 0; e < eventCount; e++) { + long event = ws.events[e]; + int id = scalingEventId(event); + + ws.curOBar[id]++; + int update = ws.curOBar[id]; + + float delta = 2.0f * update; + sqrDenominator += delta; + numerator += (float) oAbs[id]; + + float oldDenom = sqrDenominator - delta; + invSqrtDenom *= + (1.0f - 0.5f * delta / (oldDenom + delta * 0.5f)); + + float curIp = numerator * invSqrtDenom; + if (curIp > maxIp) { + maxIp = curIp; + t = scalingEventT(event); + } + } + + return t; + } + + /** + * Packs event sort key as: + * high 32 bits: positive float t bits + * low 32 bits: dimension id + * + * For positive finite t, raw float bits sort in the same order as numeric t. + * The low bits reproduce the C++ tie-breaker by id. + */ + private static long packScalingEvent(float t, int id) { + return ((long) Float.floatToRawIntBits(t) << 32) + | (id & 0xFFFF_FFFFL); + } + + private static float scalingEventT(long event) { + return Float.intBitsToFloat((int) (event >>> 32)); + } + + private static int scalingEventId(long event) { + return (int) event; + } + /** * Samples a Gaussian matrix in flat column-major storage. * @@ -1696,6 +1926,109 @@ private static double[] computeNativeVt(double[] xCol, int rows, int cols) { return vt; } + /** + * PCA basis from eig(X^T X). + * + * Input X is [rows x cols] in column-major layout. + * Returns P = top right singular vectors of X, shape [cols x latentDim], + * also column-major. + */ + private static double[] pcaProjectionFromCovariance( + double[] xCol, + int rows, + int cols, + int latentDim + ) { + double[] covCol = new double[cols * cols]; + + // cov = X^T @ X, shape [cols x cols]. + BLAS.getInstance().dgemm( + "T", "N", + cols, cols, rows, + 1.0, + xCol, rows, + xCol, rows, + 0.0, + covCol, cols + ); + + symmetricEigenvectorsInPlace(covCol, cols); + + // LAPACK dsyev returns eigenvalues ascending and eigenvectors by columns. + // Select the largest latentDim eigenvectors. + double[] pCol = new double[cols * latentDim]; + for (int col = 0; col < latentDim; col++) { + int eigCol = cols - 1 - col; + System.arraycopy(covCol, eigCol * cols, pCol, col * cols, cols); + } + + return pCol; + } + + /** + * Overwrites a symmetric column-major matrix with eigenvectors by column. + */ + private static void symmetricEigenvectorsInPlace(double[] aCol, int dim) { + double[] evals = new double[dim]; + intW info = new intW(0); + + double[] workQuery = new double[1]; + LAPACK.getInstance().dsyev( + "V", "U", + dim, + aCol, dim, + evals, + workQuery, -1, + info + ); + + if (info.val != 0) { + throw new RuntimeException("LAPACK dsyev workspace query failed with info=" + info.val); + } + + int lwork = Math.max(1, (int) workQuery[0]); + double[] work = new double[lwork]; + + LAPACK.getInstance().dsyev( + "V", "U", + dim, + aCol, dim, + evals, + work, lwork, + info + ); + + if (info.val != 0) { + throw new RuntimeException("LAPACK dsyev failed with info=" + info.val); + } + } + + /** + * Native DGEMM into caller-provided output: C = A @ B. + */ + private static void multiplyInto( + double[] aCol, + int m, + int k, + double[] bCol, + int n, + double[] cCol + ) { + if (cCol.length < m * n) { + throw new IllegalArgumentException("Output matrix too small"); + } + + BLAS.getInstance().dgemm( + "N", "N", + m, n, k, + 1.0, + aCol, m, + bCol, k, + 0.0, + cCol, m + ); + } + /** * Orthogonalizes a flat column-major matrix using thin SVD and returns U @ VT * in flat column-major layout. @@ -1761,28 +2094,73 @@ private static RealMatrix orthogonalize(RealMatrix m) { } /** - * Binarizes values in-place to +/-1 and, if prevBinCol is non-null, counts - * how many entries changed relative to the previous binarized matrix. + * Encodes projected training directions. + * + * Input and output are [rows x dims] column-major. + * + * bitsPerDimension == 1: + * y_j = sign(x_j) / sqrt(d) + * + * bitsPerDimension > 1: + * y_j uses signed multibit magnitudes and is normalized to unit length. */ - private static long sign01InPlace(double[] valuesCol, double[] prevBinCol) { - long changed = 0L; + private static void encodeTrainingDirections( + double[] xCol, + int rows, + int dims, + int bitsPerDimension, + double[] outCol + ) { + if (outCol.length < rows * dims) { + throw new IllegalArgumentException("Output matrix too small"); + } + + if (bitsPerDimension <= 1) { + final double invSqrtD = 1.0 / Math.sqrt(dims); + final int size = rows * dims; - if (prevBinCol == null) { - for (int i = 0; i < valuesCol.length; i++) { - valuesCol[i] = (valuesCol[i] > 0.0) ? 1.0 : -1.0; + for (int i = 0; i < size; i++) { + outCol[i] = xCol[i] >= 0.0 ? invSqrtD : -invSqrtD; } - return 0L; + return; } - for (int i = 0; i < valuesCol.length; i++) { - double bin = (valuesCol[i] > 0.0) ? 1.0 : -1.0; - if (bin != prevBinCol[i]) { - changed++; + final int excessBits = bitsPerDimension - 1; + final int maxCode = (1 << excessBits) - 1; + + double[] absNorm = new double[dims]; + double[] mag = new double[dims]; + + for (int r = 0; r < rows; r++) { + double norm2 = 0.0; + for (int j = 0; j < dims; j++) { + double v = xCol[r + j * rows]; + norm2 += v * v; } - valuesCol[i] = bin; - } - return changed; + double invNorm = 1.0 / Math.sqrt(Math.max(norm2, 1e-20)); + + for (int j = 0; j < dims; j++) { + absNorm[j] = Math.abs(xCol[r + j * rows]) * invNorm; + } + + double t = computeOptimalScalingFactor(absNorm, dims, bitsPerDimension); + + double encodedNorm2 = 0.0; + for (int j = 0; j < dims; j++) { + int q = Math.min((int) (t * absNorm[j] + 1e-5), maxCode); + double m = q + 0.5; + mag[j] = m; + encodedNorm2 += m * m; + } + + double encodedInvNorm = 1.0 / Math.sqrt(Math.max(encodedNorm2, 1e-20)); + + for (int j = 0; j < dims; j++) { + double signed = xCol[r + j * rows] < 0.0 ? -mag[j] : mag[j]; + outCol[r + j * rows] = signed * encodedInvNorm; + } + } } private static void writeStiefelTransform(IndexWriter out, StiefelTransform st) throws IOException { From b03b39be06cbc1106582190bbad4e201958c210b Mon Sep 17 00:00:00 2001 From: Ted Willke Date: Wed, 10 Jun 2026 22:41:37 +0000 Subject: [PATCH 039/243] Removed Landing algo from DistancesASH. --- .../jbellis/jvector/example/DistancesASH.java | 24 +++++++++---------- 1 file changed, 11 insertions(+), 13 deletions(-) diff --git a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/DistancesASH.java b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/DistancesASH.java index 687d7efac..e9c6a3f29 100644 --- a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/DistancesASH.java +++ b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/DistancesASH.java @@ -66,17 +66,15 @@ private static int parseOptimizerFromProperty() { if ("random".equals(opt)) return AsymmetricHashing.RANDOM; if ("itq".equals(opt)) return AsymmetricHashing.ITQ; - if ("landing".equals(opt)) return AsymmetricHashing.LANDING; throw new IllegalArgumentException( - "Unknown jvector.ash.optimizer=" + opt + " (expected random|itq|landing)" + "Unknown jvector.ash.optimizer=" + opt + " (expected random|itq)" ); } private static String optimizerName(int optimizer) { if (optimizer == AsymmetricHashing.RANDOM) return "RANDOM"; if (optimizer == AsymmetricHashing.ITQ) return "ITQ"; - if (optimizer == AsymmetricHashing.LANDING) return "LANDING"; return "UNKNOWN(" + optimizer + ")"; } @@ -103,13 +101,13 @@ public static void testASHEncodings(String filenameBase, String filenameQueries, Boolean.parseBoolean(System.getProperty("jvector.bench.float-scoring", "false")); // ASH header bits - final int HEADER_BITS = 72; + final int HEADER_BITS = 40; // Block sizes to benchmark final int[] BLOCK_SIZES = {32}; // 16, 32, and/or 64 // How many ASH landmarks to use, C = [1, 64] - final int landmarkCount = 1; + final int landmarkCount = 32; List> vectors = SiftLoader.readFvecs(filenameBase); List> queries = SiftLoader.readFvecs(filenameQueries); @@ -126,7 +124,7 @@ public static void testASHEncodings(String filenameBase, String filenameQueries, // - No other normalization steps are applied. int dimension = vectors.get(0).length(); - int encodedBits = 328; // (dimension / 4) + HEADER_BITS; + int encodedBits = 1064; // (dimension / 4) + HEADER_BITS; // Payload must be 64-bit aligned for SIMD int payloadBits = encodedBits - HEADER_BITS; // if ((payloadBits & 63) != 0) { @@ -155,7 +153,7 @@ public static void testASHEncodings(String filenameBase, String filenameQueries, var ravv = new ListRandomAccessVectorValues(vectors, dimension); // Choose optimizer at runtime: - // -Djvector.ash.optimizer=random|itq|landing + // -Djvector.ash.optimizer=random|itq final int optimizer = parseOptimizerFromProperty(); logProgress("\tASH optimizer = " + optimizerName(optimizer)); @@ -606,9 +604,9 @@ public static void runGIST() throws IOException { public static void runCohere100k() throws IOException { System.out.println("Running Cohere-100k"); - var baseVectors = "./fvec/cohere-100k/cohere_embed-english-v3.0_1024_base_vectors_100000.fvec"; - var queryVectors = "./fvec/cohere-100k/cohere_embed-english-v3.0_1024_query_vectors_10000.fvec"; - var gtVectors = "./fvec/cohere-100k/cohere_embed-english-v3.0_1024_indices_b100000_q10000_k100.ivec"; + var baseVectors = "/home/ted_willke/datasets-clean/cohere_english_v3_100k_base_99685.fvecs"; + var queryVectors = "/home/ted_willke/datasets-clean/cohere_english_v3_100k_query_10000.fvecs"; + var gtVectors = "/home/ted_willke/datasets-clean/cohere_english_v3_100k_gt_ip_100.ivecs"; testASHEncodings(baseVectors, queryVectors, gtVectors); } @@ -679,12 +677,12 @@ public static void main(String[] args) throws IOException { // runSIFT(); // runGIST(); // runColbert(); -// runCohere100k(); + runCohere100k(); // runADA(); // runADANoZeros(); // runOpenai1536(); // runOpenai3072(); // runCap6m(); - runCohere10m(); +// runCohere10m(); } -} +} \ No newline at end of file From 63c113dfcfca22825c6e5086cdf96155f3e9434b Mon Sep 17 00:00:00 2001 From: Ted Willke Date: Wed, 10 Jun 2026 23:08:53 +0000 Subject: [PATCH 040/243] Remove Landing trainer from Asymmetric Hashing. --- .../quantization/AsymmetricHashing.java | 401 +++++++----------- 1 file changed, 155 insertions(+), 246 deletions(-) diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/AsymmetricHashing.java b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/AsymmetricHashing.java index a08e89924..b5764fa88 100644 --- a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/AsymmetricHashing.java +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/AsymmetricHashing.java @@ -60,8 +60,8 @@ * orthonormal projection and sign thresholding. */ public class AsymmetricHashing implements VectorCompressor, Accountable { - public static final int ITQ = 1, LANDING = 2, RANDOM = 3; - private static final int MAGIC = 0x75EC4012; // TODO update the magic number? + public static final int ITQ = 1, RANDOM = 2; + private static final int MAGIC = 0x75EC4013; // --------------------------------------------------------------------- // Training configuration (reference defaults; TODO tune later) @@ -72,14 +72,6 @@ public class AsymmetricHashing implements VectorCompressor vector, } } - public static final class LandingBinaryQuantizer implements BinaryQuantizer { - // Same binarization math for all optimizers; only the learned transform differs. - private final RandomBinaryQuantizer delegate = new RandomBinaryQuantizer(); - - @Override - public void quantizeBody(VectorFloat vector, - VectorFloat mu, - float residualNorm, - int quantizedDim, - StiefelTransform stiefel, - long[] outWords) { - delegate.quantizeBody(vector, mu, residualNorm, quantizedDim, stiefel, outWords); - } - } - // --------------------------------------------------------------------- // Quantized vector (per-vector data) // --------------------------------------------------------------------- @@ -792,7 +762,7 @@ public static QuantizedVector createEmpty(int quantizedDim) { } public static int serializedSizeBytes(int quantizedDim) { - return Float.BYTES + Float.BYTES + Byte.BYTES + return Short.BYTES + Short.BYTES + Byte.BYTES + wordsForDims(quantizedDim) * Long.BYTES; } @@ -839,8 +809,8 @@ static void quantizeTo(VectorFloat vector, } public void write(IndexWriter out, int quantizedDim) throws IOException { - out.writeFloat(scale); - out.writeFloat(offset); + writeFloat16(out, scale); + writeFloat16(out, offset); out.writeByte(landmark); int words = wordsForDims(quantizedDim); @@ -851,8 +821,9 @@ public void write(IndexWriter out, int quantizedDim) throws IOException { public static QuantizedVector load(RandomAccessReader in, int quantizedDim) throws IOException { - float scale = in.readFloat(); - float offset = in.readFloat(); + float scale = readFloat16(in); + float offset = readFloat16(in); + byte landmark; if (in instanceof io.github.jbellis.jvector.disk.ByteReadable) { landmark = ((io.github.jbellis.jvector.disk.ByteReadable) in).readByte(); @@ -872,8 +843,9 @@ public static QuantizedVector load(RandomAccessReader in, public static void loadInto(RandomAccessReader in, QuantizedVector dest, int quantizedDim) throws IOException { - dest.scale = in.readFloat(); - dest.offset = in.readFloat(); + dest.scale = readFloat16(in); + dest.offset = readFloat16(in); + if (in instanceof io.github.jbellis.jvector.disk.ByteReadable) { dest.landmark = ((io.github.jbellis.jvector.disk.ByteReadable) in).readByte(); } else { @@ -1210,8 +1182,8 @@ public void encodeTo(VectorFloat vector, QuantizedVector dest) { final float offset = dotXMu - landmarkNormSq[landmark]; // Write the new header fields into dest (requires QuantizedVector fields renamed, see below) - dest.scale = scale; - dest.offset = offset; + dest.scale = roundToFloat16(scale); + dest.offset = roundToFloat16(offset); dest.landmark = landmark; // Binary body: sign(A · (x − μ)) with Eq. 6 normalization inside quantizer @@ -1219,7 +1191,7 @@ public void encodeTo(VectorFloat vector, QuantizedVector dest) { } // --------------------------------------------------------------------- - // Training of projection matrix (ITQ + LANDING algorithms) + // Training of projection matrix // --------------------------------------------------------------------- /** @@ -1249,21 +1221,13 @@ private static final class TrainingData { private static TrainingData prepareTrainingData( VectorFloat[] points, VectorFloat[] landmarks, - int optimizer, int originalDim, Random rng ) { final int N = points.length; - final int cap; - if (optimizer == LANDING) { - cap = LANDING_N_TRAIN; - } else { - long learnedCap = Math.max(1L, (long) originalDim * ITQ_TRAINING_FACTOR); - cap = (int) Math.min((long) N, learnedCap); - } - - final int nTrain = Math.min(N, cap); + long learnedCap = Math.max(1L, (long) originalDim * ITQ_TRAINING_FACTOR); + final int nTrain = (int) Math.min((long) N, learnedCap); final int[] sample = reservoirSampleOrdinals(N, nTrain, rng); Arrays.sort(sample); // deterministic order for reproducibility @@ -1422,144 +1386,6 @@ private static StiefelTransform runItqTrainer( return new StiefelTransform(W); } - /** - * - * LANDING trainer TODO incomplete - * - * NOTE: This is a DRAFT (not hardened) implementation using RealMatrix operations. - * We can SIMD/block-optimize the hot math once correctness is validated. - * - * Row-oriented implementation of the paper's stochastic updates: - * - A is stored as the decoder matrix [D x d] (paper's A) - * - W is stored as the encoder-transpose [D x d], so Xb * W gives - * row-wise projections equivalent to W^T x in the paper - * - * The update equations implemented are the row-oriented forms of - * Equations (22)-(25) from the paper. - */ - private static StiefelTransform runLandingTrainer( - double[][] xHdNorm, // [N][D] normalized residuals - int quantizedDim, // d - Random rng, - int nTrainingIterations - ) { - logProgress("\t[stage] Starting runLandingTrainer..."); - - final int N = xHdNorm.length; - final int D = xHdNorm[0].length; - final int d = quantizedDim; - - final double invD = 1.0 / d; - final double invSqrtD = 1.0 / Math.sqrt(d); - final double lambda = 1.0; - - // ------------------------------------------------------------------ - // ITQ-style initialization from PCA basis P and random orthogonal R. - // - // xHdNorm is row-stacked [N x D]. The top right singular vectors of X - // are the top left singular vectors of X^T used in the paper. - // ------------------------------------------------------------------ - double[] xCol = serializeColumnMajor(xHdNorm); - - logProgress("\t[stage] Starting Native SVD for LANDING PCA..."); - double[] vtCol = computeNativeVt(xCol, N, D); // [D x D], column-major - logProgress("\t[stage] Completed Native SVD for LANDING PCA..."); - - double[] pCol = extractPcaBasis(vtCol, D, d); // [D x d], column-major - double[] rCol = orthogonalize(gaussianMatrix(d, d, rng), d, d); // [d x d] - - // Stored row-oriented decoder A and encoder-transpose W both start from PR, - // matching the ITQ-style initialization used elsewhere in this class. - double[] initCol = nativeMultiply(pCol, D, d, rCol, d); // [D x d] - RealMatrix A = new Array2DRowRealMatrix(deserializeColumnMajor(initCol, D, d), false); - RealMatrix W = A.copy(); - - // I_d for the Stiefel penalty A^T A - I_d - double[][] eye = new double[d][d]; - for (int i = 0; i < d; i++) { - eye[i][i] = 1.0; - } - final RealMatrix I_d = new Array2DRowRealMatrix(eye, false); - - int[] idx = new int[N]; - for (int i = 0; i < N; i++) { - idx[i] = i; - } - - logProgress("\t[stage] LANDING training iterations started..."); - for (int epoch = 0; epoch < nTrainingIterations; epoch++) { - // Fisher-Yates shuffle - for (int i = idx.length - 1; i > 0; i--) { - int j = rng.nextInt(i + 1); - int tmp = idx[i]; - idx[i] = idx[j]; - idx[j] = tmp; - } - - final double lr = 0.1 * Math.pow(0.98, epoch); - - for (int off = 0; off < N; off += LANDING_BATCH_SIZE) { - final int end = Math.min(N, off + LANDING_BATCH_SIZE); - final int B = end - off; - - // Build batch matrix Xb [B x D] without copying row contents. - double[][] xb = new double[B][]; - for (int bi = 0; bi < B; bi++) { - xb[bi] = xHdNorm[idx[off + bi]]; - } - RealMatrix Xb = new Array2DRowRealMatrix(xb, false); - - // Z = sign(Xb * W) in {+1, -1}^{B x d}, unscaled. - RealMatrix Xproj = Xb.multiply(W); // [B x d] - double[][] projData = (Xproj instanceof Array2DRowRealMatrix) - ? ((Array2DRowRealMatrix) Xproj).getDataRef() - : Xproj.getData(); - - double[][] zData = new double[B][d]; - for (int i = 0; i < B; i++) { - double[] src = projData[i]; - double[] dst = zData[i]; - for (int j = 0; j < d; j++) { - dst[j] = (src[j] > 0.0) ? 1.0 : -1.0; - } - } - RealMatrix Z = new Array2DRowRealMatrix(zData, false); // [B x d] - - // Batch statistics - RealMatrix ZtZ = Z.transpose().multiply(Z); // [d x d] - RealMatrix XtZ = Xb.transpose().multiply(Z); // [D x d] - RealMatrix XtX = Xb.transpose().multiply(Xb); // [D x D] - RealMatrix AtA = A.transpose().multiply(A); // [d x d] - - // Row-oriented gradA = d^-1 * A * (Z^T Z) - d^-1/2 * X^T Z - RealMatrix gradA = A.multiply(ZtZ).scalarMultiply(invD) - .subtract(XtZ.scalarMultiply(invSqrtD)); - - // Row-oriented for stored W = (paper W)^T: - // gradW = (X^T X) * ( d^-1 * W * (A^T A) - d^-1/2 * A ) - RealMatrix gradW = XtX.multiply( - W.multiply(AtA).scalarMultiply(invD) - .subtract(A.scalarMultiply(invSqrtD)) - ); - - // Λ(A) = skew(gradA * A^T) * A + λ * A * (A^T A - I) - RealMatrix skewBase = gradA.multiply(A.transpose()); // [D x D] - RealMatrix skew = skewBase.subtract(skewBase.transpose()).scalarMultiply(0.5); - RealMatrix ortho = AtA.subtract(I_d); // [d x d] - RealMatrix landing = skew.multiply(A) - .add(A.multiply(ortho).scalarMultiply(lambda)); - - A = A.subtract(landing.scalarMultiply(lr)); - W = W.subtract(gradW.scalarMultiply(lr)); - } - } - - logProgress("\t[stage] LANDING training completed."); - - // StiefelTransform expects the decoder matrix [D x d]. - return new StiefelTransform(A); - } - private static final class ProjectionTrainingLoss { final double ip; final double loss; @@ -1796,33 +1622,6 @@ private static double[] serializeColumnMajor(double[][] matrix) { return data; } - /** Converts a RealMatrix to a flat column-major array for BLAS/LAPACK. */ - private static double[] serializeColumnMajor(RealMatrix m) { - int rows = m.getRowDimension(); - int cols = m.getColumnDimension(); - double[] data = new double[rows * cols]; - - if (m instanceof Array2DRowRealMatrix) { - double[][] raw = ((Array2DRowRealMatrix) m).getDataRef(); - for (int r = 0; r < rows; r++) { - double[] row = raw[r]; - int dst = r; - for (int c = 0; c < cols; c++, dst += rows) { - data[dst] = row[c]; - } - } - return data; - } - - for (int r = 0; r < rows; r++) { - int dst = r; - for (int c = 0; c < cols; c++, dst += rows) { - data[dst] = m.getEntry(r, c); - } - } - return data; - } - /** Converts a flat column-major array back into a row-major double[][] for Commons Math. */ private static double[][] deserializeColumnMajor(double[] data, int rows, int cols) { double[][] matrix = new double[rows][cols]; @@ -2082,17 +1881,6 @@ private static double[] orthogonalize(double[] mCol, int rows, int cols) { return nativeMultiply(u, rows, k, vt, cols); } - /** - * Thin wrapper retained so the LANDING stub continues to compile - * without changing callers or method names. - */ - private static RealMatrix orthogonalize(RealMatrix m) { - int rows = m.getRowDimension(); - int cols = m.getColumnDimension(); - double[] qCol = orthogonalize(serializeColumnMajor(m), rows, cols); - return new Array2DRowRealMatrix(deserializeColumnMajor(qCol, rows, cols), false); - } - /** * Encodes projected training directions. * @@ -2232,13 +2020,137 @@ public int compressorSize() { return size; } + private static float roundToFloat16(float value) { + return halfBitsToFloat(floatToHalfBits(value)); + } + + private static void writeFloat16(IndexWriter out, float value) throws IOException { + int bits = floatToHalfBits(value); + + // Explicit big-endian byte order, matching Java DataOutput-style primitives. + out.writeByte((byte) ((bits >>> 8) & 0xFF)); + out.writeByte((byte) (bits & 0xFF)); + } + + private static float readFloat16(RandomAccessReader in) throws IOException { + int hi = readUnsignedByte(in); + int lo = readUnsignedByte(in); + return halfBitsToFloat((hi << 8) | lo); + } + + private static int readUnsignedByte(RandomAccessReader in) throws IOException { + if (in instanceof io.github.jbellis.jvector.disk.ByteReadable) { + return ((io.github.jbellis.jvector.disk.ByteReadable) in).readByte() & 0xFF; + } + + byte[] one = ONE_BYTE.get(); + in.readFully(one); + return one[0] & 0xFF; + } + + private static final ThreadLocal ONE_BYTE = + ThreadLocal.withInitial(() -> new byte[1]); + + /** + * IEEE-754 binary16 round-to-nearest-even conversion. + */ + private static int floatToHalfBits(float value) { + int bits = Float.floatToRawIntBits(value); + int sign = (bits >>> 16) & 0x8000; + int abs = bits & 0x7FFF_FFFF; + + // NaN / infinity + if (abs >= 0x7F80_0000) { + if ((abs & 0x007F_FFFF) == 0) { + return sign | 0x7C00; + } + return sign | 0x7E00; // canonical quiet NaN + } + + int exp = ((abs >>> 23) & 0xFF) - 127 + 15; + int mant = abs & 0x007F_FFFF; + + // Overflow to infinity. + if (exp >= 31) { + return sign | 0x7C00; + } + + // Subnormal / underflow. + if (exp <= 0) { + if (exp < -10) { + return sign; + } + + mant |= 0x0080_0000; + int shift = 14 - exp; + int halfMant = mant >>> shift; + + int roundBit = 1 << (shift - 1); + int remainder = mant & (roundBit - 1); + + if ((mant & roundBit) != 0 && (remainder != 0 || (halfMant & 1) != 0)) { + halfMant++; + } + + return sign | halfMant; + } + + // Normalized. + int halfMant = mant >>> 13; + int roundBit = 0x0000_1000; + int remainder = mant & (roundBit - 1); + + if ((mant & roundBit) != 0 && (remainder != 0 || (halfMant & 1) != 0)) { + halfMant++; + + if (halfMant == 0x0400) { + halfMant = 0; + exp++; + + if (exp >= 31) { + return sign | 0x7C00; + } + } + } + + return sign | (exp << 10) | halfMant; + } + + private static float halfBitsToFloat(int half) { + int h = half & 0xFFFF; + int sign = (h & 0x8000) << 16; + int exp = (h >>> 10) & 0x1F; + int mant = h & 0x03FF; + + if (exp == 0) { + if (mant == 0) { + return Float.intBitsToFloat(sign); + } + + // Normalize half subnormal. + while ((mant & 0x0400) == 0) { + mant <<= 1; + exp--; + } + + exp++; + mant &= 0x03FF; + } else if (exp == 31) { + return Float.intBitsToFloat(sign | 0x7F80_0000 | (mant << 13)); + } + + int floatExp = exp + (127 - 15); + int bits = sign | (floatExp << 23) | (mant << 13); + return Float.intBitsToFloat(bits); + } + @Override public int compressedVectorSize() { int words = QuantizedVector.wordsForDims(quantizedDim); - return Float.BYTES // scale - + Float.BYTES // offset - + Byte.BYTES // landmark - + words * Long.BYTES; // binary payload + return Short.BYTES // scale, fp16 + + Short.BYTES // offset, fp16 + + Byte.BYTES // landmark + + words * Long.BYTES; } @Override @@ -2459,9 +2371,6 @@ public String toString() { case ITQ: optimizerName = "ITQ"; break; - case LANDING: - optimizerName = "LANDING"; - break; default: optimizerName = "UNKNOWN(" + optimizer + ")"; } From 5ba29d97a57e92ef5c6d16c934369e6b23f9b2d7 Mon Sep 17 00:00:00 2001 From: Ted Willke Date: Wed, 10 Jun 2026 23:10:45 +0000 Subject: [PATCH 041/243] Removed Landing algo selection from Compression. --- .../io/github/jbellis/jvector/example/yaml/Compression.java | 3 --- 1 file changed, 3 deletions(-) diff --git a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/yaml/Compression.java b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/yaml/Compression.java index 4bac586ce..eef2e57db 100644 --- a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/yaml/Compression.java +++ b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/yaml/Compression.java @@ -83,9 +83,6 @@ public Function getCompressorParameters() { case "ITQ": optimizer = AsymmetricHashing.ITQ; break; - case "LANDING": - optimizer = AsymmetricHashing.LANDING; - break; default: optimizer = Integer.parseInt(optStr); } From d5aa241794e8f185041de34b550383276915641a Mon Sep 17 00:00:00 2001 From: Ted Willke Date: Thu, 11 Jun 2026 01:05:06 +0000 Subject: [PATCH 042/243] Initial implementation of scalar/fallback multibit ASH support. --- .../jvector/quantization/ASHScorer.java | 172 +++-- .../jvector/quantization/ASHVectors.java | 167 ++--- .../quantization/AsymmetricHashing.java | 655 ++++++++++-------- .../jvector/example/yaml/Compression.java | 6 +- 4 files changed, 535 insertions(+), 465 deletions(-) diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHScorer.java b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHScorer.java index a345a088f..0a844b347 100644 --- a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHScorer.java +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHScorer.java @@ -21,11 +21,28 @@ import io.github.jbellis.jvector.vector.types.VectorFloat; /** - * Query-time scorer for ASH vectors. + * Query-time scalar scorer for ASH vectors. * - * Multi-landmark scoring (C >= 1) following the paper’s Eq. 11 decomposition: - * for each landmark c we form q̃_c = A (q - μ_c) = (Aq) - (Aμ_c) once per query, - * then score each vector using its stored landmark id. + *

    + * Multi-landmark scoring follows the same decomposition used by the 1-bit ASH path: + * for each landmark c we form q̃_c = A(q - μ_c) = Aq - Aμ_c once per query, then + * score each vector using its stored landmark id. + *

    + * + *

    + * For 1-bit ASH, the packed sign bits are interpreted as values in {-1,+1}. For + * multibit ASH, sign bits plus packed extra bits reconstruct the centered scalar + * code used by the encoder: + *

    + * + *
    + *   code_j = ((sign_j << exBits) + extra_j) - (2^exBits - 0.5)
    + * 
    + * + *

    + * This exactly matches the C++ scalar reconstruction formula and keeps SIMD/block + * paths optional correctness optimizations outside this reference scorer. + *

    * * NOTE: Only DOT_PRODUCT is supported for now. */ @@ -54,7 +71,9 @@ public ASHScorer(AsymmetricHashing ash) { : "Stiefel rows must equal quantizedDim"; } - public ASHScoreFunction scoreFunctionFor(VectorFloat query, VectorSimilarityFunction similarityFunction) { + public ASHScoreFunction scoreFunctionFor( + VectorFloat query, + VectorSimilarityFunction similarityFunction) { if (similarityFunction != VectorSimilarityFunction.DOT_PRODUCT) { throw new UnsupportedOperationException( "ASH scorer supports DOT_PRODUCT only (requested " + similarityFunction + ")" @@ -67,6 +86,81 @@ public ASHScoreFunction scoreFunctionFor(VectorFloat query, VectorSimilarityF * Scorer for dot product approximation. */ private ASHScoreFunction dotProductScoreFunctionFor(VectorFloat query) { + QueryPrecompute qp = precomputeQuery(query); + + if (ash.bitsPerDimension == 1) { + return oneBitDotProductScoreFunction(qp); + } + + return multibitDotProductScoreFunction(qp); + } + + private ASHScoreFunction oneBitDotProductScoreFunction(QueryPrecompute qp) { + final int d = qp.d; + final int C = qp.C; + final int words = AsymmetricHashing.QuantizedVector.wordsForDims(d); + + return (AsymmetricHashing.QuantizedVector v) -> { + final int c = v.landmark & 0xFF; // unsigned [0,C) + assert c < C : "Invalid landmark id " + c + " for landmarkCount=" + C; + + // maskedAdd = , where b ∈ {0,1}^d is stored as packed longs. + float maskedAdd = 0f; + + final int tildeBase = c * d; + final long[] bits = v.binaryVector; + int dimBase = 0; + for (int w = 0; w < words && dimBase < d; w++, dimBase += 64) { + long word = bits[w]; + while (word != 0L) { + int bit = Long.numberOfTrailingZeros(word); + int idx = dimBase + bit; + if (idx < d) { + maskedAdd += qp.tildeQPool[tildeBase + idx]; + } + word &= (word - 1); + } + } + + // 1-bit ASH dot-product approximation: + // scale * (2 - ) + + ( - ||μ_c||²) + return v.scale * (2f * maskedAdd - qp.sumTildeQByLandmark[c]) + + qp.dotQMuByLandmark[c] + + v.offset; + }; + } + + private ASHScoreFunction multibitDotProductScoreFunction(QueryPrecompute qp) { + final int d = qp.d; + final int C = qp.C; + final int exBits = ash.bitsPerDimension - 1; + final int signMagnitudeOffset = 1 << exBits; + final float centerBias = -(signMagnitudeOffset - 0.5f); + + return (AsymmetricHashing.QuantizedVector v) -> { + final int c = v.landmark & 0xFF; // unsigned [0,C) + assert c < C : "Invalid landmark id " + c + " for landmarkCount=" + C; + + final int tildeBase = c * d; + final long[] signBits = v.binaryVector; + final byte[] extraBits = v.extraBits; + + float ip = 0f; + for (int j = 0; j < d; j++) { + int sign = AsymmetricHashing.QuantizedVector.getBit(signBits, j) ? 1 : 0; + int extra = AsymmetricHashing.QuantizedVector.readExtraCode(extraBits, j, exBits); + + // Matches C++: + // (sign << exBits) + extra - (2^exBits - 0.5) + float code = (float) ((sign << exBits) + extra) + centerBias; + ip += qp.tildeQPool[tildeBase + j] * code; + } + + return v.scale * ip + qp.dotQMuByLandmark[c] + v.offset; + }; + } + + private QueryPrecompute precomputeQuery(VectorFloat query) { final int d = ash.quantizedDim; final int D = ash.originalDimension; final int C = ash.landmarkCount; @@ -78,9 +172,11 @@ private ASHScoreFunction dotProductScoreFunctionFor(VectorFloat query) { .getInstance() .getVectorUtilSupport(); - // Materialize query once (avoid VectorFloat.get in inner loops) + // Materialize query once to avoid VectorFloat.get in inner loops. final float[] qArr = new float[D]; - for (int k = 0; k < D; k++) qArr[k] = query.get(k); + for (int k = 0; k < D; k++) { + qArr[k] = query.get(k); + } // Compute Aq once per query: // qProj[j] = @@ -96,8 +192,6 @@ private ASHScoreFunction dotProductScoreFunctionFor(VectorFloat query) { // tildeQ_c = Aq - Aμ_c // sumTildeQc = sum(Aq) - sum(Aμ_c) // dotQMu_c = - // Pooled storage for q̃_c vectors: - // tildeQPool[c*d + j] == q̃_c[j] final float[] tildeQPool = new float[C * d]; final float[] sumTildeQByLandmark = new float[C]; final float[] dotQMuByLandmark = new float[C]; @@ -113,45 +207,28 @@ private ASHScoreFunction dotProductScoreFunctionFor(VectorFloat query) { } } - return (AsymmetricHashing.QuantizedVector v) -> { - final int c = v.landmark & 0xFF; // unsigned [0,C) - assert c < C : "Invalid landmark id " + c + " for landmarkCount=" + C; - - // scale = ||x − μ_c|| / √d (precomputed at encode-time) - final float scale = v.scale; - - // offset = − ||μ_c||² (precomputed at encode-time) - final float offset = v.offset; - - // maskedAdd = - // b ∈ {0,1}^d stored as packed longs - float maskedAdd = 0f; - - final int tildeBase = c * d; - final float sumTildeQ = sumTildeQByLandmark[c]; - final float dotQMu = dotQMuByLandmark[c]; - - final long[] bits = v.binaryVector; - int dimBase = 0; - for (int w = 0; w < bits.length && dimBase < d; w++, dimBase += 64) { - long word = bits[w]; - while (word != 0L) { - int bit = Long.numberOfTrailingZeros(word); - int idx = dimBase + bit; - if (idx < d) { - maskedAdd += tildeQPool[tildeBase + idx]; - } - word &= (word - 1); - } - } + return new QueryPrecompute(d, C, tildeQPool, sumTildeQByLandmark, dotQMuByLandmark); + } - // ASH dot-product approximation (multi-landmark, {0,1} encoding): - // - // scale * (2⟨q̃_c, b⟩ − ⟨q̃_c, 1⟩) + ⟨q, μ_c⟩ + (⟨x, μ_c⟩ − ||μ_c||²) - // - // where q̃_c = A(q − μ_c) is precomputed once per query and per landmark c. - return scale * (2f * maskedAdd - sumTildeQ) + dotQMu + offset; - }; + private static final class QueryPrecompute { + final int d; + final int C; + final float[] tildeQPool; // [C * d] + final float[] sumTildeQByLandmark; // [C] + final float[] dotQMuByLandmark; // [C] + + QueryPrecompute( + int d, + int C, + float[] tildeQPool, + float[] sumTildeQByLandmark, + float[] dotQMuByLandmark) { + this.d = d; + this.C = C; + this.tildeQPool = tildeQPool; + this.sumTildeQByLandmark = sumTildeQByLandmark; + this.dotQMuByLandmark = dotQMuByLandmark; + } } @FunctionalInterface @@ -159,3 +236,4 @@ public interface ASHScoreFunction { float similarityTo(AsymmetricHashing.QuantizedVector vector2); } } + diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHVectors.java b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHVectors.java index 9759322ad..b86cd45a2 100644 --- a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHVectors.java +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHVectors.java @@ -84,20 +84,20 @@ static AshBlockKernel fromProperty() { } // Cached scalar headers (avoid object chasing in blocked scorer) - private final float[] scales; // scale_i = ||x_i − μ|| / sqrt(d) + private final float[] scales; // scale_i = ||x_i − μ|| / ||code_i|| private final float[] offsets; // offset_i = − ||μ||^2 private final byte[] landmarks; - // Quantized dimensions + // Projected dimensions and sign-bit word count private final int d; private final int words; - // Packed block-column-major bits (built lazily per blockSize) + // Packed block-column-major sign bits (built lazily per blockSize, 1-bit path only) private int packedBlockSize = -1; private long[] packedBits = null; private int packedBlockCount = 0; - // Flat row-major bits for single-vector SIMD (built lazily) + // Flat row-major sign bits for single-vector SIMD (built lazily, 1-bit path only) // Layout: [v0_w0, v0_w1, ... v0_wN, v1_w0, ...] private volatile long[] flatPackedVectors = null; @@ -126,7 +126,6 @@ public ASHVectors(AsymmetricHashing ash, offsets[i] = v.offset; landmarks[i] = v.landmark; } - } // Private constructor that trusts prebuilt arrays (for vector landmark sorting) @@ -183,7 +182,7 @@ public void write(IndexWriter out, int version) throws IOException { // Write vectors for (var v : compressedVectors) { - v.write(out, ash.quantizedDim); + v.write(out, ash.quantizedDim, ash.bitsPerDimension); } } @@ -198,7 +197,7 @@ public static ASHVectors load(RandomAccessReader in) throws IOException { var compressedVectors = new AsymmetricHashing.QuantizedVector[size]; for (int i = 0; i < size; i++) { compressedVectors[i] = - AsymmetricHashing.QuantizedVector.load(in, ash.quantizedDim); + AsymmetricHashing.QuantizedVector.load(in, ash.quantizedDim, ash.bitsPerDimension); } return new ASHVectors(ash, compressedVectors); @@ -213,6 +212,13 @@ public ScoreFunction.ApproximateScoreFunction scoreFunctionFor( throw new UnsupportedOperationException("ASH scorer supports DOT_PRODUCT only"); } + // Multibit scoring is currently routed through the scalar/reference scorer. + // The packed SIMD kernels below are sign-bit-only and remain enabled for 1-bit ASH. + if (ash.bitsPerDimension != 1) { + final ASHScorer.ASHScoreFunction f = scorer.scoreFunctionFor(query, similarityFunction); + return node -> f.similarityTo(compressedVectors[node]); + } + final AshSingleKernel kernel = AshSingleKernel.fromProperty(); final VectorUtilSupport vecUtil = VectorizationProvider.getInstance().getVectorUtilSupport(); @@ -236,7 +242,7 @@ public ScoreFunction.ApproximateScoreFunction scoreFunctionFor( } // ============================================================================ - // ASH single vector scoring (Optimized Zero-Copy) + // ASH single vector scoring (Optimized Zero-Copy, 1-bit only) // ============================================================================ private final class SimdASHScoreFunction implements ScoreFunction.ApproximateScoreFunction { @@ -251,6 +257,10 @@ private final class SimdASHScoreFunction implements ScoreFunction.ApproximateSco private final int words; SimdASHScoreFunction(QueryPrecompute qp) { + if (ash.bitsPerDimension != 1) { + throw new IllegalStateException("SIMD ASH scorer supports only 1-bit ASH"); + } + this.qp = qp; this.vecUtil = VectorizationProvider.getInstance().getVectorUtilSupport(); this.d = qp.d; @@ -270,7 +280,7 @@ public float similarityTo(int node2) { final int qOffset = c * d; final int packedBase = node2 * words; - // 2. Run existing optimized v4 Kernel + // Run existing optimized 1-bit kernel. final float maskedAdd = vecUtil.ashMaskedAdd_512( tildeQPool, qOffset, @@ -283,97 +293,11 @@ public float similarityTo(int node2) { return scales[node2] * (2f * maskedAdd - qp.sumTildeQ[c]) + qp.dotQMu[c] + offsets[node2]; } -// @Override -// public float similarityTo(int node2) { -// // A. Identify Landmark (Supports C >= 1) -// // 'landmarks' is a field in the outer ASHVectors class -// final int c = landmarks[node2] & 0xFF; -// final int qOffset = c * d; -// -// // B. Identify Vector Location in the flat array -// // Integer multiply is significantly faster than object pointer chasing -// final int packedBase = node2 * words; -// -// // C. Run Kernel (Zero-Copy) -// // Note: Ensure your VectorUtilSupport has the 'ashMaskedAddFlat' method signature -// final float maskedAdd = vecUtil.ashMaskedAddFlatOptimized( -// tildeQPool, -// qOffset, -// allPackedVectors, -// packedBase, -// d, -// words -// ); -// -// // D. Linear Combination -// // scales[] and offsets[] are fields in the outer ASHVectors class -// return scales[node2] * (2f * maskedAdd - qp.sumTildeQ[c]) -// + qp.dotQMu[c] -// + offsets[node2]; -// } - @Override public boolean isExact() { return false; } } -// -// // ============================================================================ -// // ASH single vector scoring -// // ============================================================================ -// -// private final class SimdASHScoreFunction implements ScoreFunction.ApproximateScoreFunction { -// private final QueryPrecompute qp; -// private final VectorUtilSupport vecUtil; -// -// private final int d; -// private final int words; -// -// // Scratch for current landmark -// private final float[] tildeQScratch; -// private int lastC = -1; -// -// SimdASHScoreFunction(QueryPrecompute qp) { -// this.qp = qp; -// this.vecUtil = VectorizationProvider.getInstance().getVectorUtilSupport(); -// this.d = qp.d; -// this.words = AsymmetricHashing.QuantizedVector.wordsForDims(d); -// this.tildeQScratch = new float[d]; -// } -// -// @Override -// public float similarityTo(int node2) { -// final int c = landmarks[node2] & 0xFF; -// final int base = c * d; -// -// if (c != lastC) { -// System.arraycopy(qp.tildeQPool, base, tildeQScratch, 0, d); -// lastC = c; -// } -// -// // by-vector bits -// final long[] bits = compressedVectors[node2].binaryVector; -// -// // Route masked add through VectorUtilSupport. -// // Default impl is scalar; Panama can override for SIMD. -// final float maskedAdd = vecUtil.ashMaskedAddAllWords( -// tildeQScratch, -// d, -// bits, -// 0, // packedBase (by-vector, so base is 0) -// words -// ); -// -// return scales[node2] * (2f * maskedAdd - qp.sumTildeQ[c]) -// + qp.dotQMu[c] -// + offsets[node2]; -// } -// -// @Override -// public boolean isExact() { -// return false; -// } -// } // ============================================================================ // ASH block-oriented scoring @@ -486,6 +410,12 @@ public ASHBlockScorer blockScorerFor( throw new IllegalArgumentException("blockSize must be > 0"); } + // Multibit block scoring uses the scalar/reference scorer for now. The + // optimized block kernels below assume 1-bit sign reconstruction. + if (ash.bitsPerDimension != 1) { + return blockScorerFor(query, similarityFunction); + } + final AshBlockKernel kernel = AshBlockKernel.fromProperty(); // Compute query precompute ONCE (shared by scalar + simd) @@ -524,6 +454,9 @@ public ASHBlockScorer blockScorerFor( } private void ensurePackedBits(int blockSize) { + if (ash.bitsPerDimension != 1) { + throw new IllegalStateException("Packed sign-bit block cache supports only 1-bit ASH"); + } if (blockSize <= 0) { throw new IllegalArgumentException("blockSize must be > 0"); } @@ -552,13 +485,16 @@ private void ensurePackedBits(int blockSize) { } /** - * Returns a monolithic long[] containing all quantized bits. + * Returns a monolithic long[] containing all 1-bit sign payloads. *

    * This method is thread-safe and computes the view lazily. * Use this for single-vector SIMD scoring to avoid pointer chasing. *

    */ public long[] getFlatPackedVectors() { + if (ash.bitsPerDimension != 1) { + throw new IllegalStateException("Flat packed sign-bit cache supports only 1-bit ASH"); + } if (flatPackedVectors == null) { synchronized (this) { if (flatPackedVectors == null) { @@ -586,11 +522,11 @@ private long[] createFlatPackedVectors() { } /** - * Block-oriented ASH scorer using register-local accumulation. + * Block-oriented 1-bit ASH scorer using register-local accumulation. * *

    - * This is the preferred block implementation. It avoids temporary - * per-block arrays and accumulates the masked-add term directly + * This is the preferred scalar block implementation for 1-bit ASH. It avoids + * temporary per-block arrays and accumulates the masked-add term directly * into scalar registers. *

    */ @@ -667,7 +603,7 @@ public void scoreRange(int start, int count, float[] out) { } /** - * Block-oriented ASH scorer using SIMD masked-loads with register-local + * Block-oriented 1-bit ASH scorer using SIMD masked-loads with register-local * accumulation. * *

    @@ -712,9 +648,6 @@ private static final class SimdASHBlockScorer implements ASHBlockScorer { private final VectorUtilSupport vecUtil; - private static final java.util.concurrent.atomic.AtomicBoolean PRINTED_MIXED_ONCE = - new java.util.concurrent.atomic.AtomicBoolean(false); - SimdASHBlockScorer( AsymmetricHashing.QuantizedVector[] vectors, float[] scales, @@ -777,23 +710,6 @@ public void scoreRange(int start, int count, float[] out) { runLen++; } - // Catch mixed-landmark calls for debug. TODO remove later -// if (runLen != maxLen && PRINTED_MIXED_ONCE.compareAndSet(false, true)) { -// int nextLm = (ord + runLen < end) ? (landmarks[ord + runLen] & 0xFF) : -1; -// System.out.println( -// "\t[ASH SIMD debug] mixed-landmark boundary inside packed-block slice: " + -// "start=" + start + -// " count=" + count + -// " ord=" + ord + -// " blockId=" + blockId + -// " laneStart=" + laneStart + -// " runLen=" + runLen + -// " maxLen=" + maxLen + -// " c=" + c + -// " nextC=" + nextLm -// ); -// } - assert runLen >= 1 && runLen <= maxLen; assert laneStart >= 0 && laneStart + runLen <= blockSize; @@ -819,9 +735,6 @@ public void scoreRange(int start, int count, float[] out) { maskedAdds ); - - - for (int lane = 0; lane < runLen; lane++) { int idx = ord + lane; float m = maskedAdds[lane]; @@ -838,6 +751,10 @@ public void scoreRange(int start, int count, float[] out) { } private QueryPrecompute precomputeQuery(VectorFloat query) { + if (ash.bitsPerDimension != 1) { + throw new IllegalStateException("ASHVectors precomputeQuery supports only 1-bit ASH"); + } + final int d = ash.quantizedDim; final int D = ash.originalDimension; final int C = ash.landmarkCount; @@ -884,8 +801,8 @@ static final class QueryPrecompute { final int d; final int C; final float[] tildeQPool; // [C * d] - final float[] sumTildeQ; // [C] - final float[] dotQMu; // [C] + final float[] sumTildeQ; // [C] + final float[] dotQMu; // [C] QueryPrecompute(int d, int C, float[] tildeQPool, diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/AsymmetricHashing.java b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/AsymmetricHashing.java index b5764fa88..de28de8f9 100644 --- a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/AsymmetricHashing.java +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/AsymmetricHashing.java @@ -36,32 +36,19 @@ import java.util.Objects; import java.util.concurrent.ForkJoinPool; import java.util.Random; -import org.apache.commons.math3.linear.MatrixUtils; import org.apache.commons.math3.linear.RealMatrix; import org.apache.commons.math3.linear.Array2DRowRealMatrix; -import org.apache.commons.math3.linear.SingularValueDecomposition; import org.netlib.util.intW; -import java.io.BufferedInputStream; -import java.io.DataInputStream; -import java.io.FileInputStream; -import java.io.IOException; - -import java.io.BufferedOutputStream; -import java.io.FileOutputStream; -import java.nio.ByteBuffer; -import java.nio.ByteOrder; - -import static io.github.jbellis.jvector.quantization.KMeansPlusPlusClusterer.UNWEIGHTED; - /** * Asymmetric Hashing (ASH) for float vectors. - * Encodes each vector into a fixed-length binary code using a learned or random - * orthonormal projection and sign thresholding. + * Encodes each vector into a fixed-length code using a learned or random + * orthonormal projection. The 1-bit case stores only sign bits; multibit ASH + * stores sign bits plus packed extra magnitude bits per projected dimension. */ public class AsymmetricHashing implements VectorCompressor, Accountable { public static final int ITQ = 1, RANDOM = 2; - private static final int MAGIC = 0x75EC4013; + private static final int MAGIC = 0x75EC4014; // --------------------------------------------------------------------- // Training configuration (reference defaults; TODO tune later) @@ -75,19 +62,13 @@ public class AsymmetricHashing implements VectorCompressor − ||μ||_2^2 + // - scale: fp16 (16 bits), where scale = ||x − μ|| / ||code|| + // - offset: fp16 (16 bits), where offset = − ||μ||_2^2 // - landmark id: byte (8 bits) in [0, C) - // - // NOTE: residualNorm (= ||x − μ||) is still computed during encoding for Eq. 6, - // but it is not stored explicitly once scale is stored. public static final int HEADER_BITS = (Short.BYTES + Short.BYTES + Byte.BYTES) * 8; // 40 bits currently @@ -98,28 +79,34 @@ public class AsymmetricHashing implements VectorCompressor[] landmarks; // One entry per landmark private final float[] landmarkNormSq; - /** Original (uncompressed) dimensionality */ + /** Original (uncompressed) dimensionality. */ public final int originalDimension; - /** Total bits per encoded vector (header + body) */ + /** Total bits per encoded vector (header + body). */ public final int encodedBits; - /** Number of bits in the binary body */ + /** Number of bits in the encoded body. */ + public final int bodyBits; + + /** Number of projected dimensions in the ASH body. */ public final int quantizedDim; - /** Optimizer / learning mode */ + /** Bits stored per projected dimension. */ + public final int bitsPerDimension; + + /** Optimizer / learning mode. */ public final int optimizer; - /** Learned or random Stiefel transform */ + /** Learned or random Stiefel transform. */ public final StiefelTransform stiefelTransform; /** @@ -138,7 +125,7 @@ public class AsymmetricHashing implements VectorCompressor[] landmarks, StiefelTransform stiefelTransform, @@ -176,13 +164,19 @@ private AsymmetricHashing(int originalDim, this.originalDimension = originalDim; this.encodedBits = encodedBits; this.quantizedDim = quantizedDim; + this.bitsPerDimension = bitsPerDimension; + this.bodyBits = encodedBits - HEADER_BITS; - // Defensive check for consistency - if (encodedBits - HEADER_BITS != quantizedDim) { + validateBitsPerDimension(bitsPerDimension); + + long expectedBodyBits = (long) quantizedDim * (long) bitsPerDimension; + if (bodyBits != expectedBodyBits) { throw new IllegalArgumentException( "Invalid ASH configuration: encodedBits=" + encodedBits + ", quantizedDim=" + quantizedDim + - ", HEADER_BITS=" + HEADER_BITS + ", bitsPerDimension=" + bitsPerDimension + + ", HEADER_BITS=" + HEADER_BITS + + ", bodyBits=" + bodyBits ); } @@ -238,10 +232,14 @@ private AsymmetricHashing(int originalDim, if (quantizedDim < 1) { throw new IllegalArgumentException("quantizedDim must be > 0"); } + if (quantizedDim > originalDimension) { + throw new IllegalArgumentException( + "quantizedDim (" + quantizedDim + ") exceeds original dimension (" + originalDimension + ")"); + } } /** - * Initialize ASH index-wide parameters. + * Initialize ASH index-wide parameters using the legacy 1-bit body layout. * * @param ravv the vectors to quantize * @param optimizer the optimizer to use @@ -251,24 +249,40 @@ public static AsymmetricHashing initialize(RandomAccessVectorValues ravv, int optimizer, int encodedBits, int landmarkCount) throws IOException { + return initialize(ravv, optimizer, encodedBits, landmarkCount, DEFAULT_BITS_PER_DIMENSION); + } - final int quantizedDim = encodedBits - HEADER_BITS; + /** + * Initialize ASH index-wide parameters. + * + * @param ravv the vectors to quantize + * @param optimizer the optimizer to use + * @param encodedBits the number of bits used to encode vector, including the header + * @param landmarkCount number of landmarks + * @param bitsPerDimension number of stored bits per projected dimension + */ + public static AsymmetricHashing initialize(RandomAccessVectorValues ravv, + int optimizer, + int encodedBits, + int landmarkCount, + int bitsPerDimension) throws IOException { + validateBitsPerDimension(bitsPerDimension); if (landmarkCount < 1 || landmarkCount > 64) { throw new IllegalArgumentException( "landmarkCount must be in [1,64], got " + landmarkCount); } + var ravvCopy = ravv.threadLocalSupplier().get(); + int originalDim = ravvCopy.getVector(0).length(); + final int quantizedDim = validateEncodedBits(encodedBits, HEADER_BITS, originalDim, bitsPerDimension); + System.out.println( "\tASH initialized with landmarkCount=" + landmarkCount + - ", quantizedDim=" + quantizedDim + ", quantizedDim=" + quantizedDim + + ", bitsPerDimension=" + bitsPerDimension ); - var ravvCopy = ravv.threadLocalSupplier().get(); - int originalDim = ravvCopy.getVector(0).length(); - // payload bits are always 64-bit aligned by validateEncodedBits() - validateEncodedBits(encodedBits, HEADER_BITS, originalDim); - // NOTE: points are treated as read-only by KMeansPlusPlusClusterer. // Materialize and L2-normalize points VectorFloat[] points = new VectorFloat[ravvCopy.size()]; @@ -305,7 +319,6 @@ public static AsymmetricHashing initialize(RandomAccessVectorValues ravv, long kmStart = System.nanoTime(); // KMeans++ in JVector returns centroids packed as one big vector [C * D] - // KMeansPlusPlusClusterer km = new KMeansPlusPlusClusterer(points, landmarkCount); MiniBatchKMeansClusterer km = new MiniBatchKMeansClusterer(points, landmarkCount, 1024); VectorFloat packed = km.cluster(/*unweightedIterations*/ 20, /*anisotropicIterations*/ 0); @@ -340,7 +353,7 @@ public static AsymmetricHashing initialize(RandomAccessVectorValues ravv, stiefelTransform = runItqTrainer( td.xTrainNorm, quantizedDim, - DEFAULT_BITS_PER_DIMENSION, + bitsPerDimension, rng, TRAINING_ITERS ); @@ -360,6 +373,7 @@ public static AsymmetricHashing initialize(RandomAccessVectorValues ravv, originalDim, encodedBits, quantizedDim, + bitsPerDimension, optimizer, landmarks, stiefelTransform, @@ -368,25 +382,24 @@ public static AsymmetricHashing initialize(RandomAccessVectorValues ravv, ); } + private static void validateBitsPerDimension(int bitsPerDimension) { + if (bitsPerDimension < 1 || bitsPerDimension > 9) { + throw new IllegalArgumentException( + "bitsPerDimension must be in [1,9], got " + bitsPerDimension); + } + } + /** - * Validates that the requested {@code encodedBits} value is compatible with - * ASH’s block-SIMD scoring kernel. - * - *

    - * For performance reasons, the binary payload size - * {@code (encodedBits - headerBits)} must be a multiple of 64. Non-aligned - * payloads introduce partial-word tails that significantly degrade SIMD - * efficiency. - *

    - * - * @throws IllegalArgumentException if the payload bit count is invalid or - * not 64-bit aligned + * Validates encodedBits and returns the projected dimension count. */ - private static void validateEncodedBits( + private static int validateEncodedBits( int encodedBits, int headerBits, - int originalDim + int originalDim, + int bitsPerDimension ) { + validateBitsPerDimension(bitsPerDimension); + int payloadBits = encodedBits - headerBits; if (payloadBits < 1) { @@ -396,22 +409,27 @@ private static void validateEncodedBits( ); } -// if ((payloadBits & 63) != 0) { -// throw new IllegalArgumentException( -// "Invalid encodedBits=" + encodedBits + -// ". ASH requires (encodedBits - headerBits) to be a multiple of 64 " + -// "for aligned binary payload. " + -// "Got payloadBits=" + payloadBits + -// " (headerBits=" + headerBits + ")." -// ); -// } + if (payloadBits % bitsPerDimension != 0) { + throw new IllegalArgumentException( + "Invalid encodedBits=" + encodedBits + + ". ASH requires (encodedBits - headerBits) to be divisible by bitsPerDimension. " + + "Got payloadBits=" + payloadBits + + ", bitsPerDimension=" + bitsPerDimension + "." + ); + } - if (payloadBits > originalDim) { + int projectedDims = payloadBits / bitsPerDimension; + if (projectedDims < 1) { + throw new IllegalArgumentException("ASH projected dimensions must be > 0"); + } + if (projectedDims > originalDim) { throw new IllegalArgumentException( - "Invalid ASH payloadBits=" + payloadBits + + "Invalid ASH projectedDims=" + projectedDims + " exceeds originalDim=" + originalDim ); } + + return projectedDims; } /** @@ -498,15 +516,19 @@ public static AsymmetricHashing load(RandomAccessReader in) throws IOException { int originalDimension = in.readInt(); int encodedBits = in.readInt(); - validateEncodedBits(encodedBits, HEADER_BITS, originalDimension); int quantizedDim = in.readInt(); + int bitsPerDimension = in.readInt(); - // HEADER_BITS compatibility check - int expectedQuantizedDim = encodedBits - HEADER_BITS; + int expectedQuantizedDim = validateEncodedBits( + encodedBits, + HEADER_BITS, + originalDimension, + bitsPerDimension); if (quantizedDim != expectedQuantizedDim) { throw new IOException( "ASH format mismatch: encodedBits=" + encodedBits + ", quantizedDim=" + quantizedDim + + ", bitsPerDimension=" + bitsPerDimension + ", but runtime HEADER_BITS=" + HEADER_BITS + " implies quantizedDim=" + expectedQuantizedDim ); @@ -546,6 +568,7 @@ public static AsymmetricHashing load(RandomAccessReader in) throws IOException { originalDimension, encodedBits, quantizedDim, + bitsPerDimension, optimizer, landmarks, stiefelTransform, @@ -573,6 +596,7 @@ public void write(IndexWriter out, int version) throws IOException { out.writeInt(originalDimension); out.writeInt(encodedBits); out.writeInt(quantizedDim); + out.writeInt(bitsPerDimension); out.writeInt(optimizer); // Multi-landmark serialization @@ -583,7 +607,7 @@ public void write(IndexWriter out, int version) throws IOException { vectorTypeSupport.writeFloatVector(out, landmarks[c]); } - // Stiefel transform serialization (required for caching and non-RANDOM optimizers): + // Stiefel transform serialization: // Serialize A = W^T in float precision as a [d][D] row-major matrix. writeStiefelTransform(out, stiefelTransform); } @@ -592,10 +616,10 @@ public void write(IndexWriter out, int version) throws IOException { // Index-level quantization entry point // --------------------------------------------------------------------- - public void quantizeVector(VectorFloat vector, - float residualNorm, - byte landmark, - QuantizedVector dest) { + public float quantizeVector(VectorFloat vector, + float residualNorm, + byte landmark, + QuantizedVector dest) { if (quantizedDim > originalDimension) { throw new IllegalArgumentException( @@ -611,10 +635,11 @@ public void quantizeVector(VectorFloat vector, // Landmark-specific mean (C=1 → landmarks[0] == dataset mean) final VectorFloat mu = landmarks[landmark]; - QuantizedVector.quantizeTo( + return QuantizedVector.quantizeTo( vector, residualNorm, quantizedDim, + bitsPerDimension, mu, stiefelTransform, quantizer(), @@ -623,16 +648,18 @@ public void quantizeVector(VectorFloat vector, } // --------------------------------------------------------------------- - // Binary quantization strategies + // Quantization strategies // --------------------------------------------------------------------- public interface BinaryQuantizer { - void quantizeBody(VectorFloat vector, - VectorFloat mu, - float residualNorm, - int quantizedDim, - StiefelTransform stiefel, - long[] outWords); + float quantizeBody(VectorFloat vector, + VectorFloat mu, + float residualNorm, + int quantizedDim, + int bitsPerDimension, + StiefelTransform stiefel, + long[] outSignWords, + byte[] outExtraBits); } public static final class RandomBinaryQuantizer implements BinaryQuantizer { @@ -644,32 +671,40 @@ private static final class Workspace { float[] x; float[] muArr; float[] xhat; - - void ensureCapacity(int dim) { - if (x == null || x.length < dim) { - x = new float[dim]; - muArr = new float[dim]; - xhat = new float[dim]; + float[] proj; + double[] absNorm; + + void ensureCapacity(int originalDim, int quantizedDim) { + if (x == null || x.length < originalDim) { + x = new float[originalDim]; + muArr = new float[originalDim]; + xhat = new float[originalDim]; + } + if (proj == null || proj.length < quantizedDim) { + proj = new float[quantizedDim]; + absNorm = new double[quantizedDim]; } } } @Override - public void quantizeBody(VectorFloat vector, - VectorFloat mu, - float residualNorm, - int quantizedDim, - StiefelTransform stiefel, - long[] outWords) { + public float quantizeBody(VectorFloat vector, + VectorFloat mu, + float residualNorm, + int quantizedDim, + int bitsPerDimension, + StiefelTransform stiefel, + long[] outSignWords, + byte[] outExtraBits) { final float[][] A = stiefel.AFloat; final int originalDim = stiefel.cols; - // Get workspace for this thread and ensure correct sizing (originalDim may have changed) + // Get workspace for this thread and ensure correct sizing. Workspace ws = WORKSPACE.get(); - ws.ensureCapacity(originalDim); + ws.ensureCapacity(originalDim, quantizedDim); - // Alias for readability (no new allocations) + // Alias for readability (no new allocations). final float[] x = ws.x; final float[] muArr = ws.muArr; final float[] xhat = ws.xhat; @@ -679,52 +714,93 @@ public void quantizeBody(VectorFloat vector, .getInstance() .getVectorUtilSupport(); - // Copy vector and mean once into workspace + // Copy vector and mean once into workspace. for (int d = 0; d < originalDim; d++) { x[d] = vector.get(d); muArr[d] = mu.get(d); } - // ASH paper, Eq. 6, normalization + // ASH paper, Eq. 6, normalization. final float invNorm = (residualNorm > 0f) ? (1.0f / residualNorm) : 0.0f; - // Compute normalized residual x̂ + // Compute normalized residual x̂. for (int d = 0; d < originalDim; d++) { xhat[d] = (x[d] - muArr[d]) * invNorm; } - // Binarize directly from per-row projection - final int words = QuantizedVector.wordsForDims(quantizedDim); - for (int w = 0; w < words; w++) { - long bits = 0L; - int base = w << 6; - int rem = Math.min(64, quantizedDim - base); - - for (int j = 0; j < rem; j++) { - int bitIndex = base + j; - float acc = vecUtil.ashDotRow(A[bitIndex], xhat); - if (acc > 0.0f) { - bits |= (1L << j); + if (bitsPerDimension == 1) { + // Binarize directly from per-row projection. + final int words = QuantizedVector.wordsForDims(quantizedDim); + for (int w = 0; w < words; w++) { + long bits = 0L; + int base = w << 6; + int rem = Math.min(64, quantizedDim - base); + + for (int j = 0; j < rem; j++) { + int bitIndex = base + j; + float acc = vecUtil.ashDotRow(A[bitIndex], xhat); + if (acc > 0.0f) { + bits |= (1L << j); + } } + outSignWords[w] = bits; + } + + return (float) Math.sqrt(quantizedDim); + } + + final int exBits = bitsPerDimension - 1; + final int maxCode = (1 << exBits) - 1; + final float[] proj = ws.proj; + final double[] absNorm = ws.absNorm; + + double projNorm2 = 0.0; + for (int j = 0; j < quantizedDim; j++) { + float acc = vecUtil.ashDotRow(A[j], xhat); + proj[j] = acc; + projNorm2 += (double) acc * (double) acc; + if (acc >= 0.0f) { + QuantizedVector.setBit(outSignWords, j); } - outWords[w] = bits; } + + final double invProjNorm = 1.0 / Math.max(Math.sqrt(projNorm2), 1e-20); + for (int j = 0; j < quantizedDim; j++) { + absNorm[j] = Math.abs((double) proj[j]) * invProjNorm; + } + + final double t = computeOptimalScalingFactor(absNorm, quantizedDim, bitsPerDimension); + + double codeNorm2 = 0.0; + for (int j = 0; j < quantizedDim; j++) { + int q = Math.min((int) (t * absNorm[j] + K_EPS), maxCode); + double mag = q + 0.5; + codeNorm2 += mag * mag; + + int extraCode = (proj[j] < 0.0f) ? ((~q) & maxCode) : q; + QuantizedVector.writeExtraCode(outExtraBits, j, exBits, extraCode); + } + + return (float) Math.sqrt(Math.max(codeNorm2, 1e-20)); } } public static final class ItqBinaryQuantizer implements BinaryQuantizer { - // Same binarization math for all optimizers; only the learned transform differs. - // TODO consolidate quantizeBody across learners/random + // Same quantization math for all optimizers; only the learned transform differs. + // TODO consolidate quantizeBody across learners/random. private final RandomBinaryQuantizer delegate = new RandomBinaryQuantizer(); @Override - public void quantizeBody(VectorFloat vector, - VectorFloat mu, - float residualNorm, - int quantizedDim, - StiefelTransform stiefel, - long[] outWords) { - delegate.quantizeBody(vector, mu, residualNorm, quantizedDim, stiefel, outWords); + public float quantizeBody(VectorFloat vector, + VectorFloat mu, + float residualNorm, + int quantizedDim, + int bitsPerDimension, + StiefelTransform stiefel, + long[] outSignWords, + byte[] outExtraBits) { + return delegate.quantizeBody(vector, mu, residualNorm, quantizedDim, bitsPerDimension, + stiefel, outSignWords, outExtraBits); } } @@ -733,63 +809,79 @@ public void quantizeBody(VectorFloat vector, // --------------------------------------------------------------------- public static class QuantizedVector { - public float scale; // d^{-1/2} ||x_i - μ_i*||_2 + public float scale; // ||x_i - μ_i*||_2 / ||encoded body||_2 public float offset; // offset_i = - ||μ_i*||^2 public byte landmark; // c_i*, unsigned [0, C) - public long[] binaryVector; // bin(x̂_i) + public long[] binaryVector; // sign bits; bit=1 means positive code value + public byte[] extraBits; // packed extra magnitude/sign-complement bits for multibit ASH private QuantizedVector(float scale, float offset, byte landmark, - long[] binaryVector) { + long[] binaryVector, + byte[] extraBits) { this.scale = scale; this.offset = offset; this.landmark = landmark; this.binaryVector = binaryVector; + this.extraBits = extraBits; } public static int wordsForDims(int quantizedDim) { return (quantizedDim + 63) >>> 6; } + public static int extraBytesForDims(int quantizedDim, int bitsPerDimension) { + int exBits = bitsPerDimension - 1; + if (exBits <= 0) { + return 0; + } + return (quantizedDim * exBits + 7) >>> 3; + } + public static QuantizedVector createEmpty(int quantizedDim) { + return createEmpty(quantizedDim, DEFAULT_BITS_PER_DIMENSION); + } + + public static QuantizedVector createEmpty(int quantizedDim, int bitsPerDimension) { return new QuantizedVector( Float.NaN, Float.NaN, (byte) 0, - new long[wordsForDims(quantizedDim)] + new long[wordsForDims(quantizedDim)], + new byte[extraBytesForDims(quantizedDim, bitsPerDimension)] ); } public static int serializedSizeBytes(int quantizedDim) { + return serializedSizeBytes(quantizedDim, DEFAULT_BITS_PER_DIMENSION); + } + + public static int serializedSizeBytes(int quantizedDim, int bitsPerDimension) { return Short.BYTES + Short.BYTES + Byte.BYTES - + wordsForDims(quantizedDim) * Long.BYTES; + + wordsForDims(quantizedDim) * Long.BYTES + + extraBytesForDims(quantizedDim, bitsPerDimension); } /** - * Writes only the binary body into {@code dest.binaryVector}. + * Writes only the encoded body into {@code dest}. * - *

    Header fields ({@code scale}, {@code offset}, {@code landmark}) must be - * set by the caller prior to calling this method.

    + *

    Header fields ({@code offset}, {@code landmark}) must be set by the caller + * before calling this method. {@code scale} is set by the caller after this method + * returns the encoded body norm.

    * - * @param residualNorm ||x - μ||_2, required for Eq. 6 normalization during binarization + * @param residualNorm ||x - μ||_2, required for Eq. 6 normalization during encoding + * @return norm of the stored centered code body */ - static void quantizeTo(VectorFloat vector, - float residualNorm, - int quantizedDim, - VectorFloat mu, - StiefelTransform stiefel, - BinaryQuantizer quantizer, - QuantizedVector dest) { - - // encodeTo() must have initialized header - if (Float.isNaN(dest.scale) || Float.isNaN(dest.offset)) { - throw new IllegalStateException( - "QuantizedVector header not initialized before quantizeTo()" - ); - } + static float quantizeTo(VectorFloat vector, + float residualNorm, + int quantizedDim, + int bitsPerDimension, + VectorFloat mu, + StiefelTransform stiefel, + BinaryQuantizer quantizer, + QuantizedVector dest) { - assert Float.isFinite(dest.scale) : "scale is not finite"; assert Float.isFinite(dest.offset) : "offset is not finite"; int words = wordsForDims(quantizedDim); @@ -797,18 +889,33 @@ static void quantizeTo(VectorFloat vector, throw new IllegalArgumentException("binaryVector too short"); } + int extraBytes = extraBytesForDims(quantizedDim, bitsPerDimension); + if (dest.extraBits == null || dest.extraBits.length < extraBytes) { + throw new IllegalArgumentException("extraBits too short"); + } + Arrays.fill(dest.binaryVector, 0, words, 0L); - quantizer.quantizeBody( + if (extraBytes > 0) { + Arrays.fill(dest.extraBits, 0, extraBytes, (byte) 0); + } + + return quantizer.quantizeBody( vector, mu, residualNorm, quantizedDim, + bitsPerDimension, stiefel, - dest.binaryVector + dest.binaryVector, + dest.extraBits ); } public void write(IndexWriter out, int quantizedDim) throws IOException { + write(out, quantizedDim, DEFAULT_BITS_PER_DIMENSION); + } + + public void write(IndexWriter out, int quantizedDim, int bitsPerDimension) throws IOException { writeFloat16(out, scale); writeFloat16(out, offset); out.writeByte(landmark); @@ -817,10 +924,21 @@ public void write(IndexWriter out, int quantizedDim) throws IOException { for (int i = 0; i < words; i++) { out.writeLong(binaryVector[i]); } + + int extraBytes = extraBytesForDims(quantizedDim, bitsPerDimension); + for (int i = 0; i < extraBytes; i++) { + out.writeByte(extraBits[i]); + } } public static QuantizedVector load(RandomAccessReader in, int quantizedDim) throws IOException { + return load(in, quantizedDim, DEFAULT_BITS_PER_DIMENSION); + } + + public static QuantizedVector load(RandomAccessReader in, + int quantizedDim, + int bitsPerDimension) throws IOException { float scale = readFloat16(in); float offset = readFloat16(in); @@ -837,12 +955,25 @@ public static QuantizedVector load(RandomAccessReader in, body[i] = in.readLong(); } - return new QuantizedVector(scale, offset, landmark, body); + int extraBytes = extraBytesForDims(quantizedDim, bitsPerDimension); + byte[] extra = new byte[extraBytes]; + if (extraBytes > 0) { + in.readFully(extra); + } + + return new QuantizedVector(scale, offset, landmark, body, extra); } public static void loadInto(RandomAccessReader in, QuantizedVector dest, int quantizedDim) throws IOException { + loadInto(in, dest, quantizedDim, DEFAULT_BITS_PER_DIMENSION); + } + + public static void loadInto(RandomAccessReader in, + QuantizedVector dest, + int quantizedDim, + int bitsPerDimension) throws IOException { dest.scale = readFloat16(in); dest.offset = readFloat16(in); @@ -853,9 +984,46 @@ public static void loadInto(RandomAccessReader in, } int words = wordsForDims(quantizedDim); + if (dest.binaryVector == null || dest.binaryVector.length < words) { + dest.binaryVector = new long[words]; + } for (int i = 0; i < words; i++) { dest.binaryVector[i] = in.readLong(); } + + int extraBytes = extraBytesForDims(quantizedDim, bitsPerDimension); + if (dest.extraBits == null || dest.extraBits.length != extraBytes) { + dest.extraBits = new byte[extraBytes]; + } + if (extraBytes > 0) { + in.readFully(dest.extraBits); + } + } + + static void setBit(long[] words, int bitIndex) { + words[bitIndex >>> 6] |= 1L << (bitIndex & 63); + } + + static boolean getBit(long[] words, int bitIndex) { + return ((words[bitIndex >>> 6] >>> (bitIndex & 63)) & 1L) != 0L; + } + + static void writeExtraCode(byte[] extraBits, int dim, int exBits, int code) { + int bitPos = dim * exBits; + for (int bit = 0; bit < exBits; bit++, bitPos++) { + if ((code & (1 << bit)) != 0) { + extraBits[bitPos >>> 3] |= (byte) (1 << (bitPos & 7)); + } + } + } + + static int readExtraCode(byte[] extraBits, int dim, int exBits) { + int code = 0; + int bitPos = dim * exBits; + for (int bit = 0; bit < exBits; bit++, bitPos++) { + code |= ((extraBits[bitPos >>> 3] >>> (bitPos & 7)) & 1) << bit; + } + return code; } /** @@ -864,16 +1032,15 @@ public static void loadInto(RandomAccessReader in, *

    * Equality is defined as bitwise equality of all encoded components: *

      - *
    • Binary code payload
    • + *
    • Sign-code payload
    • + *
    • Extra-bit payload
    • *
    • Associated landmark index
    • - *
    • Auxiliary scalar values (residual norm, dot-with-landmark)
    • + *
    • Auxiliary scalar values
    • *
    * *

    * Floating-point values are compared using their raw bit representations - * rather than tolerance-based comparisons. This is intentional: quantized - * vectors are expected to be deterministic outputs of the encoder, and - * approximate equality would be ambiguous and unsafe for hashing. + * rather than tolerance-based comparisons. *

    */ @Override @@ -885,16 +1052,12 @@ public boolean equals(Object o) { return Float.floatToIntBits(scale) == Float.floatToIntBits(that.scale) && Float.floatToIntBits(offset) == Float.floatToIntBits(that.offset) && landmark == that.landmark - && Arrays.equals(binaryVector, that.binaryVector); + && Arrays.equals(binaryVector, that.binaryVector) + && Arrays.equals(extraBits, that.extraBits); } /** * Hash code consistent with {@link #equals(Object)}. - * - *

    - * This hash reflects the full encoded state of the vector and is suitable - * for use in hash-based collections during testing, debugging, or caching. - *

    */ @Override public int hashCode() { @@ -902,12 +1065,13 @@ public int hashCode() { result = 31 * result + Integer.hashCode(Float.floatToIntBits(offset)); result = 31 * result + Short.hashCode(landmark); result = 31 * result + Arrays.hashCode(binaryVector); + result = 31 * result + Arrays.hashCode(extraBits); return result; } } /** - * Private helper to provide byte storage for binary body + * Private helper to provide byte storage for binary body. * @param nBits the number of bits to store * @return the byte sequence */ @@ -962,9 +1126,6 @@ public StiefelTransform(RealMatrix W) { this.cols = originalDim; // Explicitly materialize A = Wᵀ into row-major arrays. - // We do this directly instead of relying on RealMatrix.transpose()+getData(), - // because some RealMatrix implementations return views where getData() - // can be surprisingly expensive or shaped unexpectedly. this.AData = new double[quantizedDim][originalDim]; for (int i = 0; i < quantizedDim; i++) { final double[] row = this.AData[i]; @@ -1008,7 +1169,7 @@ public StiefelTransform(RealMatrix W) { * (e.g., y = Wᵀx maps R^D → R^d). * * @param D dimensionality of the input vectors - * @param d dimensionality of the projected (embedding) space + * @param d dimensionality of the projected space * @param rng random number generator used to sample G * @return a {@link StiefelTransform} containing W ∈ R^{D×d} with orthonormal columns * @throws IllegalArgumentException if d <= 0 or d > D @@ -1039,38 +1200,7 @@ public CompressedVectors createCompressedVectors(Object[] compressedVectors) { /** * Encodes all vectors using ASH, honoring the caller-provided ForkJoinPool. - * - *

    Concurrency model:

    - *
      - *
    • The work is partitioned into a small number of contiguous ordinal ranges ("chunks").
    • - *
    • Each chunk is processed sequentially by a single task.
    • - *
    • Chunks are executed in parallel using the provided {@code simdExecutor}.
    • - *
    - * - *

    - * This design intentionally avoids {@code IntStream.parallel()} and other - * constructs that implicitly use {@link ForkJoinPool#commonPool()}. - * All parallelism is derived exclusively from the supplied executor, - * ensuring predictable CPU usage and isolation from unrelated workloads. - *

    - * - *

    Correctness guarantees:

    - *
      - *
    • Each vector ordinal {@code i} is written exactly once to {@code out[i]}.
    • - *
    • The final output array is ordered by ordinal, independent of execution order.
    • - *
    • Missing vectors ({@code null}) are encoded as zero vectors, per the - * {@link VectorCompressor} contract.
    • - *
    • Safe publication is guaranteed by joining all submitted tasks - * before returning.
    • - *
    - * - *

    - * This implementation favors clarity and correctness over maximal parallel - * granularity. More aggressive chunking or SIMD-aware partitioning can be - * layered on later without changing observable semantics. - *

    */ - @Override public ASHVectors encodeAll(RandomAccessVectorValues ravv, ForkJoinPool simdExecutor) { final int n = ravv.size(); @@ -1080,18 +1210,11 @@ public ASHVectors encodeAll(RandomAccessVectorValues ravv, ForkJoinPool simdExec final int parallelism = Math.max(1, simdExecutor.getParallelism()); // Each task processes a contiguous range of ordinals. - // Chunking by contiguous ranges preserves cache locality and - // guarantees each ordinal is written exactly once. final int chunkSize = (n + parallelism - 1) / parallelism; final QuantizedVector[] out = new QuantizedVector[n]; final var ravvSupplier = ravv.threadLocalSupplier(); - // NOTE: - // We intentionally do NOT use IntStream.parallel() here. - // Parallel streams always execute on ForkJoinPool.commonPool(), - // which would ignore the caller-provided executor and risk - // oversubscribing CPU resources. var tasks = new java.util.ArrayList>(parallelism); for (int t = 0; t < parallelism; t++) { @@ -1112,7 +1235,7 @@ public ASHVectors encodeAll(RandomAccessVectorValues ravv, ForkJoinPool simdExec if (v != null) { out[i] = encode(v); } else { - out[i] = QuantizedVector.createEmpty(quantizedDim); + out[i] = QuantizedVector.createEmpty(quantizedDim, bitsPerDimension); } } })); @@ -1130,23 +1253,18 @@ public ASHVectors encodeAll(RandomAccessVectorValues ravv, ForkJoinPool simdExec /** * Encodes the input vector using ASH. - * @return the header and binary vector payload + * @return the header and encoded vector payload */ @Override public QuantizedVector encode(VectorFloat vector) { - var qv = QuantizedVector.createEmpty(this.quantizedDim); + var qv = QuantizedVector.createEmpty(this.quantizedDim, this.bitsPerDimension); encodeTo(vector, qv); return qv; } @Override public void encodeTo(VectorFloat vector, QuantizedVector dest) { - // NOTE: - // Single-landmark baseline configuration (Table 2, C = 1). - // All vectors are encoded relative to landmark μ_c (C=1 => μ_0 is the dataset mean). - // The landmark id is always 0 in this mode. - - // Landmark assignment: nearest centroid by L2 distance + // Landmark assignment: nearest centroid by L2 distance. byte landmark = 0; float bestDist = Float.POSITIVE_INFINITY; @@ -1160,34 +1278,26 @@ public void encodeTo(VectorFloat vector, QuantizedVector dest) { final VectorFloat mu = landmarks[landmark & 0xFF]; - // Compute + // Compute . final float dotXMu = VectorUtil.dotProduct(vector, mu); - // Compute ||x_i − μ_i*||_2 - // We store the true L2 norm (not squared), matching the paper’s use in Eq. 6 normalization. + // Compute ||x_i − μ_i*||_2. final float sqDist = VectorUtil.squareL2Distance(vector, mu); final float residualNorm = (float) Math.sqrt(sqDist); - // Sanity: quantizedDim already accounts for physical header bits - // Binary body uses exactly quantizedDim bits. assert quantizedDim > 0; - // Store: - // scale = ||x − μ|| / sqrt(d) - // offset = − ||μ||^2 - // NOTE: we still compute residualNorm (||x − μ||) even though we don't store it alone, - // because the binarizer needs it for Eq. 6 normalization. - final float invSqrtD = (float) (1.0 / Math.sqrt(quantizedDim)); - final float scale = residualNorm * invSqrtD; final float offset = dotXMu - landmarkNormSq[landmark]; - // Write the new header fields into dest (requires QuantizedVector fields renamed, see below) - dest.scale = roundToFloat16(scale); + // Header fields needed before quantization. dest.offset = roundToFloat16(offset); dest.landmark = landmark; - // Binary body: sign(A · (x − μ)) with Eq. 6 normalization inside quantizer - quantizeVector(vector, residualNorm, landmark, dest); + // Body: sign bits plus optional extra bits. The returned code norm is + // sqrt(d) for 1-bit ASH and sqrt(sum_j (q_j + 0.5)^2) for multibit ASH. + float codeNorm = quantizeVector(vector, residualNorm, landmark, dest); + final float scale = codeNorm > 0f ? residualNorm / codeNorm : 0f; + dest.scale = roundToFloat16(scale); } // --------------------------------------------------------------------- @@ -1210,7 +1320,7 @@ private static final class TrainingData { /** * Builds normalized centered residuals for training: - * - Choose N_train = min(N, cap) (cap differs by optimizer). + * - Choose N_train = min(N, D * training_factor). * - Sample ordinals uniformly at random (deterministic RNG). * - For each sampled ordinal i: * c = argmin ||x_i - mu_c||^2 (same rule as encoding) @@ -1300,8 +1410,8 @@ private static int[] reservoirSampleOrdinals(int N, int k, Random rng) { /** * ITQ / learned projection trainer. * - * Mirrors the current C++ learned_projection path: - * - PCA basis P from eig(X^T X), using BLAS + LAPACK + * Mirrors the current learned_projection path: + * - PCA basis P from SVD(X), matching the Python reference * - Xld = X @ P * - iterative Procrustes: * R = polar(M) @@ -1309,23 +1419,17 @@ private static int[] reservoirSampleOrdinals(int N, int k, Random rng) { * Xenc = encodeTrainingDirections(Xtr, bitsPerDimension) * M = Xld^T @ Xenc * - W = P @ R - * - * For bitsPerDimension == 1, Xenc is normalized sign coding: +/- 1/sqrt(d). - * This is equivalent to the old ITQ update up to a positive global scale, so - * the learned rotation remains compatible with the current 1-bit encoder. */ private static StiefelTransform runItqTrainer( double[][] xHdNorm, // [N][D] normalized residuals int latentDim, // projected dimensions, d - int bitsPerDimension, // current Java path passes 1 + int bitsPerDimension, Random rng, int nTrainingIterations ) { logProgress("\t[stage] Starting runITQTrainer..."); - if (bitsPerDimension < 1 || bitsPerDimension > 9) { - throw new IllegalArgumentException("bitsPerDimension must be in [1,9], got " + bitsPerDimension); - } + validateBitsPerDimension(bitsPerDimension); final int N = xHdNorm.length; final int D = xHdNorm[0].length; @@ -1372,8 +1476,6 @@ private static StiefelTransform runItqTrainer( } // Final polar step after the last update. - // We do not print a final no-update loss here because exact C++ final-loss - // logging would require another Xld @ R and encode pass. rCol = orthogonalize(mCol, d, d); double loopTime = (System.nanoTime() - startTime) / 1e9; @@ -1936,7 +2038,7 @@ private static void encodeTrainingDirections( double encodedNorm2 = 0.0; for (int j = 0; j < dims; j++) { - int q = Math.min((int) (t * absNorm[j] + 1e-5), maxCode); + int q = Math.min((int) (t * absNorm[j] + K_EPS), maxCode); double m = q + 0.5; mag[j] = m; encodedNorm2 += m * m; @@ -1988,7 +2090,7 @@ private static StiefelTransform readStiefelTransform(RandomAccessReader in, int } } - // Reconstruct W [D x d] so we can build StiefelTransform(W) + // Reconstruct W [D x d] so we can build StiefelTransform(W). // W[j][i] = A[i][j] double[][] wData = new double[cols][rows]; for (int i = 0; i < rows; i++) { @@ -2011,12 +2113,16 @@ public int compressorSize() { size += Integer.BYTES; // originalDimension size += Integer.BYTES; // encodedBits size += Integer.BYTES; // quantizedDim + size += Integer.BYTES; // bitsPerDimension size += Integer.BYTES; // optimizer size += Integer.BYTES; // landmarkCount for (int c = 0; c < landmarkCount; c++) { size += Integer.BYTES; // landmark dimension size += Float.BYTES * landmarks[c].length(); } + size += Integer.BYTES; // stiefel rows + size += Integer.BYTES; // stiefel cols + size += Float.BYTES * quantizedDim * originalDimension; return size; } @@ -2146,11 +2252,7 @@ private static float halfBitsToFloat(int half) { @Override public int compressedVectorSize() { - int words = QuantizedVector.wordsForDims(quantizedDim); - return Short.BYTES // scale, fp16 - + Short.BYTES // offset, fp16 - + Byte.BYTES // landmark - + words * Long.BYTES; + return QuantizedVector.serializedSizeBytes(quantizedDim, bitsPerDimension); } @Override @@ -2207,8 +2309,6 @@ private static long estimateMatrixBytes(RealMatrix m) { } // Exact, element-wise comparison of VectorFloat contents. - // We use Float.floatToIntBits to handle -0.0f, NaN, and ensure - // stable, bitwise equality suitable for hashing. private static boolean vectorEquals(VectorFloat a, VectorFloat b) { if (a == b) return true; if (a == null || b == null) return false; @@ -2223,7 +2323,6 @@ private static boolean vectorEquals(VectorFloat a, VectorFloat b) { } // Hash based on the raw bit representation of each float. - // This mirrors vectorEquals and guarantees hash consistency. private static int vectorHash(VectorFloat v) { if (v == null) return 0; int h = 1; @@ -2234,9 +2333,6 @@ private static int vectorHash(VectorFloat v) { } // Exact comparison of two RealMatrix instances by dimensions and entries. - // We intentionally avoid RealMatrix.equals() and tolerance-based checks. - // ASH initialization is deterministic, so bitwise equality is expected - // and required for robust hashing semantics. private static boolean matrixEquals(RealMatrix a, RealMatrix b) { if (a == b) return true; if (a == null || b == null) return false; @@ -2257,8 +2353,6 @@ private static boolean matrixEquals(RealMatrix a, RealMatrix b) { } // Hash code derived from matrix dimensions and raw double bits. - // Only the forward projection matrix (W) is hashed; the transpose (A) - // is derived and does not need to be hashed separately. private static int matrixHash(RealMatrix m) { if (m == null) return 0; int h = 1; @@ -2276,27 +2370,6 @@ private static int matrixHash(RealMatrix m) { /** * Compares this ASH compressor to another for encoding equivalence. - * - *

    - * Two compressors are equal if they would produce identical encoded outputs - * (including binary codes and auxiliary scalars) for the same input vectors. - *

    - * - *

    - * Equality therefore requires: - *

      - *
    • Matching dimensional parameters
    • - *
    • Identical optimizer selection
    • - *
    • Bitwise-equal landmark vectors
    • - *
    • Bitwise-equal Stiefel transform matrices
    • - *
    - * - *

    - * Note that this is a strong notion of equality. We deliberately - * avoid approximate floating-point comparisons because ASH initialization - * and training are deterministic, and weaker equality would break hashing - * and cache correctness. - *

    */ @Override public boolean equals(Object o) { @@ -2306,7 +2379,9 @@ public boolean equals(Object o) { AsymmetricHashing that = (AsymmetricHashing) o; return originalDimension == that.originalDimension && encodedBits == that.encodedBits + && bodyBits == that.bodyBits && quantizedDim == that.quantizedDim + && bitsPerDimension == that.bitsPerDimension && optimizer == that.optimizer && landmarksEqual(this.landmarks, that.landmarks) && matrixEquals( @@ -2327,18 +2402,14 @@ private static boolean landmarksEqual(VectorFloat[] a, VectorFloat[] b) { /** * Hash code consistent with {@link #equals(Object)}. - * - *

    - * The hash incorporates all parameters and learned state that affect encoding, - * including the contents of landmarks and Stiefel transform. This ensures - * safe use of ASH compressors as map keys or cache entries. - *

    */ @Override public int hashCode() { int result = Integer.hashCode(originalDimension); result = 31 * result + Integer.hashCode(encodedBits); + result = 31 * result + Integer.hashCode(bodyBits); result = 31 * result + Integer.hashCode(quantizedDim); + result = 31 * result + Integer.hashCode(bitsPerDimension); result = 31 * result + Integer.hashCode(optimizer); result = 31 * result + landmarksHash(landmarks); result = 31 * result + matrixHash( @@ -2376,10 +2447,12 @@ public String toString() { } return String.format( - "AsymmetricHashing[origDim=%d, encodedBits=%d, quantizedDim=%d, optimizer=%s, landmarks=%d]", + "AsymmetricHashing[origDim=%d, encodedBits=%d, bodyBits=%d, quantizedDim=%d, bitsPerDimension=%d, optimizer=%s, landmarks=%d]", originalDimension, encodedBits, + bodyBits, quantizedDim, + bitsPerDimension, optimizerName, landmarkCount ); diff --git a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/yaml/Compression.java b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/yaml/Compression.java index eef2e57db..c602308c4 100644 --- a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/yaml/Compression.java +++ b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/yaml/Compression.java @@ -99,12 +99,14 @@ public Function getCompressorParameters() { encodedBits = Integer.parseInt(parameters.get("encodedBits")); } else { int requestedCompressionRatio = Integer.parseInt(parameters.get("compressionRatio")); - if (requestedCompressionRatio != 32 + if (requestedCompressionRatio != 8 + && requestedCompressionRatio != 16 + && requestedCompressionRatio != 32 && requestedCompressionRatio != 64 && requestedCompressionRatio != 128 && requestedCompressionRatio != 256) { throw new IllegalArgumentException( - "ASH compressionRatio must be one of 32, 64, 128, or 256"); + "ASH compressionRatio must be one of 8, 16, 32, 64, 128, or 256"); } int originalBits = ds.getDimension() * Float.SIZE; From 118ff3877d313e2beeb81ec5e50d748bffee21ea Mon Sep 17 00:00:00 2001 From: Ted Willke Date: Fri, 12 Jun 2026 23:45:10 +0000 Subject: [PATCH 043/243] Initial implementation of Fused ASH. --- compare_benchmark_iterations.py | 288 ------------ .../jvector/disk/ByteBufferReader.java | 5 + .../jvector/disk/MappedChunkReader.java | 12 + .../jvector/disk/RandomAccessReader.java | 16 +- .../jbellis/jvector/disk/SimpleReader.java | 5 + .../graph/disk/AbstractGraphIndexWriter.java | 7 +- .../jvector/graph/disk/OnDiskGraphIndex.java | 49 ++- .../jvector/graph/disk/feature/FeatureId.java | 3 +- .../jvector/graph/disk/feature/FusedASH.java | 333 ++++++++++++++ .../jvector/quantization/ASHScorer.java | 75 +++- .../quantization/AsymmetricHashing.java | 271 ++++++++++-- .../jvector/quantization/FusedASHDecoder.java | 294 +++++++++++++ .../jvector/quantization/FusedASHLayout.java | 412 ++++++++++++++++++ .../jbellis/jvector/example/DistancesASH.java | 14 +- .../jbellis/jvector/example/DistancesPQ.java | 246 +++++++++-- .../github/jbellis/jvector/example/Grid.java | 68 ++- .../example/util/CompressorParameters.java | 26 +- .../jvector/example/yaml/Compression.java | 24 +- .../example/yaml/ConstructionParameters.java | 68 ++- .../jvector/example/yaml/MultiConfig.java | 2 +- .../yaml-configs/dataset-metadata.yml | 12 + jvector-examples/yaml-configs/datasets.yml | 19 +- .../yaml-configs/index-parameters/cap-1M.yml | 8 +- .../yaml-configs/index-parameters/cap-6M.yml | 4 +- .../index-parameters/cohere-10M.yml | 4 +- .../index-parameters/cohere-english-v3-1M.yml | 72 --- .../index-parameters/colbert-1M.yml | 4 +- .../yaml-configs/index-parameters/default.yml | 50 ++- .../index-parameters/dpr-gemma-1M.yml | 8 +- .../index-parameters/esmc-600-1M.yml | 4 +- .../index-parameters/esmc-600-6M.yml | 4 +- .../index-parameters/gecko-100k.yml | 4 +- .../index-parameters/glove-100-angular.yml | 4 +- .../index-parameters/glove-200-angular.yml | 4 +- .../index-parameters/glove-25-angular.yml | 4 +- .../index-parameters/glove-50-angular.yml | 4 +- .../index-parameters/lastfm-64-dot.yml | 4 +- .../index-parameters/mpnet-1M.yml | 6 +- .../index-parameters/nytimes-256-angular.yml | 4 +- .../index-parameters/openai-v3-small-100k.yml | 4 +- .../index-parameters/personahub-1M.yml | 6 +- .../index-parameters/sift-128-euclidean.yml | 4 +- .../index-parameters/tahoe-x1-1M.yml | 6 +- plot_output.py | 142 ------ visualize_benchmarks.py | 360 --------------- 45 files changed, 1844 insertions(+), 1119 deletions(-) delete mode 100644 compare_benchmark_iterations.py create mode 100644 jvector-base/src/main/java/io/github/jbellis/jvector/graph/disk/feature/FusedASH.java create mode 100644 jvector-base/src/main/java/io/github/jbellis/jvector/quantization/FusedASHDecoder.java create mode 100644 jvector-base/src/main/java/io/github/jbellis/jvector/quantization/FusedASHLayout.java delete mode 100644 jvector-examples/yaml-configs/index-parameters/cohere-english-v3-1M.yml delete mode 100644 plot_output.py delete mode 100644 visualize_benchmarks.py diff --git a/compare_benchmark_iterations.py b/compare_benchmark_iterations.py deleted file mode 100644 index 3899cfd91..000000000 --- a/compare_benchmark_iterations.py +++ /dev/null @@ -1,288 +0,0 @@ -#!/usr/bin/env python3 -# 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. - -""" -Compare two different iterations of the same benchmark comparison on a single graph. -This script takes two directories containing CSV benchmark files and overlays the results -with the same graph structure as visualize_benchmarks.py. -""" - -import argparse -import csv -import os -import sys -import re -import platform -import psutil -from collections import defaultdict -from typing import Dict, List, Any, Tuple, Optional -import matplotlib.pyplot as plt -import numpy as np -from matplotlib.ticker import MaxNLocator - - -# Define metrics where higher values are better and lower values are better -HIGHER_IS_BETTER = ["QPS", "Recall@10"] -LOWER_IS_BETTER = ["Mean Latency", "Index Build Time"] - - -class BenchmarkData: - def __init__(self): - # Structure: {release: {dataset: {metric: value}}} - self.data = defaultdict(lambda: defaultdict(dict)) - self.releases = [] # This will maintain the order of releases as they are added - self.datasets = set() - self.metrics = set() - - def add_file(self, file_path: str, release: str): - """ - Add benchmark data from a CSV file for a specific release - """ - if release not in self.releases: - self.releases.append(release) - - with open(file_path, 'r') as csvfile: - reader = csv.DictReader(csvfile) - for row in reader: - # Handle both 'Dataset' and 'dataset' column names - dataset = row.get('Dataset') or row.get('dataset') - if not dataset: - # Skip rows without a dataset identifier - continue - - self.datasets.add(dataset) - - # Add each metric - for key, value in row.items(): - if key.lower() not in ['dataset'] and value.strip(): # Skip dataset column and empty values - try: - numeric_value = float(value) - self.data[release][dataset][key] = numeric_value - self.metrics.add(key) - except ValueError: - # Skip non-numeric values - pass - - def get_metric_data_for_dataset(self, metric: str, dataset: str) -> Tuple[List[str], List[float]]: - """ - Get metric data for a specific dataset across all releases - Returns tuple of (releases, values) - """ - releases = [] - values = [] - - for release in self.releases: - if dataset in self.data[release] and metric in self.data[release][dataset]: - releases.append(release) - values.append(self.data[release][dataset][metric]) - - return releases, values - - -def extract_release_from_filename(filename: str) -> str: - """ - Extract release number from filename - """ - # Try to extract version number from filename - # Look for patterns like v1.2.3, 1.2.3, etc. - version_match = re.search(r'v?(\d+\.\d+\.\d+)', filename) - if version_match: - return version_match.group(1) - - # Fallback to filename without extension - return os.path.splitext(filename)[0] - - -def load_benchmark_data(directory: str) -> BenchmarkData: - """ - Load benchmark data from all CSV files in a directory - """ - benchmark_data = BenchmarkData() - - if not os.path.exists(directory): - print(f"Error: Directory {directory} does not exist") - sys.exit(1) - - csv_files = [f for f in os.listdir(directory) if f.endswith('.csv')] - if not csv_files: - print(f"Error: No CSV files found in {directory}") - sys.exit(1) - - # Custom sort to ensure "main" appears last - def sort_key(filename): - release = extract_release_from_filename(filename) - # If the release is "main", return a tuple that sorts it last - if release.lower() == "main": - return (0, release) # 0 ensures it comes before all 1s - else: - return (1, release) # 1 ensures it comes after main - - csv_files.sort(key=sort_key) - - for filename in csv_files: - file_path = os.path.join(directory, filename) - release = extract_release_from_filename(filename) - print(f"Loading {filename} as release {release}") - benchmark_data.add_file(file_path, release) - - return benchmark_data - - -def generate_comparison_plots(benchmark_data1: BenchmarkData, benchmark_data2: BenchmarkData, - output_dir: str, run1_label: str = "Run 1", run2_label: str = "Run 2"): - """ - Generate comparison plots for each metric, with two lines per dataset (one for each run) - """ - os.makedirs(output_dir, exist_ok=True) - - # Get all releases from both runs (maintaining order) - all_releases = list(benchmark_data1.releases) - for release in benchmark_data2.releases: - if release not in all_releases: - all_releases.append(release) - - # Get all datasets from both runs - all_datasets = benchmark_data1.datasets | benchmark_data2.datasets - - # Get all metrics from both runs - all_metrics = benchmark_data1.metrics | benchmark_data2.metrics - - # Define a color cycle for datasets - import matplotlib.pyplot as plt - colors = plt.cm.tab10(range(len(all_datasets))) - dataset_colors = {dataset: colors[i] for i, dataset in enumerate(sorted(all_datasets))} - - # Create a plot for each metric - for metric in sorted(all_metrics): - plt.figure(figsize=(10, 6)) - - # Plot lines for each dataset from both runs - for dataset in sorted(all_datasets): - color = dataset_colors[dataset] - - # Get data for run 1 - releases1, values1 = benchmark_data1.get_metric_data_for_dataset(metric, dataset) - if releases1 and values1: - plt.plot(releases1, values1, marker='o', label=f"{dataset} ({run1_label})", - linestyle='-', linewidth=2, color=color) - - # Get data for run 2 - releases2, values2 = benchmark_data2.get_metric_data_for_dataset(metric, dataset) - if releases2 and values2: - plt.plot(releases2, values2, marker='s', label=f"{dataset} ({run2_label})", - linestyle='--', linewidth=2, color=color, alpha=0.8) - - plt.title(f"{metric} Over Time - Comparison") - plt.xlabel("Release") - plt.ylabel(metric) - plt.xticks(rotation=45) - plt.grid(True, linestyle='--', alpha=0.7) - plt.legend(bbox_to_anchor=(1.05, 1), loc='upper left') - plt.tight_layout() - - # Save the plot - safe_metric_name = metric.replace('@', '_at_').replace(' ', '_') - plt.savefig(os.path.join(output_dir, f"{safe_metric_name}_comparison.png"), - bbox_inches='tight', dpi=300) - plt.close() - - print(f"Generated comparison plot for {metric}") - - -def generate_summary_report(benchmark_data1: BenchmarkData, benchmark_data2: BenchmarkData, - output_file: str, run1_label: str = "Run 1", run2_label: str = "Run 2"): - """ - Generate a summary report comparing the two runs - """ - with open(output_file, 'w') as f: - f.write(f"# Benchmark Comparison Report: {run1_label} vs {run2_label}\n\n") - - # Get system information - cpu_count = psutil.cpu_count(logical=False) - if cpu_count is None: - cpu_count = psutil.cpu_count(logical=True) - memory_gb = round(psutil.virtual_memory().total / (1024**3), 2) - processor = platform.processor() - if not processor: - processor = platform.machine() - - f.write(f"**System Information:**\n") - f.write(f"- CPU: {processor}\n") - f.write(f"- Cores: {cpu_count}\n") - f.write(f"- Memory: {memory_gb} GB\n") - f.write(f"- Platform: {platform.system()} {platform.release()}\n\n") - - f.write(f"**Comparison Summary:**\n") - f.write(f"- {run1_label}: {len(benchmark_data1.releases)} releases, {len(benchmark_data1.datasets)} datasets\n") - f.write(f"- {run2_label}: {len(benchmark_data2.releases)} releases, {len(benchmark_data2.datasets)} datasets\n") - f.write(f"- Metrics compared: {', '.join(sorted(benchmark_data1.metrics | benchmark_data2.metrics))}\n\n") - - f.write("## Datasets\n\n") - all_datasets = sorted(benchmark_data1.datasets | benchmark_data2.datasets) - for dataset in all_datasets: - in_run1 = dataset in benchmark_data1.datasets - in_run2 = dataset in benchmark_data2.datasets - status = "" - if in_run1 and in_run2: - status = "✓ Both runs" - elif in_run1: - status = f"✓ {run1_label} only" - else: - status = f"✓ {run2_label} only" - f.write(f"- **{dataset}**: {status}\n") - - f.write("\n## Releases\n\n") - f.write(f"**{run1_label}**: {', '.join(benchmark_data1.releases)}\n\n") - f.write(f"**{run2_label}**: {', '.join(benchmark_data2.releases)}\n\n") - - f.write("Generated plots show the comparison of both runs with:\n") - f.write(f"- Solid lines with circle markers for {run1_label}\n") - f.write(f"- Dashed lines with square markers for {run2_label}\n") - - -def main(): - parser = argparse.ArgumentParser(description="Compare two benchmark iterations and generate overlay plots") - parser.add_argument('dir1', help="Directory containing CSV files for the first run") - parser.add_argument('dir2', help="Directory containing CSV files for the second run") - parser.add_argument('--output-dir', default='benchmark_comparison', - help="Directory to save comparison plots (default: benchmark_comparison)") - parser.add_argument('--run1-label', default='Run 1', - help="Label for the first run (default: Run 1)") - parser.add_argument('--run2-label', default='Run 2', - help="Label for the second run (default: Run 2)") - - args = parser.parse_args() - - print(f"Loading benchmark data from {args.dir1}...") - benchmark_data1 = load_benchmark_data(args.dir1) - - print(f"Loading benchmark data from {args.dir2}...") - benchmark_data2 = load_benchmark_data(args.dir2) - - print(f"Generating comparison plots...") - generate_comparison_plots(benchmark_data1, benchmark_data2, args.output_dir, - args.run1_label, args.run2_label) - - # Generate summary report - report_file = os.path.join(args.output_dir, "comparison_summary.md") - generate_summary_report(benchmark_data1, benchmark_data2, report_file, - args.run1_label, args.run2_label) - - print(f"Comparison complete! Results saved to {args.output_dir}") - print(f"Summary report: {report_file}") - - -if __name__ == "__main__": - main() diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/disk/ByteBufferReader.java b/jvector-base/src/main/java/io/github/jbellis/jvector/disk/ByteBufferReader.java index 9d02269d7..c61eecc56 100644 --- a/jvector-base/src/main/java/io/github/jbellis/jvector/disk/ByteBufferReader.java +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/disk/ByteBufferReader.java @@ -73,6 +73,11 @@ public void readFully(long[] vector) { } } + @Override + public byte readByte() { + return bb.get(); + } + @Override public int readInt() { return bb.getInt(); diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/disk/MappedChunkReader.java b/jvector-base/src/main/java/io/github/jbellis/jvector/disk/MappedChunkReader.java index 181cd82a8..20bdd6e19 100644 --- a/jvector-base/src/main/java/io/github/jbellis/jvector/disk/MappedChunkReader.java +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/disk/MappedChunkReader.java @@ -288,6 +288,18 @@ public void read(float[] floats, int offset, int count) { tmp.flip().asFloatBuffer().get(floats, offset, count); } + @Override + public byte readByte() { + try { + ensureAvailable(1); + byte v = currentBuffer.get(); + position++; + return v; + } catch (IOException e) { + throw new RuntimeException(e); + } + } + /** * Returns the total length of the file. * diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/disk/RandomAccessReader.java b/jvector-base/src/main/java/io/github/jbellis/jvector/disk/RandomAccessReader.java index 09e751e1a..1c362d8d0 100644 --- a/jvector-base/src/main/java/io/github/jbellis/jvector/disk/RandomAccessReader.java +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/disk/RandomAccessReader.java @@ -31,7 +31,7 @@ * Thus, RandomAccessReader implementations are expected to be stateful and NOT threadsafe; JVector * uses the ReaderSupplier API to create a RandomAccessReader per thread, as needed. */ -public interface RandomAccessReader extends AutoCloseable { +public interface RandomAccessReader extends AutoCloseable, ByteReadable { /** * Seeks to the specified offset. * @param offset the offset to seek to @@ -46,6 +46,20 @@ public interface RandomAccessReader extends AutoCloseable { */ long getPosition() throws IOException; + /** + * Reads a single byte. + * + *

    The default implementation preserves compatibility for custom + * RandomAccessReader implementations. Production readers should override this + * with a zero-allocation implementation.

    + */ + @Override + default byte readByte() throws IOException { + byte[] one = new byte[1]; + readFully(one); + return one[0]; + } + /** * Reads an integer. * @return the integer value diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/disk/SimpleReader.java b/jvector-base/src/main/java/io/github/jbellis/jvector/disk/SimpleReader.java index 659c44dc7..392b0d121 100644 --- a/jvector-base/src/main/java/io/github/jbellis/jvector/disk/SimpleReader.java +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/disk/SimpleReader.java @@ -47,6 +47,11 @@ public void seek(long offset) throws IOException { public long getPosition() throws IOException { return raf.getFilePointer(); } + + @Override + public byte readByte() throws IOException { + return raf.readByte(); + } @Override public int readInt() throws IOException { diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/graph/disk/AbstractGraphIndexWriter.java b/jvector-base/src/main/java/io/github/jbellis/jvector/graph/disk/AbstractGraphIndexWriter.java index a5ff739f3..834c9c9dd 100644 --- a/jvector-base/src/main/java/io/github/jbellis/jvector/graph/disk/AbstractGraphIndexWriter.java +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/graph/disk/AbstractGraphIndexWriter.java @@ -236,9 +236,10 @@ void writeSparseLevels(ImmutableGraphIndex.View view, Map= 6) { // There should be only one fused feature per node. This is checked in the class constructor. // This is the only place where we explicitly need the fused feature. If there are more places in the // future, it may be worth having fusedFeature as class member. diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/graph/disk/OnDiskGraphIndex.java b/jvector-base/src/main/java/io/github/jbellis/jvector/graph/disk/OnDiskGraphIndex.java index 9ab122392..5fa57d3de 100644 --- a/jvector-base/src/main/java/io/github/jbellis/jvector/graph/disk/OnDiskGraphIndex.java +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/graph/disk/OnDiskGraphIndex.java @@ -25,6 +25,7 @@ import io.github.jbellis.jvector.graph.disk.feature.Feature; import io.github.jbellis.jvector.graph.disk.feature.FeatureId; import io.github.jbellis.jvector.graph.disk.feature.FeatureSource; +import io.github.jbellis.jvector.graph.disk.feature.FusedASH; import io.github.jbellis.jvector.graph.disk.feature.FusedPQ; import io.github.jbellis.jvector.graph.disk.feature.FusedFeature; import io.github.jbellis.jvector.graph.disk.feature.InlineVectors; @@ -69,7 +70,7 @@ public class OnDiskGraphIndex implements ImmutableGraphIndex, AutoCloseable, Accountable { private static final Logger logger = LoggerFactory.getLogger(OnDiskGraphIndex.class); - public static final int CURRENT_VERSION = 6; + public static final int CURRENT_VERSION = 7; static final int MAGIC = 0xFFFF0D61; // FFFF to distinguish from old graphs, which should never start with a negative size "ODGI" static final VectorTypeSupport vectorTypeSupport = VectorizationProvider.getInstance().getVectorTypeSupport(); final ReaderSupplier readerSupplier; @@ -186,39 +187,41 @@ private Int2ObjectHashMap loadInMemoryFeatures(Random CommonHeader.LayerInfo info = layerInfo.get(lvl); inMemorySize += Integer.BYTES * info.size * (1L + 1L + info.degree); } + in.seek(neighborsOffset + L0size + inMemorySize); - // In V6, fused features for the in-memory hierarchy are written in a block after the top layers of the graph. - if (version == 6) { + FusedFeature fusedFeature = null; + for (var feature : features.values()) { + if (feature.isFused()) { + fusedFeature = (FusedFeature) feature; + break; + } + } + + if (fusedFeature == null) { + return hierarchyFeatures; + } + + // V6+ layout: fused source features for the in-memory hierarchy are written + // after the upper-layer graph data. Level 1 contains all higher-level nodes, + // so reading level 1 is sufficient. + if (version >= 6) { if (layerInfo.size() >= 2) { int level = 1; CommonHeader.LayerInfo info = layerInfo.get(level); + for (int i = 0; i < info.size; i++) { int nodeId = in.readInt(); - - // There should be only one fused feature per node. This is checked in AbstractGraphIndexWriter. - for (var feature : features.values()) { - if (feature.isFused()) { - var fusedFeature = (FusedFeature) feature; - var inlineSource = fusedFeature.loadSourceFeature(in); - hierarchyFeatures.put(nodeId, inlineSource); - } - } + var inlineSource = fusedFeature.loadSourceFeature(in); + hierarchyFeatures.put(nodeId, inlineSource); } } else { - // read the entry node int nodeId = in.readInt(); - - // There should be only one fused feature per node. This is checked in AbstractGraphIndexWriter. - for (var feature : features.values()) { - if (feature.isFused()) { - var fusedFeature = (FusedFeature) feature; - var inlineSource = fusedFeature.loadSourceFeature(in); - hierarchyFeatures.put(nodeId, inlineSource); - } - } + var inlineSource = fusedFeature.loadSourceFeature(in); + hierarchyFeatures.put(nodeId, inlineSource); } } + return hierarchyFeatures; } @@ -695,6 +698,8 @@ public ScoreFunction.ExactScoreFunction rerankerFor(VectorFloat queryVector, public ScoreFunction.ApproximateScoreFunction approximateScoreFunctionFor(VectorFloat queryVector, VectorSimilarityFunction vsf) { if (features.containsKey(FeatureId.FUSED_PQ)) { return ((FusedPQ) features.get(FeatureId.FUSED_PQ)).approximateScoreFunctionFor(queryVector, vsf, this, rerankerFor(queryVector, vsf)); + } else if (features.containsKey(FeatureId.FUSED_ASH)) { + return ((FusedASH) features.get(FeatureId.FUSED_ASH)).approximateScoreFunctionFor(queryVector, vsf, this, rerankerFor(queryVector, vsf)); } else { throw new UnsupportedOperationException("No approximate score function available for this graph"); } diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/graph/disk/feature/FeatureId.java b/jvector-base/src/main/java/io/github/jbellis/jvector/graph/disk/feature/FeatureId.java index 131c4c8ee..7b25517d1 100644 --- a/jvector-base/src/main/java/io/github/jbellis/jvector/graph/disk/feature/FeatureId.java +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/graph/disk/feature/FeatureId.java @@ -33,7 +33,8 @@ public enum FeatureId { FUSED_PQ(FusedPQ::load), NVQ_VECTORS(NVQ::load), SEPARATED_VECTORS(SeparatedVectors::load), - SEPARATED_NVQ(SeparatedNVQ::load); + SEPARATED_NVQ(SeparatedNVQ::load), + FUSED_ASH(FusedASH::load); private final BiFunction loader; diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/graph/disk/feature/FusedASH.java b/jvector-base/src/main/java/io/github/jbellis/jvector/graph/disk/feature/FusedASH.java new file mode 100644 index 000000000..5c5ded27f --- /dev/null +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/graph/disk/feature/FusedASH.java @@ -0,0 +1,333 @@ +/* + * 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.disk.feature; + +import io.github.jbellis.jvector.disk.IndexWriter; +import io.github.jbellis.jvector.disk.RandomAccessReader; +import io.github.jbellis.jvector.graph.ImmutableGraphIndex; +import io.github.jbellis.jvector.graph.disk.CommonHeader; +import io.github.jbellis.jvector.graph.disk.OnDiskGraphIndex; +import io.github.jbellis.jvector.graph.similarity.ScoreFunction; +import io.github.jbellis.jvector.quantization.ASHVectors; +import io.github.jbellis.jvector.quantization.AsymmetricHashing; +import io.github.jbellis.jvector.quantization.FusedASHDecoder; +import io.github.jbellis.jvector.quantization.FusedASHLayout; +import io.github.jbellis.jvector.vector.VectorSimilarityFunction; +import io.github.jbellis.jvector.vector.types.VectorFloat; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.util.Arrays; +import java.util.function.IntFunction; + +/** + * Fuses ASH-encoded neighbors into an {@link OnDiskGraphIndex} for block-oriented + * graph-neighborhood scoring. + * + *

    Unlike regular {@link ASHVectors}, this feature stores each node's layer-0 + * neighbor codes in a neighborhood-local block layout. The decoder reads and + * scores the whole neighborhood in {@code enableSimilarityToNeighbors(origin)}, + * then {@code similarityToNeighbor(origin, i)} is a cheap score lookup.

    + * + *

    The fused layout supports only {@code bitsPerDimension in {1,2,4}} because + * it is based on 4-bit projection groups and a 16-entry query LUT per group. + * Core standalone ASH may support additional bit widths, but those are not part + * of the fused graph hot path.

    + */ +public class FusedASH extends AbstractFeature implements FusedFeature { + private final int maxDegree; + private final int blockSize; + private final AsymmetricHashing ash; + private final int featureSize; + + private final ThreadLocal reusableNeighborhoodBytes; + private final ThreadLocal reusableScores; + private final ThreadLocal writeScratch; + + public FusedASH(int maxDegree, AsymmetricHashing ash) { + this(maxDegree, ash, FusedASHLayout.chooseBlockSize(maxDegree)); + } + + public FusedASH(int maxDegree, AsymmetricHashing ash, int blockSize) { + if (maxDegree <= 0) { + throw new IllegalArgumentException("maxDegree must be > 0"); + } + if (ash == null) { + throw new NullPointerException("ash"); + } + FusedASHLayout.validateBitsPerDimension(ash.bitsPerDimension); + FusedASHLayout.validateBlockSize(blockSize); + + this.maxDegree = maxDegree; + this.ash = ash; + this.blockSize = blockSize; + this.featureSize = FusedASHLayout.featureSize( + maxDegree, + ash.quantizedDim, + ash.bitsPerDimension, + blockSize); + + this.reusableNeighborhoodBytes = ThreadLocal.withInitial(() -> new byte[featureSize]); + this.reusableScores = ThreadLocal.withInitial(() -> new float[maxDegree]); + this.writeScratch = ThreadLocal.withInitial(() -> new byte[featureSize]); + } + + @Override + public FeatureId id() { + return FeatureId.FUSED_ASH; + } + + public AsymmetricHashing getASH() { + return ash; + } + + public int blockSize() { + return blockSize; + } + + @Override + public int headerSize() { + return ash.compressorSize() + Integer.BYTES; // blockSize + } + + @Override + public int featureSize() { + return featureSize; + } + + static FusedASH load(CommonHeader header, RandomAccessReader reader) { + try { + AsymmetricHashing ash = AsymmetricHashing.load(reader); + int blockSize = reader.readInt(); + return new FusedASH(header.layerInfo.get(0).degree, ash, blockSize); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + } + + @Override + public void writeHeader(IndexWriter out) throws IOException { + ash.write(out, OnDiskGraphIndex.CURRENT_VERSION); + out.writeInt(blockSize); + } + + /** + * Writes a fused, block-packed ASH payload for {@code state.nodeId}'s layer-0 + * neighbors. Neighbor lanes are stored in graph neighbor iterator order and + * padded with zeros up to {@code maxDegree}. + */ + @Override + public void writeInline(IndexWriter out, Feature.State state_) throws IOException { + var state = (FusedASH.State) state_; + byte[] scratch = writeScratch.get(); + Arrays.fill(scratch, (byte) 0); + + var neighbors = state.view.getNeighborsIterator(0, state.nodeId); + int count = 0; + while (neighbors.hasNext()) { + int node = neighbors.nextInt(); + if (count >= maxDegree) { + throw new IOException("Node " + state.nodeId + " has more than maxDegree=" + maxDegree + " neighbors"); + } + + var compressed = state.compressedVectorFunction.apply(node); + packForFused(scratch, count, compressed); + count++; + } + + writeBytes(out, scratch, featureSize); + } + + private void packForFused(byte[] dest, int neighborIndex, AsymmetricHashing.QuantizedVector vector) { + int blockIndex = neighborIndex / blockSize; + int lane = neighborIndex % blockSize; + int blockOffset = FusedASHLayout.blockOffset( + blockIndex, + ash.quantizedDim, + ash.bitsPerDimension, + blockSize); + + if (ash.bitsPerDimension == 1) { + packOneBitForFused(dest, blockOffset, lane, vector); + } else { + // For 2-bit and 4-bit ASH, the canonical vector already stores the + // C++ projection-mode adjusted offset used by FusedASH scoring. + FusedASHLayout.packQuantizedVector( + dest, + blockOffset, + lane, + vector, + ash.quantizedDim, + ash.bitsPerDimension, + blockSize); + } + } + + /** + * The regular 1-bit ASH vector keeps the legacy unadjusted offset, because + * its standalone scorer uses A(q - μ). FusedASH scores via Aq for every + * supported bit width, so we apply the projection-offset adjustment here. + */ + private void packOneBitForFused(byte[] dest, int blockOffset, int lane, AsymmetricHashing.QuantizedVector vector) { + int groups = FusedASHLayout.codeGroups(ash.quantizedDim, ash.bitsPerDimension); + for (int group = 0; group < groups; group++) { + int nibble = FusedASHLayout.signNibbleFromWords(vector.binaryVector, group, ash.quantizedDim); + FusedASHLayout.setPackedNibble(dest, blockOffset, lane, group, blockSize, nibble); + } + + int c = vector.landmark & 0xFF; + if (c >= ash.landmarkCount) { + throw new IllegalArgumentException("Invalid ASH landmark " + c + " for landmarkCount=" + ash.landmarkCount); + } + + float landmarkDot = dotOneBitProjectionCode(ash.landmarkProj[c], vector.binaryVector, ash.quantizedDim); + float adjustedOffset = vector.offset - vector.scale * landmarkDot; + + FusedASHLayout.writeLaneHeader( + dest, + blockOffset, + lane, + ash.quantizedDim, + ash.bitsPerDimension, + blockSize, + vector.scale, + adjustedOffset, + vector.landmark); + } + + private static float dotOneBitProjectionCode(float[] projected, long[] signWords, int quantizedDim) { + float sum = 0f; + for (int j = 0; j < quantizedDim; j++) { + boolean positive = ((signWords[j >>> 6] >>> (j & 63)) & 1L) != 0L; + sum += positive ? projected[j] : -projected[j]; + } + return sum; + } + + private static void writeBytes(IndexWriter out, byte[] bytes, int length) throws IOException { + for (int i = 0; i < length; i++) { + out.writeByte(bytes[i]); + } + } + + public ScoreFunction.ApproximateScoreFunction approximateScoreFunctionFor( + VectorFloat queryVector, + VectorSimilarityFunction vsf, + OnDiskGraphIndex.View view, + ScoreFunction.ExactScoreFunction esf) { + + var neighborhoods = new PackedNeighborhoods(view); + var hierarchyCachedFeatures = view.getInlineSourceFeatures(); + + return FusedASHDecoder.newDecoder( + neighborhoods, + ash, + hierarchyCachedFeatures, + source -> ((FusedASHInlineSource) source).getVector(), + queryVector, + reusableNeighborhoodBytes.get(), + reusableScores.get(), + blockSize, + vsf); + } + + @Override + public void writeSourceFeature(IndexWriter out, Feature.State state_) throws IOException { + var state = (FusedASH.State) state_; + var compressed = state.compressedVectorFunction.apply(state.nodeId); + compressed.write(out, ash.quantizedDim, ash.bitsPerDimension); + } + + @Override + public InlineSource loadSourceFeature(RandomAccessReader in) throws IOException { + var vector = AsymmetricHashing.QuantizedVector.load(in, ash.quantizedDim, ash.bitsPerDimension); + return new FusedASHInlineSource(vector); + } + + public class FusedASHInlineSource implements InlineSource { + private final AsymmetricHashing.QuantizedVector vector; + + FusedASHInlineSource(AsymmetricHashing.QuantizedVector vector) { + this.vector = vector; + } + + public AsymmetricHashing.QuantizedVector getVector() { + return vector; + } + + @Override + public long ramBytesUsed() { + return ash.compressedVectorSize(); + } + } + + public class PackedNeighborhoods implements FusedASHDecoder.PackedNeighborhoods { + private final OnDiskGraphIndex.View view; + + PackedNeighborhoods(OnDiskGraphIndex.View view) { + this.view = view; + } + + @Override + public void readInto(int origin, byte[] dest) { + if (dest.length < featureSize) { + throw new IllegalArgumentException("Destination buffer is smaller than FusedASH feature size"); + } + + try { + view.getPackedNeighbors(origin, FeatureId.FUSED_ASH, reader -> { + try { + reader.readFully(dest); + } catch (IOException e) { + throw new RuntimeException(e); + } + }); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + + @Override + public int maxDegree() { + return maxDegree; + } + + @Override + public int featureSize() { + return featureSize; + } + } + + public static class State implements Feature.State { + public final ImmutableGraphIndex.View view; + public final IntFunction compressedVectorFunction; + public final int nodeId; + + public State(ImmutableGraphIndex.View view, ASHVectors ashVectors, int nodeId) { + this(view, ashVectors::get, nodeId); + } + + public State( + ImmutableGraphIndex.View view, + IntFunction compressedVectorFunction, + int nodeId) { + this.view = view; + this.compressedVectorFunction = compressedVectorFunction; + this.nodeId = nodeId; + } + } +} diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHScorer.java b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHScorer.java index 0a844b347..502f9014a 100644 --- a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHScorer.java +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHScorer.java @@ -24,24 +24,28 @@ * Query-time scalar scorer for ASH vectors. * *

    - * Multi-landmark scoring follows the same decomposition used by the 1-bit ASH path: - * for each landmark c we form q̃_c = A(q - μ_c) = Aq - Aμ_c once per query, then - * score each vector using its stored landmark id. + * The 1-bit path preserves the original ASH decomposition: *

    * + *
    + *   score = scale * (2 * <A(q - mu), b> - <A(q - mu), 1>)
    + *         + <q, mu>
    + *         + offset
    + * 
    + * *

    - * For 1-bit ASH, the packed sign bits are interpreted as values in {-1,+1}. For - * multibit ASH, sign bits plus packed extra bits reconstruct the centered scalar - * code used by the encoder: + * Fast-scan projection codes, currently bitsPerDimension in {2,4}, use the + * C++ projection-mode decomposition. During encoding, {@code offset} already + * includes {@code -scale * <A(mu), code>}, so scoring uses only {@code A(q)}: *

    * *
    - *   code_j = ((sign_j << exBits) + extra_j) - (2^exBits - 0.5)
    + *   score = scale * <A(q), code> + <q, mu> + offset
      * 
    * *

    - * This exactly matches the C++ scalar reconstruction formula and keeps SIMD/block - * paths optional correctness optimizations outside this reference scorer. + * Other multibit widths keep the generic sign+extra-bit reference scorer used + * for standalone ASH and future ASH reranking experiments. *

    * * NOTE: Only DOT_PRODUCT is supported for now. @@ -92,7 +96,11 @@ private ASHScoreFunction dotProductScoreFunctionFor(VectorFloat query) { return oneBitDotProductScoreFunction(qp); } - return multibitDotProductScoreFunction(qp); + if (AsymmetricHashing.usesFastScanProjectionCode(ash.bitsPerDimension)) { + return fastScanProjectionDotProductScoreFunction(qp); + } + + return genericMultibitDotProductScoreFunction(qp); } private ASHScoreFunction oneBitDotProductScoreFunction(QueryPrecompute qp) { @@ -123,17 +131,40 @@ private ASHScoreFunction oneBitDotProductScoreFunction(QueryPrecompute qp) { } // 1-bit ASH dot-product approximation: - // scale * (2 - ) + + ( - ||μ_c||²) + // scale * (2 - ) + + offset return v.scale * (2f * maskedAdd - qp.sumTildeQByLandmark[c]) + qp.dotQMuByLandmark[c] + v.offset; }; } - private ASHScoreFunction multibitDotProductScoreFunction(QueryPrecompute qp) { + private ASHScoreFunction fastScanProjectionDotProductScoreFunction(QueryPrecompute qp) { + final int d = qp.d; + final int C = qp.C; + final int bitsPerDimension = ash.bitsPerDimension; + + return (AsymmetricHashing.QuantizedVector v) -> { + final int c = v.landmark & 0xFF; // unsigned [0,C) + assert c < C : "Invalid landmark id " + c + " for landmarkCount=" + C; + + // C++ projection-mode scoring: + // score = scale * + + stored_offset + // stored_offset already includes -scale * . + float ip = AsymmetricHashing.dotProjectionCode( + qp.qProj, + v.extraBits, + d, + bitsPerDimension); + + return v.scale * ip + qp.dotQMuByLandmark[c] + v.offset; + }; + } + + private ASHScoreFunction genericMultibitDotProductScoreFunction(QueryPrecompute qp) { final int d = qp.d; final int C = qp.C; - final int exBits = ash.bitsPerDimension - 1; + final int bitsPerDimension = ash.bitsPerDimension; + final int exBits = bitsPerDimension - 1; final int signMagnitudeOffset = 1 << exBits; final float centerBias = -(signMagnitudeOffset - 0.5f); @@ -150,7 +181,7 @@ private ASHScoreFunction multibitDotProductScoreFunction(QueryPrecompute qp) { int sign = AsymmetricHashing.QuantizedVector.getBit(signBits, j) ? 1 : 0; int extra = AsymmetricHashing.QuantizedVector.readExtraCode(extraBits, j, exBits); - // Matches C++: + // Matches the C++ generic scalar reconstruction formula: // (sign << exBits) + extra - (2^exBits - 0.5) float code = (float) ((sign << exBits) + extra) + centerBias; ip += qp.tildeQPool[tildeBase + j] * code; @@ -189,9 +220,9 @@ private QueryPrecompute precomputeQuery(VectorFloat query) { } // For each landmark c: - // tildeQ_c = Aq - Aμ_c + // tildeQ_c = Aq - Aμ_c (1-bit and generic multibit paths) // sumTildeQc = sum(Aq) - sum(Aμ_c) - // dotQMu_c = + // dotQMu_c = (all paths) final float[] tildeQPool = new float[C * d]; final float[] sumTildeQByLandmark = new float[C]; final float[] dotQMuByLandmark = new float[C]; @@ -207,24 +238,27 @@ private QueryPrecompute precomputeQuery(VectorFloat query) { } } - return new QueryPrecompute(d, C, tildeQPool, sumTildeQByLandmark, dotQMuByLandmark); + return new QueryPrecompute(d, C, qProj, tildeQPool, sumTildeQByLandmark, dotQMuByLandmark); } private static final class QueryPrecompute { final int d; final int C; - final float[] tildeQPool; // [C * d] - final float[] sumTildeQByLandmark; // [C] - final float[] dotQMuByLandmark; // [C] + final float[] qProj; // [d], Aq + final float[] tildeQPool; // [C * d], Aq - Aμ_c + final float[] sumTildeQByLandmark; // [C] + final float[] dotQMuByLandmark; // [C] QueryPrecompute( int d, int C, + float[] qProj, float[] tildeQPool, float[] sumTildeQByLandmark, float[] dotQMuByLandmark) { this.d = d; this.C = C; + this.qProj = qProj; this.tildeQPool = tildeQPool; this.sumTildeQByLandmark = sumTildeQByLandmark; this.dotQMuByLandmark = dotQMuByLandmark; @@ -236,4 +270,3 @@ public interface ASHScoreFunction { float similarityTo(AsymmetricHashing.QuantizedVector vector2); } } - diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/AsymmetricHashing.java b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/AsymmetricHashing.java index de28de8f9..7930fe10e 100644 --- a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/AsymmetricHashing.java +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/AsymmetricHashing.java @@ -44,11 +44,11 @@ * Asymmetric Hashing (ASH) for float vectors. * Encodes each vector into a fixed-length code using a learned or random * orthonormal projection. The 1-bit case stores only sign bits; multibit ASH - * stores sign bits plus packed extra magnitude bits per projected dimension. + * stores either generic sign/extra bits or the C++-style fast-scan projection code for 2/4-bit ASH. */ public class AsymmetricHashing implements VectorCompressor, Accountable { public static final int ITQ = 1, RANDOM = 2; - private static final int MAGIC = 0x75EC4014; + private static final int MAGIC = 0x75EC4015; // --------------------------------------------------------------------- // Training configuration (reference defaults; TODO tune later) @@ -647,6 +647,117 @@ public float quantizeVector(VectorFloat vector, ); } + + // --------------------------------------------------------------------- + // Projection-code layout helpers + // --------------------------------------------------------------------- + + /** + * Fast-scan projection codes use C++-compatible 4-bit projection groups. + * Core ASH supports bitsPerDimension in [1,9], but this layout is only + * defined for the nibble-friendly multibit cases used by FusedASH. + */ + static boolean usesFastScanProjectionCode(int bitsPerDimension) { + return bitsPerDimension == 2 || bitsPerDimension == 4; + } + + static boolean supportsFusedAshBits(int bitsPerDimension) { + return bitsPerDimension == 1 || bitsPerDimension == 2 || bitsPerDimension == 4; + } + + static int projectionDimsPerNibble(int bitsPerDimension) { + if (bitsPerDimension != 1 && bitsPerDimension != 2 && bitsPerDimension != 4) { + throw new IllegalArgumentException( + "projection fast-scan layout supports bitsPerDimension in {1,2,4}, got " + + bitsPerDimension); + } + return 4 / bitsPerDimension; + } + + static int projectionCodeGroups(int quantizedDim, int bitsPerDimension) { + int groupDims = projectionDimsPerNibble(bitsPerDimension); + return (quantizedDim + groupDims - 1) / groupDims; + } + + static int projectionCodeBytesForDims(int quantizedDim, int bitsPerDimension) { + return (projectionCodeGroups(quantizedDim, bitsPerDimension) + 1) >>> 1; + } + + static void setFlatNibble(byte[] code, int group, int value) { + int idx = group >>> 1; + int v = value & 0x0F; + if ((group & 1) == 0) { + code[idx] = (byte) ((code[idx] & 0xF0) | v); + } else { + code[idx] = (byte) ((code[idx] & 0x0F) | (v << 4)); + } + } + + static int flatNibble(byte[] code, int group) { + int v = code[group >>> 1] & 0xFF; + return ((group & 1) == 0) ? (v & 0x0F) : ((v >>> 4) & 0x0F); + } + + static int projectionFieldForDimension( + byte[] projectionCode, + int dimension, + int bitsPerDimension + ) { + int groupDims = projectionDimsPerNibble(bitsPerDimension); + int group = dimension / groupDims; + int slot = dimension - group * groupDims; + int mask = (1 << bitsPerDimension) - 1; + return (flatNibble(projectionCode, group) >>> (slot * bitsPerDimension)) & mask; + } + + static float decodeProjectionComponent(int field, int bitsPerDimension) { + if (bitsPerDimension == 1) { + return field != 0 ? 1.0f : -1.0f; + } + + int exBits = bitsPerDimension - 1; + int magMask = (1 << exBits) - 1; + boolean positive = ((field >>> exBits) & 1) != 0; + float mag = (field & magMask) + 0.5f; + return positive ? mag : -mag; + } + + static float projectionComponent( + byte[] projectionCode, + int dimension, + int bitsPerDimension + ) { + return decodeProjectionComponent( + projectionFieldForDimension(projectionCode, dimension, bitsPerDimension), + bitsPerDimension); + } + + static float dotProjectionCode( + float[] projectedVector, + byte[] projectionCode, + int quantizedDim, + int bitsPerDimension + ) { + int groupDims = projectionDimsPerNibble(bitsPerDimension); + int groups = projectionCodeGroups(quantizedDim, bitsPerDimension); + float sum = 0.0f; + + for (int group = 0; group < groups; group++) { + int nibble = flatNibble(projectionCode, group); + int baseDim = group * groupDims; + for (int slot = 0; slot < groupDims; slot++) { + int dim = baseDim + slot; + if (dim >= quantizedDim) { + break; + } + int field = (nibble >>> (slot * bitsPerDimension)) & ((1 << bitsPerDimension) - 1); + sum += projectedVector[dim] * decodeProjectionComponent(field, bitsPerDimension); + } + } + + return sum; + } + // --------------------------------------------------------------------- // Quantization strategies // --------------------------------------------------------------------- @@ -759,7 +870,7 @@ public float quantizeBody(VectorFloat vector, float acc = vecUtil.ashDotRow(A[j], xhat); proj[j] = acc; projNorm2 += (double) acc * (double) acc; - if (acc >= 0.0f) { + if (!usesFastScanProjectionCode(bitsPerDimension) && acc >= 0.0f) { QuantizedVector.setBit(outSignWords, j); } } @@ -771,6 +882,16 @@ public float quantizeBody(VectorFloat vector, final double t = computeOptimalScalingFactor(absNorm, quantizedDim, bitsPerDimension); + if (usesFastScanProjectionCode(bitsPerDimension)) { + return quantizeFastScanProjectionCode( + proj, + absNorm, + t, + quantizedDim, + bitsPerDimension, + outExtraBits); + } + double codeNorm2 = 0.0; for (int j = 0; j < quantizedDim; j++) { int q = Math.min((int) (t * absNorm[j] + K_EPS), maxCode); @@ -783,6 +904,45 @@ public float quantizeBody(VectorFloat vector, return (float) Math.sqrt(Math.max(codeNorm2, 1e-20)); } + + private static float quantizeFastScanProjectionCode( + float[] proj, + double[] absNorm, + double t, + int quantizedDim, + int bitsPerDimension, + byte[] outProjectionCode + ) { + final int groupDims = projectionDimsPerNibble(bitsPerDimension); + final int groups = projectionCodeGroups(quantizedDim, bitsPerDimension); + final int exBits = bitsPerDimension - 1; + final int maxCode = (1 << exBits) - 1; + + double codeNorm2 = 0.0; + for (int group = 0; group < groups; group++) { + int nibble = 0; + int baseDim = group * groupDims; + + for (int slot = 0; slot < groupDims; slot++) { + int j = baseDim + slot; + if (j >= quantizedDim) { + break; + } + + int q = Math.min((int) (t * absNorm[j] + K_EPS), maxCode); + boolean positive = proj[j] >= 0.0f; + int field = (positive ? (1 << exBits) : 0) | q; + nibble |= field << (slot * bitsPerDimension); + + double mag = q + 0.5; + codeNorm2 += mag * mag; + } + + setFlatNibble(outProjectionCode, group, nibble); + } + + return (float) Math.sqrt(Math.max(codeNorm2, 1e-20)); + } } public static final class ItqBinaryQuantizer implements BinaryQuantizer { @@ -831,6 +991,10 @@ public static int wordsForDims(int quantizedDim) { return (quantizedDim + 63) >>> 6; } + public static int signWordsForDims(int quantizedDim, int bitsPerDimension) { + return usesFastScanProjectionCode(bitsPerDimension) ? 0 : wordsForDims(quantizedDim); + } + public static int extraBytesForDims(int quantizedDim, int bitsPerDimension) { int exBits = bitsPerDimension - 1; if (exBits <= 0) { @@ -839,6 +1003,13 @@ public static int extraBytesForDims(int quantizedDim, int bitsPerDimension) { return (quantizedDim * exBits + 7) >>> 3; } + public static int bodyBytesForDims(int quantizedDim, int bitsPerDimension) { + if (usesFastScanProjectionCode(bitsPerDimension)) { + return projectionCodeBytesForDims(quantizedDim, bitsPerDimension); + } + return extraBytesForDims(quantizedDim, bitsPerDimension); + } + public static QuantizedVector createEmpty(int quantizedDim) { return createEmpty(quantizedDim, DEFAULT_BITS_PER_DIMENSION); } @@ -848,8 +1019,8 @@ public static QuantizedVector createEmpty(int quantizedDim, int bitsPerDimension Float.NaN, Float.NaN, (byte) 0, - new long[wordsForDims(quantizedDim)], - new byte[extraBytesForDims(quantizedDim, bitsPerDimension)] + new long[signWordsForDims(quantizedDim, bitsPerDimension)], + new byte[bodyBytesForDims(quantizedDim, bitsPerDimension)] ); } @@ -859,8 +1030,8 @@ public static int serializedSizeBytes(int quantizedDim) { public static int serializedSizeBytes(int quantizedDim, int bitsPerDimension) { return Short.BYTES + Short.BYTES + Byte.BYTES - + wordsForDims(quantizedDim) * Long.BYTES - + extraBytesForDims(quantizedDim, bitsPerDimension); + + signWordsForDims(quantizedDim, bitsPerDimension) * Long.BYTES + + bodyBytesForDims(quantizedDim, bitsPerDimension); } /** @@ -884,19 +1055,21 @@ static float quantizeTo(VectorFloat vector, assert Float.isFinite(dest.offset) : "offset is not finite"; - int words = wordsForDims(quantizedDim); - if (dest.binaryVector.length < words) { + int words = signWordsForDims(quantizedDim, bitsPerDimension); + if (dest.binaryVector == null || dest.binaryVector.length < words) { throw new IllegalArgumentException("binaryVector too short"); } - int extraBytes = extraBytesForDims(quantizedDim, bitsPerDimension); - if (dest.extraBits == null || dest.extraBits.length < extraBytes) { - throw new IllegalArgumentException("extraBits too short"); + int bodyBytes = bodyBytesForDims(quantizedDim, bitsPerDimension); + if (dest.extraBits == null || dest.extraBits.length < bodyBytes) { + throw new IllegalArgumentException("extraBits/projectionCode too short"); } - Arrays.fill(dest.binaryVector, 0, words, 0L); - if (extraBytes > 0) { - Arrays.fill(dest.extraBits, 0, extraBytes, (byte) 0); + if (words > 0) { + Arrays.fill(dest.binaryVector, 0, words, 0L); + } + if (bodyBytes > 0) { + Arrays.fill(dest.extraBits, 0, bodyBytes, (byte) 0); } return quantizer.quantizeBody( @@ -920,13 +1093,13 @@ public void write(IndexWriter out, int quantizedDim, int bitsPerDimension) throw writeFloat16(out, offset); out.writeByte(landmark); - int words = wordsForDims(quantizedDim); + int words = signWordsForDims(quantizedDim, bitsPerDimension); for (int i = 0; i < words; i++) { out.writeLong(binaryVector[i]); } - int extraBytes = extraBytesForDims(quantizedDim, bitsPerDimension); - for (int i = 0; i < extraBytes; i++) { + int bodyBytes = bodyBytesForDims(quantizedDim, bitsPerDimension); + for (int i = 0; i < bodyBytes; i++) { out.writeByte(extraBits[i]); } } @@ -949,19 +1122,19 @@ public static QuantizedVector load(RandomAccessReader in, throw new IOException("ASH requires ByteReadable reader for landmark"); } - int words = wordsForDims(quantizedDim); - long[] body = new long[words]; + int words = signWordsForDims(quantizedDim, bitsPerDimension); + long[] signWords = new long[words]; for (int i = 0; i < words; i++) { - body[i] = in.readLong(); + signWords[i] = in.readLong(); } - int extraBytes = extraBytesForDims(quantizedDim, bitsPerDimension); - byte[] extra = new byte[extraBytes]; - if (extraBytes > 0) { - in.readFully(extra); + int bodyBytes = bodyBytesForDims(quantizedDim, bitsPerDimension); + byte[] body = new byte[bodyBytes]; + if (bodyBytes > 0) { + in.readFully(body); } - return new QuantizedVector(scale, offset, landmark, body, extra); + return new QuantizedVector(scale, offset, landmark, signWords, body); } public static void loadInto(RandomAccessReader in, @@ -983,19 +1156,19 @@ public static void loadInto(RandomAccessReader in, throw new IOException("ASH requires ByteReadable reader for landmark"); } - int words = wordsForDims(quantizedDim); - if (dest.binaryVector == null || dest.binaryVector.length < words) { + int words = signWordsForDims(quantizedDim, bitsPerDimension); + if (dest.binaryVector == null || dest.binaryVector.length != words) { dest.binaryVector = new long[words]; } for (int i = 0; i < words; i++) { dest.binaryVector[i] = in.readLong(); } - int extraBytes = extraBytesForDims(quantizedDim, bitsPerDimension); - if (dest.extraBits == null || dest.extraBits.length != extraBytes) { - dest.extraBits = new byte[extraBytes]; + int bodyBytes = bodyBytesForDims(quantizedDim, bitsPerDimension); + if (dest.extraBits == null || dest.extraBits.length != bodyBytes) { + dest.extraBits = new byte[bodyBytes]; } - if (extraBytes > 0) { + if (bodyBytes > 0) { in.readFully(dest.extraBits); } } @@ -1276,28 +1449,44 @@ public void encodeTo(VectorFloat vector, QuantizedVector dest) { } } - final VectorFloat mu = landmarks[landmark & 0xFF]; + final int c = landmark & 0xFF; + final VectorFloat mu = landmarks[c]; - // Compute . + // Compute and ||x_i − μ_i*||_2. final float dotXMu = VectorUtil.dotProduct(vector, mu); - - // Compute ||x_i − μ_i*||_2. final float sqDist = VectorUtil.squareL2Distance(vector, mu); final float residualNorm = (float) Math.sqrt(sqDist); assert quantizedDim > 0; - final float offset = dotXMu - landmarkNormSq[landmark]; + final float rawOffset = dotXMu - landmarkNormSq[c]; - // Header fields needed before quantization. - dest.offset = roundToFloat16(offset); + // Header fields needed before quantization. The final offset may be + // adjusted after quantization for C++-style fast-scan projection codes. + dest.offset = rawOffset; dest.landmark = landmark; - // Body: sign bits plus optional extra bits. The returned code norm is - // sqrt(d) for 1-bit ASH and sqrt(sum_j (q_j + 0.5)^2) for multibit ASH. + // Body: sign bits, generic sign+extra bits, or fast-scan projection code. + // The returned code norm is sqrt(d) for 1-bit ASH and + // sqrt(sum_j (q_j + 0.5)^2) for multibit ASH. float codeNorm = quantizeVector(vector, residualNorm, landmark, dest); final float scale = codeNorm > 0f ? residualNorm / codeNorm : 0f; + + float finalOffset = rawOffset; + if (usesFastScanProjectionCode(bitsPerDimension)) { + // C++ projection-mode optimization: + // offset = - ||μ||² - scale * + // so query-time scoring can use Aq instead of A(q - μ). + float landmarkDot = dotProjectionCode( + landmarkProj[c], + dest.extraBits, + quantizedDim, + bitsPerDimension); + finalOffset -= scale * landmarkDot; + } + dest.scale = roundToFloat16(scale); + dest.offset = roundToFloat16(finalOffset); } // --------------------------------------------------------------------- diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/FusedASHDecoder.java b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/FusedASHDecoder.java new file mode 100644 index 000000000..d26e3eaea --- /dev/null +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/FusedASHDecoder.java @@ -0,0 +1,294 @@ +/* + * 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 io.github.jbellis.jvector.graph.disk.feature.FusedFeature; +import io.github.jbellis.jvector.graph.similarity.ScoreFunction; +import io.github.jbellis.jvector.vector.VectorSimilarityFunction; +import io.github.jbellis.jvector.vector.VectorUtil; +import io.github.jbellis.jvector.vector.VectorUtilSupport; +import io.github.jbellis.jvector.vector.VectorizationProvider; +import io.github.jbellis.jvector.vector.types.VectorFloat; +import org.agrona.collections.Int2ObjectHashMap; + +import java.util.Objects; + +/** + * Scores FusedASH neighborhood blocks. + * + *

    The fused layout is intentionally block-oriented: {@link #enableSimilarityToNeighbors(int)} + * reads and scores the whole packed neighborhood for one origin node, and + * {@link #similarityToNeighbor(int, int)} becomes a cheap array lookup. This preserves the + * existing fused-feature search API while avoiding one-neighbor-at-a-time decode work in the + * hot graph-expansion loop.

    + * + *

    This class is the scalar/reference implementation over the final FusedASH block layout. + * SIMD kernels should consume the same byte layout and produce the same lane scores.

    + * + *

    FusedASH has two primary scoring paths:

    + * + *
      + *
    • L0 neighborhood scoring uses fused packed ASH blocks via + * {@link #enableSimilarityToNeighbors(int)} and + * {@link #similarityToNeighbor(int, int)}.
    • + *
    • Upper-layer/direct node scoring uses canonical per-node ASH source + * features via {@link #similarityTo(int)}. Upper layers are not fused.
    • + *
    + * + *

    FusedASH lane headers are expected to contain projection-adjusted offsets:

    + * + *
    + *   offset = <x, μ> - ||μ||² - scale * <Aμ, code>
    + * 
    + * + *

    Given that encode-time adjustment, query-time scoring is:

    + * + *
    + *   score = scale * <Aq, code> + <q, μ> + offset
    + * 
    + */ +public final class FusedASHDecoder implements ScoreFunction.ApproximateScoreFunction { + + /** + * Source of fused neighborhood bytes. The implementation is provided by the graph feature, + * typically by reading the FusedASH inline payload for {@code origin} into {@code dest}. + */ + public interface PackedNeighborhoods { + void readInto(int origin, byte[] dest); + int maxDegree(); + int featureSize(); + } + + /** + * Converts an in-memory upper-layer/source feature into the canonical ASH vector + * used for primary direct-node ASH scoring via {@link #similarityTo(int)}. + */ + @FunctionalInterface + public interface InlineSourceAccessor { + AsymmetricHashing.QuantizedVector vector(FusedFeature.InlineSource source); + } + + private final AsymmetricHashing ash; + private final PackedNeighborhoods packedNeighborhoods; + private final Int2ObjectHashMap hierarchyCachedFeatures; + private final InlineSourceAccessor inlineSourceAccessor; + private final ASHScorer.ASHScoreFunction sourceScorer; + + private final int maxDegree; + private final int blockSize; + private final int blocksPerNode; + private final int quantizedDim; + private final int bitsPerDimension; + + private final byte[] neighborhoodScratch; + private final float[] neighborScores; + + private final float[] queryLut; + private final float[] dotQMuByLandmark; + + private int origin = -1; + + public FusedASHDecoder( + PackedNeighborhoods packedNeighborhoods, + AsymmetricHashing ash, + Int2ObjectHashMap hierarchyCachedFeatures, + InlineSourceAccessor inlineSourceAccessor, + VectorFloat query, + byte[] reusableNeighborhoodBytes, + float[] reusableScores, + int blockSize, + VectorSimilarityFunction similarityFunction) { + + if (similarityFunction != VectorSimilarityFunction.DOT_PRODUCT) { + throw new UnsupportedOperationException("FusedASH supports DOT_PRODUCT only"); + } + + this.packedNeighborhoods = Objects.requireNonNull(packedNeighborhoods, "packedNeighborhoods"); + this.ash = Objects.requireNonNull(ash, "ash"); + this.hierarchyCachedFeatures = Objects.requireNonNull( + hierarchyCachedFeatures, + "hierarchyCachedFeatures" + ); + this.inlineSourceAccessor = Objects.requireNonNull( + inlineSourceAccessor, + "inlineSourceAccessor" + ); + this.sourceScorer = new ASHScorer(ash).scoreFunctionFor(query, similarityFunction); + + FusedASHLayout.validateBitsPerDimension(ash.bitsPerDimension); + FusedASHLayout.validateBlockSize(blockSize); + + this.maxDegree = packedNeighborhoods.maxDegree(); + if (maxDegree <= 0) { + throw new IllegalArgumentException("maxDegree must be > 0"); + } + + this.blockSize = blockSize; + this.blocksPerNode = FusedASHLayout.blocksPerNode(maxDegree, blockSize); + this.quantizedDim = ash.quantizedDim; + this.bitsPerDimension = ash.bitsPerDimension; + + int expectedFeatureSize = FusedASHLayout.featureSize( + maxDegree, + quantizedDim, + bitsPerDimension, + blockSize + ); + if (packedNeighborhoods.featureSize() != expectedFeatureSize) { + throw new IllegalArgumentException( + "FusedASH feature size mismatch: expected " + expectedFeatureSize + + ", got " + packedNeighborhoods.featureSize()); + } + if (reusableNeighborhoodBytes.length < expectedFeatureSize) { + throw new IllegalArgumentException("reusableNeighborhoodBytes is smaller than FusedASH feature size"); + } + if (reusableScores.length < maxDegree) { + throw new IllegalArgumentException("reusableScores is smaller than maxDegree"); + } + + this.neighborhoodScratch = reusableNeighborhoodBytes; + this.neighborScores = reusableScores; + + int groups = FusedASHLayout.codeGroups(quantizedDim, bitsPerDimension); + this.queryLut = new float[groups * 16]; + this.dotQMuByLandmark = new float[ash.landmarkCount]; + + precomputeQuery(query, queryLut, dotQMuByLandmark); + } + + public static FusedASHDecoder newDecoder( + PackedNeighborhoods packedNeighborhoods, + AsymmetricHashing ash, + Int2ObjectHashMap hierarchyCachedFeatures, + InlineSourceAccessor inlineSourceAccessor, + VectorFloat query, + byte[] reusableNeighborhoodBytes, + float[] reusableScores, + int blockSize, + VectorSimilarityFunction similarityFunction) { + return new FusedASHDecoder( + packedNeighborhoods, + ash, + hierarchyCachedFeatures, + inlineSourceAccessor, + query, + reusableNeighborhoodBytes, + reusableScores, + blockSize, + similarityFunction); + } + + @Override + public boolean supportsSimilarityToNeighbors() { + return true; + } + + @Override + public void enableSimilarityToNeighbors(int origin) { + if (this.origin == origin) { + return; + } + + this.origin = origin; + packedNeighborhoods.readInto(origin, neighborhoodScratch); + scoreNeighborhood(); + } + + /** + * Scores a directly-addressed node using its canonical ASH source feature. + * + *

    This method is used for upper-layer graph traversal and other direct-node + * primary scoring paths. It must not call the configured reranker. Missing + * source features indicate an invalid fused graph/source-feature layout.

    + */ + @Override + public float similarityTo(int node2) { + FusedFeature.InlineSource source = hierarchyCachedFeatures.get(node2); + if (source == null) { + throw new IllegalStateException( + "Missing FusedASH source feature for direct ASH scoring of node " + node2 + + ". FusedASH requires canonical ASH source features for every upper-layer node " + + "that can be scored directly." + ); + } + + AsymmetricHashing.QuantizedVector vector = inlineSourceAccessor.vector(source); + if (vector == null) { + throw new IllegalStateException( + "FusedASH source accessor returned null for node " + node2 + ); + } + + return sourceScorer.similarityTo(vector); + } + + @Override + public float similarityToNeighbor(int origin, int neighborIndex) { + if (this.origin != origin) { + throw new IllegalArgumentException( + "origin must be the same as the origin used to enable similarityToNeighbor"); + } + if (neighborIndex < 0 || neighborIndex >= maxDegree) { + throw new IllegalArgumentException("neighborIndex out of range: " + neighborIndex); + } + return neighborScores[neighborIndex]; + } + + private void scoreNeighborhood() { + for (int block = 0; block < blocksPerNode; block++) { + int blockOffset = FusedASHLayout.blockOffset(block, quantizedDim, bitsPerDimension, blockSize); + int laneBase = block * blockSize; + int lanes = Math.min(blockSize, maxDegree - laneBase); + + for (int lane = 0; lane < lanes; lane++) { + neighborScores[laneBase + lane] = FusedASHLayout.scoreLane( + neighborhoodScratch, + blockOffset, + lane, + quantizedDim, + bitsPerDimension, + blockSize, + queryLut, + dotQMuByLandmark); + } + } + } + + private void precomputeQuery(VectorFloat query, float[] lut, float[] dotQMu) { + final int D = ash.originalDimension; + final int d = ash.quantizedDim; + final float[][] A = ash.stiefelTransform.AFloat; + + final VectorUtilSupport vecUtil = VectorizationProvider.getInstance().getVectorUtilSupport(); + + float[] qArr = new float[D]; + for (int i = 0; i < D; i++) { + qArr[i] = query.get(i); + } + + float[] qProj = new float[d]; + for (int j = 0; j < d; j++) { + qProj[j] = vecUtil.ashDotRow(A[j], qArr); + } + + FusedASHLayout.buildQueryLut(qProj, d, bitsPerDimension, lut); + + for (int c = 0; c < ash.landmarkCount; c++) { + dotQMu[c] = VectorUtil.dotProduct(query, ash.landmarks[c]); + } + } +} \ No newline at end of file diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/FusedASHLayout.java b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/FusedASHLayout.java new file mode 100644 index 000000000..d7bd8ad40 --- /dev/null +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/FusedASHLayout.java @@ -0,0 +1,412 @@ +/* + * 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; + +/** + * Layout and scalar reference helpers for FusedASH neighborhood blocks. + * + *

    The canonical ASH vector format remains owned by {@link AsymmetricHashing}. + * FusedASH stores each origin node's neighbor codes in a neighborhood-local block + * layout optimized for scoring the whole adjacency list at once. The projection + * code itself follows the C++ fast-scan nibble model for bits-per-dimension + * values {@code 1}, {@code 2}, and {@code 4}:

    + * + *
      + *
    • 1 bit/dim: four projected dimensions per nibble
    • + *
    • 2 bits/dim: two projected dimensions per nibble
    • + *
    • 4 bits/dim: one projected dimension per nibble
    • + *
    + * + *

    Within a fused block, bytes are stored by code-group pair first and lane + * second:

    + * + *
    + *   byteIndex = blockOffset + (group / 2) * blockSize + lane
    + * 
    + * + *

    The low nibble stores even groups and the high nibble stores odd groups. + * This is deliberately graph-neighborhood-oriented: it supports block sizes + * {@code 8}, {@code 16}, and {@code 32} without copying the IVF-specific B32 + * addressing scheme.

    + */ +public final class FusedASHLayout { + private FusedASHLayout() {} + + public static final int SCALE_BYTES = Short.BYTES; + public static final int OFFSET_BYTES = Short.BYTES; + public static final int LANDMARK_BYTES = Byte.BYTES; + public static final int HEADER_BYTES_PER_LANE = SCALE_BYTES + OFFSET_BYTES + LANDMARK_BYTES; + + public static boolean supportsBitsPerDimension(int bitsPerDimension) { + return bitsPerDimension == 1 || bitsPerDimension == 2 || bitsPerDimension == 4; + } + + public static void validateBitsPerDimension(int bitsPerDimension) { + if (!supportsBitsPerDimension(bitsPerDimension)) { + throw new IllegalArgumentException( + "FusedASH supports bitsPerDimension in {1,2,4}; got " + bitsPerDimension); + } + } + + public static void validateBlockSize(int blockSize) { + if (blockSize != 8 && blockSize != 16 && blockSize != 32) { + throw new IllegalArgumentException( + "FusedASH blockSize must be one of {8,16,32}; got " + blockSize); + } + } + + public static int chooseBlockSize(int maxDegree) { + if (maxDegree <= 0) { + throw new IllegalArgumentException("maxDegree must be > 0"); + } + if (maxDegree <= 8) return 8; + if (maxDegree <= 16) return 16; + return 32; + } + + public static int projectionDimsPerNibble(int bitsPerDimension) { + validateBitsPerDimension(bitsPerDimension); + return 4 / bitsPerDimension; + } + + public static int codeGroups(int quantizedDim, int bitsPerDimension) { + if (quantizedDim <= 0) { + throw new IllegalArgumentException("quantizedDim must be > 0"); + } + int groupDims = projectionDimsPerNibble(bitsPerDimension); + return (quantizedDim + groupDims - 1) / groupDims; + } + + public static int canonicalCodeBytes(int quantizedDim, int bitsPerDimension) { + return (codeGroups(quantizedDim, bitsPerDimension) + 1) >>> 1; + } + + public static int blockBodyBytes(int quantizedDim, int bitsPerDimension, int blockSize) { + validateBlockSize(blockSize); + return canonicalCodeBytes(quantizedDim, bitsPerDimension) * blockSize; + } + + public static int blockHeaderBytes(int blockSize) { + validateBlockSize(blockSize); + return HEADER_BYTES_PER_LANE * blockSize; + } + + public static int blockBytes(int quantizedDim, int bitsPerDimension, int blockSize) { + return blockBodyBytes(quantizedDim, bitsPerDimension, blockSize) + blockHeaderBytes(blockSize); + } + + public static int blocksPerNode(int maxDegree, int blockSize) { + if (maxDegree <= 0) { + throw new IllegalArgumentException("maxDegree must be > 0"); + } + validateBlockSize(blockSize); + return (maxDegree + blockSize - 1) / blockSize; + } + + public static int featureSize(int maxDegree, int quantizedDim, int bitsPerDimension, int blockSize) { + return blocksPerNode(maxDegree, blockSize) * blockBytes(quantizedDim, bitsPerDimension, blockSize); + } + + public static int blockOffset(int blockIndex, int quantizedDim, int bitsPerDimension, int blockSize) { + return blockIndex * blockBytes(quantizedDim, bitsPerDimension, blockSize); + } + + public static int scaleOffset(int blockOffset, int quantizedDim, int bitsPerDimension, int blockSize) { + return blockOffset + blockBodyBytes(quantizedDim, bitsPerDimension, blockSize); + } + + public static int offsetOffset(int blockOffset, int quantizedDim, int bitsPerDimension, int blockSize) { + return scaleOffset(blockOffset, quantizedDim, bitsPerDimension, blockSize) + SCALE_BYTES * blockSize; + } + + public static int landmarkOffset(int blockOffset, int quantizedDim, int bitsPerDimension, int blockSize) { + return offsetOffset(blockOffset, quantizedDim, bitsPerDimension, blockSize) + OFFSET_BYTES * blockSize; + } + + public static int getPackedNibble(byte[] block, int blockOffset, int lane, int group, int blockSize) { + validateLane(lane, blockSize); + int byteIndex = blockOffset + (group >>> 1) * blockSize + lane; + int value = block[byteIndex] & 0xFF; + return ((group & 1) == 0) ? (value & 0x0F) : ((value >>> 4) & 0x0F); + } + + public static void setPackedNibble(byte[] block, int blockOffset, int lane, int group, int blockSize, int nibble) { + validateLane(lane, blockSize); + int byteIndex = blockOffset + (group >>> 1) * blockSize + lane; + int old = block[byteIndex] & 0xFF; + int n = nibble & 0x0F; + block[byteIndex] = (byte) (((group & 1) == 0) + ? ((old & 0xF0) | n) + : ((old & 0x0F) | (n << 4))); + } + + public static int flatNibble(byte[] code, int group) { + int b = code[group >>> 1] & 0xFF; + return ((group & 1) == 0) ? (b & 0x0F) : ((b >>> 4) & 0x0F); + } + + public static void setFlatNibble(byte[] code, int group, int nibble) { + int byteIndex = group >>> 1; + int old = code[byteIndex] & 0xFF; + int n = nibble & 0x0F; + code[byteIndex] = (byte) (((group & 1) == 0) + ? ((old & 0xF0) | n) + : ((old & 0x0F) | (n << 4))); + } + + public static float decodeProjectionComponent(int field, int bitsPerDimension) { + validateBitsPerDimension(bitsPerDimension); + if (bitsPerDimension == 1) { + return field != 0 ? 1.0f : -1.0f; + } + + int exBits = bitsPerDimension - 1; + int magMask = (1 << exBits) - 1; + boolean positive = ((field >>> exBits) & 1) != 0; + float mag = (field & magMask) + 0.5f; + return positive ? mag : -mag; + } + + public static int signNibbleFromWords(long[] signWords, int group, int quantizedDim) { + int baseDim = group << 2; + int nibble = 0; + for (int slot = 0; slot < 4; slot++) { + int dim = baseDim + slot; + if (dim >= quantizedDim) break; + if (((signWords[dim >>> 6] >>> (dim & 63)) & 1L) != 0L) { + nibble |= 1 << slot; + } + } + return nibble; + } + + public static void packQuantizedVector( + byte[] block, + int blockOffset, + int lane, + AsymmetricHashing.QuantizedVector vector, + int quantizedDim, + int bitsPerDimension, + int blockSize) { + validateBitsPerDimension(bitsPerDimension); + validateLane(lane, blockSize); + + int groups = codeGroups(quantizedDim, bitsPerDimension); + if (bitsPerDimension == 1) { + for (int group = 0; group < groups; group++) { + setPackedNibble(block, blockOffset, lane, group, blockSize, + signNibbleFromWords(vector.binaryVector, group, quantizedDim)); + } + } else { + byte[] code = vector.extraBits; + int bytes = canonicalCodeBytes(quantizedDim, bitsPerDimension); + if (code == null || code.length < bytes) { + throw new IllegalArgumentException("ASH projection code is missing or too short"); + } + for (int group = 0; group < groups; group++) { + setPackedNibble(block, blockOffset, lane, group, blockSize, flatNibble(code, group)); + } + } + + writeLaneHeader(block, blockOffset, lane, quantizedDim, bitsPerDimension, blockSize, + vector.scale, vector.offset, vector.landmark); + } + + public static void writeLaneHeader( + byte[] block, + int blockOffset, + int lane, + int quantizedDim, + int bitsPerDimension, + int blockSize, + float scale, + float offset, + byte landmark) { + validateLane(lane, blockSize); + writeFloat16(block, scaleOffset(blockOffset, quantizedDim, bitsPerDimension, blockSize) + lane * SCALE_BYTES, scale); + writeFloat16(block, offsetOffset(blockOffset, quantizedDim, bitsPerDimension, blockSize) + lane * OFFSET_BYTES, offset); + block[landmarkOffset(blockOffset, quantizedDim, bitsPerDimension, blockSize) + lane] = landmark; + } + + public static float readScale(byte[] block, int blockOffset, int lane, int quantizedDim, int bitsPerDimension, int blockSize) { + validateLane(lane, blockSize); + return readFloat16(block, scaleOffset(blockOffset, quantizedDim, bitsPerDimension, blockSize) + lane * SCALE_BYTES); + } + + public static float readOffset(byte[] block, int blockOffset, int lane, int quantizedDim, int bitsPerDimension, int blockSize) { + validateLane(lane, blockSize); + return readFloat16(block, offsetOffset(blockOffset, quantizedDim, bitsPerDimension, blockSize) + lane * OFFSET_BYTES); + } + + public static int readLandmark(byte[] block, int blockOffset, int lane, int quantizedDim, int bitsPerDimension, int blockSize) { + validateLane(lane, blockSize); + return block[landmarkOffset(blockOffset, quantizedDim, bitsPerDimension, blockSize) + lane] & 0xFF; + } + + public static void buildQueryLut(float[] qProj, int quantizedDim, int bitsPerDimension, float[] lut) { + validateBitsPerDimension(bitsPerDimension); + int groupDims = projectionDimsPerNibble(bitsPerDimension); + int groups = codeGroups(quantizedDim, bitsPerDimension); + if (qProj.length < quantizedDim) { + throw new IllegalArgumentException("qProj length is smaller than quantizedDim"); + } + if (lut.length < groups * 16) { + throw new IllegalArgumentException("lut length is smaller than groups * 16"); + } + + for (int group = 0; group < groups; group++) { + int dimBase = group * groupDims; + int lutBase = group << 4; + for (int nibble = 0; nibble < 16; nibble++) { + float sum = 0f; + for (int slot = 0; slot < groupDims; slot++) { + int dim = dimBase + slot; + if (dim >= quantizedDim) break; + int mask = (1 << bitsPerDimension) - 1; + int field = (nibble >>> (slot * bitsPerDimension)) & mask; + sum += qProj[dim] * decodeProjectionComponent(field, bitsPerDimension); + } + lut[lutBase + nibble] = sum; + } + } + } + + public static float scoreLane( + byte[] block, + int blockOffset, + int lane, + int quantizedDim, + int bitsPerDimension, + int blockSize, + float[] lut, + float[] dotQMuByLandmark) { + validateLane(lane, blockSize); + int groups = codeGroups(quantizedDim, bitsPerDimension); + float ip = 0f; + for (int group = 0; group < groups; group++) { + int nibble = getPackedNibble(block, blockOffset, lane, group, blockSize); + ip += lut[(group << 4) + nibble]; + } + + int landmark = readLandmark(block, blockOffset, lane, quantizedDim, bitsPerDimension, blockSize); + float dotQMu = landmark < dotQMuByLandmark.length ? dotQMuByLandmark[landmark] : 0f; + return readScale(block, blockOffset, lane, quantizedDim, bitsPerDimension, blockSize) * ip + + dotQMu + + readOffset(block, blockOffset, lane, quantizedDim, bitsPerDimension, blockSize); + } + + private static void validateLane(int lane, int blockSize) { + validateBlockSize(blockSize); + if (lane < 0 || lane >= blockSize) { + throw new IllegalArgumentException("lane out of range: " + lane + " for blockSize=" + blockSize); + } + } + + private static void writeFloat16(byte[] out, int offset, float value) { + int bits = floatToHalfBits(value); + out[offset] = (byte) ((bits >>> 8) & 0xFF); + out[offset + 1] = (byte) (bits & 0xFF); + } + + private static float readFloat16(byte[] in, int offset) { + int hi = in[offset] & 0xFF; + int lo = in[offset + 1] & 0xFF; + return halfBitsToFloat((hi << 8) | lo); + } + + /** IEEE-754 binary16 round-to-nearest-even conversion. */ + private static int floatToHalfBits(float value) { + int bits = Float.floatToRawIntBits(value); + int sign = (bits >>> 16) & 0x8000; + int abs = bits & 0x7FFF_FFFF; + + if (abs >= 0x7F80_0000) { + if ((abs & 0x007F_FFFF) == 0) { + return sign | 0x7C00; + } + return sign | 0x7E00; + } + + int exp = ((abs >>> 23) & 0xFF) - 127 + 15; + int mant = abs & 0x007F_FFFF; + + if (exp >= 31) { + return sign | 0x7C00; + } + + if (exp <= 0) { + if (exp < -10) { + return sign; + } + + mant |= 0x0080_0000; + int shift = 14 - exp; + int halfMant = mant >>> shift; + + int roundBit = 1 << (shift - 1); + int remainder = mant & (roundBit - 1); + + if ((mant & roundBit) != 0 && (remainder != 0 || (halfMant & 1) != 0)) { + halfMant++; + } + + return sign | halfMant; + } + + int halfMant = mant >>> 13; + int roundBit = 0x0000_1000; + int remainder = mant & (roundBit - 1); + + if ((mant & roundBit) != 0 && (remainder != 0 || (halfMant & 1) != 0)) { + halfMant++; + if (halfMant == 0x0400) { + halfMant = 0; + exp++; + if (exp >= 31) { + return sign | 0x7C00; + } + } + } + + return sign | (exp << 10) | halfMant; + } + + private static float halfBitsToFloat(int half) { + int h = half & 0xFFFF; + int sign = (h & 0x8000) << 16; + int exp = (h >>> 10) & 0x1F; + int mant = h & 0x03FF; + + if (exp == 0) { + if (mant == 0) { + return Float.intBitsToFloat(sign); + } + + while ((mant & 0x0400) == 0) { + mant <<= 1; + exp--; + } + exp++; + mant &= 0x03FF; + } else if (exp == 31) { + return Float.intBitsToFloat(sign | 0x7F80_0000 | (mant << 13)); + } + + int floatExp = exp + (127 - 15); + return Float.intBitsToFloat(sign | (floatExp << 23) | (mant << 13)); + } +} diff --git a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/DistancesASH.java b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/DistancesASH.java index e9c6a3f29..d323b5145 100644 --- a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/DistancesASH.java +++ b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/DistancesASH.java @@ -107,7 +107,7 @@ public static void testASHEncodings(String filenameBase, String filenameQueries, final int[] BLOCK_SIZES = {32}; // 16, 32, and/or 64 // How many ASH landmarks to use, C = [1, 64] - final int landmarkCount = 32; + final int landmarkCount = 1; List> vectors = SiftLoader.readFvecs(filenameBase); List> queries = SiftLoader.readFvecs(filenameQueries); @@ -124,7 +124,7 @@ public static void testASHEncodings(String filenameBase, String filenameQueries, // - No other normalization steps are applied. int dimension = vectors.get(0).length(); - int encodedBits = 1064; // (dimension / 4) + HEADER_BITS; + int encodedBits = 1536; // (dimension / 4) + HEADER_BITS; // Payload must be 64-bit aligned for SIMD int payloadBits = encodedBits - HEADER_BITS; // if ((payloadBits & 63) != 0) { @@ -640,9 +640,9 @@ public static void runColbert() throws IOException { public static void runOpenai1536() throws IOException { System.out.println("Running text-embedding-3-large_1536"); - var baseVectors = "./fvec/openai-v3-large-1536-100k/text-embedding-3-large_1536_100000_base_vectors.fvec"; - var queryVectors = "./fvec/openai-v3-large-1536-100k/text-embedding-3-large_1536_100000_query_vectors_10000.fvec"; - var gtVectors = "./fvec/openai-v3-large-1536-100k/text-embedding-3-large_1536_100000_indices_query_10000.ivec"; + var baseVectors = "./fvec/openai-v3-large-1536-100k/openai_v3_large_1536_100k_base_98716.fvecs"; + var queryVectors = "./fvec/openai-v3-large-1536-100k/openai_v3_large_1536_100k_query_10000.fvecs"; + var gtVectors = "./fvec/openai-v3-large-1536-100k/openai_v3_large_1536_100k_gt_ip_100.ivecs"; testASHEncodings(baseVectors, queryVectors, gtVectors); } @@ -677,10 +677,10 @@ public static void main(String[] args) throws IOException { // runSIFT(); // runGIST(); // runColbert(); - runCohere100k(); +// runCohere100k(); // runADA(); // runADANoZeros(); -// runOpenai1536(); + runOpenai1536(); // runOpenai3072(); // runCap6m(); // runCohere10m(); diff --git a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/DistancesPQ.java b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/DistancesPQ.java index 1fc3d4c08..cbf3572e0 100644 --- a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/DistancesPQ.java +++ b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/DistancesPQ.java @@ -47,10 +47,16 @@ public class DistancesPQ { * * Fast PQ path = PQDecoder (precomputedScoreFunctionFor). */ - public static void testPQEncodings(String filenameBase, String filenameQueries) throws IOException { + public static void testPQEncodings(String filenameBase, String filenameQueries, String filenameGT) throws IOException { // ------------------------------------------------------------ // Benchmark configuration (runtime flags) // ------------------------------------------------------------ + final boolean RUN_RECALL_CHECK = + Boolean.parseBoolean(System.getProperty("jvector.bench.recall", "false")); + + final int RECALL_K = + Integer.getInteger("jvector.bench.recall.k", 10); + final boolean RUN_ACCURACY_CHECK = Boolean.parseBoolean(System.getProperty("jvector.bench.accuracy-check", "false")); final boolean RUN_FLOAT_SCORING = @@ -59,6 +65,36 @@ public static void testPQEncodings(String filenameBase, String filenameQueries) List> vectors = SiftLoader.readFvecs(filenameBase); List> queries = SiftLoader.readFvecs(filenameQueries); + final List> groundTruth; + if (RUN_RECALL_CHECK) { + if (filenameGT == null || filenameGT.trim().isEmpty()) { + throw new IllegalArgumentException( + "Recall is enabled but no ground-truth ivec file was provided" + ); + } + + groundTruth = SiftLoader.readIvecs(filenameGT); + + if (groundTruth.size() < queries.size()) { + throw new IllegalArgumentException( + "Ground truth contains " + groundTruth.size() + + " rows but there are " + queries.size() + " queries" + ); + } + + for (int i = 0; i < queries.size(); i++) { + if (groundTruth.get(i).size() < RECALL_K) { + throw new IllegalArgumentException( + "Ground truth row " + i + " has only " + + groundTruth.get(i).size() + + " entries, but recall.k=" + RECALL_K + ); + } + } + } else { + groundTruth = java.util.Collections.emptyList(); + } + // ------------------------------------------------------------ // Parallel executor (identical to DistancesASH) // ------------------------------------------------------------ @@ -68,32 +104,56 @@ public static void testPQEncodings(String filenameBase, String filenameQueries) int chunkSize = Math.max(1, (queries.size() + parallelism - 1) / parallelism); // ------------------------------------------------------------ - // PQ parameters (EXACT bit-for-bit match with ASH) + // PQ parameters // ------------------------------------------------------------ - // ASH total size = encodedBits (includes header + payload) - // PQ total size = M * 8 bits (1 byte per subspace) + // M is derived from the input dimension: + // + // M = dimension / mFactor + // + // With K=256, each subspace code is one byte, so PQ stores M bytes + // per vector. Since the input is float32, the exact compression ratio is: // - // We REQUIRE exact equality for fair benchmarking. - int HEADER_BITS = 72; + // (dimension * 4 bytes) / M bytes = 4 * mFactor + // + // This requires dimension to be evenly divisible by mFactor. int dimension = vectors.get(0).length(); - int encodedBitsASH = (dimension / 4) + HEADER_BITS; // HEADER_BITS = 72 - if ((encodedBitsASH & 7) != 0) { + + final int mFactor = Integer.getInteger("jvector.pq.mFactor", 8); + if (mFactor <= 0) { throw new IllegalArgumentException( - "PQ requires encodedBits to be a multiple of 8 for exact bit matching; got " + encodedBitsASH + "jvector.pq.mFactor must be positive; got " + mFactor + ); + } + if (dimension % mFactor != 0) { + throw new IllegalArgumentException( + "dimension must be evenly divisible by jvector.pq.mFactor: " + + "dimension=" + dimension + + ", mFactor=" + mFactor ); } - final int M = encodedBitsASH / 8; + final int M = dimension / mFactor; final int K = 256; // 1 byte per subspace final boolean globallyCenter = false; final float anisotropicThreshold = 0.0f; + final int inputBytesPerVector = dimension * Float.BYTES; + final int compressedBytesPerVector = M; + final double compressionRatio = + (double) inputBytesPerVector / compressedBytesPerVector; + System.out.println("\tPQ params:"); + System.out.println("\t dimension = " + dimension); + System.out.println("\t mFactor = " + mFactor); System.out.println("\t M = " + M + " subspaces"); System.out.println("\t K = " + K + " clusters"); - System.out.println("\t Compression = " + (M * 8) + " bits (" + M + " bytes)"); - System.out.println("\tASH reference = " + encodedBitsASH + " bits (" + (encodedBitsASH / 8) + " bytes)"); - + System.out.println("\t Input size = " + inputBytesPerVector + " bytes/vector"); + System.out.println("\t PQ size = " + compressedBytesPerVector + " bytes/vector"); + System.out.println( + "\t Compression ratio = " + + String.format(java.util.Locale.ROOT, "%.3f", compressionRatio) + + "x" + ); // ------------------------------------------------------------ // Train PQ + encode (timed) @@ -176,6 +236,105 @@ public static void testPQEncodings(String filenameBase, String filenameQueries) System.out.println("\tAverage absolute dot-product error = " + (totalError / totalCount)); } + // ============================================================ + // [1b] Recall@K run (NOT timed) + // ============================================================ + if (RUN_RECALL_CHECK) { + final int[] atValues = {10, 15, 20, 30, 40, 50}; + final int maxAt = atValues[atValues.length - 1]; + + List> recallTasks = new ArrayList<>(); + + System.out.println("\t[stage] Computing " + RECALL_K + "-Recall@K..."); + System.out.flush(); + + for (int start = 0; start < queries.size(); start += chunkSize) { + final int s = start; + final int e = Math.min(start + chunkSize, queries.size()); + + recallTasks.add(simdExecutor.submit(() -> { + double[] localTotalRecall = new double[atValues.length]; + + for (int i = s; i < e; i++) { + ScoreFunction.ApproximateScoreFunction f = + pqVecs.precomputedScoreFunctionFor( + queries.get(i), + VectorSimilarityFunction.DOT_PRODUCT + ); + + // Min-heap keeping the top maxAt approximate results. + var topCandidates = + new java.util.PriorityQueue((a, b) -> + Float.compare( + Float.intBitsToFloat((int) a[0]), + Float.intBitsToFloat((int) b[0]) + )); + + for (int j = 0; j < vectors.size(); j++) { + float score = f.similarityTo(j); + + if (topCandidates.size() < maxAt) { + topCandidates.add(new long[]{Float.floatToRawIntBits(score), j}); + } else if (score > Float.intBitsToFloat((int) topCandidates.peek()[0])) { + topCandidates.poll(); + topCandidates.add(new long[]{Float.floatToRawIntBits(score), j}); + } + } + + int[] topIndices = new int[topCandidates.size()]; + for (int rank = topCandidates.size() - 1; rank >= 0; rank--) { + topIndices[rank] = (int) topCandidates.poll()[1]; + } + + int[] queryGT = groundTruth.get(i).stream() + .mapToInt(Integer::intValue) + .toArray(); + + for (int aIdx = 0; aIdx < atValues.length; aIdx++) { + int at = atValues[aIdx]; + + java.util.Set topAtSet = new java.util.HashSet<>(); + for (int r = 0; r < Math.min(at, topIndices.length); r++) { + topAtSet.add(topIndices[r]); + } + + int matches = 0; + java.util.Set gtSeen = new java.util.HashSet<>(RECALL_K * 2); + + // Avoid double-counting duplicate GT ids. + for (int g = 0; g < RECALL_K; g++) { + int gtId = queryGT[g]; + if (gtSeen.add(gtId) && topAtSet.contains(gtId)) { + matches++; + } + } + + localTotalRecall[aIdx] += (double) matches / RECALL_K; + } + } + + return localTotalRecall; + })); + } + + double[] totalRecall = new double[atValues.length]; + for (ForkJoinTask t : recallTasks) { + double[] local = t.join(); + for (int aIdx = 0; aIdx < atValues.length; aIdx++) { + totalRecall[aIdx] += local[aIdx]; + } + } + + for (int aIdx = 0; aIdx < atValues.length; aIdx++) { + System.out.format( + "\tPQ %d-recall@%d = %.4f%n", + RECALL_K, + atValues[aIdx], + totalRecall[aIdx] / queries.size() + ); + } + } + // ============================================================ // [2] Fast PQ scan timing (PQDecoder / ADC) // ============================================================ @@ -270,43 +429,52 @@ public static void testPQEncodings(String filenameBase, String filenameQueries) public static void runCohere100k() throws IOException { System.out.println("Running Cohere-100k"); - testPQEncodings( - "./fvec/cohere-100k/cohere_embed-english-v3.0_1024_base_vectors_100000.fvec", - "./fvec/cohere-100k/cohere_embed-english-v3.0_1024_query_vectors_10000.fvec" - ); + + var baseVectors = "./fvec/cohere-100k/cohere_embed-english-v3.0_1024_base_vectors_100000.fvec"; + var queryVectors = "./fvec/cohere-100k/cohere_embed-english-v3.0_1024_query_vectors_10000.fvec"; + var gtVectors = ""; // Set this to the GT file for this exact base/query ordering before enabling recall. + + testPQEncodings(baseVectors, queryVectors, gtVectors); } public static void runADA() throws IOException { System.out.println("Running ada_002"); - testPQEncodings( - "./fvec/ada-002/ada_002_100000_base_vectors.fvec", - "./fvec/ada-002/ada_002_100000_query_vectors_10000.fvec" - ); + + var baseVectors = "./fvec/ada-002/ada_002_100000_base_vectors.fvec"; + var queryVectors = "./fvec/ada-002/ada_002_100000_query_vectors_10000.fvec"; + var gtVectors = "./fvec/ada-002/ada_002_100000_indices_query_10000.ivec"; + + testPQEncodings(baseVectors, queryVectors, gtVectors); } public static void runADANoZeros() throws IOException { System.out.println("Running ada-002-no-zeros"); - testPQEncodings( - "./fvec/ada-002-no-zeros/ada_002_100000_base_vectors_no_zeros.fvec", - "./fvec/ada-002-no-zeros/ada_002_100000_query_vectors_10000_no_zeros.fvec" - ); + var baseVectors = "./fvec/ada-002-no-zeros/ada_002_100000_base_vectors_no_zeros.fvec"; + var queryVectors = "./fvec/ada-002-no-zeros/ada_002_100000_query_vectors_10000_no_zeros.fvec"; + var gtVectors = "./fvec/ada-002-no-zeros/ada-002_gt_no_zeros.ivec"; + + testPQEncodings(baseVectors, queryVectors, gtVectors); } public static void runOpenai1536() throws IOException { System.out.println("Running text-embedding-3-large_1536"); - testPQEncodings( - "./fvec/openai-v3-large-1536-100k/text-embedding-3-large_1536_100000_base_vectors.fvec", - "./fvec/openai-v3-large-1536-100k/text-embedding-3-large_1536_100000_query_vectors_10000.fvec" - ); + + var baseVectors = "./fvec/openai-v3-large-1536-100k/openai_v3_large_1536_100k_base_98716.fvecs"; + var queryVectors = "./fvec/openai-v3-large-1536-100k/openai_v3_large_1536_100k_query_10000.fvecs"; + var gtVectors = "./fvec/openai-v3-large-1536-100k/openai_v3_large_1536_100k_gt_ip_100.ivecs"; + + testPQEncodings(baseVectors, queryVectors, gtVectors); } public static void runOpenai3072() throws IOException { System.out.println("Running text-embedding-3-large_3072"); - testPQEncodings( - "./fvec/openai-v3-large-3072-100k/text-embedding-3-large_3072_100000_base_vectors.fvec", - "./fvec/openai-v3-large-3072-100k/text-embedding-3-large_3072_100000_query_vectors_10000.fvec" - ); + + var baseVectors = "./fvec/openai-v3-large-3072-100k/text-embedding-3-large_3072_100000_base_vectors.fvec"; + var queryVectors = "./fvec/openai-v3-large-3072-100k/text-embedding-3-large_3072_100000_query_vectors_10000.fvec"; + var gtVectors = "./fvec/openai-v3-large-3072-100k/text-embedding-3-large_3072_100000_indices_query_10000.ivec"; + + testPQEncodings(baseVectors, queryVectors, gtVectors); } public static void runCap6m() throws IOException { @@ -314,7 +482,9 @@ public static void runCap6m() throws IOException { var baseVectors = "./fvec/cap-6m/Caselaw_gte-Qwen2-1.5B_embeddings_base_6m_norm_shuffle.fvecs"; var queryVectors = "./fvec/cap-6m/Caselaw_gte-Qwen2-1.5B_embeddings_query_10k_norm_shuffle.fvecs"; - testPQEncodings(baseVectors, queryVectors); + var gtVectors = "./fvec/cap-6m/cap_6m_gt_norm_shuffle_ip_k100.ivecs"; + + testPQEncodings(baseVectors, queryVectors, gtVectors); } public static void runCohere10m() throws IOException { @@ -322,16 +492,18 @@ public static void runCohere10m() throws IOException { var baseVectors = "./fvec/cohere-10m/cohere_wiki_en_flat_base_10m_norm.fvecs"; var queryVectors = "./fvec/cohere-10m/cohere_wiki_en_flat_query_10k_norm.fvecs"; - testPQEncodings(baseVectors, queryVectors); + var gtVectors = "./fvec/cohere-10m/cohere_wiki_en_flat_gt_10m_ip_k100.ivecs"; + + testPQEncodings(baseVectors, queryVectors, gtVectors); } public static void main(String[] args) throws IOException { // runCohere100k(); // runADA(); // runADANoZeros(); -// runOpenai1536(); + runOpenai1536(); // runOpenai3072(); // runCap6m(); - runCohere10m(); +// runCohere10m(); } } diff --git a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/Grid.java b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/Grid.java index 0640ecddb..5455a1c8b 100644 --- a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/Grid.java +++ b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/Grid.java @@ -43,6 +43,7 @@ import io.github.jbellis.jvector.graph.disk.*; import io.github.jbellis.jvector.graph.disk.feature.Feature; import io.github.jbellis.jvector.graph.disk.feature.FeatureId; +import io.github.jbellis.jvector.graph.disk.feature.FusedASH; import io.github.jbellis.jvector.graph.disk.feature.FusedPQ; import io.github.jbellis.jvector.graph.disk.feature.InlineVectors; import io.github.jbellis.jvector.graph.disk.feature.NVQ; @@ -84,7 +85,7 @@ */ public class Grid { - private static final String pqCacheDir = "pq_cache"; + private static final String compressorCacheDir = "compressor_cache"; private static final String indexCacheDir = "index_cache"; @@ -314,9 +315,10 @@ static void runOneGraph(OnDiskGraphIndexCache cache, final Set featureSetForIndex = index instanceof OnDiskGraphIndex ? ((OnDiskGraphIndex) index).getFeatureSet() : Set.of(); CompressedVectors cv; - if (featureSetForIndex.contains(FeatureId.FUSED_PQ)) { + if (featureSetForIndex.contains(FeatureId.FUSED_PQ) + || featureSetForIndex.contains(FeatureId.FUSED_ASH)) { cv = null; - System.out.format("%s: configured to use FUSED PQ, skipping vector compression%n", ds.getName()); + System.out.format("%s: configured to use fused graph quantization, skipping search vector compression%n", ds.getName()); } else { constructionMetrics.resetSearch(); // per (index, cpSupplier) config @@ -425,20 +427,23 @@ private static Map, ImmutableGraphIndex> buildOnDisk(List, ImmutableGraphIndex> buildOnDisk(List, ImmutableGraphIndex> buildOnDisk(List, ImmutableGraphIndex> buildOnDisk(List> writeSuppliers; - if (features.contains(FeatureId.FUSED_PQ)) { - // Safe: such features are only present when pq != null (we skipped them otherwise) + if (features.contains(FeatureId.FUSED_PQ) || features.contains(FeatureId.FUSED_ASH)) { writeSuppliers = new EnumMap<>(FeatureId.class); var view = builder.getGraph().getView(); - writeSuppliers.put(FeatureId.FUSED_PQ, ordinal -> new FusedPQ.State(view, pqFinal, ordinal)); + + if (features.contains(FeatureId.FUSED_PQ)) { + // Safe: such features are only present when pq != null (we skipped them otherwise) + writeSuppliers.put(FeatureId.FUSED_PQ, ordinal -> new FusedPQ.State(view, pqFinal, ordinal)); + } + if (features.contains(FeatureId.FUSED_ASH)) { + // Safe: such features are only present when ashVectors != null (we skipped them otherwise) + writeSuppliers.put(FeatureId.FUSED_ASH, ordinal -> new FusedASH.State(view, ashVectorsFinal, ordinal)); + } } else { writeSuppliers = Map.of(); } @@ -539,6 +562,9 @@ private static Map, ImmutableGraphIndex> buildOnDisk(List features Path outPath, RandomAccessVectorValues floatVectors, ProductQuantization pq, + ASHVectors ashVectors, ConstructionMetrics constructionMetrics) throws FileNotFoundException { @@ -575,6 +602,14 @@ private static BuilderWithSuppliers builderWithSuppliers(Set features // no supplier as these will be used for writeInline, when we don't have enough information to fuse neighbors builder.with(new FusedPQ(onHeapGraph.maxDegree(), pq)); break; + case FUSED_ASH: + if (ashVectors == null) { + System.out.println("Skipping Fused ASH feature due to null ASHVectors"); + continue; + } + // no supplier as these will be used for writeInline, when we have neighbor information + builder.with(new FusedASH(onHeapGraph.maxDegree(), ashVectors.getCompressor())); + break; case NVQ_VECTORS: int nSubVectors = floatVectors.dimension() == 2 ? 1 : 2; var nvq = (constructionMetrics != null) @@ -663,7 +698,7 @@ private static Map, ImmutableGraphIndex> buildInMemory(List, ImmutableGraphIndex> buildInMemory(List> cachedCompressors = new IdentityHashMap<>(); + static final Map> cachedCompressors = new HashMap<>(); private static void testConfiguration(ConfiguredSystem cs, Map> topKGrid, @@ -918,9 +953,10 @@ public static List runAllAndCollectResults( var searchCompressorObj = getCompressor(searchCompressor, ds); // Encode vectors for reranking if a compressor is provided CompressedVectors cvArg; - if (features.contains(FeatureId.FUSED_PQ)) { + if (features.contains(FeatureId.FUSED_PQ) + || features.contains(FeatureId.FUSED_ASH)) { cvArg = null; - System.out.format("%s: configured to use FUSED PQ, skipping vector compression%n", ds.getName()); + System.out.format("%s: configured to use fused graph quantization, skipping search vector compression%n", ds.getName()); } else { if (searchCompressorObj == null) { cvArg = null; @@ -1135,7 +1171,7 @@ private static VectorCompressor getCompressor(Function { - Path path = Paths.get(pqCacheDir).resolve(fname); + Path path = Paths.get(compressorCacheDir).resolve(fname); if (Files.exists(path)) { return loadFromCache(ds, fname, path); } @@ -1190,7 +1226,7 @@ private static void saveToCache(String fname, VectorCompressor compressor) { validatePrefix(fname, compressor); try { - Path path = Paths.get(pqCacheDir).resolve(fname); + Path path = Paths.get(compressorCacheDir).resolve(fname); Files.createDirectories(path.getParent()); try (var writer = new BufferedRandomAccessWriter(path)) { compressor.write(writer, OnDiskGraphIndex.CURRENT_VERSION); @@ -1235,7 +1271,7 @@ public static class ConfiguredSystem implements AutoCloseable { public SearchScoreProvider scoreProviderFor(VectorFloat queryVector, ImmutableGraphIndex.View view) { var scoringView = (ImmutableGraphIndex.ScoringView) view; ScoreFunction.ApproximateScoreFunction asf; - if (features.contains(FeatureId.FUSED_PQ)) { + if (features.contains(FeatureId.FUSED_PQ) || features.contains(FeatureId.FUSED_ASH)) { asf = scoringView.approximateScoreFunctionFor(queryVector, ds.getSimilarityFunction()); } else { // if we're not compressing then just use the exact score function diff --git a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/util/CompressorParameters.java b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/util/CompressorParameters.java index 55432b1a8..f0f5ac30d 100644 --- a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/util/CompressorParameters.java +++ b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/util/CompressorParameters.java @@ -108,17 +108,28 @@ public static class ASHParameters extends CompressorParameters { private final int optimizer; private final int encodedBits; private final int landmarkCount; + private final int bitsPerDimension; public ASHParameters(int optimizer, int encodedBits, int landmarkCount) { + this(optimizer, encodedBits, landmarkCount, 1); + } + + public ASHParameters(int optimizer, int encodedBits, int landmarkCount, int bitsPerDimension) { this.optimizer = optimizer; this.encodedBits = encodedBits; this.landmarkCount = landmarkCount; + this.bitsPerDimension = bitsPerDimension; } @Override public VectorCompressor computeCompressor(DataSet ds) { try { - return AsymmetricHashing.initialize(ds.getBaseRavv(), optimizer, encodedBits, landmarkCount); + return AsymmetricHashing.initialize( + ds.getBaseRavv(), + optimizer, + encodedBits, + landmarkCount, + bitsPerDimension); } catch (IOException e) { throw new RuntimeException(e); } @@ -126,13 +137,20 @@ public VectorCompressor computeCompressor(DataSet ds) { @Override public String idStringFor(DataSet ds) { - return String.format("ASH_%s_opt%d_bits%d_C%d", ds.getName(), optimizer, encodedBits, landmarkCount); + return String.format( + "ASH_%s_v%d_opt%d_bits%d_bpd%d_C%d", + ds.getName(), + io.github.jbellis.jvector.graph.disk.OnDiskGraphIndex.CURRENT_VERSION, + optimizer, + encodedBits, + bitsPerDimension, + landmarkCount + ); } - // TODO set to true for production after debug is complete. @Override public boolean supportsCaching() { - return false; + return true; } } } diff --git a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/yaml/Compression.java b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/yaml/Compression.java index c602308c4..248e55dd1 100644 --- a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/yaml/Compression.java +++ b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/yaml/Compression.java @@ -68,8 +68,13 @@ public Function getCompressorParameters() { } int landmarkCount = Integer.parseInt(parameters.getOrDefault("landmarkCount", "1")); + int bitsPerDimension = Integer.parseInt(parameters.getOrDefault("bitsPerDimension", "1")); + if (bitsPerDimension < 1 || bitsPerDimension > 9) { + throw new IllegalArgumentException( + "ASH bitsPerDimension must be in [1,9], got " + bitsPerDimension); + } - // optimizer can be numeric or name (RANDOM/ITQ/LANDING) + // optimizer can be numeric or name (RANDOM/ITQ) String optStr = parameters.get("optimizer"); if (optStr == null) { throw new IllegalArgumentException("ASH requires 'optimizer' parameter"); @@ -113,9 +118,14 @@ public Function getCompressorParameters() { int targetEncodedBits = Math.max(AsymmetricHashing.HEADER_BITS + 64, originalBits / requestedCompressionRatio); - int payloadTargetBits = Math.max(64, targetEncodedBits - AsymmetricHashing.HEADER_BITS); - int lowerPayloadBits = Math.max(64, (payloadTargetBits / 64) * 64); - int upperPayloadBits = Math.max(64, ((payloadTargetBits + 63) / 64) * 64); + int minimumPayloadBits = ((64 + bitsPerDimension - 1) / bitsPerDimension) + * bitsPerDimension; + int payloadTargetBits = Math.max(minimumPayloadBits, + targetEncodedBits - AsymmetricHashing.HEADER_BITS); + int lowerPayloadBits = Math.max(minimumPayloadBits, + (payloadTargetBits / bitsPerDimension) * bitsPerDimension); + int upperPayloadBits = Math.max(minimumPayloadBits, + ((payloadTargetBits + bitsPerDimension - 1) / bitsPerDimension) * bitsPerDimension); int payloadBits = Math.abs(lowerPayloadBits - payloadTargetBits) <= Math.abs(upperPayloadBits - payloadTargetBits) @@ -126,16 +136,18 @@ public Function getCompressorParameters() { double actualCompressionRatio = (double) originalBits / encodedBits; System.out.printf( - "ASH requested compressionRatio=%d resolved to encodedBits=%d " + "ASH requested compressionRatio=%d bitsPerDimension=%d resolved to encodedBits=%d " + "(header=%d, payload=%d) actualCompressionRatio=%.2f%n", requestedCompressionRatio, + bitsPerDimension, encodedBits, AsymmetricHashing.HEADER_BITS, payloadBits, actualCompressionRatio); } - return new CompressorParameters.ASHParameters(optimizer, encodedBits, landmarkCount); + return new CompressorParameters.ASHParameters( + optimizer, encodedBits, landmarkCount, bitsPerDimension); }; default: throw new IllegalArgumentException("Unsupported compression type: " + type); diff --git a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/yaml/ConstructionParameters.java b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/yaml/ConstructionParameters.java index 5177fdf4a..30f7d672c 100644 --- a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/yaml/ConstructionParameters.java +++ b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/yaml/ConstructionParameters.java @@ -18,9 +18,10 @@ import io.github.jbellis.jvector.graph.disk.feature.FeatureId; +import java.util.ArrayList; import java.util.EnumSet; +import java.util.LinkedHashSet; import java.util.List; -import java.util.stream.Collectors; public class ConstructionParameters extends CommonParameters { @@ -34,39 +35,64 @@ public class ConstructionParameters extends CommonParameters { public Boolean useSavedIndexIfExists; public List> getFeatureSets() { - List> featureSets = null; + var featureSets = new LinkedHashSet>(); + boolean hasPQConstruction = hasConstructionCompressor("PQ"); + boolean hasASHConstruction = hasConstructionCompressor("ASH"); + for (var fusedItem : fusedGraph) { - var newFeatures = reranking.stream().map(item -> { - EnumSet features; + for (var item : reranking) { + EnumSet baseFeatures; switch (item) { case "FP": - if (fusedItem) { - features = EnumSet.of(FeatureId.INLINE_VECTORS, FeatureId.FUSED_PQ); - } else { - features = EnumSet.of(FeatureId.INLINE_VECTORS); - } + baseFeatures = EnumSet.of(FeatureId.INLINE_VECTORS); break; case "NVQ": - if (fusedItem) { - features = EnumSet.of(FeatureId.NVQ_VECTORS, FeatureId.FUSED_PQ); - } else { - features = EnumSet.of(FeatureId.NVQ_VECTORS); - } + baseFeatures = EnumSet.of(FeatureId.NVQ_VECTORS); break; default: throw new IllegalArgumentException("Only 'FP' and 'NVQ' are supported"); } - return features; - }).collect(Collectors.toList()); - if (featureSets == null) { - featureSets = newFeatures; - } else { - featureSets.addAll(newFeatures); + if (!fusedItem) { + featureSets.add(baseFeatures); + continue; + } + + boolean addedFused = false; + if (hasPQConstruction) { + var features = EnumSet.copyOf(baseFeatures); + features.add(FeatureId.FUSED_PQ); + featureSets.add(features); + addedFused = true; + } + if (hasASHConstruction) { + var features = EnumSet.copyOf(baseFeatures); + features.add(FeatureId.FUSED_ASH); + featureSets.add(features); + addedFused = true; + } + + if (!addedFused) { + throw new IllegalArgumentException( + "fusedGraph=Yes requires construction compression type PQ or ASH"); + } } } - return featureSets; + return new ArrayList<>(featureSets); + } + + private boolean hasConstructionCompressor(String type) { + if (compression == null) { + return false; + } + + for (Compression c : compression) { + if (c != null && c.type != null && c.type.equalsIgnoreCase(type)) { + return true; + } + } + return false; } } \ No newline at end of file diff --git a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/yaml/MultiConfig.java b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/yaml/MultiConfig.java index 6e56a6e26..d2a009832 100644 --- a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/yaml/MultiConfig.java +++ b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/yaml/MultiConfig.java @@ -112,7 +112,7 @@ static MultiConfig getConfig(File configFile) throws FileNotFoundException { } // Validate supported versions - if (cfg.getYamlSchemaVersion() != 1) { + if (cfg.getYamlSchemaVersion() != 2) { throw new IllegalArgumentException("Unsupported yamlSchemaVersion=" + cfg.getYamlSchemaVersion() + " in " + configFile.getAbsolutePath()); } diff --git a/jvector-examples/yaml-configs/dataset-metadata.yml b/jvector-examples/yaml-configs/dataset-metadata.yml index 4ec2114c3..34c4ae737 100644 --- a/jvector-examples/yaml-configs/dataset-metadata.yml +++ b/jvector-examples/yaml-configs/dataset-metadata.yml @@ -96,4 +96,16 @@ nytimes-256-angular: load_behavior: NO_SCRUB sift-128-euclidean: similarity_function: EUCLIDEAN + load_behavior: NO_SCRUB +dpr-gemma-1m: + similarity_function: DOT_PRODUCT + load_behavior: NO_SCRUB +mpnet-1m: + similarity_function: DOT_PRODUCT + load_behavior: NO_SCRUB +tahoe-x1-1m: + similarity_function: DOT_PRODUCT + load_behavior: NO_SCRUB +personahub-1m: + similarity_function: DOT_PRODUCT load_behavior: NO_SCRUB \ No newline at end of file diff --git a/jvector-examples/yaml-configs/datasets.yml b/jvector-examples/yaml-configs/datasets.yml index 767314d71..5e0423c8e 100644 --- a/jvector-examples/yaml-configs/datasets.yml +++ b/jvector-examples/yaml-configs/datasets.yml @@ -1,10 +1,10 @@ jvector-100k: - - openai-v3-large-3072-100k - - openai-v3-large-1536-100k - - ada002-100k - - openai-v3-small-1536-100k - - gecko-100k - - cohere-english-v3-100k +# - openai-v3-large-3072-100k +# - openai-v3-large-1536-100k +# - ada002-100k +# - openai-v3-small-1536-100k +# - gecko-100k +# - cohere-english-v3-100k # - e5-small-v2-100k # - e5-base-v2-100k # - e5-large-v2-100k @@ -20,11 +20,14 @@ jvector-100k: # - nytimes-256-angular # - sift-128-euclidean #other-datasets: -# - dpr-1M +# - dpr-gemma-1m # - dpr-10M # - cap-1M # - cap-6M -# - cohere-english-v3-1M + - cohere-english-v3-1M # - cohere-english-v3-10M +# - mpnet-1m +# - tahoe-x1-1m +# - personahub-1m # - deep-image-96-angular # large files not yet supported # - gist-960-euclidean # large files not yet supported \ No newline at end of file diff --git a/jvector-examples/yaml-configs/index-parameters/cap-1M.yml b/jvector-examples/yaml-configs/index-parameters/cap-1M.yml index 5fdb1ee2e..9f07ca886 100644 --- a/jvector-examples/yaml-configs/index-parameters/cap-1M.yml +++ b/jvector-examples/yaml-configs/index-parameters/cap-1M.yml @@ -1,7 +1,7 @@ -yamlSchemaVersion: 1 -onDiskIndexVersion: 6 +yamlSchemaVersion: 2 +onDiskIndexVersion: 7 -dataset: cap-6M +dataset: cap-1M construction: outDegree: [64] @@ -41,7 +41,7 @@ construction: search: topKOverquery: - 10: [1.00, 1.25, 1.56, 1.95, 2.44, 3.05, 3.81, 4.77, 5.96, 7.45, 9.31, 11.64] + 10: [1.00] # 100: [1.0] useSearchPruning: [No] compression: diff --git a/jvector-examples/yaml-configs/index-parameters/cap-6M.yml b/jvector-examples/yaml-configs/index-parameters/cap-6M.yml index bf3769014..122bf0c34 100644 --- a/jvector-examples/yaml-configs/index-parameters/cap-6M.yml +++ b/jvector-examples/yaml-configs/index-parameters/cap-6M.yml @@ -1,5 +1,5 @@ -yamlSchemaVersion: 1 -onDiskIndexVersion: 6 +yamlSchemaVersion: 2 +onDiskIndexVersion: 7 dataset: cap-6M diff --git a/jvector-examples/yaml-configs/index-parameters/cohere-10M.yml b/jvector-examples/yaml-configs/index-parameters/cohere-10M.yml index 45e164079..71f5da569 100644 --- a/jvector-examples/yaml-configs/index-parameters/cohere-10M.yml +++ b/jvector-examples/yaml-configs/index-parameters/cohere-10M.yml @@ -1,5 +1,5 @@ -yamlSchemaVersion: 1 -onDiskIndexVersion: 6 +yamlSchemaVersion: 2 +onDiskIndexVersion: 7 dataset: cohere-10M diff --git a/jvector-examples/yaml-configs/index-parameters/cohere-english-v3-1M.yml b/jvector-examples/yaml-configs/index-parameters/cohere-english-v3-1M.yml deleted file mode 100644 index 549f39de8..000000000 --- a/jvector-examples/yaml-configs/index-parameters/cohere-english-v3-1M.yml +++ /dev/null @@ -1,72 +0,0 @@ -yamlSchemaVersion: 1 -onDiskIndexVersion: 6 - -dataset: cohere-1M - -construction: - outDegree: [64] - efConstruction: [200] - neighborOverflow: [1.6f] - addHierarchy: [No] - refineFinalGraph: [Yes] - fusedGraph: [No] - compression: - - type: ASH - parameters: - encodedBits: 1032 - optimizer: itq - landmarkCount: 1 - - type: ASH - parameters: - encodedBits: 520 - optimizer: itq - landmarkCount: 1 - - type: PQ - parameters: - m: 129 # we can either specify the integer m or the integer mFactor. In this case, m will be set to the data dimensionality divided by mFactor - # mFactor: 8 - # k: 256 # optional parameter. By default, k=256 - centerData: No - anisotropicThreshold: -1.0 # optional parameter. By default, anisotropicThreshold=-1 (i.e., no anisotropy) - - type: PQ - parameters: - m: 65 # we can either specify the integer m or the integer mFactor. In this case, m will be set to the data dimensionality divided by mFactor - # mFactor: 8 - # k: 256 # optional parameter. By default, k=256 - centerData: No - anisotropicThreshold: -1.0 # optional parameter. By default, anisotropicThreshold=-1 (i.e., no anisotropy) - reranking: - - FP - useSavedIndexIfExists: Yes # If yes, the system will attempt to locate a cached copy of the index instead of constructing - -search: - topKOverquery: - 10: [1.00, 1.25, 1.56, 1.95, 2.44, 3.05, 3.81, 4.77, 5.96, 7.45, 9.31, 11.64] -# 100: [1.0] - useSearchPruning: [No] - compression: - - type: ASH - parameters: - encodedBits: 1032 - optimizer: itq - landmarkCount: 1 - - type: ASH - parameters: - encodedBits: 520 - optimizer: itq - landmarkCount: 1 - - type: PQ - parameters: - m: 129 # we can either specify the integer m or the integer mFactor. In this case, m will be set to the data dimensionality divided by mFactor - # mFactor: 8 - # k: 256 # optional parameter. By default, k=256 - centerData: No - anisotropicThreshold: -1.0 # optional parameter. By default, anisotropicThreshold=-1 (i.e., no anisotropy) - - type: PQ - parameters: - m: 65 # we can either specify the integer m or the integer mFactor. In this case, m will be set to the data dimensionality divided by mFactor - # mFactor: 8 - # k: 256 # optional parameter. By default, k=256 - centerData: No - anisotropicThreshold: -1.0 # optional parameter. By default, anisotropicThreshold=-1 (i.e., no anisotropy) -# Run-level controls, such as benchmarks, console, and logging, are in run.yml. \ No newline at end of file diff --git a/jvector-examples/yaml-configs/index-parameters/colbert-1M.yml b/jvector-examples/yaml-configs/index-parameters/colbert-1M.yml index 0a20806ba..c76d24fed 100644 --- a/jvector-examples/yaml-configs/index-parameters/colbert-1M.yml +++ b/jvector-examples/yaml-configs/index-parameters/colbert-1M.yml @@ -1,5 +1,5 @@ -yamlSchemaVersion: 1 -onDiskIndexVersion: 6 +yamlSchemaVersion: 2 +onDiskIndexVersion: 7 dataset: colbert-1M diff --git a/jvector-examples/yaml-configs/index-parameters/default.yml b/jvector-examples/yaml-configs/index-parameters/default.yml index 2fbcd077a..b7c7f1482 100644 --- a/jvector-examples/yaml-configs/index-parameters/default.yml +++ b/jvector-examples/yaml-configs/index-parameters/default.yml @@ -1,28 +1,30 @@ -yamlSchemaVersion: 1 -onDiskIndexVersion: 6 +yamlSchemaVersion: 2 +onDiskIndexVersion: 7 dataset: default construction: - outDegree: [32] - efConstruction: [100] + outDegree: [64] + efConstruction: [200] neighborOverflow: [1.2f] - addHierarchy: [No] + addHierarchy: [Yes] refineFinalGraph: [Yes] fusedGraph: [No] compression: - - type: ASH - parameters: - compressionRatio: 32 - optimizer: ITQ - landmarkCount: 1 -# - type: PQ -# parameters: -# # m: 192 # we can either specify the integer m or the integer mFactor. In this case, m will be set to the data dimensionality divided by mFactor -# mFactor: 8 -# # k: 256 # optional parameter. By default, k=256 -# centerData: No -# anisotropicThreshold: -1.0 # optional parameter. By default, anisotropicThreshold=-1 (i.e., no anisotropy) + - type: None +# - type: ASH +# parameters: +# compressionRatio: 32 +# optimizer: ITQ +# landmarkCount: 1 +# bitsPerDimension: 2 +# - type: PQ +# parameters: +# # m: 192 # we can either specify the integer m or the integer mFactor. In this case, m will be set to the data dimensionality divided by mFactor +# mFactor: 8 +# # k: 256 # optional parameter. By default, k=256 +# centerData: No +# anisotropicThreshold: -1.0 # optional parameter. By default, anisotropicThreshold=-1 (i.e., no anisotropy) reranking: - NVQ useSavedIndexIfExists: No # If yes, the system will attempt to locate a cached copy of the index instead of constructing @@ -31,13 +33,15 @@ search: topKOverquery: 10: [1.0, 2.0, 5.0, 10.0] 100: [1.0, 2.0] - useSearchPruning: [Yes] + useSearchPruning: [No] compression: - - type: ASH - parameters: - compressionRatio: 32 - optimizer: ITQ - landmarkCount: 1 + - type: None +# - type: ASH +# parameters: +# compressionRatio: 32 +# optimizer: ITQ +# landmarkCount: 1 +# bitsPerDimension: 2 # - type: PQ # parameters: # # m: 192 diff --git a/jvector-examples/yaml-configs/index-parameters/dpr-gemma-1M.yml b/jvector-examples/yaml-configs/index-parameters/dpr-gemma-1M.yml index adc38e804..d3aba3a4d 100644 --- a/jvector-examples/yaml-configs/index-parameters/dpr-gemma-1M.yml +++ b/jvector-examples/yaml-configs/index-parameters/dpr-gemma-1M.yml @@ -1,7 +1,7 @@ -yamlSchemaVersion: 1 -onDiskIndexVersion: 6 +yamlSchemaVersion: 2 +onDiskIndexVersion: 7 -dataset: dpr-1M +dataset: dpr-gemma-1M construction: outDegree: [64] @@ -41,7 +41,7 @@ construction: search: topKOverquery: - 10: [1.00, 1.25, 1.56, 1.95, 2.44, 3.05, 3.81, 4.77, 5.96, 7.45, 9.31, 11.64] + 10: [1.00] # 100: [1.0] useSearchPruning: [No] compression: diff --git a/jvector-examples/yaml-configs/index-parameters/esmc-600-1M.yml b/jvector-examples/yaml-configs/index-parameters/esmc-600-1M.yml index 549f39de8..f95d76ca4 100644 --- a/jvector-examples/yaml-configs/index-parameters/esmc-600-1M.yml +++ b/jvector-examples/yaml-configs/index-parameters/esmc-600-1M.yml @@ -1,5 +1,5 @@ -yamlSchemaVersion: 1 -onDiskIndexVersion: 6 +yamlSchemaVersion: 2 +onDiskIndexVersion: 7 dataset: cohere-1M diff --git a/jvector-examples/yaml-configs/index-parameters/esmc-600-6M.yml b/jvector-examples/yaml-configs/index-parameters/esmc-600-6M.yml index b58605d22..04f121b60 100644 --- a/jvector-examples/yaml-configs/index-parameters/esmc-600-6M.yml +++ b/jvector-examples/yaml-configs/index-parameters/esmc-600-6M.yml @@ -1,5 +1,5 @@ -yamlSchemaVersion: 1 -onDiskIndexVersion: 6 +yamlSchemaVersion: 2 +onDiskIndexVersion: 7 dataset: esmc-600-1M diff --git a/jvector-examples/yaml-configs/index-parameters/gecko-100k.yml b/jvector-examples/yaml-configs/index-parameters/gecko-100k.yml index 1e2455336..176e3fec7 100644 --- a/jvector-examples/yaml-configs/index-parameters/gecko-100k.yml +++ b/jvector-examples/yaml-configs/index-parameters/gecko-100k.yml @@ -1,5 +1,5 @@ -yamlSchemaVersion: 1 -onDiskIndexVersion: 6 +yamlSchemaVersion: 2 +onDiskIndexVersion: 7 dataset: dpr-10M diff --git a/jvector-examples/yaml-configs/index-parameters/glove-100-angular.yml b/jvector-examples/yaml-configs/index-parameters/glove-100-angular.yml index 0810c2b73..72fe235cf 100644 --- a/jvector-examples/yaml-configs/index-parameters/glove-100-angular.yml +++ b/jvector-examples/yaml-configs/index-parameters/glove-100-angular.yml @@ -1,6 +1,6 @@ # This is here for loader testing purposes only, use other datasets for realistic test data -yamlSchemaVersion: 1 -onDiskIndexVersion: 6 +yamlSchemaVersion: 2 +onDiskIndexVersion: 7 dataset: glove-100-angular diff --git a/jvector-examples/yaml-configs/index-parameters/glove-200-angular.yml b/jvector-examples/yaml-configs/index-parameters/glove-200-angular.yml index 4252f3e6c..40b1ee89b 100644 --- a/jvector-examples/yaml-configs/index-parameters/glove-200-angular.yml +++ b/jvector-examples/yaml-configs/index-parameters/glove-200-angular.yml @@ -1,6 +1,6 @@ # This is here for loader testing purposes only, use other datasets for realistic test data -yamlSchemaVersion: 1 -onDiskIndexVersion: 6 +yamlSchemaVersion: 2 +onDiskIndexVersion: 7 dataset: glove-200-angular diff --git a/jvector-examples/yaml-configs/index-parameters/glove-25-angular.yml b/jvector-examples/yaml-configs/index-parameters/glove-25-angular.yml index d5770dc4b..1d5aff275 100644 --- a/jvector-examples/yaml-configs/index-parameters/glove-25-angular.yml +++ b/jvector-examples/yaml-configs/index-parameters/glove-25-angular.yml @@ -1,6 +1,6 @@ # This is here for loader testing purposes only, use other datasets for realistic test data -yamlSchemaVersion: 1 -onDiskIndexVersion: 6 +yamlSchemaVersion: 2 +onDiskIndexVersion: 7 dataset: glove-25-angular diff --git a/jvector-examples/yaml-configs/index-parameters/glove-50-angular.yml b/jvector-examples/yaml-configs/index-parameters/glove-50-angular.yml index 292ce4dce..b17f48005 100644 --- a/jvector-examples/yaml-configs/index-parameters/glove-50-angular.yml +++ b/jvector-examples/yaml-configs/index-parameters/glove-50-angular.yml @@ -1,6 +1,6 @@ # This is here for loader testing purposes only, use other datasets for realistic test data -yamlSchemaVersion: 1 -onDiskIndexVersion: 6 +yamlSchemaVersion: 2 +onDiskIndexVersion: 7 dataset: glove-50-angular diff --git a/jvector-examples/yaml-configs/index-parameters/lastfm-64-dot.yml b/jvector-examples/yaml-configs/index-parameters/lastfm-64-dot.yml index 9230e7ca7..d28720aa2 100644 --- a/jvector-examples/yaml-configs/index-parameters/lastfm-64-dot.yml +++ b/jvector-examples/yaml-configs/index-parameters/lastfm-64-dot.yml @@ -1,6 +1,6 @@ # This is here for loader testing purposes only, use other datasets for realistic test data -yamlSchemaVersion: 1 -onDiskIndexVersion: 6 +yamlSchemaVersion: 2 +onDiskIndexVersion: 7 dataset: lastfm-64-dot diff --git a/jvector-examples/yaml-configs/index-parameters/mpnet-1M.yml b/jvector-examples/yaml-configs/index-parameters/mpnet-1M.yml index 9eba8d1ef..4f6342c16 100644 --- a/jvector-examples/yaml-configs/index-parameters/mpnet-1M.yml +++ b/jvector-examples/yaml-configs/index-parameters/mpnet-1M.yml @@ -1,7 +1,7 @@ -yamlSchemaVersion: 1 -onDiskIndexVersion: 6 +yamlSchemaVersion: 2 +onDiskIndexVersion: 7 -dataset: mpnet-100k +dataset: mpnet-1M construction: outDegree: [64] diff --git a/jvector-examples/yaml-configs/index-parameters/nytimes-256-angular.yml b/jvector-examples/yaml-configs/index-parameters/nytimes-256-angular.yml index 5802b7857..a06e26ba5 100644 --- a/jvector-examples/yaml-configs/index-parameters/nytimes-256-angular.yml +++ b/jvector-examples/yaml-configs/index-parameters/nytimes-256-angular.yml @@ -1,6 +1,6 @@ # This is here for loader testing purposes only, use other datasets for realistic test data -yamlSchemaVersion: 1 -onDiskIndexVersion: 6 +yamlSchemaVersion: 2 +onDiskIndexVersion: 7 dataset: nytimes-256-angular diff --git a/jvector-examples/yaml-configs/index-parameters/openai-v3-small-100k.yml b/jvector-examples/yaml-configs/index-parameters/openai-v3-small-100k.yml index 5fdb1ee2e..4b75cd41e 100644 --- a/jvector-examples/yaml-configs/index-parameters/openai-v3-small-100k.yml +++ b/jvector-examples/yaml-configs/index-parameters/openai-v3-small-100k.yml @@ -1,5 +1,5 @@ -yamlSchemaVersion: 1 -onDiskIndexVersion: 6 +yamlSchemaVersion: 2 +onDiskIndexVersion: 7 dataset: cap-6M diff --git a/jvector-examples/yaml-configs/index-parameters/personahub-1M.yml b/jvector-examples/yaml-configs/index-parameters/personahub-1M.yml index 549f39de8..7f5252fb0 100644 --- a/jvector-examples/yaml-configs/index-parameters/personahub-1M.yml +++ b/jvector-examples/yaml-configs/index-parameters/personahub-1M.yml @@ -1,7 +1,7 @@ -yamlSchemaVersion: 1 -onDiskIndexVersion: 6 +yamlSchemaVersion: 2 +onDiskIndexVersion: 7 -dataset: cohere-1M +dataset: personahub-1M construction: outDegree: [64] diff --git a/jvector-examples/yaml-configs/index-parameters/sift-128-euclidean.yml b/jvector-examples/yaml-configs/index-parameters/sift-128-euclidean.yml index 8053a7041..c0e81c088 100644 --- a/jvector-examples/yaml-configs/index-parameters/sift-128-euclidean.yml +++ b/jvector-examples/yaml-configs/index-parameters/sift-128-euclidean.yml @@ -1,6 +1,6 @@ # This is here for loader testing purposes only, use other datasets for realistic test data -yamlSchemaVersion: 1 -onDiskIndexVersion: 6 +yamlSchemaVersion: 2 +onDiskIndexVersion: 7 dataset: sift-128-euclidean diff --git a/jvector-examples/yaml-configs/index-parameters/tahoe-x1-1M.yml b/jvector-examples/yaml-configs/index-parameters/tahoe-x1-1M.yml index 86f4114e0..79b44cf09 100644 --- a/jvector-examples/yaml-configs/index-parameters/tahoe-x1-1M.yml +++ b/jvector-examples/yaml-configs/index-parameters/tahoe-x1-1M.yml @@ -1,7 +1,7 @@ -yamlSchemaVersion: 1 -onDiskIndexVersion: 6 +yamlSchemaVersion: 2 +onDiskIndexVersion: 7 -dataset: tahoe-x1-100k +dataset: tahoe-x1-1M construction: outDegree: [32] diff --git a/plot_output.py b/plot_output.py deleted file mode 100644 index 7a3215236..000000000 --- a/plot_output.py +++ /dev/null @@ -1,142 +0,0 @@ -# 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. -# -import sys -import re -import matplotlib.pyplot as plt - -import re -from dataclasses import dataclass, astuple - - -@dataclass -class Point: - pq: int - recall: float - throughput: float - M: int - ef: int - overquery: int - -def parse_data(description, data): - """ - Parses a given set of data lines to extract relevant information. - - Parameters: - - description (str): Metadata description of the dataset. - - data (list of str): List of data lines to parse. - - Returns: - - dict: A dictionary containing parsed information. - """ - base_vector_count = int(re.search(r'(\d+) base', description).group(1)) - query_vector_count = int(re.search(r'(\d+) query', description).group(1)) - dimensions = int(re.search(r'dimensions (\d+)', description).group(1)) - dataset_name = re.search(r'(\S+):', description).group(1) - - parsed_data = [] - current_pq = None - M = None - for line in data: - if "ProductQuantization" in line: - current_pq = 'PQ@' + re.search(r'\((\d+)\)', line).group(1) - elif "BinaryQuantization" in line: - current_pq = 'BQ' - elif "Uncompressed" in line: - current_pq = 'UC' - elif "Build M=" in line: - M = int(re.search(r'M=(\d+)', line).group(1)) - ef = int(re.search(r'ef=(\d+)', line).group(1)) - elif " Query " in line: - if "(memory)" in line: - # in-memory (on-heap) graph + vectors are benched as a sanity check; - # we shouldn't include them in the plot of disk-based performance - continue - recall = float(re.search(r'recall (\d+\.\d+)', line).group(1)) - query_time = float(re.search(r'in (\d+\.\d+)s', line).group(1)) - overquery = int(re.search(r'top 100/(\d+) ', line).group(1)) - - throughput = query_vector_count * 10 / query_time - - assert current_pq is not None - assert M is not None - parsed_data.append(Point(current_pq, recall, throughput, M, ef, overquery)) - - return { - 'name': dataset_name, - 'base_vector_count': base_vector_count, - 'dimensions': dimensions, - 'data': parsed_data - } - - -def is_pareto_optimal(candidate, others): - """Determine if a candidate point is Pareto-optimal.""" - for point in others: - # Check if another point has higher or equal recall and throughput - if point.recall >= candidate.recall and point.throughput > candidate.throughput: - return False - if point.recall > candidate.recall and point.throughput >= candidate.throughput: - return False - return True - -def filter_pareto_optimal(data): - """Filter out only the Pareto-optimal points.""" - return [point for point in data if is_pareto_optimal(point, data)] - -def plot_dataset(dataset, output_dir="."): - # Extract dataset info - name = dataset['name'] - base_vector_count = dataset['base_vector_count'] - dimensions = dataset['dimensions'] - data = dataset['data'] - - # Create plot - plt.figure(figsize=(15, 20)) - for pq, recall, throughput, M, ef, overquery in (astuple(p) for p in data): - plt.scatter(recall, throughput, label=f'Q={pq}, M={M}, ef={ef}, oq={overquery}') - plt.annotate(f'Q={pq}, M={M}, ef={ef}, oq={overquery}', (recall, throughput)) - - # Set title and labels - plt.title(f"Dataset: {name}\\nBase Vector Count: {base_vector_count}\\nDimensions: {dimensions}") - plt.xlabel('Recall') - plt.ylabel('Throughput') - plt.legend(loc='upper left', bbox_to_anchor=(1, 1)) - plt.grid(True, which='both', linestyle='--', linewidth=0.5) - plt.tight_layout() - - # Save the plot to a file - filename = f"{output_dir}/{name}_plot.png" - plt.savefig(filename) - print("saved " + filename) - - # Clear the figure for the next plot - plt.clf() - -# Load and parse data -with open(sys.argv[1], 'r') as file: - content = file.read().strip().split('\n\n') -datasets = [] -for dataset in content: - lines = dataset.split('\n') - description = lines[0] - data = lines[1:] - datasets.append((description, data)) -parsed_datasets = [parse_data(desc, data) for desc, data in datasets] - -# Filter and plot -for dataset in parsed_datasets: - dataset['data'] = filter_pareto_optimal(dataset['data']) -for dataset in parsed_datasets: - plot_dataset(dataset) diff --git a/visualize_benchmarks.py b/visualize_benchmarks.py deleted file mode 100644 index 903c3448a..000000000 --- a/visualize_benchmarks.py +++ /dev/null @@ -1,360 +0,0 @@ -#!/usr/bin/env python3 -# 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. - -import argparse -import csv -import os -import sys -import re -import platform -import psutil -from collections import defaultdict -from typing import Dict, List, Any, Tuple, Optional -import matplotlib.pyplot as plt -import numpy as np -from matplotlib.ticker import MaxNLocator - - -# Define metrics where higher values are better and lower values are better - -HIGHER_IS_BETTER = ["QPS", "Recall@10"] -LOWER_IS_BETTER = ["Mean Latency", "Index Build Time", "Average Nodes Visited"] - - -class BenchmarkData: - - def __init__(self): - # Structure: {release: {dataset: {metric: value}}} - self.data = defaultdict(lambda: defaultdict(dict)) - self.releases = [] # This will maintain the order of releases as they are added - self.datasets = set() - self.metrics = set() - - def add_file(self, file_path: str, release: str): - """ - Add benchmark data from a CSV file for a specific release - """ - if release not in self.releases: - self.releases.append(release) - - try: - with open(file_path, 'r') as f: - reader = csv.DictReader(f) - for row in reader: - dataset = row['dataset'] - self.datasets.add(dataset) - - for metric, value in row.items(): - if metric != 'dataset': - try: - # Convert to float if possible - value = float(value) - self.metrics.add(metric) - self.data[release][dataset][metric] = value - except ValueError: - # Skip non-numeric values - pass - except Exception as e: - print(f"Error loading benchmark results from {file_path}: {e}") - return False - - return True - - def get_metric_data_for_dataset(self, metric: str, dataset: str) -> Tuple[List[str], List[float]]: - """ - Get the values of a specific metric for a dataset across all releases - """ - releases = [] - values = [] - - for release in self.releases: - if dataset in self.data[release] and metric in self.data[release][dataset]: - releases.append(release) - values.append(self.data[release][dataset][metric]) - - return releases, values - - def detect_changes(self, threshold_percent: float = 5.0) -> List[Dict[str, Any]]: - """ - Detect significant changes in metrics between consecutive releases - """ - changes = [] - - for dataset in self.datasets: - for metric in self.metrics: - releases, values = self.get_metric_data_for_dataset(metric, dataset) - - for i in range(1, len(releases)): - current_value = values[i] - previous_value = values[i-1] - current_release = releases[i] - previous_release = releases[i-1] - - if previous_value == 0: - continue # Avoid division by zero - - change_pct = ((current_value - previous_value) / previous_value) * 100 - - # Determine if this is an improvement or regression - if metric in HIGHER_IS_BETTER: - status = "improvement" if change_pct > 0 else "regression" - elif metric in LOWER_IS_BETTER: - status = "improvement" if change_pct < 0 else "regression" - else: - status = "unknown" - - # Only include significant changes - if abs(change_pct) >= threshold_percent: - changes.append({ - "dataset": dataset, - "metric": metric, - "current_release": current_release, - "previous_release": previous_release, - "current_value": current_value, - "previous_value": previous_value, - "change_pct": change_pct, - "status": status - }) - - return changes - - -def extract_release_from_filename(filename: str) -> str: - """ - Extract release number from filename - """ - # Try to find a version pattern like v1.2.3 or 1.2.3 - match = re.search(r'(?:v|release[-_])?(\d+\.\d+(?:\.\d+)?)', filename) - if match: - return match.group(1) - - # If no version pattern, use the filename without extension - base = os.path.basename(filename) - return os.path.splitext(base)[0] - - -def generate_plots(benchmark_data: BenchmarkData, output_dir: str): - """ - Generate plots for each metric, with one line per dataset - """ - os.makedirs(output_dir, exist_ok=True) - - # Create a plot for each metric - for metric in benchmark_data.metrics: - plt.figure(figsize=(10, 6)) - - # Plot one line per dataset - for dataset in benchmark_data.datasets: - releases, values = benchmark_data.get_metric_data_for_dataset(metric, dataset) - if releases and values: - # For QPS, try to add error bars using corresponding stddev column - if metric == "QPS": - std_releases, std_values = benchmark_data.get_metric_data_for_dataset("QPS StdDev", dataset) - if std_releases and std_values: - # Align stddev values to the QPS releases order - std_map = {r: v for r, v in zip(std_releases, std_values)} - yerr = [std_map.get(r, 0.0) for r in releases] - plt.errorbar(releases, values, yerr=yerr, marker='o', capsize=4, label=dataset) - else: - plt.plot(releases, values, marker='o', label=dataset) - else: - plt.plot(releases, values, marker='o', label=dataset) - - plt.title(f"{metric} Over Time") - plt.xlabel("Release") - plt.ylabel(metric) - plt.xticks(rotation=45) - plt.grid(True, linestyle='--', alpha=0.7) - plt.legend() - plt.tight_layout() - - # Save the plot - safe_metric_name = metric.replace('@', '_at_').replace(' ', '_') - plt.savefig(os.path.join(output_dir, f"{safe_metric_name}.png")) - plt.close() - - # Create a combined plot with aggregated values for comparison - plt.figure(figsize=(12, 8)) - - # Get system information - import platform - import psutil - - # Get CPU information - cpu_count = psutil.cpu_count(logical=False) - if cpu_count is None: - cpu_count = psutil.cpu_count(logical=True) - - # Get memory information - memory_gb = round(psutil.virtual_memory().total / (1024**3), 2) - - # Get processor information - processor = platform.processor() - if not processor: - processor = platform.machine() - - system_info = f"CPU: {processor}, Cores: {cpu_count}, Memory: {memory_gb} GB" - - for metric in benchmark_data.metrics: - plt.subplot(len(benchmark_data.metrics), 1, list(benchmark_data.metrics).index(metric) + 1) - - for dataset in benchmark_data.datasets: - releases, values = benchmark_data.get_metric_data_for_dataset(metric, dataset) - if releases and values: - # Aggregate values to the first release - if values[0] != 0: - aggregated_values = [v / values[0] for v in values] - plt.plot(releases, aggregated_values, marker='o', label=f"{dataset}") - - plt.title(f"Aggregated {metric}") - plt.grid(True, linestyle='--', alpha=0.7) - if metric == list(benchmark_data.metrics)[-1]: - plt.xlabel("Release") - plt.ylabel("Relative Change") - plt.xticks(rotation=45) - - # Add system information as text annotation at the bottom of the plot - if metric == list(benchmark_data.metrics)[-1]: - plt.figtext(0.5, 0.01, system_info, ha='center', fontsize=8, - bbox={'facecolor': 'white', 'alpha': 0.8, 'pad': 5}) - - plt.legend(bbox_to_anchor=(1.05, 1), loc='upper left') - plt.tight_layout(rect=[0, 0.03, 1, 1]) # Adjust layout to make room for the annotation - plt.savefig(os.path.join(output_dir, "aggregated_comparison.png")) - plt.close() - - -def generate_report(benchmark_data: BenchmarkData, changes: List[Dict[str, Any]], output_file: str): - """ - Generate a markdown report summarizing the benchmark results and changes - """ - with open(output_file, 'w') as f: - f.write("# Benchmark Performance Report\n\n") - - # Summary of datasets and metrics - f.write("## Summary\n\n") - f.write(f"- Total releases analyzed: {len(benchmark_data.releases)}\n") - f.write(f"- Datasets: {', '.join(benchmark_data.datasets)}\n") - f.write(f"- Metrics: {', '.join(benchmark_data.metrics)}\n\n") - - # Significant changes - improvements = [c for c in changes if c['status'] == 'improvement'] - regressions = [c for c in changes if c['status'] == 'regression'] - - f.write("## Significant Changes\n\n") - f.write(f"- Total improvements: {len(improvements)}\n") - f.write(f"- Total regressions: {len(regressions)}\n\n") - - # List improvements - if improvements: - f.write("### Improvements\n\n") - f.write("| Dataset | Metric | Releases | Previous | Current | Change |\n") - f.write("|---------|--------|----------|----------|---------|-------|\n") - - for change in improvements: - f.write(f"| {change['dataset']} | {change['metric']} | " + - f"{change['previous_release']} → {change['current_release']} | " + - f"{change['previous_value']:.4f} | {change['current_value']:.4f} | " + - f"{change['change_pct']:+.2f}% |\n") - - f.write("\n") - - # List regressions - if regressions: - f.write("### Regressions\n\n") - f.write("| Dataset | Metric | Releases | Previous | Current | Change |\n") - f.write("|---------|--------|----------|----------|---------|-------|\n") - - for change in regressions: - f.write(f"| {change['dataset']} | {change['metric']} | " + - f"{change['previous_release']} → {change['current_release']} | " + - f"{change['previous_value']:.4f} | {change['current_value']:.4f} | " + - f"{change['change_pct']:+.2f}% |\n") - - f.write("\n") - - # Latest results - f.write("## Latest Results\n\n") - latest_release = benchmark_data.releases[-1] if benchmark_data.releases else "N/A" - f.write(f"### Release: {latest_release}\n\n") - - f.write("| Dataset | QPS | Mean Latency | Recall@10 |\n") - f.write("|---------|-----|-------------|----------|\n") - - for dataset in benchmark_data.datasets: - if latest_release in benchmark_data.data and dataset in benchmark_data.data[latest_release]: - qps = benchmark_data.data[latest_release][dataset].get("QPS", "N/A") - latency = benchmark_data.data[latest_release][dataset].get("Mean Latency", "N/A") - recall = benchmark_data.data[latest_release][dataset].get("Recall@10", "N/A") - - if isinstance(qps, float): - qps = f"{qps:.2f}" - if isinstance(latency, float): - latency = f"{latency:.4f}" - if isinstance(recall, float): - recall = f"{recall:.4f}" - - f.write(f"| {dataset} | {qps} | {latency} | {recall} |\n") - - f.write("\n") - - # Include links to the plots - f.write("## Performance Graphs\n\n") - for metric in benchmark_data.metrics: - safe_metric_name = metric.replace('@', '_at_').replace(' ', '_') - f.write(f"![{metric}]({safe_metric_name}.png)\n\n") - - f.write("![Aggregated Comparison](aggregated_comparison.png)\n") - - -def main(): - parser = argparse.ArgumentParser(description="Compare and visualize benchmark results across multiple releases") - parser.add_argument("files", nargs="+", help="Paths to CSV benchmark result files") - parser.add_argument("--output-dir", default="benchmark_reports", help="Directory to save plots and reports") - parser.add_argument("--threshold", type=float, default=5.0, - help="Threshold percentage for detecting significant changes (default: 5.0)") - - args = parser.parse_args() - - # Create output directory - os.makedirs(args.output_dir, exist_ok=True) - - # Load benchmark data - benchmark_data = BenchmarkData() - - for file_path in args.files: - release = extract_release_from_filename(file_path) - print(f"Loading {file_path} as release {release}...") - benchmark_data.add_file(file_path, release) - - # We no longer sort the releases - we keep them in the order they were provided - # This comment replaces the previous sorting code - - # Detect significant changes - changes = benchmark_data.detect_changes(args.threshold) - - # Generate plots - generate_plots(benchmark_data, args.output_dir) - - # Generate report - report_path = os.path.join(args.output_dir, "benchmark_report.md") - generate_report(benchmark_data, changes, report_path) - - print(f"Benchmark analysis complete. Report saved to {report_path}") - print(f"Plots saved to {args.output_dir}") - - -if __name__ == "__main__": - main() From 01fdeff19b4b2ceb4e785901c5e909dcd56d7bf0 Mon Sep 17 00:00:00 2001 From: Ted Willke Date: Fri, 11 Sep 2026 18:44:13 +0000 Subject: [PATCH 044/243] Add compressor_cache to .gitignore. --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 4b5599f84..b8e3fb5b6 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,7 @@ dataset_ **/local_datasets/** ### Bench caches +compressor_cache/ pq_cache/ index_cache/ dataset_cache/ From 001f24e6b4ce72fdb6eeab40bc32e98096f4dbac Mon Sep 17 00:00:00 2001 From: Ted Willke Date: Fri, 11 Sep 2026 18:53:19 +0000 Subject: [PATCH 045/243] Implement standalone and fused ASH SIMD scoring with graph-compatible similarities and regression coverage. --- .../jvector/quantization/ASHBlockScorer.java | 4 +- .../jvector/quantization/ASHLutKernel.java | 53 +++ .../jvector/quantization/ASHScorer.java | 52 ++- .../jvector/quantization/ASHVectors.java | 176 ++++++-- .../jvector/quantization/FusedASHDecoder.java | 44 +- .../jbellis/jvector/vector/ASHLutScoring.java | 51 +++ .../jvector/vector/VectorUtilSupport.java | 45 ++ .../vector/PanamaVectorUtilSupport.java | 427 ++++++++++++++---- 8 files changed, 710 insertions(+), 142 deletions(-) create mode 100644 jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHLutKernel.java create mode 100644 jvector-base/src/main/java/io/github/jbellis/jvector/vector/ASHLutScoring.java diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHBlockScorer.java b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHBlockScorer.java index f2555d748..0398bdfba 100644 --- a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHBlockScorer.java +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHBlockScorer.java @@ -11,6 +11,9 @@ */ public interface ASHBlockScorer { + /** Human-readable implementation and dispatch details for benchmark diagnostics. */ + default String description() { return getClass().getSimpleName(); } + /** * Score {@code count} vectors starting at {@code start}. * Results are written to {@code out[0..count-1]}. @@ -20,4 +23,3 @@ public interface ASHBlockScorer { */ void scoreRange(int start, int count, float[] out); } - diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHLutKernel.java b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHLutKernel.java new file mode 100644 index 000000000..38e38a160 --- /dev/null +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHLutKernel.java @@ -0,0 +1,53 @@ +/* + * 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 io.github.jbellis.jvector.vector.ASHLutScoring; +import io.github.jbellis.jvector.vector.VectorUtilSupport; +import io.github.jbellis.jvector.vector.VectorizationProvider; +import java.util.Locale; + +/** Query-lifetime dispatch, shared by standalone and fused nibble-block scoring. */ +final class ASHLutKernel { + private final VectorUtilSupport backend = VectorizationProvider.getInstance().getVectorUtilSupport(); + private final boolean simd; + + ASHLutKernel() { + String mode = System.getProperty("jvector.ash.blockKernel", "auto").trim().toLowerCase(Locale.ROOT); + switch (mode) { + case "scalar": simd = false; break; + case "auto": simd = backend.supportsAshLutScoring(); break; + case "simd": + if (!backend.supportsAshLutScoring()) { + throw new IllegalStateException("ASH SIMD LUT scoring requested, but backend " + + backend.getClass().getName() + " does not support it"); + } + simd = true; + break; + default: throw new IllegalArgumentException("jvector.ash.blockKernel must be auto, scalar or simd: " + mode); + } + } + + void score(byte[] codes, int offset, int groups, int stride, int lane, int count, + float[] lut, float[] out, int outOffset) { + if (simd) backend.ashLutScore(codes, offset, groups, stride, lane, count, lut, out, outOffset); + else ASHLutScoring.score(codes, offset, groups, stride, lane, count, lut, out, outOffset); + } + + @Override + public String toString() { return simd ? "SIMD ASH LUT (" + backend.getClass().getSimpleName() + ")" : "scalar ASH LUT"; } +} diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHScorer.java b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHScorer.java index 502f9014a..e2fbecb27 100644 --- a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHScorer.java +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHScorer.java @@ -16,12 +16,15 @@ package io.github.jbellis.jvector.quantization; +import io.github.jbellis.jvector.vector.VectorizationProvider; + import io.github.jbellis.jvector.vector.VectorSimilarityFunction; import io.github.jbellis.jvector.vector.VectorUtil; import io.github.jbellis.jvector.vector.types.VectorFloat; /** - * Query-time scalar scorer for ASH vectors. + * Query-time raw dot-product scorer for ASH vectors, with scalar and SIMD paths. + * Graph-facing scorers convert its estimates using {@link #toSimilarity(float)}. * *

    * The 1-bit path preserves the original ASH decomposition: @@ -51,6 +54,18 @@ * NOTE: Only DOT_PRODUCT is supported for now. */ public final class ASHScorer { + /** + * Converts a raw ASH dot-product estimate to the graph's DOT_PRODUCT similarity + * scale. Graph search uses zero as its minimum similarity, including during + * hierarchy descent; passing a negative raw dot product can lose the entry point. + * Clamp the lower tail because quantization can estimate a dot product below -1. + * Kernels and this class's ASHScoreFunction retain raw dot products; node and + * block scorer adapters must apply this conversion exactly once. + */ + public static float toSimilarity(float dotProduct) { + return Math.max(0f, (1f + dotProduct) / 2f); + } + private final AsymmetricHashing ash; public ASHScorer(AsymmetricHashing ash) { @@ -100,6 +115,9 @@ private ASHScoreFunction dotProductScoreFunctionFor(VectorFloat query) { return fastScanProjectionDotProductScoreFunction(qp); } + if (ASHVectors.AshSingleKernel.fromProperty() == ASHVectors.AshSingleKernel.SIMD) { + throw new UnsupportedOperationException("SIMD ASH projection scoring supports 2 and 4 bits per dimension"); + } return genericMultibitDotProductScoreFunction(qp); } @@ -138,10 +156,39 @@ private ASHScoreFunction oneBitDotProductScoreFunction(QueryPrecompute qp) { }; } + /** Independent scalar oracle for the reference block-scoring API. */ + ASHScoreFunction scalarScoreFunctionFor(VectorFloat query) { + QueryPrecompute qp = precomputeQuery(query); + if (ash.bitsPerDimension == 1) return oneBitDotProductScoreFunction(qp); + if (AsymmetricHashing.usesFastScanProjectionCode(ash.bitsPerDimension)) { + return fastScanProjectionDotProductScoreFunction(qp, false); + } + return genericMultibitDotProductScoreFunction(qp); + } + private ASHScoreFunction fastScanProjectionDotProductScoreFunction(QueryPrecompute qp) { + final var backend = VectorizationProvider.getInstance().getVectorUtilSupport(); + final var mode = ASHVectors.AshSingleKernel.fromProperty(); + if (mode == ASHVectors.AshSingleKernel.SIMD && !backend.supportsAshProjectionScoring()) { + throw new IllegalStateException("ASH single-vector SIMD requested, but backend " + + backend.getClass().getName() + " does not support projection scoring"); + } + final boolean simd = mode != ASHVectors.AshSingleKernel.SCALAR && backend.supportsAshProjectionScoring(); + return fastScanProjectionDotProductScoreFunction(qp, simd); + } + + private ASHScoreFunction fastScanProjectionDotProductScoreFunction(QueryPrecompute qp, boolean simd) { final int d = qp.d; final int C = qp.C; final int bitsPerDimension = ash.bitsPerDimension; + final var backend = VectorizationProvider.getInstance().getVectorUtilSupport(); + + // Keep tuning dispatch out of the per-vector default kernel. Its compact + // compilation shape matters for end-to-end scoring throughput on HotSpot. + if (simd && backend.usesAshProjectionTuning()) { + return v -> v.scale * backend.ashProjectionDotTuned(qp.qProj, v.extraBits, d, bitsPerDimension) + + qp.dotQMuByLandmark[v.landmark & 255] + v.offset; + } return (AsymmetricHashing.QuantizedVector v) -> { final int c = v.landmark & 0xFF; // unsigned [0,C) @@ -150,7 +197,8 @@ private ASHScoreFunction fastScanProjectionDotProductScoreFunction(QueryPrecompu // C++ projection-mode scoring: // score = scale * + + stored_offset // stored_offset already includes -scale * . - float ip = AsymmetricHashing.dotProjectionCode( + float ip = simd ? backend.ashProjectionDot(qp.qProj, v.extraBits, d, bitsPerDimension) + : AsymmetricHashing.dotProjectionCode( qp.qProj, v.extraBits, d, diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHVectors.java b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHVectors.java index b86cd45a2..59abeeed4 100644 --- a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHVectors.java +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHVectors.java @@ -31,7 +31,9 @@ import java.util.Objects; /** - * Container for ASH-compressed vectors with a minimal scoring interface. + * Container for ASH-compressed vectors with scalar, SIMD, and block scoring. + * All public node and block scorers return nonnegative DOT_PRODUCT similarities, + * {@code max(0, (1 + rawDotProduct) / 2)}, rather than raw ASH dot products. * *

    * This class implements the functionality required for: @@ -41,15 +43,8 @@ *

  • Vectorized versions of scoring
  • * * - *

    - * TODO (future work): - *

      - *
    • Add a full ASH ScoreFunction aligned with graph search semantics
    • - *
    • Support similarityToNeighbor(...) where appropriate
    • - *
    • Integrate ASH as a Feature for on-disk graph indexes
    • - *
    • Define reranking strategy
    • - *
    • Support multi-landmark ASH
    • - *
    + *

    Fused graph neighborhoods are scored separately by {@link FusedASHDecoder}, + * using the same similarity scale and shared numerical kernels.

    */ public class ASHVectors implements CompressedVectors { @@ -63,10 +58,11 @@ enum AshSingleKernel { SIMD; static AshSingleKernel fromProperty() { - String v = System.getProperty("jvector.ash.singleKernel", "auto").toLowerCase(); + String v = System.getProperty("jvector.ash.singleKernel", "auto").trim().toLowerCase(java.util.Locale.ROOT); if ("scalar".equals(v)) return SCALAR; if ("simd".equals(v)) return SIMD; - return AUTO; + if ("auto".equals(v)) return AUTO; + throw new IllegalArgumentException("jvector.ash.singleKernel must be auto, scalar or simd: " + v); } } @@ -76,10 +72,11 @@ enum AshBlockKernel { SIMD; static AshBlockKernel fromProperty() { - String v = System.getProperty("jvector.ash.blockKernel", "auto").toLowerCase(); + String v = System.getProperty("jvector.ash.blockKernel", "auto").trim().toLowerCase(java.util.Locale.ROOT); if ("scalar".equals(v)) return SCALAR; if ("simd".equals(v)) return SIMD; - return AUTO; + if ("auto".equals(v)) return AUTO; + throw new IllegalArgumentException("jvector.ash.blockKernel must be auto, scalar or simd: " + v); } } @@ -101,6 +98,11 @@ static AshBlockKernel fromProperty() { // Layout: [v0_w0, v0_w1, ... v0_wN, v1_w0, ...] private volatile long[] flatPackedVectors = null; + // Immutable, body-only nibble blocks shared by queries. Header arrays contain + // decoded binary16 values; the serialized header remains 40 bits per vector. + private final java.util.concurrent.ConcurrentMap projectionBlocks = + new java.util.concurrent.ConcurrentHashMap<>(); + /** * Initialize ASHVectors with an array of ASH-compressed vectors. * @@ -212,11 +214,11 @@ public ScoreFunction.ApproximateScoreFunction scoreFunctionFor( throw new UnsupportedOperationException("ASH scorer supports DOT_PRODUCT only"); } - // Multibit scoring is currently routed through the scalar/reference scorer. - // The packed SIMD kernels below are sign-bit-only and remain enabled for 1-bit ASH. + // ASHScorer dispatches canonical 2/4-bit projection scoring to SIMD when available. + // The packed masked-add path below is specific to one-bit codes. if (ash.bitsPerDimension != 1) { final ASHScorer.ASHScoreFunction f = scorer.scoreFunctionFor(query, similarityFunction); - return node -> f.similarityTo(compressedVectors[node]); + return node -> ASHScorer.toSimilarity(f.similarityTo(compressedVectors[node])); } final AshSingleKernel kernel = AshSingleKernel.fromProperty(); @@ -225,7 +227,7 @@ public ScoreFunction.ApproximateScoreFunction scoreFunctionFor( // Scalar forced if (kernel == AshSingleKernel.SCALAR) { final ASHScorer.ASHScoreFunction f = scorer.scoreFunctionFor(query, similarityFunction); - return node -> f.similarityTo(compressedVectors[node]); + return node -> ASHScorer.toSimilarity(f.similarityTo(compressedVectors[node])); } // AUTO/SIMD: use SIMD scorer only when backend supports masked-load kernel; @@ -237,8 +239,11 @@ public ScoreFunction.ApproximateScoreFunction scoreFunctionFor( } // Fallback: existing scalar behavior (stable reference) + if (kernel == AshSingleKernel.SIMD) { + throw new IllegalStateException("ASH single-vector SIMD is unavailable on " + vecUtil.getClass().getName()); + } final ASHScorer.ASHScoreFunction f = scorer.scoreFunctionFor(query, similarityFunction); - return node -> f.similarityTo(compressedVectors[node]); + return node -> ASHScorer.toSimilarity(f.similarityTo(compressedVectors[node])); } // ============================================================================ @@ -290,7 +295,8 @@ public float similarityTo(int node2) { words ); - return scales[node2] * (2f * maskedAdd - qp.sumTildeQ[c]) + qp.dotQMu[c] + offsets[node2]; + return ASHScorer.toSimilarity(scales[node2] * (2f * maskedAdd - qp.sumTildeQ[c]) + + qp.dotQMu[c] + offsets[node2]); } @Override @@ -343,18 +349,14 @@ public void scoreRange(int start, int count, float[] out) { final int end = start + count; for (int i = start, o = 0; i < end; i++, o++) { - out[o] = scorer.similarityTo(vectors[i]); + out[o] = ASHScorer.toSimilarity(scorer.similarityTo(vectors[i])); } } } /** - * Returns a scalar reference block scorer. - * - *

    - * The returned scorer matches {@link #scoreFunctionFor} exactly - * and computes each score independently. - *

    + * Returns the independent scalar reference scorer, regardless of kernel properties. + * Use the overload accepting a block size for automatic or forced SIMD dispatch. */ public ASHBlockScorer blockScorerFor( VectorFloat query, @@ -366,7 +368,7 @@ public ASHBlockScorer blockScorerFor( } ASHScorer.ASHScoreFunction f = - scorer.scoreFunctionFor(query, similarityFunction); + scorer.scalarScoreFunctionFor(query); return new LegacyASHBlockScorer(f, compressedVectors); } @@ -376,7 +378,8 @@ public ASHBlockScorer blockScorerFor( * *

    * This factory selects between a portable scalar implementation and a - * SIMD implementation of ASH block scoring (ASH paper, Figure 1 / Table 1). + * SIMD implementation. One-bit codes use masked adds; 2/4-bit codes use + * nibble lookup tables over interleaved blocks of 8, 16 or 32 vectors. *

    * *

    @@ -388,14 +391,14 @@ public ASHBlockScorer blockScorerFor( * * *

      - *
    • auto (default): the active VectorUtilSupport backend determines whether execution is vectorized or scalar
    • - *
    • scalar: force the portable scalar register-accumulating implementation
    • - *
    • simd: simd: force the block scorer defined in the ASH paper; execution may still be scalar if the active backend does not support masked-load SIMD
    • + *
    • auto (default): use SIMD when supported by the active backend
    • + *
    • scalar: force portable scalar accumulation
    • + *
    • simd: require SIMD; fail if unavailable
    • *
    * *

    - * In all cases, accumulation of the masked-add term ⟨q̃, b⟩ is performed in - * local scalar registers, ensuring numerical equivalence across backends. + * Scorers are numerically equivalent within floating-point accumulation error. + * Packed multi-bit bodies are cached per block size and shared across queries. *

    */ public ASHBlockScorer blockScorerFor( @@ -410,13 +413,24 @@ public ASHBlockScorer blockScorerFor( throw new IllegalArgumentException("blockSize must be > 0"); } - // Multibit block scoring uses the scalar/reference scorer for now. The - // optimized block kernels below assume 1-bit sign reconstruction. + if (ash.bitsPerDimension == 2 || ash.bitsPerDimension == 4) { + FusedASHLayout.validateBlockSize(blockSize); + return new LutASHBlockScorer(query, blockSize); + } + + // Other bit widths retain their canonical scalar reconstruction. if (ash.bitsPerDimension != 1) { + if (AshBlockKernel.fromProperty() == AshBlockKernel.SIMD) { + throw new UnsupportedOperationException("SIMD ASH block scoring supports bitsPerDimension 1, 2 and 4"); + } return blockScorerFor(query, similarityFunction); } final AshBlockKernel kernel = AshBlockKernel.fromProperty(); + final boolean supportsSimd = VectorizationProvider.getInstance().getVectorUtilSupport().supportsAshMaskedLoad(); + if (kernel == AshBlockKernel.SIMD && !supportsSimd) { + throw new IllegalStateException("ASH masked-add SIMD is unavailable on the active backend"); + } // Compute query precompute ONCE (shared by scalar + simd) final QueryPrecompute qp = precomputeQuery(query); @@ -424,7 +438,7 @@ public ASHBlockScorer blockScorerFor( // ------------------------------------------------------------ // Forced scalar path (portable register-accumulator) // ------------------------------------------------------------ - if (kernel == AshBlockKernel.SCALAR) { + if (kernel == AshBlockKernel.SCALAR || !supportsSimd) { return new ScalarASHBlockScorer( compressedVectors, scales, @@ -435,11 +449,8 @@ public ASHBlockScorer blockScorerFor( } // ------------------------------------------------------------ - // SIMD or AUTO path (paper-faithful block kernel) + // SIMD path // ------------------------------------------------------------ - // AUTO and SIMD intentionally share the same implementation. - // VectorUtilSupport decides whether masked-load is vectorized - // or falls back to scalar internally. ensurePackedBits(blockSize); return new SimdASHBlockScorer( @@ -453,6 +464,74 @@ public ASHBlockScorer blockScorerFor( ); } + private byte[][] packProjectionBlocks(int blockSize) { + int count = compressedVectors.length; + int bytes = FusedASHLayout.canonicalCodeBytes(d, ash.bitsPerDimension); + byte[][] blocks = new byte[count / blockSize + (count % blockSize == 0 ? 0 : 1)][]; + for (int block = 0; block < blocks.length; block++) { + byte[] body = new byte[Math.multiplyExact(bytes, blockSize)]; + int base = block * blockSize; + for (int lane = 0; lane < Math.min(blockSize, count - base); lane++) { + byte[] code = compressedVectors[base + lane].extraBits; + if (code == null || code.length < bytes) { + throw new IllegalArgumentException("Missing ASH projection code at ordinal " + (base + lane)); + } + for (int i = 0; i < bytes; i++) body[i * blockSize + lane] = code[i]; + } + blocks[block] = body; + } + return blocks; + } + + /** Query-local LUT and dispatch over immutable, container-owned packed blocks. */ + private final class LutASHBlockScorer implements ASHBlockScorer { + private final ASHLutKernel kernel = new ASHLutKernel(); + private final int blockSize; + private final byte[][] blocks; + private final float[] lut; + private final float[] dotQMu; + private final int groups = FusedASHLayout.codeGroups(d, ash.bitsPerDimension); + + LutASHBlockScorer(VectorFloat query, int blockSize) { + this.blockSize = blockSize; + this.blocks = projectionBlocks.computeIfAbsent(blockSize, ASHVectors.this::packProjectionBlocks); + float[] q = new float[ash.originalDimension]; + for (int i = 0; i < q.length; i++) q[i] = query.get(i); + float[] projected = new float[d]; + VectorUtilSupport backend = VectorizationProvider.getInstance().getVectorUtilSupport(); + for (int i = 0; i < d; i++) projected[i] = backend.ashDotRow(ash.stiefelTransform.AFloat[i], q); + lut = new float[Math.multiplyExact(groups, 16)]; + FusedASHLayout.buildQueryLut(projected, d, ash.bitsPerDimension, lut); + dotQMu = new float[ash.landmarkCount]; + for (int i = 0; i < dotQMu.length; i++) dotQMu[i] = VectorUtil.dotProduct(query, ash.landmarks[i]); + } + + @Override + public void scoreRange(int start, int count, float[] out) { + Objects.checkFromIndexSize(start, count, compressedVectors.length); + Objects.checkFromIndexSize(0, count, out.length); + for (int done = 0; done < count;) { + int ordinal = start + done; + int lane = ordinal % blockSize; + int lanes = Math.min(count - done, blockSize - lane); + kernel.score(blocks[ordinal / blockSize], 0, groups, blockSize, + lane, lanes, lut, out, done); + for (int i = 0; i < lanes; i++) { + int node = ordinal + i; + out[done + i] = ASHScorer.toSimilarity(scales[node] * out[done + i] + + dotQMu[landmarks[node] & 255] + offsets[node]); + } + done += lanes; + } + } + + @Override + public String toString() { return "LutASHBlockScorer[" + kernel + ", blockSize=" + blockSize + "]"; } + + @Override + public String description() { return toString(); } + } + private void ensurePackedBits(int blockSize) { if (ash.bitsPerDimension != 1) { throw new IllegalStateException("Packed sign-bit block cache supports only 1-bit ASH"); @@ -594,10 +673,10 @@ public void scoreRange(int start, int count, float[] out) { } } - out[i] = + out[i] = ASHScorer.toSimilarity( scales[idx] * (2f * maskedAdd - sumTildeQ[c]) + dotQMu[c] - + offsets[idx]; + + offsets[idx]); } } } @@ -738,11 +817,11 @@ public void scoreRange(int start, int count, float[] out) { for (int lane = 0; lane < runLen; lane++) { int idx = ord + lane; float m = maskedAdds[lane]; - out[idx - start] = + out[idx - start] = ASHScorer.toSimilarity( // scale the 0/1 stored bits to +/-1 for processing. scales[idx] * (2f * m - runSumTildeQ) + runDotQMu - + offsets[idx]; + + offsets[idx]); } ord += runLen; @@ -1007,7 +1086,12 @@ public long ramBytesUsed() { long vectorsDataSize = (long) ash.compressedVectorSize() * compressedVectors.length; - return ash.ramBytesUsed() + vectorsArraySize + vectorsDataSize; + long projectionCacheBytes = 0; + for (byte[][] blocks : projectionBlocks.values()) { + projectionCacheBytes += AH_BYTES + (long) REF_BYTES * blocks.length; + for (byte[] body : blocks) projectionCacheBytes += RamUsageEstimator.sizeOf(body); + } + return ash.ramBytesUsed() + vectorsArraySize + vectorsDataSize + projectionCacheBytes; } @Override diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/FusedASHDecoder.java b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/FusedASHDecoder.java index d26e3eaea..f7fa06281 100644 --- a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/FusedASHDecoder.java +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/FusedASHDecoder.java @@ -36,8 +36,9 @@ * existing fused-feature search API while avoiding one-neighbor-at-a-time decode work in the * hot graph-expansion loop.

    * - *

    This class is the scalar/reference implementation over the final FusedASH block layout. - * SIMD kernels should consume the same byte layout and produce the same lane scores.

    + *

    Scalar and SIMD execution share the same packed layout. The property + * {@code jvector.ash.blockKernel=auto|scalar|simd} selects execution once per decoder; + * forced SIMD fails at construction if the backend cannot provide it.

    * *

    FusedASH has two primary scoring paths:

    * @@ -55,10 +56,11 @@ * offset = <x, μ> - ||μ||² - scale * <Aμ, code> * * - *

    Given that encode-time adjustment, query-time scoring is:

    + *

    Given that encode-time adjustment, the raw dot-product estimate is:

    * *
    - *   score = scale * <Aq, code> + <q, μ> + offset
    + *   rawDotProduct = scale * <Aq, code> + <q, μ> + offset
    + *   similarity = max(0, (1 + rawDotProduct) / 2)
      * 
    */ public final class FusedASHDecoder implements ScoreFunction.ApproximateScoreFunction { @@ -99,6 +101,7 @@ public interface InlineSourceAccessor { private final float[] queryLut; private final float[] dotQMuByLandmark; + private final ASHLutKernel kernel = new ASHLutKernel(); private int origin = -1; @@ -203,9 +206,11 @@ public void enableSimilarityToNeighbors(int origin) { return; } - this.origin = origin; + // Invalidate before I/O: a failed read must not leave partially replaced scores cached. + this.origin = -1; packedNeighborhoods.readInto(origin, neighborhoodScratch); scoreNeighborhood(); + this.origin = origin; } /** @@ -233,7 +238,7 @@ public float similarityTo(int node2) { ); } - return sourceScorer.similarityTo(vector); + return ASHScorer.toSimilarity(sourceScorer.similarityTo(vector)); } @Override @@ -254,20 +259,27 @@ private void scoreNeighborhood() { int laneBase = block * blockSize; int lanes = Math.min(blockSize, maxDegree - laneBase); + kernel.score(neighborhoodScratch, blockOffset, + FusedASHLayout.codeGroups(quantizedDim, bitsPerDimension), blockSize, + 0, lanes, queryLut, neighborScores, laneBase); for (int lane = 0; lane < lanes; lane++) { - neighborScores[laneBase + lane] = FusedASHLayout.scoreLane( - neighborhoodScratch, - blockOffset, - lane, - quantizedDim, - bitsPerDimension, - blockSize, - queryLut, - dotQMuByLandmark); + int landmark = FusedASHLayout.readLandmark(neighborhoodScratch, blockOffset, + lane, quantizedDim, bitsPerDimension, blockSize); + if (landmark >= dotQMuByLandmark.length) { + throw new IllegalStateException("Invalid FusedASH landmark: " + landmark); + } + neighborScores[laneBase + lane] = ASHScorer.toSimilarity(FusedASHLayout.readScale(neighborhoodScratch, + blockOffset, lane, quantizedDim, bitsPerDimension, blockSize) + * neighborScores[laneBase + lane] + dotQMuByLandmark[landmark] + + FusedASHLayout.readOffset(neighborhoodScratch, blockOffset, + lane, quantizedDim, bitsPerDimension, blockSize)); } } } + @Override + public String toString() { return "FusedASHDecoder[" + kernel + "]"; } + private void precomputeQuery(VectorFloat query, float[] lut, float[] dotQMu) { final int D = ash.originalDimension; final int d = ash.quantizedDim; @@ -291,4 +303,4 @@ private void precomputeQuery(VectorFloat query, float[] lut, float[] dotQMu) dotQMu[c] = VectorUtil.dotProduct(query, ash.landmarks[c]); } } -} \ No newline at end of file +} diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/vector/ASHLutScoring.java b/jvector-base/src/main/java/io/github/jbellis/jvector/vector/ASHLutScoring.java new file mode 100644 index 000000000..32ae91560 --- /dev/null +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/vector/ASHLutScoring.java @@ -0,0 +1,51 @@ +/* + * 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 java.util.Objects; + +/** Portable reference and bounds contract for interleaved ASH nibble blocks. */ +public final class ASHLutScoring { + private ASHLutScoring() {} + + public static void checkBounds(byte[] codes, int offset, int groups, int stride, + int lane, int count, float[] lut, float[] out, int outOffset) { + if (groups < 0 || stride <= 0) { + throw new IllegalArgumentException("groups must be nonnegative and stride must be positive"); + } + Objects.checkFromIndexSize(lane, count, stride); + Objects.checkFromIndexSize(outOffset, count, out.length); + Objects.checkFromIndexSize(0, Math.multiplyExact(groups, 16), lut.length); + int bytes = Math.multiplyExact(groups / 2 + groups % 2, stride); + Objects.checkFromIndexSize(offset, bytes, codes.length); + } + + /** Returns projection dot products only; callers apply scale, offset and landmark terms. */ + public static void score(byte[] codes, int offset, int groups, int stride, + int lane, int count, float[] lut, float[] out, int outOffset) { + checkBounds(codes, offset, groups, stride, lane, count, lut, out, outOffset); + for (int i = 0; i < count; i++) { + float sum = 0; + for (int group = 0; group < groups; group++) { + int packed = codes[offset + (group >>> 1) * stride + lane + i] & 255; + int nibble = (packed >>> ((group & 1) * 4)) & 15; + sum += lut[group * 16 + nibble]; + } + out[outOffset + i] = sum; + } + } +} diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/vector/VectorUtilSupport.java b/jvector-base/src/main/java/io/github/jbellis/jvector/vector/VectorUtilSupport.java index eeaaf223b..0322310d8 100644 --- a/jvector-base/src/main/java/io/github/jbellis/jvector/vector/VectorUtilSupport.java +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/vector/VectorUtilSupport.java @@ -35,6 +35,51 @@ */ public interface VectorUtilSupport { + /** Effective ASH kernel tuning for benchmark diagnostics. */ + default String ashKernelDescription() { return "portable scalar ASH kernels"; } + + /** Query factories select the tuned entry point once, outside the scoring hot loop. */ + default boolean usesAshProjectionTuning() { return false; } + + /** Explicitly tuned projection kernel; the ordinary entry point retains its compact default loop. */ + default float ashProjectionDotTuned(float[] query, byte[] code, int dimensions, int bitsPerDimension) { + return ashProjectionDot(query, code, dimensions, bitsPerDimension); + } + + /** Whether ASH nibble lookup and accumulation are implemented with SIMD. */ + default boolean supportsAshLutScoring() { return false; } + + /** Whether canonical 2/4-bit projection codes have a single-vector SIMD kernel. */ + default boolean supportsAshProjectionScoring() { return false; } + + /** Dot product of a projected query with a canonical signed-magnitude 2/4-bit code. */ + default float ashProjectionDot(float[] query, byte[] code, int dimensions, int bitsPerDimension) { + if (bitsPerDimension != 2 && bitsPerDimension != 4) { + throw new IllegalArgumentException("Projection scoring requires 2 or 4 bits per dimension"); + } + java.util.Objects.checkFromIndexSize(0, dimensions, query.length); + int perByte = 8 / bitsPerDimension; + java.util.Objects.checkFromIndexSize(0, dimensions / perByte + (dimensions % perByte == 0 ? 0 : 1), code.length); + int sign = 1 << (bitsPerDimension - 1); + float sum = 0; + for (int i = 0; i < dimensions; i++) { + int field = (code[i / perByte] & 255) >>> ((i % perByte) * bitsPerDimension); + float magnitude = (field & (sign - 1)) + 0.5f; + sum += query[i] * ((field & sign) == 0 ? -magnitude : magnitude); + } + return sum; + } + + /** + * Scores contiguous lanes in a byte-interleaved ASH block using a 16-entry LUT per nibble. + * Even groups occupy low nibbles; odd groups occupy high nibbles. Each byte row has + * {@code stride} lanes. Writes projection dot products, without per-vector headers. + */ + default void ashLutScore(byte[] codes, int offset, int groups, int stride, + int lane, int count, float[] lut, float[] out, int outOffset) { + ASHLutScoring.score(codes, offset, groups, stride, lane, count, lut, out, outOffset); + } + /** Calculates the dot product of the given float arrays. */ float dotProduct(VectorFloat a, VectorFloat b); 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 5b60b16d7..37832ccb2 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 @@ -31,8 +31,86 @@ import java.util.List; class PanamaVectorUtilSupport implements VectorUtilSupport { - private static final String ASH_BLOCK_KERNEL = - System.getProperty("jvector.ash.blockKernel", "mask"); // "lut" or "mask" + private static final VectorSpecies ASH_FLOATS = FloatVector.SPECIES_PREFERRED; + private static final VectorSpecies ASH_INTS = VectorSpecies.of( + int.class, ASH_FLOATS.vectorShape()); + // Byte loads need only one byte per float lane; conversion part zero widens those lanes. + private static final VectorSpecies ASH_BYTES = ASH_FLOATS.length() <= 8 + ? ByteVector.SPECIES_64 : ByteVector.SPECIES_128; + private static final int[] ASH_TWO_BIT_QUERY_INDICES = ashQueryIndices(4); + private static final int[] ASH_FOUR_BIT_QUERY_INDICES = ashQueryIndices(2); + + private static int[] ashQueryIndices(int dimensionsPerByte) { + int[] indices = new int[ASH_FLOATS.length()]; + for (int i = 0; i < indices.length; i++) indices[i] = i * dimensionsPerByte; + return indices; + } + + @Override + public boolean supportsAshProjectionScoring() { return PanamaASHKernels.supported(); } + + @Override + public boolean supportsAshLutScoring() { return PanamaASHKernels.supported(); } + + @Override + public String ashKernelDescription() { return PanamaASHKernels.description(); } + + @Override + public boolean usesAshProjectionTuning() { return PanamaASHKernels.projectionTuningEnabled(); } + + @Override + public float ashProjectionDotTuned(float[] query, byte[] code, int dimensions, int bitsPerDimension) { + if (!supportsAshProjectionScoring()) { + return VectorUtilSupport.super.ashProjectionDot(query, code, dimensions, bitsPerDimension); + } + return PanamaASHKernels.projectionDot(query, code, dimensions, bitsPerDimension); + } + + @Override + public float ashProjectionDot(float[] query, byte[] code, int dimensions, int bitsPerDimension) { + if (!supportsAshProjectionScoring()) { + return VectorUtilSupport.super.ashProjectionDot(query, code, dimensions, bitsPerDimension); + } + if (bitsPerDimension != 2 && bitsPerDimension != 4) { + throw new IllegalArgumentException("Projection scoring requires 2 or 4 bits per dimension"); + } + Objects.checkFromIndexSize(0, dimensions, query.length); + int perByte = 8 / bitsPerDimension; + int bytes = dimensions / perByte + (dimensions % perByte == 0 ? 0 : 1); + Objects.checkFromIndexSize(0, bytes, code.length); + int sign = 1 << (bitsPerDimension - 1); + int[] indices = bitsPerDimension == 2 ? ASH_TWO_BIT_QUERY_INDICES : ASH_FOUR_BIT_QUERY_INDICES; + FloatVector sum = FloatVector.zero(ASH_FLOATS); + for (int base = 0; base < bytes; base += ASH_FLOATS.length()) { + int activeBytes = Math.min(ASH_FLOATS.length(), bytes - base); + var packed = (IntVector) ByteVector.fromArray(ASH_BYTES, code, base, + ASH_BYTES.indexInRange(0, activeBytes)).convertShape(VectorOperators.B2I, ASH_INTS, 0); + for (int slot = 0; slot < perByte; slot++) { + int remaining = dimensions - base * perByte - slot; + if (remaining <= 0) break; + int active = Math.min(ASH_FLOATS.length(), (remaining - 1) / perByte + 1); + var mask = ASH_FLOATS.indexInRange(0, active); + var fields = packed.lanewise(VectorOperators.LSHR, slot * bitsPerDimension); + var magnitude = ((FloatVector) fields.and(sign - 1) + .convertShape(VectorOperators.I2F, ASH_FLOATS, 0)).add(0.5f); + var values = magnitude.blend(magnitude.neg(), fields.and(sign) + .compare(VectorOperators.EQ, 0).cast(ASH_FLOATS)); + var q = FloatVector.fromArray(ASH_FLOATS, query, base * perByte + slot, indices, 0, mask); + sum = sum.add(q.mul(values)); + } + } + return sum.reduceLanes(VectorOperators.ADD); + } + + @Override + public void ashLutScore(byte[] codes, int offset, int groups, int stride, + int lane, int count, float[] lut, float[] out, int outOffset) { + if (!supportsAshLutScoring()) { + VectorUtilSupport.super.ashLutScore(codes, offset, groups, stride, lane, count, lut, out, outOffset); + return; + } + PanamaASHKernels.lutScore(codes, offset, groups, stride, lane, count, lut, out, outOffset); + } static final int PREFERRED_BIT_SIZE = FloatVector.SPECIES_PREFERRED.vectorBitSize(); static { @@ -1672,95 +1750,290 @@ public float ashMaskedAddFlat(float[] tildeQ, return acc0.add(acc1).add(acc2).add(acc3).reduceLanes(VectorOperators.ADD); } - public float ashMaskedAdd_512(float[] tildeQ, int qOffset, long[] allPackedVectors, int packedBase, int d, int words) { - final VectorSpecies SPEC = FloatVector.SPECIES_PREFERRED; // Assumed 512-bit (16 floats) - int safeWords = d / 64; + private static final int ASH_512_ACCUMULATORS = + Integer.getInteger("jvector.ash.512.accumulators", 8); - // 1. Determine Unroll Strategy based on d - // We aim for 8 accumulators for large d, but 384 remains at 6. - int numAcc = (d >= 512) ? 8 : (d == 384 ? 6 : (d >= 128 ? 4 : 2)); + public float ashMaskedAdd_512( + float[] tildeQ, + int qOffset, + long[] allPackedVectors, + int packedBase, + int d, + int words) { + return ashMaskedAdd_512( + tildeQ, + qOffset, + allPackedVectors, + packedBase, + d, + words, + ASH_512_ACCUMULATORS); + } - // Initialize required accumulators - FloatVector acc0 = FloatVector.zero(SPEC), acc1 = FloatVector.zero(SPEC); - FloatVector acc2 = null, acc3 = null, acc4 = null, acc5 = null, acc6 = null, acc7 = null; + /** + * Package-private overload for correctness tests and microbenchmarks. + */ + float ashMaskedAdd_512( + float[] tildeQ, + int qOffset, + long[] allPackedVectors, + int packedBase, + int d, + int words, + int accumulatorCount) { + if (d < 0) { + throw new IllegalArgumentException("d must be nonnegative: " + d); + } + + int requiredWords = d / 64 + (d % 64 == 0 ? 0 : 1); + if (words < requiredWords) { + throw new IllegalArgumentException( + "Not enough packed words: required=" + + requiredWords + + ", provided=" + + words); + } - if (numAcc >= 4) { acc2 = FloatVector.zero(SPEC); acc3 = FloatVector.zero(SPEC); } - if (numAcc >= 6) { acc4 = FloatVector.zero(SPEC); acc5 = FloatVector.zero(SPEC); } - if (numAcc >= 8) { acc6 = FloatVector.zero(SPEC); acc7 = FloatVector.zero(SPEC); } + Objects.checkFromIndexSize(qOffset, d, tildeQ.length); + Objects.checkFromIndexSize(packedBase, requiredWords, allPackedVectors.length); + // The unrolled kernels below address sixteen float lanes per vector. + // Other machines must use the species-width-independent implementation. + if (FloatVector.SPECIES_PREFERRED.length() != 16) { + if (accumulatorCount != 4 && accumulatorCount != 8) { + throw new IllegalArgumentException("accumulatorCount must be 4 or 8"); + } + return ashMaskedAddFlat(tildeQ, qOffset, allPackedVectors, packedBase, d, requiredWords); + } - int w = 0; + if (accumulatorCount == 4) { + return ashMaskedAdd4( + tildeQ, + qOffset, + allPackedVectors, + packedBase, + d); + } - // 2. Main Unrolled Loop - // For d=384, this uses a 3-word unroll. - // For d >= 512, we use a 2-word unroll across 8 registers for better throughput. - int unrollFactor = (d == 384) ? 3 : (d >= 512 ? 2 : 1); - - for (; w <= safeWords - unrollFactor; w += unrollFactor) { - int baseIdx = qOffset + (w << 6); - - // Word 0 Processing - long wd0 = allPackedVectors[packedBase + w]; - acc0 = acc0.add(FloatVector.fromArray(SPEC, tildeQ, baseIdx), VectorMask.fromLong(SPEC, wd0 & 0xFFFF)); - acc1 = acc1.add(FloatVector.fromArray(SPEC, tildeQ, baseIdx + 16), VectorMask.fromLong(SPEC, (wd0 >>> 16) & 0xFFFF)); - acc2 = acc2.add(FloatVector.fromArray(SPEC, tildeQ, baseIdx + 32), VectorMask.fromLong(SPEC, (wd0 >>> 32) & 0xFFFF)); - acc3 = acc3.add(FloatVector.fromArray(SPEC, tildeQ, baseIdx + 48), VectorMask.fromLong(SPEC, (wd0 >>> 48))); - - if (unrollFactor >= 2) { - long wd1 = allPackedVectors[packedBase + w + 1]; - int b1 = baseIdx + 64; - if (numAcc == 6) { // Special Case for d=384 Interleave - acc4 = acc4.add(FloatVector.fromArray(SPEC, tildeQ, b1), VectorMask.fromLong(SPEC, wd1 & 0xFFFF)); - acc5 = acc5.add(FloatVector.fromArray(SPEC, tildeQ, b1 + 16), VectorMask.fromLong(SPEC, (wd1 >>> 16) & 0xFFFF)); - acc0 = acc0.add(FloatVector.fromArray(SPEC, tildeQ, b1 + 32), VectorMask.fromLong(SPEC, (wd1 >>> 32) & 0xFFFF)); - acc1 = acc1.add(FloatVector.fromArray(SPEC, tildeQ, b1 + 48), VectorMask.fromLong(SPEC, (wd1 >>> 48))); - } else { // Standard 8-accumulator path - acc4 = acc4.add(FloatVector.fromArray(SPEC, tildeQ, b1), VectorMask.fromLong(SPEC, wd1 & 0xFFFF)); - acc5 = acc5.add(FloatVector.fromArray(SPEC, tildeQ, b1 + 16), VectorMask.fromLong(SPEC, (wd1 >>> 16) & 0xFFFF)); - acc6 = acc6.add(FloatVector.fromArray(SPEC, tildeQ, b1 + 32), VectorMask.fromLong(SPEC, (wd1 >>> 32) & 0xFFFF)); - acc7 = acc7.add(FloatVector.fromArray(SPEC, tildeQ, b1 + 48), VectorMask.fromLong(SPEC, (wd1 >>> 48))); - } - } + if (accumulatorCount == 8) { + return ashMaskedAdd8( + tildeQ, + qOffset, + allPackedVectors, + packedBase, + d); + } - if (unrollFactor == 3) { // Tail of the d=384 case - long wd2 = allPackedVectors[packedBase + w + 2]; - int b2 = baseIdx + 128; - acc2 = acc2.add(FloatVector.fromArray(SPEC, tildeQ, b2), VectorMask.fromLong(SPEC, wd2 & 0xFFFF)); - acc3 = acc3.add(FloatVector.fromArray(SPEC, tildeQ, b2 + 16), VectorMask.fromLong(SPEC, (wd2 >>> 16) & 0xFFFF)); - acc4 = acc4.add(FloatVector.fromArray(SPEC, tildeQ, b2 + 32), VectorMask.fromLong(SPEC, (wd2 >>> 32) & 0xFFFF)); - acc5 = acc5.add(FloatVector.fromArray(SPEC, tildeQ, b2 + 48), VectorMask.fromLong(SPEC, (wd2 >>> 48))); - } + throw new IllegalArgumentException( + "ASH 512 accumulator count must be 4 or 8: " + + accumulatorCount); + } + + private float ashMaskedAdd4( + float[] tildeQ, + int qOffset, + long[] packedVectors, + int packedBase, + int d) { + final VectorSpecies spec = FloatVector.SPECIES_PREFERRED; + + FloatVector acc0 = FloatVector.zero(spec); + FloatVector acc1 = FloatVector.zero(spec); + FloatVector acc2 = FloatVector.zero(spec); + FloatVector acc3 = FloatVector.zero(spec); + + int fullWords = d >>> 6; + + for (int w = 0; w < fullWords; w++) { + int qBase = qOffset + (w << 6); + long packed = packedVectors[packedBase + w]; + + acc0 = addMasked16(acc0, tildeQ, qBase, packed, 0, spec); + acc1 = addMasked16(acc1, tildeQ, qBase + 16, packed, 16, spec); + acc2 = addMasked16(acc2, tildeQ, qBase + 32, packed, 32, spec); + acc3 = addMasked16(acc3, tildeQ, qBase + 48, packed, 48, spec); } - // 3. Cleanup Loop - for (; w < safeWords; w++) { - int baseIdx = qOffset + (w << 6); - long wd = allPackedVectors[packedBase + w]; - acc0 = acc0.add(FloatVector.fromArray(SPEC, tildeQ, baseIdx), VectorMask.fromLong(SPEC, wd & 0xFFFF)); - acc1 = acc1.add(FloatVector.fromArray(SPEC, tildeQ, baseIdx + 16), VectorMask.fromLong(SPEC, (wd >>> 16) & 0xFFFF)); - if (numAcc >= 4) { - acc2 = acc2.add(FloatVector.fromArray(SPEC, tildeQ, baseIdx + 32), VectorMask.fromLong(SPEC, (wd >>> 32) & 0xFFFF)); - acc3 = acc3.add(FloatVector.fromArray(SPEC, tildeQ, baseIdx + 48), VectorMask.fromLong(SPEC, (wd >>> 48))); + int remainder = d & 63; + if (remainder != 0) { + int qBase = qOffset + (fullWords << 6); + long packed = packedVectors[packedBase + fullWords]; + + if (remainder > 0) { + acc0 = addMaskedTail( + acc0, tildeQ, qBase, packed, 0, + Math.min(remainder, 16), spec); + } + if (remainder > 16) { + acc1 = addMaskedTail( + acc1, tildeQ, qBase + 16, packed, 16, + Math.min(remainder - 16, 16), spec); + } + if (remainder > 32) { + acc2 = addMaskedTail( + acc2, tildeQ, qBase + 32, packed, 32, + Math.min(remainder - 32, 16), spec); + } + if (remainder > 48) { + acc3 = addMaskedTail( + acc3, tildeQ, qBase + 48, packed, 48, + remainder - 48, spec); } } - // 4. Dynamic Reduction Tree - return performReduction(acc0, acc1, acc2, acc3, acc4, acc5, acc6, acc7, numAcc); + return reduce4(acc0, acc1, acc2, acc3); } - private float performReduction(FloatVector a0, FloatVector a1, FloatVector a2, FloatVector a3, - FloatVector a4, FloatVector a5, FloatVector a6, FloatVector a7, int count) { - FloatVector res; - if (count == 8) { - FloatVector s01 = a0.add(a1), s23 = a2.add(a3), s45 = a4.add(a5), s67 = a6.add(a7); - res = s01.add(s23).add(s45.add(s67)); - } else if (count == 6) { - res = a0.add(a1).add(a2.add(a3)).add(a4.add(a5)); - } else if (count == 4) { - res = a0.add(a1).add(a2.add(a3)); - } else { - res = a0.add(a1); + private float ashMaskedAdd8( + float[] tildeQ, + int qOffset, + long[] packedVectors, + int packedBase, + int d) { + final VectorSpecies spec = FloatVector.SPECIES_PREFERRED; + + FloatVector acc0 = FloatVector.zero(spec); + FloatVector acc1 = FloatVector.zero(spec); + FloatVector acc2 = FloatVector.zero(spec); + FloatVector acc3 = FloatVector.zero(spec); + FloatVector acc4 = FloatVector.zero(spec); + FloatVector acc5 = FloatVector.zero(spec); + FloatVector acc6 = FloatVector.zero(spec); + FloatVector acc7 = FloatVector.zero(spec); + + int fullWords = d >>> 6; + int w = 0; + + // Two packed words produce eight independent SIMD additions. + for (; w + 1 < fullWords; w += 2) { + int qBase = qOffset + (w << 6); + + long packed0 = packedVectors[packedBase + w]; + acc0 = addMasked16(acc0, tildeQ, qBase, packed0, 0, spec); + acc1 = addMasked16(acc1, tildeQ, qBase + 16, packed0, 16, spec); + acc2 = addMasked16(acc2, tildeQ, qBase + 32, packed0, 32, spec); + acc3 = addMasked16(acc3, tildeQ, qBase + 48, packed0, 48, spec); + + long packed1 = packedVectors[packedBase + w + 1]; + int qBase1 = qBase + 64; + + acc4 = addMasked16(acc4, tildeQ, qBase1, packed1, 0, spec); + acc5 = addMasked16(acc5, tildeQ, qBase1 + 16, packed1, 16, spec); + acc6 = addMasked16(acc6, tildeQ, qBase1 + 32, packed1, 32, spec); + acc7 = addMasked16(acc7, tildeQ, qBase1 + 48, packed1, 48, spec); + } + + // One remaining complete word. + if (w < fullWords) { + int qBase = qOffset + (w << 6); + long packed = packedVectors[packedBase + w]; + + acc0 = addMasked16(acc0, tildeQ, qBase, packed, 0, spec); + acc1 = addMasked16(acc1, tildeQ, qBase + 16, packed, 16, spec); + acc2 = addMasked16(acc2, tildeQ, qBase + 32, packed, 32, spec); + acc3 = addMasked16(acc3, tildeQ, qBase + 48, packed, 48, spec); } - return res.reduceLanes(VectorOperators.ADD); + + int remainder = d & 63; + if (remainder != 0) { + int qBase = qOffset + (fullWords << 6); + long packed = packedVectors[packedBase + fullWords]; + + if (remainder > 0) { + acc4 = addMaskedTail( + acc4, tildeQ, qBase, packed, 0, + Math.min(remainder, 16), spec); + } + if (remainder > 16) { + acc5 = addMaskedTail( + acc5, tildeQ, qBase + 16, packed, 16, + Math.min(remainder - 16, 16), spec); + } + if (remainder > 32) { + acc6 = addMaskedTail( + acc6, tildeQ, qBase + 32, packed, 32, + Math.min(remainder - 32, 16), spec); + } + if (remainder > 48) { + acc7 = addMaskedTail( + acc7, tildeQ, qBase + 48, packed, 48, + remainder - 48, spec); + } + } + + return reduce8( + acc0, acc1, acc2, acc3, + acc4, acc5, acc6, acc7); + } + + private static FloatVector addMasked16( + FloatVector accumulator, + float[] values, + int valueOffset, + long packed, + int bitShift, + VectorSpecies spec) { + long bits = (packed >>> bitShift) & 0xFFFFL; + VectorMask selected = VectorMask.fromLong(spec, bits); + + return accumulator.add( + FloatVector.fromArray(spec, values, valueOffset), + selected); + } + + private static FloatVector addMaskedTail( + FloatVector accumulator, + float[] values, + int valueOffset, + long packed, + int bitShift, + int laneCount, + VectorSpecies spec) { + VectorMask validLanes = + spec.indexInRange(0, laneCount); + + long bits = (packed >>> bitShift) & 0xFFFFL; + VectorMask selected = + VectorMask.fromLong(spec, bits).and(validLanes); + + FloatVector vector = + FloatVector.fromArray( + spec, + values, + valueOffset, + validLanes); + + return accumulator.add(vector, selected); + } + + private static float reduce4( + FloatVector acc0, + FloatVector acc1, + FloatVector acc2, + FloatVector acc3) { + FloatVector sum01 = acc0.add(acc1); + FloatVector sum23 = acc2.add(acc3); + + return sum01.add(sum23) + .reduceLanes(VectorOperators.ADD); + } + + private static float reduce8( + FloatVector acc0, + FloatVector acc1, + FloatVector acc2, + FloatVector acc3, + FloatVector acc4, + FloatVector acc5, + FloatVector acc6, + FloatVector acc7) { + FloatVector sum01 = acc0.add(acc1); + FloatVector sum23 = acc2.add(acc3); + FloatVector sum45 = acc4.add(acc5); + FloatVector sum67 = acc6.add(acc7); + + return sum01.add(sum23) + .add(sum45.add(sum67)) + .reduceLanes(VectorOperators.ADD); } // This version is optimized for dense masks and uses a masked load, plain add. From 074998a80056eceaf6f8038d5c702b6c406c124d Mon Sep 17 00:00:00 2001 From: Ted Willke Date: Fri, 11 Sep 2026 18:55:41 +0000 Subject: [PATCH 046/243] Eliminate ASH LUT scoring allocations, add independent kernel tuning, and enforce 40-bit encoded headers. --- .../quantization/AsymmetricHashing.java | 10 +- .../jvector/quantization/FusedASHLayout.java | 2 +- .../quantization/TestASHKernelTuning.java | 101 ++++++++ .../quantization/TestASHLutScoring.java | 134 ++++++++++ .../jvector/vector/PanamaASHKernels.java | 239 ++++++++++++++++++ 5 files changed, 481 insertions(+), 5 deletions(-) create mode 100644 jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestASHKernelTuning.java create mode 100644 jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestASHLutScoring.java create mode 100644 jvector-twenty/src/main/java/io/github/jbellis/jvector/vector/PanamaASHKernels.java diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/AsymmetricHashing.java b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/AsymmetricHashing.java index 7930fe10e..0bbbb7de3 100644 --- a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/AsymmetricHashing.java +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/AsymmetricHashing.java @@ -62,15 +62,15 @@ public class AsymmetricHashing implements VectorCompressor − ||μ||_2^2 // - landmark id: byte (8 bits) in [0, C) - public static final int HEADER_BITS = - (Short.BYTES + Short.BYTES + Byte.BYTES) * 8; // 40 bits currently + public static final int HEADER_BYTES = Short.BYTES + Short.BYTES + Byte.BYTES; + public static final int HEADER_BITS = HEADER_BYTES * Byte.SIZE; private static final VectorTypeSupport vectorTypeSupport = VectorizationProvider.getInstance().getVectorTypeSupport(); @@ -969,6 +969,8 @@ public float quantizeBody(VectorFloat vector, // --------------------------------------------------------------------- public static class QuantizedVector { + // Decoded arithmetic values, rounded to binary16 by encoding/loading. + // They are serialized as two bytes each, not as Java float32 fields. public float scale; // ||x_i - μ_i*||_2 / ||encoded body||_2 public float offset; // offset_i = - ||μ_i*||^2 public byte landmark; // c_i*, unsigned [0, C) @@ -1029,7 +1031,7 @@ public static int serializedSizeBytes(int quantizedDim) { } public static int serializedSizeBytes(int quantizedDim, int bitsPerDimension) { - return Short.BYTES + Short.BYTES + Byte.BYTES + return HEADER_BYTES + signWordsForDims(quantizedDim, bitsPerDimension) * Long.BYTES + bodyBytesForDims(quantizedDim, bitsPerDimension); } diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/FusedASHLayout.java b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/FusedASHLayout.java index d7bd8ad40..a101147fb 100644 --- a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/FusedASHLayout.java +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/FusedASHLayout.java @@ -49,7 +49,7 @@ private FusedASHLayout() {} public static final int SCALE_BYTES = Short.BYTES; public static final int OFFSET_BYTES = Short.BYTES; public static final int LANDMARK_BYTES = Byte.BYTES; - public static final int HEADER_BYTES_PER_LANE = SCALE_BYTES + OFFSET_BYTES + LANDMARK_BYTES; + public static final int HEADER_BYTES_PER_LANE = AsymmetricHashing.HEADER_BYTES; public static boolean supportsBitsPerDimension(int bitsPerDimension) { return bitsPerDimension == 1 || bitsPerDimension == 2 || bitsPerDimension == 4; diff --git a/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestASHKernelTuning.java b/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestASHKernelTuning.java new file mode 100644 index 000000000..fb6e36681 --- /dev/null +++ b/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestASHKernelTuning.java @@ -0,0 +1,101 @@ +/* + * 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 io.github.jbellis.jvector.vector.VectorizationProvider; +import org.junit.Test; +import org.junit.Assume; +import java.nio.charset.StandardCharsets; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.TimeUnit; +import static org.junit.Assert.*; + +/** Opt-in forked-JVM matrix, because tuning constants are initialized once per JVM. */ +public class TestASHKernelTuning { + @Test + public void allSettingsAndWidthsMatchScalarOracle() throws Exception { + Assume.assumeTrue(Boolean.getBoolean("jvector.test.ashTuningMatrix")); + Assume.assumeTrue(VectorizationProvider.getInstance().getVectorUtilSupport().supportsAshLutScoring()); + String architecture = System.getProperty("os.arch"); + int[] widths = architecture.equals("amd64") || architecture.equals("x86_64") + ? new int[]{16, 32, 64} : new int[]{0}; + for (int width : widths) { + for (int accumulators : new int[]{1, 2, 4, 8}) { + for (int unroll : new int[]{1, 2, 4}) { + var command = javaCommand(); + if (width != 0) command.add("-XX:MaxVectorSize=" + width); + command.add("-Djvector.test.requireAshSimd=true"); + for (String kernel : new String[]{"lut", "projection"}) { + command.add("-Djvector.ash." + kernel + ".accumulators=" + accumulators); + command.add("-Djvector.ash." + kernel + ".unroll=" + unroll); + } + command.add("org.junit.runner.JUnitCore"); + command.add(TestASHLutScoring.class.getName()); + command.add(TestASHScoringDispatch.class.getName()); + run(command, true, "width=" + width + ", accumulators=" + accumulators + ", unroll=" + unroll); + } + } + } + } + + @Test + public void invalidTuningFailsExplicitly() throws Exception { + Assume.assumeTrue(Boolean.getBoolean("jvector.test.ashTuningMatrix")); + Assume.assumeTrue(VectorizationProvider.getInstance().getVectorUtilSupport().supportsAshLutScoring()); + for (String name : new String[]{"lut.accumulators", "lut.unroll", "projection.accumulators", "projection.unroll"}) { + for (String invalid : new String[]{"0", "3", "16", "invalid"}) { + var command = javaCommand(); + command.add("-Djvector.ash." + name + "=" + invalid); + command.add(Probe.class.getName()); + run(command, false, "jvector.ash." + name); + } + } + } + + private static List javaCommand() { + return new ArrayList<>(List.of(Path.of(System.getProperty("java.home"), "bin", "java").toString(), + "-ea", "-Xmx256m", "--add-modules=jdk.incubator.vector", "--enable-native-access=ALL-UNNAMED", + "-cp", System.getProperty("java.class.path"))); + } + + private static void run(List command, boolean success, String label) throws Exception { + // Child output is small (JUnit summary and backend diagnostics); a temporary file + // prevents a full pipe from blocking the child while the parent waits with a timeout. + var output = java.nio.file.Files.createTempFile("ash-tuning-test-", ".log"); + try { + Process process = new ProcessBuilder(command).redirectErrorStream(true).redirectOutput(output.toFile()).start(); + if (!process.waitFor(60, TimeUnit.SECONDS)) { + process.destroyForcibly(); + fail("Timed out: " + label); + } + String text = java.nio.file.Files.readString(output, StandardCharsets.UTF_8); + if (success) assertEquals(label + "\n" + text, 0, process.exitValue()); + else { + assertNotEquals(label, 0, process.exitValue()); + assertTrue(text, text.contains(label)); + } + } finally { java.nio.file.Files.deleteIfExists(output); } + } + + public static class Probe { + public static void main(String[] ignored) { + System.out.println(VectorizationProvider.getInstance().getVectorUtilSupport().ashKernelDescription()); + } + } +} diff --git a/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestASHLutScoring.java b/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestASHLutScoring.java new file mode 100644 index 000000000..eeddc70f7 --- /dev/null +++ b/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestASHLutScoring.java @@ -0,0 +1,134 @@ +/* + * 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 io.github.jbellis.jvector.vector.ASHLutScoring; +import io.github.jbellis.jvector.vector.VectorizationProvider; +import org.junit.Test; +import java.util.Arrays; +import java.util.Random; +import static org.junit.Assert.*; + +/** Independent decoded-component oracle; covers padding, offsets and SIMD lane boundaries. */ +public class TestASHLutScoring { + @Test + public void allSerializedHeadersAreFortyBits() throws Exception { + assertEquals(40, AsymmetricHashing.HEADER_BITS); + assertEquals(5, AsymmetricHashing.HEADER_BYTES); + for (int bits = 1; bits <= 9; bits++) { + for (int dimensions : new int[]{1, 33, 64, 769}) { + var vector = AsymmetricHashing.QuantizedVector.createEmpty(dimensions, bits); + vector.scale = 1.0003f; + vector.offset = -0.33333f; + vector.landmark = (byte) 255; + int bodyBytes = vector.binaryVector.length * Long.BYTES + vector.extraBits.length; + try (var writer = io.github.jbellis.jvector.disk.ByteBufferIndexWriter.create(bodyBytes + 5, false)) { + vector.write(writer, dimensions, bits); + assertEquals(bodyBytes + 5, writer.position()); + assertEquals(bodyBytes + 5, AsymmetricHashing.QuantizedVector.serializedSizeBytes(dimensions, bits)); + var decoded = AsymmetricHashing.QuantizedVector.load( + new io.github.jbellis.jvector.disk.ByteBufferReader(writer.getWrittenData()), dimensions, bits); + assertEquals(255, decoded.landmark & 255); + assertEquals(vector.scale, decoded.scale, 0.001f); + assertEquals(vector.offset, decoded.offset, 0.001f); + } + if (FusedASHLayout.supportsBitsPerDimension(bits)) { + for (int blockSize : new int[]{8, 16, 32}) { + assertEquals(5 * blockSize, FusedASHLayout.blockHeaderBytes(blockSize)); + byte[] block = new byte[FusedASHLayout.blockBytes(dimensions, bits, blockSize)]; + FusedASHLayout.packQuantizedVector(block, 0, blockSize - 1, vector, dimensions, bits, blockSize); + assertEquals(255, FusedASHLayout.readLandmark(block, 0, blockSize - 1, dimensions, bits, blockSize)); + } + } + } + } + } + + @Test + public void packedBlocksMatchDecodedComponents() { + var backend = VectorizationProvider.getInstance().getVectorUtilSupport(); + if (Boolean.getBoolean("jvector.test.requireAshSimd")) { + assertTrue("Test must execute a SIMD backend: " + backend.getClass(), backend.supportsAshLutScoring()); + assertTrue(backend.supportsAshProjectionScoring()); + } + Random random = new Random(87342); + for (int bits : new int[]{1, 2, 4}) { + for (int d : new int[]{1, 2, 3, 4, 7, 15, 16, 17, 31, 32, 33, 63, 64, 65, 127, 129, 384, 768, 769}) { + int groups = FusedASHLayout.codeGroups(d, bits); + float[] q = new float[d]; + for (int i = 0; i < d; i++) q[i] = random.nextFloat() * 2 - 1; + float[] lut = new float[groups * 16]; + FusedASHLayout.buildQueryLut(q, d, bits, lut); + for (int size : new int[]{8, 16, 32}) { + int offset = 7; + byte[] packed = new byte[offset + FusedASHLayout.blockBodyBytes(d, bits, size)]; + random.nextBytes(packed); // Padding is deliberately nonzero. + float[] expected = new float[size]; + for (int lane = 0; lane < size; lane++) { + byte[] canonical = new byte[FusedASHLayout.canonicalCodeBytes(d, bits)]; + for (int g = 0; g < groups; g++) { + int nibble = (lane + g) & 15; // Exercise every code, including both signs. + FusedASHLayout.setPackedNibble(packed, offset, lane, g, size, nibble); + FusedASHLayout.setFlatNibble(canonical, g, nibble); + } + for (int i = 0; i < d; i++) { + int field = (canonical[i * bits / 8] & 255) >>> (i * bits % 8); + int sign = 1 << (bits - 1); + float magnitude = bits == 1 ? 1 : (field & (sign - 1)) + 0.5f; + expected[lane] += q[i] * ((field & sign) == 0 ? -magnitude : magnitude); + } + if (bits != 1) { + assertEquals("single bits=" + bits + " d=" + d, expected[lane], + backend.usesAshProjectionTuning() + ? backend.ashProjectionDotTuned(q, canonical, d, bits) + : backend.ashProjectionDot(q, canonical, d, bits), tolerance(expected[lane])); + } + } + for (int start = 0; start <= size; start++) { + for (int count = 0; count <= size - start; count++) { + float[] actual = new float[size + 4]; + float[] scalar = new float[size + 4]; + Arrays.fill(actual, Float.NaN); + backend.ashLutScore(packed, offset, groups, size, start, count, lut, actual, 2); + ASHLutScoring.score(packed, offset, groups, size, start, count, lut, scalar, 2); + assertTrue(Float.isNaN(actual[1])); + assertTrue(Float.isNaN(actual[count + 2])); + for (int i = 0; i < count; i++) { + String label = "bits=" + bits + " d=" + d + " size=" + size + " lane=" + (start + i); + assertEquals(label, expected[start + i], actual[2 + i], tolerance(expected[start + i])); + assertEquals(label, expected[start + i], scalar[2 + i], tolerance(expected[start + i])); + } + } + } + } + } + } + } + + @Test + public void rejectsInvalidBounds() { + var backend = VectorizationProvider.getInstance().getVectorUtilSupport(); + assertThrows(IndexOutOfBoundsException.class, + () -> backend.ashLutScore(new byte[8], 0, 2, 8, 7, 2, new float[32], new float[2], 0)); + assertThrows(IndexOutOfBoundsException.class, + () -> backend.ashLutScore(new byte[7], 0, 2, 8, 0, 1, new float[32], new float[1], 0)); + assertThrows(IndexOutOfBoundsException.class, + () -> backend.ashLutScore(new byte[8], 0, 2, 8, 0, 1, new float[31], new float[1], 0)); + } + + private static float tolerance(float value) { return 0.0003f + Math.abs(value) * 0.00002f; } +} diff --git a/jvector-twenty/src/main/java/io/github/jbellis/jvector/vector/PanamaASHKernels.java b/jvector-twenty/src/main/java/io/github/jbellis/jvector/vector/PanamaASHKernels.java new file mode 100644 index 000000000..53f72d991 --- /dev/null +++ b/jvector-twenty/src/main/java/io/github/jbellis/jvector/vector/PanamaASHKernels.java @@ -0,0 +1,239 @@ +/* + * 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 jdk.incubator.vector.*; +import java.util.Objects; + +/** + * Tunable ASH SIMD kernels. Settings are JVM-lifetime constants so HotSpot can remove + * unused registers and unrolled updates. Accumulators are named locals, never an array. + * + *

    Unroll is the number of SIMD code chunks grouped in an outer-loop iteration. + * Accumulators rotate independently of that grouping, so any supported combination + * uses all requested registers. A chunk is a nibble pair for LUT scoring or one + * SIMD-width of code bytes for projection scoring. HotSpot controls final machine + * unrolling; keeping a single vector-helper call site avoids heap allocation caused + * by exhausting the compiler's inlining budget.

    + * + *

    Optional VM settings (defaults shown): + * {@code jvector.ash.lut.accumulators=2}, {@code jvector.ash.lut.unroll=1}, + * {@code jvector.ash.projection.accumulators=1}, {@code jvector.ash.projection.unroll=1}. + * Accumulators accept 1/2/4/8; unroll accepts 1/2/4. Invalid values fail explicitly. + * These settings are independent of the one-bit {@code jvector.ash.512.accumulators}. + * Use a fresh JVM for each tuning trial.

    + */ +final class PanamaASHKernels { + private static final VectorSpecies F = FloatVector.SPECIES_PREFERRED; + private static final VectorSpecies I = VectorSpecies.of(int.class, F.vectorShape()); + private static final VectorSpecies B = F.length() <= 8 ? ByteVector.SPECIES_64 : ByteVector.SPECIES_128; + private static final int[] TWO_BIT_INDICES = queryIndices(4); + private static final int[] FOUR_BIT_INDICES = queryIndices(2); + + // Conservative defaults from JDK 23/AVX-512 sweeps and end-to-end validation. + // The original compact projection loop is retained for the default 1/1 setting. + private static final int LUT_ACC = setting("lut.accumulators", 2, 8); + private static final int LUT_UNROLL = setting("lut.unroll", 1, 4); + private static final int PROJECTION_ACC = setting("projection.accumulators", 1, 8); + private static final int PROJECTION_UNROLL = setting("projection.unroll", 1, 4); + + private PanamaASHKernels() {} + + private static int setting(String suffix, int fallback, int maximum) { + String name = "jvector.ash." + suffix; + String value = System.getProperty(name, Integer.toString(fallback)).trim(); + final int parsed; + try { parsed = Integer.parseInt(value); } + catch (NumberFormatException e) { throw new IllegalArgumentException(name + " must be a power of two in [1," + maximum + "]: " + value, e); } + if (parsed < 1 || parsed > maximum || (parsed & (parsed - 1)) != 0) { + throw new IllegalArgumentException(name + " must be a power of two in [1," + maximum + "]: " + value); + } + return parsed; + } + + static String description() { + return "LUT accumulators=" + LUT_ACC + ", unroll=" + LUT_UNROLL + + "; projection accumulators=" + PROJECTION_ACC + ", unroll=" + PROJECTION_UNROLL; + } + + static boolean supported() { return F.length() == 4 || F.length() == 8 || F.length() == 16; } + + static boolean projectionTuningEnabled() { return PROJECTION_ACC != 1 || PROJECTION_UNROLL != 1; } + + private static int[] queryIndices(int stride) { + int[] indices = new int[F.length()]; + for (int i = 0; i < indices.length; i++) indices[i] = i * stride; + return indices; + } + + static void lutScore(byte[] codes, int offset, int groups, int stride, + int lane, int count, float[] lut, float[] out, int outOffset) { + ASHLutScoring.checkBounds(codes, offset, groups, stride, lane, count, lut, out, outOffset); + int pairs = groups / 2; + for (int i = 0; i < count; i += F.length()) { + int active = Math.min(F.length(), count - i); + var byteMask = B.indexInRange(0, active); + FloatVector a0 = FloatVector.zero(F); + FloatVector a1 = FloatVector.zero(F); + FloatVector a2 = FloatVector.zero(F); + FloatVector a3 = FloatVector.zero(F); + FloatVector a4 = FloatVector.zero(F); + FloatVector a5 = FloatVector.zero(F); + FloatVector a6 = FloatVector.zero(F); + FloatVector a7 = FloatVector.zero(F); + int pair = 0; + while (pair < pairs) { + int end = Math.min(pairs, pair + LUT_UNROLL); + // One lookup call site avoids HotSpot's vector inlining budget limit. + // A constant-trip inner loop supplies the requested unroll grouping. + do { + FloatVector value = lutPair(codes, offset, pair, stride, lane + i, lut, byteMask); + FloatVector next = a0.add(value); + if (LUT_ACC == 1) { + a0 = next; + } + else if (LUT_ACC == 2) { + a0 = a1; + a1 = next; + } + else if (LUT_ACC == 4) { + a0 = a1; + a1 = a2; + a2 = a3; + a3 = next; + } + else if (LUT_ACC == 8) { + a0 = a1; + a1 = a2; + a2 = a3; + a3 = a4; + a4 = a5; + a5 = a6; + a6 = a7; + a7 = next; + } + } while (++pair < end); + } + if ((groups & 1) != 0) { + var packed = loadCodes(codes, offset + pairs * stride + lane + i, byteMask); + a0 = a0.add(lookup(lut, (groups - 1) * 16, packed.and(15))); + } + if (LUT_ACC > 1) a0 = a0.add(a1); + if (LUT_ACC > 2) a0 = a0.add(a2); + if (LUT_ACC > 3) a0 = a0.add(a3); + if (LUT_ACC > 4) a0 = a0.add(a4); + if (LUT_ACC > 5) a0 = a0.add(a5); + if (LUT_ACC > 6) a0 = a0.add(a6); + if (LUT_ACC > 7) a0 = a0.add(a7); + a0.intoArray(out, outOffset + i, F.indexInRange(0, active)); + } + } + + private static IntVector loadCodes(byte[] codes, int offset, VectorMask mask) { + return (IntVector) ByteVector.fromArray(B, codes, offset, mask).convertShape(VectorOperators.B2I, I, 0); + } + + private static FloatVector lutPair(byte[] codes, int offset, int pair, int stride, + int lane, float[] lut, VectorMask mask) { + var packed = loadCodes(codes, offset + pair * stride + lane, mask); + return lookup(lut, pair * 32, packed.and(15)) + .add(lookup(lut, pair * 32 + 16, packed.lanewise(VectorOperators.LSHR, 4).and(15))); + } + + private static FloatVector lookup(float[] lut, int offset, IntVector indices) { + int width = F.length(); + // Keep the shuffle and data in the same lane type. Casting an Int shuffle + // to a Float shuffle can materialize heap objects on JDK 23. Reinterpreting + // the LUT bits instead preserves register-only permutation. + var shuffle = indices.and(width - 1).toShuffle(); + IntVector result = FloatVector.fromArray(F, lut, offset).reinterpretAsInts().rearrange(shuffle); + for (int table = width; table < 16; table += width) { + var values = FloatVector.fromArray(F, lut, offset + table).reinterpretAsInts().rearrange(shuffle); + result = result.blend(values, indices.compare(VectorOperators.GE, table)); + } + return result.reinterpretAsFloats(); + } + + static float projectionDot(float[] query, byte[] code, int dimensions, int bits) { + if (bits != 2 && bits != 4) throw new IllegalArgumentException("Projection scoring requires 2 or 4 bits per dimension"); + Objects.checkFromIndexSize(0, dimensions, query.length); + int perByte = 8 / bits; + int bytes = dimensions / perByte + (dimensions % perByte == 0 ? 0 : 1); + Objects.checkFromIndexSize(0, bytes, code.length); + int sign = 1 << (bits - 1); + int[] indices = bits == 2 ? TWO_BIT_INDICES : FOUR_BIT_INDICES; + FloatVector a0 = FloatVector.zero(F); + FloatVector a1 = FloatVector.zero(F); + FloatVector a2 = FloatVector.zero(F); + FloatVector a3 = FloatVector.zero(F); + FloatVector a4 = FloatVector.zero(F); + FloatVector a5 = FloatVector.zero(F); + FloatVector a6 = FloatVector.zero(F); + FloatVector a7 = FloatVector.zero(F); + int base = 0; + while (base < bytes) { + int remainingChunks = (bytes - base - 1) / F.length() + 1; + int chunks = Math.min(PROJECTION_UNROLL, remainingChunks); + for (int u = 0; u < chunks; u++, base += F.length()) { + var packed = loadCodes(code, base, B.indexInRange(0, Math.min(F.length(), bytes - base))); + // Reuse each widened code byte across its 2/4 projected dimensions. + // Keep the decode, query gather and accumulation in one compilation unit. + for (int slot = 0; slot < perByte; slot++) { + int remaining = dimensions - base * perByte - slot; + if (remaining <= 0) break; + int active = Math.min(F.length(), (remaining - 1) / perByte + 1); + var fields = packed.lanewise(VectorOperators.LSHR, slot * bits); + var magnitude = ((FloatVector) fields.and(sign - 1).convertShape(VectorOperators.I2F, F, 0)).add(0.5f); + var values = magnitude.blend(magnitude.neg(), fields.and(sign).compare(VectorOperators.EQ, 0).cast(F)); + var q = FloatVector.fromArray(F, query, base * perByte + slot, indices, 0, F.indexInRange(0, active)); + FloatVector next = a0.add(q.mul(values)); + if (PROJECTION_ACC == 1) { + a0 = next; + } + else if (PROJECTION_ACC == 2) { + a0 = a1; + a1 = next; + } + else if (PROJECTION_ACC == 4) { + a0 = a1; + a1 = a2; + a2 = a3; + a3 = next; + } + else if (PROJECTION_ACC == 8) { + a0 = a1; + a1 = a2; + a2 = a3; + a3 = a4; + a4 = a5; + a5 = a6; + a6 = a7; + a7 = next; + } + } + } + } + if (PROJECTION_ACC > 1) a0 = a0.add(a1); + if (PROJECTION_ACC > 2) a0 = a0.add(a2); + if (PROJECTION_ACC > 3) a0 = a0.add(a3); + if (PROJECTION_ACC > 4) a0 = a0.add(a4); + if (PROJECTION_ACC > 5) a0 = a0.add(a5); + if (PROJECTION_ACC > 6) a0 = a0.add(a6); + if (PROJECTION_ACC > 7) a0 = a0.add(a7); + return a0.reduceLanes(VectorOperators.ADD); + } +} From 019177dd422eabfe27c7dfeb78eacdd63abc7ced Mon Sep 17 00:00:00 2001 From: Ted Willke Date: Fri, 11 Sep 2026 18:57:14 +0000 Subject: [PATCH 047/243] Improve ASH benchmark warmup and dispatch reporting, correct similarity-error measurement, and add kernel microbenchmarks. --- .../jbellis/jvector/example/DistancesASH.java | 165 +++++++++++++----- .../jvector/quantization/BenchASHKernels.java | 130 ++++++++++++++ 2 files changed, 249 insertions(+), 46 deletions(-) create mode 100644 jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/BenchASHKernels.java diff --git a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/DistancesASH.java b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/DistancesASH.java index d323b5145..2d8736477 100644 --- a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/DistancesASH.java +++ b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/DistancesASH.java @@ -41,17 +41,20 @@ public class DistancesASH { static final String singleMode = System.getProperty("jvector.ash.singleKernel", "auto").toLowerCase(); private static void printScorerInfo(String label, Object scorer) { - boolean maskedLoad = + var backend = io.github.jbellis.jvector.vector.VectorizationProvider.getInstance() - .getVectorUtilSupport() - .supportsAshMaskedLoad(); + .getVectorUtilSupport(); System.out.println( "\t[" + label + "] scorer implementation = " - + scorer.getClass().getName() + + (scorer instanceof ASHBlockScorer + ? ((ASHBlockScorer) scorer).description() : scorer.getClass().getName()) + " (singleKernel=" + singleMode + ", blockKernel=" + blockMode - + ", supportsAshMaskedLoad=" + maskedLoad + + ", supportsAshMaskedLoad=" + backend.supportsAshMaskedLoad() + + ", supportsAshLutScoring=" + backend.supportsAshLutScoring() + + ", supportsAshProjectionScoring=" + backend.supportsAshProjectionScoring() + + ", " + backend.ashKernelDescription() + ")" ); } @@ -61,6 +64,42 @@ private static void logProgress(String msg) { System.out.flush(); } + /** Warm each measured path independently; recall only warms single-vector scoring. */ + private static void warmupASH(ASHVectors vectors, List> queries, + ForkJoinPool executor, int blockSize) { + int requested = Integer.parseInt(System.getProperty("jvector.bench.scoringWarmupQueries", "128")); + if (requested < 0) throw new IllegalArgumentException("scoringWarmupQueries must be nonnegative"); + int queryCount = Math.min(requested, queries.size()); + if (queryCount == 0) return; + int workers = Math.min(executor.getParallelism(), queryCount); + List> tasks = new ArrayList<>(); + for (int worker = 0; worker < workers; worker++) { + final int first = worker; + tasks.add(executor.submit(() -> { + double checksum = 0; + float[] scores = blockSize == 0 ? null : new float[blockSize]; + for (int q = first; q < queryCount; q += workers) { + if (blockSize == 0) { + var scorer = vectors.scoreFunctionFor(queries.get(q), VectorSimilarityFunction.DOT_PRODUCT); + for (int i = 0; i < vectors.count(); i++) checksum += scorer.similarityTo(i); + } else { + var scorer = vectors.blockScorerFor(queries.get(q), VectorSimilarityFunction.DOT_PRODUCT, blockSize); + for (int start = 0; start < vectors.count(); start += blockSize) { + int count = Math.min(blockSize, vectors.count() - start); + scorer.scoreRange(start, count, scores); + for (int i = 0; i < count; i++) checksum += scores[i]; + } + } + } + return checksum; + })); + } + double checksum = 0; + for (var task : tasks) checksum += task.join(); + System.out.printf(java.util.Locale.ROOT, "\t%s warmup: %d queries, checksum=%.6f (excluded from timing)%n", + blockSize == 0 ? "Single" : "Block", queryCount, checksum); + } + private static int parseOptimizerFromProperty() { String opt = System.getProperty("jvector.ash.optimizer", "random").trim().toLowerCase(); @@ -94,24 +133,34 @@ public static void testASHEncodings(String filenameBase, String filenameQueries, final boolean RUN_ACCURACY_CHECK = Boolean.parseBoolean(System.getProperty("jvector.bench.accuracy", "false")); - final boolean RUN_SCALAR_SCORING = - Boolean.parseBoolean(System.getProperty("jvector.bench.scalar-scoring", "true")); + final boolean RUN_SINGLE_SCORING = Boolean.parseBoolean(System.getProperty( + "jvector.bench.single-scoring", System.getProperty("jvector.bench.scalar-scoring", "true"))); final boolean RUN_FLOAT_SCORING = Boolean.parseBoolean(System.getProperty("jvector.bench.float-scoring", "false")); // ASH header bits - final int HEADER_BITS = 40; + final int HEADER_BITS = AsymmetricHashing.HEADER_BITS; + System.out.println("\tASH header: " + HEADER_BITS + " bits (scale=16, offset=16, landmark=8)"); // Block sizes to benchmark - final int[] BLOCK_SIZES = {32}; // 16, 32, and/or 64 + final int[] BLOCK_SIZES = {32}; // Supported fused/multi-bit block capacities: 8, 16, 32. // How many ASH landmarks to use, C = [1, 64] final int landmarkCount = 1; List> vectors = SiftLoader.readFvecs(filenameBase); List> queries = SiftLoader.readFvecs(filenameQueries); - List> groundTruth = SiftLoader.readIvecs(filenameGT); + final List> groundTruth; + if (RUN_RECALL_CHECK) { + if (filenameGT == null || filenameGT.isBlank()) { + throw new IllegalArgumentException( + "Recall requires a ground-truth filename"); + } + groundTruth = SiftLoader.readIvecs(filenameGT); + } else { + groundTruth = List.of(); + } // ASH normalization policy: // @@ -124,22 +173,35 @@ public static void testASHEncodings(String filenameBase, String filenameQueries, // - No other normalization steps are applied. int dimension = vectors.get(0).length(); - int encodedBits = 1536; // (dimension / 4) + HEADER_BITS; - // Payload must be 64-bit aligned for SIMD - int payloadBits = encodedBits - HEADER_BITS; -// if ((payloadBits & 63) != 0) { -// throw new IllegalArgumentException( -// "ASH payloadBits must be 64-bit aligned for SIMD. " + -// "Got payloadBits=" + payloadBits + -// " (encodedBits=" + encodedBits + -// ", HEADER_BITS=" + HEADER_BITS + ")" -// ); -// } + + final int bitsPerDimension = + Integer.getInteger("jvector.ash.bitsPerDimension", 4); + + final int quantizedDimensions = + Integer.getInteger("jvector.ash.quantizedDimensions", dimension / 2); + + if (bitsPerDimension != 1 + && bitsPerDimension != 2 + && bitsPerDimension != 4) { + throw new IllegalArgumentException( + "bitsPerDimension must be 1, 2, or 4: " + bitsPerDimension); + } + + if (quantizedDimensions <= 0 || quantizedDimensions > dimension) { + throw new IllegalArgumentException( + "Invalid quantizedDimensions=" + quantizedDimensions + + " for original dimension=" + dimension); + } + + int payloadBits = Math.multiplyExact(quantizedDimensions, bitsPerDimension); + int encodedBits = Math.addExact(HEADER_BITS, payloadBits); System.out.println( - "\toriginalDim = " + dimension + - ", encodedBits = " + encodedBits - ); + "\toriginalDim=" + dimension + + ", quantizedDim=" + quantizedDimensions + + ", bitsPerDimension=" + bitsPerDimension + + ", payloadBits=" + payloadBits + + ", encodedBits=" + encodedBits); final List> finalQueries = queries; final List> finalVectors = vectors; @@ -160,7 +222,8 @@ public static void testASHEncodings(String filenameBase, String filenameQueries, logProgress("\t[stage] ASH initialize: starting (centroids + training)"); long initStart = System.nanoTime(); - var ash = AsymmetricHashing.initialize(ravv, optimizer, encodedBits, landmarkCount); + var ash = AsymmetricHashing.initialize( + ravv, optimizer, encodedBits, landmarkCount, bitsPerDimension); long initEnd = System.nanoTime(); logProgress("\t[stage] ASH initialize: done in " + (initEnd - initStart) / 1e9 + " seconds"); @@ -264,19 +327,33 @@ public static void testASHEncodings(String filenameBase, String filenameQueries, // ------------------------------------------------------------------ // Shared parallelization setup (executor-honoring) // ------------------------------------------------------------------ -// ForkJoinPool simdExecutor = PhysicalCoreExecutor.pool(); // For production - ForkJoinPool simdExecutor = new ForkJoinPool(180); // For profiling + // Default to one scoring thread for kernel/accumulator comparisons. + // For aggregate throughput, set -Djvector.bench.scoringThreads=N, + // where N is the number of physical cores available to this process. + // This controls accuracy, recall, and scoring tasks, not encodeAll. + final int scoringThreads = + Integer.parseInt( + System.getProperty("jvector.bench.scoringThreads", "1").trim()); + + if (scoringThreads <= 0) { + throw new IllegalArgumentException( + "scoringThreads must be positive: " + scoringThreads); + } + + final ForkJoinPool simdExecutor = new ForkJoinPool(scoringThreads); int parallelism = simdExecutor.getParallelism(); int chunkSize = Math.max(1, (queries.size() + parallelism - 1) / parallelism); + System.out.println("\tScoring parallelism = " + parallelism); + // ================================================================== // [1] Accuracy run (NOT timed) // ================================================================== - if(RUN_ACCURACY_CHECK) { + if (RUN_ACCURACY_CHECK) { List> errorTasks = new ArrayList<>(); - for (int start = 0; start < vectors.size(); start += chunkSize) { + for (int start = 0; start < queries.size(); start += chunkSize) { final int s = start; final int e = Math.min(start + chunkSize, queries.size()); @@ -293,10 +370,10 @@ public static void testASHEncodings(String filenameBase, String filenameQueries, final int baseOrd = (newToOldFinal == null) ? j : newToOldFinal[j]; VectorFloat v = finalVectors.get(baseOrd); - float trueDot = VectorUtil.dotProduct(q, v); - float approxDot = f.similarityTo(j); + float trueSimilarity = VectorSimilarityFunction.DOT_PRODUCT.compare(q, v); + float approxSimilarity = f.similarityTo(j); - localError += Math.abs(approxDot - trueDot); + localError += Math.abs(approxSimilarity - trueSimilarity); localCount++; } @@ -315,7 +392,7 @@ public static void testASHEncodings(String filenameBase, String filenameQueries, } distanceError /= count; - System.out.println("\tAverage absolute dot-product error = " + distanceError); + System.out.println("\tAverage absolute DOT_PRODUCT similarity error = " + distanceError); } // ================================================================== @@ -389,9 +466,10 @@ public static void testASHEncodings(String filenameBase, String filenameQueries, } // ================================================================== - // [2] Scalar ASH scoring timing (reference) + // [2] Single-vector ASH scoring, using the selected singleKernel // ================================================================== - if (RUN_SCALAR_SCORING) { + if (RUN_SINGLE_SCORING) { + warmupASH(ashVectorsFinal, finalQueries, simdExecutor, 0); List> ashTasks = new ArrayList<>(); long ashStart = System.nanoTime(); @@ -506,6 +584,7 @@ public static void testASHEncodings(String filenameBase, String filenameQueries, printScorerInfo(kernelMode + " (blockSize=" + blockSize + ")", scorer); } + warmupASH(ashVectorsFinal, finalQueries, simdExecutor, blockSize); List> blockTasks = new ArrayList<>(); long blockStart = System.nanoTime(); @@ -534,17 +613,11 @@ public static void testASHEncodings(String filenameBase, String filenameQueries, j += blockSize; } - // Tail uses scalar per-vector scorer (correctness-preserving) + // Exercise the selected block kernel for partial batches as well. if (j < vectors.size()) { - ScoreFunction.ApproximateScoreFunction f = - ashVecsFinal.scoreFunctionFor( - finalQueries.get(qi), - VectorSimilarityFunction.DOT_PRODUCT - ); - - for (; j < vectors.size(); j++) { - localSum += f.similarityTo(j); - } + int count = vectors.size() - j; + scorer.scoreRange(j, count, scores); + for (int k = 0; k < count; k++) localSum += scores[k]; } } @@ -685,4 +758,4 @@ public static void main(String[] args) throws IOException { // runCap6m(); // runCohere10m(); } -} \ No newline at end of file +} diff --git a/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/BenchASHKernels.java b/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/BenchASHKernels.java new file mode 100644 index 000000000..842fe799e --- /dev/null +++ b/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/BenchASHKernels.java @@ -0,0 +1,130 @@ +/* + * 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 io.github.jbellis.jvector.vector.VectorUtilSupport; +import io.github.jbellis.jvector.vector.VectorizationProvider; +import org.openjdk.jmh.annotations.*; +import java.util.Random; +import java.util.concurrent.TimeUnit; + +/** + * Kernel-only comparison: identical codes/query, 32 scores per invocation, no training + * or query preparation in timed work. Both paths traverse many blocks in permuted order. + * Each representation is at least workingSetMiB; choose a size larger than the machine's + * last-level cache for a cold-data experiment. Smaller sets measure cache-resident work. + * + *

    Use JMH forks (never -f 0): tuning is fixed when the backend is initialized. + * The default is one worker; -t N measures aggregate throughput over shared read-only + * codes with independent worker cursors and output buffers.

    + */ +@State(Scope.Benchmark) +@BenchmarkMode(Mode.Throughput) +@OutputTimeUnit(TimeUnit.SECONDS) +@Warmup(iterations = 3, time = 1) +@Measurement(iterations = 5, time = 1) +@Fork(value = 2, jvmArgsAppend = {"--add-modules=jdk.incubator.vector", "--enable-native-access=ALL-UNNAMED", "-Xmx8g"}) +@Threads(1) +public class BenchASHKernels { + @Param({"64", "384", "768"}) public int dimensions; + @Param({"2", "4"}) public int bits; + @Param({"1", "2", "4", "8"}) public int accumulators; + @Param({"1", "2", "4"}) public int unroll; + @Param({"1024"}) public int workingSetMiB; + + private VectorUtilSupport backend; + private byte[][] canonical; + private byte[][] packed; + private float[] query, lut; + private int groups; + private boolean tunedProjection; + + @State(Scope.Thread) + public static class Worker { + int cursor; + final float[] scores = new float[32]; + + @Setup(Level.Trial) + public void setup(org.openjdk.jmh.infra.ThreadParams thread) { + cursor = thread.getThreadIndex() * 15485863; + } + } + + @Setup(Level.Trial) + public void setup() { + System.setProperty("jvector.ash.lut.accumulators", Integer.toString(accumulators)); + System.setProperty("jvector.ash.lut.unroll", Integer.toString(unroll)); + System.setProperty("jvector.ash.projection.accumulators", Integer.toString(accumulators)); + System.setProperty("jvector.ash.projection.unroll", Integer.toString(unroll)); + backend = VectorizationProvider.getInstance().getVectorUtilSupport(); + tunedProjection = backend.usesAshProjectionTuning(); + if (!backend.supportsAshLutScoring() || !backend.supportsAshProjectionScoring()) { + throw new IllegalStateException("This benchmark requires the Panama ASH SIMD backend"); + } + if (!backend.ashKernelDescription().contains("LUT accumulators=" + accumulators + ", unroll=" + unroll)) { + throw new IllegalStateException("Tuning already initialized: run each parameter combination in its own JMH fork"); + } + if (workingSetMiB <= 0) throw new IllegalArgumentException("workingSetMiB must be positive"); + groups = FusedASHLayout.codeGroups(dimensions, bits); + int bytes = FusedASHLayout.canonicalCodeBytes(dimensions, bits); + long target = (long) workingSetMiB * 1024 * 1024; + int blocks = 1; + while ((long) blocks * bytes * 32 < target) blocks = Math.multiplyExact(blocks, 2); + canonical = new byte[Math.multiplyExact(blocks, 32)][bytes]; + packed = new byte[blocks][Math.multiplyExact(bytes, 32)]; + Random random = new Random(89712); + for (int b = 0; b < blocks; b++) { + for (int lane = 0; lane < 32; lane++) { + byte[] code = canonical[b * 32 + lane]; + random.nextBytes(code); + for (int i = 0; i < bytes; i++) packed[b][i * 32 + lane] = code[i]; + } + } + query = new float[dimensions]; + for (int i = 0; i < dimensions; i++) query[i] = random.nextFloat() - 0.5f; + lut = new float[Math.multiplyExact(groups, 16)]; + FusedASHLayout.buildQueryLut(query, dimensions, bits, lut); + System.out.println(backend.ashKernelDescription() + "; code bytes per representation=" + (long) blocks * bytes * 32); + } + + private int nextBlock(Worker worker) { + worker.cursor = (worker.cursor + 104729) & (packed.length - 1); + return worker.cursor; + } + + @Benchmark + @OperationsPerInvocation(32) + public float single(Worker worker) { + int base = nextBlock(worker) * 32; + float sum = 0; + for (int i = 0; i < 32; i++) { + sum += tunedProjection + ? backend.ashProjectionDotTuned(query, canonical[base + i], dimensions, bits) + : backend.ashProjectionDot(query, canonical[base + i], dimensions, bits); + } + return sum; + } + + @Benchmark + @OperationsPerInvocation(32) + public float block(Worker worker) { + backend.ashLutScore(packed[nextBlock(worker)], 0, groups, 32, 0, 32, lut, worker.scores, 0); + float sum = 0; + for (float score : worker.scores) sum += score; + return sum; + } +} From a1b4b9d321364433ae371008a917ec472afd0228 Mon Sep 17 00:00:00 2001 From: Ted Willke Date: Fri, 11 Sep 2026 18:58:20 +0000 Subject: [PATCH 048/243] Add ASH scoring, fused dispatch, and negative-entry graph search regression tests. --- .../quantization/TestASHGraphSearch.java | 133 ++++++ .../quantization/TestASHScoringDispatch.java | 201 ++++++++ .../TestASHStandaloneScoring.java | 326 +++++++++++++ .../TestASHStandaloneVsFusedScoring.java | 433 ++++++++++++++++++ 4 files changed, 1093 insertions(+) create mode 100644 jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestASHGraphSearch.java create mode 100644 jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestASHScoringDispatch.java create mode 100644 jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestASHStandaloneScoring.java create mode 100644 jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestASHStandaloneVsFusedScoring.java diff --git a/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestASHGraphSearch.java b/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestASHGraphSearch.java new file mode 100644 index 000000000..44d817f3b --- /dev/null +++ b/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestASHGraphSearch.java @@ -0,0 +1,133 @@ +/* + * 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 io.github.jbellis.jvector.TestUtil; +import io.github.jbellis.jvector.disk.SimpleMappedReader; +import io.github.jbellis.jvector.graph.GraphSearcher; +import io.github.jbellis.jvector.graph.MockVectorValues; +import io.github.jbellis.jvector.graph.disk.OnDiskGraphIndex; +import io.github.jbellis.jvector.graph.disk.OnDiskGraphIndexWriter; +import io.github.jbellis.jvector.graph.disk.feature.*; +import io.github.jbellis.jvector.graph.similarity.DefaultSearchScoreProvider; +import io.github.jbellis.jvector.util.Bits; +import io.github.jbellis.jvector.vector.VectorSimilarityFunction; +import io.github.jbellis.jvector.vector.VectorUtil; +import io.github.jbellis.jvector.vector.VectorizationProvider; +import io.github.jbellis.jvector.vector.types.VectorFloat; +import org.junit.Test; +import java.nio.file.Files; +import java.util.EnumMap; +import java.util.List; +import java.util.Random; +import java.util.concurrent.ForkJoinPool; +import java.util.function.IntFunction; +import static org.junit.Assert.*; + +/** Search-level regression: raw negative dot products must not strand hierarchy descent. */ +public class TestASHGraphSearch { + @Test + public void negativeEntryReachesBaseLayerWithStandaloneAndFusedScoring() throws Exception { + String single = System.getProperty("jvector.ash.singleKernel"); + String block = System.getProperty("jvector.ash.blockKernel"); + try { + var provider = VectorizationProvider.getInstance(); + var vts = provider.getVectorTypeSupport(); + VectorFloat[] input = new VectorFloat[17]; + Random random = new Random(92201); + for (int n = 0; n < input.length; n++) { + input[n] = vts.createFloatVector(64); + for (int d = 0; d < 64; d++) input[n].set(d, random.nextFloat() - 0.5f); + VectorUtil.l2normalize(input[n]); + } + var query = input[0].copy(); + for (int d = 0; d < query.length(); d++) query.set(d, -query.get(d)); + var values = MockVectorValues.fromValues(input); + var graph = new TestUtil.FullyConnectedGraphIndex(0, List.of(input.length, 1)) { + @Override public List maxDegrees() { return List.of(16, 0); } + @Override public int getDimension() { return 64; } + }; + boolean simd = provider.getVectorUtilSupport().supportsAshLutScoring(); + for (int bits : new int[]{1, 2, 4}) { + var ash = AsymmetricHashing.initialize(values, AsymmetricHashing.RANDOM, + AsymmetricHashing.HEADER_BITS + 33 * bits, 1, bits); + var encoded = ash.encodeAll(values, ForkJoinPool.commonPool()); + var path = Files.createTempFile("ash-negative-entry-", ".index"); + try { + var suppliers = new EnumMap>(FeatureId.class); + try (var view = graph.getView(); var writer = new OnDiskGraphIndexWriter.Builder(graph, path) + .with(new FusedASH(graph.maxDegree(), ash, 32)) + .with(new InlineVectors(64)).build()) { + suppliers.put(FeatureId.FUSED_ASH, n -> new FusedASH.State(view, encoded, n)); + suppliers.put(FeatureId.INLINE_VECTORS, n -> new InlineVectors.State(input[n])); + writer.write(suppliers); + } + try (var reader = new SimpleMappedReader.Supplier(path); + var disk = OnDiskGraphIndex.load(reader, 0)) { + for (String mode : simd ? new String[]{"scalar", "auto", "simd"} : new String[]{"scalar", "auto"}) { + System.setProperty("jvector.ash.singleKernel", mode); + System.setProperty("jvector.ash.blockKernel", mode); + var raw = new ASHScorer(ash).scoreFunctionFor(query, VectorSimilarityFunction.DOT_PRODUCT); + assertTrue("Regression requires a negative entry score", raw.similarityTo(encoded.get(0)) < 0f); + var standalone = encoded.precomputedScoreFunctionFor(query, VectorSimilarityFunction.DOT_PRODUCT); + try (var searcher = new GraphSearcher(graph)) { + searcher.usePruning(false); + var scores = new DefaultSearchScoreProvider(standalone, + n -> VectorSimilarityFunction.DOT_PRODUCT.compare(query, input[n])); + assertEquals(10, searcher.search(scores, 10, 10, 0f, 0f, Bits.ALL).getNodes().length); + } + for (int n = 0; n < input.length; n++) { + assertEquals(Math.max(0f, (1f + raw.similarityTo(encoded.get(n))) / 2f), + standalone.similarityTo(n), 0.00005f); + } + try (var searcher = new GraphSearcher(disk)) { + searcher.usePruning(false); + var view = (OnDiskGraphIndex.View) searcher.getView(); + var fused = view.approximateScoreFunctionFor(query, VectorSimilarityFunction.DOT_PRODUCT); + assertEquals(standalone.similarityTo(0), fused.similarityTo(0), 0.00005f); + var scores = new DefaultSearchScoreProvider(fused, + view.rerankerFor(query, VectorSimilarityFunction.DOT_PRODUCT)); + assertEquals(10, searcher.search(scores, 10, 10, 0f, 0f, Bits.ALL).getNodes().length); + fused.enableSimilarityToNeighbors(0); + var neighbors = view.getNeighborsIterator(0, 0); + for (int lane = 0; neighbors.hasNext(); lane++) { + assertEquals(standalone.similarityTo(neighbors.nextInt()), + fused.similarityToNeighbor(0, lane), 0.002f); + } + } + } + } + } finally { Files.deleteIfExists(path); } + } + } finally { + restore("jvector.ash.singleKernel", single); + restore("jvector.ash.blockKernel", block); + } + } + + @Test + public void dotProductSimilarityHasNonnegativeLowerBound() { + assertEquals(0f, ASHScorer.toSimilarity(-1.25f), 0f); + assertEquals(0f, ASHScorer.toSimilarity(-1f), 0f); + assertEquals(0.375f, ASHScorer.toSimilarity(-0.25f), 0f); + assertEquals(0.5f, ASHScorer.toSimilarity(0f), 0f); + assertEquals(1f, ASHScorer.toSimilarity(1f), 0f); + } + + private static void restore(String name, String value) { + if (value == null) System.clearProperty(name); else System.setProperty(name, value); + } +} diff --git a/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestASHScoringDispatch.java b/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestASHScoringDispatch.java new file mode 100644 index 000000000..4821cb2bd --- /dev/null +++ b/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestASHScoringDispatch.java @@ -0,0 +1,201 @@ +/* + * 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 io.github.jbellis.jvector.graph.MockVectorValues; +import io.github.jbellis.jvector.TestUtil; +import io.github.jbellis.jvector.disk.ByteBufferIndexWriter; +import io.github.jbellis.jvector.disk.ByteBufferReader; +import io.github.jbellis.jvector.disk.SimpleMappedReader; +import io.github.jbellis.jvector.graph.disk.OnDiskGraphIndex; +import io.github.jbellis.jvector.graph.disk.OnDiskGraphIndexWriter; +import io.github.jbellis.jvector.graph.disk.feature.Feature; +import io.github.jbellis.jvector.graph.disk.feature.FeatureId; +import io.github.jbellis.jvector.graph.disk.feature.FusedASH; +import io.github.jbellis.jvector.graph.disk.feature.InlineVectors; +import io.github.jbellis.jvector.graph.disk.feature.FusedFeature; +import io.github.jbellis.jvector.vector.VectorSimilarityFunction; +import io.github.jbellis.jvector.vector.VectorUtil; +import io.github.jbellis.jvector.vector.VectorizationProvider; +import io.github.jbellis.jvector.vector.types.VectorFloat; +import org.agrona.collections.Int2ObjectHashMap; +import org.junit.Test; +import java.util.Random; +import java.util.EnumMap; +import java.util.function.IntFunction; +import java.nio.file.Files; +import java.util.concurrent.ForkJoinPool; +import static org.junit.Assert.*; + +/** Exercises the production factories and decoder, not only their layout helpers. */ +public class TestASHScoringDispatch { + private static final String BLOCK = "jvector.ash.blockKernel"; + private static final String SINGLE = "jvector.ash.singleKernel"; + + @Test + public void standaloneAndFusedUseSelectedKernels() throws Exception { + String oldBlock = System.getProperty(BLOCK), oldSingle = System.getProperty(SINGLE); + try { + var provider = VectorizationProvider.getInstance(); + boolean simd = provider.getVectorUtilSupport().supportsAshLutScoring(); + VectorFloat[] input = new VectorFloat[97]; + Random random = new Random(6234); + for (int n = 0; n < input.length; n++) { + input[n] = provider.getVectorTypeSupport().createFloatVector(64); + for (int i = 0; i < 64; i++) input[n].set(i, random.nextFloat() - 0.5f); + VectorUtil.l2normalize(input[n]); + } + var values = MockVectorValues.fromValues(input); + for (int bits : new int[]{1, 2, 4}) { + var ash = AsymmetricHashing.initialize(values, AsymmetricHashing.RANDOM, + AsymmetricHashing.HEADER_BITS + 33 * bits, 2, bits); + var encoded = ash.encodeAll(values, ForkJoinPool.commonPool()); + // Reload canonical metadata/body bytes before exercising the new factories. + try (var writer = ByteBufferIndexWriter.create(1024 * 1024, false)) { + encoded.write(writer, OnDiskGraphIndex.CURRENT_VERSION); + encoded = ASHVectors.load(new ByteBufferReader(writer.getWrittenData())); + } + checkDiskGraph(encoded, input); + for (int q : new int[]{3, 68}) { + System.setProperty(SINGLE, "scalar"); + var reference = new ASHScorer(ash).scoreFunctionFor(input[q], VectorSimilarityFunction.DOT_PRODUCT); + float[] expected = new float[input.length]; + for (int i = 0; i < expected.length; i++) { + expected[i] = Math.max(0f, (1f + reference.similarityTo(encoded.get(i))) / 2f); + } + for (String mode : simd ? new String[]{"scalar", "auto", "simd"} : new String[]{"scalar", "auto"}) { + System.setProperty(BLOCK, mode); + System.setProperty(SINGLE, mode); + var single = encoded.scoreFunctionFor(input[q], VectorSimilarityFunction.DOT_PRODUCT); + for (int i = 0; i < expected.length; i++) assertEquals(expected[i], single.similarityTo(i), 0.00005f); + for (int size : new int[]{8, 16, 32}) { + var block = encoded.blockScorerFor(input[q], VectorSimilarityFunction.DOT_PRODUCT, size); + if (bits != 1) assertTrue(block.toString(), block.toString().contains( + mode.equals("scalar") || !simd ? "scalar ASH LUT" : "SIMD ASH LUT")); + for (int start : new int[]{0, 1, 7, 15, 31, 65, 96, 97}) { + int count = Math.min(32, input.length - start); + float[] actual = new float[count]; + block.scoreRange(start, count, actual); + for (int i = 0; i < count; i++) assertEquals(expected[start + i], actual[i], 0.00005f); + } + checkFused(ash, encoded, input[q], expected, size); + } + // The two-argument overload deliberately remains an independent scalar oracle. + float[] referenceBlock = new float[32]; + encoded.blockScorerFor(input[q], VectorSimilarityFunction.DOT_PRODUCT).scoreRange(0, 32, referenceBlock); + for (int i = 0; i < 32; i++) assertEquals(expected[i], referenceBlock[i], 0.00005f); + } + } + } + } finally { + restore(BLOCK, oldBlock); + restore(SINGLE, oldSingle); + } + } + + private static void checkDiskGraph(ASHVectors encoded, VectorFloat[] input) throws Exception { + var graph = new TestUtil.RandomlyConnectedGraphIndex(input.length, 37, new Random(9182)); + var path = Files.createTempFile("ash-simd-roundtrip-", ".index"); + try { + var builder = new OnDiskGraphIndexWriter.Builder(graph, path) + .with(new FusedASH(37, encoded.getCompressor(), 32)) + .with(new InlineVectors(input[0].length())); + var suppliers = new EnumMap>(FeatureId.class); + try (var graphView = graph.getView(); var writer = builder.build()) { + suppliers.put(FeatureId.FUSED_ASH, ordinal -> new FusedASH.State(graphView, encoded, ordinal)); + suppliers.put(FeatureId.INLINE_VECTORS, ordinal -> new InlineVectors.State(input[ordinal])); + writer.write(suppliers); + } + try (var reader = new SimpleMappedReader.Supplier(path); + var loaded = OnDiskGraphIndex.load(reader, 0); + var view = loaded.getView()) { + var reference = encoded.scoreFunctionFor(input[3], VectorSimilarityFunction.DOT_PRODUCT); + var fused = view.approximateScoreFunctionFor(input[3], VectorSimilarityFunction.DOT_PRODUCT); + assertTrue(fused.toString(), fused instanceof FusedASHDecoder); + for (int origin : new int[]{0, 7, 63, 96}) { + fused.enableSimilarityToNeighbors(origin); + var neighbors = view.getNeighborsIterator(0, origin); + for (int lane = 0; neighbors.hasNext(); lane++) { + assertEquals(reference.similarityTo(neighbors.nextInt()), + fused.similarityToNeighbor(origin, lane), 0.002f); + } + } + } + } finally { Files.deleteIfExists(path); } + } + + private static void checkFused(AsymmetricHashing ash, ASHVectors vectors, + VectorFloat query, float[] expected, int size) { + int degree = 37; + byte[] bytes = new byte[FusedASHLayout.featureSize(degree, ash.quantizedDim, ash.bitsPerDimension, size)]; + for (int i = 0; i < degree; i++) { + int offset = FusedASHLayout.blockOffset(i / size, ash.quantizedDim, ash.bitsPerDimension, size); + var v = vectors.get(i); + FusedASHLayout.packQuantizedVector(bytes, offset, i % size, v, ash.quantizedDim, ash.bitsPerDimension, size); + if (ash.bitsPerDimension == 1) { + float landmarkDot = 0; + for (int d = 0; d < ash.quantizedDim; d++) landmarkDot += ash.landmarkProj[v.landmark & 255][d] + * (((v.binaryVector[d >>> 6] >>> (d & 63)) & 1) == 0 ? -1 : 1); + FusedASHLayout.writeLaneHeader(bytes, offset, i % size, ash.quantizedDim, 1, size, + v.scale, v.offset - v.scale * landmarkDot, v.landmark); + } + } + int[] reads = {0}; + var source = new FusedASHDecoder.PackedNeighborhoods() { + public void readInto(int origin, byte[] dest) { + reads[0]++; + if (origin == 2) throw new IllegalStateException("Injected read failure"); + System.arraycopy(bytes, 0, dest, 0, bytes.length); + } + public int maxDegree() { return degree; } + public int featureSize() { return bytes.length; } + }; + var cache = new Int2ObjectHashMap(); + cache.put(0, () -> 0L); + var decoder = FusedASHDecoder.newDecoder(source, ash, cache, ignored -> vectors.get(0), query, + new byte[bytes.length], new float[degree], size, VectorSimilarityFunction.DOT_PRODUCT); + decoder.enableSimilarityToNeighbors(1); + for (int i = 0; i < degree; i++) assertEquals("fused bits=" + ash.bitsPerDimension + " lane=" + i, + expected[i], decoder.similarityToNeighbor(1, i), 0.002f); + assertEquals(expected[0], decoder.similarityTo(0), 0.00005f); + decoder.enableSimilarityToNeighbors(1); + assertEquals(1, reads[0]); + assertThrows(IllegalStateException.class, () -> decoder.enableSimilarityToNeighbors(2)); + assertThrows(IllegalArgumentException.class, () -> decoder.similarityToNeighbor(1, 0)); + decoder.enableSimilarityToNeighbors(1); + assertEquals(3, reads[0]); + } + + @Test + public void forcedSimdCannotSilentlyFallBack() { + String old = System.getProperty(BLOCK); + try { + System.setProperty(BLOCK, "simd"); + if (VectorizationProvider.getInstance().getVectorUtilSupport().supportsAshLutScoring()) { + assertTrue(new ASHLutKernel().toString().contains("SIMD")); + } else { + assertThrows(IllegalStateException.class, ASHLutKernel::new); + } + System.setProperty(BLOCK, "misspelled"); + assertThrows(IllegalArgumentException.class, ASHLutKernel::new); + } finally { restore(BLOCK, old); } + } + + private static void restore(String property, String value) { + if (value == null) System.clearProperty(property); else System.setProperty(property, value); + } +} diff --git a/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestASHStandaloneScoring.java b/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestASHStandaloneScoring.java new file mode 100644 index 000000000..9dace5ba9 --- /dev/null +++ b/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestASHStandaloneScoring.java @@ -0,0 +1,326 @@ +/* + * 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 io.github.jbellis.jvector.TestUtil; +import io.github.jbellis.jvector.graph.MockVectorValues; +import io.github.jbellis.jvector.graph.similarity.ScoreFunction; +import io.github.jbellis.jvector.vector.VectorSimilarityFunction; +import io.github.jbellis.jvector.vector.VectorUtil; +import io.github.jbellis.jvector.vector.types.VectorFloat; +import org.junit.Test; + +import java.util.Random; +import java.util.concurrent.ForkJoinPool; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; + +/** + * Exercises standalone ASH scoring without a graph, an on-disk index, + * FusedASH, or YAML configuration. + * + * The projected dimension is held constant while bitsPerDimension changes. + * This isolates the multibit ASH encoder and ASHScorer paths. + */ +public class TestASHStandaloneScoring { + private static final int ORIGINAL_DIMENSION = 128; + private static final int PROJECTED_DIMENSION = 64; + private static final int VECTOR_COUNT = 1_024; + private static final int QUERY_COUNT = 64; + private static final int LANDMARK_COUNT = 1; + + @Test + public void testOneBitStandaloneScoring() throws Exception { + runStandaloneScoringTest(1); + } + + @Test + public void testTwoBitStandaloneScoring() throws Exception { + runStandaloneScoringTest(2); + } + + @Test + public void testFourBitStandaloneScoring() throws Exception { + runStandaloneScoringTest(4); + } + + @Test + public void testAllSupportedBitWidthsTogether() throws Exception { + var vectors = createNormalizedVectors( + VECTOR_COUNT, + ORIGINAL_DIMENSION, + 0x5eedL); + + double oneBitCorrelation = scoreCorrelation(vectors, 1); + double twoBitCorrelation = scoreCorrelation(vectors, 2); + double fourBitCorrelation = scoreCorrelation(vectors, 4); + + System.out.printf( + "Standalone ASH correlation: 1-bit=%.6f, 2-bit=%.6f, 4-bit=%.6f%n", + oneBitCorrelation, + twoBitCorrelation, + fourBitCorrelation); + + assertTrue( + "1-bit correlation is too low: " + oneBitCorrelation, + oneBitCorrelation > 0.20); + + assertTrue( + "2-bit correlation is too low: " + twoBitCorrelation, + twoBitCorrelation > 0.20); + + assertTrue( + "4-bit correlation is too low: " + fourBitCorrelation, + fourBitCorrelation > 0.20); + /* + * More stored bits should not catastrophically degrade scoring. + * The tolerance allows small variations from training and quantization. + */ + assertTrue( + "2-bit scoring degraded unexpectedly" + + ": oneBit=" + oneBitCorrelation + + ", twoBit=" + twoBitCorrelation, + twoBitCorrelation >= oneBitCorrelation - 0.10); + + assertTrue( + "4-bit scoring degraded unexpectedly" + + ": oneBit=" + oneBitCorrelation + + ", fourBit=" + fourBitCorrelation, + fourBitCorrelation >= oneBitCorrelation - 0.10); + } + + private static void runStandaloneScoringTest(int bitsPerDimension) + throws Exception { + var vectors = createNormalizedVectors( + VECTOR_COUNT, + ORIGINAL_DIMENSION, + 0x5eedL); + + double correlation = scoreCorrelation(vectors, bitsPerDimension); + + System.out.printf( + "Standalone ASH bitsPerDimension=%d, correlation=%.6f%n", + bitsPerDimension, + correlation); + + assertTrue( + "Expected positive exact/approximate correlation" + + " for bitsPerDimension=" + bitsPerDimension + + ", but got " + correlation, + correlation > 0.2); + } + + private static double scoreCorrelation( + VectorFloat[] vectors, + int bitsPerDimension) throws Exception { + var ravv = MockVectorValues.fromValues(vectors); + + /* + * encodedBits includes the ASH header. Keeping PROJECTED_DIMENSION + * constant reproduces the important distinction between: + * + * 1 bit: HEADER_BITS + 32 + * 2 bit: HEADER_BITS + 64 + * 4 bit: HEADER_BITS + 128 + */ + int encodedBits = + AsymmetricHashing.HEADER_BITS + + PROJECTED_DIMENSION * bitsPerDimension; + + AsymmetricHashing ash = AsymmetricHashing.initialize( + ravv, + AsymmetricHashing.ITQ, + encodedBits, + LANDMARK_COUNT, + bitsPerDimension); + + ASHVectors encoded = + ash.encodeAll(ravv, ForkJoinPool.commonPool()); + + int sampleCount = QUERY_COUNT * VECTOR_COUNT; + double[] exactScores = new double[sampleCount]; + double[] approximateScores = new double[sampleCount]; + + int sample = 0; + + for (int queryOrdinal = 0; + queryOrdinal < QUERY_COUNT; + queryOrdinal++) { + VectorFloat query = vectors[queryOrdinal]; + + int querySampleStart = sample; + + /* + * Calculate ground truth before constructing the ASH scorer. + * This also protects the test against query preprocessing that + * uses or modifies mutable vector storage. + */ + for (int vectorOrdinal = 0; + vectorOrdinal < VECTOR_COUNT; + vectorOrdinal++) { + exactScores[sample++] = VectorUtil.dotProduct( + query, + vectors[vectorOrdinal]); + } + + ScoreFunction.ApproximateScoreFunction scorer = + encoded.scoreFunctionFor( + query, + VectorSimilarityFunction.DOT_PRODUCT); + + for (int vectorOrdinal = 0; + vectorOrdinal < VECTOR_COUNT; + vectorOrdinal++) { + double approximate = + scorer.similarityTo(vectorOrdinal); + + assertFinite( + approximate, + bitsPerDimension, + queryOrdinal, + vectorOrdinal, + encoded.get(vectorOrdinal)); + + approximateScores[querySampleStart + vectorOrdinal] = + approximate; + } + } + + assertEquals(sampleCount, sample); + + double correlation = + pearsonCorrelation(exactScores, approximateScores); + + assertTrue( + "Correlation is not finite for bitsPerDimension=" + + bitsPerDimension + + ": " + correlation, + Double.isFinite(correlation)); + + return correlation; + } + + private static void assertFinite( + double score, + int bitsPerDimension, + int queryOrdinal, + int vectorOrdinal, + AsymmetricHashing.QuantizedVector encoded) { + if (!Double.isFinite(score)) { + fail( + "Non-finite standalone ASH score" + + ": bitsPerDimension=" + bitsPerDimension + + ", query=" + queryOrdinal + + ", vector=" + vectorOrdinal + + ", score=" + score + + ", scale=" + encoded.scale + + ", offset=" + encoded.offset + + ", landmark=" + + (encoded.landmark & 0xFF)); + } + + if (!Float.isFinite(encoded.scale)) { + fail( + "Non-finite ASH scale" + + ": bitsPerDimension=" + bitsPerDimension + + ", vector=" + vectorOrdinal + + ", scale=" + encoded.scale); + } + + if (!Float.isFinite(encoded.offset)) { + fail( + "Non-finite ASH offset" + + ": bitsPerDimension=" + bitsPerDimension + + ", vector=" + vectorOrdinal + + ", offset=" + encoded.offset); + } + + int landmark = encoded.landmark & 0xFF; + assertTrue( + "Invalid landmark" + + ": bitsPerDimension=" + bitsPerDimension + + ", vector=" + vectorOrdinal + + ", landmark=" + landmark, + landmark >= 0 && landmark < LANDMARK_COUNT); + } + + private static VectorFloat[] createNormalizedVectors( + int count, + int dimension, + long seed) { + Random random = new Random(seed); + VectorFloat[] vectors = new VectorFloat[count]; + + for (int i = 0; i < count; i++) { + VectorFloat vector = + TestUtil.randomVector(random, dimension); + + // Replace TestUtil's values with zero-mean Gaussian components. + for (int d = 0; d < dimension; d++) { + vector.set(d, (float) random.nextGaussian()); + } + + VectorUtil.l2normalize(vector); + vectors[i] = vector; + } + + return vectors; + } + + private static double pearsonCorrelation( + double[] left, + double[] right) { + assertEquals(left.length, right.length); + + double leftMean = 0.0; + double rightMean = 0.0; + + for (int i = 0; i < left.length; i++) { + leftMean += left[i]; + rightMean += right[i]; + } + + leftMean /= left.length; + rightMean /= right.length; + + double covariance = 0.0; + double leftVariance = 0.0; + double rightVariance = 0.0; + + for (int i = 0; i < left.length; i++) { + double leftDelta = left[i] - leftMean; + double rightDelta = right[i] - rightMean; + + covariance += leftDelta * rightDelta; + leftVariance += leftDelta * leftDelta; + rightVariance += rightDelta * rightDelta; + } + + assertTrue( + "Exact scores have zero variance", + leftVariance > 0.0); + + assertTrue( + "Approximate scores have zero variance", + rightVariance > 0.0); + + return covariance + / Math.sqrt(leftVariance * rightVariance); + } +} diff --git a/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestASHStandaloneVsFusedScoring.java b/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestASHStandaloneVsFusedScoring.java new file mode 100644 index 000000000..a8666720e --- /dev/null +++ b/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestASHStandaloneVsFusedScoring.java @@ -0,0 +1,433 @@ +/* + * 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 io.github.jbellis.jvector.TestUtil; +import io.github.jbellis.jvector.graph.MockVectorValues; +import io.github.jbellis.jvector.graph.similarity.ScoreFunction; +import io.github.jbellis.jvector.vector.VectorSimilarityFunction; +import io.github.jbellis.jvector.vector.VectorUtil; +import io.github.jbellis.jvector.vector.VectorizationProvider; +import io.github.jbellis.jvector.vector.types.VectorFloat; +import org.junit.Test; + +import java.util.Random; +import java.util.concurrent.ForkJoinPool; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +/** + * Compares standalone ASH scores with scores produced from the fused byte + * layout for the same queries and quantized vectors. + * + * This does not involve graph construction, graph search, or disk I/O. + */ +public class TestASHStandaloneVsFusedScoring { + private static final int ORIGINAL_DIMENSION = 128; + private static final int PROJECTED_DIMENSION = 64; + private static final int VECTOR_COUNT = 1_024; + private static final int QUERY_COUNT = 32; + private static final int LANDMARK_COUNT = 1; + private static final int MAX_DEGREE = 64; + + /* + * The fused layout stores scale and offset as float16. Allow for the + * resulting rounding error when comparing against standalone scoring. + */ + private static final float ABSOLUTE_TOLERANCE = 2.0e-3f; + private static final float RELATIVE_TOLERANCE = 2.0e-3f; + + @Test + public void testOneBitStandaloneMatchesFused() throws Exception { + compareStandaloneAndFused(1); + } + + @Test + public void testTwoBitStandaloneMatchesFused() throws Exception { + compareStandaloneAndFused(2); + } + + @Test + public void testFourBitStandaloneMatchesFused() throws Exception { + compareStandaloneAndFused(4); + } + + private static void compareStandaloneAndFused(int bitsPerDimension) + throws Exception { + VectorFloat[] vectors = createNormalizedVectors( + VECTOR_COUNT, + ORIGINAL_DIMENSION, + 0x5eedL); + + var ravv = MockVectorValues.fromValues(vectors); + + int encodedBits = + AsymmetricHashing.HEADER_BITS + + PROJECTED_DIMENSION * bitsPerDimension; + + AsymmetricHashing ash = AsymmetricHashing.initialize( + ravv, + AsymmetricHashing.ITQ, + encodedBits, + LANDMARK_COUNT, + bitsPerDimension); + + ASHVectors encoded = + ash.encodeAll(ravv, ForkJoinPool.commonPool()); + + int blockSize = + FusedASHLayout.chooseBlockSize(MAX_DEGREE); + + byte[] fusedBlock = new byte[ + FusedASHLayout.blockBytes( + ash.quantizedDim, + bitsPerDimension, + blockSize)]; + + /* + * Fill one complete fused block. Each encoded vector occupies the + * lane with the same ordinal. + */ + for (int lane = 0; lane < blockSize; lane++) { + packForFused( + fusedBlock, + 0, + lane, + encoded.get(lane), + ash, + blockSize); + } + + int groups = FusedASHLayout.codeGroups( + ash.quantizedDim, + bitsPerDimension); + + float[] queryLut = new float[groups * 16]; + float[] dotQMuByLandmark = + new float[ash.landmarkCount]; + + double maximumDelta = 0.0; + int maximumDeltaQuery = -1; + int maximumDeltaLane = -1; + float maximumDeltaStandalone = Float.NaN; + float maximumDeltaFused = Float.NaN; + + for (int queryOrdinal = 0; + queryOrdinal < QUERY_COUNT; + queryOrdinal++) { + VectorFloat query = vectors[queryOrdinal]; + + ScoreFunction.ApproximateScoreFunction standaloneScorer = + encoded.scoreFunctionFor( + query, + VectorSimilarityFunction.DOT_PRODUCT); + + precomputeFusedQuery( + query, + ash, + queryLut, + dotQMuByLandmark); + + for (int lane = 0; lane < blockSize; lane++) { + float standaloneScore = + standaloneScorer.similarityTo(lane); + + float fusedScore = + ASHScorer.toSimilarity(FusedASHLayout.scoreLane( + fusedBlock, + 0, + lane, + ash.quantizedDim, + bitsPerDimension, + blockSize, + queryLut, + dotQMuByLandmark)); + + assertTrue( + failurePrefix( + bitsPerDimension, + queryOrdinal, + lane, + standaloneScore, + fusedScore) + + ": standalone score is not finite", + Float.isFinite(standaloneScore)); + + assertTrue( + failurePrefix( + bitsPerDimension, + queryOrdinal, + lane, + standaloneScore, + fusedScore) + + ": fused score is not finite", + Float.isFinite(fusedScore)); + + double delta = + Math.abs(standaloneScore - fusedScore); + + if (delta > maximumDelta) { + maximumDelta = delta; + maximumDeltaQuery = queryOrdinal; + maximumDeltaLane = lane; + maximumDeltaStandalone = standaloneScore; + maximumDeltaFused = fusedScore; + } + + float tolerance = + ABSOLUTE_TOLERANCE + + RELATIVE_TOLERANCE + * Math.abs(standaloneScore); + + assertEquals( + failurePrefix( + bitsPerDimension, + queryOrdinal, + lane, + standaloneScore, + fusedScore) + + ", tolerance=" + tolerance, + standaloneScore, + fusedScore, + tolerance); + } + } + + System.out.printf( + "ASH standalone/fused parity" + + ": bitsPerDimension=%d" + + ", blockSize=%d" + + ", maxDelta=%.9f" + + ", query=%d" + + ", lane=%d" + + ", standalone=%.9f" + + ", fused=%.9f%n", + bitsPerDimension, + blockSize, + maximumDelta, + maximumDeltaQuery, + maximumDeltaLane, + maximumDeltaStandalone, + maximumDeltaFused); + } + + /** + * Mirrors FusedASH.packForFused(). + * + * Multibit vectors already contain the projection-mode adjusted offset. + * One-bit vectors require the same adjustment performed by + * FusedASH.packOneBitForFused(). + */ + private static void packForFused( + byte[] destination, + int blockOffset, + int lane, + AsymmetricHashing.QuantizedVector vector, + AsymmetricHashing ash, + int blockSize) { + if (ash.bitsPerDimension == 1) { + packOneBitForFused( + destination, + blockOffset, + lane, + vector, + ash, + blockSize); + } else { + FusedASHLayout.packQuantizedVector( + destination, + blockOffset, + lane, + vector, + ash.quantizedDim, + ash.bitsPerDimension, + blockSize); + } + } + + /** + * Mirrors FusedASH.packOneBitForFused(). + */ + private static void packOneBitForFused( + byte[] destination, + int blockOffset, + int lane, + AsymmetricHashing.QuantizedVector vector, + AsymmetricHashing ash, + int blockSize) { + int groups = FusedASHLayout.codeGroups( + ash.quantizedDim, + ash.bitsPerDimension); + + for (int group = 0; group < groups; group++) { + int nibble = FusedASHLayout.signNibbleFromWords( + vector.binaryVector, + group, + ash.quantizedDim); + + FusedASHLayout.setPackedNibble( + destination, + blockOffset, + lane, + group, + blockSize, + nibble); + } + + int landmark = vector.landmark & 0xFF; + + assertTrue( + "Invalid landmark " + landmark + + " for landmarkCount=" + ash.landmarkCount, + landmark < ash.landmarkCount); + + float landmarkDot = dotOneBitProjectionCode( + ash.landmarkProj[landmark], + vector.binaryVector, + ash.quantizedDim); + + float adjustedOffset = + vector.offset - vector.scale * landmarkDot; + + FusedASHLayout.writeLaneHeader( + destination, + blockOffset, + lane, + ash.quantizedDim, + ash.bitsPerDimension, + blockSize, + vector.scale, + adjustedOffset, + vector.landmark); + } + + private static float dotOneBitProjectionCode( + float[] projected, + long[] signWords, + int quantizedDimension) { + float sum = 0.0f; + + for (int dimension = 0; + dimension < quantizedDimension; + dimension++) { + boolean positive = + ((signWords[dimension >>> 6] + >>> (dimension & 63)) & 1L) != 0L; + + sum += positive + ? projected[dimension] + : -projected[dimension]; + } + + return sum; + } + + /** + * Mirrors FusedASHDecoder.precomputeQuery(). + */ + private static void precomputeFusedQuery( + VectorFloat query, + AsymmetricHashing ash, + float[] queryLut, + float[] dotQMuByLandmark) { + int originalDimension = query.length(); + int quantizedDimension = ash.quantizedDim; + + float[] queryArray = + new float[originalDimension]; + + for (int dimension = 0; + dimension < originalDimension; + dimension++) { + queryArray[dimension] = query.get(dimension); + } + + float[][] projectionMatrix = + ash.stiefelTransform.AFloat; + + var vectorUtil = VectorizationProvider + .getInstance() + .getVectorUtilSupport(); + + float[] projectedQuery = + new float[quantizedDimension]; + + for (int projectedDimension = 0; + projectedDimension < quantizedDimension; + projectedDimension++) { + projectedQuery[projectedDimension] = + vectorUtil.ashDotRow( + projectionMatrix[projectedDimension], + queryArray); + } + + FusedASHLayout.buildQueryLut( + projectedQuery, + quantizedDimension, + ash.bitsPerDimension, + queryLut); + + for (int landmark = 0; + landmark < ash.landmarkCount; + landmark++) { + dotQMuByLandmark[landmark] = + VectorUtil.dotProduct( + query, + ash.landmarks[landmark]); + } + } + + private static VectorFloat[] createNormalizedVectors( + int count, + int dimension, + long seed) { + Random random = new Random(seed); + VectorFloat[] vectors = + new VectorFloat[count]; + + for (int i = 0; i < count; i++) { + VectorFloat vector = + TestUtil.randomVector(random, dimension); + + for (int d = 0; d < dimension; d++) { + vector.set( + d, + (float) random.nextGaussian()); + } + + VectorUtil.l2normalize(vector); + vectors[i] = vector; + } + + return vectors; + } + + private static String failurePrefix( + int bitsPerDimension, + int queryOrdinal, + int lane, + float standaloneScore, + float fusedScore) { + return "Standalone/fused score mismatch" + + ": bitsPerDimension=" + bitsPerDimension + + ", query=" + queryOrdinal + + ", lane=" + lane + + ", standalone=" + standaloneScore + + ", fused=" + fusedScore; + } +} From e744b3b331c785a3433f1ea543f6e1d32829ef53 Mon Sep 17 00:00:00 2001 From: Ted Willke Date: Fri, 11 Sep 2026 19:00:02 +0000 Subject: [PATCH 049/243] Initial ASH graph index config file for ada002-1M. --- .../index-parameters/ada002-1M.yml | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) create mode 100644 jvector-examples/yaml-configs/index-parameters/ada002-1M.yml diff --git a/jvector-examples/yaml-configs/index-parameters/ada002-1M.yml b/jvector-examples/yaml-configs/index-parameters/ada002-1M.yml new file mode 100644 index 000000000..c02d2a0ff --- /dev/null +++ b/jvector-examples/yaml-configs/index-parameters/ada002-1M.yml @@ -0,0 +1,36 @@ +yamlSchemaVersion: 2 +onDiskIndexVersion: 7 + +dataset: ada002-1M + +construction: + outDegree: [64] + efConstruction: [200] + neighborOverflow: [1.6f] + addHierarchy: [Yes] + refineFinalGraph: [Yes] + fusedGraph: [Yes] + compression: + - type: ASH + parameters: + encodedBits: 1576 + bitsPerDimension: 2 + optimizer: itq + landmarkCount: 1 + reranking: + - FP + useSavedIndexIfExists: No # If yes, the system will attempt to locate a cached copy of the index instead of constructing + +search: + topKOverquery: + 10: [1.00] +# 100: [1.0] + useSearchPruning: [No] + compression: + - type: ASH + parameters: + encodedBits: 1576 + bitsPerDimension: 2 + optimizer: itq + landmarkCount: 1 +# Run-level controls, such as benchmarks, console, and logging, are in run.yml. \ No newline at end of file From 2d81a3d989992d92c70bc5aa408f11e7c0ba04f7 Mon Sep 17 00:00:00 2001 From: Ted Willke Date: Fri, 11 Sep 2026 19:01:54 +0000 Subject: [PATCH 050/243] Tweak datasets.yml for current benchmarking. --- jvector-examples/yaml-configs/datasets.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/jvector-examples/yaml-configs/datasets.yml b/jvector-examples/yaml-configs/datasets.yml index 5e0423c8e..8dcb370a1 100644 --- a/jvector-examples/yaml-configs/datasets.yml +++ b/jvector-examples/yaml-configs/datasets.yml @@ -9,7 +9,7 @@ jvector-100k: # - e5-base-v2-100k # - e5-large-v2-100k #jvector-1M: -# - ada002-1M + - ada002-1M # - colbert-1M #ann-benchmarks: # - glove-25-angular @@ -24,7 +24,7 @@ jvector-100k: # - dpr-10M # - cap-1M # - cap-6M - - cohere-english-v3-1M +# - cohere-english-v3-1M # - cohere-english-v3-10M # - mpnet-1m # - tahoe-x1-1m From 62a5360fdccd4f451165c5e6abbe0b191c4410a1 Mon Sep 17 00:00:00 2001 From: Ted Willke Date: Fri, 11 Sep 2026 23:34:31 +0000 Subject: [PATCH 051/243] Tweak index config YAML for ada002-1M. --- .../yaml-configs/index-parameters/ada002-1M.yml | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/jvector-examples/yaml-configs/index-parameters/ada002-1M.yml b/jvector-examples/yaml-configs/index-parameters/ada002-1M.yml index c02d2a0ff..c8b9b8223 100644 --- a/jvector-examples/yaml-configs/index-parameters/ada002-1M.yml +++ b/jvector-examples/yaml-configs/index-parameters/ada002-1M.yml @@ -17,13 +17,19 @@ construction: bitsPerDimension: 2 optimizer: itq landmarkCount: 1 +# - type: PQ +# parameters: +# m: 197 +# k: 256 +# centerData: No +# anisotropicThreshold: -1.0 reranking: - FP - useSavedIndexIfExists: No # If yes, the system will attempt to locate a cached copy of the index instead of constructing + useSavedIndexIfExists: Yes # If yes, the system will attempt to locate a cached copy of the index instead of constructing search: topKOverquery: - 10: [1.00] + 10: [1.0, 2.0, 3.0, 4.0] # 100: [1.0] useSearchPruning: [No] compression: @@ -33,4 +39,10 @@ search: bitsPerDimension: 2 optimizer: itq landmarkCount: 1 +# - type: PQ +# parameters: +# m: 197 +# k: 256 +# centerData: No +# anisotropicThreshold: -1.0 # Run-level controls, such as benchmarks, console, and logging, are in run.yml. \ No newline at end of file From 129d4bac27b9e4947623b29a274465eed3fce6e4 Mon Sep 17 00:00:00 2001 From: Ted Willke Date: Wed, 16 Sep 2026 18:38:45 +0000 Subject: [PATCH 052/243] Stub off ASH compaction for now. --- .../jvector/graph/disk/feature/FusedASH.java | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/graph/disk/feature/FusedASH.java b/jvector-base/src/main/java/io/github/jbellis/jvector/graph/disk/feature/FusedASH.java index 5c5ded27f..e30ac5c4b 100644 --- a/jvector-base/src/main/java/io/github/jbellis/jvector/graph/disk/feature/FusedASH.java +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/graph/disk/feature/FusedASH.java @@ -20,13 +20,17 @@ import io.github.jbellis.jvector.disk.RandomAccessReader; import io.github.jbellis.jvector.graph.ImmutableGraphIndex; import io.github.jbellis.jvector.graph.disk.CommonHeader; +import io.github.jbellis.jvector.graph.disk.CompactionContext; import io.github.jbellis.jvector.graph.disk.OnDiskGraphIndex; +import io.github.jbellis.jvector.graph.disk.QuantizationCompactionStrategy; import io.github.jbellis.jvector.graph.similarity.ScoreFunction; import io.github.jbellis.jvector.quantization.ASHVectors; import io.github.jbellis.jvector.quantization.AsymmetricHashing; import io.github.jbellis.jvector.quantization.FusedASHDecoder; import io.github.jbellis.jvector.quantization.FusedASHLayout; +import io.github.jbellis.jvector.quantization.VectorCompressor; import io.github.jbellis.jvector.vector.VectorSimilarityFunction; +import io.github.jbellis.jvector.vector.types.ByteSequence; import io.github.jbellis.jvector.vector.types.VectorFloat; import java.io.IOException; @@ -95,6 +99,28 @@ public AsymmetricHashing getASH() { return ash; } + // ASH uses QuantizedVector codes and a block-packed layout that the current + // byte-sequence compaction pipeline does not support. + @Override + public int codeSize() { + throw new UnsupportedOperationException("FusedASH compaction is not supported yet"); + } + + @Override + public VectorCompressor> getCompressor() { + throw new UnsupportedOperationException("FusedASH compaction is not supported yet"); + } + + @Override + public FusedFeature withCompressor(VectorCompressor> newCompressor, int maxDegree) { + throw new UnsupportedOperationException("FusedASH compaction is not supported yet"); + } + + @Override + public QuantizationCompactionStrategy createCompactionStrategy(CompactionContext ctx) { + throw new UnsupportedOperationException("FusedASH compaction is not supported yet"); + } + public int blockSize() { return blockSize; } From 49b74c2cc4ae1bff8bf03d517a0f71fad25a56f0 Mon Sep 17 00:00:00 2001 From: Ted Willke Date: Wed, 16 Sep 2026 19:14:50 +0000 Subject: [PATCH 053/243] Minor dataset config change. --- jvector-examples/yaml-configs/datasets.yml | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/jvector-examples/yaml-configs/datasets.yml b/jvector-examples/yaml-configs/datasets.yml index 8dcb370a1..8a6be912a 100644 --- a/jvector-examples/yaml-configs/datasets.yml +++ b/jvector-examples/yaml-configs/datasets.yml @@ -1,7 +1,7 @@ jvector-100k: +# - ada002-100k # - openai-v3-large-3072-100k # - openai-v3-large-1536-100k -# - ada002-100k # - openai-v3-small-1536-100k # - gecko-100k # - cohere-english-v3-100k @@ -19,9 +19,17 @@ jvector-100k: # - glove-200-angular # - nytimes-256-angular # - sift-128-euclidean -#other-datasets: +#regression-tests: +# - cap-1M +# - cap-6M +# - cohere-english-v3-1M +# - cohere-english-v3-10M # - dpr-gemma-1m -# - dpr-10M +# - dpr-gemma-10m +#openai-unconfigured: +# - openai-3072-1m +# - openai-1536-1m +#other-datasets: # - cap-1M # - cap-6M # - cohere-english-v3-1M From fd53a1fd6134a47158ca1c1c2a5f5622cd65af6f Mon Sep 17 00:00:00 2001 From: Ted Willke Date: Tue, 16 Jun 2026 17:43:40 +0000 Subject: [PATCH 054/243] Initial commit for adaptive termination algo. --- .../jvector/graph/AdaptiveTermination.java | 161 ++++++++++++ .../graph/AdaptiveTerminationTest.java | 248 ++++++++++++++++++ 2 files changed, 409 insertions(+) create mode 100644 jvector-base/src/main/java/io/github/jbellis/jvector/graph/AdaptiveTermination.java create mode 100644 jvector-tests/src/test/java/io/github/jbellis/jvector/graph/AdaptiveTerminationTest.java diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/graph/AdaptiveTermination.java b/jvector-base/src/main/java/io/github/jbellis/jvector/graph/AdaptiveTermination.java new file mode 100644 index 000000000..0c85785c3 --- /dev/null +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/graph/AdaptiveTermination.java @@ -0,0 +1,161 @@ +/* + * 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; + +/** + * Per-query adaptive early termination based on result-set discovery. + * + *

    + * This is based on: + *

      + *
    • [1] Tommaso Teofili and Jimmy Lin. 2025. Patience in Proximity: + * A Simple Early Termination Strategy for HNSW Graph Traversal in + * Approximate k-Nearest Neighbor Search. ECIR 2025. + * https://doi.org/10.1007/978-3-031-88714-7_39
    • + *
    • [2] Tommaso Teofili. 2026. Elasticsearch HNSW: Adaptive early + * termination for vector search. Elasticsearch Labs. + * https://www.elastic.co/search-labs/blog/hnsw-elasticsearch-adaptive-early-termination
    • + *
    + * + *

    + * The adaptive variant tracks a smoothed discovery rate per query: + * + *

    + *   d_i = alpha * introduced_i / (steps_i * k) + (1 - alpha) * d_{i-1}
    + *   threshold_i = mean(d) * thresholdScale * stddev(d)
    + *   patience_i = patienceNumerator / (1 + stddev(d))
    + * 
    + * + *

    + * This class only decides when traversal should terminate after completed visits. + * It must not be used to skip expansion of a candidate that has already been + * selected for expansion. + */ +final class AdaptiveTermination { + static final float DISCOVERY_ALPHA = 0.9f; + static final float THRESHOLD_SCALE = 0.01f; + static final float PATIENCE_NUMERATOR = 10.0f; + + private static final float EPSILON = 1e-6f; + private static final int MIN_OBSERVATIONS = 2; + + private int k; + private float discovery; + + // Welford state for rolling mean/stddev of discovery. + private int observations; + private double mean; + private double m2; + + private int saturatedVisits; + + AdaptiveTermination() { + reset(1); + } + + void reset(int k) { + if (k <= 0) { + throw new IllegalArgumentException("k must be > 0"); + } + + this.k = k; + this.discovery = 0.0f; + this.observations = 0; + this.mean = 0.0; + this.m2 = 0.0; + this.saturatedVisits = 0; + } + + /** + * Records one completed graph-visit interval. + * + * @param introduced number of new entries introduced into the tracked result set + * @param steps number of graph visits represented by this observation + * @param resultSetFull whether the tracked result set is full enough to stop + */ + void observe(int introduced, int steps, boolean resultSetFull) { + int safeIntroduced = Math.max(0, introduced); + int safeSteps = Math.max(1, steps); + + float rawDiscovery = safeDivide(safeIntroduced, (float) safeSteps * k); + discovery = DISCOVERY_ALPHA * rawDiscovery + (1.0f - DISCOVERY_ALPHA) * discovery; + + updateStats(discovery); + + if (resultSetFull && observations >= MIN_OBSERVATIONS && isSaturated()) { + saturatedVisits++; + } else { + saturatedVisits = 0; + } + } + + boolean shouldTerminate() { + return observations >= MIN_OBSERVATIONS + && saturatedVisits > patience(); + } + + private boolean isSaturated() { + return discovery < saturationThreshold(); + } + + private float saturationThreshold() { + return (float) mean - THRESHOLD_SCALE * stddev(); + } + + private float patience() { + return safeDivide(PATIENCE_NUMERATOR, 1.0f + stddev()); + } + + /** + * Welford's online update for numerically stable mean and variance. + */ + private void updateStats(float value) { + observations++; + + double delta = value - mean; + mean += delta / observations; + double delta2 = value - mean; + m2 += delta * delta2; + } + + private float stddev() { + if (observations < MIN_OBSERVATIONS) { + return 0.0f; + } + + double variance = m2 / (observations - 1); + if (!Double.isFinite(variance) || variance <= 0.0) { + return 0.0f; + } + + return (float) Math.sqrt(variance); + } + + /** + * Local finite/zero guard keeps the adaptive equations robust without + * introducing a dependency on a broader math helper. + */ + private static float safeDivide(float numerator, float denominator) { + if (!Float.isFinite(numerator)) { + return 0.0f; + } + if (!Float.isFinite(denominator) || Math.abs(denominator) < EPSILON) { + return 0.0f; + } + return numerator / denominator; + } +} diff --git a/jvector-tests/src/test/java/io/github/jbellis/jvector/graph/AdaptiveTerminationTest.java b/jvector-tests/src/test/java/io/github/jbellis/jvector/graph/AdaptiveTerminationTest.java new file mode 100644 index 000000000..550bcace5 --- /dev/null +++ b/jvector-tests/src/test/java/io/github/jbellis/jvector/graph/AdaptiveTerminationTest.java @@ -0,0 +1,248 @@ +/* + * 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 org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Tests adaptive early termination based on per-query result-set discovery. + * + *

    The tracker models the "Patience in Proximity" idea: terminate only after + * the tracked nearest-neighbor set stops changing for a sustained period. The + * adaptive variant uses per-query discovery-rate statistics to decide saturation + * and patience.

    + */ +class AdaptiveTerminationTest { + private static final int K = 10; + + @Test + void resetRejectsZeroK() { + AdaptiveTermination tracker = new AdaptiveTermination(); + + assertThrows(IllegalArgumentException.class, () -> tracker.reset(0)); + } + + @Test + void resetRejectsNegativeK() { + AdaptiveTermination tracker = new AdaptiveTermination(); + + assertThrows(IllegalArgumentException.class, () -> tracker.reset(-1)); + } + + @Test + void doesNotTerminateWithoutObservations() { + AdaptiveTermination tracker = new AdaptiveTermination(); + tracker.reset(K); + + assertFalse(tracker.shouldTerminate()); + } + + @Test + void oneObservationIsInsufficient() { + AdaptiveTermination tracker = new AdaptiveTermination(); + tracker.reset(K); + + tracker.observe(0, 1, true); + + assertFalse(tracker.shouldTerminate()); + } + + @Test + void steadyHealthyDiscoveryDoesNotTerminate() { + AdaptiveTermination tracker = new AdaptiveTermination(); + tracker.reset(K); + + for (int i = 0; i < 200; i++) { + tracker.observe(K, 1, true); + assertFalse(tracker.shouldTerminate()); + } + } + + @Test + void largeKSteadyHealthyDiscoveryDoesNotTerminate() { + int k = 1_000; + AdaptiveTermination tracker = new AdaptiveTermination(); + tracker.reset(k); + + for (int i = 0; i < 200; i++) { + tracker.observe(k, 1, true); + assertFalse(tracker.shouldTerminate()); + } + } + + @Test + void terminationRequiresResultSetFull() { + AdaptiveTermination tracker = new AdaptiveTermination(); + tracker.reset(K); + + seedHealthyDiscovery(tracker, K); + + boolean terminated = observeUntilTerminated( + tracker, + 0, + 1, + false, + 200 + ); + + assertFalse(terminated); + } + + @Test + void terminatesWhenDiscoveryDriesUpAfterResultSetIsFull() { + AdaptiveTermination tracker = new AdaptiveTermination(); + tracker.reset(K); + + seedHealthyDiscovery(tracker, K); + + boolean terminated = observeUntilTerminated( + tracker, + 0, + 1, + true, + 200 + ); + + assertTrue(terminated); + } + + @Test + void terminatesWhenDryDiscoveryIsReportedAcrossMultipleSteps() { + AdaptiveTermination tracker = new AdaptiveTermination(); + tracker.reset(K); + + seedHealthyDiscovery(tracker, K); + + boolean terminated = observeUntilTerminated( + tracker, + 0, + 5, + true, + 200 + ); + + assertTrue(terminated); + } + + @Test + void resetClearsPriorTerminationState() { + AdaptiveTermination tracker = new AdaptiveTermination(); + tracker.reset(K); + + seedHealthyDiscovery(tracker, K); + assertTrue(observeUntilTerminated(tracker, 0, 1, true, 200)); + + tracker.reset(K); + + assertFalse(tracker.shouldTerminate()); + tracker.observe(0, 1, true); + assertFalse(tracker.shouldTerminate()); + } + + @Test + void terminationIsRepeatableAfterReset() { + AdaptiveTermination tracker = new AdaptiveTermination(); + tracker.reset(K); + + seedHealthyDiscovery(tracker, K); + assertTrue(observeUntilTerminated(tracker, 0, 1, true, 200)); + + tracker.reset(K); + + seedHealthyDiscovery(tracker, K); + assertTrue(observeUntilTerminated(tracker, 0, 1, true, 200)); + } + + @Test + void observeGuardsInvalidInputs() { + AdaptiveTermination tracker = new AdaptiveTermination(); + tracker.reset(K); + + assertDoesNotThrow(() -> { + for (int i = 0; i < 100; i++) { + tracker.observe(-10, 0, true); + } + }); + } + + + + @Test + void lowVarianceRequiresMorePatienceThanHighVariance() { + AdaptiveTermination stableTracker = new AdaptiveTermination(); + stableTracker.reset(K); + for (int i = 0; i < 100; i++) { + stableTracker.observe(5, 1, true); + } + + AdaptiveTermination chaoticTracker = new AdaptiveTermination(); + chaoticTracker.reset(K); + for (int i = 0; i < 100; i++) { + chaoticTracker.observe(i % 2 == 0 ? K : 0, 1, true); + } + + // Force a non-saturated visit to synchronize state + stableTracker.observe(K, 1, true); + chaoticTracker.observe(K, 1, true); + + int stableSteps = 0; + while (!stableTracker.shouldTerminate() && stableSteps < 50) { + stableTracker.observe(0, 1, true); + stableSteps++; + } + + int chaoticSteps = 0; + while (!chaoticTracker.shouldTerminate() && chaoticSteps < 50) { + chaoticTracker.observe(0, 1, true); + chaoticSteps++; + } + + assertTrue(stableTracker.shouldTerminate()); + assertTrue(chaoticTracker.shouldTerminate()); + assertTrue(chaoticSteps < stableSteps, + "High variance should reduce patience, terminating faster than a stable history"); + } + + private static void seedHealthyDiscovery(AdaptiveTermination tracker, int k) { + for (int i = 0; i < 20; i++) { + tracker.observe(k, 1, true); + assertFalse(tracker.shouldTerminate()); + } + } + + + private static boolean observeUntilTerminated( + AdaptiveTermination tracker, + int introduced, + int steps, + boolean resultSetFull, + int maxObservations + ) { + for (int i = 0; i < maxObservations; i++) { + tracker.observe(introduced, steps, resultSetFull); + if (tracker.shouldTerminate()) { + return true; + } + } + return false; + } +} From f067fb6129f86732fde4eaad58f2c678a0ba9c7a Mon Sep 17 00:00:00 2001 From: Ted Willke Date: Wed, 17 Jun 2026 17:11:47 +0000 Subject: [PATCH 055/243] Implementation of Distance Adaptive Beam Search. --- .../jvector/graph/AdaptiveTermination.java | 184 +++++++----------- 1 file changed, 65 insertions(+), 119 deletions(-) diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/graph/AdaptiveTermination.java b/jvector-base/src/main/java/io/github/jbellis/jvector/graph/AdaptiveTermination.java index 0c85785c3..6a7f2984d 100644 --- a/jvector-base/src/main/java/io/github/jbellis/jvector/graph/AdaptiveTermination.java +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/graph/AdaptiveTermination.java @@ -1,161 +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.graph; /** - * Per-query adaptive early termination based on result-set discovery. + * Distance-adaptive base-layer termination. * - *

    - * This is based on: - *

      - *
    • [1] Tommaso Teofili and Jimmy Lin. 2025. Patience in Proximity: - * A Simple Early Termination Strategy for HNSW Graph Traversal in - * Approximate k-Nearest Neighbor Search. ECIR 2025. - * https://doi.org/10.1007/978-3-031-88714-7_39
    • - *
    • [2] Tommaso Teofili. 2026. Elasticsearch HNSW: Adaptive early - * termination for vector search. Elasticsearch Labs. - * https://www.elastic.co/search-labs/blog/hnsw-elasticsearch-adaptive-early-termination
    • - *
    - * - *

    - * The adaptive variant tracks a smoothed discovery rate per query: + *

    The adaptive rule stops when the best unexpanded candidate is farther than + * the kth-best discovered result by a relative factor: * *

    - *   d_i = alpha * introduced_i / (steps_i * k) + (1 - alpha) * d_{i-1}
    - *   threshold_i = mean(d) * thresholdScale * stddev(d)
    - *   patience_i = patienceNumerator / (1 + stddev(d))
    + *   d(q, candidate) >= (1 + gamma) * d_k
      * 
    * - *

    - * This class only decides when traversal should terminate after completed visits. - * It must not be used to skip expansion of a candidate that has already been - * selected for expansion. + *

    GraphSearcher works with higher-is-better normalized scores. We convert + * those scores back to a distance-like scale using the common bounded-score + * convention {@code score = 1 / (1 + distance)}. If a query produces scores + * outside the supported range, callers must fall back to standard termination. */ final class AdaptiveTermination { - static final float DISCOVERY_ALPHA = 0.9f; - static final float THRESHOLD_SCALE = 0.01f; - static final float PATIENCE_NUMERATOR = 10.0f; - - private static final float EPSILON = 1e-6f; - private static final int MIN_OBSERVATIONS = 2; + static final float DEFAULT_GAMMA = 0.005f; - private int k; - private float discovery; + private static final double MIN_SCORE = 0.0d; + private static final double MAX_SCORE = 1.0d; + private static final double EPSILON = 1e-6d; - // Welford state for rolling mean/stddev of discovery. - private int observations; - private double mean; - private double m2; + enum Decision { + CONTINUE, + TERMINATE, + FALL_BACK_TO_STANDARD + } - private int saturatedVisits; + private int k; + private float gamma; + private boolean unsupportedScoreObserved; AdaptiveTermination() { - reset(1); + reset(1, DEFAULT_GAMMA); } void reset(int k) { + reset(k, DEFAULT_GAMMA); + } + + void reset(int k, float gamma) { if (k <= 0) { throw new IllegalArgumentException("k must be > 0"); } + validateGamma(gamma); this.k = k; - this.discovery = 0.0f; - this.observations = 0; - this.mean = 0.0; - this.m2 = 0.0; - this.saturatedVisits = 0; + this.gamma = gamma; + this.unsupportedScoreObserved = false; } - /** - * Records one completed graph-visit interval. - * - * @param introduced number of new entries introduced into the tracked result set - * @param steps number of graph visits represented by this observation - * @param resultSetFull whether the tracked result set is full enough to stop - */ - void observe(int introduced, int steps, boolean resultSetFull) { - int safeIntroduced = Math.max(0, introduced); - int safeSteps = Math.max(1, steps); - - float rawDiscovery = safeDivide(safeIntroduced, (float) safeSteps * k); - discovery = DISCOVERY_ALPHA * rawDiscovery + (1.0f - DISCOVERY_ALPHA) * discovery; - - updateStats(discovery); - - if (resultSetFull && observations >= MIN_OBSERVATIONS && isSaturated()) { - saturatedVisits++; - } else { - saturatedVisits = 0; + static void validateGamma(float gamma) { + if (!Float.isFinite(gamma) || gamma < 0.0f) { + throw new IllegalArgumentException("gamma must be finite and >= 0"); } } - boolean shouldTerminate() { - return observations >= MIN_OBSERVATIONS - && saturatedVisits > patience(); - } - - private boolean isSaturated() { - return discovery < saturationThreshold(); - } - - private float saturationThreshold() { - return (float) mean - THRESHOLD_SCALE * stddev(); - } - - private float patience() { - return safeDivide(PATIENCE_NUMERATOR, 1.0f + stddev()); - } - /** - * Welford's online update for numerically stable mean and variance. + * Decides whether the current best unexpanded candidate should terminate + * the search under the distance-adaptive rule. */ - private void updateStats(float value) { - observations++; + Decision shouldTerminate(float candidateScore, NodeQueue discoveredResults) { + if (unsupportedScoreObserved) { + return Decision.FALL_BACK_TO_STANDARD; + } + if (discoveredResults.size() < k) { + return Decision.CONTINUE; + } - double delta = value - mean; - mean += delta / observations; - double delta2 = value - mean; - m2 += delta * delta2; - } + double candidateDistance = scoreToDistance(candidateScore); + double kthDistance = scoreToDistance(discoveredResults.topScore()); - private float stddev() { - if (observations < MIN_OBSERVATIONS) { - return 0.0f; + if (Double.isNaN(candidateDistance) || Double.isNaN(kthDistance)) { + unsupportedScoreObserved = true; + return Decision.FALL_BACK_TO_STANDARD; } - double variance = m2 / (observations - 1); - if (!Double.isFinite(variance) || variance <= 0.0) { - return 0.0f; + if (Double.isInfinite(kthDistance)) { + return Decision.CONTINUE; + } + if (Double.isInfinite(candidateDistance)) { + return Decision.TERMINATE; } - return (float) Math.sqrt(variance); + double cutoff = (1.0d + gamma) * kthDistance; + return candidateDistance + EPSILON >= cutoff + ? Decision.TERMINATE + : Decision.CONTINUE; } - /** - * Local finite/zero guard keeps the adaptive equations robust without - * introducing a dependency on a broader math helper. - */ - private static float safeDivide(float numerator, float denominator) { - if (!Float.isFinite(numerator)) { - return 0.0f; + private static double scoreToDistance(float score) { + if (!Float.isFinite(score)) { + return Double.NaN; } - if (!Float.isFinite(denominator) || Math.abs(denominator) < EPSILON) { - return 0.0f; + if (score < MIN_SCORE - EPSILON || score > MAX_SCORE + EPSILON) { + return Double.NaN; } - return numerator / denominator; + if (score <= EPSILON) { + return Double.POSITIVE_INFINITY; + } + + double boundedScore = Math.min(MAX_SCORE, Math.max(MIN_SCORE, score)); + return (1.0d / boundedScore) - 1.0d; } } From 91ebf3539294f7972cd7bea134c1976eedf31d12 Mon Sep 17 00:00:00 2001 From: Ted Willke Date: Wed, 16 Sep 2026 22:07:22 +0000 Subject: [PATCH 056/243] Integrate adaptive termination for ASH graph search --- .../jvector/graph/AdaptiveTermination.java | 14 +- .../jbellis/jvector/graph/GraphSearcher.java | 84 ++++-- .../DefaultSearchScoreProvider.java | 9 + .../graph/similarity/SearchScoreProvider.java | 3 + .../github/jbellis/jvector/example/Grid.java | 2 +- .../jbellis/jvector/example/IPCService.java | 2 +- .../graph/AdaptiveTerminationTest.java | 280 +++--------------- .../quantization/TestASHGraphSearch.java | 4 +- 8 files changed, 126 insertions(+), 272 deletions(-) diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/graph/AdaptiveTermination.java b/jvector-base/src/main/java/io/github/jbellis/jvector/graph/AdaptiveTermination.java index 6a7f2984d..41cb10d24 100644 --- a/jvector-base/src/main/java/io/github/jbellis/jvector/graph/AdaptiveTermination.java +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/graph/AdaptiveTermination.java @@ -1,18 +1,18 @@ package io.github.jbellis.jvector.graph; /** - * Distance-adaptive base-layer termination. + * Adaptive termination for graph DOT_PRODUCT similarity scores. * *

    The adaptive rule stops when the best unexpanded candidate is farther than * the kth-best discovered result by a relative factor: * *

    - *   d(q, candidate) >= (1 + gamma) * d_k
    + *   d(q, candidate) > (1 + gamma) * d_k
      * 
    * *

    GraphSearcher works with higher-is-better normalized scores. We convert - * those scores back to a distance-like scale using the common bounded-score - * convention {@code score = 1 / (1 + distance)}. If a query produces scores + * those scores back to a squared-L2-like scale for normalized vectors using + * {@code distance = 1 - score}. If a query produces scores * outside the supported range, callers must fall back to standard termination. */ final class AdaptiveTermination { @@ -85,7 +85,7 @@ Decision shouldTerminate(float candidateScore, NodeQueue discoveredResults) { } double cutoff = (1.0d + gamma) * kthDistance; - return candidateDistance + EPSILON >= cutoff + return candidateDistance > cutoff + EPSILON ? Decision.TERMINATE : Decision.CONTINUE; } @@ -98,10 +98,10 @@ private static double scoreToDistance(float score) { return Double.NaN; } if (score <= EPSILON) { - return Double.POSITIVE_INFINITY; + return 1.0d; } double boundedScore = Math.min(MAX_SCORE, Math.max(MIN_SCORE, score)); - return (1.0d / boundedScore) - 1.0d; + return 1.0d - boundedScore; } } 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 4d41feef3..178e6d67e 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 @@ -62,6 +62,11 @@ public class GraphSearcher implements Closeable { private CachingReranker cachingReranker; private boolean pruneSearch; + private boolean adaptiveTerminationEnabled = true; + private float adaptiveTerminationGamma = AdaptiveTermination.DEFAULT_GAMMA; + private final AdaptiveTermination adaptiveTermination = new AdaptiveTermination(); + private final NodeQueue adaptiveResults = new NodeQueue(new BoundedLongHeap(100), NodeQueue.Order.MIN_HEAP); + private final IntHashSet adaptiveResultMembers = new IntHashSet(); private final ScoreTracker.ScoreTrackerFactory scoreTrackerFactory; private int visitedCount; @@ -138,6 +143,18 @@ public void usePruning(boolean usage) { pruneSearch = false; } + /** Enable or disable adaptive stopping for score providers that explicitly support it. */ + public void useAdaptiveTermination(boolean enabled) { + adaptiveTerminationEnabled = enabled; + } + + /** Configure the relative squared-distance margin for adaptive stopping. */ + public void useAdaptiveTermination(boolean enabled, float gamma) { + AdaptiveTermination.validateGamma(gamma); + adaptiveTerminationEnabled = enabled; + adaptiveTerminationGamma = gamma; + } + /** * Convenience function for simple one-off searches. It is caller's responsibility to make sure that it * is the unique owner of the vectors instance passed in here. @@ -364,20 +381,17 @@ public void initializeInternal(SearchScoreProvider scoreProvider, NodeAtLevel en expandedCountBaseLayer = 0; } - private boolean stopSearch(NodeQueue localCandidates, ScoreTracker scoreTracker, int rerankK, float threshold) { - float topCandidateScore = localCandidates.topScore(); - - // we're done when we have K results and the best candidate is worse than the worst result so far - if (approximateResults.size() >= rerankK && topCandidateScore < approximateResults.topScore()) { - return true; - } - - // preserve legacy threshold early termination - if (threshold > 0 && scoreTracker.shouldStop()) { - return true; + private boolean stopSearch(NodeQueue localCandidates, ScoreTracker scoreTracker, int rerankK, + float threshold, boolean adaptive) { + if (adaptive && approximateResults.size() >= rerankK) { + var decision = adaptiveTermination.shouldTerminate(localCandidates.topScore(), adaptiveResults); + if (decision == AdaptiveTermination.Decision.CONTINUE) return false; + if (decision == AdaptiveTermination.Decision.TERMINATE) + return !adaptiveResultMembers.contains(localCandidates.topNode()); } - - return false; + if (approximateResults.size() >= rerankK + && localCandidates.topScore() < approximateResults.topScore()) return true; + return threshold > 0 && scoreTracker.shouldStop(); } /** @@ -424,14 +438,26 @@ public void searchOneLayer(SearchScoreProvider scoreProvider, try { assert approximateResults.size() == 0; // should be cleared by setEntryPointsFromPreviousLayer approximateResults.setMaxSize(rerankK); + boolean adaptive = adaptiveTerminationEnabled + && scoreProvider.supportsAdaptiveDotProductTermination() + && level == 0 && threshold == 0.0f && rerankK > 0; + if (adaptive) resetAdaptiveTermination(rerankK, acceptOrdsThisLayer); // TopK and filtered pruning are disabled. Threshold searches retain their // legacy threshold early-termination path inside ScoreTrackerFactory. var scoreTracker = scoreTrackerFactory.getScoreTracker(false, rerankK, threshold); + var scoreFunction = scoreProvider.scoreFunction(); + ImmutableGraphIndex.NeighborProcessor neighborProcessor = (node2, score) -> { + if (adaptive && acceptOrdsThisLayer.get(node2)) offerAdaptiveResult(node2, score, rerankK); + scoreTracker.track(score); + candidates.push(node2, score); + visitedCount++; + }; + // the main search loop while (candidates.size() > 0) { - if (stopSearch(candidates, scoreTracker, rerankK, threshold)) { + if (stopSearch(candidates, scoreTracker, rerankK, threshold, adaptive)) { break; } @@ -440,6 +466,7 @@ public void searchOneLayer(SearchScoreProvider scoreProvider, int topCandidateNode = candidates.pop(); if (acceptOrdsThisLayer.get(topCandidateNode) && topCandidateScore >= threshold) { addTopCandidate(topCandidateNode, topCandidateScore, rerankK); + if (adaptive) offerAdaptiveResult(topCandidateNode, topCandidateScore, rerankK); } // skip edge loading if we've found a local maximum and we have enough results @@ -453,12 +480,6 @@ public void searchOneLayer(SearchScoreProvider scoreProvider, expandedCount++; // score the neighbors of the top candidate and add them to the queue - var scoreFunction = scoreProvider.scoreFunction(); - ImmutableGraphIndex.NeighborProcessor neighborProcessor = (node2, score) -> { - scoreTracker.track(score); - candidates.push(node2, score); - visitedCount++; - }; view.processNeighbors(level, topCandidateNode, scoreFunction, visited::add, neighborProcessor); } } catch (Throwable t) { @@ -523,6 +544,29 @@ SearchResult resume(int topK, int rerankK, float threshold, float rerankFloor) { return reranking(topK, rerankK, rerankFloor); } + private void resetAdaptiveTermination(int rerankK, Bits accepted) { + adaptiveTermination.reset(rerankK, adaptiveTerminationGamma); + adaptiveResults.clear(); + adaptiveResults.setMaxSize(rerankK); + adaptiveResultMembers.clear(); + candidates.foreach((node, score) -> { + if (accepted.get(node)) offerAdaptiveResult(node, score, rerankK); + }); + } + + private void offerAdaptiveResult(int node, float score, int rerankK) { + if (adaptiveResultMembers.contains(node)) return; + if (adaptiveResults.size() < rerankK) { + adaptiveResults.push(node, score); + adaptiveResultMembers.add(node); + } else if (score > adaptiveResults.topScore()) { + int evicted = adaptiveResults.topNode(); + adaptiveResults.push(node, score); + adaptiveResultMembers.remove(evicted); + adaptiveResultMembers.add(node); + } + } + @SuppressWarnings("StatementWithEmptyBody") private void addTopCandidate(int topCandidateNode, float topCandidateScore, int rerankK) { // add the new node to the results queue, and any evicted node to evictedResults in case we resume later diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/graph/similarity/DefaultSearchScoreProvider.java b/jvector-base/src/main/java/io/github/jbellis/jvector/graph/similarity/DefaultSearchScoreProvider.java index de46762b2..ccdc0f27c 100644 --- a/jvector-base/src/main/java/io/github/jbellis/jvector/graph/similarity/DefaultSearchScoreProvider.java +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/graph/similarity/DefaultSearchScoreProvider.java @@ -24,6 +24,7 @@ public final class DefaultSearchScoreProvider implements SearchScoreProvider { private final ScoreFunction scoreFunction; private final ScoreFunction.ExactScoreFunction reranker; + private final boolean adaptiveDotProductTermination; /** * @param scoreFunction the primary, fast scoring function @@ -44,11 +45,19 @@ public DefaultSearchScoreProvider(ScoreFunction scoreFunction) { * or `ScoringView.rerankerFor`. */ public DefaultSearchScoreProvider(ScoreFunction scoreFunction, ScoreFunction.ExactScoreFunction reranker) { + this(scoreFunction, reranker, false); + } + + public DefaultSearchScoreProvider(ScoreFunction scoreFunction, ScoreFunction.ExactScoreFunction reranker, boolean adaptiveDotProductTermination) { assert scoreFunction != null; this.scoreFunction = scoreFunction; this.reranker = reranker; + this.adaptiveDotProductTermination = adaptiveDotProductTermination; } + @Override + public boolean supportsAdaptiveDotProductTermination() { return adaptiveDotProductTermination; } + public ScoreFunction scoreFunction() { return scoreFunction; } diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/graph/similarity/SearchScoreProvider.java b/jvector-base/src/main/java/io/github/jbellis/jvector/graph/similarity/SearchScoreProvider.java index 4122f7105..68dc8c08c 100644 --- a/jvector-base/src/main/java/io/github/jbellis/jvector/graph/similarity/SearchScoreProvider.java +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/graph/similarity/SearchScoreProvider.java @@ -24,4 +24,7 @@ public interface SearchScoreProvider { ScoreFunction.ExactScoreFunction reranker(); ScoreFunction.ExactScoreFunction exactScoreFunction(); + + /** Approximate scores use graph DOT_PRODUCT similarity and support adaptive termination. */ + default boolean supportsAdaptiveDotProductTermination() { return false; } } \ No newline at end of file diff --git a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/Grid.java b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/Grid.java index 471e6d30b..ec141bf7a 100644 --- a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/Grid.java +++ b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/Grid.java @@ -1402,7 +1402,7 @@ public SearchScoreProvider scoreProviderFor(VectorFloat queryVector, Immutabl asf = cv.precomputedScoreFunctionFor(queryVector, ds.getSimilarityFunction()); } var rr = scoringView.rerankerFor(queryVector, ds.getSimilarityFunction()); - return new DefaultSearchScoreProvider(asf, rr); + return new DefaultSearchScoreProvider(asf, rr, features.contains(FeatureId.FUSED_ASH) || cv instanceof ASHVectors); } public GraphSearcher getSearcher() { diff --git a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/IPCService.java b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/IPCService.java index 92782c316..599369ddb 100644 --- a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/IPCService.java +++ b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/IPCService.java @@ -275,7 +275,7 @@ String search(String input, SessionContext ctx) { var rr = view instanceof ImmutableGraphIndex.ScoringView ? ((ImmutableGraphIndex.ScoringView) view).rerankerFor(queryVector, ctx.similarityFunction) : ctx.ravv.rerankerFor(queryVector, ctx.similarityFunction); - var ssp = new DefaultSearchScoreProvider(sf, rr); + var ssp = new DefaultSearchScoreProvider(sf, rr, ctx.cv instanceof io.github.jbellis.jvector.quantization.ASHVectors); r = new GraphSearcher(ctx.index).search(ssp, searchEf, Bits.ALL); } catch (Exception e) { throw new RuntimeException(e); diff --git a/jvector-tests/src/test/java/io/github/jbellis/jvector/graph/AdaptiveTerminationTest.java b/jvector-tests/src/test/java/io/github/jbellis/jvector/graph/AdaptiveTerminationTest.java index 550bcace5..347c64121 100644 --- a/jvector-tests/src/test/java/io/github/jbellis/jvector/graph/AdaptiveTerminationTest.java +++ b/jvector-tests/src/test/java/io/github/jbellis/jvector/graph/AdaptiveTerminationTest.java @@ -1,248 +1,46 @@ -/* - * 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.util.BoundedLongHeap; import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.*; -import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.junit.jupiter.api.Assertions.assertTrue; - -/** - * Tests adaptive early termination based on per-query result-set discovery. - * - *

    The tracker models the "Patience in Proximity" idea: terminate only after - * the tracked nearest-neighbor set stops changing for a sustained period. The - * adaptive variant uses per-query discovery-rate statistics to decide saturation - * and patience.

    - */ class AdaptiveTerminationTest { - private static final int K = 10; - - @Test - void resetRejectsZeroK() { - AdaptiveTermination tracker = new AdaptiveTermination(); - - assertThrows(IllegalArgumentException.class, () -> tracker.reset(0)); - } - - @Test - void resetRejectsNegativeK() { - AdaptiveTermination tracker = new AdaptiveTermination(); - - assertThrows(IllegalArgumentException.class, () -> tracker.reset(-1)); - } - - @Test - void doesNotTerminateWithoutObservations() { - AdaptiveTermination tracker = new AdaptiveTermination(); - tracker.reset(K); - - assertFalse(tracker.shouldTerminate()); - } - - @Test - void oneObservationIsInsufficient() { - AdaptiveTermination tracker = new AdaptiveTermination(); - tracker.reset(K); - - tracker.observe(0, 1, true); - - assertFalse(tracker.shouldTerminate()); - } - - @Test - void steadyHealthyDiscoveryDoesNotTerminate() { - AdaptiveTermination tracker = new AdaptiveTermination(); - tracker.reset(K); - - for (int i = 0; i < 200; i++) { - tracker.observe(K, 1, true); - assertFalse(tracker.shouldTerminate()); - } - } - - @Test - void largeKSteadyHealthyDiscoveryDoesNotTerminate() { - int k = 1_000; - AdaptiveTermination tracker = new AdaptiveTermination(); - tracker.reset(k); - - for (int i = 0; i < 200; i++) { - tracker.observe(k, 1, true); - assertFalse(tracker.shouldTerminate()); - } - } - - @Test - void terminationRequiresResultSetFull() { - AdaptiveTermination tracker = new AdaptiveTermination(); - tracker.reset(K); - - seedHealthyDiscovery(tracker, K); - - boolean terminated = observeUntilTerminated( - tracker, - 0, - 1, - false, - 200 - ); - - assertFalse(terminated); - } - - @Test - void terminatesWhenDiscoveryDriesUpAfterResultSetIsFull() { - AdaptiveTermination tracker = new AdaptiveTermination(); - tracker.reset(K); - - seedHealthyDiscovery(tracker, K); - - boolean terminated = observeUntilTerminated( - tracker, - 0, - 1, - true, - 200 - ); - - assertTrue(terminated); - } - - @Test - void terminatesWhenDryDiscoveryIsReportedAcrossMultipleSteps() { - AdaptiveTermination tracker = new AdaptiveTermination(); - tracker.reset(K); - - seedHealthyDiscovery(tracker, K); - - boolean terminated = observeUntilTerminated( - tracker, - 0, - 5, - true, - 200 - ); - - assertTrue(terminated); - } - - @Test - void resetClearsPriorTerminationState() { - AdaptiveTermination tracker = new AdaptiveTermination(); - tracker.reset(K); - - seedHealthyDiscovery(tracker, K); - assertTrue(observeUntilTerminated(tracker, 0, 1, true, 200)); - - tracker.reset(K); - - assertFalse(tracker.shouldTerminate()); - tracker.observe(0, 1, true); - assertFalse(tracker.shouldTerminate()); - } - - @Test - void terminationIsRepeatableAfterReset() { - AdaptiveTermination tracker = new AdaptiveTermination(); - tracker.reset(K); - - seedHealthyDiscovery(tracker, K); - assertTrue(observeUntilTerminated(tracker, 0, 1, true, 200)); - - tracker.reset(K); - - seedHealthyDiscovery(tracker, K); - assertTrue(observeUntilTerminated(tracker, 0, 1, true, 200)); - } - - @Test - void observeGuardsInvalidInputs() { - AdaptiveTermination tracker = new AdaptiveTermination(); - tracker.reset(K); - - assertDoesNotThrow(() -> { - for (int i = 0; i < 100; i++) { - tracker.observe(-10, 0, true); - } - }); - } - - - - @Test - void lowVarianceRequiresMorePatienceThanHighVariance() { - AdaptiveTermination stableTracker = new AdaptiveTermination(); - stableTracker.reset(K); - for (int i = 0; i < 100; i++) { - stableTracker.observe(5, 1, true); - } - - AdaptiveTermination chaoticTracker = new AdaptiveTermination(); - chaoticTracker.reset(K); - for (int i = 0; i < 100; i++) { - chaoticTracker.observe(i % 2 == 0 ? K : 0, 1, true); - } - - // Force a non-saturated visit to synchronize state - stableTracker.observe(K, 1, true); - chaoticTracker.observe(K, 1, true); - - int stableSteps = 0; - while (!stableTracker.shouldTerminate() && stableSteps < 50) { - stableTracker.observe(0, 1, true); - stableSteps++; - } - - int chaoticSteps = 0; - while (!chaoticTracker.shouldTerminate() && chaoticSteps < 50) { - chaoticTracker.observe(0, 1, true); - chaoticSteps++; - } - - assertTrue(stableTracker.shouldTerminate()); - assertTrue(chaoticTracker.shouldTerminate()); - assertTrue(chaoticSteps < stableSteps, - "High variance should reduce patience, terminating faster than a stable history"); - } - - private static void seedHealthyDiscovery(AdaptiveTermination tracker, int k) { - for (int i = 0; i < 20; i++) { - tracker.observe(k, 1, true); - assertFalse(tracker.shouldTerminate()); - } - } - - - private static boolean observeUntilTerminated( - AdaptiveTermination tracker, - int introduced, - int steps, - boolean resultSetFull, - int maxObservations - ) { - for (int i = 0; i < maxObservations; i++) { - tracker.observe(introduced, steps, resultSetFull); - if (tracker.shouldTerminate()) { - return true; - } - } - return false; + private static NodeQueue results(float... scores) { + NodeQueue q = new NodeQueue(new BoundedLongHeap(scores.length), NodeQueue.Order.MIN_HEAP); + for (int i = 0; i < scores.length; i++) q.push(i, scores[i]); + return q; + } + + @Test void requiresKResults() { + var termination = new AdaptiveTermination(); + termination.reset(2, 0.1f); + assertEquals(AdaptiveTermination.Decision.CONTINUE, + termination.shouldTerminate(0.1f, results(0.9f))); + } + + @Test void usesDotProductDistanceMargin() { + var termination = new AdaptiveTermination(); + termination.reset(1, 0.1f); + NodeQueue q = results(0.9f); + assertEquals(AdaptiveTermination.Decision.CONTINUE, termination.shouldTerminate(0.89f, q)); + assertEquals(AdaptiveTermination.Decision.TERMINATE, termination.shouldTerminate(0.88f, q)); + } + + @Test void invalidApproximateScoresFallBack() { + var termination = new AdaptiveTermination(); + termination.reset(1); + assertEquals(AdaptiveTermination.Decision.FALL_BACK_TO_STANDARD, + termination.shouldTerminate(1.1f, results(0.9f))); + assertEquals(AdaptiveTermination.Decision.FALL_BACK_TO_STANDARD, + termination.shouldTerminate(0.1f, results(0.9f))); + } + + @Test void resetClearsFallbackState() { + var termination = new AdaptiveTermination(); + termination.reset(1); + termination.shouldTerminate(Float.NaN, results(0.9f)); + termination.reset(1, 0.1f); + assertEquals(AdaptiveTermination.Decision.TERMINATE, + termination.shouldTerminate(0.88f, results(0.9f))); } } diff --git a/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestASHGraphSearch.java b/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestASHGraphSearch.java index 44d817f3b..4a4c663eb 100644 --- a/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestASHGraphSearch.java +++ b/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestASHGraphSearch.java @@ -86,7 +86,7 @@ public void negativeEntryReachesBaseLayerWithStandaloneAndFusedScoring() throws try (var searcher = new GraphSearcher(graph)) { searcher.usePruning(false); var scores = new DefaultSearchScoreProvider(standalone, - n -> VectorSimilarityFunction.DOT_PRODUCT.compare(query, input[n])); + n -> VectorSimilarityFunction.DOT_PRODUCT.compare(query, input[n]), true); assertEquals(10, searcher.search(scores, 10, 10, 0f, 0f, Bits.ALL).getNodes().length); } for (int n = 0; n < input.length; n++) { @@ -99,7 +99,7 @@ public void negativeEntryReachesBaseLayerWithStandaloneAndFusedScoring() throws var fused = view.approximateScoreFunctionFor(query, VectorSimilarityFunction.DOT_PRODUCT); assertEquals(standalone.similarityTo(0), fused.similarityTo(0), 0.00005f); var scores = new DefaultSearchScoreProvider(fused, - view.rerankerFor(query, VectorSimilarityFunction.DOT_PRODUCT)); + view.rerankerFor(query, VectorSimilarityFunction.DOT_PRODUCT), true); assertEquals(10, searcher.search(scores, 10, 10, 0f, 0f, Bits.ALL).getNodes().length); fused.enableSimilarityToNeighbors(0); var neighbors = view.getNeighborsIterator(0, 0); From 5da79108185a2b63427831a9006869132119ec92 Mon Sep 17 00:00:00 2001 From: Ted Willke Date: Wed, 16 Sep 2026 22:25:54 +0000 Subject: [PATCH 057/243] Enable adaptive termination for ASH construction scores --- .../jvector/graph/AdaptiveTermination.java | 18 ++++++++++-------- .../jbellis/jvector/graph/GraphSearcher.java | 2 +- .../graph/similarity/BuildScoreProvider.java | 4 ++-- .../similarity/DefaultSearchScoreProvider.java | 11 ++++++++++- .../graph/similarity/SearchScoreProvider.java | 3 +++ .../jvector/graph/AdaptiveTerminationTest.java | 8 ++++++++ 6 files changed, 34 insertions(+), 12 deletions(-) diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/graph/AdaptiveTermination.java b/jvector-base/src/main/java/io/github/jbellis/jvector/graph/AdaptiveTermination.java index 41cb10d24..1370e8d3f 100644 --- a/jvector-base/src/main/java/io/github/jbellis/jvector/graph/AdaptiveTermination.java +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/graph/AdaptiveTermination.java @@ -31,6 +31,7 @@ enum Decision { private int k; private float gamma; private boolean unsupportedScoreObserved; + private boolean rawDotProductScores; AdaptiveTermination() { reset(1, DEFAULT_GAMMA); @@ -41,6 +42,10 @@ void reset(int k) { } void reset(int k, float gamma) { + reset(k, gamma, false); + } + + void reset(int k, float gamma, boolean rawDotProductScores) { if (k <= 0) { throw new IllegalArgumentException("k must be > 0"); } @@ -49,6 +54,7 @@ void reset(int k, float gamma) { this.k = k; this.gamma = gamma; this.unsupportedScoreObserved = false; + this.rawDotProductScores = rawDotProductScores; } static void validateGamma(float gamma) { @@ -90,18 +96,14 @@ Decision shouldTerminate(float candidateScore, NodeQueue discoveredResults) { : Decision.CONTINUE; } - private static double scoreToDistance(float score) { + private double scoreToDistance(float score) { if (!Float.isFinite(score)) { return Double.NaN; } - if (score < MIN_SCORE - EPSILON || score > MAX_SCORE + EPSILON) { + if (score < (rawDotProductScores ? -1.0d : MIN_SCORE) - EPSILON || score > MAX_SCORE + EPSILON) { return Double.NaN; } - if (score <= EPSILON) { - return 1.0d; - } - - double boundedScore = Math.min(MAX_SCORE, Math.max(MIN_SCORE, score)); - return 1.0d - boundedScore; + double boundedScore = Math.min(MAX_SCORE, Math.max(rawDotProductScores ? -1.0d : MIN_SCORE, score)); + return rawDotProductScores ? (1.0d - boundedScore) / 2.0d : 1.0d - boundedScore; } } 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 178e6d67e..e7f995f77 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 @@ -545,7 +545,7 @@ SearchResult resume(int topK, int rerankK, float threshold, float rerankFloor) { } private void resetAdaptiveTermination(int rerankK, Bits accepted) { - adaptiveTermination.reset(rerankK, adaptiveTerminationGamma); + adaptiveTermination.reset(rerankK, adaptiveTerminationGamma, scoreProvider.adaptiveScoresAreRawDotProducts()); adaptiveResults.clear(); adaptiveResults.setMaxSize(rerankK); adaptiveResultMembers.clear(); diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/graph/similarity/BuildScoreProvider.java b/jvector-base/src/main/java/io/github/jbellis/jvector/graph/similarity/BuildScoreProvider.java index 8dc577c72..493ad73fa 100644 --- a/jvector-base/src/main/java/io/github/jbellis/jvector/graph/similarity/BuildScoreProvider.java +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/graph/similarity/BuildScoreProvider.java @@ -316,7 +316,7 @@ public VectorFloat approximateCentroid() { @Override public SearchScoreProvider searchProviderFor(VectorFloat vector) { // Query-time asymmetric scoring is already implemented by ASHVectors. - return new DefaultSearchScoreProvider(ashv.precomputedScoreFunctionFor(vector, vsf)); + return new DefaultSearchScoreProvider(ashv.precomputedScoreFunctionFor(vector, vsf), null, true); } @Override @@ -370,7 +370,7 @@ public float similarityTo(int node2) { // so centered term becomes: scale_x * scale_y * return (v1.scale * v2.scale) * signDot + v1.offset + v2.offset + muNormSq; } - }); + }, null, true, true); } @Override diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/graph/similarity/DefaultSearchScoreProvider.java b/jvector-base/src/main/java/io/github/jbellis/jvector/graph/similarity/DefaultSearchScoreProvider.java index ccdc0f27c..87509dc4f 100644 --- a/jvector-base/src/main/java/io/github/jbellis/jvector/graph/similarity/DefaultSearchScoreProvider.java +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/graph/similarity/DefaultSearchScoreProvider.java @@ -25,6 +25,7 @@ public final class DefaultSearchScoreProvider implements SearchScoreProvider { private final ScoreFunction scoreFunction; private final ScoreFunction.ExactScoreFunction reranker; private final boolean adaptiveDotProductTermination; + private final boolean adaptiveScoresAreRawDotProducts; /** * @param scoreFunction the primary, fast scoring function @@ -45,19 +46,27 @@ public DefaultSearchScoreProvider(ScoreFunction scoreFunction) { * or `ScoringView.rerankerFor`. */ public DefaultSearchScoreProvider(ScoreFunction scoreFunction, ScoreFunction.ExactScoreFunction reranker) { - this(scoreFunction, reranker, false); + this(scoreFunction, reranker, false, false); } public DefaultSearchScoreProvider(ScoreFunction scoreFunction, ScoreFunction.ExactScoreFunction reranker, boolean adaptiveDotProductTermination) { + this(scoreFunction, reranker, adaptiveDotProductTermination, false); + } + + public DefaultSearchScoreProvider(ScoreFunction scoreFunction, ScoreFunction.ExactScoreFunction reranker, boolean adaptiveDotProductTermination, boolean adaptiveScoresAreRawDotProducts) { assert scoreFunction != null; this.scoreFunction = scoreFunction; this.reranker = reranker; this.adaptiveDotProductTermination = adaptiveDotProductTermination; + this.adaptiveScoresAreRawDotProducts = adaptiveScoresAreRawDotProducts; } @Override public boolean supportsAdaptiveDotProductTermination() { return adaptiveDotProductTermination; } + @Override + public boolean adaptiveScoresAreRawDotProducts() { return adaptiveScoresAreRawDotProducts; } + public ScoreFunction scoreFunction() { return scoreFunction; } diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/graph/similarity/SearchScoreProvider.java b/jvector-base/src/main/java/io/github/jbellis/jvector/graph/similarity/SearchScoreProvider.java index 68dc8c08c..ab7d493dd 100644 --- a/jvector-base/src/main/java/io/github/jbellis/jvector/graph/similarity/SearchScoreProvider.java +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/graph/similarity/SearchScoreProvider.java @@ -27,4 +27,7 @@ public interface SearchScoreProvider { /** Approximate scores use graph DOT_PRODUCT similarity and support adaptive termination. */ default boolean supportsAdaptiveDotProductTermination() { return false; } + + /** Whether the supported ASH score is a raw dot product in [-1, 1]. */ + default boolean adaptiveScoresAreRawDotProducts() { return false; } } \ No newline at end of file diff --git a/jvector-tests/src/test/java/io/github/jbellis/jvector/graph/AdaptiveTerminationTest.java b/jvector-tests/src/test/java/io/github/jbellis/jvector/graph/AdaptiveTerminationTest.java index 347c64121..8b0102eed 100644 --- a/jvector-tests/src/test/java/io/github/jbellis/jvector/graph/AdaptiveTerminationTest.java +++ b/jvector-tests/src/test/java/io/github/jbellis/jvector/graph/AdaptiveTerminationTest.java @@ -43,4 +43,12 @@ private static NodeQueue results(float... scores) { assertEquals(AdaptiveTermination.Decision.TERMINATE, termination.shouldTerminate(0.88f, results(0.9f))); } + @Test void rawDotProductBuildScoresUseTheSameDistanceRule() { + var t = new AdaptiveTermination(); + t.reset(1, 0.1f, true); + assertEquals(AdaptiveTermination.Decision.CONTINUE, t.shouldTerminate(0.78f, results(0.8f))); + assertEquals(AdaptiveTermination.Decision.TERMINATE, t.shouldTerminate(0.76f, results(0.8f))); + assertEquals(AdaptiveTermination.Decision.FALL_BACK_TO_STANDARD, t.shouldTerminate(-1.1f, results(0.8f))); + } + } From a9d4064064f237c7291885d68f17f28e99b01b09 Mon Sep 17 00:00:00 2001 From: Ted Willke Date: Thu, 17 Sep 2026 04:35:07 +0000 Subject: [PATCH 058/243] Parallelize ASH ITQ training encoding --- .../quantization/AsymmetricHashing.java | 63 ++++++++++--------- 1 file changed, 34 insertions(+), 29 deletions(-) diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/AsymmetricHashing.java b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/AsymmetricHashing.java index 0bbbb7de3..600d0d306 100644 --- a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/AsymmetricHashing.java +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/AsymmetricHashing.java @@ -36,6 +36,7 @@ import java.util.Objects; import java.util.concurrent.ForkJoinPool; import java.util.Random; +import java.util.stream.IntStream; import org.apache.commons.math3.linear.RealMatrix; import org.apache.commons.math3.linear.Array2DRowRealMatrix; import org.netlib.util.intW; @@ -2209,39 +2210,43 @@ private static void encodeTrainingDirections( final int excessBits = bitsPerDimension - 1; final int maxCode = (1 << excessBits) - 1; - double[] absNorm = new double[dims]; - double[] mag = new double[dims]; - - for (int r = 0; r < rows; r++) { - double norm2 = 0.0; - for (int j = 0; j < dims; j++) { - double v = xCol[r + j * rows]; - norm2 += v * v; - } - - double invNorm = 1.0 / Math.sqrt(Math.max(norm2, 1e-20)); - - for (int j = 0; j < dims; j++) { - absNorm[j] = Math.abs(xCol[r + j * rows]) * invNorm; - } - - double t = computeOptimalScalingFactor(absNorm, dims, bitsPerDimension); + // Each task owns a contiguous range of training rows and its scratch arrays. + // computeOptimalScalingFactor already keeps its event buffers thread-local. + final int workers = Math.min(rows, Math.min(16, Runtime.getRuntime().availableProcessors())); + IntStream.range(0, workers).parallel().forEach(worker -> { + double[] absNorm = new double[dims]; + double[] mag = new double[dims]; + int start = worker * rows / workers; + int end = (worker + 1) * rows / workers; + + for (int r = start; r < end; r++) { + double norm2 = 0.0; + for (int j = 0; j < dims; j++) { + double v = xCol[r + j * rows]; + norm2 += v * v; + } - double encodedNorm2 = 0.0; - for (int j = 0; j < dims; j++) { - int q = Math.min((int) (t * absNorm[j] + K_EPS), maxCode); - double m = q + 0.5; - mag[j] = m; - encodedNorm2 += m * m; - } + double invNorm = 1.0 / Math.sqrt(Math.max(norm2, 1e-20)); + for (int j = 0; j < dims; j++) { + absNorm[j] = Math.abs(xCol[r + j * rows]) * invNorm; + } - double encodedInvNorm = 1.0 / Math.sqrt(Math.max(encodedNorm2, 1e-20)); + double t = computeOptimalScalingFactor(absNorm, dims, bitsPerDimension); + double encodedNorm2 = 0.0; + for (int j = 0; j < dims; j++) { + int q = Math.min((int) (t * absNorm[j] + K_EPS), maxCode); + double m = q + 0.5; + mag[j] = m; + encodedNorm2 += m * m; + } - for (int j = 0; j < dims; j++) { - double signed = xCol[r + j * rows] < 0.0 ? -mag[j] : mag[j]; - outCol[r + j * rows] = signed * encodedInvNorm; + double encodedInvNorm = 1.0 / Math.sqrt(Math.max(encodedNorm2, 1e-20)); + for (int j = 0; j < dims; j++) { + double signed = xCol[r + j * rows] < 0.0 ? -mag[j] : mag[j]; + outCol[r + j * rows] = signed * encodedInvNorm; + } } - } + }); } private static void writeStiefelTransform(IndexWriter out, StiefelTransform st) throws IOException { From d2d91684a438e0a8f7be5d3650dd73b701010baf Mon Sep 17 00:00:00 2001 From: Ted Willke Date: Thu, 17 Sep 2026 06:33:02 +0000 Subject: [PATCH 059/243] Use divide-and-conquer SVD for ASH ITQ polar steps --- .../jvector/quantization/AsymmetricHashing.java | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/AsymmetricHashing.java b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/AsymmetricHashing.java index 600d0d306..b2038483e 100644 --- a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/AsymmetricHashing.java +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/AsymmetricHashing.java @@ -2133,23 +2133,24 @@ private static double[] orthogonalize(double[] mCol, int rows, int cols) { double[] vt = new double[k * cols]; double[] s = new double[k]; intW info = new intW(0); + int[] iwork = new int[8 * k]; // Workspace query double[] a = mCol.clone(); // dgesvd overwrites input double[] workQuery = new double[1]; - LAPACK.getInstance().dgesvd( - "S", "S", + LAPACK.getInstance().dgesdd( + "S", rows, cols, a, rows, s, u, rows, vt, k, - workQuery, -1, + workQuery, -1, iwork, info ); if (info.val != 0) { - throw new RuntimeException("LAPACK dgesvd workspace query failed with info=" + info.val); + throw new RuntimeException("LAPACK dgesdd workspace query failed with info=" + info.val); } int lwork = (int) workQuery[0]; @@ -2157,19 +2158,19 @@ private static double[] orthogonalize(double[] mCol, int rows, int cols) { // Actual call a = mCol.clone(); - LAPACK.getInstance().dgesvd( - "S", "S", + LAPACK.getInstance().dgesdd( + "S", rows, cols, a, rows, s, u, rows, vt, k, - work, lwork, + work, lwork, iwork, info ); if (info.val != 0) { - throw new RuntimeException("LAPACK dgesvd failed with info=" + info.val); + throw new RuntimeException("LAPACK dgesdd failed with info=" + info.val); } return nativeMultiply(u, rows, k, vt, cols); From 990c5aaf3b44f5f5a397a6d708fb1a095a62c687 Mon Sep 17 00:00:00 2001 From: Ted Willke Date: Thu, 17 Sep 2026 18:31:40 +0000 Subject: [PATCH 060/243] Set ASH adaptive termination default gamma to 0.01 --- .../io/github/jbellis/jvector/graph/AdaptiveTermination.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/graph/AdaptiveTermination.java b/jvector-base/src/main/java/io/github/jbellis/jvector/graph/AdaptiveTermination.java index 1370e8d3f..1d0983e96 100644 --- a/jvector-base/src/main/java/io/github/jbellis/jvector/graph/AdaptiveTermination.java +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/graph/AdaptiveTermination.java @@ -16,7 +16,7 @@ * outside the supported range, callers must fall back to standard termination. */ final class AdaptiveTermination { - static final float DEFAULT_GAMMA = 0.005f; + static final float DEFAULT_GAMMA = 0.01f; private static final double MIN_SCORE = 0.0d; private static final double MAX_SCORE = 1.0d; From d34afca89e37872da5a174eac5fe1d86a2ebaad7 Mon Sep 17 00:00:00 2001 From: Ted Willke Date: Thu, 17 Sep 2026 18:45:54 +0000 Subject: [PATCH 061/243] Resize ASH quantization workspace for each vector shape --- .../jbellis/jvector/quantization/AsymmetricHashing.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/AsymmetricHashing.java b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/AsymmetricHashing.java index b2038483e..49df51690 100644 --- a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/AsymmetricHashing.java +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/AsymmetricHashing.java @@ -787,12 +787,12 @@ private static final class Workspace { double[] absNorm; void ensureCapacity(int originalDim, int quantizedDim) { - if (x == null || x.length < originalDim) { + if (x == null || x.length != originalDim) { x = new float[originalDim]; muArr = new float[originalDim]; xhat = new float[originalDim]; } - if (proj == null || proj.length < quantizedDim) { + if (proj == null || proj.length != quantizedDim) { proj = new float[quantizedDim]; absNorm = new double[quantizedDim]; } From 7a08308bb11d067e54205ec9de3fc044cdac5188 Mon Sep 17 00:00:00 2001 From: Ted Willke Date: Thu, 17 Sep 2026 19:46:54 +0000 Subject: [PATCH 062/243] Use 20x ASH ITQ training sample with early stopping --- .../jbellis/jvector/quantization/AsymmetricHashing.java | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/AsymmetricHashing.java b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/AsymmetricHashing.java index 49df51690..64466f291 100644 --- a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/AsymmetricHashing.java +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/AsymmetricHashing.java @@ -58,7 +58,7 @@ public class AsymmetricHashing implements VectorCompressor= 8 && recentLosses[(epoch - 3) % 3] - loss.loss <= 0.01 * loss.loss) { + logProgress("ITQ early stop at epoch " + epoch); + break; + } + recentLosses[epoch % 3] = loss.loss; } // Final polar step after the last update. From a88033ac62c1a1e82910fd8ecb76dee323ce9010 Mon Sep 17 00:00:00 2001 From: Ted Willke Date: Thu, 17 Sep 2026 19:47:07 +0000 Subject: [PATCH 063/243] Align ASH benchmark configs at 32x and retain PQ alternatives --- .../yaml-configs/dataset-metadata.yml | 17 ++++- jvector-examples/yaml-configs/datasets.yml | 20 ++--- .../index-parameters/ada002-1M.yml | 32 ++++---- .../yaml-configs/index-parameters/cap-10M.yml | 40 ++++++---- .../yaml-configs/index-parameters/cap-1M.yml | 76 +++++++------------ .../yaml-configs/index-parameters/cap-6M.yml | 36 ++++----- .../index-parameters/cohere-10M.yml | 36 ++++----- .../cohere-english-v3-10M.yml | 40 ++++++---- .../index-parameters/cohere-english-v3-1M.yml | 50 +++++++----- .../index-parameters/colbert-1M.yml | 30 +++++++- .../yaml-configs/index-parameters/default.yml | 53 ++++++------- .../index-parameters/dpr-gemma-10M.yml | 43 ++++++----- .../index-parameters/dpr-gemma-1M.yml | 63 +++++---------- .../index-parameters/esmc-600-1M.yml | 63 +++++---------- .../index-parameters/esmc-600-6M.yml | 63 +++++---------- .../index-parameters/gecko-100k.yml | 68 +++++------------ .../index-parameters/glove-100-angular.yml | 28 ++++++- .../index-parameters/glove-200-angular.yml | 28 ++++++- .../index-parameters/glove-25-angular.yml | 28 ++++++- .../index-parameters/glove-50-angular.yml | 28 ++++++- .../index-parameters/lastfm-64-dot.yml | 28 ++++++- .../index-parameters/mpnet-1M.yml | 63 +++++---------- .../index-parameters/nytimes-256-angular.yml | 28 ++++++- .../index-parameters/openai-v3-small-100k.yml | 63 +++++---------- .../index-parameters/personahub-1M.yml | 63 +++++---------- .../index-parameters/sift-128-euclidean.yml | 14 +++- .../index-parameters/tahoe-x1-1M.yml | 63 +++++---------- jvector-examples/yaml-configs/run-config.yml | 55 ++++++-------- 28 files changed, 612 insertions(+), 607 deletions(-) diff --git a/jvector-examples/yaml-configs/dataset-metadata.yml b/jvector-examples/yaml-configs/dataset-metadata.yml index 1920d4c75..914a486c6 100644 --- a/jvector-examples/yaml-configs/dataset-metadata.yml +++ b/jvector-examples/yaml-configs/dataset-metadata.yml @@ -115,4 +115,19 @@ openai-3072-1m: load_behavior: NO_SCRUB openai-1536-1m: similarity_function: DOT_PRODUCT - load_behavior: NO_SCRUB \ No newline at end of file + load_behavior: NO_SCRUB +cap-10M: + similarity_function: DOT_PRODUCT + load_behavior: NO_SCRUB + +cohere-10M: + similarity_function: DOT_PRODUCT + load_behavior: NO_SCRUB + +esmc-600-1M: + similarity_function: DOT_PRODUCT + load_behavior: NO_SCRUB + +esmc-600-6M: + similarity_function: DOT_PRODUCT + load_behavior: NO_SCRUB diff --git a/jvector-examples/yaml-configs/datasets.yml b/jvector-examples/yaml-configs/datasets.yml index 8a6be912a..7ec47f6ef 100644 --- a/jvector-examples/yaml-configs/datasets.yml +++ b/jvector-examples/yaml-configs/datasets.yml @@ -1,15 +1,15 @@ jvector-100k: -# - ada002-100k -# - openai-v3-large-3072-100k -# - openai-v3-large-1536-100k -# - openai-v3-small-1536-100k -# - gecko-100k -# - cohere-english-v3-100k -# - e5-small-v2-100k -# - e5-base-v2-100k -# - e5-large-v2-100k + - ada002-100k + - openai-v3-large-3072-100k + - openai-v3-large-1536-100k + - openai-v3-small-1536-100k + - gecko-100k + - cohere-english-v3-100k + - e5-small-v2-100k + - e5-base-v2-100k + - e5-large-v2-100k #jvector-1M: - - ada002-1M +# - ada002-1M # - colbert-1M #ann-benchmarks: # - glove-25-angular diff --git a/jvector-examples/yaml-configs/index-parameters/ada002-1M.yml b/jvector-examples/yaml-configs/index-parameters/ada002-1M.yml index c8b9b8223..f57ba9beb 100644 --- a/jvector-examples/yaml-configs/index-parameters/ada002-1M.yml +++ b/jvector-examples/yaml-configs/index-parameters/ada002-1M.yml @@ -1,3 +1,4 @@ +# ASH configuration for DOT_PRODUCT datasets only. yamlSchemaVersion: 2 onDiskIndexVersion: 7 @@ -6,43 +7,42 @@ dataset: ada002-1M construction: outDegree: [64] efConstruction: [200] - neighborOverflow: [1.6f] + neighborOverflow: [1.2f] addHierarchy: [Yes] refineFinalGraph: [Yes] fusedGraph: [Yes] compression: - type: ASH parameters: - encodedBits: 1576 - bitsPerDimension: 2 - optimizer: itq + compressionRatio: 32 + optimizer: ITQ landmarkCount: 1 + bitsPerDimension: 2 +# To use PQ, comment out ASH and uncomment PQ in both sections. # - type: PQ # parameters: -# m: 197 +# mFactor: 8 # k: 256 -# centerData: No # anisotropicThreshold: -1.0 reranking: - - FP - useSavedIndexIfExists: Yes # If yes, the system will attempt to locate a cached copy of the index instead of constructing + - NVQ + useSavedIndexIfExists: No # If yes, the system will attempt to locate a cached copy of the index instead of constructing search: topKOverquery: - 10: [1.0, 2.0, 3.0, 4.0] -# 100: [1.0] + 10: [1.0, 2.0, 5.0, 10.0] +# 100: [1.0, 2.0] useSearchPruning: [No] compression: - type: ASH parameters: - encodedBits: 1576 - bitsPerDimension: 2 - optimizer: itq + compressionRatio: 32 + optimizer: ITQ landmarkCount: 1 + bitsPerDimension: 2 +# To use PQ, comment out ASH and uncomment PQ in both sections. # - type: PQ # parameters: -# m: 197 +# mFactor: 8 # k: 256 -# centerData: No # anisotropicThreshold: -1.0 -# Run-level controls, such as benchmarks, console, and logging, are in run.yml. \ No newline at end of file diff --git a/jvector-examples/yaml-configs/index-parameters/cap-10M.yml b/jvector-examples/yaml-configs/index-parameters/cap-10M.yml index c9c6c09d8..99166a920 100644 --- a/jvector-examples/yaml-configs/index-parameters/cap-10M.yml +++ b/jvector-examples/yaml-configs/index-parameters/cap-10M.yml @@ -1,5 +1,5 @@ -yamlSchemaVersion: 1 -onDiskIndexVersion: 6 +yamlSchemaVersion: 2 +onDiskIndexVersion: 7 dataset: cap-10M useDatasetPrefetching: false @@ -12,13 +12,18 @@ construction: refineFinalGraph: [Yes] fusedGraph: [Yes] compression: - - type: PQ + - type: ASH parameters: - # m: 192 # we can either specify the integer m or the integer mFactor. In this case, m will be set to the data dimensionality divided by mFactor - mFactor: 8 - # k: 256 # optional parameter. By default, k=256 - centerData: No - anisotropicThreshold: -1.0 # optional parameter. By default, anisotropicThreshold=-1 (i.e., no anisotropy) + compressionRatio: 32 + optimizer: ITQ + landmarkCount: 1 + bitsPerDimension: 2 +# To use PQ, comment out ASH and uncomment PQ in both sections. +# - type: PQ +# parameters: +# mFactor: 8 +# k: 256 +# anisotropicThreshold: -1.0 reranking: - NVQ useSavedIndexIfExists: No @@ -26,12 +31,17 @@ construction: search: topKOverquery: 10: [1.0] - useSearchPruning: [Yes] + useSearchPruning: [No] compression: - - type: PQ + - type: ASH parameters: - # m: 192 - mFactor: 8 - # k: 256 # optional parameter. By default, k=256 - centerData: No - anisotropicThreshold: -1.0 # optional parameter. By default, anisotropicThreshold=-1 (i.e., no anisotropy) \ No newline at end of file + compressionRatio: 32 + optimizer: ITQ + landmarkCount: 1 + bitsPerDimension: 2 +# To use PQ, comment out ASH and uncomment PQ in both sections. +# - type: PQ +# parameters: +# mFactor: 8 +# k: 256 +# anisotropicThreshold: -1.0 diff --git a/jvector-examples/yaml-configs/index-parameters/cap-1M.yml b/jvector-examples/yaml-configs/index-parameters/cap-1M.yml index 9f07ca886..892b8f34d 100644 --- a/jvector-examples/yaml-configs/index-parameters/cap-1M.yml +++ b/jvector-examples/yaml-configs/index-parameters/cap-1M.yml @@ -1,3 +1,4 @@ +# ASH configuration for DOT_PRODUCT datasets only. yamlSchemaVersion: 2 onDiskIndexVersion: 7 @@ -6,67 +7,42 @@ dataset: cap-1M construction: outDegree: [64] efConstruction: [200] - neighborOverflow: [1.6f] - addHierarchy: [No] + neighborOverflow: [1.2f] + addHierarchy: [Yes] refineFinalGraph: [Yes] - fusedGraph: [No] + fusedGraph: [Yes] compression: - type: ASH parameters: - encodedBits: 1544 - optimizer: itq + compressionRatio: 32 + optimizer: ITQ landmarkCount: 1 - - type: ASH - parameters: - encodedBits: 776 - optimizer: itq - landmarkCount: 1 - - type: PQ - parameters: - m: 193 # we can either specify the integer m or the integer mFactor. In this case, m will be set to the data dimensionality divided by mFactor - # mFactor: 8 - # k: 256 # optional parameter. By default, k=256 - centerData: No - anisotropicThreshold: -1.0 # optional parameter. By default, anisotropicThreshold=-1 (i.e., no anisotropy) - - type: PQ - parameters: - m: 97 # we can either specify the integer m or the integer mFactor. In this case, m will be set to the data dimensionality divided by mFactor - # mFactor: 8 - # k: 256 # optional parameter. By default, k=256 - centerData: No - anisotropicThreshold: -1.0 # optional parameter. By default, anisotropicThreshold=-1 (i.e., no anisotropy) + bitsPerDimension: 2 +# To use PQ, comment out ASH and uncomment PQ in both sections. +# - type: PQ +# parameters: +# mFactor: 8 +# k: 256 +# anisotropicThreshold: -1.0 reranking: - - FP - useSavedIndexIfExists: Yes # If yes, the system will attempt to locate a cached copy of the index instead of constructing + - NVQ + useSavedIndexIfExists: No # If yes, the system will attempt to locate a cached copy of the index instead of constructing search: topKOverquery: - 10: [1.00] -# 100: [1.0] + 10: [1.0, 2.0, 5.0, 10.0] +# 100: [1.0, 2.0] useSearchPruning: [No] compression: - type: ASH parameters: - encodedBits: 1544 - optimizer: itq + compressionRatio: 32 + optimizer: ITQ landmarkCount: 1 - - type: ASH - parameters: - encodedBits: 776 - optimizer: itq - landmarkCount: 1 - - type: PQ - parameters: - m: 193 # we can either specify the integer m or the integer mFactor. In this case, m will be set to the data dimensionality divided by mFactor - # mFactor: 8 - # k: 256 # optional parameter. By default, k=256 - centerData: No - anisotropicThreshold: -1.0 # optional parameter. By default, anisotropicThreshold=-1 (i.e., no anisotropy) - - type: PQ - parameters: - m: 97 # we can either specify the integer m or the integer mFactor. In this case, m will be set to the data dimensionality divided by mFactor - # mFactor: 8 - # k: 256 # optional parameter. By default, k=256 - centerData: No - anisotropicThreshold: -1.0 # optional parameter. By default, anisotropicThreshold=-1 (i.e., no anisotropy) -# Run-level controls, such as benchmarks, console, and logging, are in run.yml. \ No newline at end of file + bitsPerDimension: 2 +# To use PQ, comment out ASH and uncomment PQ in both sections. +# - type: PQ +# parameters: +# mFactor: 8 +# k: 256 +# anisotropicThreshold: -1.0 diff --git a/jvector-examples/yaml-configs/index-parameters/cap-6M.yml b/jvector-examples/yaml-configs/index-parameters/cap-6M.yml index 122bf0c34..0e588f5f7 100644 --- a/jvector-examples/yaml-configs/index-parameters/cap-6M.yml +++ b/jvector-examples/yaml-configs/index-parameters/cap-6M.yml @@ -11,18 +11,18 @@ construction: refineFinalGraph: [Yes] fusedGraph: [No] compression: - - type: PQ - parameters: - m: 192 # we can either specify the integer m or the integer mFactor. In this case, m will be set to the data dimensionality divided by mFactor - # mFactor: 8 - # k: 256 # optional parameter. By default, k=256 - centerData: No - anisotropicThreshold: -1.0 # optional parameter. By default, anisotropicThreshold=-1 (i.e., no anisotropy) - type: ASH parameters: - encodedBits: 1536 - optimizer: itq + compressionRatio: 32 + optimizer: ITQ landmarkCount: 1 + bitsPerDimension: 2 +# To use PQ, comment out ASH and uncomment PQ in both sections. +# - type: PQ +# parameters: +# mFactor: 8 +# k: 256 +# anisotropicThreshold: -1.0 reranking: - FP useSavedIndexIfExists: Yes # If yes, the system will attempt to locate a cached copy of the index instead of constructing @@ -33,15 +33,15 @@ search: # 100: [1.0] useSearchPruning: [No] compression: - - type: PQ - parameters: - m: 192 - # k: 256 # optional parameter. By default, k=256 - centerData: No - anisotropicThreshold: -1.0 # optional parameter. By default, anisotropicThreshold=-1 (i.e., no anisotropy) - type: ASH parameters: - encodedBits: 1536 - optimizer: itq + compressionRatio: 32 + optimizer: ITQ landmarkCount: 1 -# Run-level controls, such as benchmarks, console, and logging, are in run.yml. \ No newline at end of file + bitsPerDimension: 2 +# To use PQ, comment out ASH and uncomment PQ in both sections. +# - type: PQ +# parameters: +# mFactor: 8 +# k: 256 +# anisotropicThreshold: -1.0 diff --git a/jvector-examples/yaml-configs/index-parameters/cohere-10M.yml b/jvector-examples/yaml-configs/index-parameters/cohere-10M.yml index 71f5da569..3a555acc0 100644 --- a/jvector-examples/yaml-configs/index-parameters/cohere-10M.yml +++ b/jvector-examples/yaml-configs/index-parameters/cohere-10M.yml @@ -11,18 +11,18 @@ construction: refineFinalGraph: [Yes] fusedGraph: [No] compression: - - type: PQ - parameters: - m: 128 # we can either specify the integer m or the integer mFactor. In this case, m will be set to the data dimensionality divided by mFactor - # mFactor: 8 - # k: 256 # optional parameter. By default, k=256 - centerData: No - anisotropicThreshold: -1.0 # optional parameter. By default, anisotropicThreshold=-1 (i.e., no anisotropy) - type: ASH parameters: - encodedBits: 1024 - optimizer: itq + compressionRatio: 32 + optimizer: ITQ landmarkCount: 1 + bitsPerDimension: 2 +# To use PQ, comment out ASH and uncomment PQ in both sections. +# - type: PQ +# parameters: +# mFactor: 8 +# k: 256 +# anisotropicThreshold: -1.0 reranking: - FP useSavedIndexIfExists: Yes # If yes, the system will attempt to locate a cached copy of the index instead of constructing @@ -33,15 +33,15 @@ search: # 100: [1.0] useSearchPruning: [No] compression: - - type: PQ - parameters: - m: 128 - # k: 256 # optional parameter. By default, k=256 - centerData: No - anisotropicThreshold: -1.0 # optional parameter. By default, anisotropicThreshold=-1 (i.e., no anisotropy) - type: ASH parameters: - encodedBits: 1024 - optimizer: itq + compressionRatio: 32 + optimizer: ITQ landmarkCount: 1 -# Run-level controls, such as benchmarks, console, and logging, are in run.yml. \ No newline at end of file + bitsPerDimension: 2 +# To use PQ, comment out ASH and uncomment PQ in both sections. +# - type: PQ +# parameters: +# mFactor: 8 +# k: 256 +# anisotropicThreshold: -1.0 diff --git a/jvector-examples/yaml-configs/index-parameters/cohere-english-v3-10M.yml b/jvector-examples/yaml-configs/index-parameters/cohere-english-v3-10M.yml index 17b3dd54a..c803d8264 100644 --- a/jvector-examples/yaml-configs/index-parameters/cohere-english-v3-10M.yml +++ b/jvector-examples/yaml-configs/index-parameters/cohere-english-v3-10M.yml @@ -1,5 +1,5 @@ -yamlSchemaVersion: 1 -onDiskIndexVersion: 6 +yamlSchemaVersion: 2 +onDiskIndexVersion: 7 dataset: cohere-english-v3-10M useDatasetPrefetching: false @@ -12,13 +12,18 @@ construction: refineFinalGraph: [Yes] fusedGraph: [Yes] compression: - - type: PQ + - type: ASH parameters: - # m: 192 # we can either specify the integer m or the integer mFactor. In this case, m will be set to the data dimensionality divided by mFactor - mFactor: 8 - # k: 256 # optional parameter. By default, k=256 - centerData: No - anisotropicThreshold: -1.0 # optional parameter. By default, anisotropicThreshold=-1 (i.e., no anisotropy) + compressionRatio: 32 + optimizer: ITQ + landmarkCount: 1 + bitsPerDimension: 2 +# To use PQ, comment out ASH and uncomment PQ in both sections. +# - type: PQ +# parameters: +# mFactor: 8 +# k: 256 +# anisotropicThreshold: -1.0 reranking: - NVQ useSavedIndexIfExists: No @@ -26,12 +31,17 @@ construction: search: topKOverquery: 10: [1.0] - useSearchPruning: [Yes] + useSearchPruning: [No] compression: - - type: PQ + - type: ASH parameters: - # m: 192 - mFactor: 8 - # k: 256 # optional parameter. By default, k=256 - centerData: No - anisotropicThreshold: -1.0 # optional parameter. By default, anisotropicThreshold=-1 (i.e., no anisotropy) \ No newline at end of file + compressionRatio: 32 + optimizer: ITQ + landmarkCount: 1 + bitsPerDimension: 2 +# To use PQ, comment out ASH and uncomment PQ in both sections. +# - type: PQ +# parameters: +# mFactor: 8 +# k: 256 +# anisotropicThreshold: -1.0 diff --git a/jvector-examples/yaml-configs/index-parameters/cohere-english-v3-1M.yml b/jvector-examples/yaml-configs/index-parameters/cohere-english-v3-1M.yml index 56fb2849f..d9b2b608c 100644 --- a/jvector-examples/yaml-configs/index-parameters/cohere-english-v3-1M.yml +++ b/jvector-examples/yaml-configs/index-parameters/cohere-english-v3-1M.yml @@ -1,37 +1,49 @@ -yamlSchemaVersion: 1 -onDiskIndexVersion: 6 +# ASH configuration for DOT_PRODUCT datasets only. +yamlSchemaVersion: 2 +onDiskIndexVersion: 7 dataset: cohere-english-v3-1M useDatasetPrefetching: false construction: - outDegree: [32] - efConstruction: [100] + outDegree: [64] + efConstruction: [200] neighborOverflow: [1.2f] addHierarchy: [Yes] refineFinalGraph: [Yes] fusedGraph: [Yes] compression: - - type: PQ + - type: ASH parameters: - # m: 192 # we can either specify the integer m or the integer mFactor. In this case, m will be set to the data dimensionality divided by mFactor - mFactor: 8 - # k: 256 # optional parameter. By default, k=256 - centerData: No - anisotropicThreshold: -1.0 # optional parameter. By default, anisotropicThreshold=-1 (i.e., no anisotropy) + compressionRatio: 32 + optimizer: ITQ + landmarkCount: 1 + bitsPerDimension: 2 +# To use PQ, comment out ASH and uncomment PQ in both sections. +# - type: PQ +# parameters: +# mFactor: 8 +# k: 256 +# anisotropicThreshold: -1.0 reranking: - NVQ - useSavedIndexIfExists: No + useSavedIndexIfExists: No # If yes, the system will attempt to locate a cached copy of the index instead of constructing search: topKOverquery: - 10: [1.0] - useSearchPruning: [Yes] + 10: [1.0, 2.0, 5.0, 10.0] +# 100: [1.0, 2.0] + useSearchPruning: [No] compression: - - type: PQ + - type: ASH parameters: - # m: 192 - mFactor: 8 - # k: 256 # optional parameter. By default, k=256 - centerData: No - anisotropicThreshold: -1.0 # optional parameter. By default, anisotropicThreshold=-1 (i.e., no anisotropy) \ No newline at end of file + compressionRatio: 32 + optimizer: ITQ + landmarkCount: 1 + bitsPerDimension: 2 +# To use PQ, comment out ASH and uncomment PQ in both sections. +# - type: PQ +# parameters: +# mFactor: 8 +# k: 256 +# anisotropicThreshold: -1.0 diff --git a/jvector-examples/yaml-configs/index-parameters/colbert-1M.yml b/jvector-examples/yaml-configs/index-parameters/colbert-1M.yml index 872165628..244cc712f 100644 --- a/jvector-examples/yaml-configs/index-parameters/colbert-1M.yml +++ b/jvector-examples/yaml-configs/index-parameters/colbert-1M.yml @@ -13,6 +13,19 @@ construction: fusedGraph: [No] compression: - type: None +# To use ASH, comment out None in both sections. +# - type: ASH +# parameters: +# compressionRatio: 16 +# optimizer: ITQ +# landmarkCount: 1 +# bitsPerDimension: 2 +# To use PQ, comment out None in both sections. +# - type: PQ +# parameters: +# mFactor: 4 +# k: 256 +# anisotropicThreshold: -1.0 reranking: - NVQ useSavedIndexIfExists: No @@ -21,8 +34,19 @@ search: topKOverquery: 10: [1.0, 2.0, 5.0, 10.0] 100: [1.0, 2.0] - useSearchPruning: [Yes] + useSearchPruning: [No] compression: - type: None - -# Run-level controls, such as benchmarks, console, and logging, are in run-config.yml. \ No newline at end of file +# To use ASH, comment out None in both sections. +# - type: ASH +# parameters: +# compressionRatio: 16 +# optimizer: ITQ +# landmarkCount: 1 +# bitsPerDimension: 2 +# To use PQ, comment out None in both sections. +# - type: PQ +# parameters: +# mFactor: 4 +# k: 256 +# anisotropicThreshold: -1.0 diff --git a/jvector-examples/yaml-configs/index-parameters/default.yml b/jvector-examples/yaml-configs/index-parameters/default.yml index bc3ae2aa0..6950a42b6 100644 --- a/jvector-examples/yaml-configs/index-parameters/default.yml +++ b/jvector-examples/yaml-configs/index-parameters/default.yml @@ -1,3 +1,4 @@ +# ASH configuration for DOT_PRODUCT datasets only. yamlSchemaVersion: 2 onDiskIndexVersion: 7 @@ -11,22 +12,20 @@ construction: neighborOverflow: [1.2f] addHierarchy: [Yes] refineFinalGraph: [Yes] - fusedGraph: [No] + fusedGraph: [Yes] compression: - - type: None -# - type: ASH -# parameters: -# compressionRatio: 32 -# optimizer: ITQ -# landmarkCount: 1 -# bitsPerDimension: 2 -# - type: PQ -# parameters: -# # m: 192 # we can either specify the integer m or the integer mFactor. In this case, m will be set to the data dimensionality divided by mFactor -# mFactor: 8 -# # k: 256 # optional parameter. By default, k=256 -# centerData: No -# anisotropicThreshold: -1.0 # optional parameter. By default, anisotropicThreshold=-1 (i.e., no anisotropy) + - type: ASH + parameters: + compressionRatio: 32 + optimizer: ITQ + landmarkCount: 1 + bitsPerDimension: 2 +# To use PQ, comment out ASH and uncomment PQ in both sections. +# - type: PQ +# parameters: +# mFactor: 8 +# k: 256 +# anisotropicThreshold: -1.0 reranking: - NVQ useSavedIndexIfExists: No # If yes, the system will attempt to locate a cached copy of the index instead of constructing @@ -34,22 +33,18 @@ construction: search: topKOverquery: 10: [1.0, 2.0, 5.0, 10.0] - 100: [1.0, 2.0] +# 100: [1.0, 2.0] useSearchPruning: [No] compression: - - type: None -# - type: ASH -# parameters: -# compressionRatio: 32 -# optimizer: ITQ -# landmarkCount: 1 -# bitsPerDimension: 2 + - type: ASH + parameters: + compressionRatio: 32 + optimizer: ITQ + landmarkCount: 1 + bitsPerDimension: 2 +# To use PQ, comment out ASH and uncomment PQ in both sections. # - type: PQ # parameters: -# # m: 192 # mFactor: 8 -# # k: 256 # optional parameter. By default, k=256 -# centerData: No -# anisotropicThreshold: -1.0 # optional parameter. By default, anisotropicThreshold=-1 (i.e., no anisotropy) - -# Run-level controls, such as benchmarks, console, and logging, are in run-config.yml. \ No newline at end of file +# k: 256 +# anisotropicThreshold: -1.0 diff --git a/jvector-examples/yaml-configs/index-parameters/dpr-gemma-10M.yml b/jvector-examples/yaml-configs/index-parameters/dpr-gemma-10M.yml index 90eac34e8..cd920f05a 100644 --- a/jvector-examples/yaml-configs/index-parameters/dpr-gemma-10M.yml +++ b/jvector-examples/yaml-configs/index-parameters/dpr-gemma-10M.yml @@ -1,8 +1,7 @@ -yamlSchemaVersion: 1 -onDiskIndexVersion: 6 +yamlSchemaVersion: 2 +onDiskIndexVersion: 7 -dataset: dpr-gemma-10M -useDatasetPrefetching: false +dataset: dpr-gemma-10m construction: outDegree: [32] @@ -12,13 +11,18 @@ construction: refineFinalGraph: [Yes] fusedGraph: [Yes] compression: - - type: PQ + - type: ASH parameters: - # m: 192 # we can either specify the integer m or the integer mFactor. In this case, m will be set to the data dimensionality divided by mFactor - mFactor: 8 - # k: 256 # optional parameter. By default, k=256 - centerData: No - anisotropicThreshold: -1.0 # optional parameter. By default, anisotropicThreshold=-1 (i.e., no anisotropy) + compressionRatio: 32 + optimizer: ITQ + landmarkCount: 1 + bitsPerDimension: 2 +# To use PQ, comment out ASH and uncomment PQ in both sections. +# - type: PQ +# parameters: +# mFactor: 8 +# k: 256 +# anisotropicThreshold: -1.0 reranking: - NVQ useSavedIndexIfExists: No @@ -26,12 +30,17 @@ construction: search: topKOverquery: 10: [1.0] - useSearchPruning: [Yes] + useSearchPruning: [No] compression: - - type: PQ + - type: ASH parameters: - # m: 192 - mFactor: 8 - # k: 256 # optional parameter. By default, k=256 - centerData: No - anisotropicThreshold: -1.0 # optional parameter. By default, anisotropicThreshold=-1 (i.e., no anisotropy) \ No newline at end of file + compressionRatio: 32 + optimizer: ITQ + landmarkCount: 1 + bitsPerDimension: 2 +# To use PQ, comment out ASH and uncomment PQ in both sections. +# - type: PQ +# parameters: +# mFactor: 8 +# k: 256 +# anisotropicThreshold: -1.0 diff --git a/jvector-examples/yaml-configs/index-parameters/dpr-gemma-1M.yml b/jvector-examples/yaml-configs/index-parameters/dpr-gemma-1M.yml index d3aba3a4d..b418c2bb5 100644 --- a/jvector-examples/yaml-configs/index-parameters/dpr-gemma-1M.yml +++ b/jvector-examples/yaml-configs/index-parameters/dpr-gemma-1M.yml @@ -1,7 +1,7 @@ yamlSchemaVersion: 2 onDiskIndexVersion: 7 -dataset: dpr-gemma-1M +dataset: dpr-gemma-1m construction: outDegree: [64] @@ -13,28 +13,16 @@ construction: compression: - type: ASH parameters: - encodedBits: 776 - optimizer: itq + compressionRatio: 32 + optimizer: ITQ landmarkCount: 1 - - type: ASH - parameters: - encodedBits: 392 - optimizer: itq - landmarkCount: 1 - - type: PQ - parameters: - m: 97 # we can either specify the integer m or the integer mFactor. In this case, m will be set to the data dimensionality divided by mFactor - # mFactor: 8 - # k: 256 # optional parameter. By default, k=256 - centerData: No - anisotropicThreshold: -1.0 # optional parameter. By default, anisotropicThreshold=-1 (i.e., no anisotropy) - - type: PQ - parameters: - m: 49 # we can either specify the integer m or the integer mFactor. In this case, m will be set to the data dimensionality divided by mFactor - # mFactor: 8 - # k: 256 # optional parameter. By default, k=256 - centerData: No - anisotropicThreshold: -1.0 # optional parameter. By default, anisotropicThreshold=-1 (i.e., no anisotropy) + bitsPerDimension: 2 +# To use PQ, comment out ASH and uncomment PQ in both sections. +# - type: PQ +# parameters: +# mFactor: 8 +# k: 256 +# anisotropicThreshold: -1.0 reranking: - FP useSavedIndexIfExists: Yes # If yes, the system will attempt to locate a cached copy of the index instead of constructing @@ -47,26 +35,13 @@ search: compression: - type: ASH parameters: - encodedBits: 776 - optimizer: itq + compressionRatio: 32 + optimizer: ITQ landmarkCount: 1 - - type: ASH - parameters: - encodedBits: 392 - optimizer: itq - landmarkCount: 1 - - type: PQ - parameters: - m: 97 # we can either specify the integer m or the integer mFactor. In this case, m will be set to the data dimensionality divided by mFactor - # mFactor: 8 - # k: 256 # optional parameter. By default, k=256 - centerData: No - anisotropicThreshold: -1.0 # optional parameter. By default, anisotropicThreshold=-1 (i.e., no anisotropy) - - type: PQ - parameters: - m: 49 # we can either specify the integer m or the integer mFactor. In this case, m will be set to the data dimensionality divided by mFactor - # mFactor: 8 - # k: 256 # optional parameter. By default, k=256 - centerData: No - anisotropicThreshold: -1.0 # optional parameter. By default, anisotropicThreshold=-1 (i.e., no anisotropy) -# Run-level controls, such as benchmarks, console, and logging, are in run.yml. \ No newline at end of file + bitsPerDimension: 2 +# To use PQ, comment out ASH and uncomment PQ in both sections. +# - type: PQ +# parameters: +# mFactor: 8 +# k: 256 +# anisotropicThreshold: -1.0 diff --git a/jvector-examples/yaml-configs/index-parameters/esmc-600-1M.yml b/jvector-examples/yaml-configs/index-parameters/esmc-600-1M.yml index f95d76ca4..648cd29ce 100644 --- a/jvector-examples/yaml-configs/index-parameters/esmc-600-1M.yml +++ b/jvector-examples/yaml-configs/index-parameters/esmc-600-1M.yml @@ -1,7 +1,7 @@ yamlSchemaVersion: 2 onDiskIndexVersion: 7 -dataset: cohere-1M +dataset: esmc-600-1M construction: outDegree: [64] @@ -13,28 +13,16 @@ construction: compression: - type: ASH parameters: - encodedBits: 1032 - optimizer: itq + compressionRatio: 32 + optimizer: ITQ landmarkCount: 1 - - type: ASH - parameters: - encodedBits: 520 - optimizer: itq - landmarkCount: 1 - - type: PQ - parameters: - m: 129 # we can either specify the integer m or the integer mFactor. In this case, m will be set to the data dimensionality divided by mFactor - # mFactor: 8 - # k: 256 # optional parameter. By default, k=256 - centerData: No - anisotropicThreshold: -1.0 # optional parameter. By default, anisotropicThreshold=-1 (i.e., no anisotropy) - - type: PQ - parameters: - m: 65 # we can either specify the integer m or the integer mFactor. In this case, m will be set to the data dimensionality divided by mFactor - # mFactor: 8 - # k: 256 # optional parameter. By default, k=256 - centerData: No - anisotropicThreshold: -1.0 # optional parameter. By default, anisotropicThreshold=-1 (i.e., no anisotropy) + bitsPerDimension: 2 +# To use PQ, comment out ASH and uncomment PQ in both sections. +# - type: PQ +# parameters: +# mFactor: 8 +# k: 256 +# anisotropicThreshold: -1.0 reranking: - FP useSavedIndexIfExists: Yes # If yes, the system will attempt to locate a cached copy of the index instead of constructing @@ -47,26 +35,13 @@ search: compression: - type: ASH parameters: - encodedBits: 1032 - optimizer: itq + compressionRatio: 32 + optimizer: ITQ landmarkCount: 1 - - type: ASH - parameters: - encodedBits: 520 - optimizer: itq - landmarkCount: 1 - - type: PQ - parameters: - m: 129 # we can either specify the integer m or the integer mFactor. In this case, m will be set to the data dimensionality divided by mFactor - # mFactor: 8 - # k: 256 # optional parameter. By default, k=256 - centerData: No - anisotropicThreshold: -1.0 # optional parameter. By default, anisotropicThreshold=-1 (i.e., no anisotropy) - - type: PQ - parameters: - m: 65 # we can either specify the integer m or the integer mFactor. In this case, m will be set to the data dimensionality divided by mFactor - # mFactor: 8 - # k: 256 # optional parameter. By default, k=256 - centerData: No - anisotropicThreshold: -1.0 # optional parameter. By default, anisotropicThreshold=-1 (i.e., no anisotropy) -# Run-level controls, such as benchmarks, console, and logging, are in run.yml. \ No newline at end of file + bitsPerDimension: 2 +# To use PQ, comment out ASH and uncomment PQ in both sections. +# - type: PQ +# parameters: +# mFactor: 8 +# k: 256 +# anisotropicThreshold: -1.0 diff --git a/jvector-examples/yaml-configs/index-parameters/esmc-600-6M.yml b/jvector-examples/yaml-configs/index-parameters/esmc-600-6M.yml index 04f121b60..1321eecb2 100644 --- a/jvector-examples/yaml-configs/index-parameters/esmc-600-6M.yml +++ b/jvector-examples/yaml-configs/index-parameters/esmc-600-6M.yml @@ -1,7 +1,7 @@ yamlSchemaVersion: 2 onDiskIndexVersion: 7 -dataset: esmc-600-1M +dataset: esmc-600-6M construction: outDegree: [64] @@ -13,28 +13,16 @@ construction: compression: - type: ASH parameters: - encodedBits: 1160 - optimizer: itq + compressionRatio: 32 + optimizer: ITQ landmarkCount: 1 - - type: ASH - parameters: - encodedBits: 584 - optimizer: itq - landmarkCount: 1 - - type: PQ - parameters: - m: 145 # we can either specify the integer m or the integer mFactor. In this case, m will be set to the data dimensionality divided by mFactor - # mFactor: 8 - # k: 256 # optional parameter. By default, k=256 - centerData: No - anisotropicThreshold: -1.0 # optional parameter. By default, anisotropicThreshold=-1 (i.e., no anisotropy) - - type: PQ - parameters: - m: 73 # we can either specify the integer m or the integer mFactor. In this case, m will be set to the data dimensionality divided by mFactor - # mFactor: 8 - # k: 256 # optional parameter. By default, k=256 - centerData: No - anisotropicThreshold: -1.0 # optional parameter. By default, anisotropicThreshold=-1 (i.e., no anisotropy) + bitsPerDimension: 2 +# To use PQ, comment out ASH and uncomment PQ in both sections. +# - type: PQ +# parameters: +# mFactor: 8 +# k: 256 +# anisotropicThreshold: -1.0 reranking: - FP useSavedIndexIfExists: Yes # If yes, the system will attempt to locate a cached copy of the index instead of constructing @@ -47,26 +35,13 @@ search: compression: - type: ASH parameters: - encodedBits: 1160 - optimizer: itq + compressionRatio: 32 + optimizer: ITQ landmarkCount: 1 - - type: ASH - parameters: - encodedBits: 584 - optimizer: itq - landmarkCount: 1 - - type: PQ - parameters: - m: 145 # we can either specify the integer m or the integer mFactor. In this case, m will be set to the data dimensionality divided by mFactor - # mFactor: 8 - # k: 256 # optional parameter. By default, k=256 - centerData: No - anisotropicThreshold: -1.0 # optional parameter. By default, anisotropicThreshold=-1 (i.e., no anisotropy) - - type: PQ - parameters: - m: 73 # we can either specify the integer m or the integer mFactor. In this case, m will be set to the data dimensionality divided by mFactor - # mFactor: 8 - # k: 256 # optional parameter. By default, k=256 - centerData: No - anisotropicThreshold: -1.0 # optional parameter. By default, anisotropicThreshold=-1 (i.e., no anisotropy) -# Run-level controls, such as benchmarks, console, and logging, are in run.yml. \ No newline at end of file + bitsPerDimension: 2 +# To use PQ, comment out ASH and uncomment PQ in both sections. +# - type: PQ +# parameters: +# mFactor: 8 +# k: 256 +# anisotropicThreshold: -1.0 diff --git a/jvector-examples/yaml-configs/index-parameters/gecko-100k.yml b/jvector-examples/yaml-configs/index-parameters/gecko-100k.yml index 176e3fec7..831c7f8a0 100644 --- a/jvector-examples/yaml-configs/index-parameters/gecko-100k.yml +++ b/jvector-examples/yaml-configs/index-parameters/gecko-100k.yml @@ -1,75 +1,47 @@ yamlSchemaVersion: 2 onDiskIndexVersion: 7 -dataset: dpr-10M +dataset: gecko-100k construction: outDegree: [64] efConstruction: [200] - neighborOverflow: [1.6f] - addHierarchy: [No] + neighborOverflow: [1.2f] + addHierarchy: [Yes] refineFinalGraph: [Yes] - fusedGraph: [No] + fusedGraph: [Yes] compression: -# - type: PQ -# parameters: -# m: 97 # we can either specify the integer m or the integer mFactor. In this case, m will be set to the data dimensionality divided by mFactor -# # mFactor: 8 -# # k: 256 # optional parameter. By default, k=256 -# centerData: No -# anisotropicThreshold: -1.0 # optional parameter. By default, anisotropicThreshold=-1 (i.e., no anisotropy) -# - type: PQ -# parameters: -# m: 49 # we can either specify the integer m or the integer mFactor. In this case, m will be set to the data dimensionality divided by mFactor -# # mFactor: 8 -# # k: 256 # optional parameter. By default, k=256 -# centerData: No -# anisotropicThreshold: -1.0 # optional parameter. By default, anisotropicThreshold=-1 (i.e., no anisotropy) - type: ASH parameters: compressionRatio: 32 -# encodedBits: 776 optimizer: ITQ landmarkCount: 1 -# - type: ASH + bitsPerDimension: 2 +# To use PQ, comment out ASH and uncomment PQ in both sections. +# - type: PQ # parameters: -# encodedBits: 392 -# optimizer: ITQ -# landmarkCount: 1 +# mFactor: 8 +# k: 256 +# anisotropicThreshold: -1.0 reranking: - NVQ - useSavedIndexIfExists: Yes # If yes, the system will attempt to locate a cached copy of the index instead of constructing + useSavedIndexIfExists: No # If yes, the system will attempt to locate a cached copy of the index instead of constructing search: topKOverquery: - 10: [1.00, 1.25, 1.56, 1.95, 2.44, 3.05, 3.81, 4.77, 5.96, 7.45, 9.31, 11.64] -# 100: [1.0] + 10: [1.0, 2.0, 5.0, 10.0] +# 100: [1.0, 2.0] useSearchPruning: [No] compression: -# - type: PQ -# parameters: -# m: 97 # we can either specify the integer m or the integer mFactor. In this case, m will be set to the data dimensionality divided by mFactor -# # mFactor: 8 -# # k: 256 # optional parameter. By default, k=256 -# centerData: No -# anisotropicThreshold: -1.0 # optional parameter. By default, anisotropicThreshold=-1 (i.e., no anisotropy) -# - type: PQ -# parameters: -# m: 49 # we can either specify the integer m or the integer mFactor. In this case, m will be set to the data dimensionality divided by mFactor -# # mFactor: 8 -# # k: 256 # optional parameter. By default, k=256 -# centerData: No -# anisotropicThreshold: -1.0 # optional parameter. By default, anisotropicThreshold=-1 (i.e., no anisotropy) - type: ASH parameters: compressionRatio: 32 -# encodedBits: 776 - optimizer: itq + optimizer: ITQ landmarkCount: 1 -# - type: ASH + bitsPerDimension: 2 +# To use PQ, comment out ASH and uncomment PQ in both sections. +# - type: PQ # parameters: -# compressionRatio: 64 -## encodedBits: 392 -# optimizer: itq -# landmarkCount: 1 -# Run-level controls, such as benchmarks, console, and logging, are in run.yml. \ No newline at end of file +# mFactor: 8 +# k: 256 +# anisotropicThreshold: -1.0 diff --git a/jvector-examples/yaml-configs/index-parameters/glove-100-angular.yml b/jvector-examples/yaml-configs/index-parameters/glove-100-angular.yml index 032b28813..a57756a6a 100644 --- a/jvector-examples/yaml-configs/index-parameters/glove-100-angular.yml +++ b/jvector-examples/yaml-configs/index-parameters/glove-100-angular.yml @@ -14,6 +14,19 @@ construction: fusedGraph: [No] compression: - type: None +# To use ASH, comment out None in both sections. +# - type: ASH +# parameters: +# compressionRatio: 16 +# optimizer: ITQ +# landmarkCount: 1 +# bitsPerDimension: 2 +# To use PQ, comment out None in both sections. +# - type: PQ +# parameters: +# mFactor: 4 +# k: 256 +# anisotropicThreshold: -1.0 reranking: - NVQ useSavedIndexIfExists: No @@ -22,6 +35,19 @@ search: topKOverquery: 10: [1.0, 2.0, 5.0, 10.0] 100: [1.0, 2.0] - useSearchPruning: [Yes] + useSearchPruning: [No] compression: - type: None +# To use ASH, comment out None in both sections. +# - type: ASH +# parameters: +# compressionRatio: 16 +# optimizer: ITQ +# landmarkCount: 1 +# bitsPerDimension: 2 +# To use PQ, comment out None in both sections. +# - type: PQ +# parameters: +# mFactor: 4 +# k: 256 +# anisotropicThreshold: -1.0 diff --git a/jvector-examples/yaml-configs/index-parameters/glove-200-angular.yml b/jvector-examples/yaml-configs/index-parameters/glove-200-angular.yml index 9cff95f1d..82d766576 100644 --- a/jvector-examples/yaml-configs/index-parameters/glove-200-angular.yml +++ b/jvector-examples/yaml-configs/index-parameters/glove-200-angular.yml @@ -14,6 +14,19 @@ construction: fusedGraph: [No] compression: - type: None +# To use ASH, comment out None in both sections. +# - type: ASH +# parameters: +# compressionRatio: 16 +# optimizer: ITQ +# landmarkCount: 1 +# bitsPerDimension: 2 +# To use PQ, comment out None in both sections. +# - type: PQ +# parameters: +# mFactor: 4 +# k: 256 +# anisotropicThreshold: -1.0 reranking: - NVQ useSavedIndexIfExists: No @@ -22,6 +35,19 @@ search: topKOverquery: 10: [1.0, 2.0, 5.0, 10.0] 100: [1.0, 2.0] - useSearchPruning: [Yes] + useSearchPruning: [No] compression: - type: None +# To use ASH, comment out None in both sections. +# - type: ASH +# parameters: +# compressionRatio: 16 +# optimizer: ITQ +# landmarkCount: 1 +# bitsPerDimension: 2 +# To use PQ, comment out None in both sections. +# - type: PQ +# parameters: +# mFactor: 4 +# k: 256 +# anisotropicThreshold: -1.0 diff --git a/jvector-examples/yaml-configs/index-parameters/glove-25-angular.yml b/jvector-examples/yaml-configs/index-parameters/glove-25-angular.yml index ec690ecd0..d662d965f 100644 --- a/jvector-examples/yaml-configs/index-parameters/glove-25-angular.yml +++ b/jvector-examples/yaml-configs/index-parameters/glove-25-angular.yml @@ -14,6 +14,19 @@ construction: fusedGraph: [No] compression: - type: None +# To use ASH, comment out None in both sections. +# - type: ASH +# parameters: +# compressionRatio: 16 +# optimizer: ITQ +# landmarkCount: 1 +# bitsPerDimension: 2 +# To use PQ, comment out None in both sections. +# - type: PQ +# parameters: +# mFactor: 4 +# k: 256 +# anisotropicThreshold: -1.0 reranking: - NVQ useSavedIndexIfExists: No @@ -22,6 +35,19 @@ search: topKOverquery: 10: [1.0, 2.0, 5.0, 10.0] 100: [1.0, 2.0] - useSearchPruning: [Yes] + useSearchPruning: [No] compression: - type: None +# To use ASH, comment out None in both sections. +# - type: ASH +# parameters: +# compressionRatio: 16 +# optimizer: ITQ +# landmarkCount: 1 +# bitsPerDimension: 2 +# To use PQ, comment out None in both sections. +# - type: PQ +# parameters: +# mFactor: 4 +# k: 256 +# anisotropicThreshold: -1.0 diff --git a/jvector-examples/yaml-configs/index-parameters/glove-50-angular.yml b/jvector-examples/yaml-configs/index-parameters/glove-50-angular.yml index 9216e285f..e04f6bd3e 100644 --- a/jvector-examples/yaml-configs/index-parameters/glove-50-angular.yml +++ b/jvector-examples/yaml-configs/index-parameters/glove-50-angular.yml @@ -14,6 +14,19 @@ construction: fusedGraph: [No] compression: - type: None +# To use ASH, comment out None in both sections. +# - type: ASH +# parameters: +# compressionRatio: 16 +# optimizer: ITQ +# landmarkCount: 1 +# bitsPerDimension: 2 +# To use PQ, comment out None in both sections. +# - type: PQ +# parameters: +# mFactor: 4 +# k: 256 +# anisotropicThreshold: -1.0 reranking: - NVQ useSavedIndexIfExists: No @@ -22,6 +35,19 @@ search: topKOverquery: 10: [1.0, 2.0, 5.0, 10.0] 100: [1.0, 2.0] - useSearchPruning: [Yes] + useSearchPruning: [No] compression: - type: None +# To use ASH, comment out None in both sections. +# - type: ASH +# parameters: +# compressionRatio: 16 +# optimizer: ITQ +# landmarkCount: 1 +# bitsPerDimension: 2 +# To use PQ, comment out None in both sections. +# - type: PQ +# parameters: +# mFactor: 4 +# k: 256 +# anisotropicThreshold: -1.0 diff --git a/jvector-examples/yaml-configs/index-parameters/lastfm-64-dot.yml b/jvector-examples/yaml-configs/index-parameters/lastfm-64-dot.yml index c30cf4aad..d7b65a093 100644 --- a/jvector-examples/yaml-configs/index-parameters/lastfm-64-dot.yml +++ b/jvector-examples/yaml-configs/index-parameters/lastfm-64-dot.yml @@ -14,6 +14,19 @@ construction: fusedGraph: [No] compression: - type: None +# To use ASH, comment out None in both sections. +# - type: ASH +# parameters: +# compressionRatio: 16 +# optimizer: ITQ +# landmarkCount: 1 +# bitsPerDimension: 2 +# To use PQ, comment out None in both sections. +# - type: PQ +# parameters: +# mFactor: 4 +# k: 256 +# anisotropicThreshold: -1.0 reranking: - NVQ useSavedIndexIfExists: No @@ -22,6 +35,19 @@ search: topKOverquery: 10: [1.0, 2.0, 5.0, 10.0] 100: [1.0, 2.0] - useSearchPruning: [Yes] + useSearchPruning: [No] compression: - type: None +# To use ASH, comment out None in both sections. +# - type: ASH +# parameters: +# compressionRatio: 16 +# optimizer: ITQ +# landmarkCount: 1 +# bitsPerDimension: 2 +# To use PQ, comment out None in both sections. +# - type: PQ +# parameters: +# mFactor: 4 +# k: 256 +# anisotropicThreshold: -1.0 diff --git a/jvector-examples/yaml-configs/index-parameters/mpnet-1M.yml b/jvector-examples/yaml-configs/index-parameters/mpnet-1M.yml index 4f6342c16..72f880722 100644 --- a/jvector-examples/yaml-configs/index-parameters/mpnet-1M.yml +++ b/jvector-examples/yaml-configs/index-parameters/mpnet-1M.yml @@ -1,7 +1,7 @@ yamlSchemaVersion: 2 onDiskIndexVersion: 7 -dataset: mpnet-1M +dataset: mpnet-1m construction: outDegree: [64] @@ -13,28 +13,16 @@ construction: compression: - type: ASH parameters: - encodedBits: 776 - optimizer: itq + compressionRatio: 32 + optimizer: ITQ landmarkCount: 1 - - type: ASH - parameters: - encodedBits: 392 - optimizer: itq - landmarkCount: 1 - - type: PQ - parameters: - m: 97 # we can either specify the integer m or the integer mFactor. In this case, m will be set to the data dimensionality divided by mFactor - # mFactor: 8 - # k: 256 # optional parameter. By default, k=256 - centerData: No - anisotropicThreshold: -1.0 # optional parameter. By default, anisotropicThreshold=-1 (i.e., no anisotropy) - - type: PQ - parameters: - m: 49 # we can either specify the integer m or the integer mFactor. In this case, m will be set to the data dimensionality divided by mFactor - # mFactor: 8 - # k: 256 # optional parameter. By default, k=256 - centerData: No - anisotropicThreshold: -1.0 # optional parameter. By default, anisotropicThreshold=-1 (i.e., no anisotropy) + bitsPerDimension: 2 +# To use PQ, comment out ASH and uncomment PQ in both sections. +# - type: PQ +# parameters: +# mFactor: 8 +# k: 256 +# anisotropicThreshold: -1.0 reranking: - FP useSavedIndexIfExists: Yes # If yes, the system will attempt to locate a cached copy of the index instead of constructing @@ -47,26 +35,13 @@ search: compression: - type: ASH parameters: - encodedBits: 776 - optimizer: itq + compressionRatio: 32 + optimizer: ITQ landmarkCount: 1 - - type: ASH - parameters: - encodedBits: 392 - optimizer: itq - landmarkCount: 1 - - type: PQ - parameters: - m: 97 # we can either specify the integer m or the integer mFactor. In this case, m will be set to the data dimensionality divided by mFactor - # mFactor: 8 - # k: 256 # optional parameter. By default, k=256 - centerData: No - anisotropicThreshold: -1.0 # optional parameter. By default, anisotropicThreshold=-1 (i.e., no anisotropy) - - type: PQ - parameters: - m: 49 # we can either specify the integer m or the integer mFactor. In this case, m will be set to the data dimensionality divided by mFactor - # mFactor: 8 - # k: 256 # optional parameter. By default, k=256 - centerData: No - anisotropicThreshold: -1.0 # optional parameter. By default, anisotropicThreshold=-1 (i.e., no anisotropy) -# Run-level controls, such as benchmarks, console, and logging, are in run.yml. \ No newline at end of file + bitsPerDimension: 2 +# To use PQ, comment out ASH and uncomment PQ in both sections. +# - type: PQ +# parameters: +# mFactor: 8 +# k: 256 +# anisotropicThreshold: -1.0 diff --git a/jvector-examples/yaml-configs/index-parameters/nytimes-256-angular.yml b/jvector-examples/yaml-configs/index-parameters/nytimes-256-angular.yml index 86b04a7d1..19d9d7c79 100644 --- a/jvector-examples/yaml-configs/index-parameters/nytimes-256-angular.yml +++ b/jvector-examples/yaml-configs/index-parameters/nytimes-256-angular.yml @@ -14,6 +14,19 @@ construction: fusedGraph: [No] compression: - type: None +# To use ASH, comment out None in both sections. +# - type: ASH +# parameters: +# compressionRatio: 16 +# optimizer: ITQ +# landmarkCount: 1 +# bitsPerDimension: 2 +# To use PQ, comment out None in both sections. +# - type: PQ +# parameters: +# mFactor: 4 +# k: 256 +# anisotropicThreshold: -1.0 reranking: - NVQ useSavedIndexIfExists: No @@ -22,6 +35,19 @@ search: topKOverquery: 10: [1.0, 2.0, 5.0, 10.0] 100: [1.0, 2.0] - useSearchPruning: [Yes] + useSearchPruning: [No] compression: - type: None +# To use ASH, comment out None in both sections. +# - type: ASH +# parameters: +# compressionRatio: 16 +# optimizer: ITQ +# landmarkCount: 1 +# bitsPerDimension: 2 +# To use PQ, comment out None in both sections. +# - type: PQ +# parameters: +# mFactor: 4 +# k: 256 +# anisotropicThreshold: -1.0 diff --git a/jvector-examples/yaml-configs/index-parameters/openai-v3-small-100k.yml b/jvector-examples/yaml-configs/index-parameters/openai-v3-small-100k.yml index 4b75cd41e..ca8fd0c73 100644 --- a/jvector-examples/yaml-configs/index-parameters/openai-v3-small-100k.yml +++ b/jvector-examples/yaml-configs/index-parameters/openai-v3-small-100k.yml @@ -1,7 +1,7 @@ yamlSchemaVersion: 2 onDiskIndexVersion: 7 -dataset: cap-6M +dataset: openai-v3-small-1536-100k construction: outDegree: [64] @@ -13,28 +13,16 @@ construction: compression: - type: ASH parameters: - encodedBits: 1544 - optimizer: itq + compressionRatio: 32 + optimizer: ITQ landmarkCount: 1 - - type: ASH - parameters: - encodedBits: 776 - optimizer: itq - landmarkCount: 1 - - type: PQ - parameters: - m: 193 # we can either specify the integer m or the integer mFactor. In this case, m will be set to the data dimensionality divided by mFactor - # mFactor: 8 - # k: 256 # optional parameter. By default, k=256 - centerData: No - anisotropicThreshold: -1.0 # optional parameter. By default, anisotropicThreshold=-1 (i.e., no anisotropy) - - type: PQ - parameters: - m: 97 # we can either specify the integer m or the integer mFactor. In this case, m will be set to the data dimensionality divided by mFactor - # mFactor: 8 - # k: 256 # optional parameter. By default, k=256 - centerData: No - anisotropicThreshold: -1.0 # optional parameter. By default, anisotropicThreshold=-1 (i.e., no anisotropy) + bitsPerDimension: 2 +# To use PQ, comment out ASH and uncomment PQ in both sections. +# - type: PQ +# parameters: +# mFactor: 8 +# k: 256 +# anisotropicThreshold: -1.0 reranking: - FP useSavedIndexIfExists: Yes # If yes, the system will attempt to locate a cached copy of the index instead of constructing @@ -47,26 +35,13 @@ search: compression: - type: ASH parameters: - encodedBits: 1544 - optimizer: itq + compressionRatio: 32 + optimizer: ITQ landmarkCount: 1 - - type: ASH - parameters: - encodedBits: 776 - optimizer: itq - landmarkCount: 1 - - type: PQ - parameters: - m: 193 # we can either specify the integer m or the integer mFactor. In this case, m will be set to the data dimensionality divided by mFactor - # mFactor: 8 - # k: 256 # optional parameter. By default, k=256 - centerData: No - anisotropicThreshold: -1.0 # optional parameter. By default, anisotropicThreshold=-1 (i.e., no anisotropy) - - type: PQ - parameters: - m: 97 # we can either specify the integer m or the integer mFactor. In this case, m will be set to the data dimensionality divided by mFactor - # mFactor: 8 - # k: 256 # optional parameter. By default, k=256 - centerData: No - anisotropicThreshold: -1.0 # optional parameter. By default, anisotropicThreshold=-1 (i.e., no anisotropy) -# Run-level controls, such as benchmarks, console, and logging, are in run.yml. \ No newline at end of file + bitsPerDimension: 2 +# To use PQ, comment out ASH and uncomment PQ in both sections. +# - type: PQ +# parameters: +# mFactor: 8 +# k: 256 +# anisotropicThreshold: -1.0 diff --git a/jvector-examples/yaml-configs/index-parameters/personahub-1M.yml b/jvector-examples/yaml-configs/index-parameters/personahub-1M.yml index 7f5252fb0..74cbdfc74 100644 --- a/jvector-examples/yaml-configs/index-parameters/personahub-1M.yml +++ b/jvector-examples/yaml-configs/index-parameters/personahub-1M.yml @@ -1,7 +1,7 @@ yamlSchemaVersion: 2 onDiskIndexVersion: 7 -dataset: personahub-1M +dataset: personahub-1m construction: outDegree: [64] @@ -13,28 +13,16 @@ construction: compression: - type: ASH parameters: - encodedBits: 1032 - optimizer: itq + compressionRatio: 32 + optimizer: ITQ landmarkCount: 1 - - type: ASH - parameters: - encodedBits: 520 - optimizer: itq - landmarkCount: 1 - - type: PQ - parameters: - m: 129 # we can either specify the integer m or the integer mFactor. In this case, m will be set to the data dimensionality divided by mFactor - # mFactor: 8 - # k: 256 # optional parameter. By default, k=256 - centerData: No - anisotropicThreshold: -1.0 # optional parameter. By default, anisotropicThreshold=-1 (i.e., no anisotropy) - - type: PQ - parameters: - m: 65 # we can either specify the integer m or the integer mFactor. In this case, m will be set to the data dimensionality divided by mFactor - # mFactor: 8 - # k: 256 # optional parameter. By default, k=256 - centerData: No - anisotropicThreshold: -1.0 # optional parameter. By default, anisotropicThreshold=-1 (i.e., no anisotropy) + bitsPerDimension: 2 +# To use PQ, comment out ASH and uncomment PQ in both sections. +# - type: PQ +# parameters: +# mFactor: 8 +# k: 256 +# anisotropicThreshold: -1.0 reranking: - FP useSavedIndexIfExists: Yes # If yes, the system will attempt to locate a cached copy of the index instead of constructing @@ -47,26 +35,13 @@ search: compression: - type: ASH parameters: - encodedBits: 1032 - optimizer: itq + compressionRatio: 32 + optimizer: ITQ landmarkCount: 1 - - type: ASH - parameters: - encodedBits: 520 - optimizer: itq - landmarkCount: 1 - - type: PQ - parameters: - m: 129 # we can either specify the integer m or the integer mFactor. In this case, m will be set to the data dimensionality divided by mFactor - # mFactor: 8 - # k: 256 # optional parameter. By default, k=256 - centerData: No - anisotropicThreshold: -1.0 # optional parameter. By default, anisotropicThreshold=-1 (i.e., no anisotropy) - - type: PQ - parameters: - m: 65 # we can either specify the integer m or the integer mFactor. In this case, m will be set to the data dimensionality divided by mFactor - # mFactor: 8 - # k: 256 # optional parameter. By default, k=256 - centerData: No - anisotropicThreshold: -1.0 # optional parameter. By default, anisotropicThreshold=-1 (i.e., no anisotropy) -# Run-level controls, such as benchmarks, console, and logging, are in run.yml. \ No newline at end of file + bitsPerDimension: 2 +# To use PQ, comment out ASH and uncomment PQ in both sections. +# - type: PQ +# parameters: +# mFactor: 8 +# k: 256 +# anisotropicThreshold: -1.0 diff --git a/jvector-examples/yaml-configs/index-parameters/sift-128-euclidean.yml b/jvector-examples/yaml-configs/index-parameters/sift-128-euclidean.yml index 7a844e2ea..995356efa 100644 --- a/jvector-examples/yaml-configs/index-parameters/sift-128-euclidean.yml +++ b/jvector-examples/yaml-configs/index-parameters/sift-128-euclidean.yml @@ -14,6 +14,12 @@ construction: fusedGraph: [No] compression: - type: None +# To use PQ, comment out None in both sections. +# - type: PQ +# parameters: +# mFactor: 4 +# k: 256 +# anisotropicThreshold: -1.0 reranking: - NVQ useSavedIndexIfExists: No @@ -22,6 +28,12 @@ search: topKOverquery: 10: [1.0, 2.0, 5.0, 10.0] 100: [1.0, 2.0] - useSearchPruning: [Yes] + useSearchPruning: [No] compression: - type: None +# To use PQ, comment out None in both sections. +# - type: PQ +# parameters: +# mFactor: 4 +# k: 256 +# anisotropicThreshold: -1.0 diff --git a/jvector-examples/yaml-configs/index-parameters/tahoe-x1-1M.yml b/jvector-examples/yaml-configs/index-parameters/tahoe-x1-1M.yml index 79b44cf09..8461bca2e 100644 --- a/jvector-examples/yaml-configs/index-parameters/tahoe-x1-1M.yml +++ b/jvector-examples/yaml-configs/index-parameters/tahoe-x1-1M.yml @@ -1,7 +1,7 @@ yamlSchemaVersion: 2 onDiskIndexVersion: 7 -dataset: tahoe-x1-1M +dataset: tahoe-x1-1m construction: outDegree: [32] @@ -13,28 +13,16 @@ construction: compression: - type: ASH parameters: - encodedBits: 2568 - optimizer: itq + compressionRatio: 32 + optimizer: ITQ landmarkCount: 1 - - type: ASH - parameters: - encodedBits: 1288 - optimizer: itq - landmarkCount: 1 - - type: PQ - parameters: - m: 321 # we can either specify the integer m or the integer mFactor. In this case, m will be set to the data dimensionality divided by mFactor - # mFactor: 8 - # k: 256 # optional parameter. By default, k=256 - centerData: No - anisotropicThreshold: -1.0 # optional parameter. By default, anisotropicThreshold=-1 (i.e., no anisotropy) - - type: PQ - parameters: - m: 161 # we can either specify the integer m or the integer mFactor. In this case, m will be set to the data dimensionality divided by mFactor - # mFactor: 8 - # k: 256 # optional parameter. By default, k=256 - centerData: No - anisotropicThreshold: -1.0 # optional parameter. By default, anisotropicThreshold=-1 (i.e., no anisotropy) + bitsPerDimension: 2 +# To use PQ, comment out ASH and uncomment PQ in both sections. +# - type: PQ +# parameters: +# mFactor: 8 +# k: 256 +# anisotropicThreshold: -1.0 reranking: - FP useSavedIndexIfExists: Yes # If yes, the system will attempt to locate a cached copy of the index instead of constructing @@ -48,26 +36,13 @@ search: compression: - type: ASH parameters: - encodedBits: 2568 - optimizer: itq + compressionRatio: 32 + optimizer: ITQ landmarkCount: 1 - - type: ASH - parameters: - encodedBits: 1288 - optimizer: itq - landmarkCount: 1 - - type: PQ - parameters: - m: 321 # we can either specify the integer m or the integer mFactor. In this case, m will be set to the data dimensionality divided by mFactor - # mFactor: 8 - # k: 256 # optional parameter. By default, k=256 - centerData: No - anisotropicThreshold: -1.0 # optional parameter. By default, anisotropicThreshold=-1 (i.e., no anisotropy) - - type: PQ - parameters: - m: 161 # we can either specify the integer m or the integer mFactor. In this case, m will be set to the data dimensionality divided by mFactor - # mFactor: 8 - # k: 256 # optional parameter. By default, k=256 - centerData: No - anisotropicThreshold: -1.0 # optional parameter. By default, anisotropicThreshold=-1 (i.e., no anisotropy) -# Run-level controls, such as benchmarks, console, and logging, are in run.yml. \ No newline at end of file + bitsPerDimension: 2 +# To use PQ, comment out ASH and uncomment PQ in both sections. +# - type: PQ +# parameters: +# mFactor: 8 +# k: 256 +# anisotropicThreshold: -1.0 diff --git a/jvector-examples/yaml-configs/run-config.yml b/jvector-examples/yaml-configs/run-config.yml index ba5ee1077..e50a915db 100644 --- a/jvector-examples/yaml-configs/run-config.yml +++ b/jvector-examples/yaml-configs/run-config.yml @@ -1,38 +1,27 @@ -# Run-level controls for BenchYAML (applies to the whole invocation) yamlSchemaVersion: 1 -onDiskIndexVersion: 6 - -# Benchmark stats to compute +onDiskIndexVersion: 7 benchmarks: - throughput: [ AVG ] # additional options: [AVG, MEDIAN, MAX] - latency: [ AVG ] # [AVG, STD, P999] - count: [ visited ] # [visited, expanded, expanded base layer] - accuracy: [ recall ] # [recall, MAP] - -# Console display (omit/leave blank to print all computed outputs) + throughput: [AVG] + latency: [AVG, P999] + count: [visited, expanded, expanded base layer] + accuracy: [recall] console: benchmarks: - throughput: [ AVG ] # [AVG, MEDIAN, MAX] - latency: [ AVG ] # [AVG, STD, P999] - count: [ visited ] # [visited, expanded, expanded base layer] - accuracy: [ recall ] # [recall, MAP] + throughput: [AVG] + latency: [AVG, P999] + count: [visited, expanded, expanded base layer] + accuracy: [recall] metrics: - system: [ max_heap_mb, max_offheap_mb ] # [ max_heap_mb, max_offheap_mb ] -# disk: total_file_size_mb # [ total_file_size_mb, file_count ] -# construction: index_build_time_s # [ index_build_time_s, index_quant_time_s, search_quant_time_s ] - -# Experiments CSV logging (selection + run metadata) -#logging: -# dir: logging # set as desired -# jvectorRef: "no-tag" # tag or git sha (recommended) -# runId: "my-experiment-{ts}" # optional; template with {ts} (UTC timestamp). Default: "{ts}" -# type: csv # only csv is presently supported -# benchmarks: -# throughput: [ AVG ] # [AVG, MEDIAN, MAX] -# latency: [ AVG ] # [ AVG, STD, P999 ] -# count: [ visited ] # [ visited, expanded, expanded base layer ] -# accuracy: [ recall ] # [ recall, MAP ] -# metrics: -# system: [ max_heap_mb ] # [ max_heap_mb, max_offheap_mb ] -# disk: [ total_file_size_mb, file_count ] # [ total_file_size_mb, file_count ] -# construction: [ index_build_time_s ] # [ index_build_time_s, index_quant_time_s, search_quant_time_s ] + construction: [index_build_time_s, index_quant_time_s, search_quant_time_s] +logging: + dir: logging + jvectorRef: "ash-dev" + runId: "ash-64x-integrated-{ts}" + type: csv + benchmarks: + throughput: [AVG] + latency: [AVG, P999] + count: [visited, expanded, expanded base layer] + accuracy: [recall] + metrics: + construction: [index_build_time_s, index_quant_time_s, search_quant_time_s] From 3f52d28fb773b5dc946df431e2403d04515ec669 Mon Sep 17 00:00:00 2001 From: Ted Willke Date: Thu, 17 Sep 2026 22:37:19 +0000 Subject: [PATCH 064/243] Fix ASH SIMD projection gather for partial blocks --- .../quantization/TestASHScoringDispatch.java | 18 ++++++++++++++++++ .../vector/PanamaVectorUtilSupport.java | 18 +++++++++++++++++- 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestASHScoringDispatch.java b/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestASHScoringDispatch.java index 4821cb2bd..1c53ad57f 100644 --- a/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestASHScoringDispatch.java +++ b/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestASHScoringDispatch.java @@ -46,6 +46,24 @@ public class TestASHScoringDispatch { private static final String BLOCK = "jvector.ash.blockKernel"; private static final String SINGLE = "jvector.ash.singleKernel"; + @Test + public void projectionSimdHandlesPartialBlocks() { + var backend = VectorizationProvider.getInstance().getVectorUtilSupport(); + if (!backend.supportsAshProjectionScoring()) return; + Random random = new Random(84621); + for (int bits : new int[]{2, 4}) { + for (int dimensions = 1; dimensions <= 748; dimensions++) { + float[] query = new float[dimensions]; + byte[] code = new byte[(dimensions * bits + 7) / 8]; + for (int i = 0; i < dimensions; i++) query[i] = random.nextFloat() - 0.5f; + random.nextBytes(code); + float expected = AsymmetricHashing.dotProjectionCode(query, code, dimensions, bits); + assertEquals("bits=" + bits + " dimensions=" + dimensions, expected, + backend.ashProjectionDot(query, code, dimensions, bits), 0.0002f); + } + } + } + @Test public void standaloneAndFusedUseSelectedKernels() throws Exception { String oldBlock = System.getProperty(BLOCK), oldSingle = System.getProperty(SINGLE); 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 b3ca3ecc6..fadcaf3c0 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 @@ -41,6 +41,8 @@ class PanamaVectorUtilSupport implements VectorUtilSupport { ? ByteVector.SPECIES_64 : ByteVector.SPECIES_128; private static final int[] ASH_TWO_BIT_QUERY_INDICES = ashQueryIndices(4); private static final int[] ASH_FOUR_BIT_QUERY_INDICES = ashQueryIndices(2); + private static final int[][] ASH_TWO_BIT_TAIL_INDICES = ashTailQueryIndices(4); + private static final int[][] ASH_FOUR_BIT_TAIL_INDICES = ashTailQueryIndices(2); private static int[] ashQueryIndices(int dimensionsPerByte) { int[] indices = new int[ASH_FLOATS.length()]; @@ -48,6 +50,16 @@ private static int[] ashQueryIndices(int dimensionsPerByte) { return indices; } + private static int[][] ashTailQueryIndices(int dimensionsPerByte) { + int[][] tails = new int[ASH_FLOATS.length()][]; + for (int active = 1; active < tails.length; active++) { + int[] indices = new int[ASH_FLOATS.length()]; + for (int lane = 0; lane < active; lane++) indices[lane] = lane * dimensionsPerByte; + tails[active] = indices; + } + return tails; + } + @Override public boolean supportsAshProjectionScoring() { return PanamaASHKernels.supported(); } @@ -97,7 +109,11 @@ public float ashProjectionDot(float[] query, byte[] code, int dimensions, int bi .convertShape(VectorOperators.I2F, ASH_FLOATS, 0)).add(0.5f); var values = magnitude.blend(magnitude.neg(), fields.and(sign) .compare(VectorOperators.EQ, 0).cast(ASH_FLOATS)); - var q = FloatVector.fromArray(ASH_FLOATS, query, base * perByte + slot, indices, 0, mask); + // A masked gather may bounds-check inactive indices. Point those lanes at + // the first valid query element for a partial final block. + int[] gatherIndices = active == ASH_FLOATS.length() ? indices + : (bitsPerDimension == 2 ? ASH_TWO_BIT_TAIL_INDICES : ASH_FOUR_BIT_TAIL_INDICES)[active]; + var q = FloatVector.fromArray(ASH_FLOATS, query, base * perByte + slot, gatherIndices, 0, mask); sum = sum.add(q.mul(values)); } } From 1ed906c666221575fb8a78e3590f12422f5a7908 Mon Sep 17 00:00:00 2001 From: Ted Willke Date: Thu, 17 Sep 2026 23:49:06 +0000 Subject: [PATCH 065/243] Validate ASH exhaustive recall across scalar and SIMD scorers --- .../jbellis/jvector/example/DistancesASH.java | 189 ++++++++++++++---- 1 file changed, 150 insertions(+), 39 deletions(-) diff --git a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/DistancesASH.java b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/DistancesASH.java index 2d8736477..2dade13f0 100644 --- a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/DistancesASH.java +++ b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/DistancesASH.java @@ -117,6 +117,76 @@ private static String optimizerName(int optimizer) { return "UNKNOWN(" + optimizer + ")"; } + private static void validateRecallScorers(ASHVectors vectors, List> queries, int blockSize) { + float maxBlockVsScalar = 0f; + float maxBlockVsSingle = 0f; + for (int qi : new int[]{0, queries.size() / 2}) { + VectorFloat query = queries.get(qi); + ASHBlockScorer block = vectors.blockScorerFor(query, VectorSimilarityFunction.DOT_PRODUCT, blockSize); + ASHBlockScorer scalar = vectors.blockScorerFor(query, VectorSimilarityFunction.DOT_PRODUCT); + ScoreFunction.ApproximateScoreFunction single = + vectors.scoreFunctionFor(query, VectorSimilarityFunction.DOT_PRODUCT); + printScorerInfo("recall-block", block); + float[] blockScores = new float[blockSize]; + float[] scalarScores = new float[blockSize]; + for (int start = 0; start < vectors.count(); start += blockSize) { + int count = Math.min(blockSize, vectors.count() - start); + block.scoreRange(start, count, blockScores); + scalar.scoreRange(start, count, scalarScores); + for (int lane = 0; lane < count; lane++) { + float actual = blockScores[lane]; + float reference = scalarScores[lane]; + float singleScore = single.similarityTo(start + lane); + if (!Float.isFinite(actual) || !Float.isFinite(reference) || !Float.isFinite(singleScore)) { + throw new AssertionError("Non-finite ASH score at query=" + qi + " ordinal=" + (start + lane)); + } + maxBlockVsScalar = Math.max(maxBlockVsScalar, Math.abs(actual - reference)); + maxBlockVsSingle = Math.max(maxBlockVsSingle, Math.abs(actual - singleScore)); + } + } + } + System.out.printf(java.util.Locale.ROOT, + "\tRecall scorer validation: max block/scalar=%.8f, max block/single=%.8f%n", + maxBlockVsScalar, maxBlockVsSingle); + if (maxBlockVsScalar > 0.005f || maxBlockVsSingle > 0.005f) { + throw new AssertionError("ASH block recall scorer disagrees with scalar or single scoring"); + } + } + + private static void offerTop(java.util.PriorityQueue candidates, int capacity, + float score, int ordinal) { + if (candidates.size() < capacity) { + candidates.add(new long[]{Float.floatToRawIntBits(score), ordinal}); + } else if (score > Float.intBitsToFloat((int) candidates.peek()[0])) { + candidates.poll(); + candidates.add(new long[]{Float.floatToRawIntBits(score), ordinal}); + } + } + + private static int[] rankedOrdinals(java.util.PriorityQueue candidates) { + int[] ranked = new int[candidates.size()]; + for (int rank = ranked.length - 1; rank >= 0; rank--) ranked[rank] = (int) candidates.poll()[1]; + return ranked; + } + + private static void addRecall(int[] ranked, int[] groundTruth, int[] newToOld, + int recallK, int[] atValues, double[] totals, int offset) { + for (int aIdx = 0; aIdx < atValues.length; aIdx++) { + int at = atValues[aIdx]; + java.util.Set topAtSet = new java.util.HashSet<>(); + for (int rank = 0; rank < Math.min(at, ranked.length); rank++) { + topAtSet.add(newToOld == null ? ranked[rank] : newToOld[ranked[rank]]); + } + int matches = 0; + java.util.HashSet gtSeen = new java.util.HashSet<>(recallK * 2); + for (int g = 0; g < recallK; g++) { + int gtId = groundTruth[g]; + if (gtSeen.add(gtId) && topAtSet.contains(gtId)) matches++; + } + totals[offset + aIdx] += (double) matches / recallK; + } + } + public static void testASHEncodings(String filenameBase, String filenameQueries, String filenameGT) throws IOException { // ------------------------------------------------------------ // Benchmark configuration (runtime flags) @@ -127,6 +197,22 @@ public static void testASHEncodings(String filenameBase, String filenameQueries, final boolean RUN_RECALL_CHECK = Boolean.parseBoolean(System.getProperty("jvector.bench.recall", "false")); + final boolean RUN_BLOCK_RECALL = + Boolean.parseBoolean(System.getProperty("jvector.bench.recall.block-scoring", "false")); + + final boolean COMPARE_ALL_RECALL = + Boolean.parseBoolean(System.getProperty("jvector.bench.recall.compare-all", "false")); + + final boolean RECALL_ONLY_TEN = + Boolean.parseBoolean(System.getProperty("jvector.bench.recall.only10", "false")); + + if (COMPARE_ALL_RECALL && !RUN_BLOCK_RECALL) { + throw new IllegalArgumentException("compare-all requires recall.block-scoring=true"); + } + + final boolean RUN_BLOCK_TIMING = + Boolean.parseBoolean(System.getProperty("jvector.bench.block-scoring", "true")); + final int RECALL_K = Integer.getInteger("jvector.bench.recall.k", 10); @@ -147,7 +233,7 @@ public static void testASHEncodings(String filenameBase, String filenameQueries, final int[] BLOCK_SIZES = {32}; // Supported fused/multi-bit block capacities: 8, 16, 32. // How many ASH landmarks to use, C = [1, 64] - final int landmarkCount = 1; + final int landmarkCount = Integer.getInteger("jvector.ash.landmarkCount", 1); List> vectors = SiftLoader.readFvecs(filenameBase); List> queries = SiftLoader.readFvecs(filenameQueries); @@ -399,69 +485,87 @@ public static void testASHEncodings(String filenameBase, String filenameQueries, // [1b] Recall@K run (Parallelized) // ================================================================== if (RUN_RECALL_CHECK) { - int[] atValues = {10, 15, 20, 30, 40, 50}; - int maxAt = 50; + int[] atValues = RECALL_ONLY_TEN ? new int[]{10} : new int[]{10, 15, 20, 30, 40, 50}; + int maxAt = atValues[atValues.length - 1]; List> recallTasks = new ArrayList<>(); - logProgress("\t[stage] Computing " + RECALL_K + "-Recall@K..."); + if (RUN_BLOCK_RECALL) { + validateRecallScorers(ashVectorsFinal, finalQueries, BLOCK_SIZES[0]); + } + + logProgress("\t[stage] Computing " + RECALL_K + "-Recall@K with " + + (COMPARE_ALL_RECALL ? "ASH LUT block, single-vector and scalar" + : RUN_BLOCK_RECALL ? "ASH LUT block" : "ASH single-vector") + " scoring..."); for (int start = 0; start < queries.size(); start += chunkSize) { final int s = start; final int e = Math.min(start + chunkSize, queries.size()); recallTasks.add(simdExecutor.submit(() -> { - double[] localTotalRecall = new double[atValues.length]; + double[] localTotalRecall = new double[atValues.length * (COMPARE_ALL_RECALL ? 3 : 1)]; for (int i = s; i < e; i++) { VectorFloat q = finalQueries.get(i); - ScoreFunction.ApproximateScoreFunction f = ashVecsFinal.scoreFunctionFor(q, VectorSimilarityFunction.DOT_PRODUCT); - - // Min-heap to keep top 50 results (storing score as int bits and ordinal as long) + ScoreFunction.ApproximateScoreFunction f = RUN_BLOCK_RECALL && !COMPARE_ALL_RECALL ? null + : ashVecsFinal.scoreFunctionFor(q, VectorSimilarityFunction.DOT_PRODUCT); + ASHBlockScorer block = RUN_BLOCK_RECALL + ? ashVectorsFinal.blockScorerFor(q, VectorSimilarityFunction.DOT_PRODUCT, BLOCK_SIZES[0]) + : null; + ASHBlockScorer scalar = COMPARE_ALL_RECALL + ? ashVectorsFinal.blockScorerFor(q, VectorSimilarityFunction.DOT_PRODUCT) + : null; + float[] blockScores = RUN_BLOCK_RECALL ? new float[BLOCK_SIZES[0]] : null; + float[] scalarScores = COMPARE_ALL_RECALL ? new float[BLOCK_SIZES[0]] : null; + + // Min-heaps retain the top candidates from each scoring path. var topCandidates = new java.util.PriorityQueue((a, b) -> Float.compare(Float.intBitsToFloat((int) a[0]), Float.intBitsToFloat((int) b[0]))); - for (int j = 0; j < vectors.size(); j++) { - float score = f.similarityTo(j); - if (topCandidates.size() < maxAt) { - topCandidates.add(new long[]{Float.floatToRawIntBits(score), j}); - } else if (score > Float.intBitsToFloat((int) topCandidates.peek()[0])) { - topCandidates.poll(); - topCandidates.add(new long[]{Float.floatToRawIntBits(score), j}); + java.util.PriorityQueue singleCandidates = COMPARE_ALL_RECALL + ? new java.util.PriorityQueue<>((a, b) -> Float.compare(Float.intBitsToFloat((int) a[0]), Float.intBitsToFloat((int) b[0]))) : null; + java.util.PriorityQueue scalarCandidates = COMPARE_ALL_RECALL + ? new java.util.PriorityQueue<>((a, b) -> Float.compare(Float.intBitsToFloat((int) a[0]), Float.intBitsToFloat((int) b[0]))) : null; + for (int startOrdinal = 0; startOrdinal < vectors.size(); startOrdinal += BLOCK_SIZES[0]) { + int count = Math.min(BLOCK_SIZES[0], vectors.size() - startOrdinal); + if (RUN_BLOCK_RECALL) block.scoreRange(startOrdinal, count, blockScores); + if (COMPARE_ALL_RECALL) scalar.scoreRange(startOrdinal, count, scalarScores); + for (int lane = 0; lane < count; lane++) { + int ordinal = startOrdinal + lane; + float score = RUN_BLOCK_RECALL ? blockScores[lane] : f.similarityTo(ordinal); + offerTop(topCandidates, maxAt, score, ordinal); + if (COMPARE_ALL_RECALL) { + offerTop(singleCandidates, maxAt, f.similarityTo(ordinal), ordinal); + offerTop(scalarCandidates, maxAt, scalarScores[lane], ordinal); + } } } - int[] topIndices = new int[topCandidates.size()]; - for (int rank = topCandidates.size() - 1; rank >= 0; rank--) topIndices[rank] = (int) topCandidates.poll()[1]; - int[] queryGT = groundTruth.get(i).stream() .mapToInt(Integer::intValue) .toArray(); - for (int aIdx = 0; aIdx < atValues.length; aIdx++) { - int at = atValues[aIdx]; - java.util.Set topAtSet = new java.util.HashSet<>(); - for (int r = 0; r < Math.min(at, topIndices.length); r++) { - topAtSet.add((newToOldFinal == null) ? topIndices[r] : newToOldFinal[topIndices[r]]); - } - int matches = 0; - java.util.HashSet gtSeen = new java.util.HashSet<>(RECALL_K * 2); - // Compute and don't count duplicates (if present in GT or retrieved IDs) - for (int g = 0; g < RECALL_K; g++) { - int gtId = queryGT[g]; - if (gtSeen.add(gtId) && topAtSet.contains(gtId)) { - matches++; - } - } - localTotalRecall[aIdx] += (double) matches / RECALL_K; + addRecall(rankedOrdinals(topCandidates), queryGT, newToOldFinal, + RECALL_K, atValues, localTotalRecall, 0); + if (COMPARE_ALL_RECALL) { + addRecall(rankedOrdinals(singleCandidates), queryGT, newToOldFinal, + RECALL_K, atValues, localTotalRecall, atValues.length); + addRecall(rankedOrdinals(scalarCandidates), queryGT, newToOldFinal, + RECALL_K, atValues, localTotalRecall, 2 * atValues.length); } } return localTotalRecall; })); } - double[] totalRecall = new double[atValues.length]; + double[] totalRecall = new double[atValues.length * (COMPARE_ALL_RECALL ? 3 : 1)]; for (ForkJoinTask t : recallTasks) { double[] local = t.join(); - for (int aIdx = 0; aIdx < atValues.length; aIdx++) totalRecall[aIdx] += local[aIdx]; + for (int aIdx = 0; aIdx < totalRecall.length; aIdx++) totalRecall[aIdx] += local[aIdx]; } - for (int aIdx = 0; aIdx < atValues.length; aIdx++) { - System.out.format("\tASH %d-recall@%d = %.4f%n", RECALL_K, atValues[aIdx], totalRecall[aIdx] / queries.size()); + String[] scorerNames = COMPARE_ALL_RECALL ? new String[]{"LUT-block", "single", "scalar"} + : new String[]{RUN_BLOCK_RECALL ? "LUT-block" : "single"}; + for (int scorer = 0; scorer < scorerNames.length; scorer++) { + for (int aIdx = 0; aIdx < atValues.length; aIdx++) { + System.out.format(java.util.Locale.ROOT, "\tASH %s %d-recall@%d = %.6f%n", + scorerNames[scorer], RECALL_K, atValues[aIdx], + totalRecall[scorer * atValues.length + aIdx] / queries.size()); + } } } @@ -570,7 +674,7 @@ public static void testASHEncodings(String filenameBase, String filenameQueries, final String kernelMode = System.getProperty("jvector.ash.blockKernel", "auto").toLowerCase(); - for (int blockSize : BLOCK_SIZES) { + if (RUN_BLOCK_TIMING) for (int blockSize : BLOCK_SIZES) { // Print scorer implementation once per blockSize (diagnostic) { @@ -747,6 +851,13 @@ public static void runCohere10m() throws IOException { } public static void main(String[] args) throws IOException { + if (args.length == 3) { + testASHEncodings(args[0], args[1], args[2]); + return; + } + if (args.length != 0) { + throw new IllegalArgumentException("Expected base.fvecs query.fvecs ground-truth.ivecs"); + } // runSIFT(); // runGIST(); // runColbert(); From 32ff4bde87081b716f3c7aa05e9b9701470bf83d Mon Sep 17 00:00:00 2001 From: Ted Willke Date: Fri, 18 Sep 2026 00:12:28 +0000 Subject: [PATCH 066/243] Validate 64-landmark ASH fused graph scoring --- .../quantization/TestASHScoringDispatch.java | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestASHScoringDispatch.java b/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestASHScoringDispatch.java index 1c53ad57f..27c78e9ea 100644 --- a/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestASHScoringDispatch.java +++ b/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestASHScoringDispatch.java @@ -198,6 +198,27 @@ public void readInto(int origin, byte[] dest) { assertEquals(3, reads[0]); } + @Test + public void fusedGraphWith64LandmarksMatchesStandalone() throws Exception { + var provider = VectorizationProvider.getInstance(); + VectorFloat[] input = new VectorFloat[97]; + Random random = new Random(64256); + for (int n = 0; n < input.length; n++) { + input[n] = provider.getVectorTypeSupport().createFloatVector(128); + for (int d = 0; d < 128; d++) input[n].set(d, random.nextFloat() - 0.5f); + VectorUtil.l2normalize(input[n]); + } + var values = MockVectorValues.fromValues(input); + var ash = AsymmetricHashing.initialize(values, AsymmetricHashing.RANDOM, + AsymmetricHashing.HEADER_BITS + 90, 64, 2); + var encoded = ash.encodeAll(values, ForkJoinPool.commonPool()); + var scorer = encoded.scoreFunctionFor(input[3], VectorSimilarityFunction.DOT_PRODUCT); + float[] expected = new float[input.length]; + for (int i = 0; i < input.length; i++) expected[i] = scorer.similarityTo(i); + for (int size : new int[]{8, 16, 32}) checkFused(ash, encoded, input[3], expected, size); + checkDiskGraph(encoded, input); + } + @Test public void forcedSimdCannotSilentlyFallBack() { String old = System.getProperty(BLOCK); From f650cdb70c52c7468d741bcb9a09df39f6aaed1e Mon Sep 17 00:00:00 2001 From: Ted Willke Date: Fri, 18 Sep 2026 06:12:38 +0000 Subject: [PATCH 067/243] Align ASH and PQ single-thread throughput benchmarks --- .../jbellis/jvector/example/DistancesASH.java | 5 +- .../jbellis/jvector/example/DistancesPQ.java | 47 ++++++++++++++----- 2 files changed, 38 insertions(+), 14 deletions(-) diff --git a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/DistancesASH.java b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/DistancesASH.java index 2dade13f0..85c0c5976 100644 --- a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/DistancesASH.java +++ b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/DistancesASH.java @@ -236,7 +236,10 @@ public static void testASHEncodings(String filenameBase, String filenameQueries, final int landmarkCount = Integer.getInteger("jvector.ash.landmarkCount", 1); List> vectors = SiftLoader.readFvecs(filenameBase); - List> queries = SiftLoader.readFvecs(filenameQueries); + List> allQueries = SiftLoader.readFvecs(filenameQueries); + int maxQueries = Integer.getInteger("jvector.bench.maxQueries", allQueries.size()); + if (maxQueries <= 0) throw new IllegalArgumentException("maxQueries must be positive"); + List> queries = allQueries.subList(0, Math.min(maxQueries, allQueries.size())); final List> groundTruth; if (RUN_RECALL_CHECK) { if (filenameGT == null || filenameGT.isBlank()) { diff --git a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/DistancesPQ.java b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/DistancesPQ.java index cbf3572e0..39f96a2b8 100644 --- a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/DistancesPQ.java +++ b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/DistancesPQ.java @@ -23,7 +23,6 @@ import io.github.jbellis.jvector.quantization.CompressedVectors; import io.github.jbellis.jvector.quantization.PQVectors; import io.github.jbellis.jvector.quantization.ProductQuantization; -import io.github.jbellis.jvector.util.PhysicalCoreExecutor; import io.github.jbellis.jvector.vector.VectorSimilarityFunction; import io.github.jbellis.jvector.vector.VectorUtil; import io.github.jbellis.jvector.vector.types.VectorFloat; @@ -40,10 +39,10 @@ public class DistancesPQ { * PQ benchmark aligned with DistancesASH: * * - Same datasets - * - Same query normalization + * - Same loaded query vectors * - Same brute-force scan pattern - * - Same parallel executor - * - Same compression budget (≈ d/32 bytes per vector) + * - Configurable training and single-thread scoring executors + * - Compression budget selected with jvector.pq.mFactor * * Fast PQ path = PQDecoder (precomputedScoreFunctionFor). */ @@ -63,7 +62,11 @@ public static void testPQEncodings(String filenameBase, String filenameQueries, Boolean.parseBoolean(System.getProperty("jvector.bench.float-scoring", "false")); List> vectors = SiftLoader.readFvecs(filenameBase); - List> queries = SiftLoader.readFvecs(filenameQueries); + List> allQueries = SiftLoader.readFvecs(filenameQueries); + int maxQueries = Integer.getInteger("jvector.bench.maxQueries", allQueries.size()); + if (maxQueries <= 0) throw new IllegalArgumentException("maxQueries must be positive"); + final List> queries = allQueries.subList(0, Math.min(maxQueries, allQueries.size())); + System.out.format("\t%d base and %d measured query vectors loaded%n", vectors.size(), queries.size()); final List> groundTruth; if (RUN_RECALL_CHECK) { @@ -98,10 +101,14 @@ public static void testPQEncodings(String filenameBase, String filenameQueries, // ------------------------------------------------------------ // Parallel executor (identical to DistancesASH) // ------------------------------------------------------------ -// ForkJoinPool simdExecutor = PhysicalCoreExecutor.pool(); // For production - ForkJoinPool simdExecutor = new ForkJoinPool(180); // For profiling + int trainingThreads = Integer.getInteger("jvector.bench.trainingThreads", 48); + int scoringThreads = Integer.getInteger("jvector.bench.scoringThreads", 1); + if (trainingThreads <= 0 || scoringThreads <= 0) throw new IllegalArgumentException("Thread counts must be positive"); + ForkJoinPool trainingExecutor = new ForkJoinPool(trainingThreads); + ForkJoinPool simdExecutor = new ForkJoinPool(scoringThreads); int parallelism = simdExecutor.getParallelism(); int chunkSize = Math.max(1, (queries.size() + parallelism - 1) / parallelism); + System.out.println("\tTraining parallelism = " + trainingThreads + "; scoring parallelism = " + parallelism); // ------------------------------------------------------------ // PQ parameters @@ -135,7 +142,7 @@ public static void testPQEncodings(String filenameBase, String filenameQueries, final int M = dimension / mFactor; final int K = 256; // 1 byte per subspace final boolean globallyCenter = false; - final float anisotropicThreshold = 0.0f; + final float anisotropicThreshold = -1.0f; final int inputBytesPerVector = dimension * Float.BYTES; final int compressedBytesPerVector = M; @@ -168,16 +175,17 @@ public static void testPQEncodings(String filenameBase, String filenameQueries, K, globallyCenter, anisotropicThreshold, - simdExecutor, + trainingExecutor, ForkJoinPool.commonPool() ); long pqTrainEnd = System.nanoTime(); long pqEncodeStart = System.nanoTime(); - CompressedVectors pqAny = pq.encodeAll(ravv, simdExecutor); + CompressedVectors pqAny = pq.encodeAll(ravv, trainingExecutor); long pqEncodeEnd = System.nanoTime(); PQVectors pqVecs = (PQVectors) pqAny; + trainingExecutor.shutdown(); System.out.println("\tPQ training took " + (pqTrainEnd - pqTrainStart) / 1e9 + " seconds"); System.out.println("\tPQ encoding took " + (pqEncodeEnd - pqEncodeStart) / 1e9 + " seconds"); @@ -338,6 +346,15 @@ public static void testPQEncodings(String filenameBase, String filenameQueries, // ============================================================ // [2] Fast PQ scan timing (PQDecoder / ADC) // ============================================================ + int warmupQueries = Integer.getInteger("jvector.bench.scoringWarmupQueries", 32); + if (warmupQueries < 0) throw new IllegalArgumentException("scoringWarmupQueries must be nonnegative"); + double warmupSum = 0; + for (int i = 0; i < Math.min(warmupQueries, queries.size()); i++) { + var f = pqVecs.precomputedScoreFunctionFor(queries.get(i), VectorSimilarityFunction.DOT_PRODUCT); + for (int j = 0; j < vectors.size(); j++) warmupSum += f.similarityTo(j); + } + System.out.printf(java.util.Locale.ROOT, "\tPQ warmup: %d queries, checksum=%.6f (excluded from timing)%n", + Math.min(warmupQueries, queries.size()), warmupSum); List> pqTasks = new ArrayList<>(); long pqStart = System.nanoTime(); @@ -365,11 +382,10 @@ public static void testPQEncodings(String filenameBase, String filenameQueries, for (ForkJoinTask t : pqTasks) { pqDummy += t.join(); } - // Prevent dead-code elimination + long pqEnd = System.nanoTime(); + // Prevent dead-code elimination outside the measured interval. System.out.println("\tdummyAccumulator = " + (float) (pqDummy)); System.out.println("--"); - - long pqEnd = System.nanoTime(); System.out.println("\tPQDecoder scan took " + (pqEnd - pqStart) / 1e9 + " seconds"); double blockSeconds = (pqEnd - pqStart) / 1e9; @@ -498,6 +514,11 @@ public static void runCohere10m() throws IOException { } public static void main(String[] args) throws IOException { + if (args.length == 3) { + testPQEncodings(args[0], args[1], args[2]); + return; + } + if (args.length != 0) throw new IllegalArgumentException("Expected base, query, ground-truth paths"); // runCohere100k(); // runADA(); // runADANoZeros(); From fa5b1a3a9e962476ecad46d154311b216c405d79 Mon Sep 17 00:00:00 2001 From: Ted Willke Date: Fri, 18 Sep 2026 16:19:07 +0000 Subject: [PATCH 068/243] Share ASH query projection in fused graph scorer --- .../jvector/quantization/ASHScorer.java | 11 +++--- .../jvector/quantization/FusedASHDecoder.java | 38 ++++--------------- 2 files changed, 12 insertions(+), 37 deletions(-) diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHScorer.java b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHScorer.java index e2fbecb27..e8e151ff7 100644 --- a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHScorer.java +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHScorer.java @@ -98,15 +98,13 @@ public ASHScoreFunction scoreFunctionFor( "ASH scorer supports DOT_PRODUCT only (requested " + similarityFunction + ")" ); } - return dotProductScoreFunctionFor(query); + return scoreFunctionFor(precomputeQuery(query)); } /** * Scorer for dot product approximation. */ - private ASHScoreFunction dotProductScoreFunctionFor(VectorFloat query) { - QueryPrecompute qp = precomputeQuery(query); - + ASHScoreFunction scoreFunctionFor(QueryPrecompute qp) { if (ash.bitsPerDimension == 1) { return oneBitDotProductScoreFunction(qp); } @@ -239,7 +237,8 @@ private ASHScoreFunction genericMultibitDotProductScoreFunction(QueryPrecompute }; } - private QueryPrecompute precomputeQuery(VectorFloat query) { + /** Prepares the projection and landmark terms shared by source and fused scorers. */ + QueryPrecompute precomputeQuery(VectorFloat query) { final int d = ash.quantizedDim; final int D = ash.originalDimension; final int C = ash.landmarkCount; @@ -289,7 +288,7 @@ private QueryPrecompute precomputeQuery(VectorFloat query) { return new QueryPrecompute(d, C, qProj, tildeQPool, sumTildeQByLandmark, dotQMuByLandmark); } - private static final class QueryPrecompute { + static final class QueryPrecompute { final int d; final int C; final float[] qProj; // [d], Aq diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/FusedASHDecoder.java b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/FusedASHDecoder.java index f7fa06281..5c099ffeb 100644 --- a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/FusedASHDecoder.java +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/FusedASHDecoder.java @@ -19,9 +19,6 @@ import io.github.jbellis.jvector.graph.disk.feature.FusedFeature; import io.github.jbellis.jvector.graph.similarity.ScoreFunction; import io.github.jbellis.jvector.vector.VectorSimilarityFunction; -import io.github.jbellis.jvector.vector.VectorUtil; -import io.github.jbellis.jvector.vector.VectorUtilSupport; -import io.github.jbellis.jvector.vector.VectorizationProvider; import io.github.jbellis.jvector.vector.types.VectorFloat; import org.agrona.collections.Int2ObjectHashMap; @@ -130,7 +127,11 @@ public FusedASHDecoder( inlineSourceAccessor, "inlineSourceAccessor" ); - this.sourceScorer = new ASHScorer(ash).scoreFunctionFor(query, similarityFunction); + // Upper-layer source scoring and L0 block scoring use the same Aq and + // values. Prepare them once, then build each scoring form. + var scorer = new ASHScorer(ash); + var preparedQuery = scorer.precomputeQuery(query); + this.sourceScorer = scorer.scoreFunctionFor(preparedQuery); FusedASHLayout.validateBitsPerDimension(ash.bitsPerDimension); FusedASHLayout.validateBlockSize(blockSize); @@ -168,9 +169,8 @@ public FusedASHDecoder( int groups = FusedASHLayout.codeGroups(quantizedDim, bitsPerDimension); this.queryLut = new float[groups * 16]; - this.dotQMuByLandmark = new float[ash.landmarkCount]; - - precomputeQuery(query, queryLut, dotQMuByLandmark); + this.dotQMuByLandmark = preparedQuery.dotQMuByLandmark; + FusedASHLayout.buildQueryLut(preparedQuery.qProj, quantizedDim, bitsPerDimension, queryLut); } public static FusedASHDecoder newDecoder( @@ -279,28 +279,4 @@ private void scoreNeighborhood() { @Override public String toString() { return "FusedASHDecoder[" + kernel + "]"; } - - private void precomputeQuery(VectorFloat query, float[] lut, float[] dotQMu) { - final int D = ash.originalDimension; - final int d = ash.quantizedDim; - final float[][] A = ash.stiefelTransform.AFloat; - - final VectorUtilSupport vecUtil = VectorizationProvider.getInstance().getVectorUtilSupport(); - - float[] qArr = new float[D]; - for (int i = 0; i < D; i++) { - qArr[i] = query.get(i); - } - - float[] qProj = new float[d]; - for (int j = 0; j < d; j++) { - qProj[j] = vecUtil.ashDotRow(A[j], qArr); - } - - FusedASHLayout.buildQueryLut(qProj, d, bitsPerDimension, lut); - - for (int c = 0; c < ash.landmarkCount; c++) { - dotQMu[c] = VectorUtil.dotProduct(query, ash.landmarks[c]); - } - } } From 3695c3f9617ce14ae780b8224fe2b3102ddd7ffa Mon Sep 17 00:00:00 2001 From: Ted Willke Date: Fri, 18 Sep 2026 20:05:32 +0000 Subject: [PATCH 069/243] Write fused ASH neighborhoods in bulk --- .../github/jbellis/jvector/graph/disk/feature/FusedASH.java | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/graph/disk/feature/FusedASH.java b/jvector-base/src/main/java/io/github/jbellis/jvector/graph/disk/feature/FusedASH.java index e30ac5c4b..03bbf3a45 100644 --- a/jvector-base/src/main/java/io/github/jbellis/jvector/graph/disk/feature/FusedASH.java +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/graph/disk/feature/FusedASH.java @@ -245,9 +245,7 @@ private static float dotOneBitProjectionCode(float[] projected, long[] signWords } private static void writeBytes(IndexWriter out, byte[] bytes, int length) throws IOException { - for (int i = 0; i < length; i++) { - out.writeByte(bytes[i]); - } + out.write(bytes, 0, length); } public ScoreFunction.ApproximateScoreFunction approximateScoreFunctionFor( From 3dba09fa38342bbe22f38181f2d7b5200ff89de8 Mon Sep 17 00:00:00 2001 From: Ted Willke Date: Sat, 19 Sep 2026 04:54:07 +0000 Subject: [PATCH 070/243] Complete symmetric ASH construction scoring for packed codes --- .../graph/similarity/BuildScoreProvider.java | 59 +----- .../quantization/ASHSymmetricScorer.java | 142 +++++++++++++ .../quantization/TestASHSymmetricScorer.java | 186 ++++++++++++++++++ 3 files changed, 332 insertions(+), 55 deletions(-) create mode 100644 jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHSymmetricScorer.java create mode 100644 jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestASHSymmetricScorer.java diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/graph/similarity/BuildScoreProvider.java b/jvector-base/src/main/java/io/github/jbellis/jvector/graph/similarity/BuildScoreProvider.java index 493ad73fa..be6208051 100644 --- a/jvector-base/src/main/java/io/github/jbellis/jvector/graph/similarity/BuildScoreProvider.java +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/graph/similarity/BuildScoreProvider.java @@ -20,6 +20,7 @@ import io.github.jbellis.jvector.graph.RemappedRandomAccessVectorValues; import io.github.jbellis.jvector.quantization.AsymmetricHashing; import io.github.jbellis.jvector.quantization.ASHVectors; +import io.github.jbellis.jvector.quantization.ASHSymmetricScorer; import io.github.jbellis.jvector.quantization.BQVectors; import io.github.jbellis.jvector.quantization.PQVectors; import io.github.jbellis.jvector.vector.VectorSimilarityFunction; @@ -277,7 +278,7 @@ public float similarityTo(int node2) { * Returns a BSP that performs approximate score comparisons using the given ASHVectors. * *

    Construction (node-node) scoring: Uses symmetric ASH–ASH dot-product approximation - * computed from the encoded binary payload + per-vector header fields (scale, offset). + * computed from the complete encoded payload + per-vector header fields (scale, offset). * *

    Query (vector-node) scoring: Delegates to ASHVectors' query-time score function * (asymmetric float query → ASH vector). @@ -295,10 +296,7 @@ static BuildScoreProvider ashBuildScoreProvider(VectorSimilarityFunction vsf, AS throw new IllegalArgumentException("ASH build scoring requires landmarkCount==1, got " + ash.landmarkCount); } - final int d = ash.quantizedDim; - - // ||mu||^2 is a constant (C=1) for this scorer. - final float muNormSq = VectorUtil.dotProduct(ash.landmarks[0], ash.landmarks[0]); + final var symmetric = new ASHSymmetricScorer(ashv); return new BuildScoreProvider() { @Override @@ -321,56 +319,7 @@ public SearchScoreProvider searchProviderFor(VectorFloat vector) { @Override public SearchScoreProvider searchProviderFor(int node1) { - - final AsymmetricHashing.QuantizedVector v1 = ashv.get(node1); - - return new DefaultSearchScoreProvider(new ScoreFunction() { - @Override - public boolean isExact() { - return false; - } - - @Override - public float similarityTo(int node2) { - final AsymmetricHashing.QuantizedVector v2 = ashv.get(node2); - - final long[] aBits = v1.binaryVector; - final long[] bBits = v2.binaryVector; - - int matches = 0; - - int bitBase = 0; - for (int w = 0; w < aBits.length && bitBase < d; w++, bitBase += 64) { - long aw = aBits[w]; - long bw = bBits[w]; - - // Defensive tail masking (should be unnecessary when d is 64-bit aligned) - final int remaining = d - bitBase; - if (remaining < 64) { - final long mask = (remaining == 64) ? ~0L : ((1L << remaining) - 1L); - aw &= mask; - bw &= mask; - } - - // matches = count of equal bits across positions (XNOR-popcount) - matches += Long.bitCount(~(aw ^ bw)); - } - - // g(.) is sign-coded; with bits interpreted as {0,1} representing { -1, +1 } via s = 2b - 1: - // = 2*matches - d - final float signDot = 2.0f * matches - (float) d; - - // Reconstruction (C=1): - // ≈ ||x-μ|| ||y-μ|| + + - ||μ||^2 - // - // with: - // scale = ||x-μ|| / sqrt(d) - // offset = - ||μ||^2 - // and decoder scaling f(z)=d^{-1/2}Az implies ≈ (1/d) - // so centered term becomes: scale_x * scale_y * - return (v1.scale * v2.scale) * signDot + v1.offset + v2.offset + muNormSq; - } - }, null, true, true); + return new DefaultSearchScoreProvider(symmetric.scoreFunctionFor(node1), null, true); } @Override diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHSymmetricScorer.java b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHSymmetricScorer.java new file mode 100644 index 000000000..87068ab19 --- /dev/null +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHSymmetricScorer.java @@ -0,0 +1,142 @@ +/* + * 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 io.github.jbellis.jvector.graph.similarity.ScoreFunction; +import io.github.jbellis.jvector.vector.VectorUtil; + +/** + * Compressed-to-compressed ASH dot products for C=1 (paper Appendix B, B.2-B.4). + * The codes remain packed; no original vectors or per-pair projections are needed. + * Instances require immutable encoded vectors, as does ASHVectors itself. + */ +public final class ASHSymmetricScorer { + private static final short[] DOT_2 = nibbleProducts(2); + private static final short[] DOT_4 = nibbleProducts(4); + private final ASHVectors vectors; + private final int dimensions; + private final int bits; + private final float muNormSquared; + // Recover -||mu||^2 once from the projection-mode query offset. + // Four bytes per vector, only for 2/4-bit construction; never serialized. + private final float[] centeredOffsets; + + public ASHSymmetricScorer(ASHVectors vectors) { + var ash = vectors.getCompressor(); + if (ash.landmarkCount != 1) { + throw new IllegalArgumentException("Symmetric ASH scoring requires landmarkCount=1"); + } + this.vectors = vectors; + dimensions = ash.quantizedDim; + bits = ash.bitsPerDimension; + muNormSquared = VectorUtil.dotProduct(ash.landmarks[0], ash.landmarks[0]); + if (AsymmetricHashing.usesFastScanProjectionCode(bits)) { + centeredOffsets = new float[vectors.count()]; + for (int i = 0; i < centeredOffsets.length; i++) { + var v = vectors.get(i); + centeredOffsets[i] = v.offset + v.scale * AsymmetricHashing.dotProjectionCode( + ash.landmarkProj[0], v.extraBits, dimensions, bits); + } + } else { + centeredOffsets = null; + } + } + + private float offset(int node) { + return centeredOffsets == null ? vectors.get(node).offset : centeredOffsets[node]; + } + + /** Raw dot product, for diagnostics and independent comparison with input-space ground truth. */ + public float dotProduct(int a, int b) { + var x = vectors.get(a); + var y = vectors.get(b); + // Sum offsets first to preserve exactly the same float result in both directions. + return (x.scale * y.scale) * codeDot(x, y, dimensions, bits) + + (offset(a) + offset(b)) + muNormSquared; + } + + /** Graph-facing similarity, using the same normalization as asymmetric ASH. */ + public ScoreFunction.ApproximateScoreFunction scoreFunctionFor(int node) { + return other -> ASHScorer.toSimilarity(dotProduct(node, other)); + } + + static float codeDot(AsymmetricHashing.QuantizedVector a, + AsymmetricHashing.QuantizedVector b, int d, int bits) { + if (bits == 1) { + long mismatches = 0; + int words = d >>> 6; + for (int w = 0; w < words; w++) { + mismatches += Long.bitCount(a.binaryVector[w] ^ b.binaryVector[w]); + } + int tail = d & 63; + if (tail != 0) { + mismatches += Long.bitCount((a.binaryVector[words] ^ b.binaryVector[words]) + & ((1L << tail) - 1)); + } + return d - 2f * mismatches; + } + if (bits == 2 || bits == 4) { + // Each table entry sums products of doubled (odd integer) code values. + // 256 entries = 512 bytes; shared across all nodes and threads. + short[] table = bits == 2 ? DOT_2 : DOT_4; + int dimsPerByte = 8 / bits; + int bytes = d / dimsPerByte; + long sum = 0; + for (int i = 0; i < bytes; i++) { + int x = a.extraBits[i] & 255; + int y = b.extraBits[i] & 255; + sum += table[((x & 15) << 4) | (y & 15)] + + table[(x & 240) | (y >>> 4)]; + } + for (int j = bytes * dimsPerByte; j < d; j++) { + int x = (int) (2 * AsymmetricHashing.projectionComponent(a.extraBits, j, bits)); + int y = (int) (2 * AsymmetricHashing.projectionComponent(b.extraBits, j, bits)); + sum += x * y; + } + return sum * 0.25f; + } + // Generic sign+extra representation supports the remaining widths through 9 bits. + int extra = bits - 1; + int bias = (1 << bits) - 1; + long sum = 0; + for (int j = 0; j < d; j++) { + int x = ((AsymmetricHashing.QuantizedVector.getBit(a.binaryVector, j) ? 1 : 0) << extra) + + AsymmetricHashing.QuantizedVector.readExtraCode(a.extraBits, j, extra); + int y = ((AsymmetricHashing.QuantizedVector.getBit(b.binaryVector, j) ? 1 : 0) << extra) + + AsymmetricHashing.QuantizedVector.readExtraCode(b.extraBits, j, extra); + sum += (long) (2 * x - bias) * (2 * y - bias); + } + return sum * 0.25f; + } + + private static short[] nibbleProducts(int bits) { + short[] table = new short[256]; + int mask = (1 << bits) - 1; + for (int a = 0; a < 16; a++) { + for (int b = 0; b < 16; b++) { + int sum = 0; + for (int shift = 0; shift < 4; shift += bits) { + int x = (int) (2 * AsymmetricHashing.decodeProjectionComponent((a >>> shift) & mask, bits)); + int y = (int) (2 * AsymmetricHashing.decodeProjectionComponent((b >>> shift) & mask, bits)); + sum += x * y; + } + table[(a << 4) | b] = (short) sum; + } + } + return table; + } +} diff --git a/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestASHSymmetricScorer.java b/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestASHSymmetricScorer.java new file mode 100644 index 000000000..fd1a4dd9b --- /dev/null +++ b/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestASHSymmetricScorer.java @@ -0,0 +1,186 @@ +/* + * 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 io.github.jbellis.jvector.graph.MockVectorValues; +import io.github.jbellis.jvector.graph.GraphIndexBuilder; +import io.github.jbellis.jvector.graph.GraphSearcher; +import io.github.jbellis.jvector.graph.similarity.BuildScoreProvider; +import io.github.jbellis.jvector.util.Bits; +import io.github.jbellis.jvector.vector.VectorizationProvider; +import io.github.jbellis.jvector.vector.VectorSimilarityFunction; +import io.github.jbellis.jvector.vector.VectorUtil; +import io.github.jbellis.jvector.vector.types.VectorFloat; +import org.junit.Test; +import java.util.Random; +import static org.junit.Assert.*; + +public class TestASHSymmetricScorer { + // Deliberately independent unpacking oracle, including padding with arbitrary bits. + private static float component(AsymmetricHashing.QuantizedVector v, int j, int bits) { + if (bits == 1) return ((v.binaryVector[j / 64] >>> (j % 64)) & 1) == 0 ? -1f : 1f; + if (bits == 2 || bits == 4) { + int field = (v.extraBits[j * bits / 8] >>> (j * bits % 8)) & ((1 << bits) - 1); + float magnitude = (field & ((1 << (bits - 1)) - 1)) + 0.5f; + return (field & (1 << (bits - 1))) == 0 ? -magnitude : magnitude; + } + int code = 0; + for (int k = 0; k < bits - 1; k++) { + int pos = j * (bits - 1) + k; + code |= ((v.extraBits[pos / 8] >>> (pos % 8)) & 1) << k; + } + code |= ((v.binaryVector[j / 64] >>> (j % 64)) & 1) << (bits - 1); + return code - ((1 << (bits - 1)) - 0.5f); + } + + @Test public void packedDotMatchesIndependentDecoderAndSimdIncludingTails() { + Random random = new Random(981234); + var backend = VectorizationProvider.getInstance().getVectorUtilSupport(); + for (int bits = 1; bits <= 9; bits++) { + for (int d = 1; d <= 749; d++) { + var a = AsymmetricHashing.QuantizedVector.createEmpty(d, bits); + var b = AsymmetricHashing.QuantizedVector.createEmpty(d, bits); + for (int w = 0; w < a.binaryVector.length; w++) { + a.binaryVector[w] = random.nextLong(); b.binaryVector[w] = random.nextLong(); + } + random.nextBytes(a.extraBits); random.nextBytes(b.extraBits); + double expected = 0; + float[] unpacked = new float[d]; + for (int j = 0; j < d; j++) { + unpacked[j] = component(a, j, bits); + expected += unpacked[j] * component(b, j, bits); + } + float actual = ASHSymmetricScorer.codeDot(a, b, d, bits); + assertEquals("bits=" + bits + " d=" + d, (float) expected, actual, 0f); + assertEquals(actual, ASHSymmetricScorer.codeDot(b, a, d, bits), 0f); + if ((bits == 2 || bits == 4) && backend.supportsAshProjectionScoring()) { + assertEquals(actual, backend.ashProjectionDot(unpacked, b.extraBits, d, bits), 0f); + } + } + } + } + + private static VectorFloat[] inputs(int n, int d) { + var vts = VectorizationProvider.getInstance().getVectorTypeSupport(); + Random r = new Random(773); + VectorFloat[] input = new VectorFloat[n]; + for (int i = 0; i < n; i++) { + input[i] = vts.createFloatVector(d); + for (int j = 0; j < d; j++) input[i].set(j, r.nextFloat() - 0.15f); + VectorUtil.l2normalize(input[i]); + } + return input; + } + + @Test public void appendixBMatchesDecodedReconstructionAndHeaderRoundTrip() throws Exception { + var input = inputs(25, 40); + var values = MockVectorValues.fromValues(input); + for (int bits = 1; bits <= 9; bits++) { + var ash = AsymmetricHashing.initialize(values, AsymmetricHashing.RANDOM, + AsymmetricHashing.HEADER_BITS + 31 * bits, 1, bits); + var encoded = ash.encodeAll(values, java.util.concurrent.ForkJoinPool.commonPool()); + var scorer = new ASHSymmetricScorer(encoded); + var bsp = BuildScoreProvider.ashBuildScoreProvider(VectorSimilarityFunction.DOT_PRODUCT, encoded); + for (int i = 0; i < input.length; i++) { + for (int j = 0; j < input.length; j++) { + var a = encoded.get(i); var b = encoded.get(j); + double dot = 0, ma = 0, mb = 0; + var decodedX = VectorizationProvider.getInstance().getVectorTypeSupport().createFloatVector(40); + // Explicit W^T reconstruction exercises the orthonormal simplification. + for (int k = 0; k < 40; k++) { + double x = 0, y = 0; + for (int l = 0; l < 31; l++) { + x += ash.stiefelTransform.AFloat[l][k] * component(a, l, bits); + y += ash.stiefelTransform.AFloat[l][k] * component(b, l, bits); + } + decodedX.set(k, ash.landmarks[0].get(k) + a.scale * (float) x); + dot += x * y; + ma += x * ash.landmarks[0].get(k); + mb += y * ash.landmarks[0].get(k); + } + double oa = a.offset, ob = b.offset; + if (bits == 2 || bits == 4) { oa += a.scale * ma; ob += b.scale * mb; } + double expected = a.scale * b.scale * dot + oa + ob + + VectorUtil.dotProduct(ash.landmarks[0], ash.landmarks[0]); + assertEquals(expected, scorer.dotProduct(i,j), 2e-5); + // ASH preserves the original landmark dot via the header, rather than + // substituting the reconstructed source's landmark dot. + double sourceCorrection = a.offset; + if (bits != 2 && bits != 4) sourceCorrection -= a.scale * ma; + float decodedAsymmetric = new ASHScorer(ash) + .scoreFunctionFor(decodedX, VectorSimilarityFunction.DOT_PRODUCT).similarityTo(b); + assertEquals(decodedAsymmetric + sourceCorrection, scorer.dotProduct(i,j), 2e-5); + assertEquals(scorer.dotProduct(i,j), scorer.dotProduct(j,i), 0f); + assertEquals(ASHScorer.toSimilarity(scorer.dotProduct(i,j)), + bsp.searchProviderFor(i).scoreFunction().similarityTo(j), 0f); + assertEquals(bsp.searchProviderFor(i).scoreFunction().similarityTo(j), + bsp.diversityScoreFunctionFor(i).similarityTo(j), 0f); + } + assertEquals("Self score should reconstruct the input norm, up to FP16 headers", + VectorUtil.dotProduct(input[i],input[i]), scorer.dotProduct(i,i), 0.002); + } + try (var out = io.github.jbellis.jvector.disk.ByteBufferIndexWriter.create(1024*1024,false)) { + encoded.write(out, io.github.jbellis.jvector.graph.disk.OnDiskGraphIndex.CURRENT_VERSION); + var reloaded = ASHVectors.load(new io.github.jbellis.jvector.disk.ByteBufferReader(out.getWrittenData())); + assertEquals(scorer.dotProduct(2,7), new ASHSymmetricScorer(reloaded).dotProduct(2,7), 0f); + } + } + } + + @Test public void zeroResidualAtLandmarkStaysFinite() throws Exception { + var input = inputs(20,40); + for (int i = 1; i < input.length; i++) input[i] = input[0].copy(); + var values = MockVectorValues.fromValues(input); + for (int bits : new int[]{1,2,4,9}) { + var ash = AsymmetricHashing.initialize(values, AsymmetricHashing.RANDOM, + AsymmetricHashing.HEADER_BITS + 31*bits,1,bits); + var encoded = ash.encodeAll(values, java.util.concurrent.ForkJoinPool.commonPool()); + float score = new ASHSymmetricScorer(encoded).dotProduct(0,1); + assertEquals(VectorUtil.dotProduct(input[0],input[1]), score, 0.0001f); + } + } + + @Test public void rejectsMultipleLandmarks() throws Exception { + var values = MockVectorValues.fromValues(inputs(25,40)); + var ash = AsymmetricHashing.initialize(values,AsymmetricHashing.RANDOM, + AsymmetricHashing.HEADER_BITS+31*2,2,2); + assertThrows(IllegalArgumentException.class, () -> new ASHSymmetricScorer(ash.encodeAll(values, java.util.concurrent.ForkJoinPool.commonPool()))); + } + + @Test public void constructionWorksWithPackedCodesAndNegativeSimilarities() throws Exception { + var input = inputs(80,40); + for (int d = 0; d < 40; d++) input[1].set(d, -input[0].get(d)); + var values = MockVectorValues.fromValues(input); + for (int bits : new int[]{1,2,4}) { + var ash = AsymmetricHashing.initialize(values,AsymmetricHashing.RANDOM, + AsymmetricHashing.HEADER_BITS+31*bits,1,bits); + var encoded = ash.encodeAll(values, java.util.concurrent.ForkJoinPool.commonPool()); + var bsp = BuildScoreProvider.ashBuildScoreProvider(VectorSimilarityFunction.DOT_PRODUCT,encoded); + assertTrue(new ASHSymmetricScorer(encoded).dotProduct(0,1) < 0f); + assertFalse(bsp.searchProviderFor(0).adaptiveScoresAreRawDotProducts()); + try (var builder = new GraphIndexBuilder(bsp,40,16,40,1.2f,1.2f,true,true)) { + for (int i=0;i Date: Sat, 19 Sep 2026 05:11:26 +0000 Subject: [PATCH 071/243] Benchmark prepared symmetric ASH queries in DistancesASH --- .../quantization/ASHSymmetricScorer.java | 23 +++ .../jbellis/jvector/example/DistancesASH.java | 182 +++++++++++++++++- .../quantization/TestASHSymmetricScorer.java | 2 + 3 files changed, 203 insertions(+), 4 deletions(-) diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHSymmetricScorer.java b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHSymmetricScorer.java index 87068ab19..77b04c813 100644 --- a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHSymmetricScorer.java +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHSymmetricScorer.java @@ -74,6 +74,29 @@ public ScoreFunction.ApproximateScoreFunction scoreFunctionFor(int node) { return other -> ASHScorer.toSimilarity(dotProduct(node, other)); } + /** + * Prepare an external query already encoded with this scorer's compressor. + * Query encoding and landmark-offset recovery happen before pairwise scoring. + * The caller must retain the immutable query payload while using the function. + */ + public ScoreFunction.ApproximateScoreFunction scoreFunctionFor(AsymmetricHashing.QuantizedVector query) { + if (query.landmark != 0) { + throw new IllegalArgumentException("Symmetric ASH query requires landmark zero"); + } + float queryOffset = query.offset; + if (AsymmetricHashing.usesFastScanProjectionCode(bits)) { + queryOffset += query.scale * AsymmetricHashing.dotProjectionCode( + vectors.getCompressor().landmarkProj[0], query.extraBits, dimensions, bits); + } + final float centeredQueryOffset = queryOffset; + return node -> { + var target = vectors.get(node); + float dot = (query.scale * target.scale) * codeDot(query, target, dimensions, bits) + + (centeredQueryOffset + offset(node)) + muNormSquared; + return ASHScorer.toSimilarity(dot); + }; + } + static float codeDot(AsymmetricHashing.QuantizedVector a, AsymmetricHashing.QuantizedVector b, int d, int bits) { if (bits == 1) { diff --git a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/DistancesASH.java b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/DistancesASH.java index 85c0c5976..8a6279c3c 100644 --- a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/DistancesASH.java +++ b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/DistancesASH.java @@ -187,6 +187,162 @@ private static void addRecall(int[] ranked, int[] groundTruth, int[] newToOld, } } + /** Independent component decoder for validation; never used in timed scoring. */ + private static double symmetricComponent(AsymmetricHashing.QuantizedVector v, int j, int bits) { + if (bits == 1) return ((v.binaryVector[j / 64] >>> (j % 64)) & 1L) == 0 ? -1 : 1; + if (bits != 2 && bits != 4) { + int code = 0; + for (int k = 0; k < bits - 1; k++) { + int pos = j * (bits - 1) + k; + code |= ((v.extraBits[pos / 8] >>> (pos % 8)) & 1) << k; + } + code |= ((v.binaryVector[j / 64] >>> (j % 64)) & 1L) << (bits - 1); + return code - ((1 << (bits - 1)) - 0.5); + } + int field = (v.extraBits[j * bits / 8] >>> (j * bits % 8)) & ((1 << bits) - 1); + double magnitude = (field & ((1 << (bits - 1)) - 1)) + 0.5; + return (field & (1 << (bits - 1))) == 0 ? -magnitude : magnitude; + } + + private static double decodedSymmetric(AsymmetricHashing ash, + AsymmetricHashing.QuantizedVector a, + AsymmetricHashing.QuantizedVector b) { + double dot = 0, ma = 0, mb = 0; + for (int j = 0; j < ash.quantizedDim; j++) { + double x = symmetricComponent(a, j, ash.bitsPerDimension); + double y = symmetricComponent(b, j, ash.bitsPerDimension); + dot += x * y; + ma += x * ash.landmarkProj[0][j]; + mb += y * ash.landmarkProj[0][j]; + } + double oa = a.offset, ob = b.offset; + if (ash.bitsPerDimension == 2 || ash.bitsPerDimension == 4) { + oa += a.scale * ma; + ob += b.scale * mb; + } + double muNorm = 0; + for (int j = 0; j < ash.landmarks[0].length(); j++) { + double x = ash.landmarks[0].get(j); + muNorm += x * x; + } + return Math.max(0, (1 + (double) a.scale * b.scale * dot + oa + ob + muNorm) / 2); + } + + /** Prepared scoring comparison: all query encoding/projection is outside every timer. */ + private static void benchmarkSymmetric(ASHVectors vectors, List> queries, + List> groundTruth, int[] newToOld, + int recallK, boolean recall, ForkJoinPool executor) { + var ash = vectors.getCompressor(); + if (ash.landmarkCount != 1) throw new IllegalArgumentException("Symmetric scoring requires C=1"); + int passes = Integer.getInteger("jvector.bench.scoringPasses", 5); + int timingQueries = Math.min(queries.size(), Integer.getInteger("jvector.bench.scoringQueries", 128)); + int warmupQueries = Math.min(timingQueries, Integer.getInteger("jvector.bench.scoringWarmupQueries", 32)); + if (passes < 1 || timingQueries < 1 || warmupQueries < 0) { + throw new IllegalArgumentException("Invalid symmetric scoring pass/query counts"); + } + long prepareStart = System.nanoTime(); + var encodedQueries = new AsymmetricHashing.QuantizedVector[queries.size()]; + for (int q = 0; q < queries.size(); q++) encodedQueries[q] = ash.encode(queries.get(q)); + double encodeSeconds = (System.nanoTime() - prepareStart) / 1e9; + prepareStart = System.nanoTime(); + var symmetric = new io.github.jbellis.jvector.quantization.ASHSymmetricScorer(vectors); + ScoreFunction[][] prepared = new ScoreFunction[2][queries.size()]; + for (int q = 0; q < queries.size(); q++) { + prepared[0][q] = symmetric.scoreFunctionFor(encodedQueries[q]); + prepared[1][q] = vectors.scoreFunctionFor(queries.get(q), VectorSimilarityFunction.DOT_PRODUCT); + } + System.out.printf(java.util.Locale.ROOT, + "SYMMETRIC_PREPARATION query_encoding_s=%.6f scorer_setup_s=%.6f queries=%d (excluded from timing)%n", + encodeSeconds, (System.nanoTime() - prepareStart) / 1e9, queries.size()); + + double maxError = 0; + for (int q = 0; q < Math.min(4, queries.size()); q++) { + int samples = Math.min(512, vectors.count()); + for (int sample = 0; sample < samples; sample++) { + int node = samples == 1 ? 0 : (int) ((long) sample * (vectors.count() - 1) / (samples - 1)); + double expected = decodedSymmetric(ash, encodedQueries[q], vectors.get(node)); + float actual = prepared[0][q].similarityTo(node); + double error = Math.abs(actual - expected); + if (!Float.isFinite(actual) || !Double.isFinite(expected) + || error > 5e-5 * Math.max(1, Math.abs(expected))) { + throw new AssertionError("Symmetric decoder mismatch q=" + q + " node=" + node + + " expected=" + expected + " actual=" + actual); + } + maxError = Math.max(maxError, error); + } + } + System.out.printf(java.util.Locale.ROOT, "SYMMETRIC_VALIDATION max_abs_error=%.9g (independent decoded oracle)%n", maxError); + String symmetricName = ash.bitsPerDimension == 1 ? "symmetric-popcount" + : ash.bitsPerDimension == 2 || ash.bitsPerDimension == 4 ? "symmetric-packed" : "symmetric-generic"; + String[] names = {symmetricName, "asymmetric-" + singleMode + "-prepared"}; + if (recall) { + if (recallK < 1 || recallK > vectors.count() || groundTruth.size() < queries.size()) { + throw new IllegalArgumentException("Invalid recall size or missing query ground truth"); + } + double[] totals = new double[2]; + for (int q = 0; q < queries.size(); q++) { + int[] gt = groundTruth.get(q).stream().mapToInt(Integer::intValue).toArray(); + if (gt.length < recallK) throw new IllegalArgumentException("Ground truth row too short"); + for (int mode = 0; mode < 2; mode++) { + var heap = new java.util.PriorityQueue((a, b) -> Float.compare( + Float.intBitsToFloat((int) a[0]), Float.intBitsToFloat((int) b[0]))); + for (int node = 0; node < vectors.count(); node++) { + offerTop(heap, recallK, prepared[mode][q].similarityTo(node), node); + } + addRecall(rankedOrdinals(heap), gt, newToOld, recallK, new int[]{recallK}, totals, mode); + } + } + for (int mode = 0; mode < 2; mode++) { + System.out.printf(java.util.Locale.ROOT, "PREPARED_RECALL mode=%s queries=%d %d-recall@%d=%.6f%n", + names[mode], queries.size(), recallK, recallK, totals[mode] / queries.size()); + } + } + for (int mode = 0; mode < 2; mode++) { + scanPrepared(prepared[mode], warmupQueries, vectors.count(), executor); + } + long[][] times = new long[2][passes]; + for (int pass = 0; pass < passes; pass++) { + for (int order = 0; order < 2; order++) { + int mode = (pass + order) & 1; + long start = System.nanoTime(); + double checksum = scanPrepared(prepared[mode], timingQueries, vectors.count(), executor); + times[mode][pass] = System.nanoTime() - start; + System.out.printf(java.util.Locale.ROOT, "PREPARED_PASS mode=%s pass=%d seconds=%.6f checksum=%.6f%n", + names[mode], pass + 1, times[mode][pass] / 1e9, checksum); + } + } + long pairs = (long) timingQueries * vectors.count(); + for (int mode = 0; mode < 2; mode++) { + java.util.Arrays.sort(times[mode]); + double median = times[mode][passes / 2]; + System.out.printf(java.util.Locale.ROOT, + "PREPARED_THROUGHPUT mode=%s threads=%d queries=%d passes=%d median_Mdot_s=%.6f median_ns_pair=%.3f min_s=%.6f max_s=%.6f%n", + names[mode], executor.getParallelism(), timingQueries, passes, + pairs * 1000.0 / median, median / pairs, + times[mode][0] / 1e9, times[mode][passes - 1] / 1e9); + } + } + + private static double scanPrepared(ScoreFunction[] scorers, int queries, int count, ForkJoinPool executor) { + if (queries == 0) return 0; + int workers = Math.min(executor.getParallelism(), queries); + List> tasks = new ArrayList<>(); + for (int worker = 0; worker < workers; worker++) { + final int first = worker; + tasks.add(executor.submit(() -> { + double sum = 0; + for (int q = first; q < queries; q += workers) { + var scorer = scorers[q]; + for (int node = 0; node < count; node++) sum += scorer.similarityTo(node); + } + return sum; + })); + } + double sum = 0; + for (var task : tasks) sum += task.join(); + return sum; + } + public static void testASHEncodings(String filenameBase, String filenameQueries, String filenameGT) throws IOException { // ------------------------------------------------------------ // Benchmark configuration (runtime flags) @@ -261,6 +417,9 @@ public static void testASHEncodings(String filenameBase, String filenameQueries, // but are NOT normalized inside the encoder. // - No other normalization steps are applied. + if (vectors.isEmpty() || queries.isEmpty()) { + throw new IllegalArgumentException("Base and query vectors must be nonempty"); + } int dimension = vectors.get(0).length(); final int bitsPerDimension = @@ -269,11 +428,11 @@ public static void testASHEncodings(String filenameBase, String filenameQueries, final int quantizedDimensions = Integer.getInteger("jvector.ash.quantizedDimensions", dimension / 2); - if (bitsPerDimension != 1 - && bitsPerDimension != 2 - && bitsPerDimension != 4) { + if (bitsPerDimension < 1 || bitsPerDimension > 9 + || (!Boolean.getBoolean("jvector.bench.symmetric-scoring") + && bitsPerDimension != 1 && bitsPerDimension != 2 && bitsPerDimension != 4)) { throw new IllegalArgumentException( - "bitsPerDimension must be 1, 2, or 4: " + bitsPerDimension); + "bitsPerDimension must be 1, 2, or 4 (symmetric comparison also supports widths 3 through 9): " + bitsPerDimension); } if (quantizedDimensions <= 0 || quantizedDimensions > dimension) { @@ -292,6 +451,9 @@ public static void testASHEncodings(String filenameBase, String filenameQueries, + ", payloadBits=" + payloadBits + ", encodedBits=" + encodedBits); + if (Boolean.getBoolean("jvector.bench.symmetric-scoring") && landmarkCount != 1) { + throw new IllegalArgumentException("Symmetric scoring requires landmarkCount=1"); + } final List> finalQueries = queries; final List> finalVectors = vectors; @@ -436,6 +598,18 @@ public static void testASHEncodings(String filenameBase, String filenameQueries, System.out.println("\tScoring parallelism = " + parallelism); + if (Boolean.getBoolean("jvector.bench.symmetric-scoring")) { + // A separate comparison mode uses identical scan loops and prepared scorers. + // Encoding, projection, validation, recall, and warmup are all excluded. + try { + benchmarkSymmetric(ashVectorsFinal, finalQueries, groundTruth, newToOldFinal, + RECALL_K, RUN_RECALL_CHECK, simdExecutor); + } finally { + simdExecutor.shutdown(); + } + return; + } + // ================================================================== // [1] Accuracy run (NOT timed) // ================================================================== diff --git a/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestASHSymmetricScorer.java b/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestASHSymmetricScorer.java index fd1a4dd9b..ab03ead20 100644 --- a/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestASHSymmetricScorer.java +++ b/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestASHSymmetricScorer.java @@ -125,6 +125,8 @@ private static VectorFloat[] inputs(int n, int d) { .scoreFunctionFor(decodedX, VectorSimilarityFunction.DOT_PRODUCT).similarityTo(b); assertEquals(decodedAsymmetric + sourceCorrection, scorer.dotProduct(i,j), 2e-5); assertEquals(scorer.dotProduct(i,j), scorer.dotProduct(j,i), 0f); + assertEquals(scorer.scoreFunctionFor(i).similarityTo(j), + scorer.scoreFunctionFor(encoded.get(i)).similarityTo(j), 0f); assertEquals(ASHScorer.toSimilarity(scorer.dotProduct(i,j)), bsp.searchProviderFor(i).scoreFunction().similarityTo(j), 0f); assertEquals(bsp.searchProviderFor(i).scoreFunction().similarityTo(j), From fc4833513e09a71eb1376d6cede09700d0360942 Mon Sep 17 00:00:00 2001 From: Ted Willke Date: Sat, 19 Sep 2026 05:12:57 +0000 Subject: [PATCH 072/243] Compare ASH construction scoring policies and setup costs --- .../github/jbellis/jvector/example/Grid.java | 16 +- .../diagnostics/ASHBuildScoreExperiment.java | 65 +++ .../example/diagnostics/ASHScoreDebug.java | 407 +++++------------- 3 files changed, 192 insertions(+), 296 deletions(-) create mode 100644 jvector-examples/src/main/java/io/github/jbellis/jvector/example/diagnostics/ASHBuildScoreExperiment.java diff --git a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/Grid.java b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/Grid.java index ec141bf7a..c13712e64 100644 --- a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/Grid.java +++ b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/Grid.java @@ -451,19 +451,22 @@ private static BuildOnDiskResult buildOnDisk(List> feat PQVectors pq = null; ASHVectors ashVectors = null; + long providerStart = System.nanoTime(); final BuildScoreProvider bsp; if (buildCv instanceof PQVectors) { pq = (PQVectors) buildCv; bsp = BuildScoreProvider.pqBuildScoreProvider(ds.getSimilarityFunction(), pq); } else if (buildCv instanceof ASHVectors) { ashVectors = (ASHVectors) buildCv; - bsp = BuildScoreProvider.ashBuildScoreProvider(ds.getSimilarityFunction(), ashVectors); + bsp = io.github.jbellis.jvector.example.diagnostics.ASHBuildScoreExperiment.create(ds.getSimilarityFunction(), ashVectors, floatVectors); } else if (buildCv instanceof BQVectors) { bsp = BuildScoreProvider.bqBuildScoreProvider((BQVectors) buildCv); } else { throw new IllegalArgumentException("Unsupported build compressor output type: " + buildCv.getClass().getName()); } + System.out.printf("Build score provider setup: %.6f seconds%n", (System.nanoTime()-providerStart)/1e9); + final boolean allSymmetric = ashVectors != null && "symmetric-all".equals(System.getProperty("jvector.bench.ashConstruction")); final PQVectors pqFinal = pq; final ASHVectors ashVectorsFinal = ashVectors; @@ -523,7 +526,8 @@ private static BuildOnDiskResult buildOnDisk(List> feat if (ds.useDatasetPrefetching()) { VectorAccess.forEach(floatVectors, PhysicalCoreExecutor.pool(), (node, vector) -> { if (!deferFeatures) writeInlineStates(writers, inputSuppliers, node, vector); - builder.addGraphNode(node, vector); + if (allSymmetric) builder.addGraphNode(node, bsp.searchProviderFor(node)); + else builder.addGraphNode(node, vector); }); } else { var vv = floatVectors.threadLocalSupplier(); @@ -531,11 +535,14 @@ private static BuildOnDiskResult buildOnDisk(List> feat IntStream.range(0, floatVectors.size()).parallel().forEach(node -> { var vector = vv.get().getVector(node); if (!deferFeatures) writeInlineStates(writers, inputSuppliers, node, vector); - builder.addGraphNode(node, vector); + if (allSymmetric) builder.addGraphNode(node, bsp.searchProviderFor(node)); + else builder.addGraphNode(node, vector); }); }).join(); } + long insertionEnd = System.nanoTime(); builder.cleanup(); + long cleanupEnd = System.nanoTime(); // With deferred features, supply every feature so the parallel writer emits complete records. writers.entrySet().stream().parallel().forEach(entry -> { @@ -564,6 +571,9 @@ private static BuildOnDiskResult buildOnDisk(List> feat builder.close(); double totalTime = (System.nanoTime() - startTime) / 1_000_000_000.0; + System.out.printf("Graph phases: insertion=%.6f cleanup=%.6f flush=%.6f seconds%n", + (insertionEnd-startTime)/1e9, (cleanupEnd-insertionEnd)/1e9, + (System.nanoTime()-cleanupEnd)/1e9); System.out.format("Build and write %s in %ss%n", featureSets, totalTime); indexBuildTimes.put(ds.getName(), totalTime); diff --git a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/diagnostics/ASHBuildScoreExperiment.java b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/diagnostics/ASHBuildScoreExperiment.java new file mode 100644 index 000000000..5a0b35f1b --- /dev/null +++ b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/diagnostics/ASHBuildScoreExperiment.java @@ -0,0 +1,65 @@ +/* + * 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.diagnostics; + +import io.github.jbellis.jvector.graph.RandomAccessVectorValues; +import io.github.jbellis.jvector.graph.similarity.BuildScoreProvider; +import io.github.jbellis.jvector.graph.similarity.DefaultSearchScoreProvider; +import io.github.jbellis.jvector.graph.similarity.SearchScoreProvider; +import io.github.jbellis.jvector.quantization.ASHVectors; +import io.github.jbellis.jvector.vector.VectorSimilarityFunction; +import io.github.jbellis.jvector.vector.types.VectorFloat; + +/** Benchmark-only adapter; asymmetric construction retains access to original vectors. */ +public final class ASHBuildScoreExperiment { + private ASHBuildScoreExperiment() {} + public static BuildScoreProvider create(VectorSimilarityFunction vsf, ASHVectors encoded, + RandomAccessVectorValues raw) { + String mode=System.getProperty("jvector.bench.ashConstruction","symmetric"); + System.out.println("ASH construction scoring mode: " + mode); + if (mode.equals("symmetric") || mode.equals("symmetric-all")) { + return BuildScoreProvider.ashBuildScoreProvider(vsf,encoded); + } + if (!mode.equals("asymmetric") && !mode.equals("asymmetric-cached")) throw new IllegalArgumentException("Unknown ASH construction mode: " + mode); + var views=raw.threadLocalSupplier(); + final SearchScoreProvider[] cached; + if (mode.equals("asymmetric-cached")) { + // ASH single-vector scorers own immutable query state and can be shared. + cached = new SearchScoreProvider[encoded.count()]; + io.github.jbellis.jvector.util.PhysicalCoreExecutor.pool().submit(() -> + java.util.stream.IntStream.range(0, cached.length).parallel().forEach(node -> + cached[node] = new DefaultSearchScoreProvider( + encoded.precomputedScoreFunctionFor(views.get().getVector(node), vsf), null, true))) + .join(); + long landmarks = encoded.getCompressor().landmarkCount; + long floats = (long) cached.length * ((1L + landmarks) * encoded.getCompressor().quantizedDim + 2 * landmarks); + System.out.printf("Cached asymmetric query arrays: %.3f MiB (array/object overhead additional)%n", + floats * Float.BYTES / 1048576.0); + } else { + cached = null; + } + return new BuildScoreProvider() { + public boolean isExact() { return false; } + public VectorFloat approximateCentroid() { return encoded.getCompressor().landmarks[0].copy(); } + public SearchScoreProvider searchProviderFor(VectorFloat x) { + return new DefaultSearchScoreProvider(encoded.precomputedScoreFunctionFor(x,vsf),null,true); + } + public SearchScoreProvider searchProviderFor(int node) { return cached == null ? searchProviderFor(views.get().getVector(node)) : cached[node]; } + public SearchScoreProvider diversityProviderFor(int node) { return searchProviderFor(node); } + }; + } +} diff --git a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/diagnostics/ASHScoreDebug.java b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/diagnostics/ASHScoreDebug.java index 6a8787bf6..3d0db3ca3 100644 --- a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/diagnostics/ASHScoreDebug.java +++ b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/diagnostics/ASHScoreDebug.java @@ -1,316 +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.example.diagnostics; import io.github.jbellis.jvector.graph.RandomAccessVectorValues; -import io.github.jbellis.jvector.graph.similarity.ScoreFunction; import io.github.jbellis.jvector.quantization.ASHVectors; -import io.github.jbellis.jvector.quantization.AsymmetricHashing; +import io.github.jbellis.jvector.quantization.ASHScorer; +import io.github.jbellis.jvector.quantization.ASHSymmetricScorer; import io.github.jbellis.jvector.vector.VectorSimilarityFunction; import io.github.jbellis.jvector.vector.VectorUtil; -import io.github.jbellis.jvector.vector.types.VectorFloat; - import java.util.Arrays; import java.util.Comparator; import java.util.Random; -/** - * Self-contained sanity check for ASH asymmetric vs symmetric scoring (Java 11). - * - * Intended usage: - * ASHScoreDebug.run(ds.getBaseRavv(), ashVectors, 2000, 50000, 100, new Random(42)); - * - * Prints: - * - Pearson correlations: (GT vs asym), (GT vs sym), (asym vs sym) - * - A few top-K overlap comparisons on a subset - */ +/** Input-space comparison using production scorers, with all scores on the raw dot-product scale. */ public final class ASHScoreDebug { - private ASHScoreDebug() { } - - public static void run(RandomAccessVectorValues floatVectors, - ASHVectors ashVectors, - int numPairs, - int subsetN, - int topK, - Random rng) { - if (floatVectors == null) throw new IllegalArgumentException("floatVectors is null"); - if (ashVectors == null) throw new IllegalArgumentException("ashVectors is null"); - if (rng == null) throw new IllegalArgumentException("rng is null"); - if (numPairs <= 0) throw new IllegalArgumentException("numPairs must be > 0"); - if (subsetN <= 0) throw new IllegalArgumentException("subsetN must be > 0"); - if (topK <= 0) throw new IllegalArgumentException("topK must be > 0"); - - AsymmetricHashing ash = ashVectors.getCompressor(); - if (ash.landmarkCount != 1) { - throw new IllegalArgumentException("ASHScoreDebug assumes landmarkCount==1, got " + ash.landmarkCount); + private static volatile float sink; + private ASHScoreDebug() {} + public static void run(RandomAccessVectorValues values, ASHVectors encoded, + int pairs, int subset, int k, Random random) { + if (pairs <= 0 || subset < 2 || k <= 0) throw new IllegalArgumentException("Invalid sample sizes"); + int n = Math.min(subset, values.size()); + if (n < 2) throw new IllegalArgumentException("At least two vectors are required"); + k = Math.min(k, n - 1); + var symmetric = new ASHSymmetricScorer(encoded); + var asymmetric = new ASHScorer(encoded.getCompressor()); + // Separate views: shared RAVV implementations may overwrite returned vectors. + var left = values.copy(); var right = values.copy(); + double[] gt = new double[pairs], sa = new double[pairs], sb = new double[pairs], ss = new double[pairs]; + int asymWins = 0, symWins = 0; + for (int t = 0; t < pairs; t++) { + int i = random.nextInt(n), j = random.nextInt(n - 1); + if (j >= i) j++; + var x = left.getVector(i); var y = right.getVector(j); + gt[t] = VectorUtil.dotProduct(x,y); + sa[t] = asymmetric.scoreFunctionFor(x,VectorSimilarityFunction.DOT_PRODUCT).similarityTo(encoded.get(j)); + sb[t] = asymmetric.scoreFunctionFor(y,VectorSimilarityFunction.DOT_PRODUCT).similarityTo(encoded.get(i)); + ss[t] = symmetric.dotProduct(i,j); + if (Math.abs(sa[t]-gt[t]) < Math.abs(ss[t]-gt[t])) asymWins++; + if (Math.abs(ss[t]-gt[t]) < Math.abs(sa[t]-gt[t])) symWins++; } - - int n = floatVectors.size(); - int D = floatVectors.dimension(); - int d = ash.quantizedDim; - - int effectiveSubsetN = Math.min(subsetN, n); - - // Constant for C=1 (included for manuscript-faithful reconstruction; does not affect ranking) - final float muNormSq = VectorUtil.dotProduct(ash.landmarks[0], ash.landmarks[0]); - - System.out.println("=== ASHScoreDebug ==="); - System.out.println("N=" + n + " D=" + D + " d=" + d - + " subsetN=" + effectiveSubsetN + " pairs=" + numPairs + " topK=" + topK); - - // 1) Bit-math sanity on trivial patterns (checks signDot identity) - sanityCheckBinaryMath(d); - - // 2) Pair sampling: GT dot, asymmetric score, symmetric score - double[] gt = new double[numPairs]; - double[] asym = new double[numPairs]; - double[] sym = new double[numPairs]; - - for (int t = 0; t < numPairs; t++) { - int i = rng.nextInt(effectiveSubsetN); - int j = rng.nextInt(effectiveSubsetN); - - VectorFloat xi = floatVectors.getVector(i); - VectorFloat xj = floatVectors.getVector(j); - - // Ground truth dot - gt[t] = VectorUtil.dotProduct(xi, xj); - - // Asymmetric: query xi vs encoded j - ScoreFunction.ApproximateScoreFunction f = - ashVectors.scoreFunctionFor(xi, VectorSimilarityFunction.DOT_PRODUCT); - asym[t] = f.similarityTo(j); - - // Symmetric: encoded i vs encoded j (manuscript-consistent) - sym[t] = symmetricDot(ashVectors.get(i), ashVectors.get(j), d, muNormSq); - } - - printCorrelations(gt, asym, sym); - - // 3) Top-K overlap sanity for a few query ords (subset brute force) - int[] queries = pickQueries(effectiveSubsetN, rng, 5); - for (int qi = 0; qi < queries.length; qi++) { - int q = queries[qi]; - System.out.println(); - System.out.println("--- TopK overlap for q=" + q - + " (subsetN=" + effectiveSubsetN + ", topK=" + topK + ") ---"); - topKOverlapForQuery(floatVectors, ashVectors, q, effectiveSubsetN, topK, d, muNormSq); - } - - System.out.println("=== end ASHScoreDebug ==="); - } - - /** - * Symmetric ASH–ASH dot-product approximation (C=1), consistent with: - * - g produces sign vectors in {-1,+1}^d (stored as bits b in {0,1}^d via s = 2b - 1) - * - f(z) = d^{-1/2} A z implies ≈ (1/d) - * - * With stored header: - * - scale = ||x-μ|| / sqrt(d) - * - offset = - ||μ||^2 - * - * Centered dot term: - * ||x-μ|| ||y-μ|| ≈ (scale_x * scale_y) * - * - * And = 2*matches - d, where matches is XNOR-popcount of bit vectors. - * - * Full dot: - * ≈ scale_x*scale_y*(2*matches - d) + offset_x + offset_y + ||μ||^2 - */ - public static float symmetricDot(AsymmetricHashing.QuantizedVector v1, - AsymmetricHashing.QuantizedVector v2, - int d, - float muNormSq) { - long[] aBits = v1.binaryVector; - long[] bBits = v2.binaryVector; - - int matches = 0; - - int bitBase = 0; - for (int w = 0; w < aBits.length && bitBase < d; w++, bitBase += 64) { - long aw = aBits[w]; - long bw = bBits[w]; - - // Defensive tail masking (should be unnecessary when d is 64-bit aligned) - int remaining = d - bitBase; - if (remaining < 64) { - long mask = (remaining == 64) ? ~0L : ((1L << remaining) - 1L); - aw &= mask; - bw &= mask; + System.out.printf("ASH score study: N=%d sampleN=%d pairs=%d bpd=%d d=%d raw dots, self excluded%n", + values.size(),n,pairs,encoded.getCompressor().bitsPerDimension,encoded.getCompressor().quantizedDim); + report("ASYM_FORWARD",gt,sa); report("ASYM_REVERSE",gt,sb); report("SYMMETRIC",gt,ss); + System.out.printf("CLOSER_TO_EXACT asym=%d sym=%d ties=%d%n",asymWins,symWins,pairs-asymWins-symWins); + int queries = 16; + double overlapAsym=0, overlapSym=0, localA=0,localS=0; + for (int q = 0; q < queries; q++) { + int id=random.nextInt(n); + var x=left.getVector(id); + var asf=asymmetric.scoreFunctionFor(x,VectorSimilarityFunction.DOT_PRODUCT); + float[][] scores = new float[3][n]; + for (int j=0;j xq = floatVectors.getVector(q); - - // Precompute asymmetric score function once - ScoreFunction.ApproximateScoreFunction asymF = - ashVectors.scoreFunctionFor(xq, VectorSimilarityFunction.DOT_PRODUCT); - - // Encoded q for symmetric - AsymmetricHashing.QuantizedVector qEnc = ashVectors.get(q); - - // Score all candidates in subset (excluding self) - Scored[] gt = new Scored[subsetN - 1]; - Scored[] asym = new Scored[subsetN - 1]; - Scored[] sym = new Scored[subsetN - 1]; - - int idx = 0; - for (int j = 0; j < subsetN; j++) { - if (j == q) continue; - - VectorFloat xj = floatVectors.getVector(j); - - double gtScore = VectorUtil.dotProduct(xq, xj); - double asymScore = asymF.similarityTo(j); - double symScore = (double) symmetricDot(qEnc, ashVectors.get(j), d, muNormSq); - - gt[idx] = new Scored(j, gtScore); - asym[idx] = new Scored(j, asymScore); - sym[idx] = new Scored(j, symScore); - idx++; } - - int[] gtTop = topKIds(gt, topK); - int[] asymTop = topKIds(asym, topK); - int[] symTop = topKIds(sym, topK); - - System.out.printf("overlap(GT, ASYM) = %.2f%%%n", 100.0 * overlapFraction(gtTop, asymTop)); - System.out.printf("overlap(GT, SYM) = %.2f%%%n", 100.0 * overlapFraction(gtTop, symTop)); - System.out.printf("overlap(ASYM, SYM) = %.2f%%%n", 100.0 * overlapFraction(asymTop, symTop)); - } - - private static int[] topKIds(Scored[] arr, int k) { - int kk = Math.min(k, arr.length); - Arrays.sort(arr, new Comparator() { - @Override - public int compare(Scored a, Scored b) { - return Double.compare(b.score, a.score); // descending + System.out.printf("EXACT_TOP%d_OVERLAP queries=%d subset=%d ASYM=%.6f SYMMETRIC=%.6f NEAREST100_MAE_ASYM=%.7f NEAREST100_MAE_SYM=%.7f%n", + k,queries,n,overlapAsym/(queries*k),overlapSym/(queries*k),localA/queries,localS/queries); + int queryId = 0; + var query = left.getVector(queryId); + time("SYMMETRIC_PACKED", symmetric.scoreFunctionFor(queryId), n); + String oldMode = System.getProperty("jvector.ash.singleKernel"); + try { + System.setProperty("jvector.ash.singleKernel", "scalar"); + time("ASYMMETRIC_SCALAR", encoded.scoreFunctionFor(query, VectorSimilarityFunction.DOT_PRODUCT), n); + int bits = encoded.getCompressor().bitsPerDimension; + if ((bits == 2 || bits == 4) && io.github.jbellis.jvector.vector.VectorizationProvider.getInstance() + .getVectorUtilSupport().supportsAshProjectionScoring()) { + System.setProperty("jvector.ash.singleKernel", "simd"); + time("ASYMMETRIC_SIMD", encoded.scoreFunctionFor(query, VectorSimilarityFunction.DOT_PRODUCT), n); } - }); - int[] out = new int[kk]; - for (int i = 0; i < kk; i++) out[i] = arr[i].id; - Arrays.sort(out); - return out; - } - - private static double overlapFraction(int[] aSorted, int[] bSorted) { - int i = 0, j = 0, hit = 0; - while (i < aSorted.length && j < bSorted.length) { - int va = aSorted[i]; - int vb = bSorted[j]; - if (va == vb) { hit++; i++; j++; } - else if (va < vb) i++; - else j++; + } finally { + if (oldMode == null) System.clearProperty("jvector.ash.singleKernel"); + else System.setProperty("jvector.ash.singleKernel", oldMode); } - return (double) hit / (double) aSorted.length; } - private static final class Scored { - final int id; - final double score; - Scored(int id, double score) { this.id = id; this.score = score; } - } - - private static void sanityCheckBinaryMath(int d) { - if ((d & 63) != 0) { - System.out.println("[sanity] skipped: d not multiple of 64"); - return; + // Single-thread sequential scans; query setup excluded. This is not a graph QPS estimate. + private static void time(String mode, io.github.jbellis.jvector.graph.similarity.ScoreFunction sf, int n) { + long[] times = new long[9]; + for (int pass = -5; pass < times.length; pass++) { + float total = 0; + long start = System.nanoTime(); + for (int j = 0; j < n; j++) total += sf.similarityTo(j); + long elapsed = System.nanoTime()-start; + sink = total; + if (pass >= 0) times[pass] = elapsed; } - - // For sign vectors s in {-1,+1}^d represented as bits b via s = 2b - 1: - // zeros·zeros = d, ones·ones = d, zeros·ones = -d - float exp00 = (float) d; - float exp11 = (float) d; - float exp01 = -(float) d; - - System.out.printf("[sanity] expected signDot: 00=%.1f 11=%.1f 01=%.1f%n", exp00, exp11, exp01); - - int words = (d + 63) >>> 6; - long[] zeros = new long[words]; - long[] ones = new long[words]; - Arrays.fill(ones, ~0L); - - float got00 = computeSignDotFromBits(zeros, zeros, d); - float got11 = computeSignDotFromBits(ones, ones, d); - float got01 = computeSignDotFromBits(zeros, ones, d); - - System.out.printf("[sanity] computed signDot: 00=%.1f 11=%.1f 01=%.1f%n", got00, got11, got01); + Arrays.sort(times); + System.out.printf("STEADY_SCORING %s median_ns_per_pair=%.3f millions_per_second=%.3f%n", + mode, times[4]/(double)n, n*1000.0/times[4]); } - - private static float computeSignDotFromBits(long[] aBits, long[] bBits, int d) { - int matches = 0; - for (int w = 0; w < aBits.length; w++) { - matches += Long.bitCount(~(aBits[w] ^ bBits[w])); + private static Integer[] top(float[] a,int k) { + Integer[] ids=new Integer[a.length]; for(int i=0;icomparingDouble(i->-a[i]).thenComparingInt(i->i)); + return Arrays.copyOf(ids,k); + } + private static void report(String name,double[] x,double[] y) { + double sx=0,sy=0,sxx=0,syy=0,sxy=0,mae=0,mse=0; + for(int i=0;i Date: Sat, 19 Sep 2026 06:15:40 +0000 Subject: [PATCH 073/243] Optimize symmetric ASH scoring from encoded vectors --- .../jvector/quantization/ASHLutKernel.java | 7 + .../jvector/quantization/ASHScorer.java | 10 + .../quantization/ASHSymmetricScorer.java | 275 +++++++++++++++--- .../jvector/quantization/ASHVectors.java | 45 ++- .../quantization/AsymmetricHashing.java | 14 +- .../jvector/vector/VectorUtilSupport.java | 21 ++ .../quantization/TestASHSymmetricScorer.java | 110 ++++++- .../vector/PanamaVectorUtilSupport.java | 143 +++++++++ 8 files changed, 569 insertions(+), 56 deletions(-) diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHLutKernel.java b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHLutKernel.java index 38e38a160..2613eaf50 100644 --- a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHLutKernel.java +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHLutKernel.java @@ -42,6 +42,13 @@ final class ASHLutKernel { } } + ASHLutKernel(boolean simd) { + if (simd && !backend.supportsAshLutScoring()) { + throw new IllegalStateException("ASH SIMD LUT scoring is unavailable"); + } + this.simd = simd; + } + void score(byte[] codes, int offset, int groups, int stride, int lane, int count, float[] lut, float[] out, int outOffset) { if (simd) backend.ashLutScore(codes, offset, groups, stride, lane, count, lut, out, outOffset); diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHScorer.java b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHScorer.java index e8e151ff7..aa322fe3a 100644 --- a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHScorer.java +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHScorer.java @@ -119,6 +119,16 @@ ASHScoreFunction scoreFunctionFor(QueryPrecompute qp) { return genericMultibitDotProductScoreFunction(qp); } + /** Prepared encoded-query state; uses the same arithmetic as the raw-query scorer. */ + ASHScoreFunction scoreFunctionFor(QueryPrecompute qp, boolean simd) { + if (ash.bitsPerDimension == 1) return oneBitDotProductScoreFunction(qp); + if (AsymmetricHashing.usesFastScanProjectionCode(ash.bitsPerDimension)) { + return fastScanProjectionDotProductScoreFunction(qp, simd); + } + if (simd) throw new UnsupportedOperationException("SIMD ASH requires bits 1, 2, or 4"); + return genericMultibitDotProductScoreFunction(qp); + } + private ASHScoreFunction oneBitDotProductScoreFunction(QueryPrecompute qp) { final int d = qp.d; final int C = qp.C; diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHSymmetricScorer.java b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHSymmetricScorer.java index 77b04c813..2b75d4cc4 100644 --- a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHSymmetricScorer.java +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHSymmetricScorer.java @@ -20,83 +20,267 @@ import io.github.jbellis.jvector.vector.VectorUtil; /** - * Compressed-to-compressed ASH dot products for C=1 (paper Appendix B, B.2-B.4). - * The codes remain packed; no original vectors or per-pair projections are needed. - * Instances require immutable encoded vectors, as does ASHVectors itself. + * Symmetric ASH scoring from already encoded operands. Graph construction remains C=1. + * C=1 uses Appendix B directly. Multiple landmarks use the same header-calibrated + * decoded dot product, including cross-landmark terms, without projecting raw vectors. */ public final class ASHSymmetricScorer { - private static final short[] DOT_2 = nibbleProducts(2); + public enum Kernel { AUTO, SCALAR, SIMD } + private static final java.lang.invoke.VarHandle SYMMETRIC_WORD = java.lang.invoke.MethodHandles.byteArrayViewVarHandle(long[].class, java.nio.ByteOrder.LITTLE_ENDIAN); private static final short[] DOT_4 = nibbleProducts(4); private final ASHVectors vectors; + private final AsymmetricHashing ash; private final int dimensions; private final int bits; - private final float muNormSquared; - // Recover -||mu||^2 once from the projection-mode query offset. - // Four bytes per vector, only for 2/4-bit construction; never serialized. + private final int landmarks; + private final float[][] landmarkDots; private final float[] centeredOffsets; + private final boolean simd; + private final io.github.jbellis.jvector.vector.VectorUtilSupport backend = + io.github.jbellis.jvector.vector.VectorizationProvider.getInstance().getVectorUtilSupport(); public ASHSymmetricScorer(ASHVectors vectors) { - var ash = vectors.getCompressor(); - if (ash.landmarkCount != 1) { - throw new IllegalArgumentException("Symmetric ASH scoring requires landmarkCount=1"); - } + this(vectors, Kernel.valueOf(System.getProperty("jvector.ash.singleKernel", "auto") + .trim().toUpperCase(java.util.Locale.ROOT))); + } + + public ASHSymmetricScorer(ASHVectors vectors, Kernel kernel) { this.vectors = vectors; + ash = vectors.getCompressor(); dimensions = ash.quantizedDim; bits = ash.bitsPerDimension; - muNormSquared = VectorUtil.dotProduct(ash.landmarks[0], ash.landmarks[0]); + landmarks = ash.landmarkCount; + if (landmarks < 1 || landmarks > 256) throw new IllegalArgumentException("ASH requires 1..256 landmarks"); + simd = selectSimd(kernel); + landmarkDots = new float[landmarks][landmarks]; + for (int a = 0; a < landmarks; a++) { + for (int b = 0; b <= a; b++) { + landmarkDots[a][b] = landmarkDots[b][a] = VectorUtil.dotProduct(ash.landmarks[a], ash.landmarks[b]); + } + } if (AsymmetricHashing.usesFastScanProjectionCode(bits)) { centeredOffsets = new float[vectors.count()]; - for (int i = 0; i < centeredOffsets.length; i++) { - var v = vectors.get(i); - centeredOffsets[i] = v.offset + v.scale * AsymmetricHashing.dotProjectionCode( - ash.landmarkProj[0], v.extraBits, dimensions, bits); - } + for (int i = 0; i < centeredOffsets.length; i++) centeredOffsets[i] = centeredOffset(vectors.get(i)); } else { centeredOffsets = null; } } - private float offset(int node) { - return centeredOffsets == null ? vectors.get(node).offset : centeredOffsets[node]; + private boolean selectSimd(Kernel kernel) { + boolean supported = (bits == 1 || bits == 2 || bits == 4) && backend.supportsAshSymmetricScoring(); + if (landmarks != 1) supported &= bits == 1 ? backend.supportsAshMaskedLoad() : backend.supportsAshProjectionScoring(); + if (kernel == Kernel.SIMD && !supported) throw new UnsupportedOperationException("Symmetric SIMD requires a vector backend and bits 1, 2, or 4"); + return kernel != Kernel.SCALAR && supported; } - /** Raw dot product, for diagnostics and independent comparison with input-space ground truth. */ + public String description() { return "symmetric " + (simd ? "SIMD" : "scalar") + ", bits=" + bits + ", C=" + landmarks; } + + private float centeredOffset(AsymmetricHashing.QuantizedVector vector) { + int c = vector.landmark & 255; + if (c >= landmarks) throw new IllegalArgumentException("Invalid landmark " + c); + if (!AsymmetricHashing.usesFastScanProjectionCode(bits)) return vector.offset; + return vector.offset + vector.scale * AsymmetricHashing.dotProjectionCode( + ash.landmarkProj[c], vector.extraBits, dimensions, bits); + } + + private float offset(int node) { return centeredOffsets == null ? vectors.get(node).offset : centeredOffsets[node]; } + + private float packedDot(AsymmetricHashing.QuantizedVector a, AsymmetricHashing.QuantizedVector b) { + return simd ? backend.ashSymmetricDot(a.binaryVector, a.extraBits, b.binaryVector, b.extraBits, dimensions, bits) + : codeDot(a, b, dimensions, bits); + } + + /** Raw symmetric dot product. No encoding or original-dimensional projection. */ public float dotProduct(int a, int b) { - var x = vectors.get(a); - var y = vectors.get(b); - // Sum offsets first to preserve exactly the same float result in both directions. - return (x.scale * y.scale) * codeDot(x, y, dimensions, bits) - + (offset(a) + offset(b)) + muNormSquared; + var x = vectors.get(a); var y = vectors.get(b); + int ca = x.landmark & 255, cb = y.landmark & 255; + float crossA = 0, crossB = 0; + if (ca != cb) { + for (int j = 0; j < dimensions; j++) { + float delta = ash.landmarkProj[cb][j] - ash.landmarkProj[ca][j]; + crossA += component(x, j, bits) * delta; + crossB += component(y, j, bits) * -delta; + } + } + return (x.scale * y.scale) * packedDot(x, y) + + ((x.scale * crossA + y.scale * crossB) + (offset(a) + offset(b))) + landmarkDots[ca][cb]; } - /** Graph-facing similarity, using the same normalization as asymmetric ASH. */ public ScoreFunction.ApproximateScoreFunction scoreFunctionFor(int node) { - return other -> ASHScorer.toSimilarity(dotProduct(node, other)); + if (landmarks != 1) return scoreFunctionFor(vectors.get(node)); + return c1ScoreFunction(vectors.get(node), offset(node)); } - /** - * Prepare an external query already encoded with this scorer's compressor. - * Query encoding and landmark-offset recovery happen before pairwise scoring. - * The caller must retain the immutable query payload while using the function. - */ + /** The operand must already be encoded with this instance's compressor. */ public ScoreFunction.ApproximateScoreFunction scoreFunctionFor(AsymmetricHashing.QuantizedVector query) { - if (query.landmark != 0) { - throw new IllegalArgumentException("Symmetric ASH query requires landmark zero"); - } - float queryOffset = query.offset; - if (AsymmetricHashing.usesFastScanProjectionCode(bits)) { - queryOffset += query.scale * AsymmetricHashing.dotProjectionCode( - vectors.getCompressor().landmarkProj[0], query.extraBits, dimensions, bits); - } - final float centeredQueryOffset = queryOffset; + if (landmarks != 1) return vectors.scoreFunctionFor(preparedState(query), simd); + return c1ScoreFunction(query, centeredOffset(query)); + } + + private ScoreFunction.ApproximateScoreFunction c1ScoreFunction( + AsymmetricHashing.QuantizedVector query, float queryOffset) { return node -> { var target = vectors.get(node); - float dot = (query.scale * target.scale) * codeDot(query, target, dimensions, bits) - + (centeredQueryOffset + offset(node)) + muNormSquared; + float dot = (query.scale * target.scale) * packedDot(query, target) + + (queryOffset + offset(node)) + landmarkDots[0][0]; return ASHScorer.toSimilarity(dot); }; } + /** Score encoded pairs in ranges; C=1 uses integer kernels, C>1 reuses projected-state kernels. */ + public ASHBlockScorer blockScorerFor(AsymmetricHashing.QuantizedVector query, int blockSize) { + Kernel kernel = Kernel.valueOf(System.getProperty("jvector.ash.blockKernel", "auto") + .trim().toUpperCase(java.util.Locale.ROOT)); + return blockScorerFor(query, blockSize, kernel); + } + + public ASHBlockScorer blockScorerFor(AsymmetricHashing.QuantizedVector query, int blockSize, Kernel kernel) { + if (blockSize <= 0) throw new IllegalArgumentException("blockSize must be positive"); + if (landmarks == 1 && (bits == 2 || bits == 4)) { + if (kernel == Kernel.SIMD && !backend.supportsAshSymmetricScoring()) throw new UnsupportedOperationException("Symmetric SIMD is unavailable"); + final boolean useVector = kernel != Kernel.SCALAR && backend.supportsAshSymmetricScoring(); + final byte[][] blocks = vectors.symmetricProjectionBlocks(blockSize); + final int components = 4 / bits; + final int groups = (dimensions + components - 1) / components; + final short[] lut = new short[Math.multiplyExact(groups, 32)]; + for (int g = 0; g < groups; g++) { + int q0 = (int)(2*component(query, g*components, bits)); + if (bits == 4) { + for (int magnitude = 0; magnitude < 8; magnitude++) { + int product = q0*(2*magnitude+1); + lut[g*32+magnitude] = (short)-product; + lut[g*32+magnitude+8] = (short)product; + } + } else { + int q1 = g*components+1 < dimensions ? (int)(2*component(query,g*components+1,bits)) : 0; + for (int value = 0; value < 16; value++) { + int a = 2*(value&1)+1, b = 2*((value>>>2)&1)+1; + if ((value&2)==0) a = -a; + if ((value&8)==0) b = -b; + lut[g*32+value] = (short)(q0*a+q1*b); + } + } + System.arraycopy(lut,g*32,lut,g*32+16,16); + } + final float queryOffset = centeredOffset(query); + return new ASHBlockScorer() { + public String description() { return "symmetric exact integer LUT " + (useVector ? "SIMD" : "scalar"); } + public void scoreRange(int start, int count, float[] out) { + java.util.Objects.checkFromIndexSize(start, count, vectors.count()); + java.util.Objects.checkFromIndexSize(0, count, out.length); + for (int done = 0; done < count;) { + int ordinal = start + done, lane = ordinal % blockSize; + int lanes = Math.min(count - done, blockSize - lane); + byte[] body = blocks[ordinal / blockSize]; + if (useVector) backend.ashSymmetricLutScore(body, groups, blockSize, lane, lanes, lut, out, done); + else for (int i = 0; i < lanes; i++) { + long sum = 0; + for (int g = 0; g < groups; g++) sum += lut[g * 32 + ((body[(g / 2)*blockSize+lane+i] >>> (4*(g%2))) & 15)]; + out[done+i] = sum*.25f; + } + for (int i = 0; i < lanes; i++) { + var target = vectors.get(ordinal+i); + out[done+i] = ASHScorer.toSimilarity((query.scale*target.scale)*out[done+i] + +(queryOffset+offset(ordinal+i))+landmarkDots[0][0]); + } + done += lanes; + } + } + }; + } + if (bits == 1 && landmarks == 1) { + boolean supported = backend.supportsAshSymmetricScoring(); + if (kernel == Kernel.SIMD && !supported) throw new UnsupportedOperationException("Symmetric SIMD is unavailable"); + boolean vector = kernel != Kernel.SCALAR && supported; + float queryOffset = centeredOffset(query); + return new ASHBlockScorer() { + public String description() { return "symmetric binary packed-pair range " + (vector ? "SIMD" : "scalar"); } + public void scoreRange(int start, int count, float[] out) { + java.util.Objects.checkFromIndexSize(start, count, vectors.count()); + java.util.Objects.checkFromIndexSize(0, count, out.length); + for (int i = 0; i < count; i++) { + int node = start + i; + var target = vectors.get(node); + float dot = vector ? backend.ashSymmetricDot(query.binaryVector, query.extraBits, + target.binaryVector, target.extraBits, dimensions, bits) + : codeDot(query, target, dimensions, bits); + out[i] = ASHScorer.toSimilarity((query.scale * target.scale) * dot + + (queryOffset + offset(node)) + landmarkDots[0][0]); + } + } + }; + } + boolean supported = bits == 1 ? backend.supportsAshMaskedLoad() + : (bits == 2 || bits == 4) && backend.supportsAshLutScoring(); + if (kernel == Kernel.SIMD && !supported) throw new UnsupportedOperationException("Symmetric block SIMD is unavailable for this backend/bit width"); + return vectors.blockScorerFor(preparedState(query), blockSize, kernel != Kernel.SCALAR && supported); + } + + // qProj is derived from encoded coordinates, NOT from W times a raw D-dimensional query. + // Header calibration adds the query's stored reconstruction correction to each centroid dot. + private ASHScorer.QueryPrecompute preparedState(AsymmetricHashing.QuantizedVector query) { + int cq = query.landmark & 255; + if (cq >= landmarks) throw new IllegalArgumentException("Invalid query landmark " + cq); + float[] code = new float[dimensions]; + float[] qProj = new float[dimensions]; + float ownProjection = 0; + for (int j = 0; j < dimensions; j++) { + code[j] = component(query, j, bits); + qProj[j] = ash.landmarkProj[cq][j] + query.scale * code[j]; + ownProjection += code[j] * ash.landmarkProj[cq][j]; + } + float correction = AsymmetricHashing.usesFastScanProjectionCode(bits) + ? query.offset : query.offset - query.scale * ownProjection; + float[] tilde = new float[Math.multiplyExact(landmarks, dimensions)]; + float[] sums = new float[landmarks]; + float[] dots = new float[landmarks]; + for (int c = 0; c < landmarks; c++) { + float dot = 0, sum = 0; + for (int j = 0; j < dimensions; j++) { + dot += code[j] * ash.landmarkProj[c][j]; + float value = qProj[j] - ash.landmarkProj[c][j]; + tilde[c * dimensions + j] = value; + sum += value; + } + sums[c] = sum; + dots[c] = landmarkDots[cq][c] + query.scale * dot + correction; + } + return new ASHScorer.QueryPrecompute(dimensions, landmarks, qProj, tilde, sums, dots); + } + + private static float component(AsymmetricHashing.QuantizedVector v, int j, int bits) { + if (bits == 1) return AsymmetricHashing.QuantizedVector.getBit(v.binaryVector, j) ? 1f : -1f; + if (bits == 2 || bits == 4) return AsymmetricHashing.projectionComponent(v.extraBits, j, bits); + int ex = bits - 1; + int code = ((AsymmetricHashing.QuantizedVector.getBit(v.binaryVector, j) ? 1 : 0) << ex) + + AsymmetricHashing.QuantizedVector.readExtraCode(v.extraBits, j, ex); + return code - ((1 << ex) - 0.5f); + } + + private static float symmetric2ScalarWords(byte[] ac, byte[] bc, int d) { + long sum = 0; + for (int start = 0; start < d; start += 32) { + int active = Math.min(32, d - start); + long a = 0, b = 0; + if (active == 32) { + a = (long) SYMMETRIC_WORD.get(ac, start / 4); + b = (long) SYMMETRIC_WORD.get(bc, start / 4); + } else { + for (int j = 0; j < (active + 3) / 4; j++) { + a |= (long) (ac[start / 4 + j] & 255) << (8*j); + b |= (long) (bc[start / 4 + j] & 255) << (8*j); + } + } + long valid = 0x5555555555555555L; + if (active < 32) valid &= (1L << (2*active)) - 1; + long neg = ((a ^ b) >>> 1) & valid, ma = a & valid, mb = b & valid, both = ma & mb; + sum += active - 2*Long.bitCount(neg) + 2*(Long.bitCount(ma)+Long.bitCount(mb)) + -4*(Long.bitCount(ma & neg)+Long.bitCount(mb & neg)) + +4*Long.bitCount(both)-8*Long.bitCount(both & neg); + } + return sum * .25f; + } + static float codeDot(AsymmetricHashing.QuantizedVector a, AsymmetricHashing.QuantizedVector b, int d, int bits) { if (bits == 1) { @@ -112,10 +296,11 @@ static float codeDot(AsymmetricHashing.QuantizedVector a, } return d - 2f * mismatches; } - if (bits == 2 || bits == 4) { + if (bits == 2) return symmetric2ScalarWords(a.extraBits, b.extraBits, d); + if (bits == 4) { // Each table entry sums products of doubled (odd integer) code values. // 256 entries = 512 bytes; shared across all nodes and threads. - short[] table = bits == 2 ? DOT_2 : DOT_4; + short[] table = DOT_4; int dimsPerByte = 8 / bits; int bytes = d / dimsPerByte; long sum = 0; diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHVectors.java b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHVectors.java index 59abeeed4..2e8f81cc0 100644 --- a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHVectors.java +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHVectors.java @@ -464,6 +464,32 @@ public ASHBlockScorer blockScorerFor( ); } + // Package-private prepared-state entry points shared by symmetric scoring. + ScoreFunction.ApproximateScoreFunction scoreFunctionFor(ASHScorer.QueryPrecompute qp, boolean simd) { + if (ash.bitsPerDimension == 1 && simd) { + return new SimdASHScoreFunction(new QueryPrecompute(qp.d, qp.C, qp.tildeQPool, + qp.sumTildeQByLandmark, qp.dotQMuByLandmark)); + } + var f = scorer.scoreFunctionFor(qp, simd); + return node -> ASHScorer.toSimilarity(f.similarityTo(compressedVectors[node])); + } + + ASHBlockScorer blockScorerFor(ASHScorer.QueryPrecompute prepared, int blockSize, boolean simd) { + if (blockSize <= 0) throw new IllegalArgumentException("blockSize must be > 0"); + if (ash.bitsPerDimension == 2 || ash.bitsPerDimension == 4) { + FusedASHLayout.validateBlockSize(blockSize); + return new LutASHBlockScorer(prepared, blockSize, simd); + } + if (ash.bitsPerDimension != 1) { + return new LegacyASHBlockScorer(scorer.scoreFunctionFor(prepared, simd), compressedVectors); + } + var qp = new QueryPrecompute(prepared.d, prepared.C, prepared.tildeQPool, + prepared.sumTildeQByLandmark, prepared.dotQMuByLandmark); + if (!simd) return new ScalarASHBlockScorer(compressedVectors, scales, offsets, landmarks, qp); + ensurePackedBits(blockSize); + return new SimdASHBlockScorer(compressedVectors, scales, offsets, landmarks, packedBits, blockSize, qp); + } + private byte[][] packProjectionBlocks(int blockSize) { int count = compressedVectors.length; int bytes = FusedASHLayout.canonicalCodeBytes(d, ash.bitsPerDimension); @@ -483,9 +509,14 @@ private byte[][] packProjectionBlocks(int blockSize) { return blocks; } + byte[][] symmetricProjectionBlocks(int blockSize) { + if (blockSize <= 0) throw new IllegalArgumentException("blockSize must be positive"); + return projectionBlocks.computeIfAbsent(blockSize, this::packProjectionBlocks); + } + /** Query-local LUT and dispatch over immutable, container-owned packed blocks. */ private final class LutASHBlockScorer implements ASHBlockScorer { - private final ASHLutKernel kernel = new ASHLutKernel(); + private final ASHLutKernel kernel; private final int blockSize; private final byte[][] blocks; private final float[] lut; @@ -493,6 +524,7 @@ private final class LutASHBlockScorer implements ASHBlockScorer { private final int groups = FusedASHLayout.codeGroups(d, ash.bitsPerDimension); LutASHBlockScorer(VectorFloat query, int blockSize) { + this.kernel = new ASHLutKernel(); this.blockSize = blockSize; this.blocks = projectionBlocks.computeIfAbsent(blockSize, ASHVectors.this::packProjectionBlocks); float[] q = new float[ash.originalDimension]; @@ -506,6 +538,15 @@ private final class LutASHBlockScorer implements ASHBlockScorer { for (int i = 0; i < dotQMu.length; i++) dotQMu[i] = VectorUtil.dotProduct(query, ash.landmarks[i]); } + LutASHBlockScorer(ASHScorer.QueryPrecompute prepared, int blockSize, boolean simd) { + this.kernel = new ASHLutKernel(simd); + this.blockSize = blockSize; + this.blocks = projectionBlocks.computeIfAbsent(blockSize, ASHVectors.this::packProjectionBlocks); + this.lut = new float[Math.multiplyExact(groups, 16)]; + FusedASHLayout.buildQueryLut(prepared.qProj, d, ash.bitsPerDimension, lut); + this.dotQMu = prepared.dotQMuByLandmark; + } + @Override public void scoreRange(int start, int count, float[] out) { Objects.checkFromIndexSize(start, count, compressedVectors.length); @@ -910,7 +951,7 @@ public static final class LandmarkOrder { } /** - * Stable, linear-time reorder by landmark id (C <= 64). + * Stable, linear-time reorder by landmark id (C <= 256). * Fills reordered header arrays in the same pass (no second constructor scan). */ public LandmarkOrder reorderByLandmarkFast() { diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/AsymmetricHashing.java b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/AsymmetricHashing.java index 64466f291..a9764822a 100644 --- a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/AsymmetricHashing.java +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/AsymmetricHashing.java @@ -80,7 +80,7 @@ public class AsymmetricHashing implements VectorCompressor 64) { + if (landmarkCount < 1 || landmarkCount > 256) { throw new IllegalArgumentException( - "landmarkCount must be in [1,64], got " + landmarkCount); + "landmarkCount must be in [1,256], got " + landmarkCount); } for (VectorFloat mu : landmarks) { @@ -269,9 +269,9 @@ public static AsymmetricHashing initialize(RandomAccessVectorValues ravv, int bitsPerDimension) throws IOException { validateBitsPerDimension(bitsPerDimension); - if (landmarkCount < 1 || landmarkCount > 64) { + if (landmarkCount < 1 || landmarkCount > 256) { throw new IllegalArgumentException( - "landmarkCount must be in [1,64], got " + landmarkCount); + "landmarkCount must be in [1,256], got " + landmarkCount); } var ravvCopy = ravv.threadLocalSupplier().get(); @@ -539,7 +539,7 @@ public static AsymmetricHashing load(RandomAccessReader in) throws IOException { // Multi-landmark support int landmarkCount = in.readInt(); - if (landmarkCount < 1 || landmarkCount > 64) { + if (landmarkCount < 1 || landmarkCount > 256) { throw new IOException("Invalid landmarkCount=" + landmarkCount); } @@ -634,7 +634,7 @@ public float quantizeVector(VectorFloat vector, } // Landmark-specific mean (C=1 → landmarks[0] == dataset mean) - final VectorFloat mu = landmarks[landmark]; + final VectorFloat mu = landmarks[landmark & 0xFF]; return QuantizedVector.quantizeTo( vector, diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/vector/VectorUtilSupport.java b/jvector-base/src/main/java/io/github/jbellis/jvector/vector/VectorUtilSupport.java index 2de9496c8..23fb182ec 100644 --- a/jvector-base/src/main/java/io/github/jbellis/jvector/vector/VectorUtilSupport.java +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/vector/VectorUtilSupport.java @@ -49,6 +49,27 @@ default float ashProjectionDotTuned(float[] query, byte[] code, int dimensions, /** Whether ASH nibble lookup and accumulation are implemented with SIMD. */ default boolean supportsAshLutScoring() { return false; } + /** Exact integer lookup for encoded C=1 query pairs. LUT stride is 32 (two repeated 16-entry tables). */ + default void ashSymmetricLutScore(byte[] codes, int groups, int stride, int lane, int count, + short[] lut, float[] out, int outOffset) { + for (int i = 0; i < count; i++) { + long sum = 0; + for (int g = 0; g < groups; g++) { + int nibble = (codes[(g / 2) * stride + lane + i] >>> (4 * (g % 2))) & 15; + sum += lut[g * 32 + nibble]; + } + out[outOffset + i] = sum * .25f; + } + } + + /** Packed-to-packed integer dot products for symmetric ASH. */ + default boolean supportsAshSymmetricScoring() { return false; } + + default float ashSymmetricDot(long[] aBits, byte[] aCodes, long[] bBits, byte[] bCodes, + int dimensions, int bits) { + throw new UnsupportedOperationException("Symmetric ASH SIMD is unavailable on this backend"); + } + /** Whether canonical 2/4-bit projection codes have a single-vector SIMD kernel. */ default boolean supportsAshProjectionScoring() { return false; } diff --git a/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestASHSymmetricScorer.java b/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestASHSymmetricScorer.java index ab03ead20..1dcedee8c 100644 --- a/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestASHSymmetricScorer.java +++ b/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestASHSymmetricScorer.java @@ -67,6 +67,10 @@ private static float component(AsymmetricHashing.QuantizedVector v, int j, int b float actual = ASHSymmetricScorer.codeDot(a, b, d, bits); assertEquals("bits=" + bits + " d=" + d, (float) expected, actual, 0f); assertEquals(actual, ASHSymmetricScorer.codeDot(b, a, d, bits), 0f); + if ((bits == 1 || bits == 2 || bits == 4) && backend.supportsAshSymmetricScoring()) { + assertEquals("symmetric SIMD bits=" + bits + " d=" + d, actual, + backend.ashSymmetricDot(a.binaryVector, a.extraBits, b.binaryVector, b.extraBits, d, bits), 0f); + } if ((bits == 2 || bits == 4) && backend.supportsAshProjectionScoring()) { assertEquals(actual, backend.ashProjectionDot(unpacked, b.extraBits, d, bits), 0f); } @@ -74,6 +78,32 @@ private static float component(AsymmetricHashing.QuantizedVector v, int j, int b } } + @Test public void integerBlockLutPreservesTailsAndAvoidsShortOverflow() { + var backend = VectorizationProvider.getInstance().getVectorUtilSupport(); + Random random = new Random(77531); + for (int groups : new int[]{1,2,31,63,64,65,127,257,4097}) { + int stride = 37; + byte[] codes = new byte[((groups+1)/2)*stride]; + random.nextBytes(codes); + short[] lut = new short[groups*32]; + for (int g = 0; g < groups; g++) for (int code = 0; code < 16; code++) { + short value = groups == 4097 ? (short)225 : (short)(random.nextInt(451)-225); + for (int copy = 0; copy < 2; copy++) lut[g*32+copy*16+code] = value; + } + for (int count : new int[]{1,7,16,17,32,34}) { + float[] out = new float[count+2]; + java.util.Arrays.fill(out, Float.NaN); + backend.ashSymmetricLutScore(codes, groups, stride, 3, count, lut, out, 1); + assertTrue(Float.isNaN(out[0])); assertTrue(Float.isNaN(out[count+1])); + for (int i = 0; i < count; i++) { + long expected = 0; + for (int g = 0; g < groups; g++) expected += lut[g*32+((codes[(g/2)*stride+3+i] >>> (4*(g%2))) & 15)]; + assertEquals("groups="+groups+" count="+count, expected*.25f,out[i+1],0f); + } + } + } + } + private static VectorFloat[] inputs(int n, int d) { var vts = VectorizationProvider.getInstance().getVectorTypeSupport(); Random r = new Random(773); @@ -132,6 +162,16 @@ private static VectorFloat[] inputs(int n, int d) { assertEquals(bsp.searchProviderFor(i).scoreFunction().similarityTo(j), bsp.diversityScoreFunctionFor(i).similarityTo(j), 0f); } + for (var kernel : ASHSymmetricScorer.Kernel.values()) { + if (kernel == ASHSymmetricScorer.Kernel.SIMD && + (!(bits == 1 || bits == 2 || bits == 4) || + !VectorizationProvider.getInstance().getVectorUtilSupport().supportsAshSymmetricScoring())) continue; + var block = scorer.blockScorerFor(encoded.get(i), 8, kernel); + float[] scores = new float[19]; + block.scoreRange(3, scores.length, scores); + for (int j = 0; j < scores.length; j++) assertEquals("C1 block bits="+bits+" kernel="+kernel, + ASHScorer.toSimilarity(scorer.dotProduct(i,j+3)), scores[j], 5e-5f); + } assertEquals("Self score should reconstruct the input norm, up to FP16 headers", VectorUtil.dotProduct(input[i],input[i]), scorer.dotProduct(i,i), 0.002); } @@ -156,11 +196,13 @@ private static VectorFloat[] inputs(int n, int d) { } } - @Test public void rejectsMultipleLandmarks() throws Exception { + @Test public void graphProviderStillRejectsMultipleLandmarks() throws Exception { var values = MockVectorValues.fromValues(inputs(25,40)); var ash = AsymmetricHashing.initialize(values,AsymmetricHashing.RANDOM, AsymmetricHashing.HEADER_BITS+31*2,2,2); - assertThrows(IllegalArgumentException.class, () -> new ASHSymmetricScorer(ash.encodeAll(values, java.util.concurrent.ForkJoinPool.commonPool()))); + var encoded = ash.encodeAll(values, java.util.concurrent.ForkJoinPool.commonPool()); + new ASHSymmetricScorer(encoded); + assertThrows(IllegalArgumentException.class, () -> BuildScoreProvider.ashBuildScoreProvider(VectorSimilarityFunction.DOT_PRODUCT, encoded)); } @Test public void constructionWorksWithPackedCodesAndNegativeSimilarities() throws Exception { @@ -185,4 +227,68 @@ private static VectorFloat[] inputs(int n, int d) { } } } + @Test public void multipleLandmarksMatchCalibratedReconstructionAndExistingKernels() throws Exception { + var input = inputs(300,40); + var values = MockVectorValues.fromValues(input); + var backend = VectorizationProvider.getInstance().getVectorUtilSupport(); + for (int centers : new int[]{2,64,256}) { + for (int bits : new int[]{1,2,4}) { + var ash = AsymmetricHashing.initialize(values, AsymmetricHashing.RANDOM, + AsymmetricHashing.HEADER_BITS + 31*bits, centers, bits); + var encoded = ash.encodeAll(values, java.util.concurrent.ForkJoinPool.commonPool()); + var scalar = new ASHSymmetricScorer(encoded, ASHSymmetricScorer.Kernel.SCALAR); + var vector = backend.supportsAshSymmetricScoring() + ? new ASHSymmetricScorer(encoded, ASHSymmetricScorer.Kernel.SIMD) : scalar; + for (int i = 0; i < input.length; i++) { + int j = (i*31+7) % input.length; + var a = encoded.get(i); var b = encoded.get(j); + int ca = a.landmark & 255, cb = b.landmark & 255; + double expected = 0, pa = 0, pb = 0; + for (int k = 0; k < 40; k++) { + double x = 0, y = 0; + for (int l = 0; l < 31; l++) { + x += ash.stiefelTransform.AFloat[l][k] * component(a,l,bits); + y += ash.stiefelTransform.AFloat[l][k] * component(b,l,bits); + } + pa += x * ash.landmarks[ca].get(k); + pb += y * ash.landmarks[cb].get(k); + expected += (ash.landmarks[ca].get(k) + a.scale*x) + * (ash.landmarks[cb].get(k) + b.scale*y); + } + double correctionA = a.offset, correctionB = b.offset; + if (bits != 2 && bits != 4) { + correctionA -= a.scale*pa; correctionB -= b.scale*pb; + } + expected += correctionA + correctionB; + assertEquals(expected, scalar.dotProduct(i,j), 2e-5); + assertEquals(scalar.dotProduct(i,j), scalar.dotProduct(j,i), 0f); + float similarity = ASHScorer.toSimilarity((float) expected); + assertEquals(similarity, scalar.scoreFunctionFor(a).similarityTo(j), 2e-5f); + assertEquals(similarity, vector.scoreFunctionFor(a).similarityTo(j), 2e-5f); + } + if (centers == 256) { + try (var out = io.github.jbellis.jvector.disk.ByteBufferIndexWriter.create(1024*1024,false)) { + encoded.write(out, io.github.jbellis.jvector.graph.disk.OnDiskGraphIndex.CURRENT_VERSION); + var loaded = ASHVectors.load(new io.github.jbellis.jvector.disk.ByteBufferReader(out.getWrittenData())); + assertEquals(256, loaded.getCompressor().landmarkCount); + for (int i = 0; i < encoded.count(); i++) { + assertEquals(encoded.get(i).landmark & 255, loaded.get(i).landmark & 255); + } + assertEquals(scalar.dotProduct(7,11), new ASHSymmetricScorer(loaded,ASHSymmetricScorer.Kernel.SCALAR).dotProduct(7,11), 2e-6f); + assertEquals(encoded.scoreFunctionFor(input[3],VectorSimilarityFunction.DOT_PRODUCT).similarityTo(7), + loaded.scoreFunctionFor(input[3],VectorSimilarityFunction.DOT_PRODUCT).similarityTo(7), 2e-6f); + } + } + for (int blockSize : new int[]{8,16,32}) { + var query = encoded.get(7); + var block = vector.blockScorerFor(query, blockSize, backend.supportsAshSymmetricScoring() + ? ASHSymmetricScorer.Kernel.SIMD : ASHSymmetricScorer.Kernel.SCALAR); + float[] scores = new float[41]; + block.scoreRange(3,41,scores); + for (int k = 0; k < 41; k++) assertEquals(scalar.scoreFunctionFor(query).similarityTo(k+3), scores[k], 0.005f); + } + } + } + } + } 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 fadcaf3c0..9bb77cd65 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 @@ -60,6 +60,149 @@ private static int[][] ashTailQueryIndices(int dimensionsPerByte) { return tails; } + @Override + public void ashSymmetricLutScore(byte[] codes, int groups, int stride, int lane, int count, + short[] lut, float[] out, int outOffset) { + var shorts = jdk.incubator.vector.ShortVector.SPECIES_PREFERRED; + if (shorts.length() < 16 || shorts.length() > 32) { + VectorUtilSupport.super.ashSymmetricLutScore(codes, groups, stride, lane, count, lut, out, outOffset); + return; + } + var bytes = VectorSpecies.of(byte.class, jdk.incubator.vector.VectorShape.forBitSize(shorts.vectorBitSize() / 2)); + var ints = IntVector.SPECIES_PREFERRED; + var floats = FloatVector.SPECIES_PREFERRED; + for (int i = 0; i < count; i += shorts.length()) { + int active = Math.min(shorts.length(), count - i); + var mask = bytes.indexInRange(0, active); + var sum0 = FloatVector.zero(floats); + var sum1 = FloatVector.zero(floats); + for (int first = 0; first < groups; first += 64) { + var subtotal = jdk.incubator.vector.ShortVector.zero(shorts); + int end = Math.min(groups, first + 64); + for (int g = first; g < end; g += 2) { + var packed = ByteVector.fromArray(bytes, codes, (g / 2) * stride + lane + i, mask); + var fields = (jdk.incubator.vector.ShortVector) packed.convertShape(VectorOperators.B2S, shorts, 0); + var low = jdk.incubator.vector.ShortVector.fromArray(shorts, lut, g * 32) + .rearrange(fields.and((short)15).toShuffle()); + if (g + 1 < end) { + var high = jdk.incubator.vector.ShortVector.fromArray(shorts, lut, (g + 1) * 32) + .rearrange(fields.lanewise(VectorOperators.LSHR, 4).and((short)15).toShuffle()); + low = low.add(high); + } + subtotal = subtotal.add(low); + } + var lo = (IntVector) subtotal.convertShape(VectorOperators.S2I, ints, 0); + var hi = (IntVector) subtotal.convertShape(VectorOperators.S2I, ints, 1); + sum0 = sum0.add((FloatVector)lo.convertShape(VectorOperators.I2F, floats, 0)); + sum1 = sum1.add((FloatVector)hi.convertShape(VectorOperators.I2F, floats, 0)); + } + sum0.mul(.25f).intoArray(out, outOffset + i, floats.indexInRange(0, active)); + if (active > floats.length()) sum1.mul(.25f).intoArray(out, outOffset + i + floats.length(), floats.indexInRange(floats.length(), active)); + } + } + + @Override + public boolean supportsAshSymmetricScoring() { return true; } + + @Override + public float ashSymmetricDot(long[] aBits, byte[] aCodes, long[] bBits, byte[] bCodes, + int dimensions, int bits) { + var longs = LongVector.SPECIES_PREFERRED; + if (bits == 1) { + long mismatches = 0; + int words = dimensions >>> 6; + for (int w = 0; w < words; w += longs.length()) { + var mask = longs.indexInRange(w, words); + var a = LongVector.fromArray(longs, aBits, w, mask); + var b = LongVector.fromArray(longs, bBits, w, mask); + mismatches += a.lanewise(VectorOperators.XOR, b) + .lanewise(VectorOperators.BIT_COUNT).reduceLanes(VectorOperators.ADD); + } + int tail = dimensions & 63; + if (tail != 0) mismatches += Long.bitCount((aBits[words] ^ bBits[words]) & ((1L << tail) - 1)); + return dimensions - 2f * mismatches; + } + if (bits == 2) { + // The doubled signed code is sign * (1 + 2*magnitudeBit). + // Weighted popcounts evaluate its product without unpacking components. + final long slots = 0x5555555555555555L; + int words = dimensions / 32; + long sum = (long) words * 32; + for (int word = 0; word < words; word += longs.length()) { + var byteSpecies = VectorSpecies.of(byte.class, longs.vectorShape()); + var byteMask = byteSpecies.indexInRange(word * 8, words * 8); + var a = ByteVector.fromArray(byteSpecies, aCodes, word * 8, byteMask).reinterpretAsLongs(); + var b = ByteVector.fromArray(byteSpecies, bCodes, word * 8, byteMask).reinterpretAsLongs(); + var negative = a.lanewise(VectorOperators.XOR, b).lanewise(VectorOperators.LSHR, 1).and(slots); + var ma = a.and(slots); + var mb = b.and(slots); + var both = ma.and(mb); + var delta = negative.lanewise(VectorOperators.BIT_COUNT).mul(-2) + .add(ma.lanewise(VectorOperators.BIT_COUNT).mul(2)) + .add(mb.lanewise(VectorOperators.BIT_COUNT).mul(2)) + .add(ma.and(negative).lanewise(VectorOperators.BIT_COUNT).mul(-4)) + .add(mb.and(negative).lanewise(VectorOperators.BIT_COUNT).mul(-4)) + .add(both.lanewise(VectorOperators.BIT_COUNT).mul(4)) + .add(both.and(negative).lanewise(VectorOperators.BIT_COUNT).mul(-8)); + sum += delta.reduceLanes(VectorOperators.ADD); + } + int tail = dimensions % 32; + if (tail != 0) { + long a = 0, b = 0; + for (int j = 0; j < (tail + 3) / 4; j++) { + a |= (long) (aCodes[words * 8 + j] & 255) << (8 * j); + b |= (long) (bCodes[words * 8 + j] & 255) << (8 * j); + } + long valid = slots & ((1L << (2 * tail)) - 1); + long negative = ((a ^ b) >>> 1) & valid; + long ma = a & valid, mb = b & valid, both = ma & mb; + sum += tail - 2 * Long.bitCount(negative) + + 2 * (Long.bitCount(ma) + Long.bitCount(mb)) + - 4 * (Long.bitCount(ma & negative) + Long.bitCount(mb & negative)) + + 4 * Long.bitCount(both) - 8 * Long.bitCount(both & negative); + } + return sum * 0.25f; + } + if (bits != 4) throw new IllegalArgumentException("Symmetric SIMD supports 1, 2, and 4 bits"); + var bytes = ByteVector.SPECIES_PREFERRED; + var shorts = jdk.incubator.vector.ShortVector.SPECIES_PREFERRED; + int count = dimensions / 2; + long sum = 0; + for (int offset = 0; offset < count; offset += bytes.length()) { + int active = Math.min(bytes.length(), count - offset); + var mask = bytes.indexInRange(0, active); + var a = ByteVector.fromArray(bytes, aCodes, offset, mask); + var b = ByteVector.fromArray(bytes, bCodes, offset, mask); + var total = jdk.incubator.vector.ShortVector.zero(shorts); + for (int shift = 0; shift < 8; shift += 4) { + var ax = a.lanewise(VectorOperators.LSHR, shift); + var bx = b.lanewise(VectorOperators.LSHR, shift); + var am = ax.and((byte) 7).mul((byte) 2).add((byte) 1); + var bm = bx.and((byte) 7).mul((byte) 2).add((byte) 1); + am = am.blend(am.neg(), ax.and((byte) 8).compare(VectorOperators.EQ, (byte) 0)); + bm = bm.blend(bm.neg(), bx.and((byte) 8).compare(VectorOperators.EQ, (byte) 0)); + for (int part = 0; part < 2; part++) { + var av = (jdk.incubator.vector.ShortVector) am.convertShape(VectorOperators.B2S, shorts, part); + var bv = (jdk.incubator.vector.ShortVector) bm.convertShape(VectorOperators.B2S, shorts, part); + total = total.add(av.mul(bv)); + } + } + // Widen before reduction: no short-sum overflow, including wider future species. + for (int part = 0; part < 2; part++) { + sum += ((IntVector) total.convertShape(VectorOperators.S2I, IntVector.SPECIES_PREFERRED, part)) + .reduceLanes(VectorOperators.ADD); + } + sum -= 2L * (bytes.length() - active); // zero padding decodes to -1 in each nibble + } + if ((dimensions & 1) != 0) { + int a = aCodes[count] & 15, b = bCodes[count] & 15; + int av = (2 * (a & 7) + 1) * ((a & 8) == 0 ? -1 : 1); + int bv = (2 * (b & 7) + 1) * ((b & 8) == 0 ? -1 : 1); + sum += av * bv; + } + return sum * 0.25f; + } + @Override public boolean supportsAshProjectionScoring() { return PanamaASHKernels.supported(); } From 430819e3482d2850663910958642fbbcd78cb776 Mon Sep 17 00:00:00 2001 From: Ted Willke Date: Sat, 19 Sep 2026 06:15:40 +0000 Subject: [PATCH 074/243] Measure ASH scoring with correct query setup and isolated modes --- .../jbellis/jvector/example/DistancesASH.java | 239 ++++++++++++------ 1 file changed, 155 insertions(+), 84 deletions(-) diff --git a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/DistancesASH.java b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/DistancesASH.java index 8a6279c3c..95604c7c8 100644 --- a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/DistancesASH.java +++ b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/DistancesASH.java @@ -207,134 +207,208 @@ private static double symmetricComponent(AsymmetricHashing.QuantizedVector v, in private static double decodedSymmetric(AsymmetricHashing ash, AsymmetricHashing.QuantizedVector a, AsymmetricHashing.QuantizedVector b) { - double dot = 0, ma = 0, mb = 0; + int ca = a.landmark & 255, cb = b.landmark & 255; + double dot = 0, ownA = 0, ownB = 0, crossA = 0, crossB = 0; for (int j = 0; j < ash.quantizedDim; j++) { double x = symmetricComponent(a, j, ash.bitsPerDimension); double y = symmetricComponent(b, j, ash.bitsPerDimension); dot += x * y; - ma += x * ash.landmarkProj[0][j]; - mb += y * ash.landmarkProj[0][j]; + ownA += x * ash.landmarkProj[ca][j]; + ownB += y * ash.landmarkProj[cb][j]; + crossA += x * ash.landmarkProj[cb][j]; + crossB += y * ash.landmarkProj[ca][j]; } - double oa = a.offset, ob = b.offset; - if (ash.bitsPerDimension == 2 || ash.bitsPerDimension == 4) { - oa += a.scale * ma; - ob += b.scale * mb; + double correctionA = a.offset, correctionB = b.offset; + if (ash.bitsPerDimension != 2 && ash.bitsPerDimension != 4) { + correctionA -= a.scale * ownA; correctionB -= b.scale * ownB; } - double muNorm = 0; - for (int j = 0; j < ash.landmarks[0].length(); j++) { - double x = ash.landmarks[0].get(j); - muNorm += x * x; + double centroidDot = 0; + for (int j = 0; j < ash.originalDimension; j++) { + centroidDot += (double) ash.landmarks[ca].get(j) * ash.landmarks[cb].get(j); } - return Math.max(0, (1 + (double) a.scale * b.scale * dot + oa + ob + muNorm) / 2); + return Math.max(0, (1 + (double) a.scale * b.scale * dot + a.scale * crossA + + b.scale * crossB + correctionA + correctionB + centroidDot) / 2); } - /** Prepared scoring comparison: all query encoding/projection is outside every timer. */ + private static final class ScanScorer { + final ScoreFunction single; + final ASHBlockScorer block; + ScanScorer(ScoreFunction single) { this.single = single; this.block = null; } + ScanScorer(ASHBlockScorer block) { this.single = null; this.block = block; } + void fill(int start, int count, float[] out) { + if (block != null) block.scoreRange(start,count,out); + else for (int i = 0; i < count; i++) out[i] = single.similarityTo(start+i); + } + double scan(int count, float[] scratch) { + double sum = 0; + if (single != null) { + for (int node = 0; node < count; node++) sum += single.similarityTo(node); + } else { + for (int start = 0; start < count; start += scratch.length) { + int n = Math.min(scratch.length, count-start); + block.scoreRange(start,n,scratch); + for (int i = 0; i < n; i++) sum += scratch[i]; + } + } + return sum; + } + } + + /** Only query encoding is excluded. Every timed query creates its actual scoring state. */ private static void benchmarkSymmetric(ASHVectors vectors, List> queries, List> groundTruth, int[] newToOld, int recallK, boolean recall, ForkJoinPool executor) { var ash = vectors.getCompressor(); - if (ash.landmarkCount != 1) throw new IllegalArgumentException("Symmetric scoring requires C=1"); int passes = Integer.getInteger("jvector.bench.scoringPasses", 5); int timingQueries = Math.min(queries.size(), Integer.getInteger("jvector.bench.scoringQueries", 128)); int warmupQueries = Math.min(timingQueries, Integer.getInteger("jvector.bench.scoringWarmupQueries", 32)); - if (passes < 1 || timingQueries < 1 || warmupQueries < 0) { - throw new IllegalArgumentException("Invalid symmetric scoring pass/query counts"); + int timingVectors = Math.min(vectors.count(), Integer.getInteger("jvector.bench.scoringVectors", vectors.count())); + if (passes < 1 || timingQueries < 1 || warmupQueries < 0 || timingVectors < 1) { + throw new IllegalArgumentException("Invalid scoring pass/query/vector counts"); } - long prepareStart = System.nanoTime(); + long start = System.nanoTime(); var encodedQueries = new AsymmetricHashing.QuantizedVector[queries.size()]; for (int q = 0; q < queries.size(); q++) encodedQueries[q] = ash.encode(queries.get(q)); - double encodeSeconds = (System.nanoTime() - prepareStart) / 1e9; - prepareStart = System.nanoTime(); - var symmetric = new io.github.jbellis.jvector.quantization.ASHSymmetricScorer(vectors); - ScoreFunction[][] prepared = new ScoreFunction[2][queries.size()]; - for (int q = 0; q < queries.size(); q++) { - prepared[0][q] = symmetric.scoreFunctionFor(encodedQueries[q]); - prepared[1][q] = vectors.scoreFunctionFor(queries.get(q), VectorSimilarityFunction.DOT_PRODUCT); + System.out.printf(java.util.Locale.ROOT, "QUERY_ENCODING seconds=%.6f queries=%d (excluded)%n", + (System.nanoTime()-start)/1e9, queries.size()); + var scalar = new io.github.jbellis.jvector.quantization.ASHSymmetricScorer(vectors, + io.github.jbellis.jvector.quantization.ASHSymmetricScorer.Kernel.SCALAR); + var backend = io.github.jbellis.jvector.vector.VectorizationProvider.getInstance().getVectorUtilSupport(); + boolean hasSimd = (ash.bitsPerDimension == 1 || ash.bitsPerDimension == 2 || ash.bitsPerDimension == 4) + && backend.supportsAshSymmetricScoring() + && (ash.landmarkCount == 1 || (ash.bitsPerDimension == 1 + ? backend.supportsAshMaskedLoad() : backend.supportsAshProjectionScoring())); + boolean hasBlockSimd = ash.bitsPerDimension == 1 ? backend.supportsAshMaskedLoad() + : (ash.bitsPerDimension == 2 || ash.bitsPerDimension == 4) && backend.supportsAshLutScoring(); + List names = new ArrayList<>(); + List> factories = new ArrayList<>(); + names.add("symmetric-single-scalar"); + factories.add(q -> new ScanScorer(scalar.scoreFunctionFor(encodedQueries[q]))); + if (hasSimd) { + var simd = new io.github.jbellis.jvector.quantization.ASHSymmetricScorer(vectors, + io.github.jbellis.jvector.quantization.ASHSymmetricScorer.Kernel.SIMD); + names.add("symmetric-single-simd"); + factories.add(q -> new ScanScorer(simd.scoreFunctionFor(encodedQueries[q]))); } - System.out.printf(java.util.Locale.ROOT, - "SYMMETRIC_PREPARATION query_encoding_s=%.6f scorer_setup_s=%.6f queries=%d (excluded from timing)%n", - encodeSeconds, (System.nanoTime() - prepareStart) / 1e9, queries.size()); - - double maxError = 0; - for (int q = 0; q < Math.min(4, queries.size()); q++) { - int samples = Math.min(512, vectors.count()); - for (int sample = 0; sample < samples; sample++) { - int node = samples == 1 ? 0 : (int) ((long) sample * (vectors.count() - 1) / (samples - 1)); - double expected = decodedSymmetric(ash, encodedQueries[q], vectors.get(node)); - float actual = prepared[0][q].similarityTo(node); - double error = Math.abs(actual - expected); - if (!Float.isFinite(actual) || !Double.isFinite(expected) - || error > 5e-5 * Math.max(1, Math.abs(expected))) { - throw new AssertionError("Symmetric decoder mismatch q=" + q + " node=" + node - + " expected=" + expected + " actual=" + actual); + names.add("symmetric-block-scalar"); + factories.add(q -> new ScanScorer(scalar.blockScorerFor(encodedQueries[q],32, + io.github.jbellis.jvector.quantization.ASHSymmetricScorer.Kernel.SCALAR))); + if (hasBlockSimd) { + names.add("symmetric-block-simd"); + factories.add(q -> new ScanScorer(scalar.blockScorerFor(encodedQueries[q],32, + io.github.jbellis.jvector.quantization.ASHSymmetricScorer.Kernel.SIMD))); + } + names.add("asymmetric-single-" + singleMode); + factories.add(q -> new ScanScorer(vectors.scoreFunctionFor(queries.get(q),VectorSimilarityFunction.DOT_PRODUCT))); + names.add("asymmetric-block-" + blockMode); + factories.add(q -> new ScanScorer(vectors.blockScorerFor(queries.get(q),VectorSimilarityFunction.DOT_PRODUCT,32))); + + String requestedModes = System.getProperty("jvector.bench.scoringModes", "").trim(); + if (!requestedModes.isEmpty()) { + var selectedModes = new java.util.HashSet<>(java.util.Arrays.asList(requestedModes.split(","))); + for (String name : selectedModes) if (!names.contains(name)) throw new IllegalArgumentException("Unavailable scoring mode: " + name); + for (int i = names.size()-1; i >= 0; i--) if (!selectedModes.contains(names.get(i))) { + names.remove(i); factories.remove(i); + } + } + // Validate selected symmetric modes against an independent decoded/header formula. + for (int mode = 0; mode < names.size(); mode++) { + if (!names.get(mode).startsWith("symmetric-")) continue; + double maxError = 0; + for (int q = 0; q < Math.min(4,queries.size()); q++) { + var scorer = factories.get(mode).apply(q); + float[] one = new float[1]; + int samples = Math.min(512,vectors.count()); + for (int sample = 0; sample < samples; sample++) { + int node = samples == 1 ? 0 : (int) ((long) sample*(vectors.count()-1)/(samples-1)); + double expected = decodedSymmetric(ash,encodedQueries[q],vectors.get(node)); + scorer.fill(node,1,one); + double error = Math.abs(one[0]-expected); + if (!Float.isFinite(one[0]) || !Double.isFinite(expected) + || error > 5e-5*Math.max(1,Math.abs(expected))) { + throw new AssertionError(names.get(mode)+" decoder mismatch q="+q+" node="+node + +" expected="+expected+" actual="+one[0]); + } + maxError = Math.max(maxError,error); } - maxError = Math.max(maxError, error); } + System.out.printf(java.util.Locale.ROOT,"SYMMETRIC_VALIDATION mode=%s max_abs_error=%.9g%n",names.get(mode),maxError); } - System.out.printf(java.util.Locale.ROOT, "SYMMETRIC_VALIDATION max_abs_error=%.9g (independent decoded oracle)%n", maxError); - String symmetricName = ash.bitsPerDimension == 1 ? "symmetric-popcount" - : ash.bitsPerDimension == 2 || ash.bitsPerDimension == 4 ? "symmetric-packed" : "symmetric-generic"; - String[] names = {symmetricName, "asymmetric-" + singleMode + "-prepared"}; if (recall) { if (recallK < 1 || recallK > vectors.count() || groundTruth.size() < queries.size()) { - throw new IllegalArgumentException("Invalid recall size or missing query ground truth"); + throw new IllegalArgumentException("Invalid recall size or missing ground truth"); } - double[] totals = new double[2]; + double[] totals = new double[names.size()]; + float[] scores = new float[32]; for (int q = 0; q < queries.size(); q++) { int[] gt = groundTruth.get(q).stream().mapToInt(Integer::intValue).toArray(); if (gt.length < recallK) throw new IllegalArgumentException("Ground truth row too short"); - for (int mode = 0; mode < 2; mode++) { - var heap = new java.util.PriorityQueue((a, b) -> Float.compare( - Float.intBitsToFloat((int) a[0]), Float.intBitsToFloat((int) b[0]))); - for (int node = 0; node < vectors.count(); node++) { - offerTop(heap, recallK, prepared[mode][q].similarityTo(node), node); + for (int mode = 0; mode < names.size(); mode++) { + var scorer = factories.get(mode).apply(q); + var heap = new java.util.PriorityQueue((a,b) -> Float.compare( + Float.intBitsToFloat((int)a[0]),Float.intBitsToFloat((int)b[0]))); + for (int node = 0; node < vectors.count(); node += scores.length) { + int n = Math.min(scores.length,vectors.count()-node); + scorer.fill(node,n,scores); + for (int i = 0; i < n; i++) offerTop(heap,recallK,scores[i],node+i); } - addRecall(rankedOrdinals(heap), gt, newToOld, recallK, new int[]{recallK}, totals, mode); + addRecall(rankedOrdinals(heap),gt,newToOld,recallK,new int[]{recallK},totals,mode); } } - for (int mode = 0; mode < 2; mode++) { - System.out.printf(java.util.Locale.ROOT, "PREPARED_RECALL mode=%s queries=%d %d-recall@%d=%.6f%n", - names[mode], queries.size(), recallK, recallK, totals[mode] / queries.size()); + for (int mode = 0; mode < names.size(); mode++) { + System.out.printf(java.util.Locale.ROOT,"SCORING_RECALL mode=%s queries=%d %d-recall@%d=%.6f%n", + names.get(mode),queries.size(),recallK,recallK,totals[mode]/queries.size()); } } - for (int mode = 0; mode < 2; mode++) { - scanPrepared(prepared[mode], warmupQueries, vectors.count(), executor); + System.out.println("SCORING_TIMING query encoding excluded; asymmetric projection and all scorer setup included once per scan; symmetric input already encoded"); + long minNanos = (long) (Double.parseDouble(System.getProperty("jvector.bench.scoringMinSeconds", "0.25")) * 1e9); + if (minNanos < 0) throw new IllegalArgumentException("scoringMinSeconds must be nonnegative"); + for (var factory : factories) { + if (warmupQueries == 0) continue; + long until = System.nanoTime() + Math.max(minNanos, 1_000_000_000L); + do { scanWithSetup(factory,warmupQueries,timingVectors,executor); } + while (System.nanoTime() < until); } - long[][] times = new long[2][passes]; + double[][] times = new double[names.size()][passes]; for (int pass = 0; pass < passes; pass++) { - for (int order = 0; order < 2; order++) { - int mode = (pass + order) & 1; - long start = System.nanoTime(); - double checksum = scanPrepared(prepared[mode], timingQueries, vectors.count(), executor); - times[mode][pass] = System.nanoTime() - start; - System.out.printf(java.util.Locale.ROOT, "PREPARED_PASS mode=%s pass=%d seconds=%.6f checksum=%.6f%n", - names[mode], pass + 1, times[mode][pass] / 1e9, checksum); + for (int order = 0; order < names.size(); order++) { + int mode = pass % 2 == 0 ? order : names.size()-1-order; + start = System.nanoTime(); + double checksum = 0; + int repetitions = 0; + long elapsed; + do { + checksum += scanWithSetup(factories.get(mode),timingQueries,timingVectors,executor); + repetitions++; + elapsed = System.nanoTime()-start; + } while (elapsed < minNanos); + times[mode][pass] = elapsed / (double) repetitions; + System.out.printf(java.util.Locale.ROOT,"SCORING_PASS mode=%s pass=%d seconds=%.6f repetitions=%d checksum=%.6f%n", + names.get(mode),pass+1,elapsed/1e9,repetitions,checksum); } } - long pairs = (long) timingQueries * vectors.count(); - for (int mode = 0; mode < 2; mode++) { + long pairs = (long) timingQueries*timingVectors; + for (int mode = 0; mode < names.size(); mode++) { java.util.Arrays.sort(times[mode]); - double median = times[mode][passes / 2]; + double median = times[mode][passes/2]; System.out.printf(java.util.Locale.ROOT, - "PREPARED_THROUGHPUT mode=%s threads=%d queries=%d passes=%d median_Mdot_s=%.6f median_ns_pair=%.3f min_s=%.6f max_s=%.6f%n", - names[mode], executor.getParallelism(), timingQueries, passes, - pairs * 1000.0 / median, median / pairs, - times[mode][0] / 1e9, times[mode][passes - 1] / 1e9); + "SCORING_THROUGHPUT mode=%s threads=%d queries=%d vectors=%d passes=%d median_Mdot_s=%.6f median_ns_pair=%.3f min_scan_s=%.6f max_scan_s=%.6f%n", + names.get(mode),executor.getParallelism(),timingQueries,timingVectors,passes, + pairs*1000.0/median,median/pairs,times[mode][0]/1e9,times[mode][passes-1]/1e9); } } - private static double scanPrepared(ScoreFunction[] scorers, int queries, int count, ForkJoinPool executor) { + private static double scanWithSetup(java.util.function.IntFunction factory, + int queries, int count, ForkJoinPool executor) { if (queries == 0) return 0; - int workers = Math.min(executor.getParallelism(), queries); + int workers = Math.min(executor.getParallelism(),queries); List> tasks = new ArrayList<>(); for (int worker = 0; worker < workers; worker++) { final int first = worker; tasks.add(executor.submit(() -> { double sum = 0; - for (int q = first; q < queries; q += workers) { - var scorer = scorers[q]; - for (int node = 0; node < count; node++) sum += scorer.similarityTo(node); - } + float[] scratch = new float[32]; + for (int q = first; q < queries; q += workers) sum += factory.apply(q).scan(count,scratch); return sum; })); } @@ -388,7 +462,7 @@ public static void testASHEncodings(String filenameBase, String filenameQueries, // Block sizes to benchmark final int[] BLOCK_SIZES = {32}; // Supported fused/multi-bit block capacities: 8, 16, 32. - // How many ASH landmarks to use, C = [1, 64] + // How many ASH landmarks to use, C = [1, 256] final int landmarkCount = Integer.getInteger("jvector.ash.landmarkCount", 1); List> vectors = SiftLoader.readFvecs(filenameBase); @@ -451,9 +525,6 @@ public static void testASHEncodings(String filenameBase, String filenameQueries, + ", payloadBits=" + payloadBits + ", encodedBits=" + encodedBits); - if (Boolean.getBoolean("jvector.bench.symmetric-scoring") && landmarkCount != 1) { - throw new IllegalArgumentException("Symmetric scoring requires landmarkCount=1"); - } final List> finalQueries = queries; final List> finalVectors = vectors; @@ -600,7 +671,7 @@ public static void testASHEncodings(String filenameBase, String filenameQueries, if (Boolean.getBoolean("jvector.bench.symmetric-scoring")) { // A separate comparison mode uses identical scan loops and prepared scorers. - // Encoding, projection, validation, recall, and warmup are all excluded. + // Encoding, validation, recall, and warmup are excluded. Projection/setup are timed. try { benchmarkSymmetric(ashVectorsFinal, finalQueries, groundTruth, newToOldFinal, RECALL_K, RUN_RECALL_CHECK, simdExecutor); From 76ff343ae585d4c76936629f3941528d09fdf80a Mon Sep 17 00:00:00 2001 From: Ted Willke Date: Sat, 19 Sep 2026 06:31:46 +0000 Subject: [PATCH 075/243] Prevent boxed SIMD allocations during ASH graph pruning --- .../diagnostics/ASHBuildScoreExperiment.java | 17 +++++++ .../quantization/TestASHSymmetricScorer.java | 7 +-- .../vector/PanamaVectorUtilSupport.java | 49 +++++++++++++------ 3 files changed, 56 insertions(+), 17 deletions(-) diff --git a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/diagnostics/ASHBuildScoreExperiment.java b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/diagnostics/ASHBuildScoreExperiment.java index 5a0b35f1b..754b7e23a 100644 --- a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/diagnostics/ASHBuildScoreExperiment.java +++ b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/diagnostics/ASHBuildScoreExperiment.java @@ -34,6 +34,23 @@ public static BuildScoreProvider create(VectorSimilarityFunction vsf, ASHVectors if (mode.equals("symmetric") || mode.equals("symmetric-all")) { return BuildScoreProvider.ashBuildScoreProvider(vsf,encoded); } + if (mode.equals("symmetric-scalar")) { + if (vsf != VectorSimilarityFunction.DOT_PRODUCT || encoded.getCompressor().landmarkCount != 1) + throw new IllegalArgumentException("ASH construction requires DOT_PRODUCT and C=1"); + var symmetric = new io.github.jbellis.jvector.quantization.ASHSymmetricScorer(encoded, + io.github.jbellis.jvector.quantization.ASHSymmetricScorer.Kernel.SCALAR); + return new BuildScoreProvider() { + public boolean isExact() { return false; } + public VectorFloat approximateCentroid() { return encoded.getCompressor().landmarks[0].copy(); } + public SearchScoreProvider searchProviderFor(VectorFloat x) { + return new DefaultSearchScoreProvider(encoded.precomputedScoreFunctionFor(x,vsf),null,true); + } + public SearchScoreProvider searchProviderFor(int node) { + return new DefaultSearchScoreProvider(symmetric.scoreFunctionFor(node),null,true); + } + public SearchScoreProvider diversityProviderFor(int node) { return searchProviderFor(node); } + }; + } if (!mode.equals("asymmetric") && !mode.equals("asymmetric-cached")) throw new IllegalArgumentException("Unknown ASH construction mode: " + mode); var views=raw.threadLocalSupplier(); final SearchScoreProvider[] cached; diff --git a/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestASHSymmetricScorer.java b/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestASHSymmetricScorer.java index 1dcedee8c..ca322ed88 100644 --- a/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestASHSymmetricScorer.java +++ b/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestASHSymmetricScorer.java @@ -232,12 +232,13 @@ private static VectorFloat[] inputs(int n, int d) { var values = MockVectorValues.fromValues(input); var backend = VectorizationProvider.getInstance().getVectorUtilSupport(); for (int centers : new int[]{2,64,256}) { - for (int bits : new int[]{1,2,4}) { + for (int bits = 1; bits <= 9; bits++) { var ash = AsymmetricHashing.initialize(values, AsymmetricHashing.RANDOM, AsymmetricHashing.HEADER_BITS + 31*bits, centers, bits); var encoded = ash.encodeAll(values, java.util.concurrent.ForkJoinPool.commonPool()); var scalar = new ASHSymmetricScorer(encoded, ASHSymmetricScorer.Kernel.SCALAR); - var vector = backend.supportsAshSymmetricScoring() + boolean useSimd = (bits == 1 || bits == 2 || bits == 4) && backend.supportsAshSymmetricScoring(); + var vector = useSimd ? new ASHSymmetricScorer(encoded, ASHSymmetricScorer.Kernel.SIMD) : scalar; for (int i = 0; i < input.length; i++) { int j = (i*31+7) % input.length; @@ -281,7 +282,7 @@ private static VectorFloat[] inputs(int n, int d) { } for (int blockSize : new int[]{8,16,32}) { var query = encoded.get(7); - var block = vector.blockScorerFor(query, blockSize, backend.supportsAshSymmetricScoring() + var block = vector.blockScorerFor(query, blockSize, useSimd ? ASHSymmetricScorer.Kernel.SIMD : ASHSymmetricScorer.Kernel.SCALAR); float[] scores = new float[41]; block.scoreRange(3,41,scores); 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 9bb77cd65..45d88033b 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 @@ -39,6 +39,14 @@ class PanamaVectorUtilSupport implements VectorUtilSupport { // Byte loads need only one byte per float lane; conversion part zero widens those lanes. private static final VectorSpecies ASH_BYTES = ASH_FLOATS.length() <= 8 ? ByteVector.SPECIES_64 : ByteVector.SPECIES_128; + private static final VectorSpecies ASH_SYM_LONGS = LongVector.SPECIES_PREFERRED; + private static final VectorSpecies ASH_SYM_WORD_BYTES = VectorSpecies.of(byte.class, ASH_SYM_LONGS.vectorShape()); + private static final VectorSpecies ASH_SYM_BYTES = ByteVector.SPECIES_PREFERRED; + private static final VectorSpecies ASH_SYM_SHORTS = VectorSpecies.of(short.class, ASH_SYM_BYTES.vectorShape()); + private static final VectorSpecies ASH_SYM_INTS = VectorSpecies.of(int.class, ASH_SYM_BYTES.vectorShape()); + private static final VectorSpecies ASH_SYM_FLOATS = VectorSpecies.of(float.class, ASH_SYM_BYTES.vectorShape()); + private static final VectorSpecies ASH_SYM_BLOCK_BYTES = VectorSpecies.of(byte.class, + jdk.incubator.vector.VectorShape.forBitSize(ASH_SYM_SHORTS.vectorBitSize() / 2)); private static final int[] ASH_TWO_BIT_QUERY_INDICES = ashQueryIndices(4); private static final int[] ASH_FOUR_BIT_QUERY_INDICES = ashQueryIndices(2); private static final int[][] ASH_TWO_BIT_TAIL_INDICES = ashTailQueryIndices(4); @@ -63,14 +71,14 @@ private static int[][] ashTailQueryIndices(int dimensionsPerByte) { @Override public void ashSymmetricLutScore(byte[] codes, int groups, int stride, int lane, int count, short[] lut, float[] out, int outOffset) { - var shorts = jdk.incubator.vector.ShortVector.SPECIES_PREFERRED; + var shorts = ASH_SYM_SHORTS; if (shorts.length() < 16 || shorts.length() > 32) { VectorUtilSupport.super.ashSymmetricLutScore(codes, groups, stride, lane, count, lut, out, outOffset); return; } - var bytes = VectorSpecies.of(byte.class, jdk.incubator.vector.VectorShape.forBitSize(shorts.vectorBitSize() / 2)); - var ints = IntVector.SPECIES_PREFERRED; - var floats = FloatVector.SPECIES_PREFERRED; + var bytes = ASH_SYM_BLOCK_BYTES; + var ints = ASH_SYM_INTS; + var floats = ASH_SYM_FLOATS; for (int i = 0; i < count; i += shorts.length()) { int active = Math.min(shorts.length(), count - i); var mask = bytes.indexInRange(0, active); @@ -107,8 +115,18 @@ public void ashSymmetricLutScore(byte[] codes, int groups, int stride, int lane, @Override public float ashSymmetricDot(long[] aBits, byte[] aCodes, long[] bBits, byte[] bCodes, int dimensions, int bits) { - var longs = LongVector.SPECIES_PREFERRED; - if (bits == 1) { + // Keep the Vector API compilation units small and species compile-time constant. + // A shared, large dispatch body can leave boxed vectors in graph-pruning callers. + return switch (bits) { + case 1 -> ashSymmetricBinary(aBits, bBits, dimensions); + case 2 -> ashSymmetricTwoBit(aCodes, bCodes, dimensions); + case 4 -> ashSymmetricFourBit(aCodes, bCodes, dimensions); + default -> throw new IllegalArgumentException("Symmetric SIMD supports 1, 2, and 4 bits"); + }; + } + + private static float ashSymmetricBinary(long[] aBits, long[] bBits, int dimensions) { + var longs = ASH_SYM_LONGS; long mismatches = 0; int words = dimensions >>> 6; for (int w = 0; w < words; w += longs.length()) { @@ -121,15 +139,17 @@ public float ashSymmetricDot(long[] aBits, byte[] aCodes, long[] bBits, byte[] b int tail = dimensions & 63; if (tail != 0) mismatches += Long.bitCount((aBits[words] ^ bBits[words]) & ((1L << tail) - 1)); return dimensions - 2f * mismatches; - } - if (bits == 2) { + } + + private static float ashSymmetricTwoBit(byte[] aCodes, byte[] bCodes, int dimensions) { + var longs = ASH_SYM_LONGS; // The doubled signed code is sign * (1 + 2*magnitudeBit). // Weighted popcounts evaluate its product without unpacking components. final long slots = 0x5555555555555555L; int words = dimensions / 32; long sum = (long) words * 32; for (int word = 0; word < words; word += longs.length()) { - var byteSpecies = VectorSpecies.of(byte.class, longs.vectorShape()); + var byteSpecies = ASH_SYM_WORD_BYTES; var byteMask = byteSpecies.indexInRange(word * 8, words * 8); var a = ByteVector.fromArray(byteSpecies, aCodes, word * 8, byteMask).reinterpretAsLongs(); var b = ByteVector.fromArray(byteSpecies, bCodes, word * 8, byteMask).reinterpretAsLongs(); @@ -162,10 +182,11 @@ public float ashSymmetricDot(long[] aBits, byte[] aCodes, long[] bBits, byte[] b + 4 * Long.bitCount(both) - 8 * Long.bitCount(both & negative); } return sum * 0.25f; - } - if (bits != 4) throw new IllegalArgumentException("Symmetric SIMD supports 1, 2, and 4 bits"); - var bytes = ByteVector.SPECIES_PREFERRED; - var shorts = jdk.incubator.vector.ShortVector.SPECIES_PREFERRED; + } + + private static float ashSymmetricFourBit(byte[] aCodes, byte[] bCodes, int dimensions) { + var bytes = ASH_SYM_BYTES; + var shorts = ASH_SYM_SHORTS; int count = dimensions / 2; long sum = 0; for (int offset = 0; offset < count; offset += bytes.length()) { @@ -189,7 +210,7 @@ public float ashSymmetricDot(long[] aBits, byte[] aCodes, long[] bBits, byte[] b } // Widen before reduction: no short-sum overflow, including wider future species. for (int part = 0; part < 2; part++) { - sum += ((IntVector) total.convertShape(VectorOperators.S2I, IntVector.SPECIES_PREFERRED, part)) + sum += ((IntVector) total.convertShape(VectorOperators.S2I, ASH_SYM_INTS, part)) .reduceLanes(VectorOperators.ADD); } sum -= 2L * (bytes.length() - active); // zero padding decodes to -1 in each nibble From ffa463d0bcf4f7b2231efc2de61f1ade5b369387 Mon Sep 17 00:00:00 2001 From: Ted Willke Date: Sat, 19 Sep 2026 06:39:29 +0000 Subject: [PATCH 076/243] Measure graph throughput over sustained query batches --- .../benchmarks/ThroughputBenchmark.java | 42 ++++++++++++------- 1 file changed, 27 insertions(+), 15 deletions(-) diff --git a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/benchmarks/ThroughputBenchmark.java b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/benchmarks/ThroughputBenchmark.java index eaca57a17..bbf777ef8 100644 --- a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/benchmarks/ThroughputBenchmark.java +++ b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/benchmarks/ThroughputBenchmark.java @@ -188,6 +188,10 @@ public List runBenchmark( } } + double minSampleSeconds = Double.parseDouble(System.getProperty("jvector.bench.throughputMinSeconds", "1.0")); + if (!Double.isFinite(minSampleSeconds) || minSampleSeconds < 0) + throw new IllegalArgumentException("throughputMinSeconds must be finite and nonnegative"); + long minSampleNanos = (long)(minSampleSeconds * 1e9); double[] qpsSamples = new double[numTestRuns]; for (int testRun = 0; testRun < numTestRuns; testRun++) { String testPhase = "Test-" + testRun; @@ -209,22 +213,30 @@ public List runBenchmark( LongAdder visitedAdder = new LongAdder(); long startTime = System.nanoTime(); - IntStream.range(0, totalQueries) - .parallel() - .forEach(i -> { - long queryStart = System.nanoTime(); - - SearchResult sr = QueryExecutor.executeQuery( - cs, topK, rerankK, usePruning, i); - // "Use" the result to prevent optimization - visitedAdder.add(sr.getVisitedCount()); - - long queryEnd = System.nanoTime(); - recorder.recordTime(queryEnd - queryStart); - }); - + long completedQueries = 0; + do { + IntStream.range(0, totalQueries) + .parallel() + .forEach(i -> { + long queryStart = System.nanoTime(); + + SearchResult sr = QueryExecutor.executeQuery( + cs, topK, rerankK, usePruning, i); + // "Use" the result to prevent optimization + visitedAdder.add(sr.getVisitedCount()); + + long queryEnd = System.nanoTime(); + recorder.recordTime(queryEnd - queryStart); + }); + + completedQueries += totalQueries; + } while (System.nanoTime() - startTime < minSampleNanos); + SINK += visitedAdder.sum(); double elapsedSec = (System.nanoTime() - startTime) / 1e9; - return totalQueries / elapsedSec; + System.out.printf(java.util.Locale.ROOT, + "THROUGHPUT_SAMPLE phase=%s queries=%d seconds=%.6f%n", + testPhase, completedQueries, elapsedSec); + return completedQueries / elapsedSec; }); diagnostics.console("Test Run " + testRun + ": " + qpsSamples[testRun] + " QPS\n"); From 869052c175ed0756ded53810c1f446e6244a6cc9 Mon Sep 17 00:00:00 2001 From: Ted Willke Date: Sat, 19 Sep 2026 08:13:38 +0000 Subject: [PATCH 077/243] Expose symmetric ASH scoring through the diversity API --- .../jvector/quantization/ASHVectors.java | 19 ++++++++++++------- .../quantization/TestASHSymmetricScorer.java | 5 +++++ 2 files changed, 17 insertions(+), 7 deletions(-) diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHVectors.java b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHVectors.java index 2e8f81cc0..dd9b4f61b 100644 --- a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHVectors.java +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHVectors.java @@ -51,6 +51,7 @@ public class ASHVectors implements CompressedVectors { final AsymmetricHashing ash; final AsymmetricHashing.QuantizedVector[] compressedVectors; final ASHScorer scorer; + private volatile ASHSymmetricScorer diversityScorer; enum AshSingleKernel { AUTO, @@ -1085,17 +1086,21 @@ public ScoreFunction.ApproximateScoreFunction precomputedScoreFunctionFor( return scoreFunctionFor(query, similarityFunction); } - /** - * Diversity-aware scoring is not supported for ASH at this stage. - * - *

    - * TODO: Define how diversity should be measured. - */ + /** Symmetric node-to-node dot-product similarity from the stored encoded vectors. */ @Override public ScoreFunction.ApproximateScoreFunction diversityFunctionFor( int node1, VectorSimilarityFunction similarityFunction) { - throw new UnsupportedOperationException("ASH diversity scoring not implemented"); + if (similarityFunction != VectorSimilarityFunction.DOT_PRODUCT) + throw new UnsupportedOperationException("ASH diversity scoring supports DOT_PRODUCT only"); + ASHSymmetricScorer symmetric = diversityScorer; + if (symmetric == null) { + synchronized (this) { + symmetric = diversityScorer; + if (symmetric == null) diversityScorer = symmetric = new ASHSymmetricScorer(this); + } + } + return symmetric.scoreFunctionFor(node1); } public AsymmetricHashing.QuantizedVector get(int ordinal) { diff --git a/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestASHSymmetricScorer.java b/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestASHSymmetricScorer.java index ca322ed88..27963b394 100644 --- a/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestASHSymmetricScorer.java +++ b/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestASHSymmetricScorer.java @@ -157,6 +157,8 @@ private static VectorFloat[] inputs(int n, int d) { assertEquals(scorer.dotProduct(i,j), scorer.dotProduct(j,i), 0f); assertEquals(scorer.scoreFunctionFor(i).similarityTo(j), scorer.scoreFunctionFor(encoded.get(i)).similarityTo(j), 0f); + assertEquals(scorer.scoreFunctionFor(i).similarityTo(j), + encoded.diversityFunctionFor(i,VectorSimilarityFunction.DOT_PRODUCT).similarityTo(j), 0f); assertEquals(ASHScorer.toSimilarity(scorer.dotProduct(i,j)), bsp.searchProviderFor(i).scoreFunction().similarityTo(j), 0f); assertEquals(bsp.searchProviderFor(i).scoreFunction().similarityTo(j), @@ -266,8 +268,11 @@ private static VectorFloat[] inputs(int n, int d) { float similarity = ASHScorer.toSimilarity((float) expected); assertEquals(similarity, scalar.scoreFunctionFor(a).similarityTo(j), 2e-5f); assertEquals(similarity, vector.scoreFunctionFor(a).similarityTo(j), 2e-5f); + assertEquals(similarity, encoded.diversityFunctionFor(i,VectorSimilarityFunction.DOT_PRODUCT).similarityTo(j), 2e-5f); } if (centers == 256) { + assertTrue("Exercise unsigned landmark IDs", java.util.stream.IntStream.range(0,encoded.count()) + .anyMatch(node -> (encoded.get(node).landmark & 255) >= 128)); try (var out = io.github.jbellis.jvector.disk.ByteBufferIndexWriter.create(1024*1024,false)) { encoded.write(out, io.github.jbellis.jvector.graph.disk.OnDiskGraphIndex.CURRENT_VERSION); var loaded = ASHVectors.load(new io.github.jbellis.jvector.disk.ByteBufferReader(out.getWrittenData())); From ffa6608572c9d426f6e028f5ab87f34542dd0870 Mon Sep 17 00:00:00 2001 From: Ted Willke Date: Sat, 19 Sep 2026 08:15:40 +0000 Subject: [PATCH 078/243] Document symmetric ASH correctness and performance study --- .../final-asymmetric-controls.csv | 13 + .../final-graph-results.csv | 49 +++ .../final-micro-results.csv | 31 ++ .../implementation-notes.md | 294 ++++++++++++++++++ .../ash-symmetric-study/kernel-experiments.md | 39 +++ docs/ash-symmetric-study/report.md | 174 +++++++++++ 6 files changed, 600 insertions(+) create mode 100644 docs/ash-symmetric-study/final-asymmetric-controls.csv create mode 100644 docs/ash-symmetric-study/final-graph-results.csv create mode 100644 docs/ash-symmetric-study/final-micro-results.csv create mode 100644 docs/ash-symmetric-study/implementation-notes.md create mode 100644 docs/ash-symmetric-study/kernel-experiments.md create mode 100644 docs/ash-symmetric-study/report.md diff --git a/docs/ash-symmetric-study/final-asymmetric-controls.csv b/docs/ash-symmetric-study/final-asymmetric-controls.csv new file mode 100644 index 000000000..f0dd43453 --- /dev/null +++ b/docs/ash-symmetric-study/final-asymmetric-controls.csv @@ -0,0 +1,13 @@ +dataset,mode,rep,build_s,qps,recall,visited,qps_cv,provider_s +ada002-100k,asymmetric-cached,1,19.062567521,150638.30920807895,0.86243,547.9675,1.3794590964550122,0.720251 +cohere-english-v3-100k,asymmetric-cached,1,14.910241575,231653.94557050234,0.82729,657.4614,3.453374124251149,0.324744 +e5-large-v2-100k,asymmetric-cached,1,15.278566978,219437.87965171272,0.81166,668.5197,2.012703348778286,0.308676 +ada002-1M,asymmetric-cached,1,222.707469286,131708.20389569228,0.82501,751.7288,5.299918739807189,5.510627 +cohere-english-v3-1M,asymmetric-cached,1,308.143331078,221832.42654368113,0.69536,883.0375,4.083988556683095,1.484941 +cap-1M,asymmetric-cached,1,207.220140035,133480.11597603708,0.7895,757.8936,6.853695150484436,5.783209 +cap-1M,asymmetric-cached,2,207.996352629,147046.0693958429,0.78781,750.4877,1.6034492683831063,5.626615 +cohere-english-v3-1M,asymmetric-cached,2,155.049321232,240774.58995543895,0.69493,890.907,1.8581733690493571,1.429855 +ada002-1M,asymmetric-cached,2,229.365798497,84947.42036576896,0.82422,752.1287,3.5711789391588313,5.794614 +e5-large-v2-100k,asymmetric-cached,2,15.478295209,222987.4528446198,0.81012,669.5041,2.8123015639604487,0.334623 +cohere-english-v3-100k,asymmetric-cached,2,15.296824291,227276.05582969685,0.82779,653.8773,2.4048198767111466,0.29074 +ada002-100k,asymmetric-cached,2,18.888044342,137221.1547473062,0.86242,545.7134,1.0185284705650302,0.720043 diff --git a/docs/ash-symmetric-study/final-graph-results.csv b/docs/ash-symmetric-study/final-graph-results.csv new file mode 100644 index 000000000..6124fad50 --- /dev/null +++ b/docs/ash-symmetric-study/final-graph-results.csv @@ -0,0 +1,49 @@ +dataset,mode,rep,build_s,qps,recall,visited,qps_cv,provider_s +ada002-100k,baseline,1,11.563925809,128133.40927762356,0.83461,564.3859,1.110392783753509,0 +ada002-100k,symmetric,1,13.976677912,135293.41978232944,0.86364,542.6461,1.2520330791002383,0.298638 +cohere-english-v3-100k,baseline,1,11.920247309,219171.92318082254,0.81607,650.7273,6.416266613052947,0 +cohere-english-v3-100k,symmetric,1,12.742506519,235272.91324787997,0.82895,644.3192,2.195856352720201,0.193623 +e5-base-v2-100k,baseline,1,10.163011807,321335.2472272473,0.77881,646.0268,3.2949920500847996,0 +e5-base-v2-100k,symmetric,1,11.288730394,322348.0309277013,0.78983,644.3812,2.5996258123386653,0.150881 +e5-large-v2-100k,baseline,1,12.967752424,235847.15578126197,0.78072,656.2855,4.337714196878359,0 +e5-large-v2-100k,symmetric,1,12.293223491,218999.23444991774,0.81132,663.6025,1.0085956673754617,0.201756 +e5-small-v2-100k,baseline,1,8.448564154,579677.7373333726,0.59323,717.2814,2.481837237318503,0 +e5-small-v2-100k,symmetric,1,9.586961812,571372.0860323014,0.60474,694.6828,2.6233208197508455,0.076015 +gecko-100k,baseline,1,8.353635419,333785.6857949328,0.78486,659.703,1.2423567759233953,0 +gecko-100k,symmetric,1,11.17258453,330961.81152381445,0.80384,674.6207,3.3530395938281887,0.150993 +openai-v3-large-1536-100k,baseline,1,16.19114714,128625.47979776154,0.86642,514.8725,7.611520119511065,0 +openai-v3-large-1536-100k,symmetric,1,13.534156892,142614.32964480369,0.87934,502.3617,0.5289835042525478,0.301922 +openai-v3-large-3072-100k,baseline,1,16.664105234,45532.79702228551,0.89502,509.3317,4.857718673541916,0 +openai-v3-large-3072-100k,symmetric,1,20.948994785,46111.57765970198,0.91135,497.8579,0.7756766910829613,0.571658 +openai-v3-small-1536-100k,baseline,1,10.985525643,132321.22709798117,0.85159,539.8539,2.404656968140009,0 +openai-v3-small-1536-100k,symmetric,1,13.807036565,140884.70286009577,0.86757,534.4987,2.427578309650972,0.290641 +ada002-100k,symmetric,2,14.580490042,131969.47669913192,0.86386,546.0476,1.7248588981180986,0.306431 +ada002-100k,baseline,2,11.981172058,136106.2708997775,0.84332,552.6875,2.148493081399691,0 +cohere-english-v3-100k,symmetric,2,12.239456559,218271.54512359714,0.82702,643.8008,5.724098326799852,0.206087 +cohere-english-v3-100k,baseline,2,11.711900771,225327.01805467263,0.8147,648.8847,1.0947403115083671,0 +e5-base-v2-100k,symmetric,2,10.200328798,413069.1513778616,0.78879,645.3975,0.5114132632204597,0.148046 +e5-base-v2-100k,baseline,2,10.061538823,310434.6033447739,0.77734,650.337,2.243410455220526,0 +e5-large-v2-100k,symmetric,2,12.047909709,251813.06184014358,0.81257,668.6171,0.954623441783475,0.193218 +e5-large-v2-100k,baseline,2,11.377263422,198062.82246446874,0.78922,659.9321,1.6239998342060038,0 +e5-small-v2-100k,symmetric,2,8.544671915,552494.9813362691,0.60354,688.3396,2.2746895827147022,0.074423 +e5-small-v2-100k,baseline,2,8.094338923,590912.5878094168,0.59194,721.0227,1.4747756773193148,0 +gecko-100k,symmetric,2,11.233076168,370218.64661124424,0.80413,672.4625,1.5236913815301771,0.151427 +gecko-100k,baseline,2,10.251034801,390576.81954583235,0.78489,660.8607,1.175281286750001,0 +openai-v3-large-1536-100k,symmetric,2,13.605731306,143170.37106488296,0.87952,499.516,3.172266381600221,0.298886 +openai-v3-large-1536-100k,baseline,2,13.15607144,128641.47954605563,0.8659,517.8566,5.519978561362939,0 +openai-v3-large-3072-100k,symmetric,2,20.491915231,46947.548523567304,0.91144,499.5414,2.888381824309649,0.60297 +openai-v3-large-3072-100k,baseline,2,18.410612637,44932.711712325356,0.89666,507.5203,1.1598763127760914,0 +openai-v3-small-1536-100k,symmetric,2,13.660567867,157711.7566109329,0.86719,532.6686,1.2291076736510924,0.303335 +openai-v3-small-1536-100k,baseline,2,13.654421596,133767.7879151411,0.85084,542.4316,2.796080469652006,0 +ada002-1M,baseline,1,128.024840629,122500.09915563895,0.79495,773.9812,1.109027052536163,0 +ada002-1M,symmetric,1,159.359565626,133700.257710118,0.82656,742.8768,0.6300371060924892,2.870145 +cohere-english-v3-1M,baseline,1,244.790835916,197088.91836877936,0.63769,893.0455,1.2240737403354007,0 +cohere-english-v3-1M,symmetric,1,124.396110455,216640.72177507152,0.69621,894.964,0.5314395893423304,1.870582 +cap-1M,baseline,1,125.077205941,125473.95949366484,0.73663,760.8866,0.4379570774346236,0 +cap-1M,symmetric,1,141.178990091,142809.93692013,0.78746,750.1225,0.44767066363590347,2.918257 +ada002-1M,symmetric,2,150.705520182,136171.694616978,0.82682,747.8467,1.169995543264484,2.861839 +ada002-1M,baseline,2,128.88373143,122884.11737802878,0.7905,772.5621,0.26386216198655665,0 +cohere-english-v3-1M,symmetric,2,114.995056094,213219.87925304964,0.69643,884.2188,1.60117178767442,1.87581 +cohere-english-v3-1M,baseline,2,135.07986109,219249.3567696565,0.65126,883.5158,0.3492137374999325,0 +cap-1M,symmetric,2,153.133362125,139040.26147924952,0.79033,753.2187,1.4794840301794259,2.921004 +cap-1M,baseline,2,130.060055604,125743.95169939703,0.7294,764.4218,1.0602563210175113,0 diff --git a/docs/ash-symmetric-study/final-micro-results.csv b/docs/ash-symmetric-study/final-micro-results.csv new file mode 100644 index 000000000..521633762 --- /dev/null +++ b/docs/ash-symmetric-study/final-micro-results.csv @@ -0,0 +1,31 @@ +dataset,bits,mode,mdot_s,ns_pair,recall,queries +ada002,2,asymmetric-block-simd,21.495776,46.521,0.8808,1000 +ada002,2,asymmetric-single-simd,5.513374,181.377,0.8808,1000 +ada002,2,symmetric-block-scalar,2.419918,413.237,0.8437,1000 +ada002,2,symmetric-block-simd,30.849219,32.416,0.8437,1000 +ada002,2,symmetric-single-scalar,8.866285,112.787,0.8437,1000 +ada002,2,symmetric-single-simd,22.054206,45.343,0.8437,1000 +cohere_english_v3,1,asymmetric-block-simd,12.404803,80.614,0.7891,1000 +cohere_english_v3,1,asymmetric-single-simd,18.87169,52.989,0.7891,1000 +cohere_english_v3,1,symmetric-block-scalar,83.872673,11.923,0.6931,1000 +cohere_english_v3,1,symmetric-block-simd,84.57992,11.823,0.6931,1000 +cohere_english_v3,1,symmetric-single-scalar,71.156303,14.054,0.6931,1000 +cohere_english_v3,1,symmetric-single-simd,103.800972,9.634,0.6931,1000 +cohere_english_v3,2,asymmetric-block-simd,33.083916,30.226,0.853,1000 +cohere_english_v3,2,asymmetric-single-simd,8.052505,124.185,0.853,1000 +cohere_english_v3,2,symmetric-block-scalar,3.954008,252.908,0.8045,1000 +cohere_english_v3,2,symmetric-block-simd,37.979327,26.33,0.8045,1000 +cohere_english_v3,2,symmetric-single-scalar,13.813492,72.393,0.8045,1000 +cohere_english_v3,2,symmetric-single-simd,29.763263,33.598,0.8045,1000 +cohere_english_v3,4,asymmetric-block-simd,33.161011,30.156,0.8036,1000 +cohere_english_v3,4,asymmetric-single-simd,14.796965,67.581,0.8036,1000 +cohere_english_v3,4,symmetric-block-scalar,3.961422,252.435,0.7938,1000 +cohere_english_v3,4,symmetric-block-simd,43.79741,22.832,0.7938,1000 +cohere_english_v3,4,symmetric-single-scalar,8.252719,121.172,0.7938,1000 +cohere_english_v3,4,symmetric-single-simd,18.346236,54.507,0.7938,1000 +e5_large_v2,2,asymmetric-block-simd,32.756876,30.528,0.8395,1000 +e5_large_v2,2,asymmetric-single-simd,8.113795,123.247,0.8395,1000 +e5_large_v2,2,symmetric-block-scalar,3.934209,254.181,0.7877,1000 +e5_large_v2,2,symmetric-block-simd,38.003571,26.313,0.7877,1000 +e5_large_v2,2,symmetric-single-scalar,14.440238,69.251,0.7877,1000 +e5_large_v2,2,symmetric-single-simd,29.673568,33.7,0.7877,1000 diff --git a/docs/ash-symmetric-study/implementation-notes.md b/docs/ash-symmetric-study/implementation-notes.md new file mode 100644 index 000000000..dfd07aa8e --- /dev/null +++ b/docs/ash-symmetric-study/implementation-notes.md @@ -0,0 +1,294 @@ +# ASH symmetric construction scoring + +Implementation branch: `ash-symmetric-scorer`, based on `ash-dev` commit +`67f532a282c72b66e97c4b074242bea406c0eaa0`. No PR #522 deduplication changes. + +## Derivation and storage + +The final ASH paper (2606.07870v2), Appendix B equations B.2–B.4, gives + + dot(x,y) ~= sx sy dot(code_x,code_y) + ox + oy + ||mu||² + +where `sx = ||x-mu|| / ||code_x||` and `ox = - ||mu||²`. +For the Java/C++ 2/4-bit projection representation, the stored offset is +`ox - sx `. The new scorer restores the latter term once per +encoded vector. Both calculations use stored FP16-rounded headers; recovering +`ox` consequently retains FP16 error. Graph construction remains restricted to C=1. The symmetric scorer does not change the encoded payload layout. Standalone scoring now supports up to 256 landmarks; this expands the prior reader/encoder range guard, so standalone C>64 compressor files will be rejected by older implementations even though the layout is unchanged. + +The packed code layouts and offset convention follow +`tlwillke/ivf-ash-fastscan` revision `56d78d0`, particularly +`ash_projection_code.h` and the projection query code in `ash_query.cpp`, as +well as the existing Java `AsymmetricHashing` encoder and `ASHScorer` decoder. +The C++ IVF implementation provides the packed layout and header arithmetic; +it is not evidence for a graph-construction symmetric kernel. + +The binary shortcut is `d - 2*popcount(xor(sign_x,sign_y))`, with masked tail +bits. This follows B.3/B.6 directly. The printed B.7 has an apparent constant +term error (`d^-1` instead of 1 after dividing by d); its prose also confuses +the AND dot product of 0/1 bits with XNOR agreement. The implementation uses +the algebraic identity and independent decoder tests. + +For 2/4-bit codes, doubled component values are odd signed integers. The 2-bit +single kernels use weighted popcounts (scalar word or SIMD); the 4-bit scalar +fallback uses a shared 512-byte nibble-product table, while SIMD unpacks to +short lanes and multiplies. Integer sums are divided by four. Widths 3 and 5–9 +use the existing generic sign-plus-extra layout. Binary range scoring applies +direct XOR/popcount pairs. C=1 multibit block scoring builds exact short-valued +lookup tables from the encoded query, then uses packed paired-nibble lookups +and bounded short accumulation followed by widening. This borrows the C++ +fastscan implementation approach without adding another quantization step. +There is no per-pair raw-vector projection or SIMD gather. + +The graph adapter applies `max(0,(1+dot)/2)` exactly once, matching asymmetric +ASH. The prior symmetric adapter returned raw dots while insertion search +used transformed similarities. That mixed incompatible scales during pruning. + +## Defects in the previous scorer + +For 2/4-bit ASH, `binaryVector` is empty and the payload is in `extraBits`. +The previous loop read no payload, yielding the same code-dot term `-d` for +every pair before header corrections. It also treated the projection-mode +offset as the unadjusted offset. For one-bit tails it counted complement +padding as matches. These defects predate the duplicate-neighbor work. + +## Comparison modes + +- Baseline: untouched ash-dev jar, including the broken construction scorer. +- Symmetric (default candidate): corrected symmetric node-to-node scoring; + insertion search still uses the incoming raw vector asymmetrically. +- Asymmetric: benchmark adapter uses the existing query scorer and original + source vectors for node-ID construction calls, including diversity checks. +- Symmetric-all: corrected symmetric scorer also used for insertion search. +- Asymmetric-cached: identical existing asymmetric math, with immutable query + state precomputed once per node in parallel; retains the arrays for the build. + +All modes use the existing graph algorithm, including its current duplicate +behavior. Query-time scoring remains the same asymmetric fused scorer. +Symmetric construction adds one float per encoded vector for 2/4-bit landmark +corrections. Asymmetric construction needs access to original vectors and +repeats the current scorer's projection/setup on each node-ID provider call. +The uncached and cached controls separate pairwise scoring cost from setup cost. + +Run-level settings: 32X, 2 bits, C=1, ITQ, M=64, efConstruction=200, +overflow=1.2, hierarchy/refine/fused enabled, NVQ reranking, k=10 at 1x, +pruning disabled, adaptive termination at the existing default gamma=0.01. New graph build each run; existing identical compressors +are reused to isolate graph construction. JDK23, vector module, SIMD kernels, +128GB heap, RAID10 temp directory. Two repetitions use reversed mode order. +Provider setup is reported separately because the legacy graph-build timer +starts after provider initialization; the tables add it to measured graph time. +Writer/builder initialization outside that timer and initial ASH encoding are +not included; these are graph-build comparisons, not end-to-end indexing time. +The old provider setup was not separately timed and is treated as zero (it +only computed the landmark norm). + +## Asymmetric construction profile + +A 30-second JFR profile of the first Ada002 asymmetric construction run contains +14,333 execution samples: 96.29% of samples include `ASHScorer.precomputeQuery`, +and 86.90% have `PanamaVectorUtilSupport.ashDotRow` as the leaf method. Only +1.97% include `ashProjectionDot`. Inclusive percentages overlap and must not +be added. The first Ada002 asymmetric timing includes this profile overhead; +the reversed-order repetition is unprofiled. + +This establishes repeated projection/setup as the main cost of the uncached +asymmetric adapter. It does not establish that asymmetric pairwise arithmetic +is intrinsically slower by the observed build-time ratio. A separate cached +adapter retains each immutable existing asymmetric scorer once per node to +measure the speed/memory tradeoff without changing its numerical algorithm. +For C=1 the existing scorer retains two d-length float arrays plus two scalar +float arrays per node, as well as object/array overhead. This is far larger +than the symmetric scorer's single cached float per node. + +Existing on-disk graphs keep their existing topology. Rebuild to obtain the +new construction behavior; merely changing the reader does not repair edges +selected by the old scorer. The asymmetric query scorer and encoded payload +format are unchanged. + +The proposed default remains a mixed construction strategy: accurate asymmetric search +for a newly supplied raw vector, compact symmetric scoring for existing-node +pruning and refinement. Fully symmetric insertion and fully asymmetric node-ID +scoring are benchmark controls, not additional production defaults. + +The paper writes multibit components as odd integers (e.g. -3,-1,1,3 at two +bits). Java and the C++ projection decoder use half of those values +(-1.5,-0.5,0.5,1.5). The stored scale is correspondingly twice as large. +The reconstruction and symmetric product are unchanged by this convention; +the nibble table uses doubled integer components internally and divides its +product sum by four. + +Score-quality probes are sampled database vectors, with self matches excluded, +and their reference neighbors are obtained by an exact input-space scan. +They diagnose construction-space score quality. Graph recall uses the dataset's +separate benchmark queries and ground truth; the two recall numbers should +not be interpreted as a shared recall ceiling. + +## Validation and reproduction + +Initial correctness fix: `baa1027c`. Expanded encoded kernels and flexibility: `cc5d0d1b`. Correct timing and isolated benchmark modes: `62bc6f6d`. Graph-context SIMD allocation fix: `1338d46f`. The expanded regression tests cover all widths 1–9, +dimensions 1–749 including every tail alignment, independent unpacking, +scalar/SIMD cross-checks, exact exchange symmetry, Appendix B reconstruction, +existing asymmetric scoring of reconstructed sources, FP16 header round trips, +zero residuals, negative raw scores, the graph C=1 guard, standalone C=2/64/256 across widths 1–9, and graph construction. +The focused suite also includes existing `TestASHGraphSearch` (2 tests) and +`TestASHScoringDispatch` (4 tests). Scalar and SIMD Maven executions passed, with both the preferred 512-bit SIMD shape and an additional forced-AVX2 256-bit check. + +Build/test command (JDK23): + +```sh +mvn -pl jvector-tests,jvector-examples -am \ + -Dtest=TestASHSymmetricScorer,TestASHGraphSearch,TestASHScoringDispatch,TestASHStandaloneVsFusedScoring \ + -Dsurefire.failIfNoSpecifiedTests=false \ + -DskipScalar=false -DskipSIMD=false -Dmaven.javadoc.skip=true package +``` + +Benchmark-only mode selection is +`-Djvector.bench.ashConstruction=symmetric|symmetric-all|asymmetric|asymmetric-cached`. +The library default has no new configuration switch. The example benchmark +adapter changes construction only; query scoring remains asymmetric. + +JVM options used for every benchmark: + +```text +--add-modules=jdk.incubator.vector +--enable-native-access=ALL-UNNAMED +-Xmx128g +-Djvector.ash.singleKernel=simd +-Djvector.ash.blockKernel=simd +-Djvector.ash.lut.accumulators=2 +-Djvector.ash.lut.unroll=1 +-Djvector.ash.projection.accumulators=1 +-Djvector.ash.projection.unroll=1 +-Djava.io.tmpdir=/mnt/raid10/jvector-bench/tmp +``` + +The main class is `io.github.jbellis.jvector.example.BenchYAML`; pass a YAML +with the settings above as its argument. Score diagnostics use +`-Djvector.ash.debugSymmetric=true`, with `.pairs=5000`, `.subsetN=100000`, +and `.topK=10` appended to the same property prefix. Diagnostics run before +graph construction; those extra diagnostic-run builds are excluded from the +comparison tables. Their single-thread scoring measurements exclude setup +and use the median of nine scans after five warmups. These are supporting +microbenchmarks, not graph throughput predictions or a replacement for JMH. + +The remote evidence directory is +`/mnt/raid10/jvector-bench/ted_willke/ash-symmetric-study`. +It retains frozen baseline/candidate jars, per-run logs, the input-space score +study, JFR profile, and extracted `results.csv`. The baseline jar was compiled +from `67f532a2` before the scorer change. The first candidate jar is preserved +as `candidate-v1.jar`; the later jar adds cache and diagnostic controls with +identical production scorer arithmetic. No compressor training was retimed. + +## DistancesASH prepared-query comparison + +Commit `f8e64213` adds `ASHSymmetricScorer.scoreFunctionFor(QuantizedVector)` +and the benchmark mode `-Djvector.bench.symmetric-scoring=true`. Queries must +be encoded with the same trained compressor as the base vectors. This API +recovers the query landmark offset once, before returning the scoring function; +its timed kernel is the same `codeDot` implementation used by node-ID scoring. + +In this mode DistancesASH prepares all compressed queries and both sets of +scoring functions before measurement. Query encoding and projection setup are +reported separately and excluded. Both scorers then use the same full-scan +loop, executor, query subset, checksum accumulation, and five measured passes +with alternating order. It reports median throughput and min/max elapsed time. +Warmup, independent decoded-oracle validation, and held-out-query recall are +outside all throughput timers. The ordinary historical benchmark mode remains +available; its timing includes scorer setup and must not be mixed with these +prepared-query results without labeling that distinction. + +Example flags in addition to the shared JVM flags: + +```text +-Djvector.bench.symmetric-scoring=true +-Djvector.ash.optimizer=itq +-Djvector.ash.landmarkCount=1 +-Djvector.ash.bitsPerDimension=2 +-Djvector.ash.quantizedDimensions=748 +-Djvector.bench.recall=true +-Djvector.bench.recall.k=10 +-Djvector.bench.maxQueries=1000 +-Djvector.bench.scoringQueries=128 +-Djvector.bench.scoringPasses=5 +-Djvector.bench.scoringWarmupQueries=32 +-Djvector.bench.scoringThreads=1 +``` + +Call `io.github.jbellis.jvector.example.DistancesASH` with base `.fvecs`, +query `.fvecs`, and ground-truth `.ivecs` paths. At 32X with two bits, the +quantized dimension is `(originalDimension - 40) / 2`: 748 for Ada002 and +492 for E5-large/Cohere. The 40-bit header is included in the compression +budget. Generic bit widths require `singleKernel=scalar` for the asymmetric +control; that restriction belongs to the existing asymmetric implementation. +The binary symmetric path is popcount, 2/4-bit is packed nibble arithmetic, +and the remaining widths use the generic reference representation. + +The tiny end-to-end fixture uses 129 base vectors and nine distinct held-out +queries at original dimension 64 / quantized dimension 31. All widths 1–9 +pass the independent decoder validation, ground-truth recall plumbing, and +timing loops. The fixture timings are not used as performance evidence. + + +## Encoded-input contract and measurement + +Symmetric distance starts from two encoded vectors. Prior projection and encoding +of both operands are excluded. Query-specific state derived from the encoded +query (such as LUT creation or folded-header recovery) is included in timing. +No symmetric scorer multiplies a raw vector by the projection matrix. +Asymmetric distance starts from a raw query and an encoded target: its query +projection and scorer setup are timed once per query, then reused over that +query's candidates. Recall selection is outside throughput timing. + +The initial PREPARED_* results are kernel-only measurements with scorer setup +excluded for both methods. They are retained as historical data, not the final +end-to-end scoring comparison. SCORING_* results use the contract above. +Final microbenchmark modes should run in independent JVMs to avoid cross-mode +JIT profile effects. DistancesASH supports `jvector.bench.scoringModes` for this. +Warmup lasts at least one second per selected mode, and each timed pass lasts +at least 0.25 seconds by repeating query batches when necessary. Raw duration, +repetitions, and normalized scan duration are recorded separately. A 64-candidate +run measures setup amortization and is not graph QPS or a full scan. + +## Multiple landmarks + +Appendix B presents C=1. For standalone C>1, the implementation generalizes its +header-calibrated reconstruction rather than claiming an additional paper result: + + xhat = mu_a + sx W^T code_x + delta_x = ox - sx + score(x,y) = + delta_x + delta_y + +This expands into symmetric code-dot, cross-centroid, and centroid-dot terms and +reduces algebraically to Appendix B at C=1. Encoded-query state can be fed to the +existing asymmetric single/block kernels without a raw-vector projection. Tests +compare direct decoded reconstruction, exchanged operands, and scalar/SIMD paths. +Centroid IDs must be treated as unsigned bytes, including IDs 128–255. Serialization +round trips allow float-matrix rounding error; packed integer kernel parity is exact. + + +## Graph-context SIMD compilation + +A fast isolated scorer was not sufficient evidence for a production build path. +The first combined SIMD dispatch body allocated boxed ByteVector and mask objects +when called from graph diversity pruning. A profiled Ada002 100k build took +24.7 seconds, versus 18.2 seconds for the earlier corrected scalar scorer. +Splitting the 1/2/4-bit Vector API kernels into small compilation units and +making matched vector species static constants removed that allocation pattern. +The first profiled split-SIMD build took 12.8 seconds; the improved scalar +word-popcount control took 15.5 seconds. Whole-run GC counts were 137 before +the split and 30 afterward. These are diagnostic runs; final tables use fresh +unprofiled runs. This is why the final recommendation is based on actual graph +construction as well as isolated full scans. + + +## Sustained graph-query timing + +The original graph throughput harness measured each 10,000-query batch only +once. At hundreds of thousands of QPS those samples lasted only tens of +milliseconds, producing large variability and an unreproduced Cohere slowdown. +`f98fcf92` repeats complete query batches until each sample lasts at least one +second and records completed query count plus elapsed time. Synthetic warmup +queries remain unchanged. Both graph branches use the identical benchmark-only +overlay; the baseline library JAR remains byte-for-byte unchanged. The overlay +contains only `ThroughputBenchmark.class`, no graph, quantization, or SIMD classes. +Final graph results use this sustained timing protocol. Earlier short-sample +results and the construction-boxing experiment are archived separately. diff --git a/docs/ash-symmetric-study/kernel-experiments.md b/docs/ash-symmetric-study/kernel-experiments.md new file mode 100644 index 000000000..2f95d9a5b --- /dev/null +++ b/docs/ash-symmetric-study/kernel-experiments.md @@ -0,0 +1,39 @@ +# Encoded symmetric kernel experiments + +Cohere 100k, 32X, C=1, one thread, JDK23, Xeon 6985P-C. Each final comparison mode ran in its own JVM. Five timing passes; prior query encoding is excluded for symmetric scoring; asymmetric projection and scorer setup are included once per query. Short scans repeat batches for at least 0.25 seconds per pass. Values below are median ns/pair. + +## 2-bit pair implementations + +| Implementation | 99,685 targets/query | 64 targets/query | +|---|---:|---:| +| Scalar word popcounts | 63.3 | 90.3 | +| SIMD unpack and multiply | 95.6 | 125.9 | +| SIMD weighted popcounts | 42.4 | 62.0 | +| Asymmetric SIMD single | 119.5 | 980.7 | + +The experimental scalar-word implementation used the temporary SIMD entry point for the comparison harness; it executes scalar CPU popcount instructions. It is retained in the scalar fallback. The final SIMD implementation uses weighted vector popcounts. Experimental switches were removed. + +## Block implementations + +| Bits | Implementation | 99,685 targets/query | 64 targets/query | +|---:|---|---:|---:| +| 2 | Reused floating-point symmetric LUT | 37.9 | 197.6 | +| 2 | Paired-nibble exact integer symmetric LUT | 25.3 | 200.7 | +| 2 | Asymmetric SIMD block | 31.0 | 670.7 | +| 4 | Reused floating-point symmetric LUT | 36.5 | 161.5 | +| 4 | Paired-nibble exact integer symmetric LUT | 23.2 | 127.5 | +| 4 | Asymmetric SIMD block | 30.1 | 363.7 | + +## Binary scoring + +| Implementation | 99,685 targets/query | +|---|---:| +| symmetric-single-scalar | 13.2 | +| symmetric-single-simd | 12.2 | +| symmetric-block-simd | 14.8 | +| asymmetric-single-simd | 54.0 | +| asymmetric-block-simd | 80.5 | + +The integer LUT kernel borrows the C++ fastscan pattern of packed nibble lookups and narrow accumulators. Its values are exact products of encoded integer components; it does not requantize scores. Accumulators widen in bounded chunks to prevent overflow. These are scorer measurements, not graph QPS; the two methods also have different approximation error because symmetric scoring quantizes both operands. + +Shared-JVM exploratory runs exhibited large changes in unchanged control timings. They are not used in this table. Independent-process comparisons eliminate cross-mode type-profile pollution, but these are still one-host measurements rather than a universal hardware claim. diff --git a/docs/ash-symmetric-study/report.md b/docs/ash-symmetric-study/report.md new file mode 100644 index 000000000..8ee100df3 --- /dev/null +++ b/docs/ash-symmetric-study/report.md @@ -0,0 +1,174 @@ +# ASH symmetric scorer: final study + +Completed on the authoritative remote repository `/home/ted_willke/jvector-ash`, branch `ash-symmetric-scorer`. No deduplication changes. + +# Findings and recommendation + +Use symmetric ASH for comparisons between already encoded graph nodes. Keep the existing asymmetric scorer for raw-vector insertion and user queries. Both operands of symmetric scoring are already encoded; their earlier projection/encoding is excluded. Asymmetric query projection is timed once per query and reused over that query's candidates. + +## Correctness + +The old node-to-node implementation ignored the 2/4-bit payload, used folded offsets as though they were original centroid corrections, and returned a score on a different scale from insertion search. Binary tail bits were also handled incorrectly. The replacement follows Appendix B of the final paper at C=1 and preserves the library's normalized dot-product similarity scale. + +Standalone scoring supports 1–9 bits, with SIMD single/range kernels for 1/2/4 bits and up to 256 landmarks. The graph provider retains its C=1 restriction. Multiple-landmark scoring uses a tested header-calibrated reconstruction generalization; Appendix B itself only presents C=1. Tests also uncovered and fixed signed-byte indexing for landmark IDs 128–255. The encoded layout is unchanged, but older implementations' C≤64 guard rejects newly written standalone C>64 compressor files. + +## Kernel choices + +The retained paths are binary XOR/popcount, scalar word and SIMD weighted popcounts for 2-bit pairs, SIMD unpack/multiply for 4-bit pairs, and exact integer LUT block scoring for 2/4 bits. The block implementation borrows the C++ fastscan approach of packed nibble lookup and narrow accumulation. Bounded widening prevents short overflow. It adds no extra score quantization. Experimental alternatives and switches were removed. + +A fast isolated SIMD kernel initially allocated boxed Vector API objects when called from graph pruning. Profiling exposed this; splitting kernels by bit width and making species constants explicit removed the dominant boxed allocation pattern. A profiled Ada002-100k build fell from 24.7 to 12.8 seconds; the earlier corrected scalar implementation took 18.2 seconds. These diagnostic timings are separate from the final unprofiled tables. + +## Graph evidence + +Against the correct, projection-cached asymmetric construction control, symmetric construction was faster on all six measured cases, with slightly higher mean graph recall in each case. At 1M, build plus provider setup averaged: + +| Dataset | Cached asymmetric | Symmetric | Recall@10: asymmetric / symmetric | +|---|---:|---:|---:| +| Ada002 | 231.69 s | 157.90 s | 82.462% / 82.669% | +| CAP | 213.31 s | 150.08 s | 78.865% / 78.889% | +| Cohere | 233.05 s | 121.57 s | 69.514% / 69.632% | + +The asymmetric cache retains approximately 3.7–5.6 GiB of float arrays at 1M, plus object overhead; symmetric header corrections need approximately 3.8 MiB. No such asymmetric cache was added to production defaults. + +These means need qualification. Cohere's cached-asymmetric build varied from about 155 to 308 seconds. Query QPS also varies between independently built graphs/JVMs, even with sustained timing. The table does not establish one universal speedup or statistically significant small recall differences. + +Compared with the legacy ash-dev scorer, corrected recall improves on all nine 100k and all three 1M datasets. Construction is not uniformly faster than the legacy implementation, which was not performing the intended distance calculation. At 100k, mean query QPS decreases about 4.0% for E5-small and 3.2% for Gecko and improves for the other seven. These exceptions are retained in the report. + +An earlier fully symmetric insertion control gave no consistent build benefit and lost 0.358 percentage points on E5-large versus mixed construction. Keep raw-vector insertion asymmetric. Full-scan symmetric query scoring also incurs additional quantization error; faster encoded-pair arithmetic is not a reason to replace raw-query asymmetric scoring universally. + +## Measurement protocol + +Graph settings: 32X, 2 bits, C=1, ITQ, M=64, efConstruction=200, overflow=1.2, hierarchy/refinement/fused storage, NVQ reranking, k=10 at 1×, pruning disabled, existing adaptive gamma=0.01. Fresh graph builds reuse identical compressors to isolate scoring/build changes. Build seconds include graph build/write and provider setup, excluding training and initial vector encoding. + +Each graph mode has two independent builds. Legacy/candidate order is reversed on the second pass. QPS uses three samples of at least one second each, with actual completed-query counts recorded. The same benchmark-only overlay is applied to both library JARs; baseline library bytes are unchanged. Short-sample exploratory timings are archived separately. + +Microbenchmarks run one mode per JVM, one scoring thread, five timing passes, and 1,000 held-out queries for untimed exhaustive recall. Symmetric query encoding is excluded; scorer setup is included. Asymmetric projection is included once per timed query. The 64-target diagnostics measure setup amortization and are not graph QPS. + +Tests cover independent decoded-vector formulas, exact packed scalar/SIMD parity, all tail alignments, integer accumulator overflow, zero residuals, negative scores, serialization, unsigned landmark IDs, C=1 graph guards, and standalone C=2/64/256 across all bit widths. SIMD was checked at both 512-bit and forced 256-bit widths on JDK23. + + +# Final full-scan scorer comparison + +One thread, one mode per JVM, 32X compression, C=1, ITQ. Symmetric starts from encoded vectors; previous query encoding is excluded. Asymmetric raw-query projection is included once per query. Scorer setup is included for both. Five timing passes use 128 queries each; held-out exhaustive recall uses 1,000 queries and is outside the timer. Values are million scored vector pairs per second. + +| Dataset | Bits | Sym single scalar | Sym single SIMD | Sym block scalar | Sym block SIMD | Asym single SIMD | Asym block SIMD | Sym recall@10 | Asym recall@10 | +|---|---:|---:|---:|---:|---:|---:|---:|---:|---:| +| ada002 | 2 | 8.87 | 22.05 | 2.42 | 30.85 | 5.51 | 21.50 | 84.37% | 88.08% | +| cohere_english_v3 | 1 | 71.16 | 103.80 | 83.87 | 84.58 | 18.87 | 12.40 | 69.31% | 78.91% | +| cohere_english_v3 | 2 | 13.81 | 29.76 | 3.95 | 37.98 | 8.05 | 33.08 | 80.45% | 85.30% | +| cohere_english_v3 | 4 | 8.25 | 18.35 | 3.96 | 43.80 | 14.80 | 33.16 | 79.38% | 80.36% | +| e5_large_v2 | 2 | 14.44 | 29.67 | 3.93 | 38.00 | 8.11 | 32.76 | 78.77% | 83.95% | + +All four symmetric modes produced identical recall for each dataset/bit-depth case; both asymmetric modes also agreed. Kernel tests independently compare scores and arbitrary padding, rather than relying on recall agreement alone. Symmetric and asymmetric recall are expected to differ because symmetric scoring quantizes both operands. All widths here use the same 32X byte budget: changing bits changes projected dimensionality. These 4-bit results are not a 16X comparison. + +Scalar block throughput can be lower than scalar single throughput; block layout is intended to benefit SIMD. No claim is made that every scalar fallback is faster than every asymmetric SIMD path. + + +# Final graph comparison + +Means of two independent builds, with branch order reversed on the second pass. Baseline is ash-dev `67f532a2`, including its legacy incomplete symmetric construction scorer. Candidate uses corrected symmetric node-to-node scoring and existing asymmetric raw-vector insertion/query scoring. No deduplication changes. + +32X ASH, 2 bits, C=1, ITQ, M=64, efConstruction=200, NVQ reranking, k=10, 1× overquery, search pruning disabled, existing adaptive gamma=0.01. Build seconds include provider setup plus the existing graph-build/write timer; compressor training and initial vector encoding are excluded. Query throughput uses identical benchmark-only code for both branches, at least one second per sample, three samples per build. + +## 100k + +| Dataset | Build seconds: old / new | QPS: old / new | Visited: old / new | Recall@10 %: old / new | +|---|---:|---:|---:|---:| +| ada002-100k | 11.77 / 14.58 | 132,120 / 133,631 | 558.5 / 544.3 | 83.897 / 86.375 | +| cohere-english-v3-100k | 11.82 / 12.69 | 222,249 / 226,772 | 649.8 / 644.1 | 81.538 / 82.798 | +| e5-base-v2-100k | 10.11 / 10.89 | 315,885 / 367,709 | 648.2 / 644.9 | 77.808 / 78.931 | +| e5-large-v2-100k | 12.17 / 12.37 | 216,955 / 235,406 | 658.1 / 666.1 | 78.497 / 81.195 | +| e5-small-v2-100k | 8.27 / 9.14 | 585,295 / 561,934 | 719.2 / 691.5 | 59.259 / 60.414 | +| gecko-100k | 9.30 / 11.35 | 362,181 / 350,590 | 660.3 / 673.5 | 78.487 / 80.398 | +| openai-v3-large-1536-100k | 14.67 / 13.87 | 128,633 / 142,892 | 516.4 / 500.9 | 86.616 / 87.943 | +| openai-v3-large-3072-100k | 17.54 / 21.31 | 45,233 / 46,530 | 508.4 / 498.7 | 89.584 / 91.139 | +| openai-v3-small-1536-100k | 12.32 / 14.03 | 133,045 / 149,298 | 541.1 / 533.6 | 85.122 / 86.738 | + +## 1M + +| Dataset | Build seconds: old / new | QPS: old / new | Visited: old / new | Recall@10 %: old / new | +|---|---:|---:|---:|---:| +| ada002-1M | 128.45 / 157.90 | 122,692 / 134,936 | 773.3 / 745.4 | 79.273 / 82.669 | +| cap-1M | 127.57 / 150.08 | 125,609 / 140,925 | 762.7 / 751.7 | 73.302 / 78.889 | +| cohere-english-v3-1M | 189.94 / 121.57 | 208,169 / 214,930 | 888.3 / 889.6 | 64.447 / 69.632 | + +## Build-time ranges across repetitions + +| Dataset | Old seconds, range | New seconds, range | +|---|---:|---:| +| ada002-100k | 11.56–11.98 | 14.28–14.89 | +| ada002-1M | 128.02–128.88 | 153.57–162.23 | +| cap-1M | 125.08–130.06 | 144.10–156.05 | +| cohere-english-v3-100k | 11.71–11.92 | 12.45–12.94 | +| cohere-english-v3-1M | 135.08–244.79 | 116.87–126.27 | +| e5-base-v2-100k | 10.06–10.16 | 10.35–11.44 | +| e5-large-v2-100k | 11.38–12.97 | 12.24–12.49 | +| e5-small-v2-100k | 8.09–8.45 | 8.62–9.66 | +| gecko-100k | 8.35–10.25 | 11.32–11.38 | +| openai-v3-large-1536-100k | 13.16–16.19 | 13.84–13.90 | +| openai-v3-large-3072-100k | 16.66–18.41 | 21.09–21.52 | +| openai-v3-small-1536-100k | 10.99–13.65 | 13.96–14.10 | + +Cohere-1M baseline build time varies substantially (135–245 seconds); the mean improvement should not be interpreted as a stable fixed speedup. Across the nine 100k datasets, mean query QPS decreases by about 4.0% on E5-small and 3.2% on Gecko; it improves on the other seven. Recall increases on all datasets. Two builds do not establish statistical equivalence for small performance or recall differences. + + +# Correct symmetric versus asymmetric construction + +Two builds per mode. The asymmetric control projects each raw source vector once, caches its existing immutable asymmetric scorer, and uses it for node-ID construction comparisons. The symmetric mode starts from encoded nodes and uses cached header corrections. Both use asymmetric raw-vector insertion and query scoring. Setup is included below. This control avoids attributing repeated projection work to pair-kernel speed. It is a benchmark adapter, not a proposed production cache. + +| Dataset | Build seconds: asymmetric / symmetric | Symmetric speedup | Recall@10 %: asymmetric / symmetric | QPS: asymmetric / symmetric | Visited: asymmetric / symmetric | +|---|---:|---:|---:|---:|---:| +| ada002-100k | 19.70 / 14.58 | 1.35× | 86.243 / 86.375 | 143,930 / 133,631 | 546.8 / 544.3 | +| ada002-1M | 231.69 / 157.90 | 1.47× | 82.462 / 82.669 | 108,328 / 134,936 | 751.9 / 745.4 | +| cap-1M | 213.31 / 150.08 | 1.42× | 78.865 / 78.889 | 140,263 / 140,925 | 754.2 / 751.7 | +| cohere-english-v3-100k | 15.41 / 12.69 | 1.21× | 82.754 / 82.798 | 229,465 / 226,772 | 655.7 / 644.1 | +| cohere-english-v3-1M | 233.05 / 121.57 | 1.92× | 69.514 / 69.632 | 231,304 / 214,930 | 887.0 / 889.6 | +| e5-large-v2-100k | 15.70 / 12.37 | 1.27× | 81.089 / 81.195 | 221,213 / 235,406 | 669.0 / 666.1 | + +Mean symmetric construction recall is slightly higher in every case (approximately +0.02 to +0.21 percentage points); two builds are insufficient to claim all of those small differences are significant. Query QPS varies with the built graph and JVM: all modes retain the same asymmetric query scorer. In particular, cached-asymmetric Ada002-1M measured roughly 85k and 132k QPS across its two builds, and cached-asymmetric Cohere-1M build time varied from roughly 155 to 308 seconds. Reported means do not remove that uncertainty. + +At 1M, the asymmetric control retains approximately 3.7–5.6 GiB of float query-state arrays, plus object/array overhead. The symmetric scorer retains about 3.8 MiB of per-node header corrections at C=1, plus negligible centroid state. Encoded vectors and other shared index memory are excluded from both figures. + + +# Implementation and provenance + +Final implementation commit: `1707d916`. The final graph/scorer measurements use kernel source `1338d46f` with the identical `f98fcf92` throughput benchmark overlay on both branches. The subsequent API commit delegates the previously unimplemented `ASHVectors.diversityFunctionFor` to the tested scorer; the measured graph provider already called that scorer directly. No measured kernel was changed afterward. + +The final API regression run passed TestASHSymmetricScorer (7 tests), TestASHGraphSearch (2), TestASHScoringDispatch (4), and TestASHStandaloneVsFusedScoring (3). Earlier scalar/SIMD and forced 256-bit validation is described in [implementation notes](implementation-notes.md). Optional native gtest/Google Benchmark dependencies were unavailable; no native C++ test result is claimed. + +Source references: + +- Final paper: supplied `2606.07870v2 (3).pdf`, Appendix B (C=1 symmetric derivation). +- C++ reference: `https://github.com/tlwillke/ivf-ash-fastscan`, commit `56d78d0ff3aaaa61a02b94c6f49a97789706a717`; packed lookup and accumulation patterns in `ash_fastscan_avx512.cpp`, header/projection handling in `ash_projection_code.h` and `ash_query.cpp`, LUT setup in `ash_lut.cpp`. +- Java: `ASHSymmetricScorer` (encoded pair scoring and prepared factories), `PanamaVectorUtilSupport` (specialized SIMD kernels), `ASHVectors.diversityFunctionFor` (public API), `AsymmetricHashing` (unsigned landmark handling), `DistancesASH` (isolated timing and score oracle), `ThroughputBenchmark` (sustained samples). + +## Commits + +| Commit | Change | +|---|---| +| baa1027c | Complete symmetric ASH construction scoring for packed codes | +| f8e64213 | Benchmark prepared symmetric ASH queries in DistancesASH | +| 992f7c6c | Compare ASH construction scoring policies and setup costs | +| cc5d0d1b | Optimize symmetric ASH scoring from encoded vectors | +| 62bc6f6d | Measure ASH scoring with correct query setup and isolated modes | +| 1338d46f | Prevent boxed SIMD allocations during ASH graph pruning | +| f98fcf92 | Measure graph throughput over sustained query batches | +| 1707d916 | Expose symmetric ASH scoring through the diversity API | + +## Data and reproduction + +- [Graph observations, 48 builds](final-graph-results.csv) +- [Correct asymmetric construction controls, 12 builds](final-asymmetric-controls.csv) +- [Scorer measurements, 30 isolated cases](final-micro-results.csv) +- [Kernel experiments](kernel-experiments.md) +- [Implementation notes](implementation-notes.md) + +Remote evidence, frozen JARs, raw logs, profiles, runner configuration and drivers remain under `/mnt/raid10/jvector-bench/ted_willke/ash-symmetric-study`. Local report data is also committed under `docs/ash-symmetric-study`. Existing user YAML files were preserved. No merge or push was performed. + +Frozen artifact SHA-256: + +```text +22212ed3842676dec1b32558888c178601a5d94ca5a989d828c731f8e7b8762e /mnt/raid10/jvector-bench/ted_willke/ash-symmetric-study/candidate-final.jar +b19993e9a43a321428e613277afba3de15a9c5cc1bf1085d6579267d8e296f77 /mnt/raid10/jvector-bench/ted_willke/ash-symmetric-study/baseline.jar +40aede39e95da39ed82b6b373acd90554561a7edd5118a45912ae159dc9a44e2 /mnt/raid10/jvector-bench/ted_willke/ash-symmetric-study/throughput-overlay.jar +``` From c0b4dfba0bb54d2399c1f53bdb5483f1d8e330ea Mon Sep 17 00:00:00 2001 From: Ted Willke Date: Sat, 19 Sep 2026 16:16:34 +0000 Subject: [PATCH 079/243] Control graph benchmark query concurrency and verify result parity --- .../benchmarks/ThroughputBenchmark.java | 42 +++++++++++++++++-- 1 file changed, 38 insertions(+), 4 deletions(-) diff --git a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/benchmarks/ThroughputBenchmark.java b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/benchmarks/ThroughputBenchmark.java index bbf777ef8..9f04e0c21 100644 --- a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/benchmarks/ThroughputBenchmark.java +++ b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/benchmarks/ThroughputBenchmark.java @@ -135,6 +135,42 @@ public List runBenchmark( boolean usePruning, int queryRuns) { + int threads = Integer.getInteger("jvector.bench.queryThreads", 0); + if (threads < 0) throw new IllegalArgumentException("queryThreads must be nonnegative"); + System.out.printf("QUERY_CONCURRENCY threads=%s%n", threads == 0 ? "common-pool" : threads); + if (threads > 1) { + var pool = new java.util.concurrent.ForkJoinPool(threads); + try { + return pool.submit(() -> runConfiguredBenchmark(cs, topK, rerankK, usePruning, queryRuns)).join(); + } finally { + pool.shutdown(); + } + } + return runConfiguredBenchmark(cs, topK, rerankK, usePruning, queryRuns); + } + + private static IntStream queryStream(int count) { + IntStream stream = IntStream.range(0, count); + return Integer.getInteger("jvector.bench.queryThreads", 0) == 1 ? stream : stream.parallel(); + } + + private List runConfiguredBenchmark(ConfiguredSystem cs, int topK, int rerankK, + boolean usePruning, int queryRuns) { + if (Boolean.getBoolean("jvector.bench.verifyConcurrentResults")) { + int count = cs.getDataSet().getQueryVectors().size(); + SearchResult[] serial = new SearchResult[count]; + for (int i = 0; i < count; i++) + serial[i] = QueryExecutor.executeQuery(cs, topK, rerankK, usePruning, i); + queryStream(count).forEach(i -> { + SearchResult actual = QueryExecutor.executeQuery(cs, topK, rerankK, usePruning, i); + if (!Arrays.equals(serial[i].getNodes(), actual.getNodes()) + || serial[i].getVisitedCount() != actual.getVisitedCount()) + throw new IllegalStateException("Concurrent query mismatch at " + i); + }); + System.out.printf("CONCURRENCY_VALIDATION queries=%d topK=%d rerankK=%d status=PASS%n", + count, topK, rerankK); + } + if (!(computeAvgQps || computeMedianQps || computeMaxQps)) { throw new RuntimeException("At least one metric must be displayed"); } @@ -149,8 +185,7 @@ public List runBenchmark( String warmupPhase = "Warmup-" + warmupRun; warmupQps[warmupRun] = diagnostics.monitorPhaseWithQueryTiming(warmupPhase, (recorder) -> { - IntStream.range(0, totalQueries) - .parallel() + queryStream(totalQueries) .forEach(k -> { long queryStart = System.nanoTime(); @@ -215,8 +250,7 @@ public List runBenchmark( long completedQueries = 0; do { - IntStream.range(0, totalQueries) - .parallel() + queryStream(totalQueries) .forEach(i -> { long queryStart = System.nanoTime(); From cf8eaa83cc1e1528d557aad8196297fbc099f570 Mon Sep 17 00:00:00 2001 From: Ted Willke Date: Sat, 19 Sep 2026 21:44:08 +0000 Subject: [PATCH 080/243] Document completed ASH versus PQ Figure 9 graph study --- docs/figure9-graph-study/branch-audit.md | 36 +++ .../cohere-investigation.md | 39 ++++ docs/figure9-graph-study/gc-summary.json | 30 +++ .../figure9-graph-study/graph-concurrency.png | Bin 0 -> 287714 bytes .../graph-construction.png | Bin 0 -> 110420 bytes docs/figure9-graph-study/graph-qps-recall.png | Bin 0 -> 198585 bytes docs/figure9-graph-study/jars.sha256 | 4 + docs/figure9-graph-study/methodology.md | 35 +++ docs/figure9-graph-study/paper-panel-0.png | Bin 0 -> 328985 bytes docs/figure9-graph-study/paper-panel-1.png | Bin 0 -> 309213 bytes docs/figure9-graph-study/paper-panel-2.png | Bin 0 -> 327966 bytes .../published-ivf-reference.png | Bin 0 -> 319379 bytes docs/figure9-graph-study/quality-results.csv | 9 + docs/figure9-graph-study/repeat-summary.json | 37 +++ .../report-scripts/data.py | 17 ++ .../report-scripts/finalize.py | 46 ++++ .../report-scripts/plot.py | 71 ++++++ .../report-scripts/tables.py | 50 ++++ docs/figure9-graph-study/report.md | 213 ++++++++++++++++++ docs/figure9-graph-study/reported-results.csv | 97 ++++++++ docs/figure9-graph-study/results.csv | 97 ++++++++ docs/figure9-graph-study/system-summary.json | 162 +++++++++++++ docs/figure9-graph-study/tables.md | 81 +++++++ docs/figure9-graph-study/timing-validation.md | 14 ++ 24 files changed, 1038 insertions(+) create mode 100644 docs/figure9-graph-study/branch-audit.md create mode 100644 docs/figure9-graph-study/cohere-investigation.md create mode 100644 docs/figure9-graph-study/gc-summary.json create mode 100644 docs/figure9-graph-study/graph-concurrency.png create mode 100644 docs/figure9-graph-study/graph-construction.png create mode 100644 docs/figure9-graph-study/graph-qps-recall.png create mode 100644 docs/figure9-graph-study/jars.sha256 create mode 100644 docs/figure9-graph-study/methodology.md create mode 100644 docs/figure9-graph-study/paper-panel-0.png create mode 100644 docs/figure9-graph-study/paper-panel-1.png create mode 100644 docs/figure9-graph-study/paper-panel-2.png create mode 100644 docs/figure9-graph-study/published-ivf-reference.png create mode 100644 docs/figure9-graph-study/quality-results.csv create mode 100644 docs/figure9-graph-study/repeat-summary.json create mode 100644 docs/figure9-graph-study/report-scripts/data.py create mode 100644 docs/figure9-graph-study/report-scripts/finalize.py create mode 100644 docs/figure9-graph-study/report-scripts/plot.py create mode 100644 docs/figure9-graph-study/report-scripts/tables.py create mode 100644 docs/figure9-graph-study/report.md create mode 100644 docs/figure9-graph-study/reported-results.csv create mode 100644 docs/figure9-graph-study/results.csv create mode 100644 docs/figure9-graph-study/system-summary.json create mode 100644 docs/figure9-graph-study/tables.md create mode 100644 docs/figure9-graph-study/timing-validation.md diff --git a/docs/figure9-graph-study/branch-audit.md b/docs/figure9-graph-study/branch-audit.md new file mode 100644 index 000000000..2638eb546 --- /dev/null +++ b/docs/figure9-graph-study/branch-audit.md @@ -0,0 +1,36 @@ +# ASH integration audit — 19 September 2026 + +The authoritative checkout is `/home/ted_willke/jvector-ash` on the benchmark host. `ash-dev` was fast-forwarded from `67f532a2` to `638edc37`. No production code differs from the frozen ASH JAR used in this study. No push was performed. Existing untracked `pr522-bench-*.yml` files were preserved. + +| Branch/work | Audited tip | Decision | +|---|---|---| +| ash-symmetric-scorer | 638edc37 | Integrated all 10 commits: completed symmetric math and kernels, boxed-SIMD allocation fix, diversity API, microbenchmarks, sustained query timing, concurrency checks and report. | +| ash-adaptive-integration | 479a61e5 | Already an ancestor of ash-dev. Default gamma 0.01 is included through 28d9620a. | +| ash-training-optimization | 334185a5 | Already included: parallel encoding, divide-and-conquer SVD; subsequent 20D/early-stop default b2555f84 also included. | +| ash-query-performance | 21d2d211 | Already included; subsequent projection-tail fix 2715f71f, shared query setup b1fe8846 and bulk writes 67f532a2 included. | +| ash-neighbor-uniqueness | 638edc37 | Separate worktree, same baseline, no additional changes at audit time. Remains separate. | +| ash-unique-neighbors-integration / ash-unique-neighbors-score-study | 727ec140 | Excluded: unfinished uniqueness and compatibility/version-policy work, including version 8. | +| fix-neighbor-duplicates | 112ec68e | Excluded: PR522 node-ID ordering and related cleanup/format changes. | +| origin/ash-dev-rdevulap | 75fdd2a5 | Not merged: older native 1-bit masked-add/AVX512 tuning; not a validated replacement for current packed multibit Panama kernels. | +| origin/nb-ash-dev | 320f33db | Not merged: alternative multibit driver architecture; current scorer already implements the supported multibit math/kernels. Requires its own evaluation, not an integration cherry-pick. | +| pruning-fix | 6cfc6055 | Not merged: earlier L0 pruning policy; current ash-dev deliberately disables edge pruning. | +| origin/disable-pruning | 11d876cc | Equivalent intended behavior is already present in ash-dev's usePruning API; no old-branch replay. | +| origin/adaptive-term-feature | 2eda0678 | Original design superseded by ASH-specific integrated stopping rules; no merge. | +| origin/bq-adaptive | 63db574f | BQ experiment, not completed ASH work. | +| origin/main | 1a86718b | Frozen production PQ comparator. New file-versioning policy is not pulled into ASH during the separate compatibility discussion. | + +## Byte-write fix verification + +`67f532a2` replaces the per-byte loop in `FusedASH.writeBytes` with `out.write(bytes, 0, length)`. It is an ancestor of both the original benchmark commit and integrated ash-dev. `javap -p -c` of the actual frozen `study.jar` confirms one `IndexWriter.write:([BII)V` invocation and no per-byte loop. The upstream general vector bulk-write fix `c059e869` is also an ancestor of both ASH and latest-main PQ. + +The earlier statement about an absent cleanup fix referred to `804a1d1c` on the deduplication branch, not the byte-write fix. A small independently reproduced NodeArray duplicate-at-capacity patch was set aside after the user clarified the intended fix. It is **not applied**; the source is clean apart from this documentation. The preserved patch is outside the repository at `ash-integration-cleanup-unapplied.patch`. + +## Stashes + +`preserve-asymmetric-score-study` contains an old diagnostic mode superseded by the completed construction-scoring experiment. `preserve-DistancesPQ-before-PR522` contains additional full-scan PQ-mode instrumentation whose validation was paused; it remains preserved and is not treated as finished production work. Neither stash was applied or deleted. + +## Validation + +JDK23 Maven package completed successfully on the final integrated source. Seven focused classes passed in scalar and SIMD executions, 33 tests per execution: TestNodeArray, TestNeighbors, GraphIndexBuilderTest, TestASHSymmetricScorer, TestASHGraphSearch, TestASHScoringDispatch and TestASHStandaloneVsFusedScoring. Authoritative log: `/mnt/raid10/jvector-bench/ted_willke/ash-dev-integrated-tests.log`. + +Completed benchmark observations and indexes remain valid: the integration moved a branch pointer to the already-tested source. The interrupted CAP 2-bit ASH second attempt is archived separately and is rebuilt for a complete measurement set. Cohere is prioritized on resume, with GC logs, system sampling and short JFR profiles; those profiled timings are identified in the final analysis. diff --git a/docs/figure9-graph-study/cohere-investigation.md b/docs/figure9-graph-study/cohere-investigation.md new file mode 100644 index 000000000..7f0c85924 --- /dev/null +++ b/docs/figure9-graph-study/cohere-investigation.md @@ -0,0 +1,39 @@ +## Cohere construction investigation + +The first 4-bit Cohere runs were anomalous. Their timings are retained in the primary tables and ranges; they are not silently replaced with faster repeats. The integrated ash-dev code is identical to the frozen ASH production code used for those runs. + +| Cohere 4-bit run | ASH graph build/write | PQ graph build/write | +|---|---:|---:| +| Primary repetition 1, no JFR | 658.45 s | 1,224.67 s | +| Primary repetition 2, short JFR capture | 158.05 s | 320.37 s | +| Additional control, no startup JFR | 203.34 s | 350.11 s | + +ASH's first/second insertion times were 511.91/89.53 seconds; cleanup 82.69/5.49 seconds; flush 63.85/63.03 seconds. This localizes that anomaly to graph computation rather than the bulk byte-write fix or SSD flush throughput. The extra control's insertion/cleanup/flush times were 105.59/5.58/92.17 seconds. + +### What the evidence establishes + +- The byte-write fix is present in the actual frozen JAR. No production-code change caused the faster second repetition. +- Source dispatch and live profiles use the intended SIMD kernels for d=512. There is no dimension-based Java dispatch to a scalar ASH kernel. This does not by itself guarantee that every Vector API operation is optimally compiled. +- During the monitored Cohere runs there was ample available memory, no swap, and zero sampled memory-pressure PSI. Each build used a fresh JVM. The pre-resume host had 340 GiB available and no benchmark JVM running. These observations cannot reconstruct the complete system state of the first repetition. +- Full-JVM GC pause totals in the monitored runs were 0.93–2.48 seconds. Large allocation volumes exist, particularly in the 2-bit PQ run, but long stop-the-world pauses do not explain the monitored build costs. JFR shows boxed Vector API objects and array allocation, including NVQ's nvqLoss path. The sampled allocation weights are estimates, not exact per-method allocation accounting. +- The active PQ construction implementation is ImmutablePQVectors' cached centroid-pair lookup. In its 4-bit profile, assembleAndSumPQ_512 was the top frame in 54.1% of execution samples, ThreadLocal.get in 25.2%, and assembleAndSum512 in 14.2%. ASH's 4-bit profile was concentrated in packed asymmetric projection scoring and symmetric packed comparisons. These are sampled CPU-stack proportions, not an exact wall-time decomposition. +- Hardware cycles/instructions/cache counters are not exposed by this VM's perf interface. Therefore these measurements do not distinguish execution throughput from cache/memory stalls precisely. Historical sar files were stale and did not cover the first runs. + +### Diagnostic comparison counts + +A separate diagnostic class counted actual pair evaluations while preserving the pruning decisions and score arithmetic. It was not added to production code. Cohere-100k is a scale control, not a substitute for measuring the original 1M execution history. + +| Method / budget | Prune calls | Candidate checks | Pair comparisons | Diagnostic build/write | +|---|---:|---:|---:|---:| +| ASH 2-bit | 661,994 | 87,840,870 | 1,503,229,262 | 13.27 s | +| ASH 4-bit | 667,048 | 91,887,956 | 1,504,819,172 | 16.91 s | +| PQ matching 2-bit | 669,293 | 82,090,179 | 1,535,016,576 | 16.10 s | +| PQ matching 4-bit | 673,297 | 87,645,603 | 1,539,002,008 | 36.35 s | + +Pair counts barely change with bit budget in this control. It supports the expected increase in cost per score, rather than a multiplicative increase in pair count at this scale. It does not identify the cause of the historical 1M outlier. + +One initial diagnostic completed its build but failed in post-build reporting because an empty throughput key still enabled the benchmark. The configuration was corrected by omitting that key and the four diagnostics were rerun successfully. The failed attempt remains archived separately and is excluded from the table above. + +### Remaining limitation + +The first 1M outlier has not yet been causally explained. A missing byte-write fix and a fixed dimensionality fallback are not supported by the evidence. Runtime compilation and execution-state effects remain hypotheses; the original runs lacked the relevant GC/JIT/system traces. The additional controls completed without reproducing the large slowdown. Their full-JVM GC pauses totaled 0.676 seconds for ASH and 1.651 seconds for PQ; available memory stayed above 321/318 GiB, respectively, with zero sampled memory-pressure PSI. Compilation logs recorded class-loading/retry events, but those events alone establish neither a persistent kernel compilation failure nor the cause of the first outlier. No speculative production fix was applied. Do not use the outlying Cohere mean as a precise capacity-planning estimate. diff --git a/docs/figure9-graph-study/gc-summary.json b/docs/figure9-graph-study/gc-summary.json new file mode 100644 index 000000000..8ae31d930 --- /dev/null +++ b/docs/figure9-graph-study/gc-summary.json @@ -0,0 +1,30 @@ +[ + { + "case": "cohere-english-v3-1M-b2-pq-r2", + "pauses": 183, + "pause_s": 2.4758060000000004, + "max_pause_ms": 75.863, + "reclaimed_GiB": 1423.6875 + }, + { + "case": "cohere-english-v3-1M-b2-ash-r2", + "pauses": 48, + "pause_s": 1.017786, + "max_pause_ms": 80.523, + "reclaimed_GiB": 109.443359375 + }, + { + "case": "cohere-english-v3-1M-b4-pq-r2", + "pauses": 60, + "pause_s": 0.9321240000000003, + "max_pause_ms": 72.282, + "reclaimed_GiB": 166.974609375 + }, + { + "case": "cohere-english-v3-1M-b4-ash-r2", + "pauses": 51, + "pause_s": 1.042585, + "max_pause_ms": 92.298, + "reclaimed_GiB": 146.6640625 + } +] \ No newline at end of file diff --git a/docs/figure9-graph-study/graph-concurrency.png b/docs/figure9-graph-study/graph-concurrency.png new file mode 100644 index 0000000000000000000000000000000000000000..cf3eb9e4e1f92fa7a1cb6f475754577d01951fd2 GIT binary patch literal 287714 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 eKbCUD)yOTXly&JV}@aHXD0{GcS$o>KnGl zC7ORawZuxbzlEI?X{w-}j~@fUH>?O)`*2R1n1lj>(nDaoUq}N44AbQS;o;UxQb9f) z>v9i7d!=^peJ(Zq=Be?xx0NNj$XBBNN?3(#{L(n1#8flMEt0Qm9e(vmXcTC@xqbRX zM*}5O7JjZSdoH4Mb+{kry$(QY6h7O<=gweRzWvwzND%Rz|2a~mVRiIUbF@`kN%Aez zg6HgfAhw@R*y+zZ4>&6{6W)a4d=Q=PK19b`ls8+Sm_@y2(Mt!FQq}*v8TfmI3BNKf>bcBVo#Z|<5ptRreF~`cJ`WXp;_RDlHc3g z=Zrdl5vfAxa52z{WlhbPSD7g37sKu`UnSDNm4`A*gQT+V1L*~iUsk>bRO_187j)2ooq~M~5qbWQCXc+a9c-N`s z+8gA3%qqN#G*-m*c_@`M;8F2hw;ne1m3-@Xs5tu6g0@CBl9c1WIiOu(U`@{dp61kW zJ;QM#kvY1>S@uF;-7nNaKBg;3MSU1_Kz<0G(c5|xfpe>)Ra-@(^!K%J&tyj^s18N^ z6E)@jM8+yw{X8(YR#U@Pe6B>VN6;dJ5j2Fv-k3T&vJl`je|=+$6__%uzxKsDT`DCt ze$Q%-DmA8(rxzn&(;d0TGoX2olz$R_dTdiSAT#4rq80G#`i<}?sAx`)k&+5km?{QH z($%*1{C;>=Pth>XLXqb)fSt0(=1{xr(wXnL4A=q)Rn3DO7LRu7=sH1UU~7&7P{H!- z+yYuJE0SvQ(iNAD^H*7vhs+lV6^Yz=DTwT1l|hO;pFZE z%o70g(@ZJ1bgT-er|$t$sQLMH0iP@DdnRh*RW@9&L1~X#GtmAs4k+TTm1;gFoQdIu zGMcO&sneO*Cuy#iPo4-GFyAAwJV^yo4H&w4vIy^^#h~rWn64U=Ry;z&g{OKo724Fa z0Ncx<0W@n+8!dp=EnNhFwrA28HoDqVO^Dm&)Y`AFHGy+R)qWTe8$^SfA$FhCVY7ne z@GCaneK+1}c-`K~5ky#8?%8&~d++K~zVqjo#^Pq<0K3y$W%|Ha%DIQ%VX2CU{WE3> z&f{0M$7jiwP~o)V+cHyEGxc(4T!>uc`kN#&E`v63B3X}C+4|G$y`hg}O5aJo>S9RF z>M= z`|TZ9<~xrX?MKJch*UG(QhBqbZ;YQEt#MjPy$~SW^6MidB&W2v(A8+Dxyv`ebGrO4 z`Wam-T|teTWl+Q^An3ikzo4>zMTM}?`X8YVu87?sQ3h_)6`!9^^|atONG`%h)_#gI zl;C&a{Vi#Ue3S+E+WEa*kH??#$c^^k^prH`n4b5Z^p7L);#G{e*)N|s zMC>}hqrBBPedCc2+FQf@%!_HLP(3QS-7=9FFyA_5fH=#sQiS-6H##Nsr%`a^oKt3O z)`iOeDXQA|1`yYM5$0l%{Y-Jj5mzJWmR;&ylz!I0H5y=(ZPD$0{^?3=tcLfjxPY+3 zhi1$3*;iapQ*LWycpM>R z!2V6RFJ-w3lz!GBlmcFy#wSf=G$ogA1ELd~ZE7id;x$_AbI0_uV?l#){-+8PGl7bI z4`^rsZUH~MiOQx7zxTJYRLaaEquDBZ$u1Q*!$W8JO++1!EOxgv2a=YpC+g0RgiyKB?mecbo2;!y6pVGe4^&Bd<@#*iYkmCrJeJ{GH{k|7^6|^0gsvlwAT0>)%)f%2 zHv&%OP0=2~Z+3Qe%4)$oa{B#&YAHH~LecaWcOQI=0Bxo&19s4%?^bZ=5S~6nh}G`U z1JIE0A6nAVcByZa_GPSHnA93@RxM)br~LT3vj5huaemZdj`rE5N8Lce@yuo3GeiL1 zqX1r{qRKZvkpDt1YBa2x;o>7g$=cgn-Rpk{6F1B5y`)kH;-$o&(o1X0J9OAeOl@qZ z!}ALRzo)CBR>@*G+*z3JAp*Xe$Vy_18(vgP-@Qk+N_1rJeo+&=tmh*ctg@zaw|{#J z+vq7jX2B2-7Up?u+na3KfEYibCRX3|)^~1-6LxofbGyC~oZcLE`U_V80JnK$BCBY) z?;sE7tD%h@G#03wCZDpF%Gs<7A@M_^u*wNssWU>j_4qg1rR#D$sj(mVe@Q9Mh zj@RyZ%2{BU(^y(BsOcw5;E-d4Z=@oN!)C z14k_!k5}$=ytO=io)~`*x!d?1Nmu!JdhvmG2O$Pi%d;OfVoNgtIW)b=E~ixz1$ZBy z4=xkJ0~0D6`{~n}O3x)_nIVX9zNZC%$8WL%J$(H=*L>%P=1=LUHV|J`o8Omvu0C67 zTDTLdwY1LGO30GBo~8cDV8M%c*y5Se@iCACEFPG6#nWrJ_ddE&VyN5y%|`xm+$LQ? zO|R@Z5wpKzR00*n_nz;@c8*(~^_)hso-B5oLF=xHsY(WT96-kur|20#CHFnb{T7k7 z&eE0-o70h{9X|KOQj+{@84ND^I2ER;IM{gI`|X>qs5Od21{E}WSm%KCa{7tY>#3LO zAB+sP5UcpiG)%Clmb0q#lUeM#<4_(4#?kMQ@fEuRV7dHf_vczUGOP|gJd&q^0!M1Z zA3Av{TRXfPtQTw^EA**Q3Zyjv3PM2Pk&sdjpvjq32O??_9>>?II2S(J78wsU1{iwf z%HkQ6XVN;2KMQ1A+#T3h%YJhACUxLQv9=;mmi{{MDKR=jKtzoifpiFNPabaWLi`r~ zLPwcR=g3YDAhdf-LW728dlHpbjtW`I-EK~u%>#f?m`?}93JU4iRClv=B4W_Ka7Ca3 zGQ1w40DL<6jc(n}I_mJ_YuCD@f}Ijtd^N2Gf0)zTx{b_8S^YVt={x1HcbA+TS9mje z%MKB9J6DFl^Dcm2v!C3)yXkvme!3gfqrDJGk3ApE^-xe*mulgI()$$3M9)v2X>$K& z^gG=1y2W#Y<3sNWAs&!A2*xc?_;lSK_D8VE_#oV653*j8bF)5R=4?_-$SZgsk@}DU z#!k4(v$p(=e8|Q1)=~sK&`AUckxdf5B(iWBe_lnwh1{_&1=3}K2q64#30pPsEjA2) zVNZ?%2+dxiukW}7OH?iEvDF=t*NaNZx}S0&Z9&2SB#nirI`6ix;-D1`*7&vE2@+`> zg6ww6jOj~khbaQT^c~8>(VYCBsvX3Zrhy6ud9pJZ5z9>-Pm^0G8kU zW81bG+jgTJG`4N)uC(7d_ufB{XJyZ|<{0DEklGor4vTv4EGpqr^r(}iCFSh7U0b@G zqE{WsZ`U(_l)Dmie?ap_g5+g#Z7OGe+*Q#&PBAZ!2B?I`XLq!}6>%%E7cDjHnN`-x zYTb(^O8^gt>5HuQQx(kF^f0QRy!_2AVw!lOu~FY($f`nGKk4IX3Q=NTW2Bg9tp$~` zXn4#74x2~42beKlbn_`dcOvc2ZX&cn1{jwyT-snL{~lR%TG3V*bPJQ zyq_H#W`z#_yiswI!TsSf!CkhQy~A+~s7o`aA~f)H$v%D3xFfRwm&~U zZi5=#w?>$DfA;JJy~B3bmT39~G$`j5`}=kq#|_(awWcJdW6A`8eT2iaIVh9?*lP$7 zDJNYy+cd+N&T{eJyvm4*_s6<&0I}TgG=O#;LS~~RCh0!-0l8_OFAHEGJMtCs>t7J= zn%f&^0HXnL7~lR%CqCO&zMP`D9voxcKYj(kkvQ64TC@~oyh}-mil%@mNJljZJb`lk zCId3qY$JJ4ilMHffJ1szEuFH5KP0v>4zqr3^9OiFNr`~4^Bcg-*6aw- z`DuoH;Znm||nR4b9A-jJXvHko%k5sQ*(5L;BMijB!4LjSaD-Y_n#;gqa| z&!_$cNTs}H?(@0~Kk_~(e$|48j;aP6ndlE!O!&X?p{BM#9Aksl?rBhj*UZh%fzVtP zc2V%pjIw&D=@o@i3D8lX+xR&8vYvEE)Tzylw`&&;!$m7=O@|6d2NV_+P0^9pG>#+k z695wh!au%WGW>g?3jus-Fq%m}O$)A6MQFv0UEGM!e2v{Rl{}ky|EEc@`i+I@L!9TM zbf@V?f4J3D0-$>>28;yYBBDU^;8_iD4Uz!$v*Rk=(qdrXzjARYdwaM}|1IG6o|Ul_ zk=LVfHTLxM#pSDc?)HH_!{4S3j-l~e)7g&~ofjU!^8NH?Z+&yX?wkT~Z#!*3;`z|o zdQV%%ED-4mB+64&>-E8M0g3Z*)XI`6tfqRyBdb8*vf9dB$tj!Vf)W9+s`K*sE$ZUA z3GiOtZ#i>&Mg9|udrJaveWSlEv!|(#0jGu=zmvXS*wKSFMDS9OTDt~f$6Zcb^D(<3 zH9t>FWWmrBi~e>C9iXS6OSJovv3wa)%m3`Vctw`Nkw~2@CiRAhXw_S@Q%JqeGgY6u z1k{>+B!SChApQCCy^+S%?EB46W_?ez0^UdS5|h`D1u-$?C^)qK^d@ot3-VkD0bH-0 z$=c`O)(5lVJ*=$cV7Yd=IIfEF7)&JDsLCm^w3VqJQ?Snu67#Rm3g-<&1T=;nj!rUc)Ky;hdApRuzZhQlf)oI7 z;it4zH$%rDz#$xi#b!W$kOJH)>t)|l57*~re!<}Qul_@d1uNfBRw#lfKgYw7OVnA1 z7_mvhsNA8kc^#7O4{yz*o%bTj&Vq8q_iIRA1ooMjn+P8u?w^`eRD8(1V)UY=&bq$A z*;;M!+7P=gKch(vBtKvDE_ser-O3W19$@&e?`=x~lQ5_i6A%`zMo?4KQFGqjwnj(C zefsON8BN}NDbPwhWOEe{io@nhNri^{sv?-i1%s8&@7eseLPOztM-AyM_eQPJCRen-sT=7KGAA6Hf0Z>0d;rL+X(BkT+VW~@CSi)JTna_J3il4MTH3*gswIz)7xR7{*w7|i) zOOae?$%P=jKLXQ@q@5d(yauupn}D2WGbsWmygecB&h;e6Sjdw9Um@Wc1s!q`*()H8 z@L=yT`8qK_rao0rpr4RLje@>~Ni zm?umq%1?QW4erSk#pRKkHmezvZMp$pyI73S+k`+XP3 z`L{Nv%cNM5YK|Yv1yDZ*e{Y(!Tqpu6AT>ifW!Lua#YfluLRkru(G;sGg218iaiGRzBbg4+REK&f6TKz6I)*;?0RjtCr=-B!>Lr`hN!U_^=m zf|Oj-2Hvd6Z+4#m{a*0O z>UcNxjQPuwTCTeOW1j9*RwiY{o-qM|f!{al&JxT=xvBu0_>`MFxnUsL|6RfPayNOL zC^i*Z(^qu1RcWlO)l-n@f53=$-T-3p9T5S?Kys7^7LXPMdpKwCvhhd&5Y%94q2xRu zFRHhS2ZjiSA@A2?-WmW(o&A*<=Y1gobMB4v9BvyH?v;Hi6%I zASp|J7UgminEFr5wl(y$MuCxNMLmOPih!Q%x`f+#ET-O)=h5BiXwdHK1kT;11K$V{ z@l){McI!z(YT!^C2=gFV&Ad`84SSg8QZ!Kw8ho2q1`&`^u1-f?78tl$4TADszvC>S ze9iGwa(i*50yKHg3xAZHu^2ypASKDWX>~=xx>}Xb3E0xkxTS`akCZRtPtKM1(ov{OUo~YUAf9I6_RVGPTu>X5C zh?#aN+VB38gT&Yu`*-e{aNTt5ZfDb#ZMiJX&ywFzQY}{?#-g$qr%30O`$Ta5cDx70fCaB<*q5$xxV^;H5O&CeqB4R<;WAvJ3jt}MQNp>H9YE*ojR zZ(~o*GY$fWg5We%nIW3dS1f2mf=yuXzKIYE6cjY?WT*k?4_Vze#;4Tj7_S1U6a8;* zaObO~o$+#|$i?kcx*wX};8NwMRI1?TQE~2D$c1?<-*57V6|D#69#g5gkHfC!S=7pTnkMbzdzkcjBS?+N*p4l}9`uUX z#%z6zEBW$)WotPvG5?Pd((IGraBy$N5J<*Putz)E4Zp2romOCrck9jT{JARDZtcgB z=h}Nk$Zj^(TH(WATd|L;i4g*?n6xDY?JYv}&Mn#zZ5JWmZ48S6=8YQ;bB9w^$yY^g zES-jj$B`SCwq$ZrIsqGB*x_n1L#fH?4hHZAt`S8WTSvx`v-tu|q@Hvka}QhFNQW;? z1Q4XnXFLw1&H!p~$45R;!xD*OB5m{ql0aJSPyTe#!kO_n_dVkOW^8rSfrufXflKTG z;A7D*J_fzLgV|lae2{tm;802LK!a!R_PxdaI{)}ol^*RD3g0x*urU{H?o!9Ms|`@0 zNr`OFn*jql9OX1okm# z)r%dnMm(EqTbEcCe(A_Rrx)_Ss5$|wVgWJq*JNe|(=(mOuAG%CsB0E()gpAhubg!1 zwSzC-5WvQvTh8!=;ts(4J+g^Tqat2<_`@0FZ8zsQ`DCO5QX~$s zKox}0ajopL2KsN5nrLYB0A0k@VpP{q8)a})J@9N6&#m1sX(>+-W+ga8J1RaQ{!C48ZtY}OdERexHu6EOxtZlmVy$vtE4>m zdk8cL@cr~FZWak6=HoK^fH)rKAD;rqAHV<3Z!#W6s3>jWdwlv{TVM+MkR3}dI94E= zS4e6JEVd#fF&NEp2s-O>TI+oO;>@1FPf59#P+`8(%Jr7sv$egw4x~7l08L93|0^)L zJe|hxcYyhQ`~pq9^}eTDnVuZRyy9~@+Elao`NLGcIC|Y;bdxP&lkdk4hp@BX}S(MF?D7iu!B#|X%_ z@4tv+C3Ogy!Ta0Xb(?H`3EklLRtaJB2NLc+>UvF5K0f+ZYl|b~{(TTX-W=MYA#ojC zy^bdm^-GIsXs8veNO_)GM)dvRt9k=s5?k^NSv2b$S2pv2i6kr+aL9x`e$}1=uSNDx z{k=i5Sk(nHS)knYscbO0`^IP^q1SvhP8;=B;<@}LSTA*|fYQlO+0W(kvA#&!N^mG5 zmmCnNBqTZx_9f$o_q)hA_;e4Rl8S?T(0zo&|DLF^djGl=L!T{~#yp&YiQ55<-Wxqg z8)pFyM<{f-!>HXMST2Auos-xtT&_?Snt(rT*7-gVVsjuuWGq`KXSSey2|YTa&E^To zwuI`}KFP||g-mPaD|_x@n)DVdlziu`luz-pqse04U24jMW=j+nGO<}ge9;Uv0ZTy^ z!Ki?6(##gR2`-l%h*Xn4 zLm4?SJ%dm1EzY5T<~ScGhGMfRwKHCyMu|~HOaGA4h^hA7n|{K&sfWb9+1Q#2qBko76+^HO8 z<`J!!(Ki(!yhR0e?tY$}F1AUM_F+@|{B9L~n$=p%hVHQCGzw6oannMlb2vGsgqnJP z^P^fO#-S4^OhjbhFQ#q0Q9?(`AV^8oYRtV;;Da#oDiR8kTZdddo`Z+{B$E+VYTbce zR-EYut6Np|RjHJ+k^dT(bjPExj~a<(VVa>eb8-GY5t|RwoawF^nRL-KXY-w1_cMcjv-bcKkfG!5W;qjHVFi0e4&ot0J5`sfkfYIgV&>xNy6nID zA0(K@_7s(GsLc#W6vn`V^!_s)@utJP8Sc6aO0Pcja}mg2UNXK%6-elo&f4=u51 z!b)oduNVmB04X^hUR3!E0|1H#QmF(hF>_4h6-@|cM5Y%kuq})zT2gn=Ff|s^f|qj1 z!$AFya+WR|7r7;C=Mf6`aKGWm!`HyXOa%;Y#Y!!{-GVhRrCQ{-|NP?@akc8*9TL|a zX@gR22CZL82=dDP8)Ev(UDwu#$)HzdxC3P>JZuwPRNVe>d!D*4i_L6b$YuSOW)ojW z5D&;i#e`lvUW2vFO(r32mBls_1^S?FR?PBxYYDjJn~NI~Yl3SxYo%R^KLwqXZU;hy zy6-VOp6T{RVuP7~@p0kZ=={lBB}#(Io}ndPt2gF6KUnjDZ`V+7=5rOpWaM3^vAj||%HQ+2fYy^q*0r^5yc*;2K<)PKd`J7oskhb9h!f8r zhORE`mFU2Y)(3vpkB}&q7?62_Ume2*i>(Lf+RjziM5CelmePF}HZS3Jbm=L$VA?He z7c#z_u$I6mFMu^3q2c@Sv%*#PRlrF4m0j$Lt3sfx_v(Zje9td6WIhn_929890r7Yn z=v|@ZGZp^tj{kU}9pnDQNr<26d|z!NNYZt`+nklzL?b=lUFDVCKDINGGn&(oGS?}3 z0}+W2cUo@=hqH2I#6s_Q1T7VfCiW5!3lZUv91x6oNPO7JsI0V0)fHE+zM|v$tG@}u zF+S+9MC8y-QVEt;tAw&8F^rsd*^V8v@T_(8WfmtsQ_k zv>L=OhJgW@X}g(Kq}?HW>qIv569$*I2$@>M#a$QQNJ`Sj^FXQf?6AUrwp`0!_ug*r z;kM&=_k^GgZN^(IvgbK>qS1bxHGGll>7E~)0V{HO+*$Cjk>BOxSHu4<0B<}V!Cfm~ z8RcBaFS$8cCCYHa2Q#*g@+w_k$s@W#vwn_y(P!9sH{J3d!^HYRJW$JONeNYj$QSd_ z*s^Ypf2V#`11sq}=)AJ7VRHY@e$>s!J6%pRfGbB+wf2Z-klLFXVoPXpE~~g+K(dOK z#<$o+wdZ3ATOFr#O~(%LukH|Ub#!JV1|IrCcBQ1zYCt3QlnxB6rKUCK+_o+%`417A zxaLskv*4to;x6I6Q{ph`L?Wlw5OtG^m%MY>`w`b<4_BT{X#L}Uymx)ELk+d0vfNzd zH`pcrg{Z4Hc0E3m?lrJxR4*xVQ7K8Qj4$;0G4kmGmCcsgoX6)4DJ5~5{xQFk=^ zBt;bWo710_26GI$6KHq4(<V?07U=knmb3rMQx2*QmCnRrfvgW zgc5cKtM%~061Owsqmj|nXdo^AXnww%V|e=v9=h@P^5~v)*R`goTX!8HYe0Dl^wg6G zUSaF`+8*C>XFl&f=dL(jH=rM$md$mG$`^(F;AcynmyrRB>b0`6y^Dn$*=y+O!IRvp(46{`vNODndw!^8NThvm;#p zQ-AlJ(bW@=$vDY-^mS!W7hp%w=zzU|Z zV$|Of`0)#YlY;*TVpQ_Ahac}DX02Gga6Lg#YFyn2jql39r`-3>9;W=m9k@DP#|9c( z$o(&Z5rCJa!&nZ4b?foc=HAn2O=uU1-!p&P&BR~Eh9b)?9Q&!iEmts;J!4{*`)p`~ z*5YL*`H9m~ioJUk6nAjzG-o0Hy(wn4zY_LP+EpAn({6nxPohd-Tyx4~97EygdEjT6 z{xPTB%9qg`B_Z) zv&8Ls(Tm^wYKg;wyVV$e7a{BD^pVI4hi-v3<#mBK7^*rzMyw4gOD!%tK>lC7ees=} zyWJ`V&cYP+mCN?RAOqHohc?~bbz3v)GzAgKe4UeBbmw$@O1zFvJ#Of^YG>ky*R#&- zAu^{6V;(iSz3VxR7(WtvTuNUxWNHt@S_} zozJ%KpnfHFK_-;wG$t~Sqv}V(nN8Ao8nVW$^(x}3Z2H=KW%inzQ9b^q0LR+z=Wf0M z^?t=98%Nk-Lv3`=z;T{aB9krgK}j1Nr*HDa}zFEpa@nJxRS(&z*p}XGYsbXfhD{GfTvT3gGR^`_zVFSW5GeL?_0y66+VZo>7m8S*-tDOen zdF7)?uHVYf)$3LrBolq4yLzWifrNPH=zjU9Rfp`h1fpnzq5{N8IOs2 z=c<#nD8sO-n>)eO#xrJg2tM1!NA^~W1C5m-WHK#09>b$aMR0xKGv^o$#JhR}S`TMk z@po2#Vb;=pht+aFs)l$@aNn?Kt&GsqW;tV%)1pzcKnznY8#v(Wmf%vKGM@GShS^mwixq6`_9%V?k6wPb1(ETb4oVj*4rTL4&XciE) z-F|F7j&{HL1&F3T>018RsVY1vftnU;m^K$JR5cVtJlj4cP09q;%fwkB-7g~w2sfG_ zBbn;Pf`!H$I6xDy!5%;&fX{Xan024cKz=etCjh^y+|Aw%XWL7=BFl^DJ`CgKOga@Y z%kXX&zU`ls5-SC5s+XKyCOK16Y);iE>b@QzC6b2zX!i2-1Daf*N0%JdoZ4IUt`nL_ z*3N$3fWuq57yw(Q9sb%w`R^)vMRtmXp{g+^^*mMwrHJ{%M%~@(KVXm+c&Cmke)mdR z8{k+1rZiGwk`MLp>q*YVg%sAM2Ky{zbObWS;}_vyF88M@HQI(lTfgh>Gl3=ULx_-W z=EvyVEo$w(mK5$ivQbmD<7|gA3Q%oTlN9G5LNRjyLs+e8POJ~Jwl>h#OXbrHt4dOu zV~i4Vsiuu~JQ>N~5Zq|hpG!*o2O>nZ_;W~lT_RY=8HA_4_k0Y|%DtJ<`)bSuNjrt9 zVSexTp+;bucw{`8d9!jcy2=Va#&~Qi@VzF-MK`$|JreJn#0xjm@cT_ACfGGJg+sV^%3d&G_Xmm}V)URnXTJ6kOoU zHX28oi~^X#eL`TJe zmM__#b49~X&Nctl&5e~}bBy*`rzM$^J#&P(qy^1Toy?fRkR1YnF-4I}dX0}|rt0;= z;=4v0D*a&QRx#r9uVvC6~rDxw5b_v_l`mF+gjX58LZZ zih?B^qZAJCDl12nbTRi@u;-}JK^%NL|JYWbmsJ^6^NE5~cZt*Bbd|xatUv_LPd053 z)k!jx&8=Fd;|X$thb@G27_Bnl!kqCnIIgF8Oi=lqAFTFD5A*3|N@j(=Fr0 zmCr~lHyI;B(~md~mf7^2MaB}+Ti1qT)*Zosc1xPQxhyGz3wbZg3;9X{bk3vZMswB@K5HE!`vPgwh)1U48owWFh4pz zO&iS|6;go2xof@E3PuvfsN40!&McoM<=0Hp5+bkyLUXqfa!cR40pY&D=K01wHW(O~ zxh8vx*Imr{j$O*Wt$%ODm*JS@lg^{lY9`N6PUY!=-&`U&&n*6mpCr)~SiJcszf~A% zlc>ht-w|>0rp*T4Xa3H}ZNOvJAK~Vkg!k`DU$PQx02=OuN&I`I?#xM;XbXLyc6zMl8$@ky?h4M8~- z+K;A6iEs8@T=!>!Rv3mR4gb}NKrBMADxLR}Dy1LMEiR%y`t|kdBwkU$hk}GM6;(O; zbUANs26^@=B_^YEQ-7_eC))k0$b{}OQ5zf~m!d-bYzT$#rHP(* zpE8y^v@Fsex78 zPK2ik{~B6~`s?divR_4NS2K{3(lmx8CS!!&XFo9NPj@AONPB2(jtIH9!NerfogEbOQAx3iv-lv{00(I63`x?k8C85u+ zFCNUKi!|^!{CMq0@3L!M*mm%d%w^M{H&2!uXEINGmuQE@@k+Pjc^yKWOa!2rSy=8@ zJtgl14E{EP3T_47FM>}=OpKX)H0t{LTWr2!I!`KVm!9NZ673o1zd&T?QsVgCkcuKR zCD5td=w=-IEDiQ8OlJz|5ryMhnal(7F|!;X*Kd~or|<*P$ZhY+u_7P z@W$-dw@JcW|93d<23Yj-Lsy5hwfI*4xE$A+904TVc=wEwZAp>R2dxgb!5{a8!`UE{ zB7#;qVChoi*KIoyzeitJzwEEU+p%&&Ab;j@S3;#)5p#Wh#Qf4zal~&oo4wG)hRQ*CpzCR;1 z^s?-GPeL@YB-r+&Cy;QVp+BCZeZrypvB zNa}8PliKhO&kT#qFcMbq?ahc1`@V26Bc8z(OVN);1_!;iiAJyDhUkf5xUQ$Le)2tk z>35QiDRYT&sNBW9*V+5GNmVJI8su3i?+>#%K>FDE9+dK}#t1q*B0%ix$5$@m7&DlbCozgN?3lwZ#^iDbXmm-Xfc2nlZ zS?%;XWxbrWX5sA50NZm0?6M*9C%e6~?Ty4ClTC>pvN>7~PD+}FFBa)XV$P6+$>A|S z>w@#wKP~jI>;*s!hzj>zHFUiev-M3fx|iN?*UPASJ2H1h48ou^W{3lB$FJ(N_-tme z?|(Z7D~k9!DC8M0y7tigdfvH?%T?CI_9iF@M!gk_B;czw8coq_OCSrZ{v_{=L96=FuuQ<=+sDc70vZboao4T5vl}oQGDNBVuT|*nimW zv0~lk4^Sr%rdhL77Ff-ak$e<>?q2bB6+{R9Xi8&uVpmNIiCP<@IU-XMQk$p0Flr;j z#oAyy;b%nej5I`=Os8x()L59KKB6ORBCP^2J?P6Ub%N+5>ffLB%W#_XFLbuhYP9jJ zs&&l;hR=Vm1cHxFvB8lu zQraylwMJCmAU1!8_Y1lstQj>*$`ag(t|Wlkef+Rn=8vB6IAolLQ`qjoVU@2BAckm* zr4oo7&-FUVdH0NqHBf)6kM$;c*FbXXj?V5eA&s2o3eD)OJ55hCw7TeTpVO$^nSM(8 zBNW(xgZ}z1cYg%CHYV|Ra zS9xSB*O*fpKKSfJ*I^phKP8~?aC;gFyaeaMAV6LObPO;P`wlJ}!4wm~Y7qxRgz0>7 zv;W}2Rh^-fP_M!D+?{27NQ>LBkXL1l@sehal^-vGggV@=D8$jx6BzTEIW7n{erg-hkflG7ByhT)~e@9%19vfJa$#yVQCqS`4wq7MSX znc1G(=zg+EovYd?f36%p$)r>q(rcl$3zGcQ9?#%aQ!4qoX2tv+FR6(wHS%k1 zFa?U;Vc>)jQBw;-uKVrDws+ybPiYGj<9?CHvOZNu{=p_kmjYf8X-mStPQ2{GZl(6# zAiq1DeM;cm)7j|^+ikn?earW=Pcj0jOyibhYUTb`y^B3gOzUmbrh)UB|H&~;Vkx}{}>qz(p_NY(LE;QQPzwowNZD&;Pr)2th}lC5cQFcMf?qFfOa}W_za~>rqVBw!Mk%Z?xAl={Q(H# zX`J#l;ppK%eW7|P+;4rlFK#bg?+k(o5@Y>?X)SWYEMuyC7-kK*`K0jD7i0$H1Tp&9 zKPV>BJEj_K0s-vZdiv}4S5u9P5t`L!IZv5~8|1S&MY?fImP)99ABop33<_yOeYHKu z6t(`x*Is4(VEl*&pF@k+%2)FYtQlFb{DIM3u~Y=pUn=JTQnTad&~TfmxEkg(QhFrx z^_DbA^t!i5{fU;CIzuqt^7@9?U$HSfkHRTvexKhnko!GIf)0YcwF$SE8-MyS=|^2Y zp60NrtXB8pBsVx03%rhpV!*2=_}83oD1pXCmG3I!aVkWN8!g|p&0JQRPwlm1c{iAT z_KQRNulPsGmQeCc8_`eZVqdpIdTO6jKkphD5}OXA8CcHytN}dvY}q3Iss4O9p7e?Z z+RjB{1|=&IwwbtRGZp^CtoW%Vm1p|K z>4^YuvH`U0&g(vVZ7C{cAGvKeItZLI$19x~$|KU>nTo=ORDTI+Yac!L*m*A~v=@7f z3zHE@cvT>8hUFKF~^H|va|u|HFIh|HOF zVK?=uOu}kMzOz0#G&@}xyg%l6si{_>+r_#|&4n#vhcdDp?GG#403RFpJ#<$TG%6C( z<^j;LYovQZ9{v>AqlxtA^1cPA6vC(HCll|xazzCTKx)-Ga=Xl zTK~-IbD_`+lO+^zrs#uGe6_l&{pACFoi$SC?W&m0YJ1cYp0W$qe2ACS5@zK+ZXhOV znF|#&enM(C9nfEqw^ax>V3-Lq4d5De-1!t1aZ3YJMRFz~1AASW`np#^NOdnk3)orI z`%8g1pM{OF z4V01*;THsr<^2SDCO2?k<@NEESEndfWwc2qpr{Bwof4rjb7+e)LC^PF*>{~= zc88{d0UG7EGU&=$$vRHPWB2hf9+^C`jJ-_V2M{zkcxfqb?1-C-LRP?Bzs)GEt6r&c z7pSc(JDonK_KcRHjLUK1NiGzgxDVRe@M>D+YkU?0EG}E(cdd_BRi}~RO@vkbKiViX zMNvK!0rOs(F+Gt>pYWDuJFWU``vvTm?w;SY%F~VB0`%boNUm-%Yt0A>7SOJk{kvKs zkrnPO|8w0G%^p;ospbyYbjbg|$D9hwexawY%YhLQ^NqtINYebzQuTZ+*=XKKM)(z* zxv)9VIU%zbv_|Id?XxqeYUM-uce}Jp3z1PX>H;i@A8;QrHAF;Lx&d^kTmMgy^t;0rA+jh_U?tL%_ zRGD80hxrMGfEAU~8WVVN8KZBybw&-vPoHd<49BAYr^kKMy!Cwfyf`0r2yH|aT z8RYZAJJ;{;Oi3A@lCkDsC+RU6sGM=L;L*b{GRTupyJmhEs&5Q^8216uPB`W`qEPnl9Dk$Aw0_@-^3_ z6w#sO|NWIw-P@fXm=pzhe;~hDE5do!tnWZZjT(|DRUwsbR03FWHrC9s%Q z_;!8px6nZhnYd;%S|!&n!3yI{5r4zqOBimCvMEeD$pkRi-7AWduu6bIh-jhpy^BI(ZyH6|!?brH z(?1tFd0~4yX;mzcg4z}Bar_5Ic?34;3Rc|E$B71p4-Z#AdvotHcbj2a&K~i4JuSB! z)#cl@P^9{{tvE|19>NGgL@_v#@_kn!bCzpI&-68ddetT*{ttusE>zdg6l=-nn* z>!gVtz=qJj7K=Q-*))LuI<%|1z9jY!0T^ss{TLSF11#%Yq9~NkfS1TGNopJlf>_-} zE0u`dJO^a{MR6|DSNsW`Lnyiz1AUl@`%7FIl=?5bgk9H1+TJ1nBH#7PRI#h^W+?S- z+*#+_{zX776fp!0Zt7=;YHM|n1w0WKEGMgxezu!BGO`#N#<)LQb^nZt_qIe+bDJa+ z6fWh3w9ktN%W0hpFV2POar~%eyGeN}06ejtOaQ#|#SrTs(X}QuMAFaX-)-}*bz-#SVX`Rsh4b)UIoebQt7gLm2fG*y?B z8tVS-;+WnNI-$H?(J&a+YdHoWhqc{HvuEy!M_ZS6W*64$1m=c$U~UjQN3n6)Jn(@K z|6Y}oWy_YCV`sRsRpPY)vlI@BsNl`-y&_Nte~j~}CH?n&eowfxQGIo4)1mnxPLo;Y zp#UwU4pc=>Taqw=S6X$&KSe(Qg>2E`G*x3l>h7BUI_A@Oe(=}gyo+?b}PC%7vitT)hbH-#Zv}7_2z1zfK{!~?BHAg z#{Ulh)sTjGkd_=cf2x8E>0rwV{CzH&aQ-HlCQXUxBc zgjNz@D4^3>B_?Qx`){u`WBg;kmF38EK`L+GJ^P$-<9=H$YKHP@?Gwr6qktM>F=~`) zm;X~5Tv@D`U8045%c2|?PU_K8RkQARhO71D*e3%=x4=5QA~l-x7i{p0+KABRNbtsc z?zrr)-o^h))r+fq%3nx-XB;>)Od*m-udc*MN0JF}os=Ao)YI1oT-xRhr25ZppT^T* z0@acT%n;}!XDY@97W7v0$IduOM zFy+x!g8yD&D6aDOX4S zECs3gZ;V@+n9>OVZ}WHwh0zz7BHi67NN6B~>nR@*4h{3nxdnvV{c;d)Z@O(Qzy!a! z`C=DIfL><0SSvW_2NU&pkIw5(znUH@feX*rd_+g;tIDXsT6 zpHmn`%8q6eiHENe?5kGW$GMJPfciT@d zE%r|j2#HFl$?yU*#{rbC#&dagSKc$I(dkxvf8kRV9urt{t)aLePflibXP3i5w?ypk{XA8QEqQ2H9dGSLz)L5KZV1EvmxQjcP!QR7O>h@Nm2>2e$Vsm~mi) zKlgQtiKY3(_pctz;LkkZ=P9@HE(8Supg=TqpT8dl5<_a&k5KCl1arNdN{MP`YjB`~ zw24Hl|GK`rvC*BB+5{Bg_NK5;bo)NzF-1zyZ!FPihjz|~7(Ov0s|CM2D#f)y#|(Aa zM)Y(<;`Q~3wsX-gV{)SfMwcc6fpnW-gZEzxbKnar7KQiIvfC9#j?08Xo4f7ZX!}?J zI5op-lm4EZmK`Gl0cTB60-X-gjez_X=Ra%%iJ@hFgo3wH0;#;kzFkmJM$#mx%37GlI~6rgHldrz9BPv@fzfN)Ejuyo8N4aY)FyX zjUa+d28wRk?$-Rh8(;Beyw*5zcOIf75c#xZa>J@xx6;=~?KvI^kqhXwi*)<>Z(t*uYd zZUhBExc|!U2~)C4Xyu-X#_@9MYiW^!e_=@V`Hzl_A)yX3)5eUQBR!=B={;vprUf!@ zng_f$_19ZRYBq!7*+IsI{g-x0)D|`epn?GobiOZNV9Pzdr+Y&OSbZy_CQHbmCnRkz zN;s8k^%c>{y|ery6Du28v>Q7WMPw-CR+#R01SQe`&b0vjQ?0}87&0robGa*GE_UG5 ztJ)XH_}edVHlu8xShBmepnycT8~nUT@EuTnk-1G#wq9PIj8nBDd-wKyMjLY!a61C_ zE_!UQ;#^QG4<C4@!TEl9fktvnj4W<{FtOqi8+4woH39bf+v{0aV#z6>j)OPw3`A z&*Sy1ygKdk6(HVv@=)-Yt5Tyw8rk0t5BX^rOZ`u)3Qg?XjU)K&c`I!t(fqwxLTs)C z!ns3R40~wxzGwZBMUnX|Mwu($B|O1m9o)r_A3o*}cs%3)k$XJe-4if^>k7qmbRj)h zJ-HNFeL4k&63G*3+OUA9+DZxqNR~)AkcvJMiL562yL&yP^Sm+5-__ThtEhqi#-gXf z{97DZ4@jSr&j6m4Dsf%C%8T#{rxsvM|11hxrK-Ok0&wI%A=YCj&<|xx;;Ns(!kXGb$0R9=?W5 zx^}tZ4nc)(@u}|)K*{UBY4EBbB&>@zBP7Q7kLGycz!cn0tdELPgKn$!7y$J==ZODM zF~krD2sF`t_Tj|1#6;s8{sKFdR|(~RJxQD}K*iARNtN_q zwM7lc!q&v^^Iy269{sJ$1@3ro@R$48l4t~8(RA%_ z+vI!~E+WEdKH9yw92;BEVNJ%c1;1%Fcp9(Z>WhG-Hl7f-`AmiAnV$IOfYM~4U*+4; z!Op^@hG;8oWMuy~hfvAC9;Q9nZz65{285&D&u?Vkrv7b~*`xL#nU=hP1$GdrdaxoQ zOcddp@}Jn5rtA3!npFiuhMlm-d*21TUv4l4AmPvIz28;KkkH1nN)ufvH>?NTW$5V% zMS*UvEyz5tb9XvWy=~aqN8;dLf@9n5UCFOng9*!tW)A`k5IzyJNxWD-+sYR4UUMX2*D%L(cof{O?X7i=|sdjT3_(-pA4uE<9R(EKqD_8G4C)*yVXlhTan)dZ6!vl@p#f zl-dhrxX{l$$8z8R#Yw~*GPPt&&{IA*PC6)s#weBh+n%|;MPsA zPlG+|6e6Xif~ie4nulJVtja%aT3~NUkl51qSwCADJ5p$%(VizfW4)7%6xlCs&k~J^ z!YhjYdthro1oqM^kbmfFaz(2xVQ77_Rba2ZU7|>THDzAs1_7KG~Bq zAWGkSeS-#sojdol4`N$ZC)Klr{2v|W609_J-9?In2FiDg2;a7iV6%r?sG*CwjAfmk z(zPF|Fq@hxvf-}8XBX*EQT7V3(i09-39$tpmJWVNA-bhK4%1~ zsXAfk*MV*5vY5dSXx1&vJ5Ee}CIK}nBbh!Y$`{x2nP9UaFV`2kx;z-B)~xTe=qsmH zwatCQb$9H{o%Q1l*}bnV^{6bRupG!0*yMHHcu|Evb_83+o^oKcv40lV6)#L-{Kdl~ zmBNXfjL#W7rRgiDsY4JqS@eZnbNG6)j4E8q?3HQl(WP>w!J9f)G#-6Q1Em-XI4q=2?N7N+{4-NQ%J`|^4IC>mSQCG> zr82+$`h-@iW0*zp4d<42;^&@Y>#Rd%Ac#PjPACd$mE6ubbR_Gzb4;2*p{#IhRC5hS z4-A(&u1L&q;IasR>u^nPO3Ke!_MuL+zCC#htgUY%#WLZ`$R`wExStk$ z;?@+GC*r+M-9;Vn@l57w`sStcfExT;j#utP_%uN&8{OzgYtW<>EtOl)?0s*oDm?3; zyVVm>kIe>6q!H_C?4gEP4f4-BdYU&#eD~Y$ty+f7H~KS+@Hn1{TeX4WqGPQsyF_aS zQxV#AMnY7d)KK-8>lt=qW4`ZacBn&pzlM9IF-?a1UafV797rPn?T@(n2duVzqLUfz z+|c{S^WRuCmX_&=e5iPlclF{Yk+*Nnf8qn2#Sq9%$JBcAgcYLMY{@4-Js%$PvSKI2 zKix*dq`1)o?uAhTrQy1=9r8qdBP{mWnD8d)Dt@XE5sr=3g-16A#`1K+j?1usGZL3X zgzx6A`l_#5=!Ynwt^WLj-N`knv3ZuaHAM4|%wbatxm8Qn|EpvXG3+;3{TgiuzN$Pr z(iNhX5wYNLAIyIFKdG56q5Kg*+E(Eb^y zx^^__@?~(Y%jAu{a<0;f!>UL{O<~?;gM6gO5;)XG4su0PZw7oVNd;Nu4huy+QzG(f z>apuUV-`)wyV-T(v&bp|#}zp8M8u&<5clt#(vVqMYqO32eyOa5O8XQJrDyj2nA9U( z(+VDaAx!gDNX##GC3noJ*IS4<&xI^7Cb08R6MUr zrIAs=tWSU31w*6+s@hehmD=K}cfO#RrWl0c>nFB+x0UuS`n_W^jp1mwPRXlta{Z9j z-fR)*47%Of^40Xi{?!%vtaTwAAK23a2Rb=-eZ;~27ZgB}GuPFsqC$;Ro-EZZ5%S|( zit6_591B}*aHG8gd1|%|LGBq-Om~T9c%NU3^Rb?q%ANm=E|2KM&=vNSDqEvCSl0Z6 z?5yZ>IQkpwOWTi3uy*?REDd4U0{UnEhL`Ut#KgXV*pzFg*jEWS%PXtpRx#?OeJTtv zI0)a&)s)z;r#hzK=+Q`}*~Q0O_XcygdpbyY{wQy5!=#73S}81A>q@M{3&CgnH9ISn z!W$peX4@SqBxrF|(R-vX{bxkZDoIY33JvuIYs)oEhS~kidusY}yHmc$flgu^25>5E zy)Y|+h+a;^Zsu3t)tv*yXws7)EzRP7C@3^M3@G_3(~)Ujg1)&%ENK>&a{P!p?USKo zIC#{c*|nAPZQvAXy3riD^59~7??>1_uzEJr3|imFW%u$kACeOm1z`Hnhj2AS68+-5 z@7Zp-e9r&&Sx!apSpb{+MwG$AYf1%xSoDzy)Jzu`^Qov-Oy*&Z|qXlk?V~L zo2s^+BcULBYOk+Pk;B*9a`@QGb0FZXi=J`)o~L{rf9w_vQ32&MnlyA83}Y=D5BFTl zNrO$U6-QZSNR^?OxaX$ z7&k~c%Hc8Ca(M|+Q^s1Z^GMXsPW9zH`I`kLT~RYoFt$8O+Kphjjy@!3@ejm{nme*L zr?Asrv{cNMyBFwuXQqqg-5W%+<~U+dnRW+Sy(gc`7A&bcC;}g04cJ7*3>}Ufr=IA9E6Uv%^ozzvWDUa`~}SsbEF zo#?wX4KwRi%P0M{SX_3pUHf=%*pW|v)>qFnS5~_EtVcIgJc&p6>We+kQ&0&N;xpX- zvEbHg=OdfI-K$cM84ZHVtt{J<)rFrMKIIOTSF29<_Wzastk8Y6Tl&!QsGnZ%ppB0S zl@<}3#JW4QiPhrBP#P}S;sMdLAt>j!hb?552=fcSav zF)`(P=e~OkU)ce_rM>5~-J>^!e-I9P0rov@{ki)W8z3X%lPiE5Z$*r}%UV5WEH}w^wd1P|O9BLOK zi@x4HK9#mx+s4<+XIaxBpx`PEp7LDs!VxByc(t|Zy?p2#PQ{lJl)tK7M5w6o$w)&H zc{d^U)h-ot;N(E^vL_U%E0M$`%4cjww2iEuBv(E0Dvv@#jxu;H?xkyR6Cy~>v=NQp%)Nu*>ZdB>rN9lVs9Co z3yRk4ZZ;ZM7%MF!Jma<8_6Vdjn53AA{q@>$*MDWTQ&zy@XgM$R24wYqMErRYTogyf zvYUw<(yLdZWsXH7P??AKpsu`see^>91y?^z$_X^Ht{wB8I%|xtZk!hW<^8d4Pmzp9 zhc~l#wN%22!yAX6-?~zG^U(`_n5ig4s3109{YBZ`g}i2}T${)PYD`Erd(h%6vAkN@ zDc-lv-iO`q>|YjUDkNR64*DZC?dWOU{OohaqY92!EdrT07t+bJ?$AjG#WLnZZ#+7t!q-pch$NM@EpcX6shjgr{%9|P}DEK;si98 ze@OmbKx3ZaaS(ZFSi!rXVEerwCuvMH$fxwp*!nSMju?`#%&$2(A_46)?ZQNyJHe~J zOxoYqtmXG%!QGjXc7TuTIqOS>77l{L$iu&wuyRAOasDlTs)$&L4%L9MN3euv985xb z)%RYLzELr)+2T`Uw2{r#dmO+};UJ-jdIsdbauu0i@xc!lDN1M-f4j|8h{w*a*5f0ck;w0-_ye+*pj%Z#(ED>+6*5 z)dwA&j-tya9SNdTI*JZU9yQN#vCobrIxV^tcdJcI$qau`yzssfuHKr>f$wy2sv57 zrT1{zp(8xT5(#1sZd-)xm15Uyx}NML*5e7nqQt`U_}sPQYYPA-g-d5{Hjb@x-wQRl z>o?WT{8w5NNxbj^_&lhZpVqQt&QNLbID{duSNq3us{X-rKAar)lVPANW?Xe-j3yim zbmt($2tFvTm1ldMaP4?l)5f6~=H~&&@Cyq|H}5dD47bVpgZHCubx*HKxt-6$1N8K# zDy!xX3r=$U3BJ}urlw(;w9A%x9UF|=JNy9wWt%<7+Xcd0Ka1vG>;1L~mb=YF66kn= z_2r$2TwI0oI+_lzS4AA3UN+r8Rr`N8j04p1WVT>p0lY0XGcn6sV#^@u#wdeNh0xQ> zz+CT$YJq;2RPr;9u)`?mc;!SczeE|zP&MzBdKJ~N!OP*MDM6dx~i3qj4 zZ|w`!uj5G0;g1U+MZ~ahX|Izer-}84?_b)AY#GnPEUfY`2OfwGl&!%&=UxL7q z`1*tM!zRIM@4;7^+#oi%-Wo0TSM=>bp=zE|N^&0HB&n>m68^E#->ih?S*NfKwxOFn zmm1~MD=f33oqkKO6A=l#3_WIYg#2HM^euJ*hTm8101%cHmrpf4d?m-gMR41Khgf>+ zF3p_Vgz_si#{-)^JrWQ4bhfI4DMrdQ%Y5atFf_ikRY&LKQcH}(RryYaLbD(yhP(~w zb#r;CD*cDx%j&BYXe^cUgVg?%NtKmp+J^!>ojYqXkN1qk5lP3_+gTi&>rvSYakNSr zWsyJnIQgE}jD5gr>MqQRhcZdJjX@cm%K0O#wX|P!a}qFiCVMX z3*^IU)P8TpDT;v<@$oP@vgRb?&Y`cfa6Xdj{9X0#9GTUtyyr?;8mH2sR)^IkY1jAY zBgpt+7PP%$mXH4I5?^u0cFxqUS#Mh<(f)mL+apd@vul>>2Q}&mjN#2c^RT)+@Xd;d zkn-^{rBbO(i%9|{X$C+W{I4= zdPO^O_G2G1Ov#OM^K71~?B`J|8@$%3S0O(Iy5$og@olhH0c9%d(v`z{_wFG2Ilo2W zzXq*j(4x6mk6C6&iuhyE33|^BlD~2(mX1$h##1)Xl=w*pLGUjDh#%R6XRhYR z{SRHh-iG40R(ad+1&s8BBEtz&W)inKcH{R;I#&W?W1gX37N%dHxra@Dyk6cm0HxVX zbIrJ^&OmX&RAswu`=ueRIPkGnB%5eC`$2g4QzjI-^Yz~G>GIlgmC9$NQGtUe0P-LO zhZ^+L=2*(LRtT5%9XjUbfGLHiUIy|T@~N!dTDJmI7pnBF!34K8_aIVN%e&j?!2-d% zx_@nh=UiueE@XCVVy~z~K{$IcQj5vGqf90m$pZD25y#uU_*qw4-36U~$?4v%Z{!Rm z6PJ|gG*?t>xtB|RVvLgDDlasAzIZJ6E#p8(!?RV>R2`V+(-H*z0~~BE?V4v`nmGmw zx$=0f;V2P>T+&}}v{OJyrcex++>oTvL^e#AADE@=Zmzd8p@mfa*ndB+EtIDh66R`{ zrRTo}eQMoznZK?*_bkJrYPbAXHTg!BpRg`De;rb;+r6ZZR7YqIu&Jqn*)ZD1)P`G|%rjB2a^qHLC5EYNa= z$t64TMT1!9i@BY5SQ#S;f_P~q#Vv6}@UkvSUhwOdy-oJS`GZ|}W(6dJ@Q{z*w->&a z(qm&ZTwXWU%lW5qZ?(qRn8gCfX_&;zdwQ6kq2N9RJSCY4oqhyWmw<#Kjh6rkA$q?N ztE5({<9g@}i|w7+$p!P0v7JHP&qQ-!8edWU{DU?q_7edE`Xf(^6V0K1l6$8RJs-Zb zon7C}F8%SP5a5QrHTqJbjpc&)XR{?@PuUv&Vl|IO{a7D}=G_^c2lu?B>MXvYrA3*g zT&k#`^T~a!H9t>aZX7n{~=pY-*-&TE(HWBQ-JzzBMmx%h4EG~PfKtMoN|f}tTX z;a!bNGuumypb>;|Ssc$yKZW6m0@QO~r`HoUy-Z++E>-?f;eRGp?$zj98 z8XTjWnqhATsc2UjGr_9vk6QDUEWN`^Np%B};!l>dI$XjGT#_~0QMh5Eh(|34`F^EV zgoJ=i$!hcu(op$Kj}6 zmz#s-b&<$PQ|4OD5o7d#|83I7@}J-I{o9b24D#bCpU7nJk?U=r3|LGqw2r>#BsaOR zDwr<$IAwL#tKTj&8ycdQ3&6CcV&Meb@IcV3R}~Jm2d;{q%1bZB@Sfe}2*f zYK^E0c|I4XDEA|Cq=*S}w)~@%f74Bfd-SzDNx)`D;&Tob>;)F3e{ z&kze8^70;e`eJ34sI8XC#*1Z+XXoOfrL)g?e)kV%iU5ULRoi9(N`O5G!!zt&)-jSV z#nt!xxbbs*l^LE|t?%yM?MG2<0JBz2{S3?w1P654WHqC!A};EglXU(C$VhT)AT3FvmIpJqFZuUghwgV8oRy@N!D-J37=G~Z5Igc}A-x{HDx}8`rpAOqlA?araEt&Ac;0M$n84&_Z z4!Vw6M7EH^iQiDJ@&uqDP5j+BERKe@45d@QDiA)$P+WQc<b|_MJ(Q?1c zR5BO|;`P75YSWtd6z2~ze9>MjAI#+;JhmB$4Z^ zr>|v$+lYh$r||c4rS!~vs4$UQa;rhlnV}AkIMDni;;WrOP9vY%sW|>{^onFWtMn;g z1S$R8%0ZfqRTR<9M*%P(de?|Nb$Wj=y{x0i7j~QURjdxe<16{}GECH%AWjXbP_i!L z`*RvKDHrYNiv7d6+=xlIp9lYBs6ia0!u2Ha9gkq>W;S>w+`0YH=j5pW69a|ujA7Nx z(YvMBAQ3C+IG{j;H;_u-g2zEO)>1EfF*H{hU^DRUO$~aBUZzp+&M4B1qms@Ft-uGX z*!2YIw9?_MJT^1%v%0KPUPDye${5usz>c2qP0|o1v zox?#J@|f0W4A0KgymIkf=hjSZ#)$znDlst;~enA)J9IyAZlRH0wDPuQ?N|jL3AeTQZ?Ouv?a~0%A)?y5T6^KWd+s_fc zOxQ&ytRMGz6i^ha*-IZTzt-H9w{_(PMTRCd*eeDNSRq#)qv9)Dl432hvx!g?-ajsa z^HI3?1jB%ecu?JW$G7R`6;dYe`%aX*iWEACMH`N^nx%%&cCd)XZA+3<)G@bRSLODi zStlw?8Z@R#IaH4N`?)nQiGVy&*^gk-RdHyu^G5ILq8xrJ)eqsm23@SBtea0> z-IrI9EcQ5WN|uoK)$Y%b6}$CNj@{P!i#tzbcr-Nggp*YBmx5>T6Y2OtH%>)y{Vp+Y zvj5>9ok;zI8>~xpz4O<%Vo-GIUtUOQq)fH3tymd;?m%WS8YN!2TEEj`1r?FL~H zkZ{={F*lkXb~afo-k(7PXpRSmOzx=kWVbK-J8INc7vPdHJ>$f@+oydBiG;+jal*4* zHbG*c)&lF%TY25mmYF!F;N2YN|9eq;C`4AZpN28{BXZ)SbTf#bj&39L46&66YMoUm zsO4{D@CJZ1evHd;*43*uNn+BYSVfWsHg7e`BGg@ zlWX17ST7o;K3GeF=QX034@0r|*`REE$?m}E&wMwp5)iY_wYzD=f=V!1oiRCH{Z@xh z&yt&p@}T{n3GHF^_;bEf#_DlV>y3c2bRmb;KB>nRz_WV%xwV zf_&e44gJN)P{IsVaR!Hzo^YgkubM_xvj0HhEXgc~AKlLg$+wbd*UnJ;xPJ-zd2%PC z*)V>=>t9|a5f6{gL%$>xh#0O3$~*Ku_qK>^h%**n?$+XV9~(&J(N~xcQ7jt@NEDDf z5=^e+A|B57xZXrxM4VTWGw9FQ9t}Ipbucrl+#dGmx6w;?u5z1nVh<1*!D9}XW~7`i zzqDb~v!qe5>~Hc;htOOE=(NXM9(YB1BG%ic7dv{;P;^Rr`-}gx`YvaJ1vD@krUFqy zp3T#>Mez_VqE+zW5ZBR`^q*iYra5SS1;OE`K!^@TS#wh>_`vK;QXverZApl-l$OHp z#Y;f7S{)x!T7_YR0PrYl+s zmPg-wuZqX>(__*qDkzRVvfUuy?LyrYrdo%Eg#uBeB;&q)fpx_C%>6_(a8#q3%*~mA@$S&4pwR?z zYh&I)TJmw{dF55Jzd?QA>_7Qk)_ zwGIu*J-84PXWn|z|NdswnSl8E_0-q&Zf!IoLqpFl-)Xi{Or3d0lEnPl7xE~sZz`Gh z2jr^zW~guE1aCfKy%QGhZbhyAlGJF0tAg{A&be|sK#X2cD=k2XDFmNkvG4^+cXL$p z*qL3xQ4=AGvDZDvr?*CKDGJIe=_sJ*=h@t~c%m=&|7-#kcRYhFC4G9l*m_lL(YXmE~rEb+QFiOb|b zbP*K^#IhuBZAbJl{WhP6Q=*ka)VH^QiY$5IEB0`-t^3=uM}5rX2ID;@GO^@e;O3gN zn6Mfc^(nvga)c8_0M`zo^t$;J1>!Z!Wv>e1+y8pe(DPxqjdxrR&y27qNmg~mMjFR6 z0RluiT0Qc5yhrEYN@3F5zz>BqM=b#*VUyrpnNbKU%~j%K$p?bEr4MQRt9zTQ8qY%8 z*8OlMg5C9@BD6L6wN+JPo})ck&k!*Aaiy*D-?N+v%!Xs-qu=>&54zTn`VWfH^z;P% zW|e;lWex#m;?e<0#?Um*7Yu4ZqIGKLxQ`^13J=F5)T=i4EPoh=)^rn;a}=r>9IQAp zI_B;Ii>_gBrYS+&+wOcDcSGEX1}zLeulI3D<^`E^XIfaAYf%5g@*vH&2%v!2)s^;_kFh*gdZG z91-gM1Wj{19m<+CsuzLNmi$M>U%Mt3={d%K=4N|M)jTj;E|uegj3}5}l*%t|uldh( zOI#wY%9Fnz-n^}reb6|=1vMSFwb0*DR+pYceKJsh<{mN~3nU|c_xMBBckdkxUtr0^ z>Dma^ype-7w#hoYnNNX(hm1;_2NyaynZY3V0!03aOuAUFAG9U?o5FB&{awu0@&nS- zN;zB;t^>zIFcN;GehVVC0yEaroTT}A{9z0DRO+wq-i`RV3f)uO5NYTKN{C~HpH<-^ zNu}`PYAuKE4HP3Ui>dWJPvc=K@zd0rcvM$J@)(qLgo}8HU&a=@?}%R%IvVumE76@?;x)W;cjf8KPzbJj?syIG4x7}_lqEg%NJYEHFwwYgoJ z&BxzM@0U+Sbw(4~0<}c63;ISJgSEEHzCV#E;{mx)aogBvdlH!5GM3XKWEfLCo|${5 zRpyOjBb^4EgpM#1nCfHtz5!A9Pcv)A4WeegU>I-p`TLI@%miL-wO z3;G6X+aLa$$+#XJXtIYU!lDZfrPU~@kWhBkb#`F?FbvRF*0V{ql&LuCQTU2R3%UIT z=Jt`sG$iq4E9+5c-|K7&!*HifmF0^y;3C2qW|W35$KlzC*AvqyOHMQiEnUhn+&f0S zs$$T`8~0YqupU}H{a87%wcgYQABdlC#M0mZNZIB<{hR+Bwzi6AFB09E91bYeKi#83x5w?w8>X`F`M$dD-ziUY;|f1vcFXmX@4_+bvIGy$s0)TW?sOiw%xF;ggqm;mtuoq zkimhQuF@SuUnNqMJ<`l=zBfS^dr1Bm+0|}u&@W2;s9(xY^LT7xYI@RkyI@sM0{QAT z9QLymza}>U@|?v|nQS6oYfyxznREtg5V9`%RJP=ub{}B}Sr|uVAPA(5-ki(&*r33c zdfl;9ULFRiWv{Mpw=8?VDR%?KrdJaXRGd9_dMW;UiC9tgIU5x7|@Pt_=|NLJk14*CviPF}~|Dqa`o?D#ND(?-jM{A1du za|lBq2Nv)_a^03h05#Sl;vXOp3ujFf@VPuR`D@CY-FUXVb3|JPC=vZw^jpIr8!`lz zHVlLoHO*SN^3nJ-lTOGss6jEyu0Lsji#>6N)reJ5zUb*a&?{o`6b8lLXEY;gLS=f{ zt^)_4)JYUxnt0gkf7k2HqkD0AAP!R1CH;)~n6qqez&!&z-fFcVm_;6Zo9@bx`QwW+ z)m0lT1)geY<7lU!5xqe^PeZbdBN|a-hl2LqlK!Xixj@B1$)A?FKd9(dcr*FhMecj` z9KjC97WdKu8@yBHBih%hxFi7jB|Sg6nZ7iamRTc**SZOp)J!t?wV{r9!#L9M6TbsR z7HmBPtHsE1u9S3uM7>Je4#Y<|2i#v~T&XdjT?45;iO0|tX>3N60cw(M9mF;GU;j^L z#BmFW5dh2;ZYL#O``~hDoM3Rw-QrPbhsxDsyszIuy_Jkuf(Wdx>tih;{Zz(D*69LFhrK`-*@zJtcfYswsAsy ze~)=Ygod1rRrb^EFe+HUYj}o?`*e9E9WjM()?pSRWI3ll!JX|eW7oh#e)qoTw%3%@ zY#L?ErO;wp!rY2MZL)az#{0eZ17JK#IY=v*XvXg3T-F>_>|IX?1C=#vy4-+31J<46 zoo}T<1#kIPu^2aY*J|#BZkCgg6vspcK5SW|i;d8UaLjeI0P47u`|phPN#?g+Vb5b| zAOL*mRC6tgq%5H5>He~|)?Mda*7>CKbt<#_CFZw{PxWhuqhZw6g9((4#0ezp<(ez^ zJ@{E1Q7Z;Jl66-oCtY1azspXFWf8;8^{*+|+ zhE#bx_2HD6i*|cCK4sx(y4^j>_P7JyYD=<`SWWrFMpqV^Joc0*lWH@58ZSsdW=c-{I1%uBwO@IN_$h}S#P zcCxV4**w?Q=^%|{|Ko>$n0>?PbFx&J9lJe`=9IO&I|1shOnsJDq3;A7&CmTmP3I=g zIc;Auk;Htv^h15l?#f&SKiC0=FNUaxCx=AIp?`%PT~^BG9PQkZ;zhmyw~)Bi&kQBh z`F?BSZaZ2a7aH&ea=V11%yjrU9xiRY>43wlT-*2IT417OHhc=|r8-n0^dQep0idzq zZiphey3jc$aH1A31WKX_%7^Y>v;jO#@XwL}3Ed>y)8oMm*Y$T;bT9~9q=XvufC>3( zz!&<1`iv7DD!lN_M*orf7Vbi#+E}2B+^>h_KS}|=Cg6)?Zb9m%Mek>yCM!_8%eQ?* zu6U=z%b3s2$EIJD|7{4>3GNteiVn#CSHk=*`2JR&`bWT}!naQFe+H!NK}!|Ow`6xh zcjYTJd=KnKeKIXHn}L*e3ZD95rZxQEJw6gijw}<9D|fw#&?L;IALDih37(v!7sl3$ zbP$~2hu;A}wO7&=ehHA+E)bIeFq|Df9`^Y|Bt-WWRQcmm-df%sQia!x7wvqTZehN& zW}*?0iKert;J%#2X2kqeRdK^)eq{?d9E=ny?}XEo)j*7%p7((R zs;7Usebb60j>GcAi%NEpTMKs*cy=F1U=Pg%q9Umh1TknJ(UL1D@;WS6e z8z2D?o|zvE4Pt{E%FqKV+5p!0ccx9)V|QBzvqKZzegIse3~f={(* z_V&tRFmYf#oNMcC@i`C#51Unw_PbR00dsLxI)jh={tItq5Leh7E9eCU`Ik82a4rH0?GAeAmV%LG~BvO|E3#pew>fjeL=Z!JB&hidMeE0W5F@!w-@& z`0Cbnj_unU>C~j9jSVI%NP%6F<2UglSm~myjZBa~jmv6lS{NLLc&~rEm(1^&p5Bko z$tP5XK0&vCP3Hjlh^Ese>9zO$Ni9N8g9x04} zw72c-1$4cjw7t(~(%!{{+-Jwbx6_HP*kmzwfVk$_-dhmDe0yOh- zGqEM@6OhR@@Q>QE*SazSC;6El$q9)Ny{b>8cmAttZYZ18a(}n7!egXH}PI^ z;@VAQR-XW0oU;6(d_}-!$?%8_8h6En4>l~!gg}5UbGVVVLS*cRyw;d#wiSIjD8=h( zeh1Q@&(twdP)aTD^Dtq6F9YM%otA*p`&iZ4)7A5jP;N?*Ab|vQ_|HRmC=bq@sDp1^ zvggNW;VR*Lm|!WCoDV+Ik;4cVsjVr;LSEiNb6EU;KhWk{pJLsr%r08J!0SaK;yK6$ zi(j9B#0{%8|NW=*Z>sb5myi@fbl3FXO?1;`do=9KQERUBAX4yJ!qi&OD1C&XcT!!ZN& ztJvXK>lu1k*+y$?z&Iu7a6T?SiiNNXmCc47WBZKtP|7yoj!hvR@)twc*LVA-MirA7 z2#|tEunj1)H_e8}%~r*!eTbL6BLZeC2QTx2fTGzJG4eZH0|U$Fr?svQK` zgt>=-n}Jr-A52hHuYh;Y`u(6RnOV#eYC4?_08KqnRqg>8rnPORR9i6F7ii9H(J?SV z!>=7_q8H5ov$=%W>zNa7u4kpd@Qjo6_phk|tF&t^>JGNzYMCs!dM0siWex=YO)jHi zO_B6-ZiG_RgAE0^EWDVlx{Yw?0xpDXM&BzLa_|TWEU)la6W=P25HKm|{QPI&WqL5O z2Z_bIt}g)QvFZ!%Av}jcB7iF#gl{L`23J!mS`D79{R-DB?v*`{J8I@p5UEb)@ot{a z(<<8eV!(fyk@i>ILgm*|w0S zcJ)d`B2jxgbXh~Rc{j)e^B;9jj)Of;^K8xXUbUAwxQU;v zcE$!&tFhO}I}gX7em=7$ik1tF5Py{^=*jdW9#Fm?iy7`5JHoNXeI&|^?BIrF(Q`xH zop1i)kn}_-)Yhe1mOMZ%+WP&!)+b8oFiu4wArV?yoZcs`9PJb?#Q|+Mi-=05#c;sy+?FCqof(Q;st{|2w7=Z9ib2odM*<)x;M*0qr8RF=?m>nIw0k z@x7QA7*LP_|FR&{J=6w)j!vU%@l|0~oy7l?z@q3*0}z{u*wXoFL0|ELH!sN3mq5!< zpy$WyNIB$J1)23RY}=R1*JfI4i*lb|u=i=1EfE~{?ta53mn1vvfNq!lg-+|-EC5~g zDHGe80OnTeI=D+i5e~zcldw;`E!W-K1`P5?VAhMSxDM%#b2+EWbd0&(&dlNf_mUuE zvhmy-uKpz%aIa!@4{65rJB7B- zG{in>`7H$&6EvD;;^VuS_Se$<4jF`mpW1|%EoHwrZomsl5@GrS6bpz~m(DEzD%Xaz z?Nq=(NL^n(3T&=CEw)t;0u+ELg*TeUkG5_+ZR<)~iDRa6DebtbI%dZkLa(iF= zG9_H$qTTWf5w_8-%pb!Mq7n^C_?9UansGto$97{2VG5vG;gY%08WZ(I-rcz_3`x>c znIfo)&pvE?n-*Zn`U)cxC3*NKZ1XVVG!*`Y9?>!`x5*wH+K~|qY-QgEnz`aDf%Su?{}Aa2+J(4V9Mk9}3Gl)j zH}7@iD?oclz?+wJq0M;4IP?mMZ{T(?+GQOEomy_i!@pF`Y4-@q&xDd?JkX9_RmpDt zshr#mo(kNa$r}TjBDs){!0w5gb@_DbhYr}Ij`qL9d{Iv|niqE+Q|u1)Tm^0!C_+0r zn1>Cv@pXjm-buXDL+H^-36;267C)S9y<-(2C%X#NmM(W34YO&1wzRKY;l-ClLnlH> zuB$Wm?S~mZSlpvzqPu>PsiXX*$$zr$Auwp-^C^4&p;y&~XYmvV%ls(&_m~s3zq{?%u zYWRTsUyo7TQg1kwoPiDj1YXd6N=XwV1liv%>H#AEH={4VS6Ad>kY`B${y)#G50ARz z9S#gp&sq^U1=;#y_s!bqkf3Amv}%IoG(ssY-D`>XvbI1qO2NNe??R#d68DblQlH0T zJ7FbVoQZ4Z`*+%(TU1b^ZB+qSLM=UL-QmWp|DYd`k>vdNR?P#MUx1+R>O>aw+(>zu z`z|D)ZZ2`;^%-7X$X#!GV@xsKVEH|Sh(ez_jajl2E{V_Vpz+TH@!$Pd;w0*;IPD+L zBi)V?QZhL;Z8y~w90vC$yq6voiGY3At%z4Ex9n$_XeMmN2B$|3(5I~L2^?)&wTfUQ zlNEYZ#mdfqswSK38+EitCu$ILROqbtKs4$!WNF&w# zY{dwD@%}mv`p*N)52tw#j<zF7Pp85HRUKVvQvC)SiGyTop3Zep--qVs~oXu!Wm}3>Trs{z&@(aXP;Hd6Da9 z#Z(`zJjMT93!OyBAyKf{5%MJz_1tf&{l?Cpvl}iB2=2xv3a~W#!B})>d;N*99+vmR z0w_sLwA~(FE(M4s+0+j$lnYmzj*dtyO&0so2AYQXQHNFo$LTeN12A(}B z79Xek9lDJSd_A65x9DQjwp=l|=2h2-wF!x3ofo^3fMrmJP>6`9Elku7P?U~aWK3!- zXb>5GI!;t#sx7xXQ$^KyK1#A)d!Pw)IW*!E8Ot@?U3gh_|mVdnHD0n=gqJagig!F)8cI-?Exk zp#kUY*N0$L0oq%<|=6yn@!1rc5Kv3wq3U@ zmRbDw@ernz7co)tEe5KDj4nTY+fqG0(2NpE*H{!pkfUPC*u6^n}X9%iJ~0n!-|9Gh?#XV5{>;^{6Um z7AiNgMExCsdaj~!L%@0c?Zf$PS78>2Zzj#Z38JDbQ4YVoJXKQ$EFa(o9=wvmz>ShnVuy-%n}$dN z7(p^rcG^X?x*qiLU^C@ZjkeEN6Ci@Hop zoSZ_?YaFq2`9egZmTf>vUK^6_|US_2tiT%!ykFyJGECM z)~%lt`8+KaoC{RI~@e0IVchCL>sYf}Zv| ze*aeWj3e|VJ)*Q~KG|NXjhH3m(3?UEh18(&a2f;+a~w&aEU=v)LvkQMnZehH@XqV? zUz~=7l&AMdws?GvU(K?P92t#m6H;k@*WRB76mpg@GGMRh&_PFBdt%e#H ziftK}LchqW9(OSfvaqen_L1CGxm>|!y*U!E!_lou%Df9A3y(Y-Q+J_+?STTdnXkf{NXr1^Ess1OTevk zhBr;6;*4MevfOfW<{uiE`QwT~#KJeK1@VWLX4U4&gCNZzllUe{LxD2K`XEybzjeya z#%GYE$4Lhr?%$K}-;YN0Z3MMNPmjp`gzAykHsP4NQoI5GgY;`|PEZVS@)C{!ozBn7 zvf`=!0JQquW|{GZV3bNe4TA=9{{x)%0v=9anrIN@s^ayVmxc=OVbmd&Ewto(=h zie(01xl+PZiMlYt!Y03eIRU&qPn$YmE;&rq7k87RV5t|M;Jwyu;*_D|8vQs^mX`XN zk$4dQ{r1^O&uI3=QcXUpcU~Ju$(Et=@m4rq#23q^)GU^0ZZw_ir=NiZpq5A@OoOuc-md@xv?Rr!g zHh7D{|L$IKAec^z$p41x!x6Ba=e_f*<0#(TrsyX7+8Zl4zg2WwT{* z*dOW}400$m4i-Iqw$S|fy}9DATcrW<*p z`?`Xs%JOHR)9>^n90G>yxbS@v(14@qCkoy~@ku7wH>f7Ei0!O&XI1*26Bl!=TBE=+sTI}*Kgx! z@SA{k!+UXVa@q$~htd)oU&Ae)c?j`5lm2aT$);^nr6E z+d$CqB}DbL@h$Y#qdZ?VVgWP5X9aDq{gZ`VLX_%OBa$AkZG>XZE9{qv&(b}WdoTq2 zm6UmdH>;Qzv=~|u#kfG}rx!L`QWaLzC7klI*;@t?Q#a55A_KPuhRiljo#D8@MlKO} zdR%}N;6rXZ21%94Pr3cs&X`0b$#|w~^Wl6@@XV10$$gl4_$+jRa)ZA6Q&~pA9n2%g z^(_@@am*zYBBcU42NtR>U1olgmD~srKBu_`xoVS0u>+1|4k@`zK6z)`lo^5@Y z%LZYfl0HeQ0G5#5EuuCH332#+b z`j8ChS}SVTB$4xSlT-S%jJyd``FG!XSQUqQrI>&2eeGsOeLe1%_!U6sZwGr`YH zr}mxcC6#OkzT3fUKGI6_S;kMTM8UnzKLK*md;4i1NWe9;`6l~{xE(ptH@bGp zaDS}r0FnDDckA6!+F=@Fep#yB!Kqbg5A=O^KJ|V>gG>fzr)@S9WegD^64-^;lWL9% z0^ERalPtb>=?4&BtA8Bk^ipO9_E!K;jqm>FNbwr1Pu+w!QVxFXp?VNM8$f^YZEmI} z+021z1&x;A?93IW<<4gZ8Fotixjp}l6|C`sGo*^rnjsiHG$q z4X6`3{{Euf8kA)8ls#Jt*O#|Qb0NxRSCI63ssJGgRSo?D>KI|Sux>UB=ynSNTJ$#~ z1Ovhhno|aS3d2}>!Y9Lc096CB04uZ`hfl`e^h;aR6foQH53wW9n9-5?y5(bsnuh)- z>!)_wN%s)yL*U7y*-AW?aKm^e8T zV#3}Cd2#S%OHRXhJrlmabY$SL=V5$3FHmuk?%K#&wURe~G4GVe-)j{McJknB3Jq>y z{n`6c(Ai|#|B|A8eWS@gW4NXb{~8^_HsLJa9{90P&5)1C&Ds2NrrI8MRVqD5)bct? zJwUvor}Z{FQHV3kk%WuhC(MLT4Z9_UPkJjywoa(8?l6bA^EIi1fL0pEi5DvZT+Pn_ zk9%D0$tJD$nbm0PlVaTfKyXyU9C-P0H1bHF2r^stjL4G_qqbTpTrb-aRFx#`RI$rZ z2>+7=I6!^>Kjq5);M zIc&Px9A+;^3P?!Nz@o~LPx;^T)yH@{V-KPOcI07`J}15ymG~cNIy=jkQu5)FAQ=S0 zZwsm8QLUu_5d`PH2&i_zTaZ5Wg^I}uGbd9RUs0pr*cbRLxT{sr%(gjTdOy6YqkN`e zp7kb&+9)8jl5DGFMJT>?<^7y5FZL&aHi4_eZ#~x^uc8Y2QiwoBrYKtoCV%U~h}KDi z+|i%4GU#w^TK+D|qM<(AsTubBwu!ix?X5~%m>c)azNX#WidmGbaIeUp zfAPFD9o>!PvL!l8{@|NgY(>pAxLqiACffSaC_mX@jexg?OO>7d5#XSK?)~{mPq3h& zY2^S=>y<85f)TF5YJx-I&Bqo$zJypm$Kjj>CQivn4DlUpN|cX$j!~XaJY{AeUc6Wk-{4#P?)BTfC^G$vG5sSR_GT&% zTd>NwPlv5X*ME7po*!f2fl0n#^F+~xQ@?qOtPXmy*lnlIiuRv+q+Ef5UBb)MzqkCO zci5K_683B*SEhwL8`mOgV^;m z1J;jtmaq^bv!s$9t2J3x+Lfck@}(*ZtAC`VzZ5TZa3-pHRI3gUpj03x0D}8KQ!qh> zuHim9-R{JElMo23v%k`RlKfr+zsUXVlFMl#iK5GY3`#G-lMaYaCDW`n9VY!SInMmV zGO#oeY4}JIB{7@2CAc03Fp<}8)vH?iq&!a~sd6|~F^=49lV8kL2K=TSnc?0LBE+P-f z0jrSx!Yo^^!{anWAFZMDX=pX5imPAIYV#>PRLWAO0#ixibJxK0+(hWMFz<2u{C4lH zT(twOER^lJ#ep;Yq|2|0anl{WtY!L9Yst?pEgnz#N8_jeCSY~=pWmVw&Dgfv z@n-Ep%jK{yY`ixoEIYd3G4}Y|>{;uPLk*=qP=o_+VXxQ`T~!99%};-j9(9ZRQ>CPz zJTGWKowQEzt&owG{P#lAmC_gyTcIN_R!pxdiKxKG=B#dP?H`jAq(HG}!p?>3+WOH^ zfF<+m5MES#jUot;4(jYeMK4r8Khrq9Cp}o~di?Qthp#Jj^XI2vdFn+doA=G>BrJZsm7DDRi2f8eqGOLx=vf0DQA2M$Ys``9lk-U`atv3b>*QD z8#-l#V2_$zEPsG>h%gOqHo%GGR7I_G)KLpe))E}%b|9IuZ zaEnbXvT* z`!VgL7p~BL_??<@j>RkMGZ`=)oJ0t{X`ol60?koJM4|~lXF$2z)p^V6Bh-_KM}z6_ z@tjpNKF+M`=oXa6Q5M0DG%3n2bSBu?doi(n?9`;}d*2<9t$-U77>`F&{)CVP@lMS;uB<9)%u8HhlUSd?v`OE+rzZ%? z@oe%~FoK6R9vdeMlVZfmayQU8j8OS7DAUB^3!!7EFkg=Ot)p!()qOJWgiC;3mrm%aJ$y{`mPS6zp}0W`3fP#8-c-H8WZA z+MUeeyg2?sbw69oRz29+S{ZQ9aPG09Ga)wS zgb9+3_+F6XWM_lN#3O6arvL_8h|g|mQP>H6 zyQ8zv7kN~#t31Mwt?S*prCDV6N&=fCc=`JcnZikShcWd264;$!PJ=)$H@#?oy3{P* z`?-Dcx^4sWB`?NqiSY$_HH-$$rBrY*m}jrrc0yG^BWL^v(X`J|xe|g*!>tt!fAJk7lIb$C{$4 zuQ@U$Ja#?RJ!Bx;q&X+wAnT zbz@+aK{*LINzdH2uj({iY*B>v*%WFsGX5->4x7c1P=46{GvzSySewy(&dUA@Y+G|t z2x&sZpt!*9=5}0NM70BYGMvSuFp$u!r4CwBOFRvSQncRI(O4d@`FF5$(jl(rDYmS_ zG+)Yf5h5n(Bx@0*2q9lsnLf0kzv2fl6YGFuF6x{Dtw56ue zz5Rtn;k4(^I>4tF1@B&J3=F0P++Z0O!3BvOY_dV?mLc$hYx()WJCeh)XI2eNINE8U zby+7RETe&;|Ki@e6w2$28yo}fh}XyjXh5p-FBPUF(2{oVLbHyK5Vw`1!vbfaAYZjZ z`uVvQ9`y+ghEgLEVT=3%$`cq|f!e8C^R3-IozjSf`r}UcMyPyU^T3Vx6bF{ZSmsKC*?1cTr zEQE1%ca~R$8c-BdBh<4v553^J!Q&-GP=mWiY2z^JzU1z04MTk%DAIZ&pN8C|ci2qW zlED{cWyaPMQY!_bGz8whAc}DcG~*b35&jNIWx03t_5}`;Fl* z7-%(a^#uXuSEn!g0ieCs&D(j*ow4S-^Y#Cc%zVMql_2*vvUW=ZGW%nPL?urlP&-)=q!UhrkbDq2y zP+Gz#<9Mv7z{asiyH>)w(Bt_%ulzPUnaIuMVOjp|@P1)yMLqw0GwS&bsM3%E<753% z9KKJc59+Xah9m4xCeY12Ve8ab#TY28@--_7q(SFKu#7A)NGJ!lb$;M+bwpNK!guQSM7n3o_QUCd)th>9Jlc38;oFt?UeE zS?;ENk^}Bp5@kMeZo@r6_$sHlYYK86P*qExV|Q!ww;yy(&tg_Hv@|##y6TVc%yVa? zQSS!`nADUv!|h{=LK?#qVv$KW{go2?7B{Q@W<0d=hUFm?;g?AL%IZ}UkO?LJ08M{B zuOtbId5$YV+ZOTtXv2N40bDCDz=p$jC#u9)`wO6$#Q`OR zMd*}n+eXNUO-bDN<`7U&MQ&=IY1BM|6$NtqGo*&GUL3YLdP58}ad86CR2h}<(C<~Q zApRIj}c$n za4`kssB4>SiH1OX2e2|A4Afe$h=xaryj-HsgycE6}O^VkXZ))5MZs`QnevtBxm`!$e}aqSE_Pwhns_(o>}-zHc4*3Lo@u^XktGn< zbaLb>eX#}oMbvuPBm9AeUOFxghsQ?y9^(4*kd1<%~J^7Ng>zZv0hMfrqohnApJb20fb^ zNQZn**!{lcg6QQgkrw&8IT9}XYTMxrx9$F4NA^oQ^vU|^`V^gaM9~u#&K}!Tu@641 zm*fgnnoD-~if1W9v|nM9>(;m)zj{d7d)td})T+6Qh&s4iD!w&p?^p=-@%8drYS{0u ztt98@;JHo}eQbXrAud2k%3?h*E0Vc#3vT7)Wc(UU2eJYp;=7HUm*!6#oWT8fyy9@f?iimCswyv*Wj+UM)38f9>^dSE-cH=r|^Nk;h_~Lq#F_GjkgI-PfZjI}4uf zrCLdGD>d3Nl?D&1rb>;Ud-{70^Vkfhst@bqQe+szk~=e0jK|Te7}gK8E#=M8^L$q* zNGY>o3|y7SGd#by>9oO%*cwf;todA5S`6JCG`Z_;y=mg@w}zDye0p0h{Y?dW!npo@ znvlm0w*8i9ogC>9Bo}kQBj`#T}2ox|Q5?>U$_Y+HJX0LOGL%M5qn(16jRS6@9@54vYj7BfEO%6R)-CBJ@TN2vA!HdDt7!QM+jc@GJl1qx zb)JShLzEZT^{m$|5)!nl&2n4VE)V}{hFWti=|On2YRPS4H!Y@gQNke8EN)1{;|QlB7KHw*kjW;w&j0ck_2lGe>f?>6KM4uE%N z!=bTaZNO`A&I%7dKBMU4tI7g>DhfLWMI=*Wg7QkoaNnD!+GrkU8WNEt-O{G&|3M|L z+bU1v=UCrH^Lp~?m@Ri9W^-Z5PAs(FM)y9N(xNp}ued<(x?c3%T-}pYBW-^2T~jfA z1(%{EUkS~wR?cMl_mM8kKr<3X6jgExHW$lw`8@;bx1B@p1Fy3td_zITjniK=G!Y-zF(+_)YZ!W2&?2(A%Voe>41_gy(DL{0!>fLzv4g6?DVBXumm#u|1!Nb1+dX&Pv z9I0Y(+DroGd9BR1v9W`5GMX(D85O)gCm@opbTVxWDb^_Nf<@^$T2$kDj zy&L6z%xlY$W3Cj4N%5)Qpoagly+_0I_{d~KkEOmDOFJ0$#hCiMIuMgQESf@?DrPi; zs*vc46!Fi0S(U9(G2KAK_3>}|48KN6v{6CkFTCFNV%Hyagj`&Z5OuXML=zR>y^2ov z856p$7%ty+5_Bw-#?*|YBxI9$!bkGv=B6bB$IDZa0^?d&dBeK-JVX>nZ|Wr>6@H8B z`PErpl4vIVCjxpjG;#TtaNJQMVg3Id=CfjA+9aw#%-`;%l(cSpBpQ;llO3Jg%=wXB zow+r7&!FyWsDiWH~5-!T?}d8N-Q>xUfs#*SNq%@ajjN&oCbGuJ6me!gYQtexS|DJm_99f(Kf!(%EvIuQwlBu!P<|9ky@Ni zr4&7J@=tWq!lj*japqSSb5M+MaF^fYE9N%SQU7xlOAPi#os4>@000CHKCe<0dhO>P z=Czcx=IN`;{Nf}hLjvtB%zDDt%9* zE8C}QDQf>IPSDKDM`#`VC`--RF@4irrh;X%+T5L_XzJqTw{-}S0M0KzKPTbXiQ!c~ z63EfRaEnqE4FQ6)sjrq1$4}$`sc9M#bSirw{(58iU6q^qA~WXYiP_L`pQu+7-^bq| zI8w?;L%Uq1NyW%pdpM#6#HaDq!87%-70q;k;$VW5RngTBG+YCv69&Xw)xh(Eh>G?A z>N6R9o>`rwlfovH$!BTZ`VT)Hl(c`zhl7d<6NJUqIn)0#8}w5vCtP5OrLNHz718K8 z;jxp791RVn1a%acWpn%SPtV`JZx-es3alRt7MTXekcppIDa~T%#91BhQz}|}7 z38Y~#yZGz*5MvwnzT7Z<)GV?XS_#(UYvxBisriZ*)kE^+l+v1H?5LnD!7?<|fh5S7 zGDo`w0PVsuM^TK8!B1VQ zJl5kUS3H6gZ2M-KiMO3MzMJAl>LyA{)YB{1*s*%(y2~JE<;%BEd=0l!A4K(VcdCXz9>%RVrsVojHZKo!-2{5;san^p`G|Z%PA~n>3~tj-dzl=G6*! za&8r2OpC!LMq^kgdYx~|+x z1_OcHRbE{vrs%Edsu&;PuP%jxC~no`&d6HY`BGzElHp2PcH*F$H_-FRx z$nnc3odln>?EP4uk*cu`NzbO;WO+_oSJtTCHM%gb^Pn(B{;eM)n+tfS(C2C1-8OBJ z%;KquUUOVqO)#B#YB{fWurhAByRl{T|EAT(v>5d#v<^!mO%2Tz8D3t>e<;jHJTqle zJ>qrzq$WyuEG2PzeE%X+kwkpld?V5 zG=2&|J6~TF48r(fP`4zXs~@}LAGc&~N`Vd*sMo z=m>c{i;PF1GssX1a1>_I=X&71V`Zz`NyT`bzb8g?f2p=6?qzmQT?9 z8DC4^oF?Aa(>ikMUSFlZy`nxsCs(J(TvKJp{3HA@y$%E(wH=39r03SVSreH4NUL^Ds(?7hZap#qw*U(%RtP}K zB9H!noE^Sq*(w&-U{6i{=naF0JeGc)u+-Q!92F&PVvOT`qvh4T0PE1D8@wD3g!@;N zxX$E1IWYu{CAVk0;gjQMyGF2RawsI2bQbmDD+ARxtRBK;$J*svZPz%AYIz}2m+q-A zVtm(-l>%e>OtMgLLN%lf2IAqquiEGic%U}lg3G%uC3+)fvM~Cgv7j|az44_~cQt&> z)v&0v)l0ZEMDbjOROoFWe&_3LM;f+Seo7(A7!R){x+@>e2j#vDw?%HG-2|3-pA<4m#`nRd?uJ8|TPV8HB($`S%1C8kFzSV!) zu!?tUI=+9L$$oKlXMh-gBEfV2xo_J_2-#_#@)B&^dtH75FgFiics|s`$)5=O1oq@D zcH}Sv8qJg9H<+BVvU+?ZEryxafL|~6dvC@ec__L-y`aSy2@uEPFIzTM_lfFRFnx6@ zJm=lQy=CD=gJ37ICmwu->P&MX&K20iva)?QYq+B)H>7-0gM-PCVl||Az<#(R-vU90 ziNC^0GNuD}H}6GU{3AY@*Cy4CEAcW_><6->DLDh+83MuC7=FYd^UtqSv@TcgwnSki zgD7~4)yYvs=Bucki|$B>H>PBv5I!X+~XK*da`( zoCNZ)F1h>H?iUJ90#@Eo>L)IwMjO1e30}#E?!4IOMSUDgg%1YrSSS;@0=w}%pR=gM zJq;(IAUTac3Z{(4p~Uo*`Uvr6ju*1rFw0Z8_Uixm^>Ej5bk&>7d$tg3Q4+0|a>DBl z@e~Blt^Q&J=g@>vBARHfY?M%F3ysZPsE@u>3`Zaoe7}MF2#v8wUF;OtxZ zdAjeNe4askq*LAkg}rO|Qld4s+j1Tkmjxe*-wtQ<+SjjCN~m^QQb*q2f9KNz!^pno{y~yii#}!tG{( zb`zDu!IHPp)aO(Fts7;TKb&JFuQB2{fa2MYFP?5^VF$4oazkd!weSk24u{5{a~efZ z=u=dB=+@!YA2LSVPv$G#2N5b!9kKXfz>F|a!}$M*4+&%W^GFYs(s6XAs@3Dam1n#6 zdP**yeAj(-8&!A3WPIzDNvB-D=W0@_6Z$Dv?xAO8W1n5G5}Iho_y{$>yM_+?nRWik z9YtjimDLkd%k*&A_DGi9&%gcknF-#Gx(n}tj5GGk!FZgmHpdR3eavEJut$du? zF;r#QA`J;9Rkiwyi$%S57r>*$wpZL)9V+0~8boR4Iz_GiwiVtfDChPzB!d?{altFr zcL-xv@-Za_NDIFkPLyT=mJz>Gp`%va@?MQ3NEqVrSg*r!?oowq{Sx4ToLcm8lZ^V1 z&N2%*Ekx4stU*0gk=M9Fx4tO&Q7$?0N4GfA+RK>oSPdzpN_^niTj;kS5KcAf1(7r| zAvHfFXW#a%aAz#NXvSt)CP|A-5oAx8Z6>@Mav{s#1I;nz=aV1wGMNIg_96JaVc+^3 zSFxZg;d;2NGuzFQO~;kzoz+Cnu5<@HWI^UWVoT1$)Ld+e9Ivyufx3~Sn2y?cFC}YM zF_50U`Ne-_e_La;^{Qp6qpF@~F=g~p?2s}2sqH6%h-xrfsK$FftE&3X_u~4UuYa;k z5L0{mutzRHwvA`-)jmYe8xM4lRGE2cAf!*!2*jlOEg7oBF)-$2RP5WU5k+wsGD zKC9G;(jnA4L>h ziWo*Aw7boAqFOAFEgv=q_<0_#-@T_M*yHlarkM4`4{jTOoM`AnNv=Kdkc)6wdU9t% zQjHz;2Q97pNW?~rv_$afhSyHUE5kNDkILwTb^Z0#{YX9%4K%Ic%uPeVAeVymVNv%m zON(;7f<5o=xlJdWp265o)!4@6ERy+y?2@38cS>sTbX%Fn`gLKsQb_sVrk6F*E~n$1 z0xj+@rg9=3A0mPwAMroivknAZ<+DR>*Mmm*<^5f>45+f`6#UD?kun#9muKx`091fX zOKU+I?|f_7<*_jHgM1&8@BP9n=r7xypM0NHLNc&1k0@|pwKZ0!!2YncJ@(adp5*?P z{97}xgP)COzE@x``>;oU^HJM!*7Z0LtqD>MeXyh?cKanStD~6T)T&+aLPuXx@~99! z2$x~H$LL_PR3?=Zo~^nfPcEt;N{e-4$sonexO_nT_S!-Bi*{J!D3=Ya+g~RZhHNY! z5|_UsG&w#0X@~h-?^z1p}LvNgyGGBRZe_yx#Wko*V=w)lT$dxA`JbxPKA#AW|4MyWlP((;;Juf(}*+TNm z@4=`rxV2FZ79B^zrqMl2hyxLDX1|e+h&));`}M-({0Unt8QShlb=D>V;38u-#cT)i zY)ARdfYo9U_YrkV=IyuT(tP-^bHvMc;kDM6HDZZFboGe}EyzA+8s(w`L(mh|habzG z4&ix+pMR<(G^hQQaB!o%9!)Kj93Op0`1br}NT$=Nt9fE}-jh zkF^tm+sZ@HzOPsX4pbCmVwCQLs~LcUx{*)H7p72?w>yokapfbf;;KYO(6cT!%_ z%SV>a%)>}{BT!ivN_oi^KZ@wRi->1n%FXF|l}J+~k)2S`&bRNA=)J=B7457(FDCvRo;Hte3T zVkWS3s!Fonv-46DB-TjUs(Ej`1*7=tb49oJ}6$oxVSQORbtIz&^XoqAc7hD z_V4n!xc323d^QJ!l#i}?AP=8RkVVLw)*Emyb>LvER1Q;K4U351k0Mgp1+8IbxC|LS zjaw0#HkNhc#EeSK{z6&~ux<(xT`OU3hYm~y;;q11=Pb5mJ zoB8+4RodO8pHWaofFr4)_xaCR%Z}z@FtX6o)6gh~0WsEXNADH>4f_{RXz==Ps_oOD z^2hG_QLBmFFx5pROv%oo)=kvomRhiE^R)K3i9iT_#KUWY#UoM$1M`R2H68r%aZsj; ziK+pRmOpvu!Rzo3b?TP*-tF_uJ>?RiI5x^;bkp80f8AxvMJ0Zy;`VTB?iVJ?d`$>) z+$&lN`?BcgVa&?g5|4;%y4QMvdot z5kr+CRShW5kA22JuOXDboWXZ5uY^6ngnSAF%HpW@i<6D!Xy!sJqwck(AWOGnh-XqP zng-f_@+ggAA+Ga<*1{JV!PTvsd`nwlh9kj0av8Qow+4KGX%Mi~Xk{uzO6Kk3y(!qHXPx`bQ$cW_=D9 z%aEsXRWlxuh&X(idNNYgSEH?mr+Y9K>`Ok^^89{5r+P=aiV!~Q-q#U6prf`ei!vKOe<{+5(|ImCKI0Aq&{;g^Cw2 z!`D%y+o`68odC6yl=#8}(U$lJ6zY~ouBL|D4|>wNa%uwb7Xx4vZGzyGUD z!QX*u8dK32wbLB`Bau6=+&vVZ_k&?E)@SvfwJdnEK8EgS|tc&m!J^s{)`o&8TxSOz92_tFwd}3H8`n`a zztdukmh1SNr>un1Z?@FIRgofjdHDbaa+UV+%c(Mc-Ca)kavluAUu#*T zfj6^-koQ&Ova<^QJPFgsr`|pV#MEsK@bP1LVt3tyS+#yh6ffkBXz(=bxCgIR-c^x{ zFAM`Gq#mzPD(uK?)Pelp|J|*7?!FvCwRhoxw#d!*DR0KvT5B1=FuH8JrqMpRmw8lx z%+r!(P55h%s8TA5@{S2>-dBnBeUU{c5hK{&ih&2b!;DRq)XxH_+m@iq@fC%&^bhE* zsUy%kK$IHe!d*VyAT&#BATgYdyh{9&O;#Wc4IF;FwUbeY*~M*cW~YDq)a&K-n$qZw zY!zO*k9TF`m?E0XQ%}}1U;dr9c+WpcmMmgTAbPa<<8*-CL7U}io*TjE938Q{`E0Cs zC!HFC)8l~JXYReq_%7^PmpjmZ-aj>aJk_g^zP`q3*EfHhf8`Q*6&hUh-i@aF{*J^Qfu zPID!H(~R`a11j;DH@#bST~~6dQVvIr6OE%T^o1w{o#8O zEA14>1<*OYpbmkjThy<_StB~PJC4hbW9lc6tv^8hmIj^dy8hnD$VgthOn4{lZ%H1t z;yr)I*Az?;BXS_u0K^wBD77fV7FQ~Qb;$x6sSpv^auZB=mWXIKOnvH#Lhrjs!aK}= z(BnF3pd9>wzg(vymv7bJ_U8Slwbkz$F+5UCN(#Ldn3cA8duBB>-0OBuW!M^WKK2j< zj$IPLB^UVaFmmr~_j0Q03zbAqx7bvraqhTfCgnGSJrDR_CutToLhWjj#h&t3CjO1@ zmT@smNfd(gW21d>{>~cDwad5N_Vcw%48`D}y!5=-kW3pNhVi$rWIz37_ERFx9GeKw zp9w|ntM|2I=0fD`wI4Os-R%7^B8r=o z&<-+){H+XCRX%LiXkS`aUo5%%D{BEWgxSx)or5NoDd^H#(!?(#@ih!HgE#z4Z;G8QtxR>#A@7Ubtr0*$w&vP1}dWDI}-8sXaV_Of2N10 zfyv$42Q=ewkFSLCxAJH9@EV=*yGf;`jFlzhID2km;madE`e*b)EY8kI&OrBWE$)D+ zmSi+>IZ(-q-ZE&b%{v3u%n^7_Z2e9)c}|JzyJhoXO3=_b*ep| z#>ce}HFxaehpE@A6XQ|T0S?d9DDZ=}EXTfB&Np_H%l4I0Iab~+Djo0#-F3P*U2hHy z4Xxi{prT&+w&8>2_nPPjd;g)-;Is%*N$aqkiTTv`7YlE9p6vmi8G!!w0FPH`A@G0? z)>w)Vs(oXA1MSJV?292K%vBwquJ}55(ayWLpk3B$zuDEj?sxt$4R$(XLYLQro9$_Sv+27|z1LGUfYbLtiCOKz=LsUn$)|lP(rV!xQ(CS4*;`8M zCW2V)8{A;xm{2(PP;k`oc6* z8=VoSep=hLNQvDUOk4k!PsXGwYbo(wEU<2J+ks2Pz@Cx}7f~u& zl43g_b^l{BZs{00FF>QYV!0~fyp^b0r#pclHwsBQhox=gWoJ9O+QmO z_#R_2RZ0!aenYo>W(HBhEw$kIU09_^W)xVHh}7Ew#NQi9$T1?;upb-0|XGI4IYT^4l1ayik{_-int859^7O zl-J8OU+s@QfFtZh8WRacWI-V~P4_BM+vuq4Nd7s7snd8#xd_>%fPN*!lgID=uBJ)g zxE)FE`qm~J*Elgh>mdJxYG@82N>92Vg=f`xwk3DOO)Wa12kn`?G1EAv=%6$^0}ZX5 z7yEr^YPX`cYco^U5wbCMU<{=&=y!XH*KdMQ@V(uG()`R;QN zMJFVMGy~r^Si0P_MAsCHI0+j`YQ-`<$Esg?Mq6frg88_LYV_-H0p}G0Pu%gbt%Eke zeT>nu_}*%nkrc6`VrnT~k08fNLPg-O0SjECb4+F1kLu5tqh5PmpR)cU9;>hb(J=U7 zA_j6`CRqq-!llzDM20zfGO&1Pzk`gJy*C8<#BG$hA8PSYUTa=EX;h(s zl0tX953Yfr13;`jWYwnfxO&G>tj^fz$~y3YbN38-g=sj?Zx!(nme1_IuaVYi^71N8 zpzj9KnPvUM(eZw}mBWP3gqF%xx$*W{p7&lE(r&V%@pVvgfo&^Q8DFMnBod?#Za`7; z>6$CxkG&81AMxvUcsjIU&t><%BM$ddgy4w9tLp{IyDCGUUrI!7?*xl9V}rs$_7QXe zeD#LCq`BB%HT-)x;6L+le7U_fifn{nib84uQ_|#g3!*4&=*aAJ?u?G0^14xpzfd2G z5|z?c{k#=N!#tQpr}XilFkkt9@6`(3rjKz)t1XexS;A|Ejd7w6LXuRsdim1DS7BV3 zwZ!N9tHBS+wX{~Q?DjOpQ00L>(kxa@rKS0X`bYP4vK~HGg5|X^%K>4?&N5e8 zy-{z9z1^HL{i~1NH}5Qb5)ZRi!gpRK=ym4lYO?tAXvrWeb&ZPLx;11Zr|pCN+5sN1 z5+;wbffyyZmDETA>fawWa}zk?P6xc601**zC-rQeF0dOHX0%pa-|(rftYZHvCWHf3 zHl?tM!p=L!)7~Js$8QS(vuWi)JbeliXMR2gC$;KDTv*N%{s<`*VPa0BNB;Vrr3DY; zZ{&;HIE}C9SKvKL)@PwPnkoObV#Ul#;nlJ_f(;ztS^q#Cg7 z=hjH|YGtl<)E^7WD=x5*Bz`H-LensND&}%AFVcbpxW)E(rrC@_g$GTToS~tBT6}Qk z$;wN}4wvZtl+1Rk&@Aw=+|}+#R+(GpNJdOMn)vGNO%~4?Z4?AIIt=2VPWSKndkzEI z2i>2Yr(IR(h&iBZAaO3n?97O!pMP#!(lEBs5#x-o<<@rL5hNVIqf-U%o^hSZD~Zqh zAaVEE z!U=9N)Wtk*%>Pspf&8lPultwKLtm)Q!#j^TM0jmJb(&+k^Q>u!u)+0#n9Ez3-+UxD zZ)pWE-=g3woFAL*a~S;qa&lwWL#DAN%h7de-&XziXQi@w7rRg&YB~HfB5P5jh=*;u zcgbPa>Kc&^EiUzKky%?Q;^mvG`zPP*@aKo~*p6>TCredST!Yc%S_gag$fY#V`~)w} zu|26#&W?w}4kldoLi+0Y`vL-(uNwoe+hle2?8W=Y^C1!!_uFwkzQnud3=CU$H?P6% z5$(&9*!e+jzDZmqH_UU*gVFa~Po~=VS4s9(vo8tBS|&j$QtP>3mQ=*4yH0@NozMFn zd9yr#>3d0dIV049UT4~OgS!l9Zv_iH9-5h982vJ`Fh-IaBYV#ed%vte@o z@l;M@tkZtx_!Nk2VOEaN|189B--37ALCn#jH@)`roF+J%rIZOV`< zjb?MHq$rB++Dc;;hs!DlF*@<-xAxQ}`fXVtY6FHb;9)JvS46R^eT6qY;zKcM&PO2J zW~f2D06ZBz6ZSSxd?V68x^Ubm-g9yHZICwDiDLvtHk2|scsMBk_wR)IEN>zbfK|JX z$HQx#6E((tiS2$9|5Xuh-7dQ7e_ndmu!=`NO~IqcIJL>WvOe+c<_A2qIYD4WBK9vSWNNA zlp*5v1gE=YsA4WJd* z6o$77x)wg@eA57LY@+zQG1teB;>Ye+2b2pgpk;-m@K^rIC;jZ3_-9*wqkwdOCiU4) z<5^wbdRokW!!NCbE57QF_j*>+b@;vkaujt5HJn8J#NYPVhi$nh@fklE&*wbx6G-R! z|8aE|d{t&`-xd*&mPSBAB&AzYX-P=|X$0x+6zK**LVDBPDcvX`-3`(mo90>Dac<{* zzrf6I*t4#6u74b-t4{1nw^%;}JRvBl>5y+vw_I*Wb4L82(66k!ls>t&j7;>B!9e|X zZU_QrT#w}dC&Go#DR^8NI_nevnEgQx?sH(4V+&hyTTEA8cr_!y!m8J)@exnjzr6DK zEm_q_-5h9-!A+7L@@ry4)A{Pq2rrU|Ku8;vYZj#r1tqf1{4v|x!h0lbbG)(C-?#Ww zqN&9u%DF9sd5N#I$r&~V-kJq<_$%4)zT6F>QV={F@nPF)(aXF8*Nq>_q4;k6r zVA#wRU0S3_V{CoBtlp)&L^V{aL?XPGN=iJ;jKM zyl4<|$@x6_CJ2<*!TkLaBVq?|4d{}S_hv}V0v?Xt;!z{0&sFonm!(`e{e-#(Ce>Kz@d&Y3r3)Dy8p-7sSBHA z(h@#8KV-SP<;(9&TtBVUFzJU}y3=Gc2n1xO%Cx_`Y==19(M)GdSN29ObZlmq-rnfV z)?mis(A>+zO~1LMa0e|<{hx`Wnn=u+Q?;$b4Wr-5*uR-!W#Z=qfAzfZyA#~W%WFfZ zBU!R)4*MzS5v*uM|NDpiJ%d_u7e*;9)9`CLDbC#+G3#6o&UYQEx@2Wbp`R{{h5E~E z`=Lsb^2L%Np5R`?qM)2o{N8PA*c>6e#YYAF+azi@m-(Z7BB z_6hMTvivXgIRY}t^k47yK(`Sw#f|spKi%-gs$RDBksO}1-vdc~ygQl8Mz^_q6(6{nPb!%qYPR7@9_QLxDcipaSKh7B4@@V?XZC87(LlA++E(p!cZo z#r0S&FCD4(pupSowl3E*hT<^NunkcDG#xMOxN17@Z$s@fH|R}O`7{R9)x7p&#%nP% zknDI2@D*-1u0vlJw44r42=BzheveSf4?IYzYly~tVc!O?kj)7lo4U8mlT>VlVVmBv zLnZALHWM^E2Ce@%w&>Siv=zm)n4B74fS5jhh^{O?zJI*$17lK;$_|ZFsk>X2Cp;fx ze%8m&>0@AgFl`K{c^`o2#Ya<^bIG6A*0)!mc$pwwgt|RuH{pj)1k`<)=Liy{35ZJ{ zXc451Jha2olN)F%uodzDrja7OB=R@Fnnx0TN;y0WyBh3c*jAL$8V|ahsnFl?Ej@Ul z^S4P1&wROqw|MPo>y23WSPet4wwvtsU*i)ze%AR-pmM`HRR{(OHW(pox>mGL)oJ(V zR~enb6+U~M^Ayq)^#p1HoO&FAv9%z>+k?n|n>?UgV*{EM(eeUra<-FyQD^@ZgjGSKOWwn51 z1NsI$O&l)bGMnbw7Wdlp`&CchPnIrxa_o-*+u+>bvoBU(r??vMISs5JMUiJpVESSb zBj!oe%ZY*XLmt|HvOaeL(w9vE{dHkB8d}X88vWKF_BFB8>N-y&g!}$IFd#9q$-GWd znA*8oKlw;|XuUHfQk9wVbJ~5PSB|_5c@*{_`RkS5_MqzOJ1xj+d?t65bKVyT(>zQD z8u?|7*A99m+6}6)u`Ii7i6AUgMvMj`%X;k$Beh>iLaw&0ogDUi3w_T&{j~&F2iD%_ zLEL8ZAVv`s9zHv&}q zyXwr^9exAJz#+Vd5osdhdH*o;cKK(*(X`VnkoZq(lQw-v*Ivd|7HzmWP+jvYie-TW z8Q^^EO?@ztkG$R%!c=PCcoK~A3U*otx^GDaXPTbC-usCC%g^ZXo_2?ey@_Ko#HT8J zBm=9BQHfd4Q79c8x-T`lo^zx`ggx%lY)ZPL1ep0~8DxztAfdPG$P zicmf^yblh_d>O!g5T9j4nYg*|!Y|LG%IxWM39^TKGBPgc#2iBKb7g>+Ly!8OGuw>G z*U)VWiC3>^;1V&~NOpejPF-G{V{qmw>=#sGoOT%J>XG6(NSX;Kou6bO^+t(-Bb7(8>)A zdc{$IO6HuVIXG)K`kh8Ma}9-C#86RplHD|gAwN@DLGjgp{;zW?R-XxrZxpFz?%^L- z3xX}^#sB5@0<905@xVkDzUsgco3IU)lR0ns$S4$yIjjOqH%sOx(_Du> zl*|$PTEBK@>FNhdEVZ=b@`~l*?0K}#ukxRSN%MS!-XK5UD=7LXzSd~T$OH-rtCkA` zIl3`iYo+{1NHm_hEl5JOUi0~}1%yr%$g*MGV$ggt58NT8?3Y-N)t4ry?qutnCfD^$ z2^&QeC}29u?3CJf)R|iLy%`z}RFK3O0)miwkw)I2^|blkA>b7RbxGqYmxQ>Y8nrgx zhOH))i}zP#EBy!p{|)wFeImWxub7KQFqMP&G zn+z=KMRi|75Cs&`Y`h48LdpCM2(yf{QveUh!7suR(*)o}wD$5pdfk32=6;cycc0;p zdjat29+%`GC0hdZ^ql-uD~l-jHPT|$Wrc+(3Z_y#;S)8>zuH^LnEj$s5Zu)yU8ONP z-;~U4#HMqzXIc4PHlq`P)|)`%@)!z%5;9>euFJK%RD|uD@c5a>G1YCi3EduO6NHmt zCWx;bAD>+8_5bW&2;l~Qhx%F_^1UKiFuB|QC-pd6$5ujy*w^4m_-2>6HKv&RtwdSW z;d-hEe)!G~#LPRHkJ7e}u;)H}NbxSOdF#e0;``MgC9w;=4kn`cQ4=tXW`Yv6n``|6 zH5fuXuAc4`)dXYk+=2%MR>3Kc%UF1-kuSv5cr&bc$1dcyV(`(=d^}w3uEuX97`O5+ z*{@@KZyF57i6c|ieLL1VLN;eu7q_ksoKP@Dq&SI0!->1SK2*|3-b$wiK6k?2m9BL17wWYDpUAqG-sp-an^t;#N>Cj3n3C{{^k+GhaBN{mCZ;U7 zA~J|^UygjW74-L9u(3g(1RLi;ZN=CZ8;jOkCnnJ>A#RxM}O8hB_ z+?rI3mdOF4^Ekxld0EUeU9!yw^0^WP9-4Jiuse#M(*{Xs9Kb6vP52cvd z&OO~7L;*no+7u!Mc47Yu4}0Uemz#pl@iD#)MeqKJmI0$sSvK*g1PBA5ym+XKe|7pM z7Tjf%CvtCsWaB_F!@Z$#^J+icWf@sntM-7*j+!o&@im%4U3An6l<>;&`k$!Y^klyJu@T`}t_z;? zq4@h=tR6eIaPnjbQnVN&BT*o03*@NaaSn@_BZw*~zW;aOzs{ZC4-iJlmX%E9BVa19 zUSX2J4~(cbG#J2P>d9Qb?08o}3)1>^&a5CGj4hi640Rt6`aEbBWQ%-4(x*{@2ug6^ zz~04X3+rnlG8syXuFU9|>_!N9sU5D1MXdkuo{Zxl9HnU4zG4g$p!CU^AcXD{6q!s{ z+D!OqfBqn)4>Ac3As~5=$Ng~OL_fpYMktqULg0Nfl_Ig;}Ylg)ZJZ%*Hh%Ma0r%U&1WjT<2 z$R5qs<84+LwVH4onRQt;c>_wE;AUw#?%umDD=zYh)#W_MprO0}tGBTfEp$n|t)2l` z23_R2(gf2u&+gR!L`iK!G6z!_E%6QMn0=5aE1j$)SC14!zH^c#iL^k1oK^M>`4{X4 z>$;CO?G2kSwUa!KkcXo`JKLy}5WgbCBS4y~7>reWw)Qm%WMZ#OM3;;pD4r;<>l9Vh zqq@2Vk($o+v+zy+<|VXBPX}fZF~xV8k6A0D>-*N#2a}dw zr;jV;h?-FdxB?sX0H_f=i{#SeoMbPay31p~5H#m|QV`0x*udHw`R%!ef7Z?12xaxn z(^+FPMUikSSl`t2qK4?i?Tp-E_!&=ejy3M*TxIuXx<7v~#}zQ~AJl~91Z(#l@K8KV z)8WHg{$?yKV?^~^s`HKfZqGUwu2*nUihu9hvQHyvu^Yr>D*FCopvetmdgchF@5WprIx5W{didcVPIf3PWlTY3_UjC0-4-W+jB zetSOc!smD(D_%{fHoF%1jdf-ISyB%^&De>HA{ex0jVnp+J|jj8avX_#YEM-$=&%FL z^0k5-5=3~=DqP&}js~UlIS0MdEkPmXg)QM_8u7x(=>5mV!N7lE*w`j5`{UzK+;QOF zt2pChw<>;Lg;D{;W39}ht|p}d?W3+w_O7SGVuz*4R{im3Wmwc)=tFC5>mLC=C!|jr)p5$9Jv%h#!_#PbZ8oDw=rJ93~l%t5?l-uY%Urd zsJKG#Gc(CuuANKhvOqEV5gr+)x^CK4m7T5g=N!F#?N$#t$pe>yD78?zQs>NIam3eU zr6m{*4sAh&TWbf@A+ky@&2yQsH z-yFtXuE-~P|2dal^J{2G{zc#0BfRh)7SmX8FDLx+%*ESu3yBqAk0#^daKEWqbnSwO z{p7#pxi%fg)olK}*%HX8!Sb=r(G@1qzcYCqm6lxvXw{oE!KHeH2V z#p6-8;g(B%QPa*Ms}^s17}udSQ`YV@qCJx4MywLVDLr4T#jn?uHxWYpMpQQbV~bpJ zaPed4zJ#rN!$uW5hYIWZ5&};o-c#3GN_4zj9Tg^1e8Wk?P6UtHu-vt!|1lwOETrhS zM_=Ko5^$NW9v`dq^P7FUdXq6iRV+Uh*vaK|#Wf%GClpNoDa*R}Y9O@tT``RoK2F*L z>ITT#2&TISe&TR~aQ1#rcU}l2d z-ZPyckO_3xH9XN!QuG83bR_A$>#k5=`HsQ&hV+S|SkTXiL}OtBQgQ9AP`Hux!GIt7 zSe_B7WNqr!GSEFdjPNo(O|^Qy)CnKe@#kB%=_2EA32j|iD@m1zrnbv7M;E!>7v#a2 z{PgEc(qoY=v3T;YVqxW$Std`k~Fr<+8GcL8 zb@#9~Ghe~JNW}v|-Xrhv(oMRnbInjk%tXxrAT6WcA!*k9sQ27T1|O};P=B3SA_;hHlOz;w&Cv&ek1*&xKklE~Kv&X$m z#eL3@&GpoaB~dqxuZz3|n3RAZ@-_Y``ol$HueV6(%R0m{-fF{+6iq$F7-}7fLE;6d z-@Iz@wTLU^B9OM9Kf<%O3)oX@Lz{{0Q>%ScIwO|&xsXY@#(eYm*xV8JmVnveJicy8 zAY!erZFR^a3NsgAi3plffkyPJzWDQW9zUh52Vis>g1!bHkd*a`Be3xmSjxtV4KLSWewLr3JtyH_W3|M+kPR!0H6kD+Zu$Gy;= zG6RZ{%?A^lw~DYnV~tCl80U82`Y7S70(zF_^>JytcS=75`^$H&f~GSKzY&2cUu0F( z^36|<)kqt*=~`#nXx}Oy@&>$8rVIdXD<+L9M}JJD7yF+)F#=b1{3X$O zAA4kg-7{p8}{c zB_ln%iha+O3y*8m-!2YWkS4}V;DaPjH{AIm*85&&mL&7va$-rOFOeYHp$)6;LC5Rg zFlk&53UtU=!GzyzIft$D?rPG&y>hXM67iWTanPu_p2aE+5~)RDhf}wCsT+r0d6XXm z+O$7WcE41szt%Uy!PW=Ui0j9d#Mz)@rtJPMnC&3=wo zw5ywPwQ52X8dFUXXXT&=NKS<}13M1pUBhtRlU{9zj)b4XE{@G59bP2=kK0g%qQlMc zTi3ceU&&?odqk7fGDX#g7TTLuQzRleq$R5czk^`eTkt>VC9(Jj& zj`y!Unf9&mdas4;eD2wpKaYc4gu$mk4u{7%-{OI{Eaca3!L62G09`DLUQGhi5! zhW?yy6ys#2=-|7&qx(J8ai>@6KR@szn5Fw_n3+`?Ryiv#_-eW@!8*ZD_N#a^xvzXm zu>YXYCJ^yq`COZoU@q*D4}<>MD$i*_4`&m=foNfpFOS=euQo+>(2ME{L=oOP)LCv) zC6}99&_&Md=saW%O5u>_Z%q_iqoDZjFRAsnVCtguem9wq0uuv&j=L!Y-z{P)E%J3~ zZ8L_f8SQt0?^;+mKJf|cSU(#s!s?GQA|koW@E77yn*}|aN(bD`#7O06P`CN3t)Oa@ zRDcuZGesFgEy>tg$nCIkt$UHkvisk>B$Y!bV(7^!D7dGQrUAzK=w zPyDTPjU$gsf<_ZP!_`(7Tphl{TplDV%i6X5E0>S9dlXXZLq#^g)_rUKx#RE$0>9z$ zp!D+e#*nyF-)@menDt{#1cV;7ChUa$Mw)yoLkfyLcz1ch-t8WsJO$3_E+RwB_4$bn z_Jd?e8NxYzHNY;JpQy}%dtU`_eZ%I($dhp#wa5@BrvC1g7gu7? z@{_TR4$*&#}mO1gP+?~nTy1vNrYzC?6 zW#%JRyF@IRy{pR=1;Mta z+2a3XAS^M*LkKRG1b(D?;cG<;jAJ;&4L~*GG{5Uo&SxDcIhUxLPZS@nCwN`;UoDBo zKFF}KTAgBNn{`;b-A*)Odu(bsk0zG!&1H_mE#S5CdZ(`>R(kX@CUd}kigA9&^*p!J z`M^OxcZ2$9R5D{NlS1C37p@$iv^~bwdBeu?emJfhba7D@Y7=Qh*PFSBJRkE|brk-O z27T8~eI_8tiaJqF!mm>4<+h+CCh(%)D22^bURvZllH#GuaX%zmI&sd=g-!3b<|1AQc#lLiWi~clj|XNizwfai$v+%`Xns4dXumtOv(I`o%>Q6!E|oJFU_^;| z9>I_ZS*6%VcQI}Oz=QKu_&o)X!RoJh5KsgWHJiqG@`zLK(*$n9@}$o4ZKVk)BamJZZ-XugeeF^W3(C8l_hk(zdL#Ed9g{aA)qOH;=yp5 zmASsQJLz57A<835t+0hA|H)*bY2zz+kvjPp$qj@NKfxeLsm2YG#kM{#thF>%(P@n~ z`CKta$LKeDi!I|O+fE}DQ-eIB#|DUU=4g#HciFNEhQDM|X>?fD71if*y3;EV=C_-) zc|{Qv<|O{iL+g+G<&2e=^AOD+Tpzvi=J!4>HeQ%6-kF-xra64ALa*!s)so~SI>#$W z_7u|j2y`WPt3IV~Mp6wkN&)eY65ND4r>oqB5l|E8!ev`@!yx9EBb)bC_jIN z2T5*6ZZV+QT$@bWQmL-FR&9A0HhqlpwuDi)o=w@u7|N4xB z5-N1vf+`wotVF`sNg(LX16~Oqefvvm5%M4)kaZ|!CKdNbE}bO;s&oS>+Y=sgjVrnh zioW6(*watwcg*&DyP9|Eds3o(eiM2sa{3hZs5p(ww{{Dzko20o2v*%VLi|mr8itO5 zK*JK>DVu3-uiqbdWQDg%CfJ8#j#8}s8-MGy&FFi!yM4?f4AL}T-1;TYC+}6rCMC6R zH5Fqj{r%y};62E0jaxe~(bV`j9)i4x7?h(R@4&^Dg80QCHG%58YP2nUMo}oB@O|63 z;{uSYs=2@{onOb)gUg@B_FlU&SJkJ`yDr@a&Rf96hQbg#r^U=upt1F|x4VpN29CLs z2+|0~PoxEhMpSw&GA#{mRH{Xm0i}AP*bpI6w`lRmaFR*e_qUvI!jGiSO;~#TGwfzy z{DqL-Xja`r#z%&{fs^ z%i6(!2#Z{hn3A2XAJ_1tb6X5hTFLlnHAeVvYp-s6{bSE>KzfCY^RRcYt!rX=tFxyx zvl*&zGH~sl*?7fdD|G#)^4CNf8~{%)>eMhzBGV z>N4u&x3z8g9Ewejn9H%0vy;Ss8}S7$skBG-;nltiZwMv3lC{?Ienbze!Gnz1;|P+a zl`R2cj*6Rhmb)Rb$v7N@$&S3FW7q9RShVdo4!AtdUH;}7sGca~dxq$Um6#tZlQM8F zc-q85BCA|Eud*JF$5fde*S{I7EgKyC7PHh+`o-mZ)9Ub-VdA$h@eA()=+l*qA$0947(?FoNIuk}6loGi7r)%)&?oZINZmk9@_y??h>8P+_1 z!7(`bcyS8pnx*1f?1UqO8ns(HVo58TViA7SN;l;}-MTL0JtHel{UIhB@(@LgP=JY{ zNFIIj-H%i6cE9-E*BQebcdvjg++#O37*Jc#GB0k&81b52Kie{Jnh40WB5 zy|=)ufF-&5t9&C9|CNgbg9%yhgY1B>!1Is@H%#{>KeiEB6b3GLR(*=Cop-35zBC8r zJ_RdHYx|eVvj>O1Gu0Fpzhs7-g}Jgn5qJ#>>1rT9YELH;WB<%{JUwx3JQZwMS$#}lXASM zyOovHd~X)eec?gWYtY^1x)o#0cpk>> zJjZYfg>I@B4rnzGhQ1~v(mNsy>Y2YAo#1e;=nsR+|MStMxWiRVHNWVcYl(kr&P&F( z*ws}#=q$2+RRJPxpnFiVqT5{7t75%|B2N@XBA{X2JhZ%8Qf68*bu2^L=AhW;t`xFP zYCWM|{Ua`lgaGN-syRseEeLJ{y#Oybm$r$fhe&5IJ1|jxDiayM$M$WdrgBa;vsz0I ziE9%Utb+~%;drgW66FeIvv;HN6 zVir;;<>OoJz^;V^ZcMghjNtfqwp6>#8g?E|bF?kP5bogt!2`BwMp7iNBB+vYiY=Fw z_%y(+Y{Ov+0=YJ&GzMx=@vZwU!|WGkdJa?-)M$N$g_o61B0qSZ|-8;GI zDb({lfqu)DXVw6hfMJnS-{z=CvO^{n<5UFm;<^E-t7tk!-AyZ`QMu;Vy6_12C+MPK z*7;Ze{-O<|Bu@Do6JCMa(XjSwMUj=83tGY4Brfvyf|r}c2HNJhto7~{j%q`NM#PVV z<GXrmg*0#Z!Kt`fj8?Kf3C^6- z+CEi(VJ=ttl-==a&Hb>f0HYFhpHHi+kV9XFIx0YLqKJy+M_e%vegYroV%5ZOa+v|3 z30kN{Qr9E^S>aAOVrTam8R(jxD8ujUcKiQ>lv4bU5Yuj^wnF@HV1UNm%-t1wO##`o~PFf$Nn)W5i*rqZ{k{eRvO7 z{DL^4>mgW3g0Qa*CybtoWeJb*lAmI};a_0gcU2yIk85$geB-5_GuGtaG~XUW!wDL> zMD&Cov2`>exYBrrfG7n*K6o-rqw9RE7b28gI|SU0;s~$7ejAM4eH*g-47HBvy*Sk5 zcX2wRP{c~}O^nz)MbNB{kNX-kRuE)mjD`2?*yYlX|LJ`g*K?-Y-RVrE_wmLU?cvLb3@k+#i33F1B*kJ+v3J`57CAe)Ud z*i|lc^dqi@V2a|0mBmlI3&`2S%NaO`4kqS)>anLOc>Cr+yRGdJy9E&k*jM)7V~Lf> zf(01aLXzD)v=z{=PnC5nAlA0R#6_(7NS8^k*mG&09vCqX3dx!t8-9lO*FJ8i_3*B#nca?!9 z#6dJAA$75_ zTA$muKz0XvCOG@!cZN8yyz*6iG%^*>?< z%crj}ozm;){(RVIp3+k7?YesU;@%t<9`W|EU3l@`{puG=>2!~%2jkzHW;zo4gd1po zs*K_Ixtwz$Wva|c%5x8d!XkU5nH1Si=yMP;)t5azoiaKk=5wq^)%xs0BX&hNFo_t${w{-W} z|1BZJ8k?sv0F4QBzh|;#gGwx3IG_;dxF8VNpRxce^!iKAS$)FAx)>k75Z-85te%%J zE$-3Lgwk(Cu=2BZ2;zqJ+}aW&p|e^?%_19Tc&FyPkY z1VO{-Kd(+(yUEn#cV5-xju~)uq^Ci`h%jhtWVm2?Vh;K;)B!=`tZLOGrHq$iT*CPifXacrR_QIY6{3meO4o>1en?4il)n67R zgzg@HWozeJT1`*SYN9cmb3HGgFztJxiZIT0S@$L?YTRT9ez&)A6gO`*WApbnQF=UACNT zPBjmRNQUX&YwRXnJq~kvADUeyc?x<#?Kk@#jy<11s1$O;mpvFw-{Ca$a)r6E>}@x- zz=j_jC$^B#{F`2)=kinl?BfQOLEEj+KeGI^P1g7=HXnSxe zel3i*j_Q=4>r`8E!PiG>$e}WWDtz|T=_0FNUvx%xuuCk$(XW58b zZ&?{_t*xKe6VUT|DiLvZvCayfX62n9Rowi7IQ=qi=7C5A{C}P#rjk5Huo}(DwJ${g zhzeja^*NH5J;y(%mGVEQ_4^7SvVpe+8$RQ5#*)aqbNjZ(^QHj_K>jB+O>2QPdFPU9 zAHi`0oN%q#KoZH&GN!1iS^sCK{Yfrz8ft%HZ94r^F` zul40uc*lswt{!qad47-bg!6k4`2fNX{la_QWFh0%i5#zf!z@*Ptgm~3+~JVfX%9!B zlACyAS{H!&rYgEUti$gz2x$n4;M#sHEK0G@D+*g{G6$z4^X7 z%t*666&3O?h_&Vo^3U(FpH7`~L%=B(aEJV?k`qVg}Zj-#YA)yc6f{LXGV( z0S|`O`_Opg2%!k(JhN7>HknG7;vo6KUd_rtD>;-D%BZw1fAni*9rbW6`j#|97aobIU%FzZ9nv)!KJBS1ZvL9}@ zrdX(eSJ~TTH;$dT5Ztq!KA8v2x4iN~Mdtsj-&{j><+4VCJ>R~CNKdOuQd~a);c2Ys z$a@Y2s$Xuk!DZV6YnP7{62M;F*B|0NZy$pOY-vt53PC0yg>U+l)-3odtDiX|{@5Im z1S^QfM{@j7v5{^08ob@Q&_tBqznmHbYfYLcJ_y)}10u*)!9j^m^XUGE#4r21128G< z8duLo>P4xAIg7@)(!8dt%u1Jg#xP*VzfsSFgf#bbn=|J@>bLVCC4cuHj(xCv^dP;L~_r0OAzO>Tnj|+DNLs2pbhn6AQNH^Wz+pi!?S!4S-QYOo|+i{k6Bw)9nA@7JEVBsX%R z*bUyxR2q2L-i#r$nq~>u)`&nfdN0{d3}$VE0K&UV>B=s}4*dEjn@{IozLavY5PUS+?O9I;A3ZD>doU$wuM_j0G6MCJ)(M40YHtUHf zYc;5yFgVhdma4}*k(mWt1VDEM|J0~lI{4Iw=K~d{=XAw6!FzKdK-*jbFsY_o&G6D! zKF*W<#TjxPOas_Yz8j=?v|z&CG;0c&XT? z`Z$%eQg@X|;D|rCe>n#y4(*}n2ocIYae{O#x!D-~p}0{P(cTFm+1C#qul^TEnL(FU zeVxFDAlImEEuga%K{Ynt_F-x8U=wSFi|D{w#4sUX2Bs=xzJaLHx>9?kqluCv2sFdi6`LDR`U2WC;>8- zKxNtXX%Aa1^yj47d7Q?@I0dN{K^8MmmamL22;=;ko*up!hU^3o1S7d^^Yug zArC`dF9Cvx$2bFRzVZ^WF4$uf>u0Ptg?e2xaPTmT^B;}x0k*}psi;aKrgzoDIuRYvi`D>;7rGPxKuu<_}jMeDn5 zv&27I@58zTVg7J^*$f{Q9vBUcl)VBjTlTzku2U zgEDx&7j04@Jb}c(XK4S_JpgPZ&5~`jD=4MIvglC7)Pt6vo;Qzpb?=SeY_wEfr~^M{ zFj})jMcpD-1$kRV)oDxUDwG@IN*cL3xHp2NXVr+1SFX>g;hF*uz$g!zKlqLKgZl6g zD2dFBX?}t6Iy-M@{?WJ9BmqL2pwMj`r_jHeGKGi$=$rPQLh#YPNzJ7C4 zj+FA@#1qppyp#^P_NsTwu$e3e=Pe?}3Gd#Uk!nT=jr=bJB@>L4GCi;XN^{^u@I#Yp zd`!f-nTn3)#$i;z_Bd?18tQ3Jd>uiD{`a}U-9_fLO1ZJSx%p`u0sDoj%4Qg5hPJoT z@;raxliseJxcLpW$XI{i9Q;ELOm#&Wil)Zn)S6b$*aG}NSk6+AFLUx}(YerG050Iw zc_=-FV#A$d+J-=G47NELacJO6I< zTNrPe$GxuR!dD;3#ytMj9Ogd+Fc5-_QXja;{oX644MxK~9Ex!#Eah>N!>~d*b^Q58 zp@;~AgZ-c%0)xgkNk!<$p+e{5ueNEO^Jx?DTY1nBGy9W*8g^$IX}kvF;XtEV^136? zCDkP~<=z|caR=RhV}-YyRpN#YIW~8mB|%x{WN*fDvFJaI#iWvyl3PZ@y~rkRUU$tN zf#Kj$RoqD=k>Lc~ z^EvQMRe^N8?OzbCadJyHt#w}g#*Lka3kRK}F(&+yKL(soG2QinEXy9eNv-&b5ntf% zTDzvcjNqQhWyvZ)B#IdE4c(pQ%U7z}FHg z$Pe?KUa=8OlN+jP+EtGKDfZ4Z!{9aLZWa+bWO?PnUrjIU!}TT&2uw0dh1Wrf!ZPvg z4s!(GoLp;Qv1v*=wJhBfl0!cI&{6s_TKW9}*o{H=Xz4M_qjq;0;#CCW zl!`pziw z7w^AWu;;RgTyK<%f?IO6rtJbV{f2=PzjR%R0Q#^^$-m^R$!Q|L z={_*@-q38h=I8eM0?1_4)sV#UhlyNopqf4yWa59ubBKq2HwBiBwajYsj)u|IRsD#H zqcOz?fYtbBtGmln1uVH7)a@pAgYOdJ@eJIG7N+Z|BC`OVljx;-B4BRC1tY^QE}}GY zfm@Qwrp)p_VE3BW*8WckREXgK7>;+LVebY(4JaW5AGxdO$9;GOvvoPz?}Vq?Nf+lf z*PQlf%6S9`6I1@n>kjqlb31yq&E6k~^Y#k~m=0ow%DGl2Q}+{_oFW`_HyP&}ehy>4 zw%Te4A1eJ|NMShqjIjAewKCgtgF^cmJp^(fpA#%NB@FD8$a`jmItqYu>-$aZyrkYN35oifZq#&Qa9AHn9G-+Wp-O06mm z{6y5(Ghu3%hE2vtVqYH7{eljq_s=c+g!*2`{go9~o9SnI6vfad&qBm1$J2tr&5E#` zqS2HUTn_J@7Zd~_E4nzS>PPD{Rs>XDOfO~RFD@3Nv!{u8$$QY}uG&FD0V6@uR!zvqQIrkhh9+4% zjnb=lKQQ^@Okw%o^9$v&#r>!*GrsBofG;!hBYf&Zu^v07mB$_ZJ9Az*E@q34X+2E)JE5|Gc-8+XKk4&#iXz!ohA0EZFmfs+Q@W zTE)Y?>9O^b_~E~1xMRC1pOu5|q)30+R^F!CR~yVeHt54vn5T9+yp-oAZE0^mZ4NfP zkxgxnm(7W`e${DoZP+ZY?7&yWEQ5F34k!}+Tuozp(xxGkWpBmc$`w-1W(p<|!pl%1 z?soSRS}W@+VAzs6O*RXG2FQuavn4VD6c!L0lcNez67w*r{$Ftk?8JOI6Ep?1%<(sc zIku8H_d-2ittjQwwi7X^&#eZHxfX^i&3QNtRT;#gu-Ipjb~A^xD@}8#>dP@d$q)Du zjIp0J2w;DJYzQsiBrxagtf?hl$tHS3^F4Uiz(-6reP{av*+CQLM$qm>88ngC5CvQ= z9)g5w)3*yivIjsrtXO@De9nG?M?e=kmCX@dXg?FnpqBIH=cp7ek^OspI*~YPFacyL zSZ#$G*ZGvRhLj8wZ1xc3d=ATU+L|-;HQ!+jvb%rF;7u35R+Dk&FYH?<#3Xqs<<2jk z#Cm&zqYRx#b&-^;^DOr0E^>DBBi$tRW14v><1P{sM|vVs+}qi$;0z$^l8G;HTxP0+ z{#Dxyxe8)wRO~mt^IyyToo(MAJuutqySla;oo#rm=(W^ERDgaZ>w&S|{h_tcX&EpK z;>X&rI{bxWm2x8a9T~uq#E=nP*E3O|sEEC0FD$Oqs=e255P@ttw$6w3O)Dq@w-GMl zAaO&NPSG}rWyUuwm$ThaZB!=!X`AlHof|FfOBf&*6xhXvACBXW6&%<9w42p?4bs>0 z1L_BL$QTg^BS1AI3z@tn{ohM;mN)7Z8^5xSw>;+jL#;fr6*;`d8po+TNGHOqmk?QM z@!gR$6p%#p;1S7T5FdC)Py&LbxtEx5b}($5QTSd$0VBOl=|~-C>->lnc&qyrB?I1)nGwv_V9-Jvq%FU0*qKb64U}S=_&n zN-)n)-bL4wY<#`Mx}+}go>eEGk2cJMJmFLPh(_T=Nc7q(0HS~cM!VYrSjMia@Ap8T zN9OVX=6GOXDf|LHM0y=qFmBO>R@-OPsu9lraE6p5q!l^&4LjuInDaVw5fIfb7U73X z>gkWyuawEl4n2{{h+(_RhNeMQM2+`lz;qNG_(n%Y7-$$hzOxr7T%wc02Ll=Dj4Rb2(iM(@YIRG(H^$bwp<&;LDKq zV0)MRP+b@|UnGATi&9X^S;F}t0IQoJR#*#wFzUWtyHp{3(fS~zVcP>z_jV!9aWD!Y#Ob=ETujwD=qodUNYkmB$;1!fPnvX>gRbRvWOm(H;$%1Gw!L37x#}C1V{AcKK%Q? zz9r|=#L||poFNl|3?>{ky_C5NhZIB5N;6g0r__ADasTc%02G%17^SOHqr`!#phvCU zX!OdA#V+8nw9~p{a@79=F_VwEqaYn$lakzHm-8*|RQB4NbNv6vIt#C;_W$dv2#83Rv`7ik-AJQ!cY~yKr_vpQbm-9CN_U5d zv~)=K5JNwo@%p>>yVmm`usCzhyY_zV(f|nrwzCt%7Mlig$s5cv9#HV7?fe7lEw)s|plh;lYS40Cm2uCkss5@f0 zY6ixcuO9<0n@TRr9dA(Wc)cfsfvS*4&nD|52r5kfiihw~LuA>$)paygJ)-Yu)gwyw zK3m{i{oldmn;;;(6dyDpjGZq=Og_;5D();QsuV7x1@N&U)<-x?1O0$?4$ieC+~^}N z-dG>De-e{xWz)R;0SHa_F&D+!cmPQE$YP=C#GFS@vyzrv<=a!GXF}vJV;jqEa9Y3=ujOZc~sQ)SM& z%8hvz1t-OQ`D~?%9&BAO&tIo+^Iz{!KkU$6IMy z&IEl?6Oc}EXc_TGJ1ZX_rXZgH;}L;SfmJ)Z5bJUCz)A>dv1S>?ts^$k1}Ri%%VAp; zkU3wT{0fY{6aGiB(DzPZ<$#%}@OxQhUf^+K#UmQR?dn`U2XgP+XHsQyzJg73T##t-iDv2hUEaSu7uOt zz3RnoN0fkvDlYJQeD;-2&v9b^AKvol!A!t7AmY~vZ5PK%Y=`7dAQ?-+ps$JoY0W@j zn#wwKx4vFFgAMTyFrZN9+d48XDtD)*UJ8)RI||=w+Gims+qAm9NW2h4)(NhNF2;V| zy>j~@^hv_XQJ7W*?woXK%HwBIe(2h4OJ>d^sr5*ID}ijAt=}?7UdtW&ry{8#V<8s1x9bdJ8?#^k!xRahj$K zn)i~rZ%Wo+?(k}l%^ZI}3ZFL5$FvKW@&Hlj72VtB!fy8NJVxO30Pp64sGjEW`fm-% zFTGN2DY)?#H|?H}f#*zY{;5M_V)5sDTE*&c;oB(LXVp^4uFX+z<|@eP%sF5|RQ9}= zA>`)MqGqDy%RgwF?SJ{XLPw%}Sk%os;{g8!;pwC}~5u-6cno z@3*FM`dr>#3*WW{#hFmZ)j!vb?Z-Tu)B&?@xy88nYuIMUL~&fDdu5#xy8y|k&Cp_Y zOyY&UE9qCmzBhtT-X_5|9-H3nL8^BvAu@Z6wzYwo=Lh;I@&<`I9Ui{BH zN#ZF8_G5A1oboO9>Kd@Hfo!43Pwlq0^??wzD~K>ap z67%CDCNZP#%PDmmx)xvX*gP`N$bL|+tAK&lbgRE&F#6m#{{FxAsKmQ_WY!60QIJdK zR@_p=8jE&XeK5y*drsD?@&`AS1BLZUXQaY>c2_!3StE^7z}Z0Xd%v*Vfzo7Gfz+Gt zQ8n`T7I51N{QUPhaiwHq1nMjgCHH$&R$U{11M(3-DjDL`c~-?r2DowbK(duagXe%Qv0}^14d>k#0MMY(Pl@WO_X{;>RD0QiyltKF`cXam zU#0c_qvHw|P)LV*r2@~kf1f`5Osig(TkU}Pav@}#n&E`$LLDk$H@<0 zg(Ah5qQQVS!tHV|hP$q6QOvUe5G)JDVf+Wra=hFYY3zCQ-2KW4$ArP%0jKQ0=UJLF z;`ZFP`ab#fvjyQ|Fm@GRcAy02BDI!i1q@NkELEYPl7<0OyiztSQ(tR1UKZp^O*!wS zUZC`L_4XV)D=9SOXGxlR3!vu~SK7)%TQmcO&Jbw*3k;8LiHP52)q3-?E&3V2(aMjeXW@PV3pUt zF82+tuHJ}VokCN|ima!~bZH+kN0A`(7RC$ISJ%D6Vs0C~B8~K69LhI;jr;gkKq-42 zEUF(d41&0_A6^bjAFI3baZ)m4u1p*?NXLCw{0B2r!r(C3xo;y^VWM1Qtj{0ZIm7kU zGp9zsSpJIV=Cm!V%_+TxiAGPMMXAa9)&k!Pc8<&O*bHUO(U9Dpyl~O|a?yoC6+?=e zCKZKTm@owaDQQPYvOPeG-a6%YUAHu;TaS)Po*C1er~O{4)2u+!|7!ci;T!@t7rtFn z;KRKGECXg4OSCdyG@?T!jk|{ZMKP`EJ`q>x!?s0W9Q!Jl0GCle<)iwGPpC(15nPu) zKbaFY(NRLkdi-ivhNvLH>_A2f1jYj+2o7x%jj5zu#$&Tj^b%9>VhZ?Q(u@lN9`kAM zR0U8}LD0GEv$d!K#16jjp#H<=j-4+|rT*o}? z+da=pEabqF8zZuClgu!9Aq*rwO&=B84|(Ts;^CAxN6=`sdSvLgpOJr3n^zHdL~$qd zccC}y50{;rP|3V8B6j2CvOS@b7}p`9ofVf!Ic3U0>{y{y!3sYoijioYcD0!8I+x>< zFr@-xtjEBMY~K^5b6C*fAzK;?fwyf($sP?)H_j$j(I>;L_pb!>JG@2@zzGkY2h)$0 zwM{HYBm(f!bZF`MWtFUfVA%NQu2g?h(dpUEOVOTKpixou#%?-izvnwV9cRuUq_H?D zU7(ZuLcL79=VvZt93N!2g6tp!uore)^$kJLKtjWg`g~Wb%}+H!6d)UaSU&m5@f8hX zRV33cuT|$)0|tPJdyEHc;zRRGo=1?n_F4tABxVB z41ZY@H60~~qoe!{o3MRdwAqExl+=M{*jd__||t$Ut8c8tS6HcHU9P6LP&3P*#4Bj(L)HW+zJkMgvoH8SG4vK z2r~&iaLhg$C)ToO*(LLOUxLEP#)RGwP5M$btCHw29;o0!f>W<*36gXDq3k-r)66E^ zH##jmVJ=`7*?N|$F!u5hg&R{?#7+4_c^d=)&L zEEEf9hQ8^J&`T@yP8>N@kicHod}W3yk!*9IzYZv-)YZ6s9&(eC#%A;YPEGxsq)%Th zZBob>kobG~hn9mC>R3I9dpsSdkY@B{Mi00T=yT8PdeKKyi?rBV;tEebwrT5^d~xYd zx=60g0Q=g)7yqe-t~DwoM^^O~-d~SrNh?WGRG#sWOmMAQE2FUN5^@3sPq4DS{2h` zm(a%z^_^}E^)yc1k4>3$ka$SUxatzuwG$D5p&poYrAX-ChY-_Od{%f?6fy?tB>n%O z0NskfN~B4sew`Xv${O#%NqF~|aW0zVVhjr*f7(F-81VJJfB>q;o`7k4-6>LL-PT2e zPnPP@)C9~x0nY^_R2m_GQTY9!6<$=BTQgn+0;>Y#Jbs0*s)FKcjige0vw>ffb!Q0l zPG`175ToMAMwF3R9U)pfzjqUaLO9lmn2EgI|U+oX)xS zZng(wjTai!PgfO-DXx(6U=K$j0d|u*S&9W{FzDVc*kNu8z0~n6tO=YYMG2Uk5%i{Q zv!oM`#ujv+N1U!mWYTXlcGCd#l6WNHYHj<(?8sd^20XQ)%@Dt(_ud~7wWshG|Ad>r z9Bj;Ej5KH=7W=ob^E)faIu!|>31$%L?!cSj?KkRqSyPb0Zgmash`(A0vw*aZ^Iaif z2AgY!l9wCjSa~VGz+uW!mt;sP8fe%Bk6#G+STKA%Lu}jU0K45ca~rudr?pxMi{RnO z>NSeaS5Z4F2nL1Q^UcRogR>~^zEXqkX7UZ5Bo*_ApT@Y}Fgp*uQPUkUP-^6h8Dz!nQTxvjLt0U3pc?&lWTJ-ddej`XqWz7nTAa!xNJWIfaD@T zSA{Qh{(fWUD~4M@D#@1u%=(GqNAHl#5s<36KXsr0UKb!0~`5@#qOk4X?Ah%Jfz(6aQDK*(%g)|Np zh{#wSnua_bTl67dim(@lFmNQan!lUk2jag zt#Df0=66t7KN7pp75A=x4;r+=Unp1a9Jl7U)VGZoTU?NS;bUG)=9M4#N@<`gqNC#J z*)DyxxffbcSSAk^o(q60FOH1l2gYNFz0*og6(W#RMt9G!dYX~^s8zd`{`xX7mLK&d z3KQN^+tPugRRNJP*CPn9-keTh0Stc5{r#@ws$_Ih(c?M?2*>vaVI&6{TLJH<7P^qj zkv}zrZGscj>Z0&X%_h-HBoJlUS?>BS%dhM}L37mnHJPJexO^fP`<@Hqf=xDkCCZrN zF;!UxWDcq=nBpjfMgQroOg} zp)ZVL%^TI)s5S1?=>Sg@+$#By1-V8(eEf6lf=A*X(j2gZk0}B>xS3GMyKvj9Zo$)z zYN1n=d#eUN4ck=i$Nu+iz_(V?2oq62G~C+v{u38#K&s7s$f!210uEl!)`q#`&S%WM zBOA{O^TW5B)__{8biyR2SK2kt2wX!?Mwb_Uaid=V#@Fhp=56_k(w;9PS=Z%Nk5Pa5 z2ZmdT3RmjE5Ahm%7V2YHA4Ed>10(C5JgQ&BgNt;>grFz=sHc zB#^SpnfyCS2)tv&3)fEVq(?HrYtiSI0Mgo#!sgC?)mT!(JnwFAt{4A*Kel6GwG= z-2?YSBLz9&vNZK(XWHy081!Za3uYPs1>Lp_%tuS{vg$utc>ih+Z|^2DdeByCa^O>+ zW&>`;YWB%#uWRG(bclW`c zX+;XtPCwuNE0yJv_>@BhqJQd?vYGUE%amra@|EeVZ07oK)InaL$pC(UeRb43>C5{U zD<7~6@aQ^MeEC`~JlNrZi3xNnOXE8dbXmp-PO8Bd$#WAyYT85eY9qJiW~ zi8+{!3II<2=P>5ldmDMI-kbc6iDAL4Lx*%iC^vll?u_`Fh~quHj*rIPsJPcfH?Q~H zyp_HD@q%xSHtGI>KylBZ`Glgr!b7w3>PuG8uJAzTaTvqNd1U=gNt=9X1(l&)ehaQv z`ynZC56f=z@=y1rd&;!(;v$a6QReiY3Tjtag_-b8q(2X7U2iOpkKZz_=X>vZK*vaZgU&9IFc<3LI+2`tb0ke}o)!+TOa?Kq=x2 zjk4HVF+-qg-B@0}@*fZ!_LsATUmfb6#Zv$zse8E0@}}@$3Omq*N@{BGWx&{(P@Kqg zB7JDPPUCyXSsv$iIr(I&8g;zqKsOzK5RkP zkD8l6SnulUx*)LS-C}f}ZA$`Z`Bgj^gz`8+qNN7t*%zfBFiOk)j;Cvq^N3xXC5a%8 z?q0_+Dk)X1z{jJK_z5IlO^aR@QJsdbE_Y}8Zq{%{iPDPNx7)&vjkusf>_+VyG}D4# zU+_OYGdlTnp_nY7FU2GUo3*2?QOJV)O2#~1l(>6oA2R*TE^Ep9%{@x6YNs{ojyoM9 zZ0FR{dzG$BoM(J3(5E4;Lw@voVwUS>m8d2J>l5}L|}*x;USQbfWm=$DT4}aIs1RI1RGn9Se}k6sqbSErXhco%Jz(d?)_d1m zqy~XSH(-y6Oyu-OH8^%6nGONJxLjQr%%^mNwQ&G*4W9UY1O-{6hrh92w!rp!&btDo z|A65*&l(FC5whH|2$114Bandc3LMphVvvTo_^=w=+j@U^St*Nsk5?d8g9?%TOAi|Z zInFFLW@Jp}4c67>saLDZ!2@YUNy_25PE~RHfJ^Y%cj^9UGY}CJx3E$!Qz9G-dP;n9 zgip~6zL!1s8z7bh?B*SoERXgDdmZI*OhrQnFd8m1X9DjgW4isngmfB z3`Nl7&l>t7fUFm5IbfT}IK2Fi4!+RTDBr?RgzW7aW7j-E3!YC5V4!=GPk`XXKgxu~ zfWIQz7!yPi>mF0ItjIDIT6o&tz6llo%ST#7=C@zOAe{5;BhToC$k;Mq3C0dK>?|hz z*ynX3F>>M(pXfv7Wlyv%Jl_nuEDa;p^Em3K^K`!HZ6`$lVVgbH)fOU>NO1oCUw3bJ3^^|0LiB~dOl~f%&Bv5+21((Nf8W}o2*5PTC#yxbl(|08|%-obLBev%U~NR zS^p(|lG?<@lw>t(emrSitNa#PHlS(29zGsCT7_^XzUt&!eLVK|v9j_XeWu@ynthf? zUe8DM>cgN~tR>d|X22UuJi!pJWg*P8*^Uu2uYygVYmlGyo=nv~QcarwvE6T3S*PL% zf`3ad0H#t!0$?hE)fbK$1B||$4HRqL6x^pr>8UX~43^stIOC1X#tsG)e$wxg&e}XDUt}iBnY7P7@K}k{|vzU5aD*`Gy$!2EVm(Kx#fw9E@cT{;Ov(G`!9~{fQ z@E#u6-V%~=Jmz=HtDHyOW`>)2%uZiZ!0Ph+dFFxYx)YF?AE_#hKJ{{i9Q;ngVs&LO zwW?L1yj{?aLymAyMSLDWr76tu^nBt--~Ml=3b>K0)0r!m>@|pc%b;C$D7fR{$zu+>GD@2GERm)5JWV{V&!C6ugxkp2OqR{)9ZSBLJT02uZcN}epus(-e&xLdY^h+FW6*-?;@8p z3_yYl>j8+TKCIV@%jl6w;ll$uHVNwx=i$nNWzD9kI;Q8|?&|we-MIyn7d9dWY}tbJ~BdkwL~>DD_b|FBhS;_ybV)#Bvp#MX@5aW$TJW#x|&J{9Ozz%uy>FdW{c zl1`ft``egQ@Dz)G;KY#adOOwmp5ThUP8j?_wG!6K;2-0Y~~ z$6;+<;m_9BIB*kqYvcZkL3MKtEvJUWC>*5z=6|^Vv`pT!^}EIb zK-a@^7l4`XL&155@t7X!1xHF1J2}Rh9VQ>NWoCagoLG8QQ$Op~KA37Azq$fq;r?Jo z0LziKPudjEdIdoG{|o`fQ;)W>E`2DQvK%5IFOU={>pz|rVIF1Vd__99oI zb+2e@%b4ntJOF#?f-|GlZRYcR#y9!AIMbNy2N^-^G*L@@S4`@egp5o>cLL-JT+jer z^&`Fk5QF^D#KcI*Qxz#k?v`Dkk)oaICMl7U;n0px4<_oYXyDQO4NXL+Q zW5gnEsdnvCCf-%Lk^hiWe@D`GH}kr>fxv436Eo!j{c}j@v7v>EcJorz0+1C962C~HgFo_wmagdQ8;kA!=QP` zO35VWuBm8VYv}OZYMhr=;AuY?43XD4JFj0J zb5w3I*yNCOG00rU>*;(;vl79aczA~^=PPs863@yJ;CuGZm zy~C*&YfjLTsC|F*sTfy>{oLSha^k*)?3tPf*adXC51Y$lps5)Pmkj-;dZfXnl>F8( zt#Yd_lWOjTDLL}O_R@#K@-1EYre}fIUO**t@l8n%Z2Z?+-{3`EPe-C5S1n!RtG4S5 z;PB29ijAlUQ&Q^56RvHgJFP$RxBRX<+?gCjbM33wM1Ce4aD7t|F60M3&Z z_$IZ@ss#)sqiFTu8cAlJ=E?8B&~}T2P+?rhtUbvj#7fP~gw9n>xUF3)Zf&zGHhgnM zaeDaTRNs~gWJc-n`3fA=n7DgU9%Zy6HT_avz1)B;-eIzvg>P;c@_Q^GAhp?@SV$k( zKYaLa$=xToB;Z>)IWEUc`_gv&hBB67L}AQETkHd#7+L|A=5cueyWeffl;&;)#FU+xmz3>^Jv~MyL}U9jXgLun$K+wPp^;$PNi> zdcZ0yIg5OY2 z4e8q)&A|COZ7-yS$})*kb}#Y8{1SCWVYL%S)VG=svrR}? zNL-wvdePcsEs6TekKDH6E8(Bs%T3?7%iu-!S*a+l&g8+qw%(U4t(ks4aMDe1%>kL~ zMV=748UI~??3OAae8Y+w|1H&M02kk6fe(d9UCD5Qh4NZAoySyI>Su$CG!Xn&JoZDE zuxC-gW0XO7=kVWMwA^8OkHrs)c0$Vc>N(z|3nCJ{5IRm+NJOu_|jM2js%~l z*@?~;eX^KeJ?g#4;&*sC6Z~37cG{0dqvkC9nxn@c9m7&Nou?qr&Hxrgxaz2ofGPUs zZ;(43&X|V5QAb~5CX>4^M7iB>j>o3r;y2Dh4h8wnCM`6Q^7)8+RWEBHwx{+b)|$=`gc^Cx_e(wp~N@ zjzP2wrdfh7wb@*I2g{6o*?= z3Mc7AcpS+W4G21|BOOIwkj9I1UL2*)fN&TTT;7e5h)MUAq3OyXlQT!VdC?At#G_PR z9JXvFmL9u~w>T|)dMeIQ{%@5wdh?tojNIv>O1h24n5O$WPez167zCLgNxPDgEoq4< z3Ln%}+x=!kV5JfggDZnfc0?l zKEZQNpIBPFJip1uAKO32B?wqLY{+*>NkLyn@DeaXF8AkSt!nFZcF(T)c(27!8D@Z> zHJYI$!RFN|xm$RPf>I&20-o6z9tuT{02TU~0k7jCojbHq9(z2aYdi;>MlDwqP3+X<+?`3Bd67`npa;dyN&|a z4_XGvU&f^~Sm~Em+L7bl-rCNOXyjM1I=k>yId!jYbT0lddBq zNXf5LK?6NoH$gfb5sKhRwil^Xc^P8tk>(Yi(-XfU3L+nZ9=CFv}L)&U{|4(`=X60#n08oQFu6>P)89eLG7^~%ZY^j-3` zIHtDSeyD9tUjdoL-sphiLWMT}^y$ba0`jaZh|@^{$P_ChwZs2(1zWPs3ID!mCK9dE zEd6g~x;uGg47*5No3%E>u1`&e$Z8$-l`Oi zcyr=j8D_uh*>kk&ofXZQ`&eJ+dHe15JnN#b#Ic! z_`^xF*M&H$Qj{6XGy6;Dr~EGT-b0R?n8qC);x51MnusZ_3n(*L`HBBJrtV5gbde!$+7*{OveTq(UBfgs#5-E+H)V6g+%SE^(a!g~ZARU{ z2le!KmS#wR+?ZWU0Da|@>mnXMd{Vq5j@{_UIH%=2bMt~`Bs>|P1I64KO@e}f-E;HD zx6)wpx6U4mAPr>kTtPT&k|TGg^MMRwTw7#vGy*R3V&Ckp3JUufzLl-yhvvP}D zXO_1{zXy3LLR28_4DGKD1^ac5@R^3soCj(74E0T9V^1 zw}a7?DENrL5*s!ZRouAns2aG6jQX*2Y!{idRn&`vmpq`KEq}C?szi;sCRZBy;p?K= z7|{a%!Wok%cd-~>C5kwQ^4}6Z;tkQM+d?+}^P-TI)I9n- z&*2u?6N_9XSIV-(RPgcVoJ=wZpA^upd388fulNuV&-QT-vuSoiz*?Ra1DZN>Y_+-q#FD^9F$j zz7;<+x{?Hn5e8fO+8`tBhAydJH_c;>jr<(^(f{^4*EqiQ3B6*nOg*=H>AP2)1mxs{ zk};D+-w-S`acE};ok05gvDx(@f@8UUP?2_RNAFMe-Q=SWTF!Mbg-eXxko!mivx@GI zn4w7qFw^iR;+4FC18e7Q-Bm-=!NBv`LKI@Naw$bt3Wky%nfXct6q%&;g8+OArNSj{ zPmZRmvmkNxTTB8jHRG+!Ck!{Dx_2G}PL-0ii0#M=1af6doa^blKfYS2?N0&brpDhr zQLeID>Ek2n6vdAviO(7B`wHcAeS z7d9n3uTSJ-v~m|VoJ7?Szvgldd((bObwnr8%qOf?BPTe8!jeetyOhmXf`a1TtCdP8 z6K#aT=BCWR_TRCa!0h(g={9d}IL*JeG}c(>Fm0M>9GT0~S8r;=676m4mzT9#9IL(L z7o4R>NB+Gt?c#SwtIMbBY1j8C<4ceLrQN=@O|D ztwNuo!gPj06>tuwPW>bU2nAh!Scm6?zcr_Hm|@GYnWwiZQhkUF-MN|}rtm6#ZGQ*7 z+&oeqTRF#YD%0(6Js8XHKA+D)v71y?9~OSO{3c9mh*g6rTS>ow+p(}V*VrBPrB$w7Wyh*i*t_mU`Yy$eLitG+&4b> zW|`&f55@!W+QOkRoDU#O;KvuS!l}v`Qg6z{D32kRZM@UT*O>ED@0l1uj~#mjcclDn z!sZH903K9~kEzbW)D(yE`66z#KQmJXyV)Q<6_WwFlsu@s5Oru9K{+^hAZUCXb`7-U zHra4+D6&u!CQN+7_E}CZmq(R`1GgR|C$;oed%3|BQoGXVXS|F@BrJK{xd?7DM-4sQ zNF%A|gwtsH&>vPcE42Gqb6kWxU3AYr#5D0RUE^UnP(hvajR(t$PKAt!39^HBzdNo` zvLb7M7eF_(KIx*JJY;eFR^AqQBqk5bu@ z9}BlCmpjpY2k)sY?Be4Xoya{>at$L9VZjFig=gTV2;T@^S5Lb;;~Q43$T$=gfObaW z=G1-p=;t|t6O+EUtmf0IkxaZ&4-UKUX=I;1y@Zx)v2U0Q|2Ev_!udGVL0~o;Czb_m z8@J`Nuu}+q!PAypV~qGwOKy8kb&LG{=TP1swU#PteAo=T=53|8#F_3nC(ZmWH<$0n z)?UTnGrC9RmuOn<$(42 z7o56Ak>qk_b6a;lm~=6xAE04nS-1L@y({)wml`tCQZ*n{5V1pHJ&( z3oPqUlx?0$jz?_tPS|D$pfe zcHk$!Qr!%%rw<>x1niZDX`qouGH^HU4y9D&=7nRMgWk?PRnC$#ZGOM4;U*>YdfMM6 z|JTS8#d6fwc^##_fAQlPE$N_)>PAT&oCOj&&GS6!JNq%cEXsxAe{%rIAtO7?;=#%k z^u*0`>dBwMteS{yZheF!osud7VEU%7pxR($Y`u$~E>wwwP8mQBW;V_RYD`2~a^yZ+ zlvZU$-Z>y!zda@A$3Ie10>h?2;dlm7In@@StI(eP6~(gYV;X6@ZlZbQ!(TT{Hu7FE zYz77ogF+gc@4z9!E&R{zGd2X5xH7ixE!xMaasp0?D$$W2g(@kMjBC_X&$t+#5DH&E z)8=10rNUQFdEXOrv3&If=^=i6s246>sg7+k0Y^$y*G#}QdW1+z)`O@Ae{p(+l1G8#s&7 z)K?x>CZ!ftc&PRt@Y|I59RI5At9Qr3OJux~vZ|npC;c)jH|-*t{#kosHu}?L%$LPx z5sr6a>w8gcTGfUYi??NqI23D(Nv>J{m*}uYu~MNO*JO!*Yy@>CZZ?YFOR*3KxCE z%?t@TXaQEUO=eik9{VW`^iVPF+AVcw7aOxMbK$d&^?*awC^cSd5_?eU&)73_Ba(*JGH>I4<06h&h@DMA) zE{@9I5fb(ZxuO4hJN$5DaKOdzyT;>^bM*UFK~shsn5aD?1c!>hxsA`>>9k17evM21 z+iH!66_x0dwu633rCh&osy@Z0b2^HKa8XKOYMq3u~T}jndFD zh4x4qG(SXzwWN3zLcVLpXKw`&xZgva6;pv-5$sRAyAncW7p`dg$XHU0%Vsz?s2%B{ zg_^7Te6De>;(l1Mde|A0++#|7Rk9GV1qD>H16bYTxaL#p1(V5{>d*|XQu~r-%Mr4t zfCB4=dZpz?HnQ#&PlvxeGVtan3Yy(YE&2L$fB_a?EcRPQd>9-wf3!eJ5tu+g9l4Od zxdu|0W2*-0y;HJ=28Fx;nd0CFMJwIU!D5@jmnwPBm}w*r-803b(eRK~I@`$HS1lQz zd+vNuiBO=aI%gS6GI(zLon%^$rakqGiIl2lP2ZkZJ#JEn#zrm$mzu!(m%Z#a4CFSi zJ|_*G3hvGX)b&YE55H|sRm~S`tFdfyZ;HnWfh!R^Km7Lw>?47-F$I@EQw2@Ds|P~N zP{#go{S?l-f=x;nUQWo1!7pN=D5$Gpxe~9(tsa8!_DH56>S8aO-$l?=A;aq$@C8*= zFB>8kcB00xE0^o-)?9wnu~Jh|y5wR|`~LVhMu@#9#pxp_6nkk-$JV=S7ChpmOVNhhdk4(BsHBpvsmM`dq%oU5+)AO zw&THZ>{Ps_su{^oT)zXoOXA$*DJcV+SF-Is)TO%p2+oFgUWK%mc(QEP#;Cnz5wd~y4gO2=oszA7E%XjGvJx}zL>t-u7mA3~t-*FBV5rtDNYHM=0z5hNu(Vag$l!*py@qus-uf}xR4L$wl z*CLUn!{TQi{6bGM_uku+A13x*Q^bywH5{35t!~$eM^mLD@E=7-20rKUTQm0fPyecK zYI3S5{!q9}Bf;nV3YNVWQ84=@Y}1SW>XUBcG&?(1t3Aa}A=s;$Jlh}l+M(+rYNbSX zzuLFoeEsmv!DJbUj6YV>2#U|Jm=u86$ZlBsDU-e^0pUye7RxRoM&H0_=vKu{?D2kM zQ;*9U$_|%@;j?noTCl_2c3Dw=_w?ILAOaH?suueWe*jv6{`a@)e_u!kjk1Xk)LG`Q ziW3djBD6>Ohy`0M-hUb2G0o)brd3)418?~qDRm-pN*L`gy-6=hsg_771w%^ZWK}wJFB5Oim$-Jw!EUkIcn|3d zZTo3@b4F|jGffM7@bBs{D2&5~WQkE-=y07|RDx8qKi5iV7{Mg{esB5i?YgYKAeA{Megi`0Gq@PR6)b$F&9 zYfm^Uu^i7u5)V(V*6?+w6K6o+dE0gm*m}mgT-I!QMH6MUbrj*mmYmRem#D`^a@PwT zg;v3?z7L45RGCT7e3ip>rGy^!E1d5BxbL>&*o3#rm14iEa5VDk^T&q=?)$;sdvDhG zNF~>n1&xeYoVUfV`~zdXkp5Z7V(`4%ne<-s7WtT`S}f5vUdYmNSu`7jB10!P(|{Mo zkUOrnJup}Q`Ic#$JU{|IZk5_)t+D z!w2@oVh@ss^t?D^rAo?W>7U!z=QFZg{jvYq4g?~mLLALhL!*Q9l(Jj%4R)tL1a?Ot zjxkTGFC{>yxmq!b&Axiwkwr|GR(+Xi!QL$d$;U$q? ztVKO?+Bj~-*@$ZToWed&gpnL;+quU0cK1etr$4}>se$BW@KdFi$-X!8|LBM=%ztZ< zYBta!M4Vp>7E(+-j?~VIF3(ruQBeUnmUz65sARVOqKtdT zZkjI8k0HUg=+r1RdLixMD9cU&)man6pb8TP+ID7xg&4PU5hPvc`($aE(3 z#u4!0`py`S@cOL7`DZu66KeSc!}E+fcUEQK*!5oY^}RTsFC*Q5c%{{A%dvcd2i{Id zFm8lE>wA+6{ysX#7a!xcPeG(x2LD*gGm5oLDu)WniM1A$!#v0G>m3;xnbo_Krhs@t zFk0I1og3yT2_wzRRB`Icny8FC%PKH{fAZb;0c*^nS zS=Bc(JE2l6n-WlpGCyXkC)!mH1Nnx+U?nvzDp^v ~w~LEg#lX&HX9bz&XF4`_&_z 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

    K|Jx!COOO$0*k4CSYzfzv+{ouFR} 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

    I%v%s$q3fO`uTC z^NNxBT_o53;Ihau@>Op@=7sz9KODJ1ZFCUt*YF*rR+F|qGjy8D_q+>ys9P|1 zCk@k}JIj`-8eseZ+m1r@C;$?}`m_gzHi)5T@_CiOz7mPv9J{$lsSN}kC%tG$sQ=rk z5fPQ~zX7Z-L!!Cz*|73{1hdfRnk~?2W;syMKv3QFtojG@JMLaTCkkAnwRyqbb`b-$K(DTXRZQXs9nXKD7riMj}VJi<2mUBb{8$vx!VimVNyx7dM(h*H#Akze|2Q0 zqG2*B2h6sG-%eSJElZf-syWsA^v?+M)UsVD@2+UxQknzsj4@4mUlx475lx^}Vuu=v zx!Y&R*K{3QD{%QpPS;!l`>=Gp(oQ9)gOKMIr>|==%H69p_Mb$fyRV1WoPfh9_{08Zjn-|6iOfxBq9#jIx~}V)*Fc(Lz#UG8~3}=Db>wu zr>z&VX3+4_N6pSaD3uX=`)E9~_L>OkXZ?FNM%XaKPe-zDi-Ajpx;Bb8YT7bvv zPG#VN!&&U}+au2Ij+!s~ znw(^+4cT3lN9kOM#{5q9La$GXou2k8RAH&`%s#bzhPu;YbC#rnC#9Ne^WV#G49B2G!o@={*yty5Z>tS~cPAWMc*Y|bO92xYxbo_uKZvPPa#p`H za(s1t;x<-anSB~{Xg<3N!}rq1pBQkC1Wf%fwboH7))yBI+)2U1;xqr0{~`d%W~r~m z31ECwrBYE!b~x-ljSePWsb{d-*hW^`k%O=mhxC3n5AIXSsg7h*@#zQt_U%^oOcWn0td_y{R4;%|Sp00*>PD-Q2z;)l;Vz)K{v2!C z*fIZfWHeDHkw*UeOtnNrvl;8{QHxSxAgY5us-;_8s53!QQX@M1keud!x|> z=1jod5BMUnvJZ1lupSEjnIm66IoLi0GnG{44v2|5v@V_?qZhvF4K|TxX6GiSK}4)l zv7!!C4gD5~>ijWZK=ss|*c@fXnI81M}KC|={n1x6BUlyy9ARg%O47_E~uD_}JBWLmsgebkP(A5k{Y1$qe zjbpqA$3Y?lwx-ic+L6w~nN+7#vfupKR#!oaH*~w@AXZ8}Y)C@n@Hk5%_DnpIH93ZT zl}4){la0e(cX0bJT{3Lp68hC^UoQ$K6aUxd*yPCDBwZoqhRs*@VcVu@CK*5aGt=_d zDJhNf5fFSY&cBRpH8(m6|ClIY=VaRD~y*#{k zuUy^s8f*j7*<%S{?YdCdA9}+5FKK!`VgC2qxPzgX{F?ARI+KGV0zO~R);DK>O)^)1!dMx;eU;M^`L<~ z_u!-?2PHd`f9sPuue4aE`I7QjNe*@i0SZ|VhQzl7ZoC_%vY^T7RwAj6^OKdbr$&pP z^%p9v)?=Hs_-Y@wCm)skW;}ZjI=1@yUgVm6)Y(F#od!@YED-M+N&MaP3%;{Yo)<+? zyn1k@p`4;#qZqovWO1LIC|44*ph{|Roz6Wpu{$?e=>dKL(N2WE8K62HQ-R>bG(Gv?7ny9Dfyzhv5fu&|h~8eMs%-5Zl}fUY@DS z1d%Tjc@NCE{q6gyq?%S3x*j|Z8W^%6lI|`t$Lb#m@0sux_qU(DP7(3A(n!N!8yrIC z8AxFpt@~ozWAvYwR#5MLp8?qX+I1LV7mXS|;xMOmSKl};(e2u?I(?P^>I%*;Ijqjo zNa@QN<}8j<% zWrBOy7mic={Cp}zkSfM_P#m)P=m`pe8AeV)e~UK7bsW{%-EOC z6mYnE;aJ=)t5`(pDn~&LQ}&jraQl<)mx?3n4Wk+5dSOm_zLLK>G=J+j`K$F6l-j>f zj_V~gsh@0-_0A+zIPbzNpn<%qkHip=ug%g40b5l1Z8XQ{OV@H&d zO&9_2J2>ok{W!LIziC2eV*%}c-IO5>Te0}*I;X*Dno9C?g6D~oR$ycCP)<*ML8MA= z-i5gJF-tjbTcXBGpVhs+8%I942v;3gq8#uJq=F_n zwcOA>L1JMbYJ8L#R?~0*!AKB6sfHd&9O6g9Y5#wR*H7?zwJ#W!SakMS08KL2Fav(T zAM4mj!@4SeqlX}pP7nGNu**xFTkk=f+S*2!R(fO9$X?pGQrD~NX`i;P7YO;QO43*A zQdN4phQx^XG>c_L`;=WBG|Urs+8DA+jS5%jvMd&ZZ_o-&ep6gr9kG~UrAtPBy`+-2 z-z8)vg-#L*l~yDJUL^I`OIfBig_PV45jjDP1;q9M!Upoazakz$4i)g8rt$(*C{9z~ z%S26Dm-N)(qH1I0Y%rjFY{uk#FgVf#!EDFT0bLb*QRuvWTj@PyJ;hjc+z2W&l9z%` zYz91w&6muaxQZv_O*`W^-8x^AlxH3s@oL`fbD@Up&(Z{@@h*N;7eNDQpx)`@gq8kj z=`e${ZRwT~?Bb*=Roiy1gV2;AmRD5&R$K)EgMfC`gE%WE`|@Z@Y+YIF3lewP?-$k& z&7(CN3psj*Zuj&kTZ70`s>O%NY^Z6ptxkI?tv9*LfHD}oC?<2QWAO>g{YE53%t@rqyqM~T!|#c|YibAQD_)4gVf;vpCRUEVxh7<#fW0+Jr&hqrmlLDAL%_SO zo|JZh|MTlWS$7(|XMVsJrttciXLGZCer;!6xP83E<@7GszyG0apjc+2MnxVUtM}^S zyhQD6s^La5?L&W*pt-{dqc5A~_DT0R)W!FOmCi$D|e1~WwQ zw{JUw`!QZBs9!^TT!o(Yf&d)?E+P~)xdy6?9Kyh3bwb5SB6Q$QG%i5xZM5%pA3(+KQpKsmZSmoj&fqFfj z_w)3i&SG5K%V@-JeyVPpD=y(@lu;`ivE-n0Vnvn4nxJtug2Hz_t4eq<^Vq(OEKJ`Ed0+T+cJXS|Sp<$Gfru*~m8#{1 zCPx_tQ|lyu{8f+G-%>_rPCpNBUY=Y{w~Dr3^&*nTF{XOqxeT&d^Ht}%-G0#&j&o=O z8J1uthrf&R&gZ_!te^mlXT5Gli5`O%V|4td{Z=#GzUS-Y?7qXVuZtfjHfjAN(^swP~~B-P@&YRbgV7vTPIMXfpB`hjRvNgvKo zo2Xq2yZ7l030;*x{);&oynjfCH(TZJM=-at*UE5KNqclgz7nq1M9B#T>)9hFXImW% zu^_XWV^>RY_bnNH)jZ>7TuY^`TtSNCIvx{6ni8f>;IrP=r@5sXLL-T&eC-k4v^}bI zK^)aH>_ty4as8|zB|LhoAOqHS&d81(w|BlKnBb$n9OBHhbGX~xq4UUi%G#%M3kFbu z=eiW1`64W-%&j|Zd(R!f%*{?FWW|>}^O$UeC zXeZl=o^27oXKz$Ll##uK%d(iqBC_4$Q^@i&4n-FVeuW(o9q-$VFV%6Br6b~YXKsw| z7Lzydp-7SXg2C*G=9S(gw&2ked`yR#Z@x+llhyD}j&K_@HY>2{Ak~bTM2XOSlgC_skgKNGgL3Lq=IiEkkq*Qq&0s0smvZAroIlyHvo zS&#{J-jLq}lc|#g%w35_*V?TxvJx3?UnO`0ef?^u2pi*D=acAxeK<;2H=@Qe-8gir zz}i|`u|W1*Q`Zj*Ojm~@0jBWO~rNR9y=!}WdF?+Gv6umeoyqMRQUa5vwhzeyAh zoD9QAO(4d_CuF}UKx+lLxE|{wX)d-+@?%5(wsimP#L;7P&l%M@u3tR2iN4uuc}%Tn z-4qeu-3iw@;5sZ!s{n(1oMOdhE8cp-$RCct@(_-#Aa7dXaw=r}p_ohx!;MPmduLKV ze>r9T*T5@n9ES}BQx*4n+~%`%qnZ zERMCWL$RU0yuP#MY`2}O^?EsiyK*KXPl2!cbGN#Z)X6XbFX5*3^;{cjuGdFiO<)5ni+%Tq#KgX*viSI^r#qQmx9)Qw zFi2sOV^mEIx4~jVap(7oVeQYX{`}cP2Th^q6mW8*Nv}`q)I-$befK$+M;S>tnQ7LO z&CId|o$nsYIPFrCJtEa7_`1*A>}j@arNIVzQz<>YxMUvs4Vh1VAxW4W9No$~wzBS-1`xv@ge@{_0s4%6FvTL2$|BSkS$tKo}fG8LF&w`nA_ zKARnih+B2x%;`>HIp;+3(_s0%jz$uKF?+7S%sx5df0ioPJKv&KU0N*TErqLp7f7P> z3Vtn5N_aRB)q;%orLZu+LJNDp$rG)j_}3|)7%Ff**402)x$J5mTRpjLEY*8n0|(5* zTKIRS@E!u;;}kY&#ZvXZo6KJChda850(zo#0v)hZW^|;fadov3@eA+37& zOAEGQ+{P1xJxi^4mS2S^J7z4)YsjrxitN+BX`O-kLJcZE-@`G0}<3DoG`+F{z<1HgHO0oakok0Pi{kEH% zdBii}Sn6tls>6(#IuUA%1F*r|^5py$690;WuO_jpH!+DG6p_V{Nq=yCf zgb^(nci**hRb-wLPA|LfcA84%BTp&4p)I)!Z94tTd-0a;8(HvYg}Nl!t4Fi0i6O{b z%05FD(VfnE0uJBt!uEfe>%Xoq#qqmKMml_kRnN_d7kdi#Dc3yE&Vy4WbIqCym~fb=MBFaX#o& zdwq}gUUxUVS$XB&At`THA%h5+^qg;_2(!gt(E3a}`QDrfuA%FOPEE zj=3bN+gnuCUil=|JU{TN$sAK5#S+o;> zg*6i!Ps()0$KRgd$LVbg{ok4K6g;#0V?WiI_w<{=a%H$jHLUTz z7Ytfcm~LrIa+N11W+Ba#U`7ixf%c~Y)(s%Eic89|oe3YnTbwS95|G7B$5->NG58UD& zUg9w1N;^kw;n{4q0!ROks;^*(YVF!qK`ALo=@5|aE=dsy>5`I?mTpiw1q7sP=5fm$h;jgz!dqLyj<<^->|pXE%6~Ym;@}bF)oo9gUSjsKe#ul)h7Fj|S(#rX z(0>UOn;hIG($mG7|HL?~R8k)m;ZOGFWA$9Z2#%Ll7m-}HnOgqO!8C!W(TjD5 zRJoiTz{H?LyesZF3@XF1|F1LH>c z9^n2{v5~%iAlT&BCJXS1yE)y}um$efaajD2y&Igs zC+ZVA`Rz$LB5dgq9>Nh4d2!;%z(I(enTVa~R*1LP{dUl(RQNl^Y)pU4`uO(chDhss z##wv46Z;%U=(v&&_ka8Do`_i*Jt+<1_jsxTBqz5DB%C`UZ}rnBdz)AJ2vHf06T?HW z`|n3gU}X{!i-`CGO$v5EDNvcl!n#sg!;?DNsv80nmINDV+gG;-k+qHbIuaI{fqU2Ofq&>Jwc@cl8FC~RND$1>nPPhdVw<4dRcLS&($23PozYY*howgBzTsXk{E;A1 zDR*I-)ydJu4x3m(j(`0H8AC^^)>(~wM#ao#+x zMIqsDjQEHUZO;v8F zs}tbOk15xA%;KIzbQCO)7*3ejio*1@lY`5b+|IN&XU6TJY z4>jbE0y3s5b0p!D$SBz!6aDmQX#vgcG1pJxx`;G300?#z;e>wD&mhiY&$1nis!sj4 zPXBysL)!_r>bHL{Py~ZQ{gOvT;1b1SKF5Md3dO+Cg``N1)5LpW<8$`**lJM(%Pzdz zxOZr10P6lI;t$08ZpNY3?%N zJvGgb9rftLy?BoMC}3x9TM7EZyLo7-TPfrwM5)q4uGexwmdT64kuA6eOzKpEyzva>|=q|fRikB7s_C1RLG5^P&LM5Gen_vacc zd5M-l%j>MpN`LX5yqFrP8m<4`dcwo|4$a!5gn&N~AAML?RuZ$`GNP-PRR2+<#RO8X z@8v?QpQ3}^Z9)XqC8hpc6m84X*yrx8e#cF(0zIXxW{_|$j0%i?W4h64_ADE=iT?ufW}#Copo@VEvrCqQgf5xt#4G+Fa#F zV4C8Bp=up_?L7Q5u7}Dl29?m-7er39y=V{DSkAXS zPEJm@O~gJoND~`ABcpjx(M(aR<|wK2Fv^iQ`XqWOlHHU{|IYO@9>+@2HDm8eNIXwG zg4KFvTEJ!chV#|gE;^WyHKDROdjlO>Qd6JXjHueO@Z!hiepxsywTZ=lHh;t`cj-%H zqQL>yxMgzAk2%E7*(95phW#)0Wgj*siFTnT)Cdp!QSTM^wXSZVqoww)iCU#{dpQL{ zX7-jt)y@u}wE8utt+d)uSWTJLQ%VDi@Gl0whOBILtf;kqugrH5o&!7tH{27)TsD@Y z_F1+eXbQYf4g*OB)BU@PFoU1yr=e&@Snv7Hnu*Js(UqR7IjasVFv61of4R=lwRT{1GA2@f`R2Z%Mq% zl6X6Z1#IqbBAN{Q!{%n|qOaXl$9LgiBf8T3KI$0Z4M^+HV!FB3aL)i z@kv-n0DPeT*HoKzc^v0tg0fFTl;lGn#b1`~Ib0ma%!LIcej-A91oX zbXi_Ch6^i-Vir_TG@+9R{;-%y{qMN2Sev|{WibLegE0$=L^1h>-HtgE?y=v+aqXr> zaU`BH?SnY)bh`2F7qtS(>A^zLam0@>H1y^O8Kl+sD-)PQ#2O;b&5swK871nxR7Pfu z!ED}OJ8zWxdG3|;&w`!T)c_fJr24Z1jaw7eBm zXTC|Kt3D(1yO3~-AdjzC!K=+m_C3e9JZQCY9ZHhwz<)D|>C?4q81wOLVq&oYA+Z8? zUJWUuJ_Y-IkF8GM6rJjEJ3`j+HU6K*RM?z6mBphtc1Wu?i(r#yfET#abrJkvkoSjQ z(X8*%Hrktdx<9Vn*`Nc5inCj$(@upwq3Qh5&g7?W?y__hg-`Ru(6Ccl_GS>HsA=vq zrWjsD;{r~1I7JjrtjkX7gtZFaC zYcDhnR?N|yL8Ne9@FF%z)UcT0$4Ww2mFlHViV=3#4gOq`*K=MQVW= zY&Jdd!Fla>C3;KG$Q!LNB9FeXbN@tTUP#StNx`b5;>^fHv9XW4H2sOdy26dzqCG0) zeoDY_`dnmB+asXRXk!H78#;Pebl5?A<|hDKzn{=+z@Dlfc|LVDYXFO%!*W)c6W{YS zSI_D~(|OSVADHLxhv=aVjQV_E9tD8o^jTR*fFUCiUFNjiFmL?~WeJ5PgYd*@{XrEn zyw<$}`7Z=&>ShW7J_2x`m{NVX(&TB5xB|nuRrH;F*Fg(XeR1S0Wuue23L#NJ&M&0U zXTK)CJR`tHl8vG6^F%Ky{Mx>nrXxd!WriEiD^2VJgjc@`V4($Lsn!)t2H#l4Wju8hP4 ztSG|qxOUK(?SMe9g5HXNz)i(}>lV_Gv;&-d>lRIGI3v1cVM$<#2Y#rvDR#x(E$5P& zx#L;>=l(2h!HZC0Z8m0ds)2xN)IP`cATv2nSu8>7pU7uz!^nRv#VbwUDbas;1Z61) zwtGF_sNlkAo|C5|`uxjA+8f-uR;4s=eU@IR-hQt|L=gP7MDP7`Ni~nxaD%+kUG|fA z^3-3cHEnTEJ>gbZ&QB-0P>3!L-qI$Z9C#1E&3y+5HDr0^f+uDzR{Jc(#IXGd2(8Bh z(HFM{#Js*2fXVeZ&HXGro{9X*p=8~-k_WA9I`|aMM$|4bRK{}xi0`I33|37;KVt)4 zDR;Ub1$>rPs$vlSq?gKIzwl6iwxT7sj!wFAW}G}-iIwiA?voP-gm0a`>-UUN?_tX7 zPNE@JN34bi!(Yv8TU+=ISEj#F4@`yCe?})>REZ(V4SRp^smxQFbKrO4{*_us{l+&& zkN$Q*^k(DJiIu5K*54N>DXy^R(sTk*TaHu0^8^|H!nkc|Gfylho0<7fg^AMA?Z2L) z83c2bAAyi#2iYe_$5;oRQu-Ol#}nWQD1m zbWfZZqx~+ThOqG9`j5yv|GbyJ7*}s+w3>38FfCEfMQ?kKtmFHdO+>~+-UFAz{K{~p zncB(RA<`vo14pm<)pL9~+*4l^XX(jd)xW;bbhab2-;l%o_6keL5-DY3T#h4*vDH}9 zop8&^W^od?!im<@R0VJ+BxL6t3PX^W$M6q_)uLmM9>t9->ul=4Z25Lw;-1e6uB5!B zwNjv%zMkf2M!I`OO%TnvU|sY@RW=!0Q}+Jm*i}$&Z+rX9#ekHx`~A-YH(3=|S2z7v zCj$c8XQr0x2RWdHN58E?G5YfOQLR9suoN+j$JvjV#w$dGGQ;MixUHiPEEgz*i1+B^ zBvuh~(DDm3YeuM9VM+Ne1pmfc^GsGSV60qLFa+u-fq!1VetmEf&22N26nE@+W98k} zb})!KF29Rg!xMxPMPKILHXnp76c>xA@rf)qUrnJfr)~#xxkvX|{!N?be9^?gokNSE znmw)k6mxkz%8zvXY8H~`+?}C8g{^TPy@1uKZ@8?@Mebk6T{>B*kzsA(BVDHK1&k_q zON;K#boZ#itctb5E%v&0PpB)@?IQKF^zlX-d;qvYE{WBA&EoYf6txeiQB491$8^}^ zG4Ment-Jga>He|&Jw_ugbKq_%VV)QMd?Da?S@_633Pt4V!U&M4>E& zCH@(AywlnO`_Qa|b~G;@0sqeFQv}MI+vm0AMZx6FIqbM?z+_;?F>ErrjRvf-;&B3iw$RJ#;RM_ zOGJjTLecJd=WOd*zCw+EmQVMpKQSo3!9~opN4>(aKhJn1TWSEBYD1G(AMp`|NB_c; z=qmCZi+ZO#(X<%{4G*zMMVmfaFu3^Uf}GfHAWk4ik81UXO6sfZd}EkI2B6iVl7IJd zsxhr@(b&o~jD9q^ymQgtn|QpR#NX>`V!cR&L{vbHgJrg3bTFIjhSMd zBfd#eA=z@{bebl-DQWXA4hAn(^NA9@L6;`&-<>fP;x|s3)y+NibCMx3bIy*ls{eqRpp=!+LK?vPyC{@E`WG)*P8mRG2m%g0EQ$HenkmRXd z;gCJ&v*p5aH_%9-gY%Aigh>*fxy+ z%U>}K27?3l@Uo}|Qsk21gNOu&n^I=DaJ3Q3L@Ype6gseSGEqlbt&Fk=i=BZS zMbpjZ-S_ZOWfJyIHxc7=DkVT1Rc{-PP2chb4hx(r3Yp`g{nB2aPEZAObo(`Zag9{U zIZ(s~AMtBdOJ6-$PQY(z28rV=QJTkbn{_cAMmekgARAFSmv&}vJ);y4j83_bjtKtN zR$a$U3is@&QNCWm5}iRUq$5oeqC|C1HGaGL*dK`=213mF+-&U`i$5CX&dacFr<{Ln zej(A@Ru&MSGsenvg!)B5)af`%MUwA>DQE1LlJ__hFJ6e6YR%S>>wqZSiE|`~6A`BF z#~mCp9}<1C^LUAk%CfNOOA#7sG!O}P!cu{YH|3f6axkPNC4bnA<^N*R8vK!P@Mykr z>1k;2+0RjgkKsZGvcPfqfQtceUAM_51_B4CrUe5v7dxln^K?lH_qxbXD24AVj3Tb7 z?YH-?j?{-KKtZhl&vSWcVX)jdXV2V$>+tvbr2p-&;QDwtd`W3co@6M6?FfBgi2e) zl=XVs!C6m?Wo+PS5&eQ|*P`{MFF_mlY|o-k8CnS^aw|s;?>B502Bvf^qq{fY`$ME1(tqu{KYN)Fr1!xCs-0-gfH zWHa-62J^t=gm#sSqOR0^?Xm{EF$2SPTV!=#Wt-d&h&c3?$2~e1jYO zZ9o~;o(66>&oww>nc+G+MsD3jN0__r%!e8sw+}p{%Cz7BH$^K;UXRpQKCuiv5H*lS zVU(HN5L4Oe2tfzASjEx2Xh)C#3iB03`5sd+{5GdQ$*L5jyu8lr;w%HzlNl%umVeR}b>9kX;IwqsXvAepH{ObRg2fhQrm?Eco62FyJb8zhyTkY-My^2e3}Ev zXP{1^>4BX|u>CXG4<6&>!){cWMVxL|S5VTL3e49dLt@u-uG#~8EQzzjBAy#_&L^KU z*3PwDNbs5K+P~4{FIsom;CYw%zOR6NIbB6=-28;g#@of-OppmMvyKgC>H*F4g{pso z0A+ERL^Z8)lFam&r2yN&ihlI_yZB%(SIPP&6-H5CKxH}RpP9>x(PO`2RQ(D7otSB0 zt`hnS446>E{=d2m>3Lvn1U%^z=%UeImc$JMJOHHO^Vj!MD)R+w!~*K?|Y+5oTKu6#%l4TXSY2hYdwGOv`Ro=skg>gN)8P z=W?_H{0wv_`tmxGIP@J2>7dC^uNo5!!wbj7_EqbP5O7B}Rpk8*_y6`JNa@W9hK09J zYtn0iMI*owa#u6Tly8Kxle_NKs<8=D2N3s_5%ANf4?FDmFT}Ev9v{(E8yQl*NQ3BH zEd@aa^tEd}xzV*36Z<+v_2$$X+UYhd7lz3s>rx$g0l7h*H0nEEgoLBda?x;^nZm(` zW7!&GCr;2rB2a#@ltx2`oAUPS`t0q@KjWQrMaI1$CY0H^-)clY2g!kTRJ3BV8LTFc}xQC0ihS-o+%aYE z>zk;eA1`YUeR4ITS48YKH#PZ5)03%RotVz-?waFe_3%M^poXI(V*P<0;h}o4!F;QA zcN^rvAN+M!tCFwAVmdGIs^kPGNUWIh_(b%BBIa{YNT}`M-qM5g-mS-=-i;aiv}|Cm zvZkEv1?z4-JpV!6#fbK{FCtrB!LOT8x-N$t15?~eNmbY4svLH%v5pE2th zN4Jq)m})uaWOonW+@4Foqx#WPWNg@sD*?X}JE2E9JDO<SNGb{m>igXvmNK22O+0 z{?3so+F8n->M>ssjWBKO6W_65&1bky-TCwBBQd|0OQ|c~zxbt1Gk2qEe%5{H+dSyLBjoxz({#V4x9$bXLGD07qPl${h81y4$yKk}_z`@gGs4mp-k%Jg zz@m}?`ujFAo`o#=M-f_sx+h3P^_MKiUZ@w16!EX*5=7I4ex<)V*qg+7k#oJK4BTx8 zIk-_Og|>A@S>}^34D1A&JI>GDtywFIjgh&5VHgk>fiIfofHzV>owd>&qktMz3HKLG zVbk~Ae0-pbR(*J);4edU({NsZUkg3=%Q3-w(GWsj5-XdnJ*`O=7Q`X##v6IyAKapb z?QDCqxUX$1X?ZAsG}JFMGSEP3f^9&~;0bihyP9=LxBCV`4Q7J~^q1JW6NfauD3n-f_D?Z-m~ z?&p8qUa1RLJHa#Uui~@S%9Y%nVk6VRD|XAubX0&kgUdDi!Be(Sfu3yez&t#W&>6z#A;99WOXD1B?wC=3%kiWmg!VjBQTh3#f`S-1 z-IGiyYAys-I%zSQsD)jitZ=dtzHLZqTfZY5WL~vt?1&TfA|nUR4f@drR(cfgM$;BJ zK~*u+epA{%t5#UpJ0&E;8Q~)u8pS0SOXP^@1=@a*QM8Zai&K8WJw04fyl%tz|Hwb3 zu}Ymxi-_=xkK%`XSW7<9&?v5Yg+k#NtB z`W0p)WD&)&e$VL&SH7o)I<2#!lZa)~|D}_=1mBTHFI;BQ2(`Y>3!iYjh$g}y$}a;2 z7IA{aSC!_emg6$E^wO=qe*ivj;#}uqa>bDksl?w2PCQ^WIx7? zmZg9ea_^S53Dv<7P-^z{{&0D5I1BX^OYHO?Z2L#d)Eqx$73tcZ=*X)S`QFDcSL0_D zt{lJf--|O(&dyqJ@voYmUyk7UNlzOyviPEb1{FoT3&cp~@<|`Q(YyI<(}1x1N4<M<#y_`?K5}1?VVh+~dTGr_mo0!Ws^#>ZN`H&p zK_yx3{w{tzu*PTr5$Dy5i{}LJ_n=d#ft!U1SYzA#d=XI&)zXPn}sQe0+2LdBOveg-922XM6Mu6A6s*LBve4P!J>kV|35)c9?2 z|H%9^>bpE(fvM-tToL0%U8~wB3555(^^sot^; zbUT)BGNuV0>HCo@s1%BVxS83Bw?k# z&$_EW@@sDqZ0%5I%GXhx&tKiEREyl4k`G~Y?-dEFf<=slT^@^F_;SgLF(z?!1`o59 z%Ze`O%mTGL)qX#VHA3SvLczSd+iP;j!qE7>LSoJPpPhy1c~QS3GSWb%PO_1UXh?QP z=gT?dtl94;((z%Xt~HM7y!f4*4h-e}@knz z?)_cOsO@Cj*0C_4+R*f)bn`vUZ^wm4FF^K!A6Vyg17PI~l+jj>E)i%cEjdW36%uH8 ziW8r)YKEO4^h|NbjS!6`g8eqR)9k#o0P=*vZPBG)GxdX$P4rfFA&nqj#;l?dI1HMeyH zC+55E&)a;wU4zw4TMm(0Lf#pQ!lBOf#X77!nn%M-)F1hb24V>JM3OYm{=OUA2lrc7 z=Ed1bYczJ|U;d7ntIN-kC{z^w>n&Bob@-mHZkK?)RQcNFQ7?S3|5qtS zBKm=1^|Py`RS<(Bsf0WIsz7EJZ&+p#Nkx>WS$PrzsP8*G5QPcikfET#dO|7tAblod zTu<)um_S_{`UZ8}Y!jM(z#?wJoBPj|7s@NQn49Tr${Wj`D(({7rl&#Y8aib#K&NrV zUq%{uA>P6Y=r8ECQ4sipup69i@o{B*!PAZFZr)Br^}qzqv-4=(ZXFm9p>0bZH;<`q z_Sa)@`Eq=<$-JSas~Q5h6_BqCz*kaZ+3-i_BaEa7uKk3ZA6sWn>a-~AUi~3bg2(!m zD;%o*bwyQci#eq^%aVF-^~=iMWtz4#=3nz94YvY2xk^v3iy4G?SOf*ZK~&4(%zxP5 zut3H#Pw>i_mGb6$lW#OY=DwwPm@QaIJzinjY+U(*W3|Wg_AxB&(9HKQz~$!gY!aT;Rc^sXgZFc|OiNbX_7Ae>eoqB2Vc(UMKo&41GF)%! z9?00yn=%)V-(nMW;^}eTF53B=P8Y{S@NPwORp|Gymds*`TUbZwe z!;7ue%zBgwn^p3f<@*d`@>yAE6WL?@+VekS)tH7rT13orO1S`0ycP}?lij4$%3n*f z7SpX`@yilO8!;PYz2xBRhXCX_7JSmiiW+6kBL-bUkPnA5N$;oF7d@q(HR-lG4oU0E z_rDcXL$7W3w=Mg!tVj))X*aApf@J&o;V}i<$v{RR1E?nOacP&1GjnyW^J{09cL#UP z0LKAceL5)bORl7btC>9#V}Rir8$*DbVNN3tgRZrd^ch=GLEE{H-;%5ivx*&pU7mY^2bh_iPOx;8*!e17CVT zep7<)6vW>=L8*u<<`X@~LoryHWE3avL_=0Wo;D;t7jfSOw;VsXC#@|DP>ZNni_B(8 zM}Mz2VXH>;8bscr{$gb*Md$#ZA8&{k)7yuEa+1k81idK%=X+K08jOoaDa48Jsdy1P z8MT7yPRU3tP3ita(FuuDDH6^dZTZv44>(jqbL__Z_p19? zTVwJ*Pj8W#93$($si>Q!G5Um{eysX#mxDMqk&d8@Z$ji={h44Hc=dO##pm zo1E_+SZpuKRNIp=R$abcpGxO!Q1bd7KR9!U8fsE#*(E$~)_*`mYKQmJ= zf*l)mWd862_z_szYKG6iQcSvSO@OrH)JaioPHJi%2hsIA1%Ot z&RG9}VZ6MI+0ACXc2;ME?+9KNK}k6WY)H4XsLRR}Y0I0ZF_G06^6)`YV41!-CJN<~ z&vhyDv>OK0R>iEhS+tHUV*xC4!zq{3r0*Wm#}4C1pOxQ#Q))$qm*EZSoy{WKUlLn{ z0}JiRKI%gRp@tFuKinZN2Q>hg4!0~wRFgW+Kck*Q-tcyTS0s5p;mZbb&nn-SdtBJX z<<;1Vk9(UC0$L`C>uu^etsG=;%L{*=T!2QhNmQ4AY=P@Ux*vnc&!lYRu59c|c z)+7cc+d-}OgKZ7MbLt2H{H2+`w;OD(UvflV!yHqAI)iY}TNWb0<_i_HQzModihzx= z#H{!+s!H^6t4;nQOS?47dT+lHUopj>ycFG0>yy_@S+Y2?7kVyMFmOk<9~@k)Waw4I*5Fy2H(n=5?z^R~;k;{45oDbHsmig8<+_z+??eK-`c9Eg@y zyKL59`vecLY`20&{oxEsPxy!{4jC7QbfSfi-fO0~Z*{t99G}fdl#& zzP+68Pt2IQ@Z(Nm*Yvs#9isTx%x0?1{>|~^ zvo?&HOUV^U0-7f!bg_?va%F#^Cv!kdtDGbPfL`L*5cSO6*IEM0jw|eN&IOdK#83re zQ=Ol$zheisv9+Sfw%`NsFxZXI)XKE>Sm)y!{%RdXY-D_xQ^F_LOqW%1(iaO?$F(7{ zIv2O4TuLWCZzoTDG^FDwx^EU>UiHN^o3b>8lSA#b_bzAqy>*=DjeQI*+K6h+b5j%1 z!>IU%c4tx+(7^LcufkIBmg#(k`|e}A>4fkVxcmy5aKf%Hqk4~_ zJMc<#m|C+tai%JtFb$e=SBWR>gN$J9_&CF5E!JaAs|tU(vqQWu85J-qHx=BS%@_iq zi36>*?~y=QwDjv6?>149@m!_Ny_Y-?XAh)p_%6y2lYL;#Me_|yMM6U&aV8hx=ddA)-(&{~0I z>+pv2=BO{;alQX*U7@i>bZHBfWM4d7Xr?rhz_u-!-nIR!GQ2V8yvN5>A~t;mSb=Oc zp&MVc^ilbr&5DrcwsM!ZCL!yi5F84!yy$kkDjSRONz zp|kwZIRze1jd+eFFXffyvUMt2@q%vQBxyb?#==mdA^}oxBY{1-E>aEvR!3IKc=7C9 z1-~W-?#Yhr#Qi(lNmZCX<>ErQpId3FP&ymHVzKWUu8FDKW5EhykYrk;LRr*Y6Gp(q z_)1ii1o*87*&D7;gnoG=gqg4by!DOjU;opb^A_d_WBnswsjJpN@x#Dx>S<2UTq42c zp!Spwi20+26t%>W0|7c3hlS`?Al6MyU z*5gSDKj9s_{sx!J4X>j}mqlTrtKL}FJ*0YYzEEi%4gZDamO}B>KzJB0 zlA%5>kApax>F<*hy3vMVDHN$$vlTstkNjFH-(qL&REs-3$t~izT|f=KnI+-Qwf*8! zf9BMZ{@ffq@Z~Mmtk2BF|4&rK$}<{LE)qY|$RjC1a=?kex>Nn&f|rE<2amXM<8h6Y z_4qKul88w7@w6p`y=YuZWC5JNF{!w*bC*P%=PGwS*BmsmPB*F7bmBULwMTbzi`cTR z=byo_6JA+8J7(IO4{eLV<&vUoj#DgRoY-*Lq-Y#8>ao)4;J~PKCzru00Bs?l(#J8K z^1pTNPAzm)`9Q?PsZzs5kuZ*?iu2#Pli9xJ=Srt(togVrpcEk~e@`*BW34 zXMlR4-3LMIXGXrBGAcE1I*1r@*fEZ49j%-L6M@TC# zs!dV+Q%d6DXj-$0NI2L7^gSNV^G`nOOW&R)REPQ6WP1@4;d>kX-W8V^p~Ic3VjN}# zNfI&>Y^9!W#VU4u{N?+lQt9!DkccQ?i#YgXNB*Ki7Trt&n{f@qMyZDDBCtb?2f!=aEsCHQzf z#~9YBEB_pG6DknM9<$$nEwG42e9DHhgnT&$HGL=GCUcZV@I%c6vUi`mh#%-RI5-I-0b1N_ zGAJ22>gWJ@QJW=LkHi`qs(P0*TbBk964Tnhu@;!$of|(QedaD}+K9o!35R54JDv)% zi6T=jn9>?ucc&_Is<95Mfg8tK;KcWrqZNpkcVPsw!h;h^CQrxj3hLPbO$7$JWxR)D zMnD4Yq>Gd2^3;KD?V^6u#BusLkj?$Zph$yWQJ1uxnu#{yfoSc_`?UwA@l80_Wb#!6 zV%9@_FBI*Ns+Zo?M+cxiRe$YvWJa6(|9b{(waI?kviICl?n(UO_=YQ=+pNn z(3@F}5PwJ=Th4qP8CGB#yY*{2zMyWLE5HPSzp(48pO@Ty%5YP^B|2AEJiUj+l~z^% zXosFR{Yb3Pm`k-!f%RyFg`GQhO9TwEYKv=jXEsHF4#`O8pPQQg&| z8)@%(zA+_ZS$#&ABY-Y_-QXyjHezmUED^&!Y_6GWI_%3CgiX|^=Z6>ZN$)bX6H*l2 zRZLNhTIqe_BVtX9p7o)%hs6wU+XwI4s?D2$o82nD0^iZ$_IHXj0`#qQ4wgxcttJDL zynU|vsd;$cSn_c{{aUs|8nSuVF!#Lwh8<}ee{^)0o#X6QqyYKPvpy&z5Y#Nq*T1Bu zRYAnVpA=B-ce_r3M?hzjFTCQzKNft!DewIHoBy7(fL0QPHMCuB7&$U#e*Vk!x*c?( z0C#vSr`pzJDcNsI+d-Lx%h z3Tpw*h)Mp=StPh)CwRo@awD!92_O~d)Sxl84z0WD^7w~ETsi`m*_nE__i{^OW zdm|idPOGqeTGHB!Lc?m0=dzD0QNB3|JQgFF!F3=ztauz2H3S*T ziD&&hdiJN#hL!0`jDCtgzpZPu1M+NWDJQKyw#-1_hn|SlGAJV$AL6@uExfcOX_ou9 z4HkS3uUF}%kDI;+bU2uD>Zlx3`{Qe1^qb7yzP1CHmanEGDT%>R8!|}3#11eF1v3g0~%ID~GfZi2f^!`TbjvzZ$w<0cz@q2i6uF4z(bea%axg(Hgq+!KX7d zp3Hk$G698I7Bew+vDgg)HuS`L<1Y`d4dSQKezwD$9*ju>g%V-kUGvM=Jy&5AEjMA! zrPP`Pg)B4xN9u!c#7yamDZWgx0e@1JbY@PTGAWp*zZmkH(x1V2wzTSQ%+yG2f@)YQ z;C|C-&%&PgD9HnCQ6TnuVw(2tgu{+9xw_h1qq$8ik15|bBdyfADxC!HVY`ESOcAX7 z!Pfeq*`T-U4Txhfx!u)FLqanGp&d)nE|VeRP)6+Huye2Tq`eI@Q6gBjN^h$4z2n-i zk9<*1v_9oW8kR!ymFBVg{fM<~QoOlv)s|iLKR>npPE)3v(EFz8_yPS?Sl`hooyz3n zy-)H@83hHx7zN_fo6he(179D9b%7$tzv0Rw@xM&}z2;7G&Ec>tX^}cIps;5OEw9G{ z3q$i3t)itvQG%XA+eB=}83yAr-(k$T=Hx3&m;VGQwfBro&oz{>&n}ugt8%}KCMi-} zePs8bo+I23c^1{X{#EPueiI0!EAqXNqw(1XC=(QU5jXaA+mP8;z07OsV`xn7Q96wH zbcrU*H~RIT2`_K|cL7#qEbTrE*Un+OrkSw>nyzB%IBL6FNexLVuCCZC*TzT=ylaJ1( zB{ZsJMh>XY&)@z5OnsGwrgb7V`eJ3b;(Q}NYQFCH>!9R;Rd9FN24Shy14JF#AdjCZ z9%1bxVMjac;#oHKgQ9K_+6|xrg!*@qf+#4IHM*Y64y#wJpYJ>$SZjWmG#1CFp%1vCqTy!xAzn!-9Pv$_r z94|!p$EQMh{o@suc1+}t3JbaHP#V{J@wc3Iv4ZM4pV-f;Nr$$Wh1pA)Y=4B6!+$yqv+GTxFMJtXYC26jp^=V*iyx94Jl zUVw8!zbZs9j9!ubr|>Ez>V$5|#fNNvh+?sw;>86D9&8h{9*Gi7ZvqY8T^vg5zvg{3 z>tq&>TlPZVfv>N~qnc26qBI=^1YtV zo#}g)b8TiI-t(u+L)uP0oE)Xb?nq}b(8UH~?4~sp9}-EgG+{dtxS?q{IETL-LU5Ar zD?2wl&-bPib*(_NH@W%rjzwD=R!qF*V8#X*3rA7;VRs{BTaIQO#P(lmxuSntTnxLu zNTK_l@Vh-dMEs+)-Q}~#^+Uow)xZIXZ{cylc;A5xjivOW3W7yYz5LA=TQ*=>_?Ks* zVR=}An-O_A))qcRGkl5ZhYUKcHeZcv^HEct&(!d8Xe8DC2wjzf`?G%W5q1R!EU?xR z2JpCc*ZMD2pc{9?SeJrkpoR+x-0hSjNtZ@ocYN@~b=if!Ugujy)frhBGmW6$Je8mb zJsK6Lw#-N5vQsE87LCfBV$XCt@L*L2uaCRdj#jF^c+}-R%`g0O9pW2_O(sISxl=B+ zZ}&3Z*DKqOyKX)xhSHvj3SsJT$zi_~3{^Fi?UuzQ^jUDr2wl?`!4Rk#-@lB+15vXS zxt}7r*{$D#&8MN+k%XEWNdNoFK^3kS;TbzX*l)7%R}h0@E-6NSzKZ{1K9@%FV&kr{ z{SnLs;9GP?#z!w&CtnJpQU_q! zKKTudb*W7v5jlBMLC`+K#aqjEJHwP{z*=k`equc}FKSj06TEoTeY|!!rG5~MhYS@> zLHG_+`r|`L@3JTbB?ob-3!Io3$Mn0``IJTu*xrjAp|M9f#zW~J3%ARs!yAn;&QRl+h@}L%eAPC$H zw{d`H$3|fyY2SDgD)!P}7AFa1|Rc#E~p3|*GuSu4gasJ z8*+njjczWm{c%rgsyL6#WauW1nt$A4%k`kdYn3>DB_bqlxa}bO`@GJ)&wY)Let*nu zm1Y<(E=fnI|As>Q5ZBrMgY0m5PKs`1h#P9K{{ljyna9MBZP6JE)-i%E>=2{#;rC2c z$&aBJ_hi|O?ta44HQHT1g8*vD(Bm;qjl|grO?H=ptz!J-wt_TRY!{e>WxX3?ag zA&FQ-tV!+ZCFis%nY}$qYh~;9PJr96eT1I!l~KU9)R;d0=<#pA`y&htBoK+uNWgyB zKR)54Lg;3elLN)9>t}QSJt#TZ6YwpOShQi4x-hR8 z+K23vDDoX&6L@tB0JeU{ZCDQ~qGZZC)H!8(++BLE^~T3L16VZo+T`YB7&5RTiRnKR z1z!H?$Id0yao2NqYrXPOYvC(Jd&aRzJUd?AuV{^;C`N|f1pgiyqMj_u)58(PRiHr2;I9gjfoc+ zsQ}^$fq3He)ltR%U0_S5*~@NFZh6s7m0e;KIvu_@1!|MjirpkRz_CQeC1dZ= z8v|9o51~eTUr53UgZrlHaDa#>=0}JGkQs>gp(YEsT|68S1+}0wTR+KGv{$AKk4NRW_je~y0pqXSG5j3KLbCh*xR5Ex-LHc%eCZG5kJT|%3V?rR*?IfzLLQ( zA;(XQf+;K}jDcRD4lJYbyf97KyTQnld77HWU#ge*Ai6QUE zSn}|bR#eFLc9Ppq&Y)U+mw&m46`_$#>G=C|@-UA8pio#j9kFpSCSZl6+;`m@By6)` zoBBN^`?I~rSrrU!{8#}D_K;ugPcOv-R zOs`skjsey);8g(?S8+;6&>)I}E@l)>Gw^x?5J7j*MJM@3cbI-(|R1ThV*!P0&U>I2TXCxcE^?H@KQsfP&H?(%m5lNOyO4mm;8acS%WiNvCvocXw}^GdKGBeCMZsbjxDxdCzf=aSgo; zQE)~W3m%2N?Qn$5s%W5)+drubDvyM1p+Oy!k95mWWF0}v$Bi&25;TelCJ^LvWHIgR z^n^gKThK&UgF8rsvETvIcR9dhOC?ZDWyc~QKE+ya;Qodmmy7cTjUm8z^o79b zNQwrYR?Z)SuL&jvwc-Qu<+fCl%_P^7jeV5F9_WxWBZ3uduzwnWTRIzHU?857$6LM| zz+mer0EQmtCm8IuF%D2bP)n%IlGjGuRA#c!!OrBTCr<&pJEbMv={Tg^Ab8!5z{>P+r#S`<44zP?=$oS>6YZ7EOORBg zek*uTFOa{cLsdHuV5MtBc)`WG4KTM(%Mv}N;FAb8&hc`Q;hNT4sb44G1 z+%Mpd#-s?C*K7tmtknH-(vj~jzaJiZ-d~>$XVaW)+>Z(^k4sqogfu$t@cwRixE!er z`j@3E%|lqd#Dig7rxw@OGd1_Jy0Ug$An__7wep&a0sZ3euo;G0Q zfe&R~-4fvy6UKyYTfZ6MhxFlgBII^ABN(yS*U!9IflDa8SeLl z_>jBi{TB3c;STN&=QFDLkZusR8pIXreD(~HvQl5FKBdAC^?ikzjmGodL`1pXxJoXI za=x5;#w2YJ$*5-y|7L6R9Ni*MV0rBHV&k$TDe2{P0xazw0%JB!yGjd(73*YB1^rN{ z@e52h(`Rj-?#>(*>^@#e=D2E+qR?1)#LH#BfA2HOBeU75^YYQ>9A~@i*X2?_+q;yP z2lY=VCx-UMCK^kuS5{x^U0A1E%yig?!!ZS*0{X=nKpo91HFEMx&d%^Uu1S>nEzN8(<&_(cz%)KIHPk# zB3kc+yCS|xcmH6)vIm7-pgm4tGD-2SqM5DFE`UAU<(301Dxlt9&uv%d89(xPagn|k znSRKw@?SR#jSriGtFW)-F(;_&gO3F@i=Pbg{f-wrHlJDeLO;oU^X4+)kG_BmR+y=b zDnd__Wt(1bsFCN$@``+X1}+iL^qO#$+6$Qxh7|qxDNAGrh2$_CYJ(0-`$Jd^kaw8z z82mb*;Ag+_kX4*1{=d`HdSpyw zZ_!g0p<%IQky-l*VO67|&2GosDVHQiezBX2)L~|bSdmp3=?N~pEp9`Ndla+ALF0PE z8Yn>kEIuXw#eNsfJ{o!^Ld+!D`$3|s9)XOBrm=i?8psr&TL#D!l0jBrN&NR`2P+=! zO>FtArBcLZ>p(F6U28xt`NRSx<)7LE4A9a_t$e&WpG?>no+B$bOjm%6(;O~>sMp0= z{NqxAT zB)T;Tc)F*iz|KAA829hj=`NtVlj-A}Zgar6ouRba%*aGVagMCc3{Hh`rqNj5h8;M6 z0N!!pSf*g3bo4PoTj|wi#zqwjVnag1s432E68yGsjby3zl~HVs1F_Q_2MF=Pn7sI3 zMh~TuG2+ml_B_VHMLEurmuQif)^wysHjUS4az`TXkE|@|tLfq*Q?vE1Bn@_@ggxC) zU-v*r$^i{g4tNx0rR?gQ|GkH};CxL_SXHm~Ixl*6Z!g?T_8cMQEcgZC?HNX%QTQ1b3fCyRg8It;4FAsQ)sdWzRk1b7mG%us5MU+aO05}{?0teZ5 zizFkJ3iR53V_Lb{YC)tlZpVt&8C;prl;`6B$eupII*G{mXg}dx8n-H%_Lx6pOePZ~ zNDJ$HJ{t|c8aB3K{2gSqT?(SqOXzV-z$~u&|Cz<5ZxMvEgm>Q&vyb~mlncH5eq>dn zAm3rT@%#nACrXOjvA4~7G(S<_F8V1k_69%=hE+*Ui%Tql)sid8?KFB)@vIo;qS=g@ zV8xoe(+BrUb6xRutH8aW0NkT{P~Axf+)t!`-^4&R3}fz-GI>g9O@o66hEwQzR+q+H zZTa*|D;SIJB*P^Ip3E&#YmMoLz`IzIoNa=ZtE&DymEKfQ>O1lujVzcg2acT7l%EFV zXy4GPKQkyeSVjP0Y2gy!`@sM2Pj@HTVCa0(iOI%f6Dji z6=q8Dtn>*hlReS$4PvLegu#t-b{zp~UShRu$dyfC zyAS$ap*-=ZM;`x~uZ2cDXYb3VLQ6}SnmWTnnHTG;=UA*|fChUpdb z#(hno;ob44ALG0t2qiRq zmj!oFHmH;hR(CeAIGzuxDbUzqNmfq+X#Vkfi&42>rj39g?|cFN-Aq)=WK7lUGk-Da zjg+ugIhjGhF8?Rc(KNeQPCss@ST^1a`8gA06$~KC!;*p~ri1~Es>bom>xF3aJ2BP2 zz4|49*s<-?Q3Sj%Ot1&qwrqWGCuSv%RX7G-8Di=&%8u2$xkl8s=)Q1OPnCYHxg_8S z*;7L&lX-bHWdbmIm+gd0MbW%p7nqG@2h;tAI)EYuGEh+-+NirY(7~-<{Zs#k{ZBe2 z#mBzI#qZp&iwy(u-B+?xk?^7}|5}>50U{#AIt*+M!HI3X+YLcj z7?21M4J3jb`OVHuH>NA7o%fqHKCm$9lf=!R%iB^1ul1&w4F4Q~^Z`GuZi6?Ly*=zZ zr*Kq|Du2FVN7=NhoRi2LetS^Ka;V3nf<&bJ<~6bFlcBx@)O?q^TQ)*Bsfi8czl%8V z#Y^%27&5{@{5>HQ6xQ^WPJLgTI%hTP`~ zt+0GOOW@l98Nc1|+JP+Pi#O#fq@=`Jm$sE3n5*q~9ZR!j#<31J?ZKqhx+t@xe9(D` z09Z7fpN)C{Xuu*DIxg1nDC?SI$jslyBY1v9%bqwfk#N$HJUu%(nGUitNS?h-!~i_U zkVxv*0nQnUzYrziqZ%fs+Ii zV|kY@3>uga9gjn9QdO_qn?q1 zXm+O+4Dhw=bN(K|gx4W0lDxQT2rArlPsCui2CswFM0U!t`#2Vbl6(45UIXc8OS|BBoHqmo6bVeLeWObvs){Y>pcH z)xt`T=&#@NmvCj$o)A@U)16eYXAVGj9N_n+V^;GadEVq0E0W?XusubA>+@^I%mZ>qE~CmE`&&sj%J;06G3c>U_->kf`_0pH(=R2}hGBw;fF0?@`8V7q<| zIm68VFKSHG4Js5>V;GQ1&T~B>_#v7umMCONZfI%=eJ*UehOEx$hp4DUU{^P1ut{Os z>Uv1`l{Hi1wfhvqidy}%V~6>aHCVz0z9G^urkh#>2mV`ci4w3vXM zj?+JB^qXpE;-I5}od)nJ~zh8jR!{Tr*)(8^r zy-dG)Ix{M3m9|Yut(cbkWuJD=7aA|G&oaqJ1Yr8FPcd#j-n))OCI^!X(5$h%wE>vw z(|A6ma`>ig*6m(R2rNt_Th+0|4yIVsW)%w8^L)@Om9EL3Z#Xp1 z&l4x&?-Ju5(C_PzPJX1DiPc`v-3;;YZSQt4{<)M!)YmpWpf7}5kjW~rnJmR=x1rDV zIlaCZtN=Xivekose1`+RB}}s*>HokwIx-k9R zXPLFx^1z|3Bi$3Il6d?fc zfR~UGmt8dI?m;CafZE3`d1-_je_3&C`j}n2oupw5S73)~p^kZqI`LAoln{@4{vDq^ z+IBrZ5;VkunEG3}GL{O54Q5vs>3L^Fuo862FsLrm4^NxS(O`t2*(F2Ll1ir+0vtYUwkod}~n;5AHrr%~{)As%w3T(7Kd4 zi`p-smQAq#&G7bXO8!#AsNY{=r^P4E0$Ef^8fE-oZcZJGV~Yky@oOdnRxXZ1t6VC< zi&@9!siWf`rhNZgJ7!aWg4@ORf{OxSt*}Apbay(%XyVfej-ZH0)hzARxCSaAuHUuE z_zad4zYh-)?Ro#ip5@ZAm6#M{K-o~WAY3ymh~juXb(S6_daFL~lk1%dCE?BbR8jW0 z$h*qIq~u2lccCzpTo}ROhN|L0mGhQ7SD-Hy`Q>x9`Q^iQL?NQ5u>NEJjjN%>kA2oj zCBB|CYgn3@%&gzNc<(IUIOYCjpvv?p()+XrXGZfw3Kk6>9RQ+?*aT&p3V5GQv}lffDkgsKFxb3-0^-ECKQ8M&k&z z(!`xVi(n>*Xv-s?3e<`IdpU1$pf30m#cm7+<}~TXR*&D{ zEz%sHctN%ckE;WA=kssg->VJFC8xjnr1@$vywW@LV8+jeL{Z0ugI`-<1Vk2M5w$Gw zJ}t{4=CCyE@3}|cCc6?jKb5O<=72uuf};Yp<<7)O=FCSw&1O0>A7V09%mqpYtI*l` z!+(ffk{z7u&!WrCO!Cmki2Fh3R(<{>;2qHj&~Vu&nMam$j`0`Upa}t zHb}rB37*c@h$W6GjWNXiEh>nIr&W|0%kvF!I{x9bk&+5APoJb)N~Bsa7VmMuv2on< zWgf7)(9J5(p!^Y5x&tylHyaHGxD-w;`VvWKiK+Ag#E6sd?Sr(AngMrK>CfUH%7ELH zTEVFHSdyzuj@h4>6l3qPDBP;73Fgi$=m!uNeT&?CZj( zAkH}qu*!UjD6v5=7sZp$Mr0SUx^xflm;y2rFbRmLR%}^9ip}tnWuO*ePgZ)yPq8N?0v#GEwAZdk2FW|1tgEeDp8Pt* z&|fCe8&jb0WGOy6N4Z?U+nvUl~mtA??!0n=x-g(8|lD&q}kIXH$# zXC;*Y>)aZ9K}|($UAEc$O>BCDX4e&wL4%L)@uOt%zJ0Cl_pPr78GL9aLT~;EG<;8) zUO#xl4o7ZmiUdz?XzI2@ryGM0Hy`3X%l%y#lS@`G2!kI9d}Xk(h2H->Lhe$T%%-jz z2p7LVEE!=)Y{#^l2oO=;gOaC~Bw7H*z@@3Q5I#y36hw*!r|0K-GnK(|Q+rZ*_qae9 zm!1x^D1n1dAI=RgeB|^%2WCL!Hy1zT4v;>vOXCnkO0Z8u{a~|BWYSMC$QfB~d?+jB z@FB(Y=!w{#t%9U6fzuOkiPQsl1z2>@b*6oJ&jaQZw8x2R>vDHj9!)GXy0|d7HvqRG zL9SBKNO-murMCX!E**sM6}@Jj;&JvfO`4LE1AQ|gx$k&$G2Aq(o zcSlr}ooV+}+D#|y7NQWR-MFja0AeAY)aT|W|Aw9wUM-hw(jIn)W=&=cM#F-9W&&Et zT|X&m1_w7ad1gJg6VxzmoyCqm%)HLp+UMvPF#K0%g8Q-cmpQMDNI2^@>Y%kiGW6)# zq8@{%(~R;L1IKb=>vhmPPMX6 z|4!}|4TkzsOz73bU5XO!y_TIsa{vxB26=GNs#NNDl+mL}=DdG0>Y7C1eB{@=`=(*i zVxwp?_oY{z!Y6j7f~Y_%S|h<^JF^7_Q2W@Fl(GHG{(f3%vKS!~k)s%l@B^q%Xi(O0 zUn*7pSpOpsTVI7n-X6R{{Gx?Mgq(=Jk2QuA3c@0f&;1 z<2)R;0sUfvCZM?ig45mlDRubn9^ARcdhOCjN2|LsAI7F`GON9I}wbA^$(5M96#N+k9i3Q z15E0`6JrD7YsH}+SVD9+l7C%6H5=PXI%`0C16WQ+og?!i4=j3zJ%OR?i{?Y09rd|6 zn85sgPFQH=;T*HpG_!nVyg%hP=cUlNKVKRle%^oWocqXEPG)y~eliM2sMGD1>;0uZ zAm~eN_^V7Ayr;LzZHI@)Ao$Y1)*9x9Np&;vC|=DGAB1th17BQy2o{Gz+QnaOsCZVl zu{8*gmyvT%&yzCZH{H9oMp1j(%9+i83?Kdi>{(A&J`BbQ`(wf-apHC5$Vn*!e?7R{ zzB10l!Hj;6U6I*whDKVS=Phcre$xph^Yu#$7OGHs8p{G;BNQk zbGv;bxiuwYr_8tA4!soC%1o}{3hWwjQElt>_xB2n}bRYiQlQsmcUds$XmW7k=y zWgim%CGbv-)y+FTei%;T+a~Z&+(}QhQOpm?0rRv5;nR6-Qo7;>3{=JVxa?SW`GwSwmj@Vu|Q0uQU?&Ek+0RC z>kj=4Md)Y7AhE`bc3FJ%Vgeg7?*ZCB0qx5Z9CuCRJ%w1uP6u7dfBSic{NBaq$^KZ#B@76Xx z)ECkW1-D&oW*y2E*rL<+v>lugcaV?SJzx0DKK;3@3{m=UYwf{722>-}byX+pnD^4(A-<=;GY|b|rF4J+-p=P#w^Sf;c z|1wW5PAy2+#dDZo3GjBI0=lv{R>3J{C*77{zF~T6PJbzC&2)fkjU%yz9TvUoT&(Aa zPP@Q3bM3(+PXK1>S@n4E-*7t^>v8@N>!%lgY|<0?LszVthmw+nM3*yl4BBf69Gxb^ zwUi28i0^T(g#td$7ira+Q*~yLx@dk3zho&yXO4L4C|YFV$BZjao@}-MXYJiFRijY$ z(`g0OeIqQ8nd4G{KoX-aVM$u^5%yG)S|4S)ow`(lTB6)9c) zcSUEO$6YTPXFe!uzO02Vu3FoE&71zZ-ZX{&MImGbc5KWFS{pV5-{;KQ`qx{t6YThP zp=iPOZ9kMgy=0az=LeRR%TpJ&4Zdg=>;<#AD$H{Xao{#6h=w#7fdGP1U9yEq^>O_& zf*uW265?h`3Qhb*#xS#0qm7~Q-SHMmXf!XIjnIx7dFEPkK=e$S8|(gRQsofwPMLSG zv+2kH6cwm$4L&ZyKG4G-iR08C<)ATQ10eYSDdfL(MlbfH{JjE_GeP}Um<-91!;mAPReMIh->vV@T~!)-+8mRb`URd(-=Gu`y?-d2|Z=Ua=b-% zp6j;woUghPwB_|$#+MB@4C>u+!eo5)$j0uyC zo|&T(z3&7qGv46brSE?g!>Bqp^Z05lSoPzKVKW9Z8hSN%wva9gzB5%%E8#eJQ$g?1 zf$U)QYir2z6fqH?a-|yPr*n!4s}m$kq!h@s?@EPQ42a~G4j|iEteH7M_c*B)* zq-U?JOOVpWaSw+lT`QO0VV*D_31wS*XwUygqjkfNEE}f0KJt1 zXO7c_k0>&VP41*v8KmXyX3$&zNNdlnxi!nDPb2pmf0-jiNGz_&d%l0pp7g!Z>pN4{ zzJ7S|2*i>t6okOSfd7M@R2TpcPgh}&K}|DOox#764RCK{(d(r+{m2PLLyTW!BG|bK z_#&lori4UG>X3+K`2wsqkT_ErlW%Vpx!pOA-iY?hX*H`P>$NN%iM-~LhPpGS+cI(u z?)5X!rkKY#2B>VPXG!p}m|DE4ArED$x8Aiu#lJDd&NzPB`s??vB8mRkx-PETOkq>) zV~SV57uoB0)~45n9$ox|Deo&qp@cz7a}M-k4m&fWmRMa0j5Jlfx;$O7+I{#C%(T~Y5=Vx5y-Qnf8DG?$!lxDY4M#}{5TtkWJweh2m@N* zaM|tnJ_&eDuiw*KU*8dIO%UCYo9g%n_AW2a>u3vXzwk~~1x5#pnFT?qGuj4ljn{C; z`I1LAw0&X1w06^#hposxNi2$SGo6O)?!t*6)6VrQQCT@B^?a6?HRiA$XR@lfs}BH8 zoo-Gx#-sy5R0H@T(>C6`5ql8mtmKL1uI!aJfFd}q0NTs()Z`eAzwe3tGaL9(L7HK% zIn621vF^M~PEURB{6m6b0wQN0r-aPAsp*J);JpsO@mz`Ht2RhpHKMd8B)pw6XCMMA zDgbiz=L)AD)%5Ov=UNh5XYGf)DOi`n@6Xm{&Hf}7_&}xQ^PUIlLr20lG|0de*}fmG z=oahKF#Bfk#8otKL*1Ec)${Knr3=r*(e*fy%GH@KfdarAq9}}cS}6QdA*MJ`5Q0Bf zcbEVrtr~({B3sDS)iDJ5I;orVj31&Koc9D$a=Yi!Mr-6UBfYZK3QA%rg zMpj(b1)Eb5Ki@AcrkHufOF>wI`0NOCcV`KYCpD7!PPO+ZU;XYgb~~I1V5Zyd^lTeq z|IkyUbN!SUIkCqk9=qDL=%M>l@-;WzCU6^*+Un+)TXC!zbt1!*6dnu211*Ek+=+!6 zEhgU0sjB?n*LIyR$V{8X+ia_tE4bKTT8P+FjF7abW#xZG?3mCJrgiJseffAnwJ zWwXdrt&3w;@WZ8?Eb~<={o!$@G&l}LPI%$apJQJxO}M8!%-0WaoILL$>RxK>*(EW( z61u)ARyKEs*4>i0yE}jxOf7^0A*8 zj%YO2ba1TQZ{%ccM=yah*{lCZySqnV2@vuA*WK=#_k-va&%y0C?iI~*cA=4;`p@MC zLOhFQizU^#XXUY9zJG6*nuPR`LSll!S-GX3W;pVcGQ!|=M2CkR2~@Mu9VJpF{34^` zijboouAcqG&^P zbUGfGBDp`7@O_tEDpB+d+Dj4cVychv()0R&9}0~NO{C^}!g|laTL{ht)f8jjhZTOG zuI7Sv)M;T9Li|5Ls+A^%K@UO_43tqXZJ;4>U?e@22||M!#;mg*qR7?B5i7Z0K+EEZ z0ea_l3M?6<>~5Sz(wE{^mvz8wrp+fB#bReOP>KtrG_6|##Ie95ftt1q?pFq!E>VPC zHsgv$f5|mf-TfirWFOV5(Q&|q=4?;b-5o9oIOkR(ejAYH#gt5XrGGqxjBn#Gf_T-U zoF6=@L_Ctw_SkE^Q3|W$+-NKdbVlG4iA}n{xTF(x)uOl)(jfX>5u@0$eQdkhzjp}c zHxFA$*AW1GI8V-S#7=lHDJcm2cY!tkZ|XzGHYeb#^o*gMPn) zZ2&ljMmA)rIX;N|`yPhUmQa>22+DdditAid8d0nws;G$6PDBE$0GyY&w_Y5sB%5*w6c15K9pwN z6XgMSyoHfUBu9Ym6|%9BdWh%!_r9=ld+Qd%ah^p&1|&R8*D-V9%b((|97K3h9W1Y!_8QF<)ts=8 zGMaHiBUQ8tjp^!bF7Uk=0KO?+aCdVP*r{=mxNSE_(_n#Tht63XS}P%e;kHSKVS#zd z{8zKORUz=2|Mubckw!ke690N0MBMc*H|H)f^H?c}0EmA9!9Xms-;^G(+}!N+2qL^S zT>m6!mxWs0qgO>eBu7q`$}`(x(ElaH?mtrQuL+@;bZe^N-D8t&NdR zv(NYTu8yx1rJkc+=r_UyWP&=!YkjMvPyNH49bf&$tyf`^vt~*jpG4P+MSFoli)$a0 zZ}Z<#L&y+>)D`;^DMkD)wrN+?el_BKKrGM8ZM_^!a{QU{;%RWrw5pEpdkduF6`jp! zB)T6`axef@`x~&v2NmJ-l|8-t4Hk=044SS%W2^~|y0b5q-S2p1oPSJvj_;oQC+B9` z&zRgTAK@Dy@g$SPxl|K~sZ=#>aWkhqtpmC|po@5tBSYiRr@NK@>ZIg1s0zgBq|bu= z>XYypuE|F5Mjv-&o@vL2<^}1WmQjLQbExE%XUv!fN(Ze7<3cHC(4al+PHv|gQ_|w` z|Mu6dbIDNb%XfxwGyqcBN!pUrzR?0`DO?(dea!sgBJ?@Siyr!GJAnO>VZuB7ZL#E5 zcp0f%8*2>=dMtSRL*xiQKv{Z)Sais9f}jMbu(&x@#tYEp0G65I&UAm$ zAKmc+S#QMSbY)=|*q)AMJpVL`FjxeE+FUY91)9pM!$Z_RU&T`IGgCz|po$vc%fMvr zhDA|-Yqd8q$R9dd1r0{3vHLf}lN@BaFF2IN7H=mLfZdYWAQ}e%#xe7`~@Nlkb2P1ET=ul^VqX?FJbSm ztvH7as2Q;B9@!)x8QFl=u^n>`ya3n(ef%d*!{ZvnR%28XVPWRemYweQ)Ai-hRUme$ zH@9%rGwi{p$4Z#6b2n2I)j{gSO+Djw8{0YrCT_-n1s5kwV|X2xj@GPkx!% za)OBVOK3DoPy{YHX@d>*!2TsNDHCB|(@x@&8|T?gpp%q8KtQF)&pBxX@7vTv2tZLi z#%LAvywlP;a9zb<lVGnrV6p?N*pF;uCPER~|>@oBMf zO_B5UFXS$qez$~AeAkc~>fNtAxVxdMXzxmW$+kNzNepT_r9!(T2qh*Afm80ky=Tlv z?prUL@aB|Gk;d}<;FK-}Lf!%lbDEM~>cq=E=3hsD`VCDu6`*#V0f0=4Jb)E4eUhTL z^Rj0)?0cc>wC|Xk-HV-=C2x{ZvFK}SQV_%iHGp5rknkpGO7E*=xSwPj4?H(PS$hRC z$gECFNJF6G=fw3tYyDloO@rcvY@KX)k$&ihFI3Sak}mrCO)Fk*R|4ZwvN^H?mKUvP z*Qe#O>?9>d&K}<(b+`)Q%OIEGcy(-=q`Q?PLyxg^xq-SvY~%6Sa=OCvv6sA7TDEK^ zzosD>*xFJT`)CK|eHs3FSX~n0P^f+YiX}?ouYxkHUJtfq!T=NF`2eUJTmghCD@I5P zgE>Tim}rKn!8qC3?@kqim-oMyQW#%-mj@OTU}zDz25usbdgHSHFV?B?T@ zMK;JQ6jjlT3tE72b(i7ZifS#dXSU~K9~kkoxZ(K~%;skxAcjJO-@neFq2c-MzhF$_E7} zE$y)H@3%b4PqsBYQb3yqfYx>N*=e6ipp6lt6ENtOQD`13(w3R0^EoZ(JOOnzT}P{y zYlsdgs{jt;8|2fDCaKvS`clAL63y&~bJm-f8=Db9L!`vpYUw!BJeOs8|RVfgyu&5SXB2pWpfIsi_ayu2*PyFYB;iNRf>rl6# z-RTdU9Ir;v30!JRxeL~GW}J!L8+O9T9xohLBe-uA+FNd(otlqdcO|iUF*eBO$o|iG z0KwWiBf>ytA6zr9ttEOF65OMcc|2mb9ktCVuHokG1&RMSlT#-t~8<0>k4<=N@ z-akmLNDMlppPI8Ng$C{Z+U*wHeEFJ$pI)A9E66sKtc~6m5ETD#Vl=yPR<+=MZc)(J zT=k@88ol44-H9)sDp_{07LPgPIi}AJSk#zM-7tW)?3*cD?4oyQ!V><>A}XT_nv>5Z zWsfue*FYe0A;!%9{Hy?;@Rkg`i(4Qg53som`BF#Nc2P^4r&A^!;9`1DpBzkcYRR}+ zv#kdum>pK+b|VO{t{L%tI5mp)F(&zg`5V@WuGZGKo^%&EhCHO+Mj#bH?~)WDg9~AG zmMvQwOofqzlanu?LdeqkhAa+}s*Rh&z=RpSVS@PBSdqKBmaXzgiMu}HdAqIAGT zn3soF@L|&&9)-Qsj$+ZPAJR|WoSY=})Y_T%gmj8GKY8@J^R7}}UVpC&(ie;47`!`a zupd^9@#w*WA4gWddK<1kZ8}a(;J?+-(AG{pWoKi%EUK5-iq=rxUBIJJ{Lx5UWJ66$ zEncJFQ#3Vzg)vlLwJ6TvR8L8LI)YUMAe_YH+!r$ zWfq&#(~6&R<>hPTib4n96z-}0`cy;cCfn6pcoIgLnUw4CHHv8?Uo{%Su{Ca~9BER( zF$)oWpF!s#6gKB7!=5P-NlD#n(u0GO{mW_@-D*(K`LO#VWj$53)w?&JaBb|oE)%z> z9}z!zz+-32aN~kK$}i^+&wart_861I#39T}JB zj@8IQ&ui_4B`i~B!~TiHmnP`%m(QTAKICz$H2n=lp>HgoOK$Ds#Rj~@w^%BJtJn5l#w6OR2i@f@U9OftOx1R?!#z8R{+W1I z$X3rOF^R`Cv8T10^O8fc@5c=_QZ@wwc)~v@Y||z#M~+1k*u_$+l+<;6Z>5~K15&EZ z_!JoPZ4B?oTg$?kBl${iFS}hD7e57ZxuLUL{z&+8F=|j!X*zg@N*r-`+@CaD@pN>2 zM4z~f8;`YR#^1(Eote8X3hmmi& zyL21my^l{sJgy#5fY0+Ff;9Y2nE|xM)LrMKk}@GqB&T1DsmO=v_lzp2e25i{6_5_6 ziVNQ!Bi_$28dEXqq8UozWavnV?}wLfu;Cy5Rlzw%H!4JHNGPj)WglKt-O{Q=DL>K1 zC&3|g`tzfdW;n^*_|B9bM&gH?9s0uMpMIH>nubhAk%G!mD#PmHqiBQ~xrTj3C|dNMa{#&$%vvbdtV}cCf^q$WwCH>st;ged{!Ls$gIj2*5yL^`${K zI8%HwaX|wxoI%4!xy=|a9T##^fA+Q&Wy(ZE>{XNpyz}?bKYZ8!w%3(jj(%RWlmKh& zXMsxasH0k+FoD9EN2CeDdHe6|xshyy?SZCk7CFI(;4{kFv$ET7j6WnsuU=!vF#B{a z-hv8z%dP($cG;fH5-ICip$z`lyU4^wG0ydo;u&rPzqJ5dlf zejuTD;iS-onF|BGFR>#-ZB&t;0oP!)*=}_p_v|oRP?zUU*d|X3?GqIMEb<(5< z6+3H!_p9vk&-j&t<5CCL7`hhwK=MyAxgH*)wYwGX-Q+d$I)^wVGd7ZE&vH`LB;kin z@Q7vEyV`S7+3m^PcN8;#v};DSk{*5(u7Sr)Gkgs`x#bBl;xl1t1$}*Y{|193I@HK< zMV=NP1oPSI84Y@qmZ!NnhHJPk+s!fCg_ZV$to%D>4>iu;^HYwmZ3L4*j1J>&$A}hO z@-4>rE5}BhcFz=!sCv)bXmY8AKqH3xoagHBpis$3vz3a8g6GX1PBH#!Vyo1fh9+~RgG{Ux52 zuzhG1ijpkHltI;Be~dzQ7^z^Y(_}WS?3>@oh?hHmty_2e)#0stnwyMA?>6D8%(rLY zGdv)pMRV~snV#b?aoh4}-zlUp*2Ww59>~4S=+=|lF+J*{O!Df_ds(SZD>gjiFchKk zCi?yR5iPkHSID=IZdFVk;yc6hCHZ_b3Q6V~5X!Drr?pdQuG?SdEGApBF!(kB<5rZN zb~C@U!|A>%S5}14eVr~lJRA-U8&buEX#15{D9G-TW$MqMr*uo4nW3ddc7Sc_6suj1 zDml5jPSB4Z*DK?A7AK*xC4?%Sa8!KV1n) z2#;Tvugr#*Co(Wpk7Fwf9<03$<=S7MJUG8H!ae58$t=IlN?NSmZ3ac3t6~~@#;x?^ zqP-`#eLBSW$|EcMzd5+4jn$-LG;GVZBw~Ja9QQ-2ayIU6b1A06?oV8dvwf`})$Lxe z%bPU>!{o`F+MMe6gA!Nf$v;#qOj91*@n=ye7ky{a^Ha^fC{}*hkVQ6%&#fMcrM|%$ zbu?)pZx_`p$H^VcDucpQrOZcIT2TCmkarvoNPSn zUSmA#oI7{(RuV${VJsvDj6XTAFSfdnjIH)C^0OzwG~y)79*=E`o1o+y)T%e*>j7sz zdt0!=mO=7lo2MkpYwnA3CHmy`r{I4fR~-`)@b0CY>g42<&Quevs-X$&zD8xi5{abz zIgUZZw#nMgO!oO;TIAWNLPha+L|WOIUsm!<8KH;Ug=0%9dwq$Kw`Qtr1SJ=<9DaYjDZfs&I1x@n z-lDq!W{kEx0A z&VNA(KE&s(rdA$qv!2l<$)VFRNUV4D`fll1yt6Hwd@@Z6TXt+iWPnkQPLMN~Agx<0 zId`V5b1qN01@0&7(CGE#@q)=SK63eO$iv67%WLuZZ+ZhvEA?xWa@yx4qjNj>lmack z(QuKASQK{2`a#U7>iXdaV{*mj8V$6Y6T?Z!oi41w3>nXq!)zAksZw#mmQ-gJztkQ; z&eb_8OO(i=#C!L)>QdXGM2t9Dd@O!`avTnC)Y@1+S;UTut7Cni>SETRT<1u)DlELN z?c-2A15-8p>Rkg1pb)6w$TtLjqE(@)7A*3((h zr{fxz4ioS6@>LA*{HylZa{1faw-c)kM$}apaZ5k3gPC`46I1{95Abt(VEHN=hi3NH zF^o&mOvB>+$D^zd<I}Do{ok6ZxQ*hJIy*Br)6r-7v1CPcpm@=d47=)_UbbqXUxN zOJv9aEE568w8C&g6tF&HhrqqYB?0r#{s*$=W$A_KK|kfuR;833<`HQP*UOBg z%?rk@8sZo+n5|NY`dEcm$!S@Y*ugXiDswA}2V^A6AEkYEKFwQ?^|;B{ZTUuNva38@ zg@i@*={(lp^13>9(p=n)S}xclqyVN;c=KrZjFt>DDsp*jL_y|s<7%^_czD}jqiN*A z+U>gFexAsyf@LyZ`SWUjZYeA&kK zt7D)*sWi`+={HSkZ;X_-373NYPxqSIXw22be5`GC;>s46MVj}mDF#o4Hv`7L$zPON zj^dYMSGn}PGzr(y`Z?@n)W5;D`7F>KyqbAsv|$xF>zPiKLXnE2u;9m1PfQ|U=L;`h zvaw44qRcrQ;;gR3n7f>bwI3)y%yt1E@xN(sy*He_yC&`@F^gjPw`avnt*qDA5pkPC z;CfWPq2n?+Fd>r3R)IX=!mUSM(}Di@`iQv)shX-MDognlE%^ubOi6_y6^qHXk9>MF zWkv|BXA{!QtmZpo;#G%-ze%bto6$d4Sr`GN+uNE6-SJ5`;?a}{-SH*A^&JqX`S5)o#3DW2T3<93Q#XSC~vjE^a!zW~;!{L3)`L=XR;sWc|M}YzG`5%JfD-35C>h zzmBl@Z3yG?R%|4tbiZCl9jv}yhRo+H(zz@P?LW+vVYK*vOnr4&lWo|y?W3rO5(*-U zN+TjAts)|wqeDQtV;~@)QjZ`Y4Wk=IjBeN?2uO|^g8@TwFgiw!_^zq%`+k4;j~<7+ zkNdu^>pXvTUX74a_i3gL+v$wG`BD**9N;d^N>$|y4^Z_{p#IHt+Cx&+tN7$x*+R?xv4opCh!(c1!>`XlQ}&vYdXX8xjflAGFVW= zDvFl^)18h17goY$0E!;OHfZTb(5Y+Zq^We`ZOU9x)9>s?XBhFf@X6Qndg`5t^Se7c zE4loXB{#T3mX5XVLFeQOl0U2#8|=iF7)he*mL3bAl~*3xrl2UFueba49DP@8Bx;sG z5!Y3etM$&ZSw3H`$91eu>PiDYQq&*eq;LQ7vzt;%;p|+Oo#~4ry#gHg=Jx2$TrU7X z(yZ^RUy(T<_5QXu8X^Y-hka@3{MC`tVP-84o~N)*PL@j=(r)?}DsA&UDaF!$#c~(n zNA;?$Nk1bx_cmn4yfuF$W$T0nS!%vAh3-y4UAD$yBqf-6Q%uhIO9S2;gX6GGYxvzo zrnyTMJ@!8@@no|Nib_IyOq?t3{bE1Q?fpW!#nEPUFqg9iYJ=ka+elvq8B{c^K8V8^ ze*rN=B`oV{feWg$Ke!rnNWVI+zw(E-7^;fkO*57Pt-d8`XCK_OA_t5KoNiZ+CQrmd zJaa<)^BtqqAL{Twdk*u}i>^yn`{4fNDGI$(C1SQ5FQ!vGq=DeIc$XE!M!?LOT=imy z$V+xj_Ul&!qZ;%cK;>K}3;M|)_2b51+xU&KK2nC^)m++fDu^xTUf$|Nkv6AG5B*Xt z=t?Ws>XrMojO=JUWR={l#zXQwcecKotcjMpXH^QXVs&s%D(_3AOcB>GJ$V10I?XU` zLuV3IzR~lrXf=sfYW2!m z8ec1qz6(9&P;O=KUlKNLeCL+ARX4ZL5Muo%%9i{ldFb3ajpAkKMW zcoSWZs-vRoJ)*or#x#q#s*L+zx}2wlq9-{* zV^{Se3-``>CAp~MuZPyN=trb0EqR~@{fGpigy9)^rORZzO0AVS8_%_p=uqLkZK+JL ze{|DXoAFYSK+UhWYgdCp=xk7j`@%Z`!MfR)m__cpC@g2!i(a+vY&GC z4RKXpw0+jCQ2BF2Yp1RP#Pku~H6?i4InVX-neUlXOMIeh zJ4YKqza2KW*x_|L;&~dg8nPau>sG2BJgpwUc9Pm#byjGbmBi7WDdjeC zXlrJ>IXSR*rBezQ6@xemeGJ5XWTIo<>(Hwkn@_dhhHaNM>RR^v6I404Q5LmzwtLsN zbLdxV2R3o~$e`^U-paT~i-y$u(kmwMfLK%;l=5Nocvp_xB7eLb6(4W(omoVrE+hA| z^|WR1r7qIi^S1dOUGH{B>65JjyXCDqd_l*aYTZJil#R4QYc8S*o;;X4CP;CB&NHsO z{&iuX)rD(6GrYod{F%*0Po3dFCxwjl>$dY%dtDkd$&PmZ2Pt&$&B%jRx?yH>T#C41 zJL8KNaBr0OWr9a?PD5-Ct7MthnAM%7+}i3_a8nz2JNWW<9{=#qBk&r_1B8K^x4+#meE(h0914pUK@UQj`KpOaG?JbbxtHOp<$&e5 z`}EaC)1KHnHr|dz7O(zt}MN4_t{^iIsZDV`N`x@I=A%>|pb~%}9A_mmY!35>w|m^SZu-qmEEjCD3|WI93NqO<^Dm= zWeU%U>1ly&M80#;;e^C4XR%81R7+!|^Iy8MjL&IwlEO#@+rmeajBmUbKTDU7>Scr< z-}K7Ue7DFu`e}A`!iGB|+h-@ij;q1Nf>UP*smYDH(2=a3%dyxaB#wW0AY-jLr!iF2qLXGzYGc1*iyK#cLs+7b7>ic*rnm z&USQK{Fke)foL-cmva%D;U%cV6P|CyO5z-sstRmJK=nRiViG_JN92#SwY7%?@0V<+2lVn8`N54^WcCJLY?TW=dZys}_J66;1ZJ61@W{O_DLAkxa#^A3$He>v_8 zOq5CKInt5RgUR0y_O@TtIUNw;T3da9nY4EV+@?0#DEP7!rWZbRd!)wcxHiYLyTOA@ zhgeYcyQaP`{BGx2u0%OTTuD4w>IbxJM$$BoyHePW*ztQGB7K~@K=MZ*vPq+f5$%N4 z4%ybnPYvBQOIV1(D(~3-JVG?3`mOBeZw%|&?2|IWzU*A}4V1mx_tlke1WHKj9-WrK)D@Cg7k{V2-=5t$(Fkz4UkpE84U^p;UIw7kt+WUWys zC-CsYNQH&qtQ`~Y%h5z3z*!*b>h;*lSV5AoaVf)e+AB^jam!D3s1%Gi;v}FJc%S%9LE*gTR zoaghC#Sf)WV$KnA0OQiVGgvI))^`tGv&bTR*qJMB^l69(S>d)nw~AXif{}~i$x-kk zy6bX^5W?J z+9dMw2-SsnQVvy4S=IlR<#1B5yBx3Wo?TZM&#om`hH$1GBN{J!vL@+y zkngeY-HyX09?n&6eewD55<%Fwt$)>dA+D;q`bCy0oALMe$Q?06kq=W?8Y*q%5^54S zo;%7^E3ljF-MQ8K2$F1>dzWxCp8~5cPNoo_?^~;lp>+M6&2wye>%4|oCJdd*pRs0P zG1N!a!x{j-iFIV;Jn!_Dk0kcGDn{IA3 zhu*mMw7$XPM`F84M?`VavR}cRhP5GfL_(s7;mj7x#cd`F0_LoDAu-WiZZ%D zYbrmXsBPJ0+h792fep)78Ej2e#Xa+S*QYi^zHZy=oUFHC!G$GNw+47vUPEQVHGfFc z?KkA}viF!lS@_pV^G4Wm@7A!tj7z?wU)6iN+?;)*mCnxe^i>8pE}DH9Oa5|>_uj>R zx46`L?w%Rdhr^Az6fW!YUM+#6?)$>YaF3;;;KrG2!e`DDGWyr!yE`a1$L+~@l?tC^ zT6vf2$qX2+h%$yOi1jh+xfSbuYSs8wyFL4sn~^Pf^6^jj6VuNxqSy`&suQYQ918Z! z?e~o9#=YD;7=^Yf;U%~a<++zR|DLMni??0P9_6rQ88VKI#7OZBmd%7=zR4%<4XKsz z>m&;5l$tm(J~b(>SE^iV#1u@+^&(Gnc}-uGEyVr`gpIrU4+m728u)g|ej>x;X1^|0 zVLprYYh>AFani?zA*ANl88p3s<`(kWQ1>HJVO2-~I@nR0fliT&T<%W=4RT)V+UoVo;v8a624HqfElcl;_((R+W1G?NyT~X&wo7B(p^${Ufbeey53)^Ar{B zws*M(lyJ5`LlU2rnbDiPjVF|ko?l3H$q}V zB0Q6C7k+7Ere>V?Qm~@=0XkaYu__v-8JtUaQhK_?Bqew%bYhF$(|(mjYI|j$hqKu^ z1cXwuSct{NmWq!K8*_1+6qJ&)`!y~uMOmnca0!mNd8~VXPkegc&PoyfHu59|=~wbb zEkB=aaJdLv(RI-~B4TA2@M^@ZwmXgDPjiOO^&p1LmR4CKE9?;NPj0QmrL&$%QJaWC zj1*t8Mt8!z9J^vuOf_f#SbKKxh=oNLnVFf%>B*7D66UI)4a=J7(A}NTHDk#wcs-$O zJycbiVX~`b#a^S_I~y2{3Z6exTJ*b)p{(iRiu7Lhhcm2 z)RZO>w!&&yc$y-L*fl?HqDLp;sGemltp=e6ZDt15%?yzr{wLR%if74QiKPei?pou~ z`MQU)fh>)Y@NKTO+KYR#?L)-DI^UI|F&i1-qF4Q0O4m3ln=R(+pjZhL|1uzc9I?5B&b&U3b=c{>{;Mjr+;&_9abVD4>WN8ItGu*`^vXIkTYXWPH zu2)y-%~Oqv_4o{x(ZHaPXp&=C7l^qz#(XSej|f4`qo|Mhpy zIWNZT`VP-aqY6gr!f6(JRbK=zn9F<=tPnO*B<-lxhbToq5Yj4W6E7^Oj%Cw*EX{gU zz{A2lG&z$gBBx8Rd-Fz$!cSag&QH z3RqoQ!?J~?D#h890KU20Bd^t1_&%U^zz$!uKdUuCTP8DUSE4l`R+WxC9!=J>En84b zmg3(bI7-K;MU+>(aKo?9XPZ#4qXTvT!KueHqzLM~HeqxGziWQP;S>7( z?KY!Y-uwRykMwKYSd=s*D;?$oO(ew;XzMpu6= z7hq@)k9MbpD8m?+&`LNtpQB%ArHwqYS3bx*{|FO1s-TfSAWSxT0HwA};-IxD#{$=q z)D%JJE8E^xiRa`Sgge&($5{C@g222{wY?AiD_`&Wz7Kfd=c@0LzO$NL`g*P1+j(~t zayU{d2aGyn+Izxy?T(CN0h`VhXRFCUWt-h3;i}-gOUM15B^`|iPjjM7_>!>|oXSA> zJl1*(tX_34pTtiO)xb?l=-UrGB6Z??V$(}`ve+`;h`o3IMJrxn8hGftmpK%icM;A( zzrb^~o>|}h&#ruao+ZR*;>f&GK^s4~ujqDYD_X^xmNRT9perPl@AY%5LteJOH(xzw zdswfk^xMgiGBHOP{OAFRZeqT>)TGUm&zuczqxC4ao_{)ly792mo#dGbgn4ma<*)Qd zbi0h;4x~W09$2ErLT;NL{}=YaiakT?EM%Mw=8eBO8< z`95sKHo7TnQG2R~n$E_OK=3r;;3um8>3tYt6XCs3$p8D*!%K7x*Dmnydp>r|AunePD z{=ZMwQ^8WrY#&VEZ(-J~K?AWkD(b9{7p``GFzC!p)%~sq06K1$;*x4I6}bQVBg;q^ zu1ay^fegn{5R>ih9`k`hkL}Y}UrzICLi&OnzNp!<7oAi5R{g8RRnbELqJ!hjfhJ#i z%Ol47)3Zr*G#iMF+f&wSsJ1CLs}DcNMDV2c>_RRC?{B%!Dw0{meFy$qXg!$4Ocw;h zgsb5wBOUVIpuq&<;9LaLGE1kXB8+sq_!Fh-6l(Bsc}pjErdndkz3X>BB9YdV^jY+c zUR_ghsmlqqZbiq8A{wI%o1Dq3faotUJ)vLI*2p+^wmE%=8O1A}};MucASBfTU@uv}-_0 zO?|2jfv#&eewIO~D#fBuCzD%fl1t084&#yUV+tyBSu}kynMLx zL0R0)21PfN5dabA2l|h4YeFOVt7OFZVK0?V*eUL>O@=&@i_v7{eV3zO`(CKQsk2z@wx!e0y>^wN>A1<^H3uuqE%9CeKQU_aloX z&9&3MzO6WqTx*!_)$%+QIM}Dorm8CmC~(^931%ew zQ4j5}kDeUcK<@Pb;SVM38+0dAw&%6Ax+NdNM)6;BT4JCPm0^zCr|b@FMTh56_oPqx z@5XG?zE-)>=qEZ@fW5WKoPXnOruJW%?|sg_Zb|%3KgKKAL250eJPJ94C%mH_y-wx(zX2*Y<39mM&7M;is z(hcq7wjp({I1)-PR4?{mCyLs=d$0EU+dQCF>&!4p=_0pPCEJ-!&w(mE4c4^SG6MVcKK*KNq(@dm$3}G1{X35K0`ov`KJB`5T{hWJX}%I zEQc~N4Ty*7kRqU|c_U@qmuWR3MgfY&+QEO$zK?U-KR=IPjOKzNiL9cWn zXcV{vM6l)>7J60EqSp%ab9Y}iUl2%!VI<`k$IjgyAZ>M{S2UY}`2y3OP!%jRoH6<1 zi|)KT?6tD2s$8JE*sU9Ep|yYv>LuFuklmuEXnG3ktx(^MkRtC+4sd*_7TVIfy_Sp% z>SFPGn_bxbUw+OO@9CVK0t4YYCC0(4qk}-2-3(%~ZqJhK9IMBW%z@xI{yx*>;b^VOw)*{@FXE{7AgK7*`D!`lf< z)y1P3dA&P|AG15YgjdrJ3?NCpg0vo^W}2)Qkv|U~4vqVtTe&5q#Ll#)Y(H^VTT4AL zv=2eWI#Mh|5zS}+Rpp(fPlH>Yv)ii1;X=P0iNHCg{lf#_A^h&Ez3-qAQ?XK2XzGC2 z^YHEPAztX0i)1tL9Rsze`no$7KkrXG=+wD6nXD<_CVfSh z6gC%rEH$H{a-SuN5+-3&g{Ef(PKMv0LpQ|VFpTo)ryj?eL9QK5@}3?ho~MAGto95V zC_h{*fZ`~!?t9#ZMeSx`4>to>e7?8eE${@^f;sXq>bQ=8;G z@r*X5sF>-As)@g;29J%_6w~}vUwiI{v>M-dRWX~obXNA6r#sgvD($X+*o%EbTyV6c zNC|kvV^q8c?16GSN*ADk0r|Z@BM!291L%+6!fy;V2vLH1@BncIV@DULn|Pc2d45@z zduXMUtt5|hwM~_Xh;<_c2p?mli+ZUL$YP*?dWp!qx4(HK1G|eWh_^uyscm@itjOII z^k)ek-hPs~Qm*yRN`bEX#3KupZ_4V5UCuTKHu<{ifFZ)kdj?-~u^fO>)#aG3pAMO$ zj@`$n{=wzNm7&u5j-O(<2u7KKf4>s#rA#)ysKe2>I5D&1ox~f4ANnudQRT4AEjrnc(zB{ z>Kau}fANLYdqkvrMVQluNxIm8^LX&%s)q#UmQ!U~lYH;hgZ`N** zy2K_?e7%9F*^2Y2hZql~{jEq`4><}ytlnH1^X@(~8P*Y11nz6-8&oRS?YlTsv?Lt6 zhqVc2;xj*SIbjnYnY~-|Rk+K|J1F+F?t86h!fR5~_mZJnmPd0a;)MH(i}?P#^Px!@ z-F!y_yfmcx z-0uRJo%#jsVET~8IMc2dViO?T(oz)rIl`b`ca0CU-i14U!eb3L`Ib7b>NVIqorFdV}F)LJJO(8YcjPSwpd^gV@q z63(l=aE=YA72e+C0(B<|{40tZ$0=tHvq(IYn2+-Rl8-L<8IzY9#Q1fPFc>mhZjx$3 zSwvYc?7cqS8m0qz8OJ>smalG?%%D&T8~N@BnE?my`W7c||Im*B&7jLbV<$`+2{_a% z_MQaS-+AL)CqHATfG$t97-=)fOoD-Nk##9I3d??COTO#Q@JqiBzPvWjN=P!U!VGI<>FP% z;N~5aeBA4GS4k$nBctHBcNiQ(HiNxl1q1kCSpn+1(~Up1f7MHtL-C&<0*6=fbu5Qh zl=)gh@w)Fxg~3gb$AG&`dOHWsSBrcVf=I_bT$(W|3n^mWdgJ@IKf0q3iEkUm=M%AW z7riN6yh&=2(gq0$j)xjo< z)Jpib3)NOLTjLUY!oNM$VXxMpFJ`bxzun)wQ>_Sg+V zD!mpeE=i#q8Xa^-M*&N;XAZmT!>U+(vZ-`3nw$$51VVZfE+hspNCiblNoFKeiO^zp>|c4y@MC!6u4zur(V!B1qIhAB{)0|D`S?3^viarv)8WE? zH$F@#!-~raMp@FGfbdG`94daeW>4dzWc3E~ zE^OA{C{+SY&4`TS!Gy_1*{;m?V2z9F*ED-1yl2;UcYpYOK{SvR0h*)+!dVLr zHLveyRy>w@*@ZAm4F;zx`5PCX99@T&S&K&%@nCDH8FUWpno zYsZ~Mw&)0g%RejO!N(Q-q;U})QGTAq7wCOF>zFw6 zd1_zrV)RWbzx+JdQZ6_M-d|}2S+C%>Yj@Z{Gf*aIKSWOXf!j%{FkZ;{eb1Wy=h+8A zU9;{x!a^Fn#*Z#)cs#DY>m_M0XebXMv&bU-NJ#by%sI=VP%9lfNP*c}U z)UnJ+O_Xj>3G)psuQ0z3*PP9c)BVKw_a#A9wTD3n!8F5L;uN%OQ=4OjioQtODu?S08_RDM zn%=zAY-Oa=mqgbW=J=0d@__T&bbFi`}J+rjMG4KjSA%>|oejE0_ddE(M?)Waw4LafuOtSpKza48JC z`_7NFfgs1g*F12NxpXNO?bT8R%4TN6W-Mk`B{MoMqpxKW6IEWG`Cf^+-*V10DVQnJ5}XlFiP_=aj(qd_1* zwh*2J%!SF65(_%<+ufC~ueN@rM=Xjep$9n|+P|6-VDO9xky?*YT6$2`Rrasn%&7a} z5Aq5I5)WP!2#pG@#$#6%nbB`X8MJzcVJmZ*$rc86bdJ!SRFmbo-D?2Y$ z`PAq05?agr1{YKJ#iVA86d=|)yZCv0^lH;XaMKZ`mSfsl;!)wdVLGEBV9U4V%_KW< zgRZK(qEddN=e+3~oH`0`Xc_+3D05t|jJI$Yc`Hf__#47k`bWrrk0=6jV&3+@>yPPY z6e#Zym-{ErSbWXL`WxAaqoKXA!la;b*d2T&)Fb^(w#pXW)9&<(_Q6AQx#zvYybttZ zFNh@+8pu9IxYTEPXR7L1aNILX#W=~Re6ybPLICZx*V^gDA07-HA$z=hfID-v`Q6%1 zce{a%=g&-#gqF>O&sLTa_pPyrICNl>qgnsBH>tw(@O=SHLDkqIIomUer9)D1_@?h_ zl7xXQqH$i>!u>h7qK$}#Qvb(OhgToGAks&WRA$-^XwXYI(zunt$nIwZU~bl~!P!UVu7S%BP;u$7d-=9eRK$a*AALx`!1!jt0@!;F?Ve zu=Y|(8aQeegUA{&z*4zj2mAP8^Z&*t>Kh#h_*_`^c*cn6YDwKKdU7bi>%eLSj4~NJ zNdFT_=rBTT@lv_W^y(LU?ABUZZZ!{cm_-0HCc(e$qP>}s_Reo9`oMj1wC$y+s|xS} z(5N7N5>_vPhq`k~<5p1?hY3E-qsY#Eaz)*&fBG3Svj$2dxOO4KWbMdnO>_=PaLGOh zSe$z8zH|hm)8(`?|6~V@7r?yc_~p}N*blL*hS7z}K9ySg%L9TcE+lEV{nu2Y7~fdY z7oOaOj_?LL;>*nef%_Z0$yNPL1-u{$2XQ!XJuhHmF8&A4b8Pj#(D0$rF|Nq-@7XvY zweZB$^Qcl0XEaW_PuO$5G@?^wgZoTUP5NaR=m=Z{yDLi#Swy_n|k z#xWFwqRCNVKpUU#@6IGm=vq zW0qFFzKpxLJC7QV>sBN_^}uu(FG*pmh{JVRXg6h>}@})0|b@HO|$jyrunwr zdcvjdYrsF9Cutf8X#6ThluNAuX@#G=lOgXk@4#W8yQP*2qfMc~O<(Ss2XgI0+(+To z)`5CpfhghlzvO_h>QYYc{gnSafJ1ho^q!7QwMSfrRoqTuPP?*}cC*_W18F~hc%_=` zZ11GH?)V=(Ly8AQ$@M?cc)rqzFI0+4jUe4XlhP=8cTAX5<#wFD3HP`2sg9q~v8W1H zArMNrY(J*?+eZ_{vcKEYJG?5xgvJF>C?cOPX$KqCm2nqp?XZpc;?^ zu;&GBwf}TcauxYh|C7WjK@a$`UyCK0h)a-JQ)0LV2poMP6JR%)a%=`T^E)3?(_N@36L@I+ zE3_Y)|QB4Aq?$9s#cbt!g$V{BI|Mxu7Fnb7L&%8qrEB zUAByWv77lv4PYaN{QI zU4O-=qahBvqZgA8N}Nnv6_eq8HVbOD=LYxte~ZUGxxd{DAe;53WEdTpZ@@ZbOz zQb$tQql_l;whN@@Aca9KM|DSVrC)>*j)0~?U$I_*dut8#MtawXht_=}tMagmvkEuU<< z5SXd3Sab%8Z%yAM@$~V1NdeO_mbL_NE)KCxFZkS*w2H)i$5v@vCZKTdwMOSaf9=$b z5F>Nz?XBN+?K%3NbE^B0q3JYP+KGYO@Ta~fM`>%uCNFGU4C~!eOi3xeIBPtr&bbWOcO?#&p}6VV9| zLgO0EM1@&tQY{Xmc(gQBik0?Hbqf4TftO%sRfbVd%&gfiuXt*-A5Y`7>Rj(Y_B3XeXx<`ciUkl2xZ_trv*c}|lyTsr( z4Q&eUSZ( zO;N~WScFV4b$(V5`t7!8+Lc$*T*WnB&TB*#{Q~VuflN@^4Zliyx(Y*sykM&ShQPK z#3R2N`Kb2b8p)a!sw3H`xM%pA^!`$iw1KDdH}mmZ=_37R@`Ub*Mm<1K&@r67SZxPb z-yHTlo${v>V2sPDXS!t3?O986=bS`N+kz0QK0-o%Ez0Oy;|+PI??ym}AX|YN<`y{? z3V9DlsgwIfRERd%vkhQk%rGMbcv)KM|MjhCR(r2mts}!bB{?TZjnc0zIdBZymyR}!m`xcDN_Ro*C6|Y36rs){kO_u-PBo>fex1S(dlU~3!0;Os_ za2*+?5lTJFjOp^n7O4-w6Q9yB^T^MaGi*BiwisYj7v1 z^E2;|qsT!6`V$j)%Dz~OAT7gO#SQ8D6t$|o9yrGV&I=2Uw9UxKhPFGWPm}%Q@X_4x zJI94L$>n=<+NgY87EH|0^a_x&l6P^fJ`a9{C1*#r=ntFpS&>B4h=0xFCOa zUK+VzId}C=0nSFbpQmdDB3+++BkQ)1G+guHUlb}StXF+^fIi*0c+$CxJIC|uOQmNo z?yq1+dp87hw*6AeT_+>ot-Rr?xVM*TBXZtG4e?K_e;l_DUow@u0xT<q zxvRCD><%|a`e+7#R2LXQbnomniICbItd!g>@1kCs;kDlO^MnY!2l5KE6D1F{+$AFl znFQan`qGOlQw~>vL>yx*lCva@W8q&5_2}4IBnLTw6WE4QkK5a|+x-Uwm(Zya2A>pR z+8AWX)65_63@{W5GZClqhm0d?+K2eDc<@OW@`QT4h;sI_R1S8(LwkcwvFY(kA-8L# zg1a$6v*D7%VNNr)6u#yU(zkX<(6OB@Vhah3k)cbz^{z6F2ayFK@1|$k$)fJXeHiz^ zfS9Eth5Fs)o_I3n16aDE90P))OW~F;Kcjt`YQpGW z6zS&wj?7NfDeRK17GZ7=zcyREup1+wUNfSdyR^)jb_y2wwzXc_DAus(R~y~p$Q@L* zE!q7$*=rz#IzFFjwePHu44jltDyVyyRp+`E`HovJW)5m!KZ}NKuU(POU1js3`^8=T zb85{2LL-1l91fCJ{rxgdb^1CzX`#vrVxa7+zZ3^!>$6#&H9fs@qit;Sc~5V6-=GvamU<8Lpj{1R-Ri zCP)_wE{T)tHstE4pw=dvwe(q^H!$XE{K_9AC_wrga665baG#lKbg8{ z2k2rD-u42q*e>KQBYmV#cGJO6!340VA8x$ zfl;&T$T0qXt$=2VXt9DIME>8<0z-V&8FF{E8+6O-f< zVU5XJc6!%|Pqk?uDeatS9P|2bu|kA;xNb{H_zw%k5!U@|@8P4C?CK^|#ofeN6jcu^ zurG=bM~Jb#W4m9@jN`Zueg1tWt1MU}ZPTKgXcsnIDunDBs$c-|!|93%-@{6(gV}1^ z6{SwZElRRj_go+vtac{`ac5kKl`A*cqg`n_e zCDG77$DiZB1CMLuMEqO73t8{3d*V(Guan&j_st3~s7H1amnyx%yp5y%Tsy8`a0Y&k zJcPu4cSEeZ!bra2$N8NQwak|rZRIaNS4CfO z$ZctBWIVBf)a{8Q17{vnNdN4+6_BKsb!aeVD6gXioEU48${&0dLvtx}y<^_Mqm!fI z3u>wlyDk{qt2k8aC0Uy6`;y==to@=a&$#%+!aXI-;hplWqT5py*kfXHT%M|U3v}#! zI$+Zg0v#z7lD&OD;?$ybEUyvyPSE~EgXpIl?qf$8bx`%>hzE0Yw`Kp(PHY!@L{sya zu&LSWXrt(T-9hhWtO{GcTeSL?Lnuhf`E{BZ0t%-fW1+a#C0n2Ly15b)Hux(QJED6l z@PeEvg6xI3pRz}8q)w@b4Th%U&Qe;+r%H4lPBaN>Mg9VYm|{^Cp`gk!X8}v&NFUrR zBj$E4zYxO_Gdja?6tkrUNZpMb_(B-tpTzlM=E&)@vsXA$_sFtK-j$z`e#&Zmjda6b z2X7+#vykoVDz%+%=PVVrE)6>^#f!Ed*oArD^sd9h0MTYQmY91%U?oQHu_aR$llidP z#KMuO=#4G%Trvq#kxE(aSdo2woYk+LcAEI8DJUg4*!~P((trN;t007(Z-+_N3mb^_to?H@P0T$RCpn8x8)qo~Qq zk^I@g*o;lI{$u1RHcyF ziC1Alqt=7!-XV5UQ)M!f+ptb>BERhE($e4X@jt4V_;|~su&}V=h5R(wvjN`pjhVnZ z2K-K`WJb(wh8n7Hh|TTHVA0`k;n8T}p&V=!Z|*XCJnzj4V6B@{A`96$7!Sm|lWqF+ z`r*{#AN*@5R<`GwS|NHo|HLr;ZqZbf{=KBiXwux=y=4B!VW29z)?m7{a~ym^yPaTEXVd=#3+F4=S0 zQ{G_58-C9{4Bx;dlO~Zr3i9f1T@F$wlheO)CZy$B?_`ZPdXWLGn&i=tAZ=fI=6uB+ zc$`mg1%yT%jA8;*ibx)9AbE{^JW{w}`4b!_;A)gZ|9)l#x_|gI)ygbAV}Ik18K$f* z$V0pU$51-J-U~oBy=ZG{6wL_+ir)2nbBU+nEdzz+hzfZ=B4B;u7E{1~`5Yn@&@TsP zcDsfnzKX1BBIy7rIv^%ioBkK;(X;Z{Y3uiEmk&Km!|Z*Q7#>d zGmRQP0JDG~4p{#4g4S7#mptn5`H=;|5+CHFv44s}(2L(h&f1%HfA-stD{q&KVP9!$ zfIe=zY?=lZm+p$Z-g>c>eFBBE@3VcWPkK|x{PJ28qc_t>ZNx=vPbK10{rCdqF z6cs{f68%lzYmG@ge&E@keEQ09)=ma3lJ3Q1 zYjE9aSLG;u8JkXG$f(Bq#f}$bMW67B=@q2^f2TT@hqmgwC;#?)rKg)U@qiyt0i%3w zkjb!r&P{rGH4?&Wu(|L0BO_mXF(KcqdWI6;oUkQ(*rwqd#Nx)_A_u<`tvb1>nNp$; zGuv!Eo(u4~c}$K>8kM?F7J)Q`tWvJw0f%~Ks=VrrKMVXchu*z^JIOBxc@&lT*mRe1 z(_11@L#kcqGTRo^BS#KQd?tgMvG@}wJK`&ns_v!*7Hjv6$8t*gOP45$7dd2bz6)%v}UVju?;R1if8 zlU5Yz4n?KAOF-$A&W(y9AfR-oz^1!PN$IXlgR*Hvy6;?g&gXN!W86E&_mBI#|J;4X zIJh@^E!KM1`_B2yXFhYjqdJ`*d|#<~A^j1V)-*J(Ud6B(`27(9$YjSL9nwJkN4f5d z06Y+KJS(jiOR)YtYEL>jm35D8Z;sq%8DroocKIGA=CHJ6p?9_7a)gts$E`Bk&puhlhqUH`-RSj%9$!#T~u-f?LtFm2t%|7Eat11<Zi~ia<3?N zHa%NuX>TP|JFM~)QsyZW{aMuW;gV9tM!0j|!kG1jnVM^CHj8MY{ei{jCq&#@q5Bp` z?SlCWt4I;=V)&(`7mM1grr||e)9ljnwd*KvDM2>#Dpep)nxH}DmFm_%!Xq4t6O-Tk zZo6H#w=1&ih)k*Uxg3tuZPHq8CC`UyAZyzzPhH~9H~RJY5Rj0^--JjEP1~WS|3R-z zpzu+O-<*I`n&)K&1%?RL-90`%w&Yd1gWr--skrmDQV3@W#i~qD^2B={dPms_?B{IW z(=_(Kve2(M@yame$$`_b%Dl+Hyk8XmE-hE*LP^g} z!V=bAj3C*iuQR|0;!towSv}E$hY4!?DYo5~q46pjN4bcq@_CBWh{LN~6kcn^*fzWT z7L$aGaaQb-7G%|-gaC=hKs%#X!NfjePC_4`<;*0$hA&comxU_|$)m$)55kAV#;wUl z#(`!1cK+y=`^r$T@Z;Rs#6LPR<5X*GJL&E&O;i3}>NlsI^wzgkjY+;Fq!;Bp9~3W~T|YG|*Ht>4@^tAJo7}fr%PXu~x547Fv)l z40y7l)+G~fmTx2Eyh0Gay7${=y!(Y~3Fea9s?=q^!g=Yl5RQ`I67p zPt^ysl&htpmN4Ft4}>NSVqHJ25Wy{@`Ja*13o+xz*=tcG&sZJKuRTl1^SMOMwi8b? zQGS?lp(A%uqd2)bCe3jrX=R0I(k{Jmx!7i_2mnrTH3mHiXi#l_ERR_zDZNXDD&Gyy_5ZHK%~mq0?&h_FWn*+}c* zR9qO&dWvr;6jGLrZTF7_{PObeCMExw0t!Tr!_3R*j{yfdlo@LfiIzpyYi0^T~-Bu9K z0j-(>o;sFQ{ z?K7^qdpCLKf9Bd(k}pyEIpWkNp6r z+TXJ{lk8ZHa6U*gscMA zC6|d4)K$N|m&P31{QCyq-!6Dq_}Xmoef}fXT;-Bm!WDtf5N6-|3E+hOfU2)Wm5$tI zgH+jJoeoYiLC~tRq;>00a*He;5^6ceV&8c`=)M=R$%I5$W|GR!ad7mgL>~(&2#Sau zw=}nhye+rn%Pu|>4$*Ifu2oEQ_vN=NVv^2ox|&`$=@UaL|T|S%TQNfR%bJ^2w?RlA4L|q1w*`;uS@w5l?w%JI_Tpp#1 zS2TZ!1|gWn^4Bx6cO4St-<($Xhoj)359h6CfYvTc{ zrS^~7nO0R6DgXk17iH6pQWPQilhwuveheKu35>hzw$_dg)ra4olihqzSd`mF=`=5; zteuqX=|Ji4YXia3hquN$$F&)p524v0Gb;}VO4CA18}-i9)7g)b&Zsv!G{sds)Of4* zyn5uhv(`_Zq%D8{?7fKjGie365+CEa1O!ZW2MCy`iza}aK)QQ&&TD+I-7F|q#6PQ2 z-L>d`A+=8#Q-y})xWl8SHq63%F0Zn^rWfjY=v0Q?rn(4#dLLgPBry)B_afwYBFpTR zo4rZ#cnAJ20-YvR9t;#QsWWy<2`_814VC*DeHU}kdm}SSE%48Q=+jLJuMc;c5;Xm` z@&g`82B5i=KCv3N-!s2GCBQL;)?_ij1c=nObBni)xv9&JmVG{MK|pbasCalOI@41u z{)Qm8oc6fI(GPUXpliQg;2q-t5y@_}DAheQ>%3*TO??&z#2~bnerysg%SGu#ylQ9#G0H1SD{5syEH3uzzg>K-rH+t}EMNaOa^0a!YFd0iq84VJ^t9j7mrM;SU5T-md~ zLCZg8OdobQYG1Us$T`V7dexzLlQW^hZ{RO`!v@LE^IfkWBjcffbQ~jM))9qABedR2 zb5#q4=grf2CosbI9exL2ie|n+OLX|0tv1rr_T2R>fG@PN$chaXLlU8)pn~zUxv|~0 zYib)AG2y7iYGk7{eUsZ_oR*t<=jm`^wI^lz#e2zg^x&3R&v&L(Tbp8m7leusxyUnz zo(Gl4GwI{I@c5^)tk2uzyxHovXNCfvoINCZEFwY=Rrs*y@{3YAxr+Nl1YIn&W7)LU zJdqp|Y&NT+LJ{EN3KRGJehh>0!D+^2zL_R2p(1z7&DufKW z3r*2TDF|weTyzzHZwuOfJid5_yc$C*(M0{rB3rxRdfC0Da%>fvuPGtvfD(Ly>-Dje zg+tW@6Z@)!3(B9<65`|2_)NnRoHy^#fGZSC3RY>-6>jFLp_Me=qo)&WW>BE$Er{2? zIx32#anTpQY}*unRdTuK_n^2~1?`_WDDIWxgs(!IE7qa%fnl<7M`efDH8#?xk1WbW zuUb@655({lnN%6tgg5k3-72n~v)vydImuQo{blNJ;u#VN&{APw{}Z-%eK<+1%s#o@ z756%^{OeWJA9>Wsk0yc)?V|^6WXR}RRVbUd6F>Ig?1Nn_mbQB~%oY3= zeVlJcN;5F4_9~E4TD=Sl@lO@Xql0hbC=e${v_hlz47)d#Rop`5XCQI@G2%;CfryW3 zQ-V1cYw_F--z@8FsD*?cmB?-z@0=4vFCQlg#BQ0a=@)91_RjMfvR_EfP?4P~P5y0E z1G1p$m!b5QKK6NNMEcb5Q5H7Ecy@dc0}c?JLvbxNW{vFc*!%DbnQ|!at8pb;Dt1ZJ z^!M_O)))yP00JjCBjTeG7uoE6>25>JYRVPotd_}Mv>wfDp&DO>U#zWZTveEyJnT^+ zT#f`CIOxZ3B3NY)O0D_G@g$4ArUIT=PfZsVqEJYc{xVb(=OS9%e2y_0U#-d?mU$9{ zaFD|3Lu#Vf=-yWCD@9vb51pK*ILe-{G!|LY9`LnaV>GQJ{oBPgFTU9n(h^PN*d9%+ z==^Kg@pgoCe<)k~I`4VdlnT1pD=+&i)vEpTZMp<&EOFoyz<_lF+5-*CqrsaS17lat zoJO~QxM*DP!s{eCTUuBX0x5HlY%oV|41fGzQL?EEuk|W)lGyE7*6u zzKX2kTSvxS<4pv}e~SKfFY2ywSmTUJjJEs95iYT7{B=EkNDzo4!xq-|hUD;W=|WwB zC5*l z_Xd4>w=b^PUAp6ooK~>UoA5w$Op}&?{X*HnsUNM>mybbk+TG2p@;fNh@amcK0vXpT zofN-EAy*OP2c0YK!V1WK(Y0%hP3t4Y&Zqf(E3!T~py+QGww9XwB5Qp-l?8t)+T5OO z98JNLNiAOG4J7nHj+d#&WwQXI&Xj-t51qzojO2qL3`>YO*mDN|Bs}Yxp1%%lHHxhF zcmoW5Nu{o_4MaF22jiqY$(zj*V%g|y4>ZGW?9QJy`S|77$)d|7qC)qV%ZsM#+~Ip) zyILmg%1250@PndjCv)7C>jn(cJR<6(11}g8KgzdG!Vxg~2N2-yic1rI=*L}p%R2yv z;wZ}gy^+yzug-*9y22y_X7gjC5NiR!e3_)Gb5*%7#yJ)nk!(>ZVu~1Q22aoe8Aa!m z5WXY$OQsbzGl4e5wv4RxFX4RdX?Jr2>OB<5M8ZiWFSGf$>+9sSthy<^LW>f!HMXh< zCCOesjrfzed7EfDXzHXUp;FRKr+J)ci8Ho~ebAKfjgrU7mg>te@@On=5{~;H^5FT< z_(arym5+Sk@KWrGy`L&4^6RvO<|6A0!P+7X969qqktB_HoR>}+eM36{FqD>+sp2m7y{erHL(112X zy`f>98jmE7u~l$nwpX0OHN+(PX(6d!*h(QJ)fn+@GgR;)xGxwL z|4a>1ADBdoVP%KcY*bak)mpD;-C+Ia5i?xZ6-JIiS0R?I?eL(bSGUTt8(rEzf*20L z`d1~R=JvQ9C-A;aTB=ktPA#yI>UcB#{->*uYZbzUp&5wQVLytBi-@oT=jnL}U{k_6 z74YvxY&*tPpQ_hH&ZR(l+%&L;u6gnK6z`(JnGfT;AG8t$aba`4uY_;iD?;Wu2R912Nd_L zdArv*N3!ipmQe*R+USvOYXw(Wdrjy&>XT3v{F}RoDG}$4$aS-MAe(MJscDy1* zmdC$eX`ICWtB*W~m+X|+@5tr6^Z)UqsM;mFaz-cO<1hU8f1EDp|IcUZ|8{+NKO8PJ2_199 zQu;z{dqvwN*SIf@+Ot;Y;Nx|Hs)Q7Tg`l`Rpyiy29j}}*Set1}iaBbzUp?!`v)pOb zNSX5C!(X^(&ZJ32b8{cL~AMjth{Qb|YOkw7D)Vs=u z4ENSNuOPpb3W18{P%Wn>>{^OMc!Je*1O0typn--K;@8y$fGI6p9{wU4So=V4;@8b% zRBQ_taPcW(1>NWM9Z7xgQy+Lg>PE}n*0lVFOUj2Jtce^^Y3YGz`!OTFEY0%G6CJ4K zQY@>}x*_9VtmU|#=dLE$kGm?5yKY6tpbi~vK_|;?F`(BN_BV?wKhkJrnV5Tiyv%3W+Ms^IyzH{LPGGq)TJqjXQsga%TY z8A>L24{hfaI;ID-QJUwW#~@Sb58`O2`PBKo^iZWNja0_0lA3GU`TD+NP79f}+^D~e zB_gap{l{*`abH!&)q`}AtUd{iM9#w~BQ971?rG*5^L-I427ZO7esw*)&Ns2s)YP;) z?JtngNE>Yvz*M>3P5idycZFdVXBVgC=luV8-$OaSa-ZoFFtlwqM@N zs6!Vxs+nN+i66yW4_hI$QIwGQxZyC};7c7-tW$fYWBT$vwV+zlmU2R^RJT( z7MVpp@xAtvFNVV+Mp#(brE0Ti*gW6uXuU_;>F8j~htHt8H2jiqd%S7PX#8i`;8rDv?mZ!f3y+4%C6 zDs^m(b5>z`=OT^EVs7I))TJ{%cp&3*<-U@s>7tJF@x}@6d4FuB`@D%DsXs)Ih_iJU z9)p2hXk46+WHFEm&*m7or(LBX9&)!Uh+dx6r2p(rcPGwhs*TV7i#9-j|4i0KkI+LvG4etO;Y zi-^xqejQJf<)`Au*AZj0_!e zPu&<(V5C-J8INUa^d+|^8$*^Ql+yk)3~GwLx;1q}ewexu(WMIYLdWVeJdY6-d5 zFD)C%ZuXm3tH?B5S5Ge!XT_T_E61;Ld&(yU3JRZmlrc0k%+2mAx3`dt=2k=^IfE-)c89mYh zn$r9`UjTlT5JWN}N5{GR^LSyxKo^(*XgPczAryyW^!O{nr)HChT zCVb|-=+Avp31-7ZTG%MZneeiM&0#i+F7#IO0R;G#+T!#gCceRXKJj7up&v8xv<;$0I@t#!OsVOmO z*Vv3z8nLb6iSe*&i@#(fAVs?%?$C+~-o9+@aqgojpZg)%Rj>{_H247n>UMA)Zcp=J zD>gAE(6+|TDq_`#sSG`6wMNxu-kA@CIf4;nWIll>Pod&-nr(x|Z?=9Qhb~$+b0c3E z$Szj<6ee)xCNxP38)BPhTpJA=MbDHC4yqggynMP}ey_DadJf#p#sNT2hKCQkoh-0X z)_ye7F&S(o{oDIvFz)VY?qQ$8avtakADb$L`*j|59`7!a5R;rlpB{nPb~3c(XO?W@ z`{8|Uv1iXd@X*sYVNI?;2RBUvxA#Pp#^m?O$T`e4Qn!poN^)(-;372^9kjG!oWFm% zY3^A&S!&RA9fd-f@Yg%er>5{?p}YCkGPtmztXMwhBIhMcVGQ;>|Mv4#Ypp7mQtU{m zw%gEBdQ=pJy^2bh?WhMniRpB?{mQ0O?5Q*7a-sucVwQ={ohxWak%~4ZAJ5TwTMoXT z+D&ODMZMU(-__fCbI3p)8#ZoHvHG5em9^K-Rap4VQvPUxQQ(pm0EYZ>_~ZQ!2fG^} zS6zMj&ZQVP_JCAbLv|jk>y}mVyP3EeA;RO*ZqnSLx$6ysgSeMIJm#CsI@wSh8OMrOKgB4S;8Hsmhw|HK@S|NfhXBr|WN6yM_bX!@ zrtm497jv82+WO3jNA;I6QH7;-xu7Pt_VxDO-U77r%%RFwfpUVw`YYNhEBS&}*sxJg0HytUYmS#fqM|aa zJNT3lzrV2`VUpG5yi#dU*#U@73IV{jBQ8=LV-9+$)}whZeQf=V1b$DvJwLwy-C{V# zUG$b(S$LL-exzqt<=I_d#uSP-Qdobi+W#$HXw;J|T%`_Mz*F_Wm(BT>!7gl2MhFz^ z4@~W?R9h5EGdH%GKSIW^>;^K1f2^0x1~REaJYYEebV;Lg32@LOF|M1c9e$|*R)ThR zVffSr&fwu)4~c$z-EBJ_%MWmoeslNZ$8+{bs9@b%*-DffU>6!5nzQ?%TP`I19qPI&BdWeD(CFwTHoKL|EvFfQm(7=kO(NGic%Yn~Yo%gM%8LiDP5SK_!V$oWjH{JF1=ha~ zw#K&>IZcO)GI{vGy^-@kW4 z%!Xytw`;pPplKhiR$!>Jgk~wn_u;`pkf1Cm=#F(zQVM}k;{~7jcva<-k7PQ|X4Z#O z^*($&M>8F<+VdI)tzq%I8w;Ux2jQbm`W!EvoXWUuW}mkemk6jo-7O!@)$`mKUL<1U z;S|@kdy7yaeYni=9LM(xZv^|ENX+^kj{^=x^fSbe2LuIO>X^>S$$6-Kor-GNF)Az! z{rd%m=2C&$>`=ZzDmLQs8L)PFRFQUnQO^PRO5I|P{Z=@jwSd1ZVW}t==|v*N`K_lc z{;m|?h5~p>2VC1Hu&(XdxjC zdieu>;wK5#5(SYR}csONG?aboii*Nm71Cw(TfYU#-3su8T6!=6)=ul<<(uT(_{zOh(38=K z`|VRM*bXR(r-c2f`qoZ6z3bRH+*`?{^sL8yW740oO296zXSaS;-+sKom)wUh1%m7T zc0TR4aF)j1MTzG_;8ZKO#+Oa{J3VwHde;9?^o~VO-)a`y7?nd zNb!+`;Zun)|6|*R-u-~T^Z|bj;KA1};5>1@-lQM=Y#WDq5&8o5#Ot>Z5D*Y~`ZRQ5 z1T3em%s}`=@|~*IuG6B8l=d3AOEd&_gb^Wx5WrUwDG6OLu&ep`o+h%qo z5t{Gbbugb$6MCQ=_shHl12oF+?mKmXpaP$)jBzZmgPG&WZ109_RWT?s^oNxqFoQ~a zglh(Ui#Zw)8THN7oF2_oFYX7VQ{cL{`8EO}L}XMil3*^9%=`&G)A)6Td30+K`owbm zH&A*wI1I%#<(aMKS$Pj$>k0iDtZ=m6*$Z7cx<_jM`uSm(7V9s1QL9Wsg0LH3uju2A z@NJDGo)_5f#Hi|HE`{yzuC=oCZ-d!L24_l{@#aRyX?DQL-B%5wCWmegmRcJ~i+@O# zj5G!u(w|iWJ6AiN6k1aax@;KStDkV!UuO`uDf$7EhD-J7D8uy!1sA7@eH9;a1`+BJnBJN zhfKSx$J6fB{%T`71VH`Ex%aEKg@M)Z{`hfy4g=G{kt!CjR{fdV=EcK3a&dR43rnDq zG8tVl_h#*^Z{Lv?m#*0euE)hk=uA=Gekfv7$Mw$-Hcfgqy z`VI}wCXtSuW25PnkH1;;Bu_%H!~JD1Qt4t!j8odi;NlsG zChuo3TsqBaGLY4W2R-MC$DCU7({p$+#+Oc8(_DCHcAnrm zqm$oG##t=^U~B-&mMyu_#=7JI^}r{jA{v`+gu4pX#d%pf=AN*GrNNuDH%BGUG0#R^ zw??0i+N(zab{JT5=!oGXIeK5gbDFGhXi+?t*1@T^Zs(* z7%Yf*aCAc}UtiJ-E3i4Jca7aN3_iU0pyRCFYtk!qVRDD#ZhM(#`>;Opnh!#gpN}l^ zvzC(t1_X%l4ipfE381P9*V1{TzvV_EyxPS~h>GWDa$=r`}G z$yxC7(>p6Tri0|AWC5HmxzU8(|4R!FG~nBwcw1!s>eZ_ZYl*Xj(^=)qa*ix08j0ce zT9rLivkGczHa=4xJ-=sHG&;Lg5w`Gw^`z>fDPFND@OUW^Ww5WY|R zWqArmM(PX^g<(;Is=$d8C!k8(xm??MiES%QF?|GhBpU(1^vf*moVC_|JK+K-H?OTd z*dch-&CRXf5kR+%0MJRx0B^pP4FDlA%FN8XJku6gt_K8;^|9|?VS!oEu~1)MUrH+x zHgWhPPq^xT9H_^$0?U4#+u+Cd&(vcq0N+Qyh>~=aRyu9Gy02X|asZTQSu8BwttQ&o z#jE8&2KrPs4jIIn`9_Z8$zcp4BO@28=F`%7Ve?fK>YZEhOcX%1vK83ObPFa47DB@8 z%#NR<_T#qWz;v4f*k6cp+L#~GLfAWm_WDA znso5^@#7M0@YScrEE+yur5;PvseQi;qggvzVl^#yN+`7DCGKCiM>6-9Xz$)_jgE=Y zGJib*LpKV+n{_4r`<9-$l-T7`doamBgHG1G(^^@5d!Cq1&{yjiZTA&v$SoKJiVt{uoz@7isoXn9;N#2Rv}l5_L}?P(%t$TNfo&xqkDt3-!F8=$NugTZ2rA}_efZLwMj}k+=J?V|^-&hA zF?DNZ)UvLwZpKdd084D(RfwvhrTBM#ymDND!4Q1;7{PYGI{yU!!DkWNSMvxlYM!38 zFMO$zoloIRBuU5es{swNu|4Lz($^Z!qSyzR01XotQ|Vj`Ymplqt~DtiH8R@h3&;2v z8R`4SQRvsM*9bLnd&Y}4Mg{;#W@Em7TQ>j{yBRJ_><+`V|Zi5^gS91XgRgu$F zeZcnu4*w}0g0ub59v-Ko?J!14COADbUkfglzzkMU3@;)A!r!-#N)O_{erF^H>puTa zjE688h|@(o9Fp>#0Ahofgd`CFm2?O1+Q$u-+V_Ob?d?j>pU0)W0XVxfQYtShD%vk4 zN^<%d{O((s>Cu4w9TuAIb@xZcF1I$)+ z#yE7u0|ch5qC(b`@Siafyhvk9eea&+(cxaUfomoZ4E=C8f<3T3`}_M0PTc>wl&`WK8R_90g*6LiFa!3H6#rKpnNGi$7srXI z@J$gxV5O?*NVi;$MNWPht0Ui>-O$mw8XK#$VpXGg-Vx-h9w|j(#BN2sLP<3_5yshKA;jqOp%$1)7G-tn8P$-J{YI3*zy50 z@E=}0w?$`h-1m!t9AvRuRDpiE1Ci5yJnf4RUnT3eMd+6=6|q1*lo=sX9B1P_Tfulz zl~PeYg~-eRa%VbzoKt_vrz_oaZtco_xSb!>eH^-bM+Yk?Gv{5%HgTjx z+eP>~XYpkeS&5pBlyvc~){)Erm|+Hr$kVs6e7P*0YcZ#`wGVcv9s|>!QE?uJ&I9iH zFAnP&36|$yajd7jXd}S)jw)nn4E3UAYZt4J4mUO2z%!MOJ%;hT^ufFInCLSb1>8u! zH@e2lqTfo^Q;j zrUIX&gTBLtX^nXNN)#viPhaG%dQIcaa4~vPX>;`r4 zA}8X{{<@;&5YGi1cmBG$`#M74&Hxf8a9ISBFSi&>YsU!SW%io|s`>i$A(7DNfompA z?_{7D3cMH$u!6Jm#B03PwGIIbDb7icjEXBb-`|be4d2sSh+s1bDTP#**{9A6i-%qISx=|$n~+}<>>>+kQNDN6XzF7<5JtYfUDoURZc9R5HCx5<+V$1 zoX{zCTeV;z;BGCzE1R&JVO|8r1yAhst6PL$N9}h-x%_U@AFFUo29R2x7F6~^)>b&$ zZFkO26L|c@9XPugt~_O^0&Ehwx5+*v>NWJ;e=f-O)TdI1G62=#z+TWMy^tkjhXM7a zbV*>4zV;*G1%slMQluspSM0}AH!?n_g31%$U15A4_mq~-Vt&C%E&TX4Rh$pA=Qppr z{CZ6}k$NI_H+4vu?k>!q{|prBU_pDcCOSD`0GG#>;=V>nr_{|$3O^--D(t3xIWXgF znK{JPMBKA+x-AIlxj7ZvzX2vzAGFLnPel8aHEk6aEvPH#Ri`2D;D8)*(Ov_BW|F02 zwZ7!NA?6BNpJ8Az#MCiqmJNFFNM0>B?qgVk)P4Bp+fOkYXoehy2WbRGGQa6RiHkjv z(>460l&Lo4ft{@Nj9h?jjYPUK#OnaNeD0>`DR>f5E*8Yk|Jah(H3TNr6jyG^LabG3 z!$h&RdSvA-T7U46Ap7sUqoGd(Lv`oATPd;ROlfhTmSq5bJXj;{i z>I1#lKC}sL>G`k{{{T6Rrp#YGUB%|37@H+bpH%2lwScLUj*V+2N)W)uC{2Q;jG!$HUZ1iO_f>Xo7!<&qDRszR`{;_3q6D>fvijds)O zF&noVHk*O#W(A>0s-_go-~yf!nrTH zwojXJbPDx}C!ArRd#%_frO?NE^%`9~0X@W|;>M0R-@lwVb!M=+c}^ld!>$fcuLWX1 z)1#hdroZ~x{CPo6(U8zVls(sG=l4(c878#?blouF4XzmM&{ckJ`(@J(A=bDjibEUn zsB}y*)xfKlk~F$#!??`;wdL{^nga^z$42unao*hy-jq-^IwcwQ;9C?-_%ji*{qfbZkF2J&<4hnr*j>GM=_lRaJ@O1HxY@&4UshNg!#Ax0t-?`r2>3| zgu1A0qZ~@fl%~+lvNyE7I=ysw-L7ha&QUv8iCOuSc6X{)+F+_f zyb1&88H&A5V>*h5&3WXD%{A3ajlnwM4fKHx-*YZ<3cy+EK5pI1z%RrHL#Dfo@nbNH{7? zvwx?!Yge^Bb=Deq>mfZEh))->@y$Vd%jMaWH_(lrs})2QL`SYgZML#>Trv@#v!EWC zwYrz`Q}&hEz6<0h+f)srUnWb02UzYrrE;trvFVZ2huk3&MLgTvkoQst18H_w>fAL} zI@p~SmUQmVT8#a*eTSdH%9!oA6WZVjimOe2DT!h{cD0N{$O|?5&h#JbX6&=t+5(?! z=Ax*snr}2aG7v4mxLIo3&|^~4xQ>yX(la7!dmgu)l~(D!Wy}E<^qU>|sKSWQRIz+M z1Knp;(rSC~ht5Y}I`p#}=e9s_#WlGqx6o;j@N;7*(PkEvdgLhDey<=SCUAC7QK`h* z?n+|9)aT7n2;PPscRucA?<+LBw-6xLvjKh#6Sjg;EMrgkMpm|b*=AHYpMaj-uv($- z@UfQDT#|~Z12)?JWa!RJq{ZUu)A5`2XP36Tg^X_-pGicU;F^!_YBqiVBBb}$HAeRuJk~g`qkGR zNRrSr91Jy6rkUk}5$MR|S`fY<7b_9LN}oA!CQoQwe2cPgi>(#pbr;n^k!F{>2MU*K z;+()iK*H-5v(d7?1rS}BMk{MMCWZR7NAnDCu$m5b7Uf@KHN2p1zrVDC zI&z+MDuR8=;i2=bmzMRV%G0vG|9Fj!p=bmue`kQjxob%Y5OWJ5Jolocp?gF+0ELNH zJc~JRx4@m!x?}<#tQwr=J}kHU3WBE!h(zga)PdI^*>#*>zz zA3RIqO$BUGO@XwHAt>;rx?4ve>l*{kbqNW08_Hj7ERU9dcLO2`h09b7S%Q_$N9QF= z&eMwL$}h;>b)8EJtpKBZW_iJwq!NOb_yyTkfq8V|t?7F3pG!!p9ba!Jip#nJ0Z*2c zJS!FX9FUFQi-H^KPgh8ln|S3(@#+HrUW`XJY26W@!>@06l?0>=9_Bun7_TYqLCGV5 zOo4D~)*vQVdq3Xa-#v~V9Z9x$x?-E(1T@GzUF|M>2l3gYWGp|Q%@8<0wfKq;WU--c|Mk z{bKBICX3x{qEbIF+r5!wTcMisTxik4wO@AqYS~M~$7SD{H8_QH!?}b27f0~2-dT^Oho*BfhazFcFN5E#anpsc^k*Y8E%1ak+(Ob2Q4&=RT zA+9tB6`Eeh!B+KA^yVH;9xMXeqD*E89(tg)9}hu%ed$H|qQ2 z@5mh7mj)fy3V_NjM4vl+F?G|6`!f;$_QW9j2#7{s4~y2&5pWI70ao(8cT{^`V9>7z zBy6eA5(=lfA+n+@@y9I%dPuiJQPrJEI()sz3G1o*g z>%HeN!?3PE8rHiq1yZEW2-W<&!g4q%lHU_dN__)Aq4%17G9wowdTs1_3m7G7a`KI< zCKIEc*WC<@YwH^z^E#-K9Ol+3gy)#})W0Y@iPN^>szIHW_$q6(hb*=C99!^@4e)V; zdT(@}0~w;%;!$rz$#Y3N_#7e|9-hw8MPmYyJVP;9(}qKT%<_#pA7|cgmOsY7r^CT_ zf7DJ=Rh%psat7VYYgy4rC)ie52OH{dQF|~t@>9+=WJ0)7Pjwy&@nSg>Hx(2{l=mdf zcikCyqHxk!oNK|XY5_1=Q4&r+K0mz`ibPU+1L)ujX`C0JpV_jD z?4EQdmw{-sbvu^`M%sJ9TOKA&GPoRUJ(c`#`+b~qgi;%{Z#=s;@IrrF3*ua`s9xaq z45%A-Ug0aJ1P`cJ*7)|!#&(Cr=uXa9Xp!_=lMPp!;gso*v}^WI5&ku6Voag*<} za;t8o-|knW^4%(~KFECL8OTIMKPQq{2C;1Mm*QGJ?JLm=LbtlwVk;gA;}!k%kX1Zq z)iPI;bnZGouiZEhqVfp?ioYYBmq9`kg{)H{uMaw2XT_3bv9f9Vq>fSo2kT#AUW5{u~_MilxH zW7;W^1%mmCbySSlN0}NK8Qfr?JVcaiCE0ZpIH#|q}-nZqk%ig8~iP%VnZ78)?5tJj}!8^Kzm0og4^yOSlpj)bMU*=qC!dg%K^vh z3O8k!Xp27*rzZG4O6!f9bwyNe^&zVQF%qT<=lZbm#)@uY=eluj4(wEbm?Vhgi6+*q zlN)H0i&s+=zcCtidL*78WAYM=o)>yX)<#Hu+jFU(@af;h^qCBQw$XHRFHqR)L2JhLBkcUi#CLvhlkDcxIjRdMvd zOn4MzuydVetHrekiccvEQGG<4+4t2UT9WG46I&z@k5HD&A?B!^`XcaT9(7R>cMq7F z;+8q*F9Z`_0nTil1WAnHk7WWx-lpFBV$Hu*TGtqHV?NT-t5@ut;!l^O()=@(CF~gt z+sWcbjK!!qjD6=hQgeYm$y4w46%-Y)yG{~)PihT2Q`9{JIu2|!tb#@Gh$zT7zdl;} z4yx5Uro@gLZkEC>xQ>LqgwxfRud)V)@xR@6b2p{_7_zzAfLgjkH>uq#WtY#bY2C)A zCEC?-21jtyl9T|){qDr$qF+X8n#!cN-Ckr=|JG4qyJ726ryaL7&WcgKvU3nkE7UzV zaV!4RuyGBVNk5G^NL?y8?gy-J|IsT7TsOsJ>gArRi;^jJKTntzxkQa)JkK@IM9D_! z%1iyQbcu7hWZE=A9(1EVcVf>_Pkgy0OB^tr_Kr@7c5yp1lK65B9Z`wY+_oPllp3dz zc&F2; zFHhMC3EAHvcU5(H3Q181qDh#oz;J$5uXQo>mVH?IsVxr@sb`Kj5_$NEIz8zy3#bj( z0SpXIXE8(v5L{ZR?T??2S2G8dng7S1I`TTZdWpnqeNtx={49$#WD?BbII%dHDT51= zD00%2z<36Eb4sD?M>U4#=y_dkY>!TGC4puceqzFWz%`EqTSM4aRXMxan(6R`uGkNW zhI@+CeR^pgSCv%z+XNHuL^&`qKPBx?nB`8`Go21(gz04_O{N+9s!8dZ3dIw}sr_-R z>79@B`LFs20Y#9P5-Gm4Tkw)rTWfsQcIJY}@X{I2)SBlP%zVTdB#Rz$K4fIL8T3N- ze(H-~adv8COY#v#qts6szR59vrCu>f_RcBN5iMOaEDV2phNX->N1XV4SytA;NMXmD zGY;M+_Zb`NtVyTX&}`n-Vn+KrWk>r;jTfoI-gj9&YB5s!oHHL@mON24po85Ee}BFj=8pr;IL;P$;giAQ5^xh zw*pr1;pK;d2xmymh0P7+r(ET>L8k%?RT@{0S9NKk)^GX!vvlm^8~I!*${xeku&9bn zpvQCouDo0ZeHM%Bu8p%cBwgBYuV_3+9RV=bYv~7Z#p8uBP}S*H?+wVKL5`^~bsQ_` zL)c!wd<6V~1Rxr*iYgEhU@E}a*DntoZNfI1cUN~W`9r;n+~Of3KSV-DYAx+-t;)rF zF;jWw?GWQg8o>q@J`xa*EUo~3$8q)w6DyJzas)WG==KX7b0o<5Lky$mLrd1X#g8eY zBhYe1wt*3jSziN0skR34xX8nUy-0fbt_?z%491M=nX z8%k)IZ@d6<*kh{URyWBun;Em~bZ4Pb#uyQht!`O=zv&tFmWZ3~7ju0g5CdU|4oRhA z8@ziPVAYqX0jn~&()fkWX#@`f3Vy8$^$e*HPdzt9Dy2vokhz9gOV8n4Fmx_7Wsu^Y zyOZC-RDPPjsDpnm3`#-9s1^de;$bS~fyCaKKM@7Bs$}y6tXR_h)IE9ByQz zk_w3m^*qmCH#defY3>pOW3LvafLjIRo1%R$!cpwN+Y}6_%>y?(2E#88kH0{hR^Ce7 z>=%HofxD`CbyV3vln&nNI07f!3VafYI{RTcJJ33N&Iy}6u#_zHKDR&Z3sE#BAnqgT zw?%`xerOz$R1{@VS*rB!-9S=Jqi!R)0CG>s`O52{Ud|og7_+_0Fz0NKE#=1RI$D5P z$_}LgO#SVBw%j|z~9SO7Ac&625En3g)j3ZMix{%lt~t6>XOMe$I7<+_G&yR5D=KX^49ew?jolC=uJEyj1%`I^=Ka}kW1zSaakB7 zlUiOpQu+Nd!C}(>T~nP5wN@z>m-^fta`Hg0e4*jUrjEzMsnZ&RCbMpYG+jrUqNkW- zS)Zx;$L|R5MQrxj%QJuJVS=J!*5+bwszw^VjptAO0B3-1~i9pU?Yq-C1vTICm^rTl{=E zX#vb{RNo9!KY#cE_NL`+F5CcdN}-CKH~%O!XG(8L*~kI{#;aD`U#IlG#)Ye_?S5j= z(CF~i(P_W?ikumD+PHLcDQ5lhy+wrw; zqsw6PckPsvUSWqu8>yc3JW2UN6Rk!LW)I!7my|kKb-V&QaZw+cdb@wyu4?1;W$w_F zy7%wx2VFkRKQi4;@wgKu>-K8ZD?WiAKIz@3m4}A6t}3>ZOr;(zL{C%l6ru$zr z-j$V^rtYMQeGGiu^>;lj5#{)`(?4Es61U6estXe6h5b{miKfdvP6a4G&ubzJ-C6^i zwFYajm?gA5EyU^xQYL|;GzR}B?!p(JiFm@3?)CWVj1t3Lm#Gv1A<6))^${XZq;o1< zG~KyQP8B|o)S&A#0_A{I)DPn5Eo9%}REb3~ysW{+fT=pU@U^Dw++!|Sb6RkEQB}dV zuN7+axr^NZh}wXkxm(X~MQ*7h5MJa?t)-`XD7`cr=H zu=^qWkY|MSP2CY5n;K=EBr^nv_-Z#0BBa4d*CiUUOsU-@(G3|5>z86CUz)YJWRPurX1k!?et-B`-(v54ifP9LaYusR82J=4q>129? zzu83PP?UZHaHi}57DQpXmT{yz4rg|r&fk1UEGavrluO!aAT0;~Ebf#^%FGFh5GBaU zJ3MQ%W^b1IZQLi z^3#HCM~~XH5z<13aQM_B>FIdo5WLCBH%Vehc5Etgqq=q4@LJFSN>L|zIIBtsE%v9W>75E*pE5WsGqh{9AIDS4aIVO_$+K*|C|&aCBG3o* zbBB^+M6bNwpl4P%3q5nSgQ!tk3X5-tLSA`Ph67Bzgp>-tJ7qFctWhCQ#(-aQm1H64 zv7L#FM`IJKR<~tQjqLTe;H1S%P<9q^+m9LOKUy1 zP)PCjrJ&IAB?Fx9VeEIwy5}W!ZZTRBtW%aFBli*Jz3N^#5*7f)sGDVVG|k72zU%_O z!aq^3-}=DtMvjL`)6W_JM0~9%D_W_1_kFmih;OU5j8f$j5u$~Oa3pKUE=W-5T!A6n;m!nURQa00gb~lnOh?KM6 zpcN~+ZL8hU-Aj9!j8u1VDRusC0T1hXAtFGX>;1Hj(}i2};1*dFY}dBUTh3+?pcZn@ zb_4TCm)9IcO!Z8Hj0iuaD~z)u8;E5<*mX&@YY7c0y7yTQc93=MYtHeBore>P&1vF& z)f2!Ny~ACV@ED$XZ>t`|Qs+$%rSWT?iA zJcaWEVSn}p-toOx=vMpvMh`&yV;#LCCRr>gU_NdU?oFDHsRHXbP74Tj5K^Yg2<^|T z_g5q+OWiEOi#9pi1@rBBi#%}dGOiYM`doBz==Wok)Rh{wHJd<)J5=V?PGnvqr)Jyc z@=eX1$G_eWeMwy@%Fw!c6d#o|$L5Ylehjc=daZN3DB7(_d*D^WKO|(9Cu7!hEVL)4 zR(ZX}_pz-rP&l>`0896pp8sX9zON}b7ajWITc(=n!JNS&F5B{V`yO}l8@akQCX0p4 z+1yu+OSRXf%oyOVn2&rwhK0|vuIQPqu}r3B%kL5o7U++*+$i$IE@f)S&j)|>#cg|w z7DfG56`oQ|_L)I@q8Wj#w0hxD^jQIGPXg);&rd>&+i%)HsD|>+rP?7+pKT(b{==xb zFW*W5u}uOAZ+8z$_2aszoDsXt_zOU(Vx;2^?9lp>PZ6P()w z`F&-ccC~(g@{~(L3sZVYW98~0nU79Sym+>T4(2y%Ra^2%w2nmXsEIF#%~@~$RScR1 zbKQ}UpMiQ!GC%^I)*?|CSlCN>r2Fnj);70_bHhi^Nu}Vh#A;fmqqAmBSR3cgk4Z7@ zAjEgZP;ER@DlRoWE&QlyHDEwF-{NZF86||0yWB&5O{q5`eHtCb)7NsZi^aBz_L&_DIX8gb;aBBbY6y2qVO9zZtbc6_rh035Sl}6EGLYx!*Mq! zf2)_qY)d7DY4G11`YmED+$))DWRGkvvebR)2YoEH^~I(;BZW3(?l~}RLMcC5U+4MY zuS6&t-tVfy2W}4dN$WlDx|{tB zNyv9jN41>A(=T=-yED}})Hme;hRy%-dJ5Hp{a76*JI6>1#|pwV5UwF)*P_%Oe4_HWqv;$5gHv$nl)WPA$3viI*~Ma)CF}5sg5?Ptrypwa z%At8JLZ`_CV}V1MTTWeg8X|dpbV1s0{e1pII8K@7IL=(IB1dO68XlnnA+b%h>H&aNdq9vx)lgzxRIGX8_oGJIphriQohgie6BU2U^$ zC&15j9m$vMyGyf`CL$1ooA<^HqPgdk#3AT|v~fqWoK6V%&z&EaXcC|Wvl7ng>4u2l z#aaS@%`NL@7+#|2(CU2hH&q&sS$@Brf!-94Vak^}Xg3Q!GjIbRuSqSKnMO-C zr2EQli5Y~P zJ(ve#73i=}%)pN9T;l76Z0f)&024P^UiBqzy~^3H<)EY3HpStbOz!t>F&3V!^z0`F zSd&s#b3u?RoNLRs>Sn=I7Nx6$TJ9iL_rXnTl1CU<+YQu|UA<#cf}Gvi_x(E_<^vXc zh?eUJLjjZ^V*8qgQXpe?;V;Kd+MuYCJ9yl45Q?r(@B0(DUEciyz zA*WCK&u_P?QQsmu1aymxjSL#iw=ydC_{LY|otSp%_*uia;~BL}Gy+F?;gv5M&ww(V zUr+uAy-^z#R{C9##>-emF`+)F%;RTSKz#q%2gkvrYiGfMyq_rx^wAd;pb`6^hV@$- z3?S;sF;3Bl9H|27U(dA$Jc;jcJRE^#oqW{QJ(kda6g1w;Kv|An_Jn>yy3Vm)KtLrs_>+S{Qa=`DO}=VS=WmoPmKjPG)ns~5q%@fmvz6#T25?vcT(rdrnEVfW z+$x^Ckb;Z}W?6`H9RsMtyqGA*@%on3t;XQZ#^&{8AvY0;RJ#J(d)Vtad8sBWE+#vk}qp<&1SI>>pQpoTX_H=X4O3p1n2k<2Ky4R4jis8=eKxRa(rxR_$^` zNSo4^3BwnD7TW`n()qsUTHKl+$WD+1rkJ#h7=wsPoq*3{c}E;USnEQPPMfmewOu9>jq)AX;>n{rpr)X zP)V2({T`E`6Qyr%XBvd3hLj-b5av3FLTMr@WHy0q5|Gu#)jFj;H1&S+`^gALy7#~unR$5zGu?*CK~ zErtL=;w;sZhahm1)}w!K@G+Jv=T?S8pNG7&)UZ?1UU0bb-e-R%@ex%lI)1Fsb4x$k zGiM%Lgaf$IWCvGFZCQU!{ zczO7akme^B4MxV!N93E2uQQyR>&Pq__Sdi^hot89b0vLIduJfGG$x&u+r0M=nCZhw zAf-DZrdGN<@tFb~#tF(yh2BI-Up*m<)|74OcJCh)l4`Mvm$zyYCWj>NlZBe8Co9oe zF*jC{rHT{^T);|FEYxUSJ%r!9<+-+UM|;~F6GrbH;Zx_IT{sQ%Z5*ieN6KOWc~a?! zCl8Ma6ZCeek$ia{x}S~M=bGgrR4NZ9oc-1?+dSKR+evL9D|@Il%S$@+)PRNlOp*&_ z{Yqf*0R=!ftCG`}Eqi6)j6bz)u^XXCTQr|x?t-arN2svp93~Xdn2k$(%w90_S}onV zZfrNET*1g@4?fjz({Rg(TF@(!EO3pLwYfzQq{CHV*pCYNn~er~&?s^w8xS+Z2vg}! zg=E(fxRiM*JHWdD53>Q_O53A)?Zv7U>3o*THynpp7epT>nozImxoh|T`;=d;1cxLQ zHHY3u>~}{@^PCl3I)Cpfb9Wa!Y8lIFL=|EV4ykkH9vLWl*#OfV8iRx`IA$Gx1enD) zzgCXq0Y|RG)jbd_h=*w|@3_AHWOsL%dchW043io|*Dgj6?hnPS7#P>ZT+1`e07F%& zBOaYaz`=pn70}+YeYf$DDkdC(P?=aU>IR{ygrTZm!{?wj6;u0boMCc@J!*36zb8U+{yUMjQ3s95{rBt4 zO?1lV4Vh_Jp7_6i;UYh3jY4tgPzkYZA0DswAMk=?PeR*Yn;-o{J>yqTmO55ep`;L_ zo~Qr?h!ZaIrE0)Cx+uE!z^?lLc`c6Vr%TMXSBGFF>(Lc7zVg@Sfk48d=*&Y1$-EH2>td+WJNxNa%5h-OaR43Gk9qz5 zWs8bDl{qq4XnBs)SnZAIuZ)9#eWAW*uvOJH-^ld8BV8O{Vr0%|8H$D!fCU+!8A?XP(J#9m+ Je2w34{SSz)7SI3y literal 0 HcmV?d00001 diff --git a/docs/figure9-graph-study/graph-construction.png b/docs/figure9-graph-study/graph-construction.png new file mode 100644 index 0000000000000000000000000000000000000000..5233732a292895f1dc779e13e4bd7586ce713b21 GIT binary patch literal 110420 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 XhkCpV*^Nihx7EfOXuMFra{qq-K`?Uz literal 0 HcmV?d00001 diff --git a/docs/figure9-graph-study/graph-qps-recall.png b/docs/figure9-graph-study/graph-qps-recall.png new file mode 100644 index 0000000000000000000000000000000000000000..e40ee3a8f537f8ac8b3da5bbd47f527bc6df4747 GIT binary patch literal 198585 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 NWL+WGQ8@C*-DnSZpm-PT?uB_~D38^w8`;Ghx2A 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

    @HSUn3w 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

    cNBD-B0HXbehZ*Mqjz}=&`hnOj{vs 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 S2F_Ln2Bl-F3UNm--u_>PZ-*-Y literal 0 HcmV?d00001 diff --git a/docs/figure9-graph-study/jars.sha256 b/docs/figure9-graph-study/jars.sha256 new file mode 100644 index 000000000..8c65bd19b --- /dev/null +++ b/docs/figure9-graph-study/jars.sha256 @@ -0,0 +1,4 @@ +6b0a8c8591ea6f5e35abdadf0709b53fa440bff01441dfc21b8f11190bc152e8 /mnt/raid10/jvector-bench/ted_willke/figure9-graph/jvector-twenty.jar +a7e2c16516d5e296d1ba95741abaeccca8b3140abbcc71bf495eb5b2212207f4 /mnt/raid10/jvector-bench/ted_willke/figure9-graph/pq-main-twenty.jar +f8aa46d6f7ba9c7e8eb5cfc2606989ae8a4f0116294b1dbaec0c380a5ef41fcd /mnt/raid10/jvector-bench/ted_willke/figure9-graph/pq-main.jar +f9e4006ba1ea135d20beb2d780c065d68a472d1f04ea500fb26afec0b0f3c799 /mnt/raid10/jvector-bench/ted_willke/figure9-graph/study.jar diff --git a/docs/figure9-graph-study/methodology.md b/docs/figure9-graph-study/methodology.md new file mode 100644 index 000000000..022c8f923 --- /dev/null +++ b/docs/figure9-graph-study/methodology.md @@ -0,0 +1,35 @@ +# Figure 9 graph comparison — protocol + +Authoritative host: `ted_willke@34.169.37.132`, GCP c4-standard-96-lssd. ASH source: `/home/ted_willke/jvector-ash`, `ash-symmetric-scorer` commit `922b23e3`, plus benchmark-only explicit concurrency controls, subsequently committed as `638edc37`. PQ: verified GitHub origin/main `1a86718baa2d8b72a0d134711a682875888b9646`, isolated detached worktree. Identical ThroughputBenchmark source on both. No duplicate-ID changes. + +## Matrix + +Three datasets: Ada002-1M (982,790 base vectors) and CAP-1M (1,000,000), both D=1536; Cohere English v3-1M (1,000,000), D=1024. Each has 10,000 queries. User confirmed d=D/2 to match Figure 9: respectively 768, 768, 512. ASH b=2 and b=4. Graph ASH requires C=1. Production training remains 20D, max25 iterations with early stopping; paper IVF uses C=32 and 10D. The study does not change production training defaults. + +PQ uses production JVector K=256, uncentered, anisotropy disabled. The actual fused query path is `FusedPQDecoder.DotProductDecoder`, with float partial-sum tables and SIMD `VectorUtil.assembleAndSum`; it is not Faiss 4-bit FastScan. Match exact stored quantized-vector bytes including ASH's 5-byte header: M=197/389 for Ada002/CAP and M=133/261 for Cohere, with uneven PQ subvector dimensions supported. The nominal 32X/16X paper labels refer to payload only; actual graph code compression is slightly lower once the ASH header is counted. Faiss IVF in the paper uses packed 4-bit PQ; these are different production PQ implementations. + +Graph settings: M=64, efConstruction=200, overflow1.2, hierarchy/refinement enabled, fused neighborhoods, NVQ reranking, k=10 and rerankK=10/20 (overquery1/2), search pruning disabled. ASH retains production adaptive termination gamma0.01. PQ uses latest-main behavior, which has no ASH adaptive feature. ASH writes version7, latest-main PQ version6. This is a production-branch comparison, not an isolated quantizer ablation. + +## Timing and retention + +Two independent fresh compressor/graph builds per configuration (24 total), reversed ASH/PQ order on repetition2. No shared compressor cache across fresh repetitions. Each index is retained and reopened for a 48-query-worker concurrency pass. Explicit serial/concurrent checks compare complete ranked node IDs, scores and visited count for all test queries at each overquery setting. Recall and visited measurements are outside the throughput timer. + +Single-threaded QPS is the main result. Three timed samples, each at least3seconds and a complete query batch; raw durations and query counts logged. Existing synthetic warmup remains enabled. Query concurrency uses a dedicated 48-worker pool. Grid submits insertion to PhysicalCoreExecutor with 48 workers; GraphIndexBuilder cleanup uses its default common ForkJoinPool (normally parallelism 95 on this 96-logical-CPU JVM). Both branches use this arrangement. Workers are not explicitly pinned, unlike the paper’s 48 pinned build threads. Asymmetric projection/setup remains inside each query call for ASH. The graph uses corrected symmetric node comparisons and asymmetric raw-vector insertion/query scoring. + +JDK23, vector module, native access enabled, Xmx128g, ASH SIMD single/block kernels, production accumulator/unroll settings. Explicit `jvector-twenty` classpath supplies MemorySegmentReader; reader fallback warnings are checked. Temporary files and retained indexes are on RAID10 SSDs. These1M indexes fit RAM; warmed memory-mapped query throughput is not a larger-than-memory I/O result. + +Retain graph build/write time separately from training, base encoding and provider setup. Add NVQ compressor setup to these measured phases; their sum is a measured phase subtotal, excluding uninstrumented writer initialization overhead. Process elapsed time additionally includes dataset loading, JVM startup, warmup and queries and is not called construction time. + +The paper uses the same GCP c4-standard-96-lssd/Intel Xeon 6985P-C host class, 48 build cores and single-thread queries. Its IVF implementation is C++/AVX-512 with 4096 lists and an nprobe sweep; this graph experiment uses Java/JDK23 and two overquery settings. + +No IVF reruns. Published Figure9 panels are extracted from the supplied final PDF and shown separately from measured graph points. Any comparison across index types must retain the landmark/training/PQ implementation and reranking differences above. + +Remote evidence and reusable caches: `/mnt/raid10/jvector-bench/ted_willke/figure9-graph/`. Per-case directories contain YAMLs, `index_cache`, ASH `compressor_cache` or latest-main PQ `pq_cache`, logs and completion manifests. Frozen JARs and hashes are stored at the study root. + +## Interpretation + +The runtime profile confirms that graph construction uses `ImmutablePQVectors.diversityFunctionFor(DOT_PRODUCT)`, which caches centroid-pair partial sums and calls `VectorUtil.assembleAndSumPQ`. The base `PQVectors` implementation is not the active diversity scorer in these runs. Corrected ASH construction compares packed codes. Query scoring remains asymmetric in both configurations. + +Figure9 compares published IVF search curves. It does not report complete graph or IVF index-build times. Its ASH/PQ query paths do not use this graph's NVQ reranking configuration, so higher graph terminal recall must not be attributed solely to the quantizer. Graph construction is compared directly between the two measured graph implementations. + +The exact same graph is used for each serial/concurrent comparison; independent graph repetitions are used only for build/run variability. Two repetitions provide ranges, not narrow statistical confidence intervals. diff --git a/docs/figure9-graph-study/paper-panel-0.png b/docs/figure9-graph-study/paper-panel-0.png new file mode 100644 index 0000000000000000000000000000000000000000..a230e036b882f3c510536691de337d1881f62644 GIT binary patch literal 328985 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
    Hx$!MMnJoIYN_EMI5S)W~LSnwq! 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

    6slB}V^uAGN=bF|@r7Fv7?!&Xk4 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

    F7WlcF6~gYp5;j3*}{w=w~Wyx?u6*H=m2)1 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�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 C#3|eW literal 0 HcmV?d00001 diff --git a/docs/figure9-graph-study/paper-panel-2.png b/docs/figure9-graph-study/paper-panel-2.png new file mode 100644 index 0000000000000000000000000000000000000000..c6fdf1da9e8f980b6431bf222e977d6ffd8424b3 GIT binary patch literal 327966 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⪻yX@9GeCm=kCH0v>cn|)vG%}+tLEISH_%vJq!X!< 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

    Nb3$XCo`7)nz|9H4)R& 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.?a0m45aK$XGTzG5?c;)@;ANa^uw#umuT0v`+<_svoHyXyCbExfT^fJkS5L# zzRK;C&}d~If~7fTOBbcV@v%stP80TJSI&>AQI@Cjs&5)FfK^$DP?Xhd_Nu{?RT&HCJ}2u|9Tx*muY>yb}|4%LNl$~-$* zJUNR11<6)t&P8UCEhk_$-t_(%~JkAD_K&PVr1R9M?kwFs_y#{nbQmVRP-1wROJ!1hB2EU<?0a`qO$^T6B{ z9UqhShDneI^v+BB9QZo4@YAi58#Y75F*TKrg|BBhU+?uzUY@QWt0bmUx&|X!TGSGC zfzMD$QBg}?w-Wf`I{)&z@AUTW^YCqn`x=a#I<=NMLahebmO6u>FdP4WUcUhV$sW`k zMjlJUq{bG;l(#t_vQgX=xAucrU^R7enjn++skpDyOp)vV$x!lXtP$zzL1Y7IIh)ZI z9zSeT;P<6XsOD{AUcnOUE-_k1UH#b?Sy}Be!>Gsk3JGcM8JGN@$n{bIkCh9)H_VKW zLG$DSy85=OFK{6N`0NO({wjx;2N>@4PA`*yRWY#r*EV_c70pAm+SqmvL!j~O^oq>0 zqxYWug26{_A3eu779vfG_bP6j3uxZidD_Vd$HfHJzro*1kxuw}hx0#!`|R(CXA*UH z;?fa6#~Pf6dS3u6qu$GLr#*OgSRjplAhYakcW6a}yfL*foQWF)6T~)vuico^1g>XtcH9`hjB68$r>$$2yyv zVQv+}FLBd;$RGzD#!SPgtw5t-NG$)tysN~?#Gu>Zde=t*El~* zu*Z1aW{C9B3$^N&SYkOhQ3NXWUZ6tef}9vx#{?7xN&zF{Gftab*mm7{2{}OCh+wZ~ z|M7jER7atnl!o_*A9g2DqLKZ*;(=N{wS^jZ6{?A4UmnO+(E@H0^hspxB|$+oAVApy z7mH!3%ZKw-P~E1D)A4It>h2Ecfz0Y3suWGxkiOe%t8cWdf`XyIQ7 z%3x;5E1iSULG5~*sNEdxi<+PB90d{gEz-=u;)x}<6sOkwdrh~c9d0(2`vU-^QX-C~ zX8)!ii~`DTM2@8aOX@Dk;K!4nmHMyImgEB@kV>MYEQnBiF}5c8^5xADb+T-R5P?PI z9}ju^=wJVP($(+ll1fY*AH-Sf8VJ&-^Xl~!qxWz%{+JoPA$X49PrB%Y-2a|~+ZTy` z7*W6dL1P%EJpcX>!U1*;b7O(_R8=Y6XOAC`CkTxF|DLzor*H67lR+5^)OJ-+-Fdqe zzMZ&D@xej1Rtt4ia9(Z#KVR(Bf4$gieW21&EH4yOI#A+9tGA0wU0vuT9l}4hQ=%zw zJ7p4J%x$Qm0;58vAK%h+=D+5-iDDH8S8wGUgiJFs1t%B&;8@uul+ud@#7!=|NTu()(Z!3 mm8&RsNBjTzx4TeF;+Pt}&~T*mFDlBzD&A9-E4=&g`Tqdqxw2;f literal 0 HcmV?d00001 diff --git a/docs/figure9-graph-study/published-ivf-reference.png b/docs/figure9-graph-study/published-ivf-reference.png new file mode 100644 index 0000000000000000000000000000000000000000..2b18e14095fad662c291f335026b633741531069 GIT binary patch literal 319379 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%0AHz=$bFAJ}eR4|} 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

    X#M=H?QG`mSmso%o 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 literal 0 HcmV?d00001 diff --git a/docs/figure9-graph-study/quality-results.csv b/docs/figure9-graph-study/quality-results.csv new file mode 100644 index 000000000..234071376 --- /dev/null +++ b/docs/figure9-graph-study/quality-results.csv @@ -0,0 +1,9 @@ +name,threads,overquery,qps,qps_cv,recall,visited,log +ada002-1M-b4-pq-r2,1,1.0,2213.5294641529763,2.656232061739049,0.78124,741.0503,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/ada002-1M-b4-pq-r2/quality-1.log +ada002-1M-b4-pq-r2,1,2.0,1697.0094228980922,0.5827720462316611,0.94098,1071.2316,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/ada002-1M-b4-pq-r2/quality-1.log +cap-1M-b2-ash-r2,1,1.0,2671.8321513333003,6.581649160723063,0.79039,751.5542,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/cap-1M-b2-ash-r2/quality-1.log +cap-1M-b2-ash-r2,1,2.0,2181.25379117744,1.411287086771372,0.94304,1096.9158,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/cap-1M-b2-ash-r2/quality-1.log +cap-1M-b4-pq-r2,1,1.0,2463.1515004291546,5.7424423017788095,0.77117,721.4095,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/cap-1M-b4-pq-r2/quality-1.log +cap-1M-b4-pq-r2,1,2.0,1936.7856067073103,0.9009159641326212,0.93402,1029.0857,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/cap-1M-b4-pq-r2/quality-1.log +cohere-english-v3-1M-b4-ash-r1,1,1.0,3659.88651139761,3.3352298962798885,0.79959,829.839,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/cohere-english-v3-1M-b4-ash-r1/quality-1.log +cohere-english-v3-1M-b4-ash-r1,1,2.0,2652.860877518716,0.8181655322064287,0.91214,1232.4703,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/cohere-english-v3-1M-b4-ash-r1/quality-1.log diff --git a/docs/figure9-graph-study/repeat-summary.json b/docs/figure9-graph-study/repeat-summary.json new file mode 100644 index 000000000..a2085b556 --- /dev/null +++ b/docs/figure9-graph-study/repeat-summary.json @@ -0,0 +1,37 @@ +[ + { + "case": "cohere-1m-b4-ash", + "build_s": [ + "203.342001607" + ], + "gc_pause_s": 0.6757420000000003, + "max_gc_pause_ms": 38.576, + "compilation_events": { + "nmethod": 7407, + "make_not_entrant": 2082, + "deoptimized": 1528, + "failure": 1 + }, + "compilation_failures": { + "concurrent class loading": 1 + } + }, + { + "case": "cohere-1m-b4-pq", + "build_s": [ + "350.110698789" + ], + "gc_pause_s": 1.651136, + "max_gc_pause_ms": 69.35, + "compilation_events": { + "nmethod": 6856, + "make_not_entrant": 1841, + "deoptimized": 903, + "failure": 2 + }, + "compilation_failures": { + "retry without subsuming loads": 1, + "concurrent class loading": 1 + } + } +] \ No newline at end of file diff --git a/docs/figure9-graph-study/report-scripts/data.py b/docs/figure9-graph-study/report-scripts/data.py new file mode 100644 index 000000000..f2f5cddfc --- /dev/null +++ b/docs/figure9-graph-study/report-scripts/data.py @@ -0,0 +1,17 @@ +"""Preserve primary observations and explicitly overlay predeclared longer retimings.""" +from pathlib import Path +import csv + +def load_rows(): + p=Path(__file__).resolve().parent.parent + rows=list(csv.DictReader((p/'results.csv').open())) + for x in rows:x['throughput_source']='primary-3s';x['original_qps']=x['qps'];x['original_qps_cv']=x['qps_cv'] + if (p/'quality-results.csv').exists(): + for q in csv.DictReader((p/'quality-results.csv').open()): + x=next(x for x in rows if x['name']==q['name'] and x['threads']==q['threads'] and float(x['overquery'])==float(q['overquery'])) + assert float(x['recall'])==float(q['recall']) and float(x['visited'])==float(q['visited']) + x.update(qps=q['qps'],qps_cv=q['qps_cv'],throughput_source='cached-10s: '+q['log']) + if len(rows)==96: + with (p/'reported-results.csv').open('w') as f: + w=csv.DictWriter(f,fieldnames=list(rows[0]));w.writeheader();w.writerows(rows) + return rows diff --git a/docs/figure9-graph-study/report-scripts/finalize.py b/docs/figure9-graph-study/report-scripts/finalize.py new file mode 100644 index 000000000..5625ba5f6 --- /dev/null +++ b/docs/figure9-graph-study/report-scripts/finalize.py @@ -0,0 +1,46 @@ +from pathlib import Path +import csv,statistics,json,subprocess +p=Path(__file__).resolve().parent.parent +from data import load_rows +rows=load_rows() +assert len(rows)==96,len(rows) +for r in rows: + for k in ['bits','rep','threads','overquery','qps','recall','build_s','provider_s']: + r[k]=float(r[k]) if r[k] else 0. +by={} +for r in rows: + key=(r['dataset'],r['bits'],r['mode'],r['rep'],r['overquery']) + by.setdefault(key,{})[r['threads']]=r +for key,pair in by.items(): + assert set(pair)=={1,48},key + assert pair[1]['recall']==pair[48]['recall'],key + assert pair[1]['visited']==pair[48]['visited'],key +for r in rows:assert r['qps']>0 and 0<=r['recall']<=1 +subprocess.run(['python3',str(Path(__file__).with_name('tables.py'))],check=True) +subprocess.run(['python3',str(Path(__file__).with_name('plot.py'))],check=True) +def group(ds,b,m,o):return [r for r in rows if r['dataset']==ds and r['bits']==b and r['mode']==m and r['threads']==1 and r['overquery']==o] +def avg(rr,k):return statistics.mean(r[k] for r in rr) +dslist=['ada002-1M','cap-1M','cohere-english-v3-1M'] +rec_wins=qps_wins=build_wins=0 +for ds in dslist: + for b in (2,4): + a,q=group(ds,b,'ASH',1),group(ds,b,'PQ',1) + build_wins+=avg(a,'build_s')+avg(a,'provider_s')avg(q,'recall') + qps_wins+=avg(a,'qps')>avg(q,'qps') +text='# Production ASH versus latest-main PQ: Figure 9 graph study\n\n' +text+=f'Completed 24 fresh builds and 24 cached-index concurrency passes. ASH has higher mean recall at **{rec_wins}/12** matched query operating points, higher mean single-thread QPS at **{qps_wins}/12**, and faster graph construction plus scorer setup in **{build_wins}/6** configurations. These counts summarize the measured points; they do not imply a universal winner at matched recall.\n\n' +text+='The new symmetric scorer is used for encoded-node comparisons during construction. Production ASH queries remain asymmetric and include raw-query projection. Neither branch includes the separate duplicate-ID fix.\n\n' +text+='## Recommendation\n\nKeep the corrected symmetric scorer for construction and asymmetric scoring for raw queries. At these measured settings, ASH improves recall in every comparison and builds faster, but it has not universally beaten PQ on query throughput: Ada and CAP still have a throughput gap. Cohere wins both query metrics, while its original construction outlier remains unexplained. Prioritize measured score-kernel and ThreadLocal overhead before changing graph pruning policy; keep deduplication experiments separate.\n\n' +text+='## Plots\n\n![Single-thread graph QPS and recall](graph-qps-recall.png)\n\n![Construction phases](graph-construction.png)\n\n![Cached-index concurrency](graph-concurrency.png)\n\n' +text+='## Published IVF reference\n\n![Published Figure 9 panels, unchanged](published-ivf-reference.png)\n\nThe reference panels retain the paper’s original colors (red ASH, blue PQ); the new graph plots use blue ASH and red PQ. No IVF benchmark was rerun. These are architectural comparisons, with material differences: graph ASH uses C=1 and 20D training, while paper IVF uses C=32 and 10D; graph PQ uses JVector 8-bit ADC while paper PQ uses Faiss 4-bit FastScan; the graph runs use NVQ reranking. Increasing graph overquery also enlarges its search candidate set, so its gain is not solely a reranking ablation.\n\n' +text+='## Integration and validation\n\n[Branch audit](branch-audit.md) records the completed work integrated into ash-dev. The production baseline is unchanged from the original frozen ASH JAR; the byte-write fix was present throughout. Scalar and SIMD regression executions each passed 33 tests.\n\n' +for extra in ['cohere-investigation.md','timing-validation.md']: + if (p/extra).exists():text+=(p/extra).read_text()+'\n\n' +text+=(p/'tables.md').read_text()+'\n\n'+(p/'methodology.md').read_text() +text+='\n## Reusable artifacts\n\n[Reported observations](reported-results.csv) explicitly identify longer cached-query retimings and preserve original QPS/CV columns. [Original observations](results.csv) include each build repetition, both query concurrencies, timing variation, recall, visited counts and phase times. Every retained index has its own configuration and compressor cache. The remote query-only helper is `/mnt/raid10/jvector-bench/ted_willke/figure9-graph/retest.py`. For example:\n\n```sh\npython3 /mnt/raid10/jvector-bench/ted_willke/figure9-graph/retest.py ada002-1M-b2-ash-r1 --threads 1 --overquery 1.0,2.0\n```\n\nAll 48 serial/concurrent operating-point checks compare complete ranked results and visited counts for every supplied query at each index/overquery setting. Separate reported recall and visited metrics agree exactly between serial and concurrent passes. The concurrency harness is committed as `638edc37`.\n' +(p/'report.md').write_text(text) +print(dict(recall_wins=rec_wins,qps_wins=qps_wins,build_wins=build_wins)) diff --git a/docs/figure9-graph-study/report-scripts/plot.py b/docs/figure9-graph-study/report-scripts/plot.py new file mode 100644 index 000000000..ce9ab5028 --- /dev/null +++ b/docs/figure9-graph-study/report-scripts/plot.py @@ -0,0 +1,71 @@ +from pathlib import Path +import csv,statistics,json +import numpy as np +import matplotlib +matplotlib.use('Agg') +import matplotlib.pyplot as plt +from matplotlib.lines import Line2D +from matplotlib.patches import Patch +p=Path(__file__).resolve().parent.parent +from data import load_rows +rows=load_rows() +for r in rows: + for k in ['bits','rep','threads','overquery','qps','recall','visited','build_s','train_s','encode_s','provider_s','nvq_s']: + if k in r:r[k]=float(r[k]) if r[k] else 0. +datasets=[('ada002-1M','Ada002-1M'),('cap-1M','CAP-1M'),('cohere-english-v3-1M','Cohere-1M')] +colors={'ASH':'#145ee8','PQ':'#d62929'} +preliminary = len(rows) < 96 +plt.rcParams.update({'font.size':11,'axes.spines.top':False,'axes.spines.right':False,'savefig.dpi':180}) +def selected(ds,b,m,t,o):return [r for r in rows if r['dataset']==ds and r['bits']==b and r['mode']==m and r['threads']==t and r['overquery']==o] +def avg(rr,key):return statistics.mean(r[key] for r in rr) +fig,axs=plt.subplots(1,3,figsize=(15,4.7),layout='constrained') +for ax,(ds,title) in zip(axs,datasets): + for b in (2,4): + for mode in ('ASH','PQ'): + rr=[selected(ds,b,mode,1,o) for o in (1,2)] + if not all(rr):continue + xx=[avg(x,'recall')*100 for x in rr];yy=[avg(x,'qps') for x in rr] + ax.plot(xx,yy,color=colors[mode],ls='--' if b==2 else '-',marker='x',ms=7,lw=2) + for o,x,y in zip((1,2),xx,yy):ax.annotate(f'{o}×',(x,y),xytext=(5,5 if mode=='ASH' else -12),textcoords='offset points',fontsize=9,color=colors[mode]) + ax.margins(x=.08,y=.12);ax.set_title(title);ax.set_xlabel('Recall@10 (%)');ax.set_ylabel('Single-thread QPS');ax.grid(alpha=.2) +fig.suptitle('Production graph: ASH vs latest-main PQ · NVQ reranking' + (' (preliminary)' if preliminary else '')) +fig.legend(handles=[Line2D([],[],color=colors[m],ls=ls,lw=2,label=f'{m} · {label}') for b,ls,label in [(2,'--','32× nominal'),(4,'-','16× nominal')] for m in ['ASH','PQ']],loc='outside lower center',ncol=4,handlelength=3.5) +fig.savefig(p/'graph-qps-recall.png');plt.close(fig) +fig,axs=plt.subplots(1,3,figsize=(15,4.7),layout='constrained') +for ax,(ds,title) in zip(axs,datasets): + for x,b in enumerate((2,4)): + for shift,m in [(-.18,'ASH'),(.18,'PQ')]: + rr=selected(ds,b,m,1,1) + if not rr:continue + stages=[avg(rr,'train_s'),avg(rr,'encode_s')+avg(rr,'provider_s')+avg(rr,'nvq_s'),avg(rr,'build_s')] + bottom=0 + for val,hatch in zip(stages,['///','xxx','']): + ax.bar(x+shift,val,.32,bottom=bottom,color=colors[m],hatch=hatch,edgecolor='white',linewidth=.8) + bottom+=val + totals=[sum(z[k] for k in ['train_s','encode_s','provider_s','nvq_s','build_s']) for z in rr] + if len(totals)>1: ax.errorbar(x+shift,bottom,yerr=[[bottom-min(totals)],[max(totals)-bottom]],fmt='none',ecolor='black',capsize=4,lw=1) + ax.text(x+shift,max(totals)+2,f'{bottom:.0f}s',ha='center',fontsize=9) + ax.set_xticks([0,1],['32× nominal\n2-bit ASH','16× nominal\n4-bit ASH']);ax.set_title(title);ax.set_ylabel('Indexing phase seconds');ax.grid(axis='y',alpha=.2) +fig.suptitle('Fresh production index construction · ' + ('preliminary observations' if preliminary else 'mean of two builds; whiskers show range')) +fig.legend(handles=[Patch(facecolor=colors[m],label=m) for m in ['ASH','PQ']]+[Patch(facecolor='gray',edgecolor='white',hatch=h,label=t) for h,t in [('///','Training'),('xxx','Encoding + setup'),('','Graph build/write')]],loc='outside lower center',ncol=5) +fig.savefig(p/'graph-construction.png');plt.close(fig) +fig,axs=plt.subplots(1,3,figsize=(15,4.7),layout='constrained') +for ax,(ds,title) in zip(axs,datasets): + for b in (2,4): + for m in ['ASH','PQ']: + for o,mark in [(1,'o'),(2,'x')]: + rr=[selected(ds,b,m,t,o) for t in (1,48)] + if not all(rr):continue + yy=[avg(x,'qps') for x in rr] + ax.plot([1,48],yy,color=colors[m],ls='--' if b==2 else '-',marker=mark,lw=1.5) + ax.set_xscale('log');ax.set_yscale('log');ax.set_xticks([1,48],['1','48']);ax.set_title(title);ax.set_xlabel('Query workers');ax.set_ylabel('QPS');ax.grid(alpha=.2) +fig.suptitle('Same cached graphs: single-thread and concurrent query throughput') +fig.legend(handles=[Line2D([],[],color=colors[m],label=m) for m in ['ASH','PQ']]+[Line2D([],[],color='black',ls=ls,label=label) for ls,label in [('--','32× nominal'),('-','16× nominal')]]+[Line2D([],[],color='black',ls='',marker=m,label=f'{o}× overquery') for m,o in [('o',1),('x',2)]],loc='outside lower center',ncol=6,handlelength=3) +fig.savefig(p/'graph-concurrency.png');plt.close(fig) +# Published raster panels are shown unchanged; they are not new measurements. +fig,axs=plt.subplots(1,3,figsize=(15,5.2),layout='constrained') +for i,ax in enumerate(axs): + ax.imshow(plt.imread(p/f'paper-panel-{i}.png'));ax.axis('off') +fig.suptitle('Published IVF results — Figure 9 (unchanged; original paper colors)') +fig.savefig(p/'published-ivf-reference.png');plt.close(fig) +print('Plots written') diff --git a/docs/figure9-graph-study/report-scripts/tables.py b/docs/figure9-graph-study/report-scripts/tables.py new file mode 100644 index 000000000..7cc1bbacf --- /dev/null +++ b/docs/figure9-graph-study/report-scripts/tables.py @@ -0,0 +1,50 @@ +from pathlib import Path +import csv,statistics +p=Path(__file__).resolve().parent.parent +from data import load_rows +rows=load_rows() +for r in rows: + for k in ['bits','rep','threads','overquery','qps','recall','visited','build_s','train_s','encode_s','provider_s','nvq_s']: + if k in r:r[k]=float(r[k]) if r[k] else 0. +def sel(ds,b,m,t,o):return [r for r in rows if r['dataset']==ds and r['bits']==b and r['mode']==m and r['threads']==t and r['overquery']==o] +def mean(rr,key):return statistics.mean(x[key] for x in rr) +dslist=['ada002-1M','cap-1M','cohere-english-v3-1M'] +text='# Final graph results\n\nMeans of two independent builds per configuration. ASH/PQ values are shown in that order.\n\n' +text+='## Single-threaded queries\n\n| Dataset | ASH bits / PQ matching budget | Overquery | Recall@10 (%): ASH / PQ | QPS: ASH / PQ | ASH/PQ QPS | Recall difference (pp) |\n|---|---:|---:|---:|---:|---:|---:|\n' +for ds in dslist: + for b in (2,4): + for o in (1,2): + aa,pp=sel(ds,b,'ASH',1,o),sel(ds,b,'PQ',1,o) + if len(aa)!=2 or len(pp)!=2:continue + ar,pr=mean(aa,'recall')*100,mean(pp,'recall')*100 + aq,pq=mean(aa,'qps'),mean(pp,'qps') + text+=f'| {ds} | {b} | {o}× | {ar:.3f} / {pr:.3f} | {aq:,.0f} / {pq:,.0f} | {aq/pq:.2f}× | {ar-pr:+.3f} |\n' +text+='\n## Construction\n\nGraph column includes scorer-provider setup. Phase subtotal also includes compressor training, base encoding and NVQ compressor setup; it excludes dataset loading, JVM startup and uninstrumented writer initialization.\n\n| Dataset | Bits/budget | Graph + provider seconds: ASH / PQ | Training seconds: ASH / PQ | Encoding seconds: ASH / PQ | Measured phase subtotal seconds: ASH / PQ |\n|---|---:|---:|---:|---:|---:|\n' +for ds in dslist: + for b in (2,4): + rr=[sel(ds,b,m,1,1) for m in ['ASH','PQ']] + if any(len(x)!=2 for x in rr):continue + v=[] + for x in rr: + graph=mean(x,'build_s')+mean(x,'provider_s');train=mean(x,'train_s');enc=mean(x,'encode_s');total=graph+train+enc+mean(x,'nvq_s');v.append((graph,train,enc,total)) + vals=[' / '.join(f'{v[i][j]:.2f}' for i in (0,1)) for j in range(4)] + text+=f'| {ds} | {b} | '+' | '.join(vals)+' |\n' +text+='\n## Same cached indexes, 48 query workers\n\n| Dataset | Bits/budget | Overquery | Concurrent QPS: ASH / PQ | Speedup over one worker: ASH / PQ |\n|---|---:|---:|---:|---:|\n' +for ds in dslist: + for b in (2,4): + for o in (1,2): + aa,pp=sel(ds,b,'ASH',48,o),sel(ds,b,'PQ',48,o) + if len(aa)!=2 or len(pp)!=2:continue + aq,pq=mean(aa,'qps'),mean(pp,'qps');asq=mean(sel(ds,b,'ASH',1,o),'qps');psq=mean(sel(ds,b,'PQ',1,o),'qps') + text+=f'| {ds} | {b} | {o}× | {aq:,.0f} / {pq:,.0f} | {aq/asq:.2f}× / {pq/psq:.2f}× |\n' +text+='\n## Between-build ranges\n\nRanges of two observations, not confidence intervals.\n\n| Dataset | Bits/budget | Method | Overquery | Single-thread QPS range | Recall@10 (%) range | Graph + setup seconds range |\n|---|---:|---|---:|---:|---:|---:|\n' +for ds in dslist: + for b in (2,4): + for m in ['ASH','PQ']: + for o in (1,2): + rr=sel(ds,b,m,1,o) + if len(rr)!=2:continue + q=[x['qps'] for x in rr];r=[x['recall']*100 for x in rr];g=[x['build_s']+x['provider_s'] for x in rr] + text+=f'| {ds} | {b} | {m} | {o}× | {min(q):,.0f}–{max(q):,.0f} | {min(r):.3f}–{max(r):.3f} | {min(g):.2f}–{max(g):.2f} |\n' +(p/'tables.md').write_text(text) +print('Table rows available',len(rows)) diff --git a/docs/figure9-graph-study/report.md b/docs/figure9-graph-study/report.md new file mode 100644 index 000000000..d223d8abe --- /dev/null +++ b/docs/figure9-graph-study/report.md @@ -0,0 +1,213 @@ +# Production ASH versus latest-main PQ: Figure 9 graph study + +Completed 24 fresh builds and 24 cached-index concurrency passes. ASH has higher mean recall at **12/12** matched query operating points, higher mean single-thread QPS at **4/12**, and faster graph construction plus scorer setup in **6/6** configurations. These counts summarize the measured points; they do not imply a universal winner at matched recall. + +The new symmetric scorer is used for encoded-node comparisons during construction. Production ASH queries remain asymmetric and include raw-query projection. Neither branch includes the separate duplicate-ID fix. + +## Recommendation + +Keep the corrected symmetric scorer for construction and asymmetric scoring for raw queries. At these measured settings, ASH improves recall in every comparison and builds faster, but it has not universally beaten PQ on query throughput: Ada and CAP still have a throughput gap. Cohere wins both query metrics, while its original construction outlier remains unexplained. Prioritize measured score-kernel and ThreadLocal overhead before changing graph pruning policy; keep deduplication experiments separate. + +## Plots + +![Single-thread graph QPS and recall](graph-qps-recall.png) + +![Construction phases](graph-construction.png) + +![Cached-index concurrency](graph-concurrency.png) + +## Published IVF reference + +![Published Figure 9 panels, unchanged](published-ivf-reference.png) + +The reference panels retain the paper’s original colors (red ASH, blue PQ); the new graph plots use blue ASH and red PQ. No IVF benchmark was rerun. These are architectural comparisons, with material differences: graph ASH uses C=1 and 20D training, while paper IVF uses C=32 and 10D; graph PQ uses JVector 8-bit ADC while paper PQ uses Faiss 4-bit FastScan; the graph runs use NVQ reranking. Increasing graph overquery also enlarges its search candidate set, so its gain is not solely a reranking ablation. + +## Integration and validation + +[Branch audit](branch-audit.md) records the completed work integrated into ash-dev. The production baseline is unchanged from the original frozen ASH JAR; the byte-write fix was present throughout. Scalar and SIMD regression executions each passed 33 tests. + +## Cohere construction investigation + +The first 4-bit Cohere runs were anomalous. Their timings are retained in the primary tables and ranges; they are not silently replaced with faster repeats. The integrated ash-dev code is identical to the frozen ASH production code used for those runs. + +| Cohere 4-bit run | ASH graph build/write | PQ graph build/write | +|---|---:|---:| +| Primary repetition 1, no JFR | 658.45 s | 1,224.67 s | +| Primary repetition 2, short JFR capture | 158.05 s | 320.37 s | +| Additional control, no startup JFR | 203.34 s | 350.11 s | + +ASH's first/second insertion times were 511.91/89.53 seconds; cleanup 82.69/5.49 seconds; flush 63.85/63.03 seconds. This localizes that anomaly to graph computation rather than the bulk byte-write fix or SSD flush throughput. The extra control's insertion/cleanup/flush times were 105.59/5.58/92.17 seconds. + +### What the evidence establishes + +- The byte-write fix is present in the actual frozen JAR. No production-code change caused the faster second repetition. +- Source dispatch and live profiles use the intended SIMD kernels for d=512. There is no dimension-based Java dispatch to a scalar ASH kernel. This does not by itself guarantee that every Vector API operation is optimally compiled. +- During the monitored Cohere runs there was ample available memory, no swap, and zero sampled memory-pressure PSI. Each build used a fresh JVM. The pre-resume host had 340 GiB available and no benchmark JVM running. These observations cannot reconstruct the complete system state of the first repetition. +- Full-JVM GC pause totals in the monitored runs were 0.93–2.48 seconds. Large allocation volumes exist, particularly in the 2-bit PQ run, but long stop-the-world pauses do not explain the monitored build costs. JFR shows boxed Vector API objects and array allocation, including NVQ's nvqLoss path. The sampled allocation weights are estimates, not exact per-method allocation accounting. +- The active PQ construction implementation is ImmutablePQVectors' cached centroid-pair lookup. In its 4-bit profile, assembleAndSumPQ_512 was the top frame in 54.1% of execution samples, ThreadLocal.get in 25.2%, and assembleAndSum512 in 14.2%. ASH's 4-bit profile was concentrated in packed asymmetric projection scoring and symmetric packed comparisons. These are sampled CPU-stack proportions, not an exact wall-time decomposition. +- Hardware cycles/instructions/cache counters are not exposed by this VM's perf interface. Therefore these measurements do not distinguish execution throughput from cache/memory stalls precisely. Historical sar files were stale and did not cover the first runs. + +### Diagnostic comparison counts + +A separate diagnostic class counted actual pair evaluations while preserving the pruning decisions and score arithmetic. It was not added to production code. Cohere-100k is a scale control, not a substitute for measuring the original 1M execution history. + +| Method / budget | Prune calls | Candidate checks | Pair comparisons | Diagnostic build/write | +|---|---:|---:|---:|---:| +| ASH 2-bit | 661,994 | 87,840,870 | 1,503,229,262 | 13.27 s | +| ASH 4-bit | 667,048 | 91,887,956 | 1,504,819,172 | 16.91 s | +| PQ matching 2-bit | 669,293 | 82,090,179 | 1,535,016,576 | 16.10 s | +| PQ matching 4-bit | 673,297 | 87,645,603 | 1,539,002,008 | 36.35 s | + +Pair counts barely change with bit budget in this control. It supports the expected increase in cost per score, rather than a multiplicative increase in pair count at this scale. It does not identify the cause of the historical 1M outlier. + +One initial diagnostic completed its build but failed in post-build reporting because an empty throughput key still enabled the benchmark. The configuration was corrected by omitting that key and the four diagnostics were rerun successfully. The failed attempt remains archived separately and is excluded from the table above. + +### Remaining limitation + +The first 1M outlier has not yet been causally explained. A missing byte-write fix and a fixed dimensionality fallback are not supported by the evidence. Runtime compilation and execution-state effects remain hypotheses; the original runs lacked the relevant GC/JIT/system traces. The additional controls completed without reproducing the large slowdown. Their full-JVM GC pauses totaled 0.676 seconds for ASH and 1.651 seconds for PQ; available memory stayed above 321/318 GiB, respectively, with zero sampled memory-pressure PSI. Compilation logs recorded class-loading/retry events, but those events alone establish neither a persistent kernel compilation failure nor the cause of the first outlier. No speculative production fix was applied. Do not use the outlying Cohere mean as a precise capacity-planning estimate. + + +## Timing validation + +The predeclared rule retimed any case/JVM with a sample CV above 10%, using both overquery settings and at least 10 seconds per sample (three samples). Four cached-index JVMs were retimed. All recall and visited-count values matched the originals exactly. No reconstruction was performed. These longer observations supply QPS in the reported tables; original observations remain in results.csv and in the original columns of reported-results.csv. + +| Case | Overquery | Original QPS | Longer QPS | Original CV | Longer CV | +|---|---:|---:|---:|---:|---:| +| ada002-1M-b4-pq-r2 | 1× | 1,821 | 2,214 | 30.7% | 2.7% | +| ada002-1M-b4-pq-r2 | 2× | 1,588 | 1,697 | 14.3% | 0.6% | +| cap-1M-b2-ash-r2 | 1× | 2,258 | 2,672 | 23.8% | 6.6% | +| cap-1M-b2-ash-r2 | 2× | 2,053 | 2,181 | 2.7% | 1.4% | +| cap-1M-b4-pq-r2 | 1× | 1,997 | 2,463 | 18.8% | 5.7% | +| cap-1M-b4-pq-r2 | 2× | 1,628 | 1,937 | 15.1% | 0.9% | +| cohere-english-v3-1M-b4-ash-r1 | 1× | 2,915 | 3,660 | 18.9% | 3.3% | +| cohere-english-v3-1M-b4-ash-r1 | 2× | 2,319 | 2,653 | 2.8% | 0.8% | + + +# Final graph results + +Means of two independent builds per configuration. ASH/PQ values are shown in that order. + +## Single-threaded queries + +| Dataset | ASH bits / PQ matching budget | Overquery | Recall@10 (%): ASH / PQ | QPS: ASH / PQ | ASH/PQ QPS | Recall difference (pp) | +|---|---:|---:|---:|---:|---:|---:| +| ada002-1M | 2 | 1× | 82.724 / 61.028 | 2,087 / 2,627 | 0.79× | +21.696 | +| ada002-1M | 2 | 2× | 95.969 / 81.254 | 1,938 / 2,143 | 0.90× | +14.715 | +| ada002-1M | 4 | 1× | 88.771 / 78.085 | 1,817 / 2,255 | 0.81× | +10.686 | +| ada002-1M | 4 | 2× | 96.114 / 94.155 | 1,693 / 1,771 | 0.96× | +1.959 | +| cap-1M | 2 | 1× | 79.109 / 57.654 | 2,557 / 2,561 | 1.00× | +21.456 | +| cap-1M | 2 | 2× | 94.369 / 77.959 | 2,075 / 2,111 | 0.98× | +16.410 | +| cap-1M | 4 | 1× | 86.525 / 77.097 | 2,059 / 2,237 | 0.92× | +9.427 | +| cap-1M | 4 | 2× | 95.438 / 93.395 | 1,734 / 1,789 | 0.97× | +2.043 | +| cohere-english-v3-1M | 2 | 1× | 70.487 / 66.674 | 3,872 / 3,657 | 1.06× | +3.813 | +| cohere-english-v3-1M | 2 | 2× | 88.550 / 85.674 | 3,356 / 2,880 | 1.17× | +2.876 | +| cohere-english-v3-1M | 4 | 1× | 79.983 / 73.727 | 3,461 / 3,055 | 1.13× | +6.255 | +| cohere-english-v3-1M | 4 | 2× | 91.210 / 89.178 | 2,612 / 2,323 | 1.12× | +2.031 | + +## Construction + +Graph column includes scorer-provider setup. Phase subtotal also includes compressor training, base encoding and NVQ compressor setup; it excludes dataset loading, JVM startup and uninstrumented writer initialization. + +| Dataset | Bits/budget | Graph + provider seconds: ASH / PQ | Training seconds: ASH / PQ | Encoding seconds: ASH / PQ | Measured phase subtotal seconds: ASH / PQ | +|---|---:|---:|---:|---:|---:| +| ada002-1M | 2 | 156.20 / 309.93 | 9.43 / 6.69 | 7.65 / 4.73 | 173.79 / 321.90 | +| ada002-1M | 4 | 230.94 / 489.46 | 8.21 / 6.97 | 6.14 / 4.86 | 245.86 / 501.90 | +| cap-1M | 2 | 152.19 / 276.91 | 9.18 / 16.51 | 7.11 / 4.54 | 168.98 / 298.45 | +| cap-1M | 4 | 224.20 / 406.63 | 8.43 / 7.55 | 7.37 / 4.72 | 240.52 / 419.45 | +| cohere-english-v3-1M | 2 | 145.53 / 206.35 | 5.33 / 5.44 | 1.80 / 3.86 | 153.08 / 216.02 | +| cohere-english-v3-1M | 4 | 410.35 / 772.52 | 4.54 / 5.85 | 2.23 / 3.28 | 417.54 / 782.05 | + +## Same cached indexes, 48 query workers + +| Dataset | Bits/budget | Overquery | Concurrent QPS: ASH / PQ | Speedup over one worker: ASH / PQ | +|---|---:|---:|---:|---:| +| ada002-1M | 2 | 1× | 95,507 / 96,984 | 45.76× / 36.92× | +| ada002-1M | 2 | 2× | 74,420 / 81,508 | 38.40× / 38.03× | +| ada002-1M | 4 | 1× | 77,725 / 91,585 | 42.79× / 40.62× | +| ada002-1M | 4 | 2× | 58,574 / 69,742 | 34.60× / 39.39× | +| cap-1M | 2 | 1× | 95,767 / 99,468 | 37.45× / 38.84× | +| cap-1M | 2 | 2× | 78,606 / 85,994 | 37.88× / 40.74× | +| cap-1M | 4 | 1× | 82,883 / 91,129 | 40.26× / 40.74× | +| cap-1M | 4 | 2× | 62,894 / 71,334 | 36.28× / 39.86× | +| cohere-english-v3-1M | 2 | 1× | 155,768 / 138,016 | 40.23× / 37.74× | +| cohere-english-v3-1M | 2 | 2× | 112,761 / 109,256 | 33.60× / 37.94× | +| cohere-english-v3-1M | 4 | 1× | 129,480 / 126,769 | 37.41× / 41.49× | +| cohere-english-v3-1M | 4 | 2× | 94,590 / 95,306 | 36.21× / 41.03× | + +## Between-build ranges + +Ranges of two observations, not confidence intervals. + +| Dataset | Bits/budget | Method | Overquery | Single-thread QPS range | Recall@10 (%) range | Graph + setup seconds range | +|---|---:|---|---:|---:|---:|---:| +| ada002-1M | 2 | ASH | 1× | 1,996–2,178 | 82.642–82.806 | 151.60–160.79 | +| ada002-1M | 2 | ASH | 2× | 1,803–2,073 | 95.953–95.985 | 151.60–160.79 | +| ada002-1M | 2 | PQ | 1× | 2,506–2,749 | 60.969–61.087 | 305.36–314.50 | +| ada002-1M | 2 | PQ | 2× | 2,071–2,215 | 81.207–81.301 | 305.36–314.50 | +| ada002-1M | 4 | ASH | 1× | 1,748–1,885 | 88.768–88.774 | 230.19–231.68 | +| ada002-1M | 4 | ASH | 2× | 1,691–1,695 | 96.112–96.117 | 230.19–231.68 | +| ada002-1M | 4 | PQ | 1× | 2,214–2,296 | 78.046–78.124 | 471.03–507.89 | +| ada002-1M | 4 | PQ | 2× | 1,697–1,844 | 94.098–94.212 | 471.03–507.89 | +| cap-1M | 2 | ASH | 1× | 2,442–2,672 | 79.039–79.180 | 147.18–157.20 | +| cap-1M | 2 | ASH | 2× | 1,969–2,181 | 94.304–94.435 | 147.18–157.20 | +| cap-1M | 2 | PQ | 1× | 2,539–2,583 | 57.475–57.832 | 235.00–318.82 | +| cap-1M | 2 | PQ | 2× | 2,102–2,119 | 77.755–78.163 | 235.00–318.82 | +| cap-1M | 4 | ASH | 1× | 1,969–2,149 | 86.483–86.566 | 221.44–226.96 | +| cap-1M | 4 | ASH | 2× | 1,710–1,757 | 95.431–95.445 | 221.44–226.96 | +| cap-1M | 4 | PQ | 1× | 2,011–2,463 | 77.078–77.117 | 384.54–428.72 | +| cap-1M | 4 | PQ | 2× | 1,642–1,937 | 93.387–93.402 | 384.54–428.72 | +| cohere-english-v3-1M | 2 | ASH | 1× | 3,712–4,032 | 70.446–70.528 | 133.48–157.58 | +| cohere-english-v3-1M | 2 | ASH | 2× | 2,991–3,721 | 88.522–88.578 | 133.48–157.58 | +| cohere-english-v3-1M | 2 | PQ | 1× | 3,555–3,760 | 66.659–66.689 | 202.06–210.65 | +| cohere-english-v3-1M | 2 | PQ | 2× | 2,755–3,005 | 85.658–85.690 | 202.06–210.65 | +| cohere-english-v3-1M | 4 | ASH | 1× | 3,263–3,660 | 79.959–80.006 | 160.09–660.61 | +| cohere-english-v3-1M | 4 | ASH | 2× | 2,572–2,653 | 91.205–91.214 | 160.09–660.61 | +| cohere-english-v3-1M | 4 | PQ | 1× | 3,054–3,057 | 73.716–73.738 | 320.37–1224.67 | +| cohere-english-v3-1M | 4 | PQ | 2× | 2,296–2,349 | 89.174–89.183 | 320.37–1224.67 | + + +# Figure 9 graph comparison — protocol + +Authoritative host: `ted_willke@34.169.37.132`, GCP c4-standard-96-lssd. ASH source: `/home/ted_willke/jvector-ash`, `ash-symmetric-scorer` commit `922b23e3`, plus benchmark-only explicit concurrency controls, subsequently committed as `638edc37`. PQ: verified GitHub origin/main `1a86718baa2d8b72a0d134711a682875888b9646`, isolated detached worktree. Identical ThroughputBenchmark source on both. No duplicate-ID changes. + +## Matrix + +Three datasets: Ada002-1M (982,790 base vectors) and CAP-1M (1,000,000), both D=1536; Cohere English v3-1M (1,000,000), D=1024. Each has 10,000 queries. User confirmed d=D/2 to match Figure 9: respectively 768, 768, 512. ASH b=2 and b=4. Graph ASH requires C=1. Production training remains 20D, max25 iterations with early stopping; paper IVF uses C=32 and 10D. The study does not change production training defaults. + +PQ uses production JVector K=256, uncentered, anisotropy disabled. The actual fused query path is `FusedPQDecoder.DotProductDecoder`, with float partial-sum tables and SIMD `VectorUtil.assembleAndSum`; it is not Faiss 4-bit FastScan. Match exact stored quantized-vector bytes including ASH's 5-byte header: M=197/389 for Ada002/CAP and M=133/261 for Cohere, with uneven PQ subvector dimensions supported. The nominal 32X/16X paper labels refer to payload only; actual graph code compression is slightly lower once the ASH header is counted. Faiss IVF in the paper uses packed 4-bit PQ; these are different production PQ implementations. + +Graph settings: M=64, efConstruction=200, overflow1.2, hierarchy/refinement enabled, fused neighborhoods, NVQ reranking, k=10 and rerankK=10/20 (overquery1/2), search pruning disabled. ASH retains production adaptive termination gamma0.01. PQ uses latest-main behavior, which has no ASH adaptive feature. ASH writes version7, latest-main PQ version6. This is a production-branch comparison, not an isolated quantizer ablation. + +## Timing and retention + +Two independent fresh compressor/graph builds per configuration (24 total), reversed ASH/PQ order on repetition2. No shared compressor cache across fresh repetitions. Each index is retained and reopened for a 48-query-worker concurrency pass. Explicit serial/concurrent checks compare complete ranked node IDs, scores and visited count for all test queries at each overquery setting. Recall and visited measurements are outside the throughput timer. + +Single-threaded QPS is the main result. Three timed samples, each at least3seconds and a complete query batch; raw durations and query counts logged. Existing synthetic warmup remains enabled. Query concurrency uses a dedicated 48-worker pool. Grid submits insertion to PhysicalCoreExecutor with 48 workers; GraphIndexBuilder cleanup uses its default common ForkJoinPool (normally parallelism 95 on this 96-logical-CPU JVM). Both branches use this arrangement. Workers are not explicitly pinned, unlike the paper’s 48 pinned build threads. Asymmetric projection/setup remains inside each query call for ASH. The graph uses corrected symmetric node comparisons and asymmetric raw-vector insertion/query scoring. + +JDK23, vector module, native access enabled, Xmx128g, ASH SIMD single/block kernels, production accumulator/unroll settings. Explicit `jvector-twenty` classpath supplies MemorySegmentReader; reader fallback warnings are checked. Temporary files and retained indexes are on RAID10 SSDs. These1M indexes fit RAM; warmed memory-mapped query throughput is not a larger-than-memory I/O result. + +Retain graph build/write time separately from training, base encoding and provider setup. Add NVQ compressor setup to these measured phases; their sum is a measured phase subtotal, excluding uninstrumented writer initialization overhead. Process elapsed time additionally includes dataset loading, JVM startup, warmup and queries and is not called construction time. + +The paper uses the same GCP c4-standard-96-lssd/Intel Xeon 6985P-C host class, 48 build cores and single-thread queries. Its IVF implementation is C++/AVX-512 with 4096 lists and an nprobe sweep; this graph experiment uses Java/JDK23 and two overquery settings. + +No IVF reruns. Published Figure9 panels are extracted from the supplied final PDF and shown separately from measured graph points. Any comparison across index types must retain the landmark/training/PQ implementation and reranking differences above. + +Remote evidence and reusable caches: `/mnt/raid10/jvector-bench/ted_willke/figure9-graph/`. Per-case directories contain YAMLs, `index_cache`, ASH `compressor_cache` or latest-main PQ `pq_cache`, logs and completion manifests. Frozen JARs and hashes are stored at the study root. + +## Interpretation + +The runtime profile confirms that graph construction uses `ImmutablePQVectors.diversityFunctionFor(DOT_PRODUCT)`, which caches centroid-pair partial sums and calls `VectorUtil.assembleAndSumPQ`. The base `PQVectors` implementation is not the active diversity scorer in these runs. Corrected ASH construction compares packed codes. Query scoring remains asymmetric in both configurations. + +Figure9 compares published IVF search curves. It does not report complete graph or IVF index-build times. Its ASH/PQ query paths do not use this graph's NVQ reranking configuration, so higher graph terminal recall must not be attributed solely to the quantizer. Graph construction is compared directly between the two measured graph implementations. + +The exact same graph is used for each serial/concurrent comparison; independent graph repetitions are used only for build/run variability. Two repetitions provide ranges, not narrow statistical confidence intervals. + +## Reusable artifacts + +[Reported observations](reported-results.csv) explicitly identify longer cached-query retimings and preserve original QPS/CV columns. [Original observations](results.csv) include each build repetition, both query concurrencies, timing variation, recall, visited counts and phase times. Every retained index has its own configuration and compressor cache. The remote query-only helper is `/mnt/raid10/jvector-bench/ted_willke/figure9-graph/retest.py`. For example: + +```sh +python3 /mnt/raid10/jvector-bench/ted_willke/figure9-graph/retest.py ada002-1M-b2-ash-r1 --threads 1 --overquery 1.0,2.0 +``` + +All 48 serial/concurrent operating-point checks compare complete ranked results and visited counts for every supplied query at each index/overquery setting. Separate reported recall and visited metrics agree exactly between serial and concurrent passes. The concurrency harness is committed as `638edc37`. diff --git a/docs/figure9-graph-study/reported-results.csv b/docs/figure9-graph-study/reported-results.csv new file mode 100644 index 000000000..18a5d5d1b --- /dev/null +++ b/docs/figure9-graph-study/reported-results.csv @@ -0,0 +1,97 @@ +name,dataset,D,d,bits,mode,rep,code_bytes,threads,overquery,build_s,qps,recall,visited,qps_cv,train_s,encode_s,nvq_s,provider_s,log,throughput_source,original_qps,original_qps_cv +ada002-1M-b2-ash-r1,ada002-1M,1536,768,2,ASH,1,197,1,1.0,157.840009863,2177.8102536704814,0.82642,743.6996,2.089696662700664,9.230167,7.706519616,0.546488417,2.95154,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/ada002-1M-b2-ash-r1/query-1.log,primary-3s,2177.8102536704814,2.089696662700664 +ada002-1M-b2-ash-r1,ada002-1M,1536,768,2,ASH,1,197,1,2.0,157.840009863,2072.775696510429,0.95953,1099.5859,0.6538369048721234,9.230167,7.706519616,0.546488417,2.95154,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/ada002-1M-b2-ash-r1/query-1.log,primary-3s,2072.775696510429,0.6538369048721234 +ada002-1M-b2-ash-r1,ada002-1M,1536,768,2,ASH,1,197,48,1.0,,95956.71345138094,0.82642,743.6996,1.504784845208021,,,0.0,0,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/ada002-1M-b2-ash-r1/query-48.log,primary-3s,95956.71345138094,1.504784845208021 +ada002-1M-b2-ash-r1,ada002-1M,1536,768,2,ASH,1,197,48,2.0,,74038.29496332126,0.95953,1099.5859,0.2560122257189981,,,0.0,0,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/ada002-1M-b2-ash-r1/query-48.log,primary-3s,74038.29496332126,0.2560122257189981 +ada002-1M-b2-pq-r1,ada002-1M,1536,768,2,PQ,1,197,1,1.0,305.357773341,2505.50863765249,0.60969,777.0072,3.3329151504713037,6.223289688,4.361790065,0.51526343,0,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/ada002-1M-b2-pq-r1/query-1.log,primary-3s,2505.50863765249,3.3329151504713037 +ada002-1M-b2-pq-r1,ada002-1M,1536,768,2,PQ,1,197,1,2.0,305.357773341,2070.9961907891375,0.81301,1088.5782,0.6403227236256307,6.223289688,4.361790065,0.51526343,0,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/ada002-1M-b2-pq-r1/query-1.log,primary-3s,2070.9961907891375,0.6403227236256307 +ada002-1M-b2-pq-r1,ada002-1M,1536,768,2,PQ,1,197,48,1.0,,98002.36666217295,0.60969,777.0072,0.4664038965243055,,,0.0,0,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/ada002-1M-b2-pq-r1/query-48.log,primary-3s,98002.36666217295,0.4664038965243055 +ada002-1M-b2-pq-r1,ada002-1M,1536,768,2,PQ,1,197,48,2.0,,83275.93656993553,0.81301,1088.5782,2.4477334633540697,,,0.0,0,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/ada002-1M-b2-pq-r1/query-48.log,primary-3s,83275.93656993553,2.4477334633540697 +ada002-1M-b4-ash-r1,ada002-1M,1536,768,4,ASH,1,389,1,1.0,227.142034764,1885.1372911497197,0.88774,712.952,8.466917289018335,8.071419789,5.700532906,0.497370085,3.051166,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/ada002-1M-b4-ash-r1/query-1.log,primary-3s,1885.1372911497197,8.466917289018335 +ada002-1M-b4-ash-r1,ada002-1M,1536,768,4,ASH,1,389,1,2.0,227.142034764,1694.774941404327,0.96112,1036.2085,1.440621537569398,8.071419789,5.700532906,0.497370085,3.051166,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/ada002-1M-b4-ash-r1/query-1.log,primary-3s,1694.774941404327,1.440621537569398 +ada002-1M-b4-ash-r1,ada002-1M,1536,768,4,ASH,1,389,48,1.0,,78535.16386753105,0.88774,712.952,1.0498522055276105,,,0.0,0,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/ada002-1M-b4-ash-r1/query-48.log,primary-3s,78535.16386753105,1.0498522055276105 +ada002-1M-b4-ash-r1,ada002-1M,1536,768,4,ASH,1,389,48,2.0,,58078.37660324255,0.96112,1036.2085,1.0265098943456157,,,0.0,0,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/ada002-1M-b4-ash-r1/query-48.log,primary-3s,58078.37660324255,1.0265098943456157 +ada002-1M-b4-pq-r1,ada002-1M,1536,768,4,PQ,1,389,1,1.0,471.033761676,2296.391366518768,0.78046,743.3911,3.454129477444065,6.66483986,4.78256411,0.619976129,0,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/ada002-1M-b4-pq-r1/query-1.log,primary-3s,2296.391366518768,3.454129477444065 +ada002-1M-b4-pq-r1,ada002-1M,1536,768,4,PQ,1,389,1,2.0,471.033761676,1844.3575579359417,0.94212,1075.2136,0.5016351987560125,6.66483986,4.78256411,0.619976129,0,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/ada002-1M-b4-pq-r1/query-1.log,primary-3s,1844.3575579359417,0.5016351987560125 +ada002-1M-b4-pq-r1,ada002-1M,1536,768,4,PQ,1,389,48,1.0,,88588.06947666063,0.78046,743.3911,1.2252863478487552,,,0.0,0,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/ada002-1M-b4-pq-r1/query-48.log,primary-3s,88588.06947666063,1.2252863478487552 +ada002-1M-b4-pq-r1,ada002-1M,1536,768,4,PQ,1,389,48,2.0,,69034.79529813363,0.94212,1075.2136,0.3523116662786264,,,0.0,0,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/ada002-1M-b4-pq-r1/query-48.log,primary-3s,69034.79529813363,0.3523116662786264 +cap-1M-b2-ash-r1,cap-1M,1536,768,2,ASH,1,197,1,1.0,144.164132004,2442.48519676128,0.7918,752.783,6.871960701628724,9.336766347,7.555819332,0.49760735,3.01653,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/cap-1M-b2-ash-r1/query-1.log,primary-3s,2442.48519676128,6.871960701628724 +cap-1M-b2-ash-r1,cap-1M,1536,768,2,ASH,1,197,1,2.0,144.164132004,1969.1891971271289,0.94435,1099.8606,1.0289141912055246,9.336766347,7.555819332,0.49760735,3.01653,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/cap-1M-b2-ash-r1/query-1.log,primary-3s,1969.1891971271289,1.0289141912055246 +cap-1M-b2-ash-r1,cap-1M,1536,768,2,ASH,1,197,48,1.0,,95279.65361270042,0.7918,752.783,0.9853988466271536,,,0.0,0,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/cap-1M-b2-ash-r1/query-48.log,primary-3s,95279.65361270042,0.9853988466271536 +cap-1M-b2-ash-r1,cap-1M,1536,768,2,ASH,1,197,48,2.0,,78168.15369319917,0.94435,1099.8606,0.552073063676741,,,0.0,0,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/cap-1M-b2-ash-r1/query-48.log,primary-3s,78168.15369319917,0.552073063676741 +cap-1M-b2-pq-r1,cap-1M,1536,768,2,PQ,1,197,1,1.0,234.99586654,2582.7721961649463,0.57832,725.6598,3.9486382814193726,17.339142317,4.496241169,0.495377823,0,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/cap-1M-b2-pq-r1/query-1.log,primary-3s,2582.7721961649463,3.9486382814193726 +cap-1M-b2-pq-r1,cap-1M,1536,768,2,PQ,1,197,1,2.0,234.99586654,2118.999904259021,0.78163,1010.7918,1.8779604116325714,17.339142317,4.496241169,0.495377823,0,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/cap-1M-b2-pq-r1/query-1.log,primary-3s,2118.999904259021,1.8779604116325714 +cap-1M-b2-pq-r1,cap-1M,1536,768,2,PQ,1,197,48,1.0,,102157.77848107649,0.57832,725.6598,0.8520534403998001,,,0.0,0,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/cap-1M-b2-pq-r1/query-48.log,primary-3s,102157.77848107649,0.8520534403998001 +cap-1M-b2-pq-r1,cap-1M,1536,768,2,PQ,1,197,48,2.0,,87196.68753139579,0.78163,1010.7918,0.6228168823421181,,,0.0,0,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/cap-1M-b2-pq-r1/query-48.log,primary-3s,87196.68753139579,0.6228168823421181 +cap-1M-b4-ash-r1,cap-1M,1536,768,4,ASH,1,389,1,1.0,223.669037493,1968.8190491505034,0.86483,710.3348,6.406771306054616,7.937175314,6.870664075,0.512322427,3.287562,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/cap-1M-b4-ash-r1/query-1.log,primary-3s,1968.8190491505034,6.406771306054616 +cap-1M-b4-ash-r1,cap-1M,1536,768,4,ASH,1,389,1,2.0,223.669037493,1757.2733497910124,0.95431,1021.7334,2.695601637716467,7.937175314,6.870664075,0.512322427,3.287562,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/cap-1M-b4-ash-r1/query-1.log,primary-3s,1757.2733497910124,2.695601637716467 +cap-1M-b4-ash-r1,cap-1M,1536,768,4,ASH,1,389,48,1.0,,83224.75726728224,0.86483,710.3348,1.42150330749097,,,0.0,0,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/cap-1M-b4-ash-r1/query-48.log,primary-3s,83224.75726728224,1.42150330749097 +cap-1M-b4-ash-r1,cap-1M,1536,768,4,ASH,1,389,48,2.0,,63126.66771149495,0.95431,1021.7334,0.6423858748901395,,,0.0,0,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/cap-1M-b4-ash-r1/query-48.log,primary-3s,63126.66771149495,0.6423858748901395 +cap-1M-b4-pq-r1,cap-1M,1536,768,4,PQ,1,389,1,1.0,428.719613174,2010.5628717956458,0.77078,724.1648,5.712025504481833,7.322717334,4.716799943,0.509658225,0,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/cap-1M-b4-pq-r1/query-1.log,primary-3s,2010.5628717956458,5.712025504481833 +cap-1M-b4-pq-r1,cap-1M,1536,768,4,PQ,1,389,1,2.0,428.719613174,1642.1041956039842,0.93387,1033.019,1.6530978389723041,7.322717334,4.716799943,0.509658225,0,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/cap-1M-b4-pq-r1/query-1.log,primary-3s,1642.1041956039842,1.6530978389723041 +cap-1M-b4-pq-r1,cap-1M,1536,768,4,PQ,1,389,48,1.0,,88276.39085252174,0.77078,724.1648,1.814849190790097,,,0.0,0,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/cap-1M-b4-pq-r1/query-48.log,primary-3s,88276.39085252174,1.814849190790097 +cap-1M-b4-pq-r1,cap-1M,1536,768,4,PQ,1,389,48,2.0,,72320.63990408325,0.93387,1033.019,0.5961574242426309,,,0.0,0,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/cap-1M-b4-pq-r1/query-48.log,primary-3s,72320.63990408325,0.5961574242426309 +cohere-english-v3-1M-b2-ash-r1,cohere-english-v3-1M,1024,512,2,ASH,1,133,1,1.0,131.540012311,4032.4153832742772,0.70528,884.6191,3.560891364246673,5.381116293,1.874303968,0.459574838,1.941501,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/cohere-english-v3-1M-b2-ash-r1/query-1.log,primary-3s,4032.4153832742772,3.560891364246673 +cohere-english-v3-1M-b2-ash-r1,cohere-english-v3-1M,1024,512,2,ASH,1,133,1,2.0,131.540012311,2990.537197603152,0.88578,1323.1688,5.124065470263526,5.381116293,1.874303968,0.459574838,1.941501,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/cohere-english-v3-1M-b2-ash-r1/query-1.log,primary-3s,2990.537197603152,5.124065470263526 +cohere-english-v3-1M-b2-ash-r1,cohere-english-v3-1M,1024,512,2,ASH,1,133,48,1.0,,154673.1592239879,0.70528,884.6191,4.393186725468967,,,0.0,0,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/cohere-english-v3-1M-b2-ash-r1/query-48.log,primary-3s,154673.1592239879,4.393186725468967 +cohere-english-v3-1M-b2-ash-r1,cohere-english-v3-1M,1024,512,2,ASH,1,133,48,2.0,,114552.33144904207,0.88578,1323.1688,0.6570685869485251,,,0.0,0,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/cohere-english-v3-1M-b2-ash-r1/query-48.log,primary-3s,114552.33144904207,0.6570685869485251 +cohere-english-v3-1M-b2-pq-r1,cohere-english-v3-1M,1024,512,2,PQ,1,133,1,1.0,202.056488632,3760.082855364721,0.66689,824.2477,2.4226242494143873,5.495381372,3.826856416,0.373458183,0,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/cohere-english-v3-1M-b2-pq-r1/query-1.log,primary-3s,3760.082855364721,2.4226242494143873 +cohere-english-v3-1M-b2-pq-r1,cohere-english-v3-1M,1024,512,2,PQ,1,133,1,2.0,202.056488632,3004.8417879055482,0.8569,1200.9145,1.4786229125049466,5.495381372,3.826856416,0.373458183,0,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/cohere-english-v3-1M-b2-pq-r1/query-1.log,primary-3s,3004.8417879055482,1.4786229125049466 +cohere-english-v3-1M-b2-pq-r1,cohere-english-v3-1M,1024,512,2,PQ,1,133,48,1.0,,137651.87500685465,0.66689,824.2477,1.0614539879575466,,,0.0,0,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/cohere-english-v3-1M-b2-pq-r1/query-48.log,primary-3s,137651.87500685465,1.0614539879575466 +cohere-english-v3-1M-b2-pq-r1,cohere-english-v3-1M,1024,512,2,PQ,1,133,48,2.0,,110493.6991190779,0.8569,1200.9145,1.8720195013611096,,,0.0,0,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/cohere-english-v3-1M-b2-pq-r1/query-48.log,primary-3s,110493.6991190779,1.8720195013611096 +cohere-english-v3-1M-b4-ash-r1,cohere-english-v3-1M,1024,512,4,ASH,1,261,1,1.0,658.453734072,3659.88651139761,0.79959,829.839,3.3352298962798885,4.541078397,2.222092598,0.357154296,2.160219,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/cohere-english-v3-1M-b4-ash-r1/query-1.log,cached-10s: /mnt/raid10/jvector-bench/ted_willke/figure9-graph/cohere-english-v3-1M-b4-ash-r1/quality-1.log,2914.921321324916,18.87768723717277 +cohere-english-v3-1M-b4-ash-r1,cohere-english-v3-1M,1024,512,4,ASH,1,261,1,2.0,658.453734072,2652.860877518716,0.91214,1232.4703,0.8181655322064287,4.541078397,2.222092598,0.357154296,2.160219,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/cohere-english-v3-1M-b4-ash-r1/query-1.log,cached-10s: /mnt/raid10/jvector-bench/ted_willke/figure9-graph/cohere-english-v3-1M-b4-ash-r1/quality-1.log,2318.7834081655997,2.8346436633059775 +cohere-english-v3-1M-b4-ash-r1,cohere-english-v3-1M,1024,512,4,ASH,1,261,48,1.0,,130101.16171643394,0.79959,829.839,2.5756810774540098,,,0.0,0,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/cohere-english-v3-1M-b4-ash-r1/query-48.log,primary-3s,130101.16171643394,2.5756810774540098 +cohere-english-v3-1M-b4-ash-r1,cohere-english-v3-1M,1024,512,4,ASH,1,261,48,2.0,,94805.88464088204,0.91214,1232.4703,0.0724673350571949,,,0.0,0,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/cohere-english-v3-1M-b4-ash-r1/query-48.log,primary-3s,94805.88464088204,0.0724673350571949 +cohere-english-v3-1M-b4-pq-r1,cohere-english-v3-1M,1024,512,4,PQ,1,261,1,1.0,1224.670977507,3056.927847735833,0.73716,792.6989,5.354685894084355,5.670114916,3.275320129,0.362849512,0,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/cohere-english-v3-1M-b4-pq-r1/query-1.log,primary-3s,3056.927847735833,5.354685894084355 +cohere-english-v3-1M-b4-pq-r1,cohere-english-v3-1M,1024,512,4,PQ,1,261,1,2.0,1224.670977507,2296.056472237398,0.89174,1155.4997,5.725529209257075,5.670114916,3.275320129,0.362849512,0,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/cohere-english-v3-1M-b4-pq-r1/query-1.log,primary-3s,2296.056472237398,5.725529209257075 +cohere-english-v3-1M-b4-pq-r1,cohere-english-v3-1M,1024,512,4,PQ,1,261,48,1.0,,125649.75380191854,0.73716,792.6989,2.0450484393226622,,,0.0,0,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/cohere-english-v3-1M-b4-pq-r1/query-48.log,primary-3s,125649.75380191854,2.0450484393226622 +cohere-english-v3-1M-b4-pq-r1,cohere-english-v3-1M,1024,512,4,PQ,1,261,48,2.0,,95476.75849465677,0.89174,1155.4997,0.24166504216312962,,,0.0,0,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/cohere-english-v3-1M-b4-pq-r1/query-48.log,primary-3s,95476.75849465677,0.24166504216312962 +ada002-1M-b2-pq-r2,ada002-1M,1536,768,2,PQ,2,197,1,1.0,314.49511595,2748.769882268208,0.61087,761.9973,3.034200291066349,7.164817247,5.105142127,0.58527832,0,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/ada002-1M-b2-pq-r2/query-1.log,primary-3s,2748.769882268208,3.034200291066349 +ada002-1M-b2-pq-r2,ada002-1M,1536,768,2,PQ,2,197,1,2.0,314.49511595,2215.193301397225,0.81207,1073.2808,1.0813312289988994,7.164817247,5.105142127,0.58527832,0,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/ada002-1M-b2-pq-r2/query-1.log,primary-3s,2215.193301397225,1.0813312289988994 +ada002-1M-b2-pq-r2,ada002-1M,1536,768,2,PQ,2,197,48,1.0,,95964.90321354134,0.61087,761.9973,2.651360702180009,,,0.0,0,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/ada002-1M-b2-pq-r2/query-48.log,primary-3s,95964.90321354134,2.651360702180009 +ada002-1M-b2-pq-r2,ada002-1M,1536,768,2,PQ,2,197,48,2.0,,79740.98409975477,0.81207,1073.2808,3.040241779525206,,,0.0,0,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/ada002-1M-b2-pq-r2/query-48.log,primary-3s,79740.98409975477,3.040241779525206 +ada002-1M-b2-ash-r2,ada002-1M,1536,768,2,ASH,2,197,1,1.0,148.462216427,1996.4088078542302,0.82806,743.1514,8.337650633903259,9.620892019,7.596049599,0.492858178,3.140455,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/ada002-1M-b2-ash-r2/query-1.log,primary-3s,1996.4088078542302,8.337650633903259 +ada002-1M-b2-ash-r2,ada002-1M,1536,768,2,ASH,2,197,1,2.0,148.462216427,1802.8315892472526,0.95985,1098.6193,1.3155639215124004,9.620892019,7.596049599,0.492858178,3.140455,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/ada002-1M-b2-ash-r2/query-1.log,primary-3s,1802.8315892472526,1.3155639215124004 +ada002-1M-b2-ash-r2,ada002-1M,1536,768,2,ASH,2,197,48,1.0,,95056.34990344389,0.82806,743.1514,1.7700623107441582,,,0.0,0,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/ada002-1M-b2-ash-r2/query-48.log,primary-3s,95056.34990344389,1.7700623107441582 +ada002-1M-b2-ash-r2,ada002-1M,1536,768,2,ASH,2,197,48,2.0,,74802.08869227431,0.95985,1098.6193,0.14584232924624438,,,0.0,0,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/ada002-1M-b2-ash-r2/query-48.log,primary-3s,74802.08869227431,0.14584232924624438 +ada002-1M-b4-pq-r2,ada002-1M,1536,768,4,PQ,2,389,1,1.0,507.890157909,2213.5294641529763,0.78124,741.0503,2.656232061739049,7.278564002,4.93304114,0.600501602,0,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/ada002-1M-b4-pq-r2/query-1.log,cached-10s: /mnt/raid10/jvector-bench/ted_willke/figure9-graph/ada002-1M-b4-pq-r2/quality-1.log,1820.6690398184126,30.736736316022316 +ada002-1M-b4-pq-r2,ada002-1M,1536,768,4,PQ,2,389,1,2.0,507.890157909,1697.0094228980922,0.94098,1071.2316,0.5827720462316611,7.278564002,4.93304114,0.600501602,0,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/ada002-1M-b4-pq-r2/query-1.log,cached-10s: /mnt/raid10/jvector-bench/ted_willke/figure9-graph/ada002-1M-b4-pq-r2/quality-1.log,1588.1010017830652,14.340092833165679 +ada002-1M-b4-pq-r2,ada002-1M,1536,768,4,PQ,2,389,48,1.0,,94582.40323816786,0.78124,741.0503,1.2108098886995722,,,0.0,0,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/ada002-1M-b4-pq-r2/query-48.log,primary-3s,94582.40323816786,1.2108098886995722 +ada002-1M-b4-pq-r2,ada002-1M,1536,768,4,PQ,2,389,48,2.0,,70449.31841046993,0.94098,1071.2316,0.701127293075968,,,0.0,0,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/ada002-1M-b4-pq-r2/query-48.log,primary-3s,70449.31841046993,0.701127293075968 +ada002-1M-b4-ash-r2,ada002-1M,1536,768,4,ASH,2,389,1,1.0,228.410139478,1747.928265482728,0.88768,705.7386,1.8283062091639757,8.356441471,6.579782222,0.647681467,3.269243,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/ada002-1M-b4-ash-r2/query-1.log,primary-3s,1747.928265482728,1.8283062091639757 +ada002-1M-b4-ash-r2,ada002-1M,1536,768,4,ASH,2,389,1,2.0,228.410139478,1690.787299577052,0.96117,1030.419,0.5222633104861074,8.356441471,6.579782222,0.647681467,3.269243,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/ada002-1M-b4-ash-r2/query-1.log,primary-3s,1690.787299577052,0.5222633104861074 +ada002-1M-b4-ash-r2,ada002-1M,1536,768,4,ASH,2,389,48,1.0,,76915.82888351154,0.88768,705.7386,2.018428409914763,,,0.0,0,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/ada002-1M-b4-ash-r2/query-48.log,primary-3s,76915.82888351154,2.018428409914763 +ada002-1M-b4-ash-r2,ada002-1M,1536,768,4,ASH,2,389,48,2.0,,59068.83275336893,0.96117,1030.419,0.42491432057404144,,,0.0,0,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/ada002-1M-b4-ash-r2/query-48.log,primary-3s,59068.83275336893,0.42491432057404144 +cap-1M-b2-pq-r2,cap-1M,1536,768,2,PQ,2,197,1,1.0,318.820383687,2538.509055353608,0.57475,737.8679,1.4309608739915773,15.680441056,4.578405703,0.490970401,0,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/cap-1M-b2-pq-r2/query-1.log,primary-3s,2538.509055353608,1.4309608739915773 +cap-1M-b2-pq-r2,cap-1M,1536,768,2,PQ,2,197,1,2.0,318.820383687,2102.1416711961683,0.77755,1024.8805,2.2009318201309274,15.680441056,4.578405703,0.490970401,0,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/cap-1M-b2-pq-r2/query-1.log,primary-3s,2102.1416711961683,2.2009318201309274 +cap-1M-b2-pq-r2,cap-1M,1536,768,2,PQ,2,197,48,1.0,,96777.26308815173,0.57475,737.8679,2.5873596884050936,,,0.0,0,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/cap-1M-b2-pq-r2/query-48.log,primary-3s,96777.26308815173,2.5873596884050936 +cap-1M-b2-pq-r2,cap-1M,1536,768,2,PQ,2,197,48,2.0,,84790.32448230755,0.77755,1024.8805,0.9254997826172315,,,0.0,0,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/cap-1M-b2-pq-r2/query-48.log,primary-3s,84790.32448230755,0.9254997826172315 +cap-1M-b2-ash-r2,cap-1M,1536,768,2,ASH,2,197,1,1.0,154.184889795,2671.8321513333003,0.79039,751.5542,6.581649160723063,9.027614868,6.660274635,0.508288027,3.013961,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/cap-1M-b2-ash-r2/query-1.log,cached-10s: /mnt/raid10/jvector-bench/ted_willke/figure9-graph/cap-1M-b2-ash-r2/quality-1.log,2258.191054953794,23.759659693908986 +cap-1M-b2-ash-r2,cap-1M,1536,768,2,ASH,2,197,1,2.0,154.184889795,2181.25379117744,0.94304,1096.9158,1.411287086771372,9.027614868,6.660274635,0.508288027,3.013961,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/cap-1M-b2-ash-r2/query-1.log,cached-10s: /mnt/raid10/jvector-bench/ted_willke/figure9-graph/cap-1M-b2-ash-r2/quality-1.log,2052.5449317362495,2.712071494538914 +cap-1M-b2-ash-r2,cap-1M,1536,768,2,ASH,2,197,48,1.0,,96253.66102754153,0.79039,751.5542,1.7058235542763742,,,0.0,0,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/cap-1M-b2-ash-r2/query-48.log,primary-3s,96253.66102754153,1.7058235542763742 +cap-1M-b2-ash-r2,cap-1M,1536,768,2,ASH,2,197,48,2.0,,79043.16466879538,0.94304,1096.9158,0.2782425279737823,,,0.0,0,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/cap-1M-b2-ash-r2/query-48.log,primary-3s,79043.16466879538,0.2782425279737823 +cap-1M-b4-pq-r2,cap-1M,1536,768,4,PQ,2,389,1,1.0,384.541696496,2463.1515004291546,0.77117,721.4095,5.7424423017788095,7.772478911,4.729168092,0.584808812,0,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/cap-1M-b4-pq-r2/query-1.log,cached-10s: /mnt/raid10/jvector-bench/ted_willke/figure9-graph/cap-1M-b4-pq-r2/quality-1.log,1997.0962184856235,18.76226207864229 +cap-1M-b4-pq-r2,cap-1M,1536,768,4,PQ,2,389,1,2.0,384.541696496,1936.7856067073103,0.93402,1029.0857,0.9009159641326212,7.772478911,4.729168092,0.584808812,0,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/cap-1M-b4-pq-r2/query-1.log,cached-10s: /mnt/raid10/jvector-bench/ted_willke/figure9-graph/cap-1M-b4-pq-r2/quality-1.log,1628.2176350293066,15.060018096648184 +cap-1M-b4-pq-r2,cap-1M,1536,768,4,PQ,2,389,48,1.0,,93982.41166067754,0.77117,721.4095,2.0385129815211256,,,0.0,0,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/cap-1M-b4-pq-r2/query-48.log,primary-3s,93982.41166067754,2.0385129815211256 +cap-1M-b4-pq-r2,cap-1M,1536,768,4,PQ,2,389,48,2.0,,70346.9921004846,0.93402,1029.0857,0.9724138237250509,,,0.0,0,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/cap-1M-b4-pq-r2/query-48.log,primary-3s,70346.9921004846,0.9724138237250509 +cap-1M-b4-ash-r2,cap-1M,1536,768,4,ASH,2,389,1,1.0,218.04224412,2149.0023927609095,0.86566,708.1348,8.837725956246652,8.929087608,7.874196863,0.512352717,3.399143,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/cap-1M-b4-ash-r2/query-1.log,primary-3s,2149.0023927609095,8.837725956246652 +cap-1M-b4-ash-r2,cap-1M,1536,768,4,ASH,2,389,1,2.0,218.04224412,1709.8616544967786,0.95445,1020.1297,2.1276746338547827,8.929087608,7.874196863,0.512352717,3.399143,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/cap-1M-b4-ash-r2/query-1.log,primary-3s,1709.8616544967786,2.1276746338547827 +cap-1M-b4-ash-r2,cap-1M,1536,768,4,ASH,2,389,48,1.0,,82541.79142378358,0.86566,708.1348,3.0493408284794836,,,0.0,0,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/cap-1M-b4-ash-r2/query-48.log,primary-3s,82541.79142378358,3.0493408284794836 +cap-1M-b4-ash-r2,cap-1M,1536,768,4,ASH,2,389,48,2.0,,62660.6088961071,0.95445,1020.1297,0.6444089703901766,,,0.0,0,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/cap-1M-b4-ash-r2/query-48.log,primary-3s,62660.6088961071,0.6444089703901766 +cohere-english-v3-1M-b2-pq-r2,cohere-english-v3-1M,1024,512,2,PQ,2,133,1,1.0,210.651240768,3554.791683888936,0.66659,824.1673,1.9970658008975664,5.380806686,3.888598589,0.374447596,0,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/cohere-english-v3-1M-b2-pq-r2/query-1.log,primary-3s,3554.791683888936,1.9970658008975664 +cohere-english-v3-1M-b2-pq-r2,cohere-english-v3-1M,1024,512,2,PQ,2,133,1,2.0,210.651240768,2755.1301538763946,0.85658,1199.3812,3.764086183772186,5.380806686,3.888598589,0.374447596,0,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/cohere-english-v3-1M-b2-pq-r2/query-1.log,primary-3s,2755.1301538763946,3.764086183772186 +cohere-english-v3-1M-b2-pq-r2,cohere-english-v3-1M,1024,512,2,PQ,2,133,48,1.0,,138379.5345558035,0.66659,824.1673,1.884179629700551,,,0.0,0,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/cohere-english-v3-1M-b2-pq-r2/query-48.log,primary-3s,138379.5345558035,1.884179629700551 +cohere-english-v3-1M-b2-pq-r2,cohere-english-v3-1M,1024,512,2,PQ,2,133,48,2.0,,108017.34345790854,0.85658,1199.3812,0.2428364162563542,,,0.0,0,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/cohere-english-v3-1M-b2-pq-r2/query-48.log,primary-3s,108017.34345790854,0.2428364162563542 +cohere-english-v3-1M-b2-ash-r2,cohere-english-v3-1M,1024,512,2,ASH,2,133,1,1.0,155.626287523,3711.8325622709335,0.70446,891.4132,3.439385278420078,5.27452495,1.718166483,0.399741568,1.952495,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/cohere-english-v3-1M-b2-ash-r2/query-1.log,primary-3s,3711.8325622709335,3.439385278420078 +cohere-english-v3-1M-b2-ash-r2,cohere-english-v3-1M,1024,512,2,ASH,2,133,1,2.0,155.626287523,3720.63426140302,0.88522,1327.2389,2.135700347945317,5.27452495,1.718166483,0.399741568,1.952495,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/cohere-english-v3-1M-b2-ash-r2/query-1.log,primary-3s,3720.63426140302,2.135700347945317 +cohere-english-v3-1M-b2-ash-r2,cohere-english-v3-1M,1024,512,2,ASH,2,133,48,1.0,,156862.83319312084,0.70446,891.4132,3.014477053063219,,,0.0,0,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/cohere-english-v3-1M-b2-ash-r2/query-48.log,primary-3s,156862.83319312084,3.014477053063219 +cohere-english-v3-1M-b2-ash-r2,cohere-english-v3-1M,1024,512,2,ASH,2,133,48,2.0,,110969.92932781803,0.88522,1327.2389,2.6712548800277496,,,0.0,0,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/cohere-english-v3-1M-b2-ash-r2/query-48.log,primary-3s,110969.92932781803,2.6712548800277496 +cohere-english-v3-1M-b4-pq-r2,cohere-english-v3-1M,1024,512,4,PQ,2,261,1,1.0,320.372937538,3053.848581254041,0.73738,789.9314,4.2108275645215425,6.033624081,3.279443062,0.426116483,0,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/cohere-english-v3-1M-b4-pq-r2/query-1.log,primary-3s,3053.848581254041,4.2108275645215425 +cohere-english-v3-1M-b4-pq-r2,cohere-english-v3-1M,1024,512,4,PQ,2,261,1,2.0,320.372937538,2349.17408087356,0.89183,1153.327,1.6478659973446461,6.033624081,3.279443062,0.426116483,0,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/cohere-english-v3-1M-b4-pq-r2/query-1.log,primary-3s,2349.17408087356,1.6478659973446461 +cohere-english-v3-1M-b4-pq-r2,cohere-english-v3-1M,1024,512,4,PQ,2,261,48,1.0,,127888.00395618335,0.73738,789.9314,0.6495444554179923,,,0.0,0,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/cohere-english-v3-1M-b4-pq-r2/query-48.log,primary-3s,127888.00395618335,0.6495444554179923 +cohere-english-v3-1M-b4-pq-r2,cohere-english-v3-1M,1024,512,4,PQ,2,261,48,2.0,,95135.73123335889,0.89183,1153.327,0.6440058638324528,,,0.0,0,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/cohere-english-v3-1M-b4-pq-r2/query-48.log,primary-3s,95135.73123335889,0.6440058638324528 +cohere-english-v3-1M-b4-ash-r2,cohere-english-v3-1M,1024,512,4,ASH,2,261,1,1.0,158.050770213,3262.9891204212204,0.80006,834.193,6.604247423409825,4.543623972,2.239317788,0.479592937,2.036372,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/cohere-english-v3-1M-b4-ash-r2/query-1.log,primary-3s,3262.9891204212204,6.604247423409825 +cohere-english-v3-1M-b4-ash-r2,cohere-english-v3-1M,1024,512,4,ASH,2,261,1,2.0,158.050770213,2571.738833639199,0.91205,1235.6143,2.7208643656202733,4.543623972,2.239317788,0.479592937,2.036372,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/cohere-english-v3-1M-b4-ash-r2/query-1.log,primary-3s,2571.738833639199,2.7208643656202733 +cohere-english-v3-1M-b4-ash-r2,cohere-english-v3-1M,1024,512,4,ASH,2,261,48,1.0,,128859.33340409711,0.80006,834.193,2.2828342408538718,,,0.0,0,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/cohere-english-v3-1M-b4-ash-r2/query-48.log,primary-3s,128859.33340409711,2.2828342408538718 +cohere-english-v3-1M-b4-ash-r2,cohere-english-v3-1M,1024,512,4,ASH,2,261,48,2.0,,94374.11028668427,0.91205,1235.6143,0.6084842422665558,,,0.0,0,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/cohere-english-v3-1M-b4-ash-r2/query-48.log,primary-3s,94374.11028668427,0.6084842422665558 diff --git a/docs/figure9-graph-study/results.csv b/docs/figure9-graph-study/results.csv new file mode 100644 index 000000000..d5e46bc13 --- /dev/null +++ b/docs/figure9-graph-study/results.csv @@ -0,0 +1,97 @@ +name,dataset,D,d,bits,mode,rep,code_bytes,threads,overquery,build_s,qps,recall,visited,qps_cv,train_s,encode_s,nvq_s,provider_s,log +ada002-1M-b2-ash-r1,ada002-1M,1536,768,2,ASH,1,197,1,1.0,157.840009863,2177.8102536704814,0.82642,743.6996,2.089696662700664,9.230167,7.706519616,0.546488417,2.95154,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/ada002-1M-b2-ash-r1/query-1.log +ada002-1M-b2-ash-r1,ada002-1M,1536,768,2,ASH,1,197,1,2.0,157.840009863,2072.775696510429,0.95953,1099.5859,0.6538369048721234,9.230167,7.706519616,0.546488417,2.95154,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/ada002-1M-b2-ash-r1/query-1.log +ada002-1M-b2-ash-r1,ada002-1M,1536,768,2,ASH,1,197,48,1.0,,95956.71345138094,0.82642,743.6996,1.504784845208021,,,0.0,0,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/ada002-1M-b2-ash-r1/query-48.log +ada002-1M-b2-ash-r1,ada002-1M,1536,768,2,ASH,1,197,48,2.0,,74038.29496332126,0.95953,1099.5859,0.2560122257189981,,,0.0,0,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/ada002-1M-b2-ash-r1/query-48.log +ada002-1M-b2-pq-r1,ada002-1M,1536,768,2,PQ,1,197,1,1.0,305.357773341,2505.50863765249,0.60969,777.0072,3.3329151504713037,6.223289688,4.361790065,0.51526343,0,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/ada002-1M-b2-pq-r1/query-1.log +ada002-1M-b2-pq-r1,ada002-1M,1536,768,2,PQ,1,197,1,2.0,305.357773341,2070.9961907891375,0.81301,1088.5782,0.6403227236256307,6.223289688,4.361790065,0.51526343,0,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/ada002-1M-b2-pq-r1/query-1.log +ada002-1M-b2-pq-r1,ada002-1M,1536,768,2,PQ,1,197,48,1.0,,98002.36666217295,0.60969,777.0072,0.4664038965243055,,,0.0,0,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/ada002-1M-b2-pq-r1/query-48.log +ada002-1M-b2-pq-r1,ada002-1M,1536,768,2,PQ,1,197,48,2.0,,83275.93656993553,0.81301,1088.5782,2.4477334633540697,,,0.0,0,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/ada002-1M-b2-pq-r1/query-48.log +ada002-1M-b4-ash-r1,ada002-1M,1536,768,4,ASH,1,389,1,1.0,227.142034764,1885.1372911497197,0.88774,712.952,8.466917289018335,8.071419789,5.700532906,0.497370085,3.051166,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/ada002-1M-b4-ash-r1/query-1.log +ada002-1M-b4-ash-r1,ada002-1M,1536,768,4,ASH,1,389,1,2.0,227.142034764,1694.774941404327,0.96112,1036.2085,1.440621537569398,8.071419789,5.700532906,0.497370085,3.051166,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/ada002-1M-b4-ash-r1/query-1.log +ada002-1M-b4-ash-r1,ada002-1M,1536,768,4,ASH,1,389,48,1.0,,78535.16386753105,0.88774,712.952,1.0498522055276105,,,0.0,0,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/ada002-1M-b4-ash-r1/query-48.log +ada002-1M-b4-ash-r1,ada002-1M,1536,768,4,ASH,1,389,48,2.0,,58078.37660324255,0.96112,1036.2085,1.0265098943456157,,,0.0,0,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/ada002-1M-b4-ash-r1/query-48.log +ada002-1M-b4-pq-r1,ada002-1M,1536,768,4,PQ,1,389,1,1.0,471.033761676,2296.391366518768,0.78046,743.3911,3.454129477444065,6.66483986,4.78256411,0.619976129,0,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/ada002-1M-b4-pq-r1/query-1.log +ada002-1M-b4-pq-r1,ada002-1M,1536,768,4,PQ,1,389,1,2.0,471.033761676,1844.3575579359417,0.94212,1075.2136,0.5016351987560125,6.66483986,4.78256411,0.619976129,0,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/ada002-1M-b4-pq-r1/query-1.log +ada002-1M-b4-pq-r1,ada002-1M,1536,768,4,PQ,1,389,48,1.0,,88588.06947666063,0.78046,743.3911,1.2252863478487552,,,0.0,0,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/ada002-1M-b4-pq-r1/query-48.log +ada002-1M-b4-pq-r1,ada002-1M,1536,768,4,PQ,1,389,48,2.0,,69034.79529813363,0.94212,1075.2136,0.3523116662786264,,,0.0,0,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/ada002-1M-b4-pq-r1/query-48.log +cap-1M-b2-ash-r1,cap-1M,1536,768,2,ASH,1,197,1,1.0,144.164132004,2442.48519676128,0.7918,752.783,6.871960701628724,9.336766347,7.555819332,0.49760735,3.01653,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/cap-1M-b2-ash-r1/query-1.log +cap-1M-b2-ash-r1,cap-1M,1536,768,2,ASH,1,197,1,2.0,144.164132004,1969.1891971271289,0.94435,1099.8606,1.0289141912055246,9.336766347,7.555819332,0.49760735,3.01653,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/cap-1M-b2-ash-r1/query-1.log +cap-1M-b2-ash-r1,cap-1M,1536,768,2,ASH,1,197,48,1.0,,95279.65361270042,0.7918,752.783,0.9853988466271536,,,0.0,0,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/cap-1M-b2-ash-r1/query-48.log +cap-1M-b2-ash-r1,cap-1M,1536,768,2,ASH,1,197,48,2.0,,78168.15369319917,0.94435,1099.8606,0.552073063676741,,,0.0,0,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/cap-1M-b2-ash-r1/query-48.log +cap-1M-b2-pq-r1,cap-1M,1536,768,2,PQ,1,197,1,1.0,234.99586654,2582.7721961649463,0.57832,725.6598,3.9486382814193726,17.339142317,4.496241169,0.495377823,0,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/cap-1M-b2-pq-r1/query-1.log +cap-1M-b2-pq-r1,cap-1M,1536,768,2,PQ,1,197,1,2.0,234.99586654,2118.999904259021,0.78163,1010.7918,1.8779604116325714,17.339142317,4.496241169,0.495377823,0,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/cap-1M-b2-pq-r1/query-1.log +cap-1M-b2-pq-r1,cap-1M,1536,768,2,PQ,1,197,48,1.0,,102157.77848107649,0.57832,725.6598,0.8520534403998001,,,0.0,0,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/cap-1M-b2-pq-r1/query-48.log +cap-1M-b2-pq-r1,cap-1M,1536,768,2,PQ,1,197,48,2.0,,87196.68753139579,0.78163,1010.7918,0.6228168823421181,,,0.0,0,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/cap-1M-b2-pq-r1/query-48.log +cap-1M-b4-ash-r1,cap-1M,1536,768,4,ASH,1,389,1,1.0,223.669037493,1968.8190491505034,0.86483,710.3348,6.406771306054616,7.937175314,6.870664075,0.512322427,3.287562,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/cap-1M-b4-ash-r1/query-1.log +cap-1M-b4-ash-r1,cap-1M,1536,768,4,ASH,1,389,1,2.0,223.669037493,1757.2733497910124,0.95431,1021.7334,2.695601637716467,7.937175314,6.870664075,0.512322427,3.287562,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/cap-1M-b4-ash-r1/query-1.log +cap-1M-b4-ash-r1,cap-1M,1536,768,4,ASH,1,389,48,1.0,,83224.75726728224,0.86483,710.3348,1.42150330749097,,,0.0,0,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/cap-1M-b4-ash-r1/query-48.log +cap-1M-b4-ash-r1,cap-1M,1536,768,4,ASH,1,389,48,2.0,,63126.66771149495,0.95431,1021.7334,0.6423858748901395,,,0.0,0,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/cap-1M-b4-ash-r1/query-48.log +cap-1M-b4-pq-r1,cap-1M,1536,768,4,PQ,1,389,1,1.0,428.719613174,2010.5628717956458,0.77078,724.1648,5.712025504481833,7.322717334,4.716799943,0.509658225,0,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/cap-1M-b4-pq-r1/query-1.log +cap-1M-b4-pq-r1,cap-1M,1536,768,4,PQ,1,389,1,2.0,428.719613174,1642.1041956039842,0.93387,1033.019,1.6530978389723041,7.322717334,4.716799943,0.509658225,0,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/cap-1M-b4-pq-r1/query-1.log +cap-1M-b4-pq-r1,cap-1M,1536,768,4,PQ,1,389,48,1.0,,88276.39085252174,0.77078,724.1648,1.814849190790097,,,0.0,0,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/cap-1M-b4-pq-r1/query-48.log +cap-1M-b4-pq-r1,cap-1M,1536,768,4,PQ,1,389,48,2.0,,72320.63990408325,0.93387,1033.019,0.5961574242426309,,,0.0,0,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/cap-1M-b4-pq-r1/query-48.log +cohere-english-v3-1M-b2-ash-r1,cohere-english-v3-1M,1024,512,2,ASH,1,133,1,1.0,131.540012311,4032.4153832742772,0.70528,884.6191,3.560891364246673,5.381116293,1.874303968,0.459574838,1.941501,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/cohere-english-v3-1M-b2-ash-r1/query-1.log +cohere-english-v3-1M-b2-ash-r1,cohere-english-v3-1M,1024,512,2,ASH,1,133,1,2.0,131.540012311,2990.537197603152,0.88578,1323.1688,5.124065470263526,5.381116293,1.874303968,0.459574838,1.941501,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/cohere-english-v3-1M-b2-ash-r1/query-1.log +cohere-english-v3-1M-b2-ash-r1,cohere-english-v3-1M,1024,512,2,ASH,1,133,48,1.0,,154673.1592239879,0.70528,884.6191,4.393186725468967,,,0.0,0,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/cohere-english-v3-1M-b2-ash-r1/query-48.log +cohere-english-v3-1M-b2-ash-r1,cohere-english-v3-1M,1024,512,2,ASH,1,133,48,2.0,,114552.33144904207,0.88578,1323.1688,0.6570685869485251,,,0.0,0,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/cohere-english-v3-1M-b2-ash-r1/query-48.log +cohere-english-v3-1M-b2-pq-r1,cohere-english-v3-1M,1024,512,2,PQ,1,133,1,1.0,202.056488632,3760.082855364721,0.66689,824.2477,2.4226242494143873,5.495381372,3.826856416,0.373458183,0,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/cohere-english-v3-1M-b2-pq-r1/query-1.log +cohere-english-v3-1M-b2-pq-r1,cohere-english-v3-1M,1024,512,2,PQ,1,133,1,2.0,202.056488632,3004.8417879055482,0.8569,1200.9145,1.4786229125049466,5.495381372,3.826856416,0.373458183,0,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/cohere-english-v3-1M-b2-pq-r1/query-1.log +cohere-english-v3-1M-b2-pq-r1,cohere-english-v3-1M,1024,512,2,PQ,1,133,48,1.0,,137651.87500685465,0.66689,824.2477,1.0614539879575466,,,0.0,0,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/cohere-english-v3-1M-b2-pq-r1/query-48.log +cohere-english-v3-1M-b2-pq-r1,cohere-english-v3-1M,1024,512,2,PQ,1,133,48,2.0,,110493.6991190779,0.8569,1200.9145,1.8720195013611096,,,0.0,0,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/cohere-english-v3-1M-b2-pq-r1/query-48.log +cohere-english-v3-1M-b4-ash-r1,cohere-english-v3-1M,1024,512,4,ASH,1,261,1,1.0,658.453734072,2914.921321324916,0.79959,829.839,18.87768723717277,4.541078397,2.222092598,0.357154296,2.160219,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/cohere-english-v3-1M-b4-ash-r1/query-1.log +cohere-english-v3-1M-b4-ash-r1,cohere-english-v3-1M,1024,512,4,ASH,1,261,1,2.0,658.453734072,2318.7834081655997,0.91214,1232.4703,2.8346436633059775,4.541078397,2.222092598,0.357154296,2.160219,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/cohere-english-v3-1M-b4-ash-r1/query-1.log +cohere-english-v3-1M-b4-ash-r1,cohere-english-v3-1M,1024,512,4,ASH,1,261,48,1.0,,130101.16171643394,0.79959,829.839,2.5756810774540098,,,0.0,0,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/cohere-english-v3-1M-b4-ash-r1/query-48.log +cohere-english-v3-1M-b4-ash-r1,cohere-english-v3-1M,1024,512,4,ASH,1,261,48,2.0,,94805.88464088204,0.91214,1232.4703,0.0724673350571949,,,0.0,0,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/cohere-english-v3-1M-b4-ash-r1/query-48.log +cohere-english-v3-1M-b4-pq-r1,cohere-english-v3-1M,1024,512,4,PQ,1,261,1,1.0,1224.670977507,3056.927847735833,0.73716,792.6989,5.354685894084355,5.670114916,3.275320129,0.362849512,0,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/cohere-english-v3-1M-b4-pq-r1/query-1.log +cohere-english-v3-1M-b4-pq-r1,cohere-english-v3-1M,1024,512,4,PQ,1,261,1,2.0,1224.670977507,2296.056472237398,0.89174,1155.4997,5.725529209257075,5.670114916,3.275320129,0.362849512,0,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/cohere-english-v3-1M-b4-pq-r1/query-1.log +cohere-english-v3-1M-b4-pq-r1,cohere-english-v3-1M,1024,512,4,PQ,1,261,48,1.0,,125649.75380191854,0.73716,792.6989,2.0450484393226622,,,0.0,0,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/cohere-english-v3-1M-b4-pq-r1/query-48.log +cohere-english-v3-1M-b4-pq-r1,cohere-english-v3-1M,1024,512,4,PQ,1,261,48,2.0,,95476.75849465677,0.89174,1155.4997,0.24166504216312962,,,0.0,0,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/cohere-english-v3-1M-b4-pq-r1/query-48.log +ada002-1M-b2-pq-r2,ada002-1M,1536,768,2,PQ,2,197,1,1.0,314.49511595,2748.769882268208,0.61087,761.9973,3.034200291066349,7.164817247,5.105142127,0.58527832,0,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/ada002-1M-b2-pq-r2/query-1.log +ada002-1M-b2-pq-r2,ada002-1M,1536,768,2,PQ,2,197,1,2.0,314.49511595,2215.193301397225,0.81207,1073.2808,1.0813312289988994,7.164817247,5.105142127,0.58527832,0,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/ada002-1M-b2-pq-r2/query-1.log +ada002-1M-b2-pq-r2,ada002-1M,1536,768,2,PQ,2,197,48,1.0,,95964.90321354134,0.61087,761.9973,2.651360702180009,,,0.0,0,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/ada002-1M-b2-pq-r2/query-48.log +ada002-1M-b2-pq-r2,ada002-1M,1536,768,2,PQ,2,197,48,2.0,,79740.98409975477,0.81207,1073.2808,3.040241779525206,,,0.0,0,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/ada002-1M-b2-pq-r2/query-48.log +ada002-1M-b2-ash-r2,ada002-1M,1536,768,2,ASH,2,197,1,1.0,148.462216427,1996.4088078542302,0.82806,743.1514,8.337650633903259,9.620892019,7.596049599,0.492858178,3.140455,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/ada002-1M-b2-ash-r2/query-1.log +ada002-1M-b2-ash-r2,ada002-1M,1536,768,2,ASH,2,197,1,2.0,148.462216427,1802.8315892472526,0.95985,1098.6193,1.3155639215124004,9.620892019,7.596049599,0.492858178,3.140455,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/ada002-1M-b2-ash-r2/query-1.log +ada002-1M-b2-ash-r2,ada002-1M,1536,768,2,ASH,2,197,48,1.0,,95056.34990344389,0.82806,743.1514,1.7700623107441582,,,0.0,0,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/ada002-1M-b2-ash-r2/query-48.log +ada002-1M-b2-ash-r2,ada002-1M,1536,768,2,ASH,2,197,48,2.0,,74802.08869227431,0.95985,1098.6193,0.14584232924624438,,,0.0,0,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/ada002-1M-b2-ash-r2/query-48.log +ada002-1M-b4-pq-r2,ada002-1M,1536,768,4,PQ,2,389,1,1.0,507.890157909,1820.6690398184126,0.78124,741.0503,30.736736316022316,7.278564002,4.93304114,0.600501602,0,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/ada002-1M-b4-pq-r2/query-1.log +ada002-1M-b4-pq-r2,ada002-1M,1536,768,4,PQ,2,389,1,2.0,507.890157909,1588.1010017830652,0.94098,1071.2316,14.340092833165679,7.278564002,4.93304114,0.600501602,0,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/ada002-1M-b4-pq-r2/query-1.log +ada002-1M-b4-pq-r2,ada002-1M,1536,768,4,PQ,2,389,48,1.0,,94582.40323816786,0.78124,741.0503,1.2108098886995722,,,0.0,0,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/ada002-1M-b4-pq-r2/query-48.log +ada002-1M-b4-pq-r2,ada002-1M,1536,768,4,PQ,2,389,48,2.0,,70449.31841046993,0.94098,1071.2316,0.701127293075968,,,0.0,0,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/ada002-1M-b4-pq-r2/query-48.log +ada002-1M-b4-ash-r2,ada002-1M,1536,768,4,ASH,2,389,1,1.0,228.410139478,1747.928265482728,0.88768,705.7386,1.8283062091639757,8.356441471,6.579782222,0.647681467,3.269243,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/ada002-1M-b4-ash-r2/query-1.log +ada002-1M-b4-ash-r2,ada002-1M,1536,768,4,ASH,2,389,1,2.0,228.410139478,1690.787299577052,0.96117,1030.419,0.5222633104861074,8.356441471,6.579782222,0.647681467,3.269243,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/ada002-1M-b4-ash-r2/query-1.log +ada002-1M-b4-ash-r2,ada002-1M,1536,768,4,ASH,2,389,48,1.0,,76915.82888351154,0.88768,705.7386,2.018428409914763,,,0.0,0,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/ada002-1M-b4-ash-r2/query-48.log +ada002-1M-b4-ash-r2,ada002-1M,1536,768,4,ASH,2,389,48,2.0,,59068.83275336893,0.96117,1030.419,0.42491432057404144,,,0.0,0,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/ada002-1M-b4-ash-r2/query-48.log +cap-1M-b2-pq-r2,cap-1M,1536,768,2,PQ,2,197,1,1.0,318.820383687,2538.509055353608,0.57475,737.8679,1.4309608739915773,15.680441056,4.578405703,0.490970401,0,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/cap-1M-b2-pq-r2/query-1.log +cap-1M-b2-pq-r2,cap-1M,1536,768,2,PQ,2,197,1,2.0,318.820383687,2102.1416711961683,0.77755,1024.8805,2.2009318201309274,15.680441056,4.578405703,0.490970401,0,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/cap-1M-b2-pq-r2/query-1.log +cap-1M-b2-pq-r2,cap-1M,1536,768,2,PQ,2,197,48,1.0,,96777.26308815173,0.57475,737.8679,2.5873596884050936,,,0.0,0,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/cap-1M-b2-pq-r2/query-48.log +cap-1M-b2-pq-r2,cap-1M,1536,768,2,PQ,2,197,48,2.0,,84790.32448230755,0.77755,1024.8805,0.9254997826172315,,,0.0,0,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/cap-1M-b2-pq-r2/query-48.log +cap-1M-b2-ash-r2,cap-1M,1536,768,2,ASH,2,197,1,1.0,154.184889795,2258.191054953794,0.79039,751.5542,23.759659693908986,9.027614868,6.660274635,0.508288027,3.013961,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/cap-1M-b2-ash-r2/query-1.log +cap-1M-b2-ash-r2,cap-1M,1536,768,2,ASH,2,197,1,2.0,154.184889795,2052.5449317362495,0.94304,1096.9158,2.712071494538914,9.027614868,6.660274635,0.508288027,3.013961,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/cap-1M-b2-ash-r2/query-1.log +cap-1M-b2-ash-r2,cap-1M,1536,768,2,ASH,2,197,48,1.0,,96253.66102754153,0.79039,751.5542,1.7058235542763742,,,0.0,0,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/cap-1M-b2-ash-r2/query-48.log +cap-1M-b2-ash-r2,cap-1M,1536,768,2,ASH,2,197,48,2.0,,79043.16466879538,0.94304,1096.9158,0.2782425279737823,,,0.0,0,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/cap-1M-b2-ash-r2/query-48.log +cap-1M-b4-pq-r2,cap-1M,1536,768,4,PQ,2,389,1,1.0,384.541696496,1997.0962184856235,0.77117,721.4095,18.76226207864229,7.772478911,4.729168092,0.584808812,0,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/cap-1M-b4-pq-r2/query-1.log +cap-1M-b4-pq-r2,cap-1M,1536,768,4,PQ,2,389,1,2.0,384.541696496,1628.2176350293066,0.93402,1029.0857,15.060018096648184,7.772478911,4.729168092,0.584808812,0,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/cap-1M-b4-pq-r2/query-1.log +cap-1M-b4-pq-r2,cap-1M,1536,768,4,PQ,2,389,48,1.0,,93982.41166067754,0.77117,721.4095,2.0385129815211256,,,0.0,0,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/cap-1M-b4-pq-r2/query-48.log +cap-1M-b4-pq-r2,cap-1M,1536,768,4,PQ,2,389,48,2.0,,70346.9921004846,0.93402,1029.0857,0.9724138237250509,,,0.0,0,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/cap-1M-b4-pq-r2/query-48.log +cap-1M-b4-ash-r2,cap-1M,1536,768,4,ASH,2,389,1,1.0,218.04224412,2149.0023927609095,0.86566,708.1348,8.837725956246652,8.929087608,7.874196863,0.512352717,3.399143,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/cap-1M-b4-ash-r2/query-1.log +cap-1M-b4-ash-r2,cap-1M,1536,768,4,ASH,2,389,1,2.0,218.04224412,1709.8616544967786,0.95445,1020.1297,2.1276746338547827,8.929087608,7.874196863,0.512352717,3.399143,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/cap-1M-b4-ash-r2/query-1.log +cap-1M-b4-ash-r2,cap-1M,1536,768,4,ASH,2,389,48,1.0,,82541.79142378358,0.86566,708.1348,3.0493408284794836,,,0.0,0,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/cap-1M-b4-ash-r2/query-48.log +cap-1M-b4-ash-r2,cap-1M,1536,768,4,ASH,2,389,48,2.0,,62660.6088961071,0.95445,1020.1297,0.6444089703901766,,,0.0,0,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/cap-1M-b4-ash-r2/query-48.log +cohere-english-v3-1M-b2-pq-r2,cohere-english-v3-1M,1024,512,2,PQ,2,133,1,1.0,210.651240768,3554.791683888936,0.66659,824.1673,1.9970658008975664,5.380806686,3.888598589,0.374447596,0,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/cohere-english-v3-1M-b2-pq-r2/query-1.log +cohere-english-v3-1M-b2-pq-r2,cohere-english-v3-1M,1024,512,2,PQ,2,133,1,2.0,210.651240768,2755.1301538763946,0.85658,1199.3812,3.764086183772186,5.380806686,3.888598589,0.374447596,0,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/cohere-english-v3-1M-b2-pq-r2/query-1.log +cohere-english-v3-1M-b2-pq-r2,cohere-english-v3-1M,1024,512,2,PQ,2,133,48,1.0,,138379.5345558035,0.66659,824.1673,1.884179629700551,,,0.0,0,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/cohere-english-v3-1M-b2-pq-r2/query-48.log +cohere-english-v3-1M-b2-pq-r2,cohere-english-v3-1M,1024,512,2,PQ,2,133,48,2.0,,108017.34345790854,0.85658,1199.3812,0.2428364162563542,,,0.0,0,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/cohere-english-v3-1M-b2-pq-r2/query-48.log +cohere-english-v3-1M-b2-ash-r2,cohere-english-v3-1M,1024,512,2,ASH,2,133,1,1.0,155.626287523,3711.8325622709335,0.70446,891.4132,3.439385278420078,5.27452495,1.718166483,0.399741568,1.952495,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/cohere-english-v3-1M-b2-ash-r2/query-1.log +cohere-english-v3-1M-b2-ash-r2,cohere-english-v3-1M,1024,512,2,ASH,2,133,1,2.0,155.626287523,3720.63426140302,0.88522,1327.2389,2.135700347945317,5.27452495,1.718166483,0.399741568,1.952495,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/cohere-english-v3-1M-b2-ash-r2/query-1.log +cohere-english-v3-1M-b2-ash-r2,cohere-english-v3-1M,1024,512,2,ASH,2,133,48,1.0,,156862.83319312084,0.70446,891.4132,3.014477053063219,,,0.0,0,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/cohere-english-v3-1M-b2-ash-r2/query-48.log +cohere-english-v3-1M-b2-ash-r2,cohere-english-v3-1M,1024,512,2,ASH,2,133,48,2.0,,110969.92932781803,0.88522,1327.2389,2.6712548800277496,,,0.0,0,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/cohere-english-v3-1M-b2-ash-r2/query-48.log +cohere-english-v3-1M-b4-pq-r2,cohere-english-v3-1M,1024,512,4,PQ,2,261,1,1.0,320.372937538,3053.848581254041,0.73738,789.9314,4.2108275645215425,6.033624081,3.279443062,0.426116483,0,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/cohere-english-v3-1M-b4-pq-r2/query-1.log +cohere-english-v3-1M-b4-pq-r2,cohere-english-v3-1M,1024,512,4,PQ,2,261,1,2.0,320.372937538,2349.17408087356,0.89183,1153.327,1.6478659973446461,6.033624081,3.279443062,0.426116483,0,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/cohere-english-v3-1M-b4-pq-r2/query-1.log +cohere-english-v3-1M-b4-pq-r2,cohere-english-v3-1M,1024,512,4,PQ,2,261,48,1.0,,127888.00395618335,0.73738,789.9314,0.6495444554179923,,,0.0,0,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/cohere-english-v3-1M-b4-pq-r2/query-48.log +cohere-english-v3-1M-b4-pq-r2,cohere-english-v3-1M,1024,512,4,PQ,2,261,48,2.0,,95135.73123335889,0.89183,1153.327,0.6440058638324528,,,0.0,0,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/cohere-english-v3-1M-b4-pq-r2/query-48.log +cohere-english-v3-1M-b4-ash-r2,cohere-english-v3-1M,1024,512,4,ASH,2,261,1,1.0,158.050770213,3262.9891204212204,0.80006,834.193,6.604247423409825,4.543623972,2.239317788,0.479592937,2.036372,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/cohere-english-v3-1M-b4-ash-r2/query-1.log +cohere-english-v3-1M-b4-ash-r2,cohere-english-v3-1M,1024,512,4,ASH,2,261,1,2.0,158.050770213,2571.738833639199,0.91205,1235.6143,2.7208643656202733,4.543623972,2.239317788,0.479592937,2.036372,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/cohere-english-v3-1M-b4-ash-r2/query-1.log +cohere-english-v3-1M-b4-ash-r2,cohere-english-v3-1M,1024,512,4,ASH,2,261,48,1.0,,128859.33340409711,0.80006,834.193,2.2828342408538718,,,0.0,0,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/cohere-english-v3-1M-b4-ash-r2/query-48.log +cohere-english-v3-1M-b4-ash-r2,cohere-english-v3-1M,1024,512,4,ASH,2,261,48,2.0,,94374.11028668427,0.91205,1235.6143,0.6084842422665558,,,0.0,0,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/cohere-english-v3-1M-b4-ash-r2/query-48.log diff --git a/docs/figure9-graph-study/system-summary.json b/docs/figure9-graph-study/system-summary.json new file mode 100644 index 000000000..7672b8df4 --- /dev/null +++ b/docs/figure9-graph-study/system-summary.json @@ -0,0 +1,162 @@ +[ + { + "case": "cohere-english-v3-1M-b2-pq-r2", + "pid": "2035635", + "from_utc": "2026-09-19T20:30:27.089599+00:00", + "to_utc": "2026-09-19T20:34:59.294364+00:00", + "min_available_GiB": 320.67454528808594, + "max_rss_GiB": 22.119491577148438, + "max_dirty_writeback_GiB": 3.08685302734375, + "max_memory_psi_avg10": 0.0 + }, + { + "case": "cohere-english-v3-1M-b2-pq-r2", + "pid": "2037555", + "from_utc": "2026-09-19T20:35:04.320821+00:00", + "to_utc": "2026-09-19T20:35:34.495364+00:00", + "min_available_GiB": 324.8975257873535, + "max_rss_GiB": 21.283061981201172, + "max_dirty_writeback_GiB": 0.0055694580078125, + "max_memory_psi_avg10": 0.0 + }, + { + "case": "cohere-english-v3-1M-b2-ash-r2", + "pid": "2038257", + "from_utc": "2026-09-19T20:35:39.520745+00:00", + "to_utc": "2026-09-19T20:39:31.266594+00:00", + "min_available_GiB": 316.5094299316406, + "max_rss_GiB": 23.954978942871094, + "max_dirty_writeback_GiB": 6.342803955078125, + "max_memory_psi_avg10": 0.0 + }, + { + "case": "cohere-english-v3-1M-b2-ash-r2", + "pid": "2039608", + "from_utc": "2026-09-19T20:39:36.294845+00:00", + "to_utc": "2026-09-19T20:40:06.513302+00:00", + "min_available_GiB": 321.39935302734375, + "max_rss_GiB": 25.008392333984375, + "max_dirty_writeback_GiB": 0.000514984130859375, + "max_memory_psi_avg10": 0.0 + }, + { + "case": "cohere-english-v3-1M-b4-pq-r2", + "pid": "2040168", + "from_utc": "2026-09-19T20:40:11.542975+00:00", + "to_utc": "2026-09-19T20:46:29.965677+00:00", + "min_available_GiB": 321.69421005249023, + "max_rss_GiB": 23.741764068603516, + "max_dirty_writeback_GiB": 5.1027984619140625, + "max_memory_psi_avg10": 0.0 + }, + { + "case": "cohere-english-v3-1M-b4-pq-r2", + "pid": "2042565", + "from_utc": "2026-09-19T20:46:40.022028+00:00", + "to_utc": "2026-09-19T20:47:10.261674+00:00", + "min_available_GiB": 324.74349212646484, + "max_rss_GiB": 23.483871459960938, + "max_dirty_writeback_GiB": 0.0050201416015625, + "max_memory_psi_avg10": 0.0 + }, + { + "case": "cohere-english-v3-1M-b4-ash-r2", + "pid": "2043265", + "from_utc": "2026-09-19T20:47:15.292182+00:00", + "to_utc": "2026-09-19T20:51:12.183433+00:00", + "min_available_GiB": 319.2004508972168, + "max_rss_GiB": 26.202648162841797, + "max_dirty_writeback_GiB": 8.726558685302734, + "max_memory_psi_avg10": 0.0 + }, + { + "case": "cohere-english-v3-1M-b4-ash-r2", + "pid": "2045044", + "from_utc": "2026-09-19T20:51:17.211849+00:00", + "to_utc": "2026-09-19T20:51:47.450795+00:00", + "min_available_GiB": 324.8402404785156, + "max_rss_GiB": 24.63471221923828, + "max_dirty_writeback_GiB": 0.001110076904296875, + "max_memory_psi_avg10": 0.0 + }, + { + "case": "cohere-english-v3-1M-b4-ash-r1", + "pid": "2057944", + "from_utc": "2026-09-19T21:19:22.866606+00:00", + "to_utc": "2026-09-19T21:21:19.922627+00:00", + "min_available_GiB": 328.5126152038574, + "max_rss_GiB": 20.657455444335938, + "max_dirty_writeback_GiB": 0.005218505859375, + "max_memory_psi_avg10": 0.0 + }, + { + "case": "cohere-100k-b2-ash", + "pid": "2058896", + "from_utc": "2026-09-19T21:21:45.134857+00:00", + "to_utc": "2026-09-19T21:22:00.303707+00:00", + "min_available_GiB": 333.24229431152344, + "max_rss_GiB": 6.707263946533203, + "max_dirty_writeback_GiB": 0.6126480102539062, + "max_memory_psi_avg10": 0.0 + }, + { + "case": "cohere-100k-b2-ash", + "pid": "2060298", + "from_utc": "2026-09-19T21:25:06.850904+00:00", + "to_utc": "2026-09-19T21:25:22.002665+00:00", + "min_available_GiB": 334.38719177246094, + "max_rss_GiB": 5.549938201904297, + "max_dirty_writeback_GiB": 0.8749160766601562, + "max_memory_psi_avg10": 0.0 + }, + { + "case": "cohere-100k-b2-pq", + "pid": "2060984", + "from_utc": "2026-09-19T21:25:27.082719+00:00", + "to_utc": "2026-09-19T21:25:47.338261+00:00", + "min_available_GiB": 318.1367721557617, + "max_rss_GiB": 21.780776977539062, + "max_dirty_writeback_GiB": 0.7725296020507812, + "max_memory_psi_avg10": 0.0 + }, + { + "case": "cohere-100k-b4-ash", + "pid": "2061586", + "from_utc": "2026-09-19T21:25:52.388155+00:00", + "to_utc": "2026-09-19T21:26:12.654560+00:00", + "min_available_GiB": 335.6449546813965, + "max_rss_GiB": 4.201637268066406, + "max_dirty_writeback_GiB": 1.6809310913085938, + "max_memory_psi_avg10": 0.0 + }, + { + "case": "cohere-100k-b4-pq", + "pid": "2062247", + "from_utc": "2026-09-19T21:26:17.706419+00:00", + "to_utc": "2026-09-19T21:26:58.288125+00:00", + "min_available_GiB": 333.78203201293945, + "max_rss_GiB": 6.060382843017578, + "max_dirty_writeback_GiB": 1.7176704406738281, + "max_memory_psi_avg10": 0.0 + }, + { + "case": "cohere-1m-b4-ash", + "pid": "2062996", + "from_utc": "2026-09-19T21:27:13.430324+00:00", + "to_utc": "2026-09-19T21:30:46.023376+00:00", + "min_available_GiB": 321.1495819091797, + "max_rss_GiB": 18.847640991210938, + "max_dirty_writeback_GiB": 3.8353195190429688, + "max_memory_psi_avg10": 0.0 + }, + { + "case": "cohere-1m-b4-pq", + "pid": "2064589", + "from_utc": "2026-09-19T21:30:51.080848+00:00", + "to_utc": "2026-09-19T21:36:51.520063+00:00", + "min_available_GiB": 317.8377342224121, + "max_rss_GiB": 21.94839859008789, + "max_dirty_writeback_GiB": 5.549713134765625, + "max_memory_psi_avg10": 0.0 + } +] \ No newline at end of file diff --git a/docs/figure9-graph-study/tables.md b/docs/figure9-graph-study/tables.md new file mode 100644 index 000000000..51dbb6e4e --- /dev/null +++ b/docs/figure9-graph-study/tables.md @@ -0,0 +1,81 @@ +# Final graph results + +Means of two independent builds per configuration. ASH/PQ values are shown in that order. + +## Single-threaded queries + +| Dataset | ASH bits / PQ matching budget | Overquery | Recall@10 (%): ASH / PQ | QPS: ASH / PQ | ASH/PQ QPS | Recall difference (pp) | +|---|---:|---:|---:|---:|---:|---:| +| ada002-1M | 2 | 1× | 82.724 / 61.028 | 2,087 / 2,627 | 0.79× | +21.696 | +| ada002-1M | 2 | 2× | 95.969 / 81.254 | 1,938 / 2,143 | 0.90× | +14.715 | +| ada002-1M | 4 | 1× | 88.771 / 78.085 | 1,817 / 2,255 | 0.81× | +10.686 | +| ada002-1M | 4 | 2× | 96.114 / 94.155 | 1,693 / 1,771 | 0.96× | +1.959 | +| cap-1M | 2 | 1× | 79.109 / 57.654 | 2,557 / 2,561 | 1.00× | +21.456 | +| cap-1M | 2 | 2× | 94.369 / 77.959 | 2,075 / 2,111 | 0.98× | +16.410 | +| cap-1M | 4 | 1× | 86.525 / 77.097 | 2,059 / 2,237 | 0.92× | +9.427 | +| cap-1M | 4 | 2× | 95.438 / 93.395 | 1,734 / 1,789 | 0.97× | +2.043 | +| cohere-english-v3-1M | 2 | 1× | 70.487 / 66.674 | 3,872 / 3,657 | 1.06× | +3.813 | +| cohere-english-v3-1M | 2 | 2× | 88.550 / 85.674 | 3,356 / 2,880 | 1.17× | +2.876 | +| cohere-english-v3-1M | 4 | 1× | 79.983 / 73.727 | 3,461 / 3,055 | 1.13× | +6.255 | +| cohere-english-v3-1M | 4 | 2× | 91.210 / 89.178 | 2,612 / 2,323 | 1.12× | +2.031 | + +## Construction + +Graph column includes scorer-provider setup. Phase subtotal also includes compressor training, base encoding and NVQ compressor setup; it excludes dataset loading, JVM startup and uninstrumented writer initialization. + +| Dataset | Bits/budget | Graph + provider seconds: ASH / PQ | Training seconds: ASH / PQ | Encoding seconds: ASH / PQ | Measured phase subtotal seconds: ASH / PQ | +|---|---:|---:|---:|---:|---:| +| ada002-1M | 2 | 156.20 / 309.93 | 9.43 / 6.69 | 7.65 / 4.73 | 173.79 / 321.90 | +| ada002-1M | 4 | 230.94 / 489.46 | 8.21 / 6.97 | 6.14 / 4.86 | 245.86 / 501.90 | +| cap-1M | 2 | 152.19 / 276.91 | 9.18 / 16.51 | 7.11 / 4.54 | 168.98 / 298.45 | +| cap-1M | 4 | 224.20 / 406.63 | 8.43 / 7.55 | 7.37 / 4.72 | 240.52 / 419.45 | +| cohere-english-v3-1M | 2 | 145.53 / 206.35 | 5.33 / 5.44 | 1.80 / 3.86 | 153.08 / 216.02 | +| cohere-english-v3-1M | 4 | 410.35 / 772.52 | 4.54 / 5.85 | 2.23 / 3.28 | 417.54 / 782.05 | + +## Same cached indexes, 48 query workers + +| Dataset | Bits/budget | Overquery | Concurrent QPS: ASH / PQ | Speedup over one worker: ASH / PQ | +|---|---:|---:|---:|---:| +| ada002-1M | 2 | 1× | 95,507 / 96,984 | 45.76× / 36.92× | +| ada002-1M | 2 | 2× | 74,420 / 81,508 | 38.40× / 38.03× | +| ada002-1M | 4 | 1× | 77,725 / 91,585 | 42.79× / 40.62× | +| ada002-1M | 4 | 2× | 58,574 / 69,742 | 34.60× / 39.39× | +| cap-1M | 2 | 1× | 95,767 / 99,468 | 37.45× / 38.84× | +| cap-1M | 2 | 2× | 78,606 / 85,994 | 37.88× / 40.74× | +| cap-1M | 4 | 1× | 82,883 / 91,129 | 40.26× / 40.74× | +| cap-1M | 4 | 2× | 62,894 / 71,334 | 36.28× / 39.86× | +| cohere-english-v3-1M | 2 | 1× | 155,768 / 138,016 | 40.23× / 37.74× | +| cohere-english-v3-1M | 2 | 2× | 112,761 / 109,256 | 33.60× / 37.94× | +| cohere-english-v3-1M | 4 | 1× | 129,480 / 126,769 | 37.41× / 41.49× | +| cohere-english-v3-1M | 4 | 2× | 94,590 / 95,306 | 36.21× / 41.03× | + +## Between-build ranges + +Ranges of two observations, not confidence intervals. + +| Dataset | Bits/budget | Method | Overquery | Single-thread QPS range | Recall@10 (%) range | Graph + setup seconds range | +|---|---:|---|---:|---:|---:|---:| +| ada002-1M | 2 | ASH | 1× | 1,996–2,178 | 82.642–82.806 | 151.60–160.79 | +| ada002-1M | 2 | ASH | 2× | 1,803–2,073 | 95.953–95.985 | 151.60–160.79 | +| ada002-1M | 2 | PQ | 1× | 2,506–2,749 | 60.969–61.087 | 305.36–314.50 | +| ada002-1M | 2 | PQ | 2× | 2,071–2,215 | 81.207–81.301 | 305.36–314.50 | +| ada002-1M | 4 | ASH | 1× | 1,748–1,885 | 88.768–88.774 | 230.19–231.68 | +| ada002-1M | 4 | ASH | 2× | 1,691–1,695 | 96.112–96.117 | 230.19–231.68 | +| ada002-1M | 4 | PQ | 1× | 2,214–2,296 | 78.046–78.124 | 471.03–507.89 | +| ada002-1M | 4 | PQ | 2× | 1,697–1,844 | 94.098–94.212 | 471.03–507.89 | +| cap-1M | 2 | ASH | 1× | 2,442–2,672 | 79.039–79.180 | 147.18–157.20 | +| cap-1M | 2 | ASH | 2× | 1,969–2,181 | 94.304–94.435 | 147.18–157.20 | +| cap-1M | 2 | PQ | 1× | 2,539–2,583 | 57.475–57.832 | 235.00–318.82 | +| cap-1M | 2 | PQ | 2× | 2,102–2,119 | 77.755–78.163 | 235.00–318.82 | +| cap-1M | 4 | ASH | 1× | 1,969–2,149 | 86.483–86.566 | 221.44–226.96 | +| cap-1M | 4 | ASH | 2× | 1,710–1,757 | 95.431–95.445 | 221.44–226.96 | +| cap-1M | 4 | PQ | 1× | 2,011–2,463 | 77.078–77.117 | 384.54–428.72 | +| cap-1M | 4 | PQ | 2× | 1,642–1,937 | 93.387–93.402 | 384.54–428.72 | +| cohere-english-v3-1M | 2 | ASH | 1× | 3,712–4,032 | 70.446–70.528 | 133.48–157.58 | +| cohere-english-v3-1M | 2 | ASH | 2× | 2,991–3,721 | 88.522–88.578 | 133.48–157.58 | +| cohere-english-v3-1M | 2 | PQ | 1× | 3,555–3,760 | 66.659–66.689 | 202.06–210.65 | +| cohere-english-v3-1M | 2 | PQ | 2× | 2,755–3,005 | 85.658–85.690 | 202.06–210.65 | +| cohere-english-v3-1M | 4 | ASH | 1× | 3,263–3,660 | 79.959–80.006 | 160.09–660.61 | +| cohere-english-v3-1M | 4 | ASH | 2× | 2,572–2,653 | 91.205–91.214 | 160.09–660.61 | +| cohere-english-v3-1M | 4 | PQ | 1× | 3,054–3,057 | 73.716–73.738 | 320.37–1224.67 | +| cohere-english-v3-1M | 4 | PQ | 2× | 2,296–2,349 | 89.174–89.183 | 320.37–1224.67 | diff --git a/docs/figure9-graph-study/timing-validation.md b/docs/figure9-graph-study/timing-validation.md new file mode 100644 index 000000000..4bab23dce --- /dev/null +++ b/docs/figure9-graph-study/timing-validation.md @@ -0,0 +1,14 @@ +## Timing validation + +The predeclared rule retimed any case/JVM with a sample CV above 10%, using both overquery settings and at least 10 seconds per sample (three samples). Four cached-index JVMs were retimed. All recall and visited-count values matched the originals exactly. No reconstruction was performed. These longer observations supply QPS in the reported tables; original observations remain in results.csv and in the original columns of reported-results.csv. + +| Case | Overquery | Original QPS | Longer QPS | Original CV | Longer CV | +|---|---:|---:|---:|---:|---:| +| ada002-1M-b4-pq-r2 | 1× | 1,821 | 2,214 | 30.7% | 2.7% | +| ada002-1M-b4-pq-r2 | 2× | 1,588 | 1,697 | 14.3% | 0.6% | +| cap-1M-b2-ash-r2 | 1× | 2,258 | 2,672 | 23.8% | 6.6% | +| cap-1M-b2-ash-r2 | 2× | 2,053 | 2,181 | 2.7% | 1.4% | +| cap-1M-b4-pq-r2 | 1× | 1,997 | 2,463 | 18.8% | 5.7% | +| cap-1M-b4-pq-r2 | 2× | 1,628 | 1,937 | 15.1% | 0.9% | +| cohere-english-v3-1M-b4-ash-r1 | 1× | 2,915 | 3,660 | 18.9% | 3.3% | +| cohere-english-v3-1M-b4-ash-r1 | 2× | 2,319 | 2,653 | 2.8% | 0.8% | From 04bd54ca05101b283d1d6abb5e37f5c0c55ccf3a Mon Sep 17 00:00:00 2001 From: Ted Willke Date: Thu, 24 Sep 2026 06:14:22 +0000 Subject: [PATCH 081/243] Optimize fused ASH packing with 8x8 byte transposition Transpose 2- and 4-bit neighbor codes in 64-bit tiles while preserving the existing on-disk layout, header encoding, lane order, and 1-bit path. Copy whole code bytes for scalar tails and retain padding nibbles. Reuse scratch storage and release gathered vector references on failure. Add 49,476 byte-equivalence cases across dimensions, bit widths, block sizes, partial blocks, landmark bytes, and unaligned offsets. Validated fused graph/scorer tests on scalar and native SIMD backends. The 1M study shows roughly half the flush time across worker/core counts; no insertion or query-scoring speedup is claimed. --- .../jvector/graph/disk/feature/FusedASH.java | 38 +++++-- .../quantization/FusedASHBlockPacker.java | 103 ++++++++++++++++++ .../jvector/quantization/FusedASHLayout.java | 9 +- .../TestFusedASHBlockPacking.java | 100 +++++++++++++++++ 4 files changed, 240 insertions(+), 10 deletions(-) create mode 100644 jvector-base/src/main/java/io/github/jbellis/jvector/quantization/FusedASHBlockPacker.java create mode 100644 jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestFusedASHBlockPacking.java diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/graph/disk/feature/FusedASH.java b/jvector-base/src/main/java/io/github/jbellis/jvector/graph/disk/feature/FusedASH.java index 03bbf3a45..29283484a 100644 --- a/jvector-base/src/main/java/io/github/jbellis/jvector/graph/disk/feature/FusedASH.java +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/graph/disk/feature/FusedASH.java @@ -61,6 +61,7 @@ public class FusedASH extends AbstractFeature implements FusedFeature { private final ThreadLocal reusableNeighborhoodBytes; private final ThreadLocal reusableScores; private final ThreadLocal writeScratch; + private final ThreadLocal packingRefs; public FusedASH(int maxDegree, AsymmetricHashing ash) { this(maxDegree, ash, FusedASHLayout.chooseBlockSize(maxDegree)); @@ -88,6 +89,7 @@ public FusedASH(int maxDegree, AsymmetricHashing ash, int blockSize) { this.reusableNeighborhoodBytes = ThreadLocal.withInitial(() -> new byte[featureSize]); this.reusableScores = ThreadLocal.withInitial(() -> new float[maxDegree]); this.writeScratch = ThreadLocal.withInitial(() -> new byte[featureSize]); + this.packingRefs = ThreadLocal.withInitial(() -> new AsymmetricHashing.QuantizedVector[maxDegree]); } @Override @@ -162,19 +164,39 @@ public void writeInline(IndexWriter out, Feature.State state_) throws IOExceptio byte[] scratch = writeScratch.get(); Arrays.fill(scratch, (byte) 0); + var refs = ash.bitsPerDimension == 1 ? null : packingRefs.get(); var neighbors = state.view.getNeighborsIterator(0, state.nodeId); int count = 0; - while (neighbors.hasNext()) { - int node = neighbors.nextInt(); - if (count >= maxDegree) { - throw new IOException("Node " + state.nodeId + " has more than maxDegree=" + maxDegree + " neighbors"); + try { + while (neighbors.hasNext()) { + int node = neighbors.nextInt(); + if (count >= maxDegree) { + throw new IOException("Node " + state.nodeId + " has more than maxDegree=" + maxDegree + " neighbors"); + } + + var compressed = state.compressedVectorFunction.apply(node); + if (ash.bitsPerDimension == 1) { + packForFused(scratch, count, compressed); + } else { + refs[count] = compressed; + } + count++; } - var compressed = state.compressedVectorFunction.apply(node); - packForFused(scratch, count, compressed); - count++; - } + if (ash.bitsPerDimension != 1) { + for (int first = 0; first < count; first += blockSize) { + int offset = FusedASHLayout.blockOffset(first / blockSize, ash.quantizedDim, ash.bitsPerDimension, blockSize); + io.github.jbellis.jvector.quantization.FusedASHBlockPacker.pack(scratch, offset, refs, first, + Math.min(blockSize, count - first), ash.quantizedDim, ash.bitsPerDimension, blockSize); + } + } + } finally { + // Do not retain vectors if a supplier or packing operation fails. + if (refs != null) { + Arrays.fill(refs, 0, count, null); + } + } writeBytes(out, scratch, featureSize); } diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/FusedASHBlockPacker.java b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/FusedASHBlockPacker.java new file mode 100644 index 000000000..81eeec244 --- /dev/null +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/FusedASHBlockPacker.java @@ -0,0 +1,103 @@ +/* + * 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 java.lang.invoke.MethodHandles; +import java.lang.invoke.VarHandle; +import java.nio.ByteOrder; + +/** Packs canonical ASH codes into the existing fused neighborhood layout. */ +public final class FusedASHBlockPacker { + private static final VarHandle LONGS = + MethodHandles.byteArrayViewVarHandle(long[].class, ByteOrder.LITTLE_ENDIAN); + private static final ThreadLocal WORK = ThreadLocal.withInitial(() -> new long[8]); + + private FusedASHBlockPacker() {} + + /** + * Packs up to {@code blockSize} vectors from {@code vectors[first]} into a + * zero-initialized block. Supported block sizes are 8, 16 and 32. + * Header encoding and lane order are identical to packQuantizedVector. + */ + public static void pack(byte[] destination, int offset, + AsymmetricHashing.QuantizedVector[] vectors, int first, int count, + int dimension, int bits, int blockSize) { + if (bits == 1) { + for (int lane = 0; lane < count; lane++) { + FusedASHLayout.packQuantizedVector(destination, offset, lane, + vectors[first + lane], dimension, bits, blockSize); + } + return; + } + int groups = FusedASHLayout.codeGroups(dimension, bits); + int fullBytes = groups / 2; + for (int lane = 0; lane < count; lane++) { + byte[] code = vectors[first + lane].extraBits; + if (code == null || code.length < (groups + 1) / 2) { + throw new IllegalArgumentException("Missing or truncated ASH code"); + } + } + + long[] work = WORK.get(); + int column = 0; + // Transpose an 8-neighbor by 8-byte tile using three swap stages. + // Explicit little-endian loads/stores preserve the format on either endianness. + for (; column + 8 <= fullBytes; column += 8) { + for (int laneBase = 0; laneBase < blockSize; laneBase += 8) { + for (int i = 0; i < 8; i++) { + work[i] = laneBase + i < count + ? (long) LONGS.get(vectors[first + laneBase + i].extraBits, column) + : 0; + } + for (int step = 1; step <= 4; step *= 2) { + int shift = step * 8; + long mask = step == 1 ? 0x00ff00ff00ff00ffL + : step == 2 ? 0x0000ffff0000ffffL : 0x00000000ffffffffL; + for (int tile = 0; tile < 8; tile += 2 * step) { + for (int j = 0; j < step; j++) { + long a = work[tile + j]; + long b = work[tile + j + step]; + work[tile + j] = (a & mask) | ((b & mask) << shift); + work[tile + j + step] = ((a >>> shift) & mask) | (b & ~mask); + } + } + } + for (int i = 0; i < 8; i++) { + LONGS.set(destination, offset + (column + i) * blockSize + laneBase, work[i]); + } + } + } + for (; column < fullBytes; column++) { + for (int lane = 0; lane < count; lane++) { + destination[offset + column * blockSize + lane] = vectors[first + lane].extraBits[column]; + } + } + // An odd group count leaves a high padding nibble; preserve it. + if ((groups & 1) != 0) { + for (int lane = 0; lane < count; lane++) { + int at = offset + fullBytes * blockSize + lane; + destination[at] = (byte) ((destination[at] & 0xf0) + | (vectors[first + lane].extraBits[fullBytes] & 15)); + } + } + for (int lane = 0; lane < count; lane++) { + var vector = vectors[first + lane]; + FusedASHLayout.writeLaneHeader(destination, offset, lane, dimension, bits, blockSize, + vector.scale, vector.offset, vector.landmark); + } + } +} diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/FusedASHLayout.java b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/FusedASHLayout.java index a101147fb..87a4eb367 100644 --- a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/FusedASHLayout.java +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/FusedASHLayout.java @@ -217,8 +217,13 @@ public static void packQuantizedVector( if (code == null || code.length < bytes) { throw new IllegalArgumentException("ASH projection code is missing or too short"); } - for (int group = 0; group < groups; group++) { - setPackedNibble(block, blockOffset, lane, group, blockSize, flatNibble(code, group)); + int fullBytes = groups >>> 1; + int destination = blockOffset + lane; + for (int i = 0; i < fullBytes; i++, destination += blockSize) { + block[destination] = code[i]; + } + if ((groups & 1) != 0) { + block[destination] = (byte) ((block[destination] & 0xF0) | (code[fullBytes] & 0x0F)); } } diff --git a/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestFusedASHBlockPacking.java b/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestFusedASHBlockPacking.java new file mode 100644 index 000000000..c97228248 --- /dev/null +++ b/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestFusedASHBlockPacking.java @@ -0,0 +1,100 @@ +/* + * 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 org.junit.Test; +import java.util.Arrays; +import java.util.Random; +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; + +public class TestFusedASHBlockPacking { + /** Independent oracle: the original nibble-by-nibble fused layout. */ + private static void reference(byte[] destination, int offset, int lane, + AsymmetricHashing.QuantizedVector vector, + int dimension, int bits, int blockSize) { + int groups = FusedASHLayout.codeGroups(dimension, bits); + for (int group = 0; group < groups; group++) { + int nibble = bits == 1 + ? FusedASHLayout.signNibbleFromWords(vector.binaryVector, group, dimension) + : FusedASHLayout.flatNibble(vector.extraBits, group); + FusedASHLayout.setPackedNibble(destination, offset, lane, group, blockSize, nibble); + } + FusedASHLayout.writeLaneHeader(destination, offset, lane, dimension, bits, blockSize, + vector.scale, vector.offset, vector.landmark); + } + + @Test + public void testPackingMatchesOriginalLayout() throws Exception { + var constructor = AsymmetricHashing.QuantizedVector.class.getDeclaredConstructor( + float.class, float.class, byte.class, long[].class, byte[].class); + constructor.setAccessible(true); + Random random = new Random(19); + int[] dimensions = new int[266]; + for (int i = 0; i < 257; i++) { + dimensions[i] = i + 1; + } + int[] large = {383, 384, 385, 767, 768, 769, 1536, 3072, 3073}; + System.arraycopy(large, 0, dimensions, 257, large.length); + int cases = 0; + for (int bits : new int[]{1, 2, 4}) { + for (int dimension : dimensions) { + for (int blockSize : new int[]{8, 16, 32}) { + var vectors = new AsymmetricHashing.QuantizedVector[blockSize + 3]; + for (int i = 0; i < vectors.length; i++) { + byte[] code = new byte[FusedASHLayout.canonicalCodeBytes(dimension, bits)]; + random.nextBytes(code); + long[] signs = new long[(dimension + 63) / 64]; + for (int j = 0; j < signs.length; j++) { + signs[j] = random.nextLong(); + } + vectors[i] = constructor.newInstance(random.nextFloat() * 3, + random.nextFloat() - 0.5f, (byte) random.nextInt(256), signs, code); + } + int length = FusedASHLayout.blockBytes(dimension, bits, blockSize); + int offset = 7; // Exercise unaligned long loads/stores and guard bytes. + for (int count = 0; count <= blockSize; count++) { + byte[] expected = new byte[length + 14]; + Arrays.fill(expected, (byte) 87); + Arrays.fill(expected, offset, offset + length, (byte) 0); + byte[] actual = expected.clone(); + for (int lane = 0; lane < count; lane++) { + reference(expected, offset, lane, vectors[3 + lane], + dimension, bits, blockSize); + } + FusedASHBlockPacker.pack(actual, offset, vectors, 3, count, + dimension, bits, blockSize); + assertArrayEquals("bits=" + bits + " dimension=" + dimension + + " blockSize=" + blockSize + " count=" + count, expected, actual); + cases++; + } + + // Single-vector packing must preserve other lanes and high padding nibbles. + byte[] expected = new byte[length + 14]; + random.nextBytes(expected); + byte[] actual = expected.clone(); + reference(expected, offset, blockSize - 1, vectors[0], dimension, bits, blockSize); + FusedASHLayout.packQuantizedVector(actual, offset, blockSize - 1, + vectors[0], dimension, bits, blockSize); + assertArrayEquals(expected, actual); + cases++; + } + } + } + assertEquals(49_476, cases); + } +} From 889eb5ab981cd2d2e49198f23a10083ac09d6e03 Mon Sep 17 00:00:00 2001 From: Ted Willke Date: Wed, 28 Oct 2026 22:16:03 +0000 Subject: [PATCH 082/243] Handle fresh Meson build directories Omit --wipe until a configured Meson tree exists so first builds work with older Meson releases. Preserve the existing compiler, dependency, tracing, configured-tree rebuild and library-publication policies. --- jvector-native/src/main/native/src/build_native_lib.sh | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/jvector-native/src/main/native/src/build_native_lib.sh b/jvector-native/src/main/native/src/build_native_lib.sh index 060b1b7a0..ea0c35c67 100755 --- a/jvector-native/src/main/native/src/build_native_lib.sh +++ b/jvector-native/src/main/native/src/build_native_lib.sh @@ -112,6 +112,12 @@ build_native() { rm -rf "${OUT_SO}" + # Older Meson versions only accept --wipe for an already configured build. + local setup_options=() + if [[ -f "${BUILD_DIR}/meson-private/coredata.dat" ]]; then + setup_options+=(--wipe) + fi + if [ "${ARCH}" == "${HOST_ARCH}" ]; then # ---- Native build ------------------------------------------------------ require_cmd g++ "sudo apt-get install -y g++" @@ -123,7 +129,7 @@ build_native() { fi meson setup "${BUILD_DIR}" "${NATIVE_DIR}" \ - --wipe \ + "${setup_options[@]}" \ --buildtype="${BUILDTYPE}" else # ---- Cross-compile build ----------------------------------------------- @@ -143,7 +149,7 @@ build_native() { require_cmd "${CROSS_TOOLCHAIN}" "${CROSS_INSTALL}" meson setup "${BUILD_DIR}" "${NATIVE_DIR}" \ - --wipe \ + "${setup_options[@]}" \ --cross-file "${CROSS_FILE}" \ --buildtype="${BUILDTYPE}" fi From 87d0b80ce249976c8573ec0527871bf301727c23 Mon Sep 17 00:00:00 2001 From: Ted Willke Date: Wed, 28 Oct 2026 22:16:03 +0000 Subject: [PATCH 083/243] Store heap-backed native float vectors directly Use the backing float array for Vector API stores instead of the segment store intrinsic. Preserve offsets and avoid the observed JDK 22 compiler failure without compiler exclusions. Add coverage for every float species and surrounding canaries. --- .../vector/NativeVectorUtilSupport.java | 4 +- .../jvector/vector/NativeVectorStoreTest.java | 63 +++++++++++++++++++ 2 files changed, 66 insertions(+), 1 deletion(-) create mode 100644 jvector-native/src/test/java/io/github/jbellis/jvector/vector/NativeVectorStoreTest.java 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 4f6b57650..2647b46f8 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 @@ -103,7 +103,9 @@ protected FloatVector fromVectorFloat(VectorSpecies SPEC, VectorFloat @Override protected void intoVectorFloat(FloatVector vector, VectorFloat v, int offset) { - vector.intoMemorySegment(((MemorySegmentVectorFloat) v).get(), v.offset(offset), ByteOrder.LITTLE_ENDIAN); + // MemorySegmentVectorFloat is heap-backed: use its array store intrinsic directly. + // This also avoids the intoMemorySegment compiler failure observed on JDK 22. + vector.intoArray(((MemorySegmentVectorFloat) v).array(), offset); } @Override diff --git a/jvector-native/src/test/java/io/github/jbellis/jvector/vector/NativeVectorStoreTest.java b/jvector-native/src/test/java/io/github/jbellis/jvector/vector/NativeVectorStoreTest.java new file mode 100644 index 000000000..85f49fb19 --- /dev/null +++ b/jvector-native/src/test/java/io/github/jbellis/jvector/vector/NativeVectorStoreTest.java @@ -0,0 +1,63 @@ +/* + * 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.cnative.LibraryLoader; +import java.util.Arrays; +import java.util.List; +import jdk.incubator.vector.FloatVector; +import jdk.incubator.vector.VectorSpecies; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assumptions.assumeTrue; + +/** Exercise the heap-backed store intrinsic on JDK 22 without compiler exclusions. */ +class NativeVectorStoreTest { + private static NativeVectorUtilSupport backend; + + @BeforeAll + static void loadNativeLibrary() { + String arch = System.getProperty("os.arch", ""); + assumeTrue(arch.equals("amd64") || arch.equals("x86_64")); + assumeTrue(LibraryLoader.loadJvector(), "Native jvector library not available"); + backend = new NativeVectorUtilSupport(); + } + + @Test + void storesPreserveOffsetsAndCanariesForEveryFloatSpecies() { + List> species = List.of( + FloatVector.SPECIES_64, FloatVector.SPECIES_128, FloatVector.SPECIES_256, FloatVector.SPECIES_512); + for (VectorSpecies shape : species) { + float[] values = new float[shape.length()]; + for (int i = 0; i < values.length; i++) + values[i] = i + .25f; + var vector = FloatVector.fromArray(shape, values, 0); + float[] destination = new float[shape.length() + 5]; + Arrays.fill(destination, -17f); + var output = new MemorySegmentVectorFloat(destination); + // Repeated stores exercise compiled Vector API code, where JDK 22 previously crashed. + for (int i = 0; i < 20_000; i++) + backend.intoVectorFloat(vector, output, 3); + for (int i = 0; i < destination.length; i++) { + float expected = i >= 3 && i < 3 + values.length ? values[i - 3] : -17f; + assertEquals(expected, destination[i], 0f); + } + } + } +} From 2b4095776dfcffe7362ee83a63a510997cf8b5bd Mon Sep 17 00:00:00 2001 From: Ted Willke Date: Wed, 28 Oct 2026 22:16:03 +0000 Subject: [PATCH 084/243] Share prepared ASH neighborhood scoring with graph decoding Extract immutable per-query projection, landmark and LUT state for scoring complete packed neighborhoods. Reuse preparation across graph layers and centralize one-bit packing correction. Validate query dimensions, layout arithmetic and buffer contracts before scoring. --- .../jvector/graph/disk/feature/FusedASH.java | 43 +----- .../jvector/quantization/ASHLutKernel.java | 5 + .../quantization/ASHNeighborhoodScorer.java | 135 ++++++++++++++++++ .../jvector/quantization/FusedASHDecoder.java | 87 ++++++----- .../jvector/quantization/FusedASHLayout.java | 57 +++++++- .../jvector/vector/VectorUtilSupport.java | 39 ++++- .../TestASHNeighborhoodScorer.java | 59 ++++++++ 7 files changed, 329 insertions(+), 96 deletions(-) create mode 100644 jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHNeighborhoodScorer.java create mode 100644 jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestASHNeighborhoodScorer.java diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/graph/disk/feature/FusedASH.java b/jvector-base/src/main/java/io/github/jbellis/jvector/graph/disk/feature/FusedASH.java index 29283484a..1c5a3b4b6 100644 --- a/jvector-base/src/main/java/io/github/jbellis/jvector/graph/disk/feature/FusedASH.java +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/graph/disk/feature/FusedASH.java @@ -210,7 +210,7 @@ private void packForFused(byte[] dest, int neighborIndex, AsymmetricHashing.Quan blockSize); if (ash.bitsPerDimension == 1) { - packOneBitForFused(dest, blockOffset, lane, vector); + FusedASHLayout.packForFused(dest, blockOffset, lane, vector, ash, blockSize); } else { // For 2-bit and 4-bit ASH, the canonical vector already stores the // C++ projection-mode adjusted offset used by FusedASH scoring. @@ -225,47 +225,6 @@ private void packForFused(byte[] dest, int neighborIndex, AsymmetricHashing.Quan } } - /** - * The regular 1-bit ASH vector keeps the legacy unadjusted offset, because - * its standalone scorer uses A(q - μ). FusedASH scores via Aq for every - * supported bit width, so we apply the projection-offset adjustment here. - */ - private void packOneBitForFused(byte[] dest, int blockOffset, int lane, AsymmetricHashing.QuantizedVector vector) { - int groups = FusedASHLayout.codeGroups(ash.quantizedDim, ash.bitsPerDimension); - for (int group = 0; group < groups; group++) { - int nibble = FusedASHLayout.signNibbleFromWords(vector.binaryVector, group, ash.quantizedDim); - FusedASHLayout.setPackedNibble(dest, blockOffset, lane, group, blockSize, nibble); - } - - int c = vector.landmark & 0xFF; - if (c >= ash.landmarkCount) { - throw new IllegalArgumentException("Invalid ASH landmark " + c + " for landmarkCount=" + ash.landmarkCount); - } - - float landmarkDot = dotOneBitProjectionCode(ash.landmarkProj[c], vector.binaryVector, ash.quantizedDim); - float adjustedOffset = vector.offset - vector.scale * landmarkDot; - - FusedASHLayout.writeLaneHeader( - dest, - blockOffset, - lane, - ash.quantizedDim, - ash.bitsPerDimension, - blockSize, - vector.scale, - adjustedOffset, - vector.landmark); - } - - private static float dotOneBitProjectionCode(float[] projected, long[] signWords, int quantizedDim) { - float sum = 0f; - for (int j = 0; j < quantizedDim; j++) { - boolean positive = ((signWords[j >>> 6] >>> (j & 63)) & 1L) != 0L; - sum += positive ? projected[j] : -projected[j]; - } - return sum; - } - private static void writeBytes(IndexWriter out, byte[] bytes, int length) throws IOException { out.write(bytes, 0, length); } diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHLutKernel.java b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHLutKernel.java index 2613eaf50..dbc8cd524 100644 --- a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHLutKernel.java +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHLutKernel.java @@ -55,6 +55,11 @@ void score(byte[] codes, int offset, int groups, int stride, int lane, int count else ASHLutScoring.score(codes, offset, groups, stride, lane, count, lut, out, outOffset); } + /** Whether the query requested accelerated execution; scalar selection must also disable native scoring. */ + boolean isSimd() { + return simd; + } + @Override public String toString() { return simd ? "SIMD ASH LUT (" + backend.getClass().getSimpleName() + ")" : "scalar ASH LUT"; } } diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHNeighborhoodScorer.java b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHNeighborhoodScorer.java new file mode 100644 index 000000000..451bd4b8a --- /dev/null +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHNeighborhoodScorer.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.quantization; + +import io.github.jbellis.jvector.vector.VectorUtilSupport; +import io.github.jbellis.jvector.vector.VectorizationProvider; +import io.github.jbellis.jvector.vector.types.VectorFloat; + +import java.util.Objects; + +/** + * Prepared query state for scoring serialized {@link FusedASHLayout} neighborhoods. + * + *

    Construction computes the query projection, landmark terms, and nibble LUT once. Each + * {@link #score} call then performs lookup, binary16 header decoding, landmark correction, and + * conversion to the graph's nonnegative DOT_PRODUCT similarity. The graph decoder and isolated + * benchmark share this implementation.

    + * + *

    Prepared state is immutable. Instances may be shared across threads with independent output + * arrays and immutable packed inputs. Scalar/Panama execution supports all model landmarks; + * a capable native provider handles C=1 with one call per entire neighborhood.

    + */ +public final class ASHNeighborhoodScorer { + private final int maxDegree; + private final int blockSize; + private final int blocks; + private final int dimensions; + private final int bits; + private final int groups; + private final int featureSize; + private final float[] lut; + private final float[] dotQMu; + private final VectorUtilSupport neighborhoodBackend; + private final ASHLutKernel kernel = new ASHLutKernel(); + + /** + * Prepares a query for neighborhoods with a fixed serialized shape. + * + * @param ash compressor describing the projection, bit width, and landmarks + * @param query original-dimensional query; callers apply any desired normalization + * @param maxDegree positive scored capacity, including padded graph lanes + * @param blockSize serialized block width: 8, 16, or 32 + * @throws IllegalArgumentException for a dimension mismatch or invalid shape + * @throws ArithmeticException if the serialized shape cannot fit a Java array + */ + public ASHNeighborhoodScorer(AsymmetricHashing ash, VectorFloat query, int maxDegree, int blockSize) { + this(ash, prepareQuery(ash, query, maxDegree, blockSize), maxDegree, blockSize); + } + + private static ASHScorer.QueryPrecompute prepareQuery( + AsymmetricHashing ash, VectorFloat query, int maxDegree, int blockSize) { + Objects.requireNonNull(ash, "ash"); + Objects.requireNonNull(query, "query"); + if (query.length() != ash.originalDimension) { + throw new IllegalArgumentException("ASH query dimension must be " + ash.originalDimension); + } + FusedASHLayout.featureSize(maxDegree, ash.quantizedDim, ash.bitsPerDimension, blockSize); + return new ASHScorer(ash).precomputeQuery(query); + } + + /** Reuses preparation already shared with upper-layer scoring in {@link FusedASHDecoder}. */ + ASHNeighborhoodScorer(AsymmetricHashing ash, ASHScorer.QueryPrecompute query, int maxDegree, int blockSize) { + FusedASHLayout.validateBitsPerDimension(ash.bitsPerDimension); + FusedASHLayout.validateBlockSize(blockSize); + this.maxDegree = maxDegree; + this.blockSize = blockSize; + this.dimensions = ash.quantizedDim; + this.bits = ash.bitsPerDimension; + this.groups = FusedASHLayout.codeGroups(dimensions, bits); + this.blocks = FusedASHLayout.blocksPerNode(maxDegree, blockSize); + this.featureSize = FusedASHLayout.featureSize(maxDegree, dimensions, bits, blockSize); + this.dotQMu = query.dotQMuByLandmark; + this.lut = new float[Math.multiplyExact(groups, 16)]; + FusedASHLayout.buildQueryLut(query.qProj, dimensions, bits, lut); + var backend = VectorizationProvider.getInstance().getVectorUtilSupport(); + this.neighborhoodBackend = + kernel.isSimd() && dotQMu.length == 1 && backend.supportsAshNeighborhoodScoring() ? backend : null; + } + + /** + * Scores the configured capacity, including padded neighbors, in serialized lane order. + * + *

    On failure, output contents are unspecified. The graph decoder invalidates its cached + * origin before this call, so partially written scores cannot be reused.

    + * + * @param packed complete neighborhood bytes in the constructor's layout; not modified + * @param out receives one similarity per configured lane; later elements remain unchanged + * @throws IndexOutOfBoundsException if either array is too small + * @throws IllegalStateException if a scored lane refers to an invalid landmark + */ + public void score(byte[] packed, float[] out) { + Objects.checkFromIndexSize(0, featureSize, packed.length); + Objects.checkFromIndexSize(0, maxDegree, out.length); + if (neighborhoodBackend != null) { + neighborhoodBackend.ashScoreNeighborhood(packed, groups, blockSize, maxDegree, lut, dotQMu[0], out); + return; + } + for (int block = 0; block < blocks; block++) { + int offset = FusedASHLayout.blockOffset(block, dimensions, bits, blockSize); + int base = block * blockSize; + int count = Math.min(blockSize, maxDegree - base); + kernel.score(packed, offset, groups, blockSize, 0, count, lut, out, base); + for (int lane = 0; lane < count; lane++) { + int landmark = FusedASHLayout.readLandmark(packed, offset, lane, dimensions, bits, blockSize); + if (landmark >= dotQMu.length) { + throw new IllegalStateException("Invalid FusedASH landmark: " + landmark); + } + out[base + lane] = ASHScorer.toSimilarity( + FusedASHLayout.readScale(packed, offset, lane, dimensions, bits, blockSize) * out[base + lane] + + dotQMu[landmark] + + FusedASHLayout.readOffset(packed, offset, lane, dimensions, bits, blockSize)); + } + } + } + + @Override + public String toString() { + Object implementation = neighborhoodBackend == null ? kernel : "native Highway whole-neighborhood FFM"; + return "ASHNeighborhoodScorer[" + implementation + ", blockSize=" + blockSize + ", capacity=" + maxDegree + "]"; + } +} diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/FusedASHDecoder.java b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/FusedASHDecoder.java index 5c099ffeb..08f711b07 100644 --- a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/FusedASHDecoder.java +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/FusedASHDecoder.java @@ -33,6 +33,9 @@ * existing fused-feature search API while avoiding one-neighbor-at-a-time decode work in the * hot graph-expansion loop.

    * + *

    Each decoder and its mutable scratch/cache are confined to one graph view/search thread. + * The underlying prepared neighborhood scorer may be shared only with separate scratch storage.

    + * *

    Scalar and SIMD execution share the same packed layout. The property * {@code jvector.ash.blockKernel=auto|scalar|simd} selects execution once per decoder; * forced SIMD fails at construction if the backend cannot provide it.

    @@ -67,8 +70,13 @@ public final class FusedASHDecoder implements ScoreFunction.ApproximateScoreFunc * typically by reading the FusedASH inline payload for {@code origin} into {@code dest}. */ public interface PackedNeighborhoods { + /** Replaces the complete serialized payload for {@code origin} in caller-owned storage. */ void readInto(int origin, byte[] dest); + + /** Returns the scored capacity, including padding, of each neighborhood. */ int maxDegree(); + + /** Returns the complete serialized byte count for the configured layout. */ int featureSize(); } @@ -81,27 +89,35 @@ public interface InlineSourceAccessor { AsymmetricHashing.QuantizedVector vector(FusedFeature.InlineSource source); } - private final AsymmetricHashing ash; private final PackedNeighborhoods packedNeighborhoods; private final Int2ObjectHashMap hierarchyCachedFeatures; private final InlineSourceAccessor inlineSourceAccessor; private final ASHScorer.ASHScoreFunction sourceScorer; private final int maxDegree; - private final int blockSize; - private final int blocksPerNode; - private final int quantizedDim; - private final int bitsPerDimension; private final byte[] neighborhoodScratch; private final float[] neighborScores; - private final float[] queryLut; - private final float[] dotQMuByLandmark; - private final ASHLutKernel kernel = new ASHLutKernel(); + private final ASHNeighborhoodScorer neighborhoodScorer; private int origin = -1; + /** + * Creates a query-local decoder and shares one projection between L0 and upper-layer scoring. + * + * @param packedNeighborhoods source of complete fused payloads with a fixed layout + * @param ash compressor that encoded the graph's ASH features + * @param hierarchyCachedFeatures canonical features available for direct/upper-layer scoring + * @param inlineSourceAccessor reads a canonical ASH vector from a cached feature + * @param query original-dimensional query, normalized by the caller when appropriate + * @param reusableNeighborhoodBytes decoder-owned scratch, at least the complete feature size + * @param reusableScores decoder-owned scratch, at least the source's maximum degree + * @param blockSize serialized block width: 8, 16, or 32 + * @param similarityFunction must be DOT_PRODUCT + * @throws IllegalArgumentException if dimensions, payload size, or scratch capacity disagree + * @throws UnsupportedOperationException for another similarity function + */ public FusedASHDecoder( PackedNeighborhoods packedNeighborhoods, AsymmetricHashing ash, @@ -118,7 +134,7 @@ public FusedASHDecoder( } this.packedNeighborhoods = Objects.requireNonNull(packedNeighborhoods, "packedNeighborhoods"); - this.ash = Objects.requireNonNull(ash, "ash"); + Objects.requireNonNull(ash, "ash"); this.hierarchyCachedFeatures = Objects.requireNonNull( hierarchyCachedFeatures, "hierarchyCachedFeatures" @@ -127,11 +143,10 @@ public FusedASHDecoder( inlineSourceAccessor, "inlineSourceAccessor" ); - // Upper-layer source scoring and L0 block scoring use the same Aq and - // values. Prepare them once, then build each scoring form. - var scorer = new ASHScorer(ash); - var preparedQuery = scorer.precomputeQuery(query); - this.sourceScorer = scorer.scoreFunctionFor(preparedQuery); + Objects.requireNonNull(query, "query"); + if (query.length() != ash.originalDimension) { + throw new IllegalArgumentException("ASH query dimension must be " + ash.originalDimension); + } FusedASHLayout.validateBitsPerDimension(ash.bitsPerDimension); FusedASHLayout.validateBlockSize(blockSize); @@ -141,10 +156,8 @@ public FusedASHDecoder( throw new IllegalArgumentException("maxDegree must be > 0"); } - this.blockSize = blockSize; - this.blocksPerNode = FusedASHLayout.blocksPerNode(maxDegree, blockSize); - this.quantizedDim = ash.quantizedDim; - this.bitsPerDimension = ash.bitsPerDimension; + int quantizedDim = ash.quantizedDim; + int bitsPerDimension = ash.bitsPerDimension; int expectedFeatureSize = FusedASHLayout.featureSize( maxDegree, @@ -167,10 +180,13 @@ public FusedASHDecoder( this.neighborhoodScratch = reusableNeighborhoodBytes; this.neighborScores = reusableScores; - int groups = FusedASHLayout.codeGroups(quantizedDim, bitsPerDimension); - this.queryLut = new float[groups * 16]; - this.dotQMuByLandmark = preparedQuery.dotQMuByLandmark; - FusedASHLayout.buildQueryLut(preparedQuery.qProj, quantizedDim, bitsPerDimension, queryLut); + // Upper-layer source scoring and L0 block scoring use the same Aq and + // values. Prepare them once, then build each scoring form. + var scorer = new ASHScorer(ash); + var preparedQuery = scorer.precomputeQuery(query); + this.sourceScorer = scorer.scoreFunctionFor(preparedQuery); + + this.neighborhoodScorer = new ASHNeighborhoodScorer(ash, preparedQuery, maxDegree, blockSize); } public static FusedASHDecoder newDecoder( @@ -253,30 +269,13 @@ public float similarityToNeighbor(int origin, int neighborIndex) { return neighborScores[neighborIndex]; } + /** Uses the same prepared scorer as the isolated benchmark; graph I/O stays in this decoder. */ private void scoreNeighborhood() { - for (int block = 0; block < blocksPerNode; block++) { - int blockOffset = FusedASHLayout.blockOffset(block, quantizedDim, bitsPerDimension, blockSize); - int laneBase = block * blockSize; - int lanes = Math.min(blockSize, maxDegree - laneBase); - - kernel.score(neighborhoodScratch, blockOffset, - FusedASHLayout.codeGroups(quantizedDim, bitsPerDimension), blockSize, - 0, lanes, queryLut, neighborScores, laneBase); - for (int lane = 0; lane < lanes; lane++) { - int landmark = FusedASHLayout.readLandmark(neighborhoodScratch, blockOffset, - lane, quantizedDim, bitsPerDimension, blockSize); - if (landmark >= dotQMuByLandmark.length) { - throw new IllegalStateException("Invalid FusedASH landmark: " + landmark); - } - neighborScores[laneBase + lane] = ASHScorer.toSimilarity(FusedASHLayout.readScale(neighborhoodScratch, - blockOffset, lane, quantizedDim, bitsPerDimension, blockSize) - * neighborScores[laneBase + lane] + dotQMuByLandmark[landmark] - + FusedASHLayout.readOffset(neighborhoodScratch, blockOffset, - lane, quantizedDim, bitsPerDimension, blockSize)); - } - } + neighborhoodScorer.score(neighborhoodScratch, neighborScores); } @Override - public String toString() { return "FusedASHDecoder[" + kernel + "]"; } + public String toString() { + return "FusedASHDecoder[" + neighborhoodScorer + "]"; + } } diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/FusedASHLayout.java b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/FusedASHLayout.java index 87a4eb367..27668a90f 100644 --- a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/FusedASHLayout.java +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/FusedASHLayout.java @@ -42,6 +42,9 @@ * This is deliberately graph-neighborhood-oriented: it supports block sizes * {@code 8}, {@code 16}, and {@code 32} without copying the IVF-specific B32 * addressing scheme.

    + * + *

    Block and neighborhood byte-size calculations use checked arithmetic. Shapes whose + * serialized storage cannot fit a Java array fail with {@link ArithmeticException}.

    */ public final class FusedASHLayout { private FusedASHLayout() {} @@ -88,16 +91,16 @@ public static int codeGroups(int quantizedDim, int bitsPerDimension) { throw new IllegalArgumentException("quantizedDim must be > 0"); } int groupDims = projectionDimsPerNibble(bitsPerDimension); - return (quantizedDim + groupDims - 1) / groupDims; + return (quantizedDim - 1) / groupDims + 1; } public static int canonicalCodeBytes(int quantizedDim, int bitsPerDimension) { - return (codeGroups(quantizedDim, bitsPerDimension) + 1) >>> 1; + return (codeGroups(quantizedDim, bitsPerDimension) - 1) / 2 + 1; } public static int blockBodyBytes(int quantizedDim, int bitsPerDimension, int blockSize) { validateBlockSize(blockSize); - return canonicalCodeBytes(quantizedDim, bitsPerDimension) * blockSize; + return Math.multiplyExact(canonicalCodeBytes(quantizedDim, bitsPerDimension), blockSize); } public static int blockHeaderBytes(int blockSize) { @@ -106,7 +109,7 @@ public static int blockHeaderBytes(int blockSize) { } public static int blockBytes(int quantizedDim, int bitsPerDimension, int blockSize) { - return blockBodyBytes(quantizedDim, bitsPerDimension, blockSize) + blockHeaderBytes(blockSize); + return Math.addExact(blockBodyBytes(quantizedDim, bitsPerDimension, blockSize), blockHeaderBytes(blockSize)); } public static int blocksPerNode(int maxDegree, int blockSize) { @@ -114,15 +117,18 @@ public static int blocksPerNode(int maxDegree, int blockSize) { throw new IllegalArgumentException("maxDegree must be > 0"); } validateBlockSize(blockSize); - return (maxDegree + blockSize - 1) / blockSize; + return (maxDegree - 1) / blockSize + 1; } public static int featureSize(int maxDegree, int quantizedDim, int bitsPerDimension, int blockSize) { - return blocksPerNode(maxDegree, blockSize) * blockBytes(quantizedDim, bitsPerDimension, blockSize); + return Math.multiplyExact(blocksPerNode(maxDegree, blockSize), blockBytes(quantizedDim, bitsPerDimension, blockSize)); } public static int blockOffset(int blockIndex, int quantizedDim, int bitsPerDimension, int blockSize) { - return blockIndex * blockBytes(quantizedDim, bitsPerDimension, blockSize); + if (blockIndex < 0) { + throw new IllegalArgumentException("blockIndex must be nonnegative"); + } + return Math.multiplyExact(blockIndex, blockBytes(quantizedDim, bitsPerDimension, blockSize)); } public static int scaleOffset(int blockOffset, int quantizedDim, int bitsPerDimension, int blockSize) { @@ -231,6 +237,43 @@ public static void packQuantizedVector( vector.scale, vector.offset, vector.landmark); } + /** + * Packs one canonical ASH vector into the graph's fused neighborhood representation. + * + *

    The one-bit canonical scorer uses {@code A(q - mu)}, whereas fused scoring uses + * {@code Aq} for every bit width. Only one-bit offsets therefore need the adjustment + * {@code offset - scale * dot(A(mu), code)}. Two/four-bit offsets already contain it. + * Keeping this conversion here makes benchmark fixtures use the graph writer's exact bytes.

    + * + * @param block destination containing a complete serialized block + * @param blockOffset byte offset of that block + * @param lane destination lane within the block + * @param vector canonical quantized vector, whose landmark belongs to {@code ash} + * @param ash compressor that owns the vector's projection and landmarks + * @param blockSize serialized block width: 8, 16, or 32 + * @throws IllegalArgumentException if the landmark or block shape is invalid + */ + public static void packForFused(byte[] block, int blockOffset, int lane, + AsymmetricHashing.QuantizedVector vector, + AsymmetricHashing ash, int blockSize) { + int landmark = vector.landmark & 0xFF; + if (landmark >= ash.landmarkCount) { + throw new IllegalArgumentException("Invalid ASH landmark: " + landmark); + } + packQuantizedVector(block, blockOffset, lane, vector, + ash.quantizedDim, ash.bitsPerDimension, blockSize); + if (ash.bitsPerDimension == 1) { + float landmarkDot = 0f; + for (int d = 0; d < ash.quantizedDim; d++) { + float projected = ash.landmarkProj[landmark][d]; + boolean positive = ((vector.binaryVector[d >>> 6] >>> (d & 63)) & 1L) != 0; + landmarkDot += positive ? projected : -projected; + } + writeLaneHeader(block, blockOffset, lane, ash.quantizedDim, 1, blockSize, + vector.scale, vector.offset - vector.scale * landmarkDot, vector.landmark); + } + } + public static void writeLaneHeader( byte[] block, int blockOffset, diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/vector/VectorUtilSupport.java b/jvector-base/src/main/java/io/github/jbellis/jvector/vector/VectorUtilSupport.java index 23fb182ec..dfe188daf 100644 --- a/jvector-base/src/main/java/io/github/jbellis/jvector/vector/VectorUtilSupport.java +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/vector/VectorUtilSupport.java @@ -101,6 +101,39 @@ default void ashLutScore(byte[] codes, int offset, int groups, int stride, ASHLutScoring.score(codes, offset, groups, stride, lane, count, lut, out, outOffset); } + /** + * Whether this provider implements complete FusedASH neighborhood scoring for one landmark. + * Query factories use this capability independently of the standalone LUT primitive. + * + * @return {@code true} when {@link #ashScoreNeighborhood} is implemented + */ + default boolean supportsAshNeighborhoodScoring() { return false; } + + /** + * Scores one complete C=1 FusedASH neighborhood, including all serialized blocks and headers. + * The result is {@code max(0, (1 + rawDotProduct) / 2)}, matching graph DOT_PRODUCT similarity. + * + *

    Inputs are read-only for the call; output storage must be distinct from {@code lut}. + * Calls may share immutable inputs across threads when their output arrays are separate. + * On failure, output contents are unspecified. Implementations validate sizes before native access.

    + * + * @param codes complete packed neighborhood, with full serialized storage for its final block + * @param groups positive nibble group count, including a possible partial final group + * @param blockSize serialized block width: 8, 16, or 32 + * @param capacity positive number of lanes to score, including graph padding + * @param lut query LUT with at least {@code 16 * groups} floats + * @param landmarkDot query dot product with the single landmark + * @param out receives {@code capacity} similarities; later elements are left unchanged + * @throws UnsupportedOperationException if this provider has no implementation + * @throws IllegalArgumentException for an invalid shape or aliased LUT/output + * @throws IndexOutOfBoundsException if an array is too small + * @throws IllegalStateException if a scored lane contains a nonzero landmark ID + */ + default void ashScoreNeighborhood(byte[] codes, int groups, int blockSize, int capacity, + float[] lut, float landmarkDot, float[] out) { + throw new UnsupportedOperationException("Native ASH neighborhood scoring is unavailable"); + } + /** Calculates the dot product of the given float arrays. */ float dotProduct(VectorFloat a, VectorFloat b); @@ -422,7 +455,7 @@ default boolean supportsAshMaskedLoad() { /** * Dot product between a matrix row and a dense vector. - * Used by ASH projection: y_i = . + * Used by ASH projection: {@code y_i = }. * * Contract: Arow.length == x.length. * Default is scalar; SIMD backends may override. @@ -439,7 +472,7 @@ default float ashDotRow(float[] Arow, float[] x) { /** * Computes maskedAdd for a single vector stored in packed *by-vector* form. * Returns: - * maskedAdd = + * {@code maskedAdd = } * * packedBits layout: * packedBits[packedBase + w] is 64-bit word w for this vector. @@ -484,7 +517,7 @@ default float ashMaskedAddAllWords( /** * Computes maskedAdd for a block slice of vectors stored in packed block-column-major form. * For each lane in [0, blockLen), writes: - * outMaskedAdd[lane] = + * {@code outMaskedAdd[lane] = } * packedBits layout: * packedBits[blockWordBase + w*blockSize + laneIndex] is 64-bit word w for that lane. * Default implementation is scalar bit-walk with a register accumulator per lane. diff --git a/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestASHNeighborhoodScorer.java b/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestASHNeighborhoodScorer.java new file mode 100644 index 000000000..d339797cf --- /dev/null +++ b/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestASHNeighborhoodScorer.java @@ -0,0 +1,59 @@ +/* + * 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 io.github.jbellis.jvector.graph.MockVectorValues; +import io.github.jbellis.jvector.vector.VectorizationProvider; +import io.github.jbellis.jvector.vector.types.VectorFloat; +import java.util.Random; +import org.junit.Test; + +import static org.junit.Assert.*; + +/** Public scorer construction rejects invalid shapes before projection or native access. */ +public class TestASHNeighborhoodScorer { + @Test + public void rejectsWrongQueryDimensionsAndInvalidCapacity() throws Exception { + var types = VectorizationProvider.getInstance().getVectorTypeSupport(); + var random = new Random(14932); + VectorFloat[] input = new VectorFloat[ 3 ]; + for (int i = 0; i < input.length; i++) { + float[] values = new float[64]; + for (int d = 0; d < values.length; d++) + values[d] = random.nextFloat() - .5f; + input[i] = types.createFloatVector(values); + } + var ash = AsymmetricHashing.initialize(MockVectorValues.fromValues(input), AsymmetricHashing.RANDOM, + AsymmetricHashing.HEADER_BITS + 33 * 4, 1, 4); + assertThrows(IllegalArgumentException.class, + () -> new ASHNeighborhoodScorer(ash, types.createFloatVector(63), 32, 32)); + assertThrows(IllegalArgumentException.class, + () -> new ASHNeighborhoodScorer(ash, types.createFloatVector(65), 32, 32)); + assertThrows(IllegalArgumentException.class, () -> new ASHNeighborhoodScorer(ash, input[0], 0, 32)); + assertThrows(IllegalArgumentException.class, () -> new ASHNeighborhoodScorer(ash, input[0], 32, 31)); + assertThrows(ArithmeticException.class, () -> new ASHNeighborhoodScorer(ash, input[0], Integer.MAX_VALUE, 32)); + } + + @Test + public void layoutSizesFailOnOverflowInsteadOfWrapping() { + assertEquals(1_073_741_824, FusedASHLayout.codeGroups(Integer.MAX_VALUE, 2)); + assertEquals(67_108_864, FusedASHLayout.blocksPerNode(Integer.MAX_VALUE, 32)); + assertThrows(ArithmeticException.class, () -> FusedASHLayout.blockBodyBytes(Integer.MAX_VALUE, 4, 32)); + assertThrows(ArithmeticException.class, () -> FusedASHLayout.featureSize(Integer.MAX_VALUE, 512, 4, 32)); + assertThrows(IllegalArgumentException.class, () -> FusedASHLayout.blockOffset(-1, 33, 4, 32)); + } +} From 1f57e5ce851a802d121cc7130b766d26d55c735f Mon Sep 17 00:00:00 2001 From: Ted Willke Date: Wed, 28 Oct 2026 22:16:03 +0000 Subject: [PATCH 085/243] Add Highway scoring for complete ASH neighborhoods Score C=1 neighborhoods in one FFM call, including header decoding and similarity conversion. Reuse AVX-512 lookup tables across B32 halves while retaining existing ISA dispatch, other block widths and partial tails. Keep the checked binding private, bound critical-call work, and use an ordinary call for larger inputs. Treat the new native symbol as optional so Java LUT fallback remains available with the pre-ASH native ABI. Cover shape errors, IEEE headers, output canaries and optional-symbol handling. --- .../jvector/vector/NativeASHKernels.java | 151 ++++++++++++++++ .../vector/NativeVectorUtilSupport.java | 19 ++ .../main/native/src/ash_neighborhood-inl.h | 170 ++++++++++++++++++ .../native/src/jvector_simd_kernel_list.h | 10 ++ .../main/native/src/jvector_simd_kernels.cpp | 3 + .../vector/NativeASHCompatibilityTest.java | 88 +++++++++ .../jvector/vector/TestASHNativeScoring.java | 110 ++++++++++++ 7 files changed, 551 insertions(+) create mode 100644 jvector-native/src/main/java/io/github/jbellis/jvector/vector/NativeASHKernels.java create mode 100644 jvector-native/src/main/native/src/ash_neighborhood-inl.h create mode 100644 jvector-native/src/test/java/io/github/jbellis/jvector/vector/NativeASHCompatibilityTest.java create mode 100644 jvector-tests/src/test/java/io/github/jbellis/jvector/vector/TestASHNativeScoring.java diff --git a/jvector-native/src/main/java/io/github/jbellis/jvector/vector/NativeASHKernels.java b/jvector-native/src/main/java/io/github/jbellis/jvector/vector/NativeASHKernels.java new file mode 100644 index 000000000..ec53b74f8 --- /dev/null +++ b/jvector-native/src/main/java/io/github/jbellis/jvector/vector/NativeASHKernels.java @@ -0,0 +1,151 @@ +/* + * 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 java.lang.foreign.Arena; +import java.lang.foreign.FunctionDescriptor; +import java.lang.foreign.Linker; +import java.lang.foreign.MemorySegment; +import java.lang.foreign.SymbolLookup; +import java.lang.invoke.MethodHandle; +import java.util.Objects; + +import static java.lang.foreign.ValueLayout.ADDRESS; +import static java.lang.foreign.ValueLayout.JAVA_FLOAT; +import static java.lang.foreign.ValueLayout.JAVA_INT; + +/** + * Checked, synchronous FFM access to the C=1 Highway neighborhood kernel. + * + *

    Keep this hand-written binding outside {@code cnative}, which is generated by jextract. + * Ordinary graph neighborhoods use a bounded critical downcall and access heap arrays directly. + * Larger inputs use one ordinary downcall with temporary native storage, allowing the JVM to + * reach safepoints during longer native work. Neither path retains pointers or calls Java.

    + * + *

    The ASH symbol is optional for compatibility with libraries predating this kernel. + * Capability detection leaves the provider's Java LUT path available when it is absent.

    + */ +final class NativeASHKernels { + private static final boolean TRACE_DOWNCALLS = Boolean.getBoolean("jextract.trace.downcalls"); + private static final String SYMBOL = "ash_score_neighborhood_c1_f32"; + private static final int HEADER_BYTES_PER_LANE = 5; + private static final int LUT_ENTRIES_PER_GROUP = 16; + + // Bound critical-call work independently of CPU model, clock speed, and graph degree. + // Charge masked/padded vector lanes as well as active lanes. This is a conservative + // work budget, not a measured timing guarantee or an optimized copying crossover. + static final int MAX_CRITICAL_LANE_GROUPS = 1 << 16; + + private static final MemorySegment ADDRESS_OF_KERNEL = SymbolLookup.loaderLookup().find(SYMBOL).orElse(null); + private static final FunctionDescriptor DESCRIPTOR = + FunctionDescriptor.of(JAVA_INT, ADDRESS, ADDRESS, JAVA_FLOAT, JAVA_INT, JAVA_INT, JAVA_INT, ADDRESS); + private static final MethodHandle CRITICAL_SCORE = ADDRESS_OF_KERNEL == null + ? null + : Linker.nativeLinker().downcallHandle(ADDRESS_OF_KERNEL, DESCRIPTOR, Linker.Option.critical(true)); + private static final MethodHandle ORDINARY_SCORE = + ADDRESS_OF_KERNEL == null ? null : Linker.nativeLinker().downcallHandle(ADDRESS_OF_KERNEL, DESCRIPTOR); + + private NativeASHKernels() {} + + /** + * Detect the optional ASH entry after the provider has loaded its supporting library. + * Older libraries can still provide their existing operations and the Java LUT fallback. + */ + static boolean isAvailable() { + return ADDRESS_OF_KERNEL != null; + } + + /** Validate all Java-side size and aliasing contracts before passing pointers to C++. */ + static void scoreNeighborhood( + byte[] codes, int groups, int blockSize, int capacity, float[] lut, float landmarkDot, float[] out) { + if (!isAvailable()) { + throw new UnsupportedOperationException("Native library does not provide " + SYMBOL); + } + Objects.requireNonNull(codes, "codes"); + Objects.requireNonNull(lut, "lut"); + Objects.requireNonNull(out, "out"); + if (groups <= 0 || capacity <= 0 || (blockSize != 8 && blockSize != 16 && blockSize != 32)) { + throw new IllegalArgumentException("Expected positive groups/capacity and blockSize in {8,16,32}"); + } + if (lut == out) { + throw new IllegalArgumentException("ASH output must not alias the read-only query LUT"); + } + long blockCount = (capacity - 1L) / blockSize + 1; + long packedBytes = blockCount * (((groups + 1L) / 2 + HEADER_BYTES_PER_LANE) * blockSize); + long lutFloats = groups * (long) LUT_ENTRIES_PER_GROUP; + if (packedBytes > codes.length || lutFloats > lut.length || capacity > out.length) { + throw new IndexOutOfBoundsException("ASH arrays are smaller than the configured neighborhood"); + } + + int status; + long vectorSlots = blockCount * Math.max(blockSize, 16); // Native kernels cap FP32 width at 16. + if ((long) groups * vectorSlots <= MAX_CRITICAL_LANE_GROUPS) { + // These heap segments are valid only for this synchronous, short leaf call. + status = invoke(CRITICAL_SCORE, MemorySegment.ofArray(codes), MemorySegment.ofArray(lut), landmarkDot, + groups, blockSize, capacity, MemorySegment.ofArray(out)); + } else { + status = scoreLargeNeighborhood( + codes, packedBytes, lut, lutFloats, landmarkDot, groups, blockSize, capacity, out); + } + checkStatus(status); + } + + private static int scoreLargeNeighborhood(byte[] codes, long packedBytes, float[] lut, long lutFloats, + float landmarkDot, int groups, int blockSize, int capacity, float[] out) { + try (Arena arena = Arena.ofConfined()) { + MemorySegment nativeCodes = arena.allocate(packedBytes); + MemorySegment nativeLut = arena.allocate(lutFloats * Float.BYTES, Float.BYTES); + MemorySegment nativeOut = arena.allocate((long) capacity * Float.BYTES, Float.BYTES); + MemorySegment.copy(MemorySegment.ofArray(codes), 0, nativeCodes, 0, packedBytes); + MemorySegment.copy(MemorySegment.ofArray(lut), 0, nativeLut, 0, lutFloats * Float.BYTES); + int status = + invoke(ORDINARY_SCORE, nativeCodes, nativeLut, landmarkDot, groups, blockSize, capacity, nativeOut); + if (status == 0) { + MemorySegment.copy(nativeOut, 0, MemorySegment.ofArray(out), 0, (long) capacity * Float.BYTES); + } + return status; + } + } + + private static int invoke(MethodHandle handle, MemorySegment codes, MemorySegment lut, float landmarkDot, + int groups, int blockSize, int capacity, MemorySegment out) { + if (TRACE_DOWNCALLS) { + System.out.printf("%s(%d, %d, %d)%n", SYMBOL, groups, blockSize, capacity); + } + try { + return (int) handle.invokeExact(codes, lut, landmarkDot, groups, blockSize, capacity, out); + } catch (RuntimeException | Error error) { + throw error; + } catch (Throwable error) { + throw new AssertionError("Unexpected checked exception from " + SYMBOL, error); + } + } + + private static void checkStatus(int status) { + // These values are the C ABI contract documented in jvector_simd_kernel_list.h. + switch (status) { + case 0: + return; + case 1: + throw new IllegalStateException("Invalid landmark in a C=1 FusedASH neighborhood"); + case 2: + throw new IllegalArgumentException("Invalid native ASH neighborhood shape"); + default: + throw new IllegalStateException("Unknown native ASH status: " + status); + } + } +} 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 2647b46f8..da754e645 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 @@ -91,6 +91,25 @@ public VectorFloat nvqSub(VectorFloat a, int aOffset, VectorFloat b, in return NativeNVQArithmetic.sub(a, aOffset, b, bOffset, length); } + @Override + public boolean supportsAshNeighborhoodScoring() { + return NativeASHKernels.isAvailable(); + } + + @Override + public String ashKernelDescription() { + String neighborhood = supportsAshNeighborhoodScoring() + ? "Highway native, ISA=" + getActiveIsa() + : "unavailable; using Java LUT"; + return super.ashKernelDescription() + "; C=1 whole-neighborhood=" + neighborhood; + } + + @Override + public void ashScoreNeighborhood(byte[] codes, int groups, int blockSize, int capacity, + float[] lut, float landmarkDot, float[] out) { + NativeASHKernels.scoreNeighborhood(codes, groups, blockSize, capacity, lut, landmarkDot, out); + } + @Override protected FloatVector fromVectorFloat(VectorSpecies SPEC, VectorFloat vector, int offset) { return FloatVector.fromMemorySegment(SPEC, ((MemorySegmentVectorFloat) vector).get(), vector.offset(offset), ByteOrder.LITTLE_ENDIAN); diff --git a/jvector-native/src/main/native/src/ash_neighborhood-inl.h b/jvector-native/src/main/native/src/ash_neighborhood-inl.h new file mode 100644 index 000000000..ea1daaaa9 --- /dev/null +++ b/jvector-native/src/main/native/src/ash_neighborhood-inl.h @@ -0,0 +1,170 @@ +/* + * 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. + */ + +#ifndef JVECTOR_ASH_NEIGHBORHOOD_INL_H +#define JVECTOR_ASH_NEIGHBORHOOD_INL_H + +// Included inside JV_ISA after Highway headers. The helpers are private to each +// ISA translation unit. The public entry is registered by the kernel X-macro. +namespace { +constexpr int kAshSuccess = 0; +constexpr int kAshInvalidLandmark = 1; +constexpr int kAshInvalidShape = 2; +constexpr size_t kAshHeaderBytesPerLane = 5; + +template HWY_INLINE auto AshLookup(D d, const float *lut, I indices) { + const hn::Rebind di; + if constexpr (hn::MaxLanes(d) == 16) { + return hn::TableLookupLanes(hn::LoadU(d, lut), hn::IndicesFromVec(d, hn::BitCast(di, indices))); + } else { + // AVX2/scalar do not have a register-wide 16-float permute. + return hn::GatherIndex(d, lut, hn::BitCast(di, indices)); + } +} + +template HWY_INLINE auto AshPromoteHalf(D d, V bits) { + const hn::Rebind df16; + auto value = hn::PromoteTo(d, hn::BitCast(df16, bits)); + if constexpr (hn::MaxLanes(d) < 8) { + // Highway's SSE software conversion assumes finite half values. Repair + // exponent 31 explicitly; AVX2/AVX-512 use hardware F16 conversion. + const hn::Rebind du; + const auto wide = hn::PromoteTo(du, bits); + const auto exponent = hn::And(wide, hn::Set(du, 0x7c00)); + const auto special = hn::Eq(exponent, hn::Set(du, 0x7c00)); + const auto sign = hn::ShiftLeft<16>(hn::And(wide, hn::Set(du, 0x8000))); + const auto mantissa = hn::ShiftLeft<13>(hn::And(wide, hn::Set(du, 0x03ff))); + const auto ieee = hn::BitCast(d, hn::Or(hn::Or(sign, mantissa), hn::Set(du, 0x7f800000))); + value = hn::IfThenElse(hn::RebindMask(d, special), ieee, value); + } + return value; +} + +template +HWY_INLINE int AshFinish(D d, const unsigned char *header, int blockSize, int lane, int count, float landmarkDot, + hn::VFromD dot, float *out) { + const hn::Rebind db; + const hn::Rebind dh; + const auto landmarks = hn::LoadN(db, header + 4 * blockSize + lane, count); + if (!hn::AllTrue(db, hn::Eq(landmarks, hn::Zero(db)))) { + return kAshInvalidLandmark; + } + // Header arrays are [scale: B x f16][offset: B x f16][landmark: B x u8]. + // Disk f16 values are big-endian; Highway's x86 loads interpret native byte order. + auto scaleBits = hn::LoadN(dh, reinterpret_cast(header) + lane, count); + auto offsetBits = hn::LoadN(dh, reinterpret_cast(header + 2 * blockSize) + lane, count); + scaleBits = hn::Or(hn::ShiftLeft<8>(scaleBits), hn::ShiftRight<8>(scaleBits)); + offsetBits = hn::Or(hn::ShiftLeft<8>(offsetBits), hn::ShiftRight<8>(offsetBits)); + const auto scale = AshPromoteHalf(d, scaleBits); + const auto offset = AshPromoteHalf(d, offsetBits); + const auto raw = hn::Add(hn::Add(hn::Mul(scale, dot), hn::Set(d, landmarkDot)), offset); + const auto similarity = hn::Mul(hn::Add(hn::Set(d, 1.0f), raw), hn::Set(d, 0.5f)); + const auto result = hn::IfThenElse(hn::IsNaN(similarity), similarity, hn::Max(hn::Zero(d), similarity)); + hn::StoreN(result, d, out, count); + return kAshSuccess; +} + +template +HWY_INLINE int AshBlock32(D d, const unsigned char *codes, const float *lut, int groups, float landmarkDot, + float *out) { + const hn::Rebind db; + const hn::Rebind du; + const auto mask = hn::Set(du, 15); + // Both halves of B32 share the loaded lookup tables. Four independent + // accumulators hide addition latency and avoid repeated LUT loads. + auto lo0 = hn::Zero(d), hi0 = hn::Zero(d); + auto lo1 = hn::Zero(d), hi1 = hn::Zero(d); + const auto *row = codes; + const auto *table = lut; + const auto *end = lut + static_cast(groups / 2) * 32; + for (; table < end; table += 32, row += 32) { + const auto c0 = hn::PromoteTo(du, hn::LoadU(db, row)); + const auto c1 = hn::PromoteTo(du, hn::LoadU(db, row + 16)); + const auto lowTable = hn::LoadU(d, table); + const auto highTable = hn::LoadU(d, table + 16); + const hn::Rebind di; + lo0 = hn::Add(lo0, hn::TableLookupLanes(lowTable, hn::IndicesFromVec(d, hn::BitCast(di, hn::And(c0, mask))))); + hi0 = hn::Add(hi0, + hn::TableLookupLanes(highTable, hn::IndicesFromVec(d, hn::BitCast(di, hn::ShiftRight<4>(c0))))); + lo1 = hn::Add(lo1, hn::TableLookupLanes(lowTable, hn::IndicesFromVec(d, hn::BitCast(di, hn::And(c1, mask))))); + hi1 = hn::Add(hi1, + hn::TableLookupLanes(highTable, hn::IndicesFromVec(d, hn::BitCast(di, hn::ShiftRight<4>(c1))))); + } + if (groups & 1) { + const auto c0 = hn::PromoteTo(du, hn::LoadU(db, row)); + const auto c1 = hn::PromoteTo(du, hn::LoadU(db, row + 16)); + lo0 = hn::Add(lo0, AshLookup(d, table, hn::And(c0, mask))); + lo1 = hn::Add(lo1, AshLookup(d, table, hn::And(c1, mask))); + } + const auto *header = codes + ((static_cast(groups) + 1) / 2) * 32; + int status = AshFinish(d, header, 32, 0, 16, landmarkDot, hn::Add(lo0, hi0), out); + return status ? status : AshFinish(d, header, 32, 16, 16, landmarkDot, hn::Add(lo1, hi1), out + 16); +} + +} // namespace + +// Synchronous, allocation-free leaf. Buffer sizes and non-aliasing are checked by +// NativeASHKernels before FFM. The ordinary FFM path uses the same entry for larger +// legal inputs. FP32 reductions may differ in order from the Java scalar oracle. +int ash_score_neighborhood_c1_f32(const unsigned char *HWY_RESTRICT codes, const float *HWY_RESTRICT lut, + float landmarkDot, int groups, int blockSize, int capacity, float *HWY_RESTRICT out) { + if (codes == nullptr || lut == nullptr || out == nullptr || groups <= 0 || capacity <= 0 || + (blockSize != 8 && blockSize != 16 && blockSize != 32)) { + return kAshInvalidShape; + } + const hn::CappedTag d; + const hn::Rebind db; + const hn::Rebind du; + const auto mask = hn::Set(du, 15); + const int lanes = static_cast(hn::Lanes(d)); + const size_t bodyBytes = ((static_cast(groups) + 1) / 2) * blockSize; + const size_t blockBytes = bodyBytes + kAshHeaderBytesPerLane * blockSize; + // Widen traversal arithmetic so the final base increment cannot overflow int. + for (size_t base = 0; base < static_cast(capacity); base += blockSize, codes += blockBytes) { + const int count = std::min(blockSize, capacity - static_cast(base)); + if constexpr (hn::MaxLanes(d) == 16) { + if (blockSize == 32 && count == 32) { + int status = AshBlock32(d, codes, lut, groups, landmarkDot, out + base); + if (status) + return status; + continue; + } + } + for (int lane = 0; lane < count; lane += lanes) { + const int active = std::min(lanes, count - lane); + auto lo = hn::Zero(d), hi = hn::Zero(d); + int g = 0; + for (; g + 1 < groups; g += 2) { + const auto c = + hn::PromoteTo(du, hn::LoadN(db, codes + static_cast(g / 2) * blockSize + lane, active)); + lo = hn::Add(lo, AshLookup(d, lut + static_cast(g) * 16, hn::And(c, mask))); + hi = hn::Add(hi, AshLookup(d, lut + static_cast(g + 1) * 16, hn::ShiftRight<4>(c))); + } + if (g < groups) { + const auto c = + hn::PromoteTo(du, hn::LoadN(db, codes + static_cast(g / 2) * blockSize + lane, active)); + lo = hn::Add(lo, AshLookup(d, lut + static_cast(g) * 16, hn::And(c, mask))); + } + int status = AshFinish(d, codes + bodyBytes, blockSize, lane, active, landmarkDot, hn::Add(lo, hi), + out + base + lane); + if (status) + return status; + } + } + return kAshSuccess; +} + +#endif // JVECTOR_ASH_NEIGHBORHOOD_INL_H diff --git a/jvector-native/src/main/native/src/jvector_simd_kernel_list.h b/jvector-native/src/main/native/src/jvector_simd_kernel_list.h index 03ec7f809..31babe849 100644 --- a/jvector-native/src/main/native/src/jvector_simd_kernel_list.h +++ b/jvector-native/src/main/native/src/jvector_simd_kernel_list.h @@ -31,6 +31,14 @@ #ifndef JVECTOR_SIMD_KERNEL_LIST_H #define JVECTOR_SIMD_KERNEL_LIST_H +// ASH C=1 ABI contract: +// codes: ceil(capacity/B) complete blocks, each ceil(groups/2)*B + 5*B bytes. +// lut: groups*16 read-only floats, out: capacity writable floats, no aliasing. +// groups/capacity are positive, B is 8/16/32. Inputs use FusedASHLayout bytes. +// Return 0 on success, 1 for a nonzero landmark, 2 for an invalid shape/pointer. +// Output may be partially written on error. No allocation, pointer retention, +// callbacks, or blocking are permitted: ordinary graph calls use critical FFM. + // clang-format off #define JVECTOR_SIMD_KERNEL_LIST \ /* Vector similarity */ \ @@ -60,6 +68,8 @@ KERNEL_ENTRY(void, calculate_partial_sums_dot_f32, (const float *codebook, int codebookIndex, size_t size, int clusterCount, const float *query, int queryOffset, float *partialSums), (codebook, codebookIndex, size, clusterCount, query, queryOffset, partialSums)) \ KERNEL_ENTRY(void, calculate_partial_sums_euclidean_f32, (const float *codebook, int codebookIndex, size_t size, int clusterCount, const float *query, int queryOffset, float *partialSums), (codebook, codebookIndex, size, clusterCount, query, queryOffset, partialSums)) \ KERNEL_ENTRY(void, calculate_partial_sums_self_magnitude_f32, (const float *codebook, int codebookIndex, size_t size, int clusterCount, float *partialSums), (codebook, codebookIndex, size, clusterCount, partialSums)) \ + /* Whole-neighborhood C=1 ASH, one downcall including headers and similarity. */ \ + KERNEL_ENTRY(int, ash_score_neighborhood_c1_f32, (const unsigned char *codes, const float *lut, float landmarkDot, int groups, int blockSize, int capacity, float *out), (codes, lut, landmarkDot, groups, blockSize, capacity, out)) \ /* NVQ kernels */ \ KERNEL_ENTRY(void, nvq_quantize_8bit, (const float *vector, size_t length, float alpha, float x0, float minValue, float maxValue, unsigned char *destination), (vector, length, alpha, x0, minValue, maxValue, destination)) \ KERNEL_ENTRY(float, nvq_loss, (const float *vector, size_t length, float alpha, float x0, float minValue, float maxValue, int nBits), (vector, length, alpha, x0, minValue, maxValue, nBits)) \ diff --git a/jvector-native/src/main/native/src/jvector_simd_kernels.cpp b/jvector-native/src/main/native/src/jvector_simd_kernels.cpp index 69c7fc062..1ba0908a8 100644 --- a/jvector-native/src/main/native/src/jvector_simd_kernels.cpp +++ b/jvector-native/src/main/native/src/jvector_simd_kernels.cpp @@ -18,6 +18,7 @@ #include #include #include +#include #include "jvector_simd.h" #include "hwy/highway.h" #include "assert_hwy_targets.h" @@ -381,6 +382,8 @@ HWY_INLINE float L2SquareDistance(const float *a, namespace JV_ISA { +#include "ash_neighborhood-inl.h" + HWY_FLATTEN float cosine_f32( const float *a, size_t aoffset, const float *b, size_t boffset, size_t length) { diff --git a/jvector-native/src/test/java/io/github/jbellis/jvector/vector/NativeASHCompatibilityTest.java b/jvector-native/src/test/java/io/github/jbellis/jvector/vector/NativeASHCompatibilityTest.java new file mode 100644 index 000000000..74ff784c7 --- /dev/null +++ b/jvector-native/src/test/java/io/github/jbellis/jvector/vector/NativeASHCompatibilityTest.java @@ -0,0 +1,88 @@ +/* + * 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.cnative.LibraryLoader; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.concurrent.TimeUnit; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; +import static org.junit.jupiter.api.Assumptions.assumeTrue; + +/** Optional ASH symbol detection must not make unrelated provider construction fail. */ +class NativeASHCompatibilityTest { + @Test + void packagedLibraryProvidesNeighborhoodScoring() { + String arch = System.getProperty("os.arch", ""); + assumeTrue(arch.equals("amd64") || arch.equals("x86_64")); + assumeTrue(LibraryLoader.loadJvector(), "Native jvector library not available"); + assertTrue(new NativeVectorUtilSupport().supportsAshNeighborhoodScoring()); + } + + @Test + void missingAshSymbolRetainsJavaLutScoring() throws Exception { + // A fresh JVM deliberately loads no native library. This tests an actual absent + // symbol without changing the parent JVM's already loaded library or using mocks. + Path output = Files.createTempFile("ash-missing-symbol-", ".log"); + Process child = null; + try { + child = new ProcessBuilder(Path.of(System.getProperty("java.home"), "bin", "java").toString(), + "--add-modules=jdk.incubator.vector", "--enable-native-access=ALL-UNNAMED", "-cp", + System.getProperty("java.class.path"), MissingSymbolProbe.class.getName()) + .redirectErrorStream(true) + .redirectOutput(output.toFile()) + .start(); + assertTrue(child.waitFor(30, TimeUnit.SECONDS), "Missing-symbol probe timed out"); + String diagnostics = Files.readString(output); + assertEquals(0, child.exitValue(), diagnostics); + assertTrue(diagnostics.contains("MISSING_ASH_FALLBACK_OK"), diagnostics); + } finally { + if (child != null && child.isAlive()) { + child.destroyForcibly().waitFor(); + } + Files.deleteIfExists(output); + } + } + + /** Child-process entry: do not initialize LibraryLoader or the global provider. */ + public static final class MissingSymbolProbe { + public static void main(String[] args) { + var backend = new NativeVectorUtilSupport(); + if (backend.supportsAshNeighborhoodScoring()) { + throw new AssertionError("ASH symbol unexpectedly available without a loaded native library"); + } + float[] lut = new float[16]; + java.util.Arrays.fill(lut, .25f); + float[] out = new float[8]; + backend.ashLutScore(new byte[8], 0, 1, 8, 0, 8, lut, out, 0); + for (float value : out) { + if (value != .25f) { + throw new AssertionError("Existing Java LUT scorer failed"); + } + } + try { + backend.ashScoreNeighborhood(new byte[48], 1, 8, 8, lut, 0f, out); + throw new AssertionError("Unavailable native kernel accepted a call"); + } catch (UnsupportedOperationException expected) { + // Capability is optional, and direct calls report its absence clearly. + } + System.out.println("MISSING_ASH_FALLBACK_OK"); + } + } +} diff --git a/jvector-tests/src/test/java/io/github/jbellis/jvector/vector/TestASHNativeScoring.java b/jvector-tests/src/test/java/io/github/jbellis/jvector/vector/TestASHNativeScoring.java new file mode 100644 index 000000000..f533ba1d6 --- /dev/null +++ b/jvector-tests/src/test/java/io/github/jbellis/jvector/vector/TestASHNativeScoring.java @@ -0,0 +1,110 @@ +/* + * 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.quantization.ASHScorer; +import io.github.jbellis.jvector.quantization.FusedASHLayout; +import java.util.Arrays; +import java.util.Random; +import org.junit.Test; + +import static org.junit.Assert.*; + +/** Native array contracts, layout tails, IEEE headers, and critical/ordinary FFM agreement. */ +public class TestASHNativeScoring { + @Test + public void nativeNeighborhoodMatchesDecodedLutForWidthsTailsAndHeaders() { + var backend = VectorizationProvider.getInstance().getVectorUtilSupport(); + org.junit.Assume.assumeTrue(backend.supportsAshNeighborhoodScoring()); + var random = new Random(527399); + for (int groups : new int[] {1, 2, 7, 8, 9, 16, 17, 33, 65, 128, 256, 512, 1536}) { + float[] lut = new float[groups * 16]; + for (int i = 0; i < lut.length; i++) lut[i] = random.nextFloat() - .5f; + for (int blockSize : new int[] {8, 16, 32}) { + for (int capacity : new int[] {1, 7, 23, 32, 37, 64, 128, 129}) { + // Four bits/dimension makes quantizedDim equal to the group count. + byte[] codes = new byte[FusedASHLayout.featureSize(capacity, groups, 4, blockSize)]; + random.nextBytes(codes); + for (int lane = 0; lane < capacity; lane++) { + int offset = FusedASHLayout.blockOffset(lane / blockSize, groups, 4, blockSize); + FusedASHLayout.writeLaneHeader(codes, offset, lane % blockSize, groups, 4, blockSize, + random.nextFloat(), random.nextFloat() - .5f, (byte) 0); + } + float landmarkDot = random.nextFloat() - .5f; + float[] actual = new float[capacity + 1]; + actual[capacity] = Float.NaN; + backend.ashScoreNeighborhood(codes, groups, blockSize, capacity, lut, landmarkDot, actual); + for (int lane = 0; lane < capacity; lane++) { + int offset = FusedASHLayout.blockOffset(lane / blockSize, groups, 4, blockSize); + float expected = ASHScorer.toSimilarity(FusedASHLayout.scoreLane( + codes, offset, lane % blockSize, groups, 4, blockSize, lut, new float[] {landmarkDot})); + assertEquals("groups=" + groups + " B=" + blockSize + " capacity=" + capacity + " lane=" + lane, + expected, actual[lane], 3e-5f + 3e-5f * Math.abs(expected)); + } + assertTrue(Float.isNaN(actual[capacity])); + int header = FusedASHLayout.blockOffset(0, groups, 4, blockSize); + // Isolate half conversion from cancellation and reduction-order error. + float[] headerLut = new float[lut.length]; + Arrays.fill(headerLut, .25f); + for (float scale : new float[] {Float.NaN, Float.POSITIVE_INFINITY, Float.NEGATIVE_INFINITY, 0f, + -0f, 0x1.0p-24f, 0x1.0p-14f, 65504f}) { + FusedASHLayout.writeLaneHeader(codes, header, 0, groups, 4, blockSize, scale, 0f, (byte) 0); + backend.ashScoreNeighborhood( + codes, groups, blockSize, capacity, headerLut, landmarkDot, actual); + float expected = ASHScorer.toSimilarity(FusedASHLayout.scoreLane( + codes, header, 0, groups, 4, blockSize, headerLut, new float[] {landmarkDot})); + if (Float.isNaN(expected)) + assertTrue(Float.isNaN(actual[0])); + else + assertEquals("scale=" + scale + " groups=" + groups, expected, actual[0], + Float.isInfinite(expected) ? 0f : 3e-5f + 3e-5f * Math.abs(expected)); + } + FusedASHLayout.writeLaneHeader(codes, header, 0, groups, 4, blockSize, 1f, 0f, (byte) 1); + assertThrows(IllegalStateException.class, + () + -> backend.ashScoreNeighborhood( + codes, groups, blockSize, capacity, lut, landmarkDot, actual)); + } + } + } + } + + @Test + public void nativeEntryRejectsInvalidShapesBeforeFFM() { + var backend = VectorizationProvider.getInstance().getVectorUtilSupport(); + org.junit.Assume.assumeTrue(backend.supportsAshNeighborhoodScoring()); + var codes = new byte[192]; // one code row + five header bytes per lane, B32 + var lut = new float[16]; + var out = new float[32]; + assertThrows( + IllegalArgumentException.class, () -> backend.ashScoreNeighborhood(codes, 0, 32, 32, lut, 0f, out)); + assertThrows( + IllegalArgumentException.class, () -> backend.ashScoreNeighborhood(codes, 1, 31, 32, lut, 0f, out)); + assertThrows(IndexOutOfBoundsException.class, + () -> backend.ashScoreNeighborhood(new byte[0], 1, 32, 32, lut, 0f, out)); + assertThrows(IndexOutOfBoundsException.class, + () -> backend.ashScoreNeighborhood(codes, 1, 32, 32, new float[15], 0f, out)); + assertThrows(IndexOutOfBoundsException.class, + () -> backend.ashScoreNeighborhood(codes, 1, 32, 32, lut, 0f, new float[31])); + assertThrows(IndexOutOfBoundsException.class, + () -> backend.ashScoreNeighborhood(codes, Integer.MAX_VALUE, 32, 32, lut, 0f, out)); + float[] shared = new float[32]; + assertThrows(IllegalArgumentException.class, + () -> backend.ashScoreNeighborhood(codes, 1, 32, 32, shared, 0f, shared)); + assertThrows(NullPointerException.class, () -> backend.ashScoreNeighborhood(null, 1, 32, 32, lut, 0f, out)); + } +} From 7f6cf1c332b0648b1df36a4f33b202d9a86f6ba3 Mon Sep 17 00:00:00 2001 From: Ted Willke Date: Wed, 28 Oct 2026 22:16:03 +0000 Subject: [PATCH 086/243] Benchmark isolated ASH neighborhood full scans Add an opt-in DistancesASH mode that replays the graph's packed layout through the production scorer. Separate fixtures, independent validation and the bounded worker pipeline. Prepare each query once per full scan and include scheduling, checksum consumption, pipeline fill and drain in timing. Validate fixture boundaries and input options, identify the actual backend, and reject mislabeled native fallback comparisons. Keep machine and JDK selection in experiment launch settings. --- .../example/ASHNeighborhoodBenchmark.java | 195 +++++++++++ .../example/ASHNeighborhoodFixture.java | 312 ++++++++++++++++++ .../example/ASHNeighborhoodPipeline.java | 208 ++++++++++++ .../example/ASHNeighborhoodValidator.java | 82 +++++ .../jbellis/jvector/example/DistancesASH.java | 10 +- .../example/TestASHNeighborhoodBenchmark.java | 203 ++++++++++++ 6 files changed, 1008 insertions(+), 2 deletions(-) create mode 100644 jvector-examples/src/main/java/io/github/jbellis/jvector/example/ASHNeighborhoodBenchmark.java create mode 100644 jvector-examples/src/main/java/io/github/jbellis/jvector/example/ASHNeighborhoodFixture.java create mode 100644 jvector-examples/src/main/java/io/github/jbellis/jvector/example/ASHNeighborhoodPipeline.java create mode 100644 jvector-examples/src/main/java/io/github/jbellis/jvector/example/ASHNeighborhoodValidator.java create mode 100644 jvector-tests/src/test/java/io/github/jbellis/jvector/example/TestASHNeighborhoodBenchmark.java diff --git a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/ASHNeighborhoodBenchmark.java b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/ASHNeighborhoodBenchmark.java new file mode 100644 index 000000000..b8553786a --- /dev/null +++ b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/ASHNeighborhoodBenchmark.java @@ -0,0 +1,195 @@ +/* + * 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; + +import io.github.jbellis.jvector.quantization.ASHNeighborhoodScorer; +import io.github.jbellis.jvector.vector.VectorizationProvider; +import io.github.jbellis.jvector.vector.types.VectorFloat; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Locale; +import java.util.Objects; +import java.util.Random; + +/** + * DistancesASH entry point for isolated full scans through the production neighborhood scorer. + * Fixtures, the independent oracle, and the pipelined workload have separate owners. The timed + * paths include exactly one preparation per complete scan; input loading and graph I/O are untimed. + */ +final class ASHNeighborhoodBenchmark { + private static volatile double sink; + + private ASHNeighborhoodBenchmark() {} + + static void run(String[] args) throws IOException { + String filename = System.getProperty("jvector.bench.neighborhoodFixture"); + if (filename == null) + throw new IllegalArgumentException("Set -Djvector.bench.neighborhoodFixture=/path/to/fixture.ashn"); + Path path = Path.of(filename); + ASHNeighborhoodFixture fixture; + if (Files.exists(path)) + fixture = ASHNeighborhoodFixture.read(path); + else { + if (args.length != 2 && args.length != 3) + throw new IllegalArgumentException( + "Creating a fixture requires base.fvecs query.fvecs [ground-truth.ivecs]"); + fixture = ASHNeighborhoodFixture.create(args[0], args[1]); + ASHNeighborhoodFixture.write(path, fixture); + } + checkSetting("jvector.ash.bitsPerDimension", fixture.ash.bitsPerDimension); + checkSetting("jvector.ash.quantizedDimensions", fixture.ash.quantizedDim); + checkSetting("jvector.ash.landmarkCount", 1); + checkSetting("jvector.bench.neighborhoodSize", fixture.capacity); + checkSetting("jvector.bench.blockSize", fixture.blockSize); + boolean scaling = Boolean.getBoolean("jvector.bench.neighborhood-scaling"); + int threads = positive("jvector.bench.scoringThreads", 1); + if (!scaling && threads != 1) + throw new IllegalArgumentException("Multiple scoring threads require neighborhood-scaling=true"); + int queryIndex = Integer.parseInt(System.getProperty("jvector.bench.queryIndex", "0")); + Objects.checkIndex(queryIndex, fixture.queries.size()); + int passes = positive("jvector.bench.scoringPasses", 5); + if (System.getProperty("jvector.bench.neighborhoodsPerQuery") != null) { + throw new IllegalArgumentException( + "A full scan scores every saved neighborhood; remove neighborhoodsPerQuery"); + } + double seconds = seconds("jvector.bench.scoringMinSeconds", .5); + double warmup = seconds("jvector.bench.scoringWarmupSeconds", 1); + String access = System.getProperty("jvector.bench.neighborhoodAccess", "sequential"); + int[] order = accessOrder(fixture.neighborhoods.length, access); + VectorFloat query = fixture.queries.get(queryIndex); + var backend = VectorizationProvider.getInstance().getVectorUtilSupport(); + // A requested native comparison must fail clearly rather than time the Java fallback. + if ("native".equals(System.getProperty("jvector.vectorization_provider", "").trim().toLowerCase(Locale.ROOT)) + && !"scalar".equals( + System.getProperty("jvector.ash.blockKernel", "auto").trim().toLowerCase(Locale.ROOT)) + && !backend.supportsAshNeighborhoodScoring()) { + throw new IllegalStateException( + "Native ASH comparison requested, but the library lacks the neighborhood kernel"); + } + System.out.printf(Locale.ROOT, + "Neighborhood benchmark: java=%s, C=1, singleQuery=true, bits=%d, dimensions=%d, capacity=%d, blockSize=%d%n", + Runtime.version(), fixture.ash.bitsPerDimension, + fixture.ash.quantizedDim, fixture.capacity, fixture.blockSize); + System.out.printf(Locale.ROOT, + "Fixture: sha256=%s, queries=%d, queryIndex=%d, baseVectors=%d, neighborhoods=%d, packedBytes=%d, access=%s%n", + ASHNeighborhoodFixture.sha256(path), fixture.queries.size(), queryIndex, fixture.baseCount, + order.length, (long) order.length * fixture.neighborhoods[0].length, access); + System.out.printf("Backend: %s; %s%n", backend.getClass().getSimpleName(), backend.ashKernelDescription()); + var scorer = new ASHNeighborhoodScorer(fixture.ash, query, fixture.capacity, fixture.blockSize); + System.out.println("Scorer: " + scorer); + ASHNeighborhoodValidator.validate(fixture); + if (scaling) { + runPipelinedScans(fixture, query, order, threads, passes, warmup, seconds); + return; + } + System.out.println( + "Full scan: query preparation once per scan, all neighborhoods, headers, similarity conversion and checksum consumption are timed. Loading, packing, graph traversal and I/O are outside timing."); + float[] scores = new float[fixture.capacity]; + Runnable fullScan = () -> { + var current = new ASHNeighborhoodScorer(fixture.ash, query, fixture.capacity, fixture.blockSize); + double checksum = 0; + for (int n : order) { + current.score(fixture.neighborhoods[n], scores); + for (float score : scores) checksum += score; + } + sink = checksum; + }; + measure(fullScan, warmup); + System.out.println( + "mode,pass,ns_per_scan,ns_per_neighborhood,base_vectors_per_second,scored_lanes_per_second,checksum"); + for (int pass = 1; pass <= passes; pass++) { + double ns = measure(fullScan, seconds); + System.out.printf(Locale.ROOT, "full_scan,%d,%.3f,%.3f,%.3f,%.3f,%.9f%n", pass, ns, ns / order.length, + fixture.baseCount * 1e9 / ns, order.length * (double) fixture.capacity * 1e9 / ns, sink); + } + } + + private static void runPipelinedScans(ASHNeighborhoodFixture fixture, VectorFloat query, int[] order, + int threads, int passes, double warmup, double seconds) { + System.out.printf(Locale.ROOT, + "Strong scaling: workers=%d, fixedNeighborhoodsPerSweep=%d, fixedScoredLanesPerSweep=%d; one shared query, disjoint neighborhood partitions%n", + threads, order.length, (long) order.length * fixture.capacity); + System.out.println( + "Pipelined full scans: prepare each query once while workers score preceding scans. At most two scans are in flight. Preparation, submission, completion, checksum consumption, fill and drain are timed. Persistent-worker creation is outside timing; all threads share the stated CPU affinity."); + try (var workload = new ASHNeighborhoodPipeline(fixture, order, threads)) { + workload.measure(query, warmup); + System.out.println( + "workers,pass,ns_per_scan,ns_per_neighborhood,base_vectors_per_second,scored_lanes_per_second,ns_preparation_per_scan,checksum"); + for (int pass = 1; pass <= passes; pass++) { + ASHNeighborhoodPipeline.Timing timing = workload.measure(query, seconds); + double nanos = timing.nanosPerScan; + System.out.printf(Locale.ROOT, "%d,%d,%.3f,%.3f,%.3f,%.3f,%.3f,%.9f%n", threads, pass, nanos, + nanos / order.length, fixture.baseCount * 1e9 / nanos, + order.length * (double) fixture.capacity * 1e9 / nanos, timing.preparationNanosPerScan, + timing.checksum); + } + } + } + + private static double measure(Runnable task, double seconds) { + long start = System.nanoTime(), elapsed; + long count = 0; + do { + task.run(); + count++; + elapsed = System.nanoTime() - start; + } while (elapsed < seconds * 1e9); + return (double) elapsed / count; + } + + static int[] accessOrder(int count, String access) { + if (!access.equals("sequential") && !access.equals("shuffled")) + throw new IllegalArgumentException("neighborhoodAccess must be sequential or shuffled"); + int[] order = new int[count]; + for (int i = 0; i < count; i++) order[i] = i; + if (access.equals("shuffled")) { + Random random = new Random(87342); + for (int i = count - 1; i > 0; i--) { + int j = random.nextInt(i + 1), swap = order[i]; + order[i] = order[j]; + order[j] = swap; + } + } + return order; + } + + static int positive(String name, int fallback) { + int value; + try { + value = Integer.parseInt(System.getProperty(name, Integer.toString(fallback))); + } catch (NumberFormatException error) { + throw new IllegalArgumentException(name + " must be an integer", error); + } + if (value <= 0) + throw new IllegalArgumentException(name + " must be positive"); + return value; + } + + private static double seconds(String name, double fallback) { + double value = Double.parseDouble(System.getProperty(name, Double.toString(fallback))); + if (!Double.isFinite(value) || value <= 0) + throw new IllegalArgumentException(name + " must be finite and positive"); + return value; + } + + static void checkSetting(String name, int expected) { + String value = System.getProperty(name); + if (value != null && Integer.parseInt(value) != expected) + throw new IllegalArgumentException(name + " disagrees with saved fixture: " + expected); + } +} diff --git a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/ASHNeighborhoodFixture.java b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/ASHNeighborhoodFixture.java new file mode 100644 index 000000000..bc39af030 --- /dev/null +++ b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/ASHNeighborhoodFixture.java @@ -0,0 +1,312 @@ +/* + * 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; + +import io.github.jbellis.jvector.disk.ByteBufferIndexWriter; +import io.github.jbellis.jvector.disk.ByteBufferReader; +import io.github.jbellis.jvector.graph.ListRandomAccessVectorValues; +import io.github.jbellis.jvector.graph.disk.OnDiskGraphIndex; +import io.github.jbellis.jvector.quantization.ASHVectors; +import io.github.jbellis.jvector.quantization.AsymmetricHashing; +import io.github.jbellis.jvector.quantization.FusedASHLayout; +import io.github.jbellis.jvector.vector.VectorUtil; +import io.github.jbellis.jvector.vector.VectorizationProvider; +import io.github.jbellis.jvector.vector.types.VectorFloat; +import java.io.BufferedInputStream; +import java.io.BufferedOutputStream; +import java.io.DataInputStream; +import java.io.DataOutputStream; +import java.io.IOException; +import java.io.OutputStream; +import java.nio.BufferUnderflowException; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.DigestInputStream; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; + +/** + * Untimed fixture creation and replay for the production ASH neighborhood layout. + * + *

    Version 1 stores big-endian ints/floats: magic, version, capacity, block width, base count, + * model byte count and model, query count and original-dimensional queries, then neighborhood + * count, bytes per neighborhood, and packed payloads. fvecs input remains little-endian. + * Model metadata and lengths are checked before allocating dimensions or payload arrays.

    + * + *

    Fixture bytes and query vectors are owned by the fixture and treated as immutable during + * replay. Queries are normalized on creation, matching the inputs passed to the graph scorer. + * The final neighborhood is padded to capacity and remains part of the scored work.

    + */ +final class ASHNeighborhoodFixture { + private static final int MAGIC = 0x4153484e; + private static final int VERSION = 1; + private static final int MAX_FVECS_DIMENSION = 100_000; + private static final int FIXED_BYTES = 9 * Integer.BYTES; + + final AsymmetricHashing ash; + final List> queries; + final byte[][] neighborhoods; + final int capacity; + final int blockSize; + final int baseCount; + + ASHNeighborhoodFixture(AsymmetricHashing ash, List> queries, byte[][] neighborhoods, int capacity, + int blockSize, int baseCount) { + this.ash = Objects.requireNonNull(ash, "ash"); + if (ash.landmarkCount != 1) { + throw new IllegalArgumentException("Neighborhood benchmark requires C=1"); + } + FusedASHLayout.validateBitsPerDimension(ash.bitsPerDimension); + FusedASHLayout.validateBlockSize(blockSize); + if (capacity < 1 || baseCount < 1 || queries.isEmpty()) { + throw new IllegalArgumentException("Empty fixture"); + } + if (neighborhoods.length != (baseCount - 1) / capacity + 1) { + throw new IllegalArgumentException("Neighborhood count mismatch"); + } + int bytes = FusedASHLayout.featureSize(capacity, ash.quantizedDim, ash.bitsPerDimension, blockSize); + for (byte[] neighborhood : neighborhoods) { + if (neighborhood.length != bytes) { + throw new IllegalArgumentException("Payload size mismatch"); + } + } + for (VectorFloat query : queries) { + if (query.length() != ash.originalDimension) { + throw new IllegalArgumentException("Query dimension mismatch"); + } + } + this.queries = List.copyOf(queries); + this.neighborhoods = neighborhoods; + this.capacity = capacity; + this.blockSize = blockSize; + this.baseCount = baseCount; + } + + static ASHNeighborhoodFixture create(String baseFile, String queryFile) throws IOException { + int capacity = ASHNeighborhoodBenchmark.positive("jvector.bench.neighborhoodSize", 32); + int blockSize = + ASHNeighborhoodBenchmark.positive("jvector.bench.blockSize", FusedASHLayout.chooseBlockSize(capacity)); + FusedASHLayout.validateBlockSize(blockSize); + ASHNeighborhoodBenchmark.checkSetting("jvector.ash.landmarkCount", 1); + List> base = + readFvecs(baseFile, ASHNeighborhoodBenchmark.positive("jvector.bench.maxVectors", 8192)); + List> queries = + readFvecs(queryFile, ASHNeighborhoodBenchmark.positive("jvector.bench.maxQueries", 8)); + if (base.isEmpty() || queries.isEmpty()) { + throw new IllegalArgumentException("Empty vector input"); + } + int dimensions = base.get(0).length(); + for (VectorFloat query : queries) { + if (query.length() != dimensions) { + throw new IllegalArgumentException("Base/query dimensions differ"); + } + VectorUtil.l2normalize(query); + } + int bits = ASHNeighborhoodBenchmark.positive("jvector.ash.bitsPerDimension", 4); + FusedASHLayout.validateBitsPerDimension(bits); + int projected = ASHNeighborhoodBenchmark.positive("jvector.ash.quantizedDimensions", dimensions / 2); + if (projected > dimensions) { + throw new IllegalArgumentException("Projected dimensions exceed original dimensions"); + } + var values = new ListRandomAccessVectorValues(base, dimensions); + var ash = AsymmetricHashing.initialize(values, DistancesASH.parseOptimizerFromProperty(), + Math.addExact(AsymmetricHashing.HEADER_BITS, Math.multiplyExact(projected, bits)), 1, bits); + ASHVectors encoded = (ASHVectors) ash.encodeAll(values); + int count = (base.size() - 1) / capacity + 1; + byte[][] packed = new byte[count][FusedASHLayout.featureSize(capacity, projected, bits, blockSize)]; + for (int i = 0; i < base.size(); i++) { + int neighbor = i % capacity; + int offset = FusedASHLayout.blockOffset(neighbor / blockSize, projected, bits, blockSize); + FusedASHLayout.packForFused( + packed[i / capacity], offset, neighbor % blockSize, encoded.get(i), ash, blockSize); + } + return new ASHNeighborhoodFixture(ash, queries, packed, capacity, blockSize, base.size()); + } + + /** Read only the requested prefix, rejecting truncated, non-finite, or inconsistent vectors. */ + static List> readFvecs(String filename, int limit) throws IOException { + if (limit < 1) { + throw new IllegalArgumentException("Vector limit must be positive"); + } + List> result = new ArrayList<>(); + var types = VectorizationProvider.getInstance().getVectorTypeSupport(); + try (var input = new DataInputStream(new BufferedInputStream(Files.newInputStream(Path.of(filename))))) { + while (result.size() < limit) { + int first = input.read(); + if (first == -1) { + break; + } + int dimensions = first | input.readUnsignedByte() << 8 | input.readUnsignedByte() << 16 + | input.readUnsignedByte() << 24; + if (dimensions < 1 || dimensions > MAX_FVECS_DIMENSION + || (!result.isEmpty() && dimensions != result.get(0).length())) { + throw new IOException("Invalid fvecs dimension: " + dimensions); + } + float[] vector = new float[dimensions]; + for (int i = 0; i < dimensions; i++) { + vector[i] = Float.intBitsToFloat(Integer.reverseBytes(input.readInt())); + if (!Float.isFinite(vector[i])) { + throw new IOException("Non-finite fvecs value at row " + result.size() + ", dimension " + i); + } + } + result.add(types.createFloatVector(vector)); + } + } + return result; + } + + /** Publish a complete fixture; a failed write or existing destination never leaves a partial file. */ + static void write(Path path, ASHNeighborhoodFixture fixture) throws IOException { + Path absolute = path.toAbsolutePath(); + Path temporary = Files.createTempFile(absolute.getParent(), ".ash-neighborhood-", ".tmp"); + try { + writeComplete(temporary, fixture); + Files.move(temporary, absolute); // No REPLACE_EXISTING: preserve existing fixture inputs. + } finally { + Files.deleteIfExists(temporary); + } + } + + private static void writeComplete(Path path, ASHNeighborhoodFixture fixture) throws IOException { + try (var model = ByteBufferIndexWriter.create(fixture.ash.compressorSize(), false)) { + fixture.ash.write(model, OnDiskGraphIndex.CURRENT_VERSION); + ByteBuffer buffer = model.getWrittenData(); + byte[] bytes = new byte[buffer.remaining()]; + buffer.get(bytes); + try (var out = new DataOutputStream(new BufferedOutputStream(Files.newOutputStream(path)))) { + out.writeInt(MAGIC); + out.writeInt(VERSION); + out.writeInt(fixture.capacity); + out.writeInt(fixture.blockSize); + out.writeInt(fixture.baseCount); + out.writeInt(bytes.length); + out.write(bytes); + out.writeInt(fixture.queries.size()); + for (VectorFloat query : fixture.queries) { + for (int d = 0; d < query.length(); d++) { + out.writeFloat(query.get(d)); + } + } + out.writeInt(fixture.neighborhoods.length); + out.writeInt(fixture.neighborhoods[0].length); + for (byte[] block : fixture.neighborhoods) { + out.write(block); + } + } + } + } + + static ASHNeighborhoodFixture read(Path path) throws IOException { + long fileBytes = Files.size(path); + try (var input = new DataInputStream(new BufferedInputStream(Files.newInputStream(path)))) { + if (input.readInt() != MAGIC || input.readInt() != VERSION) { + throw new IOException("Invalid neighborhood fixture header"); + } + int capacity = input.readInt(); + int blockSize = input.readInt(); + int baseCount = input.readInt(); + int modelSize = input.readInt(); + if (capacity < 1 || baseCount < 1 || modelSize < 1 || modelSize > fileBytes - FIXED_BYTES) { + throw new IOException("Invalid fixture sizes"); + } + FusedASHLayout.validateBlockSize(blockSize); + byte[] model = new byte[modelSize]; + input.readFully(model); + validateModelEnvelope(model); + var modelBuffer = ByteBuffer.wrap(model).order(ByteOrder.BIG_ENDIAN); + var ash = AsymmetricHashing.load(new ByteBufferReader(modelBuffer)); + if (modelBuffer.hasRemaining()) { + throw new IOException("Trailing bytes in fixture model"); + } + FusedASHLayout.validateBitsPerDimension(ash.bitsPerDimension); + int queries = input.readInt(); + long queryBytes = (long) queries * ash.originalDimension * Float.BYTES; + long payloadStart = FIXED_BYTES + (long) modelSize + queryBytes; + if (queries < 1 || queryBytes < 0 || payloadStart > fileBytes) { + throw new IOException("Invalid query count"); + } + var types = VectorizationProvider.getInstance().getVectorTypeSupport(); + List> vectors = new ArrayList<>(); + for (int i = 0; i < queries; i++) { + float[] query = new float[ash.originalDimension]; + for (int d = 0; d < query.length; d++) { + query[d] = input.readFloat(); + if (!Float.isFinite(query[d])) { + throw new IOException("Non-finite fixture query value"); + } + } + vectors.add(types.createFloatVector(query)); + } + int count = input.readInt(); + int bytes = input.readInt(); + int expected = FusedASHLayout.featureSize(capacity, ash.quantizedDim, ash.bitsPerDimension, blockSize); + if (count != (baseCount - 1) / capacity + 1 || bytes != expected + || payloadStart + (long) count * bytes != fileBytes) { + throw new IOException("Invalid packed payload sizes or trailing bytes"); + } + byte[][] blocks = new byte[count][bytes]; + for (byte[] block : blocks) { + input.readFully(block); + } + return new ASHNeighborhoodFixture(ash, vectors, blocks, capacity, blockSize, baseCount); + } catch (IllegalArgumentException | ArithmeticException | BufferUnderflowException error) { + throw new IOException("Invalid ASH neighborhood fixture: " + path, error); + } + } + + private static void validateModelEnvelope(byte[] model) throws IOException { + // Fixture V1 embeds ASH's eight-int prefix, one landmark, and its row-major transform. + // Check declared dimensions against the bytes before ASH.load allocates model arrays. + int fixedModelBytes = 11 * Integer.BYTES; + if (model.length < fixedModelBytes) { + throw new IOException("Truncated fixture model"); + } + ByteBuffer header = ByteBuffer.wrap(model).order(ByteOrder.BIG_ENDIAN); + int original = header.getInt(2 * Integer.BYTES); + int projected = header.getInt(4 * Integer.BYTES); + int landmarks = header.getInt(7 * Integer.BYTES); + long floats = (long) original * (projected + 1L); + if (original < 1 || projected < 1 || projected > original || landmarks != 1 + || floats > (model.length - fixedModelBytes) / Float.BYTES + || fixedModelBytes + floats * Float.BYTES != model.length) { + throw new IOException("Invalid C=1 fixture model dimensions"); + } + } + + static String sha256(Path path) throws IOException { + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + try (var input = new DigestInputStream(Files.newInputStream(path), digest)) { + input.transferTo(OutputStream.nullOutputStream()); + } + byte[] hash = digest.digest(); + StringBuilder hex = new StringBuilder(hash.length * 2); + for (byte value : hash) { + hex.append(Character.forDigit((value >>> 4) & 0xF, 16)); + hex.append(Character.forDigit(value & 0xF, 16)); + } + return hex.toString(); + } catch (NoSuchAlgorithmException error) { + throw new AssertionError("SHA-256 is required by the Java platform", error); + } + } +} diff --git a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/ASHNeighborhoodPipeline.java b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/ASHNeighborhoodPipeline.java new file mode 100644 index 000000000..05bd1f132 --- /dev/null +++ b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/ASHNeighborhoodPipeline.java @@ -0,0 +1,208 @@ +/* + * 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; + +import io.github.jbellis.jvector.quantization.ASHNeighborhoodScorer; +import io.github.jbellis.jvector.vector.types.VectorFloat; +import java.util.ArrayDeque; +import java.util.Objects; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.CancellationException; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.Future; +import java.util.concurrent.FutureTask; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.Executors; +import java.util.concurrent.ThreadPoolExecutor; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; + +/** + * Bounded benchmark pipeline: one coordinator prepares queries while a persistent pool scores. + * + *

    Submit, measure and close belong to the coordinator thread. At most two scans are submitted + * by {@link #measure}; every scan owns its prepared query and every pool thread owns its scratch. + * Timing includes preparation, scheduling, score consumption, pipeline fill and complete drain. + * Average elapsed time per completed scan is a throughput metric, not per-query latency.

    + */ +final class ASHNeighborhoodPipeline implements AutoCloseable { + private static final int MAX_IN_FLIGHT = 2; + private static volatile double sink; + private final ASHNeighborhoodFixture fixture; + private final int[] order; + private final int threads; + private final ThreadPoolExecutor pool; + private final ThreadLocal output; + private long preparationNanos; + + ASHNeighborhoodPipeline(ASHNeighborhoodFixture fixture, int[] order, int threads) { + if (threads < 1 || threads > order.length) + throw new IllegalArgumentException("Worker count must be in [1, neighborhood count]"); + this.fixture = Objects.requireNonNull(fixture, "fixture"); + validateOrder(order, fixture.neighborhoods.length); + this.order = order.clone(); + this.threads = threads; + this.output = ThreadLocal.withInitial(() -> new float[fixture.capacity]); + var workerIds = new AtomicInteger(); + pool = (ThreadPoolExecutor) Executors.newFixedThreadPool(threads, task -> { + var worker = new Thread(task, "ash-neighborhood-" + workerIds.incrementAndGet()); + worker.setDaemon(true); // An aborted benchmark must not leave its process alive. + return worker; + }); + pool.prestartAllCoreThreads(); + } + + /** Prepare exactly once, then publish the immutable scorer with each partition task. */ + Scan submit(VectorFloat query) { + if (pool.isShutdown()) { + throw new IllegalStateException("ASH pipeline is closed"); + } + long start = System.nanoTime(); + var scorer = new ASHNeighborhoodScorer(fixture.ash, query, fixture.capacity, fixture.blockSize); + preparationNanos += System.nanoTime() - start; + var scan = new Scan(threads); + for (int worker = 0; worker < threads; worker++) { + final int slot = worker; + final int first = (int) ((long) worker * order.length / threads); + final int last = (int) ((long) (worker + 1) * order.length / threads); + var partition = new FutureTask(() -> { + float[] scores = output.get(); + double checksum = 0; + for (int n = first; n < last; n++) { + scorer.score(fixture.neighborhoods[order[n]], scores); + for (float score : scores) + checksum += score; + } + scan.checksums[slot] = checksum; + return null; + }) { + @Override + protected void done() { + // Also complete the scan when shutdown cancels a queued partition. + try { + get(); // Already terminal: no waiting here. + } catch (ExecutionException error) { + scan.failure.compareAndSet(null, error.getCause()); + } catch (CancellationException error) { + scan.failure.compareAndSet(null, error); + } catch (InterruptedException error) { + Thread.currentThread().interrupt(); + scan.failure.compareAndSet(null, error); + } finally { + scan.done.countDown(); + } + } + }; + pool.execute(partition); + } + return scan; + } + + @Override + public void close() { + pool.shutdown(); + boolean interrupted = false; + try { + while (!pool.isTerminated()) { + try { + pool.awaitTermination(1, TimeUnit.SECONDS); + } catch (InterruptedException error) { + interrupted = true; + for (Runnable queued : pool.shutdownNow()) { + ((Future) queued).cancel(false); + } + } + } + } finally { + if (interrupted) + Thread.currentThread().interrupt(); + } + } + + private static void validateOrder(int[] order, int count) { + if (order.length != count) { + throw new IllegalArgumentException("A full scan must include every neighborhood exactly once"); + } + boolean[] seen = new boolean[count]; + for (int index : order) { + if (index < 0 || index >= count || seen[index]) { + throw new IllegalArgumentException("Neighborhood order must be a permutation"); + } + seen[index] = true; + } + } + + /** Completion state for one full scan; the latch publishes each partition's checksum. */ + static final class Scan { + private final CountDownLatch done; + private final double[] checksums; + private final AtomicReference failure = new AtomicReference<>(); + + Scan(int threads) { + done = new CountDownLatch(threads); + checksums = new double[threads]; + } + + double await() { + try { + done.await(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IllegalStateException("Interrupted while scoring", e); + } + if (failure.get() != null) + throw new IllegalStateException("Neighborhood scoring worker failed", failure.get()); + double sum = 0; + for (double checksum : checksums) + sum += checksum; + return sum; + } + } + + static final class Timing { + final double nanosPerScan, preparationNanosPerScan, checksum; + Timing(double nanos, double preparation, double checksum) { + this.nanosPerScan = nanos; + this.preparationNanosPerScan = preparation; + this.checksum = checksum; + } + } + + Timing measure(VectorFloat query, double seconds) { + var pending = new ArrayDeque(MAX_IN_FLIGHT); + long preparationStart = preparationNanos; + long start = System.nanoTime(); + long completed = 0; + double checksum = 0; + do { + if (pending.size() == MAX_IN_FLIGHT) { + checksum += pending.removeFirst().await(); + completed++; + } + // Preparation of this scan overlaps scoring of earlier submitted scans. + pending.addLast(submit(query)); + } while (System.nanoTime() - start < seconds * 1e9); + while (!pending.isEmpty()) { + checksum += pending.removeFirst().await(); + completed++; + } + long elapsed = System.nanoTime() - start; + sink = checksum / completed; + return new Timing( + (double) elapsed / completed, (double) (preparationNanos - preparationStart) / completed, sink); + } +} diff --git a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/ASHNeighborhoodValidator.java b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/ASHNeighborhoodValidator.java new file mode 100644 index 000000000..fae7ef5f3 --- /dev/null +++ b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/ASHNeighborhoodValidator.java @@ -0,0 +1,82 @@ +/* + * 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; + +import io.github.jbellis.jvector.quantization.ASHNeighborhoodScorer; +import io.github.jbellis.jvector.quantization.ASHScorer; +import io.github.jbellis.jvector.quantization.FusedASHLayout; +import java.util.Locale; + +/** + * Untimed decoded-component oracle, independent of the scoring LUT and native implementation. + * It projects in double precision and checks the same serialized headers consumed by the graph. + * The tolerance permits FP32 reduction-order differences, without introducing LUT quantization. + */ +final class ASHNeighborhoodValidator { + private ASHNeighborhoodValidator() {} + + static void validate(ASHNeighborhoodFixture fixture) { + double maximum = 0; + int neighborhoods = Math.min(fixture.neighborhoods.length, + ASHNeighborhoodBenchmark.positive("jvector.bench.validationNeighborhoods", 1024)); + for (var query : fixture.queries) { + var scorer = new ASHNeighborhoodScorer(fixture.ash, query, fixture.capacity, fixture.blockSize); + // Double-precision, decoded-component oracle independent of the nibble LUT kernel. + double[] projected = new double[fixture.ash.quantizedDim]; + double dotMu = 0; + for (int d = 0; d < query.length(); d++) { + dotMu += (double) query.get(d) * fixture.ash.landmarks[0].get(d); + for (int j = 0; j < projected.length; j++) + projected[j] += (double) query.get(d) * fixture.ash.stiefelTransform.AFloat[j][d]; + } + float[] scores = new float[fixture.capacity]; + for (int n = 0; n < neighborhoods; n++) { + int index = (int) ((long) n * fixture.neighborhoods.length / neighborhoods); + byte[] packed = fixture.neighborhoods[index]; + scorer.score(packed, scores); + for (int i = 0; i < fixture.capacity; i++) { + int offset = FusedASHLayout.blockOffset( + i / fixture.blockSize, projected.length, fixture.ash.bitsPerDimension, fixture.blockSize); + int lane = i % fixture.blockSize, bits = fixture.ash.bitsPerDimension; + double dot = 0; + for (int d = 0; d < projected.length; d++) { + int perNibble = 4 / bits; + int nibble = + FusedASHLayout.getPackedNibble(packed, offset, lane, d / perNibble, fixture.blockSize); + int field = nibble >>> ((d % perNibble) * bits) & ((1 << bits) - 1); + dot += projected[d] * FusedASHLayout.decodeProjectionComponent(field, bits); + } + float expected = ASHScorer.toSimilarity((float) (FusedASHLayout.readScale(packed, offset, lane, + projected.length, bits, fixture.blockSize) + * dot + + dotMu + + FusedASHLayout.readOffset( + packed, offset, lane, projected.length, bits, fixture.blockSize))); + double error = Math.abs(scores[i] - expected); + if (!Float.isFinite(expected) || !Float.isFinite(scores[i]) + || error > 3e-5 + Math.abs(expected) * 3e-5) + throw new AssertionError("Neighborhood score mismatch at " + index + "/" + i + ": " + scores[i] + + " vs " + expected); + maximum = Math.max(maximum, error); + } + } + } + System.out.printf(Locale.ROOT, + "Validated %d queries x %d neighborhoods against decoded-component oracle; maxAbsError=%.9g%n", + fixture.queries.size(), neighborhoods, maximum); + } +} diff --git a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/DistancesASH.java b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/DistancesASH.java index 95604c7c8..3cbbfe75f 100644 --- a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/DistancesASH.java +++ b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/DistancesASH.java @@ -100,8 +100,9 @@ private static void warmupASH(ASHVectors vectors, List> queries, blockSize == 0 ? "Single" : "Block", queryCount, checksum); } - private static int parseOptimizerFromProperty() { - String opt = System.getProperty("jvector.ash.optimizer", "random").trim().toLowerCase(); + /** Shared by the original scan modes and reproducible neighborhood fixture creation. */ + static int parseOptimizerFromProperty() { + String opt = System.getProperty("jvector.ash.optimizer", "random").trim().toLowerCase(java.util.Locale.ROOT); if ("random".equals(opt)) return AsymmetricHashing.RANDOM; if ("itq".equals(opt)) return AsymmetricHashing.ITQ; @@ -1099,6 +1100,11 @@ public static void runCohere10m() throws IOException { } public static void main(String[] args) throws IOException { + // Dispatch before the legacy argument modes: saved fixtures need no positional inputs. + if (Boolean.getBoolean("jvector.bench.neighborhood-scoring")) { + ASHNeighborhoodBenchmark.run(args); + return; + } if (args.length == 3) { testASHEncodings(args[0], args[1], args[2]); return; diff --git a/jvector-tests/src/test/java/io/github/jbellis/jvector/example/TestASHNeighborhoodBenchmark.java b/jvector-tests/src/test/java/io/github/jbellis/jvector/example/TestASHNeighborhoodBenchmark.java new file mode 100644 index 000000000..3b9f4ab73 --- /dev/null +++ b/jvector-tests/src/test/java/io/github/jbellis/jvector/example/TestASHNeighborhoodBenchmark.java @@ -0,0 +1,203 @@ +/* + * 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; + +import io.github.jbellis.jvector.graph.ListRandomAccessVectorValues; +import io.github.jbellis.jvector.quantization.*; +import io.github.jbellis.jvector.vector.VectorUtil; +import io.github.jbellis.jvector.vector.VectorizationProvider; +import io.github.jbellis.jvector.vector.types.VectorFloat; +import org.junit.Test; +import java.io.IOException; +import java.nio.file.Files; +import java.util.*; +import static org.junit.Assert.*; + +/** Fixture replay, full-scan scheduling, and failure-boundary checks for DistancesASH. */ +public class TestASHNeighborhoodBenchmark { + @Test + public void serializedNeighborhoodsMatchOracleAcrossWidthsAndTails() throws Exception { + var types = VectorizationProvider.getInstance().getVectorTypeSupport(); + List> vectors = new ArrayList<>(); + Random random = new Random(5293); + for (int i = 0; i < 35; i++) { + float[] data = new float[64]; + for (int d = 0; d < data.length; d++) + data[d] = random.nextFloat() - .5f; + var vector = types.createFloatVector(data); + VectorUtil.l2normalize(vector); + vectors.add(vector); + } + var values = new ListRandomAccessVectorValues(vectors, 64); + for (int bits : new int[] {1, 2, 4}) { + var ash = AsymmetricHashing.initialize( + values, AsymmetricHashing.RANDOM, AsymmetricHashing.HEADER_BITS + 33 * bits, 1, bits); + ASHVectors encoded = (ASHVectors) ash.encodeAll(values); + for (int capacity : new int[] {1, 8, 16, 23, 32, 64}) { + int blockSize = FusedASHLayout.chooseBlockSize(capacity); + byte[][] packed = new byte[(vectors.size() - 1) / capacity + 1] + [FusedASHLayout.featureSize(capacity, 33, bits, blockSize)]; + for (int i = 0; i < vectors.size(); i++) { + int neighbor = i % capacity; + FusedASHLayout.packForFused(packed[i / capacity], + FusedASHLayout.blockOffset(neighbor / blockSize, 33, bits, blockSize), neighbor % blockSize, + encoded.get(i), ash, blockSize); + } + var fixture = new ASHNeighborhoodFixture( + ash, vectors.subList(0, 2), packed, capacity, blockSize, vectors.size()); + ASHNeighborhoodValidator.validate(fixture); + if (bits == 2 && capacity == 8) { + var query = fixture.queries.get(0); + var reference = new ASHNeighborhoodScorer(ash, query, capacity, blockSize); + float[] output = new float[capacity]; + double checksum = 0; + for (byte[] neighborhood : packed) { + reference.score(neighborhood, output); + for (float score : output) + checksum += score; + } + int[] order = ASHNeighborhoodBenchmark.accessOrder(packed.length, "shuffled"); + int[] duplicate = order.clone(); + duplicate[0] = duplicate[1]; + assertThrows( + IllegalArgumentException.class, () -> new ASHNeighborhoodPipeline(fixture, duplicate, 1)); + assertThrows( + IllegalArgumentException.class, () -> new ASHNeighborhoodPipeline(fixture, new int[0], 1)); + for (int threads : new int[] {1, 2, 4}) { + try (var parallel = new ASHNeighborhoodPipeline(fixture, order, threads)) { + for (int sweep = 0; sweep < 3; sweep++) { + // Both scans can be in flight; each must retain its own prepared query. + var firstScan = parallel.submit(query); + var nextQuery = fixture.queries.get(1); + var nextScorer = new ASHNeighborhoodScorer(ash, nextQuery, capacity, blockSize); + double nextChecksum = 0; + for (byte[] neighborhood : packed) { + nextScorer.score(neighborhood, output); + for (float score : output) + nextChecksum += score; + } + var nextScan = parallel.submit(nextQuery); + assertEquals(checksum, firstScan.await(), 1e-9); + assertEquals(nextChecksum, nextScan.await(), 1e-9); + } + } + } + var closed = new ASHNeighborhoodPipeline(fixture, order, 1); + closed.close(); + assertThrows(IllegalStateException.class, () -> closed.submit(query)); + try (var pipeline = new ASHNeighborhoodPipeline(fixture, order, 1)) { + var submitted = pipeline.submit(query); + Thread.currentThread().interrupt(); + try { + assertThrows(IllegalStateException.class, submitted::await); + assertTrue(Thread.currentThread().isInterrupted()); + } finally { + Thread.interrupted(); // Do not leak interruption into the JUnit runner. + } + } + // A failed worker must report its exception and still shut down cleanly. + int landmark = FusedASHLayout.landmarkOffset(0, 33, bits, blockSize); + byte saved = packed[0][landmark]; + packed[0][landmark] = (byte) 255; + try (var parallel = new ASHNeighborhoodPipeline(fixture, order, 2)) { + assertThrows(IllegalStateException.class, () -> parallel.submit(query).await()); + } finally { + packed[0][landmark] = saved; + } + } + var path = Files.createTempFile("ash-neighborhood-", ".ashn"); + Files.delete(path); // The writer deliberately refuses to overwrite fixtures. + try { + ASHNeighborhoodFixture.write(path, fixture); + var loaded = ASHNeighborhoodFixture.read(path); + ASHNeighborhoodValidator.validate(loaded); + assertEquals(fixture.capacity, loaded.capacity); + for (int i = 0; i < packed.length; i++) + assertArrayEquals(packed[i], loaded.neighborhoods[i]); + var scorer = new ASHNeighborhoodScorer(loaded.ash, loaded.queries.get(0), capacity, blockSize); + if (VectorizationProvider.getInstance().getVectorUtilSupport().supportsAshNeighborhoodScoring() + && !"scalar".equals(System.getProperty("jvector.ash.blockKernel"))) { + assertTrue( + scorer.toString(), scorer.toString().contains("native Highway whole-neighborhood FFM")); + } + float[] out = new float[capacity + 1]; + out[capacity] = Float.NaN; + scorer.score(packed[0], out); + assertTrue(Float.isNaN(out[capacity])); + assertThrows(IndexOutOfBoundsException.class, () -> scorer.score(new byte[0], out)); + assertThrows(IndexOutOfBoundsException.class, () -> scorer.score(packed[0], new float[0])); + String hash = ASHNeighborhoodFixture.sha256(path); + assertThrows(IOException.class, () -> ASHNeighborhoodFixture.write(path, fixture)); + assertEquals(hash, ASHNeighborhoodFixture.sha256(path)); + assertEquals(64, hash.length()); + byte[] original = Files.readAllBytes(path); + Files.write(path, Arrays.copyOf(original, original.length - 1)); + assertThrows(IOException.class, () -> ASHNeighborhoodFixture.read(path)); + Files.write(path, Arrays.copyOf(original, original.length + 1)); + assertThrows(IOException.class, () -> ASHNeighborhoodFixture.read(path)); + } finally { + Files.deleteIfExists(path); + } + } + } + } + + @Test + public void accessOrdersAreRepeatablePermutations() { + int[] sequential = ASHNeighborhoodBenchmark.accessOrder(37, "sequential"); + int[] shuffled = ASHNeighborhoodBenchmark.accessOrder(37, "shuffled"); + assertArrayEquals(shuffled, ASHNeighborhoodBenchmark.accessOrder(37, "shuffled")); + assertFalse(Arrays.equals(sequential, shuffled)); + Arrays.sort(shuffled); + assertArrayEquals(sequential, shuffled); + assertThrows(IllegalArgumentException.class, () -> ASHNeighborhoodBenchmark.accessOrder(37, "invalid")); + } + + @Test + public void rejectsMalformedIntegerSettings() { + String name = "jvector.bench.scoringPasses"; + String previous = System.getProperty(name); + try { + System.setProperty(name, "not-an-integer"); + assertThrows(IllegalArgumentException.class, () -> ASHNeighborhoodBenchmark.positive(name, 5)); + } finally { + if (previous == null) + System.clearProperty(name); + else + System.setProperty(name, previous); + } + } + + @Test + public void rejectsTruncatedFixturesAndFvecs() throws Exception { + var path = Files.createTempFile("ash-truncated-", ".bin"); + try { + Files.write(path, new byte[] {1, 2, 3}); + assertThrows(IOException.class, () -> ASHNeighborhoodFixture.read(path)); + assertThrows(IOException.class, () -> ASHNeighborhoodFixture.readFvecs(path.toString(), 1)); + assertThrows(IllegalArgumentException.class, () -> ASHNeighborhoodFixture.readFvecs(path.toString(), 0)); + var buffer = java.nio.ByteBuffer.allocate(9 * Integer.BYTES + 44); + buffer.putInt(0x4153484e).putInt(1).putInt(32).putInt(32).putInt(1).putInt(44); + buffer.putInt(0).putInt(0).putInt(Integer.MAX_VALUE).putInt(0); + buffer.putInt(Integer.MAX_VALUE).putInt(4).putInt(0).putInt(1); + Files.write(path, buffer.array()); + assertThrows(IOException.class, () -> ASHNeighborhoodFixture.read(path)); + } finally { + Files.deleteIfExists(path); + } + } +} From 47d58a0628946ecec7f09389ec5ca6e9f0d867d5 Mon Sep 17 00:00:00 2001 From: Ted Willke Date: Wed, 28 Oct 2026 22:16:03 +0000 Subject: [PATCH 087/243] Measure full-scan ASH recall through neighborhood scoring Evaluate recall@10 through the same scalar, Panama or native neighborhood scorer used by the graph. Scan every real base vector per query, exclude padding from ranking, use deterministic ties and validate matching ivecs ground truth. Retain per-query IDs, scores and hit counts for comparison. Parallelize independent queries without changing the full-scan work. --- .../example/ASHNeighborhoodBenchmark.java | 10 +- .../example/ASHNeighborhoodRecall.java | 254 ++++++++++++++++++ .../example/TestASHNeighborhoodRecall.java | 122 +++++++++ 3 files changed, 383 insertions(+), 3 deletions(-) create mode 100644 jvector-examples/src/main/java/io/github/jbellis/jvector/example/ASHNeighborhoodRecall.java create mode 100644 jvector-tests/src/test/java/io/github/jbellis/jvector/example/TestASHNeighborhoodRecall.java diff --git a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/ASHNeighborhoodBenchmark.java b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/ASHNeighborhoodBenchmark.java index b8553786a..b359e4964 100644 --- a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/ASHNeighborhoodBenchmark.java +++ b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/ASHNeighborhoodBenchmark.java @@ -58,7 +58,7 @@ static void run(String[] args) throws IOException { checkSetting("jvector.bench.blockSize", fixture.blockSize); boolean scaling = Boolean.getBoolean("jvector.bench.neighborhood-scaling"); int threads = positive("jvector.bench.scoringThreads", 1); - if (!scaling && threads != 1) + if (!scaling && !Boolean.getBoolean("jvector.bench.recall") && threads != 1) throw new IllegalArgumentException("Multiple scoring threads require neighborhood-scaling=true"); int queryIndex = Integer.parseInt(System.getProperty("jvector.bench.queryIndex", "0")); Objects.checkIndex(queryIndex, fixture.queries.size()); @@ -82,8 +82,8 @@ static void run(String[] args) throws IOException { "Native ASH comparison requested, but the library lacks the neighborhood kernel"); } System.out.printf(Locale.ROOT, - "Neighborhood benchmark: java=%s, C=1, singleQuery=true, bits=%d, dimensions=%d, capacity=%d, blockSize=%d%n", - Runtime.version(), fixture.ash.bitsPerDimension, + "Neighborhood benchmark: java=%s, C=1, singleQuery=%s, bits=%d, dimensions=%d, capacity=%d, blockSize=%d%n", + Runtime.version(), !Boolean.getBoolean("jvector.bench.recall"), fixture.ash.bitsPerDimension, fixture.ash.quantizedDim, fixture.capacity, fixture.blockSize); System.out.printf(Locale.ROOT, "Fixture: sha256=%s, queries=%d, queryIndex=%d, baseVectors=%d, neighborhoods=%d, packedBytes=%d, access=%s%n", @@ -92,6 +92,10 @@ static void run(String[] args) throws IOException { System.out.printf("Backend: %s; %s%n", backend.getClass().getSimpleName(), backend.ashKernelDescription()); var scorer = new ASHNeighborhoodScorer(fixture.ash, query, fixture.capacity, fixture.blockSize); System.out.println("Scorer: " + scorer); + if (Boolean.getBoolean("jvector.bench.recall")) { + ASHNeighborhoodRecall.run(fixture, threads); + return; + } ASHNeighborhoodValidator.validate(fixture); if (scaling) { runPipelinedScans(fixture, query, order, threads, passes, warmup, seconds); diff --git a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/ASHNeighborhoodRecall.java b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/ASHNeighborhoodRecall.java new file mode 100644 index 000000000..484bd3699 --- /dev/null +++ b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/ASHNeighborhoodRecall.java @@ -0,0 +1,254 @@ +/* + * 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; + +import io.github.jbellis.jvector.quantization.ASHNeighborhoodScorer; +import java.io.BufferedInputStream; +import java.io.DataInputStream; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardOpenOption; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Comparator; +import java.util.List; +import java.util.Locale; +import java.util.PriorityQueue; +import java.util.concurrent.Callable; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; + +/** + * Exact full-scan recall@10 evaluation through the production neighborhood scorer. + * + *

    Each query is prepared once and scores every serialized neighborhood. Only real base + * ordinals enter top-k; padded lanes are scored but excluded from ranking. Ties prefer the + * smaller original ordinal in every backend. Query parallelism changes neither the scan work + * nor its reduction order. This is an accuracy check, not a kernel throughput benchmark.

    + */ +final class ASHNeighborhoodRecall { + static final int K = 10; + + private ASHNeighborhoodRecall() {} + + static void run(ASHNeighborhoodFixture fixture, int threads) throws IOException { + String groundTruthName = System.getProperty("jvector.bench.recall.groundTruth"); + if (groundTruthName == null) { + throw new IllegalArgumentException("Set jvector.bench.recall.groundTruth to the matching .ivecs file"); + } + int[][] truth = readGroundTruth(Path.of(groundTruthName), fixture.queries.size(), fixture.baseCount); + String outputName = System.getProperty("jvector.bench.recall.output"); + if (outputName != null && Files.exists(Path.of(outputName))) { + throw new IOException("Recall output already exists: " + outputName); + } + // Keep independent component validation bounded; recall itself uses every saved query. + var validation = + new ASHNeighborhoodFixture(fixture.ash, fixture.queries.subList(0, Math.min(4, fixture.queries.size())), + fixture.neighborhoods, fixture.capacity, fixture.blockSize, fixture.baseCount); + ASHNeighborhoodValidator.validate(validation); + System.out.printf(Locale.ROOT, + "Full-scan recall@10: queries=%d baseVectors=%d neighborhoods=%d queryWorkers=%d; prepare once per query; no reranking; padding excluded from top-k%n", + fixture.queries.size(), fixture.baseCount, fixture.neighborhoods.length, threads); + var pool = Executors.newFixedThreadPool(threads); + List results = new ArrayList<>(fixture.queries.size()); + try { + var finished = new AtomicInteger(); + List> tasks = new ArrayList<>(fixture.queries.size()); + for (int q = 0; q < fixture.queries.size(); q++) { + final int query = q; + tasks.add(() -> { + Result result = scan(fixture, query, truth[query]); + int count = finished.incrementAndGet(); + if (count % 100 == 0 || count == fixture.queries.size()) { + System.out.printf(Locale.ROOT, "Recall progress: %d/%d queries complete%n", count, + fixture.queries.size()); + } + return result; + }); + } + int completed = 0; + for (var future : pool.invokeAll(tasks)) { + results.add(future.get()); + completed++; + } + if (completed != fixture.queries.size()) { + throw new AssertionError("Incomplete full-scan recall"); + } + } catch (InterruptedException error) { + Thread.currentThread().interrupt(); + throw new IllegalStateException("Interrupted during full-scan recall", error); + } catch (ExecutionException error) { + throw new IllegalStateException("Full-scan recall worker failed", error.getCause()); + } finally { + pool.shutdownNow(); + boolean interrupted = false; + try { + while (!pool.isTerminated()) { + try { + pool.awaitTermination(1, TimeUnit.SECONDS); + } catch (InterruptedException error) { + interrupted = true; + } + } + } finally { + if (interrupted) { + Thread.currentThread().interrupt(); + } + } + } + long hits = 0; + for (var result : results) { + hits += result.hits; + } + if (outputName != null) { + try (var output = Files.newBufferedWriter( + Path.of(outputName), StandardOpenOption.CREATE_NEW, StandardOpenOption.WRITE)) { + output.write("query\thits_at_10\tids_best_first\tscores_best_first\n"); + for (var result : results) { + output.write(result.query + "\t" + result.hits + "\t" + Arrays.toString(result.ids) + "\t" + + Arrays.toString(result.scores) + "\n"); + } + } + } + System.out.printf(Locale.ROOT, + "NEIGHBORHOOD_RECALL queries=%d baseVectors=%d hits=%d denominator=%d recall_at_10=%.9f%n", + results.size(), fixture.baseCount, hits, (long) K * results.size(), + hits / ((double) K * results.size())); + } + + /** Scan exactly once; a worker owns the scorer, scratch, and top-k state for its query. */ + static Result scan(ASHNeighborhoodFixture fixture, int query, int[] truth) { + var scorer = + new ASHNeighborhoodScorer(fixture.ash, fixture.queries.get(query), fixture.capacity, fixture.blockSize); + float[] scores = new float[fixture.capacity]; + var top = new TopK(); + for (int n = 0; n < fixture.neighborhoods.length; n++) { + if (Thread.currentThread().isInterrupted()) { + throw new IllegalStateException("Recall scan cancelled"); + } + scorer.score(fixture.neighborhoods[n], scores); + int base = n * fixture.capacity; + int count = Math.min(fixture.capacity, fixture.baseCount - base); + for (int lane = 0; lane < count; lane++) { + float score = scores[lane]; + if (!Float.isFinite(score)) { + throw new IllegalStateException( + "Non-finite recall score at query=" + query + " ordinal=" + (base + lane)); + } + top.offer(score, base + lane); + } + } + Candidate[] ranked = top.ranked(); + int[] ids = new int[K]; + float[] bestScores = new float[K]; + int hits = 0; + for (int rank = 0; rank < K; rank++) { + ids[rank] = ranked[rank].ordinal; + bestScores[rank] = ranked[rank].score; + for (int expected : truth) { + if (ids[rank] == expected) { + hits++; + break; + } + } + } + return new Result(query, ids, bestScores, hits); + } + + /** Read little-endian ivecs; top ten must be unique, in-range original base ordinals. */ + static int[][] readGroundTruth(Path path, int queries, int baseCount) throws IOException { + if (queries < 1 || baseCount < K) { + throw new IllegalArgumentException("Recall@10 needs queries and at least ten real base vectors"); + } + int[][] result = new int[queries][K]; + try (var input = new DataInputStream(new BufferedInputStream(Files.newInputStream(path)))) { + for (int q = 0; q < queries; q++) { + int width = Integer.reverseBytes(input.readInt()); + if (width < K || width > 100_000) { + throw new IOException("Invalid ground-truth width: " + width); + } + for (int rank = 0; rank < width; rank++) { + int id = Integer.reverseBytes(input.readInt()); + if (id < 0 || id >= baseCount) { + throw new IOException("Ground-truth ordinal outside base set: " + id); + } + if (rank < K) { + for (int prior = 0; prior < rank; prior++) { + if (result[q][prior] == id) { + throw new IOException("Duplicate top-ten ground-truth ordinal at query " + q); + } + } + result[q][rank] = id; + } + } + } + } + return result; + } + + static final class Result { + final int query; + final int[] ids; + final float[] scores; + final int hits; + + Result(int query, int[] ids, float[] scores, int hits) { + this.query = query; + this.ids = ids; + this.scores = scores; + this.hits = hits; + } + } + + static final class Candidate { + final float score; + final int ordinal; + + Candidate(float score, int ordinal) { + this.score = score; + this.ordinal = ordinal; + } + } + + /** Worst retained candidate first; equal scores evict the largest ordinal first. */ + static final class TopK { + private static final Comparator WORST_FIRST = (a, b) -> { + int comparison = Float.compare(a.score, b.score); + return comparison != 0 ? comparison : Integer.compare(b.ordinal, a.ordinal); + }; + private final PriorityQueue heap = new PriorityQueue<>(K, WORST_FIRST); + + void offer(float score, int ordinal) { + Candidate worst = heap.peek(); + if (heap.size() < K) { + heap.add(new Candidate(score, ordinal)); + } else if (score > worst.score || (score == worst.score && ordinal < worst.ordinal)) { + heap.poll(); + heap.add(new Candidate(score, ordinal)); + } + } + + Candidate[] ranked() { + Candidate[] ranked = heap.toArray(new Candidate[0]); + Arrays.sort(ranked, WORST_FIRST.reversed()); + return ranked; + } + } +} diff --git a/jvector-tests/src/test/java/io/github/jbellis/jvector/example/TestASHNeighborhoodRecall.java b/jvector-tests/src/test/java/io/github/jbellis/jvector/example/TestASHNeighborhoodRecall.java new file mode 100644 index 000000000..e933b18da --- /dev/null +++ b/jvector-tests/src/test/java/io/github/jbellis/jvector/example/TestASHNeighborhoodRecall.java @@ -0,0 +1,122 @@ +/* + * 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; + +import io.github.jbellis.jvector.graph.ListRandomAccessVectorValues; +import io.github.jbellis.jvector.quantization.ASHNeighborhoodScorer; +import io.github.jbellis.jvector.quantization.ASHVectors; +import io.github.jbellis.jvector.quantization.AsymmetricHashing; +import io.github.jbellis.jvector.quantization.FusedASHLayout; +import io.github.jbellis.jvector.vector.VectorizationProvider; +import io.github.jbellis.jvector.vector.types.VectorFloat; +import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.nio.file.Files; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Comparator; +import java.util.List; +import java.util.Random; +import org.junit.Test; +import static org.junit.Assert.*; + +/** Recall ranking is checked independently of the heap and excludes packed padding. */ +public class TestASHNeighborhoodRecall { + @Test + public void ranksTheCompleteBaseSetAndExcludesPadding() throws Exception { + var types = VectorizationProvider.getInstance().getVectorTypeSupport(); + List> base = new ArrayList<>(); + var random = new Random(9294); + for (int i = 0; i < 11; i++) { + float[] vector = new float[64]; + for (int d = 0; d < vector.length; d++) { + vector[d] = random.nextFloat() - .5f; + } + base.add(types.createFloatVector(vector)); + } + var ash = AsymmetricHashing.initialize(new ListRandomAccessVectorValues(base, 64), AsymmetricHashing.RANDOM, + AsymmetricHashing.HEADER_BITS + 33 * 2, 1, 2); + ASHVectors encoded = (ASHVectors) ash.encodeAll(new ListRandomAccessVectorValues(base, 64)); + byte[][] packed = new byte[2][FusedASHLayout.featureSize(8, 33, 2, 8)]; + for (int i = 0; i < base.size(); i++) { + FusedASHLayout.packForFused(packed[i / 8], 0, i % 8, encoded.get(i), ash, 8); + } + // Maliciously attractive padding must never displace a real result. + for (int lane = 3; lane < 8; lane++) { + int offset = FusedASHLayout.offsetOffset(0, 33, 2, 8) + lane * 2; + packed[1][offset] = 0x7b; + packed[1][offset + 1] = (byte) 0xff; // Largest finite positive binary16. + } + var fixture = new ASHNeighborhoodFixture(ash, List.of(base.get(0)), packed, 8, 8, 11); + float[] all = new float[16]; + float[] scratch = new float[8]; + var scorer = new ASHNeighborhoodScorer(ash, base.get(0), 8, 8); + for (int i = 0; i < packed.length; i++) { + scorer.score(packed[i], scratch); + System.arraycopy(scratch, 0, all, i * 8, 8); + } + Integer[] sorted = new Integer[11]; + Arrays.setAll(sorted, i -> i); + Arrays.sort(sorted, Comparator.comparingDouble(i -> all[i]).reversed().thenComparingInt(i -> i)); + int[] expected = Arrays.stream(sorted).limit(10).mapToInt(Integer::intValue).toArray(); + var result = ASHNeighborhoodRecall.scan(fixture, 0, expected); + assertArrayEquals(expected, result.ids); + assertEquals(10, result.hits); + for (int id : result.ids) { + assertTrue(id < fixture.baseCount); + } + } + + @Test + public void tiesPreferSmallerOrdinalsRegardlessOfOfferOrder() { + var top = new ASHNeighborhoodRecall.TopK(); + for (int id = 24; id >= 0; id--) { + top.offer(.5f, id); + } + var ranked = top.ranked(); + assertEquals(10, ranked.length); + for (int i = 0; i < ranked.length; i++) { + assertEquals(i, ranked[i].ordinal); + } + } + + @Test + public void validatesLittleEndianGroundTruthAndRejectsBadRows() throws Exception { + var path = Files.createTempFile("ash-recall-", ".ivecs"); + try { + ByteBuffer bytes = ByteBuffer.allocate(11 * Integer.BYTES).order(ByteOrder.LITTLE_ENDIAN); + bytes.putInt(10); + for (int id = 0; id < 10; id++) { + bytes.putInt(id); + } + Files.write(path, bytes.array()); + assertArrayEquals( + new int[] {0, 1, 2, 3, 4, 5, 6, 7, 8, 9}, ASHNeighborhoodRecall.readGroundTruth(path, 1, 11)[0]); + bytes.putInt(10 * Integer.BYTES, 11); + Files.write(path, bytes.array()); + assertThrows(IOException.class, () -> ASHNeighborhoodRecall.readGroundTruth(path, 1, 11)); + bytes.putInt(10 * Integer.BYTES, 0); + Files.write(path, bytes.array()); + assertThrows(IOException.class, () -> ASHNeighborhoodRecall.readGroundTruth(path, 1, 11)); + Files.write(path, Arrays.copyOf(bytes.array(), 20)); + assertThrows(IOException.class, () -> ASHNeighborhoodRecall.readGroundTruth(path, 1, 11)); + } finally { + Files.deleteIfExists(path); + } + } +} From cb02aefa021f1bcfa770a7f3e444884d269d6e0e Mon Sep 17 00:00:00 2001 From: Ted Willke Date: Wed, 28 Oct 2026 22:16:03 +0000 Subject: [PATCH 088/243] Document ASH neighborhood scoring and benchmark contracts Describe component ownership, packed offsets, native memory lifetime, optional capability fallback and the critical-call work bound. Document fixture replay, full-scan timing and recall semantics, experiment controls, IntelliJ launch options and regression checks. --- docs/ash-neighborhood-benchmark.md | 275 +++++++++++++++++++++++++++++ 1 file changed, 275 insertions(+) create mode 100644 docs/ash-neighborhood-benchmark.md diff --git a/docs/ash-neighborhood-benchmark.md b/docs/ash-neighborhood-benchmark.md new file mode 100644 index 000000000..20bcc1482 --- /dev/null +++ b/docs/ash-neighborhood-benchmark.md @@ -0,0 +1,275 @@ +# ASH neighborhood scoring and isolated full-scan benchmarks + +The graph decoder and `DistancesASH` use the same `ASHNeighborhoodScorer`. +It prepares a query once, then scores complete serialized neighborhoods, including +binary16 headers, landmark correction, and DOT_PRODUCT similarity conversion. +The graph path adds neighborhood I/O and traversal around this scorer. The +benchmark invokes the scorer directly, so those graph operations are untimed. + +## Code organization and ownership + +| Component | Responsibility | +| --- | --- | +| `ASHNeighborhoodScorer` | Immutable projection/LUT state for one query; selects scalar, Panama, or native execution | +| `FusedASHDecoder` | Thread-confined graph I/O, upper-layer source scoring, reusable scratch, and origin cache | +| `FusedASHLayout` | Shared packed layout, checked byte-size calculations, and canonical-to-fused conversion | +| `NativeVectorUtilSupport` | Native provider capability and delegation | +| `NativeASHKernels` | Package-private, checked FFM binding and native memory lifetime | +| `ash_neighborhood-inl.h` | Highway lookup, accumulation, header decoding, and score conversion | +| `ASHNeighborhoodBenchmark` | DistancesASH options, diagnostics, and timing/reporting | +| `ASHNeighborhoodFixture` | Untimed input loading, model/encoding creation, serialization, and replay | +| `ASHNeighborhoodValidator` | Untimed double-precision decoded-component oracle | +| `ASHNeighborhoodPipeline` | Persistent scoring workers and preparation/scoring overlap | + +The prepared scorer can be shared across threads if inputs remain immutable and +outputs are separate. A graph decoder owns mutable scratch and a cached origin, +so each decoder belongs to one graph view/search thread. Upper-layer scoring and +L0 neighborhoods share one query projection. A failed neighborhood read or score +invalidates the decoder's cached origin before any output can be reused. + +For one-bit ASH, canonical scoring uses `A(q - mu)` while fused scoring uses `Aq`. +`FusedASHLayout.packForFused` adjusts the one-bit offset accordingly. Two/four-bit +canonical offsets already contain that correction. The writer and fixture builder +use the shared helper rather than maintaining separate scoring formulas. + +## Native C=1 execution + +With `jvector.vectorization_provider=native` and +`jvector.ash.blockKernel=auto` or `simd`, C=1 neighborhoods use Highway. The native +path supports 1/2/4 bits per projected dimension, block widths 8/16/32, partial +blocks, and neighborhoods containing multiple blocks. C>1 retains the existing +scorer. Selecting the Panama provider retains the Panama kernel; selecting +`blockKernel=scalar` disables acceleration for the LUT scoring path. + +The unit passed across FFM is **one entire neighborhood**. Its native entry, +`ash_score_neighborhood_c1_f32`, reads packed codes and the existing float LUT, +decodes headers, applies landmark correction and similarity conversion, and writes +all configured lanes. It retains the disk format and float scoring formula. +No additional byte-LUT quantization is introduced. + +The AVX-512 B32 path shares each pair of 16-float register lookup tables across +both 16-lane halves and uses four independent accumulators. Headers are stored as +big-endian binary16 arrays and decoded with vector byte swaps and half-to-float +conversion. AVX2 uses indexed float LUT loads; the SSE software conversion includes +explicit handling for NaN and infinity. AVX3_DL and AVX3_SPR inherit the AVX3 ASH +slot, so an `avx3_spr` dispatch report currently executes that AVX-512 implementation. + +### FFM safety and lifetime + +`NativeASHKernels` lives outside the jextract-generated `cnative` package and +exposes no public raw-pointer interface. The provider detects the optional ASH +symbol when its ASH capability is queried, after library loading. Libraries without +that symbol retain the existing Java LUT path; unrelated native operations do not +acquire a dependency on the new kernel. An explicitly requested native benchmark +fails if that capability is absent, preventing a mislabeled fallback measurement. +The binding validates dimensions, lengths, and LUT/output non-aliasing before C++. Native output is unspecified on error. Status values are part +of the ABI: 0 means success, 1 an invalid C=1 landmark, and 2 an invalid shape. + +Normal graph neighborhoods use a short critical downcall with direct heap-array +access. No copies, Java callbacks, pointer retention, or native allocation occur +on that path. Critical work is bounded at 65,536 charged lane/group contributions; +charging includes full serialized blocks and at least 16 SIMD slots per block. +This bounds arithmetic work even for a tiny output with an enormous LUT. The +budget is independent of CPU model and dataset; it is a conservative safety limit, +not a measured wall-clock guarantee or an optimized copying crossover. For example, +B32 / 512 projected dimensions uses at most 16,384 contributions. + +Larger legal workloads use one ordinary downcall with temporary confined native +buffers. Their copy/allocation cost remains part of scoring, and the JVM can reach +safepoints during the longer call. Both paths retain the one-neighborhood-per-call +contract. Enable `jextract.trace.downcalls=true` only for untimed diagnosis. + +Native float vectors are heap-backed. Their Vector API stores write directly to +the backing float array, which avoids the HotSpot 22 `intoMemorySegment` compiler +crash encountered during development. Compiler exclusions are not required by the +reviewed implementation. The regression suite exercises compiled stores at every +float vector width and nonzero offsets. + +## Build and launch with JDK 22 + +The reported experiments use JDK 22. Runtime and toolchain selection belong to the +launcher and the repository's module requirements; the benchmark imposes no exact +runtime-version check. The examples module retains the repository's Java 16 +source/API compatibility; the native module uses the repository's JDK 22 FFM API. + +The native build uses the repository's existing Meson targets and compiler policy. +The pre-existing Sapphire Rapids target documents GCC 12+ or Clang 14+ requirements; +this ASH change adds no ISA target, compiler selection, or higher compiler floor. +For the reported benchmark server, the launcher sets `CXX=/usr/bin/g++-12`. +Production toolchains continue to use Meson's existing selection behavior. + +The build script retains the original dependency installation, command tracing, +version check, and resource-publication behavior, including deleting the old +resource library before compilation. Its sole change is to omit `--wipe` when no +configured Meson tree exists, supporting first builds on older Meson releases. +Configured trees still wipe exactly as before. A failed build continues to stop +Maven and leaves no old resource library to package. + +In IntelliJ, run `io.github.jbellis.jvector.example.DistancesASH` with JDK 22 and +these VM options, changing the fixture path as appropriate: + +```text +--add-modules=jdk.incubator.vector +--enable-native-access=ALL-UNNAMED +-Xms2g -Xmx2g +-XX:ActiveProcessorCount=1 +-XX:UseAVX=3 +-Djvector.vectorization_provider=native +-Djvector.ash.blockKernel=simd +-Djvector.bench.neighborhood-scoring=true +-Djvector.bench.neighborhood-scaling=true +-Djvector.bench.scoringThreads=1 +-Djvector.bench.neighborhoodAccess=shuffled +-Djvector.bench.neighborhoodFixture=/path/to/cohere-c1-b32-2bit.ashn +-Djvector.bench.neighborhoodSize=32 +-Djvector.bench.blockSize=32 +-Djvector.ash.bitsPerDimension=2 +-Djvector.ash.quantizedDimensions=512 +-Djvector.ash.landmarkCount=1 +-Djvector.ash.optimizer=random +-Djvector.bench.maxVectors=99685 +-Djvector.bench.maxQueries=4 +-Djvector.bench.scoringPasses=5 +-Djvector.bench.scoringMinSeconds=0.75 +-Djvector.bench.scoringWarmupSeconds=2 +``` + +To create a fixture, supply `base.fvecs query.fvecs` as program arguments. An +optional third ground-truth argument is accepted for launcher compatibility and +is unused. Input loading reads only the requested prefixes; queries are normalized +and base vectors retain their original values. Models, encoded vectors, and packed +neighborhoods are created outside timing. Replaying an existing fixture needs no +program arguments and performs no training, encoding, or packing. + +Fixtures are published only after a complete write; existing destinations are +preserved. Separate names are required for different bit widths or shapes. +Replay checks version, lengths, model dimensions, query values, payload sizes, and +truncation/trailing data. The printed SHA-256 identifies the exact stored inputs. +Fixture format version 1 and its existing serialized bytes remain compatible. + +For command-line launches, build the reactor and resolve a classpath with the +repository's Maven tooling. Set CPU affinity explicitly, for example +`taskset -c 2 "$JAVA_HOME/bin/java" ...`, using the same VM options and project +classpath as IntelliJ. `ActiveProcessorCount` changes JVM ergonomics; affinity +sets the physical CPU budget. + +## Measurement contract + +One full scan prepares the selected saved query exactly once, then scores every +saved neighborhood exactly once. Headers, score correction, similarity conversion, +and checksum consumption are included. The last neighborhood is padded to capacity +and fully scored. Output reports both real base-vector throughput and scored-lane +throughput, so padding is visible. + +With `neighborhood-scaling=true`, a fixed two-scan pipeline prepares subsequent +queries while persistent workers score earlier scans. Every scan owns independent +prepared state. Pool threads own their output scratch. Timing begins before the +first preparation and ends after all submitted scans complete: preparation, task +submission, completion, checksums, pipeline fill, and drain are included. + +`ns_per_scan` in this mode is total elapsed time divided by completed full scans, +a throughput metric. `ns_preparation_per_scan` is an overlapping diagnostic from +inside that same interval and must not be added again. The same selected saved +query is repeated across scans; no multi-query SIMD batching is used. The regular +non-scaling mode measures serial full scans on the calling thread instead. + +The preparation coordinator, scoring workers, and JVM helper threads share the +stated CPU affinity. One-core experiments have no additional preparation core. +For scaling, select one hardware thread per physical core and keep NUMA placement +consistent. A full scan partitions the same neighborhood sequence at each worker +count; input work does not grow with worker count. Actual preparation bottlenecks, +contention, and scheduling overhead stay visible in the measured result. + +Validation is independent of the LUT kernel and projects queries in double +precision. It checks serialized header effects and uses the unchanged tolerance +`3e-5 + 3e-5 * abs(expected)`. Both expected and actual scores must be finite. +Validation, fixture I/O, and worker creation are outside the timed scan stream. + +### Comparison controls + +Use fresh JVMs for `panama/scalar`, `panama/simd`, and `native/simd`. The scalar +comparator selects the scalar LUT kernel; its query preparation still uses the +Panama provider. Keep fixture hash, query index, bit width, block shape, access +order, JDK, heap, warmup, and affinity identical. The `Scorer:` line identifies the +actual execution path; the native provider's kernel description identifies its ISA. + +The initial Cohere fixtures fit in the server's shared last-level cache. These +measurements describe repeated in-memory scoring, without disk I/O. Results are +specific to the tested workload and hardware. Pipelined full-scan throughput does +not by itself describe adaptive graph traversal latency or graph-search scaling. + +| Property suffix under `jvector.bench.` | Default | Meaning | +| --- | --- | --- | +| `neighborhoodSize` | 32 | Scored capacity when creating a fixture | +| `blockSize` | Chosen from capacity | Serialized width: 8/16/32 | +| `neighborhoodAccess` | sequential | Base order; `shuffled` uses a reproducible permutation | +| `queryIndex` | 0 | Saved query to repeat; validation uses all saved queries | +| `maxVectors` | 8192 | Base input prefix used during fixture creation | +| `maxQueries` | 8 | Query input prefix saved during fixture creation | +| `scoringThreads` | 1 | Persistent scoring workers in pipelined mode | +| `scoringPasses` | 5 | Samples per JVM | +| `scoringMinSeconds` | 0.5 | Minimum sample duration before complete drain | +| `scoringWarmupSeconds` | 1.0 | Warmup duration | +| `validationNeighborhoods` | 1024 | Evenly spaced neighborhoods checked per saved query | + +The former `neighborhoodsPerQuery` setting is rejected: a full scan always covers +the complete fixture. Malformed integer options fail rather than selecting a +silent fallback value. + +## Regression checks + +Run the ASH regression classes with native, Panama, and the default scalar +provider. The focused set is: + +```text +NativeVectorStoreTest +TestASHNeighborhoodScorer +TestASHNativeScoring +TestASHNeighborhoodBenchmark +TestASHLutScoring +TestASHStandaloneScoring +TestASHStandaloneVsFusedScoring +TestASHGraphSearch +TestFusedASHBlockPacking +TestASHScoringDispatch +``` + +For example, with JDK 22 and an appropriate `CXX`: + +```bash +mvn -B -pl jvector-tests -am test \ + -DskipSIMD=false -DskipScalar=true \ + -Djvector.vectorization_provider=native \ + -Dtest=NativeVectorStoreTest,TestASHNeighborhoodScorer,TestASHNativeScoring,TestASHNeighborhoodBenchmark,TestASHLutScoring,TestASHStandaloneScoring,TestASHStandaloneVsFusedScoring,TestASHGraphSearch,TestFusedASHBlockPacking,TestASHScoringDispatch \ + -Dsurefire.failIfNoSpecifiedTests=false +``` + +Native-only ABI checks are skipped when another provider is selected. To test +lower native dispatch tiers on a capable host, use fresh JVMs with +`JVECTOR_MAX_ISA=avx2` or `JVECTOR_MAX_ISA=sse42`. The tests cover critical and ordinary +FFM calls, tails, multiple blocks, invalid shapes, aliasing, canaries, IEEE headers, +query lifetimes, failed workers, interruption, fixture replay, and real graph use. + +## Full-scan recall@10 + +Use the same DistancesASH neighborhood entry point with `jvector.bench.recall=true`. +Set `jvector.bench.recall.groundTruth=/path/to/matching.ivecs` and optionally +`jvector.bench.recall.output=/path/to/new-results.tsv`. Output files are not overwritten. +`scoringThreads` now controls parallel independent queries; each query is prepared +once and scans every stored neighborhood. This mode returns before throughput timing. +It uses all saved queries rather than `queryIndex`; set `maxQueries` at fixture +creation to retain the complete query set. Use the entire base set for recall. + +The top ten are selected directly from approximate scores without exact reranking. +Padding is scored but excluded from top-k, and equal scores prefer the smaller +original base ordinal in every backend. Recall is the intersection with the first +ten unique ground-truth IDs divided by ten, averaged over queries. Little-endian +ivecs rows are validated against the real base cardinality. The optional TSV +retains each query's hit count, ranked IDs and scores, enabling comparison beyond +the aggregate recall. An independent decoded-component check precedes evaluation +on up to four queries; recall itself uses every query and every real base vector. + +Create each fixture once, then replay the identical SHA-256 under scalar, Panama +and native providers. In particular, the legacy DistancesASH canonical-vector +recall mode does not exercise the native whole-neighborhood Highway entry. The +neighborhood recall mode is required to validate that implementation. From e7a4e0f11cb57c46eb63db20128ca1bc19e5c994 Mon Sep 17 00:00:00 2001 From: Ted Willke Date: Wed, 28 Oct 2026 22:29:35 +0000 Subject: [PATCH 089/243] Measure full-scan PQ recall at exact code byte budgets --- docs/pq-iso-compression-recall.md | 64 +++++ .../jbellis/jvector/example/DistancesPQ.java | 82 ++++-- .../jvector/example/PQRecallEvaluator.java | 240 ++++++++++++++++++ .../example/TestPQRecallEvaluator.java | 156 ++++++++++++ 4 files changed, 515 insertions(+), 27 deletions(-) create mode 100644 docs/pq-iso-compression-recall.md create mode 100644 jvector-examples/src/main/java/io/github/jbellis/jvector/example/PQRecallEvaluator.java create mode 100644 jvector-tests/src/test/java/io/github/jbellis/jvector/example/TestPQRecallEvaluator.java diff --git a/docs/pq-iso-compression-recall.md b/docs/pq-iso-compression-recall.md new file mode 100644 index 000000000..ed8fff167 --- /dev/null +++ b/docs/pq-iso-compression-recall.md @@ -0,0 +1,64 @@ +# DistancesPQ full-scan recall at exact code sizes + +`DistancesPQ` supports an accuracy-only mode for comparison with the collected +`DistancesASH` neighborhood full-scan recall. It trains and encodes standard PQ, +prepares one production `PQDecoder.DotProductDecoder` lookup table per query, +scans every encoded base ordinal, and compares its approximate top ten with the +first ten IDs in the matching ground-truth file. It performs no graph search or +exact reranking. Independent queries can run concurrently; the run duration is +not a throughput benchmark. + +Enable `jvector.bench.recall=true` and `jvector.bench.recall-only=true`. Queries +are normalized in this mode to match the ASH neighborhood fixture policy. Base +vectors retain their input values. Exact score ties prefer smaller base IDs. +The existing throughput mode and its query policy remain the default. + +Set `jvector.pq.subspaces=M` for an exact M-byte code: PQ uses 256 centroids per +subspace, so each subspace requires one byte. PQ already supports uneven +subvector sizes. This setting and the legacy `jvector.pq.mFactor` are mutually +exclusive; absent either option the legacy factor of eight is preserved. + +For ASH with C=1, projected dimension D/2, and 2 or 4 bits per projected +coordinate, the packed per-vector budget is `5 + (D/2)*bits/8` bytes. The five +bytes cover the ASH vector header. The corresponding PQ budgets are: + +| Dataset dimension | ASH 2-bit / PQ bytes | ASH 4-bit / PQ bytes | +| ---: | ---: | ---: | +| 768 | 101 | 197 | +| 1024 | 133 | 261 | +| 1536 | 197 | 389 | +| 3072 | 389 | 773 | + +This matches per-vector code bytes, not total serialized index size. Shared +ASH transforms and PQ codebooks must be reported separately. PQ uses its normal +training policy (up to 128,000 sampled vectors, six clustering rounds, no global +centering, no anisotropic weighting). Centroid initialization is stochastic; +preserve the trained artifact to identify the actual model measured. + +Example, with dataset-specific paths and an existing output directory: + +```sh +java --add-modules=jdk.incubator.vector --enable-native-access=ALL-UNNAMED \ + -Djvector.vectorization_provider=panama \ + -Djvector.pq.subspaces=101 \ + -Djvector.bench.recall=true -Djvector.bench.recall-only=true \ + -Djvector.bench.recall.k=10 \ + -Djvector.bench.trainingThreads=48 -Djvector.bench.scoringThreads=48 \ + -Djvector.bench.recall.output=/results/mpnet-pq-101.tsv \ + -Djvector.bench.pq.artifact=/results/mpnet-pq-101.pqv \ + -cp "$JVECTOR_CLASSPATH" io.github.jbellis.jvector.example.DistancesPQ \ + /data/base.fvecs /data/query.fvecs /data/ground-truth.ivecs +``` + +Choose thread counts and JVM memory for the machine and dataset. No query limit +is needed: the default uses all queries. `jvector.bench.maxQueries` can restrict +the query prefix when explicitly desired. Use the same complete base and query +sets as the ASH measurement for a direct comparison. + +The log identifies the provider, ADC implementation, normalized-query SHA-256, +shared codebook size, and exact hit numerator/denominator. A small independent +reconstruction check validates ADC scores before the recall scan. The TSV stores +per-query IDs, scores and hits; the optional PQ artifact stores the trained +compressor and encoded base set. Output paths must not exist. No scalar +comparison is implied by the accuracy-only flag: SIMD selection is controlled +by the provider and available vector width. diff --git a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/DistancesPQ.java b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/DistancesPQ.java index 39f96a2b8..84b066a83 100644 --- a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/DistancesPQ.java +++ b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/DistancesPQ.java @@ -42,7 +42,11 @@ public class DistancesPQ { * - Same loaded query vectors * - Same brute-force scan pattern * - Configurable training and single-thread scoring executors - * - Compression budget selected with jvector.pq.mFactor + * - Compression budget selected with jvector.pq.subspaces (exact bytes), or jvector.pq.mFactor + * + * Set jvector.bench.recall and jvector.bench.recall-only for a complete ADC recall scan + * with normalized queries, deterministic ties, optional rankings and a saved PQ artifact. + * This mode omits the throughput loops; it does not measure scoring speed. * * Fast PQ path = PQDecoder (precomputedScoreFunctionFor). */ @@ -53,6 +57,11 @@ public static void testPQEncodings(String filenameBase, String filenameQueries, final boolean RUN_RECALL_CHECK = Boolean.parseBoolean(System.getProperty("jvector.bench.recall", "false")); + final boolean recallOnly = Boolean.getBoolean("jvector.bench.recall-only"); + if (recallOnly && !RUN_RECALL_CHECK) { + throw new IllegalArgumentException("recall-only requires jvector.bench.recall=true"); + } + final int RECALL_K = Integer.getInteger("jvector.bench.recall.k", 10); @@ -66,6 +75,15 @@ public static void testPQEncodings(String filenameBase, String filenameQueries, int maxQueries = Integer.getInteger("jvector.bench.maxQueries", allQueries.size()); if (maxQueries <= 0) throw new IllegalArgumentException("maxQueries must be positive"); final List> queries = allQueries.subList(0, Math.min(maxQueries, allQueries.size())); + if (vectors.isEmpty() || queries.isEmpty()) { + throw new IllegalArgumentException("Base and query vectors must be nonempty"); + } + if (recallOnly) { + // Match the query-normalization policy used by the neighborhood recall benchmark. + for (VectorFloat query : queries) { + VectorUtil.l2normalize(query); + } + } System.out.format("\t%d base and %d measured query vectors loaded%n", vectors.size(), queries.size()); final List> groundTruth; @@ -113,33 +131,12 @@ public static void testPQEncodings(String filenameBase, String filenameQueries, // ------------------------------------------------------------ // PQ parameters // ------------------------------------------------------------ - // M is derived from the input dimension: - // - // M = dimension / mFactor - // - // With K=256, each subspace code is one byte, so PQ stores M bytes - // per vector. Since the input is float32, the exact compression ratio is: - // - // (dimension * 4 bytes) / M bytes = 4 * mFactor - // - // This requires dimension to be evenly divisible by mFactor. + // K=256 stores one byte per subspace. An explicit subspace count matches + // any byte budget up to the input dimension, including uneven partitions. + // The legacy mFactor option instead requires evenly divisible dimensions. int dimension = vectors.get(0).length(); - final int mFactor = Integer.getInteger("jvector.pq.mFactor", 8); - if (mFactor <= 0) { - throw new IllegalArgumentException( - "jvector.pq.mFactor must be positive; got " + mFactor - ); - } - if (dimension % mFactor != 0) { - throw new IllegalArgumentException( - "dimension must be evenly divisible by jvector.pq.mFactor: " - + "dimension=" + dimension - + ", mFactor=" + mFactor - ); - } - - final int M = dimension / mFactor; + final int M = subspaceCount(dimension); final int K = 256; // 1 byte per subspace final boolean globallyCenter = false; final float anisotropicThreshold = -1.0f; @@ -151,7 +148,8 @@ public static void testPQEncodings(String filenameBase, String filenameQueries, System.out.println("\tPQ params:"); System.out.println("\t dimension = " + dimension); - System.out.println("\t mFactor = " + mFactor); + System.out.println("\t budget selection = " + (System.getProperty("jvector.pq.subspaces") == null + ? "dimension / mFactor" : "explicit subspaces (uneven partitions supported)")); System.out.println("\t M = " + M + " subspaces"); System.out.println("\t K = " + K + " clusters"); System.out.println("\t Input size = " + inputBytesPerVector + " bytes/vector"); @@ -199,6 +197,16 @@ public static void testPQEncodings(String filenameBase, String filenameQueries, ); + System.out.println("\tShared PQ compressor size = " + pq.compressorSize() + " bytes"); + if (recallOnly) { + try { + PQRecallEvaluator.run(pqVecs, queries, groundTruth, RECALL_K, simdExecutor); + } finally { + simdExecutor.shutdown(); + } + return; + } + // ============================================================ // [1] Accuracy (NOT timed) // Compare unscaled dot products: @@ -439,6 +447,26 @@ public static void testPQEncodings(String filenameBase, String filenameQueries, } } + /** Resolve an exact byte budget, or retain the legacy evenly divisible mFactor setting. */ + static int subspaceCount(int dimension) { + String exact = System.getProperty("jvector.pq.subspaces"); + if (exact != null) { + if (System.getProperty("jvector.pq.mFactor") != null) { + throw new IllegalArgumentException("Set either jvector.pq.subspaces or jvector.pq.mFactor"); + } + int count = Integer.parseInt(exact); + if (count < 1 || count > dimension) { + throw new IllegalArgumentException("PQ subspaces must be in [1, dimension]"); + } + return count; + } + int factor = Integer.parseInt(System.getProperty("jvector.pq.mFactor", "8")); + if (factor < 1 || dimension % factor != 0) { + throw new IllegalArgumentException("dimension must be evenly divisible by positive jvector.pq.mFactor"); + } + return dimension / factor; + } + // ------------------------------------------------------------------ // Dataset runners (mirrors DistancesASH) // ------------------------------------------------------------------ diff --git a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/PQRecallEvaluator.java b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/PQRecallEvaluator.java new file mode 100644 index 000000000..6d69c8f08 --- /dev/null +++ b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/PQRecallEvaluator.java @@ -0,0 +1,240 @@ +/* + * 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; + +import io.github.jbellis.jvector.disk.SimpleWriter; +import io.github.jbellis.jvector.graph.disk.OnDiskGraphIndex; +import io.github.jbellis.jvector.graph.similarity.ScoreFunction; +import io.github.jbellis.jvector.quantization.PQVectors; +import io.github.jbellis.jvector.vector.VectorSimilarityFunction; +import io.github.jbellis.jvector.vector.VectorUtil; +import io.github.jbellis.jvector.vector.VectorizationProvider; +import io.github.jbellis.jvector.vector.types.ByteSequence; +import io.github.jbellis.jvector.vector.types.VectorFloat; +import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardOpenOption; +import java.security.MessageDigest; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Comparator; +import java.util.List; +import java.util.PriorityQueue; +import java.util.concurrent.ForkJoinPool; +import java.util.concurrent.ForkJoinTask; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.Locale; + +/** + * DistancesPQ accuracy-only evaluation using the production precomputed ADC scorer. + * Each query prepares one lookup table and scans the complete encoded base set. + * Exact ties prefer smaller original ordinals, matching neighborhood ASH recall. + * Optional outputs preserve per-query rankings and the trained/encoded PQ artifact. + */ +final class PQRecallEvaluator { + private PQRecallEvaluator() {} + + static void run(PQVectors vectors, List> queries, List> truth, int k, + ForkJoinPool executor) throws IOException { + if (queries.isEmpty() || k < 1 || k > vectors.count() || truth.size() < queries.size()) { + throw new IllegalArgumentException("Invalid recall size or missing ground truth"); + } + for (int q = 0; q < queries.size(); q++) { + if (truth.get(q).size() < k || truth.get(q).subList(0, k).stream().distinct().count() != k) { + throw new IllegalArgumentException("Ground truth must contain k distinct neighbors"); + } + for (int id : truth.get(q).subList(0, k)) { + if (id < 0 || id >= vectors.count()) { + throw new IllegalArgumentException("Ground-truth ordinal outside base set"); + } + } + } + var backend = VectorizationProvider.getInstance().getVectorUtilSupport(); + try { + var method = backend.getClass().getMethod( + "assembleAndSum", VectorFloat.class, int.class, ByteSequence.class, int.class, int.class); + System.out.println("PQ ADC backend: " + backend.getClass().getName() + + "; assembly=" + method.getDeclaringClass().getName()); + } catch (NoSuchMethodException error) { + throw new AssertionError("Missing PQ assembly contract", error); + } + var sample = vectors.precomputedScoreFunctionFor(queries.get(0), VectorSimilarityFunction.DOT_PRODUCT); + System.out.println("PQ ADC scorer: " + sample.getClass().getName()); + System.out.println("Normalized query SHA-256: " + queryHash(queries)); + // Compare the lookup scorer with full reconstruction outside the accuracy scan. + validate(vectors, queries); + String artifact = System.getProperty("jvector.bench.pq.artifact"); + if (artifact != null) { + Path target = Path.of(artifact); + if (Files.exists(target)) { + throw new IOException("PQ artifact already exists: " + target); + } + Path temporary = Files.createTempFile(target.toAbsolutePath().getParent(), ".pq-recall-", ".tmp"); + try { + try (var writer = new SimpleWriter(temporary)) { + vectors.write(writer, OnDiskGraphIndex.CURRENT_VERSION); + } + Files.move(temporary, target); + } finally { + Files.deleteIfExists(temporary); + } + } + String outputName = System.getProperty("jvector.bench.recall.output"); + if (outputName != null && Files.exists(Path.of(outputName))) { + throw new IOException("Recall output already exists: " + outputName); + } + Result[] results = new Result[queries.size()]; + var finished = new AtomicInteger(); + List> tasks = new ArrayList<>(); + int workers = Math.min(executor.getParallelism(), queries.size()); + for (int worker = 0; worker < workers; worker++) { + final int first = worker; + tasks.add(executor.submit(() -> { + for (int q = first; q < queries.size(); q += workers) { + var scorer = + vectors.precomputedScoreFunctionFor(queries.get(q), VectorSimilarityFunction.DOT_PRODUCT); + var result = rank(scorer, vectors.count(), truth.get(q).subList(0, k), k); + results[q] = result; + int count = finished.incrementAndGet(); + if (count % 100 == 0 || count == queries.size()) { + System.out.printf( + Locale.ROOT, "PQ recall progress: %d/%d queries complete%n", count, queries.size()); + } + } + })); + } + for (var task : tasks) { + task.join(); + } + long hits = Arrays.stream(results).mapToLong(r -> r.hits).sum(); + if (outputName != null) { + try (var out = Files.newBufferedWriter( + Path.of(outputName), StandardOpenOption.CREATE_NEW, StandardOpenOption.WRITE)) { + out.write("query\thits_at_" + k + "\tids_best_first\tscores_best_first\n"); + for (int q = 0; q < results.length; q++) { + var r = results[q]; + out.write(q + "\t" + r.hits + "\t" + Arrays.toString(r.ids) + "\t" + Arrays.toString(r.scores) + + "\n"); + } + } + } + System.out.printf(Locale.ROOT, + "PQ_RECALL queries=%d baseVectors=%d subspaces=%d codeBytes=%d hits=%d denominator=%d recall_at_%d=%.9f%n", + queries.size(), vectors.count(), vectors.getCompressor().getSubspaceCount(), + vectors.getCompressor().compressedVectorSize(), hits, (long) k * queries.size(), k, + hits / ((double) k * queries.size())); + } + + /** Independent decoded-vector check; never used for ranking or reranking. */ + static void validate(PQVectors vectors, List> queries) { + var types = VectorizationProvider.getInstance().getVectorTypeSupport(); + var decoded = types.createFloatVector(queries.get(0).length()); + double maximum = 0; + for (int q = 0; q < Math.min(4, queries.size()); q++) { + var scorer = vectors.precomputedScoreFunctionFor(queries.get(q), VectorSimilarityFunction.DOT_PRODUCT); + for (int i = 0; i < Math.min(256, vectors.count()); i++) { + int id = (int) ((long) i * vectors.count() / Math.min(256, vectors.count())); + vectors.getCompressor().decode(vectors.get(id), decoded); + float expected = (1f + VectorUtil.dotProduct(queries.get(q), decoded)) / 2f; + float actual = scorer.similarityTo(id); + maximum = Math.max(maximum, Math.abs(expected - actual)); + if (!Float.isFinite(expected) || !Float.isFinite(actual) + || Math.abs(expected - actual) > 3e-5 + 3e-5 * Math.abs(expected)) { + throw new AssertionError("PQ lookup/reconstruction mismatch at query=" + q + " ordinal=" + id); + } + } + } + System.out.printf(Locale.ROOT, "PQ decoded-vector validation maxAbsError=%.9g%n", maximum); + } + + static final class Result { + final int[] ids; + final float[] scores; + final int hits; + Result(int[] ids, float[] scores, int hits) { + this.ids = ids; + this.scores = scores; + this.hits = hits; + } + } + + /** Keep precisely k results; input ordinals remain in their original base order. */ + static Result rank(ScoreFunction scorer, int count, List truth, int k) { + if (k < 1 || k > count) { + throw new IllegalArgumentException("Recall k must be in [1, base count]"); + } + Comparator worst = (a, b) -> { + int compare = Float.compare(Float.intBitsToFloat((int) a[0]), Float.intBitsToFloat((int) b[0])); + return compare != 0 ? compare : Long.compare(b[1], a[1]); + }; + var heap = new PriorityQueue(k, worst); + for (int id = 0; id < count; id++) { + float score = scorer.similarityTo(id); + if (!Float.isFinite(score)) { + throw new IllegalStateException("Non-finite PQ score at ordinal " + id); + } + if (heap.size() < k) { + heap.add(new long[] {Float.floatToRawIntBits(score), id}); + } else { + var bottom = heap.peek(); + float threshold = Float.intBitsToFloat((int) bottom[0]); + if (score > threshold || (score == threshold && id < bottom[1])) { + heap.poll(); + heap.add(new long[] {Float.floatToRawIntBits(score), id}); + } + } + } + int[] ids = new int[k]; + float[] scores = new float[k]; + int hits = 0; + for (int rank = k - 1; rank >= 0; rank--) { + var candidate = heap.remove(); + ids[rank] = (int) candidate[1]; + scores[rank] = Float.intBitsToFloat((int) candidate[0]); + if (truth.contains(ids[rank])) { + hits++; + } + } + return new Result(ids, scores, hits); + } + + private static String queryHash(List> queries) { + try { + var digest = MessageDigest.getInstance("SHA-256"); + var buffer = ByteBuffer.allocate(8192); + for (var query : queries) { + for (int d = 0; d < query.length(); d++) { + if (buffer.remaining() < Float.BYTES) { + digest.update(buffer.array(), 0, buffer.position()); + buffer.clear(); + } + buffer.putFloat(query.get(d)); + } + } + digest.update(buffer.array(), 0, buffer.position()); + var text = new StringBuilder(); + for (byte b : digest.digest()) { + text.append(Character.forDigit((b >>> 4) & 15, 16)).append(Character.forDigit(b & 15, 16)); + } + return text.toString(); + } catch (java.security.NoSuchAlgorithmException impossible) { + throw new AssertionError(impossible); + } + } +} diff --git a/jvector-tests/src/test/java/io/github/jbellis/jvector/example/TestPQRecallEvaluator.java b/jvector-tests/src/test/java/io/github/jbellis/jvector/example/TestPQRecallEvaluator.java new file mode 100644 index 000000000..cfa51b1f4 --- /dev/null +++ b/jvector-tests/src/test/java/io/github/jbellis/jvector/example/TestPQRecallEvaluator.java @@ -0,0 +1,156 @@ +/* + * 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; + +import io.github.jbellis.jvector.disk.SimpleMappedReader; +import io.github.jbellis.jvector.graph.ListRandomAccessVectorValues; +import io.github.jbellis.jvector.graph.similarity.ScoreFunction; +import io.github.jbellis.jvector.quantization.PQVectors; +import io.github.jbellis.jvector.quantization.ProductQuantization; +import io.github.jbellis.jvector.vector.VectorSimilarityFunction; +import io.github.jbellis.jvector.vector.VectorUtil; +import io.github.jbellis.jvector.vector.VectorizationProvider; +import io.github.jbellis.jvector.vector.types.VectorFloat; +import java.nio.file.Files; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Random; +import java.util.concurrent.ForkJoinPool; +import org.junit.Test; +import static org.junit.Assert.*; + +public class TestPQRecallEvaluator { + @Test + public void resolvesExactStoredByteBudgetsAndPreservesLegacyFactor() { + String exact = System.getProperty("jvector.pq.subspaces"); + String factor = System.getProperty("jvector.pq.mFactor"); + try { + System.clearProperty("jvector.pq.subspaces"); + System.clearProperty("jvector.pq.mFactor"); + assertEquals(96, DistancesPQ.subspaceCount(768)); + System.setProperty("jvector.pq.mFactor", "4"); + assertEquals(192, DistancesPQ.subspaceCount(768)); + assertThrows(IllegalArgumentException.class, () -> DistancesPQ.subspaceCount(767)); + System.clearProperty("jvector.pq.mFactor"); + for (int dimension : new int[] {768, 1024, 1536, 3072}) { + for (int bits : new int[] {2, 4}) { + int bytes = 5 + (dimension / 2) * bits / 8; + System.setProperty("jvector.pq.subspaces", Integer.toString(bytes)); + assertEquals(bytes, DistancesPQ.subspaceCount(dimension)); + } + } + for (String bad : new String[] {"0", "-1", "769", "bad"}) { + System.setProperty("jvector.pq.subspaces", bad); + assertThrows(IllegalArgumentException.class, () -> DistancesPQ.subspaceCount(768)); + } + System.setProperty("jvector.pq.subspaces", "101"); + System.setProperty("jvector.pq.mFactor", "8"); + assertThrows(IllegalArgumentException.class, () -> DistancesPQ.subspaceCount(768)); + } finally { + restore("jvector.pq.subspaces", exact); + restore("jvector.pq.mFactor", factor); + } + } + + @Test + public void scansFinalOrdinalAndResolvesBoundaryTiesById() { + float[] scores = {.1f, .8f, .9f, .8f, .8f, .2f, 1f}; + ScoreFunction.ApproximateScoreFunction scorer = i -> scores[i]; + var result = PQRecallEvaluator.rank(scorer, scores.length, List.of(6, 3, 1), 3); + assertArrayEquals(new int[] {6, 2, 1}, result.ids); + assertArrayEquals(new float[] {1f, .9f, .8f}, result.scores, 0f); + assertEquals(2, result.hits); + scores[6] = Float.NaN; + assertThrows(IllegalStateException.class, () -> PQRecallEvaluator.rank(scorer, scores.length, List.of(1), 1)); + } + + @Test + public void unevenPqRoundTripPreservesScoresAndPerQueryRankings() throws Exception { + var types = VectorizationProvider.getInstance().getVectorTypeSupport(); + List> base = new ArrayList<>(); + var random = new Random(1307); + for (int i = 0; i < 96; i++) { + float[] values = new float[10]; + for (int d = 0; d < values.length; d++) + values[d] = random.nextFloat() - .5f; + var vector = types.createFloatVector(values); + VectorUtil.l2normalize(vector); + base.add(vector); + } + var executor = new ForkJoinPool(2); + var folder = Files.createTempDirectory("pq-full-recall-"); + var artifact = folder.resolve("vectors.pqv"); + var rankings = folder.resolve("rankings.tsv"); + String oldArtifact = System.getProperty("jvector.bench.pq.artifact"); + String oldOutput = System.getProperty("jvector.bench.recall.output"); + try { + var ravv = new ListRandomAccessVectorValues(base, 10); + var pq = ProductQuantization.compute(ravv, 3, 16, false, -1f, executor, executor); + var encoded = pq.encodeAll(ravv, executor); + assertEquals(3, pq.compressedVectorSize()); + var queries = base.subList(0, 3); + PQRecallEvaluator.validate(encoded, queries); + List> truth = new ArrayList<>(); + for (var query : queries) { + var scorer = encoded.precomputedScoreFunctionFor(query, VectorSimilarityFunction.DOT_PRODUCT); + Integer[] all = new Integer[base.size()]; + Arrays.setAll(all, i -> i); + Arrays.sort(all, (a, b) -> { + int c = Float.compare(scorer.similarityTo(b), scorer.similarityTo(a)); + return c != 0 ? c : Integer.compare(a, b); + }); + var top = Arrays.asList(Arrays.copyOf(all, 10)); + truth.add(top); + var result = PQRecallEvaluator.rank(scorer, base.size(), top, 10); + assertArrayEquals(top.stream().mapToInt(Integer::intValue).toArray(), result.ids); + assertEquals(10, result.hits); + } + System.setProperty("jvector.bench.pq.artifact", artifact.toString()); + System.setProperty("jvector.bench.recall.output", rankings.toString()); + PQRecallEvaluator.run(encoded, queries, truth, 10, executor); + var rows = Files.readAllLines(rankings); + assertEquals(4, rows.size()); + for (int q = 0; q < queries.size(); q++) + assertTrue(rows.get(q + 1).startsWith(q + "\t10\t")); + try (var supplier = new SimpleMappedReader.Supplier(artifact); var reader = supplier.get()) { + var loaded = PQVectors.load(reader); + assertEquals(encoded.count(), loaded.count()); + for (var query : queries) { + var original = encoded.precomputedScoreFunctionFor(query, VectorSimilarityFunction.DOT_PRODUCT); + var restored = loaded.precomputedScoreFunctionFor(query, VectorSimilarityFunction.DOT_PRODUCT); + for (int i = 0; i < base.size(); i++) { + assertEquals(original.similarityTo(i), restored.similarityTo(i), 0f); + } + } + } + } finally { + executor.shutdown(); + restore("jvector.bench.pq.artifact", oldArtifact); + restore("jvector.bench.recall.output", oldOutput); + Files.deleteIfExists(artifact); + Files.deleteIfExists(rankings); + Files.deleteIfExists(folder); + } + } + + private static void restore(String key, String value) { + if (value == null) + System.clearProperty(key); + else + System.setProperty(key, value); + } +} From 7d088b1b1d6a941102e0a265bb5c0bf773bb0af7 Mon Sep 17 00:00:00 2001 From: Ted Willke Date: Wed, 28 Oct 2026 22:48:18 +0000 Subject: [PATCH 090/243] Document trained ASH versus PQ recall at equal code sizes --- docs/pq-iso-compression-recall.md | 51 +++++++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) diff --git a/docs/pq-iso-compression-recall.md b/docs/pq-iso-compression-recall.md index ed8fff167..5d84343da 100644 --- a/docs/pq-iso-compression-recall.md +++ b/docs/pq-iso-compression-recall.md @@ -13,6 +13,16 @@ are normalized in this mode to match the ASH neighborhood fixture policy. Base vectors retain their input values. Exact score ties prefer smaller base IDs. The existing throughput mode and its query policy remain the default. +For a comparison of learned quantizers, run ASH with +`jvector.ash.optimizer=itq`. This uses the existing PCA/ITQ training defaults: +`min(N,20D)` sampled base vectors, up to 25 iterations, and early stopping. +Keep C=1, d=D/2 and the same bit width when reproducing the table below. +`DistancesASH` defaults to the RANDOM transform for its isolated kernel runs; +those results are an untrained control rather than a trained-ASH accuracy claim. +Use a fresh neighborhood fixture path for ITQ: an existing fixture replays its +stored model, so changing the optimizer property does not retrain that fixture. +Preserve the training log and verify the serialized optimizer and matrix hash. + Set `jvector.pq.subspaces=M` for an exact M-byte code: PQ uses 256 centroids per subspace, so each subspace requires one byte. PQ already supports uneven subvector sizes. This setting and the legacy `jvector.pq.mFactor` are mutually @@ -62,3 +72,44 @@ per-query IDs, scores and hits; the optional PQ artifact stores the trained compressor and encoded base set. Output paths must not exist. No scalar comparison is implied by the accuracy-only flag: SIMD selection is controlled by the provider and available vector width. + +## Measured trained-ASH comparison + +JDK 22.0.2+9, Panama SIMD (512-bit), complete base sets and all queries, +C=1, d=D/2, 32-vector neighborhoods, no graph traversal or reranking. +ASH uses the unchanged ITQ training defaults described above. PQ uses its +normal K=256 training policy. Each case retains its trained model and rankings. + +| Dataset | ASH bits | Bytes/vector | ASH recall@10 | PQ recall@10 | ASH − PQ (pp) | +| --- | ---: | ---: | ---: | ---: | ---: | +| mpnet-1M | 2 | 101 | 88.011% | 82.040% | +5.971 | +| mpnet-1M | 4 | 197 | 95.569% | 89.478% | +6.091 | +| cohere-1M | 2 | 133 | 77.200% | 74.416% | +2.784 | +| cohere-1M | 4 | 261 | 91.421% | 84.496% | +6.925 | +| ada002-1M | 2 | 197 | 86.449% | 66.772% | +19.677 | +| ada002-1M | 4 | 389 | 94.217% | 81.731% | +12.486 | +| openai-3072-1M | 2 | 389 | 90.570% | 85.600% | +4.970 | +| openai-3072-1M | 4 | 773 | 96.170% | 92.130% | +4.040 | + +MPNet, Cohere and Ada002 use 10,000 queries each; OpenAI-3072 uses 1,000. +Cleaned base counts are respectively 999,812; 1,000,000; 982,790; and 999,000. +All eight cases favor trained ASH in this measurement. Shared model storage is +excluded from the matched code sizes: ASH models are approximately 1.18/2.10/4.72/18.89 MB +for these datasets, versus PQ's 0.79/1.05/1.57/3.15 MB. This is not equal total +serialized storage and does not compare with OPQ or Faiss 4-bit FastScan. + +The original collected ASH results used RANDOM transforms and remain controls. +For example, Cohere RANDOM recall was 59.898%/65.499%; trained recall is +77.200%/91.421%. No model or parameter was selected using recall. PQ has one +stochastic fresh fit per case, saved for reproduction. + +Source for the measured runs: `c136c5411a4fa207823d0b8f9cdc2ae780bcf6cc`. +Evidence on the benchmark host: `/home/ted_willke/benchmark-runs/pq-iso-ash-full-scan-recall-jdk22-20261028`. +Its `summary.csv` includes exact hit numerators, denominators and model hashes; +`validation.json` records independent ground-truth/ranking checks. All prepared +query hashes agree bit for bit. Each trained ASH fixture declares ITQ and has a +different matrix from its RANDOM counterpart. Logs and rankings are retained +for both methods, with PQ artifacts in `artifacts/` and ASH fixtures in +`ash-itq/fixtures/`. Source, protocol and outputs are also attached to the local +comparison report. These are accuracy runs with 48 independent query workers; +no throughput conclusion is drawn from their durations. From 2b25871c284d950c250434fbb449bd12340f00d4 Mon Sep 17 00:00:00 2001 From: Ted Willke Date: Wed, 28 Oct 2026 23:32:53 +0000 Subject: [PATCH 091/243] Clean up ASH rebase dependency and writer integration --- jvector-base/pom.xml | 5 ----- .../main/java/io/github/jbellis/jvector/example/Grid.java | 7 ++++--- 2 files changed, 4 insertions(+), 8 deletions(-) diff --git a/jvector-base/pom.xml b/jvector-base/pom.xml index e3c12502f..8f105bff1 100644 --- a/jvector-base/pom.xml +++ b/jvector-base/pom.xml @@ -25,11 +25,6 @@ dev.ludovic.netlib lapack - - dev.ludovic.netlib - lapack - 3.0.3 - diff --git a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/Grid.java b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/Grid.java index c13712e64..e7c03643e 100644 --- a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/Grid.java +++ b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/Grid.java @@ -61,6 +61,7 @@ import io.github.jbellis.jvector.quantization.CompressedVectors; import io.github.jbellis.jvector.quantization.BQVectors; +import io.github.jbellis.jvector.quantization.BinaryQuantization; import io.github.jbellis.jvector.quantization.NVQuantization; import io.github.jbellis.jvector.quantization.PQVectors; import io.github.jbellis.jvector.quantization.ProductQuantization; @@ -414,7 +415,7 @@ private static BuildOnDiskResult buildOnDisk(List> feat buildQuantType = "PQ"; } else if (buildCompressor instanceof AsymmetricHashing) { buildQuantType = "ASH"; - } else if (buildCompressor instanceof io.github.jbellis.jvector.quantization.BinaryQuantization) { + } else if (buildCompressor instanceof BinaryQuantization) { buildQuantType = "BQ"; } else if (buildCompressor instanceof NVQuantization) { buildQuantType = "NVQ"; @@ -424,7 +425,7 @@ private static BuildOnDiskResult buildOnDisk(List> feat // Validation for encoding time support (NVQ not supported) if (constructionMetrics != null && buildQuantType != null) { - if (!"PQ".equals(buildQuantType) && !"ASH".equals(buildQuantType) && !(buildCompressor instanceof io.github.jbellis.jvector.quantization.BinaryQuantization)) { + if (!"PQ".equals(buildQuantType) && !"ASH".equals(buildQuantType) && !"BQ".equals(buildQuantType)) { throw new IllegalArgumentException("Encode timing only supported for PQ, ASH or BQ; got: " + buildQuantType); } } @@ -536,7 +537,7 @@ private static BuildOnDiskResult buildOnDisk(List> feat var vector = vv.get().getVector(node); if (!deferFeatures) writeInlineStates(writers, inputSuppliers, node, vector); if (allSymmetric) builder.addGraphNode(node, bsp.searchProviderFor(node)); - else builder.addGraphNode(node, vector); + else builder.addGraphNode(node, vector); }); }).join(); } From ca1d4857926720ede288b6825d69c535ef0f01f9 Mon Sep 17 00:00:00 2001 From: Ted Willke Date: Wed, 28 Oct 2026 23:38:56 +0000 Subject: [PATCH 092/243] Register fused ASH in the version 7 format extension --- .../jvector/graph/disk/GraphIndexFormatV6.java | 8 +++++--- .../jvector/graph/disk/GraphIndexFormatV7.java | 13 ++++++++++++- .../graph/disk/TestGraphIndexFormatFactory.java | 14 +++++++++++--- 3 files changed, 28 insertions(+), 7 deletions(-) diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/graph/disk/GraphIndexFormatV6.java b/jvector-base/src/main/java/io/github/jbellis/jvector/graph/disk/GraphIndexFormatV6.java index e08ea5560..ca803eea0 100644 --- a/jvector-base/src/main/java/io/github/jbellis/jvector/graph/disk/GraphIndexFormatV6.java +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/graph/disk/GraphIndexFormatV6.java @@ -20,6 +20,8 @@ import io.github.jbellis.jvector.disk.RandomAccessReader; import io.github.jbellis.jvector.graph.disk.feature.Feature; import io.github.jbellis.jvector.graph.disk.feature.FeatureId; + +import java.util.Set; import io.github.jbellis.jvector.graph.disk.feature.FusedFeature; import java.io.IOException; @@ -44,12 +46,12 @@ class GraphIndexFormatV6 extends GraphIndexFormatV5 { /** Creates the singleton format for version 6. */ GraphIndexFormatV6() { - this(6); + this(6, allFeatures()); } /** Allows later feature extensions to reuse the V6 record and hierarchy layout. */ - protected GraphIndexFormatV6(int version) { - super(version, allFeatures()); + protected GraphIndexFormatV6(int version, Set supportedFeatures) { + super(version, supportedFeatures); } /** Places fused features last so non-fused inline features occupy a contiguous prefix. */ diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/graph/disk/GraphIndexFormatV7.java b/jvector-base/src/main/java/io/github/jbellis/jvector/graph/disk/GraphIndexFormatV7.java index 6c10e1732..d512de89d 100644 --- a/jvector-base/src/main/java/io/github/jbellis/jvector/graph/disk/GraphIndexFormatV7.java +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/graph/disk/GraphIndexFormatV7.java @@ -16,12 +16,23 @@ package io.github.jbellis.jvector.graph.disk; +import io.github.jbellis.jvector.graph.disk.feature.FeatureId; +import java.util.EnumSet; +import java.util.Set; + /** * Version 7 adds ASH feature payloads while retaining the V6 graph record layout. * Feature headers describe the quantizer and the fused source representation. */ final class GraphIndexFormatV7 extends GraphIndexFormatV6 { GraphIndexFormatV7() { - super(7); + super(7, supportedFeatures()); + } + + /** The extension is restricted to V7; earlier formats retain their feature sets. */ + private static Set supportedFeatures() { + var features = EnumSet.copyOf(allFeatures()); + features.add(FeatureId.FUSED_ASH); + return features; } } diff --git a/jvector-tests/src/test/java/io/github/jbellis/jvector/graph/disk/TestGraphIndexFormatFactory.java b/jvector-tests/src/test/java/io/github/jbellis/jvector/graph/disk/TestGraphIndexFormatFactory.java index 27c0a82e8..db64f17a6 100644 --- a/jvector-tests/src/test/java/io/github/jbellis/jvector/graph/disk/TestGraphIndexFormatFactory.java +++ b/jvector-tests/src/test/java/io/github/jbellis/jvector/graph/disk/TestGraphIndexFormatFactory.java @@ -89,9 +89,9 @@ public void testFormatCharacteristicsPerVersion() { assertFalse(v2.usesFooter()); assertEquals(EnumSet.of(FeatureId.INLINE_VECTORS), v2.getSupportedFeatures()); - // versions 3-5 support every feature except FUSED_PQ; multi-layer arrives at v4, + // Versions 3-5 predate fused features; multi-layer arrives at v4, // footer-based metadata arrives at v5. - var nonFused = EnumSet.complementOf(EnumSet.of(FeatureId.FUSED_PQ)); + var nonFused = EnumSet.complementOf(EnumSet.of(FeatureId.FUSED_PQ, FeatureId.FUSED_ASH)); for (int version : new int[]{3, 4, 5}) { var format = GraphIndexFormatFactory.forVersion(version); assertEquals("version " + version + " supported features", nonFused, format.getSupportedFeatures()); @@ -104,7 +104,15 @@ public void testFormatCharacteristicsPerVersion() { assertTrue(v6.supportsMultiLayer()); assertTrue(v6.usesFooter()); assertTrue(v6.supportsFeature(FeatureId.FUSED_PQ)); - assertEquals(EnumSet.allOf(FeatureId.class), v6.getSupportedFeatures()); + assertFalse(v6.supportsFeature(FeatureId.FUSED_ASH)); + assertEquals(EnumSet.complementOf(EnumSet.of(FeatureId.FUSED_ASH)), v6.getSupportedFeatures()); + + var v7 = GraphIndexFormatFactory.forVersion(7); + assertTrue(v7.supportsMultiLayer()); + assertTrue(v7.usesFooter()); + assertTrue(v7.supportsFeature(FeatureId.FUSED_PQ)); + assertTrue(v7.supportsFeature(FeatureId.FUSED_ASH)); + assertEquals(EnumSet.allOf(FeatureId.class), v7.getSupportedFeatures()); } @Test From ae9f956b22042facb105b9d09b82a319994a129c Mon Sep 17 00:00:00 2001 From: Ted Willke Date: Thu, 29 Oct 2026 00:40:49 +0000 Subject: [PATCH 093/243] Normalize graph format extension imports --- .../github/jbellis/jvector/graph/disk/GraphIndexFormatV6.java | 3 +-- .../github/jbellis/jvector/graph/disk/GraphIndexFormatV7.java | 1 + 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/graph/disk/GraphIndexFormatV6.java b/jvector-base/src/main/java/io/github/jbellis/jvector/graph/disk/GraphIndexFormatV6.java index ca803eea0..6c97cd73d 100644 --- a/jvector-base/src/main/java/io/github/jbellis/jvector/graph/disk/GraphIndexFormatV6.java +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/graph/disk/GraphIndexFormatV6.java @@ -20,14 +20,13 @@ import io.github.jbellis.jvector.disk.RandomAccessReader; import io.github.jbellis.jvector.graph.disk.feature.Feature; import io.github.jbellis.jvector.graph.disk.feature.FeatureId; - -import java.util.Set; import io.github.jbellis.jvector.graph.disk.feature.FusedFeature; import java.io.IOException; import java.util.EnumMap; import java.util.LinkedHashMap; import java.util.Map; +import java.util.Set; import java.util.function.IntFunction; import java.util.stream.Collectors; diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/graph/disk/GraphIndexFormatV7.java b/jvector-base/src/main/java/io/github/jbellis/jvector/graph/disk/GraphIndexFormatV7.java index d512de89d..b0a703116 100644 --- a/jvector-base/src/main/java/io/github/jbellis/jvector/graph/disk/GraphIndexFormatV7.java +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/graph/disk/GraphIndexFormatV7.java @@ -17,6 +17,7 @@ package io.github.jbellis.jvector.graph.disk; import io.github.jbellis.jvector.graph.disk.feature.FeatureId; + import java.util.EnumSet; import java.util.Set; From f81d2c0e483c6d575dcce8e3f21d65d3b9f961e9 Mon Sep 17 00:00:00 2001 From: Ted Willke Date: Thu, 29 Oct 2026 00:40:50 +0000 Subject: [PATCH 094/243] Report inclusive and construction-only BenchYAML measurements --- docs/benchmarking.md | 31 +++++++++++++++++ .../jbellis/jvector/example/BenchYAML.java | 4 +++ .../github/jbellis/jvector/example/Grid.java | 33 ++++++++++++++++++- .../reporting/ExperimentsSchemaV1.java | 27 +++++++++++---- .../reporting/LoggingSchemaPlanner.java | 12 +++++++ .../example/reporting/RunArtifacts.java | 23 +++++++++++++ .../reporting/SearchReportingCatalog.java | 2 ++ 7 files changed, 124 insertions(+), 8 deletions(-) diff --git a/docs/benchmarking.md b/docs/benchmarking.md index 5809b9152..a19e5e7b8 100644 --- a/docs/benchmarking.md +++ b/docs/benchmarking.md @@ -123,3 +123,34 @@ BenchYAML and AutoBenchYAML default to parallel complete-record serialization selects the sequential path, which encodes FP/NVQ features during insertion. Dataset prefetching is a separate option and is not required. See [Deferred batch serialization](deferred-batch-writer.md) for activation and caller examples. + + +### Construction-only measurements + +To measure a fresh index build without query warmup or evaluation, set +`search.topKOverquery: {}` in its BenchYAML index configuration. The harness emits +one construction-only `experiments.csv` row per evaluated index; search parameter +and query metric fields are empty. Select construction metrics in `run-config.yml`: + +```yaml +metrics: + construction: [index_build_time_s, index_construction_time_s, index_quant_time_s, index_file_size_mb] +``` + +`index_build_time_s` retains its graph insertion/cleanup/final-write scope. +`index_quant_time_s` reports actual quantizer training and base encoding separately. +`index_construction_time_s` measures one continuous wall interval from before build +quantizer resolution through base encoding, provider/writer setup, graph building, +final serialization and cache commit/reopen. It includes NVQ setup and deferred NVQ +encoding; do not add these overlapping phases to the inclusive value. It excludes +dataset loading, which precedes construction, and query evaluation, which follows it. +If every graph is loaded from cache, graph/build construction timings remain blank. +With a mixture of cached and fresh feature sets, the inclusive value covers the +whole construction invocation, including its cache lookups and loads. + +Measure total build-process wall time externally around the build-only BenchYAML +JVM to include JVM startup, dataset loading and teardown. Use a fresh isolated +working directory (both graph and compressor caches empty) for each construction +measurement. Retain that case directory for separate cached query runs so all +query thread counts search the same newly constructed graph. BenchYAML prints the +selected vector provider and ASH neighborhood capability/ISA at startup. diff --git a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/BenchYAML.java b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/BenchYAML.java index 01b74bc4d..e2857c837 100644 --- a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/BenchYAML.java +++ b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/BenchYAML.java @@ -23,6 +23,7 @@ import io.github.jbellis.jvector.example.yaml.DatasetCollection; import io.github.jbellis.jvector.example.yaml.MultiConfig; import io.github.jbellis.jvector.example.yaml.RunConfig; +import io.github.jbellis.jvector.vector.VectorizationProvider; import java.io.IOException; import java.security.InvalidParameterException; @@ -44,6 +45,9 @@ public static void main(String[] args) throws IOException { throw new InvalidParameterException("argv[] is null, check your maven exec config"); } Grid.configureWriterForBenchmark(); + var provider = VectorizationProvider.getInstance(); + System.out.printf("Vectorization provider: %s; %s%n", provider.getClass().getName(), + provider.getVectorUtilSupport().ashKernelDescription()); String regex = Arrays.stream(args) .filter(Objects::nonNull) diff --git a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/Grid.java b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/Grid.java index e7c03643e..cacc931fa 100644 --- a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/Grid.java +++ b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/Grid.java @@ -249,6 +249,11 @@ static void runOneGraph(OnDiskGraphIndexCache cache, diagnostics.capturePrePhaseSnapshot("Graph Build"); System.out.printf("%s: Dataset similarity function is %s%n", ds.getName(), ds.getSimilarityFunction()); + // Include training, base encoding, provider/writer setup, graph construction, + // final serialization, cache commit and reopening. Dataset loading is process time. + long constructionStart = System.nanoTime(); + boolean builtIndex = false; + // Resolve build compressor (and label quant type) so we can record compute time VectorCompressor buildCompressorObj = null; String buildQuantType = null; @@ -267,6 +272,7 @@ static void runOneGraph(OnDiskGraphIndexCache cache, Map, Long> indexFileSizes = new HashMap<>(); if (buildCompressorObj == null) { indexes = buildInMemory(featureSets, M, efConstruction, neighborOverflow, addHierarchy, refineFinalGraph, ds, workDirectory); + builtIndex = true; } else { // If cache is disabled, we use the (tmp) workDirectory as the output Path outputDir = cache.isEnabled() ? cache.cacheDir().toAbsolutePath() : workDirectory; @@ -304,15 +310,20 @@ static void runOneGraph(OnDiskGraphIndexCache cache, ds, outputDir, buildCompressorObj, handles, constructionMetrics); indexes.putAll(result.indexes); indexFileSizes.putAll(result.fileSizes); + builtIndex = true; } } + if (builtIndex) { + constructionMetrics.indexConstructionTimeS = (System.nanoTime() - constructionStart) / 1_000_000_000.0; + constructionMetrics.indexBuildTimeS = Grid.getIndexBuildTimeSeconds(ds.getName()); + } + // Capture post-build memory and disk state diagnostics.capturePostPhaseSnapshot("Graph Build"); diagnostics.printDiskStatistics("Graph Index Build"); System.out.printf("Index build time: %f seconds%n%n", Grid.getIndexBuildTimeSeconds(ds.getName())); - constructionMetrics.indexBuildTimeS = Grid.getIndexBuildTimeSeconds(ds.getName()); try { for (var cpSupplier : compressionGrid) { @@ -871,6 +882,20 @@ private static void testConfiguration(ConfiguredSystem cs, consoleSel.validate(benchmarksToCompute); logSel.validate(benchmarksToCompute); + // An empty query grid is a construction-only experiment. Emit real construction + // metrics without manufacturing a query or including throughput warmup in process time. + if (topKGrid.isEmpty()) { + var results = new ArrayList(); + constructionMetrics.appendTo(results); + for (var metric : consoleSel.apply(results, consoleSel.resolveForTopK(0))) { + System.out.println(metric); + } + artifacts.logConstructionRow(cs.ds.getName(), M, efConstruction, neighborOverflow, + addHierarchy, refineFinalGraph, featureSetForIndex, buildCompressorString, + logSel.apply(results, logSel.resolveForTopK(0))); + return; + } + // 2) Select benchmarks to run (compute spec) var benchmarks = setupBenchmarks(benchmarksToCompute); QueryTester tester = new QueryTester(benchmarks, testDirectory, cs.ds.getName()); @@ -1452,6 +1477,8 @@ private static String quantTypeOf(CompressorParameters cp) { static final class ConstructionMetrics { // null means “not applicable / not measured” public Double indexBuildTimeS; + /** End-to-end construction wall time; absent when every graph was loaded from cache. */ + public Double indexConstructionTimeS; public Long indexFileSizeBytes; // Index-construction phase (single pass per run) @@ -1478,6 +1505,10 @@ private static QuantHandle handle(Map map, String quantType) /** Adds construction + quant metrics as Metric entries (null => blank in CSV). */ void appendTo(List out) { + if (indexConstructionTimeS != null) { + out.add(Metric.of("construction.index_construction_time_s", + "Inclusive construction time (s)", ".2f", indexConstructionTimeS)); + } if (indexBuildTimeS != null) { out.add(Metric.of("construction.index_build_time_s", "Index build time (s)", ".2f", indexBuildTimeS)); diff --git a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/reporting/ExperimentsSchemaV1.java b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/reporting/ExperimentsSchemaV1.java index f3e728ddf..098c634c0 100644 --- a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/reporting/ExperimentsSchemaV1.java +++ b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/reporting/ExperimentsSchemaV1.java @@ -87,6 +87,26 @@ public static Map fixedValues(RunContext run, int topK, double overquery, int rerankK) { + var fixed = constructionValues(run, datasetName, M, efConstruction, neighborOverflow, + addHierarchy, refineFinalGraph, featureSet, buildCompressorString); + fixed.put("search_compressor", csvEscape(searchCompressorString)); + fixed.put("usePruning", Boolean.toString(usePruning)); + fixed.put("topK", Integer.toString(topK)); + fixed.put("overquery", Double.toString(overquery)); + fixed.put("rerankK", Integer.toString(rerankK)); + return fixed; + } + + /** Fixed index parameters for a construction-only row; query fields remain empty. */ + public static Map constructionValues(RunContext run, + String datasetName, + int M, + int efConstruction, + float neighborOverflow, + boolean addHierarchy, + boolean refineFinalGraph, + Set featureSet, + String buildCompressorString) { Map fixed = new HashMap<>(); fixed.put("schema_version", Integer.toString(run.schemaVersion())); fixed.put("run_id", run.runId()); @@ -101,13 +121,6 @@ public static Map fixedValues(RunContext run, fixed.put("refineFinalGraph", Boolean.toString(refineFinalGraph)); fixed.put("feature_set", featureSet == null ? "" : featureSet.toString()); fixed.put("build_compressor", csvEscape(buildCompressorString)); - fixed.put("search_compressor", csvEscape(searchCompressorString)); - - fixed.put("usePruning", Boolean.toString(usePruning)); - fixed.put("topK", Integer.toString(topK)); - fixed.put("overquery", Double.toString(overquery)); - fixed.put("rerankK", Integer.toString(rerankK)); - return fixed; } } diff --git a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/reporting/LoggingSchemaPlanner.java b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/reporting/LoggingSchemaPlanner.java index 70b2b97c2..14634315d 100644 --- a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/reporting/LoggingSchemaPlanner.java +++ b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/reporting/LoggingSchemaPlanner.java @@ -59,6 +59,16 @@ public static List unionLoggingMetricKeys(RunConfig runCfg, List sortedTopKs = new ArrayList<>(allTopKs); Collections.sort(sortedTopKs); @@ -95,6 +105,8 @@ public static List unionLoggingMetricKeys(RunConfig runCfg, List featureSet, + String buildCompressorString, + List outputs) { + if (experimentsWriter == null || run == null) return; + var fixed = ExperimentsSchemaV1.constructionValues(run, datasetName, M, efConstruction, + neighborOverflow, addHierarchy, refineFinalGraph, featureSet, buildCompressorString); + try { + experimentsWriter.appendRow(fixed, outputs); + } catch (IOException e) { + throw new UncheckedIOException("Failed to append construction row", e); + } + } + public RunContext run() { return run; } public DatasetInfoWriter datasetInfoWriter() { return datasetInfoWriter; } public ExperimentsCsvWriter experimentsWriter() { return experimentsWriter; } diff --git a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/reporting/SearchReportingCatalog.java b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/reporting/SearchReportingCatalog.java index 9f9e4ae9a..8c3b573d1 100644 --- a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/reporting/SearchReportingCatalog.java +++ b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/reporting/SearchReportingCatalog.java @@ -92,6 +92,8 @@ private static Map> namedMetricKeys() { ), "construction", Map.of( "index_build_time_s","construction.index_build_time_s", + "index_construction_time_s","construction.index_construction_time_s", + "index_file_size_mb","construction.index_file_size_mb", "index_quant_time_s","@prefix:construction.index_quant_time_s", "search_quant_time_s","@prefix:search.search_quant_time_s" ) From 139a8abebe255ab5037425cd612f36a69b15ed1a Mon Sep 17 00:00:00 2001 From: Ted Willke Date: Thu, 29 Oct 2026 00:41:16 +0000 Subject: [PATCH 095/243] Revert "Report inclusive and construction-only BenchYAML measurements" This reverts commit f53cbf36efa2d93789785a454dc0f14ff5b11a7c. --- docs/benchmarking.md | 31 ----------------- .../jbellis/jvector/example/BenchYAML.java | 4 --- .../github/jbellis/jvector/example/Grid.java | 33 +------------------ .../reporting/ExperimentsSchemaV1.java | 27 ++++----------- .../reporting/LoggingSchemaPlanner.java | 12 ------- .../example/reporting/RunArtifacts.java | 23 ------------- .../reporting/SearchReportingCatalog.java | 2 -- 7 files changed, 8 insertions(+), 124 deletions(-) diff --git a/docs/benchmarking.md b/docs/benchmarking.md index a19e5e7b8..5809b9152 100644 --- a/docs/benchmarking.md +++ b/docs/benchmarking.md @@ -123,34 +123,3 @@ BenchYAML and AutoBenchYAML default to parallel complete-record serialization selects the sequential path, which encodes FP/NVQ features during insertion. Dataset prefetching is a separate option and is not required. See [Deferred batch serialization](deferred-batch-writer.md) for activation and caller examples. - - -### Construction-only measurements - -To measure a fresh index build without query warmup or evaluation, set -`search.topKOverquery: {}` in its BenchYAML index configuration. The harness emits -one construction-only `experiments.csv` row per evaluated index; search parameter -and query metric fields are empty. Select construction metrics in `run-config.yml`: - -```yaml -metrics: - construction: [index_build_time_s, index_construction_time_s, index_quant_time_s, index_file_size_mb] -``` - -`index_build_time_s` retains its graph insertion/cleanup/final-write scope. -`index_quant_time_s` reports actual quantizer training and base encoding separately. -`index_construction_time_s` measures one continuous wall interval from before build -quantizer resolution through base encoding, provider/writer setup, graph building, -final serialization and cache commit/reopen. It includes NVQ setup and deferred NVQ -encoding; do not add these overlapping phases to the inclusive value. It excludes -dataset loading, which precedes construction, and query evaluation, which follows it. -If every graph is loaded from cache, graph/build construction timings remain blank. -With a mixture of cached and fresh feature sets, the inclusive value covers the -whole construction invocation, including its cache lookups and loads. - -Measure total build-process wall time externally around the build-only BenchYAML -JVM to include JVM startup, dataset loading and teardown. Use a fresh isolated -working directory (both graph and compressor caches empty) for each construction -measurement. Retain that case directory for separate cached query runs so all -query thread counts search the same newly constructed graph. BenchYAML prints the -selected vector provider and ASH neighborhood capability/ISA at startup. diff --git a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/BenchYAML.java b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/BenchYAML.java index e2857c837..01b74bc4d 100644 --- a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/BenchYAML.java +++ b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/BenchYAML.java @@ -23,7 +23,6 @@ import io.github.jbellis.jvector.example.yaml.DatasetCollection; import io.github.jbellis.jvector.example.yaml.MultiConfig; import io.github.jbellis.jvector.example.yaml.RunConfig; -import io.github.jbellis.jvector.vector.VectorizationProvider; import java.io.IOException; import java.security.InvalidParameterException; @@ -45,9 +44,6 @@ public static void main(String[] args) throws IOException { throw new InvalidParameterException("argv[] is null, check your maven exec config"); } Grid.configureWriterForBenchmark(); - var provider = VectorizationProvider.getInstance(); - System.out.printf("Vectorization provider: %s; %s%n", provider.getClass().getName(), - provider.getVectorUtilSupport().ashKernelDescription()); String regex = Arrays.stream(args) .filter(Objects::nonNull) diff --git a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/Grid.java b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/Grid.java index cacc931fa..e7c03643e 100644 --- a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/Grid.java +++ b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/Grid.java @@ -249,11 +249,6 @@ static void runOneGraph(OnDiskGraphIndexCache cache, diagnostics.capturePrePhaseSnapshot("Graph Build"); System.out.printf("%s: Dataset similarity function is %s%n", ds.getName(), ds.getSimilarityFunction()); - // Include training, base encoding, provider/writer setup, graph construction, - // final serialization, cache commit and reopening. Dataset loading is process time. - long constructionStart = System.nanoTime(); - boolean builtIndex = false; - // Resolve build compressor (and label quant type) so we can record compute time VectorCompressor buildCompressorObj = null; String buildQuantType = null; @@ -272,7 +267,6 @@ static void runOneGraph(OnDiskGraphIndexCache cache, Map, Long> indexFileSizes = new HashMap<>(); if (buildCompressorObj == null) { indexes = buildInMemory(featureSets, M, efConstruction, neighborOverflow, addHierarchy, refineFinalGraph, ds, workDirectory); - builtIndex = true; } else { // If cache is disabled, we use the (tmp) workDirectory as the output Path outputDir = cache.isEnabled() ? cache.cacheDir().toAbsolutePath() : workDirectory; @@ -310,20 +304,15 @@ static void runOneGraph(OnDiskGraphIndexCache cache, ds, outputDir, buildCompressorObj, handles, constructionMetrics); indexes.putAll(result.indexes); indexFileSizes.putAll(result.fileSizes); - builtIndex = true; } } - if (builtIndex) { - constructionMetrics.indexConstructionTimeS = (System.nanoTime() - constructionStart) / 1_000_000_000.0; - constructionMetrics.indexBuildTimeS = Grid.getIndexBuildTimeSeconds(ds.getName()); - } - // Capture post-build memory and disk state diagnostics.capturePostPhaseSnapshot("Graph Build"); diagnostics.printDiskStatistics("Graph Index Build"); System.out.printf("Index build time: %f seconds%n%n", Grid.getIndexBuildTimeSeconds(ds.getName())); + constructionMetrics.indexBuildTimeS = Grid.getIndexBuildTimeSeconds(ds.getName()); try { for (var cpSupplier : compressionGrid) { @@ -882,20 +871,6 @@ private static void testConfiguration(ConfiguredSystem cs, consoleSel.validate(benchmarksToCompute); logSel.validate(benchmarksToCompute); - // An empty query grid is a construction-only experiment. Emit real construction - // metrics without manufacturing a query or including throughput warmup in process time. - if (topKGrid.isEmpty()) { - var results = new ArrayList(); - constructionMetrics.appendTo(results); - for (var metric : consoleSel.apply(results, consoleSel.resolveForTopK(0))) { - System.out.println(metric); - } - artifacts.logConstructionRow(cs.ds.getName(), M, efConstruction, neighborOverflow, - addHierarchy, refineFinalGraph, featureSetForIndex, buildCompressorString, - logSel.apply(results, logSel.resolveForTopK(0))); - return; - } - // 2) Select benchmarks to run (compute spec) var benchmarks = setupBenchmarks(benchmarksToCompute); QueryTester tester = new QueryTester(benchmarks, testDirectory, cs.ds.getName()); @@ -1477,8 +1452,6 @@ private static String quantTypeOf(CompressorParameters cp) { static final class ConstructionMetrics { // null means “not applicable / not measured” public Double indexBuildTimeS; - /** End-to-end construction wall time; absent when every graph was loaded from cache. */ - public Double indexConstructionTimeS; public Long indexFileSizeBytes; // Index-construction phase (single pass per run) @@ -1505,10 +1478,6 @@ private static QuantHandle handle(Map map, String quantType) /** Adds construction + quant metrics as Metric entries (null => blank in CSV). */ void appendTo(List out) { - if (indexConstructionTimeS != null) { - out.add(Metric.of("construction.index_construction_time_s", - "Inclusive construction time (s)", ".2f", indexConstructionTimeS)); - } if (indexBuildTimeS != null) { out.add(Metric.of("construction.index_build_time_s", "Index build time (s)", ".2f", indexBuildTimeS)); diff --git a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/reporting/ExperimentsSchemaV1.java b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/reporting/ExperimentsSchemaV1.java index 098c634c0..f3e728ddf 100644 --- a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/reporting/ExperimentsSchemaV1.java +++ b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/reporting/ExperimentsSchemaV1.java @@ -87,26 +87,6 @@ public static Map fixedValues(RunContext run, int topK, double overquery, int rerankK) { - var fixed = constructionValues(run, datasetName, M, efConstruction, neighborOverflow, - addHierarchy, refineFinalGraph, featureSet, buildCompressorString); - fixed.put("search_compressor", csvEscape(searchCompressorString)); - fixed.put("usePruning", Boolean.toString(usePruning)); - fixed.put("topK", Integer.toString(topK)); - fixed.put("overquery", Double.toString(overquery)); - fixed.put("rerankK", Integer.toString(rerankK)); - return fixed; - } - - /** Fixed index parameters for a construction-only row; query fields remain empty. */ - public static Map constructionValues(RunContext run, - String datasetName, - int M, - int efConstruction, - float neighborOverflow, - boolean addHierarchy, - boolean refineFinalGraph, - Set featureSet, - String buildCompressorString) { Map fixed = new HashMap<>(); fixed.put("schema_version", Integer.toString(run.schemaVersion())); fixed.put("run_id", run.runId()); @@ -121,6 +101,13 @@ public static Map constructionValues(RunContext run, fixed.put("refineFinalGraph", Boolean.toString(refineFinalGraph)); fixed.put("feature_set", featureSet == null ? "" : featureSet.toString()); fixed.put("build_compressor", csvEscape(buildCompressorString)); + fixed.put("search_compressor", csvEscape(searchCompressorString)); + + fixed.put("usePruning", Boolean.toString(usePruning)); + fixed.put("topK", Integer.toString(topK)); + fixed.put("overquery", Double.toString(overquery)); + fixed.put("rerankK", Integer.toString(rerankK)); + return fixed; } } diff --git a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/reporting/LoggingSchemaPlanner.java b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/reporting/LoggingSchemaPlanner.java index 14634315d..70b2b97c2 100644 --- a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/reporting/LoggingSchemaPlanner.java +++ b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/reporting/LoggingSchemaPlanner.java @@ -59,16 +59,6 @@ public static List unionLoggingMetricKeys(RunConfig runCfg, List sortedTopKs = new ArrayList<>(allTopKs); Collections.sort(sortedTopKs); @@ -105,8 +95,6 @@ public static List unionLoggingMetricKeys(RunConfig runCfg, List featureSet, - String buildCompressorString, - List outputs) { - if (experimentsWriter == null || run == null) return; - var fixed = ExperimentsSchemaV1.constructionValues(run, datasetName, M, efConstruction, - neighborOverflow, addHierarchy, refineFinalGraph, featureSet, buildCompressorString); - try { - experimentsWriter.appendRow(fixed, outputs); - } catch (IOException e) { - throw new UncheckedIOException("Failed to append construction row", e); - } - } - public RunContext run() { return run; } public DatasetInfoWriter datasetInfoWriter() { return datasetInfoWriter; } public ExperimentsCsvWriter experimentsWriter() { return experimentsWriter; } diff --git a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/reporting/SearchReportingCatalog.java b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/reporting/SearchReportingCatalog.java index 8c3b573d1..9f9e4ae9a 100644 --- a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/reporting/SearchReportingCatalog.java +++ b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/reporting/SearchReportingCatalog.java @@ -92,8 +92,6 @@ private static Map> namedMetricKeys() { ), "construction", Map.of( "index_build_time_s","construction.index_build_time_s", - "index_construction_time_s","construction.index_construction_time_s", - "index_file_size_mb","construction.index_file_size_mb", "index_quant_time_s","@prefix:construction.index_quant_time_s", "search_quant_time_s","@prefix:search.search_quant_time_s" ) From 93a6f98cb3248972d44d6be15a84a51eba9ab9d0 Mon Sep 17 00:00:00 2001 From: Ted Willke Date: Thu, 29 Oct 2026 02:20:00 +0000 Subject: [PATCH 096/243] Use validated divide-and-conquer PCA for ASH training --- docs/ash-pca-native-svd.md | 48 ++++++++++++ .../quantization/AsymmetricHashing.java | 72 +++++++++++++---- .../quantization/TestASHPcaTraining.java | 77 +++++++++++++++++++ 3 files changed, 180 insertions(+), 17 deletions(-) create mode 100644 docs/ash-pca-native-svd.md create mode 100644 jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestASHPcaTraining.java diff --git a/docs/ash-pca-native-svd.md b/docs/ash-pca-native-svd.md new file mode 100644 index 000000000..45eaff364 --- /dev/null +++ b/docs/ash-pca-native-svd.md @@ -0,0 +1,48 @@ +# ASH PCA SVD correctness + +During the native PQ/ASH graph comparison, JDK 25 MPNet ASH training failed in +native PCA with `DGESVD info=767`. The identical frozen-runtime retry failed too. +The 15,360-by-768 normalized residual matrix was finite, unchanged between calls, +and identical across the JDK 22/25 diagnostics (FNV fingerprint e9c6fe8880b17ba6). + +The installed MKL 2020.4.304 backend's multi-threaded, optimal-workspace QR SVD +path also returned success with incorrect results on JDK 22. For the same matrix: + +| Driver / configuration | Relative spectral-energy error | Eigenvector residual | +|---|---:|---:| +| DGESVD, optimal workspace, 48 threads | 1.733947% on a returned-success call; other calls failed | 0.635204 | +| DGESVD, minimum workspace, 48 threads | 5.91e-14 | 2.05e-14 | +| DGESDD, overwrite, 48 threads | 6.11e-14 | 1.35e-14 | +| DGESDD, thin, 48 threads | 6.11e-14 | 1.35e-14 | +| DGESVD, optimal workspace, 1 thread | 5.84e-14 | 1.95e-14 | + +Input squared Frobenius norm was approximately 15,360. A returned-success QR SVD +reported singular-value squared sum 15,626.33427256156. The independent residual +check evaluated `X^T X v - sigma^2 v` for leading and interior singular vectors +using Java arithmetic, without relying on BLAS for the check. + +These observations isolate a native QR SVD fast-path problem, rather than a JDK +selection issue or non-finite training input. They do not establish an upstream +vendor root-cause patch. Positive DGESVD status denotes QR nonconvergence; see +[the LAPACK contract](https://www.netlib.org/lapack/explore-html/d1/d7f/group__gesvd_gac6bd5d4e645049e49bb70691180abf07.html). + +ASH PCA now uses the portable divide-and-conquer DGESDD driver, already used by +its ITQ orthogonalization. Tall matrices overwrite the scratch input with the +unused left vectors. Wide matrices request full right vectors so projections can +include null-space directions when training has fewer rows than dimensions. +Finite input, workspace size and singular-value energy are validated before +accepting the PCA basis. There are no CPU-, vendor-, bit-width- or JDK-specific +branches or runtime thread-count workarounds. + +BenchYAML and its timers are unchanged. Runtime validation uses BenchYAML on +both JDKs with 64-by-768 and 1,024-by-768 training sets, native ASH, deferred writes +and NVQ reranking. All four cases passed, including serial/concurrent result +checks. Added regression sources cover principal axes, wide null-space completion +and non-finite input; they compile for CI. Unit tests were not separately executed +for this study. + +All graph measurements made with the earlier ASH PCA implementation are retained +as superseded evidence and recollected after the fix. PQ does not use this PCA +path; its completed measurements are retained after verifying that its runtime +classes are unchanged. Earlier ITQ-trained full-scan ASH comparisons also warrant +revalidation wherever they used the affected multi-threaded QR SVD backend. diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/AsymmetricHashing.java b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/AsymmetricHashing.java index a9764822a..0023edd01 100644 --- a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/AsymmetricHashing.java +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/AsymmetricHashing.java @@ -1994,34 +1994,72 @@ private static double[] nativeMultiplyTransposedA( } /** - * Uses LAPACK dgesvd but skips U and returns VT in flat column-major layout. - * Input X must be [rows x cols], flat column-major. + * Computes all right singular vectors with the divide-and-conquer LAPACK driver. + * Input X is [rows x cols] in column-major order; the returned VT is [cols x cols]. + * + *

    For tall matrices, overwrite the scratch input with the unused left singular + * vectors instead of allocating another rows-by-cols array. Wide matrices need + * the full VT, including its null-space basis, because the requested projection + * dimension can exceed the number of training rows.

    + * + *

    Check the singular-value energy before accepting the basis. A native backend + * can return success with an inaccurate decomposition; its status alone does not + * establish that the PCA input was decomposed correctly.

    */ private static double[] computeNativeVt(double[] xCol, int rows, int cols) { - double[] s = new double[Math.min(rows, cols)]; - double[] vt = new double[cols * cols]; - intW info = new intW(0); + if (rows <= 0 || cols <= 0 || xCol.length != Math.multiplyExact(rows, cols)) { + throw new IllegalArgumentException("Invalid PCA matrix dimensions"); + } + double inputEnergy = 0.0; + for (double value : xCol) { + if (!Double.isFinite(value)) { + throw new IllegalArgumentException("PCA training input contains a non-finite value"); + } + inputEnergy += value * value; + } - // Workspace query - double[] a = xCol.clone(); // dgesvd overwrites input + int k = Math.min(rows, cols); + boolean tall = rows >= cols; + String job = tall ? "O" : "A"; + int ldu = tall ? 1 : rows; + double[] u = new double[tall ? 1 : Math.multiplyExact(rows, rows)]; + double[] s = new double[k]; + double[] vt = new double[Math.multiplyExact(cols, cols)]; + int[] iwork = new int[Math.multiplyExact(8, k)]; + intW info = new intW(0); double[] workQuery = new double[1]; - LAPACK.getInstance().dgesvd("N", "A", rows, cols, a, rows, s, null, 1, vt, cols, workQuery, -1, info); + double[] a = xCol.clone(); // LAPACK overwrites its input; X is still needed by ITQ. + LAPACK.getInstance().dgesdd(job, rows, cols, a, rows, s, u, ldu, + vt, cols, workQuery, -1, iwork, info); if (info.val != 0) { - throw new RuntimeException("Native PCA SVD workspace query failed with info=" + info.val); + throw new IllegalStateException("PCA SVD workspace query failed with info=" + info.val); } - - int lwork = (int) workQuery[0]; - double[] work = new double[lwork]; - - // Actual call + if (!Double.isFinite(workQuery[0]) || workQuery[0] < 1 || workQuery[0] > Integer.MAX_VALUE) { + throw new IllegalStateException("Invalid PCA SVD workspace size: " + workQuery[0]); + } + double[] work = new double[(int) Math.ceil(workQuery[0])]; a = xCol.clone(); - LAPACK.getInstance().dgesvd("N", "A", rows, cols, a, rows, s, null, 1, vt, cols, work, lwork, info); - + LAPACK.getInstance().dgesdd(job, rows, cols, a, rows, s, u, ldu, + vt, cols, work, work.length, iwork, info); if (info.val != 0) { - throw new RuntimeException("Native PCA SVD failed with info=" + info.val); + throw new IllegalStateException("PCA SVD failed with info=" + info.val); } + double singularEnergy = 0.0; + for (double value : s) { + if (!Double.isFinite(value) || value < 0.0) { + throw new IllegalStateException("PCA SVD returned an invalid singular value"); + } + singularEnergy += value * value; + } + // Training residuals are normalized; allow accumulation roundoff while rejecting + // materially incorrect decompositions, including ones reported as successful. + double tolerance = 1e-8 * Math.max(1.0, inputEnergy); + if (!Double.isFinite(singularEnergy) || Math.abs(singularEnergy - inputEnergy) > tolerance) { + throw new IllegalStateException("PCA SVD failed its energy check: input=" + inputEnergy + + ", singular values=" + singularEnergy); + } return vt; } diff --git a/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestASHPcaTraining.java b/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestASHPcaTraining.java new file mode 100644 index 000000000..44bd68f89 --- /dev/null +++ b/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestASHPcaTraining.java @@ -0,0 +1,77 @@ +/* + * 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 org.junit.Test; + +import java.lang.reflect.InvocationTargetException; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertThrows; + +/** Checks the PCA basis contract independently of subsequent ITQ rotations. */ +public class TestASHPcaTraining { + @Test + public void tallMatrixPreservesPrincipalAxes() throws Exception { + // Column-major diagonal matrix with two additional zero rows. + double[] x = {3, 0, 0, 0, 0, 0, 2, 0, 0, 0, 0, 0, 1, 0, 0}; + double[] vt = pca(x, 5, 3); + assertOrthonormal(vt, 3); + for (int axis = 0; axis < 3; axis++) { + assertEquals(1.0, Math.abs(vt[axis + axis * 3]), 1e-12); + } + assertEquals(3.0, x[0], 0.0); // LAPACK must only overwrite the scratch copy. + } + + @Test + public void wideRankDeficientMatrixIncludesNullSpace() throws Exception { + double[] vt = pca(new double[] {3, 0, 4, 0, 0, 0, 0, 0}, 2, 4); + assertEquals(16, vt.length); + assertOrthonormal(vt, 4); + assertEquals(0.6, Math.abs(vt[0]), 1e-12); + assertEquals(0.8, Math.abs(vt[4]), 1e-12); + assertEquals(0.0, vt[8], 1e-12); + assertEquals(0.0, vt[12], 1e-12); + } + + @Test + public void nonFiniteInputIsRejectedBeforeLapack() { + assertThrows(IllegalArgumentException.class, + () -> pca(new double[] {1, Double.NaN, 0, 1}, 2, 2)); + } + + private static double[] pca(double[] x, int rows, int cols) throws ReflectiveOperationException { + var method = AsymmetricHashing.class.getDeclaredMethod("computeNativeVt", double[].class, int.class, int.class); + method.setAccessible(true); + try { + return (double[]) method.invoke(null, x, rows, cols); + } catch (InvocationTargetException error) { + if (error.getCause() instanceof RuntimeException) throw (RuntimeException) error.getCause(); + throw error; + } + } + + private static void assertOrthonormal(double[] vt, int dimension) { + for (int i = 0; i < dimension; i++) { + for (int j = 0; j < dimension; j++) { + double dot = 0.0; + for (int c = 0; c < dimension; c++) dot += vt[i + c * dimension] * vt[j + c * dimension]; + assertEquals(i == j ? 1.0 : 0.0, dot, 1e-12); + } + } + } +} From 5c3ae322f92989956e43fd87f0bfccc864592eee Mon Sep 17 00:00:00 2001 From: Ted Willke Date: Thu, 29 Oct 2026 03:54:33 +0000 Subject: [PATCH 097/243] Document native PQ versus trained ASH graph results --- docs/ash-pca-native-svd.md | 4 + .../comparison.pdf | Bin 0 -> 26364 bytes .../comparison.png | Bin 0 -> 285368 bytes .../comparisons.csv | 65 + .../construction.csv | 33 + .../native-pq-ash-graph-20261028/inputs.json | 98 + .../manifest.json | 22 + .../native-preflight-proof.json | 23 + .../pca-fix-runtime-audit.json | 14 + .../protocol.json | 141 + .../native-pq-ash-graph-20261028/queries.csv | 129 + .../raw-results.json | 8098 +++++++++++++++++ .../native-pq-ash-graph-20261028/report.md | 90 + .../native-pq-ash-graph-20261028/source.json | 12 + .../native-pq-ash-graph-20261028/summary.json | 45 + 15 files changed, 8774 insertions(+) create mode 100644 docs/benchmarks/native-pq-ash-graph-20261028/comparison.pdf create mode 100644 docs/benchmarks/native-pq-ash-graph-20261028/comparison.png create mode 100644 docs/benchmarks/native-pq-ash-graph-20261028/comparisons.csv create mode 100644 docs/benchmarks/native-pq-ash-graph-20261028/construction.csv create mode 100644 docs/benchmarks/native-pq-ash-graph-20261028/inputs.json create mode 100644 docs/benchmarks/native-pq-ash-graph-20261028/manifest.json create mode 100644 docs/benchmarks/native-pq-ash-graph-20261028/native-preflight-proof.json create mode 100644 docs/benchmarks/native-pq-ash-graph-20261028/pca-fix-runtime-audit.json create mode 100644 docs/benchmarks/native-pq-ash-graph-20261028/protocol.json create mode 100644 docs/benchmarks/native-pq-ash-graph-20261028/queries.csv create mode 100644 docs/benchmarks/native-pq-ash-graph-20261028/raw-results.json create mode 100644 docs/benchmarks/native-pq-ash-graph-20261028/report.md create mode 100644 docs/benchmarks/native-pq-ash-graph-20261028/source.json create mode 100644 docs/benchmarks/native-pq-ash-graph-20261028/summary.json diff --git a/docs/ash-pca-native-svd.md b/docs/ash-pca-native-svd.md index 45eaff364..aaab3f81b 100644 --- a/docs/ash-pca-native-svd.md +++ b/docs/ash-pca-native-svd.md @@ -46,3 +46,7 @@ as superseded evidence and recollected after the fix. PQ does not use this PCA path; its completed measurements are retained after verifying that its runtime classes are unchanged. Earlier ITQ-trained full-scan ASH comparisons also warrant revalidation wherever they used the affected multi-threaded QR SVD backend. + +Existing saved models are not rewritten by this change. Retrain the ASH compressor +and rebuild its graph/model caches to adopt the corrected PCA. The graph study +uses fresh isolated caches for every corrected ASH construction case. diff --git a/docs/benchmarks/native-pq-ash-graph-20261028/comparison.pdf b/docs/benchmarks/native-pq-ash-graph-20261028/comparison.pdf new file mode 100644 index 0000000000000000000000000000000000000000..68796521e1fed9835a37a1d22f5549bafb778372 GIT binary patch literal 26364 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 literal 0 HcmV?d00001 diff --git a/docs/benchmarks/native-pq-ash-graph-20261028/comparison.png b/docs/benchmarks/native-pq-ash-graph-20261028/comparison.png new file mode 100644 index 0000000000000000000000000000000000000000..c9a268dab27477a00e5e971a700087ab30e4d860 GIT binary patch literal 285368 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⪼F+?3z~3EE+FR{|hFlX#HW# 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

    9Uux#q@IU)*#?=ffnLAYL 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

    =-C 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 literal 0 HcmV?d00001 diff --git a/docs/benchmarks/native-pq-ash-graph-20261028/comparisons.csv b/docs/benchmarks/native-pq-ash-graph-20261028/comparisons.csv new file mode 100644 index 000000000..8686b50ed --- /dev/null +++ b/docs/benchmarks/native-pq-ash-graph-20261028/comparisons.csv @@ -0,0 +1,65 @@ +dataset,jdk,ash_bits_budget,code_bytes,query_threads,overquery,pq_recall_percent,ash_recall_percent,ash_recall_delta_percentage_points,pq_qps,ash_qps,ash_over_pq_qps +MPNet,22,2,101,1,1,65.128,81.94,16.811999999999998,11929.667667640202,9610.898579370603,0.8056300348953247 +MPNet,22,2,101,1,2,82.126,94.055,11.929000000000002,9441.313656397691,7822.315710403462,0.828519843221483 +MPNet,22,2,101,8,1,65.128,81.94,16.811999999999998,86214.6647201115,70989.65395938238,0.8234057882129937 +MPNet,22,2,101,8,2,82.126,94.055,11.929000000000002,68759.66645239317,59038.82008864344,0.8586257487086546 +MPNet,22,4,197,1,1,80.733,86.642,5.908999999999992,9642.851473210929,8629.758114420085,0.8949384047234009 +MPNet,22,4,197,1,2,92.753,93.96600000000001,1.213000000000008,7455.724453990442,6976.353944105883,0.9357043687916885 +MPNet,22,4,197,8,1,80.733,86.642,5.908999999999992,74892.80322512507,64520.16990414337,0.8615002660562465 +MPNet,22,4,197,8,2,92.753,93.96600000000001,1.213000000000008,57187.4193258142,52278.56016692018,0.9141619045453555 +MPNet,25,2,101,1,1,65.484,82.017,16.533,11812.269458852594,10357.12246572871,0.8768105487101517 +MPNet,25,2,101,1,2,82.21000000000001,93.971,11.760999999999996,9294.266279454745,8282.467440755714,0.891137308930384 +MPNet,25,2,101,8,1,65.484,82.017,16.533,76801.73087416003,73840.6240900836,0.9614447910174289 +MPNet,25,2,101,8,2,82.21000000000001,93.971,11.760999999999996,71112.3291273773,60433.593076138226,0.849832846395578 +MPNet,25,4,197,1,1,80.75099999999999,86.29299999999999,5.542000000000002,10001.00801293626,9153.966952460185,0.9153044313752743 +MPNet,25,4,197,1,2,92.601,94.033,1.4320000000000022,7557.44798962551,7114.7162102085695,0.9414178198745529 +MPNet,25,4,197,8,1,80.75099999999999,86.29299999999999,5.542000000000002,74886.84115917748,67390.32073511016,0.8998953580091206 +MPNet,25,4,197,8,2,92.601,94.033,1.4320000000000022,56904.75254695084,54357.67614973744,0.9552396542782281 +Cohere,22,2,133,1,1,59.431,63.903,4.472000000000001,9694.693384586766,6040.668013555356,0.623090156018672 +Cohere,22,2,133,1,2,78.292,81.99499999999999,3.7029999999999887,7699.839253969693,5224.424204577702,0.6785108146100871 +Cohere,22,2,133,8,1,59.431,63.903,4.472000000000001,73999.92617936898,47759.13958907922,0.6453944220608475 +Cohere,22,2,133,8,2,78.292,81.99499999999999,3.7029999999999887,59164.921945419286,39833.20097312,0.6732570527155745 +Cohere,22,4,261,1,1,65.081,70.693,5.611999999999995,7655.785166781064,5459.5384791205415,0.7131258728118214 +Cohere,22,4,261,1,2,81.039,83.397,2.358000000000004,5826.0870091323695,4609.658123480801,0.7912099692735758 +Cohere,22,4,261,8,1,65.081,70.693,5.611999999999995,57544.851973715246,41672.84985305652,0.7241803293210559 +Cohere,22,4,261,8,2,81.039,83.397,2.358000000000004,43365.895255088246,35207.00369774443,0.8118592615383282 +Cohere,25,2,133,1,1,59.13,63.975,4.844999999999999,9412.081288856038,6427.095638763094,0.6828559424335631 +Cohere,25,2,133,1,2,78.45,82.327,3.8769999999999953,7190.940014143866,5258.107673080131,0.7312128404266972 +Cohere,25,2,133,8,1,59.13,63.975,4.844999999999999,75637.85852751213,50148.40516839785,0.6630066760834737 +Cohere,25,2,133,8,2,78.45,82.327,3.8769999999999953,57862.554944797805,41901.690380100095,0.7241590078432462 +Cohere,25,4,261,1,1,65.53,70.59,5.060000000000002,7857.8449638838065,5662.326694410597,0.7205953693965406 +Cohere,25,4,261,1,2,81.365,83.696,2.331000000000003,5920.816567354704,4635.7196414662285,0.7829527546970384 +Cohere,25,4,261,8,1,65.53,70.59,5.060000000000002,57670.64664851298,42689.45179035281,0.7402284224509132 +Cohere,25,4,261,8,2,81.365,83.696,2.331000000000003,43804.78320505803,35644.71872678161,0.8137175011213342 +Ada002,22,2,197,1,1,54.986000000000004,78.88,23.89399999999999,7064.577838319312,3301.2900012077635,0.46730180865176113 +Ada002,22,2,197,1,2,75.347,92.685,17.338000000000008,5685.492109827316,2987.2294544484503,0.5254126462131666 +Ada002,22,2,197,8,1,54.986000000000004,78.88,23.89399999999999,53847.213088554396,24911.127856122304,0.46262613099687677 +Ada002,22,2,197,8,2,75.347,92.685,17.338000000000008,45513.06620677013,22127.98341769791,0.48618968709267807 +Ada002,22,4,389,1,1,73.555,83.231,9.675999999999988,5341.342726973948,2975.630205424497,0.5570940412412541 +Ada002,22,4,389,1,2,90.049,92.443,2.3939999999999912,4200.0630432290845,2698.9527131275813,0.6425981432537207 +Ada002,22,4,389,8,1,73.555,83.231,9.675999999999988,41038.5274305757,21870.622987666935,0.5329290390515391 +Ada002,22,4,389,8,2,90.049,92.443,2.3939999999999912,30912.637087924762,19258.54676558465,0.6229991543849074 +Ada002,25,2,197,1,1,54.594,79.06400000000001,24.470000000000006,7271.180761294964,3436.8450622860405,0.47266670642829045 +Ada002,25,2,197,1,2,75.03999999999999,92.718,17.67800000000001,5711.746382358798,3096.883668745141,0.5421955845781464 +Ada002,25,2,197,8,1,54.594,79.06400000000001,24.470000000000006,49406.674887911795,25148.668295078558,0.5090135766499765 +Ada002,25,2,197,8,2,75.03999999999999,92.718,17.67800000000001,43937.988185794646,22643.408793697028,0.5153492394314436 +Ada002,25,4,389,1,1,73.566,83.528,9.962000000000003,3664.671687680422,2958.5765700966863,0.8073237720155326 +Ada002,25,4,389,1,2,90.14999999999999,92.467,2.3170000000000073,3883.6968894121583,2649.82808625031,0.6822952876354343 +Ada002,25,4,389,8,1,73.566,83.528,9.962000000000003,26979.66563109857,21982.92028578378,0.8147958757667031 +Ada002,25,4,389,8,2,90.14999999999999,92.467,2.3170000000000073,22435.812911872614,20075.60298373417,0.8948016754548054 +OpenAI3072,22,2,389,1,1,64.37,77.92,13.549999999999997,3329.077370787888,1031.0076970878936,0.30969772770522497 +OpenAI3072,22,2,389,1,2,79.85,89.8,9.950000000000003,2953.6549674392622,1068.223215503725,0.36166147613031635 +OpenAI3072,22,2,389,8,1,64.37,77.92,13.549999999999997,27543.90063678985,7189.703298060613,0.26102705614823 +OpenAI3072,22,2,389,8,2,79.85,89.8,9.950000000000003,22784.923597112218,7037.381528041226,0.3088613177940676 +OpenAI3072,22,4,773,1,1,76.91,79.03,2.1200000000000045,2143.912280952585,1016.1509827131623,0.47397040995616907 +OpenAI3072,22,4,773,1,2,87.7,88.34,0.6400000000000006,1871.3707850777212,1030.8662536443949,0.5508615726314127 +OpenAI3072,22,4,773,8,1,76.91,79.03,2.1200000000000045,18466.799971430668,7507.82372210983,0.4065579165705438 +OpenAI3072,22,4,773,8,2,87.7,88.34,0.6400000000000006,14336.390737938298,7360.509055849552,0.5134143725848295 +OpenAI3072,25,2,389,1,1,65.10000000000001,76.75999999999999,11.659999999999982,2170.3203408432223,1003.0125753831542,0.4621495529979963 +OpenAI3072,25,2,389,1,2,80.32000000000001,89.17,8.849999999999994,2068.413509232902,1093.9677679889598,0.5288921983470665 +OpenAI3072,25,2,389,8,1,65.10000000000001,76.75999999999999,11.659999999999982,27772.466077225967,8050.426675043233,0.28987078974757524 +OpenAI3072,25,2,389,8,2,80.32000000000001,89.17,8.849999999999994,23213.773739290547,7893.088397249235,0.3400174605772849 +OpenAI3072,25,4,773,1,1,76.24,79.65,3.410000000000011,2280.759101685725,964.9450620510337,0.42308065825006946 +OpenAI3072,25,4,773,1,2,87.27000000000001,88.75,1.4799999999999898,1886.2499788294274,1033.6181669420773,0.5479751774913323 +OpenAI3072,25,4,773,8,1,76.24,79.65,3.410000000000011,15663.804691288786,7915.294433750889,0.5053238718019047 +OpenAI3072,25,4,773,8,2,87.27000000000001,88.75,1.4799999999999898,12884.883579153513,7489.111448400777,0.5812323722130815 diff --git a/docs/benchmarks/native-pq-ash-graph-20261028/construction.csv b/docs/benchmarks/native-pq-ash-graph-20261028/construction.csv new file mode 100644 index 000000000..5f91cefbf --- /dev/null +++ b/docs/benchmarks/native-pq-ash-graph-20261028/construction.csv @@ -0,0 +1,33 @@ +dataset,jdk,ash_bits_budget,code_bytes,algorithm,training_seconds,encoding_seconds,graph_write_seconds,total_construction_validation_process_seconds,nvq_compute_seconds,index_cache_total_bytes,source +MPNet,22,2,101,PQ,2.729675109,1.081207084,44.438387038,52.27324899379164,0.275686754,4204460293,b44e25b71e3d9c5c7a2f164610b0fdbd099d91fa +MPNet,22,2,101,ASH,5.20501655,1.091514841,58.372648107,70.72278314642608,0.368510427,4205250935,507b74e7ef8a0e12fcfe402deb4bf758c65758d5 +MPNet,22,4,197,PQ,6.137772914,1.70667817,50.786497509,62.748410480096936,0.30227987,7278884955,b44e25b71e3d9c5c7a2f164610b0fdbd099d91fa +MPNet,22,4,197,ASH,4.690223155,1.194973879,42.028731887,54.33950508851558,0.46261499,7279672095,507b74e7ef8a0e12fcfe402deb4bf758c65758d5 +MPNet,25,2,101,PQ,2.774568465,1.048305647,33.769320929,41.54969112202525,0.260673416,4204456814,b44e25b71e3d9c5c7a2f164610b0fdbd099d91fa +MPNet,25,2,101,ASH,4.795862,1.001739035,31.631570707,43.574435736984015,0.364870626,4205249625,507b74e7ef8a0e12fcfe402deb4bf758c65758d5 +MPNet,25,4,197,PQ,4.652523221,1.745370325,48.549881485,58.950520511716604,0.330302452,7278883270,507b74e7ef8a0e12fcfe402deb4bf758c65758d5 +MPNet,25,4,197,ASH,4.454340959,1.201650817,43.289757796,54.83608163334429,0.358496614,7279677824,507b74e7ef8a0e12fcfe402deb4bf758c65758d5 +Cohere,22,2,133,PQ,2.969167141,1.324768325,35.40040222,44.332542745396495,0.35765844,5486775034,b44e25b71e3d9c5c7a2f164610b0fdbd099d91fa +Cohere,22,2,133,ASH,8.026500274,1.861498103,62.374692218,96.0135448006913,0.457021944,5488881546,507b74e7ef8a0e12fcfe402deb4bf758c65758d5 +Cohere,22,4,261,PQ,6.753853519,2.302340857,70.711462575,84.35468028020114,0.34194332,9586777730,b44e25b71e3d9c5c7a2f164610b0fdbd099d91fa +Cohere,22,4,261,ASH,6.624667012,2.166285985,49.729920663,66.18678959459066,0.488109724,9588877024,507b74e7ef8a0e12fcfe402deb4bf758c65758d5 +Cohere,25,2,133,PQ,3.084744323,1.32553116,40.656450049,49.729727069847286,0.323431596,5486780219,507b74e7ef8a0e12fcfe402deb4bf758c65758d5 +Cohere,25,2,133,ASH,7.498550613,1.643006753,32.965712237,52.83737094514072,0.49575556,5488878816,507b74e7ef8a0e12fcfe402deb4bf758c65758d5 +Cohere,25,4,261,PQ,6.571955191,2.233528007,66.54631899,80.15861915424466,0.338206073,9586776527,507b74e7ef8a0e12fcfe402deb4bf758c65758d5 +Cohere,25,4,261,ASH,6.455142236,2.062987324,46.251607145,62.538319007493556,0.722485568,9588881034,507b74e7ef8a0e12fcfe402deb4bf758c65758d5 +Ada002,22,2,197,PQ,3.932531018,2.19930205,48.118085742,60.269731011241674,0.533923396,7911344189,b44e25b71e3d9c5c7a2f164610b0fdbd099d91fa +Ada002,22,2,197,ASH,13.7121569,7.911204988,49.604614259,81.39980881009251,0.598817294,7917649911,507b74e7ef8a0e12fcfe402deb4bf758c65758d5 +Ada002,22,4,389,PQ,8.623554557,3.614520167,100.923429775,119.41658374574035,0.475411504,13955502981,b44e25b71e3d9c5c7a2f164610b0fdbd099d91fa +Ada002,22,4,389,ASH,10.947159808,7.425704863,56.9124183,85.24945241492242,0.557878664,13961799958,507b74e7ef8a0e12fcfe402deb4bf758c65758d5 +Ada002,25,2,197,PQ,3.724007606,2.046804322,42.696928604,54.51408803742379,0.522869417,7911343988,507b74e7ef8a0e12fcfe402deb4bf758c65758d5 +Ada002,25,2,197,ASH,13.824727702,8.277205551,45.395450927,81.49886683840305,0.563860348,7917644448,507b74e7ef8a0e12fcfe402deb4bf758c65758d5 +Ada002,25,4,389,PQ,8.80672428,3.506287152,106.252121239,125.37208096962422,0.494723635,13955502981,507b74e7ef8a0e12fcfe402deb4bf758c65758d5 +Ada002,25,4,389,ASH,13.053361983,7.324970714,60.391773285,90.95995359588414,0.787999096,13961804326,507b74e7ef8a0e12fcfe402deb4bf758c65758d5 +OpenAI3072,22,2,389,PQ,6.505794804,5.508848828,90.502056269,113.07548744231462,1.177500504,15723261549,b44e25b71e3d9c5c7a2f164610b0fdbd099d91fa +OpenAI3072,22,2,389,ASH,41.122194304,24.101615418,85.71443966,169.54594611562788,1.578240137,15754739676,507b74e7ef8a0e12fcfe402deb4bf758c65758d5 +OpenAI3072,22,4,773,PQ,19.649721716,7.64310692,257.818535867,295.41825035214424,1.012420177,28010961543,b44e25b71e3d9c5c7a2f164610b0fdbd099d91fa +OpenAI3072,22,4,773,ASH,39.53853562,27.206676416,124.325741592,208.43669655919075,1.118749346,28042434388,507b74e7ef8a0e12fcfe402deb4bf758c65758d5 +OpenAI3072,25,2,389,PQ,5.897795915,5.580369998,113.005143545,135.150859314017,1.272420274,15723256259,507b74e7ef8a0e12fcfe402deb4bf758c65758d5 +OpenAI3072,25,2,389,ASH,39.411565317,24.278880319,87.028014205,170.93929107300937,1.4630497,15754739812,507b74e7ef8a0e12fcfe402deb4bf758c65758d5 +OpenAI3072,25,4,773,PQ,16.624147058,7.778564805,247.52862079,281.5635087201372,0.962668797,28010963369,507b74e7ef8a0e12fcfe402deb4bf758c65758d5 +OpenAI3072,25,4,773,ASH,38.63113482,25.960155066,130.569217494,213.53237825259566,1.1549786,28042443654,507b74e7ef8a0e12fcfe402deb4bf758c65758d5 diff --git a/docs/benchmarks/native-pq-ash-graph-20261028/inputs.json b/docs/benchmarks/native-pq-ash-graph-20261028/inputs.json new file mode 100644 index 000000000..5c5766949 --- /dev/null +++ b/docs/benchmarks/native-pq-ash-graph-20261028/inputs.json @@ -0,0 +1,98 @@ +[ + { + "kind": "base", + "path": "/home/ted_willke/datasets-clean/mpnet-1m/wiki_mpnet_embeddings_base_999812.fvecs", + "bytes": 3075421712, + "width": 768, + "records": 999812, + "mtime_ns": 1779289837543238134 + }, + { + "kind": "query", + "path": "/home/ted_willke/datasets-clean/mpnet-1m/wiki_mpnet_embeddings_query_10000.fvecs", + "bytes": 30760000, + "width": 768, + "records": 10000, + "mtime_ns": 1779289837559238135 + }, + { + "kind": "gt", + "path": "/home/ted_willke/datasets-clean/mpnet-1m/wiki_mpnet_embeddings_gt_ip_100.ivecs", + "bytes": 4040000, + "width": 100, + "records": 10000, + "mtime_ns": 1779289837543238134 + }, + { + "kind": "base", + "path": "/home/ted_willke/datasets-clean/cohere-1m/cohere_wiki_en_flat_base_1m_norm.fvecs", + "bytes": 4100000000, + "width": 1024, + "records": 1000000, + "mtime_ns": 1778786535687938561 + }, + { + "kind": "query", + "path": "/home/ted_willke/datasets-clean/cohere-1m/cohere_wiki_en_flat_query_10k_norm.fvecs", + "bytes": 41000000, + "width": 1024, + "records": 10000, + "mtime_ns": 1778786535703938563 + }, + { + "kind": "gt", + "path": "/home/ted_willke/datasets-clean/cohere-1m/cohere_wiki_en_flat_gt_1m_ip_k100.ivecs", + "bytes": 4040000, + "width": 100, + "records": 10000, + "mtime_ns": 1778786533775938406 + }, + { + "kind": "base", + "path": "/home/ted_willke/datasets-clean/ada_002_1M_base_982790.fvecs", + "bytes": 6042192920, + "width": 1536, + "records": 982790, + "mtime_ns": 1775399354461867895 + }, + { + "kind": "query", + "path": "/home/ted_willke/datasets-clean/ada_002_1M_query_10000.fvecs", + "bytes": 61480000, + "width": 1536, + "records": 10000, + "mtime_ns": 1775399354529867849 + }, + { + "kind": "gt", + "path": "/home/ted_willke/datasets-clean/ada_002_1M_gt_ip_100.ivecs", + "bytes": 4040000, + "width": 100, + "records": 10000, + "mtime_ns": 1775399661489659318 + }, + { + "kind": "base", + "path": "/home/ted_willke/datasets-clean/openai-3072-1m/dbpedia_openai_3_large_3072_base_999000.fvecs", + "bytes": 12279708000, + "width": 3072, + "records": 999000, + "mtime_ns": 1779387195627505524 + }, + { + "kind": "query", + "path": "/home/ted_willke/datasets-clean/openai-3072-1m/dbpedia_openai_3_large_3072_query_1000.fvecs", + "bytes": 12292000, + "width": 3072, + "records": 1000, + "mtime_ns": 1779387195663505529 + }, + { + "kind": "gt", + "path": "/home/ted_willke/datasets-clean/openai-3072-1m/dbpedia_openai_3_large_3072_gt_ip_100.ivecs", + "bytes": 404000, + "width": 100, + "records": 1000, + "mtime_ns": 1779387338759522247 + } +] diff --git a/docs/benchmarks/native-pq-ash-graph-20261028/manifest.json b/docs/benchmarks/native-pq-ash-graph-20261028/manifest.json new file mode 100644 index 000000000..989252207 --- /dev/null +++ b/docs/benchmarks/native-pq-ash-graph-20261028/manifest.json @@ -0,0 +1,22 @@ +{ + "source": { + "head": "507b74e7ef8a0e12fcfe402deb4bf758c65758d5", + "branch": "ash-dev", + "main": "4f13ff42e93f2e7b16020de3aaeefe9372e0dd36", + "clean": true, + "benchyaml_changes": false, + "backup": "c8d8de8af52b39bc8d2f6947294ed1ec40b088c6", + "base_head": "b44e25b71e3d9c5c7a2f164610b0fdbd099d91fa", + "pca_fix": "507b74e7ef8a0e12fcfe402deb4bf758c65758d5", + "retained_pq_source": "b44e25b71e3d9c5c7a2f164610b0fdbd099d91fa", + "retained_pq_count": 9 + }, + "root": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028", + "complete": true, + "cases": 32, + "fresh_builds": 32, + "cached_query_runs": 64, + "query_metric_cells": 128, + "benchyaml_unchanged": true, + "total_process_scope": "fresh construction plus one standard recall@10 accuracy pass at 1x overquery; includes JVM startup, dataset loading, construction, accuracy and teardown; no subtraction or estimate" +} diff --git a/docs/benchmarks/native-pq-ash-graph-20261028/native-preflight-proof.json b/docs/benchmarks/native-pq-ash-graph-20261028/native-preflight-proof.json new file mode 100644 index 000000000..bb63313db --- /dev/null +++ b/docs/benchmarks/native-pq-ash-graph-20261028/native-preflight-proof.json @@ -0,0 +1,23 @@ +{ + "22": { + "dispatch": [ + "INFO: Highway ISA tier: avx3_spr, Env variable JVECTOR_MAX_ISA: (not set)" + ], + "native_calls": [ + "", + "", + "" + ] + }, + "25": { + "dispatch": [ + "INFO: Highway ISA tier: avx3_spr, Env variable JVECTOR_MAX_ISA: (not set)" + ], + "native_calls": [ + "", + "", + "", + "" + ] + } +} diff --git a/docs/benchmarks/native-pq-ash-graph-20261028/pca-fix-runtime-audit.json b/docs/benchmarks/native-pq-ash-graph-20261028/pca-fix-runtime-audit.json new file mode 100644 index 000000000..cef8bff1e --- /dev/null +++ b/docs/benchmarks/native-pq-ash-graph-20261028/pca-fix-runtime-audit.json @@ -0,0 +1,14 @@ +{ + "22": { + "changed_runtime_files": [ + "jvector-base/classes/io/github/jbellis/jvector/quantization/AsymmetricHashing.class" + ], + "all_other_runtime_files_identical": true + }, + "25": { + "changed_runtime_files": [ + "jvector-base/classes/io/github/jbellis/jvector/quantization/AsymmetricHashing.class" + ], + "all_other_runtime_files_identical": true + } +} diff --git a/docs/benchmarks/native-pq-ash-graph-20261028/protocol.json b/docs/benchmarks/native-pq-ash-graph-20261028/protocol.json new file mode 100644 index 000000000..79af15886 --- /dev/null +++ b/docs/benchmarks/native-pq-ash-graph-20261028/protocol.json @@ -0,0 +1,141 @@ +{ + "status": "complete", + "benchmark_entry_point": "io.github.jbellis.jvector.example.BenchYAML", + "algorithms": [ + "PQ", + "ASH" + ], + "backend": "native Highway for both", + "source_before_integration": "c8d8de8a", + "fetched_main": "4f13ff42e93f2e7b16020de3aaeefe9372e0dd36", + "preparation": [ + "Preserve a local pre-rebase backup ref; rebase ash-dev onto fetched main.", + "Resolve overlap between current ASH fused/native changes and main deferred writer/native batching changes.", + "Retain ASH format v7; use the same supported format for PQ.", + "Check actual native ASH whole-neighborhood dispatch and native PQ ADC through BenchYAML; forced provider alone does not establish ASH kernel capability.", + "Confirm deferred NVQ/fused complete-record serialization, 64 records per writer batch; preserve same behavior in both arms." + ], + "jdks": [ + "Temurin 22.0.2+9", + "Temurin 25.0.2" + ], + "construction_physical_cores": 48, + "construction_cpu_affinity": "0-47", + "query_threads": [ + 1, + 8 + ], + "query_cpu_affinity": { + "1": "0", + "8": "0-7" + }, + "launch_properties": { + "jvector.vectorization_provider": "native", + "jvector.ash.blockKernel": "simd", + "jvector.parallel_writer": "true", + "jvector.physical_core_count": "48", + "jvector.bench.queryThreads": "1 or 8" + }, + "jvm_options": [ + "--add-modules=jdk.incubator.vector", + "--enable-native-access=ALL-UNNAMED", + "-da", + "-Xms2g", + "-Xmx128g", + "-XX:ActiveProcessorCount=48" + ], + "native_isa_policy": "Normal Highway runtime dispatch; record actual ISA and confirm AVX-512 on this host. No production hardware special-casing.", + "graph": { + "outDegree": 32, + "efConstruction": 100, + "neighborOverflow": 1.2, + "addHierarchy": true, + "refineFinalGraph": true, + "fusedGraph": true, + "reranking": "NVQ", + "nvq_subvectors": 2, + "nvq_bits_per_dimension": 8, + "search_pruning": false, + "ash_adaptive_termination": "retain existing production policy; report its presence" + }, + "quantizers": { + "ASH": { + "optimizer": "ITQ", + "landmarkCount": 1, + "projectedDimension": "D/2", + "bitsPerDimension": [ + 2, + 4 + ], + "training": "production PCA/ITQ defaults, min(N,20D), max25 iterations with early stopping" + }, + "PQ": { + "K": 256, + "m": "exact matched bytes per vector", + "centerData": false, + "anisotropicThreshold": -1 + } + }, + "timing": { + "training": "Actual new quantizer training, including the learned ASH transform.", + "encoding": "Base-vector quantizer encoding, recorded separately.", + "graph_write": "Graph insertion/refinement plus deferred NVQ encoding and complete-record serialization; no NVQ encoding double counting.", + "total_process": "fresh construction plus one standard recall@10 accuracy pass at 1x overquery; includes JVM startup, dataset loading, construction, accuracy and teardown; no subtraction or estimate", + "cache_policy": "Each construction case uses a new empty isolated working directory. Verify both graph and compressor cache misses. Cache enabled solely to retain the new graph for subsequent queries." + }, + "queries": { + "topK": 10, + "overquery": [ + 1, + 2 + ], + "rerankCandidates": [ + 10, + 20 + ], + "same_graph_across_thread_counts": true, + "all_dataset_queries": true, + "warmup_and_samples": "stock BenchYAML throughput policy; retain raw samples and CV" + }, + "matrix": { + "datasets": 4, + "matched_budgets_per_dataset": 2, + "algorithms": 2, + "jdks": 2, + "fresh_graphs": 32, + "query_processes": 64, + "qps_cells": 128, + "distinct_graph_recall_cells": 64 + }, + "file_roles": { + "index-parameters": "Saved-graph query configurations (top10, 1x/2x).", + "build-parameters": "One standard recall@10 scenario with accuracy-only compute/display/log selection; provides existing construction CSV metrics without throughput warmup.", + "run-config.yml": "Existing construction metrics; separate accuracy-only build/validation and throughput/recall query selections.", + "dataset-catalog.yaml": "Exact already-cleaned inputs used in the full-scan study, copied into an isolated study catalog." + }, + "precision_note": "Equal stored ASH/PQ code bytes, including 5-byte ASH header. Shared model bytes and NVQ/graph storage are not part of that code-budget definition; retain actual total index sizes.", + "rebase_status": "completed_and_validated_with_BenchYAML", + "source_after_rebase": "65bc5454a34e7245e969697f707713e490cee9d7", + "rebase_validation": "16 small-fixture BenchYAML checks on both JDKs; full benchmark not started; inclusive construction timing still pending", + "source": "507b74e7ef8a0e12fcfe402deb4bf758c65758d5", + "benchyaml_code_unchanged_from_rebase": true, + "construction_metrics": "existing BenchYAML construction.index_quant_time_s.* and construction.index_build_time_s; no new timer", + "total_process_scope": "fresh construction plus one standard recall@10 accuracy pass at 1x overquery; includes JVM startup, dataset loading, construction, accuracy and teardown; no subtraction or estimate", + "processes": { + "fresh_build_and_accuracy": 32, + "cached_query": 64 + }, + "measurement_instrumentation": "No JIT/class-load logging, profiling, or per-downcall tracing. Existing writer selection debug only.", + "case_repetitions": 1, + "pca_correction": { + "commit": "507b74e7ef8a0e12fcfe402deb4bf758c65758d5", + "superseded_successful_ash_cases": 9, + "retained_pq_cases": 9, + "source_of_retained_pq": "b44e25b71e3d9c5c7a2f164610b0fdbd099d91fa", + "all_non_ash_runtime_files_identical": true, + "numerical_validation": "energy and eigenvector residuals ~1e-14 on both JDKs; 4 tall/wide BenchYAML checks passed", + "new_ash_pca_driver": "LAPACK DGESDD, output energy validated", + "benchyaml_unchanged": true + }, + "completed_at": "2026-10-29T03:47:13.800992+00:00" +} diff --git a/docs/benchmarks/native-pq-ash-graph-20261028/queries.csv b/docs/benchmarks/native-pq-ash-graph-20261028/queries.csv new file mode 100644 index 000000000..d7a397888 --- /dev/null +++ b/docs/benchmarks/native-pq-ash-graph-20261028/queries.csv @@ -0,0 +1,129 @@ +dataset,jdk,ash_bits_budget,code_bytes,algorithm,query_threads,overquery,rerank_k,recall_at_10,qps,qps_stddev,qps_cv_percent,avg_visited +MPNet,22,2,101,PQ,1,1,10,0.65128,11929.667667640202,329.8666343047988,2.7650949170996433,417.1787 +MPNet,22,2,101,PQ,1,2,20,0.82126,9441.313656397691,84.63498370312523,0.8964322845663989,564.0539 +MPNet,22,2,101,PQ,8,1,10,0.65128,86214.6647201115,3212.690631712721,3.7263853453962215,417.1787 +MPNet,22,2,101,PQ,8,2,20,0.82126,68759.66645239317,938.1522559268892,1.3643932618207706,564.0539 +MPNet,22,2,101,ASH,1,1,10,0.8194,9610.898579370603,457.55703825931096,4.760814345096079,457.6786 +MPNet,22,2,101,ASH,1,2,20,0.94055,7822.315710403462,39.11300159050038,0.5000181920359105,639.0262 +MPNet,22,2,101,ASH,8,1,10,0.8194,70989.65395938238,2781.2699707500246,3.91785255403747,457.6786 +MPNet,22,2,101,ASH,8,2,20,0.94055,59038.82008864344,991.3419392083159,1.6791357579976567,639.0262 +MPNet,22,4,197,PQ,1,1,10,0.80733,9642.851473210929,252.91163255244882,2.622788842647526,439.2968 +MPNet,22,4,197,PQ,1,2,20,0.92753,7455.724453990442,29.65127525390488,0.39769811018210266,604.3334 +MPNet,22,4,197,PQ,8,1,10,0.80733,74892.80322512507,3888.4955727931288,5.192081755979211,439.2968 +MPNet,22,4,197,PQ,8,2,20,0.92753,57187.4193258142,2011.126949231717,3.516729681005034,604.3334 +MPNet,22,4,197,ASH,1,1,10,0.86642,8629.758114420085,522.9722821464711,6.0601035998054105,431.9821 +MPNet,22,4,197,ASH,1,2,20,0.93966,6976.353944105883,19.32133701087272,0.2769546551920112,596.0551 +MPNet,22,4,197,ASH,8,1,10,0.86642,64520.16990414337,1765.6384986179241,2.73656827197,431.9821 +MPNet,22,4,197,ASH,8,2,20,0.93966,52278.56016692018,2374.4805450263125,4.541977700695726,596.0551 +MPNet,25,2,101,PQ,1,1,10,0.65484,11812.269458852594,210.99098451189369,1.7862019254375245,416.9763 +MPNet,25,2,101,PQ,1,2,20,0.8221,9294.266279454745,42.12928952819675,0.45328257510035846,563.8511 +MPNet,25,2,101,PQ,8,1,10,0.65484,76801.73087416003,911.757836651423,1.187157927658352,416.9763 +MPNet,25,2,101,PQ,8,2,20,0.8221,71112.3291273773,640.384545175938,0.9005253421370478,563.8511 +MPNet,25,2,101,ASH,1,1,10,0.82017,10357.12246572871,15.687831200532978,0.15146901325578957,455.8008 +MPNet,25,2,101,ASH,1,2,20,0.93971,8282.467440755714,24.053931479168885,0.2904198736816784,636.613 +MPNet,25,2,101,ASH,8,1,10,0.82017,73840.6240900836,3301.781445008702,4.471497208610557,455.8008 +MPNet,25,2,101,ASH,8,2,20,0.93971,60433.593076138226,1524.9801313351495,2.5233980865805528,636.613 +MPNet,25,4,197,PQ,1,1,10,0.80751,10001.00801293626,179.69312362625553,1.7967501215259827,438.5955 +MPNet,25,4,197,PQ,1,2,20,0.92601,7557.44798962551,14.656592861029829,0.193935742345163,602.5851 +MPNet,25,4,197,PQ,8,1,10,0.80751,74886.84115917748,2099.6919388373026,2.803819611477874,438.5955 +MPNet,25,4,197,PQ,8,2,20,0.92601,56904.75254695084,1261.345571497758,2.2165909085661166,602.5851 +MPNet,25,4,197,ASH,1,1,10,0.86293,9153.966952460185,317.24481664160703,3.465653943139324,432.9807 +MPNet,25,4,197,ASH,1,2,20,0.94033,7114.7162102085695,46.99403949422588,0.6605188191033727,598.6739 +MPNet,25,4,197,ASH,8,1,10,0.86293,67390.32073511016,2298.9017978117477,3.4113234255821347,432.9807 +MPNet,25,4,197,ASH,8,2,20,0.94033,54357.67614973744,353.40026548673944,0.6501386566144558,598.6739 +Cohere,22,2,133,PQ,1,1,10,0.59431,9694.693384586766,325.22511177435325,3.3546714565662965,509.2777 +Cohere,22,2,133,PQ,1,2,20,0.78292,7699.839253969693,42.40381740356734,0.5507104240092522,715.1325 +Cohere,22,2,133,PQ,8,1,10,0.59431,73999.92617936898,2539.3386732272957,3.431542170828892,509.2777 +Cohere,22,2,133,PQ,8,2,20,0.78292,59164.921945419286,2035.060877910483,3.4396409409410924,715.1325 +Cohere,22,2,133,ASH,1,1,10,0.63903,6040.668013555356,274.3959501873643,4.542476917645787,567.867 +Cohere,22,2,133,ASH,1,2,20,0.81995,5224.424204577702,61.298718058874684,1.173310505780982,812.9255 +Cohere,22,2,133,ASH,8,1,10,0.63903,47759.13958907922,2166.440848887819,4.5361806505057,567.867 +Cohere,22,2,133,ASH,8,2,20,0.81995,39833.20097312,632.0039581039011,1.586626087444959,812.9255 +Cohere,22,4,261,PQ,1,1,10,0.65081,7655.785166781064,175.76862112499447,2.2958928091094513,507.7788 +Cohere,22,4,261,PQ,1,2,20,0.81039,5826.0870091323695,7.660713163915625,0.13148985162609977,712.6111 +Cohere,22,4,261,PQ,8,1,10,0.65081,57544.851973715246,1423.1567643985286,2.4731261191680254,507.7788 +Cohere,22,4,261,PQ,8,2,20,0.81039,43365.895255088246,343.3827570210426,0.7918267454209942,712.6111 +Cohere,22,4,261,ASH,1,1,10,0.70693,5459.5384791205415,302.9731487020324,5.549427847440271,540.8918 +Cohere,22,4,261,ASH,1,2,20,0.83397,4609.658123480801,55.2745257320803,1.1991024985241623,770.202 +Cohere,22,4,261,ASH,8,1,10,0.70693,41672.84985305652,708.8527180104267,1.7009941016991315,540.8918 +Cohere,22,4,261,ASH,8,2,20,0.83397,35207.00369774443,556.6259719487867,1.5810092126199524,770.202 +Cohere,25,2,133,PQ,1,1,10,0.5913,9412.081288856038,112.56406605696156,1.195952973655658,515.0213 +Cohere,25,2,133,PQ,1,2,20,0.7845,7190.940014143866,167.83733226091644,2.334011018459298,722.8076 +Cohere,25,2,133,PQ,8,1,10,0.5913,75637.85852751213,1813.227802143092,2.3972489933510714,515.0213 +Cohere,25,2,133,PQ,8,2,20,0.7845,57862.554944797805,522.6740498873122,0.903302749741962,722.8076 +Cohere,25,2,133,ASH,1,1,10,0.63975,6427.095638763094,169.83121886243185,2.642425574596182,565.5602 +Cohere,25,2,133,ASH,1,2,20,0.82327,5258.107673080131,63.42893861825412,1.2063073364395043,811.3677 +Cohere,25,2,133,ASH,8,1,10,0.63975,50148.40516839785,824.0291437743359,1.6431811560253098,565.5602 +Cohere,25,2,133,ASH,8,2,20,0.82327,41901.690380100095,454.39922005212,1.084441262226315,811.3677 +Cohere,25,4,261,PQ,1,1,10,0.6553,7857.8449638838065,139.06736295154764,1.7697901090022579,505.9876 +Cohere,25,4,261,PQ,1,2,20,0.81365,5920.816567354704,28.88024786803325,0.4877747442349213,710.3709 +Cohere,25,4,261,PQ,8,1,10,0.6553,57670.64664851298,1069.5685083266228,1.854615078005548,505.9876 +Cohere,25,4,261,PQ,8,2,20,0.81365,43804.78320505803,222.48459131611997,0.5079002223903946,710.3709 +Cohere,25,4,261,ASH,1,1,10,0.7059,5662.326694410597,193.66647724429077,3.420263218571671,539.3398 +Cohere,25,4,261,ASH,1,2,20,0.83696,4635.7196414662285,61.53701677204011,1.3274533736163694,770.3046 +Cohere,25,4,261,ASH,8,1,10,0.7059,42689.45179035281,153.1711013850193,0.3588031585349,539.3398 +Cohere,25,4,261,ASH,8,2,20,0.83696,35644.71872678161,417.585373344066,1.171521022636977,770.3046 +Ada002,22,2,197,PQ,1,1,10,0.54986,7064.577838319312,345.1160615646333,4.885161852031312,458.0818 +Ada002,22,2,197,PQ,1,2,20,0.75347,5685.492109827316,228.87502457014529,4.025597435524308,624.9491 +Ada002,22,2,197,PQ,8,1,10,0.54986,53847.213088554396,938.0679515199026,1.7420919258666252,458.0818 +Ada002,22,2,197,PQ,8,2,20,0.75347,45513.06620677013,369.25509208384045,0.8113166676274456,624.9491 +Ada002,22,2,197,ASH,1,1,10,0.7888,3301.2900012077635,172.72057577933134,5.2319116380609465,501.4557 +Ada002,22,2,197,ASH,1,2,20,0.92685,2987.2294544484503,75.4558191294618,2.5259465427771652,706.5122 +Ada002,22,2,197,ASH,8,1,10,0.7888,24911.127856122304,756.1189899379334,3.035265983559653,501.4557 +Ada002,22,2,197,ASH,8,2,20,0.92685,22127.98341769791,90.58944642512024,0.40938862215825333,706.5122 +Ada002,22,4,389,PQ,1,1,10,0.73555,5341.342726973948,197.28391094169163,3.6935265349179285,477.3403 +Ada002,22,4,389,PQ,1,2,20,0.90049,4200.0630432290845,27.34394359857975,0.6510365039082182,659.2552 +Ada002,22,4,389,PQ,8,1,10,0.73555,41038.5274305757,1647.1171751077788,4.013587421951686,477.3403 +Ada002,22,4,389,PQ,8,2,20,0.90049,30912.637087924762,630.078503069555,2.0382554269874285,659.2552 +Ada002,22,4,389,ASH,1,1,10,0.83231,2975.630205424497,207.2121051545478,6.96363764478548,475.3654 +Ada002,22,4,389,ASH,1,2,20,0.92443,2698.9527131275813,56.93823605602251,2.109641854007948,666.273 +Ada002,22,4,389,ASH,8,1,10,0.83231,21870.622987666935,2054.0557899992486,9.391848559401126,475.3654 +Ada002,22,4,389,ASH,8,2,20,0.92443,19258.54676558465,604.5771747646882,3.139266851874191,666.273 +Ada002,25,2,197,PQ,1,1,10,0.54594,7271.180761294964,164.92385726492031,2.2681853563979906,466.6089 +Ada002,25,2,197,PQ,1,2,20,0.7504,5711.746382358798,17.585559682677623,0.3078841129394695,636.743 +Ada002,25,2,197,PQ,8,1,10,0.54594,49406.674887911795,1948.6084898358451,3.9440186862536795,466.6089 +Ada002,25,2,197,PQ,8,2,20,0.7504,43937.988185794646,435.3744690698342,0.9908839413148024,636.743 +Ada002,25,2,197,ASH,1,1,10,0.79064,3436.8450622860405,85.67191578506873,2.4927488505426973,495.6071 +Ada002,25,2,197,ASH,1,2,20,0.92718,3096.883668745141,86.13452376442987,2.781329006114447,699.8394 +Ada002,25,2,197,ASH,8,1,10,0.79064,25148.668295078558,1423.1286862834309,5.658863004535029,495.6071 +Ada002,25,2,197,ASH,8,2,20,0.92718,22643.408793697028,202.7696533831723,0.8954908478250626,699.8394 +Ada002,25,4,389,PQ,1,1,10,0.73566,3664.671687680422,20.175300036192812,0.5505349934624812,474.1962 +Ada002,25,4,389,PQ,1,2,20,0.9015,3883.6968894121583,60.99347982756172,1.5705005195911101,656.8223 +Ada002,25,4,389,PQ,8,1,10,0.73566,26979.66563109857,1180.029335083216,4.373773015641214,474.1962 +Ada002,25,4,389,PQ,8,2,20,0.9015,22435.812911872614,174.55195220413958,0.7780059179927015,656.8223 +Ada002,25,4,389,ASH,1,1,10,0.83528,2958.5765700966863,117.34517913112595,3.9662714941087738,475.7187 +Ada002,25,4,389,ASH,1,2,20,0.92467,2649.82808625031,112.41385140796983,4.2423073402864855,664.8421 +Ada002,25,4,389,ASH,8,1,10,0.83528,21982.92028578378,927.6380025677681,4.2198124294144215,475.7187 +Ada002,25,4,389,ASH,8,2,20,0.92467,20075.60298373417,614.8878491828754,3.0628611737394644,664.8421 +OpenAI3072,22,2,389,PQ,1,1,10,0.6437,3329.077370787888,410.3285959728882,12.325595060464947,471.041 +OpenAI3072,22,2,389,PQ,1,2,20,0.7985,2953.6549674392622,20.699428291440878,0.7008072547277489,653.509 +OpenAI3072,22,2,389,PQ,8,1,10,0.6437,27543.90063678985,1082.175435232485,3.928911338675991,471.041 +OpenAI3072,22,2,389,PQ,8,2,20,0.7985,22784.923597112218,198.6192438432833,0.8717134512070802,653.509 +OpenAI3072,22,2,389,ASH,1,1,10,0.7792,1031.0076970878936,45.92267428032494,4.454154358889332,530.783 +OpenAI3072,22,2,389,ASH,1,2,20,0.898,1068.223215503725,5.4895348079781785,0.5138939809868825,767.32 +OpenAI3072,22,2,389,ASH,8,1,10,0.7792,7189.703298060613,411.1343239182431,5.718376779597353,530.783 +OpenAI3072,22,2,389,ASH,8,2,20,0.898,7037.381528041226,98.28375598243322,1.3965955318865506,767.32 +OpenAI3072,22,4,773,PQ,1,1,10,0.7691,2143.912280952585,170.47402337973324,7.951539104201971,500.603 +OpenAI3072,22,4,773,PQ,1,2,20,0.877,1871.3707850777212,16.099488501314273,0.8603045761797353,704.466 +OpenAI3072,22,4,773,PQ,8,1,10,0.7691,18466.799971430668,859.2042061471868,4.652696771917339,500.603 +OpenAI3072,22,4,773,PQ,8,2,20,0.877,14336.390737938298,126.38455072730488,0.8815646353224336,704.466 +OpenAI3072,22,4,773,ASH,1,1,10,0.7903,1016.1509827131623,48.29108771527011,4.752353590834606,512.685 +OpenAI3072,22,4,773,ASH,1,2,20,0.8834,1030.8662536443949,5.289502308681586,0.5131123741786817,735.241 +OpenAI3072,22,4,773,ASH,8,1,10,0.7903,7507.82372210983,379.60972307236995,5.056188545749353,512.685 +OpenAI3072,22,4,773,ASH,8,2,20,0.8834,7360.509055849552,197.69692384205354,2.6859137369709454,735.241 +OpenAI3072,25,2,389,PQ,1,1,10,0.651,2170.3203408432223,257.79510337346886,11.878205190359557,466.217 +OpenAI3072,25,2,389,PQ,1,2,20,0.8032,2068.413509232902,11.500305926225575,0.5559964617757023,646.845 +OpenAI3072,25,2,389,PQ,8,1,10,0.651,27772.466077225967,451.6869097187096,1.6263838740957284,466.217 +OpenAI3072,25,2,389,PQ,8,2,20,0.8032,23213.773739290547,250.71842094376962,1.0800416328665052,646.845 +OpenAI3072,25,2,389,ASH,1,1,10,0.7676,1003.0125753831542,80.02276597676175,7.978241543601064,532.42 +OpenAI3072,25,2,389,ASH,1,2,20,0.8917,1093.9677679889598,6.6161315083382,0.6047830385808011,770.301 +OpenAI3072,25,2,389,ASH,8,1,10,0.7676,8050.426675043233,454.19173972355793,5.641834377941449,532.42 +OpenAI3072,25,2,389,ASH,8,2,20,0.8917,7893.088397249235,67.54153630967923,0.8557048003316124,770.301 +OpenAI3072,25,4,773,PQ,1,1,10,0.7624,2280.759101685725,228.66633119364224,10.025887040180322,501.437 +OpenAI3072,25,4,773,PQ,1,2,20,0.8727,1886.2499788294274,10.71340400192702,0.5679737109169148,705.403 +OpenAI3072,25,4,773,PQ,8,1,10,0.7624,15663.804691288786,513.7893416245771,3.280105643236947,501.437 +OpenAI3072,25,4,773,PQ,8,2,20,0.8727,12884.883579153513,87.90944686405366,0.6822680726916507,705.403 +OpenAI3072,25,4,773,ASH,1,1,10,0.7965,964.9450620510337,132.69872297613534,13.751945908099502,510.697 +OpenAI3072,25,4,773,ASH,1,2,20,0.8875,1033.6181669420773,9.002443152648716,0.8709640987911547,730.922 +OpenAI3072,25,4,773,ASH,8,1,10,0.7965,7915.294433750889,199.40736877030656,2.519266597588988,510.697 +OpenAI3072,25,4,773,ASH,8,2,20,0.8875,7489.111448400777,101.00421873730507,1.34868094076599,730.922 diff --git a/docs/benchmarks/native-pq-ash-graph-20261028/raw-results.json b/docs/benchmarks/native-pq-ash-graph-20261028/raw-results.json new file mode 100644 index 000000000..8beae84b2 --- /dev/null +++ b/docs/benchmarks/native-pq-ash-graph-20261028/raw-results.json @@ -0,0 +1,8098 @@ +[ + { + "dataset": "mpnet-1m", + "display_name": "MPNet", + "original_dimensions": "768", + "projected_ash_dimensions": "384", + "base_vectors": "999812", + "queries": "10000", + "ash_bits": "2", + "algorithm": "PQ", + "code_bytes": "101", + "ash_encoded_bits": "", + "pq_m": "101", + "config": "mpnet-1m-2bit-budget-pq.yml", + "jdk": "22", + "label": "jdk22-mpnet-1m-2bit-pq", + "path": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk22-mpnet-1m-2bit-pq", + "source": "b44e25b71e3d9c5c7a2f164610b0fdbd099d91fa", + "started": "2026-10-29T00:49:17.628997+00:00", + "phases": { + "build": { + "elapsed_process_seconds": 52.27324899379164, + "log": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/logs/jdk22-mpnet-1m-2bit-pq-build.log", + "csv": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk22-mpnet-1m-2bit-pq/logging/jdk22-mpnet-1m-2bit-pq-build-20261029-004918Z/experiments.csv", + "rows": [ + { + "schema_version": "1", + "run_id": "jdk22-mpnet-1m-2bit-pq-build-20261029-004918Z", + "run_uuid": "2e4c89c4-62bd-4292-b2cc-632c940e6d75", + "system_id": "d940322e03a3", + "dataset_name": "mpnet-1m", + "M": "32", + "efConstruction": "100", + "neighborOverflow": "1.2", + "addHierarchy": "true", + "refineFinalGraph": "true", + "feature_set": "[NVQ_VECTORS, FUSED_PQ]", + "build_compressor": "\"ProductQuantization(M=101, clusters=256, centered=false)\"", + "search_compressor": "None", + "usePruning": "false", + "topK": "10", + "overquery": "1.0", + "rerankK": "10", + "search.accuracy.recall_at_10": "0.65128", + "construction.index_build_time_s": "44.438387038", + "construction.index_quant_time_s.NVQ.compute_time_s": "0.275686754", + "construction.index_quant_time_s.PQ.compute_time_s": "2.729675109", + "construction.index_quant_time_s.PQ.encoding_time_s": "1.081207084" + } + ], + "throughput_samples": [], + "finished": "2026-10-29T00:50:09.903112+00:00" + }, + "t1": { + "elapsed_process_seconds": 37.240249725058675, + "log": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/logs/jdk22-mpnet-1m-2bit-pq-t1.log", + "csv": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk22-mpnet-1m-2bit-pq/logging/jdk22-mpnet-1m-2bit-pq-t1-20261029-005011Z/experiments.csv", + "rows": [ + { + "schema_version": "1", + "run_id": "jdk22-mpnet-1m-2bit-pq-t1-20261029-005011Z", + "run_uuid": "201a4058-0405-43a4-90e5-70bf8255ef52", + "system_id": "d940322e03a3", + "dataset_name": "mpnet-1m", + "M": "32", + "efConstruction": "100", + "neighborOverflow": "1.2", + "addHierarchy": "true", + "refineFinalGraph": "true", + "feature_set": "[NVQ_VECTORS, FUSED_PQ]", + "build_compressor": "\"ProductQuantization(M=101, clusters=256, centered=false)\"", + "search_compressor": "None", + "usePruning": "false", + "topK": "10", + "overquery": "1.0", + "rerankK": "10", + "search.throughput.avg_qps": "11929.667667640202", + "search.throughput.stddev_qps": "329.8666343047988", + "search.throughput.cv_pct": "2.7650949170996433", + "search.count.avg_visited": "417.1787", + "search.accuracy.recall_at_10": "0.65128", + "construction.index_build_time_s": "", + "construction.index_quant_time_s.NVQ.compute_time_s": "", + "construction.index_quant_time_s.PQ.compute_time_s": "", + "construction.index_quant_time_s.PQ.encoding_time_s": "" + }, + { + "schema_version": "1", + "run_id": "jdk22-mpnet-1m-2bit-pq-t1-20261029-005011Z", + "run_uuid": "201a4058-0405-43a4-90e5-70bf8255ef52", + "system_id": "d940322e03a3", + "dataset_name": "mpnet-1m", + "M": "32", + "efConstruction": "100", + "neighborOverflow": "1.2", + "addHierarchy": "true", + "refineFinalGraph": "true", + "feature_set": "[NVQ_VECTORS, FUSED_PQ]", + "build_compressor": "\"ProductQuantization(M=101, clusters=256, centered=false)\"", + "search_compressor": "None", + "usePruning": "false", + "topK": "10", + "overquery": "2.0", + "rerankK": "20", + "search.throughput.avg_qps": "9441.313656397691", + "search.throughput.stddev_qps": "84.63498370312523", + "search.throughput.cv_pct": "0.8964322845663989", + "search.count.avg_visited": "564.0539", + "search.accuracy.recall_at_10": "0.82126", + "construction.index_build_time_s": "", + "construction.index_quant_time_s.NVQ.compute_time_s": "", + "construction.index_quant_time_s.PQ.compute_time_s": "", + "construction.index_quant_time_s.PQ.encoding_time_s": "" + } + ], + "throughput_samples": [ + { + "sample": "Test-0", + "queries": 20000, + "seconds": 1.731411 + }, + { + "sample": "Test-1", + "queries": 20000, + "seconds": 1.655472 + }, + { + "sample": "Test-2", + "queries": 20000, + "seconds": 1.645198 + }, + { + "sample": "Test-0", + "queries": 10000, + "seconds": 1.070246 + }, + { + "sample": "Test-1", + "queries": 10000, + "seconds": 1.053404 + }, + { + "sample": "Test-2", + "queries": 10000, + "seconds": 1.054045 + } + ], + "finished": "2026-10-29T00:50:47.144820+00:00" + }, + "t8": { + "elapsed_process_seconds": 17.557420658878982, + "log": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/logs/jdk22-mpnet-1m-2bit-pq-t8.log", + "csv": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk22-mpnet-1m-2bit-pq/logging/jdk22-mpnet-1m-2bit-pq-t8-20261029-005047Z/experiments.csv", + "rows": [ + { + "schema_version": "1", + "run_id": "jdk22-mpnet-1m-2bit-pq-t8-20261029-005047Z", + "run_uuid": "2cd0e0ee-b60f-497b-839a-eab0f3af2676", + "system_id": "d940322e03a3", + "dataset_name": "mpnet-1m", + "M": "32", + "efConstruction": "100", + "neighborOverflow": "1.2", + "addHierarchy": "true", + "refineFinalGraph": "true", + "feature_set": "[NVQ_VECTORS, FUSED_PQ]", + "build_compressor": "\"ProductQuantization(M=101, clusters=256, centered=false)\"", + "search_compressor": "None", + "usePruning": "false", + "topK": "10", + "overquery": "1.0", + "rerankK": "10", + "search.throughput.avg_qps": "86214.6647201115", + "search.throughput.stddev_qps": "3212.690631712721", + "search.throughput.cv_pct": "3.7263853453962215", + "search.count.avg_visited": "417.1787", + "search.accuracy.recall_at_10": "0.65128", + "construction.index_build_time_s": "", + "construction.index_quant_time_s.NVQ.compute_time_s": "", + "construction.index_quant_time_s.PQ.compute_time_s": "", + "construction.index_quant_time_s.PQ.encoding_time_s": "" + }, + { + "schema_version": "1", + "run_id": "jdk22-mpnet-1m-2bit-pq-t8-20261029-005047Z", + "run_uuid": "2cd0e0ee-b60f-497b-839a-eab0f3af2676", + "system_id": "d940322e03a3", + "dataset_name": "mpnet-1m", + "M": "32", + "efConstruction": "100", + "neighborOverflow": "1.2", + "addHierarchy": "true", + "refineFinalGraph": "true", + "feature_set": "[NVQ_VECTORS, FUSED_PQ]", + "build_compressor": "\"ProductQuantization(M=101, clusters=256, centered=false)\"", + "search_compressor": "None", + "usePruning": "false", + "topK": "10", + "overquery": "2.0", + "rerankK": "20", + "search.throughput.avg_qps": "68759.66645239317", + "search.throughput.stddev_qps": "938.1522559268892", + "search.throughput.cv_pct": "1.3643932618207706", + "search.count.avg_visited": "564.0539", + "search.accuracy.recall_at_10": "0.82126", + "construction.index_build_time_s": "", + "construction.index_quant_time_s.NVQ.compute_time_s": "", + "construction.index_quant_time_s.PQ.compute_time_s": "", + "construction.index_quant_time_s.PQ.encoding_time_s": "" + } + ], + "throughput_samples": [ + { + "sample": "Test-0", + "queries": 90000, + "seconds": 1.07914 + }, + { + "sample": "Test-1", + "queries": 90000, + "seconds": 1.052267 + }, + { + "sample": "Test-2", + "queries": 100000, + "seconds": 1.114646 + }, + { + "sample": "Test-0", + "queries": 70000, + "seconds": 1.00355 + }, + { + "sample": "Test-1", + "queries": 70000, + "seconds": 1.031114 + }, + { + "sample": "Test-2", + "queries": 70000, + "seconds": 1.01983 + } + ], + "finished": "2026-10-29T00:51:04.703720+00:00" + } + }, + "index_files": [ + { + "path": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk22-mpnet-1m-2bit-pq/index_cache/graph_mpnet_1m_FUSED_PQ_NVQ_VECTORS_M32_ef100_of1.2_alpha1.2_H1_R1_ProductQuantization_M_101_clusters_256_centered_false_", + "bytes": 4204460293 + } + ], + "finished": "2026-10-29T00:51:04.704834+00:00" + }, + { + "dataset": "mpnet-1m", + "display_name": "MPNet", + "original_dimensions": "768", + "projected_ash_dimensions": "384", + "base_vectors": "999812", + "queries": "10000", + "ash_bits": "4", + "algorithm": "PQ", + "code_bytes": "197", + "ash_encoded_bits": "", + "pq_m": "197", + "config": "mpnet-1m-4bit-budget-pq.yml", + "jdk": "22", + "label": "jdk22-mpnet-1m-4bit-pq", + "path": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk22-mpnet-1m-4bit-pq", + "source": "b44e25b71e3d9c5c7a2f164610b0fdbd099d91fa", + "started": "2026-10-29T00:53:34.216218+00:00", + "phases": { + "build": { + "elapsed_process_seconds": 62.748410480096936, + "log": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/logs/jdk22-mpnet-1m-4bit-pq-build.log", + "csv": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk22-mpnet-1m-4bit-pq/logging/jdk22-mpnet-1m-4bit-pq-build-20261029-005334Z/experiments.csv", + "rows": [ + { + "schema_version": "1", + "run_id": "jdk22-mpnet-1m-4bit-pq-build-20261029-005334Z", + "run_uuid": "7c75a71a-475d-4811-8931-b2d4d6d9ba53", + "system_id": "d940322e03a3", + "dataset_name": "mpnet-1m", + "M": "32", + "efConstruction": "100", + "neighborOverflow": "1.2", + "addHierarchy": "true", + "refineFinalGraph": "true", + "feature_set": "[NVQ_VECTORS, FUSED_PQ]", + "build_compressor": "\"ProductQuantization(M=197, clusters=256, centered=false)\"", + "search_compressor": "None", + "usePruning": "false", + "topK": "10", + "overquery": "1.0", + "rerankK": "10", + "search.accuracy.recall_at_10": "0.80733", + "construction.index_build_time_s": "50.786497509", + "construction.index_quant_time_s.NVQ.compute_time_s": "0.30227987", + "construction.index_quant_time_s.PQ.compute_time_s": "6.137772914", + "construction.index_quant_time_s.PQ.encoding_time_s": "1.70667817" + } + ], + "throughput_samples": [], + "finished": "2026-10-29T00:54:36.965571+00:00" + }, + "t1": { + "elapsed_process_seconds": 40.890649567358196, + "log": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/logs/jdk22-mpnet-1m-4bit-pq-t1.log", + "csv": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk22-mpnet-1m-4bit-pq/logging/jdk22-mpnet-1m-4bit-pq-t1-20261029-005438Z/experiments.csv", + "rows": [ + { + "schema_version": "1", + "run_id": "jdk22-mpnet-1m-4bit-pq-t1-20261029-005438Z", + "run_uuid": "110986bf-c4d0-4438-968d-c0f0bc3d6f99", + "system_id": "d940322e03a3", + "dataset_name": "mpnet-1m", + "M": "32", + "efConstruction": "100", + "neighborOverflow": "1.2", + "addHierarchy": "true", + "refineFinalGraph": "true", + "feature_set": "[NVQ_VECTORS, FUSED_PQ]", + "build_compressor": "\"ProductQuantization(M=197, clusters=256, centered=false)\"", + "search_compressor": "None", + "usePruning": "false", + "topK": "10", + "overquery": "1.0", + "rerankK": "10", + "search.throughput.avg_qps": "9642.851473210929", + "search.throughput.stddev_qps": "252.91163255244882", + "search.throughput.cv_pct": "2.622788842647526", + "search.count.avg_visited": "439.2968", + "search.accuracy.recall_at_10": "0.80733", + "construction.index_build_time_s": "", + "construction.index_quant_time_s.NVQ.compute_time_s": "", + "construction.index_quant_time_s.PQ.compute_time_s": "", + "construction.index_quant_time_s.PQ.encoding_time_s": "" + }, + { + "schema_version": "1", + "run_id": "jdk22-mpnet-1m-4bit-pq-t1-20261029-005438Z", + "run_uuid": "110986bf-c4d0-4438-968d-c0f0bc3d6f99", + "system_id": "d940322e03a3", + "dataset_name": "mpnet-1m", + "M": "32", + "efConstruction": "100", + "neighborOverflow": "1.2", + "addHierarchy": "true", + "refineFinalGraph": "true", + "feature_set": "[NVQ_VECTORS, FUSED_PQ]", + "build_compressor": "\"ProductQuantization(M=197, clusters=256, centered=false)\"", + "search_compressor": "None", + "usePruning": "false", + "topK": "10", + "overquery": "2.0", + "rerankK": "20", + "search.throughput.avg_qps": "7455.724453990442", + "search.throughput.stddev_qps": "29.65127525390488", + "search.throughput.cv_pct": "0.39769811018210266", + "search.count.avg_visited": "604.3334", + "search.accuracy.recall_at_10": "0.92753", + "construction.index_build_time_s": "", + "construction.index_quant_time_s.NVQ.compute_time_s": "", + "construction.index_quant_time_s.PQ.compute_time_s": "", + "construction.index_quant_time_s.PQ.encoding_time_s": "" + } + ], + "throughput_samples": [ + { + "sample": "Test-0", + "queries": 10000, + "seconds": 1.068192 + }, + { + "sample": "Test-1", + "queries": 10000, + "seconds": 1.0293 + }, + { + "sample": "Test-2", + "queries": 10000, + "seconds": 1.015063 + }, + { + "sample": "Test-0", + "queries": 10000, + "seconds": 1.347097 + }, + { + "sample": "Test-1", + "queries": 10000, + "seconds": 1.340087 + }, + { + "sample": "Test-2", + "queries": 10000, + "seconds": 1.336613 + } + ], + "finished": "2026-10-29T00:55:17.858173+00:00" + }, + "t8": { + "elapsed_process_seconds": 18.57581972051412, + "log": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/logs/jdk22-mpnet-1m-4bit-pq-t8.log", + "csv": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk22-mpnet-1m-4bit-pq/logging/jdk22-mpnet-1m-4bit-pq-t8-20261029-005518Z/experiments.csv", + "rows": [ + { + "schema_version": "1", + "run_id": "jdk22-mpnet-1m-4bit-pq-t8-20261029-005518Z", + "run_uuid": "280e8bdd-2528-4a21-a68f-c6eabc534695", + "system_id": "d940322e03a3", + "dataset_name": "mpnet-1m", + "M": "32", + "efConstruction": "100", + "neighborOverflow": "1.2", + "addHierarchy": "true", + "refineFinalGraph": "true", + "feature_set": "[NVQ_VECTORS, FUSED_PQ]", + "build_compressor": "\"ProductQuantization(M=197, clusters=256, centered=false)\"", + "search_compressor": "None", + "usePruning": "false", + "topK": "10", + "overquery": "1.0", + "rerankK": "10", + "search.throughput.avg_qps": "74892.80322512507", + "search.throughput.stddev_qps": "3888.4955727931288", + "search.throughput.cv_pct": "5.192081755979211", + "search.count.avg_visited": "439.2968", + "search.accuracy.recall_at_10": "0.80733", + "construction.index_build_time_s": "", + "construction.index_quant_time_s.NVQ.compute_time_s": "", + "construction.index_quant_time_s.PQ.compute_time_s": "", + "construction.index_quant_time_s.PQ.encoding_time_s": "" + }, + { + "schema_version": "1", + "run_id": "jdk22-mpnet-1m-4bit-pq-t8-20261029-005518Z", + "run_uuid": "280e8bdd-2528-4a21-a68f-c6eabc534695", + "system_id": "d940322e03a3", + "dataset_name": "mpnet-1m", + "M": "32", + "efConstruction": "100", + "neighborOverflow": "1.2", + "addHierarchy": "true", + "refineFinalGraph": "true", + "feature_set": "[NVQ_VECTORS, FUSED_PQ]", + "build_compressor": "\"ProductQuantization(M=197, clusters=256, centered=false)\"", + "search_compressor": "None", + "usePruning": "false", + "topK": "10", + "overquery": "2.0", + "rerankK": "20", + "search.throughput.avg_qps": "57187.4193258142", + "search.throughput.stddev_qps": "2011.126949231717", + "search.throughput.cv_pct": "3.516729681005034", + "search.count.avg_visited": "604.3334", + "search.accuracy.recall_at_10": "0.92753", + "construction.index_build_time_s": "", + "construction.index_quant_time_s.NVQ.compute_time_s": "", + "construction.index_quant_time_s.PQ.compute_time_s": "", + "construction.index_quant_time_s.PQ.encoding_time_s": "" + } + ], + "throughput_samples": [ + { + "sample": "Test-0", + "queries": 80000, + "seconds": 1.123119 + }, + { + "sample": "Test-1", + "queries": 80000, + "seconds": 1.012999 + }, + { + "sample": "Test-2", + "queries": 80000, + "seconds": 1.07419 + }, + { + "sample": "Test-0", + "queries": 60000, + "seconds": 1.013272 + }, + { + "sample": "Test-1", + "queries": 60000, + "seconds": 1.04976 + }, + { + "sample": "Test-2", + "queries": 60000, + "seconds": 1.08711 + } + ], + "finished": "2026-10-29T00:55:36.435469+00:00" + } + }, + "index_files": [ + { + "path": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk22-mpnet-1m-4bit-pq/index_cache/graph_mpnet_1m_FUSED_PQ_NVQ_VECTORS_M32_ef100_of1.2_alpha1.2_H1_R1_ProductQuantization_M_197_clusters_256_centered_false_", + "bytes": 7278884955 + } + ], + "finished": "2026-10-29T00:55:36.436064+00:00" + }, + { + "dataset": "cohere-english-v3-1M", + "display_name": "Cohere", + "original_dimensions": "1024", + "projected_ash_dimensions": "512", + "base_vectors": "1000000", + "queries": "10000", + "ash_bits": "2", + "algorithm": "PQ", + "code_bytes": "133", + "ash_encoded_bits": "", + "pq_m": "133", + "config": "cohere-english-v3-1M-2bit-budget-pq.yml", + "jdk": "22", + "label": "jdk22-cohere-english-v3-1M-2bit-pq", + "path": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk22-cohere-english-v3-1M-2bit-pq", + "source": "b44e25b71e3d9c5c7a2f164610b0fdbd099d91fa", + "started": "2026-10-29T00:57:41.695362+00:00", + "phases": { + "build": { + "elapsed_process_seconds": 44.332542745396495, + "log": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/logs/jdk22-cohere-english-v3-1M-2bit-pq-build.log", + "csv": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk22-cohere-english-v3-1M-2bit-pq/logging/jdk22-cohere-english-v3-1M-2bit-pq-build-20261029-005742Z/experiments.csv", + "rows": [ + { + "schema_version": "1", + "run_id": "jdk22-cohere-english-v3-1M-2bit-pq-build-20261029-005742Z", + "run_uuid": "f625315b-fe12-44b2-917b-5f1e9a0556f0", + "system_id": "d940322e03a3", + "dataset_name": "cohere-english-v3-1M", + "M": "32", + "efConstruction": "100", + "neighborOverflow": "1.2", + "addHierarchy": "true", + "refineFinalGraph": "true", + "feature_set": "[NVQ_VECTORS, FUSED_PQ]", + "build_compressor": "\"ProductQuantization(M=133, clusters=256, centered=false)\"", + "search_compressor": "None", + "usePruning": "false", + "topK": "10", + "overquery": "1.0", + "rerankK": "10", + "search.accuracy.recall_at_10": "0.59431", + "construction.index_build_time_s": "35.40040222", + "construction.index_quant_time_s.NVQ.compute_time_s": "0.35765844", + "construction.index_quant_time_s.PQ.compute_time_s": "2.969167141", + "construction.index_quant_time_s.PQ.encoding_time_s": "1.324768325" + } + ], + "throughput_samples": [], + "finished": "2026-10-29T00:58:26.028783+00:00" + }, + "t1": { + "elapsed_process_seconds": 39.70170094911009, + "log": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/logs/jdk22-cohere-english-v3-1M-2bit-pq-t1.log", + "csv": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk22-cohere-english-v3-1M-2bit-pq/logging/jdk22-cohere-english-v3-1M-2bit-pq-t1-20261029-005827Z/experiments.csv", + "rows": [ + { + "schema_version": "1", + "run_id": "jdk22-cohere-english-v3-1M-2bit-pq-t1-20261029-005827Z", + "run_uuid": "84406986-07ef-4a0e-824e-201f1fc77156", + "system_id": "d940322e03a3", + "dataset_name": "cohere-english-v3-1M", + "M": "32", + "efConstruction": "100", + "neighborOverflow": "1.2", + "addHierarchy": "true", + "refineFinalGraph": "true", + "feature_set": "[NVQ_VECTORS, FUSED_PQ]", + "build_compressor": "\"ProductQuantization(M=133, clusters=256, centered=false)\"", + "search_compressor": "None", + "usePruning": "false", + "topK": "10", + "overquery": "1.0", + "rerankK": "10", + "search.throughput.avg_qps": "9694.693384586766", + "search.throughput.stddev_qps": "325.22511177435325", + "search.throughput.cv_pct": "3.3546714565662965", + "search.count.avg_visited": "509.2777", + "search.accuracy.recall_at_10": "0.59431", + "construction.index_build_time_s": "", + "construction.index_quant_time_s.NVQ.compute_time_s": "", + "construction.index_quant_time_s.PQ.compute_time_s": "", + "construction.index_quant_time_s.PQ.encoding_time_s": "" + }, + { + "schema_version": "1", + "run_id": "jdk22-cohere-english-v3-1M-2bit-pq-t1-20261029-005827Z", + "run_uuid": "84406986-07ef-4a0e-824e-201f1fc77156", + "system_id": "d940322e03a3", + "dataset_name": "cohere-english-v3-1M", + "M": "32", + "efConstruction": "100", + "neighborOverflow": "1.2", + "addHierarchy": "true", + "refineFinalGraph": "true", + "feature_set": "[NVQ_VECTORS, FUSED_PQ]", + "build_compressor": "\"ProductQuantization(M=133, clusters=256, centered=false)\"", + "search_compressor": "None", + "usePruning": "false", + "topK": "10", + "overquery": "2.0", + "rerankK": "20", + "search.throughput.avg_qps": "7699.839253969693", + "search.throughput.stddev_qps": "42.40381740356734", + "search.throughput.cv_pct": "0.5507104240092522", + "search.count.avg_visited": "715.1325", + "search.accuracy.recall_at_10": "0.78292", + "construction.index_build_time_s": "", + "construction.index_quant_time_s.NVQ.compute_time_s": "", + "construction.index_quant_time_s.PQ.compute_time_s": "", + "construction.index_quant_time_s.PQ.encoding_time_s": "" + } + ], + "throughput_samples": [ + { + "sample": "Test-0", + "queries": 10000, + "seconds": 1.070627 + }, + { + "sample": "Test-1", + "queries": 10000, + "seconds": 1.024146 + }, + { + "sample": "Test-2", + "queries": 10000, + "seconds": 1.002052 + }, + { + "sample": "Test-0", + "queries": 10000, + "seconds": 1.305568 + }, + { + "sample": "Test-1", + "queries": 10000, + "seconds": 1.299381 + }, + { + "sample": "Test-2", + "queries": 10000, + "seconds": 1.291315 + } + ], + "finished": "2026-10-29T00:59:05.731799+00:00" + }, + "t8": { + "elapsed_process_seconds": 19.1846497124061, + "log": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/logs/jdk22-cohere-english-v3-1M-2bit-pq-t8.log", + "csv": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk22-cohere-english-v3-1M-2bit-pq/logging/jdk22-cohere-english-v3-1M-2bit-pq-t8-20261029-005906Z/experiments.csv", + "rows": [ + { + "schema_version": "1", + "run_id": "jdk22-cohere-english-v3-1M-2bit-pq-t8-20261029-005906Z", + "run_uuid": "86f7cde3-5919-4673-bfe0-9d411e749e8b", + "system_id": "d940322e03a3", + "dataset_name": "cohere-english-v3-1M", + "M": "32", + "efConstruction": "100", + "neighborOverflow": "1.2", + "addHierarchy": "true", + "refineFinalGraph": "true", + "feature_set": "[NVQ_VECTORS, FUSED_PQ]", + "build_compressor": "\"ProductQuantization(M=133, clusters=256, centered=false)\"", + "search_compressor": "None", + "usePruning": "false", + "topK": "10", + "overquery": "1.0", + "rerankK": "10", + "search.throughput.avg_qps": "73999.92617936898", + "search.throughput.stddev_qps": "2539.3386732272957", + "search.throughput.cv_pct": "3.431542170828892", + "search.count.avg_visited": "509.2777", + "search.accuracy.recall_at_10": "0.59431", + "construction.index_build_time_s": "", + "construction.index_quant_time_s.NVQ.compute_time_s": "", + "construction.index_quant_time_s.PQ.compute_time_s": "", + "construction.index_quant_time_s.PQ.encoding_time_s": "" + }, + { + "schema_version": "1", + "run_id": "jdk22-cohere-english-v3-1M-2bit-pq-t8-20261029-005906Z", + "run_uuid": "86f7cde3-5919-4673-bfe0-9d411e749e8b", + "system_id": "d940322e03a3", + "dataset_name": "cohere-english-v3-1M", + "M": "32", + "efConstruction": "100", + "neighborOverflow": "1.2", + "addHierarchy": "true", + "refineFinalGraph": "true", + "feature_set": "[NVQ_VECTORS, FUSED_PQ]", + "build_compressor": "\"ProductQuantization(M=133, clusters=256, centered=false)\"", + "search_compressor": "None", + "usePruning": "false", + "topK": "10", + "overquery": "2.0", + "rerankK": "20", + "search.throughput.avg_qps": "59164.921945419286", + "search.throughput.stddev_qps": "2035.060877910483", + "search.throughput.cv_pct": "3.4396409409410924", + "search.count.avg_visited": "715.1325", + "search.accuracy.recall_at_10": "0.78292", + "construction.index_build_time_s": "", + "construction.index_quant_time_s.NVQ.compute_time_s": "", + "construction.index_quant_time_s.PQ.compute_time_s": "", + "construction.index_quant_time_s.PQ.encoding_time_s": "" + } + ], + "throughput_samples": [ + { + "sample": "Test-0", + "queries": 80000, + "seconds": 1.096118 + }, + { + "sample": "Test-1", + "queries": 80000, + "seconds": 1.109183 + }, + { + "sample": "Test-2", + "queries": 80000, + "seconds": 1.04045 + }, + { + "sample": "Test-0", + "queries": 70000, + "seconds": 1.147772 + }, + { + "sample": "Test-1", + "queries": 60000, + "seconds": 1.053201 + }, + { + "sample": "Test-2", + "queries": 60000, + "seconds": 1.007762 + } + ], + "finished": "2026-10-29T00:59:24.917880+00:00" + } + }, + "index_files": [ + { + "path": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk22-cohere-english-v3-1M-2bit-pq/index_cache/graph_cohere_english_v3_1M_FUSED_PQ_NVQ_VECTORS_M32_ef100_of1.2_alpha1.2_H1_R1_ProductQuantization_M_133_clusters_256_centered_false_", + "bytes": 5486775034 + } + ], + "finished": "2026-10-29T00:59:24.918421+00:00" + }, + { + "dataset": "cohere-english-v3-1M", + "display_name": "Cohere", + "original_dimensions": "1024", + "projected_ash_dimensions": "512", + "base_vectors": "1000000", + "queries": "10000", + "ash_bits": "4", + "algorithm": "PQ", + "code_bytes": "261", + "ash_encoded_bits": "", + "pq_m": "261", + "config": "cohere-english-v3-1M-4bit-budget-pq.yml", + "jdk": "22", + "label": "jdk22-cohere-english-v3-1M-4bit-pq", + "path": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk22-cohere-english-v3-1M-4bit-pq", + "source": "b44e25b71e3d9c5c7a2f164610b0fdbd099d91fa", + "started": "2026-10-29T01:01:51.542173+00:00", + "phases": { + "build": { + "elapsed_process_seconds": 84.35468028020114, + "log": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/logs/jdk22-cohere-english-v3-1M-4bit-pq-build.log", + "csv": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk22-cohere-english-v3-1M-4bit-pq/logging/jdk22-cohere-english-v3-1M-4bit-pq-build-20261029-010151Z/experiments.csv", + "rows": [ + { + "schema_version": "1", + "run_id": "jdk22-cohere-english-v3-1M-4bit-pq-build-20261029-010151Z", + "run_uuid": "4c3a36bb-7db3-4e65-a81b-ec6494e5400b", + "system_id": "d940322e03a3", + "dataset_name": "cohere-english-v3-1M", + "M": "32", + "efConstruction": "100", + "neighborOverflow": "1.2", + "addHierarchy": "true", + "refineFinalGraph": "true", + "feature_set": "[NVQ_VECTORS, FUSED_PQ]", + "build_compressor": "\"ProductQuantization(M=261, clusters=256, centered=false)\"", + "search_compressor": "None", + "usePruning": "false", + "topK": "10", + "overquery": "1.0", + "rerankK": "10", + "search.accuracy.recall_at_10": "0.65081", + "construction.index_build_time_s": "70.711462575", + "construction.index_quant_time_s.NVQ.compute_time_s": "0.34194332", + "construction.index_quant_time_s.PQ.compute_time_s": "6.753853519", + "construction.index_quant_time_s.PQ.encoding_time_s": "2.302340857" + } + ], + "throughput_samples": [], + "finished": "2026-10-29T01:03:15.897796+00:00" + }, + "t1": { + "elapsed_process_seconds": 48.445268898271024, + "log": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/logs/jdk22-cohere-english-v3-1M-4bit-pq-t1.log", + "csv": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk22-cohere-english-v3-1M-4bit-pq/logging/jdk22-cohere-english-v3-1M-4bit-pq-t1-20261029-010317Z/experiments.csv", + "rows": [ + { + "schema_version": "1", + "run_id": "jdk22-cohere-english-v3-1M-4bit-pq-t1-20261029-010317Z", + "run_uuid": "63d21f1b-ced1-4a78-b185-5e3cbacae099", + "system_id": "d940322e03a3", + "dataset_name": "cohere-english-v3-1M", + "M": "32", + "efConstruction": "100", + "neighborOverflow": "1.2", + "addHierarchy": "true", + "refineFinalGraph": "true", + "feature_set": "[NVQ_VECTORS, FUSED_PQ]", + "build_compressor": "\"ProductQuantization(M=261, clusters=256, centered=false)\"", + "search_compressor": "None", + "usePruning": "false", + "topK": "10", + "overquery": "1.0", + "rerankK": "10", + "search.throughput.avg_qps": "7655.785166781064", + "search.throughput.stddev_qps": "175.76862112499447", + "search.throughput.cv_pct": "2.2958928091094513", + "search.count.avg_visited": "507.7788", + "search.accuracy.recall_at_10": "0.65081", + "construction.index_build_time_s": "", + "construction.index_quant_time_s.NVQ.compute_time_s": "", + "construction.index_quant_time_s.PQ.compute_time_s": "", + "construction.index_quant_time_s.PQ.encoding_time_s": "" + }, + { + "schema_version": "1", + "run_id": "jdk22-cohere-english-v3-1M-4bit-pq-t1-20261029-010317Z", + "run_uuid": "63d21f1b-ced1-4a78-b185-5e3cbacae099", + "system_id": "d940322e03a3", + "dataset_name": "cohere-english-v3-1M", + "M": "32", + "efConstruction": "100", + "neighborOverflow": "1.2", + "addHierarchy": "true", + "refineFinalGraph": "true", + "feature_set": "[NVQ_VECTORS, FUSED_PQ]", + "build_compressor": "\"ProductQuantization(M=261, clusters=256, centered=false)\"", + "search_compressor": "None", + "usePruning": "false", + "topK": "10", + "overquery": "2.0", + "rerankK": "20", + "search.throughput.avg_qps": "5826.0870091323695", + "search.throughput.stddev_qps": "7.660713163915625", + "search.throughput.cv_pct": "0.13148985162609977", + "search.count.avg_visited": "712.6111", + "search.accuracy.recall_at_10": "0.81039", + "construction.index_build_time_s": "", + "construction.index_quant_time_s.NVQ.compute_time_s": "", + "construction.index_quant_time_s.PQ.compute_time_s": "", + "construction.index_quant_time_s.PQ.encoding_time_s": "" + } + ], + "throughput_samples": [ + { + "sample": "Test-0", + "queries": 10000, + "seconds": 1.34114 + }, + { + "sample": "Test-1", + "queries": 10000, + "seconds": 1.294846 + }, + { + "sample": "Test-2", + "queries": 10000, + "seconds": 1.284013 + }, + { + "sample": "Test-0", + "queries": 10000, + "seconds": 1.718036 + }, + { + "sample": "Test-1", + "queries": 10000, + "seconds": 1.713843 + }, + { + "sample": "Test-2", + "queries": 10000, + "seconds": 1.717381 + } + ], + "finished": "2026-10-29T01:04:04.353570+00:00" + }, + "t8": { + "elapsed_process_seconds": 20.916853431612253, + "log": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/logs/jdk22-cohere-english-v3-1M-4bit-pq-t8.log", + "csv": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk22-cohere-english-v3-1M-4bit-pq/logging/jdk22-cohere-english-v3-1M-4bit-pq-t8-20261029-010404Z/experiments.csv", + "rows": [ + { + "schema_version": "1", + "run_id": "jdk22-cohere-english-v3-1M-4bit-pq-t8-20261029-010404Z", + "run_uuid": "923f84e7-6c51-45c0-b9ad-2e3093ccc6bd", + "system_id": "d940322e03a3", + "dataset_name": "cohere-english-v3-1M", + "M": "32", + "efConstruction": "100", + "neighborOverflow": "1.2", + "addHierarchy": "true", + "refineFinalGraph": "true", + "feature_set": "[NVQ_VECTORS, FUSED_PQ]", + "build_compressor": "\"ProductQuantization(M=261, clusters=256, centered=false)\"", + "search_compressor": "None", + "usePruning": "false", + "topK": "10", + "overquery": "1.0", + "rerankK": "10", + "search.throughput.avg_qps": "57544.851973715246", + "search.throughput.stddev_qps": "1423.1567643985286", + "search.throughput.cv_pct": "2.4731261191680254", + "search.count.avg_visited": "507.7788", + "search.accuracy.recall_at_10": "0.65081", + "construction.index_build_time_s": "", + "construction.index_quant_time_s.NVQ.compute_time_s": "", + "construction.index_quant_time_s.PQ.compute_time_s": "", + "construction.index_quant_time_s.PQ.encoding_time_s": "" + }, + { + "schema_version": "1", + "run_id": "jdk22-cohere-english-v3-1M-4bit-pq-t8-20261029-010404Z", + "run_uuid": "923f84e7-6c51-45c0-b9ad-2e3093ccc6bd", + "system_id": "d940322e03a3", + "dataset_name": "cohere-english-v3-1M", + "M": "32", + "efConstruction": "100", + "neighborOverflow": "1.2", + "addHierarchy": "true", + "refineFinalGraph": "true", + "feature_set": "[NVQ_VECTORS, FUSED_PQ]", + "build_compressor": "\"ProductQuantization(M=261, clusters=256, centered=false)\"", + "search_compressor": "None", + "usePruning": "false", + "topK": "10", + "overquery": "2.0", + "rerankK": "20", + "search.throughput.avg_qps": "43365.895255088246", + "search.throughput.stddev_qps": "343.3827570210426", + "search.throughput.cv_pct": "0.7918267454209942", + "search.count.avg_visited": "712.6111", + "search.accuracy.recall_at_10": "0.81039", + "construction.index_build_time_s": "", + "construction.index_quant_time_s.NVQ.compute_time_s": "", + "construction.index_quant_time_s.PQ.compute_time_s": "", + "construction.index_quant_time_s.PQ.encoding_time_s": "" + } + ], + "throughput_samples": [ + { + "sample": "Test-0", + "queries": 60000, + "seconds": 1.068123 + }, + { + "sample": "Test-1", + "queries": 60000, + "seconds": 1.016699 + }, + { + "sample": "Test-2", + "queries": 60000, + "seconds": 1.044446 + }, + { + "sample": "Test-0", + "queries": 50000, + "seconds": 1.1526 + }, + { + "sample": "Test-1", + "queries": 50000, + "seconds": 1.144115 + }, + { + "sample": "Test-2", + "queries": 50000, + "seconds": 1.162369 + } + ], + "finished": "2026-10-29T01:04:25.272033+00:00" + } + }, + "index_files": [ + { + "path": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk22-cohere-english-v3-1M-4bit-pq/index_cache/graph_cohere_english_v3_1M_FUSED_PQ_NVQ_VECTORS_M32_ef100_of1.2_alpha1.2_H1_R1_ProductQuantization_M_261_clusters_256_centered_false_", + "bytes": 9586777730 + } + ], + "finished": "2026-10-29T01:04:25.272647+00:00" + }, + { + "dataset": "ada002-1M", + "display_name": "Ada002", + "original_dimensions": "1536", + "projected_ash_dimensions": "768", + "base_vectors": "982790", + "queries": "10000", + "ash_bits": "2", + "algorithm": "PQ", + "code_bytes": "197", + "ash_encoded_bits": "", + "pq_m": "197", + "config": "ada002-1M-2bit-budget-pq.yml", + "jdk": "22", + "label": "jdk22-ada002-1M-2bit-pq", + "path": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk22-ada002-1M-2bit-pq", + "source": "b44e25b71e3d9c5c7a2f164610b0fdbd099d91fa", + "started": "2026-10-29T01:09:05.078508+00:00", + "phases": { + "build": { + "elapsed_process_seconds": 60.269731011241674, + "log": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/logs/jdk22-ada002-1M-2bit-pq-build.log", + "csv": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk22-ada002-1M-2bit-pq/logging/jdk22-ada002-1M-2bit-pq-build-20261029-010905Z/experiments.csv", + "rows": [ + { + "schema_version": "1", + "run_id": "jdk22-ada002-1M-2bit-pq-build-20261029-010905Z", + "run_uuid": "ebbe6ed1-d534-4503-8dd4-b80daf07636d", + "system_id": "d940322e03a3", + "dataset_name": "ada002-1M", + "M": "32", + "efConstruction": "100", + "neighborOverflow": "1.2", + "addHierarchy": "true", + "refineFinalGraph": "true", + "feature_set": "[NVQ_VECTORS, FUSED_PQ]", + "build_compressor": "\"ProductQuantization(M=197, clusters=256, centered=false)\"", + "search_compressor": "None", + "usePruning": "false", + "topK": "10", + "overquery": "1.0", + "rerankK": "10", + "search.accuracy.recall_at_10": "0.54986", + "construction.index_build_time_s": "48.118085742", + "construction.index_quant_time_s.NVQ.compute_time_s": "0.533923396", + "construction.index_quant_time_s.PQ.compute_time_s": "3.932531018", + "construction.index_quant_time_s.PQ.encoding_time_s": "2.19930205" + } + ], + "throughput_samples": [], + "finished": "2026-10-29T01:10:05.349154+00:00" + }, + "t1": { + "elapsed_process_seconds": 51.73143943399191, + "log": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/logs/jdk22-ada002-1M-2bit-pq-t1.log", + "csv": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk22-ada002-1M-2bit-pq/logging/jdk22-ada002-1M-2bit-pq-t1-20261029-011006Z/experiments.csv", + "rows": [ + { + "schema_version": "1", + "run_id": "jdk22-ada002-1M-2bit-pq-t1-20261029-011006Z", + "run_uuid": "37208c37-a5a6-4f35-ac91-9e3eccfc83f6", + "system_id": "d940322e03a3", + "dataset_name": "ada002-1M", + "M": "32", + "efConstruction": "100", + "neighborOverflow": "1.2", + "addHierarchy": "true", + "refineFinalGraph": "true", + "feature_set": "[NVQ_VECTORS, FUSED_PQ]", + "build_compressor": "\"ProductQuantization(M=197, clusters=256, centered=false)\"", + "search_compressor": "None", + "usePruning": "false", + "topK": "10", + "overquery": "1.0", + "rerankK": "10", + "search.throughput.avg_qps": "7064.577838319312", + "search.throughput.stddev_qps": "345.1160615646333", + "search.throughput.cv_pct": "4.885161852031312", + "search.count.avg_visited": "458.0818", + "search.accuracy.recall_at_10": "0.54986", + "construction.index_build_time_s": "", + "construction.index_quant_time_s.NVQ.compute_time_s": "", + "construction.index_quant_time_s.PQ.compute_time_s": "", + "construction.index_quant_time_s.PQ.encoding_time_s": "" + }, + { + "schema_version": "1", + "run_id": "jdk22-ada002-1M-2bit-pq-t1-20261029-011006Z", + "run_uuid": "37208c37-a5a6-4f35-ac91-9e3eccfc83f6", + "system_id": "d940322e03a3", + "dataset_name": "ada002-1M", + "M": "32", + "efConstruction": "100", + "neighborOverflow": "1.2", + "addHierarchy": "true", + "refineFinalGraph": "true", + "feature_set": "[NVQ_VECTORS, FUSED_PQ]", + "build_compressor": "\"ProductQuantization(M=197, clusters=256, centered=false)\"", + "search_compressor": "None", + "usePruning": "false", + "topK": "10", + "overquery": "2.0", + "rerankK": "20", + "search.throughput.avg_qps": "5685.492109827316", + "search.throughput.stddev_qps": "228.87502457014529", + "search.throughput.cv_pct": "4.025597435524308", + "search.count.avg_visited": "624.9491", + "search.accuracy.recall_at_10": "0.75347", + "construction.index_build_time_s": "", + "construction.index_quant_time_s.NVQ.compute_time_s": "", + "construction.index_quant_time_s.PQ.compute_time_s": "", + "construction.index_quant_time_s.PQ.encoding_time_s": "" + } + ], + "throughput_samples": [ + { + "sample": "Test-0", + "queries": 10000, + "seconds": 1.499813 + }, + { + "sample": "Test-1", + "queries": 10000, + "seconds": 1.382374 + }, + { + "sample": "Test-2", + "queries": 10000, + "seconds": 1.371309 + }, + { + "sample": "Test-0", + "queries": 10000, + "seconds": 1.84391 + }, + { + "sample": "Test-1", + "queries": 10000, + "seconds": 1.727664 + }, + { + "sample": "Test-2", + "queries": 10000, + "seconds": 1.710848 + } + ], + "finished": "2026-10-29T01:10:57.081969+00:00" + }, + "t8": { + "elapsed_process_seconds": 22.585593824274838, + "log": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/logs/jdk22-ada002-1M-2bit-pq-t8.log", + "csv": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk22-ada002-1M-2bit-pq/logging/jdk22-ada002-1M-2bit-pq-t8-20261029-011057Z/experiments.csv", + "rows": [ + { + "schema_version": "1", + "run_id": "jdk22-ada002-1M-2bit-pq-t8-20261029-011057Z", + "run_uuid": "d0fa1a11-20ec-4011-9ac5-cb17afe12b9e", + "system_id": "d940322e03a3", + "dataset_name": "ada002-1M", + "M": "32", + "efConstruction": "100", + "neighborOverflow": "1.2", + "addHierarchy": "true", + "refineFinalGraph": "true", + "feature_set": "[NVQ_VECTORS, FUSED_PQ]", + "build_compressor": "\"ProductQuantization(M=197, clusters=256, centered=false)\"", + "search_compressor": "None", + "usePruning": "false", + "topK": "10", + "overquery": "1.0", + "rerankK": "10", + "search.throughput.avg_qps": "53847.213088554396", + "search.throughput.stddev_qps": "938.0679515199026", + "search.throughput.cv_pct": "1.7420919258666252", + "search.count.avg_visited": "458.0818", + "search.accuracy.recall_at_10": "0.54986", + "construction.index_build_time_s": "", + "construction.index_quant_time_s.NVQ.compute_time_s": "", + "construction.index_quant_time_s.PQ.compute_time_s": "", + "construction.index_quant_time_s.PQ.encoding_time_s": "" + }, + { + "schema_version": "1", + "run_id": "jdk22-ada002-1M-2bit-pq-t8-20261029-011057Z", + "run_uuid": "d0fa1a11-20ec-4011-9ac5-cb17afe12b9e", + "system_id": "d940322e03a3", + "dataset_name": "ada002-1M", + "M": "32", + "efConstruction": "100", + "neighborOverflow": "1.2", + "addHierarchy": "true", + "refineFinalGraph": "true", + "feature_set": "[NVQ_VECTORS, FUSED_PQ]", + "build_compressor": "\"ProductQuantization(M=197, clusters=256, centered=false)\"", + "search_compressor": "None", + "usePruning": "false", + "topK": "10", + "overquery": "2.0", + "rerankK": "20", + "search.throughput.avg_qps": "45513.06620677013", + "search.throughput.stddev_qps": "369.25509208384045", + "search.throughput.cv_pct": "0.8113166676274456", + "search.count.avg_visited": "624.9491", + "search.accuracy.recall_at_10": "0.75347", + "construction.index_build_time_s": "", + "construction.index_quant_time_s.NVQ.compute_time_s": "", + "construction.index_quant_time_s.PQ.compute_time_s": "", + "construction.index_quant_time_s.PQ.encoding_time_s": "" + } + ], + "throughput_samples": [ + { + "sample": "Test-0", + "queries": 60000, + "seconds": 1.129328 + }, + { + "sample": "Test-1", + "queries": 60000, + "seconds": 1.092727 + }, + { + "sample": "Test-2", + "queries": 60000, + "seconds": 1.121407 + }, + { + "sample": "Test-0", + "queries": 50000, + "seconds": 1.088659 + }, + { + "sample": "Test-1", + "queries": 50000, + "seconds": 1.101554 + }, + { + "sample": "Test-2", + "queries": 50000, + "seconds": 1.105687 + } + ], + "finished": "2026-10-29T01:11:19.669032+00:00" + } + }, + "index_files": [ + { + "path": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk22-ada002-1M-2bit-pq/index_cache/graph_ada002_1M_FUSED_PQ_NVQ_VECTORS_M32_ef100_of1.2_alpha1.2_H1_R1_ProductQuantization_M_197_clusters_256_centered_false_", + "bytes": 7911344189 + } + ], + "finished": "2026-10-29T01:11:19.669627+00:00" + }, + { + "dataset": "ada002-1M", + "display_name": "Ada002", + "original_dimensions": "1536", + "projected_ash_dimensions": "768", + "base_vectors": "982790", + "queries": "10000", + "ash_bits": "4", + "algorithm": "PQ", + "code_bytes": "389", + "ash_encoded_bits": "", + "pq_m": "389", + "config": "ada002-1M-4bit-budget-pq.yml", + "jdk": "22", + "label": "jdk22-ada002-1M-4bit-pq", + "path": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk22-ada002-1M-4bit-pq", + "source": "b44e25b71e3d9c5c7a2f164610b0fdbd099d91fa", + "started": "2026-10-29T01:18:00.693272+00:00", + "phases": { + "build": { + "elapsed_process_seconds": 119.41658374574035, + "log": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/logs/jdk22-ada002-1M-4bit-pq-build.log", + "csv": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk22-ada002-1M-4bit-pq/logging/jdk22-ada002-1M-4bit-pq-build-20261029-011801Z/experiments.csv", + "rows": [ + { + "schema_version": "1", + "run_id": "jdk22-ada002-1M-4bit-pq-build-20261029-011801Z", + "run_uuid": "683c6d2a-adbd-4c97-b2a7-f186f0fdfe9d", + "system_id": "d940322e03a3", + "dataset_name": "ada002-1M", + "M": "32", + "efConstruction": "100", + "neighborOverflow": "1.2", + "addHierarchy": "true", + "refineFinalGraph": "true", + "feature_set": "[NVQ_VECTORS, FUSED_PQ]", + "build_compressor": "\"ProductQuantization(M=389, clusters=256, centered=false)\"", + "search_compressor": "None", + "usePruning": "false", + "topK": "10", + "overquery": "1.0", + "rerankK": "10", + "search.accuracy.recall_at_10": "0.73555", + "construction.index_build_time_s": "100.923429775", + "construction.index_quant_time_s.NVQ.compute_time_s": "0.475411504", + "construction.index_quant_time_s.PQ.compute_time_s": "8.623554557", + "construction.index_quant_time_s.PQ.encoding_time_s": "3.614520167" + } + ], + "throughput_samples": [], + "finished": "2026-10-29T01:20:00.111597+00:00" + }, + "t1": { + "elapsed_process_seconds": 65.49995384458452, + "log": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/logs/jdk22-ada002-1M-4bit-pq-t1.log", + "csv": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk22-ada002-1M-4bit-pq/logging/jdk22-ada002-1M-4bit-pq-t1-20261029-012001Z/experiments.csv", + "rows": [ + { + "schema_version": "1", + "run_id": "jdk22-ada002-1M-4bit-pq-t1-20261029-012001Z", + "run_uuid": "65040ead-21a1-4da9-b492-21b8f2525067", + "system_id": "d940322e03a3", + "dataset_name": "ada002-1M", + "M": "32", + "efConstruction": "100", + "neighborOverflow": "1.2", + "addHierarchy": "true", + "refineFinalGraph": "true", + "feature_set": "[NVQ_VECTORS, FUSED_PQ]", + "build_compressor": "\"ProductQuantization(M=389, clusters=256, centered=false)\"", + "search_compressor": "None", + "usePruning": "false", + "topK": "10", + "overquery": "1.0", + "rerankK": "10", + "search.throughput.avg_qps": "5341.342726973948", + "search.throughput.stddev_qps": "197.28391094169163", + "search.throughput.cv_pct": "3.6935265349179285", + "search.count.avg_visited": "477.3403", + "search.accuracy.recall_at_10": "0.73555", + "construction.index_build_time_s": "", + "construction.index_quant_time_s.NVQ.compute_time_s": "", + "construction.index_quant_time_s.PQ.compute_time_s": "", + "construction.index_quant_time_s.PQ.encoding_time_s": "" + }, + { + "schema_version": "1", + "run_id": "jdk22-ada002-1M-4bit-pq-t1-20261029-012001Z", + "run_uuid": "65040ead-21a1-4da9-b492-21b8f2525067", + "system_id": "d940322e03a3", + "dataset_name": "ada002-1M", + "M": "32", + "efConstruction": "100", + "neighborOverflow": "1.2", + "addHierarchy": "true", + "refineFinalGraph": "true", + "feature_set": "[NVQ_VECTORS, FUSED_PQ]", + "build_compressor": "\"ProductQuantization(M=389, clusters=256, centered=false)\"", + "search_compressor": "None", + "usePruning": "false", + "topK": "10", + "overquery": "2.0", + "rerankK": "20", + "search.throughput.avg_qps": "4200.0630432290845", + "search.throughput.stddev_qps": "27.34394359857975", + "search.throughput.cv_pct": "0.6510365039082182", + "search.count.avg_visited": "659.2552", + "search.accuracy.recall_at_10": "0.90049", + "construction.index_build_time_s": "", + "construction.index_quant_time_s.NVQ.compute_time_s": "", + "construction.index_quant_time_s.PQ.compute_time_s": "", + "construction.index_quant_time_s.PQ.encoding_time_s": "" + } + ], + "throughput_samples": [ + { + "sample": "Test-0", + "queries": 10000, + "seconds": 1.952556 + }, + { + "sample": "Test-1", + "queries": 10000, + "seconds": 1.851992 + }, + { + "sample": "Test-2", + "queries": 10000, + "seconds": 1.817209 + }, + { + "sample": "Test-0", + "queries": 10000, + "seconds": 2.394102 + }, + { + "sample": "Test-1", + "queries": 10000, + "seconds": 2.384918 + }, + { + "sample": "Test-2", + "queries": 10000, + "seconds": 2.363932 + } + ], + "finished": "2026-10-29T01:21:05.612921+00:00" + }, + "t8": { + "elapsed_process_seconds": 25.59007947705686, + "log": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/logs/jdk22-ada002-1M-4bit-pq-t8.log", + "csv": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk22-ada002-1M-4bit-pq/logging/jdk22-ada002-1M-4bit-pq-t8-20261029-012106Z/experiments.csv", + "rows": [ + { + "schema_version": "1", + "run_id": "jdk22-ada002-1M-4bit-pq-t8-20261029-012106Z", + "run_uuid": "50d32cd8-997a-4b71-ba10-645f69aa19b0", + "system_id": "d940322e03a3", + "dataset_name": "ada002-1M", + "M": "32", + "efConstruction": "100", + "neighborOverflow": "1.2", + "addHierarchy": "true", + "refineFinalGraph": "true", + "feature_set": "[NVQ_VECTORS, FUSED_PQ]", + "build_compressor": "\"ProductQuantization(M=389, clusters=256, centered=false)\"", + "search_compressor": "None", + "usePruning": "false", + "topK": "10", + "overquery": "1.0", + "rerankK": "10", + "search.throughput.avg_qps": "41038.5274305757", + "search.throughput.stddev_qps": "1647.1171751077788", + "search.throughput.cv_pct": "4.013587421951686", + "search.count.avg_visited": "477.3403", + "search.accuracy.recall_at_10": "0.73555", + "construction.index_build_time_s": "", + "construction.index_quant_time_s.NVQ.compute_time_s": "", + "construction.index_quant_time_s.PQ.compute_time_s": "", + "construction.index_quant_time_s.PQ.encoding_time_s": "" + }, + { + "schema_version": "1", + "run_id": "jdk22-ada002-1M-4bit-pq-t8-20261029-012106Z", + "run_uuid": "50d32cd8-997a-4b71-ba10-645f69aa19b0", + "system_id": "d940322e03a3", + "dataset_name": "ada002-1M", + "M": "32", + "efConstruction": "100", + "neighborOverflow": "1.2", + "addHierarchy": "true", + "refineFinalGraph": "true", + "feature_set": "[NVQ_VECTORS, FUSED_PQ]", + "build_compressor": "\"ProductQuantization(M=389, clusters=256, centered=false)\"", + "search_compressor": "None", + "usePruning": "false", + "topK": "10", + "overquery": "2.0", + "rerankK": "20", + "search.throughput.avg_qps": "30912.637087924762", + "search.throughput.stddev_qps": "630.078503069555", + "search.throughput.cv_pct": "2.0382554269874285", + "search.count.avg_visited": "659.2552", + "search.accuracy.recall_at_10": "0.90049", + "construction.index_build_time_s": "", + "construction.index_quant_time_s.NVQ.compute_time_s": "", + "construction.index_quant_time_s.PQ.compute_time_s": "", + "construction.index_quant_time_s.PQ.encoding_time_s": "" + } + ], + "throughput_samples": [ + { + "sample": "Test-0", + "queries": 40000, + "seconds": 1.021245 + }, + { + "sample": "Test-1", + "queries": 50000, + "seconds": 1.199723 + }, + { + "sample": "Test-2", + "queries": 50000, + "seconds": 1.182833 + }, + { + "sample": "Test-0", + "queries": 40000, + "seconds": 1.287881 + }, + { + "sample": "Test-1", + "queries": 40000, + "seconds": 1.323524 + }, + { + "sample": "Test-2", + "queries": 40000, + "seconds": 1.271585 + } + ], + "finished": "2026-10-29T01:21:31.204526+00:00" + } + }, + "index_files": [ + { + "path": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk22-ada002-1M-4bit-pq/index_cache/graph_ada002_1M_FUSED_PQ_NVQ_VECTORS_M32_ef100_of1.2_alpha1.2_H1_R1_ProductQuantization_M_389_clusters_256_centered_false_", + "bytes": 13955502981 + } + ], + "finished": "2026-10-29T01:21:31.205098+00:00" + }, + { + "dataset": "openai-3072-1m", + "display_name": "OpenAI3072", + "original_dimensions": "3072", + "projected_ash_dimensions": "1536", + "base_vectors": "999000", + "queries": "1000", + "ash_bits": "2", + "algorithm": "PQ", + "code_bytes": "389", + "ash_encoded_bits": "", + "pq_m": "389", + "config": "openai-3072-1m-2bit-budget-pq.yml", + "jdk": "22", + "label": "jdk22-openai-3072-1m-2bit-pq", + "path": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk22-openai-3072-1m-2bit-pq", + "source": "b44e25b71e3d9c5c7a2f164610b0fdbd099d91fa", + "started": "2026-10-29T01:26:07.149708+00:00", + "phases": { + "build": { + "elapsed_process_seconds": 113.07548744231462, + "log": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/logs/jdk22-openai-3072-1m-2bit-pq-build.log", + "csv": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk22-openai-3072-1m-2bit-pq/logging/jdk22-openai-3072-1m-2bit-pq-build-20261029-012607Z/experiments.csv", + "rows": [ + { + "schema_version": "1", + "run_id": "jdk22-openai-3072-1m-2bit-pq-build-20261029-012607Z", + "run_uuid": "9a4baf3c-1009-458a-8dd7-0a57154e4c1b", + "system_id": "d940322e03a3", + "dataset_name": "openai-3072-1m", + "M": "32", + "efConstruction": "100", + "neighborOverflow": "1.2", + "addHierarchy": "true", + "refineFinalGraph": "true", + "feature_set": "[NVQ_VECTORS, FUSED_PQ]", + "build_compressor": "\"ProductQuantization(M=389, clusters=256, centered=false)\"", + "search_compressor": "None", + "usePruning": "false", + "topK": "10", + "overquery": "1.0", + "rerankK": "10", + "search.accuracy.recall_at_10": "0.6437", + "construction.index_build_time_s": "90.502056269", + "construction.index_quant_time_s.NVQ.compute_time_s": "1.177500504", + "construction.index_quant_time_s.PQ.compute_time_s": "6.505794804", + "construction.index_quant_time_s.PQ.encoding_time_s": "5.508848828" + } + ], + "throughput_samples": [], + "finished": "2026-10-29T01:28:00.226162+00:00" + }, + "t1": { + "elapsed_process_seconds": 34.11608032975346, + "log": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/logs/jdk22-openai-3072-1m-2bit-pq-t1.log", + "csv": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk22-openai-3072-1m-2bit-pq/logging/jdk22-openai-3072-1m-2bit-pq-t1-20261029-012801Z/experiments.csv", + "rows": [ + { + "schema_version": "1", + "run_id": "jdk22-openai-3072-1m-2bit-pq-t1-20261029-012801Z", + "run_uuid": "3abbab77-6dc4-45ef-96fd-6c3dfaccd84a", + "system_id": "d940322e03a3", + "dataset_name": "openai-3072-1m", + "M": "32", + "efConstruction": "100", + "neighborOverflow": "1.2", + "addHierarchy": "true", + "refineFinalGraph": "true", + "feature_set": "[NVQ_VECTORS, FUSED_PQ]", + "build_compressor": "\"ProductQuantization(M=389, clusters=256, centered=false)\"", + "search_compressor": "None", + "usePruning": "false", + "topK": "10", + "overquery": "1.0", + "rerankK": "10", + "search.throughput.avg_qps": "3329.077370787888", + "search.throughput.stddev_qps": "410.3285959728882", + "search.throughput.cv_pct": "12.325595060464947", + "search.count.avg_visited": "471.041", + "search.accuracy.recall_at_10": "0.6437", + "construction.index_build_time_s": "", + "construction.index_quant_time_s.NVQ.compute_time_s": "", + "construction.index_quant_time_s.PQ.compute_time_s": "", + "construction.index_quant_time_s.PQ.encoding_time_s": "" + }, + { + "schema_version": "1", + "run_id": "jdk22-openai-3072-1m-2bit-pq-t1-20261029-012801Z", + "run_uuid": "3abbab77-6dc4-45ef-96fd-6c3dfaccd84a", + "system_id": "d940322e03a3", + "dataset_name": "openai-3072-1m", + "M": "32", + "efConstruction": "100", + "neighborOverflow": "1.2", + "addHierarchy": "true", + "refineFinalGraph": "true", + "feature_set": "[NVQ_VECTORS, FUSED_PQ]", + "build_compressor": "\"ProductQuantization(M=389, clusters=256, centered=false)\"", + "search_compressor": "None", + "usePruning": "false", + "topK": "10", + "overquery": "2.0", + "rerankK": "20", + "search.throughput.avg_qps": "2953.6549674392622", + "search.throughput.stddev_qps": "20.699428291440878", + "search.throughput.cv_pct": "0.7008072547277489", + "search.count.avg_visited": "653.509", + "search.accuracy.recall_at_10": "0.7985", + "construction.index_build_time_s": "", + "construction.index_quant_time_s.NVQ.compute_time_s": "", + "construction.index_quant_time_s.PQ.compute_time_s": "", + "construction.index_quant_time_s.PQ.encoding_time_s": "" + } + ], + "throughput_samples": [ + { + "sample": "Test-0", + "queries": 3000, + "seconds": 1.045207 + }, + { + "sample": "Test-1", + "queries": 4000, + "seconds": 1.157358 + }, + { + "sample": "Test-2", + "queries": 4000, + "seconds": 1.092645 + }, + { + "sample": "Test-0", + "queries": 3000, + "seconds": 1.023969 + }, + { + "sample": "Test-1", + "queries": 3000, + "seconds": 1.011295 + }, + { + "sample": "Test-2", + "queries": 3000, + "seconds": 1.011909 + } + ], + "finished": "2026-10-29T01:28:34.343565+00:00" + }, + "t8": { + "elapsed_process_seconds": 20.363484519533813, + "log": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/logs/jdk22-openai-3072-1m-2bit-pq-t8.log", + "csv": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk22-openai-3072-1m-2bit-pq/logging/jdk22-openai-3072-1m-2bit-pq-t8-20261029-012834Z/experiments.csv", + "rows": [ + { + "schema_version": "1", + "run_id": "jdk22-openai-3072-1m-2bit-pq-t8-20261029-012834Z", + "run_uuid": "cd053741-ba71-4eea-99b6-537ca2929843", + "system_id": "d940322e03a3", + "dataset_name": "openai-3072-1m", + "M": "32", + "efConstruction": "100", + "neighborOverflow": "1.2", + "addHierarchy": "true", + "refineFinalGraph": "true", + "feature_set": "[NVQ_VECTORS, FUSED_PQ]", + "build_compressor": "\"ProductQuantization(M=389, clusters=256, centered=false)\"", + "search_compressor": "None", + "usePruning": "false", + "topK": "10", + "overquery": "1.0", + "rerankK": "10", + "search.throughput.avg_qps": "27543.90063678985", + "search.throughput.stddev_qps": "1082.175435232485", + "search.throughput.cv_pct": "3.928911338675991", + "search.count.avg_visited": "471.041", + "search.accuracy.recall_at_10": "0.6437", + "construction.index_build_time_s": "", + "construction.index_quant_time_s.NVQ.compute_time_s": "", + "construction.index_quant_time_s.PQ.compute_time_s": "", + "construction.index_quant_time_s.PQ.encoding_time_s": "" + }, + { + "schema_version": "1", + "run_id": "jdk22-openai-3072-1m-2bit-pq-t8-20261029-012834Z", + "run_uuid": "cd053741-ba71-4eea-99b6-537ca2929843", + "system_id": "d940322e03a3", + "dataset_name": "openai-3072-1m", + "M": "32", + "efConstruction": "100", + "neighborOverflow": "1.2", + "addHierarchy": "true", + "refineFinalGraph": "true", + "feature_set": "[NVQ_VECTORS, FUSED_PQ]", + "build_compressor": "\"ProductQuantization(M=389, clusters=256, centered=false)\"", + "search_compressor": "None", + "usePruning": "false", + "topK": "10", + "overquery": "2.0", + "rerankK": "20", + "search.throughput.avg_qps": "22784.923597112218", + "search.throughput.stddev_qps": "198.6192438432833", + "search.throughput.cv_pct": "0.8717134512070802", + "search.count.avg_visited": "653.509", + "search.accuracy.recall_at_10": "0.7985", + "construction.index_build_time_s": "", + "construction.index_quant_time_s.NVQ.compute_time_s": "", + "construction.index_quant_time_s.PQ.compute_time_s": "", + "construction.index_quant_time_s.PQ.encoding_time_s": "" + } + ], + "throughput_samples": [ + { + "sample": "Test-0", + "queries": 27000, + "seconds": 1.026795 + }, + { + "sample": "Test-1", + "queries": 29000, + "seconds": 1.027868 + }, + { + "sample": "Test-2", + "queries": 29000, + "seconds": 1.031202 + }, + { + "sample": "Test-0", + "queries": 23000, + "seconds": 1.01969 + }, + { + "sample": "Test-1", + "queries": 23000, + "seconds": 1.00394 + }, + { + "sample": "Test-2", + "queries": 23000, + "seconds": 1.004843 + } + ], + "finished": "2026-10-29T01:28:54.708594+00:00" + } + }, + "index_files": [ + { + "path": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk22-openai-3072-1m-2bit-pq/index_cache/graph_openai_3072_1m_FUSED_PQ_NVQ_VECTORS_M32_ef100_of1.2_alpha1.2_H1_R1_ProductQuantization_M_389_clusters_256_centered_false_", + "bytes": 15723261549 + } + ], + "finished": "2026-10-29T01:28:54.709769+00:00" + }, + { + "dataset": "openai-3072-1m", + "display_name": "OpenAI3072", + "original_dimensions": "3072", + "projected_ash_dimensions": "1536", + "base_vectors": "999000", + "queries": "1000", + "ash_bits": "4", + "algorithm": "PQ", + "code_bytes": "773", + "ash_encoded_bits": "", + "pq_m": "773", + "config": "openai-3072-1m-4bit-budget-pq.yml", + "jdk": "22", + "label": "jdk22-openai-3072-1m-4bit-pq", + "path": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk22-openai-3072-1m-4bit-pq", + "source": "b44e25b71e3d9c5c7a2f164610b0fdbd099d91fa", + "started": "2026-10-29T01:33:40.791382+00:00", + "phases": { + "build": { + "elapsed_process_seconds": 295.41825035214424, + "log": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/logs/jdk22-openai-3072-1m-4bit-pq-build.log", + "csv": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk22-openai-3072-1m-4bit-pq/logging/jdk22-openai-3072-1m-4bit-pq-build-20261029-013341Z/experiments.csv", + "rows": [ + { + "schema_version": "1", + "run_id": "jdk22-openai-3072-1m-4bit-pq-build-20261029-013341Z", + "run_uuid": "40be9fef-948d-4db1-9c47-d49c990e4318", + "system_id": "d940322e03a3", + "dataset_name": "openai-3072-1m", + "M": "32", + "efConstruction": "100", + "neighborOverflow": "1.2", + "addHierarchy": "true", + "refineFinalGraph": "true", + "feature_set": "[NVQ_VECTORS, FUSED_PQ]", + "build_compressor": "\"ProductQuantization(M=773, clusters=256, centered=false)\"", + "search_compressor": "None", + "usePruning": "false", + "topK": "10", + "overquery": "1.0", + "rerankK": "10", + "search.accuracy.recall_at_10": "0.7691", + "construction.index_build_time_s": "257.818535867", + "construction.index_quant_time_s.NVQ.compute_time_s": "1.012420177", + "construction.index_quant_time_s.PQ.compute_time_s": "19.649721716", + "construction.index_quant_time_s.PQ.encoding_time_s": "7.64310692" + } + ], + "throughput_samples": [], + "finished": "2026-10-29T01:38:36.211693+00:00" + }, + "t1": { + "elapsed_process_seconds": 37.957580637186766, + "log": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/logs/jdk22-openai-3072-1m-4bit-pq-t1.log", + "csv": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk22-openai-3072-1m-4bit-pq/logging/jdk22-openai-3072-1m-4bit-pq-t1-20261029-013837Z/experiments.csv", + "rows": [ + { + "schema_version": "1", + "run_id": "jdk22-openai-3072-1m-4bit-pq-t1-20261029-013837Z", + "run_uuid": "7a3b16f1-54cf-4b10-80b0-3f090c3a84fd", + "system_id": "d940322e03a3", + "dataset_name": "openai-3072-1m", + "M": "32", + "efConstruction": "100", + "neighborOverflow": "1.2", + "addHierarchy": "true", + "refineFinalGraph": "true", + "feature_set": "[NVQ_VECTORS, FUSED_PQ]", + "build_compressor": "\"ProductQuantization(M=773, clusters=256, centered=false)\"", + "search_compressor": "None", + "usePruning": "false", + "topK": "10", + "overquery": "1.0", + "rerankK": "10", + "search.throughput.avg_qps": "2143.912280952585", + "search.throughput.stddev_qps": "170.47402337973324", + "search.throughput.cv_pct": "7.951539104201971", + "search.count.avg_visited": "500.603", + "search.accuracy.recall_at_10": "0.7691", + "construction.index_build_time_s": "", + "construction.index_quant_time_s.NVQ.compute_time_s": "", + "construction.index_quant_time_s.PQ.compute_time_s": "", + "construction.index_quant_time_s.PQ.encoding_time_s": "" + }, + { + "schema_version": "1", + "run_id": "jdk22-openai-3072-1m-4bit-pq-t1-20261029-013837Z", + "run_uuid": "7a3b16f1-54cf-4b10-80b0-3f090c3a84fd", + "system_id": "d940322e03a3", + "dataset_name": "openai-3072-1m", + "M": "32", + "efConstruction": "100", + "neighborOverflow": "1.2", + "addHierarchy": "true", + "refineFinalGraph": "true", + "feature_set": "[NVQ_VECTORS, FUSED_PQ]", + "build_compressor": "\"ProductQuantization(M=773, clusters=256, centered=false)\"", + "search_compressor": "None", + "usePruning": "false", + "topK": "10", + "overquery": "2.0", + "rerankK": "20", + "search.throughput.avg_qps": "1871.3707850777212", + "search.throughput.stddev_qps": "16.099488501314273", + "search.throughput.cv_pct": "0.8603045761797353", + "search.count.avg_visited": "704.466", + "search.accuracy.recall_at_10": "0.877", + "construction.index_build_time_s": "", + "construction.index_quant_time_s.NVQ.compute_time_s": "", + "construction.index_quant_time_s.PQ.compute_time_s": "", + "construction.index_quant_time_s.PQ.encoding_time_s": "" + } + ], + "throughput_samples": [ + { + "sample": "Test-0", + "queries": 2000, + "seconds": 1.004304 + }, + { + "sample": "Test-1", + "queries": 3000, + "seconds": 1.420222 + }, + { + "sample": "Test-2", + "queries": 3000, + "seconds": 1.28868 + }, + { + "sample": "Test-0", + "queries": 2000, + "seconds": 1.079459 + }, + { + "sample": "Test-1", + "queries": 2000, + "seconds": 1.06346 + }, + { + "sample": "Test-2", + "queries": 2000, + "seconds": 1.063446 + } + ], + "finished": "2026-10-29T01:39:14.171314+00:00" + }, + "t8": { + "elapsed_process_seconds": 21.19069182034582, + "log": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/logs/jdk22-openai-3072-1m-4bit-pq-t8.log", + "csv": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk22-openai-3072-1m-4bit-pq/logging/jdk22-openai-3072-1m-4bit-pq-t8-20261029-013914Z/experiments.csv", + "rows": [ + { + "schema_version": "1", + "run_id": "jdk22-openai-3072-1m-4bit-pq-t8-20261029-013914Z", + "run_uuid": "b58dc64c-c96e-4b6f-ad84-ed66e5cdcdb4", + "system_id": "d940322e03a3", + "dataset_name": "openai-3072-1m", + "M": "32", + "efConstruction": "100", + "neighborOverflow": "1.2", + "addHierarchy": "true", + "refineFinalGraph": "true", + "feature_set": "[NVQ_VECTORS, FUSED_PQ]", + "build_compressor": "\"ProductQuantization(M=773, clusters=256, centered=false)\"", + "search_compressor": "None", + "usePruning": "false", + "topK": "10", + "overquery": "1.0", + "rerankK": "10", + "search.throughput.avg_qps": "18466.799971430668", + "search.throughput.stddev_qps": "859.2042061471868", + "search.throughput.cv_pct": "4.652696771917339", + "search.count.avg_visited": "500.603", + "search.accuracy.recall_at_10": "0.7691", + "construction.index_build_time_s": "", + "construction.index_quant_time_s.NVQ.compute_time_s": "", + "construction.index_quant_time_s.PQ.compute_time_s": "", + "construction.index_quant_time_s.PQ.encoding_time_s": "" + }, + { + "schema_version": "1", + "run_id": "jdk22-openai-3072-1m-4bit-pq-t8-20261029-013914Z", + "run_uuid": "b58dc64c-c96e-4b6f-ad84-ed66e5cdcdb4", + "system_id": "d940322e03a3", + "dataset_name": "openai-3072-1m", + "M": "32", + "efConstruction": "100", + "neighborOverflow": "1.2", + "addHierarchy": "true", + "refineFinalGraph": "true", + "feature_set": "[NVQ_VECTORS, FUSED_PQ]", + "build_compressor": "\"ProductQuantization(M=773, clusters=256, centered=false)\"", + "search_compressor": "None", + "usePruning": "false", + "topK": "10", + "overquery": "2.0", + "rerankK": "20", + "search.throughput.avg_qps": "14336.390737938298", + "search.throughput.stddev_qps": "126.38455072730488", + "search.throughput.cv_pct": "0.8815646353224336", + "search.count.avg_visited": "704.466", + "search.accuracy.recall_at_10": "0.877", + "construction.index_build_time_s": "", + "construction.index_quant_time_s.NVQ.compute_time_s": "", + "construction.index_quant_time_s.PQ.compute_time_s": "", + "construction.index_quant_time_s.PQ.encoding_time_s": "" + } + ], + "throughput_samples": [ + { + "sample": "Test-0", + "queries": 18000, + "seconds": 1.030011 + }, + { + "sample": "Test-1", + "queries": 19000, + "seconds": 1.003889 + }, + { + "sample": "Test-2", + "queries": 19000, + "seconds": 1.000081 + }, + { + "sample": "Test-0", + "queries": 15000, + "seconds": 1.038658 + }, + { + "sample": "Test-1", + "queries": 15000, + "seconds": 1.043753 + }, + { + "sample": "Test-2", + "queries": 15000, + "seconds": 1.056617 + } + ], + "finished": "2026-10-29T01:39:35.363519+00:00" + } + }, + "index_files": [ + { + "path": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk22-openai-3072-1m-4bit-pq/index_cache/graph_openai_3072_1m_FUSED_PQ_NVQ_VECTORS_M32_ef100_of1.2_alpha1.2_H1_R1_ProductQuantization_M_773_clusters_256_centered_false_", + "bytes": 28010961543 + } + ], + "finished": "2026-10-29T01:39:35.364087+00:00" + }, + { + "dataset": "mpnet-1m", + "display_name": "MPNet", + "original_dimensions": "768", + "projected_ash_dimensions": "384", + "base_vectors": "999812", + "queries": "10000", + "ash_bits": "2", + "algorithm": "PQ", + "code_bytes": "101", + "ash_encoded_bits": "", + "pq_m": "101", + "config": "mpnet-1m-2bit-budget-pq.yml", + "jdk": "25", + "label": "jdk25-mpnet-1m-2bit-pq", + "path": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk25-mpnet-1m-2bit-pq", + "source": "b44e25b71e3d9c5c7a2f164610b0fdbd099d91fa", + "started": "2026-10-29T01:41:39.588096+00:00", + "phases": { + "build": { + "elapsed_process_seconds": 41.54969112202525, + "log": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/logs/jdk25-mpnet-1m-2bit-pq-build.log", + "csv": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk25-mpnet-1m-2bit-pq/logging/jdk25-mpnet-1m-2bit-pq-build-20261029-014139Z/experiments.csv", + "rows": [ + { + "schema_version": "1", + "run_id": "jdk25-mpnet-1m-2bit-pq-build-20261029-014139Z", + "run_uuid": "249222a9-be9f-416a-98b5-9e2224daabc7", + "system_id": "cd8205c1bae6", + "dataset_name": "mpnet-1m", + "M": "32", + "efConstruction": "100", + "neighborOverflow": "1.2", + "addHierarchy": "true", + "refineFinalGraph": "true", + "feature_set": "[NVQ_VECTORS, FUSED_PQ]", + "build_compressor": "\"ProductQuantization(M=101, clusters=256, centered=false)\"", + "search_compressor": "None", + "usePruning": "false", + "topK": "10", + "overquery": "1.0", + "rerankK": "10", + "search.accuracy.recall_at_10": "0.65484", + "construction.index_build_time_s": "33.769320929", + "construction.index_quant_time_s.NVQ.compute_time_s": "0.260673416", + "construction.index_quant_time_s.PQ.compute_time_s": "2.774568465", + "construction.index_quant_time_s.PQ.encoding_time_s": "1.048305647" + } + ], + "throughput_samples": [], + "finished": "2026-10-29T01:42:21.139469+00:00" + }, + "t1": { + "elapsed_process_seconds": 36.69188829232007, + "log": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/logs/jdk25-mpnet-1m-2bit-pq-t1.log", + "csv": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk25-mpnet-1m-2bit-pq/logging/jdk25-mpnet-1m-2bit-pq-t1-20261029-014222Z/experiments.csv", + "rows": [ + { + "schema_version": "1", + "run_id": "jdk25-mpnet-1m-2bit-pq-t1-20261029-014222Z", + "run_uuid": "9fb519f2-d57e-41eb-bd45-74657e5601b0", + "system_id": "cd8205c1bae6", + "dataset_name": "mpnet-1m", + "M": "32", + "efConstruction": "100", + "neighborOverflow": "1.2", + "addHierarchy": "true", + "refineFinalGraph": "true", + "feature_set": "[NVQ_VECTORS, FUSED_PQ]", + "build_compressor": "\"ProductQuantization(M=101, clusters=256, centered=false)\"", + "search_compressor": "None", + "usePruning": "false", + "topK": "10", + "overquery": "1.0", + "rerankK": "10", + "search.throughput.avg_qps": "11812.269458852594", + "search.throughput.stddev_qps": "210.99098451189369", + "search.throughput.cv_pct": "1.7862019254375245", + "search.count.avg_visited": "416.9763", + "search.accuracy.recall_at_10": "0.65484", + "construction.index_build_time_s": "", + "construction.index_quant_time_s.NVQ.compute_time_s": "", + "construction.index_quant_time_s.PQ.compute_time_s": "", + "construction.index_quant_time_s.PQ.encoding_time_s": "" + }, + { + "schema_version": "1", + "run_id": "jdk25-mpnet-1m-2bit-pq-t1-20261029-014222Z", + "run_uuid": "9fb519f2-d57e-41eb-bd45-74657e5601b0", + "system_id": "cd8205c1bae6", + "dataset_name": "mpnet-1m", + "M": "32", + "efConstruction": "100", + "neighborOverflow": "1.2", + "addHierarchy": "true", + "refineFinalGraph": "true", + "feature_set": "[NVQ_VECTORS, FUSED_PQ]", + "build_compressor": "\"ProductQuantization(M=101, clusters=256, centered=false)\"", + "search_compressor": "None", + "usePruning": "false", + "topK": "10", + "overquery": "2.0", + "rerankK": "20", + "search.throughput.avg_qps": "9294.266279454745", + "search.throughput.stddev_qps": "42.12928952819675", + "search.throughput.cv_pct": "0.45328257510035846", + "search.count.avg_visited": "563.8511", + "search.accuracy.recall_at_10": "0.8221", + "construction.index_build_time_s": "", + "construction.index_quant_time_s.NVQ.compute_time_s": "", + "construction.index_quant_time_s.PQ.compute_time_s": "", + "construction.index_quant_time_s.PQ.encoding_time_s": "" + } + ], + "throughput_samples": [ + { + "sample": "Test-0", + "queries": 20000, + "seconds": 1.728791 + }, + { + "sample": "Test-1", + "queries": 20000, + "seconds": 1.676884 + }, + { + "sample": "Test-2", + "queries": 20000, + "seconds": 1.674881 + }, + { + "sample": "Test-0", + "queries": 10000, + "seconds": 1.081375 + }, + { + "sample": "Test-1", + "queries": 10000, + "seconds": 1.074565 + }, + { + "sample": "Test-2", + "queries": 10000, + "seconds": 1.071901 + } + ], + "finished": "2026-10-29T01:42:57.832733+00:00" + }, + "t8": { + "elapsed_process_seconds": 17.65556924790144, + "log": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/logs/jdk25-mpnet-1m-2bit-pq-t8.log", + "csv": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk25-mpnet-1m-2bit-pq/logging/jdk25-mpnet-1m-2bit-pq-t8-20261029-014258Z/experiments.csv", + "rows": [ + { + "schema_version": "1", + "run_id": "jdk25-mpnet-1m-2bit-pq-t8-20261029-014258Z", + "run_uuid": "aa65257a-aa4d-4816-bdb7-0d6db95d4705", + "system_id": "cd8205c1bae6", + "dataset_name": "mpnet-1m", + "M": "32", + "efConstruction": "100", + "neighborOverflow": "1.2", + "addHierarchy": "true", + "refineFinalGraph": "true", + "feature_set": "[NVQ_VECTORS, FUSED_PQ]", + "build_compressor": "\"ProductQuantization(M=101, clusters=256, centered=false)\"", + "search_compressor": "None", + "usePruning": "false", + "topK": "10", + "overquery": "1.0", + "rerankK": "10", + "search.throughput.avg_qps": "76801.73087416003", + "search.throughput.stddev_qps": "911.757836651423", + "search.throughput.cv_pct": "1.187157927658352", + "search.count.avg_visited": "416.9763", + "search.accuracy.recall_at_10": "0.65484", + "construction.index_build_time_s": "", + "construction.index_quant_time_s.NVQ.compute_time_s": "", + "construction.index_quant_time_s.PQ.compute_time_s": "", + "construction.index_quant_time_s.PQ.encoding_time_s": "" + }, + { + "schema_version": "1", + "run_id": "jdk25-mpnet-1m-2bit-pq-t8-20261029-014258Z", + "run_uuid": "aa65257a-aa4d-4816-bdb7-0d6db95d4705", + "system_id": "cd8205c1bae6", + "dataset_name": "mpnet-1m", + "M": "32", + "efConstruction": "100", + "neighborOverflow": "1.2", + "addHierarchy": "true", + "refineFinalGraph": "true", + "feature_set": "[NVQ_VECTORS, FUSED_PQ]", + "build_compressor": "\"ProductQuantization(M=101, clusters=256, centered=false)\"", + "search_compressor": "None", + "usePruning": "false", + "topK": "10", + "overquery": "2.0", + "rerankK": "20", + "search.throughput.avg_qps": "71112.3291273773", + "search.throughput.stddev_qps": "640.384545175938", + "search.throughput.cv_pct": "0.9005253421370478", + "search.count.avg_visited": "563.8511", + "search.accuracy.recall_at_10": "0.8221", + "construction.index_build_time_s": "", + "construction.index_quant_time_s.NVQ.compute_time_s": "", + "construction.index_quant_time_s.PQ.compute_time_s": "", + "construction.index_quant_time_s.PQ.encoding_time_s": "" + } + ], + "throughput_samples": [ + { + "sample": "Test-0", + "queries": 80000, + "seconds": 1.053934 + }, + { + "sample": "Test-1", + "queries": 80000, + "seconds": 1.029219 + }, + { + "sample": "Test-2", + "queries": 80000, + "seconds": 1.04207 + }, + { + "sample": "Test-0", + "queries": 80000, + "seconds": 1.130137 + }, + { + "sample": "Test-1", + "queries": 80000, + "seconds": 1.131556 + }, + { + "sample": "Test-2", + "queries": 80000, + "seconds": 1.113431 + } + ], + "finished": "2026-10-29T01:43:15.489768+00:00" + } + }, + "index_files": [ + { + "path": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk25-mpnet-1m-2bit-pq/index_cache/graph_mpnet_1m_FUSED_PQ_NVQ_VECTORS_M32_ef100_of1.2_alpha1.2_H1_R1_ProductQuantization_M_101_clusters_256_centered_false_", + "bytes": 4204456814 + } + ], + "finished": "2026-10-29T01:43:15.490345+00:00" + }, + { + "dataset": "mpnet-1m", + "display_name": "MPNet", + "original_dimensions": "768", + "projected_ash_dimensions": "384", + "base_vectors": "999812", + "queries": "10000", + "ash_bits": "2", + "algorithm": "ASH", + "code_bytes": "101", + "ash_encoded_bits": "808", + "pq_m": "", + "config": "mpnet-1m-2bit-budget-ash.yml", + "jdk": "22", + "label": "jdk22-mpnet-1m-2bit-ash", + "path": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk22-mpnet-1m-2bit-ash", + "source": "507b74e7ef8a0e12fcfe402deb4bf758c65758d5", + "started": "2026-10-29T02:24:09.744714+00:00", + "phases": { + "build": { + "elapsed_process_seconds": 70.72278314642608, + "log": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/logs/jdk22-mpnet-1m-2bit-ash-build.log", + "csv": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk22-mpnet-1m-2bit-ash/logging/jdk22-mpnet-1m-2bit-ash-build-20261029-022410Z/experiments.csv", + "rows": [ + { + "schema_version": "1", + "run_id": "jdk22-mpnet-1m-2bit-ash-build-20261029-022410Z", + "run_uuid": "251c22bc-d7d2-411b-9d4a-2e9d99c46292", + "system_id": "941aee9b5750", + "dataset_name": "mpnet-1m", + "M": "32", + "efConstruction": "100", + "neighborOverflow": "1.2", + "addHierarchy": "true", + "refineFinalGraph": "true", + "feature_set": "[NVQ_VECTORS, FUSED_ASH]", + "build_compressor": "\"AsymmetricHashing[origDim=768, encodedBits=808, bodyBits=768, quantizedDim=384, bitsPerDimension=2, optimizer=ITQ, landmarks=1]\"", + "search_compressor": "None", + "usePruning": "false", + "topK": "10", + "overquery": "1.0", + "rerankK": "10", + "search.accuracy.recall_at_10": "0.8194", + "construction.index_build_time_s": "58.372648107", + "construction.index_quant_time_s.ASH.compute_time_s": "5.20501655", + "construction.index_quant_time_s.ASH.encoding_time_s": "1.091514841", + "construction.index_quant_time_s.NVQ.compute_time_s": "0.368510427" + } + ], + "throughput_samples": [], + "finished": "2026-10-29T02:25:20.468934+00:00" + }, + "t1": { + "elapsed_process_seconds": 48.48104625567794, + "log": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/logs/jdk22-mpnet-1m-2bit-ash-t1.log", + "csv": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk22-mpnet-1m-2bit-ash/logging/jdk22-mpnet-1m-2bit-ash-t1-20261029-022521Z/experiments.csv", + "rows": [ + { + "schema_version": "1", + "run_id": "jdk22-mpnet-1m-2bit-ash-t1-20261029-022521Z", + "run_uuid": "ce7e6667-4789-46f8-ab8b-ee86b0a16093", + "system_id": "941aee9b5750", + "dataset_name": "mpnet-1m", + "M": "32", + "efConstruction": "100", + "neighborOverflow": "1.2", + "addHierarchy": "true", + "refineFinalGraph": "true", + "feature_set": "[NVQ_VECTORS, FUSED_ASH]", + "build_compressor": "\"AsymmetricHashing[origDim=768, encodedBits=808, bodyBits=768, quantizedDim=384, bitsPerDimension=2, optimizer=ITQ, landmarks=1]\"", + "search_compressor": "None", + "usePruning": "false", + "topK": "10", + "overquery": "1.0", + "rerankK": "10", + "search.throughput.avg_qps": "9610.898579370603", + "search.throughput.stddev_qps": "457.55703825931096", + "search.throughput.cv_pct": "4.760814345096079", + "search.count.avg_visited": "457.6786", + "search.accuracy.recall_at_10": "0.8194", + "construction.index_build_time_s": "", + "construction.index_quant_time_s.ASH.compute_time_s": "", + "construction.index_quant_time_s.ASH.encoding_time_s": "", + "construction.index_quant_time_s.NVQ.compute_time_s": "" + }, + { + "schema_version": "1", + "run_id": "jdk22-mpnet-1m-2bit-ash-t1-20261029-022521Z", + "run_uuid": "ce7e6667-4789-46f8-ab8b-ee86b0a16093", + "system_id": "941aee9b5750", + "dataset_name": "mpnet-1m", + "M": "32", + "efConstruction": "100", + "neighborOverflow": "1.2", + "addHierarchy": "true", + "refineFinalGraph": "true", + "feature_set": "[NVQ_VECTORS, FUSED_ASH]", + "build_compressor": "\"AsymmetricHashing[origDim=768, encodedBits=808, bodyBits=768, quantizedDim=384, bitsPerDimension=2, optimizer=ITQ, landmarks=1]\"", + "search_compressor": "None", + "usePruning": "false", + "topK": "10", + "overquery": "2.0", + "rerankK": "20", + "search.throughput.avg_qps": "7822.315710403462", + "search.throughput.stddev_qps": "39.11300159050038", + "search.throughput.cv_pct": "0.5000181920359105", + "search.count.avg_visited": "639.0262", + "search.accuracy.recall_at_10": "0.94055", + "construction.index_build_time_s": "", + "construction.index_quant_time_s.ASH.compute_time_s": "", + "construction.index_quant_time_s.ASH.encoding_time_s": "", + "construction.index_quant_time_s.NVQ.compute_time_s": "" + } + ], + "throughput_samples": [ + { + "sample": "Test-0", + "queries": 10000, + "seconds": 1.099656 + }, + { + "sample": "Test-1", + "queries": 10000, + "seconds": 1.022938 + }, + { + "sample": "Test-2", + "queries": 10000, + "seconds": 1.003696 + }, + { + "sample": "Test-0", + "queries": 10000, + "seconds": 1.280488 + }, + { + "sample": "Test-1", + "queries": 10000, + "seconds": 1.283499 + }, + { + "sample": "Test-2", + "queries": 10000, + "seconds": 1.271258 + } + ], + "finished": "2026-10-29T02:26:08.951458+00:00" + }, + "t8": { + "elapsed_process_seconds": 19.761133586987853, + "log": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/logs/jdk22-mpnet-1m-2bit-ash-t8.log", + "csv": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk22-mpnet-1m-2bit-ash/logging/jdk22-mpnet-1m-2bit-ash-t8-20261029-022609Z/experiments.csv", + "rows": [ + { + "schema_version": "1", + "run_id": "jdk22-mpnet-1m-2bit-ash-t8-20261029-022609Z", + "run_uuid": "49f61b6b-92c8-4d0d-b433-5481edde0b81", + "system_id": "941aee9b5750", + "dataset_name": "mpnet-1m", + "M": "32", + "efConstruction": "100", + "neighborOverflow": "1.2", + "addHierarchy": "true", + "refineFinalGraph": "true", + "feature_set": "[NVQ_VECTORS, FUSED_ASH]", + "build_compressor": "\"AsymmetricHashing[origDim=768, encodedBits=808, bodyBits=768, quantizedDim=384, bitsPerDimension=2, optimizer=ITQ, landmarks=1]\"", + "search_compressor": "None", + "usePruning": "false", + "topK": "10", + "overquery": "1.0", + "rerankK": "10", + "search.throughput.avg_qps": "70989.65395938238", + "search.throughput.stddev_qps": "2781.2699707500246", + "search.throughput.cv_pct": "3.91785255403747", + "search.count.avg_visited": "457.6786", + "search.accuracy.recall_at_10": "0.8194", + "construction.index_build_time_s": "", + "construction.index_quant_time_s.ASH.compute_time_s": "", + "construction.index_quant_time_s.ASH.encoding_time_s": "", + "construction.index_quant_time_s.NVQ.compute_time_s": "" + }, + { + "schema_version": "1", + "run_id": "jdk22-mpnet-1m-2bit-ash-t8-20261029-022609Z", + "run_uuid": "49f61b6b-92c8-4d0d-b433-5481edde0b81", + "system_id": "941aee9b5750", + "dataset_name": "mpnet-1m", + "M": "32", + "efConstruction": "100", + "neighborOverflow": "1.2", + "addHierarchy": "true", + "refineFinalGraph": "true", + "feature_set": "[NVQ_VECTORS, FUSED_ASH]", + "build_compressor": "\"AsymmetricHashing[origDim=768, encodedBits=808, bodyBits=768, quantizedDim=384, bitsPerDimension=2, optimizer=ITQ, landmarks=1]\"", + "search_compressor": "None", + "usePruning": "false", + "topK": "10", + "overquery": "2.0", + "rerankK": "20", + "search.throughput.avg_qps": "59038.82008864344", + "search.throughput.stddev_qps": "991.3419392083159", + "search.throughput.cv_pct": "1.6791357579976567", + "search.count.avg_visited": "639.0262", + "search.accuracy.recall_at_10": "0.94055", + "construction.index_build_time_s": "", + "construction.index_quant_time_s.ASH.compute_time_s": "", + "construction.index_quant_time_s.ASH.encoding_time_s": "", + "construction.index_quant_time_s.NVQ.compute_time_s": "" + } + ], + "throughput_samples": [ + { + "sample": "Test-0", + "queries": 70000, + "seconds": 1.031367 + }, + { + "sample": "Test-1", + "queries": 80000, + "seconds": 1.092698 + }, + { + "sample": "Test-2", + "queries": 80000, + "seconds": 1.112894 + }, + { + "sample": "Test-0", + "queries": 60000, + "seconds": 1.00914 + }, + { + "sample": "Test-1", + "queries": 60000, + "seconds": 1.004135 + }, + { + "sample": "Test-2", + "queries": 60000, + "seconds": 1.036145 + } + ], + "finished": "2026-10-29T02:26:28.714597+00:00" + } + }, + "index_files": [ + { + "path": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk22-mpnet-1m-2bit-ash/index_cache/graph_mpnet_1m_FUSED_ASH_NVQ_VECTORS_M32_ef100_of1.2_alpha1.2_H1_R1_AsymmetricHashing_origDim_768_encodedBits_808_bodyBits_768_quantizedDim_384_bitsPerDimension_2_optimizer_ITQ_landmarks_1_", + "bytes": 4205250935 + } + ], + "finished": "2026-10-29T02:26:28.715263+00:00" + }, + { + "dataset": "mpnet-1m", + "display_name": "MPNet", + "original_dimensions": "768", + "projected_ash_dimensions": "384", + "base_vectors": "999812", + "queries": "10000", + "ash_bits": "4", + "algorithm": "ASH", + "code_bytes": "197", + "ash_encoded_bits": "1576", + "pq_m": "", + "config": "mpnet-1m-4bit-budget-ash.yml", + "jdk": "22", + "label": "jdk22-mpnet-1m-4bit-ash", + "path": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk22-mpnet-1m-4bit-ash", + "source": "507b74e7ef8a0e12fcfe402deb4bf758c65758d5", + "started": "2026-10-29T02:26:28.718332+00:00", + "phases": { + "build": { + "elapsed_process_seconds": 54.33950508851558, + "log": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/logs/jdk22-mpnet-1m-4bit-ash-build.log", + "csv": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk22-mpnet-1m-4bit-ash/logging/jdk22-mpnet-1m-4bit-ash-build-20261029-022629Z/experiments.csv", + "rows": [ + { + "schema_version": "1", + "run_id": "jdk22-mpnet-1m-4bit-ash-build-20261029-022629Z", + "run_uuid": "11824a2a-1b82-4fe0-a37a-18977fb7e5ea", + "system_id": "941aee9b5750", + "dataset_name": "mpnet-1m", + "M": "32", + "efConstruction": "100", + "neighborOverflow": "1.2", + "addHierarchy": "true", + "refineFinalGraph": "true", + "feature_set": "[NVQ_VECTORS, FUSED_ASH]", + "build_compressor": "\"AsymmetricHashing[origDim=768, encodedBits=1576, bodyBits=1536, quantizedDim=384, bitsPerDimension=4, optimizer=ITQ, landmarks=1]\"", + "search_compressor": "None", + "usePruning": "false", + "topK": "10", + "overquery": "1.0", + "rerankK": "10", + "search.accuracy.recall_at_10": "0.86642", + "construction.index_build_time_s": "42.028731887", + "construction.index_quant_time_s.ASH.compute_time_s": "4.690223155", + "construction.index_quant_time_s.ASH.encoding_time_s": "1.194973879", + "construction.index_quant_time_s.NVQ.compute_time_s": "0.46261499" + } + ], + "throughput_samples": [], + "finished": "2026-10-29T02:27:23.058936+00:00" + }, + "t1": { + "elapsed_process_seconds": 94.77228872384876, + "log": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/logs/jdk22-mpnet-1m-4bit-ash-t1.log", + "csv": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk22-mpnet-1m-4bit-ash/logging/jdk22-mpnet-1m-4bit-ash-t1-20261029-022724Z/experiments.csv", + "rows": [ + { + "schema_version": "1", + "run_id": "jdk22-mpnet-1m-4bit-ash-t1-20261029-022724Z", + "run_uuid": "202fed30-8b28-4ca0-9ba5-5ed8c185236a", + "system_id": "941aee9b5750", + "dataset_name": "mpnet-1m", + "M": "32", + "efConstruction": "100", + "neighborOverflow": "1.2", + "addHierarchy": "true", + "refineFinalGraph": "true", + "feature_set": "[NVQ_VECTORS, FUSED_ASH]", + "build_compressor": "\"AsymmetricHashing[origDim=768, encodedBits=1576, bodyBits=1536, quantizedDim=384, bitsPerDimension=4, optimizer=ITQ, landmarks=1]\"", + "search_compressor": "None", + "usePruning": "false", + "topK": "10", + "overquery": "1.0", + "rerankK": "10", + "search.throughput.avg_qps": "8629.758114420085", + "search.throughput.stddev_qps": "522.9722821464711", + "search.throughput.cv_pct": "6.0601035998054105", + "search.count.avg_visited": "431.9821", + "search.accuracy.recall_at_10": "0.86642", + "construction.index_build_time_s": "", + "construction.index_quant_time_s.ASH.compute_time_s": "", + "construction.index_quant_time_s.ASH.encoding_time_s": "", + "construction.index_quant_time_s.NVQ.compute_time_s": "" + }, + { + "schema_version": "1", + "run_id": "jdk22-mpnet-1m-4bit-ash-t1-20261029-022724Z", + "run_uuid": "202fed30-8b28-4ca0-9ba5-5ed8c185236a", + "system_id": "941aee9b5750", + "dataset_name": "mpnet-1m", + "M": "32", + "efConstruction": "100", + "neighborOverflow": "1.2", + "addHierarchy": "true", + "refineFinalGraph": "true", + "feature_set": "[NVQ_VECTORS, FUSED_ASH]", + "build_compressor": "\"AsymmetricHashing[origDim=768, encodedBits=1576, bodyBits=1536, quantizedDim=384, bitsPerDimension=4, optimizer=ITQ, landmarks=1]\"", + "search_compressor": "None", + "usePruning": "false", + "topK": "10", + "overquery": "2.0", + "rerankK": "20", + "search.throughput.avg_qps": "6976.353944105883", + "search.throughput.stddev_qps": "19.32133701087272", + "search.throughput.cv_pct": "0.2769546551920112", + "search.count.avg_visited": "596.0551", + "search.accuracy.recall_at_10": "0.93966", + "construction.index_build_time_s": "", + "construction.index_quant_time_s.ASH.compute_time_s": "", + "construction.index_quant_time_s.ASH.encoding_time_s": "", + "construction.index_quant_time_s.NVQ.compute_time_s": "" + } + ], + "throughput_samples": [ + { + "sample": "Test-0", + "queries": 10000, + "seconds": 1.245735 + }, + { + "sample": "Test-1", + "queries": 10000, + "seconds": 1.124347 + }, + { + "sample": "Test-2", + "queries": 10000, + "seconds": 1.115096 + }, + { + "sample": "Test-0", + "queries": 10000, + "seconds": 1.437032 + }, + { + "sample": "Test-1", + "queries": 10000, + "seconds": 1.434057 + }, + { + "sample": "Test-2", + "queries": 10000, + "seconds": 1.429174 + } + ], + "finished": "2026-10-29T02:28:57.836995+00:00" + }, + "t8": { + "elapsed_process_seconds": 21.507000694982708, + "log": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/logs/jdk22-mpnet-1m-4bit-ash-t8.log", + "csv": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk22-mpnet-1m-4bit-ash/logging/jdk22-mpnet-1m-4bit-ash-t8-20261029-022858Z/experiments.csv", + "rows": [ + { + "schema_version": "1", + "run_id": "jdk22-mpnet-1m-4bit-ash-t8-20261029-022858Z", + "run_uuid": "588bca5c-54ff-4315-9b55-445f634e7f44", + "system_id": "941aee9b5750", + "dataset_name": "mpnet-1m", + "M": "32", + "efConstruction": "100", + "neighborOverflow": "1.2", + "addHierarchy": "true", + "refineFinalGraph": "true", + "feature_set": "[NVQ_VECTORS, FUSED_ASH]", + "build_compressor": "\"AsymmetricHashing[origDim=768, encodedBits=1576, bodyBits=1536, quantizedDim=384, bitsPerDimension=4, optimizer=ITQ, landmarks=1]\"", + "search_compressor": "None", + "usePruning": "false", + "topK": "10", + "overquery": "1.0", + "rerankK": "10", + "search.throughput.avg_qps": "64520.16990414337", + "search.throughput.stddev_qps": "1765.6384986179241", + "search.throughput.cv_pct": "2.73656827197", + "search.count.avg_visited": "431.9821", + "search.accuracy.recall_at_10": "0.86642", + "construction.index_build_time_s": "", + "construction.index_quant_time_s.ASH.compute_time_s": "", + "construction.index_quant_time_s.ASH.encoding_time_s": "", + "construction.index_quant_time_s.NVQ.compute_time_s": "" + }, + { + "schema_version": "1", + "run_id": "jdk22-mpnet-1m-4bit-ash-t8-20261029-022858Z", + "run_uuid": "588bca5c-54ff-4315-9b55-445f634e7f44", + "system_id": "941aee9b5750", + "dataset_name": "mpnet-1m", + "M": "32", + "efConstruction": "100", + "neighborOverflow": "1.2", + "addHierarchy": "true", + "refineFinalGraph": "true", + "feature_set": "[NVQ_VECTORS, FUSED_ASH]", + "build_compressor": "\"AsymmetricHashing[origDim=768, encodedBits=1576, bodyBits=1536, quantizedDim=384, bitsPerDimension=4, optimizer=ITQ, landmarks=1]\"", + "search_compressor": "None", + "usePruning": "false", + "topK": "10", + "overquery": "2.0", + "rerankK": "20", + "search.throughput.avg_qps": "52278.56016692018", + "search.throughput.stddev_qps": "2374.4805450263125", + "search.throughput.cv_pct": "4.541977700695726", + "search.count.avg_visited": "596.0551", + "search.accuracy.recall_at_10": "0.93966", + "construction.index_build_time_s": "", + "construction.index_quant_time_s.ASH.compute_time_s": "", + "construction.index_quant_time_s.ASH.encoding_time_s": "", + "construction.index_quant_time_s.NVQ.compute_time_s": "" + } + ], + "throughput_samples": [ + { + "sample": "Test-0", + "queries": 70000, + "seconds": 1.118416 + }, + { + "sample": "Test-1", + "queries": 70000, + "seconds": 1.078232 + }, + { + "sample": "Test-2", + "queries": 70000, + "seconds": 1.059789 + }, + { + "sample": "Test-0", + "queries": 60000, + "seconds": 1.135203 + }, + { + "sample": "Test-1", + "queries": 60000, + "seconds": 1.104719 + }, + { + "sample": "Test-2", + "queries": 50000, + "seconds": 1.006659 + } + ], + "finished": "2026-10-29T02:29:19.345481+00:00" + } + }, + "index_files": [ + { + "path": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk22-mpnet-1m-4bit-ash/index_cache/graph_mpnet_1m_FUSED_ASH_NVQ_VECTORS_M32_ef100_of1.2_alpha1.2_H1_R1_AsymmetricHashing_origDim_768_encodedBits_1576_bodyBits_1536_quantizedDim_384_bitsPerDimension_4_optimizer_ITQ_landmarks_1_", + "bytes": 7279672095 + } + ], + "finished": "2026-10-29T02:29:19.346072+00:00" + }, + { + "dataset": "cohere-english-v3-1M", + "display_name": "Cohere", + "original_dimensions": "1024", + "projected_ash_dimensions": "512", + "base_vectors": "1000000", + "queries": "10000", + "ash_bits": "2", + "algorithm": "ASH", + "code_bytes": "133", + "ash_encoded_bits": "1064", + "pq_m": "", + "config": "cohere-english-v3-1M-2bit-budget-ash.yml", + "jdk": "22", + "label": "jdk22-cohere-english-v3-1M-2bit-ash", + "path": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk22-cohere-english-v3-1M-2bit-ash", + "source": "507b74e7ef8a0e12fcfe402deb4bf758c65758d5", + "started": "2026-10-29T02:29:19.350029+00:00", + "phases": { + "build": { + "elapsed_process_seconds": 96.0135448006913, + "log": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/logs/jdk22-cohere-english-v3-1M-2bit-ash-build.log", + "csv": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk22-cohere-english-v3-1M-2bit-ash/logging/jdk22-cohere-english-v3-1M-2bit-ash-build-20261029-022919Z/experiments.csv", + "rows": [ + { + "schema_version": "1", + "run_id": "jdk22-cohere-english-v3-1M-2bit-ash-build-20261029-022919Z", + "run_uuid": "8a102358-28d0-40ca-818b-b045809a6d2a", + "system_id": "941aee9b5750", + "dataset_name": "cohere-english-v3-1M", + "M": "32", + "efConstruction": "100", + "neighborOverflow": "1.2", + "addHierarchy": "true", + "refineFinalGraph": "true", + "feature_set": "[NVQ_VECTORS, FUSED_ASH]", + "build_compressor": "\"AsymmetricHashing[origDim=1024, encodedBits=1064, bodyBits=1024, quantizedDim=512, bitsPerDimension=2, optimizer=ITQ, landmarks=1]\"", + "search_compressor": "None", + "usePruning": "false", + "topK": "10", + "overquery": "1.0", + "rerankK": "10", + "search.accuracy.recall_at_10": "0.63903", + "construction.index_build_time_s": "62.374692218", + "construction.index_quant_time_s.ASH.compute_time_s": "8.026500274", + "construction.index_quant_time_s.ASH.encoding_time_s": "1.861498103", + "construction.index_quant_time_s.NVQ.compute_time_s": "0.457021944" + } + ], + "throughput_samples": [], + "finished": "2026-10-29T02:30:55.364534+00:00" + }, + "t1": { + "elapsed_process_seconds": 57.975656444206834, + "log": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/logs/jdk22-cohere-english-v3-1M-2bit-ash-t1.log", + "csv": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk22-cohere-english-v3-1M-2bit-ash/logging/jdk22-cohere-english-v3-1M-2bit-ash-t1-20261029-023056Z/experiments.csv", + "rows": [ + { + "schema_version": "1", + "run_id": "jdk22-cohere-english-v3-1M-2bit-ash-t1-20261029-023056Z", + "run_uuid": "1eeb22cf-41a0-46d1-8c55-05e9c10b5fe0", + "system_id": "941aee9b5750", + "dataset_name": "cohere-english-v3-1M", + "M": "32", + "efConstruction": "100", + "neighborOverflow": "1.2", + "addHierarchy": "true", + "refineFinalGraph": "true", + "feature_set": "[NVQ_VECTORS, FUSED_ASH]", + "build_compressor": "\"AsymmetricHashing[origDim=1024, encodedBits=1064, bodyBits=1024, quantizedDim=512, bitsPerDimension=2, optimizer=ITQ, landmarks=1]\"", + "search_compressor": "None", + "usePruning": "false", + "topK": "10", + "overquery": "1.0", + "rerankK": "10", + "search.throughput.avg_qps": "6040.668013555356", + "search.throughput.stddev_qps": "274.3959501873643", + "search.throughput.cv_pct": "4.542476917645787", + "search.count.avg_visited": "567.867", + "search.accuracy.recall_at_10": "0.63903", + "construction.index_build_time_s": "", + "construction.index_quant_time_s.ASH.compute_time_s": "", + "construction.index_quant_time_s.ASH.encoding_time_s": "", + "construction.index_quant_time_s.NVQ.compute_time_s": "" + }, + { + "schema_version": "1", + "run_id": "jdk22-cohere-english-v3-1M-2bit-ash-t1-20261029-023056Z", + "run_uuid": "1eeb22cf-41a0-46d1-8c55-05e9c10b5fe0", + "system_id": "941aee9b5750", + "dataset_name": "cohere-english-v3-1M", + "M": "32", + "efConstruction": "100", + "neighborOverflow": "1.2", + "addHierarchy": "true", + "refineFinalGraph": "true", + "feature_set": "[NVQ_VECTORS, FUSED_ASH]", + "build_compressor": "\"AsymmetricHashing[origDim=1024, encodedBits=1064, bodyBits=1024, quantizedDim=512, bitsPerDimension=2, optimizer=ITQ, landmarks=1]\"", + "search_compressor": "None", + "usePruning": "false", + "topK": "10", + "overquery": "2.0", + "rerankK": "20", + "search.throughput.avg_qps": "5224.424204577702", + "search.throughput.stddev_qps": "61.298718058874684", + "search.throughput.cv_pct": "1.173310505780982", + "search.count.avg_visited": "812.9255", + "search.accuracy.recall_at_10": "0.81995", + "construction.index_build_time_s": "", + "construction.index_quant_time_s.ASH.compute_time_s": "", + "construction.index_quant_time_s.ASH.encoding_time_s": "", + "construction.index_quant_time_s.NVQ.compute_time_s": "" + } + ], + "throughput_samples": [ + { + "sample": "Test-0", + "queries": 10000, + "seconds": 1.742422 + }, + { + "sample": "Test-1", + "queries": 10000, + "seconds": 1.593445 + }, + { + "sample": "Test-2", + "queries": 10000, + "seconds": 1.637423 + }, + { + "sample": "Test-0", + "queries": 10000, + "seconds": 1.937973 + }, + { + "sample": "Test-1", + "queries": 10000, + "seconds": 1.893198 + }, + { + "sample": "Test-2", + "queries": 10000, + "seconds": 1.911616 + } + ], + "finished": "2026-10-29T02:31:53.342118+00:00" + }, + "t8": { + "elapsed_process_seconds": 22.230311824940145, + "log": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/logs/jdk22-cohere-english-v3-1M-2bit-ash-t8.log", + "csv": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk22-cohere-english-v3-1M-2bit-ash/logging/jdk22-cohere-english-v3-1M-2bit-ash-t8-20261029-023153Z/experiments.csv", + "rows": [ + { + "schema_version": "1", + "run_id": "jdk22-cohere-english-v3-1M-2bit-ash-t8-20261029-023153Z", + "run_uuid": "b57355e7-3ab3-4119-a2ba-b9369571dd27", + "system_id": "941aee9b5750", + "dataset_name": "cohere-english-v3-1M", + "M": "32", + "efConstruction": "100", + "neighborOverflow": "1.2", + "addHierarchy": "true", + "refineFinalGraph": "true", + "feature_set": "[NVQ_VECTORS, FUSED_ASH]", + "build_compressor": "\"AsymmetricHashing[origDim=1024, encodedBits=1064, bodyBits=1024, quantizedDim=512, bitsPerDimension=2, optimizer=ITQ, landmarks=1]\"", + "search_compressor": "None", + "usePruning": "false", + "topK": "10", + "overquery": "1.0", + "rerankK": "10", + "search.throughput.avg_qps": "47759.13958907922", + "search.throughput.stddev_qps": "2166.440848887819", + "search.throughput.cv_pct": "4.5361806505057", + "search.count.avg_visited": "567.867", + "search.accuracy.recall_at_10": "0.63903", + "construction.index_build_time_s": "", + "construction.index_quant_time_s.ASH.compute_time_s": "", + "construction.index_quant_time_s.ASH.encoding_time_s": "", + "construction.index_quant_time_s.NVQ.compute_time_s": "" + }, + { + "schema_version": "1", + "run_id": "jdk22-cohere-english-v3-1M-2bit-ash-t8-20261029-023153Z", + "run_uuid": "b57355e7-3ab3-4119-a2ba-b9369571dd27", + "system_id": "941aee9b5750", + "dataset_name": "cohere-english-v3-1M", + "M": "32", + "efConstruction": "100", + "neighborOverflow": "1.2", + "addHierarchy": "true", + "refineFinalGraph": "true", + "feature_set": "[NVQ_VECTORS, FUSED_ASH]", + "build_compressor": "\"AsymmetricHashing[origDim=1024, encodedBits=1064, bodyBits=1024, quantizedDim=512, bitsPerDimension=2, optimizer=ITQ, landmarks=1]\"", + "search_compressor": "None", + "usePruning": "false", + "topK": "10", + "overquery": "2.0", + "rerankK": "20", + "search.throughput.avg_qps": "39833.20097312", + "search.throughput.stddev_qps": "632.0039581039011", + "search.throughput.cv_pct": "1.586626087444959", + "search.count.avg_visited": "812.9255", + "search.accuracy.recall_at_10": "0.81995", + "construction.index_build_time_s": "", + "construction.index_quant_time_s.ASH.compute_time_s": "", + "construction.index_quant_time_s.ASH.encoding_time_s": "", + "construction.index_quant_time_s.NVQ.compute_time_s": "" + } + ], + "throughput_samples": [ + { + "sample": "Test-0", + "queries": 50000, + "seconds": 1.098549 + }, + { + "sample": "Test-1", + "queries": 50000, + "seconds": 1.043299 + }, + { + "sample": "Test-2", + "queries": 60000, + "seconds": 1.203902 + }, + { + "sample": "Test-0", + "queries": 40000, + "seconds": 1.020939 + }, + { + "sample": "Test-1", + "queries": 50000, + "seconds": 1.236365 + }, + { + "sample": "Test-2", + "queries": 40000, + "seconds": 1.003038 + } + ], + "finished": "2026-10-29T02:32:15.573889+00:00" + } + }, + "index_files": [ + { + "path": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk22-cohere-english-v3-1M-2bit-ash/index_cache/graph_cohere_english_v3_1M_FUSED_ASH_NVQ_VECTORS_M32_ef100_of1.2_alpha1.2_H1_R1_AsymmetricHashing_origDim_1024_encodedBits_1064_bodyBits_1024_quantizedDim_512_bitsPerDimension_2_optimizer_ITQ_landmarks_1_", + "bytes": 5488881546 + } + ], + "finished": "2026-10-29T02:32:15.574452+00:00" + }, + { + "dataset": "cohere-english-v3-1M", + "display_name": "Cohere", + "original_dimensions": "1024", + "projected_ash_dimensions": "512", + "base_vectors": "1000000", + "queries": "10000", + "ash_bits": "4", + "algorithm": "ASH", + "code_bytes": "261", + "ash_encoded_bits": "2088", + "pq_m": "", + "config": "cohere-english-v3-1M-4bit-budget-ash.yml", + "jdk": "22", + "label": "jdk22-cohere-english-v3-1M-4bit-ash", + "path": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk22-cohere-english-v3-1M-4bit-ash", + "source": "507b74e7ef8a0e12fcfe402deb4bf758c65758d5", + "started": "2026-10-29T02:32:15.577584+00:00", + "phases": { + "build": { + "elapsed_process_seconds": 66.18678959459066, + "log": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/logs/jdk22-cohere-english-v3-1M-4bit-ash-build.log", + "csv": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk22-cohere-english-v3-1M-4bit-ash/logging/jdk22-cohere-english-v3-1M-4bit-ash-build-20261029-023216Z/experiments.csv", + "rows": [ + { + "schema_version": "1", + "run_id": "jdk22-cohere-english-v3-1M-4bit-ash-build-20261029-023216Z", + "run_uuid": "e2881a55-6ac5-489d-b4fc-053f77441716", + "system_id": "941aee9b5750", + "dataset_name": "cohere-english-v3-1M", + "M": "32", + "efConstruction": "100", + "neighborOverflow": "1.2", + "addHierarchy": "true", + "refineFinalGraph": "true", + "feature_set": "[NVQ_VECTORS, FUSED_ASH]", + "build_compressor": "\"AsymmetricHashing[origDim=1024, encodedBits=2088, bodyBits=2048, quantizedDim=512, bitsPerDimension=4, optimizer=ITQ, landmarks=1]\"", + "search_compressor": "None", + "usePruning": "false", + "topK": "10", + "overquery": "1.0", + "rerankK": "10", + "search.accuracy.recall_at_10": "0.70693", + "construction.index_build_time_s": "49.729920663", + "construction.index_quant_time_s.ASH.compute_time_s": "6.624667012", + "construction.index_quant_time_s.ASH.encoding_time_s": "2.166285985", + "construction.index_quant_time_s.NVQ.compute_time_s": "0.488109724" + } + ], + "throughput_samples": [], + "finished": "2026-10-29T02:33:21.765393+00:00" + }, + "t1": { + "elapsed_process_seconds": 68.6147497491911, + "log": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/logs/jdk22-cohere-english-v3-1M-4bit-ash-t1.log", + "csv": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk22-cohere-english-v3-1M-4bit-ash/logging/jdk22-cohere-english-v3-1M-4bit-ash-t1-20261029-023322Z/experiments.csv", + "rows": [ + { + "schema_version": "1", + "run_id": "jdk22-cohere-english-v3-1M-4bit-ash-t1-20261029-023322Z", + "run_uuid": "fbdbc373-5904-4bf9-abe0-e4a74d72f25c", + "system_id": "941aee9b5750", + "dataset_name": "cohere-english-v3-1M", + "M": "32", + "efConstruction": "100", + "neighborOverflow": "1.2", + "addHierarchy": "true", + "refineFinalGraph": "true", + "feature_set": "[NVQ_VECTORS, FUSED_ASH]", + "build_compressor": "\"AsymmetricHashing[origDim=1024, encodedBits=2088, bodyBits=2048, quantizedDim=512, bitsPerDimension=4, optimizer=ITQ, landmarks=1]\"", + "search_compressor": "None", + "usePruning": "false", + "topK": "10", + "overquery": "1.0", + "rerankK": "10", + "search.throughput.avg_qps": "5459.5384791205415", + "search.throughput.stddev_qps": "302.9731487020324", + "search.throughput.cv_pct": "5.549427847440271", + "search.count.avg_visited": "540.8918", + "search.accuracy.recall_at_10": "0.70693", + "construction.index_build_time_s": "", + "construction.index_quant_time_s.ASH.compute_time_s": "", + "construction.index_quant_time_s.ASH.encoding_time_s": "", + "construction.index_quant_time_s.NVQ.compute_time_s": "" + }, + { + "schema_version": "1", + "run_id": "jdk22-cohere-english-v3-1M-4bit-ash-t1-20261029-023322Z", + "run_uuid": "fbdbc373-5904-4bf9-abe0-e4a74d72f25c", + "system_id": "941aee9b5750", + "dataset_name": "cohere-english-v3-1M", + "M": "32", + "efConstruction": "100", + "neighborOverflow": "1.2", + "addHierarchy": "true", + "refineFinalGraph": "true", + "feature_set": "[NVQ_VECTORS, FUSED_ASH]", + "build_compressor": "\"AsymmetricHashing[origDim=1024, encodedBits=2088, bodyBits=2048, quantizedDim=512, bitsPerDimension=4, optimizer=ITQ, landmarks=1]\"", + "search_compressor": "None", + "usePruning": "false", + "topK": "10", + "overquery": "2.0", + "rerankK": "20", + "search.throughput.avg_qps": "4609.658123480801", + "search.throughput.stddev_qps": "55.2745257320803", + "search.throughput.cv_pct": "1.1991024985241623", + "search.count.avg_visited": "770.202", + "search.accuracy.recall_at_10": "0.83397", + "construction.index_build_time_s": "", + "construction.index_quant_time_s.ASH.compute_time_s": "", + "construction.index_quant_time_s.ASH.encoding_time_s": "", + "construction.index_quant_time_s.NVQ.compute_time_s": "" + } + ], + "throughput_samples": [ + { + "sample": "Test-0", + "queries": 10000, + "seconds": 1.950538 + }, + { + "sample": "Test-1", + "queries": 10000, + "seconds": 1.748415 + }, + { + "sample": "Test-2", + "queries": 10000, + "seconds": 1.807548 + }, + { + "sample": "Test-0", + "queries": 10000, + "seconds": 2.179653 + }, + { + "sample": "Test-1", + "queries": 10000, + "seconds": 2.14018 + }, + { + "sample": "Test-2", + "queries": 10000, + "seconds": 2.188862 + } + ], + "finished": "2026-10-29T02:34:30.389781+00:00" + }, + "t8": { + "elapsed_process_seconds": 24.113950551487505, + "log": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/logs/jdk22-cohere-english-v3-1M-4bit-ash-t8.log", + "csv": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk22-cohere-english-v3-1M-4bit-ash/logging/jdk22-cohere-english-v3-1M-4bit-ash-t8-20261029-023430Z/experiments.csv", + "rows": [ + { + "schema_version": "1", + "run_id": "jdk22-cohere-english-v3-1M-4bit-ash-t8-20261029-023430Z", + "run_uuid": "ce00b6b0-b5a2-48be-9233-8c09a7078282", + "system_id": "941aee9b5750", + "dataset_name": "cohere-english-v3-1M", + "M": "32", + "efConstruction": "100", + "neighborOverflow": "1.2", + "addHierarchy": "true", + "refineFinalGraph": "true", + "feature_set": "[NVQ_VECTORS, FUSED_ASH]", + "build_compressor": "\"AsymmetricHashing[origDim=1024, encodedBits=2088, bodyBits=2048, quantizedDim=512, bitsPerDimension=4, optimizer=ITQ, landmarks=1]\"", + "search_compressor": "None", + "usePruning": "false", + "topK": "10", + "overquery": "1.0", + "rerankK": "10", + "search.throughput.avg_qps": "41672.84985305652", + "search.throughput.stddev_qps": "708.8527180104267", + "search.throughput.cv_pct": "1.7009941016991315", + "search.count.avg_visited": "540.8918", + "search.accuracy.recall_at_10": "0.70693", + "construction.index_build_time_s": "", + "construction.index_quant_time_s.ASH.compute_time_s": "", + "construction.index_quant_time_s.ASH.encoding_time_s": "", + "construction.index_quant_time_s.NVQ.compute_time_s": "" + }, + { + "schema_version": "1", + "run_id": "jdk22-cohere-english-v3-1M-4bit-ash-t8-20261029-023430Z", + "run_uuid": "ce00b6b0-b5a2-48be-9233-8c09a7078282", + "system_id": "941aee9b5750", + "dataset_name": "cohere-english-v3-1M", + "M": "32", + "efConstruction": "100", + "neighborOverflow": "1.2", + "addHierarchy": "true", + "refineFinalGraph": "true", + "feature_set": "[NVQ_VECTORS, FUSED_ASH]", + "build_compressor": "\"AsymmetricHashing[origDim=1024, encodedBits=2088, bodyBits=2048, quantizedDim=512, bitsPerDimension=4, optimizer=ITQ, landmarks=1]\"", + "search_compressor": "None", + "usePruning": "false", + "topK": "10", + "overquery": "2.0", + "rerankK": "20", + "search.throughput.avg_qps": "35207.00369774443", + "search.throughput.stddev_qps": "556.6259719487867", + "search.throughput.cv_pct": "1.5810092126199524", + "search.count.avg_visited": "770.202", + "search.accuracy.recall_at_10": "0.83397", + "construction.index_build_time_s": "", + "construction.index_quant_time_s.ASH.compute_time_s": "", + "construction.index_quant_time_s.ASH.encoding_time_s": "", + "construction.index_quant_time_s.NVQ.compute_time_s": "" + } + ], + "throughput_samples": [ + { + "sample": "Test-0", + "queries": 50000, + "seconds": 1.215201 + }, + { + "sample": "Test-1", + "queries": 50000, + "seconds": 1.207892 + }, + { + "sample": "Test-2", + "queries": 50000, + "seconds": 1.177062 + }, + { + "sample": "Test-0", + "queries": 40000, + "seconds": 1.138557 + }, + { + "sample": "Test-1", + "queries": 40000, + "seconds": 1.117404 + }, + { + "sample": "Test-2", + "queries": 40000, + "seconds": 1.153018 + } + ], + "finished": "2026-10-29T02:34:54.505208+00:00" + } + }, + "index_files": [ + { + "path": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk22-cohere-english-v3-1M-4bit-ash/index_cache/graph_cohere_english_v3_1M_FUSED_ASH_NVQ_VECTORS_M32_ef100_of1.2_alpha1.2_H1_R1_AsymmetricHashing_origDim_1024_encodedBits_2088_bodyBits_2048_quantizedDim_512_bitsPerDimension_4_optimizer_ITQ_landmarks_1_", + "bytes": 9588877024 + } + ], + "finished": "2026-10-29T02:34:54.505808+00:00" + }, + { + "dataset": "ada002-1M", + "display_name": "Ada002", + "original_dimensions": "1536", + "projected_ash_dimensions": "768", + "base_vectors": "982790", + "queries": "10000", + "ash_bits": "2", + "algorithm": "ASH", + "code_bytes": "197", + "ash_encoded_bits": "1576", + "pq_m": "", + "config": "ada002-1M-2bit-budget-ash.yml", + "jdk": "22", + "label": "jdk22-ada002-1M-2bit-ash", + "path": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk22-ada002-1M-2bit-ash", + "source": "507b74e7ef8a0e12fcfe402deb4bf758c65758d5", + "started": "2026-10-29T02:34:54.510224+00:00", + "phases": { + "build": { + "elapsed_process_seconds": 81.39980881009251, + "log": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/logs/jdk22-ada002-1M-2bit-ash-build.log", + "csv": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk22-ada002-1M-2bit-ash/logging/jdk22-ada002-1M-2bit-ash-build-20261029-023454Z/experiments.csv", + "rows": [ + { + "schema_version": "1", + "run_id": "jdk22-ada002-1M-2bit-ash-build-20261029-023454Z", + "run_uuid": "4904a028-2365-4c2e-af75-012ece38c338", + "system_id": "941aee9b5750", + "dataset_name": "ada002-1M", + "M": "32", + "efConstruction": "100", + "neighborOverflow": "1.2", + "addHierarchy": "true", + "refineFinalGraph": "true", + "feature_set": "[NVQ_VECTORS, FUSED_ASH]", + "build_compressor": "\"AsymmetricHashing[origDim=1536, encodedBits=1576, bodyBits=1536, quantizedDim=768, bitsPerDimension=2, optimizer=ITQ, landmarks=1]\"", + "search_compressor": "None", + "usePruning": "false", + "topK": "10", + "overquery": "1.0", + "rerankK": "10", + "search.accuracy.recall_at_10": "0.7888", + "construction.index_build_time_s": "49.604614259", + "construction.index_quant_time_s.ASH.compute_time_s": "13.7121569", + "construction.index_quant_time_s.ASH.encoding_time_s": "7.911204988", + "construction.index_quant_time_s.NVQ.compute_time_s": "0.598817294" + } + ], + "throughput_samples": [], + "finished": "2026-10-29T02:36:15.911669+00:00" + }, + "t1": { + "elapsed_process_seconds": 137.43477927148342, + "log": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/logs/jdk22-ada002-1M-2bit-ash-t1.log", + "csv": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk22-ada002-1M-2bit-ash/logging/jdk22-ada002-1M-2bit-ash-t1-20261029-023617Z/experiments.csv", + "rows": [ + { + "schema_version": "1", + "run_id": "jdk22-ada002-1M-2bit-ash-t1-20261029-023617Z", + "run_uuid": "70f08959-7a9a-4467-8eda-7649c8913fce", + "system_id": "941aee9b5750", + "dataset_name": "ada002-1M", + "M": "32", + "efConstruction": "100", + "neighborOverflow": "1.2", + "addHierarchy": "true", + "refineFinalGraph": "true", + "feature_set": "[NVQ_VECTORS, FUSED_ASH]", + "build_compressor": "\"AsymmetricHashing[origDim=1536, encodedBits=1576, bodyBits=1536, quantizedDim=768, bitsPerDimension=2, optimizer=ITQ, landmarks=1]\"", + "search_compressor": "None", + "usePruning": "false", + "topK": "10", + "overquery": "1.0", + "rerankK": "10", + "search.throughput.avg_qps": "3301.2900012077635", + "search.throughput.stddev_qps": "172.72057577933134", + "search.throughput.cv_pct": "5.2319116380609465", + "search.count.avg_visited": "501.4557", + "search.accuracy.recall_at_10": "0.7888", + "construction.index_build_time_s": "", + "construction.index_quant_time_s.ASH.compute_time_s": "", + "construction.index_quant_time_s.ASH.encoding_time_s": "", + "construction.index_quant_time_s.NVQ.compute_time_s": "" + }, + { + "schema_version": "1", + "run_id": "jdk22-ada002-1M-2bit-ash-t1-20261029-023617Z", + "run_uuid": "70f08959-7a9a-4467-8eda-7649c8913fce", + "system_id": "941aee9b5750", + "dataset_name": "ada002-1M", + "M": "32", + "efConstruction": "100", + "neighborOverflow": "1.2", + "addHierarchy": "true", + "refineFinalGraph": "true", + "feature_set": "[NVQ_VECTORS, FUSED_ASH]", + "build_compressor": "\"AsymmetricHashing[origDim=1536, encodedBits=1576, bodyBits=1536, quantizedDim=768, bitsPerDimension=2, optimizer=ITQ, landmarks=1]\"", + "search_compressor": "None", + "usePruning": "false", + "topK": "10", + "overquery": "2.0", + "rerankK": "20", + "search.throughput.avg_qps": "2987.2294544484503", + "search.throughput.stddev_qps": "75.4558191294618", + "search.throughput.cv_pct": "2.5259465427771652", + "search.count.avg_visited": "706.5122", + "search.accuracy.recall_at_10": "0.92685", + "construction.index_build_time_s": "", + "construction.index_quant_time_s.ASH.compute_time_s": "", + "construction.index_quant_time_s.ASH.encoding_time_s": "", + "construction.index_quant_time_s.NVQ.compute_time_s": "" + } + ], + "throughput_samples": [ + { + "sample": "Test-0", + "queries": 10000, + "seconds": 3.196809 + }, + { + "sample": "Test-1", + "queries": 10000, + "seconds": 3.02829 + }, + { + "sample": "Test-2", + "queries": 10000, + "seconds": 2.878893 + }, + { + "sample": "Test-0", + "queries": 10000, + "seconds": 3.447542 + }, + { + "sample": "Test-1", + "queries": 10000, + "seconds": 3.308704 + }, + { + "sample": "Test-2", + "queries": 10000, + "seconds": 3.290837 + } + ], + "finished": "2026-10-29T02:38:33.347894+00:00" + }, + "t8": { + "elapsed_process_seconds": 37.58102198410779, + "log": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/logs/jdk22-ada002-1M-2bit-ash-t8.log", + "csv": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk22-ada002-1M-2bit-ash/logging/jdk22-ada002-1M-2bit-ash-t8-20261029-023833Z/experiments.csv", + "rows": [ + { + "schema_version": "1", + "run_id": "jdk22-ada002-1M-2bit-ash-t8-20261029-023833Z", + "run_uuid": "3f818f21-e7d0-48aa-8358-1624dc32f8a3", + "system_id": "941aee9b5750", + "dataset_name": "ada002-1M", + "M": "32", + "efConstruction": "100", + "neighborOverflow": "1.2", + "addHierarchy": "true", + "refineFinalGraph": "true", + "feature_set": "[NVQ_VECTORS, FUSED_ASH]", + "build_compressor": "\"AsymmetricHashing[origDim=1536, encodedBits=1576, bodyBits=1536, quantizedDim=768, bitsPerDimension=2, optimizer=ITQ, landmarks=1]\"", + "search_compressor": "None", + "usePruning": "false", + "topK": "10", + "overquery": "1.0", + "rerankK": "10", + "search.throughput.avg_qps": "24911.127856122304", + "search.throughput.stddev_qps": "756.1189899379334", + "search.throughput.cv_pct": "3.035265983559653", + "search.count.avg_visited": "501.4557", + "search.accuracy.recall_at_10": "0.7888", + "construction.index_build_time_s": "", + "construction.index_quant_time_s.ASH.compute_time_s": "", + "construction.index_quant_time_s.ASH.encoding_time_s": "", + "construction.index_quant_time_s.NVQ.compute_time_s": "" + }, + { + "schema_version": "1", + "run_id": "jdk22-ada002-1M-2bit-ash-t8-20261029-023833Z", + "run_uuid": "3f818f21-e7d0-48aa-8358-1624dc32f8a3", + "system_id": "941aee9b5750", + "dataset_name": "ada002-1M", + "M": "32", + "efConstruction": "100", + "neighborOverflow": "1.2", + "addHierarchy": "true", + "refineFinalGraph": "true", + "feature_set": "[NVQ_VECTORS, FUSED_ASH]", + "build_compressor": "\"AsymmetricHashing[origDim=1536, encodedBits=1576, bodyBits=1536, quantizedDim=768, bitsPerDimension=2, optimizer=ITQ, landmarks=1]\"", + "search_compressor": "None", + "usePruning": "false", + "topK": "10", + "overquery": "2.0", + "rerankK": "20", + "search.throughput.avg_qps": "22127.98341769791", + "search.throughput.stddev_qps": "90.58944642512024", + "search.throughput.cv_pct": "0.40938862215825333", + "search.count.avg_visited": "706.5122", + "search.accuracy.recall_at_10": "0.92685", + "construction.index_build_time_s": "", + "construction.index_quant_time_s.ASH.compute_time_s": "", + "construction.index_quant_time_s.ASH.encoding_time_s": "", + "construction.index_quant_time_s.NVQ.compute_time_s": "" + } + ], + "throughput_samples": [ + { + "sample": "Test-0", + "queries": 30000, + "seconds": 1.247963 + }, + { + "sample": "Test-1", + "queries": 30000, + "seconds": 1.181765 + }, + { + "sample": "Test-2", + "queries": 30000, + "seconds": 1.185375 + }, + { + "sample": "Test-0", + "queries": 30000, + "seconds": 1.350112 + }, + { + "sample": "Test-1", + "queries": 30000, + "seconds": 1.355977 + }, + { + "sample": "Test-2", + "queries": 30000, + "seconds": 1.361204 + } + ], + "finished": "2026-10-29T02:39:10.930443+00:00" + } + }, + "index_files": [ + { + "path": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk22-ada002-1M-2bit-ash/index_cache/graph_ada002_1M_FUSED_ASH_NVQ_VECTORS_M32_ef100_of1.2_alpha1.2_H1_R1_AsymmetricHashing_origDim_1536_encodedBits_1576_bodyBits_1536_quantizedDim_768_bitsPerDimension_2_optimizer_ITQ_landmarks_1_", + "bytes": 7917649911 + } + ], + "finished": "2026-10-29T02:39:10.931050+00:00" + }, + { + "dataset": "ada002-1M", + "display_name": "Ada002", + "original_dimensions": "1536", + "projected_ash_dimensions": "768", + "base_vectors": "982790", + "queries": "10000", + "ash_bits": "4", + "algorithm": "ASH", + "code_bytes": "389", + "ash_encoded_bits": "3112", + "pq_m": "", + "config": "ada002-1M-4bit-budget-ash.yml", + "jdk": "22", + "label": "jdk22-ada002-1M-4bit-ash", + "path": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk22-ada002-1M-4bit-ash", + "source": "507b74e7ef8a0e12fcfe402deb4bf758c65758d5", + "started": "2026-10-29T02:39:10.935661+00:00", + "phases": { + "build": { + "elapsed_process_seconds": 85.24945241492242, + "log": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/logs/jdk22-ada002-1M-4bit-ash-build.log", + "csv": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk22-ada002-1M-4bit-ash/logging/jdk22-ada002-1M-4bit-ash-build-20261029-023911Z/experiments.csv", + "rows": [ + { + "schema_version": "1", + "run_id": "jdk22-ada002-1M-4bit-ash-build-20261029-023911Z", + "run_uuid": "8946de01-da12-4754-8777-82a537627299", + "system_id": "941aee9b5750", + "dataset_name": "ada002-1M", + "M": "32", + "efConstruction": "100", + "neighborOverflow": "1.2", + "addHierarchy": "true", + "refineFinalGraph": "true", + "feature_set": "[NVQ_VECTORS, FUSED_ASH]", + "build_compressor": "\"AsymmetricHashing[origDim=1536, encodedBits=3112, bodyBits=3072, quantizedDim=768, bitsPerDimension=4, optimizer=ITQ, landmarks=1]\"", + "search_compressor": "None", + "usePruning": "false", + "topK": "10", + "overquery": "1.0", + "rerankK": "10", + "search.accuracy.recall_at_10": "0.83231", + "construction.index_build_time_s": "56.9124183", + "construction.index_quant_time_s.ASH.compute_time_s": "10.947159808", + "construction.index_quant_time_s.ASH.encoding_time_s": "7.425704863", + "construction.index_quant_time_s.NVQ.compute_time_s": "0.557878664" + } + ], + "throughput_samples": [], + "finished": "2026-10-29T02:40:36.188986+00:00" + }, + "t1": { + "elapsed_process_seconds": 307.9505597706884, + "log": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/logs/jdk22-ada002-1M-4bit-ash-t1.log", + "csv": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk22-ada002-1M-4bit-ash/logging/jdk22-ada002-1M-4bit-ash-t1-20261029-024037Z/experiments.csv", + "rows": [ + { + "schema_version": "1", + "run_id": "jdk22-ada002-1M-4bit-ash-t1-20261029-024037Z", + "run_uuid": "4fdc9d3d-e0e7-43d2-a9e5-d634ec46cbf6", + "system_id": "941aee9b5750", + "dataset_name": "ada002-1M", + "M": "32", + "efConstruction": "100", + "neighborOverflow": "1.2", + "addHierarchy": "true", + "refineFinalGraph": "true", + "feature_set": "[NVQ_VECTORS, FUSED_ASH]", + "build_compressor": "\"AsymmetricHashing[origDim=1536, encodedBits=3112, bodyBits=3072, quantizedDim=768, bitsPerDimension=4, optimizer=ITQ, landmarks=1]\"", + "search_compressor": "None", + "usePruning": "false", + "topK": "10", + "overquery": "1.0", + "rerankK": "10", + "search.throughput.avg_qps": "2975.630205424497", + "search.throughput.stddev_qps": "207.2121051545478", + "search.throughput.cv_pct": "6.96363764478548", + "search.count.avg_visited": "475.3654", + "search.accuracy.recall_at_10": "0.83231", + "construction.index_build_time_s": "", + "construction.index_quant_time_s.ASH.compute_time_s": "", + "construction.index_quant_time_s.ASH.encoding_time_s": "", + "construction.index_quant_time_s.NVQ.compute_time_s": "" + }, + { + "schema_version": "1", + "run_id": "jdk22-ada002-1M-4bit-ash-t1-20261029-024037Z", + "run_uuid": "4fdc9d3d-e0e7-43d2-a9e5-d634ec46cbf6", + "system_id": "941aee9b5750", + "dataset_name": "ada002-1M", + "M": "32", + "efConstruction": "100", + "neighborOverflow": "1.2", + "addHierarchy": "true", + "refineFinalGraph": "true", + "feature_set": "[NVQ_VECTORS, FUSED_ASH]", + "build_compressor": "\"AsymmetricHashing[origDim=1536, encodedBits=3112, bodyBits=3072, quantizedDim=768, bitsPerDimension=4, optimizer=ITQ, landmarks=1]\"", + "search_compressor": "None", + "usePruning": "false", + "topK": "10", + "overquery": "2.0", + "rerankK": "20", + "search.throughput.avg_qps": "2698.9527131275813", + "search.throughput.stddev_qps": "56.93823605602251", + "search.throughput.cv_pct": "2.109641854007948", + "search.count.avg_visited": "666.273", + "search.accuracy.recall_at_10": "0.92443", + "construction.index_build_time_s": "", + "construction.index_quant_time_s.ASH.compute_time_s": "", + "construction.index_quant_time_s.ASH.encoding_time_s": "", + "construction.index_quant_time_s.NVQ.compute_time_s": "" + } + ], + "throughput_samples": [ + { + "sample": "Test-0", + "queries": 10000, + "seconds": 3.645538 + }, + { + "sample": "Test-1", + "queries": 10000, + "seconds": 3.286067 + }, + { + "sample": "Test-2", + "queries": 10000, + "seconds": 3.184044 + }, + { + "sample": "Test-0", + "queries": 10000, + "seconds": 3.797537 + }, + { + "sample": "Test-1", + "queries": 10000, + "seconds": 3.664373 + }, + { + "sample": "Test-2", + "queries": 10000, + "seconds": 3.656852 + } + ], + "finished": "2026-10-29T02:45:44.142259+00:00" + }, + "t8": { + "elapsed_process_seconds": 51.60719249583781, + "log": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/logs/jdk22-ada002-1M-4bit-ash-t8.log", + "csv": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk22-ada002-1M-4bit-ash/logging/jdk22-ada002-1M-4bit-ash-t8-20261029-024544Z/experiments.csv", + "rows": [ + { + "schema_version": "1", + "run_id": "jdk22-ada002-1M-4bit-ash-t8-20261029-024544Z", + "run_uuid": "2fb21ff9-85df-4469-9c7f-72f5ae897b45", + "system_id": "941aee9b5750", + "dataset_name": "ada002-1M", + "M": "32", + "efConstruction": "100", + "neighborOverflow": "1.2", + "addHierarchy": "true", + "refineFinalGraph": "true", + "feature_set": "[NVQ_VECTORS, FUSED_ASH]", + "build_compressor": "\"AsymmetricHashing[origDim=1536, encodedBits=3112, bodyBits=3072, quantizedDim=768, bitsPerDimension=4, optimizer=ITQ, landmarks=1]\"", + "search_compressor": "None", + "usePruning": "false", + "topK": "10", + "overquery": "1.0", + "rerankK": "10", + "search.throughput.avg_qps": "21870.622987666935", + "search.throughput.stddev_qps": "2054.0557899992486", + "search.throughput.cv_pct": "9.391848559401126", + "search.count.avg_visited": "475.3654", + "search.accuracy.recall_at_10": "0.83231", + "construction.index_build_time_s": "", + "construction.index_quant_time_s.ASH.compute_time_s": "", + "construction.index_quant_time_s.ASH.encoding_time_s": "", + "construction.index_quant_time_s.NVQ.compute_time_s": "" + }, + { + "schema_version": "1", + "run_id": "jdk22-ada002-1M-4bit-ash-t8-20261029-024544Z", + "run_uuid": "2fb21ff9-85df-4469-9c7f-72f5ae897b45", + "system_id": "941aee9b5750", + "dataset_name": "ada002-1M", + "M": "32", + "efConstruction": "100", + "neighborOverflow": "1.2", + "addHierarchy": "true", + "refineFinalGraph": "true", + "feature_set": "[NVQ_VECTORS, FUSED_ASH]", + "build_compressor": "\"AsymmetricHashing[origDim=1536, encodedBits=3112, bodyBits=3072, quantizedDim=768, bitsPerDimension=4, optimizer=ITQ, landmarks=1]\"", + "search_compressor": "None", + "usePruning": "false", + "topK": "10", + "overquery": "2.0", + "rerankK": "20", + "search.throughput.avg_qps": "19258.54676558465", + "search.throughput.stddev_qps": "604.5771747646882", + "search.throughput.cv_pct": "3.139266851874191", + "search.count.avg_visited": "666.273", + "search.accuracy.recall_at_10": "0.92443", + "construction.index_build_time_s": "", + "construction.index_quant_time_s.ASH.compute_time_s": "", + "construction.index_quant_time_s.ASH.encoding_time_s": "", + "construction.index_quant_time_s.NVQ.compute_time_s": "" + } + ], + "throughput_samples": [ + { + "sample": "Test-0", + "queries": 30000, + "seconds": 1.538163 + }, + { + "sample": "Test-1", + "queries": 30000, + "seconds": 1.308847 + }, + { + "sample": "Test-2", + "queries": 30000, + "seconds": 1.293821 + }, + { + "sample": "Test-0", + "queries": 20000, + "seconds": 1.074196 + }, + { + "sample": "Test-1", + "queries": 20000, + "seconds": 1.009078 + }, + { + "sample": "Test-2", + "queries": 20000, + "seconds": 1.034287 + } + ], + "finished": "2026-10-29T02:46:35.751077+00:00" + } + }, + "index_files": [ + { + "path": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk22-ada002-1M-4bit-ash/index_cache/graph_ada002_1M_FUSED_ASH_NVQ_VECTORS_M32_ef100_of1.2_alpha1.2_H1_R1_AsymmetricHashing_origDim_1536_encodedBits_3112_bodyBits_3072_quantizedDim_768_bitsPerDimension_4_optimizer_ITQ_landmarks_1_", + "bytes": 13961799958 + } + ], + "finished": "2026-10-29T02:46:35.751683+00:00" + }, + { + "dataset": "openai-3072-1m", + "display_name": "OpenAI3072", + "original_dimensions": "3072", + "projected_ash_dimensions": "1536", + "base_vectors": "999000", + "queries": "1000", + "ash_bits": "2", + "algorithm": "ASH", + "code_bytes": "389", + "ash_encoded_bits": "3112", + "pq_m": "", + "config": "openai-3072-1m-2bit-budget-ash.yml", + "jdk": "22", + "label": "jdk22-openai-3072-1m-2bit-ash", + "path": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk22-openai-3072-1m-2bit-ash", + "source": "507b74e7ef8a0e12fcfe402deb4bf758c65758d5", + "started": "2026-10-29T02:46:35.756638+00:00", + "phases": { + "build": { + "elapsed_process_seconds": 169.54594611562788, + "log": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/logs/jdk22-openai-3072-1m-2bit-ash-build.log", + "csv": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk22-openai-3072-1m-2bit-ash/logging/jdk22-openai-3072-1m-2bit-ash-build-20261029-024636Z/experiments.csv", + "rows": [ + { + "schema_version": "1", + "run_id": "jdk22-openai-3072-1m-2bit-ash-build-20261029-024636Z", + "run_uuid": "565cf797-ba52-4b52-8c8e-55e293a4d413", + "system_id": "941aee9b5750", + "dataset_name": "openai-3072-1m", + "M": "32", + "efConstruction": "100", + "neighborOverflow": "1.2", + "addHierarchy": "true", + "refineFinalGraph": "true", + "feature_set": "[NVQ_VECTORS, FUSED_ASH]", + "build_compressor": "\"AsymmetricHashing[origDim=3072, encodedBits=3112, bodyBits=3072, quantizedDim=1536, bitsPerDimension=2, optimizer=ITQ, landmarks=1]\"", + "search_compressor": "None", + "usePruning": "false", + "topK": "10", + "overquery": "1.0", + "rerankK": "10", + "search.accuracy.recall_at_10": "0.7792", + "construction.index_build_time_s": "85.71443966", + "construction.index_quant_time_s.ASH.compute_time_s": "41.122194304", + "construction.index_quant_time_s.ASH.encoding_time_s": "24.101615418", + "construction.index_quant_time_s.NVQ.compute_time_s": "1.578240137" + } + ], + "throughput_samples": [], + "finished": "2026-10-29T02:49:25.303579+00:00" + }, + "t1": { + "elapsed_process_seconds": 55.10534284170717, + "log": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/logs/jdk22-openai-3072-1m-2bit-ash-t1.log", + "csv": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk22-openai-3072-1m-2bit-ash/logging/jdk22-openai-3072-1m-2bit-ash-t1-20261029-024926Z/experiments.csv", + "rows": [ + { + "schema_version": "1", + "run_id": "jdk22-openai-3072-1m-2bit-ash-t1-20261029-024926Z", + "run_uuid": "bf1a9dc9-3009-4ad5-9736-234f90bd4ade", + "system_id": "941aee9b5750", + "dataset_name": "openai-3072-1m", + "M": "32", + "efConstruction": "100", + "neighborOverflow": "1.2", + "addHierarchy": "true", + "refineFinalGraph": "true", + "feature_set": "[NVQ_VECTORS, FUSED_ASH]", + "build_compressor": "\"AsymmetricHashing[origDim=3072, encodedBits=3112, bodyBits=3072, quantizedDim=1536, bitsPerDimension=2, optimizer=ITQ, landmarks=1]\"", + "search_compressor": "None", + "usePruning": "false", + "topK": "10", + "overquery": "1.0", + "rerankK": "10", + "search.throughput.avg_qps": "1031.0076970878936", + "search.throughput.stddev_qps": "45.92267428032494", + "search.throughput.cv_pct": "4.454154358889332", + "search.count.avg_visited": "530.783", + "search.accuracy.recall_at_10": "0.7792", + "construction.index_build_time_s": "", + "construction.index_quant_time_s.ASH.compute_time_s": "", + "construction.index_quant_time_s.ASH.encoding_time_s": "", + "construction.index_quant_time_s.NVQ.compute_time_s": "" + }, + { + "schema_version": "1", + "run_id": "jdk22-openai-3072-1m-2bit-ash-t1-20261029-024926Z", + "run_uuid": "bf1a9dc9-3009-4ad5-9736-234f90bd4ade", + "system_id": "941aee9b5750", + "dataset_name": "openai-3072-1m", + "M": "32", + "efConstruction": "100", + "neighborOverflow": "1.2", + "addHierarchy": "true", + "refineFinalGraph": "true", + "feature_set": "[NVQ_VECTORS, FUSED_ASH]", + "build_compressor": "\"AsymmetricHashing[origDim=3072, encodedBits=3112, bodyBits=3072, quantizedDim=1536, bitsPerDimension=2, optimizer=ITQ, landmarks=1]\"", + "search_compressor": "None", + "usePruning": "false", + "topK": "10", + "overquery": "2.0", + "rerankK": "20", + "search.throughput.avg_qps": "1068.223215503725", + "search.throughput.stddev_qps": "5.4895348079781785", + "search.throughput.cv_pct": "0.5138939809868825", + "search.count.avg_visited": "767.32", + "search.accuracy.recall_at_10": "0.898", + "construction.index_build_time_s": "", + "construction.index_quant_time_s.ASH.compute_time_s": "", + "construction.index_quant_time_s.ASH.encoding_time_s": "", + "construction.index_quant_time_s.NVQ.compute_time_s": "" + } + ], + "throughput_samples": [ + { + "sample": "Test-0", + "queries": 1000, + "seconds": 1.000775 + }, + { + "sample": "Test-1", + "queries": 2000, + "seconds": 1.979926 + }, + { + "sample": "Test-2", + "queries": 2000, + "seconds": 1.845599 + }, + { + "sample": "Test-0", + "queries": 2000, + "seconds": 1.882245 + }, + { + "sample": "Test-1", + "queries": 2000, + "seconds": 1.87163 + }, + { + "sample": "Test-2", + "queries": 2000, + "seconds": 1.863027 + } + ], + "finished": "2026-10-29T02:50:20.411001+00:00" + }, + "t8": { + "elapsed_process_seconds": 24.599812522530556, + "log": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/logs/jdk22-openai-3072-1m-2bit-ash-t8.log", + "csv": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk22-openai-3072-1m-2bit-ash/logging/jdk22-openai-3072-1m-2bit-ash-t8-20261029-025020Z/experiments.csv", + "rows": [ + { + "schema_version": "1", + "run_id": "jdk22-openai-3072-1m-2bit-ash-t8-20261029-025020Z", + "run_uuid": "cd240602-c1d0-4cfc-bb42-a468c000885e", + "system_id": "941aee9b5750", + "dataset_name": "openai-3072-1m", + "M": "32", + "efConstruction": "100", + "neighborOverflow": "1.2", + "addHierarchy": "true", + "refineFinalGraph": "true", + "feature_set": "[NVQ_VECTORS, FUSED_ASH]", + "build_compressor": "\"AsymmetricHashing[origDim=3072, encodedBits=3112, bodyBits=3072, quantizedDim=1536, bitsPerDimension=2, optimizer=ITQ, landmarks=1]\"", + "search_compressor": "None", + "usePruning": "false", + "topK": "10", + "overquery": "1.0", + "rerankK": "10", + "search.throughput.avg_qps": "7189.703298060613", + "search.throughput.stddev_qps": "411.1343239182431", + "search.throughput.cv_pct": "5.718376779597353", + "search.count.avg_visited": "530.783", + "search.accuracy.recall_at_10": "0.7792", + "construction.index_build_time_s": "", + "construction.index_quant_time_s.ASH.compute_time_s": "", + "construction.index_quant_time_s.ASH.encoding_time_s": "", + "construction.index_quant_time_s.NVQ.compute_time_s": "" + }, + { + "schema_version": "1", + "run_id": "jdk22-openai-3072-1m-2bit-ash-t8-20261029-025020Z", + "run_uuid": "cd240602-c1d0-4cfc-bb42-a468c000885e", + "system_id": "941aee9b5750", + "dataset_name": "openai-3072-1m", + "M": "32", + "efConstruction": "100", + "neighborOverflow": "1.2", + "addHierarchy": "true", + "refineFinalGraph": "true", + "feature_set": "[NVQ_VECTORS, FUSED_ASH]", + "build_compressor": "\"AsymmetricHashing[origDim=3072, encodedBits=3112, bodyBits=3072, quantizedDim=1536, bitsPerDimension=2, optimizer=ITQ, landmarks=1]\"", + "search_compressor": "None", + "usePruning": "false", + "topK": "10", + "overquery": "2.0", + "rerankK": "20", + "search.throughput.avg_qps": "7037.381528041226", + "search.throughput.stddev_qps": "98.28375598243322", + "search.throughput.cv_pct": "1.3965955318865506", + "search.count.avg_visited": "767.32", + "search.accuracy.recall_at_10": "0.898", + "construction.index_build_time_s": "", + "construction.index_quant_time_s.ASH.compute_time_s": "", + "construction.index_quant_time_s.ASH.encoding_time_s": "", + "construction.index_quant_time_s.NVQ.compute_time_s": "" + } + ], + "throughput_samples": [ + { + "sample": "Test-0", + "queries": 7000, + "seconds": 1.040714 + }, + { + "sample": "Test-1", + "queries": 8000, + "seconds": 1.065217 + }, + { + "sample": "Test-2", + "queries": 8000, + "seconds": 1.090996 + }, + { + "sample": "Test-0", + "queries": 8000, + "seconds": 1.129425 + }, + { + "sample": "Test-1", + "queries": 7000, + "seconds": 1.010896 + }, + { + "sample": "Test-2", + "queries": 8000, + "seconds": 1.126072 + } + ], + "finished": "2026-10-29T02:50:45.013068+00:00" + } + }, + "index_files": [ + { + "path": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk22-openai-3072-1m-2bit-ash/index_cache/graph_openai_3072_1m_FUSED_ASH_NVQ_VECTORS_M32_ef100_of1.2_alpha1.2_H1_R1_AsymmetricHashing_origDim_3072_encodedBits_3112_bodyBits_3072_quantizedDim_1536_bitsPerDimension_2_optimizer_ITQ_landmarks_1_", + "bytes": 15754739676 + } + ], + "finished": "2026-10-29T02:50:45.013634+00:00" + }, + { + "dataset": "openai-3072-1m", + "display_name": "OpenAI3072", + "original_dimensions": "3072", + "projected_ash_dimensions": "1536", + "base_vectors": "999000", + "queries": "1000", + "ash_bits": "4", + "algorithm": "ASH", + "code_bytes": "773", + "ash_encoded_bits": "6184", + "pq_m": "", + "config": "openai-3072-1m-4bit-budget-ash.yml", + "jdk": "22", + "label": "jdk22-openai-3072-1m-4bit-ash", + "path": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk22-openai-3072-1m-4bit-ash", + "source": "507b74e7ef8a0e12fcfe402deb4bf758c65758d5", + "started": "2026-10-29T02:50:45.018363+00:00", + "phases": { + "build": { + "elapsed_process_seconds": 208.43669655919075, + "log": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/logs/jdk22-openai-3072-1m-4bit-ash-build.log", + "csv": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk22-openai-3072-1m-4bit-ash/logging/jdk22-openai-3072-1m-4bit-ash-build-20261029-025045Z/experiments.csv", + "rows": [ + { + "schema_version": "1", + "run_id": "jdk22-openai-3072-1m-4bit-ash-build-20261029-025045Z", + "run_uuid": "45451992-3ee3-4ed4-9fdb-d0141fefc43c", + "system_id": "941aee9b5750", + "dataset_name": "openai-3072-1m", + "M": "32", + "efConstruction": "100", + "neighborOverflow": "1.2", + "addHierarchy": "true", + "refineFinalGraph": "true", + "feature_set": "[NVQ_VECTORS, FUSED_ASH]", + "build_compressor": "\"AsymmetricHashing[origDim=3072, encodedBits=6184, bodyBits=6144, quantizedDim=1536, bitsPerDimension=4, optimizer=ITQ, landmarks=1]\"", + "search_compressor": "None", + "usePruning": "false", + "topK": "10", + "overquery": "1.0", + "rerankK": "10", + "search.accuracy.recall_at_10": "0.7903", + "construction.index_build_time_s": "124.325741592", + "construction.index_quant_time_s.ASH.compute_time_s": "39.53853562", + "construction.index_quant_time_s.ASH.encoding_time_s": "27.206676416", + "construction.index_quant_time_s.NVQ.compute_time_s": "1.118749346" + } + ], + "throughput_samples": [], + "finished": "2026-10-29T02:54:13.456146+00:00" + }, + "t1": { + "elapsed_process_seconds": 57.35175254754722, + "log": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/logs/jdk22-openai-3072-1m-4bit-ash-t1.log", + "csv": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk22-openai-3072-1m-4bit-ash/logging/jdk22-openai-3072-1m-4bit-ash-t1-20261029-025414Z/experiments.csv", + "rows": [ + { + "schema_version": "1", + "run_id": "jdk22-openai-3072-1m-4bit-ash-t1-20261029-025414Z", + "run_uuid": "272d124c-06ae-4833-bc54-2afb6f51c58c", + "system_id": "941aee9b5750", + "dataset_name": "openai-3072-1m", + "M": "32", + "efConstruction": "100", + "neighborOverflow": "1.2", + "addHierarchy": "true", + "refineFinalGraph": "true", + "feature_set": "[NVQ_VECTORS, FUSED_ASH]", + "build_compressor": "\"AsymmetricHashing[origDim=3072, encodedBits=6184, bodyBits=6144, quantizedDim=1536, bitsPerDimension=4, optimizer=ITQ, landmarks=1]\"", + "search_compressor": "None", + "usePruning": "false", + "topK": "10", + "overquery": "1.0", + "rerankK": "10", + "search.throughput.avg_qps": "1016.1509827131623", + "search.throughput.stddev_qps": "48.29108771527011", + "search.throughput.cv_pct": "4.752353590834606", + "search.count.avg_visited": "512.685", + "search.accuracy.recall_at_10": "0.7903", + "construction.index_build_time_s": "", + "construction.index_quant_time_s.ASH.compute_time_s": "", + "construction.index_quant_time_s.ASH.encoding_time_s": "", + "construction.index_quant_time_s.NVQ.compute_time_s": "" + }, + { + "schema_version": "1", + "run_id": "jdk22-openai-3072-1m-4bit-ash-t1-20261029-025414Z", + "run_uuid": "272d124c-06ae-4833-bc54-2afb6f51c58c", + "system_id": "941aee9b5750", + "dataset_name": "openai-3072-1m", + "M": "32", + "efConstruction": "100", + "neighborOverflow": "1.2", + "addHierarchy": "true", + "refineFinalGraph": "true", + "feature_set": "[NVQ_VECTORS, FUSED_ASH]", + "build_compressor": "\"AsymmetricHashing[origDim=3072, encodedBits=6184, bodyBits=6144, quantizedDim=1536, bitsPerDimension=4, optimizer=ITQ, landmarks=1]\"", + "search_compressor": "None", + "usePruning": "false", + "topK": "10", + "overquery": "2.0", + "rerankK": "20", + "search.throughput.avg_qps": "1030.8662536443949", + "search.throughput.stddev_qps": "5.289502308681586", + "search.throughput.cv_pct": "0.5131123741786817", + "search.count.avg_visited": "735.241", + "search.accuracy.recall_at_10": "0.8834", + "construction.index_build_time_s": "", + "construction.index_quant_time_s.ASH.compute_time_s": "", + "construction.index_quant_time_s.ASH.encoding_time_s": "", + "construction.index_quant_time_s.NVQ.compute_time_s": "" + } + ], + "throughput_samples": [ + { + "sample": "Test-0", + "queries": 1000, + "seconds": 1.02091 + }, + { + "sample": "Test-1", + "queries": 2000, + "seconds": 2.00389 + }, + { + "sample": "Test-2", + "queries": 2000, + "seconds": 1.867631 + }, + { + "sample": "Test-0", + "queries": 2000, + "seconds": 1.951248 + }, + { + "sample": "Test-1", + "queries": 2000, + "seconds": 1.937274 + }, + { + "sample": "Test-2", + "queries": 2000, + "seconds": 1.931929 + } + ], + "finished": "2026-10-29T02:55:10.810214+00:00" + }, + "t8": { + "elapsed_process_seconds": 24.646791006438434, + "log": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/logs/jdk22-openai-3072-1m-4bit-ash-t8.log", + "csv": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk22-openai-3072-1m-4bit-ash/logging/jdk22-openai-3072-1m-4bit-ash-t8-20261029-025511Z/experiments.csv", + "rows": [ + { + "schema_version": "1", + "run_id": "jdk22-openai-3072-1m-4bit-ash-t8-20261029-025511Z", + "run_uuid": "79ef4baf-0386-4b63-b687-dfbe2ea6ae1d", + "system_id": "941aee9b5750", + "dataset_name": "openai-3072-1m", + "M": "32", + "efConstruction": "100", + "neighborOverflow": "1.2", + "addHierarchy": "true", + "refineFinalGraph": "true", + "feature_set": "[NVQ_VECTORS, FUSED_ASH]", + "build_compressor": "\"AsymmetricHashing[origDim=3072, encodedBits=6184, bodyBits=6144, quantizedDim=1536, bitsPerDimension=4, optimizer=ITQ, landmarks=1]\"", + "search_compressor": "None", + "usePruning": "false", + "topK": "10", + "overquery": "1.0", + "rerankK": "10", + "search.throughput.avg_qps": "7507.82372210983", + "search.throughput.stddev_qps": "379.60972307236995", + "search.throughput.cv_pct": "5.056188545749353", + "search.count.avg_visited": "512.685", + "search.accuracy.recall_at_10": "0.7903", + "construction.index_build_time_s": "", + "construction.index_quant_time_s.ASH.compute_time_s": "", + "construction.index_quant_time_s.ASH.encoding_time_s": "", + "construction.index_quant_time_s.NVQ.compute_time_s": "" + }, + { + "schema_version": "1", + "run_id": "jdk22-openai-3072-1m-4bit-ash-t8-20261029-025511Z", + "run_uuid": "79ef4baf-0386-4b63-b687-dfbe2ea6ae1d", + "system_id": "941aee9b5750", + "dataset_name": "openai-3072-1m", + "M": "32", + "efConstruction": "100", + "neighborOverflow": "1.2", + "addHierarchy": "true", + "refineFinalGraph": "true", + "feature_set": "[NVQ_VECTORS, FUSED_ASH]", + "build_compressor": "\"AsymmetricHashing[origDim=3072, encodedBits=6184, bodyBits=6144, quantizedDim=1536, bitsPerDimension=4, optimizer=ITQ, landmarks=1]\"", + "search_compressor": "None", + "usePruning": "false", + "topK": "10", + "overquery": "2.0", + "rerankK": "20", + "search.throughput.avg_qps": "7360.509055849552", + "search.throughput.stddev_qps": "197.69692384205354", + "search.throughput.cv_pct": "2.6859137369709454", + "search.count.avg_visited": "735.241", + "search.accuracy.recall_at_10": "0.8834", + "construction.index_build_time_s": "", + "construction.index_quant_time_s.ASH.compute_time_s": "", + "construction.index_quant_time_s.ASH.encoding_time_s": "", + "construction.index_quant_time_s.NVQ.compute_time_s": "" + } + ], + "throughput_samples": [ + { + "sample": "Test-0", + "queries": 8000, + "seconds": 1.129005 + }, + { + "sample": "Test-1", + "queries": 8000, + "seconds": 1.022802 + }, + { + "sample": "Test-2", + "queries": 8000, + "seconds": 1.050429 + }, + { + "sample": "Test-0", + "queries": 8000, + "seconds": 1.11824 + }, + { + "sample": "Test-1", + "queries": 8000, + "seconds": 1.084117 + }, + { + "sample": "Test-2", + "queries": 8000, + "seconds": 1.059862 + } + ], + "finished": "2026-10-29T02:55:35.458471+00:00" + } + }, + "index_files": [ + { + "path": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk22-openai-3072-1m-4bit-ash/index_cache/graph_openai_3072_1m_FUSED_ASH_NVQ_VECTORS_M32_ef100_of1.2_alpha1.2_H1_R1_AsymmetricHashing_origDim_3072_encodedBits_6184_bodyBits_6144_quantizedDim_1536_bitsPerDimension_4_optimizer_ITQ_landmarks_1_", + "bytes": 28042434388 + } + ], + "finished": "2026-10-29T02:55:35.459052+00:00" + }, + { + "dataset": "mpnet-1m", + "display_name": "MPNet", + "original_dimensions": "768", + "projected_ash_dimensions": "384", + "base_vectors": "999812", + "queries": "10000", + "ash_bits": "2", + "algorithm": "ASH", + "code_bytes": "101", + "ash_encoded_bits": "808", + "pq_m": "", + "config": "mpnet-1m-2bit-budget-ash.yml", + "jdk": "25", + "label": "jdk25-mpnet-1m-2bit-ash", + "path": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk25-mpnet-1m-2bit-ash", + "source": "507b74e7ef8a0e12fcfe402deb4bf758c65758d5", + "started": "2026-10-29T02:55:35.463544+00:00", + "phases": { + "build": { + "elapsed_process_seconds": 43.574435736984015, + "log": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/logs/jdk25-mpnet-1m-2bit-ash-build.log", + "csv": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk25-mpnet-1m-2bit-ash/logging/jdk25-mpnet-1m-2bit-ash-build-20261029-025536Z/experiments.csv", + "rows": [ + { + "schema_version": "1", + "run_id": "jdk25-mpnet-1m-2bit-ash-build-20261029-025536Z", + "run_uuid": "2e08a76a-5d9a-493b-8f93-48294f83637d", + "system_id": "eafa45791612", + "dataset_name": "mpnet-1m", + "M": "32", + "efConstruction": "100", + "neighborOverflow": "1.2", + "addHierarchy": "true", + "refineFinalGraph": "true", + "feature_set": "[NVQ_VECTORS, FUSED_ASH]", + "build_compressor": "\"AsymmetricHashing[origDim=768, encodedBits=808, bodyBits=768, quantizedDim=384, bitsPerDimension=2, optimizer=ITQ, landmarks=1]\"", + "search_compressor": "None", + "usePruning": "false", + "topK": "10", + "overquery": "1.0", + "rerankK": "10", + "search.accuracy.recall_at_10": "0.82017", + "construction.index_build_time_s": "31.631570707", + "construction.index_quant_time_s.ASH.compute_time_s": "4.795862", + "construction.index_quant_time_s.ASH.encoding_time_s": "1.001739035", + "construction.index_quant_time_s.NVQ.compute_time_s": "0.364870626" + } + ], + "throughput_samples": [], + "finished": "2026-10-29T02:56:19.039914+00:00" + }, + "t1": { + "elapsed_process_seconds": 49.28656546585262, + "log": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/logs/jdk25-mpnet-1m-2bit-ash-t1.log", + "csv": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk25-mpnet-1m-2bit-ash/logging/jdk25-mpnet-1m-2bit-ash-t1-20261029-025620Z/experiments.csv", + "rows": [ + { + "schema_version": "1", + "run_id": "jdk25-mpnet-1m-2bit-ash-t1-20261029-025620Z", + "run_uuid": "5d3e6837-707d-4551-9052-9b697a01a619", + "system_id": "eafa45791612", + "dataset_name": "mpnet-1m", + "M": "32", + "efConstruction": "100", + "neighborOverflow": "1.2", + "addHierarchy": "true", + "refineFinalGraph": "true", + "feature_set": "[NVQ_VECTORS, FUSED_ASH]", + "build_compressor": "\"AsymmetricHashing[origDim=768, encodedBits=808, bodyBits=768, quantizedDim=384, bitsPerDimension=2, optimizer=ITQ, landmarks=1]\"", + "search_compressor": "None", + "usePruning": "false", + "topK": "10", + "overquery": "1.0", + "rerankK": "10", + "search.throughput.avg_qps": "10357.12246572871", + "search.throughput.stddev_qps": "15.687831200532978", + "search.throughput.cv_pct": "0.15146901325578957", + "search.count.avg_visited": "455.8008", + "search.accuracy.recall_at_10": "0.82017", + "construction.index_build_time_s": "", + "construction.index_quant_time_s.ASH.compute_time_s": "", + "construction.index_quant_time_s.ASH.encoding_time_s": "", + "construction.index_quant_time_s.NVQ.compute_time_s": "" + }, + { + "schema_version": "1", + "run_id": "jdk25-mpnet-1m-2bit-ash-t1-20261029-025620Z", + "run_uuid": "5d3e6837-707d-4551-9052-9b697a01a619", + "system_id": "eafa45791612", + "dataset_name": "mpnet-1m", + "M": "32", + "efConstruction": "100", + "neighborOverflow": "1.2", + "addHierarchy": "true", + "refineFinalGraph": "true", + "feature_set": "[NVQ_VECTORS, FUSED_ASH]", + "build_compressor": "\"AsymmetricHashing[origDim=768, encodedBits=808, bodyBits=768, quantizedDim=384, bitsPerDimension=2, optimizer=ITQ, landmarks=1]\"", + "search_compressor": "None", + "usePruning": "false", + "topK": "10", + "overquery": "2.0", + "rerankK": "20", + "search.throughput.avg_qps": "8282.467440755714", + "search.throughput.stddev_qps": "24.053931479168885", + "search.throughput.cv_pct": "0.2904198736816784", + "search.count.avg_visited": "636.613", + "search.accuracy.recall_at_10": "0.93971", + "construction.index_build_time_s": "", + "construction.index_quant_time_s.ASH.compute_time_s": "", + "construction.index_quant_time_s.ASH.encoding_time_s": "", + "construction.index_quant_time_s.NVQ.compute_time_s": "" + } + ], + "throughput_samples": [ + { + "sample": "Test-0", + "queries": 20000, + "seconds": 1.934062 + }, + { + "sample": "Test-1", + "queries": 20000, + "seconds": 1.928222 + }, + { + "sample": "Test-2", + "queries": 20000, + "seconds": 1.93084 + }, + { + "sample": "Test-0", + "queries": 10000, + "seconds": 1.211122 + }, + { + "sample": "Test-1", + "queries": 10000, + "seconds": 1.204165 + }, + { + "sample": "Test-2", + "queries": 10000, + "seconds": 1.206843 + } + ], + "finished": "2026-10-29T02:57:08.327785+00:00" + }, + "t8": { + "elapsed_process_seconds": 19.72120023984462, + "log": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/logs/jdk25-mpnet-1m-2bit-ash-t8.log", + "csv": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk25-mpnet-1m-2bit-ash/logging/jdk25-mpnet-1m-2bit-ash-t8-20261029-025708Z/experiments.csv", + "rows": [ + { + "schema_version": "1", + "run_id": "jdk25-mpnet-1m-2bit-ash-t8-20261029-025708Z", + "run_uuid": "8b03ec06-a11a-4dd9-8f41-bc0899ec533e", + "system_id": "eafa45791612", + "dataset_name": "mpnet-1m", + "M": "32", + "efConstruction": "100", + "neighborOverflow": "1.2", + "addHierarchy": "true", + "refineFinalGraph": "true", + "feature_set": "[NVQ_VECTORS, FUSED_ASH]", + "build_compressor": "\"AsymmetricHashing[origDim=768, encodedBits=808, bodyBits=768, quantizedDim=384, bitsPerDimension=2, optimizer=ITQ, landmarks=1]\"", + "search_compressor": "None", + "usePruning": "false", + "topK": "10", + "overquery": "1.0", + "rerankK": "10", + "search.throughput.avg_qps": "73840.6240900836", + "search.throughput.stddev_qps": "3301.781445008702", + "search.throughput.cv_pct": "4.471497208610557", + "search.count.avg_visited": "455.8008", + "search.accuracy.recall_at_10": "0.82017", + "construction.index_build_time_s": "", + "construction.index_quant_time_s.ASH.compute_time_s": "", + "construction.index_quant_time_s.ASH.encoding_time_s": "", + "construction.index_quant_time_s.NVQ.compute_time_s": "" + }, + { + "schema_version": "1", + "run_id": "jdk25-mpnet-1m-2bit-ash-t8-20261029-025708Z", + "run_uuid": "8b03ec06-a11a-4dd9-8f41-bc0899ec533e", + "system_id": "eafa45791612", + "dataset_name": "mpnet-1m", + "M": "32", + "efConstruction": "100", + "neighborOverflow": "1.2", + "addHierarchy": "true", + "refineFinalGraph": "true", + "feature_set": "[NVQ_VECTORS, FUSED_ASH]", + "build_compressor": "\"AsymmetricHashing[origDim=768, encodedBits=808, bodyBits=768, quantizedDim=384, bitsPerDimension=2, optimizer=ITQ, landmarks=1]\"", + "search_compressor": "None", + "usePruning": "false", + "topK": "10", + "overquery": "2.0", + "rerankK": "20", + "search.throughput.avg_qps": "60433.593076138226", + "search.throughput.stddev_qps": "1524.9801313351495", + "search.throughput.cv_pct": "2.5233980865805528", + "search.count.avg_visited": "636.613", + "search.accuracy.recall_at_10": "0.93971", + "construction.index_build_time_s": "", + "construction.index_quant_time_s.ASH.compute_time_s": "", + "construction.index_quant_time_s.ASH.encoding_time_s": "", + "construction.index_quant_time_s.NVQ.compute_time_s": "" + } + ], + "throughput_samples": [ + { + "sample": "Test-0", + "queries": 80000, + "seconds": 1.127333 + }, + { + "sample": "Test-1", + "queries": 80000, + "seconds": 1.094211 + }, + { + "sample": "Test-2", + "queries": 80000, + "seconds": 1.03298 + }, + { + "sample": "Test-0", + "queries": 70000, + "seconds": 1.139031 + }, + { + "sample": "Test-1", + "queries": 60000, + "seconds": 1.022482 + }, + { + "sample": "Test-2", + "queries": 70000, + "seconds": 1.144459 + } + ], + "finished": "2026-10-29T02:57:28.050575+00:00" + } + }, + "index_files": [ + { + "path": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk25-mpnet-1m-2bit-ash/index_cache/graph_mpnet_1m_FUSED_ASH_NVQ_VECTORS_M32_ef100_of1.2_alpha1.2_H1_R1_AsymmetricHashing_origDim_768_encodedBits_808_bodyBits_768_quantizedDim_384_bitsPerDimension_2_optimizer_ITQ_landmarks_1_", + "bytes": 4205249625 + } + ], + "finished": "2026-10-29T02:57:28.051165+00:00" + }, + { + "dataset": "mpnet-1m", + "display_name": "MPNet", + "original_dimensions": "768", + "projected_ash_dimensions": "384", + "base_vectors": "999812", + "queries": "10000", + "ash_bits": "4", + "algorithm": "ASH", + "code_bytes": "197", + "ash_encoded_bits": "1576", + "pq_m": "", + "config": "mpnet-1m-4bit-budget-ash.yml", + "jdk": "25", + "label": "jdk25-mpnet-1m-4bit-ash", + "path": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk25-mpnet-1m-4bit-ash", + "source": "507b74e7ef8a0e12fcfe402deb4bf758c65758d5", + "started": "2026-10-29T02:57:28.056368+00:00", + "phases": { + "build": { + "elapsed_process_seconds": 54.83608163334429, + "log": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/logs/jdk25-mpnet-1m-4bit-ash-build.log", + "csv": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk25-mpnet-1m-4bit-ash/logging/jdk25-mpnet-1m-4bit-ash-build-20261029-025728Z/experiments.csv", + "rows": [ + { + "schema_version": "1", + "run_id": "jdk25-mpnet-1m-4bit-ash-build-20261029-025728Z", + "run_uuid": "b3d374b8-3e2b-4449-93fa-6b96c0c5b24b", + "system_id": "eafa45791612", + "dataset_name": "mpnet-1m", + "M": "32", + "efConstruction": "100", + "neighborOverflow": "1.2", + "addHierarchy": "true", + "refineFinalGraph": "true", + "feature_set": "[NVQ_VECTORS, FUSED_ASH]", + "build_compressor": "\"AsymmetricHashing[origDim=768, encodedBits=1576, bodyBits=1536, quantizedDim=384, bitsPerDimension=4, optimizer=ITQ, landmarks=1]\"", + "search_compressor": "None", + "usePruning": "false", + "topK": "10", + "overquery": "1.0", + "rerankK": "10", + "search.accuracy.recall_at_10": "0.86293", + "construction.index_build_time_s": "43.289757796", + "construction.index_quant_time_s.ASH.compute_time_s": "4.454340959", + "construction.index_quant_time_s.ASH.encoding_time_s": "1.201650817", + "construction.index_quant_time_s.NVQ.compute_time_s": "0.358496614" + } + ], + "throughput_samples": [], + "finished": "2026-10-29T02:58:22.893449+00:00" + }, + "t1": { + "elapsed_process_seconds": 55.04718812182546, + "log": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/logs/jdk25-mpnet-1m-4bit-ash-t1.log", + "csv": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk25-mpnet-1m-4bit-ash/logging/jdk25-mpnet-1m-4bit-ash-t1-20261029-025823Z/experiments.csv", + "rows": [ + { + "schema_version": "1", + "run_id": "jdk25-mpnet-1m-4bit-ash-t1-20261029-025823Z", + "run_uuid": "b16c09e5-6408-4442-b9ce-026bbc48c28a", + "system_id": "eafa45791612", + "dataset_name": "mpnet-1m", + "M": "32", + "efConstruction": "100", + "neighborOverflow": "1.2", + "addHierarchy": "true", + "refineFinalGraph": "true", + "feature_set": "[NVQ_VECTORS, FUSED_ASH]", + "build_compressor": "\"AsymmetricHashing[origDim=768, encodedBits=1576, bodyBits=1536, quantizedDim=384, bitsPerDimension=4, optimizer=ITQ, landmarks=1]\"", + "search_compressor": "None", + "usePruning": "false", + "topK": "10", + "overquery": "1.0", + "rerankK": "10", + "search.throughput.avg_qps": "9153.966952460185", + "search.throughput.stddev_qps": "317.24481664160703", + "search.throughput.cv_pct": "3.465653943139324", + "search.count.avg_visited": "432.9807", + "search.accuracy.recall_at_10": "0.86293", + "construction.index_build_time_s": "", + "construction.index_quant_time_s.ASH.compute_time_s": "", + "construction.index_quant_time_s.ASH.encoding_time_s": "", + "construction.index_quant_time_s.NVQ.compute_time_s": "" + }, + { + "schema_version": "1", + "run_id": "jdk25-mpnet-1m-4bit-ash-t1-20261029-025823Z", + "run_uuid": "b16c09e5-6408-4442-b9ce-026bbc48c28a", + "system_id": "eafa45791612", + "dataset_name": "mpnet-1m", + "M": "32", + "efConstruction": "100", + "neighborOverflow": "1.2", + "addHierarchy": "true", + "refineFinalGraph": "true", + "feature_set": "[NVQ_VECTORS, FUSED_ASH]", + "build_compressor": "\"AsymmetricHashing[origDim=768, encodedBits=1576, bodyBits=1536, quantizedDim=384, bitsPerDimension=4, optimizer=ITQ, landmarks=1]\"", + "search_compressor": "None", + "usePruning": "false", + "topK": "10", + "overquery": "2.0", + "rerankK": "20", + "search.throughput.avg_qps": "7114.7162102085695", + "search.throughput.stddev_qps": "46.99403949422588", + "search.throughput.cv_pct": "0.6605188191033727", + "search.count.avg_visited": "598.6739", + "search.accuracy.recall_at_10": "0.94033", + "construction.index_build_time_s": "", + "construction.index_quant_time_s.ASH.compute_time_s": "", + "construction.index_quant_time_s.ASH.encoding_time_s": "", + "construction.index_quant_time_s.NVQ.compute_time_s": "" + } + ], + "throughput_samples": [ + { + "sample": "Test-0", + "queries": 10000, + "seconds": 1.136732 + }, + { + "sample": "Test-1", + "queries": 10000, + "seconds": 1.063359 + }, + { + "sample": "Test-2", + "queries": 10000, + "seconds": 1.079845 + }, + { + "sample": "Test-0", + "queries": 10000, + "seconds": 1.416096 + }, + { + "sample": "Test-1", + "queries": 10000, + "seconds": 1.40226 + }, + { + "sample": "Test-2", + "queries": 10000, + "seconds": 1.39838 + } + ], + "finished": "2026-10-29T02:59:17.941972+00:00" + }, + "t8": { + "elapsed_process_seconds": 20.877501280047, + "log": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/logs/jdk25-mpnet-1m-4bit-ash-t8.log", + "csv": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk25-mpnet-1m-4bit-ash/logging/jdk25-mpnet-1m-4bit-ash-t8-20261029-025918Z/experiments.csv", + "rows": [ + { + "schema_version": "1", + "run_id": "jdk25-mpnet-1m-4bit-ash-t8-20261029-025918Z", + "run_uuid": "f1e80eeb-5e67-4b34-a4db-6c8230e45ca3", + "system_id": "eafa45791612", + "dataset_name": "mpnet-1m", + "M": "32", + "efConstruction": "100", + "neighborOverflow": "1.2", + "addHierarchy": "true", + "refineFinalGraph": "true", + "feature_set": "[NVQ_VECTORS, FUSED_ASH]", + "build_compressor": "\"AsymmetricHashing[origDim=768, encodedBits=1576, bodyBits=1536, quantizedDim=384, bitsPerDimension=4, optimizer=ITQ, landmarks=1]\"", + "search_compressor": "None", + "usePruning": "false", + "topK": "10", + "overquery": "1.0", + "rerankK": "10", + "search.throughput.avg_qps": "67390.32073511016", + "search.throughput.stddev_qps": "2298.9017978117477", + "search.throughput.cv_pct": "3.4113234255821347", + "search.count.avg_visited": "432.9807", + "search.accuracy.recall_at_10": "0.86293", + "construction.index_build_time_s": "", + "construction.index_quant_time_s.ASH.compute_time_s": "", + "construction.index_quant_time_s.ASH.encoding_time_s": "", + "construction.index_quant_time_s.NVQ.compute_time_s": "" + }, + { + "schema_version": "1", + "run_id": "jdk25-mpnet-1m-4bit-ash-t8-20261029-025918Z", + "run_uuid": "f1e80eeb-5e67-4b34-a4db-6c8230e45ca3", + "system_id": "eafa45791612", + "dataset_name": "mpnet-1m", + "M": "32", + "efConstruction": "100", + "neighborOverflow": "1.2", + "addHierarchy": "true", + "refineFinalGraph": "true", + "feature_set": "[NVQ_VECTORS, FUSED_ASH]", + "build_compressor": "\"AsymmetricHashing[origDim=768, encodedBits=1576, bodyBits=1536, quantizedDim=384, bitsPerDimension=4, optimizer=ITQ, landmarks=1]\"", + "search_compressor": "None", + "usePruning": "false", + "topK": "10", + "overquery": "2.0", + "rerankK": "20", + "search.throughput.avg_qps": "54357.67614973744", + "search.throughput.stddev_qps": "353.40026548673944", + "search.throughput.cv_pct": "0.6501386566144558", + "search.count.avg_visited": "598.6739", + "search.accuracy.recall_at_10": "0.94033", + "construction.index_build_time_s": "", + "construction.index_quant_time_s.ASH.compute_time_s": "", + "construction.index_quant_time_s.ASH.encoding_time_s": "", + "construction.index_quant_time_s.NVQ.compute_time_s": "" + } + ], + "throughput_samples": [ + { + "sample": "Test-0", + "queries": 70000, + "seconds": 1.080794 + }, + { + "sample": "Test-1", + "queries": 70000, + "seconds": 1.024154 + }, + { + "sample": "Test-2", + "queries": 70000, + "seconds": 1.01369 + }, + { + "sample": "Test-0", + "queries": 60000, + "seconds": 1.101287 + }, + { + "sample": "Test-1", + "queries": 60000, + "seconds": 1.111956 + }, + { + "sample": "Test-2", + "queries": 60000, + "seconds": 1.098251 + } + ], + "finished": "2026-10-29T02:59:38.820953+00:00" + } + }, + "index_files": [ + { + "path": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk25-mpnet-1m-4bit-ash/index_cache/graph_mpnet_1m_FUSED_ASH_NVQ_VECTORS_M32_ef100_of1.2_alpha1.2_H1_R1_AsymmetricHashing_origDim_768_encodedBits_1576_bodyBits_1536_quantizedDim_384_bitsPerDimension_4_optimizer_ITQ_landmarks_1_", + "bytes": 7279677824 + } + ], + "finished": "2026-10-29T02:59:38.821529+00:00" + }, + { + "dataset": "mpnet-1m", + "display_name": "MPNet", + "original_dimensions": "768", + "projected_ash_dimensions": "384", + "base_vectors": "999812", + "queries": "10000", + "ash_bits": "4", + "algorithm": "PQ", + "code_bytes": "197", + "ash_encoded_bits": "", + "pq_m": "197", + "config": "mpnet-1m-4bit-budget-pq.yml", + "jdk": "25", + "label": "jdk25-mpnet-1m-4bit-pq", + "path": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk25-mpnet-1m-4bit-pq", + "source": "507b74e7ef8a0e12fcfe402deb4bf758c65758d5", + "started": "2026-10-29T02:59:38.826426+00:00", + "phases": { + "build": { + "elapsed_process_seconds": 58.950520511716604, + "log": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/logs/jdk25-mpnet-1m-4bit-pq-build.log", + "csv": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk25-mpnet-1m-4bit-pq/logging/jdk25-mpnet-1m-4bit-pq-build-20261029-025939Z/experiments.csv", + "rows": [ + { + "schema_version": "1", + "run_id": "jdk25-mpnet-1m-4bit-pq-build-20261029-025939Z", + "run_uuid": "2f8c19c7-bbdb-4b8a-b0a2-e379ecbae4e1", + "system_id": "eafa45791612", + "dataset_name": "mpnet-1m", + "M": "32", + "efConstruction": "100", + "neighborOverflow": "1.2", + "addHierarchy": "true", + "refineFinalGraph": "true", + "feature_set": "[NVQ_VECTORS, FUSED_PQ]", + "build_compressor": "\"ProductQuantization(M=197, clusters=256, centered=false)\"", + "search_compressor": "None", + "usePruning": "false", + "topK": "10", + "overquery": "1.0", + "rerankK": "10", + "search.accuracy.recall_at_10": "0.80751", + "construction.index_build_time_s": "48.549881485", + "construction.index_quant_time_s.NVQ.compute_time_s": "0.330302452", + "construction.index_quant_time_s.PQ.compute_time_s": "4.652523221", + "construction.index_quant_time_s.PQ.encoding_time_s": "1.745370325" + } + ], + "throughput_samples": [], + "finished": "2026-10-29T03:00:37.779050+00:00" + }, + "t1": { + "elapsed_process_seconds": 41.79283030703664, + "log": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/logs/jdk25-mpnet-1m-4bit-pq-t1.log", + "csv": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk25-mpnet-1m-4bit-pq/logging/jdk25-mpnet-1m-4bit-pq-t1-20261029-030038Z/experiments.csv", + "rows": [ + { + "schema_version": "1", + "run_id": "jdk25-mpnet-1m-4bit-pq-t1-20261029-030038Z", + "run_uuid": "069c9933-6c55-490a-b733-2e228f02f85f", + "system_id": "eafa45791612", + "dataset_name": "mpnet-1m", + "M": "32", + "efConstruction": "100", + "neighborOverflow": "1.2", + "addHierarchy": "true", + "refineFinalGraph": "true", + "feature_set": "[NVQ_VECTORS, FUSED_PQ]", + "build_compressor": "\"ProductQuantization(M=197, clusters=256, centered=false)\"", + "search_compressor": "None", + "usePruning": "false", + "topK": "10", + "overquery": "1.0", + "rerankK": "10", + "search.throughput.avg_qps": "10001.00801293626", + "search.throughput.stddev_qps": "179.69312362625553", + "search.throughput.cv_pct": "1.7967501215259827", + "search.count.avg_visited": "438.5955", + "search.accuracy.recall_at_10": "0.80751", + "construction.index_build_time_s": "", + "construction.index_quant_time_s.NVQ.compute_time_s": "", + "construction.index_quant_time_s.PQ.compute_time_s": "", + "construction.index_quant_time_s.PQ.encoding_time_s": "" + }, + { + "schema_version": "1", + "run_id": "jdk25-mpnet-1m-4bit-pq-t1-20261029-030038Z", + "run_uuid": "069c9933-6c55-490a-b733-2e228f02f85f", + "system_id": "eafa45791612", + "dataset_name": "mpnet-1m", + "M": "32", + "efConstruction": "100", + "neighborOverflow": "1.2", + "addHierarchy": "true", + "refineFinalGraph": "true", + "feature_set": "[NVQ_VECTORS, FUSED_PQ]", + "build_compressor": "\"ProductQuantization(M=197, clusters=256, centered=false)\"", + "search_compressor": "None", + "usePruning": "false", + "topK": "10", + "overquery": "2.0", + "rerankK": "20", + "search.throughput.avg_qps": "7557.44798962551", + "search.throughput.stddev_qps": "14.656592861029829", + "search.throughput.cv_pct": "0.193935742345163", + "search.count.avg_visited": "602.5851", + "search.accuracy.recall_at_10": "0.92601", + "construction.index_build_time_s": "", + "construction.index_quant_time_s.NVQ.compute_time_s": "", + "construction.index_quant_time_s.PQ.compute_time_s": "", + "construction.index_quant_time_s.PQ.encoding_time_s": "" + } + ], + "throughput_samples": [ + { + "sample": "Test-0", + "queries": 10000, + "seconds": 1.02106 + }, + { + "sample": "Test-1", + "queries": 20000, + "seconds": 1.977637 + }, + { + "sample": "Test-2", + "queries": 20000, + "seconds": 1.980943 + }, + { + "sample": "Test-0", + "queries": 10000, + "seconds": 1.324208 + }, + { + "sample": "Test-1", + "queries": 10000, + "seconds": 1.320287 + }, + { + "sample": "Test-2", + "queries": 10000, + "seconds": 1.325109 + } + ], + "finished": "2026-10-29T03:01:19.573312+00:00" + }, + "t8": { + "elapsed_process_seconds": 18.641796117648482, + "log": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/logs/jdk25-mpnet-1m-4bit-pq-t8.log", + "csv": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk25-mpnet-1m-4bit-pq/logging/jdk25-mpnet-1m-4bit-pq-t8-20261029-030119Z/experiments.csv", + "rows": [ + { + "schema_version": "1", + "run_id": "jdk25-mpnet-1m-4bit-pq-t8-20261029-030119Z", + "run_uuid": "aa0ea80e-50b2-4ea9-8920-cd7aec47e45e", + "system_id": "eafa45791612", + "dataset_name": "mpnet-1m", + "M": "32", + "efConstruction": "100", + "neighborOverflow": "1.2", + "addHierarchy": "true", + "refineFinalGraph": "true", + "feature_set": "[NVQ_VECTORS, FUSED_PQ]", + "build_compressor": "\"ProductQuantization(M=197, clusters=256, centered=false)\"", + "search_compressor": "None", + "usePruning": "false", + "topK": "10", + "overquery": "1.0", + "rerankK": "10", + "search.throughput.avg_qps": "74886.84115917748", + "search.throughput.stddev_qps": "2099.6919388373026", + "search.throughput.cv_pct": "2.803819611477874", + "search.count.avg_visited": "438.5955", + "search.accuracy.recall_at_10": "0.80751", + "construction.index_build_time_s": "", + "construction.index_quant_time_s.NVQ.compute_time_s": "", + "construction.index_quant_time_s.PQ.compute_time_s": "", + "construction.index_quant_time_s.PQ.encoding_time_s": "" + }, + { + "schema_version": "1", + "run_id": "jdk25-mpnet-1m-4bit-pq-t8-20261029-030119Z", + "run_uuid": "aa0ea80e-50b2-4ea9-8920-cd7aec47e45e", + "system_id": "eafa45791612", + "dataset_name": "mpnet-1m", + "M": "32", + "efConstruction": "100", + "neighborOverflow": "1.2", + "addHierarchy": "true", + "refineFinalGraph": "true", + "feature_set": "[NVQ_VECTORS, FUSED_PQ]", + "build_compressor": "\"ProductQuantization(M=197, clusters=256, centered=false)\"", + "search_compressor": "None", + "usePruning": "false", + "topK": "10", + "overquery": "2.0", + "rerankK": "20", + "search.throughput.avg_qps": "56904.75254695084", + "search.throughput.stddev_qps": "1261.345571497758", + "search.throughput.cv_pct": "2.2165909085661166", + "search.count.avg_visited": "602.5851", + "search.accuracy.recall_at_10": "0.92601", + "construction.index_build_time_s": "", + "construction.index_quant_time_s.NVQ.compute_time_s": "", + "construction.index_quant_time_s.PQ.compute_time_s": "", + "construction.index_quant_time_s.PQ.encoding_time_s": "" + } + ], + "throughput_samples": [ + { + "sample": "Test-0", + "queries": 80000, + "seconds": 1.101604 + }, + { + "sample": "Test-1", + "queries": 80000, + "seconds": 1.062819 + }, + { + "sample": "Test-2", + "queries": 80000, + "seconds": 1.042107 + }, + { + "sample": "Test-0", + "queries": 60000, + "seconds": 1.028486 + }, + { + "sample": "Test-1", + "queries": 60000, + "seconds": 1.063612 + }, + { + "sample": "Test-2", + "queries": 60000, + "seconds": 1.072108 + } + ], + "finished": "2026-10-29T03:01:38.217164+00:00" + } + }, + "index_files": [ + { + "path": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk25-mpnet-1m-4bit-pq/index_cache/graph_mpnet_1m_FUSED_PQ_NVQ_VECTORS_M32_ef100_of1.2_alpha1.2_H1_R1_ProductQuantization_M_197_clusters_256_centered_false_", + "bytes": 7278883270 + } + ], + "finished": "2026-10-29T03:01:38.217741+00:00" + }, + { + "dataset": "cohere-english-v3-1M", + "display_name": "Cohere", + "original_dimensions": "1024", + "projected_ash_dimensions": "512", + "base_vectors": "1000000", + "queries": "10000", + "ash_bits": "2", + "algorithm": "ASH", + "code_bytes": "133", + "ash_encoded_bits": "1064", + "pq_m": "", + "config": "cohere-english-v3-1M-2bit-budget-ash.yml", + "jdk": "25", + "label": "jdk25-cohere-english-v3-1M-2bit-ash", + "path": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk25-cohere-english-v3-1M-2bit-ash", + "source": "507b74e7ef8a0e12fcfe402deb4bf758c65758d5", + "started": "2026-10-29T03:01:38.223459+00:00", + "phases": { + "build": { + "elapsed_process_seconds": 52.83737094514072, + "log": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/logs/jdk25-cohere-english-v3-1M-2bit-ash-build.log", + "csv": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk25-cohere-english-v3-1M-2bit-ash/logging/jdk25-cohere-english-v3-1M-2bit-ash-build-20261029-030138Z/experiments.csv", + "rows": [ + { + "schema_version": "1", + "run_id": "jdk25-cohere-english-v3-1M-2bit-ash-build-20261029-030138Z", + "run_uuid": "d6db0329-4529-4300-bf72-f73647a74dd1", + "system_id": "eafa45791612", + "dataset_name": "cohere-english-v3-1M", + "M": "32", + "efConstruction": "100", + "neighborOverflow": "1.2", + "addHierarchy": "true", + "refineFinalGraph": "true", + "feature_set": "[NVQ_VECTORS, FUSED_ASH]", + "build_compressor": "\"AsymmetricHashing[origDim=1024, encodedBits=1064, bodyBits=1024, quantizedDim=512, bitsPerDimension=2, optimizer=ITQ, landmarks=1]\"", + "search_compressor": "None", + "usePruning": "false", + "topK": "10", + "overquery": "1.0", + "rerankK": "10", + "search.accuracy.recall_at_10": "0.63975", + "construction.index_build_time_s": "32.965712237", + "construction.index_quant_time_s.ASH.compute_time_s": "7.498550613", + "construction.index_quant_time_s.ASH.encoding_time_s": "1.643006753", + "construction.index_quant_time_s.NVQ.compute_time_s": "0.49575556" + } + ], + "throughput_samples": [], + "finished": "2026-10-29T03:02:31.062686+00:00" + }, + "t1": { + "elapsed_process_seconds": 57.36122320685536, + "log": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/logs/jdk25-cohere-english-v3-1M-2bit-ash-t1.log", + "csv": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk25-cohere-english-v3-1M-2bit-ash/logging/jdk25-cohere-english-v3-1M-2bit-ash-t1-20261029-030232Z/experiments.csv", + "rows": [ + { + "schema_version": "1", + "run_id": "jdk25-cohere-english-v3-1M-2bit-ash-t1-20261029-030232Z", + "run_uuid": "5d079417-a777-4bb9-ad82-62b0b12ff122", + "system_id": "eafa45791612", + "dataset_name": "cohere-english-v3-1M", + "M": "32", + "efConstruction": "100", + "neighborOverflow": "1.2", + "addHierarchy": "true", + "refineFinalGraph": "true", + "feature_set": "[NVQ_VECTORS, FUSED_ASH]", + "build_compressor": "\"AsymmetricHashing[origDim=1024, encodedBits=1064, bodyBits=1024, quantizedDim=512, bitsPerDimension=2, optimizer=ITQ, landmarks=1]\"", + "search_compressor": "None", + "usePruning": "false", + "topK": "10", + "overquery": "1.0", + "rerankK": "10", + "search.throughput.avg_qps": "6427.095638763094", + "search.throughput.stddev_qps": "169.83121886243185", + "search.throughput.cv_pct": "2.642425574596182", + "search.count.avg_visited": "565.5602", + "search.accuracy.recall_at_10": "0.63975", + "construction.index_build_time_s": "", + "construction.index_quant_time_s.ASH.compute_time_s": "", + "construction.index_quant_time_s.ASH.encoding_time_s": "", + "construction.index_quant_time_s.NVQ.compute_time_s": "" + }, + { + "schema_version": "1", + "run_id": "jdk25-cohere-english-v3-1M-2bit-ash-t1-20261029-030232Z", + "run_uuid": "5d079417-a777-4bb9-ad82-62b0b12ff122", + "system_id": "eafa45791612", + "dataset_name": "cohere-english-v3-1M", + "M": "32", + "efConstruction": "100", + "neighborOverflow": "1.2", + "addHierarchy": "true", + "refineFinalGraph": "true", + "feature_set": "[NVQ_VECTORS, FUSED_ASH]", + "build_compressor": "\"AsymmetricHashing[origDim=1024, encodedBits=1064, bodyBits=1024, quantizedDim=512, bitsPerDimension=2, optimizer=ITQ, landmarks=1]\"", + "search_compressor": "None", + "usePruning": "false", + "topK": "10", + "overquery": "2.0", + "rerankK": "20", + "search.throughput.avg_qps": "5258.107673080131", + "search.throughput.stddev_qps": "63.42893861825412", + "search.throughput.cv_pct": "1.2063073364395043", + "search.count.avg_visited": "811.3677", + "search.accuracy.recall_at_10": "0.82327", + "construction.index_build_time_s": "", + "construction.index_quant_time_s.ASH.compute_time_s": "", + "construction.index_quant_time_s.ASH.encoding_time_s": "", + "construction.index_quant_time_s.NVQ.compute_time_s": "" + } + ], + "throughput_samples": [ + { + "sample": "Test-0", + "queries": 10000, + "seconds": 1.603609 + }, + { + "sample": "Test-1", + "queries": 10000, + "seconds": 1.524258 + }, + { + "sample": "Test-2", + "queries": 10000, + "seconds": 1.542072 + }, + { + "sample": "Test-0", + "queries": 10000, + "seconds": 1.926879 + }, + { + "sample": "Test-1", + "queries": 10000, + "seconds": 1.897699 + }, + { + "sample": "Test-2", + "queries": 10000, + "seconds": 1.881452 + } + ], + "finished": "2026-10-29T03:03:28.425166+00:00" + }, + "t8": { + "elapsed_process_seconds": 22.270215313881636, + "log": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/logs/jdk25-cohere-english-v3-1M-2bit-ash-t8.log", + "csv": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk25-cohere-english-v3-1M-2bit-ash/logging/jdk25-cohere-english-v3-1M-2bit-ash-t8-20261029-030328Z/experiments.csv", + "rows": [ + { + "schema_version": "1", + "run_id": "jdk25-cohere-english-v3-1M-2bit-ash-t8-20261029-030328Z", + "run_uuid": "78e66c7b-0f61-4779-be5a-cbdc69a39952", + "system_id": "eafa45791612", + "dataset_name": "cohere-english-v3-1M", + "M": "32", + "efConstruction": "100", + "neighborOverflow": "1.2", + "addHierarchy": "true", + "refineFinalGraph": "true", + "feature_set": "[NVQ_VECTORS, FUSED_ASH]", + "build_compressor": "\"AsymmetricHashing[origDim=1024, encodedBits=1064, bodyBits=1024, quantizedDim=512, bitsPerDimension=2, optimizer=ITQ, landmarks=1]\"", + "search_compressor": "None", + "usePruning": "false", + "topK": "10", + "overquery": "1.0", + "rerankK": "10", + "search.throughput.avg_qps": "50148.40516839785", + "search.throughput.stddev_qps": "824.0291437743359", + "search.throughput.cv_pct": "1.6431811560253098", + "search.count.avg_visited": "565.5602", + "search.accuracy.recall_at_10": "0.63975", + "construction.index_build_time_s": "", + "construction.index_quant_time_s.ASH.compute_time_s": "", + "construction.index_quant_time_s.ASH.encoding_time_s": "", + "construction.index_quant_time_s.NVQ.compute_time_s": "" + }, + { + "schema_version": "1", + "run_id": "jdk25-cohere-english-v3-1M-2bit-ash-t8-20261029-030328Z", + "run_uuid": "78e66c7b-0f61-4779-be5a-cbdc69a39952", + "system_id": "eafa45791612", + "dataset_name": "cohere-english-v3-1M", + "M": "32", + "efConstruction": "100", + "neighborOverflow": "1.2", + "addHierarchy": "true", + "refineFinalGraph": "true", + "feature_set": "[NVQ_VECTORS, FUSED_ASH]", + "build_compressor": "\"AsymmetricHashing[origDim=1024, encodedBits=1064, bodyBits=1024, quantizedDim=512, bitsPerDimension=2, optimizer=ITQ, landmarks=1]\"", + "search_compressor": "None", + "usePruning": "false", + "topK": "10", + "overquery": "2.0", + "rerankK": "20", + "search.throughput.avg_qps": "41901.690380100095", + "search.throughput.stddev_qps": "454.39922005212", + "search.throughput.cv_pct": "1.084441262226315", + "search.count.avg_visited": "811.3677", + "search.accuracy.recall_at_10": "0.82327", + "construction.index_build_time_s": "", + "construction.index_quant_time_s.ASH.compute_time_s": "", + "construction.index_quant_time_s.ASH.encoding_time_s": "", + "construction.index_quant_time_s.NVQ.compute_time_s": "" + } + ], + "throughput_samples": [ + { + "sample": "Test-0", + "queries": 60000, + "seconds": 1.176934 + }, + { + "sample": "Test-1", + "queries": 50000, + "seconds": 1.013539 + }, + { + "sample": "Test-2", + "queries": 60000, + "seconds": 1.196812 + }, + { + "sample": "Test-0", + "queries": 50000, + "seconds": 1.185296 + }, + { + "sample": "Test-1", + "queries": 50000, + "seconds": 1.186408 + }, + { + "sample": "Test-2", + "queries": 50000, + "seconds": 1.208386 + } + ], + "finished": "2026-10-29T03:03:50.696834+00:00" + } + }, + "index_files": [ + { + "path": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk25-cohere-english-v3-1M-2bit-ash/index_cache/graph_cohere_english_v3_1M_FUSED_ASH_NVQ_VECTORS_M32_ef100_of1.2_alpha1.2_H1_R1_AsymmetricHashing_origDim_1024_encodedBits_1064_bodyBits_1024_quantizedDim_512_bitsPerDimension_2_optimizer_ITQ_landmarks_1_", + "bytes": 5488878816 + } + ], + "finished": "2026-10-29T03:03:50.697499+00:00" + }, + { + "dataset": "cohere-english-v3-1M", + "display_name": "Cohere", + "original_dimensions": "1024", + "projected_ash_dimensions": "512", + "base_vectors": "1000000", + "queries": "10000", + "ash_bits": "2", + "algorithm": "PQ", + "code_bytes": "133", + "ash_encoded_bits": "", + "pq_m": "133", + "config": "cohere-english-v3-1M-2bit-budget-pq.yml", + "jdk": "25", + "label": "jdk25-cohere-english-v3-1M-2bit-pq", + "path": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk25-cohere-english-v3-1M-2bit-pq", + "source": "507b74e7ef8a0e12fcfe402deb4bf758c65758d5", + "started": "2026-10-29T03:03:50.702501+00:00", + "phases": { + "build": { + "elapsed_process_seconds": 49.729727069847286, + "log": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/logs/jdk25-cohere-english-v3-1M-2bit-pq-build.log", + "csv": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk25-cohere-english-v3-1M-2bit-pq/logging/jdk25-cohere-english-v3-1M-2bit-pq-build-20261029-030351Z/experiments.csv", + "rows": [ + { + "schema_version": "1", + "run_id": "jdk25-cohere-english-v3-1M-2bit-pq-build-20261029-030351Z", + "run_uuid": "5fd555a2-5cfc-43a3-8c66-60186cf33a31", + "system_id": "eafa45791612", + "dataset_name": "cohere-english-v3-1M", + "M": "32", + "efConstruction": "100", + "neighborOverflow": "1.2", + "addHierarchy": "true", + "refineFinalGraph": "true", + "feature_set": "[NVQ_VECTORS, FUSED_PQ]", + "build_compressor": "\"ProductQuantization(M=133, clusters=256, centered=false)\"", + "search_compressor": "None", + "usePruning": "false", + "topK": "10", + "overquery": "1.0", + "rerankK": "10", + "search.accuracy.recall_at_10": "0.5913", + "construction.index_build_time_s": "40.656450049", + "construction.index_quant_time_s.NVQ.compute_time_s": "0.323431596", + "construction.index_quant_time_s.PQ.compute_time_s": "3.084744323", + "construction.index_quant_time_s.PQ.encoding_time_s": "1.32553116" + } + ], + "throughput_samples": [], + "finished": "2026-10-29T03:04:40.434152+00:00" + }, + "t1": { + "elapsed_process_seconds": 42.22823287732899, + "log": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/logs/jdk25-cohere-english-v3-1M-2bit-pq-t1.log", + "csv": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk25-cohere-english-v3-1M-2bit-pq/logging/jdk25-cohere-english-v3-1M-2bit-pq-t1-20261029-030441Z/experiments.csv", + "rows": [ + { + "schema_version": "1", + "run_id": "jdk25-cohere-english-v3-1M-2bit-pq-t1-20261029-030441Z", + "run_uuid": "2059abac-f3d9-4553-a989-d85ba4be1a02", + "system_id": "eafa45791612", + "dataset_name": "cohere-english-v3-1M", + "M": "32", + "efConstruction": "100", + "neighborOverflow": "1.2", + "addHierarchy": "true", + "refineFinalGraph": "true", + "feature_set": "[NVQ_VECTORS, FUSED_PQ]", + "build_compressor": "\"ProductQuantization(M=133, clusters=256, centered=false)\"", + "search_compressor": "None", + "usePruning": "false", + "topK": "10", + "overquery": "1.0", + "rerankK": "10", + "search.throughput.avg_qps": "9412.081288856038", + "search.throughput.stddev_qps": "112.56406605696156", + "search.throughput.cv_pct": "1.195952973655658", + "search.count.avg_visited": "515.0213", + "search.accuracy.recall_at_10": "0.5913", + "construction.index_build_time_s": "", + "construction.index_quant_time_s.NVQ.compute_time_s": "", + "construction.index_quant_time_s.PQ.compute_time_s": "", + "construction.index_quant_time_s.PQ.encoding_time_s": "" + }, + { + "schema_version": "1", + "run_id": "jdk25-cohere-english-v3-1M-2bit-pq-t1-20261029-030441Z", + "run_uuid": "2059abac-f3d9-4553-a989-d85ba4be1a02", + "system_id": "eafa45791612", + "dataset_name": "cohere-english-v3-1M", + "M": "32", + "efConstruction": "100", + "neighborOverflow": "1.2", + "addHierarchy": "true", + "refineFinalGraph": "true", + "feature_set": "[NVQ_VECTORS, FUSED_PQ]", + "build_compressor": "\"ProductQuantization(M=133, clusters=256, centered=false)\"", + "search_compressor": "None", + "usePruning": "false", + "topK": "10", + "overquery": "2.0", + "rerankK": "20", + "search.throughput.avg_qps": "7190.940014143866", + "search.throughput.stddev_qps": "167.83733226091644", + "search.throughput.cv_pct": "2.334011018459298", + "search.count.avg_visited": "722.8076", + "search.accuracy.recall_at_10": "0.7845", + "construction.index_build_time_s": "", + "construction.index_quant_time_s.NVQ.compute_time_s": "", + "construction.index_quant_time_s.PQ.compute_time_s": "", + "construction.index_quant_time_s.PQ.encoding_time_s": "" + } + ], + "throughput_samples": [ + { + "sample": "Test-0", + "queries": 10000, + "seconds": 1.077253 + }, + { + "sample": "Test-1", + "queries": 10000, + "seconds": 1.056583 + }, + { + "sample": "Test-2", + "queries": 10000, + "seconds": 1.053863 + }, + { + "sample": "Test-0", + "queries": 10000, + "seconds": 1.429143 + }, + { + "sample": "Test-1", + "queries": 10000, + "seconds": 1.37134 + }, + { + "sample": "Test-2", + "queries": 10000, + "seconds": 1.37297 + } + ], + "finished": "2026-10-29T03:05:22.663760+00:00" + }, + "t8": { + "elapsed_process_seconds": 18.73960914835334, + "log": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/logs/jdk25-cohere-english-v3-1M-2bit-pq-t8.log", + "csv": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk25-cohere-english-v3-1M-2bit-pq/logging/jdk25-cohere-english-v3-1M-2bit-pq-t8-20261029-030523Z/experiments.csv", + "rows": [ + { + "schema_version": "1", + "run_id": "jdk25-cohere-english-v3-1M-2bit-pq-t8-20261029-030523Z", + "run_uuid": "4696403e-eaa3-4cfc-9fc2-3da914ca060f", + "system_id": "eafa45791612", + "dataset_name": "cohere-english-v3-1M", + "M": "32", + "efConstruction": "100", + "neighborOverflow": "1.2", + "addHierarchy": "true", + "refineFinalGraph": "true", + "feature_set": "[NVQ_VECTORS, FUSED_PQ]", + "build_compressor": "\"ProductQuantization(M=133, clusters=256, centered=false)\"", + "search_compressor": "None", + "usePruning": "false", + "topK": "10", + "overquery": "1.0", + "rerankK": "10", + "search.throughput.avg_qps": "75637.85852751213", + "search.throughput.stddev_qps": "1813.227802143092", + "search.throughput.cv_pct": "2.3972489933510714", + "search.count.avg_visited": "515.0213", + "search.accuracy.recall_at_10": "0.5913", + "construction.index_build_time_s": "", + "construction.index_quant_time_s.NVQ.compute_time_s": "", + "construction.index_quant_time_s.PQ.compute_time_s": "", + "construction.index_quant_time_s.PQ.encoding_time_s": "" + }, + { + "schema_version": "1", + "run_id": "jdk25-cohere-english-v3-1M-2bit-pq-t8-20261029-030523Z", + "run_uuid": "4696403e-eaa3-4cfc-9fc2-3da914ca060f", + "system_id": "eafa45791612", + "dataset_name": "cohere-english-v3-1M", + "M": "32", + "efConstruction": "100", + "neighborOverflow": "1.2", + "addHierarchy": "true", + "refineFinalGraph": "true", + "feature_set": "[NVQ_VECTORS, FUSED_PQ]", + "build_compressor": "\"ProductQuantization(M=133, clusters=256, centered=false)\"", + "search_compressor": "None", + "usePruning": "false", + "topK": "10", + "overquery": "2.0", + "rerankK": "20", + "search.throughput.avg_qps": "57862.554944797805", + "search.throughput.stddev_qps": "522.6740498873122", + "search.throughput.cv_pct": "0.903302749741962", + "search.count.avg_visited": "722.8076", + "search.accuracy.recall_at_10": "0.7845", + "construction.index_build_time_s": "", + "construction.index_quant_time_s.NVQ.compute_time_s": "", + "construction.index_quant_time_s.PQ.compute_time_s": "", + "construction.index_quant_time_s.PQ.encoding_time_s": "" + } + ], + "throughput_samples": [ + { + "sample": "Test-0", + "queries": 80000, + "seconds": 1.086572 + }, + { + "sample": "Test-1", + "queries": 80000, + "seconds": 1.050674 + }, + { + "sample": "Test-2", + "queries": 80000, + "seconds": 1.036995 + }, + { + "sample": "Test-0", + "queries": 60000, + "seconds": 1.026278 + }, + { + "sample": "Test-1", + "queries": 60000, + "seconds": 1.041512 + }, + { + "sample": "Test-2", + "queries": 60000, + "seconds": 1.043198 + } + ], + "finished": "2026-10-29T03:05:41.404912+00:00" + } + }, + "index_files": [ + { + "path": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk25-cohere-english-v3-1M-2bit-pq/index_cache/graph_cohere_english_v3_1M_FUSED_PQ_NVQ_VECTORS_M32_ef100_of1.2_alpha1.2_H1_R1_ProductQuantization_M_133_clusters_256_centered_false_", + "bytes": 5486780219 + } + ], + "finished": "2026-10-29T03:05:41.405488+00:00" + }, + { + "dataset": "cohere-english-v3-1M", + "display_name": "Cohere", + "original_dimensions": "1024", + "projected_ash_dimensions": "512", + "base_vectors": "1000000", + "queries": "10000", + "ash_bits": "4", + "algorithm": "ASH", + "code_bytes": "261", + "ash_encoded_bits": "2088", + "pq_m": "", + "config": "cohere-english-v3-1M-4bit-budget-ash.yml", + "jdk": "25", + "label": "jdk25-cohere-english-v3-1M-4bit-ash", + "path": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk25-cohere-english-v3-1M-4bit-ash", + "source": "507b74e7ef8a0e12fcfe402deb4bf758c65758d5", + "started": "2026-10-29T03:05:41.411358+00:00", + "phases": { + "build": { + "elapsed_process_seconds": 62.538319007493556, + "log": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/logs/jdk25-cohere-english-v3-1M-4bit-ash-build.log", + "csv": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk25-cohere-english-v3-1M-4bit-ash/logging/jdk25-cohere-english-v3-1M-4bit-ash-build-20261029-030541Z/experiments.csv", + "rows": [ + { + "schema_version": "1", + "run_id": "jdk25-cohere-english-v3-1M-4bit-ash-build-20261029-030541Z", + "run_uuid": "ce09b98d-7b58-462c-82b9-55f053424f48", + "system_id": "eafa45791612", + "dataset_name": "cohere-english-v3-1M", + "M": "32", + "efConstruction": "100", + "neighborOverflow": "1.2", + "addHierarchy": "true", + "refineFinalGraph": "true", + "feature_set": "[NVQ_VECTORS, FUSED_ASH]", + "build_compressor": "\"AsymmetricHashing[origDim=1024, encodedBits=2088, bodyBits=2048, quantizedDim=512, bitsPerDimension=4, optimizer=ITQ, landmarks=1]\"", + "search_compressor": "None", + "usePruning": "false", + "topK": "10", + "overquery": "1.0", + "rerankK": "10", + "search.accuracy.recall_at_10": "0.7059", + "construction.index_build_time_s": "46.251607145", + "construction.index_quant_time_s.ASH.compute_time_s": "6.455142236", + "construction.index_quant_time_s.ASH.encoding_time_s": "2.062987324", + "construction.index_quant_time_s.NVQ.compute_time_s": "0.722485568" + } + ], + "throughput_samples": [], + "finished": "2026-10-29T03:06:43.952173+00:00" + }, + "t1": { + "elapsed_process_seconds": 64.48913117777556, + "log": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/logs/jdk25-cohere-english-v3-1M-4bit-ash-t1.log", + "csv": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk25-cohere-english-v3-1M-4bit-ash/logging/jdk25-cohere-english-v3-1M-4bit-ash-t1-20261029-030645Z/experiments.csv", + "rows": [ + { + "schema_version": "1", + "run_id": "jdk25-cohere-english-v3-1M-4bit-ash-t1-20261029-030645Z", + "run_uuid": "26d24777-8d6e-4c1e-919f-02fe017c9527", + "system_id": "eafa45791612", + "dataset_name": "cohere-english-v3-1M", + "M": "32", + "efConstruction": "100", + "neighborOverflow": "1.2", + "addHierarchy": "true", + "refineFinalGraph": "true", + "feature_set": "[NVQ_VECTORS, FUSED_ASH]", + "build_compressor": "\"AsymmetricHashing[origDim=1024, encodedBits=2088, bodyBits=2048, quantizedDim=512, bitsPerDimension=4, optimizer=ITQ, landmarks=1]\"", + "search_compressor": "None", + "usePruning": "false", + "topK": "10", + "overquery": "1.0", + "rerankK": "10", + "search.throughput.avg_qps": "5662.326694410597", + "search.throughput.stddev_qps": "193.66647724429077", + "search.throughput.cv_pct": "3.420263218571671", + "search.count.avg_visited": "539.3398", + "search.accuracy.recall_at_10": "0.7059", + "construction.index_build_time_s": "", + "construction.index_quant_time_s.ASH.compute_time_s": "", + "construction.index_quant_time_s.ASH.encoding_time_s": "", + "construction.index_quant_time_s.NVQ.compute_time_s": "" + }, + { + "schema_version": "1", + "run_id": "jdk25-cohere-english-v3-1M-4bit-ash-t1-20261029-030645Z", + "run_uuid": "26d24777-8d6e-4c1e-919f-02fe017c9527", + "system_id": "eafa45791612", + "dataset_name": "cohere-english-v3-1M", + "M": "32", + "efConstruction": "100", + "neighborOverflow": "1.2", + "addHierarchy": "true", + "refineFinalGraph": "true", + "feature_set": "[NVQ_VECTORS, FUSED_ASH]", + "build_compressor": "\"AsymmetricHashing[origDim=1024, encodedBits=2088, bodyBits=2048, quantizedDim=512, bitsPerDimension=4, optimizer=ITQ, landmarks=1]\"", + "search_compressor": "None", + "usePruning": "false", + "topK": "10", + "overquery": "2.0", + "rerankK": "20", + "search.throughput.avg_qps": "4635.7196414662285", + "search.throughput.stddev_qps": "61.53701677204011", + "search.throughput.cv_pct": "1.3274533736163694", + "search.count.avg_visited": "770.3046", + "search.accuracy.recall_at_10": "0.83696", + "construction.index_build_time_s": "", + "construction.index_quant_time_s.ASH.compute_time_s": "", + "construction.index_quant_time_s.ASH.encoding_time_s": "", + "construction.index_quant_time_s.NVQ.compute_time_s": "" + } + ], + "throughput_samples": [ + { + "sample": "Test-0", + "queries": 10000, + "seconds": 1.836672 + }, + { + "sample": "Test-1", + "queries": 10000, + "seconds": 1.746156 + }, + { + "sample": "Test-2", + "queries": 10000, + "seconds": 1.719548 + }, + { + "sample": "Test-0", + "queries": 10000, + "seconds": 2.190733 + }, + { + "sample": "Test-1", + "queries": 10000, + "seconds": 2.14009 + }, + { + "sample": "Test-2", + "queries": 10000, + "seconds": 2.141431 + } + ], + "finished": "2026-10-29T03:07:48.442803+00:00" + }, + "t8": { + "elapsed_process_seconds": 23.704103889875114, + "log": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/logs/jdk25-cohere-english-v3-1M-4bit-ash-t8.log", + "csv": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk25-cohere-english-v3-1M-4bit-ash/logging/jdk25-cohere-english-v3-1M-4bit-ash-t8-20261029-030748Z/experiments.csv", + "rows": [ + { + "schema_version": "1", + "run_id": "jdk25-cohere-english-v3-1M-4bit-ash-t8-20261029-030748Z", + "run_uuid": "32df842c-39cf-4d88-8329-8207f11f96f3", + "system_id": "eafa45791612", + "dataset_name": "cohere-english-v3-1M", + "M": "32", + "efConstruction": "100", + "neighborOverflow": "1.2", + "addHierarchy": "true", + "refineFinalGraph": "true", + "feature_set": "[NVQ_VECTORS, FUSED_ASH]", + "build_compressor": "\"AsymmetricHashing[origDim=1024, encodedBits=2088, bodyBits=2048, quantizedDim=512, bitsPerDimension=4, optimizer=ITQ, landmarks=1]\"", + "search_compressor": "None", + "usePruning": "false", + "topK": "10", + "overquery": "1.0", + "rerankK": "10", + "search.throughput.avg_qps": "42689.45179035281", + "search.throughput.stddev_qps": "153.1711013850193", + "search.throughput.cv_pct": "0.3588031585349", + "search.count.avg_visited": "539.3398", + "search.accuracy.recall_at_10": "0.7059", + "construction.index_build_time_s": "", + "construction.index_quant_time_s.ASH.compute_time_s": "", + "construction.index_quant_time_s.ASH.encoding_time_s": "", + "construction.index_quant_time_s.NVQ.compute_time_s": "" + }, + { + "schema_version": "1", + "run_id": "jdk25-cohere-english-v3-1M-4bit-ash-t8-20261029-030748Z", + "run_uuid": "32df842c-39cf-4d88-8329-8207f11f96f3", + "system_id": "eafa45791612", + "dataset_name": "cohere-english-v3-1M", + "M": "32", + "efConstruction": "100", + "neighborOverflow": "1.2", + "addHierarchy": "true", + "refineFinalGraph": "true", + "feature_set": "[NVQ_VECTORS, FUSED_ASH]", + "build_compressor": "\"AsymmetricHashing[origDim=1024, encodedBits=2088, bodyBits=2048, quantizedDim=512, bitsPerDimension=4, optimizer=ITQ, landmarks=1]\"", + "search_compressor": "None", + "usePruning": "false", + "topK": "10", + "overquery": "2.0", + "rerankK": "20", + "search.throughput.avg_qps": "35644.71872678161", + "search.throughput.stddev_qps": "417.585373344066", + "search.throughput.cv_pct": "1.171521022636977", + "search.count.avg_visited": "770.3046", + "search.accuracy.recall_at_10": "0.83696", + "construction.index_build_time_s": "", + "construction.index_quant_time_s.ASH.compute_time_s": "", + "construction.index_quant_time_s.ASH.encoding_time_s": "", + "construction.index_quant_time_s.NVQ.compute_time_s": "" + } + ], + "throughput_samples": [ + { + "sample": "Test-0", + "queries": 50000, + "seconds": 1.16983 + }, + { + "sample": "Test-1", + "queries": 50000, + "seconds": 1.175998 + }, + { + "sample": "Test-2", + "queries": 50000, + "seconds": 1.167951 + }, + { + "sample": "Test-0", + "queries": 40000, + "seconds": 1.108802 + }, + { + "sample": "Test-1", + "queries": 40000, + "seconds": 1.13504 + }, + { + "sample": "Test-2", + "queries": 40000, + "seconds": 1.123024 + } + ], + "finished": "2026-10-29T03:08:12.148410+00:00" + } + }, + "index_files": [ + { + "path": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk25-cohere-english-v3-1M-4bit-ash/index_cache/graph_cohere_english_v3_1M_FUSED_ASH_NVQ_VECTORS_M32_ef100_of1.2_alpha1.2_H1_R1_AsymmetricHashing_origDim_1024_encodedBits_2088_bodyBits_2048_quantizedDim_512_bitsPerDimension_4_optimizer_ITQ_landmarks_1_", + "bytes": 9588881034 + } + ], + "finished": "2026-10-29T03:08:12.148990+00:00" + }, + { + "dataset": "cohere-english-v3-1M", + "display_name": "Cohere", + "original_dimensions": "1024", + "projected_ash_dimensions": "512", + "base_vectors": "1000000", + "queries": "10000", + "ash_bits": "4", + "algorithm": "PQ", + "code_bytes": "261", + "ash_encoded_bits": "", + "pq_m": "261", + "config": "cohere-english-v3-1M-4bit-budget-pq.yml", + "jdk": "25", + "label": "jdk25-cohere-english-v3-1M-4bit-pq", + "path": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk25-cohere-english-v3-1M-4bit-pq", + "source": "507b74e7ef8a0e12fcfe402deb4bf758c65758d5", + "started": "2026-10-29T03:08:12.154688+00:00", + "phases": { + "build": { + "elapsed_process_seconds": 80.15861915424466, + "log": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/logs/jdk25-cohere-english-v3-1M-4bit-pq-build.log", + "csv": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk25-cohere-english-v3-1M-4bit-pq/logging/jdk25-cohere-english-v3-1M-4bit-pq-build-20261029-030812Z/experiments.csv", + "rows": [ + { + "schema_version": "1", + "run_id": "jdk25-cohere-english-v3-1M-4bit-pq-build-20261029-030812Z", + "run_uuid": "4dd6d1b4-c191-4a9a-83d2-b103d9a10dee", + "system_id": "eafa45791612", + "dataset_name": "cohere-english-v3-1M", + "M": "32", + "efConstruction": "100", + "neighborOverflow": "1.2", + "addHierarchy": "true", + "refineFinalGraph": "true", + "feature_set": "[NVQ_VECTORS, FUSED_PQ]", + "build_compressor": "\"ProductQuantization(M=261, clusters=256, centered=false)\"", + "search_compressor": "None", + "usePruning": "false", + "topK": "10", + "overquery": "1.0", + "rerankK": "10", + "search.accuracy.recall_at_10": "0.6553", + "construction.index_build_time_s": "66.54631899", + "construction.index_quant_time_s.NVQ.compute_time_s": "0.338206073", + "construction.index_quant_time_s.PQ.compute_time_s": "6.571955191", + "construction.index_quant_time_s.PQ.encoding_time_s": "2.233528007" + } + ], + "throughput_samples": [], + "finished": "2026-10-29T03:09:32.315540+00:00" + }, + "t1": { + "elapsed_process_seconds": 48.29118027724326, + "log": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/logs/jdk25-cohere-english-v3-1M-4bit-pq-t1.log", + "csv": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk25-cohere-english-v3-1M-4bit-pq/logging/jdk25-cohere-english-v3-1M-4bit-pq-t1-20261029-030933Z/experiments.csv", + "rows": [ + { + "schema_version": "1", + "run_id": "jdk25-cohere-english-v3-1M-4bit-pq-t1-20261029-030933Z", + "run_uuid": "c312af43-5328-4a55-a89d-f9bb90556565", + "system_id": "eafa45791612", + "dataset_name": "cohere-english-v3-1M", + "M": "32", + "efConstruction": "100", + "neighborOverflow": "1.2", + "addHierarchy": "true", + "refineFinalGraph": "true", + "feature_set": "[NVQ_VECTORS, FUSED_PQ]", + "build_compressor": "\"ProductQuantization(M=261, clusters=256, centered=false)\"", + "search_compressor": "None", + "usePruning": "false", + "topK": "10", + "overquery": "1.0", + "rerankK": "10", + "search.throughput.avg_qps": "7857.8449638838065", + "search.throughput.stddev_qps": "139.06736295154764", + "search.throughput.cv_pct": "1.7697901090022579", + "search.count.avg_visited": "505.9876", + "search.accuracy.recall_at_10": "0.6553", + "construction.index_build_time_s": "", + "construction.index_quant_time_s.NVQ.compute_time_s": "", + "construction.index_quant_time_s.PQ.compute_time_s": "", + "construction.index_quant_time_s.PQ.encoding_time_s": "" + }, + { + "schema_version": "1", + "run_id": "jdk25-cohere-english-v3-1M-4bit-pq-t1-20261029-030933Z", + "run_uuid": "c312af43-5328-4a55-a89d-f9bb90556565", + "system_id": "eafa45791612", + "dataset_name": "cohere-english-v3-1M", + "M": "32", + "efConstruction": "100", + "neighborOverflow": "1.2", + "addHierarchy": "true", + "refineFinalGraph": "true", + "feature_set": "[NVQ_VECTORS, FUSED_PQ]", + "build_compressor": "\"ProductQuantization(M=261, clusters=256, centered=false)\"", + "search_compressor": "None", + "usePruning": "false", + "topK": "10", + "overquery": "2.0", + "rerankK": "20", + "search.throughput.avg_qps": "5920.816567354704", + "search.throughput.stddev_qps": "28.88024786803325", + "search.throughput.cv_pct": "0.4877747442349213", + "search.count.avg_visited": "710.3709", + "search.accuracy.recall_at_10": "0.81365", + "construction.index_build_time_s": "", + "construction.index_quant_time_s.NVQ.compute_time_s": "", + "construction.index_quant_time_s.PQ.compute_time_s": "", + "construction.index_quant_time_s.PQ.encoding_time_s": "" + } + ], + "throughput_samples": [ + { + "sample": "Test-0", + "queries": 10000, + "seconds": 1.285561 + }, + { + "sample": "Test-1", + "queries": 10000, + "seconds": 1.247128 + }, + { + "sample": "Test-2", + "queries": 10000, + "seconds": 1.285941 + }, + { + "sample": "Test-0", + "queries": 10000, + "seconds": 1.69813 + }, + { + "sample": "Test-1", + "queries": 10000, + "seconds": 1.686727 + }, + { + "sample": "Test-2", + "queries": 10000, + "seconds": 1.682092 + } + ], + "finished": "2026-10-29T03:10:20.611113+00:00" + }, + "t8": { + "elapsed_process_seconds": 20.810178492218256, + "log": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/logs/jdk25-cohere-english-v3-1M-4bit-pq-t8.log", + "csv": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk25-cohere-english-v3-1M-4bit-pq/logging/jdk25-cohere-english-v3-1M-4bit-pq-t8-20261029-031020Z/experiments.csv", + "rows": [ + { + "schema_version": "1", + "run_id": "jdk25-cohere-english-v3-1M-4bit-pq-t8-20261029-031020Z", + "run_uuid": "afb8b82d-7bfe-4917-ae0b-07a0afbc7630", + "system_id": "eafa45791612", + "dataset_name": "cohere-english-v3-1M", + "M": "32", + "efConstruction": "100", + "neighborOverflow": "1.2", + "addHierarchy": "true", + "refineFinalGraph": "true", + "feature_set": "[NVQ_VECTORS, FUSED_PQ]", + "build_compressor": "\"ProductQuantization(M=261, clusters=256, centered=false)\"", + "search_compressor": "None", + "usePruning": "false", + "topK": "10", + "overquery": "1.0", + "rerankK": "10", + "search.throughput.avg_qps": "57670.64664851298", + "search.throughput.stddev_qps": "1069.5685083266228", + "search.throughput.cv_pct": "1.854615078005548", + "search.count.avg_visited": "505.9876", + "search.accuracy.recall_at_10": "0.6553", + "construction.index_build_time_s": "", + "construction.index_quant_time_s.NVQ.compute_time_s": "", + "construction.index_quant_time_s.PQ.compute_time_s": "", + "construction.index_quant_time_s.PQ.encoding_time_s": "" + }, + { + "schema_version": "1", + "run_id": "jdk25-cohere-english-v3-1M-4bit-pq-t8-20261029-031020Z", + "run_uuid": "afb8b82d-7bfe-4917-ae0b-07a0afbc7630", + "system_id": "eafa45791612", + "dataset_name": "cohere-english-v3-1M", + "M": "32", + "efConstruction": "100", + "neighborOverflow": "1.2", + "addHierarchy": "true", + "refineFinalGraph": "true", + "feature_set": "[NVQ_VECTORS, FUSED_PQ]", + "build_compressor": "\"ProductQuantization(M=261, clusters=256, centered=false)\"", + "search_compressor": "None", + "usePruning": "false", + "topK": "10", + "overquery": "2.0", + "rerankK": "20", + "search.throughput.avg_qps": "43804.78320505803", + "search.throughput.stddev_qps": "222.48459131611997", + "search.throughput.cv_pct": "0.5079002223903946", + "search.count.avg_visited": "710.3709", + "search.accuracy.recall_at_10": "0.81365", + "construction.index_build_time_s": "", + "construction.index_quant_time_s.NVQ.compute_time_s": "", + "construction.index_quant_time_s.PQ.compute_time_s": "", + "construction.index_quant_time_s.PQ.encoding_time_s": "" + } + ], + "throughput_samples": [ + { + "sample": "Test-0", + "queries": 60000, + "seconds": 1.061253 + }, + { + "sample": "Test-1", + "queries": 60000, + "seconds": 1.022813 + }, + { + "sample": "Test-2", + "queries": 60000, + "seconds": 1.037825 + }, + { + "sample": "Test-0", + "queries": 50000, + "seconds": 1.137074 + }, + { + "sample": "Test-1", + "queries": 50000, + "seconds": 1.139226 + }, + { + "sample": "Test-2", + "queries": 50000, + "seconds": 1.148042 + } + ], + "finished": "2026-10-29T03:10:41.422772+00:00" + } + }, + "index_files": [ + { + "path": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk25-cohere-english-v3-1M-4bit-pq/index_cache/graph_cohere_english_v3_1M_FUSED_PQ_NVQ_VECTORS_M32_ef100_of1.2_alpha1.2_H1_R1_ProductQuantization_M_261_clusters_256_centered_false_", + "bytes": 9586776527 + } + ], + "finished": "2026-10-29T03:10:41.423357+00:00" + }, + { + "dataset": "ada002-1M", + "display_name": "Ada002", + "original_dimensions": "1536", + "projected_ash_dimensions": "768", + "base_vectors": "982790", + "queries": "10000", + "ash_bits": "2", + "algorithm": "ASH", + "code_bytes": "197", + "ash_encoded_bits": "1576", + "pq_m": "", + "config": "ada002-1M-2bit-budget-ash.yml", + "jdk": "25", + "label": "jdk25-ada002-1M-2bit-ash", + "path": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk25-ada002-1M-2bit-ash", + "source": "507b74e7ef8a0e12fcfe402deb4bf758c65758d5", + "started": "2026-10-29T03:10:41.429665+00:00", + "phases": { + "build": { + "elapsed_process_seconds": 81.49886683840305, + "log": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/logs/jdk25-ada002-1M-2bit-ash-build.log", + "csv": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk25-ada002-1M-2bit-ash/logging/jdk25-ada002-1M-2bit-ash-build-20261029-031041Z/experiments.csv", + "rows": [ + { + "schema_version": "1", + "run_id": "jdk25-ada002-1M-2bit-ash-build-20261029-031041Z", + "run_uuid": "61d8b214-c8ac-4caf-a373-72d706e10c1a", + "system_id": "eafa45791612", + "dataset_name": "ada002-1M", + "M": "32", + "efConstruction": "100", + "neighborOverflow": "1.2", + "addHierarchy": "true", + "refineFinalGraph": "true", + "feature_set": "[NVQ_VECTORS, FUSED_ASH]", + "build_compressor": "\"AsymmetricHashing[origDim=1536, encodedBits=1576, bodyBits=1536, quantizedDim=768, bitsPerDimension=2, optimizer=ITQ, landmarks=1]\"", + "search_compressor": "None", + "usePruning": "false", + "topK": "10", + "overquery": "1.0", + "rerankK": "10", + "search.accuracy.recall_at_10": "0.79064", + "construction.index_build_time_s": "45.395450927", + "construction.index_quant_time_s.ASH.compute_time_s": "13.824727702", + "construction.index_quant_time_s.ASH.encoding_time_s": "8.277205551", + "construction.index_quant_time_s.NVQ.compute_time_s": "0.563860348" + } + ], + "throughput_samples": [], + "finished": "2026-10-29T03:12:02.930436+00:00" + }, + "t1": { + "elapsed_process_seconds": 133.8839788250625, + "log": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/logs/jdk25-ada002-1M-2bit-ash-t1.log", + "csv": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk25-ada002-1M-2bit-ash/logging/jdk25-ada002-1M-2bit-ash-t1-20261029-031204Z/experiments.csv", + "rows": [ + { + "schema_version": "1", + "run_id": "jdk25-ada002-1M-2bit-ash-t1-20261029-031204Z", + "run_uuid": "98adb99d-6dc8-4666-9ea0-7f5fc5dab68c", + "system_id": "eafa45791612", + "dataset_name": "ada002-1M", + "M": "32", + "efConstruction": "100", + "neighborOverflow": "1.2", + "addHierarchy": "true", + "refineFinalGraph": "true", + "feature_set": "[NVQ_VECTORS, FUSED_ASH]", + "build_compressor": "\"AsymmetricHashing[origDim=1536, encodedBits=1576, bodyBits=1536, quantizedDim=768, bitsPerDimension=2, optimizer=ITQ, landmarks=1]\"", + "search_compressor": "None", + "usePruning": "false", + "topK": "10", + "overquery": "1.0", + "rerankK": "10", + "search.throughput.avg_qps": "3436.8450622860405", + "search.throughput.stddev_qps": "85.67191578506873", + "search.throughput.cv_pct": "2.4927488505426973", + "search.count.avg_visited": "495.6071", + "search.accuracy.recall_at_10": "0.79064", + "construction.index_build_time_s": "", + "construction.index_quant_time_s.ASH.compute_time_s": "", + "construction.index_quant_time_s.ASH.encoding_time_s": "", + "construction.index_quant_time_s.NVQ.compute_time_s": "" + }, + { + "schema_version": "1", + "run_id": "jdk25-ada002-1M-2bit-ash-t1-20261029-031204Z", + "run_uuid": "98adb99d-6dc8-4666-9ea0-7f5fc5dab68c", + "system_id": "eafa45791612", + "dataset_name": "ada002-1M", + "M": "32", + "efConstruction": "100", + "neighborOverflow": "1.2", + "addHierarchy": "true", + "refineFinalGraph": "true", + "feature_set": "[NVQ_VECTORS, FUSED_ASH]", + "build_compressor": "\"AsymmetricHashing[origDim=1536, encodedBits=1576, bodyBits=1536, quantizedDim=768, bitsPerDimension=2, optimizer=ITQ, landmarks=1]\"", + "search_compressor": "None", + "usePruning": "false", + "topK": "10", + "overquery": "2.0", + "rerankK": "20", + "search.throughput.avg_qps": "3096.883668745141", + "search.throughput.stddev_qps": "86.13452376442987", + "search.throughput.cv_pct": "2.781329006114447", + "search.count.avg_visited": "699.8394", + "search.accuracy.recall_at_10": "0.92718", + "construction.index_build_time_s": "", + "construction.index_quant_time_s.ASH.compute_time_s": "", + "construction.index_quant_time_s.ASH.encoding_time_s": "", + "construction.index_quant_time_s.NVQ.compute_time_s": "" + } + ], + "throughput_samples": [ + { + "sample": "Test-0", + "queries": 10000, + "seconds": 2.958133 + }, + { + "sample": "Test-1", + "queries": 10000, + "seconds": 2.945865 + }, + { + "sample": "Test-2", + "queries": 10000, + "seconds": 2.828505 + }, + { + "sample": "Test-0", + "queries": 10000, + "seconds": 3.335919 + }, + { + "sample": "Test-1", + "queries": 10000, + "seconds": 3.18433 + }, + { + "sample": "Test-2", + "queries": 10000, + "seconds": 3.171987 + } + ], + "finished": "2026-10-29T03:14:16.818527+00:00" + }, + "t8": { + "elapsed_process_seconds": 34.95025229733437, + "log": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/logs/jdk25-ada002-1M-2bit-ash-t8.log", + "csv": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk25-ada002-1M-2bit-ash/logging/jdk25-ada002-1M-2bit-ash-t8-20261029-031417Z/experiments.csv", + "rows": [ + { + "schema_version": "1", + "run_id": "jdk25-ada002-1M-2bit-ash-t8-20261029-031417Z", + "run_uuid": "f8a361ec-ec21-432b-95ae-250e54310cf3", + "system_id": "eafa45791612", + "dataset_name": "ada002-1M", + "M": "32", + "efConstruction": "100", + "neighborOverflow": "1.2", + "addHierarchy": "true", + "refineFinalGraph": "true", + "feature_set": "[NVQ_VECTORS, FUSED_ASH]", + "build_compressor": "\"AsymmetricHashing[origDim=1536, encodedBits=1576, bodyBits=1536, quantizedDim=768, bitsPerDimension=2, optimizer=ITQ, landmarks=1]\"", + "search_compressor": "None", + "usePruning": "false", + "topK": "10", + "overquery": "1.0", + "rerankK": "10", + "search.throughput.avg_qps": "25148.668295078558", + "search.throughput.stddev_qps": "1423.1286862834309", + "search.throughput.cv_pct": "5.658863004535029", + "search.count.avg_visited": "495.6071", + "search.accuracy.recall_at_10": "0.79064", + "construction.index_build_time_s": "", + "construction.index_quant_time_s.ASH.compute_time_s": "", + "construction.index_quant_time_s.ASH.encoding_time_s": "", + "construction.index_quant_time_s.NVQ.compute_time_s": "" + }, + { + "schema_version": "1", + "run_id": "jdk25-ada002-1M-2bit-ash-t8-20261029-031417Z", + "run_uuid": "f8a361ec-ec21-432b-95ae-250e54310cf3", + "system_id": "eafa45791612", + "dataset_name": "ada002-1M", + "M": "32", + "efConstruction": "100", + "neighborOverflow": "1.2", + "addHierarchy": "true", + "refineFinalGraph": "true", + "feature_set": "[NVQ_VECTORS, FUSED_ASH]", + "build_compressor": "\"AsymmetricHashing[origDim=1536, encodedBits=1576, bodyBits=1536, quantizedDim=768, bitsPerDimension=2, optimizer=ITQ, landmarks=1]\"", + "search_compressor": "None", + "usePruning": "false", + "topK": "10", + "overquery": "2.0", + "rerankK": "20", + "search.throughput.avg_qps": "22643.408793697028", + "search.throughput.stddev_qps": "202.7696533831723", + "search.throughput.cv_pct": "0.8954908478250626", + "search.count.avg_visited": "699.8394", + "search.accuracy.recall_at_10": "0.92718", + "construction.index_build_time_s": "", + "construction.index_quant_time_s.ASH.compute_time_s": "", + "construction.index_quant_time_s.ASH.encoding_time_s": "", + "construction.index_quant_time_s.NVQ.compute_time_s": "" + } + ], + "throughput_samples": [ + { + "sample": "Test-0", + "queries": 30000, + "seconds": 1.276284 + }, + { + "sample": "Test-1", + "queries": 30000, + "seconds": 1.153854 + }, + { + "sample": "Test-2", + "queries": 30000, + "seconds": 1.156495 + }, + { + "sample": "Test-0", + "queries": 30000, + "seconds": 1.31178 + }, + { + "sample": "Test-1", + "queries": 30000, + "seconds": 1.334629 + }, + { + "sample": "Test-2", + "queries": 30000, + "seconds": 1.32847 + } + ], + "finished": "2026-10-29T03:14:51.770277+00:00" + } + }, + "index_files": [ + { + "path": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk25-ada002-1M-2bit-ash/index_cache/graph_ada002_1M_FUSED_ASH_NVQ_VECTORS_M32_ef100_of1.2_alpha1.2_H1_R1_AsymmetricHashing_origDim_1536_encodedBits_1576_bodyBits_1536_quantizedDim_768_bitsPerDimension_2_optimizer_ITQ_landmarks_1_", + "bytes": 7917644448 + } + ], + "finished": "2026-10-29T03:14:51.771473+00:00" + }, + { + "dataset": "ada002-1M", + "display_name": "Ada002", + "original_dimensions": "1536", + "projected_ash_dimensions": "768", + "base_vectors": "982790", + "queries": "10000", + "ash_bits": "2", + "algorithm": "PQ", + "code_bytes": "197", + "ash_encoded_bits": "", + "pq_m": "197", + "config": "ada002-1M-2bit-budget-pq.yml", + "jdk": "25", + "label": "jdk25-ada002-1M-2bit-pq", + "path": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk25-ada002-1M-2bit-pq", + "source": "507b74e7ef8a0e12fcfe402deb4bf758c65758d5", + "started": "2026-10-29T03:14:51.778435+00:00", + "phases": { + "build": { + "elapsed_process_seconds": 54.51408803742379, + "log": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/logs/jdk25-ada002-1M-2bit-pq-build.log", + "csv": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk25-ada002-1M-2bit-pq/logging/jdk25-ada002-1M-2bit-pq-build-20261029-031452Z/experiments.csv", + "rows": [ + { + "schema_version": "1", + "run_id": "jdk25-ada002-1M-2bit-pq-build-20261029-031452Z", + "run_uuid": "cda00aa3-88bc-4643-9c39-6ab3fca2be15", + "system_id": "eafa45791612", + "dataset_name": "ada002-1M", + "M": "32", + "efConstruction": "100", + "neighborOverflow": "1.2", + "addHierarchy": "true", + "refineFinalGraph": "true", + "feature_set": "[NVQ_VECTORS, FUSED_PQ]", + "build_compressor": "\"ProductQuantization(M=197, clusters=256, centered=false)\"", + "search_compressor": "None", + "usePruning": "false", + "topK": "10", + "overquery": "1.0", + "rerankK": "10", + "search.accuracy.recall_at_10": "0.54594", + "construction.index_build_time_s": "42.696928604", + "construction.index_quant_time_s.NVQ.compute_time_s": "0.522869417", + "construction.index_quant_time_s.PQ.compute_time_s": "3.724007606", + "construction.index_quant_time_s.PQ.encoding_time_s": "2.046804322" + } + ], + "throughput_samples": [], + "finished": "2026-10-29T03:15:46.294671+00:00" + }, + "t1": { + "elapsed_process_seconds": 51.58396946080029, + "log": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/logs/jdk25-ada002-1M-2bit-pq-t1.log", + "csv": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk25-ada002-1M-2bit-pq/logging/jdk25-ada002-1M-2bit-pq-t1-20261029-031547Z/experiments.csv", + "rows": [ + { + "schema_version": "1", + "run_id": "jdk25-ada002-1M-2bit-pq-t1-20261029-031547Z", + "run_uuid": "829ad00a-17e6-4512-b0ad-d3d51d2fddb4", + "system_id": "eafa45791612", + "dataset_name": "ada002-1M", + "M": "32", + "efConstruction": "100", + "neighborOverflow": "1.2", + "addHierarchy": "true", + "refineFinalGraph": "true", + "feature_set": "[NVQ_VECTORS, FUSED_PQ]", + "build_compressor": "\"ProductQuantization(M=197, clusters=256, centered=false)\"", + "search_compressor": "None", + "usePruning": "false", + "topK": "10", + "overquery": "1.0", + "rerankK": "10", + "search.throughput.avg_qps": "7271.180761294964", + "search.throughput.stddev_qps": "164.92385726492031", + "search.throughput.cv_pct": "2.2681853563979906", + "search.count.avg_visited": "466.6089", + "search.accuracy.recall_at_10": "0.54594", + "construction.index_build_time_s": "", + "construction.index_quant_time_s.NVQ.compute_time_s": "", + "construction.index_quant_time_s.PQ.compute_time_s": "", + "construction.index_quant_time_s.PQ.encoding_time_s": "" + }, + { + "schema_version": "1", + "run_id": "jdk25-ada002-1M-2bit-pq-t1-20261029-031547Z", + "run_uuid": "829ad00a-17e6-4512-b0ad-d3d51d2fddb4", + "system_id": "eafa45791612", + "dataset_name": "ada002-1M", + "M": "32", + "efConstruction": "100", + "neighborOverflow": "1.2", + "addHierarchy": "true", + "refineFinalGraph": "true", + "feature_set": "[NVQ_VECTORS, FUSED_PQ]", + "build_compressor": "\"ProductQuantization(M=197, clusters=256, centered=false)\"", + "search_compressor": "None", + "usePruning": "false", + "topK": "10", + "overquery": "2.0", + "rerankK": "20", + "search.throughput.avg_qps": "5711.746382358798", + "search.throughput.stddev_qps": "17.585559682677623", + "search.throughput.cv_pct": "0.3078841129394695", + "search.count.avg_visited": "636.743", + "search.accuracy.recall_at_10": "0.7504", + "construction.index_build_time_s": "", + "construction.index_quant_time_s.NVQ.compute_time_s": "", + "construction.index_quant_time_s.PQ.compute_time_s": "", + "construction.index_quant_time_s.PQ.encoding_time_s": "" + } + ], + "throughput_samples": [ + { + "sample": "Test-0", + "queries": 10000, + "seconds": 1.412259 + }, + { + "sample": "Test-1", + "queries": 10000, + "seconds": 1.356497 + }, + { + "sample": "Test-2", + "queries": 10000, + "seconds": 1.358555 + }, + { + "sample": "Test-0", + "queries": 10000, + "seconds": 1.745056 + }, + { + "sample": "Test-1", + "queries": 10000, + "seconds": 1.755746 + }, + { + "sample": "Test-2", + "queries": 10000, + "seconds": 1.751565 + } + ], + "finished": "2026-10-29T03:16:37.879925+00:00" + }, + "t8": { + "elapsed_process_seconds": 22.498855738900602, + "log": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/logs/jdk25-ada002-1M-2bit-pq-t8.log", + "csv": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk25-ada002-1M-2bit-pq/logging/jdk25-ada002-1M-2bit-pq-t8-20261029-031638Z/experiments.csv", + "rows": [ + { + "schema_version": "1", + "run_id": "jdk25-ada002-1M-2bit-pq-t8-20261029-031638Z", + "run_uuid": "81d4b3dd-d49a-4206-bfd9-34bf7e60f897", + "system_id": "eafa45791612", + "dataset_name": "ada002-1M", + "M": "32", + "efConstruction": "100", + "neighborOverflow": "1.2", + "addHierarchy": "true", + "refineFinalGraph": "true", + "feature_set": "[NVQ_VECTORS, FUSED_PQ]", + "build_compressor": "\"ProductQuantization(M=197, clusters=256, centered=false)\"", + "search_compressor": "None", + "usePruning": "false", + "topK": "10", + "overquery": "1.0", + "rerankK": "10", + "search.throughput.avg_qps": "49406.674887911795", + "search.throughput.stddev_qps": "1948.6084898358451", + "search.throughput.cv_pct": "3.9440186862536795", + "search.count.avg_visited": "466.6089", + "search.accuracy.recall_at_10": "0.54594", + "construction.index_build_time_s": "", + "construction.index_quant_time_s.NVQ.compute_time_s": "", + "construction.index_quant_time_s.PQ.compute_time_s": "", + "construction.index_quant_time_s.PQ.encoding_time_s": "" + }, + { + "schema_version": "1", + "run_id": "jdk25-ada002-1M-2bit-pq-t8-20261029-031638Z", + "run_uuid": "81d4b3dd-d49a-4206-bfd9-34bf7e60f897", + "system_id": "eafa45791612", + "dataset_name": "ada002-1M", + "M": "32", + "efConstruction": "100", + "neighborOverflow": "1.2", + "addHierarchy": "true", + "refineFinalGraph": "true", + "feature_set": "[NVQ_VECTORS, FUSED_PQ]", + "build_compressor": "\"ProductQuantization(M=197, clusters=256, centered=false)\"", + "search_compressor": "None", + "usePruning": "false", + "topK": "10", + "overquery": "2.0", + "rerankK": "20", + "search.throughput.avg_qps": "43937.988185794646", + "search.throughput.stddev_qps": "435.3744690698342", + "search.throughput.cv_pct": "0.9908839413148024", + "search.count.avg_visited": "636.743", + "search.accuracy.recall_at_10": "0.7504", + "construction.index_build_time_s": "", + "construction.index_quant_time_s.NVQ.compute_time_s": "", + "construction.index_quant_time_s.PQ.compute_time_s": "", + "construction.index_quant_time_s.PQ.encoding_time_s": "" + } + ], + "throughput_samples": [ + { + "sample": "Test-0", + "queries": 50000, + "seconds": 1.052748 + }, + { + "sample": "Test-1", + "queries": 50000, + "seconds": 1.013474 + }, + { + "sample": "Test-2", + "queries": 60000, + "seconds": 1.167542 + }, + { + "sample": "Test-0", + "queries": 50000, + "seconds": 1.151087 + }, + { + "sample": "Test-1", + "queries": 50000, + "seconds": 1.132495 + }, + { + "sample": "Test-2", + "queries": 50000, + "seconds": 1.130544 + } + ], + "finished": "2026-10-29T03:17:00.380447+00:00" + } + }, + "index_files": [ + { + "path": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk25-ada002-1M-2bit-pq/index_cache/graph_ada002_1M_FUSED_PQ_NVQ_VECTORS_M32_ef100_of1.2_alpha1.2_H1_R1_ProductQuantization_M_197_clusters_256_centered_false_", + "bytes": 7911343988 + } + ], + "finished": "2026-10-29T03:17:00.381014+00:00" + }, + { + "dataset": "ada002-1M", + "display_name": "Ada002", + "original_dimensions": "1536", + "projected_ash_dimensions": "768", + "base_vectors": "982790", + "queries": "10000", + "ash_bits": "4", + "algorithm": "ASH", + "code_bytes": "389", + "ash_encoded_bits": "3112", + "pq_m": "", + "config": "ada002-1M-4bit-budget-ash.yml", + "jdk": "25", + "label": "jdk25-ada002-1M-4bit-ash", + "path": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk25-ada002-1M-4bit-ash", + "source": "507b74e7ef8a0e12fcfe402deb4bf758c65758d5", + "started": "2026-10-29T03:17:00.388271+00:00", + "phases": { + "build": { + "elapsed_process_seconds": 90.95995359588414, + "log": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/logs/jdk25-ada002-1M-4bit-ash-build.log", + "csv": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk25-ada002-1M-4bit-ash/logging/jdk25-ada002-1M-4bit-ash-build-20261029-031700Z/experiments.csv", + "rows": [ + { + "schema_version": "1", + "run_id": "jdk25-ada002-1M-4bit-ash-build-20261029-031700Z", + "run_uuid": "8f9d1a2f-7230-4506-bd6d-7867d23e42b8", + "system_id": "eafa45791612", + "dataset_name": "ada002-1M", + "M": "32", + "efConstruction": "100", + "neighborOverflow": "1.2", + "addHierarchy": "true", + "refineFinalGraph": "true", + "feature_set": "[NVQ_VECTORS, FUSED_ASH]", + "build_compressor": "\"AsymmetricHashing[origDim=1536, encodedBits=3112, bodyBits=3072, quantizedDim=768, bitsPerDimension=4, optimizer=ITQ, landmarks=1]\"", + "search_compressor": "None", + "usePruning": "false", + "topK": "10", + "overquery": "1.0", + "rerankK": "10", + "search.accuracy.recall_at_10": "0.83528", + "construction.index_build_time_s": "60.391773285", + "construction.index_quant_time_s.ASH.compute_time_s": "13.053361983", + "construction.index_quant_time_s.ASH.encoding_time_s": "7.324970714", + "construction.index_quant_time_s.NVQ.compute_time_s": "0.787999096" + } + ], + "throughput_samples": [], + "finished": "2026-10-29T03:18:31.350232+00:00" + }, + "t1": { + "elapsed_process_seconds": 251.54309606924653, + "log": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/logs/jdk25-ada002-1M-4bit-ash-t1.log", + "csv": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk25-ada002-1M-4bit-ash/logging/jdk25-ada002-1M-4bit-ash-t1-20261029-031832Z/experiments.csv", + "rows": [ + { + "schema_version": "1", + "run_id": "jdk25-ada002-1M-4bit-ash-t1-20261029-031832Z", + "run_uuid": "85736aee-e6bc-4c19-b3bf-f4c91f5acce9", + "system_id": "eafa45791612", + "dataset_name": "ada002-1M", + "M": "32", + "efConstruction": "100", + "neighborOverflow": "1.2", + "addHierarchy": "true", + "refineFinalGraph": "true", + "feature_set": "[NVQ_VECTORS, FUSED_ASH]", + "build_compressor": "\"AsymmetricHashing[origDim=1536, encodedBits=3112, bodyBits=3072, quantizedDim=768, bitsPerDimension=4, optimizer=ITQ, landmarks=1]\"", + "search_compressor": "None", + "usePruning": "false", + "topK": "10", + "overquery": "1.0", + "rerankK": "10", + "search.throughput.avg_qps": "2958.5765700966863", + "search.throughput.stddev_qps": "117.34517913112595", + "search.throughput.cv_pct": "3.9662714941087738", + "search.count.avg_visited": "475.7187", + "search.accuracy.recall_at_10": "0.83528", + "construction.index_build_time_s": "", + "construction.index_quant_time_s.ASH.compute_time_s": "", + "construction.index_quant_time_s.ASH.encoding_time_s": "", + "construction.index_quant_time_s.NVQ.compute_time_s": "" + }, + { + "schema_version": "1", + "run_id": "jdk25-ada002-1M-4bit-ash-t1-20261029-031832Z", + "run_uuid": "85736aee-e6bc-4c19-b3bf-f4c91f5acce9", + "system_id": "eafa45791612", + "dataset_name": "ada002-1M", + "M": "32", + "efConstruction": "100", + "neighborOverflow": "1.2", + "addHierarchy": "true", + "refineFinalGraph": "true", + "feature_set": "[NVQ_VECTORS, FUSED_ASH]", + "build_compressor": "\"AsymmetricHashing[origDim=1536, encodedBits=3112, bodyBits=3072, quantizedDim=768, bitsPerDimension=4, optimizer=ITQ, landmarks=1]\"", + "search_compressor": "None", + "usePruning": "false", + "topK": "10", + "overquery": "2.0", + "rerankK": "20", + "search.throughput.avg_qps": "2649.82808625031", + "search.throughput.stddev_qps": "112.41385140796983", + "search.throughput.cv_pct": "4.2423073402864855", + "search.count.avg_visited": "664.8421", + "search.accuracy.recall_at_10": "0.92467", + "construction.index_build_time_s": "", + "construction.index_quant_time_s.ASH.compute_time_s": "", + "construction.index_quant_time_s.ASH.encoding_time_s": "", + "construction.index_quant_time_s.NVQ.compute_time_s": "" + } + ], + "throughput_samples": [ + { + "sample": "Test-0", + "queries": 10000, + "seconds": 3.533463 + }, + { + "sample": "Test-1", + "queries": 10000, + "seconds": 3.349437 + }, + { + "sample": "Test-2", + "queries": 10000, + "seconds": 3.267901 + }, + { + "sample": "Test-0", + "queries": 10000, + "seconds": 3.968156 + }, + { + "sample": "Test-1", + "queries": 10000, + "seconds": 3.679912 + }, + { + "sample": "Test-2", + "queries": 10000, + "seconds": 3.687363 + } + ], + "finished": "2026-10-29T03:22:42.897493+00:00" + }, + "t8": { + "elapsed_process_seconds": 52.648550101555884, + "log": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/logs/jdk25-ada002-1M-4bit-ash-t8.log", + "csv": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk25-ada002-1M-4bit-ash/logging/jdk25-ada002-1M-4bit-ash-t8-20261029-032243Z/experiments.csv", + "rows": [ + { + "schema_version": "1", + "run_id": "jdk25-ada002-1M-4bit-ash-t8-20261029-032243Z", + "run_uuid": "f139c9be-08a5-4a00-a58b-ae91ba665994", + "system_id": "eafa45791612", + "dataset_name": "ada002-1M", + "M": "32", + "efConstruction": "100", + "neighborOverflow": "1.2", + "addHierarchy": "true", + "refineFinalGraph": "true", + "feature_set": "[NVQ_VECTORS, FUSED_ASH]", + "build_compressor": "\"AsymmetricHashing[origDim=1536, encodedBits=3112, bodyBits=3072, quantizedDim=768, bitsPerDimension=4, optimizer=ITQ, landmarks=1]\"", + "search_compressor": "None", + "usePruning": "false", + "topK": "10", + "overquery": "1.0", + "rerankK": "10", + "search.throughput.avg_qps": "21982.92028578378", + "search.throughput.stddev_qps": "927.6380025677681", + "search.throughput.cv_pct": "4.2198124294144215", + "search.count.avg_visited": "475.7187", + "search.accuracy.recall_at_10": "0.83528", + "construction.index_build_time_s": "", + "construction.index_quant_time_s.ASH.compute_time_s": "", + "construction.index_quant_time_s.ASH.encoding_time_s": "", + "construction.index_quant_time_s.NVQ.compute_time_s": "" + }, + { + "schema_version": "1", + "run_id": "jdk25-ada002-1M-4bit-ash-t8-20261029-032243Z", + "run_uuid": "f139c9be-08a5-4a00-a58b-ae91ba665994", + "system_id": "eafa45791612", + "dataset_name": "ada002-1M", + "M": "32", + "efConstruction": "100", + "neighborOverflow": "1.2", + "addHierarchy": "true", + "refineFinalGraph": "true", + "feature_set": "[NVQ_VECTORS, FUSED_ASH]", + "build_compressor": "\"AsymmetricHashing[origDim=1536, encodedBits=3112, bodyBits=3072, quantizedDim=768, bitsPerDimension=4, optimizer=ITQ, landmarks=1]\"", + "search_compressor": "None", + "usePruning": "false", + "topK": "10", + "overquery": "2.0", + "rerankK": "20", + "search.throughput.avg_qps": "20075.60298373417", + "search.throughput.stddev_qps": "614.8878491828754", + "search.throughput.cv_pct": "3.0628611737394644", + "search.count.avg_visited": "664.8421", + "search.accuracy.recall_at_10": "0.92467", + "construction.index_build_time_s": "", + "construction.index_quant_time_s.ASH.compute_time_s": "", + "construction.index_quant_time_s.ASH.encoding_time_s": "", + "construction.index_quant_time_s.NVQ.compute_time_s": "" + } + ], + "throughput_samples": [ + { + "sample": "Test-0", + "queries": 30000, + "seconds": 1.434109 + }, + { + "sample": "Test-1", + "queries": 30000, + "seconds": 1.338809 + }, + { + "sample": "Test-2", + "queries": 30000, + "seconds": 1.326151 + }, + { + "sample": "Test-0", + "queries": 20000, + "seconds": 1.013557 + }, + { + "sample": "Test-1", + "queries": 30000, + "seconds": 1.443315 + }, + { + "sample": "Test-2", + "queries": 20000, + "seconds": 1.014773 + } + ], + "finished": "2026-10-29T03:23:35.547518+00:00" + } + }, + "index_files": [ + { + "path": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk25-ada002-1M-4bit-ash/index_cache/graph_ada002_1M_FUSED_ASH_NVQ_VECTORS_M32_ef100_of1.2_alpha1.2_H1_R1_AsymmetricHashing_origDim_1536_encodedBits_3112_bodyBits_3072_quantizedDim_768_bitsPerDimension_4_optimizer_ITQ_landmarks_1_", + "bytes": 13961804326 + } + ], + "finished": "2026-10-29T03:23:35.548122+00:00" + }, + { + "dataset": "ada002-1M", + "display_name": "Ada002", + "original_dimensions": "1536", + "projected_ash_dimensions": "768", + "base_vectors": "982790", + "queries": "10000", + "ash_bits": "4", + "algorithm": "PQ", + "code_bytes": "389", + "ash_encoded_bits": "", + "pq_m": "389", + "config": "ada002-1M-4bit-budget-pq.yml", + "jdk": "25", + "label": "jdk25-ada002-1M-4bit-pq", + "path": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk25-ada002-1M-4bit-pq", + "source": "507b74e7ef8a0e12fcfe402deb4bf758c65758d5", + "started": "2026-10-29T03:23:35.556325+00:00", + "phases": { + "build": { + "elapsed_process_seconds": 125.37208096962422, + "log": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/logs/jdk25-ada002-1M-4bit-pq-build.log", + "csv": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk25-ada002-1M-4bit-pq/logging/jdk25-ada002-1M-4bit-pq-build-20261029-032335Z/experiments.csv", + "rows": [ + { + "schema_version": "1", + "run_id": "jdk25-ada002-1M-4bit-pq-build-20261029-032335Z", + "run_uuid": "98332961-76ed-44b4-948b-dbaabfa599b6", + "system_id": "eafa45791612", + "dataset_name": "ada002-1M", + "M": "32", + "efConstruction": "100", + "neighborOverflow": "1.2", + "addHierarchy": "true", + "refineFinalGraph": "true", + "feature_set": "[NVQ_VECTORS, FUSED_PQ]", + "build_compressor": "\"ProductQuantization(M=389, clusters=256, centered=false)\"", + "search_compressor": "None", + "usePruning": "false", + "topK": "10", + "overquery": "1.0", + "rerankK": "10", + "search.accuracy.recall_at_10": "0.73566", + "construction.index_build_time_s": "106.252121239", + "construction.index_quant_time_s.NVQ.compute_time_s": "0.494723635", + "construction.index_quant_time_s.PQ.compute_time_s": "8.80672428", + "construction.index_quant_time_s.PQ.encoding_time_s": "3.506287152" + } + ], + "throughput_samples": [], + "finished": "2026-10-29T03:25:40.930514+00:00" + }, + "t1": { + "elapsed_process_seconds": 179.37926136236638, + "log": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/logs/jdk25-ada002-1M-4bit-pq-t1.log", + "csv": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk25-ada002-1M-4bit-pq/logging/jdk25-ada002-1M-4bit-pq-t1-20261029-032541Z/experiments.csv", + "rows": [ + { + "schema_version": "1", + "run_id": "jdk25-ada002-1M-4bit-pq-t1-20261029-032541Z", + "run_uuid": "c4431ce2-56d1-46b8-9ae3-33ece0b08428", + "system_id": "eafa45791612", + "dataset_name": "ada002-1M", + "M": "32", + "efConstruction": "100", + "neighborOverflow": "1.2", + "addHierarchy": "true", + "refineFinalGraph": "true", + "feature_set": "[NVQ_VECTORS, FUSED_PQ]", + "build_compressor": "\"ProductQuantization(M=389, clusters=256, centered=false)\"", + "search_compressor": "None", + "usePruning": "false", + "topK": "10", + "overquery": "1.0", + "rerankK": "10", + "search.throughput.avg_qps": "3664.671687680422", + "search.throughput.stddev_qps": "20.175300036192812", + "search.throughput.cv_pct": "0.5505349934624812", + "search.count.avg_visited": "474.1962", + "search.accuracy.recall_at_10": "0.73566", + "construction.index_build_time_s": "", + "construction.index_quant_time_s.NVQ.compute_time_s": "", + "construction.index_quant_time_s.PQ.compute_time_s": "", + "construction.index_quant_time_s.PQ.encoding_time_s": "" + }, + { + "schema_version": "1", + "run_id": "jdk25-ada002-1M-4bit-pq-t1-20261029-032541Z", + "run_uuid": "c4431ce2-56d1-46b8-9ae3-33ece0b08428", + "system_id": "eafa45791612", + "dataset_name": "ada002-1M", + "M": "32", + "efConstruction": "100", + "neighborOverflow": "1.2", + "addHierarchy": "true", + "refineFinalGraph": "true", + "feature_set": "[NVQ_VECTORS, FUSED_PQ]", + "build_compressor": "\"ProductQuantization(M=389, clusters=256, centered=false)\"", + "search_compressor": "None", + "usePruning": "false", + "topK": "10", + "overquery": "2.0", + "rerankK": "20", + "search.throughput.avg_qps": "3883.6968894121583", + "search.throughput.stddev_qps": "60.99347982756172", + "search.throughput.cv_pct": "1.5705005195911101", + "search.count.avg_visited": "656.8223", + "search.accuracy.recall_at_10": "0.9015", + "construction.index_build_time_s": "", + "construction.index_quant_time_s.NVQ.compute_time_s": "", + "construction.index_quant_time_s.PQ.compute_time_s": "", + "construction.index_quant_time_s.PQ.encoding_time_s": "" + } + ], + "throughput_samples": [ + { + "sample": "Test-0", + "queries": 10000, + "seconds": 2.746211 + }, + { + "sample": "Test-1", + "queries": 10000, + "seconds": 2.720442 + }, + { + "sample": "Test-2", + "queries": 10000, + "seconds": 2.719785 + }, + { + "sample": "Test-0", + "queries": 10000, + "seconds": 2.622408 + }, + { + "sample": "Test-1", + "queries": 10000, + "seconds": 2.550753 + }, + { + "sample": "Test-2", + "queries": 10000, + "seconds": 2.552719 + } + ], + "finished": "2026-10-29T03:28:40.311803+00:00" + }, + "t8": { + "elapsed_process_seconds": 31.153259568847716, + "log": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/logs/jdk25-ada002-1M-4bit-pq-t8.log", + "csv": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk25-ada002-1M-4bit-pq/logging/jdk25-ada002-1M-4bit-pq-t8-20261029-032840Z/experiments.csv", + "rows": [ + { + "schema_version": "1", + "run_id": "jdk25-ada002-1M-4bit-pq-t8-20261029-032840Z", + "run_uuid": "5d213a65-7421-40e0-bdc8-49ce38b80867", + "system_id": "eafa45791612", + "dataset_name": "ada002-1M", + "M": "32", + "efConstruction": "100", + "neighborOverflow": "1.2", + "addHierarchy": "true", + "refineFinalGraph": "true", + "feature_set": "[NVQ_VECTORS, FUSED_PQ]", + "build_compressor": "\"ProductQuantization(M=389, clusters=256, centered=false)\"", + "search_compressor": "None", + "usePruning": "false", + "topK": "10", + "overquery": "1.0", + "rerankK": "10", + "search.throughput.avg_qps": "26979.66563109857", + "search.throughput.stddev_qps": "1180.029335083216", + "search.throughput.cv_pct": "4.373773015641214", + "search.count.avg_visited": "474.1962", + "search.accuracy.recall_at_10": "0.73566", + "construction.index_build_time_s": "", + "construction.index_quant_time_s.NVQ.compute_time_s": "", + "construction.index_quant_time_s.PQ.compute_time_s": "", + "construction.index_quant_time_s.PQ.encoding_time_s": "" + }, + { + "schema_version": "1", + "run_id": "jdk25-ada002-1M-4bit-pq-t8-20261029-032840Z", + "run_uuid": "5d213a65-7421-40e0-bdc8-49ce38b80867", + "system_id": "eafa45791612", + "dataset_name": "ada002-1M", + "M": "32", + "efConstruction": "100", + "neighborOverflow": "1.2", + "addHierarchy": "true", + "refineFinalGraph": "true", + "feature_set": "[NVQ_VECTORS, FUSED_PQ]", + "build_compressor": "\"ProductQuantization(M=389, clusters=256, centered=false)\"", + "search_compressor": "None", + "usePruning": "false", + "topK": "10", + "overquery": "2.0", + "rerankK": "20", + "search.throughput.avg_qps": "22435.812911872614", + "search.throughput.stddev_qps": "174.55195220413958", + "search.throughput.cv_pct": "0.7780059179927015", + "search.count.avg_visited": "656.8223", + "search.accuracy.recall_at_10": "0.9015", + "construction.index_build_time_s": "", + "construction.index_quant_time_s.NVQ.compute_time_s": "", + "construction.index_quant_time_s.PQ.compute_time_s": "", + "construction.index_quant_time_s.PQ.encoding_time_s": "" + } + ], + "throughput_samples": [ + { + "sample": "Test-0", + "queries": 30000, + "seconds": 1.145837 + }, + { + "sample": "Test-1", + "queries": 30000, + "seconds": 1.135413 + }, + { + "sample": "Test-2", + "queries": 30000, + "seconds": 1.058755 + }, + { + "sample": "Test-0", + "queries": 30000, + "seconds": 1.346977 + }, + { + "sample": "Test-1", + "queries": 30000, + "seconds": 1.338338 + }, + { + "sample": "Test-2", + "queries": 30000, + "seconds": 1.32629 + } + ], + "finished": "2026-10-29T03:29:11.466599+00:00" + } + }, + "index_files": [ + { + "path": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk25-ada002-1M-4bit-pq/index_cache/graph_ada002_1M_FUSED_PQ_NVQ_VECTORS_M32_ef100_of1.2_alpha1.2_H1_R1_ProductQuantization_M_389_clusters_256_centered_false_", + "bytes": 13955502981 + } + ], + "finished": "2026-10-29T03:29:11.467171+00:00" + }, + { + "dataset": "openai-3072-1m", + "display_name": "OpenAI3072", + "original_dimensions": "3072", + "projected_ash_dimensions": "1536", + "base_vectors": "999000", + "queries": "1000", + "ash_bits": "2", + "algorithm": "ASH", + "code_bytes": "389", + "ash_encoded_bits": "3112", + "pq_m": "", + "config": "openai-3072-1m-2bit-budget-ash.yml", + "jdk": "25", + "label": "jdk25-openai-3072-1m-2bit-ash", + "path": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk25-openai-3072-1m-2bit-ash", + "source": "507b74e7ef8a0e12fcfe402deb4bf758c65758d5", + "started": "2026-10-29T03:29:11.475179+00:00", + "phases": { + "build": { + "elapsed_process_seconds": 170.93929107300937, + "log": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/logs/jdk25-openai-3072-1m-2bit-ash-build.log", + "csv": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk25-openai-3072-1m-2bit-ash/logging/jdk25-openai-3072-1m-2bit-ash-build-20261029-032911Z/experiments.csv", + "rows": [ + { + "schema_version": "1", + "run_id": "jdk25-openai-3072-1m-2bit-ash-build-20261029-032911Z", + "run_uuid": "c1037cea-ef15-41f1-8426-961eb0eea97c", + "system_id": "eafa45791612", + "dataset_name": "openai-3072-1m", + "M": "32", + "efConstruction": "100", + "neighborOverflow": "1.2", + "addHierarchy": "true", + "refineFinalGraph": "true", + "feature_set": "[NVQ_VECTORS, FUSED_ASH]", + "build_compressor": "\"AsymmetricHashing[origDim=3072, encodedBits=3112, bodyBits=3072, quantizedDim=1536, bitsPerDimension=2, optimizer=ITQ, landmarks=1]\"", + "search_compressor": "None", + "usePruning": "false", + "topK": "10", + "overquery": "1.0", + "rerankK": "10", + "search.accuracy.recall_at_10": "0.7676", + "construction.index_build_time_s": "87.028014205", + "construction.index_quant_time_s.ASH.compute_time_s": "39.411565317", + "construction.index_quant_time_s.ASH.encoding_time_s": "24.278880319", + "construction.index_quant_time_s.NVQ.compute_time_s": "1.4630497" + } + ], + "throughput_samples": [], + "finished": "2026-10-29T03:32:02.418174+00:00" + }, + "t1": { + "elapsed_process_seconds": 54.140950039960444, + "log": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/logs/jdk25-openai-3072-1m-2bit-ash-t1.log", + "csv": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk25-openai-3072-1m-2bit-ash/logging/jdk25-openai-3072-1m-2bit-ash-t1-20261029-033203Z/experiments.csv", + "rows": [ + { + "schema_version": "1", + "run_id": "jdk25-openai-3072-1m-2bit-ash-t1-20261029-033203Z", + "run_uuid": "730e3467-6a50-46bd-9269-058fe758ba3f", + "system_id": "eafa45791612", + "dataset_name": "openai-3072-1m", + "M": "32", + "efConstruction": "100", + "neighborOverflow": "1.2", + "addHierarchy": "true", + "refineFinalGraph": "true", + "feature_set": "[NVQ_VECTORS, FUSED_ASH]", + "build_compressor": "\"AsymmetricHashing[origDim=3072, encodedBits=3112, bodyBits=3072, quantizedDim=1536, bitsPerDimension=2, optimizer=ITQ, landmarks=1]\"", + "search_compressor": "None", + "usePruning": "false", + "topK": "10", + "overquery": "1.0", + "rerankK": "10", + "search.throughput.avg_qps": "1003.0125753831542", + "search.throughput.stddev_qps": "80.02276597676175", + "search.throughput.cv_pct": "7.978241543601064", + "search.count.avg_visited": "532.42", + "search.accuracy.recall_at_10": "0.7676", + "construction.index_build_time_s": "", + "construction.index_quant_time_s.ASH.compute_time_s": "", + "construction.index_quant_time_s.ASH.encoding_time_s": "", + "construction.index_quant_time_s.NVQ.compute_time_s": "" + }, + { + "schema_version": "1", + "run_id": "jdk25-openai-3072-1m-2bit-ash-t1-20261029-033203Z", + "run_uuid": "730e3467-6a50-46bd-9269-058fe758ba3f", + "system_id": "eafa45791612", + "dataset_name": "openai-3072-1m", + "M": "32", + "efConstruction": "100", + "neighborOverflow": "1.2", + "addHierarchy": "true", + "refineFinalGraph": "true", + "feature_set": "[NVQ_VECTORS, FUSED_ASH]", + "build_compressor": "\"AsymmetricHashing[origDim=3072, encodedBits=3112, bodyBits=3072, quantizedDim=1536, bitsPerDimension=2, optimizer=ITQ, landmarks=1]\"", + "search_compressor": "None", + "usePruning": "false", + "topK": "10", + "overquery": "2.0", + "rerankK": "20", + "search.throughput.avg_qps": "1093.9677679889598", + "search.throughput.stddev_qps": "6.6161315083382", + "search.throughput.cv_pct": "0.6047830385808011", + "search.count.avg_visited": "770.301", + "search.accuracy.recall_at_10": "0.8917", + "construction.index_build_time_s": "", + "construction.index_quant_time_s.ASH.compute_time_s": "", + "construction.index_quant_time_s.ASH.encoding_time_s": "", + "construction.index_quant_time_s.NVQ.compute_time_s": "" + } + ], + "throughput_samples": [ + { + "sample": "Test-0", + "queries": 1000, + "seconds": 1.047626 + }, + { + "sample": "Test-1", + "queries": 2000, + "seconds": 1.825855 + }, + { + "sample": "Test-2", + "queries": 1000, + "seconds": 1.042621 + }, + { + "sample": "Test-0", + "queries": 2000, + "seconds": 1.841041 + }, + { + "sample": "Test-1", + "queries": 2000, + "seconds": 1.821205 + }, + { + "sample": "Test-2", + "queries": 2000, + "seconds": 1.82251 + } + ], + "finished": "2026-10-29T03:32:56.564503+00:00" + }, + "t8": { + "elapsed_process_seconds": 25.17004393786192, + "log": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/logs/jdk25-openai-3072-1m-2bit-ash-t8.log", + "csv": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk25-openai-3072-1m-2bit-ash/logging/jdk25-openai-3072-1m-2bit-ash-t8-20261029-033256Z/experiments.csv", + "rows": [ + { + "schema_version": "1", + "run_id": "jdk25-openai-3072-1m-2bit-ash-t8-20261029-033256Z", + "run_uuid": "e7373dd6-efaa-4f56-ac5e-265918525dab", + "system_id": "eafa45791612", + "dataset_name": "openai-3072-1m", + "M": "32", + "efConstruction": "100", + "neighborOverflow": "1.2", + "addHierarchy": "true", + "refineFinalGraph": "true", + "feature_set": "[NVQ_VECTORS, FUSED_ASH]", + "build_compressor": "\"AsymmetricHashing[origDim=3072, encodedBits=3112, bodyBits=3072, quantizedDim=1536, bitsPerDimension=2, optimizer=ITQ, landmarks=1]\"", + "search_compressor": "None", + "usePruning": "false", + "topK": "10", + "overquery": "1.0", + "rerankK": "10", + "search.throughput.avg_qps": "8050.426675043233", + "search.throughput.stddev_qps": "454.19173972355793", + "search.throughput.cv_pct": "5.641834377941449", + "search.count.avg_visited": "532.42", + "search.accuracy.recall_at_10": "0.7676", + "construction.index_build_time_s": "", + "construction.index_quant_time_s.ASH.compute_time_s": "", + "construction.index_quant_time_s.ASH.encoding_time_s": "", + "construction.index_quant_time_s.NVQ.compute_time_s": "" + }, + { + "schema_version": "1", + "run_id": "jdk25-openai-3072-1m-2bit-ash-t8-20261029-033256Z", + "run_uuid": "e7373dd6-efaa-4f56-ac5e-265918525dab", + "system_id": "eafa45791612", + "dataset_name": "openai-3072-1m", + "M": "32", + "efConstruction": "100", + "neighborOverflow": "1.2", + "addHierarchy": "true", + "refineFinalGraph": "true", + "feature_set": "[NVQ_VECTORS, FUSED_ASH]", + "build_compressor": "\"AsymmetricHashing[origDim=3072, encodedBits=3112, bodyBits=3072, quantizedDim=1536, bitsPerDimension=2, optimizer=ITQ, landmarks=1]\"", + "search_compressor": "None", + "usePruning": "false", + "topK": "10", + "overquery": "2.0", + "rerankK": "20", + "search.throughput.avg_qps": "7893.088397249235", + "search.throughput.stddev_qps": "67.54153630967923", + "search.throughput.cv_pct": "0.8557048003316124", + "search.count.avg_visited": "770.301", + "search.accuracy.recall_at_10": "0.8917", + "construction.index_build_time_s": "", + "construction.index_quant_time_s.ASH.compute_time_s": "", + "construction.index_quant_time_s.ASH.encoding_time_s": "", + "construction.index_quant_time_s.NVQ.compute_time_s": "" + } + ], + "throughput_samples": [ + { + "sample": "Test-0", + "queries": 8000, + "seconds": 1.009207 + }, + { + "sample": "Test-1", + "queries": 9000, + "seconds": 1.052192 + }, + { + "sample": "Test-2", + "queries": 8000, + "seconds": 1.04293 + }, + { + "sample": "Test-0", + "queries": 8000, + "seconds": 1.023077 + }, + { + "sample": "Test-1", + "queries": 8000, + "seconds": 1.011716 + }, + { + "sample": "Test-2", + "queries": 8000, + "seconds": 1.005992 + } + ], + "finished": "2026-10-29T03:33:21.736125+00:00" + } + }, + "index_files": [ + { + "path": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk25-openai-3072-1m-2bit-ash/index_cache/graph_openai_3072_1m_FUSED_ASH_NVQ_VECTORS_M32_ef100_of1.2_alpha1.2_H1_R1_AsymmetricHashing_origDim_3072_encodedBits_3112_bodyBits_3072_quantizedDim_1536_bitsPerDimension_2_optimizer_ITQ_landmarks_1_", + "bytes": 15754739812 + } + ], + "finished": "2026-10-29T03:33:21.736748+00:00" + }, + { + "dataset": "openai-3072-1m", + "display_name": "OpenAI3072", + "original_dimensions": "3072", + "projected_ash_dimensions": "1536", + "base_vectors": "999000", + "queries": "1000", + "ash_bits": "2", + "algorithm": "PQ", + "code_bytes": "389", + "ash_encoded_bits": "", + "pq_m": "389", + "config": "openai-3072-1m-2bit-budget-pq.yml", + "jdk": "25", + "label": "jdk25-openai-3072-1m-2bit-pq", + "path": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk25-openai-3072-1m-2bit-pq", + "source": "507b74e7ef8a0e12fcfe402deb4bf758c65758d5", + "started": "2026-10-29T03:33:21.744280+00:00", + "phases": { + "build": { + "elapsed_process_seconds": 135.150859314017, + "log": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/logs/jdk25-openai-3072-1m-2bit-pq-build.log", + "csv": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk25-openai-3072-1m-2bit-pq/logging/jdk25-openai-3072-1m-2bit-pq-build-20261029-033322Z/experiments.csv", + "rows": [ + { + "schema_version": "1", + "run_id": "jdk25-openai-3072-1m-2bit-pq-build-20261029-033322Z", + "run_uuid": "2a9c1084-5e40-4ec4-b2a8-b79060cbb9ab", + "system_id": "eafa45791612", + "dataset_name": "openai-3072-1m", + "M": "32", + "efConstruction": "100", + "neighborOverflow": "1.2", + "addHierarchy": "true", + "refineFinalGraph": "true", + "feature_set": "[NVQ_VECTORS, FUSED_PQ]", + "build_compressor": "\"ProductQuantization(M=389, clusters=256, centered=false)\"", + "search_compressor": "None", + "usePruning": "false", + "topK": "10", + "overquery": "1.0", + "rerankK": "10", + "search.accuracy.recall_at_10": "0.651", + "construction.index_build_time_s": "113.005143545", + "construction.index_quant_time_s.NVQ.compute_time_s": "1.272420274", + "construction.index_quant_time_s.PQ.compute_time_s": "5.897795915", + "construction.index_quant_time_s.PQ.encoding_time_s": "5.580369998" + } + ], + "throughput_samples": [], + "finished": "2026-10-29T03:35:36.896859+00:00" + }, + "t1": { + "elapsed_process_seconds": 38.62861977890134, + "log": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/logs/jdk25-openai-3072-1m-2bit-pq-t1.log", + "csv": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk25-openai-3072-1m-2bit-pq/logging/jdk25-openai-3072-1m-2bit-pq-t1-20261029-033537Z/experiments.csv", + "rows": [ + { + "schema_version": "1", + "run_id": "jdk25-openai-3072-1m-2bit-pq-t1-20261029-033537Z", + "run_uuid": "8169c1eb-d6b4-4c43-9890-899a8c4762ce", + "system_id": "eafa45791612", + "dataset_name": "openai-3072-1m", + "M": "32", + "efConstruction": "100", + "neighborOverflow": "1.2", + "addHierarchy": "true", + "refineFinalGraph": "true", + "feature_set": "[NVQ_VECTORS, FUSED_PQ]", + "build_compressor": "\"ProductQuantization(M=389, clusters=256, centered=false)\"", + "search_compressor": "None", + "usePruning": "false", + "topK": "10", + "overquery": "1.0", + "rerankK": "10", + "search.throughput.avg_qps": "2170.3203408432223", + "search.throughput.stddev_qps": "257.79510337346886", + "search.throughput.cv_pct": "11.878205190359557", + "search.count.avg_visited": "466.217", + "search.accuracy.recall_at_10": "0.651", + "construction.index_build_time_s": "", + "construction.index_quant_time_s.NVQ.compute_time_s": "", + "construction.index_quant_time_s.PQ.compute_time_s": "", + "construction.index_quant_time_s.PQ.encoding_time_s": "" + }, + { + "schema_version": "1", + "run_id": "jdk25-openai-3072-1m-2bit-pq-t1-20261029-033537Z", + "run_uuid": "8169c1eb-d6b4-4c43-9890-899a8c4762ce", + "system_id": "eafa45791612", + "dataset_name": "openai-3072-1m", + "M": "32", + "efConstruction": "100", + "neighborOverflow": "1.2", + "addHierarchy": "true", + "refineFinalGraph": "true", + "feature_set": "[NVQ_VECTORS, FUSED_PQ]", + "build_compressor": "\"ProductQuantization(M=389, clusters=256, centered=false)\"", + "search_compressor": "None", + "usePruning": "false", + "topK": "10", + "overquery": "2.0", + "rerankK": "20", + "search.throughput.avg_qps": "2068.413509232902", + "search.throughput.stddev_qps": "11.500305926225575", + "search.throughput.cv_pct": "0.5559964617757023", + "search.count.avg_visited": "646.845", + "search.accuracy.recall_at_10": "0.8032", + "construction.index_build_time_s": "", + "construction.index_quant_time_s.NVQ.compute_time_s": "", + "construction.index_quant_time_s.PQ.compute_time_s": "", + "construction.index_quant_time_s.PQ.encoding_time_s": "" + } + ], + "throughput_samples": [ + { + "sample": "Test-0", + "queries": 3000, + "seconds": 1.598987 + }, + { + "sample": "Test-1", + "queries": 3000, + "seconds": 1.317101 + }, + { + "sample": "Test-2", + "queries": 3000, + "seconds": 1.272781 + }, + { + "sample": "Test-0", + "queries": 3000, + "seconds": 1.459214 + }, + { + "sample": "Test-1", + "queries": 3000, + "seconds": 1.448704 + }, + { + "sample": "Test-2", + "queries": 3000, + "seconds": 1.443333 + } + ], + "finished": "2026-10-29T03:36:15.529324+00:00" + }, + "t8": { + "elapsed_process_seconds": 22.55379414372146, + "log": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/logs/jdk25-openai-3072-1m-2bit-pq-t8.log", + "csv": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk25-openai-3072-1m-2bit-pq/logging/jdk25-openai-3072-1m-2bit-pq-t8-20261029-033615Z/experiments.csv", + "rows": [ + { + "schema_version": "1", + "run_id": "jdk25-openai-3072-1m-2bit-pq-t8-20261029-033615Z", + "run_uuid": "2a51d587-edca-4be1-93ab-17165a227c81", + "system_id": "eafa45791612", + "dataset_name": "openai-3072-1m", + "M": "32", + "efConstruction": "100", + "neighborOverflow": "1.2", + "addHierarchy": "true", + "refineFinalGraph": "true", + "feature_set": "[NVQ_VECTORS, FUSED_PQ]", + "build_compressor": "\"ProductQuantization(M=389, clusters=256, centered=false)\"", + "search_compressor": "None", + "usePruning": "false", + "topK": "10", + "overquery": "1.0", + "rerankK": "10", + "search.throughput.avg_qps": "27772.466077225967", + "search.throughput.stddev_qps": "451.6869097187096", + "search.throughput.cv_pct": "1.6263838740957284", + "search.count.avg_visited": "466.217", + "search.accuracy.recall_at_10": "0.651", + "construction.index_build_time_s": "", + "construction.index_quant_time_s.NVQ.compute_time_s": "", + "construction.index_quant_time_s.PQ.compute_time_s": "", + "construction.index_quant_time_s.PQ.encoding_time_s": "" + }, + { + "schema_version": "1", + "run_id": "jdk25-openai-3072-1m-2bit-pq-t8-20261029-033615Z", + "run_uuid": "2a51d587-edca-4be1-93ab-17165a227c81", + "system_id": "eafa45791612", + "dataset_name": "openai-3072-1m", + "M": "32", + "efConstruction": "100", + "neighborOverflow": "1.2", + "addHierarchy": "true", + "refineFinalGraph": "true", + "feature_set": "[NVQ_VECTORS, FUSED_PQ]", + "build_compressor": "\"ProductQuantization(M=389, clusters=256, centered=false)\"", + "search_compressor": "None", + "usePruning": "false", + "topK": "10", + "overquery": "2.0", + "rerankK": "20", + "search.throughput.avg_qps": "23213.773739290547", + "search.throughput.stddev_qps": "250.71842094376962", + "search.throughput.cv_pct": "1.0800416328665052", + "search.count.avg_visited": "646.845", + "search.accuracy.recall_at_10": "0.8032", + "construction.index_build_time_s": "", + "construction.index_quant_time_s.NVQ.compute_time_s": "", + "construction.index_quant_time_s.PQ.compute_time_s": "", + "construction.index_quant_time_s.PQ.encoding_time_s": "" + } + ], + "throughput_samples": [ + { + "sample": "Test-0", + "queries": 28000, + "seconds": 1.020852 + }, + { + "sample": "Test-1", + "queries": 29000, + "seconds": 1.025319 + }, + { + "sample": "Test-2", + "queries": 28000, + "seconds": 1.014293 + }, + { + "sample": "Test-0", + "queries": 24000, + "seconds": 1.038152 + }, + { + "sample": "Test-1", + "queries": 24000, + "seconds": 1.042343 + }, + { + "sample": "Test-2", + "queries": 24000, + "seconds": 1.021352 + } + ], + "finished": "2026-10-29T03:36:38.084606+00:00" + } + }, + "index_files": [ + { + "path": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk25-openai-3072-1m-2bit-pq/index_cache/graph_openai_3072_1m_FUSED_PQ_NVQ_VECTORS_M32_ef100_of1.2_alpha1.2_H1_R1_ProductQuantization_M_389_clusters_256_centered_false_", + "bytes": 15723256259 + } + ], + "finished": "2026-10-29T03:36:38.085181+00:00" + }, + { + "dataset": "openai-3072-1m", + "display_name": "OpenAI3072", + "original_dimensions": "3072", + "projected_ash_dimensions": "1536", + "base_vectors": "999000", + "queries": "1000", + "ash_bits": "4", + "algorithm": "ASH", + "code_bytes": "773", + "ash_encoded_bits": "6184", + "pq_m": "", + "config": "openai-3072-1m-4bit-budget-ash.yml", + "jdk": "25", + "label": "jdk25-openai-3072-1m-4bit-ash", + "path": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk25-openai-3072-1m-4bit-ash", + "source": "507b74e7ef8a0e12fcfe402deb4bf758c65758d5", + "started": "2026-10-29T03:36:38.093050+00:00", + "phases": { + "build": { + "elapsed_process_seconds": 213.53237825259566, + "log": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/logs/jdk25-openai-3072-1m-4bit-ash-build.log", + "csv": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk25-openai-3072-1m-4bit-ash/logging/jdk25-openai-3072-1m-4bit-ash-build-20261029-033638Z/experiments.csv", + "rows": [ + { + "schema_version": "1", + "run_id": "jdk25-openai-3072-1m-4bit-ash-build-20261029-033638Z", + "run_uuid": "6b505122-3861-46b7-97b6-11f4738cf959", + "system_id": "eafa45791612", + "dataset_name": "openai-3072-1m", + "M": "32", + "efConstruction": "100", + "neighborOverflow": "1.2", + "addHierarchy": "true", + "refineFinalGraph": "true", + "feature_set": "[NVQ_VECTORS, FUSED_ASH]", + "build_compressor": "\"AsymmetricHashing[origDim=3072, encodedBits=6184, bodyBits=6144, quantizedDim=1536, bitsPerDimension=4, optimizer=ITQ, landmarks=1]\"", + "search_compressor": "None", + "usePruning": "false", + "topK": "10", + "overquery": "1.0", + "rerankK": "10", + "search.accuracy.recall_at_10": "0.7965", + "construction.index_build_time_s": "130.569217494", + "construction.index_quant_time_s.ASH.compute_time_s": "38.63113482", + "construction.index_quant_time_s.ASH.encoding_time_s": "25.960155066", + "construction.index_quant_time_s.NVQ.compute_time_s": "1.1549786" + } + ], + "throughput_samples": [], + "finished": "2026-10-29T03:40:11.627475+00:00" + }, + "t1": { + "elapsed_process_seconds": 53.90180575661361, + "log": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/logs/jdk25-openai-3072-1m-4bit-ash-t1.log", + "csv": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk25-openai-3072-1m-4bit-ash/logging/jdk25-openai-3072-1m-4bit-ash-t1-20261029-034012Z/experiments.csv", + "rows": [ + { + "schema_version": "1", + "run_id": "jdk25-openai-3072-1m-4bit-ash-t1-20261029-034012Z", + "run_uuid": "faf18127-3224-4f0e-a5c9-7530ecefcc56", + "system_id": "eafa45791612", + "dataset_name": "openai-3072-1m", + "M": "32", + "efConstruction": "100", + "neighborOverflow": "1.2", + "addHierarchy": "true", + "refineFinalGraph": "true", + "feature_set": "[NVQ_VECTORS, FUSED_ASH]", + "build_compressor": "\"AsymmetricHashing[origDim=3072, encodedBits=6184, bodyBits=6144, quantizedDim=1536, bitsPerDimension=4, optimizer=ITQ, landmarks=1]\"", + "search_compressor": "None", + "usePruning": "false", + "topK": "10", + "overquery": "1.0", + "rerankK": "10", + "search.throughput.avg_qps": "964.9450620510337", + "search.throughput.stddev_qps": "132.69872297613534", + "search.throughput.cv_pct": "13.751945908099502", + "search.count.avg_visited": "510.697", + "search.accuracy.recall_at_10": "0.7965", + "construction.index_build_time_s": "", + "construction.index_quant_time_s.ASH.compute_time_s": "", + "construction.index_quant_time_s.ASH.encoding_time_s": "", + "construction.index_quant_time_s.NVQ.compute_time_s": "" + }, + { + "schema_version": "1", + "run_id": "jdk25-openai-3072-1m-4bit-ash-t1-20261029-034012Z", + "run_uuid": "faf18127-3224-4f0e-a5c9-7530ecefcc56", + "system_id": "eafa45791612", + "dataset_name": "openai-3072-1m", + "M": "32", + "efConstruction": "100", + "neighborOverflow": "1.2", + "addHierarchy": "true", + "refineFinalGraph": "true", + "feature_set": "[NVQ_VECTORS, FUSED_ASH]", + "build_compressor": "\"AsymmetricHashing[origDim=3072, encodedBits=6184, bodyBits=6144, quantizedDim=1536, bitsPerDimension=4, optimizer=ITQ, landmarks=1]\"", + "search_compressor": "None", + "usePruning": "false", + "topK": "10", + "overquery": "2.0", + "rerankK": "20", + "search.throughput.avg_qps": "1033.6181669420773", + "search.throughput.stddev_qps": "9.002443152648716", + "search.throughput.cv_pct": "0.8709640987911547", + "search.count.avg_visited": "730.922", + "search.accuracy.recall_at_10": "0.8875", + "construction.index_build_time_s": "", + "construction.index_quant_time_s.ASH.compute_time_s": "", + "construction.index_quant_time_s.ASH.encoding_time_s": "", + "construction.index_quant_time_s.NVQ.compute_time_s": "" + } + ], + "throughput_samples": [ + { + "sample": "Test-0", + "queries": 1000, + "seconds": 1.200646 + }, + { + "sample": "Test-1", + "queries": 2000, + "seconds": 1.821041 + }, + { + "sample": "Test-2", + "queries": 1000, + "seconds": 1.037692 + }, + { + "sample": "Test-0", + "queries": 2000, + "seconds": 1.954478 + }, + { + "sample": "Test-1", + "queries": 2000, + "seconds": 1.927239 + }, + { + "sample": "Test-2", + "queries": 2000, + "seconds": 1.923429 + } + ], + "finished": "2026-10-29T03:41:05.537347+00:00" + }, + "t8": { + "elapsed_process_seconds": 24.775268671102822, + "log": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/logs/jdk25-openai-3072-1m-4bit-ash-t8.log", + "csv": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk25-openai-3072-1m-4bit-ash/logging/jdk25-openai-3072-1m-4bit-ash-t8-20261029-034105Z/experiments.csv", + "rows": [ + { + "schema_version": "1", + "run_id": "jdk25-openai-3072-1m-4bit-ash-t8-20261029-034105Z", + "run_uuid": "06ea99f1-ef19-4f81-a1f3-baf497b2e990", + "system_id": "eafa45791612", + "dataset_name": "openai-3072-1m", + "M": "32", + "efConstruction": "100", + "neighborOverflow": "1.2", + "addHierarchy": "true", + "refineFinalGraph": "true", + "feature_set": "[NVQ_VECTORS, FUSED_ASH]", + "build_compressor": "\"AsymmetricHashing[origDim=3072, encodedBits=6184, bodyBits=6144, quantizedDim=1536, bitsPerDimension=4, optimizer=ITQ, landmarks=1]\"", + "search_compressor": "None", + "usePruning": "false", + "topK": "10", + "overquery": "1.0", + "rerankK": "10", + "search.throughput.avg_qps": "7915.294433750889", + "search.throughput.stddev_qps": "199.40736877030656", + "search.throughput.cv_pct": "2.519266597588988", + "search.count.avg_visited": "510.697", + "search.accuracy.recall_at_10": "0.7965", + "construction.index_build_time_s": "", + "construction.index_quant_time_s.ASH.compute_time_s": "", + "construction.index_quant_time_s.ASH.encoding_time_s": "", + "construction.index_quant_time_s.NVQ.compute_time_s": "" + }, + { + "schema_version": "1", + "run_id": "jdk25-openai-3072-1m-4bit-ash-t8-20261029-034105Z", + "run_uuid": "06ea99f1-ef19-4f81-a1f3-baf497b2e990", + "system_id": "eafa45791612", + "dataset_name": "openai-3072-1m", + "M": "32", + "efConstruction": "100", + "neighborOverflow": "1.2", + "addHierarchy": "true", + "refineFinalGraph": "true", + "feature_set": "[NVQ_VECTORS, FUSED_ASH]", + "build_compressor": "\"AsymmetricHashing[origDim=3072, encodedBits=6184, bodyBits=6144, quantizedDim=1536, bitsPerDimension=4, optimizer=ITQ, landmarks=1]\"", + "search_compressor": "None", + "usePruning": "false", + "topK": "10", + "overquery": "2.0", + "rerankK": "20", + "search.throughput.avg_qps": "7489.111448400777", + "search.throughput.stddev_qps": "101.00421873730507", + "search.throughput.cv_pct": "1.34868094076599", + "search.count.avg_visited": "730.922", + "search.accuracy.recall_at_10": "0.8875", + "construction.index_build_time_s": "", + "construction.index_quant_time_s.ASH.compute_time_s": "", + "construction.index_quant_time_s.ASH.encoding_time_s": "", + "construction.index_quant_time_s.NVQ.compute_time_s": "" + } + ], + "throughput_samples": [ + { + "sample": "Test-0", + "queries": 8000, + "seconds": 1.039072 + }, + { + "sample": "Test-1", + "queries": 8000, + "seconds": 1.005713 + }, + { + "sample": "Test-2", + "queries": 9000, + "seconds": 1.112189 + }, + { + "sample": "Test-0", + "queries": 8000, + "seconds": 1.05616 + }, + { + "sample": "Test-1", + "queries": 8000, + "seconds": 1.064529 + }, + { + "sample": "Test-2", + "queries": 8000, + "seconds": 1.084354 + } + ], + "finished": "2026-10-29T03:41:30.314433+00:00" + } + }, + "index_files": [ + { + "path": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk25-openai-3072-1m-4bit-ash/index_cache/graph_openai_3072_1m_FUSED_ASH_NVQ_VECTORS_M32_ef100_of1.2_alpha1.2_H1_R1_AsymmetricHashing_origDim_3072_encodedBits_6184_bodyBits_6144_quantizedDim_1536_bitsPerDimension_4_optimizer_ITQ_landmarks_1_", + "bytes": 28042443654 + } + ], + "finished": "2026-10-29T03:41:30.315002+00:00" + }, + { + "dataset": "openai-3072-1m", + "display_name": "OpenAI3072", + "original_dimensions": "3072", + "projected_ash_dimensions": "1536", + "base_vectors": "999000", + "queries": "1000", + "ash_bits": "4", + "algorithm": "PQ", + "code_bytes": "773", + "ash_encoded_bits": "", + "pq_m": "773", + "config": "openai-3072-1m-4bit-budget-pq.yml", + "jdk": "25", + "label": "jdk25-openai-3072-1m-4bit-pq", + "path": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk25-openai-3072-1m-4bit-pq", + "source": "507b74e7ef8a0e12fcfe402deb4bf758c65758d5", + "started": "2026-10-29T03:41:30.322936+00:00", + "phases": { + "build": { + "elapsed_process_seconds": 281.5635087201372, + "log": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/logs/jdk25-openai-3072-1m-4bit-pq-build.log", + "csv": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk25-openai-3072-1m-4bit-pq/logging/jdk25-openai-3072-1m-4bit-pq-build-20261029-034130Z/experiments.csv", + "rows": [ + { + "schema_version": "1", + "run_id": "jdk25-openai-3072-1m-4bit-pq-build-20261029-034130Z", + "run_uuid": "43b54b2a-4f1a-4229-aa99-1bfef4eeed5b", + "system_id": "eafa45791612", + "dataset_name": "openai-3072-1m", + "M": "32", + "efConstruction": "100", + "neighborOverflow": "1.2", + "addHierarchy": "true", + "refineFinalGraph": "true", + "feature_set": "[NVQ_VECTORS, FUSED_PQ]", + "build_compressor": "\"ProductQuantization(M=773, clusters=256, centered=false)\"", + "search_compressor": "None", + "usePruning": "false", + "topK": "10", + "overquery": "1.0", + "rerankK": "10", + "search.accuracy.recall_at_10": "0.7624", + "construction.index_build_time_s": "247.52862079", + "construction.index_quant_time_s.NVQ.compute_time_s": "0.962668797", + "construction.index_quant_time_s.PQ.compute_time_s": "16.624147058", + "construction.index_quant_time_s.PQ.encoding_time_s": "7.778564805" + } + ], + "throughput_samples": [], + "finished": "2026-10-29T03:46:11.888604+00:00" + }, + "t1": { + "elapsed_process_seconds": 39.05060641467571, + "log": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/logs/jdk25-openai-3072-1m-4bit-pq-t1.log", + "csv": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk25-openai-3072-1m-4bit-pq/logging/jdk25-openai-3072-1m-4bit-pq-t1-20261029-034612Z/experiments.csv", + "rows": [ + { + "schema_version": "1", + "run_id": "jdk25-openai-3072-1m-4bit-pq-t1-20261029-034612Z", + "run_uuid": "e70efc29-4365-409e-ac20-fec8da03aefb", + "system_id": "eafa45791612", + "dataset_name": "openai-3072-1m", + "M": "32", + "efConstruction": "100", + "neighborOverflow": "1.2", + "addHierarchy": "true", + "refineFinalGraph": "true", + "feature_set": "[NVQ_VECTORS, FUSED_PQ]", + "build_compressor": "\"ProductQuantization(M=773, clusters=256, centered=false)\"", + "search_compressor": "None", + "usePruning": "false", + "topK": "10", + "overquery": "1.0", + "rerankK": "10", + "search.throughput.avg_qps": "2280.759101685725", + "search.throughput.stddev_qps": "228.66633119364224", + "search.throughput.cv_pct": "10.025887040180322", + "search.count.avg_visited": "501.437", + "search.accuracy.recall_at_10": "0.7624", + "construction.index_build_time_s": "", + "construction.index_quant_time_s.NVQ.compute_time_s": "", + "construction.index_quant_time_s.PQ.compute_time_s": "", + "construction.index_quant_time_s.PQ.encoding_time_s": "" + }, + { + "schema_version": "1", + "run_id": "jdk25-openai-3072-1m-4bit-pq-t1-20261029-034612Z", + "run_uuid": "e70efc29-4365-409e-ac20-fec8da03aefb", + "system_id": "eafa45791612", + "dataset_name": "openai-3072-1m", + "M": "32", + "efConstruction": "100", + "neighborOverflow": "1.2", + "addHierarchy": "true", + "refineFinalGraph": "true", + "feature_set": "[NVQ_VECTORS, FUSED_PQ]", + "build_compressor": "\"ProductQuantization(M=773, clusters=256, centered=false)\"", + "search_compressor": "None", + "usePruning": "false", + "topK": "10", + "overquery": "2.0", + "rerankK": "20", + "search.throughput.avg_qps": "1886.2499788294274", + "search.throughput.stddev_qps": "10.71340400192702", + "search.throughput.cv_pct": "0.5679737109169148", + "search.count.avg_visited": "705.403", + "search.accuracy.recall_at_10": "0.8727", + "construction.index_build_time_s": "", + "construction.index_quant_time_s.NVQ.compute_time_s": "", + "construction.index_quant_time_s.PQ.compute_time_s": "", + "construction.index_quant_time_s.PQ.encoding_time_s": "" + } + ], + "throughput_samples": [ + { + "sample": "Test-0", + "queries": 3000, + "seconds": 1.487416 + }, + { + "sample": "Test-1", + "queries": 3000, + "seconds": 1.248072 + }, + { + "sample": "Test-2", + "queries": 3000, + "seconds": 1.238825 + }, + { + "sample": "Test-0", + "queries": 2000, + "seconds": 1.067022 + }, + { + "sample": "Test-1", + "queries": 2000, + "seconds": 1.05866 + }, + { + "sample": "Test-2", + "queries": 2000, + "seconds": 1.055302 + } + ], + "finished": "2026-10-29T03:46:50.940557+00:00" + }, + "t8": { + "elapsed_process_seconds": 22.850516594946384, + "log": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/logs/jdk25-openai-3072-1m-4bit-pq-t8.log", + "csv": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk25-openai-3072-1m-4bit-pq/logging/jdk25-openai-3072-1m-4bit-pq-t8-20261029-034651Z/experiments.csv", + "rows": [ + { + "schema_version": "1", + "run_id": "jdk25-openai-3072-1m-4bit-pq-t8-20261029-034651Z", + "run_uuid": "db0e04eb-cab5-422f-86bb-0e27f94aace0", + "system_id": "eafa45791612", + "dataset_name": "openai-3072-1m", + "M": "32", + "efConstruction": "100", + "neighborOverflow": "1.2", + "addHierarchy": "true", + "refineFinalGraph": "true", + "feature_set": "[NVQ_VECTORS, FUSED_PQ]", + "build_compressor": "\"ProductQuantization(M=773, clusters=256, centered=false)\"", + "search_compressor": "None", + "usePruning": "false", + "topK": "10", + "overquery": "1.0", + "rerankK": "10", + "search.throughput.avg_qps": "15663.804691288786", + "search.throughput.stddev_qps": "513.7893416245771", + "search.throughput.cv_pct": "3.280105643236947", + "search.count.avg_visited": "501.437", + "search.accuracy.recall_at_10": "0.7624", + "construction.index_build_time_s": "", + "construction.index_quant_time_s.NVQ.compute_time_s": "", + "construction.index_quant_time_s.PQ.compute_time_s": "", + "construction.index_quant_time_s.PQ.encoding_time_s": "" + }, + { + "schema_version": "1", + "run_id": "jdk25-openai-3072-1m-4bit-pq-t8-20261029-034651Z", + "run_uuid": "db0e04eb-cab5-422f-86bb-0e27f94aace0", + "system_id": "eafa45791612", + "dataset_name": "openai-3072-1m", + "M": "32", + "efConstruction": "100", + "neighborOverflow": "1.2", + "addHierarchy": "true", + "refineFinalGraph": "true", + "feature_set": "[NVQ_VECTORS, FUSED_PQ]", + "build_compressor": "\"ProductQuantization(M=773, clusters=256, centered=false)\"", + "search_compressor": "None", + "usePruning": "false", + "topK": "10", + "overquery": "2.0", + "rerankK": "20", + "search.throughput.avg_qps": "12884.883579153513", + "search.throughput.stddev_qps": "87.90944686405366", + "search.throughput.cv_pct": "0.6822680726916507", + "search.count.avg_visited": "705.403", + "search.accuracy.recall_at_10": "0.8727", + "construction.index_build_time_s": "", + "construction.index_quant_time_s.NVQ.compute_time_s": "", + "construction.index_quant_time_s.PQ.compute_time_s": "", + "construction.index_quant_time_s.PQ.encoding_time_s": "" + } + ], + "throughput_samples": [ + { + "sample": "Test-0", + "queries": 16000, + "seconds": 1.059179 + }, + { + "sample": "Test-1", + "queries": 17000, + "seconds": 1.054736 + }, + { + "sample": "Test-2", + "queries": 16000, + "seconds": 1.014739 + }, + { + "sample": "Test-0", + "queries": 13000, + "seconds": 1.012352 + }, + { + "sample": "Test-1", + "queries": 14000, + "seconds": 1.078079 + }, + { + "sample": "Test-2", + "queries": 13000, + "seconds": 1.013471 + } + ], + "finished": "2026-10-29T03:47:13.792536+00:00" + } + }, + "index_files": [ + { + "path": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk25-openai-3072-1m-4bit-pq/index_cache/graph_openai_3072_1m_FUSED_PQ_NVQ_VECTORS_M32_ef100_of1.2_alpha1.2_H1_R1_ProductQuantization_M_773_clusters_256_centered_false_", + "bytes": 28010963369 + } + ], + "finished": "2026-10-29T03:47:13.793206+00:00" + } +] diff --git a/docs/benchmarks/native-pq-ash-graph-20261028/report.md b/docs/benchmarks/native-pq-ash-graph-20261028/report.md new file mode 100644 index 000000000..9bffcbcea --- /dev/null +++ b/docs/benchmarks/native-pq-ash-graph-20261028/report.md @@ -0,0 +1,90 @@ +# Native PQ versus trained ASH with graph indexing + +Standard BenchYAML with ASH PCA correction `507b74e7ef8a0e12fcfe402deb4bf758c65758d5`. Four complete cleaned datasets, both JDK 22 and JDK 25, two exact code-byte budgets. All 32 final graphs and 64 cached query processes completed. No changes to BenchYAML or its timers were used. + +A native PCA correctness failure interrupted the first collection. The QR SVD fast path returned incorrect decompositions on unchanged finite input with 48 MKL threads on both JDKs, sometimes reporting success. ASH PCA now uses a validated divide-and-conquer SVD. All ASH results below were recollected after the fix; earlier ASH graph records are archived as superseded. Nine completed PQ cases from source b44e25b7 were retained: every non-ASH runtime file was verified byte-for-byte identical after the correction. Each raw record retains its actual source SHA. See [the PCA investigation](../../ash-pca-native-svd.md) and [the runtime audit](pca-fix-runtime-audit.json). + +Both algorithms use the native Highway provider, format V7, M=32, efConstruction=100, overflow=1.2, hierarchy and final refinement enabled, deferred parallel complete-record writes (batch 64), and NVQ reranking (two 8-bit subvectors). ASH uses C=1, ITQ training with production defaults, projected dimension D/2, and 2 or 4 bits per projected dimension. PQ uses K=256, no centering and anisotropy -1. Search pruning is disabled. Each query count searches the identical saved graph. + +The existing configured system opts ASH into adaptive DOT_PRODUCT stopping; PQ uses its existing stopping path. `useSearchPruning: No` does not disable that separate ASH policy. The comparison includes these current construction and stopping policies; it does not isolate quantizer error alone. No policy was changed in response to measured results. + +Compression budgets match stored ASH/PQ code bytes, including ASH’s 5-byte header. Shared models, graph structure and NVQ storage are additional. “2-bit/4-bit budget” refers to the ASH setting, not PQ bits per original dimension. + +| Dataset | Base vectors | Queries | D → ASH d | Code bytes, 2-bit / 4-bit budget | +|---|---:|---:|---:|---:| +| MPNet | 999,812 | 10,000 | 768 → 384 | 101 / 197 | +| Cohere | 1,000,000 | 10,000 | 1024 → 512 | 133 / 261 | +| Ada002 | 982,790 | 10,000 | 1536 → 768 | 197 / 389 | +| OpenAI3072 | 999,000 | 1,000 | 3072 → 1536 | 389 / 773 | + +## Construction — seconds + +Each cell is **PQ → ASH**. Training, encoding and graph/write are the harness’s existing CSV measurements. They are reported as measured, without replacing the timers or subtracting work. + +**Total process** is externally measured wall time of the fresh construction JVM, including startup, dataset loading, construction, one standard recall@10 validation pass at 1×, and teardown. It includes that accuracy pass; it is not labeled build-only. QPS warmup and measurement run in separate cached JVMs and are excluded. NVQ setup/encoding and other setup costs remain part of the actual process; the displayed phase columns need not sum to total process. + +| Dataset | JDK | ASH bit budget | Training | Encoding | Graph/write | Total process incl. validation | +|---|---:|---:|---:|---:|---:|---:| +| MPNet | 22 | 2 | 2.73 → 5.21 | 1.08 → 1.09 | 44.44 → 58.37 | 52.27 → 70.72 | +| MPNet | 22 | 4 | 6.14 → 4.69 | 1.71 → 1.19 | 50.79 → 42.03 | 62.75 → 54.34 | +| MPNet | 25 | 2 | 2.77 → 4.80 | 1.05 → 1.00 | 33.77 → 31.63 | 41.55 → 43.57 | +| MPNet | 25 | 4 | 4.65 → 4.45 | 1.75 → 1.20 | 48.55 → 43.29 | 58.95 → 54.84 | +| Cohere | 22 | 2 | 2.97 → 8.03 | 1.32 → 1.86 | 35.40 → 62.37 | 44.33 → 96.01 | +| Cohere | 22 | 4 | 6.75 → 6.62 | 2.30 → 2.17 | 70.71 → 49.73 | 84.35 → 66.19 | +| Cohere | 25 | 2 | 3.08 → 7.50 | 1.33 → 1.64 | 40.66 → 32.97 | 49.73 → 52.84 | +| Cohere | 25 | 4 | 6.57 → 6.46 | 2.23 → 2.06 | 66.55 → 46.25 | 80.16 → 62.54 | +| Ada002 | 22 | 2 | 3.93 → 13.71 | 2.20 → 7.91 | 48.12 → 49.60 | 60.27 → 81.40 | +| Ada002 | 22 | 4 | 8.62 → 10.95 | 3.61 → 7.43 | 100.92 → 56.91 | 119.42 → 85.25 | +| Ada002 | 25 | 2 | 3.72 → 13.82 | 2.05 → 8.28 | 42.70 → 45.40 | 54.51 → 81.50 | +| Ada002 | 25 | 4 | 8.81 → 13.05 | 3.51 → 7.32 | 106.25 → 60.39 | 125.37 → 90.96 | +| OpenAI3072 | 22 | 2 | 6.51 → 41.12 | 5.51 → 24.10 | 90.50 → 85.71 | 113.08 → 169.55 | +| OpenAI3072 | 22 | 4 | 19.65 → 39.54 | 7.64 → 27.21 | 257.82 → 124.33 | 295.42 → 208.44 | +| OpenAI3072 | 25 | 2 | 5.90 → 39.41 | 5.58 → 24.28 | 113.01 → 87.03 | 135.15 → 170.94 | +| OpenAI3072 | 25 | 4 | 16.62 → 38.63 | 7.78 → 25.96 | 247.53 → 130.57 | 281.56 → 213.53 | + +## Queries — recall@10 and QPS + +Each cell is **PQ → ASH**. Recall is a percentage. 1× and 2× rerank 10 and 20 candidates respectively to return 10. QPS includes the graph search, normal query preparation and NVQ reranking. Construction uses 48 physical cores; throughput uses 1 or 8 scoring threads, pinned to 1 or 8 physical cores. Standard BenchYAML warmup and three measured samples are retained. No JIT compilation logs, per-call tracing or profiler runs were used for these measurements. + +### 2-bit ASH code-byte budget + +| Dataset | JDK | Recall 1× (%) | Recall 2× (%) | 1 thread, 1× QPS | 1 thread, 2× QPS | 8 threads, 1× QPS | 8 threads, 2× QPS | +|---|---:|---:|---:|---:|---:|---:|---:| +| MPNet | 22 | 65.13 → 81.94 | 82.13 → 94.06 | 11,930 → 9,611 | 9,441 → 7,822 | 86,215 → 70,990 | 68,760 → 59,039 | +| MPNet | 25 | 65.48 → 82.02 | 82.21 → 93.97 | 11,812 → 10,357 | 9,294 → 8,282 | 76,802 → 73,841 | 71,112 → 60,434 | +| Cohere | 22 | 59.43 → 63.90 | 78.29 → 81.99 | 9,695 → 6,041 | 7,700 → 5,224 | 74,000 → 47,759 | 59,165 → 39,833 | +| Cohere | 25 | 59.13 → 63.98 | 78.45 → 82.33 | 9,412 → 6,427 | 7,191 → 5,258 | 75,638 → 50,148 | 57,863 → 41,902 | +| Ada002 | 22 | 54.99 → 78.88 | 75.35 → 92.69 | 7,065 → 3,301 | 5,685 → 2,987 | 53,847 → 24,911 | 45,513 → 22,128 | +| Ada002 | 25 | 54.59 → 79.06 | 75.04 → 92.72 | 7,271 → 3,437 | 5,712 → 3,097 | 49,407 → 25,149 | 43,938 → 22,643 | +| OpenAI3072 | 22 | 64.37 → 77.92 | 79.85 → 89.80 | 3,329 → 1,031 | 2,954 → 1,068 | 27,544 → 7,190 | 22,785 → 7,037 | +| OpenAI3072 | 25 | 65.10 → 76.76 | 80.32 → 89.17 | 2,170 → 1,003 | 2,068 → 1,094 | 27,772 → 8,050 | 23,214 → 7,893 | + +### 4-bit ASH code-byte budget + +| Dataset | JDK | Recall 1× (%) | Recall 2× (%) | 1 thread, 1× QPS | 1 thread, 2× QPS | 8 threads, 1× QPS | 8 threads, 2× QPS | +|---|---:|---:|---:|---:|---:|---:|---:| +| MPNet | 22 | 80.73 → 86.64 | 92.75 → 93.97 | 9,643 → 8,630 | 7,456 → 6,976 | 74,893 → 64,520 | 57,187 → 52,279 | +| MPNet | 25 | 80.75 → 86.29 | 92.60 → 94.03 | 10,001 → 9,154 | 7,557 → 7,115 | 74,887 → 67,390 | 56,905 → 54,358 | +| Cohere | 22 | 65.08 → 70.69 | 81.04 → 83.40 | 7,656 → 5,460 | 5,826 → 4,610 | 57,545 → 41,673 | 43,366 → 35,207 | +| Cohere | 25 | 65.53 → 70.59 | 81.36 → 83.70 | 7,858 → 5,662 | 5,921 → 4,636 | 57,671 → 42,689 | 43,805 → 35,645 | +| Ada002 | 22 | 73.56 → 83.23 | 90.05 → 92.44 | 5,341 → 2,976 | 4,200 → 2,699 | 41,039 → 21,871 | 30,913 → 19,259 | +| Ada002 | 25 | 73.57 → 83.53 | 90.15 → 92.47 | 3,665 → 2,959 | 3,884 → 2,650 | 26,980 → 21,983 | 22,436 → 20,076 | +| OpenAI3072 | 22 | 76.91 → 79.03 | 87.70 → 88.34 | 2,144 → 1,016 | 1,871 → 1,031 | 18,467 → 7,508 | 14,336 → 7,361 | +| OpenAI3072 | 25 | 76.24 → 79.65 | 87.27 → 88.75 | 2,281 → 965 | 1,886 → 1,034 | 15,664 → 7,915 | 12,885 → 7,489 | + +## Observations + +- At 1× overquery, ASH has higher recall in 16/16 matched graph cases, ties in 0, and lower recall in 0. Recall differences range from +2.12 to +24.47 percentage points. +- At 2× overquery, ASH has higher recall in 16/16 matched graph cases, ties in 0, and lower recall in 0. Recall differences range from +0.64 to +17.68 percentage points. +- ASH has higher QPS in 0/64 matched query cells and lower QPS in 64. The geometric mean ASH/PQ QPS ratio is 0.627. +- ASH graph/write is faster in 12/16 matched cases; its geometric mean graph/write time ratio versus PQ is 0.819. +- ASH total construction/validation process time is lower in all eight 4-bit-budget cases and higher in all eight 2-bit-budget cases. These totals include the single accuracy validation pass described above. +- Query sample CV exceeds 10% in 4/128 cells (maximum 13.75%). queries.csv includes all CVs; raw-results.json and logs retain the sample query counts and durations. Each construction case has one fresh parallel graph build; these data do not estimate graph-build variance or isolate JDK effects from graph/training nondeterminism. + +ASH query preparation evaluates the dense projection Aq once per graph query: d=D/2 row dot products, each of length D. At D=3072 this is 1,536 × 3,072 = 4,718,592 multiply-add positions before LUT construction and graph scoring. The current projection and ASH symmetric construction scorers use the existing Java Vector API helpers inherited by the native provider; the L0 neighborhood scorer uses Highway. Both algorithms select NativeVectorizationProvider, but not every helper is a C++ Highway operation. This preparation and the current upper-layer scoring path are included in QPS. No runtime breakdown was profiled, so these results do not assign a measured fraction of query time to projection. + +Native ASH neighborhood calls, deferred writer selection and AVX-512 Highway dispatch were confirmed through BenchYAML preflights on both JDKs before the measured runs. After the PCA correction, every non-AsymmetricHashing runtime file was verified identical, including the native provider, FFM wrapper and shared library. The corrected PCA was separately validated through four tall/wide BenchYAML cases on both JDKs. Full runs force the native provider; forcing it fails on provider initialization errors. Normal production ISA dispatch was used, with no benchmark CPU special casing. + +Earlier ITQ-trained ASH full-scan comparisons warrant revalidation if they used the affected QR SVD path. The earlier scalar/Panama/native scorer parity results compare scoring of the same codes and are a separate question. Existing saved models are not rewritten by this correction; retraining and rebuilding caches is needed to adopt the corrected PCA. + +Raw data: [construction.csv](construction.csv), [queries.csv](queries.csv), [comparisons.csv](comparisons.csv), [raw-results.json](raw-results.json), [summary.json](summary.json). Exact inputs and protocol: [inputs.json](inputs.json), [protocol.json](protocol.json), [source.json](source.json). The evidence archive retains configurations, commands, raw console samples, harness CSV and system metadata. diff --git a/docs/benchmarks/native-pq-ash-graph-20261028/source.json b/docs/benchmarks/native-pq-ash-graph-20261028/source.json new file mode 100644 index 000000000..1b1705073 --- /dev/null +++ b/docs/benchmarks/native-pq-ash-graph-20261028/source.json @@ -0,0 +1,12 @@ +{ + "head": "507b74e7ef8a0e12fcfe402deb4bf758c65758d5", + "branch": "ash-dev", + "main": "4f13ff42e93f2e7b16020de3aaeefe9372e0dd36", + "clean": true, + "benchyaml_changes": false, + "backup": "c8d8de8af52b39bc8d2f6947294ed1ec40b088c6", + "base_head": "b44e25b71e3d9c5c7a2f164610b0fdbd099d91fa", + "pca_fix": "507b74e7ef8a0e12fcfe402deb4bf758c65758d5", + "retained_pq_source": "b44e25b71e3d9c5c7a2f164610b0fdbd099d91fa", + "retained_pq_count": 9 +} diff --git a/docs/benchmarks/native-pq-ash-graph-20261028/summary.json b/docs/benchmarks/native-pq-ash-graph-20261028/summary.json new file mode 100644 index 000000000..0ed4f0c1a --- /dev/null +++ b/docs/benchmarks/native-pq-ash-graph-20261028/summary.json @@ -0,0 +1,45 @@ +{ + "recall_1x": { + "ash_higher": 16, + "ties": 0, + "ash_lower": 0, + "min_delta_pp": 2.1200000000000045, + "max_delta_pp": 24.470000000000006, + "mean_delta_pp": 10.2204375 + }, + "recall_2x": { + "ash_higher": 16, + "ties": 0, + "ash_lower": 0, + "min_delta_pp": 0.6400000000000006, + "max_delta_pp": 17.67800000000001, + "mean_delta_pp": 6.2031875 + }, + "qps": { + "ash_higher": 0, + "ties": 0, + "ash_lower": 64, + "geomean_ash_over_pq": 0.6268674626435508 + }, + "construction": { + "ash_faster_graphs": 12, + "geomean_ash_over_pq_graph": 0.8194326066810679, + "geomean_ash_over_pq_process": 1.0340460672778242, + "process_by_budget": { + "2": { + "ash_faster": 0, + "cases": 8 + }, + "4": { + "ash_faster": 8, + "cases": 8 + } + } + }, + "sampling": { + "qps_cells": 128, + "max_cv_percent": 13.751945908099502, + "cells_cv_above_10_percent": 4, + "fresh_graph_replicates_per_case": 1 + } +} From 9ca91acee88980bcbb121e0047e641f8c83d95bd Mon Sep 17 00:00:00 2001 From: Ted Willke Date: Thu, 29 Oct 2026 06:16:25 +0000 Subject: [PATCH 098/243] Project dense ASH queries with one Highway dispatch --- .../jvector/quantization/ASHScorer.java | 14 +-- .../jvector/quantization/ASHVectors.java | 15 +-- .../quantization/AsymmetricHashing.java | 11 ++ .../jvector/vector/VectorUtilSupport.java | 29 +++++ .../jvector/vector/NativeASHProjection.java | 106 ++++++++++++++++++ .../vector/NativeVectorUtilSupport.java | 9 +- .../src/main/native/src/ash_projection-inl.h | 71 ++++++++++++ .../native/src/jvector_simd_kernel_list.h | 6 + .../main/native/src/jvector_simd_kernels.cpp | 1 + 9 files changed, 237 insertions(+), 25 deletions(-) create mode 100644 jvector-native/src/main/java/io/github/jbellis/jvector/vector/NativeASHProjection.java create mode 100644 jvector-native/src/main/native/src/ash_projection-inl.h diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHScorer.java b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHScorer.java index aa322fe3a..edadbf839 100644 --- a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHScorer.java +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHScorer.java @@ -253,13 +253,6 @@ QueryPrecompute precomputeQuery(VectorFloat query) { final int D = ash.originalDimension; final int C = ash.landmarkCount; - final float[][] A = ash.stiefelTransform.AFloat; // [d][D] - - final var vecUtil = - io.github.jbellis.jvector.vector.VectorizationProvider - .getInstance() - .getVectorUtilSupport(); - // Materialize query once to avoid VectorFloat.get in inner loops. final float[] qArr = new float[D]; for (int k = 0; k < D; k++) { @@ -269,12 +262,9 @@ QueryPrecompute precomputeQuery(VectorFloat query) { // Compute Aq once per query: // qProj[j] = final float[] qProj = new float[d]; + ash.stiefelTransform.project(qArr, qProj); float sumQProj = 0f; - for (int j = 0; j < d; j++) { - float v = vecUtil.ashDotRow(A[j], qArr); - qProj[j] = v; - sumQProj += v; - } + for (float value : qProj) sumQProj += value; // For each landmark c: // tildeQ_c = Aq - Aμ_c (1-bit and generic multibit paths) diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHVectors.java b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHVectors.java index dd9b4f61b..af33082b9 100644 --- a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHVectors.java +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHVectors.java @@ -531,8 +531,7 @@ private final class LutASHBlockScorer implements ASHBlockScorer { float[] q = new float[ash.originalDimension]; for (int i = 0; i < q.length; i++) q[i] = query.get(i); float[] projected = new float[d]; - VectorUtilSupport backend = VectorizationProvider.getInstance().getVectorUtilSupport(); - for (int i = 0; i < d; i++) projected[i] = backend.ashDotRow(ash.stiefelTransform.AFloat[i], q); + ash.stiefelTransform.project(q, projected); lut = new float[Math.multiplyExact(groups, 16)]; FusedASHLayout.buildQueryLut(projected, d, ash.bitsPerDimension, lut); dotQMu = new float[ash.landmarkCount]; @@ -880,11 +879,6 @@ private QueryPrecompute precomputeQuery(VectorFloat query) { final int D = ash.originalDimension; final int C = ash.landmarkCount; - final float[][] A = ash.stiefelTransform.AFloat; - - final var vecUtil = - VectorizationProvider.getInstance().getVectorUtilSupport(); - // q materialized once final float[] qArr = new float[D]; for (int i = 0; i < D; i++) { @@ -893,12 +887,9 @@ private QueryPrecompute precomputeQuery(VectorFloat query) { // Aq final float[] qProj = new float[d]; + ash.stiefelTransform.project(qArr, qProj); float sumQProj = 0f; - for (int j = 0; j < d; j++) { - float v = vecUtil.ashDotRow(A[j], qArr); - qProj[j] = v; - sumQProj += v; - } + for (float value : qProj) sumQProj += value; final float[] tildeQPool = new float[C * d]; final float[] sumTildeQ = new float[C]; diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/AsymmetricHashing.java b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/AsymmetricHashing.java index 0023edd01..baa5b76ee 100644 --- a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/AsymmetricHashing.java +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/AsymmetricHashing.java @@ -1285,6 +1285,14 @@ public static final class StiefelTransform { */ public final float[][] AFloat; + // Prepared once per immutable model, including models loaded from disk. + private final io.github.jbellis.jvector.vector.VectorUtilSupport.AshProjection projection; + + /** Projects one dense query using this model's prepared backend representation. */ + public void project(float[] query, float[] out) { + projection.project(query, out); + } + /** Number of rows in A (quantizedDim = d). */ public final int rows; @@ -1319,6 +1327,8 @@ public StiefelTransform(RealMatrix W) { dst[j] = (float) src[j]; } } + this.projection = VectorizationProvider.getInstance().getVectorUtilSupport() + .prepareAshProjection(AFloat); // Hard invariants: these are programmer-contract checks, not user input checks. if (AFloat.length != rows) { @@ -2518,6 +2528,7 @@ public long ramBytesUsed() { // Materialized compute buffers (row-major) bytes += estimateArray2DBytes(stiefelTransform.AData); bytes += estimateArray2DBytes(stiefelTransform.AFloat); + bytes += stiefelTransform.projection.ramBytesUsed(); } return bytes; diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/vector/VectorUtilSupport.java b/jvector-base/src/main/java/io/github/jbellis/jvector/vector/VectorUtilSupport.java index dfe188daf..a403e4ac6 100644 --- a/jvector-base/src/main/java/io/github/jbellis/jvector/vector/VectorUtilSupport.java +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/vector/VectorUtilSupport.java @@ -453,6 +453,35 @@ default boolean supportsAshMaskedLoad() { return false; } + /** Immutable provider-specific projection, safe to share between query threads. */ + interface AshProjection { + /** Writes the full matrix-vector product; input and output must not alias. */ + void project(float[] query, float[] out); + + /** Additional backing storage, excluding the caller-owned matrix. */ + default long ramBytesUsed() { return 0L; } + } + + /** + * Prepares an immutable [projected dimension][original dimension] matrix once. + * The caller must not mutate its rows after preparation. Providers may cache a + * packed representation; each project call still prepares exactly one query. + */ + default AshProjection prepareAshProjection(float[][] matrix) { + java.util.Objects.requireNonNull(matrix, "matrix"); + if (matrix.length == 0 || matrix[0].length == 0) + throw new IllegalArgumentException("ASH projection dimensions must be positive"); + int columns = matrix[0].length; + for (float[] row : matrix) { + if (row.length != columns) throw new IllegalArgumentException("Ragged ASH projection"); + } + return (query, out) -> { + if (query.length != columns || out.length != matrix.length || query == out) + throw new IllegalArgumentException("ASH projection shape or aliasing mismatch"); + for (int row = 0; row < matrix.length; row++) out[row] = ashDotRow(matrix[row], query); + }; + } + /** * Dot product between a matrix row and a dense vector. * Used by ASH projection: {@code y_i = }. diff --git a/jvector-native/src/main/java/io/github/jbellis/jvector/vector/NativeASHProjection.java b/jvector-native/src/main/java/io/github/jbellis/jvector/vector/NativeASHProjection.java new file mode 100644 index 000000000..4503e82e1 --- /dev/null +++ b/jvector-native/src/main/java/io/github/jbellis/jvector/vector/NativeASHProjection.java @@ -0,0 +1,106 @@ +/* + * 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 java.lang.foreign.Arena; +import java.lang.foreign.FunctionDescriptor; +import java.lang.foreign.Linker; +import java.lang.foreign.MemorySegment; +import java.lang.foreign.SymbolLookup; +import java.lang.invoke.MethodHandle; +import java.util.Objects; + +import static java.lang.foreign.ValueLayout.ADDRESS; +import static java.lang.foreign.ValueLayout.JAVA_INT; + +/** + * Immutable native row-major matrix for one complete ASH query projection per downcall. + * The automatically managed arena lives with the model; it is not thread confined. + * Query/output buffers are local to each invocation, so projections may run concurrently. + * Large projections use ordinary FFM and copy only the small query and result, allowing + * safepoints during the dense multiply. Matrix storage is never copied per query. + */ +final class NativeASHProjection implements VectorUtilSupport.AshProjection { + private static final String SYMBOL = "ash_project_f32"; + private static final boolean TRACE = Boolean.getBoolean("jextract.trace.downcalls"); + // Conservative work bound for short, synchronous critical calls; not a timing claim. + private static final int MAX_CRITICAL_CELLS = 1 << 16; + private static final MemorySegment ADDRESS_OF_KERNEL = SymbolLookup.loaderLookup().find(SYMBOL).orElse(null); + private static final FunctionDescriptor DESCRIPTOR = + FunctionDescriptor.ofVoid(ADDRESS, ADDRESS, JAVA_INT, JAVA_INT, ADDRESS); + private static final MethodHandle CRITICAL = ADDRESS_OF_KERNEL == null ? null + : Linker.nativeLinker().downcallHandle(ADDRESS_OF_KERNEL, DESCRIPTOR, Linker.Option.critical(true)); + private static final MethodHandle ORDINARY = ADDRESS_OF_KERNEL == null ? null + : Linker.nativeLinker().downcallHandle(ADDRESS_OF_KERNEL, DESCRIPTOR); + + private final MemorySegment matrix; + private final int rows; + private final int columns; + + static boolean isAvailable() { return ADDRESS_OF_KERNEL != null; } + + NativeASHProjection(float[][] source) { + Objects.requireNonNull(source, "matrix"); + if (!isAvailable()) throw new UnsupportedOperationException("Native library does not provide " + SYMBOL); + rows = source.length; + if (rows == 0 || source[0].length == 0) + throw new IllegalArgumentException("ASH projection dimensions must be positive"); + columns = source[0].length; + for (float[] row : source) { + if (row.length != columns) throw new IllegalArgumentException("Ragged ASH projection"); + } + long rowBytes = (long) columns * Float.BYTES; + matrix = Arena.ofAuto().allocate(Math.multiplyExact((long) rows, rowBytes), 64); + for (int row = 0; row < rows; row++) + MemorySegment.copy(MemorySegment.ofArray(source[row]), 0, matrix, row * rowBytes, rowBytes); + } + + @Override + public long ramBytesUsed() { return matrix.byteSize(); } + + @Override + public void project(float[] query, float[] out) { + Objects.requireNonNull(query, "query"); + Objects.requireNonNull(out, "out"); + if (query.length != columns || out.length != rows || query == out) + throw new IllegalArgumentException("ASH projection shape or aliasing mismatch"); + if ((long) rows * columns <= MAX_CRITICAL_CELLS) { + invoke(CRITICAL, MemorySegment.ofArray(query), MemorySegment.ofArray(out)); + } else { + try (Arena arena = Arena.ofConfined()) { + long queryBytes = (long) columns * Float.BYTES; + MemorySegment buffers = arena.allocate(queryBytes + (long) rows * Float.BYTES, 64); + MemorySegment nativeQuery = buffers.asSlice(0, queryBytes); + MemorySegment nativeOut = buffers.asSlice(queryBytes); + MemorySegment.copy(MemorySegment.ofArray(query), 0, nativeQuery, 0, queryBytes); + invoke(ORDINARY, nativeQuery, nativeOut); + MemorySegment.copy(nativeOut, 0, MemorySegment.ofArray(out), 0, (long) rows * Float.BYTES); + } + } + } + + private void invoke(MethodHandle handle, MemorySegment query, MemorySegment out) { + if (TRACE) System.out.printf("%s(%d, %d)%n", SYMBOL, rows, columns); + try { + handle.invokeExact(matrix, query, rows, columns, out); + } catch (RuntimeException | Error error) { + throw error; + } catch (Throwable error) { + throw new AssertionError("Unexpected checked exception from " + SYMBOL, error); + } + } +} 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 da754e645..d105d9b10 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 @@ -91,6 +91,12 @@ public VectorFloat nvqSub(VectorFloat a, int aOffset, VectorFloat b, in return NativeNVQArithmetic.sub(a, aOffset, b, bOffset, length); } + @Override + public AshProjection prepareAshProjection(float[][] matrix) { + return NativeASHProjection.isAvailable() + ? new NativeASHProjection(matrix) : super.prepareAshProjection(matrix); + } + @Override public boolean supportsAshNeighborhoodScoring() { return NativeASHKernels.isAvailable(); @@ -101,7 +107,8 @@ public String ashKernelDescription() { String neighborhood = supportsAshNeighborhoodScoring() ? "Highway native, ISA=" + getActiveIsa() : "unavailable; using Java LUT"; - return super.ashKernelDescription() + "; C=1 whole-neighborhood=" + neighborhood; + return super.ashKernelDescription() + "; C=1 whole-neighborhood=" + neighborhood + + "; dense projection=" + (NativeASHProjection.isAvailable() ? "Highway" : "Java"); } @Override diff --git a/jvector-native/src/main/native/src/ash_projection-inl.h b/jvector-native/src/main/native/src/ash_projection-inl.h new file mode 100644 index 000000000..51447f1f6 --- /dev/null +++ b/jvector-native/src/main/native/src/ash_projection-inl.h @@ -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. + */ + +#ifndef JVECTOR_ASH_PROJECTION_INL_H +#define JVECTOR_ASH_PROJECTION_INL_H + +// Included within JV_ISA. The immutable matrix is contiguous row-major and is +// prepared once per model. A whole query crosses FFM once; no allocation, +// callback, synchronization or pointer retention occurs in this leaf kernel. +void ash_project_f32(const float *HWY_RESTRICT matrix, const float *HWY_RESTRICT query, + int rows, int cols, float *HWY_RESTRICT out) { + const hn::ScalableTag d; + const size_t lanes = hn::Lanes(d); + const size_t columns = static_cast(cols); + int row = 0; + // Four rows share each query load. Two chains per row hide FMA latency + // without changing the stored matrix or batching independent graph queries. + for (; rows - row >= 4; row += 4) { + const float *a = matrix + static_cast(row) * columns; + auto s0 = hn::Zero(d), s1 = hn::Zero(d), s2 = hn::Zero(d), s3 = hn::Zero(d); + auto t0 = hn::Zero(d), t1 = hn::Zero(d), t2 = hn::Zero(d), t3 = hn::Zero(d); + size_t col = 0; + for (; col + 2 * lanes <= columns; col += 2 * lanes) { + const auto q0 = hn::LoadU(d, query + col); + const auto q1 = hn::LoadU(d, query + col + lanes); + s0 = hn::MulAdd(hn::LoadU(d, a + col), q0, s0); + s1 = hn::MulAdd(hn::LoadU(d, a + columns + col), q0, s1); + s2 = hn::MulAdd(hn::LoadU(d, a + 2 * columns + col), q0, s2); + s3 = hn::MulAdd(hn::LoadU(d, a + 3 * columns + col), q0, s3); + t0 = hn::MulAdd(hn::LoadU(d, a + col + lanes), q1, t0); + t1 = hn::MulAdd(hn::LoadU(d, a + columns + col + lanes), q1, t1); + t2 = hn::MulAdd(hn::LoadU(d, a + 2 * columns + col + lanes), q1, t2); + t3 = hn::MulAdd(hn::LoadU(d, a + 3 * columns + col + lanes), q1, t3); + } + s0 = hn::Add(s0, t0); s1 = hn::Add(s1, t1); + s2 = hn::Add(s2, t2); s3 = hn::Add(s3, t3); + for (; col < columns; col += lanes) { + const size_t n = std::min(lanes, columns - col); + const auto q = hn::LoadN(d, query + col, n); + s0 = hn::MulAdd(hn::LoadN(d, a + col, n), q, s0); + s1 = hn::MulAdd(hn::LoadN(d, a + columns + col, n), q, s1); + s2 = hn::MulAdd(hn::LoadN(d, a + 2 * columns + col, n), q, s2); + s3 = hn::MulAdd(hn::LoadN(d, a + 3 * columns + col, n), q, s3); + } + out[row] = hn::ReduceSum(d, s0); out[row + 1] = hn::ReduceSum(d, s1); + out[row + 2] = hn::ReduceSum(d, s2); out[row + 3] = hn::ReduceSum(d, s3); + } + for (; row < rows; ++row) { + const float *a = matrix + static_cast(row) * columns; + auto sum = hn::Zero(d); + for (size_t col = 0; col < columns; col += lanes) { + const size_t n = std::min(lanes, columns - col); + sum = hn::MulAdd(hn::LoadN(d, a + col, n), hn::LoadN(d, query + col, n), sum); + } + out[row] = hn::ReduceSum(d, sum); + } +} +#endif diff --git a/jvector-native/src/main/native/src/jvector_simd_kernel_list.h b/jvector-native/src/main/native/src/jvector_simd_kernel_list.h index 31babe849..e8cb34559 100644 --- a/jvector-native/src/main/native/src/jvector_simd_kernel_list.h +++ b/jvector-native/src/main/native/src/jvector_simd_kernel_list.h @@ -39,6 +39,10 @@ // Output may be partially written on error. No allocation, pointer retention, // callbacks, or blocking are permitted: ordinary graph calls use critical FFM. +// ASH projection ABI: positive rows/cols, borrowed contiguous row-major matrix, +// query[cols], out[rows]; output must not alias either read-only input. +// Callers validate buffer sizes; the kernel is a synchronous allocation-free leaf. + // clang-format off #define JVECTOR_SIMD_KERNEL_LIST \ /* Vector similarity */ \ @@ -68,6 +72,8 @@ KERNEL_ENTRY(void, calculate_partial_sums_dot_f32, (const float *codebook, int codebookIndex, size_t size, int clusterCount, const float *query, int queryOffset, float *partialSums), (codebook, codebookIndex, size, clusterCount, query, queryOffset, partialSums)) \ KERNEL_ENTRY(void, calculate_partial_sums_euclidean_f32, (const float *codebook, int codebookIndex, size_t size, int clusterCount, const float *query, int queryOffset, float *partialSums), (codebook, codebookIndex, size, clusterCount, query, queryOffset, partialSums)) \ KERNEL_ENTRY(void, calculate_partial_sums_self_magnitude_f32, (const float *codebook, int codebookIndex, size_t size, int clusterCount, float *partialSums), (codebook, codebookIndex, size, clusterCount, partialSums)) \ + /* ASH preparation and exact packed construction scoring; synchronous leaf calls. */ \ + KERNEL_ENTRY(void, ash_project_f32, (const float *matrix, const float *query, int rows, int cols, float *out), (matrix, query, rows, cols, out)) \ /* Whole-neighborhood C=1 ASH, one downcall including headers and similarity. */ \ KERNEL_ENTRY(int, ash_score_neighborhood_c1_f32, (const unsigned char *codes, const float *lut, float landmarkDot, int groups, int blockSize, int capacity, float *out), (codes, lut, landmarkDot, groups, blockSize, capacity, out)) \ /* NVQ kernels */ \ diff --git a/jvector-native/src/main/native/src/jvector_simd_kernels.cpp b/jvector-native/src/main/native/src/jvector_simd_kernels.cpp index 1ba0908a8..1f52c6700 100644 --- a/jvector-native/src/main/native/src/jvector_simd_kernels.cpp +++ b/jvector-native/src/main/native/src/jvector_simd_kernels.cpp @@ -383,6 +383,7 @@ HWY_INLINE float L2SquareDistance(const float *a, namespace JV_ISA { #include "ash_neighborhood-inl.h" +#include "ash_projection-inl.h" HWY_FLATTEN float cosine_f32( const float *a, size_t aoffset, const float *b, size_t boffset, size_t length) From 93997f36e85f9df78905578d92d570c97bec49d5 Mon Sep 17 00:00:00 2001 From: Ted Willke Date: Thu, 29 Oct 2026 06:16:25 +0000 Subject: [PATCH 099/243] Score symmetric ASH construction codes with Highway --- .../vector/NativeASHSymmetricKernels.java | 135 +++++++++++++++ .../vector/NativeVectorUtilSupport.java | 20 ++- .../src/main/native/src/ash_symmetric-inl.h | 161 ++++++++++++++++++ .../native/src/jvector_avx3_dl_kernels.cpp | 6 + .../src/main/native/src/jvector_simd.cpp | 3 +- .../native/src/jvector_simd_kernel_list.h | 12 +- .../main/native/src/jvector_simd_kernels.cpp | 1 + 7 files changed, 333 insertions(+), 5 deletions(-) create mode 100644 jvector-native/src/main/java/io/github/jbellis/jvector/vector/NativeASHSymmetricKernels.java create mode 100644 jvector-native/src/main/native/src/ash_symmetric-inl.h diff --git a/jvector-native/src/main/java/io/github/jbellis/jvector/vector/NativeASHSymmetricKernels.java b/jvector-native/src/main/java/io/github/jbellis/jvector/vector/NativeASHSymmetricKernels.java new file mode 100644 index 000000000..5f10f7596 --- /dev/null +++ b/jvector-native/src/main/java/io/github/jbellis/jvector/vector/NativeASHSymmetricKernels.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 java.lang.foreign.Arena; +import java.lang.foreign.FunctionDescriptor; +import java.lang.foreign.Linker; +import java.lang.foreign.MemorySegment; +import java.lang.foreign.SymbolLookup; +import java.lang.invoke.MethodHandle; +import java.util.Objects; + +import static java.lang.foreign.ValueLayout.ADDRESS; +import static java.lang.foreign.ValueLayout.JAVA_FLOAT; +import static java.lang.foreign.ValueLayout.JAVA_INT; + +/** + * Checked optional Highway bindings for exact symmetric ASH integer arithmetic. + * Graph pair comparisons use one bounded critical call. Complete interleaved + * ranges use one LUT call; larger legal inputs use ordinary FFM with temporary + * native buffers. Neither kernel allocates, retains pointers, blocks or calls Java. + * Missing symbols preserve the provider's existing Java implementations. + */ +final class NativeASHSymmetricKernels { + private static final int MAX_CRITICAL_WORK = 1 << 16; + private static final boolean TRACE = Boolean.getBoolean("jextract.trace.downcalls"); + private static final Binding PAIR = new Binding("ash_symmetric_dot", + FunctionDescriptor.of(JAVA_FLOAT, ADDRESS, ADDRESS, JAVA_INT, JAVA_INT)); + private static final Binding LUT = new Binding("ash_symmetric_lut_i16", + FunctionDescriptor.ofVoid(ADDRESS, ADDRESS, JAVA_INT, JAVA_INT, JAVA_INT, JAVA_INT, ADDRESS)); + + private NativeASHSymmetricKernels() {} + + static boolean hasPairKernel() { return PAIR.available(); } + static boolean hasLutKernel() { return LUT.available(); } + + static float dot(long[] aBits, byte[] aCodes, long[] bBits, byte[] bCodes, int dimensions, int bits) { + if (dimensions < 0 || (bits != 1 && bits != 2 && bits != 4)) + throw new IllegalArgumentException("Symmetric ASH requires nonnegative dimensions and bits in {1,2,4}"); + if (!PAIR.available()) throw new UnsupportedOperationException("Native symmetric pair kernel unavailable"); + MemorySegment a, b; + long bytes; + if (bits == 1) { + long words = (dimensions + 63L) / 64; + if (words > aBits.length || words > bBits.length) throw new IndexOutOfBoundsException("ASH bit arrays"); + a = MemorySegment.ofArray(aBits); b = MemorySegment.ofArray(bBits); bytes = words * Long.BYTES; + } else { + bytes = ((long) dimensions * bits + 7) / 8; + if (bytes > aCodes.length || bytes > bCodes.length) throw new IndexOutOfBoundsException("ASH code arrays"); + a = MemorySegment.ofArray(aCodes); b = MemorySegment.ofArray(bCodes); + } + if (dimensions <= MAX_CRITICAL_WORK) return invokePair(PAIR.critical, a, b, dimensions, bits); + try (Arena arena = Arena.ofConfined()) { + MemorySegment na = arena.allocate(bytes, Long.BYTES), nb = arena.allocate(bytes, Long.BYTES); + MemorySegment.copy(a, 0, na, 0, bytes); MemorySegment.copy(b, 0, nb, 0, bytes); + return invokePair(PAIR.ordinary, na, nb, dimensions, bits); + } + } + + static void scoreLut(byte[] codes, int groups, int stride, int lane, int count, + short[] lut, float[] out, int outOffset) { + Objects.requireNonNull(codes, "codes"); Objects.requireNonNull(lut, "lut"); Objects.requireNonNull(out, "out"); + if (!LUT.available()) throw new UnsupportedOperationException("Native symmetric LUT kernel unavailable"); + if (groups < 0 || stride <= 0) throw new IllegalArgumentException("ASH groups/stride"); + Objects.checkFromIndexSize(lane, count, stride); + Objects.checkFromIndexSize(outOffset, count, out.length); + long codeBytes = ((groups + 1L) / 2) * stride, lutBytes = (long) groups * 32 * Short.BYTES; + if (codeBytes > codes.length || lutBytes / Short.BYTES > lut.length) + throw new IndexOutOfBoundsException("ASH LUT/code arrays"); + if (count == 0) return; + MemorySegment source = MemorySegment.ofArray(codes), table = MemorySegment.ofArray(lut); + MemorySegment result = MemorySegment.ofArray(out).asSlice((long) outOffset * Float.BYTES); + // Charge padded SIMD lanes as well as active lanes (native width is capped at 16). + long vectorSlots = ((count + 15L) / 16) * 16; + if ((long) groups * vectorSlots <= MAX_CRITICAL_WORK) { + invokeLut(LUT.critical, source, table, groups, stride, lane, count, result); + } else { + try (Arena arena = Arena.ofConfined()) { + MemorySegment nc = arena.allocate(Math.max(1, codeBytes)); + MemorySegment nl = arena.allocate(Math.max(Short.BYTES, lutBytes), Short.BYTES); + MemorySegment no = arena.allocate((long) count * Float.BYTES, Float.BYTES); + MemorySegment.copy(source, 0, nc, 0, codeBytes); MemorySegment.copy(table, 0, nl, 0, lutBytes); + invokeLut(LUT.ordinary, nc, nl, groups, stride, lane, count, no); + MemorySegment.copy(no, 0, result, 0, (long) count * Float.BYTES); + } + } + } + + private static float invokePair(MethodHandle handle, MemorySegment a, MemorySegment b, int dimensions, int bits) { + if (TRACE) System.out.printf("%s(%d, %d)%n", PAIR.symbol, dimensions, bits); + try { + return (float) handle.invokeExact(a, b, dimensions, bits); + } catch (RuntimeException | Error error) { throw error; + } catch (Throwable error) { throw new AssertionError("Unexpected exception from " + PAIR.symbol, error); } + } + + private static void invokeLut(MethodHandle handle, MemorySegment codes, MemorySegment lut, + int groups, int stride, int lane, int count, MemorySegment out) { + if (TRACE) System.out.printf("%s(%d, %d)%n", LUT.symbol, groups, count); + try { + handle.invokeExact(codes, lut, groups, stride, lane, count, out); + } catch (RuntimeException | Error error) { throw error; + } catch (Throwable error) { throw new AssertionError("Unexpected exception from " + LUT.symbol, error); } + } + + private static final class Binding { + final String symbol; + final MethodHandle critical; + final MethodHandle ordinary; + + Binding(String symbol, FunctionDescriptor descriptor) { + this.symbol = symbol; + MemorySegment address = SymbolLookup.loaderLookup().find(symbol).orElse(null); + critical = address == null ? null : Linker.nativeLinker().downcallHandle( + address, descriptor, Linker.Option.critical(true)); + ordinary = address == null ? null : Linker.nativeLinker().downcallHandle(address, descriptor); + } + + boolean available() { return critical != null; } + } +} 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 d105d9b10..b02b063e0 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 @@ -97,6 +97,22 @@ public AshProjection prepareAshProjection(float[][] matrix) { ? new NativeASHProjection(matrix) : super.prepareAshProjection(matrix); } + @Override + public float ashSymmetricDot(long[] aBits, byte[] aCodes, long[] bBits, byte[] bCodes, + int dimensions, int bits) { + return NativeASHSymmetricKernels.hasPairKernel() + ? NativeASHSymmetricKernels.dot(aBits, aCodes, bBits, bCodes, dimensions, bits) + : super.ashSymmetricDot(aBits, aCodes, bBits, bCodes, dimensions, bits); + } + + @Override + public void ashSymmetricLutScore(byte[] codes, int groups, int stride, int lane, int count, + short[] lut, float[] out, int outOffset) { + if (NativeASHSymmetricKernels.hasLutKernel()) + NativeASHSymmetricKernels.scoreLut(codes, groups, stride, lane, count, lut, out, outOffset); + else super.ashSymmetricLutScore(codes, groups, stride, lane, count, lut, out, outOffset); + } + @Override public boolean supportsAshNeighborhoodScoring() { return NativeASHKernels.isAvailable(); @@ -108,7 +124,9 @@ public String ashKernelDescription() { ? "Highway native, ISA=" + getActiveIsa() : "unavailable; using Java LUT"; return super.ashKernelDescription() + "; C=1 whole-neighborhood=" + neighborhood - + "; dense projection=" + (NativeASHProjection.isAvailable() ? "Highway" : "Java"); + + "; dense projection=" + (NativeASHProjection.isAvailable() ? "Highway" : "Java") + + "; symmetric pair=" + (NativeASHSymmetricKernels.hasPairKernel() ? "Highway" : "Java") + + "; symmetric LUT=" + (NativeASHSymmetricKernels.hasLutKernel() ? "Highway" : "Java"); } @Override diff --git a/jvector-native/src/main/native/src/ash_symmetric-inl.h b/jvector-native/src/main/native/src/ash_symmetric-inl.h new file mode 100644 index 000000000..c43f0fea9 --- /dev/null +++ b/jvector-native/src/main/native/src/ash_symmetric-inl.h @@ -0,0 +1,161 @@ +/* + * 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. + */ + +#ifndef JVECTOR_ASH_SYMMETRIC_INL_H +#define JVECTOR_ASH_SYMMETRIC_INL_H + +// Included within JV_ISA. All sizes/pointers are checked by the Java binding. +// Products of doubled signed-magnitude components are exact integers; divide +// by four only once, after accumulation. Padding bits never contribute. +namespace { +HWY_INLINE int AshDoubledComponent(const uint8_t *code, int j, int bits) { + const int perByte = 8 / bits; + const int field = code[j / perByte] >> ((j % perByte) * bits); + const int sign = 1 << (bits - 1); + const int magnitude = 2 * (field & (sign - 1)) + 1; + return (field & sign) ? magnitude : -magnitude; +} + +HWY_INLINE float AshBinaryPair(const uint64_t *a, const uint64_t *b, int dimensions) { + const hn::ScalableTag d; + const size_t lanes = hn::Lanes(d), words = static_cast(dimensions) / 64; + auto counts = hn::Zero(d); + // Integer lanes can hold all mismatches even at the maximum Java dimension. + for (size_t word = 0; word < words; word += lanes) { + const size_t n = std::min(lanes, words - word); + const auto diff = hn::Xor(hn::LoadN(d, a + word, n), hn::LoadN(d, b + word, n)); + counts = hn::Add(counts, hn::PopulationCount(diff)); + } + uint64_t mismatches = hn::ReduceSum(d, counts); + const int tail = dimensions & 63; + if (tail) mismatches += hwy::PopCount((a[words] ^ b[words]) & ((uint64_t{1} << tail) - 1)); + return static_cast(dimensions) - 2.0f * static_cast(mismatches); +} + +HWY_INLINE float AshTwoBitPair(const uint8_t *a, const uint8_t *b, int dimensions) { +#if HWY_TARGET == HWY_SCALAR + int64_t total = 0; + for (int j = 0; j < dimensions; ++j) + total += AshDoubledComponent(a, j, 2) * AshDoubledComponent(b, j, 2); + return static_cast(total) * 0.25f; +#else + const hn::ScalableTag d; + const auto slots = hn::Set(d, uint64_t{0x5555555555555555}); + const size_t lanes = hn::Lanes(d), words = static_cast(dimensions) / 32; + const hn::RebindToSigned di; + auto deltas = hn::Zero(di); + for (size_t word = 0; word < words; word += lanes) { + const size_t n = std::min(lanes, words - word); + // Canonical codes are little-endian packed fields. LoadU/LoadN permit + // byte alignment on all supported little-endian production targets. + const auto av = hn::LoadN(d, reinterpret_cast(a) + word, n); + const auto bv = hn::LoadN(d, reinterpret_cast(b) + word, n); + const auto neg = hn::And(hn::ShiftRight<1>(hn::Xor(av, bv)), slots); + const auto ma = hn::And(av, slots), mb = hn::And(bv, slots), both = hn::And(ma, mb); + // sign*(1+2*ma)*(1+2*mb), evaluated with weighted popcounts. + // Keep partial sums in registers; one horizontal reduction per pair. + auto delta = hn::ShiftLeft<1>(hn::Sub( + hn::Add(hn::PopulationCount(ma), hn::PopulationCount(mb)), hn::PopulationCount(neg))); + delta = hn::Sub(delta, hn::ShiftLeft<2>(hn::Add( + hn::PopulationCount(hn::And(ma, neg)), hn::PopulationCount(hn::And(mb, neg))))); + delta = hn::Add(delta, hn::ShiftLeft<2>(hn::PopulationCount(both))); + delta = hn::Sub(delta, hn::ShiftLeft<3>(hn::PopulationCount(hn::And(both, neg)))); + deltas = hn::Add(deltas, hn::BitCast(di, delta)); + } + int64_t total = static_cast(words) * 32 + hn::ReduceSum(di, deltas); + for (int j = static_cast(words * 32); j < dimensions; ++j) + total += AshDoubledComponent(a, j, 2) * AshDoubledComponent(b, j, 2); + return static_cast(total) * 0.25f; +#endif +} + +HWY_INLINE float AshFourBitPair(const uint8_t *a, const uint8_t *b, int dimensions) { + int64_t total = 0; + int j = 0; +#if HWY_TARGET != HWY_SCALAR + const hn::ScalableTag di; + const hn::Repartition db; + const hn::RebindToSigned ds; + const size_t bytes = static_cast(dimensions) / 2, lanes = hn::Lanes(db); + size_t offset = 0; + for (; offset + lanes <= bytes; offset += lanes) { + const auto av = hn::LoadU(db, a + offset), bv = hn::LoadU(db, b + offset); + auto sum = hn::Zero(di); + for (int shift = 0; shift < 8; shift += 4) { + const auto ax = hn::ShiftRightSame(av, shift), bx = hn::ShiftRightSame(bv, shift); + const auto am = hn::Add(hn::ShiftLeft<1>(hn::And(ax, hn::Set(db, 7))), hn::Set(db, 1)); + const auto bm = hn::BitCast(ds, hn::Add(hn::ShiftLeft<1>(hn::And(bx, hn::Set(db, 7))), hn::Set(db, 1))); + const auto sameSign = hn::Eq(hn::And(hn::Xor(ax, bx), hn::Set(db, 8)), hn::Zero(db)); + const auto signedB = hn::IfThenElse(hn::RebindMask(ds, sameSign), bm, hn::Neg(bm)); + // VNNI when supported; Highway supplies widening multiply otherwise. + sum = hn::SumOfMulQuadAccumulate(di, am, signedB, sum); + } + total += hn::ReduceSum(di, sum); + } + j = static_cast(2 * offset); +#endif + for (; j < dimensions; ++j) total += AshDoubledComponent(a, j, 4) * AshDoubledComponent(b, j, 4); + return static_cast(total) * 0.25f; +} +} // namespace + +float ash_symmetric_dot(const void *a, const void *b, int dimensions, int bits) { + if (bits == 1) return AshBinaryPair(static_cast(a), static_cast(b), dimensions); + if (bits == 2) return AshTwoBitPair(static_cast(a), static_cast(b), dimensions); + return AshFourBitPair(static_cast(a), static_cast(b), dimensions); +} + +#ifndef JVECTOR_ASH_PAIR_ONLY +// Exact signed-short LUTs, unlike the IVF scan's approximate uint8 LUTs. Widen +// before accumulation. At most 8192 groups enter an int32 subtotal, then spill +// into int64 lane totals so arbitrary short entries and long codes cannot wrap. +void ash_symmetric_lut_i16(const uint8_t *HWY_RESTRICT codes, const int16_t *HWY_RESTRICT lut, + int groups, int stride, int lane, int count, float *HWY_RESTRICT out) { + const hn::CappedTag d; + const hn::Rebind db; + const hn::Rebind ds; + const int lanes = static_cast(hn::Lanes(d)); + for (size_t base = 0; base < static_cast(count); base += lanes) { + const int active = static_cast(std::min(static_cast(lanes), static_cast(count) - base)); + int64_t totals[16] = {}; + for (int first = 0; first < groups;) { + const int end = first + std::min(8192, groups - first); + auto sum = hn::Zero(d); + for (int g = first; g < end; ++g) { + auto packed = hn::PromoteTo(d, hn::LoadN(db, codes + static_cast(g / 2) * stride + lane + base, active)); + const auto indices = hn::And(hn::ShiftRightSame(packed, 4 * (g & 1)), hn::Set(d, 15)); +#if !HWY_HAVE_SCALABLE + if constexpr (hn::MaxLanes(d) == 16) { + const auto table = hn::PromoteTo(d, hn::LoadU(ds, lut + static_cast(g) * 32)); + sum = hn::Add(sum, hn::TableLookupLanes(table, hn::IndicesFromVec(d, indices))); + } else +#endif + { + int32_t table[16]; + for (int i = 0; i < 16; ++i) table[i] = lut[static_cast(g) * 32 + i]; + sum = hn::Add(sum, hn::GatherIndex(d, table, indices)); + } + } + int32_t partial[16]; + hn::StoreU(sum, d, partial); + for (int i = 0; i < active; ++i) totals[i] += partial[i]; + first = end; + } + for (int i = 0; i < active; ++i) out[base + i] = static_cast(totals[i]) * 0.25f; + } +} +#endif +#endif diff --git a/jvector-native/src/main/native/src/jvector_avx3_dl_kernels.cpp b/jvector-native/src/main/native/src/jvector_avx3_dl_kernels.cpp index ae8ab73bb..95f61317d 100644 --- a/jvector-native/src/main/native/src/jvector_avx3_dl_kernels.cpp +++ b/jvector-native/src/main/native/src/jvector_avx3_dl_kernels.cpp @@ -32,6 +32,12 @@ namespace hn = hwy::HWY_NAMESPACE; +#include +#include + namespace AVX3_DL { +#define JVECTOR_ASH_PAIR_ONLY +#include "ash_symmetric-inl.h" +#undef JVECTOR_ASH_PAIR_ONLY } // namespace AVX3_DL diff --git a/jvector-native/src/main/native/src/jvector_simd.cpp b/jvector-native/src/main/native/src/jvector_simd.cpp index ec69d5cfa..34bc2ecc8 100644 --- a/jvector-native/src/main/native/src/jvector_simd.cpp +++ b/jvector-native/src/main/native/src/jvector_simd.cpp @@ -107,11 +107,12 @@ static const KernelVTable AVX3_vtable = { }; #undef KERNEL_ENTRY -// AVX3_DL (Ice Lake) inherits all slots from AVX3 unchanged for now. +// AVX3_DL inherits AVX3 and uses native popcount/byte-dot for ASH pairs. // To override a slot: t.kernel_name = AVX3_DL::kernel_name; // The implementation must exist in jvector_avx3_dl_kernels.cpp. static const KernelVTable AVX3_DL_vtable = []() { KernelVTable t = AVX3_vtable; + t.ash_symmetric_dot = AVX3_DL::ash_symmetric_dot; return t; }(); diff --git a/jvector-native/src/main/native/src/jvector_simd_kernel_list.h b/jvector-native/src/main/native/src/jvector_simd_kernel_list.h index e8cb34559..cf0e23ed2 100644 --- a/jvector-native/src/main/native/src/jvector_simd_kernel_list.h +++ b/jvector-native/src/main/native/src/jvector_simd_kernel_list.h @@ -39,9 +39,13 @@ // Output may be partially written on error. No allocation, pointer retention, // callbacks, or blocking are permitted: ordinary graph calls use critical FFM. -// ASH projection ABI: positive rows/cols, borrowed contiguous row-major matrix, -// query[cols], out[rows]; output must not alias either read-only input. -// Callers validate buffer sizes; the kernel is a synchronous allocation-free leaf. +// ASH preparation/symmetric ABI contracts (all pointers are borrowed synchronously): +// project: positive rows/cols, row-major matrix[rows*cols], query[cols], out[rows]. +// pair: nonnegative dimensions, bits in {1,2,4}; binary uint64 words for bits=1, +// canonical little-endian packed bytes otherwise. Padding bits are ignored. +// LUT: nonnegative groups/count, positive stride, 0 <= lane <= lane+count <= stride; +// codes[ceil(groups/2)*stride], lut[groups*32] signed shorts, out[count]. +// Writable outputs must not alias read-only inputs. Callers validate buffer sizes. // clang-format off #define JVECTOR_SIMD_KERNEL_LIST \ @@ -74,6 +78,8 @@ KERNEL_ENTRY(void, calculate_partial_sums_self_magnitude_f32, (const float *codebook, int codebookIndex, size_t size, int clusterCount, float *partialSums), (codebook, codebookIndex, size, clusterCount, partialSums)) \ /* ASH preparation and exact packed construction scoring; synchronous leaf calls. */ \ KERNEL_ENTRY(void, ash_project_f32, (const float *matrix, const float *query, int rows, int cols, float *out), (matrix, query, rows, cols, out)) \ + KERNEL_ENTRY(float, ash_symmetric_dot, (const void *a, const void *b, int dimensions, int bits), (a, b, dimensions, bits)) \ + KERNEL_ENTRY(void, ash_symmetric_lut_i16, (const unsigned char *codes, const int16_t *lut, int groups, int stride, int lane, int count, float *out), (codes, lut, groups, stride, lane, count, out)) \ /* Whole-neighborhood C=1 ASH, one downcall including headers and similarity. */ \ KERNEL_ENTRY(int, ash_score_neighborhood_c1_f32, (const unsigned char *codes, const float *lut, float landmarkDot, int groups, int blockSize, int capacity, float *out), (codes, lut, landmarkDot, groups, blockSize, capacity, out)) \ /* NVQ kernels */ \ diff --git a/jvector-native/src/main/native/src/jvector_simd_kernels.cpp b/jvector-native/src/main/native/src/jvector_simd_kernels.cpp index 1f52c6700..d2a38e2a3 100644 --- a/jvector-native/src/main/native/src/jvector_simd_kernels.cpp +++ b/jvector-native/src/main/native/src/jvector_simd_kernels.cpp @@ -384,6 +384,7 @@ namespace JV_ISA { #include "ash_neighborhood-inl.h" #include "ash_projection-inl.h" +#include "ash_symmetric-inl.h" HWY_FLATTEN float cosine_f32( const float *a, size_t aoffset, const float *b, size_t boffset, size_t length) From a10f4f82061c3142784c672d764f9b8af238affd Mon Sep 17 00:00:00 2001 From: Ted Willke Date: Thu, 29 Oct 2026 06:17:17 +0000 Subject: [PATCH 100/243] Cover native ASH preparation, exact scoring and compatibility --- docs/native-ash-preparation.md | 83 +++++++++++ .../vector/NativeASHCompatibilityTest.java | 17 +++ .../vector/NativeASHPreparationTest.java | 141 ++++++++++++++++++ 3 files changed, 241 insertions(+) create mode 100644 docs/native-ash-preparation.md create mode 100644 jvector-native/src/test/java/io/github/jbellis/jvector/vector/NativeASHPreparationTest.java diff --git a/docs/native-ash-preparation.md b/docs/native-ash-preparation.md new file mode 100644 index 000000000..7bcba1f07 --- /dev/null +++ b/docs/native-ash-preparation.md @@ -0,0 +1,83 @@ +# Native ASH projection and symmetric construction scoring + +ASH models prepare an immutable provider-specific projection when their Stiefel +transform is constructed or loaded. The default implementation keeps the +existing row-dot implementation, including Panama SIMD. The native provider +copies the row-major float matrix once into automatically managed shared native +memory. The extra matrix storage is included in `ramBytesUsed()` and is released +when its model is no longer reachable. It is not serialized; the model and graph +formats are unchanged. + +`ash_project_f32` computes the entire matrix-vector product in one Highway +dispatch. Four rows share query loads, with independent FMA chains and masked +tails. Every graph query still pays for its own projection. There is no batch of +unrelated queries, BLAS thread pool, per-row FFM call, or per-query matrix copy. +Small bounded products use a synchronous critical downcall with heap query and +output arrays. Larger products use an ordinary downcall with temporary native +query/output buffers so the JVM can reach safepoints during the dense multiply. +The matrix is shared read-only; temporary storage is invocation-local. + +`ash_symmetric_dot` evaluates encoded pairs used by graph construction: 1-bit +XOR/popcount; 2-bit weighted popcounts over packed sign/magnitude fields; 4-bit +unsigned-magnitude by signed-magnitude byte dot products. Integer accumulation +precedes a single factor of one quarter for 2/4-bit codes. Tail padding is ignored. +The Ice Lake vtable selects the same Highway source compiled with native +population-count and VNNI support, and Sapphire Rapids inherits that slot. +AVX-512 without those instructions, AVX2, scalar and ARM retain the portable +Highway implementations. The Java scorer still combines scales, centered +offsets, landmark terms and the existing similarity conversion. + +`ash_symmetric_lut_i16` handles byte-interleaved ranges in one call, retaining +the existing exact signed-short LUT. It uses 32-bit partial sums and bounded +chunks accumulated into 64-bit totals, rather than letting a short or integer +sum overflow. The graph builder's pair-oriented API uses the pair kernel; +callers of `ASHSymmetricScorer.blockScorerFor` use the native LUT kernel. Existing +C>1 formulas and the graph-neighborhood scorer are unchanged. The graph study +uses C=1 throughout. + +All three symbols are optional and looked up independently. Loading an older +compatible library preserves Java projection and symmetric implementations. +Java validates shapes, array bounds and output aliasing before passing pointers +to native code. Native kernels do not allocate, retain pointers or call Java. +Scorer diagnostics identify whether each operation is Highway or Java. + +## Reference implementation review + +Reviewed IVF ASH Fastscan at commit +[`56d78d0ff3aaaa61a02b94c6f49a97789706a717`](https://github.com/tlwillke/ivf-ash-fastscan/tree/56d78d0ff3aaaa61a02b94c6f49a97789706a717). +Its [`AshProjectionModel::transform_residual_batch`](https://github.com/tlwillke/ivf-ash-fastscan/blob/56d78d0ff3aaaa61a02b94c6f49a97789706a717/ash_transform.cpp#L727) +uses a contiguous input-major projection and BLAS matrix multiplication. Its +[`compute_lut_uint8`](https://github.com/tlwillke/ivf-ash-fastscan/blob/56d78d0ff3aaaa61a02b94c6f49a97789706a717/ash_query.cpp#L347) +path batches projections before building query-local LUTs. The +[`AVX-512 scan`](https://github.com/tlwillke/ivf-ash-fastscan/blob/56d78d0ff3aaaa61a02b94c6f49a97789706a717/ash_fastscan_avx512.cpp) +uses packed nibbles, byte shuffles and independent integer accumulators. + +The transferable principles here are preparing contiguous immutable weights, +amortizing native dispatch over a complete operation, reusing query loads, and +accumulating compact codes without materializing decoded vectors. This port +implements a single-query Highway matrix-vector operation because graph query +latency must include preparation for each query. The IVF implementation does +not provide a graph-construction symmetric scorer. Its quantized uint8 query +LUTs are not substituted for the graph's existing exact symmetric integer LUTs. +The kernels in this change are independent implementations of these operations. + +## Validation and measurement + +Targeted regression checks compare projection with a double-precision oracle, +packed pair dots with an independent decoder, and integer LUT ranges with +64-bit scalar accumulation. They cover dirty tail bits, irregular dimensions, +both FFM modes, concurrent projections, bounds/alias rejection and absent native +symbols. Both JDK 22 and JDK 25 are checked with normal dispatch and the existing +AVX3_DL, AVX3, AVX2 and SSE/scalar ISA caps. The generic kernel translation unit +also compiles for ARM NEON on Apple Silicon. SVE/SVE2 execution is not validated +on this host. + +Performance collection uses unchanged standard BenchYAML, existing inclusive +timers, normal query preparation, deferred writes and NVQ reranking. Before and +after query runs load the same saved model and graph, retain stock warmup and +samples, and use 1 or 8 scoring threads pinned to physical cores. Traced small +BenchYAML preflights confirm actual native calls; tracing is disabled for all +performance measurements. Separate fresh full-data builds verify construction. +The focused cases are MPNet at the 2-bit ASH budget and OpenAI3072 at the 4-bit +ASH budget on both JDKs. They are not a replacement for the earlier full PQ/ASH +matrix. Full results and measurement scope accompany the saved report. diff --git a/jvector-native/src/test/java/io/github/jbellis/jvector/vector/NativeASHCompatibilityTest.java b/jvector-native/src/test/java/io/github/jbellis/jvector/vector/NativeASHCompatibilityTest.java index 74ff784c7..d41713f91 100644 --- a/jvector-native/src/test/java/io/github/jbellis/jvector/vector/NativeASHCompatibilityTest.java +++ b/jvector-native/src/test/java/io/github/jbellis/jvector/vector/NativeASHCompatibilityTest.java @@ -33,6 +33,9 @@ void packagedLibraryProvidesNeighborhoodScoring() { assumeTrue(arch.equals("amd64") || arch.equals("x86_64")); assumeTrue(LibraryLoader.loadJvector(), "Native jvector library not available"); assertTrue(new NativeVectorUtilSupport().supportsAshNeighborhoodScoring()); + assertTrue(NativeASHProjection.isAvailable()); + assertTrue(NativeASHSymmetricKernels.hasPairKernel()); + assertTrue(NativeASHSymmetricKernels.hasLutKernel()); } @Test @@ -67,6 +70,20 @@ public static void main(String[] args) { if (backend.supportsAshNeighborhoodScoring()) { throw new AssertionError("ASH symbol unexpectedly available without a loaded native library"); } + if (NativeASHProjection.isAvailable() || NativeASHSymmetricKernels.hasPairKernel() + || NativeASHSymmetricKernels.hasLutKernel()) { + throw new AssertionError("New ASH symbols unexpectedly available"); + } + float[] projected = new float[2]; + backend.prepareAshProjection(new float[][]{{1,2,3},{4,5,6}}) + .project(new float[]{2,3,4}, projected); + if (projected[0] != 20f || projected[1] != 47f) + throw new AssertionError("Java projection fallback failed"); + if (backend.ashSymmetricDot(null,new byte[]{0},null,new byte[]{0},2,4) != .5f) + throw new AssertionError("Java symmetric pair fallback failed"); + float[] symmetric = new float[1]; + backend.ashSymmetricLutScore(new byte[1],1,1,0,1,new short[32],symmetric,0); + if (symmetric[0] != 0f) throw new AssertionError("Java symmetric LUT fallback failed"); float[] lut = new float[16]; java.util.Arrays.fill(lut, .25f); float[] out = new float[8]; diff --git a/jvector-native/src/test/java/io/github/jbellis/jvector/vector/NativeASHPreparationTest.java b/jvector-native/src/test/java/io/github/jbellis/jvector/vector/NativeASHPreparationTest.java new file mode 100644 index 000000000..c27a6d953 --- /dev/null +++ b/jvector-native/src/test/java/io/github/jbellis/jvector/vector/NativeASHPreparationTest.java @@ -0,0 +1,141 @@ +/* + * 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.cnative.LibraryLoader; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Random; +import java.util.concurrent.Executors; + +import static org.junit.jupiter.api.Assertions.*; +import static org.junit.jupiter.api.Assumptions.assumeTrue; + +/** Independent arithmetic oracles cover vector tails, both FFM modes and shared-model use. */ +class NativeASHPreparationTest { + @BeforeAll + static void loadLibrary() { + assumeTrue(LibraryLoader.loadJvector(), "Native library unavailable"); + assertTrue(NativeASHProjection.isAvailable()); + assertTrue(NativeASHSymmetricKernels.hasPairKernel()); + assertTrue(NativeASHSymmetricKernels.hasLutKernel()); + } + + @Test + void projectionMatchesDoubleOracleAndIsThreadSafe() throws Exception { + Random random = new Random(93124); + for (int[] shape : new int[][]{{1,1},{3,7},{4,15},{5,17},{17,31},{33,129},{257,513},{1536,3072}}) { + int rows = shape[0], columns = shape[1]; + float[][] matrix = new float[rows][columns]; + float[] query = new float[columns]; + for (float[] row : matrix) for (int j = 0; j < columns; j++) row[j] = random.nextFloat() - .5f; + for (int j = 0; j < columns; j++) query[j] = random.nextFloat() - .5f; + var projection = new NativeASHProjection(matrix); + assertEquals((long) rows * columns * Float.BYTES, projection.ramBytesUsed()); + float[] expected = new float[rows]; + double[] magnitude = new double[rows]; + for (int i = 0; i < rows; i++) { + double sum = 0; + for (int j = 0; j < columns; j++) { + double product = (double) matrix[i][j] * query[j]; + sum += product; magnitude[i] += Math.abs(product); + } + expected[i] = (float) sum; + } + float[] original = query.clone(); + try (var workers = Executors.newFixedThreadPool(4)) { + var tasks = new ArrayList>(); + for (int repeat = 0; repeat < 8; repeat++) tasks.add(() -> { + float[] out = new float[rows]; + projection.project(query, out); + for (int i = 0; i < rows; i++) assertEquals(expected[i], out[i], 2e-6 * magnitude[i] + 1e-7); + return null; + }); + for (var result : workers.invokeAll(tasks)) result.get(); + } + assertArrayEquals(original, query); + } + } + + @Test + void packedPairsMatchIndependentDecoderIncludingDirtyPadding() { + Random random = new Random(55191); + for (int bits : new int[]{1,2,4}) { + for (int dimensions : new int[]{0,1,2,3,7,15,16,17,31,32,33,63,64,65,127,128,129,257,384,512,768,1536,65537}) { + long[] aBits = new long[(dimensions + 63) / 64], bBits = aBits.clone(); + byte[] a = new byte[(dimensions * bits + 7) / 8], b = a.clone(); + for (int i = 0; i < aBits.length; i++) { aBits[i] = random.nextLong(); bBits[i] = random.nextLong(); } + random.nextBytes(a); random.nextBytes(b); + long total = 0; + for (int j = 0; j < dimensions; j++) { + if (bits == 1) total += (((aBits[j/64] ^ bBits[j/64]) >>> (j%64)) & 1) == 0 ? 1 : -1; + else total += (long) doubled(a,j,bits) * doubled(b,j,bits); + } + float expected = bits == 1 ? (float) total : total * .25f; + assertEquals(expected, NativeASHSymmetricKernels.dot(aBits,a,bBits,b,dimensions,bits), 0f); + assertEquals(expected, NativeASHSymmetricKernels.dot(bBits,b,aBits,a,dimensions,bits), 0f); + } + } + } + + private static int doubled(byte[] codes, int j, int bits) { + int field = (codes[j * bits / 8] >>> (j * bits % 8)) & ((1 << bits) - 1); + int sign = 1 << (bits - 1), magnitude = 2 * (field & (sign - 1)) + 1; + return (field & sign) == 0 ? -magnitude : magnitude; + } + + @Test + void integerLutHandlesOffsetsTailsAndOverflow() { + Random random = new Random(1021); + int stride = 37, lane = 3; + for (int groups : new int[]{0,1,2,31,63,64,65,257,4097,65539}) { + byte[] codes = new byte[((groups+1)/2)*stride]; random.nextBytes(codes); + short[] lut = new short[groups*32]; + for (int g = 0; g < groups; g++) for (int i = 0; i < 16; i++) { + short value = groups == 65539 ? Short.MAX_VALUE : (short) random.nextInt(); + lut[g*32+i] = lut[g*32+i+16] = value; + } + for (int count : new int[]{0,1,7,16,17,32,34}) { + float[] out = new float[count+2]; Arrays.fill(out, Float.NaN); + NativeASHSymmetricKernels.scoreLut(codes,groups,stride,lane,count,lut,out,1); + assertTrue(Float.isNaN(out[0])); assertTrue(Float.isNaN(out[count+1])); + for (int i = 0; i < count; i++) { + long sum = 0; + for (int g = 0; g < groups; g++) sum += lut[g*32+((codes[(g/2)*stride+lane+i] >>> (4*(g%2))) & 15)]; + assertEquals(sum*.25f,out[i+1],0f); + } + } + } + } + + @Test + void rejectsUnsafeInputsBeforeNativeCall() { + var projection = new NativeASHProjection(new float[][]{{1,2},{3,4}}); + float[] alias = {1,2}; + assertThrows(IllegalArgumentException.class, () -> projection.project(alias,alias)); + assertThrows(IllegalArgumentException.class, () -> projection.project(new float[1],new float[2])); + assertThrows(IllegalArgumentException.class, () -> new NativeASHProjection(new float[][]{{1},{2,3}})); + assertThrows(IndexOutOfBoundsException.class, () -> NativeASHSymmetricKernels.dot(null,new byte[1],null,new byte[1],9,2)); + assertThrows(IllegalArgumentException.class, () -> NativeASHSymmetricKernels.dot(null,null,null,null,-1,2)); + assertThrows(IllegalArgumentException.class, () -> NativeASHSymmetricKernels.dot(null,null,null,null,1,3)); + assertThrows(IndexOutOfBoundsException.class, () -> NativeASHSymmetricKernels.scoreLut(new byte[8],1,8,7,2,new short[32],new float[2],0)); + assertThrows(IndexOutOfBoundsException.class, () -> NativeASHSymmetricKernels.scoreLut(new byte[8],1,8,0,8,new short[31],new float[8],0)); + } +} From 37eb9aed317640479335de3fb6275ba606dd37bb Mon Sep 17 00:00:00 2001 From: Ted Willke Date: Thu, 29 Oct 2026 06:48:27 +0000 Subject: [PATCH 101/243] Record BenchYAML results for native ASH preparation --- .../construction.csv | 5 + .../design.md | 83 ++ .../harness-identity.json | 10 + .../implementation.patch | 1157 ++++++++++++++++ .../inputs.json | 98 ++ .../manifest.json | 17 + .../native-isa.txt | 1 + .../native-preflight.json | 26 + .../projection-diagnostic.json | 21 + .../protocol.json | 45 + .../queries.csv | 17 + .../raw-results.json | 1234 +++++++++++++++++ .../report.md | 57 + .../source.json | 16 + .../summary.json | 10 + .../validation.json | 70 + 16 files changed, 2867 insertions(+) create mode 100644 docs/benchmarks/native-ash-projection-symmetric-20261029/construction.csv create mode 100644 docs/benchmarks/native-ash-projection-symmetric-20261029/design.md create mode 100644 docs/benchmarks/native-ash-projection-symmetric-20261029/harness-identity.json create mode 100644 docs/benchmarks/native-ash-projection-symmetric-20261029/implementation.patch create mode 100644 docs/benchmarks/native-ash-projection-symmetric-20261029/inputs.json create mode 100644 docs/benchmarks/native-ash-projection-symmetric-20261029/manifest.json create mode 100644 docs/benchmarks/native-ash-projection-symmetric-20261029/native-isa.txt create mode 100644 docs/benchmarks/native-ash-projection-symmetric-20261029/native-preflight.json create mode 100644 docs/benchmarks/native-ash-projection-symmetric-20261029/projection-diagnostic.json create mode 100644 docs/benchmarks/native-ash-projection-symmetric-20261029/protocol.json create mode 100644 docs/benchmarks/native-ash-projection-symmetric-20261029/queries.csv create mode 100644 docs/benchmarks/native-ash-projection-symmetric-20261029/raw-results.json create mode 100644 docs/benchmarks/native-ash-projection-symmetric-20261029/report.md create mode 100644 docs/benchmarks/native-ash-projection-symmetric-20261029/source.json create mode 100644 docs/benchmarks/native-ash-projection-symmetric-20261029/summary.json create mode 100644 docs/benchmarks/native-ash-projection-symmetric-20261029/validation.json diff --git a/docs/benchmarks/native-ash-projection-symmetric-20261029/construction.csv b/docs/benchmarks/native-ash-projection-symmetric-20261029/construction.csv new file mode 100644 index 000000000..2bb3e003c --- /dev/null +++ b/docs/benchmarks/native-ash-projection-symmetric-20261029/construction.csv @@ -0,0 +1,5 @@ +dataset,jdk,ash_bits,training_seconds,encoding_seconds,graph_write_seconds,process_seconds_including_validation,fresh_graph_recall_at_10 +mpnet-1m,22,2,5.292379244,1.047888893,54.164598291,67.83305824548006,0.81888 +mpnet-1m,25,2,5.328078086,0.952869518,34.225727643,46.53267274983227,0.81633 +openai-3072-1m,22,4,38.086685724,25.959572952,101.919147369,185.2930998262018,0.7896 +openai-3072-1m,25,4,36.395486952,25.752220845,106.236637281,186.0093028023839,0.787 diff --git a/docs/benchmarks/native-ash-projection-symmetric-20261029/design.md b/docs/benchmarks/native-ash-projection-symmetric-20261029/design.md new file mode 100644 index 000000000..7bcba1f07 --- /dev/null +++ b/docs/benchmarks/native-ash-projection-symmetric-20261029/design.md @@ -0,0 +1,83 @@ +# Native ASH projection and symmetric construction scoring + +ASH models prepare an immutable provider-specific projection when their Stiefel +transform is constructed or loaded. The default implementation keeps the +existing row-dot implementation, including Panama SIMD. The native provider +copies the row-major float matrix once into automatically managed shared native +memory. The extra matrix storage is included in `ramBytesUsed()` and is released +when its model is no longer reachable. It is not serialized; the model and graph +formats are unchanged. + +`ash_project_f32` computes the entire matrix-vector product in one Highway +dispatch. Four rows share query loads, with independent FMA chains and masked +tails. Every graph query still pays for its own projection. There is no batch of +unrelated queries, BLAS thread pool, per-row FFM call, or per-query matrix copy. +Small bounded products use a synchronous critical downcall with heap query and +output arrays. Larger products use an ordinary downcall with temporary native +query/output buffers so the JVM can reach safepoints during the dense multiply. +The matrix is shared read-only; temporary storage is invocation-local. + +`ash_symmetric_dot` evaluates encoded pairs used by graph construction: 1-bit +XOR/popcount; 2-bit weighted popcounts over packed sign/magnitude fields; 4-bit +unsigned-magnitude by signed-magnitude byte dot products. Integer accumulation +precedes a single factor of one quarter for 2/4-bit codes. Tail padding is ignored. +The Ice Lake vtable selects the same Highway source compiled with native +population-count and VNNI support, and Sapphire Rapids inherits that slot. +AVX-512 without those instructions, AVX2, scalar and ARM retain the portable +Highway implementations. The Java scorer still combines scales, centered +offsets, landmark terms and the existing similarity conversion. + +`ash_symmetric_lut_i16` handles byte-interleaved ranges in one call, retaining +the existing exact signed-short LUT. It uses 32-bit partial sums and bounded +chunks accumulated into 64-bit totals, rather than letting a short or integer +sum overflow. The graph builder's pair-oriented API uses the pair kernel; +callers of `ASHSymmetricScorer.blockScorerFor` use the native LUT kernel. Existing +C>1 formulas and the graph-neighborhood scorer are unchanged. The graph study +uses C=1 throughout. + +All three symbols are optional and looked up independently. Loading an older +compatible library preserves Java projection and symmetric implementations. +Java validates shapes, array bounds and output aliasing before passing pointers +to native code. Native kernels do not allocate, retain pointers or call Java. +Scorer diagnostics identify whether each operation is Highway or Java. + +## Reference implementation review + +Reviewed IVF ASH Fastscan at commit +[`56d78d0ff3aaaa61a02b94c6f49a97789706a717`](https://github.com/tlwillke/ivf-ash-fastscan/tree/56d78d0ff3aaaa61a02b94c6f49a97789706a717). +Its [`AshProjectionModel::transform_residual_batch`](https://github.com/tlwillke/ivf-ash-fastscan/blob/56d78d0ff3aaaa61a02b94c6f49a97789706a717/ash_transform.cpp#L727) +uses a contiguous input-major projection and BLAS matrix multiplication. Its +[`compute_lut_uint8`](https://github.com/tlwillke/ivf-ash-fastscan/blob/56d78d0ff3aaaa61a02b94c6f49a97789706a717/ash_query.cpp#L347) +path batches projections before building query-local LUTs. The +[`AVX-512 scan`](https://github.com/tlwillke/ivf-ash-fastscan/blob/56d78d0ff3aaaa61a02b94c6f49a97789706a717/ash_fastscan_avx512.cpp) +uses packed nibbles, byte shuffles and independent integer accumulators. + +The transferable principles here are preparing contiguous immutable weights, +amortizing native dispatch over a complete operation, reusing query loads, and +accumulating compact codes without materializing decoded vectors. This port +implements a single-query Highway matrix-vector operation because graph query +latency must include preparation for each query. The IVF implementation does +not provide a graph-construction symmetric scorer. Its quantized uint8 query +LUTs are not substituted for the graph's existing exact symmetric integer LUTs. +The kernels in this change are independent implementations of these operations. + +## Validation and measurement + +Targeted regression checks compare projection with a double-precision oracle, +packed pair dots with an independent decoder, and integer LUT ranges with +64-bit scalar accumulation. They cover dirty tail bits, irregular dimensions, +both FFM modes, concurrent projections, bounds/alias rejection and absent native +symbols. Both JDK 22 and JDK 25 are checked with normal dispatch and the existing +AVX3_DL, AVX3, AVX2 and SSE/scalar ISA caps. The generic kernel translation unit +also compiles for ARM NEON on Apple Silicon. SVE/SVE2 execution is not validated +on this host. + +Performance collection uses unchanged standard BenchYAML, existing inclusive +timers, normal query preparation, deferred writes and NVQ reranking. Before and +after query runs load the same saved model and graph, retain stock warmup and +samples, and use 1 or 8 scoring threads pinned to physical cores. Traced small +BenchYAML preflights confirm actual native calls; tracing is disabled for all +performance measurements. Separate fresh full-data builds verify construction. +The focused cases are MPNet at the 2-bit ASH budget and OpenAI3072 at the 4-bit +ASH budget on both JDKs. They are not a replacement for the earlier full PQ/ASH +matrix. Full results and measurement scope accompany the saved report. diff --git a/docs/benchmarks/native-ash-projection-symmetric-20261029/harness-identity.json b/docs/benchmarks/native-ash-projection-symmetric-20261029/harness-identity.json new file mode 100644 index 000000000..eb0a19163 --- /dev/null +++ b/docs/benchmarks/native-ash-projection-symmetric-20261029/harness-identity.json @@ -0,0 +1,10 @@ +{ + "22": { + "byte_for_byte_identical": true, + "files": 222 + }, + "25": { + "byte_for_byte_identical": true, + "files": 222 + } +} diff --git a/docs/benchmarks/native-ash-projection-symmetric-20261029/implementation.patch b/docs/benchmarks/native-ash-projection-symmetric-20261029/implementation.patch new file mode 100644 index 000000000..318d90061 --- /dev/null +++ b/docs/benchmarks/native-ash-projection-symmetric-20261029/implementation.patch @@ -0,0 +1,1157 @@ +From 835bb81c002360fc885f704431d023abd372ef5e Mon Sep 17 00:00:00 2001 +From: Ted Willke +Date: Thu, 29 Oct 2026 06:16:25 +0000 +Subject: [PATCH 1/3] Project dense ASH queries with one Highway dispatch + +--- + .../jvector/quantization/ASHScorer.java | 14 +-- + .../jvector/quantization/ASHVectors.java | 15 +-- + .../quantization/AsymmetricHashing.java | 11 ++ + .../jvector/vector/VectorUtilSupport.java | 29 +++++ + .../jvector/vector/NativeASHProjection.java | 106 ++++++++++++++++++ + .../vector/NativeVectorUtilSupport.java | 9 +- + .../src/main/native/src/ash_projection-inl.h | 71 ++++++++++++ + .../native/src/jvector_simd_kernel_list.h | 6 + + .../main/native/src/jvector_simd_kernels.cpp | 1 + + 9 files changed, 237 insertions(+), 25 deletions(-) + create mode 100644 jvector-native/src/main/java/io/github/jbellis/jvector/vector/NativeASHProjection.java + create mode 100644 jvector-native/src/main/native/src/ash_projection-inl.h + +diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHScorer.java b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHScorer.java +index aa322fe3..edadbf83 100644 +--- a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHScorer.java ++++ b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHScorer.java +@@ -253,13 +253,6 @@ public final class ASHScorer { + final int D = ash.originalDimension; + final int C = ash.landmarkCount; + +- final float[][] A = ash.stiefelTransform.AFloat; // [d][D] +- +- final var vecUtil = +- io.github.jbellis.jvector.vector.VectorizationProvider +- .getInstance() +- .getVectorUtilSupport(); +- + // Materialize query once to avoid VectorFloat.get in inner loops. + final float[] qArr = new float[D]; + for (int k = 0; k < D; k++) { +@@ -269,12 +262,9 @@ public final class ASHScorer { + // Compute Aq once per query: + // qProj[j] = + final float[] qProj = new float[d]; ++ ash.stiefelTransform.project(qArr, qProj); + float sumQProj = 0f; +- for (int j = 0; j < d; j++) { +- float v = vecUtil.ashDotRow(A[j], qArr); +- qProj[j] = v; +- sumQProj += v; +- } ++ for (float value : qProj) sumQProj += value; + + // For each landmark c: + // tildeQ_c = Aq - Aμ_c (1-bit and generic multibit paths) +diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHVectors.java b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHVectors.java +index dd9b4f61..af33082b 100644 +--- a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHVectors.java ++++ b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHVectors.java +@@ -531,8 +531,7 @@ public class ASHVectors implements CompressedVectors { + float[] q = new float[ash.originalDimension]; + for (int i = 0; i < q.length; i++) q[i] = query.get(i); + float[] projected = new float[d]; +- VectorUtilSupport backend = VectorizationProvider.getInstance().getVectorUtilSupport(); +- for (int i = 0; i < d; i++) projected[i] = backend.ashDotRow(ash.stiefelTransform.AFloat[i], q); ++ ash.stiefelTransform.project(q, projected); + lut = new float[Math.multiplyExact(groups, 16)]; + FusedASHLayout.buildQueryLut(projected, d, ash.bitsPerDimension, lut); + dotQMu = new float[ash.landmarkCount]; +@@ -880,11 +879,6 @@ public class ASHVectors implements CompressedVectors { + final int D = ash.originalDimension; + final int C = ash.landmarkCount; + +- final float[][] A = ash.stiefelTransform.AFloat; +- +- final var vecUtil = +- VectorizationProvider.getInstance().getVectorUtilSupport(); +- + // q materialized once + final float[] qArr = new float[D]; + for (int i = 0; i < D; i++) { +@@ -893,12 +887,9 @@ public class ASHVectors implements CompressedVectors { + + // Aq + final float[] qProj = new float[d]; ++ ash.stiefelTransform.project(qArr, qProj); + float sumQProj = 0f; +- for (int j = 0; j < d; j++) { +- float v = vecUtil.ashDotRow(A[j], qArr); +- qProj[j] = v; +- sumQProj += v; +- } ++ for (float value : qProj) sumQProj += value; + + final float[] tildeQPool = new float[C * d]; + final float[] sumTildeQ = new float[C]; +diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/AsymmetricHashing.java b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/AsymmetricHashing.java +index 0023edd0..baa5b76e 100644 +--- a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/AsymmetricHashing.java ++++ b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/AsymmetricHashing.java +@@ -1285,6 +1285,14 @@ public class AsymmetricHashing implements VectorCompressor { ++ if (query.length != columns || out.length != matrix.length || query == out) ++ throw new IllegalArgumentException("ASH projection shape or aliasing mismatch"); ++ for (int row = 0; row < matrix.length; row++) out[row] = ashDotRow(matrix[row], query); ++ }; ++ } ++ + /** + * Dot product between a matrix row and a dense vector. + * Used by ASH projection: {@code y_i = }. +diff --git a/jvector-native/src/main/java/io/github/jbellis/jvector/vector/NativeASHProjection.java b/jvector-native/src/main/java/io/github/jbellis/jvector/vector/NativeASHProjection.java +new file mode 100644 +index 00000000..4503e82e +--- /dev/null ++++ b/jvector-native/src/main/java/io/github/jbellis/jvector/vector/NativeASHProjection.java +@@ -0,0 +1,106 @@ ++/* ++ * 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 java.lang.foreign.Arena; ++import java.lang.foreign.FunctionDescriptor; ++import java.lang.foreign.Linker; ++import java.lang.foreign.MemorySegment; ++import java.lang.foreign.SymbolLookup; ++import java.lang.invoke.MethodHandle; ++import java.util.Objects; ++ ++import static java.lang.foreign.ValueLayout.ADDRESS; ++import static java.lang.foreign.ValueLayout.JAVA_INT; ++ ++/** ++ * Immutable native row-major matrix for one complete ASH query projection per downcall. ++ * The automatically managed arena lives with the model; it is not thread confined. ++ * Query/output buffers are local to each invocation, so projections may run concurrently. ++ * Large projections use ordinary FFM and copy only the small query and result, allowing ++ * safepoints during the dense multiply. Matrix storage is never copied per query. ++ */ ++final class NativeASHProjection implements VectorUtilSupport.AshProjection { ++ private static final String SYMBOL = "ash_project_f32"; ++ private static final boolean TRACE = Boolean.getBoolean("jextract.trace.downcalls"); ++ // Conservative work bound for short, synchronous critical calls; not a timing claim. ++ private static final int MAX_CRITICAL_CELLS = 1 << 16; ++ private static final MemorySegment ADDRESS_OF_KERNEL = SymbolLookup.loaderLookup().find(SYMBOL).orElse(null); ++ private static final FunctionDescriptor DESCRIPTOR = ++ FunctionDescriptor.ofVoid(ADDRESS, ADDRESS, JAVA_INT, JAVA_INT, ADDRESS); ++ private static final MethodHandle CRITICAL = ADDRESS_OF_KERNEL == null ? null ++ : Linker.nativeLinker().downcallHandle(ADDRESS_OF_KERNEL, DESCRIPTOR, Linker.Option.critical(true)); ++ private static final MethodHandle ORDINARY = ADDRESS_OF_KERNEL == null ? null ++ : Linker.nativeLinker().downcallHandle(ADDRESS_OF_KERNEL, DESCRIPTOR); ++ ++ private final MemorySegment matrix; ++ private final int rows; ++ private final int columns; ++ ++ static boolean isAvailable() { return ADDRESS_OF_KERNEL != null; } ++ ++ NativeASHProjection(float[][] source) { ++ Objects.requireNonNull(source, "matrix"); ++ if (!isAvailable()) throw new UnsupportedOperationException("Native library does not provide " + SYMBOL); ++ rows = source.length; ++ if (rows == 0 || source[0].length == 0) ++ throw new IllegalArgumentException("ASH projection dimensions must be positive"); ++ columns = source[0].length; ++ for (float[] row : source) { ++ if (row.length != columns) throw new IllegalArgumentException("Ragged ASH projection"); ++ } ++ long rowBytes = (long) columns * Float.BYTES; ++ matrix = Arena.ofAuto().allocate(Math.multiplyExact((long) rows, rowBytes), 64); ++ for (int row = 0; row < rows; row++) ++ MemorySegment.copy(MemorySegment.ofArray(source[row]), 0, matrix, row * rowBytes, rowBytes); ++ } ++ ++ @Override ++ public long ramBytesUsed() { return matrix.byteSize(); } ++ ++ @Override ++ public void project(float[] query, float[] out) { ++ Objects.requireNonNull(query, "query"); ++ Objects.requireNonNull(out, "out"); ++ if (query.length != columns || out.length != rows || query == out) ++ throw new IllegalArgumentException("ASH projection shape or aliasing mismatch"); ++ if ((long) rows * columns <= MAX_CRITICAL_CELLS) { ++ invoke(CRITICAL, MemorySegment.ofArray(query), MemorySegment.ofArray(out)); ++ } else { ++ try (Arena arena = Arena.ofConfined()) { ++ long queryBytes = (long) columns * Float.BYTES; ++ MemorySegment buffers = arena.allocate(queryBytes + (long) rows * Float.BYTES, 64); ++ MemorySegment nativeQuery = buffers.asSlice(0, queryBytes); ++ MemorySegment nativeOut = buffers.asSlice(queryBytes); ++ MemorySegment.copy(MemorySegment.ofArray(query), 0, nativeQuery, 0, queryBytes); ++ invoke(ORDINARY, nativeQuery, nativeOut); ++ MemorySegment.copy(nativeOut, 0, MemorySegment.ofArray(out), 0, (long) rows * Float.BYTES); ++ } ++ } ++ } ++ ++ private void invoke(MethodHandle handle, MemorySegment query, MemorySegment out) { ++ if (TRACE) System.out.printf("%s(%d, %d)%n", SYMBOL, rows, columns); ++ try { ++ handle.invokeExact(matrix, query, rows, columns, out); ++ } catch (RuntimeException | Error error) { ++ throw error; ++ } catch (Throwable error) { ++ throw new AssertionError("Unexpected checked exception from " + SYMBOL, error); ++ } ++ } ++} +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 da754e64..d105d9b1 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 +@@ -91,6 +91,12 @@ class NativeVectorUtilSupport extends PanamaVectorUtilSupport + return NativeNVQArithmetic.sub(a, aOffset, b, bOffset, length); + } + ++ @Override ++ public AshProjection prepareAshProjection(float[][] matrix) { ++ return NativeASHProjection.isAvailable() ++ ? new NativeASHProjection(matrix) : super.prepareAshProjection(matrix); ++ } ++ + @Override + public boolean supportsAshNeighborhoodScoring() { + return NativeASHKernels.isAvailable(); +@@ -101,7 +107,8 @@ class NativeVectorUtilSupport extends PanamaVectorUtilSupport + String neighborhood = supportsAshNeighborhoodScoring() + ? "Highway native, ISA=" + getActiveIsa() + : "unavailable; using Java LUT"; +- return super.ashKernelDescription() + "; C=1 whole-neighborhood=" + neighborhood; ++ return super.ashKernelDescription() + "; C=1 whole-neighborhood=" + neighborhood ++ + "; dense projection=" + (NativeASHProjection.isAvailable() ? "Highway" : "Java"); + } + + @Override +diff --git a/jvector-native/src/main/native/src/ash_projection-inl.h b/jvector-native/src/main/native/src/ash_projection-inl.h +new file mode 100644 +index 00000000..51447f1f +--- /dev/null ++++ b/jvector-native/src/main/native/src/ash_projection-inl.h +@@ -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. ++ */ ++ ++#ifndef JVECTOR_ASH_PROJECTION_INL_H ++#define JVECTOR_ASH_PROJECTION_INL_H ++ ++// Included within JV_ISA. The immutable matrix is contiguous row-major and is ++// prepared once per model. A whole query crosses FFM once; no allocation, ++// callback, synchronization or pointer retention occurs in this leaf kernel. ++void ash_project_f32(const float *HWY_RESTRICT matrix, const float *HWY_RESTRICT query, ++ int rows, int cols, float *HWY_RESTRICT out) { ++ const hn::ScalableTag d; ++ const size_t lanes = hn::Lanes(d); ++ const size_t columns = static_cast(cols); ++ int row = 0; ++ // Four rows share each query load. Two chains per row hide FMA latency ++ // without changing the stored matrix or batching independent graph queries. ++ for (; rows - row >= 4; row += 4) { ++ const float *a = matrix + static_cast(row) * columns; ++ auto s0 = hn::Zero(d), s1 = hn::Zero(d), s2 = hn::Zero(d), s3 = hn::Zero(d); ++ auto t0 = hn::Zero(d), t1 = hn::Zero(d), t2 = hn::Zero(d), t3 = hn::Zero(d); ++ size_t col = 0; ++ for (; col + 2 * lanes <= columns; col += 2 * lanes) { ++ const auto q0 = hn::LoadU(d, query + col); ++ const auto q1 = hn::LoadU(d, query + col + lanes); ++ s0 = hn::MulAdd(hn::LoadU(d, a + col), q0, s0); ++ s1 = hn::MulAdd(hn::LoadU(d, a + columns + col), q0, s1); ++ s2 = hn::MulAdd(hn::LoadU(d, a + 2 * columns + col), q0, s2); ++ s3 = hn::MulAdd(hn::LoadU(d, a + 3 * columns + col), q0, s3); ++ t0 = hn::MulAdd(hn::LoadU(d, a + col + lanes), q1, t0); ++ t1 = hn::MulAdd(hn::LoadU(d, a + columns + col + lanes), q1, t1); ++ t2 = hn::MulAdd(hn::LoadU(d, a + 2 * columns + col + lanes), q1, t2); ++ t3 = hn::MulAdd(hn::LoadU(d, a + 3 * columns + col + lanes), q1, t3); ++ } ++ s0 = hn::Add(s0, t0); s1 = hn::Add(s1, t1); ++ s2 = hn::Add(s2, t2); s3 = hn::Add(s3, t3); ++ for (; col < columns; col += lanes) { ++ const size_t n = std::min(lanes, columns - col); ++ const auto q = hn::LoadN(d, query + col, n); ++ s0 = hn::MulAdd(hn::LoadN(d, a + col, n), q, s0); ++ s1 = hn::MulAdd(hn::LoadN(d, a + columns + col, n), q, s1); ++ s2 = hn::MulAdd(hn::LoadN(d, a + 2 * columns + col, n), q, s2); ++ s3 = hn::MulAdd(hn::LoadN(d, a + 3 * columns + col, n), q, s3); ++ } ++ out[row] = hn::ReduceSum(d, s0); out[row + 1] = hn::ReduceSum(d, s1); ++ out[row + 2] = hn::ReduceSum(d, s2); out[row + 3] = hn::ReduceSum(d, s3); ++ } ++ for (; row < rows; ++row) { ++ const float *a = matrix + static_cast(row) * columns; ++ auto sum = hn::Zero(d); ++ for (size_t col = 0; col < columns; col += lanes) { ++ const size_t n = std::min(lanes, columns - col); ++ sum = hn::MulAdd(hn::LoadN(d, a + col, n), hn::LoadN(d, query + col, n), sum); ++ } ++ out[row] = hn::ReduceSum(d, sum); ++ } ++} ++#endif +diff --git a/jvector-native/src/main/native/src/jvector_simd_kernel_list.h b/jvector-native/src/main/native/src/jvector_simd_kernel_list.h +index 31babe84..e8cb3455 100644 +--- a/jvector-native/src/main/native/src/jvector_simd_kernel_list.h ++++ b/jvector-native/src/main/native/src/jvector_simd_kernel_list.h +@@ -39,6 +39,10 @@ + // Output may be partially written on error. No allocation, pointer retention, + // callbacks, or blocking are permitted: ordinary graph calls use critical FFM. + ++// ASH projection ABI: positive rows/cols, borrowed contiguous row-major matrix, ++// query[cols], out[rows]; output must not alias either read-only input. ++// Callers validate buffer sizes; the kernel is a synchronous allocation-free leaf. ++ + // clang-format off + #define JVECTOR_SIMD_KERNEL_LIST \ + /* Vector similarity */ \ +@@ -68,6 +72,8 @@ + KERNEL_ENTRY(void, calculate_partial_sums_dot_f32, (const float *codebook, int codebookIndex, size_t size, int clusterCount, const float *query, int queryOffset, float *partialSums), (codebook, codebookIndex, size, clusterCount, query, queryOffset, partialSums)) \ + KERNEL_ENTRY(void, calculate_partial_sums_euclidean_f32, (const float *codebook, int codebookIndex, size_t size, int clusterCount, const float *query, int queryOffset, float *partialSums), (codebook, codebookIndex, size, clusterCount, query, queryOffset, partialSums)) \ + KERNEL_ENTRY(void, calculate_partial_sums_self_magnitude_f32, (const float *codebook, int codebookIndex, size_t size, int clusterCount, float *partialSums), (codebook, codebookIndex, size, clusterCount, partialSums)) \ ++ /* ASH preparation and exact packed construction scoring; synchronous leaf calls. */ \ ++ KERNEL_ENTRY(void, ash_project_f32, (const float *matrix, const float *query, int rows, int cols, float *out), (matrix, query, rows, cols, out)) \ + /* Whole-neighborhood C=1 ASH, one downcall including headers and similarity. */ \ + KERNEL_ENTRY(int, ash_score_neighborhood_c1_f32, (const unsigned char *codes, const float *lut, float landmarkDot, int groups, int blockSize, int capacity, float *out), (codes, lut, landmarkDot, groups, blockSize, capacity, out)) \ + /* NVQ kernels */ \ +diff --git a/jvector-native/src/main/native/src/jvector_simd_kernels.cpp b/jvector-native/src/main/native/src/jvector_simd_kernels.cpp +index 1ba0908a..1f52c670 100644 +--- a/jvector-native/src/main/native/src/jvector_simd_kernels.cpp ++++ b/jvector-native/src/main/native/src/jvector_simd_kernels.cpp +@@ -383,6 +383,7 @@ HWY_INLINE float L2SquareDistance(const float *a, + namespace JV_ISA { + + #include "ash_neighborhood-inl.h" ++#include "ash_projection-inl.h" + + HWY_FLATTEN float cosine_f32( + const float *a, size_t aoffset, const float *b, size_t boffset, size_t length) +-- +2.39.5 + + +From d76c1fe80a90330dfa3845345585a5754a9e0739 Mon Sep 17 00:00:00 2001 +From: Ted Willke +Date: Thu, 29 Oct 2026 06:16:25 +0000 +Subject: [PATCH 2/3] Score symmetric ASH construction codes with Highway + +--- + .../vector/NativeASHSymmetricKernels.java | 135 +++++++++++++++ + .../vector/NativeVectorUtilSupport.java | 20 ++- + .../src/main/native/src/ash_symmetric-inl.h | 161 ++++++++++++++++++ + .../native/src/jvector_avx3_dl_kernels.cpp | 6 + + .../src/main/native/src/jvector_simd.cpp | 3 +- + .../native/src/jvector_simd_kernel_list.h | 12 +- + .../main/native/src/jvector_simd_kernels.cpp | 1 + + 7 files changed, 333 insertions(+), 5 deletions(-) + create mode 100644 jvector-native/src/main/java/io/github/jbellis/jvector/vector/NativeASHSymmetricKernels.java + create mode 100644 jvector-native/src/main/native/src/ash_symmetric-inl.h + +diff --git a/jvector-native/src/main/java/io/github/jbellis/jvector/vector/NativeASHSymmetricKernels.java b/jvector-native/src/main/java/io/github/jbellis/jvector/vector/NativeASHSymmetricKernels.java +new file mode 100644 +index 00000000..5f10f759 +--- /dev/null ++++ b/jvector-native/src/main/java/io/github/jbellis/jvector/vector/NativeASHSymmetricKernels.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 java.lang.foreign.Arena; ++import java.lang.foreign.FunctionDescriptor; ++import java.lang.foreign.Linker; ++import java.lang.foreign.MemorySegment; ++import java.lang.foreign.SymbolLookup; ++import java.lang.invoke.MethodHandle; ++import java.util.Objects; ++ ++import static java.lang.foreign.ValueLayout.ADDRESS; ++import static java.lang.foreign.ValueLayout.JAVA_FLOAT; ++import static java.lang.foreign.ValueLayout.JAVA_INT; ++ ++/** ++ * Checked optional Highway bindings for exact symmetric ASH integer arithmetic. ++ * Graph pair comparisons use one bounded critical call. Complete interleaved ++ * ranges use one LUT call; larger legal inputs use ordinary FFM with temporary ++ * native buffers. Neither kernel allocates, retains pointers, blocks or calls Java. ++ * Missing symbols preserve the provider's existing Java implementations. ++ */ ++final class NativeASHSymmetricKernels { ++ private static final int MAX_CRITICAL_WORK = 1 << 16; ++ private static final boolean TRACE = Boolean.getBoolean("jextract.trace.downcalls"); ++ private static final Binding PAIR = new Binding("ash_symmetric_dot", ++ FunctionDescriptor.of(JAVA_FLOAT, ADDRESS, ADDRESS, JAVA_INT, JAVA_INT)); ++ private static final Binding LUT = new Binding("ash_symmetric_lut_i16", ++ FunctionDescriptor.ofVoid(ADDRESS, ADDRESS, JAVA_INT, JAVA_INT, JAVA_INT, JAVA_INT, ADDRESS)); ++ ++ private NativeASHSymmetricKernels() {} ++ ++ static boolean hasPairKernel() { return PAIR.available(); } ++ static boolean hasLutKernel() { return LUT.available(); } ++ ++ static float dot(long[] aBits, byte[] aCodes, long[] bBits, byte[] bCodes, int dimensions, int bits) { ++ if (dimensions < 0 || (bits != 1 && bits != 2 && bits != 4)) ++ throw new IllegalArgumentException("Symmetric ASH requires nonnegative dimensions and bits in {1,2,4}"); ++ if (!PAIR.available()) throw new UnsupportedOperationException("Native symmetric pair kernel unavailable"); ++ MemorySegment a, b; ++ long bytes; ++ if (bits == 1) { ++ long words = (dimensions + 63L) / 64; ++ if (words > aBits.length || words > bBits.length) throw new IndexOutOfBoundsException("ASH bit arrays"); ++ a = MemorySegment.ofArray(aBits); b = MemorySegment.ofArray(bBits); bytes = words * Long.BYTES; ++ } else { ++ bytes = ((long) dimensions * bits + 7) / 8; ++ if (bytes > aCodes.length || bytes > bCodes.length) throw new IndexOutOfBoundsException("ASH code arrays"); ++ a = MemorySegment.ofArray(aCodes); b = MemorySegment.ofArray(bCodes); ++ } ++ if (dimensions <= MAX_CRITICAL_WORK) return invokePair(PAIR.critical, a, b, dimensions, bits); ++ try (Arena arena = Arena.ofConfined()) { ++ MemorySegment na = arena.allocate(bytes, Long.BYTES), nb = arena.allocate(bytes, Long.BYTES); ++ MemorySegment.copy(a, 0, na, 0, bytes); MemorySegment.copy(b, 0, nb, 0, bytes); ++ return invokePair(PAIR.ordinary, na, nb, dimensions, bits); ++ } ++ } ++ ++ static void scoreLut(byte[] codes, int groups, int stride, int lane, int count, ++ short[] lut, float[] out, int outOffset) { ++ Objects.requireNonNull(codes, "codes"); Objects.requireNonNull(lut, "lut"); Objects.requireNonNull(out, "out"); ++ if (!LUT.available()) throw new UnsupportedOperationException("Native symmetric LUT kernel unavailable"); ++ if (groups < 0 || stride <= 0) throw new IllegalArgumentException("ASH groups/stride"); ++ Objects.checkFromIndexSize(lane, count, stride); ++ Objects.checkFromIndexSize(outOffset, count, out.length); ++ long codeBytes = ((groups + 1L) / 2) * stride, lutBytes = (long) groups * 32 * Short.BYTES; ++ if (codeBytes > codes.length || lutBytes / Short.BYTES > lut.length) ++ throw new IndexOutOfBoundsException("ASH LUT/code arrays"); ++ if (count == 0) return; ++ MemorySegment source = MemorySegment.ofArray(codes), table = MemorySegment.ofArray(lut); ++ MemorySegment result = MemorySegment.ofArray(out).asSlice((long) outOffset * Float.BYTES); ++ // Charge padded SIMD lanes as well as active lanes (native width is capped at 16). ++ long vectorSlots = ((count + 15L) / 16) * 16; ++ if ((long) groups * vectorSlots <= MAX_CRITICAL_WORK) { ++ invokeLut(LUT.critical, source, table, groups, stride, lane, count, result); ++ } else { ++ try (Arena arena = Arena.ofConfined()) { ++ MemorySegment nc = arena.allocate(Math.max(1, codeBytes)); ++ MemorySegment nl = arena.allocate(Math.max(Short.BYTES, lutBytes), Short.BYTES); ++ MemorySegment no = arena.allocate((long) count * Float.BYTES, Float.BYTES); ++ MemorySegment.copy(source, 0, nc, 0, codeBytes); MemorySegment.copy(table, 0, nl, 0, lutBytes); ++ invokeLut(LUT.ordinary, nc, nl, groups, stride, lane, count, no); ++ MemorySegment.copy(no, 0, result, 0, (long) count * Float.BYTES); ++ } ++ } ++ } ++ ++ private static float invokePair(MethodHandle handle, MemorySegment a, MemorySegment b, int dimensions, int bits) { ++ if (TRACE) System.out.printf("%s(%d, %d)%n", PAIR.symbol, dimensions, bits); ++ try { ++ return (float) handle.invokeExact(a, b, dimensions, bits); ++ } catch (RuntimeException | Error error) { throw error; ++ } catch (Throwable error) { throw new AssertionError("Unexpected exception from " + PAIR.symbol, error); } ++ } ++ ++ private static void invokeLut(MethodHandle handle, MemorySegment codes, MemorySegment lut, ++ int groups, int stride, int lane, int count, MemorySegment out) { ++ if (TRACE) System.out.printf("%s(%d, %d)%n", LUT.symbol, groups, count); ++ try { ++ handle.invokeExact(codes, lut, groups, stride, lane, count, out); ++ } catch (RuntimeException | Error error) { throw error; ++ } catch (Throwable error) { throw new AssertionError("Unexpected exception from " + LUT.symbol, error); } ++ } ++ ++ private static final class Binding { ++ final String symbol; ++ final MethodHandle critical; ++ final MethodHandle ordinary; ++ ++ Binding(String symbol, FunctionDescriptor descriptor) { ++ this.symbol = symbol; ++ MemorySegment address = SymbolLookup.loaderLookup().find(symbol).orElse(null); ++ critical = address == null ? null : Linker.nativeLinker().downcallHandle( ++ address, descriptor, Linker.Option.critical(true)); ++ ordinary = address == null ? null : Linker.nativeLinker().downcallHandle(address, descriptor); ++ } ++ ++ boolean available() { return critical != null; } ++ } ++} +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 d105d9b1..b02b063e 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 +@@ -97,6 +97,22 @@ class NativeVectorUtilSupport extends PanamaVectorUtilSupport + ? new NativeASHProjection(matrix) : super.prepareAshProjection(matrix); + } + ++ @Override ++ public float ashSymmetricDot(long[] aBits, byte[] aCodes, long[] bBits, byte[] bCodes, ++ int dimensions, int bits) { ++ return NativeASHSymmetricKernels.hasPairKernel() ++ ? NativeASHSymmetricKernels.dot(aBits, aCodes, bBits, bCodes, dimensions, bits) ++ : super.ashSymmetricDot(aBits, aCodes, bBits, bCodes, dimensions, bits); ++ } ++ ++ @Override ++ public void ashSymmetricLutScore(byte[] codes, int groups, int stride, int lane, int count, ++ short[] lut, float[] out, int outOffset) { ++ if (NativeASHSymmetricKernels.hasLutKernel()) ++ NativeASHSymmetricKernels.scoreLut(codes, groups, stride, lane, count, lut, out, outOffset); ++ else super.ashSymmetricLutScore(codes, groups, stride, lane, count, lut, out, outOffset); ++ } ++ + @Override + public boolean supportsAshNeighborhoodScoring() { + return NativeASHKernels.isAvailable(); +@@ -108,7 +124,9 @@ class NativeVectorUtilSupport extends PanamaVectorUtilSupport + ? "Highway native, ISA=" + getActiveIsa() + : "unavailable; using Java LUT"; + return super.ashKernelDescription() + "; C=1 whole-neighborhood=" + neighborhood +- + "; dense projection=" + (NativeASHProjection.isAvailable() ? "Highway" : "Java"); ++ + "; dense projection=" + (NativeASHProjection.isAvailable() ? "Highway" : "Java") ++ + "; symmetric pair=" + (NativeASHSymmetricKernels.hasPairKernel() ? "Highway" : "Java") ++ + "; symmetric LUT=" + (NativeASHSymmetricKernels.hasLutKernel() ? "Highway" : "Java"); + } + + @Override +diff --git a/jvector-native/src/main/native/src/ash_symmetric-inl.h b/jvector-native/src/main/native/src/ash_symmetric-inl.h +new file mode 100644 +index 00000000..c43f0fea +--- /dev/null ++++ b/jvector-native/src/main/native/src/ash_symmetric-inl.h +@@ -0,0 +1,161 @@ ++/* ++ * 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. ++ */ ++ ++#ifndef JVECTOR_ASH_SYMMETRIC_INL_H ++#define JVECTOR_ASH_SYMMETRIC_INL_H ++ ++// Included within JV_ISA. All sizes/pointers are checked by the Java binding. ++// Products of doubled signed-magnitude components are exact integers; divide ++// by four only once, after accumulation. Padding bits never contribute. ++namespace { ++HWY_INLINE int AshDoubledComponent(const uint8_t *code, int j, int bits) { ++ const int perByte = 8 / bits; ++ const int field = code[j / perByte] >> ((j % perByte) * bits); ++ const int sign = 1 << (bits - 1); ++ const int magnitude = 2 * (field & (sign - 1)) + 1; ++ return (field & sign) ? magnitude : -magnitude; ++} ++ ++HWY_INLINE float AshBinaryPair(const uint64_t *a, const uint64_t *b, int dimensions) { ++ const hn::ScalableTag d; ++ const size_t lanes = hn::Lanes(d), words = static_cast(dimensions) / 64; ++ auto counts = hn::Zero(d); ++ // Integer lanes can hold all mismatches even at the maximum Java dimension. ++ for (size_t word = 0; word < words; word += lanes) { ++ const size_t n = std::min(lanes, words - word); ++ const auto diff = hn::Xor(hn::LoadN(d, a + word, n), hn::LoadN(d, b + word, n)); ++ counts = hn::Add(counts, hn::PopulationCount(diff)); ++ } ++ uint64_t mismatches = hn::ReduceSum(d, counts); ++ const int tail = dimensions & 63; ++ if (tail) mismatches += hwy::PopCount((a[words] ^ b[words]) & ((uint64_t{1} << tail) - 1)); ++ return static_cast(dimensions) - 2.0f * static_cast(mismatches); ++} ++ ++HWY_INLINE float AshTwoBitPair(const uint8_t *a, const uint8_t *b, int dimensions) { ++#if HWY_TARGET == HWY_SCALAR ++ int64_t total = 0; ++ for (int j = 0; j < dimensions; ++j) ++ total += AshDoubledComponent(a, j, 2) * AshDoubledComponent(b, j, 2); ++ return static_cast(total) * 0.25f; ++#else ++ const hn::ScalableTag d; ++ const auto slots = hn::Set(d, uint64_t{0x5555555555555555}); ++ const size_t lanes = hn::Lanes(d), words = static_cast(dimensions) / 32; ++ const hn::RebindToSigned di; ++ auto deltas = hn::Zero(di); ++ for (size_t word = 0; word < words; word += lanes) { ++ const size_t n = std::min(lanes, words - word); ++ // Canonical codes are little-endian packed fields. LoadU/LoadN permit ++ // byte alignment on all supported little-endian production targets. ++ const auto av = hn::LoadN(d, reinterpret_cast(a) + word, n); ++ const auto bv = hn::LoadN(d, reinterpret_cast(b) + word, n); ++ const auto neg = hn::And(hn::ShiftRight<1>(hn::Xor(av, bv)), slots); ++ const auto ma = hn::And(av, slots), mb = hn::And(bv, slots), both = hn::And(ma, mb); ++ // sign*(1+2*ma)*(1+2*mb), evaluated with weighted popcounts. ++ // Keep partial sums in registers; one horizontal reduction per pair. ++ auto delta = hn::ShiftLeft<1>(hn::Sub( ++ hn::Add(hn::PopulationCount(ma), hn::PopulationCount(mb)), hn::PopulationCount(neg))); ++ delta = hn::Sub(delta, hn::ShiftLeft<2>(hn::Add( ++ hn::PopulationCount(hn::And(ma, neg)), hn::PopulationCount(hn::And(mb, neg))))); ++ delta = hn::Add(delta, hn::ShiftLeft<2>(hn::PopulationCount(both))); ++ delta = hn::Sub(delta, hn::ShiftLeft<3>(hn::PopulationCount(hn::And(both, neg)))); ++ deltas = hn::Add(deltas, hn::BitCast(di, delta)); ++ } ++ int64_t total = static_cast(words) * 32 + hn::ReduceSum(di, deltas); ++ for (int j = static_cast(words * 32); j < dimensions; ++j) ++ total += AshDoubledComponent(a, j, 2) * AshDoubledComponent(b, j, 2); ++ return static_cast(total) * 0.25f; ++#endif ++} ++ ++HWY_INLINE float AshFourBitPair(const uint8_t *a, const uint8_t *b, int dimensions) { ++ int64_t total = 0; ++ int j = 0; ++#if HWY_TARGET != HWY_SCALAR ++ const hn::ScalableTag di; ++ const hn::Repartition db; ++ const hn::RebindToSigned ds; ++ const size_t bytes = static_cast(dimensions) / 2, lanes = hn::Lanes(db); ++ size_t offset = 0; ++ for (; offset + lanes <= bytes; offset += lanes) { ++ const auto av = hn::LoadU(db, a + offset), bv = hn::LoadU(db, b + offset); ++ auto sum = hn::Zero(di); ++ for (int shift = 0; shift < 8; shift += 4) { ++ const auto ax = hn::ShiftRightSame(av, shift), bx = hn::ShiftRightSame(bv, shift); ++ const auto am = hn::Add(hn::ShiftLeft<1>(hn::And(ax, hn::Set(db, 7))), hn::Set(db, 1)); ++ const auto bm = hn::BitCast(ds, hn::Add(hn::ShiftLeft<1>(hn::And(bx, hn::Set(db, 7))), hn::Set(db, 1))); ++ const auto sameSign = hn::Eq(hn::And(hn::Xor(ax, bx), hn::Set(db, 8)), hn::Zero(db)); ++ const auto signedB = hn::IfThenElse(hn::RebindMask(ds, sameSign), bm, hn::Neg(bm)); ++ // VNNI when supported; Highway supplies widening multiply otherwise. ++ sum = hn::SumOfMulQuadAccumulate(di, am, signedB, sum); ++ } ++ total += hn::ReduceSum(di, sum); ++ } ++ j = static_cast(2 * offset); ++#endif ++ for (; j < dimensions; ++j) total += AshDoubledComponent(a, j, 4) * AshDoubledComponent(b, j, 4); ++ return static_cast(total) * 0.25f; ++} ++} // namespace ++ ++float ash_symmetric_dot(const void *a, const void *b, int dimensions, int bits) { ++ if (bits == 1) return AshBinaryPair(static_cast(a), static_cast(b), dimensions); ++ if (bits == 2) return AshTwoBitPair(static_cast(a), static_cast(b), dimensions); ++ return AshFourBitPair(static_cast(a), static_cast(b), dimensions); ++} ++ ++#ifndef JVECTOR_ASH_PAIR_ONLY ++// Exact signed-short LUTs, unlike the IVF scan's approximate uint8 LUTs. Widen ++// before accumulation. At most 8192 groups enter an int32 subtotal, then spill ++// into int64 lane totals so arbitrary short entries and long codes cannot wrap. ++void ash_symmetric_lut_i16(const uint8_t *HWY_RESTRICT codes, const int16_t *HWY_RESTRICT lut, ++ int groups, int stride, int lane, int count, float *HWY_RESTRICT out) { ++ const hn::CappedTag d; ++ const hn::Rebind db; ++ const hn::Rebind ds; ++ const int lanes = static_cast(hn::Lanes(d)); ++ for (size_t base = 0; base < static_cast(count); base += lanes) { ++ const int active = static_cast(std::min(static_cast(lanes), static_cast(count) - base)); ++ int64_t totals[16] = {}; ++ for (int first = 0; first < groups;) { ++ const int end = first + std::min(8192, groups - first); ++ auto sum = hn::Zero(d); ++ for (int g = first; g < end; ++g) { ++ auto packed = hn::PromoteTo(d, hn::LoadN(db, codes + static_cast(g / 2) * stride + lane + base, active)); ++ const auto indices = hn::And(hn::ShiftRightSame(packed, 4 * (g & 1)), hn::Set(d, 15)); ++#if !HWY_HAVE_SCALABLE ++ if constexpr (hn::MaxLanes(d) == 16) { ++ const auto table = hn::PromoteTo(d, hn::LoadU(ds, lut + static_cast(g) * 32)); ++ sum = hn::Add(sum, hn::TableLookupLanes(table, hn::IndicesFromVec(d, indices))); ++ } else ++#endif ++ { ++ int32_t table[16]; ++ for (int i = 0; i < 16; ++i) table[i] = lut[static_cast(g) * 32 + i]; ++ sum = hn::Add(sum, hn::GatherIndex(d, table, indices)); ++ } ++ } ++ int32_t partial[16]; ++ hn::StoreU(sum, d, partial); ++ for (int i = 0; i < active; ++i) totals[i] += partial[i]; ++ first = end; ++ } ++ for (int i = 0; i < active; ++i) out[base + i] = static_cast(totals[i]) * 0.25f; ++ } ++} ++#endif ++#endif +diff --git a/jvector-native/src/main/native/src/jvector_avx3_dl_kernels.cpp b/jvector-native/src/main/native/src/jvector_avx3_dl_kernels.cpp +index ae8ab73b..95f61317 100644 +--- a/jvector-native/src/main/native/src/jvector_avx3_dl_kernels.cpp ++++ b/jvector-native/src/main/native/src/jvector_avx3_dl_kernels.cpp +@@ -32,6 +32,12 @@ + + namespace hn = hwy::HWY_NAMESPACE; + ++#include ++#include ++ + namespace AVX3_DL { ++#define JVECTOR_ASH_PAIR_ONLY ++#include "ash_symmetric-inl.h" ++#undef JVECTOR_ASH_PAIR_ONLY + + } // namespace AVX3_DL +diff --git a/jvector-native/src/main/native/src/jvector_simd.cpp b/jvector-native/src/main/native/src/jvector_simd.cpp +index ec69d5cf..34bc2ecc 100644 +--- a/jvector-native/src/main/native/src/jvector_simd.cpp ++++ b/jvector-native/src/main/native/src/jvector_simd.cpp +@@ -107,11 +107,12 @@ static const KernelVTable AVX3_vtable = { + }; + #undef KERNEL_ENTRY + +-// AVX3_DL (Ice Lake) inherits all slots from AVX3 unchanged for now. ++// AVX3_DL inherits AVX3 and uses native popcount/byte-dot for ASH pairs. + // To override a slot: t.kernel_name = AVX3_DL::kernel_name; + // The implementation must exist in jvector_avx3_dl_kernels.cpp. + static const KernelVTable AVX3_DL_vtable = []() { + KernelVTable t = AVX3_vtable; ++ t.ash_symmetric_dot = AVX3_DL::ash_symmetric_dot; + return t; + }(); + +diff --git a/jvector-native/src/main/native/src/jvector_simd_kernel_list.h b/jvector-native/src/main/native/src/jvector_simd_kernel_list.h +index e8cb3455..cf0e23ed 100644 +--- a/jvector-native/src/main/native/src/jvector_simd_kernel_list.h ++++ b/jvector-native/src/main/native/src/jvector_simd_kernel_list.h +@@ -39,9 +39,13 @@ + // Output may be partially written on error. No allocation, pointer retention, + // callbacks, or blocking are permitted: ordinary graph calls use critical FFM. + +-// ASH projection ABI: positive rows/cols, borrowed contiguous row-major matrix, +-// query[cols], out[rows]; output must not alias either read-only input. +-// Callers validate buffer sizes; the kernel is a synchronous allocation-free leaf. ++// ASH preparation/symmetric ABI contracts (all pointers are borrowed synchronously): ++// project: positive rows/cols, row-major matrix[rows*cols], query[cols], out[rows]. ++// pair: nonnegative dimensions, bits in {1,2,4}; binary uint64 words for bits=1, ++// canonical little-endian packed bytes otherwise. Padding bits are ignored. ++// LUT: nonnegative groups/count, positive stride, 0 <= lane <= lane+count <= stride; ++// codes[ceil(groups/2)*stride], lut[groups*32] signed shorts, out[count]. ++// Writable outputs must not alias read-only inputs. Callers validate buffer sizes. + + // clang-format off + #define JVECTOR_SIMD_KERNEL_LIST \ +@@ -74,6 +78,8 @@ + KERNEL_ENTRY(void, calculate_partial_sums_self_magnitude_f32, (const float *codebook, int codebookIndex, size_t size, int clusterCount, float *partialSums), (codebook, codebookIndex, size, clusterCount, partialSums)) \ + /* ASH preparation and exact packed construction scoring; synchronous leaf calls. */ \ + KERNEL_ENTRY(void, ash_project_f32, (const float *matrix, const float *query, int rows, int cols, float *out), (matrix, query, rows, cols, out)) \ ++ KERNEL_ENTRY(float, ash_symmetric_dot, (const void *a, const void *b, int dimensions, int bits), (a, b, dimensions, bits)) \ ++ KERNEL_ENTRY(void, ash_symmetric_lut_i16, (const unsigned char *codes, const int16_t *lut, int groups, int stride, int lane, int count, float *out), (codes, lut, groups, stride, lane, count, out)) \ + /* Whole-neighborhood C=1 ASH, one downcall including headers and similarity. */ \ + KERNEL_ENTRY(int, ash_score_neighborhood_c1_f32, (const unsigned char *codes, const float *lut, float landmarkDot, int groups, int blockSize, int capacity, float *out), (codes, lut, landmarkDot, groups, blockSize, capacity, out)) \ + /* NVQ kernels */ \ +diff --git a/jvector-native/src/main/native/src/jvector_simd_kernels.cpp b/jvector-native/src/main/native/src/jvector_simd_kernels.cpp +index 1f52c670..d2a38e2a 100644 +--- a/jvector-native/src/main/native/src/jvector_simd_kernels.cpp ++++ b/jvector-native/src/main/native/src/jvector_simd_kernels.cpp +@@ -384,6 +384,7 @@ namespace JV_ISA { + + #include "ash_neighborhood-inl.h" + #include "ash_projection-inl.h" ++#include "ash_symmetric-inl.h" + + HWY_FLATTEN float cosine_f32( + const float *a, size_t aoffset, const float *b, size_t boffset, size_t length) +-- +2.39.5 + + +From 28e4c8c1c8f49db472a9d0e530c01d5dcbceb403 Mon Sep 17 00:00:00 2001 +From: Ted Willke +Date: Thu, 29 Oct 2026 06:17:17 +0000 +Subject: [PATCH 3/3] Cover native ASH preparation, exact scoring and + compatibility + +--- + docs/native-ash-preparation.md | 83 +++++++++++ + .../vector/NativeASHCompatibilityTest.java | 17 +++ + .../vector/NativeASHPreparationTest.java | 141 ++++++++++++++++++ + 3 files changed, 241 insertions(+) + create mode 100644 docs/native-ash-preparation.md + create mode 100644 jvector-native/src/test/java/io/github/jbellis/jvector/vector/NativeASHPreparationTest.java + +diff --git a/docs/native-ash-preparation.md b/docs/native-ash-preparation.md +new file mode 100644 +index 00000000..7bcba1f0 +--- /dev/null ++++ b/docs/native-ash-preparation.md +@@ -0,0 +1,83 @@ ++# Native ASH projection and symmetric construction scoring ++ ++ASH models prepare an immutable provider-specific projection when their Stiefel ++transform is constructed or loaded. The default implementation keeps the ++existing row-dot implementation, including Panama SIMD. The native provider ++copies the row-major float matrix once into automatically managed shared native ++memory. The extra matrix storage is included in `ramBytesUsed()` and is released ++when its model is no longer reachable. It is not serialized; the model and graph ++formats are unchanged. ++ ++`ash_project_f32` computes the entire matrix-vector product in one Highway ++dispatch. Four rows share query loads, with independent FMA chains and masked ++tails. Every graph query still pays for its own projection. There is no batch of ++unrelated queries, BLAS thread pool, per-row FFM call, or per-query matrix copy. ++Small bounded products use a synchronous critical downcall with heap query and ++output arrays. Larger products use an ordinary downcall with temporary native ++query/output buffers so the JVM can reach safepoints during the dense multiply. ++The matrix is shared read-only; temporary storage is invocation-local. ++ ++`ash_symmetric_dot` evaluates encoded pairs used by graph construction: 1-bit ++XOR/popcount; 2-bit weighted popcounts over packed sign/magnitude fields; 4-bit ++unsigned-magnitude by signed-magnitude byte dot products. Integer accumulation ++precedes a single factor of one quarter for 2/4-bit codes. Tail padding is ignored. ++The Ice Lake vtable selects the same Highway source compiled with native ++population-count and VNNI support, and Sapphire Rapids inherits that slot. ++AVX-512 without those instructions, AVX2, scalar and ARM retain the portable ++Highway implementations. The Java scorer still combines scales, centered ++offsets, landmark terms and the existing similarity conversion. ++ ++`ash_symmetric_lut_i16` handles byte-interleaved ranges in one call, retaining ++the existing exact signed-short LUT. It uses 32-bit partial sums and bounded ++chunks accumulated into 64-bit totals, rather than letting a short or integer ++sum overflow. The graph builder's pair-oriented API uses the pair kernel; ++callers of `ASHSymmetricScorer.blockScorerFor` use the native LUT kernel. Existing ++C>1 formulas and the graph-neighborhood scorer are unchanged. The graph study ++uses C=1 throughout. ++ ++All three symbols are optional and looked up independently. Loading an older ++compatible library preserves Java projection and symmetric implementations. ++Java validates shapes, array bounds and output aliasing before passing pointers ++to native code. Native kernels do not allocate, retain pointers or call Java. ++Scorer diagnostics identify whether each operation is Highway or Java. ++ ++## Reference implementation review ++ ++Reviewed IVF ASH Fastscan at commit ++[`56d78d0ff3aaaa61a02b94c6f49a97789706a717`](https://github.com/tlwillke/ivf-ash-fastscan/tree/56d78d0ff3aaaa61a02b94c6f49a97789706a717). ++Its [`AshProjectionModel::transform_residual_batch`](https://github.com/tlwillke/ivf-ash-fastscan/blob/56d78d0ff3aaaa61a02b94c6f49a97789706a717/ash_transform.cpp#L727) ++uses a contiguous input-major projection and BLAS matrix multiplication. Its ++[`compute_lut_uint8`](https://github.com/tlwillke/ivf-ash-fastscan/blob/56d78d0ff3aaaa61a02b94c6f49a97789706a717/ash_query.cpp#L347) ++path batches projections before building query-local LUTs. The ++[`AVX-512 scan`](https://github.com/tlwillke/ivf-ash-fastscan/blob/56d78d0ff3aaaa61a02b94c6f49a97789706a717/ash_fastscan_avx512.cpp) ++uses packed nibbles, byte shuffles and independent integer accumulators. ++ ++The transferable principles here are preparing contiguous immutable weights, ++amortizing native dispatch over a complete operation, reusing query loads, and ++accumulating compact codes without materializing decoded vectors. This port ++implements a single-query Highway matrix-vector operation because graph query ++latency must include preparation for each query. The IVF implementation does ++not provide a graph-construction symmetric scorer. Its quantized uint8 query ++LUTs are not substituted for the graph's existing exact symmetric integer LUTs. ++The kernels in this change are independent implementations of these operations. ++ ++## Validation and measurement ++ ++Targeted regression checks compare projection with a double-precision oracle, ++packed pair dots with an independent decoder, and integer LUT ranges with ++64-bit scalar accumulation. They cover dirty tail bits, irregular dimensions, ++both FFM modes, concurrent projections, bounds/alias rejection and absent native ++symbols. Both JDK 22 and JDK 25 are checked with normal dispatch and the existing ++AVX3_DL, AVX3, AVX2 and SSE/scalar ISA caps. The generic kernel translation unit ++also compiles for ARM NEON on Apple Silicon. SVE/SVE2 execution is not validated ++on this host. ++ ++Performance collection uses unchanged standard BenchYAML, existing inclusive ++timers, normal query preparation, deferred writes and NVQ reranking. Before and ++after query runs load the same saved model and graph, retain stock warmup and ++samples, and use 1 or 8 scoring threads pinned to physical cores. Traced small ++BenchYAML preflights confirm actual native calls; tracing is disabled for all ++performance measurements. Separate fresh full-data builds verify construction. ++The focused cases are MPNet at the 2-bit ASH budget and OpenAI3072 at the 4-bit ++ASH budget on both JDKs. They are not a replacement for the earlier full PQ/ASH ++matrix. Full results and measurement scope accompany the saved report. +diff --git a/jvector-native/src/test/java/io/github/jbellis/jvector/vector/NativeASHCompatibilityTest.java b/jvector-native/src/test/java/io/github/jbellis/jvector/vector/NativeASHCompatibilityTest.java +index 74ff784c..d41713f9 100644 +--- a/jvector-native/src/test/java/io/github/jbellis/jvector/vector/NativeASHCompatibilityTest.java ++++ b/jvector-native/src/test/java/io/github/jbellis/jvector/vector/NativeASHCompatibilityTest.java +@@ -33,6 +33,9 @@ class NativeASHCompatibilityTest { + assumeTrue(arch.equals("amd64") || arch.equals("x86_64")); + assumeTrue(LibraryLoader.loadJvector(), "Native jvector library not available"); + assertTrue(new NativeVectorUtilSupport().supportsAshNeighborhoodScoring()); ++ assertTrue(NativeASHProjection.isAvailable()); ++ assertTrue(NativeASHSymmetricKernels.hasPairKernel()); ++ assertTrue(NativeASHSymmetricKernels.hasLutKernel()); + } + + @Test +@@ -67,6 +70,20 @@ class NativeASHCompatibilityTest { + if (backend.supportsAshNeighborhoodScoring()) { + throw new AssertionError("ASH symbol unexpectedly available without a loaded native library"); + } ++ if (NativeASHProjection.isAvailable() || NativeASHSymmetricKernels.hasPairKernel() ++ || NativeASHSymmetricKernels.hasLutKernel()) { ++ throw new AssertionError("New ASH symbols unexpectedly available"); ++ } ++ float[] projected = new float[2]; ++ backend.prepareAshProjection(new float[][]{{1,2,3},{4,5,6}}) ++ .project(new float[]{2,3,4}, projected); ++ if (projected[0] != 20f || projected[1] != 47f) ++ throw new AssertionError("Java projection fallback failed"); ++ if (backend.ashSymmetricDot(null,new byte[]{0},null,new byte[]{0},2,4) != .5f) ++ throw new AssertionError("Java symmetric pair fallback failed"); ++ float[] symmetric = new float[1]; ++ backend.ashSymmetricLutScore(new byte[1],1,1,0,1,new short[32],symmetric,0); ++ if (symmetric[0] != 0f) throw new AssertionError("Java symmetric LUT fallback failed"); + float[] lut = new float[16]; + java.util.Arrays.fill(lut, .25f); + float[] out = new float[8]; +diff --git a/jvector-native/src/test/java/io/github/jbellis/jvector/vector/NativeASHPreparationTest.java b/jvector-native/src/test/java/io/github/jbellis/jvector/vector/NativeASHPreparationTest.java +new file mode 100644 +index 00000000..c27a6d95 +--- /dev/null ++++ b/jvector-native/src/test/java/io/github/jbellis/jvector/vector/NativeASHPreparationTest.java +@@ -0,0 +1,141 @@ ++/* ++ * 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.cnative.LibraryLoader; ++import org.junit.jupiter.api.BeforeAll; ++import org.junit.jupiter.api.Test; ++ ++import java.util.ArrayList; ++import java.util.Arrays; ++import java.util.Random; ++import java.util.concurrent.Executors; ++ ++import static org.junit.jupiter.api.Assertions.*; ++import static org.junit.jupiter.api.Assumptions.assumeTrue; ++ ++/** Independent arithmetic oracles cover vector tails, both FFM modes and shared-model use. */ ++class NativeASHPreparationTest { ++ @BeforeAll ++ static void loadLibrary() { ++ assumeTrue(LibraryLoader.loadJvector(), "Native library unavailable"); ++ assertTrue(NativeASHProjection.isAvailable()); ++ assertTrue(NativeASHSymmetricKernels.hasPairKernel()); ++ assertTrue(NativeASHSymmetricKernels.hasLutKernel()); ++ } ++ ++ @Test ++ void projectionMatchesDoubleOracleAndIsThreadSafe() throws Exception { ++ Random random = new Random(93124); ++ for (int[] shape : new int[][]{{1,1},{3,7},{4,15},{5,17},{17,31},{33,129},{257,513},{1536,3072}}) { ++ int rows = shape[0], columns = shape[1]; ++ float[][] matrix = new float[rows][columns]; ++ float[] query = new float[columns]; ++ for (float[] row : matrix) for (int j = 0; j < columns; j++) row[j] = random.nextFloat() - .5f; ++ for (int j = 0; j < columns; j++) query[j] = random.nextFloat() - .5f; ++ var projection = new NativeASHProjection(matrix); ++ assertEquals((long) rows * columns * Float.BYTES, projection.ramBytesUsed()); ++ float[] expected = new float[rows]; ++ double[] magnitude = new double[rows]; ++ for (int i = 0; i < rows; i++) { ++ double sum = 0; ++ for (int j = 0; j < columns; j++) { ++ double product = (double) matrix[i][j] * query[j]; ++ sum += product; magnitude[i] += Math.abs(product); ++ } ++ expected[i] = (float) sum; ++ } ++ float[] original = query.clone(); ++ try (var workers = Executors.newFixedThreadPool(4)) { ++ var tasks = new ArrayList>(); ++ for (int repeat = 0; repeat < 8; repeat++) tasks.add(() -> { ++ float[] out = new float[rows]; ++ projection.project(query, out); ++ for (int i = 0; i < rows; i++) assertEquals(expected[i], out[i], 2e-6 * magnitude[i] + 1e-7); ++ return null; ++ }); ++ for (var result : workers.invokeAll(tasks)) result.get(); ++ } ++ assertArrayEquals(original, query); ++ } ++ } ++ ++ @Test ++ void packedPairsMatchIndependentDecoderIncludingDirtyPadding() { ++ Random random = new Random(55191); ++ for (int bits : new int[]{1,2,4}) { ++ for (int dimensions : new int[]{0,1,2,3,7,15,16,17,31,32,33,63,64,65,127,128,129,257,384,512,768,1536,65537}) { ++ long[] aBits = new long[(dimensions + 63) / 64], bBits = aBits.clone(); ++ byte[] a = new byte[(dimensions * bits + 7) / 8], b = a.clone(); ++ for (int i = 0; i < aBits.length; i++) { aBits[i] = random.nextLong(); bBits[i] = random.nextLong(); } ++ random.nextBytes(a); random.nextBytes(b); ++ long total = 0; ++ for (int j = 0; j < dimensions; j++) { ++ if (bits == 1) total += (((aBits[j/64] ^ bBits[j/64]) >>> (j%64)) & 1) == 0 ? 1 : -1; ++ else total += (long) doubled(a,j,bits) * doubled(b,j,bits); ++ } ++ float expected = bits == 1 ? (float) total : total * .25f; ++ assertEquals(expected, NativeASHSymmetricKernels.dot(aBits,a,bBits,b,dimensions,bits), 0f); ++ assertEquals(expected, NativeASHSymmetricKernels.dot(bBits,b,aBits,a,dimensions,bits), 0f); ++ } ++ } ++ } ++ ++ private static int doubled(byte[] codes, int j, int bits) { ++ int field = (codes[j * bits / 8] >>> (j * bits % 8)) & ((1 << bits) - 1); ++ int sign = 1 << (bits - 1), magnitude = 2 * (field & (sign - 1)) + 1; ++ return (field & sign) == 0 ? -magnitude : magnitude; ++ } ++ ++ @Test ++ void integerLutHandlesOffsetsTailsAndOverflow() { ++ Random random = new Random(1021); ++ int stride = 37, lane = 3; ++ for (int groups : new int[]{0,1,2,31,63,64,65,257,4097,65539}) { ++ byte[] codes = new byte[((groups+1)/2)*stride]; random.nextBytes(codes); ++ short[] lut = new short[groups*32]; ++ for (int g = 0; g < groups; g++) for (int i = 0; i < 16; i++) { ++ short value = groups == 65539 ? Short.MAX_VALUE : (short) random.nextInt(); ++ lut[g*32+i] = lut[g*32+i+16] = value; ++ } ++ for (int count : new int[]{0,1,7,16,17,32,34}) { ++ float[] out = new float[count+2]; Arrays.fill(out, Float.NaN); ++ NativeASHSymmetricKernels.scoreLut(codes,groups,stride,lane,count,lut,out,1); ++ assertTrue(Float.isNaN(out[0])); assertTrue(Float.isNaN(out[count+1])); ++ for (int i = 0; i < count; i++) { ++ long sum = 0; ++ for (int g = 0; g < groups; g++) sum += lut[g*32+((codes[(g/2)*stride+lane+i] >>> (4*(g%2))) & 15)]; ++ assertEquals(sum*.25f,out[i+1],0f); ++ } ++ } ++ } ++ } ++ ++ @Test ++ void rejectsUnsafeInputsBeforeNativeCall() { ++ var projection = new NativeASHProjection(new float[][]{{1,2},{3,4}}); ++ float[] alias = {1,2}; ++ assertThrows(IllegalArgumentException.class, () -> projection.project(alias,alias)); ++ assertThrows(IllegalArgumentException.class, () -> projection.project(new float[1],new float[2])); ++ assertThrows(IllegalArgumentException.class, () -> new NativeASHProjection(new float[][]{{1},{2,3}})); ++ assertThrows(IndexOutOfBoundsException.class, () -> NativeASHSymmetricKernels.dot(null,new byte[1],null,new byte[1],9,2)); ++ assertThrows(IllegalArgumentException.class, () -> NativeASHSymmetricKernels.dot(null,null,null,null,-1,2)); ++ assertThrows(IllegalArgumentException.class, () -> NativeASHSymmetricKernels.dot(null,null,null,null,1,3)); ++ assertThrows(IndexOutOfBoundsException.class, () -> NativeASHSymmetricKernels.scoreLut(new byte[8],1,8,7,2,new short[32],new float[2],0)); ++ assertThrows(IndexOutOfBoundsException.class, () -> NativeASHSymmetricKernels.scoreLut(new byte[8],1,8,0,8,new short[31],new float[8],0)); ++ } ++} +-- +2.39.5 + diff --git a/docs/benchmarks/native-ash-projection-symmetric-20261029/inputs.json b/docs/benchmarks/native-ash-projection-symmetric-20261029/inputs.json new file mode 100644 index 000000000..5c5766949 --- /dev/null +++ b/docs/benchmarks/native-ash-projection-symmetric-20261029/inputs.json @@ -0,0 +1,98 @@ +[ + { + "kind": "base", + "path": "/home/ted_willke/datasets-clean/mpnet-1m/wiki_mpnet_embeddings_base_999812.fvecs", + "bytes": 3075421712, + "width": 768, + "records": 999812, + "mtime_ns": 1779289837543238134 + }, + { + "kind": "query", + "path": "/home/ted_willke/datasets-clean/mpnet-1m/wiki_mpnet_embeddings_query_10000.fvecs", + "bytes": 30760000, + "width": 768, + "records": 10000, + "mtime_ns": 1779289837559238135 + }, + { + "kind": "gt", + "path": "/home/ted_willke/datasets-clean/mpnet-1m/wiki_mpnet_embeddings_gt_ip_100.ivecs", + "bytes": 4040000, + "width": 100, + "records": 10000, + "mtime_ns": 1779289837543238134 + }, + { + "kind": "base", + "path": "/home/ted_willke/datasets-clean/cohere-1m/cohere_wiki_en_flat_base_1m_norm.fvecs", + "bytes": 4100000000, + "width": 1024, + "records": 1000000, + "mtime_ns": 1778786535687938561 + }, + { + "kind": "query", + "path": "/home/ted_willke/datasets-clean/cohere-1m/cohere_wiki_en_flat_query_10k_norm.fvecs", + "bytes": 41000000, + "width": 1024, + "records": 10000, + "mtime_ns": 1778786535703938563 + }, + { + "kind": "gt", + "path": "/home/ted_willke/datasets-clean/cohere-1m/cohere_wiki_en_flat_gt_1m_ip_k100.ivecs", + "bytes": 4040000, + "width": 100, + "records": 10000, + "mtime_ns": 1778786533775938406 + }, + { + "kind": "base", + "path": "/home/ted_willke/datasets-clean/ada_002_1M_base_982790.fvecs", + "bytes": 6042192920, + "width": 1536, + "records": 982790, + "mtime_ns": 1775399354461867895 + }, + { + "kind": "query", + "path": "/home/ted_willke/datasets-clean/ada_002_1M_query_10000.fvecs", + "bytes": 61480000, + "width": 1536, + "records": 10000, + "mtime_ns": 1775399354529867849 + }, + { + "kind": "gt", + "path": "/home/ted_willke/datasets-clean/ada_002_1M_gt_ip_100.ivecs", + "bytes": 4040000, + "width": 100, + "records": 10000, + "mtime_ns": 1775399661489659318 + }, + { + "kind": "base", + "path": "/home/ted_willke/datasets-clean/openai-3072-1m/dbpedia_openai_3_large_3072_base_999000.fvecs", + "bytes": 12279708000, + "width": 3072, + "records": 999000, + "mtime_ns": 1779387195627505524 + }, + { + "kind": "query", + "path": "/home/ted_willke/datasets-clean/openai-3072-1m/dbpedia_openai_3_large_3072_query_1000.fvecs", + "bytes": 12292000, + "width": 3072, + "records": 1000, + "mtime_ns": 1779387195663505529 + }, + { + "kind": "gt", + "path": "/home/ted_willke/datasets-clean/openai-3072-1m/dbpedia_openai_3_large_3072_gt_ip_100.ivecs", + "bytes": 404000, + "width": 100, + "records": 1000, + "mtime_ns": 1779387338759522247 + } +] diff --git a/docs/benchmarks/native-ash-projection-symmetric-20261029/manifest.json b/docs/benchmarks/native-ash-projection-symmetric-20261029/manifest.json new file mode 100644 index 000000000..2842aeeed --- /dev/null +++ b/docs/benchmarks/native-ash-projection-symmetric-20261029/manifest.json @@ -0,0 +1,17 @@ +{ + "construction.csv": "fe93d787530fa9f93de378b4c93db6d1de015945a71dfc53c38af44494711899", + "design.md": "541d488fe562454bafce18b00754bfe89e5a4c33e753dd805a16196a0e5f33c7", + "source.json": "18c0f9e4f3481cb26df94fd18b108185191a3792f55529043ba55f797e155dab", + "harness-identity.json": "e496ccfe48826cf451b5c4c0ce3b0386f8d2f0472376b5667d81e482e4d8d643", + "implementation.patch": "44c60767037a8048f78622ecf9f39a64403ceb34286af69af4125e1795d411fe", + "protocol.json": "a298b18e0807ca5d74f1b7040b82bc7fa9370352577aa7f1564744b2eb29d93a", + "inputs.json": "3552c2975db1a165a1af51403c403cc7ed05c7eee253d78741593bd259dfd025", + "projection-diagnostic.json": "bc2fa149e03303023b77ccd9a864ecb572a01861ab6221b684441cf48f4f9339", + "native-preflight.json": "b50fd7165fbcb21f5aeadeea2373b5990440579c868d81bf361bab3ed3d379d6", + "summary.json": "8728e6efb6d9f6efde3500849508fa11fb6591e65ac4a73c90a9afc9569826d4", + "native-isa.txt": "ac8ae32c1b7bc20d8e70308c6a3139d02f8eb8977963363ad742584457f73cc9", + "validation.json": "6d972dcc97402ef0725edaf8a0f3613717dcc2a6b8073d73f29c4277e033d2f1", + "raw-results.json": "636ab361c8896c83e94b833b1613799b632c8a94710a0fceff5df0f93a4643a1", + "queries.csv": "a73788c570896f8a2112523ad8b6ba62a1e0a3d8c8e96d88ed2546dd9fc701e1", + "report.md": "152f0ee714ce842ce0a620ac7c5974b70bbb3f4d96b589a625ff5b4e00a6a487" +} diff --git a/docs/benchmarks/native-ash-projection-symmetric-20261029/native-isa.txt b/docs/benchmarks/native-ash-projection-symmetric-20261029/native-isa.txt new file mode 100644 index 000000000..a9cb835a2 --- /dev/null +++ b/docs/benchmarks/native-ash-projection-symmetric-20261029/native-isa.txt @@ -0,0 +1 @@ +ACTIVE_HIGHWAY_ISA=avx3_spr diff --git a/docs/benchmarks/native-ash-projection-symmetric-20261029/native-preflight.json b/docs/benchmarks/native-ash-projection-symmetric-20261029/native-preflight.json new file mode 100644 index 000000000..91eab52e3 --- /dev/null +++ b/docs/benchmarks/native-ash-projection-symmetric-20261029/native-preflight.json @@ -0,0 +1,26 @@ +[ + { + "log": "preflight-jdk22-2bit.log", + "project_calls": 128, + "symmetric_pair_calls": 243289, + "neighborhood_calls": 1139 + }, + { + "log": "preflight-jdk22-4bit.log", + "project_calls": 128, + "symmetric_pair_calls": 224915, + "neighborhood_calls": 1143 + }, + { + "log": "preflight-jdk25-2bit.log", + "project_calls": 128, + "symmetric_pair_calls": 256359, + "neighborhood_calls": 1130 + }, + { + "log": "preflight-jdk25-4bit.log", + "project_calls": 128, + "symmetric_pair_calls": 237446, + "neighborhood_calls": 1122 + } +] diff --git a/docs/benchmarks/native-ash-projection-symmetric-20261029/projection-diagnostic.json b/docs/benchmarks/native-ash-projection-symmetric-20261029/projection-diagnostic.json new file mode 100644 index 000000000..4aa208f7e --- /dev/null +++ b/docs/benchmarks/native-ash-projection-symmetric-20261029/projection-diagnostic.json @@ -0,0 +1,21 @@ +{ + "scope": "Instrumented class overlay outside repository, accuracy-only BenchYAML on the saved OpenAI3072 graph; alternates native and Panama call order, thread CPU time, both methods consume the same prepared query; all 2000 pairs checked for agreement. Excluded from throughput tables.", + "results": [ + { + "jdk": "22", + "calls": 2000, + "native_cpu_total_ns": 1261210914, + "java_cpu_total_ns": 1264127945, + "native_cpu_median_ns": 629106, + "java_cpu_median_ns": 625767 + }, + { + "jdk": "25", + "calls": 2000, + "native_cpu_total_ns": 1258410413, + "java_cpu_total_ns": 1277275700, + "native_cpu_median_ns": 628222, + "java_cpu_median_ns": 625737 + } + ] +} diff --git a/docs/benchmarks/native-ash-projection-symmetric-20261029/protocol.json b/docs/benchmarks/native-ash-projection-symmetric-20261029/protocol.json new file mode 100644 index 000000000..96abaf170 --- /dev/null +++ b/docs/benchmarks/native-ash-projection-symmetric-20261029/protocol.json @@ -0,0 +1,45 @@ +{ + "entry": "io.github.jbellis.jvector.example.BenchYAML", + "datasets": [ + "MPNet 999812 base / 10000 queries, D768, ASH d384, 2 bits, 101 code bytes", + "OpenAI3072 999000 base / 1000 queries, D3072, ASH d1536, 4 bits, 773 code bytes" + ], + "jdks": [ + "22.0.2+9", + "25.0.2" + ], + "query_threads": [ + 1, + 8 + ], + "construction_physical_cores": 48, + "query_affinity": { + "1": "0", + "8": "0-7" + }, + "construction_affinity": "0-47", + "backend": "native provider in both arms; before uses Panama dense projection/symmetric helpers, after uses Highway for those operations", + "query_scope": "Same saved model and graph before/after; standard three random-query warmup passes, three measured samples over all supplied dataset queries; normal query preparation, graph traversal, fused neighborhood scoring and NVQ reranking included.", + "same_graph_cache_policy": "Existing unchanged saved graph/model caches, linked into isolated case directories. Original cold graph pages are loaded naturally by the standard harness; no manual cache conditioning.", + "construction_scope": "Four fresh after-implementation ASH builds, separate empty model/graph caches, production ITQ training, encoding, deferred parallel writes and NVQ reranking; existing BenchYAML phase timers.", + "total_process_scope": "Fresh build JVM wall time includes startup/loading, training/encoding, graph/write, one standard 1x recall pass, teardown. It is not build-only.", + "graph": { + "C": 1, + "M": 32, + "efConstruction": 100, + "overflow": 1.2, + "hierarchy": true, + "refinement": true, + "pruning": false, + "ash_adaptive_stopping": "existing policy", + "NVQ": "two 8-bit subvectors", + "deferred_writer_batch": 64 + }, + "caveats": [ + "Focused implementation validation, not a repeat of the full four-dataset PQ/ASH comparison.", + "One saved graph per comparison and one fresh construction per case; no graph-build variance estimate.", + "Java still combines symmetric scales/offsets/landmark terms; encoding row projections and standalone asymmetric packed helpers are outside this port.", + "Pre-final implementation runs archived in superseded-development after the range-counter/critical-work review; unchanged completed baseline query runs retained.", + "SVE/SVE2 runtime validation is unavailable." + ] +} diff --git a/docs/benchmarks/native-ash-projection-symmetric-20261029/queries.csv b/docs/benchmarks/native-ash-projection-symmetric-20261029/queries.csv new file mode 100644 index 000000000..0ecf018f9 --- /dev/null +++ b/docs/benchmarks/native-ash-projection-symmetric-20261029/queries.csv @@ -0,0 +1,17 @@ +dataset,jdk,ash_bits,threads,overquery,before_qps,after_qps,speedup,before_cv_pct,after_cv_pct,before_recall,after_recall,recall_delta_percentage_points,before_avg_visited,after_avg_visited +mpnet-1m,22,2,1,1.0,9534.411032461352,11110.99464179856,1.1653572102114633,5.624094003017182,5.908531619200125,0.8194,0.8194,0.0,457.6786,457.6786 +mpnet-1m,22,2,1,2.0,7996.33222133498,8875.235693873283,1.1099133262864322,0.8873558337502068,0.21322087119190308,0.94055,0.94055,0.0,639.0262,639.0262 +mpnet-1m,22,2,8,1.0,73300.48560157196,80378.2764372332,1.0965585804456044,1.1297386488833827,2.5685345814363902,0.8194,0.8194,0.0,457.6786,457.6786 +mpnet-1m,22,2,8,2.0,59440.87356386484,62296.523830157785,1.0480418623596566,2.42568979517639,8.754005476832623,0.94055,0.94055,0.0,639.0262,639.0262 +mpnet-1m,25,2,1,1.0,10441.19054756948,12117.702889285703,1.1605671627270977,0.5044069152625217,1.543338944623599,0.82017,0.82017,0.0,455.8008,455.8008 +mpnet-1m,25,2,1,2.0,8293.758257315945,9286.185025696477,1.1196594761494416,0.90459407773869,0.25028073268698053,0.93971,0.93971,0.0,636.613,636.613 +mpnet-1m,25,2,8,1.0,76547.2022487084,83549.5892088437,1.091478026033975,2.203891530228568,4.296693036410662,0.82017,0.82017,0.0,455.8008,455.8008 +mpnet-1m,25,2,8,2.0,61781.41999991404,69337.75348751461,1.1223075398333524,2.4886931258567033,0.909554792882135,0.93971,0.93971,0.0,636.613,636.613 +openai-3072-1m,22,4,1,1.0,1024.6329990152383,1026.4376092178372,1.0017612259260957,4.046430003138828,5.087713390174111,0.7903,0.7903,0.0,512.685,512.685 +openai-3072-1m,22,4,1,2.0,1030.0348655106716,1046.101981421671,1.015598613647931,1.1342650715278502,0.9862944129088163,0.8834,0.8834,0.0,735.241,735.241 +openai-3072-1m,22,4,8,1.0,7741.458630781505,8258.710248857857,1.0668157827543845,4.468815896859849,3.4045657578531534,0.7903,0.7903,0.0,512.685,512.685 +openai-3072-1m,22,4,8,2.0,7240.167989195661,7926.960479696699,1.0948586402312657,0.569470363321681,2.347372560147485,0.8834,0.8834,0.0,735.241,735.241 +openai-3072-1m,25,4,1,1.0,1062.2402591845162,1011.9147946874974,0.9526232751377229,5.091229038043828,7.701434971355209,0.7965,0.7965,0.0,510.697,510.697 +openai-3072-1m,25,4,1,2.0,1040.2391399169903,1054.0483629342123,1.0132750465612397,1.136075931031491,0.89958827737423,0.8875,0.8875,0.0,730.922,730.922 +openai-3072-1m,25,4,8,1.0,7843.681782186251,8559.176543886948,1.091219249017171,3.1821800596846606,1.8174480489639642,0.7965,0.7965,0.0,510.697,510.697 +openai-3072-1m,25,4,8,2.0,7463.83544647903,7983.474801021447,1.0696209553745655,0.5468623032724961,1.5482454681357327,0.8875,0.8875,0.0,730.922,730.922 diff --git a/docs/benchmarks/native-ash-projection-symmetric-20261029/raw-results.json b/docs/benchmarks/native-ash-projection-symmetric-20261029/raw-results.json new file mode 100644 index 000000000..03ed53088 --- /dev/null +++ b/docs/benchmarks/native-ash-projection-symmetric-20261029/raw-results.json @@ -0,0 +1,1234 @@ +[ + { + "label": "jdk22-mpnet-1m-2bit-ash-before-t1-before-t1", + "jdk": "22", + "version": "before", + "threads": 1, + "build": false, + "elapsed_process_seconds": 584.878967021592, + "rows": [ + { + "schema_version": "1", + "run_id": "jdk22-mpnet-1m-2bit-ash-before-t1-before-t1-20261029-060100Z", + "run_uuid": "a4f98879-0701-429c-a6f8-5aef4727c8cc", + "system_id": "1d71c58acd06", + "dataset_name": "mpnet-1m", + "M": "32", + "efConstruction": "100", + "neighborOverflow": "1.2", + "addHierarchy": "true", + "refineFinalGraph": "true", + "feature_set": "[NVQ_VECTORS, FUSED_ASH]", + "build_compressor": "\"AsymmetricHashing[origDim=768, encodedBits=808, bodyBits=768, quantizedDim=384, bitsPerDimension=2, optimizer=ITQ, landmarks=1]\"", + "search_compressor": "None", + "usePruning": "false", + "topK": "10", + "overquery": "1.0", + "rerankK": "10", + "search.throughput.avg_qps": "9534.411032461352", + "search.throughput.stddev_qps": "536.2242390996676", + "search.throughput.cv_pct": "5.624094003017182", + "search.count.avg_visited": "457.6786", + "search.accuracy.recall_at_10": "0.8194", + "construction.index_build_time_s": "", + "construction.index_quant_time_s.ASH.compute_time_s": "", + "construction.index_quant_time_s.ASH.encoding_time_s": "", + "construction.index_quant_time_s.NVQ.compute_time_s": "" + }, + { + "schema_version": "1", + "run_id": "jdk22-mpnet-1m-2bit-ash-before-t1-before-t1-20261029-060100Z", + "run_uuid": "a4f98879-0701-429c-a6f8-5aef4727c8cc", + "system_id": "1d71c58acd06", + "dataset_name": "mpnet-1m", + "M": "32", + "efConstruction": "100", + "neighborOverflow": "1.2", + "addHierarchy": "true", + "refineFinalGraph": "true", + "feature_set": "[NVQ_VECTORS, FUSED_ASH]", + "build_compressor": "\"AsymmetricHashing[origDim=768, encodedBits=808, bodyBits=768, quantizedDim=384, bitsPerDimension=2, optimizer=ITQ, landmarks=1]\"", + "search_compressor": "None", + "usePruning": "false", + "topK": "10", + "overquery": "2.0", + "rerankK": "20", + "search.throughput.avg_qps": "7996.33222133498", + "search.throughput.stddev_qps": "70.95592045206345", + "search.throughput.cv_pct": "0.8873558337502068", + "search.count.avg_visited": "639.0262", + "search.accuracy.recall_at_10": "0.94055", + "construction.index_build_time_s": "", + "construction.index_quant_time_s.ASH.compute_time_s": "", + "construction.index_quant_time_s.ASH.encoding_time_s": "", + "construction.index_quant_time_s.NVQ.compute_time_s": "" + } + ], + "log": "/home/ted_willke/benchmark-runs/ash-native-projection-symmetric-20261029/logs/jdk22-mpnet-1m-2bit-ash-before-t1-before-t1.log", + "finished": "2026-10-29T06:10:43.872168+00:00" + }, + { + "label": "jdk22-mpnet-1m-2bit-ash-before-t8-before-t8", + "jdk": "22", + "version": "before", + "threads": 8, + "build": false, + "elapsed_process_seconds": 19.901069899089634, + "rows": [ + { + "schema_version": "1", + "run_id": "jdk22-mpnet-1m-2bit-ash-before-t8-before-t8-20261029-061201Z", + "run_uuid": "5a10d29d-249d-493a-a0f9-fe4c6ce3a6a3", + "system_id": "1d71c58acd06", + "dataset_name": "mpnet-1m", + "M": "32", + "efConstruction": "100", + "neighborOverflow": "1.2", + "addHierarchy": "true", + "refineFinalGraph": "true", + "feature_set": "[NVQ_VECTORS, FUSED_ASH]", + "build_compressor": "\"AsymmetricHashing[origDim=768, encodedBits=808, bodyBits=768, quantizedDim=384, bitsPerDimension=2, optimizer=ITQ, landmarks=1]\"", + "search_compressor": "None", + "usePruning": "false", + "topK": "10", + "overquery": "1.0", + "rerankK": "10", + "search.throughput.avg_qps": "73300.48560157196", + "search.throughput.stddev_qps": "828.1039156601576", + "search.throughput.cv_pct": "1.1297386488833827", + "search.count.avg_visited": "457.6786", + "search.accuracy.recall_at_10": "0.8194", + "construction.index_build_time_s": "", + "construction.index_quant_time_s.ASH.compute_time_s": "", + "construction.index_quant_time_s.ASH.encoding_time_s": "", + "construction.index_quant_time_s.NVQ.compute_time_s": "" + }, + { + "schema_version": "1", + "run_id": "jdk22-mpnet-1m-2bit-ash-before-t8-before-t8-20261029-061201Z", + "run_uuid": "5a10d29d-249d-493a-a0f9-fe4c6ce3a6a3", + "system_id": "1d71c58acd06", + "dataset_name": "mpnet-1m", + "M": "32", + "efConstruction": "100", + "neighborOverflow": "1.2", + "addHierarchy": "true", + "refineFinalGraph": "true", + "feature_set": "[NVQ_VECTORS, FUSED_ASH]", + "build_compressor": "\"AsymmetricHashing[origDim=768, encodedBits=808, bodyBits=768, quantizedDim=384, bitsPerDimension=2, optimizer=ITQ, landmarks=1]\"", + "search_compressor": "None", + "usePruning": "false", + "topK": "10", + "overquery": "2.0", + "rerankK": "20", + "search.throughput.avg_qps": "59440.87356386484", + "search.throughput.stddev_qps": "1441.8512042023701", + "search.throughput.cv_pct": "2.42568979517639", + "search.count.avg_visited": "639.0262", + "search.accuracy.recall_at_10": "0.94055", + "construction.index_build_time_s": "", + "construction.index_quant_time_s.ASH.compute_time_s": "", + "construction.index_quant_time_s.ASH.encoding_time_s": "", + "construction.index_quant_time_s.NVQ.compute_time_s": "" + } + ], + "log": "/home/ted_willke/benchmark-runs/ash-native-projection-symmetric-20261029/logs/jdk22-mpnet-1m-2bit-ash-before-t8-before-t8.log", + "finished": "2026-10-29T06:12:21.240616+00:00" + }, + { + "label": "jdk22-mpnet-1m-2bit-ash-after-t1-after-t1", + "jdk": "22", + "version": "after", + "threads": 1, + "build": false, + "elapsed_process_seconds": 48.541612004861236, + "rows": [ + { + "schema_version": "1", + "run_id": "jdk22-mpnet-1m-2bit-ash-after-t1-after-t1-20261029-061541Z", + "run_uuid": "b0323815-8337-4caf-922e-53725467ff8e", + "system_id": "1d71c58acd06", + "dataset_name": "mpnet-1m", + "M": "32", + "efConstruction": "100", + "neighborOverflow": "1.2", + "addHierarchy": "true", + "refineFinalGraph": "true", + "feature_set": "[NVQ_VECTORS, FUSED_ASH]", + "build_compressor": "\"AsymmetricHashing[origDim=768, encodedBits=808, bodyBits=768, quantizedDim=384, bitsPerDimension=2, optimizer=ITQ, landmarks=1]\"", + "search_compressor": "None", + "usePruning": "false", + "topK": "10", + "overquery": "1.0", + "rerankK": "10", + "search.throughput.avg_qps": "11110.99464179856", + "search.throughput.stddev_qps": "656.4966316182995", + "search.throughput.cv_pct": "5.908531619200125", + "search.count.avg_visited": "457.6786", + "search.accuracy.recall_at_10": "0.8194", + "construction.index_build_time_s": "", + "construction.index_quant_time_s.ASH.compute_time_s": "", + "construction.index_quant_time_s.ASH.encoding_time_s": "", + "construction.index_quant_time_s.NVQ.compute_time_s": "" + }, + { + "schema_version": "1", + "run_id": "jdk22-mpnet-1m-2bit-ash-after-t1-after-t1-20261029-061541Z", + "run_uuid": "b0323815-8337-4caf-922e-53725467ff8e", + "system_id": "1d71c58acd06", + "dataset_name": "mpnet-1m", + "M": "32", + "efConstruction": "100", + "neighborOverflow": "1.2", + "addHierarchy": "true", + "refineFinalGraph": "true", + "feature_set": "[NVQ_VECTORS, FUSED_ASH]", + "build_compressor": "\"AsymmetricHashing[origDim=768, encodedBits=808, bodyBits=768, quantizedDim=384, bitsPerDimension=2, optimizer=ITQ, landmarks=1]\"", + "search_compressor": "None", + "usePruning": "false", + "topK": "10", + "overquery": "2.0", + "rerankK": "20", + "search.throughput.avg_qps": "8875.235693873283", + "search.throughput.stddev_qps": "18.923854866811357", + "search.throughput.cv_pct": "0.21322087119190308", + "search.count.avg_visited": "639.0262", + "search.accuracy.recall_at_10": "0.94055", + "construction.index_build_time_s": "", + "construction.index_quant_time_s.ASH.compute_time_s": "", + "construction.index_quant_time_s.ASH.encoding_time_s": "", + "construction.index_quant_time_s.NVQ.compute_time_s": "" + } + ], + "log": "/home/ted_willke/benchmark-runs/ash-native-projection-symmetric-20261029/logs/jdk22-mpnet-1m-2bit-ash-after-t1-after-t1.log", + "finished": "2026-10-29T06:16:29.049963+00:00" + }, + { + "label": "jdk22-mpnet-1m-2bit-ash-after-t8-after-t8", + "jdk": "22", + "version": "after", + "threads": 8, + "build": false, + "elapsed_process_seconds": 19.538603738881648, + "rows": [ + { + "schema_version": "1", + "run_id": "jdk22-mpnet-1m-2bit-ash-after-t8-after-t8-20261029-061629Z", + "run_uuid": "86977aa8-2dc3-4f4a-bfec-0aa6034ef473", + "system_id": "1d71c58acd06", + "dataset_name": "mpnet-1m", + "M": "32", + "efConstruction": "100", + "neighborOverflow": "1.2", + "addHierarchy": "true", + "refineFinalGraph": "true", + "feature_set": "[NVQ_VECTORS, FUSED_ASH]", + "build_compressor": "\"AsymmetricHashing[origDim=768, encodedBits=808, bodyBits=768, quantizedDim=384, bitsPerDimension=2, optimizer=ITQ, landmarks=1]\"", + "search_compressor": "None", + "usePruning": "false", + "topK": "10", + "overquery": "1.0", + "rerankK": "10", + "search.throughput.avg_qps": "80378.2764372332", + "search.throughput.stddev_qps": "2064.5438262528723", + "search.throughput.cv_pct": "2.5685345814363902", + "search.count.avg_visited": "457.6786", + "search.accuracy.recall_at_10": "0.8194", + "construction.index_build_time_s": "", + "construction.index_quant_time_s.ASH.compute_time_s": "", + "construction.index_quant_time_s.ASH.encoding_time_s": "", + "construction.index_quant_time_s.NVQ.compute_time_s": "" + }, + { + "schema_version": "1", + "run_id": "jdk22-mpnet-1m-2bit-ash-after-t8-after-t8-20261029-061629Z", + "run_uuid": "86977aa8-2dc3-4f4a-bfec-0aa6034ef473", + "system_id": "1d71c58acd06", + "dataset_name": "mpnet-1m", + "M": "32", + "efConstruction": "100", + "neighborOverflow": "1.2", + "addHierarchy": "true", + "refineFinalGraph": "true", + "feature_set": "[NVQ_VECTORS, FUSED_ASH]", + "build_compressor": "\"AsymmetricHashing[origDim=768, encodedBits=808, bodyBits=768, quantizedDim=384, bitsPerDimension=2, optimizer=ITQ, landmarks=1]\"", + "search_compressor": "None", + "usePruning": "false", + "topK": "10", + "overquery": "2.0", + "rerankK": "20", + "search.throughput.avg_qps": "62296.523830157785", + "search.throughput.stddev_qps": "5453.441107968352", + "search.throughput.cv_pct": "8.754005476832623", + "search.count.avg_visited": "639.0262", + "search.accuracy.recall_at_10": "0.94055", + "construction.index_build_time_s": "", + "construction.index_quant_time_s.ASH.compute_time_s": "", + "construction.index_quant_time_s.ASH.encoding_time_s": "", + "construction.index_quant_time_s.NVQ.compute_time_s": "" + } + ], + "log": "/home/ted_willke/benchmark-runs/ash-native-projection-symmetric-20261029/logs/jdk22-mpnet-1m-2bit-ash-after-t8-after-t8.log", + "finished": "2026-10-29T06:16:48.590488+00:00" + }, + { + "label": "jdk22-mpnet-1m-2bit-ash-after-build", + "jdk": "22", + "version": "after", + "threads": 1, + "build": true, + "elapsed_process_seconds": 67.83305824548006, + "rows": [ + { + "schema_version": "1", + "run_id": "jdk22-mpnet-1m-2bit-ash-after-build-20261029-061649Z", + "run_uuid": "474bf83b-a177-4ec6-a8d2-7cb5e1e3c329", + "system_id": "1d71c58acd06", + "dataset_name": "mpnet-1m", + "M": "32", + "efConstruction": "100", + "neighborOverflow": "1.2", + "addHierarchy": "true", + "refineFinalGraph": "true", + "feature_set": "[NVQ_VECTORS, FUSED_ASH]", + "build_compressor": "\"AsymmetricHashing[origDim=768, encodedBits=808, bodyBits=768, quantizedDim=384, bitsPerDimension=2, optimizer=ITQ, landmarks=1]\"", + "search_compressor": "None", + "usePruning": "false", + "topK": "10", + "overquery": "1.0", + "rerankK": "10", + "search.accuracy.recall_at_10": "0.81888", + "construction.index_build_time_s": "54.164598291", + "construction.index_quant_time_s.ASH.compute_time_s": "5.292379244", + "construction.index_quant_time_s.ASH.encoding_time_s": "1.047888893", + "construction.index_quant_time_s.NVQ.compute_time_s": "0.339563223" + } + ], + "log": "/home/ted_willke/benchmark-runs/ash-native-projection-symmetric-20261029/logs/jdk22-mpnet-1m-2bit-ash-after-build.log", + "finished": "2026-10-29T06:17:56.427633+00:00" + }, + { + "label": "jdk22-openai-3072-1m-4bit-ash-after-t1-after-t1", + "jdk": "22", + "version": "after", + "threads": 1, + "build": false, + "elapsed_process_seconds": 195.20415874477476, + "rows": [ + { + "schema_version": "1", + "run_id": "jdk22-openai-3072-1m-4bit-ash-after-t1-after-t1-20261029-061757Z", + "run_uuid": "ef1184fc-e6ae-44f8-a775-95d42aeb1989", + "system_id": "1d71c58acd06", + "dataset_name": "openai-3072-1m", + "M": "32", + "efConstruction": "100", + "neighborOverflow": "1.2", + "addHierarchy": "true", + "refineFinalGraph": "true", + "feature_set": "[NVQ_VECTORS, FUSED_ASH]", + "build_compressor": "\"AsymmetricHashing[origDim=3072, encodedBits=6184, bodyBits=6144, quantizedDim=1536, bitsPerDimension=4, optimizer=ITQ, landmarks=1]\"", + "search_compressor": "None", + "usePruning": "false", + "topK": "10", + "overquery": "1.0", + "rerankK": "10", + "search.throughput.avg_qps": "1026.4376092178372", + "search.throughput.stddev_qps": "52.22220368595892", + "search.throughput.cv_pct": "5.087713390174111", + "search.count.avg_visited": "512.685", + "search.accuracy.recall_at_10": "0.7903", + "construction.index_build_time_s": "", + "construction.index_quant_time_s.ASH.compute_time_s": "", + "construction.index_quant_time_s.ASH.encoding_time_s": "", + "construction.index_quant_time_s.NVQ.compute_time_s": "" + }, + { + "schema_version": "1", + "run_id": "jdk22-openai-3072-1m-4bit-ash-after-t1-after-t1-20261029-061757Z", + "run_uuid": "ef1184fc-e6ae-44f8-a775-95d42aeb1989", + "system_id": "1d71c58acd06", + "dataset_name": "openai-3072-1m", + "M": "32", + "efConstruction": "100", + "neighborOverflow": "1.2", + "addHierarchy": "true", + "refineFinalGraph": "true", + "feature_set": "[NVQ_VECTORS, FUSED_ASH]", + "build_compressor": "\"AsymmetricHashing[origDim=3072, encodedBits=6184, bodyBits=6144, quantizedDim=1536, bitsPerDimension=4, optimizer=ITQ, landmarks=1]\"", + "search_compressor": "None", + "usePruning": "false", + "topK": "10", + "overquery": "2.0", + "rerankK": "20", + "search.throughput.avg_qps": "1046.101981421671", + "search.throughput.stddev_qps": "10.317645396090365", + "search.throughput.cv_pct": "0.9862944129088163", + "search.count.avg_visited": "735.241", + "search.accuracy.recall_at_10": "0.8834", + "construction.index_build_time_s": "", + "construction.index_quant_time_s.ASH.compute_time_s": "", + "construction.index_quant_time_s.ASH.encoding_time_s": "", + "construction.index_quant_time_s.NVQ.compute_time_s": "" + } + ], + "log": "/home/ted_willke/benchmark-runs/ash-native-projection-symmetric-20261029/logs/jdk22-openai-3072-1m-4bit-ash-after-t1-after-t1.log", + "finished": "2026-10-29T06:21:11.642375+00:00" + }, + { + "label": "jdk22-openai-3072-1m-4bit-ash-before-t1-before-t1", + "jdk": "22", + "version": "before", + "threads": 1, + "build": false, + "elapsed_process_seconds": 78.98988528177142, + "rows": [ + { + "schema_version": "1", + "run_id": "jdk22-openai-3072-1m-4bit-ash-before-t1-before-t1-20261029-062112Z", + "run_uuid": "7dc810d9-479b-4df9-8be5-08b5f98553a9", + "system_id": "1d71c58acd06", + "dataset_name": "openai-3072-1m", + "M": "32", + "efConstruction": "100", + "neighborOverflow": "1.2", + "addHierarchy": "true", + "refineFinalGraph": "true", + "feature_set": "[NVQ_VECTORS, FUSED_ASH]", + "build_compressor": "\"AsymmetricHashing[origDim=3072, encodedBits=6184, bodyBits=6144, quantizedDim=1536, bitsPerDimension=4, optimizer=ITQ, landmarks=1]\"", + "search_compressor": "None", + "usePruning": "false", + "topK": "10", + "overquery": "1.0", + "rerankK": "10", + "search.throughput.avg_qps": "1024.6329990152383", + "search.throughput.stddev_qps": "41.46105709421377", + "search.throughput.cv_pct": "4.046430003138828", + "search.count.avg_visited": "512.685", + "search.accuracy.recall_at_10": "0.7903", + "construction.index_build_time_s": "", + "construction.index_quant_time_s.ASH.compute_time_s": "", + "construction.index_quant_time_s.ASH.encoding_time_s": "", + "construction.index_quant_time_s.NVQ.compute_time_s": "" + }, + { + "schema_version": "1", + "run_id": "jdk22-openai-3072-1m-4bit-ash-before-t1-before-t1-20261029-062112Z", + "run_uuid": "7dc810d9-479b-4df9-8be5-08b5f98553a9", + "system_id": "1d71c58acd06", + "dataset_name": "openai-3072-1m", + "M": "32", + "efConstruction": "100", + "neighborOverflow": "1.2", + "addHierarchy": "true", + "refineFinalGraph": "true", + "feature_set": "[NVQ_VECTORS, FUSED_ASH]", + "build_compressor": "\"AsymmetricHashing[origDim=3072, encodedBits=6184, bodyBits=6144, quantizedDim=1536, bitsPerDimension=4, optimizer=ITQ, landmarks=1]\"", + "search_compressor": "None", + "usePruning": "false", + "topK": "10", + "overquery": "2.0", + "rerankK": "20", + "search.throughput.avg_qps": "1030.0348655106716", + "search.throughput.stddev_qps": "11.683325704046414", + "search.throughput.cv_pct": "1.1342650715278502", + "search.count.avg_visited": "735.241", + "search.accuracy.recall_at_10": "0.8834", + "construction.index_build_time_s": "", + "construction.index_quant_time_s.ASH.compute_time_s": "", + "construction.index_quant_time_s.ASH.encoding_time_s": "", + "construction.index_quant_time_s.NVQ.compute_time_s": "" + } + ], + "log": "/home/ted_willke/benchmark-runs/ash-native-projection-symmetric-20261029/logs/jdk22-openai-3072-1m-4bit-ash-before-t1-before-t1.log", + "finished": "2026-10-29T06:22:30.634271+00:00" + }, + { + "label": "jdk22-openai-3072-1m-4bit-ash-before-t8-before-t8", + "jdk": "22", + "version": "before", + "threads": 8, + "build": false, + "elapsed_process_seconds": 26.530268570408225, + "rows": [ + { + "schema_version": "1", + "run_id": "jdk22-openai-3072-1m-4bit-ash-before-t8-before-t8-20261029-062231Z", + "run_uuid": "95fbca89-054e-492f-9033-345d64fad487", + "system_id": "1d71c58acd06", + "dataset_name": "openai-3072-1m", + "M": "32", + "efConstruction": "100", + "neighborOverflow": "1.2", + "addHierarchy": "true", + "refineFinalGraph": "true", + "feature_set": "[NVQ_VECTORS, FUSED_ASH]", + "build_compressor": "\"AsymmetricHashing[origDim=3072, encodedBits=6184, bodyBits=6144, quantizedDim=1536, bitsPerDimension=4, optimizer=ITQ, landmarks=1]\"", + "search_compressor": "None", + "usePruning": "false", + "topK": "10", + "overquery": "1.0", + "rerankK": "10", + "search.throughput.avg_qps": "7741.458630781505", + "search.throughput.stddev_qps": "345.95153394119274", + "search.throughput.cv_pct": "4.468815896859849", + "search.count.avg_visited": "512.685", + "search.accuracy.recall_at_10": "0.7903", + "construction.index_build_time_s": "", + "construction.index_quant_time_s.ASH.compute_time_s": "", + "construction.index_quant_time_s.ASH.encoding_time_s": "", + "construction.index_quant_time_s.NVQ.compute_time_s": "" + }, + { + "schema_version": "1", + "run_id": "jdk22-openai-3072-1m-4bit-ash-before-t8-before-t8-20261029-062231Z", + "run_uuid": "95fbca89-054e-492f-9033-345d64fad487", + "system_id": "1d71c58acd06", + "dataset_name": "openai-3072-1m", + "M": "32", + "efConstruction": "100", + "neighborOverflow": "1.2", + "addHierarchy": "true", + "refineFinalGraph": "true", + "feature_set": "[NVQ_VECTORS, FUSED_ASH]", + "build_compressor": "\"AsymmetricHashing[origDim=3072, encodedBits=6184, bodyBits=6144, quantizedDim=1536, bitsPerDimension=4, optimizer=ITQ, landmarks=1]\"", + "search_compressor": "None", + "usePruning": "false", + "topK": "10", + "overquery": "2.0", + "rerankK": "20", + "search.throughput.avg_qps": "7240.167989195661", + "search.throughput.stddev_qps": "41.23061095317257", + "search.throughput.cv_pct": "0.569470363321681", + "search.count.avg_visited": "735.241", + "search.accuracy.recall_at_10": "0.8834", + "construction.index_build_time_s": "", + "construction.index_quant_time_s.ASH.compute_time_s": "", + "construction.index_quant_time_s.ASH.encoding_time_s": "", + "construction.index_quant_time_s.NVQ.compute_time_s": "" + } + ], + "log": "/home/ted_willke/benchmark-runs/ash-native-projection-symmetric-20261029/logs/jdk22-openai-3072-1m-4bit-ash-before-t8-before-t8.log", + "finished": "2026-10-29T06:22:57.166623+00:00" + }, + { + "label": "jdk22-openai-3072-1m-4bit-ash-after-t8-after-t8", + "jdk": "22", + "version": "after", + "threads": 8, + "build": false, + "elapsed_process_seconds": 25.67743838299066, + "rows": [ + { + "schema_version": "1", + "run_id": "jdk22-openai-3072-1m-4bit-ash-after-t8-after-t8-20261029-062257Z", + "run_uuid": "fb46aace-439c-4218-a2f5-433394429de4", + "system_id": "1d71c58acd06", + "dataset_name": "openai-3072-1m", + "M": "32", + "efConstruction": "100", + "neighborOverflow": "1.2", + "addHierarchy": "true", + "refineFinalGraph": "true", + "feature_set": "[NVQ_VECTORS, FUSED_ASH]", + "build_compressor": "\"AsymmetricHashing[origDim=3072, encodedBits=6184, bodyBits=6144, quantizedDim=1536, bitsPerDimension=4, optimizer=ITQ, landmarks=1]\"", + "search_compressor": "None", + "usePruning": "false", + "topK": "10", + "overquery": "1.0", + "rerankK": "10", + "search.throughput.avg_qps": "8258.710248857857", + "search.throughput.stddev_qps": "281.17322117292355", + "search.throughput.cv_pct": "3.4045657578531534", + "search.count.avg_visited": "512.685", + "search.accuracy.recall_at_10": "0.7903", + "construction.index_build_time_s": "", + "construction.index_quant_time_s.ASH.compute_time_s": "", + "construction.index_quant_time_s.ASH.encoding_time_s": "", + "construction.index_quant_time_s.NVQ.compute_time_s": "" + }, + { + "schema_version": "1", + "run_id": "jdk22-openai-3072-1m-4bit-ash-after-t8-after-t8-20261029-062257Z", + "run_uuid": "fb46aace-439c-4218-a2f5-433394429de4", + "system_id": "1d71c58acd06", + "dataset_name": "openai-3072-1m", + "M": "32", + "efConstruction": "100", + "neighborOverflow": "1.2", + "addHierarchy": "true", + "refineFinalGraph": "true", + "feature_set": "[NVQ_VECTORS, FUSED_ASH]", + "build_compressor": "\"AsymmetricHashing[origDim=3072, encodedBits=6184, bodyBits=6144, quantizedDim=1536, bitsPerDimension=4, optimizer=ITQ, landmarks=1]\"", + "search_compressor": "None", + "usePruning": "false", + "topK": "10", + "overquery": "2.0", + "rerankK": "20", + "search.throughput.avg_qps": "7926.960479696699", + "search.throughput.stddev_qps": "186.07529515413572", + "search.throughput.cv_pct": "2.347372560147485", + "search.count.avg_visited": "735.241", + "search.accuracy.recall_at_10": "0.8834", + "construction.index_build_time_s": "", + "construction.index_quant_time_s.ASH.compute_time_s": "", + "construction.index_quant_time_s.ASH.encoding_time_s": "", + "construction.index_quant_time_s.NVQ.compute_time_s": "" + } + ], + "log": "/home/ted_willke/benchmark-runs/ash-native-projection-symmetric-20261029/logs/jdk22-openai-3072-1m-4bit-ash-after-t8-after-t8.log", + "finished": "2026-10-29T06:23:22.846942+00:00" + }, + { + "label": "jdk22-openai-3072-1m-4bit-ash-after-build", + "jdk": "22", + "version": "after", + "threads": 1, + "build": true, + "elapsed_process_seconds": 185.2930998262018, + "rows": [ + { + "schema_version": "1", + "run_id": "jdk22-openai-3072-1m-4bit-ash-after-build-20261029-062323Z", + "run_uuid": "8be0513a-d30c-4b12-a36f-23c179448814", + "system_id": "1d71c58acd06", + "dataset_name": "openai-3072-1m", + "M": "32", + "efConstruction": "100", + "neighborOverflow": "1.2", + "addHierarchy": "true", + "refineFinalGraph": "true", + "feature_set": "[NVQ_VECTORS, FUSED_ASH]", + "build_compressor": "\"AsymmetricHashing[origDim=3072, encodedBits=6184, bodyBits=6144, quantizedDim=1536, bitsPerDimension=4, optimizer=ITQ, landmarks=1]\"", + "search_compressor": "None", + "usePruning": "false", + "topK": "10", + "overquery": "1.0", + "rerankK": "10", + "search.accuracy.recall_at_10": "0.7896", + "construction.index_build_time_s": "101.919147369", + "construction.index_quant_time_s.ASH.compute_time_s": "38.086685724", + "construction.index_quant_time_s.ASH.encoding_time_s": "25.959572952", + "construction.index_quant_time_s.NVQ.compute_time_s": "1.164680616" + } + ], + "log": "/home/ted_willke/benchmark-runs/ash-native-projection-symmetric-20261029/logs/jdk22-openai-3072-1m-4bit-ash-after-build.log", + "finished": "2026-10-29T06:26:28.142317+00:00" + }, + { + "label": "jdk25-mpnet-1m-2bit-ash-before-t1-before-t1", + "jdk": "25", + "version": "before", + "threads": 1, + "build": false, + "elapsed_process_seconds": 298.95462897233665, + "rows": [ + { + "schema_version": "1", + "run_id": "jdk25-mpnet-1m-2bit-ash-before-t1-before-t1-20261029-062629Z", + "run_uuid": "b65573b2-735c-4885-b2a2-ced0a607f8a1", + "system_id": "99c688318adc", + "dataset_name": "mpnet-1m", + "M": "32", + "efConstruction": "100", + "neighborOverflow": "1.2", + "addHierarchy": "true", + "refineFinalGraph": "true", + "feature_set": "[NVQ_VECTORS, FUSED_ASH]", + "build_compressor": "\"AsymmetricHashing[origDim=768, encodedBits=808, bodyBits=768, quantizedDim=384, bitsPerDimension=2, optimizer=ITQ, landmarks=1]\"", + "search_compressor": "None", + "usePruning": "false", + "topK": "10", + "overquery": "1.0", + "rerankK": "10", + "search.throughput.avg_qps": "10441.19054756948", + "search.throughput.stddev_qps": "52.66608715767722", + "search.throughput.cv_pct": "0.5044069152625217", + "search.count.avg_visited": "455.8008", + "search.accuracy.recall_at_10": "0.82017", + "construction.index_build_time_s": "", + "construction.index_quant_time_s.ASH.compute_time_s": "", + "construction.index_quant_time_s.ASH.encoding_time_s": "", + "construction.index_quant_time_s.NVQ.compute_time_s": "" + }, + { + "schema_version": "1", + "run_id": "jdk25-mpnet-1m-2bit-ash-before-t1-before-t1-20261029-062629Z", + "run_uuid": "b65573b2-735c-4885-b2a2-ced0a607f8a1", + "system_id": "99c688318adc", + "dataset_name": "mpnet-1m", + "M": "32", + "efConstruction": "100", + "neighborOverflow": "1.2", + "addHierarchy": "true", + "refineFinalGraph": "true", + "feature_set": "[NVQ_VECTORS, FUSED_ASH]", + "build_compressor": "\"AsymmetricHashing[origDim=768, encodedBits=808, bodyBits=768, quantizedDim=384, bitsPerDimension=2, optimizer=ITQ, landmarks=1]\"", + "search_compressor": "None", + "usePruning": "false", + "topK": "10", + "overquery": "2.0", + "rerankK": "20", + "search.throughput.avg_qps": "8293.758257315945", + "search.throughput.stddev_qps": "75.02484601764363", + "search.throughput.cv_pct": "0.90459407773869", + "search.count.avg_visited": "636.613", + "search.accuracy.recall_at_10": "0.93971", + "construction.index_build_time_s": "", + "construction.index_quant_time_s.ASH.compute_time_s": "", + "construction.index_quant_time_s.ASH.encoding_time_s": "", + "construction.index_quant_time_s.NVQ.compute_time_s": "" + } + ], + "log": "/home/ted_willke/benchmark-runs/ash-native-projection-symmetric-20261029/logs/jdk25-mpnet-1m-2bit-ash-before-t1-before-t1.log", + "finished": "2026-10-29T06:31:27.100510+00:00" + }, + { + "label": "jdk25-mpnet-1m-2bit-ash-after-t1-after-t1", + "jdk": "25", + "version": "after", + "threads": 1, + "build": false, + "elapsed_process_seconds": 50.58799839951098, + "rows": [ + { + "schema_version": "1", + "run_id": "jdk25-mpnet-1m-2bit-ash-after-t1-after-t1-20261029-063128Z", + "run_uuid": "1c82d155-9879-437c-9003-c9c4199fcf2e", + "system_id": "99c688318adc", + "dataset_name": "mpnet-1m", + "M": "32", + "efConstruction": "100", + "neighborOverflow": "1.2", + "addHierarchy": "true", + "refineFinalGraph": "true", + "feature_set": "[NVQ_VECTORS, FUSED_ASH]", + "build_compressor": "\"AsymmetricHashing[origDim=768, encodedBits=808, bodyBits=768, quantizedDim=384, bitsPerDimension=2, optimizer=ITQ, landmarks=1]\"", + "search_compressor": "None", + "usePruning": "false", + "topK": "10", + "overquery": "1.0", + "rerankK": "10", + "search.throughput.avg_qps": "12117.702889285703", + "search.throughput.stddev_qps": "187.01722788412533", + "search.throughput.cv_pct": "1.543338944623599", + "search.count.avg_visited": "455.8008", + "search.accuracy.recall_at_10": "0.82017", + "construction.index_build_time_s": "", + "construction.index_quant_time_s.ASH.compute_time_s": "", + "construction.index_quant_time_s.ASH.encoding_time_s": "", + "construction.index_quant_time_s.NVQ.compute_time_s": "" + }, + { + "schema_version": "1", + "run_id": "jdk25-mpnet-1m-2bit-ash-after-t1-after-t1-20261029-063128Z", + "run_uuid": "1c82d155-9879-437c-9003-c9c4199fcf2e", + "system_id": "99c688318adc", + "dataset_name": "mpnet-1m", + "M": "32", + "efConstruction": "100", + "neighborOverflow": "1.2", + "addHierarchy": "true", + "refineFinalGraph": "true", + "feature_set": "[NVQ_VECTORS, FUSED_ASH]", + "build_compressor": "\"AsymmetricHashing[origDim=768, encodedBits=808, bodyBits=768, quantizedDim=384, bitsPerDimension=2, optimizer=ITQ, landmarks=1]\"", + "search_compressor": "None", + "usePruning": "false", + "topK": "10", + "overquery": "2.0", + "rerankK": "20", + "search.throughput.avg_qps": "9286.185025696477", + "search.throughput.stddev_qps": "23.241531920981814", + "search.throughput.cv_pct": "0.25028073268698053", + "search.count.avg_visited": "636.613", + "search.accuracy.recall_at_10": "0.93971", + "construction.index_build_time_s": "", + "construction.index_quant_time_s.ASH.compute_time_s": "", + "construction.index_quant_time_s.ASH.encoding_time_s": "", + "construction.index_quant_time_s.NVQ.compute_time_s": "" + } + ], + "log": "/home/ted_willke/benchmark-runs/ash-native-projection-symmetric-20261029/logs/jdk25-mpnet-1m-2bit-ash-after-t1-after-t1.log", + "finished": "2026-10-29T06:32:17.690674+00:00" + }, + { + "label": "jdk25-mpnet-1m-2bit-ash-after-t8-after-t8", + "jdk": "25", + "version": "after", + "threads": 8, + "build": false, + "elapsed_process_seconds": 18.72387741226703, + "rows": [ + { + "schema_version": "1", + "run_id": "jdk25-mpnet-1m-2bit-ash-after-t8-after-t8-20261029-063218Z", + "run_uuid": "67999814-171c-42bf-b7d2-5241e9055411", + "system_id": "99c688318adc", + "dataset_name": "mpnet-1m", + "M": "32", + "efConstruction": "100", + "neighborOverflow": "1.2", + "addHierarchy": "true", + "refineFinalGraph": "true", + "feature_set": "[NVQ_VECTORS, FUSED_ASH]", + "build_compressor": "\"AsymmetricHashing[origDim=768, encodedBits=808, bodyBits=768, quantizedDim=384, bitsPerDimension=2, optimizer=ITQ, landmarks=1]\"", + "search_compressor": "None", + "usePruning": "false", + "topK": "10", + "overquery": "1.0", + "rerankK": "10", + "search.throughput.avg_qps": "83549.5892088437", + "search.throughput.stddev_qps": "3589.869381486101", + "search.throughput.cv_pct": "4.296693036410662", + "search.count.avg_visited": "455.8008", + "search.accuracy.recall_at_10": "0.82017", + "construction.index_build_time_s": "", + "construction.index_quant_time_s.ASH.compute_time_s": "", + "construction.index_quant_time_s.ASH.encoding_time_s": "", + "construction.index_quant_time_s.NVQ.compute_time_s": "" + }, + { + "schema_version": "1", + "run_id": "jdk25-mpnet-1m-2bit-ash-after-t8-after-t8-20261029-063218Z", + "run_uuid": "67999814-171c-42bf-b7d2-5241e9055411", + "system_id": "99c688318adc", + "dataset_name": "mpnet-1m", + "M": "32", + "efConstruction": "100", + "neighborOverflow": "1.2", + "addHierarchy": "true", + "refineFinalGraph": "true", + "feature_set": "[NVQ_VECTORS, FUSED_ASH]", + "build_compressor": "\"AsymmetricHashing[origDim=768, encodedBits=808, bodyBits=768, quantizedDim=384, bitsPerDimension=2, optimizer=ITQ, landmarks=1]\"", + "search_compressor": "None", + "usePruning": "false", + "topK": "10", + "overquery": "2.0", + "rerankK": "20", + "search.throughput.avg_qps": "69337.75348751461", + "search.throughput.stddev_qps": "630.6648601224889", + "search.throughput.cv_pct": "0.909554792882135", + "search.count.avg_visited": "636.613", + "search.accuracy.recall_at_10": "0.93971", + "construction.index_build_time_s": "", + "construction.index_quant_time_s.ASH.compute_time_s": "", + "construction.index_quant_time_s.ASH.encoding_time_s": "", + "construction.index_quant_time_s.NVQ.compute_time_s": "" + } + ], + "log": "/home/ted_willke/benchmark-runs/ash-native-projection-symmetric-20261029/logs/jdk25-mpnet-1m-2bit-ash-after-t8-after-t8.log", + "finished": "2026-10-29T06:32:36.416722+00:00" + }, + { + "label": "jdk25-mpnet-1m-2bit-ash-before-t8-before-t8", + "jdk": "25", + "version": "before", + "threads": 8, + "build": false, + "elapsed_process_seconds": 19.61476370319724, + "rows": [ + { + "schema_version": "1", + "run_id": "jdk25-mpnet-1m-2bit-ash-before-t8-before-t8-20261029-063236Z", + "run_uuid": "de74c72c-244f-4704-9883-20f883957df8", + "system_id": "99c688318adc", + "dataset_name": "mpnet-1m", + "M": "32", + "efConstruction": "100", + "neighborOverflow": "1.2", + "addHierarchy": "true", + "refineFinalGraph": "true", + "feature_set": "[NVQ_VECTORS, FUSED_ASH]", + "build_compressor": "\"AsymmetricHashing[origDim=768, encodedBits=808, bodyBits=768, quantizedDim=384, bitsPerDimension=2, optimizer=ITQ, landmarks=1]\"", + "search_compressor": "None", + "usePruning": "false", + "topK": "10", + "overquery": "1.0", + "rerankK": "10", + "search.throughput.avg_qps": "76547.2022487084", + "search.throughput.stddev_qps": "1687.0173069862165", + "search.throughput.cv_pct": "2.203891530228568", + "search.count.avg_visited": "455.8008", + "search.accuracy.recall_at_10": "0.82017", + "construction.index_build_time_s": "", + "construction.index_quant_time_s.ASH.compute_time_s": "", + "construction.index_quant_time_s.ASH.encoding_time_s": "", + "construction.index_quant_time_s.NVQ.compute_time_s": "" + }, + { + "schema_version": "1", + "run_id": "jdk25-mpnet-1m-2bit-ash-before-t8-before-t8-20261029-063236Z", + "run_uuid": "de74c72c-244f-4704-9883-20f883957df8", + "system_id": "99c688318adc", + "dataset_name": "mpnet-1m", + "M": "32", + "efConstruction": "100", + "neighborOverflow": "1.2", + "addHierarchy": "true", + "refineFinalGraph": "true", + "feature_set": "[NVQ_VECTORS, FUSED_ASH]", + "build_compressor": "\"AsymmetricHashing[origDim=768, encodedBits=808, bodyBits=768, quantizedDim=384, bitsPerDimension=2, optimizer=ITQ, landmarks=1]\"", + "search_compressor": "None", + "usePruning": "false", + "topK": "10", + "overquery": "2.0", + "rerankK": "20", + "search.throughput.avg_qps": "61781.41999991404", + "search.throughput.stddev_qps": "1537.5499525945193", + "search.throughput.cv_pct": "2.4886931258567033", + "search.count.avg_visited": "636.613", + "search.accuracy.recall_at_10": "0.93971", + "construction.index_build_time_s": "", + "construction.index_quant_time_s.ASH.compute_time_s": "", + "construction.index_quant_time_s.ASH.encoding_time_s": "", + "construction.index_quant_time_s.NVQ.compute_time_s": "" + } + ], + "log": "/home/ted_willke/benchmark-runs/ash-native-projection-symmetric-20261029/logs/jdk25-mpnet-1m-2bit-ash-before-t8-before-t8.log", + "finished": "2026-10-29T06:32:56.034022+00:00" + }, + { + "label": "jdk25-mpnet-1m-2bit-ash-after-build", + "jdk": "25", + "version": "after", + "threads": 1, + "build": true, + "elapsed_process_seconds": 46.53267274983227, + "rows": [ + { + "schema_version": "1", + "run_id": "jdk25-mpnet-1m-2bit-ash-after-build-20261029-063256Z", + "run_uuid": "bdb3994e-9165-4e44-a660-4cc0e8706745", + "system_id": "99c688318adc", + "dataset_name": "mpnet-1m", + "M": "32", + "efConstruction": "100", + "neighborOverflow": "1.2", + "addHierarchy": "true", + "refineFinalGraph": "true", + "feature_set": "[NVQ_VECTORS, FUSED_ASH]", + "build_compressor": "\"AsymmetricHashing[origDim=768, encodedBits=808, bodyBits=768, quantizedDim=384, bitsPerDimension=2, optimizer=ITQ, landmarks=1]\"", + "search_compressor": "None", + "usePruning": "false", + "topK": "10", + "overquery": "1.0", + "rerankK": "10", + "search.accuracy.recall_at_10": "0.81633", + "construction.index_build_time_s": "34.225727643", + "construction.index_quant_time_s.ASH.compute_time_s": "5.328078086", + "construction.index_quant_time_s.ASH.encoding_time_s": "0.952869518", + "construction.index_quant_time_s.NVQ.compute_time_s": "0.307421481" + } + ], + "log": "/home/ted_willke/benchmark-runs/ash-native-projection-symmetric-20261029/logs/jdk25-mpnet-1m-2bit-ash-after-build.log", + "finished": "2026-10-29T06:33:42.568817+00:00" + }, + { + "label": "jdk25-openai-3072-1m-4bit-ash-after-t1-after-t1", + "jdk": "25", + "version": "after", + "threads": 1, + "build": false, + "elapsed_process_seconds": 72.91965845320374, + "rows": [ + { + "schema_version": "1", + "run_id": "jdk25-openai-3072-1m-4bit-ash-after-t1-after-t1-20261029-063343Z", + "run_uuid": "a2ac1bf0-43ce-4124-a7a4-bca7b4f2046b", + "system_id": "99c688318adc", + "dataset_name": "openai-3072-1m", + "M": "32", + "efConstruction": "100", + "neighborOverflow": "1.2", + "addHierarchy": "true", + "refineFinalGraph": "true", + "feature_set": "[NVQ_VECTORS, FUSED_ASH]", + "build_compressor": "\"AsymmetricHashing[origDim=3072, encodedBits=6184, bodyBits=6144, quantizedDim=1536, bitsPerDimension=4, optimizer=ITQ, landmarks=1]\"", + "search_compressor": "None", + "usePruning": "false", + "topK": "10", + "overquery": "1.0", + "rerankK": "10", + "search.throughput.avg_qps": "1011.9147946874974", + "search.throughput.stddev_qps": "77.93195987838018", + "search.throughput.cv_pct": "7.701434971355209", + "search.count.avg_visited": "510.697", + "search.accuracy.recall_at_10": "0.7965", + "construction.index_build_time_s": "", + "construction.index_quant_time_s.ASH.compute_time_s": "", + "construction.index_quant_time_s.ASH.encoding_time_s": "", + "construction.index_quant_time_s.NVQ.compute_time_s": "" + }, + { + "schema_version": "1", + "run_id": "jdk25-openai-3072-1m-4bit-ash-after-t1-after-t1-20261029-063343Z", + "run_uuid": "a2ac1bf0-43ce-4124-a7a4-bca7b4f2046b", + "system_id": "99c688318adc", + "dataset_name": "openai-3072-1m", + "M": "32", + "efConstruction": "100", + "neighborOverflow": "1.2", + "addHierarchy": "true", + "refineFinalGraph": "true", + "feature_set": "[NVQ_VECTORS, FUSED_ASH]", + "build_compressor": "\"AsymmetricHashing[origDim=3072, encodedBits=6184, bodyBits=6144, quantizedDim=1536, bitsPerDimension=4, optimizer=ITQ, landmarks=1]\"", + "search_compressor": "None", + "usePruning": "false", + "topK": "10", + "overquery": "2.0", + "rerankK": "20", + "search.throughput.avg_qps": "1054.0483629342123", + "search.throughput.stddev_qps": "9.48209551081115", + "search.throughput.cv_pct": "0.89958827737423", + "search.count.avg_visited": "730.922", + "search.accuracy.recall_at_10": "0.8875", + "construction.index_build_time_s": "", + "construction.index_quant_time_s.ASH.compute_time_s": "", + "construction.index_quant_time_s.ASH.encoding_time_s": "", + "construction.index_quant_time_s.NVQ.compute_time_s": "" + } + ], + "log": "/home/ted_willke/benchmark-runs/ash-native-projection-symmetric-20261029/logs/jdk25-openai-3072-1m-4bit-ash-after-t1-after-t1.log", + "finished": "2026-10-29T06:34:55.492583+00:00" + }, + { + "label": "jdk25-openai-3072-1m-4bit-ash-before-t1-before-t1", + "jdk": "25", + "version": "before", + "threads": 1, + "build": false, + "elapsed_process_seconds": 65.1370682939887, + "rows": [ + { + "schema_version": "1", + "run_id": "jdk25-openai-3072-1m-4bit-ash-before-t1-before-t1-20261029-063456Z", + "run_uuid": "26acb068-3141-40bc-a8f4-19b859da5a0a", + "system_id": "99c688318adc", + "dataset_name": "openai-3072-1m", + "M": "32", + "efConstruction": "100", + "neighborOverflow": "1.2", + "addHierarchy": "true", + "refineFinalGraph": "true", + "feature_set": "[NVQ_VECTORS, FUSED_ASH]", + "build_compressor": "\"AsymmetricHashing[origDim=3072, encodedBits=6184, bodyBits=6144, quantizedDim=1536, bitsPerDimension=4, optimizer=ITQ, landmarks=1]\"", + "search_compressor": "None", + "usePruning": "false", + "topK": "10", + "overquery": "1.0", + "rerankK": "10", + "search.throughput.avg_qps": "1062.2402591845162", + "search.throughput.stddev_qps": "54.081084529394104", + "search.throughput.cv_pct": "5.091229038043828", + "search.count.avg_visited": "510.697", + "search.accuracy.recall_at_10": "0.7965", + "construction.index_build_time_s": "", + "construction.index_quant_time_s.ASH.compute_time_s": "", + "construction.index_quant_time_s.ASH.encoding_time_s": "", + "construction.index_quant_time_s.NVQ.compute_time_s": "" + }, + { + "schema_version": "1", + "run_id": "jdk25-openai-3072-1m-4bit-ash-before-t1-before-t1-20261029-063456Z", + "run_uuid": "26acb068-3141-40bc-a8f4-19b859da5a0a", + "system_id": "99c688318adc", + "dataset_name": "openai-3072-1m", + "M": "32", + "efConstruction": "100", + "neighborOverflow": "1.2", + "addHierarchy": "true", + "refineFinalGraph": "true", + "feature_set": "[NVQ_VECTORS, FUSED_ASH]", + "build_compressor": "\"AsymmetricHashing[origDim=3072, encodedBits=6184, bodyBits=6144, quantizedDim=1536, bitsPerDimension=4, optimizer=ITQ, landmarks=1]\"", + "search_compressor": "None", + "usePruning": "false", + "topK": "10", + "overquery": "2.0", + "rerankK": "20", + "search.throughput.avg_qps": "1040.2391399169903", + "search.throughput.stddev_qps": "11.817906493765921", + "search.throughput.cv_pct": "1.136075931031491", + "search.count.avg_visited": "730.922", + "search.accuracy.recall_at_10": "0.8875", + "construction.index_build_time_s": "", + "construction.index_quant_time_s.ASH.compute_time_s": "", + "construction.index_quant_time_s.ASH.encoding_time_s": "", + "construction.index_quant_time_s.NVQ.compute_time_s": "" + } + ], + "log": "/home/ted_willke/benchmark-runs/ash-native-projection-symmetric-20261029/logs/jdk25-openai-3072-1m-4bit-ash-before-t1-before-t1.log", + "finished": "2026-10-29T06:36:00.631935+00:00" + }, + { + "label": "jdk25-openai-3072-1m-4bit-ash-before-t8-before-t8", + "jdk": "25", + "version": "before", + "threads": 8, + "build": false, + "elapsed_process_seconds": 25.41133080702275, + "rows": [ + { + "schema_version": "1", + "run_id": "jdk25-openai-3072-1m-4bit-ash-before-t8-before-t8-20261029-063601Z", + "run_uuid": "5ca5a2ff-fca7-4b6f-aa23-4f6277569709", + "system_id": "99c688318adc", + "dataset_name": "openai-3072-1m", + "M": "32", + "efConstruction": "100", + "neighborOverflow": "1.2", + "addHierarchy": "true", + "refineFinalGraph": "true", + "feature_set": "[NVQ_VECTORS, FUSED_ASH]", + "build_compressor": "\"AsymmetricHashing[origDim=3072, encodedBits=6184, bodyBits=6144, quantizedDim=1536, bitsPerDimension=4, optimizer=ITQ, landmarks=1]\"", + "search_compressor": "None", + "usePruning": "false", + "topK": "10", + "overquery": "1.0", + "rerankK": "10", + "search.throughput.avg_qps": "7843.681782186251", + "search.throughput.stddev_qps": "249.6000776178493", + "search.throughput.cv_pct": "3.1821800596846606", + "search.count.avg_visited": "510.697", + "search.accuracy.recall_at_10": "0.7965", + "construction.index_build_time_s": "", + "construction.index_quant_time_s.ASH.compute_time_s": "", + "construction.index_quant_time_s.ASH.encoding_time_s": "", + "construction.index_quant_time_s.NVQ.compute_time_s": "" + }, + { + "schema_version": "1", + "run_id": "jdk25-openai-3072-1m-4bit-ash-before-t8-before-t8-20261029-063601Z", + "run_uuid": "5ca5a2ff-fca7-4b6f-aa23-4f6277569709", + "system_id": "99c688318adc", + "dataset_name": "openai-3072-1m", + "M": "32", + "efConstruction": "100", + "neighborOverflow": "1.2", + "addHierarchy": "true", + "refineFinalGraph": "true", + "feature_set": "[NVQ_VECTORS, FUSED_ASH]", + "build_compressor": "\"AsymmetricHashing[origDim=3072, encodedBits=6184, bodyBits=6144, quantizedDim=1536, bitsPerDimension=4, optimizer=ITQ, landmarks=1]\"", + "search_compressor": "None", + "usePruning": "false", + "topK": "10", + "overquery": "2.0", + "rerankK": "20", + "search.throughput.avg_qps": "7463.83544647903", + "search.throughput.stddev_qps": "40.81690243508421", + "search.throughput.cv_pct": "0.5468623032724961", + "search.count.avg_visited": "730.922", + "search.accuracy.recall_at_10": "0.8875", + "construction.index_build_time_s": "", + "construction.index_quant_time_s.ASH.compute_time_s": "", + "construction.index_quant_time_s.ASH.encoding_time_s": "", + "construction.index_quant_time_s.NVQ.compute_time_s": "" + } + ], + "log": "/home/ted_willke/benchmark-runs/ash-native-projection-symmetric-20261029/logs/jdk25-openai-3072-1m-4bit-ash-before-t8-before-t8.log", + "finished": "2026-10-29T06:36:26.046141+00:00" + }, + { + "label": "jdk25-openai-3072-1m-4bit-ash-after-t8-after-t8", + "jdk": "25", + "version": "after", + "threads": 8, + "build": false, + "elapsed_process_seconds": 24.735586038790643, + "rows": [ + { + "schema_version": "1", + "run_id": "jdk25-openai-3072-1m-4bit-ash-after-t8-after-t8-20261029-063626Z", + "run_uuid": "8a406829-e956-43e9-9bd4-864ac2120474", + "system_id": "99c688318adc", + "dataset_name": "openai-3072-1m", + "M": "32", + "efConstruction": "100", + "neighborOverflow": "1.2", + "addHierarchy": "true", + "refineFinalGraph": "true", + "feature_set": "[NVQ_VECTORS, FUSED_ASH]", + "build_compressor": "\"AsymmetricHashing[origDim=3072, encodedBits=6184, bodyBits=6144, quantizedDim=1536, bitsPerDimension=4, optimizer=ITQ, landmarks=1]\"", + "search_compressor": "None", + "usePruning": "false", + "topK": "10", + "overquery": "1.0", + "rerankK": "10", + "search.throughput.avg_qps": "8559.176543886948", + "search.throughput.stddev_qps": "155.5585871042546", + "search.throughput.cv_pct": "1.8174480489639642", + "search.count.avg_visited": "510.697", + "search.accuracy.recall_at_10": "0.7965", + "construction.index_build_time_s": "", + "construction.index_quant_time_s.ASH.compute_time_s": "", + "construction.index_quant_time_s.ASH.encoding_time_s": "", + "construction.index_quant_time_s.NVQ.compute_time_s": "" + }, + { + "schema_version": "1", + "run_id": "jdk25-openai-3072-1m-4bit-ash-after-t8-after-t8-20261029-063626Z", + "run_uuid": "8a406829-e956-43e9-9bd4-864ac2120474", + "system_id": "99c688318adc", + "dataset_name": "openai-3072-1m", + "M": "32", + "efConstruction": "100", + "neighborOverflow": "1.2", + "addHierarchy": "true", + "refineFinalGraph": "true", + "feature_set": "[NVQ_VECTORS, FUSED_ASH]", + "build_compressor": "\"AsymmetricHashing[origDim=3072, encodedBits=6184, bodyBits=6144, quantizedDim=1536, bitsPerDimension=4, optimizer=ITQ, landmarks=1]\"", + "search_compressor": "None", + "usePruning": "false", + "topK": "10", + "overquery": "2.0", + "rerankK": "20", + "search.throughput.avg_qps": "7983.474801021447", + "search.throughput.stddev_qps": "123.60378680657276", + "search.throughput.cv_pct": "1.5482454681357327", + "search.count.avg_visited": "730.922", + "search.accuracy.recall_at_10": "0.8875", + "construction.index_build_time_s": "", + "construction.index_quant_time_s.ASH.compute_time_s": "", + "construction.index_quant_time_s.ASH.encoding_time_s": "", + "construction.index_quant_time_s.NVQ.compute_time_s": "" + } + ], + "log": "/home/ted_willke/benchmark-runs/ash-native-projection-symmetric-20261029/logs/jdk25-openai-3072-1m-4bit-ash-after-t8-after-t8.log", + "finished": "2026-10-29T06:36:50.784113+00:00" + }, + { + "label": "jdk25-openai-3072-1m-4bit-ash-after-build", + "jdk": "25", + "version": "after", + "threads": 1, + "build": true, + "elapsed_process_seconds": 186.0093028023839, + "rows": [ + { + "schema_version": "1", + "run_id": "jdk25-openai-3072-1m-4bit-ash-after-build-20261029-063651Z", + "run_uuid": "0a542ca8-fcb1-4cca-99ed-7fb5525b7635", + "system_id": "99c688318adc", + "dataset_name": "openai-3072-1m", + "M": "32", + "efConstruction": "100", + "neighborOverflow": "1.2", + "addHierarchy": "true", + "refineFinalGraph": "true", + "feature_set": "[NVQ_VECTORS, FUSED_ASH]", + "build_compressor": "\"AsymmetricHashing[origDim=3072, encodedBits=6184, bodyBits=6144, quantizedDim=1536, bitsPerDimension=4, optimizer=ITQ, landmarks=1]\"", + "search_compressor": "None", + "usePruning": "false", + "topK": "10", + "overquery": "1.0", + "rerankK": "10", + "search.accuracy.recall_at_10": "0.787", + "construction.index_build_time_s": "106.236637281", + "construction.index_quant_time_s.ASH.compute_time_s": "36.395486952", + "construction.index_quant_time_s.ASH.encoding_time_s": "25.752220845", + "construction.index_quant_time_s.NVQ.compute_time_s": "1.235392413" + } + ], + "log": "/home/ted_willke/benchmark-runs/ash-native-projection-symmetric-20261029/logs/jdk25-openai-3072-1m-4bit-ash-after-build.log", + "finished": "2026-10-29T06:39:56.796531+00:00" + } +] diff --git a/docs/benchmarks/native-ash-projection-symmetric-20261029/report.md b/docs/benchmarks/native-ash-projection-symmetric-20261029/report.md new file mode 100644 index 000000000..041116ea5 --- /dev/null +++ b/docs/benchmarks/native-ash-projection-symmetric-20261029/report.md @@ -0,0 +1,57 @@ +# Native ASH projection and symmetric construction + +Dense graph-query projection and encoded symmetric construction arithmetic now use Highway. Java/Panama and missing-symbol fallbacks remain available. Source and the reference implementation review are described in [design.md](design.md). + +Performance was collected through unchanged BenchYAML. Both arms use the native provider and the identical saved graph/model; **before** uses the inherited Panama projection/symmetric helpers and **after** uses the new Highway operations. Query preparation is included. These query runs measure the projection change; the construction pair kernel is exercised by separate fresh builds. + +## Query throughput — before → after, QPS + +| Dataset / budget | JDK | 1 thread, 1× | 1 thread, 2× | 8 threads, 1× | 8 threads, 2× | +|---|---:|---:|---:|---:|---:| +| MPNet, 2-bit | 22 | 9,534 → 11,111 (1.17×) | 7,996 → 8,875 (1.11×) | 73,300 → 80,378 (1.10×) | 59,441 → 62,297 (1.05×) | +| MPNet, 2-bit | 25 | 10,441 → 12,118 (1.16×) | 8,294 → 9,286 (1.12×) | 76,547 → 83,550 (1.09×) | 61,781 → 69,338 (1.12×) | +| OpenAI3072, 4-bit | 22 | 1,025 → 1,026 (1.00×) | 1,030 → 1,046 (1.02×) | 7,741 → 8,259 (1.07×) | 7,240 → 7,927 (1.09×) | +| OpenAI3072, 4-bit | 25 | 1,062 → 1,012 (0.95×) | 1,040 → 1,054 (1.01×) | 7,844 → 8,559 (1.09×) | 7,464 → 7,983 (1.07×) | + +Native QPS was higher in 15/16 matched settings, with a geometric mean ratio +of 1.075. The exception was OpenAI3072/JDK25/one thread/1×: 1,062 → 1,012 QPS, +a 4.7% decrease. The small OpenAI single-thread changes should not be presented +as an established improvement from these three-sample runs. + +A separate instrumented, accuracy-only BenchYAML diagnostic alternated both +projection methods for each of 2,000 queries, checked their outputs, and measured +calling-thread CPU time. Median native/Panama projection costs were 0.629/0.626 ms +on JDK22 and 0.628/0.626 ms on JDK25. Thus this diagnostic found similar isolated +projection cost at D=3072. It does not establish the cause of the full-query +4.7% difference or assign a hardware bottleneck. Diagnostic results are excluded +from the throughput tables and retained in [projection-diagnostic.json](projection-diagnostic.json). + +## Recall@10 — same saved graph, before → after (%) + +| Dataset / budget | JDK | 1× | 2× | +|---|---:|---:|---:| +| MPNet, 2-bit | 22 | 81.940 → 81.940 | 94.055 → 94.055 | +| MPNet, 2-bit | 25 | 82.017 → 82.017 | 93.971 → 93.971 | +| OpenAI3072, 4-bit | 22 | 79.030 → 79.030 | 88.340 → 88.340 | +| OpenAI3072, 4-bit | 25 | 79.650 → 79.650 | 88.750 → 88.750 | + +## Fresh construction — current implementation, seconds + +Existing BenchYAML phase timers are retained. Process wall time additionally includes startup/loading, one standard 1× recall pass and teardown. The fresh graphs below are separate from the identical saved graphs used for the throughput comparison. + +| Dataset / budget | JDK | Training | Encoding | Graph/write | Process incl. validation | Recall@10, 1× (%) | +|---|---:|---:|---:|---:|---:|---:| +| MPNet, 2-bit | 22 | 5.29 | 1.05 | 54.16 | 67.83 | 81.888 | +| MPNet, 2-bit | 25 | 5.33 | 0.95 | 34.23 | 46.53 | 81.633 | +| OpenAI3072, 4-bit | 22 | 38.09 | 25.96 | 101.92 | 185.29 | 78.960 | +| OpenAI3072, 4-bit | 25 | 36.40 | 25.75 | 106.24 | 186.01 | 78.700 | + +## Validation and limits + +All 60 targeted JDK/ISA regression checks passed. Four traced BenchYAML preflights confirmed actual native projection, symmetric pair and neighborhood calls. Independent ARM NEON and Highway scalar executables each passed 64 arithmetic cases. The same-graph maximum recall change was 0.00000 percentage points. Maximum throughput sample CV was 8.75%. + +Query runs use 1 or 8 physical cores; construction uses 48. Native kernels do not create workers or invoke multithreaded BLAS. Matrix preparation is once per immutable model; projection remains once per graph query. New models add rows × columns × 4 bytes of native matrix storage, included in model memory accounting; serialized formats are unchanged. + +This focused study does not repeat the entire four-dataset PQ/ASH matrix or establish construction variance. It does not measure standalone pair/LUT kernel throughput in isolation. Native libraries and Java wrappers are frozen per JDK. Earlier development runs and the interrupted build remain archived; they are excluded from these final comparisons. + +Data: [queries.csv](queries.csv), [construction.csv](construction.csv), [raw-results.json](raw-results.json). Reproduction: [protocol.json](protocol.json), [source.json](source.json), [validation.json](validation.json), [native-preflight.json](native-preflight.json), [implementation.patch](implementation.patch). diff --git a/docs/benchmarks/native-ash-projection-symmetric-20261029/source.json b/docs/benchmarks/native-ash-projection-symmetric-20261029/source.json new file mode 100644 index 000000000..88acba746 --- /dev/null +++ b/docs/benchmarks/native-ash-projection-symmetric-20261029/source.json @@ -0,0 +1,16 @@ +{ + "highway_commit": "16f5f5c768064d0bb52952888a14236bec75c104", + "active_highway_isa": "ACTIVE_HIGHWAY_ISA=avx3_spr", + "branch": "ash-dev", + "implementation_and_tests": "28e4c8c1c8f49db472a9d0e530c01d5dcbceb403", + "projection": "835bb81c002360fc885f704431d023abd372ef5e", + "symmetric": "d76c1fe80a90330dfa3845345585a5754a9e0739", + "baseline_repository_reference": "c0e8ab06ec7f26a96f53d7c7092205cc93d68788", + "baseline_runtime_source": "507b74e7ef8a0e12fcfe402deb4bf758c65758d5", + "benchyaml_unchanged": true, + "pushed": false, + "native_library_sha256": { + "22": "c187841b9124daf480606dd0ad709240fc8014cbcc306a03ebda46c1b22c227a", + "25": "c187841b9124daf480606dd0ad709240fc8014cbcc306a03ebda46c1b22c227a" + } +} diff --git a/docs/benchmarks/native-ash-projection-symmetric-20261029/summary.json b/docs/benchmarks/native-ash-projection-symmetric-20261029/summary.json new file mode 100644 index 000000000..3849eff87 --- /dev/null +++ b/docs/benchmarks/native-ash-projection-symmetric-20261029/summary.json @@ -0,0 +1,10 @@ +{ + "query_comparisons": 16, + "geometric_mean_qps_ratio": 1.0747262914811682, + "min_qps_ratio": 0.9526232751377229, + "max_qps_ratio": 1.1653572102114633, + "qps_wins": 15, + "max_abs_recall_change_percentage_points": 0.0, + "max_cv_pct": 8.754005476832623, + "fresh_graph_builds": 4 +} diff --git a/docs/benchmarks/native-ash-projection-symmetric-20261029/validation.json b/docs/benchmarks/native-ash-projection-symmetric-20261029/validation.json new file mode 100644 index 000000000..41d9c2cb5 --- /dev/null +++ b/docs/benchmarks/native-ash-projection-symmetric-20261029/validation.json @@ -0,0 +1,70 @@ +{ + "jdk_isa_checks": [ + { + "jdk": "22", + "isa": "auto", + "tests": 6, + "passed": true + }, + { + "jdk": "22", + "isa": "avx3_dl", + "tests": 6, + "passed": true + }, + { + "jdk": "22", + "isa": "avx3", + "tests": 6, + "passed": true + }, + { + "jdk": "22", + "isa": "avx2", + "tests": 6, + "passed": true + }, + { + "jdk": "22", + "isa": "sse42", + "tests": 6, + "passed": true + }, + { + "jdk": "25", + "isa": "auto", + "tests": 6, + "passed": true + }, + { + "jdk": "25", + "isa": "avx3_dl", + "tests": 6, + "passed": true + }, + { + "jdk": "25", + "isa": "avx3", + "tests": 6, + "passed": true + }, + { + "jdk": "25", + "isa": "avx2", + "tests": 6, + "passed": true + }, + { + "jdk": "25", + "isa": "sse42", + "tests": 6, + "passed": true + } + ], + "total_targeted_tests": 60, + "highway_scalar_arithmetic_checks": 64, + "highway_scalar_scope": "new ASH projection and symmetric headers only", + "arm_neon_arithmetic_checks": 64, + "arm_neon_hardware": "Apple Silicon arm64; Apple Clang 17; independent double/decoder/int64 oracles", + "sve_sve2_runtime_verified": false +} From d7605a96aee0a222ea2ed14a661eebf18ce35ccf Mon Sep 17 00:00:00 2001 From: Ted Willke Date: Thu, 29 Oct 2026 22:47:52 +0000 Subject: [PATCH 102/243] Complete native ASH results for both MPNet and OpenAI compression budgets --- .../construction.csv | 17 +++++ .../queries.csv | 65 +++++++++++++++++++ .../report.md | 50 ++++++++++++++ 3 files changed, 132 insertions(+) create mode 100644 docs/benchmarks/native-ash-complete-budgets-20261029/construction.csv create mode 100644 docs/benchmarks/native-ash-complete-budgets-20261029/queries.csv create mode 100644 docs/benchmarks/native-ash-complete-budgets-20261029/report.md diff --git a/docs/benchmarks/native-ash-complete-budgets-20261029/construction.csv b/docs/benchmarks/native-ash-complete-budgets-20261029/construction.csv new file mode 100644 index 000000000..a2338dfeb --- /dev/null +++ b/docs/benchmarks/native-ash-complete-budgets-20261029/construction.csv @@ -0,0 +1,17 @@ +dataset,jdk,ash_bits_budget,algorithm,training_seconds,encoding_seconds,graph_write_seconds,process_seconds_including_validation,fresh_graph_recall_at_10 +MPNet,22,2,ASH,5.292379244,1.047888893,54.164598291,67.83305824548006,0.81888 +OpenAI3072,22,4,ASH,38.086685724,25.959572952,101.919147369,185.2930998262018,0.7896 +MPNet,25,2,ASH,5.328078086,0.952869518,34.225727643,46.53267274983227,0.81633 +OpenAI3072,25,4,ASH,36.395486952,25.752220845,106.236637281,186.0093028023839,0.787 +MPNet,22,4,ASH,4.450163238,1.325867775,41.280168348,54.49509896989912,0.87111 +OpenAI3072,22,2,ASH,38.293915111,27.356674768,86.742283949,168.8477511294186,0.772 +MPNet,25,4,ASH,4.783722329,1.052683121,36.517193728,48.428979087620974,0.86819 +OpenAI3072,25,2,ASH,41.538574207,26.930347871,85.792219557,171.2168671572581,0.778 +MPNet,22,2,PQ,2.729675109,1.081207084,44.438387038,52.27324899379164, +MPNet,22,4,PQ,6.137772914,1.70667817,50.786497509,62.748410480096936, +MPNet,25,2,PQ,2.774568465,1.048305647,33.769320929,41.54969112202525, +MPNet,25,4,PQ,4.652523221,1.745370325,48.549881485,58.950520511716604, +OpenAI3072,22,2,PQ,6.505794804,5.508848828,90.502056269,113.07548744231462, +OpenAI3072,22,4,PQ,19.649721716,7.64310692,257.818535867,295.41825035214424, +OpenAI3072,25,2,PQ,5.897795915,5.580369998,113.005143545,135.150859314017, +OpenAI3072,25,4,PQ,16.624147058,7.778564805,247.52862079,281.5635087201372, diff --git a/docs/benchmarks/native-ash-complete-budgets-20261029/queries.csv b/docs/benchmarks/native-ash-complete-budgets-20261029/queries.csv new file mode 100644 index 000000000..550533aa5 --- /dev/null +++ b/docs/benchmarks/native-ash-complete-budgets-20261029/queries.csv @@ -0,0 +1,65 @@ +dataset,jdk,ash_bits_budget,algorithm,threads,overquery,qps,recall_at_10,avg_visited,cv_pct +MPNet,22,2,ASH,1,1,11110.99464179856,0.8194,457.6786,5.908531619200125 +MPNet,22,2,ASH,1,2,8875.235693873283,0.94055,639.0262,0.21322087119190308 +MPNet,22,2,ASH,8,1,80378.2764372332,0.8194,457.6786,2.5685345814363902 +MPNet,22,2,ASH,8,2,62296.523830157785,0.94055,639.0262,8.754005476832623 +OpenAI3072,22,4,ASH,1,1,1026.4376092178372,0.7903,512.685,5.087713390174111 +OpenAI3072,22,4,ASH,1,2,1046.101981421671,0.8834,735.241,0.9862944129088163 +OpenAI3072,22,4,ASH,8,1,8258.710248857857,0.7903,512.685,3.4045657578531534 +OpenAI3072,22,4,ASH,8,2,7926.960479696699,0.8834,735.241,2.347372560147485 +MPNet,25,2,ASH,1,1,12117.702889285703,0.82017,455.8008,1.543338944623599 +MPNet,25,2,ASH,1,2,9286.185025696477,0.93971,636.613,0.25028073268698053 +MPNet,25,2,ASH,8,1,83549.5892088437,0.82017,455.8008,4.296693036410662 +MPNet,25,2,ASH,8,2,69337.75348751461,0.93971,636.613,0.909554792882135 +OpenAI3072,25,4,ASH,1,1,1011.9147946874974,0.7965,510.697,7.701434971355209 +OpenAI3072,25,4,ASH,1,2,1054.0483629342123,0.8875,730.922,0.89958827737423 +OpenAI3072,25,4,ASH,8,1,8559.176543886948,0.7965,510.697,1.8174480489639642 +OpenAI3072,25,4,ASH,8,2,7983.474801021447,0.8875,730.922,1.5482454681357327 +MPNet,22,4,ASH,1,1,9587.562121268436,0.86642,431.9821,6.030183415148024 +MPNet,22,4,ASH,1,2,7812.970974199045,0.93966,596.0551,1.1133190250185043 +MPNet,22,4,ASH,8,1,72202.01996457983,0.86642,431.9821,6.482810657090848 +MPNet,22,4,ASH,8,2,57661.64969036542,0.93966,596.0551,3.566408856618144 +OpenAI3072,22,2,ASH,1,1,1132.2179536928695,0.7792,530.783,5.109334879272743 +OpenAI3072,22,2,ASH,1,2,1135.6799508557433,0.898,767.32,0.4463337523790325 +OpenAI3072,22,2,ASH,8,1,8817.592177857054,0.7792,530.783,3.1367457872689894 +OpenAI3072,22,2,ASH,8,2,8458.498057300621,0.898,767.32,0.5326652275380236 +MPNet,25,4,ASH,1,1,10001.27046016388,0.86293,432.9807,3.5654096212332322 +MPNet,25,4,ASH,1,2,7791.30368847857,0.94033,598.6739,1.5362433499650072 +MPNet,25,4,ASH,8,1,74362.08331746703,0.86293,432.9807,1.8182089054404562 +MPNet,25,4,ASH,8,2,59343.83497734477,0.94033,598.6739,1.4923069624117167 +OpenAI3072,25,2,ASH,1,1,806.3072036656206,0.7676,532.42,5.822985480038383 +OpenAI3072,25,2,ASH,1,2,812.0022306103976,0.8917,770.301,0.2939501500646269 +OpenAI3072,25,2,ASH,8,1,7746.97320164018,0.7676,532.42,1.7332825152324094 +OpenAI3072,25,2,ASH,8,2,7387.744268677949,0.8917,770.301,1.1965951297520279 +MPNet,22,2,PQ,1,1,11929.667667640202,0.65128,417.1787,2.7650949170996433 +MPNet,22,2,PQ,1,2,9441.313656397691,0.82126,564.0539,0.8964322845663989 +MPNet,22,2,PQ,8,1,86214.6647201115,0.65128,417.1787,3.7263853453962215 +MPNet,22,2,PQ,8,2,68759.66645239317,0.82126,564.0539,1.3643932618207706 +MPNet,22,4,PQ,1,1,9642.851473210929,0.80733,439.2968,2.622788842647526 +MPNet,22,4,PQ,1,2,7455.724453990442,0.92753,604.3334,0.39769811018210266 +MPNet,22,4,PQ,8,1,74892.80322512507,0.80733,439.2968,5.192081755979211 +MPNet,22,4,PQ,8,2,57187.4193258142,0.92753,604.3334,3.516729681005034 +MPNet,25,2,PQ,1,1,11812.269458852594,0.65484,416.9763,1.7862019254375245 +MPNet,25,2,PQ,1,2,9294.266279454745,0.8221,563.8511,0.45328257510035846 +MPNet,25,2,PQ,8,1,76801.73087416003,0.65484,416.9763,1.187157927658352 +MPNet,25,2,PQ,8,2,71112.3291273773,0.8221,563.8511,0.9005253421370478 +MPNet,25,4,PQ,1,1,10001.00801293626,0.80751,438.5955,1.7967501215259827 +MPNet,25,4,PQ,1,2,7557.44798962551,0.92601,602.5851,0.193935742345163 +MPNet,25,4,PQ,8,1,74886.84115917748,0.80751,438.5955,2.803819611477874 +MPNet,25,4,PQ,8,2,56904.75254695084,0.92601,602.5851,2.2165909085661166 +OpenAI3072,22,2,PQ,1,1,3329.077370787888,0.6437,471.041,12.325595060464947 +OpenAI3072,22,2,PQ,1,2,2953.6549674392622,0.7985,653.509,0.7008072547277489 +OpenAI3072,22,2,PQ,8,1,27543.90063678985,0.6437,471.041,3.928911338675991 +OpenAI3072,22,2,PQ,8,2,22784.923597112218,0.7985,653.509,0.8717134512070802 +OpenAI3072,22,4,PQ,1,1,2143.912280952585,0.7691,500.603,7.951539104201971 +OpenAI3072,22,4,PQ,1,2,1871.3707850777212,0.877,704.466,0.8603045761797353 +OpenAI3072,22,4,PQ,8,1,18466.799971430668,0.7691,500.603,4.652696771917339 +OpenAI3072,22,4,PQ,8,2,14336.390737938298,0.877,704.466,0.8815646353224336 +OpenAI3072,25,2,PQ,1,1,2170.3203408432223,0.651,466.217,11.878205190359557 +OpenAI3072,25,2,PQ,1,2,2068.413509232902,0.8032,646.845,0.5559964617757023 +OpenAI3072,25,2,PQ,8,1,27772.466077225967,0.651,466.217,1.6263838740957284 +OpenAI3072,25,2,PQ,8,2,23213.773739290547,0.8032,646.845,1.0800416328665052 +OpenAI3072,25,4,PQ,1,1,2280.759101685725,0.7624,501.437,10.025887040180322 +OpenAI3072,25,4,PQ,1,2,1886.2499788294274,0.8727,705.403,0.5679737109169148 +OpenAI3072,25,4,PQ,8,1,15663.804691288786,0.7624,501.437,3.280105643236947 +OpenAI3072,25,4,PQ,8,2,12884.883579153513,0.8727,705.403,0.6822680726916507 diff --git a/docs/benchmarks/native-ash-complete-budgets-20261029/report.md b/docs/benchmarks/native-ash-complete-budgets-20261029/report.md new file mode 100644 index 000000000..d0ee7eff3 --- /dev/null +++ b/docs/benchmarks/native-ash-complete-budgets-20261029/report.md @@ -0,0 +1,50 @@ +# Native ASH versus PQ: both MPNet and OpenAI3072 budgets + +Includes the previously omitted MPNet 4-bit and OpenAI3072 2-bit native-preparation reruns. ASH uses the native projection/symmetric implementation at 408df9b4 (code at 28e4c8c1), before the queued-batch POC. PQ remains the earlier full-study baseline; it was not recollected. All runs use BenchYAML. + +ASH: trained ITQ, C=1, projected dimension D/2. PQ uses exactly matched encoded byte budgets. Deferred parallel writes, fused graph vectors, NVQ reranking, M32, hierarchy and refinement retained. Queries use the same saved graphs as the original study; fresh construction uses separate newly trained models/graphs. + +Construction process time includes startup/loading and the standard validation query pass. It is not build-only. Each construction case has one fresh build; no build variance estimate is available. + +## Construction: PQ → ASH, seconds + +| Dataset | Bits | JDK | Training | Encoding | Graph/write | Process incl. validation | +|---|---:|---:|---:|---:|---:|---:| +| MPNet | 2 | 22 | 2.73 → 5.29 | 1.08 → 1.05 | 44.44 → 54.16 | 52.27 → 67.83 | +| MPNet | 2 | 25 | 2.77 → 5.33 | 1.05 → 0.95 | 33.77 → 34.23 | 41.55 → 46.53 | +| MPNet | 4 | 22 | 6.14 → 4.45 | 1.71 → 1.33 | 50.79 → 41.28 | 62.75 → 54.50 | +| MPNet | 4 | 25 | 4.65 → 4.78 | 1.75 → 1.05 | 48.55 → 36.52 | 58.95 → 48.43 | +| OpenAI3072 | 2 | 22 | 6.51 → 38.29 | 5.51 → 27.36 | 90.50 → 86.74 | 113.08 → 168.85 | +| OpenAI3072 | 2 | 25 | 5.90 → 41.54 | 5.58 → 26.93 | 113.01 → 85.79 | 135.15 → 171.22 | +| OpenAI3072 | 4 | 22 | 19.65 → 38.09 | 7.64 → 25.96 | 257.82 → 101.92 | 295.42 → 185.29 | +| OpenAI3072 | 4 | 25 | 16.62 → 36.40 | 7.78 → 25.75 | 247.53 → 106.24 | 281.56 → 186.01 | + +## Query throughput: PQ → ASH, QPS + +| Dataset | Bits | JDK | 1 core, 1× | 1 core, 2× | 8 cores, 1× | 8 cores, 2× | +|---|---:|---:|---:|---:|---:|---:| +| MPNet | 2 | 22 | 11,930 → 11,111 | 9,441 → 8,875 | 86,215 → 80,378 | 68,760 → 62,297 | +| MPNet | 2 | 25 | 11,812 → 12,118 | 9,294 → 9,286 | 76,802 → 83,550 | 71,112 → 69,338 | +| MPNet | 4 | 22 | 9,643 → 9,588 | 7,456 → 7,813 | 74,893 → 72,202 | 57,187 → 57,662 | +| MPNet | 4 | 25 | 10,001 → 10,001 | 7,557 → 7,791 | 74,887 → 74,362 | 56,905 → 59,344 | +| OpenAI3072 | 2 | 22 | 3,329 → 1,132 | 2,954 → 1,136 | 27,544 → 8,818 | 22,785 → 8,458 | +| OpenAI3072 | 2 | 25 | 2,170 → 806 | 2,068 → 812 | 27,772 → 7,747 | 23,214 → 7,388 | +| OpenAI3072 | 4 | 22 | 2,144 → 1,026 | 1,871 → 1,046 | 18,467 → 8,259 | 14,336 → 7,927 | +| OpenAI3072 | 4 | 25 | 2,281 → 1,012 | 1,886 → 1,054 | 15,664 → 8,559 | 12,885 → 7,983 | + +## Saved-graph recall@10: PQ → ASH, percent + +| Dataset | Bits | JDK | 1× | 2× | +|---|---:|---:|---:|---:| +| MPNet | 2 | 22 | 65.128 → 81.940 | 82.126 → 94.055 | +| MPNet | 2 | 25 | 65.484 → 82.017 | 82.210 → 93.971 | +| MPNet | 4 | 22 | 80.733 → 86.642 | 92.753 → 93.966 | +| MPNet | 4 | 25 | 80.751 → 86.293 | 92.601 → 94.033 | +| OpenAI3072 | 2 | 22 | 64.370 → 77.920 | 79.850 → 89.800 | +| OpenAI3072 | 2 | 25 | 65.100 → 76.760 | 80.320 → 89.170 | +| OpenAI3072 | 4 | 22 | 76.910 → 79.030 | 87.700 → 88.340 | +| OpenAI3072 | 4 | 25 | 76.240 → 79.650 | 87.270 → 88.750 | + +ASH retains its existing adaptive stopping policy and PQ its existing policy. Equal overquery is not an exact recall match or a quantizer-only comparison. Cohere and Ada are not recollected in this native-preparation subset. + +Data: [construction.csv](construction.csv), [queries.csv](queries.csv), [raw-ash-results.json](raw-ash-results.json). From cc1d119e44b2132166155cb2e080eb7e2bc22494 Mon Sep 17 00:00:00 2001 From: Ted Willke Date: Sat, 31 Oct 2026 15:57:19 +0000 Subject: [PATCH 103/243] test: expect dot product in Ada dataset metadata --- .../example/benchmarks/datasets/DataSetPropertiesTest.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/jvector-examples/src/test/java/io/github/jbellis/jvector/example/benchmarks/datasets/DataSetPropertiesTest.java b/jvector-examples/src/test/java/io/github/jbellis/jvector/example/benchmarks/datasets/DataSetPropertiesTest.java index 13f2136aa..5f337eb6d 100644 --- a/jvector-examples/src/test/java/io/github/jbellis/jvector/example/benchmarks/datasets/DataSetPropertiesTest.java +++ b/jvector-examples/src/test/java/io/github/jbellis/jvector/example/benchmarks/datasets/DataSetPropertiesTest.java @@ -384,7 +384,7 @@ public void productionMetadataFileLoadsSuccessfully() { var reader = DataSetMetadataReader.load(); var props = reader.getProperties("ada002-100k"); assertTrue(props.isPresent(), "ada002-100k should be in the production metadata file"); - assertEquals(VectorSimilarityFunction.COSINE, props.get().similarityFunction().orElse(null)); + assertEquals(VectorSimilarityFunction.DOT_PRODUCT, props.get().similarityFunction().orElse(null)); } @Test From d83a23d09413678116328853328c34e90b3d1a90 Mon Sep 17 00:00:00 2001 From: Ted Willke Date: Sat, 31 Oct 2026 15:57:19 +0000 Subject: [PATCH 104/243] docs: escape comparison notation in API comments --- .../github/jbellis/jvector/quantization/ASHVectors.java | 2 +- .../jbellis/jvector/quantization/AsymmetricHashing.java | 8 ++++---- .../jvector/quantization/MiniBatchKMeansClusterer.java | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHVectors.java b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHVectors.java index af33082b9..e45091978 100644 --- a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHVectors.java +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHVectors.java @@ -943,7 +943,7 @@ public static final class LandmarkOrder { } /** - * Stable, linear-time reorder by landmark id (C <= 256). + * Stable, linear-time reorder by landmark id ({@code C <= 256}). * Fills reordered header arrays in the same pass (no second constructor scan). */ public LandmarkOrder reorderByLandmarkFast() { diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/AsymmetricHashing.java b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/AsymmetricHashing.java index baa5b76ee..89586e8e8 100644 --- a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/AsymmetricHashing.java +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/AsymmetricHashing.java @@ -80,7 +80,7 @@ public class AsymmetricHashing implements VectorCompressor where A is [d][D] and μ_c is length D. + * landmarkProj[c][j] = {@code } where A is [d][D] and μ_c is length D. * * Shape: [landmarkCount][quantizedDim] */ @@ -436,7 +436,7 @@ private static int validateEncodedBits( /** * Precompute landmark projections in the reduced space: * - * landmarkProj[c][j] = + * landmarkProj[c][j] = {@code } * landmarkProjSum[c] = Σ_j landmarkProj[c][j] * * This is used by both initialize() and load() to ensure @@ -1358,7 +1358,7 @@ public StiefelTransform(RealMatrix W) { * @param d dimensionality of the projected space * @param rng random number generator used to sample G * @return a {@link StiefelTransform} containing W ∈ R^{D×d} with orthonormal columns - * @throws IllegalArgumentException if d <= 0 or d > D + * @throws IllegalArgumentException if {@code d <= 0 or d > D} * @throws RuntimeException if the native SVD fails */ public static StiefelTransform runWithoutTraining(int D, int d, Random rng) { diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/MiniBatchKMeansClusterer.java b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/MiniBatchKMeansClusterer.java index 9a337e980..ccf1d42dd 100644 --- a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/MiniBatchKMeansClusterer.java +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/MiniBatchKMeansClusterer.java @@ -93,7 +93,7 @@ static float computeParallelCostMultiplier(double threshold, int dimensions) { /** * Performs clustering. - * @param unweightedIterations If small (< 100), interpreted as EPOCHS (full passes). + * @param unweightedIterations If small ({@code < 100}), interpreted as EPOCHS (full passes). * If large, interpreted as TOTAL BATCH UPDATES. * @param anisotropicIterations Number of full-batch anisotropic refinement steps. */ From 8c6325b87a6f4cc2581f491a33fbb1aacefe8477 Mon Sep 17 00:00:00 2001 From: Ted Willke Date: Sat, 31 Oct 2026 15:57:19 +0000 Subject: [PATCH 105/243] native: add optional sequential oneDNN FP16 projection engine --- jvector-native/pom.xml | 16 ++ jvector-native/src/main/native/meson.build | 48 ++++- .../src/main/native/meson_options.txt | 18 ++ .../src/main/native/src/build_native_lib.sh | 32 ++- .../src/main/native/src/jvector_ash_fp16.cpp | 198 ++++++++++++++++++ .../src/main/native/src/jvector_ash_fp16.h | 62 ++++++ .../src/main/native/subprojects/.gitignore | 3 + .../src/main/native/subprojects/onednn.wrap | 22 ++ .../src/main/native/tests/test_ash_fp16.cpp | 84 ++++++++ 9 files changed, 480 insertions(+), 3 deletions(-) create mode 100644 jvector-native/src/main/native/meson_options.txt create mode 100644 jvector-native/src/main/native/src/jvector_ash_fp16.cpp create mode 100644 jvector-native/src/main/native/src/jvector_ash_fp16.h create mode 100644 jvector-native/src/main/native/subprojects/.gitignore create mode 100644 jvector-native/src/main/native/subprojects/onednn.wrap create mode 100644 jvector-native/src/main/native/tests/test_ash_fp16.cpp diff --git a/jvector-native/pom.xml b/jvector-native/pom.xml index 40d7838ef..4b0663689 100644 --- a/jvector-native/pom.xml +++ b/jvector-native/pom.xml @@ -16,6 +16,8 @@ true false + + false + + org.apache.rat + apache-rat-plugin + + + src/main/native/subprojects/onednn/** + + + org.apache.maven.plugins maven-compiler-plugin @@ -191,6 +206,7 @@ build_native_lib.sh ${native.buildtype} ${native.crossarch} + ${native.onednn} false ${project.basedir}/src/main/native/src/ diff --git a/jvector-native/src/main/native/meson.build b/jvector-native/src/main/native/meson.build index ec85d86e4..a8a078f2d 100644 --- a/jvector-native/src/main/native/meson.build +++ b/jvector-native/src/main/native/meson.build @@ -232,4 +232,50 @@ else command: ['echo', 'ERROR: bench_simd_kernels is not available because the benchmark library was not found. ' + 'Install libbenchmark-dev (apt) or google-benchmark (brew) and re-run: meson setup --wipe build']) -endif \ No newline at end of file +endif +# Optional and independent of libjvector: machines without oneDNN continue to +# use Highway. Static linkage keeps the packaged add-on self-contained, while +# oneDNN's runtime dispatcher selects instructions supported by the host CPU. +if get_option('onednn').enabled() + cmake = import('cmake') + onednn_options = cmake.subproject_options() + onednn_options.add_cmake_defines({ + 'CMAKE_POSITION_INDEPENDENT_CODE': true, + 'ONEDNN_LIBRARY_TYPE': 'STATIC', + 'ONEDNN_CPU_RUNTIME': 'SEQ', + 'ONEDNN_GPU_RUNTIME': 'NONE', + 'ONEDNN_BUILD_GRAPH': false, + 'ONEDNN_BUILD_TESTS': false, + 'ONEDNN_BUILD_EXAMPLES': false, + 'ONEDNN_ENABLE_PRIMITIVE': 'MATMUL;REORDER', + 'ONEDNN_ENABLE_WORKLOAD': 'INFERENCE', + 'ONEDNN_ARCH_OPT_FLAGS': '', + }) + onednn_project = cmake.subproject('onednn', options: onednn_options) + # oneDNN's aggregate archive has no source files of its own. Meson's CMake + # converter marks that aggregate non-PIC, although all constituent object + # libraries are PIC. Re-archive those objects with the correct metadata. + onednn_objects = static_library('ash_onednn', + objects: onednn_project.target('dnnl').extract_all_objects(recursive: true), + pic: true, + ) + onednn_dependency = onednn_project.dependency('dnnl').partial_dependency( + compile_args: true, includes: true, link_args: true) + ash_fp16_lib = shared_library('jvector_ash_fp16', + sources: 'src/jvector_ash_fp16.cpp', + dependencies: onednn_dependency, + link_with: onednn_objects, + cpp_args: ['-fvisibility=hidden'], + link_args: ['-Wl,--exclude-libs,ALL'], + install: true, + ) + if gtest_dep.found() + ash_fp16_tests = executable('test_ash_fp16', + sources: 'tests/test_ash_fp16.cpp', + include_directories: include_directories('src'), + dependencies: gtest_dep, + link_with: ash_fp16_lib, + ) + test('ash_fp16', ash_fp16_tests, protocol: 'gtest', suite: 'ash_fp16') + endif +endif diff --git a/jvector-native/src/main/native/meson_options.txt b/jvector-native/src/main/native/meson_options.txt new file mode 100644 index 000000000..3a57ba50d --- /dev/null +++ b/jvector-native/src/main/native/meson_options.txt @@ -0,0 +1,18 @@ +# +# 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. +# + +option('onednn', type: 'feature', value: 'disabled', + description: 'Build optional single-query ASH FP16 support with sequential oneDNN') diff --git a/jvector-native/src/main/native/src/build_native_lib.sh b/jvector-native/src/main/native/src/build_native_lib.sh index ea0c35c67..7da7f81e9 100755 --- a/jvector-native/src/main/native/src/build_native_lib.sh +++ b/jvector-native/src/main/native/src/build_native_lib.sh @@ -54,6 +54,14 @@ printf "BUILDTYPE=%s\n" "${BUILDTYPE}" CROSSARCH="${2:-false}" printf "CROSSARCH=%s\n" "${CROSSARCH}" +# Optional FP16 query library; it never changes libjvector's dependencies. +ONEDNN="${3:-false}" +case "${ONEDNN}" in + true) ONEDNN_FEATURE=enabled ;; + false) ONEDNN_FEATURE=disabled ;; + *) echo "ERROR: oneDNN flag must be true or false."; exit 1 ;; +esac + mkdir -p "${RESOURCES_DIR}" # target/meson-build/ is under target/, which IS cleaned by mvn clean. # src/main/resources/ is intentionally NOT written to any more. @@ -130,7 +138,7 @@ build_native() { meson setup "${BUILD_DIR}" "${NATIVE_DIR}" \ "${setup_options[@]}" \ - --buildtype="${BUILDTYPE}" + --buildtype="${BUILDTYPE}" -Donednn="${ONEDNN_FEATURE}" else # ---- Cross-compile build ----------------------------------------------- # Only x86_64 <-> aarch64 is supported. @@ -151,7 +159,7 @@ build_native() { meson setup "${BUILD_DIR}" "${NATIVE_DIR}" \ "${setup_options[@]}" \ --cross-file "${CROSS_FILE}" \ - --buildtype="${BUILDTYPE}" + --buildtype="${BUILDTYPE}" -Donednn="${ONEDNN_FEATURE}" fi meson compile -C "${BUILD_DIR}" @@ -164,6 +172,17 @@ build_native() { fi cp "${SOFILE}" "${OUT_SO}" printf "Built: %s\n" "${OUT_SO}" + + local FP16_OUT="${RESOURCES_DIR}/libjvector_ash_fp16-${ARCH}.so" + if [ "${ONEDNN}" == "true" ]; then + cp "${BUILD_DIR}/libjvector_ash_fp16.so" "${FP16_OUT}" + # License texts travel with the optional static dependency in either jar. + cp "${NATIVE_DIR}/subprojects/onednn/LICENSE" "${RESOURCES_DIR}/oneDNN-LICENSE.txt" + cp "${NATIVE_DIR}/subprojects/onednn/THIRD-PARTY-PROGRAMS" "${RESOURCES_DIR}/oneDNN-THIRD-PARTY.txt" + else + # A later build without the add-on must not package a stale enabled build. + rm -f "${FP16_OUT}" + fi } # --------------------------------------------------------------------------- @@ -178,6 +197,15 @@ else exit 1 fi +if [ "${ONEDNN}" == "false" ]; then + # Maven copies resources without removing old files from target/classes. + # Remove only this optional component from both resource locations. + for resource_dir in "${RESOURCES_DIR}" "${MODULE_ROOT}/target/classes"; do + rm -f "${resource_dir}"/libjvector_ash_fp16-*.so \ + "${resource_dir}/oneDNN-LICENSE.txt" "${resource_dir}/oneDNN-THIRD-PARTY.txt" + done +fi + # Always build the native arch. build_native "${HOST_ARCH}" diff --git a/jvector-native/src/main/native/src/jvector_ash_fp16.cpp b/jvector-native/src/main/native/src/jvector_ash_fp16.cpp new file mode 100644 index 000000000..c75ac9639 --- /dev/null +++ b/jvector-native/src/main/native/src/jvector_ash_fp16.cpp @@ -0,0 +1,198 @@ +/* + * 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. + */ +#include "jvector_ash_fp16.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +// Search callers own parallelism. This library must never start a nested pool. +static_assert(DNNL_CPU_RUNTIME == DNNL_RUNTIME_SEQ, + "ASH FP16 requires the sequential oneDNN CPU runtime"); + +static_assert(offsetof(jvector_ash_fp16_result, message) == sizeof(int32_t)); +static_assert(sizeof(jvector_ash_fp16_result) == sizeof(int32_t) + 256); + +namespace { +using namespace dnnl; +std::atomic next_id{1}; +int set_error(jvector_ash_fp16_result* result, int status, const char* message) noexcept { + result->status = status; + std::snprintf(result->message, sizeof(result->message), "%s", message); + return status; +} + +bool representable(const float* values, size_t count) noexcept { + for (size_t i = 0; i < count; ++i) { + if (!std::isfinite(values[i]) || std::fabs(values[i]) > 65504.0f) return false; + } + return true; +} + +struct Unsupported : std::runtime_error { using std::runtime_error::runtime_error; }; + +struct Context { + const uint64_t id = next_id.fetch_add(1, std::memory_order_relaxed); + const int rows, columns; + engine cpu{engine::kind::cpu, 0}; + matmul::primitive_desc descriptor; + matmul operation; + memory weights; + uint64_t weight_bytes = 0; + + Context(const float* matrix, int r, int c) : rows(r), columns(c) { + // A is row-major [rows][columns]. Its transpose is the [columns][rows] + // right operand, represented by strides instead of a second FP32 copy. + const memory::dims shape{columns, rows}, strides{1, columns}; + auto source = memory(memory::desc(shape, memory::data_type::f32, strides), + cpu, const_cast(matrix)); + auto half = memory(memory::desc(shape, memory::data_type::f16, strides), cpu); + stream setup(cpu); + reorder(source, half).execute(setup, source, half); + setup.wait(); + + primitive_attr attributes; + attributes.set_scratchpad_mode(scratchpad_mode::user); + attributes.set_fpmath_mode(fpmath_mode::strict); + attributes.set_accumulation_mode(accumulation_mode::strict); + descriptor = matmul::primitive_desc(cpu, + memory::desc({1, columns}, memory::data_type::f16, memory::format_tag::ab), + memory::desc(shape, memory::data_type::f16, memory::format_tag::any), + memory::desc({1, rows}, memory::data_type::f32, memory::format_tag::ab), + attributes); + if (std::strncmp(descriptor.impl_info_str(), "ref", 3) == 0) + throw Unsupported("No optimized oneDNN FP16 kernel supports this model"); + operation = matmul(descriptor); + if (descriptor.weights_desc() == half.get_desc()) weights = std::move(half); + else { + weights = memory(descriptor.weights_desc(), cpu); + reorder(half, weights).execute(setup, half, weights); + setup.wait(); + } + weight_bytes = descriptor.weights_desc().get_size(); + // Only the prepared representation survives construction. In particular, + // an additional unneeded copy of the FP16 matrix is not retained. + } +}; + +// Calling-thread state owns scratch and library handles, never a Context or its +// weights. Switching models replaces this state; it cannot keep a model alive. +struct Workspace { + uint64_t context_id = 0; + std::unique_ptr execution; + memory source_f32, source_f16, output, scratch; + reorder conversion; + + void initialize(const Context& context, const float* input, float* out) { + // Invalidate old state first: a failed allocation while switching models + // must not leave the previous id paired with partially replaced buffers. + context_id = 0; + execution = std::make_unique(context.cpu); + source_f32 = memory(memory::desc({1, context.columns}, memory::data_type::f32, + memory::format_tag::ab), context.cpu, const_cast(input)); + source_f16 = memory(context.descriptor.src_desc(), context.cpu); + output = memory(context.descriptor.dst_desc(), context.cpu, out); + scratch = memory(context.descriptor.scratchpad_desc(), context.cpu); + conversion = reorder(source_f32, source_f16); + context_id = context.id; // Publish only after every allocation succeeds. + } +}; +thread_local Workspace workspace; +} + +extern "C" { +int jvector_ash_fp16_abi(void) { return 1; } + +void* jvector_ash_fp16_create(const float* matrix, int rows, int columns, jvector_ash_fp16_result* result) { + if (!result) return nullptr; + result->message[0] = '\0'; + result->status = JV_ASH_FP16_INVALID_ARGUMENT; + if (!matrix || rows <= 0 || columns <= 0 + || uint64_t(rows) * uint64_t(columns) > std::numeric_limits::max() / sizeof(float)) { + set_error(result, result->status, "Invalid matrix pointer or dimensions"); return nullptr; + } + if (!representable(matrix, size_t(rows) * size_t(columns))) { + set_error(result, JV_ASH_FP16_RANGE, "Projection matrix is outside the finite FP16 range"); + return nullptr; + } + try { + auto prepared = std::make_unique(matrix, rows, columns); + result->status = JV_ASH_FP16_OK; + return prepared.release(); + } catch (const Unsupported& e) { + set_error(result, JV_ASH_FP16_UNSUPPORTED, e.what()); + } catch (const dnnl::error& e) { + set_error(result, e.status == dnnl_unimplemented ? JV_ASH_FP16_UNSUPPORTED + : e.status == dnnl_out_of_memory ? JV_ASH_FP16_NO_MEMORY + : JV_ASH_FP16_ERROR, e.what()); + } catch (const std::bad_alloc& e) { + set_error(result, JV_ASH_FP16_NO_MEMORY, e.what()); + } catch (const std::exception& e) { + set_error(result, JV_ASH_FP16_ERROR, e.what()); + } catch (...) { set_error(result, JV_ASH_FP16_ERROR, "Unknown projection initialization failure"); } + return nullptr; +} + +void jvector_ash_fp16_destroy(void* pointer) { delete static_cast(pointer); } + +uint64_t jvector_ash_fp16_bytes(const void* pointer) { + return pointer ? static_cast(pointer)->weight_bytes : 0; +} + +int jvector_ash_fp16_project(void* pointer, const float* input, float* output, + jvector_ash_fp16_result* result) { + if (!result) return JV_ASH_FP16_INVALID_ARGUMENT; + result->message[0] = '\0'; + result->status = JV_ASH_FP16_OK; + if (!pointer || !input || !output || input == output) + return set_error(result, JV_ASH_FP16_INVALID_ARGUMENT, "Invalid or aliased projection buffers"); + auto& context = *static_cast(pointer); + if (!representable(input, context.columns)) + return set_error(result, JV_ASH_FP16_RANGE, "Query is outside the finite FP16 range"); + try { + auto& buffers = workspace; + if (buffers.context_id != context.id) buffers.initialize(context, input, output); + buffers.source_f32.set_data_handle(const_cast(input)); + buffers.output.set_data_handle(output); + // Stack argument arrays avoid the allocating C++ map overloads. + const dnnl_exec_arg_t conversion[] = { + {DNNL_ARG_FROM, buffers.source_f32.get()}, {DNNL_ARG_TO, buffers.source_f16.get()}}; + auto status = dnnl_primitive_execute(buffers.conversion.get(), + buffers.execution->get(), 2, conversion); + if (status != dnnl_success) return set_error(result, status == dnnl_out_of_memory + ? JV_ASH_FP16_NO_MEMORY : JV_ASH_FP16_ERROR, "FP16 input conversion failed"); + const dnnl_exec_arg_t arguments[] = {{DNNL_ARG_SRC, buffers.source_f16.get()}, + {DNNL_ARG_WEIGHTS, context.weights.get()}, {DNNL_ARG_DST, buffers.output.get()}, + {DNNL_ARG_SCRATCHPAD, buffers.scratch.get()}}; + status = dnnl_primitive_execute(context.operation.get(), buffers.execution->get(), 4, arguments); + if (status != dnnl_success) return set_error(result, status == dnnl_out_of_memory + ? JV_ASH_FP16_NO_MEMORY : JV_ASH_FP16_ERROR, "FP16 projection execution failed"); + buffers.execution->wait(); + return JV_ASH_FP16_OK; + } catch (const dnnl::error& e) { return set_error(result, e.status == dnnl_out_of_memory + ? JV_ASH_FP16_NO_MEMORY : JV_ASH_FP16_ERROR, e.what()); } + catch (const std::bad_alloc& e) { return set_error(result, JV_ASH_FP16_NO_MEMORY, e.what()); } + catch (const std::exception& e) { return set_error(result, JV_ASH_FP16_ERROR, e.what()); } + catch (...) { return set_error(result, JV_ASH_FP16_ERROR, "Unknown projection execution failure"); } +} + +} diff --git a/jvector-native/src/main/native/src/jvector_ash_fp16.h b/jvector-native/src/main/native/src/jvector_ash_fp16.h new file mode 100644 index 000000000..3d5cfdf4f --- /dev/null +++ b/jvector-native/src/main/native/src/jvector_ash_fp16.h @@ -0,0 +1,62 @@ +/* + * 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. + */ +#pragma once +#include + +#if defined(_WIN32) +#define JV_ASH_FP16_EXPORT __declspec(dllexport) +#else +#define JV_ASH_FP16_EXPORT __attribute__((visibility("default"))) +#endif + +#ifdef __cplusplus +extern "C" { +#endif + +enum jvector_ash_fp16_status { + JV_ASH_FP16_OK = 0, + JV_ASH_FP16_UNSUPPORTED = 1, + JV_ASH_FP16_INVALID_ARGUMENT = 2, + JV_ASH_FP16_RANGE = 3, + JV_ASH_FP16_NO_MEMORY = 4, + JV_ASH_FP16_ERROR = 5 +}; + +/* Caller-owned status storage avoids a second, thread-affine last-error call. + * In particular, diagnostics remain correct if a Java virtual thread migrates. */ +typedef struct jvector_ash_fp16_result { + int status; + char message[256]; +} jvector_ash_fp16_result; + +JV_ASH_FP16_EXPORT int jvector_ash_fp16_abi(void); +/* Copies a row-major [rows][columns] FP32 matrix and prepares immutable FP16 + * weights. The caller retains its input; result is required and always written. + * Successful contexts may be used concurrently, but must not be destroyed while + * a call is in flight. No C++ exception crosses this ABI. */ +JV_ASH_FP16_EXPORT void* jvector_ash_fp16_create(const float* matrix, int rows, + int columns, jvector_ash_fp16_result* result); +JV_ASH_FP16_EXPORT void jvector_ash_fp16_destroy(void* context); +/* Retained packed weight bytes, excluding per-calling-thread workspace. */ +JV_ASH_FP16_EXPORT uint64_t jvector_ash_fp16_bytes(const void* context); +/* input has columns floats, output has rows floats; neither may alias the other. + * Finite operands must be within the FP16 finite range. Output is FP32. */ +JV_ASH_FP16_EXPORT int jvector_ash_fp16_project(void* context, const float* input, + float* output, jvector_ash_fp16_result* result); + +#ifdef __cplusplus +} +#endif diff --git a/jvector-native/src/main/native/subprojects/.gitignore b/jvector-native/src/main/native/subprojects/.gitignore new file mode 100644 index 000000000..da926c526 --- /dev/null +++ b/jvector-native/src/main/native/subprojects/.gitignore @@ -0,0 +1,3 @@ +/onednn/ +/packagecache/ +/.wraplock diff --git a/jvector-native/src/main/native/subprojects/onednn.wrap b/jvector-native/src/main/native/subprojects/onednn.wrap new file mode 100644 index 000000000..bc6fee9f6 --- /dev/null +++ b/jvector-native/src/main/native/subprojects/onednn.wrap @@ -0,0 +1,22 @@ +# +# 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. +# + +[wrap-git] +directory = onednn +url = https://github.com/uxlfoundation/oneDNN.git +revision = 18378c8475643c4172fe059fefba855b14c07345 +depth = 1 +method = cmake diff --git a/jvector-native/src/main/native/tests/test_ash_fp16.cpp b/jvector-native/src/main/native/tests/test_ash_fp16.cpp new file mode 100644 index 000000000..b5a2e3423 --- /dev/null +++ b/jvector-native/src/main/native/tests/test_ash_fp16.cpp @@ -0,0 +1,84 @@ +/* + * 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. + */ +#include "jvector_ash_fp16.h" +#include +#include +#include +#include +#include +#include + +TEST(AshFp16, InvalidArgumentsDoNotCrossTheAbi) { + const float matrix[] = {1}; + jvector_ash_fp16_result result{}; + EXPECT_EQ(jvector_ash_fp16_abi(), 1); + EXPECT_EQ(jvector_ash_fp16_create(nullptr, 1, 1, &result), nullptr); + EXPECT_EQ(result.status, JV_ASH_FP16_INVALID_ARGUMENT); + EXPECT_EQ(jvector_ash_fp16_create(matrix, 0, 1, &result), nullptr); + EXPECT_EQ(result.status, JV_ASH_FP16_INVALID_ARGUMENT); + EXPECT_EQ(jvector_ash_fp16_create(matrix, 1, -1, &result), nullptr); + EXPECT_EQ(jvector_ash_fp16_create(matrix, 1, 1, nullptr), nullptr); + EXPECT_EQ(jvector_ash_fp16_project(nullptr, nullptr, nullptr, &result), JV_ASH_FP16_INVALID_ARGUMENT); + EXPECT_EQ(jvector_ash_fp16_bytes(nullptr), 0); + jvector_ash_fp16_destroy(nullptr); +} + +TEST(AshFp16, RejectsMatrixRangeBeforeCreatingContext) { + for (float value : {std::numeric_limits::infinity(), + std::numeric_limits::quiet_NaN(), 65505.0f}) { + jvector_ash_fp16_result result{}; + EXPECT_EQ(jvector_ash_fp16_create(&value, 1, 1, &result), nullptr); + EXPECT_EQ(result.status, JV_ASH_FP16_RANGE); + } +} + +TEST(AshFp16, OddDimensionsConcurrentUseAndModelSwitching) { + const float matrix[] = {.25f, -.5f, .75f, -.125f, .5f, .25f}; + jvector_ash_fp16_result result{}; + using Owner = std::unique_ptr; + Owner a(jvector_ash_fp16_create(matrix, 2, 3, &result), &jvector_ash_fp16_destroy); + if (result.status == JV_ASH_FP16_UNSUPPORTED) GTEST_SKIP() << result.message; + ASSERT_EQ(result.status, JV_ASH_FP16_OK) << result.message; + ASSERT_NE(a, nullptr); + EXPECT_GT(jvector_ash_fp16_bytes(a.get()), 0); + const float second[] = {2, -1, .5f}; + Owner b(jvector_ash_fp16_create(second, 1, 3, &result), &jvector_ash_fp16_destroy); + ASSERT_EQ(result.status, JV_ASH_FP16_OK); + std::vector> workers; + for (int thread = 0; thread < 4; ++thread) workers.push_back(std::async(std::launch::async, [&] { + const float input[] = {1, 1, 1}; + float out[2]{}; + jvector_ash_fp16_result call_result{}; + for (int repeat = 0; repeat < 25; ++repeat) { + EXPECT_EQ(jvector_ash_fp16_project(a.get(), input, out, &call_result), JV_ASH_FP16_OK); + EXPECT_FLOAT_EQ(out[0], .5f); + EXPECT_FLOAT_EQ(out[1], .625f); + EXPECT_EQ(jvector_ash_fp16_project(b.get(), input, out, &call_result), JV_ASH_FP16_OK); + EXPECT_FLOAT_EQ(out[0], 1.5f); + } + })); + for (auto& worker : workers) worker.get(); + float out[2]; + const float input[] = {1, 1, 1}; + EXPECT_EQ(jvector_ash_fp16_project(a.get(), nullptr, out, &result), JV_ASH_FP16_INVALID_ARGUMENT); + EXPECT_EQ(jvector_ash_fp16_project(a.get(), input, nullptr, &result), JV_ASH_FP16_INVALID_ARGUMENT); + EXPECT_EQ(jvector_ash_fp16_project(a.get(), out, out, &result), JV_ASH_FP16_INVALID_ARGUMENT); + for (float value : {std::numeric_limits::infinity(), + std::numeric_limits::quiet_NaN(), 65505.0f}) { + float bad[] = {value, 1, 1}; + EXPECT_EQ(jvector_ash_fp16_project(a.get(), bad, out, &result), JV_ASH_FP16_RANGE); + } +} From 514d45d6350da957784ab604a3340163169f7d36 Mon Sep 17 00:00:00 2001 From: Ted Willke Date: Sat, 31 Oct 2026 15:57:19 +0000 Subject: [PATCH 106/243] ash: default native query projection to FP16 with FP32 fallback --- .../jvector/quantization/ASHScorer.java | 2 +- .../jvector/quantization/ASHVectors.java | 4 +- .../quantization/AsymmetricHashing.java | 25 +- .../jvector/vector/VectorUtilSupport.java | 18 ++ .../example/ASHNeighborhoodValidator.java | 21 +- .../vector/NativeASHFP16Projection.java | 188 ++++++++++++ .../jvector/vector/NativeASHProjection.java | 59 +++- .../jvector/vector/cnative/LibraryLoader.java | 19 +- .../vector/NativeASHFP16ProjectionTest.java | 289 ++++++++++++++++++ .../vector/NativeASHPreparationTest.java | 13 + .../vector/cnative/LibraryLoaderTest.java | 46 +++ .../quantization/TestASHSymmetricScorer.java | 13 + 12 files changed, 667 insertions(+), 30 deletions(-) create mode 100644 jvector-native/src/main/java/io/github/jbellis/jvector/vector/NativeASHFP16Projection.java create mode 100644 jvector-native/src/test/java/io/github/jbellis/jvector/vector/NativeASHFP16ProjectionTest.java create mode 100644 jvector-native/src/test/java/io/github/jbellis/jvector/vector/cnative/LibraryLoaderTest.java diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHScorer.java b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHScorer.java index edadbf839..513014987 100644 --- a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHScorer.java +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHScorer.java @@ -262,7 +262,7 @@ QueryPrecompute precomputeQuery(VectorFloat query) { // Compute Aq once per query: // qProj[j] = final float[] qProj = new float[d]; - ash.stiefelTransform.project(qArr, qProj); + ash.stiefelTransform.projectQuery(qArr, qProj); float sumQProj = 0f; for (float value : qProj) sumQProj += value; diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHVectors.java b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHVectors.java index e45091978..cd75341b8 100644 --- a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHVectors.java +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHVectors.java @@ -531,7 +531,7 @@ private final class LutASHBlockScorer implements ASHBlockScorer { float[] q = new float[ash.originalDimension]; for (int i = 0; i < q.length; i++) q[i] = query.get(i); float[] projected = new float[d]; - ash.stiefelTransform.project(q, projected); + ash.stiefelTransform.projectQuery(q, projected); lut = new float[Math.multiplyExact(groups, 16)]; FusedASHLayout.buildQueryLut(projected, d, ash.bitsPerDimension, lut); dotQMu = new float[ash.landmarkCount]; @@ -887,7 +887,7 @@ private QueryPrecompute precomputeQuery(VectorFloat query) { // Aq final float[] qProj = new float[d]; - ash.stiefelTransform.project(qArr, qProj); + ash.stiefelTransform.projectQuery(qArr, qProj); float sumQProj = 0f; for (float value : qProj) sumQProj += value; diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/AsymmetricHashing.java b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/AsymmetricHashing.java index 89586e8e8..2f3ecb371 100644 --- a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/AsymmetricHashing.java +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/AsymmetricHashing.java @@ -1288,7 +1288,27 @@ public static final class StiefelTransform { // Prepared once per immutable model, including models loaded from disk. private final io.github.jbellis.jvector.vector.VectorUtilSupport.AshProjection projection; - /** Projects one dense query using this model's prepared backend representation. */ + // Prepared only when this model is used for queries. Encoding-only work + // must not allocate an extra matrix or change its arithmetic precision. + private volatile io.github.jbellis.jvector.vector.VectorUtilSupport.AshProjection queryProjection; + + /** Projects a search query; encoding and training continue through project(). */ + public void projectQuery(float[] query, float[] out) { + queryProjection().project(query, out); + } + + private io.github.jbellis.jvector.vector.VectorUtilSupport.AshProjection queryProjection() { + var ready = queryProjection; + if (ready == null) { + synchronized (this) { + ready = queryProjection; + if (ready == null) queryProjection = ready = projection.forQueryScoring(); + } + } + return ready; + } + + /** Projects one vector with the original FP32 representation, including encoding/training. */ public void project(float[] query, float[] out) { projection.project(query, out); } @@ -2529,6 +2549,9 @@ public long ramBytesUsed() { bytes += estimateArray2DBytes(stiefelTransform.AData); bytes += estimateArray2DBytes(stiefelTransform.AFloat); bytes += stiefelTransform.projection.ramBytesUsed(); + var queryProjection = stiefelTransform.queryProjection; + if (queryProjection != null && queryProjection != stiefelTransform.projection) + bytes += queryProjection.ramBytesUsed(); } return bytes; diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/vector/VectorUtilSupport.java b/jvector-base/src/main/java/io/github/jbellis/jvector/vector/VectorUtilSupport.java index a403e4ac6..413a5d38f 100644 --- a/jvector-base/src/main/java/io/github/jbellis/jvector/vector/VectorUtilSupport.java +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/vector/VectorUtilSupport.java @@ -458,6 +458,24 @@ interface AshProjection { /** Writes the full matrix-vector product; input and output must not alias. */ void project(float[] query, float[] out); + /** + * Returns an immutable view for query scoring, which may use lower operand + * precision. Encoding/training must continue to use this original object. + * The caller caches the returned view for the model's lifetime. Providers + * without a query-specific representation return this same object. + */ + default AshProjection forQueryScoring() { + String mode = System.getProperty("jvector.ash.queryProjection", "auto"); + switch (mode) { + case "auto": + case "fp32": return this; + case "fp16": throw new UnsupportedOperationException( + "This provider does not support FP16 ASH query projection"); + default: throw new IllegalArgumentException( + "jvector.ash.queryProjection must be auto, fp32 or fp16: " + mode); + } + } + /** Additional backing storage, excluding the caller-owned matrix. */ default long ramBytesUsed() { return 0L; } } diff --git a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/ASHNeighborhoodValidator.java b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/ASHNeighborhoodValidator.java index fae7ef5f3..98bc6b4f2 100644 --- a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/ASHNeighborhoodValidator.java +++ b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/ASHNeighborhoodValidator.java @@ -22,8 +22,9 @@ import java.util.Locale; /** - * Untimed decoded-component oracle, independent of the scoring LUT and native implementation. - * It projects in double precision and checks the same serialized headers consumed by the graph. + * Untimed decoded-component oracle, independent of the scoring LUT and block-scoring implementation. + * It uses the model's selected query precision and checks the serialized graph headers. + * Projection arithmetic is covered separately by the provider's FP32 and FP16 oracle tests. * The tolerance permits FP32 reduction-order differences, without introducing LUT quantization. */ final class ASHNeighborhoodValidator { @@ -35,14 +36,16 @@ static void validate(ASHNeighborhoodFixture fixture) { ASHNeighborhoodBenchmark.positive("jvector.bench.validationNeighborhoods", 1024)); for (var query : fixture.queries) { var scorer = new ASHNeighborhoodScorer(fixture.ash, query, fixture.capacity, fixture.blockSize); - // Double-precision, decoded-component oracle independent of the nibble LUT kernel. - double[] projected = new double[fixture.ash.quantizedDim]; + // Share the query precision, then independently decode and accumulate + // components in double precision instead of using the nibble LUT kernel. + float[] input = new float[query.length()]; double dotMu = 0; - for (int d = 0; d < query.length(); d++) { - dotMu += (double) query.get(d) * fixture.ash.landmarks[0].get(d); - for (int j = 0; j < projected.length; j++) - projected[j] += (double) query.get(d) * fixture.ash.stiefelTransform.AFloat[j][d]; + for (int d = 0; d < input.length; d++) { + input[d] = query.get(d); + dotMu += (double) input[d] * fixture.ash.landmarks[0].get(d); } + float[] projected = new float[fixture.ash.quantizedDim]; + fixture.ash.stiefelTransform.projectQuery(input, projected); float[] scores = new float[fixture.capacity]; for (int n = 0; n < neighborhoods; n++) { int index = (int) ((long) n * fixture.neighborhoods.length / neighborhoods); @@ -58,7 +61,7 @@ static void validate(ASHNeighborhoodFixture fixture) { int nibble = FusedASHLayout.getPackedNibble(packed, offset, lane, d / perNibble, fixture.blockSize); int field = nibble >>> ((d % perNibble) * bits) & ((1 << bits) - 1); - dot += projected[d] * FusedASHLayout.decodeProjectionComponent(field, bits); + dot += (double) projected[d] * FusedASHLayout.decodeProjectionComponent(field, bits); } float expected = ASHScorer.toSimilarity((float) (FusedASHLayout.readScale(packed, offset, lane, projected.length, bits, fixture.blockSize) diff --git a/jvector-native/src/main/java/io/github/jbellis/jvector/vector/NativeASHFP16Projection.java b/jvector-native/src/main/java/io/github/jbellis/jvector/vector/NativeASHFP16Projection.java new file mode 100644 index 000000000..12ef824ac --- /dev/null +++ b/jvector-native/src/main/java/io/github/jbellis/jvector/vector/NativeASHFP16Projection.java @@ -0,0 +1,188 @@ +/* + * 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.cnative.LibraryLoader; +import java.lang.foreign.Arena; +import java.lang.foreign.FunctionDescriptor; +import java.lang.foreign.Linker; +import java.lang.foreign.MemoryLayout; +import java.lang.foreign.MemorySegment; +import java.lang.foreign.SymbolLookup; +import java.lang.invoke.MethodHandle; +import java.util.Objects; + +import static java.lang.foreign.ValueLayout.ADDRESS; +import static java.lang.foreign.ValueLayout.JAVA_INT; +import static java.lang.foreign.ValueLayout.JAVA_BYTE; +import static java.lang.foreign.ValueLayout.JAVA_LONG; + +/** + * Immutable, model-owned FP16 query matrix with strict FP32 accumulation/output. + * The optional library uses sequential oneDNN; parallelism belongs to search + * callers. A projection shares the Highway adapter's calling-thread staging, + * while native conversion storage and scratch are reused by the calling thread. + * Neither thread-local cache retains the model or its matrix. + * + *

    Only query-scoring views use this representation. Encoding retains Highway + * FP32. Each call projects one vector on the calling thread. + */ +final class NativeASHFP16Projection implements VectorUtilSupport.AshProjection { + private static final int OK = 0, UNSUPPORTED = 1, INVALID_ARGUMENT = 2, + RANGE = 3, NO_MEMORY = 4; + + private static final MemoryLayout RESULT_LAYOUT = MemoryLayout.structLayout( + JAVA_INT, MemoryLayout.sequenceLayout(256, JAVA_BYTE)); + + /** Loaded only if a query actually asks for an FP16 view. */ + private static final class Bindings { + static final Bindings INSTANCE = load(); + final MethodHandle create, destroy, project, bytes; + + private Bindings(SymbolLookup lookup) throws Throwable { + MethodHandle abi = bind(lookup, "jvector_ash_fp16_abi", FunctionDescriptor.of(JAVA_INT)); + if ((int) abi.invokeExact() != 1) + throw new UnsatisfiedLinkError("Unsupported ASH FP16 library ABI"); + create = bind(lookup, "jvector_ash_fp16_create", + FunctionDescriptor.of(ADDRESS, ADDRESS, JAVA_INT, JAVA_INT, ADDRESS)); + destroy = bind(lookup, "jvector_ash_fp16_destroy", FunctionDescriptor.ofVoid(ADDRESS)); + project = bind(lookup, "jvector_ash_fp16_project", + FunctionDescriptor.of(JAVA_INT, ADDRESS, ADDRESS, ADDRESS, ADDRESS)); + bytes = bind(lookup, "jvector_ash_fp16_bytes", FunctionDescriptor.of(JAVA_LONG, ADDRESS)); + } + + private static MethodHandle bind(SymbolLookup lookup, String name, FunctionDescriptor descriptor) { + MemorySegment symbol = lookup.find(name) + .orElseThrow(() -> new UnsatisfiedLinkError("Missing ASH FP16 symbol: " + name)); + return Linker.nativeLinker().downcallHandle(symbol, descriptor); + } + + private static Bindings load() { + if (!LibraryLoader.loadAshFp16()) return null; + try { + return new Bindings(SymbolLookup.loaderLookup()); + } catch (UnsatisfiedLinkError | IllegalCallerException | SecurityException + | UnsupportedOperationException unavailable) { + return null; + } catch (RuntimeException | Error failure) { + throw failure; + } catch (Throwable failure) { + throw new AssertionError("Unexpected ASH FP16 binding failure", failure); + } + } + } + + private final MemorySegment context; + private final int rows, columns; + private final long weightBytes; + // Present only in automatic mode, for queries outside the finite FP16 range. + private final VectorUtilSupport.AshProjection fallback; + + private NativeASHFP16Projection(MemorySegment context, int rows, int columns, + long weightBytes, VectorUtilSupport.AshProjection fallback) { + this.context = context; + this.rows = rows; + this.columns = columns; + this.weightBytes = weightBytes; + this.fallback = fallback; + } + + /** Model initialization only; returns fallback for genuinely unsupported automatic selection. */ + static VectorUtilSupport.AshProjection prepare(MemorySegment matrix, int rows, int columns, + VectorUtilSupport.AshProjection fallback, + boolean required) { + Bindings bindings = Bindings.INSTANCE; + if (bindings == null) { + if (!required) return fallback; + throw new UnsupportedOperationException("ASH FP16 query projection library is unavailable; " + + "build with -Dnative.onednn=true or select fp32/auto"); + } + try (Arena setup = Arena.ofConfined()) { + MemorySegment status = setup.allocate(RESULT_LAYOUT); + MemorySegment pointer = (MemorySegment) bindings.create.invokeExact(matrix, rows, columns, status); + int code = status.get(JAVA_INT, 0); + if (pointer.address() == 0) { + if (!required && (code == UNSUPPORTED || code == RANGE)) return fallback; + throw failure(code, status); + } + if (code != OK) { + bindings.destroy.invokeExact(pointer); + throw failure(code, status); + } + long bytes; + MemorySegment owned; + try { + bytes = (long) bindings.bytes.invokeExact(pointer); + owned = pointer.reinterpret(1, Arena.ofAuto(), NativeASHFP16Projection::destroy); + } catch (Throwable failure) { + bindings.destroy.invokeExact(pointer); + throw failure; + } + return new NativeASHFP16Projection(owned, rows, columns, bytes, required ? null : fallback); + } catch (RuntimeException | Error failure) { + throw failure; + } catch (Throwable failure) { + throw new AssertionError("Unexpected ASH FP16 initialization failure", failure); + } + } + + private static void destroy(MemorySegment pointer) { + try { + Bindings.INSTANCE.destroy.invokeExact(pointer); + } catch (Throwable failure) { + throw new AssertionError("Unexpected ASH FP16 cleanup failure", failure); + } + } + + private static RuntimeException failure(int code, MemorySegment result) { + String message = "ASH FP16 projection: " + result.getString(JAVA_INT.byteSize()); + if (code == NO_MEMORY) throw new OutOfMemoryError(message); + if (code == UNSUPPORTED) return new UnsupportedOperationException(message); + if (code == INVALID_ARGUMENT || code == RANGE) return new IllegalArgumentException(message); + return new IllegalStateException(message + " (status " + code + ")"); + } + + /** Retained packed weights; per-thread staging and oneDNN scratch are excluded. */ + @Override + public long ramBytesUsed() { return weightBytes; } + + @Override + public void project(float[] query, float[] out) { + Objects.requireNonNull(query, "query"); + Objects.requireNonNull(out, "out"); + if (query.length != columns || out.length != rows || query == out) + throw new IllegalArgumentException("ASH projection shape or aliasing mismatch"); + long inputBytes = (long) columns * Float.BYTES; + long outputBytes = (long) rows * Float.BYTES; + NativeASHProjection.Buffers buffers = NativeASHProjection.buffers(inputBytes, outputBytes); + MemorySegment.copy(MemorySegment.ofArray(query), 0, buffers.input, 0, inputBytes); + if (buffers.status.byteSize() == 0) buffers.status = Arena.ofAuto().allocate(RESULT_LAYOUT); + try { + int status = (int) Bindings.INSTANCE.project.invokeExact( + context, buffers.input, buffers.output, buffers.status); + if (status == RANGE && fallback != null) { + fallback.project(query, out); + return; + } + if (status != OK) throw failure(status, buffers.status); + } catch (RuntimeException | Error failure) { + throw failure; + } catch (Throwable failure) { + throw new AssertionError("Unexpected ASH FP16 execution failure", failure); + } + MemorySegment.copy(buffers.output, 0, MemorySegment.ofArray(out), 0, outputBytes); + } +} diff --git a/jvector-native/src/main/java/io/github/jbellis/jvector/vector/NativeASHProjection.java b/jvector-native/src/main/java/io/github/jbellis/jvector/vector/NativeASHProjection.java index 4503e82e1..67ab75262 100644 --- a/jvector-native/src/main/java/io/github/jbellis/jvector/vector/NativeASHProjection.java +++ b/jvector-native/src/main/java/io/github/jbellis/jvector/vector/NativeASHProjection.java @@ -30,7 +30,7 @@ /** * Immutable native row-major matrix for one complete ASH query projection per downcall. * The automatically managed arena lives with the model; it is not thread confined. - * Query/output buffers are local to each invocation, so projections may run concurrently. + * Native query/output staging is reused per calling thread, so projections may run concurrently. * Large projections use ordinary FFM and copy only the small query and result, allowing * safepoints during the dense multiply. Matrix storage is never copied per query. */ @@ -47,6 +47,31 @@ final class NativeASHProjection implements VectorUtilSupport.AshProjection { private static final MethodHandle ORDINARY = ADDRESS_OF_KERNEL == null ? null : Linker.nativeLinker().downcallHandle(ADDRESS_OF_KERNEL, DESCRIPTOR); + // Calling-thread storage is shared across models and FP32/FP16 views. It + // holds no model reference, grows only when necessary and dies with the thread. + private static final ThreadLocal BUFFERS = ThreadLocal.withInitial(Buffers::new); + + static final class Buffers { + MemorySegment input = MemorySegment.NULL, output = MemorySegment.NULL; + // Allocated only by the FP16 view; carries errors without native thread affinity. + MemorySegment status = MemorySegment.NULL; + + void ensure(long inputBytes, long outputBytes) { + if (input.byteSize() >= inputBytes && output.byteSize() >= outputBytes) return; + long inCapacity = Math.addExact(Math.max(inputBytes, input.byteSize()), 63) & ~63L; + long outCapacity = Math.addExact(Math.max(outputBytes, output.byteSize()), 63) & ~63L; + MemorySegment storage = Arena.ofAuto().allocate(Math.addExact(inCapacity, outCapacity), 64); + input = storage.asSlice(0, inCapacity); + output = storage.asSlice(inCapacity, outCapacity); + } + } + + static Buffers buffers(long inputBytes, long outputBytes) { + Buffers buffers = BUFFERS.get(); + buffers.ensure(inputBytes, outputBytes); + return buffers; + } + private final MemorySegment matrix; private final int rows; private final int columns; @@ -57,11 +82,11 @@ final class NativeASHProjection implements VectorUtilSupport.AshProjection { Objects.requireNonNull(source, "matrix"); if (!isAvailable()) throw new UnsupportedOperationException("Native library does not provide " + SYMBOL); rows = source.length; - if (rows == 0 || source[0].length == 0) + if (rows == 0 || Objects.requireNonNull(source[0], "matrix row").length == 0) throw new IllegalArgumentException("ASH projection dimensions must be positive"); columns = source[0].length; for (float[] row : source) { - if (row.length != columns) throw new IllegalArgumentException("Ragged ASH projection"); + if (Objects.requireNonNull(row, "matrix row").length != columns) throw new IllegalArgumentException("Ragged ASH projection"); } long rowBytes = (long) columns * Float.BYTES; matrix = Arena.ofAuto().allocate(Math.multiplyExact((long) rows, rowBytes), 64); @@ -81,15 +106,25 @@ public void project(float[] query, float[] out) { if ((long) rows * columns <= MAX_CRITICAL_CELLS) { invoke(CRITICAL, MemorySegment.ofArray(query), MemorySegment.ofArray(out)); } else { - try (Arena arena = Arena.ofConfined()) { - long queryBytes = (long) columns * Float.BYTES; - MemorySegment buffers = arena.allocate(queryBytes + (long) rows * Float.BYTES, 64); - MemorySegment nativeQuery = buffers.asSlice(0, queryBytes); - MemorySegment nativeOut = buffers.asSlice(queryBytes); - MemorySegment.copy(MemorySegment.ofArray(query), 0, nativeQuery, 0, queryBytes); - invoke(ORDINARY, nativeQuery, nativeOut); - MemorySegment.copy(nativeOut, 0, MemorySegment.ofArray(out), 0, (long) rows * Float.BYTES); - } + long queryBytes = (long) columns * Float.BYTES; + long outputBytes = (long) rows * Float.BYTES; + Buffers buffers = buffers(queryBytes, outputBytes); + MemorySegment.copy(MemorySegment.ofArray(query), 0, buffers.input, 0, queryBytes); + invoke(ORDINARY, buffers.input, buffers.output); + MemorySegment.copy(buffers.output, 0, MemorySegment.ofArray(out), 0, outputBytes); + } + } + + /** Query-only selection; the original FP32 matrix and encoding path are retained. */ + @Override + public VectorUtilSupport.AshProjection forQueryScoring() { + String mode = System.getProperty("jvector.ash.queryProjection", "auto"); + switch (mode) { + case "fp32": return this; + case "auto": return NativeASHFP16Projection.prepare(matrix, rows, columns, this, false); + case "fp16": return NativeASHFP16Projection.prepare(matrix, rows, columns, this, true); + default: throw new IllegalArgumentException( + "jvector.ash.queryProjection must be auto, fp32 or fp16: " + mode); } } diff --git a/jvector-native/src/main/java/io/github/jbellis/jvector/vector/cnative/LibraryLoader.java b/jvector-native/src/main/java/io/github/jbellis/jvector/vector/cnative/LibraryLoader.java index 0fceb23b3..f560987ec 100644 --- a/jvector-native/src/main/java/io/github/jbellis/jvector/vector/cnative/LibraryLoader.java +++ b/jvector-native/src/main/java/io/github/jbellis/jvector/vector/cnative/LibraryLoader.java @@ -39,12 +39,16 @@ private LibraryLoader() {} * current CPU architecture, or {@code null} when the architecture is not supported. */ static String resourceNameForArch() { + return resourceNameForArch("jvector"); + } + + static String resourceNameForArch(String library) { String arch = System.getProperty("os.arch", ""); if (arch.equals("aarch64") || arch.equals("arm64")) { - return "/libjvector-aarch64.so"; + return "/lib" + library + "-aarch64.so"; } if (arch.equals("amd64") || arch.equals("x86_64")) { - return "/libjvector-x86_64.so"; + return "/lib" + library + "-x86_64.so"; } return null; } @@ -55,9 +59,14 @@ static String resourceNameForArch() { /// Returns `false` when the library is unavailable, native access is denied to this module /// (`IllegalCallerException`, e.g. with `--illegal-native-access=deny`), or a security policy /// blocks loading (`SecurityException`). The bundled copy isn't retried after access denial. - public static boolean loadJvector() { + public static boolean loadJvector() { return loadLibrary("jvector"); } + + /** Loads the optional, self-contained ASH FP16 library without changing Highway availability. */ + public static boolean loadAshFp16() { return loadLibrary("jvector_ash_fp16"); } + + private static boolean loadLibrary(String library) { try { - System.loadLibrary("jvector"); + System.loadLibrary(library); return true; } catch (UnsatisfiedLinkError e) { // ignore @@ -67,7 +76,7 @@ public static boolean loadJvector() { try { // reinventing the wheel instead of picking up deps, so we'll just use the classloader to load the library // as a resource and then copy it to a tmp directory and load it from there - String resourceName = resourceNameForArch(); + String resourceName = resourceNameForArch(library); if (resourceName == null) { return false; // unsupported architecture } diff --git a/jvector-native/src/test/java/io/github/jbellis/jvector/vector/NativeASHFP16ProjectionTest.java b/jvector-native/src/test/java/io/github/jbellis/jvector/vector/NativeASHFP16ProjectionTest.java new file mode 100644 index 000000000..a48cf2336 --- /dev/null +++ b/jvector-native/src/test/java/io/github/jbellis/jvector/vector/NativeASHFP16ProjectionTest.java @@ -0,0 +1,289 @@ +/* + * 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.quantization.AsymmetricHashing; +import io.github.jbellis.jvector.vector.cnative.LibraryLoader; +import org.apache.commons.math3.linear.Array2DRowRealMatrix; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.api.parallel.ResourceLock; + +import java.io.File; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Random; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.util.jar.JarEntry; +import java.util.jar.JarOutputStream; + +import static org.junit.jupiter.api.Assertions.*; +import static org.junit.jupiter.api.Assumptions.assumeTrue; + +/** FP16 arithmetic, query-only selection, concurrency and optional-backend compatibility. */ +@ResourceLock("java.lang.System.properties") +class NativeASHFP16ProjectionTest { + private static final String PROPERTY = "jvector.ash.queryProjection"; + + @BeforeAll + static void loadNative() { + boolean available = LibraryLoader.loadJvector(); + if (Boolean.getBoolean("jvector.test.requireFp16")) + assertTrue(available, "Highway library unavailable"); + else assumeTrue(available, "Highway library unavailable"); + } + + private static VectorUtilSupport.AshProjection view(NativeASHProjection original, String mode) { + String previous = System.getProperty(PROPERTY); + try { + if (mode == null) System.clearProperty(PROPERTY); + else System.setProperty(PROPERTY, mode); + return original.forQueryScoring(); + } finally { + if (previous == null) System.clearProperty(PROPERTY); + else System.setProperty(PROPERTY, previous); + } + } + + private static VectorUtilSupport.AshProjection required(NativeASHProjection original) { + try { return view(original, "fp16"); } + catch (UnsupportedOperationException unsupported) { + if (Boolean.getBoolean("jvector.test.requireFp16")) throw unsupported; + assumeTrue(false, unsupported.getMessage()); + throw unsupported; + } + } + + private static float half(float value) { + return Float.float16ToFloat(Float.floatToFloat16(value)); + } + + private static void checkOracle(float[][] matrix, float[] query, float[] actual, boolean fp16) { + for (int row = 0; row < matrix.length; row++) { + double expected = 0, magnitude = 0; + for (int col = 0; col < query.length; col++) { + double product = (double) (fp16 ? half(matrix[row][col]) : matrix[row][col]) + * (fp16 ? half(query[col]) : query[col]); + expected += product; + magnitude += Math.abs(product); + } + assertEquals(expected, actual[row], 2e-6 * magnitude + 1e-7, "row " + row); + } + } + + @Test + void matchesIndependentRoundedOperandOracleAcrossDimensions() { + Random random = new Random(93124); + for (int[] shape : new int[][]{{1,1},{3,7},{17,31},{65,129},{257,513},{384,768},{1536,3072}}) { + float[][] matrix = new float[shape[0]][shape[1]]; + for (float[] row : matrix) for (int i = 0; i < row.length; i++) row[i] = random.nextFloat() - .5f; + var fp32 = new NativeASHProjection(matrix); + var fp16 = required(fp32); + assertInstanceOf(NativeASHFP16Projection.class, fp16); + assertTrue(fp16.ramBytesUsed() >= (long) shape[0] * shape[1] * 2); + // Repeated calls exercise workspace reuse with different queries. + for (int call = 0; call < 17; call++) { + float[] query = new float[shape[1]], out = new float[shape[0]]; + for (int i = 0; i < query.length; i++) query[i] = random.nextFloat() - .5f; + float[] saved = query.clone(); + fp16.project(query, out); + checkOracle(matrix, query, out, true); + assertArrayEquals(saved, query); + } + assertSame(fp32, view(fp32, "fp32")); + } + } + + @Test + void halfPrecisionBoundaryRoundingMatchesTheIndependentConversion() { + var projection = required(new NativeASHProjection(new float[][]{{1}})); + float tie = 1f + Math.scalb(1f, -11); + for (float value : new float[]{0f, -0f, Float.MIN_VALUE, Math.scalb(1f, -24), + Math.scalb(1f, -14), tie, Math.nextUp(tie), -tie, 65504f, -65504f}) { + float[] out = new float[1]; + projection.project(new float[]{value}, out); + assertEquals(half(value), out[0], 0f); + } + } + + @Test + void sharedModelsSurviveConcurrentCallsModelSwitchingAndGc() throws Exception { + float[][] a = {{.1234567f, -.3333333f, .9f}, {-.5555555f, .0123456f, .25f}}; + float[][] b = {{.456789f, .234567f, -.75f}}; + var first = required(new NativeASHProjection(a)); + var second = required(new NativeASHProjection(b)); + try (var pool = Executors.newFixedThreadPool(8)) { + var tasks = new ArrayList>(); + for (int thread = 0; thread < 8; thread++) tasks.add(() -> { + float[] query = {.234567f, -.456789f, .75f}; + float[] outA = new float[2], outB = new float[1]; + for (int call = 0; call < 40; call++) { + first.project(query, outA); + second.project(query, outB); + checkOracle(a, query, outA, true); + checkOracle(b, query, outB, true); + if (call == 20) System.gc(); + } + return null; + }); + for (var result : pool.invokeAll(tasks)) result.get(); + } + } + + @Test + void virtualThreadCallsKeepTheirResultsAndDiagnostics() throws Exception { + float[][] matrix = {{.1234567f, -.3456789f}}; + var projection = required(new NativeASHProjection(matrix)); + try (var pool = Executors.newVirtualThreadPerTaskExecutor()) { + var tasks = new ArrayList>(); + for (int thread = 0; thread < 16; thread++) tasks.add(() -> { + float[] out = new float[1]; + for (int call = 0; call < 16; call++) { + var error = assertThrows(IllegalArgumentException.class, + () -> projection.project(new float[]{Float.NaN, 1}, out)); + assertTrue(error.getMessage().contains("finite FP16 range")); + float[] query = {.234567f, -.456789f}; + projection.project(query, out); + checkOracle(matrix, query, out, true); + Thread.yield(); + } + return null; + }); + for (var result : pool.invokeAll(tasks)) result.get(); + } + } + + @Test + void guardsShapesAliasesAndRangeWithoutDamagingSubsequentCalls() { + float[][] matrix = {{.1234567f, .3456789f}, {-.3333333f, .5f}}; + var fp16 = required(new NativeASHProjection(matrix)); + float[] query = {1, 2}, out = new float[2]; + assertThrows(NullPointerException.class, () -> fp16.project(null, out)); + assertThrows(NullPointerException.class, () -> fp16.project(query, null)); + assertThrows(IllegalArgumentException.class, () -> fp16.project(query, query)); + assertThrows(IllegalArgumentException.class, () -> fp16.project(new float[1], out)); + assertThrows(IllegalArgumentException.class, () -> fp16.project(query, new float[1])); + for (float invalid : new float[]{Float.NaN, Float.POSITIVE_INFINITY, 65505f}) { + Arrays.fill(out, 42f); + assertThrows(IllegalArgumentException.class, () -> fp16.project(new float[]{invalid, 1}, out)); + assertArrayEquals(new float[]{42, 42}, out); + } + fp16.project(query, out); + checkOracle(matrix, query, out, true); + } + + @Test + void defaultPrefersFp16ButFp32AndAutomaticRangeFallbackRemainAvailable() { + var original = new NativeASHProjection(new float[][]{{.25f, -.5f}}); + assertSame(original, view(original, "fp32")); + var explicit = required(original); + assertInstanceOf(NativeASHFP16Projection.class, explicit); + var automatic = view(original, null); + assertInstanceOf(NativeASHFP16Projection.class, automatic); + float[] query = {70000, 2}, out = new float[1], expected = new float[1]; + original.project(query, expected); + automatic.project(query, out); + assertArrayEquals(expected, out); + var largeMatrix = new NativeASHProjection(new float[][]{{70000}}); + assertSame(largeMatrix, view(largeMatrix, "auto")); + assertThrows(IllegalArgumentException.class, () -> view(largeMatrix, "fp16")); + assertThrows(IllegalArgumentException.class, () -> view(original, "typo")); + } + + @Test + void encodingStaysFp32BeforeAndAfterTheQueryViewIsPrepared() { + String previous = System.getProperty(PROPERTY); + try { + System.setProperty(PROPERTY, "fp16"); + double[][] w = {{.1234567, .2345678}, {-.3456789, .4567891}, {.5678912, -.6789123}}; + var transform = new AsymmetricHashing.StiefelTransform(new Array2DRowRealMatrix(w)); + float[] query = {.1234567f, -.3456789f, .9876543f}; + float[] encodingBefore = new float[2], encodingAfter = new float[2], queryOut = new float[2]; + transform.project(query, encodingBefore); + checkOracle(transform.AFloat, query, encodingBefore, false); + try { transform.projectQuery(query, queryOut); } + catch (UnsupportedOperationException unsupported) { + if (Boolean.getBoolean("jvector.test.requireFp16")) throw unsupported; + assumeTrue(false, unsupported.getMessage()); + } + checkOracle(transform.AFloat, query, queryOut, true); + assertTrue(Math.abs(encodingBefore[0] - queryOut[0]) > 1e-6f); + transform.project(query, encodingAfter); + assertArrayEquals(encodingBefore, encodingAfter); + } finally { + if (previous == null) System.clearProperty(PROPERTY); + else System.setProperty(PROPERTY, previous); + } + } + + @Test + void missingOptionalLibraryFallsBackAndExplicitFp16Fails(@TempDir Path temporary) throws Exception { + Path source = Path.of(NativeASHProjection.class.getProtectionDomain().getCodeSource().getLocation().toURI()); + assertTrue(Files.isDirectory(source)); + Path stripped = temporary.resolve("native-without-fp16.jar"); + try (var jar = new JarOutputStream(Files.newOutputStream(stripped)); var paths = Files.walk(source)) { + for (Path p : paths.filter(Files::isRegularFile).toList()) { + String name = source.relativize(p).toString().replace(File.separatorChar, '/'); + if (name.startsWith("libjvector_ash_fp16")) continue; + jar.putNextEntry(new JarEntry(name)); + Files.copy(p, jar); + jar.closeEntry(); + } + } + String raw = System.getProperty("surefire.test.class.path", System.getProperty("java.class.path")); + String cp = stripped + File.pathSeparator + String.join(File.pathSeparator, + Arrays.stream(raw.split(java.util.regex.Pattern.quote(File.pathSeparator))) + .filter(p -> !Path.of(p).toAbsolutePath().normalize().equals(source.toAbsolutePath().normalize())) + .toList()); + Path log = temporary.resolve("unavailable.log"); + Process child = new ProcessBuilder(Path.of(System.getProperty("java.home"), "bin/java").toString(), + "--add-modules=jdk.incubator.vector", "--enable-native-access=ALL-UNNAMED", + "-Djava.library.path=" + temporary, "-cp", cp, UnavailableProbe.class.getName()) + .redirectErrorStream(true).redirectOutput(log.toFile()).start(); + try { + assertTrue(child.waitFor(30, TimeUnit.SECONDS)); + assertEquals(0, child.exitValue(), Files.readString(log)); + assertTrue(Files.readString(log).contains("FP16_UNAVAILABLE_OK")); + } finally { + if (child.isAlive()) { + child.destroyForcibly(); + child.waitFor(5, TimeUnit.SECONDS); + } + } + } + + public static final class UnavailableProbe { + public static void main(String[] args) { + if (!LibraryLoader.loadJvector()) throw new AssertionError("Missing Highway fixture"); + var original = new NativeASHProjection(new float[][]{{1, 2}}); + if (view(original, "auto") != original) throw new AssertionError("Automatic fallback failed"); + try { + view(original, "fp16"); + throw new AssertionError("Explicit FP16 silently fell back"); + } catch (UnsupportedOperationException expected) { /* expected */ } + var transform = new AsymmetricHashing.StiefelTransform(new Array2DRowRealMatrix(new double[][]{{1}, {2}})); + System.setProperty(PROPERTY, "fp16"); + float[] out = new float[1]; + transform.project(new float[]{1, 1}, out); // Encoding does not require FP16. + if (out[0] != 3) throw new AssertionError("Encoding changed"); + System.out.println("FP16_UNAVAILABLE_OK"); + } + } +} diff --git a/jvector-native/src/test/java/io/github/jbellis/jvector/vector/NativeASHPreparationTest.java b/jvector-native/src/test/java/io/github/jbellis/jvector/vector/NativeASHPreparationTest.java index c27a6d953..3b0332d49 100644 --- a/jvector-native/src/test/java/io/github/jbellis/jvector/vector/NativeASHPreparationTest.java +++ b/jvector-native/src/test/java/io/github/jbellis/jvector/vector/NativeASHPreparationTest.java @@ -125,8 +125,21 @@ void integerLutHandlesOffsetsTailsAndOverflow() { } } + @Test + void defaultQueryViewPreservesProvidersWithoutReducedPrecision() { + VectorUtilSupport.AshProjection original = (query, out) -> out[0] = query[0]; + assertSame(original, original.forQueryScoring()); + float[] out = new float[1]; + original.forQueryScoring().project(new float[]{.1234567f}, out); + assertEquals(.1234567f, out[0]); + } + @Test void rejectsUnsafeInputsBeforeNativeCall() { + assertThrows(NullPointerException.class, () -> new NativeASHProjection(null)); + assertThrows(NullPointerException.class, () -> new NativeASHProjection(new float[][]{null})); + assertThrows(IllegalArgumentException.class, () -> new NativeASHProjection(new float[0][])); + assertThrows(IllegalArgumentException.class, () -> new NativeASHProjection(new float[][]{new float[0]})); var projection = new NativeASHProjection(new float[][]{{1,2},{3,4}}); float[] alias = {1,2}; assertThrows(IllegalArgumentException.class, () -> projection.project(alias,alias)); diff --git a/jvector-native/src/test/java/io/github/jbellis/jvector/vector/cnative/LibraryLoaderTest.java b/jvector-native/src/test/java/io/github/jbellis/jvector/vector/cnative/LibraryLoaderTest.java new file mode 100644 index 000000000..7f2059227 --- /dev/null +++ b/jvector-native/src/test/java/io/github/jbellis/jvector/vector/cnative/LibraryLoaderTest.java @@ -0,0 +1,46 @@ +/* + * 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.cnative; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.parallel.ResourceLock; +import static org.junit.jupiter.api.Assertions.*; + +@ResourceLock("java.lang.System.properties") +class LibraryLoaderTest { + @Test + void optionalResourcesFollowTheExistingArchitectureConvention() { + String previous = System.getProperty("os.arch"); + try { + for (String arch : new String[]{"amd64", "x86_64"}) { + System.setProperty("os.arch", arch); + assertEquals("/libjvector-x86_64.so", LibraryLoader.resourceNameForArch()); + assertEquals("/libjvector_ash_fp16-x86_64.so", LibraryLoader.resourceNameForArch("jvector_ash_fp16")); + } + for (String arch : new String[]{"aarch64", "arm64"}) { + System.setProperty("os.arch", arch); + assertEquals("/libjvector-aarch64.so", LibraryLoader.resourceNameForArch()); + assertEquals("/libjvector_ash_fp16-aarch64.so", LibraryLoader.resourceNameForArch("jvector_ash_fp16")); + } + System.setProperty("os.arch", "unsupported"); + assertNull(LibraryLoader.resourceNameForArch()); + assertNull(LibraryLoader.resourceNameForArch("jvector_ash_fp16")); + } finally { + if (previous == null) System.clearProperty("os.arch"); + else System.setProperty("os.arch", previous); + } + } +} diff --git a/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestASHSymmetricScorer.java b/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestASHSymmetricScorer.java index 27963b394..e9f363cf1 100644 --- a/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestASHSymmetricScorer.java +++ b/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestASHSymmetricScorer.java @@ -117,6 +117,19 @@ private static VectorFloat[] inputs(int n, int d) { } @Test public void appendixBMatchesDecodedReconstructionAndHeaderRoundTrip() throws Exception { + String previous = System.getProperty("jvector.ash.queryProjection"); + try { + // This identity reconstructs with the original FP32 matrix. Its + // asymmetric side must use that same arithmetic, not rounded operands. + System.setProperty("jvector.ash.queryProjection", "fp32"); + checkAppendixBReconstruction(); + } finally { + if (previous == null) System.clearProperty("jvector.ash.queryProjection"); + else System.setProperty("jvector.ash.queryProjection", previous); + } + } + + private void checkAppendixBReconstruction() throws Exception { var input = inputs(25, 40); var values = MockVectorValues.fromValues(input); for (int bits = 1; bits <= 9; bits++) { From 800bdd04a8a8631dcf2ffc5a89353fb8e0beb731 Mon Sep 17 00:00:00 2001 From: Ted Willke Date: Sat, 31 Oct 2026 15:57:19 +0000 Subject: [PATCH 107/243] docs: document portable FP16 query projection and validation --- jvector-native/src/main/native/README.md | 87 ++++++++++++++++++++++++ 1 file changed, 87 insertions(+) diff --git a/jvector-native/src/main/native/README.md b/jvector-native/src/main/native/README.md index 8983a68d0..4e9d97b59 100644 --- a/jvector-native/src/main/native/README.md +++ b/jvector-native/src/main/native/README.md @@ -824,3 +824,90 @@ When changing the native interface: including `jvector_supports_abi`, where the inspection tools are available. Export inspection complements the runtime tests; it does not establish ABI support by itself. + + +## ASH FP16 query projection + +ASH encoding and training retain the Highway FP32 projection. Query scoring +lazily prepares a separate immutable FP16 representation of the same trained +matrix. Incoming query operands are converted to FP16; accumulation and output +are strictly FP32. The serialized model and graph format do not change. + +The runtime property `jvector.ash.queryProjection` selects the query arithmetic: + +| Value | Behavior | +|-------|----------| +| `auto` (default) | Prefer optimized native FP16; use the original FP32 projection if the optional library, CPU/model kernel or operand range is unsupported. | +| `fp32` | Keep the original provider's FP32 projection. | +| `fp16` | Require native FP16; report unsupported configurations or operands explicitly. | + +The setting is read when each model's query projection is first prepared. Set it +before searching; changing it does not mutate an already prepared model. Other +providers retain their original projection in automatic mode. Explicit FP16 +requires the native provider. Input shape/aliasing errors, allocation failures +and execution errors are reported rather than silently converted to fallback. +In automatic mode an individual query outside the finite FP16 range uses FP32. +Strict FP16 accepts only finite matrix/query values with magnitude at most +65504. Underflow and rounding follow IEEE binary16 arithmetic. Reduced operand +precision can change scores and rankings slightly; compare recall for the target +workload when selecting a precision. + +### Optional build and package + +The existing Highway library has no new dependency. To include the separate +FP16 library, build with JDK 22 and: + +```bash +mvn -pl jvector-native -am package -Dnative.onednn=true +``` + +For a direct native build, the third script argument enables the component: + +```bash +bash src/build_native_lib.sh release false true +``` + +The optional build requires CMake in addition to the native prerequisites. Meson +fetches the exact oneDNN revision pinned in `subprojects/onednn.wrap`; an offline +build can populate `subprojects/onednn` with that revision beforehand. oneDNN is +built with the sequential CPU runtime, no GPU support, and only inference +matrix-multiplication/reorder primitives. Its dispatcher selects supported CPU +instructions at runtime; the build does not use `-march=native`. + +oneDNN is statically linked into `libjvector_ash_fp16-.so`. Maven packages +this library and the upstream license/third-party notices beside the Highway +resource. The existing architecture-aware loader first tries the system library +path, then extracts the matching classpath resource. No external `libdnnl` or +benchmark-specific path is required. Omitting `-Dnative.onednn=true` leaves FP16 +out of the package and removes stale optional resources from previous builds. +The optional component follows the native module's Linux platform support; +cross-compilation still requires the corresponding target toolchain. + +### Ownership, parallelism and tests + +Each model owns its immutable prepared FP16 weights and a single-query execution +plan. Java automatic arenas release the native context when it becomes +unreachable. Each calling thread reuses its conversion, staging and scratch +buffers; neither thread-local cache retains the model or weights. Calls use +ordinary FFM downcalls. oneDNN starts no search workers or nested thread pool. +Concurrent callers use the same immutable model with separate workspaces. + +The native-module Java tests retain FP32 coverage and check FP16 against an +independent rounded-operand oracle, irregular and large dimensions, numeric +boundaries, input guards, model switching, concurrent platform/virtual threads, +query-only precision and loading without the optional library. On a machine +known to support FP16, require execution rather than capability skips with: + +```bash +mvn -pl jvector-native -am test -Dnative.onednn=true -Djvector.test.requireFp16=true +``` + +With Google Test installed, Meson also builds `test_ash_fp16`; run it with +`meson test -C --suite ash_fp16 --print-errorlogs`. + +For performance validation, use the standard BenchYAML query path on identical +saved graphs for `fp32` and `auto`/`fp16`. Include normal query preparation in +its timers and compare recall@10 as well as QPS. Control CPU affinity and NUMA +memory placement consistently across runs; otherwise projection locality can +obscure the precision comparison. Do not include compilation or another active +benchmark in the query measurement interval. From 5a8a78c115bb2e533a9cf8540c325355eee2d4a6 Mon Sep 17 00:00:00 2001 From: Ted Willke Date: Sat, 31 Oct 2026 19:15:15 +0000 Subject: [PATCH 108/243] native: add portable FP32 batch projection for ASH Share immutable packed weights across worker-local batches with one ordinary FFM call. Keep provider fallback, validate input shapes and aliases, and test FP32 accuracy, batch tails, concurrency and memory accounting. --- .../jvector/vector/VectorUtilSupport.java | 41 +++++ .../jvector/vector/NativeASHProjection.java | 76 +++++++- .../src/main/native/src/ash_projection-inl.h | 84 +++++++++ .../native/src/jvector_simd_kernel_list.h | 4 + .../vector/NativeASHProjectionTest.java | 163 ++++++++++++++++++ 5 files changed, 366 insertions(+), 2 deletions(-) create mode 100644 jvector-native/src/test/java/io/github/jbellis/jvector/vector/NativeASHProjectionTest.java diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/vector/VectorUtilSupport.java b/jvector-base/src/main/java/io/github/jbellis/jvector/vector/VectorUtilSupport.java index 413a5d38f..b894a5a26 100644 --- a/jvector-base/src/main/java/io/github/jbellis/jvector/vector/VectorUtilSupport.java +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/vector/VectorUtilSupport.java @@ -458,6 +458,47 @@ interface AshProjection { /** Writes the full matrix-vector product; input and output must not alias. */ void project(float[] query, float[] out); + /** + * Projects the first {@code count} vectors, each stored in its own row. + * All input rows must have the model's original dimension and output rows + * its projected dimension. Output rows must be distinct and must not alias + * any input row. Rows beyond count are ignored; count zero is a no-op. + * Providers may batch the multiply; the default retains their single-vector + * arithmetic without allocating temporary arrays. + */ + default void projectBatch(float[][] inputs, float[][] outputs, int count) { + validateBatch(inputs, outputs, count); + for (int i = 0; i < count; i++) project(inputs[i], outputs[i]); + } + + /** Validates a batch before any output is changed. */ + static void validateBatch(float[][] inputs, float[][] outputs, int count) { + java.util.Objects.requireNonNull(inputs, "inputs"); + java.util.Objects.requireNonNull(outputs, "outputs"); + if (count < 0 || count > inputs.length || count > outputs.length) + throw new IllegalArgumentException("Invalid ASH projection batch count: " + count); + for (int i = 0; i < count; i++) { + java.util.Objects.requireNonNull(inputs[i], "input row"); + java.util.Objects.requireNonNull(outputs[i], "output row"); + if (inputs[i].length != inputs[0].length || outputs[i].length != outputs[0].length) + throw new IllegalArgumentException("Ragged ASH projection batch"); + } + if (count <= 32) { + // The normal encoding batch needs no temporary collection. For larger + // API requests use linear validation rather than an unbounded scan. + for (int i = 0; i < count; i++) + for (int j = 0; j < count; j++) + if (outputs[i] == inputs[j] || (j < i && outputs[i] == outputs[j])) + throw new IllegalArgumentException("Aliased ASH projection batch"); + } else { + var used = new java.util.IdentityHashMap(count); + for (int i = 0; i < count; i++) used.put(inputs[i], Boolean.TRUE); + for (int i = 0; i < count; i++) + if (used.put(outputs[i], Boolean.TRUE) != null) + throw new IllegalArgumentException("Aliased ASH projection batch"); + } + } + /** * Returns an immutable view for query scoring, which may use lower operand * precision. Encoding/training must continue to use this original object. diff --git a/jvector-native/src/main/java/io/github/jbellis/jvector/vector/NativeASHProjection.java b/jvector-native/src/main/java/io/github/jbellis/jvector/vector/NativeASHProjection.java index 67ab75262..46d2a08a3 100644 --- a/jvector-native/src/main/java/io/github/jbellis/jvector/vector/NativeASHProjection.java +++ b/jvector-native/src/main/java/io/github/jbellis/jvector/vector/NativeASHProjection.java @@ -26,9 +26,10 @@ import static java.lang.foreign.ValueLayout.ADDRESS; import static java.lang.foreign.ValueLayout.JAVA_INT; +import static java.lang.foreign.ValueLayout.JAVA_FLOAT; /** - * Immutable native row-major matrix for one complete ASH query projection per downcall. + * Immutable native FP32 matrix for single-vector and bulk ASH projection. * The automatically managed arena lives with the model; it is not thread confined. * Native query/output staging is reused per calling thread, so projections may run concurrently. * Large projections use ordinary FFM and copy only the small query and result, allowing @@ -46,6 +47,12 @@ final class NativeASHProjection implements VectorUtilSupport.AshProjection { : Linker.nativeLinker().downcallHandle(ADDRESS_OF_KERNEL, DESCRIPTOR, Linker.Option.critical(true)); private static final MethodHandle ORDINARY = ADDRESS_OF_KERNEL == null ? null : Linker.nativeLinker().downcallHandle(ADDRESS_OF_KERNEL, DESCRIPTOR); + // Packing width is an ABI layout constant, not a limit on the batch size. + private static final int PACKED_ROWS = 32; + private static final MethodHandle BATCH = SymbolLookup.loaderLookup().find("ash_project_batch_f32") + .map(symbol -> Linker.nativeLinker().downcallHandle(symbol, + FunctionDescriptor.ofVoid(ADDRESS, ADDRESS, JAVA_INT, JAVA_INT, JAVA_INT, ADDRESS))) + .orElse(null); // Calling-thread storage is shared across models and FP32/FP16 views. It // holds no model reference, grows only when necessary and dies with the thread. @@ -73,6 +80,9 @@ static Buffers buffers(long inputBytes, long outputBytes) { } private final MemorySegment matrix; + // Allocated only for bulk encoding, shared by all encoding workers. Queries + // continue to use the original matrix and do not pay for this extra backing. + private volatile MemorySegment batchMatrix; private final int rows; private final int columns; @@ -95,7 +105,69 @@ static Buffers buffers(long inputBytes, long outputBytes) { } @Override - public long ramBytesUsed() { return matrix.byteSize(); } + public long ramBytesUsed() { + MemorySegment packed = batchMatrix; + return matrix.byteSize() + (packed == null ? 0 : packed.byteSize()); + } + + private MemorySegment batchMatrix() { + MemorySegment ready = batchMatrix; + if (ready == null) { + synchronized (this) { + ready = batchMatrix; + if (ready == null) { + ready = Arena.ofAuto().allocate(matrix.byteSize(), 64); + float[] tile = new float[Math.multiplyExact(columns, Math.min(rows, PACKED_ROWS))]; + for (long firstRow = 0; firstRow < rows; firstRow += PACKED_ROWS) { + int width = (int) Math.min(PACKED_ROWS, rows - firstRow); + for (int col = 0; col < columns; col++) { + for (int lane = 0; lane < width; lane++) { + long row = firstRow + lane; + tile[col * width + lane] = matrix.getAtIndex(JAVA_FLOAT, row * columns + col); + } + } + MemorySegment.copy(MemorySegment.ofArray(tile), 0, ready, + firstRow * columns * Float.BYTES, (long) width * columns * Float.BYTES); + } + batchMatrix = ready; + } + } + } + return ready; + } + + @Override + public void projectBatch(float[][] inputs, float[][] outputs, int count) { + VectorUtilSupport.AshProjection.validateBatch(inputs, outputs, count); + if (count == 0) return; + if (inputs[0].length != columns || outputs[0].length != rows) + throw new IllegalArgumentException("ASH projection batch shape mismatch"); + if (BATCH == null) { + // Compatibility with an older native library; Panama providers use + // the same default batch contract with their own FP32 projection. + VectorUtilSupport.AshProjection.super.projectBatch(inputs, outputs, count); + return; + } + long inputRowBytes = (long) columns * Float.BYTES; + long outputRowBytes = (long) rows * Float.BYTES; + Buffers buffers = buffers(Math.multiplyExact(count, inputRowBytes), + Math.multiplyExact(count, outputRowBytes)); + for (int i = 0; i < count; i++) + MemorySegment.copy(MemorySegment.ofArray(inputs[i]), 0, buffers.input, + i * inputRowBytes, inputRowBytes); + // Bulk work uses ordinary FFM so the JVM can reach safepoints. The + // matrix is packed once; input/output staging grows only when needed. + try { + BATCH.invokeExact(batchMatrix(), buffers.input, rows, columns, count, buffers.output); + } catch (RuntimeException | Error error) { + throw error; + } catch (Throwable error) { + throw new AssertionError("Unexpected checked exception from ash_project_batch_f32", error); + } + for (int i = 0; i < count; i++) + MemorySegment.copy(buffers.output, i * outputRowBytes, + MemorySegment.ofArray(outputs[i]), 0, outputRowBytes); + } @Override public void project(float[] query, float[] out) { diff --git a/jvector-native/src/main/native/src/ash_projection-inl.h b/jvector-native/src/main/native/src/ash_projection-inl.h index 51447f1f6..7b4be30b7 100644 --- a/jvector-native/src/main/native/src/ash_projection-inl.h +++ b/jvector-native/src/main/native/src/ash_projection-inl.h @@ -68,4 +68,88 @@ void ash_project_f32(const float *HWY_RESTRICT matrix, const float *HWY_RESTRICT out[row] = hn::ReduceSum(d, sum); } } + +// A packed matrix consists of 32-output tiles. Within each tile the original +// dimensions are contiguous rows of up to 32 weights (no tail padding). The +// caller prepares this once; each tile is reused across the whole input batch. +// All operands, FMA accumulators and results are FP32. +template +HWY_INLINE void ash_project_batch_tile(const float* HWY_RESTRICT weights, + const float* HWY_RESTRICT inputs, + int rows, int cols, size_t width, size_t firstLane, + size_t n0, size_t n1, + float* HWY_RESTRICT out) { + const hn::CappedTag d; + const size_t lanes = hn::Lanes(d); + const size_t columns = static_cast(cols); + const size_t stride = static_cast(rows); + auto s0 = hn::Zero(d), s1 = hn::Zero(d), s2 = hn::Zero(d), s3 = hn::Zero(d); + auto t0 = hn::Zero(d), t1 = hn::Zero(d), t2 = hn::Zero(d), t3 = hn::Zero(d); + for (size_t col = 0; col < columns; ++col) { + const float* w = weights + col * width + firstLane; + const auto a0 = hn::LoadN(d, w, n0); + const auto q0 = hn::Set(d, inputs[col]); + s0 = hn::MulAdd(a0, q0, s0); + if constexpr (TwoVectors) { + const auto a1 = hn::LoadN(d, w + lanes, n1); + t0 = hn::MulAdd(a1, q0, t0); + if constexpr (FourQueries) { + t1 = hn::MulAdd(a1, hn::Set(d, inputs[columns + col]), t1); + t2 = hn::MulAdd(a1, hn::Set(d, inputs[2 * columns + col]), t2); + t3 = hn::MulAdd(a1, hn::Set(d, inputs[3 * columns + col]), t3); + } + } + if constexpr (FourQueries) { + s1 = hn::MulAdd(a0, hn::Set(d, inputs[columns + col]), s1); + s2 = hn::MulAdd(a0, hn::Set(d, inputs[2 * columns + col]), s2); + s3 = hn::MulAdd(a0, hn::Set(d, inputs[3 * columns + col]), s3); + } + } + hn::StoreN(s0, d, out, n0); + if constexpr (TwoVectors) hn::StoreN(t0, d, out + lanes, n1); + if constexpr (FourQueries) { + hn::StoreN(s1, d, out + stride, n0); + hn::StoreN(s2, d, out + 2 * stride, n0); + hn::StoreN(s3, d, out + 3 * stride, n0); + if constexpr (TwoVectors) { + hn::StoreN(t1, d, out + stride + lanes, n1); + hn::StoreN(t2, d, out + 2 * stride + lanes, n1); + hn::StoreN(t3, d, out + 3 * stride + lanes, n1); + } + } +} + +void ash_project_batch_f32(const float* HWY_RESTRICT matrix, + const float* HWY_RESTRICT inputs, + int rows, int cols, int count, + float* HWY_RESTRICT out) { + const hn::CappedTag d; + const size_t lanes = hn::Lanes(d); + for (size_t row = 0; row < static_cast(rows); row += 32) { + const size_t tileRows = std::min(size_t{32}, static_cast(rows) - row); + const float* weights = matrix + row * static_cast(cols); + for (size_t offset = 0; offset < tileRows; offset += 2 * lanes) { + const size_t n0 = std::min(lanes, tileRows - offset); + const size_t n1 = tileRows - offset > lanes + ? std::min(lanes, tileRows - offset - lanes) : 0; + int b = 0; + for (; count - b >= 4; b += 4) { + const float* x = inputs + static_cast(b) * cols; + float* y = out + static_cast(b) * rows + row + offset; + if (n1 != 0) + ash_project_batch_tile(weights, x, rows, cols, tileRows, offset, n0, n1, y); + else + ash_project_batch_tile(weights, x, rows, cols, tileRows, offset, n0, n1, y); + } + for (; b < count; ++b) { + const float* x = inputs + static_cast(b) * cols; + float* y = out + static_cast(b) * rows + row + offset; + if (n1 != 0) + ash_project_batch_tile(weights, x, rows, cols, tileRows, offset, n0, n1, y); + else + ash_project_batch_tile(weights, x, rows, cols, tileRows, offset, n0, n1, y); + } + } + } +} #endif diff --git a/jvector-native/src/main/native/src/jvector_simd_kernel_list.h b/jvector-native/src/main/native/src/jvector_simd_kernel_list.h index cf0e23ed2..f2f17f42f 100644 --- a/jvector-native/src/main/native/src/jvector_simd_kernel_list.h +++ b/jvector-native/src/main/native/src/jvector_simd_kernel_list.h @@ -41,6 +41,9 @@ // ASH preparation/symmetric ABI contracts (all pointers are borrowed synchronously): // project: positive rows/cols, row-major matrix[rows*cols], query[cols], out[rows]. +// batch: nonnegative count; input[count*cols] and out[count*rows] are row-major. +// Matrix is tiled in output groups of 32, each [cols][tileRows]; the +// last tile is not padded. FP32, no batch-size cap or internal threads. // pair: nonnegative dimensions, bits in {1,2,4}; binary uint64 words for bits=1, // canonical little-endian packed bytes otherwise. Padding bits are ignored. // LUT: nonnegative groups/count, positive stride, 0 <= lane <= lane+count <= stride; @@ -78,6 +81,7 @@ KERNEL_ENTRY(void, calculate_partial_sums_self_magnitude_f32, (const float *codebook, int codebookIndex, size_t size, int clusterCount, float *partialSums), (codebook, codebookIndex, size, clusterCount, partialSums)) \ /* ASH preparation and exact packed construction scoring; synchronous leaf calls. */ \ KERNEL_ENTRY(void, ash_project_f32, (const float *matrix, const float *query, int rows, int cols, float *out), (matrix, query, rows, cols, out)) \ + KERNEL_ENTRY(void, ash_project_batch_f32, (const float *matrix, const float *inputs, int rows, int cols, int count, float *out), (matrix, inputs, rows, cols, count, out)) \ KERNEL_ENTRY(float, ash_symmetric_dot, (const void *a, const void *b, int dimensions, int bits), (a, b, dimensions, bits)) \ KERNEL_ENTRY(void, ash_symmetric_lut_i16, (const unsigned char *codes, const int16_t *lut, int groups, int stride, int lane, int count, float *out), (codes, lut, groups, stride, lane, count, out)) \ /* Whole-neighborhood C=1 ASH, one downcall including headers and similarity. */ \ diff --git a/jvector-native/src/test/java/io/github/jbellis/jvector/vector/NativeASHProjectionTest.java b/jvector-native/src/test/java/io/github/jbellis/jvector/vector/NativeASHProjectionTest.java new file mode 100644 index 000000000..5d10d4d42 --- /dev/null +++ b/jvector-native/src/test/java/io/github/jbellis/jvector/vector/NativeASHProjectionTest.java @@ -0,0 +1,163 @@ +/* + * 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.cnative.LibraryLoader; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +import java.lang.foreign.SymbolLookup; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Random; +import java.util.concurrent.CyclicBarrier; +import java.util.concurrent.Executors; + +import static org.junit.jupiter.api.Assertions.*; +import static org.junit.jupiter.api.Assumptions.assumeTrue; + +/** FP32 batch arithmetic, caller-buffer contracts and shared-matrix lifetime. */ +class NativeASHProjectionTest { + @BeforeAll + static void loadNative() { + boolean available = LibraryLoader.loadJvector(); + if (Boolean.getBoolean("jvector.test.requireNative")) assertTrue(available); + else assumeTrue(available, "Native library unavailable"); + assertTrue(SymbolLookup.loaderLookup().find("ash_project_batch_f32").isPresent(), + "Tests must exercise the new native batch kernel, not compatibility fallback"); + } + + private static float[][] randomRows(Random random, int rows, int columns) { + float[][] result = new float[rows][columns]; + for (float[] row : result) + for (int i = 0; i < columns; i++) row[i] = random.nextFloat() - .5f; + return result; + } + + private static void checkOracle(float[][] matrix, float[][] inputs, float[][] out, int count) { + for (int b = 0; b < count; b++) { + for (int row = 0; row < matrix.length; row++) { + double expected = 0, magnitude = 0; + for (int col = 0; col < matrix[row].length; col++) { + double product = (double) matrix[row][col] * inputs[b][col]; + expected += product; + magnitude += Math.abs(product); + } + assertEquals(expected, out[b][row], 5e-6 * magnitude + 1e-7, + "vector " + b + ", row " + row); + } + } + } + + @Test + void nativeAndPanamaMatchDoubleOracleAcrossBatchAndSimdTails() { + Random random = new Random(829104); + for (int[] shape : new int[][]{{1,1}, {3,7}, {17,31}, {33,65}, {384,768}, {1536,3072}}) { + float[][] matrix = randomRows(random, shape[0], shape[1]); + var nativeProjection = new NativeASHProjection(matrix); + var panama = new PanamaVectorUtilSupport().prepareAshProjection(matrix); + long matrixBytes = (long) shape[0] * shape[1] * Float.BYTES; + assertEquals(matrixBytes, nativeProjection.ramBytesUsed()); + for (int count : new int[]{0,1,3,4,5,8,17,32,33,65}) { + float[][] inputs = randomRows(random, count + 1, shape[1]); + float[][] out = new float[count + 1][shape[0]]; + float[][] reference = new float[count + 1][shape[0]]; + float[][] saved = Arrays.stream(inputs).map(float[]::clone).toArray(float[][]::new); + Arrays.fill(out[count], 123f); + nativeProjection.projectBatch(inputs, out, count); + panama.projectBatch(inputs, reference, count); + checkOracle(matrix, inputs, out, count); + for (int b = 0; b < count; b++) { + float[][] one = new float[1][shape[0]]; + nativeProjection.projectBatch(new float[][]{inputs[b]}, one, 1); + assertArrayEquals(out[b], one[0], "Batch size must not change native arithmetic"); + } + checkOracle(matrix, inputs, reference, count); + for (int b = 0; b <= count; b++) assertArrayEquals(saved[b], inputs[b]); + for (float value : out[count]) assertEquals(123f, value); + } + assertEquals(2 * matrixBytes, nativeProjection.ramBytesUsed()); + } + } + + @Test + void retainsFp32OperandsIncludingValuesThatRoundInFp16() { + float weight = 1f + Math.scalb(1f, -12); + float value = 1f + Math.scalb(1f, -13); + var projection = new NativeASHProjection(new float[][]{{weight}}); + float[][] out = new float[5][1]; + projection.projectBatch(new float[][]{{value},{value},{value},{value},{value}}, out, 5); + for (float[] row : out) assertEquals(weight * value, row[0], 0f); + float[] single = new float[1]; + projection.project(new float[]{value}, single); + assertEquals(weight * value, single[0], 0f); + assertNotEquals(1f, out[0][0]); + } + + @Test + void validatesShapesCountsAndAliasingBeforeWriting() { + for (var projection : new VectorUtilSupport.AshProjection[]{ + new NativeASHProjection(new float[][]{{1,2},{3,4}}), + new PanamaVectorUtilSupport().prepareAshProjection(new float[][]{{1,2},{3,4}})}) { + float[][] x = {{1,2},{3,4}}, y = {{99,99},{99,99}}; + assertThrows(IllegalArgumentException.class, () -> projection.projectBatch(x, y, -1)); + assertThrows(IllegalArgumentException.class, () -> projection.projectBatch(x, y, 3)); + assertThrows(IllegalArgumentException.class, () -> projection.projectBatch(x, x, 2)); + assertThrows(IllegalArgumentException.class, () -> projection.projectBatch(x, new float[][]{y[0],y[0]}, 2)); + assertThrows(IllegalArgumentException.class, () -> projection.projectBatch(x, new float[][]{y[0],x[0]}, 2)); + assertThrows(IllegalArgumentException.class, () -> projection.projectBatch(new float[][]{x[0],{1}}, y, 2)); + assertThrows(IllegalArgumentException.class, () -> projection.projectBatch(new float[][]{{1}}, new float[][]{{1,2}}, 1)); + assertThrows(NullPointerException.class, () -> projection.projectBatch(null, y, 1)); + assertThrows(NullPointerException.class, () -> projection.projectBatch(new float[][]{null}, y, 1)); + for (float[] row : y) for (float value : row) assertEquals(99f, value); + projection.projectBatch(new float[0][], new float[0][], 0); + float[][] manyInputs = new float[65][2], manyOutputs = new float[65][2]; + manyOutputs[64] = manyInputs[17]; + assertThrows(IllegalArgumentException.class, + () -> projection.projectBatch(manyInputs, manyOutputs, 65)); + manyOutputs[64] = manyOutputs[3]; + assertThrows(IllegalArgumentException.class, + () -> projection.projectBatch(manyInputs, manyOutputs, 65)); + } + } + + @Test + void lazyPackingAndStagingRemainSafeAcrossWorkersAndModels() throws Exception { + Random random = new Random(40215); + float[][] a = randomRows(random, 33, 65), b = randomRows(random, 129, 257); + var first = new NativeASHProjection(a); + var second = new NativeASHProjection(b); + CyclicBarrier start = new CyclicBarrier(8); + try (var pool = Executors.newFixedThreadPool(8)) { + var tasks = new ArrayList>(); + for (int t = 0; t < 8; t++) tasks.add(() -> { + float[][] inputA = randomRows(new Random(42), 35, 65); + float[][] inputB = randomRows(new Random(43), 5, 257); + float[][] outA = new float[35][33], outB = new float[5][129]; + start.await(); + for (int call = 0; call < 8; call++) { + first.projectBatch(inputA, outA, 35); + second.projectBatch(inputB, outB, 5); + checkOracle(a, inputA, outA, 35); + checkOracle(b, inputB, outB, 5); + if (call == 4) System.gc(); + } + return null; + }); + for (var task : pool.invokeAll(tasks)) task.get(); + } + } +} From cb9164b9f2721b143baedb20d3f743ad0c0f7d30 Mon Sep 17 00:00:00 2001 From: Ted Willke Date: Sat, 31 Oct 2026 19:15:15 +0000 Subject: [PATCH 109/243] ash: use native FP32 projection for single and bulk encoding Route encode through the prepared FP32 projection and replace encodeAll row-dot loops with bounded worker-local batches. Share normalization and code generation, preserve missing and borrowed vector semantics, and await submitted workers on failure. Query FP16 selection remains independent. --- .../quantization/AsymmetricHashing.java | 200 ++++++++++------- .../quantization/TestASHBatchEncoding.java | 207 ++++++++++++++++++ 2 files changed, 326 insertions(+), 81 deletions(-) create mode 100644 jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestASHBatchEncoding.java diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/AsymmetricHashing.java b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/AsymmetricHashing.java index 2f3ecb371..301d05b88 100644 --- a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/AsymmetricHashing.java +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/AsymmetricHashing.java @@ -780,16 +780,12 @@ public static final class RandomBinaryQuantizer implements BinaryQuantizer { private static final ThreadLocal WORKSPACE = ThreadLocal.withInitial(Workspace::new); private static final class Workspace { - float[] x; - float[] muArr; float[] xhat; float[] proj; double[] absNorm; void ensureCapacity(int originalDim, int quantizedDim) { - if (x == null || x.length != originalDim) { - x = new float[originalDim]; - muArr = new float[originalDim]; + if (xhat == null || xhat.length != originalDim) { xhat = new float[originalDim]; } if (proj == null || proj.length != quantizedDim) { @@ -809,37 +805,21 @@ public float quantizeBody(VectorFloat vector, long[] outSignWords, byte[] outExtraBits) { - final float[][] A = stiefel.AFloat; - final int originalDim = stiefel.cols; - - // Get workspace for this thread and ensure correct sizing. Workspace ws = WORKSPACE.get(); - ws.ensureCapacity(originalDim, quantizedDim); - - // Alias for readability (no new allocations). - final float[] x = ws.x; - final float[] muArr = ws.muArr; - final float[] xhat = ws.xhat; - - final var vecUtil = - io.github.jbellis.jvector.vector.VectorizationProvider - .getInstance() - .getVectorUtilSupport(); - - // Copy vector and mean once into workspace. - for (int d = 0; d < originalDim; d++) { - x[d] = vector.get(d); - muArr[d] = mu.get(d); - } - - // ASH paper, Eq. 6, normalization. - final float invNorm = (residualNorm > 0f) ? (1.0f / residualNorm) : 0.0f; - - // Compute normalized residual x̂. - for (int d = 0; d < originalDim; d++) { - xhat[d] = (x[d] - muArr[d]) * invNorm; - } - + ws.ensureCapacity(stiefel.cols, quantizedDim); + normalizeResidual(vector, mu, residualNorm, ws.xhat); + // Encoding always uses the original FP32 projection, independently + // of the model's query-only FP16 view. + stiefel.project(ws.xhat, ws.proj); + return quantizeProjected(ws.proj, bitsPerDimension, outSignWords, + outExtraBits, ws.absNorm); + } + + /** Shared code generation for singleton and bulk projections. */ + private static float quantizeProjected(float[] proj, int bitsPerDimension, + long[] outSignWords, byte[] outExtraBits, + double[] absNorm) { + final int quantizedDim = proj.length; if (bitsPerDimension == 1) { // Binarize directly from per-row projection. final int words = QuantizedVector.wordsForDims(quantizedDim); @@ -850,7 +830,7 @@ public float quantizeBody(VectorFloat vector, for (int j = 0; j < rem; j++) { int bitIndex = base + j; - float acc = vecUtil.ashDotRow(A[bitIndex], xhat); + float acc = proj[bitIndex]; if (acc > 0.0f) { bits |= (1L << j); } @@ -863,13 +843,9 @@ public float quantizeBody(VectorFloat vector, final int exBits = bitsPerDimension - 1; final int maxCode = (1 << exBits) - 1; - final float[] proj = ws.proj; - final double[] absNorm = ws.absNorm; - double projNorm2 = 0.0; for (int j = 0; j < quantizedDim; j++) { - float acc = vecUtil.ashDotRow(A[j], xhat); - proj[j] = acc; + float acc = proj[j]; projNorm2 += (double) acc * (double) acc; if (!usesFastScanProjectionCode(bitsPerDimension) && acc >= 0.0f) { QuantizedVector.setBit(outSignWords, j); @@ -1313,6 +1289,11 @@ public void project(float[] query, float[] out) { projection.project(query, out); } + /** Projects base vectors in FP32; query precision selection does not apply. */ + public void projectBatch(float[][] vectors, float[][] out, int count) { + projection.projectBatch(vectors, out, count); + } + /** Number of rows in A (quantizedDim = d). */ public final int rows; @@ -1404,58 +1385,97 @@ public CompressedVectors createCompressedVectors(Object[] compressedVectors) { } + // An internal working-set bound, not a projection API limit. Each task owns + // at most this many normalized vectors and projected results, reused until + // its range is complete. Native code supplies the SIMD/cache micro-tiling. + private static final int ENCODING_BATCH_SIZE = 32; + /** - * Encodes all vectors using ASH, honoring the caller-provided ForkJoinPool. + * Encodes all base vectors in bounded FP32 projection batches. The supplied + * pool controls parallelism; native calls execute on the calling worker. + * Missing vectors retain the existing all-zero encoded representation. */ @Override public ASHVectors encodeAll(RandomAccessVectorValues ravv, ForkJoinPool simdExecutor) { + Objects.requireNonNull(ravv, "ravv"); + Objects.requireNonNull(simdExecutor, "simdExecutor"); + if (ravv.dimension() != originalDimension) + throw new IllegalArgumentException("ASH encoding vector dimension mismatch"); final int n = ravv.size(); - - // The caller controls parallelism; we derive a bounded number of tasks - // directly from the executor rather than using parallel streams. final int parallelism = Math.max(1, simdExecutor.getParallelism()); - - // Each task processes a contiguous range of ordinals. - final int chunkSize = (n + parallelism - 1) / parallelism; - + final int chunkSize = (int) (((long) n + parallelism - 1) / parallelism); final QuantizedVector[] out = new QuantizedVector[n]; final var ravvSupplier = ravv.threadLocalSupplier(); - var tasks = new java.util.ArrayList>(parallelism); - - for (int t = 0; t < parallelism; t++) { - final int start = t * chunkSize; - final int end = Math.min(n, start + chunkSize); - if (start >= end) { - break; + Throwable failure = null; + try { + for (int start = 0; start < n;) { + final int first = start; + final int end = (int) Math.min((long) n, (long) first + chunkSize); + tasks.add(simdExecutor.submit(() -> encodeRange(ravvSupplier.get(), out, first, end))); + start = end; + } + } catch (RuntimeException | Error error) { + failure = error; + } + // Await every submitted task, including when the pool rejected a later + // submission. The caller may close its source when this method exits. + for (var task : tasks) { + try { task.join(); } + catch (RuntimeException | Error error) { + if (failure == null) failure = error; + else if (failure != error) failure.addSuppressed(error); } - - tasks.add(simdExecutor.submit(() -> { - // Each task uses its own thread-local RAVV view. - var localRavv = ravvSupplier.get(); - - for (int i = start; i < end; i++) { - VectorFloat v = localRavv.getVector(i); - - // Contract: missing vectors are encoded as zero vectors. - if (v != null) { - out[i] = encode(v); - } else { - out[i] = QuantizedVector.createEmpty(quantizedDim, bitsPerDimension); - } - } - })); } + if (failure instanceof Error) throw (Error) failure; + if (failure != null) throw (RuntimeException) failure; + return new ASHVectors(this, out); + } - // Join establishes a happens-before relationship, ensuring safe publication - // of all writes to the output array. - for (var task : tasks) { - task.join(); + private void encodeRange(RandomAccessVectorValues source, QuantizedVector[] out, int start, int end) { + int capacity = Math.min(ENCODING_BATCH_SIZE, end - start); + float[][] inputs = new float[capacity][originalDimension]; + float[][] projected = new float[capacity][quantizedDim]; + float[] residualNorms = new float[capacity]; + int[] ordinals = new int[capacity]; + double[] absNorm = new double[quantizedDim]; + int count = 0; + for (int i = start; i < end; i++) { + VectorFloat vector = source.getVector(i); + QuantizedVector dest = QuantizedVector.createEmpty(quantizedDim, bitsPerDimension); + out[i] = dest; + if (vector == null) continue; + float residualNorm = prepareEncoding(vector, dest); + normalizeResidual(vector, landmarks[dest.landmark & 0xFF], residualNorm, inputs[count]); + residualNorms[count] = residualNorm; + ordinals[count] = i; + if (++count == capacity) { + encodeBatch(inputs, projected, residualNorms, ordinals, count, absNorm, out); + count = 0; + } } + if (count != 0) encodeBatch(inputs, projected, residualNorms, ordinals, count, absNorm, out); + } - return new ASHVectors(this, out); + private void encodeBatch(float[][] inputs, float[][] projected, float[] residualNorms, + int[] ordinals, int count, double[] absNorm, QuantizedVector[] out) { + stiefelTransform.projectBatch(inputs, projected, count); + for (int i = 0; i < count; i++) { + QuantizedVector dest = out[ordinals[i]]; + float codeNorm = RandomBinaryQuantizer.quantizeProjected(projected[i], bitsPerDimension, + dest.binaryVector, dest.extraBits, absNorm); + finishEncoding(dest, residualNorms[i], codeNorm); + } } + private static void normalizeResidual(VectorFloat vector, VectorFloat mean, + float residualNorm, float[] normalized) { + if (vector.length() != normalized.length || mean.length() != normalized.length) + throw new IllegalArgumentException("ASH encoding vector dimension mismatch"); + float invNorm = residualNorm > 0f ? 1f / residualNorm : 0f; + for (int d = 0; d < normalized.length; d++) + normalized[d] = (vector.get(d) - mean.get(d)) * invNorm; + } /** * Encodes the input vector using ASH. @@ -1470,6 +1490,20 @@ public QuantizedVector encode(VectorFloat vector) { @Override public void encodeTo(VectorFloat vector, QuantizedVector dest) { + float residualNorm = prepareEncoding(vector, dest); + float codeNorm = quantizeVector(vector, residualNorm, dest.landmark, dest); + finishEncoding(dest, residualNorm, codeNorm); + } + + /** Sets landmark and raw offset; returns the residual norm needed by both paths. */ + private float prepareEncoding(VectorFloat vector, QuantizedVector dest) { + Objects.requireNonNull(vector, "vector"); + Objects.requireNonNull(dest, "dest"); + if (vector.length() != originalDimension) + throw new IllegalArgumentException("ASH encoding vector dimension mismatch"); + if (dest.binaryVector == null || dest.binaryVector.length < QuantizedVector.signWordsForDims(quantizedDim, bitsPerDimension) + || dest.extraBits == null || dest.extraBits.length < QuantizedVector.bodyBytesForDims(quantizedDim, bitsPerDimension)) + throw new IllegalArgumentException("ASH encoding destination too short"); // Landmark assignment: nearest centroid by L2 distance. byte landmark = 0; float bestDist = Float.POSITIVE_INFINITY; @@ -1498,11 +1532,15 @@ public void encodeTo(VectorFloat vector, QuantizedVector dest) { // adjusted after quantization for C++-style fast-scan projection codes. dest.offset = rawOffset; dest.landmark = landmark; + assert Float.isFinite(rawOffset) : "offset is not finite"; + + return residualNorm; + } - // Body: sign bits, generic sign+extra bits, or fast-scan projection code. - // The returned code norm is sqrt(d) for 1-bit ASH and - // sqrt(sum_j (q_j + 0.5)^2) for multibit ASH. - float codeNorm = quantizeVector(vector, residualNorm, landmark, dest); + /** Applies the existing code-dependent header adjustment and FP16 storage rounding. */ + private void finishEncoding(QuantizedVector dest, float residualNorm, float codeNorm) { + final int c = dest.landmark & 0xFF; + final float rawOffset = dest.offset; final float scale = codeNorm > 0f ? residualNorm / codeNorm : 0f; float finalOffset = rawOffset; diff --git a/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestASHBatchEncoding.java b/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestASHBatchEncoding.java new file mode 100644 index 000000000..95dd00476 --- /dev/null +++ b/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestASHBatchEncoding.java @@ -0,0 +1,207 @@ +/* + * 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 io.github.jbellis.jvector.graph.MockVectorValues; +import io.github.jbellis.jvector.graph.RandomAccessVectorValues; +import io.github.jbellis.jvector.vector.VectorizationProvider; +import io.github.jbellis.jvector.vector.types.VectorFloat; +import org.junit.Test; + +import java.util.Random; +import java.util.concurrent.ForkJoinPool; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.junit.Assert.*; + +/** Base-vector encoding contracts shared by the native and Panama providers. */ +public class TestASHBatchEncoding { + private static final int DIMENSION = 16; + + private static VectorFloat[] vectors(int count) { + Random random = new Random(771023); + var types = VectorizationProvider.getInstance().getVectorTypeSupport(); + VectorFloat[] result = new VectorFloat[count]; + for (int i = 0; i < count; i++) { + result[i] = types.createFloatVector(DIMENSION); + for (int d = 0; d < DIMENSION; d++) result[i].set(d, random.nextFloat() - .5f); + } + return result; + } + + private static AsymmetricHashing model(int bits, int landmarks) throws Exception { + return AsymmetricHashing.initialize(MockVectorValues.fromValues(vectors(128)), + AsymmetricHashing.RANDOM, AsymmetricHashing.HEADER_BITS + 9 * bits, landmarks, bits); + } + + private static void assertCode(AsymmetricHashing.QuantizedVector expected, + AsymmetricHashing.QuantizedVector actual) { + assertEquals(expected.landmark, actual.landmark); + assertEquals(expected.scale, actual.scale, 0f); + assertEquals(expected.offset, actual.offset, 0f); + assertArrayEquals(expected.binaryVector, actual.binaryVector); + assertArrayEquals(expected.extraBits, actual.extraBits); + } + + private static void assertHighPrecisionCodeCompatible(AsymmetricHashing.QuantizedVector expected, + AsymmetricHashing.QuantizedVector actual, + int dimensions) { + assertEquals(expected.landmark, actual.landmark); + assertEquals(expected.offset, actual.offset, 0f); + assertArrayEquals(expected.binaryVector, actual.binaryVector); + // Different FP32 reduction orders can cross a 9-bit code boundary. + // Check the reconstructed component within one step from each code, + // rather than requiring bit equality of that discontinuous quantizer. + for (int d = 0; d < dimensions; d++) { + boolean positive = AsymmetricHashing.QuantizedVector.getBit(expected.binaryVector, d); + int e = AsymmetricHashing.QuantizedVector.readExtraCode(expected.extraBits, d, 8); + int a = AsymmetricHashing.QuantizedVector.readExtraCode(actual.extraBits, d, 8); + if (!positive) { e = (~e) & 255; a = (~a) & 255; } + assertEquals(expected.scale * (e + .5), actual.scale * (a + .5), + expected.scale + actual.scale + 1e-7); + } + } + + private static final class Values implements RandomAccessVectorValues { + private final VectorFloat[] vectors; + private final boolean shared; + private final VectorFloat scratch; + Values(VectorFloat[] vectors, boolean shared) { + this.vectors = vectors; + this.shared = shared; + scratch = VectorizationProvider.getInstance().getVectorTypeSupport().createFloatVector(DIMENSION); + } + @Override public int size() { return vectors.length; } + @Override public int dimension() { return DIMENSION; } + @Override public boolean isValueShared() { return shared; } + @Override public RandomAccessVectorValues copy() { return new Values(vectors, shared); } + @Override public VectorFloat getVector(int ordinal) { + VectorFloat vector = vectors[ordinal]; + if (vector == null || !shared) return vector; + for (int d = 0; d < DIMENSION; d++) scratch.set(d, vector.get(d)); + return scratch; + } + } + + @Test + public void bulkMatchesSingleForTailsMissingValuesAndBorrowedStorage() throws Exception { + for (int bits : new int[]{1,2,3,4,9}) { + var ash = model(bits, bits == 3 ? 3 : 1); + for (int count : new int[]{0,1,31,32,33,65,97}) { + VectorFloat[] input = vectors(count); + if (count > 32) { + input[0] = null; + input[31] = null; + input[count - 1] = ash.landmarks[0].copy(); // zero residual + } + ASHVectors firstBulkResult = null; + for (boolean shared : new boolean[]{false,true}) { + for (int threads : new int[]{1,3}) { + ForkJoinPool pool = new ForkJoinPool(threads); + try { + var actual = ash.encodeAll(new Values(input, shared), pool); + assertEquals(count, actual.count()); + if (firstBulkResult == null) firstBulkResult = actual; + for (int i = 0; i < count; i++) { + var expected = input[i] == null + ? AsymmetricHashing.QuantizedVector.createEmpty(ash.quantizedDim, bits) + : ash.encode(input[i]); + try { + // A fixed projection implementation must be + // byte-stable across batch tails and workers. + assertCode(firstBulkResult.get(i), actual.get(i)); + if (bits == 9 && input[i] != null) + assertHighPrecisionCodeCompatible(expected, actual.get(i), ash.quantizedDim); + else assertCode(expected, actual.get(i)); + } + catch (AssertionError failure) { + throw new AssertionError("bits=" + bits + ", count=" + count + + ", shared=" + shared + ", threads=" + threads + ", ordinal=" + i, + failure); + } + } + } finally { pool.shutdown(); } + } + } + } + } + } + + @Test + public void encodeToClearsReusedCodesAndEncodingIgnoresQueryPrecision() throws Exception { + String old = System.getProperty("jvector.ash.queryProjection"); + try { + // This would fail even on a capable machine if encoding requested a + // query view. Query-only selection must never affect base encoding. + System.setProperty("jvector.ash.queryProjection", "invalid-query-mode"); + var ash = model(4, 1); + VectorFloat[] input = vectors(65); + var dest = ash.encode(input[0]); + for (var vector : input) { + var expected = ash.encode(vector); + ash.encodeTo(vector, dest); + assertCode(expected, dest); + } + ForkJoinPool pool = new ForkJoinPool(2); + try { + var bulk = ash.encodeAll(new Values(input, true), pool); + for (int i = 0; i < input.length; i++) assertCode(ash.encode(input[i]), bulk.get(i)); + } finally { pool.shutdown(); } + } finally { + if (old == null) System.clearProperty("jvector.ash.queryProjection"); + else System.setProperty("jvector.ash.queryProjection", old); + } + } + + @Test + public void allMissingSourceRetainsZeroRepresentations() throws Exception { + var ash = model(2, 1); + ForkJoinPool pool = new ForkJoinPool(3); + try { + var encoded = ash.encodeAll(new Values(new VectorFloat[97], true), pool); + var empty = AsymmetricHashing.QuantizedVector.createEmpty(ash.quantizedDim, 2); + for (int i = 0; i < 97; i++) assertCode(empty, encoded.get(i)); + } finally { pool.shutdown(); } + } + + @Test + public void invalidArgumentsAndSourceFailureArePropagatedAfterWorkersFinish() throws Exception { + var ash = model(2, 1); + assertThrows(NullPointerException.class, () -> ash.encode(null)); + var shortVector = VectorizationProvider.getInstance().getVectorTypeSupport().createFloatVector(1); + assertThrows(IllegalArgumentException.class, () -> ash.encode(shortVector)); + var input = vectors(128); + AtomicInteger otherRangeReads = new AtomicInteger(); + RandomAccessVectorValues source = new RandomAccessVectorValues() { + @Override public int size() { return 128; } + @Override public int dimension() { return DIMENSION; } + @Override public boolean isValueShared() { return false; } + @Override public RandomAccessVectorValues copy() { return this; } + @Override public VectorFloat getVector(int ordinal) { + if (ordinal == 0) throw new IllegalStateException("source failed"); + if (ordinal >= 64) otherRangeReads.incrementAndGet(); + return input[ordinal]; + } + }; + ForkJoinPool pool = new ForkJoinPool(2); + try { + assertThrows(NullPointerException.class, () -> ash.encodeAll(null, pool)); + assertThrows(NullPointerException.class, () -> ash.encodeAll(source, null)); + assertThrows(IllegalStateException.class, () -> ash.encodeAll(source, pool)); + assertEquals("No source worker may outlive encodeAll failure", 64, otherRangeReads.get()); + } finally { pool.shutdown(); } + } +} From 7d46fe6854ed98bdb07fef0093618aecf6e68a34 Mon Sep 17 00:00:00 2001 From: Ted Willke Date: Sun, 1 Nov 2026 02:47:54 +0000 Subject: [PATCH 110/243] docs: document FP32 ASH encoding and encodeAll measurements --- docs/native-ash-preparation.md | 89 +++++++++++++++++++++++++++++++++- 1 file changed, 87 insertions(+), 2 deletions(-) diff --git a/docs/native-ash-preparation.md b/docs/native-ash-preparation.md index 7bcba1f07..6a484a1ed 100644 --- a/docs/native-ash-preparation.md +++ b/docs/native-ash-preparation.md @@ -15,7 +15,8 @@ unrelated queries, BLAS thread pool, per-row FFM call, or per-query matrix copy. Small bounded products use a synchronous critical downcall with heap query and output arrays. Larger products use an ordinary downcall with temporary native query/output buffers so the JVM can reach safepoints during the dense multiply. -The matrix is shared read-only; temporary storage is invocation-local. +The matrix is shared read-only; native staging buffers are reused per calling +thread and grow only when needed. `ash_symmetric_dot` evaluates encoded pairs used by graph construction: 1-bit XOR/popcount; 2-bit weighted popcounts over packed sign/magnitude fields; 4-bit @@ -35,12 +36,57 @@ callers of `ASHSymmetricScorer.blockScorerFor` use the native LUT kernel. Existi C>1 formulas and the graph-neighborhood scorer are unchanged. The graph study uses C=1 throughout. -All three symbols are optional and looked up independently. Loading an older +Projection and symmetric symbols are optional and looked up independently. Loading an older compatible library preserves Java projection and symmetric implementations. Java validates shapes, array bounds and output aliasing before passing pointers to native code. Native kernels do not allocate, retain pointers or call Java. Scorer diagnostics identify whether each operation is Highway or Java. +## Base-vector encoding + +`encode()` and `encodeTo()` normalize the centered residual, project it through +`StiefelTransform.project()` in FP32, and apply the existing code quantizer and +header adjustment. This uses the prepared Highway projection with the native +provider and Panama SIMD with the Panama provider. Encoding never requests the +query-only FP16 view. Header scale and offset retain their existing FP16 storage +format; database projection precision remains FP32. + +`encodeAll()` replaces per-vector projection with bounded batches on each worker +of the caller's `ForkJoinPool`. Each worker gathers up to 32 non-null vectors, +normalizes them immediately into reusable buffers, calls `projectBatch()` once, +and quantizes/packs the projected outputs in their original ordinal positions. +Immediate gathering is necessary because a vector source may lend the same +mutable vector object on each read. Missing vectors keep the existing all-zero +encoded representation. Empty sources and partial final batches are supported. +Every submitted task is joined before returning or propagating a worker error. + +The Highway `ash_project_batch_f32` kernel is a blocked matrix multiply with +FP32 operands and FP32 accumulators. Its SIMD microkernel shares matrix weights +across four inputs; output tiles are reused over the full caller batch. It has +no internal threads. A second native FP32 matrix, tiled by up to 32 output +coordinates, is packed lazily once per model and shared read-only. The final +tile has no padding, so this backing is exactly the original matrix size and +is included in `ramBytesUsed()`. Single-vector queries do not allocate it. +Input and output staging is reused per calling thread. Bulk calls use ordinary +FFM to allow JVM safepoints during the multiply. + +The worker batch size of 32 is an internal working-set bound, not an API or ISA +limit. `AshProjection.projectBatch()` accepts any nonnegative count that fits +the supplied row arrays. The default provider implementation calls its own +FP32 single-vector projector; the normal worker batch needs no temporary +validation collection or projection arrays. This preserves Panama fallback. An older native library without the batch symbol uses its available +single-vector projection. There are no leader groups, arrival queues, timers, +public tuning controls, or new dependencies. + +Changing SIMD reduction order can produce small FP32 differences near code +boundaries; FP32 does not imply bit-identical arithmetic across providers. +Regression tests use a double-precision projection oracle and exercise native +and Panama batch tails, input/alias rejection, concurrent models, and FP32 +operand precision. Encoding tests cover missing/borrowed vectors, supported bit +widths, multiple landmarks, zero residuals, destination reuse, source failure, +and independence from the query precision setting. Existing model and graph +formats are unchanged. + ## Reference implementation review Reviewed IVF ASH Fastscan at commit @@ -81,3 +127,42 @@ performance measurements. Separate fresh full-data builds verify construction. The focused cases are MPNet at the 2-bit ASH budget and OpenAI3072 at the 4-bit ASH budget on both JDKs. They are not a replacement for the earlier full PQ/ASH matrix. Full results and measurement scope accompany the saved report. + +## FP32 encodeAll measurements (JDK 22, October 31, 2026) + +These are standard BenchYAML `construction.index_quant_time_s.ASH.encoding_time_s` +measurements for four 1M-vector datasets, ITQ, C=1 and D/2. They time the complete +`encodeAll()` operation, including normalization, projection, quantization and +result packing. They are not timings of individual `encode()` calls. Training +and graph/write are separate metrics. The first native packing cost is included. + +Both runs use physical CPUs 0–47 with memory interleaved across NUMA nodes 0 and +1. The before version is `16273e24` (per-vector Panama FP32 encoding); the after +code is `83e49aac` (Highway FP32 worker-local batches). Both use fresh model and +index caches, the native scoring provider, deferred parallel writes, NVQ +reranking, and FP16 query projection. All eight serialized trained models are +byte-identical between the two runs. Each cell is one full construction run; +these are not repeated-run averages. + +| Dataset | ASH case | Before (s) | After (s) | Speedup | +| --- | --- | ---: | ---: | ---: | +| MPNet | 2-bit | 1.140 | 0.800 | 1.43× | +| MPNet | 4-bit | 1.205 | 0.986 | 1.22× | +| Cohere | 2-bit | 1.699 | 0.960 | 1.77× | +| Cohere | 4-bit | 2.049 | 1.264 | 1.62× | +| Ada002 | 2-bit | 5.793 | 1.344 | 4.31× | +| Ada002 | 4-bit | 6.057 | 1.896 | 3.19× | +| OpenAI3072 | 2-bit | 20.621 | 3.641 | 5.66× | +| OpenAI3072 | 4-bit | 25.240 | 4.712 | 5.36× | + +The current code was checked on JDK 22 with native and Panama providers, and +native batch projection with the AVX2 and SSE/scalar ISA caps. The native module +passed 12 projection/FP16 tests; the general ASH suite passed 41 tests with two +provider-specific skips under native, and 39 with four skips under Panama. +Each lower-ISA projection pass ran four tests. The optional standalone Meson +C++ test targets were unavailable on this host; the Java native tests invoke +the actual dispatched C++ kernels and require the new batch symbol. + +Fresh graph recall was also recorded, but these independent parallel graph +builds do not isolate an encoding-only recall effect. Model and graph formats, +query FP16 arithmetic, and scoring kernels were not changed by this extension. From e2f9384c11d42b65121310383ae770fafab7b9d5 Mon Sep 17 00:00:00 2001 From: Ted Willke Date: Sun, 1 Nov 2026 21:11:53 +0000 Subject: [PATCH 111/243] ash: streamline residual normalization during encoding Resolve FloatArray backing once for residual normalization while retaining generic vector access and identical FP32 operation order. Add bit-exact coverage for zero norms, signed zero, mixed implementations and SIMD tails. --- .../quantization/AsymmetricHashing.java | 16 +++++++- .../quantization/TestASHBatchEncoding.java | 37 +++++++++++++++++++ 2 files changed, 51 insertions(+), 2 deletions(-) diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/AsymmetricHashing.java b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/AsymmetricHashing.java index 301d05b88..308f22376 100644 --- a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/AsymmetricHashing.java +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/AsymmetricHashing.java @@ -26,6 +26,7 @@ import io.github.jbellis.jvector.vector.VectorUtil; import io.github.jbellis.jvector.vector.VectorizationProvider; import io.github.jbellis.jvector.vector.types.ByteSequence; +import io.github.jbellis.jvector.vector.types.FloatArray; import io.github.jbellis.jvector.vector.types.VectorFloat; import io.github.jbellis.jvector.vector.types.VectorTypeSupport; @@ -1473,8 +1474,19 @@ private static void normalizeResidual(VectorFloat vector, VectorFloat mean if (vector.length() != normalized.length || mean.length() != normalized.length) throw new IllegalArgumentException("ASH encoding vector dimension mismatch"); float invNorm = residualNorm > 0f ? 1f / residualNorm : 0f; - for (int d = 0; d < normalized.length; d++) - normalized[d] = (vector.get(d) - mean.get(d)) * invNorm; + if (vector instanceof FloatArray + && mean instanceof FloatArray) { + // Resolve heap backing once, including MemorySegment views. The + // simple array loop avoids per-component virtual/scope checks and + // permits JVM SIMD without changing the FP32 operation order. + float[] values = ((FloatArray) vector).array(); + float[] center = ((FloatArray) mean).array(); + for (int d = 0; d < normalized.length; d++) + normalized[d] = (values[d] - center[d]) * invNorm; + } else { + for (int d = 0; d < normalized.length; d++) + normalized[d] = (vector.get(d) - mean.get(d)) * invNorm; + } } /** diff --git a/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestASHBatchEncoding.java b/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestASHBatchEncoding.java index 95dd00476..0541b4d0e 100644 --- a/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestASHBatchEncoding.java +++ b/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestASHBatchEncoding.java @@ -21,6 +21,7 @@ import io.github.jbellis.jvector.vector.types.VectorFloat; import org.junit.Test; +import java.lang.reflect.Proxy; import java.util.Random; import java.util.concurrent.ForkJoinPool; import java.util.concurrent.atomic.AtomicInteger; @@ -204,4 +205,40 @@ public void invalidArgumentsAndSourceFailureArePropagatedAfterWorkersFinish() th assertEquals("No source worker may outlive encodeAll failure", 64, otherRangeReads.get()); } finally { pool.shutdown(); } } + + @Test + public void backingArrayNormalizationMatchesOpaqueVectorsBitForBit() throws Exception { + var types = VectorizationProvider.getInstance().getVectorTypeSupport(); + var normalize = AsymmetricHashing.class.getDeclaredMethod("normalizeResidual", + VectorFloat.class, + VectorFloat.class, float.class, float[].class); + normalize.setAccessible(true); + Random random = new Random(81231); + for (int dimensions : new int[]{0,1,15,16,17,31,32,33,768,3072}) { + var vector = types.createFloatVector(dimensions); + var mean = types.createFloatVector(dimensions); + for (int j = 0; j < dimensions; j++) { + vector.set(j, j % 7 == 0 ? -0.0f : random.nextFloat() - .5f); + mean.set(j, j % 7 == 0 ? 0.0f : random.nextFloat() - .5f); + } + var opaqueVector = (VectorFloat) Proxy.newProxyInstance( + getClass().getClassLoader(), new Class[]{VectorFloat.class}, + (proxy, method, args) -> method.invoke(vector, args)); + var opaqueMean = (VectorFloat) Proxy.newProxyInstance( + getClass().getClassLoader(), new Class[]{VectorFloat.class}, + (proxy, method, args) -> method.invoke(mean, args)); + for (float residualNorm : new float[]{0f,1f,3f,Float.MAX_VALUE}) { + float[] expected = new float[dimensions]; + normalize.invoke(null, opaqueVector, opaqueMean, residualNorm, expected); + for (var x : new VectorFloat[]{vector, opaqueVector}) + for (var mu : new VectorFloat[]{mean, opaqueMean}) { + float[] actual = new float[dimensions]; + normalize.invoke(null, x, mu, residualNorm, actual); + for (int j = 0; j < dimensions; j++) + assertEquals(Float.floatToRawIntBits(expected[j]), Float.floatToRawIntBits(actual[j])); + } + } + } + } + } From 1b84ae280b2aed8f7fe93f44a72175e42dbff78a Mon Sep 17 00:00:00 2001 From: Ted Willke Date: Sun, 1 Nov 2026 21:11:53 +0000 Subject: [PATCH 112/243] native: prefer oneDNN FP32 for ASH batch projection Use sequential oneDNN SGEMM from the existing add-on for bulk base-vector projection, sharing the immutable FP32 matrix and reusable calling-thread staging. Keep Highway for unavailable, older or unsupported add-ons; preserve single-vector FP32 encoding and query-only FP16 selection. Cover actual oneDNN execution, numeric range, arbitrary batch sizes, invalid inputs, concurrent models and fallback. --- .../vector/NativeASHFP32BatchProjection.java | 121 ++++++++++++ .../jvector/vector/NativeASHProjection.java | 4 + .../vector/NativeVectorUtilSupport.java | 2 +- .../jvector/vector/cnative/LibraryLoader.java | 2 +- .../src/main/native/src/jvector_ash_fp16.cpp | 27 ++- .../src/main/native/src/jvector_ash_fp16.h | 7 + .../NativeASHFP32BatchProjectionTest.java | 174 ++++++++++++++++++ .../vector/NativeASHProjectionTest.java | 12 ++ .../quantization/TestASHBatchEncoding.java | 10 +- 9 files changed, 352 insertions(+), 7 deletions(-) create mode 100644 jvector-native/src/main/java/io/github/jbellis/jvector/vector/NativeASHFP32BatchProjection.java create mode 100644 jvector-native/src/test/java/io/github/jbellis/jvector/vector/NativeASHFP32BatchProjectionTest.java diff --git a/jvector-native/src/main/java/io/github/jbellis/jvector/vector/NativeASHFP32BatchProjection.java b/jvector-native/src/main/java/io/github/jbellis/jvector/vector/NativeASHFP32BatchProjection.java new file mode 100644 index 000000000..6b593f7a7 --- /dev/null +++ b/jvector-native/src/main/java/io/github/jbellis/jvector/vector/NativeASHFP32BatchProjection.java @@ -0,0 +1,121 @@ +/* + * 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.cnative.LibraryLoader; +import java.lang.foreign.FunctionDescriptor; +import java.lang.foreign.Linker; +import java.lang.foreign.MemorySegment; +import java.lang.foreign.SymbolLookup; +import java.lang.invoke.MethodHandle; + +import static java.lang.foreign.ValueLayout.ADDRESS; +import static java.lang.foreign.ValueLayout.JAVA_INT; + +/** + * Preferred oneDNN FP32 GEMM for bulk base-vector projection, with Highway fallback. + * Owns no second matrix: the immutable row-major Highway matrix is shared. + * Single-vector projection and query precision selection stay with the delegate. + * Library scratch and JIT preparation are included in the caller's first use. + */ +final class NativeASHFP32BatchProjection implements VectorUtilSupport.AshProjection { + private static final class Bindings { + static final MethodHandle BATCH = load(); + private static MethodHandle load() { + // The optional package already embeds sequential oneDNN for FP16 + // queries. Older packages simply lack the new FP32 entry point. + if (!LibraryLoader.loadAshFp16()) return null; + try { + SymbolLookup lookup = SymbolLookup.loaderLookup(); + MethodHandle abi = Linker.nativeLinker().downcallHandle( + lookup.find("jvector_ash_fp16_abi") + .orElseThrow(() -> new UnsatisfiedLinkError("Missing ASH add-on ABI")), + FunctionDescriptor.of(JAVA_INT)); + if ((int) abi.invokeExact() != 1) + throw new UnsatisfiedLinkError("Unsupported ASH add-on ABI"); + return lookup.find("jvector_ash_fp32_project_batch") + .map(symbol -> Linker.nativeLinker().downcallHandle(symbol, + FunctionDescriptor.of(JAVA_INT, ADDRESS, ADDRESS, + JAVA_INT, JAVA_INT, JAVA_INT, ADDRESS))) + .orElse(null); + } catch (UnsatisfiedLinkError | IllegalCallerException | SecurityException + | UnsupportedOperationException unavailable) { + return null; + } catch (RuntimeException | Error failure) { + throw failure; + } catch (Throwable failure) { + throw new AssertionError("Unexpected ASH FP32 binding failure", failure); + } + } + } + + private final NativeASHProjection fallback; + + static VectorUtilSupport.AshProjection prepare(NativeASHProjection fallback) { + return Bindings.BATCH == null ? fallback : new NativeASHFP32BatchProjection(fallback); + } + + private NativeASHFP32BatchProjection(NativeASHProjection fallback) { + this.fallback = fallback; + } + + @Override + public long ramBytesUsed() { return fallback.ramBytesUsed(); } + + @Override + public void project(float[] input, float[] output) { fallback.project(input, output); } + + @Override + public VectorUtilSupport.AshProjection forQueryScoring() { return fallback.forQueryScoring(); } + + @Override + public void projectBatch(float[][] inputs, float[][] outputs, int count) { + VectorUtilSupport.AshProjection.validateBatch(inputs, outputs, count); + if (count == 0) return; + final int rows = fallback.rows(), columns = fallback.columns(); + if (inputs[0].length != columns || outputs[0].length != rows) + throw new IllegalArgumentException("ASH projection batch shape mismatch"); + long inputRowBytes = (long) columns * Float.BYTES; + long outputRowBytes = (long) rows * Float.BYTES; + NativeASHProjection.Buffers buffers = NativeASHProjection.buffers( + Math.multiplyExact(count, inputRowBytes), Math.multiplyExact(count, outputRowBytes)); + for (int i = 0; i < count; i++) + MemorySegment.copy(MemorySegment.ofArray(inputs[i]), 0, buffers.input, + i * inputRowBytes, inputRowBytes); + final int status; + try { + status = (int) Bindings.BATCH.invokeExact(fallback.matrix(), buffers.input, + rows, columns, count, buffers.output); + } catch (RuntimeException | Error error) { + throw error; + } catch (Throwable error) { + throw new AssertionError("Unexpected exception from oneDNN FP32 projection", error); + } + // oneDNN status values are translated by the C wrapper. Unsupported + // kernels retain Highway; allocation/runtime failures are never hidden. + if (status == 3) { + fallback.projectBatch(inputs, outputs, count); + return; + } + if (status == 1) throw new OutOfMemoryError("oneDNN FP32 projection allocation failed"); + if (status == 2) throw new IllegalArgumentException("Invalid oneDNN FP32 projection arguments"); + if (status != 0) throw new IllegalStateException("oneDNN FP32 projection failed: " + status); + for (int i = 0; i < count; i++) + MemorySegment.copy(buffers.output, i * outputRowBytes, + MemorySegment.ofArray(outputs[i]), 0, outputRowBytes); + } +} diff --git a/jvector-native/src/main/java/io/github/jbellis/jvector/vector/NativeASHProjection.java b/jvector-native/src/main/java/io/github/jbellis/jvector/vector/NativeASHProjection.java index 46d2a08a3..419f0a1ab 100644 --- a/jvector-native/src/main/java/io/github/jbellis/jvector/vector/NativeASHProjection.java +++ b/jvector-native/src/main/java/io/github/jbellis/jvector/vector/NativeASHProjection.java @@ -110,6 +110,10 @@ public long ramBytesUsed() { return matrix.byteSize() + (packed == null ? 0 : packed.byteSize()); } + int rows() { return rows; } + int columns() { return columns; } + MemorySegment matrix() { return matrix; } + private MemorySegment batchMatrix() { MemorySegment ready = batchMatrix; if (ready == null) { 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 b02b063e0..ce64fe443 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 @@ -94,7 +94,7 @@ public VectorFloat nvqSub(VectorFloat a, int aOffset, VectorFloat b, in @Override public AshProjection prepareAshProjection(float[][] matrix) { return NativeASHProjection.isAvailable() - ? new NativeASHProjection(matrix) : super.prepareAshProjection(matrix); + ? NativeASHFP32BatchProjection.prepare(new NativeASHProjection(matrix)) : super.prepareAshProjection(matrix); } @Override diff --git a/jvector-native/src/main/java/io/github/jbellis/jvector/vector/cnative/LibraryLoader.java b/jvector-native/src/main/java/io/github/jbellis/jvector/vector/cnative/LibraryLoader.java index f560987ec..21259fd33 100644 --- a/jvector-native/src/main/java/io/github/jbellis/jvector/vector/cnative/LibraryLoader.java +++ b/jvector-native/src/main/java/io/github/jbellis/jvector/vector/cnative/LibraryLoader.java @@ -61,7 +61,7 @@ static String resourceNameForArch(String library) { /// blocks loading (`SecurityException`). The bundled copy isn't retried after access denial. public static boolean loadJvector() { return loadLibrary("jvector"); } - /** Loads the optional, self-contained ASH FP16 library without changing Highway availability. */ + /** Loads the optional oneDNN add-on for FP32 base batches and FP16 queries; Highway stays independent. */ public static boolean loadAshFp16() { return loadLibrary("jvector_ash_fp16"); } private static boolean loadLibrary(String library) { diff --git a/jvector-native/src/main/native/src/jvector_ash_fp16.cpp b/jvector-native/src/main/native/src/jvector_ash_fp16.cpp index c75ac9639..9fea07dcb 100644 --- a/jvector-native/src/main/native/src/jvector_ash_fp16.cpp +++ b/jvector-native/src/main/native/src/jvector_ash_fp16.cpp @@ -25,9 +25,9 @@ #include #include -// Search callers own parallelism. This library must never start a nested pool. +// Calling workers own parallelism. This library must never start a nested pool. static_assert(DNNL_CPU_RUNTIME == DNNL_RUNTIME_SEQ, - "ASH FP16 requires the sequential oneDNN CPU runtime"); + "ASH projection requires the sequential oneDNN CPU runtime"); static_assert(offsetof(jvector_ash_fp16_result, message) == sizeof(int32_t)); static_assert(sizeof(jvector_ash_fp16_result) == sizeof(int32_t) + 256); @@ -196,3 +196,26 @@ int jvector_ash_fp16_project(void* pointer, const float* input, float* output, } } + +// Row-major FP32 GEMM. This API performs no reduced-precision conversion and +// uses the package's sequential runtime, leaving parallelism with encodeAll. +extern "C" int jvector_ash_fp32_project_batch(const float* matrix, + const float* input, int rows, int columns, int count, float* output) { + if (!matrix || !input || !output || rows <= 0 || columns <= 0 || count <= 0) return 2; + try { + const auto status = dnnl_sgemm('N', 'T', count, rows, columns, + 1.0f, input, columns, matrix, columns, 0.0f, output, rows); + // Keep the add-on's small ABI independent of oneDNN enum extensions. + switch (status) { + case dnnl_success: return 0; + case dnnl_out_of_memory: return 1; + case dnnl_invalid_arguments: return 2; + case dnnl_unimplemented: return 3; + default: return 5; + } + } catch (const std::bad_alloc&) { + return 1; + } catch (...) { + return 5; + } +} diff --git a/jvector-native/src/main/native/src/jvector_ash_fp16.h b/jvector-native/src/main/native/src/jvector_ash_fp16.h index 3d5cfdf4f..c45aca81c 100644 --- a/jvector-native/src/main/native/src/jvector_ash_fp16.h +++ b/jvector-native/src/main/native/src/jvector_ash_fp16.h @@ -57,6 +57,13 @@ JV_ASH_FP16_EXPORT uint64_t jvector_ash_fp16_bytes(const void* context); JV_ASH_FP16_EXPORT int jvector_ash_fp16_project(void* context, const float* input, float* output, jvector_ash_fp16_result* result); +/* FP32 bulk projection, using the same sequential oneDNN package. + * Matrix is row-major [rows][columns], input [count][columns], output [count][rows]. + * Java validates nonaliasing storage and lengths. Returns 0 success, 1 allocation + * failure, 2 invalid arguments, 3 unsupported, or 5 runtime failure. */ +JV_ASH_FP16_EXPORT int jvector_ash_fp32_project_batch(const float* matrix, + const float* input, int rows, int columns, int count, float* output); + #ifdef __cplusplus } #endif diff --git a/jvector-native/src/test/java/io/github/jbellis/jvector/vector/NativeASHFP32BatchProjectionTest.java b/jvector-native/src/test/java/io/github/jbellis/jvector/vector/NativeASHFP32BatchProjectionTest.java new file mode 100644 index 000000000..2eb56e305 --- /dev/null +++ b/jvector-native/src/test/java/io/github/jbellis/jvector/vector/NativeASHFP32BatchProjectionTest.java @@ -0,0 +1,174 @@ +/* + * 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.cnative.LibraryLoader; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import java.lang.foreign.Arena; +import java.lang.foreign.FunctionDescriptor; +import java.lang.foreign.Linker; +import java.lang.foreign.SymbolLookup; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Random; +import java.util.concurrent.CyclicBarrier; +import java.util.concurrent.Executors; +import static java.lang.foreign.ValueLayout.ADDRESS; +import static java.lang.foreign.ValueLayout.JAVA_INT; +import static java.lang.foreign.ValueLayout.JAVA_FLOAT; +import static org.junit.jupiter.api.Assertions.*; +import static org.junit.jupiter.api.Assumptions.assumeTrue; + +/** Actual optional oneDNN FP32 execution, separate from Highway's arithmetic tests. */ +class NativeASHFP32BatchProjectionTest { + @BeforeAll + static void requireBackend() { + boolean nativeAvailable = LibraryLoader.loadJvector(); + if (Boolean.getBoolean("jvector.test.requireNative")) assertTrue(nativeAvailable); + else assumeTrue(nativeAvailable); + var projection = prepared(new float[][]{{1}}); + if (Boolean.getBoolean("jvector.test.requireFp32OneDnn")) + assertInstanceOf(NativeASHFP32BatchProjection.class, projection); + else assumeTrue(projection instanceof NativeASHFP32BatchProjection, "Optional oneDNN FP32 unavailable"); + } + + private static VectorUtilSupport.AshProjection prepared(float[][] matrix) { + return NativeASHFP32BatchProjection.prepare(new NativeASHProjection(matrix)); + } + + private static float[][] random(Random random, int rows, int cols) { + float[][] result = new float[rows][cols]; + for (float[] row : result) for (int c = 0; c < cols; c++) row[c] = random.nextFloat() - .5f; + return result; + } + + private static void oracle(float[][] matrix, float[][] inputs, float[][] outputs, int count) { + for (int b = 0; b < count; b++) for (int r = 0; r < matrix.length; r++) { + double sum = 0, magnitude = 0; + for (int c = 0; c < matrix[r].length; c++) { + double value = (double) matrix[r][c] * inputs[b][c]; + sum += value; magnitude += Math.abs(value); + } + assertEquals(sum, outputs[b][r], 5e-6 * magnitude + 1e-7); + } + } + + @Test + void optionalSymbolExecutesOneDnnWithoutHighwayFallback() throws Throwable { + var descriptor = FunctionDescriptor.of( + JAVA_INT, + ADDRESS, ADDRESS, + JAVA_INT, JAVA_INT, + JAVA_INT, ADDRESS); + var symbol = SymbolLookup.loaderLookup() + .find("jvector_ash_fp32_project_batch").orElseThrow(); + var call = Linker.nativeLinker().downcallHandle(symbol, descriptor); + try (var arena = Arena.ofConfined()) { + var matrix = arena.allocate(4, 4); var input = arena.allocate(4, 4); + var output = arena.allocate(4, 4); + matrix.set(JAVA_FLOAT, 0, 2f); + input.set(JAVA_FLOAT, 0, 3f); + int status = (int) call.invokeExact(matrix, input, 1, 1, 1, output); + assertEquals(0, status, "Actual oneDNN SGEMM must succeed, not compatibility fallback"); + assertEquals(6f, output.get(JAVA_FLOAT, 0)); + assertEquals(2, (int) call.invokeExact(matrix, input, 0, 1, 1, output)); + } + } + + @Test + void fp32ArithmeticSupportsArbitraryBatchSizesAndSimdTails() { + Random random = new Random(32145); + for (int[] shape : new int[][]{{1,1},{3,7},{33,65},{384,768},{1536,3072}}) { + float[][] matrix = random(random, shape[0], shape[1]); + var projection = prepared(matrix); + for (int count : new int[]{0,1,3,4,8,17,32,33,65}) { + float[][] inputs = random(random, count + 1, shape[1]); + float[][] saved = Arrays.stream(inputs).map(float[]::clone).toArray(float[][]::new); + float[][] outputs = new float[count + 1][shape[0]]; + Arrays.fill(outputs[count], 123f); + projection.projectBatch(inputs, outputs, count); + oracle(matrix, inputs, outputs, count); + for (int i = 0; i <= count; i++) assertArrayEquals(saved[i], inputs[i]); + for (float value : outputs[count]) assertEquals(123f, value); + } + assertEquals((long) shape[0] * shape[1] * Float.BYTES, projection.ramBytesUsed()); + } + } + + @Test + void preservesFullPrecisionWeightsAndCopiesModelOnlyAtPreparation() { + float weight = 1f + Math.scalb(1f, -12), value = 1f + Math.scalb(1f, -13); + float[][] matrix = {{weight}}; + var projection = prepared(matrix); + matrix[0][0] = 0; + float[][] outputs = new float[33][1], inputs = new float[33][1]; + for (float[] input : inputs) input[0] = value; + projection.projectBatch(inputs, outputs, 33); + for (float[] output : outputs) assertEquals(weight * value, output[0], 0f); + float[] singleton = new float[1]; + projection.project(new float[]{value}, singleton); + assertEquals(weight * value, singleton[0], 0f); + var fullRange = prepared(new float[][]{{70000f}}); + float[][] rangeOutput = new float[1][1]; + fullRange.projectBatch(new float[][]{{2f}}, rangeOutput, 1); + assertEquals(140000f, rangeOutput[0][0], 0f); + } + + @Test + void validatesEveryInputBeforeWritingOutputs() { + var projection = prepared(new float[][]{{1,2},{3,4}}); + float[][] x = {{1,2},{3,4}}, y = {{99,99},{99,99}}; + assertThrows(IllegalArgumentException.class, () -> projection.projectBatch(x, y, -1)); + assertThrows(IllegalArgumentException.class, () -> projection.projectBatch(x, y, 3)); + assertThrows(IllegalArgumentException.class, () -> projection.projectBatch(x, x, 2)); + assertThrows(IllegalArgumentException.class, () -> projection.projectBatch(x, new float[][]{y[0],x[0]}, 2)); + assertThrows(IllegalArgumentException.class, () -> projection.projectBatch(x, new float[][]{y[0],y[0]}, 2)); + assertThrows(IllegalArgumentException.class, () -> projection.projectBatch(new float[][]{x[0],{1}}, y, 2)); + assertThrows(IllegalArgumentException.class, () -> projection.projectBatch(new float[][]{{1}}, new float[][]{{1,2}}, 1)); + assertThrows(NullPointerException.class, () -> projection.projectBatch(null, y, 1)); + assertThrows(NullPointerException.class, () -> projection.projectBatch(new float[][]{x[0],null}, y, 2)); + for (float[] row : y) for (float value : row) assertEquals(99f, value); + projection.projectBatch(new float[0][], new float[0][], 0); + float[][] largeX = new float[65][2], largeY = new float[65][2]; + largeY[64] = largeX[17]; + assertThrows(IllegalArgumentException.class, () -> projection.projectBatch(largeX, largeY, 65)); + } + + @Test + void concurrentCallersCanSwitchMatricesAndSurviveCollection() throws Exception { + Random random = new Random(8419); + float[][] a = random(random, 33, 65), b = random(random, 129, 257); + var first = prepared(a); var second = prepared(b); + CyclicBarrier barrier = new CyclicBarrier(8); + try (var pool = Executors.newFixedThreadPool(8)) { + var tasks = new ArrayList>(); + for (int t = 0; t < 8; t++) tasks.add(() -> { + float[][] x = random(new Random(14), 33, 65), z = random(new Random(15), 5, 257); + float[][] y = new float[33][33], w = new float[5][129]; + barrier.await(); + for (int i = 0; i < 8; i++) { + first.projectBatch(x, y, 33); second.projectBatch(z, w, 5); + oracle(a, x, y, 33); oracle(b, z, w, 5); + if (i == 4) System.gc(); + } + return null; + }); + for (var result : pool.invokeAll(tasks)) result.get(); + } + } +} diff --git a/jvector-native/src/test/java/io/github/jbellis/jvector/vector/NativeASHProjectionTest.java b/jvector-native/src/test/java/io/github/jbellis/jvector/vector/NativeASHProjectionTest.java index 5d10d4d42..ba92aaee2 100644 --- a/jvector-native/src/test/java/io/github/jbellis/jvector/vector/NativeASHProjectionTest.java +++ b/jvector-native/src/test/java/io/github/jbellis/jvector/vector/NativeASHProjectionTest.java @@ -62,6 +62,18 @@ private static void checkOracle(float[][] matrix, float[][] inputs, float[][] ou } } + @Test + void providerRetainsHighwayWhenOptionalFp32IsUnavailable() { + var projection = new NativeVectorUtilSupport().prepareAshProjection(new float[][]{{1,2},{3,4}}); + if (Boolean.getBoolean("jvector.test.requireFp32OneDnn")) + assertInstanceOf(NativeASHFP32BatchProjection.class, projection); + if (Boolean.getBoolean("jvector.test.requireFp32Absent")) + assertInstanceOf(NativeASHProjection.class, projection); + float[][] outputs = new float[1][2]; + projection.projectBatch(new float[][]{{2,3}}, outputs, 1); + assertArrayEquals(new float[]{8,18}, outputs[0]); + } + @Test void nativeAndPanamaMatchDoubleOracleAcrossBatchAndSimdTails() { Random random = new Random(829104); diff --git a/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestASHBatchEncoding.java b/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestASHBatchEncoding.java index 0541b4d0e..a359afb9a 100644 --- a/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestASHBatchEncoding.java +++ b/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestASHBatchEncoding.java @@ -121,9 +121,13 @@ public void bulkMatchesSingleForTailsMissingValuesAndBorrowedStorage() throws Ex ? AsymmetricHashing.QuantizedVector.createEmpty(ash.quantizedDim, bits) : ash.encode(input[i]); try { - // A fixed projection implementation must be - // byte-stable across batch tails and workers. - assertCode(firstBulkResult.get(i), actual.get(i)); + // A BLAS implementation may choose a different FP32 + // reduction for a worker's batch shape. Preserve exact + // 1/2/3/4-bit checks; use the existing reconstruction + // error bound for discontinuous 9-bit codes/half scales. + if (bits == 9 && input[i] != null) + assertHighPrecisionCodeCompatible(firstBulkResult.get(i), actual.get(i), ash.quantizedDim); + else assertCode(firstBulkResult.get(i), actual.get(i)); if (bits == 9 && input[i] != null) assertHighPrecisionCodeCompatible(expected, actual.get(i), ash.quantizedDim); else assertCode(expected, actual.get(i)); From 6eaec4ceca53dc23fbc2a70cebf617a076033d38 Mon Sep 17 00:00:00 2001 From: Ted Willke Date: Sun, 1 Nov 2026 21:11:53 +0000 Subject: [PATCH 113/243] docs: recommend oneDNN for native ASH installation Document JDK/CMake prerequisites, the recommended packaged oneDNN build, automatic projection preference and Highway fallback. Record repeated BenchYAML encoding results and their scope. --- README.md | 16 +++++ docs/native-ash-preparation.md | 63 +++++++++++++++++++ jvector-native/src/main/native/README.md | 60 +++++++++++++++--- .../src/main/native/src/build_native_lib.sh | 2 +- 4 files changed, 130 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index a0a1bec56..9f6b31129 100644 --- a/README.md +++ b/README.md @@ -128,6 +128,22 @@ git clone --recurse-submodules ### Building native libraries +**For ASH on JDK 22 or newer, oneDNN is the recommended native installation.** +It is automatically preferred for FP32 base-vector batch projection and FP16 +query projection on supported CPUs. Highway FP32 remains the portable fallback. +Install CMake 3.13+ in addition to the C++ compiler, Meson, and Ninja, then build +from the repository root: + +```bash +mvn -pl jvector-native -am package -Dnative.onednn=true +``` + +Meson fetches the pinned oneDNN source and Maven packages its statically linked +add-on with the native JAR. Deploy that JAR with the native provider; no separate +oneDNN runtime installation or per-machine library symlinks are required. +See the [oneDNN installation and fallback details](./jvector-native/src/main/native/README.md#recommended-onednn-build-and-package). + + The native SIMD library (`libjvector.so`) is built with [Meson](https://mesonbuild.com/) + [Ninja](https://ninja-build.org/) and requires **g++ 11+**. Supported platforms: **Linux x86-64** (SSE4.2, AVX2, AVX-512) and **Linux AArch64** (NEON, SVE, SVE2). The entry-point script is `jvector-native/src/main/native/src/build_native_lib.sh`. Run it from that directory. diff --git a/docs/native-ash-preparation.md b/docs/native-ash-preparation.md index 6a484a1ed..c0d40b037 100644 --- a/docs/native-ash-preparation.md +++ b/docs/native-ash-preparation.md @@ -166,3 +166,66 @@ the actual dispatched C++ kernels and require the new batch symbol. Fresh graph recall was also recorded, but these independent parallel graph builds do not isolate an encoding-only recall effect. Model and graph formats, query FP16 arithmetic, and scoring kernels were not changed by this extension. + +## Preferred oneDNN FP32 encoding projection (November 1, 2026) + +The native provider automatically prefers the existing sequential oneDNN add-on +for FP32 `projectBatch()` when its new entry point is available. It executes row-major +SGEMM as `[B,D] * transpose([d,D]) -> [B,d]`, with FP32 inputs, weights and outputs. +The adapter shares the immutable Highway matrix, avoiding a second packed model +copy, and reuses the existing calling-thread native input/output buffers. Its +batch API accepts arbitrary positive counts; no eight-vector cap or padding +controls were introduced. The library's existing `encodeAll()` bound remains 32. +The sequential-runtime build assertion prevents nested oneDNN thread pools. + +Single-vector `encode()` retains Highway FP32 projection. The wrapper delegates +query precision selection to the same original projection: FP16 queries remain +FP16 in automatic/required modes, with their existing FP32 fallback. Database +encoding never converts to FP16. Java code selection and landmark correction +remain unchanged. The slower experimental native quantizer was archived outside +the production tree after repeated MPNet 2-bit regressions; it is not packaged. + +The optional library retains its existing package/resource name for compatibility. +Both FP32 and FP16 loaders validate ABI 1, handle missing/denied/unsupported +libraries, and retain Highway when optional support is absent. An older add-on +with ABI 1 but without the FP32 symbol also keeps Highway. Unsupported oneDNN +FP32 execution falls back to Highway batch projection; invalid arguments, +allocation failures and unexpected runtime errors propagate. FP16 additionally +checks representable range, and explicit `fp16` selection requires support. +No production selection flag, benchmark-specific path or BenchYAML change was +added. The existing `-Dnative.onednn=true` build packages both operations. + +BLAS kernels can use different FP32 reduction orders for different batch shapes. +The 9-bit reconstruction check therefore uses its existing per-component code +step error bound for worker/batch comparisons as well as singleton comparisons; +sign bits, offsets and the full 1/2/3/4-bit code/header checks remain exact in +those tests. Separate tests compare oneDNN output to a double-precision oracle, +cover FP32 values outside FP16 precision/range, caller aliasing and invalid +shapes, batch tails/counts above 32, immutable model copies and concurrent model +switching. A direct ABI test requires successful oneDNN SGEMM, preventing a +Highway fallback from passing as a oneDNN test. + +### Repeated BenchYAML measurements + +Each case used three alternating matched control/candidate pairs with JDK 22, +CPUs 0-47 and NUMA interleave across nodes 0 and 1. Both variants include the same +backing-array residual-normalization improvement and Java code generator. Both +use C=1, ITQ, D/2, deferred parallel writing and NVQ reranking. Each process builds +fresh model/index caches; input/output copies, library/JIT first use and scratch +allocation are included in BenchYAML's existing `encoding_time_s` field. These +are `encodeAll()` times, not standalone `encode()` or query-throughput results. + +| Case | Highway median (range), s | oneDNN median (range), s | Ratio of medians | +| --- | ---: | ---: | ---: | +| mpnet-1m, 2-bit | 0.665 (0.622-0.665) | 0.589 (0.550-0.604) | 1.13x | +| openai-3072-1m, 4-bit | 4.055 (3.996-4.092) | 3.926 (3.913-3.955) | 1.03x | + +oneDNN was faster in each of the three matched pairs for both datasets. The +OpenAI3072 gain is modest; these measurements do not establish improvements for +all other datasets, code widths or hardware. Median graph recall@10 was 81.901% +versus 81.844% for MPNet and 79.280% versus 79.310% for OpenAI3072. Independent +parallel graph rebuilds vary and do not isolate an encoding-only recall effect. +Training and graph-write timers/recall for every run are retained in the raw +CSVs; they are not manually added to inclusive harness timers. Original CSVs, +commands, class/source snapshots and live placement observations are preserved +under `benchmark-runs/ash-quantization-20261101` on the development server. diff --git a/jvector-native/src/main/native/README.md b/jvector-native/src/main/native/README.md index 4e9d97b59..061f15d68 100644 --- a/jvector-native/src/main/native/README.md +++ b/jvector-native/src/main/native/README.md @@ -58,6 +58,8 @@ third_party/highway/ - Google Highway header-only library (git submodul | clang++ (AArch64) | Clang any recent | NEON tier always built; SVE/SVE2 require **Clang ≥ 22** — older Clang produces a NEON-only build with a warning | | [Meson](https://mesonbuild.com/) | 0.55 | `pip install meson` | | [Ninja](https://ninja-build.org/) | any | `sudo apt install ninja-build` | +| CMake (recommended ASH/oneDNN build) | 3.13+ | `sudo apt install cmake`; needed in addition to Meson and Ninja | +| JDK (native ASH) | 22+ | Final FFM API; build and run the native provider with supported native-access settings | | Git submodules | — | `git submodule update --init` (needed once) | > **AArch64 compiler note:** On Clang < 22, the SVE and SVE2 tiers are @@ -66,12 +68,23 @@ third_party/highway/ - Google Highway header-only library (git submodul > runtime even on SVE2-capable hardware (e.g. Graviton 4). Upgrade to > Clang ≥ 22 or use GCC to get full SVE/SVE2 acceleration. +For ASH, include oneDNN: it is the preferred projection implementation for FP32 +encoding batches and FP16 queries where supported. It is built from the pinned +source and bundled; users do not need a separately installed `libdnnl`. +A first online build needs access to the source URL in `subprojects/onednn.wrap`; +offline builds can populate that exact revision beforehand. On Ubuntu, install +the native build prerequisites with: + +```bash +sudo apt-get install g++ meson ninja-build cmake +``` + ### Build steps Run the build script from this directory: ```bash -bash src/build_native_lib.sh [buildtype] [crossarch] +bash src/build_native_lib.sh [buildtype] [crossarch] [onednn] ``` Parameters (all optional): @@ -80,6 +93,13 @@ Parameters (all optional): |-----------|---------|-------------| | `buildtype` | `release` | `release`, `debug`, or `debugoptimized` | | `crossarch` | `false` | Set to `true` to also cross-compile for the other arch | +| `onednn` | `false` | **Recommended for ASH:** set to `true` to bundle FP32 batch and FP16 query projection support | + +For the recommended ASH build, pass all three arguments: + +```bash +bash src/build_native_lib.sh release false true +``` The script: 1. Detects the host arch via `uname -m` (x86_64 or aarch64). @@ -153,7 +173,14 @@ select the correct ISA variant list (x86 tiers vs AArch64 tiers) automatically. ### Building with Maven -From the project root, you can build the native module using Maven: +From the project root, the recommended ASH build includes oneDNN: + +```bash +mvn -pl jvector-native -am package -Dnative.onednn=true +``` + +It requires the CMake prerequisite above. The commands below build Highway +without oneDNN unless `-Dnative.onednn=true` is appended. **Native arch only — release (default):** ```bash @@ -852,10 +879,13 @@ Strict FP16 accepts only finite matrix/query values with magnitude at most precision can change scores and rankings slightly; compare recall for the target workload when selecting a precision. -### Optional build and package +### Recommended oneDNN build and package + +For ASH, build and deploy the oneDNN add-on with JDK 22 or newer. It is +**automatically preferred** for FP32 encoding batches and supported FP16 queries; +there is no additional runtime opt-in. Highway stays independent and supplies +FP32 fallback when the add-on or the requested operation is unavailable: -The existing Highway library has no new dependency. To include the separate -FP16 library, build with JDK 22 and: ```bash mvn -pl jvector-native -am package -Dnative.onednn=true @@ -867,7 +897,7 @@ For a direct native build, the third script argument enables the component: bash src/build_native_lib.sh release false true ``` -The optional build requires CMake in addition to the native prerequisites. Meson +The recommended oneDNN build requires CMake 3.13+ in addition to the native prerequisites. Meson fetches the exact oneDNN revision pinned in `subprojects/onednn.wrap`; an offline build can populate `subprojects/onednn` with that revision beforehand. oneDNN is built with the sequential CPU runtime, no GPU support, and only inference @@ -878,11 +908,21 @@ oneDNN is statically linked into `libjvector_ash_fp16-.so`. Maven packages this library and the upstream license/third-party notices beside the Highway resource. The existing architecture-aware loader first tries the system library path, then extracts the matching classpath resource. No external `libdnnl` or -benchmark-specific path is required. Omitting `-Dnative.onednn=true` leaves FP16 -out of the package and removes stale optional resources from previous builds. +benchmark-specific path is required. The resource keeps its existing FP16 name +for binary compatibility but supplies both FP32 and FP16 projection. Omitting +`-Dnative.onednn=true` leaves oneDNN projection out of the package and removes +stale add-on resources from previous builds; Highway FP32 is then used. The optional component follows the native module's Linux platform support; cross-compilation still requires the corresponding target toolchain. +FP32 batch projection retains the original FP32 matrix and uses sequential +oneDNN SGEMM without converting database operands to FP16. Single-vector encoding +continues to use Highway FP32. The FP32 loader validates the same ABI as FP16; +missing/older add-ons and unsupported operations keep Highway. FP16 automatic +mode additionally falls back for operands outside FP16's finite range. Explicit +`fp16` mode requires support; invalid inputs, allocation failures and execution +errors propagate. See [implementation and measured encoding scope](../../../../docs/native-ash-preparation.md#preferred-onednn-fp32-encoding-projection-november-1-2026). + ### Ownership, parallelism and tests Each model owns its immutable prepared FP16 weights and a single-query execution @@ -896,10 +936,10 @@ The native-module Java tests retain FP32 coverage and check FP16 against an independent rounded-operand oracle, irregular and large dimensions, numeric boundaries, input guards, model switching, concurrent platform/virtual threads, query-only precision and loading without the optional library. On a machine -known to support FP16, require execution rather than capability skips with: +known to support FP16, require both oneDNN paths rather than capability skips with: ```bash -mvn -pl jvector-native -am test -Dnative.onednn=true -Djvector.test.requireFp16=true +mvn -pl jvector-native -am test -Dnative.onednn=true -Djvector.test.requireFp16=true -Djvector.test.requireFp32OneDnn=true ``` With Google Test installed, Meson also builds `test_ash_fp16`; run it with diff --git a/jvector-native/src/main/native/src/build_native_lib.sh b/jvector-native/src/main/native/src/build_native_lib.sh index 7da7f81e9..2b7bd318b 100755 --- a/jvector-native/src/main/native/src/build_native_lib.sh +++ b/jvector-native/src/main/native/src/build_native_lib.sh @@ -54,7 +54,7 @@ printf "BUILDTYPE=%s\n" "${BUILDTYPE}" CROSSARCH="${2:-false}" printf "CROSSARCH=%s\n" "${CROSSARCH}" -# Optional FP16 query library; it never changes libjvector's dependencies. +# oneDNN add-on for FP32 encoding batches and FP16 queries; libjvector stays independent. ONEDNN="${3:-false}" case "${ONEDNN}" in true) ONEDNN_FEATURE=enabled ;; From a35367b669b5bb848550ba75086d3eb3e3aba85c Mon Sep 17 00:00:00 2001 From: Ted Willke Date: Sun, 1 Nov 2026 23:53:08 +0000 Subject: [PATCH 114/243] ash: prepare single-landmark training from one shared sample Reuse the deterministic training sample for the C=1 mean, PCA and ITQ. Read each selected source vector once and prepare centered FP64 inputs directly in column-major tiles instead of materializing the full dataset. Validate source metadata, sampled dimensions and norms, and matrix sizes. Preserve FP64 ITQ arithmetic, random initialization, multi-landmark training, and the existing model and graph formats. Add regression coverage for sampled reads, borrowed storage, numeric order, RNG state, invalid inputs, zero/constant data, and array limits. Document the sample policy, precision, and cache compatibility. --- docs/native-ash-preparation.md | 40 ++ .../quantization/AsymmetricHashing.java | 275 ++++++++++---- .../quantization/TestASHTrainingSetup.java | 351 ++++++++++++++++++ 3 files changed, 588 insertions(+), 78 deletions(-) create mode 100644 jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestASHTrainingSetup.java diff --git a/docs/native-ash-preparation.md b/docs/native-ash-preparation.md index c0d40b037..a41882a94 100644 --- a/docs/native-ash-preparation.md +++ b/docs/native-ash-preparation.md @@ -87,6 +87,46 @@ widths, multiple landmarks, zero residuals, destination reuse, source failure, and independence from the query precision setting. Existing model and graph formats are unchanged. +## Training sample preparation + +For a single landmark (`C=1`), `AsymmetricHashing.initialize()` computes the mean, +PCA inputs and ITQ training inputs from one uniform reservoir sample of +`min(N, 20 * D)` vectors, where `D` is the original dimension. The sample size +is independent of the projected dimension and encoded bit width. Selection uses +the existing deterministic seed, 103, and selected ordinals are sorted before +reading. Only selected source vectors are fetched, once each; encoding performs +the full pass over the source vectors afterward. + +Each selected vector is copied into one reusable normalization buffer before +another source read, so borrowed source storage is never retained or modified. +ITQ retains normalized sample values in a flat FP32 array. The mean is accumulated +in FP64 and stored as the existing FP32 landmark. Centered residuals are normalized +in FP64 and copied in bounded tiles directly into the column-major training +matrix, avoiding an array of per-vector objects and a second full-size FP64 +matrix conversion. PCA, the ITQ loop, and final projection construction retain +their existing FP64 arithmetic and BLAS/LAPACK backend. The +[PCA validation contract](ash-pca-native-svd.md) is unchanged. + +The `RANDOM` optimizer estimates its single landmark from the same deterministic +sample, using an independent sampler so the Gaussian projection's existing random +sequence is preserved. Multi-landmark training retains its clustering and full +input-materialization path. + +Empty sources, invalid dimensions/configurations, and training matrices that +cannot fit a Java array are rejected before gathering vectors. Selected inputs +must be non-null, have the declared dimension, and have a finite norm under the +provider's FP32 normalization. Non-finite values and FP32 norm overflow are +rejected with the offending ordinal. Zero and small-norm vectors retain the +existing normalization behavior. Source read failures propagate to the caller. +Training validates selected vectors only; it does not fetch unsampled vectors +for validation. Allocation failures are not swallowed or retried with a different +training sample. + +Saved model and graph formats are unchanged. Loading an existing model preserves +its stored landmark and projection. Retrain the compressor and rebuild its graph +cache to adopt sampled-mean training; the new mean is used consistently by +encoding and scoring. + ## Reference implementation review Reviewed IVF ASH Fastscan at commit diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/AsymmetricHashing.java b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/AsymmetricHashing.java index 308f22376..d69eb3b47 100644 --- a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/AsymmetricHashing.java +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/AsymmetricHashing.java @@ -24,6 +24,7 @@ import io.github.jbellis.jvector.util.Accountable; import io.github.jbellis.jvector.util.RamUsageEstimator; import io.github.jbellis.jvector.vector.VectorUtil; +import io.github.jbellis.jvector.vector.VectorUtilSupport; import io.github.jbellis.jvector.vector.VectorizationProvider; import io.github.jbellis.jvector.vector.types.ByteSequence; import io.github.jbellis.jvector.vector.types.FloatArray; @@ -61,6 +62,12 @@ public class AsymmetricHashing implements VectorCompressorWith one landmark, the mean and learned projection use the same + * deterministic sample of {@code min(ravv.size(), 20 * ravv.dimension())} + * vectors. Training fetches each sampled vector once and leaves the full + * dataset pass to encoding. The sample size uses the original dimension, + * independently of the projected dimension and stored bit width.

    + * * @param ravv the vectors to quantize * @param optimizer the optimizer to use * @param encodedBits the number of bits used to encode vector, including the header * @param landmarkCount number of landmarks * @param bitsPerDimension number of stored bits per projected dimension + * @throws IllegalArgumentException if the configuration is invalid, the source + * is empty, or a sampled vector has an invalid dimension or non-finite norm + * @throws NullPointerException if the source or a sampled vector is null */ public static AsymmetricHashing initialize(RandomAccessVectorValues ravv, int optimizer, @@ -275,9 +293,19 @@ public static AsymmetricHashing initialize(RandomAccessVectorValues ravv, "landmarkCount must be in [1,256], got " + landmarkCount); } - var ravvCopy = ravv.threadLocalSupplier().get(); - int originalDim = ravvCopy.getVector(0).length(); + Objects.requireNonNull(ravv, "ravv"); + if (ravv.size() <= 0) { + throw new IllegalArgumentException("ASH training requires at least one vector"); + } + if (optimizer != RANDOM && optimizer != ITQ) { + throw new IllegalArgumentException("Unknown optimizer " + optimizer); + } + int originalDim = ravv.dimension(); + if (originalDim <= 0) { + throw new IllegalArgumentException("ASH training requires a positive vector dimension"); + } final int quantizedDim = validateEncodedBits(encodedBits, HEADER_BITS, originalDim, bitsPerDimension); + var ravvCopy = ravv.threadLocalSupplier().get(); System.out.println( "\tASH initialized with landmarkCount=" + landmarkCount + @@ -285,83 +313,43 @@ public static AsymmetricHashing initialize(RandomAccessVectorValues ravv, ", bitsPerDimension=" + bitsPerDimension ); - // NOTE: points are treated as read-only by KMeansPlusPlusClusterer. - // Materialize and L2-normalize points - VectorFloat[] points = new VectorFloat[ravvCopy.size()]; - for (int i = 0; i < ravvCopy.size(); i++) { - VectorFloat v = ravvCopy.getVector(i).copy(); // copy to avoid mutating original source - float norm = (float) Math.sqrt(VectorUtil.dotProduct(v, v)); - if (norm > 1e-10f) { - VectorUtil.scale(v, 1.0f / norm); - } - points[i] = v; - } - + // C=1 uses the same sample for its mean and learned projection. + // Sample selection preserves ITQ's existing RNG sequence. + final Random rng = new Random(TRAINING_SEED); VectorFloat[] landmarks; + TrainingData trainingData = null; if (landmarkCount == 1) { - double[] muAccumulator = new double[originalDim]; - - // Accumulate in double precision + SingleLandmarkTraining prepared = prepareSingleLandmarkTraining( + ravvCopy, originalDim, rng, optimizer == ITQ); + landmarks = new VectorFloat[]{prepared.landmark}; + trainingData = prepared.trainingData; + } else { + // Multi-landmark clustering still needs its materialized input. + VectorFloat[] points = new VectorFloat[ravvCopy.size()]; for (int i = 0; i < points.length; i++) { - VectorFloat p = points[i]; - for (int d = 0; d < originalDim; d++) { - muAccumulator[d] += p.get(d); - } + VectorFloat v = ravvCopy.getVector(i).copy(); + float norm = (float) Math.sqrt(VectorUtil.dotProduct(v, v)); + if (norm > 1e-10f) VectorUtil.scale(v, 1.0f / norm); + points[i] = v; } - - // Scale and materialize back into a float vector - VectorFloat mu0 = vectorTypeSupport.createFloatVector(originalDim); - double invN = 1.0 / points.length; - for (int d = 0; d < originalDim; d++) { - mu0.set(d, (float) (muAccumulator[d] * invN)); - } - - landmarks = new VectorFloat[] { mu0 }; - } else { long kmStart = System.nanoTime(); - - // KMeans++ in JVector returns centroids packed as one big vector [C * D] MiniBatchKMeansClusterer km = new MiniBatchKMeansClusterer(points, landmarkCount, 1024); VectorFloat packed = km.cluster(/*unweightedIterations*/ 20, /*anisotropicIterations*/ 0); - long kmEnd = System.nanoTime(); - - System.out.printf( - "\tKMeans++ (C=%d, N=%d, D=%d) took %.3f seconds%n", - landmarkCount, - points.length, - originalDim, - (kmEnd - kmStart) / 1e9 - ); - + System.out.printf("\tKMeans++ (C=%d, N=%d, D=%d) took %.3f seconds%n", + landmarkCount, points.length, originalDim, (kmEnd - kmStart) / 1e9); landmarks = new VectorFloat[landmarkCount]; for (int c = 0; c < landmarkCount; c++) { VectorFloat mu = vectorTypeSupport.createFloatVector(originalDim); mu.copyFrom(packed, c * originalDim, 0, originalDim); landmarks[c] = mu; } + if (optimizer == ITQ) trainingData = prepareTrainingData(points, landmarks, originalDim, rng); } - // Keep determinism consistent within ASH. TODO explore more robust random initialization - final Random rng = new Random(103); - - StiefelTransform stiefelTransform; - if (optimizer == RANDOM) { - stiefelTransform = runWithoutTraining(originalDim, quantizedDim, rng); - } else if (optimizer == ITQ) { - // Build training inputs from a random sample of points. - TrainingData td = prepareTrainingData(points, landmarks, originalDim, rng); - - stiefelTransform = runItqTrainer( - td.xTrainNorm, - quantizedDim, - bitsPerDimension, - rng, - TRAINING_ITERS - ); - } else { - throw new IllegalArgumentException("Unknown optimizer " + optimizer); - } + StiefelTransform stiefelTransform = optimizer == RANDOM + ? runWithoutTraining(originalDim, quantizedDim, rng) + : runItqTrainer(trainingData, quantizedDim, bitsPerDimension, rng, TRAINING_ITERS); LandmarkProjections lp = computeLandmarkProjections( @@ -634,7 +622,7 @@ public float quantizeVector(VectorFloat vector, + " for landmarkCount=" + landmarkCount); } - // Landmark-specific mean (C=1 → landmarks[0] == dataset mean) + // Landmark-specific mean (C=1 → landmarks[0] is the sampled mean) final VectorFloat mu = landmarks[landmark & 0xFF]; return QuantizedVector.quantizeTo( @@ -1576,18 +1564,150 @@ private void finishEncoding(QuantizedVector dest, float residualNorm, float code // Training of projection matrix // --------------------------------------------------------------------- + /** Sampled, normalized residuals in the column-major layout consumed by LAPACK. */ + private static final class TrainingData { + final double[] xCol; + final int rows; + final int columns; + + TrainingData(double[] xCol, int rows, int columns) { + this.xCol = xCol; + this.rows = rows; + this.columns = columns; + } + + TrainingData(double[][] rows) { + this(serializeColumnMajor(rows), rows.length, rows[0].length); + } + } + + private static final class SingleLandmarkTraining { + final VectorFloat landmark; + final TrainingData trainingData; + + SingleLandmarkTraining(VectorFloat landmark, TrainingData trainingData) { + this.landmark = landmark; + this.trainingData = trainingData; + } + } + + private static int trainingSampleSize(int count, int dimensions) { + return (int) Math.min((long) count, Math.max(1L, (long) dimensions * ITQ_TRAINING_FACTOR)); + } + /** - * Training inputs derived from a random sample of the dataset: - * xTrainNorm holds normalized centered residuals. - * - * NOTE: Vectors are read as floats; training matrices are accumulated/stored in double precision. + * Computes the C=1 landmark and ITQ inputs from the same deterministic + * sample. Only sampled source vectors are read; normalized sample values + * are retained until the mean is known. One reusable vector protects + * borrowed source storage. Double sums follow sorted ordinal order, and + * normalization retains the provider's FP32 dot/scale operations. */ - private static final class TrainingData { - final double[][] xTrainNorm; // [N_train][D] + private static SingleLandmarkTraining prepareSingleLandmarkTraining( + RandomAccessVectorValues source, int dimensions, Random rng, boolean learn) { + final int count = source.size(); + if (count <= 0 || dimensions <= 0) { + throw new IllegalArgumentException("ASH training requires positive size and dimension"); + } + final int sampleSize = trainingSampleSize(count, dimensions); + // Validate the flat-array representation before allocating ordinal or + // normalization buffers. + long sampleElements = (long) sampleSize * dimensions; + if (learn && sampleElements > Integer.MAX_VALUE) { + throw new IllegalArgumentException("ASH training sample exceeds the Java array size limit: " + + sampleSize + " rows x " + dimensions + " dimensions"); + } + // The existing PCA driver also requires a full D-by-D right basis, + // including null-space directions when the training matrix is wide. + if (learn && (long) dimensions * dimensions > Integer.MAX_VALUE) { + throw new IllegalArgumentException("ASH PCA basis exceeds the Java array size limit: " + + dimensions + " x " + dimensions); + } + // RANDOM projections keep their existing Gaussian RNG sequence. ITQ + // consumes exactly the same reservoir-sampling draws as before. + final Random sampleRandom = learn ? rng : new Random(TRAINING_SEED); + final int[] ordinals = reservoirSampleOrdinals(count, sampleSize, sampleRandom); + Arrays.sort(ordinals); + final float[] sample = learn ? new float[(int) sampleElements] : null; + final double[] accumulator = new double[dimensions]; + final VectorFloat normalized = vectorTypeSupport.createFloatVector(dimensions); + final float[] values = normalized instanceof FloatArray ? ((FloatArray) normalized).array() : null; + for (int row = 0; row < sampleSize; row++) { + int ordinal = ordinals[row]; + VectorFloat input = source.getVector(ordinal); + if (input == null) { + throw new NullPointerException("Null ASH training vector at ordinal " + ordinal); + } + if (input.length() != dimensions) { + throw new IllegalArgumentException("ASH training vector at ordinal " + ordinal + + " has dimension " + input.length() + "; expected " + dimensions); + } + normalized.copyFrom(input, 0, 0, dimensions); + // Dimensions are already checked. Call the provider directly so invalid + // norms reach argument validation even when VectorUtil assertions are enabled. + float norm = (float) Math.sqrt(vectorUtilSupport.dotProduct(normalized, normalized)); + if (!Float.isFinite(norm)) { + throw new IllegalArgumentException("ASH training vector at ordinal " + ordinal + + " has a non-finite FP32 L2 norm"); + } + if (norm > 1e-10f) { + VectorUtil.scale(normalized, 1.0f / norm); + } + if (values != null) { + for (int d = 0; d < dimensions; d++) accumulator[d] += values[d]; + } else { + for (int d = 0; d < dimensions; d++) accumulator[d] += normalized.get(d); + } + if (learn) { + int offset = row * dimensions; + if (values != null) { + System.arraycopy(values, 0, sample, offset, dimensions); + } else { + for (int d = 0; d < dimensions; d++) sample[offset + d] = normalized.get(d); + } + } + } + VectorFloat mean = vectorTypeSupport.createFloatVector(dimensions); + double invCount = 1.0 / sampleSize; + for (int d = 0; d < dimensions; d++) mean.set(d, (float) (accumulator[d] * invCount)); + if (!learn) return new SingleLandmarkTraining(mean, null); + return new SingleLandmarkTraining(mean, centerTrainingSample(sample, sampleSize, dimensions, mean)); + } - TrainingData(double[][] xTrainNorm) { - this.xTrainNorm = xTrainNorm; + /** + * Centers/normalizes contiguous sample rows in FP64, then writes small tiles + * into the final column-major matrix. No full-size double row matrix or + * strided, element-at-a-time whole-matrix conversion is needed. Tile width + * bounds scratch only; it does not change arithmetic or sample boundaries. + */ + private static TrainingData centerTrainingSample(float[] sample, int rows, int dimensions, + VectorFloat mean) { + final int tileRows = Math.min(TRAINING_TRANSPOSE_TILE_ROWS, rows); + double[] tile = new double[Math.multiplyExact(tileRows, dimensions)]; + double[] columnMajor = new double[Math.multiplyExact(rows, dimensions)]; + float[] center = new float[dimensions]; + for (int d = 0; d < dimensions; d++) center[d] = mean.get(d); + for (int first = 0; first < rows; first += tileRows) { + int rowsInTile = Math.min(tileRows, rows - first); + for (int row = 0; row < rowsInTile; row++) { + int sampleOffset = (first + row) * dimensions; + int tileOffset = row * dimensions; + double norm2 = 0.0; + for (int d = 0; d < dimensions; d++) { + double value = (double) sample[sampleOffset + d] - (double) center[d]; + tile[tileOffset + d] = value; + norm2 += value * value; + } + double inverse = 1.0 / Math.max(Math.sqrt(norm2), 1e-20); + for (int d = 0; d < dimensions; d++) tile[tileOffset + d] *= inverse; + } + for (int d = 0; d < dimensions; d++) { + int columnOffset = d * rows + first; + for (int row = 0; row < rowsInTile; row++) { + columnMajor[columnOffset + row] = tile[row * dimensions + d]; + } + } } + return new TrainingData(columnMajor, rows, dimensions); } /** @@ -1608,8 +1728,7 @@ private static TrainingData prepareTrainingData( ) { final int N = points.length; - long learnedCap = Math.max(1L, (long) originalDim * ITQ_TRAINING_FACTOR); - final int nTrain = (int) Math.min((long) N, learnedCap); + final int nTrain = trainingSampleSize(N, originalDim); final int[] sample = reservoirSampleOrdinals(N, nTrain, rng); Arrays.sort(sample); // deterministic order for reproducibility @@ -1693,7 +1812,7 @@ private static int[] reservoirSampleOrdinals(int N, int k, Random rng) { * - W = P @ R */ private static StiefelTransform runItqTrainer( - double[][] xHdNorm, // [N][D] normalized residuals + TrainingData trainingData, // [N x D] normalized residuals, column-major int latentDim, // projected dimensions, d int bitsPerDimension, Random rng, @@ -1703,8 +1822,8 @@ private static StiefelTransform runItqTrainer( validateBitsPerDimension(bitsPerDimension); - final int N = xHdNorm.length; - final int D = xHdNorm[0].length; + final int N = trainingData.rows; + final int D = trainingData.columns; final int d = latentDim; if (d <= 0 || d > D) { @@ -1712,7 +1831,7 @@ private static StiefelTransform runItqTrainer( } // X is [N x D], flat column-major for BLAS/LAPACK. - double[] xCol = serializeColumnMajor(xHdNorm); + double[] xCol = trainingData.xCol; // PCA basis P = V[:, :d] from SVD(X), matching the Python reference. logProgress("\t[stage] Starting Native SVD for PCA..."); diff --git a/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestASHTrainingSetup.java b/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestASHTrainingSetup.java new file mode 100644 index 000000000..37a143cdc --- /dev/null +++ b/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestASHTrainingSetup.java @@ -0,0 +1,351 @@ +/* + * 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 io.github.jbellis.jvector.graph.RandomAccessVectorValues; +import io.github.jbellis.jvector.vector.VectorUtil; +import io.github.jbellis.jvector.vector.VectorizationProvider; +import io.github.jbellis.jvector.vector.types.VectorFloat; +import org.junit.Test; + +import java.lang.reflect.InvocationTargetException; +import java.util.Arrays; +import java.util.Random; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.junit.Assert.*; + +/** Sampled C=1 setup, numeric order, RNG state and borrowed-source lifetime. */ +public class TestASHTrainingSetup { + private static final class Values implements RandomAccessVectorValues { + final VectorFloat[] input; + final int dimensions; + final boolean shared; + final VectorFloat scratch; + final AtomicInteger reads; + int failAt = -1; + + Values(VectorFloat[] input, int dimensions, boolean shared) { + this(input, dimensions, shared, new AtomicInteger()); + } + + private Values(VectorFloat[] input, int dimensions, boolean shared, AtomicInteger reads) { + this.input = input; + this.dimensions = dimensions; + this.shared = shared; + this.reads = reads; + scratch = VectorizationProvider.getInstance().getVectorTypeSupport().createFloatVector(dimensions); + } + + @Override public int size() { return input.length; } + @Override public int dimension() { return dimensions; } + @Override public boolean isValueShared() { return shared; } + @Override public RandomAccessVectorValues copy() { + Values copy = new Values(input, dimensions, shared, reads); + copy.failAt = failAt; + return copy; + } + @Override public VectorFloat getVector(int ordinal) { + reads.incrementAndGet(); + if (ordinal == failAt) throw new IllegalStateException("source failure"); + if (!shared || input[ordinal] == null) return input[ordinal]; + scratch.copyFrom(input[ordinal], 0, 0, dimensions); + return scratch; + } + } + + private static Object prepare(Values source, int dimensions, Random random, boolean learn) throws Exception { + var method = AsymmetricHashing.class.getDeclaredMethod("prepareSingleLandmarkTraining", + RandomAccessVectorValues.class, int.class, Random.class, boolean.class); + method.setAccessible(true); + try { return method.invoke(null, source, dimensions, random, learn); } + catch (InvocationTargetException failure) { + if (failure.getCause() instanceof Exception) throw (Exception) failure.getCause(); + throw (Error) failure.getCause(); + } + } + + private static Object field(Object owner, String name) throws Exception { + var field = owner.getClass().getDeclaredField(name); + field.setAccessible(true); + return field.get(owner); + } + + private static VectorFloat[] vectors(int count, int dimensions) { + var types = VectorizationProvider.getInstance().getVectorTypeSupport(); + Random random = new Random(19093); + VectorFloat[] values = new VectorFloat[count]; + for (int i = 0; i < count; i++) { + values[i] = types.createFloatVector(dimensions); + for (int d = 0; d < dimensions; d++) { + float value = random.nextFloat() - .5f; + if (i % 11 == 0) value = d % 2 == 0 ? -0f : 0f; + if (i % 11 == 1) value *= 1e-12f; + values[i].set(d, value); + } + } + return values; + } + + private static final class Reference { + final VectorFloat mean; + final double[] xCol; + final int rows; + + Reference(VectorFloat[] source, int dimensions, Random rng, boolean learn) { + int sampleSize = Math.min(source.length, dimensions * 20); + Random selection = learn ? rng : new Random(103); + int[] ordinals = new int[sampleSize]; + for (int i = 0; i < sampleSize; i++) ordinals[i] = i; + for (int i = sampleSize; i < source.length; i++) { + int j = selection.nextInt(i + 1); + if (j < sampleSize) ordinals[j] = i; + } + Arrays.sort(ordinals); + VectorFloat[] points = new VectorFloat[sampleSize]; + for (int i = 0; i < sampleSize; i++) { + points[i] = source[ordinals[i]].copy(); + float norm = (float) Math.sqrt(VectorUtil.dotProduct(points[i], points[i])); + if (norm > 1e-10f) VectorUtil.scale(points[i], 1f / norm); + } + double[] accumulator = new double[dimensions]; + for (VectorFloat point : points) + for (int d = 0; d < dimensions; d++) accumulator[d] += point.get(d); + mean = VectorizationProvider.getInstance().getVectorTypeSupport().createFloatVector(dimensions); + double inverseCount = 1.0 / points.length; + for (int d = 0; d < dimensions; d++) mean.set(d, (float) (accumulator[d] * inverseCount)); + rows = learn ? sampleSize : 0; + xCol = learn ? new double[rows * dimensions] : null; + if (!learn) return; + double[] residual = new double[dimensions]; + for (int row = 0; row < rows; row++) { + double norm2 = 0; + for (int d = 0; d < dimensions; d++) { + double value = (double) points[row].get(d) - (double) mean.get(d); + residual[d] = value; + norm2 += value * value; + } + double inverse = 1.0 / Math.max(Math.sqrt(norm2), 1e-20); + for (int d = 0; d < dimensions; d++) xCol[row + d * rows] = residual[d] * inverse; + } + } + } + + @Test + public void sampledMeanAndResidualsMatchOracleIncludingRngAndBorrowedStorage() throws Exception { + for (int[] shape : new int[][]{{1,1},{1,20},{1,21},{1,65},{7,31},{7,32},{7,33}, + {7,64},{7,65},{7,139},{7,140},{7,141},{16,321},{33,67},{65,1311}}) { + int dimensions = shape[0], count = shape[1]; + VectorFloat[] values = vectors(count, dimensions); + int[][] saved = new int[count][dimensions]; + for (int i = 0; i < count; i++) for (int d = 0; d < dimensions; d++) + saved[i][d] = Float.floatToRawIntBits(values[i].get(d)); + for (boolean shared : new boolean[]{false,true}) for (boolean learn : new boolean[]{false,true}) { + Random referenceRandom = new Random(103), actualRandom = new Random(103); + Reference expected = new Reference(values, dimensions, referenceRandom, learn); + Values source = new Values(values, dimensions, shared); + Object actual = prepare(source, dimensions, actualRandom, learn); + VectorFloat mean = (VectorFloat) field(actual, "landmark"); + assertEquals(Math.min(count, dimensions * 20), source.reads.get()); + for (int d = 0; d < dimensions; d++) + assertEquals(Float.floatToRawIntBits(expected.mean.get(d)), Float.floatToRawIntBits(mean.get(d))); + Object training = field(actual, "trainingData"); + if (learn) { + assertEquals(expected.rows, field(training, "rows")); + assertEquals(dimensions, field(training, "columns")); + double[] observed = (double[]) field(training, "xCol"); + assertEquals(expected.xCol.length, observed.length); + for (int i = 0; i < observed.length; i++) + assertEquals(Double.doubleToRawLongBits(expected.xCol[i]), Double.doubleToRawLongBits(observed[i])); + } else assertNull(training); + for (int i = 0; i < 8; i++) assertEquals(referenceRandom.nextLong(), actualRandom.nextLong()); + for (int i = 0; i < count; i++) for (int d = 0; d < dimensions; d++) + assertEquals(saved[i][d], Float.floatToRawIntBits(values[i].get(d))); + } + } + } + + @Test + public void initializationReadsTheSameSampleForEveryTrainingStage() throws Exception { + int dimensions = 7, count = 141; + for (int optimizer : new int[]{AsymmetricHashing.RANDOM, AsymmetricHashing.ITQ}) { + for (int bits : new int[]{1,2,4,9}) { + Values source = new Values(vectors(count, dimensions), dimensions, true); + var model = AsymmetricHashing.initialize(source, optimizer, + AsymmetricHashing.HEADER_BITS + 3 * bits, 1, bits); + Reference expected = new Reference(source.input, dimensions, new Random(103), + optimizer == AsymmetricHashing.ITQ); + assertEquals(1, model.landmarks.length); + assertEquals(3, model.quantizedDim); + assertEquals(dimensions * 20, source.reads.get()); + for (int d = 0; d < dimensions; d++) { + assertEquals(Float.floatToRawIntBits(expected.mean.get(d)), + Float.floatToRawIntBits(model.landmarks[0].get(d))); + } + } + } + } + + @Test + public void setupRejectsInvalidSourcesAndPropagatesReadFailures() throws Exception { + assertThrows(IllegalArgumentException.class, + () -> prepare(new Values(new VectorFloat[0], 3, false), 3, new Random(103), true)); + Values source = new Values(vectors(65, 7), 7, false); + source.failAt = 17; + assertThrows(IllegalStateException.class, () -> prepare(source, 7, new Random(103), true)); + assertEquals(18, source.reads.get()); + Values ragged = new Values(vectors(3, 7), 7, false); + ragged.input[1] = VectorizationProvider.getInstance().getVectorTypeSupport().createFloatVector(6); + assertThrows(IllegalArgumentException.class, () -> prepare(ragged, 7, new Random(103), true)); + Values nullRow = new Values(vectors(3, 7), 7, false); + nullRow.input[1] = null; + assertThrows(NullPointerException.class, () -> prepare(nullRow, 7, new Random(103), true)); + Values empty = new Values(new VectorFloat[0], 7, false); + assertThrows(IllegalArgumentException.class, () -> AsymmetricHashing.initialize(empty, + AsymmetricHashing.ITQ, AsymmetricHashing.HEADER_BITS + 6, 1, 2)); + assertEquals(0, empty.reads.get()); + } + + /** Metadata-only sources must fail validation before any vector access. */ + private static RandomAccessVectorValues metadataOnlySource(int count, int dimensions) { + return new RandomAccessVectorValues() { + @Override public int size() { return count; } + @Override public int dimension() { return dimensions; } + @Override public boolean isValueShared() { return false; } + @Override public RandomAccessVectorValues copy() { return this; } + @Override public VectorFloat getVector(int ordinal) { + throw new AssertionError("Invalid source/configuration must not fetch vectors"); + } + }; + } + + @Test + public void invalidMetadataAndConfigurationAreRejectedBeforeReading() { + int budget = AsymmetricHashing.HEADER_BITS + 6; + assertThrows(NullPointerException.class, + () -> AsymmetricHashing.initialize(null, AsymmetricHashing.ITQ, budget, 1, 2)); + assertThrows(IllegalArgumentException.class, + () -> AsymmetricHashing.initialize(metadataOnlySource(0, 7), AsymmetricHashing.ITQ, budget, 1, 2)); + assertThrows(IllegalArgumentException.class, + () -> AsymmetricHashing.initialize(metadataOnlySource(-1, 7), AsymmetricHashing.ITQ, budget, 1, 2)); + for (int dimensions : new int[]{0,-1}) { + assertThrows(IllegalArgumentException.class, + () -> AsymmetricHashing.initialize(metadataOnlySource(1, dimensions), + AsymmetricHashing.ITQ, budget, 1, 2)); + } + for (int optimizer : new int[]{0,-1,3}) { + assertThrows(IllegalArgumentException.class, + () -> AsymmetricHashing.initialize(metadataOnlySource(1, 7), optimizer, budget, 1, 2)); + } + for (int bits : new int[]{0,10}) { + assertThrows(IllegalArgumentException.class, + () -> AsymmetricHashing.initialize(metadataOnlySource(1, 7), + AsymmetricHashing.ITQ, budget, 1, bits)); + } + for (int landmarks : new int[]{0,257}) { + assertThrows(IllegalArgumentException.class, + () -> AsymmetricHashing.initialize(metadataOnlySource(1, 7), + AsymmetricHashing.ITQ, budget, landmarks, 2)); + } + for (int invalidBudget : new int[]{AsymmetricHashing.HEADER_BITS, + AsymmetricHashing.HEADER_BITS + 3, AsymmetricHashing.HEADER_BITS + 16}) { + assertThrows(IllegalArgumentException.class, + () -> AsymmetricHashing.initialize(metadataOnlySource(1, 7), + AsymmetricHashing.ITQ, invalidBudget, 1, 2)); + } + } + + @Test + public void sampleSizingDoesNotOverflowAndRejectsUnrepresentableMatrices() throws Exception { + var method = AsymmetricHashing.class.getDeclaredMethod("trainingSampleSize", int.class, int.class); + method.setAccessible(true); + assertEquals(Integer.valueOf(Integer.MAX_VALUE), + method.invoke(null, Integer.MAX_VALUE, Integer.MAX_VALUE)); + assertEquals(Integer.valueOf(100), method.invoke(null, 100, Integer.MAX_VALUE)); + assertEquals(Integer.valueOf(140), method.invoke(null, Integer.MAX_VALUE, 7)); + IllegalArgumentException failure = assertThrows(IllegalArgumentException.class, + () -> AsymmetricHashing.initialize(metadataOnlySource(50_000, 50_000), + AsymmetricHashing.ITQ, AsymmetricHashing.HEADER_BITS + 6, 1, 2)); + assertTrue(failure.getMessage().contains("array size limit")); + IllegalArgumentException wideFailure = assertThrows(IllegalArgumentException.class, + () -> AsymmetricHashing.initialize(metadataOnlySource(1, 50_000), + AsymmetricHashing.ITQ, AsymmetricHashing.HEADER_BITS + 6, 1, 2)); + assertTrue(wideFailure.getMessage().contains("PCA basis")); + } + + @Test + public void nonFiniteAndOverflowingSampleNormsAreRejected() { + for (float invalid : new float[]{Float.NaN, Float.POSITIVE_INFINITY, + Float.NEGATIVE_INFINITY, Float.MAX_VALUE}) { + for (boolean shared : new boolean[]{false,true}) { + for (boolean learn : new boolean[]{false,true}) { + Values source = new Values(vectors(3, 7), 7, shared); + source.input[1].set(3, invalid); + IllegalArgumentException failure = assertThrows(IllegalArgumentException.class, + () -> prepare(source, 7, new Random(103), learn)); + assertTrue(failure.getMessage().contains("ordinal 1")); + assertEquals(2, source.reads.get()); + assertEquals(Float.floatToRawIntBits(invalid), + Float.floatToRawIntBits(source.input[1].get(3))); + } + } + } + } + + @Test + public void sharedSourceReadFailuresPropagateDuringInitialization() { + Values source = new Values(vectors(65, 7), 7, true); + source.failAt = 17; + IllegalStateException failure = assertThrows(IllegalStateException.class, + () -> AsymmetricHashing.initialize(source, AsymmetricHashing.ITQ, + AsymmetricHashing.HEADER_BITS + 6, 1, 2)); + assertEquals("source failure", failure.getMessage()); + assertEquals(18, source.reads.get()); + } + + @Test + public void zeroAndConstantSamplesProduceFiniteModels() throws Exception { + for (boolean constant : new boolean[]{false,true}) { + VectorFloat[] input = vectors(65, 3); + for (VectorFloat value : input) { + for (int d = 0; d < 3; d++) value.set(d, constant ? (d + 1) * .25f : 0f); + } + Object prepared = prepare(new Values(input, 3, true), 3, new Random(103), true); + Object training = field(prepared, "trainingData"); + for (double value : (double[]) field(training, "xCol")) assertEquals(0.0, value, 0.0); + Values source = new Values(input, 3, true); + var model = AsymmetricHashing.initialize(source, AsymmetricHashing.ITQ, + AsymmetricHashing.HEADER_BITS + 4, 1, 2); + for (double[] row : model.stiefelTransform.AData) { + for (double value : row) assertTrue(Double.isFinite(value)); + } + assertEquals(60, source.reads.get()); + } + } + + @Test + public void multipleLandmarksStillTrainThroughTheExistingPath() throws Exception { + for (int optimizer : new int[]{AsymmetricHashing.ITQ, AsymmetricHashing.RANDOM}) { + Values source = new Values(vectors(120, 7), 7, true); + var model = AsymmetricHashing.initialize(source, optimizer, + AsymmetricHashing.HEADER_BITS + 6, 3, 2); + assertEquals(3, model.landmarks.length); + assertEquals(3, model.quantizedDim); + assertEquals(120, source.reads.get()); + } + } +} From 08515828fec9069d59e52b8588c3e5fd8d9fe082 Mon Sep 17 00:00:00 2001 From: Ted Willke Date: Sun, 1 Nov 2026 23:58:54 +0000 Subject: [PATCH 115/243] docs: fix PQ entropy diagnostic paragraph markup --- .../io/github/jbellis/jvector/example/PQEntropyAnalysis.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/PQEntropyAnalysis.java b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/PQEntropyAnalysis.java index 9b5aa9d77..8bc30717d 100644 --- a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/PQEntropyAnalysis.java +++ b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/PQEntropyAnalysis.java @@ -43,12 +43,12 @@ * *

    * This is NOT a benchmark: + *

    *
      *
    • No timing loops
    • *
    • No scoring
    • *
    • No SIMD assumptions
    • *
    - *

    */ public class PQEntropyAnalysis { From 322e179ed179fef924f77b13c978f25956ca0454 Mon Sep 17 00:00:00 2001 From: Ted Willke Date: Mon, 2 Nov 2026 02:29:46 +0000 Subject: [PATCH 116/243] ash: reuse FP64 ITQ workspaces and quantize contiguous rows Write contiguous projected rows directly with transposed DGEMMs and quantize them in place, removing the second large iteration buffer. Reuse polar-decomposition, update and per-worker quantization scratch; query SVD workspace once and avoid input copies during workspace queries. Retain FP64 PCA/ITQ arithmetic, scaling-factor semantics, random state, convergence checks and model formats. Add numeric regression coverage for all code widths, matrix orientation, workspace reuse and invalid inputs. Document memory ownership and the training layout. --- docs/native-ash-preparation.md | 32 ++ .../quantization/ASHOrthogonalizer.java | 104 ++++++ .../quantization/AsymmetricHashing.java | 310 ++++++++---------- .../TestASHTrainingWorkspace.java | 299 +++++++++++++++++ 4 files changed, 570 insertions(+), 175 deletions(-) create mode 100644 jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHOrthogonalizer.java create mode 100644 jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestASHTrainingWorkspace.java diff --git a/docs/native-ash-preparation.md b/docs/native-ash-preparation.md index a41882a94..3a6330b9d 100644 --- a/docs/native-ash-preparation.md +++ b/docs/native-ash-preparation.md @@ -269,3 +269,35 @@ Training and graph-write timers/recall for every run are retained in the raw CSVs; they are not manually added to inclusive harness timers. Original CSVs, commands, class/source snapshots and live placement observations are preserved under `benchmark-runs/ash-quantization-20261101` on the development server. + +## FP64 ITQ training workspaces + +ITQ keeps the residual and PCA-reduced inputs in column-major FP64 arrays for +BLAS/LAPACK. Its iterative projection writes contiguous training vectors directly: +`(Xld * R)^T = R^T * Xld^T`, evaluated by one DGEMM. The result is viewed as +row-major `[N,d]` by the direction encoder. Quantization replaces each projected +row in place; the unquantized result is not needed by the update or loss. A second +DGEMM consumes this layout to overwrite the existing update matrix with +`Xld^T * Xenc`. No explicit transpose pass or second `[N,d]` output is required. + +Each trainer owns an `ASHOrthogonalizer` with one thin-DGESDD workspace. It queries +the required workspace once, copies only the input for each destructive SVD, and +reuses the singular vectors, values, output and native work arrays. The direction +encoder similarly retains scratch for its logical workers across iterations. +Workers operate on disjoint rows; neither workspace is shared between trainers. +The existing scaling-event buffers remain thread-local. Parallel task submission +still occurs each iteration; this is not an allocation-free training API. + +PCA retains its original FP64 DGESDD algorithm, complete right-singular-vector +basis, wide-matrix handling and spectral-energy check. Its workspace query uses +the read-only input directly; only the actual destructive decomposition needs a +copy. The ITQ norms, update, polar decomposition, loss, random initialization and +stopping criterion remain FP64. The scaling-factor search retains its existing +FP32 arithmetic. The 1-bit sign rule and 2–9-bit direction quantization are +unchanged; tests compare the new in-place encoder to the former column-major +algorithm bit for bit on identical projected inputs. + +Different DGEMM orientations can change reduction order, so retraining need not +produce a byte-identical matrix. Model serialization, database encoding and query +projection precision are unchanged. Existing saved models remain compatible. +This structural change does not select a different BLAS/LAPACK backend. diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHOrthogonalizer.java b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHOrthogonalizer.java new file mode 100644 index 000000000..9d395a998 --- /dev/null +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHOrthogonalizer.java @@ -0,0 +1,104 @@ +/* + * 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 dev.ludovic.netlib.blas.BLAS; +import dev.ludovic.netlib.lapack.LAPACK; +import org.netlib.util.intW; + +import java.util.Objects; + +/** + * Reusable FP64 thin-SVD workspace for the polar factor U * VT. + * + *

    One instance belongs to one trainer. It is not thread-safe and retains no + * caller buffers. Input is copied only for the destructive decomposition; the + * workspace query is performed once, using internal scratch.

    + */ +final class ASHOrthogonalizer { + private final int rows; + private final int cols; + private final int rank; + private final int elements; + private final double[] input; + private final double[] u; + private final double[] vt; + private final double[] singularValues; + private final int[] iwork; + private final double[] work; + private final intW info = new intW(0); + private final LAPACK lapack = LAPACK.getInstance(); + private final BLAS blas = BLAS.getInstance(); + + ASHOrthogonalizer(int rows, int cols) { + if (rows <= 0 || cols <= 0 || (long) rows * cols > Integer.MAX_VALUE) { + throw new IllegalArgumentException("Invalid ASH orthogonalization dimensions"); + } + this.rows = rows; + this.cols = cols; + rank = Math.min(rows, cols); + elements = rows * cols; + input = new double[elements]; + u = new double[Math.multiplyExact(rows, rank)]; + vt = new double[Math.multiplyExact(rank, cols)]; + singularValues = new double[rank]; + iwork = new int[Math.multiplyExact(8, rank)]; + double[] query = new double[1]; + lapack.dgesdd("S", rows, cols, input, rows, singularValues, u, rows, + vt, rank, query, -1, iwork, info); + if (info.val != 0) { + throw new IllegalStateException("ASH SVD workspace query failed with info=" + info.val); + } + if (!Double.isFinite(query[0]) || query[0] < 1 || query[0] > Integer.MAX_VALUE) { + throw new IllegalStateException("Invalid ASH SVD workspace size: " + query[0]); + } + work = new double[(int) Math.ceil(query[0])]; + } + + /** + * Writes the polar factor in column-major order. Buffers must have the + * constructor's shape and must be distinct. The source is never modified, + * including on failure; every successful call overwrites the whole output. + */ + void orthogonalize(double[] source, double[] destination) { + Objects.requireNonNull(source, "source"); + Objects.requireNonNull(destination, "destination"); + if (source.length != elements || destination.length != elements || source == destination) { + throw new IllegalArgumentException("Invalid ASH orthogonalization buffers"); + } + for (int i = 0; i < elements; i++) { + double value = source[i]; + if (!Double.isFinite(value)) { + throw new IllegalArgumentException("ASH orthogonalization input must be finite"); + } + input[i] = value; + } + info.val = 0; + lapack.dgesdd("S", rows, cols, input, rows, singularValues, u, rows, + vt, rank, work, work.length, iwork, info); + if (info.val != 0) { + throw new IllegalStateException("ASH SVD failed with info=" + info.val); + } + for (double value : singularValues) { + if (!Double.isFinite(value) || value < 0) { + throw new IllegalStateException("ASH SVD returned an invalid singular value"); + } + } + blas.dgemm("N", "N", rows, cols, rank, 1.0, u, rows, + vt, rank, 0.0, destination, rows); + } +} diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/AsymmetricHashing.java b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/AsymmetricHashing.java index d69eb3b47..2872c2b24 100644 --- a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/AsymmetricHashing.java +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/AsymmetricHashing.java @@ -1845,22 +1845,25 @@ private static StiefelTransform runItqTrainer( // Initial random update matrix M, shape [d x d]. double[] mCol = gaussianMatrix(d, d, rng); - double[] rCol = null; - double[] xtrCol = new double[N * d]; - double[] xencCol = new double[N * d]; + // Each projected vector is contiguous. Quantization replaces it in place: + // the loss uses the update matrix, so unquantized rows need not be retained. + double[] projectedRows = new double[Math.multiplyExact(N, d)]; logProgress("\t[stage] ITQ training iterations started..."); long startTime = System.nanoTime(); + ASHOrthogonalizer orthogonalizer = new ASHOrthogonalizer(d, d); + double[] rCol = new double[Math.multiplyExact(d, d)]; + TrainingDirectionEncoder encoder = new TrainingDirectionEncoder(N, d, bitsPerDimension); double[] recentLosses = new double[3]; for (int epoch = 0; epoch < nTrainingIterations; epoch++) { - rCol = orthogonalize(mCol, d, d); + orthogonalizer.orthogonalize(mCol, rCol); - multiplyInto(xldCol, N, d, rCol, d, xtrCol); - encodeTrainingDirections(xtrCol, N, d, bitsPerDimension, xencCol); + projectTrainingRows(xldCol, N, d, rCol, projectedRows); + encoder.encodeInPlace(projectedRows); - // ITQ update: M = Xld^T @ Xenc. - mCol = nativeMultiplyTransposedA(xldCol, N, d, xencCol, d); + // ITQ update: M = Xld^T @ Xenc. Reuse the initial Gaussian buffer. + updateTrainingMatrix(xldCol, N, d, projectedRows, mCol); // C++-style cheap loss: trace(R^T M) / N. ProjectionTrainingLoss loss = computeTrainingLossFromUpdateMatrix(rCol, mCol, N); @@ -1872,8 +1875,8 @@ private static StiefelTransform runItqTrainer( recentLosses[epoch % 3] = loss.loss; } - // Final polar step after the last update. - rCol = orthogonalize(mCol, d, d); + // Final polar step after the last update, using the same workspace. + orthogonalizer.orthogonalize(mCol, rCol); double loopTime = (System.nanoTime() - startTime) / 1e9; logProgress("\t[stage] ITQ training completed in " + loopTime + " seconds."); @@ -2169,27 +2172,46 @@ private static double[] nativeMultiply(double[] aCol, int m, int k, double[] bCo return cCol; } - /** Native DGEMM for flat column-major matrices: C = A^T @ B. */ - private static double[] nativeMultiplyTransposedA( - double[] aCol, - int rowsA, - int colsA, - double[] bCol, - int colsB - ) { - double[] cCol = new double[colsA * colsB]; + /** + * Produces contiguous projected vectors without a separate transpose pass. + * Xld is [rows x dims] column-major; output rows are its product with R, + * stored as [dims x rows] column-major: output = R^T * Xld^T. + */ + private static void projectTrainingRows(double[] xldCol, int rows, int dims, + double[] rCol, double[] outRows) { + checkTrainingProduct(xldCol, rows, dims, rCol, outRows); + if (outRows == xldCol || outRows == rCol) { + throw new IllegalArgumentException("ITQ projection output must not alias an input"); + } + BLAS.getInstance().dgemm("T", "T", dims, rows, dims, 1.0, + rCol, dims, xldCol, rows, 0.0, outRows, dims); + } - BLAS.getInstance().dgemm( - "T", "N", - colsA, colsB, rowsA, - 1.0, - aCol, rowsA, - bCol, rowsA, - 0.0, - cCol, colsA - ); + /** + * Recomputes M = Xld^T * Xenc into an existing square matrix. Encoded rows + * are viewed as [dims x rows] column-major, hence the second transpose. + */ + private static void updateTrainingMatrix(double[] xldCol, int rows, int dims, + double[] encodedRows, double[] mCol) { + checkTrainingProduct(xldCol, rows, dims, mCol, encodedRows); + if (mCol == xldCol || mCol == encodedRows) { + throw new IllegalArgumentException("ITQ update output must not alias an input"); + } + BLAS.getInstance().dgemm("T", "T", dims, dims, rows, 1.0, + xldCol, rows, encodedRows, dims, 0.0, mCol, dims); + } - return cCol; + private static void checkTrainingProduct(double[] xldCol, int rows, int dims, + double[] square, double[] rowMajor) { + Objects.requireNonNull(xldCol, "xldCol"); + Objects.requireNonNull(square, "square"); + Objects.requireNonNull(rowMajor, "rowMajor"); + if (rows <= 0 || dims <= 0 || (long) rows * dims > Integer.MAX_VALUE + || (long) dims * dims > Integer.MAX_VALUE + || xldCol.length != rows * dims || rowMajor.length != rows * dims + || square.length != dims * dims) { + throw new IllegalArgumentException("Invalid ITQ matrix dimensions"); + } } /** @@ -2227,9 +2249,8 @@ private static double[] computeNativeVt(double[] xCol, int rows, int cols) { int[] iwork = new int[Math.multiplyExact(8, k)]; intW info = new intW(0); double[] workQuery = new double[1]; - double[] a = xCol.clone(); // LAPACK overwrites its input; X is still needed by ITQ. - - LAPACK.getInstance().dgesdd(job, rows, cols, a, rows, s, u, ldu, + // LAPACK's workspace query does not modify input matrix values. + LAPACK.getInstance().dgesdd(job, rows, cols, xCol, rows, s, u, ldu, vt, cols, workQuery, -1, iwork, info); if (info.val != 0) { throw new IllegalStateException("PCA SVD workspace query failed with info=" + info.val); @@ -2238,7 +2259,7 @@ private static double[] computeNativeVt(double[] xCol, int rows, int cols) { throw new IllegalStateException("Invalid PCA SVD workspace size: " + workQuery[0]); } double[] work = new double[(int) Math.ceil(workQuery[0])]; - a = xCol.clone(); + double[] a = xCol.clone(); // The actual decomposition overwrites its input. LAPACK.getInstance().dgesdd(job, rows, cols, a, rows, s, u, ldu, vt, cols, work, work.length, iwork, info); if (info.val != 0) { @@ -2339,158 +2360,97 @@ private static void symmetricEigenvectorsInPlace(double[] aCol, int dim) { } } - /** - * Native DGEMM into caller-provided output: C = A @ B. - */ - private static void multiplyInto( - double[] aCol, - int m, - int k, - double[] bCol, - int n, - double[] cCol - ) { - if (cCol.length < m * n) { - throw new IllegalArgumentException("Output matrix too small"); - } - - BLAS.getInstance().dgemm( - "N", "N", - m, n, k, - 1.0, - aCol, m, - bCol, k, - 0.0, - cCol, m - ); - } - - /** - * Orthogonalizes a flat column-major matrix using thin SVD and returns U @ VT - * in flat column-major layout. - */ + /** Single-use convenience path for random projection initialization. */ private static double[] orthogonalize(double[] mCol, int rows, int cols) { - int k = Math.min(rows, cols); - - double[] u = new double[rows * k]; - double[] vt = new double[k * cols]; - double[] s = new double[k]; - intW info = new intW(0); - int[] iwork = new int[8 * k]; - - // Workspace query - double[] a = mCol.clone(); // dgesvd overwrites input - double[] workQuery = new double[1]; - LAPACK.getInstance().dgesdd( - "S", - rows, cols, - a, rows, - s, - u, rows, - vt, k, - workQuery, -1, iwork, - info - ); - - if (info.val != 0) { - throw new RuntimeException("LAPACK dgesdd workspace query failed with info=" + info.val); - } - - int lwork = (int) workQuery[0]; - double[] work = new double[lwork]; - - // Actual call - a = mCol.clone(); - LAPACK.getInstance().dgesdd( - "S", - rows, cols, - a, rows, - s, - u, rows, - vt, k, - work, lwork, iwork, - info - ); - - if (info.val != 0) { - throw new RuntimeException("LAPACK dgesdd failed with info=" + info.val); - } - - return nativeMultiply(u, rows, k, vt, cols); + ASHOrthogonalizer workspace = new ASHOrthogonalizer(rows, cols); + double[] result = new double[Math.multiplyExact(rows, cols)]; + workspace.orthogonalize(mCol, result); + return result; } /** - * Encodes projected training directions. - * - * Input and output are [rows x dims] column-major. - * - * bitsPerDimension == 1: - * y_j = sign(x_j) / sqrt(d) - * - * bitsPerDimension > 1: - * y_j uses signed multibit magnitudes and is normalized to unit length. + * Quantizes contiguous training vectors in place, retaining the existing + * FP64 norms and FP32 scaling-factor search. Scratch belongs to logical + * workers and is reused across epochs, regardless of which pool threads + * execute them. One encoder belongs to one trainer and is not thread-safe. */ - private static void encodeTrainingDirections( - double[] xCol, - int rows, - int dims, - int bitsPerDimension, - double[] outCol - ) { - if (outCol.length < rows * dims) { - throw new IllegalArgumentException("Output matrix too small"); - } - - if (bitsPerDimension <= 1) { - final double invSqrtD = 1.0 / Math.sqrt(dims); - final int size = rows * dims; - - for (int i = 0; i < size; i++) { - outCol[i] = xCol[i] >= 0.0 ? invSqrtD : -invSqrtD; + private static final class TrainingDirectionEncoder { + private final int rows; + private final int dims; + private final int elements; + private final int bits; + private final DirectionScratch[] workers; + + TrainingDirectionEncoder(int rows, int dims, int bits) { + validateBitsPerDimension(bits); + if (rows <= 0 || dims <= 0 || (long) rows * dims > Integer.MAX_VALUE) { + throw new IllegalArgumentException("Invalid ITQ direction dimensions"); } - return; - } - - final int excessBits = bitsPerDimension - 1; - final int maxCode = (1 << excessBits) - 1; - - // Each task owns a contiguous range of training rows and its scratch arrays. - // computeOptimalScalingFactor already keeps its event buffers thread-local. - final int workers = Math.min(rows, Math.min(16, Runtime.getRuntime().availableProcessors())); - IntStream.range(0, workers).parallel().forEach(worker -> { - double[] absNorm = new double[dims]; - double[] mag = new double[dims]; - int start = worker * rows / workers; - int end = (worker + 1) * rows / workers; - - for (int r = start; r < end; r++) { - double norm2 = 0.0; - for (int j = 0; j < dims; j++) { - double v = xCol[r + j * rows]; - norm2 += v * v; + this.rows = rows; + this.dims = dims; + this.elements = rows * dims; + this.bits = bits; + int count = bits == 1 ? 0 + : Math.min(rows, Math.min(16, Runtime.getRuntime().availableProcessors())); + workers = new DirectionScratch[count]; + for (int i = 0; i < count; i++) workers[i] = new DirectionScratch(dims); + } + + void encodeInPlace(double[] vectors) { + Objects.requireNonNull(vectors, "vectors"); + if (vectors.length != elements) { + throw new IllegalArgumentException("Invalid ITQ direction buffer length"); + } + if (bits == 1) { + double inverse = 1.0 / Math.sqrt(dims); + for (int i = 0; i < elements; i++) { + vectors[i] = vectors[i] >= 0.0 ? inverse : -inverse; } - - double invNorm = 1.0 / Math.sqrt(Math.max(norm2, 1e-20)); - for (int j = 0; j < dims; j++) { - absNorm[j] = Math.abs(xCol[r + j * rows]) * invNorm; + return; + } + int maxCode = (1 << (bits - 1)) - 1; + IntStream.range(0, workers.length).parallel().forEach(worker -> { + double[] absNorm = workers[worker].absNorm; + double[] mag = workers[worker].mag; + int first = (int) ((long) worker * rows / workers.length); + int end = (int) ((long) (worker + 1) * rows / workers.length); + for (int r = first; r < end; r++) { + int offset = r * dims; + double norm2 = 0.0; + for (int j = 0; j < dims; j++) { + double value = vectors[offset + j]; + norm2 += value * value; + } + double inverse = 1.0 / Math.sqrt(Math.max(norm2, 1e-20)); + for (int j = 0; j < dims; j++) { + absNorm[j] = Math.abs(vectors[offset + j]) * inverse; + } + double scale = computeOptimalScalingFactor(absNorm, dims, bits); + double encodedNorm2 = 0.0; + for (int j = 0; j < dims; j++) { + int q = Math.min((int) (scale * absNorm[j] + K_EPS), maxCode); + double magnitude = q + 0.5; + mag[j] = magnitude; + encodedNorm2 += magnitude * magnitude; + } + double encodedInverse = 1.0 / Math.sqrt(Math.max(encodedNorm2, 1e-20)); + for (int j = 0; j < dims; j++) { + double signed = vectors[offset + j] < 0.0 ? -mag[j] : mag[j]; + vectors[offset + j] = signed * encodedInverse; + } } + }); + } + } - double t = computeOptimalScalingFactor(absNorm, dims, bitsPerDimension); - double encodedNorm2 = 0.0; - for (int j = 0; j < dims; j++) { - int q = Math.min((int) (t * absNorm[j] + K_EPS), maxCode); - double m = q + 0.5; - mag[j] = m; - encodedNorm2 += m * m; - } + private static final class DirectionScratch { + final double[] absNorm; + final double[] mag; - double encodedInvNorm = 1.0 / Math.sqrt(Math.max(encodedNorm2, 1e-20)); - for (int j = 0; j < dims; j++) { - double signed = xCol[r + j * rows] < 0.0 ? -mag[j] : mag[j]; - outCol[r + j * rows] = signed * encodedInvNorm; - } - } - }); + DirectionScratch(int dims) { + absNorm = new double[dims]; + mag = new double[dims]; + } } private static void writeStiefelTransform(IndexWriter out, StiefelTransform st) throws IOException { diff --git a/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestASHTrainingWorkspace.java b/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestASHTrainingWorkspace.java new file mode 100644 index 000000000..c37e64199 --- /dev/null +++ b/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestASHTrainingWorkspace.java @@ -0,0 +1,299 @@ +/* + * 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 org.apache.commons.math3.linear.Array2DRowRealMatrix; +import org.apache.commons.math3.linear.SingularValueDecomposition; +import org.junit.Test; + +import java.lang.reflect.Constructor; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.util.Arrays; +import java.util.Random; +import java.util.stream.IntStream; + +import static org.junit.Assert.*; + +/** Numeric layout, in-place quantization, workspace reuse and native buffer contracts. */ +public class TestASHTrainingWorkspace { + private static final double K_EPS = 1e-5; + + @Test + public void transposedProductsMatchIndependentScalarProducts() throws Exception { + Random random = new Random(41); + for (int[] shape : new int[][] {{1, 1}, {1, 5}, {3, 7}, {19, 5}, {33, 8}}) { + int rows = shape[0], dims = shape[1]; + double[] x = randomArray(rows * dims, random); + double[] xCopy = x.clone(); + double[] rotation = randomArray(dims * dims, random); + double[] rotationCopy = rotation.clone(); + double[] projected = new double[x.length]; + double[] update = new double[rotation.length]; + for (int repeat = 0; repeat < 3; repeat++) { + Arrays.fill(projected, Double.NaN); + product("projectTrainingRows", x, rows, dims, rotation, projected); + for (int row = 0; row < rows; row++) { + for (int col = 0; col < dims; col++) { + double expected = 0; + for (int k = 0; k < dims; k++) expected += x[row + k * rows] * rotation[k + col * dims]; + assertEquals(expected, projected[row * dims + col], 1e-12); + } + } + Arrays.fill(update, Double.NaN); + product("updateTrainingMatrix", x, rows, dims, projected, update); + for (int row = 0; row < dims; row++) { + for (int col = 0; col < dims; col++) { + double expected = 0; + for (int k = 0; k < rows; k++) expected += x[k + row * rows] * projected[k * dims + col]; + assertEquals(expected, update[row + col * dims], 1e-11); + } + } + assertArrayEquals(xCopy, x, 0); + assertArrayEquals(rotationCopy, rotation, 0); + } + } + } + + @Test + public void contiguousQuantizationMatchesPreviousColumnMajorAlgorithmBitForBit() throws Exception { + Random random = new Random(103); + for (int bits = 1; bits <= 9; bits++) { + for (int[] shape : new int[][] {{1, 1}, {3, 5}, {17, 9}, {35, 32}}) { + int rows = shape[0], dims = shape[1]; + Object encoder = encoder(rows, dims, bits); + for (int repeat = 0; repeat < 3; repeat++) { + double[] contiguous = randomArray(rows * dims, random); + // Zero/tiny rows and signed zero exercise normalization and sign boundaries. + for (int j = 0; j < dims; j++) contiguous[j] = j % 2 == 0 ? 0.0 : -0.0; + if (rows > 1) for (int j = 0; j < dims; j++) contiguous[dims + j] *= 1e-30; + double[] columnMajor = new double[contiguous.length]; + for (int r = 0; r < rows; r++) for (int j = 0; j < dims; j++) columnMajor[r + j * rows] = contiguous[r * dims + j]; + double[] expected = new double[contiguous.length]; + referenceColumnMajor(columnMajor, rows, dims, bits, expected); + encode(encoder, contiguous); + for (int r = 0; r < rows; r++) for (int j = 0; j < dims; j++) { + assertEquals("bits=" + bits + " row=" + r + " dim=" + j, + Double.doubleToLongBits(expected[r + j * rows]), Double.doubleToLongBits(contiguous[r * dims + j])); + } + } + } + } + } + + @Test + public void reusedPolarWorkspaceMatchesIndependentSvd() { + Random random = new Random(77); + for (int[] shape : new int[][] {{1, 1}, {5, 3}, {3, 5}, {4, 4}}) { + int rows = shape[0], cols = shape[1]; + ASHOrthogonalizer workspace = new ASHOrthogonalizer(rows, cols); + double[] destination = new double[rows * cols]; + for (int repeat = 0; repeat < 4; repeat++) { + double[] input = randomArray(rows * cols, random); + double[] copy = input.clone(); + double[][] matrix = new double[rows][cols]; + for (int r = 0; r < rows; r++) for (int c = 0; c < cols; c++) matrix[r][c] = input[r + c * rows]; + var svd = new SingularValueDecomposition(new Array2DRowRealMatrix(matrix, false)); + var expected = svd.getU().multiply(svd.getVT()); + Arrays.fill(destination, Double.NaN); + workspace.orthogonalize(input, destination); + assertArrayEquals(copy, input, 0); + for (int r = 0; r < rows; r++) for (int c = 0; c < cols; c++) assertEquals(expected.getEntry(r, c), destination[r + c * rows], 1e-11); + assertPartialIsometry(destination, rows, cols); + } + // The polar completion is not unique for rank-deficient inputs. + double[] deficient = new double[rows * cols]; + for (int r = 0; r < rows; r++) deficient[r] = r + 1; + workspace.orthogonalize(deficient, destination); + assertPartialIsometry(destination, rows, cols); + workspace.orthogonalize(new double[rows * cols], destination); + assertPartialIsometry(destination, rows, cols); + } + } + + @Test + public void polarWorkspaceRejectsInvalidBuffersAndRecoversAfterFailure() { + assertThrows(IllegalArgumentException.class, () -> new ASHOrthogonalizer(0, 1)); + assertThrows(IllegalArgumentException.class, () -> new ASHOrthogonalizer(1, -1)); + assertThrows(IllegalArgumentException.class, () -> new ASHOrthogonalizer(50000, 50000)); + ASHOrthogonalizer workspace = new ASHOrthogonalizer(2, 2); + double[] input = {1, 0, 0, 1}, output = new double[4]; + assertThrows(NullPointerException.class, () -> workspace.orthogonalize(null, output)); + assertThrows(NullPointerException.class, () -> workspace.orthogonalize(input, null)); + assertThrows(IllegalArgumentException.class, () -> workspace.orthogonalize(new double[3], output)); + assertThrows(IllegalArgumentException.class, () -> workspace.orthogonalize(input, new double[3])); + assertThrows(IllegalArgumentException.class, () -> workspace.orthogonalize(input, input)); + for (double invalid : new double[] {Double.NaN, Double.POSITIVE_INFINITY, Double.NEGATIVE_INFINITY}) { + double[] bad = {1, invalid, 0, 1}; + assertThrows(IllegalArgumentException.class, () -> workspace.orthogonalize(bad, output)); + workspace.orthogonalize(input, output); + assertArrayEquals(input, output, 1e-12); + } + } + + @Test + public void matrixAndEncoderContractsRejectInvalidShapesAndAliasing() throws Exception { + double[] x = new double[6], square = new double[4]; + assertThrows(IllegalArgumentException.class, () -> product("projectTrainingRows", x, 0, 2, square, x)); + assertThrows(IllegalArgumentException.class, () -> product("projectTrainingRows", x, 3, 2, square, new double[5])); + assertThrows(IllegalArgumentException.class, () -> product("projectTrainingRows", x, 3, 2, square, x)); + assertThrows(IllegalArgumentException.class, () -> product("updateTrainingMatrix", x, 3, 2, x, x)); + assertThrows(IllegalArgumentException.class, () -> product("updateTrainingMatrix", square, 2, 2, square, square)); + assertThrows(NullPointerException.class, () -> product("projectTrainingRows", null, 3, 2, square, x)); + assertThrows(IllegalArgumentException.class, () -> encoder(0, 2, 2)); + assertThrows(IllegalArgumentException.class, () -> encoder(1, 2, 0)); + assertThrows(IllegalArgumentException.class, () -> encoder(1, 2, 10)); + assertThrows(IllegalArgumentException.class, () -> encoder(50000, 50000, 2)); + Object encoder = encoder(3, 2, 2); + assertThrows(NullPointerException.class, () -> encode(encoder, null)); + assertThrows(IllegalArgumentException.class, () -> encode(encoder, new double[5])); + } + + @Test + public void pcaWorkspaceQueryAndDecompositionLeaveAllInputUntouched() throws Exception { + Random random = new Random(82); + for (int[] shape : new int[][] {{7, 3}, {2, 5}, {1, 1}}) { + double[] input = randomArray(shape[0] * shape[1], random); + double[] copy = input.clone(); + call(method("computeNativeVt", double[].class, int.class, int.class), null, input, shape[0], shape[1]); + assertArrayEquals(copy, input, 0); + } + } + + private static void assertPartialIsometry(double[] matrix, int rows, int cols) { + int rank = Math.min(rows, cols); + for (int i = 0; i < rank; i++) for (int j = 0; j < rank; j++) { + double dot = 0; + for (int k = 0; k < Math.max(rows, cols); k++) { + dot += rows >= cols ? matrix[k + i * rows] * matrix[k + j * rows] + : matrix[i + k * rows] * matrix[j + k * rows]; + } + assertEquals(i == j ? 1 : 0, dot, 1e-11); + } + } + + private static double[] randomArray(int count, Random random) { + double[] values = new double[count]; + for (int i = 0; i < count; i++) values[i] = random.nextGaussian(); + return values; + } + + private static Method method(String name, Class... types) throws NoSuchMethodException { + Method method = AsymmetricHashing.class.getDeclaredMethod(name, types); + method.setAccessible(true); + return method; + } + + private static Object call(Method method, Object target, Object... args) throws Exception { + try { return method.invoke(target, args); } + catch (InvocationTargetException failure) { + if (failure.getCause() instanceof Exception) throw (Exception) failure.getCause(); + throw (Error) failure.getCause(); + } + } + + private static void product(String name, double[] x, int rows, int dims, double[] other, double[] output) throws Exception { + call(method(name, double[].class, int.class, int.class, double[].class, double[].class), null, x, rows, dims, other, output); + } + + private static Object encoder(int rows, int dims, int bits) throws Exception { + Class type = Class.forName(AsymmetricHashing.class.getName() + "$TrainingDirectionEncoder"); + Constructor constructor = type.getDeclaredConstructor(int.class, int.class, int.class); + constructor.setAccessible(true); + try { return constructor.newInstance(rows, dims, bits); } + catch (InvocationTargetException failure) { + if (failure.getCause() instanceof Exception) throw (Exception) failure.getCause(); + throw (Error) failure.getCause(); + } + } + + private static void encode(Object encoder, double[] input) throws Exception { + Method method = encoder.getClass().getDeclaredMethod("encodeInPlace", double[].class); + method.setAccessible(true); + call(method, encoder, (Object) input); + } + + private static double scale(double[] values, int dims, int bits) { + try { return (double) call(method("computeOptimalScalingFactor", double[].class, int.class, int.class), null, values, dims, bits); } + catch (Exception failure) { throw new AssertionError(failure); } + } + + // Retain the former column-major loop as the numerical regression oracle. + private static void referenceColumnMajor( + double[] xCol, + int rows, + int dims, + int bitsPerDimension, + double[] outCol + ) { + if (outCol.length < rows * dims) { + throw new IllegalArgumentException("Output matrix too small"); + } + + if (bitsPerDimension <= 1) { + final double invSqrtD = 1.0 / Math.sqrt(dims); + final int size = rows * dims; + + for (int i = 0; i < size; i++) { + outCol[i] = xCol[i] >= 0.0 ? invSqrtD : -invSqrtD; + } + return; + } + + final int excessBits = bitsPerDimension - 1; + final int maxCode = (1 << excessBits) - 1; + + // Each task owns a contiguous range of training rows and its scratch arrays. + // computeOptimalScalingFactor already keeps its event buffers thread-local. + final int workers = Math.min(rows, Math.min(16, Runtime.getRuntime().availableProcessors())); + IntStream.range(0, workers).parallel().forEach(worker -> { + double[] absNorm = new double[dims]; + double[] mag = new double[dims]; + int start = worker * rows / workers; + int end = (worker + 1) * rows / workers; + + for (int r = start; r < end; r++) { + double norm2 = 0.0; + for (int j = 0; j < dims; j++) { + double v = xCol[r + j * rows]; + norm2 += v * v; + } + + double invNorm = 1.0 / Math.sqrt(Math.max(norm2, 1e-20)); + for (int j = 0; j < dims; j++) { + absNorm[j] = Math.abs(xCol[r + j * rows]) * invNorm; + } + + double t = scale(absNorm, dims, bitsPerDimension); + double encodedNorm2 = 0.0; + for (int j = 0; j < dims; j++) { + int q = Math.min((int) (t * absNorm[j] + K_EPS), maxCode); + double m = q + 0.5; + mag[j] = m; + encodedNorm2 += m * m; + } + + double encodedInvNorm = 1.0 / Math.sqrt(Math.max(encodedNorm2, 1e-20)); + for (int j = 0; j < dims; j++) { + double signed = xCol[r + j * rows] < 0.0 ? -mag[j] : mag[j]; + outCol[r + j * rows] = signed * encodedInvNorm; + } + } + }); + } + +} From 6436b6e6c03052ec633252a6cfefe1db55bd9964 Mon Sep 17 00:00:00 2001 From: Ted Willke Date: Mon, 2 Nov 2026 18:00:04 +0000 Subject: [PATCH 117/243] ash: use guarded covariance PCA for training --- docs/ash-pca-native-svd.md | 27 +++- .../quantization/ASHCovariancePCA.java | 137 +++++++++++++++++ .../quantization/AsymmetricHashing.java | 92 ++--------- .../quantization/TestASHCovariancePCA.java | 145 ++++++++++++++++++ 4 files changed, 317 insertions(+), 84 deletions(-) create mode 100644 jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHCovariancePCA.java create mode 100644 jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestASHCovariancePCA.java diff --git a/docs/ash-pca-native-svd.md b/docs/ash-pca-native-svd.md index aaab3f81b..03e11d73e 100644 --- a/docs/ash-pca-native-svd.md +++ b/docs/ash-pca-native-svd.md @@ -1,4 +1,23 @@ -# ASH PCA SVD correctness +# ASH PCA correctness + +PCA first computes FP64 covariance with DSYRK and its eigendecomposition with +DSYEVD. This avoids computing and discarding a full tall left-singular-vector +matrix. The input is read-only. Finite values, covariance trace, eigenvalue energy, +the retained tail, the retained/discarded spectral gap and selected eigenpair +residuals are validated before the leading directions are accepted. + +Covariance squares the condition number. A retained eigenvalue or cutoff gap +at the scale of accumulated FP64 error causes fallback to direct DGESDD. Zero +energy, wide input and eigenvalue nonconvergence also use direct SVD. Its full +right basis preserves null-space completion for underdetermined training. +Invalid input, invalid native arguments and invalid workspace sizes are reported; +allocation failures are not swallowed. Models and graph formats are unchanged. + +Tests compare subspace projectors with an independent direct SVD, including +repeated eigenvalues inside the retained subspace, ties at its boundary, +ill-conditioned and rank-deficient matrices, wide inputs and input preservation. + +## Historical DGESVD correctness issue During the native PQ/ASH graph comparison, JDK 25 MPNet ASH training failed in native PCA with `DGESVD info=767`. The identical frozen-runtime retry failed too. @@ -26,8 +45,8 @@ selection issue or non-finite training input. They do not establish an upstream vendor root-cause patch. Positive DGESVD status denotes QR nonconvergence; see [the LAPACK contract](https://www.netlib.org/lapack/explore-html/d1/d7f/group__gesvd_gac6bd5d4e645049e49bb70691180abf07.html). -ASH PCA now uses the portable divide-and-conquer DGESDD driver, already used by -its ITQ orthogonalization. Tall matrices overwrite the scratch input with the +The initial fix selected the portable divide-and-conquer DGESDD driver, already +used by ITQ orthogonalization and retained as the direct-SVD fallback. Tall matrices overwrite the scratch input with the unused left vectors. Wide matrices request full right vectors so projections can include null-space directions when training has fewer rows than dimensions. Finite input, workspace size and singular-value energy are validated before @@ -39,7 +58,7 @@ both JDKs with 64-by-768 and 1,024-by-768 training sets, native ASH, deferred wr and NVQ reranking. All four cases passed, including serial/concurrent result checks. Added regression sources cover principal axes, wide null-space completion and non-finite input; they compile for CI. Unit tests were not separately executed -for this study. +for that initial study; the current regression suite executes the PCA checks. All graph measurements made with the earlier ASH PCA implementation are retained as superseded evidence and recollected after the fix. PQ does not use this PCA diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHCovariancePCA.java b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHCovariancePCA.java new file mode 100644 index 000000000..31118602d --- /dev/null +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHCovariancePCA.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.quantization; + +import dev.ludovic.netlib.blas.BLAS; +import dev.ludovic.netlib.lapack.LAPACK; +import org.netlib.util.intW; + +import java.util.Objects; + +/** + * FP64 PCA from a symmetric rank-k product. Avoids the unused tall left-singular + * matrix. An uncertain retained subspace or a failed numerical check returns + * null so the trainer can use its guarded direct SVD instead. No source is mutated. + */ +final class ASHCovariancePCA { + private ASHCovariancePCA() {} + + /** + * Returns the leading {@code latent} PCA directions, largest eigenvalue + * first, as a column-major {@code [dims, latent]} matrix. The input is + * column-major {@code [rows, dims]} and remains unchanged. + * + *

    Returns {@code null} when direct SVD is needed: underdetermined input, + * unresolved retained directions or cutoff, nonconvergence, or a failed + * numerical check. Invalid input and native argument/workspace errors are + * reported rather than hidden by the fallback.

    + */ + static double[] projection(double[] x, int rows, int dims, int latent) { + Objects.requireNonNull(x, "x"); + if (rows <= 0 || dims <= 0 || latent <= 0 || latent > dims + || (long) rows * dims > Integer.MAX_VALUE || (long) dims * dims > Integer.MAX_VALUE + || x.length != rows * dims) { + throw new IllegalArgumentException("Invalid PCA matrix dimensions"); + } + double energy = 0; + for (double value : x) { + if (!Double.isFinite(value)) throw new IllegalArgumentException("Non-finite PCA input"); + energy += value * value; + } + if (!Double.isFinite(energy)) throw new IllegalArgumentException("PCA input energy overflows FP64"); + // Preserve direct SVD's full null-space basis for underdetermined inputs. + if (rows < dims || energy == 0) return null; + BLAS blas = BLAS.getInstance(); + double[] covariance = new double[dims * dims]; + blas.dsyrk("U", "T", dims, rows, 1.0, x, rows, 0.0, covariance, dims); + double trace = 0; + for (int col = 0; col < dims; col++) { + for (int row = 0; row <= col; row++) { + if (!Double.isFinite(covariance[row + col * dims])) return null; + } + trace += covariance[col + col * dims]; + } + double energyTolerance = 1e-8 * Math.max(1.0, energy); + if (!Double.isFinite(trace) || Math.abs(trace - energy) > energyTolerance) return null; + + // Retain just the small covariance for independent eigenpair checks. + // Workspace queries do not need another input copy. + double[] eigenvectors = covariance.clone(); + double[] values = new double[dims], query = new double[1]; + int[] integerQuery = new int[1]; + intW info = new intW(0); + LAPACK lapack = LAPACK.getInstance(); + lapack.dsyevd("V", "U", dims, eigenvectors, dims, values, query, -1, integerQuery, -1, info); + if (info.val != 0) throw new IllegalStateException("PCA eigen workspace query failed: " + info.val); + if (!Double.isFinite(query[0]) || query[0] < 1 || query[0] > Integer.MAX_VALUE || integerQuery[0] < 1) { + throw new IllegalStateException("Invalid PCA eigen workspace size"); + } + double[] work = new double[(int) Math.ceil(query[0])]; + int[] integerWork = new int[integerQuery[0]]; + info.val = 0; + lapack.dsyevd("V", "U", dims, eigenvectors, dims, values, work, work.length, + integerWork, integerWork.length, info); + if (info.val < 0) throw new IllegalStateException("Invalid native PCA eigen argument: " + info.val); + if (info.val > 0) return null; + double sum = 0; + double previous = Double.NEGATIVE_INFINITY; + for (double value : values) { + if (!Double.isFinite(value)) return null; + if (value < previous) return null; + previous = value; + sum += value; + } + if (Math.abs(sum - energy) > energyTolerance) return null; + double largest = values[dims - 1]; + // Covariance squares conditioning. Reject a retained tail or boundary + // gap at the scale of accumulated FP64 error, including rank deficiency. + double uncertainty = 128.0 * dims * Math.ulp(1.0) * Math.max(1.0, largest); + int cutoff = dims - latent; + if (!(largest > 0) || values[0] < -uncertainty || values[cutoff] <= uncertainty + || (cutoff > 0 && values[cutoff] - values[cutoff - 1] <= uncertainty)) { + return null; + } + + double[] vector = new double[dims], product = new double[dims]; + int[] probes = {dims - 1, dims - 1 - latent / 2, cutoff}; + for (int p = 0; p < probes.length; p++) { + int column = probes[p]; + if ((p > 0 && column == probes[0]) || (p > 1 && column == probes[1])) continue; + System.arraycopy(eigenvectors, column * dims, vector, 0, dims); + blas.dsymv("U", dims, 1.0, covariance, dims, vector, 1, 0.0, product, 1); + double residual = 0; + for (int i = 0; i < dims; i++) { + double difference = product[i] - values[column] * vector[i]; + residual += difference * difference; + } + if (!Double.isFinite(residual) || Math.sqrt(residual) > 1e-8 * Math.max(1.0, largest)) return null; + } + double[] basis = new double[dims * latent]; + for (int col = 0; col < latent; col++) { + int source = (dims - 1 - col) * dims; + double norm = 0; + for (int row = 0; row < dims; row++) { + double value = eigenvectors[source + row]; + if (!Double.isFinite(value)) return null; + basis[col * dims + row] = value; + norm += value * value; + } + if (Math.abs(norm - 1.0) > 1e-8) return null; + } + return basis; + } +} diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/AsymmetricHashing.java b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/AsymmetricHashing.java index 2872c2b24..842b99662 100644 --- a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/AsymmetricHashing.java +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/AsymmetricHashing.java @@ -1802,7 +1802,7 @@ private static int[] reservoirSampleOrdinals(int N, int k, Random rng) { * ITQ / learned projection trainer. * * Mirrors the current learned_projection path: - * - PCA basis P from SVD(X), matching the Python reference + * - PCA basis P from checked FP64 covariance PCA, with direct SVD fallback * - Xld = X @ P * - iterative Procrustes: * R = polar(M) @@ -1833,11 +1833,9 @@ private static StiefelTransform runItqTrainer( // X is [N x D], flat column-major for BLAS/LAPACK. double[] xCol = trainingData.xCol; - // PCA basis P = V[:, :d] from SVD(X), matching the Python reference. - logProgress("\t[stage] Starting Native SVD for PCA..."); - double[] vtCol = computeNativeVt(xCol, N, D); // [D x D], column-major - double[] pCol = extractPcaBasis(vtCol, D, d); // [D x d], column-major - logProgress("\t[stage] Completed Native SVD for PCA..."); + logProgress("\t[stage] Starting native PCA..."); + double[] pCol = computePcaBasis(xCol, N, D, d); + logProgress("\t[stage] Completed native PCA..."); // Xld = X @ P, shape [N x d]. double[] xldCol = nativeMultiply(xCol, N, D, pCol, d); @@ -2283,81 +2281,15 @@ private static double[] computeNativeVt(double[] xCol, int rows, int cols) { return vt; } - /** - * PCA basis from eig(X^T X). - * - * Input X is [rows x cols] in column-major layout. - * Returns P = top right singular vectors of X, shape [cols x latentDim], - * also column-major. - */ - private static double[] pcaProjectionFromCovariance( - double[] xCol, - int rows, - int cols, - int latentDim - ) { - double[] covCol = new double[cols * cols]; - - // cov = X^T @ X, shape [cols x cols]. - BLAS.getInstance().dgemm( - "T", "N", - cols, cols, rows, - 1.0, - xCol, rows, - xCol, rows, - 0.0, - covCol, cols - ); - - symmetricEigenvectorsInPlace(covCol, cols); - - // LAPACK dsyev returns eigenvalues ascending and eigenvectors by columns. - // Select the largest latentDim eigenvectors. - double[] pCol = new double[cols * latentDim]; - for (int col = 0; col < latentDim; col++) { - int eigCol = cols - 1 - col; - System.arraycopy(covCol, eigCol * cols, pCol, col * cols, cols); - } - - return pCol; - } - - /** - * Overwrites a symmetric column-major matrix with eigenvectors by column. - */ - private static void symmetricEigenvectorsInPlace(double[] aCol, int dim) { - double[] evals = new double[dim]; - intW info = new intW(0); - - double[] workQuery = new double[1]; - LAPACK.getInstance().dsyev( - "V", "U", - dim, - aCol, dim, - evals, - workQuery, -1, - info - ); - - if (info.val != 0) { - throw new RuntimeException("LAPACK dsyev workspace query failed with info=" + info.val); - } - - int lwork = Math.max(1, (int) workQuery[0]); - double[] work = new double[lwork]; - - LAPACK.getInstance().dsyev( - "V", "U", - dim, - aCol, dim, - evals, - work, lwork, - info - ); - - if (info.val != 0) { - throw new RuntimeException("LAPACK dsyev failed with info=" + info.val); + /** Prefer checked covariance PCA; preserve direct SVD for uncertain subspaces. */ + private static double[] computePcaBasis(double[] xCol, int rows, int dims, int latent) { + double[] basis = ASHCovariancePCA.projection(xCol, rows, dims, latent); + if (basis != null) { + logProgress("\t[stage] PCA used FP64 covariance eigendecomposition"); + return basis; } + logProgress("\t[stage] PCA used guarded direct SVD"); + return extractPcaBasis(computeNativeVt(xCol, rows, dims), dims, latent); } /** Single-use convenience path for random projection initialization. */ diff --git a/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestASHCovariancePCA.java b/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestASHCovariancePCA.java new file mode 100644 index 000000000..729ae5f8d --- /dev/null +++ b/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestASHCovariancePCA.java @@ -0,0 +1,145 @@ +/* + * 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 org.apache.commons.math3.linear.Array2DRowRealMatrix; +import org.apache.commons.math3.linear.SingularValueDecomposition; +import org.junit.Test; +import java.lang.reflect.InvocationTargetException; +import java.util.Random; +import static org.junit.Assert.*; + +/** Covariance PCA against independent SVD subspaces and direct-SVD fallback cases. */ +public class TestASHCovariancePCA { + @Test public void principalAxesAndInputArePreserved() { + double[] x = {3, 0, 0, 0, 0, 0, 2, 0, 0, 0, 0, 0, 1, 0, 0}; + double[] copy = x.clone(), basis = ASHCovariancePCA.projection(x, 5, 3, 2); + assertNotNull(basis); + assertOrthonormal(basis, 3, 2); + assertEquals(1.0, Math.abs(basis[0]), 1e-12); + assertEquals(1.0, Math.abs(basis[4]), 1e-12); + assertArrayEquals(copy, x, 0); + } + + @Test public void retainedSubspaceMatchesIndependentDirectSvd() { + Random random = new Random(103); + for (int[] shape : new int[][] {{13, 5, 2}, {31, 7, 4}, {21, 3, 1}}) { + int rows = shape[0], dims = shape[1], latent = shape[2]; + double[] x = new double[rows * dims]; + double[][] matrix = new double[rows][dims]; + for (int r = 0; r < rows; r++) for (int c = 0; c < dims; c++) + x[r + c * rows] = matrix[r][c] = random.nextGaussian(); + double[] copy = x.clone(), basis = ASHCovariancePCA.projection(x, rows, dims, latent); + assertNotNull(basis); + var v = new SingularValueDecomposition(new Array2DRowRealMatrix(matrix, false)).getV(); + assertOrthonormal(basis, dims, latent); + // Compare projectors, independent of eigenvector signs or rotations. + for (int r = 0; r < dims; r++) for (int c = 0; c < dims; c++) { + double actual = 0, expected = 0; + for (int j = 0; j < latent; j++) { + actual += basis[r + j * dims] * basis[c + j * dims]; + expected += v.getEntry(r, j) * v.getEntry(c, j); + } + assertEquals(expected, actual, 1e-10); + } + assertArrayEquals(copy, x, 0); + } + } + + @Test public void uncertainRankAndBoundaryUseDirectSvd() throws Exception { + double[][] inputs = {new double[15], new double[] {3,0,0, 0,0,0, 0,0,0}, + new double[] {3,0,0, 0,1,0, 0,0,1}}; + for (double[] x : inputs) { + int rows = x.length / 3; + assertNull(ASHCovariancePCA.projection(x, rows, 3, 2)); + assertOrthonormal(basis(x, rows, 3, 2), 3, 2); + } + } + + @Test public void wideInputsRetainNullSpaceCompletion() throws Exception { + double[] x = {3, 0, 4, 0, 0, 0, 0, 0}; + double[] copy = x.clone(); + assertNull(ASHCovariancePCA.projection(x, 2, 4, 3)); + assertOrthonormal(basis(x, 2, 4, 3), 4, 3); + assertArrayEquals(copy, x, 0); + } + + @Test public void illConditionedRetainedTailUsesDirectSvd() throws Exception { + double[] x = {1,0,0, 0,1e-10,0, 0,0,1e-15}; + assertNull(ASHCovariancePCA.projection(x, 3, 3, 2)); + assertOrthonormal(basis(x, 3, 3, 2), 3, 2); + } + + @Test public void repeatedEigenvaluesWithinRetainedSubspaceAreAllowed() { + double[] x = {2,0,0, 0,2,0, 0,0,1}; + for (int latent : new int[] {2, 3}) { + double[] basis = ASHCovariancePCA.projection(x, 3, 3, latent); + assertNotNull(basis); + assertOrthonormal(basis, 3, latent); + for (int r = 0; r < 3; r++) for (int c = 0; c < 3; c++) { + double product = 0; + for (int j = 0; j < latent; j++) product += basis[r + j * 3] * basis[c + j * 3]; + assertEquals(r == c && r < latent ? 1 : 0, product, 1e-12); + } + } + } + + @Test public void discardedNullDirectionsDoNotForceFallback() { + double[] x = {3,0,0, 0,2,0, 0,0,0}; + double[] basis = ASHCovariancePCA.projection(x, 3, 3, 2); + assertNotNull(basis); + assertOrthonormal(basis, 3, 2); + assertEquals(1, Math.abs(basis[0]), 1e-12); + assertEquals(1, Math.abs(basis[4]), 1e-12); + } + + @Test public void oneDimensionalInputHasACompleteBasis() { + double[] x = {0, 2, -3}, copy = x.clone(); + double[] basis = ASHCovariancePCA.projection(x, 3, 1, 1); + assertNotNull(basis); + assertOrthonormal(basis, 1, 1); + assertArrayEquals(copy, x, 0); + } + + @Test public void invalidShapesAndNonFiniteInputAreRejected() { + assertThrows(NullPointerException.class, () -> ASHCovariancePCA.projection(null, 1, 1, 1)); + assertThrows(IllegalArgumentException.class, () -> ASHCovariancePCA.projection(new double[1], 0, 1, 1)); + assertThrows(IllegalArgumentException.class, () -> ASHCovariancePCA.projection(new double[1], 1, 1, 0)); + assertThrows(IllegalArgumentException.class, () -> ASHCovariancePCA.projection(new double[1], 1, 1, 2)); + assertThrows(IllegalArgumentException.class, () -> ASHCovariancePCA.projection(new double[2], 1, 1, 1)); + assertThrows(IllegalArgumentException.class, () -> ASHCovariancePCA.projection(new double[0], 50000, 50000, 1)); + for (double invalid : new double[] {Double.NaN, Double.POSITIVE_INFINITY, Double.NEGATIVE_INFINITY, Double.MAX_VALUE}) + assertThrows(IllegalArgumentException.class, () -> ASHCovariancePCA.projection(new double[] {invalid}, 1, 1, 1)); + } + + private static double[] basis(double[] x, int rows, int dims, int latent) throws Exception { + var method = AsymmetricHashing.class.getDeclaredMethod("computePcaBasis", double[].class, int.class, int.class, int.class); + method.setAccessible(true); + try { return (double[]) method.invoke(null, x, rows, dims, latent); } + catch (InvocationTargetException failure) { + if (failure.getCause() instanceof Exception) throw (Exception) failure.getCause(); + throw (Error) failure.getCause(); + } + } + private static void assertOrthonormal(double[] basis, int dims, int latent) { + for (int i = 0; i < latent; i++) for (int j = 0; j < latent; j++) { + double dot = 0; + for (int k = 0; k < dims; k++) dot += basis[k + i * dims] * basis[k + j * dims]; + assertEquals(i == j ? 1 : 0, dot, 1e-11); + } + } +} From ac624ab4eedfeed7a891dc6db5799c37eb4f1981 Mon Sep 17 00:00:00 2001 From: Ted Willke Date: Mon, 2 Nov 2026 18:00:04 +0000 Subject: [PATCH 118/243] ash: accelerate ITQ polar steps and share quantization workers --- docs/native-ash-preparation.md | 48 ++++--- .../quantization/ASHFloatOrthogonalizer.java | 118 ++++++++++++++++++ .../quantization/AsymmetricHashing.java | 84 ++++++++----- .../TestASHFloatOrthogonalizer.java | 104 +++++++++++++++ .../TestASHTrainingWorkspace.java | 30 ++++- 5 files changed, 333 insertions(+), 51 deletions(-) create mode 100644 jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHFloatOrthogonalizer.java create mode 100644 jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestASHFloatOrthogonalizer.java diff --git a/docs/native-ash-preparation.md b/docs/native-ash-preparation.md index 3a6330b9d..475270a28 100644 --- a/docs/native-ash-preparation.md +++ b/docs/native-ash-preparation.md @@ -103,9 +103,10 @@ ITQ retains normalized sample values in a flat FP32 array. The mean is accumulat in FP64 and stored as the existing FP32 landmark. Centered residuals are normalized in FP64 and copied in bounded tiles directly into the column-major training matrix, avoiding an array of per-vector objects and a second full-size FP64 -matrix conversion. PCA, the ITQ loop, and final projection construction retain -their existing FP64 arithmetic and BLAS/LAPACK backend. The -[PCA validation contract](ash-pca-native-svd.md) is unchanged. +matrix conversion. Preparation retains bounded serial transpose tiles. +The existing BLAS/LAPACK backend is retained. PCA uses checked +FP64 covariance eigendecomposition, with guarded direct SVD fallback, as described +in the [PCA validation contract](ash-pca-native-svd.md). The `RANDOM` optimizer estimates its single landmark from the same deterministic sample, using an independent sampler so the Gaussian projection's existing random @@ -270,7 +271,7 @@ CSVs; they are not manually added to inclusive harness timers. Original CSVs, commands, class/source snapshots and live placement observations are preserved under `benchmark-runs/ash-quantization-20261101` on the development server. -## FP64 ITQ training workspaces +## ITQ training workspaces ITQ keeps the residual and PCA-reduced inputs in column-major FP64 arrays for BLAS/LAPACK. Its iterative projection writes contiguous training vectors directly: @@ -280,20 +281,35 @@ row in place; the unquantized result is not needed by the update or loss. A seco DGEMM consumes this layout to overwrite the existing update matrix with `Xld^T * Xenc`. No explicit transpose pass or second `[N,d]` output is required. -Each trainer owns an `ASHOrthogonalizer` with one thin-DGESDD workspace. It queries -the required workspace once, copies only the input for each destructive SVD, and -reuses the singular vectors, values, output and native work arrays. The direction -encoder similarly retains scratch for its logical workers across iterations. -Workers operate on disjoint rows; neither workspace is shared between trainers. -The existing scaling-event buffers remain thread-local. Parallel task submission +Each trainer owns an `ASHFloatOrthogonalizer` with one thin-SGESDD workspace for +repeated polar steps. It rounds the small update matrix to FP32 into reusable +scratch, decomposes it, multiplies `U * VT` with SGEMM, and widens the rotation into +the trainer's FP64 buffer. Its workspace is queried once; singular vectors, +values and native work arrays are reused. The destroyed SVD input also holds +the polar product, avoiding another output array. + +After the last update, the trainer computes a fresh FP64 polar factor with +`ASHOrthogonalizer` before forming the final projection. This preserves the +final transform's orthogonality contract. Random projection initialization also +retains FP64. Both implementations use the existing portable BLAS/LAPACK provider; +there is no new native dependency, vendor selection or precision control. + +The direction encoder retains scratch for its logical workers across iterations. +Workers operate on disjoint rows in the shared physical-core executor, without +the previous fixed 16-worker cap. Each trainer owns its direction and polar +workspaces; scaling-event buffers remain thread-local. Parallel task submission still occurs each iteration; this is not an allocation-free training API. -PCA retains its original FP64 DGESDD algorithm, complete right-singular-vector -basis, wide-matrix handling and spectral-energy check. Its workspace query uses -the read-only input directly; only the actual destructive decomposition needs a -copy. The ITQ norms, update, polar decomposition, loss, random initialization and -stopping criterion remain FP64. The scaling-factor search retains its existing -FP32 arithmetic. The 1-bit sign rule and 2–9-bit direction quantization are +PCA forms the upper triangle of `X^T X` with FP64 DSYRK and obtains eigenvectors +with DSYEVD. Spectral energy, retained-tail conditioning, the cutoff gap and +selected eigenpair residuals are checked before accepting the result. Uncertain +or underdetermined inputs use the guarded FP64 DGESDD fallback, including its +complete null-space basis for wide matrices. No full left-singular-vector matrix +is computed in the common covariance path. + +Large ITQ projection/update products, norms, update storage, loss and the stopping +criterion remain FP64. The scaling-factor search retains its existing FP32 +arithmetic and event ordering. The 1-bit sign rule and 2–9-bit direction quantization are unchanged; tests compare the new in-place encoder to the former column-major algorithm bit for bit on identical projected inputs. diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHFloatOrthogonalizer.java b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHFloatOrthogonalizer.java new file mode 100644 index 000000000..7a49c6f3e --- /dev/null +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHFloatOrthogonalizer.java @@ -0,0 +1,118 @@ +/* + * 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 dev.ludovic.netlib.blas.BLAS; +import dev.ludovic.netlib.lapack.LAPACK; +import org.netlib.util.intW; + +import java.util.Objects; + +/** + * Reusable FP32 thin-SVD workspace for the polar factor U * VT. + * FP64 caller buffers keep the rest of the trainer independent of this choice. + * + *

    One instance belongs to one trainer. It is not thread-safe and retains no + * caller buffers. Input is copied only for the destructive decomposition; the + * workspace query is performed once, using internal scratch.

    + */ +final class ASHFloatOrthogonalizer { + private final int rows; + private final int cols; + private final int rank; + private final int elements; + private final float[] input; + private final float[] u; + private final float[] vt; + private final float[] singularValues; + private final int[] iwork; + private final float[] work; + private final intW info = new intW(0); + private final LAPACK lapack = LAPACK.getInstance(); + private final BLAS blas = BLAS.getInstance(); + + ASHFloatOrthogonalizer(int rows, int cols) { + if (rows <= 0 || cols <= 0 || (long) rows * cols > Integer.MAX_VALUE) { + throw new IllegalArgumentException("Invalid ASH orthogonalization dimensions"); + } + this.rows = rows; + this.cols = cols; + rank = Math.min(rows, cols); + elements = rows * cols; + input = new float[elements]; + u = new float[Math.multiplyExact(rows, rank)]; + vt = new float[Math.multiplyExact(rank, cols)]; + singularValues = new float[rank]; + iwork = new int[Math.multiplyExact(8, rank)]; + float[] query = new float[1]; + lapack.sgesdd("S", rows, cols, input, rows, singularValues, u, rows, + vt, rank, query, -1, iwork, info); + if (info.val != 0) { + throw new IllegalStateException("FP32 ASH SVD workspace query failed with info=" + info.val); + } + if (!Float.isFinite(query[0]) || query[0] < 1 || (double) query[0] > Integer.MAX_VALUE) { + throw new IllegalStateException("Invalid FP32 ASH SVD workspace size: " + query[0]); + } + // A FP32 query may round a large integer workspace size downward. + // One representable step rounds it conservatively upward. + double required = Math.nextUp(query[0]); + if (required > Integer.MAX_VALUE) { + throw new IllegalStateException("FP32 ASH SVD workspace exceeds the Java array limit"); + } + work = new float[(int) Math.ceil(required)]; + } + + /** + * Writes the polar factor in column-major order. Buffers must have the + * constructor's shape and must be distinct. The source is never modified, + * including on failure; every successful call overwrites the whole output. + */ + void orthogonalize(double[] source, double[] destination) { + Objects.requireNonNull(source, "source"); + Objects.requireNonNull(destination, "destination"); + if (source.length != elements || destination.length != elements || source == destination) { + throw new IllegalArgumentException("Invalid ASH orthogonalization buffers"); + } + for (int i = 0; i < elements; i++) { + float value = (float) source[i]; + if (!Float.isFinite(value)) { + throw new IllegalArgumentException("ASH orthogonalization input must be finite and representable in FP32"); + } + input[i] = value; + } + info.val = 0; + lapack.sgesdd("S", rows, cols, input, rows, singularValues, u, rows, + vt, rank, work, work.length, iwork, info); + if (info.val != 0) { + throw new IllegalStateException("FP32 ASH SVD failed with info=" + info.val); + } + for (float value : singularValues) { + if (!Float.isFinite(value) || value < 0) { + throw new IllegalStateException("FP32 ASH SVD returned an invalid singular value"); + } + } + // The destructive SVD input is dead and can hold the FP32 polar factor. + blas.sgemm("N", "N", rows, cols, rank, 1.0f, u, rows, + vt, rank, 0.0f, input, rows); + for (int i = 0; i < elements; i++) { + if (!Float.isFinite(input[i])) { + throw new IllegalStateException("FP32 ASH polar factor contains a non-finite value"); + } + destination[i] = input[i]; + } + } +} diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/AsymmetricHashing.java b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/AsymmetricHashing.java index 842b99662..deb035b5a 100644 --- a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/AsymmetricHashing.java +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/AsymmetricHashing.java @@ -22,6 +22,7 @@ import io.github.jbellis.jvector.graph.RandomAccessVectorValues; import io.github.jbellis.jvector.graph.disk.OnDiskGraphIndex; import io.github.jbellis.jvector.util.Accountable; +import io.github.jbellis.jvector.util.PhysicalCoreExecutor; import io.github.jbellis.jvector.util.RamUsageEstimator; import io.github.jbellis.jvector.vector.VectorUtil; import io.github.jbellis.jvector.vector.VectorUtilSupport; @@ -1849,7 +1850,10 @@ private static StiefelTransform runItqTrainer( logProgress("\t[stage] ITQ training iterations started..."); long startTime = System.nanoTime(); - ASHOrthogonalizer orthogonalizer = new ASHOrthogonalizer(d, d); + // Repeated polar steps need FP32 accuracy; the accumulated update, + // projection products and loss retain FP64. Final W uses a FP64 polar + // factor so the stored transform keeps its orthogonality contract. + ASHFloatOrthogonalizer orthogonalizer = new ASHFloatOrthogonalizer(d, d); double[] rCol = new double[Math.multiplyExact(d, d)]; TrainingDirectionEncoder encoder = new TrainingDirectionEncoder(N, d, bitsPerDimension); @@ -1873,8 +1877,9 @@ private static StiefelTransform runItqTrainer( recentLosses[epoch % 3] = loss.loss; } - // Final polar step after the last update, using the same workspace. - orthogonalizer.orthogonalize(mCol, rCol); + // Recompute from the final update, in FP64, rather than retaining the + // previous epoch's rotation or widening a FP32 polar factor. + new ASHOrthogonalizer(d, d).orthogonalize(mCol, rCol); double loopTime = (System.nanoTime() - startTime) / 1e9; logProgress("\t[stage] ITQ training completed in " + loopTime + " seconds."); @@ -2323,7 +2328,7 @@ private static final class TrainingDirectionEncoder { this.elements = rows * dims; this.bits = bits; int count = bits == 1 ? 0 - : Math.min(rows, Math.min(16, Runtime.getRuntime().availableProcessors())); + : Math.min(rows, PhysicalCoreExecutor.getPhysicalCoreCount()); workers = new DirectionScratch[count]; for (int i = 0; i < count; i++) workers[i] = new DirectionScratch(dims); } @@ -2341,37 +2346,48 @@ void encodeInPlace(double[] vectors) { return; } int maxCode = (1 << (bits - 1)) - 1; - IntStream.range(0, workers.length).parallel().forEach(worker -> { - double[] absNorm = workers[worker].absNorm; - double[] mag = workers[worker].mag; - int first = (int) ((long) worker * rows / workers.length); - int end = (int) ((long) (worker + 1) * rows / workers.length); - for (int r = first; r < end; r++) { - int offset = r * dims; - double norm2 = 0.0; - for (int j = 0; j < dims; j++) { - double value = vectors[offset + j]; - norm2 += value * value; - } - double inverse = 1.0 / Math.sqrt(Math.max(norm2, 1e-20)); - for (int j = 0; j < dims; j++) { - absNorm[j] = Math.abs(vectors[offset + j]) * inverse; - } - double scale = computeOptimalScalingFactor(absNorm, dims, bits); - double encodedNorm2 = 0.0; - for (int j = 0; j < dims; j++) { - int q = Math.min((int) (scale * absNorm[j] + K_EPS), maxCode); - double magnitude = q + 0.5; - mag[j] = magnitude; - encodedNorm2 += magnitude * magnitude; - } - double encodedInverse = 1.0 / Math.sqrt(Math.max(encodedNorm2, 1e-20)); - for (int j = 0; j < dims; j++) { - double signed = vectors[offset + j] < 0.0 ? -mag[j] : mag[j]; - vectors[offset + j] = signed * encodedInverse; - } + if (workers.length == 1) { + encodeRows(vectors, workers[0], 0, rows, maxCode); + } else { + // Share the library's bounded physical-core pool. A trainer's + // logical workers own their scratch and disjoint output rows. + PhysicalCoreExecutor.instance().execute(() -> + IntStream.range(0, workers.length).parallel().forEach(worker -> { + int first = (int) ((long) worker * rows / workers.length); + int end = (int) ((long) (worker + 1) * rows / workers.length); + encodeRows(vectors, workers[worker], first, end, maxCode); + })); + } + } + + private void encodeRows(double[] vectors, DirectionScratch scratch, int first, int end, int maxCode) { + double[] absNorm = scratch.absNorm; + double[] mag = scratch.mag; + for (int r = first; r < end; r++) { + int offset = r * dims; + double norm2 = 0.0; + for (int j = 0; j < dims; j++) { + double value = vectors[offset + j]; + norm2 += value * value; + } + double inverse = 1.0 / Math.sqrt(Math.max(norm2, 1e-20)); + for (int j = 0; j < dims; j++) { + absNorm[j] = Math.abs(vectors[offset + j]) * inverse; + } + double scale = computeOptimalScalingFactor(absNorm, dims, bits); + double encodedNorm2 = 0.0; + for (int j = 0; j < dims; j++) { + int q = Math.min((int) (scale * absNorm[j] + K_EPS), maxCode); + double magnitude = q + 0.5; + mag[j] = magnitude; + encodedNorm2 += magnitude * magnitude; } - }); + double encodedInverse = 1.0 / Math.sqrt(Math.max(encodedNorm2, 1e-20)); + for (int j = 0; j < dims; j++) { + double signed = vectors[offset + j] < 0.0 ? -mag[j] : mag[j]; + vectors[offset + j] = signed * encodedInverse; + } + } } } diff --git a/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestASHFloatOrthogonalizer.java b/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestASHFloatOrthogonalizer.java new file mode 100644 index 000000000..a7d11f322 --- /dev/null +++ b/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestASHFloatOrthogonalizer.java @@ -0,0 +1,104 @@ +/* + * 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 org.apache.commons.math3.linear.Array2DRowRealMatrix; +import org.apache.commons.math3.linear.SingularValueDecomposition; +import org.junit.Test; + +import java.util.Arrays; +import java.util.Random; + +import static org.junit.Assert.*; + +/** FP32 polar factors against an independent FP64 reference and degenerate shapes. */ +public class TestASHFloatOrthogonalizer { + @Test + public void reusedWorkspaceMatchesIndependentPolarFactor() { + Random random = new Random(77); + for (int[] shape : new int[][] {{1,1}, {5,3}, {3,5}, {7,7}}) { + int rows = shape[0], cols = shape[1]; + ASHFloatOrthogonalizer workspace = new ASHFloatOrthogonalizer(rows, cols); + double[] output = new double[rows * cols]; + for (int repeat = 0; repeat < 12; repeat++) { + double[] input = new double[output.length]; + double[][] rounded = new double[rows][cols]; + for (int r = 0; r < rows; r++) for (int c = 0; c < cols; c++) { + input[r + c * rows] = random.nextGaussian(); + rounded[r][c] = (float) input[r + c * rows]; + } + double[] copy = input.clone(); + var svd = new SingularValueDecomposition(new Array2DRowRealMatrix(rounded, false)); + var expected = svd.getU().multiply(svd.getVT()); + Arrays.fill(output, Double.NaN); + workspace.orthogonalize(input, output); + assertArrayEquals(copy, input, 0); + for (int r = 0; r < rows; r++) for (int c = 0; c < cols; c++) { + assertEquals(expected.getEntry(r, c), output[r + c * rows], 2e-5); + } + assertPartialIsometry(output, rows, cols); + } + } + } + + @Test + public void zeroAndRankDeficientInputsKeepAnOrthonormalCompletion() { + for (int[] shape : new int[][] {{1,1}, {5,3}, {3,5}, {7,7}}) { + int rows = shape[0], cols = shape[1]; + ASHFloatOrthogonalizer workspace = new ASHFloatOrthogonalizer(rows, cols); + double[] input = new double[rows * cols], output = new double[input.length]; + workspace.orthogonalize(input, output); + assertPartialIsometry(output, rows, cols); + for (int r = 0; r < rows; r++) input[r] = r + 1; + workspace.orthogonalize(input, output); + assertPartialIsometry(output, rows, cols); + } + } + + @Test + public void invalidInputsAreRejectedAndTheWorkspaceCanBeReusedAfterFailure() { + assertThrows(IllegalArgumentException.class, () -> new ASHFloatOrthogonalizer(0, 1)); + assertThrows(IllegalArgumentException.class, () -> new ASHFloatOrthogonalizer(1, -1)); + assertThrows(IllegalArgumentException.class, () -> new ASHFloatOrthogonalizer(50000, 50000)); + ASHFloatOrthogonalizer workspace = new ASHFloatOrthogonalizer(2, 2); + double[] input = {1,0,0,1}, output = new double[4]; + assertThrows(NullPointerException.class, () -> workspace.orthogonalize(null, output)); + assertThrows(NullPointerException.class, () -> workspace.orthogonalize(input, null)); + assertThrows(IllegalArgumentException.class, () -> workspace.orthogonalize(new double[3], output)); + assertThrows(IllegalArgumentException.class, () -> workspace.orthogonalize(input, new double[3])); + assertThrows(IllegalArgumentException.class, () -> workspace.orthogonalize(input, input)); + for (double invalid : new double[] {Double.NaN, Double.POSITIVE_INFINITY, + Double.NEGATIVE_INFINITY, Double.MAX_VALUE}) { + assertThrows(IllegalArgumentException.class, + () -> workspace.orthogonalize(new double[] {1,invalid,0,1}, output)); + workspace.orthogonalize(input, output); + assertArrayEquals(input, output, 1e-6); + } + } + + private static void assertPartialIsometry(double[] matrix, int rows, int cols) { + int rank = Math.min(rows, cols); + for (int i = 0; i < rank; i++) for (int j = 0; j < rank; j++) { + double dot = 0; + for (int k = 0; k < Math.max(rows, cols); k++) { + dot += rows >= cols ? matrix[k + i * rows] * matrix[k + j * rows] + : matrix[i + k * rows] * matrix[j + k * rows]; + } + assertEquals(i == j ? 1 : 0, dot, 2e-5); + } + } +} diff --git a/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestASHTrainingWorkspace.java b/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestASHTrainingWorkspace.java index c37e64199..0d252e318 100644 --- a/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestASHTrainingWorkspace.java +++ b/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestASHTrainingWorkspace.java @@ -25,6 +25,7 @@ import java.lang.reflect.Method; import java.util.Arrays; import java.util.Random; +import java.util.concurrent.CompletableFuture; import java.util.stream.IntStream; import static org.junit.Assert.*; @@ -73,7 +74,7 @@ public void transposedProductsMatchIndependentScalarProducts() throws Exception public void contiguousQuantizationMatchesPreviousColumnMajorAlgorithmBitForBit() throws Exception { Random random = new Random(103); for (int bits = 1; bits <= 9; bits++) { - for (int[] shape : new int[][] {{1, 1}, {3, 5}, {17, 9}, {35, 32}}) { + for (int[] shape : new int[][] {{1, 1}, {3, 5}, {17, 9}, {35, 32}, {97, 17}}) { int rows = shape[0], dims = shape[1]; Object encoder = encoder(rows, dims, bits); for (int repeat = 0; repeat < 3; repeat++) { @@ -95,6 +96,33 @@ public void contiguousQuantizationMatchesPreviousColumnMajorAlgorithmBitForBit() } } + @Test + public void independentTrainersCanQuantizeConcurrently() { + CompletableFuture[] jobs = IntStream.range(0, 4).mapToObj(index -> + CompletableFuture.runAsync(() -> { + try { + int rows = 97 + index, dims = 17 + index, bits = 2 + index; + Object encoder = encoder(rows, dims, bits); + Random random = new Random(108 + index); + for (int repeat = 0; repeat < 3; repeat++) { + double[] actual = randomArray(rows * dims, random); + double[] columns = new double[actual.length]; + for (int r = 0; r < rows; r++) for (int d = 0; d < dims; d++) + columns[r + d * rows] = actual[r * dims + d]; + double[] expected = new double[actual.length]; + referenceColumnMajor(columns, rows, dims, bits, expected); + encode(encoder, actual); + for (int r = 0; r < rows; r++) for (int d = 0; d < dims; d++) + assertEquals(Double.doubleToLongBits(expected[r + d * rows]), + Double.doubleToLongBits(actual[r * dims + d])); + } + } catch (Exception failure) { + throw new AssertionError(failure); + } + })).toArray(CompletableFuture[]::new); + CompletableFuture.allOf(jobs).join(); + } + @Test public void reusedPolarWorkspaceMatchesIndependentSvd() { Random random = new Random(77); From 7e99a0c1136dcbcb69567ae4abab4e31506cea9c Mon Sep 17 00:00:00 2001 From: Ted Willke Date: Mon, 2 Nov 2026 18:00:04 +0000 Subject: [PATCH 119/243] native: include oneDNN in default ASH builds --- README.md | 5 ++-- jvector-native/pom.xml | 4 +-- jvector-native/src/main/native/README.md | 29 ++++++++++--------- .../src/main/native/meson_options.txt | 4 +-- .../src/main/native/src/build_native_lib.sh | 5 +++- 5 files changed, 27 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index 9f6b31129..7ef7a64e7 100644 --- a/README.md +++ b/README.md @@ -128,19 +128,20 @@ git clone --recurse-submodules ### Building native libraries -**For ASH on JDK 22 or newer, oneDNN is the recommended native installation.** +**Native builds include oneDNN by default for ASH on JDK 22 or newer.** It is automatically preferred for FP32 base-vector batch projection and FP16 query projection on supported CPUs. Highway FP32 remains the portable fallback. Install CMake 3.13+ in addition to the C++ compiler, Meson, and Ninja, then build from the repository root: ```bash -mvn -pl jvector-native -am package -Dnative.onednn=true +mvn -pl jvector-native -am package ``` Meson fetches the pinned oneDNN source and Maven packages its statically linked add-on with the native JAR. Deploy that JAR with the native provider; no separate oneDNN runtime installation or per-machine library symlinks are required. +Use `-Dnative.onednn=false` to build a Highway-only package with FP32 fallback. See the [oneDNN installation and fallback details](./jvector-native/src/main/native/README.md#recommended-onednn-build-and-package). diff --git a/jvector-native/pom.xml b/jvector-native/pom.xml index 4b0663689..438337ea4 100644 --- a/jvector-native/pom.xml +++ b/jvector-native/pom.xml @@ -16,8 +16,8 @@ true false - - false + + true + +# ASH query batching: maintainer design + +Database integrators should use [the caller integration guide](ash-query-integration.md). +They do not need to know or configure the coordination described here. + +## Projection and policy are separate + +`NativeASHFP16Projection.projectBatch` accepts independent FP32 query rows and +caller-owned FP32 output rows. The native implementation converts inputs to FP16, +uses FP16 weights with strict FP32 accumulation/output, and executes true oneDNN +matrix multiplication. The API accepts arbitrary batch counts; its internal +micro-tiles of at most eight rows are an implementation choice, not an API limit. +Singletons use the existing direct kernel. Training and base-vector encoding +continue through the separate FP32 projection object. + +`CallerBatchProcessor` is a generic synchronous computation combiner, independent +of ASH, FFM and oneDNN. Its `Operation` accepts borrowed input/output objects and +supports direct and batch operations. It creates no executor, worker, timer, +future or per-call request object after each thread's first use. It can be reused +for other computations that benefit from combining independent caller work. + +## Fixed capacity and a pressure bypass + +For ASH, C is the smaller of the JVM processor limit and the existing physical +core budget, with a minimum of one. The processor has C direct slots and +L = max(1, floor(C / 4)) combining contexts. Capacity follows the CPU budget, +not a caller-thread count that the library does not own. Neither value is learned +or matrix-specific. The sole optional ASH policy property is +`jvector.ash.queryBatchSize` (default 8, 1 disables coordination). + +The recommended caller-side worker capacity is 4C for every model. With eight +allocated physical cores this gives 32 caller workers and two combining contexts. +The library derives its internal capacity; database integrations do not select a +leader count. Keep the worker/core ratio fixed for throughput comparisons. A +single active query is the light-traffic scenario within that configuration. + +When no work is queued or executing as a multi-query batch, a caller claims a direct slot and runs +the original singleton operation immediately. No queue publication or batch-list +mutation is involved. Only actual overlap exhausting that direct budget sends +work through the combining path. This avoids throttling lightly overlapping, +short projections to L operations. + +A combining caller claims a free context and immediately takes existing FIFO +requests, up to the configured capacity. It does not wait for a full batch. +While requests are pending or a genuine multi-query batch is executing, new +arrivals join that path, preserving FIFO progress and the shared matrix +computation. Once the queue and real batches drain, free direct slots are usable +immediately. An overflowing singleton alone cannot suppress free direct capacity: +it must not create a queue phase after a brief overlap spike. The distinction +uses the actual batch width, not time measurements or prediction. + +Direct calls already executing are outside the combining limit. There can be at +most C + L executing projection operations, all on existing caller threads. This +is an operation bound, not an extra thread or CPU reservation. The policy does +not promise a full batch under heavy overall search traffic: backlog must occur +at projection, not merely elsewhere in search. + +## Publication, completion and failure + +Producers publish reusable thread-local request records to a CAS stack. A caller +with free combining capacity detaches and reverses a chain under a short +nonblocking drain gate, preserving FIFO publication order. Context capacity is +never reserved for a parked caller. No queue monitor is held during native work. + +Request state publishes assignment and completion. A parked-flag handshake avoids +unnecessary wakeups while preventing a completion/park race from losing a signal. +A caller may help drain work while waiting. Inputs, outputs and failure references +are cleared before reuse. A failed batch completes every member with the same +failure and releases capacity, allowing subsequent calls to proceed. Interrupts +are restored after completion; borrowed data is never abandoned mid-computation. +Reentry into the same processor is rejected. Each participating caller has at +most one outstanding request in this processor. The application's admission +budget bounds total pending work; maximum batch size is not a queue-depth limit. + +Input/output row-reference arrays used to bridge a batch to FFM are reused per +executing thread, grow only when needed, and are cleared in a finally block. +They do not retain the model. Native per-thread workspaces similarly do not own +the projection or its packed weights. Native batch kernels/weights are lazily +cached on the model and shared safely across callers. Exceptions cannot cross +the C ABI; status handling remains at the Java/native boundary. + +## Capability and memory ownership + +A projection explicitly advertises bulk support. Providers without a real batch +kernel bypass coordination. A missing batch ABI preserves compatibility with +older native libraries. A native UNSUPPORTED response disables subsequent batch +attempts on that FP16 projection and uses the existing singleton fallback. +Other failures remain errors rather than being silently hidden. + +The query-specific projection and coordinator are initialized together on first +query use and published through the volatile query projection. Encoding/training +alone allocates neither. ASH accounts for the coordinator, its operation object, +owned slot/context arrays, and query projection storage once per model. A query +view that aliases the primary FP32 projection is not charged twice. Native packed +weight growth updates the existing projection retained-byte estimate. Caller TLS +requests and scratch belong to the caller budget, matching existing query-scratch +ownership; they are not multiplied into each model's retained total. + +Batch histograms are diagnostic snapshots, exact when the processor is idle and +weakly consistent during concurrent writes. Updates belong to exclusive slots, +without a globally contended per-query counter. Snapshot allocation is outside +the search hot path. Statistics must not influence dispatch decisions. + +## Regression gates + +Tests cover singleton execution at batch capacity eight, pressure-triggered +combining, FIFO progress, bounded operation counts, repeated record reuse, +interruption, reentry, batch/direct failures, completion/park races, and steady +state allocation. Native tests compare singleton and batch results with an +independent FP16-rounded reference, including tails and counts above eight, +aliasing, validation, fallback and retained-memory growth. + +Use BenchYAML for graph measurements. Compare batch size 1 and the default under +matched affinity, CPU budget, callers, index/query data and warmup. Check isolated, +saturated and intermediate arrival scenarios, actual recall and acceptance-to-return +latency. Short projections must not pay persistent queue latency when overlap +fits the direct budget; projection-heavy searches must demonstrate end-to-end +benefit. Keep compute-only kernel audits separate from graph performance claims. diff --git a/docs/ash-query-integration.md b/docs/ash-query-integration.md new file mode 100644 index 000000000..5630393f2 --- /dev/null +++ b/docs/ash-query-integration.md @@ -0,0 +1,159 @@ + + +# Integrating ASH search into a database + +ASH search uses the application's calling threads. A search is synchronous: +its result or exception is returned to the caller when the work finishes. +JVector does not create an ASH query executor. Concurrent searches sharing an ASH +model can automatically combine projection work; isolated requests start +immediately without waiting for more queries. Applications submit ordinary, +independent searches and do not construct batches. + +## Recommended caller configuration + +Let **C** be the physical cores allocated to search, after reserving capacity for +other database work. Count the cores available to this service or partition, +not every core in the host and not both SMT siblings as two physical cores. +Use one shared admission budget across indexes that compete for those cores. +A request that fans out to several indexes consumes several search slots. + +Use **4C caller workers** and keep the default maximum projection batch size of +**8**. Apply the same rule across models and compression levels. The integration +selects its existing search CPU budget C; worker capacity is derived from it. +No model-specific concurrency setting or separate projection executor is needed. + +| Search CPU budget C | Caller worker capacity 4C | +| --- | --- | +| 1 core | 4 workers | +| 2 cores | 8 workers | +| 4 cores | 16 workers | +| 8 cores | 32 workers | +| 16 cores | 64 workers | +| 32 cores | 128 workers | +| 48 cores | 192 workers | + +This is the recommended configuration, not a requirement to maintain 4C requests +in flight. Under light traffic, one request starts immediately and the remaining +workers stay idle. Under load, additional callers allow searches to progress +while others wait for shared computation or I/O. They do not add CPU capacity. +Keep the worker capacity fixed as arrival rate changes; let bounded admission +control overload instead of creating more workers or waiting to fill a batch. + +Validate end-to-end latency against the database's service objectives. More +runnable callers than cores can introduce CPU scheduling tails. Record p50, p95, +p99 and deadline misses as well as QPS; the recommendation does not guarantee an +improvement in every latency percentile. + +Use a bounded platform-thread pool or an equivalent database scheduler for +these synchronous calls. A large number of tasks or virtual threads is not by +itself evidence that enough independent searches can execute concurrently. +Blocking native computation occupies the executing thread; test the scheduler +that will actually run in production. + +## Tell JVector about the allocation + +Before initializing JVector, set the existing JVM property +`-Djvector.physical_core_count=C` to the physical-core budget assigned to search. +ASH also respects the JVM's `availableProcessors()` limit, using the smaller of +these two counts. The property is a sizing hint: it does **not** pin threads or +reserve cores, and it also sizes other JVector physical-core executors. Use the +database's CPU controls, OS affinity, or container allocation to enforce the +budget. If necessary, use the JVM's `-XX:ActiveProcessorCount=C` consistently +with that allocation. + +On NUMA machines, place search callers and index memory together where practical. +Validate placement rather than assuming a thread-count property controls it. +Budget concurrent index construction, compaction and training separately from +query traffic so they cannot silently consume the search allocation. + +Keep ASH's default maximum projection batch size of **8**. No other ASH query +coordination setting is needed. The optional JVM property +`-Djvector.ash.queryBatchSize=1` disables query combining for comparison or +operational rollback. This property is read when each ASH model is constructed +or loaded; set it before doing so. It does not change compression, the index +format, or FP32 base-vector encoding/training. Batch size does not set the +application's queue depth or concurrent search count. + +For native deployment, follow the repository's +[oneDNN build and packaging instructions](../jvector-native/src/main/native/README.md#recommended-onednn-build-and-package). +JDK 22+ native access and the Vector API must be enabled as described in the +[provider instructions](../README.md#simd-provider-selection). The packaged +oneDNN query implementation runs on the calling threads without a nested native +thread pool. Retain that model; adding native query threads on top of the +application's callers oversubscribes the CPU budget. When optimized projection +is unavailable, JVector retains its existing projection fallback. + +## Admission, state ownership and completion + +The database owns arrival scheduling, overload handling and completion delivery: + +1. Admit a request to a bounded search executor or reject/backpressure it when + that executor and its bounded queue are full. Size the admission queue from + the request latency budget; an unbounded queue turns overload into long + delays and retained request memory. +2. Reuse the loaded index and its immutable ASH model across searches. Give + each active search exclusive use of a `GraphSearcher`, its view and mutable + scoring/scratch state. A pool can reuse that state after a call completes; + concurrent calls must not share one searcher's mutable state. Creating a + separate model per request prevents sharing and repeatedly allocates matrices. +3. Obtain the query vector from the database's normal request/source API before + invoking the synchronous search. Keep it and any borrowed buffers valid and + unmodified until the call returns. ASH does not fetch queries from an input + source or take ownership of the database's request queue. +4. Return the result or exception through the database's existing response path. + An asynchronous integration may complete an application-owned promise after + the synchronous call finishes. JVector does not create a future per query. + +A request timeout may stop the database waiting for a result, but must not cause +its buffers, searcher, or index/model resources to be reused or closed while +native work is still running. Release them only after the actual call finishes. +Interrupts are preserved; they do not abandon borrowed buffers in another +caller's computation. Apply deadlines and cancellation at admission and at safe +boundaries supported by the integration. Include waiting for admission in +end-to-end latency. + +## Validate the integration + +Use the database's normal query mix and scheduler, with native dependencies and +CPU/NUMA placement matching deployment. Compare the default against batch size 1 +under the same placement and caller budget: + +- **Isolated requests:** submit one query at a time to the normal 4C-worker + executor with the default batch size. There should be no deliberate delay to + collect more queries. One active request is a traffic scenario, not a different + worker configuration. +- **Sustained backlog:** keep the recommended 4C callers supplied with work. + Record achieved QPS, CPU utilization, memory and p50/p95/p99 + acceptance-to-completion latency. Keep the 4:1 worker/core ratio when comparing + different CPU budgets. +- **Intermediate and bursty traffic:** test several offered rates below + saturation, including a Poisson arrival stream and realistic bursts. Report + offered and achieved QPS separately and keep late requests in the latency + measurement instead of dropping them. + +Measure the entire real search, including query preparation and reranking. +Check recall against the same queries and ground truth, and require no regression +when changing execution policy. Repeat matched runs to distinguish scheduling +variance from a persistent regression. A projection-only speedup is useful +analysis, but does not establish an end-to-end search improvement. + +Warm each loaded model through the real query path before serving latency-sensitive +traffic if deployment permits. Include representative concurrency as well as +isolated searches during warmup. Query-specific matrices, native kernels and caller +scratch are created lazily. Include model-retained memory in the index budget and +per-active-caller scratch in the caller budget; increasing the caller count has a +memory cost even when the model is shared. diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/AsymmetricHashing.java b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/AsymmetricHashing.java index 517a685ad..d7bee8009 100644 --- a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/AsymmetricHashing.java +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/AsymmetricHashing.java @@ -22,6 +22,7 @@ import io.github.jbellis.jvector.graph.RandomAccessVectorValues; import io.github.jbellis.jvector.graph.disk.OnDiskGraphIndex; import io.github.jbellis.jvector.util.Accountable; +import io.github.jbellis.jvector.util.CallerBatchProcessor; import io.github.jbellis.jvector.util.PhysicalCoreExecutor; import io.github.jbellis.jvector.util.RamUsageEstimator; import io.github.jbellis.jvector.vector.VectorUtil; @@ -36,6 +37,7 @@ import dev.ludovic.netlib.lapack.LAPACK; import java.io.IOException; import java.util.Arrays; +import java.util.List; import java.util.Objects; import java.util.concurrent.ForkJoinPool; import java.util.Random; @@ -1248,6 +1250,8 @@ private static ByteSequence createBodyByteSequenceForBits(int nBits) { * Orthonormal linear transform on the Stiefel manifold. * W : originalDim × quantizedDim * A : quantizedDim × originalDim, where A = Wᵀ + * Concurrent query projection and caller CPU budgeting are described in + * {@code docs/ash-query-integration.md}. * The transform is applied as: * y = A · (x − μ) */ @@ -1274,27 +1278,87 @@ public static final class StiefelTransform { public final float[][] AFloat; // Prepared once per immutable model, including models loaded from disk. - private final io.github.jbellis.jvector.vector.VectorUtilSupport.AshProjection projection; + private final VectorUtilSupport.AshProjection projection; // Prepared only when this model is used for queries. Encoding-only work // must not allocate an extra matrix or change its arithmetic precision. - private volatile io.github.jbellis.jvector.vector.VectorUtilSupport.AshProjection queryProjection; + private volatile VectorUtilSupport.AshProjection queryProjection; private long queryProjectionHeapBytes; private final long heapBytes; + // Default group capacity. Larger configured groups are supported by + // grow-once caller scratch; native code controls its own micro-tiling. + private static final int QUERY_BATCH_SIZE = 8; + // Published together with queryProjection. Training/encoding never + // initializes query-only dispatch or its retained storage. + private CallerBatchProcessor queryBatches; + private final int queryBatchSize; + private static final class QueryBatchRows { + float[][] input = new float[QUERY_BATCH_SIZE][]; + float[][] output = new float[QUERY_BATCH_SIZE][]; + void ensure(int count) { + if (input.length >= count) return; + float[][] nextInput = new float[count][]; + float[][] nextOutput = new float[count][]; + input = nextInput; + output = nextOutput; + } + } + // Scratch holds row references only during a batch and never a model. + private static final ThreadLocal QUERY_BATCH_ROWS = + ThreadLocal.withInitial(QueryBatchRows::new); + /** Projects a search query; encoding and training continue through project(). */ public void projectQuery(float[] query, float[] out) { - queryProjection().project(query, out); + var projection = queryProjection(); // Acquire the matching dispatcher too. + var batches = queryBatches; + if (batches == null || !projection.supportsBatchProjection()) projection.project(query, out); + else batches.process(query, out); + } + + /** Snapshot of completed/claimed batch counts, or null when combining is disabled. */ + public long[] queryProjectionBatchHistogram() { + var projection = queryProjection; // Inspection must not prepare a query view. + return projection == null || queryBatches == null ? null : queryBatches.batchHistogram(); } - private io.github.jbellis.jvector.vector.VectorUtilSupport.AshProjection queryProjection() { + private VectorUtilSupport.AshProjection queryProjection() { var ready = queryProjection; if (ready == null) { synchronized (this) { ready = queryProjection; if (ready == null) { ready = projection.forQueryScoring(); - queryProjectionHeapBytes = ready == projection ? 0 : RamUsageEstimator.shallowSizeOf(ready); + long retained = ready == projection ? 0 : RamUsageEstimator.shallowSizeOf(ready); + if (queryBatchSize > 1 && ready.supportsBatchProjection()) { + final var target = ready; + var operation = new CallerBatchProcessor.Operation() { + @Override + public void apply(float[] input, float[] output) { target.project(input, output); } + @Override + public void applyBatch(List inputs, List outputs) { + QueryBatchRows rows = QUERY_BATCH_ROWS.get(); + int count = inputs.size(); + rows.ensure(count); + try { + for (int i = 0; i < count; i++) { + rows.input[i] = inputs.get(i); + rows.output[i] = outputs.get(i); + } + target.projectBatch(rows.input, rows.output, count); + } finally { + Arrays.fill(rows.input, 0, count, null); + Arrays.fill(rows.output, 0, count, null); + } + } + }; + int budget = Math.max(1, Math.min(Runtime.getRuntime().availableProcessors(), + PhysicalCoreExecutor.getPhysicalCoreCount())); + queryBatches = CallerBatchProcessor.opportunistic( + budget, queryBatchSize, operation); + retained += queryBatches.ramBytesUsed() + RamUsageEstimator.shallowSizeOf(operation); + } + queryProjectionHeapBytes = retained; queryProjection = ready; // Publish the representation and its heap charge together. } } @@ -1320,6 +1384,13 @@ public void projectBatch(float[][] vectors, float[][] out, int count) { public StiefelTransform(RealMatrix W) { this.W = Objects.requireNonNull(W, "W"); + String batchSize = System.getProperty("jvector.ash.queryBatchSize", "8"); + try { queryBatchSize = Integer.parseInt(batchSize); } + catch (NumberFormatException invalid) { + throw new IllegalArgumentException("jvector.ash.queryBatchSize must be a positive integer: " + batchSize, invalid); + } + if (queryBatchSize < 1 || queryBatchSize == Integer.MAX_VALUE) + throw new IllegalArgumentException("jvector.ash.queryBatchSize must be between 1 and " + (Integer.MAX_VALUE - 1)); // Dimensions: W is [D × d] final int originalDim = W.getRowDimension(); // D @@ -2648,8 +2719,9 @@ public double reconstructionError(VectorFloat vector) { public long ramBytesUsed() { long bytes = heapBytes + stiefelTransform.projection.ramBytesUsed(); var queryProjection = stiefelTransform.queryProjection; - if (queryProjection != null && queryProjection != stiefelTransform.projection) { - bytes += stiefelTransform.queryProjectionHeapBytes + queryProjection.ramBytesUsed(); + if (queryProjection != null) { + bytes += stiefelTransform.queryProjectionHeapBytes; + if (queryProjection != stiefelTransform.projection) bytes += queryProjection.ramBytesUsed(); } return bytes; } diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/util/CallerBatchProcessor.java b/jvector-base/src/main/java/io/github/jbellis/jvector/util/CallerBatchProcessor.java new file mode 100644 index 000000000..e0bc62351 --- /dev/null +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/util/CallerBatchProcessor.java @@ -0,0 +1,472 @@ +/* + * 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.util; + +import java.lang.invoke.MethodHandles; +import java.lang.invoke.VarHandle; +import java.util.AbstractList; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; +import java.util.concurrent.locks.LockSupport; + +/** + * Synchronously combines work from concurrent callers, using only their threads. + * The strict constructor permits at most {@code maxLeaders} operations at once; + * {@link #opportunistic} additionally supplies a direct pass-through budget. + * An available + * leader starts immediately; it never waits for a batch to fill. Waiting requests + * are taken in FIFO order by a caller that acquires free capacity. Capacity is + * never reserved for a parked caller. Reused request records are published + * without a blocking queue lock; only batch removal needs brief exclusion. + * + *

    Input and output belong to the submitting caller but must be accessible to + * other threads until {@link #process} returns. The operation must not retain + * them or reenter this processor. A batch finishes before any of its callers + * returns; subsequent application work is entirely outside this processor. + * Interrupts are preserved, but do not abandon an input that another caller may + * already be using. A failed operation fails its batch and still hands off work. + * + *

    Request slots, batch lists and callbacks are reused. This class creates no + * executor, worker, completion future, timer, or per-operation request object. + * A slot is allocated on each participating thread's first use of this instance. + */ +public final class CallerBatchProcessor implements Accountable { + /** An operation that writes caller-owned outputs before returning. */ + public interface Operation { + /** Handles a single input without wrapping it in a batch. */ + void apply(I input, O output) throws Exception; + + /** Borrowed read-only lists; neither the lists nor their rows may escape. */ + void applyBatch(List inputs, List outputs) throws Exception; + } + + private static final int WAITING = 0, ASSIGNED = 1, DONE = 2; + private static final int CLAIM_BUSY = 0, CLAIM_SKIPPED = 1, CLAIM_READY = 2; + private static final VarHandle INCOMING, DRAINING; + static { + try { + var lookup = MethodHandles.lookup(); + INCOMING = lookup.findVarHandle(CallerBatchProcessor.class, "incoming", Request.class); + DRAINING = lookup.findVarHandle(CallerBatchProcessor.class, "draining", int.class); + } catch (ReflectiveOperationException failure) { + throw new ExceptionInInitializerError(failure); + } + } + private final int maxBatchSize; + private final Operation operation; + private final Context[] contexts; + private final DirectSlot[] directSlots; + private final ThreadLocal> local = ThreadLocal.withInitial(Request::new); + // Producers publish their reusable records with one successful CAS. A + // consumer reverses a detached chain once, preserving FIFO publication order. + // ready and incoming are disjoint; only the drain owner modifies ready. + private volatile Request incoming, ready; + private volatile int draining; + private final long retainedBytes; + + /** + * @param maxLeaders maximum concurrent operations, without creating threads + * @param maxBatchSize maximum existing requests claimed by one leader + * @param operation computation; all its methods must support concurrent calls + */ + public CallerBatchProcessor(int maxLeaders, int maxBatchSize, Operation operation) { + this(maxLeaders, maxBatchSize, 0, operation); + } + + /** + * Creates an opportunistic processor for a caller-owned CPU budget. Up to + * {@code parallelism} overlapping calls pass through directly while no work + * is queued or executing as a multi-input batch. Only overlap beyond that budget starts + * combining; it does not throttle ordinary calls to the combiner limit. + * + *

    The fixed internal limit is one combiner per four budgeted CPUs, with + * a minimum of one. No thread is created. Direct calls are outside that + * combiner limit; total concurrent operations cannot exceed the direct budget + * plus the combiner limit. Emptying the queue and completing its multi-input + * batches restores + * the direct path. An overflowing singleton never suppresses free direct + * capacity on its own. There are no timing thresholds, + * learned decisions, dataset-dependent rules or batch-fill delays. + * + * @param parallelism CPU capacity available to the callers, positive + * @param maxBatchSize maximum ready inputs passed to one batch operation + * @param operation operation whose methods support concurrent calls + */ + public static CallerBatchProcessor opportunistic( + int parallelism, int maxBatchSize, Operation operation) { + if (parallelism < 1) throw new IllegalArgumentException("Parallelism must be positive"); + return new CallerBatchProcessor<>(Math.max(1, parallelism / 4), maxBatchSize, parallelism, operation); + } + + @SuppressWarnings("unchecked") + private CallerBatchProcessor(int maxLeaders, int maxBatchSize, int directParallelism, + Operation operation) { + if (maxLeaders < 1 || maxBatchSize < 1 || maxBatchSize == Integer.MAX_VALUE) + throw new IllegalArgumentException("Leader and batch limits must be positive"); + this.maxBatchSize = maxBatchSize; + this.operation = Objects.requireNonNull(operation); + directSlots = new DirectSlot[directParallelism]; + for (int i = 0; i < directSlots.length; i++) directSlots[i] = new DirectSlot(i); + contexts = (Context[]) new Context[maxLeaders]; + for (int i = 0; i < maxLeaders; i++) contexts[i] = new Context<>(maxBatchSize, i); + long bytes = RamUsageEstimator.shallowSizeOf(this) + + RamUsageEstimator.shallowSizeOf(local) + RamUsageEstimator.shallowSizeOf(contexts) + + RamUsageEstimator.shallowSizeOf(directSlots); + for (DirectSlot slot : directSlots) bytes = Math.addExact(bytes, RamUsageEstimator.shallowSizeOf(slot)); + for (Context context : contexts) { + bytes = Math.addExact(bytes, RamUsageEstimator.shallowSizeOf(context) + + listBytes(context.requests, maxBatchSize) + + RamUsageEstimator.shallowSizeOf(context.inputs) + + RamUsageEstimator.shallowSizeOf(context.outputs) + + RamUsageEstimator.shallowSizeOf(context.histogram)); + } + retainedBytes = bytes; + } + + private static long listBytes(ArrayList list, int capacity) { + return RamUsageEstimator.shallowSizeOf(list) + + RamUsageEstimator.alignObjectSize(RamUsageEstimator.NUM_BYTES_ARRAY_HEADER + + (long) capacity * RamUsageEstimator.NUM_BYTES_OBJECT_REF); + } + + /** Owned coordinator storage; the operation and caller-owned request slots are excluded. */ + @Override + public long ramBytesUsed() { return retainedBytes; } + + private static final class Request { + final Thread thread = Thread.currentThread(); + volatile int state; + volatile boolean parked; + boolean inUse; + int preferred = (int) (thread.getId() ^ (thread.getId() >>> 32)) & Integer.MAX_VALUE; + int preferredDirect = preferred; + I input; + O output; + Throwable failure; + Request next; + } + + private static final class DirectSlot { + private static final VarHandle CLAIMED, COUNT; + static { + try { + var lookup = MethodHandles.lookup(); + CLAIMED = lookup.findVarHandle(DirectSlot.class, "claimed", int.class); + COUNT = lookup.findVarHandle(DirectSlot.class, "calls", long.class); + } catch (ReflectiveOperationException failure) { + throw new ExceptionInInitializerError(failure); + } + } + final int index; + volatile int claimed; + private long calls; + DirectSlot(int index) { this.index = index; } + void record() { COUNT.setOpaque(this, (long) COUNT.getOpaque(this) + 1); } + } + + // Static slots/contexts do not retain the processor (and its model) in an + // idle thread's ThreadLocal value after the application releases the model. + private static final class Context { + private static final VarHandle CLAIMED; + private static final VarHandle COUNT = MethodHandles.arrayElementVarHandle(long[].class); + static { + try { + CLAIMED = MethodHandles.lookup().findVarHandle(Context.class, "claimed", int.class); + } catch (ReflectiveOperationException failure) { + throw new ExceptionInInitializerError(failure); + } + } + volatile int claimed; + final int slot; + final long[] histogram; + final ArrayList> requests; + final List inputs; + final List outputs; + Context(int capacity, int slot) { + this.slot = slot; + histogram = new long[capacity + 1]; + requests = new ArrayList<>(capacity); + inputs = new AbstractList<>() { + public int size() { return requests.size(); } + public I get(int i) { return requests.get(i).input; } + }; + outputs = new AbstractList<>() { + public int size() { return requests.size(); } + public O get(int i) { return requests.get(i).output; } + }; + } + + // Only the owning leader writes these counts. Opaque access makes the + // diagnostic snapshot non-tearing without a shared atomic increment. + void record(int size) { + COUNT.setOpaque(histogram, size, (long) COUNT.getOpaque(histogram, size) + 1); + } + } + + /** Executes synchronously, preserving interruption and caller buffer lifetime. */ + public void process(I input, O output) { + Objects.requireNonNull(input); Objects.requireNonNull(output); + Request request = local.get(); + if (request.inUse) throw new IllegalStateException("Reentrant batch processing is unsupported"); + request.inUse = true; + boolean interrupted = Thread.interrupted(); + try { + if (directSlots.length != 0 && !hasPending() && !isBatching()) { + DirectSlot slot = acquireDirect(request.preferredDirect % directSlots.length); + if (slot != null) { + try { + // Honor older requests published while capacity was acquired. + // A singleton overflow must not suppress free direct + // capacity. Real batches retain sharing until completion. + if (!hasPending() && !isBatching()) { + request.preferredDirect = slot.index; + slot.record(); + operation.apply(input, output); + return; + } + } finally { DirectSlot.CLAIMED.setVolatile(slot, 0); } + } + } + // An idle caller executes directly. Only saturated callers enter the + // FIFO; no monitor, list mutation or request publication is needed. + if (!hasPending()) { + Context context = acquire(request.preferred % contexts.length); + if (context != null) { + if (!hasPending()) { + request.preferred = context.slot; + context.record(1); + try { operation.apply(input, output); } + finally { release(context); } + return; + } + // A waiter arrived while we acquired capacity; give it priority. + release(context); + } + } + request.input = input; + request.output = output; + request.failure = null; + request.state = WAITING; + publish(request); + for (;;) { + int state = request.state; // Acquire publishes outputs and failures. + if (state == DONE) break; + if (state == WAITING) { + Context context = acquire(request.preferred % contexts.length); + if (context != null) { + request.preferred = context.slot; + int claim = take(context, request); + if (claim == CLAIM_READY) { run(context); continue; } + if (claim == CLAIM_SKIPPED) { release(context); continue; } + // The drain owner will signal pending work after release. + // Publish this slot first, then recheck the drain gate to + // cover an unlock that observed the slot still occupied. + free(context); + if ((int) DRAINING.getVolatile(this) == 0) continue; + } + } + request.parked = true; + // Completion publishes DONE before checking parked. Rechecking + // after this volatile store ensures an early completion either + // prevents parking or supplies the unpark permit. + int latest = request.state; + if (latest != DONE && (latest != WAITING + || (int) DRAINING.getVolatile(this) != 0 || !hasFreeCapacity())) { + LockSupport.park(this); + if (Thread.interrupted()) interrupted = true; + } + request.parked = false; + } + if (request.failure instanceof Error) throw (Error) request.failure; + if (request.failure instanceof RuntimeException) throw (RuntimeException) request.failure; + if (request.failure != null) throw new RuntimeException("Batch operation failed", request.failure); + } catch (RuntimeException | Error failure) { + throw failure; + } catch (Exception failure) { + if (failure instanceof InterruptedException) interrupted = true; + throw new RuntimeException("Batch operation failed", failure); + } finally { + request.input = null; + request.output = null; + request.failure = null; + request.parked = false; + request.inUse = false; + if (interrupted) Thread.currentThread().interrupt(); + } + } + + private DirectSlot acquireDirect(int preferred) { + int index = preferred; + do { + DirectSlot slot = directSlots[index]; + if ((int) DirectSlot.CLAIMED.getVolatile(slot) == 0 + && DirectSlot.CLAIMED.compareAndSet(slot, 0, 1)) return slot; + if (++index == directSlots.length) index = 0; + } while (index != preferred); + return null; + } + + private boolean isBatching() { + for (Context context : contexts) + if ((int) Context.CLAIMED.getVolatile(context) > 1) return true; + return false; + } + + // Each slot has its own admission word. Callers start at their last slot, + // avoiding a global CAS counter and repeated competition for the first slot. + private Context acquire(int preferred) { + int slot = preferred; + do { + Context context = contexts[slot]; + if ((int) Context.CLAIMED.getVolatile(context) == 0 + && Context.CLAIMED.compareAndSet(context, 0, 1)) return context; + if (++slot == contexts.length) slot = 0; + } while (slot != preferred); + return null; + } + + private void free(Context context) { + // Volatile publication orders the subsequent waiter check after freeing + // capacity, closing the enqueue-versus-release lost-wakeup race. + Context.CLAIMED.setVolatile(context, 0); + } + + private boolean hasFreeCapacity() { + for (Context context : contexts) + if ((int) Context.CLAIMED.getVolatile(context) == 0) return true; + return false; + } + + private boolean hasPending() { return ready != null || incoming != null; } + + private void publish(Request request) { + Request previous; + do { + previous = incoming; + request.next = previous; + } while (!INCOMING.compareAndSet(this, previous, request)); + } + + private void signalPending() { + Request next = ready; + if (next == null) next = incoming; + // Any pending caller can act as combiner; take() still serves FIFO. + // A concurrent completion can make this a harmless spurious wakeup. + if (next != null) LockSupport.unpark(next.thread); + } + + private void release(Context context) { + free(context); + if (hasPending()) signalPending(); + } + + private void releaseDrain() { + // A failed drainer frees its slot before checking this publication. The + // two checks ensure it either retries or receives a wakeup, never sleeps + // with free capacity and no owner responsible for further progress. + DRAINING.setVolatile(this, 0); + if (hasPending() && hasFreeCapacity()) signalPending(); + } + + @SuppressWarnings("unchecked") + private int take(Context context, Request owner) { + if (!DRAINING.compareAndSet(this, 0, 1)) return CLAIM_BUSY; + try { + if (owner.state != WAITING) return CLAIM_SKIPPED; + Request first = ready; + if (first == null) { + Request stack = (Request) INCOMING.getAndSet(this, null); + while (stack != null) { + Request next = stack.next; + stack.next = first; + first = stack; + stack = next; + } + } + if (first == null) return CLAIM_SKIPPED; + do { + Request next = first.next; + first.next = null; + first.state = ASSIGNED; + context.requests.add(first); + first = next; + } while (first != null && context.requests.size() < maxBatchSize); + ready = first; + return CLAIM_READY; + } finally { releaseDrain(); } + } + + private void run(Context context) { + int size = context.requests.size(); + // Admission distinguishes a true shared computation from a singleton + // overflow. Zero is free; one is reserved/singleton; larger values are + // executing batch widths. All occupied states retain the same limit. + if (size > 1) Context.CLAIMED.setVolatile(context, size); + context.record(size); + Throwable failure = null; + try { + if (context.requests.size() == 1) { + Request only = context.requests.get(0); + operation.apply(only.input, only.output); + } else operation.applyBatch(context.inputs, context.outputs); + } catch (Throwable error) { + failure = error; + if (error instanceof InterruptedException) Thread.currentThread().interrupt(); + } finally { + // This context is exclusively owned until release. Publish results + // and wake callers without holding the drain gate. + for (int i = 0; i < context.requests.size(); i++) { + Request request = context.requests.get(i); + request.failure = failure; + request.state = DONE; // Release before followers access their output. + if (request.parked) LockSupport.unpark(request.thread); + } + context.requests.clear(); + release(context); + } + } + + /** + * Diagnostic snapshot: index is batch size, value is batch count. Concurrent + * calls may observe an in-progress operation; totals are exact when idle. + */ + public long[] batchHistogram() { + long[] snapshot = new long[maxBatchSize + 1]; + for (DirectSlot slot : directSlots) snapshot[1] += (long) DirectSlot.COUNT.getOpaque(slot); + for (Context context : contexts) + for (int i = 1; i < snapshot.length; i++) + snapshot[i] += (long) Context.COUNT.getOpaque(context.histogram, i); + return snapshot; + } + + /** + * Diagnostic snapshot of waiting requests, excluding executing batches. The + * short drain exclusion is used only by this explicit inspection method. + */ + public int waitingCount() { + while (!DRAINING.compareAndSet(this, 0, 1)) Thread.yield(); + try { + int count = 0; + for (Request r = ready; r != null; r = r.next) count = Math.incrementExact(count); + // Producers can prepend records, but cannot change this chain while + // the drain gate is held. This is a snapshot at the incoming read. + for (Request r = incoming; r != null; r = r.next) count = Math.incrementExact(count); + return count; + } finally { releaseDrain(); } + } +} diff --git a/jvector-native/src/main/native/README.md b/jvector-native/src/main/native/README.md index 25df76149..a2dd0e8c3 100644 --- a/jvector-native/src/main/native/README.md +++ b/jvector-native/src/main/native/README.md @@ -954,3 +954,17 @@ its timers and compare recall@10 as well as QPS. Control CPU affinity and NUMA memory placement consistently across runs; otherwise projection locality can obscure the precision comparison. Do not include compilation or another active benchmark in the query measurement interval. + + +### Concurrent query integration + +ASH can combine independent query projections into true FP16 oneDNN matrix +multiplication while retaining the direct singleton path. Applications continue +to issue synchronous searches; no query executor or nested native thread pool +is created. Base-vector encoding and training remain FP32. + +Database developers should follow [ASH query callers and CPU budgeting](../../../../docs/ash-query-integration.md) +for caller concurrency, bounded admission, resource ownership and latency +validation. The [maintainer design](../../../../docs/ash-query-batching-design.md) +describes coordination separately. Native batch entry points accept counts above +eight; internal micro-tiling does not restrict their API. diff --git a/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestASHMemoryAccounting.java b/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestASHMemoryAccounting.java index 31989fa51..ef05658e5 100644 --- a/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestASHMemoryAccounting.java +++ b/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestASHMemoryAccounting.java @@ -179,6 +179,7 @@ public void queryRepresentationsAreChargedOnlyAfterCreationAndOnlyOnce() throws var ash = model(2, 1); long before = ash.ramBytesUsed(); assertNull(field(ash.stiefelTransform, "queryProjection")); + assertNull(field(ash.stiefelTransform, "queryBatches")); float[] input = new float[64], output = new float[33]; ash.stiefelTransform.projectQuery(input, output); var primary = (io.github.jbellis.jvector.vector.VectorUtilSupport.AshProjection) @@ -190,7 +191,11 @@ public void queryRepresentationsAreChargedOnlyAfterCreationAndOnlyOnce() throws assertNotSame(primary, query); assertTrue(query.ramBytesUsed() >= 64L*33*Short.BYTES); } - long added = query == primary ? 0 : RamUsageEstimator.shallowSizeOf(query)+query.ramBytesUsed(); + long added = (long) field(ash.stiefelTransform, "queryProjectionHeapBytes") + + (query == primary ? 0 : query.ramBytesUsed()); + var batches = field(ash.stiefelTransform, "queryBatches"); + if (query.supportsBatchProjection()) assertNotNull(batches); + else assertNull(batches); assertEquals(before+added, ash.ramBytesUsed()); ash.stiefelTransform.projectQuery(input, output); assertEquals(before+added, ash.ramBytesUsed()); @@ -305,6 +310,97 @@ public void fusedAshSourcesChargeTheirOwnedVectorStorage() throws Exception { } + + @Test + public void queryBatchConfigurationIsValidatedAndDoesNotChangeEncodingOnlyMemory() throws Exception { + String property = "jvector.ash.queryBatchSize"; + String previous = System.getProperty(property); + try { + for (String invalid : new String[]{"", "invalid", "0", "-1", "2147483647", "2147483648"}) { + System.setProperty(property, invalid); + assertThrows(IllegalArgumentException.class, () -> + new AsymmetricHashing.StiefelTransform(new Array2DRowRealMatrix(4, 2))); + } + long encodingBytes = -1; + for (int capacity : new int[]{1,8,17}) { + System.setProperty(property, Integer.toString(capacity)); + var transform = new AsymmetricHashing.StiefelTransform(new Array2DRowRealMatrix(4, 2)); + long before = (long) field(transform, "heapBytes"); + if (encodingBytes < 0) encodingBytes = before; + assertEquals("No query dispatcher is allocated by construction", encodingBytes, before); + transform.project(new float[4], new float[2]); + transform.projectBatch(new float[2][4], new float[2][2], 2); + assertEquals(before, (long) field(transform, "heapBytes")); + assertNull(field(transform, "queryProjection")); + assertNull(field(transform, "queryBatches")); + assertNull(transform.queryProjectionBatchHistogram()); + transform.projectQuery(new float[4], new float[2]); + if (capacity == 1) { + assertNull(field(transform, "queryBatches")); + assertNull(transform.queryProjectionBatchHistogram()); + } + } + System.clearProperty(property); + var defaults = new AsymmetricHashing.StiefelTransform(new Array2DRowRealMatrix(4, 2)); + assertEquals(8, field(defaults, "queryBatchSize")); + } finally { + if (previous == null) System.clearProperty(property); else System.setProperty(property, previous); + } + } + + @Test + public void callerLedProjectionPreservesResultsAndDoesNotChangeEncodingPrecision() throws Exception { + String property = "jvector.ash.queryBatchSize"; + String old = System.getProperty(property); + try { + for (int batchSize : new int[]{2,8,17}) { + System.setProperty(property, Integer.toString(batchSize)); + var ash = model(4, 1); + float[][] inputs = new float[8][64], expected = new float[8][33]; + Random random = new Random(78342); + for (int i = 0; i < inputs.length; i++) { + for (int j = 0; j < inputs[i].length; j++) inputs[i][j] = random.nextFloat() - .5f; + ash.stiefelTransform.projectQuery(inputs[i], expected[i]); + } + var pool = Executors.newFixedThreadPool(8); + try { + var jobs = new ArrayList>(); + for (int i = 0; i < 8; i++) { + final int index = i; + jobs.add(() -> { + float[] actual = new float[33]; + for (int repeat = 0; repeat < 50; repeat++) { + ash.stiefelTransform.projectQuery(inputs[index], actual); + assertArrayEquals(expected[index], actual, 2e-6f); + } + return null; + }); + } + for (var result : pool.invokeAll(jobs)) result.get(); + } finally { + pool.shutdownNow(); + assertTrue(pool.awaitTermination(30, java.util.concurrent.TimeUnit.SECONDS)); + } + long projected = 0; + long[] histogram = ash.stiefelTransform.queryProjectionBatchHistogram(); + if (histogram != null) { + for (int i = 1; i < histogram.length; i++) projected += i * histogram[i]; + assertEquals(408, projected); + } + float[] encodedProjection = new float[33]; + ash.stiefelTransform.project(inputs[0], encodedProjection); + for (int row = 0; row < 33; row++) { + double value = 0; + for (int col = 0; col < 64; col++) + value += (double) ash.stiefelTransform.AFloat[row][col] * inputs[0][col]; + assertEquals(value, encodedProjection[row], 2e-6); + } + } + } finally { + if (old == null) System.clearProperty(property); else System.setProperty(property, old); + } + } + @Test public void repeatedModelAndVectorAccountingDoesNotAllocate() throws Exception { java.lang.management.ThreadMXBean threadBean = java.lang.management.ManagementFactory.getThreadMXBean(); diff --git a/jvector-tests/src/test/java/io/github/jbellis/jvector/util/TestCallerBatchProcessor.java b/jvector-tests/src/test/java/io/github/jbellis/jvector/util/TestCallerBatchProcessor.java new file mode 100644 index 000000000..bc3b20f59 --- /dev/null +++ b/jvector-tests/src/test/java/io/github/jbellis/jvector/util/TestCallerBatchProcessor.java @@ -0,0 +1,530 @@ +/* + * 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.util; + +import org.junit.Test; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.*; +import java.util.concurrent.atomic.*; +import static org.junit.Assert.*; + +public class TestCallerBatchProcessor { + private static void awaitWaiting(CallerBatchProcessor processor, int expected) { + long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5); + while (processor.waitingCount() != expected && System.nanoTime() < deadline) Thread.yield(); + assertEquals(expected, processor.waitingCount()); + } + + private abstract static class IntegerOperation implements CallerBatchProcessor.Operation { + public void applyBatch(List input, List output) throws Exception { + for (int i = 0; i < input.size(); i++) apply(input.get(i), output.get(i)); + } + } + + @Test(timeout=10000) + public void loneCallsRunImmediatelyOnTheCallerAtBothBatchLimits() { + for (int limit : new int[]{1,8}) { + Thread owner = Thread.currentThread(); + var processor = new CallerBatchProcessor<>(2, limit, new IntegerOperation() { + public void apply(Integer value, int[] output) { + assertSame(owner, Thread.currentThread()); + output[0] = value; + } + }); + int[] out = new int[1]; + for (int i = 0; i < 100; i++) { processor.process(i, out); assertEquals(i, out[0]); } + assertEquals(100, processor.batchHistogram()[1]); + assertEquals(0, processor.waitingCount()); + } + } + + @Test(timeout=15000) + public void concurrentLeadersCombineOnlyExistingFollowersAndPublishOwnedOutputs() throws Exception { + var entered = new CountDownLatch(2); + var release = new CountDownLatch(1); + var active = new AtomicInteger(); + var maximum = new AtomicInteger(); + var processor = new CallerBatchProcessor<>(2, 3, new CallerBatchProcessor.Operation() { + private void begin() { maximum.accumulateAndGet(active.incrementAndGet(), Math::max); } + public void apply(Integer value, int[] out) throws Exception { + begin(); + try { if (value < 2) { entered.countDown(); release.await(); } out[0] = value * 7; } + finally { active.decrementAndGet(); } + } + public void applyBatch(List input, List output) { + begin(); + try { + assertTrue(input.size() <= 3); + for (int i = 0; i < input.size(); i++) output.get(i)[0] = input.get(i) * 7; + try { input.clear(); fail("Batch input view must be read-only"); } + catch (UnsupportedOperationException expected) { } + } finally { active.decrementAndGet(); } + } + }); + var executor = Executors.newFixedThreadPool(9); + try { + var futures = new ArrayList>(); + for (int i = 0; i < 2; i++) { + final int value = i; + futures.add(executor.submit(() -> { int[] out = new int[1]; processor.process(value, out); assertEquals(value * 7, out[0]); })); + } + assertTrue(entered.await(5, TimeUnit.SECONDS)); + assertEquals(2, maximum.get()); + for (int i = 2; i < 9; i++) { + final int value = i; + futures.add(executor.submit(() -> { + Thread original = Thread.currentThread(); + int[] out = new int[1]; processor.process(value, out); + assertSame(original, Thread.currentThread()); + assertEquals(value * 7, out[0]); + })); + } + awaitWaiting(processor, 7); + release.countDown(); + for (var future : futures) future.get(5, TimeUnit.SECONDS); + long[] histogram = processor.batchHistogram(); + assertEquals(2, maximum.get()); + assertTrue(histogram[2] + histogram[3] > 0); + assertEquals(9, histogram[1] + 2 * histogram[2] + 3 * histogram[3]); + } finally { release.countDown(); executor.shutdownNow(); } + } + + @Test(timeout=20000) + public void repeatedSlotReuseDoesNotLoseOrMisrouteRequests() throws Exception { + var processor = new CallerBatchProcessor<>(2, 8, new IntegerOperation() { + public void apply(Integer value, int[] out) { Thread.yield(); out[0] = value; } + }); + var executor = Executors.newFixedThreadPool(16); + var start = new CountDownLatch(1); + try { + var futures = new ArrayList>(); + for (int thread = 0; thread < 16; thread++) { + final int prefix = thread * 1000; + futures.add(executor.submit(() -> { + start.await(); + int[] output = new int[1]; + for (int i = 0; i < 500; i++) { + processor.process(prefix + i, output); + assertEquals(prefix + i, output[0]); + } + return null; + })); + } + start.countDown(); + for (var future : futures) future.get(10, TimeUnit.SECONDS); + long[] histogram = processor.batchHistogram(); + long requests = 0; + for (int i = 1; i < histogram.length; i++) requests += i * histogram[i]; + assertEquals(8000, requests); + assertEquals(0, processor.waitingCount()); + } finally { start.countDown(); executor.shutdownNow(); } + } + + @Test(timeout=15000) + public void batchFailureIsPublishedAndDoesNotStrandTheNextLeader() throws Exception { + var entered = new CountDownLatch(1); + var release = new CountDownLatch(1); + var processor = new CallerBatchProcessor<>(1, 2, new IntegerOperation() { + public void apply(Integer value, int[] out) throws Exception { + if (value == 0) { entered.countDown(); release.await(); } + if (value == 2) throw new IllegalArgumentException("batch failure"); + out[0] = value; + } + }); + var executor = Executors.newFixedThreadPool(4); + try { + var first = executor.submit(() -> processor.process(0, new int[1])); + assertTrue(entered.await(5, TimeUnit.SECONDS)); + var one = executor.submit(() -> processor.process(1, new int[1])); + awaitWaiting(processor, 1); + var two = executor.submit(() -> processor.process(2, new int[1])); + awaitWaiting(processor, 2); + var three = executor.submit(() -> { int[] out = new int[1]; processor.process(3, out); return out[0]; }); + awaitWaiting(processor, 3); + release.countDown(); first.get(5, TimeUnit.SECONDS); + for (var failed : List.of(one, two)) { + try { failed.get(5, TimeUnit.SECONDS); fail("Missing batch failure"); } + catch (ExecutionException expected) { assertTrue(expected.getCause() instanceof IllegalArgumentException); } + } + assertEquals(Integer.valueOf(3), three.get(5, TimeUnit.SECONDS)); + } finally { release.countDown(); executor.shutdownNow(); } + } + + @Test(timeout=15000) + public void interruptedFollowerKeepsItsBuffersAliveUntilCompletion() throws Exception { + var entered = new CountDownLatch(1); + var release = new CountDownLatch(1); + var completed = new CountDownLatch(1); + var interrupted = new AtomicBoolean(); + var processor = new CallerBatchProcessor<>(1, 8, new IntegerOperation() { + public void apply(Integer value, int[] out) throws Exception { + if (value == 0) { entered.countDown(); release.await(); } + out[0] = value; + } + }); + var executor = Executors.newSingleThreadExecutor(); + Thread follower = new Thread(() -> { + int[] output = new int[1]; processor.process(17, output); + assertEquals(17, output[0]); + interrupted.set(Thread.currentThread().isInterrupted()); completed.countDown(); + }); + try { + var leader = executor.submit(() -> processor.process(0, new int[1])); + assertTrue(entered.await(5, TimeUnit.SECONDS)); + follower.start(); awaitWaiting(processor, 1); follower.interrupt(); + assertFalse(completed.await(50, TimeUnit.MILLISECONDS)); + release.countDown(); leader.get(5, TimeUnit.SECONDS); + assertTrue(completed.await(5, TimeUnit.SECONDS)); assertTrue(interrupted.get()); + } finally { release.countDown(); follower.join(5000); executor.shutdownNow(); } + } + + @Test(timeout=10000) + public void reentrantOperationFailsWithoutLeakingLeaderCapacity() { + var reference = new AtomicReference>(); + var processor = new CallerBatchProcessor<>(1, 8, new IntegerOperation() { + public void apply(Integer value, int[] out) { + if (value == 0) reference.get().process(1, out); + out[0] = value; + } + }); + reference.set(processor); + try { processor.process(0, new int[1]); fail("Reentrant operation must fail"); } + catch (IllegalStateException expected) { } + int[] out = new int[1]; processor.process(2, out); assertEquals(2, out[0]); + } + @Test + public void repeatedSingletonDispatchDoesNotAllocateRequestObjects() { + var threadBean = java.lang.management.ManagementFactory.getThreadMXBean(); + org.junit.Assume.assumeTrue(threadBean instanceof com.sun.management.ThreadMXBean); + var bean = (com.sun.management.ThreadMXBean) threadBean; + org.junit.Assume.assumeTrue(bean.isThreadAllocatedMemorySupported()); + if (!bean.isThreadAllocatedMemoryEnabled()) bean.setThreadAllocatedMemoryEnabled(true); + var processor = CallerBatchProcessor.opportunistic(8, 8, new IntegerOperation() { + public void apply(Integer value, int[] output) { output[0] = value; } + }); + int[] output = new int[1]; + for (int i = 0; i < 50000; i++) processor.process(1, output); + long thread = Thread.currentThread().getId(); + long before = bean.getThreadAllocatedBytes(thread); + for (int i = 0; i < 10000; i++) processor.process(1, output); + long allocated = bean.getThreadAllocatedBytes(thread) - before; + assertEquals(0, allocated); + assertEquals(1, output[0]); + } + + @Test(timeout=15000) + public void concurrentHistogramSnapshotsDoNotChangeDispatchOrFinalCounts() throws Exception { + // Also exercises leader counts above a machine-word bitmap's capacity. + var processor = new CallerBatchProcessor<>(65, 8, new IntegerOperation() { + public void apply(Integer value, int[] output) { output[0] = value; } + }); + var pool = Executors.newFixedThreadPool(8); + var stop = new AtomicBoolean(); + var observer = new Thread(() -> { + while (!stop.get()) { + processor.batchHistogram(); + Thread.yield(); + } + }); + observer.start(); + try { + var work = new ArrayList>(); + for (int worker = 0; worker < 8; worker++) work.add(() -> { + int[] out = new int[1]; + for (int i = 0; i < 1000; i++) { + processor.process(1, out); + assertEquals(1, out[0]); + } + return null; + }); + for (var future : pool.invokeAll(work)) future.get(); + assertEquals(8000, processor.batchHistogram()[1]); + assertEquals(0, processor.waitingCount()); + } finally { + stop.set(true); + observer.join(5000); + pool.shutdownNow(); + } + } + + + @Test(timeout=15000) + public void pendingInputsAreServedInFifoOrderRegardlessOfCombinerThread() throws Exception { + var entered = new CountDownLatch(1); + var release = new CountDownLatch(1); + var order = new ArrayList(); + var processor = new CallerBatchProcessor<>(1, 3, new IntegerOperation() { + public void apply(Integer value, int[] output) throws Exception { + if (value == 0) { entered.countDown(); release.await(); } + order.add(value); + output[0] = value; + } + }); + var pool = Executors.newFixedThreadPool(8); + try { + var work = new ArrayList>(); + work.add(pool.submit(() -> processor.process(0, new int[1]))); + assertTrue(entered.await(5, TimeUnit.SECONDS)); + for (int i = 1; i < 8; i++) { + final int value = i; + work.add(pool.submit(() -> { + int[] output = new int[1]; + processor.process(value, output); + assertEquals(value, output[0]); + })); + awaitWaiting(processor, i); + } + release.countDown(); + for (var future : work) future.get(5, TimeUnit.SECONDS); + assertEquals(List.of(0, 1, 2, 3, 4, 5, 6, 7), order); + } finally { release.countDown(); pool.shutdownNow(); } + } + + @Test(timeout=20000) + public void contendedDispatchReusesRequestsAndBatchViewsWithoutJavaAllocation() throws Exception { + var threadBean = java.lang.management.ManagementFactory.getThreadMXBean(); + org.junit.Assume.assumeTrue(threadBean instanceof com.sun.management.ThreadMXBean); + var bean = (com.sun.management.ThreadMXBean) threadBean; + org.junit.Assume.assumeTrue(bean.isThreadAllocatedMemorySupported()); + if (!bean.isThreadAllocatedMemoryEnabled()) bean.setThreadAllocatedMemoryEnabled(true); + var processor = new CallerBatchProcessor<>(1, 8, new IntegerOperation() { + public void apply(Integer value, int[] output) { + for (int i = 0; i < 100; i++) Thread.onSpinWait(); + output[0] = value; + } + }); + var pool = Executors.newFixedThreadPool(8); + try { + var work = new ArrayList>(); + for (int worker = 0; worker < 8; worker++) work.add(() -> { + int[] output = new int[1]; + for (int i = 0; i < 20000; i++) processor.process(1, output); + long tid = Thread.currentThread().getId(); + long before = bean.getThreadAllocatedBytes(tid); + for (int i = 0; i < 2000; i++) processor.process(1, output); + long bytes = bean.getThreadAllocatedBytes(tid) - before; + assertEquals(1, output[0]); + return bytes; + }); + for (var result : pool.invokeAll(work)) assertEquals(0L, result.get().longValue()); + long[] histogram = processor.batchHistogram(); + long combined = 0; + for (int size = 2; size < histogram.length; size++) combined += histogram[size]; + assertTrue("Must exercise contended batch dispatch", combined > 0); + } finally { pool.shutdownNow(); } + } + + @Test(timeout=20000) + public void completionRacingWithParkingNeverLosesTheWakeup() throws Exception { + var processor = CallerBatchProcessor.opportunistic(8, 8, new IntegerOperation() { + public void apply(Integer value, int[] output) { output[0] = value; } + }); + var pool = Executors.newFixedThreadPool(16); + var ready = new CountDownLatch(16); + var start = new CountDownLatch(1); + try { + var work = new ArrayList>(); + for (int worker = 0; worker < 16; worker++) work.add(() -> { + int[] output = new int[1]; + ready.countDown(); + start.await(); + for (int i = 0; i < 10000; i++) { + processor.process(1, output); + assertEquals(1, output[0]); + } + return null; + }); + var futures = new ArrayList>(); + for (var task : work) futures.add(pool.submit(task)); + assertTrue(ready.await(5, TimeUnit.SECONDS)); + start.countDown(); + for (var future : futures) future.get(10, TimeUnit.SECONDS); + long total = 0; + long[] histogram = processor.batchHistogram(); + for (int size = 1; size < histogram.length; size++) total += size * histogram[size]; + assertEquals(160000, total); + } finally { start.countDown(); pool.shutdownNow(); } + } + + @Test(timeout=20000) + public void opportunisticBypassUsesTheCallerBudgetAndDrainsOverflowBeforeReturningToDirect() throws Exception { + var directEntered = new CountDownLatch(4); + var directRelease = new CountDownLatch(1); + var overflowEntered = new CountDownLatch(1); + var overflowRelease = new CountDownLatch(1); + var active = new AtomicInteger(); + var maximum = new AtomicInteger(); + var batches = new AtomicInteger(); + var lastCaller = new AtomicReference(); + var processor = CallerBatchProcessor.opportunistic(4, 8, + new CallerBatchProcessor.Operation() { + public void apply(Integer value, int[] output) throws Exception { + maximum.accumulateAndGet(active.incrementAndGet(), Math::max); + try { + if (value < 4) { directEntered.countDown(); directRelease.await(); } + if (value == 4) { overflowEntered.countDown(); overflowRelease.await(); } + if (value == 13) lastCaller.set(Thread.currentThread()); + output[0] = value; + } finally { active.decrementAndGet(); } + } + public void applyBatch(List input, List output) { + maximum.accumulateAndGet(active.incrementAndGet(), Math::max); + try { + batches.incrementAndGet(); + for (int i = 0; i < input.size(); i++) output.get(i)[0] = input.get(i); + } finally { active.decrementAndGet(); } + } + }); + var pool = Executors.newFixedThreadPool(13); + try { + var work = new ArrayList>(); + for (int value = 0; value < 4; value++) { + final int v = value; + work.add(pool.submit(() -> processor.process(v, new int[1]))); + } + assertTrue(directEntered.await(5, TimeUnit.SECONDS)); + assertEquals(4, maximum.get()); // No premature serialization at the combiner limit of one. + work.add(pool.submit(() -> processor.process(4, new int[1]))); + assertTrue(overflowEntered.await(5, TimeUnit.SECONDS)); + assertEquals(5, maximum.get()); // Four direct calls plus one combining call. + for (int value = 5; value < 12; value++) { + final int v = value; + work.add(pool.submit(() -> { + int[] output = new int[1]; processor.process(v, output); assertEquals(v, output[0]); + })); + } + awaitWaiting(processor, 7); + directRelease.countDown(); + for (int k = 0; k < 4; k++) work.get(k).get(5, TimeUnit.SECONDS); + work.add(pool.submit(() -> processor.process(12, new int[1]))); + awaitWaiting(processor, 8); // Released direct slots cannot barge ahead of queued work. + overflowRelease.countDown(); + for (var result : work) result.get(5, TimeUnit.SECONDS); + assertTrue(batches.get() > 0); + assertTrue(maximum.get() <= 5); + int[] output = new int[1]; + processor.process(13, output); + assertEquals(13, output[0]); + assertSame(Thread.currentThread(), lastCaller.get()); + long total = 0; + long[] histogram = processor.batchHistogram(); + for (int b = 1; b < histogram.length; b++) total += b * histogram[b]; + assertEquals(14, total); + } finally { directRelease.countDown(); overflowRelease.countDown(); pool.shutdownNow(); } + } + + @Test(timeout=15000) + public void freeDirectCapacityDoesNotWaitForAnExecutingOverflowOperation() throws Exception { + var directEntered = new CountDownLatch(1); + var directRelease = new CountDownLatch(1); + var overflowEntered = new CountDownLatch(1); + var overflowRelease = new CountDownLatch(1); + var processor = CallerBatchProcessor.opportunistic(1, 8, new IntegerOperation() { + public void apply(Integer value, int[] output) throws Exception { + if (value == 0) { directEntered.countDown(); directRelease.await(); } + if (value == 1) { overflowEntered.countDown(); overflowRelease.await(); } + output[0] = value; + } + }); + var pool = Executors.newFixedThreadPool(3); + try { + var direct = pool.submit(() -> processor.process(0, new int[1])); + assertTrue(directEntered.await(5, TimeUnit.SECONDS)); + var overflow = pool.submit(() -> processor.process(1, new int[1])); + assertTrue(overflowEntered.await(5, TimeUnit.SECONDS)); + directRelease.countDown(); direct.get(5, TimeUnit.SECONDS); + assertEquals(0, processor.waitingCount()); + // The earlier overflow call is already executing, not waiting. It + // must not make a new call wait with an available direct slot. + var next = pool.submit(() -> { + int[] output = new int[1]; processor.process(2, output); return output[0]; + }); + assertEquals(Integer.valueOf(2), next.get(1, TimeUnit.SECONDS)); + overflowRelease.countDown(); overflow.get(5, TimeUnit.SECONDS); + assertEquals(3, processor.batchHistogram()[1]); + } finally { directRelease.countDown(); overflowRelease.countDown(); pool.shutdownNow(); } + } + + @Test(timeout=15000) + public void realBatchesRetainSharingUntilTheirBorrowedInputsComplete() throws Exception { + var directEntered = new CountDownLatch(1); + var directRelease = new CountDownLatch(1); + var overflowEntered = new CountDownLatch(1); + var overflowRelease = new CountDownLatch(1); + var batchEntered = new CountDownLatch(1); + var batchRelease = new CountDownLatch(1); + var processor = CallerBatchProcessor.opportunistic(1, 8, new IntegerOperation() { + public void apply(Integer value, int[] output) throws Exception { + if (value == 0) { directEntered.countDown(); directRelease.await(); } + if (value == 1) { overflowEntered.countDown(); overflowRelease.await(); } + output[0] = value; + } + public void applyBatch(List input, List output) throws Exception { + assertEquals(2, input.size()); + batchEntered.countDown(); batchRelease.await(); + super.applyBatch(input, output); + } + }); + var pool = Executors.newFixedThreadPool(5); + try { + var first = pool.submit(() -> processor.process(0, new int[1])); + assertTrue(directEntered.await(5, TimeUnit.SECONDS)); + var overflow = pool.submit(() -> processor.process(1, new int[1])); + assertTrue(overflowEntered.await(5, TimeUnit.SECONDS)); + var two = pool.submit(() -> processor.process(2, new int[1])); + awaitWaiting(processor, 1); + var three = pool.submit(() -> processor.process(3, new int[1])); + awaitWaiting(processor, 2); + overflowRelease.countDown(); overflow.get(5, TimeUnit.SECONDS); + assertTrue(batchEntered.await(5, TimeUnit.SECONDS)); + directRelease.countDown(); first.get(5, TimeUnit.SECONDS); + var next = pool.submit(() -> { + int[] output = new int[1]; processor.process(4, output); return output[0]; + }); + awaitWaiting(processor, 1); + batchRelease.countDown(); + two.get(5, TimeUnit.SECONDS); three.get(5, TimeUnit.SECONDS); + assertEquals(Integer.valueOf(4), next.get(5, TimeUnit.SECONDS)); + assertEquals(3, processor.batchHistogram()[1]); + assertEquals(1, processor.batchHistogram()[2]); + } finally { + directRelease.countDown(); overflowRelease.countDown(); batchRelease.countDown(); pool.shutdownNow(); + } + } + + @Test(timeout=10000) + public void opportunisticBypassReleasesCapacityAfterFailureAndRejectsInvalidBudgets() { + var operation = new IntegerOperation() { + public void apply(Integer value, int[] output) { + if (value == 0) throw new IllegalArgumentException("failure"); + output[0] = value; + } + }; + for (int parallelism : new int[]{1, 4, 8}) { + var processor = CallerBatchProcessor.opportunistic(parallelism, 8, operation); + for (int i = 0; i < parallelism + 2; i++) { + try { processor.process(0, new int[1]); fail("Missing failure"); } + catch (IllegalArgumentException expected) { } + } + int[] output = new int[1]; processor.process(7, output); assertEquals(7, output[0]); + } + try { CallerBatchProcessor.opportunistic(0, 8, operation); fail("Invalid budget"); } + catch (IllegalArgumentException expected) { } + } + +} From 3553ea4de06bf29df0462db682db7dbf8edf201e Mon Sep 17 00:00:00 2001 From: Ted Willke Date: Tue, 3 Nov 2026 23:45:58 +0000 Subject: [PATCH 125/243] Add reusable prepared ASH queries for graph search --- .../jvector/graph/ImmutableGraphIndex.java | 20 ++ .../jvector/graph/disk/OnDiskGraphIndex.java | 21 ++ .../jvector/graph/disk/feature/FusedASH.java | 10 + .../graph/similarity/PreparedQuery.java | 34 +++ .../graph/similarity/QueryPreparer.java | 44 ++++ .../quantization/ASHNeighborhoodScorer.java | 8 +- .../quantization/ASHQueryPreparer.java | 194 ++++++++++++++++++ .../jvector/quantization/ASHScorer.java | 5 + .../quantization/AsymmetricHashing.java | 15 ++ .../jvector/quantization/FusedASHDecoder.java | 35 +++- .../quantization/TestPreparedASHQuery.java | 192 +++++++++++++++++ 11 files changed, 572 insertions(+), 6 deletions(-) create mode 100644 jvector-base/src/main/java/io/github/jbellis/jvector/graph/similarity/PreparedQuery.java create mode 100644 jvector-base/src/main/java/io/github/jbellis/jvector/graph/similarity/QueryPreparer.java create mode 100644 jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHQueryPreparer.java create mode 100644 jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestPreparedASHQuery.java diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/graph/ImmutableGraphIndex.java b/jvector-base/src/main/java/io/github/jbellis/jvector/graph/ImmutableGraphIndex.java index 6482431cd..2074dfc84 100644 --- a/jvector-base/src/main/java/io/github/jbellis/jvector/graph/ImmutableGraphIndex.java +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/graph/ImmutableGraphIndex.java @@ -51,6 +51,16 @@ public interface ImmutableGraphIndex extends AutoCloseable, Accountable { /** Marks entry node as absent (fe, empty graph) */ int ENTRY_NODE_ABSENT = -1; + /** + * Optional numerical query preparation, independent of a mutable graph view. + * Obtain and reuse one preparer per loaded model. Returned preparers and query + * states belong to the caller; their byte counts exclude shared model weights. + */ + default java.util.Optional queryPreparer( + VectorSimilarityFunction similarity) { + return java.util.Optional.empty(); + } + /** Returns the number of nodes in the graph */ @Deprecated default int size() { @@ -216,6 +226,16 @@ default int getIdUpperBound() { * except for OnHeapGraphIndex.ConcurrentGraphIndexView.) */ interface ScoringView extends View { + /** + * Binds prepared numerical state to this view without projecting again. + * Keep the state unchanged through all uses of the provider, including + * resumed searches. Providers without this capability reject the call. + */ + default io.github.jbellis.jvector.graph.similarity.SearchScoreProvider scoreProviderFor( + io.github.jbellis.jvector.graph.similarity.PreparedQuery query, boolean rerank) { + throw new UnsupportedOperationException("Prepared query scoring is unavailable"); + } + ScoreFunction.ExactScoreFunction rerankerFor(VectorFloat queryVector, VectorSimilarityFunction vsf); ScoreFunction.ApproximateScoreFunction approximateScoreFunctionFor(VectorFloat queryVector, VectorSimilarityFunction vsf); } diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/graph/disk/OnDiskGraphIndex.java b/jvector-base/src/main/java/io/github/jbellis/jvector/graph/disk/OnDiskGraphIndex.java index 488c7b26f..1249b9da1 100644 --- a/jvector-base/src/main/java/io/github/jbellis/jvector/graph/disk/OnDiskGraphIndex.java +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/graph/disk/OnDiskGraphIndex.java @@ -486,6 +486,17 @@ public double getAverageDegree(int level) { return (double) sum / it.size(); } + @Override + public java.util.Optional queryPreparer( + VectorSimilarityFunction similarity) { + var feature = features.get(FeatureId.FUSED_ASH); + if (feature == null) return java.util.Optional.empty(); + if (similarity != VectorSimilarityFunction.DOT_PRODUCT) + throw new UnsupportedOperationException("FusedASH supports DOT_PRODUCT only"); + return java.util.Optional.of(new io.github.jbellis.jvector.quantization.ASHQueryPreparer( + ((FusedASH) feature).getASH())); + } + public class View implements FeatureSource, ScoringView, RandomAccessVectorValues { protected final RandomAccessReader reader; private final int[] neighbors; @@ -737,6 +748,16 @@ public ScoreFunction.ExactScoreFunction rerankerFor(VectorFloat queryVector, } } + @Override + public io.github.jbellis.jvector.graph.similarity.SearchScoreProvider scoreProviderFor( + io.github.jbellis.jvector.graph.similarity.PreparedQuery prepared, boolean rerank) { + var feature = (FusedASH) features.get(FeatureId.FUSED_ASH); + if (feature == null) throw new UnsupportedOperationException("Prepared query scoring requires FusedASH"); + var asf = feature.approximateScoreFunctionFor(prepared, this); + var rr = rerank ? rerankerFor(prepared.queryVector(), VectorSimilarityFunction.DOT_PRODUCT) : null; + return new io.github.jbellis.jvector.graph.similarity.DefaultSearchScoreProvider(asf, rr, true); + } + @Override public ScoreFunction.ApproximateScoreFunction approximateScoreFunctionFor(VectorFloat queryVector, VectorSimilarityFunction vsf) { if (features.containsKey(FeatureId.FUSED_PQ)) { diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/graph/disk/feature/FusedASH.java b/jvector-base/src/main/java/io/github/jbellis/jvector/graph/disk/feature/FusedASH.java index dbc2d3861..a8110c989 100644 --- a/jvector-base/src/main/java/io/github/jbellis/jvector/graph/disk/feature/FusedASH.java +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/graph/disk/feature/FusedASH.java @@ -250,6 +250,16 @@ public ScoreFunction.ApproximateScoreFunction approximateScoreFunctionFor( vsf); } + /** Bind preparation to the view while retaining the established decoder lifetime checks. */ + public ScoreFunction.ApproximateScoreFunction approximateScoreFunctionFor( + io.github.jbellis.jvector.graph.similarity.PreparedQuery query, + OnDiskGraphIndex.View view) { + return FusedASHDecoder.newDecoder(new PackedNeighborhoods(view), ash, + view.getInlineSourceFeatures(), source -> ((FusedASHInlineSource) source).getVector(), + query, view.getOrCreateFeatureScratch(id(), FusedASHDecoder.QueryScratch.class, + queryScratchFactory), blockSize); + } + @Override public void writeSourceFeature(IndexWriter out, Feature.State state_) throws IOException { var state = (FusedASH.State) state_; diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/graph/similarity/PreparedQuery.java b/jvector-base/src/main/java/io/github/jbellis/jvector/graph/similarity/PreparedQuery.java new file mode 100644 index 000000000..8e3d46100 --- /dev/null +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/graph/similarity/PreparedQuery.java @@ -0,0 +1,34 @@ +/* + * 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.similarity; + +import io.github.jbellis.jvector.util.Accountable; +import io.github.jbellis.jvector.vector.types.VectorFloat; + +/** + * Reusable, model-specific numerical preparation for one query. This state owns + * its query snapshot and scoring buffers, but no graph view or traversal state. + * + *

    After preparation it can be read by searches using the same model. The + * owner must not prepare it again until all searches and resumable result + * iterators borrowing it have finished. Publishing it to another thread requires + * the usual executor, queue or other happens-before handoff. No executor or + * request queue is created by this API. + */ +public interface PreparedQuery extends Accountable { + /** Borrowed FP32 query snapshot for reranking; callers must not modify it. */ + VectorFloat queryVector(); +} diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/graph/similarity/QueryPreparer.java b/jvector-base/src/main/java/io/github/jbellis/jvector/graph/similarity/QueryPreparer.java new file mode 100644 index 000000000..c3d9575d6 --- /dev/null +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/graph/similarity/QueryPreparer.java @@ -0,0 +1,44 @@ +/* + * 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.similarity; + +import io.github.jbellis.jvector.util.Accountable; +import io.github.jbellis.jvector.vector.types.VectorFloat; + +/** + * Prepares independent queries against one immutable model on the calling + * thread. Distinct output states may be prepared concurrently. The caller owns + * scheduling, admission, input lifetime during each call, and output reuse. + */ +public interface QueryPreparer extends Accountable { + /** Allocates one reusable state. Shared model weights are not copied. */ + PreparedQuery newState(); + + /** Whether the current backend has a genuine multi-vector preparation path. */ + boolean supportsBatchPreparation(); + + /** Snapshots and prepares one input, without participating in caller batching. */ + void prepare(VectorFloat query, PreparedQuery preparedState); + + /** + * Snapshots and prepares the first {@code count} inputs into distinct states + * from this model. Zero is a no-op. Counts above eight are supported. + * Structural validation precedes modification; a computation failure leaves + * affected states unusable until successfully prepared again. Unused array + * elements are ignored. Inputs must remain unmodified until this call returns. + */ + void prepareBatch(VectorFloat[] queries, PreparedQuery[] preparedStates, int count); +} diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHNeighborhoodScorer.java b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHNeighborhoodScorer.java index 3758c0f5f..b73dcfed3 100644 --- a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHNeighborhoodScorer.java +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHNeighborhoodScorer.java @@ -80,6 +80,12 @@ private static ASHScorer.QueryPrecompute prepareQuery( /** Borrows a view-owned LUT; its owner prevents use after the next query. */ ASHNeighborhoodScorer(AsymmetricHashing ash, ASHScorer.QueryPrecompute query, int maxDegree, int blockSize, float[] reusableLut) { + this(ash, query, maxDegree, blockSize, reusableLut, false); + } + + /** An externally prepared LUT is immutable until all borrowing searches finish. */ + ASHNeighborhoodScorer(AsymmetricHashing ash, ASHScorer.QueryPrecompute query, + int maxDegree, int blockSize, float[] reusableLut, boolean alreadyPrepared) { FusedASHLayout.validateBitsPerDimension(ash.bitsPerDimension); FusedASHLayout.validateBlockSize(blockSize); this.maxDegree = maxDegree; @@ -95,7 +101,7 @@ private static ASHScorer.QueryPrecompute prepareQuery( throw new IllegalArgumentException("LUT size does not match the ASH model"); } this.lut = reusableLut == null ? new float[lutSize] : reusableLut; - FusedASHLayout.buildQueryLut(query.qProj, dimensions, bits, lut); + if (!alreadyPrepared) FusedASHLayout.buildQueryLut(query.qProj, dimensions, bits, lut); var backend = VectorizationProvider.getInstance().getVectorUtilSupport(); this.neighborhoodBackend = kernel.isSimd() && dotQMu.length == 1 && backend.supportsAshNeighborhoodScoring() ? backend : null; diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHQueryPreparer.java b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHQueryPreparer.java new file mode 100644 index 000000000..3197649bb --- /dev/null +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHQueryPreparer.java @@ -0,0 +1,194 @@ +/* + * 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 io.github.jbellis.jvector.graph.similarity.PreparedQuery; +import io.github.jbellis.jvector.graph.similarity.QueryPreparer; +import io.github.jbellis.jvector.util.RamUsageEstimator; +import io.github.jbellis.jvector.vector.VectorizationProvider; +import io.github.jbellis.jvector.vector.types.FloatArray; +import io.github.jbellis.jvector.vector.types.VectorFloat; +import java.util.Arrays; +import java.util.Objects; + +/** + * Caller-scheduled ASH preparation. Uses the same query precision and native + * matrix as synchronous scoring, and calls an already formed batch directly. + * Encoding and training representations are unaffected. + */ +public final class ASHQueryPreparer implements QueryPreparer { + private final AsymmetricHashing ash; + private final ASHScorer scorer; + private static final class Rows { + float[][] input = new float[8][]; + float[][] output = new float[8][]; + void ensure(int count) { + if (count > input.length) { + input = new float[count][]; + output = new float[count][]; + } + } + } + // Values contain only cleared row-reference arrays, never a model or query. + private final ThreadLocal rows = ThreadLocal.withInitial(Rows::new); + + public ASHQueryPreparer(AsymmetricHashing ash) { + this.ash = Objects.requireNonNull(ash, "ash"); + FusedASHLayout.validateBitsPerDimension(ash.bitsPerDimension); + this.scorer = new ASHScorer(ash); + } + + @Override + public PreparedQuery newState() { return new State(ash, scorer.newQueryPrecompute()); } + + @Override + public boolean supportsBatchPreparation() { + return ash.stiefelTransform.supportsQueryBatchProjection(); + } + + @Override + public void prepare(VectorFloat query, PreparedQuery preparedState) { + State state = state(ash, preparedState, false); + validate(query, state); + state.invalidate(); + state.copy(query); + ash.stiefelTransform.projectQueryDirect(state.input, state.prepared.qProj); + finish(state); + } + + @Override + public void prepareBatch(VectorFloat[] queries, PreparedQuery[] preparedStates, int count) { + Objects.requireNonNull(queries, "queries"); + Objects.requireNonNull(preparedStates, "preparedStates"); + Objects.checkFromIndexSize(0, count, queries.length); + Objects.checkFromIndexSize(0, count, preparedStates.length); + validateBatch(queries, preparedStates, count); + if (count == 0) return; + Rows buffers = rows.get(); + buffers.ensure(count); + // Invalidate all outputs before any computation can fail. + for (int i = 0; i < count; i++) ((State) preparedStates[i]).invalidate(); + try { + for (int i = 0; i < count; i++) { + State state = (State) preparedStates[i]; + state.copy(queries[i]); + buffers.input[i] = state.input; + buffers.output[i] = state.prepared.qProj; + } + ash.stiefelTransform.projectQueryBatch(buffers.input, buffers.output, count); + for (int i = 0; i < count; i++) finish((State) preparedStates[i]); + } catch (RuntimeException | Error failure) { + for (int i = 0; i < count; i++) ((State) preparedStates[i]).ready = false; + throw failure; + } finally { + Arrays.fill(buffers.input, 0, count, null); + Arrays.fill(buffers.output, 0, count, null); + } + } + + private void validateBatch(VectorFloat[] queries, PreparedQuery[] preparedStates, int count) { + for (int i = 0; i < count; i++) { + State state = state(ash, preparedStates[i], false); + validate(queries[i], state); + for (int j = 0; j < i; j++) + if (preparedStates[j] == state) + throw new IllegalArgumentException("Batch output states must be distinct"); + } + for (int i = 0; i < count; i++) { + // Resolve an array-backed input once, rather than once per output. + // Other implementations are copied through VectorFloat's accessor. + if (!(queries[i] instanceof FloatArray)) continue; + float[] input = ((FloatArray) queries[i]).array(); + for (int j = 0; j < count; j++) { + if (i != j && input == ((State) preparedStates[j]).input) + throw new IllegalArgumentException("Batch input aliases another output state"); + } + } + } + + private void validate(VectorFloat query, State state) { + scorer.validateQuery(query); + if (state.ash != ash) throw new IllegalArgumentException("Prepared query belongs to another ASH model"); + } + + private void finish(State state) { + scorer.finishQuery(state.query, state.prepared); + FusedASHLayout.buildQueryLut(state.prepared.qProj, ash.quantizedDim, ash.bitsPerDimension, state.lut); + state.ready = true; + } + + static State state(AsymmetricHashing ash, PreparedQuery query, boolean requireReady) { + Objects.requireNonNull(query, "preparedQuery"); + if (!(query instanceof State) || ((State) query).ash != ash) + throw new IllegalArgumentException("Prepared query belongs to another ASH model"); + State state = (State) query; + if (requireReady) state.check(state.generation); + return state; + } + + @Override + public long ramBytesUsed() { + return RamUsageEstimator.shallowSizeOf(this) + RamUsageEstimator.shallowSizeOf(scorer) + + RamUsageEstimator.shallowSizeOf(rows); + } + + static final class State implements PreparedQuery { + final AsymmetricHashing ash; + final float[] input; + final VectorFloat query; + final ASHScorer.QueryPrecompute prepared; + final float[] lut; + final long bytes; + long generation; + boolean ready; + + State(AsymmetricHashing ash, ASHScorer.QueryPrecompute prepared) { + this.ash = ash; + this.input = new float[ash.originalDimension]; + this.query = VectorizationProvider.getInstance().getVectorTypeSupport().createFloatVector(input); + this.prepared = prepared; + this.lut = new float[Math.multiplyExact(FusedASHLayout.codeGroups(ash.quantizedDim, ash.bitsPerDimension), 16)]; + // Initialize a provider's lazy view once, outside preparation/search. + Object backing = query.get(); + long sum = RamUsageEstimator.shallowSizeOf(this) + RamUsageEstimator.sizeOf(input) + + RamUsageEstimator.shallowSizeOf(query) + RamUsageEstimator.shallowSizeOf(prepared) + + RamUsageEstimator.sizeOf(prepared.qProj) + RamUsageEstimator.sizeOf(lut) + + RamUsageEstimator.sizeOf(prepared.dotQMuByLandmark); + if (backing != input) sum += RamUsageEstimator.shallowSizeOf(backing); + // Empty generic arrays are shared by fast-scan states. + if (prepared.tildeQPool.length != 0) sum += RamUsageEstimator.sizeOf(prepared.tildeQPool); + if (prepared.sumTildeQByLandmark.length != 0) sum += RamUsageEstimator.sizeOf(prepared.sumTildeQByLandmark); + this.bytes = sum; + } + + void invalidate() { ready = false; generation++; } + void copy(VectorFloat source) { + if (source instanceof FloatArray) { + System.arraycopy(((FloatArray) source).array(), 0, input, 0, input.length); + } else { + for (int i = 0; i < input.length; i++) input[i] = source.get(i); + } + } + void check(long expectedGeneration) { + if (!ready || generation != expectedGeneration) + throw new IllegalStateException("Prepared query is unprepared or has been reused"); + } + @Override + public VectorFloat queryVector() { check(generation); return query; } + @Override + public long ramBytesUsed() { return bytes; } + } +} diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHScorer.java b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHScorer.java index 50dabf305..93bec5336 100644 --- a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHScorer.java +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHScorer.java @@ -296,6 +296,11 @@ void prepareQuery(VectorFloat query, float[] input, QueryPrecompute prepared) // Scratch is constructed with this model and never resized on the hot path. for (int k = 0; k < input.length; k++) input[k] = query.get(k); ash.stiefelTransform.projectQuery(input, prepared.qProj); + finishQuery(query, prepared); + } + + /** Completes the non-projection terms after direct or batched query projection. */ + void finishQuery(VectorFloat query, QueryPrecompute prepared) { for (int c = 0; c < ash.landmarkCount; c++) { prepared.dotQMuByLandmark[c] = VectorUtil.dotProduct(query, ash.landmarks[c]); } diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/AsymmetricHashing.java b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/AsymmetricHashing.java index d7bee8009..f61f72179 100644 --- a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/AsymmetricHashing.java +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/AsymmetricHashing.java @@ -1316,6 +1316,21 @@ public void projectQuery(float[] query, float[] out) { else batches.process(query, out); } + /** Prepared-query callers already own scheduling; avoid a second combining layer. */ + public void projectQueryDirect(float[] query, float[] out) { + queryProjection().project(query, out); + } + + /** Executes a caller-formed batch with the query precision, never the encoding view. */ + public void projectQueryBatch(float[][] queries, float[][] out, int count) { + queryProjection().projectBatch(queries, out, count); + } + + /** Capability inspection may lazily prepare the immutable query representation. */ + public boolean supportsQueryBatchProjection() { + return queryProjection().supportsBatchProjection(); + } + /** Snapshot of completed/claimed batch counts, or null when combining is disabled. */ public long[] queryProjectionBatchHistogram() { var projection = queryProjection; // Inspection must not prepare a query view. diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/FusedASHDecoder.java b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/FusedASHDecoder.java index ce4d5c8fd..54006a142 100644 --- a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/FusedASHDecoder.java +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/FusedASHDecoder.java @@ -121,6 +121,8 @@ public QueryScratch(AsymmetricHashing ash, int maxDegree, int blockSize) { private final QueryScratch queryScratch; private final long generation; + private final ASHQueryPreparer.State externalPrepared; + private final long preparedGeneration; private final PackedNeighborhoods packedNeighborhoods; private final Int2ObjectHashMap hierarchyCachedFeatures; @@ -163,7 +165,7 @@ public FusedASHDecoder( VectorSimilarityFunction similarityFunction) { this(packedNeighborhoods, ash, hierarchyCachedFeatures, inlineSourceAccessor, query, - reusableNeighborhoodBytes, reusableScores, blockSize, similarityFunction, null); + reusableNeighborhoodBytes, reusableScores, blockSize, similarityFunction, null, null); } private FusedASHDecoder( @@ -171,7 +173,8 @@ private FusedASHDecoder( Int2ObjectHashMap hierarchyCachedFeatures, InlineSourceAccessor inlineSourceAccessor, VectorFloat query, byte[] reusableNeighborhoodBytes, float[] reusableScores, int blockSize, - VectorSimilarityFunction similarityFunction, QueryScratch scratch) { + VectorSimilarityFunction similarityFunction, QueryScratch scratch, + ASHQueryPreparer.State externalPrepared) { if (similarityFunction != VectorSimilarityFunction.DOT_PRODUCT) { throw new UnsupportedOperationException("FusedASH supports DOT_PRODUCT only"); @@ -231,9 +234,15 @@ private FusedASHDecoder( throw new IllegalArgumentException("Query scratch belongs to a different ASH model or graph layout"); } this.queryScratch = scratch; + this.externalPrepared = externalPrepared; + this.preparedGeneration = externalPrepared == null ? 0 : externalPrepared.generation; var scorer = scratch == null ? new ASHScorer(ash) : scratch.scorer; ASHScorer.QueryPrecompute preparedQuery; - if (scratch == null) { + if (externalPrepared != null) { + externalPrepared.check(preparedGeneration); + this.generation = scratch == null ? 0 : ++scratch.generation; + preparedQuery = externalPrepared.prepared; + } else if (scratch == null) { this.generation = 0; preparedQuery = scorer.precomputeQuery(query); } else { @@ -244,7 +253,8 @@ private FusedASHDecoder( scorer.prepareQuery(query, scratch.input, preparedQuery); } this.neighborhoodScorer = new ASHNeighborhoodScorer(ash, preparedQuery, maxDegree, blockSize, - scratch == null ? null : scratch.lut); + externalPrepared == null ? (scratch == null ? null : scratch.lut) : externalPrepared.lut, + externalPrepared != null); this.sourceScorer = scorer.scoreFunctionFor(preparedQuery, neighborhoodScorer.queryLut()); } @@ -278,10 +288,25 @@ public static FusedASHDecoder newDecoder( int blockSize, VectorSimilarityFunction similarityFunction) { Objects.requireNonNull(scratch, "scratch"); return new FusedASHDecoder(neighborhoods, ash, hierarchyFeatures, sourceAccessor, query, - scratch.neighborhoodBytes, scratch.scores, blockSize, similarityFunction, scratch); + scratch.neighborhoodBytes, scratch.scores, blockSize, similarityFunction, scratch, null); + } + + /** Binds caller-owned preparation; no projection or LUT construction is repeated. */ + public static FusedASHDecoder newDecoder( + PackedNeighborhoods neighborhoods, AsymmetricHashing ash, + Int2ObjectHashMap hierarchyFeatures, + InlineSourceAccessor sourceAccessor, + io.github.jbellis.jvector.graph.similarity.PreparedQuery preparedQuery, + QueryScratch scratch, int blockSize) { + Objects.requireNonNull(scratch, "scratch"); + ASHQueryPreparer.State prepared = ASHQueryPreparer.state(ash, preparedQuery, true); + return new FusedASHDecoder(neighborhoods, ash, hierarchyFeatures, sourceAccessor, + prepared.queryVector(), scratch.neighborhoodBytes, scratch.scores, blockSize, + VectorSimilarityFunction.DOT_PRODUCT, scratch, prepared); } private void checkQueryLifetime() { + if (externalPrepared != null) externalPrepared.check(preparedGeneration); if (queryScratch != null && generation != queryScratch.generation) { throw new IllegalStateException("FusedASH decoder was invalidated by a new query on the same graph view"); } diff --git a/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestPreparedASHQuery.java b/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestPreparedASHQuery.java new file mode 100644 index 000000000..74efcbe45 --- /dev/null +++ b/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestPreparedASHQuery.java @@ -0,0 +1,192 @@ +/* + * 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 io.github.jbellis.jvector.TestUtil; +import io.github.jbellis.jvector.disk.SimpleMappedReader; +import io.github.jbellis.jvector.graph.GraphSearcher; +import io.github.jbellis.jvector.graph.MockVectorValues; +import io.github.jbellis.jvector.graph.SearchResult; +import io.github.jbellis.jvector.graph.disk.OnDiskGraphIndex; +import io.github.jbellis.jvector.graph.disk.OnDiskGraphIndexWriter; +import io.github.jbellis.jvector.graph.disk.feature.*; +import io.github.jbellis.jvector.graph.similarity.DefaultSearchScoreProvider; +import io.github.jbellis.jvector.graph.similarity.PreparedQuery; +import io.github.jbellis.jvector.util.Bits; +import io.github.jbellis.jvector.vector.VectorSimilarityFunction; +import io.github.jbellis.jvector.vector.VectorUtil; +import io.github.jbellis.jvector.vector.VectorizationProvider; +import io.github.jbellis.jvector.vector.types.VectorFloat; +import org.junit.Test; +import java.nio.file.Files; +import java.util.EnumMap; +import java.util.List; +import java.util.Random; +import java.util.concurrent.Executors; +import java.util.concurrent.ForkJoinPool; +import java.util.concurrent.TimeUnit; +import java.util.function.IntFunction; +import static org.junit.Assert.*; + +/** Numerical preparation ownership, batch tails, handoff, and resumed graph searches. */ +public class TestPreparedASHQuery { + private static final VectorSimilarityFunction DOT = VectorSimilarityFunction.DOT_PRODUCT; + + private static VectorFloat[] inputs(int count) { + var type = VectorizationProvider.getInstance().getVectorTypeSupport(); + var values = new VectorFloat[count]; + Random random = new Random(341975); + for (int n = 0; n < count; n++) { + values[n] = type.createFloatVector(64); + for (int d = 0; d < 64; d++) values[n].set(d, random.nextFloat() - .5f); + VectorUtil.l2normalize(values[n]); + } + return values; + } + + private static AsymmetricHashing model(VectorFloat[] input, int bits) throws Exception { + return AsymmetricHashing.initialize(MockVectorValues.fromValues(input), AsymmetricHashing.RANDOM, + AsymmetricHashing.HEADER_BITS + 33 * bits, 1, bits); + } + + @Test + public void batchMatchesSinglePreparationForEveryFusedBitCountAndOwnsInputs() throws Exception { + var values = inputs(97); + for (int bits : new int[]{1, 2, 4}) { + var ash = model(values, bits); + var preparer = new ASHQueryPreparer(ash); + var states = new PreparedQuery[17]; + for (int i = 0; i < states.length; i++) states[i] = preparer.newState(); + var reference = preparer.newState(); + for (int count : new int[]{0, 1, 2, 8, 17}) { + preparer.prepareBatch(values, states, count); + for (int i = 0; i < count; i++) { + preparer.prepare(values[i], reference); + var expected = ASHQueryPreparer.state(ash, reference, true); + var actual = ASHQueryPreparer.state(ash, states[i], true); + assertArrayEquals(expected.prepared.qProj, actual.prepared.qProj, 2e-6f); + assertArrayEquals(expected.prepared.dotQMuByLandmark, actual.prepared.dotQMuByLandmark, 0f); + assertArrayEquals(expected.lut, actual.lut, 5e-6f); + } + } + var mutable = values[0].copy(); + preparer.prepare(mutable, reference); + float first = reference.queryVector().get(0); + mutable.set(0, first + 10); + assertEquals(first, reference.queryVector().get(0), 0f); + long bytes = reference.ramBytesUsed(); + for (int i = 0; i < 50; i++) preparer.prepare(values[i], reference); + assertEquals(bytes, reference.ramBytesUsed()); + assertTrue(bytes > 4L * (64 + 33)); + } + } + + @Test + public void invalidBatchDoesNotOverwriteExistingStates() throws Exception { + var values = inputs(97); + var ash = model(values, 2); + var preparer = new ASHQueryPreparer(ash); + var first = preparer.newState(); + var second = preparer.newState(); + assertThrows(IllegalStateException.class, first::queryVector); + preparer.prepare(values[0], first); + float original = first.queryVector().get(0); + assertThrows(IllegalArgumentException.class, + () -> preparer.prepareBatch(values, new PreparedQuery[]{first, first}, 2)); + assertEquals(original, first.queryVector().get(0), 0f); + var other = new ASHQueryPreparer(model(values, 2)).newState(); + assertThrows(IllegalArgumentException.class, () -> preparer.prepare(values[0], other)); + assertThrows(IndexOutOfBoundsException.class, + () -> preparer.prepareBatch(values, new PreparedQuery[]{first}, 2)); + assertThrows(IndexOutOfBoundsException.class, + () -> preparer.prepareBatch(values, new PreparedQuery[]{first}, -1)); + var bad = VectorizationProvider.getInstance().getVectorTypeSupport().createFloatVector(63); + assertThrows(IllegalArgumentException.class, + () -> preparer.prepareBatch(new VectorFloat[]{values[1], bad}, new PreparedQuery[]{first, second}, 2)); + assertEquals(original, first.queryVector().get(0), 0f); + } + + @Test(timeout=30000) + public void distinctStatesCanBePreparedAndHandedOffAcrossThreads() throws Exception { + var values = inputs(97); + var ash = model(values, 4); + var preparer = new ASHQueryPreparer(ash); + var pool = Executors.newFixedThreadPool(4); + try { + var tasks = new java.util.ArrayList>(); + for (int i = 0; i < 16; i++) { + final int ordinal = i; + tasks.add(pool.submit(() -> { + var state = preparer.newState(); + preparer.prepare(values[ordinal], state); + return state; + })); + } + for (int i = 0; i < tasks.size(); i++) { + var state = tasks.get(i).get(10, TimeUnit.SECONDS); + for (int d = 0; d < 64; d++) assertEquals(values[i].get(d), state.queryVector().get(d), 0f); + } + } finally { pool.shutdownNow(); } + } + + private static void assertSameResults(SearchResult a, SearchResult b) { + assertArrayEquals(a.getNodes(), b.getNodes()); + } + + @Test + public void realDiskSearchFilteringAndResumeUsePreparedStateWithoutReprojection() throws Exception { + var values = inputs(17); + var ravv = MockVectorValues.fromValues(values); + var graph = new TestUtil.FullyConnectedGraphIndex(0, List.of(17, 1)) { + @Override public List maxDegrees() { return List.of(16, 0); } + @Override public int getDimension() { return 64; } + }; + for (int bits : new int[]{1, 2, 4}) { + var ash = model(values, bits); + var encoded = ash.encodeAll(ravv, ForkJoinPool.commonPool()); + var path = Files.createTempFile("ash-prepared-query-", ".index"); + try { + var suppliers = new EnumMap>(FeatureId.class); + try (var view = graph.getView(); var writer = new OnDiskGraphIndexWriter.Builder(graph, path) + .with(new FusedASH(16, ash, 32)).with(new InlineVectors(64)).build()) { + suppliers.put(FeatureId.FUSED_ASH, n -> new FusedASH.State(view, encoded, n)); + suppliers.put(FeatureId.INLINE_VECTORS, n -> new InlineVectors.State(values[n])); + writer.write(suppliers); + } + try (var reader = new SimpleMappedReader.Supplier(path); var disk = OnDiskGraphIndex.load(reader, 0); + var direct = new GraphSearcher(disk); var queued = new GraphSearcher(disk)) { + var preparer = disk.queryPreparer(DOT).orElseThrow(); + var state = preparer.newState(); + var directView = (OnDiskGraphIndex.View) direct.getView(); + var queuedView = (OnDiskGraphIndex.View) queued.getView(); + for (int q = 0; q < 4; q++) { + preparer.prepareBatch(new VectorFloat[]{values[q]}, new PreparedQuery[]{state}, 1); + var provider = queuedView.scoreProviderFor(state, true); + var ordinary = new DefaultSearchScoreProvider( + directView.approximateScoreFunctionFor(values[q], DOT), directView.rerankerFor(values[q], DOT), true); + Bits accepted = n -> (n & 1) == 0; + assertSameResults(direct.search(ordinary, 3, 17, 0f, 0f, accepted), + queued.search(provider, 3, 17, 0f, 0f, accepted)); + assertSameResults(direct.resume(3, 17), queued.resume(3, 17)); + preparer.prepare(values[q + 1], state); + assertThrows(IllegalStateException.class, () -> provider.scoreFunction().similarityTo(0)); + } + assertThrows(UnsupportedOperationException.class, () -> disk.queryPreparer(VectorSimilarityFunction.COSINE)); + } + } finally { Files.deleteIfExists(path); } + } + } +} From d342b8618804f0eb7519a930d33ef2ee29837240 Mon Sep 17 00:00:00 2001 From: Ted Willke Date: Tue, 3 Nov 2026 23:45:59 +0000 Subject: [PATCH 126/243] Add bounded execution with batched request preparation --- .../jvector/util/BatchingExecutor.java | 242 ++++++++++++++++++ .../jvector/util/TestBatchingExecutor.java | 184 +++++++++++++ 2 files changed, 426 insertions(+) create mode 100644 jvector-base/src/main/java/io/github/jbellis/jvector/util/BatchingExecutor.java create mode 100644 jvector-tests/src/test/java/io/github/jbellis/jvector/util/TestBatchingExecutor.java diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/util/BatchingExecutor.java b/jvector-base/src/main/java/io/github/jbellis/jvector/util/BatchingExecutor.java new file mode 100644 index 000000000..bd1b62717 --- /dev/null +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/util/BatchingExecutor.java @@ -0,0 +1,242 @@ +/* + * 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.util; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Objects; +import java.util.concurrent.ArrayBlockingQueue; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.Executor; +import java.util.concurrent.RejectedExecutionException; +import java.util.concurrent.Semaphore; +import java.util.concurrent.atomic.AtomicLong; +import java.util.concurrent.atomic.AtomicReference; +import java.util.concurrent.locks.ReentrantLock; + +/** + * Bounded two-stage execution on a caller-supplied executor. Ready work is + * prepared in groups without a batch-fill delay, then each request executes + * independently. A worker can execute requests prepared by another worker. + * + *

    Exactly {@code workers} long-lived tasks are submitted to the executor; it + * must have that much available execution capacity. This class creates no + * threads and does not shut down the supplied executor. Use one instance for a + * shared CPU budget rather than reserving worker sets for every model. + * + *

    Queue records, batch lists and row views are allocated at construction. + * Each accepted request is handed back through its completion callback, which + * may recycle it after its last use. No request is discarded on close. Close drains accepted + * work and must precede shutting down the supplied executor. + */ +public final class BatchingExecutor implements AutoCloseable { + /** Thread-safe operations over independently owned requests. */ + public interface Operation { + /** Identity key for compatible preparation; null requests are prepared alone. */ + Object batchKey(T request); + /** Borrowed read-only list, valid only for this call; never retained. */ + void prepareBatch(List requests) throws Exception; + /** Runs one prepared request, without any batch-wide completion barrier. */ + void execute(T request) throws Exception; + /** Called exactly once per accepted request; must not throw. */ + void complete(T request, Throwable failure); + } + + private static final long CLOSED = Long.MIN_VALUE; + private static final class Slot { T request; } + private final ArrayBlockingQueue> available, pending, ready; + private final ReentrantLock drain = new ReentrantLock(); + private final Semaphore work = new Semaphore(0); + private final AtomicLong admission = new AtomicLong(); + private final AtomicReference callbackFailure = new AtomicReference<>(); + private final CountDownLatch stopped; + private final Operation operation; + private final int capacity, workerCount, batchSize; + private final List workers; + private final ThreadLocal inWorker = new ThreadLocal<>(); + + /** + * @param capacity total accepted requests, including preparing and executing + * @param batchSize maximum currently available compatible requests per preparation + * @param executor caller-owned asynchronous execution capacity; completion + * callbacks run on these workers and must return promptly + */ + public BatchingExecutor(int workerCount, int capacity, int batchSize, + Executor executor, Operation operation) { + if (workerCount <= 0 || capacity <= 0 || batchSize <= 0 || batchSize > capacity) + throw new IllegalArgumentException("Workers, capacity and batch size must be positive; batch size <= capacity"); + this.workerCount = workerCount; + this.capacity = capacity; + this.batchSize = batchSize; + this.operation = Objects.requireNonNull(operation, "operation"); + Objects.requireNonNull(executor, "executor"); + available = new ArrayBlockingQueue<>(capacity); + pending = new ArrayBlockingQueue<>(capacity); + ready = new ArrayBlockingQueue<>(capacity); + for (int i = 0; i < capacity; i++) available.add(new Slot<>()); + stopped = new CountDownLatch(workerCount); + workers = new ArrayList<>(workerCount); + for (int i = 0; i < workerCount; i++) workers.add(new Worker()); + int submitted = 0; + try { + for (Worker worker : workers) { executor.execute(worker); submitted++; } + } catch (RuntimeException | Error failure) { + admission.getAndUpdate(value -> value | CLOSED); + for (int i = submitted; i < workerCount; i++) stopped.countDown(); + work.release(submitted); + awaitStopped(); + throw failure; + } + } + + /** + * Accepts without waiting, returning false at the total admission limit. + * Rejection leaves ownership with the caller. Close rejects further requests. + * Inputs must not be mutated or submitted twice before completion. + */ + public boolean trySubmit(T request) { + Objects.requireNonNull(request, "request"); + if (callbackFailure.get() != null) + throw new IllegalStateException("A completion callback failed", callbackFailure.get()); + for (;;) { + long count = admission.get(); + if (count < 0) throw new RejectedExecutionException("Batching executor is closed"); + if (count == capacity) return false; + if (admission.compareAndSet(count, count + 1)) break; + } + // Completion returns the slot before releasing its admission count. + Slot slot = available.remove(); + slot.request = request; + pending.add(slot); + work.release(); + return true; + } + + private final class Worker implements Runnable { + final ArrayList> slots = new ArrayList<>(batchSize); + final ArrayList requests = new ArrayList<>(batchSize); + final List borrowedRequests = Collections.unmodifiableList(requests); + final long[] histogram = new long[batchSize + 1]; + + @Override + public void run() { + inWorker.set(Boolean.TRUE); + try { + for (;;) { + if (admission.get() == CLOSED) return; + work.acquireUninterruptibly(); + Slot next = ready.poll(); + if (next != null) { + Throwable failure = null; + try { operation.execute(next.request); } + catch (Throwable error) { failure = error; } + finish(next, failure); + continue; + } + drain.lock(); + try { + next = pending.poll(); + if (next == null) continue; + slots.add(next); + requests.add(next.request); + Object key = operation.batchKey(next.request); + while (key != null && slots.size() < batchSize) { + Slot head = pending.peek(); + if (head == null || operation.batchKey(head.request) != key) break; + next = pending.remove(); + // Each submission signaled one pending request. This worker + // already consumed the first signal; retire the additional + // ones as their requests are folded into this preparation. + // Another worker may already have consumed a signal and be + // waiting for drain, so acquisition must never block here. + work.tryAcquire(); + slots.add(next); + requests.add(next.request); + } + } catch (Throwable failure) { + // A key callback failure cannot strand already removed work. + for (Slot slot : slots) finish(slot, failure); + slots.clear(); requests.clear(); + continue; + } finally { drain.unlock(); } + Throwable failure = null; + try { operation.prepareBatch(borrowedRequests); histogram[slots.size()]++; } + catch (Throwable error) { failure = error; } + if (failure == null) { + int size = slots.size(); + if (size == 1) { + Slot slot = slots.get(0); + try { operation.execute(slot.request); } + catch (Throwable error) { failure = error; } + finish(slot, failure); + } else { + for (Slot slot : slots) ready.add(slot); + work.release(size); + } + } else { + for (Slot slot : slots) finish(slot, failure); + } + slots.clear(); requests.clear(); + } + } finally { + inWorker.remove(); + stopped.countDown(); + } + } + } + + private void finish(Slot slot, Throwable failure) { + T request = slot.request; + slot.request = null; + available.add(slot); + long count = admission.decrementAndGet(); + try { operation.complete(request, failure); } + catch (Throwable error) { callbackFailure.compareAndSet(null, error); } + if (count == CLOSED) work.release(workerCount); + } + + /** Snapshot of successful preparation groups; exact when all requests have completed. */ + public long[] batchHistogram() { + long[] result = new long[batchSize + 1]; + for (Worker worker : workers) + for (int i = 1; i < result.length; i++) result[i] += worker.histogram[i]; + return result; + } + + /** Accepted requests in either queue or preparation/execution, excluding completion callbacks. */ + public int outstanding() { return (int) (admission.get() & Long.MAX_VALUE); } + + @Override + public void close() { + if (Boolean.TRUE.equals(inWorker.get())) + throw new IllegalStateException("Cannot close a batching executor from its worker callback"); + admission.getAndUpdate(value -> value | CLOSED); + work.release(workerCount); + awaitStopped(); + if (callbackFailure.get() != null) + throw new IllegalStateException("A completion callback failed", callbackFailure.get()); + } + + private void awaitStopped() { + boolean interrupted = false; + for (;;) { + try { stopped.await(); break; } + catch (InterruptedException e) { interrupted = true; } + } + if (interrupted) Thread.currentThread().interrupt(); + } +} diff --git a/jvector-tests/src/test/java/io/github/jbellis/jvector/util/TestBatchingExecutor.java b/jvector-tests/src/test/java/io/github/jbellis/jvector/util/TestBatchingExecutor.java new file mode 100644 index 000000000..5c14c7abb --- /dev/null +++ b/jvector-tests/src/test/java/io/github/jbellis/jvector/util/TestBatchingExecutor.java @@ -0,0 +1,184 @@ +/* + * 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.util; + +import org.junit.Test; +import java.util.List; +import java.util.concurrent.*; +import java.util.concurrent.atomic.*; +import static org.junit.Assert.*; + +/** Tests real queued submission, bounded admission, failures, handoff and close. */ +public class TestBatchingExecutor { + private abstract static class IntOperation implements BatchingExecutor.Operation { + public Object batchKey(Integer value) { return this; } + public void prepareBatch(List values) throws Exception {} + public void execute(Integer value) throws Exception {} + } + + @Test(timeout=15000) + public void oneProducerAndOneWorkerPrepareEightWithoutEightCallerThreads() throws Exception { + var executor = Executors.newSingleThreadExecutor(); + var entered = new CountDownLatch(1); + var release = new CountDownLatch(1); + var done = new CountDownLatch(9); + var maximumBatch = new AtomicInteger(); + var failure = new AtomicReference(); + try (var service = new BatchingExecutor(1, 9, 8, executor, new IntOperation() { + public void prepareBatch(List values) { + maximumBatch.accumulateAndGet(values.size(), Math::max); + assertTrue(values.size() <= 8); + } + public void execute(Integer value) throws Exception { + if (value == 0) { entered.countDown(); release.await(); } + } + public void complete(Integer value, Throwable error) { + if (error != null) failure.compareAndSet(null, error); + done.countDown(); + } + })) { + assertTrue(service.trySubmit(0)); + assertTrue(entered.await(5, TimeUnit.SECONDS)); + for (int i = 1; i < 9; i++) assertTrue(service.trySubmit(i)); + assertEquals(9, service.outstanding()); + assertFalse(service.trySubmit(10)); + release.countDown(); + assertTrue(done.await(5, TimeUnit.SECONDS)); + assertNull(failure.get()); + assertEquals(8, maximumBatch.get()); + assertEquals(1, service.batchHistogram()[1]); + assertEquals(1, service.batchHistogram()[8]); + } finally { release.countDown(); executor.shutdownNow(); } + } + + @Test(timeout=15000) + public void drainingABatchDoesNotAccumulateDuplicateWakeups() throws Exception { + var executor = Executors.newSingleThreadExecutor(); + var firstEntered = new CountDownLatch(1); + var firstRelease = new CountDownLatch(1); + var batchEntered = new CountDownLatch(1); + var batchRelease = new CountDownLatch(1); + var done = new CountDownLatch(9); + var failure = new AtomicReference(); + try (var service = new BatchingExecutor(1, 9, 8, executor, new IntOperation() { + public void execute(Integer value) throws Exception { + if (value == 0) { firstEntered.countDown(); firstRelease.await(); } + if (value == 1) { batchEntered.countDown(); batchRelease.await(); } + } + public void complete(Integer value, Throwable error) { + if (error != null) failure.compareAndSet(null, error); + done.countDown(); + } + })) { + try { + assertTrue(service.trySubmit(0)); + assertTrue(firstEntered.await(5, TimeUnit.SECONDS)); + for (int i = 1; i < 9; i++) assertTrue(service.trySubmit(i)); + firstRelease.countDown(); + assertTrue(batchEntered.await(5, TimeUnit.SECONDS)); + // Seven prepared requests remain runnable. Counting their original + // pending signals again would grow notifications under continuous load. + var field = BatchingExecutor.class.getDeclaredField("work"); + field.setAccessible(true); + assertEquals(7, ((Semaphore) field.get(service)).availablePermits()); + batchRelease.countDown(); + assertTrue(done.await(5, TimeUnit.SECONDS)); + assertNull(failure.get()); + } finally { firstRelease.countDown(); batchRelease.countDown(); } + } finally { firstRelease.countDown(); batchRelease.countDown(); executor.shutdownNow(); } + } + + @Test(timeout=15000) + public void isolatedRequestsStartImmediatelyAtCapacityEightAndRecordsAreReused() throws Exception { + var executor = Executors.newFixedThreadPool(2); + var completed = new ArrayBlockingQueue(1); + var completedCount = new AtomicInteger(); + try (var service = new BatchingExecutor(2, 8, 8, executor, new IntOperation() { + public void complete(Integer value, Throwable error) { + assertNull(error); completedCount.incrementAndGet(); completed.add(value); + } + })) { + for (int i = 0; i < 100; i++) { + assertTrue(service.trySubmit(i)); + assertEquals(Integer.valueOf(i), completed.poll(5, TimeUnit.SECONDS)); + } + assertEquals(100, completedCount.get()); + assertEquals(100, service.batchHistogram()[1]); + } finally { executor.shutdownNow(); } + } + + @Test(timeout=15000) + public void prepareAndExecutionFailuresCompleteRequestsAndCloseDrainsAcceptedWork() throws Exception { + var executor = Executors.newFixedThreadPool(3); + var counts = new AtomicIntegerArray(64); + var errors = new AtomicInteger(); + var service = new BatchingExecutor(3, 64, 8, executor, new IntOperation() { + public Object batchKey(Integer value) { return null; } + public void prepareBatch(List values) { + if (values.get(0) == 7) throw new IllegalArgumentException("prepare failure"); + } + public void execute(Integer value) { + if (value == 31) throw new IllegalStateException("search failure"); + } + public void complete(Integer value, Throwable error) { + counts.incrementAndGet(value); + if (error != null) errors.incrementAndGet(); + } + }); + try { + for (int i = 0; i < 64; i++) assertTrue(service.trySubmit(i)); + service.close(); + assertEquals(0, service.outstanding()); + for (int i = 0; i < 64; i++) assertEquals(1, counts.get(i)); + assertEquals(2, errors.get()); + assertThrows(RejectedExecutionException.class, () -> service.trySubmit(0)); + service.close(); + } finally { executor.shutdownNow(); } + } + + @Test(timeout=15000) + public void differentModelsNeverSharePreparationAndPreparedWorkCanChangeWorkers() throws Exception { + var executor = Executors.newFixedThreadPool(4); + Object[] keys = {new Object(), new Object()}; + var done = new CountDownLatch(128); + var failure = new AtomicReference(); + var preparedThread = new ConcurrentHashMap(); + var changedWorker = new AtomicBoolean(); + try (var service = new BatchingExecutor(4, 128, 8, executor, new IntOperation() { + public Object batchKey(Integer value) { return keys[(value / 16) & 1]; } + public void prepareBatch(List values) { + Object key = batchKey(values.get(0)); + for (Integer value : values) { + assertSame(key, batchKey(value)); + preparedThread.put(value, Thread.currentThread()); + } + } + public void execute(Integer value) { + if (preparedThread.get(value) != Thread.currentThread()) changedWorker.set(true); + } + public void complete(Integer value, Throwable error) { + if (error != null) failure.compareAndSet(null, error); + done.countDown(); + } + })) { + for (int i = 0; i < 128; i++) assertTrue(service.trySubmit(i)); + assertTrue(done.await(5, TimeUnit.SECONDS)); + assertNull(failure.get()); + assertEquals(128, preparedThread.size()); + // Handoff is allowed rather than mandatory; scheduling determines the owner. + } finally { executor.shutdownNow(); } + } +} From 6aefea301aa22f4cafea6cf224a480f701965ae1 Mon Sep 17 00:00:00 2001 From: Ted Willke Date: Tue, 3 Nov 2026 23:45:59 +0000 Subject: [PATCH 127/243] Add queued query execution mode to BenchYAML --- .../github/jbellis/jvector/example/Grid.java | 47 +++- .../example/benchmarks/AccuracyBenchmark.java | 14 +- .../example/benchmarks/CountBenchmark.java | 13 + .../example/benchmarks/LatencyBenchmark.java | 8 +- .../example/benchmarks/QueryExecutor.java | 9 + .../benchmarks/QueuedQueryExecution.java | 235 ++++++++++++++++++ .../benchmarks/ThroughputBenchmark.java | 43 +++- .../example/reporting/RunArtifacts.java | 21 +- .../reporting/SearchReportingCatalog.java | 6 + .../jvector/example/yaml/RunConfig.java | 8 + .../example/TestQueuedWorkerLifetime.java | 73 ++++++ .../benchmarks/QueryExecutionConfigTest.java | 54 ++++ 12 files changed, 507 insertions(+), 24 deletions(-) create mode 100644 jvector-examples/src/main/java/io/github/jbellis/jvector/example/benchmarks/QueuedQueryExecution.java create mode 100644 jvector-examples/src/test/java/io/github/jbellis/jvector/example/TestQueuedWorkerLifetime.java create mode 100644 jvector-examples/src/test/java/io/github/jbellis/jvector/example/benchmarks/QueryExecutionConfigTest.java diff --git a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/Grid.java b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/Grid.java index e7c03643e..c66bcdd7e 100644 --- a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/Grid.java +++ b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/Grid.java @@ -854,6 +854,7 @@ private static void testConfiguration(ConfiguredSystem cs, ConstructionMetrics constructionMetrics, Path testDirectory) { int queryRuns = 2; + cs.configureQueryExecution(artifacts.queryExecutionMode(), artifacts.queryBatchSize()); System.out.format("%s: Using %s:%n", cs.ds.getName(), cs.index); Map> benchmarksToCompute = artifacts.benchmarksToCompute(); @@ -1385,6 +1386,34 @@ public static class ConfiguredSystem implements AutoCloseable { CompressedVectors cv; Set features; private final Supplier exactVectors; + private boolean queuedExecution; + private int queryBatchSize = 8; + private io.github.jbellis.jvector.graph.similarity.QueryPreparer queryPreparer; + private java.util.concurrent.ExecutorService queuedQueryWorkers; + private boolean closed; + + public int queryWorkerCount() { + return Math.max(1, Math.min(Runtime.getRuntime().availableProcessors(), PhysicalCoreExecutor.getPhysicalCoreCount())); + } + + /** Shared across this configured index's warmups and all query measurements. */ + public synchronized java.util.concurrent.ExecutorService queryWorkers() { + if (closed) throw new IllegalStateException("Configured system is closed"); + if (queuedQueryWorkers == null) + queuedQueryWorkers = java.util.concurrent.Executors.newFixedThreadPool(queryWorkerCount()); + return queuedQueryWorkers; + } + + public void configureQueryExecution(String mode, int batchSize) { + queuedExecution = "queued".equals(mode); + queryBatchSize = batchSize; + if (queuedExecution && queryPreparer == null) + queryPreparer = index.queryPreparer(ds.getSimilarityFunction()).orElse(null); + } + public boolean usesQueuedExecution() { return queuedExecution; } + public int queryBatchSize() { return queryBatchSize; } + public io.github.jbellis.jvector.graph.similarity.QueryPreparer queryPreparer() { return queryPreparer; } + private final ExplicitThreadLocal searchers = ExplicitThreadLocal.withInitial(() -> { return new GraphSearcher(index); @@ -1425,8 +1454,22 @@ public DataSet getDataSet() { } @Override - public void close() throws Exception { - searchers.close(); + public synchronized void close() throws Exception { + closed = true; + try { + if (queuedQueryWorkers != null) { + queuedQueryWorkers.shutdown(); + boolean interrupted = false; + try { + while (!queuedQueryWorkers.isTerminated()) { + try { queuedQueryWorkers.awaitTermination(Long.MAX_VALUE, java.util.concurrent.TimeUnit.NANOSECONDS); } + catch (InterruptedException e) { interrupted = true; } + } + } finally { if (interrupted) Thread.currentThread().interrupt(); } + } + } finally { + searchers.close(); + } } } diff --git a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/benchmarks/AccuracyBenchmark.java b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/benchmarks/AccuracyBenchmark.java index 897eec550..dfd42850f 100644 --- a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/benchmarks/AccuracyBenchmark.java +++ b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/benchmarks/AccuracyBenchmark.java @@ -91,11 +91,15 @@ public List runBenchmark( int totalQueries = cs.getDataSet().getQueryVectors().size(); // execute all queries in parallel and collect results - List results = IntStream.range(0, totalQueries) - .parallel() - .mapToObj(i -> QueryExecutor.executeQuery( - cs, topK, rerankK, usePruning, i)) - .collect(Collectors.toList()); + List results; + try (QueuedQueryExecution queued = cs.usesQueuedExecution() + ? new QueuedQueryExecution(cs, topK, rerankK, usePruning) : null) { + results = queued == null ? IntStream.range(0, totalQueries).parallel() + .mapToObj(i -> QueryExecutor.executeQuery(cs, topK, rerankK, usePruning, i)) + .collect(Collectors.toList()) + : java.util.Arrays.asList(queued.runPass(totalQueries, + cs.getDataSet().getQueryVectors()::get, null, true).results); + } var list = new ArrayList(); if (computeRecall) { diff --git a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/benchmarks/CountBenchmark.java b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/benchmarks/CountBenchmark.java index 1e10c102d..784b12111 100644 --- a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/benchmarks/CountBenchmark.java +++ b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/benchmarks/CountBenchmark.java @@ -107,7 +107,18 @@ public List runBenchmark( LongAdder nodesExpandedBaseLayer = new LongAdder(); int totalQueries = cs.getDataSet().getQueryVectors().size(); + try (QueuedQueryExecution queued = cs.usesQueuedExecution() + ? new QueuedQueryExecution(cs, topK, rerankK, usePruning) : null) { for (int run = 0; run < queryRuns; run++) { + if (queued != null) { + var pass = queued.runPass(totalQueries, cs.getDataSet().getQueryVectors()::get, null, true); + for (SearchResult result : pass.results) { + nodesVisited.add(result.getVisitedCount()); + nodesExpanded.add(result.getExpandedCount()); + nodesExpandedBaseLayer.add(result.getExpandedCountBaseLayer()); + } + continue; + } IntStream.range(0, totalQueries) .parallel() .forEach(i -> { @@ -119,6 +130,8 @@ public List runBenchmark( }); } + } + double avgVisited = nodesVisited.sum() / (double) (queryRuns * totalQueries); double avgExpanded = nodesExpanded.sum() / (double) (queryRuns * totalQueries); double avgBase = nodesExpandedBaseLayer.sum() / (double) (queryRuns * totalQueries); diff --git a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/benchmarks/LatencyBenchmark.java b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/benchmarks/LatencyBenchmark.java index d04a9c1bc..12ad6983e 100644 --- a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/benchmarks/LatencyBenchmark.java +++ b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/benchmarks/LatencyBenchmark.java @@ -112,12 +112,15 @@ public List runBenchmark( // Collect every query latency List latencies = new ArrayList<>(totalQueries * queryRuns); + try (QueuedQueryExecution queued = cs.usesQueuedExecution() + ? new QueuedQueryExecution(cs, topK, rerankK, usePruning) : null) { // Run the full set of queries queryRuns times for (int run = 0; run < queryRuns; run++) { for (int i = 0; i < totalQueries; i++) { long start = System.nanoTime(); - SearchResult sr = QueryExecutor.executeQuery( - cs, topK, rerankK, usePruning, i); + SearchResult sr = queued == null ? QueryExecutor.executeQuery( + cs, topK, rerankK, usePruning, i) + : queued.executeSingle(cs.getDataSet().getQueryVectors().get(i)); long duration = System.nanoTime() - start; // record latency for percentile computation latencies.add(duration); @@ -152,6 +155,7 @@ public List runBenchmark( list.add(Metric.of("search.latency.p999_ms", "p999 Latency (ms)", formatP999Latency, p999Latency)); } return list; + } } } diff --git a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/benchmarks/QueryExecutor.java b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/benchmarks/QueryExecutor.java index 45094bbd6..d67fb4a2f 100644 --- a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/benchmarks/QueryExecutor.java +++ b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/benchmarks/QueryExecutor.java @@ -22,6 +22,15 @@ import io.github.jbellis.jvector.vector.types.VectorFloat; public class QueryExecutor { + /** Executes only traversal/reranking after a caller has prepared the query. */ + public static SearchResult executePreparedQuery(ConfiguredSystem cs, int topK, int rerankK, + boolean usePruning, io.github.jbellis.jvector.graph.similarity.PreparedQuery prepared) { + var searcher = cs.getSearcher(); + searcher.usePruning(usePruning); + var view = (io.github.jbellis.jvector.graph.ImmutableGraphIndex.ScoringView) searcher.getView(); + return searcher.search(view.scoreProviderFor(prepared, true), topK, rerankK, 0.0f, 0.0f, Bits.ALL); + } + /** * Executes the query at index i using the given parameters. * diff --git a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/benchmarks/QueuedQueryExecution.java b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/benchmarks/QueuedQueryExecution.java new file mode 100644 index 000000000..fa822ed62 --- /dev/null +++ b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/benchmarks/QueuedQueryExecution.java @@ -0,0 +1,235 @@ +/* + * 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.benchmarks; + +import io.github.jbellis.jvector.example.Grid.ConfiguredSystem; +import io.github.jbellis.jvector.example.benchmarks.diagnostics.BenchmarkDiagnostics.QueryTimeRecorder; +import io.github.jbellis.jvector.graph.SearchResult; +import io.github.jbellis.jvector.graph.similarity.PreparedQuery; +import io.github.jbellis.jvector.graph.similarity.QueryPreparer; +import io.github.jbellis.jvector.util.BatchingExecutor; +import io.github.jbellis.jvector.vector.types.VectorFloat; +import org.agrona.collections.LongArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.concurrent.ArrayBlockingQueue; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Semaphore; +import java.util.concurrent.atomic.AtomicReference; +import java.util.concurrent.atomic.LongAdder; +import java.util.function.IntFunction; + +/** + * The queued execution policy used by the standard BenchYAML benchmarks. It + * supplies the same full passes and query sources as the calling benchmark; + * warmup, sampling, GC policy and reporting remain in those benchmarks. + */ +public final class QueuedQueryExecution implements AutoCloseable { + private static final QueryTimeRecorder NO_TIMING = nanos -> {}; + private final ConfiguredSystem system; + private final int topK, rerankK; + private final boolean usePruning; + private final QueryPreparer preparer; + private final ExecutorService workers; + private final BatchingExecutor execution; + private final ArrayBlockingQueue free; + private final LongArrayList measuredResponseTimes = new LongArrayList(); + private long[] initialHistogram; + private boolean measuring; + + private static final class Rows { + final VectorFloat[] queries; + final PreparedQuery[] states; + Rows(int count) { queries = new VectorFloat[count]; states = new PreparedQuery[count]; } + } + + private static final class Request { + final PreparedQuery state; + final Semaphore completed = new Semaphore(0); + Pass pass; + int ordinal; + long submittedAt; + VectorFloat query; + SearchResult result; + Throwable failure; + Request(PreparedQuery state) { this.state = state; } + } + + public static final class Pass { + final IntFunction> inputs; + final QueryTimeRecorder recorder; + final CountDownLatch completed; + final AtomicReference failure = new AtomicReference<>(); + final LongAdder visited = new LongAdder(); + final long[] responseTimes; + public final SearchResult[] results; + Pass(int count, IntFunction> inputs, QueryTimeRecorder recorder, boolean capture) { + this.inputs = inputs; this.recorder = recorder; + completed = new CountDownLatch(count); + responseTimes = new long[count]; + results = capture ? new SearchResult[count] : null; + } + public long visited() { return visited.sum(); } + } + + public QueuedQueryExecution(ConfiguredSystem system, int topK, int rerankK, boolean usePruning) { + this.system = system; this.topK = topK; this.rerankK = rerankK; this.usePruning = usePruning; + this.preparer = system.queryPreparer(); + int cores = system.queryWorkerCount(); + int batchSize = system.queryBatchSize(); + if (batchSize <= 0) throw new IllegalArgumentException("Benchmark query batch size must be positive"); + // One full default batch per worker bounds waiting while permitting + // genuine GEMM preparation. Keep the same admission limit for B=1 + // controls; larger explicitly selected batches remain supported. + int capacity = Math.multiplyExact(Math.max(8, batchSize), cores); + free = new ArrayBlockingQueue<>(capacity); + for (int i = 0; i < capacity; i++) free.add(new Request(preparer == null ? null : preparer.newState())); + var rows = ThreadLocal.withInitial(() -> new Rows(batchSize)); + workers = system.queryWorkers(); + execution = new BatchingExecutor<>(cores, capacity, batchSize, workers, + new BatchingExecutor.Operation() { + public Object batchKey(Request request) { + return preparer != null && preparer.supportsBatchPreparation() ? preparer : null; + } + public void prepareBatch(List requests) { + for (Request request : requests) + if (request.pass != null) request.query = request.pass.inputs.apply(request.ordinal); + if (preparer == null) return; + if (requests.size() == 1) { + var request = requests.get(0); + preparer.prepare(request.query, request.state); + return; + } + Rows buffers = rows.get(); + try { + for (int i = 0; i < requests.size(); i++) { + buffers.queries[i] = requests.get(i).query; + buffers.states[i] = requests.get(i).state; + } + preparer.prepareBatch(buffers.queries, buffers.states, requests.size()); + } finally { + Arrays.fill(buffers.queries, 0, requests.size(), null); + Arrays.fill(buffers.states, 0, requests.size(), null); + } + } + public void execute(Request request) { + request.result = request.state == null + ? QueryExecutor.executeQuery(system, topK, rerankK, usePruning, request.query) + : QueryExecutor.executePreparedQuery(system, topK, rerankK, usePruning, request.state); + } + public void complete(Request request, Throwable failure) { + long responseTime = System.nanoTime() - request.submittedAt; + request.failure = failure; + Pass pass = request.pass; + if (pass == null) { request.completed.release(); return; } + try { + if (failure == null) { + pass.visited.add(request.result.getVisitedCount()); + pass.responseTimes[request.ordinal] = responseTime; + pass.recorder.recordTime(responseTime); + if (pass.results != null) pass.results[request.ordinal] = request.result; + } else pass.failure.compareAndSet(null, failure); + } catch (Throwable error) { pass.failure.compareAndSet(null, error); } + finally { + release(request); + pass.completed.countDown(); + } + } + }); + System.out.printf("QUERY_EXECUTION mode=queued workers=%d capacity=%d max_batch_size=%d optimized_batch=%s%n", + cores, capacity, batchSize, preparer != null && preparer.supportsBatchPreparation()); + } + + private Request acquire() { + boolean interrupted = false; + for (;;) { + try { Request request = free.take(); if (interrupted) Thread.currentThread().interrupt(); return request; } + catch (InterruptedException e) { interrupted = true; } + } + } + + private void release(Request request) { + request.query = null; request.result = null; request.failure = null; request.pass = null; + free.add(request); + } + + public Pass runPass(int count, IntFunction> source, QueryTimeRecorder recorder, boolean capture) { + Pass pass = new Pass(count, source, recorder == null ? NO_TIMING : recorder, capture); + for (int i = 0; i < count; i++) { + long submittedAt = System.nanoTime(); // Includes waiting for admission. + Request request = acquire(); + request.pass = pass; request.ordinal = i; request.submittedAt = submittedAt; + if (!execution.trySubmit(request)) { release(request); throw new IllegalStateException("Admission accounting mismatch"); } + } + boolean interrupted = false; + for (;;) { + try { pass.completed.await(); break; } + catch (InterruptedException e) { interrupted = true; } + } + if (interrupted) Thread.currentThread().interrupt(); + rethrow(pass.failure.get()); + if (measuring) for (long time : pass.responseTimes) measuredResponseTimes.addLong(time); + return pass; + } + + /** One submitted request with normal queue/batch capacity; not a singleton setting. */ + public SearchResult executeSingle(VectorFloat query) { + long submittedAt = System.nanoTime(); + Request request = acquire(); + request.pass = null; request.query = query; request.submittedAt = submittedAt; + if (!execution.trySubmit(request)) { release(request); throw new IllegalStateException("Admission accounting mismatch"); } + request.completed.acquireUninterruptibly(); + try { rethrow(request.failure); return request.result; } + finally { release(request); } + } + + private static void rethrow(Throwable error) { + if (error instanceof Error) throw (Error) error; + if (error instanceof RuntimeException) throw (RuntimeException) error; + if (error != null) throw new RuntimeException("Queued query failed", error); + } + + public void beginMeasurements() { initialHistogram = execution.batchHistogram(); measuring = true; } + + /** Metrics are computed after QPS timers stop; response samples include admission and execution. */ + public List measuredMetrics() { + long[] times = measuredResponseTimes.toLongArray(); + Arrays.sort(times); + double sum = 0; for (long time : times) sum += time; + long[] histogram = execution.batchHistogram(); + long groups = 0, queries = 0; + for (int i = 1; i < histogram.length; i++) { + histogram[i] -= initialHistogram[i]; groups += histogram[i]; queries += i * histogram[i]; + } + double meanBatch = groups == 0 ? 0 : (double) queries / groups; + System.out.printf("QUERY_BATCHES queries=%d groups=%d mean=%.4f histogram=%s%n", queries, groups, meanBatch, Arrays.toString(histogram)); + return List.of( + Metric.of("search.execution.response_mean_ms", "Loaded mean (ms)", ".3f", times.length == 0 ? 0 : sum / times.length / 1e6), + Metric.of("search.execution.response_p95_ms", "Loaded p95 (ms)", ".3f", percentile(times, .95)), + Metric.of("search.execution.response_p99_ms", "Loaded p99 (ms)", ".3f", percentile(times, .99)), + Metric.of("search.execution.mean_batch_size", "Mean batch", ".3f", meanBatch)); + } + + private static double percentile(long[] sorted, double p) { + return sorted.length == 0 ? 0 : sorted[Math.min(sorted.length - 1, (int) Math.ceil(sorted.length * p) - 1)] / 1e6; + } + + @Override + public void close() { + execution.close(); // ConfiguredSystem owns the workers and their reusable search state. + } +} diff --git a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/benchmarks/ThroughputBenchmark.java b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/benchmarks/ThroughputBenchmark.java index 9f04e0c21..26563c28a 100644 --- a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/benchmarks/ThroughputBenchmark.java +++ b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/benchmarks/ThroughputBenchmark.java @@ -135,6 +135,7 @@ public List runBenchmark( boolean usePruning, int queryRuns) { + if (cs.usesQueuedExecution()) return runConfiguredBenchmark(cs, topK, rerankK, usePruning, queryRuns); int threads = Integer.getInteger("jvector.bench.queryThreads", 0); if (threads < 0) throw new IllegalArgumentException("queryThreads must be nonnegative"); System.out.printf("QUERY_CONCURRENCY threads=%s%n", threads == 0 ? "common-pool" : threads); @@ -149,6 +150,13 @@ public List runBenchmark( return runConfiguredBenchmark(cs, topK, rerankK, usePruning, queryRuns); } + private VectorFloat randomQuery(int dim) { + VectorFloat query = vts.createFloatVector(dim); + for (int j = 0; j < dim; j++) query.set(j, ThreadLocalRandom.current().nextFloat()); + VectorUtil.l2normalize(query); + return query; + } + private static IntStream queryStream(int count) { IntStream stream = IntStream.range(0, count); return Integer.getInteger("jvector.bench.queryThreads", 0) == 1 ? stream : stream.parallel(); @@ -156,25 +164,31 @@ private static IntStream queryStream(int count) { private List runConfiguredBenchmark(ConfiguredSystem cs, int topK, int rerankK, boolean usePruning, int queryRuns) { + + if (!(computeAvgQps || computeMedianQps || computeMaxQps)) { + throw new RuntimeException("At least one metric must be displayed"); + } + try (BenchmarkDiagnostics diagnostics = new BenchmarkDiagnostics(diagnosticLevel); + QueuedQueryExecution queued = cs.usesQueuedExecution() + ? new QueuedQueryExecution(cs, topK, rerankK, usePruning) : null) { if (Boolean.getBoolean("jvector.bench.verifyConcurrentResults")) { int count = cs.getDataSet().getQueryVectors().size(); SearchResult[] serial = new SearchResult[count]; for (int i = 0; i < count; i++) serial[i] = QueryExecutor.executeQuery(cs, topK, rerankK, usePruning, i); + SearchResult[] queuedResults = queued == null ? null : queued.runPass(count, + cs.getDataSet().getQueryVectors()::get, null, true).results; queryStream(count).forEach(i -> { - SearchResult actual = QueryExecutor.executeQuery(cs, topK, rerankK, usePruning, i); + SearchResult actual = queuedResults == null + ? QueryExecutor.executeQuery(cs, topK, rerankK, usePruning, i) : queuedResults[i]; if (!Arrays.equals(serial[i].getNodes(), actual.getNodes()) - || serial[i].getVisitedCount() != actual.getVisitedCount()) + || (queued == null && serial[i].getVisitedCount() != actual.getVisitedCount())) throw new IllegalStateException("Concurrent query mismatch at " + i); }); System.out.printf("CONCURRENCY_VALIDATION queries=%d topK=%d rerankK=%d status=PASS%n", count, topK, rerankK); } - if (!(computeAvgQps || computeMedianQps || computeMaxQps)) { - throw new RuntimeException("At least one metric must be displayed"); - } - try (BenchmarkDiagnostics diagnostics = new BenchmarkDiagnostics(diagnosticLevel)) { int totalQueries = cs.getDataSet().getQueryVectors().size(); int dim = cs.getDataSet().getDimension(); @@ -185,16 +199,14 @@ private List runConfiguredBenchmark(ConfiguredSystem cs, int topK, int r String warmupPhase = "Warmup-" + warmupRun; warmupQps[warmupRun] = diagnostics.monitorPhaseWithQueryTiming(warmupPhase, (recorder) -> { - queryStream(totalQueries) + if (queued != null) { + SINK += queued.runPass(totalQueries, k -> randomQuery(dim), recorder, false).visited(); + } else queryStream(totalQueries) .forEach(k -> { long queryStart = System.nanoTime(); // Generate a random vector - VectorFloat randQ = vts.createFloatVector(dim); - for (int j = 0; j < dim; j++) { - randQ.set(j, ThreadLocalRandom.current().nextFloat()); - } - VectorUtil.l2normalize(randQ); + VectorFloat randQ = randomQuery(dim); SearchResult sr = QueryExecutor.executeQuery( cs, topK, rerankK, usePruning, randQ); SINK += sr.getVisitedCount(); @@ -228,6 +240,7 @@ private List runConfiguredBenchmark(ConfiguredSystem cs, int topK, int r throw new IllegalArgumentException("throughputMinSeconds must be finite and nonnegative"); long minSampleNanos = (long)(minSampleSeconds * 1e9); double[] qpsSamples = new double[numTestRuns]; + if (queued != null) queued.beginMeasurements(); for (int testRun = 0; testRun < numTestRuns; testRun++) { String testPhase = "Test-" + testRun; @@ -250,7 +263,10 @@ private List runConfiguredBenchmark(ConfiguredSystem cs, int topK, int r long completedQueries = 0; do { - queryStream(totalQueries) + if (queued != null) { + visitedAdder.add(queued.runPass(totalQueries, + cs.getDataSet().getQueryVectors()::get, recorder, false).visited()); + } else queryStream(totalQueries) .forEach(i -> { long queryStart = System.nanoTime(); @@ -334,6 +350,7 @@ private List runConfiguredBenchmark(ConfiguredSystem cs, int topK, int r formatMaxQps, minQps)); } + if (queued != null) list.addAll(queued.measuredMetrics()); return list; } catch (IOException e) { diff --git a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/reporting/RunArtifacts.java b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/reporting/RunArtifacts.java index 5cb2ebcde..3e91d51b3 100644 --- a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/reporting/RunArtifacts.java +++ b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/reporting/RunArtifacts.java @@ -42,6 +42,23 @@ */ public final class RunArtifacts { + private String queryExecutionMode = "direct"; + private int queryBatchSize = 8; + + public String queryExecutionMode() { return queryExecutionMode; } + public int queryBatchSize() { return queryBatchSize; } + + private RunArtifacts withQueryExecution(RunConfig.QueryExecution settings) { + if (settings == null) return this; + if (!"direct".equals(settings.mode) && !"queued".equals(settings.mode)) + throw new IllegalArgumentException("queryExecution.mode must be direct or queued"); + if (settings.maxBatchSize <= 0) + throw new IllegalArgumentException("queryExecution.maxBatchSize must be positive"); + queryExecutionMode = settings.mode; + queryBatchSize = settings.maxBatchSize; + return this; + } + private final RunContext run; private final DatasetInfoWriter datasetInfoWriter; private final ExperimentsCsvWriter experimentsWriter; @@ -124,7 +141,7 @@ public static RunArtifacts open(RunConfig runCfg, List datasetConfi metricsToDisplay, benchmarksToLog, metricsToLog - ); + ).withQueryExecution(runCfg.queryExecution); } // Logging enabled => create run artifacts @@ -148,7 +165,7 @@ public static RunArtifacts open(RunConfig runCfg, List datasetConfi metricsToDisplay, benchmarksToLog, metricsToLog - ); + ).withQueryExecution(runCfg.queryExecution); } /** No-op artifacts instance for legacy callers (no sys_info/dataset_info/experiments output). */ diff --git a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/reporting/SearchReportingCatalog.java b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/reporting/SearchReportingCatalog.java index 9f9e4ae9a..48788d981 100644 --- a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/reporting/SearchReportingCatalog.java +++ b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/reporting/SearchReportingCatalog.java @@ -90,6 +90,12 @@ private static Map> namedMetricKeys() { "total_file_size_mb", "search.disk.total_file_size_mb", "file_count", "search.disk.file_count" ), + "execution", Map.of( + "response_mean_ms", "search.execution.response_mean_ms", + "response_p95_ms", "search.execution.response_p95_ms", + "response_p99_ms", "search.execution.response_p99_ms", + "mean_batch_size", "search.execution.mean_batch_size" + ), "construction", Map.of( "index_build_time_s","construction.index_build_time_s", "index_quant_time_s","@prefix:construction.index_quant_time_s", diff --git a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/yaml/RunConfig.java b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/yaml/RunConfig.java index 8711007aa..85bc639f5 100644 --- a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/yaml/RunConfig.java +++ b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/yaml/RunConfig.java @@ -41,6 +41,14 @@ public class RunConfig { public int yamlSchemaVersion; public int onDiskIndexVersion; + /** Query delivery policy; omitted settings preserve the synchronous benchmark. */ + public QueryExecution queryExecution = new QueryExecution(); + + public static class QueryExecution { + public String mode = "direct"; + public int maxBatchSize = 8; + } + /** Benchmark computation policy (what is computed). */ public Map> benchmarks; diff --git a/jvector-examples/src/test/java/io/github/jbellis/jvector/example/TestQueuedWorkerLifetime.java b/jvector-examples/src/test/java/io/github/jbellis/jvector/example/TestQueuedWorkerLifetime.java new file mode 100644 index 000000000..214b13825 --- /dev/null +++ b/jvector-examples/src/test/java/io/github/jbellis/jvector/example/TestQueuedWorkerLifetime.java @@ -0,0 +1,73 @@ +/* + * 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; + +import io.github.jbellis.jvector.example.benchmarks.datasets.SimpleDataSet; +import io.github.jbellis.jvector.util.BatchingExecutor; +import io.github.jbellis.jvector.vector.VectorSimilarityFunction; +import io.github.jbellis.jvector.vector.VectorizationProvider; +import org.junit.Test; +import java.util.List; +import java.util.Set; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; +import static org.junit.Assert.*; + +/** Warmup and later benchmark phases must use the same workers and thread-owned state. */ +public class TestQueuedWorkerLifetime { + @Test(timeout=20000) + public void workerStateSurvivesClosingAnExecutionPhase() throws Exception { + var vector = VectorizationProvider.getInstance().getVectorTypeSupport().createFloatVector(new float[]{1}); + var data = new SimpleDataSet("worker-lifetime", VectorSimilarityFunction.DOT_PRODUCT, + List.of(vector), List.of(vector), List.of(List.of(0))); + var system = new Grid.ConfiguredSystem(data, null, null, Set.of()); + var pool = system.queryWorkers(); + var scratch = new ThreadLocal(); + int count = system.queryWorkerCount(); + try { + for (int phase = 0; phase < 2; phase++) { + int expected = phase; + var entered = new CountDownLatch(count); + var completed = new CountDownLatch(count); + var failure = new AtomicReference(); + try (var queue = new BatchingExecutor(count, count, 1, pool, + new BatchingExecutor.Operation() { + public Object batchKey(Integer query) { return null; } + public void prepareBatch(List queries) {} + public void execute(Integer query) throws Exception { + entered.countDown(); + if (!entered.await(5, TimeUnit.SECONDS)) throw new IllegalStateException("Workers did not all start"); + if (expected == 0) scratch.set(1); + else assertEquals(Integer.valueOf(1), scratch.get()); + } + public void complete(Integer query, Throwable error) { + if (error != null) failure.compareAndSet(null, error); + completed.countDown(); + } + })) { + for (int i = 0; i < count; i++) assertTrue(queue.trySubmit(i)); + assertTrue(completed.await(10, TimeUnit.SECONDS)); + assertNull(failure.get()); + } + assertSame(pool, system.queryWorkers()); + assertFalse(pool.isShutdown()); + } + } finally { system.close(); } + assertTrue(pool.isTerminated()); + assertThrows(IllegalStateException.class, system::queryWorkers); + } +} diff --git a/jvector-examples/src/test/java/io/github/jbellis/jvector/example/benchmarks/QueryExecutionConfigTest.java b/jvector-examples/src/test/java/io/github/jbellis/jvector/example/benchmarks/QueryExecutionConfigTest.java new file mode 100644 index 000000000..447cea23b --- /dev/null +++ b/jvector-examples/src/test/java/io/github/jbellis/jvector/example/benchmarks/QueryExecutionConfigTest.java @@ -0,0 +1,54 @@ +/* + * 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.benchmarks; + +import io.github.jbellis.jvector.example.reporting.RunArtifacts; +import io.github.jbellis.jvector.example.reporting.SearchReportingCatalog; +import io.github.jbellis.jvector.example.yaml.RunConfig; +import org.junit.Test; +import org.junit.Rule; +import org.junit.rules.TemporaryFolder; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import static org.junit.Assert.*; + +public class QueryExecutionConfigTest { + @Rule public final TemporaryFolder directory = new TemporaryFolder(); + + @Test + public void defaultsPreserveDirectExecutionAndQueuedModeIsSelectedInYaml() throws Exception { + var file = directory.getRoot().toPath().resolve("run.yml"); + Files.writeString(file, "yamlSchemaVersion: 1\nqueryExecution:\n mode: queued\n maxBatchSize: 8\n"); + var config = RunConfig.load(file.toString()); + config.benchmarks = SearchReportingCatalog.defaultComputeBenchmarks(); + var artifacts = RunArtifacts.open(config, List.of()); + assertEquals("queued", artifacts.queryExecutionMode()); + assertEquals(8, artifacts.queryBatchSize()); + assertEquals("direct", RunArtifacts.disabled().queryExecutionMode()); + } + + @Test + public void invalidExecutionSettingsFailBeforeCollectingResults() { + var config = new RunConfig(); + config.benchmarks = SearchReportingCatalog.defaultComputeBenchmarks(); + config.queryExecution.mode = "unknown"; + assertThrows(IllegalArgumentException.class, () -> RunArtifacts.open(config, List.of())); + config.queryExecution.mode = "queued"; + config.queryExecution.maxBatchSize = 0; + assertThrows(IllegalArgumentException.class, () -> RunArtifacts.open(config, List.of())); + } +} From 5c9572625572f26b8da923a97d1c0e7a344283e3 Mon Sep 17 00:00:00 2001 From: Ted Willke Date: Tue, 3 Nov 2026 23:45:59 +0000 Subject: [PATCH 128/243] Document queued ASH POC and unresolved latency regression --- docs/ash-queued-query-poc.md | 159 +++++++++++++++++++++++++++++++++++ 1 file changed, 159 insertions(+) create mode 100644 docs/ash-queued-query-poc.md diff --git a/docs/ash-queued-query-poc.md b/docs/ash-queued-query-poc.md new file mode 100644 index 000000000..6c08134ce --- /dev/null +++ b/docs/ash-queued-query-poc.md @@ -0,0 +1,159 @@ +# Queued ASH query POC: review guide + +This branch preserves the experimental queued-query architecture for independent +review. It has a reproducible unloaded tail-latency regression and is not a +finished production recommendation. No Cassandra changes are included. + +## Base and scope + +The base is local `ash-dev` at `cd890861fe707e99775b67ff18b15ef62ab7fa55`. +That base already contains the native FP16 batch kernel (`85699a49`) and the +synchronous caller leader-follower implementation (`cd890861`). Review the new +POC with: + +```sh +git diff cd890861fe707e99775b67ff18b15ef62ab7fa55..ash-queued-query-poc +``` + +The existing synchronous search API and its caller-combining path remain +available. Explicitly prepared query batches call the same native FP16 kernel +directly, bypassing caller combining. Encoding and training remain FP32. + +## Execution and ownership + +1. A producer submits independently owned requests to `BatchingExecutor`. + Multiple requests can be outstanding without a blocked caller for each one. +2. Workers take currently available preparation requests sharing an identity + key. They do not wait for a batch-fill timer. +3. `ASHQueryPreparer` snapshots FP32 inputs and prepares projection, landmark + terms and LUTs into reusable `PreparedQuery` states. A formed batch uses + native FP16 projection with FP32 output/accumulation. +4. A single prepared request executes on its preparing worker. Larger groups + publish individual searches to a shared ready queue; any execution worker + may execute them. Each traversal exclusively owns its searcher and graph view. +5. Completion returns each result/error through the request's callback. There + is no requirement to return execution to the submitting thread. The + synchronous benchmark latency path does wait for its caller to resume. + +The generic executor uses caller-supplied execution capacity. BenchYAML supplies +one persistent worker per allocated physical core, shared across its benchmark +phases and overquery cases for a configured index. Default maximum batch size +is 8; total admission is `8 * cores` for batch-size-1 and batch-size-8 controls. +The implementation also permits larger explicitly selected batches. This is a +POC policy, not a completed production sizing study. + +A prepared state retains its input snapshot and numerical buffers, not a graph +view. Do not reuse it until all searches/resumable iterators borrowing it finish. +State accounting excludes the shared model. Keep index/model resources alive +until accepted requests finish, including requests whose clients timed out. +Close/drain the batching executor before shutting down its supplied workers. + +## Code map + +- `graph/similarity/PreparedQuery.java`, `QueryPreparer.java`: numerical state API. +- `quantization/ASHQueryPreparer.java`: input snapshots and numerical preparation. +- `quantization/AsymmetricHashing.java`: direct/supplied-batch projection entry points. +- `graph/ImmutableGraphIndex.java`, `graph/disk/OnDiskGraphIndex.java`: + preparer access and scoring with supplied preparation. +- `quantization/FusedASHDecoder.java`, `ASHNeighborhoodScorer.java`, + `graph/disk/feature/FusedASH.java`: reuse supplied projection/LUT in graph scoring. +- `util/BatchingExecutor.java`: admission, compatible preparation, ready searches, + completion, close/drain and batch counts. +- `example/benchmarks/QueuedQueryExecution.java`: standard BenchYAML queued mode. +- `example/Grid.java`: worker ownership and configured-index lifetime. + +## BenchYAML configuration + +Select the mode in the existing run YAML; use the usual dataset/index YAMLs: + +```yaml +queryExecution: + mode: queued + maxBatchSize: 8 +``` + +The default mode is `direct`. Setting `maxBatchSize: 1` is a no-batching control +with the same admission budget; it is not how isolated production traffic is +selected. LatencyBenchmark submits one active request even with maximum batch 8. +The mode uses the existing random warmups, full query passes, timing boundaries, +accuracy checks, and query parameters. There is no separate arrival adapter. + +Optional console/logging named metrics: + +```yaml +metrics: + execution: [response_mean_ms, response_p95_ms, response_p99_ms, mean_batch_size] +``` + +## Collected results and unresolved issue + +Latest tested source revision is the recorded POC v5. Performance uses JDK 22, +native defaults, oneDNN sequential runtime, CPU 0 or physical CPUs 0-7, NUMA node +0, saved 1M graphs, C=1 landmarks, 2-bit ASH, matched PQ code budgets and NVQ +reranking. Both 1x and 2x rerank overquery are measured. Numbers below are the +three-sample BenchYAML mean QPS from one process per case, not a confidence claim. + +| Dataset | Cores | Overquery | PQ QPS | Queued ASH QPS | +| --- | ---: | ---: | ---: | ---: | +| MPNet | 1 | 1x | 11004.60 | 13730.79 | +| MPNet | 1 | 2x | 8910.11 | 10229.72 | +| OpenAI3072 | 1 | 1x | 3592.86 | 5306.32 | +| OpenAI3072 | 1 | 2x | 3002.15 | 4205.70 | +| OpenAI3072 | 8 | 1x | 29439.43 | 41519.84 | +| OpenAI3072 | 8 | 2x | 24503.91 | 33684.40 | + +ASH recall is unchanged from the synchronous reference on these saved graphs: +MPNet 0.82012/0.93911 and OpenAI3072 0.7713/0.8979 at 1x/2x. Corresponding PQ +recalls are 0.65429/0.81957 and 0.6374/0.7960. MPNet eight-core, four-bit cases, +and other datasets are not collected for this final POC revision. + +The central unresolved regression is unloaded MPNet 1x latency: synchronous +mean 77.444 us and p99.9 0.137355 ms versus queued mean 92.844 us and p99.9 +2.826679 ms. At 2x, the means are 101.034/106.326 us and p99.9 values are +0.180007/0.173778 ms. Do not hide the 1x result by reporting only 2x or averages. + +Confirmed structural differences requiring investigation: + +- An isolated queued request has caller-to-worker dispatch and worker-to-caller + wakeup. There is no batch-fill delay and no ready-queue hop for size 1. +- Full batches use `prepareBatch`; isolated requests use `prepare`. Loaded + completion uses pass bookkeeping; isolated completion uses a semaphore. + Warm worker threads do not imply both paths/native workspaces are initialized + and compiled. This is a hypothesis, not an established cause of the regression. +- Loaded `response_*` timestamps are taken on the worker before completion + delivery. Unloaded latency includes the waiting caller resuming and returning. + These endpoints differ; loaded p99 and unloaded p99.9 are also distinct metrics. +- Pooling requests does not prove zero allocation in blocking-wait machinery. + +The worker-lifetime correction applies only to the new queued BenchYAML mode. +It did not resolve the tail regression and is not a general BenchYAML warmup fix. +Earlier JFR evidence on an older OpenAI POC showed compilation overlapping long +waits; it does not prove the cause of the latest MPNet regression. No change to +GraphSearcher visited-set behavior is part of this POC. + +## Validation and evidence + +The committed source matches the v5 source manifest used for 61 passing JDK 22 +tests: 13 native projection, 45 base/graph/queue, and 3 example/configuration tests. +The previous v4 passed 60 tests on JDK 22 and JDK 25; v5's additional persistent +worker-lifetime test has not yet been run under JDK 25. + +Relevant tests: `TestPreparedASHQuery`, `TestBatchingExecutor`, +`TestQueuedWorkerLifetime`, `QueryExecutionConfigTest`, +`NativeASHFP16ProjectionTest`, `TestASHQueryPreparation`, `TestASHGraphSearch`, +`TestASHMemoryAccounting`, and `TestCallerBatchProcessor`. + +Raw commands, YAML copies, CSVs, console logs, placement checks, source hashes, +and all earlier experiment results remain on the development server at: + +```text +/home/ted_willke/benchmark-runs/ash-queued-query-poc-20261103/ + poc-v5-source.json + workers-v5-tests-jdk22.log + gate-results.json + cases/*-rv5/command.json + cases/*-rv5/logging/*/experiments.csv +``` + +No new construction measurements are claimed for this query-only POC. Profiling +preparation is local and has not been applied as production instrumentation. From bbac8c66462eee20d69d6c822550434a3a79e0a9 Mon Sep 17 00:00:00 2001 From: Ted Willke Date: Wed, 4 Nov 2026 00:49:48 +0000 Subject: [PATCH 129/243] Unify queued ASH preparation and execute the first search locally --- .../jbellis/jvector/util/BatchingExecutor.java | 18 +++++++++--------- .../benchmarks/QueuedQueryExecution.java | 5 ----- 2 files changed, 9 insertions(+), 14 deletions(-) diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/util/BatchingExecutor.java b/jvector-base/src/main/java/io/github/jbellis/jvector/util/BatchingExecutor.java index bd1b62717..5eee12749 100644 --- a/jvector-base/src/main/java/io/github/jbellis/jvector/util/BatchingExecutor.java +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/util/BatchingExecutor.java @@ -178,15 +178,15 @@ public void run() { catch (Throwable error) { failure = error; } if (failure == null) { int size = slots.size(); - if (size == 1) { - Slot slot = slots.get(0); - try { operation.execute(slot.request); } - catch (Throwable error) { failure = error; } - finish(slot, failure); - } else { - for (Slot slot : slots) ready.add(slot); - work.release(size); - } + // Keep one search on the preparing worker for every + // group size. Singleton and full-batch traffic use the + // same execution path; other workers can take the rest. + Slot first = slots.get(0); + for (int i = 1; i < size; i++) ready.add(slots.get(i)); + if (size > 1) work.release(size - 1); + try { operation.execute(first.request); } + catch (Throwable error) { failure = error; } + finish(first, failure); } else { for (Slot slot : slots) finish(slot, failure); } diff --git a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/benchmarks/QueuedQueryExecution.java b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/benchmarks/QueuedQueryExecution.java index fa822ed62..7e7e4ec4c 100644 --- a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/benchmarks/QueuedQueryExecution.java +++ b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/benchmarks/QueuedQueryExecution.java @@ -109,11 +109,6 @@ public void prepareBatch(List requests) { for (Request request : requests) if (request.pass != null) request.query = request.pass.inputs.apply(request.ordinal); if (preparer == null) return; - if (requests.size() == 1) { - var request = requests.get(0); - preparer.prepare(request.query, request.state); - return; - } Rows buffers = rows.get(); try { for (int i = 0; i < requests.size(); i++) { From a18853dc8c5a09ea174e8cbe89061536e8ce21d9 Mon Sep 17 00:00:00 2001 From: Ted Willke Date: Wed, 4 Nov 2026 00:49:48 +0000 Subject: [PATCH 130/243] Defer queued latency reporting until query measurements finish --- .../github/jbellis/jvector/example/Grid.java | 15 ++++ .../example/benchmarks/QueryTester.java | 5 ++ .../benchmarks/ThroughputBenchmark.java | 2 +- .../example/TestDeferredQueryReporting.java | 69 +++++++++++++++++++ 4 files changed, 90 insertions(+), 1 deletion(-) create mode 100644 jvector-examples/src/test/java/io/github/jbellis/jvector/example/TestDeferredQueryReporting.java diff --git a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/Grid.java b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/Grid.java index c66bcdd7e..00d9b1e1b 100644 --- a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/Grid.java +++ b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/Grid.java @@ -1391,6 +1391,20 @@ public static class ConfiguredSystem implements AutoCloseable { private io.github.jbellis.jvector.graph.similarity.QueryPreparer queryPreparer; private java.util.concurrent.ExecutorService queuedQueryWorkers; private boolean closed; + private final List>> deferredQueryMetrics = new ArrayList<>(); + + /** Reporting work must not compile or allocate between timed query benchmarks. */ + public void deferQueryMetrics(Supplier> supplier) { + deferredQueryMetrics.add(Objects.requireNonNull(supplier)); + } + + public void appendDeferredQueryMetrics(List target) { + try { + for (var supplier : deferredQueryMetrics) target.addAll(supplier.get()); + } finally { deferredQueryMetrics.clear(); } + } + + public void discardDeferredQueryMetrics() { deferredQueryMetrics.clear(); } public int queryWorkerCount() { return Math.max(1, Math.min(Runtime.getRuntime().availableProcessors(), PhysicalCoreExecutor.getPhysicalCoreCount())); @@ -1456,6 +1470,7 @@ public DataSet getDataSet() { @Override public synchronized void close() throws Exception { closed = true; + deferredQueryMetrics.clear(); try { if (queuedQueryWorkers != null) { queuedQueryWorkers.shutdown(); diff --git a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/benchmarks/QueryTester.java b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/benchmarks/QueryTester.java index 7eb2d28a5..b4e7a9755 100644 --- a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/benchmarks/QueryTester.java +++ b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/benchmarks/QueryTester.java @@ -94,6 +94,9 @@ public List run( results.addAll(metrics); } + // Fold/sort queued response samples only after all query measurements. + cs.appendDeferredQueryMetrics(results); + // Capture memory and disk usage after running queries diagnostics.capturePostPhaseSnapshot("Query"); @@ -129,6 +132,8 @@ public List run( } catch (IOException e) { throw new RuntimeException(e); + } finally { + cs.discardDeferredQueryMetrics(); } return results; } diff --git a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/benchmarks/ThroughputBenchmark.java b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/benchmarks/ThroughputBenchmark.java index 26563c28a..734ecd932 100644 --- a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/benchmarks/ThroughputBenchmark.java +++ b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/benchmarks/ThroughputBenchmark.java @@ -350,7 +350,7 @@ private List runConfiguredBenchmark(ConfiguredSystem cs, int topK, int r formatMaxQps, minQps)); } - if (queued != null) list.addAll(queued.measuredMetrics()); + if (queued != null) cs.deferQueryMetrics(queued::measuredMetrics); return list; } catch (IOException e) { diff --git a/jvector-examples/src/test/java/io/github/jbellis/jvector/example/TestDeferredQueryReporting.java b/jvector-examples/src/test/java/io/github/jbellis/jvector/example/TestDeferredQueryReporting.java new file mode 100644 index 000000000..2d34459c0 --- /dev/null +++ b/jvector-examples/src/test/java/io/github/jbellis/jvector/example/TestDeferredQueryReporting.java @@ -0,0 +1,69 @@ +/* + * 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; + +import io.github.jbellis.jvector.example.benchmarks.*; +import io.github.jbellis.jvector.example.benchmarks.datasets.SimpleDataSet; +import io.github.jbellis.jvector.vector.VectorSimilarityFunction; +import io.github.jbellis.jvector.vector.VectorizationProvider; +import org.junit.Test; +import java.util.*; +import java.util.function.Function; +import static org.junit.Assert.*; + +public class TestDeferredQueryReporting { + private Grid.ConfiguredSystem system() { + var v = VectorizationProvider.getInstance().getVectorTypeSupport().createFloatVector(new float[]{1}); + return new Grid.ConfiguredSystem(new SimpleDataSet("deferred-report", VectorSimilarityFunction.DOT_PRODUCT, + List.of(v), List.of(v), List.of(List.of(0))), null, null, Set.of()); + } + private QueryBenchmark probe(String name, Function> operation) { + return new QueryBenchmark() { + public String getBenchmarkName() { return name; } + public List runBenchmark(Grid.ConfiguredSystem cs, int k, int r, boolean p, int runs) { + return operation.apply(cs); + } + }; + } + @Test + public void reportingRunsAfterEveryMeasurementAndRetainsItsValues() throws Exception { + var order = new ArrayList(); + try (var cs = system()) { + var producer = probe("throughput", c -> { + order.add("throughput"); + c.deferQueryMetrics(() -> { order.add("report"); return List.of(Metric.of("test.report", "report", ".1f", 7)); }); + return List.of(); + }); + var latency = probe("latency", c -> { assertFalse(order.contains("report")); order.add("latency"); return List.of(); }); + var accuracy = probe("accuracy", c -> { assertFalse(order.contains("report")); order.add("accuracy"); return List.of(); }); + var metrics = new QueryTester(List.of(producer, latency, accuracy)).run(cs, 1, 1, false, 1); + assertEquals(List.of("throughput", "latency", "accuracy", "report"), order); + assertEquals(7, metrics.stream().filter(m -> m.getKey().equals("test.report")).findFirst().orElseThrow().getValue(), 0); + var later = new ArrayList(); cs.appendDeferredQueryMetrics(later); assertTrue(later.isEmpty()); + } + } + @Test + public void failedMeasurementDiscardsPendingReporting() throws Exception { + var order = new ArrayList(); + try (var cs = system()) { + var producer = probe("throughput", c -> { c.deferQueryMetrics(() -> { order.add("report"); return List.of(); }); return List.of(); }); + var failure = probe("latency", c -> { throw new IllegalStateException("measurement failed"); }); + assertThrows(IllegalStateException.class, () -> new QueryTester(List.of(producer, failure)).run(cs, 1, 1, false, 1)); + var later = new ArrayList(); cs.appendDeferredQueryMetrics(later); + assertTrue(order.isEmpty()); assertTrue(later.isEmpty()); + } + } +} From e87c7dc53a1e51e47c6457cbfb8edb1f33666c38 Mon Sep 17 00:00:00 2001 From: Ted Willke Date: Wed, 4 Nov 2026 00:49:48 +0000 Subject: [PATCH 131/243] Document queued-query tail latency findings and focused fixes --- docs/ash-queued-query-poc.md | 70 ++++++++++++++++++++++++++++++++---- 1 file changed, 64 insertions(+), 6 deletions(-) diff --git a/docs/ash-queued-query-poc.md b/docs/ash-queued-query-poc.md index 6c08134ce..a6130dd4d 100644 --- a/docs/ash-queued-query-poc.md +++ b/docs/ash-queued-query-poc.md @@ -28,9 +28,9 @@ directly, bypassing caller combining. Encoding and training remain FP32. 3. `ASHQueryPreparer` snapshots FP32 inputs and prepares projection, landmark terms and LUTs into reusable `PreparedQuery` states. A formed batch uses native FP16 projection with FP32 output/accumulation. -4. A single prepared request executes on its preparing worker. Larger groups - publish individual searches to a shared ready queue; any execution worker - may execute them. Each traversal exclusively owns its searcher and graph view. +4. The preparing worker executes the first search for every group size. Other + prepared searches are published to a shared ready queue, where any execution + worker may take them. Each traversal exclusively owns its searcher and view. 5. Completion returns each result/error through the request's callback. There is no requirement to return execution to the submitting thread. The synchronous benchmark latency path does wait for its caller to resume. @@ -85,9 +85,10 @@ metrics: execution: [response_mean_ms, response_p95_ms, response_p99_ms, mean_batch_size] ``` -## Collected results and unresolved issue +## Initial POC measurements and the latency issue -Latest tested source revision is the recorded POC v5. Performance uses JDK 22, +The following table records the initial POC v5 baseline. Focused follow-up +changes and measurements are documented below. Performance uses JDK 22, native defaults, oneDNN sequential runtime, CPU 0 or physical CPUs 0-7, NUMA node 0, saved 1M graphs, C=1 landmarks, 2-bit ASH, matched PQ code budgets and NVQ reranking. Both 1x and 2x rerank overquery are measured. Numbers below are the @@ -133,7 +134,7 @@ GraphSearcher visited-set behavior is part of this POC. ## Validation and evidence -The committed source matches the v5 source manifest used for 61 passing JDK 22 +The initial v5 source matched its recorded manifest and passed 61 JDK 22 tests: 13 native projection, 45 base/graph/queue, and 3 example/configuration tests. The previous v4 passed 60 tests on JDK 22 and JDK 25; v5's additional persistent worker-lifetime test has not yet been run under JDK 25. @@ -157,3 +158,60 @@ and all earlier experiment results remain on the development server at: No new construction measurements are claimed for this query-only POC. Profiling preparation is local and has not been applied as production instrumentation. + + +## Focused tail-latency correction + +The follow-up patch keeps the preparation and first-search execution path the +same for singleton and full-batch traffic. `QueuedQueryExecution` uses +`prepareBatch` for all group sizes; the backend still selects its single-vector +kernel for count 1. `BatchingExecutor` publishes searches 2..N and runs the first +locally. Asynchronous submission remains asynchronous, and the CPU budget, +precision, admission policy, graph state and search parameters are unchanged. + +Queued response-sample aggregation is now deferred until `QueryTester` has +finished its query benchmarks. Previously that reporting calculation could +trigger compilation that continued into the subsequent latency measurement. +This correction preserves every query, latency sample and reporting statistic. +It affects the new queued mode, not the existing direct-mode warmup policy. + +The six-point request trace, JFR and CPU-0 scheduler recording showed that the +largest stalls were runnable worker CPU waits while C2 compiled code. One +13.137 ms request included 12.980 ms of worker run-queue delay; C2 occupied that +core for 12.813 ms. The unchanged published classes reproduced long waits +alongside compilation, so the timestamp overlay was not required for the issue. +The trace is diagnostic evidence, not replacement performance data. + +Three fresh unprofiled JDK 22 JVMs per flow were compared on MPNet 1M, 2-bit, +one physical core. Both flows below use the corrected reporting schedule: + +| Overquery | Original flow QPS, mean +/- SD | Unified flow QPS, mean +/- SD | Original unloaded p99.9, median [range] ms | Unified unloaded p99.9, median [range] ms | +| --- | ---: | ---: | ---: | ---: | +| 1x | 13585.66 +/- 182.00 | 13681.17 +/- 199.35 | 0.372607 [0.291989, 0.562090] | 0.211354 [0.144735, 0.230088] | +| 2x | 10245.88 +/- 104.74 | 10274.58 +/- 80.55 | 0.170740 [0.169270, 0.171266] | 0.169295 [0.167371, 0.170570] | + +Percentiles are medians/ranges of per-process percentiles, not pooled percentiles. +Recall remained exactly 0.82012/0.93911. QPS differences are small relative to run +variation. The earlier direct synchronous 1x reference was 77.444 us mean and +0.137355 ms p99.9; this patch does not establish complete unloaded-latency parity. + +The follow-up passed 34 relevant tests on each of JDK 22 and JDK 25, including +new reporting-order and failure-cleanup tests. Native kernels are unchanged. +The supplemental execution metrics are collected by the standard `QueryTester` +pipeline after the benchmarks finish; they are no longer calculated inline at +the end of the queued throughput benchmark. + +Evidence is retained under the development server's `tail-profile` directory: + +```text +/home/ted_willke/benchmark-runs/ash-queued-query-poc-20261103/tail-profile/ + focused-repeat-summary.json + flow-report-candidate/checks-jdk22.log + flow-report-candidate/checks-jdk25.log + cases/mpnet-2bit-C1-trace-01/stage-analysis.json + cases/mpnet-2bit-C1-original-profile-01/scheduler-latency.txt +``` + +The broader executor changes discussed in review, including removal of the slot +pool, short-lived drain tasks, callback failure policy and a CPU-budgeted +synchronous caller fast path, are not part of this focused patch. From 906f45a69ce5ecbfb8dd564e43176ada09ad9b54 Mon Sep 17 00:00:00 2001 From: Ted Willke Date: Wed, 4 Nov 2026 02:03:51 +0000 Subject: [PATCH 132/243] Preserve direct ASH projection for one-row query preparation --- .../quantization/ASHQueryPreparer.java | 8 +++- .../quantization/TestPreparedASHQuery.java | 47 +++++++++++++++++++ 2 files changed, 54 insertions(+), 1 deletion(-) diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHQueryPreparer.java b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHQueryPreparer.java index 3197649bb..dbf0ea8e9 100644 --- a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHQueryPreparer.java +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHQueryPreparer.java @@ -75,8 +75,14 @@ public void prepareBatch(VectorFloat[] queries, PreparedQuery[] preparedState Objects.requireNonNull(preparedStates, "preparedStates"); Objects.checkFromIndexSize(0, count, queries.length); Objects.checkFromIndexSize(0, count, preparedStates.length); - validateBatch(queries, preparedStates, count); if (count == 0) return; + if (count == 1) { + // Preserve each provider's direct kernel and avoid batch staging. + // prepare() validates before invalidating or overwriting the state. + prepare(queries[0], preparedStates[0]); + return; + } + validateBatch(queries, preparedStates, count); Rows buffers = rows.get(); buffers.ensure(count); // Invalidate all outputs before any computation can fail. diff --git a/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestPreparedASHQuery.java b/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestPreparedASHQuery.java index 74efcbe45..00a2cf9db 100644 --- a/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestPreparedASHQuery.java +++ b/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestPreparedASHQuery.java @@ -119,6 +119,53 @@ public void invalidBatchDoesNotOverwriteExistingStates() throws Exception { assertEquals(original, first.queryVector().get(0), 0f); } + + @Test + public void oneRowUsesDirectProjectionEvenWhenBackendHasNoSingleRowShortcut() throws Exception { + var values = inputs(17); + var ash = model(values, 2); + var preparer = new ASHQueryPreparer(ash); + preparer.supportsBatchPreparation(); // Initialize the immutable query view. + var field = AsymmetricHashing.StiefelTransform.class.getDeclaredField("queryProjection"); + field.setAccessible(true); + var original = (io.github.jbellis.jvector.vector.VectorUtilSupport.AshProjection) field.get(ash.stiefelTransform); + int[] calls = new int[2]; + var wrapper = new io.github.jbellis.jvector.vector.VectorUtilSupport.AshProjection() { + public void project(float[] input, float[] output) { + calls[0]++; + original.project(input, output); + } + public boolean supportsBatchProjection() { return true; } + public void projectBatch(float[][] input, float[][] output, int count) { + assertTrue("One-row requests must not enter backend batching", count > 1); + calls[1]++; + original.projectBatch(input, output, count); + } + public long ramBytesUsed() { return original.ramBytesUsed(); } + }; + var first = preparer.newState(); + var second = preparer.newState(); + field.set(ash.stiefelTransform, wrapper); + try { + preparer.prepareBatch(values, new PreparedQuery[]{first}, 0); + assertArrayEquals(new int[]{0, 0}, calls); + preparer.prepareBatch(values, new PreparedQuery[]{first}, 1); + assertArrayEquals(new int[]{1, 0}, calls); + var snapshot = ASHQueryPreparer.state(ash, first, true).prepared.qProj.clone(); + long generation = ASHQueryPreparer.state(ash, first, true).generation; + var bad = VectorizationProvider.getInstance().getVectorTypeSupport().createFloatVector(63); + assertThrows(IllegalArgumentException.class, + () -> preparer.prepareBatch(new VectorFloat[]{bad}, new PreparedQuery[]{first}, 1)); + assertEquals(generation, ASHQueryPreparer.state(ash, first, true).generation); + assertArrayEquals(snapshot, ASHQueryPreparer.state(ash, first, true).prepared.qProj, 0f); + assertArrayEquals(new int[]{1, 0}, calls); + preparer.prepareBatch(values, new PreparedQuery[]{first, second}, 2); + assertArrayEquals(new int[]{1, 1}, calls); + } finally { + field.set(ash.stiefelTransform, original); + } + } + @Test(timeout=30000) public void distinctStatesCanBePreparedAndHandedOffAcrossThreads() throws Exception { var values = inputs(97); From 0cbd1865a765acf83c02503be57c035936c41ceb Mon Sep 17 00:00:00 2001 From: Ted Willke Date: Wed, 4 Nov 2026 02:03:51 +0000 Subject: [PATCH 133/243] Scope queued benchmark reporting to completed measurement snapshots --- .../github/jbellis/jvector/example/Grid.java | 15 -- .../example/benchmarks/QueryBenchmark.java | 15 ++ .../example/benchmarks/QueryTester.java | 10 +- .../benchmarks/QueuedQueryExecution.java | 67 ++++++--- .../benchmarks/ThroughputBenchmark.java | 32 +++- .../example/TestDeferredQueryReporting.java | 137 +++++++++++++++--- 6 files changed, 215 insertions(+), 61 deletions(-) diff --git a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/Grid.java b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/Grid.java index 00d9b1e1b..c66bcdd7e 100644 --- a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/Grid.java +++ b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/Grid.java @@ -1391,20 +1391,6 @@ public static class ConfiguredSystem implements AutoCloseable { private io.github.jbellis.jvector.graph.similarity.QueryPreparer queryPreparer; private java.util.concurrent.ExecutorService queuedQueryWorkers; private boolean closed; - private final List>> deferredQueryMetrics = new ArrayList<>(); - - /** Reporting work must not compile or allocate between timed query benchmarks. */ - public void deferQueryMetrics(Supplier> supplier) { - deferredQueryMetrics.add(Objects.requireNonNull(supplier)); - } - - public void appendDeferredQueryMetrics(List target) { - try { - for (var supplier : deferredQueryMetrics) target.addAll(supplier.get()); - } finally { deferredQueryMetrics.clear(); } - } - - public void discardDeferredQueryMetrics() { deferredQueryMetrics.clear(); } public int queryWorkerCount() { return Math.max(1, Math.min(Runtime.getRuntime().availableProcessors(), PhysicalCoreExecutor.getPhysicalCoreCount())); @@ -1470,7 +1456,6 @@ public DataSet getDataSet() { @Override public synchronized void close() throws Exception { closed = true; - deferredQueryMetrics.clear(); try { if (queuedQueryWorkers != null) { queuedQueryWorkers.shutdown(); diff --git a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/benchmarks/QueryBenchmark.java b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/benchmarks/QueryBenchmark.java index c5039569b..a05fc3567 100644 --- a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/benchmarks/QueryBenchmark.java +++ b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/benchmarks/QueryBenchmark.java @@ -19,6 +19,8 @@ import io.github.jbellis.jvector.example.Grid.ConfiguredSystem; import java.util.List; +import java.util.function.Consumer; +import java.util.function.Supplier; /** * A common interface for all search benchmarks. @@ -33,6 +35,19 @@ List runBenchmark( boolean usePruning, int queryRuns ); + + /** + * Runs within a query-measurement group. The reporter belongs only to this + * invocation and must not be retained after this method returns. Suppliers + * must own completed samples, independently of any closed execution object. + * QueryTester evaluates them after all measurements in the group succeed. + * The original overload remains responsible for immediate standalone reports. + */ + default List runBenchmark( + ConfiguredSystem cs, int topK, int rerankK, boolean usePruning, + int queryRuns, Consumer>> deferredReports) { + return runBenchmark(cs, topK, rerankK, usePruning, queryRuns); + } } diff --git a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/benchmarks/QueryTester.java b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/benchmarks/QueryTester.java index b4e7a9755..44ffc7149 100644 --- a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/benchmarks/QueryTester.java +++ b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/benchmarks/QueryTester.java @@ -20,6 +20,7 @@ import java.nio.file.Path; import java.util.ArrayList; import java.util.List; +import java.util.function.Supplier; import io.github.jbellis.jvector.example.Grid.ConfiguredSystem; import io.github.jbellis.jvector.example.benchmarks.diagnostics.BenchmarkDiagnostics; @@ -77,6 +78,7 @@ public List run( int queryRuns) { List results = new ArrayList<>(); + List>> deferredReports = new ArrayList<>(); // Capture memory and disk usage before/after running queries // Use NONE level to suppress logging output that would break the table @@ -90,12 +92,13 @@ public List run( diagnostics.capturePrePhaseSnapshot("Query"); for (var benchmark : benchmarks) { - var metrics = benchmark.runBenchmark(cs, topK, rerankK, usePruning, queryRuns); + var metrics = benchmark.runBenchmark(cs, topK, rerankK, usePruning, queryRuns, deferredReports::add); results.addAll(metrics); } // Fold/sort queued response samples only after all query measurements. - cs.appendDeferredQueryMetrics(results); + for (var report : deferredReports) results.addAll(report.get()); + deferredReports.clear(); // Capture memory and disk usage after running queries diagnostics.capturePostPhaseSnapshot("Query"); @@ -133,7 +136,8 @@ public List run( } catch (IOException e) { throw new RuntimeException(e); } finally { - cs.discardDeferredQueryMetrics(); + // Failure discards only this invocation's unreported snapshots. + deferredReports.clear(); } return results; } diff --git a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/benchmarks/QueuedQueryExecution.java b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/benchmarks/QueuedQueryExecution.java index 7e7e4ec4c..efe9d3e8b 100644 --- a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/benchmarks/QueuedQueryExecution.java +++ b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/benchmarks/QueuedQueryExecution.java @@ -47,7 +47,7 @@ public final class QueuedQueryExecution implements AutoCloseable { private final ExecutorService workers; private final BatchingExecutor execution; private final ArrayBlockingQueue free; - private final LongArrayList measuredResponseTimes = new LongArrayList(); + private LongArrayList measuredResponseTimes = new LongArrayList(); private long[] initialHistogram; private boolean measuring; @@ -198,25 +198,58 @@ private static void rethrow(Throwable error) { if (error != null) throw new RuntimeException("Queued query failed", error); } - public void beginMeasurements() { initialHistogram = execution.batchHistogram(); measuring = true; } + public void beginMeasurements() { + if (measuring || measuredResponseTimes == null) + throw new IllegalStateException("Measurements already started or transferred"); + initialHistogram = execution.batchHistogram(); + measuring = true; + } - /** Metrics are computed after QPS timers stop; response samples include admission and execution. */ - public List measuredMetrics() { - long[] times = measuredResponseTimes.toLongArray(); - Arrays.sort(times); - double sum = 0; for (long time : times) sum += time; + /** + * Transfers completed samples without copying the large response buffer. + * Call after measured passes complete and before closing this execution. + * The returned value does not retain this executor, requests, or model. + */ + public Measurements finishMeasurements() { + if (!measuring) throw new IllegalStateException("Measurements are not active"); long[] histogram = execution.batchHistogram(); - long groups = 0, queries = 0; - for (int i = 1; i < histogram.length; i++) { - histogram[i] -= initialHistogram[i]; groups += histogram[i]; queries += i * histogram[i]; + for (int i = 1; i < histogram.length; i++) histogram[i] -= initialHistogram[i]; + var snapshot = new Measurements(measuredResponseTimes, histogram); + measuredResponseTimes = null; + measuring = false; + return snapshot; + } + + /** Completed samples, safe to aggregate after the execution has closed. */ + public static final class Measurements { + private final LongArrayList responseTimes; + private final long[] histogram; + + private Measurements(LongArrayList responseTimes, long[] histogram) { + this.responseTimes = responseTimes; + this.histogram = histogram; + } + + /** Sorting and formatting occur only when the owner requests reporting. */ + public List metrics() { + long[] times = responseTimes.toLongArray(); + Arrays.sort(times); + double sum = 0; + for (long time : times) sum += time; + long groups = 0, queries = 0; + for (int i = 1; i < histogram.length; i++) { + groups += histogram[i]; + queries += i * histogram[i]; + } + double meanBatch = groups == 0 ? 0 : (double) queries / groups; + System.out.printf("QUERY_BATCHES queries=%d groups=%d mean=%.4f histogram=%s%n", + queries, groups, meanBatch, Arrays.toString(histogram)); + return List.of( + Metric.of("search.execution.response_mean_ms", "Loaded mean (ms)", ".3f", times.length == 0 ? 0 : sum / times.length / 1e6), + Metric.of("search.execution.response_p95_ms", "Loaded p95 (ms)", ".3f", percentile(times, .95)), + Metric.of("search.execution.response_p99_ms", "Loaded p99 (ms)", ".3f", percentile(times, .99)), + Metric.of("search.execution.mean_batch_size", "Mean batch", ".3f", meanBatch)); } - double meanBatch = groups == 0 ? 0 : (double) queries / groups; - System.out.printf("QUERY_BATCHES queries=%d groups=%d mean=%.4f histogram=%s%n", queries, groups, meanBatch, Arrays.toString(histogram)); - return List.of( - Metric.of("search.execution.response_mean_ms", "Loaded mean (ms)", ".3f", times.length == 0 ? 0 : sum / times.length / 1e6), - Metric.of("search.execution.response_p95_ms", "Loaded p95 (ms)", ".3f", percentile(times, .95)), - Metric.of("search.execution.response_p99_ms", "Loaded p99 (ms)", ".3f", percentile(times, .99)), - Metric.of("search.execution.mean_batch_size", "Mean batch", ".3f", meanBatch)); } private static double percentile(long[] sorted, double p) { diff --git a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/benchmarks/ThroughputBenchmark.java b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/benchmarks/ThroughputBenchmark.java index 734ecd932..d09cc5634 100644 --- a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/benchmarks/ThroughputBenchmark.java +++ b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/benchmarks/ThroughputBenchmark.java @@ -29,6 +29,9 @@ import java.util.ArrayList; import java.util.Arrays; import java.util.List; +import java.util.Objects; +import java.util.function.Consumer; +import java.util.function.Supplier; import java.util.concurrent.ThreadLocalRandom; import java.util.concurrent.atomic.LongAdder; import java.util.stream.IntStream; @@ -135,19 +138,33 @@ public List runBenchmark( boolean usePruning, int queryRuns) { - if (cs.usesQueuedExecution()) return runConfiguredBenchmark(cs, topK, rerankK, usePruning, queryRuns); + return runWithReporting(cs, topK, rerankK, usePruning, queryRuns, null); + } + + @Override + public List runBenchmark( + ConfiguredSystem cs, int topK, int rerankK, boolean usePruning, + int queryRuns, Consumer>> deferredReports) { + return runWithReporting(cs, topK, rerankK, usePruning, queryRuns, + Objects.requireNonNull(deferredReports, "deferredReports")); + } + + private List runWithReporting( + ConfiguredSystem cs, int topK, int rerankK, boolean usePruning, + int queryRuns, Consumer>> deferredReports) { + if (cs.usesQueuedExecution()) return runConfiguredBenchmark(cs, topK, rerankK, usePruning, queryRuns, deferredReports); int threads = Integer.getInteger("jvector.bench.queryThreads", 0); if (threads < 0) throw new IllegalArgumentException("queryThreads must be nonnegative"); System.out.printf("QUERY_CONCURRENCY threads=%s%n", threads == 0 ? "common-pool" : threads); if (threads > 1) { var pool = new java.util.concurrent.ForkJoinPool(threads); try { - return pool.submit(() -> runConfiguredBenchmark(cs, topK, rerankK, usePruning, queryRuns)).join(); + return pool.submit(() -> runConfiguredBenchmark(cs, topK, rerankK, usePruning, queryRuns, deferredReports)).join(); } finally { pool.shutdown(); } } - return runConfiguredBenchmark(cs, topK, rerankK, usePruning, queryRuns); + return runConfiguredBenchmark(cs, topK, rerankK, usePruning, queryRuns, deferredReports); } private VectorFloat randomQuery(int dim) { @@ -163,7 +180,8 @@ private static IntStream queryStream(int count) { } private List runConfiguredBenchmark(ConfiguredSystem cs, int topK, int rerankK, - boolean usePruning, int queryRuns) { + boolean usePruning, int queryRuns, + Consumer>> deferredReports) { if (!(computeAvgQps || computeMedianQps || computeMaxQps)) { throw new RuntimeException("At least one metric must be displayed"); @@ -350,7 +368,11 @@ private List runConfiguredBenchmark(ConfiguredSystem cs, int topK, int r formatMaxQps, minQps)); } - if (queued != null) cs.deferQueryMetrics(queued::measuredMetrics); + if (queued != null) { + var samples = queued.finishMeasurements(); + if (deferredReports == null) list.addAll(samples.metrics()); + else deferredReports.accept(samples::metrics); + } return list; } catch (IOException e) { diff --git a/jvector-examples/src/test/java/io/github/jbellis/jvector/example/TestDeferredQueryReporting.java b/jvector-examples/src/test/java/io/github/jbellis/jvector/example/TestDeferredQueryReporting.java index 2d34459c0..e53069430 100644 --- a/jvector-examples/src/test/java/io/github/jbellis/jvector/example/TestDeferredQueryReporting.java +++ b/jvector-examples/src/test/java/io/github/jbellis/jvector/example/TestDeferredQueryReporting.java @@ -15,55 +15,150 @@ */ package io.github.jbellis.jvector.example; -import io.github.jbellis.jvector.example.benchmarks.*; +import io.github.jbellis.jvector.example.benchmarks.Metric; +import io.github.jbellis.jvector.example.benchmarks.QueryBenchmark; +import io.github.jbellis.jvector.example.benchmarks.QueryTester; +import io.github.jbellis.jvector.example.benchmarks.QueuedQueryExecution; import io.github.jbellis.jvector.example.benchmarks.datasets.SimpleDataSet; import io.github.jbellis.jvector.vector.VectorSimilarityFunction; import io.github.jbellis.jvector.vector.VectorizationProvider; import org.junit.Test; -import java.util.*; -import java.util.function.Function; + +import java.util.ArrayList; +import java.util.List; +import java.util.Set; +import java.util.function.BiFunction; +import java.util.function.Consumer; +import java.util.function.Supplier; + import static org.junit.Assert.*; +/** Reporting belongs to one measurement invocation and owns completed samples. */ public class TestDeferredQueryReporting { private Grid.ConfiguredSystem system() { - var v = VectorizationProvider.getInstance().getVectorTypeSupport().createFloatVector(new float[]{1}); - return new Grid.ConfiguredSystem(new SimpleDataSet("deferred-report", VectorSimilarityFunction.DOT_PRODUCT, - List.of(v), List.of(v), List.of(List.of(0))), null, null, Set.of()); + var vector = VectorizationProvider.getInstance().getVectorTypeSupport() + .createFloatVector(new float[]{1}); + var data = new SimpleDataSet("deferred-report", VectorSimilarityFunction.DOT_PRODUCT, + List.of(vector), List.of(vector), List.of(List.of(0))); + return new Grid.ConfiguredSystem(data, null, null, Set.of()) { + @Override public int queryWorkerCount() { return 1; } + }; } - private QueryBenchmark probe(String name, Function> operation) { + + private QueryBenchmark probe(String name, + BiFunction>>, List> operation) { return new QueryBenchmark() { public String getBenchmarkName() { return name; } - public List runBenchmark(Grid.ConfiguredSystem cs, int k, int r, boolean p, int runs) { - return operation.apply(cs); + + public List runBenchmark(Grid.ConfiguredSystem cs, int k, int r, + boolean pruning, int runs) { + var reports = new ArrayList>>(); + var result = new ArrayList<>(operation.apply(cs, reports::add)); + for (var report : reports) result.addAll(report.get()); + return result; + } + + public List runBenchmark(Grid.ConfiguredSystem cs, int k, int r, + boolean pruning, int runs, Consumer>> reports) { + return operation.apply(cs, reports); } }; } + + private static Metric report() { + return Metric.of("test.report", "report", ".1f", 7); + } + @Test public void reportingRunsAfterEveryMeasurementAndRetainsItsValues() throws Exception { var order = new ArrayList(); try (var cs = system()) { - var producer = probe("throughput", c -> { + var producer = probe("throughput", (c, reports) -> { order.add("throughput"); - c.deferQueryMetrics(() -> { order.add("report"); return List.of(Metric.of("test.report", "report", ".1f", 7)); }); + reports.accept(() -> { order.add("report"); return List.of(report()); }); return List.of(); }); - var latency = probe("latency", c -> { assertFalse(order.contains("report")); order.add("latency"); return List.of(); }); - var accuracy = probe("accuracy", c -> { assertFalse(order.contains("report")); order.add("accuracy"); return List.of(); }); + var latency = probe("latency", (c, reports) -> { + assertFalse(order.contains("report")); order.add("latency"); return List.of(); + }); + var accuracy = probe("accuracy", (c, reports) -> { + assertFalse(order.contains("report")); order.add("accuracy"); return List.of(); + }); var metrics = new QueryTester(List.of(producer, latency, accuracy)).run(cs, 1, 1, false, 1); assertEquals(List.of("throughput", "latency", "accuracy", "report"), order); - assertEquals(7, metrics.stream().filter(m -> m.getKey().equals("test.report")).findFirst().orElseThrow().getValue(), 0); - var later = new ArrayList(); cs.appendDeferredQueryMetrics(later); assertTrue(later.isEmpty()); + assertEquals(1, metrics.stream().filter(m -> m.getKey().equals("test.report")).count()); + assertEquals(7, metrics.stream().filter(m -> m.getKey().equals("test.report")) + .findFirst().orElseThrow().getValue(), 0); + assertTrue(new QueryTester(List.of()).run(cs, 1, 1, false, 1).stream() + .noneMatch(m -> m.getKey().equals("test.report"))); } } + @Test - public void failedMeasurementDiscardsPendingReporting() throws Exception { + public void failedMeasurementCannotLeakReportingIntoTheNextInvocation() throws Exception { var order = new ArrayList(); try (var cs = system()) { - var producer = probe("throughput", c -> { c.deferQueryMetrics(() -> { order.add("report"); return List.of(); }); return List.of(); }); - var failure = probe("latency", c -> { throw new IllegalStateException("measurement failed"); }); - assertThrows(IllegalStateException.class, () -> new QueryTester(List.of(producer, failure)).run(cs, 1, 1, false, 1)); - var later = new ArrayList(); cs.appendDeferredQueryMetrics(later); - assertTrue(order.isEmpty()); assertTrue(later.isEmpty()); + var producer = probe("throughput", (c, reports) -> { + reports.accept(() -> { order.add("report"); return List.of(report()); }); + return List.of(); + }); + var failure = probe("latency", (c, reports) -> { + throw new IllegalStateException("measurement failed"); + }); + assertThrows(IllegalStateException.class, + () -> new QueryTester(List.of(producer, failure)).run(cs, 1, 1, false, 1)); + var later = new QueryTester(List.of()).run(cs, 1, 1, false, 1); + assertTrue(order.isEmpty()); + assertTrue(later.stream().noneMatch(m -> m.getKey().equals("test.report"))); + } + } + + @Test + public void standaloneReportingDoesNotAttachStateToTheConfiguredSystem() throws Exception { + try (var cs = system()) { + var benchmark = probe("standalone", (c, reports) -> { + reports.accept(() -> List.of(report())); + return List.of(); + }); + assertEquals(1, benchmark.runBenchmark(cs, 1, 1, false, 1).size()); + assertTrue(new QueryTester(List.of()).run(cs, 1, 1, false, 1).stream() + .noneMatch(m -> m.getKey().equals("test.report"))); + } + } + + @Test + public void legacyBenchmarkOverloadStillRunsInQueryTester() throws Exception { + try (var cs = system()) { + var benchmark = new QueryBenchmark() { + public String getBenchmarkName() { return "legacy"; } + public List runBenchmark(Grid.ConfiguredSystem system, int k, int r, + boolean pruning, int runs) { return List.of(report()); } + }; + assertTrue(new QueryTester(List.of(benchmark)).run(cs, 1, 1, false, 1).stream() + .anyMatch(m -> m.getKey().equals("test.report"))); + } + } + + @Test + public void completedSnapshotSurvivesExecutorCloseAndCanOnlyBeTransferredOnce() throws Exception { + try (var cs = system()) { + QueuedQueryExecution.Measurements samples; + try (var queued = new QueuedQueryExecution(cs, 1, 1, false)) { + assertThrows(IllegalStateException.class, queued::finishMeasurements); + queued.beginMeasurements(); + assertThrows(IllegalStateException.class, queued::beginMeasurements); + queued.runPass(0, i -> { throw new AssertionError("Empty pass fetched a query"); }, null, false); + samples = queued.finishMeasurements(); + assertThrows(IllegalStateException.class, queued::finishMeasurements); + assertThrows(IllegalStateException.class, queued::beginMeasurements); + } + var first = samples.metrics(); + var second = samples.metrics(); + assertEquals(4, first.size()); + for (int i = 0; i < first.size(); i++) { + assertEquals(0, first.get(i).getValue(), 0); + assertEquals(first.get(i).getValue(), second.get(i).getValue(), 0); + } } } } From 3e614cd0677111e4aeb164d5a3f0598a4bc660d9 Mon Sep 17 00:00:00 2001 From: Ted Willke Date: Wed, 4 Nov 2026 02:03:51 +0000 Subject: [PATCH 134/243] Record matched query measurements and reporting limitations --- docs/ash-queued-query-poc.md | 97 +++++++++++++++++++++++++++++++++++- 1 file changed, 95 insertions(+), 2 deletions(-) diff --git a/docs/ash-queued-query-poc.md b/docs/ash-queued-query-poc.md index a6130dd4d..f997de616 100644 --- a/docs/ash-queued-query-poc.md +++ b/docs/ash-queued-query-poc.md @@ -164,8 +164,9 @@ preparation is local and has not been applied as production instrumentation. The follow-up patch keeps the preparation and first-search execution path the same for singleton and full-batch traffic. `QueuedQueryExecution` uses -`prepareBatch` for all group sizes; the backend still selects its single-vector -kernel for count 1. `BatchingExecutor` publishes searches 2..N and runs the first +`prepareBatch` for all group sizes. The latest review correction makes +`ASHQueryPreparer` select direct preparation for count 1 on every backend, +including native FP32. Larger groups use the backend's batch projection. `BatchingExecutor` publishes searches 2..N and runs the first locally. Asynchronous submission remains asynchronous, and the CPU budget, precision, admission policy, graph state and search parameters are unchanged. @@ -175,6 +176,11 @@ trigger compilation that continued into the subsequent latency measurement. This correction preserves every query, latency sample and reporting statistic. It affects the new queued mode, not the existing direct-mode warmup policy. +Deferral removes an observed trigger for compilation interference; it does not +guarantee that compilation will stay out of timed work. Both direct and queued +execution can still experience late compilation. No compilation-quiescence gate, +different warmup queries, or new compiler flags are introduced by this patch. + The six-point request trace, JFR and CPU-0 scheduler recording showed that the largest stalls were runnable worker CPU waits while C2 compiled code. One 13.137 ms request included 12.980 ms of worker run-queue delay; C2 occupied that @@ -215,3 +221,90 @@ Evidence is retained under the development server's `tail-profile` directory: The broader executor changes discussed in review, including removal of the slot pool, short-lived drain tasks, callback failure policy and a CPU-budgeted synchronous caller fast path, are not part of this focused patch. + + +## Review corrections and matched direct comparison + +The review correction routes a one-row `ASHQueryPreparer.prepareBatch` call to +`prepare` after checking the array bounds. Validation still precedes state +invalidation. Every projection provider therefore uses its direct kernel without +batch row-reference staging; larger groups retain batched preparation. A dispatch +test wraps a backend without its own count-1 shortcut to verify this behavior. + +Deferred reports now belong to a local list in one `QueryTester.run` invocation, +not to `ConfiguredSystem`. Standalone throughput calls return their supplementary +metrics immediately. Before an execution closes, it transfers ownership of its +completed response buffer and snapshots the histogram. The later report owns +only those samples, without retaining or reading the closed executor. Transfer +does not copy the large sample buffer; its existing sort/copy is deferred until +reporting. Failed measurements discard that invocation's pending reports. + +The source compiled successfully and passed 38 focused tests in each of four +combinations: JDK 22 and JDK 25, each using native default query precision and +explicit FP32. The tests cover one-row dispatch and validation, prepared states, +graph searches, queue behavior, caller combining, worker lifetime, reporting +order, failure cleanup, standalone reporting, legacy benchmark compatibility, +and reporting a completed snapshot after executor closure. Native kernels, +training, encoding, graph layout and visited-set behavior are unchanged. + +The fresh performance comparison uses ordinary BenchYAML on JDK 22, MPNet 1M, +2-bit ASH, one physical core (CPU 0), NUMA node 0, and the same cached index. +The existing benchmark warmups, queries, timers, latency endpoints and recall +checks are unchanged. Direct throughput uses four synchronous caller threads; +queued throughput uses one execution worker, maximum batch 8 and admission 8. +Both are restricted to the same CPU. Unloaded latency submits one query at a +time. Three fresh JVMs per arm ran sequentially in D/Q/Q/D/D/Q order, with idle +checks before each launch and without profiling. The historical single-JVM +direct reference is not used in this matched table. + +| Overquery | Direct QPS, mean +/- SD | Queued QPS, mean +/- SD | Direct unloaded mean +/- SD (us) | Queued unloaded mean +/- SD (us) | Direct unloaded p99.9, median [range] ms | Queued unloaded p99.9, median [range] ms | Recall@10, both | +| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | +| 1x | 12725.60 +/- 52.65 | 13469.67 +/- 23.27 | 78.34 +/- 0.20 | 83.91 +/- 0.24 | 0.140633 [0.134897, 0.141831] | 0.242624 [0.241883, 0.290886] | 0.82012 | +| 2x | 9664.89 +/- 31.14 | 10167.81 +/- 120.36 | 102.20 +/- 0.21 | 105.70 +/- 0.80 | 0.175723 [0.174020, 0.182396] | 0.172512 [0.170874, 0.174481] | 0.93911 | + +Means and sample standard deviations describe the three per-JVM measurements. +Tail values are medians and ranges of per-JVM percentiles, not pooled percentiles. + +Queued bounded-backlog response measurements are reported separately: + +| Overquery | Loaded mean +/- SD (ms) | Loaded p95, median [range] ms | Loaded p99, median [range] ms | Mean batch size | +| --- | ---: | ---: | ---: | ---: | +| 1x | 0.664472 +/- 0.001166 | 0.732305 [0.731926, 0.736801] | 0.778456 [0.773166, 0.779190] | 8.0000 | +| 2x | 0.882424 +/- 0.010087 | 0.974059 [0.958689, 0.974725] | 1.019129 [1.002839, 1.020753] | 7.9996 | + +At 1x, queued QPS is 5.85% higher and unloaded mean latency is 5.58 us higher. +At 2x, queued QPS is 5.20% higher and unloaded mean latency is 3.49 us higher. + +This establishes the remaining unloaded cost under a matched protocol. It does +not identify the fraction due to thread handoffs, compilation or other work, +nor show that a synchronous caller fast path would eliminate the entire gap. +Loaded response samples end on the worker before completion delivery; unloaded +latency includes return to the caller. The loaded experiment measures bounded +backlog, not a controlled arrival-rate or Poisson latency curve. + +Reproducibility: the measured Java changes are recorded in +`tail-profile/review-readiness/manifest.json` and `review-fixes.patch`, based on +`46dc0543`. The YAML templates retain a legacy descriptive `jvectorRef` label; +the checked source hashes, rather than that inherited label, identify this run. +The measurements use the normally compiled repository classes, with no overlay +classpath, diagnostic timestamps or custom query harness. + +Evidence under the same development-server experiment root: + +```text +tail-profile/review-readiness/ + validation-passed.json + validation-commands.json + tests-jdk22-auto.log + tests-jdk22-fp32.log + tests-jdk25-auto.log + tests-jdk25-fp32.log + benchmark-driver.log + matched-1.log ... matched-6.log + matched-summary.json +cases/*-rreview-matched-*/ + command.json + placement.jsonl + summary.json + logging/*/experiments.csv +``` From 9c9e590efd356d2e8d8c62e42cf71fde76847af3 Mon Sep 17 00:00:00 2001 From: Ted Willke Date: Wed, 4 Nov 2026 03:18:08 +0000 Subject: [PATCH 135/243] Harden bounded batch execution for shared host executors --- .../jvector/util/BatchingExecutor.java | 460 ++++++++++++------ .../jvector/util/TestBatchingExecutor.java | 288 ++++++++++- 2 files changed, 588 insertions(+), 160 deletions(-) diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/util/BatchingExecutor.java b/jvector-base/src/main/java/io/github/jbellis/jvector/util/BatchingExecutor.java index 5eee12749..fa0fa0952 100644 --- a/jvector-base/src/main/java/io/github/jbellis/jvector/util/BatchingExecutor.java +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/util/BatchingExecutor.java @@ -15,228 +15,380 @@ */ package io.github.jbellis.jvector.util; +import java.util.ArrayDeque; import java.util.ArrayList; import java.util.Collections; import java.util.List; import java.util.Objects; -import java.util.concurrent.ArrayBlockingQueue; -import java.util.concurrent.CountDownLatch; import java.util.concurrent.Executor; import java.util.concurrent.RejectedExecutionException; -import java.util.concurrent.Semaphore; +import java.util.concurrent.ThreadPoolExecutor; import java.util.concurrent.atomic.AtomicLong; +import java.util.concurrent.atomic.AtomicLongArray; import java.util.concurrent.atomic.AtomicReference; +import java.util.concurrent.locks.Condition; import java.util.concurrent.locks.ReentrantLock; /** - * Bounded two-stage execution on a caller-supplied executor. Ready work is - * prepared in groups without a batch-fill delay, then each request executes - * independently. A worker can execute requests prepared by another worker. + * Bounded asynchronous execution with optional preparation of compatible requests + * in batches. Batches contain only requests already waiting; there is no fill timer. + * Prepared work takes priority, and the preparing worker executes its first request + * while other workers may execute the rest. * - *

    Exactly {@code workers} long-lived tasks are submitted to the executor; it - * must have that much available execution capacity. This class creates no - * threads and does not shut down the supplied executor. Use one instance for a - * shared CPU budget rather than reserving worker sets for every model. + *

    The supplied executor must schedule asynchronously, reject explicitly when it + * cannot accept work, and remain operational until this service closes. Direct + * executors, caller-runs rejection policies and silent-discard policies are not + * supported. At most {@code workerCount} reusable drain tasks are submitted at a + * time. Each exits when the queues are empty, releasing shared execution capacity. + * This class creates no threads and does not shut down the supplied executor. * - *

    Queue records, batch lists and row views are allocated at construction. - * Each accepted request is handed back through its completion callback, which - * may recycle it after its last use. No request is discarded on close. Close drains accepted - * work and must precede shutting down the supplied executor. + *

    Requests and their buffers remain caller-owned. An accepted request must not + * be modified or submitted again until its completion callback. Rejection at the + * admission limit leaves ownership with the caller. Accepted work completes once, + * including preparation or dispatch failure. Completion may run on a submitting + * thread if the executor rejects dispatch; successful work never runs inline. + * Queue arrays, worker runnables and batch lists are allocated at construction. + * + *

    We do not record load times. If a cached compressor is loaded, no compute metric is emitted * (value remains {@code null} and will be blank in CSV/console).

    * - * @param cpSupplier supplies dataset-specific compressor parameters (YAML-derived) + * @param cp dataset-specific compressor parameters (YAML-derived) * @param ds dataset being benchmarked * @param metrics per-run construction metrics sink (may be null) * @param phase which phase to attribute compute time to (index construction vs search-time setup) * @param quantType YAML quantization type label (e.g., PQ/BQ); if null, no quant metric is recorded */ - private static VectorCompressor getCompressor(Function cpSupplier, + private static VectorCompressor getCompressor(CompressorParameters cp, DataSet ds, ConstructionMetrics metrics, Phase phase, String quantType) { - var cp = cpSupplier.apply(ds); // Core compute and save logic Supplier> computeAndSaveAction = () -> { @@ -627,7 +710,7 @@ private static VectorCompressor getCompressor(Function { - Path path = Paths.get(pqCacheDir).resolve(fname); + Path path = Paths.get(compressorCacheDir).resolve(fname); if (Files.exists(path)) { return loadFromCache(ds, cp, fname, path); } @@ -635,6 +718,19 @@ private static VectorCompressor getCompressor(Function compressor, RandomAccessVectorValues vectors, + ConstructionMetrics metrics) { + if (compressor == null) return null; + String quantType = compressor instanceof AsymmetricHashing ? "ASH" + : compressor instanceof ProductQuantization ? "PQ" : "BQ"; + return metrics.index(quantType).timeEncode(() -> compressor.encodeAll(vectors)); + } + // Load/save helpers for getCompressor private static VectorCompressor loadFromCache(DataSet ds, CompressorParameters cp, String fname, Path path) { try (var readerSupplier = ReaderSupplierFactory.open(path); @@ -650,7 +746,7 @@ private static VectorCompressor loadFromCache(DataSet ds, CompressorParameter private static void saveToCache(String fname, VectorCompressor compressor) { try { - Path path = Paths.get(pqCacheDir).resolve(fname); + Path path = Paths.get(compressorCacheDir).resolve(fname); Files.createDirectories(path.getParent()); try (var writer = new BufferedRandomAccessWriter(path)) { compressor.write(writer, OnDiskGraphIndex.CURRENT_VERSION); @@ -667,6 +763,8 @@ public static class ConfiguredSystem implements AutoCloseable { Set features; private final Supplier exactVectors; private final boolean scoresFromIndex; + private final String rerankingType; + private final NVQVectors memoryReranker; private boolean queuedExecution; private int queryBatchSize = 8; private io.github.jbellis.jvector.graph.similarity.QueryPreparer queryPreparer; @@ -674,7 +772,7 @@ public static class ConfiguredSystem implements AutoCloseable { private boolean closed; public int queryWorkerCount() { - return Math.max(1, Math.min(Runtime.getRuntime().availableProcessors(), PhysicalCoreExecutor.getPhysicalCoreCount())); + return queryWorkerCountForMachine(); } /** Shared across this configured index's warmups and all query measurements. */ @@ -714,6 +812,13 @@ public synchronized void configureQueryExecution(String mode, int batchSize) { }); ConfiguredSystem(DataSet ds, ImmutableGraphIndex index, CompressedVectors cv, Set features) { + this(ds, index, cv, features, null, null); + } + + ConfiguredSystem(DataSet ds, ImmutableGraphIndex index, CompressedVectors cv, Set features, + String rerankingType, NVQVectors memoryReranker) { + this.rerankingType = rerankingType; + this.memoryReranker = memoryReranker; this.ds = ds; this.index = index; this.cv = cv; @@ -732,7 +837,19 @@ public AsymmetricHashing ashQueryProjectionModel() { return cv instanceof ASHVectors ? ((ASHVectors) cv).getCompressor() : null; } + private VectorCompressor fusedCompressor() { + if (index instanceof OnDiskGraphIndex) { + var disk = (OnDiskGraphIndex) index; + var ash = disk.getFeatures().get(FeatureId.FUSED_ASH); + if (ash instanceof FusedASH) return ((FusedASH) ash).getASH(); + var pq = disk.getFeatures().get(FeatureId.FUSED_PQ); + if (pq instanceof FusedPQ) return ((FusedPQ) pq).getPQ(); + } + return null; + } + public SearchScoreProvider scoreProviderFor(VectorFloat queryVector, ImmutableGraphIndex.View view) { + if (rerankingType != null) return configuredScorer(queryVector, view); var scoringView = (ImmutableGraphIndex.ScoringView) view; ScoreFunction.ApproximateScoreFunction asf; if (scoresFromIndex) { @@ -749,6 +866,35 @@ public SearchScoreProvider scoreProviderFor(VectorFloat queryVector, Immutabl return new DefaultSearchScoreProvider(asf, rr, features.contains(FeatureId.FUSED_ASH) || cv instanceof ASHVectors); } + private SearchScoreProvider configuredScorer(VectorFloat query, ImmutableGraphIndex.View view) { + var similarity = ds.getSimilarityFunction(); + var diskView = view instanceof OnDiskGraphIndex.View ? (OnDiskGraphIndex.View) view : null; + ScoreFunction score; + if (scoresFromIndex) { + score = diskView.approximateScoreFunctionFor(query, similarity); + } else if (cv != null) { + score = cv.precomputedScoreFunctionFor(query, similarity); + } else { + score = diskView == null + ? DefaultSearchScoreProvider.exact(query, similarity, exactVectors.get()).scoreFunction() + : diskView.rerankerFor(query, similarity); + } + + ScoreFunction.ExactScoreFunction reranker = null; + if ("FP32".equals(rerankingType)) { + var vectors = diskView == null ? exactVectors.get() : diskView; + reranker = vectors.rerankerFor(query, similarity); + } else if ("NVQ".equals(rerankingType)) { + if (diskView == null) { + reranker = memoryReranker.scoreFunctionFor(query, similarity)::similarityTo; + } else { + var nvq = (NVQ) ((OnDiskGraphIndex) index).getFeatures().get(FeatureId.NVQ_VECTORS); + reranker = nvq.rerankerFor(query, similarity, diskView); + } + } + return new DefaultSearchScoreProvider(score, reranker, features.contains(FeatureId.FUSED_ASH) || cv instanceof ASHVectors); + } + public GraphSearcher getSearcher() { return searchers.get(); } diff --git a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/GridSpec.java b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/GridSpec.java index 82bf3eacd..73ffdd72a 100644 --- a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/GridSpec.java +++ b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/GridSpec.java @@ -17,11 +17,13 @@ package io.github.jbellis.jvector.example; import io.github.jbellis.jvector.example.benchmarks.datasets.DataSet; +import io.github.jbellis.jvector.example.diagnostics.ASHBuildScoreExperiment; import io.github.jbellis.jvector.example.util.CompressorParameters; import io.github.jbellis.jvector.example.yaml.MultiConfig; import io.github.jbellis.jvector.graph.disk.feature.FeatureId; import java.util.ArrayList; +import java.util.EnumSet; import java.util.List; import java.util.Objects; import java.util.Set; @@ -41,18 +43,39 @@ public final class GridSpec { final List> featureSets; final SearchConfig search; final boolean enableIndexCache; + private final List fusedGraph; private GridSpec(Builder b) { this.buildSweep = List.copyOf(b.buildSweep); this.featureSets = b.featureSets; this.search = b.search; this.enableIndexCache = b.enableIndexCache; + this.fusedGraph = b.fusedGraph; } /** The sweep a dataset's YAML config describes. */ public static GridSpec fromConfig(MultiConfig config) { var construction = config.construction; var search = config.search; + if (config.usesSchema3()) { + boolean onDisk = "onDisk".equals(construction.type); + var writerMode = onDisk ? Objects.requireNonNullElse(construction.writerMode, "parallel") : null; + return graphSweeps(construction.addHierarchy, construction.refineFinalGraph, construction.outDegree, + construction.neighborOverflow, construction.efConstruction) + .sweep("construction.compression", construction.compression, (point, compressor) -> point + .buildCompressor(compressor.getCompressorParameters()) + .ashConstructionMode("ASH".equals(compressor.type) + ? Objects.requireNonNullElseGet(compressor.ashConstructionMode(), + ASHBuildScoreExperiment::defaultMode) + : null) + .indexType(construction.type).writerMode(writerMode).rerankingType(construction.rerankingType())) + .featureSets(List.of()) + .fusedGraph(construction.fusedGraph == null ? List.of(false) : construction.fusedGraph) + .search(SearchConfig.builder().compressionEntries(search.compression) + .topKGrid(search.topKOverquery).usePruningGrid(search.useSearchPruning).build()) + .enableIndexCache(onDisk && Boolean.TRUE.equals(construction.useSavedIndexIfExists)) + .build(); + } return fromGrids(construction.addHierarchy, construction.refineFinalGraph, construction.outDegree, @@ -82,13 +105,7 @@ static GridSpec fromGrids(List addHierarchyGrid, List> featureSets, SearchConfig search, boolean enableIndexCache) { - // Dimension names are the YAML field names, so error messages point at the config to fix. - return builder() - .sweep("addHierarchy", addHierarchyGrid, BuildConfig.Builder::addHierarchy) - .sweep("refineFinalGraph", refineFinalGraphGrid, BuildConfig.Builder::refineFinalGraph) - .sweep("outDegree", mGrid, BuildConfig.Builder::M) - .sweep("neighborOverflow", neighborOverflowGrid, BuildConfig.Builder::neighborOverflow) - .sweep("efConstruction", efConstructionGrid, BuildConfig.Builder::efConstruction) + return graphSweeps(addHierarchyGrid, refineFinalGraphGrid, mGrid, neighborOverflowGrid, efConstructionGrid) .sweep("construction.compression", buildCompressors, BuildConfig.Builder::buildCompressor) .featureSets(featureSets) .search(search) @@ -96,6 +113,36 @@ static GridSpec fromGrids(List addHierarchyGrid, .build(); } + private static Builder graphSweeps(List hierarchy, List refinement, List degree, + List overflow, List efConstruction) { + return builder() + .sweep("addHierarchy", hierarchy, BuildConfig.Builder::addHierarchy) + .sweep("refineFinalGraph", refinement, BuildConfig.Builder::refineFinalGraph) + .sweep("outDegree", degree, BuildConfig.Builder::M) + .sweep("neighborOverflow", overflow, BuildConfig.Builder::neighborOverflow) + .sweep("efConstruction", efConstruction, BuildConfig.Builder::efConstruction); + } + + List> featuresFor(BuildConfig build, SearchConfig searchCase) { + if (build.indexType == null) return featureSets; + if ("inMemory".equals(build.indexType)) return List.of(Set.of()); + + // The file format requires a vector feature. Source vectors also provide primary + // traversal scores for uncompressed search, independently of the reranking policy. + var base = EnumSet.noneOf(FeatureId.class); + if ("NVQ".equals(build.rerankingType)) base.add(FeatureId.NVQ_VECTORS); + if (base.isEmpty() || "None".equals(searchCase.compression.type)) base.add(FeatureId.INLINE_VECTORS); + var sets = new ArrayList>(); + for (boolean fused : fusedGraph) { + var features = EnumSet.copyOf(base); + if (fused) { + features.add("ASH".equals(searchCase.compression.type) ? FeatureId.FUSED_ASH : FeatureId.FUSED_PQ); + } + if (!sets.contains(features)) sets.add(features); + } + return sets; + } + public static Builder builder() { return new Builder(); } @@ -138,6 +185,7 @@ public static final class Builder { private List> featureSets; private SearchConfig search; private boolean enableIndexCache; + private List fusedGraph; private Builder() {} @@ -150,6 +198,7 @@ public Builder sweep(String name, List values, BiConsumer> featureSets) { this.featureSets = featureSets; return this; } public Builder search(SearchConfig search) { this.search = search; return this; } public Builder enableIndexCache(boolean enableIndexCache) { this.enableIndexCache = enableIndexCache; return this; } + Builder fusedGraph(List fusedGraph) { this.fusedGraph = List.copyOf(fusedGraph); return this; } /** * @throws IllegalStateException if the feature sets or search config were not set diff --git a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/InMemoryBuild.java b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/InMemoryBuild.java index 346818c2d..a33cce6d4 100644 --- a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/InMemoryBuild.java +++ b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/InMemoryBuild.java @@ -19,18 +19,16 @@ import io.github.jbellis.jvector.disk.ReaderSupplierFactory; import io.github.jbellis.jvector.example.benchmarks.datasets.DataSet; import io.github.jbellis.jvector.graph.ImmutableGraphIndex; +import io.github.jbellis.jvector.quantization.CompressedVectors; import io.github.jbellis.jvector.graph.disk.OnDiskGraphIndex; -import io.github.jbellis.jvector.graph.disk.feature.Feature; import io.github.jbellis.jvector.graph.disk.feature.FeatureId; import java.io.IOException; import java.nio.file.Path; -import java.util.EnumMap; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Set; -import java.util.function.IntFunction; /** * Builds the graph on heap from full-precision vectors, then writes each feature set to the work directory. @@ -38,15 +36,26 @@ */ final class InMemoryBuild implements IndexBuildStrategy { private final Path workDirectory; + private final CompressedVectors encoded; InMemoryBuild(Path workDirectory) { + this(workDirectory, null); + } + + InMemoryBuild(Path workDirectory, CompressedVectors encoded) { this.workDirectory = workDirectory; + this.encoded = encoded; } @Override public BuiltIndexes build(List> featureSets, BuildConfig build, DataSet ds) throws IOException { var floatVectors = ds.getBaseRavv(); Map, ImmutableGraphIndex> indexes = new LinkedHashMap<>(); + if (build.indexType != null) { + long start = System.nanoTime(); + var graph = GraphBuilderFactory.buildGraph(ds, build, encoded); + return new BuiltIndexes(Map.of(Set.of(), graph), Map.of(), (System.nanoTime() - start) / 1e9); + } var builder = GraphBuilderFactory.forFullPrecision(floatVectors, ds.getSimilarityFunction(), build); long start = System.nanoTime(); var onHeapGraph = builder.build(floatVectors); @@ -75,7 +84,7 @@ public BuiltIndexes build(List> featureSets, BuildConfi try (var writer = ctx.buildWriter(); var view = ctx.afterBuildSuppliers().isEmpty() ? null : onHeapGraph.getView()) { start = System.nanoTime(); - writer.write(writeSuppliers(ctx, view)); + writer.write(ctx.completedSuppliers(view)); System.out.format("Wrote %s in %.2fs%n", features, (System.nanoTime() - start) / 1_000_000_000.0); } @@ -84,16 +93,4 @@ public BuiltIndexes build(List> featureSets, BuildConfi } return new BuiltIndexes(indexes, new LinkedHashMap<>(), buildTimeS); } - - /** @param view a view of the completed graph, or null when no feature needs one */ - private static Map> writeSuppliers(FeatureWriteContext ctx, - ImmutableGraphIndex.View view) { - if (view == null) { - return ctx.ordinalSuppliers(); - } - Map> suppliers = new EnumMap<>(FeatureId.class); - suppliers.putAll(ctx.ordinalSuppliers()); - ctx.afterBuildSuppliers().forEach((id, supplier) -> suppliers.put(id, supplier.apply(view))); - return suppliers; - } } diff --git a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/NVQProvider.java b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/NVQProvider.java index 8c818e1c9..eeeb442cf 100644 --- a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/NVQProvider.java +++ b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/NVQProvider.java @@ -16,6 +16,8 @@ package io.github.jbellis.jvector.example; +import io.github.jbellis.jvector.graph.RandomAccessVectorValues; +import io.github.jbellis.jvector.vector.types.VectorFloat; import io.github.jbellis.jvector.graph.disk.feature.FeatureId; import io.github.jbellis.jvector.graph.disk.feature.NVQ; import io.github.jbellis.jvector.quantization.NVQuantization; @@ -30,14 +32,16 @@ public FeatureId id() { @Override public void configure(FeatureWriteContext ctx) { var vectors = ctx.vectors(); - int nSubVectors = vectors.dimension() == 2 ? 1 : 2; - var nvq = ctx.timeIndexCompute("NVQ", () -> ctx.inputMean() == null - ? NVQuantization.compute(vectors, nSubVectors) - : NVQuantization.create(ctx.inputMean(), nSubVectors)); + var nvq = ctx.timeIndexCompute("NVQ", () -> train(vectors, ctx.inputMean())); ctx.add(new NVQ(nvq)); ctx.supplyFromVector(FeatureId.NVQ_VECTORS, vector -> new NVQ.State(nvq.encode(vector))); } + static NVQuantization train(RandomAccessVectorValues vectors, VectorFloat mean) { + int nSubVectors = vectors.dimension() <= 2 ? 1 : 2; + return mean == null ? NVQuantization.compute(vectors, nSubVectors) : NVQuantization.create(mean, nSubVectors); + } + @Override public boolean providesRerankVectors() { return true; diff --git a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/SearchConfig.java b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/SearchConfig.java index 00351059a..5b77f64bd 100644 --- a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/SearchConfig.java +++ b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/SearchConfig.java @@ -18,10 +18,12 @@ import io.github.jbellis.jvector.example.benchmarks.datasets.DataSet; import io.github.jbellis.jvector.example.util.CompressorParameters; +import io.github.jbellis.jvector.example.yaml.Compression; import java.util.List; import java.util.Map; import java.util.function.Function; +import java.util.stream.Collectors; /** * The search-side sweep {@link Grid} runs against every index it builds: each search compressor, @@ -35,11 +37,26 @@ public final class SearchConfig { /** topK mapped to the overquery factors to try; each factor gives rerankK = topK * factor. */ public final Map> topKGrid; public final List usePruningGrid; + private final List entries; + /** Search compression and delivery settings for a single schema 3 index case. */ + final Compression compression; private SearchConfig(Builder b) { this.searchCompressors = b.searchCompressors; this.topKGrid = b.topKGrid; this.usePruningGrid = b.usePruningGrid; + this.entries = b.entries; + this.compression = b.compression; + } + + List cases() { + if (entries == null) return List.of(this); + return entries.stream().map(entry -> { + var builder = builder().topKGrid(topKGrid).usePruningGrid(usePruningGrid) + .searchCompressors(List.of(entry.getCompressorParameters())); + builder.compression = entry; + return builder.build(); + }).collect(Collectors.toList()); } public static Builder builder() { @@ -50,6 +67,8 @@ public static final class Builder { private List> searchCompressors; private Map> topKGrid; private List usePruningGrid; + private List entries; + private Compression compression; private Builder() {} @@ -57,6 +76,12 @@ private Builder() {} public Builder topKGrid(Map> topKGrid) { this.topKGrid = topKGrid; return this; } public Builder usePruningGrid(List usePruningGrid) { this.usePruningGrid = usePruningGrid; return this; } + Builder compressionEntries(List entries) { + this.entries = List.copyOf(entries); + this.searchCompressors = entries.stream().map(Compression::getCompressorParameters).collect(Collectors.toList()); + return this; + } + /** @throws IllegalStateException if a parameter was not set */ public SearchConfig build() { require(searchCompressors, "searchCompressors"); diff --git a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/diagnostics/ASHBuildScoreExperiment.java b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/diagnostics/ASHBuildScoreExperiment.java index bea094df1..e831b544e 100644 --- a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/diagnostics/ASHBuildScoreExperiment.java +++ b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/diagnostics/ASHBuildScoreExperiment.java @@ -29,7 +29,15 @@ public final class ASHBuildScoreExperiment { private ASHBuildScoreExperiment() {} public static BuildScoreProvider create(VectorSimilarityFunction vsf, ASHVectors encoded, RandomAccessVectorValues raw) { - String mode=System.getProperty("jvector.bench.ashConstruction","symmetric-all"); + return create(vsf, encoded, raw, defaultMode()); + } + + public static String defaultMode() { + return System.getProperty("jvector.bench.ashConstruction", "symmetric-all"); + } + + public static BuildScoreProvider create(VectorSimilarityFunction vsf, ASHVectors encoded, + RandomAccessVectorValues raw, String mode) { System.out.println("ASH construction scoring mode: " + mode); if (mode.equals("symmetric") || mode.equals("symmetric-all")) { return BuildScoreProvider.ashBuildScoreProvider(vsf,encoded); diff --git a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/reporting/ExperimentsSchemaV1.java b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/reporting/ExperimentsSchemaV1.java index f6b1479e3..560157791 100644 --- a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/reporting/ExperimentsSchemaV1.java +++ b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/reporting/ExperimentsSchemaV1.java @@ -49,6 +49,10 @@ static String csvEscape(String s) { } public static List fixedColumns() { + return fixedColumns(false); + } + + static List fixedColumns(boolean includeIndexOptions) { List columns = new ArrayList<>(List.of( "schema_version", "run_id", @@ -68,6 +72,10 @@ public static List fixedColumns() { "ash_query_projection_requested", "ash_query_projection_effective" )); + if (includeIndexOptions) { + columns.addAll(List.of("index_type", "writer_mode", "reranking_type", "ash_construction_mode", + "query_execution", "query_threads", "max_batch_size")); + } return List.copyOf(columns); } @@ -128,6 +136,18 @@ public static Map fixedValues(RunContext run, return fixed; } + static void addIndexOptions(Map row, Map build, Map search) { + row.put("index_type", java.util.Objects.toString(build.get("type"), "")); + row.put("writer_mode", java.util.Objects.toString(build.get("writerMode"), "")); + row.put("reranking_type", java.util.Objects.toString(build.get("reranking"), "")); + row.put("ash_construction_mode", java.util.Objects.toString(build.get("ashConstruction"), "")); + row.put("query_execution", java.util.Objects.toString(search.get("executionMode"), "")); + String threadCountKey = "queued".equals(search.get("executionMode")) ? "workers" : "callerThreads"; + Object threadCount = search.get(threadCountKey); + row.put("query_threads", threadCount instanceof Number ? threadCount.toString() : ""); + row.put("max_batch_size", java.util.Objects.toString(search.get("maxBatchSize"), "")); + } + /** * @deprecated Use {@link #fixedValues(RunContext, String, Map, Set, String, String, boolean, int, double, int)}, * which takes the construction parameters by name. diff --git a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/reporting/LoggingSchemaPlanner.java b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/reporting/LoggingSchemaPlanner.java index 70b2b97c2..76ca97fdc 100644 --- a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/reporting/LoggingSchemaPlanner.java +++ b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/reporting/LoggingSchemaPlanner.java @@ -135,9 +135,10 @@ private static void expandPrefixes(Set union, } // construction reranking types (FP/NVQ) - if (cfg.construction != null && cfg.construction.reranking != null) { - if (cfg.construction.reranking.contains("NVQ")) { - wantsNVQ = true; + if (cfg.construction != null && cfg.construction.usesNVQReranking()) { + wantsNVQ = true; + if (cfg.usesSchema3() && "inMemory".equals(cfg.construction.type)) { + indexQuantTypes.add("NVQ"); } } @@ -145,7 +146,13 @@ private static void expandPrefixes(Set union, if (cfg.search != null && cfg.search.compression != null) { for (var c : cfg.search.compression) { if (c != null && c.type != null && !"None".equals(c.type)) { - searchQuantTypes.add(c.type); // "PQ" or "BQ" + var fusedGraph = cfg.construction == null ? null : cfg.construction.fusedGraph; + if (cfg.usesSchema3() && fusedGraph != null && fusedGraph.contains(true)) { + indexQuantTypes.add(c.type); + } + if (!cfg.usesSchema3() || fusedGraph == null || fusedGraph.contains(false)) { + searchQuantTypes.add(c.type); + } } } } @@ -158,7 +165,7 @@ private static void expandPrefixes(Set union, union.add(p + "." + qt + ".encoding_time_s"); } if (wantsNVQ) { - union.add(p + ".NVQ.compute_time_s"); // we intentionally do not time NVQ encode + union.add(p + ".NVQ.compute_time_s"); // Disk NVQ encoding is part of graph/write. } } else if ("search.search_quant_time_s".equals(p)) { for (String qt : searchQuantTypes) { diff --git a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/reporting/RunArtifacts.java b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/reporting/RunArtifacts.java index 35c53672c..1601f0e73 100644 --- a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/reporting/RunArtifacts.java +++ b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/reporting/RunArtifacts.java @@ -47,23 +47,6 @@ public final class RunArtifacts { private final String ashQueryProjectionRequested = System.getProperty(SearchParameters.ASH_QUERY_PROJECTION_PROPERTY, "auto"); - private String queryExecutionMode = "direct"; - private int queryBatchSize = 8; - - public String queryExecutionMode() { return queryExecutionMode; } - public int queryBatchSize() { return queryBatchSize; } - - private RunArtifacts withQueryExecution(RunConfig.QueryExecution settings) { - if (settings == null) return this; - if (!"direct".equals(settings.mode) && !"queued".equals(settings.mode)) - throw new IllegalArgumentException("queryExecution.mode must be direct or queued"); - if (settings.maxBatchSize <= 0 || settings.maxBatchSize == Integer.MAX_VALUE) - throw new IllegalArgumentException("queryExecution.maxBatchSize must be positive and fit its histogram"); - queryExecutionMode = settings.mode; - queryBatchSize = settings.maxBatchSize; - return this; - } - private final RunContext run; private final DatasetInfoWriter datasetInfoWriter; private final ExperimentsCsvWriter experimentsWriter; @@ -146,11 +129,11 @@ public static RunArtifacts open(RunConfig runCfg, List datasetConfi metricsToDisplay, benchmarksToLog, metricsToLog - ).withQueryExecution(runCfg.queryExecution); + ); } // Logging enabled => create run artifacts - var reporting = RunReporting.open(runCfg); + var reporting = RunReporting.open(runCfg, datasetConfigs); var run = reporting.run(); System.out.println("Logging enabled: " + run.runDir().toAbsolutePath() + ".\n" + @@ -159,7 +142,10 @@ public static RunArtifacts open(RunConfig runCfg, List datasetConfi var datasetInfoWriter = DatasetInfoWriter.open(run); var outputKeyColumns = LoggingSchemaPlanner.unionLoggingMetricKeys(runCfg, datasetConfigs); - var experimentsWriter = new ExperimentsCsvWriter(run, ExperimentsSchemaV1.fixedColumns(), outputKeyColumns); + boolean includeIndexOptions = datasetConfigs != null + && datasetConfigs.stream().anyMatch(cfg -> cfg != null && cfg.usesSchema3()); + var experimentsWriter = new ExperimentsCsvWriter(run, ExperimentsSchemaV1.fixedColumns(includeIndexOptions), + outputKeyColumns); return new RunArtifacts( run, @@ -170,7 +156,7 @@ public static RunArtifacts open(RunConfig runCfg, List datasetConfi metricsToDisplay, benchmarksToLog, metricsToLog - ).withQueryExecution(runCfg.queryExecution); + ); } /** No-op artifacts instance for legacy callers (no sys_info/dataset_info/experiments output). */ @@ -242,6 +228,14 @@ public void logRow(String datasetName, int rerankK, AsymmetricHashing ashQueryModel, List logOutputs) { + logRow(datasetName, buildParameters, featureSetForIndex, buildCompressorString, searchCompressorString, + usePruning, topK, overquery, rerankK, ashQueryModel, Map.of(), logOutputs); + } + + public void logRow(String datasetName, Map buildParameters, Set featureSetForIndex, + String buildCompressorString, String searchCompressorString, boolean usePruning, + int topK, double overquery, int rerankK, AsymmetricHashing ashQueryModel, + Map queryParameters, List logOutputs) { if (experimentsWriter == null || run == null) { return; } @@ -263,6 +257,9 @@ public void logRow(String datasetName, String effective = ashQueryModel == null ? null : ashQueryModel.stiefelTransform.initializedQueryProjectionPrecision(); fixed.put("ash_query_projection_effective", effective == null ? "" : effective); + if (buildParameters.containsKey("type")) { + ExperimentsSchemaV1.addIndexOptions(fixed, buildParameters, queryParameters); + } try { experimentsWriter.appendRow(fixed, logOutputs); diff --git a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/reporting/RunReporting.java b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/reporting/RunReporting.java index aaa206850..640ef07f7 100644 --- a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/reporting/RunReporting.java +++ b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/reporting/RunReporting.java @@ -16,7 +16,9 @@ package io.github.jbellis.jvector.example.reporting; +import io.github.jbellis.jvector.example.yaml.MultiConfig; import io.github.jbellis.jvector.example.yaml.RunConfig; +import io.github.jbellis.jvector.util.PhysicalCoreExecutor; import java.io.IOException; import java.nio.file.Files; @@ -24,7 +26,9 @@ import java.nio.file.Paths; import java.time.Clock; import java.time.Instant; +import java.util.List; import java.util.UUID; +import java.util.concurrent.ForkJoinPool; /** * Bootstraps a benchmark run directory and writes sys_info.json. @@ -52,6 +56,10 @@ public String jvectorRef() { } public static RunReporting open(RunConfig runCfg) throws IOException { + return open(runCfg, List.of()); + } + + static RunReporting open(RunConfig runCfg, List configs) throws IOException { // Resolve base logging directory (default "logging") String logDir = (runCfg != null && runCfg.logging != null && runCfg.logging.dir != null && !runCfg.logging.dir.isBlank()) ? runCfg.logging.dir.trim() @@ -74,15 +82,7 @@ public static RunReporting open(RunConfig runCfg) throws IOException { Files.createDirectories(runDir); Integer buildThreads = detectBuildThreads(); - Integer queryThreads; - if (runCfg != null && runCfg.queryExecution != null && "queued".equals(runCfg.queryExecution.mode)) { - queryThreads = Math.max(1, Math.min(Runtime.getRuntime().availableProcessors(), - io.github.jbellis.jvector.util.PhysicalCoreExecutor.getPhysicalCoreCount())); - } else { - int selected = Integer.getInteger("jvector.bench.queryThreads", 0); - if (selected < 0) throw new IllegalArgumentException("queryThreads must be nonnegative"); - queryThreads = selected == 0 ? java.util.concurrent.ForkJoinPool.getCommonPoolParallelism() : selected; - } + Integer queryThreads = queryParallelism(configs); String systemId = SysInfoWriter.writeSysInfo( runDir, @@ -99,6 +99,30 @@ public static RunReporting open(RunConfig runCfg) throws IOException { return new RunReporting(run, jvectorRef); } + private static Integer queryParallelism(List configs) { + boolean direct = configs == null || configs.isEmpty(); + boolean queued = false; + if (configs != null) { + for (var config : configs) { + if (config == null || !config.usesSchema3()) { + direct = true; + continue; + } + for (var entry : config.search.compression) { + if ("queued".equals(entry.queryExecution())) queued = true; + else direct = true; + } + } + } + if (direct && queued) return null; + if (queued) { + return Math.max(1, Math.min(Runtime.getRuntime().availableProcessors(), PhysicalCoreExecutor.getPhysicalCoreCount())); + } + int selected = Integer.getInteger("jvector.bench.queryThreads", 0); + if (selected < 0) throw new IllegalArgumentException("queryThreads must be nonnegative"); + return selected == 0 ? ForkJoinPool.getCommonPoolParallelism() : selected; + } + private static Integer detectBuildThreads() { try { Object exec = io.github.jbellis.jvector.util.PhysicalCoreExecutor.pool(); diff --git a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/reporting/SysInfoWriter.java b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/reporting/SysInfoWriter.java index e218b2943..e4cc79327 100644 --- a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/reporting/SysInfoWriter.java +++ b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/reporting/SysInfoWriter.java @@ -70,7 +70,7 @@ public static String writeSysInfo(Path runDir, threads.put("parallel_stream_parallelism", fjp); // Query benchmark characteristics - threads.put("query_throughput_parallelism", queryThreads == null ? fjp : queryThreads); + threads.put("query_throughput_parallelism", queryThreads); threads.put("query_latency_threads", 1); // Raw common pool parallelism diff --git a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/util/CompressorParameters.java b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/util/CompressorParameters.java index ae688b339..40469c59f 100644 --- a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/util/CompressorParameters.java +++ b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/util/CompressorParameters.java @@ -23,6 +23,7 @@ import io.github.jbellis.jvector.quantization.NVQuantization; import io.github.jbellis.jvector.quantization.ProductQuantization; import io.github.jbellis.jvector.quantization.VectorCompressor; +import io.github.jbellis.jvector.vector.VectorSimilarityFunction; import java.io.IOException; @@ -48,6 +49,9 @@ public VectorCompressor loadCompressor(RandomAccessReader in) throws IOExcept public abstract VectorCompressor computeCompressor(DataSet ds); + /** Validate dataset-dependent constraints before allocating codes or training a model. */ + public void validateFor(DataSet ds, boolean fused, String ashConstructionMode) {} + public static class PQParameters extends CompressorParameters { private final int m; private final int k; @@ -61,6 +65,20 @@ public PQParameters(int m, int k, boolean isCentered, float anisotropicThreshold this.anisotropicThreshold = anisotropicThreshold; } + @Override + public void validateFor(DataSet ds, boolean fused, String ashConstructionMode) { + if (m < 1 || m > ds.getDimension()) { + throw new IllegalArgumentException("PQ m must be between 1 and the dataset dimension"); + } + if (k < 1 || k > 256 || k > ds.getBaseRavv().size()) { + throw new IllegalArgumentException("PQ k must be between 1 and 256 and fit the training set"); + } + if (fused && k != 256) throw new IllegalArgumentException("Fused PQ requires k=256"); + if (!Float.isFinite(anisotropicThreshold) || anisotropicThreshold < -1 || anisotropicThreshold >= 1) { + throw new IllegalArgumentException("PQ anisotropicThreshold must be finite and in [-1, 1)"); + } + } + @Override public VectorCompressor computeCompressor(DataSet ds) { return ProductQuantization.compute(ds.getBaseRavv(), m, k, isCentered, anisotropicThreshold); @@ -146,6 +164,43 @@ public VectorCompressor loadCompressor(RandomAccessReader in) throws IOExcept return AsymmetricHashing.load(in); } + @Override + public void validateFor(DataSet ds, boolean fused, String ashConstructionMode) { + if (ds.getSimilarityFunction() != VectorSimilarityFunction.DOT_PRODUCT) { + throw new IllegalArgumentException("ASH requires dot-product similarity"); + } + int payload = encodedBits - AsymmetricHashing.HEADER_BITS; + if (encodedBits <= AsymmetricHashing.HEADER_BITS || payload % bitsPerDimension != 0 + || payload / bitsPerDimension > ds.getDimension()) { + throw new IllegalArgumentException("ASH encodedBits must yield a whole projected dimension count from 1 to the dataset dimension"); + } + if (optimizer != AsymmetricHashing.ITQ && optimizer != AsymmetricHashing.RANDOM) { + throw new IllegalArgumentException("ASH optimizer must be ITQ or RANDOM"); + } + if (landmarkCount < 1 || landmarkCount > 256 || landmarkCount > ds.getBaseRavv().size()) { + throw new IllegalArgumentException("ASH landmarkCount must be between 1 and 256 and fit the training set"); + } + if (fused && bitsPerDimension != 1 && bitsPerDimension != 2 && bitsPerDimension != 4) { + throw new IllegalArgumentException("Fused ASH requires 1, 2, or 4 bits per dimension"); + } + if (ashConstructionMode != null) { + switch (ashConstructionMode) { + case "symmetric": + case "symmetric-all": + case "symmetric-scalar": + if (landmarkCount != 1) { + throw new IllegalArgumentException("Symmetric ASH construction scoring requires one landmark"); + } + break; + case "asymmetric": + case "asymmetric-cached": + break; + default: + throw new IllegalArgumentException("Unknown ASH construction mode: " + ashConstructionMode); + } + } + } + @Override public VectorCompressor computeCompressor(DataSet ds) { try { diff --git a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/util/OnDiskGraphIndexCache.java b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/util/OnDiskGraphIndexCache.java index ae4a57e1d..2da5134ec 100644 --- a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/util/OnDiskGraphIndexCache.java +++ b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/util/OnDiskGraphIndexCache.java @@ -168,7 +168,15 @@ public CacheKey key(String datasetName, Objects.requireNonNull(featureSet, "featureSet"); Objects.requireNonNull(buildCompressor, "buildCompressor"); String compressorId = buildCompressor.toString().replaceAll("\\s+", ""); - return new CacheKey(datasetName, featureSet, M, efConstruction, neighborOverflow, alpha, addHierarchy, refineFinalGraph, compressorId); + return keyWithCompressorId(datasetName, featureSet, M, efConstruction, neighborOverflow, alpha, + addHierarchy, refineFinalGraph, compressorId); + } + + public CacheKey keyWithCompressorId(String datasetName, Set featureSet, int M, + int efConstruction, float neighborOverflow, float alpha, + boolean addHierarchy, boolean refineFinalGraph, String compressorId) { + return new CacheKey(datasetName, featureSet, M, efConstruction, neighborOverflow, alpha, + addHierarchy, refineFinalGraph, compressorId); } /** diff --git a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/yaml/CommonParameters.java b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/yaml/CommonParameters.java index 626efc591..429210cf9 100644 --- a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/yaml/CommonParameters.java +++ b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/yaml/CommonParameters.java @@ -29,4 +29,14 @@ public class CommonParameters { public List> getCompressorParameters() { return compression.stream().map(Compression::getCompressorParameters).collect(Collectors.toList()); } + + void validateCompression(String section, boolean search) { + if (compression == null || compression.isEmpty()) { + throw new IllegalArgumentException(section + ".compression must contain at least one entry"); + } + for (var entry : compression) { + if (entry == null) throw new IllegalArgumentException(section + ".compression contains an empty entry"); + entry.validateSchema3(section, search); + } + } } diff --git a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/yaml/Compression.java b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/yaml/Compression.java index 248e55dd1..6e171c12e 100644 --- a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/yaml/Compression.java +++ b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/yaml/Compression.java @@ -22,12 +22,98 @@ import io.github.jbellis.jvector.vector.VectorSimilarityFunction; import java.util.Map; +import java.util.List; +import java.util.Set; import java.util.function.Function; public class Compression { public String type; + public String scoring; public Map parameters; + void validateSchema3(String section, boolean search) { + String path = section + ".compression"; + if (type == null || !List.of("None", "ASH", "PQ", "BQ").contains(type)) { + throw new IllegalArgumentException(path + ".type must be None, ASH, PQ, or BQ"); + } + if (scoring != null && (search || !"ASH".equals(type) + || !List.of("symmetric", "asymmetric", "mixed").contains(scoring))) { + throw new IllegalArgumentException(path + ".scoring requires ASH construction and must be symmetric, asymmetric, or mixed"); + } + + Set allowed; + switch (type) { + case "ASH": + allowed = search + ? Set.of("optimizer", "encodedBits", "compressionRatio", "landmarkCount", "bitsPerDimension", + "queryProjection", "queryExecution", "maxBatchSize") + : Set.of("optimizer", "encodedBits", "compressionRatio", "landmarkCount", "bitsPerDimension", + "queryProjection"); + break; + case "PQ": + allowed = Set.of("m", "mFactor", "k", "centerData", "anisotropicThreshold"); + break; + default: + allowed = Set.of(); + } + var settings = parameters == null ? Map.of() : parameters; + for (var setting : settings.entrySet()) { + var key = setting.getKey(); + if (key == null || !allowed.contains(key) || setting.getValue() == null) { + throw new IllegalArgumentException(path + ".parameters." + key + " is invalid for " + type); + } + } + if ("PQ".equals(type) && settings.containsKey("m") == settings.containsKey("mFactor")) { + throw new IllegalArgumentException(path + " requires exactly one of m or mFactor for PQ"); + } + if ("PQ".equals(type) && settings.containsKey("mFactor") && Integer.parseInt(settings.get("mFactor")) < 1) { + throw new IllegalArgumentException(path + ".parameters.mFactor must be positive"); + } + if ("ASH".equals(type)) { + if (!settings.containsKey("optimizer") + || settings.containsKey("encodedBits") == settings.containsKey("compressionRatio")) { + throw new IllegalArgumentException(path + " requires optimizer and exactly one of encodedBits or compressionRatio for ASH"); + } + if (!List.of("ITQ", "RANDOM").contains(settings.get("optimizer"))) { + throw new IllegalArgumentException(path + ".parameters.optimizer must be ITQ or RANDOM"); + } + var projection = settings.get("queryProjection"); + if (projection != null) SearchParameters.AshQueryProjectionSetting.validatePrecision(projection); + var execution = queryExecution(); + if (!List.of("direct", "queued").contains(execution)) { + throw new IllegalArgumentException(path + ".parameters.queryExecution must be direct or queued"); + } + if (settings.containsKey("maxBatchSize") && !"queued".equals(execution)) { + throw new IllegalArgumentException(path + ".parameters.maxBatchSize requires queued queryExecution"); + } + maxBatchSize(); + } + } + + public String queryExecution() { + return parameters == null ? "direct" : parameters.getOrDefault("queryExecution", "direct"); + } + + public int maxBatchSize() { + String configured = parameters == null ? null : parameters.get("maxBatchSize"); + if (configured == null) return 8; + int size = Integer.parseInt(configured); + if (size <= 0 || size == Integer.MAX_VALUE) { + throw new IllegalArgumentException("maxBatchSize must be positive and fit its histogram"); + } + return size; + } + + public String ashConstructionMode() { + if (scoring == null) return null; + switch (scoring) { + case "symmetric": return "symmetric-all"; + case "mixed": return "symmetric"; + case "asymmetric": return "asymmetric"; + default: throw new IllegalArgumentException("Unknown ASH construction scoring: " + scoring); + } + } + public Function getCompressorParameters() { switch (type) { case "None": @@ -45,7 +131,7 @@ public Function getCompressorParameters() { if (strCenterData == null) { centerData = ds.getSimilarityFunction() == VectorSimilarityFunction.EUCLIDEAN; } else { - centerData = strCenterData.equals("Yes");; + centerData = strCenterData.equals("Yes"); } if (parameters.containsKey("m")) { @@ -154,4 +240,4 @@ public Function getCompressorParameters() { } } -} \ No newline at end of file +} diff --git a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/yaml/ConstructionParameters.java b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/yaml/ConstructionParameters.java index 30f7d672c..f4e7e6fcd 100644 --- a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/yaml/ConstructionParameters.java +++ b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/yaml/ConstructionParameters.java @@ -22,28 +22,80 @@ import java.util.EnumSet; import java.util.LinkedHashSet; import java.util.List; +import java.util.Map; +import java.util.function.Predicate; public class ConstructionParameters extends CommonParameters { + public String type; + public String writerMode; public List outDegree; public List efConstruction; public List neighborOverflow; public List addHierarchy; public List refineFinalGraph; - public List reranking; + /** Schema 2 accepts a list of features. Schema 3 accepts a single map with a type. */ + public Object reranking; public List fusedGraph; public Boolean useSavedIndexIfExists; + void validateSchema3() { + if (type == null || !List.of("inMemory", "onDisk").contains(type)) { + throw new IllegalArgumentException("construction.type must be inMemory or onDisk"); + } + if (writerMode != null && !List.of("sequential", "parallel").contains(writerMode)) { + throw new IllegalArgumentException("construction.writerMode must be sequential or parallel"); + } + if ("inMemory".equals(type) && (writerMode != null || fusedGraph != null || useSavedIndexIfExists != null)) { + throw new IllegalArgumentException("construction.writerMode, fusedGraph, and useSavedIndexIfExists require onDisk"); + } + validateSweep("outDegree", outDegree, value -> value > 0); + validateSweep("efConstruction", efConstruction, value -> value > 0); + validateSweep("neighborOverflow", neighborOverflow, value -> Float.isFinite(value) && value >= 1); + validateSweep("addHierarchy", addHierarchy, value -> true); + validateSweep("refineFinalGraph", refineFinalGraph, value -> true); + if (fusedGraph != null) validateSweep("fusedGraph", fusedGraph, value -> true); + rerankingType(); + validateCompression("construction", false); + } + + private static void validateSweep(String name, List values, Predicate valid) { + if (values == null || values.isEmpty() || values.stream().anyMatch(value -> value == null || !valid.test(value))) { + throw new IllegalArgumentException("construction." + name + " requires a nonempty list of valid values"); + } + } + + public String rerankingType() { + if (reranking == null) return "None"; + if (!(reranking instanceof Map)) { + throw new IllegalArgumentException("construction.reranking must be a map with a type in schema 3"); + } + var fields = (Map) reranking; + if (fields.size() != 1 || !(fields.get("type") instanceof String) + || !List.of("None", "FP32", "NVQ").contains(fields.get("type"))) { + throw new IllegalArgumentException("construction.reranking.type must be None, FP32, or NVQ"); + } + return (String) fields.get("type"); + } + + public boolean usesNVQReranking() { + if (reranking instanceof List) return ((List) reranking).contains("NVQ"); + return "NVQ".equals(rerankingType()); + } + public List> getFeatureSets() { var featureSets = new LinkedHashSet>(); boolean hasPQConstruction = hasConstructionCompressor("PQ"); boolean hasASHConstruction = hasConstructionCompressor("ASH"); for (var fusedItem : fusedGraph) { - for (var item : reranking) { + if (!(reranking instanceof List)) { + throw new IllegalArgumentException("construction.reranking must be a list in schema 2"); + } + for (var item : (List) reranking) { EnumSet baseFeatures; - switch (item) { + switch (String.valueOf(item)) { case "FP": baseFeatures = EnumSet.of(FeatureId.INLINE_VECTORS); break; @@ -95,4 +147,4 @@ private boolean hasConstructionCompressor(String type) { } return false; } -} \ No newline at end of file +} diff --git a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/yaml/MultiConfig.java b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/yaml/MultiConfig.java index f217254cf..21968786e 100644 --- a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/yaml/MultiConfig.java +++ b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/yaml/MultiConfig.java @@ -116,10 +116,16 @@ static MultiConfig getConfig(File configFile) throws FileNotFoundException { } // Validate supported versions - if (cfg.getYamlSchemaVersion() != 2) { + if (cfg.getYamlSchemaVersion() != 2 && cfg.getYamlSchemaVersion() != 3) { throw new IllegalArgumentException("Unsupported yamlSchemaVersion=" + cfg.getYamlSchemaVersion() + " in " + configFile.getAbsolutePath()); } + try { + if (cfg.usesSchema3()) cfg.validateSchema3(); + else cfg.validateSchema2(); + } catch (IllegalArgumentException e) { + throw new IllegalArgumentException(configFile.getAbsolutePath() + ": " + e.getMessage(), e); + } return cfg; } catch (IOException e) { @@ -189,6 +195,54 @@ public int getYamlSchemaVersion() { return yamlSchemaVersion; } + public boolean usesSchema3() { + return yamlSchemaVersion == 3; + } + + private void validateSchema2() { + if (construction != null && (construction.type != null || construction.writerMode != null + || (construction.reranking != null && !(construction.reranking instanceof List)))) { + throw new IllegalArgumentException("construction.type, writerMode, and reranking.type require schema 3"); + } + if (hasSchema3CompressionFields(construction) || hasSchema3CompressionFields(search)) { + throw new IllegalArgumentException("compression scoring and ASH query settings require schema 3"); + } + } + + private static boolean hasSchema3CompressionFields(CommonParameters section) { + if (section == null || section.compression == null) return false; + for (var entry : section.compression) { + if (entry == null) continue; + if (entry.scoring != null) return true; + if ("ASH".equals(entry.type) && entry.parameters != null + && (entry.parameters.containsKey("queryExecution") + || entry.parameters.containsKey("maxBatchSize") + || entry.parameters.containsKey("queryProjection"))) return true; + } + return false; + } + + private void validateSchema3() { + if (dataset == null || dataset.isBlank() || construction == null || search == null) { + throw new IllegalArgumentException("schema 3 requires dataset, construction, and search"); + } + construction.validateSchema3(); + search.validateSchema3(); + var reranking = construction.rerankingType(); + boolean fused = construction.fusedGraph != null && construction.fusedGraph.contains(Boolean.TRUE); + if (fused && "None".equals(reranking)) { + throw new IllegalArgumentException("fusedGraph requires construction.reranking.type FP32 or NVQ"); + } + for (var entry : search.compression) { + if (fused && !List.of("ASH", "PQ").contains(entry.type)) { + throw new IllegalArgumentException("fusedGraph requires ASH or PQ search compression"); + } + if ("None".equals(entry.type) && "FP32".equals(reranking)) { + throw new IllegalArgumentException("FP32 reranking repeats source-vector traversal scores. Use reranking.type None"); + } + } + } + public void setYamlSchemaVersion(int yamlSchemaVersion) { this.yamlSchemaVersion = yamlSchemaVersion; } diff --git a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/yaml/RunConfig.java b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/yaml/RunConfig.java index 0f97effe1..a78802f10 100644 --- a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/yaml/RunConfig.java +++ b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/yaml/RunConfig.java @@ -43,14 +43,6 @@ public class RunConfig { public int yamlSchemaVersion; public int onDiskIndexVersion; - /** Query delivery policy; omitted settings preserve the synchronous benchmark. */ - public QueryExecution queryExecution = new QueryExecution(); - - public static class QueryExecution { - public String mode = "direct"; - public int maxBatchSize = 8; - } - /** Benchmark computation policy (what is computed). */ public Map> benchmarks; diff --git a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/yaml/SearchParameters.java b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/yaml/SearchParameters.java index 7da16c6fb..2df526f7b 100644 --- a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/yaml/SearchParameters.java +++ b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/yaml/SearchParameters.java @@ -18,6 +18,8 @@ import java.util.List; import java.util.Map; +import java.util.ArrayList; +import java.util.Objects; public class SearchParameters extends CommonParameters { /** ASH query arithmetic; omitted settings preserve JVM properties and auto. */ @@ -33,10 +35,23 @@ public static final class AshQueryProjectionSetting implements AutoCloseable { public void apply(List configs) { String selected = null; boolean explicitYaml = false; + List settings = new ArrayList<>(); + boolean usesAsh = false; for (MultiConfig config : configs) { - String yaml = config.search == null ? null : config.search.queryProjection; + boolean configUsesAsh = hasAsh(config.construction) || hasAsh(config.search); + usesAsh |= configUsesAsh; + if (config.usesSchema3()) { + collectProjectionSettings(config.construction, settings); + collectProjectionSettings(config.search, settings); + } else { + String legacy = config.search == null ? null : config.search.queryProjection; + if (legacy != null) validatePrecision(legacy); + if (configUsesAsh) settings.add(legacy); + } + } + if (usesAsh && original != null) validatePrecision(original); + for (var yaml : settings) { if (yaml != null) validatePrecision(yaml); - if (!hasAsh(config.construction) && !hasAsh(config.search)) continue; String mode = original != null ? original : yaml == null ? "auto" : yaml; validatePrecision(mode); if (selected != null && !selected.equals(mode)) @@ -50,14 +65,24 @@ public void apply(List configs) { } } + private static void collectProjectionSettings(CommonParameters section, List settings) { + if (section == null || section.compression == null) return; + for (var entry : section.compression) { + if (entry != null && "ASH".equals(entry.type) && entry.parameters != null) { + String value = entry.parameters.get("queryProjection"); + if (value != null) settings.add(value); + } + } + } + private static boolean hasAsh(CommonParameters parameters) { return parameters != null && parameters.compression != null && parameters.compression.stream().anyMatch(c -> c != null && "ASH".equals(c.type)); } - private static void validatePrecision(String value) { + static void validatePrecision(String value) { if (!List.of("auto", "fp32", "fp16", "int8").contains(value)) - throw new IllegalArgumentException("search.queryProjection must be auto, fp32, fp16 or int8: " + value); + throw new IllegalArgumentException("queryProjection must be auto, fp32, fp16 or int8: " + value); } @Override public void close() { @@ -71,6 +96,28 @@ private static void validatePrecision(String value) { public Map> topKOverquery; public List useSearchPruning; + void validateSchema3() { + if (queryProjection != null) { + throw new IllegalArgumentException("place queryProjection in the ASH compression parameters in schema 3"); + } + validateCompression("search", true); + if (topKOverquery == null || topKOverquery.isEmpty() || useSearchPruning == null || useSearchPruning.isEmpty() + || useSearchPruning.stream().anyMatch(Objects::isNull)) { + throw new IllegalArgumentException("search.topKOverquery and search.useSearchPruning must be set"); + } + for (var entry : topKOverquery.entrySet()) { + var k = entry.getKey(); + if (k == null || k <= 0 || entry.getValue() == null || entry.getValue().isEmpty()) { + throw new IllegalArgumentException("search.topKOverquery requires positive K and a list of multipliers"); + } + for (var multiplier : entry.getValue()) { + if (multiplier == null || !Double.isFinite(multiplier) || multiplier < 1 || multiplier * k > Integer.MAX_VALUE) { + throw new IllegalArgumentException("search.topKOverquery multipliers must be finite, at least 1, and fit an integer candidate budget"); + } + } + } + } + // NOTE: benchmark compute + console/logging selection are now run-level (run-config.yml) // and are no longer recognized in dataset configs. } diff --git a/jvector-examples/src/test/java/io/github/jbellis/jvector/example/benchmarks/AshProjectionConfigTest.java b/jvector-examples/src/test/java/io/github/jbellis/jvector/example/benchmarks/AshProjectionConfigTest.java index f60b25042..1b69970f7 100644 --- a/jvector-examples/src/test/java/io/github/jbellis/jvector/example/benchmarks/AshProjectionConfigTest.java +++ b/jvector-examples/src/test/java/io/github/jbellis/jvector/example/benchmarks/AshProjectionConfigTest.java @@ -52,8 +52,6 @@ private MultiConfig yaml(String body) throws Exception { try (var setting = new SearchParameters.AshQueryProjectionSetting()) { setting.apply(List.of(config)); assertNull(System.getProperty(SearchParameters.ASH_QUERY_PROJECTION_PROPERTY)); - assertEquals("direct", new RunConfig().queryExecution.mode); - assertEquals(8, new RunConfig().queryExecution.maxBatchSize); } assertNull(System.getProperty(SearchParameters.ASH_QUERY_PROJECTION_PROPERTY)); } diff --git a/jvector-examples/src/test/java/io/github/jbellis/jvector/example/benchmarks/QueryExecutionConfigTest.java b/jvector-examples/src/test/java/io/github/jbellis/jvector/example/benchmarks/QueryExecutionConfigTest.java index 447cea23b..195536fe3 100644 --- a/jvector-examples/src/test/java/io/github/jbellis/jvector/example/benchmarks/QueryExecutionConfigTest.java +++ b/jvector-examples/src/test/java/io/github/jbellis/jvector/example/benchmarks/QueryExecutionConfigTest.java @@ -15,40 +15,30 @@ */ package io.github.jbellis.jvector.example.benchmarks; -import io.github.jbellis.jvector.example.reporting.RunArtifacts; -import io.github.jbellis.jvector.example.reporting.SearchReportingCatalog; +import io.github.jbellis.jvector.example.yaml.MultiConfig; import io.github.jbellis.jvector.example.yaml.RunConfig; import org.junit.Test; import org.junit.Rule; import org.junit.rules.TemporaryFolder; import java.nio.file.Files; -import java.nio.file.Path; -import java.util.List; import static org.junit.Assert.*; public class QueryExecutionConfigTest { @Rule public final TemporaryFolder directory = new TemporaryFolder(); @Test - public void defaultsPreserveDirectExecutionAndQueuedModeIsSelectedInYaml() throws Exception { - var file = directory.getRoot().toPath().resolve("run.yml"); - Files.writeString(file, "yamlSchemaVersion: 1\nqueryExecution:\n mode: queued\n maxBatchSize: 8\n"); - var config = RunConfig.load(file.toString()); - config.benchmarks = SearchReportingCatalog.defaultComputeBenchmarks(); - var artifacts = RunArtifacts.open(config, List.of()); - assertEquals("queued", artifacts.queryExecutionMode()); - assertEquals(8, artifacts.queryBatchSize()); - assertEquals("direct", RunArtifacts.disabled().queryExecutionMode()); + public void runConfigRejectsQueryDeliverySettings() throws Exception { + var file = directory.newFile("run.yml").toPath(); + Files.writeString(file, "yamlSchemaVersion: 1\nqueryExecution:\n mode: queued\n"); + assertThrows(org.yaml.snakeyaml.error.YAMLException.class, () -> RunConfig.load(file.toString())); } @Test - public void invalidExecutionSettingsFailBeforeCollectingResults() { - var config = new RunConfig(); - config.benchmarks = SearchReportingCatalog.defaultComputeBenchmarks(); - config.queryExecution.mode = "unknown"; - assertThrows(IllegalArgumentException.class, () -> RunArtifacts.open(config, List.of())); - config.queryExecution.mode = "queued"; - config.queryExecution.maxBatchSize = 0; - assertThrows(IllegalArgumentException.class, () -> RunArtifacts.open(config, List.of())); + public void schema2RejectsNestedAshDeliverySettings() throws Exception { + var file = directory.newFile("index.yml").toPath(); + Files.writeString(file, "yamlSchemaVersion: 2\nonDiskIndexVersion: 7\ndataset: test\n" + + "search:\n compression:\n - type: ASH\n parameters:\n queryExecution: queued\n"); + var error = assertThrows(IllegalArgumentException.class, () -> MultiConfig.getConfig(file.toString())); + assertTrue(error.getMessage().contains("require schema 3")); } } diff --git a/jvector-examples/src/test/java/io/github/jbellis/jvector/example/benchmarks/StandaloneASHBenchmarkConfigTest.java b/jvector-examples/src/test/java/io/github/jbellis/jvector/example/benchmarks/StandaloneASHBenchmarkConfigTest.java index 450c60522..41fc26238 100644 --- a/jvector-examples/src/test/java/io/github/jbellis/jvector/example/benchmarks/StandaloneASHBenchmarkConfigTest.java +++ b/jvector-examples/src/test/java/io/github/jbellis/jvector/example/benchmarks/StandaloneASHBenchmarkConfigTest.java @@ -37,11 +37,11 @@ public class StandaloneASHBenchmarkConfigTest { @Test public void explicitRunFileDoesNotRequireEditingTheDefaultYaml() throws Exception { var path = directory.newFile("run.yml").toPath().toAbsolutePath(); - Files.writeString(path, "yamlSchemaVersion: 1\nqueryExecution:\n mode: queued\n"); + Files.writeString(path, "yamlSchemaVersion: 1\nbenchmarks:\n accuracy: [recall]\n"); String previous = System.getProperty("jvector.bench.runConfig"); try { System.setProperty("jvector.bench.runConfig", path.toString()); - assertEquals("queued", RunConfig.loadDefault().queryExecution.mode); + assertEquals(List.of("recall"), RunConfig.loadDefault().benchmarks.get("accuracy")); Files.writeString(path, ""); assertThrows(IllegalArgumentException.class, RunConfig::loadDefault); } finally { @@ -94,10 +94,19 @@ public void allPairsMatchBytesAndUseFusedGraphsNvqAndTheSameSearchParameters() t var construction = (Map) root.get("construction"); var search = (Map) root.get("search"); assertEquals(List.of(true), construction.get("fusedGraph")); - assertEquals(List.of("NVQ"), construction.get("reranking")); + assertEquals(Map.of("type", "NVQ"), construction.get("reranking")); + assertEquals("onDisk", construction.get("type")); + assertEquals("parallel", construction.get("writerMode")); assertEquals(List.of(32), construction.get("outDegree")); assertEquals(List.of(100), construction.get("efConstruction")); - assertEquals(compression(construction), compression(search)); + var searchSettings = new java.util.LinkedHashMap<>( + (Map) compression(search).get("parameters")); + if (algorithm.equals("ash")) { + assertEquals("queued", searchSettings.remove("queryExecution")); + assertEquals(8, searchSettings.remove("maxBatchSize")); + assertEquals("auto", searchSettings.remove("queryProjection")); + } + assertEquals(compression(construction).get("parameters"), searchSettings); assertEquals(Map.of(10, List.of(1.0, 2.0)), search.get("topKOverquery")); var parameters = (Map) compression(construction).get("parameters"); int expectedBits = 40 + entry.getValue() / 2 * bits; @@ -122,11 +131,9 @@ public void allPairsMatchBytesAndUseFusedGraphsNvqAndTheSameSearchParameters() t } @Test - public void runModesKeepStandardBenchmarkSelectionsAndQueuedBatchDefaults() throws Exception { + public void runRecipesKeepStandardBenchmarkSelections() throws Exception { for (String mode : List.of("construction", "direct", "queued")) { var config = RunConfig.load(CONFIGS.resolve("run-" + mode + ".yml").toString()); - assertEquals(mode.equals("queued") ? "queued" : "direct", config.queryExecution.mode); - assertEquals(8, config.queryExecution.maxBatchSize); assertEquals(List.of("recall"), config.benchmarks.get("accuracy")); if (!mode.equals("construction")) { assertEquals(List.of("AVG"), config.benchmarks.get("throughput")); @@ -148,10 +155,15 @@ public void provenanceRecordsTheActualSourceAndSelectedQueryParallelism() throws config.logging.dir = directory.getRoot().getAbsolutePath(); config.logging.jvectorRef = "old-label"; for (String mode : List.of("direct", "queued")) { - config.queryExecution.mode = mode; + config.benchmarks = Map.of("accuracy", List.of("recall")); + config.logging.type = "csv"; + config.logging.benchmarks = config.benchmarks; config.logging.runId = mode; - var reporting = io.github.jbellis.jvector.example.reporting.RunReporting.open(config); - var info = yaml(reporting.run().runDir().resolve("sys_info.json")); + String codec = mode.equals("queued") ? "ash" : "pq"; + var index = MultiConfig.getConfig(CONFIGS.resolve("mpnet-2bit-" + codec + ".yml") + .toAbsolutePath().toString()); + var artifacts = io.github.jbellis.jvector.example.reporting.RunArtifacts.open(config, List.of(index)); + var info = yaml(artifacts.experimentsWriter().path().resolveSibling("sys_info.json")); assertEquals("test-source-reference", info.get("jvector_ref")); var threads = (Map) info.get("threads"); int expected = mode.equals("direct") ? 7 : Math.max(1, diff --git a/jvector-examples/yaml-configs/ash-pq/ada002-2bit-ash.yml b/jvector-examples/yaml-configs/ash-pq/ada002-2bit-ash.yml index 7a25716c6..56901979d 100644 --- a/jvector-examples/yaml-configs/ash-pq/ada002-2bit-ash.yml +++ b/jvector-examples/yaml-configs/ash-pq/ada002-2bit-ash.yml @@ -2,11 +2,13 @@ # The 2-bit label describes the ASH budget; PQ uses 197 8-bit subspaces. # ASH uses C=1 and D/2 projected dimensions, including its five-byte header. # See docs/ash-pq-benchmark.md for construction, direct and queued run commands. -yamlSchemaVersion: 2 +yamlSchemaVersion: 3 onDiskIndexVersion: 7 dataset: ada002-1M construction: + type: onDisk + writerMode: parallel outDegree: [32] efConstruction: [100] neighborOverflow: [1.2f] @@ -20,19 +22,22 @@ construction: optimizer: ITQ landmarkCount: 1 bitsPerDimension: 2 - reranking: [NVQ] + reranking: + type: NVQ # A fresh case directory measures training/encoding/construction. Later JVMs # in that directory reuse exactly the same model and index for querying. useSavedIndexIfExists: Yes search: - queryProjection: auto # auto, fp16, fp32, int8; explicit -D overrides YAML topKOverquery: 10: [1.0, 2.0] useSearchPruning: [No] compression: - type: ASH parameters: + queryProjection: auto + queryExecution: queued + maxBatchSize: 8 encodedBits: 1576 optimizer: ITQ landmarkCount: 1 diff --git a/jvector-examples/yaml-configs/ash-pq/ada002-2bit-pq.yml b/jvector-examples/yaml-configs/ash-pq/ada002-2bit-pq.yml index 16dbc7d36..c547f0a9b 100644 --- a/jvector-examples/yaml-configs/ash-pq/ada002-2bit-pq.yml +++ b/jvector-examples/yaml-configs/ash-pq/ada002-2bit-pq.yml @@ -2,11 +2,13 @@ # The 2-bit label describes the ASH budget; PQ uses 197 8-bit subspaces. # ASH uses C=1 and D/2 projected dimensions, including its five-byte header. # See docs/ash-pq-benchmark.md for construction, direct and queued run commands. -yamlSchemaVersion: 2 +yamlSchemaVersion: 3 onDiskIndexVersion: 7 dataset: ada002-1M construction: + type: onDisk + writerMode: parallel outDegree: [32] efConstruction: [100] neighborOverflow: [1.2f] @@ -20,7 +22,8 @@ construction: k: 256 centerData: "No" anisotropicThreshold: -1.0 - reranking: [NVQ] + reranking: + type: NVQ # A fresh case directory measures training/encoding/construction. Later JVMs # in that directory reuse exactly the same model and index for querying. useSavedIndexIfExists: Yes diff --git a/jvector-examples/yaml-configs/ash-pq/ada002-4bit-ash.yml b/jvector-examples/yaml-configs/ash-pq/ada002-4bit-ash.yml index c7fd51871..5611d961c 100644 --- a/jvector-examples/yaml-configs/ash-pq/ada002-4bit-ash.yml +++ b/jvector-examples/yaml-configs/ash-pq/ada002-4bit-ash.yml @@ -2,11 +2,13 @@ # The 4-bit label describes the ASH budget; PQ uses 389 8-bit subspaces. # ASH uses C=1 and D/2 projected dimensions, including its five-byte header. # See docs/ash-pq-benchmark.md for construction, direct and queued run commands. -yamlSchemaVersion: 2 +yamlSchemaVersion: 3 onDiskIndexVersion: 7 dataset: ada002-1M construction: + type: onDisk + writerMode: parallel outDegree: [32] efConstruction: [100] neighborOverflow: [1.2f] @@ -20,19 +22,22 @@ construction: optimizer: ITQ landmarkCount: 1 bitsPerDimension: 4 - reranking: [NVQ] + reranking: + type: NVQ # A fresh case directory measures training/encoding/construction. Later JVMs # in that directory reuse exactly the same model and index for querying. useSavedIndexIfExists: Yes search: - queryProjection: auto # auto, fp16, fp32, int8; explicit -D overrides YAML topKOverquery: 10: [1.0, 2.0] useSearchPruning: [No] compression: - type: ASH parameters: + queryProjection: auto + queryExecution: queued + maxBatchSize: 8 encodedBits: 3112 optimizer: ITQ landmarkCount: 1 diff --git a/jvector-examples/yaml-configs/ash-pq/ada002-4bit-pq.yml b/jvector-examples/yaml-configs/ash-pq/ada002-4bit-pq.yml index 4527ddcfc..736278f8b 100644 --- a/jvector-examples/yaml-configs/ash-pq/ada002-4bit-pq.yml +++ b/jvector-examples/yaml-configs/ash-pq/ada002-4bit-pq.yml @@ -2,11 +2,13 @@ # The 4-bit label describes the ASH budget; PQ uses 389 8-bit subspaces. # ASH uses C=1 and D/2 projected dimensions, including its five-byte header. # See docs/ash-pq-benchmark.md for construction, direct and queued run commands. -yamlSchemaVersion: 2 +yamlSchemaVersion: 3 onDiskIndexVersion: 7 dataset: ada002-1M construction: + type: onDisk + writerMode: parallel outDegree: [32] efConstruction: [100] neighborOverflow: [1.2f] @@ -20,7 +22,8 @@ construction: k: 256 centerData: "No" anisotropicThreshold: -1.0 - reranking: [NVQ] + reranking: + type: NVQ # A fresh case directory measures training/encoding/construction. Later JVMs # in that directory reuse exactly the same model and index for querying. useSavedIndexIfExists: Yes diff --git a/jvector-examples/yaml-configs/ash-pq/cap6m-2bit-ash.yml b/jvector-examples/yaml-configs/ash-pq/cap6m-2bit-ash.yml index bf9afb5d9..ac68161f3 100644 --- a/jvector-examples/yaml-configs/ash-pq/cap6m-2bit-ash.yml +++ b/jvector-examples/yaml-configs/ash-pq/cap6m-2bit-ash.yml @@ -2,11 +2,13 @@ # The 2-bit label describes the ASH budget; PQ uses 197 8-bit subspaces. # ASH uses C=1 and D/2 projected dimensions, including its five-byte header. # See docs/ash-pq-benchmark.md for construction, direct and queued run commands. -yamlSchemaVersion: 2 +yamlSchemaVersion: 3 onDiskIndexVersion: 7 dataset: cap-6M construction: + type: onDisk + writerMode: parallel outDegree: [32] efConstruction: [100] neighborOverflow: [1.2f] @@ -20,19 +22,22 @@ construction: optimizer: ITQ landmarkCount: 1 bitsPerDimension: 2 - reranking: [NVQ] + reranking: + type: NVQ # A fresh case directory measures training/encoding/construction. Later JVMs # in that directory reuse exactly the same model and index for querying. useSavedIndexIfExists: Yes search: - queryProjection: auto # auto, fp16, fp32, int8; explicit -D overrides YAML topKOverquery: 10: [1.0, 2.0] useSearchPruning: [No] compression: - type: ASH parameters: + queryProjection: auto + queryExecution: queued + maxBatchSize: 8 encodedBits: 1576 optimizer: ITQ landmarkCount: 1 diff --git a/jvector-examples/yaml-configs/ash-pq/cap6m-2bit-pq.yml b/jvector-examples/yaml-configs/ash-pq/cap6m-2bit-pq.yml index 3e3e50fb6..5afd99a7e 100644 --- a/jvector-examples/yaml-configs/ash-pq/cap6m-2bit-pq.yml +++ b/jvector-examples/yaml-configs/ash-pq/cap6m-2bit-pq.yml @@ -2,11 +2,13 @@ # The 2-bit label describes the ASH budget; PQ uses 197 8-bit subspaces. # ASH uses C=1 and D/2 projected dimensions, including its five-byte header. # See docs/ash-pq-benchmark.md for construction, direct and queued run commands. -yamlSchemaVersion: 2 +yamlSchemaVersion: 3 onDiskIndexVersion: 7 dataset: cap-6M construction: + type: onDisk + writerMode: parallel outDegree: [32] efConstruction: [100] neighborOverflow: [1.2f] @@ -20,7 +22,8 @@ construction: k: 256 centerData: "No" anisotropicThreshold: -1.0 - reranking: [NVQ] + reranking: + type: NVQ # A fresh case directory measures training/encoding/construction. Later JVMs # in that directory reuse exactly the same model and index for querying. useSavedIndexIfExists: Yes diff --git a/jvector-examples/yaml-configs/ash-pq/cap6m-4bit-ash.yml b/jvector-examples/yaml-configs/ash-pq/cap6m-4bit-ash.yml index e5dae3e21..7505d3afa 100644 --- a/jvector-examples/yaml-configs/ash-pq/cap6m-4bit-ash.yml +++ b/jvector-examples/yaml-configs/ash-pq/cap6m-4bit-ash.yml @@ -2,11 +2,13 @@ # The 4-bit label describes the ASH budget; PQ uses 389 8-bit subspaces. # ASH uses C=1 and D/2 projected dimensions, including its five-byte header. # See docs/ash-pq-benchmark.md for construction, direct and queued run commands. -yamlSchemaVersion: 2 +yamlSchemaVersion: 3 onDiskIndexVersion: 7 dataset: cap-6M construction: + type: onDisk + writerMode: parallel outDegree: [32] efConstruction: [100] neighborOverflow: [1.2f] @@ -20,19 +22,22 @@ construction: optimizer: ITQ landmarkCount: 1 bitsPerDimension: 4 - reranking: [NVQ] + reranking: + type: NVQ # A fresh case directory measures training/encoding/construction. Later JVMs # in that directory reuse exactly the same model and index for querying. useSavedIndexIfExists: Yes search: - queryProjection: auto # auto, fp16, fp32, int8; explicit -D overrides YAML topKOverquery: 10: [1.0, 2.0] useSearchPruning: [No] compression: - type: ASH parameters: + queryProjection: auto + queryExecution: queued + maxBatchSize: 8 encodedBits: 3112 optimizer: ITQ landmarkCount: 1 diff --git a/jvector-examples/yaml-configs/ash-pq/cap6m-4bit-pq.yml b/jvector-examples/yaml-configs/ash-pq/cap6m-4bit-pq.yml index d9ccc0e9d..71e4b92d4 100644 --- a/jvector-examples/yaml-configs/ash-pq/cap6m-4bit-pq.yml +++ b/jvector-examples/yaml-configs/ash-pq/cap6m-4bit-pq.yml @@ -2,11 +2,13 @@ # The 4-bit label describes the ASH budget; PQ uses 389 8-bit subspaces. # ASH uses C=1 and D/2 projected dimensions, including its five-byte header. # See docs/ash-pq-benchmark.md for construction, direct and queued run commands. -yamlSchemaVersion: 2 +yamlSchemaVersion: 3 onDiskIndexVersion: 7 dataset: cap-6M construction: + type: onDisk + writerMode: parallel outDegree: [32] efConstruction: [100] neighborOverflow: [1.2f] @@ -20,7 +22,8 @@ construction: k: 256 centerData: "No" anisotropicThreshold: -1.0 - reranking: [NVQ] + reranking: + type: NVQ # A fresh case directory measures training/encoding/construction. Later JVMs # in that directory reuse exactly the same model and index for querying. useSavedIndexIfExists: Yes diff --git a/jvector-examples/yaml-configs/ash-pq/cohere-2bit-ash.yml b/jvector-examples/yaml-configs/ash-pq/cohere-2bit-ash.yml index c1660b39c..457ff26e7 100644 --- a/jvector-examples/yaml-configs/ash-pq/cohere-2bit-ash.yml +++ b/jvector-examples/yaml-configs/ash-pq/cohere-2bit-ash.yml @@ -2,11 +2,13 @@ # The 2-bit label describes the ASH budget; PQ uses 133 8-bit subspaces. # ASH uses C=1 and D/2 projected dimensions, including its five-byte header. # See docs/ash-pq-benchmark.md for construction, direct and queued run commands. -yamlSchemaVersion: 2 +yamlSchemaVersion: 3 onDiskIndexVersion: 7 dataset: cohere-english-v3-1M construction: + type: onDisk + writerMode: parallel outDegree: [32] efConstruction: [100] neighborOverflow: [1.2f] @@ -20,19 +22,22 @@ construction: optimizer: ITQ landmarkCount: 1 bitsPerDimension: 2 - reranking: [NVQ] + reranking: + type: NVQ # A fresh case directory measures training/encoding/construction. Later JVMs # in that directory reuse exactly the same model and index for querying. useSavedIndexIfExists: Yes search: - queryProjection: auto # auto, fp16, fp32, int8; explicit -D overrides YAML topKOverquery: 10: [1.0, 2.0] useSearchPruning: [No] compression: - type: ASH parameters: + queryProjection: auto + queryExecution: queued + maxBatchSize: 8 encodedBits: 1064 optimizer: ITQ landmarkCount: 1 diff --git a/jvector-examples/yaml-configs/ash-pq/cohere-2bit-pq.yml b/jvector-examples/yaml-configs/ash-pq/cohere-2bit-pq.yml index a41f3cf3a..dbc00bb40 100644 --- a/jvector-examples/yaml-configs/ash-pq/cohere-2bit-pq.yml +++ b/jvector-examples/yaml-configs/ash-pq/cohere-2bit-pq.yml @@ -2,11 +2,13 @@ # The 2-bit label describes the ASH budget; PQ uses 133 8-bit subspaces. # ASH uses C=1 and D/2 projected dimensions, including its five-byte header. # See docs/ash-pq-benchmark.md for construction, direct and queued run commands. -yamlSchemaVersion: 2 +yamlSchemaVersion: 3 onDiskIndexVersion: 7 dataset: cohere-english-v3-1M construction: + type: onDisk + writerMode: parallel outDegree: [32] efConstruction: [100] neighborOverflow: [1.2f] @@ -20,7 +22,8 @@ construction: k: 256 centerData: "No" anisotropicThreshold: -1.0 - reranking: [NVQ] + reranking: + type: NVQ # A fresh case directory measures training/encoding/construction. Later JVMs # in that directory reuse exactly the same model and index for querying. useSavedIndexIfExists: Yes diff --git a/jvector-examples/yaml-configs/ash-pq/cohere-4bit-ash.yml b/jvector-examples/yaml-configs/ash-pq/cohere-4bit-ash.yml index d885556e8..189fcf892 100644 --- a/jvector-examples/yaml-configs/ash-pq/cohere-4bit-ash.yml +++ b/jvector-examples/yaml-configs/ash-pq/cohere-4bit-ash.yml @@ -2,11 +2,13 @@ # The 4-bit label describes the ASH budget; PQ uses 261 8-bit subspaces. # ASH uses C=1 and D/2 projected dimensions, including its five-byte header. # See docs/ash-pq-benchmark.md for construction, direct and queued run commands. -yamlSchemaVersion: 2 +yamlSchemaVersion: 3 onDiskIndexVersion: 7 dataset: cohere-english-v3-1M construction: + type: onDisk + writerMode: parallel outDegree: [32] efConstruction: [100] neighborOverflow: [1.2f] @@ -20,19 +22,22 @@ construction: optimizer: ITQ landmarkCount: 1 bitsPerDimension: 4 - reranking: [NVQ] + reranking: + type: NVQ # A fresh case directory measures training/encoding/construction. Later JVMs # in that directory reuse exactly the same model and index for querying. useSavedIndexIfExists: Yes search: - queryProjection: auto # auto, fp16, fp32, int8; explicit -D overrides YAML topKOverquery: 10: [1.0, 2.0] useSearchPruning: [No] compression: - type: ASH parameters: + queryProjection: auto + queryExecution: queued + maxBatchSize: 8 encodedBits: 2088 optimizer: ITQ landmarkCount: 1 diff --git a/jvector-examples/yaml-configs/ash-pq/cohere-4bit-pq.yml b/jvector-examples/yaml-configs/ash-pq/cohere-4bit-pq.yml index 619fe60b9..446263fe0 100644 --- a/jvector-examples/yaml-configs/ash-pq/cohere-4bit-pq.yml +++ b/jvector-examples/yaml-configs/ash-pq/cohere-4bit-pq.yml @@ -2,11 +2,13 @@ # The 4-bit label describes the ASH budget; PQ uses 261 8-bit subspaces. # ASH uses C=1 and D/2 projected dimensions, including its five-byte header. # See docs/ash-pq-benchmark.md for construction, direct and queued run commands. -yamlSchemaVersion: 2 +yamlSchemaVersion: 3 onDiskIndexVersion: 7 dataset: cohere-english-v3-1M construction: + type: onDisk + writerMode: parallel outDegree: [32] efConstruction: [100] neighborOverflow: [1.2f] @@ -20,7 +22,8 @@ construction: k: 256 centerData: "No" anisotropicThreshold: -1.0 - reranking: [NVQ] + reranking: + type: NVQ # A fresh case directory measures training/encoding/construction. Later JVMs # in that directory reuse exactly the same model and index for querying. useSavedIndexIfExists: Yes diff --git a/jvector-examples/yaml-configs/ash-pq/cohere10m-2bit-ash.yml b/jvector-examples/yaml-configs/ash-pq/cohere10m-2bit-ash.yml index 998be6c69..df4294d7c 100644 --- a/jvector-examples/yaml-configs/ash-pq/cohere10m-2bit-ash.yml +++ b/jvector-examples/yaml-configs/ash-pq/cohere10m-2bit-ash.yml @@ -2,11 +2,13 @@ # The 2-bit label describes the ASH budget; PQ uses 133 8-bit subspaces. # ASH uses C=1 and D/2 projected dimensions, including its five-byte header. # See docs/ash-pq-benchmark.md for construction, direct and queued run commands. -yamlSchemaVersion: 2 +yamlSchemaVersion: 3 onDiskIndexVersion: 7 dataset: cohere-english-v3-10M construction: + type: onDisk + writerMode: parallel outDegree: [32] efConstruction: [100] neighborOverflow: [1.2f] @@ -20,19 +22,22 @@ construction: optimizer: ITQ landmarkCount: 1 bitsPerDimension: 2 - reranking: [NVQ] + reranking: + type: NVQ # A fresh case directory measures training/encoding/construction. Later JVMs # in that directory reuse exactly the same model and index for querying. useSavedIndexIfExists: Yes search: - queryProjection: auto # auto, fp16, fp32, int8; explicit -D overrides YAML topKOverquery: 10: [1.0, 2.0] useSearchPruning: [No] compression: - type: ASH parameters: + queryProjection: auto + queryExecution: queued + maxBatchSize: 8 encodedBits: 1064 optimizer: ITQ landmarkCount: 1 diff --git a/jvector-examples/yaml-configs/ash-pq/cohere10m-2bit-pq.yml b/jvector-examples/yaml-configs/ash-pq/cohere10m-2bit-pq.yml index db032d63a..938650fd4 100644 --- a/jvector-examples/yaml-configs/ash-pq/cohere10m-2bit-pq.yml +++ b/jvector-examples/yaml-configs/ash-pq/cohere10m-2bit-pq.yml @@ -2,11 +2,13 @@ # The 2-bit label describes the ASH budget; PQ uses 133 8-bit subspaces. # ASH uses C=1 and D/2 projected dimensions, including its five-byte header. # See docs/ash-pq-benchmark.md for construction, direct and queued run commands. -yamlSchemaVersion: 2 +yamlSchemaVersion: 3 onDiskIndexVersion: 7 dataset: cohere-english-v3-10M construction: + type: onDisk + writerMode: parallel outDegree: [32] efConstruction: [100] neighborOverflow: [1.2f] @@ -20,7 +22,8 @@ construction: k: 256 centerData: "No" anisotropicThreshold: -1.0 - reranking: [NVQ] + reranking: + type: NVQ # A fresh case directory measures training/encoding/construction. Later JVMs # in that directory reuse exactly the same model and index for querying. useSavedIndexIfExists: Yes diff --git a/jvector-examples/yaml-configs/ash-pq/cohere10m-4bit-ash.yml b/jvector-examples/yaml-configs/ash-pq/cohere10m-4bit-ash.yml index 8e027a7af..8e1f6ff36 100644 --- a/jvector-examples/yaml-configs/ash-pq/cohere10m-4bit-ash.yml +++ b/jvector-examples/yaml-configs/ash-pq/cohere10m-4bit-ash.yml @@ -2,11 +2,13 @@ # The 4-bit label describes the ASH budget; PQ uses 261 8-bit subspaces. # ASH uses C=1 and D/2 projected dimensions, including its five-byte header. # See docs/ash-pq-benchmark.md for construction, direct and queued run commands. -yamlSchemaVersion: 2 +yamlSchemaVersion: 3 onDiskIndexVersion: 7 dataset: cohere-english-v3-10M construction: + type: onDisk + writerMode: parallel outDegree: [32] efConstruction: [100] neighborOverflow: [1.2f] @@ -20,19 +22,22 @@ construction: optimizer: ITQ landmarkCount: 1 bitsPerDimension: 4 - reranking: [NVQ] + reranking: + type: NVQ # A fresh case directory measures training/encoding/construction. Later JVMs # in that directory reuse exactly the same model and index for querying. useSavedIndexIfExists: Yes search: - queryProjection: auto # auto, fp16, fp32, int8; explicit -D overrides YAML topKOverquery: 10: [1.0, 2.0] useSearchPruning: [No] compression: - type: ASH parameters: + queryProjection: auto + queryExecution: queued + maxBatchSize: 8 encodedBits: 2088 optimizer: ITQ landmarkCount: 1 diff --git a/jvector-examples/yaml-configs/ash-pq/cohere10m-4bit-pq.yml b/jvector-examples/yaml-configs/ash-pq/cohere10m-4bit-pq.yml index 049dbc30d..df5b1ec2b 100644 --- a/jvector-examples/yaml-configs/ash-pq/cohere10m-4bit-pq.yml +++ b/jvector-examples/yaml-configs/ash-pq/cohere10m-4bit-pq.yml @@ -2,11 +2,13 @@ # The 4-bit label describes the ASH budget; PQ uses 261 8-bit subspaces. # ASH uses C=1 and D/2 projected dimensions, including its five-byte header. # See docs/ash-pq-benchmark.md for construction, direct and queued run commands. -yamlSchemaVersion: 2 +yamlSchemaVersion: 3 onDiskIndexVersion: 7 dataset: cohere-english-v3-10M construction: + type: onDisk + writerMode: parallel outDegree: [32] efConstruction: [100] neighborOverflow: [1.2f] @@ -20,7 +22,8 @@ construction: k: 256 centerData: "No" anisotropicThreshold: -1.0 - reranking: [NVQ] + reranking: + type: NVQ # A fresh case directory measures training/encoding/construction. Later JVMs # in that directory reuse exactly the same model and index for querying. useSavedIndexIfExists: Yes diff --git a/jvector-examples/yaml-configs/ash-pq/mpnet-2bit-ash.yml b/jvector-examples/yaml-configs/ash-pq/mpnet-2bit-ash.yml index 2de051545..320138023 100644 --- a/jvector-examples/yaml-configs/ash-pq/mpnet-2bit-ash.yml +++ b/jvector-examples/yaml-configs/ash-pq/mpnet-2bit-ash.yml @@ -2,11 +2,13 @@ # The 2-bit label describes the ASH budget; PQ uses 101 8-bit subspaces. # ASH uses C=1 and D/2 projected dimensions, including its five-byte header. # See docs/ash-pq-benchmark.md for construction, direct and queued run commands. -yamlSchemaVersion: 2 +yamlSchemaVersion: 3 onDiskIndexVersion: 7 dataset: mpnet-1m construction: + type: onDisk + writerMode: parallel outDegree: [32] efConstruction: [100] neighborOverflow: [1.2f] @@ -20,19 +22,22 @@ construction: optimizer: ITQ landmarkCount: 1 bitsPerDimension: 2 - reranking: [NVQ] + reranking: + type: NVQ # A fresh case directory measures training/encoding/construction. Later JVMs # in that directory reuse exactly the same model and index for querying. useSavedIndexIfExists: Yes search: - queryProjection: auto # auto, fp16, fp32, int8; explicit -D overrides YAML topKOverquery: 10: [1.0, 2.0] useSearchPruning: [No] compression: - type: ASH parameters: + queryProjection: auto + queryExecution: queued + maxBatchSize: 8 encodedBits: 808 optimizer: ITQ landmarkCount: 1 diff --git a/jvector-examples/yaml-configs/ash-pq/mpnet-2bit-pq.yml b/jvector-examples/yaml-configs/ash-pq/mpnet-2bit-pq.yml index aedf1da07..265b7664a 100644 --- a/jvector-examples/yaml-configs/ash-pq/mpnet-2bit-pq.yml +++ b/jvector-examples/yaml-configs/ash-pq/mpnet-2bit-pq.yml @@ -2,11 +2,13 @@ # The 2-bit label describes the ASH budget; PQ uses 101 8-bit subspaces. # ASH uses C=1 and D/2 projected dimensions, including its five-byte header. # See docs/ash-pq-benchmark.md for construction, direct and queued run commands. -yamlSchemaVersion: 2 +yamlSchemaVersion: 3 onDiskIndexVersion: 7 dataset: mpnet-1m construction: + type: onDisk + writerMode: parallel outDegree: [32] efConstruction: [100] neighborOverflow: [1.2f] @@ -20,7 +22,8 @@ construction: k: 256 centerData: "No" anisotropicThreshold: -1.0 - reranking: [NVQ] + reranking: + type: NVQ # A fresh case directory measures training/encoding/construction. Later JVMs # in that directory reuse exactly the same model and index for querying. useSavedIndexIfExists: Yes diff --git a/jvector-examples/yaml-configs/ash-pq/mpnet-4bit-ash.yml b/jvector-examples/yaml-configs/ash-pq/mpnet-4bit-ash.yml index faa63e405..a5b45e4e8 100644 --- a/jvector-examples/yaml-configs/ash-pq/mpnet-4bit-ash.yml +++ b/jvector-examples/yaml-configs/ash-pq/mpnet-4bit-ash.yml @@ -2,11 +2,13 @@ # The 4-bit label describes the ASH budget; PQ uses 197 8-bit subspaces. # ASH uses C=1 and D/2 projected dimensions, including its five-byte header. # See docs/ash-pq-benchmark.md for construction, direct and queued run commands. -yamlSchemaVersion: 2 +yamlSchemaVersion: 3 onDiskIndexVersion: 7 dataset: mpnet-1m construction: + type: onDisk + writerMode: parallel outDegree: [32] efConstruction: [100] neighborOverflow: [1.2f] @@ -20,19 +22,22 @@ construction: optimizer: ITQ landmarkCount: 1 bitsPerDimension: 4 - reranking: [NVQ] + reranking: + type: NVQ # A fresh case directory measures training/encoding/construction. Later JVMs # in that directory reuse exactly the same model and index for querying. useSavedIndexIfExists: Yes search: - queryProjection: auto # auto, fp16, fp32, int8; explicit -D overrides YAML topKOverquery: 10: [1.0, 2.0] useSearchPruning: [No] compression: - type: ASH parameters: + queryProjection: auto + queryExecution: queued + maxBatchSize: 8 encodedBits: 1576 optimizer: ITQ landmarkCount: 1 diff --git a/jvector-examples/yaml-configs/ash-pq/mpnet-4bit-pq.yml b/jvector-examples/yaml-configs/ash-pq/mpnet-4bit-pq.yml index 4556eee09..c53a271c0 100644 --- a/jvector-examples/yaml-configs/ash-pq/mpnet-4bit-pq.yml +++ b/jvector-examples/yaml-configs/ash-pq/mpnet-4bit-pq.yml @@ -2,11 +2,13 @@ # The 4-bit label describes the ASH budget; PQ uses 197 8-bit subspaces. # ASH uses C=1 and D/2 projected dimensions, including its five-byte header. # See docs/ash-pq-benchmark.md for construction, direct and queued run commands. -yamlSchemaVersion: 2 +yamlSchemaVersion: 3 onDiskIndexVersion: 7 dataset: mpnet-1m construction: + type: onDisk + writerMode: parallel outDegree: [32] efConstruction: [100] neighborOverflow: [1.2f] @@ -20,7 +22,8 @@ construction: k: 256 centerData: "No" anisotropicThreshold: -1.0 - reranking: [NVQ] + reranking: + type: NVQ # A fresh case directory measures training/encoding/construction. Later JVMs # in that directory reuse exactly the same model and index for querying. useSavedIndexIfExists: Yes diff --git a/jvector-examples/yaml-configs/ash-pq/mpnet10m-2bit-ash.yml b/jvector-examples/yaml-configs/ash-pq/mpnet10m-2bit-ash.yml index 16beec797..e4db21135 100644 --- a/jvector-examples/yaml-configs/ash-pq/mpnet10m-2bit-ash.yml +++ b/jvector-examples/yaml-configs/ash-pq/mpnet10m-2bit-ash.yml @@ -2,11 +2,13 @@ # The 2-bit label describes the ASH budget; PQ uses 101 8-bit subspaces. # ASH uses C=1 and D/2 projected dimensions, including its five-byte header. # See docs/ash-pq-benchmark.md for construction, direct and queued run commands. -yamlSchemaVersion: 2 +yamlSchemaVersion: 3 onDiskIndexVersion: 7 dataset: mpnet-10m construction: + type: onDisk + writerMode: parallel outDegree: [32] efConstruction: [100] neighborOverflow: [1.2f] @@ -20,19 +22,22 @@ construction: optimizer: ITQ landmarkCount: 1 bitsPerDimension: 2 - reranking: [NVQ] + reranking: + type: NVQ # A fresh case directory measures training/encoding/construction. Later JVMs # in that directory reuse exactly the same model and index for querying. useSavedIndexIfExists: Yes search: - queryProjection: auto # auto, fp16, fp32, int8; explicit -D overrides YAML topKOverquery: 10: [1.0, 2.0] useSearchPruning: [No] compression: - type: ASH parameters: + queryProjection: auto + queryExecution: queued + maxBatchSize: 8 encodedBits: 808 optimizer: ITQ landmarkCount: 1 diff --git a/jvector-examples/yaml-configs/ash-pq/mpnet10m-2bit-pq.yml b/jvector-examples/yaml-configs/ash-pq/mpnet10m-2bit-pq.yml index e1a184bf4..3101d62c6 100644 --- a/jvector-examples/yaml-configs/ash-pq/mpnet10m-2bit-pq.yml +++ b/jvector-examples/yaml-configs/ash-pq/mpnet10m-2bit-pq.yml @@ -2,11 +2,13 @@ # The 2-bit label describes the ASH budget; PQ uses 101 8-bit subspaces. # ASH uses C=1 and D/2 projected dimensions, including its five-byte header. # See docs/ash-pq-benchmark.md for construction, direct and queued run commands. -yamlSchemaVersion: 2 +yamlSchemaVersion: 3 onDiskIndexVersion: 7 dataset: mpnet-10m construction: + type: onDisk + writerMode: parallel outDegree: [32] efConstruction: [100] neighborOverflow: [1.2f] @@ -20,7 +22,8 @@ construction: k: 256 centerData: "No" anisotropicThreshold: -1.0 - reranking: [NVQ] + reranking: + type: NVQ # A fresh case directory measures training/encoding/construction. Later JVMs # in that directory reuse exactly the same model and index for querying. useSavedIndexIfExists: Yes diff --git a/jvector-examples/yaml-configs/ash-pq/mpnet10m-4bit-ash.yml b/jvector-examples/yaml-configs/ash-pq/mpnet10m-4bit-ash.yml index 561583df2..ec21db3ca 100644 --- a/jvector-examples/yaml-configs/ash-pq/mpnet10m-4bit-ash.yml +++ b/jvector-examples/yaml-configs/ash-pq/mpnet10m-4bit-ash.yml @@ -2,11 +2,13 @@ # The 4-bit label describes the ASH budget; PQ uses 197 8-bit subspaces. # ASH uses C=1 and D/2 projected dimensions, including its five-byte header. # See docs/ash-pq-benchmark.md for construction, direct and queued run commands. -yamlSchemaVersion: 2 +yamlSchemaVersion: 3 onDiskIndexVersion: 7 dataset: mpnet-10m construction: + type: onDisk + writerMode: parallel outDegree: [32] efConstruction: [100] neighborOverflow: [1.2f] @@ -20,19 +22,22 @@ construction: optimizer: ITQ landmarkCount: 1 bitsPerDimension: 4 - reranking: [NVQ] + reranking: + type: NVQ # A fresh case directory measures training/encoding/construction. Later JVMs # in that directory reuse exactly the same model and index for querying. useSavedIndexIfExists: Yes search: - queryProjection: auto # auto, fp16, fp32, int8; explicit -D overrides YAML topKOverquery: 10: [1.0, 2.0] useSearchPruning: [No] compression: - type: ASH parameters: + queryProjection: auto + queryExecution: queued + maxBatchSize: 8 encodedBits: 1576 optimizer: ITQ landmarkCount: 1 diff --git a/jvector-examples/yaml-configs/ash-pq/mpnet10m-4bit-pq.yml b/jvector-examples/yaml-configs/ash-pq/mpnet10m-4bit-pq.yml index e25dcf8d0..a32d405c3 100644 --- a/jvector-examples/yaml-configs/ash-pq/mpnet10m-4bit-pq.yml +++ b/jvector-examples/yaml-configs/ash-pq/mpnet10m-4bit-pq.yml @@ -2,11 +2,13 @@ # The 4-bit label describes the ASH budget; PQ uses 197 8-bit subspaces. # ASH uses C=1 and D/2 projected dimensions, including its five-byte header. # See docs/ash-pq-benchmark.md for construction, direct and queued run commands. -yamlSchemaVersion: 2 +yamlSchemaVersion: 3 onDiskIndexVersion: 7 dataset: mpnet-10m construction: + type: onDisk + writerMode: parallel outDegree: [32] efConstruction: [100] neighborOverflow: [1.2f] @@ -20,7 +22,8 @@ construction: k: 256 centerData: "No" anisotropicThreshold: -1.0 - reranking: [NVQ] + reranking: + type: NVQ # A fresh case directory measures training/encoding/construction. Later JVMs # in that directory reuse exactly the same model and index for querying. useSavedIndexIfExists: Yes diff --git a/jvector-examples/yaml-configs/ash-pq/openai3072-2bit-ash.yml b/jvector-examples/yaml-configs/ash-pq/openai3072-2bit-ash.yml index b7d32ac23..0623f4b18 100644 --- a/jvector-examples/yaml-configs/ash-pq/openai3072-2bit-ash.yml +++ b/jvector-examples/yaml-configs/ash-pq/openai3072-2bit-ash.yml @@ -2,11 +2,13 @@ # The 2-bit label describes the ASH budget; PQ uses 389 8-bit subspaces. # ASH uses C=1 and D/2 projected dimensions, including its five-byte header. # See docs/ash-pq-benchmark.md for construction, direct and queued run commands. -yamlSchemaVersion: 2 +yamlSchemaVersion: 3 onDiskIndexVersion: 7 dataset: openai-3072-1m construction: + type: onDisk + writerMode: parallel outDegree: [32] efConstruction: [100] neighborOverflow: [1.2f] @@ -20,19 +22,22 @@ construction: optimizer: ITQ landmarkCount: 1 bitsPerDimension: 2 - reranking: [NVQ] + reranking: + type: NVQ # A fresh case directory measures training/encoding/construction. Later JVMs # in that directory reuse exactly the same model and index for querying. useSavedIndexIfExists: Yes search: - queryProjection: auto # auto, fp16, fp32, int8; explicit -D overrides YAML topKOverquery: 10: [1.0, 2.0] useSearchPruning: [No] compression: - type: ASH parameters: + queryProjection: auto + queryExecution: queued + maxBatchSize: 8 encodedBits: 3112 optimizer: ITQ landmarkCount: 1 diff --git a/jvector-examples/yaml-configs/ash-pq/openai3072-2bit-pq.yml b/jvector-examples/yaml-configs/ash-pq/openai3072-2bit-pq.yml index 2d0cdfb52..9efeb6189 100644 --- a/jvector-examples/yaml-configs/ash-pq/openai3072-2bit-pq.yml +++ b/jvector-examples/yaml-configs/ash-pq/openai3072-2bit-pq.yml @@ -2,11 +2,13 @@ # The 2-bit label describes the ASH budget; PQ uses 389 8-bit subspaces. # ASH uses C=1 and D/2 projected dimensions, including its five-byte header. # See docs/ash-pq-benchmark.md for construction, direct and queued run commands. -yamlSchemaVersion: 2 +yamlSchemaVersion: 3 onDiskIndexVersion: 7 dataset: openai-3072-1m construction: + type: onDisk + writerMode: parallel outDegree: [32] efConstruction: [100] neighborOverflow: [1.2f] @@ -20,7 +22,8 @@ construction: k: 256 centerData: "No" anisotropicThreshold: -1.0 - reranking: [NVQ] + reranking: + type: NVQ # A fresh case directory measures training/encoding/construction. Later JVMs # in that directory reuse exactly the same model and index for querying. useSavedIndexIfExists: Yes diff --git a/jvector-examples/yaml-configs/ash-pq/openai3072-4bit-ash.yml b/jvector-examples/yaml-configs/ash-pq/openai3072-4bit-ash.yml index 34a6e1a95..32e711b99 100644 --- a/jvector-examples/yaml-configs/ash-pq/openai3072-4bit-ash.yml +++ b/jvector-examples/yaml-configs/ash-pq/openai3072-4bit-ash.yml @@ -2,11 +2,13 @@ # The 4-bit label describes the ASH budget; PQ uses 773 8-bit subspaces. # ASH uses C=1 and D/2 projected dimensions, including its five-byte header. # See docs/ash-pq-benchmark.md for construction, direct and queued run commands. -yamlSchemaVersion: 2 +yamlSchemaVersion: 3 onDiskIndexVersion: 7 dataset: openai-3072-1m construction: + type: onDisk + writerMode: parallel outDegree: [32] efConstruction: [100] neighborOverflow: [1.2f] @@ -20,19 +22,22 @@ construction: optimizer: ITQ landmarkCount: 1 bitsPerDimension: 4 - reranking: [NVQ] + reranking: + type: NVQ # A fresh case directory measures training/encoding/construction. Later JVMs # in that directory reuse exactly the same model and index for querying. useSavedIndexIfExists: Yes search: - queryProjection: auto # auto, fp16, fp32, int8; explicit -D overrides YAML topKOverquery: 10: [1.0, 2.0] useSearchPruning: [No] compression: - type: ASH parameters: + queryProjection: auto + queryExecution: queued + maxBatchSize: 8 encodedBits: 6184 optimizer: ITQ landmarkCount: 1 diff --git a/jvector-examples/yaml-configs/ash-pq/openai3072-4bit-pq.yml b/jvector-examples/yaml-configs/ash-pq/openai3072-4bit-pq.yml index 00d8c7d25..a00fa2e1e 100644 --- a/jvector-examples/yaml-configs/ash-pq/openai3072-4bit-pq.yml +++ b/jvector-examples/yaml-configs/ash-pq/openai3072-4bit-pq.yml @@ -2,11 +2,13 @@ # The 4-bit label describes the ASH budget; PQ uses 773 8-bit subspaces. # ASH uses C=1 and D/2 projected dimensions, including its five-byte header. # See docs/ash-pq-benchmark.md for construction, direct and queued run commands. -yamlSchemaVersion: 2 +yamlSchemaVersion: 3 onDiskIndexVersion: 7 dataset: openai-3072-1m construction: + type: onDisk + writerMode: parallel outDegree: [32] efConstruction: [100] neighborOverflow: [1.2f] @@ -20,7 +22,8 @@ construction: k: 256 centerData: "No" anisotropicThreshold: -1.0 - reranking: [NVQ] + reranking: + type: NVQ # A fresh case directory measures training/encoding/construction. Later JVMs # in that directory reuse exactly the same model and index for querying. useSavedIndexIfExists: Yes diff --git a/jvector-examples/yaml-configs/ash-pq/run-construction.yml b/jvector-examples/yaml-configs/ash-pq/run-construction.yml index cae967a5a..52f5f83c2 100644 --- a/jvector-examples/yaml-configs/ash-pq/run-construction.yml +++ b/jvector-examples/yaml-configs/ash-pq/run-construction.yml @@ -1,8 +1,6 @@ # Used by standalone BenchYAML via -Djvector.bench.runConfig=. yamlSchemaVersion: 1 onDiskIndexVersion: 7 -queryExecution: - mode: direct benchmarks: accuracy: [recall] console: diff --git a/jvector-examples/yaml-configs/ash-pq/run-direct.yml b/jvector-examples/yaml-configs/ash-pq/run-direct.yml index b4115f19c..dd5c09aa0 100644 --- a/jvector-examples/yaml-configs/ash-pq/run-direct.yml +++ b/jvector-examples/yaml-configs/ash-pq/run-direct.yml @@ -1,8 +1,6 @@ # Used by standalone BenchYAML via -Djvector.bench.runConfig=. yamlSchemaVersion: 1 onDiskIndexVersion: 7 -queryExecution: - mode: direct benchmarks: throughput: [AVG] latency: [AVG, STD, P999] diff --git a/jvector-examples/yaml-configs/ash-pq/run-queued.yml b/jvector-examples/yaml-configs/ash-pq/run-queued.yml index 65049e295..d22eccc68 100644 --- a/jvector-examples/yaml-configs/ash-pq/run-queued.yml +++ b/jvector-examples/yaml-configs/ash-pq/run-queued.yml @@ -1,8 +1,6 @@ # Used by standalone BenchYAML via -Djvector.bench.runConfig=. yamlSchemaVersion: 1 onDiskIndexVersion: 7 -queryExecution: - mode: queued benchmarks: throughput: [AVG] latency: [AVG, STD, P999] diff --git a/jvector-examples/yaml-configs/index-parameters/ada002-1M.yml b/jvector-examples/yaml-configs/index-parameters/ada002-1M.yml index f68088ff1..4eb1e418a 100644 --- a/jvector-examples/yaml-configs/index-parameters/ada002-1M.yml +++ b/jvector-examples/yaml-configs/index-parameters/ada002-1M.yml @@ -1,10 +1,11 @@ # ASH configuration for DOT_PRODUCT datasets only. -yamlSchemaVersion: 2 +yamlSchemaVersion: 3 onDiskIndexVersion: 7 dataset: ada002-1M construction: + type: onDisk outDegree: [64] efConstruction: [200] neighborOverflow: [1.2f] @@ -25,11 +26,10 @@ construction: # k: 256 # anisotropicThreshold: -1.0 reranking: - - NVQ + type: NVQ useSavedIndexIfExists: No # If yes, the system will attempt to locate a cached copy of the index instead of constructing search: - queryProjection: auto # auto, fp16, fp32, int8; explicit -D overrides YAML topKOverquery: 10: [1.0, 2.0, 5.0, 10.0] # 100: [1.0, 2.0] diff --git a/jvector-examples/yaml-configs/index-parameters/cap-10M.yml b/jvector-examples/yaml-configs/index-parameters/cap-10M.yml index 5d5e6e2c7..abd1e7f2f 100644 --- a/jvector-examples/yaml-configs/index-parameters/cap-10M.yml +++ b/jvector-examples/yaml-configs/index-parameters/cap-10M.yml @@ -1,10 +1,11 @@ -yamlSchemaVersion: 2 +yamlSchemaVersion: 3 onDiskIndexVersion: 7 dataset: cap-10M useDatasetPrefetching: false construction: + type: onDisk outDegree: [32] efConstruction: [100] neighborOverflow: [1.2f] @@ -25,11 +26,10 @@ construction: # k: 256 # anisotropicThreshold: -1.0 reranking: - - NVQ + type: NVQ useSavedIndexIfExists: No search: - queryProjection: auto # auto, fp16, fp32, int8; explicit -D overrides YAML topKOverquery: 10: [1.0] useSearchPruning: [No] diff --git a/jvector-examples/yaml-configs/index-parameters/cap-1M.yml b/jvector-examples/yaml-configs/index-parameters/cap-1M.yml index f5a4485a5..6158f8dee 100644 --- a/jvector-examples/yaml-configs/index-parameters/cap-1M.yml +++ b/jvector-examples/yaml-configs/index-parameters/cap-1M.yml @@ -1,10 +1,11 @@ # ASH configuration for DOT_PRODUCT datasets only. -yamlSchemaVersion: 2 +yamlSchemaVersion: 3 onDiskIndexVersion: 7 dataset: cap-1M construction: + type: onDisk outDegree: [64] efConstruction: [200] neighborOverflow: [1.2f] @@ -25,11 +26,10 @@ construction: # k: 256 # anisotropicThreshold: -1.0 reranking: - - NVQ + type: NVQ useSavedIndexIfExists: No # If yes, the system will attempt to locate a cached copy of the index instead of constructing search: - queryProjection: auto # auto, fp16, fp32, int8; explicit -D overrides YAML topKOverquery: 10: [1.0, 2.0, 5.0, 10.0] # 100: [1.0, 2.0] diff --git a/jvector-examples/yaml-configs/index-parameters/cap-6M.yml b/jvector-examples/yaml-configs/index-parameters/cap-6M.yml index 5f33db39f..b85b3222d 100644 --- a/jvector-examples/yaml-configs/index-parameters/cap-6M.yml +++ b/jvector-examples/yaml-configs/index-parameters/cap-6M.yml @@ -1,9 +1,10 @@ -yamlSchemaVersion: 2 +yamlSchemaVersion: 3 onDiskIndexVersion: 7 dataset: cap-6M construction: + type: onDisk outDegree: [64] efConstruction: [200] neighborOverflow: [1.6f] @@ -24,11 +25,10 @@ construction: # k: 256 # anisotropicThreshold: -1.0 reranking: - - FP + type: FP32 useSavedIndexIfExists: Yes # If yes, the system will attempt to locate a cached copy of the index instead of constructing search: - queryProjection: auto # auto, fp16, fp32, int8; explicit -D overrides YAML topKOverquery: 10: [1.00, 1.25, 1.56, 1.95, 2.44, 3.05, 3.81, 4.77, 5.96, 7.45, 9.31, 11.64] # 100: [1.0] diff --git a/jvector-examples/yaml-configs/index-parameters/cohere-10M.yml b/jvector-examples/yaml-configs/index-parameters/cohere-10M.yml index bfabfdd4d..606ca90b0 100644 --- a/jvector-examples/yaml-configs/index-parameters/cohere-10M.yml +++ b/jvector-examples/yaml-configs/index-parameters/cohere-10M.yml @@ -1,9 +1,10 @@ -yamlSchemaVersion: 2 +yamlSchemaVersion: 3 onDiskIndexVersion: 7 dataset: cohere-10M construction: + type: onDisk outDegree: [64] efConstruction: [200] neighborOverflow: [1.6f] @@ -24,11 +25,10 @@ construction: # k: 256 # anisotropicThreshold: -1.0 reranking: - - FP + type: FP32 useSavedIndexIfExists: Yes # If yes, the system will attempt to locate a cached copy of the index instead of constructing search: - queryProjection: auto # auto, fp16, fp32, int8; explicit -D overrides YAML topKOverquery: 10: [1.00, 1.25, 1.56, 1.95, 2.44, 3.05, 3.81, 4.77, 5.96, 7.45, 9.31, 11.64] # 100: [1.0] diff --git a/jvector-examples/yaml-configs/index-parameters/cohere-english-v3-10M.yml b/jvector-examples/yaml-configs/index-parameters/cohere-english-v3-10M.yml index c641d7747..6a9d0cf49 100644 --- a/jvector-examples/yaml-configs/index-parameters/cohere-english-v3-10M.yml +++ b/jvector-examples/yaml-configs/index-parameters/cohere-english-v3-10M.yml @@ -1,10 +1,11 @@ -yamlSchemaVersion: 2 +yamlSchemaVersion: 3 onDiskIndexVersion: 7 dataset: cohere-english-v3-10M useDatasetPrefetching: false construction: + type: onDisk outDegree: [32] efConstruction: [100] neighborOverflow: [1.2f] @@ -25,11 +26,10 @@ construction: # k: 256 # anisotropicThreshold: -1.0 reranking: - - NVQ + type: NVQ useSavedIndexIfExists: No search: - queryProjection: auto # auto, fp16, fp32, int8; explicit -D overrides YAML topKOverquery: 10: [1.0] useSearchPruning: [No] diff --git a/jvector-examples/yaml-configs/index-parameters/cohere-english-v3-1M.yml b/jvector-examples/yaml-configs/index-parameters/cohere-english-v3-1M.yml index e23035817..8611fca4f 100644 --- a/jvector-examples/yaml-configs/index-parameters/cohere-english-v3-1M.yml +++ b/jvector-examples/yaml-configs/index-parameters/cohere-english-v3-1M.yml @@ -1,11 +1,12 @@ # ASH configuration for DOT_PRODUCT datasets only. -yamlSchemaVersion: 2 +yamlSchemaVersion: 3 onDiskIndexVersion: 7 dataset: cohere-english-v3-1M useDatasetPrefetching: false construction: + type: onDisk outDegree: [64] efConstruction: [200] neighborOverflow: [1.2f] @@ -26,11 +27,10 @@ construction: # k: 256 # anisotropicThreshold: -1.0 reranking: - - NVQ + type: NVQ useSavedIndexIfExists: No # If yes, the system will attempt to locate a cached copy of the index instead of constructing search: - queryProjection: auto # auto, fp16, fp32, int8; explicit -D overrides YAML topKOverquery: 10: [1.0, 2.0, 5.0, 10.0] # 100: [1.0, 2.0] diff --git a/jvector-examples/yaml-configs/index-parameters/colbert-1M.yml b/jvector-examples/yaml-configs/index-parameters/colbert-1M.yml index 244cc712f..df5049fa5 100644 --- a/jvector-examples/yaml-configs/index-parameters/colbert-1M.yml +++ b/jvector-examples/yaml-configs/index-parameters/colbert-1M.yml @@ -1,10 +1,11 @@ -yamlSchemaVersion: 2 +yamlSchemaVersion: 3 onDiskIndexVersion: 7 dataset: colbert-1M useDatasetPrefetching: false construction: + type: onDisk outDegree: [32] efConstruction: [100] neighborOverflow: [1.2f] @@ -27,7 +28,7 @@ construction: # k: 256 # anisotropicThreshold: -1.0 reranking: - - NVQ + type: None useSavedIndexIfExists: No search: diff --git a/jvector-examples/yaml-configs/index-parameters/default.yml b/jvector-examples/yaml-configs/index-parameters/default.yml index 0eb58996e..8ae59d930 100644 --- a/jvector-examples/yaml-configs/index-parameters/default.yml +++ b/jvector-examples/yaml-configs/index-parameters/default.yml @@ -1,5 +1,5 @@ # ASH configuration for DOT_PRODUCT datasets only. -yamlSchemaVersion: 2 +yamlSchemaVersion: 3 onDiskIndexVersion: 7 dataset: default @@ -7,31 +7,47 @@ dataset: default useDatasetPrefetching: false construction: + type: onDisk + # type: inMemory # Omit writerMode, fusedGraph, and useSavedIndexIfExists. + # writerMode: parallel # Default for onDisk. + # writerMode: sequential outDegree: [64] efConstruction: [200] neighborOverflow: [1.2f] addHierarchy: [Yes] refineFinalGraph: [Yes] fusedGraph: [Yes] + # fusedGraph: [No] # Use separate search codes, or source vectors with search type None. compression: - type: ASH + # scoring: symmetric # Also accepts asymmetric or mixed. parameters: - compressionRatio: 32 - optimizer: ITQ - landmarkCount: 1 - bitsPerDimension: 2 -# To use PQ, comment out ASH and uncomment PQ in both sections. + compressionRatio: 32 # Other ratios: 8, 16, 64, 128, 256. + # encodedBits: 808 # Alternative to compressionRatio, includes the 40-bit header. + optimizer: ITQ # Or RANDOM. + landmarkCount: 1 # Symmetric and mixed construction require 1. + bitsPerDimension: 2 # Fused ASH supports 1, 2, and 4. + # queryProjection: auto # Used for asymmetric scoring. See ASH search below. +# Select the compression entries for this case. +# For source scoring, use None in both sections, fusedGraph: [No], and reranking.type: None. +# BQ traversal requires fusedGraph: [No]. +# - type: None +# - type: BQ +# For fused PQ search, select PQ for search and use 256 centroids. # - type: PQ # parameters: # mFactor: 8 +# # m: 96 # Alternative to mFactor. # k: 256 +# # centerData: "No" # Defaults to centering for Euclidean similarity. # anisotropicThreshold: -1.0 reranking: - - NVQ - useSavedIndexIfExists: No # If yes, the system will attempt to locate a cached copy of the index instead of constructing + type: NVQ + # type: FP32 # Rescore with source vectors. + # type: None # Skip rescoring where supported. Set fusedGraph: [No]. + useSavedIndexIfExists: No # Set Yes to reuse a compatible disk index. search: - queryProjection: auto # auto, fp16, fp32, int8; explicit -D overrides YAML topKOverquery: 10: [1.0, 2.0, 5.0, 10.0] # 100: [1.0, 2.0] @@ -39,13 +55,25 @@ search: compression: - type: ASH parameters: - compressionRatio: 32 - optimizer: ITQ - landmarkCount: 1 - bitsPerDimension: 2 -# To use PQ, comment out ASH and uncomment PQ in both sections. + compressionRatio: 32 # Other ratios: 8, 16, 64, 128, 256. + # encodedBits: 808 # Alternative to compressionRatio, includes the 40-bit header. + optimizer: ITQ # Or RANDOM. + landmarkCount: 1 # Symmetric and mixed construction require 1. + bitsPerDimension: 2 # Fused ASH supports 1, 2, and 4. + # queryProjection: auto # fp32, fp16, and int8 are also available. + # queryExecution: direct + # queryExecution: queued + # maxBatchSize: 8 # For queued execution. +# Select the compression entries for this case. +# For source scoring, use None in both sections, fusedGraph: [No], and reranking.type: None. +# BQ traversal requires fusedGraph: [No]. +# - type: None +# - type: BQ +# For fused PQ search, select PQ for search and use 256 centroids. # - type: PQ # parameters: # mFactor: 8 +# # m: 96 # Alternative to mFactor. # k: 256 +# # centerData: "No" # Defaults to centering for Euclidean similarity. # anisotropicThreshold: -1.0 diff --git a/jvector-examples/yaml-configs/index-parameters/dpr-gemma-10M.yml b/jvector-examples/yaml-configs/index-parameters/dpr-gemma-10M.yml index 43811df46..1b4b055ca 100644 --- a/jvector-examples/yaml-configs/index-parameters/dpr-gemma-10M.yml +++ b/jvector-examples/yaml-configs/index-parameters/dpr-gemma-10M.yml @@ -1,9 +1,10 @@ -yamlSchemaVersion: 2 +yamlSchemaVersion: 3 onDiskIndexVersion: 7 dataset: dpr-gemma-10m construction: + type: onDisk outDegree: [32] efConstruction: [100] neighborOverflow: [1.2f] @@ -24,11 +25,10 @@ construction: # k: 256 # anisotropicThreshold: -1.0 reranking: - - NVQ + type: NVQ useSavedIndexIfExists: No search: - queryProjection: auto # auto, fp16, fp32, int8; explicit -D overrides YAML topKOverquery: 10: [1.0] useSearchPruning: [No] diff --git a/jvector-examples/yaml-configs/index-parameters/dpr-gemma-1M.yml b/jvector-examples/yaml-configs/index-parameters/dpr-gemma-1M.yml index 089b235ec..e1dd268d1 100644 --- a/jvector-examples/yaml-configs/index-parameters/dpr-gemma-1M.yml +++ b/jvector-examples/yaml-configs/index-parameters/dpr-gemma-1M.yml @@ -1,9 +1,10 @@ -yamlSchemaVersion: 2 +yamlSchemaVersion: 3 onDiskIndexVersion: 7 dataset: dpr-gemma-1m construction: + type: onDisk outDegree: [64] efConstruction: [200] neighborOverflow: [1.6f] @@ -24,11 +25,10 @@ construction: # k: 256 # anisotropicThreshold: -1.0 reranking: - - FP + type: FP32 useSavedIndexIfExists: Yes # If yes, the system will attempt to locate a cached copy of the index instead of constructing search: - queryProjection: auto # auto, fp16, fp32, int8; explicit -D overrides YAML topKOverquery: 10: [1.00] # 100: [1.0] diff --git a/jvector-examples/yaml-configs/index-parameters/esmc-600-1M.yml b/jvector-examples/yaml-configs/index-parameters/esmc-600-1M.yml index 91e87d0a9..ceb5de9a2 100644 --- a/jvector-examples/yaml-configs/index-parameters/esmc-600-1M.yml +++ b/jvector-examples/yaml-configs/index-parameters/esmc-600-1M.yml @@ -1,9 +1,10 @@ -yamlSchemaVersion: 2 +yamlSchemaVersion: 3 onDiskIndexVersion: 7 dataset: esmc-600-1M construction: + type: onDisk outDegree: [64] efConstruction: [200] neighborOverflow: [1.6f] @@ -24,11 +25,10 @@ construction: # k: 256 # anisotropicThreshold: -1.0 reranking: - - FP + type: FP32 useSavedIndexIfExists: Yes # If yes, the system will attempt to locate a cached copy of the index instead of constructing search: - queryProjection: auto # auto, fp16, fp32, int8; explicit -D overrides YAML topKOverquery: 10: [1.00, 1.25, 1.56, 1.95, 2.44, 3.05, 3.81, 4.77, 5.96, 7.45, 9.31, 11.64] # 100: [1.0] diff --git a/jvector-examples/yaml-configs/index-parameters/esmc-600-6M.yml b/jvector-examples/yaml-configs/index-parameters/esmc-600-6M.yml index b15c4e8e2..0c04dd8a3 100644 --- a/jvector-examples/yaml-configs/index-parameters/esmc-600-6M.yml +++ b/jvector-examples/yaml-configs/index-parameters/esmc-600-6M.yml @@ -1,9 +1,10 @@ -yamlSchemaVersion: 2 +yamlSchemaVersion: 3 onDiskIndexVersion: 7 dataset: esmc-600-6M construction: + type: onDisk outDegree: [64] efConstruction: [200] neighborOverflow: [1.6f] @@ -24,11 +25,10 @@ construction: # k: 256 # anisotropicThreshold: -1.0 reranking: - - FP + type: FP32 useSavedIndexIfExists: Yes # If yes, the system will attempt to locate a cached copy of the index instead of constructing search: - queryProjection: auto # auto, fp16, fp32, int8; explicit -D overrides YAML topKOverquery: 10: [1.00, 1.25, 1.56, 1.95, 2.44, 3.05, 3.81, 4.77, 5.96, 7.45, 9.31, 11.64] # 100: [1.0] diff --git a/jvector-examples/yaml-configs/index-parameters/gecko-100k.yml b/jvector-examples/yaml-configs/index-parameters/gecko-100k.yml index 6d9ca6576..fae9ff783 100644 --- a/jvector-examples/yaml-configs/index-parameters/gecko-100k.yml +++ b/jvector-examples/yaml-configs/index-parameters/gecko-100k.yml @@ -1,9 +1,10 @@ -yamlSchemaVersion: 2 +yamlSchemaVersion: 3 onDiskIndexVersion: 7 dataset: gecko-100k construction: + type: onDisk outDegree: [64] efConstruction: [200] neighborOverflow: [1.2f] @@ -24,11 +25,10 @@ construction: # k: 256 # anisotropicThreshold: -1.0 reranking: - - NVQ + type: NVQ useSavedIndexIfExists: No # If yes, the system will attempt to locate a cached copy of the index instead of constructing search: - queryProjection: auto # auto, fp16, fp32, int8; explicit -D overrides YAML topKOverquery: 10: [1.0, 2.0, 5.0, 10.0] # 100: [1.0, 2.0] diff --git a/jvector-examples/yaml-configs/index-parameters/glove-100-angular.yml b/jvector-examples/yaml-configs/index-parameters/glove-100-angular.yml index a57756a6a..d3bf7db08 100644 --- a/jvector-examples/yaml-configs/index-parameters/glove-100-angular.yml +++ b/jvector-examples/yaml-configs/index-parameters/glove-100-angular.yml @@ -1,11 +1,12 @@ # This is here for loader testing purposes only, use other datasets for realistic test data -yamlSchemaVersion: 2 +yamlSchemaVersion: 3 onDiskIndexVersion: 7 dataset: glove-100-angular useDatasetPrefetching: false construction: + type: onDisk outDegree: [32] efConstruction: [100] neighborOverflow: [1.2f] @@ -28,7 +29,7 @@ construction: # k: 256 # anisotropicThreshold: -1.0 reranking: - - NVQ + type: None useSavedIndexIfExists: No search: diff --git a/jvector-examples/yaml-configs/index-parameters/glove-200-angular.yml b/jvector-examples/yaml-configs/index-parameters/glove-200-angular.yml index 82d766576..0471e1284 100644 --- a/jvector-examples/yaml-configs/index-parameters/glove-200-angular.yml +++ b/jvector-examples/yaml-configs/index-parameters/glove-200-angular.yml @@ -1,11 +1,12 @@ # This is here for loader testing purposes only, use other datasets for realistic test data -yamlSchemaVersion: 2 +yamlSchemaVersion: 3 onDiskIndexVersion: 7 dataset: glove-200-angular useDatasetPrefetching: false construction: + type: onDisk outDegree: [32] efConstruction: [100] neighborOverflow: [1.2f] @@ -28,7 +29,7 @@ construction: # k: 256 # anisotropicThreshold: -1.0 reranking: - - NVQ + type: None useSavedIndexIfExists: No search: diff --git a/jvector-examples/yaml-configs/index-parameters/glove-25-angular.yml b/jvector-examples/yaml-configs/index-parameters/glove-25-angular.yml index d662d965f..de82a8a69 100644 --- a/jvector-examples/yaml-configs/index-parameters/glove-25-angular.yml +++ b/jvector-examples/yaml-configs/index-parameters/glove-25-angular.yml @@ -1,11 +1,12 @@ # This is here for loader testing purposes only, use other datasets for realistic test data -yamlSchemaVersion: 2 +yamlSchemaVersion: 3 onDiskIndexVersion: 7 dataset: glove-25-angular useDatasetPrefetching: false construction: + type: onDisk outDegree: [32] efConstruction: [100] neighborOverflow: [1.2f] @@ -28,7 +29,7 @@ construction: # k: 256 # anisotropicThreshold: -1.0 reranking: - - NVQ + type: None useSavedIndexIfExists: No search: diff --git a/jvector-examples/yaml-configs/index-parameters/glove-50-angular.yml b/jvector-examples/yaml-configs/index-parameters/glove-50-angular.yml index e04f6bd3e..721ad09b3 100644 --- a/jvector-examples/yaml-configs/index-parameters/glove-50-angular.yml +++ b/jvector-examples/yaml-configs/index-parameters/glove-50-angular.yml @@ -1,11 +1,12 @@ # This is here for loader testing purposes only, use other datasets for realistic test data -yamlSchemaVersion: 2 +yamlSchemaVersion: 3 onDiskIndexVersion: 7 dataset: glove-50-angular useDatasetPrefetching: false construction: + type: onDisk outDegree: [32] efConstruction: [100] neighborOverflow: [1.2f] @@ -28,7 +29,7 @@ construction: # k: 256 # anisotropicThreshold: -1.0 reranking: - - NVQ + type: None useSavedIndexIfExists: No search: diff --git a/jvector-examples/yaml-configs/index-parameters/lastfm-64-dot.yml b/jvector-examples/yaml-configs/index-parameters/lastfm-64-dot.yml index d7b65a093..eab27443a 100644 --- a/jvector-examples/yaml-configs/index-parameters/lastfm-64-dot.yml +++ b/jvector-examples/yaml-configs/index-parameters/lastfm-64-dot.yml @@ -1,11 +1,12 @@ # This is here for loader testing purposes only, use other datasets for realistic test data -yamlSchemaVersion: 2 +yamlSchemaVersion: 3 onDiskIndexVersion: 7 dataset: lastfm-64-dot useDatasetPrefetching: false construction: + type: onDisk outDegree: [32] efConstruction: [100] neighborOverflow: [1.2f] @@ -28,7 +29,7 @@ construction: # k: 256 # anisotropicThreshold: -1.0 reranking: - - NVQ + type: None useSavedIndexIfExists: No search: diff --git a/jvector-examples/yaml-configs/index-parameters/mpnet-10M.yml b/jvector-examples/yaml-configs/index-parameters/mpnet-10M.yml index 588dbbb02..307b77d5b 100644 --- a/jvector-examples/yaml-configs/index-parameters/mpnet-10M.yml +++ b/jvector-examples/yaml-configs/index-parameters/mpnet-10M.yml @@ -1,10 +1,11 @@ # MPNet-10M: ASH(b=2,d=384), nominal 32x compression. # Fused graph scoring, NVQ reranking, and saved model/index reuse. -yamlSchemaVersion: 2 +yamlSchemaVersion: 3 onDiskIndexVersion: 7 dataset: mpnet-10m construction: + type: onDisk outDegree: [32] efConstruction: [100] neighborOverflow: [1.2f] @@ -18,13 +19,13 @@ construction: optimizer: ITQ landmarkCount: 1 bitsPerDimension: 2 - reranking: [NVQ] + reranking: + type: NVQ # A fresh case directory measures training/encoding/construction. Later JVMs # in that directory reuse exactly the same model and index for querying. useSavedIndexIfExists: Yes search: - queryProjection: auto # auto, fp16, fp32, int8; explicit -D overrides YAML topKOverquery: 10: [1.0, 2.0] useSearchPruning: [No] diff --git a/jvector-examples/yaml-configs/index-parameters/mpnet-1M.yml b/jvector-examples/yaml-configs/index-parameters/mpnet-1M.yml index 9c3140b95..a85802db0 100644 --- a/jvector-examples/yaml-configs/index-parameters/mpnet-1M.yml +++ b/jvector-examples/yaml-configs/index-parameters/mpnet-1M.yml @@ -1,9 +1,10 @@ -yamlSchemaVersion: 2 +yamlSchemaVersion: 3 onDiskIndexVersion: 7 dataset: mpnet-1m construction: + type: onDisk outDegree: [64] efConstruction: [200] neighborOverflow: [1.6f] @@ -24,11 +25,10 @@ construction: # k: 256 # anisotropicThreshold: -1.0 reranking: - - FP + type: FP32 useSavedIndexIfExists: Yes # If yes, the system will attempt to locate a cached copy of the index instead of constructing search: - queryProjection: auto # auto, fp16, fp32, int8; explicit -D overrides YAML topKOverquery: 10: [1.00, 1.25, 1.56, 1.95, 2.44, 3.05, 3.81, 4.77, 5.96, 7.45, 9.31, 11.64] # 100: [1.0] diff --git a/jvector-examples/yaml-configs/index-parameters/nytimes-256-angular.yml b/jvector-examples/yaml-configs/index-parameters/nytimes-256-angular.yml index 19d9d7c79..7f9ed766f 100644 --- a/jvector-examples/yaml-configs/index-parameters/nytimes-256-angular.yml +++ b/jvector-examples/yaml-configs/index-parameters/nytimes-256-angular.yml @@ -1,11 +1,12 @@ # This is here for loader testing purposes only, use other datasets for realistic test data -yamlSchemaVersion: 2 +yamlSchemaVersion: 3 onDiskIndexVersion: 7 dataset: nytimes-256-angular useDatasetPrefetching: false construction: + type: onDisk outDegree: [32] efConstruction: [100] neighborOverflow: [1.2f] @@ -28,7 +29,7 @@ construction: # k: 256 # anisotropicThreshold: -1.0 reranking: - - NVQ + type: None useSavedIndexIfExists: No search: diff --git a/jvector-examples/yaml-configs/index-parameters/openai-v3-small-100k.yml b/jvector-examples/yaml-configs/index-parameters/openai-v3-small-100k.yml index 6ea8cdc74..557a66573 100644 --- a/jvector-examples/yaml-configs/index-parameters/openai-v3-small-100k.yml +++ b/jvector-examples/yaml-configs/index-parameters/openai-v3-small-100k.yml @@ -1,9 +1,10 @@ -yamlSchemaVersion: 2 +yamlSchemaVersion: 3 onDiskIndexVersion: 7 dataset: openai-v3-small-1536-100k construction: + type: onDisk outDegree: [64] efConstruction: [200] neighborOverflow: [1.6f] @@ -24,11 +25,10 @@ construction: # k: 256 # anisotropicThreshold: -1.0 reranking: - - FP + type: FP32 useSavedIndexIfExists: Yes # If yes, the system will attempt to locate a cached copy of the index instead of constructing search: - queryProjection: auto # auto, fp16, fp32, int8; explicit -D overrides YAML topKOverquery: 10: [1.00, 1.25, 1.56, 1.95, 2.44, 3.05, 3.81, 4.77, 5.96, 7.45, 9.31, 11.64] # 100: [1.0] diff --git a/jvector-examples/yaml-configs/index-parameters/personahub-1M.yml b/jvector-examples/yaml-configs/index-parameters/personahub-1M.yml index 42608ede4..d55c1f0c5 100644 --- a/jvector-examples/yaml-configs/index-parameters/personahub-1M.yml +++ b/jvector-examples/yaml-configs/index-parameters/personahub-1M.yml @@ -1,9 +1,10 @@ -yamlSchemaVersion: 2 +yamlSchemaVersion: 3 onDiskIndexVersion: 7 dataset: personahub-1m construction: + type: onDisk outDegree: [64] efConstruction: [200] neighborOverflow: [1.6f] @@ -24,11 +25,10 @@ construction: # k: 256 # anisotropicThreshold: -1.0 reranking: - - FP + type: FP32 useSavedIndexIfExists: Yes # If yes, the system will attempt to locate a cached copy of the index instead of constructing search: - queryProjection: auto # auto, fp16, fp32, int8; explicit -D overrides YAML topKOverquery: 10: [1.00, 1.25, 1.56, 1.95, 2.44, 3.05, 3.81, 4.77, 5.96, 7.45, 9.31, 11.64] # 100: [1.0] diff --git a/jvector-examples/yaml-configs/index-parameters/sift-128-euclidean.yml b/jvector-examples/yaml-configs/index-parameters/sift-128-euclidean.yml index 995356efa..44ce2626d 100644 --- a/jvector-examples/yaml-configs/index-parameters/sift-128-euclidean.yml +++ b/jvector-examples/yaml-configs/index-parameters/sift-128-euclidean.yml @@ -1,11 +1,12 @@ # This is here for loader testing purposes only, use other datasets for realistic test data -yamlSchemaVersion: 2 +yamlSchemaVersion: 3 onDiskIndexVersion: 7 dataset: sift-128-euclidean useDatasetPrefetching: false construction: + type: onDisk outDegree: [32] efConstruction: [100] neighborOverflow: [1.2f] @@ -21,7 +22,7 @@ construction: # k: 256 # anisotropicThreshold: -1.0 reranking: - - NVQ + type: None useSavedIndexIfExists: No search: diff --git a/jvector-examples/yaml-configs/index-parameters/tahoe-x1-1M.yml b/jvector-examples/yaml-configs/index-parameters/tahoe-x1-1M.yml index 5d277bbf6..c8692c4f1 100644 --- a/jvector-examples/yaml-configs/index-parameters/tahoe-x1-1M.yml +++ b/jvector-examples/yaml-configs/index-parameters/tahoe-x1-1M.yml @@ -1,9 +1,10 @@ -yamlSchemaVersion: 2 +yamlSchemaVersion: 3 onDiskIndexVersion: 7 dataset: tahoe-x1-1m construction: + type: onDisk outDegree: [32] efConstruction: [100] neighborOverflow: [1.6f] @@ -24,11 +25,10 @@ construction: # k: 256 # anisotropicThreshold: -1.0 reranking: - - FP + type: FP32 useSavedIndexIfExists: Yes # If yes, the system will attempt to locate a cached copy of the index instead of constructing search: - queryProjection: auto # auto, fp16, fp32, int8; explicit -D overrides YAML topKOverquery: # 10: [1.00, 1.25, 1.56, 1.95, 2.44, 3.05, 3.81, 4.77, 5.96, 7.45, 9.31, 11.64] 10: [1.00, 1.25, 1.56, 1.95, 2.44] diff --git a/jvector-tests/src/test/java/io/github/jbellis/jvector/example/TestCompressedBuildPreflight.java b/jvector-tests/src/test/java/io/github/jbellis/jvector/example/TestCompressedBuildPreflight.java index 89f6c32d8..1b909e64c 100644 --- a/jvector-tests/src/test/java/io/github/jbellis/jvector/example/TestCompressedBuildPreflight.java +++ b/jvector-tests/src/test/java/io/github/jbellis/jvector/example/TestCompressedBuildPreflight.java @@ -51,7 +51,7 @@ public void fusedPqWithoutTwoHundredFiftySixClustersFailsBeforeWriting() throws var ds = dataset("preflight-" + UUID.randomUUID(), 16, 128); var work = Files.createTempDirectory("compressed-build-preflight"); var cacheDir = work.resolve("saved"); - var pqFile = Path.of("pq_cache").resolve(new CompressorParameters.PQParameters(4, 16, false, -1f).idStringFor(ds)); + var pqFile = Path.of("compressor_cache").resolve(new CompressorParameters.PQParameters(4, 16, false, -1f).idStringFor(ds)); try { var cache = OnDiskGraphIndexCache.initialize(cacheDir); var e = assertThrows(IllegalArgumentException.class, () -> Grid.runOneGraph(cache, diff --git a/jvector-tests/src/test/java/io/github/jbellis/jvector/example/TestDatasetPrefetching.java b/jvector-tests/src/test/java/io/github/jbellis/jvector/example/TestDatasetPrefetching.java index 7982c7f69..9034a23fd 100644 --- a/jvector-tests/src/test/java/io/github/jbellis/jvector/example/TestDatasetPrefetching.java +++ b/jvector-tests/src/test/java/io/github/jbellis/jvector/example/TestDatasetPrefetching.java @@ -136,7 +136,7 @@ private MultiConfig config(String build, String search, boolean fused, String re public List> getQueryVectors(){return List.of(vectors.get(0));} public List> getGroundTruth(){return List.of(List.of(0));} }; - var cacheFile = Path.of("pq_cache").resolve(new CompressorParameters.PQParameters(4,16,false,-1f).idStringFor(ds)); + var cacheFile = Path.of("compressor_cache").resolve(new CompressorParameters.PQParameters(4,16,false,-1f).idStringFor(ds)); var work = Files.createTempDirectory("bulk-grid-wiring"); try { Grid.runOneGraph(OnDiskGraphIndexCache.initialize(work.resolve("saved")), List.of(EnumSet.of(FeatureId.INLINE_VECTORS),EnumSet.of(FeatureId.NVQ_VECTORS)), diff --git a/jvector-tests/src/test/java/io/github/jbellis/jvector/example/TestGridASHIntegration.java b/jvector-tests/src/test/java/io/github/jbellis/jvector/example/TestGridASHIntegration.java index 248bc19eb..fddfc8c7d 100644 --- a/jvector-tests/src/test/java/io/github/jbellis/jvector/example/TestGridASHIntegration.java +++ b/jvector-tests/src/test/java/io/github/jbellis/jvector/example/TestGridASHIntegration.java @@ -19,11 +19,16 @@ import io.github.jbellis.jvector.example.benchmarks.datasets.SimpleDataSet; import io.github.jbellis.jvector.example.reporting.RunArtifacts; import io.github.jbellis.jvector.example.util.CompressorParameters; +import io.github.jbellis.jvector.example.util.OnDiskGraphIndexCache; +import io.github.jbellis.jvector.example.yaml.MetricSelection; import io.github.jbellis.jvector.example.yaml.RunConfig; import io.github.jbellis.jvector.example.yaml.MultiConfig; import io.github.jbellis.jvector.example.yaml.SearchParameters; +import io.github.jbellis.jvector.graph.disk.OnDiskGraphIndex; import io.github.jbellis.jvector.graph.disk.feature.FeatureId; import io.github.jbellis.jvector.quantization.AsymmetricHashing; +import io.github.jbellis.jvector.util.Bits; +import io.github.jbellis.jvector.vector.VectorUtil; import io.github.jbellis.jvector.vector.VectorSimilarityFunction; import io.github.jbellis.jvector.vector.VectorizationProvider; import org.junit.Test; @@ -43,7 +48,7 @@ public class TestGridASHIntegration { @Rule public final TemporaryFolder temporary = new TemporaryFolder(); @Test(timeout = 120_000) - public void gridSpecBuildsFusedASHAndRunsQueuedQueriesWithPrecisionMetadata() throws Exception { + public void gridSpecBuildsFusedASHAndReportsPrecisionMetadata() throws Exception { var vts = VectorizationProvider.getInstance().getVectorTypeSupport(); var random = new Random(19); var vectors = new ArrayList>(); @@ -62,7 +67,6 @@ public void gridSpecBuildsFusedASHAndRunsQueuedQueriesWithPrecisionMetadata() th .topKGrid(Map.of(1, List.of(1.0, 2.0))).usePruningGrid(List.of(false)).build(), false); var config = new RunConfig(); config.benchmarks = Map.of("accuracy", List.of("recall")); - config.queryExecution.mode = "queued"; config.logging = new RunConfig.RunLogging(); config.logging.type = "csv"; config.logging.dir = temporary.getRoot().toString(); @@ -89,13 +93,227 @@ public void gridSpecBuildsFusedASHAndRunsQueuedQueriesWithPrecisionMetadata() th assertTrue(cells[headers.indexOf("feature_set")].contains("FUSED_ASH")); } var console = output.toString(StandardCharsets.UTF_8); - assertTrue(console, console.contains("executionMode") && console.contains("queued")); - assertTrue(console, console.contains("batchPreparation")); + assertTrue(console, console.contains("executionMode direct")); } finally { System.setOut(originalOut); if (previous == null) System.clearProperty(SearchParameters.ASH_QUERY_PROJECTION_PROPERTY); else System.setProperty(SearchParameters.ASH_QUERY_PROJECTION_PROPERTY, previous); - Files.deleteIfExists(Path.of("pq_cache").resolve(parameters.idStringFor(data))); + Files.deleteIfExists(Path.of("compressor_cache").resolve(parameters.idStringFor(data))); } } + @Test(timeout = 120_000) + public void schema3FlushesSearchCodesOverSourceScoredGraph() throws Exception { + var data = schema3Data(); + String callerThreads = expectedDirectThreadCount(); + for (String codec : List.of("ASH", "PQ")) { + var search = "ASH".equals(codec) + ? String.join("\n", "- type: ASH", " parameters:", " optimizer: RANDOM", + " encodedBits: 72", " bitsPerDimension: 2", " queryProjection: fp32", + " queryExecution: queued", " maxBatchSize: 4") + : "- type: PQ\n parameters: {m: 4, k: 256}"; + var config = schema3Config("onDisk", "ASH".equals(codec) ? "sequential" : "parallel", + true, "NVQ", search); + var spec = GridSpec.fromConfig(config); + try (var projection = new SearchParameters.AshQueryProjectionSetting()) { + projection.apply(List.of(config)); + var artifacts = RunArtifacts.open(csvRunConfig(), List.of(config)); + try { + Grid.run(data, spec, artifacts); + var lines = Files.readAllLines(artifacts.experimentsWriter().path()); + assertEquals(2, lines.size()); + assertTrue(lines.get(0), lines.get(0).contains("index_type,writer_mode,reranking_type")); + assertTrue(lines.get(1), lines.get(1).contains("FUSED_" + codec)); + assertTrue(lines.get(1), lines.get(1).matches("ASH".equals(codec) + ? ".*,onDisk,sequential,NVQ,,queued,[0-9]+,4,.*" + : ".*,onDisk,parallel,NVQ,,direct," + callerThreads + ",,.*")); + assertTrue(lines.get(1), lines.get(1).contains("NVQ_VECTORS")); + assertFalse(lines.get(1), lines.get(1).contains("INLINE_VECTORS")); + } finally { + var params = config.search.getCompressorParameters().get(0).apply(data); + Files.deleteIfExists(Path.of("compressor_cache").resolve(params.idStringFor(data))); + } + } + } + } + + @Test(timeout = 120_000) + public void schema3SourceOnlySearchUsesNoReranker() throws Exception { + var data = schema3Data(); + for (String type : List.of("inMemory", "onDisk")) { + var config = schema3Config(type, "sequential", false, "None", "- type: None"); + var spec = GridSpec.fromConfig(config); + var build = spec.buildPoints().get(0); + var features = spec.featuresFor(build, spec.search.cases().get(0)); + var directory = temporary.newFolder().toPath(); + IndexBuildStrategy strategy = "inMemory".equals(type) + ? new InMemoryBuild(directory) + : new CompressedOnDiskBuild( + OnDiskGraphIndexCache.disabled(), + directory, null, new ConstructionMetrics(true), null, "test"); + var built = strategy.build(features, build, data); + var featureSet = features.get(0); + assertEquals("onDisk".equals(type) ? Set.of(FeatureId.INLINE_VECTORS) : Set.of(), featureSet); + try (var index = built.indexes.get(featureSet); + var cs = new Grid.ConfiguredSystem(data, index, null, featureSet, "None", null); + var view = index.getView()) { + if (index instanceof OnDiskGraphIndex) { + assertEquals(featureSet, ((OnDiskGraphIndex) index).getFeatureSet()); + } + var scorer = cs.scoreProviderFor(data.getQueryVectors().get(0), view); + assertTrue(scorer.scoreFunction().isExact()); + assertNull(scorer.reranker()); + var searcher = cs.getSearcher(); + var query = data.getQueryVectors().get(0); + var result = searcher.search(cs.scoreProviderFor(query, searcher.getView()), + 1, 2, 0.0f, 0.0f, Bits.ALL); + assertEquals(1, result.getNodes().length); + assertEquals(0, result.getRerankedCount()); + var best = result.getNodes()[0]; + assertEquals(data.getSimilarityFunction().compare(query, data.getBaseVectors().get(best.node)), + best.score, 0.000001f); + } + var artifacts = RunArtifacts.open(csvRunConfig(), List.of(config)); + Grid.run(data, spec, artifacts); + assertEquals(2, Files.readAllLines(artifacts.experimentsWriter().path()).size()); + } + } + + @Test(timeout = 120_000) + public void schema3SupportsOptionalRerankingForNonFusedCompressedSearch() throws Exception { + var data = schema3Data(); + String callerThreads = expectedDirectThreadCount(); + for (String reranker : List.of("None", "NVQ")) { + var config = schema3Config("inMemory", null, false, reranker, "- type: BQ"); + var run = csvRunConfig(); + run.logging.metrics = new MetricSelection(); + run.logging.metrics.put("construction", List.of("index_quant_time_s")); + var artifacts = RunArtifacts.open(run, List.of(config)); + Grid.run(data, GridSpec.fromConfig(config), artifacts); + var lines = Files.readAllLines(artifacts.experimentsWriter().path()); + assertEquals(2, lines.size()); + assertTrue(lines.get(1), lines.get(1).matches(".*,inMemory,," + reranker + ",,direct," + callerThreads + ",,.*")); + assertEquals("NVQ".equals(reranker), + lines.get(0).contains("construction.index_quant_time_s.NVQ.encoding_time_s")); + } + } + + @Test(timeout = 120_000) + public void schema3BuildsAndFlushesWithTheSameAshSettings() throws Exception { + var data = schema3Data(); + String ash = "- type: ASH\n parameters: {optimizer: RANDOM, encodedBits: 72, bitsPerDimension: 2}"; + var config = schema3Config("onDisk", "parallel", true, "FP32", + ash.replace(" parameters:", " scoring: symmetric\n parameters:"), ash); + var params = config.search.getCompressorParameters().get(0).apply(data); + try { + var artifacts = RunArtifacts.open(csvRunConfig(), List.of(config)); + Grid.run(data, GridSpec.fromConfig(config), artifacts); + var lines = Files.readAllLines(artifacts.experimentsWriter().path()); + assertEquals(2, lines.size()); + assertTrue(lines.get(1), lines.get(1).contains("FUSED_ASH")); + assertTrue(lines.get(1), lines.get(1).matches(".*,onDisk,parallel,FP32,symmetric-all,direct," + + expectedDirectThreadCount() + ",,.*")); + } finally { + Files.deleteIfExists(Path.of("compressor_cache").resolve(params.idStringFor(data))); + } + } + + @Test + public void schema3CacheHitSkipsBuildAndFlush() throws Exception { + var data = schema3Data(); + var config = schema3Config("onDisk", "parallel", false, "None", "- type: None"); + var spec = GridSpec.fromConfig(config); + var build = spec.buildPoints().get(0); + var features = spec.featuresFor(build, spec.search.cases().get(0)); + var cache = OnDiskGraphIndexCache.initialize(temporary.newFolder().toPath()); + var work = temporary.newFolder().toPath(); + var fresh = new CompressedOnDiskBuild(cache, work, () -> null, new ConstructionMetrics(true), + null, "schema3:None").build(features, build, data); + var featureSet = features.get(0); + try (var index = fresh.indexes.get(featureSet)) { + assertNotNull(fresh.buildTimeS); + assertEquals(data.getBaseRavv().size(), index.size(0)); + } + + var reused = new CompressedOnDiskBuild(cache, work, () -> { + throw new AssertionError("cache hit tried to prepare a build compressor"); + }, new ConstructionMetrics(true), null, "schema3:None").build(features, build, data); + try (var index = reused.indexes.get(featureSet)) { + assertNull(reused.buildTimeS); + assertEquals(fresh.fileSizes, reused.fileSizes); + assertEquals(data.getBaseRavv().size(), index.size(0)); + } + } + + @Test + public void schema3RejectsFusedSearchWithoutReranking() { + var error = assertThrows(IllegalArgumentException.class, + () -> schema3Config("onDisk", "parallel", true, "None", + "- type: PQ\n parameters: {m: 4, k: 256}")); + assertTrue(error.getMessage(), error.getMessage().contains("fusedGraph requires construction.reranking.type")); + } + + private SimpleDataSet schema3Data() { + var support = VectorizationProvider.getInstance().getVectorTypeSupport(); + var random = new Random(91); + var vectors = new ArrayList>(); + for (int i = 0; i < 288; i++) { + var vector = support.createFloatVector(32); + for (int d = 0; d < 32; d++) vector.set(d, random.nextFloat() - 0.5f); + VectorUtil.l2normalize(vector); + vectors.add(vector); + } + return new SimpleDataSet("grid-schema3-" + UUID.randomUUID(), VectorSimilarityFunction.DOT_PRODUCT, + vectors, List.of(vectors.get(0)), List.of(List.of(0))); + } + + private static String expectedDirectThreadCount() { + int threads = Integer.getInteger("jvector.bench.queryThreads", 0); + return threads == 0 ? "" : Integer.toString(threads); + } + + private RunConfig csvRunConfig() throws Exception { + var config = new RunConfig(); + config.benchmarks = Map.of("accuracy", List.of("recall")); + config.logging = new RunConfig.RunLogging(); + config.logging.type = "csv"; + config.logging.dir = temporary.newFolder().toString(); + config.logging.benchmarks = config.benchmarks; + return config; + } + + private MultiConfig schema3Config(String type, String writer, boolean fused, String reranker, String search) + throws Exception { + return schema3Config(type, writer, fused, reranker, "- type: None", search); + } + + private MultiConfig schema3Config(String type, String writer, boolean fused, String reranker, + String construction, String search) throws Exception { + var path = temporary.newFile().toPath(); + String diskSettings = "onDisk".equals(type) + ? " writerMode: " + writer + "\n fusedGraph: [" + fused + "]\n" + : ""; + String yaml = String.join("\n", + "yamlSchemaVersion: 3", + "onDiskIndexVersion: " + OnDiskGraphIndex.CURRENT_VERSION, + "dataset: test", + "construction:", + " type: " + type, + diskSettings + " outDegree: [8]", + " efConstruction: [32]", + " neighborOverflow: [1.2f]", + " addHierarchy: [No]", + " refineFinalGraph: [No]", + " compression:", + " " + construction.replace("\n", "\n "), + " reranking:", + " type: " + reranker, + "search:", + " topKOverquery:", + " 1: [2.0]", + " useSearchPruning: [No]", + " compression:", + " " + search.replace("\n", "\n ")); + Files.writeString(path, yaml); + return MultiConfig.getConfig(path.toString()); + } } diff --git a/jvector-tests/src/test/java/io/github/jbellis/jvector/example/TestGridDeferredFeatureParallelism.java b/jvector-tests/src/test/java/io/github/jbellis/jvector/example/TestGridDeferredFeatureParallelism.java index 7a5bbd3b7..1f8ba1f4c 100644 --- a/jvector-tests/src/test/java/io/github/jbellis/jvector/example/TestGridDeferredFeatureParallelism.java +++ b/jvector-tests/src/test/java/io/github/jbellis/jvector/example/TestGridDeferredFeatureParallelism.java @@ -148,7 +148,7 @@ private static void runGrid(List> list, RecordingVectors vectors, config.setParallelBuild(previous); TestUtil.deleteQuietly(work); // Grid caches codebooks by dataset name in the working directory; remove this run's. - Files.deleteIfExists(Path.of("pq_cache").resolve(pqParameters.idStringFor(ds))); + Files.deleteIfExists(Path.of("compressor_cache").resolve(pqParameters.idStringFor(ds))); } } diff --git a/jvector-tests/src/test/java/io/github/jbellis/jvector/example/TestGridSpec.java b/jvector-tests/src/test/java/io/github/jbellis/jvector/example/TestGridSpec.java index 2e8adbd22..7d0c797aa 100644 --- a/jvector-tests/src/test/java/io/github/jbellis/jvector/example/TestGridSpec.java +++ b/jvector-tests/src/test/java/io/github/jbellis/jvector/example/TestGridSpec.java @@ -25,11 +25,16 @@ import io.github.jbellis.jvector.graph.disk.feature.FeatureId; import org.junit.Test; +import java.nio.file.Files; +import java.nio.file.Path; import java.util.ArrayList; import java.util.EnumSet; import java.util.List; +import java.util.HashSet; +import java.util.Set; import java.util.Map; import java.util.function.Function; +import java.util.stream.Collectors; import static org.junit.Assert.*; @@ -193,4 +198,38 @@ public void fromConfigRejectsAnUnsetCacheFlag() { var e = assertThrows(NullPointerException.class, () -> GridSpec.fromConfig(config)); assertTrue(e.getMessage(), e.getMessage().contains("useSavedIndexIfExists")); } + @Test + public void checkedInIndexParametersParseAsSchema3() throws Exception { + Path directory = Path.of("jvector-examples", "yaml-configs", "index-parameters"); + if (!Files.isDirectory(directory)) directory = Path.of("..").resolve(directory); + int checked = 0; + var sourceOnlyFiles = Set.of("colbert-1M.yml", "glove-25-angular.yml", "glove-50-angular.yml", + "glove-100-angular.yml", "glove-200-angular.yml", "lastfm-64-dot.yml", + "nytimes-256-angular.yml", "sift-128-euclidean.yml"); + var sourceOnly = new HashSet(); + try (var paths = Files.list(directory)) { + for (var path : paths.filter(p -> p.toString().endsWith(".yml")).collect(Collectors.toList())) { + var config = MultiConfig.getConfig(path.toAbsolutePath().toString()); + assertEquals(path.toString(), 3, config.getYamlSchemaVersion()); + assertTrue(path.toString(), config.construction.neighborOverflow.stream().allMatch(Float.class::isInstance)); + var spec = GridSpec.fromConfig(config); + var name = path.getFileName().toString(); + if (sourceOnlyFiles.contains(name)) { + for (var point : spec.buildPoints()) { + assertEquals("onDisk", point.indexType); + assertEquals(CompressorParameters.NONE, point.buildCompressor.apply(null)); + assertEquals("None", point.rerankingType); + for (var search : spec.search.cases()) { + assertEquals(CompressorParameters.NONE, search.searchCompressors.get(0).apply(null)); + assertEquals(List.of(Set.of(FeatureId.INLINE_VECTORS)), spec.featuresFor(point, search)); + } + } + sourceOnly.add(name); + } + checked++; + } + } + assertTrue(checked >= 26); + assertEquals(sourceOnlyFiles, sourceOnly); + } } From 3671a37820d77a320b7f48a313d2f3f4cf9f7abe Mon Sep 17 00:00:00 2001 From: Ted Willke Date: Sun, 8 Nov 2026 00:14:00 +0000 Subject: [PATCH 161/243] Use the JVector API in HelloVectorWorld Build an in-memory graph over the Ada002 100k dataset and search it directly with GraphSearcher. --- .../jvector/example/HelloVectorWorld.java | 48 ++++++++++--------- 1 file changed, 25 insertions(+), 23 deletions(-) diff --git a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/HelloVectorWorld.java b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/HelloVectorWorld.java index bec06b41d..83823a028 100644 --- a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/HelloVectorWorld.java +++ b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/HelloVectorWorld.java @@ -17,34 +17,36 @@ package io.github.jbellis.jvector.example; import io.github.jbellis.jvector.example.benchmarks.datasets.DataSets; -import io.github.jbellis.jvector.example.reporting.RunArtifacts; -import io.github.jbellis.jvector.example.yaml.MultiConfig; -import io.github.jbellis.jvector.example.yaml.RunConfig; +import io.github.jbellis.jvector.graph.GraphIndexBuilder; +import io.github.jbellis.jvector.graph.GraphSearcher; +import io.github.jbellis.jvector.util.Bits; import java.io.IOException; -import java.util.List; +/** Builds a graph and searches it through the library API. */ public class HelloVectorWorld { public static void main(String[] args) throws IOException { - System.out.println("Heap space available is " + Runtime.getRuntime().maxMemory()); - String datasetName = "ada002-100k"; - - // Dataset-tuned config (construction/search grids) - MultiConfig config = MultiConfig.getDefaultConfig(datasetName); - - // Run-level policy config (benchmarks/console/logging + run metadata) - RunConfig runCfg = RunConfig.loadDefault(); - - var ds = DataSets.loadDataSet(datasetName).orElseThrow( - () -> new RuntimeException("dataset " + datasetName + " not found")) - .getDataSet(); - - // Run artifacts + selections (sys_info/dataset_info/experiments.csv) - RunArtifacts artifacts = RunArtifacts.open(runCfg, List.of(config)); - artifacts.registerDataset(datasetName, ds); - - // Run - Grid.run(ds, GridSpec.fromConfig(config), artifacts); + var loaded = DataSets.loadDataSet(datasetName) + .orElseThrow(() -> new IllegalArgumentException("Dataset not found: " + datasetName)); + try (var dataset = loaded.getDataSet()) { + var vectors = dataset.getBaseRavv(); + var similarity = dataset.getSimilarityFunction(); + var query = dataset.getQueryVectors().get(0); + + try (var builder = GraphIndexBuilder.builder(vectors, similarity, 32) + .withBeamWidth(100) + .withNeighborOverflow(1.2f) + .withAlpha(1.2f) + .build(); + var graph = builder.build(vectors)) { + var result = GraphSearcher.search(query, 10, vectors, similarity, graph, Bits.ALL); + System.out.printf("Nearest neighbors to the first query in %s (%d indexed vectors)%n", + datasetName, graph.size(0)); + for (var match : result.getNodes()) { + System.out.printf("vector %d score %.6f%n", match.node, match.score); + } + } + } } } From 88df2918526555c2c483c88423eba257c80371f7 Mon Sep 17 00:00:00 2001 From: Ted Willke Date: Sun, 8 Nov 2026 18:46:02 +0000 Subject: [PATCH 162/243] Avoid per-call heap segment allocation in symmetric ASH pair kernel The downcall handles lived in final instance fields of a Binding object, which HotSpot does not treat as constants. invokeExact therefore went through the generic invoker, the downcall stub was not inlined, and the two MemorySegment.ofArray wrappers (plus their heap sessions) escaped on every pair score: 144 bytes per call. During graph construction this was 95% of all allocation (about 25 GB/s on 48 cores) and drove a young GC roughly every half second. Hold the handles in static final fields, create each heap segment in the same small method that calls invokeExact, and move tracing, exception construction and the large-input Arena path into cold helpers so the hot methods stay well under the inlining limits. Behavior, exception types and check order are unchanged. Verification: - PrintInlining (JDK 22.0.2): the chain through DowncallStub inlines into the caller; only linkToNative remains. - Pair microbenchmark: 144 -> 0 bytes per call on JDK 22.0.2 and 25.0.2 for 384x2, 1024x2, 1536x4 and 3072x1 bits, identical results. - mpnet-10m 2-bit fused ASH, 48 cores, 3 alternating full builds each on JDK 22.0.2: build time 442 s -> 346 s mean (SD 95 s -> 24 s), GC pauses per build about 20 s -> 3.3 s. - jvector-native tests (102) and the documented ASH test set (128, two opt-in tuning tests skipped) pass on JDK 22.0.2 and 25.0.2. --- .../vector/NativeASHSymmetricKernels.java | 194 ++++++++++++------ 1 file changed, 136 insertions(+), 58 deletions(-) diff --git a/jvector-native/src/main/java/io/github/jbellis/jvector/vector/NativeASHSymmetricKernels.java b/jvector-native/src/main/java/io/github/jbellis/jvector/vector/NativeASHSymmetricKernels.java index 5f10f7596..7cf5f57bb 100644 --- a/jvector-native/src/main/java/io/github/jbellis/jvector/vector/NativeASHSymmetricKernels.java +++ b/jvector-native/src/main/java/io/github/jbellis/jvector/vector/NativeASHSymmetricKernels.java @@ -34,102 +34,180 @@ * ranges use one LUT call; larger legal inputs use ordinary FFM with temporary * native buffers. Neither kernel allocates, retains pointers, blocks or calls Java. * Missing symbols preserve the provider's existing Java implementations. + *

    + * JIT notes. The pair kernel runs billions of times per graph build, so: + *

      + *
    • Downcall handles are {@code static final} fields. HotSpot trusts only static + * finals (not instance finals) as constants, so {@code invokeExact} links straight to + * the downcall stub and inlines it. A non-constant handle goes through the generic + * invoker ({@code Invokers.checkCustomized}) and the heap segments escape into it.
    • + *
    • Each heap {@link MemorySegment} is created in the same small method that passes + * it to {@code invokeExact}, so once the stub inlines, escape analysis can + * scalar-replace the segment and its session instead of allocating them per call.
    • + *
    • Tracing, exception construction and the large-input {@link Arena} path live in + * separate cold methods, keeping hot-method bytecode small enough to inline.
    • + *
    */ final class NativeASHSymmetricKernels { private static final int MAX_CRITICAL_WORK = 1 << 16; private static final boolean TRACE = Boolean.getBoolean("jextract.trace.downcalls"); - private static final Binding PAIR = new Binding("ash_symmetric_dot", - FunctionDescriptor.of(JAVA_FLOAT, ADDRESS, ADDRESS, JAVA_INT, JAVA_INT)); - private static final Binding LUT = new Binding("ash_symmetric_lut_i16", - FunctionDescriptor.ofVoid(ADDRESS, ADDRESS, JAVA_INT, JAVA_INT, JAVA_INT, JAVA_INT, ADDRESS)); + private static final String PAIR_SYMBOL = "ash_symmetric_dot"; + private static final String LUT_SYMBOL = "ash_symmetric_lut_i16"; + private static final FunctionDescriptor PAIR_DESCRIPTOR = + FunctionDescriptor.of(JAVA_FLOAT, ADDRESS, ADDRESS, JAVA_INT, JAVA_INT); + private static final FunctionDescriptor LUT_DESCRIPTOR = + FunctionDescriptor.ofVoid(ADDRESS, ADDRESS, JAVA_INT, JAVA_INT, JAVA_INT, JAVA_INT, ADDRESS); + private static final MemorySegment PAIR_ADDRESS = find(PAIR_SYMBOL); + private static final MemorySegment LUT_ADDRESS = find(LUT_SYMBOL); + // Must stay static final: see the class comment. + private static final MethodHandle PAIR_CRITICAL = downcall(PAIR_ADDRESS, PAIR_DESCRIPTOR, true); + private static final MethodHandle PAIR_ORDINARY = downcall(PAIR_ADDRESS, PAIR_DESCRIPTOR, false); + private static final MethodHandle LUT_CRITICAL = downcall(LUT_ADDRESS, LUT_DESCRIPTOR, true); + private static final MethodHandle LUT_ORDINARY = downcall(LUT_ADDRESS, LUT_DESCRIPTOR, false); private NativeASHSymmetricKernels() {} - static boolean hasPairKernel() { return PAIR.available(); } - static boolean hasLutKernel() { return LUT.available(); } + static boolean hasPairKernel() { return PAIR_CRITICAL != null; } + static boolean hasLutKernel() { return LUT_CRITICAL != null; } static float dot(long[] aBits, byte[] aCodes, long[] bBits, byte[] bCodes, int dimensions, int bits) { - if (dimensions < 0 || (bits != 1 && bits != 2 && bits != 4)) - throw new IllegalArgumentException("Symmetric ASH requires nonnegative dimensions and bits in {1,2,4}"); - if (!PAIR.available()) throw new UnsupportedOperationException("Native symmetric pair kernel unavailable"); - MemorySegment a, b; - long bytes; + if (dimensions < 0 || (bits != 1 && bits != 2 && bits != 4)) throw badPairShape(); + if (PAIR_CRITICAL == null) throw pairUnavailable(); if (bits == 1) { long words = (dimensions + 63L) / 64; - if (words > aBits.length || words > bBits.length) throw new IndexOutOfBoundsException("ASH bit arrays"); - a = MemorySegment.ofArray(aBits); b = MemorySegment.ofArray(bBits); bytes = words * Long.BYTES; - } else { - bytes = ((long) dimensions * bits + 7) / 8; - if (bytes > aCodes.length || bytes > bCodes.length) throw new IndexOutOfBoundsException("ASH code arrays"); - a = MemorySegment.ofArray(aCodes); b = MemorySegment.ofArray(bCodes); + if (words > aBits.length || words > bBits.length) throw outOfBounds("ASH bit arrays"); + if (TRACE) trace(PAIR_SYMBOL, dimensions, bits); + return dimensions <= MAX_CRITICAL_WORK + ? pairCritical(aBits, bBits, dimensions, bits) + : pairOrdinary(MemorySegment.ofArray(aBits), MemorySegment.ofArray(bBits), + words * Long.BYTES, dimensions, bits); + } + long bytes = ((long) dimensions * bits + 7) / 8; + if (bytes > aCodes.length || bytes > bCodes.length) throw outOfBounds("ASH code arrays"); + if (TRACE) trace(PAIR_SYMBOL, dimensions, bits); + return dimensions <= MAX_CRITICAL_WORK + ? pairCritical(aCodes, bCodes, dimensions, bits) + : pairOrdinary(MemorySegment.ofArray(aCodes), MemorySegment.ofArray(bCodes), bytes, dimensions, bits); + } + + // Hot path: segments are created and consumed here so they don't escape after inlining. + private static float pairCritical(byte[] a, byte[] b, int dimensions, int bits) { + try { + return (float) PAIR_CRITICAL.invokeExact(MemorySegment.ofArray(a), MemorySegment.ofArray(b), dimensions, bits); + } catch (RuntimeException | Error e) { + throw e; + } catch (Throwable t) { + throw unexpected(PAIR_SYMBOL, t); } - if (dimensions <= MAX_CRITICAL_WORK) return invokePair(PAIR.critical, a, b, dimensions, bits); + } + + private static float pairCritical(long[] a, long[] b, int dimensions, int bits) { + try { + return (float) PAIR_CRITICAL.invokeExact(MemorySegment.ofArray(a), MemorySegment.ofArray(b), dimensions, bits); + } catch (RuntimeException | Error e) { + throw e; + } catch (Throwable t) { + throw unexpected(PAIR_SYMBOL, t); + } + } + + // Cold path: inputs too large for a bounded critical call are copied to native memory. + private static float pairOrdinary(MemorySegment a, MemorySegment b, long bytes, int dimensions, int bits) { try (Arena arena = Arena.ofConfined()) { MemorySegment na = arena.allocate(bytes, Long.BYTES), nb = arena.allocate(bytes, Long.BYTES); MemorySegment.copy(a, 0, na, 0, bytes); MemorySegment.copy(b, 0, nb, 0, bytes); - return invokePair(PAIR.ordinary, na, nb, dimensions, bits); + return (float) PAIR_ORDINARY.invokeExact(na, nb, dimensions, bits); + } catch (RuntimeException | Error e) { + throw e; + } catch (Throwable t) { + throw unexpected(PAIR_SYMBOL, t); } } static void scoreLut(byte[] codes, int groups, int stride, int lane, int count, short[] lut, float[] out, int outOffset) { Objects.requireNonNull(codes, "codes"); Objects.requireNonNull(lut, "lut"); Objects.requireNonNull(out, "out"); - if (!LUT.available()) throw new UnsupportedOperationException("Native symmetric LUT kernel unavailable"); + if (LUT_CRITICAL == null) throw lutUnavailable(); if (groups < 0 || stride <= 0) throw new IllegalArgumentException("ASH groups/stride"); Objects.checkFromIndexSize(lane, count, stride); Objects.checkFromIndexSize(outOffset, count, out.length); long codeBytes = ((groups + 1L) / 2) * stride, lutBytes = (long) groups * 32 * Short.BYTES; - if (codeBytes > codes.length || lutBytes / Short.BYTES > lut.length) - throw new IndexOutOfBoundsException("ASH LUT/code arrays"); + if (codeBytes > codes.length || lutBytes / Short.BYTES > lut.length) throw outOfBounds("ASH LUT/code arrays"); if (count == 0) return; - MemorySegment source = MemorySegment.ofArray(codes), table = MemorySegment.ofArray(lut); - MemorySegment result = MemorySegment.ofArray(out).asSlice((long) outOffset * Float.BYTES); + if (TRACE) trace(LUT_SYMBOL, groups, count); // Charge padded SIMD lanes as well as active lanes (native width is capped at 16). long vectorSlots = ((count + 15L) / 16) * 16; if ((long) groups * vectorSlots <= MAX_CRITICAL_WORK) { - invokeLut(LUT.critical, source, table, groups, stride, lane, count, result); + lutCritical(codes, lut, out, outOffset, groups, stride, lane, count); } else { - try (Arena arena = Arena.ofConfined()) { - MemorySegment nc = arena.allocate(Math.max(1, codeBytes)); - MemorySegment nl = arena.allocate(Math.max(Short.BYTES, lutBytes), Short.BYTES); - MemorySegment no = arena.allocate((long) count * Float.BYTES, Float.BYTES); - MemorySegment.copy(source, 0, nc, 0, codeBytes); MemorySegment.copy(table, 0, nl, 0, lutBytes); - invokeLut(LUT.ordinary, nc, nl, groups, stride, lane, count, no); - MemorySegment.copy(no, 0, result, 0, (long) count * Float.BYTES); - } + lutOrdinary(codes, lut, out, outOffset, groups, stride, lane, count, codeBytes, lutBytes); } } - private static float invokePair(MethodHandle handle, MemorySegment a, MemorySegment b, int dimensions, int bits) { - if (TRACE) System.out.printf("%s(%d, %d)%n", PAIR.symbol, dimensions, bits); + // Hot path: as with pairCritical, segments stay local to the invokeExact call. + private static void lutCritical(byte[] codes, short[] lut, float[] out, int outOffset, + int groups, int stride, int lane, int count) { try { - return (float) handle.invokeExact(a, b, dimensions, bits); - } catch (RuntimeException | Error error) { throw error; - } catch (Throwable error) { throw new AssertionError("Unexpected exception from " + PAIR.symbol, error); } + LUT_CRITICAL.invokeExact(MemorySegment.ofArray(codes), MemorySegment.ofArray(lut), groups, stride, lane, count, + MemorySegment.ofArray(out).asSlice((long) outOffset * Float.BYTES)); + } catch (RuntimeException | Error e) { + throw e; + } catch (Throwable t) { + throw unexpected(LUT_SYMBOL, t); + } } - private static void invokeLut(MethodHandle handle, MemorySegment codes, MemorySegment lut, - int groups, int stride, int lane, int count, MemorySegment out) { - if (TRACE) System.out.printf("%s(%d, %d)%n", LUT.symbol, groups, count); - try { - handle.invokeExact(codes, lut, groups, stride, lane, count, out); - } catch (RuntimeException | Error error) { throw error; - } catch (Throwable error) { throw new AssertionError("Unexpected exception from " + LUT.symbol, error); } + private static void lutOrdinary(byte[] codes, short[] lut, float[] out, int outOffset, + int groups, int stride, int lane, int count, long codeBytes, long lutBytes) { + try (Arena arena = Arena.ofConfined()) { + MemorySegment nc = arena.allocate(Math.max(1, codeBytes)); + MemorySegment nl = arena.allocate(Math.max(Short.BYTES, lutBytes), Short.BYTES); + MemorySegment no = arena.allocate((long) count * Float.BYTES, Float.BYTES); + MemorySegment.copy(MemorySegment.ofArray(codes), 0, nc, 0, codeBytes); + MemorySegment.copy(MemorySegment.ofArray(lut), 0, nl, 0, lutBytes); + LUT_ORDINARY.invokeExact(nc, nl, groups, stride, lane, count, no); + MemorySegment.copy(no, 0, MemorySegment.ofArray(out), (long) outOffset * Float.BYTES, (long) count * Float.BYTES); + } catch (RuntimeException | Error e) { + throw e; + } catch (Throwable t) { + throw unexpected(LUT_SYMBOL, t); + } } - private static final class Binding { - final String symbol; - final MethodHandle critical; - final MethodHandle ordinary; - - Binding(String symbol, FunctionDescriptor descriptor) { - this.symbol = symbol; - MemorySegment address = SymbolLookup.loaderLookup().find(symbol).orElse(null); - critical = address == null ? null : Linker.nativeLinker().downcallHandle( - address, descriptor, Linker.Option.critical(true)); - ordinary = address == null ? null : Linker.nativeLinker().downcallHandle(address, descriptor); - } + // ---- cold helpers (kept out of line so hot methods stay small) ---- + + private static MemorySegment find(String symbol) { + return SymbolLookup.loaderLookup().find(symbol).orElse(null); + } + + private static MethodHandle downcall(MemorySegment address, FunctionDescriptor descriptor, boolean critical) { + if (address == null) return null; + return critical + ? Linker.nativeLinker().downcallHandle(address, descriptor, Linker.Option.critical(true)) + : Linker.nativeLinker().downcallHandle(address, descriptor); + } + + private static void trace(String symbol, int x, int y) { + System.out.printf("%s(%d, %d)%n", symbol, x, y); + } + + private static IllegalArgumentException badPairShape() { + return new IllegalArgumentException("Symmetric ASH requires nonnegative dimensions and bits in {1,2,4}"); + } + + private static UnsupportedOperationException pairUnavailable() { + return new UnsupportedOperationException("Native symmetric pair kernel unavailable"); + } + + private static UnsupportedOperationException lutUnavailable() { + return new UnsupportedOperationException("Native symmetric LUT kernel unavailable"); + } + + private static IndexOutOfBoundsException outOfBounds(String what) { + return new IndexOutOfBoundsException(what); + } - boolean available() { return critical != null; } + private static AssertionError unexpected(String symbol, Throwable t) { + return new AssertionError("Unexpected exception from " + symbol, t); } } From a2cedea282212c3a578977238be360b23b540564 Mon Sep 17 00:00:00 2001 From: Ted Willke Date: Sun, 8 Nov 2026 20:15:06 +0000 Subject: [PATCH 163/243] Add DistancesFP scoring and allocation benchmark --- .../jbellis/jvector/example/DistancesFP.java | 180 ++++++++++++++++++ .../jvector/example/util/SiftLoader.java | 8 +- 2 files changed, 187 insertions(+), 1 deletion(-) create mode 100644 jvector-examples/src/main/java/io/github/jbellis/jvector/example/DistancesFP.java diff --git a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/DistancesFP.java b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/DistancesFP.java new file mode 100644 index 000000000..c0fa93478 --- /dev/null +++ b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/DistancesFP.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.example; + +import com.sun.management.ThreadMXBean; +import io.github.jbellis.jvector.example.util.SiftLoader; +import io.github.jbellis.jvector.graph.ListRandomAccessVectorValues; +import io.github.jbellis.jvector.graph.similarity.BuildScoreProvider; +import io.github.jbellis.jvector.vector.VectorizationProvider; +import io.github.jbellis.jvector.vector.types.VectorFloat; + +import java.lang.management.ManagementFactory; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.SplittableRandom; +import java.util.stream.IntStream; + +import static io.github.jbellis.jvector.vector.VectorSimilarityFunction.DOT_PRODUCT; + +/** + * Single-thread FP32 distance benchmark. Pass {@code base.fvecs query.fvecs} or + * {@code --synthetic dimensions}. Use separate JVMs with {@code jvector.vectorization_provider} + * set to {@code native}, {@code panama}, or {@code default} to compare backends. + * + *

    Properties (prefix {@code jvector.bench.}): {@code maxBaseVectors} (65536), + * {@code maxQueries} (16), {@code candidatesPerQuery} (entire base), + * {@code scoringWarmupPasses} (3), and {@code scoringSamples} (3). A smaller candidate + * count gives each query a different window of the selected traversal order.

    + * + *

    The direct mode calls the production dot-product similarity. The construction mode + * creates the same exact scorer used for graph insertion once per query and includes that + * setup in the measurement. Input loading and shuffling happen outside the measurements. + * The first pass is reported separately because it can initialize native vector views and + * compile code. Allocation measurements count Java heap bytes on the scoring thread.

    + */ +public final class DistancesFP { + private DistancesFP() {} + + public static void main(String[] args) { + if (args.length != 2) { + throw new IllegalArgumentException("Usage: DistancesFP | --synthetic "); + } + int maxBase = positiveProperty("maxBaseVectors", 65_536); + int maxQueries = positiveProperty("maxQueries", 16); + int warmups = positiveProperty("scoringWarmupPasses", 3); + int samples = positiveProperty("scoringSamples", 3); + var provider = VectorizationProvider.getInstance(); + List> base; + List> queries; + if (args[0].equals("--synthetic")) { + int dimension = Integer.parseInt(args[1]); + if (dimension <= 0) throw new IllegalArgumentException("Dimensions must be positive"); + var random = new SplittableRandom(42); + base = syntheticVectors(provider, random, maxBase, dimension); + queries = syntheticVectors(provider, random, maxQueries, dimension); + } else { + base = SiftLoader.readFvecs(args[0], maxBase); + queries = SiftLoader.readFvecs(args[1], maxQueries); + } + if (base.isEmpty() || queries.isEmpty()) { + throw new IllegalArgumentException("Base and query vectors must be nonempty"); + } + int dimensions = base.get(0).length(); + if (base.stream().anyMatch(v -> v.length() != dimensions) + || queries.stream().anyMatch(v -> v.length() != dimensions)) { + throw new IllegalArgumentException("All base and query vectors must have the same dimension"); + } + int candidates = positiveProperty("candidatesPerQuery", base.size()); + if (candidates > base.size()) throw new IllegalArgumentException("candidatesPerQuery exceeds the base size"); + + var alloc = ManagementFactory.getPlatformMXBean(ThreadMXBean.class); + if (alloc == null || !alloc.isThreadAllocatedMemorySupported()) { + throw new IllegalStateException("This JVM does not support per-thread heap allocation measurements"); + } + if (!alloc.isThreadAllocatedMemoryEnabled()) alloc.setThreadAllocatedMemoryEnabled(true); + var scoreProvider = BuildScoreProvider.randomAccessScoreProvider( + new ListRandomAccessVectorValues(base, dimensions), DOT_PRODUCT); + var sequential = IntStream.range(0, base.size()).toArray(); + var shuffled = sequential.clone(); + var random = new SplittableRandom(43); + for (int i = shuffled.length - 1; i > 0; i--) { + int j = random.nextInt(i + 1); + int saved = shuffled[i]; + shuffled[i] = shuffled[j]; + shuffled[j] = saved; + } + + System.out.printf(Locale.ROOT, "DistancesFP provider=%s dimensions=%d base=%d queries=%d candidates/query=%d%n", + provider.getClass().getSimpleName(), dimensions, base.size(), queries.size(), candidates); + System.out.println("phase,access,scorer,sample,scores,seconds,ns_per_score,million_scores_per_s," + + "allocated_bytes,bytes_per_score,bytes_per_query,checksum"); + run("sequential", sequential, base, queries, candidates, scoreProvider, alloc, warmups, samples); + run("shuffled", shuffled, base, queries, candidates, scoreProvider, alloc, warmups, samples); + } + + private static void run(String access, int[] order, List> base, List> queries, + int candidates, BuildScoreProvider provider, ThreadMXBean alloc, int warmups, int samples) { + long thread = Thread.currentThread().getId(); + long comparisons = (long) candidates * queries.size(); + double expected = Double.NaN; + for (String scorer : List.of("direct", "construction")) { + boolean construction = scorer.equals("construction"); + for (int sample = -warmups; sample < samples; sample++) { + long beforeBytes = alloc.getThreadAllocatedBytes(thread); + long start = System.nanoTime(); + double checksum = score(order, base, queries, candidates, provider, construction); + long elapsed = System.nanoTime() - start; + long bytes = alloc.getThreadAllocatedBytes(thread) - beforeBytes; + if (!Double.isFinite(checksum)) throw new IllegalStateException("Non-finite score checksum"); + if (sample >= 0) { + if (Double.isNaN(expected)) expected = checksum; + if (checksum != expected) throw new IllegalStateException("Direct and construction scores differ"); + } + if (sample == -warmups || sample >= 0) { + System.out.printf(Locale.ROOT, "%s,%s,%s,%d,%d,%.6f,%.2f,%.3f,%d,%.6f,%.2f,%.9f%n", + sample < 0 ? "first-pass" : "steady", access, scorer, sample < 0 ? 1 : sample + 1, + comparisons, elapsed / 1e9, (double) elapsed / comparisons, + comparisons * 1e3 / elapsed, bytes, (double) bytes / comparisons, + (double) bytes / queries.size(), checksum); + } + } + } + } + + private static double score(int[] order, List> base, List> queries, + int candidates, BuildScoreProvider provider, boolean construction) { + double checksum = 0; + for (int q = 0; q < queries.size(); q++) { + var query = queries.get(q); + int start = (int) ((long) q * candidates % (order.length - candidates + 1)); + int end = start + candidates; + if (construction) { + var scorer = provider.searchProviderFor(query).scoreFunction(); + for (int i = start; i < end; i++) checksum += scorer.similarityTo(order[i]); + } else { + for (int i = start; i < end; i++) checksum += DOT_PRODUCT.compare(query, base.get(order[i])); + } + } + return checksum; + } + + private static List> syntheticVectors(VectorizationProvider provider, SplittableRandom random, + int count, int dimensions) { + var support = provider.getVectorTypeSupport(); + var vectors = new ArrayList>(count); + for (int i = 0; i < count; i++) { + var values = new float[dimensions]; + double squaredNorm = 0; + for (int d = 0; d < dimensions; d++) { + values[d] = (float) random.nextDouble(-1, 1); + squaredNorm += (double) values[d] * values[d]; + } + double scale = 1 / Math.sqrt(squaredNorm); + for (int d = 0; d < dimensions; d++) values[d] = (float) (values[d] * scale); + vectors.add(support.createFloatVector(values)); + } + return vectors; + } + + private static int positiveProperty(String name, int defaultValue) { + int value = Integer.parseInt(System.getProperty("jvector.bench." + name, Integer.toString(defaultValue))); + if (value <= 0) throw new IllegalArgumentException(name + " must be positive"); + return value; + } +} diff --git a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/util/SiftLoader.java b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/util/SiftLoader.java index a491d0c9e..58e987f71 100644 --- a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/util/SiftLoader.java +++ b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/util/SiftLoader.java @@ -35,9 +35,15 @@ public class SiftLoader { private static final VectorTypeSupport vectorTypeSupport = VectorizationProvider.getInstance().getVectorTypeSupport(); public static List> readFvecs(String filePath) { + return readFvecs(filePath, Integer.MAX_VALUE); + } + + /** Reads at most {@code maxVectors} vectors without loading the rest of the file. */ + public static List> readFvecs(String filePath, int maxVectors) { + if (maxVectors <= 0) throw new IllegalArgumentException("maxVectors must be positive"); var vectors = new ArrayList>(); try (var dis = new DataInputStream(new BufferedInputStream(new FileInputStream(filePath)))) { - while (dis.available() > 0) { + while (vectors.size() < maxVectors && dis.available() > 0) { var dimension = Integer.reverseBytes(dis.readInt()); if (dimension <= 0) { throw new IOException("Corrupt fvecs file: negative or zero dimension " + dimension + " (possible file corruption or wrong format)"); From 08c97767129c4a5ce144025d3073a19d4eea240e Mon Sep 17 00:00:00 2001 From: Ted Willke Date: Mon, 9 Nov 2026 09:07:50 -0800 Subject: [PATCH 164/243] Group ASH benchmark index recipes and leave saved-index reuse off by default Move the 28 ASH/PQ comparison index configurations from yaml-configs/ash-pq into index-parameters/ash-pq, next to the other index parameters. The run configurations and local dataset catalogs stay in yaml-configs/ash-pq. Add index-parameters/ash-paths with CAP-1M and MPNet-10M recipes for 2- and 4-bit fused ASH graphs built either from source vectors or with symmetric ASH scoring. Index parameter files are starting points for users, and a saved index outlives its run and consumes disk space without notice. Set useSavedIndexIfExists: No in the dataset files that had it on and in the new recipes. The ASH/PQ recipes keep Yes: that protocol builds a graph in one JVM and queries it from later JVMs. Tests check which index configurations keep saved indexes and that the new recipes load and match their names. --- docs/ash-pq-benchmark.md | 13 ++-- docs/benchyaml-guide.md | 7 +- jvector-examples/run-ash-pq-benchmark.sh | 2 +- .../StandaloneASHBenchmarkConfigTest.java | 65 +++++++++++++++++-- .../cap-1m-build-flush-fused-ash-2bit.yml | 35 ++++++++++ .../cap-1m-build-flush-fused-ash-4bit.yml | 35 ++++++++++ ...cap-1m-compressed-build-fused-ash-2bit.yml | 41 ++++++++++++ ...cap-1m-compressed-build-fused-ash-4bit.yml | 41 ++++++++++++ .../mpnet-10m-build-flush-fused-ash-2bit.yml | 35 ++++++++++ .../mpnet-10m-build-flush-fused-ash-4bit.yml | 35 ++++++++++ ...et-10m-compressed-build-fused-ash-2bit.yml | 41 ++++++++++++ ...et-10m-compressed-build-fused-ash-4bit.yml | 41 ++++++++++++ .../ash-pq/ada002-2bit-ash.yml | 0 .../ash-pq/ada002-2bit-pq.yml | 0 .../ash-pq/ada002-4bit-ash.yml | 0 .../ash-pq/ada002-4bit-pq.yml | 0 .../ash-pq/cap6m-2bit-ash.yml | 0 .../ash-pq/cap6m-2bit-pq.yml | 0 .../ash-pq/cap6m-4bit-ash.yml | 0 .../ash-pq/cap6m-4bit-pq.yml | 0 .../ash-pq/cohere-2bit-ash.yml | 0 .../ash-pq/cohere-2bit-pq.yml | 0 .../ash-pq/cohere-4bit-ash.yml | 0 .../ash-pq/cohere-4bit-pq.yml | 0 .../ash-pq/cohere10m-2bit-ash.yml | 0 .../ash-pq/cohere10m-2bit-pq.yml | 0 .../ash-pq/cohere10m-4bit-ash.yml | 0 .../ash-pq/cohere10m-4bit-pq.yml | 0 .../ash-pq/mpnet-2bit-ash.yml | 0 .../ash-pq/mpnet-2bit-pq.yml | 0 .../ash-pq/mpnet-4bit-ash.yml | 0 .../ash-pq/mpnet-4bit-pq.yml | 0 .../ash-pq/mpnet10m-2bit-ash.yml | 0 .../ash-pq/mpnet10m-2bit-pq.yml | 0 .../ash-pq/mpnet10m-4bit-ash.yml | 0 .../ash-pq/mpnet10m-4bit-pq.yml | 0 .../ash-pq/openai3072-2bit-ash.yml | 0 .../ash-pq/openai3072-2bit-pq.yml | 0 .../ash-pq/openai3072-4bit-ash.yml | 0 .../ash-pq/openai3072-4bit-pq.yml | 0 .../yaml-configs/index-parameters/cap-6M.yml | 2 +- .../index-parameters/cohere-10M.yml | 2 +- .../index-parameters/dpr-gemma-1M.yml | 2 +- .../index-parameters/esmc-600-1M.yml | 2 +- .../index-parameters/esmc-600-6M.yml | 2 +- .../index-parameters/mpnet-10M.yml | 8 +-- .../index-parameters/mpnet-1M.yml | 2 +- .../index-parameters/openai-v3-small-100k.yml | 2 +- .../index-parameters/personahub-1M.yml | 2 +- .../index-parameters/tahoe-x1-1M.yml | 2 +- 50 files changed, 391 insertions(+), 26 deletions(-) create mode 100644 jvector-examples/yaml-configs/index-parameters/ash-paths/cap-1m-build-flush-fused-ash-2bit.yml create mode 100644 jvector-examples/yaml-configs/index-parameters/ash-paths/cap-1m-build-flush-fused-ash-4bit.yml create mode 100644 jvector-examples/yaml-configs/index-parameters/ash-paths/cap-1m-compressed-build-fused-ash-2bit.yml create mode 100644 jvector-examples/yaml-configs/index-parameters/ash-paths/cap-1m-compressed-build-fused-ash-4bit.yml create mode 100644 jvector-examples/yaml-configs/index-parameters/ash-paths/mpnet-10m-build-flush-fused-ash-2bit.yml create mode 100644 jvector-examples/yaml-configs/index-parameters/ash-paths/mpnet-10m-build-flush-fused-ash-4bit.yml create mode 100644 jvector-examples/yaml-configs/index-parameters/ash-paths/mpnet-10m-compressed-build-fused-ash-2bit.yml create mode 100644 jvector-examples/yaml-configs/index-parameters/ash-paths/mpnet-10m-compressed-build-fused-ash-4bit.yml rename jvector-examples/yaml-configs/{ => index-parameters}/ash-pq/ada002-2bit-ash.yml (100%) rename jvector-examples/yaml-configs/{ => index-parameters}/ash-pq/ada002-2bit-pq.yml (100%) rename jvector-examples/yaml-configs/{ => index-parameters}/ash-pq/ada002-4bit-ash.yml (100%) rename jvector-examples/yaml-configs/{ => index-parameters}/ash-pq/ada002-4bit-pq.yml (100%) rename jvector-examples/yaml-configs/{ => index-parameters}/ash-pq/cap6m-2bit-ash.yml (100%) rename jvector-examples/yaml-configs/{ => index-parameters}/ash-pq/cap6m-2bit-pq.yml (100%) rename jvector-examples/yaml-configs/{ => index-parameters}/ash-pq/cap6m-4bit-ash.yml (100%) rename jvector-examples/yaml-configs/{ => index-parameters}/ash-pq/cap6m-4bit-pq.yml (100%) rename jvector-examples/yaml-configs/{ => index-parameters}/ash-pq/cohere-2bit-ash.yml (100%) rename jvector-examples/yaml-configs/{ => index-parameters}/ash-pq/cohere-2bit-pq.yml (100%) rename jvector-examples/yaml-configs/{ => index-parameters}/ash-pq/cohere-4bit-ash.yml (100%) rename jvector-examples/yaml-configs/{ => index-parameters}/ash-pq/cohere-4bit-pq.yml (100%) rename jvector-examples/yaml-configs/{ => index-parameters}/ash-pq/cohere10m-2bit-ash.yml (100%) rename jvector-examples/yaml-configs/{ => index-parameters}/ash-pq/cohere10m-2bit-pq.yml (100%) rename jvector-examples/yaml-configs/{ => index-parameters}/ash-pq/cohere10m-4bit-ash.yml (100%) rename jvector-examples/yaml-configs/{ => index-parameters}/ash-pq/cohere10m-4bit-pq.yml (100%) rename jvector-examples/yaml-configs/{ => index-parameters}/ash-pq/mpnet-2bit-ash.yml (100%) rename jvector-examples/yaml-configs/{ => index-parameters}/ash-pq/mpnet-2bit-pq.yml (100%) rename jvector-examples/yaml-configs/{ => index-parameters}/ash-pq/mpnet-4bit-ash.yml (100%) rename jvector-examples/yaml-configs/{ => index-parameters}/ash-pq/mpnet-4bit-pq.yml (100%) rename jvector-examples/yaml-configs/{ => index-parameters}/ash-pq/mpnet10m-2bit-ash.yml (100%) rename jvector-examples/yaml-configs/{ => index-parameters}/ash-pq/mpnet10m-2bit-pq.yml (100%) rename jvector-examples/yaml-configs/{ => index-parameters}/ash-pq/mpnet10m-4bit-ash.yml (100%) rename jvector-examples/yaml-configs/{ => index-parameters}/ash-pq/mpnet10m-4bit-pq.yml (100%) rename jvector-examples/yaml-configs/{ => index-parameters}/ash-pq/openai3072-2bit-ash.yml (100%) rename jvector-examples/yaml-configs/{ => index-parameters}/ash-pq/openai3072-2bit-pq.yml (100%) rename jvector-examples/yaml-configs/{ => index-parameters}/ash-pq/openai3072-4bit-ash.yml (100%) rename jvector-examples/yaml-configs/{ => index-parameters}/ash-pq/openai3072-4bit-pq.yml (100%) diff --git a/docs/ash-pq-benchmark.md b/docs/ash-pq-benchmark.md index 52e31f937..601911d3b 100644 --- a/docs/ash-pq-benchmark.md +++ b/docs/ash-pq-benchmark.md @@ -23,9 +23,9 @@ describes the index configuration. See settings. This comparison uses the branch's ordinary packaged JVector code and -`io.github.jbellis.jvector.example.BenchYAML`. The fixed index configurations, -run configurations and local dataset catalog are in -[`jvector-examples/yaml-configs/ash-pq`](../jvector-examples/yaml-configs/ash-pq). +`io.github.jbellis.jvector.example.BenchYAML`. The fixed [index configurations](../jvector-examples/yaml-configs/index-parameters/ash-pq) +have their own directory. The [run configurations and local dataset catalogs](../jvector-examples/yaml-configs/ash-pq) +are grouped separately. No separate query harness, source overlay, profiling agent, manually linked library, or dataset-specific projection setting is required. @@ -149,7 +149,10 @@ For the matching PQ case, replace `ash` with `pq`, use `direct` for the query report selection, and use a separate case directory. Each index YAML selects its delivery policy. The run YAML selects the metrics and reporting. Repeat with each prefix and bit count in the table. Construction refuses nonempty model/index caches; query invocations require caches and reject a run -that rebuilt an index. No existing indexes are automatically deleted. +that rebuilt an index. These recipes set `useSavedIndexIfExists: Yes` so the +graph survives its construction JVM; most index parameter files leave it off +because saved indexes consume disk space. No existing indexes are automatically +deleted. For the full report, collect five fresh construction JVMs per case in separate directories. Retain the first graph for queries, without choosing the best build. @@ -212,7 +215,7 @@ numactl --physcpubind=0 --membind=0 "$JAVA_HOME/bin/java" \ "-Djvector.bench.sourceRef=$(git -C "$REPO_ROOT" rev-parse HEAD)" \ "-Djvector.bench.runConfig=$REPO_ROOT/jvector-examples/yaml-configs/ash-pq/run-queued.yml" \ -cp "$BENCH_JAR" io.github.jbellis.jvector.example.BenchYAML \ - "$REPO_ROOT/jvector-examples/yaml-configs/ash-pq/mpnet-2bit-ash.yml" + "$REPO_ROOT/jvector-examples/yaml-configs/index-parameters/ash-pq/mpnet-2bit-ash.yml" ``` The case directory contains copies of the checked-in dataset catalog, dataset diff --git a/docs/benchyaml-guide.md b/docs/benchyaml-guide.md index 5edfdcbf9..26bf6a946 100644 --- a/docs/benchyaml-guide.md +++ b/docs/benchyaml-guide.md @@ -25,7 +25,10 @@ reranking optionally rescores the candidates. - [Index parameters](../jvector-examples/yaml-configs/index-parameters) describe construction and search for a dataset. A dataset-specific file takes - precedence over `default.yml`. + precedence over `default.yml`. To run an explicit recipe, pass its path to + BenchYAML. The [ASH/PQ comparison recipes](../jvector-examples/yaml-configs/index-parameters/ash-pq) + and [ASH build and flush examples](../jvector-examples/yaml-configs/index-parameters/ash-paths) + are grouped in subdirectories. - [Dataset catalogs](../jvector-examples/yaml-configs/dataset-catalogs) locate the base vectors, query vectors, and ground truth. [Dataset metadata](../jvector-examples/yaml-configs/dataset-metadata.yml) @@ -49,7 +52,7 @@ Each `compression` list entry has a `type` and its own settings. | --- | --- | --- | | `type` | `inMemory`, `onDisk` | `inMemory` keeps the graph in memory for search. `onDisk` serializes the completed graph and reopens it for search. | | `writerMode` | `sequential`, `parallel` | Selects the writer after graph cleanup for `onDisk`. Defaults to `parallel`. | -| `useSavedIndexIfExists` | `Yes`, `No` | Controls reuse of a compatible `onDisk` index. A cache hit skips fresh construction and serialization measurements. | +| `useSavedIndexIfExists` | `Yes`, `No` | Controls reuse of a compatible `onDisk` index. `Yes` saves each built index in the working directory's `index_cache` and reuses it on later runs; a cache hit skips fresh construction and serialization measurements. Saved indexes are never deleted automatically, so starting-point configurations use `No`; recipes that query an index from a later JVM, such as the ASH/PQ comparison, use `Yes`. | | `outDegree` | Positive integers | Maximum final neighborhood degree, also called M. | | `efConstruction` | Positive integers | Search width used to find candidates during insertion. | | `neighborOverflow` | Finite numbers ≥ 1.0 | Temporary neighborhood capacity relative to final degree. Java float suffixes such as `1.2f` are accepted. | diff --git a/jvector-examples/run-ash-pq-benchmark.sh b/jvector-examples/run-ash-pq-benchmark.sh index a1178fe1f..194108f59 100755 --- a/jvector-examples/run-ash-pq-benchmark.sh +++ b/jvector-examples/run-ash-pq-benchmark.sh @@ -70,7 +70,7 @@ git -C "$REPO_ROOT" diff --quiet git -C "$REPO_ROOT" diff --cached --quiet SOURCE_REF="$(git -C "$REPO_ROOT" rev-parse HEAD)" JAVA_BIN="$(command -v java)" -CONFIG="$REPO_ROOT/jvector-examples/yaml-configs/ash-pq/$CONFIG_NAME" +CONFIG="$REPO_ROOT/jvector-examples/yaml-configs/index-parameters/ash-pq/$CONFIG_NAME" RUN_CONFIG="$REPO_ROOT/jvector-examples/yaml-configs/ash-pq/run-$MODE.yml" CATALOG_NAME="${JVECTOR_BENCH_CATALOG:-catalog-1m.yml}" case "$CATALOG_NAME" in catalog-1m.yml|catalog-large.yml) ;; *) echo "Unknown dataset catalog: $CATALOG_NAME" >&2; exit 2;; esac diff --git a/jvector-examples/src/test/java/io/github/jbellis/jvector/example/benchmarks/StandaloneASHBenchmarkConfigTest.java b/jvector-examples/src/test/java/io/github/jbellis/jvector/example/benchmarks/StandaloneASHBenchmarkConfigTest.java index 41fc26238..9c0d5e4e9 100644 --- a/jvector-examples/src/test/java/io/github/jbellis/jvector/example/benchmarks/StandaloneASHBenchmarkConfigTest.java +++ b/jvector-examples/src/test/java/io/github/jbellis/jvector/example/benchmarks/StandaloneASHBenchmarkConfigTest.java @@ -31,7 +31,9 @@ /** Fair code budgets and independent selection of checked-in run/index YAML. */ public class StandaloneASHBenchmarkConfigTest { - private static final Path CONFIGS = Path.of("jvector-examples/yaml-configs/ash-pq"); + private static final Path INDEX_CONFIGS = Path.of("jvector-examples/yaml-configs/index-parameters/ash-pq"); + private static final Path RUN_CONFIGS = Path.of("jvector-examples/yaml-configs/ash-pq"); + private static final Path ALL_INDEX_CONFIGS = Path.of("jvector-examples/yaml-configs/index-parameters"); @Rule public final TemporaryFolder directory = new TemporaryFolder(); @Test @@ -53,7 +55,7 @@ public void explicitRunFileDoesNotRequireEditingTheDefaultYaml() throws Exceptio @Test public void yamlPathsAreNotRegexesAndMissingExplicitRunFileFailsBeforeDataLoading() throws Exception { var index = directory.newFile("index with [literal.yml").toPath(); - Files.copy(CONFIGS.resolve("mpnet-2bit-ash.yml"), index, + Files.copy(INDEX_CONFIGS.resolve("mpnet-2bit-ash.yml"), index, java.nio.file.StandardCopyOption.REPLACE_EXISTING); var missing = directory.getRoot().toPath().resolve("missing-run.yml"); String previous = System.getProperty("jvector.bench.runConfig"); @@ -88,7 +90,7 @@ public void allPairsMatchBytesAndUseFusedGraphsNvqAndTheSameSearchParameters() t Map previousConstruction = null; String previousDataset = null; for (String algorithm : List.of("ash", "pq")) { - Path path = CONFIGS.resolve(entry.getKey() + "-" + bits + "bit-" + algorithm + ".yml"); + Path path = INDEX_CONFIGS.resolve(entry.getKey() + "-" + bits + "bit-" + algorithm + ".yml"); assertNotNull(MultiConfig.getConfig(path.toAbsolutePath().toString())); var root = yaml(path); var construction = (Map) root.get("construction"); @@ -133,7 +135,7 @@ public void allPairsMatchBytesAndUseFusedGraphsNvqAndTheSameSearchParameters() t @Test public void runRecipesKeepStandardBenchmarkSelections() throws Exception { for (String mode : List.of("construction", "direct", "queued")) { - var config = RunConfig.load(CONFIGS.resolve("run-" + mode + ".yml").toString()); + var config = RunConfig.load(RUN_CONFIGS.resolve("run-" + mode + ".yml").toString()); assertEquals(List.of("recall"), config.benchmarks.get("accuracy")); if (!mode.equals("construction")) { assertEquals(List.of("AVG"), config.benchmarks.get("throughput")); @@ -160,7 +162,7 @@ public void provenanceRecordsTheActualSourceAndSelectedQueryParallelism() throws config.logging.benchmarks = config.benchmarks; config.logging.runId = mode; String codec = mode.equals("queued") ? "ash" : "pq"; - var index = MultiConfig.getConfig(CONFIGS.resolve("mpnet-2bit-" + codec + ".yml") + var index = MultiConfig.getConfig(INDEX_CONFIGS.resolve("mpnet-2bit-" + codec + ".yml") .toAbsolutePath().toString()); var artifacts = io.github.jbellis.jvector.example.reporting.RunArtifacts.open(config, List.of(index)); var info = yaml(artifacts.experimentsWriter().path().resolveSibling("sys_info.json")); @@ -178,4 +180,57 @@ public void provenanceRecordsTheActualSourceAndSelectedQueryParallelism() throws else System.setProperty("jvector.bench.sourceRef", oldRef); } } + + @Test + @SuppressWarnings("unchecked") + public void onlyMultiJvmRecipesKeepSavedIndexes() throws Exception { + // Saved indexes consume disk space without notice, so starting points leave reuse off. + // The ASH/PQ protocol queries each graph from JVMs after the one that built it. + List paths; + try (var files = Files.walk(ALL_INDEX_CONFIGS)) { + paths = files.filter(p -> p.toString().endsWith(".yml")).sorted().collect(java.util.stream.Collectors.toList()); + } + assertTrue(paths.size() > 36); + for (Path path : paths) { + var construction = (Map) yaml(path).get("construction"); + if (construction != null && construction.containsKey("useSavedIndexIfExists")) { + boolean reused = path.startsWith(INDEX_CONFIGS); + assertEquals(path.toString(), reused, construction.get("useSavedIndexIfExists")); + } + } + } + + @Test + @SuppressWarnings("unchecked") + public void ashPathRecipesLoadAndMatchTheirNames() throws Exception { + List paths; + try (var files = Files.list(ALL_INDEX_CONFIGS.resolve("ash-paths"))) { + paths = files.filter(p -> p.toString().endsWith(".yml")).sorted().collect(java.util.stream.Collectors.toList()); + } + assertEquals(8, paths.size()); + for (Path path : paths) { + String name = path.getFileName().toString(); + assertNotNull(name, MultiConfig.getConfig(path.toAbsolutePath().toString())); + var root = yaml(path); + var construction = (Map) root.get("construction"); + assertEquals(name, Boolean.TRUE, ((List) construction.get("fusedGraph")).get(0)); + var build = compression(construction); + var search = compression((Map) root.get("search")); + var searchParameters = (Map) search.get("parameters"); + int bits = name.endsWith("-4bit.yml") ? 4 : 2; + assertEquals(name, bits, searchParameters.get("bitsPerDimension")); + if (name.contains("-compressed-build-")) { + assertEquals(name, "ASH", build.get("type")); + assertEquals(name, "symmetric", build.get("scoring")); + assertEquals(name, build.get("parameters"), Map.of( + "encodedBits", searchParameters.get("encodedBits"), + "optimizer", searchParameters.get("optimizer"), + "landmarkCount", searchParameters.get("landmarkCount"), + "bitsPerDimension", bits)); + } else { + assertTrue(name, name.contains("-build-flush-")); + assertEquals(name, "None", build.get("type")); + } + } + } } diff --git a/jvector-examples/yaml-configs/index-parameters/ash-paths/cap-1m-build-flush-fused-ash-2bit.yml b/jvector-examples/yaml-configs/index-parameters/ash-paths/cap-1m-build-flush-fused-ash-2bit.yml new file mode 100644 index 000000000..ef21451e4 --- /dev/null +++ b/jvector-examples/yaml-configs/index-parameters/ash-paths/cap-1m-build-flush-fused-ash-2bit.yml @@ -0,0 +1,35 @@ +# CAP-1M at 32X nominal compression. Build the graph with source FP32 vectors, +# then write fused 2-bit ASH traversal codes and NVQ reranking vectors to disk. +yamlSchemaVersion: 3 +onDiskIndexVersion: 7 +dataset: cap-1M + +construction: + type: onDisk + writerMode: sequential + outDegree: [32] + efConstruction: [100] + neighborOverflow: [1.2f] + addHierarchy: [Yes] + refineFinalGraph: [Yes] + fusedGraph: [Yes] + compression: + - type: None + reranking: + type: NVQ + useSavedIndexIfExists: No + +search: + topKOverquery: + 10: [1.0, 2.0] + useSearchPruning: [No] + compression: + - type: ASH + parameters: + encodedBits: 1576 + optimizer: ITQ + landmarkCount: 1 + bitsPerDimension: 2 + queryProjection: fp16 + queryExecution: queued + maxBatchSize: 8 diff --git a/jvector-examples/yaml-configs/index-parameters/ash-paths/cap-1m-build-flush-fused-ash-4bit.yml b/jvector-examples/yaml-configs/index-parameters/ash-paths/cap-1m-build-flush-fused-ash-4bit.yml new file mode 100644 index 000000000..aaf278b2d --- /dev/null +++ b/jvector-examples/yaml-configs/index-parameters/ash-paths/cap-1m-build-flush-fused-ash-4bit.yml @@ -0,0 +1,35 @@ +# CAP-1M at 16X nominal compression. Build the graph with source FP32 vectors, +# then write fused 4-bit ASH traversal codes and NVQ reranking vectors to disk. +yamlSchemaVersion: 3 +onDiskIndexVersion: 7 +dataset: cap-1M + +construction: + type: onDisk + writerMode: sequential + outDegree: [32] + efConstruction: [100] + neighborOverflow: [1.2f] + addHierarchy: [Yes] + refineFinalGraph: [Yes] + fusedGraph: [Yes] + compression: + - type: None + reranking: + type: NVQ + useSavedIndexIfExists: No + +search: + topKOverquery: + 10: [1.0, 2.0] + useSearchPruning: [No] + compression: + - type: ASH + parameters: + encodedBits: 3112 + optimizer: ITQ + landmarkCount: 1 + bitsPerDimension: 4 + queryProjection: fp16 + queryExecution: queued + maxBatchSize: 8 diff --git a/jvector-examples/yaml-configs/index-parameters/ash-paths/cap-1m-compressed-build-fused-ash-2bit.yml b/jvector-examples/yaml-configs/index-parameters/ash-paths/cap-1m-compressed-build-fused-ash-2bit.yml new file mode 100644 index 000000000..28563bdd0 --- /dev/null +++ b/jvector-examples/yaml-configs/index-parameters/ash-paths/cap-1m-compressed-build-fused-ash-2bit.yml @@ -0,0 +1,41 @@ +# CAP-1M at 32X nominal compression. Build with symmetric 2-bit ASH scoring +# and write fused ASH traversal codes and NVQ reranking vectors in parallel. +yamlSchemaVersion: 3 +onDiskIndexVersion: 7 +dataset: cap-1M + +construction: + type: onDisk + writerMode: parallel + outDegree: [32] + efConstruction: [100] + neighborOverflow: [1.2f] + addHierarchy: [Yes] + refineFinalGraph: [Yes] + fusedGraph: [Yes] + compression: + - type: ASH + scoring: symmetric + parameters: + encodedBits: 1576 + optimizer: ITQ + landmarkCount: 1 + bitsPerDimension: 2 + reranking: + type: NVQ + useSavedIndexIfExists: No + +search: + topKOverquery: + 10: [1.0, 2.0] + useSearchPruning: [No] + compression: + - type: ASH + parameters: + encodedBits: 1576 + optimizer: ITQ + landmarkCount: 1 + bitsPerDimension: 2 + queryProjection: fp16 + queryExecution: queued + maxBatchSize: 8 diff --git a/jvector-examples/yaml-configs/index-parameters/ash-paths/cap-1m-compressed-build-fused-ash-4bit.yml b/jvector-examples/yaml-configs/index-parameters/ash-paths/cap-1m-compressed-build-fused-ash-4bit.yml new file mode 100644 index 000000000..9f83744ce --- /dev/null +++ b/jvector-examples/yaml-configs/index-parameters/ash-paths/cap-1m-compressed-build-fused-ash-4bit.yml @@ -0,0 +1,41 @@ +# CAP-1M at 16X nominal compression. Build with symmetric 4-bit ASH scoring +# and write fused ASH traversal codes and NVQ reranking vectors in parallel. +yamlSchemaVersion: 3 +onDiskIndexVersion: 7 +dataset: cap-1M + +construction: + type: onDisk + writerMode: parallel + outDegree: [32] + efConstruction: [100] + neighborOverflow: [1.2f] + addHierarchy: [Yes] + refineFinalGraph: [Yes] + fusedGraph: [Yes] + compression: + - type: ASH + scoring: symmetric + parameters: + encodedBits: 3112 + optimizer: ITQ + landmarkCount: 1 + bitsPerDimension: 4 + reranking: + type: NVQ + useSavedIndexIfExists: No + +search: + topKOverquery: + 10: [1.0, 2.0] + useSearchPruning: [No] + compression: + - type: ASH + parameters: + encodedBits: 3112 + optimizer: ITQ + landmarkCount: 1 + bitsPerDimension: 4 + queryProjection: fp16 + queryExecution: queued + maxBatchSize: 8 diff --git a/jvector-examples/yaml-configs/index-parameters/ash-paths/mpnet-10m-build-flush-fused-ash-2bit.yml b/jvector-examples/yaml-configs/index-parameters/ash-paths/mpnet-10m-build-flush-fused-ash-2bit.yml new file mode 100644 index 000000000..7b04e9883 --- /dev/null +++ b/jvector-examples/yaml-configs/index-parameters/ash-paths/mpnet-10m-build-flush-fused-ash-2bit.yml @@ -0,0 +1,35 @@ +# MPNet 10M at 32X nominal compression. Build the graph with source FP32 vectors, +# then write fused 2-bit ASH traversal codes and NVQ reranking vectors to disk. +yamlSchemaVersion: 3 +onDiskIndexVersion: 7 +dataset: mpnet-10m + +construction: + type: onDisk + writerMode: sequential + outDegree: [32] + efConstruction: [100] + neighborOverflow: [1.2f] + addHierarchy: [Yes] + refineFinalGraph: [Yes] + fusedGraph: [Yes] + compression: + - type: None + reranking: + type: NVQ + useSavedIndexIfExists: No + +search: + topKOverquery: + 10: [1.0, 2.0] + useSearchPruning: [No] + compression: + - type: ASH + parameters: + encodedBits: 808 + optimizer: ITQ + landmarkCount: 1 + bitsPerDimension: 2 + queryProjection: fp16 + queryExecution: queued + maxBatchSize: 8 diff --git a/jvector-examples/yaml-configs/index-parameters/ash-paths/mpnet-10m-build-flush-fused-ash-4bit.yml b/jvector-examples/yaml-configs/index-parameters/ash-paths/mpnet-10m-build-flush-fused-ash-4bit.yml new file mode 100644 index 000000000..c857d1f9e --- /dev/null +++ b/jvector-examples/yaml-configs/index-parameters/ash-paths/mpnet-10m-build-flush-fused-ash-4bit.yml @@ -0,0 +1,35 @@ +# MPNet 10M at 16X nominal compression. Build the graph with source FP32 vectors, +# then write fused 4-bit ASH traversal codes and NVQ reranking vectors to disk. +yamlSchemaVersion: 3 +onDiskIndexVersion: 7 +dataset: mpnet-10m + +construction: + type: onDisk + writerMode: sequential + outDegree: [32] + efConstruction: [100] + neighborOverflow: [1.2f] + addHierarchy: [Yes] + refineFinalGraph: [Yes] + fusedGraph: [Yes] + compression: + - type: None + reranking: + type: NVQ + useSavedIndexIfExists: No + +search: + topKOverquery: + 10: [1.0, 2.0] + useSearchPruning: [No] + compression: + - type: ASH + parameters: + encodedBits: 1576 + optimizer: ITQ + landmarkCount: 1 + bitsPerDimension: 4 + queryProjection: fp16 + queryExecution: queued + maxBatchSize: 8 diff --git a/jvector-examples/yaml-configs/index-parameters/ash-paths/mpnet-10m-compressed-build-fused-ash-2bit.yml b/jvector-examples/yaml-configs/index-parameters/ash-paths/mpnet-10m-compressed-build-fused-ash-2bit.yml new file mode 100644 index 000000000..fc0def898 --- /dev/null +++ b/jvector-examples/yaml-configs/index-parameters/ash-paths/mpnet-10m-compressed-build-fused-ash-2bit.yml @@ -0,0 +1,41 @@ +# MPNet 10M at 32X nominal compression. Build with symmetric 2-bit ASH scoring +# and write fused ASH traversal codes and NVQ reranking vectors in parallel. +yamlSchemaVersion: 3 +onDiskIndexVersion: 7 +dataset: mpnet-10m + +construction: + type: onDisk + writerMode: parallel + outDegree: [32] + efConstruction: [100] + neighborOverflow: [1.2f] + addHierarchy: [Yes] + refineFinalGraph: [Yes] + fusedGraph: [Yes] + compression: + - type: ASH + scoring: symmetric + parameters: + encodedBits: 808 + optimizer: ITQ + landmarkCount: 1 + bitsPerDimension: 2 + reranking: + type: NVQ + useSavedIndexIfExists: No + +search: + topKOverquery: + 10: [1.0, 2.0] + useSearchPruning: [No] + compression: + - type: ASH + parameters: + encodedBits: 808 + optimizer: ITQ + landmarkCount: 1 + bitsPerDimension: 2 + queryProjection: fp16 + queryExecution: queued + maxBatchSize: 8 diff --git a/jvector-examples/yaml-configs/index-parameters/ash-paths/mpnet-10m-compressed-build-fused-ash-4bit.yml b/jvector-examples/yaml-configs/index-parameters/ash-paths/mpnet-10m-compressed-build-fused-ash-4bit.yml new file mode 100644 index 000000000..4403d5c48 --- /dev/null +++ b/jvector-examples/yaml-configs/index-parameters/ash-paths/mpnet-10m-compressed-build-fused-ash-4bit.yml @@ -0,0 +1,41 @@ +# MPNet 10M at 16X nominal compression. Build with symmetric 4-bit ASH scoring +# and write fused ASH traversal codes and NVQ reranking vectors in parallel. +yamlSchemaVersion: 3 +onDiskIndexVersion: 7 +dataset: mpnet-10m + +construction: + type: onDisk + writerMode: parallel + outDegree: [32] + efConstruction: [100] + neighborOverflow: [1.2f] + addHierarchy: [Yes] + refineFinalGraph: [Yes] + fusedGraph: [Yes] + compression: + - type: ASH + scoring: symmetric + parameters: + encodedBits: 1576 + optimizer: ITQ + landmarkCount: 1 + bitsPerDimension: 4 + reranking: + type: NVQ + useSavedIndexIfExists: No + +search: + topKOverquery: + 10: [1.0, 2.0] + useSearchPruning: [No] + compression: + - type: ASH + parameters: + encodedBits: 1576 + optimizer: ITQ + landmarkCount: 1 + bitsPerDimension: 4 + queryProjection: fp16 + queryExecution: queued + maxBatchSize: 8 diff --git a/jvector-examples/yaml-configs/ash-pq/ada002-2bit-ash.yml b/jvector-examples/yaml-configs/index-parameters/ash-pq/ada002-2bit-ash.yml similarity index 100% rename from jvector-examples/yaml-configs/ash-pq/ada002-2bit-ash.yml rename to jvector-examples/yaml-configs/index-parameters/ash-pq/ada002-2bit-ash.yml diff --git a/jvector-examples/yaml-configs/ash-pq/ada002-2bit-pq.yml b/jvector-examples/yaml-configs/index-parameters/ash-pq/ada002-2bit-pq.yml similarity index 100% rename from jvector-examples/yaml-configs/ash-pq/ada002-2bit-pq.yml rename to jvector-examples/yaml-configs/index-parameters/ash-pq/ada002-2bit-pq.yml diff --git a/jvector-examples/yaml-configs/ash-pq/ada002-4bit-ash.yml b/jvector-examples/yaml-configs/index-parameters/ash-pq/ada002-4bit-ash.yml similarity index 100% rename from jvector-examples/yaml-configs/ash-pq/ada002-4bit-ash.yml rename to jvector-examples/yaml-configs/index-parameters/ash-pq/ada002-4bit-ash.yml diff --git a/jvector-examples/yaml-configs/ash-pq/ada002-4bit-pq.yml b/jvector-examples/yaml-configs/index-parameters/ash-pq/ada002-4bit-pq.yml similarity index 100% rename from jvector-examples/yaml-configs/ash-pq/ada002-4bit-pq.yml rename to jvector-examples/yaml-configs/index-parameters/ash-pq/ada002-4bit-pq.yml diff --git a/jvector-examples/yaml-configs/ash-pq/cap6m-2bit-ash.yml b/jvector-examples/yaml-configs/index-parameters/ash-pq/cap6m-2bit-ash.yml similarity index 100% rename from jvector-examples/yaml-configs/ash-pq/cap6m-2bit-ash.yml rename to jvector-examples/yaml-configs/index-parameters/ash-pq/cap6m-2bit-ash.yml diff --git a/jvector-examples/yaml-configs/ash-pq/cap6m-2bit-pq.yml b/jvector-examples/yaml-configs/index-parameters/ash-pq/cap6m-2bit-pq.yml similarity index 100% rename from jvector-examples/yaml-configs/ash-pq/cap6m-2bit-pq.yml rename to jvector-examples/yaml-configs/index-parameters/ash-pq/cap6m-2bit-pq.yml diff --git a/jvector-examples/yaml-configs/ash-pq/cap6m-4bit-ash.yml b/jvector-examples/yaml-configs/index-parameters/ash-pq/cap6m-4bit-ash.yml similarity index 100% rename from jvector-examples/yaml-configs/ash-pq/cap6m-4bit-ash.yml rename to jvector-examples/yaml-configs/index-parameters/ash-pq/cap6m-4bit-ash.yml diff --git a/jvector-examples/yaml-configs/ash-pq/cap6m-4bit-pq.yml b/jvector-examples/yaml-configs/index-parameters/ash-pq/cap6m-4bit-pq.yml similarity index 100% rename from jvector-examples/yaml-configs/ash-pq/cap6m-4bit-pq.yml rename to jvector-examples/yaml-configs/index-parameters/ash-pq/cap6m-4bit-pq.yml diff --git a/jvector-examples/yaml-configs/ash-pq/cohere-2bit-ash.yml b/jvector-examples/yaml-configs/index-parameters/ash-pq/cohere-2bit-ash.yml similarity index 100% rename from jvector-examples/yaml-configs/ash-pq/cohere-2bit-ash.yml rename to jvector-examples/yaml-configs/index-parameters/ash-pq/cohere-2bit-ash.yml diff --git a/jvector-examples/yaml-configs/ash-pq/cohere-2bit-pq.yml b/jvector-examples/yaml-configs/index-parameters/ash-pq/cohere-2bit-pq.yml similarity index 100% rename from jvector-examples/yaml-configs/ash-pq/cohere-2bit-pq.yml rename to jvector-examples/yaml-configs/index-parameters/ash-pq/cohere-2bit-pq.yml diff --git a/jvector-examples/yaml-configs/ash-pq/cohere-4bit-ash.yml b/jvector-examples/yaml-configs/index-parameters/ash-pq/cohere-4bit-ash.yml similarity index 100% rename from jvector-examples/yaml-configs/ash-pq/cohere-4bit-ash.yml rename to jvector-examples/yaml-configs/index-parameters/ash-pq/cohere-4bit-ash.yml diff --git a/jvector-examples/yaml-configs/ash-pq/cohere-4bit-pq.yml b/jvector-examples/yaml-configs/index-parameters/ash-pq/cohere-4bit-pq.yml similarity index 100% rename from jvector-examples/yaml-configs/ash-pq/cohere-4bit-pq.yml rename to jvector-examples/yaml-configs/index-parameters/ash-pq/cohere-4bit-pq.yml diff --git a/jvector-examples/yaml-configs/ash-pq/cohere10m-2bit-ash.yml b/jvector-examples/yaml-configs/index-parameters/ash-pq/cohere10m-2bit-ash.yml similarity index 100% rename from jvector-examples/yaml-configs/ash-pq/cohere10m-2bit-ash.yml rename to jvector-examples/yaml-configs/index-parameters/ash-pq/cohere10m-2bit-ash.yml diff --git a/jvector-examples/yaml-configs/ash-pq/cohere10m-2bit-pq.yml b/jvector-examples/yaml-configs/index-parameters/ash-pq/cohere10m-2bit-pq.yml similarity index 100% rename from jvector-examples/yaml-configs/ash-pq/cohere10m-2bit-pq.yml rename to jvector-examples/yaml-configs/index-parameters/ash-pq/cohere10m-2bit-pq.yml diff --git a/jvector-examples/yaml-configs/ash-pq/cohere10m-4bit-ash.yml b/jvector-examples/yaml-configs/index-parameters/ash-pq/cohere10m-4bit-ash.yml similarity index 100% rename from jvector-examples/yaml-configs/ash-pq/cohere10m-4bit-ash.yml rename to jvector-examples/yaml-configs/index-parameters/ash-pq/cohere10m-4bit-ash.yml diff --git a/jvector-examples/yaml-configs/ash-pq/cohere10m-4bit-pq.yml b/jvector-examples/yaml-configs/index-parameters/ash-pq/cohere10m-4bit-pq.yml similarity index 100% rename from jvector-examples/yaml-configs/ash-pq/cohere10m-4bit-pq.yml rename to jvector-examples/yaml-configs/index-parameters/ash-pq/cohere10m-4bit-pq.yml diff --git a/jvector-examples/yaml-configs/ash-pq/mpnet-2bit-ash.yml b/jvector-examples/yaml-configs/index-parameters/ash-pq/mpnet-2bit-ash.yml similarity index 100% rename from jvector-examples/yaml-configs/ash-pq/mpnet-2bit-ash.yml rename to jvector-examples/yaml-configs/index-parameters/ash-pq/mpnet-2bit-ash.yml diff --git a/jvector-examples/yaml-configs/ash-pq/mpnet-2bit-pq.yml b/jvector-examples/yaml-configs/index-parameters/ash-pq/mpnet-2bit-pq.yml similarity index 100% rename from jvector-examples/yaml-configs/ash-pq/mpnet-2bit-pq.yml rename to jvector-examples/yaml-configs/index-parameters/ash-pq/mpnet-2bit-pq.yml diff --git a/jvector-examples/yaml-configs/ash-pq/mpnet-4bit-ash.yml b/jvector-examples/yaml-configs/index-parameters/ash-pq/mpnet-4bit-ash.yml similarity index 100% rename from jvector-examples/yaml-configs/ash-pq/mpnet-4bit-ash.yml rename to jvector-examples/yaml-configs/index-parameters/ash-pq/mpnet-4bit-ash.yml diff --git a/jvector-examples/yaml-configs/ash-pq/mpnet-4bit-pq.yml b/jvector-examples/yaml-configs/index-parameters/ash-pq/mpnet-4bit-pq.yml similarity index 100% rename from jvector-examples/yaml-configs/ash-pq/mpnet-4bit-pq.yml rename to jvector-examples/yaml-configs/index-parameters/ash-pq/mpnet-4bit-pq.yml diff --git a/jvector-examples/yaml-configs/ash-pq/mpnet10m-2bit-ash.yml b/jvector-examples/yaml-configs/index-parameters/ash-pq/mpnet10m-2bit-ash.yml similarity index 100% rename from jvector-examples/yaml-configs/ash-pq/mpnet10m-2bit-ash.yml rename to jvector-examples/yaml-configs/index-parameters/ash-pq/mpnet10m-2bit-ash.yml diff --git a/jvector-examples/yaml-configs/ash-pq/mpnet10m-2bit-pq.yml b/jvector-examples/yaml-configs/index-parameters/ash-pq/mpnet10m-2bit-pq.yml similarity index 100% rename from jvector-examples/yaml-configs/ash-pq/mpnet10m-2bit-pq.yml rename to jvector-examples/yaml-configs/index-parameters/ash-pq/mpnet10m-2bit-pq.yml diff --git a/jvector-examples/yaml-configs/ash-pq/mpnet10m-4bit-ash.yml b/jvector-examples/yaml-configs/index-parameters/ash-pq/mpnet10m-4bit-ash.yml similarity index 100% rename from jvector-examples/yaml-configs/ash-pq/mpnet10m-4bit-ash.yml rename to jvector-examples/yaml-configs/index-parameters/ash-pq/mpnet10m-4bit-ash.yml diff --git a/jvector-examples/yaml-configs/ash-pq/mpnet10m-4bit-pq.yml b/jvector-examples/yaml-configs/index-parameters/ash-pq/mpnet10m-4bit-pq.yml similarity index 100% rename from jvector-examples/yaml-configs/ash-pq/mpnet10m-4bit-pq.yml rename to jvector-examples/yaml-configs/index-parameters/ash-pq/mpnet10m-4bit-pq.yml diff --git a/jvector-examples/yaml-configs/ash-pq/openai3072-2bit-ash.yml b/jvector-examples/yaml-configs/index-parameters/ash-pq/openai3072-2bit-ash.yml similarity index 100% rename from jvector-examples/yaml-configs/ash-pq/openai3072-2bit-ash.yml rename to jvector-examples/yaml-configs/index-parameters/ash-pq/openai3072-2bit-ash.yml diff --git a/jvector-examples/yaml-configs/ash-pq/openai3072-2bit-pq.yml b/jvector-examples/yaml-configs/index-parameters/ash-pq/openai3072-2bit-pq.yml similarity index 100% rename from jvector-examples/yaml-configs/ash-pq/openai3072-2bit-pq.yml rename to jvector-examples/yaml-configs/index-parameters/ash-pq/openai3072-2bit-pq.yml diff --git a/jvector-examples/yaml-configs/ash-pq/openai3072-4bit-ash.yml b/jvector-examples/yaml-configs/index-parameters/ash-pq/openai3072-4bit-ash.yml similarity index 100% rename from jvector-examples/yaml-configs/ash-pq/openai3072-4bit-ash.yml rename to jvector-examples/yaml-configs/index-parameters/ash-pq/openai3072-4bit-ash.yml diff --git a/jvector-examples/yaml-configs/ash-pq/openai3072-4bit-pq.yml b/jvector-examples/yaml-configs/index-parameters/ash-pq/openai3072-4bit-pq.yml similarity index 100% rename from jvector-examples/yaml-configs/ash-pq/openai3072-4bit-pq.yml rename to jvector-examples/yaml-configs/index-parameters/ash-pq/openai3072-4bit-pq.yml diff --git a/jvector-examples/yaml-configs/index-parameters/cap-6M.yml b/jvector-examples/yaml-configs/index-parameters/cap-6M.yml index b85b3222d..a3ff0a12d 100644 --- a/jvector-examples/yaml-configs/index-parameters/cap-6M.yml +++ b/jvector-examples/yaml-configs/index-parameters/cap-6M.yml @@ -26,7 +26,7 @@ construction: # anisotropicThreshold: -1.0 reranking: type: FP32 - useSavedIndexIfExists: Yes # If yes, the system will attempt to locate a cached copy of the index instead of constructing + useSavedIndexIfExists: No # If yes, the system will attempt to locate a cached copy of the index instead of constructing search: topKOverquery: diff --git a/jvector-examples/yaml-configs/index-parameters/cohere-10M.yml b/jvector-examples/yaml-configs/index-parameters/cohere-10M.yml index 606ca90b0..ab836ff70 100644 --- a/jvector-examples/yaml-configs/index-parameters/cohere-10M.yml +++ b/jvector-examples/yaml-configs/index-parameters/cohere-10M.yml @@ -26,7 +26,7 @@ construction: # anisotropicThreshold: -1.0 reranking: type: FP32 - useSavedIndexIfExists: Yes # If yes, the system will attempt to locate a cached copy of the index instead of constructing + useSavedIndexIfExists: No # If yes, the system will attempt to locate a cached copy of the index instead of constructing search: topKOverquery: diff --git a/jvector-examples/yaml-configs/index-parameters/dpr-gemma-1M.yml b/jvector-examples/yaml-configs/index-parameters/dpr-gemma-1M.yml index e1dd268d1..59d87db33 100644 --- a/jvector-examples/yaml-configs/index-parameters/dpr-gemma-1M.yml +++ b/jvector-examples/yaml-configs/index-parameters/dpr-gemma-1M.yml @@ -26,7 +26,7 @@ construction: # anisotropicThreshold: -1.0 reranking: type: FP32 - useSavedIndexIfExists: Yes # If yes, the system will attempt to locate a cached copy of the index instead of constructing + useSavedIndexIfExists: No # If yes, the system will attempt to locate a cached copy of the index instead of constructing search: topKOverquery: diff --git a/jvector-examples/yaml-configs/index-parameters/esmc-600-1M.yml b/jvector-examples/yaml-configs/index-parameters/esmc-600-1M.yml index ceb5de9a2..23bb5ee79 100644 --- a/jvector-examples/yaml-configs/index-parameters/esmc-600-1M.yml +++ b/jvector-examples/yaml-configs/index-parameters/esmc-600-1M.yml @@ -26,7 +26,7 @@ construction: # anisotropicThreshold: -1.0 reranking: type: FP32 - useSavedIndexIfExists: Yes # If yes, the system will attempt to locate a cached copy of the index instead of constructing + useSavedIndexIfExists: No # If yes, the system will attempt to locate a cached copy of the index instead of constructing search: topKOverquery: diff --git a/jvector-examples/yaml-configs/index-parameters/esmc-600-6M.yml b/jvector-examples/yaml-configs/index-parameters/esmc-600-6M.yml index 0c04dd8a3..0655539d7 100644 --- a/jvector-examples/yaml-configs/index-parameters/esmc-600-6M.yml +++ b/jvector-examples/yaml-configs/index-parameters/esmc-600-6M.yml @@ -26,7 +26,7 @@ construction: # anisotropicThreshold: -1.0 reranking: type: FP32 - useSavedIndexIfExists: Yes # If yes, the system will attempt to locate a cached copy of the index instead of constructing + useSavedIndexIfExists: No # If yes, the system will attempt to locate a cached copy of the index instead of constructing search: topKOverquery: diff --git a/jvector-examples/yaml-configs/index-parameters/mpnet-10M.yml b/jvector-examples/yaml-configs/index-parameters/mpnet-10M.yml index 307b77d5b..021f4adc4 100644 --- a/jvector-examples/yaml-configs/index-parameters/mpnet-10M.yml +++ b/jvector-examples/yaml-configs/index-parameters/mpnet-10M.yml @@ -1,5 +1,5 @@ # MPNet-10M: ASH(b=2,d=384), nominal 32x compression. -# Fused graph scoring, NVQ reranking, and saved model/index reuse. +# Fused graph scoring and NVQ reranking. yamlSchemaVersion: 3 onDiskIndexVersion: 7 dataset: mpnet-10m @@ -21,9 +21,9 @@ construction: bitsPerDimension: 2 reranking: type: NVQ - # A fresh case directory measures training/encoding/construction. Later JVMs - # in that directory reuse exactly the same model and index for querying. - useSavedIndexIfExists: Yes + # Saved indexes consume disk space, so reuse is opt-in. Set Yes to query a + # compatible index saved by an earlier run in the same working directory. + useSavedIndexIfExists: No search: topKOverquery: diff --git a/jvector-examples/yaml-configs/index-parameters/mpnet-1M.yml b/jvector-examples/yaml-configs/index-parameters/mpnet-1M.yml index a85802db0..7d3657107 100644 --- a/jvector-examples/yaml-configs/index-parameters/mpnet-1M.yml +++ b/jvector-examples/yaml-configs/index-parameters/mpnet-1M.yml @@ -26,7 +26,7 @@ construction: # anisotropicThreshold: -1.0 reranking: type: FP32 - useSavedIndexIfExists: Yes # If yes, the system will attempt to locate a cached copy of the index instead of constructing + useSavedIndexIfExists: No # If yes, the system will attempt to locate a cached copy of the index instead of constructing search: topKOverquery: diff --git a/jvector-examples/yaml-configs/index-parameters/openai-v3-small-100k.yml b/jvector-examples/yaml-configs/index-parameters/openai-v3-small-100k.yml index 557a66573..9f6f2a05c 100644 --- a/jvector-examples/yaml-configs/index-parameters/openai-v3-small-100k.yml +++ b/jvector-examples/yaml-configs/index-parameters/openai-v3-small-100k.yml @@ -26,7 +26,7 @@ construction: # anisotropicThreshold: -1.0 reranking: type: FP32 - useSavedIndexIfExists: Yes # If yes, the system will attempt to locate a cached copy of the index instead of constructing + useSavedIndexIfExists: No # If yes, the system will attempt to locate a cached copy of the index instead of constructing search: topKOverquery: diff --git a/jvector-examples/yaml-configs/index-parameters/personahub-1M.yml b/jvector-examples/yaml-configs/index-parameters/personahub-1M.yml index d55c1f0c5..200c4622f 100644 --- a/jvector-examples/yaml-configs/index-parameters/personahub-1M.yml +++ b/jvector-examples/yaml-configs/index-parameters/personahub-1M.yml @@ -26,7 +26,7 @@ construction: # anisotropicThreshold: -1.0 reranking: type: FP32 - useSavedIndexIfExists: Yes # If yes, the system will attempt to locate a cached copy of the index instead of constructing + useSavedIndexIfExists: No # If yes, the system will attempt to locate a cached copy of the index instead of constructing search: topKOverquery: diff --git a/jvector-examples/yaml-configs/index-parameters/tahoe-x1-1M.yml b/jvector-examples/yaml-configs/index-parameters/tahoe-x1-1M.yml index c8692c4f1..8caea1ed4 100644 --- a/jvector-examples/yaml-configs/index-parameters/tahoe-x1-1M.yml +++ b/jvector-examples/yaml-configs/index-parameters/tahoe-x1-1M.yml @@ -26,7 +26,7 @@ construction: # anisotropicThreshold: -1.0 reranking: type: FP32 - useSavedIndexIfExists: Yes # If yes, the system will attempt to locate a cached copy of the index instead of constructing + useSavedIndexIfExists: No # If yes, the system will attempt to locate a cached copy of the index instead of constructing search: topKOverquery: From ba0a9f2ae8a6f29a6cc153ef65c27d38c7b7c799 Mon Sep 17 00:00:00 2001 From: Ted Willke Date: Mon, 9 Nov 2026 10:00:34 -0800 Subject: [PATCH 165/243] Organize index parameters into dataset defaults and example pipelines Running BenchYAML with a dataset name now loads only index-parameters/.yml, compared by exact name, and the file must declare that dataset. File.exists ignores case on macOS, so six defaults were found there but silently replaced by default.yml on Linux, and openai-v3-small-100k.yml was never found. Rename those seven files to their dataset names. A case-only match or a mismatched dataset field now stops with the files involved and the rule broken; a dataset without a file warns before using default.yml. Subdirectories hold complete example pipelines, run by path and never searched for defaults: in-memory graphs (full precision, ASH and BQ search), in-memory builds flushed to fused ASH or PQ graphs with NVQ reranking, compressed ASH builds, on-disk graphs with separate ASH or PQ codes, uncompressed on-disk graphs with a graph-settings sweep, and one-option ASH variants. The ASH/PQ comparison recipes move to studies/ash-vs-pq. Each folder has a README with its use case. BenchYAML prints the pipeline each configuration selects, inferred from its fields. Tests check exact-name lookup and its messages, that every file in an example folder selects one pipeline, and that every documented construction, search, reranking and ASH option has an example. Fill in the missing PersonaHub-1M metadata from its source: unit vectors with inner-product ground truth. --- docs/ash-pq-benchmark.md | 4 +- docs/benchmarking.md | 2 +- docs/benchyaml-guide.md | 11 +- jvector-examples/run-ash-pq-benchmark.sh | 2 +- .../jbellis/jvector/example/BenchYAML.java | 4 +- .../jvector/example/yaml/MultiConfig.java | 134 +++++++---- .../StandaloneASHBenchmarkConfigTest.java | 36 +-- .../example/yaml/IndexParametersTest.java | 213 ++++++++++++++++++ .../yaml-configs/dataset-metadata.yml | 2 + .../yaml-configs/index-parameters/README.md | 34 +++ .../index-parameters/ash-options/README.md | 15 ++ .../ash-options/ada002-1M-4-landmarks.yml | 39 ++++ .../ash-options/cap-1M-direct-execution.yml | 38 ++++ .../ash-options/cap-1M-fp32-projection.yml | 38 ++++ .../cohere-english-v3-1M-mixed-build.yml | 38 ++++ .../cohere-english-v3-1M-queued-execution.yml | 39 ++++ .../gecko-100k-random-optimizer.yml | 37 +++ .../ash-options/mpnet-1m-asymmetric-build.yml | 39 ++++ .../ash-options/mpnet-1m-int8-projection.yml | 39 ++++ .../compressed-build-fused-ash-nvq/README.md | 15 ++ .../cap-1M-2bit.yml} | 0 .../cap-1M-4bit.yml} | 0 .../mpnet-10m-2bit.yml} | 0 .../mpnet-10m-4bit.yml} | 0 .../{dpr-gemma-10M.yml => dpr-gemma-10m.yml} | 0 .../{dpr-gemma-1M.yml => dpr-gemma-1m.yml} | 0 .../flush-fused-ash-nvq/README.md | 15 ++ .../cap-1M-2bit.yml} | 0 .../cap-1M-4bit.yml} | 0 .../mpnet-10m-2bit.yml} | 0 .../mpnet-10m-4bit.yml} | 0 .../flush-fused-pq-nvq/README.md | 15 ++ .../cohere-english-v3-1M.yml | 31 +++ .../flush-fused-pq-nvq/mpnet-1m.yml | 31 +++ .../index-parameters/in-memory-ash/README.md | 15 ++ .../in-memory-ash/ada002-1M.yml | 30 +++ .../in-memory-ash/cohere-english-v3-1M.yml | 30 +++ .../in-memory-ash/mpnet-1m.yml | 30 +++ .../index-parameters/in-memory-bq/README.md | 15 ++ .../in-memory-bq/openai-3072-1m.yml | 25 ++ .../in-memory-bq/tahoe-x1-1m.yml | 25 ++ .../index-parameters/in-memory-fp/README.md | 15 ++ .../in-memory-fp/gecko-100k.yml | 26 +++ .../in-memory-fp/glove-100-angular.yml | 26 +++ .../in-memory-fp/sift-128-euclidean.yml | 26 +++ .../{mpnet-10M.yml => mpnet-10m.yml} | 0 .../{mpnet-1M.yml => mpnet-1m.yml} | 0 .../on-disk-ash-fp32/README.md | 15 ++ .../on-disk-ash-fp32/esmc-600-1M.yml | 37 +++ .../on-disk-ash-fp32/personahub-1m.yml | 37 +++ .../on-disk-ash-fp32/tahoe-x1-1m.yml | 37 +++ .../index-parameters/on-disk-fp/README.md | 15 ++ .../glove-50-angular-graph-sweep.yml | 28 +++ .../on-disk-fp/lastfm-64-dot.yml | 27 +++ .../on-disk-fp/nytimes-256-angular.yml | 27 +++ .../on-disk-pq-fp32/README.md | 15 ++ .../on-disk-pq-fp32/dpr-gemma-1m.yml | 35 +++ .../on-disk-pq-fp32/personahub-1m.yml | 35 +++ ...100k.yml => openai-v3-small-1536-100k.yml} | 0 .../{personahub-1M.yml => personahub-1m.yml} | 0 .../index-parameters/studies/README.md | 5 + .../ash-vs-pq}/ada002-2bit-ash.yml | 0 .../ash-vs-pq}/ada002-2bit-pq.yml | 0 .../ash-vs-pq}/ada002-4bit-ash.yml | 0 .../ash-vs-pq}/ada002-4bit-pq.yml | 0 .../ash-vs-pq}/cap6m-2bit-ash.yml | 0 .../ash-vs-pq}/cap6m-2bit-pq.yml | 0 .../ash-vs-pq}/cap6m-4bit-ash.yml | 0 .../ash-vs-pq}/cap6m-4bit-pq.yml | 0 .../ash-vs-pq}/cohere-2bit-ash.yml | 0 .../ash-vs-pq}/cohere-2bit-pq.yml | 0 .../ash-vs-pq}/cohere-4bit-ash.yml | 0 .../ash-vs-pq}/cohere-4bit-pq.yml | 0 .../ash-vs-pq}/cohere10m-2bit-ash.yml | 0 .../ash-vs-pq}/cohere10m-2bit-pq.yml | 0 .../ash-vs-pq}/cohere10m-4bit-ash.yml | 0 .../ash-vs-pq}/cohere10m-4bit-pq.yml | 0 .../ash-vs-pq}/mpnet-2bit-ash.yml | 0 .../ash-vs-pq}/mpnet-2bit-pq.yml | 0 .../ash-vs-pq}/mpnet-4bit-ash.yml | 0 .../ash-vs-pq}/mpnet-4bit-pq.yml | 0 .../ash-vs-pq}/mpnet10m-2bit-ash.yml | 0 .../ash-vs-pq}/mpnet10m-2bit-pq.yml | 0 .../ash-vs-pq}/mpnet10m-4bit-ash.yml | 0 .../ash-vs-pq}/mpnet10m-4bit-pq.yml | 0 .../ash-vs-pq}/openai3072-2bit-ash.yml | 0 .../ash-vs-pq}/openai3072-2bit-pq.yml | 0 .../ash-vs-pq}/openai3072-4bit-ash.yml | 0 .../ash-vs-pq}/openai3072-4bit-pq.yml | 0 .../{tahoe-x1-1M.yml => tahoe-x1-1m.yml} | 0 90 files changed, 1352 insertions(+), 95 deletions(-) create mode 100644 jvector-examples/src/test/java/io/github/jbellis/jvector/example/yaml/IndexParametersTest.java create mode 100644 jvector-examples/yaml-configs/index-parameters/README.md create mode 100644 jvector-examples/yaml-configs/index-parameters/ash-options/README.md create mode 100644 jvector-examples/yaml-configs/index-parameters/ash-options/ada002-1M-4-landmarks.yml create mode 100644 jvector-examples/yaml-configs/index-parameters/ash-options/cap-1M-direct-execution.yml create mode 100644 jvector-examples/yaml-configs/index-parameters/ash-options/cap-1M-fp32-projection.yml create mode 100644 jvector-examples/yaml-configs/index-parameters/ash-options/cohere-english-v3-1M-mixed-build.yml create mode 100644 jvector-examples/yaml-configs/index-parameters/ash-options/cohere-english-v3-1M-queued-execution.yml create mode 100644 jvector-examples/yaml-configs/index-parameters/ash-options/gecko-100k-random-optimizer.yml create mode 100644 jvector-examples/yaml-configs/index-parameters/ash-options/mpnet-1m-asymmetric-build.yml create mode 100644 jvector-examples/yaml-configs/index-parameters/ash-options/mpnet-1m-int8-projection.yml create mode 100644 jvector-examples/yaml-configs/index-parameters/compressed-build-fused-ash-nvq/README.md rename jvector-examples/yaml-configs/index-parameters/{ash-paths/cap-1m-compressed-build-fused-ash-2bit.yml => compressed-build-fused-ash-nvq/cap-1M-2bit.yml} (100%) rename jvector-examples/yaml-configs/index-parameters/{ash-paths/cap-1m-compressed-build-fused-ash-4bit.yml => compressed-build-fused-ash-nvq/cap-1M-4bit.yml} (100%) rename jvector-examples/yaml-configs/index-parameters/{ash-paths/mpnet-10m-compressed-build-fused-ash-2bit.yml => compressed-build-fused-ash-nvq/mpnet-10m-2bit.yml} (100%) rename jvector-examples/yaml-configs/index-parameters/{ash-paths/mpnet-10m-compressed-build-fused-ash-4bit.yml => compressed-build-fused-ash-nvq/mpnet-10m-4bit.yml} (100%) rename jvector-examples/yaml-configs/index-parameters/{dpr-gemma-10M.yml => dpr-gemma-10m.yml} (100%) rename jvector-examples/yaml-configs/index-parameters/{dpr-gemma-1M.yml => dpr-gemma-1m.yml} (100%) create mode 100644 jvector-examples/yaml-configs/index-parameters/flush-fused-ash-nvq/README.md rename jvector-examples/yaml-configs/index-parameters/{ash-paths/cap-1m-build-flush-fused-ash-2bit.yml => flush-fused-ash-nvq/cap-1M-2bit.yml} (100%) rename jvector-examples/yaml-configs/index-parameters/{ash-paths/cap-1m-build-flush-fused-ash-4bit.yml => flush-fused-ash-nvq/cap-1M-4bit.yml} (100%) rename jvector-examples/yaml-configs/index-parameters/{ash-paths/mpnet-10m-build-flush-fused-ash-2bit.yml => flush-fused-ash-nvq/mpnet-10m-2bit.yml} (100%) rename jvector-examples/yaml-configs/index-parameters/{ash-paths/mpnet-10m-build-flush-fused-ash-4bit.yml => flush-fused-ash-nvq/mpnet-10m-4bit.yml} (100%) create mode 100644 jvector-examples/yaml-configs/index-parameters/flush-fused-pq-nvq/README.md create mode 100644 jvector-examples/yaml-configs/index-parameters/flush-fused-pq-nvq/cohere-english-v3-1M.yml create mode 100644 jvector-examples/yaml-configs/index-parameters/flush-fused-pq-nvq/mpnet-1m.yml create mode 100644 jvector-examples/yaml-configs/index-parameters/in-memory-ash/README.md create mode 100644 jvector-examples/yaml-configs/index-parameters/in-memory-ash/ada002-1M.yml create mode 100644 jvector-examples/yaml-configs/index-parameters/in-memory-ash/cohere-english-v3-1M.yml create mode 100644 jvector-examples/yaml-configs/index-parameters/in-memory-ash/mpnet-1m.yml create mode 100644 jvector-examples/yaml-configs/index-parameters/in-memory-bq/README.md create mode 100644 jvector-examples/yaml-configs/index-parameters/in-memory-bq/openai-3072-1m.yml create mode 100644 jvector-examples/yaml-configs/index-parameters/in-memory-bq/tahoe-x1-1m.yml create mode 100644 jvector-examples/yaml-configs/index-parameters/in-memory-fp/README.md create mode 100644 jvector-examples/yaml-configs/index-parameters/in-memory-fp/gecko-100k.yml create mode 100644 jvector-examples/yaml-configs/index-parameters/in-memory-fp/glove-100-angular.yml create mode 100644 jvector-examples/yaml-configs/index-parameters/in-memory-fp/sift-128-euclidean.yml rename jvector-examples/yaml-configs/index-parameters/{mpnet-10M.yml => mpnet-10m.yml} (100%) rename jvector-examples/yaml-configs/index-parameters/{mpnet-1M.yml => mpnet-1m.yml} (100%) create mode 100644 jvector-examples/yaml-configs/index-parameters/on-disk-ash-fp32/README.md create mode 100644 jvector-examples/yaml-configs/index-parameters/on-disk-ash-fp32/esmc-600-1M.yml create mode 100644 jvector-examples/yaml-configs/index-parameters/on-disk-ash-fp32/personahub-1m.yml create mode 100644 jvector-examples/yaml-configs/index-parameters/on-disk-ash-fp32/tahoe-x1-1m.yml create mode 100644 jvector-examples/yaml-configs/index-parameters/on-disk-fp/README.md create mode 100644 jvector-examples/yaml-configs/index-parameters/on-disk-fp/glove-50-angular-graph-sweep.yml create mode 100644 jvector-examples/yaml-configs/index-parameters/on-disk-fp/lastfm-64-dot.yml create mode 100644 jvector-examples/yaml-configs/index-parameters/on-disk-fp/nytimes-256-angular.yml create mode 100644 jvector-examples/yaml-configs/index-parameters/on-disk-pq-fp32/README.md create mode 100644 jvector-examples/yaml-configs/index-parameters/on-disk-pq-fp32/dpr-gemma-1m.yml create mode 100644 jvector-examples/yaml-configs/index-parameters/on-disk-pq-fp32/personahub-1m.yml rename jvector-examples/yaml-configs/index-parameters/{openai-v3-small-100k.yml => openai-v3-small-1536-100k.yml} (100%) rename jvector-examples/yaml-configs/index-parameters/{personahub-1M.yml => personahub-1m.yml} (100%) create mode 100644 jvector-examples/yaml-configs/index-parameters/studies/README.md rename jvector-examples/yaml-configs/index-parameters/{ash-pq => studies/ash-vs-pq}/ada002-2bit-ash.yml (100%) rename jvector-examples/yaml-configs/index-parameters/{ash-pq => studies/ash-vs-pq}/ada002-2bit-pq.yml (100%) rename jvector-examples/yaml-configs/index-parameters/{ash-pq => studies/ash-vs-pq}/ada002-4bit-ash.yml (100%) rename jvector-examples/yaml-configs/index-parameters/{ash-pq => studies/ash-vs-pq}/ada002-4bit-pq.yml (100%) rename jvector-examples/yaml-configs/index-parameters/{ash-pq => studies/ash-vs-pq}/cap6m-2bit-ash.yml (100%) rename jvector-examples/yaml-configs/index-parameters/{ash-pq => studies/ash-vs-pq}/cap6m-2bit-pq.yml (100%) rename jvector-examples/yaml-configs/index-parameters/{ash-pq => studies/ash-vs-pq}/cap6m-4bit-ash.yml (100%) rename jvector-examples/yaml-configs/index-parameters/{ash-pq => studies/ash-vs-pq}/cap6m-4bit-pq.yml (100%) rename jvector-examples/yaml-configs/index-parameters/{ash-pq => studies/ash-vs-pq}/cohere-2bit-ash.yml (100%) rename jvector-examples/yaml-configs/index-parameters/{ash-pq => studies/ash-vs-pq}/cohere-2bit-pq.yml (100%) rename jvector-examples/yaml-configs/index-parameters/{ash-pq => studies/ash-vs-pq}/cohere-4bit-ash.yml (100%) rename jvector-examples/yaml-configs/index-parameters/{ash-pq => studies/ash-vs-pq}/cohere-4bit-pq.yml (100%) rename jvector-examples/yaml-configs/index-parameters/{ash-pq => studies/ash-vs-pq}/cohere10m-2bit-ash.yml (100%) rename jvector-examples/yaml-configs/index-parameters/{ash-pq => studies/ash-vs-pq}/cohere10m-2bit-pq.yml (100%) rename jvector-examples/yaml-configs/index-parameters/{ash-pq => studies/ash-vs-pq}/cohere10m-4bit-ash.yml (100%) rename jvector-examples/yaml-configs/index-parameters/{ash-pq => studies/ash-vs-pq}/cohere10m-4bit-pq.yml (100%) rename jvector-examples/yaml-configs/index-parameters/{ash-pq => studies/ash-vs-pq}/mpnet-2bit-ash.yml (100%) rename jvector-examples/yaml-configs/index-parameters/{ash-pq => studies/ash-vs-pq}/mpnet-2bit-pq.yml (100%) rename jvector-examples/yaml-configs/index-parameters/{ash-pq => studies/ash-vs-pq}/mpnet-4bit-ash.yml (100%) rename jvector-examples/yaml-configs/index-parameters/{ash-pq => studies/ash-vs-pq}/mpnet-4bit-pq.yml (100%) rename jvector-examples/yaml-configs/index-parameters/{ash-pq => studies/ash-vs-pq}/mpnet10m-2bit-ash.yml (100%) rename jvector-examples/yaml-configs/index-parameters/{ash-pq => studies/ash-vs-pq}/mpnet10m-2bit-pq.yml (100%) rename jvector-examples/yaml-configs/index-parameters/{ash-pq => studies/ash-vs-pq}/mpnet10m-4bit-ash.yml (100%) rename jvector-examples/yaml-configs/index-parameters/{ash-pq => studies/ash-vs-pq}/mpnet10m-4bit-pq.yml (100%) rename jvector-examples/yaml-configs/index-parameters/{ash-pq => studies/ash-vs-pq}/openai3072-2bit-ash.yml (100%) rename jvector-examples/yaml-configs/index-parameters/{ash-pq => studies/ash-vs-pq}/openai3072-2bit-pq.yml (100%) rename jvector-examples/yaml-configs/index-parameters/{ash-pq => studies/ash-vs-pq}/openai3072-4bit-ash.yml (100%) rename jvector-examples/yaml-configs/index-parameters/{ash-pq => studies/ash-vs-pq}/openai3072-4bit-pq.yml (100%) rename jvector-examples/yaml-configs/index-parameters/{tahoe-x1-1M.yml => tahoe-x1-1m.yml} (100%) diff --git a/docs/ash-pq-benchmark.md b/docs/ash-pq-benchmark.md index 601911d3b..3733013a2 100644 --- a/docs/ash-pq-benchmark.md +++ b/docs/ash-pq-benchmark.md @@ -23,7 +23,7 @@ describes the index configuration. See settings. This comparison uses the branch's ordinary packaged JVector code and -`io.github.jbellis.jvector.example.BenchYAML`. The fixed [index configurations](../jvector-examples/yaml-configs/index-parameters/ash-pq) +`io.github.jbellis.jvector.example.BenchYAML`. The fixed [index configurations](../jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq) have their own directory. The [run configurations and local dataset catalogs](../jvector-examples/yaml-configs/ash-pq) are grouped separately. No separate query harness, source overlay, profiling agent, manually linked @@ -215,7 +215,7 @@ numactl --physcpubind=0 --membind=0 "$JAVA_HOME/bin/java" \ "-Djvector.bench.sourceRef=$(git -C "$REPO_ROOT" rev-parse HEAD)" \ "-Djvector.bench.runConfig=$REPO_ROOT/jvector-examples/yaml-configs/ash-pq/run-queued.yml" \ -cp "$BENCH_JAR" io.github.jbellis.jvector.example.BenchYAML \ - "$REPO_ROOT/jvector-examples/yaml-configs/index-parameters/ash-pq/mpnet-2bit-ash.yml" + "$REPO_ROOT/jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq/mpnet-2bit-ash.yml" ``` The case directory contains copies of the checked-in dataset catalog, dataset diff --git a/docs/benchmarking.md b/docs/benchmarking.md index 79c98f032..e0ba15437 100644 --- a/docs/benchmarking.md +++ b/docs/benchmarking.md @@ -51,7 +51,7 @@ some-other-category: `jvector-examples/yaml-configs/index-parameters/default.yml` specifies the default index construction and search parameters to be used by `bench` for all datasets. -You can specify a custom set of a parameters for any given dataset by creating a file called `.yml`, with `` replaced by the actual name of the dataset. This is the same as the identifier used in `datasets.yml`. The format of this file is exactly the same as `default.yml`. +You can specify a custom set of a parameters for any given dataset by creating a file called `.yml`, with `` replaced by the actual name of the dataset. This is the same as the identifier used in `datasets.yml`. The format of this file is exactly the same as `default.yml`. The name must match exactly, including case, and the file's `dataset:` field must name the same dataset; BenchYAML stops with an error otherwise. Place the file directly in `index-parameters`, not a subfolder. Subfolders hold [example pipelines](../jvector-examples/yaml-configs/index-parameters/README.md) that you run by path. See the [BenchYAML Guide](benchyaml-guide.md) for the field reference and an example index configuration. diff --git a/docs/benchyaml-guide.md b/docs/benchyaml-guide.md index 26bf6a946..766deaba0 100644 --- a/docs/benchyaml-guide.md +++ b/docs/benchyaml-guide.md @@ -24,11 +24,12 @@ reranking optionally rescores the candidates. ## Configuration files - [Index parameters](../jvector-examples/yaml-configs/index-parameters) describe - construction and search for a dataset. A dataset-specific file takes - precedence over `default.yml`. To run an explicit recipe, pass its path to - BenchYAML. The [ASH/PQ comparison recipes](../jvector-examples/yaml-configs/index-parameters/ash-pq) - and [ASH build and flush examples](../jvector-examples/yaml-configs/index-parameters/ash-paths) - are grouped in subdirectories. + construction and search for a dataset. Running BenchYAML with a dataset name + loads `index-parameters/.yml`, named exactly like the dataset and + declaring it, or `default.yml` when the dataset has no file. Subdirectories + hold [example pipelines](../jvector-examples/yaml-configs/index-parameters/README.md), + such as in-memory graphs and in-memory builds flushed to fused on-disk graphs, + and published studies. Run them by passing their paths to BenchYAML. - [Dataset catalogs](../jvector-examples/yaml-configs/dataset-catalogs) locate the base vectors, query vectors, and ground truth. [Dataset metadata](../jvector-examples/yaml-configs/dataset-metadata.yml) diff --git a/jvector-examples/run-ash-pq-benchmark.sh b/jvector-examples/run-ash-pq-benchmark.sh index 194108f59..8ebb3cc0e 100755 --- a/jvector-examples/run-ash-pq-benchmark.sh +++ b/jvector-examples/run-ash-pq-benchmark.sh @@ -70,7 +70,7 @@ git -C "$REPO_ROOT" diff --quiet git -C "$REPO_ROOT" diff --cached --quiet SOURCE_REF="$(git -C "$REPO_ROOT" rev-parse HEAD)" JAVA_BIN="$(command -v java)" -CONFIG="$REPO_ROOT/jvector-examples/yaml-configs/index-parameters/ash-pq/$CONFIG_NAME" +CONFIG="$REPO_ROOT/jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq/$CONFIG_NAME" RUN_CONFIG="$REPO_ROOT/jvector-examples/yaml-configs/ash-pq/run-$MODE.yml" CATALOG_NAME="${JVECTOR_BENCH_CATALOG:-catalog-1m.yml}" case "$CATALOG_NAME" in catalog-1m.yml|catalog-large.yml) ;; *) echo "Unknown dataset catalog: $CATALOG_NAME" >&2; exit 2;; esac diff --git a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/BenchYAML.java b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/BenchYAML.java index dfa28544b..ca1ec242a 100644 --- a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/BenchYAML.java +++ b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/BenchYAML.java @@ -82,8 +82,6 @@ private static void run(String[] args, SearchParameters.AshQueryProjectionSettin String name = raw.endsWith(HDF5) ? raw.substring(0, raw.length() - HDF5.length()) : raw; allConfigs.add(MultiConfig.getDefaultConfig(name)); } - - MultiConfig.printDefaultConfigUsageSummary(); } // get the list of YAML files from args @@ -134,6 +132,8 @@ private static void run(String[] args, SearchParameters.AshQueryProjectionSettin var loading = BaseVectorLoading.forBenchmark(config); String datasetName = config.dataset; + String pipeline = config.describePipeline(); + if (pipeline != null) System.out.println("Pipeline for " + datasetName + ": " + pipeline); try (var ds = DataSets.loadDataSet(datasetName, loading).orElseThrow( () -> new RuntimeException("Could not load dataset:" + datasetName) ).getDataSet()) { diff --git a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/yaml/MultiConfig.java b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/yaml/MultiConfig.java index 21968786e..64b67e4b8 100644 --- a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/yaml/MultiConfig.java +++ b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/yaml/MultiConfig.java @@ -48,33 +48,45 @@ public class MultiConfig { private int yamlSchemaVersion; private int onDiskIndexVersion; - private static final java.util.concurrent.ConcurrentSkipListSet DEFAULT_USED_FOR_DATASETS = - new java.util.concurrent.ConcurrentSkipListSet<>(); - - private static final java.util.concurrent.atomic.AtomicReference DEFAULT_FILE_USED = - new java.util.concurrent.atomic.AtomicReference<>(); - + /** + * Returns the default index config for a dataset: {@code index-parameters/.yml}, named exactly + * like the dataset and declaring that dataset. Subdirectories hold examples and are never searched. + * A dataset without its own file uses {@code default.yml}. + */ public static MultiConfig getDefaultConfig(String datasetName) throws FileNotFoundException { - var name = defaultDirectory + datasetName; - if (!name.endsWith(".yml")) { - name += ".yml"; - } - File configFile = new File(name); - boolean useDefault = !configFile.exists(); - if (useDefault) { - configFile = new File(defaultDirectory + "default.yml"); - - // Record fallback usage for a compact summary print later - DEFAULT_FILE_USED.compareAndSet(null, configFile); - DEFAULT_USED_FOR_DATASETS.add(datasetName); - } - - var config = getConfig(configFile); + return getDefaultConfig(new File(defaultDirectory), datasetName); + } - if (useDefault) { - config.dataset = datasetName; + static MultiConfig getDefaultConfig(File directory, String datasetName) throws FileNotFoundException { + String fileName = datasetName.endsWith(".yml") ? datasetName : datasetName + ".yml"; + String[] names = directory.list(); + if (names == null) throw new FileNotFoundException("Index config directory not found: " + directory.getPath()); + // Compare names exactly. File.exists ignores case on some file systems, so lookups would + // otherwise find different files on macOS and Linux. + String caseOnly = null; + for (String name : names) { + if (name.equals(fileName)) { + File file = new File(directory, name); + MultiConfig config = getConfig(file); + if (config.dataset != null && !config.dataset.equals(datasetName)) { + throw new IllegalArgumentException("Index config " + file.getPath() + " declares dataset: " + + config.dataset + "." + System.lineSeparator() + + "A default config's file name must match its dataset field exactly."); + } + return config; + } + if (name.equalsIgnoreCase(fileName) && new File(directory, name).isFile()) caseOnly = name; } - + if (caseOnly != null) { + throw new IllegalArgumentException("No index config is named exactly " + fileName + "; found" + + System.lineSeparator() + " " + new File(directory, caseOnly).getPath() + + System.lineSeparator() + "File names must match the dataset name exactly, including case."); + } + File fallback = new File(directory, "default.yml"); + System.err.println("WARNING: No index config named " + fileName + "; using " + fallback.getPath() + "." + + System.lineSeparator() + "Dataset defaults are named .yml in " + directory.getPath() + "."); + MultiConfig config = getConfig(fallback); + config.dataset = datasetName; return config; } @@ -82,14 +94,16 @@ public static MultiConfig getConfig(String configName) throws FileNotFoundExcept File configFile = new File(configName); // If the file doesn't exist as an absolute path, try relative to the default directory if (!configFile.exists() && !configName.startsWith("/") && !configName.contains(":")) { - configFile = new File(defaultDirectory + configName); + File relative = new File(defaultDirectory + configName); + if (!relative.exists()) throw new FileNotFoundException("Index config not found: " + configName); + configFile = relative; } return getConfig(configFile); } static MultiConfig getConfig(File configFile) throws FileNotFoundException { if (!configFile.exists()) { - throw new FileNotFoundException(configFile.getAbsolutePath()); + throw new FileNotFoundException("Index config not found: " + configFile.getPath()); } final String text; @@ -243,6 +257,51 @@ private void validateSchema3() { } } + /** + * The pipeline this schema 3 config selects, inferred from its fields, for example + * {@code onDisk · build ASH-2b symmetric · search fused ASH-2b · rerank NVQ}. + * Sweeps list each value. Returns null for older schemas. + */ + public String describePipeline() { + return pipeline(true); + } + + /** As {@link #describePipeline()} without quantizer settings, so configs with one pipeline compare equal. */ + public String pipelineSignature() { + return pipeline(false); + } + + private String pipeline(boolean detailed) { + if (!usesSchema3()) return null; + String fused = ""; + if (construction.fusedGraph != null) { + boolean yes = construction.fusedGraph.contains(Boolean.TRUE); + boolean no = construction.fusedGraph.contains(Boolean.FALSE); + fused = yes && no ? "fused or separate " : yes ? "fused " : ""; + } + return construction.type + + " · build " + describe(construction.compression, detailed) + + " · search " + fused + describe(search.compression, detailed) + + " · rerank " + construction.rerankingType(); + } + + private static String describe(List entries, boolean detailed) { + var parts = new ArrayList(); + for (var entry : entries) { + String part = entry.type; + if (detailed && "ASH".equals(entry.type)) { + Map parameters = entry.parameters == null ? Map.of() : entry.parameters; + Object bits = parameters.get("bitsPerDimension"); + part += "-" + (bits == null ? "1" : bits) + "b"; + Object landmarks = parameters.get("landmarkCount"); + if (landmarks != null && !"1".equals(String.valueOf(landmarks))) part += " " + landmarks + " landmarks"; + } + if (detailed && entry.scoring != null) part += " " + entry.scoring; + parts.add(part); + } + return String.join(" | ", parts); + } + public void setYamlSchemaVersion(int yamlSchemaVersion) { this.yamlSchemaVersion = yamlSchemaVersion; } @@ -259,29 +318,6 @@ public void setOnDiskIndexVersion(int onDiskIndexVersion) { this.onDiskIndexVersion = onDiskIndexVersion; } - public static void printDefaultConfigUsageSummary() { - File f = DEFAULT_FILE_USED.get(); - if (f == null) return; - - var datasets = new java.util.ArrayList<>(DEFAULT_USED_FOR_DATASETS); - if (datasets.isEmpty()) return; - - // Print a wrapped bracket-list similar to "Executing the following datasets" - System.out.println("Default YAML used for datasets: " + wrapList(datasets, 6, " ")); - } - - private static String wrapList(java.util.List items, int perLine, String indent) { - StringBuilder sb = new StringBuilder(); - sb.append("["); - for (int i = 0; i < items.size(); i++) { - if (i > 0) sb.append(", "); - if (i > 0 && (i % perLine) == 0) sb.append(System.lineSeparator()).append(indent); - sb.append(items.get(i)); - } - sb.append("]"); - return sb.toString(); - } - @SuppressWarnings("unchecked") private static Map> extractLegacySearchBenchmarks(String yamlText) { try { diff --git a/jvector-examples/src/test/java/io/github/jbellis/jvector/example/benchmarks/StandaloneASHBenchmarkConfigTest.java b/jvector-examples/src/test/java/io/github/jbellis/jvector/example/benchmarks/StandaloneASHBenchmarkConfigTest.java index 9c0d5e4e9..40a65d709 100644 --- a/jvector-examples/src/test/java/io/github/jbellis/jvector/example/benchmarks/StandaloneASHBenchmarkConfigTest.java +++ b/jvector-examples/src/test/java/io/github/jbellis/jvector/example/benchmarks/StandaloneASHBenchmarkConfigTest.java @@ -31,7 +31,7 @@ /** Fair code budgets and independent selection of checked-in run/index YAML. */ public class StandaloneASHBenchmarkConfigTest { - private static final Path INDEX_CONFIGS = Path.of("jvector-examples/yaml-configs/index-parameters/ash-pq"); + private static final Path INDEX_CONFIGS = Path.of("jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq"); private static final Path RUN_CONFIGS = Path.of("jvector-examples/yaml-configs/ash-pq"); private static final Path ALL_INDEX_CONFIGS = Path.of("jvector-examples/yaml-configs/index-parameters"); @Rule public final TemporaryFolder directory = new TemporaryFolder(); @@ -199,38 +199,4 @@ public void onlyMultiJvmRecipesKeepSavedIndexes() throws Exception { } } } - - @Test - @SuppressWarnings("unchecked") - public void ashPathRecipesLoadAndMatchTheirNames() throws Exception { - List paths; - try (var files = Files.list(ALL_INDEX_CONFIGS.resolve("ash-paths"))) { - paths = files.filter(p -> p.toString().endsWith(".yml")).sorted().collect(java.util.stream.Collectors.toList()); - } - assertEquals(8, paths.size()); - for (Path path : paths) { - String name = path.getFileName().toString(); - assertNotNull(name, MultiConfig.getConfig(path.toAbsolutePath().toString())); - var root = yaml(path); - var construction = (Map) root.get("construction"); - assertEquals(name, Boolean.TRUE, ((List) construction.get("fusedGraph")).get(0)); - var build = compression(construction); - var search = compression((Map) root.get("search")); - var searchParameters = (Map) search.get("parameters"); - int bits = name.endsWith("-4bit.yml") ? 4 : 2; - assertEquals(name, bits, searchParameters.get("bitsPerDimension")); - if (name.contains("-compressed-build-")) { - assertEquals(name, "ASH", build.get("type")); - assertEquals(name, "symmetric", build.get("scoring")); - assertEquals(name, build.get("parameters"), Map.of( - "encodedBits", searchParameters.get("encodedBits"), - "optimizer", searchParameters.get("optimizer"), - "landmarkCount", searchParameters.get("landmarkCount"), - "bitsPerDimension", bits)); - } else { - assertTrue(name, name.contains("-build-flush-")); - assertEquals(name, "None", build.get("type")); - } - } - } } diff --git a/jvector-examples/src/test/java/io/github/jbellis/jvector/example/yaml/IndexParametersTest.java b/jvector-examples/src/test/java/io/github/jbellis/jvector/example/yaml/IndexParametersTest.java new file mode 100644 index 000000000..db0fb4dac --- /dev/null +++ b/jvector-examples/src/test/java/io/github/jbellis/jvector/example/yaml/IndexParametersTest.java @@ -0,0 +1,213 @@ +/* + * 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.yaml; + +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.FileNotFoundException; +import java.io.PrintStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.TreeMap; +import java.util.TreeSet; +import java.util.stream.Collectors; + +import static org.junit.Assert.*; + +/** Dataset default lookup, the checked-in index parameters, and the example pipelines. */ +public class IndexParametersTest { + private static final Path INDEX_PARAMETERS = Path.of("jvector-examples/yaml-configs/index-parameters"); + private static final String NL = System.lineSeparator(); + @Rule public final TemporaryFolder directory = new TemporaryFolder(); + + private static String config(String dataset) { + return "yamlSchemaVersion: 3\nonDiskIndexVersion: 7\ndataset: " + dataset + "\n" + + "construction:\n type: inMemory\n outDegree: [16]\n efConstruction: [50]\n" + + " neighborOverflow: [1.2f]\n addHierarchy: [No]\n refineFinalGraph: [Yes]\n" + + " compression:\n - type: None\n reranking:\n type: None\n" + + "search:\n topKOverquery:\n 10: [1.0]\n useSearchPruning: [No]\n" + + " compression:\n - type: None\n"; + } + + private File write(String name, String dataset) throws Exception { + var file = new File(directory.getRoot(), name); + Files.writeString(file.toPath(), config(dataset)); + return file; + } + + @Test + public void exactNameFindsTheDatasetDefault() throws Exception { + write("mpnet-1m.yml", "mpnet-1m"); + write("default.yml", "default"); + assertEquals("mpnet-1m", MultiConfig.getDefaultConfig(directory.getRoot(), "mpnet-1m").dataset); + } + + @Test + public void mismatchedDatasetFieldIsRejected() throws Exception { + var file = write("cap-6m.yml", "cap-1M"); + var e = assertThrows(IllegalArgumentException.class, + () -> MultiConfig.getDefaultConfig(directory.getRoot(), "cap-6m")); + assertEquals("Index config " + file.getPath() + " declares dataset: cap-1M." + NL + + "A default config's file name must match its dataset field exactly.", e.getMessage()); + } + + @Test + public void caseOnlyMatchIsRejectedOnEveryFileSystem() throws Exception { + var file = write("mpnet-1M.yml", "mpnet-1m"); + write("default.yml", "default"); + var e = assertThrows(IllegalArgumentException.class, + () -> MultiConfig.getDefaultConfig(directory.getRoot(), "mpnet-1m")); + assertEquals("No index config is named exactly mpnet-1m.yml; found" + NL + " " + file.getPath() + NL + + "File names must match the dataset name exactly, including case.", e.getMessage()); + } + + @Test + public void subdirectoriesAreNotSearchedAndMissingDefaultsWarn() throws Exception { + assertTrue(new File(directory.getRoot(), "in-memory-fp").mkdir()); + write("in-memory-fp/mpnet-5m.yml", "mpnet-5m"); + var fallback = write("default.yml", "default"); + var err = new ByteArrayOutputStream(); + var previous = System.err; + MultiConfig config; + try { + System.setErr(new PrintStream(err, true, StandardCharsets.UTF_8)); + config = MultiConfig.getDefaultConfig(directory.getRoot(), "mpnet-5m"); + } finally { + System.setErr(previous); + } + assertEquals("mpnet-5m", config.dataset); + assertEquals("WARNING: No index config named mpnet-5m.yml; using " + fallback.getPath() + "." + NL + + "Dataset defaults are named .yml in " + directory.getRoot().getPath() + "." + NL, + err.toString(StandardCharsets.UTF_8)); + } + + @Test + public void missingExplicitConfigNamesThePath() { + var e = assertThrows(FileNotFoundException.class, + () -> MultiConfig.getConfig("ash-options/no-such-example.yml")); + assertEquals("Index config not found: ash-options/no-such-example.yml", e.getMessage()); + } + + @Test + public void checkedInDefaultsAreFoundByTheirExactDatasetNames() throws Exception { + List names; + try (var files = Files.list(INDEX_PARAMETERS)) { + names = files.map(p -> p.getFileName().toString()) + .filter(n -> n.endsWith(".yml") && !n.equals("default.yml")).sorted().collect(Collectors.toList()); + } + assertTrue(names.size() > 20); + for (String name : names) { + String dataset = name.substring(0, name.length() - ".yml".length()); + assertEquals(name, dataset, MultiConfig.getDefaultConfig(INDEX_PARAMETERS.toFile(), dataset).dataset); + } + } + + private static Map allConfigs() throws Exception { + var configs = new TreeMap(); + try (var files = Files.walk(INDEX_PARAMETERS)) { + for (var path : files.filter(p -> p.toString().endsWith(".yml")).collect(Collectors.toList())) { + configs.put(INDEX_PARAMETERS.relativize(path), MultiConfig.getConfig(path.toString())); + } + } + return configs; + } + + @Test + public void everyExampleInAFolderUsesOnePipeline() throws Exception { + var byFolder = new TreeMap>>(); + for (var entry : allConfigs().entrySet()) { + Path relative = entry.getKey(); + if (relative.getNameCount() != 2 || relative.startsWith("studies")) continue; + byFolder.computeIfAbsent(relative.getName(0).toString(), k -> new TreeMap<>()) + .computeIfAbsent(entry.getValue().pipelineSignature(), k -> new ArrayList<>()).add(relative); + } + assertTrue(byFolder.size() >= 10); + for (var folder : byFolder.entrySet()) { + if (folder.getValue().size() == 1) continue; + var listing = new StringBuilder(); + folder.getValue().forEach((pipeline, files) -> + files.forEach(f -> listing.append(NL).append(" ").append(f).append(": ").append(pipeline))); + fail("Files in " + folder.getKey() + "/ select different pipelines:" + listing + NL + + "Every file in an example folder must use that folder's pipeline."); + } + } + + @Test + public void everyConfigurationOptionHasAnExample() throws Exception { + var used = new LinkedHashMap>(); + for (var config : allConfigs().values()) { + var c = config.construction; + add(used, "construction.type", c.type); + add(used, "construction.writerMode", c.writerMode); + add(used, "construction.reranking.type", c.rerankingType()); + if (c.fusedGraph != null) c.fusedGraph.forEach(v -> add(used, "construction.fusedGraph", v ? "Yes" : "No")); + for (var entry : c.compression) { + add(used, "construction.compression.type", entry.type); + add(used, "construction.compression.scoring", entry.scoring); + if (entry.parameters != null) add(used, "ASH optimizer", entry.parameters.get("optimizer")); + } + for (var entry : config.search.compression) { + add(used, "search.compression.type", entry.type); + if ("ASH".equals(entry.type) && entry.parameters != null) { + add(used, "ASH queryProjection", entry.parameters.get("queryProjection")); + add(used, "ASH queryExecution", entry.parameters.get("queryExecution")); + } + } + } + var required = new LinkedHashMap>(); + required.put("construction.type", List.of("inMemory", "onDisk")); + required.put("construction.writerMode", List.of("sequential", "parallel")); + required.put("construction.fusedGraph", List.of("Yes", "No")); + required.put("construction.compression.type", List.of("None", "ASH", "PQ", "BQ")); + required.put("construction.compression.scoring", List.of("symmetric", "asymmetric", "mixed")); + required.put("search.compression.type", List.of("None", "ASH", "PQ", "BQ")); + required.put("construction.reranking.type", List.of("None", "FP32", "NVQ")); + required.put("ASH optimizer", List.of("ITQ", "RANDOM")); + required.put("ASH queryProjection", List.of("auto", "fp32", "fp16", "int8")); + required.put("ASH queryExecution", List.of("direct", "queued")); + var missing = new ArrayList(); + required.forEach((field, values) -> values.stream() + .filter(v -> !used.getOrDefault(field, Set.of()).contains(v)) + .forEach(v -> missing.add("No index config uses " + field + ": " + v + "."))); + assertTrue(String.join(NL, missing), missing.isEmpty()); + } + + private static void add(Map> used, String field, Object value) { + if (value != null) used.computeIfAbsent(field, k -> new TreeSet<>()).add(String.valueOf(value)); + } + + @Test + public void pipelineIsInferredFromTheFields() throws Exception { + var compressed = MultiConfig.getConfig(INDEX_PARAMETERS.resolve("compressed-build-fused-ash-nvq/cap-1M-2bit.yml").toString()); + assertEquals("onDisk · build ASH-2b symmetric · search fused ASH-2b · rerank NVQ", compressed.describePipeline()); + assertEquals("onDisk · build ASH · search fused ASH · rerank NVQ", compressed.pipelineSignature()); + var memory = MultiConfig.getConfig(INDEX_PARAMETERS.resolve("in-memory-ash/mpnet-1m.yml").toString()); + assertEquals("inMemory · build None · search ASH-2b · rerank FP32", memory.describePipeline()); + var landmarks = MultiConfig.getConfig(INDEX_PARAMETERS.resolve("ash-options/ada002-1M-4-landmarks.yml").toString()); + assertEquals("onDisk · build ASH-2b 4 landmarks asymmetric · search fused ASH-2b 4 landmarks · rerank NVQ", + landmarks.describePipeline()); + } +} diff --git a/jvector-examples/yaml-configs/dataset-metadata.yml b/jvector-examples/yaml-configs/dataset-metadata.yml index dc82ef39e..99f883546 100644 --- a/jvector-examples/yaml-configs/dataset-metadata.yml +++ b/jvector-examples/yaml-configs/dataset-metadata.yml @@ -110,6 +110,8 @@ tahoe-x1-1m: similarity_function: DOT_PRODUCT load_behavior: NO_SCRUB personahub-1m: + similarity_function: DOT_PRODUCT + load_behavior: NO_SCRUB openai-3072-1m: similarity_function: DOT_PRODUCT load_behavior: NO_SCRUB diff --git a/jvector-examples/yaml-configs/index-parameters/README.md b/jvector-examples/yaml-configs/index-parameters/README.md new file mode 100644 index 000000000..1bc15a04a --- /dev/null +++ b/jvector-examples/yaml-configs/index-parameters/README.md @@ -0,0 +1,34 @@ +# Index parameters + +## Dataset defaults + +Each `.yml` in this directory is that dataset's default. Running BenchYAML with a dataset +name loads `index-parameters/.yml`. The file name must match the dataset name exactly, +including case, and the file must declare that dataset. A dataset without its own file uses +`default.yml`. Subdirectories are never searched for defaults. + +```bash +java -cp "$BENCH_JAR" io.github.jbellis.jvector.example.BenchYAML mpnet-1m +``` + +## Examples + +Each subdirectory holds complete configurations for one pipeline. Run them by path. BenchYAML +prints the pipeline each configuration selects before it runs. + +| Folder | Pipeline | Use case | +| --- | --- | --- | +| [in-memory-fp](in-memory-fp) | inMemory · build None · search None · rerank None | Small or frequently queried collections that fit in memory, and lower-dimensional data where quantization saves little. | +| [in-memory-ash](in-memory-ash) | inMemory · build None · search ASH · rerank FP32 | Larger in-memory collections that need faster traversal while keeping exact final scores. | +| [in-memory-bq](in-memory-bq) | inMemory · build BQ · search BQ · rerank FP32 | Very high-dimensional embeddings, where sign codes keep construction and traversal cheap. | +| [flush-fused-ash-nvq](flush-fused-ash-nvq) | onDisk · build None · search fused ASH · rerank NVQ | The database write path: an in-memory index (for example a memtable) is flushed to an on-disk index. | +| [compressed-build-fused-ash-nvq](compressed-build-fused-ash-nvq) | onDisk · build ASH symmetric · search fused ASH · rerank NVQ | Ingest under a memory limit: construction scores compact codes instead of source vectors. | +| [flush-fused-pq-nvq](flush-fused-pq-nvq) | onDisk · build None · search fused PQ · rerank NVQ | The same write path as flush-fused-ash-nvq with PQ codes, for comparison and compatibility. | +| [on-disk-ash-fp32](on-disk-ash-fp32) | onDisk · build ASH · search ASH · rerank FP32 | Indexes too large for memory, where compact codes stay resident and full vectors are read only for rescoring. | +| [on-disk-pq-fp32](on-disk-pq-fp32) | onDisk · build PQ · search PQ · rerank FP32 | The same layout as on-disk-ash-fp32 with PQ codes. | +| [on-disk-fp](on-disk-fp) | onDisk · build None · search None · rerank None | Lower-dimensional data stored on disk, and tuning graph degree, build width, hierarchy and pruning. | +| [ash-options](ash-options) | onDisk · build ASH · search fused ASH · rerank NVQ | Choosing ASH settings for a deployment. Each file changes one option from the folder's shared setup. | +| [studies](studies) | Varies | Published comparisons, run with their own scripts | + +A pipeline combines the index type, construction scoring, fused codes, search scoring and +reranking. See [the BenchYAML guide](../../../docs/benchyaml-guide.md) for every field. diff --git a/jvector-examples/yaml-configs/index-parameters/ash-options/README.md b/jvector-examples/yaml-configs/index-parameters/ash-options/README.md new file mode 100644 index 000000000..f9b14b29d --- /dev/null +++ b/jvector-examples/yaml-configs/index-parameters/ash-options/README.md @@ -0,0 +1,15 @@ +# ASH options + +Fused ASH graphs with NVQ reranking, each changing one ASH option: construction scoring, landmarks, optimizer, query projection or query execution. + +**Use case:** Choosing ASH settings for a deployment. Each file changes one option from the folder's shared setup. + +**Pipeline:** onDisk · build ASH · search fused ASH · rerank NVQ + +Files are named for their dataset. Each sets `dataset:`; change it to run the same pipeline on another compatible dataset. ASH requires dot-product datasets. + +Run one file, or every file in the folder, by passing paths to BenchYAML from the repository root: + +```bash +java -cp "$BENCH_JAR" io.github.jbellis.jvector.example.BenchYAML jvector-examples/yaml-configs/index-parameters/ash-options/*.yml +``` diff --git a/jvector-examples/yaml-configs/index-parameters/ash-options/ada002-1M-4-landmarks.yml b/jvector-examples/yaml-configs/index-parameters/ash-options/ada002-1M-4-landmarks.yml new file mode 100644 index 000000000..2960c23e5 --- /dev/null +++ b/jvector-examples/yaml-configs/index-parameters/ash-options/ada002-1M-4-landmarks.yml @@ -0,0 +1,39 @@ +# ada002-1M: fused 2-bit ASH graph with NVQ reranking. This example shows how to +# use four ASH landmarks. Symmetric and mixed construction +# require one landmark, so the build uses asymmetric scoring. +yamlSchemaVersion: 3 +onDiskIndexVersion: 7 + +dataset: ada002-1M + +construction: + type: onDisk + outDegree: [32] + efConstruction: [100] + neighborOverflow: [1.2f] + addHierarchy: [Yes] + refineFinalGraph: [Yes] + fusedGraph: [Yes] + compression: + - type: ASH + scoring: asymmetric + parameters: + compressionRatio: 32 + optimizer: ITQ + landmarkCount: 4 + bitsPerDimension: 2 + reranking: + type: NVQ + useSavedIndexIfExists: No + +search: + topKOverquery: + 10: [1.0, 2.0] + useSearchPruning: [No] + compression: + - type: ASH + parameters: + compressionRatio: 32 + optimizer: ITQ + landmarkCount: 4 + bitsPerDimension: 2 diff --git a/jvector-examples/yaml-configs/index-parameters/ash-options/cap-1M-direct-execution.yml b/jvector-examples/yaml-configs/index-parameters/ash-options/cap-1M-direct-execution.yml new file mode 100644 index 000000000..6b2b773dd --- /dev/null +++ b/jvector-examples/yaml-configs/index-parameters/ash-options/cap-1M-direct-execution.yml @@ -0,0 +1,38 @@ +# cap-1M: fused 2-bit ASH graph with NVQ reranking. This example shows how to +# run each search on the calling thread. +yamlSchemaVersion: 3 +onDiskIndexVersion: 7 + +dataset: cap-1M + +construction: + type: onDisk + outDegree: [32] + efConstruction: [100] + neighborOverflow: [1.2f] + addHierarchy: [Yes] + refineFinalGraph: [Yes] + fusedGraph: [Yes] + compression: + - type: ASH + parameters: + compressionRatio: 32 + optimizer: ITQ + landmarkCount: 1 + bitsPerDimension: 2 + reranking: + type: NVQ + useSavedIndexIfExists: No + +search: + topKOverquery: + 10: [1.0, 2.0] + useSearchPruning: [No] + compression: + - type: ASH + parameters: + compressionRatio: 32 + optimizer: ITQ + landmarkCount: 1 + bitsPerDimension: 2 + queryExecution: direct diff --git a/jvector-examples/yaml-configs/index-parameters/ash-options/cap-1M-fp32-projection.yml b/jvector-examples/yaml-configs/index-parameters/ash-options/cap-1M-fp32-projection.yml new file mode 100644 index 000000000..e55563814 --- /dev/null +++ b/jvector-examples/yaml-configs/index-parameters/ash-options/cap-1M-fp32-projection.yml @@ -0,0 +1,38 @@ +# cap-1M: fused 2-bit ASH graph with NVQ reranking. This example shows how to +# project queries with FP32 arithmetic. +yamlSchemaVersion: 3 +onDiskIndexVersion: 7 + +dataset: cap-1M + +construction: + type: onDisk + outDegree: [32] + efConstruction: [100] + neighborOverflow: [1.2f] + addHierarchy: [Yes] + refineFinalGraph: [Yes] + fusedGraph: [Yes] + compression: + - type: ASH + parameters: + compressionRatio: 32 + optimizer: ITQ + landmarkCount: 1 + bitsPerDimension: 2 + reranking: + type: NVQ + useSavedIndexIfExists: No + +search: + topKOverquery: + 10: [1.0, 2.0] + useSearchPruning: [No] + compression: + - type: ASH + parameters: + compressionRatio: 32 + optimizer: ITQ + landmarkCount: 1 + bitsPerDimension: 2 + queryProjection: fp32 diff --git a/jvector-examples/yaml-configs/index-parameters/ash-options/cohere-english-v3-1M-mixed-build.yml b/jvector-examples/yaml-configs/index-parameters/ash-options/cohere-english-v3-1M-mixed-build.yml new file mode 100644 index 000000000..18d6ce3d1 --- /dev/null +++ b/jvector-examples/yaml-configs/index-parameters/ash-options/cohere-english-v3-1M-mixed-build.yml @@ -0,0 +1,38 @@ +# cohere-english-v3-1M: fused 2-bit ASH graph with NVQ reranking. This example shows how to +# build with mixed ASH scoring: asymmetric insertion, symmetric pruning. +yamlSchemaVersion: 3 +onDiskIndexVersion: 7 + +dataset: cohere-english-v3-1M + +construction: + type: onDisk + outDegree: [32] + efConstruction: [100] + neighborOverflow: [1.2f] + addHierarchy: [Yes] + refineFinalGraph: [Yes] + fusedGraph: [Yes] + compression: + - type: ASH + scoring: mixed + parameters: + compressionRatio: 32 + optimizer: ITQ + landmarkCount: 1 + bitsPerDimension: 2 + reranking: + type: NVQ + useSavedIndexIfExists: No + +search: + topKOverquery: + 10: [1.0, 2.0] + useSearchPruning: [No] + compression: + - type: ASH + parameters: + compressionRatio: 32 + optimizer: ITQ + landmarkCount: 1 + bitsPerDimension: 2 diff --git a/jvector-examples/yaml-configs/index-parameters/ash-options/cohere-english-v3-1M-queued-execution.yml b/jvector-examples/yaml-configs/index-parameters/ash-options/cohere-english-v3-1M-queued-execution.yml new file mode 100644 index 000000000..2ebe71a80 --- /dev/null +++ b/jvector-examples/yaml-configs/index-parameters/ash-options/cohere-english-v3-1M-queued-execution.yml @@ -0,0 +1,39 @@ +# cohere-english-v3-1M: fused 2-bit ASH graph with NVQ reranking. This example shows how to +# submit searches to workers that prepare up to eight queries together. +yamlSchemaVersion: 3 +onDiskIndexVersion: 7 + +dataset: cohere-english-v3-1M + +construction: + type: onDisk + outDegree: [32] + efConstruction: [100] + neighborOverflow: [1.2f] + addHierarchy: [Yes] + refineFinalGraph: [Yes] + fusedGraph: [Yes] + compression: + - type: ASH + parameters: + compressionRatio: 32 + optimizer: ITQ + landmarkCount: 1 + bitsPerDimension: 2 + reranking: + type: NVQ + useSavedIndexIfExists: No + +search: + topKOverquery: + 10: [1.0, 2.0] + useSearchPruning: [No] + compression: + - type: ASH + parameters: + compressionRatio: 32 + optimizer: ITQ + landmarkCount: 1 + bitsPerDimension: 2 + queryExecution: queued + maxBatchSize: 8 diff --git a/jvector-examples/yaml-configs/index-parameters/ash-options/gecko-100k-random-optimizer.yml b/jvector-examples/yaml-configs/index-parameters/ash-options/gecko-100k-random-optimizer.yml new file mode 100644 index 000000000..ac363e7bd --- /dev/null +++ b/jvector-examples/yaml-configs/index-parameters/ash-options/gecko-100k-random-optimizer.yml @@ -0,0 +1,37 @@ +# gecko-100k: fused 2-bit ASH graph with NVQ reranking. This example shows how to +# train ASH with a random rotation instead of ITQ. +yamlSchemaVersion: 3 +onDiskIndexVersion: 7 + +dataset: gecko-100k + +construction: + type: onDisk + outDegree: [32] + efConstruction: [100] + neighborOverflow: [1.2f] + addHierarchy: [Yes] + refineFinalGraph: [Yes] + fusedGraph: [Yes] + compression: + - type: ASH + parameters: + compressionRatio: 32 + optimizer: RANDOM + landmarkCount: 1 + bitsPerDimension: 2 + reranking: + type: NVQ + useSavedIndexIfExists: No + +search: + topKOverquery: + 10: [1.0, 2.0] + useSearchPruning: [No] + compression: + - type: ASH + parameters: + compressionRatio: 32 + optimizer: RANDOM + landmarkCount: 1 + bitsPerDimension: 2 diff --git a/jvector-examples/yaml-configs/index-parameters/ash-options/mpnet-1m-asymmetric-build.yml b/jvector-examples/yaml-configs/index-parameters/ash-options/mpnet-1m-asymmetric-build.yml new file mode 100644 index 000000000..1a667ecbd --- /dev/null +++ b/jvector-examples/yaml-configs/index-parameters/ash-options/mpnet-1m-asymmetric-build.yml @@ -0,0 +1,39 @@ +# mpnet-1m: fused 2-bit ASH graph with NVQ reranking. This example shows how to +# build with asymmetric ASH scoring: full-precision insertion +# queries against stored codes for insertion and pruning. +yamlSchemaVersion: 3 +onDiskIndexVersion: 7 + +dataset: mpnet-1m + +construction: + type: onDisk + outDegree: [32] + efConstruction: [100] + neighborOverflow: [1.2f] + addHierarchy: [Yes] + refineFinalGraph: [Yes] + fusedGraph: [Yes] + compression: + - type: ASH + scoring: asymmetric + parameters: + compressionRatio: 32 + optimizer: ITQ + landmarkCount: 1 + bitsPerDimension: 2 + reranking: + type: NVQ + useSavedIndexIfExists: No + +search: + topKOverquery: + 10: [1.0, 2.0] + useSearchPruning: [No] + compression: + - type: ASH + parameters: + compressionRatio: 32 + optimizer: ITQ + landmarkCount: 1 + bitsPerDimension: 2 diff --git a/jvector-examples/yaml-configs/index-parameters/ash-options/mpnet-1m-int8-projection.yml b/jvector-examples/yaml-configs/index-parameters/ash-options/mpnet-1m-int8-projection.yml new file mode 100644 index 000000000..7caf7660c --- /dev/null +++ b/jvector-examples/yaml-configs/index-parameters/ash-options/mpnet-1m-int8-projection.yml @@ -0,0 +1,39 @@ +# mpnet-1m: fused 2-bit ASH graph with NVQ reranking. This example shows how to +# project queries with INT8 arithmetic. Requires a +# provider and CPU that support it; the run fails validation otherwise. +yamlSchemaVersion: 3 +onDiskIndexVersion: 7 + +dataset: mpnet-1m + +construction: + type: onDisk + outDegree: [32] + efConstruction: [100] + neighborOverflow: [1.2f] + addHierarchy: [Yes] + refineFinalGraph: [Yes] + fusedGraph: [Yes] + compression: + - type: ASH + parameters: + compressionRatio: 32 + optimizer: ITQ + landmarkCount: 1 + bitsPerDimension: 2 + reranking: + type: NVQ + useSavedIndexIfExists: No + +search: + topKOverquery: + 10: [1.0, 2.0] + useSearchPruning: [No] + compression: + - type: ASH + parameters: + compressionRatio: 32 + optimizer: ITQ + landmarkCount: 1 + bitsPerDimension: 2 + queryProjection: int8 diff --git a/jvector-examples/yaml-configs/index-parameters/compressed-build-fused-ash-nvq/README.md b/jvector-examples/yaml-configs/index-parameters/compressed-build-fused-ash-nvq/README.md new file mode 100644 index 000000000..bdb4478ee --- /dev/null +++ b/jvector-examples/yaml-configs/index-parameters/compressed-build-fused-ash-nvq/README.md @@ -0,0 +1,15 @@ +# Build with ASH codes, flush a fused ASH graph + +Builds the graph with symmetric ASH scoring, then writes fused ASH traversal codes and NVQ reranking vectors in parallel. + +**Use case:** Ingest under a memory limit: construction scores compact codes instead of source vectors. + +**Pipeline:** onDisk · build ASH symmetric · search fused ASH · rerank NVQ + +Files are named for their dataset. Each sets `dataset:`; change it to run the same pipeline on another compatible dataset. ASH requires dot-product datasets. + +Run one file, or every file in the folder, by passing paths to BenchYAML from the repository root: + +```bash +java -cp "$BENCH_JAR" io.github.jbellis.jvector.example.BenchYAML jvector-examples/yaml-configs/index-parameters/compressed-build-fused-ash-nvq/*.yml +``` diff --git a/jvector-examples/yaml-configs/index-parameters/ash-paths/cap-1m-compressed-build-fused-ash-2bit.yml b/jvector-examples/yaml-configs/index-parameters/compressed-build-fused-ash-nvq/cap-1M-2bit.yml similarity index 100% rename from jvector-examples/yaml-configs/index-parameters/ash-paths/cap-1m-compressed-build-fused-ash-2bit.yml rename to jvector-examples/yaml-configs/index-parameters/compressed-build-fused-ash-nvq/cap-1M-2bit.yml diff --git a/jvector-examples/yaml-configs/index-parameters/ash-paths/cap-1m-compressed-build-fused-ash-4bit.yml b/jvector-examples/yaml-configs/index-parameters/compressed-build-fused-ash-nvq/cap-1M-4bit.yml similarity index 100% rename from jvector-examples/yaml-configs/index-parameters/ash-paths/cap-1m-compressed-build-fused-ash-4bit.yml rename to jvector-examples/yaml-configs/index-parameters/compressed-build-fused-ash-nvq/cap-1M-4bit.yml diff --git a/jvector-examples/yaml-configs/index-parameters/ash-paths/mpnet-10m-compressed-build-fused-ash-2bit.yml b/jvector-examples/yaml-configs/index-parameters/compressed-build-fused-ash-nvq/mpnet-10m-2bit.yml similarity index 100% rename from jvector-examples/yaml-configs/index-parameters/ash-paths/mpnet-10m-compressed-build-fused-ash-2bit.yml rename to jvector-examples/yaml-configs/index-parameters/compressed-build-fused-ash-nvq/mpnet-10m-2bit.yml diff --git a/jvector-examples/yaml-configs/index-parameters/ash-paths/mpnet-10m-compressed-build-fused-ash-4bit.yml b/jvector-examples/yaml-configs/index-parameters/compressed-build-fused-ash-nvq/mpnet-10m-4bit.yml similarity index 100% rename from jvector-examples/yaml-configs/index-parameters/ash-paths/mpnet-10m-compressed-build-fused-ash-4bit.yml rename to jvector-examples/yaml-configs/index-parameters/compressed-build-fused-ash-nvq/mpnet-10m-4bit.yml diff --git a/jvector-examples/yaml-configs/index-parameters/dpr-gemma-10M.yml b/jvector-examples/yaml-configs/index-parameters/dpr-gemma-10m.yml similarity index 100% rename from jvector-examples/yaml-configs/index-parameters/dpr-gemma-10M.yml rename to jvector-examples/yaml-configs/index-parameters/dpr-gemma-10m.yml diff --git a/jvector-examples/yaml-configs/index-parameters/dpr-gemma-1M.yml b/jvector-examples/yaml-configs/index-parameters/dpr-gemma-1m.yml similarity index 100% rename from jvector-examples/yaml-configs/index-parameters/dpr-gemma-1M.yml rename to jvector-examples/yaml-configs/index-parameters/dpr-gemma-1m.yml diff --git a/jvector-examples/yaml-configs/index-parameters/flush-fused-ash-nvq/README.md b/jvector-examples/yaml-configs/index-parameters/flush-fused-ash-nvq/README.md new file mode 100644 index 000000000..1db072ed5 --- /dev/null +++ b/jvector-examples/yaml-configs/index-parameters/flush-fused-ash-nvq/README.md @@ -0,0 +1,15 @@ +# Build in memory, flush a fused ASH graph + +Builds the graph with the source vectors, then writes fused ASH traversal codes and NVQ reranking vectors to disk. + +**Use case:** The database write path: an in-memory index (for example a memtable) is flushed to an on-disk index. + +**Pipeline:** onDisk · build None · search fused ASH · rerank NVQ + +Files are named for their dataset. Each sets `dataset:`; change it to run the same pipeline on another compatible dataset. ASH requires dot-product datasets. + +Run one file, or every file in the folder, by passing paths to BenchYAML from the repository root: + +```bash +java -cp "$BENCH_JAR" io.github.jbellis.jvector.example.BenchYAML jvector-examples/yaml-configs/index-parameters/flush-fused-ash-nvq/*.yml +``` diff --git a/jvector-examples/yaml-configs/index-parameters/ash-paths/cap-1m-build-flush-fused-ash-2bit.yml b/jvector-examples/yaml-configs/index-parameters/flush-fused-ash-nvq/cap-1M-2bit.yml similarity index 100% rename from jvector-examples/yaml-configs/index-parameters/ash-paths/cap-1m-build-flush-fused-ash-2bit.yml rename to jvector-examples/yaml-configs/index-parameters/flush-fused-ash-nvq/cap-1M-2bit.yml diff --git a/jvector-examples/yaml-configs/index-parameters/ash-paths/cap-1m-build-flush-fused-ash-4bit.yml b/jvector-examples/yaml-configs/index-parameters/flush-fused-ash-nvq/cap-1M-4bit.yml similarity index 100% rename from jvector-examples/yaml-configs/index-parameters/ash-paths/cap-1m-build-flush-fused-ash-4bit.yml rename to jvector-examples/yaml-configs/index-parameters/flush-fused-ash-nvq/cap-1M-4bit.yml diff --git a/jvector-examples/yaml-configs/index-parameters/ash-paths/mpnet-10m-build-flush-fused-ash-2bit.yml b/jvector-examples/yaml-configs/index-parameters/flush-fused-ash-nvq/mpnet-10m-2bit.yml similarity index 100% rename from jvector-examples/yaml-configs/index-parameters/ash-paths/mpnet-10m-build-flush-fused-ash-2bit.yml rename to jvector-examples/yaml-configs/index-parameters/flush-fused-ash-nvq/mpnet-10m-2bit.yml diff --git a/jvector-examples/yaml-configs/index-parameters/ash-paths/mpnet-10m-build-flush-fused-ash-4bit.yml b/jvector-examples/yaml-configs/index-parameters/flush-fused-ash-nvq/mpnet-10m-4bit.yml similarity index 100% rename from jvector-examples/yaml-configs/index-parameters/ash-paths/mpnet-10m-build-flush-fused-ash-4bit.yml rename to jvector-examples/yaml-configs/index-parameters/flush-fused-ash-nvq/mpnet-10m-4bit.yml diff --git a/jvector-examples/yaml-configs/index-parameters/flush-fused-pq-nvq/README.md b/jvector-examples/yaml-configs/index-parameters/flush-fused-pq-nvq/README.md new file mode 100644 index 000000000..179da499b --- /dev/null +++ b/jvector-examples/yaml-configs/index-parameters/flush-fused-pq-nvq/README.md @@ -0,0 +1,15 @@ +# Build in memory, flush a fused PQ graph + +Builds the graph with the source vectors, then writes fused PQ traversal codes and NVQ reranking vectors to disk. + +**Use case:** The same write path as flush-fused-ash-nvq with PQ codes, for comparison and compatibility. + +**Pipeline:** onDisk · build None · search fused PQ · rerank NVQ + +Files are named for their dataset. Each sets `dataset:`; change it to run the same pipeline on another compatible dataset. + +Run one file, or every file in the folder, by passing paths to BenchYAML from the repository root: + +```bash +java -cp "$BENCH_JAR" io.github.jbellis.jvector.example.BenchYAML jvector-examples/yaml-configs/index-parameters/flush-fused-pq-nvq/*.yml +``` diff --git a/jvector-examples/yaml-configs/index-parameters/flush-fused-pq-nvq/cohere-english-v3-1M.yml b/jvector-examples/yaml-configs/index-parameters/flush-fused-pq-nvq/cohere-english-v3-1M.yml new file mode 100644 index 000000000..6ac0190d2 --- /dev/null +++ b/jvector-examples/yaml-configs/index-parameters/flush-fused-pq-nvq/cohere-english-v3-1M.yml @@ -0,0 +1,31 @@ +# cohere-english-v3-1M (1024 dimensions): build the graph with full-precision vectors, then write fused PQ +# traversal codes (D/8 subspaces, 256 centroids) and NVQ reranking vectors to disk. +yamlSchemaVersion: 3 +onDiskIndexVersion: 7 + +dataset: cohere-english-v3-1M + +construction: + type: onDisk + outDegree: [32] + efConstruction: [100] + neighborOverflow: [1.2f] + addHierarchy: [Yes] + refineFinalGraph: [Yes] + fusedGraph: [Yes] + compression: + - type: None + reranking: + type: NVQ + useSavedIndexIfExists: No + +search: + topKOverquery: + 10: [1.0, 2.0] + useSearchPruning: [No] + compression: + - type: PQ + parameters: + mFactor: 8 + k: 256 + anisotropicThreshold: -1.0 diff --git a/jvector-examples/yaml-configs/index-parameters/flush-fused-pq-nvq/mpnet-1m.yml b/jvector-examples/yaml-configs/index-parameters/flush-fused-pq-nvq/mpnet-1m.yml new file mode 100644 index 000000000..3d1b01223 --- /dev/null +++ b/jvector-examples/yaml-configs/index-parameters/flush-fused-pq-nvq/mpnet-1m.yml @@ -0,0 +1,31 @@ +# mpnet-1m (768 dimensions): build the graph with full-precision vectors, then write fused PQ +# traversal codes (D/8 subspaces, 256 centroids) and NVQ reranking vectors to disk. +yamlSchemaVersion: 3 +onDiskIndexVersion: 7 + +dataset: mpnet-1m + +construction: + type: onDisk + outDegree: [32] + efConstruction: [100] + neighborOverflow: [1.2f] + addHierarchy: [Yes] + refineFinalGraph: [Yes] + fusedGraph: [Yes] + compression: + - type: None + reranking: + type: NVQ + useSavedIndexIfExists: No + +search: + topKOverquery: + 10: [1.0, 2.0] + useSearchPruning: [No] + compression: + - type: PQ + parameters: + mFactor: 8 + k: 256 + anisotropicThreshold: -1.0 diff --git a/jvector-examples/yaml-configs/index-parameters/in-memory-ash/README.md b/jvector-examples/yaml-configs/index-parameters/in-memory-ash/README.md new file mode 100644 index 000000000..29e2cd8a7 --- /dev/null +++ b/jvector-examples/yaml-configs/index-parameters/in-memory-ash/README.md @@ -0,0 +1,15 @@ +# In-memory graph with ASH search + +Builds an in-memory graph with the source vectors. Searches traverse with ASH codes and rescore the candidates with the source vectors. + +**Use case:** Larger in-memory collections that need faster traversal while keeping exact final scores. + +**Pipeline:** inMemory · build None · search ASH · rerank FP32 + +Files are named for their dataset. Each sets `dataset:`; change it to run the same pipeline on another compatible dataset. ASH requires dot-product datasets. + +Run one file, or every file in the folder, by passing paths to BenchYAML from the repository root: + +```bash +java -cp "$BENCH_JAR" io.github.jbellis.jvector.example.BenchYAML jvector-examples/yaml-configs/index-parameters/in-memory-ash/*.yml +``` diff --git a/jvector-examples/yaml-configs/index-parameters/in-memory-ash/ada002-1M.yml b/jvector-examples/yaml-configs/index-parameters/in-memory-ash/ada002-1M.yml new file mode 100644 index 000000000..e1ca75496 --- /dev/null +++ b/jvector-examples/yaml-configs/index-parameters/in-memory-ash/ada002-1M.yml @@ -0,0 +1,30 @@ +# ada002-1M (1536 dimensions): in-memory graph built with full-precision vectors. +# Searches traverse with 2-bit ASH codes (nominal 32x) and rescore with full-precision vectors. +yamlSchemaVersion: 3 +onDiskIndexVersion: 7 + +dataset: ada002-1M + +construction: + type: inMemory + outDegree: [32] + efConstruction: [100] + neighborOverflow: [1.2f] + addHierarchy: [Yes] + refineFinalGraph: [Yes] + compression: + - type: None + reranking: + type: FP32 + +search: + topKOverquery: + 10: [1.0, 2.0, 4.0] + useSearchPruning: [No] + compression: + - type: ASH + parameters: + compressionRatio: 32 + optimizer: ITQ + landmarkCount: 1 + bitsPerDimension: 2 diff --git a/jvector-examples/yaml-configs/index-parameters/in-memory-ash/cohere-english-v3-1M.yml b/jvector-examples/yaml-configs/index-parameters/in-memory-ash/cohere-english-v3-1M.yml new file mode 100644 index 000000000..141651cb8 --- /dev/null +++ b/jvector-examples/yaml-configs/index-parameters/in-memory-ash/cohere-english-v3-1M.yml @@ -0,0 +1,30 @@ +# cohere-english-v3-1M (1024 dimensions): in-memory graph built with full-precision vectors. +# Searches traverse with 2-bit ASH codes (nominal 32x) and rescore with full-precision vectors. +yamlSchemaVersion: 3 +onDiskIndexVersion: 7 + +dataset: cohere-english-v3-1M + +construction: + type: inMemory + outDegree: [32] + efConstruction: [100] + neighborOverflow: [1.2f] + addHierarchy: [Yes] + refineFinalGraph: [Yes] + compression: + - type: None + reranking: + type: FP32 + +search: + topKOverquery: + 10: [1.0, 2.0, 4.0] + useSearchPruning: [No] + compression: + - type: ASH + parameters: + compressionRatio: 32 + optimizer: ITQ + landmarkCount: 1 + bitsPerDimension: 2 diff --git a/jvector-examples/yaml-configs/index-parameters/in-memory-ash/mpnet-1m.yml b/jvector-examples/yaml-configs/index-parameters/in-memory-ash/mpnet-1m.yml new file mode 100644 index 000000000..73e76e919 --- /dev/null +++ b/jvector-examples/yaml-configs/index-parameters/in-memory-ash/mpnet-1m.yml @@ -0,0 +1,30 @@ +# mpnet-1m (768 dimensions): in-memory graph built with full-precision vectors. +# Searches traverse with 2-bit ASH codes (nominal 32x) and rescore with full-precision vectors. +yamlSchemaVersion: 3 +onDiskIndexVersion: 7 + +dataset: mpnet-1m + +construction: + type: inMemory + outDegree: [32] + efConstruction: [100] + neighborOverflow: [1.2f] + addHierarchy: [Yes] + refineFinalGraph: [Yes] + compression: + - type: None + reranking: + type: FP32 + +search: + topKOverquery: + 10: [1.0, 2.0, 4.0] + useSearchPruning: [No] + compression: + - type: ASH + parameters: + compressionRatio: 32 + optimizer: ITQ + landmarkCount: 1 + bitsPerDimension: 2 diff --git a/jvector-examples/yaml-configs/index-parameters/in-memory-bq/README.md b/jvector-examples/yaml-configs/index-parameters/in-memory-bq/README.md new file mode 100644 index 000000000..101a018ca --- /dev/null +++ b/jvector-examples/yaml-configs/index-parameters/in-memory-bq/README.md @@ -0,0 +1,15 @@ +# In-memory graph built and searched with BQ + +Builds and searches an in-memory graph with 1-bit BQ codes, then rescores with the source vectors. + +**Use case:** Very high-dimensional embeddings, where sign codes keep construction and traversal cheap. + +**Pipeline:** inMemory · build BQ · search BQ · rerank FP32 + +Files are named for their dataset. Each sets `dataset:`; change it to run the same pipeline on another compatible dataset. + +Run one file, or every file in the folder, by passing paths to BenchYAML from the repository root: + +```bash +java -cp "$BENCH_JAR" io.github.jbellis.jvector.example.BenchYAML jvector-examples/yaml-configs/index-parameters/in-memory-bq/*.yml +``` diff --git a/jvector-examples/yaml-configs/index-parameters/in-memory-bq/openai-3072-1m.yml b/jvector-examples/yaml-configs/index-parameters/in-memory-bq/openai-3072-1m.yml new file mode 100644 index 000000000..2a6bd7b20 --- /dev/null +++ b/jvector-examples/yaml-configs/index-parameters/in-memory-bq/openai-3072-1m.yml @@ -0,0 +1,25 @@ +# openai-3072-1m (3072 dimensions): in-memory graph built and searched with 1-bit BQ codes, +# then rescored with full-precision vectors. BQ suits very high-dimensional embeddings. +yamlSchemaVersion: 3 +onDiskIndexVersion: 7 + +dataset: openai-3072-1m + +construction: + type: inMemory + outDegree: [32] + efConstruction: [100] + neighborOverflow: [1.2f] + addHierarchy: [Yes] + refineFinalGraph: [Yes] + compression: + - type: BQ + reranking: + type: FP32 + +search: + topKOverquery: + 10: [2.0, 4.0, 8.0] + useSearchPruning: [No] + compression: + - type: BQ diff --git a/jvector-examples/yaml-configs/index-parameters/in-memory-bq/tahoe-x1-1m.yml b/jvector-examples/yaml-configs/index-parameters/in-memory-bq/tahoe-x1-1m.yml new file mode 100644 index 000000000..5f2da82a8 --- /dev/null +++ b/jvector-examples/yaml-configs/index-parameters/in-memory-bq/tahoe-x1-1m.yml @@ -0,0 +1,25 @@ +# tahoe-x1-1m (2560 dimensions): in-memory graph built and searched with 1-bit BQ codes, +# then rescored with full-precision vectors. BQ suits very high-dimensional embeddings. +yamlSchemaVersion: 3 +onDiskIndexVersion: 7 + +dataset: tahoe-x1-1m + +construction: + type: inMemory + outDegree: [32] + efConstruction: [100] + neighborOverflow: [1.2f] + addHierarchy: [Yes] + refineFinalGraph: [Yes] + compression: + - type: BQ + reranking: + type: FP32 + +search: + topKOverquery: + 10: [2.0, 4.0, 8.0] + useSearchPruning: [No] + compression: + - type: BQ diff --git a/jvector-examples/yaml-configs/index-parameters/in-memory-fp/README.md b/jvector-examples/yaml-configs/index-parameters/in-memory-fp/README.md new file mode 100644 index 000000000..4393c83c9 --- /dev/null +++ b/jvector-examples/yaml-configs/index-parameters/in-memory-fp/README.md @@ -0,0 +1,15 @@ +# In-memory graph, full-precision vectors + +Builds and searches a graph held entirely in memory with the source vectors. Nothing is written to disk and nothing is quantized. + +**Use case:** Small or frequently queried collections that fit in memory, and lower-dimensional data where quantization saves little. + +**Pipeline:** inMemory · build None · search None · rerank None + +Files are named for their dataset. Each sets `dataset:`; change it to run the same pipeline on another compatible dataset. + +Run one file, or every file in the folder, by passing paths to BenchYAML from the repository root: + +```bash +java -cp "$BENCH_JAR" io.github.jbellis.jvector.example.BenchYAML jvector-examples/yaml-configs/index-parameters/in-memory-fp/*.yml +``` diff --git a/jvector-examples/yaml-configs/index-parameters/in-memory-fp/gecko-100k.yml b/jvector-examples/yaml-configs/index-parameters/in-memory-fp/gecko-100k.yml new file mode 100644 index 000000000..6647cf594 --- /dev/null +++ b/jvector-examples/yaml-configs/index-parameters/in-memory-fp/gecko-100k.yml @@ -0,0 +1,26 @@ +# gecko-100k (768 dimensions, 100k vectors): in-memory graph built and searched with full-precision vectors. +# Nothing is written to disk. +yamlSchemaVersion: 3 +onDiskIndexVersion: 7 + +dataset: gecko-100k + +construction: + type: inMemory + outDegree: [32] + efConstruction: [100] + neighborOverflow: [1.2f] + addHierarchy: [Yes] + refineFinalGraph: [Yes] + compression: + - type: None + reranking: + type: None + +search: + topKOverquery: + 10: [1.0, 2.0] + 100: [1.0, 2.0] + useSearchPruning: [No] + compression: + - type: None diff --git a/jvector-examples/yaml-configs/index-parameters/in-memory-fp/glove-100-angular.yml b/jvector-examples/yaml-configs/index-parameters/in-memory-fp/glove-100-angular.yml new file mode 100644 index 000000000..a38ef9722 --- /dev/null +++ b/jvector-examples/yaml-configs/index-parameters/in-memory-fp/glove-100-angular.yml @@ -0,0 +1,26 @@ +# glove-100-angular (100 dimensions, angular similarity): in-memory graph built and searched with full-precision vectors. +# Nothing is written to disk. +yamlSchemaVersion: 3 +onDiskIndexVersion: 7 + +dataset: glove-100-angular + +construction: + type: inMemory + outDegree: [32] + efConstruction: [100] + neighborOverflow: [1.2f] + addHierarchy: [Yes] + refineFinalGraph: [Yes] + compression: + - type: None + reranking: + type: None + +search: + topKOverquery: + 10: [1.0, 2.0] + 100: [1.0, 2.0] + useSearchPruning: [No] + compression: + - type: None diff --git a/jvector-examples/yaml-configs/index-parameters/in-memory-fp/sift-128-euclidean.yml b/jvector-examples/yaml-configs/index-parameters/in-memory-fp/sift-128-euclidean.yml new file mode 100644 index 000000000..1a576046a --- /dev/null +++ b/jvector-examples/yaml-configs/index-parameters/in-memory-fp/sift-128-euclidean.yml @@ -0,0 +1,26 @@ +# sift-128-euclidean (128 dimensions, Euclidean similarity): in-memory graph built and searched with full-precision vectors. +# Nothing is written to disk. +yamlSchemaVersion: 3 +onDiskIndexVersion: 7 + +dataset: sift-128-euclidean + +construction: + type: inMemory + outDegree: [32] + efConstruction: [100] + neighborOverflow: [1.2f] + addHierarchy: [Yes] + refineFinalGraph: [Yes] + compression: + - type: None + reranking: + type: None + +search: + topKOverquery: + 10: [1.0, 2.0] + 100: [1.0, 2.0] + useSearchPruning: [No] + compression: + - type: None diff --git a/jvector-examples/yaml-configs/index-parameters/mpnet-10M.yml b/jvector-examples/yaml-configs/index-parameters/mpnet-10m.yml similarity index 100% rename from jvector-examples/yaml-configs/index-parameters/mpnet-10M.yml rename to jvector-examples/yaml-configs/index-parameters/mpnet-10m.yml diff --git a/jvector-examples/yaml-configs/index-parameters/mpnet-1M.yml b/jvector-examples/yaml-configs/index-parameters/mpnet-1m.yml similarity index 100% rename from jvector-examples/yaml-configs/index-parameters/mpnet-1M.yml rename to jvector-examples/yaml-configs/index-parameters/mpnet-1m.yml diff --git a/jvector-examples/yaml-configs/index-parameters/on-disk-ash-fp32/README.md b/jvector-examples/yaml-configs/index-parameters/on-disk-ash-fp32/README.md new file mode 100644 index 000000000..f7e99b020 --- /dev/null +++ b/jvector-examples/yaml-configs/index-parameters/on-disk-ash-fp32/README.md @@ -0,0 +1,15 @@ +# On-disk graph with separate ASH codes + +Builds with ASH scoring and writes the graph with the source vectors. Searches traverse with ASH codes held in memory and rescore from disk. + +**Use case:** Indexes too large for memory, where compact codes stay resident and full vectors are read only for rescoring. + +**Pipeline:** onDisk · build ASH · search ASH · rerank FP32 + +Files are named for their dataset. Each sets `dataset:`; change it to run the same pipeline on another compatible dataset. ASH requires dot-product datasets. + +Run one file, or every file in the folder, by passing paths to BenchYAML from the repository root: + +```bash +java -cp "$BENCH_JAR" io.github.jbellis.jvector.example.BenchYAML jvector-examples/yaml-configs/index-parameters/on-disk-ash-fp32/*.yml +``` diff --git a/jvector-examples/yaml-configs/index-parameters/on-disk-ash-fp32/esmc-600-1M.yml b/jvector-examples/yaml-configs/index-parameters/on-disk-ash-fp32/esmc-600-1M.yml new file mode 100644 index 000000000..5aa69d918 --- /dev/null +++ b/jvector-examples/yaml-configs/index-parameters/on-disk-ash-fp32/esmc-600-1M.yml @@ -0,0 +1,37 @@ +# esmc-600-1M (1152 dimensions): build with 2-bit ASH scoring and write the graph with full-precision +# vectors. Searches traverse with ASH codes held in memory and rescore from disk. +yamlSchemaVersion: 3 +onDiskIndexVersion: 7 + +dataset: esmc-600-1M + +construction: + type: onDisk + outDegree: [32] + efConstruction: [100] + neighborOverflow: [1.2f] + addHierarchy: [Yes] + refineFinalGraph: [Yes] + fusedGraph: [No] + compression: + - type: ASH + parameters: + compressionRatio: 32 + optimizer: ITQ + landmarkCount: 1 + bitsPerDimension: 2 + reranking: + type: FP32 + useSavedIndexIfExists: No + +search: + topKOverquery: + 10: [1.0, 2.0, 4.0] + useSearchPruning: [No] + compression: + - type: ASH + parameters: + compressionRatio: 32 + optimizer: ITQ + landmarkCount: 1 + bitsPerDimension: 2 diff --git a/jvector-examples/yaml-configs/index-parameters/on-disk-ash-fp32/personahub-1m.yml b/jvector-examples/yaml-configs/index-parameters/on-disk-ash-fp32/personahub-1m.yml new file mode 100644 index 000000000..c07d014fb --- /dev/null +++ b/jvector-examples/yaml-configs/index-parameters/on-disk-ash-fp32/personahub-1m.yml @@ -0,0 +1,37 @@ +# personahub-1m (1024 dimensions): build with 2-bit ASH scoring and write the graph with full-precision +# vectors. Searches traverse with ASH codes held in memory and rescore from disk. +yamlSchemaVersion: 3 +onDiskIndexVersion: 7 + +dataset: personahub-1m + +construction: + type: onDisk + outDegree: [32] + efConstruction: [100] + neighborOverflow: [1.2f] + addHierarchy: [Yes] + refineFinalGraph: [Yes] + fusedGraph: [No] + compression: + - type: ASH + parameters: + compressionRatio: 32 + optimizer: ITQ + landmarkCount: 1 + bitsPerDimension: 2 + reranking: + type: FP32 + useSavedIndexIfExists: No + +search: + topKOverquery: + 10: [1.0, 2.0, 4.0] + useSearchPruning: [No] + compression: + - type: ASH + parameters: + compressionRatio: 32 + optimizer: ITQ + landmarkCount: 1 + bitsPerDimension: 2 diff --git a/jvector-examples/yaml-configs/index-parameters/on-disk-ash-fp32/tahoe-x1-1m.yml b/jvector-examples/yaml-configs/index-parameters/on-disk-ash-fp32/tahoe-x1-1m.yml new file mode 100644 index 000000000..cc6b80eac --- /dev/null +++ b/jvector-examples/yaml-configs/index-parameters/on-disk-ash-fp32/tahoe-x1-1m.yml @@ -0,0 +1,37 @@ +# tahoe-x1-1m (2560 dimensions): build with 2-bit ASH scoring and write the graph with full-precision +# vectors. Searches traverse with ASH codes held in memory and rescore from disk. +yamlSchemaVersion: 3 +onDiskIndexVersion: 7 + +dataset: tahoe-x1-1m + +construction: + type: onDisk + outDegree: [32] + efConstruction: [100] + neighborOverflow: [1.2f] + addHierarchy: [Yes] + refineFinalGraph: [Yes] + fusedGraph: [No] + compression: + - type: ASH + parameters: + compressionRatio: 32 + optimizer: ITQ + landmarkCount: 1 + bitsPerDimension: 2 + reranking: + type: FP32 + useSavedIndexIfExists: No + +search: + topKOverquery: + 10: [1.0, 2.0, 4.0] + useSearchPruning: [No] + compression: + - type: ASH + parameters: + compressionRatio: 32 + optimizer: ITQ + landmarkCount: 1 + bitsPerDimension: 2 diff --git a/jvector-examples/yaml-configs/index-parameters/on-disk-fp/README.md b/jvector-examples/yaml-configs/index-parameters/on-disk-fp/README.md new file mode 100644 index 000000000..56045c1de --- /dev/null +++ b/jvector-examples/yaml-configs/index-parameters/on-disk-fp/README.md @@ -0,0 +1,15 @@ +# On-disk graph, full-precision vectors + +Builds and searches an on-disk graph with the source vectors, without quantization. Includes a graph-settings sweep. + +**Use case:** Lower-dimensional data stored on disk, and tuning graph degree, build width, hierarchy and pruning. + +**Pipeline:** onDisk · build None · search None · rerank None + +Files are named for their dataset. Each sets `dataset:`; change it to run the same pipeline on another compatible dataset. + +Run one file, or every file in the folder, by passing paths to BenchYAML from the repository root: + +```bash +java -cp "$BENCH_JAR" io.github.jbellis.jvector.example.BenchYAML jvector-examples/yaml-configs/index-parameters/on-disk-fp/*.yml +``` diff --git a/jvector-examples/yaml-configs/index-parameters/on-disk-fp/glove-50-angular-graph-sweep.yml b/jvector-examples/yaml-configs/index-parameters/on-disk-fp/glove-50-angular-graph-sweep.yml new file mode 100644 index 000000000..3f0896ffa --- /dev/null +++ b/jvector-examples/yaml-configs/index-parameters/on-disk-fp/glove-50-angular-graph-sweep.yml @@ -0,0 +1,28 @@ +# glove-50-angular (50 dimensions, angular similarity): sweep graph settings on an +# uncompressed on-disk graph. Each list value is a separate case; the run covers every +# combination of degree, build width and hierarchy, each searched with and without pruning. +yamlSchemaVersion: 3 +onDiskIndexVersion: 7 + +dataset: glove-50-angular + +construction: + type: onDisk + outDegree: [16, 32] + efConstruction: [50, 100] + neighborOverflow: [1.2f] + addHierarchy: [No, Yes] + refineFinalGraph: [Yes] + fusedGraph: [No] + compression: + - type: None + reranking: + type: None + useSavedIndexIfExists: No + +search: + topKOverquery: + 10: [1.0, 2.0] + useSearchPruning: [No, Yes] + compression: + - type: None diff --git a/jvector-examples/yaml-configs/index-parameters/on-disk-fp/lastfm-64-dot.yml b/jvector-examples/yaml-configs/index-parameters/on-disk-fp/lastfm-64-dot.yml new file mode 100644 index 000000000..e8d5d91a6 --- /dev/null +++ b/jvector-examples/yaml-configs/index-parameters/on-disk-fp/lastfm-64-dot.yml @@ -0,0 +1,27 @@ +# lastfm-64-dot (65 dimensions, dot product): on-disk graph built and searched with full-precision vectors. +yamlSchemaVersion: 3 +onDiskIndexVersion: 7 + +dataset: lastfm-64-dot + +construction: + type: onDisk + outDegree: [32] + efConstruction: [100] + neighborOverflow: [1.2f] + addHierarchy: [Yes] + refineFinalGraph: [Yes] + fusedGraph: [No] + compression: + - type: None + reranking: + type: None + useSavedIndexIfExists: No + +search: + topKOverquery: + 10: [1.0, 2.0] + 100: [1.0, 2.0] + useSearchPruning: [No] + compression: + - type: None diff --git a/jvector-examples/yaml-configs/index-parameters/on-disk-fp/nytimes-256-angular.yml b/jvector-examples/yaml-configs/index-parameters/on-disk-fp/nytimes-256-angular.yml new file mode 100644 index 000000000..06f7d90c6 --- /dev/null +++ b/jvector-examples/yaml-configs/index-parameters/on-disk-fp/nytimes-256-angular.yml @@ -0,0 +1,27 @@ +# nytimes-256-angular (256 dimensions, angular similarity): on-disk graph built and searched with full-precision vectors. +yamlSchemaVersion: 3 +onDiskIndexVersion: 7 + +dataset: nytimes-256-angular + +construction: + type: onDisk + outDegree: [32] + efConstruction: [100] + neighborOverflow: [1.2f] + addHierarchy: [Yes] + refineFinalGraph: [Yes] + fusedGraph: [No] + compression: + - type: None + reranking: + type: None + useSavedIndexIfExists: No + +search: + topKOverquery: + 10: [1.0, 2.0] + 100: [1.0, 2.0] + useSearchPruning: [No] + compression: + - type: None diff --git a/jvector-examples/yaml-configs/index-parameters/on-disk-pq-fp32/README.md b/jvector-examples/yaml-configs/index-parameters/on-disk-pq-fp32/README.md new file mode 100644 index 000000000..eafd79a4f --- /dev/null +++ b/jvector-examples/yaml-configs/index-parameters/on-disk-pq-fp32/README.md @@ -0,0 +1,15 @@ +# On-disk graph with separate PQ codes + +Builds with PQ scoring and writes the graph with the source vectors. Searches traverse with PQ codes held in memory and rescore from disk. + +**Use case:** The same layout as on-disk-ash-fp32 with PQ codes. + +**Pipeline:** onDisk · build PQ · search PQ · rerank FP32 + +Files are named for their dataset. Each sets `dataset:`; change it to run the same pipeline on another compatible dataset. + +Run one file, or every file in the folder, by passing paths to BenchYAML from the repository root: + +```bash +java -cp "$BENCH_JAR" io.github.jbellis.jvector.example.BenchYAML jvector-examples/yaml-configs/index-parameters/on-disk-pq-fp32/*.yml +``` diff --git a/jvector-examples/yaml-configs/index-parameters/on-disk-pq-fp32/dpr-gemma-1m.yml b/jvector-examples/yaml-configs/index-parameters/on-disk-pq-fp32/dpr-gemma-1m.yml new file mode 100644 index 000000000..e324d58cb --- /dev/null +++ b/jvector-examples/yaml-configs/index-parameters/on-disk-pq-fp32/dpr-gemma-1m.yml @@ -0,0 +1,35 @@ +# dpr-gemma-1m (768 dimensions): build with PQ scoring (D/8 subspaces) and write the graph with +# full-precision vectors. Searches traverse with PQ codes in memory and rescore from disk. +yamlSchemaVersion: 3 +onDiskIndexVersion: 7 + +dataset: dpr-gemma-1m + +construction: + type: onDisk + outDegree: [32] + efConstruction: [100] + neighborOverflow: [1.2f] + addHierarchy: [Yes] + refineFinalGraph: [Yes] + fusedGraph: [No] + compression: + - type: PQ + parameters: + mFactor: 8 + k: 256 + anisotropicThreshold: -1.0 + reranking: + type: FP32 + useSavedIndexIfExists: No + +search: + topKOverquery: + 10: [1.0, 2.0, 4.0] + useSearchPruning: [No] + compression: + - type: PQ + parameters: + mFactor: 8 + k: 256 + anisotropicThreshold: -1.0 diff --git a/jvector-examples/yaml-configs/index-parameters/on-disk-pq-fp32/personahub-1m.yml b/jvector-examples/yaml-configs/index-parameters/on-disk-pq-fp32/personahub-1m.yml new file mode 100644 index 000000000..f4cff95a4 --- /dev/null +++ b/jvector-examples/yaml-configs/index-parameters/on-disk-pq-fp32/personahub-1m.yml @@ -0,0 +1,35 @@ +# personahub-1m (1024 dimensions): build with PQ scoring (D/8 subspaces) and write the graph with +# full-precision vectors. Searches traverse with PQ codes in memory and rescore from disk. +yamlSchemaVersion: 3 +onDiskIndexVersion: 7 + +dataset: personahub-1m + +construction: + type: onDisk + outDegree: [32] + efConstruction: [100] + neighborOverflow: [1.2f] + addHierarchy: [Yes] + refineFinalGraph: [Yes] + fusedGraph: [No] + compression: + - type: PQ + parameters: + mFactor: 8 + k: 256 + anisotropicThreshold: -1.0 + reranking: + type: FP32 + useSavedIndexIfExists: No + +search: + topKOverquery: + 10: [1.0, 2.0, 4.0] + useSearchPruning: [No] + compression: + - type: PQ + parameters: + mFactor: 8 + k: 256 + anisotropicThreshold: -1.0 diff --git a/jvector-examples/yaml-configs/index-parameters/openai-v3-small-100k.yml b/jvector-examples/yaml-configs/index-parameters/openai-v3-small-1536-100k.yml similarity index 100% rename from jvector-examples/yaml-configs/index-parameters/openai-v3-small-100k.yml rename to jvector-examples/yaml-configs/index-parameters/openai-v3-small-1536-100k.yml diff --git a/jvector-examples/yaml-configs/index-parameters/personahub-1M.yml b/jvector-examples/yaml-configs/index-parameters/personahub-1m.yml similarity index 100% rename from jvector-examples/yaml-configs/index-parameters/personahub-1M.yml rename to jvector-examples/yaml-configs/index-parameters/personahub-1m.yml diff --git a/jvector-examples/yaml-configs/index-parameters/studies/README.md b/jvector-examples/yaml-configs/index-parameters/studies/README.md new file mode 100644 index 000000000..66aebe99f --- /dev/null +++ b/jvector-examples/yaml-configs/index-parameters/studies/README.md @@ -0,0 +1,5 @@ +# Studies + +Checked-in recipes for published comparisons. Run them with the study's own scripts so settings, repetitions and reporting match the published protocol. + +- [ash-vs-pq](ash-vs-pq): matched ASH and PQ code budgets. See [docs/ash-pq-benchmark.md](../../../../docs/ash-pq-benchmark.md). diff --git a/jvector-examples/yaml-configs/index-parameters/ash-pq/ada002-2bit-ash.yml b/jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq/ada002-2bit-ash.yml similarity index 100% rename from jvector-examples/yaml-configs/index-parameters/ash-pq/ada002-2bit-ash.yml rename to jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq/ada002-2bit-ash.yml diff --git a/jvector-examples/yaml-configs/index-parameters/ash-pq/ada002-2bit-pq.yml b/jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq/ada002-2bit-pq.yml similarity index 100% rename from jvector-examples/yaml-configs/index-parameters/ash-pq/ada002-2bit-pq.yml rename to jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq/ada002-2bit-pq.yml diff --git a/jvector-examples/yaml-configs/index-parameters/ash-pq/ada002-4bit-ash.yml b/jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq/ada002-4bit-ash.yml similarity index 100% rename from jvector-examples/yaml-configs/index-parameters/ash-pq/ada002-4bit-ash.yml rename to jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq/ada002-4bit-ash.yml diff --git a/jvector-examples/yaml-configs/index-parameters/ash-pq/ada002-4bit-pq.yml b/jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq/ada002-4bit-pq.yml similarity index 100% rename from jvector-examples/yaml-configs/index-parameters/ash-pq/ada002-4bit-pq.yml rename to jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq/ada002-4bit-pq.yml diff --git a/jvector-examples/yaml-configs/index-parameters/ash-pq/cap6m-2bit-ash.yml b/jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq/cap6m-2bit-ash.yml similarity index 100% rename from jvector-examples/yaml-configs/index-parameters/ash-pq/cap6m-2bit-ash.yml rename to jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq/cap6m-2bit-ash.yml diff --git a/jvector-examples/yaml-configs/index-parameters/ash-pq/cap6m-2bit-pq.yml b/jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq/cap6m-2bit-pq.yml similarity index 100% rename from jvector-examples/yaml-configs/index-parameters/ash-pq/cap6m-2bit-pq.yml rename to jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq/cap6m-2bit-pq.yml diff --git a/jvector-examples/yaml-configs/index-parameters/ash-pq/cap6m-4bit-ash.yml b/jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq/cap6m-4bit-ash.yml similarity index 100% rename from jvector-examples/yaml-configs/index-parameters/ash-pq/cap6m-4bit-ash.yml rename to jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq/cap6m-4bit-ash.yml diff --git a/jvector-examples/yaml-configs/index-parameters/ash-pq/cap6m-4bit-pq.yml b/jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq/cap6m-4bit-pq.yml similarity index 100% rename from jvector-examples/yaml-configs/index-parameters/ash-pq/cap6m-4bit-pq.yml rename to jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq/cap6m-4bit-pq.yml diff --git a/jvector-examples/yaml-configs/index-parameters/ash-pq/cohere-2bit-ash.yml b/jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq/cohere-2bit-ash.yml similarity index 100% rename from jvector-examples/yaml-configs/index-parameters/ash-pq/cohere-2bit-ash.yml rename to jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq/cohere-2bit-ash.yml diff --git a/jvector-examples/yaml-configs/index-parameters/ash-pq/cohere-2bit-pq.yml b/jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq/cohere-2bit-pq.yml similarity index 100% rename from jvector-examples/yaml-configs/index-parameters/ash-pq/cohere-2bit-pq.yml rename to jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq/cohere-2bit-pq.yml diff --git a/jvector-examples/yaml-configs/index-parameters/ash-pq/cohere-4bit-ash.yml b/jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq/cohere-4bit-ash.yml similarity index 100% rename from jvector-examples/yaml-configs/index-parameters/ash-pq/cohere-4bit-ash.yml rename to jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq/cohere-4bit-ash.yml diff --git a/jvector-examples/yaml-configs/index-parameters/ash-pq/cohere-4bit-pq.yml b/jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq/cohere-4bit-pq.yml similarity index 100% rename from jvector-examples/yaml-configs/index-parameters/ash-pq/cohere-4bit-pq.yml rename to jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq/cohere-4bit-pq.yml diff --git a/jvector-examples/yaml-configs/index-parameters/ash-pq/cohere10m-2bit-ash.yml b/jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq/cohere10m-2bit-ash.yml similarity index 100% rename from jvector-examples/yaml-configs/index-parameters/ash-pq/cohere10m-2bit-ash.yml rename to jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq/cohere10m-2bit-ash.yml diff --git a/jvector-examples/yaml-configs/index-parameters/ash-pq/cohere10m-2bit-pq.yml b/jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq/cohere10m-2bit-pq.yml similarity index 100% rename from jvector-examples/yaml-configs/index-parameters/ash-pq/cohere10m-2bit-pq.yml rename to jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq/cohere10m-2bit-pq.yml diff --git a/jvector-examples/yaml-configs/index-parameters/ash-pq/cohere10m-4bit-ash.yml b/jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq/cohere10m-4bit-ash.yml similarity index 100% rename from jvector-examples/yaml-configs/index-parameters/ash-pq/cohere10m-4bit-ash.yml rename to jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq/cohere10m-4bit-ash.yml diff --git a/jvector-examples/yaml-configs/index-parameters/ash-pq/cohere10m-4bit-pq.yml b/jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq/cohere10m-4bit-pq.yml similarity index 100% rename from jvector-examples/yaml-configs/index-parameters/ash-pq/cohere10m-4bit-pq.yml rename to jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq/cohere10m-4bit-pq.yml diff --git a/jvector-examples/yaml-configs/index-parameters/ash-pq/mpnet-2bit-ash.yml b/jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq/mpnet-2bit-ash.yml similarity index 100% rename from jvector-examples/yaml-configs/index-parameters/ash-pq/mpnet-2bit-ash.yml rename to jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq/mpnet-2bit-ash.yml diff --git a/jvector-examples/yaml-configs/index-parameters/ash-pq/mpnet-2bit-pq.yml b/jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq/mpnet-2bit-pq.yml similarity index 100% rename from jvector-examples/yaml-configs/index-parameters/ash-pq/mpnet-2bit-pq.yml rename to jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq/mpnet-2bit-pq.yml diff --git a/jvector-examples/yaml-configs/index-parameters/ash-pq/mpnet-4bit-ash.yml b/jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq/mpnet-4bit-ash.yml similarity index 100% rename from jvector-examples/yaml-configs/index-parameters/ash-pq/mpnet-4bit-ash.yml rename to jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq/mpnet-4bit-ash.yml diff --git a/jvector-examples/yaml-configs/index-parameters/ash-pq/mpnet-4bit-pq.yml b/jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq/mpnet-4bit-pq.yml similarity index 100% rename from jvector-examples/yaml-configs/index-parameters/ash-pq/mpnet-4bit-pq.yml rename to jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq/mpnet-4bit-pq.yml diff --git a/jvector-examples/yaml-configs/index-parameters/ash-pq/mpnet10m-2bit-ash.yml b/jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq/mpnet10m-2bit-ash.yml similarity index 100% rename from jvector-examples/yaml-configs/index-parameters/ash-pq/mpnet10m-2bit-ash.yml rename to jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq/mpnet10m-2bit-ash.yml diff --git a/jvector-examples/yaml-configs/index-parameters/ash-pq/mpnet10m-2bit-pq.yml b/jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq/mpnet10m-2bit-pq.yml similarity index 100% rename from jvector-examples/yaml-configs/index-parameters/ash-pq/mpnet10m-2bit-pq.yml rename to jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq/mpnet10m-2bit-pq.yml diff --git a/jvector-examples/yaml-configs/index-parameters/ash-pq/mpnet10m-4bit-ash.yml b/jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq/mpnet10m-4bit-ash.yml similarity index 100% rename from jvector-examples/yaml-configs/index-parameters/ash-pq/mpnet10m-4bit-ash.yml rename to jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq/mpnet10m-4bit-ash.yml diff --git a/jvector-examples/yaml-configs/index-parameters/ash-pq/mpnet10m-4bit-pq.yml b/jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq/mpnet10m-4bit-pq.yml similarity index 100% rename from jvector-examples/yaml-configs/index-parameters/ash-pq/mpnet10m-4bit-pq.yml rename to jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq/mpnet10m-4bit-pq.yml diff --git a/jvector-examples/yaml-configs/index-parameters/ash-pq/openai3072-2bit-ash.yml b/jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq/openai3072-2bit-ash.yml similarity index 100% rename from jvector-examples/yaml-configs/index-parameters/ash-pq/openai3072-2bit-ash.yml rename to jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq/openai3072-2bit-ash.yml diff --git a/jvector-examples/yaml-configs/index-parameters/ash-pq/openai3072-2bit-pq.yml b/jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq/openai3072-2bit-pq.yml similarity index 100% rename from jvector-examples/yaml-configs/index-parameters/ash-pq/openai3072-2bit-pq.yml rename to jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq/openai3072-2bit-pq.yml diff --git a/jvector-examples/yaml-configs/index-parameters/ash-pq/openai3072-4bit-ash.yml b/jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq/openai3072-4bit-ash.yml similarity index 100% rename from jvector-examples/yaml-configs/index-parameters/ash-pq/openai3072-4bit-ash.yml rename to jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq/openai3072-4bit-ash.yml diff --git a/jvector-examples/yaml-configs/index-parameters/ash-pq/openai3072-4bit-pq.yml b/jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq/openai3072-4bit-pq.yml similarity index 100% rename from jvector-examples/yaml-configs/index-parameters/ash-pq/openai3072-4bit-pq.yml rename to jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq/openai3072-4bit-pq.yml diff --git a/jvector-examples/yaml-configs/index-parameters/tahoe-x1-1M.yml b/jvector-examples/yaml-configs/index-parameters/tahoe-x1-1m.yml similarity index 100% rename from jvector-examples/yaml-configs/index-parameters/tahoe-x1-1M.yml rename to jvector-examples/yaml-configs/index-parameters/tahoe-x1-1m.yml From 8ebde412242719d9a547c24e288615d3940157f5 Mon Sep 17 00:00:00 2001 From: Ted Willke Date: Mon, 9 Nov 2026 03:02:00 +0000 Subject: [PATCH 166/243] Use lock-free paged storage for dense graph node maps DenseIntMap takes a shared read lock on every compareAndPut so that a resize cannot lose a concurrent update. During construction every neighbor-list update goes through it, and contention on that lock produced large run-to-run swings in build time (one mpnet-10m build spent 49% of samples spinning in the read lock). PagedIntMap stores values in fixed 4096-slot pages that are never copied, so a slot never moves and compareAndPut is a single CAS with no lock. Growth installs new pages under a lock taken once per page. Layer 0 and the ConcurrentNeighborMap default constructor now use it. --- .../jvector/graph/ConcurrentNeighborMap.java | 2 +- .../jvector/graph/OnHeapGraphIndex.java | 14 +- .../jbellis/jvector/util/PagedIntMap.java | 149 ++++++++++++++ .../jbellis/jvector/util/TestIntMap.java | 4 + .../jbellis/jvector/util/TestPagedIntMap.java | 184 ++++++++++++++++++ 5 files changed, 350 insertions(+), 3 deletions(-) create mode 100644 jvector-base/src/main/java/io/github/jbellis/jvector/util/PagedIntMap.java create mode 100644 jvector-tests/src/test/java/io/github/jbellis/jvector/util/TestPagedIntMap.java diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/graph/ConcurrentNeighborMap.java b/jvector-base/src/main/java/io/github/jbellis/jvector/graph/ConcurrentNeighborMap.java index 0339dd233..2b2e8dc4f 100644 --- a/jvector-base/src/main/java/io/github/jbellis/jvector/graph/ConcurrentNeighborMap.java +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/graph/ConcurrentNeighborMap.java @@ -41,7 +41,7 @@ public class ConcurrentNeighborMap { public final int maxOverflowDegree; public ConcurrentNeighborMap(DiversityProvider diversityProvider, int maxDegree, int maxOverflowDegree) { - this(new DenseIntMap<>(1024), diversityProvider, maxDegree, maxOverflowDegree); + this(OnHeapGraphIndex.newDenseNodeMap(1024), diversityProvider, maxDegree, maxOverflowDegree); } public ConcurrentNeighborMap(IntMap neighbors, DiversityProvider diversityProvider, int maxDegree, int maxOverflowDegree) { diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/graph/OnHeapGraphIndex.java b/jvector-base/src/main/java/io/github/jbellis/jvector/graph/OnHeapGraphIndex.java index c1b44e991..4a49049a9 100644 --- a/jvector-base/src/main/java/io/github/jbellis/jvector/graph/OnHeapGraphIndex.java +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/graph/OnHeapGraphIndex.java @@ -32,7 +32,8 @@ import io.github.jbellis.jvector.util.Accountable; import io.github.jbellis.jvector.util.BitSet; import io.github.jbellis.jvector.util.Bits; -import io.github.jbellis.jvector.util.DenseIntMap; +import io.github.jbellis.jvector.util.IntMap; +import io.github.jbellis.jvector.util.PagedIntMap; import io.github.jbellis.jvector.util.RamUsageEstimator; import io.github.jbellis.jvector.util.SparseIntMap; import io.github.jbellis.jvector.util.ThreadSafeGrowableBitSet; @@ -83,6 +84,15 @@ public class OnHeapGraphIndex implements MutableGraphIndex { private final boolean isHierarchical; + /** + * Dense node maps use {@link PagedIntMap}: slots never move, so updates are a single CAS with no + * lock. A read-write-locked map ({@link io.github.jbellis.jvector.util.DenseIntMap}) made every + * concurrent neighbor update take a shared lock, whose contention caused large build-time swings. + */ + static IntMap newDenseNodeMap(int initialCapacity) { + return new PagedIntMap<>(initialCapacity); + } + OnHeapGraphIndex(List maxDegrees, int dimension, double overflowRatio, DiversityProvider diversityProvider, boolean isHierarchical) { this.overflowRatio = overflowRatio; this.maxDegrees = new IntArrayList(); @@ -92,7 +102,7 @@ public class OnHeapGraphIndex implements MutableGraphIndex { this.completions = new CompletionTracker(1024); // Initialize the base layer (layer 0) with a dense map. this.layers.add(new ConcurrentNeighborMap( - new DenseIntMap<>(1024), + newDenseNodeMap(1024), diversityProvider, getDegree(0), (int) (getDegree(0) * overflowRatio)) diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/util/PagedIntMap.java b/jvector-base/src/main/java/io/github/jbellis/jvector/util/PagedIntMap.java new file mode 100644 index 000000000..5bdbdcbc7 --- /dev/null +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/util/PagedIntMap.java @@ -0,0 +1,149 @@ +/* + * 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.util; + +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReferenceArray; + +/** + * A growable map of dense-ish int keys to values, like {@link DenseIntMap}, whose updates take no lock. + *

    + * Values live in fixed-size pages. A page, once installed, is never copied or replaced, so a slot + * has one location for the life of the map. Growing installs new pages and, rarely, replaces the + * spine (the array of page references) with a larger copy that refers to the same page objects. + * Because no slot ever moves, a compare-and-set can never be lost to a concurrent resize, which is + * the race {@link DenseIntMap}'s read-write lock exists to prevent. {@link #compareAndPut} is + * therefore a single slot CAS, and readers take no locks. + *

    + * Publication: pages are installed with a volatile store into the spine, and a replaced spine is + * published through a volatile field after it holds every existing page. A thread that observes a + * value written into a page therefore also observes that page through the spine. + *

    + * As with {@link DenseIntMap}, space is allocated for all keys from 0 to the highest page in use, + * and gap keys map to null. + */ +public final class PagedIntMap implements IntMap { + static final int PAGE_BITS = 12; + static final int PAGE_SIZE = 1 << PAGE_BITS; + private static final int PAGE_MASK = PAGE_SIZE - 1; + + /** Spine of pages. Entries are installed once, under {@link #growLock}, and never change. */ + private volatile AtomicReferenceArray> spine; + private final Object growLock = new Object(); + private final AtomicInteger size = new AtomicInteger(); + + public PagedIntMap(int initialCapacity) { + if (initialCapacity < 0) throw new IllegalArgumentException("initialCapacity must be nonnegative"); + int pages = Math.max(1, (int) ((initialCapacity + (long) PAGE_SIZE - 1) >>> PAGE_BITS)); + var s = new AtomicReferenceArray>(pages); + for (int p = 0; p < pages; p++) s.set(p, new AtomicReferenceArray<>(PAGE_SIZE)); + spine = s; + } + + @Override + public boolean compareAndPut(int key, T existing, T value) { + if (value == null) { + throw new IllegalArgumentException("compareAndPut() value cannot be null -- use remove() instead"); + } + if (key < 0) throw new IndexOutOfBoundsException("negative key " + key); + boolean success = pageForWrite(key).compareAndSet(key & PAGE_MASK, existing, value); + if (success && existing == null) size.incrementAndGet(); + return success; + } + + @Override + public int size() { + return size.get(); + } + + @Override + public T get(int key) { + var page = pageOrNull(key); + return page == null ? null : page.get(key & PAGE_MASK); + } + + @Override + public T remove(int key) { + var page = pageOrNull(key); + if (page == null) return null; + int slot = key & PAGE_MASK; + T old = page.get(slot); + if (old == null) return null; + if (page.compareAndSet(slot, old, null)) { + size.decrementAndGet(); + return old; + } + return null; + } + + @Override + public boolean containsKey(int key) { + return get(key) != null; + } + + @Override + public void forEach(IntBiConsumer consumer) { + var s = spine; + for (int p = 0; p < s.length(); p++) { + var page = s.get(p); + if (page == null) continue; + int base = p << PAGE_BITS; + for (int i = 0; i < PAGE_SIZE; i++) { + T value = page.get(i); + if (value != null) consumer.consume(base + i, value); + } + } + } + + private AtomicReferenceArray pageOrNull(int key) { + if (key < 0) throw new IndexOutOfBoundsException("negative key " + key); + int p = key >>> PAGE_BITS; + var s = spine; + return p < s.length() ? s.get(p) : null; + } + + private AtomicReferenceArray pageForWrite(int key) { + int p = key >>> PAGE_BITS; + var s = spine; + if (p < s.length()) { + var page = s.get(p); + if (page != null) return page; + } + return installPage(p); + } + + /** Slow path, once per new page: install it, growing the spine geometrically if needed. */ + private AtomicReferenceArray installPage(int p) { + synchronized (growLock) { + var s = spine; + if (p >= s.length()) { + // Keys up to Integer.MAX_VALUE need (MAX_VALUE >>> PAGE_BITS) + 1 pages. + int newLength = (int) Math.min((Integer.MAX_VALUE >>> PAGE_BITS) + 1L, + Math.max(p + 1L, s.length() + (s.length() >> 1) + 1L)); + var grown = new AtomicReferenceArray>(newLength); + for (int i = 0; i < s.length(); i++) grown.set(i, s.get(i)); + spine = grown; // volatile publish after copying every existing page + s = grown; + } + var page = s.get(p); + if (page == null) { + page = new AtomicReferenceArray<>(PAGE_SIZE); + s.set(p, page); + } + return page; + } + } +} diff --git a/jvector-tests/src/test/java/io/github/jbellis/jvector/util/TestIntMap.java b/jvector-tests/src/test/java/io/github/jbellis/jvector/util/TestIntMap.java index ac4695bbc..9abe4662a 100644 --- a/jvector-tests/src/test/java/io/github/jbellis/jvector/util/TestIntMap.java +++ b/jvector-tests/src/test/java/io/github/jbellis/jvector/util/TestIntMap.java @@ -37,6 +37,7 @@ public class TestIntMap extends RandomizedTest { @Test public void testInsert() { testInsertInternal(new DenseIntMap<>(100)); + testInsertInternal(new PagedIntMap<>(100)); testInsertInternal(new SparseIntMap<>()); } @@ -55,6 +56,7 @@ private void testInsertInternal(IntMap map) { @Test public void testUpdate() { testUpdateInternal(new DenseIntMap<>(100)); + testUpdateInternal(new PagedIntMap<>(100)); testUpdateInternal(new SparseIntMap<>()); } @@ -74,6 +76,7 @@ private void testUpdateInternal(IntMap map) { @Test public void testRemove() { testRemoveInternal(new DenseIntMap<>(100)); + testRemoveInternal(new PagedIntMap<>(100)); testRemoveInternal(new SparseIntMap<>()); } @@ -95,6 +98,7 @@ private void testRemoveInternal(IntMap map) { public void testConcurrency() throws InterruptedException { for (int i = 0; i < 100; i++) { testConcurrencyOnce(new DenseIntMap<>(100)); + testConcurrencyOnce(new PagedIntMap<>(100)); testConcurrencyOnce(new SparseIntMap<>()); } } diff --git a/jvector-tests/src/test/java/io/github/jbellis/jvector/util/TestPagedIntMap.java b/jvector-tests/src/test/java/io/github/jbellis/jvector/util/TestPagedIntMap.java new file mode 100644 index 000000000..ee6d32988 --- /dev/null +++ b/jvector-tests/src/test/java/io/github/jbellis/jvector/util/TestPagedIntMap.java @@ -0,0 +1,184 @@ +/* + * 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.util; + +import com.carrotsearch.randomizedtesting.RandomizedTest; +import com.carrotsearch.randomizedtesting.annotations.ThreadLeakScope; +import org.junit.Assert; +import org.junit.Test; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicReference; + +/** PagedIntMap-specific behavior: page boundaries, growth, and no lost updates while growing. */ +@ThreadLeakScope(ThreadLeakScope.Scope.NONE) +public class TestPagedIntMap extends RandomizedTest { + + @Test + public void testPageBoundariesAndSparseGrowth() { + var map = new PagedIntMap(1); + int page = PagedIntMap.PAGE_SIZE; + int[] keys = {0, page - 1, page, page + 1, 3 * page - 1, 10 * page, 1_000_003}; + for (int k : keys) { + Assert.assertNull(map.get(k)); + Assert.assertTrue(map.compareAndPut(k, null, "v" + k)); + } + Assert.assertEquals(keys.length, map.size()); + for (int k : keys) Assert.assertEquals("v" + k, map.get(k)); + // Gaps read as null, including inside and beyond installed pages. + Assert.assertNull(map.get(page + 2)); + Assert.assertNull(map.get(5 * page)); + Assert.assertNull(map.get(Integer.MAX_VALUE)); + Assert.assertNull(map.remove(Integer.MAX_VALUE)); + } + + @Test + public void testLargestKey() { + var map = new PagedIntMap(0); + Assert.assertTrue(map.compareAndPut(Integer.MAX_VALUE, null, "max")); + Assert.assertEquals("max", map.get(Integer.MAX_VALUE)); + Assert.assertEquals(1, map.size()); + } + + @Test + public void testArgumentChecks() { + var map = new PagedIntMap(16); + Assert.assertThrows(IllegalArgumentException.class, () -> map.compareAndPut(1, null, null)); + Assert.assertThrows(IndexOutOfBoundsException.class, () -> map.compareAndPut(-1, null, "x")); + Assert.assertThrows(IndexOutOfBoundsException.class, () -> map.get(-1)); + Assert.assertThrows(IllegalArgumentException.class, () -> new PagedIntMap(-1)); + } + + @Test + public void testCompareAndPutSemantics() { + var map = new PagedIntMap(4); + Assert.assertTrue(map.compareAndPut(7, null, "a")); + Assert.assertFalse("insert over existing must fail", map.compareAndPut(7, null, "b")); + Assert.assertFalse("stale expected value must fail", map.compareAndPut(7, "zzz", "b")); + Assert.assertTrue(map.compareAndPut(7, "a", "b")); + Assert.assertEquals(1, map.size()); + Assert.assertEquals("b", map.remove(7)); + Assert.assertNull(map.remove(7)); + Assert.assertEquals(0, map.size()); + } + + @Test + public void testForEachInKeyOrder() { + var map = new PagedIntMap(1); + int[] keys = {5, 4096, 4097, 9000, 70_000}; + for (int i = keys.length - 1; i >= 0; i--) map.compareAndPut(keys[i], null, keys[i]); + List seen = new ArrayList<>(); + map.forEach((k, v) -> { Assert.assertEquals(Integer.valueOf(k), v); seen.add(k); }); + Assert.assertEquals(List.of(5, 4096, 4097, 9000, 70_000), seen); + } + + /** + * The race DenseIntMap's lock prevents: a CAS landing while the storage grows. Threads keep + * incrementing counters on low keys with CAS loops while other threads force growth by + * inserting ever-higher keys. Any lost update shows up as a counter below its expected total. + */ + @Test + public void testNoLostUpdatesWhileGrowing() throws Exception { + for (int round = 0; round < 5; round++) { + var map = new PagedIntMap(1); + int counters = 64, incrementers = 8, incrementsEach = 20_000, growers = 4, growKeys = 300_000; + for (int k = 0; k < counters; k++) map.compareAndPut(k, null, 0); + var start = new CountDownLatch(1); + var failure = new AtomicReference(); + List threads = new ArrayList<>(); + for (int t = 0; t < incrementers; t++) { + final int seed = t; + threads.add(new Thread(() -> { + try { + start.await(); + for (int i = 0; i < incrementsEach; i++) { + int key = (seed * 31 + i) % counters; + while (true) { + Integer old = map.get(key); + if (map.compareAndPut(key, old, old + 1)) break; + } + } + } catch (Throwable e) { failure.compareAndSet(null, e); } + })); + } + for (int t = 0; t < growers; t++) { + final int offset = t; + threads.add(new Thread(() -> { + try { + start.await(); + for (int i = 0; i < growKeys; i++) { + int key = counters + i * growers + offset; + Assert.assertTrue(map.compareAndPut(key, null, -key)); + } + } catch (Throwable e) { failure.compareAndSet(null, e); } + })); + } + threads.forEach(Thread::start); + start.countDown(); + for (Thread t : threads) t.join(); + if (failure.get() != null) throw new AssertionError(failure.get()); + + long total = 0; + for (int k = 0; k < counters; k++) total += map.get(k); + Assert.assertEquals((long) incrementers * incrementsEach, total); + Assert.assertEquals(counters + growers * growKeys, map.size()); + for (int i = 0; i < growers * growKeys; i++) { + int key = counters + i; + Assert.assertEquals(Integer.valueOf(-key), map.get(key)); + } + } + } + + /** Readers running during growth never see a value disappear once observed. */ + @Test + public void testReadersDuringGrowth() throws Exception { + var map = new PagedIntMap(1); + int keys = 400_000; + var done = new AtomicBoolean(); + var failure = new AtomicReference(); + var writer = new Thread(() -> { + for (int k = 0; k < keys; k++) map.compareAndPut(k, null, k); + done.set(true); + }); + List readers = new ArrayList<>(); + for (int r = 0; r < 4; r++) { + readers.add(new Thread(() -> { + try { + int highestSeen = -1; + while (!done.get()) { + // Values are inserted in key order, so everything at or below a seen key must be present. + int probe = Math.max(0, highestSeen); + Integer v = map.get(probe); + if (v != null) { + Assert.assertEquals(Integer.valueOf(probe), v); + highestSeen = probe + 1; + } + if (highestSeen > 0) Assert.assertNotNull(map.get(highestSeen - 1)); + } + } catch (Throwable e) { failure.compareAndSet(null, e); } + })); + } + readers.forEach(Thread::start); + writer.start(); + writer.join(); + for (Thread t : readers) t.join(); + if (failure.get() != null) throw new AssertionError(failure.get()); + Assert.assertEquals(keys, map.size()); + } +} From fddd5f00dced08b86b65998e738d932df2eef33a Mon Sep 17 00:00:00 2001 From: Ted Willke Date: Mon, 9 Nov 2026 03:10:47 +0000 Subject: [PATCH 167/243] Join pool workers before the TestNeighbors leak check ForkJoinPool.awaitTermination can return while worker threads are still exiting, so the randomized-testing thread-leak check intermittently reported a TERMINATED worker (about 1 run in 8, on ash-dev as well). Track the workers the test creates and join them after shutdown. --- .../jbellis/jvector/graph/TestNeighbors.java | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/jvector-tests/src/test/java/io/github/jbellis/jvector/graph/TestNeighbors.java b/jvector-tests/src/test/java/io/github/jbellis/jvector/graph/TestNeighbors.java index 0c7afabb9..91e6a4db5 100644 --- a/jvector-tests/src/test/java/io/github/jbellis/jvector/graph/TestNeighbors.java +++ b/jvector-tests/src/test/java/io/github/jbellis/jvector/graph/TestNeighbors.java @@ -23,7 +23,9 @@ import io.github.jbellis.jvector.vector.VectorSimilarityFunction; import org.junit.Test; +import java.util.ArrayList; import java.util.HashSet; +import java.util.List; import java.util.concurrent.ForkJoinPool; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicInteger; @@ -116,13 +118,23 @@ public void testConcurrentDuplicateOffersWithDifferentScores() throws Exception new TestVectorGraph.CircularFloatVectorValues(32), VectorSimilarityFunction.DOT_PRODUCT); var map = new ConcurrentNeighborMap(new VamanaDiversityProvider(bsp, 1.2f), 64, 80); map.addNode(0); - var pool = new ForkJoinPool(8); + // awaitTermination can return before the worker threads have exited, and the + // randomized-testing leak check then reports them; join every worker we created. + List workers = new ArrayList<>(); + var pool = new ForkJoinPool(8, p -> { + var worker = ForkJoinPool.defaultForkJoinWorkerThreadFactory.newThread(p); + synchronized (workers) { workers.add(worker); } + return worker; + }, null, false); try { pool.submit(() -> IntStream.range(0, 2000).parallel() .forEach(i -> map.insertEdge(0, 1 + i % 20, i, 1.2f))).join(); } finally { pool.shutdown(); assertTrue(pool.awaitTermination(10, TimeUnit.SECONDS)); + synchronized (workers) { + for (Thread worker : workers) worker.join(TimeUnit.SECONDS.toMillis(10)); + } } assertEquals(20, map.get(0).size()); var ids = new HashSet(); From beb274e955529c9cf99e05ee259a87c125e7af91 Mon Sep 17 00:00:00 2001 From: Ted Willke Date: Mon, 9 Nov 2026 07:30:32 -0800 Subject: [PATCH 168/243] Store ASH codes in compact pages and score them in place ASHVectors held one QuantizedVector object per vector, each with its own long[] sign words and byte[] code body, plus a second copy of the headers. At 768-D and 32x that is roughly 165 bytes of heap for a 101-byte code, and graph construction chased three references per scored neighbor. ASHVectors now owns a single ASHCodes store. Code bodies sit back to back in pages of primitive arrays (sign words in long pages, byte bodies in byte pages) with no per-vector object, array or padding. Scale, offset and landmark are decoded once into parallel arrays. Retained memory per vector is its serialized size plus 4 bytes. Pages are capped at 256 KiB so G1 never treats them as humongous objects rounded up to whole regions. The encoder writes each batch straight into the store. Serialization is unchanged: the store writes and reads the existing per-vector format. Asymmetric, symmetric, block and diversity scorers read codes in place; ASHScorer's formulas take an array, an offset and a header, so standalone vectors and stored codes share one code path and score identically. The fused graph writer copies neighbor codes into reusable per-thread storage instead of allocating one vector per neighbor. Kernels gain offset entry points: ashProjectionDot, ashProjectionDotTuned and ashCanonicalLutDot read a code at an array offset, and ashSymmetricBinaryDotAt joins ashSymmetricDotAt for 1-bit sign words, in the portable, Panama and native backends. The zero-offset entry points remain and delegate. API: get(i) now returns an allocated copy and copyTo(i, dest) fills reusable storage. The public constructor copies its vectors. The getFlatPackedVectors() cache, which duplicated 1-bit codes, is removed because the 1-bit SIMD scorer now reads the sign pages directly. The layout and memory cost are documented in docs/ash-configuration.md. --- docs/ash-configuration.md | 19 ++ docs/native-ash-preparation.md | 20 +- .../jvector/graph/disk/feature/FusedASH.java | 28 +- .../jvector/quantization/ASHCodes.java | 265 +++++++++++++++ .../jvector/quantization/ASHScorer.java | 105 +++--- .../quantization/ASHSymmetricScorer.java | 147 ++++++--- .../jvector/quantization/ASHVectors.java | 308 +++++++----------- .../quantization/AsymmetricHashing.java | 88 +++-- .../jbellis/jvector/vector/ASHLutScoring.java | 16 +- .../jvector/vector/VectorUtilSupport.java | 44 ++- .../jvector/vector/NativeASHKernels.java | 41 ++- .../vector/NativeASHSymmetricKernels.java | 56 ++++ .../vector/NativeVectorUtilSupport.java | 25 +- .../vector/NativeASHSymmetricDotAtTest.java | 94 ++++++ .../jvector/quantization/TestASHCodes.java | 279 ++++++++++++++++ .../quantization/TestASHMemoryAccounting.java | 67 ++-- .../jvector/vector/PanamaASHKernels.java | 7 +- .../vector/PanamaVectorUtilSupport.java | 70 ++-- 18 files changed, 1287 insertions(+), 392 deletions(-) create mode 100644 jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHCodes.java create mode 100644 jvector-native/src/test/java/io/github/jbellis/jvector/vector/NativeASHSymmetricDotAtTest.java create mode 100644 jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestASHCodes.java diff --git a/docs/ash-configuration.md b/docs/ash-configuration.md index 2b351b59b..ac0a002c1 100644 --- a/docs/ash-configuration.md +++ b/docs/ash-configuration.md @@ -78,6 +78,25 @@ Construction policy can change the graph, recall and QPS. Use fresh indexes when comparing modes: the graph cache key does not include this JVM property, so changing it does not invalidate an existing cached graph. +### In-memory code layout + +`ASHVectors` keeps one copy of the codes, in compact pages. Each vector's code +body (sign words, projection code or extra bits) sits back to back with the +others, with no per-vector object, array or padding. Scale, offset and landmark +are decoded once into three parallel arrays. Every scorer, symmetric and +asymmetric, reads the codes in place, and the encoder writes straight into the +pages. + +Retained memory per vector is the serialized size plus 4 bytes, because the two +binary16 header fields are held as floats: for example 101 bytes on disk and +105 bytes in memory for 768-D at 32x. Pages hold up to 256 KiB, which keeps +them below G1's smallest humongous-object threshold so the collector never +rounds them up to whole regions. `ASHVectors.get(i)` returns an allocated copy; +`copyTo(i, dest)` fills reusable storage. Optional caches built for block +scoring (`blockScorerFor` with a block size) hold a second, interleaved copy and +are charged in `ramBytesUsed()` when created; graph construction and search do +not build them. + ## Query projection and scoring | JVM property | Default | Optional values and definition | diff --git a/docs/native-ash-preparation.md b/docs/native-ash-preparation.md index f1a612f68..b6652e6b5 100644 --- a/docs/native-ash-preparation.md +++ b/docs/native-ash-preparation.md @@ -371,17 +371,15 @@ Dense row and block matrices use their corresponding storage layouts; other `RealMatrix` implementations retain a dense storage estimate without calling `getData()`, which could materialize a matrix copy. -`ASHVectors.ramBytesUsed()` includes its compressor once, its reference array, -per-vector objects and primitive arrays, cached scales/offsets/landmark IDs, -and any retained packed or symmetric-scoring caches. It uses heap capacity and -array/object layout estimates, not serialized code sizes. Vector totals are -accumulated in the existing constructor pass. Packed-block totals are charged -when a layout is successfully created and include a small conservative allowance -for map bookkeeping. Polling the estimate is constant-time with respect to vector -count, even after packed caches are created. Repeated ordinals sharing vector -objects or backing arrays are conservatively charged per ordinal; no large -identity map is allocated to deduplicate caller-owned aliases. The vectors and -model arrays must remain immutable after construction, as required by scoring. +`ASHVectors.ramBytesUsed()` includes its compressor once, its compact code +pages and decoded scale/offset/landmark arrays, and any retained packed or +symmetric-scoring caches. It uses heap array layout estimates, not serialized +code sizes. Code storage is sized when the pages are allocated. Packed-block +totals are charged when a layout is successfully created and include a small +conservative allowance for map bookkeeping. Polling the estimate is +constant-time with respect to vector count, even after packed caches are +created. Vectors passed to the public constructor are copied, so repeated +ordinals sharing one input vector are charged per ordinal. The model arrays must remain immutable after construction, as required by scoring. Cached fused ASH sources report their owned source object and quantized vector, including its primitive arrays, without charging the shared compressor. Shared diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/graph/disk/feature/FusedASH.java b/jvector-base/src/main/java/io/github/jbellis/jvector/graph/disk/feature/FusedASH.java index a8110c989..52e5aeceb 100644 --- a/jvector-base/src/main/java/io/github/jbellis/jvector/graph/disk/feature/FusedASH.java +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/graph/disk/feature/FusedASH.java @@ -64,6 +64,8 @@ public class FusedASH extends AbstractFeature implements FusedFeature { private final Supplier queryScratchFactory; private final ThreadLocal writeScratch; private final ThreadLocal packingRefs; + // Reusable per-thread copies of neighbor codes read from compact ASHVectors storage. + private final ThreadLocal neighborCopies; public FusedASH(int maxDegree, AsymmetricHashing ash) { this(maxDegree, ash, FusedASHLayout.chooseBlockSize(maxDegree)); @@ -91,6 +93,12 @@ public FusedASH(int maxDegree, AsymmetricHashing ash, int blockSize) { this.queryScratchFactory = () -> new FusedASHDecoder.QueryScratch(ash, maxDegree, blockSize); this.writeScratch = ThreadLocal.withInitial(() -> new byte[featureSize]); this.packingRefs = ThreadLocal.withInitial(() -> new AsymmetricHashing.QuantizedVector[maxDegree]); + this.neighborCopies = ThreadLocal.withInitial(() -> { + var copies = new AsymmetricHashing.QuantizedVector[maxDegree]; + for (int i = 0; i < maxDegree; i++) + copies[i] = AsymmetricHashing.QuantizedVector.createEmpty(ash.quantizedDim, ash.bitsPerDimension); + return copies; + }); } @Override @@ -166,6 +174,7 @@ public void writeInline(IndexWriter out, Feature.State state_) throws IOExceptio Arrays.fill(scratch, (byte) 0); var refs = ash.bitsPerDimension == 1 ? null : packingRefs.get(); + var copies = state.vectors == null ? null : neighborCopies.get(); var neighbors = state.view.getNeighborsIterator(0, state.nodeId); int count = 0; try { @@ -175,7 +184,14 @@ public void writeInline(IndexWriter out, Feature.State state_) throws IOExceptio throw new IOException("Node " + state.nodeId + " has more than maxDegree=" + maxDegree + " neighbors"); } - var compressed = state.compressedVectorFunction.apply(node); + AsymmetricHashing.QuantizedVector compressed; + if (copies != null) { + // Copy out of the compact store; no allocation per neighbor. + compressed = copies[count]; + state.vectors.copyTo(node, compressed); + } else { + compressed = state.compressedVectorFunction.apply(node); + } if (ash.bitsPerDimension == 1) { packForFused(scratch, count, compressed); } else { @@ -331,18 +347,26 @@ public static class State implements Feature.State { public final ImmutableGraphIndex.View view; public final IntFunction compressedVectorFunction; public final int nodeId; + /** When non-null, neighbor codes are copied from here into reusable storage instead. */ + final ASHVectors vectors; public State(ImmutableGraphIndex.View view, ASHVectors ashVectors, int nodeId) { - this(view, ashVectors::get, nodeId); + this(view, ashVectors::get, nodeId, ashVectors); } public State( ImmutableGraphIndex.View view, IntFunction compressedVectorFunction, int nodeId) { + this(view, compressedVectorFunction, nodeId, null); + } + + private State(ImmutableGraphIndex.View view, IntFunction compressedVectorFunction, + int nodeId, ASHVectors vectors) { this.view = view; this.compressedVectorFunction = compressedVectorFunction; this.nodeId = nodeId; + this.vectors = vectors; } } } diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHCodes.java b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHCodes.java new file mode 100644 index 000000000..0f41e8a7b --- /dev/null +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHCodes.java @@ -0,0 +1,265 @@ +/* + * 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 io.github.jbellis.jvector.disk.ByteReadable; +import io.github.jbellis.jvector.disk.IndexWriter; +import io.github.jbellis.jvector.disk.RandomAccessReader; +import io.github.jbellis.jvector.util.Accountable; +import io.github.jbellis.jvector.util.RamUsageEstimator; + +import java.io.IOException; +import java.util.Arrays; + +/** + * The in-memory store of an {@link ASHVectors} collection: every stored code, and nothing else. + * + *

    Codes sit back to back, at a fixed stride and with no padding, in pages of primitive arrays. There is + * no per-vector object or array, so the heap cost per vector is its code body plus the decoded header + * ({@value #HEADER_RAM_BYTES} bytes in the parallel {@link #scales}, {@link #offsets} and {@link #landmarks} + * arrays) and a negligible share of the page headers.

    + * + *
      + *
    • Sign words, used by 1-bit codes and by the generic sign-plus-extra-bit widths (3, 5..9 bits), are in + * {@code long} pages, {@link #signWords} per vector.
    • + *
    • Byte bodies, which are the canonical projection codes at 2 and 4 bits and the extra bits of the + * generic widths, are in {@code byte} pages, {@link #bodyBytes} per vector.
    • + *
    + * + *

    Both kinds of page hold the same power-of-two number of vectors, so vector {@code i} is in page + * {@code i >>> pageShift} at slot {@code i & pageMask}. Pages are kept under G1's smallest humongous + * threshold (half of a 1 MiB region) so the collector never rounds them up to whole regions; the last + * page is trimmed to the vectors it holds.

    + * + *

    Concurrency: a store is filled once, by the encoder (tasks own disjoint vectors and are joined + * before the store is published) or by the loader, and is read-only afterward.

    + */ +final class ASHCodes implements Accountable { + /** Target page size in bytes; see the class comment. */ + static final int DEFAULT_PAGE_BYTES = 256 << 10; + + /** Decoded header bytes per vector: a float scale, a float offset and a byte landmark. */ + static final int HEADER_RAM_BYTES = Float.BYTES + Float.BYTES + Byte.BYTES; + + final int size; + final int quantizedDim; + final int bitsPerDimension; + /** Sign words per vector; zero for the 2/4-bit projection codes. */ + final int signWords; + /** Body bytes per vector; zero for 1-bit codes. */ + final int bodyBytes; + final int pageShift; + final int pageMask; + /** Null when {@link #signWords} is zero. */ + final long[][] signPages; + /** Null when {@link #bodyBytes} is zero. */ + final byte[][] bodyPages; + /** Binary16-rounded values decoded to float, as serialized. */ + final float[] scales; + final float[] offsets; + final byte[] landmarks; + private final long ramBytesUsed; + + private ASHCodes(int size, int quantizedDim, int bitsPerDimension, int pageBytes) { + if (size < 0) throw new IllegalArgumentException("size must be nonnegative"); + if (pageBytes <= 0) throw new IllegalArgumentException("pageBytes must be positive"); + this.size = size; + this.quantizedDim = quantizedDim; + this.bitsPerDimension = bitsPerDimension; + this.signWords = AsymmetricHashing.QuantizedVector.signWordsForDims(quantizedDim, bitsPerDimension); + this.bodyBytes = AsymmetricHashing.QuantizedVector.bodyBytesForDims(quantizedDim, bitsPerDimension); + int strideBytes = Math.max(1, Math.max(Math.multiplyExact(signWords, Long.BYTES), bodyBytes)); + // A power of two, so locating a vector is a shift and a mask. + this.pageShift = 31 - Integer.numberOfLeadingZeros(Math.max(1, pageBytes / strideBytes)); + this.pageMask = (1 << pageShift) - 1; + int pages = Math.toIntExact((size + (long) pageMask) >>> pageShift); + this.signPages = signWords == 0 ? null : new long[pages][]; + this.bodyPages = bodyBytes == 0 ? null : new byte[pages][]; + long bytes = RamUsageEstimator.shallowSizeOfInstance(ASHCodes.class); + for (int p = 0; p < pages; p++) { + int vectors = (int) Math.min(pageMask + 1L, size - ((long) p << pageShift)); + if (signPages != null) { + signPages[p] = new long[Math.multiplyExact(vectors, signWords)]; + bytes += RamUsageEstimator.sizeOf(signPages[p]); + } + if (bodyPages != null) { + bodyPages[p] = new byte[Math.multiplyExact(vectors, bodyBytes)]; + bytes += RamUsageEstimator.sizeOf(bodyPages[p]); + } + } + this.scales = new float[size]; + this.offsets = new float[size]; + this.landmarks = new byte[size]; + if (signPages != null) bytes += RamUsageEstimator.shallowSizeOf(signPages); + if (bodyPages != null) bytes += RamUsageEstimator.shallowSizeOf(bodyPages); + bytes += RamUsageEstimator.sizeOf(scales) + RamUsageEstimator.sizeOf(offsets) + + RamUsageEstimator.sizeOf(landmarks); + this.ramBytesUsed = bytes; + } + + /** An all-zero store for {@code size} vectors, to be filled with {@link #set}. */ + static ASHCodes allocate(int size, int quantizedDim, int bitsPerDimension) { + return allocate(size, quantizedDim, bitsPerDimension, DEFAULT_PAGE_BYTES); + } + + /** As {@link #allocate(int, int, int)} with a chosen page size; tests use small pages. */ + static ASHCodes allocate(int size, int quantizedDim, int bitsPerDimension, int pageBytes) { + return new ASHCodes(size, quantizedDim, bitsPerDimension, pageBytes); + } + + /** Copies the given vectors, which must all have this model's shape. Vectors are not retained. */ + static ASHCodes copyOf(AsymmetricHashing.QuantizedVector[] vectors, int quantizedDim, int bitsPerDimension) { + ASHCodes codes = allocate(vectors.length, quantizedDim, bitsPerDimension); + for (int i = 0; i < vectors.length; i++) codes.set(i, vectors[i]); + return codes; + } + + long[] signPage(int node) { return signPages[node >>> pageShift]; } + + int signOffset(int node) { return (node & pageMask) * signWords; } + + byte[] bodyPage(int node) { return bodyPages[node >>> pageShift]; } + + int bodyOffset(int node) { return (node & pageMask) * bodyBytes; } + + /** Stores {@code vector} at {@code node}. For filling only; see the class comment. */ + void set(int node, AsymmetricHashing.QuantizedVector vector) { + java.util.Objects.checkIndex(node, size); + if (vector.binaryVector.length < signWords || vector.extraBits.length < bodyBytes) + throw new IllegalArgumentException("ASH vector " + node + " does not match the model's code shape"); + if (signWords != 0) System.arraycopy(vector.binaryVector, 0, signPage(node), signOffset(node), signWords); + if (bodyBytes != 0) System.arraycopy(vector.extraBits, 0, bodyPage(node), bodyOffset(node), bodyBytes); + scales[node] = vector.scale; + offsets[node] = vector.offset; + landmarks[node] = vector.landmark; + } + + /** Copies the stored vector into {@code dest}, replacing its arrays if their lengths differ. */ + void copyTo(int node, AsymmetricHashing.QuantizedVector dest) { + java.util.Objects.checkIndex(node, size); + if (dest.binaryVector == null || dest.binaryVector.length != signWords) dest.binaryVector = new long[signWords]; + if (dest.extraBits == null || dest.extraBits.length != bodyBytes) dest.extraBits = new byte[bodyBytes]; + if (signWords != 0) System.arraycopy(signPage(node), signOffset(node), dest.binaryVector, 0, signWords); + if (bodyBytes != 0) System.arraycopy(bodyPage(node), bodyOffset(node), dest.extraBits, 0, bodyBytes); + dest.scale = scales[node]; + dest.offset = offsets[node]; + dest.landmark = landmarks[node]; + } + + /** A new, independent copy of the stored vector. */ + AsymmetricHashing.QuantizedVector get(int node) { + var vector = AsymmetricHashing.QuantizedVector.createEmpty(quantizedDim, bitsPerDimension); + copyTo(node, vector); + return vector; + } + + /** A store holding vector {@code newToOld[i]} at {@code i}. */ + ASHCodes permute(int[] newToOld) { + if (newToOld.length != size) throw new IllegalArgumentException("Permutation must cover every vector"); + ASHCodes result = new ASHCodes(size, quantizedDim, bitsPerDimension, (pageMask + 1) * strideBytes()); + for (int i = 0; i < size; i++) { + int old = newToOld[i]; + if (signWords != 0) + System.arraycopy(signPage(old), signOffset(old), result.signPage(i), result.signOffset(i), signWords); + if (bodyBytes != 0) + System.arraycopy(bodyPage(old), bodyOffset(old), result.bodyPage(i), result.bodyOffset(i), bodyBytes); + result.scales[i] = scales[old]; + result.offsets[i] = offsets[old]; + result.landmarks[i] = landmarks[old]; + } + return result; + } + + private int strideBytes() { + return Math.max(1, Math.max(signWords * Long.BYTES, bodyBytes)); + } + + /** Writes every vector in the same format as {@link AsymmetricHashing.QuantizedVector#write}. */ + void write(IndexWriter out) throws IOException { + for (int i = 0; i < size; i++) { + AsymmetricHashing.writeFloat16(out, scales[i]); + AsymmetricHashing.writeFloat16(out, offsets[i]); + out.writeByte(landmarks[i]); + if (signWords != 0) { + long[] page = signPage(i); + for (int w = 0, at = signOffset(i); w < signWords; w++) out.writeLong(page[at + w]); + } + if (bodyBytes != 0) out.write(bodyPage(i), bodyOffset(i), bodyBytes); + } + } + + /** Reads {@code size} vectors written by {@link #write} or {@link AsymmetricHashing.QuantizedVector#write}. */ + static ASHCodes load(RandomAccessReader in, int size, int quantizedDim, int bitsPerDimension) throws IOException { + if (!(in instanceof ByteReadable)) throw new IOException("ASH requires ByteReadable reader for landmark"); + ASHCodes codes = allocate(size, quantizedDim, bitsPerDimension); + byte[] body = new byte[codes.bodyBytes]; + for (int i = 0; i < size; i++) { + codes.scales[i] = AsymmetricHashing.readFloat16(in); + codes.offsets[i] = AsymmetricHashing.readFloat16(in); + codes.landmarks[i] = ((ByteReadable) in).readByte(); + if (codes.signWords != 0) { + long[] page = codes.signPage(i); + for (int w = 0, at = codes.signOffset(i); w < codes.signWords; w++) page[at + w] = in.readLong(); + } + if (codes.bodyBytes != 0) { + in.readFully(body); + System.arraycopy(body, 0, codes.bodyPage(i), codes.bodyOffset(i), codes.bodyBytes); + } + } + return codes; + } + + /** Fixed at allocation: pages, header arrays and this object. */ + @Override + public long ramBytesUsed() { + return ramBytesUsed; + } + + /** Same vectors in the same order, comparing headers by their float bits. */ + boolean contentEquals(ASHCodes that) { + if (size != that.size || signWords != that.signWords || bodyBytes != that.bodyBytes) return false; + if (!Arrays.equals(landmarks, that.landmarks)) return false; + for (int i = 0; i < size; i++) { + if (Float.floatToIntBits(scales[i]) != Float.floatToIntBits(that.scales[i]) + || Float.floatToIntBits(offsets[i]) != Float.floatToIntBits(that.offsets[i])) return false; + if (signWords != 0 && !Arrays.equals(signPage(i), signOffset(i), signOffset(i) + signWords, + that.signPage(i), that.signOffset(i), that.signOffset(i) + signWords)) return false; + if (bodyBytes != 0 && !Arrays.equals(bodyPage(i), bodyOffset(i), bodyOffset(i) + bodyBytes, + that.bodyPage(i), that.bodyOffset(i), that.bodyOffset(i) + bodyBytes)) return false; + } + return true; + } + + /** Consistent with {@link #contentEquals}, independent of page size. */ + int contentHash() { + int h = size; + for (int i = 0; i < size; i++) { + h = 31 * h + Float.floatToIntBits(scales[i]); + h = 31 * h + Float.floatToIntBits(offsets[i]); + h = 31 * h + landmarks[i]; + if (signWords != 0) { + long[] page = signPage(i); + for (int w = 0, at = signOffset(i); w < signWords; w++) h = 31 * h + Long.hashCode(page[at + w]); + } + if (bodyBytes != 0) { + byte[] page = bodyPage(i); + for (int b = 0, at = bodyOffset(i); b < bodyBytes; b++) h = 31 * h + page[at + b]; + } + } + return h; + } +} diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHScorer.java b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHScorer.java index bd33fa5b3..9b474da6a 100644 --- a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHScorer.java +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHScorer.java @@ -105,18 +105,23 @@ public ASHScoreFunction scoreFunctionFor( * Scorer for dot product approximation. */ ASHScoreFunction scoreFunctionFor(QueryPrecompute qp) { + return forVectors(codeScorerFor(qp)); + } + + /** As {@link #scoreFunctionFor(QueryPrecompute)}, scoring codes in place. */ + CodeScorer codeScorerFor(QueryPrecompute qp) { if (ash.bitsPerDimension == 1) { - return oneBitDotProductScoreFunction(qp); + return oneBitDotProductScorer(qp); } if (AsymmetricHashing.usesFastScanProjectionCode(ash.bitsPerDimension)) { - return fastScanProjectionDotProductScoreFunction(qp); + return fastScanProjectionDotProductScorer(qp); } if (ASHVectors.AshSingleKernel.fromProperty() == ASHVectors.AshSingleKernel.SIMD) { throw new UnsupportedOperationException("SIMD ASH projection scoring supports 2 and 4 bits per dimension"); } - return genericMultibitDotProductScoreFunction(qp); + return genericMultibitDotProductScorer(qp); } /** Reuses a fused scorer's table for direct nodes when the backend supports it. */ @@ -137,31 +142,39 @@ ASHScoreFunction scoreFunctionFor(QueryPrecompute qp, float[] lut) { /** Prepared encoded-query state; uses the same arithmetic as the raw-query scorer. */ ASHScoreFunction scoreFunctionFor(QueryPrecompute qp, boolean simd) { - if (ash.bitsPerDimension == 1) return oneBitDotProductScoreFunction(qp); + return forVectors(codeScorerFor(qp, simd)); + } + + /** As {@link #scoreFunctionFor(QueryPrecompute, boolean)}, scoring codes in place. */ + CodeScorer codeScorerFor(QueryPrecompute qp, boolean simd) { + if (ash.bitsPerDimension == 1) return oneBitDotProductScorer(qp); if (AsymmetricHashing.usesFastScanProjectionCode(ash.bitsPerDimension)) { - return fastScanProjectionDotProductScoreFunction(qp, simd); + return fastScanProjectionDotProductScorer(qp, simd); } if (simd) throw new UnsupportedOperationException("SIMD ASH requires bits 1, 2, or 4"); - return genericMultibitDotProductScoreFunction(qp); + return genericMultibitDotProductScorer(qp); } - private ASHScoreFunction oneBitDotProductScoreFunction(QueryPrecompute qp) { + /** Adapts a code scorer to standalone vectors, whose arrays start at offset zero. */ + static ASHScoreFunction forVectors(CodeScorer scorer) { + return v -> scorer.dot(v.binaryVector, 0, v.extraBits, 0, v.scale, v.offset, v.landmark & 0xFF); + } + + private CodeScorer oneBitDotProductScorer(QueryPrecompute qp) { final int d = qp.d; final int C = qp.C; final int words = AsymmetricHashing.QuantizedVector.wordsForDims(d); - return (AsymmetricHashing.QuantizedVector v) -> { - final int c = v.landmark & 0xFF; // unsigned [0,C) + return (bits, bitsOffset, body, bodyOffset, scale, offset, c) -> { assert c < C : "Invalid landmark id " + c + " for landmarkCount=" + C; // maskedAdd = , where b ∈ {0,1}^d is stored as packed longs. float maskedAdd = 0f; final int tildeBase = c * d; - final long[] bits = v.binaryVector; int dimBase = 0; for (int w = 0; w < words && dimBase < d; w++, dimBase += 64) { - long word = bits[w]; + long word = bits[bitsOffset + w]; while (word != 0L) { int bit = Long.numberOfTrailingZeros(word); int idx = dimBase + bit; @@ -174,23 +187,28 @@ private ASHScoreFunction oneBitDotProductScoreFunction(QueryPrecompute qp) { // 1-bit ASH dot-product approximation: // scale * (2 - ) + + offset - return v.scale * (2f * maskedAdd - qp.sumTildeQByLandmark[c]) + return scale * (2f * maskedAdd - qp.sumTildeQByLandmark[c]) + qp.dotQMuByLandmark[c] - + v.offset; + + offset; }; } /** Independent scalar oracle for the reference block-scoring API. */ ASHScoreFunction scalarScoreFunctionFor(VectorFloat query) { + return forVectors(scalarCodeScorerFor(query)); + } + + /** As {@link #scalarScoreFunctionFor}, scoring codes in place. */ + CodeScorer scalarCodeScorerFor(VectorFloat query) { QueryPrecompute qp = precomputeQuery(query); - if (ash.bitsPerDimension == 1) return oneBitDotProductScoreFunction(qp); + if (ash.bitsPerDimension == 1) return oneBitDotProductScorer(qp); if (AsymmetricHashing.usesFastScanProjectionCode(ash.bitsPerDimension)) { - return fastScanProjectionDotProductScoreFunction(qp, false); + return fastScanProjectionDotProductScorer(qp, false); } - return genericMultibitDotProductScoreFunction(qp); + return genericMultibitDotProductScorer(qp); } - private ASHScoreFunction fastScanProjectionDotProductScoreFunction(QueryPrecompute qp) { + private CodeScorer fastScanProjectionDotProductScorer(QueryPrecompute qp) { final var backend = VectorizationProvider.getInstance().getVectorUtilSupport(); final var mode = ASHVectors.AshSingleKernel.fromProperty(); if (mode == ASHVectors.AshSingleKernel.SIMD && !backend.supportsAshProjectionScoring()) { @@ -198,10 +216,10 @@ private ASHScoreFunction fastScanProjectionDotProductScoreFunction(QueryPrecompu + backend.getClass().getName() + " does not support projection scoring"); } final boolean simd = mode != ASHVectors.AshSingleKernel.SCALAR && backend.supportsAshProjectionScoring(); - return fastScanProjectionDotProductScoreFunction(qp, simd); + return fastScanProjectionDotProductScorer(qp, simd); } - private ASHScoreFunction fastScanProjectionDotProductScoreFunction(QueryPrecompute qp, boolean simd) { + private CodeScorer fastScanProjectionDotProductScorer(QueryPrecompute qp, boolean simd) { final int d = qp.d; final int C = qp.C; final int bitsPerDimension = ash.bitsPerDimension; @@ -217,36 +235,33 @@ private ASHScoreFunction fastScanProjectionDotProductScoreFunction(QueryPrecompu final int groups = FusedASHLayout.codeGroups(d, bitsPerDimension); final float[] lut = new float[Math.multiplyExact(groups, 16)]; FusedASHLayout.buildQueryLut(qp.qProj, d, bitsPerDimension, lut); - return v -> v.scale * backend.ashCanonicalLutDot(v.extraBits, groups, lut) - + qp.dotQMuByLandmark[v.landmark & 255] + v.offset; + return (bits, bitsOffset, code, codeOffset, scale, offset, c) -> + scale * backend.ashCanonicalLutDot(code, codeOffset, groups, lut) + + qp.dotQMuByLandmark[c] + offset; } // Keep tuning dispatch out of the per-vector default kernel. Its compact // compilation shape matters for end-to-end scoring throughput on HotSpot. if (simd && backend.usesAshProjectionTuning()) { - return v -> v.scale * backend.ashProjectionDotTuned(qp.qProj, v.extraBits, d, bitsPerDimension) - + qp.dotQMuByLandmark[v.landmark & 255] + v.offset; + return (bits, bitsOffset, code, codeOffset, scale, offset, c) -> + scale * backend.ashProjectionDotTuned(qp.qProj, code, codeOffset, d, bitsPerDimension) + + qp.dotQMuByLandmark[c] + offset; } - return (AsymmetricHashing.QuantizedVector v) -> { - final int c = v.landmark & 0xFF; // unsigned [0,C) + return (bits, bitsOffset, code, codeOffset, scale, offset, c) -> { assert c < C : "Invalid landmark id " + c + " for landmarkCount=" + C; // C++ projection-mode scoring: // score = scale * + + stored_offset // stored_offset already includes -scale * . - float ip = simd ? backend.ashProjectionDot(qp.qProj, v.extraBits, d, bitsPerDimension) - : AsymmetricHashing.dotProjectionCode( - qp.qProj, - v.extraBits, - d, - bitsPerDimension); - - return v.scale * ip + qp.dotQMuByLandmark[c] + v.offset; + float ip = simd ? backend.ashProjectionDot(qp.qProj, code, codeOffset, d, bitsPerDimension) + : AsymmetricHashing.dotProjectionCode(qp.qProj, code, codeOffset, d, bitsPerDimension); + + return scale * ip + qp.dotQMuByLandmark[c] + offset; }; } - private ASHScoreFunction genericMultibitDotProductScoreFunction(QueryPrecompute qp) { + private CodeScorer genericMultibitDotProductScorer(QueryPrecompute qp) { final int d = qp.d; final int C = qp.C; final int bitsPerDimension = ash.bitsPerDimension; @@ -254,18 +269,15 @@ private ASHScoreFunction genericMultibitDotProductScoreFunction(QueryPrecompute final int signMagnitudeOffset = 1 << exBits; final float centerBias = -(signMagnitudeOffset - 0.5f); - return (AsymmetricHashing.QuantizedVector v) -> { - final int c = v.landmark & 0xFF; // unsigned [0,C) + return (signBits, signOffset, extraBits, extraOffset, scale, offset, c) -> { assert c < C : "Invalid landmark id " + c + " for landmarkCount=" + C; final int tildeBase = c * d; - final long[] signBits = v.binaryVector; - final byte[] extraBits = v.extraBits; float ip = 0f; for (int j = 0; j < d; j++) { - int sign = AsymmetricHashing.QuantizedVector.getBit(signBits, j) ? 1 : 0; - int extra = AsymmetricHashing.QuantizedVector.readExtraCode(extraBits, j, exBits); + int sign = AsymmetricHashing.QuantizedVector.getBit(signBits, signOffset, j) ? 1 : 0; + int extra = AsymmetricHashing.QuantizedVector.readExtraCode(extraBits, extraOffset, j, exBits); // Matches the C++ generic scalar reconstruction formula: // (sign << exBits) + extra - (2^exBits - 0.5) @@ -273,7 +285,7 @@ private ASHScoreFunction genericMultibitDotProductScoreFunction(QueryPrecompute ip += qp.tildeQPool[tildeBase + j] * code; } - return v.scale * ip + qp.dotQMuByLandmark[c] + v.offset; + return scale * ip + qp.dotQMuByLandmark[c] + offset; }; } @@ -360,4 +372,15 @@ static final class QueryPrecompute { public interface ASHScoreFunction { float similarityTo(AsymmetricHashing.QuantizedVector vector2); } + + /** + * Raw dot product of a prepared query with one code read in place: sign words from {@code bits} + * at {@code bitsOffset} and the byte body from {@code body} at {@code bodyOffset} (either is + * unused when the bit width stores none), with the code's decoded header and unsigned landmark. + * Lets {@link ASHVectors} score its compact pages and standalone vectors with one formula. + */ + @FunctionalInterface + interface CodeScorer { + float dot(long[] bits, int bitsOffset, byte[] body, int bodyOffset, float scale, float offset, int landmark); + } } diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHSymmetricScorer.java b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHSymmetricScorer.java index 0039ba208..c17827261 100644 --- a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHSymmetricScorer.java +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHSymmetricScorer.java @@ -24,12 +24,18 @@ * Symmetric ASH scoring from already encoded operands. Graph construction remains C=1. * C=1 uses Appendix B directly. Multiple landmarks use the same header-calibrated * decoded dot product, including cross-landmark terms, without projecting raw vectors. + * + *

    Stored operands are read in place from the {@link ASHCodes} pages of their + * {@link ASHVectors}; standalone {@link AsymmetricHashing.QuantizedVector} operands are read + * from their own arrays. Both go through the same kernels, so scores do not depend on where + * an operand is stored.

    */ public final class ASHSymmetricScorer { public enum Kernel { AUTO, SCALAR, SIMD } private static final java.lang.invoke.VarHandle SYMMETRIC_WORD = java.lang.invoke.MethodHandles.byteArrayViewVarHandle(long[].class, java.nio.ByteOrder.LITTLE_ENDIAN); private static final short[] DOT_4 = nibbleProducts(4); private final ASHVectors vectors; + private final ASHCodes codes; private final AsymmetricHashing ash; private final int dimensions; private final int bits; @@ -48,6 +54,7 @@ public ASHSymmetricScorer(ASHVectors vectors) { public ASHSymmetricScorer(ASHVectors vectors, Kernel kernel) { this.vectors = vectors; + codes = vectors.codes; ash = vectors.getCompressor(); dimensions = ash.quantizedDim; bits = ash.bitsPerDimension; @@ -64,8 +71,11 @@ public ASHSymmetricScorer(ASHVectors vectors, Kernel kernel) { if (vectors.symmetricOffsets != null) { centeredOffsets = vectors.symmetricOffsets; } else { - centeredOffsets = new float[vectors.count()]; - for (int i = 0; i < centeredOffsets.length; i++) centeredOffsets[i] = centeredOffset(vectors.get(i)); + centeredOffsets = new float[codes.size]; + for (int i = 0; i < centeredOffsets.length; i++) { + centeredOffsets[i] = ash.centeredOffset(codes.scales[i], codes.offsets[i], codes.landmarks[i], + codes.bodyPage(i), codes.bodyOffset(i)); + } } } else { centeredOffsets = null; @@ -80,6 +90,8 @@ public ASHSymmetricScorer(ASHVectors vectors, Kernel kernel) { private boolean selectSimd(Kernel kernel) { boolean supported = (bits == 1 || bits == 2 || bits == 4) && backend.supportsAshSymmetricScoring(); + // Pair kernels read stored codes at page offsets. + if (bits == 2 || bits == 4) supported &= backend.supportsAshSymmetricDotAt(); if (landmarks != 1) supported &= bits == 1 ? backend.supportsAshMaskedLoad() : backend.supportsAshProjectionScoring(); if (kernel == Kernel.SIMD && !supported) throw new UnsupportedOperationException("Symmetric SIMD requires a vector backend and bits 1, 2, or 4"); return kernel != Kernel.SCALAR && supported; @@ -91,46 +103,78 @@ private float centeredOffset(AsymmetricHashing.QuantizedVector vector) { return ash.centeredOffset(vector); } - private float offset(int node) { return centeredOffsets == null ? vectors.get(node).offset : centeredOffsets[node]; } + private float offset(int node) { return centeredOffsets == null ? codes.offsets[node] : centeredOffsets[node]; } + + private long[] signs(int node) { return codes.signWords == 0 ? null : codes.signPage(node); } - private float packedDot(AsymmetricHashing.QuantizedVector a, AsymmetricHashing.QuantizedVector b) { - return simd ? backend.ashSymmetricDot(a.binaryVector, a.extraBits, b.binaryVector, b.extraBits, dimensions, bits) - : codeDot(a, b, dimensions, bits); + private byte[] body(int node) { return codes.bodyBytes == 0 ? null : codes.bodyPage(node); } + + /** Packed-code dot product of two codes, each given by its sign-word and body arrays and offsets. */ + private float pairDot(long[] aSigns, int aSignOffset, byte[] aBody, int aBodyOffset, + long[] bSigns, int bSignOffset, byte[] bBody, int bBodyOffset) { + if (simd) { + return bits == 1 ? backend.ashSymmetricBinaryDotAt(aSigns, aSignOffset, bSigns, bSignOffset, dimensions) + : backend.ashSymmetricDotAt(aBody, aBodyOffset, bBody, bBodyOffset, dimensions, bits); + } + return codeDot(aSigns, aSignOffset, aBody, aBodyOffset, bSigns, bSignOffset, bBody, bBodyOffset, dimensions, bits); } /** Raw symmetric dot product. No encoding or original-dimensional projection. */ public float dotProduct(int a, int b) { - var x = vectors.get(a); var y = vectors.get(b); - int ca = x.landmark & 255, cb = y.landmark & 255; + long[] as = signs(a), bs = signs(b); + byte[] ab = body(a), bb = body(b); + int aso = codes.signOffset(a), abo = codes.bodyOffset(a), bso = codes.signOffset(b), bbo = codes.bodyOffset(b); + int ca = codes.landmarks[a] & 255, cb = codes.landmarks[b] & 255; float crossA = 0, crossB = 0; if (ca != cb) { for (int j = 0; j < dimensions; j++) { float delta = ash.landmarkProj[cb][j] - ash.landmarkProj[ca][j]; - crossA += component(x, j, bits) * delta; - crossB += component(y, j, bits) * -delta; + crossA += component(as, aso, ab, abo, j, bits) * delta; + crossB += component(bs, bso, bb, bbo, j, bits) * -delta; } } - return (x.scale * y.scale) * packedDot(x, y) - + ((x.scale * crossA + y.scale * crossB) + (offset(a) + offset(b))) + landmarkDots[ca][cb]; + float xs = codes.scales[a], ys = codes.scales[b]; + return (xs * ys) * pairDot(as, aso, ab, abo, bs, bso, bb, bbo) + + ((xs * crossA + ys * crossB) + (offset(a) + offset(b))) + landmarkDots[ca][cb]; } public ScoreFunction.ApproximateScoreFunction scoreFunctionFor(int node) { if (landmarks != 1) return scoreFunctionFor(vectors.get(node)); - return c1ScoreFunction(vectors.get(node), offset(node)); + return c1ScoreFunction(signs(node), codes.signOffset(node), body(node), codes.bodyOffset(node), + codes.scales[node], offset(node)); } /** The operand must already be encoded with this instance's compressor. */ public ScoreFunction.ApproximateScoreFunction scoreFunctionFor(AsymmetricHashing.QuantizedVector query) { if (landmarks != 1) return vectors.scoreFunctionFor(preparedState(query), simd); - return c1ScoreFunction(query, centeredOffset(query)); + return c1ScoreFunction(query.binaryVector, 0, query.extraBits, 0, query.scale, centeredOffset(query)); } - private ScoreFunction.ApproximateScoreFunction c1ScoreFunction( - AsymmetricHashing.QuantizedVector query, float queryOffset) { + private ScoreFunction.ApproximateScoreFunction c1ScoreFunction(long[] querySigns, int querySignOffset, + byte[] queryBody, int queryBodyOffset, + float queryScale, float queryOffset) { + final ASHCodes c = codes; + final float[] scales = c.scales; + final float landmarkDot = landmarkDots[0][0]; + if (simd && (bits == 2 || bits == 4)) { + // The graph-construction hot path: one offset kernel call per target, no object loads. + final byte[][] pages = c.bodyPages; + final int shift = c.pageShift, mask = c.pageMask, stride = c.bodyBytes; + final float[] offsets = centeredOffsets; + final int d = dimensions, b = bits; + final io.github.jbellis.jvector.vector.VectorUtilSupport vu = backend; + return node -> { + float dot = (queryScale * scales[node]) + * vu.ashSymmetricDotAt(queryBody, queryBodyOffset, pages[node >>> shift], (node & mask) * stride, d, b) + + (queryOffset + offsets[node]) + landmarkDot; + return ASHScorer.toSimilarity(dot); + }; + } return node -> { - var target = vectors.get(node); - float dot = (query.scale * target.scale) * packedDot(query, target) - + (queryOffset + offset(node)) + landmarkDots[0][0]; + float dot = (queryScale * scales[node]) + * pairDot(querySigns, querySignOffset, queryBody, queryBodyOffset, + signs(node), c.signOffset(node), body(node), c.bodyOffset(node)) + + (queryOffset + offset(node)) + landmarkDot; return ASHScorer.toSimilarity(dot); }; } @@ -171,6 +215,7 @@ public ASHBlockScorer blockScorerFor(AsymmetricHashing.QuantizedVector query, in System.arraycopy(lut,g*32,lut,g*32+16,16); } final float queryOffset = centeredOffset(query); + final float[] scales = codes.scales; return new ASHBlockScorer() { public String description() { return "symmetric exact integer LUT " + (useVector ? "SIMD" : "scalar"); } public void scoreRange(int start, int count, float[] out) { @@ -187,8 +232,7 @@ public void scoreRange(int start, int count, float[] out) { out[done+i] = sum*.25f; } for (int i = 0; i < lanes; i++) { - var target = vectors.get(ordinal+i); - out[done+i] = ASHScorer.toSimilarity((query.scale*target.scale)*out[done+i] + out[done+i] = ASHScorer.toSimilarity((query.scale*scales[ordinal+i])*out[done+i] +(queryOffset+offset(ordinal+i))+landmarkDots[0][0]); } done += lanes; @@ -208,11 +252,11 @@ public void scoreRange(int start, int count, float[] out) { java.util.Objects.checkFromIndexSize(0, count, out.length); for (int i = 0; i < count; i++) { int node = start + i; - var target = vectors.get(node); - float dot = vector ? backend.ashSymmetricDot(query.binaryVector, query.extraBits, - target.binaryVector, target.extraBits, dimensions, bits) - : codeDot(query, target, dimensions, bits); - out[i] = ASHScorer.toSimilarity((query.scale * target.scale) * dot + long[] page = codes.signPage(node); + int at = codes.signOffset(node); + float dot = vector ? backend.ashSymmetricBinaryDotAt(query.binaryVector, 0, page, at, dimensions) + : codeDot(query.binaryVector, 0, null, 0, page, at, null, 0, dimensions, bits); + out[i] = ASHScorer.toSimilarity((query.scale * codes.scales[node]) * dot + (queryOffset + offset(node)) + landmarkDots[0][0]); } } @@ -257,26 +301,31 @@ private ASHScorer.QueryPrecompute preparedState(AsymmetricHashing.QuantizedVecto } private static float component(AsymmetricHashing.QuantizedVector v, int j, int bits) { - if (bits == 1) return AsymmetricHashing.QuantizedVector.getBit(v.binaryVector, j) ? 1f : -1f; - if (bits == 2 || bits == 4) return AsymmetricHashing.projectionComponent(v.extraBits, j, bits); + return component(v.binaryVector, 0, v.extraBits, 0, j, bits); + } + + /** Decoded component {@code j} of a code given by its sign-word and body arrays and offsets. */ + private static float component(long[] signs, int signOffset, byte[] body, int bodyOffset, int j, int bits) { + if (bits == 1) return AsymmetricHashing.QuantizedVector.getBit(signs, signOffset, j) ? 1f : -1f; + if (bits == 2 || bits == 4) return AsymmetricHashing.projectionComponent(body, bodyOffset, j, bits); int ex = bits - 1; - int code = ((AsymmetricHashing.QuantizedVector.getBit(v.binaryVector, j) ? 1 : 0) << ex) - + AsymmetricHashing.QuantizedVector.readExtraCode(v.extraBits, j, ex); + int code = ((AsymmetricHashing.QuantizedVector.getBit(signs, signOffset, j) ? 1 : 0) << ex) + + AsymmetricHashing.QuantizedVector.readExtraCode(body, bodyOffset, j, ex); return code - ((1 << ex) - 0.5f); } - private static float symmetric2ScalarWords(byte[] ac, byte[] bc, int d) { + private static float symmetric2ScalarWords(byte[] ac, int aOffset, byte[] bc, int bOffset, int d) { long sum = 0; for (int start = 0; start < d; start += 32) { int active = Math.min(32, d - start); long a = 0, b = 0; if (active == 32) { - a = (long) SYMMETRIC_WORD.get(ac, start / 4); - b = (long) SYMMETRIC_WORD.get(bc, start / 4); + a = (long) SYMMETRIC_WORD.get(ac, aOffset + start / 4); + b = (long) SYMMETRIC_WORD.get(bc, bOffset + start / 4); } else { for (int j = 0; j < (active + 3) / 4; j++) { - a |= (long) (ac[start / 4 + j] & 255) << (8*j); - b |= (long) (bc[start / 4 + j] & 255) << (8*j); + a |= (long) (ac[aOffset + start / 4 + j] & 255) << (8*j); + b |= (long) (bc[bOffset + start / 4 + j] & 255) << (8*j); } } long valid = 0x5555555555555555L; @@ -291,20 +340,26 @@ private static float symmetric2ScalarWords(byte[] ac, byte[] bc, int d) { static float codeDot(AsymmetricHashing.QuantizedVector a, AsymmetricHashing.QuantizedVector b, int d, int bits) { + return codeDot(a.binaryVector, 0, a.extraBits, 0, b.binaryVector, 0, b.extraBits, 0, d, bits); + } + + /** Portable packed-code dot product; each code is given by its sign-word and body arrays and offsets. */ + static float codeDot(long[] aSigns, int aSignOffset, byte[] aBody, int aBodyOffset, + long[] bSigns, int bSignOffset, byte[] bBody, int bBodyOffset, int d, int bits) { if (bits == 1) { long mismatches = 0; int words = d >>> 6; for (int w = 0; w < words; w++) { - mismatches += Long.bitCount(a.binaryVector[w] ^ b.binaryVector[w]); + mismatches += Long.bitCount(aSigns[aSignOffset + w] ^ bSigns[bSignOffset + w]); } int tail = d & 63; if (tail != 0) { - mismatches += Long.bitCount((a.binaryVector[words] ^ b.binaryVector[words]) + mismatches += Long.bitCount((aSigns[aSignOffset + words] ^ bSigns[bSignOffset + words]) & ((1L << tail) - 1)); } return d - 2f * mismatches; } - if (bits == 2) return symmetric2ScalarWords(a.extraBits, b.extraBits, d); + if (bits == 2) return symmetric2ScalarWords(aBody, aBodyOffset, bBody, bBodyOffset, d); if (bits == 4) { // Each table entry sums products of doubled (odd integer) code values. // 256 entries = 512 bytes; shared across all nodes and threads. @@ -313,14 +368,14 @@ static float codeDot(AsymmetricHashing.QuantizedVector a, int bytes = d / dimsPerByte; long sum = 0; for (int i = 0; i < bytes; i++) { - int x = a.extraBits[i] & 255; - int y = b.extraBits[i] & 255; + int x = aBody[aBodyOffset + i] & 255; + int y = bBody[bBodyOffset + i] & 255; sum += table[((x & 15) << 4) | (y & 15)] + table[(x & 240) | (y >>> 4)]; } for (int j = bytes * dimsPerByte; j < d; j++) { - int x = (int) (2 * AsymmetricHashing.projectionComponent(a.extraBits, j, bits)); - int y = (int) (2 * AsymmetricHashing.projectionComponent(b.extraBits, j, bits)); + int x = (int) (2 * AsymmetricHashing.projectionComponent(aBody, aBodyOffset, j, bits)); + int y = (int) (2 * AsymmetricHashing.projectionComponent(bBody, bBodyOffset, j, bits)); sum += x * y; } return sum * 0.25f; @@ -330,10 +385,10 @@ static float codeDot(AsymmetricHashing.QuantizedVector a, int bias = (1 << bits) - 1; long sum = 0; for (int j = 0; j < d; j++) { - int x = ((AsymmetricHashing.QuantizedVector.getBit(a.binaryVector, j) ? 1 : 0) << extra) - + AsymmetricHashing.QuantizedVector.readExtraCode(a.extraBits, j, extra); - int y = ((AsymmetricHashing.QuantizedVector.getBit(b.binaryVector, j) ? 1 : 0) << extra) - + AsymmetricHashing.QuantizedVector.readExtraCode(b.extraBits, j, extra); + int x = ((AsymmetricHashing.QuantizedVector.getBit(aSigns, aSignOffset, j) ? 1 : 0) << extra) + + AsymmetricHashing.QuantizedVector.readExtraCode(aBody, aBodyOffset, j, extra); + int y = ((AsymmetricHashing.QuantizedVector.getBit(bSigns, bSignOffset, j) ? 1 : 0) << extra) + + AsymmetricHashing.QuantizedVector.readExtraCode(bBody, bBodyOffset, j, extra); sum += (long) (2 * x - bias) * (2 * y - bias); } return sum * 0.25f; diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHVectors.java b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHVectors.java index e206f52e3..665f86502 100644 --- a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHVectors.java +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHVectors.java @@ -27,7 +27,6 @@ import io.github.jbellis.jvector.vector.types.VectorFloat; import java.io.IOException; -import java.util.Arrays; import java.util.Objects; /** @@ -43,15 +42,17 @@ *
  • Vectorized versions of scoring
  • * * - *

    Fused graph neighborhoods are scored separately by {@link FusedASHDecoder}, - * using the same similarity scale and shared numerical kernels.

    + *

    Codes are held in one compact {@link ASHCodes} store with no per-vector objects; + * every scorer reads them in place. Fused graph neighborhoods are scored separately by + * {@link FusedASHDecoder}, from the on-disk index, using the same similarity scale and + * shared numerical kernels.

    */ public class ASHVectors implements CompressedVectors { final AsymmetricHashing ash; - final AsymmetricHashing.QuantizedVector[] compressedVectors; + /** The only copy of the stored codes and headers; see {@link ASHCodes}. */ + final ASHCodes codes; final ASHScorer scorer; - private final long vectorDataBytes; private final long containerBytes; // Immutable, optional construction cache produced by the bounded encoding workers. // Raw input vectors are never retained. Loaded/individually encoded collections use @@ -98,7 +99,7 @@ static AshBlockKernel fromProperty() { } } - // Cached scalar headers (avoid object chasing in blocked scorer) + // Decoded headers, owned by codes; aliased for the scorers' hot loops. private final float[] scales; // scale_i = ||x_i − μ|| / ||code_i|| private final float[] offsets; // offset_i = − ||μ||^2 private final byte[] landmarks; @@ -112,92 +113,45 @@ static AshBlockKernel fromProperty() { private volatile long[] packedBits = null; private int packedBlockCount = 0; - // Flat row-major sign bits for single-vector SIMD (built lazily, 1-bit path only) - // Layout: [v0_w0, v0_w1, ... v0_wN, v1_w0, ...] - private volatile long[] flatPackedVectors = null; - // Immutable, body-only nibble blocks shared by queries. Header arrays contain // decoded binary16 values; the serialized header remains 40 bits per vector. private final java.util.concurrent.ConcurrentMap projectionBlocks = new java.util.concurrent.ConcurrentHashMap<>(); /** - * Initialize ASHVectors with an array of ASH-compressed vectors. + * Initialize ASHVectors by copying an array of ASH-compressed vectors into compact storage. + * The vectors are not retained, so later changes to them are not seen. * - *

    - * The array is treated as immutable after construction. + * @throws IllegalArgumentException if a vector does not have this compressor's code shape */ public ASHVectors(AsymmetricHashing ash, AsymmetricHashing.QuantizedVector[] compressedVectors) { - this(ash, compressedVectors, null); + this(ash, ASHCodes.copyOf(compressedVectors, ash.quantizedDim, ash.bitsPerDimension), null); } - ASHVectors(AsymmetricHashing ash, AsymmetricHashing.QuantizedVector[] compressedVectors, - float[] symmetricOffsets) { - if (symmetricOffsets != null && symmetricOffsets.length != compressedVectors.length) + /** Takes ownership of {@code codes}, which must not change afterward. */ + ASHVectors(AsymmetricHashing ash, ASHCodes codes, float[] symmetricOffsets) { + if (codes.quantizedDim != ash.quantizedDim || codes.bitsPerDimension != ash.bitsPerDimension) + throw new IllegalArgumentException("ASH codes do not match the compressor's code shape"); + if (symmetricOffsets != null && symmetricOffsets.length != codes.size) throw new IllegalArgumentException("Symmetric offsets must match encoded vector count"); this.symmetricOffsets = symmetricOffsets; this.ash = ash; - this.compressedVectors = compressedVectors; + this.codes = codes; this.scorer = new ASHScorer(ash); this.d = ash.quantizedDim; this.words = AsymmetricHashing.QuantizedVector.wordsForDims(d); - int n = compressedVectors.length; - this.scales = new float[n]; - this.offsets = new float[n]; - this.landmarks = new byte[n]; - long dataBytes = 0; - for (int i = 0; i < n; i++) { - var v = compressedVectors[i]; - dataBytes = Math.addExact(dataBytes, v.ramBytesUsed()); - scales[i] = v.scale; - offsets[i] = v.offset; - landmarks[i] = v.landmark; - } - this.vectorDataBytes = dataBytes; + this.scales = codes.scales; + this.offsets = codes.offsets; + this.landmarks = codes.landmarks; this.containerBytes = containerBytes(); } - // Private constructor that trusts prebuilt arrays (for vector landmark sorting) - private ASHVectors(AsymmetricHashing ash, - AsymmetricHashing.QuantizedVector[] compressedVectors, - float[] scales, - float[] offsets, - byte[] landmarks, - long vectorDataBytes, - float[] symmetricOffsets) { - this.symmetricOffsets = symmetricOffsets; - this.ash = ash; - this.compressedVectors = compressedVectors; - this.scorer = new ASHScorer(ash); - - this.d = ash.quantizedDim; - this.words = AsymmetricHashing.QuantizedVector.wordsForDims(d); - - // Defensive sanity checks (cheap) - if (scales.length != compressedVectors.length || - offsets.length != compressedVectors.length || - landmarks.length != compressedVectors.length) { - throw new IllegalArgumentException("Header arrays must match compressedVectors length"); - } - - this.scales = scales; - this.offsets = offsets; - this.landmarks = landmarks; - this.vectorDataBytes = vectorDataBytes; // Reordering preserves the same vector multiset. - this.containerBytes = containerBytes(); - - // packedBits cache starts empty (as usual) - this.packedBlockSize = -1; - this.packedBits = null; - this.packedBlockCount = 0; - } - @Override public int count() { - return compressedVectors.length; + return codes.size; } /** @@ -215,12 +169,10 @@ public void write(IndexWriter out, int version) throws IOException { ash.write(out, version); // Write vector count - out.writeInt(compressedVectors.length); + out.writeInt(codes.size); - // Write vectors - for (var v : compressedVectors) { - v.write(out, ash.quantizedDim, ash.bitsPerDimension); - } + // Write vectors, each in the AsymmetricHashing.QuantizedVector format + codes.write(out); } public static ASHVectors load(RandomAccessReader in) throws IOException { @@ -231,13 +183,7 @@ public static ASHVectors load(RandomAccessReader in) throws IOException { throw new IOException("Invalid compressed vector count " + size); } - var compressedVectors = new AsymmetricHashing.QuantizedVector[size]; - for (int i = 0; i < size; i++) { - compressedVectors[i] = - AsymmetricHashing.QuantizedVector.load(in, ash.quantizedDim, ash.bitsPerDimension); - } - - return new ASHVectors(ash, compressedVectors); + return new ASHVectors(ash, ASHCodes.load(in, size, ash.quantizedDim, ash.bitsPerDimension), null); } @Override @@ -252,8 +198,7 @@ public ScoreFunction.ApproximateScoreFunction scoreFunctionFor( // ASHScorer dispatches canonical 2/4-bit projection scoring to SIMD when available. // The packed masked-add path below is specific to one-bit codes. if (ash.bitsPerDimension != 1) { - final ASHScorer.ASHScoreFunction f = scorer.scoreFunctionFor(query, similarityFunction); - return node -> ASHScorer.toSimilarity(f.similarityTo(compressedVectors[node])); + return nodeScoreFunction(scorer.codeScorerFor(scorer.precomputeQuery(query))); } final AshSingleKernel kernel = AshSingleKernel.fromProperty(); @@ -261,8 +206,7 @@ public ScoreFunction.ApproximateScoreFunction scoreFunctionFor( // Scalar forced if (kernel == AshSingleKernel.SCALAR) { - final ASHScorer.ASHScoreFunction f = scorer.scoreFunctionFor(query, similarityFunction); - return node -> ASHScorer.toSimilarity(f.similarityTo(compressedVectors[node])); + return nodeScoreFunction(scorer.codeScorerFor(scorer.precomputeQuery(query))); } // AUTO/SIMD: use SIMD scorer only when backend supports masked-load kernel; @@ -277,21 +221,38 @@ public ScoreFunction.ApproximateScoreFunction scoreFunctionFor( if (kernel == AshSingleKernel.SIMD) { throw new IllegalStateException("ASH single-vector SIMD is unavailable on " + vecUtil.getClass().getName()); } - final ASHScorer.ASHScoreFunction f = scorer.scoreFunctionFor(query, similarityFunction); - return node -> ASHScorer.toSimilarity(f.similarityTo(compressedVectors[node])); + return nodeScoreFunction(scorer.codeScorerFor(scorer.precomputeQuery(query))); + } + + /** + * Applies {@code f} to stored codes in place, in graph similarity units. One lambda per storage + * shape keeps each call site free of null checks for the array a bit width does not use. + */ + private ScoreFunction.ApproximateScoreFunction nodeScoreFunction(ASHScorer.CodeScorer f) { + final ASHCodes c = codes; + if (c.signWords == 0) { + return node -> ASHScorer.toSimilarity(f.dot(null, 0, c.bodyPage(node), c.bodyOffset(node), + scales[node], offsets[node], landmarks[node] & 0xFF)); + } + if (c.bodyBytes == 0) { + return node -> ASHScorer.toSimilarity(f.dot(c.signPage(node), c.signOffset(node), null, 0, + scales[node], offsets[node], landmarks[node] & 0xFF)); + } + return node -> ASHScorer.toSimilarity(f.dot(c.signPage(node), c.signOffset(node), + c.bodyPage(node), c.bodyOffset(node), scales[node], offsets[node], landmarks[node] & 0xFF)); } // ============================================================================ - // ASH single vector scoring (Optimized Zero-Copy, 1-bit only) + // ASH single vector scoring (1-bit only, reads the stored sign pages in place) // ============================================================================ private final class SimdASHScoreFunction implements ScoreFunction.ApproximateScoreFunction { private final QueryPrecompute qp; private final VectorUtilSupport vecUtil; - // Cached Arrays (Zero-Copy) + // Query state and the stored sign pages, read in place private final float[] tildeQPool; - private final long[] allPackedVectors; + private final ASHCodes codes; private final int d; private final int words; @@ -309,8 +270,7 @@ private final class SimdASHScoreFunction implements ScoreFunction.ApproximateSco // 1. Grab direct reference to the pool this.tildeQPool = qp.tildeQPool; - // 2. Trigger/Get the flat array from the outer class - this.allPackedVectors = getFlatPackedVectors(); + this.codes = ASHVectors.this.codes; } @Override @@ -318,14 +278,13 @@ public float similarityTo(int node2) { final int c = landmarks[node2] & 0xFF; final int qOffset = c * d; - final int packedBase = node2 * words; - // Run existing optimized 1-bit kernel. + // Run existing optimized 1-bit kernel on the node's sign words in its page. final float maskedAdd = vecUtil.ashMaskedAdd_512( tildeQPool, qOffset, - allPackedVectors, - packedBase, + codes.signPage(node2), + codes.signOffset(node2), d, words ); @@ -359,20 +318,19 @@ public boolean isExact() { */ private static final class LegacyASHBlockScorer implements ASHBlockScorer { - private final ASHScorer.ASHScoreFunction scorer; - private final AsymmetricHashing.QuantizedVector[] vectors; + /** Per-node scorer already in graph similarity units. */ + private final ScoreFunction.ApproximateScoreFunction scorer; + private final int size; - LegacyASHBlockScorer( - ASHScorer.ASHScoreFunction scorer, - AsymmetricHashing.QuantizedVector[] vectors) { + LegacyASHBlockScorer(ScoreFunction.ApproximateScoreFunction scorer, int size) { this.scorer = scorer; - this.vectors = vectors; + this.size = size; } @Override public void scoreRange(int start, int count, float[] out) { if (count < 0) throw new IllegalArgumentException("count must be >= 0"); - if (start < 0 || start + count > vectors.length) { + if (start < 0 || start + count > size) { throw new IllegalArgumentException( "Range out of bounds: [" + start + ", " + (start + count) + ")" ); @@ -384,7 +342,7 @@ public void scoreRange(int start, int count, float[] out) { final int end = start + count; for (int i = start, o = 0; i < end; i++, o++) { - out[o] = ASHScorer.toSimilarity(scorer.similarityTo(vectors[i])); + out[o] = scorer.similarityTo(i); } } } @@ -402,10 +360,7 @@ public ASHBlockScorer blockScorerFor( "ASH block scorer supports DOT_PRODUCT only"); } - ASHScorer.ASHScoreFunction f = - scorer.scalarScoreFunctionFor(query); - - return new LegacyASHBlockScorer(f, compressedVectors); + return new LegacyASHBlockScorer(nodeScoreFunction(scorer.scalarCodeScorerFor(query)), codes.size); } /** @@ -475,7 +430,7 @@ public ASHBlockScorer blockScorerFor( // ------------------------------------------------------------ if (kernel == AshBlockKernel.SCALAR || !supportsSimd) { return new ScalarASHBlockScorer( - compressedVectors, + codes, scales, offsets, landmarks, @@ -489,7 +444,7 @@ public ASHBlockScorer blockScorerFor( ensurePackedBits(blockSize); return new SimdASHBlockScorer( - compressedVectors, + codes.size, scales, offsets, landmarks, @@ -505,8 +460,7 @@ ScoreFunction.ApproximateScoreFunction scoreFunctionFor(ASHScorer.QueryPrecomput return new SimdASHScoreFunction(new QueryPrecompute(qp.d, qp.C, qp.tildeQPool, qp.sumTildeQByLandmark, qp.dotQMuByLandmark)); } - var f = scorer.scoreFunctionFor(qp, simd); - return node -> ASHScorer.toSimilarity(f.similarityTo(compressedVectors[node])); + return nodeScoreFunction(scorer.codeScorerFor(qp, simd)); } ASHBlockScorer blockScorerFor(ASHScorer.QueryPrecompute prepared, int blockSize, boolean simd) { @@ -516,29 +470,28 @@ ASHBlockScorer blockScorerFor(ASHScorer.QueryPrecompute prepared, int blockSize, return new LutASHBlockScorer(prepared, blockSize, simd); } if (ash.bitsPerDimension != 1) { - return new LegacyASHBlockScorer(scorer.scoreFunctionFor(prepared, simd), compressedVectors); + return new LegacyASHBlockScorer(nodeScoreFunction(scorer.codeScorerFor(prepared, simd)), codes.size); } var qp = new QueryPrecompute(prepared.d, prepared.C, prepared.tildeQPool, prepared.sumTildeQByLandmark, prepared.dotQMuByLandmark); - if (!simd) return new ScalarASHBlockScorer(compressedVectors, scales, offsets, landmarks, qp); + if (!simd) return new ScalarASHBlockScorer(codes, scales, offsets, landmarks, qp); ensurePackedBits(blockSize); - return new SimdASHBlockScorer(compressedVectors, scales, offsets, landmarks, packedBits, blockSize, qp); + return new SimdASHBlockScorer(codes.size, scales, offsets, landmarks, packedBits, blockSize, qp); } private byte[][] packProjectionBlocks(int blockSize) { - int count = compressedVectors.length; + int count = codes.size; int bytes = FusedASHLayout.canonicalCodeBytes(d, ash.bitsPerDimension); + if (bytes > codes.bodyBytes) throw new IllegalStateException("ASH projection codes are shorter than their layout"); byte[][] blocks = new byte[count / blockSize + (count % blockSize == 0 ? 0 : 1)][]; long bytesUsed = RamUsageEstimator.shallowSizeOf(blocks); for (int block = 0; block < blocks.length; block++) { byte[] body = new byte[Math.multiplyExact(bytes, blockSize)]; int base = block * blockSize; for (int lane = 0; lane < Math.min(blockSize, count - base); lane++) { - byte[] code = compressedVectors[base + lane].extraBits; - if (code == null || code.length < bytes) { - throw new IllegalArgumentException("Missing ASH projection code at ordinal " + (base + lane)); - } - for (int i = 0; i < bytes; i++) body[i * blockSize + lane] = code[i]; + byte[] page = codes.bodyPage(base + lane); + int at = codes.bodyOffset(base + lane); + for (int i = 0; i < bytes; i++) body[i * blockSize + lane] = page[at + i]; } blocks[block] = body; bytesUsed = Math.addExact(bytesUsed, RamUsageEstimator.sizeOf(body)); @@ -587,7 +540,7 @@ private final class LutASHBlockScorer implements ASHBlockScorer { @Override public void scoreRange(int start, int count, float[] out) { - Objects.checkFromIndexSize(start, count, compressedVectors.length); + Objects.checkFromIndexSize(start, count, codes.size); Objects.checkFromIndexSize(0, count, out.length); for (int done = 0; done < count;) { int ordinal = start + done; @@ -622,7 +575,7 @@ private void ensurePackedBits(int blockSize) { return; } - final int n = compressedVectors.length; + final int n = codes.size; packedBlockSize = blockSize; packedBlockCount = (n + blockSize - 1) / blockSize; packedBits = new long[packedBlockCount * words * blockSize]; @@ -634,51 +587,14 @@ private void ensurePackedBits(int blockSize) { for (int w = 0; w < words; w++) { int dstBase = (b * words + w) * blockSize; for (int lane = 0; lane < blockLen; lane++) { - packedBits[dstBase + lane] = - compressedVectors[baseOrd + lane].binaryVector[w]; + int node = baseOrd + lane; + packedBits[dstBase + lane] = codes.signPage(node)[codes.signOffset(node) + w]; } // remaining lanes default to 0 } } } - /** - * Returns a monolithic long[] containing all 1-bit sign payloads. - *

    - * This method is thread-safe and computes the view lazily. - * Use this for single-vector SIMD scoring to avoid pointer chasing. - *

    - */ - public long[] getFlatPackedVectors() { - if (ash.bitsPerDimension != 1) { - throw new IllegalStateException("Flat packed sign-bit cache supports only 1-bit ASH"); - } - if (flatPackedVectors == null) { - synchronized (this) { - if (flatPackedVectors == null) { - flatPackedVectors = createFlatPackedVectors(); - } - } - } - return flatPackedVectors; - } - - private long[] createFlatPackedVectors() { - final int n = compressedVectors.length; - if (n == 0) return new long[0]; - - // words is already a class field - long[] flat = new long[n * words]; - - for (int i = 0; i < n; i++) { - var v = compressedVectors[i]; - if (v != null && v.binaryVector != null) { - System.arraycopy(v.binaryVector, 0, flat, i * words, words); - } - } - return flat; - } - /** * Block-oriented 1-bit ASH scorer using register-local accumulation. * @@ -690,7 +606,7 @@ private long[] createFlatPackedVectors() { */ private static final class ScalarASHBlockScorer implements ASHBlockScorer { - private final AsymmetricHashing.QuantizedVector[] vectors; + private final ASHCodes codes; private final float[] scales; private final float[] offsets; private final byte[] landmarks; @@ -702,14 +618,14 @@ private static final class ScalarASHBlockScorer implements ASHBlockScorer { private final int words; ScalarASHBlockScorer( - AsymmetricHashing.QuantizedVector[] vectors, + ASHCodes codes, float[] scales, float[] offsets, byte[] landmarks, QueryPrecompute qp) { - this.vectors = vectors; + this.codes = codes; this.scales = scales; this.offsets = offsets; this.landmarks = landmarks; @@ -725,7 +641,7 @@ private static final class ScalarASHBlockScorer implements ASHBlockScorer { @Override public void scoreRange(int start, int count, float[] out) { if (count < 0) throw new IllegalArgumentException("count must be >= 0"); - if (start < 0 || start + count > vectors.length) { + if (start < 0 || start + count > codes.size) { throw new IllegalArgumentException("Range out of bounds: [" + start + ", " + (start + count) + ")"); } if (out.length < count) throw new IllegalArgumentException("out.length < count"); @@ -740,10 +656,11 @@ public void scoreRange(int start, int count, float[] out) { float maskedAdd = 0f; // bit-walk against landmark-specific q̃ - final long[] bits = vectors[idx].binaryVector; + final long[] bits = codes.signPage(idx); + final int bitsOffset = codes.signOffset(idx); int baseDim = 0; for (int w = 0; w < words && baseDim < d; w++, baseDim += 64) { - long word = bits[w]; + long word = bits[bitsOffset + w]; while (word != 0L) { int bit = Long.numberOfTrailingZeros(word); int j = baseDim + bit; @@ -783,7 +700,7 @@ public void scoreRange(int start, int count, float[] out) { */ private static final class SimdASHBlockScorer implements ASHBlockScorer { - private final AsymmetricHashing.QuantizedVector[] vectors; + private final int size; private final float[] scales; private final float[] offsets; private final byte[] landmarks; @@ -807,7 +724,7 @@ private static final class SimdASHBlockScorer implements ASHBlockScorer { private final VectorUtilSupport vecUtil; SimdASHBlockScorer( - AsymmetricHashing.QuantizedVector[] vectors, + int size, float[] scales, float[] offsets, byte[] landmarks, @@ -817,7 +734,7 @@ private static final class SimdASHBlockScorer implements ASHBlockScorer { this.vecUtil = VectorizationProvider.getInstance().getVectorUtilSupport(); - this.vectors = vectors; + this.size = size; this.scales = scales; this.offsets = offsets; this.landmarks = landmarks; @@ -840,7 +757,7 @@ private static final class SimdASHBlockScorer implements ASHBlockScorer { @Override public void scoreRange(int start, int count, float[] out) { if (count < 0) throw new IllegalArgumentException("count must be >= 0"); - if (start < 0 || start + count > vectors.length) { + if (start < 0 || start + count > size) { throw new IllegalArgumentException("Range out of bounds: [" + start + ", " + (start + count) + ")"); } if (out.length < count) throw new IllegalArgumentException("out.length < count"); @@ -982,10 +899,10 @@ public static final class LandmarkOrder { /** * Stable, linear-time reorder by landmark id ({@code C <= 256}). - * Fills reordered header arrays in the same pass (no second constructor scan). + * The result holds its own reordered copy of the codes. */ public LandmarkOrder reorderByLandmarkFast() { - final int n = compressedVectors.length; + final int n = codes.size; final int C = ash.landmarkCount; // Count per landmark @@ -1010,24 +927,14 @@ public LandmarkOrder reorderByLandmarkFast() { final int[] writePos = landmarkOffsets.clone(); - final AsymmetricHashing.QuantizedVector[] newVecs = new AsymmetricHashing.QuantizedVector[n]; - final float[] newScales = new float[n]; - final float[] newOffsets = new float[n]; final float[] newSymmetricOffsets = symmetricOffsets == null ? null : new float[n]; - final byte[] newLandmarks = new byte[n]; final int[] newToOld = new int[n]; // Preserve an ordinal map so we can go back and forth for (int oldOrd = 0; oldOrd < n; oldOrd++) { final int c = this.landmarks[oldOrd] & 0xFF; final int newOrd = writePos[c]++; - - newVecs[newOrd] = this.compressedVectors[oldOrd]; - newScales[newOrd] = this.scales[oldOrd]; - newOffsets[newOrd] = this.offsets[oldOrd]; if (newSymmetricOffsets != null) newSymmetricOffsets[newOrd] = symmetricOffsets[oldOrd]; - newLandmarks[newOrd] = this.landmarks[oldOrd]; - newToOld[newOrd] = oldOrd; } @@ -1036,7 +943,7 @@ public LandmarkOrder reorderByLandmarkFast() { oldToNew[newToOld[newOrd]] = newOrd; } - ASHVectors reordered = new ASHVectors(ash, newVecs, newScales, newOffsets, newLandmarks, vectorDataBytes, newSymmetricOffsets); + ASHVectors reordered = new ASHVectors(ash, codes.permute(newToOld), newSymmetricOffsets); return new LandmarkOrder(reordered, newToOld, oldToNew, landmarkOffsets); } @@ -1134,8 +1041,20 @@ public ScoreFunction.ApproximateScoreFunction diversityFunctionFor( return symmetric.scoreFunctionFor(node1); } + /** + * A new, independent copy of the stored vector. Allocates; for repeated reads into reusable + * storage use {@link #copyTo}. Scorers read the stored codes in place. + */ public AsymmetricHashing.QuantizedVector get(int ordinal) { - return compressedVectors[ordinal]; + return codes.get(ordinal); + } + + /** + * Copies the stored vector into {@code dest}, replacing its arrays only if their lengths do not + * match this compressor's code shape. + */ + public void copyTo(int ordinal, AsymmetricHashing.QuantizedVector dest) { + codes.copyTo(ordinal, dest); } @Override @@ -1155,48 +1074,45 @@ public AsymmetricHashing getCompressor() { private long containerBytes() { return RamUsageEstimator.shallowSizeOfInstance(ASHVectors.class) - + RamUsageEstimator.shallowSizeOf(compressedVectors) - + RamUsageEstimator.sizeOf(scales) + RamUsageEstimator.sizeOf(offsets) - + RamUsageEstimator.sizeOf(landmarks) + RamUsageEstimator.shallowSizeOf(scorer) + + RamUsageEstimator.shallowSizeOf(scorer) + RamUsageEstimator.shallowSizeOf(symmetricOffsets) + RamUsageEstimator.shallowSizeOf(projectionBlocks) + RamUsageEstimator.shallowSizeOf(projectionCacheBytes); } /** - * Retained compressor, vector heap storage and published caches, analogous to PQVectors. - * Vector objects/arrays are charged per ordinal (conservative for caller-shared vectors). + * Retained compressor, code storage and published caches, analogous to PQVectors. * Query-local scorers and worker scratch are owned by callers and excluded here. - * Immutable payload totals are cached at construction; lazy cache totals at allocation. + * Code storage is fixed at construction; lazy cache totals are added at allocation. */ @Override public long ramBytesUsed() { ASHSymmetricScorer symmetric = diversityScorer; - return ash.ramBytesUsed() + containerBytes + vectorDataBytes + projectionCacheBytes.get() - + RamUsageEstimator.shallowSizeOf(packedBits) + RamUsageEstimator.shallowSizeOf(flatPackedVectors) + return ash.ramBytesUsed() + containerBytes + codes.ramBytesUsed() + projectionCacheBytes.get() + + RamUsageEstimator.shallowSizeOf(packedBits) + (symmetric == null ? 0 : symmetric.ramBytesUsed()); } + /** Equal compressors and the same stored vectors in the same order. */ @Override public boolean equals(Object o) { if (this == o) return true; if (o == null || getClass() != o.getClass()) return false; ASHVectors that = (ASHVectors) o; - return Objects.equals(ash, that.ash) - && Arrays.equals(compressedVectors, that.compressedVectors); + return Objects.equals(ash, that.ash) && codes.contentEquals(that.codes); } @Override public int hashCode() { int result = Objects.hash(ash); - result = 31 * result + Arrays.hashCode(compressedVectors); + result = 31 * result + codes.contentHash(); return result; } @Override public String toString() { - return "ASHVectors{count=" + compressedVectors.length + + return "ASHVectors{count=" + codes.size + ", ash=" + ash + '}'; } } diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/AsymmetricHashing.java b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/AsymmetricHashing.java index 5d7d9c81c..a7ac37711 100644 --- a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/AsymmetricHashing.java +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/AsymmetricHashing.java @@ -702,7 +702,12 @@ static void setFlatNibble(byte[] code, int group, int value) { } static int flatNibble(byte[] code, int group) { - int v = code[group >>> 1] & 0xFF; + return flatNibble(code, 0, group); + } + + /** Nibble {@code group} of the code starting at {@code codeOffset}. */ + static int flatNibble(byte[] code, int codeOffset, int group) { + int v = code[codeOffset + (group >>> 1)] & 0xFF; return ((group & 1) == 0) ? (v & 0x0F) : ((v >>> 4) & 0x0F); } @@ -711,11 +716,15 @@ static int projectionFieldForDimension( int dimension, int bitsPerDimension ) { + return projectionFieldForDimension(projectionCode, 0, dimension, bitsPerDimension); + } + + static int projectionFieldForDimension(byte[] projectionCode, int codeOffset, int dimension, int bitsPerDimension) { int groupDims = projectionDimsPerNibble(bitsPerDimension); int group = dimension / groupDims; int slot = dimension - group * groupDims; int mask = (1 << bitsPerDimension) - 1; - return (flatNibble(projectionCode, group) >>> (slot * bitsPerDimension)) & mask; + return (flatNibble(projectionCode, codeOffset, group) >>> (slot * bitsPerDimension)) & mask; } static float decodeProjectionComponent(int field, int bitsPerDimension) { @@ -735,8 +744,13 @@ static float projectionComponent( int dimension, int bitsPerDimension ) { + return projectionComponent(projectionCode, 0, dimension, bitsPerDimension); + } + + /** Component {@code dimension} of the code starting at {@code codeOffset}. */ + static float projectionComponent(byte[] projectionCode, int codeOffset, int dimension, int bitsPerDimension) { return decodeProjectionComponent( - projectionFieldForDimension(projectionCode, dimension, bitsPerDimension), + projectionFieldForDimension(projectionCode, codeOffset, dimension, bitsPerDimension), bitsPerDimension); } @@ -746,12 +760,18 @@ static float dotProjectionCode( int quantizedDim, int bitsPerDimension ) { + return dotProjectionCode(projectedVector, projectionCode, 0, quantizedDim, bitsPerDimension); + } + + /** Scalar projection dot with the code starting at {@code codeOffset}. */ + static float dotProjectionCode(float[] projectedVector, byte[] projectionCode, int codeOffset, + int quantizedDim, int bitsPerDimension) { int groupDims = projectionDimsPerNibble(bitsPerDimension); int groups = projectionCodeGroups(quantizedDim, bitsPerDimension); float sum = 0.0f; for (int group = 0; group < groups; group++) { - int nibble = flatNibble(projectionCode, group); + int nibble = flatNibble(projectionCode, codeOffset, group); int baseDim = group * groupDims; for (int slot = 0; slot < groupDims; slot++) { int dim = baseDim + slot; @@ -768,11 +788,15 @@ static float dotProjectionCode( /** Header-calibrated correction for symmetric scoring, using the stored code/header. */ float centeredOffset(QuantizedVector vector) { - int c = vector.landmark & 255; + return centeredOffset(vector.scale, vector.offset, vector.landmark, vector.extraBits, 0); + } + + /** As {@link #centeredOffset(QuantizedVector)} for a header and a body stored at {@code bodyOffset}. */ + float centeredOffset(float scale, float offset, byte landmark, byte[] body, int bodyOffset) { + int c = landmark & 255; if (c >= landmarkCount) throw new IllegalArgumentException("Invalid landmark " + c); - if (!usesFastScanProjectionCode(bitsPerDimension)) return vector.offset; - return vector.offset + vector.scale * dotProjectionCode( - landmarkProj[c], vector.extraBits, quantizedDim, bitsPerDimension); + if (!usesFastScanProjectionCode(bitsPerDimension)) return offset; + return offset + scale * dotProjectionCode(landmarkProj[c], body, bodyOffset, quantizedDim, bitsPerDimension); } // --------------------------------------------------------------------- @@ -1181,7 +1205,12 @@ static void setBit(long[] words, int bitIndex) { } static boolean getBit(long[] words, int bitIndex) { - return ((words[bitIndex >>> 6] >>> (bitIndex & 63)) & 1L) != 0L; + return getBit(words, 0, bitIndex); + } + + /** Bit {@code bitIndex} of the sign words starting at {@code wordOffset}. */ + static boolean getBit(long[] words, int wordOffset, int bitIndex) { + return ((words[wordOffset + (bitIndex >>> 6)] >>> (bitIndex & 63)) & 1L) != 0L; } static void writeExtraCode(byte[] extraBits, int dim, int exBits, int code) { @@ -1194,10 +1223,15 @@ static void writeExtraCode(byte[] extraBits, int dim, int exBits, int code) { } static int readExtraCode(byte[] extraBits, int dim, int exBits) { + return readExtraCode(extraBits, 0, dim, exBits); + } + + /** As {@link #readExtraCode(byte[], int, int)} for extra bits starting at {@code byteOffset}. */ + static int readExtraCode(byte[] extraBits, int byteOffset, int dim, int exBits) { int code = 0; int bitPos = dim * exBits; for (int bit = 0; bit < exBits; bit++, bitPos++) { - code |= ((extraBits[bitPos >>> 3] >>> (bitPos & 7)) & 1) << bit; + code |= ((extraBits[byteOffset + (bitPos >>> 3)] >>> (bitPos & 7)) & 1) << bit; } return code; } @@ -1527,7 +1561,8 @@ public ASHVectors encodeAll(RandomAccessVectorValues ravv, ForkJoinPool simdExec final int n = ravv.size(); final int parallelism = Math.max(1, simdExecutor.getParallelism()); final int chunkSize = (int) (((long) n + parallelism - 1) / parallelism); - final QuantizedVector[] out = new QuantizedVector[n]; + // Encoded straight into the compact store; no per-vector objects are retained. + final ASHCodes out = ASHCodes.allocate(n, quantizedDim, bitsPerDimension); // Fill while each encoded vector is hot; never reread the raw input for setup. final float[] symmetricOffsets = usesFastScanProjectionCode(bitsPerDimension) ? new float[n] : null; final var ravvSupplier = ravv.threadLocalSupplier(); @@ -1557,7 +1592,7 @@ public ASHVectors encodeAll(RandomAccessVectorValues ravv, ForkJoinPool simdExec return new ASHVectors(this, out, symmetricOffsets); } - private void encodeRange(RandomAccessVectorValues source, QuantizedVector[] out, + private void encodeRange(RandomAccessVectorValues source, ASHCodes out, float[] symmetricOffsets, int start, int end) { int capacity = Math.min(ENCODING_BATCH_SIZE, end - start); float[][] inputs = new float[capacity][originalDimension]; @@ -1565,6 +1600,11 @@ private void encodeRange(RandomAccessVectorValues source, QuantizedVector[] out, float[] residualNorms = new float[capacity]; int[] ordinals = new int[capacity]; double[] absNorm = new double[quantizedDim]; + // Per-batch scratch, copied into the store once complete. Never modified: the code for missing vectors. + QuantizedVector[] scratch = new QuantizedVector[capacity]; + for (int k = 0; k < capacity; k++) scratch[k] = QuantizedVector.createEmpty(quantizedDim, bitsPerDimension); + final QuantizedVector missing = QuantizedVector.createEmpty(quantizedDim, bitsPerDimension); + final float missingOffset = symmetricOffsets == null ? 0f : centeredOffset(missing); int count = 0; try (var cursor = VectorAccess.openRange(source, start, end)) { int expected = start; @@ -1572,35 +1612,39 @@ private void encodeRange(RandomAccessVectorValues source, QuantizedVector[] out, int i = cursor.ordinal(); if (i != expected++) throw new IllegalStateException("Unexpected encoding ordinal"); VectorFloat vector = cursor.vector(); - QuantizedVector dest = QuantizedVector.createEmpty(quantizedDim, bitsPerDimension); - out[i] = dest; if (vector == null) { - if (symmetricOffsets != null) symmetricOffsets[i] = centeredOffset(dest); + out.set(i, missing); + if (symmetricOffsets != null) symmetricOffsets[i] = missingOffset; continue; } + QuantizedVector dest = scratch[count]; float residualNorm = prepareEncoding(vector, dest); normalizeResidual(vector, landmarks[dest.landmark & 0xFF], residualNorm, inputs[count]); residualNorms[count] = residualNorm; ordinals[count] = i; if (++count == capacity) { - encodeBatch(inputs, projected, residualNorms, ordinals, count, absNorm, out, symmetricOffsets); + encodeBatch(inputs, projected, residualNorms, ordinals, count, absNorm, scratch, out, symmetricOffsets); count = 0; } } if (expected != end) throw new IllegalStateException("Incomplete encoding range"); } - if (count != 0) encodeBatch(inputs, projected, residualNorms, ordinals, count, absNorm, out, symmetricOffsets); + if (count != 0) encodeBatch(inputs, projected, residualNorms, ordinals, count, absNorm, scratch, out, symmetricOffsets); } private void encodeBatch(float[][] inputs, float[][] projected, float[] residualNorms, - int[] ordinals, int count, double[] absNorm, QuantizedVector[] out, - float[] symmetricOffsets) { + int[] ordinals, int count, double[] absNorm, QuantizedVector[] scratch, + ASHCodes out, float[] symmetricOffsets) { stiefelTransform.projectBatch(inputs, projected, count); for (int i = 0; i < count; i++) { - QuantizedVector dest = out[ordinals[i]]; + QuantizedVector dest = scratch[i]; + // Quantizers OR bits and nibbles into place, so reused scratch starts from zero. + Arrays.fill(dest.binaryVector, 0L); + Arrays.fill(dest.extraBits, (byte) 0); float codeNorm = RandomBinaryQuantizer.quantizeProjected(projected[i], bitsPerDimension, dest.binaryVector, dest.extraBits, absNorm); float correction = finishEncoding(dest, residualNorms[i], codeNorm); + out.set(ordinals[i], dest); if (symmetricOffsets != null) symmetricOffsets[ordinals[i]] = correction; } } @@ -2629,7 +2673,7 @@ private static float roundToFloat16(float value) { return halfBitsToFloat(floatToHalfBits(value)); } - private static void writeFloat16(IndexWriter out, float value) throws IOException { + static void writeFloat16(IndexWriter out, float value) throws IOException { int bits = floatToHalfBits(value); // Explicit big-endian byte order, matching Java DataOutput-style primitives. @@ -2637,7 +2681,7 @@ private static void writeFloat16(IndexWriter out, float value) throws IOExceptio out.writeByte((byte) (bits & 0xFF)); } - private static float readFloat16(RandomAccessReader in) throws IOException { + static float readFloat16(RandomAccessReader in) throws IOException { int hi = readUnsignedByte(in); int lo = readUnsignedByte(in); return halfBitsToFloat((hi << 8) | lo); diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/vector/ASHLutScoring.java b/jvector-base/src/main/java/io/github/jbellis/jvector/vector/ASHLutScoring.java index 28667d8b5..27a63597c 100644 --- a/jvector-base/src/main/java/io/github/jbellis/jvector/vector/ASHLutScoring.java +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/vector/ASHLutScoring.java @@ -24,20 +24,30 @@ private ASHLutScoring() {} /** Checks a canonical packed code against a 16-entry table for each nibble. */ public static void checkCanonicalBounds(byte[] code, int groups, float[] lut) { + checkCanonicalBounds(code, 0, groups, lut); + } + + /** As {@link #checkCanonicalBounds(byte[], int, float[])} for the code starting at {@code codeOffset}. */ + public static void checkCanonicalBounds(byte[] code, int codeOffset, int groups, float[] lut) { Objects.requireNonNull(code, "code"); Objects.requireNonNull(lut, "lut"); if (groups <= 0) throw new IllegalArgumentException("groups must be positive"); - if ((groups + 1L) / 2 > code.length || (long) groups * 16 > lut.length) { + if (codeOffset < 0 || codeOffset + (groups + 1L) / 2 > code.length || (long) groups * 16 > lut.length) { throw new IndexOutOfBoundsException("Canonical ASH code or LUT is too small"); } } /** Scalar reference; the unused high nibble of an odd final group is ignored. */ public static float dotCanonical(byte[] code, int groups, float[] lut) { - checkCanonicalBounds(code, groups, lut); + return dotCanonical(code, 0, groups, lut); + } + + /** As {@link #dotCanonical(byte[], int, float[])} for the code starting at {@code codeOffset}. */ + public static float dotCanonical(byte[] code, int codeOffset, int groups, float[] lut) { + checkCanonicalBounds(code, codeOffset, groups, lut); float sum = 0; for (int group = 0; group < groups; group++) { - int nibble = (code[group >>> 1] >>> ((group & 1) * 4)) & 15; + int nibble = (code[codeOffset + (group >>> 1)] >>> ((group & 1) * 4)) & 15; sum += lut[group * 16 + nibble]; } return sum; diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/vector/VectorUtilSupport.java b/jvector-base/src/main/java/io/github/jbellis/jvector/vector/VectorUtilSupport.java index 9b151af6c..e16639a11 100644 --- a/jvector-base/src/main/java/io/github/jbellis/jvector/vector/VectorUtilSupport.java +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/vector/VectorUtilSupport.java @@ -43,7 +43,12 @@ public interface VectorUtilSupport { /** Explicitly tuned projection kernel; the ordinary entry point retains its compact default loop. */ default float ashProjectionDotTuned(float[] query, byte[] code, int dimensions, int bitsPerDimension) { - return ashProjectionDot(query, code, dimensions, bitsPerDimension); + return ashProjectionDotTuned(query, code, 0, dimensions, bitsPerDimension); + } + + /** As {@link #ashProjectionDotTuned(float[], byte[], int, int)} for the code starting at {@code codeOffset}. */ + default float ashProjectionDotTuned(float[] query, byte[] code, int codeOffset, int dimensions, int bitsPerDimension) { + return ashProjectionDot(query, code, codeOffset, dimensions, bitsPerDimension); } /** Whether ASH nibble lookup and accumulation are implemented with SIMD. */ @@ -70,6 +75,27 @@ default float ashSymmetricDot(long[] aBits, byte[] aCodes, long[] bBits, byte[] throw new UnsupportedOperationException("Symmetric ASH SIMD is unavailable on this backend"); } + /** Whether {@link #ashSymmetricDotAt} is implemented (2- and 4-bit codes at array offsets). */ + default boolean supportsAshSymmetricDotAt() { return false; } + + /** + * Symmetric 2/4-bit packed dot product of codes stored at offsets inside larger arrays. + * Same result as {@link #ashSymmetricDot} on the corresponding standalone code arrays. + */ + default float ashSymmetricDotAt(byte[] aCodes, int aOffset, byte[] bCodes, int bOffset, + int dimensions, int bits) { + throw new UnsupportedOperationException("Offset symmetric ASH SIMD is unavailable on this backend"); + } + + /** + * Symmetric 1-bit dot product, {@code dimensions - 2 * mismatches}, of sign words stored at offsets + * inside larger arrays. Same result as {@link #ashSymmetricDot} with {@code bits == 1}. + * Available whenever {@link #supportsAshSymmetricScoring()} is. + */ + default float ashSymmetricBinaryDotAt(long[] aBits, int aOffset, long[] bBits, int bOffset, int dimensions) { + throw new UnsupportedOperationException("Symmetric ASH SIMD is unavailable on this backend"); + } + /** Whether canonical 2/4-bit projection codes have a single-vector SIMD kernel. */ default boolean supportsAshProjectionScoring() { return false; } @@ -86,21 +112,31 @@ default void requireNativeAshSingleScoring(int bitsPerDimension) { /** Projection dot only; callers apply the vector's scale, offset and landmark term. */ default float ashCanonicalLutDot(byte[] code, int groups, float[] lut) { - return ASHLutScoring.dotCanonical(code, groups, lut); + return ashCanonicalLutDot(code, 0, groups, lut); + } + + /** As {@link #ashCanonicalLutDot(byte[], int, float[])} for the code starting at {@code codeOffset}. */ + default float ashCanonicalLutDot(byte[] code, int codeOffset, int groups, float[] lut) { + return ASHLutScoring.dotCanonical(code, codeOffset, groups, lut); } /** Dot product of a projected query with a canonical signed-magnitude 2/4-bit code. */ default float ashProjectionDot(float[] query, byte[] code, int dimensions, int bitsPerDimension) { + return ashProjectionDot(query, code, 0, dimensions, bitsPerDimension); + } + + /** As {@link #ashProjectionDot(float[], byte[], int, int)} for the code starting at {@code codeOffset}. */ + default float ashProjectionDot(float[] query, byte[] code, int codeOffset, int dimensions, int bitsPerDimension) { if (bitsPerDimension != 2 && bitsPerDimension != 4) { throw new IllegalArgumentException("Projection scoring requires 2 or 4 bits per dimension"); } java.util.Objects.checkFromIndexSize(0, dimensions, query.length); int perByte = 8 / bitsPerDimension; - java.util.Objects.checkFromIndexSize(0, dimensions / perByte + (dimensions % perByte == 0 ? 0 : 1), code.length); + java.util.Objects.checkFromIndexSize(codeOffset, dimensions / perByte + (dimensions % perByte == 0 ? 0 : 1), code.length); int sign = 1 << (bitsPerDimension - 1); float sum = 0; for (int i = 0; i < dimensions; i++) { - int field = (code[i / perByte] & 255) >>> ((i % perByte) * bitsPerDimension); + int field = (code[codeOffset + i / perByte] & 255) >>> ((i % perByte) * bitsPerDimension); float magnitude = (field & (sign - 1)) + 0.5f; sum += query[i] * ((field & sign) == 0 ? -magnitude : magnitude); } diff --git a/jvector-native/src/main/java/io/github/jbellis/jvector/vector/NativeASHKernels.java b/jvector-native/src/main/java/io/github/jbellis/jvector/vector/NativeASHKernels.java index 441b8c69b..d6d5a4289 100644 --- a/jvector-native/src/main/java/io/github/jbellis/jvector/vector/NativeASHKernels.java +++ b/jvector-native/src/main/java/io/github/jbellis/jvector/vector/NativeASHKernels.java @@ -80,6 +80,11 @@ final class NativeASHKernels { static boolean isFourBitDotAvailable() { return ADDRESS_OF_FOUR_BIT != null; } static float dotFourBit(float[] query, byte[] code, int dimensions) { + return dotFourBit(query, code, 0, dimensions); + } + + /** Projection dot with the 4-bit code starting at {@code codeOffset}. */ + static float dotFourBit(float[] query, byte[] code, int codeOffset, int dimensions) { if (!isFourBitDotAvailable()) { throw new UnsupportedOperationException("Native 4-bit ASH projection scoring is unavailable"); } @@ -87,18 +92,18 @@ static float dotFourBit(float[] query, byte[] code, int dimensions) { Objects.requireNonNull(code, "code"); Objects.checkFromIndexSize(0, dimensions, query.length); long bytes = (dimensions + 1L) / 2; - if (bytes > code.length) throw new IndexOutOfBoundsException("Canonical ASH code is too small"); + if (codeOffset < 0 || codeOffset + bytes > code.length) + throw new IndexOutOfBoundsException("Canonical ASH code is too small"); if (dimensions == 0) return 0f; if (dimensions <= MAX_CRITICAL_LANE_GROUPS) { - return invokeDot(CRITICAL_FOUR_BIT, - MemorySegment.ofArray(query), MemorySegment.ofArray(code), dimensions); + return dotFourBitCritical(query, code, codeOffset, dimensions); } try (Arena arena = Arena.ofConfined()) { long queryBytes = (long) dimensions * Float.BYTES; MemorySegment nativeQuery = arena.allocate(queryBytes, Float.BYTES); MemorySegment nativeCode = arena.allocate(bytes); MemorySegment.copy(MemorySegment.ofArray(query), 0, nativeQuery, 0, queryBytes); - MemorySegment.copy(MemorySegment.ofArray(code), 0, nativeCode, 0, bytes); + MemorySegment.copy(MemorySegment.ofArray(code), codeOffset, nativeCode, 0, bytes); return invokeDot(ORDINARY_FOUR_BIT, nativeQuery, nativeCode, dimensions); } } @@ -115,15 +120,21 @@ static boolean isAvailable() { static boolean isCanonicalLutAvailable() { return ADDRESS_OF_CANONICAL != null; } - /** One short heap-access call per direct node; no decoding or temporary output array. */ static float dotCanonical(byte[] code, int groups, float[] lut) { + return dotCanonical(code, 0, groups, lut); + } + + /** + * One short heap-access call per direct node, for the code starting at {@code codeOffset}; + * no decoding or temporary output array. + */ + static float dotCanonical(byte[] code, int codeOffset, int groups, float[] lut) { if (!isCanonicalLutAvailable()) { throw new UnsupportedOperationException("Native canonical ASH LUT scoring is unavailable"); } - ASHLutScoring.checkCanonicalBounds(code, groups, lut); + ASHLutScoring.checkCanonicalBounds(code, codeOffset, groups, lut); if (groups <= MAX_CRITICAL_LANE_GROUPS) { - return invokeDot(CRITICAL_CANONICAL, - MemorySegment.ofArray(code), MemorySegment.ofArray(lut), groups); + return dotCanonicalCritical(code, codeOffset, groups, lut); } // Bound time in a critical call by the same group/lane work budget as // neighborhood scoring. Unusually large legal codes may reach safepoints. @@ -132,12 +143,24 @@ static float dotCanonical(byte[] code, int groups, float[] lut) { long lutBytes = (long) groups * LUT_ENTRIES_PER_GROUP * Float.BYTES; MemorySegment nativeCode = arena.allocate(bytes); MemorySegment nativeLut = arena.allocate(lutBytes, Float.BYTES); - MemorySegment.copy(MemorySegment.ofArray(code), 0, nativeCode, 0, bytes); + MemorySegment.copy(MemorySegment.ofArray(code), codeOffset, nativeCode, 0, bytes); MemorySegment.copy(MemorySegment.ofArray(lut), 0, nativeLut, 0, lutBytes); return invokeDot(ORDINARY_CANONICAL, nativeCode, nativeLut, groups); } } + // Hot paths: each heap segment is created next to its invokeExact so, once the + // static final handle inlines, escape analysis can scalar-replace it. + private static float dotFourBitCritical(float[] query, byte[] code, int codeOffset, int dimensions) { + return invokeDot(CRITICAL_FOUR_BIT, MemorySegment.ofArray(query), + MemorySegment.ofArray(code).asSlice(codeOffset), dimensions); + } + + private static float dotCanonicalCritical(byte[] code, int codeOffset, int groups, float[] lut) { + return invokeDot(CRITICAL_CANONICAL, MemorySegment.ofArray(code).asSlice(codeOffset), + MemorySegment.ofArray(lut), groups); + } + private static float invokeDot(MethodHandle handle, MemorySegment first, MemorySegment second, int count) { try { diff --git a/jvector-native/src/main/java/io/github/jbellis/jvector/vector/NativeASHSymmetricKernels.java b/jvector-native/src/main/java/io/github/jbellis/jvector/vector/NativeASHSymmetricKernels.java index 7cf5f57bb..7f644ab48 100644 --- a/jvector-native/src/main/java/io/github/jbellis/jvector/vector/NativeASHSymmetricKernels.java +++ b/jvector-native/src/main/java/io/github/jbellis/jvector/vector/NativeASHSymmetricKernels.java @@ -90,6 +90,58 @@ static float dot(long[] aBits, byte[] aCodes, long[] bBits, byte[] bCodes, int d : pairOrdinary(MemorySegment.ofArray(aCodes), MemorySegment.ofArray(bCodes), bytes, dimensions, bits); } + /** 2/4-bit codes at offsets in larger arrays (flat code pages). Same arithmetic as {@link #dot}. */ + static float dotAt(byte[] aCodes, int aOffset, byte[] bCodes, int bOffset, int dimensions, int bits) { + if (dimensions < 0 || (bits != 2 && bits != 4)) throw badOffsetShape(); + if (PAIR_CRITICAL == null) throw pairUnavailable(); + long bytes = ((long) dimensions * bits + 7) / 8; + if (aOffset < 0 || bOffset < 0 || aOffset + bytes > aCodes.length || bOffset + bytes > bCodes.length) + throw outOfBounds("ASH code array ranges"); + if (TRACE) trace(PAIR_SYMBOL, dimensions, bits); + return dimensions <= MAX_CRITICAL_WORK + ? pairCriticalAt(aCodes, aOffset, bCodes, bOffset, dimensions, bits) + : pairOrdinary(MemorySegment.ofArray(aCodes).asSlice(aOffset, bytes), + MemorySegment.ofArray(bCodes).asSlice(bOffset, bytes), bytes, dimensions, bits); + } + + /** 1-bit sign words at offsets in larger arrays (code pages). Same arithmetic as {@link #dot}. */ + static float binaryDotAt(long[] aBits, int aOffset, long[] bBits, int bOffset, int dimensions) { + if (dimensions < 0) throw badPairShape(); + if (PAIR_CRITICAL == null) throw pairUnavailable(); + long words = (dimensions + 63L) / 64; + if (aOffset < 0 || bOffset < 0 || aOffset + words > aBits.length || bOffset + words > bBits.length) + throw outOfBounds("ASH bit array ranges"); + if (TRACE) trace(PAIR_SYMBOL, dimensions, 1); + return dimensions <= MAX_CRITICAL_WORK + ? pairCriticalAt(aBits, aOffset, bBits, bOffset, dimensions) + : pairOrdinary(MemorySegment.ofArray(aBits).asSlice((long) aOffset * Long.BYTES, words * Long.BYTES), + MemorySegment.ofArray(bBits).asSlice((long) bOffset * Long.BYTES, words * Long.BYTES), + words * Long.BYTES, dimensions, 1); + } + + private static float pairCriticalAt(long[] a, int aOffset, long[] b, int bOffset, int dimensions) { + try { + return (float) PAIR_CRITICAL.invokeExact(MemorySegment.ofArray(a).asSlice((long) aOffset * Long.BYTES), + MemorySegment.ofArray(b).asSlice((long) bOffset * Long.BYTES), dimensions, 1); + } catch (RuntimeException | Error e) { + throw e; + } catch (Throwable t) { + throw unexpected(PAIR_SYMBOL, t); + } + } + + // Hot path for dotAt: as in pairCritical, the sliced segments stay local to invokeExact. + private static float pairCriticalAt(byte[] a, int aOffset, byte[] b, int bOffset, int dimensions, int bits) { + try { + return (float) PAIR_CRITICAL.invokeExact(MemorySegment.ofArray(a).asSlice(aOffset), + MemorySegment.ofArray(b).asSlice(bOffset), dimensions, bits); + } catch (RuntimeException | Error e) { + throw e; + } catch (Throwable t) { + throw unexpected(PAIR_SYMBOL, t); + } + } + // Hot path: segments are created and consumed here so they don't escape after inlining. private static float pairCritical(byte[] a, byte[] b, int dimensions, int bits) { try { @@ -195,6 +247,10 @@ private static IllegalArgumentException badPairShape() { return new IllegalArgumentException("Symmetric ASH requires nonnegative dimensions and bits in {1,2,4}"); } + private static IllegalArgumentException badOffsetShape() { + return new IllegalArgumentException("Offset symmetric ASH requires nonnegative dimensions and bits in {2,4}"); + } + private static UnsupportedOperationException pairUnavailable() { return new UnsupportedOperationException("Native symmetric pair kernel unavailable"); } 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 97287496b..d6318792d 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 @@ -107,6 +107,21 @@ public float ashSymmetricDot(long[] aBits, byte[] aCodes, long[] bBits, byte[] b : super.ashSymmetricDot(aBits, aCodes, bBits, bCodes, dimensions, bits); } + @Override + public float ashSymmetricBinaryDotAt(long[] aBits, int aOffset, long[] bBits, int bOffset, int dimensions) { + return NativeASHSymmetricKernels.hasPairKernel() + ? NativeASHSymmetricKernels.binaryDotAt(aBits, aOffset, bBits, bOffset, dimensions) + : super.ashSymmetricBinaryDotAt(aBits, aOffset, bBits, bOffset, dimensions); + } + + @Override + public float ashSymmetricDotAt(byte[] aCodes, int aOffset, byte[] bCodes, int bOffset, + int dimensions, int bits) { + return NativeASHSymmetricKernels.hasPairKernel() + ? NativeASHSymmetricKernels.dotAt(aCodes, aOffset, bCodes, bOffset, dimensions, bits) + : super.ashSymmetricDotAt(aCodes, aOffset, bCodes, bOffset, dimensions, bits); + } + @Override public void ashSymmetricLutScore(byte[] codes, int groups, int stride, int lane, int count, short[] lut, float[] out, int outOffset) { @@ -133,16 +148,16 @@ public void requireNativeAshSingleScoring(int bitsPerDimension) { } @Override - public float ashProjectionDot(float[] query, byte[] code, int dimensions, int bitsPerDimension) { + public float ashProjectionDot(float[] query, byte[] code, int codeOffset, int dimensions, int bitsPerDimension) { if (bitsPerDimension == 4 && NativeASHKernels.isFourBitDotAvailable()) { - return NativeASHKernels.dotFourBit(query, code, dimensions); + return NativeASHKernels.dotFourBit(query, code, codeOffset, dimensions); } if (REQUIRE_NATIVE_ASH) { requireNativeAshSingleScoring(bitsPerDimension); if (bitsPerDimension == 2) throw new IllegalStateException("Native b=2 scoring requires its prepared LUT; use ASHScorer"); } - return super.ashProjectionDot(query, code, dimensions, bitsPerDimension); + return super.ashProjectionDot(query, code, codeOffset, dimensions, bitsPerDimension); } @Override @@ -151,8 +166,8 @@ public boolean supportsAshCanonicalLutScoring() { } @Override - public float ashCanonicalLutDot(byte[] code, int groups, float[] lut) { - return NativeASHKernels.dotCanonical(code, groups, lut); + public float ashCanonicalLutDot(byte[] code, int codeOffset, int groups, float[] lut) { + return NativeASHKernels.dotCanonical(code, codeOffset, groups, lut); } @Override diff --git a/jvector-native/src/test/java/io/github/jbellis/jvector/vector/NativeASHSymmetricDotAtTest.java b/jvector-native/src/test/java/io/github/jbellis/jvector/vector/NativeASHSymmetricDotAtTest.java new file mode 100644 index 000000000..d1bcc5b33 --- /dev/null +++ b/jvector-native/src/test/java/io/github/jbellis/jvector/vector/NativeASHSymmetricDotAtTest.java @@ -0,0 +1,94 @@ +/* + * 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.cnative.LibraryLoader; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +import java.util.Random; + +import static org.junit.jupiter.api.Assertions.*; +import static org.junit.jupiter.api.Assumptions.assumeTrue; + +/** Offset kernels on codes embedded in larger arrays must equal the standalone kernels, bit for bit. */ +class NativeASHSymmetricDotAtTest { + @BeforeAll + static void loadLibrary() { + assumeTrue(LibraryLoader.loadJvector(), "Native library unavailable"); + assertTrue(NativeASHSymmetricKernels.hasPairKernel()); + } + + @Test + void offsetCodesMatchStandaloneCodes() { + Random random = new Random(20261108); + for (int bits : new int[]{2, 4}) { + for (int dimensions : new int[]{0, 1, 3, 7, 15, 16, 17, 31, 32, 33, 63, 64, 65, 127, 128, 129, + 257, 384, 512, 768, 1024, 1536, 3072, 65537}) { + int bytes = (dimensions * bits + 7) / 8; + for (int trial = 0; trial < 4; trial++) { + byte[] a = new byte[bytes], b = new byte[bytes]; + random.nextBytes(a); random.nextBytes(b); + // Embed at arbitrary offsets surrounded by noise, as in a code page. + int aOff = random.nextInt(130), bOff = random.nextInt(130); + byte[] aPage = new byte[aOff + bytes + random.nextInt(70)]; + byte[] bPage = new byte[bOff + bytes + random.nextInt(70)]; + random.nextBytes(aPage); random.nextBytes(bPage); + System.arraycopy(a, 0, aPage, aOff, bytes); + System.arraycopy(b, 0, bPage, bOff, bytes); + float expected = NativeASHSymmetricKernels.dot(null, a, null, b, dimensions, bits); + assertEquals(expected, NativeASHSymmetricKernels.dotAt(aPage, aOff, bPage, bOff, dimensions, bits), 0f, + "bits=" + bits + " d=" + dimensions); + assertEquals(expected, NativeASHSymmetricKernels.dotAt(bPage, bOff, aPage, aOff, dimensions, bits), 0f); + } + } + } + } + + @Test + void offsetSignWordsMatchStandaloneSignWords() { + Random random = new Random(20261109); + for (int dimensions : new int[]{0, 1, 63, 64, 65, 384, 1000, 65537}) { + int words = (dimensions + 63) / 64; + for (int trial = 0; trial < 4; trial++) { + long[] a = random.longs(words).toArray(), b = random.longs(words).toArray(); + int aOff = random.nextInt(20), bOff = random.nextInt(20); + long[] aPage = random.longs(aOff + words + random.nextInt(9)).toArray(); + long[] bPage = random.longs(bOff + words + random.nextInt(9)).toArray(); + System.arraycopy(a, 0, aPage, aOff, words); + System.arraycopy(b, 0, bPage, bOff, words); + float expected = NativeASHSymmetricKernels.dot(a, null, b, null, dimensions, 1); + assertEquals(expected, NativeASHSymmetricKernels.binaryDotAt(aPage, aOff, bPage, bOff, dimensions), 0f, + "d=" + dimensions); + } + } + long[] page = new long[4]; + assertThrows(IllegalArgumentException.class, () -> NativeASHSymmetricKernels.binaryDotAt(page, 0, page, 0, -1)); + assertThrows(IndexOutOfBoundsException.class, () -> NativeASHSymmetricKernels.binaryDotAt(page, 3, page, 0, 128)); + assertDoesNotThrow(() -> NativeASHSymmetricKernels.binaryDotAt(page, 2, page, 0, 128)); + } + + @Test + void argumentAndBoundsChecks() { + byte[] page = new byte[200]; + assertThrows(IllegalArgumentException.class, () -> NativeASHSymmetricKernels.dotAt(page, 0, page, 0, 8, 1)); + assertThrows(IllegalArgumentException.class, () -> NativeASHSymmetricKernels.dotAt(page, 0, page, 0, -1, 2)); + assertThrows(IndexOutOfBoundsException.class, () -> NativeASHSymmetricKernels.dotAt(page, 150, page, 0, 384, 2)); + assertThrows(IndexOutOfBoundsException.class, () -> NativeASHSymmetricKernels.dotAt(page, 0, page, -1, 8, 2)); + // Exactly fitting at the end of the page is legal. + assertDoesNotThrow(() -> NativeASHSymmetricKernels.dotAt(page, 104, page, 0, 384, 2)); + } +} diff --git a/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestASHCodes.java b/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestASHCodes.java new file mode 100644 index 000000000..6ca31a706 --- /dev/null +++ b/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestASHCodes.java @@ -0,0 +1,279 @@ +/* + * 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 io.github.jbellis.jvector.disk.ByteBufferIndexWriter; +import io.github.jbellis.jvector.disk.ByteBufferReader; +import io.github.jbellis.jvector.graph.MockVectorValues; +import io.github.jbellis.jvector.vector.VectorSimilarityFunction; +import io.github.jbellis.jvector.vector.VectorizationProvider; +import io.github.jbellis.jvector.vector.types.VectorFloat; +import org.junit.Assume; +import org.junit.Test; + +import java.nio.ByteBuffer; +import java.util.Random; +import java.util.concurrent.ForkJoinPool; + +import static org.junit.Assert.*; + +/** + * The compact code store must hold exactly the codes it is given, serialize them in the + * standalone vector format, and score them identically to standalone vectors, including + * across page boundaries. + */ +public class TestASHCodes { + private static final int[] BITS = {1, 2, 3, 4, 9}; + // Small pages so that a few hundred vectors span many of them. + private static final int SMALL_PAGE_BYTES = 256; + + private static AsymmetricHashing.QuantizedVector randomVector(int dims, int bits, Random random) { + var v = AsymmetricHashing.QuantizedVector.createEmpty(dims, bits); + for (int w = 0; w < v.binaryVector.length; w++) v.binaryVector[w] = random.nextLong(); + random.nextBytes(v.extraBits); + // Values exactly representable in binary16, so a serialization round trip is lossless. + v.scale = 0.5f + random.nextInt(64) / 64f; + v.offset = -1f + random.nextInt(128) / 64f; + v.landmark = (byte) random.nextInt(3); + return v; + } + + private static ASHCodes store(AsymmetricHashing.QuantizedVector[] vectors, int dims, int bits) { + var codes = ASHCodes.allocate(vectors.length, dims, bits, SMALL_PAGE_BYTES); + for (int i = 0; i < vectors.length; i++) codes.set(i, vectors[i]); + return codes; + } + + @Test + public void storesExactlyWhatItIsGivenAcrossPages() { + var random = new Random(4); + for (int bits : BITS) for (int dims : new int[]{1, 33, 64, 200}) { + var vectors = new AsymmetricHashing.QuantizedVector[301]; + for (int i = 0; i < vectors.length; i++) vectors[i] = randomVector(dims, bits, random); + var codes = store(vectors, dims, bits); + assertTrue("bits=" + bits + " dims=" + dims + " should span pages", + (codes.signPages != null ? codes.signPages.length : codes.bodyPages.length) > 1); + var reused = AsymmetricHashing.QuantizedVector.createEmpty(dims, bits); + for (int i = 0; i < vectors.length; i++) { + assertEquals(vectors[i], codes.get(i)); + codes.copyTo(i, reused); + assertEquals(vectors[i], reused); + } + // Same content in a store with different page sizes is equal and hashes equally. + var defaultPages = ASHCodes.copyOf(vectors, dims, bits); + assertTrue(codes.contentEquals(defaultPages)); + assertEquals(codes.contentHash(), defaultPages.contentHash()); + + int[] newToOld = new int[vectors.length]; + for (int i = 0; i < newToOld.length; i++) newToOld[i] = (i * 7) % newToOld.length; + var permuted = codes.permute(newToOld); + for (int i = 0; i < vectors.length; i++) assertEquals(vectors[newToOld[i]], permuted.get(i)); + } + } + + @Test + public void rejectsVectorsWithTheWrongShape() { + var codes = ASHCodes.allocate(2, 64, 4); + var shortCode = AsymmetricHashing.QuantizedVector.createEmpty(64, 4); + shortCode.extraBits = new byte[shortCode.extraBits.length - 1]; + assertThrows(IllegalArgumentException.class, () -> codes.set(0, shortCode)); + assertThrows(IndexOutOfBoundsException.class, + () -> codes.set(2, AsymmetricHashing.QuantizedVector.createEmpty(64, 4))); + assertThrows(IllegalArgumentException.class, () -> ASHCodes.allocate(1, 64, 4, 0)); + } + + @Test + public void serializesInTheStandaloneVectorFormat() throws Exception { + var random = new Random(5); + for (int bits : BITS) for (int dims : new int[]{1, 33, 200}) { + var vectors = new AsymmetricHashing.QuantizedVector[97]; + for (int i = 0; i < vectors.length; i++) vectors[i] = randomVector(dims, bits, random); + int size = vectors.length * AsymmetricHashing.QuantizedVector.serializedSizeBytes(dims, bits); + ByteBuffer expected, actual; + try (var writer = ByteBufferIndexWriter.create(size, false)) { + for (var v : vectors) v.write(writer, dims, bits); + expected = writer.getWrittenData(); + } + try (var writer = ByteBufferIndexWriter.create(size, false)) { + store(vectors, dims, bits).write(writer); + actual = writer.getWrittenData(); + } + assertEquals("bits=" + bits + " dims=" + dims, expected, actual); + var loaded = ASHCodes.load(new ByteBufferReader(actual.duplicate()), vectors.length, dims, bits); + for (int i = 0; i < vectors.length; i++) assertEquals(vectors[i], loaded.get(i)); + } + } + + private static VectorFloat[] inputs(int n, int dim, long seed) { + var vts = VectorizationProvider.getInstance().getVectorTypeSupport(); + var random = new Random(seed); + VectorFloat[] out = new VectorFloat[n]; + for (int i = 0; i < n; i++) { + float[] v = new float[dim]; + for (int j = 0; j < dim; j++) v[j] = (float) random.nextGaussian(); + out[i] = vts.createFloatVector(v); + } + return out; + } + + /** Encoded vectors re-stored in small pages, plus standalone copies of the same codes. */ + private static ASHVectors smallPages(ASHVectors encoded) { + var a = encoded.getCompressor(); + var codes = ASHCodes.allocate(encoded.count(), a.quantizedDim, a.bitsPerDimension, SMALL_PAGE_BYTES); + for (int i = 0; i < encoded.count(); i++) codes.set(i, encoded.get(i)); + return new ASHVectors(a, codes, encoded.symmetricOffsets); + } + + @Test + public void asymmetricScoresMatchStandaloneVectors() throws Exception { + for (int bits : new int[]{1, 2, 3, 4}) for (int dim : new int[]{40, 130}) { + var input = inputs(260, dim, 17L * bits + dim); + var values = MockVectorValues.fromValues(input); + var ash = AsymmetricHashing.initialize(values, AsymmetricHashing.RANDOM, + AsymmetricHashing.HEADER_BITS + (dim / 2) * bits, 2, bits); + var vectors = smallPages(ash.encodeAll(values, ForkJoinPool.commonPool())); + var reference = new ASHScorer(ash); + for (int q = 0; q < 5; q++) { + var query = input[q * 31]; + var standalone = reference.scoreFunctionFor(query, VectorSimilarityFunction.DOT_PRODUCT); + var inPlace = vectors.scoreFunctionFor(query, VectorSimilarityFunction.DOT_PRODUCT); + for (int i = 0; i < vectors.count(); i++) { + float expected = ASHScorer.toSimilarity(standalone.similarityTo(vectors.get(i))); + float actual = inPlace.similarityTo(i); + if (bits == 1) { + // The 1-bit SIMD kernel accumulates in a different order. + assertEquals("bits=1 dim=" + dim + " i=" + i, expected, actual, 1e-5f * Math.max(1f, Math.abs(expected))); + } else { + assertEquals("bits=" + bits + " dim=" + dim + " i=" + i, + Float.floatToIntBits(expected), Float.floatToIntBits(actual)); + } + } + } + } + } + + @Test + public void symmetricScoresDoNotDependOnWhereTheQueryIsStored() throws Exception { + for (int bits : new int[]{1, 2, 4}) for (int dim : new int[]{40, 96, 200}) { + var input = inputs(300, dim, 31L * bits + dim); + var values = MockVectorValues.fromValues(input); + var ash = AsymmetricHashing.initialize(values, AsymmetricHashing.RANDOM, + AsymmetricHashing.HEADER_BITS + (dim / 2) * bits, 1, bits); + var vectors = smallPages(ash.encodeAll(values, ForkJoinPool.commonPool())); + var auto = new ASHSymmetricScorer(vectors, ASHSymmetricScorer.Kernel.AUTO); + var scalar = new ASHSymmetricScorer(vectors, ASHSymmetricScorer.Kernel.SCALAR); + var random = new Random(bits * 1000L + dim); + for (int t = 0; t < 3000; t++) { + int i = random.nextInt(input.length), j = random.nextInt(input.length); + String where = "bits=" + bits + " dim=" + dim + " i=" + i + " j=" + j; + int expected = Float.floatToIntBits(scalar.scoreFunctionFor(i).similarityTo(j)); + // Integer-exact pair kernels: SIMD and scalar agree bit for bit. + assertEquals(where, expected, Float.floatToIntBits(auto.scoreFunctionFor(i).similarityTo(j))); + // A standalone copy of a stored query scores the same as the stored query. + assertEquals(where, expected, Float.floatToIntBits(auto.scoreFunctionFor(vectors.get(i)).similarityTo(j))); + } + var diversity = vectors.diversityFunctionFor(3, VectorSimilarityFunction.DOT_PRODUCT); + for (int j = 0; j < input.length; j++) { + assertEquals(Float.floatToIntBits(auto.scoreFunctionFor(3).similarityTo(j)), + Float.floatToIntBits(diversity.similarityTo(j))); + } + } + } + + @Test + public void reorderingMovesCodesWithTheirOrdinals() throws Exception { + var input = inputs(200, 64, 9); + var values = MockVectorValues.fromValues(input); + var ash = AsymmetricHashing.initialize(values, AsymmetricHashing.RANDOM, + AsymmetricHashing.HEADER_BITS + 32 * 4, 3, 4); + var vectors = smallPages(ash.encodeAll(values, ForkJoinPool.commonPool())); + var order = vectors.reorderByLandmarkFast(); + for (int n = 0; n < vectors.count(); n++) { + assertEquals(vectors.get(order.newToOld[n]), order.vectors.get(n)); + } + assertEquals(vectors.ramBytesUsed(), order.vectors.ramBytesUsed()); + } + + @Test + public void offsetKernelsMatchZeroOffsetKernels() { + var vu = VectorizationProvider.getInstance().getVectorUtilSupport(); + var random = new Random(20261109); + for (int bits : new int[]{2, 4}) { + // Odd and partial-word lengths exercise the vector tails. + for (int dims : new int[]{1, 3, 7, 31, 32, 33, 63, 65, 127, 129, 384, 768, 1536}) { + int bytes = (dims * bits + 7) / 8; + for (int trial = 0; trial < 4; trial++) { + byte[] a = new byte[bytes], b = new byte[bytes]; + random.nextBytes(a); + random.nextBytes(b); + // Embed the codes at arbitrary offsets among random bytes, as in a code page. + int aOff = random.nextInt(130), bOff = random.nextInt(130); + byte[] aPage = embed(a, aOff, random); + byte[] bPage = embed(b, bOff, random); + String where = "bits=" + bits + " dims=" + dims; + // Padded: the opt-in tuned kernel gathers whole vectors of query lanes. + float[] query = new float[dims + 64]; + for (int k = 0; k < dims; k++) query[k] = (float) random.nextGaussian(); + assertEquals(where, Float.floatToIntBits(vu.ashProjectionDot(query, a, dims, bits)), + Float.floatToIntBits(vu.ashProjectionDot(query, aPage, aOff, dims, bits))); + assertEquals(where, Float.floatToIntBits(vu.ashProjectionDotTuned(query, a, dims, bits)), + Float.floatToIntBits(vu.ashProjectionDotTuned(query, aPage, aOff, dims, bits))); + int groups = FusedASHLayout.codeGroups(dims, bits); + float[] lut = new float[groups * 16]; + for (int k = 0; k < lut.length; k++) lut[k] = (float) random.nextGaussian(); + assertEquals(where, Float.floatToIntBits(vu.ashCanonicalLutDot(a, groups, lut)), + Float.floatToIntBits(vu.ashCanonicalLutDot(aPage, aOff, groups, lut))); + if (vu.supportsAshSymmetricDotAt()) { + assertEquals(where, Float.floatToIntBits(vu.ashSymmetricDot(null, a, null, b, dims, bits)), + Float.floatToIntBits(vu.ashSymmetricDotAt(aPage, aOff, bPage, bOff, dims, bits))); + } + } + } + } + if (vu.supportsAshSymmetricScoring()) { + for (int dims : new int[]{1, 63, 64, 65, 384, 1000}) { + int words = (dims + 63) / 64; + long[] a = random.longs(words).toArray(), b = random.longs(words).toArray(); + long[] aPage = new long[words + 9], bPage = new long[words + 5]; + System.arraycopy(a, 0, aPage, 7, words); + System.arraycopy(b, 0, bPage, 2, words); + assertEquals("dims=" + dims, Float.floatToIntBits(vu.ashSymmetricDot(a, null, b, null, dims, 1)), + Float.floatToIntBits(vu.ashSymmetricBinaryDotAt(aPage, 7, bPage, 2, dims))); + } + long[] bitsPage = new long[4]; + assertThrows(IndexOutOfBoundsException.class, () -> vu.ashSymmetricBinaryDotAt(bitsPage, 3, bitsPage, 0, 128)); + assertThrows(IndexOutOfBoundsException.class, () -> vu.ashSymmetricBinaryDotAt(bitsPage, -1, bitsPage, 0, 64)); + } + float[] query = new float[384]; + byte[] page = new byte[200]; + assertThrows(IndexOutOfBoundsException.class, () -> vu.ashProjectionDot(query, page, 150, 384, 2)); + assertThrows(IndexOutOfBoundsException.class, () -> vu.ashProjectionDot(query, page, -1, 8, 2)); + // A code that ends exactly at the end of the array is in range. + vu.ashProjectionDot(query, page, 104, 384, 2); + if (vu.supportsAshSymmetricDotAt()) { + assertThrows(IllegalArgumentException.class, () -> vu.ashSymmetricDotAt(page, 0, page, 0, 8, 1)); + assertThrows(IndexOutOfBoundsException.class, () -> vu.ashSymmetricDotAt(page, 150, page, 0, 384, 2)); + vu.ashSymmetricDotAt(page, 104, page, 0, 384, 2); + } + } + + private static byte[] embed(byte[] code, int offset, Random random) { + byte[] page = new byte[offset + code.length + random.nextInt(70)]; + random.nextBytes(page); + System.arraycopy(code, 0, page, offset, code.length); + return page; + } +} diff --git a/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestASHMemoryAccounting.java b/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestASHMemoryAccounting.java index ef05658e5..42eb6c19e 100644 --- a/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestASHMemoryAccounting.java +++ b/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestASHMemoryAccounting.java @@ -79,6 +79,10 @@ private static Object field(Object object, String name) throws Exception { return f.get(object); } + private static long pagesShallow(Object[] pages) { + return pages == null ? 0 : RamUsageEstimator.shallowSizeOf(pages); + } + private static long blockBytes(byte[][] blocks) { long bytes = RamUsageEstimator.shallowSizeOf(blocks); for (byte[] block : blocks) bytes += RamUsageEstimator.sizeOf(block); @@ -111,12 +115,19 @@ public void collectionsChargeVectorsHeadersAndModelExactlyOnce() throws Exceptio for (int count : new int[]{1,17,1024}) { var data = codes(ash, count); var vectors = new ASHVectors(ash, data); - long expectedDelta = RamUsageEstimator.shallowSizeOf(data) - - RamUsageEstimator.shallowSizeOf(new Object[0]) + // Code pages plus decoded headers, with no per-vector object or array. + var store = (ASHCodes) field(vectors, "codes"); + var none = (ASHCodes) field(empty, "codes"); + long expectedDelta = pagesShallow(store.signPages) + pagesShallow(store.bodyPages) + - pagesShallow(none.signPages) - pagesShallow(none.bodyPages) + 2*(RamUsageEstimator.sizeOf(new float[count])-RamUsageEstimator.sizeOf(new float[0])) + RamUsageEstimator.sizeOf(new byte[count])-RamUsageEstimator.sizeOf(new byte[0]); - for (var code : data) expectedDelta += code.ramBytesUsed(); + if (store.signPages != null) for (long[] page : store.signPages) expectedDelta += RamUsageEstimator.sizeOf(page); + if (store.bodyPages != null) for (byte[] page : store.bodyPages) expectedDelta += RamUsageEstimator.sizeOf(page); assertEquals(expectedDelta, vectors.ramBytesUsed()-empty.ramBytesUsed()); + long payload = (long) count * (store.signWords * Long.BYTES + store.bodyBytes + ASHCodes.HEADER_RAM_BYTES); + assertTrue("bits=" + bits + " count=" + count + " must not exceed its payload by more than page and" + + " array headers", vectors.ramBytesUsed() - empty.ramBytesUsed() <= payload + 256); long bytes = vectors.ramBytesUsed(); for (int i=0;i<10;i++) assertEquals(bytes, vectors.ramBytesUsed()); assertEquals(bytes, vectors.reorderByLandmarkFast().vectors.ramBytesUsed()); @@ -125,7 +136,7 @@ public void collectionsChargeVectorsHeadersAndModelExactlyOnce() throws Exceptio } @Test - public void sharedOrdinalsAreChargedConservativelyWithoutIdentityMaps() throws Exception { + public void sharedInputVectorsAreCopiedAndChargedPerOrdinal() throws Exception { var ash = model(2, 1); var data = codes(ash, 2); var separate = new ASHVectors(ash, data); @@ -134,28 +145,32 @@ public void sharedOrdinalsAreChargedConservativelyWithoutIdentityMaps() throws E } @Test - public void conservativeLargeCollectionTotalsUseLongArithmetic() throws Exception { + public void oversizedInputArraysAreNotRetained() throws Exception { var ash = model(4, 1); var code = codes(ash, 1)[0]; - code.extraBits = new byte[8192]; // Valid oversized capacity; only its code prefix is scored. + code.extraBits = new byte[8192]; // Valid oversized capacity; only its code prefix is stored. var data = new AsymmetricHashing.QuantizedVector[300_000]; Arrays.fill(data, code); var vectors = new ASHVectors(ash, data); + var store = (ASHCodes) field(vectors, "codes"); assertTrue((long)data.length*code.ramBytesUsed() > Integer.MAX_VALUE); - assertTrue(vectors.ramBytesUsed() > Integer.MAX_VALUE); - long bytes = vectors.ramBytesUsed(); - assertEquals(bytes, vectors.ramBytesUsed()); + long payload = (long) data.length * (store.bodyBytes + ASHCodes.HEADER_RAM_BYTES); + assertTrue(vectors.ramBytesUsed() - ash.ramBytesUsed() < payload + (1 << 16)); + assertEquals(code.extraBits.length, 8192); + assertEquals(store.bodyBytes, vectors.get(7).extraBits.length); } @Test - public void productionCodeLayoutsExposeThePreviouslyMissingPerVectorStorage() { + public void productionCodeLayoutsRetainLittleMoreThanTheirSerializedSize() { for (int bits : new int[]{2,4}) for (int dimension : new int[]{384,512,768,1536}) { - var code = AsymmetricHashing.QuantizedVector.createEmpty(dimension, bits); - long missing = code.ramBytesUsed()+2L*Float.BYTES+Byte.BYTES - - AsymmetricHashing.QuantizedVector.serializedSizeBytes(dimension,bits); - assertTrue(missing > 0); - System.out.printf("ASH_RETAINED_LAYOUT refs=%d bits=%d projected=%d extra_per_million=%d%n", - RamUsageEstimator.NUM_BYTES_OBJECT_REF,bits,dimension,missing*1_000_000L); + int vectors = 100_000; + var codes = ASHCodes.allocate(vectors, dimension, bits); + // Per vector: the code body, plus 4 bytes because the two binary16 headers are kept as floats. + long perVector = AsymmetricHashing.QuantizedVector.serializedSizeBytes(dimension, bits) + - AsymmetricHashing.HEADER_BYTES + ASHCodes.HEADER_RAM_BYTES; + long overhead = codes.ramBytesUsed() - vectors * perVector; + assertTrue("bits=" + bits + " dims=" + dimension + " overhead=" + overhead, + overhead >= 0 && overhead < 64L * 1024); } } @@ -240,10 +255,9 @@ public void concurrentCacheCreationIsChargedOnceAndFailuresDoNotPublish() throws assertTrue(pool.awaitTermination(30, java.util.concurrent.TimeUnit.SECONDS)); } assertEquals(serial.ramBytesUsed(), parallel.ramBytesUsed()); + // A vector without a complete code is rejected when the collection is built. var bad = codes(ash, 17); bad[0].extraBits = new byte[0]; - var broken = new ASHVectors(ash, bad); long before = broken.ramBytesUsed(); - assertThrows(IllegalArgumentException.class, () -> broken.symmetricProjectionBlocks(8)); - assertEquals(before, broken.ramBytesUsed()); + assertThrows(IllegalArgumentException.class, () -> new ASHVectors(ash, bad)); } @Test @@ -251,15 +265,12 @@ public void symmetricCachesAreChargedWithoutCountingTheirSharedModelAgain() thro for (int bits : new int[]{1,2,4}) { var ash = model(bits, 2); var vectors = new ASHVectors(ash, codes(ash, 17)); long before = vectors.ramBytesUsed(); - long[] previousFlat = (long[])field(vectors, "flatPackedVectors"); vectors.diversityFunctionFor(0, VectorSimilarityFunction.DOT_PRODUCT); Object symmetric = field(vectors, "diversityScorer"); float[][] dots = (float[][]) field(symmetric, "landmarkDots"); long expected = RamUsageEstimator.shallowSizeOf(symmetric)+RamUsageEstimator.shallowSizeOf(dots); for (float[] row : dots) expected += RamUsageEstimator.sizeOf(row); expected += RamUsageEstimator.shallowSizeOf((float[])field(symmetric, "centeredOffsets")); - expected += RamUsageEstimator.shallowSizeOf((long[])field(vectors, "flatPackedVectors")) - - RamUsageEstimator.shallowSizeOf(previousFlat); assertEquals(expected, vectors.ramBytesUsed()-before); long after = vectors.ramBytesUsed(); vectors.diversityFunctionFor(1, VectorSimilarityFunction.DOT_PRODUCT); @@ -268,12 +279,14 @@ public void symmetricCachesAreChargedWithoutCountingTheirSharedModelAgain() thro } @Test - public void oneBitFlatAndBlockRepresentationsAreBothCharged() throws Exception { + public void oneBitSingleScoringAddsNoCopyAndBlockLayoutIsCharged() throws Exception { assumeTrue(VectorizationProvider.getInstance().getVectorUtilSupport().supportsAshMaskedLoad()); var ash = model(1, 1); var vectors = new ASHVectors(ash, codes(ash, 17)); - long before = vectors.ramBytesUsed(); - long[] flat = vectors.getFlatPackedVectors(); - assertEquals(RamUsageEstimator.sizeOf(flat), vectors.ramBytesUsed()-before); + long ownBefore = vectors.ramBytesUsed() - ash.ramBytesUsed(); + // Single-vector SIMD scoring reads the stored sign pages in place. Query preparation may + // initialize provider weights in the model, so compare the collection's own bytes. + vectors.scoreFunctionFor(ash.landmarks[0], VectorSimilarityFunction.DOT_PRODUCT).similarityTo(3); + assertEquals(ownBefore, vectors.ramBytesUsed() - ash.ramBytesUsed()); var query = ash.landmarks[0]; for (int block : new int[]{8,16,8}) { vectors.blockScorerFor(query, VectorSimilarityFunction.DOT_PRODUCT, block); @@ -281,7 +294,7 @@ public void oneBitFlatAndBlockRepresentationsAreBothCharged() throws Exception { // Query preparation may initialize provider weights; subtract model growth. long own = vectors.ramBytesUsed()-ash.ramBytesUsed(); assertTrue(packed != null); - assertTrue(own >= RamUsageEstimator.sizeOf(flat)+RamUsageEstimator.sizeOf(packed)); + assertTrue(own >= RamUsageEstimator.sizeOf(packed)); long stable = vectors.ramBytesUsed(); vectors.blockScorerFor(query, VectorSimilarityFunction.DOT_PRODUCT, block); assertEquals(stable, vectors.ramBytesUsed()); diff --git a/jvector-twenty/src/main/java/io/github/jbellis/jvector/vector/PanamaASHKernels.java b/jvector-twenty/src/main/java/io/github/jbellis/jvector/vector/PanamaASHKernels.java index 53f72d991..465fd971c 100644 --- a/jvector-twenty/src/main/java/io/github/jbellis/jvector/vector/PanamaASHKernels.java +++ b/jvector-twenty/src/main/java/io/github/jbellis/jvector/vector/PanamaASHKernels.java @@ -168,12 +168,13 @@ private static FloatVector lookup(float[] lut, int offset, IntVector indices) { return result.reinterpretAsFloats(); } - static float projectionDot(float[] query, byte[] code, int dimensions, int bits) { + /** Projection dot with the code starting at {@code codeOffset}. */ + static float projectionDot(float[] query, byte[] code, int codeOffset, int dimensions, int bits) { if (bits != 2 && bits != 4) throw new IllegalArgumentException("Projection scoring requires 2 or 4 bits per dimension"); Objects.checkFromIndexSize(0, dimensions, query.length); int perByte = 8 / bits; int bytes = dimensions / perByte + (dimensions % perByte == 0 ? 0 : 1); - Objects.checkFromIndexSize(0, bytes, code.length); + Objects.checkFromIndexSize(codeOffset, bytes, code.length); int sign = 1 << (bits - 1); int[] indices = bits == 2 ? TWO_BIT_INDICES : FOUR_BIT_INDICES; FloatVector a0 = FloatVector.zero(F); @@ -189,7 +190,7 @@ static float projectionDot(float[] query, byte[] code, int dimensions, int bits) int remainingChunks = (bytes - base - 1) / F.length() + 1; int chunks = Math.min(PROJECTION_UNROLL, remainingChunks); for (int u = 0; u < chunks; u++, base += F.length()) { - var packed = loadCodes(code, base, B.indexInRange(0, Math.min(F.length(), bytes - base))); + var packed = loadCodes(code, codeOffset + base, B.indexInRange(0, Math.min(F.length(), bytes - base))); // Reuse each widened code byte across its 2/4 projected dimensions. // Keep the decode, query gather and accumulation in one compilation unit. for (int slot = 0; slot < perByte; slot++) { 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 45d88033b..517617be7 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 @@ -118,30 +118,54 @@ public float ashSymmetricDot(long[] aBits, byte[] aCodes, long[] bBits, byte[] b // Keep the Vector API compilation units small and species compile-time constant. // A shared, large dispatch body can leave boxed vectors in graph-pruning callers. return switch (bits) { - case 1 -> ashSymmetricBinary(aBits, bBits, dimensions); - case 2 -> ashSymmetricTwoBit(aCodes, bCodes, dimensions); - case 4 -> ashSymmetricFourBit(aCodes, bCodes, dimensions); + case 1 -> ashSymmetricBinary(aBits, 0, bBits, 0, dimensions); + case 2 -> ashSymmetricTwoBit(aCodes, 0, bCodes, 0, dimensions); + case 4 -> ashSymmetricFourBit(aCodes, 0, bCodes, 0, dimensions); default -> throw new IllegalArgumentException("Symmetric SIMD supports 1, 2, and 4 bits"); }; } - private static float ashSymmetricBinary(long[] aBits, long[] bBits, int dimensions) { + @Override + public boolean supportsAshSymmetricDotAt() { return true; } + + @Override + public float ashSymmetricDotAt(byte[] aCodes, int aOffset, byte[] bCodes, int bOffset, + int dimensions, int bits) { + if (dimensions < 0 || (bits != 2 && bits != 4)) + throw new IllegalArgumentException("Offset symmetric ASH requires nonnegative dimensions and bits in {2,4}"); + long bytes = ((long) dimensions * bits + 7) / 8; + if (aOffset < 0 || bOffset < 0 || aOffset + bytes > aCodes.length || bOffset + bytes > bCodes.length) + throw new IndexOutOfBoundsException("ASH code array ranges"); + return bits == 2 ? ashSymmetricTwoBit(aCodes, aOffset, bCodes, bOffset, dimensions) + : ashSymmetricFourBit(aCodes, aOffset, bCodes, bOffset, dimensions); + } + + @Override + public float ashSymmetricBinaryDotAt(long[] aBits, int aOffset, long[] bBits, int bOffset, int dimensions) { + if (dimensions < 0) throw new IllegalArgumentException("dimensions must be nonnegative"); + int words = (int) ((dimensions + 63L) >>> 6); + Objects.checkFromIndexSize(aOffset, words, aBits.length); + Objects.checkFromIndexSize(bOffset, words, bBits.length); + return ashSymmetricBinary(aBits, aOffset, bBits, bOffset, dimensions); + } + + private static float ashSymmetricBinary(long[] aBits, int aOff, long[] bBits, int bOff, int dimensions) { var longs = ASH_SYM_LONGS; long mismatches = 0; int words = dimensions >>> 6; for (int w = 0; w < words; w += longs.length()) { var mask = longs.indexInRange(w, words); - var a = LongVector.fromArray(longs, aBits, w, mask); - var b = LongVector.fromArray(longs, bBits, w, mask); + var a = LongVector.fromArray(longs, aBits, aOff + w, mask); + var b = LongVector.fromArray(longs, bBits, bOff + w, mask); mismatches += a.lanewise(VectorOperators.XOR, b) .lanewise(VectorOperators.BIT_COUNT).reduceLanes(VectorOperators.ADD); } int tail = dimensions & 63; - if (tail != 0) mismatches += Long.bitCount((aBits[words] ^ bBits[words]) & ((1L << tail) - 1)); + if (tail != 0) mismatches += Long.bitCount((aBits[aOff + words] ^ bBits[bOff + words]) & ((1L << tail) - 1)); return dimensions - 2f * mismatches; } - private static float ashSymmetricTwoBit(byte[] aCodes, byte[] bCodes, int dimensions) { + private static float ashSymmetricTwoBit(byte[] aCodes, int aOff, byte[] bCodes, int bOff, int dimensions) { var longs = ASH_SYM_LONGS; // The doubled signed code is sign * (1 + 2*magnitudeBit). // Weighted popcounts evaluate its product without unpacking components. @@ -151,8 +175,8 @@ private static float ashSymmetricTwoBit(byte[] aCodes, byte[] bCodes, int dimens for (int word = 0; word < words; word += longs.length()) { var byteSpecies = ASH_SYM_WORD_BYTES; var byteMask = byteSpecies.indexInRange(word * 8, words * 8); - var a = ByteVector.fromArray(byteSpecies, aCodes, word * 8, byteMask).reinterpretAsLongs(); - var b = ByteVector.fromArray(byteSpecies, bCodes, word * 8, byteMask).reinterpretAsLongs(); + var a = ByteVector.fromArray(byteSpecies, aCodes, aOff + word * 8, byteMask).reinterpretAsLongs(); + var b = ByteVector.fromArray(byteSpecies, bCodes, bOff + word * 8, byteMask).reinterpretAsLongs(); var negative = a.lanewise(VectorOperators.XOR, b).lanewise(VectorOperators.LSHR, 1).and(slots); var ma = a.and(slots); var mb = b.and(slots); @@ -170,8 +194,8 @@ private static float ashSymmetricTwoBit(byte[] aCodes, byte[] bCodes, int dimens if (tail != 0) { long a = 0, b = 0; for (int j = 0; j < (tail + 3) / 4; j++) { - a |= (long) (aCodes[words * 8 + j] & 255) << (8 * j); - b |= (long) (bCodes[words * 8 + j] & 255) << (8 * j); + a |= (long) (aCodes[aOff + words * 8 + j] & 255) << (8 * j); + b |= (long) (bCodes[bOff + words * 8 + j] & 255) << (8 * j); } long valid = slots & ((1L << (2 * tail)) - 1); long negative = ((a ^ b) >>> 1) & valid; @@ -184,7 +208,7 @@ private static float ashSymmetricTwoBit(byte[] aCodes, byte[] bCodes, int dimens return sum * 0.25f; } - private static float ashSymmetricFourBit(byte[] aCodes, byte[] bCodes, int dimensions) { + private static float ashSymmetricFourBit(byte[] aCodes, int aOff, byte[] bCodes, int bOff, int dimensions) { var bytes = ASH_SYM_BYTES; var shorts = ASH_SYM_SHORTS; int count = dimensions / 2; @@ -192,8 +216,8 @@ private static float ashSymmetricFourBit(byte[] aCodes, byte[] bCodes, int dimen for (int offset = 0; offset < count; offset += bytes.length()) { int active = Math.min(bytes.length(), count - offset); var mask = bytes.indexInRange(0, active); - var a = ByteVector.fromArray(bytes, aCodes, offset, mask); - var b = ByteVector.fromArray(bytes, bCodes, offset, mask); + var a = ByteVector.fromArray(bytes, aCodes, aOff + offset, mask); + var b = ByteVector.fromArray(bytes, bCodes, bOff + offset, mask); var total = jdk.incubator.vector.ShortVector.zero(shorts); for (int shift = 0; shift < 8; shift += 4) { var ax = a.lanewise(VectorOperators.LSHR, shift); @@ -216,7 +240,7 @@ private static float ashSymmetricFourBit(byte[] aCodes, byte[] bCodes, int dimen sum -= 2L * (bytes.length() - active); // zero padding decodes to -1 in each nibble } if ((dimensions & 1) != 0) { - int a = aCodes[count] & 15, b = bCodes[count] & 15; + int a = aCodes[aOff + count] & 15, b = bCodes[bOff + count] & 15; int av = (2 * (a & 7) + 1) * ((a & 8) == 0 ? -1 : 1); int bv = (2 * (b & 7) + 1) * ((b & 8) == 0 ? -1 : 1); sum += av * bv; @@ -237,17 +261,17 @@ private static float ashSymmetricFourBit(byte[] aCodes, byte[] bCodes, int dimen public boolean usesAshProjectionTuning() { return PanamaASHKernels.projectionTuningEnabled(); } @Override - public float ashProjectionDotTuned(float[] query, byte[] code, int dimensions, int bitsPerDimension) { + public float ashProjectionDotTuned(float[] query, byte[] code, int codeOffset, int dimensions, int bitsPerDimension) { if (!supportsAshProjectionScoring()) { - return VectorUtilSupport.super.ashProjectionDot(query, code, dimensions, bitsPerDimension); + return VectorUtilSupport.super.ashProjectionDot(query, code, codeOffset, dimensions, bitsPerDimension); } - return PanamaASHKernels.projectionDot(query, code, dimensions, bitsPerDimension); + return PanamaASHKernels.projectionDot(query, code, codeOffset, dimensions, bitsPerDimension); } @Override - public float ashProjectionDot(float[] query, byte[] code, int dimensions, int bitsPerDimension) { + public float ashProjectionDot(float[] query, byte[] code, int codeOffset, int dimensions, int bitsPerDimension) { if (!supportsAshProjectionScoring()) { - return VectorUtilSupport.super.ashProjectionDot(query, code, dimensions, bitsPerDimension); + return VectorUtilSupport.super.ashProjectionDot(query, code, codeOffset, dimensions, bitsPerDimension); } if (bitsPerDimension != 2 && bitsPerDimension != 4) { throw new IllegalArgumentException("Projection scoring requires 2 or 4 bits per dimension"); @@ -255,13 +279,13 @@ public float ashProjectionDot(float[] query, byte[] code, int dimensions, int bi Objects.checkFromIndexSize(0, dimensions, query.length); int perByte = 8 / bitsPerDimension; int bytes = dimensions / perByte + (dimensions % perByte == 0 ? 0 : 1); - Objects.checkFromIndexSize(0, bytes, code.length); + Objects.checkFromIndexSize(codeOffset, bytes, code.length); int sign = 1 << (bitsPerDimension - 1); int[] indices = bitsPerDimension == 2 ? ASH_TWO_BIT_QUERY_INDICES : ASH_FOUR_BIT_QUERY_INDICES; FloatVector sum = FloatVector.zero(ASH_FLOATS); for (int base = 0; base < bytes; base += ASH_FLOATS.length()) { int activeBytes = Math.min(ASH_FLOATS.length(), bytes - base); - var packed = (IntVector) ByteVector.fromArray(ASH_BYTES, code, base, + var packed = (IntVector) ByteVector.fromArray(ASH_BYTES, code, codeOffset + base, ASH_BYTES.indexInRange(0, activeBytes)).convertShape(VectorOperators.B2I, ASH_INTS, 0); for (int slot = 0; slot < perByte; slot++) { int remaining = dimensions - base * perByte - slot; From 24ebb57288369402c6eca2096885ab02f9a5328d Mon Sep 17 00:00:00 2001 From: Ted Willke Date: Mon, 9 Nov 2026 08:53:47 -0800 Subject: [PATCH 169/243] Keep the tuned ASH projection kernel within short query arrays The opt-in tuned Panama projection kernel gathered a partial final block with full-block query indices. A masked gather may still bounds-check inactive lanes, so a query no longer than the dimension count could throw IndexOutOfBoundsException. Point inactive tail lanes at index 0, as the default kernel already does, and test both kernels with exact-length queries. --- .../jvector/quantization/TestASHCodes.java | 4 ++-- .../jvector/vector/PanamaASHKernels.java | 21 ++++++++++++++++++- 2 files changed, 22 insertions(+), 3 deletions(-) diff --git a/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestASHCodes.java b/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestASHCodes.java index 6ca31a706..5403c091c 100644 --- a/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestASHCodes.java +++ b/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestASHCodes.java @@ -224,8 +224,8 @@ public void offsetKernelsMatchZeroOffsetKernels() { byte[] aPage = embed(a, aOff, random); byte[] bPage = embed(b, bOff, random); String where = "bits=" + bits + " dims=" + dims; - // Padded: the opt-in tuned kernel gathers whole vectors of query lanes. - float[] query = new float[dims + 64]; + // Exact length: no kernel may read query elements past the dimension count. + float[] query = new float[dims]; for (int k = 0; k < dims; k++) query[k] = (float) random.nextGaussian(); assertEquals(where, Float.floatToIntBits(vu.ashProjectionDot(query, a, dims, bits)), Float.floatToIntBits(vu.ashProjectionDot(query, aPage, aOff, dims, bits))); diff --git a/jvector-twenty/src/main/java/io/github/jbellis/jvector/vector/PanamaASHKernels.java b/jvector-twenty/src/main/java/io/github/jbellis/jvector/vector/PanamaASHKernels.java index 465fd971c..45a9322de 100644 --- a/jvector-twenty/src/main/java/io/github/jbellis/jvector/vector/PanamaASHKernels.java +++ b/jvector-twenty/src/main/java/io/github/jbellis/jvector/vector/PanamaASHKernels.java @@ -43,6 +43,8 @@ final class PanamaASHKernels { private static final VectorSpecies B = F.length() <= 8 ? ByteVector.SPECIES_64 : ByteVector.SPECIES_128; private static final int[] TWO_BIT_INDICES = queryIndices(4); private static final int[] FOUR_BIT_INDICES = queryIndices(2); + private static final int[][] TWO_BIT_TAIL_INDICES = tailQueryIndices(4); + private static final int[][] FOUR_BIT_TAIL_INDICES = tailQueryIndices(2); // Conservative defaults from JDK 23/AVX-512 sweeps and end-to-end validation. // The original compact projection loop is retained for the default 1/1 setting. @@ -80,6 +82,21 @@ private static int[] queryIndices(int stride) { return indices; } + /** + * Gather indices for a partial final block of {@code active} lanes. Inactive lanes point at + * index 0: a masked gather may still bounds-check them, and a short query has no element + * at their full-block positions. + */ + private static int[][] tailQueryIndices(int stride) { + int[][] tails = new int[F.length()][]; + for (int active = 1; active < tails.length; active++) { + int[] indices = new int[F.length()]; + for (int lane = 0; lane < active; lane++) indices[lane] = lane * stride; + tails[active] = indices; + } + return tails; + } + static void lutScore(byte[] codes, int offset, int groups, int stride, int lane, int count, float[] lut, float[] out, int outOffset) { ASHLutScoring.checkBounds(codes, offset, groups, stride, lane, count, lut, out, outOffset); @@ -200,7 +217,9 @@ static float projectionDot(float[] query, byte[] code, int codeOffset, int dimen var fields = packed.lanewise(VectorOperators.LSHR, slot * bits); var magnitude = ((FloatVector) fields.and(sign - 1).convertShape(VectorOperators.I2F, F, 0)).add(0.5f); var values = magnitude.blend(magnitude.neg(), fields.and(sign).compare(VectorOperators.EQ, 0).cast(F)); - var q = FloatVector.fromArray(F, query, base * perByte + slot, indices, 0, F.indexInRange(0, active)); + int[] gather = active == F.length() ? indices + : (bits == 2 ? TWO_BIT_TAIL_INDICES : FOUR_BIT_TAIL_INDICES)[active]; + var q = FloatVector.fromArray(F, query, base * perByte + slot, gather, 0, F.indexInRange(0, active)); FloatVector next = a0.add(q.mul(values)); if (PROJECTION_ACC == 1) { a0 = next; From 2478da8716672e2cb5130a0827bcc4e5d36d7c13 Mon Sep 17 00:00:00 2001 From: Ted Willke Date: Mon, 9 Nov 2026 10:43:03 -0800 Subject: [PATCH 170/243] Report graph build, flush and reopen times for on-disk builds index_build_time_s combines graph construction with writing the index, so a flush's cost was visible only in a console line. For schema 3 onDisk builds, record graph_build_time_s (construction and cleanup), flush_time_s (writing the completed graph and its features) and reopen_time_s (reopening the written files). Graph build plus flush equals index_build_time_s; quantizer times stay under index_quant_time_s. The metrics are selectable under metrics.construction and are absent for cached indexes, in-memory builds and schema 2 configurations, so default runs print no new warnings. --- docs/benchyaml-guide.md | 7 ++++ .../example/CompressedOnDiskBuild.java | 6 ++++ .../jvector/example/ConstructionMetrics.java | 13 +++++++ .../reporting/SearchReportingCatalog.java | 3 ++ .../example/TestGridASHIntegration.java | 36 +++++++++++++++++++ 5 files changed, 65 insertions(+) diff --git a/docs/benchyaml-guide.md b/docs/benchyaml-guide.md index 766deaba0..ec02969d3 100644 --- a/docs/benchyaml-guide.md +++ b/docs/benchyaml-guide.md @@ -70,6 +70,13 @@ the configured ASH construction policy applies. See [ASH configuration and defaults](ash-configuration.md#graph-construction) for the JVM setting. +For `onDisk` builds, three construction metrics split the build: `graph_build_time_s` +covers graph construction and cleanup, `flush_time_s` covers writing the completed +graph and its features, and `reopen_time_s` covers reopening the written files. +Graph build plus flush equals `index_build_time_s`; quantizer training and encoding +are reported separately under `index_quant_time_s`. Select them under +`metrics.construction` in the run YAML. A cached index reports none of them. + ## Search Search uses the index selected by `construction.type`. Changes to stored features diff --git a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/CompressedOnDiskBuild.java b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/CompressedOnDiskBuild.java index 8c056c631..b4db11d4d 100644 --- a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/CompressedOnDiskBuild.java +++ b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/CompressedOnDiskBuild.java @@ -163,7 +163,9 @@ private double buildMissing(List> featureSets, if (build.indexType != null) { buildCompressor = buildCodec == null ? null : buildCodec.get(); double seconds = buildAndFlush(featureSets, build, ds, outputDir, handles); + long reopenStart = System.nanoTime(); reopen(featureSets, outputDir, handles, indexes, fileSizes); + if (constructionMetrics != null) constructionMetrics.reopenTimeS = (System.nanoTime() - reopenStart) / 1e9; return seconds; } @@ -309,6 +311,10 @@ private double buildAndFlush(List> featureSets, BuildConfig build flushSeconds += (System.nanoTime() - start) / 1e9; } double totalTime = buildSeconds + flushSeconds; + if (constructionMetrics != null) { + constructionMetrics.graphBuildTimeS = buildSeconds; + constructionMetrics.flushTimeS = flushSeconds; + } System.out.printf("Graph phases: buildAndCleanup=%.6f flush=%.6f seconds%n", buildSeconds, flushSeconds); System.out.format("Build and write %s in %ss%n", featureSets, totalTime); return totalTime; diff --git a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/ConstructionMetrics.java b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/ConstructionMetrics.java index d3fd7de21..9105a5b75 100644 --- a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/ConstructionMetrics.java +++ b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/ConstructionMetrics.java @@ -36,6 +36,11 @@ final class ConstructionMetrics { // null means “not applicable / not measured” public Double indexBuildTimeS; public Long indexFileSizeBytes; + // Schema 3 on-disk phases: graph construction and cleanup, writing the completed graph, + // and reopening the written files. Graph build plus flush equals indexBuildTimeS. + public Double graphBuildTimeS; + public Double flushTimeS; + public Double reopenTimeS; // Index-construction phase (single pass per run) private final Map indexByType = new HashMap<>(); @@ -76,6 +81,10 @@ void appendTo(List out) { "Index file size (MB)", ".2f", indexFileSizeBytes / 1024.0 / 1024.0)); } + appendPhase(out, "construction.graph_build_time_s", "Graph build time (s)", graphBuildTimeS); + appendPhase(out, "construction.flush_time_s", "Flush time (s)", flushTimeS); + appendPhase(out, "construction.reopen_time_s", "Reopen time (s)", reopenTimeS); + // Index-construction quant timings appendQuant(out, "construction.index_quant_time_s", "Idx", indexByType, sumIndexQuantTimes); @@ -83,6 +92,10 @@ void appendTo(List out) { appendQuant(out, "search.search_quant_time_s", "Search", searchByType, false); } + private static void appendPhase(List out, String key, String header, Double seconds) { + if (seconds != null) out.add(Metric.of(key, header, ".2f", seconds)); + } + private static void appendQuant(List out, String prefix, String phaseLabel, diff --git a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/reporting/SearchReportingCatalog.java b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/reporting/SearchReportingCatalog.java index 48788d981..be4716996 100644 --- a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/reporting/SearchReportingCatalog.java +++ b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/reporting/SearchReportingCatalog.java @@ -98,6 +98,9 @@ private static Map> namedMetricKeys() { ), "construction", Map.of( "index_build_time_s","construction.index_build_time_s", + "graph_build_time_s","construction.graph_build_time_s", + "flush_time_s","construction.flush_time_s", + "reopen_time_s","construction.reopen_time_s", "index_quant_time_s","@prefix:construction.index_quant_time_s", "search_quant_time_s","@prefix:search.search_quant_time_s" ) diff --git a/jvector-tests/src/test/java/io/github/jbellis/jvector/example/TestGridASHIntegration.java b/jvector-tests/src/test/java/io/github/jbellis/jvector/example/TestGridASHIntegration.java index fddfc8c7d..d39efaa7b 100644 --- a/jvector-tests/src/test/java/io/github/jbellis/jvector/example/TestGridASHIntegration.java +++ b/jvector-tests/src/test/java/io/github/jbellis/jvector/example/TestGridASHIntegration.java @@ -244,6 +244,42 @@ public void schema3CacheHitSkipsBuildAndFlush() throws Exception { } } + @Test + public void schema3RecordsGraphBuildFlushAndReopenTimes() throws Exception { + var data = schema3Data(); + var config = schema3Config("onDisk", "parallel", false, "None", "- type: None"); + var spec = GridSpec.fromConfig(config); + var build = spec.buildPoints().get(0); + var features = spec.featuresFor(build, spec.search.cases().get(0)); + var cache = OnDiskGraphIndexCache.initialize(temporary.newFolder().toPath()); + var work = temporary.newFolder().toPath(); + + var fresh = new ConstructionMetrics(true); + var built = new CompressedOnDiskBuild(cache, work, () -> null, fresh, null, "schema3:None") + .build(features, build, data); + built.indexes.values().forEach(index -> { try { index.close(); } catch (Exception e) { throw new RuntimeException(e); } }); + assertTrue(fresh.graphBuildTimeS > 0); + assertTrue(fresh.flushTimeS > 0); + assertTrue(fresh.reopenTimeS > 0); + assertEquals(built.buildTimeS, fresh.graphBuildTimeS + fresh.flushTimeS, 1e-9); + var recorded = new ArrayList(); + fresh.appendTo(recorded); + var keys = recorded.stream().map(io.github.jbellis.jvector.example.benchmarks.Metric::getKey) + .collect(java.util.stream.Collectors.toSet()); + assertTrue(keys.toString(), keys.containsAll(List.of("construction.graph_build_time_s", + "construction.flush_time_s", "construction.reopen_time_s"))); + + // A cache hit loads the cached index without building, flushing or reopening it. + var reused = new ConstructionMetrics(true); + var cached = new CompressedOnDiskBuild(cache, work, () -> null, reused, null, "schema3:None") + .build(features, build, data); + cached.indexes.values().forEach(index -> { try { index.close(); } catch (Exception e) { throw new RuntimeException(e); } }); + assertNull(cached.buildTimeS); + assertNull(reused.graphBuildTimeS); + assertNull(reused.flushTimeS); + assertNull(reused.reopenTimeS); + } + @Test public void schema3RejectsFusedSearchWithoutReranking() { var error = assertThrows(IllegalArgumentException.class, From 8e1aa6db093249d06431d4019161ff9838403cde Mon Sep 17 00:00:00 2001 From: Ted Willke Date: Mon, 9 Nov 2026 14:59:48 -0800 Subject: [PATCH 171/243] Name ESMC recipes after their catalog datasets The dataset catalog is the source of truth for dataset names, so the ESMC recipes and their dataset metadata keys now use the catalog's lowercase names (esmc-600-1m, esmc-600-6m). --- jvector-examples/yaml-configs/dataset-metadata.yml | 4 ++-- .../index-parameters/{esmc-600-1M.yml => esmc-600-1m.yml} | 2 +- .../index-parameters/{esmc-600-6M.yml => esmc-600-6m.yml} | 2 +- .../on-disk-ash-fp32/{esmc-600-1M.yml => esmc-600-1m.yml} | 4 ++-- 4 files changed, 6 insertions(+), 6 deletions(-) rename jvector-examples/yaml-configs/index-parameters/{esmc-600-1M.yml => esmc-600-1m.yml} (98%) rename jvector-examples/yaml-configs/index-parameters/{esmc-600-6M.yml => esmc-600-6m.yml} (98%) rename jvector-examples/yaml-configs/index-parameters/on-disk-ash-fp32/{esmc-600-1M.yml => esmc-600-1m.yml} (90%) diff --git a/jvector-examples/yaml-configs/dataset-metadata.yml b/jvector-examples/yaml-configs/dataset-metadata.yml index 99f883546..64a3be859 100644 --- a/jvector-examples/yaml-configs/dataset-metadata.yml +++ b/jvector-examples/yaml-configs/dataset-metadata.yml @@ -126,11 +126,11 @@ cohere-10M: similarity_function: DOT_PRODUCT load_behavior: NO_SCRUB -esmc-600-1M: +esmc-600-1m: similarity_function: DOT_PRODUCT load_behavior: NO_SCRUB -esmc-600-6M: +esmc-600-6m: similarity_function: DOT_PRODUCT load_behavior: NO_SCRUB diff --git a/jvector-examples/yaml-configs/index-parameters/esmc-600-1M.yml b/jvector-examples/yaml-configs/index-parameters/esmc-600-1m.yml similarity index 98% rename from jvector-examples/yaml-configs/index-parameters/esmc-600-1M.yml rename to jvector-examples/yaml-configs/index-parameters/esmc-600-1m.yml index 23bb5ee79..3bd50bd3e 100644 --- a/jvector-examples/yaml-configs/index-parameters/esmc-600-1M.yml +++ b/jvector-examples/yaml-configs/index-parameters/esmc-600-1m.yml @@ -1,7 +1,7 @@ yamlSchemaVersion: 3 onDiskIndexVersion: 7 -dataset: esmc-600-1M +dataset: esmc-600-1m construction: type: onDisk diff --git a/jvector-examples/yaml-configs/index-parameters/esmc-600-6M.yml b/jvector-examples/yaml-configs/index-parameters/esmc-600-6m.yml similarity index 98% rename from jvector-examples/yaml-configs/index-parameters/esmc-600-6M.yml rename to jvector-examples/yaml-configs/index-parameters/esmc-600-6m.yml index 0655539d7..994207e9f 100644 --- a/jvector-examples/yaml-configs/index-parameters/esmc-600-6M.yml +++ b/jvector-examples/yaml-configs/index-parameters/esmc-600-6m.yml @@ -1,7 +1,7 @@ yamlSchemaVersion: 3 onDiskIndexVersion: 7 -dataset: esmc-600-6M +dataset: esmc-600-6m construction: type: onDisk diff --git a/jvector-examples/yaml-configs/index-parameters/on-disk-ash-fp32/esmc-600-1M.yml b/jvector-examples/yaml-configs/index-parameters/on-disk-ash-fp32/esmc-600-1m.yml similarity index 90% rename from jvector-examples/yaml-configs/index-parameters/on-disk-ash-fp32/esmc-600-1M.yml rename to jvector-examples/yaml-configs/index-parameters/on-disk-ash-fp32/esmc-600-1m.yml index 5aa69d918..005b58354 100644 --- a/jvector-examples/yaml-configs/index-parameters/on-disk-ash-fp32/esmc-600-1M.yml +++ b/jvector-examples/yaml-configs/index-parameters/on-disk-ash-fp32/esmc-600-1m.yml @@ -1,9 +1,9 @@ -# esmc-600-1M (1152 dimensions): build with 2-bit ASH scoring and write the graph with full-precision +# esmc-600-1m (1152 dimensions): build with 2-bit ASH scoring and write the graph with full-precision # vectors. Searches traverse with ASH codes held in memory and rescore from disk. yamlSchemaVersion: 3 onDiskIndexVersion: 7 -dataset: esmc-600-1M +dataset: esmc-600-1m construction: type: onDisk From ff4d30dcfdc2a872d2d42138a780c53c0281e81a Mon Sep 17 00:00:00 2001 From: Ted Willke Date: Mon, 9 Nov 2026 14:59:48 -0800 Subject: [PATCH 172/243] Log ASH training through SLF4J instead of standard output AsymmetricHashing reports training start, ITQ convergence and timing at INFO and per-epoch details at DEBUG, so integrators control it with their logging configuration. The examples' log4j2 pattern drops the INFO prefix and quiets the management and dataset loader loggers. --- .../quantization/AsymmetricHashing.java | 45 +++++++++---------- .../src/main/resources/log4j2.xml | 6 ++- 2 files changed, 27 insertions(+), 24 deletions(-) diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/AsymmetricHashing.java b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/AsymmetricHashing.java index a7ac37711..653200ce4 100644 --- a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/AsymmetricHashing.java +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/AsymmetricHashing.java @@ -54,6 +54,7 @@ * stores either generic sign/extra bits or the C++-style fast-scan projection code for 2/4-bit ASH. */ public class AsymmetricHashing implements VectorCompressor, Accountable { + private static final org.slf4j.Logger LOG = org.slf4j.LoggerFactory.getLogger(AsymmetricHashing.class); public static final int ITQ = 1, RANDOM = 2; private static final int MAGIC = 0x75EC4015; @@ -325,11 +326,8 @@ public static AsymmetricHashing initialize(RandomAccessVectorValues ravv, final int quantizedDim = validateEncodedBits(encodedBits, HEADER_BITS, originalDim, bitsPerDimension); var ravvCopy = ravv.threadLocalSupplier().get(); - System.out.println( - "\tASH initialized with landmarkCount=" + landmarkCount + - ", quantizedDim=" + quantizedDim + - ", bitsPerDimension=" + bitsPerDimension - ); + LOG.info("ASH training: {} landmark(s), {} projected dimensions, {} bit(s) per dimension", + landmarkCount, quantizedDim, bitsPerDimension); // C=1 uses the same sample for its mean and learned projection. // Sample selection preserves ITQ's existing RNG sequence. @@ -354,8 +352,11 @@ public static AsymmetricHashing initialize(RandomAccessVectorValues ravv, MiniBatchKMeansClusterer km = new MiniBatchKMeansClusterer(points, landmarkCount, 1024); VectorFloat packed = km.cluster(/*unweightedIterations*/ 20, /*anisotropicIterations*/ 0); long kmEnd = System.nanoTime(); - System.out.printf("\tKMeans++ (C=%d, N=%d, D=%d) took %.3f seconds%n", - landmarkCount, points.length, originalDim, (kmEnd - kmStart) / 1e9); + if (LOG.isInfoEnabled()) { + LOG.info("ASH landmark k-means ({} landmarks, {} vectors, {} dimensions) finished in {} s", + landmarkCount, points.length, originalDim, + String.format(java.util.Locale.ROOT, "%.1f", (kmEnd - kmStart) / 1e9)); + } landmarks = new VectorFloat[landmarkCount]; for (int c = 0; c < landmarkCount; c++) { VectorFloat mu = vectorTypeSupport.createFloatVector(originalDim); @@ -2014,7 +2015,7 @@ private static StiefelTransform runItqTrainer( Random rng, int nTrainingIterations ) { - logProgress("\t[stage] Starting runITQTrainer..."); + LOG.info("ASH ITQ training started"); validateBitsPerDimension(bitsPerDimension); @@ -2029,9 +2030,9 @@ private static StiefelTransform runItqTrainer( // X is [N x D], flat column-major for BLAS/LAPACK. double[] xCol = trainingData.xCol; - logProgress("\t[stage] Starting native PCA..."); + LOG.debug("ASH ITQ: PCA started"); double[] pCol = computePcaBasis(xCol, N, D, d); - logProgress("\t[stage] Completed native PCA..."); + LOG.debug("ASH ITQ: PCA finished"); // Xld = X @ P, shape [N x d]. double[] xldCol = nativeMultiply(xCol, N, D, pCol, d); @@ -2043,7 +2044,7 @@ private static StiefelTransform runItqTrainer( // the loss uses the update matrix, so unquantized rows need not be retained. double[] projectedRows = new double[Math.multiplyExact(N, d)]; - logProgress("\t[stage] ITQ training iterations started..."); + LOG.debug("ASH ITQ: training iterations started"); long startTime = System.nanoTime(); // Repeated polar steps need FP32 accuracy; the accumulated update, // projection products and loss retain FP64. Final W uses a FP64 polar @@ -2066,7 +2067,7 @@ private static StiefelTransform runItqTrainer( ProjectionTrainingLoss loss = computeTrainingLossFromUpdateMatrix(rCol, mCol, N); printTrainingLoss(epoch, loss); if (epoch >= 8 && recentLosses[(epoch - 3) % 3] - loss.loss <= 0.01 * loss.loss) { - logProgress("ITQ early stop at epoch " + epoch); + LOG.info("ASH ITQ training converged at epoch {}", epoch); break; } recentLosses[epoch % 3] = loss.loss; @@ -2077,7 +2078,9 @@ private static StiefelTransform runItqTrainer( new ASHOrthogonalizer(d, d).orthogonalize(mCol, rCol); double loopTime = (System.nanoTime() - startTime) / 1e9; - logProgress("\t[stage] ITQ training completed in " + loopTime + " seconds."); + if (LOG.isInfoEnabled()) { + LOG.info("ASH ITQ training finished in {} s", String.format(java.util.Locale.ROOT, "%.1f", loopTime)); + } // W = P @ R, shape [D x d]. double[] wCol = nativeMultiply(pCol, D, d, rCol, d); @@ -2116,9 +2119,10 @@ private static ProjectionTrainingLoss computeTrainingLossFromUpdateMatrix( } private static void printTrainingLoss(int epoch, ProjectionTrainingLoss loss) { - logProgress(String.format(java.util.Locale.ROOT, - "projection_train epoch=%2d ip_loss=%.5f loss=%.5f", - epoch, loss.ip, loss.loss)); + if (LOG.isDebugEnabled()) { + LOG.debug(String.format(java.util.Locale.ROOT, + "ASH ITQ: epoch %2d inner-product loss %.5f, loss %.5f", epoch, loss.ip, loss.loss)); + } } private static final float[] K_TIGHT_START = { @@ -2485,10 +2489,10 @@ private static double[] computeNativeVt(double[] xCol, int rows, int cols) { private static double[] computePcaBasis(double[] xCol, int rows, int dims, int latent) { double[] basis = ASHCovariancePCA.projection(xCol, rows, dims, latent); if (basis != null) { - logProgress("\t[stage] PCA used FP64 covariance eigendecomposition"); + LOG.debug("ASH ITQ: PCA used FP64 covariance eigendecomposition"); return basis; } - logProgress("\t[stage] PCA used guarded direct SVD"); + LOG.debug("ASH ITQ: PCA used guarded direct SVD"); return extractPcaBasis(computeNativeVt(xCol, rows, dims), dims, latent); } @@ -2984,11 +2988,6 @@ private static int landmarksHash(VectorFloat[] a) { return h; } - private static void logProgress(String msg) { - System.out.println(msg); - System.out.flush(); - } - @Override public String toString() { String optimizerName; diff --git a/jvector-examples/src/main/resources/log4j2.xml b/jvector-examples/src/main/resources/log4j2.xml index 0f9260830..caf9f2290 100644 --- a/jvector-examples/src/main/resources/log4j2.xml +++ b/jvector-examples/src/main/resources/log4j2.xml @@ -2,11 +2,15 @@ - + + + + + From 28396dddf8d331b2d1e48107038a9b7b76a7ec1b Mon Sep 17 00:00:00 2001 From: Ted Willke Date: Mon, 9 Nov 2026 14:59:48 -0800 Subject: [PATCH 173/243] Set construction and search workers in the index YAML Schema 3 index YAML takes construction.workers (one count) and search.workers (a sweep), each a positive integer or max for the platform's physical cores. Construction training, encoding, graph insertion and writing run in a pool of that size; every search worker count queries the same index, so a sweep builds once. The console gains a pipeline banner (build -> search -> rerank), stage headers, progress for long construction phases, and build, flush and reopen times. Query batch lines print only with -Djvector.bench.printQueryBatches=true. --- docs/benchyaml-guide.md | 2 + .../jbellis/jvector/example/BenchYAML.java | 5 +- .../jbellis/jvector/example/BuildConfig.java | 6 ++ .../example/CompressedOnDiskBuild.java | 17 ++-- .../jvector/example/ConsoleSections.java | 38 ++++++++ .../jvector/example/FeatureProviders.java | 12 ++- .../jvector/example/GraphBuilderFactory.java | 81 ++++++++++++---- .../github/jbellis/jvector/example/Grid.java | 82 +++++++++++----- .../jbellis/jvector/example/GridSpec.java | 6 +- .../jvector/example/PhaseProgress.java | 97 +++++++++++++++++++ .../jbellis/jvector/example/SearchConfig.java | 16 +++ .../benchmarks/QueuedQueryExecution.java | 6 +- .../benchmarks/ThroughputBenchmark.java | 13 ++- .../diagnostics/BenchmarkDiagnostics.java | 25 ++--- .../example/reporting/RunArtifacts.java | 3 +- .../example/yaml/ConstructionParameters.java | 8 ++ .../jvector/example/yaml/MultiConfig.java | 11 ++- .../example/yaml/SearchParameters.java | 15 +++ .../jbellis/jvector/example/yaml/Workers.java | 32 ++++++ .../jvector/example/WorkersConfigTest.java | 89 +++++++++++++++++ .../example/yaml/IndexParametersTest.java | 8 +- .../yaml-configs/index-parameters/README.md | 20 ++-- .../index-parameters/ash-options/README.md | 2 +- .../compressed-build-fused-ash-nvq/README.md | 2 +- .../flush-fused-ash-nvq/README.md | 2 +- .../flush-fused-pq-nvq/README.md | 2 +- .../index-parameters/in-memory-ash/README.md | 2 +- .../index-parameters/in-memory-bq/README.md | 2 +- .../index-parameters/in-memory-fp/README.md | 2 +- .../on-disk-ash-fp32/README.md | 2 +- .../index-parameters/on-disk-fp/README.md | 2 +- .../on-disk-pq-fp32/README.md | 2 +- .../example/TestGridASHIntegration.java | 33 +++++++ 33 files changed, 534 insertions(+), 111 deletions(-) create mode 100644 jvector-examples/src/main/java/io/github/jbellis/jvector/example/ConsoleSections.java create mode 100644 jvector-examples/src/main/java/io/github/jbellis/jvector/example/PhaseProgress.java create mode 100644 jvector-examples/src/main/java/io/github/jbellis/jvector/example/yaml/Workers.java create mode 100644 jvector-examples/src/test/java/io/github/jbellis/jvector/example/WorkersConfigTest.java diff --git a/docs/benchyaml-guide.md b/docs/benchyaml-guide.md index ec02969d3..d21745667 100644 --- a/docs/benchyaml-guide.md +++ b/docs/benchyaml-guide.md @@ -54,6 +54,7 @@ Each `compression` list entry has a `type` and its own settings. | `type` | `inMemory`, `onDisk` | `inMemory` keeps the graph in memory for search. `onDisk` serializes the completed graph and reopens it for search. | | `writerMode` | `sequential`, `parallel` | Selects the writer after graph cleanup for `onDisk`. Defaults to `parallel`. | | `useSavedIndexIfExists` | `Yes`, `No` | Controls reuse of a compatible `onDisk` index. `Yes` saves each built index in the working directory's `index_cache` and reuses it on later runs; a cache hit skips fresh construction and serialization measurements. Saved indexes are never deleted automatically, so starting-point configurations use `No`; recipes that query an index from a later JVM, such as the ASH/PQ comparison, use `Yes`. | +| `workers` | Positive integer or `max` | Optional. Threads for quantizer training, encoding, graph insertion and the parallel writer. `max` is the platform's physical core count, which is also the default. | | `outDegree` | Positive integers | Maximum final neighborhood degree, also called M. | | `efConstruction` | Positive integers | Search width used to find candidates during insertion. | | `neighborOverflow` | Finite numbers ≥ 1.0 | Temporary neighborhood capacity relative to final degree. Java float suffixes such as `1.2f` are accepted. | @@ -90,6 +91,7 @@ reports thread counts per case in the CSV. | --- | --- | --- | | `topKOverquery` | Map of positive integer K to lists of finite multipliers ≥ 1 | Required for search. The integer part of K × multiplier is the candidate budget. Return up to K results, with a second scoring pass when configured. | | `useSearchPruning` | List of `Yes` and `No` values | Enables traversal pruning for supported scorers and similarity functions. | +| `workers` | List of positive integers or `max` | Optional sweep. Direct search uses this many calling threads; queued ASH search uses this many workers to prepare and execute queries. Each value queries the same index, so `[1, 8]` reports 1 and 8 workers from one build. Omitted, the JVM-wide settings apply. | | `compression[].type` | `None`, `ASH`, `PQ`, `BQ` | Selects the traversal representation. `None` scores source vectors. | | `compression[].parameters` | [Quantizer parameters](#quantizer-parameters) | Supplies encoding settings for traversal. Reuse matching construction models and codes where possible. | diff --git a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/BenchYAML.java b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/BenchYAML.java index ca1ec242a..844894bdc 100644 --- a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/BenchYAML.java +++ b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/BenchYAML.java @@ -65,7 +65,7 @@ private static void run(String[] args, SearchParameters.AshQueryProjectionSettin .collect(Collectors.joining("|")); var pattern = Pattern.compile(regex.isEmpty() ? ".*" : regex); - System.out.println("Heap space available is " + Runtime.getRuntime().maxMemory()); + System.out.printf(java.util.Locale.ROOT, "Maximum heap: %.1f GiB%n", Runtime.getRuntime().maxMemory() / 1073741824.0); var datasetCollection = DatasetCollection.load(); var datasetNames = regex.isEmpty() && !configNames.isEmpty() ? List.of() @@ -133,7 +133,8 @@ private static void run(String[] args, SearchParameters.AshQueryProjectionSettin String datasetName = config.dataset; String pipeline = config.describePipeline(); - if (pipeline != null) System.out.println("Pipeline for " + datasetName + ": " + pipeline); + if (pipeline != null) ConsoleSections.pipeline(datasetName, pipeline); + ConsoleSections.stage("Dataset"); try (var ds = DataSets.loadDataSet(datasetName, loading).orElseThrow( () -> new RuntimeException("Could not load dataset:" + datasetName) ).getDataSet()) { diff --git a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/BuildConfig.java b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/BuildConfig.java index 82be1f9e8..eae17699a 100644 --- a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/BuildConfig.java +++ b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/BuildConfig.java @@ -48,6 +48,8 @@ public final class BuildConfig { public final String writerMode; public final String rerankingType; public final String ashConstructionMode; + /** Construction threads for training, encoding, insertion and writing; null for the platform default. */ + public final Integer workers; private BuildConfig(Builder b) { this.M = b.M; @@ -61,6 +63,7 @@ private BuildConfig(Builder b) { this.writerMode = b.writerMode; this.rerankingType = b.rerankingType; this.ashConstructionMode = b.ashConstructionMode; + this.workers = b.workers; } /** @@ -82,6 +85,7 @@ public Map reportedParameters() { if (writerMode != null) params.put("writerMode", writerMode); if (rerankingType != null) params.put("reranking", rerankingType); if (ashConstructionMode != null) params.put("ashConstruction", ashConstructionMode); + if (workers != null) params.put("constructionWorkers", workers); return Collections.unmodifiableMap(params); } @@ -101,6 +105,7 @@ public static final class Builder { private String writerMode; private String rerankingType; private String ashConstructionMode; + private Integer workers; private Builder() {} @@ -115,6 +120,7 @@ private Builder() {} public Builder writerMode(String writerMode) { this.writerMode = writerMode; return this; } public Builder rerankingType(String rerankingType) { this.rerankingType = rerankingType; return this; } public Builder ashConstructionMode(String mode) { this.ashConstructionMode = mode; return this; } + public Builder workers(Integer workers) { this.workers = workers; return this; } /** @throws IllegalStateException if a parameter without a default was not set */ public BuildConfig build() { diff --git a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/CompressedOnDiskBuild.java b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/CompressedOnDiskBuild.java index b4db11d4d..78d11fac4 100644 --- a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/CompressedOnDiskBuild.java +++ b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/CompressedOnDiskBuild.java @@ -161,7 +161,7 @@ private double buildMissing(List> featureSets, Files.createDirectories(outputDir); if (build.indexType != null) { - buildCompressor = buildCodec == null ? null : buildCodec.get(); + buildCompressor = buildCodec == null ? null : GraphBuilderFactory.inConstructionPool(build, buildCodec::get); double seconds = buildAndFlush(featureSets, build, ds, outputDir, handles); long reopenStart = System.nanoTime(); reopen(featureSets, outputDir, handles, indexes, fileSizes); @@ -283,16 +283,18 @@ private double buildAndFlush(List> featureSets, BuildConfig build var mean = featureSets.stream().anyMatch(features -> features.contains(FeatureId.NVQ_VECTORS)) ? new VectorMean(vectors.size(), vectors.dimension()) : null; var buildVectors = mean == null ? vectors : mean.duringScan(vectors); - var encoded = Grid.encodeForConstruction(buildCompressor, buildVectors, constructionMetrics); + var encoded = Grid.encodeForConstruction(buildCompressor, buildVectors, constructionMetrics, + GraphBuilderFactory.constructionPool(build)); long start = System.nanoTime(); try (var graph = GraphBuilderFactory.buildGraph(ds, build, encoded)) { double buildSeconds = (System.nanoTime() - start) / 1e9; - var codec = fusedCompressor == null ? null : fusedCompressor.get(); + var codec = fusedCompressor == null ? null + : GraphBuilderFactory.inConstructionPool(build, fusedCompressor::get); CompressedVectors searchCodes = codec == null ? null : codec == buildCompressor ? encoded : Grid.encodeForConstruction(codec, mean != null && !mean.isComplete() ? buildVectors : vectors, - constructionMetrics); + constructionMetrics, GraphBuilderFactory.constructionPool(build)); var inputMean = mean != null && mean.isComplete() ? mean.mean() : null; double flushSeconds = 0; int n = 0; @@ -301,9 +303,10 @@ private double buildAndFlush(List> featureSets, BuildConfig build var ctx = FeatureProviders.configureWriter(features, graph, path, vectors, searchCodes instanceof PQVectors ? (PQVectors) searchCodes : null, searchCodes instanceof ASHVectors ? (ASHVectors) searchCodes : null, - inputMean, constructionMetrics, build.writerMode); + inputMean, constructionMetrics, build.writerMode, GraphBuilderFactory.constructionThreads(build)); start = System.nanoTime(); - try (var input = new FinalWriteVectors(vectors); + try (var progress = PhaseProgress.timed("Index write " + features); + var input = new FinalWriteVectors(vectors); var view = graph.getView(); var writer = ctx.buildWriter()) { writer.write(ctx.completedSuppliers(view, input::get)); @@ -315,8 +318,6 @@ private double buildAndFlush(List> featureSets, BuildConfig build constructionMetrics.graphBuildTimeS = buildSeconds; constructionMetrics.flushTimeS = flushSeconds; } - System.out.printf("Graph phases: buildAndCleanup=%.6f flush=%.6f seconds%n", buildSeconds, flushSeconds); - System.out.format("Build and write %s in %ss%n", featureSets, totalTime); return totalTime; } } diff --git a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/ConsoleSections.java b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/ConsoleSections.java new file mode 100644 index 000000000..66fb5ba07 --- /dev/null +++ b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/ConsoleSections.java @@ -0,0 +1,38 @@ +/* + * 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; + +/** Console structure for BenchYAML runs: a banner per experiment and a header per stage. */ +public final class ConsoleSections { + private static final String RULE = "=".repeat(80); + + private ConsoleSections() {} + + /** The experiment's pipeline, framed so it stands out at the top of its output. */ + public static void pipeline(String dataset, String pipeline) { + System.out.println(); + System.out.println(RULE); + System.out.println(" PIPELINE " + dataset + ": " + pipeline); + System.out.println(RULE); + } + + /** Starts a stage of the run, for example Dataset, Construction or Search. */ + public static void stage(String title) { + System.out.println(); + System.out.println("== " + title + " =="); + } +} diff --git a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/FeatureProviders.java b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/FeatureProviders.java index 9385f9bc0..6308a5b5f 100644 --- a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/FeatureProviders.java +++ b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/FeatureProviders.java @@ -106,7 +106,17 @@ static FeatureWriteContext configureWriter(Set features, ImmutableGra Path outPath, RandomAccessVectorValues vectors, PQVectors buildPQ, ASHVectors buildASH, VectorFloat inputMean, ConstructionMetrics metrics, String writerMode) throws FileNotFoundException { - var writer = GraphBuilderFactory.writerBuilder(graph, outPath, vectors.size(), writerMode); + return configureWriter(features, graph, outPath, vectors, buildPQ, buildASH, inputMean, metrics, writerMode, + io.github.jbellis.jvector.util.PhysicalCoreExecutor.getPhysicalCoreCount()); + } + + /** As above, with {@code writerThreads} parallel writer workers. */ + static FeatureWriteContext configureWriter(Set features, ImmutableGraphIndex graph, + Path outPath, RandomAccessVectorValues vectors, + PQVectors buildPQ, ASHVectors buildASH, VectorFloat inputMean, + ConstructionMetrics metrics, String writerMode, int writerThreads) + throws FileNotFoundException { + var writer = GraphBuilderFactory.writerBuilder(graph, outPath, vectors.size(), writerMode, writerThreads); var ctx = new FeatureWriteContext(writer, graph, vectors, buildPQ, buildASH, inputMean, metrics); for (var id : features) { require(id).configure(ctx); diff --git a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/GraphBuilderFactory.java b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/GraphBuilderFactory.java index ccef03cae..055f192e0 100644 --- a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/GraphBuilderFactory.java +++ b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/GraphBuilderFactory.java @@ -39,6 +39,7 @@ import java.io.FileNotFoundException; import java.io.IOException; import java.nio.file.Path; +import java.util.function.Supplier; import java.util.stream.IntStream; /** @@ -61,8 +62,34 @@ static GraphIndexBuilder forFullPrecision(RandomAccessVectorValues vectors, build.alpha, build.addHierarchy, build.refineFinalGraph, - PhysicalCoreExecutor.pool(), - FilteredForkJoinPool.createFilteredPool()); + constructionPool(build), + hasCustomWorkers(build) ? FilteredForkJoinPool.createFilteredPool(build.workers) + : FilteredForkJoinPool.createFilteredPool()); + } + + private static final java.util.concurrent.ConcurrentHashMap CONSTRUCTION_POOLS = + new java.util.concurrent.ConcurrentHashMap<>(); + + /** True when construction.workers asks for a thread count other than the platform's physical cores. */ + static boolean hasCustomWorkers(BuildConfig build) { + return build.workers != null && build.workers != PhysicalCoreExecutor.getPhysicalCoreCount(); + } + + /** Pool for construction work: the platform pool, or one sized to construction.workers. */ + static java.util.concurrent.ForkJoinPool constructionPool(BuildConfig build) { + return hasCustomWorkers(build) + ? CONSTRUCTION_POOLS.computeIfAbsent(build.workers, java.util.concurrent.ForkJoinPool::new) + : PhysicalCoreExecutor.pool(); + } + + /** Thread count for construction work, including the parallel writer. */ + static int constructionThreads(BuildConfig build) { + return build.workers != null ? build.workers : PhysicalCoreExecutor.getPhysicalCoreCount(); + } + + /** Runs training or encoding in the construction pool, so its parallel work uses those threads. */ + static T inConstructionPool(BuildConfig build, Supplier work) { + return hasCustomWorkers(build) ? constructionPool(build).submit(work::get).join() : work.get(); } /** A builder scoring with PQ-, BQ- or ASH-encoded vectors. */ @@ -94,6 +121,11 @@ private static BuildScoreProvider compressedScores(CompressedVectors encoded, Ve } static GraphIndexBuilder forCompressed(BuildScoreProvider bsp, int dimension, BuildConfig build) { + if (hasCustomWorkers(build)) { + return new GraphIndexBuilder(bsp, dimension, build.M, build.efConstruction, build.neighborOverflow, + build.alpha, build.addHierarchy, build.refineFinalGraph, + constructionPool(build), FilteredForkJoinPool.createFilteredPool(build.workers)); + } return new GraphIndexBuilder(bsp, dimension, build.M, build.efConstruction, build.neighborOverflow, build.alpha, build.addHierarchy, build.refineFinalGraph); } @@ -107,9 +139,11 @@ static ImmutableGraphIndex buildGraph(DataSet ds, BuildConfig build, CompressedV var vectors = ds.getBaseRavv(); var similarity = ds.getSimilarityFunction(); if (encoded == null) { - try (var builder = forFullPrecision(vectors, similarity, build)) { + try (var builder = forFullPrecision(vectors, similarity, build); + var progress = PhaseProgress.counting("Graph construction", vectors.size(), + () -> builder.getGraph().size(0))) { if (!ds.useDatasetPrefetching()) return builder.build(vectors); - VectorAccess.forEach(vectors, PhysicalCoreExecutor.pool(), builder::addGraphNode); + VectorAccess.forEach(vectors, constructionPool(build), builder::addGraphNode); builder.cleanup(); return builder.getGraph(); } @@ -118,33 +152,44 @@ static ImmutableGraphIndex buildGraph(DataSet ds, BuildConfig build, CompressedV var scores = compressedScores(encoded, similarity, vectors, ashMode); boolean symmetricInsertion = encoded instanceof ASHVectors && "symmetric-all".equals(ashMode); try (var builder = forCompressed(scores, vectors.dimension(), build)) { - if (ds.useDatasetPrefetching()) { - VectorAccess.forEach(vectors, PhysicalCoreExecutor.pool(), (node, vector) -> { - if (symmetricInsertion) builder.addGraphNode(node, scores.searchProviderFor(node)); - else builder.addGraphNode(node, vector); - }); - } else { - var views = vectors.threadLocalSupplier(); - PhysicalCoreExecutor.pool().submit(() -> - IntStream.range(0, vectors.size()).parallel().forEach(node -> { - if (symmetricInsertion) builder.addGraphNode(node, scores.searchProviderFor(node)); - else builder.addGraphNode(node, views.get().getVector(node)); - })).join(); + try (var progress = PhaseProgress.counting("Graph insertion", vectors.size(), + () -> builder.getGraph().size(0))) { + if (ds.useDatasetPrefetching()) { + VectorAccess.forEach(vectors, constructionPool(build), (node, vector) -> { + if (symmetricInsertion) builder.addGraphNode(node, scores.searchProviderFor(node)); + else builder.addGraphNode(node, vector); + }); + } else { + var views = vectors.threadLocalSupplier(); + constructionPool(build).submit(() -> + IntStream.range(0, vectors.size()).parallel().forEach(node -> { + if (symmetricInsertion) builder.addGraphNode(node, scores.searchProviderFor(node)); + else builder.addGraphNode(node, views.get().getVector(node)); + })).join(); + } + } + try (var progress = PhaseProgress.timed("Graph cleanup")) { + builder.cleanup(); } - builder.cleanup(); return builder.getGraph(); } } static AbstractGraphIndexWriter.Builder writerBuilder( ImmutableGraphIndex graph, Path outPath, int size, String mode) throws FileNotFoundException { + return writerBuilder(graph, outPath, size, mode, PhysicalCoreExecutor.getPhysicalCoreCount()); + } + + /** As above, with {@code threads} parallel writer workers. */ + static AbstractGraphIndexWriter.Builder writerBuilder( + ImmutableGraphIndex graph, Path outPath, int size, String mode, int threads) throws FileNotFoundException { if (mode == null) return writerBuilder(graph, outPath, size); AbstractGraphIndexWriter.Builder builder; if ("sequential".equals(mode)) { builder = new OnDiskGraphIndexWriter.Builder(graph, outPath); } else if ("parallel".equals(mode)) { builder = new OnDiskParallelGraphIndexWriter.Builder(graph, outPath) - .withParallelWorkerThreads(PhysicalCoreExecutor.getPhysicalCoreCount()); + .withParallelWorkerThreads(threads); } else { throw new IllegalArgumentException("Unknown writer mode: " + mode); } diff --git a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/Grid.java b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/Grid.java index 768e26296..19bf3265a 100644 --- a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/Grid.java +++ b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/Grid.java @@ -284,6 +284,9 @@ private static void runOneGraph(OnDiskGraphIndexCache cache, diagnostics.startMonitoring("testDirectory", workDirectory); diagnostics.startMonitoring("indexCache", Paths.get(indexCacheDir)); diagnostics.capturePrePhaseSnapshot("Graph Build"); + ConsoleSections.stage("Construction"); + System.out.println("Index files: " + workDirectory.toAbsolutePath() + + (cache.isEnabled() ? " (saved indexes: " + cache.cacheDir().toAbsolutePath() + ")" : "")); System.out.printf("%s: Dataset similarity function is %s%n", ds.getName(), ds.getSimilarityFunction()); // Resolve build compressor (and label quant type) so we can record compute time @@ -308,10 +311,11 @@ private static void runOneGraph(OnDiskGraphIndexCache cache, strategy = new CompressedOnDiskBuild(cache, workDirectory, buildCodec, constructionMetrics, fusedCodec, cacheIdentity); } else { - buildCompressorObj = buildCodec.get(); + buildCompressorObj = GraphBuilderFactory.inConstructionPool(build, buildCodec::get); if ("inMemory".equals(build.indexType)) { strategy = new InMemoryBuild(workDirectory, - encodeForConstruction(buildCompressorObj, ds.getBaseRavv(), constructionMetrics)); + encodeForConstruction(buildCompressorObj, ds.getBaseRavv(), constructionMetrics, + GraphBuilderFactory.constructionPool(build))); } else { strategy = buildCompressorObj == null ? new InMemoryBuild(workDirectory) : new CompressedOnDiskBuild(cache, workDirectory, buildCompressorObj, constructionMetrics); @@ -337,8 +341,11 @@ private static void runOneGraph(OnDiskGraphIndexCache cache, var buildSnapshot = diagnostics.getLatestSystemSnapshot(); diagnostics.printDiskStatistics("Graph Index Build"); - if (indexBuildTimeS != null) { - System.out.printf("Index build time: %f seconds%n%n", indexBuildTimeS); + if (indexBuildTimeS != null && constructionMetrics.graphBuildTimeS != null) { + System.out.printf(java.util.Locale.ROOT, "Index build time: %.1f s (graph %.1f s, flush %.1f s)%n%n", + indexBuildTimeS, constructionMetrics.graphBuildTimeS, constructionMetrics.flushTimeS); + } else if (indexBuildTimeS != null) { + System.out.printf(java.util.Locale.ROOT, "Index build time: %.1f s%n%n", indexBuildTimeS); } else { System.out.printf("Index build time: not measured, all indexes loaded from the index cache%n%n"); } @@ -388,21 +395,27 @@ private static void runOneGraph(OnDiskGraphIndexCache cache, var nvq = constructionMetrics.index("NVQ").timeCompute(() -> NVQProvider.train(base, null)); memoryReranker = (NVQVectors) constructionMetrics.index("NVQ").timeEncode(() -> nvq.encodeAll(base)); } - try (var cs = new ConfiguredSystem(ds, index, cv, featureSetForIndex, build.rerankingType, - memoryReranker)) { - if (search.compression != null) { - cs.configureQueryExecution(search.compression.queryExecution(), search.compression.maxBatchSize()); + // Each search worker count queries this same index with its own query workers. + for (Integer workers : search.workerCounts()) { + ConsoleSections.stage(workers == null ? "Search" + : "Search: " + workers + (workers == 1 ? " worker" : " workers")); + try (var cs = new ConfiguredSystem(ds, index, cv, featureSetForIndex, build.rerankingType, + memoryReranker)) { + if (search.compression != null) { + cs.configureQueryExecution(search.compression.queryExecution(), + search.compression.maxBatchSize(), workers); + } + var traversal = cv == null ? null : cv.getCompressor(); + if (traversal == null && build.indexType != null) traversal = cs.fusedCompressor(); + var ctx = new SearchContext(ds, build, search, features, featureSetForIndex, + buildCompressorString, cpSupplier, + traversal == null ? "None" : traversal.toString(), + indexBuildTimeS, indexFileSizes.get(features), buildSnapshot, + queryParameters(cs), cs.ashQueryProjectionModel()); + testConfiguration(cs, ctx, constructionMetrics, workDirectory, benchmarks, listeners); + } catch (Exception e) { + throw new RuntimeException(e); } - var traversal = cv == null ? null : cv.getCompressor(); - if (traversal == null && build.indexType != null) traversal = cs.fusedCompressor(); - var ctx = new SearchContext(ds, build, search, features, featureSetForIndex, - buildCompressorString, cpSupplier, - traversal == null ? "None" : traversal.toString(), - indexBuildTimeS, indexFileSizes.get(features), buildSnapshot, - queryParameters(cs), cs.ashQueryProjectionModel()); - testConfiguration(cs, ctx, constructionMetrics, workDirectory, benchmarks, listeners); - } catch (Exception e) { - throw new RuntimeException(e); } }); } @@ -445,7 +458,7 @@ private static Map queryParameters(ConfiguredSystem cs) { var preparer = cs.queryPreparer(); queryParams.put("batchPreparation", preparer != null && preparer.supportsBatchPreparation()); } else { - int threads = Integer.getInteger("jvector.bench.queryThreads", 0); + int threads = cs.directCallerThreads(); queryParams.put("callerThreads", threads == 0 ? "common pool" : threads); } @@ -684,7 +697,7 @@ private static VectorCompressor getCompressor(CompressorParameters cp, Supplier> computeAndSaveAction = () -> { var start = System.nanoTime(); var compressor = cp.computeCompressor(ds); - System.out.format("%s: %s codebooks computed in %.2fs,%n", + System.out.format(java.util.Locale.ROOT, "%s: trained %s in %.2f s%n", ds.getName(), compressor, (System.nanoTime() - start) / 1_000_000_000.0); if (cp.supportsCaching()) { @@ -725,10 +738,16 @@ private static String compressorId(CompressorParameters params, DataSet ds) { static CompressedVectors encodeForConstruction(VectorCompressor compressor, RandomAccessVectorValues vectors, ConstructionMetrics metrics) { + return encodeForConstruction(compressor, vectors, metrics, PhysicalCoreExecutor.pool()); + } + + /** Encodes with {@code pool}, which sets how many threads the encoding uses. */ + static CompressedVectors encodeForConstruction(VectorCompressor compressor, RandomAccessVectorValues vectors, + ConstructionMetrics metrics, java.util.concurrent.ForkJoinPool pool) { if (compressor == null) return null; String quantType = compressor instanceof AsymmetricHashing ? "ASH" : compressor instanceof ProductQuantization ? "PQ" : "BQ"; - return metrics.index(quantType).timeEncode(() -> compressor.encodeAll(vectors)); + return metrics.index(quantType).timeEncode(() -> compressor.encodeAll(vectors, pool)); } // Load/save helpers for getCompressor @@ -771,8 +790,19 @@ public static class ConfiguredSystem implements AutoCloseable { private java.util.concurrent.ExecutorService queuedQueryWorkers; private boolean closed; + private Integer searchWorkers; + + /** Queued workers: the case's search.workers, else one per physical core the JVM can use. */ public int queryWorkerCount() { - return queryWorkerCountForMachine(); + return searchWorkers != null ? searchWorkers : queryWorkerCountForMachine(); + } + + /** Direct caller threads: the case's search.workers, else jvector.bench.queryThreads (0 = common pool). */ + public int directCallerThreads() { + if (searchWorkers != null) return searchWorkers; + int threads = Integer.getInteger("jvector.bench.queryThreads", 0); + if (threads < 0) throw new IllegalArgumentException("queryThreads must be nonnegative"); + return threads; } /** Shared across this configured index's warmups and all query measurements. */ @@ -792,6 +822,14 @@ public synchronized java.util.concurrent.ExecutorService queryWorkers() { /** Select before launching queries; workers and search-owned state are reused. */ public synchronized void configureQueryExecution(String mode, int batchSize) { + configureQueryExecution(mode, batchSize, null); + } + + /** As above, with this case's search worker count, or null for the JVM-wide setting. */ + public synchronized void configureQueryExecution(String mode, int batchSize, Integer workers) { + if (queuedQueryWorkers != null && !java.util.Objects.equals(workers, searchWorkers)) + throw new IllegalStateException("Query workers are already running; configure workers before searching"); + searchWorkers = workers; if (closed) throw new IllegalStateException("Configured system is closed"); if (!"direct".equals(mode) && !"queued".equals(mode)) throw new IllegalArgumentException("Query execution must be direct or queued"); diff --git a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/GridSpec.java b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/GridSpec.java index 73ffdd72a..2d998b161 100644 --- a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/GridSpec.java +++ b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/GridSpec.java @@ -68,11 +68,13 @@ public static GridSpec fromConfig(MultiConfig config) { ? Objects.requireNonNullElseGet(compressor.ashConstructionMode(), ASHBuildScoreExperiment::defaultMode) : null) - .indexType(construction.type).writerMode(writerMode).rerankingType(construction.rerankingType())) + .indexType(construction.type).writerMode(writerMode).rerankingType(construction.rerankingType()) + .workers(construction.resolvedWorkers())) .featureSets(List.of()) .fusedGraph(construction.fusedGraph == null ? List.of(false) : construction.fusedGraph) .search(SearchConfig.builder().compressionEntries(search.compression) - .topKGrid(search.topKOverquery).usePruningGrid(search.useSearchPruning).build()) + .topKGrid(search.topKOverquery).usePruningGrid(search.useSearchPruning) + .workerGrid(search.resolvedWorkers()).build()) .enableIndexCache(onDisk && Boolean.TRUE.equals(construction.useSavedIndexIfExists)) .build(); } diff --git a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/PhaseProgress.java b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/PhaseProgress.java new file mode 100644 index 000000000..2bfaece77 --- /dev/null +++ b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/PhaseProgress.java @@ -0,0 +1,97 @@ +/* + * 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; + +import java.util.Locale; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; +import java.util.function.LongSupplier; + +/** + * Console progress for a long construction phase. A daemon thread wakes every + * {@code jvector.bench.progressSeconds} (default 10) to print elapsed time and, when the phase + * has a counter, items done and an estimate of the time left. It reads the counter only, so it + * does not slow the phase. Set the property to 0 to print only the start and finish lines. + */ +final class PhaseProgress implements AutoCloseable { + private static final long INTERVAL_SECONDS = Long.getLong("jvector.bench.progressSeconds", 10); + + private final String phase; + private final long total; + private final LongSupplier done; + private final long start = System.nanoTime(); + private final ScheduledExecutorService ticker; + private boolean reportedComplete; + + private PhaseProgress(String phase, long total, LongSupplier done) { + this.phase = phase; + this.total = total; + this.done = done; + System.out.println(phase + " started"); + if (INTERVAL_SECONDS <= 0) { + ticker = null; + return; + } + ticker = Executors.newSingleThreadScheduledExecutor(r -> { + var thread = new Thread(r, "jvector-bench-progress"); + thread.setDaemon(true); + return thread; + }); + ticker.scheduleAtFixedRate(this::report, INTERVAL_SECONDS, INTERVAL_SECONDS, TimeUnit.SECONDS); + } + + /** A phase that reports {@code done} of {@code total} items. */ + static PhaseProgress counting(String phase, long total, LongSupplier done) { + return new PhaseProgress(phase, total, done); + } + + /** A phase without a counter; reports elapsed time only. */ + static PhaseProgress timed(String phase) { + return new PhaseProgress(phase, 0, null); + } + + private double elapsedSeconds() { + return (System.nanoTime() - start) / 1e9; + } + + private synchronized void report() { + double elapsed = elapsedSeconds(); + if (done == null || total <= 0) { + System.out.printf(Locale.ROOT, "%s: %.0f s elapsed%n", phase, elapsed); + return; + } + long count = Math.min(done.getAsLong(), total); + if (count >= total) { + if (!reportedComplete) System.out.printf(Locale.ROOT, "%s: all %,d done, finishing (%.0f s elapsed)%n", phase, total, elapsed); + else System.out.printf(Locale.ROOT, "%s: finishing (%.0f s elapsed)%n", phase, elapsed); + reportedComplete = true; + return; + } + String left = count == 0 ? "" : String.format(Locale.ROOT, ", about %.0f s left", elapsed * (total - count) / count); + System.out.printf(Locale.ROOT, "%s: %,d of %,d (%.0f%%), %.0f s elapsed%s%n", + phase, count, total, 100.0 * count / total, elapsed, left); + } + + @Override + public void close() { + if (ticker != null) ticker.shutdownNow(); + synchronized (this) { + System.out.printf(Locale.ROOT, "%s finished in %.1f s%n", phase, elapsedSeconds()); + } + } +} diff --git a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/SearchConfig.java b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/SearchConfig.java index 5b77f64bd..753ff4a25 100644 --- a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/SearchConfig.java +++ b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/SearchConfig.java @@ -40,6 +40,8 @@ public final class SearchConfig { private final List entries; /** Search compression and delivery settings for a single schema 3 index case. */ final Compression compression; + /** Search worker counts to sweep over one index; null leaves query concurrency to JVM-wide settings. */ + public final List workerGrid; private SearchConfig(Builder b) { this.searchCompressors = b.searchCompressors; @@ -47,14 +49,22 @@ private SearchConfig(Builder b) { this.usePruningGrid = b.usePruningGrid; this.entries = b.entries; this.compression = b.compression; + this.workerGrid = b.workerGrid; } + /** The worker counts to query each index with; a single null means the JVM-wide setting. */ + List workerCounts() { + return workerGrid == null ? java.util.Collections.singletonList(null) : workerGrid; + } + + /** One case per compression entry. Each case queries its index once per search worker count. */ List cases() { if (entries == null) return List.of(this); return entries.stream().map(entry -> { var builder = builder().topKGrid(topKGrid).usePruningGrid(usePruningGrid) .searchCompressors(List.of(entry.getCompressorParameters())); builder.compression = entry; + builder.workerGrid = workerGrid; return builder.build(); }).collect(Collectors.toList()); } @@ -69,9 +79,15 @@ public static final class Builder { private List usePruningGrid; private List entries; private Compression compression; + private List workerGrid; private Builder() {} + Builder workerGrid(List workerGrid) { + this.workerGrid = workerGrid == null ? null : List.copyOf(workerGrid); + return this; + } + public Builder searchCompressors(List> searchCompressors) { this.searchCompressors = searchCompressors; return this; } public Builder topKGrid(Map> topKGrid) { this.topKGrid = topKGrid; return this; } public Builder usePruningGrid(List usePruningGrid) { this.usePruningGrid = usePruningGrid; return this; } diff --git a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/benchmarks/QueuedQueryExecution.java b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/benchmarks/QueuedQueryExecution.java index bb3823d61..53afcda16 100644 --- a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/benchmarks/QueuedQueryExecution.java +++ b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/benchmarks/QueuedQueryExecution.java @@ -296,8 +296,10 @@ public List metrics() { queries += i * histogram[i]; } double meanBatch = groups == 0 ? 0 : (double) queries / groups; - System.out.printf("QUERY_BATCHES queries=%d groups=%d mean=%.4f histogram=%s%n", - queries, groups, meanBatch, Arrays.toString(histogram)); + if (Boolean.getBoolean("jvector.bench.printQueryBatches")) { + System.out.printf("QUERY_BATCHES queries=%d groups=%d mean=%.4f histogram=%s%n", + queries, groups, meanBatch, Arrays.toString(histogram)); + } return List.of( Metric.of("search.execution.response_mean_ms", "Loaded mean (ms)", ".3f", times.length == 0 ? 0 : sum / times.length / 1e6), Metric.of("search.execution.response_p95_ms", "Loaded p95 (ms)", ".3f", percentile(times, .95)), diff --git a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/benchmarks/ThroughputBenchmark.java b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/benchmarks/ThroughputBenchmark.java index 84cc8a18a..b935c19fc 100644 --- a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/benchmarks/ThroughputBenchmark.java +++ b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/benchmarks/ThroughputBenchmark.java @@ -153,8 +153,7 @@ private List runWithReporting( ConfiguredSystem cs, int topK, int rerankK, boolean usePruning, int queryRuns, Consumer>> deferredReports) { if (cs.usesQueuedExecution()) return runConfiguredBenchmark(cs, topK, rerankK, usePruning, queryRuns, deferredReports); - int threads = Integer.getInteger("jvector.bench.queryThreads", 0); - if (threads < 0) throw new IllegalArgumentException("queryThreads must be nonnegative"); + int threads = cs.directCallerThreads(); if (threads > 1) { var pool = new java.util.concurrent.ForkJoinPool(threads); try { @@ -173,9 +172,9 @@ private VectorFloat randomQuery(int dim) { return query; } - private static IntStream queryStream(int count) { + private static IntStream queryStream(ConfiguredSystem cs, int count) { IntStream stream = IntStream.range(0, count); - return Integer.getInteger("jvector.bench.queryThreads", 0) == 1 ? stream : stream.parallel(); + return cs.directCallerThreads() == 1 ? stream : stream.parallel(); } private List runConfiguredBenchmark(ConfiguredSystem cs, int topK, int rerankK, @@ -196,7 +195,7 @@ private List runConfiguredBenchmark(ConfiguredSystem cs, int topK, int r // Direct callers may naturally combine projections too. Keep // the prepared provider fixed while checking per-thread search // reuse; comparing GEMV with GEMM would test rounding instead. - queryStream(count).forEach(i -> + queryStream(cs, count).forEach(i -> QueryExecutor.verifyConcurrentQuery(cs, topK, rerankK, usePruning, i)); } if (diagnosticLevel != DiagnosticLevel.NONE) { @@ -218,7 +217,7 @@ private List runConfiguredBenchmark(ConfiguredSystem cs, int topK, int r warmupQps[warmupRun] = diagnostics.monitorPhaseWithQueryTiming(warmupPhase, (recorder) -> { if (queued != null) { SINK += queued.runPass(totalQueries, k -> randomQuery(dim), recorder, false).visited(); - } else queryStream(totalQueries) + } else queryStream(cs, totalQueries) .forEach(k -> { long queryStart = System.nanoTime(); @@ -283,7 +282,7 @@ private List runConfiguredBenchmark(ConfiguredSystem cs, int topK, int r if (queued != null) { visitedAdder.add(queued.runPass(totalQueries, cs.getDataSet().getQueryVectors()::get, recorder, false).visited()); - } else queryStream(totalQueries) + } else queryStream(cs, totalQueries) .forEach(i -> { long queryStart = System.nanoTime(); diff --git a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/benchmarks/diagnostics/BenchmarkDiagnostics.java b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/benchmarks/diagnostics/BenchmarkDiagnostics.java index 0891115b4..cedaf43ac 100644 --- a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/benchmarks/diagnostics/BenchmarkDiagnostics.java +++ b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/benchmarks/diagnostics/BenchmarkDiagnostics.java @@ -329,28 +329,15 @@ public void printDiskStatistics(String label) { DiskUsageMonitor.MultiDirectorySnapshot lastDisk = diskSnapshots.get(diskSnapshots.size() - 1); DiskUsageMonitor.MultiDirectorySnapshot totalDisk = lastDisk.subtract(firstDisk); - System.out.printf("\nDisk Usage Summary %s:%n", label); - - // Print statistics for each monitored directory + // One line per monitored directory: size and files now, and the change during the phase. + System.out.printf("Disk use after %s:%n", label); for (String dirLabel : lastDisk.snapshots.keySet()) { DiskUsageMonitor.DiskUsageSnapshot lastSnap = lastDisk.get(dirLabel); - DiskUsageMonitor.DiskUsageSnapshot totalSnap = totalDisk.get(dirLabel); - - System.out.printf(" [%s]:%n", dirLabel); - System.out.printf(" Total Disk Used: %s%n", DiskUsageMonitor.formatBytes(lastSnap.totalBytes)); - System.out.printf(" Total Files: %d%n", lastSnap.fileCount); - if (totalSnap != null) { - System.out.printf(" Net Change: %s, %+d files%n", - DiskUsageMonitor.formatBytes(totalSnap.totalBytes), totalSnap.fileCount); - } + DiskUsageMonitor.DiskUsageSnapshot change = totalDisk.get(dirLabel); + System.out.printf(" %-16s %10s in %d files%s%n", dirLabel, DiskUsageMonitor.formatBytes(lastSnap.totalBytes), + lastSnap.fileCount, change == null ? "" : String.format(" (%s%s, %+d files)", + change.totalBytes >= 0 ? "+" : "", DiskUsageMonitor.formatBytes(change.totalBytes), change.fileCount)); } - - // Print overall totals - System.out.printf(" [Overall Total]:%n"); - System.out.printf(" Total Disk Used: %s%n", DiskUsageMonitor.formatBytes(lastDisk.getTotalBytes())); - System.out.printf(" Total Files: %d%n", lastDisk.getTotalFileCount()); - System.out.printf(" Net Change: %s, %+d files%n", - DiskUsageMonitor.formatBytes(totalDisk.getTotalBytes()), totalDisk.getTotalFileCount()); } } diff --git a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/reporting/RunArtifacts.java b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/reporting/RunArtifacts.java index 1601f0e73..7a0de96e3 100644 --- a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/reporting/RunArtifacts.java +++ b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/reporting/RunArtifacts.java @@ -136,8 +136,7 @@ public static RunArtifacts open(RunConfig runCfg, List datasetConfi var reporting = RunReporting.open(runCfg, datasetConfigs); var run = reporting.run(); - System.out.println("Logging enabled: " + run.runDir().toAbsolutePath() + ".\n" + - "Delete this directory to remove run artifacts and free up disk space."); + System.out.println("Results: " + run.runDir().toAbsolutePath()); var datasetInfoWriter = DatasetInfoWriter.open(run); diff --git a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/yaml/ConstructionParameters.java b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/yaml/ConstructionParameters.java index f4e7e6fcd..942f94139 100644 --- a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/yaml/ConstructionParameters.java +++ b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/yaml/ConstructionParameters.java @@ -38,6 +38,13 @@ public class ConstructionParameters extends CommonParameters { public Object reranking; public List fusedGraph; public Boolean useSavedIndexIfExists; + /** Schema 3: threads for training, encoding, graph insertion and writing; an integer or max. */ + public Object workers; + + /** The construction worker count, or null when the YAML leaves it to the platform default. */ + public Integer resolvedWorkers() { + return workers == null ? null : Workers.resolve(workers, "construction.workers"); + } void validateSchema3() { if (type == null || !List.of("inMemory", "onDisk").contains(type)) { @@ -56,6 +63,7 @@ void validateSchema3() { validateSweep("refineFinalGraph", refineFinalGraph, value -> true); if (fusedGraph != null) validateSweep("fusedGraph", fusedGraph, value -> true); rerankingType(); + resolvedWorkers(); validateCompression("construction", false); } diff --git a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/yaml/MultiConfig.java b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/yaml/MultiConfig.java index 64b67e4b8..f0baa557a 100644 --- a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/yaml/MultiConfig.java +++ b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/yaml/MultiConfig.java @@ -218,6 +218,9 @@ private void validateSchema2() { || (construction.reranking != null && !(construction.reranking instanceof List)))) { throw new IllegalArgumentException("construction.type, writerMode, and reranking.type require schema 3"); } + if ((construction != null && construction.workers != null) || (search != null && search.workers != null)) { + throw new IllegalArgumentException("construction.workers and search.workers require schema 3"); + } if (hasSchema3CompressionFields(construction) || hasSchema3CompressionFields(search)) { throw new IllegalArgumentException("compression scoring and ASH query settings require schema 3"); } @@ -259,7 +262,7 @@ private void validateSchema3() { /** * The pipeline this schema 3 config selects, inferred from its fields, for example - * {@code onDisk · build ASH-2b symmetric · search fused ASH-2b · rerank NVQ}. + * {@code onDisk: build ASH-2b symmetric -> search fused ASH-2b -> rerank NVQ}. * Sweeps list each value. Returns null for older schemas. */ public String describePipeline() { @@ -280,9 +283,9 @@ private String pipeline(boolean detailed) { fused = yes && no ? "fused or separate " : yes ? "fused " : ""; } return construction.type - + " · build " + describe(construction.compression, detailed) - + " · search " + fused + describe(search.compression, detailed) - + " · rerank " + construction.rerankingType(); + + ": build " + describe(construction.compression, detailed) + + " -> search " + fused + describe(search.compression, detailed) + + " -> rerank " + construction.rerankingType(); } private static String describe(List entries, boolean detailed) { diff --git a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/yaml/SearchParameters.java b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/yaml/SearchParameters.java index 2df526f7b..4606ce988 100644 --- a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/yaml/SearchParameters.java +++ b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/yaml/SearchParameters.java @@ -95,12 +95,27 @@ static void validatePrecision(String value) { public Map> topKOverquery; public List useSearchPruning; + /** + * Schema 3: a sweep of search worker counts, each an integer or max. Direct search uses this many + * calling threads; queued ASH search uses this many workers to prepare and execute queries. + */ + public List workers; + + /** One resolved count per sweep value, or null when the YAML leaves it to the platform default. */ + public List resolvedWorkers() { + if (workers == null) return null; + if (workers.isEmpty()) throw new IllegalArgumentException("search.workers must list at least one worker count"); + var counts = new ArrayList(); + for (var value : workers) counts.add(Workers.resolve(value, "search.workers")); + return counts; + } void validateSchema3() { if (queryProjection != null) { throw new IllegalArgumentException("place queryProjection in the ASH compression parameters in schema 3"); } validateCompression("search", true); + resolvedWorkers(); if (topKOverquery == null || topKOverquery.isEmpty() || useSearchPruning == null || useSearchPruning.isEmpty() || useSearchPruning.stream().anyMatch(Objects::isNull)) { throw new IllegalArgumentException("search.topKOverquery and search.useSearchPruning must be set"); diff --git a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/yaml/Workers.java b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/yaml/Workers.java new file mode 100644 index 000000000..3dc3add0b --- /dev/null +++ b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/yaml/Workers.java @@ -0,0 +1,32 @@ +/* + * 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.yaml; + +import io.github.jbellis.jvector.util.PhysicalCoreExecutor; + +/** Worker counts in index YAML: a positive integer, or {@code max} for the platform's physical cores. */ +public final class Workers { + private Workers() {} + + /** Resolves one YAML value; {@code field} names it in the error message. */ + public static int resolve(Object value, String field) { + if ("max".equals(value)) return PhysicalCoreExecutor.getPhysicalCoreCount(); + if (value instanceof Integer && (Integer) value > 0) return (Integer) value; + throw new IllegalArgumentException(field + ": " + value + " is not a worker count." + + System.lineSeparator() + "Use a positive integer, or max for the platform's physical cores."); + } +} diff --git a/jvector-examples/src/test/java/io/github/jbellis/jvector/example/WorkersConfigTest.java b/jvector-examples/src/test/java/io/github/jbellis/jvector/example/WorkersConfigTest.java new file mode 100644 index 000000000..cef1d06d0 --- /dev/null +++ b/jvector-examples/src/test/java/io/github/jbellis/jvector/example/WorkersConfigTest.java @@ -0,0 +1,89 @@ +/* + * 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; + +import io.github.jbellis.jvector.example.yaml.MultiConfig; +import io.github.jbellis.jvector.util.PhysicalCoreExecutor; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +import java.nio.file.Files; +import java.util.List; + +import static org.junit.Assert.*; + +/** construction.workers and search.workers in schema 3 index YAML. */ +public class WorkersConfigTest { + @Rule public final TemporaryFolder directory = new TemporaryFolder(); + + private MultiConfig load(int schema, String constructionWorkers, String searchWorkers) throws Exception { + var file = directory.newFile().toPath(); + Files.writeString(file, "yamlSchemaVersion: " + schema + "\nonDiskIndexVersion: 7\ndataset: test\n" + + "construction:\n" + (schema == 3 ? " type: onDisk\n" : "") + + " outDegree: [16]\n efConstruction: [50]\n" + + " neighborOverflow: [1.2]\n addHierarchy: [No]\n refineFinalGraph: [Yes]\n" + + " compression:\n - type: None\n" + (schema == 3 ? " reranking:\n type: None\n" : " reranking: [NVQ]\n") + + " useSavedIndexIfExists: No\n" + + (constructionWorkers == null ? "" : " workers: " + constructionWorkers + "\n") + + "search:\n topKOverquery:\n 10: [1.0]\n useSearchPruning: [No]\n" + + (searchWorkers == null ? "" : " workers: " + searchWorkers + "\n") + + " compression:\n - type: None\n"); + return MultiConfig.getConfig(file.toString()); + } + + @Test + public void workersResolveToCountsAndMaxIsThePhysicalCoreCount() throws Exception { + var config = load(3, "max", "[1, 8, max]"); + int max = PhysicalCoreExecutor.getPhysicalCoreCount(); + assertEquals(Integer.valueOf(max), config.construction.resolvedWorkers()); + assertEquals(List.of(1, 8, max), config.search.resolvedWorkers()); + + var spec = GridSpec.fromConfig(config); + assertEquals(Integer.valueOf(max), spec.buildPoints().get(0).workers); + // One search case per compression entry; it queries one index with each worker count. + var cases = spec.search.cases(); + assertEquals(1, cases.size()); + assertEquals(List.of(1, 8, max), cases.get(0).workerCounts()); + } + + @Test + public void omittedWorkersKeepThePlatformDefaults() throws Exception { + var spec = GridSpec.fromConfig(load(3, null, null)); + assertNull(spec.buildPoints().get(0).workers); + assertEquals(1, spec.search.cases().size()); + assertEquals(java.util.Collections.singletonList(null), spec.search.cases().get(0).workerCounts()); + } + + @Test + public void invalidWorkerCountsNameTheFieldAndTheRule() { + var nl = System.lineSeparator(); + var zero = assertThrows(IllegalArgumentException.class, () -> load(3, "0", null)); + assertTrue(zero.getMessage(), zero.getMessage().endsWith("construction.workers: 0 is not a worker count." + nl + + "Use a positive integer, or max for the platform's physical cores.")); + var word = assertThrows(IllegalArgumentException.class, () -> load(3, null, "[1, all]")); + assertTrue(word.getMessage(), word.getMessage().endsWith("search.workers: all is not a worker count." + nl + + "Use a positive integer, or max for the platform's physical cores.")); + var empty = assertThrows(IllegalArgumentException.class, () -> load(3, null, "[]")); + assertTrue(empty.getMessage(), empty.getMessage().endsWith("search.workers must list at least one worker count")); + } + + @Test + public void workersRequireSchema3() { + var error = assertThrows(IllegalArgumentException.class, () -> load(2, "max", null)); + assertTrue(error.getMessage(), error.getMessage().endsWith("construction.workers and search.workers require schema 3")); + } +} diff --git a/jvector-examples/src/test/java/io/github/jbellis/jvector/example/yaml/IndexParametersTest.java b/jvector-examples/src/test/java/io/github/jbellis/jvector/example/yaml/IndexParametersTest.java index db0fb4dac..120886b32 100644 --- a/jvector-examples/src/test/java/io/github/jbellis/jvector/example/yaml/IndexParametersTest.java +++ b/jvector-examples/src/test/java/io/github/jbellis/jvector/example/yaml/IndexParametersTest.java @@ -202,12 +202,12 @@ private static void add(Map> used, String field, Object valu @Test public void pipelineIsInferredFromTheFields() throws Exception { var compressed = MultiConfig.getConfig(INDEX_PARAMETERS.resolve("compressed-build-fused-ash-nvq/cap-1M-2bit.yml").toString()); - assertEquals("onDisk · build ASH-2b symmetric · search fused ASH-2b · rerank NVQ", compressed.describePipeline()); - assertEquals("onDisk · build ASH · search fused ASH · rerank NVQ", compressed.pipelineSignature()); + assertEquals("onDisk: build ASH-2b symmetric -> search fused ASH-2b -> rerank NVQ", compressed.describePipeline()); + assertEquals("onDisk: build ASH -> search fused ASH -> rerank NVQ", compressed.pipelineSignature()); var memory = MultiConfig.getConfig(INDEX_PARAMETERS.resolve("in-memory-ash/mpnet-1m.yml").toString()); - assertEquals("inMemory · build None · search ASH-2b · rerank FP32", memory.describePipeline()); + assertEquals("inMemory: build None -> search ASH-2b -> rerank FP32", memory.describePipeline()); var landmarks = MultiConfig.getConfig(INDEX_PARAMETERS.resolve("ash-options/ada002-1M-4-landmarks.yml").toString()); - assertEquals("onDisk · build ASH-2b 4 landmarks asymmetric · search fused ASH-2b 4 landmarks · rerank NVQ", + assertEquals("onDisk: build ASH-2b 4 landmarks asymmetric -> search fused ASH-2b 4 landmarks -> rerank NVQ", landmarks.describePipeline()); } } diff --git a/jvector-examples/yaml-configs/index-parameters/README.md b/jvector-examples/yaml-configs/index-parameters/README.md index 1bc15a04a..0730b84bd 100644 --- a/jvector-examples/yaml-configs/index-parameters/README.md +++ b/jvector-examples/yaml-configs/index-parameters/README.md @@ -18,16 +18,16 @@ prints the pipeline each configuration selects before it runs. | Folder | Pipeline | Use case | | --- | --- | --- | -| [in-memory-fp](in-memory-fp) | inMemory · build None · search None · rerank None | Small or frequently queried collections that fit in memory, and lower-dimensional data where quantization saves little. | -| [in-memory-ash](in-memory-ash) | inMemory · build None · search ASH · rerank FP32 | Larger in-memory collections that need faster traversal while keeping exact final scores. | -| [in-memory-bq](in-memory-bq) | inMemory · build BQ · search BQ · rerank FP32 | Very high-dimensional embeddings, where sign codes keep construction and traversal cheap. | -| [flush-fused-ash-nvq](flush-fused-ash-nvq) | onDisk · build None · search fused ASH · rerank NVQ | The database write path: an in-memory index (for example a memtable) is flushed to an on-disk index. | -| [compressed-build-fused-ash-nvq](compressed-build-fused-ash-nvq) | onDisk · build ASH symmetric · search fused ASH · rerank NVQ | Ingest under a memory limit: construction scores compact codes instead of source vectors. | -| [flush-fused-pq-nvq](flush-fused-pq-nvq) | onDisk · build None · search fused PQ · rerank NVQ | The same write path as flush-fused-ash-nvq with PQ codes, for comparison and compatibility. | -| [on-disk-ash-fp32](on-disk-ash-fp32) | onDisk · build ASH · search ASH · rerank FP32 | Indexes too large for memory, where compact codes stay resident and full vectors are read only for rescoring. | -| [on-disk-pq-fp32](on-disk-pq-fp32) | onDisk · build PQ · search PQ · rerank FP32 | The same layout as on-disk-ash-fp32 with PQ codes. | -| [on-disk-fp](on-disk-fp) | onDisk · build None · search None · rerank None | Lower-dimensional data stored on disk, and tuning graph degree, build width, hierarchy and pruning. | -| [ash-options](ash-options) | onDisk · build ASH · search fused ASH · rerank NVQ | Choosing ASH settings for a deployment. Each file changes one option from the folder's shared setup. | +| [in-memory-fp](in-memory-fp) | inMemory: build None -> search None -> rerank None | Small or frequently queried collections that fit in memory, and lower-dimensional data where quantization saves little. | +| [in-memory-ash](in-memory-ash) | inMemory: build None -> search ASH -> rerank FP32 | Larger in-memory collections that need faster traversal while keeping exact final scores. | +| [in-memory-bq](in-memory-bq) | inMemory: build BQ -> search BQ -> rerank FP32 | Very high-dimensional embeddings, where sign codes keep construction and traversal cheap. | +| [flush-fused-ash-nvq](flush-fused-ash-nvq) | onDisk: build None -> search fused ASH -> rerank NVQ | The database write path: an in-memory index (for example a memtable) is flushed to an on-disk index. | +| [compressed-build-fused-ash-nvq](compressed-build-fused-ash-nvq) | onDisk: build ASH symmetric -> search fused ASH -> rerank NVQ | Ingest under a memory limit: construction scores compact codes instead of source vectors. | +| [flush-fused-pq-nvq](flush-fused-pq-nvq) | onDisk: build None -> search fused PQ -> rerank NVQ | The same write path as flush-fused-ash-nvq with PQ codes, for comparison and compatibility. | +| [on-disk-ash-fp32](on-disk-ash-fp32) | onDisk: build ASH -> search ASH -> rerank FP32 | Indexes too large for memory, where compact codes stay resident and full vectors are read only for rescoring. | +| [on-disk-pq-fp32](on-disk-pq-fp32) | onDisk: build PQ -> search PQ -> rerank FP32 | The same layout as on-disk-ash-fp32 with PQ codes. | +| [on-disk-fp](on-disk-fp) | onDisk: build None -> search None -> rerank None | Lower-dimensional data stored on disk, and tuning graph degree, build width, hierarchy and pruning. | +| [ash-options](ash-options) | onDisk: build ASH -> search fused ASH -> rerank NVQ | Choosing ASH settings for a deployment. Each file changes one option from the folder's shared setup. | | [studies](studies) | Varies | Published comparisons, run with their own scripts | A pipeline combines the index type, construction scoring, fused codes, search scoring and diff --git a/jvector-examples/yaml-configs/index-parameters/ash-options/README.md b/jvector-examples/yaml-configs/index-parameters/ash-options/README.md index f9b14b29d..faec313d1 100644 --- a/jvector-examples/yaml-configs/index-parameters/ash-options/README.md +++ b/jvector-examples/yaml-configs/index-parameters/ash-options/README.md @@ -4,7 +4,7 @@ Fused ASH graphs with NVQ reranking, each changing one ASH option: construction **Use case:** Choosing ASH settings for a deployment. Each file changes one option from the folder's shared setup. -**Pipeline:** onDisk · build ASH · search fused ASH · rerank NVQ +**Pipeline:** onDisk: build ASH -> search fused ASH -> rerank NVQ Files are named for their dataset. Each sets `dataset:`; change it to run the same pipeline on another compatible dataset. ASH requires dot-product datasets. diff --git a/jvector-examples/yaml-configs/index-parameters/compressed-build-fused-ash-nvq/README.md b/jvector-examples/yaml-configs/index-parameters/compressed-build-fused-ash-nvq/README.md index bdb4478ee..538f848bb 100644 --- a/jvector-examples/yaml-configs/index-parameters/compressed-build-fused-ash-nvq/README.md +++ b/jvector-examples/yaml-configs/index-parameters/compressed-build-fused-ash-nvq/README.md @@ -4,7 +4,7 @@ Builds the graph with symmetric ASH scoring, then writes fused ASH traversal cod **Use case:** Ingest under a memory limit: construction scores compact codes instead of source vectors. -**Pipeline:** onDisk · build ASH symmetric · search fused ASH · rerank NVQ +**Pipeline:** onDisk: build ASH symmetric -> search fused ASH -> rerank NVQ Files are named for their dataset. Each sets `dataset:`; change it to run the same pipeline on another compatible dataset. ASH requires dot-product datasets. diff --git a/jvector-examples/yaml-configs/index-parameters/flush-fused-ash-nvq/README.md b/jvector-examples/yaml-configs/index-parameters/flush-fused-ash-nvq/README.md index 1db072ed5..6ca1aaf7d 100644 --- a/jvector-examples/yaml-configs/index-parameters/flush-fused-ash-nvq/README.md +++ b/jvector-examples/yaml-configs/index-parameters/flush-fused-ash-nvq/README.md @@ -4,7 +4,7 @@ Builds the graph with the source vectors, then writes fused ASH traversal codes **Use case:** The database write path: an in-memory index (for example a memtable) is flushed to an on-disk index. -**Pipeline:** onDisk · build None · search fused ASH · rerank NVQ +**Pipeline:** onDisk: build None -> search fused ASH -> rerank NVQ Files are named for their dataset. Each sets `dataset:`; change it to run the same pipeline on another compatible dataset. ASH requires dot-product datasets. diff --git a/jvector-examples/yaml-configs/index-parameters/flush-fused-pq-nvq/README.md b/jvector-examples/yaml-configs/index-parameters/flush-fused-pq-nvq/README.md index 179da499b..190722264 100644 --- a/jvector-examples/yaml-configs/index-parameters/flush-fused-pq-nvq/README.md +++ b/jvector-examples/yaml-configs/index-parameters/flush-fused-pq-nvq/README.md @@ -4,7 +4,7 @@ Builds the graph with the source vectors, then writes fused PQ traversal codes a **Use case:** The same write path as flush-fused-ash-nvq with PQ codes, for comparison and compatibility. -**Pipeline:** onDisk · build None · search fused PQ · rerank NVQ +**Pipeline:** onDisk: build None -> search fused PQ -> rerank NVQ Files are named for their dataset. Each sets `dataset:`; change it to run the same pipeline on another compatible dataset. diff --git a/jvector-examples/yaml-configs/index-parameters/in-memory-ash/README.md b/jvector-examples/yaml-configs/index-parameters/in-memory-ash/README.md index 29e2cd8a7..5d4e8a5b8 100644 --- a/jvector-examples/yaml-configs/index-parameters/in-memory-ash/README.md +++ b/jvector-examples/yaml-configs/index-parameters/in-memory-ash/README.md @@ -4,7 +4,7 @@ Builds an in-memory graph with the source vectors. Searches traverse with ASH co **Use case:** Larger in-memory collections that need faster traversal while keeping exact final scores. -**Pipeline:** inMemory · build None · search ASH · rerank FP32 +**Pipeline:** inMemory: build None -> search ASH -> rerank FP32 Files are named for their dataset. Each sets `dataset:`; change it to run the same pipeline on another compatible dataset. ASH requires dot-product datasets. diff --git a/jvector-examples/yaml-configs/index-parameters/in-memory-bq/README.md b/jvector-examples/yaml-configs/index-parameters/in-memory-bq/README.md index 101a018ca..fd5d631b4 100644 --- a/jvector-examples/yaml-configs/index-parameters/in-memory-bq/README.md +++ b/jvector-examples/yaml-configs/index-parameters/in-memory-bq/README.md @@ -4,7 +4,7 @@ Builds and searches an in-memory graph with 1-bit BQ codes, then rescores with t **Use case:** Very high-dimensional embeddings, where sign codes keep construction and traversal cheap. -**Pipeline:** inMemory · build BQ · search BQ · rerank FP32 +**Pipeline:** inMemory: build BQ -> search BQ -> rerank FP32 Files are named for their dataset. Each sets `dataset:`; change it to run the same pipeline on another compatible dataset. diff --git a/jvector-examples/yaml-configs/index-parameters/in-memory-fp/README.md b/jvector-examples/yaml-configs/index-parameters/in-memory-fp/README.md index 4393c83c9..abfc03544 100644 --- a/jvector-examples/yaml-configs/index-parameters/in-memory-fp/README.md +++ b/jvector-examples/yaml-configs/index-parameters/in-memory-fp/README.md @@ -4,7 +4,7 @@ Builds and searches a graph held entirely in memory with the source vectors. Not **Use case:** Small or frequently queried collections that fit in memory, and lower-dimensional data where quantization saves little. -**Pipeline:** inMemory · build None · search None · rerank None +**Pipeline:** inMemory: build None -> search None -> rerank None Files are named for their dataset. Each sets `dataset:`; change it to run the same pipeline on another compatible dataset. diff --git a/jvector-examples/yaml-configs/index-parameters/on-disk-ash-fp32/README.md b/jvector-examples/yaml-configs/index-parameters/on-disk-ash-fp32/README.md index f7e99b020..346d359d5 100644 --- a/jvector-examples/yaml-configs/index-parameters/on-disk-ash-fp32/README.md +++ b/jvector-examples/yaml-configs/index-parameters/on-disk-ash-fp32/README.md @@ -4,7 +4,7 @@ Builds with ASH scoring and writes the graph with the source vectors. Searches t **Use case:** Indexes too large for memory, where compact codes stay resident and full vectors are read only for rescoring. -**Pipeline:** onDisk · build ASH · search ASH · rerank FP32 +**Pipeline:** onDisk: build ASH -> search ASH -> rerank FP32 Files are named for their dataset. Each sets `dataset:`; change it to run the same pipeline on another compatible dataset. ASH requires dot-product datasets. diff --git a/jvector-examples/yaml-configs/index-parameters/on-disk-fp/README.md b/jvector-examples/yaml-configs/index-parameters/on-disk-fp/README.md index 56045c1de..2a4972b9c 100644 --- a/jvector-examples/yaml-configs/index-parameters/on-disk-fp/README.md +++ b/jvector-examples/yaml-configs/index-parameters/on-disk-fp/README.md @@ -4,7 +4,7 @@ Builds and searches an on-disk graph with the source vectors, without quantizati **Use case:** Lower-dimensional data stored on disk, and tuning graph degree, build width, hierarchy and pruning. -**Pipeline:** onDisk · build None · search None · rerank None +**Pipeline:** onDisk: build None -> search None -> rerank None Files are named for their dataset. Each sets `dataset:`; change it to run the same pipeline on another compatible dataset. diff --git a/jvector-examples/yaml-configs/index-parameters/on-disk-pq-fp32/README.md b/jvector-examples/yaml-configs/index-parameters/on-disk-pq-fp32/README.md index eafd79a4f..e334f9991 100644 --- a/jvector-examples/yaml-configs/index-parameters/on-disk-pq-fp32/README.md +++ b/jvector-examples/yaml-configs/index-parameters/on-disk-pq-fp32/README.md @@ -4,7 +4,7 @@ Builds with PQ scoring and writes the graph with the source vectors. Searches tr **Use case:** The same layout as on-disk-ash-fp32 with PQ codes. -**Pipeline:** onDisk · build PQ · search PQ · rerank FP32 +**Pipeline:** onDisk: build PQ -> search PQ -> rerank FP32 Files are named for their dataset. Each sets `dataset:`; change it to run the same pipeline on another compatible dataset. diff --git a/jvector-tests/src/test/java/io/github/jbellis/jvector/example/TestGridASHIntegration.java b/jvector-tests/src/test/java/io/github/jbellis/jvector/example/TestGridASHIntegration.java index d39efaa7b..a5ee607b2 100644 --- a/jvector-tests/src/test/java/io/github/jbellis/jvector/example/TestGridASHIntegration.java +++ b/jvector-tests/src/test/java/io/github/jbellis/jvector/example/TestGridASHIntegration.java @@ -244,6 +244,39 @@ public void schema3CacheHitSkipsBuildAndFlush() throws Exception { } } + @Test(timeout = 120_000) + public void searchWorkersSweepQueriesOneIndexWithEachWorkerCount() throws Exception { + var data = schema3Data(); + var search = String.join("\n", "- type: ASH", " parameters:", " optimizer: RANDOM", + " encodedBits: 72", " bitsPerDimension: 2", " queryProjection: fp32", + " queryExecution: queued", " maxBatchSize: 4"); + var config = schema3Config("onDisk", "parallel", true, "NVQ", search); + config.construction.workers = 2; + config.search.workers = List.of(1, 2); + var params = config.search.getCompressorParameters().get(0).apply(data); + try (var projection = new SearchParameters.AshQueryProjectionSetting()) { + projection.apply(List.of(config)); + var artifacts = RunArtifacts.open(csvRunConfig(), List.of(config)); + var console = new java.io.ByteArrayOutputStream(); + var stdout = System.out; + try { + System.setOut(new java.io.PrintStream(console, true, java.nio.charset.StandardCharsets.UTF_8)); + Grid.run(data, GridSpec.fromConfig(config), artifacts); + } finally { + System.setOut(stdout); + } + var output = console.toString(java.nio.charset.StandardCharsets.UTF_8); + assertEquals(output, 1, output.split("Building graph index", -1).length - 1); + var lines = Files.readAllLines(artifacts.experimentsWriter().path()); + // One build, then one row per search worker count, each recording its own workers. + assertEquals(3, lines.size()); + assertTrue(lines.get(1), lines.get(1).matches(".*,onDisk,parallel,NVQ,,queued,1,4,.*")); + assertTrue(lines.get(2), lines.get(2).matches(".*,onDisk,parallel,NVQ,,queued,2,4,.*")); + } finally { + Files.deleteIfExists(Path.of("compressor_cache").resolve(params.idStringFor(data))); + } + } + @Test public void schema3RecordsGraphBuildFlushAndReopenTimes() throws Exception { var data = schema3Data(); From fe80b2d1653e40d4019bb864d8c545e74f1c95ac Mon Sep 17 00:00:00 2001 From: Ted Willke Date: Mon, 9 Nov 2026 14:59:48 -0800 Subject: [PATCH 174/243] Add a build-and-run launcher and IntelliJ run configurations benchyaml-play.sh rebuilds the checkout, including native code, then runs one index-parameters YAML in one JVM pinned to physical cores, with machine settings from a local server profile. Indexes go under the profile's work root, console output is UTF-8, and recipes that do not save their index start with empty caches. Adds the single-JVM MPNet 10M 2-bit ASH study recipe, its run YAML and an IntelliJ run configuration. --- .gitignore | 3 + ...Q single JVM - MPNet 10M 2-bit ASH.run.xml | 19 +++ jvector-examples/bench-profiles/README.md | 39 +++++ jvector-examples/bench-profiles/example.env | 31 ++++ jvector-examples/benchyaml-play.sh | 139 ++++++++++++++++++ .../yaml-configs/ash-pq/run-single-jvm.yml | 32 ++++ .../mpnet10m-2bit-ash.yml | 46 ++++++ rat-excludes.txt | 2 + 8 files changed, 311 insertions(+) create mode 100644 .run/ASH vs PQ single JVM - MPNet 10M 2-bit ASH.run.xml create mode 100644 jvector-examples/bench-profiles/README.md create mode 100644 jvector-examples/bench-profiles/example.env create mode 100755 jvector-examples/benchyaml-play.sh create mode 100644 jvector-examples/yaml-configs/ash-pq/run-single-jvm.yml create mode 100644 jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq-single-jvm/mpnet10m-2bit-ash.yml diff --git a/.gitignore b/.gitignore index f6524e47d..37abe9532 100644 --- a/.gitignore +++ b/.gitignore @@ -65,3 +65,6 @@ dependency-reduced-pom.xml results.csv **/datasets/custom/** **/dataset_cache/** + +### Benchmark server profiles (machine-specific) ### +jvector-examples/bench-profiles/local/ diff --git a/.run/ASH vs PQ single JVM - MPNet 10M 2-bit ASH.run.xml b/.run/ASH vs PQ single JVM - MPNet 10M 2-bit ASH.run.xml new file mode 100644 index 000000000..06276bcf7 --- /dev/null +++ b/.run/ASH vs PQ single JVM - MPNet 10M 2-bit ASH.run.xml @@ -0,0 +1,19 @@ + + + + diff --git a/jvector-examples/bench-profiles/README.md b/jvector-examples/bench-profiles/README.md new file mode 100644 index 000000000..6c375f6fb --- /dev/null +++ b/jvector-examples/bench-profiles/README.md @@ -0,0 +1,39 @@ +# Benchmark launcher and server profiles + +`jvector-examples/benchyaml-play.sh` runs one +[index-parameters](../yaml-configs/index-parameters) YAML with BenchYAML in one JVM. Every launch +first rebuilds the checkout with Maven, including the native libraries, so results always come +from the current source. The run records the commit, with `-dirty` when tracked files have +uncommitted changes, and the JAR checksum. + +```bash +BENCH_RUN_CONFIG=jvector-examples/yaml-configs/ash-pq/run-single-jvm.yml \ + jvector-examples/benchyaml-play.sh \ + jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq-single-jvm/mpnet10m-2bit-ash.yml +``` + +The index YAML sets concurrency: `construction.workers` for training, encoding, graph insertion +and writing, and `search.workers` for queries, each a number or `max` (the platform's physical +cores). `search.workers` is a sweep, so `[1, 8]` queries the same index with 1 and then 8 workers. +The launcher sets where the JVM may run, and the run YAML sets what is printed and logged. + +`BENCH_RUN_CONFIG` selects the run YAML (default `yaml-configs/run-config.yml`). +`BENCH_DEBUG=` waits for a debugger on that port. + +Long construction phases print progress every 10 seconds: vectors inserted with an estimate of +the time left, then graph cleanup and index writing. Set `-Djvector.bench.progressSeconds=0` +to print only each phase's start and finish. + +## Server profiles + +A profile describes one machine: JDK, working directory root, dataset catalogs and, optionally, +cores and NUMA nodes. CPU lists come from `lscpu`, one hardware thread per physical core. +Copy [example.env](example.env) to `local/profile.env`, or keep several profiles in +`local/.env` and pick one with `BENCH_PROFILE=`. The `local/` directory is ignored +by git because profiles hold machine paths. Each recipe runs in its own directory under +`BENCH_WORK_ROOT`. + +## IntelliJ + +The repository's `.run/` directory holds run configurations for experiments. Each names a recipe +and a run YAML, and takes machine settings from your profile. diff --git a/jvector-examples/bench-profiles/example.env b/jvector-examples/bench-profiles/example.env new file mode 100644 index 000000000..1235deedd --- /dev/null +++ b/jvector-examples/bench-profiles/example.env @@ -0,0 +1,31 @@ +# Server profile for jvector-examples/benchyaml-play.sh. +# Copy to bench-profiles/local/profile.env (ignored by git) and edit for your machine, +# or keep several in bench-profiles/local/.env and select one with BENCH_PROFILE=. + +# JDK used to build and run BenchYAML. +BENCH_JDK=$HOME/.jdks/jdk-22.0.2+9 + +# Parent of each experiment's working directory (indexes, models, logs). Put it on fast local +# storage: queries read the index from here, including indexes that are not saved. +BENCH_WORK_ROOT=/data/jvector-bench + +# Dataset catalogs, one per line: . +# Repository paths are relative to the checkout. Each catalog's ${JVECTOR_BENCH_DATA_DIR} +# is replaced by its directory. List each dataset name in only one catalog. +BENCH_CATALOGS=" +jvector-examples/yaml-configs/ash-pq/catalog-large.yml /data/vectors/large +jvector-examples/yaml-configs/ash-pq/catalog-1m.yml /data/vectors/1m +" + +# Optional. The JVM runs on one hardware thread of each of the first BENCH_CORES physical +# cores (default: all), with memory interleaved across BENCH_NODES (default: all NUMA nodes). +# The index YAML's construction.workers and search.workers set how many threads work. +#BENCH_CORES=48 +#BENCH_NODES=0,1 + +# Optional. Where indexes that are not saved are written and reopened for queries +# (default: each experiment's tmp directory under BENCH_WORK_ROOT). Use fast local storage. +#BENCH_TMP_DIR=/data/jvector-bench/tmp + +# Optional. Maximum Java heap (default 128g). +#BENCH_HEAP=128g diff --git a/jvector-examples/benchyaml-play.sh b/jvector-examples/benchyaml-play.sh new file mode 100755 index 000000000..dac5359db --- /dev/null +++ b/jvector-examples/benchyaml-play.sh @@ -0,0 +1,139 @@ +#!/usr/bin/env bash +# 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. + +# Build BenchYAML from this checkout, then run one index-parameters YAML in one JVM. +# Every launch rebuilds Java and native code, so a run always reflects the current +# source. The YAML's construction.workers and search.workers set concurrency; this +# script sets where the JVM may run. See jvector-examples/bench-profiles/README.md. +# +# benchyaml-play.sh build, then run +# benchyaml-play.sh --build-only build and print JAR=, SOURCE_REF= and JAR_SHA256= +# +# BENCH_RUN_CONFIG selects the run YAML (default jvector-examples/yaml-configs/run-config.yml). +# Machine settings come from the server profile (BENCH_PROFILE, or bench-profiles/local/profile.env). +set -euo pipefail + +repo=$(git -C "$(dirname -- "$0")" rev-parse --show-toplevel) +profiles="$repo/jvector-examples/bench-profiles" + +profile=${BENCH_PROFILE:-$profiles/local/profile.env} +[[ $profile == */* ]] || profile="$profiles/local/$profile.env" +[[ -f $profile ]] || { echo "Server profile not found: $profile. Copy bench-profiles/example.env to bench-profiles/local/profile.env and edit it." >&2; exit 2; } +# shellcheck source=/dev/null +source "$profile" +jdk=${BENCH_JDK:?The server profile must set BENCH_JDK.} + +# Maven and native build output goes to build.log under the work root; the console gets one line. +build() { + local marker start jars log + marker=$(mktemp) + start=$(date +%s) + log="${BENCH_WORK_ROOT:-$repo/jvector-examples/target}/build.log" + mkdir -p "$(dirname "$log")" + printf 'Building BenchYAML from %s ... ' "$repo" >&2 + if ! (cd "$repo" && JAVA_HOME="$jdk" PATH="$jdk/bin:$PATH" JAVA_TOOL_OPTIONS= JDK_JAVA_OPTIONS= \ + mvn -B -q package -DskipTests -pl jvector-examples -am) > "$log" 2>&1; then + printf 'failed. Last lines of %s:\n' "$log" >&2 + tail -40 "$log" >&2 + exit 1 + fi + printf 'done in %s s (log: %s)\n' "$(( $(date +%s) - start ))" "$log" >&2 + mapfile -t jars < <(find "$repo/jvector-examples/target" -maxdepth 1 -name '*-jar-with-dependencies.jar' -newer "$marker") + rm -f "$marker" + if [[ ${#jars[@]} != 1 ]]; then + echo "Expected the build to write one jar-with-dependencies in jvector-examples/target; found ${#jars[@]}." >&2 + exit 1 + fi + JAR=${jars[0]} + SOURCE_REF=$(git -C "$repo" rev-parse HEAD) + [[ -z $(git -C "$repo" status --porcelain --untracked-files=no) ]] || SOURCE_REF="$SOURCE_REF-dirty" + JAR_SHA256=$(sha256sum "$JAR" | cut -d' ' -f1) +} + +if [[ ${1:-} == --build-only ]]; then + build + printf 'JAR=%q\nSOURCE_REF=%q\nJAR_SHA256=%q\n' "$JAR" "$SOURCE_REF" "$JAR_SHA256" + exit 0 +fi + +index=${1:?Pass an index-parameters YAML file.} +[[ $index == /* ]] || index="$repo/$index" +[[ -f $index ]] || { echo "Index config not found: $index" >&2; exit 2; } +run_config=${BENCH_RUN_CONFIG:-jvector-examples/yaml-configs/run-config.yml} +[[ $run_config == /* ]] || run_config="$repo/$run_config" +[[ -f $run_config ]] || { echo "Run config not found: $run_config" >&2; exit 2; } +work_root=${BENCH_WORK_ROOT:?The server profile must set BENCH_WORK_ROOT.} + +# One hardware thread on each of the first BENCH_CORES physical cores, in CPU order. +mapfile -t physical < <(lscpu -p=CPU,CORE,SOCKET | awk -F, '!/^#/ && !seen[$3":"$2]++ {print $1}') +cores=${BENCH_CORES:-${#physical[@]}} +(( cores >= 1 && cores <= ${#physical[@]} )) || { echo "BENCH_CORES=$cores must be between 1 and the ${#physical[@]} physical cores." >&2; exit 2; } +cpus=$(printf '%s\n' "${physical[@]:0:cores}" | paste -sd, -) +# Print CPU lists as ranges, for example 0-47. +cpu_ranges=$(printf '%s\n' "${physical[@]:0:cores}" | sort -n | awk 'NR==1{a=b=$1; next} $1==b+1{b=$1; next} {printf "%s%s,", a, (a==b?"":"-"b); a=b=$1} END{printf "%s%s", a, (a==b?"":"-"b)}') +nodes=${BENCH_NODES:-$(lscpu -p=NODE | grep -v '^#' | sort -un | paste -sd, -)} + +# Each recipe gets its own working directory, so caches and logs never mix between experiments. +# Unsaved indexes are written and reopened for queries under BENCH_TMP_DIR (java.io.tmpdir), +# by default the experiment's tmp directory on the same storage as BENCH_WORK_ROOT. +recipe=${index#"$repo/jvector-examples/yaml-configs/index-parameters/"} +[[ $recipe != /* ]] || recipe="other/$(basename "$recipe")" +work="$work_root/${recipe%.yml}" + +mkdir -p "$work_root" +exec 9>"$work_root/benchyaml.lock" +flock -n 9 || { echo "Another BenchYAML launch holds $work_root/benchyaml.lock." >&2; exit 1; } +if pgrep -f '[.]example[.]BenchYAML' >/dev/null; then echo "A BenchYAML JVM is already running on this machine." >&2; exit 1; fi + +build + +# BenchYAML reads dataset metadata and catalogs relative to its working directory. +catalogs="$work/jvector-examples/yaml-configs/dataset-catalogs" +tmp=${BENCH_TMP_DIR:-$work/tmp} +mkdir -p "$catalogs" "$tmp" +cp "$repo/jvector-examples/yaml-configs/dataset-metadata.yml" "$repo/jvector-examples/yaml-configs/datasets.yml" "$work/jvector-examples/yaml-configs/" +find "$catalogs" -mindepth 1 -delete +# A recipe that does not save its index also starts without saved models, so every run +# measures training and encoding. Recipes that reuse indexes keep both caches. +if ! grep -Eq '^[[:space:]]+useSavedIndexIfExists:[[:space:]]*Yes' "$index"; then + for cache in compressor_cache index_cache; do + if [[ -d $work/$cache ]]; then find "$work/$cache" -mindepth 1 -delete; fi + done +fi +n=0 +while read -r catalog data; do + [[ -n $catalog ]] || continue + [[ $catalog == /* ]] || catalog="$repo/$catalog" + n=$((n + 1)) + sed "s|\${JVECTOR_BENCH_DATA_DIR}|$data|g" "$catalog" > "$catalogs/$n-$(basename "${catalog%.*}").yml" +done <<< "${BENCH_CATALOGS:?The server profile must list BENCH_CATALOGS.}" + +printf '\nRecipe %s\nCPUs %s (%s physical cores), memory interleaved on NUMA nodes %s\nSource %s\nJAR %s\n sha256 %s\nRun YAML %s\nWork %s\nIndexes %s\n\n' \ + "$recipe" "$cpu_ranges" "$cores" "$nodes" "$SOURCE_REF" "$JAR" "$JAR_SHA256" "$run_config" "$work" "$tmp" +debug=() +[[ -z ${BENCH_DEBUG:-} ]] || debug=("-agentlib:jdwp=transport=dt_socket,server=y,suspend=y,address=*:$BENCH_DEBUG") +cd "$work" +exec env -u JAVA_TOOL_OPTIONS -u JDK_JAVA_OPTIONS -u _JAVA_OPTIONS \ + MKL_NUM_THREADS="$cores" OMP_NUM_THREADS="$cores" OPENBLAS_NUM_THREADS="$cores" GOTO_NUM_THREADS="$cores" \ + MKL_DYNAMIC=FALSE ONEDNN_VERBOSE=0 \ + numactl --physcpubind="$cpus" --interleave="$nodes" "$jdk/bin/java" "${debug[@]}" \ + --add-modules=jdk.incubator.vector --enable-native-access=ALL-UNNAMED -da -Xms2g "-Xmx${BENCH_HEAP:-128g}" \ + "-XX:ActiveProcessorCount=$cores" "-Djava.io.tmpdir=$tmp" '-Djava.util.logging.SimpleFormatter.format=%5$s%6$s%n' \ + -Dstdout.encoding=UTF-8 -Dstderr.encoding=UTF-8 \ + -Djvector.vectorization_provider=native -Djvector.parallel_writer=true \ + "-Djvector.physical_core_count=$cores" \ + -Djvector.bench.verifyConcurrentResults=true -Djvector.bench.throughputMinSeconds=3.0 \ + "-Djvector.bench.sourceRef=$SOURCE_REF" "-Djvector.bench.runConfig=$run_config" \ + -cp "$JAR" io.github.jbellis.jvector.example.BenchYAML "$index" diff --git a/jvector-examples/yaml-configs/ash-pq/run-single-jvm.yml b/jvector-examples/yaml-configs/ash-pq/run-single-jvm.yml new file mode 100644 index 000000000..fd8083962 --- /dev/null +++ b/jvector-examples/yaml-configs/ash-pq/run-single-jvm.yml @@ -0,0 +1,32 @@ +# One JVM per experiment: construction, then queries for each search.workers value. +# The console prints the construction summary once and keeps the query tables to query +# columns; the CSV records construction metrics on every row. +# Used by standalone BenchYAML via -Djvector.bench.runConfig=. +yamlSchemaVersion: 1 +onDiskIndexVersion: 7 +benchmarks: + throughput: [AVG] + latency: [AVG, STD, P999] + accuracy: [recall] + count: [visited] +console: + benchmarks: + throughput: [AVG] + latency: [AVG, STD, P999] + accuracy: [recall] + count: [visited] + metrics: + execution: [response_mean_ms, response_p95_ms, response_p99_ms, mean_batch_size] +logging: + dir: logging + jvectorRef: ash-dev + runId: ash-pq-single-jvm-{ts} + type: csv + benchmarks: + throughput: [AVG] + latency: [AVG, STD, P999] + accuracy: [recall] + count: [visited] + metrics: + construction: [index_build_time_s, graph_build_time_s, flush_time_s, reopen_time_s, index_quant_time_s, search_quant_time_s] + execution: [response_mean_ms, response_p95_ms, response_p99_ms, mean_batch_size] diff --git a/jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq-single-jvm/mpnet10m-2bit-ash.yml b/jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq-single-jvm/mpnet10m-2bit-ash.yml new file mode 100644 index 000000000..a8ccd92e1 --- /dev/null +++ b/jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq-single-jvm/mpnet10m-2bit-ash.yml @@ -0,0 +1,46 @@ +# mpnet-10m: 768 input dimensions, 101 code bytes per vector. +# The 2-bit label describes the ASH budget; PQ uses 101 8-bit subspaces. +# ASH uses C=1 and D/2 projected dimensions, including its five-byte header. +# One JVM per experiment: build with every physical core, then query the same index +# with 1 and 8 search workers. The settings otherwise match studies/ash-vs-pq. +yamlSchemaVersion: 3 +onDiskIndexVersion: 7 +dataset: mpnet-10m + +construction: + type: onDisk + writerMode: parallel + workers: max + outDegree: [32] + efConstruction: [100] + neighborOverflow: [1.2f] + addHierarchy: [Yes] + refineFinalGraph: [Yes] + fusedGraph: [Yes] + compression: + - type: ASH + parameters: + encodedBits: 808 + optimizer: ITQ + landmarkCount: 1 + bitsPerDimension: 2 + reranking: + type: NVQ + # One JVM builds and queries, so nothing needs to be saved for later JVMs. + useSavedIndexIfExists: No + +search: + topKOverquery: + 10: [1.0, 2.0] + useSearchPruning: [No] + workers: [1, 8] + compression: + - type: ASH + parameters: + queryProjection: auto + queryExecution: queued + maxBatchSize: 8 + encodedBits: 808 + optimizer: ITQ + landmarkCount: 1 + bitsPerDimension: 2 diff --git a/rat-excludes.txt b/rat-excludes.txt index 4702f7689..9e887f482 100644 --- a/rat-excludes.txt +++ b/rat-excludes.txt @@ -45,3 +45,5 @@ docs/figure9-graph-study/jars.sha256 docs/benchmarks/**/*.csv docs/benchmarks/native-ash-projection-symmetric-20261029/native-isa.txt docs/ash-symmetric-study/*.csv +.run/*.run.xml +bench-profiles/** From a09f65d963bfc2e91a26fc4be6391d91a51d1e8a Mon Sep 17 00:00:00 2001 From: Ted Willke Date: Mon, 9 Nov 2026 15:25:20 -0800 Subject: [PATCH 175/243] Remove benchmark run records and machine paths Study reports, raw results, figures and the implementation patch were run records, not documentation. DistancesASH reads Cohere-100k from the public catalog's cache, and the native preparation guide no longer cites branch commits or the development server. --- docs/ash-queued-query-poc.md | 308 - .../final-asymmetric-controls.csv | 13 - .../final-graph-results.csv | 49 - .../final-micro-results.csv | 31 - .../implementation-notes.md | 294 - .../ash-symmetric-study/kernel-experiments.md | 39 - docs/ash-symmetric-study/report.md | 174 - .../construction.csv | 17 - .../queries.csv | 65 - .../report.md | 50 - .../construction.csv | 5 - .../design.md | 83 - .../harness-identity.json | 10 - .../implementation.patch | 1157 --- .../inputs.json | 98 - .../manifest.json | 17 - .../native-isa.txt | 1 - .../native-preflight.json | 26 - .../projection-diagnostic.json | 21 - .../protocol.json | 45 - .../queries.csv | 17 - .../raw-results.json | 1234 --- .../report.md | 57 - .../source.json | 16 - .../summary.json | 10 - .../validation.json | 70 - .../comparison.pdf | Bin 26364 -> 0 bytes .../comparison.png | Bin 285368 -> 0 bytes .../comparisons.csv | 65 - .../construction.csv | 33 - .../native-pq-ash-graph-20261028/inputs.json | 98 - .../manifest.json | 22 - .../native-preflight-proof.json | 23 - .../pca-fix-runtime-audit.json | 14 - .../protocol.json | 141 - .../native-pq-ash-graph-20261028/queries.csv | 129 - .../raw-results.json | 8098 ----------------- .../native-pq-ash-graph-20261028/report.md | 90 - .../native-pq-ash-graph-20261028/source.json | 12 - .../native-pq-ash-graph-20261028/summary.json | 45 - docs/figure9-graph-study/branch-audit.md | 36 - .../cohere-investigation.md | 39 - docs/figure9-graph-study/gc-summary.json | 30 - .../figure9-graph-study/graph-concurrency.png | Bin 287714 -> 0 bytes .../graph-construction.png | Bin 110420 -> 0 bytes docs/figure9-graph-study/graph-qps-recall.png | Bin 198585 -> 0 bytes docs/figure9-graph-study/jars.sha256 | 4 - docs/figure9-graph-study/methodology.md | 35 - docs/figure9-graph-study/paper-panel-0.png | Bin 328985 -> 0 bytes docs/figure9-graph-study/paper-panel-1.png | Bin 309213 -> 0 bytes docs/figure9-graph-study/paper-panel-2.png | Bin 327966 -> 0 bytes .../published-ivf-reference.png | Bin 319379 -> 0 bytes docs/figure9-graph-study/quality-results.csv | 9 - docs/figure9-graph-study/repeat-summary.json | 37 - .../report-scripts/data.py | 31 - .../report-scripts/finalize.py | 60 - .../report-scripts/plot.py | 85 - .../report-scripts/tables.py | 64 - docs/figure9-graph-study/report.md | 213 - docs/figure9-graph-study/reported-results.csv | 97 - docs/figure9-graph-study/results.csv | 97 - docs/figure9-graph-study/system-summary.json | 162 - docs/figure9-graph-study/tables.md | 81 - docs/figure9-graph-study/timing-validation.md | 14 - docs/native-ash-preparation.md | 10 +- docs/pq-iso-compression-recall.md | 115 - .../jbellis/jvector/example/DistancesASH.java | 6 +- rat-excludes.txt | 5 - 68 files changed, 6 insertions(+), 13901 deletions(-) delete mode 100644 docs/ash-queued-query-poc.md delete mode 100644 docs/ash-symmetric-study/final-asymmetric-controls.csv delete mode 100644 docs/ash-symmetric-study/final-graph-results.csv delete mode 100644 docs/ash-symmetric-study/final-micro-results.csv delete mode 100644 docs/ash-symmetric-study/implementation-notes.md delete mode 100644 docs/ash-symmetric-study/kernel-experiments.md delete mode 100644 docs/ash-symmetric-study/report.md delete mode 100644 docs/benchmarks/native-ash-complete-budgets-20261029/construction.csv delete mode 100644 docs/benchmarks/native-ash-complete-budgets-20261029/queries.csv delete mode 100644 docs/benchmarks/native-ash-complete-budgets-20261029/report.md delete mode 100644 docs/benchmarks/native-ash-projection-symmetric-20261029/construction.csv delete mode 100644 docs/benchmarks/native-ash-projection-symmetric-20261029/design.md delete mode 100644 docs/benchmarks/native-ash-projection-symmetric-20261029/harness-identity.json delete mode 100644 docs/benchmarks/native-ash-projection-symmetric-20261029/implementation.patch delete mode 100644 docs/benchmarks/native-ash-projection-symmetric-20261029/inputs.json delete mode 100644 docs/benchmarks/native-ash-projection-symmetric-20261029/manifest.json delete mode 100644 docs/benchmarks/native-ash-projection-symmetric-20261029/native-isa.txt delete mode 100644 docs/benchmarks/native-ash-projection-symmetric-20261029/native-preflight.json delete mode 100644 docs/benchmarks/native-ash-projection-symmetric-20261029/projection-diagnostic.json delete mode 100644 docs/benchmarks/native-ash-projection-symmetric-20261029/protocol.json delete mode 100644 docs/benchmarks/native-ash-projection-symmetric-20261029/queries.csv delete mode 100644 docs/benchmarks/native-ash-projection-symmetric-20261029/raw-results.json delete mode 100644 docs/benchmarks/native-ash-projection-symmetric-20261029/report.md delete mode 100644 docs/benchmarks/native-ash-projection-symmetric-20261029/source.json delete mode 100644 docs/benchmarks/native-ash-projection-symmetric-20261029/summary.json delete mode 100644 docs/benchmarks/native-ash-projection-symmetric-20261029/validation.json delete mode 100644 docs/benchmarks/native-pq-ash-graph-20261028/comparison.pdf delete mode 100644 docs/benchmarks/native-pq-ash-graph-20261028/comparison.png delete mode 100644 docs/benchmarks/native-pq-ash-graph-20261028/comparisons.csv delete mode 100644 docs/benchmarks/native-pq-ash-graph-20261028/construction.csv delete mode 100644 docs/benchmarks/native-pq-ash-graph-20261028/inputs.json delete mode 100644 docs/benchmarks/native-pq-ash-graph-20261028/manifest.json delete mode 100644 docs/benchmarks/native-pq-ash-graph-20261028/native-preflight-proof.json delete mode 100644 docs/benchmarks/native-pq-ash-graph-20261028/pca-fix-runtime-audit.json delete mode 100644 docs/benchmarks/native-pq-ash-graph-20261028/protocol.json delete mode 100644 docs/benchmarks/native-pq-ash-graph-20261028/queries.csv delete mode 100644 docs/benchmarks/native-pq-ash-graph-20261028/raw-results.json delete mode 100644 docs/benchmarks/native-pq-ash-graph-20261028/report.md delete mode 100644 docs/benchmarks/native-pq-ash-graph-20261028/source.json delete mode 100644 docs/benchmarks/native-pq-ash-graph-20261028/summary.json delete mode 100644 docs/figure9-graph-study/branch-audit.md delete mode 100644 docs/figure9-graph-study/cohere-investigation.md delete mode 100644 docs/figure9-graph-study/gc-summary.json delete mode 100644 docs/figure9-graph-study/graph-concurrency.png delete mode 100644 docs/figure9-graph-study/graph-construction.png delete mode 100644 docs/figure9-graph-study/graph-qps-recall.png delete mode 100644 docs/figure9-graph-study/jars.sha256 delete mode 100644 docs/figure9-graph-study/methodology.md delete mode 100644 docs/figure9-graph-study/paper-panel-0.png delete mode 100644 docs/figure9-graph-study/paper-panel-1.png delete mode 100644 docs/figure9-graph-study/paper-panel-2.png delete mode 100644 docs/figure9-graph-study/published-ivf-reference.png delete mode 100644 docs/figure9-graph-study/quality-results.csv delete mode 100644 docs/figure9-graph-study/repeat-summary.json delete mode 100644 docs/figure9-graph-study/report-scripts/data.py delete mode 100644 docs/figure9-graph-study/report-scripts/finalize.py delete mode 100644 docs/figure9-graph-study/report-scripts/plot.py delete mode 100644 docs/figure9-graph-study/report-scripts/tables.py delete mode 100644 docs/figure9-graph-study/report.md delete mode 100644 docs/figure9-graph-study/reported-results.csv delete mode 100644 docs/figure9-graph-study/results.csv delete mode 100644 docs/figure9-graph-study/system-summary.json delete mode 100644 docs/figure9-graph-study/tables.md delete mode 100644 docs/figure9-graph-study/timing-validation.md delete mode 100644 docs/pq-iso-compression-recall.md diff --git a/docs/ash-queued-query-poc.md b/docs/ash-queued-query-poc.md deleted file mode 100644 index e0f67a231..000000000 --- a/docs/ash-queued-query-poc.md +++ /dev/null @@ -1,308 +0,0 @@ -# Queued ASH query experiments and review history - -This document preserves the earlier POC revisions and their measured limitations. -The production integration and standalone benchmark protocol are documented in -[ASH query integration](ash-query-integration.md) and the -[reproduction guide](ash-pq-benchmark.md). The historical tables below do not -claim final-candidate performance. No Cassandra changes are included. - -## Base and scope - -The base is local `ash-dev` at `cd890861fe707e99775b67ff18b15ef62ab7fa55`. -That base already contains the native FP16 batch kernel (`85699a49`) and the -synchronous caller leader-follower implementation (`cd890861`). Review the new -POC with: - -```sh -git diff cd890861fe707e99775b67ff18b15ef62ab7fa55..ash-queued-query-poc -``` - -The existing synchronous search API and its caller-combining path remain -available. Explicitly prepared query batches call the same native FP16 kernel -directly, bypassing caller combining. Encoding and training remain FP32. - -## Execution and ownership - -1. A producer submits independently owned requests to `BatchingExecutor`. - Multiple requests can be outstanding without a blocked caller for each one. -2. Workers take currently available preparation requests sharing an identity - key. They do not wait for a batch-fill timer. -3. `ASHQueryPreparer` snapshots FP32 inputs and prepares projection, landmark - terms and LUTs into reusable `PreparedQuery` states. A formed batch uses - native FP16 projection with FP32 output/accumulation. -4. The preparing worker executes the first search for every group size. Other - prepared searches are published to a shared ready queue, where any execution - worker may take them. Each traversal exclusively owns its searcher and view. -5. Completion returns each result/error through the request's callback. There - is no requirement to return execution to the submitting thread. The - synchronous benchmark latency path does wait for its caller to resume. - -The generic executor uses caller-supplied execution capacity. BenchYAML supplies -one persistent worker per allocated physical core, shared across its benchmark -phases and overquery cases for a configured index. Default maximum batch size -is 8; total admission is `8 * cores` for batch-size-1 and batch-size-8 controls. -The implementation also permits larger explicitly selected batches. This is a -POC policy, not a completed production sizing study. - -A prepared state retains its input snapshot and numerical buffers, not a graph -view. Do not reuse it until all searches/resumable iterators borrowing it finish. -State accounting excludes the shared model. Keep index/model resources alive -until accepted requests finish, including requests whose clients timed out. -Close/drain the batching executor before shutting down its supplied workers. - -## Code map - -- `graph/similarity/PreparedQuery.java`, `QueryPreparer.java`: numerical state API. -- `quantization/ASHQueryPreparer.java`: input snapshots and numerical preparation. -- `quantization/AsymmetricHashing.java`: direct/supplied-batch projection entry points. -- `graph/ImmutableGraphIndex.java`, `graph/disk/OnDiskGraphIndex.java`: - preparer access and scoring with supplied preparation. -- `quantization/FusedASHDecoder.java`, `ASHNeighborhoodScorer.java`, - `graph/disk/feature/FusedASH.java`: reuse supplied projection/LUT in graph scoring. -- `util/BatchingExecutor.java`: admission, compatible preparation, ready searches, - completion, close/drain and batch counts. -- `example/benchmarks/QueuedQueryExecution.java`: standard BenchYAML queued mode. -- `example/Grid.java`: worker ownership and configured-index lifetime. - -## BenchYAML configuration - -Set `queryExecution: queued` and `maxBatchSize: 8` in the parameters of -the ASH search compression entry. See the -[BenchYAML guide](benchyaml-guide.md#ash-query-parameters) for placement. - -The default mode is `direct`. Setting `maxBatchSize: 1` is a no-batching control -with the same admission budget; it is not how isolated production traffic is -selected. LatencyBenchmark submits one active request even with maximum batch 8. -The mode uses the existing random warmups, full query passes, timing boundaries, -accuracy checks, and query parameters. There is no separate arrival adapter. - -Optional console/logging named metrics: - -```yaml -metrics: - execution: [response_mean_ms, response_p95_ms, response_p99_ms, mean_batch_size] -``` - -## Initial POC measurements and the latency issue - -The following table records the initial POC v5 baseline. Focused follow-up -changes and measurements are documented below. Performance uses JDK 22, -native defaults, oneDNN sequential runtime, CPU 0 or physical CPUs 0-7, NUMA node -0, saved 1M graphs, C=1 landmarks, 2-bit ASH, matched PQ code budgets and NVQ -reranking. Both 1x and 2x rerank overquery are measured. Numbers below are the -three-sample BenchYAML mean QPS from one process per case, not a confidence claim. - -| Dataset | Cores | Overquery | PQ QPS | Queued ASH QPS | -| --- | ---: | ---: | ---: | ---: | -| MPNet | 1 | 1x | 11004.60 | 13730.79 | -| MPNet | 1 | 2x | 8910.11 | 10229.72 | -| OpenAI3072 | 1 | 1x | 3592.86 | 5306.32 | -| OpenAI3072 | 1 | 2x | 3002.15 | 4205.70 | -| OpenAI3072 | 8 | 1x | 29439.43 | 41519.84 | -| OpenAI3072 | 8 | 2x | 24503.91 | 33684.40 | - -ASH recall is unchanged from the synchronous reference on these saved graphs: -MPNet 0.82012/0.93911 and OpenAI3072 0.7713/0.8979 at 1x/2x. Corresponding PQ -recalls are 0.65429/0.81957 and 0.6374/0.7960. MPNet eight-core, four-bit cases, -and other datasets are not collected for this final POC revision. - -The central unresolved regression is unloaded MPNet 1x latency: synchronous -mean 77.444 us and p99.9 0.137355 ms versus queued mean 92.844 us and p99.9 -2.826679 ms. At 2x, the means are 101.034/106.326 us and p99.9 values are -0.180007/0.173778 ms. Do not hide the 1x result by reporting only 2x or averages. - -Confirmed structural differences requiring investigation: - -- An isolated queued request has caller-to-worker dispatch and worker-to-caller - wakeup. There is no batch-fill delay and no ready-queue hop for size 1. -- Full batches use `prepareBatch`; isolated requests use `prepare`. Loaded - completion uses pass bookkeeping; isolated completion uses a semaphore. - Warm worker threads do not imply both paths/native workspaces are initialized - and compiled. This is a hypothesis, not an established cause of the regression. -- Loaded `response_*` timestamps are taken on the worker before completion - delivery. Unloaded latency includes the waiting caller resuming and returning. - These endpoints differ; loaded p99 and unloaded p99.9 are also distinct metrics. -- Pooling requests does not prove zero allocation in blocking-wait machinery. - -The worker-lifetime correction applies only to the new queued BenchYAML mode. -It did not resolve the tail regression and is not a general BenchYAML warmup fix. -Earlier JFR evidence on an older OpenAI POC showed compilation overlapping long -waits; it does not prove the cause of the latest MPNet regression. No change to -GraphSearcher visited-set behavior is part of this POC. - -## Validation and evidence - -The initial v5 source matched its recorded manifest and passed 61 JDK 22 -tests: 13 native projection, 45 base/graph/queue, and 3 example/configuration tests. -The previous v4 passed 60 tests on JDK 22 and JDK 25; v5's additional persistent -worker-lifetime test has not yet been run under JDK 25. - -Relevant tests: `TestPreparedASHQuery`, `TestBatchingExecutor`, -`TestQueuedWorkerLifetime`, `QueryExecutionConfigTest`, -`NativeASHFP16ProjectionTest`, `TestASHQueryPreparation`, `TestASHGraphSearch`, -`TestASHMemoryAccounting`, and `TestCallerBatchProcessor`. - -Raw commands, YAML copies, CSVs, console logs, placement checks, source hashes, -and all earlier experiment results remain on the development server at: - -```text -/home/ted_willke/benchmark-runs/ash-queued-query-poc-20261103/ - poc-v5-source.json - workers-v5-tests-jdk22.log - gate-results.json - cases/*-rv5/command.json - cases/*-rv5/logging/*/experiments.csv -``` - -No new construction measurements are claimed for this query-only POC. Profiling -preparation is local and has not been applied as production instrumentation. - - -## Focused tail-latency correction - -The follow-up patch keeps the preparation and first-search execution path the -same for singleton and full-batch traffic. `QueuedQueryExecution` uses -`prepareBatch` for all group sizes. The latest review correction makes -`ASHQueryPreparer` select direct preparation for count 1 on every backend, -including native FP32. Larger groups use the backend's batch projection. `BatchingExecutor` publishes searches 2..N and runs the first -locally. Asynchronous submission remains asynchronous, and the CPU budget, -precision, admission policy, graph state and search parameters are unchanged. - -Queued response-sample aggregation is now deferred until `QueryTester` has -finished its query benchmarks. Previously that reporting calculation could -trigger compilation that continued into the subsequent latency measurement. -This correction preserves every query, latency sample and reporting statistic. -It affects the new queued mode, not the existing direct-mode warmup policy. - -Deferral removes an observed trigger for compilation interference; it does not -guarantee that compilation will stay out of timed work. Both direct and queued -execution can still experience late compilation. No compilation-quiescence gate, -different warmup queries, or new compiler flags are introduced by this patch. - -The six-point request trace, JFR and CPU-0 scheduler recording showed that the -largest stalls were runnable worker CPU waits while C2 compiled code. One -13.137 ms request included 12.980 ms of worker run-queue delay; C2 occupied that -core for 12.813 ms. The unchanged published classes reproduced long waits -alongside compilation, so the timestamp overlay was not required for the issue. -The trace is diagnostic evidence, not replacement performance data. - -Three fresh unprofiled JDK 22 JVMs per flow were compared on MPNet 1M, 2-bit, -one physical core. Both flows below use the corrected reporting schedule: - -| Overquery | Original flow QPS, mean +/- SD | Unified flow QPS, mean +/- SD | Original unloaded p99.9, median [range] ms | Unified unloaded p99.9, median [range] ms | -| --- | ---: | ---: | ---: | ---: | -| 1x | 13585.66 +/- 182.00 | 13681.17 +/- 199.35 | 0.372607 [0.291989, 0.562090] | 0.211354 [0.144735, 0.230088] | -| 2x | 10245.88 +/- 104.74 | 10274.58 +/- 80.55 | 0.170740 [0.169270, 0.171266] | 0.169295 [0.167371, 0.170570] | - -Percentiles are medians/ranges of per-process percentiles, not pooled percentiles. -Recall remained exactly 0.82012/0.93911. QPS differences are small relative to run -variation. The earlier direct synchronous 1x reference was 77.444 us mean and -0.137355 ms p99.9; this patch does not establish complete unloaded-latency parity. - -The follow-up passed 34 relevant tests on each of JDK 22 and JDK 25, including -new reporting-order and failure-cleanup tests. Native kernels are unchanged. -The supplemental execution metrics are collected by the standard `QueryTester` -pipeline after the benchmarks finish; they are no longer calculated inline at -the end of the queued throughput benchmark. - -Evidence is retained under the development server's `tail-profile` directory: - -```text -/home/ted_willke/benchmark-runs/ash-queued-query-poc-20261103/tail-profile/ - focused-repeat-summary.json - flow-report-candidate/checks-jdk22.log - flow-report-candidate/checks-jdk25.log - cases/mpnet-2bit-C1-trace-01/stage-analysis.json - cases/mpnet-2bit-C1-original-profile-01/scheduler-latency.txt -``` - -The broader executor changes discussed in review, including removal of the slot -pool, short-lived drain tasks, callback failure policy and a CPU-budgeted -synchronous caller fast path, are not part of this focused patch. - - -## Review corrections and matched direct comparison - -The review correction routes a one-row `ASHQueryPreparer.prepareBatch` call to -`prepare` after checking the array bounds. Validation still precedes state -invalidation. Every projection provider therefore uses its direct kernel without -batch row-reference staging; larger groups retain batched preparation. A dispatch -test wraps a backend without its own count-1 shortcut to verify this behavior. - -Deferred reports now belong to a local list in one `QueryTester.run` invocation, -not to `ConfiguredSystem`. Standalone throughput calls return their supplementary -metrics immediately. Before an execution closes, it transfers ownership of its -completed response buffer and snapshots the histogram. The later report owns -only those samples, without retaining or reading the closed executor. Transfer -does not copy the large sample buffer; its existing sort/copy is deferred until -reporting. Failed measurements discard that invocation's pending reports. - -The source compiled successfully and passed 38 focused tests in each of four -combinations: JDK 22 and JDK 25, each using native default query precision and -explicit FP32. The tests cover one-row dispatch and validation, prepared states, -graph searches, queue behavior, caller combining, worker lifetime, reporting -order, failure cleanup, standalone reporting, legacy benchmark compatibility, -and reporting a completed snapshot after executor closure. Native kernels, -training, encoding, graph layout and visited-set behavior are unchanged. - -The fresh performance comparison uses ordinary BenchYAML on JDK 22, MPNet 1M, -2-bit ASH, one physical core (CPU 0), NUMA node 0, and the same cached index. -The existing benchmark warmups, queries, timers, latency endpoints and recall -checks are unchanged. Direct throughput uses four synchronous caller threads; -queued throughput uses one execution worker, maximum batch 8 and admission 8. -Both are restricted to the same CPU. Unloaded latency submits one query at a -time. Three fresh JVMs per arm ran sequentially in D/Q/Q/D/D/Q order, with idle -checks before each launch and without profiling. The historical single-JVM -direct reference is not used in this matched table. - -| Overquery | Direct QPS, mean +/- SD | Queued QPS, mean +/- SD | Direct unloaded mean +/- SD (us) | Queued unloaded mean +/- SD (us) | Direct unloaded p99.9, median [range] ms | Queued unloaded p99.9, median [range] ms | Recall@10, both | -| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | -| 1x | 12725.60 +/- 52.65 | 13469.67 +/- 23.27 | 78.34 +/- 0.20 | 83.91 +/- 0.24 | 0.140633 [0.134897, 0.141831] | 0.242624 [0.241883, 0.290886] | 0.82012 | -| 2x | 9664.89 +/- 31.14 | 10167.81 +/- 120.36 | 102.20 +/- 0.21 | 105.70 +/- 0.80 | 0.175723 [0.174020, 0.182396] | 0.172512 [0.170874, 0.174481] | 0.93911 | - -Means and sample standard deviations describe the three per-JVM measurements. -Tail values are medians and ranges of per-JVM percentiles, not pooled percentiles. - -Queued bounded-backlog response measurements are reported separately: - -| Overquery | Loaded mean +/- SD (ms) | Loaded p95, median [range] ms | Loaded p99, median [range] ms | Mean batch size | -| --- | ---: | ---: | ---: | ---: | -| 1x | 0.664472 +/- 0.001166 | 0.732305 [0.731926, 0.736801] | 0.778456 [0.773166, 0.779190] | 8.0000 | -| 2x | 0.882424 +/- 0.010087 | 0.974059 [0.958689, 0.974725] | 1.019129 [1.002839, 1.020753] | 7.9996 | - -At 1x, queued QPS is 5.85% higher and unloaded mean latency is 5.58 us higher. -At 2x, queued QPS is 5.20% higher and unloaded mean latency is 3.49 us higher. - -This establishes the remaining unloaded cost under a matched protocol. It does -not identify the fraction due to thread handoffs, compilation or other work, -nor show that a synchronous caller fast path would eliminate the entire gap. -Loaded response samples end on the worker before completion delivery; unloaded -latency includes return to the caller. The loaded experiment measures bounded -backlog, not a controlled arrival-rate or Poisson latency curve. - -Reproducibility: the measured Java changes are recorded in -`tail-profile/review-readiness/manifest.json` and `review-fixes.patch`, based on -`46dc0543`. The YAML templates retain a legacy descriptive `jvectorRef` label; -the checked source hashes, rather than that inherited label, identify this run. -The measurements use the normally compiled repository classes, with no overlay -classpath, diagnostic timestamps or custom query harness. - -Evidence under the same development-server experiment root: - -```text -tail-profile/review-readiness/ - validation-passed.json - validation-commands.json - tests-jdk22-auto.log - tests-jdk22-fp32.log - tests-jdk25-auto.log - tests-jdk25-fp32.log - benchmark-driver.log - matched-1.log ... matched-6.log - matched-summary.json -cases/*-rreview-matched-*/ - command.json - placement.jsonl - summary.json - logging/*/experiments.csv -``` diff --git a/docs/ash-symmetric-study/final-asymmetric-controls.csv b/docs/ash-symmetric-study/final-asymmetric-controls.csv deleted file mode 100644 index f0dd43453..000000000 --- a/docs/ash-symmetric-study/final-asymmetric-controls.csv +++ /dev/null @@ -1,13 +0,0 @@ -dataset,mode,rep,build_s,qps,recall,visited,qps_cv,provider_s -ada002-100k,asymmetric-cached,1,19.062567521,150638.30920807895,0.86243,547.9675,1.3794590964550122,0.720251 -cohere-english-v3-100k,asymmetric-cached,1,14.910241575,231653.94557050234,0.82729,657.4614,3.453374124251149,0.324744 -e5-large-v2-100k,asymmetric-cached,1,15.278566978,219437.87965171272,0.81166,668.5197,2.012703348778286,0.308676 -ada002-1M,asymmetric-cached,1,222.707469286,131708.20389569228,0.82501,751.7288,5.299918739807189,5.510627 -cohere-english-v3-1M,asymmetric-cached,1,308.143331078,221832.42654368113,0.69536,883.0375,4.083988556683095,1.484941 -cap-1M,asymmetric-cached,1,207.220140035,133480.11597603708,0.7895,757.8936,6.853695150484436,5.783209 -cap-1M,asymmetric-cached,2,207.996352629,147046.0693958429,0.78781,750.4877,1.6034492683831063,5.626615 -cohere-english-v3-1M,asymmetric-cached,2,155.049321232,240774.58995543895,0.69493,890.907,1.8581733690493571,1.429855 -ada002-1M,asymmetric-cached,2,229.365798497,84947.42036576896,0.82422,752.1287,3.5711789391588313,5.794614 -e5-large-v2-100k,asymmetric-cached,2,15.478295209,222987.4528446198,0.81012,669.5041,2.8123015639604487,0.334623 -cohere-english-v3-100k,asymmetric-cached,2,15.296824291,227276.05582969685,0.82779,653.8773,2.4048198767111466,0.29074 -ada002-100k,asymmetric-cached,2,18.888044342,137221.1547473062,0.86242,545.7134,1.0185284705650302,0.720043 diff --git a/docs/ash-symmetric-study/final-graph-results.csv b/docs/ash-symmetric-study/final-graph-results.csv deleted file mode 100644 index 6124fad50..000000000 --- a/docs/ash-symmetric-study/final-graph-results.csv +++ /dev/null @@ -1,49 +0,0 @@ -dataset,mode,rep,build_s,qps,recall,visited,qps_cv,provider_s -ada002-100k,baseline,1,11.563925809,128133.40927762356,0.83461,564.3859,1.110392783753509,0 -ada002-100k,symmetric,1,13.976677912,135293.41978232944,0.86364,542.6461,1.2520330791002383,0.298638 -cohere-english-v3-100k,baseline,1,11.920247309,219171.92318082254,0.81607,650.7273,6.416266613052947,0 -cohere-english-v3-100k,symmetric,1,12.742506519,235272.91324787997,0.82895,644.3192,2.195856352720201,0.193623 -e5-base-v2-100k,baseline,1,10.163011807,321335.2472272473,0.77881,646.0268,3.2949920500847996,0 -e5-base-v2-100k,symmetric,1,11.288730394,322348.0309277013,0.78983,644.3812,2.5996258123386653,0.150881 -e5-large-v2-100k,baseline,1,12.967752424,235847.15578126197,0.78072,656.2855,4.337714196878359,0 -e5-large-v2-100k,symmetric,1,12.293223491,218999.23444991774,0.81132,663.6025,1.0085956673754617,0.201756 -e5-small-v2-100k,baseline,1,8.448564154,579677.7373333726,0.59323,717.2814,2.481837237318503,0 -e5-small-v2-100k,symmetric,1,9.586961812,571372.0860323014,0.60474,694.6828,2.6233208197508455,0.076015 -gecko-100k,baseline,1,8.353635419,333785.6857949328,0.78486,659.703,1.2423567759233953,0 -gecko-100k,symmetric,1,11.17258453,330961.81152381445,0.80384,674.6207,3.3530395938281887,0.150993 -openai-v3-large-1536-100k,baseline,1,16.19114714,128625.47979776154,0.86642,514.8725,7.611520119511065,0 -openai-v3-large-1536-100k,symmetric,1,13.534156892,142614.32964480369,0.87934,502.3617,0.5289835042525478,0.301922 -openai-v3-large-3072-100k,baseline,1,16.664105234,45532.79702228551,0.89502,509.3317,4.857718673541916,0 -openai-v3-large-3072-100k,symmetric,1,20.948994785,46111.57765970198,0.91135,497.8579,0.7756766910829613,0.571658 -openai-v3-small-1536-100k,baseline,1,10.985525643,132321.22709798117,0.85159,539.8539,2.404656968140009,0 -openai-v3-small-1536-100k,symmetric,1,13.807036565,140884.70286009577,0.86757,534.4987,2.427578309650972,0.290641 -ada002-100k,symmetric,2,14.580490042,131969.47669913192,0.86386,546.0476,1.7248588981180986,0.306431 -ada002-100k,baseline,2,11.981172058,136106.2708997775,0.84332,552.6875,2.148493081399691,0 -cohere-english-v3-100k,symmetric,2,12.239456559,218271.54512359714,0.82702,643.8008,5.724098326799852,0.206087 -cohere-english-v3-100k,baseline,2,11.711900771,225327.01805467263,0.8147,648.8847,1.0947403115083671,0 -e5-base-v2-100k,symmetric,2,10.200328798,413069.1513778616,0.78879,645.3975,0.5114132632204597,0.148046 -e5-base-v2-100k,baseline,2,10.061538823,310434.6033447739,0.77734,650.337,2.243410455220526,0 -e5-large-v2-100k,symmetric,2,12.047909709,251813.06184014358,0.81257,668.6171,0.954623441783475,0.193218 -e5-large-v2-100k,baseline,2,11.377263422,198062.82246446874,0.78922,659.9321,1.6239998342060038,0 -e5-small-v2-100k,symmetric,2,8.544671915,552494.9813362691,0.60354,688.3396,2.2746895827147022,0.074423 -e5-small-v2-100k,baseline,2,8.094338923,590912.5878094168,0.59194,721.0227,1.4747756773193148,0 -gecko-100k,symmetric,2,11.233076168,370218.64661124424,0.80413,672.4625,1.5236913815301771,0.151427 -gecko-100k,baseline,2,10.251034801,390576.81954583235,0.78489,660.8607,1.175281286750001,0 -openai-v3-large-1536-100k,symmetric,2,13.605731306,143170.37106488296,0.87952,499.516,3.172266381600221,0.298886 -openai-v3-large-1536-100k,baseline,2,13.15607144,128641.47954605563,0.8659,517.8566,5.519978561362939,0 -openai-v3-large-3072-100k,symmetric,2,20.491915231,46947.548523567304,0.91144,499.5414,2.888381824309649,0.60297 -openai-v3-large-3072-100k,baseline,2,18.410612637,44932.711712325356,0.89666,507.5203,1.1598763127760914,0 -openai-v3-small-1536-100k,symmetric,2,13.660567867,157711.7566109329,0.86719,532.6686,1.2291076736510924,0.303335 -openai-v3-small-1536-100k,baseline,2,13.654421596,133767.7879151411,0.85084,542.4316,2.796080469652006,0 -ada002-1M,baseline,1,128.024840629,122500.09915563895,0.79495,773.9812,1.109027052536163,0 -ada002-1M,symmetric,1,159.359565626,133700.257710118,0.82656,742.8768,0.6300371060924892,2.870145 -cohere-english-v3-1M,baseline,1,244.790835916,197088.91836877936,0.63769,893.0455,1.2240737403354007,0 -cohere-english-v3-1M,symmetric,1,124.396110455,216640.72177507152,0.69621,894.964,0.5314395893423304,1.870582 -cap-1M,baseline,1,125.077205941,125473.95949366484,0.73663,760.8866,0.4379570774346236,0 -cap-1M,symmetric,1,141.178990091,142809.93692013,0.78746,750.1225,0.44767066363590347,2.918257 -ada002-1M,symmetric,2,150.705520182,136171.694616978,0.82682,747.8467,1.169995543264484,2.861839 -ada002-1M,baseline,2,128.88373143,122884.11737802878,0.7905,772.5621,0.26386216198655665,0 -cohere-english-v3-1M,symmetric,2,114.995056094,213219.87925304964,0.69643,884.2188,1.60117178767442,1.87581 -cohere-english-v3-1M,baseline,2,135.07986109,219249.3567696565,0.65126,883.5158,0.3492137374999325,0 -cap-1M,symmetric,2,153.133362125,139040.26147924952,0.79033,753.2187,1.4794840301794259,2.921004 -cap-1M,baseline,2,130.060055604,125743.95169939703,0.7294,764.4218,1.0602563210175113,0 diff --git a/docs/ash-symmetric-study/final-micro-results.csv b/docs/ash-symmetric-study/final-micro-results.csv deleted file mode 100644 index 521633762..000000000 --- a/docs/ash-symmetric-study/final-micro-results.csv +++ /dev/null @@ -1,31 +0,0 @@ -dataset,bits,mode,mdot_s,ns_pair,recall,queries -ada002,2,asymmetric-block-simd,21.495776,46.521,0.8808,1000 -ada002,2,asymmetric-single-simd,5.513374,181.377,0.8808,1000 -ada002,2,symmetric-block-scalar,2.419918,413.237,0.8437,1000 -ada002,2,symmetric-block-simd,30.849219,32.416,0.8437,1000 -ada002,2,symmetric-single-scalar,8.866285,112.787,0.8437,1000 -ada002,2,symmetric-single-simd,22.054206,45.343,0.8437,1000 -cohere_english_v3,1,asymmetric-block-simd,12.404803,80.614,0.7891,1000 -cohere_english_v3,1,asymmetric-single-simd,18.87169,52.989,0.7891,1000 -cohere_english_v3,1,symmetric-block-scalar,83.872673,11.923,0.6931,1000 -cohere_english_v3,1,symmetric-block-simd,84.57992,11.823,0.6931,1000 -cohere_english_v3,1,symmetric-single-scalar,71.156303,14.054,0.6931,1000 -cohere_english_v3,1,symmetric-single-simd,103.800972,9.634,0.6931,1000 -cohere_english_v3,2,asymmetric-block-simd,33.083916,30.226,0.853,1000 -cohere_english_v3,2,asymmetric-single-simd,8.052505,124.185,0.853,1000 -cohere_english_v3,2,symmetric-block-scalar,3.954008,252.908,0.8045,1000 -cohere_english_v3,2,symmetric-block-simd,37.979327,26.33,0.8045,1000 -cohere_english_v3,2,symmetric-single-scalar,13.813492,72.393,0.8045,1000 -cohere_english_v3,2,symmetric-single-simd,29.763263,33.598,0.8045,1000 -cohere_english_v3,4,asymmetric-block-simd,33.161011,30.156,0.8036,1000 -cohere_english_v3,4,asymmetric-single-simd,14.796965,67.581,0.8036,1000 -cohere_english_v3,4,symmetric-block-scalar,3.961422,252.435,0.7938,1000 -cohere_english_v3,4,symmetric-block-simd,43.79741,22.832,0.7938,1000 -cohere_english_v3,4,symmetric-single-scalar,8.252719,121.172,0.7938,1000 -cohere_english_v3,4,symmetric-single-simd,18.346236,54.507,0.7938,1000 -e5_large_v2,2,asymmetric-block-simd,32.756876,30.528,0.8395,1000 -e5_large_v2,2,asymmetric-single-simd,8.113795,123.247,0.8395,1000 -e5_large_v2,2,symmetric-block-scalar,3.934209,254.181,0.7877,1000 -e5_large_v2,2,symmetric-block-simd,38.003571,26.313,0.7877,1000 -e5_large_v2,2,symmetric-single-scalar,14.440238,69.251,0.7877,1000 -e5_large_v2,2,symmetric-single-simd,29.673568,33.7,0.7877,1000 diff --git a/docs/ash-symmetric-study/implementation-notes.md b/docs/ash-symmetric-study/implementation-notes.md deleted file mode 100644 index dfd07aa8e..000000000 --- a/docs/ash-symmetric-study/implementation-notes.md +++ /dev/null @@ -1,294 +0,0 @@ -# ASH symmetric construction scoring - -Implementation branch: `ash-symmetric-scorer`, based on `ash-dev` commit -`67f532a282c72b66e97c4b074242bea406c0eaa0`. No PR #522 deduplication changes. - -## Derivation and storage - -The final ASH paper (2606.07870v2), Appendix B equations B.2–B.4, gives - - dot(x,y) ~= sx sy dot(code_x,code_y) + ox + oy + ||mu||² - -where `sx = ||x-mu|| / ||code_x||` and `ox = - ||mu||²`. -For the Java/C++ 2/4-bit projection representation, the stored offset is -`ox - sx `. The new scorer restores the latter term once per -encoded vector. Both calculations use stored FP16-rounded headers; recovering -`ox` consequently retains FP16 error. Graph construction remains restricted to C=1. The symmetric scorer does not change the encoded payload layout. Standalone scoring now supports up to 256 landmarks; this expands the prior reader/encoder range guard, so standalone C>64 compressor files will be rejected by older implementations even though the layout is unchanged. - -The packed code layouts and offset convention follow -`tlwillke/ivf-ash-fastscan` revision `56d78d0`, particularly -`ash_projection_code.h` and the projection query code in `ash_query.cpp`, as -well as the existing Java `AsymmetricHashing` encoder and `ASHScorer` decoder. -The C++ IVF implementation provides the packed layout and header arithmetic; -it is not evidence for a graph-construction symmetric kernel. - -The binary shortcut is `d - 2*popcount(xor(sign_x,sign_y))`, with masked tail -bits. This follows B.3/B.6 directly. The printed B.7 has an apparent constant -term error (`d^-1` instead of 1 after dividing by d); its prose also confuses -the AND dot product of 0/1 bits with XNOR agreement. The implementation uses -the algebraic identity and independent decoder tests. - -For 2/4-bit codes, doubled component values are odd signed integers. The 2-bit -single kernels use weighted popcounts (scalar word or SIMD); the 4-bit scalar -fallback uses a shared 512-byte nibble-product table, while SIMD unpacks to -short lanes and multiplies. Integer sums are divided by four. Widths 3 and 5–9 -use the existing generic sign-plus-extra layout. Binary range scoring applies -direct XOR/popcount pairs. C=1 multibit block scoring builds exact short-valued -lookup tables from the encoded query, then uses packed paired-nibble lookups -and bounded short accumulation followed by widening. This borrows the C++ -fastscan implementation approach without adding another quantization step. -There is no per-pair raw-vector projection or SIMD gather. - -The graph adapter applies `max(0,(1+dot)/2)` exactly once, matching asymmetric -ASH. The prior symmetric adapter returned raw dots while insertion search -used transformed similarities. That mixed incompatible scales during pruning. - -## Defects in the previous scorer - -For 2/4-bit ASH, `binaryVector` is empty and the payload is in `extraBits`. -The previous loop read no payload, yielding the same code-dot term `-d` for -every pair before header corrections. It also treated the projection-mode -offset as the unadjusted offset. For one-bit tails it counted complement -padding as matches. These defects predate the duplicate-neighbor work. - -## Comparison modes - -- Baseline: untouched ash-dev jar, including the broken construction scorer. -- Symmetric (default candidate): corrected symmetric node-to-node scoring; - insertion search still uses the incoming raw vector asymmetrically. -- Asymmetric: benchmark adapter uses the existing query scorer and original - source vectors for node-ID construction calls, including diversity checks. -- Symmetric-all: corrected symmetric scorer also used for insertion search. -- Asymmetric-cached: identical existing asymmetric math, with immutable query - state precomputed once per node in parallel; retains the arrays for the build. - -All modes use the existing graph algorithm, including its current duplicate -behavior. Query-time scoring remains the same asymmetric fused scorer. -Symmetric construction adds one float per encoded vector for 2/4-bit landmark -corrections. Asymmetric construction needs access to original vectors and -repeats the current scorer's projection/setup on each node-ID provider call. -The uncached and cached controls separate pairwise scoring cost from setup cost. - -Run-level settings: 32X, 2 bits, C=1, ITQ, M=64, efConstruction=200, -overflow=1.2, hierarchy/refine/fused enabled, NVQ reranking, k=10 at 1x, -pruning disabled, adaptive termination at the existing default gamma=0.01. New graph build each run; existing identical compressors -are reused to isolate graph construction. JDK23, vector module, SIMD kernels, -128GB heap, RAID10 temp directory. Two repetitions use reversed mode order. -Provider setup is reported separately because the legacy graph-build timer -starts after provider initialization; the tables add it to measured graph time. -Writer/builder initialization outside that timer and initial ASH encoding are -not included; these are graph-build comparisons, not end-to-end indexing time. -The old provider setup was not separately timed and is treated as zero (it -only computed the landmark norm). - -## Asymmetric construction profile - -A 30-second JFR profile of the first Ada002 asymmetric construction run contains -14,333 execution samples: 96.29% of samples include `ASHScorer.precomputeQuery`, -and 86.90% have `PanamaVectorUtilSupport.ashDotRow` as the leaf method. Only -1.97% include `ashProjectionDot`. Inclusive percentages overlap and must not -be added. The first Ada002 asymmetric timing includes this profile overhead; -the reversed-order repetition is unprofiled. - -This establishes repeated projection/setup as the main cost of the uncached -asymmetric adapter. It does not establish that asymmetric pairwise arithmetic -is intrinsically slower by the observed build-time ratio. A separate cached -adapter retains each immutable existing asymmetric scorer once per node to -measure the speed/memory tradeoff without changing its numerical algorithm. -For C=1 the existing scorer retains two d-length float arrays plus two scalar -float arrays per node, as well as object/array overhead. This is far larger -than the symmetric scorer's single cached float per node. - -Existing on-disk graphs keep their existing topology. Rebuild to obtain the -new construction behavior; merely changing the reader does not repair edges -selected by the old scorer. The asymmetric query scorer and encoded payload -format are unchanged. - -The proposed default remains a mixed construction strategy: accurate asymmetric search -for a newly supplied raw vector, compact symmetric scoring for existing-node -pruning and refinement. Fully symmetric insertion and fully asymmetric node-ID -scoring are benchmark controls, not additional production defaults. - -The paper writes multibit components as odd integers (e.g. -3,-1,1,3 at two -bits). Java and the C++ projection decoder use half of those values -(-1.5,-0.5,0.5,1.5). The stored scale is correspondingly twice as large. -The reconstruction and symmetric product are unchanged by this convention; -the nibble table uses doubled integer components internally and divides its -product sum by four. - -Score-quality probes are sampled database vectors, with self matches excluded, -and their reference neighbors are obtained by an exact input-space scan. -They diagnose construction-space score quality. Graph recall uses the dataset's -separate benchmark queries and ground truth; the two recall numbers should -not be interpreted as a shared recall ceiling. - -## Validation and reproduction - -Initial correctness fix: `baa1027c`. Expanded encoded kernels and flexibility: `cc5d0d1b`. Correct timing and isolated benchmark modes: `62bc6f6d`. Graph-context SIMD allocation fix: `1338d46f`. The expanded regression tests cover all widths 1–9, -dimensions 1–749 including every tail alignment, independent unpacking, -scalar/SIMD cross-checks, exact exchange symmetry, Appendix B reconstruction, -existing asymmetric scoring of reconstructed sources, FP16 header round trips, -zero residuals, negative raw scores, the graph C=1 guard, standalone C=2/64/256 across widths 1–9, and graph construction. -The focused suite also includes existing `TestASHGraphSearch` (2 tests) and -`TestASHScoringDispatch` (4 tests). Scalar and SIMD Maven executions passed, with both the preferred 512-bit SIMD shape and an additional forced-AVX2 256-bit check. - -Build/test command (JDK23): - -```sh -mvn -pl jvector-tests,jvector-examples -am \ - -Dtest=TestASHSymmetricScorer,TestASHGraphSearch,TestASHScoringDispatch,TestASHStandaloneVsFusedScoring \ - -Dsurefire.failIfNoSpecifiedTests=false \ - -DskipScalar=false -DskipSIMD=false -Dmaven.javadoc.skip=true package -``` - -Benchmark-only mode selection is -`-Djvector.bench.ashConstruction=symmetric|symmetric-all|asymmetric|asymmetric-cached`. -The library default has no new configuration switch. The example benchmark -adapter changes construction only; query scoring remains asymmetric. - -JVM options used for every benchmark: - -```text ---add-modules=jdk.incubator.vector ---enable-native-access=ALL-UNNAMED --Xmx128g --Djvector.ash.singleKernel=simd --Djvector.ash.blockKernel=simd --Djvector.ash.lut.accumulators=2 --Djvector.ash.lut.unroll=1 --Djvector.ash.projection.accumulators=1 --Djvector.ash.projection.unroll=1 --Djava.io.tmpdir=/mnt/raid10/jvector-bench/tmp -``` - -The main class is `io.github.jbellis.jvector.example.BenchYAML`; pass a YAML -with the settings above as its argument. Score diagnostics use -`-Djvector.ash.debugSymmetric=true`, with `.pairs=5000`, `.subsetN=100000`, -and `.topK=10` appended to the same property prefix. Diagnostics run before -graph construction; those extra diagnostic-run builds are excluded from the -comparison tables. Their single-thread scoring measurements exclude setup -and use the median of nine scans after five warmups. These are supporting -microbenchmarks, not graph throughput predictions or a replacement for JMH. - -The remote evidence directory is -`/mnt/raid10/jvector-bench/ted_willke/ash-symmetric-study`. -It retains frozen baseline/candidate jars, per-run logs, the input-space score -study, JFR profile, and extracted `results.csv`. The baseline jar was compiled -from `67f532a2` before the scorer change. The first candidate jar is preserved -as `candidate-v1.jar`; the later jar adds cache and diagnostic controls with -identical production scorer arithmetic. No compressor training was retimed. - -## DistancesASH prepared-query comparison - -Commit `f8e64213` adds `ASHSymmetricScorer.scoreFunctionFor(QuantizedVector)` -and the benchmark mode `-Djvector.bench.symmetric-scoring=true`. Queries must -be encoded with the same trained compressor as the base vectors. This API -recovers the query landmark offset once, before returning the scoring function; -its timed kernel is the same `codeDot` implementation used by node-ID scoring. - -In this mode DistancesASH prepares all compressed queries and both sets of -scoring functions before measurement. Query encoding and projection setup are -reported separately and excluded. Both scorers then use the same full-scan -loop, executor, query subset, checksum accumulation, and five measured passes -with alternating order. It reports median throughput and min/max elapsed time. -Warmup, independent decoded-oracle validation, and held-out-query recall are -outside all throughput timers. The ordinary historical benchmark mode remains -available; its timing includes scorer setup and must not be mixed with these -prepared-query results without labeling that distinction. - -Example flags in addition to the shared JVM flags: - -```text --Djvector.bench.symmetric-scoring=true --Djvector.ash.optimizer=itq --Djvector.ash.landmarkCount=1 --Djvector.ash.bitsPerDimension=2 --Djvector.ash.quantizedDimensions=748 --Djvector.bench.recall=true --Djvector.bench.recall.k=10 --Djvector.bench.maxQueries=1000 --Djvector.bench.scoringQueries=128 --Djvector.bench.scoringPasses=5 --Djvector.bench.scoringWarmupQueries=32 --Djvector.bench.scoringThreads=1 -``` - -Call `io.github.jbellis.jvector.example.DistancesASH` with base `.fvecs`, -query `.fvecs`, and ground-truth `.ivecs` paths. At 32X with two bits, the -quantized dimension is `(originalDimension - 40) / 2`: 748 for Ada002 and -492 for E5-large/Cohere. The 40-bit header is included in the compression -budget. Generic bit widths require `singleKernel=scalar` for the asymmetric -control; that restriction belongs to the existing asymmetric implementation. -The binary symmetric path is popcount, 2/4-bit is packed nibble arithmetic, -and the remaining widths use the generic reference representation. - -The tiny end-to-end fixture uses 129 base vectors and nine distinct held-out -queries at original dimension 64 / quantized dimension 31. All widths 1–9 -pass the independent decoder validation, ground-truth recall plumbing, and -timing loops. The fixture timings are not used as performance evidence. - - -## Encoded-input contract and measurement - -Symmetric distance starts from two encoded vectors. Prior projection and encoding -of both operands are excluded. Query-specific state derived from the encoded -query (such as LUT creation or folded-header recovery) is included in timing. -No symmetric scorer multiplies a raw vector by the projection matrix. -Asymmetric distance starts from a raw query and an encoded target: its query -projection and scorer setup are timed once per query, then reused over that -query's candidates. Recall selection is outside throughput timing. - -The initial PREPARED_* results are kernel-only measurements with scorer setup -excluded for both methods. They are retained as historical data, not the final -end-to-end scoring comparison. SCORING_* results use the contract above. -Final microbenchmark modes should run in independent JVMs to avoid cross-mode -JIT profile effects. DistancesASH supports `jvector.bench.scoringModes` for this. -Warmup lasts at least one second per selected mode, and each timed pass lasts -at least 0.25 seconds by repeating query batches when necessary. Raw duration, -repetitions, and normalized scan duration are recorded separately. A 64-candidate -run measures setup amortization and is not graph QPS or a full scan. - -## Multiple landmarks - -Appendix B presents C=1. For standalone C>1, the implementation generalizes its -header-calibrated reconstruction rather than claiming an additional paper result: - - xhat = mu_a + sx W^T code_x - delta_x = ox - sx - score(x,y) = + delta_x + delta_y - -This expands into symmetric code-dot, cross-centroid, and centroid-dot terms and -reduces algebraically to Appendix B at C=1. Encoded-query state can be fed to the -existing asymmetric single/block kernels without a raw-vector projection. Tests -compare direct decoded reconstruction, exchanged operands, and scalar/SIMD paths. -Centroid IDs must be treated as unsigned bytes, including IDs 128–255. Serialization -round trips allow float-matrix rounding error; packed integer kernel parity is exact. - - -## Graph-context SIMD compilation - -A fast isolated scorer was not sufficient evidence for a production build path. -The first combined SIMD dispatch body allocated boxed ByteVector and mask objects -when called from graph diversity pruning. A profiled Ada002 100k build took -24.7 seconds, versus 18.2 seconds for the earlier corrected scalar scorer. -Splitting the 1/2/4-bit Vector API kernels into small compilation units and -making matched vector species static constants removed that allocation pattern. -The first profiled split-SIMD build took 12.8 seconds; the improved scalar -word-popcount control took 15.5 seconds. Whole-run GC counts were 137 before -the split and 30 afterward. These are diagnostic runs; final tables use fresh -unprofiled runs. This is why the final recommendation is based on actual graph -construction as well as isolated full scans. - - -## Sustained graph-query timing - -The original graph throughput harness measured each 10,000-query batch only -once. At hundreds of thousands of QPS those samples lasted only tens of -milliseconds, producing large variability and an unreproduced Cohere slowdown. -`f98fcf92` repeats complete query batches until each sample lasts at least one -second and records completed query count plus elapsed time. Synthetic warmup -queries remain unchanged. Both graph branches use the identical benchmark-only -overlay; the baseline library JAR remains byte-for-byte unchanged. The overlay -contains only `ThroughputBenchmark.class`, no graph, quantization, or SIMD classes. -Final graph results use this sustained timing protocol. Earlier short-sample -results and the construction-boxing experiment are archived separately. diff --git a/docs/ash-symmetric-study/kernel-experiments.md b/docs/ash-symmetric-study/kernel-experiments.md deleted file mode 100644 index 2f95d9a5b..000000000 --- a/docs/ash-symmetric-study/kernel-experiments.md +++ /dev/null @@ -1,39 +0,0 @@ -# Encoded symmetric kernel experiments - -Cohere 100k, 32X, C=1, one thread, JDK23, Xeon 6985P-C. Each final comparison mode ran in its own JVM. Five timing passes; prior query encoding is excluded for symmetric scoring; asymmetric projection and scorer setup are included once per query. Short scans repeat batches for at least 0.25 seconds per pass. Values below are median ns/pair. - -## 2-bit pair implementations - -| Implementation | 99,685 targets/query | 64 targets/query | -|---|---:|---:| -| Scalar word popcounts | 63.3 | 90.3 | -| SIMD unpack and multiply | 95.6 | 125.9 | -| SIMD weighted popcounts | 42.4 | 62.0 | -| Asymmetric SIMD single | 119.5 | 980.7 | - -The experimental scalar-word implementation used the temporary SIMD entry point for the comparison harness; it executes scalar CPU popcount instructions. It is retained in the scalar fallback. The final SIMD implementation uses weighted vector popcounts. Experimental switches were removed. - -## Block implementations - -| Bits | Implementation | 99,685 targets/query | 64 targets/query | -|---:|---|---:|---:| -| 2 | Reused floating-point symmetric LUT | 37.9 | 197.6 | -| 2 | Paired-nibble exact integer symmetric LUT | 25.3 | 200.7 | -| 2 | Asymmetric SIMD block | 31.0 | 670.7 | -| 4 | Reused floating-point symmetric LUT | 36.5 | 161.5 | -| 4 | Paired-nibble exact integer symmetric LUT | 23.2 | 127.5 | -| 4 | Asymmetric SIMD block | 30.1 | 363.7 | - -## Binary scoring - -| Implementation | 99,685 targets/query | -|---|---:| -| symmetric-single-scalar | 13.2 | -| symmetric-single-simd | 12.2 | -| symmetric-block-simd | 14.8 | -| asymmetric-single-simd | 54.0 | -| asymmetric-block-simd | 80.5 | - -The integer LUT kernel borrows the C++ fastscan pattern of packed nibble lookups and narrow accumulators. Its values are exact products of encoded integer components; it does not requantize scores. Accumulators widen in bounded chunks to prevent overflow. These are scorer measurements, not graph QPS; the two methods also have different approximation error because symmetric scoring quantizes both operands. - -Shared-JVM exploratory runs exhibited large changes in unchanged control timings. They are not used in this table. Independent-process comparisons eliminate cross-mode type-profile pollution, but these are still one-host measurements rather than a universal hardware claim. diff --git a/docs/ash-symmetric-study/report.md b/docs/ash-symmetric-study/report.md deleted file mode 100644 index 8ee100df3..000000000 --- a/docs/ash-symmetric-study/report.md +++ /dev/null @@ -1,174 +0,0 @@ -# ASH symmetric scorer: final study - -Completed on the authoritative remote repository `/home/ted_willke/jvector-ash`, branch `ash-symmetric-scorer`. No deduplication changes. - -# Findings and recommendation - -Use symmetric ASH for comparisons between already encoded graph nodes. Keep the existing asymmetric scorer for raw-vector insertion and user queries. Both operands of symmetric scoring are already encoded; their earlier projection/encoding is excluded. Asymmetric query projection is timed once per query and reused over that query's candidates. - -## Correctness - -The old node-to-node implementation ignored the 2/4-bit payload, used folded offsets as though they were original centroid corrections, and returned a score on a different scale from insertion search. Binary tail bits were also handled incorrectly. The replacement follows Appendix B of the final paper at C=1 and preserves the library's normalized dot-product similarity scale. - -Standalone scoring supports 1–9 bits, with SIMD single/range kernels for 1/2/4 bits and up to 256 landmarks. The graph provider retains its C=1 restriction. Multiple-landmark scoring uses a tested header-calibrated reconstruction generalization; Appendix B itself only presents C=1. Tests also uncovered and fixed signed-byte indexing for landmark IDs 128–255. The encoded layout is unchanged, but older implementations' C≤64 guard rejects newly written standalone C>64 compressor files. - -## Kernel choices - -The retained paths are binary XOR/popcount, scalar word and SIMD weighted popcounts for 2-bit pairs, SIMD unpack/multiply for 4-bit pairs, and exact integer LUT block scoring for 2/4 bits. The block implementation borrows the C++ fastscan approach of packed nibble lookup and narrow accumulation. Bounded widening prevents short overflow. It adds no extra score quantization. Experimental alternatives and switches were removed. - -A fast isolated SIMD kernel initially allocated boxed Vector API objects when called from graph pruning. Profiling exposed this; splitting kernels by bit width and making species constants explicit removed the dominant boxed allocation pattern. A profiled Ada002-100k build fell from 24.7 to 12.8 seconds; the earlier corrected scalar implementation took 18.2 seconds. These diagnostic timings are separate from the final unprofiled tables. - -## Graph evidence - -Against the correct, projection-cached asymmetric construction control, symmetric construction was faster on all six measured cases, with slightly higher mean graph recall in each case. At 1M, build plus provider setup averaged: - -| Dataset | Cached asymmetric | Symmetric | Recall@10: asymmetric / symmetric | -|---|---:|---:|---:| -| Ada002 | 231.69 s | 157.90 s | 82.462% / 82.669% | -| CAP | 213.31 s | 150.08 s | 78.865% / 78.889% | -| Cohere | 233.05 s | 121.57 s | 69.514% / 69.632% | - -The asymmetric cache retains approximately 3.7–5.6 GiB of float arrays at 1M, plus object overhead; symmetric header corrections need approximately 3.8 MiB. No such asymmetric cache was added to production defaults. - -These means need qualification. Cohere's cached-asymmetric build varied from about 155 to 308 seconds. Query QPS also varies between independently built graphs/JVMs, even with sustained timing. The table does not establish one universal speedup or statistically significant small recall differences. - -Compared with the legacy ash-dev scorer, corrected recall improves on all nine 100k and all three 1M datasets. Construction is not uniformly faster than the legacy implementation, which was not performing the intended distance calculation. At 100k, mean query QPS decreases about 4.0% for E5-small and 3.2% for Gecko and improves for the other seven. These exceptions are retained in the report. - -An earlier fully symmetric insertion control gave no consistent build benefit and lost 0.358 percentage points on E5-large versus mixed construction. Keep raw-vector insertion asymmetric. Full-scan symmetric query scoring also incurs additional quantization error; faster encoded-pair arithmetic is not a reason to replace raw-query asymmetric scoring universally. - -## Measurement protocol - -Graph settings: 32X, 2 bits, C=1, ITQ, M=64, efConstruction=200, overflow=1.2, hierarchy/refinement/fused storage, NVQ reranking, k=10 at 1×, pruning disabled, existing adaptive gamma=0.01. Fresh graph builds reuse identical compressors to isolate scoring/build changes. Build seconds include graph build/write and provider setup, excluding training and initial vector encoding. - -Each graph mode has two independent builds. Legacy/candidate order is reversed on the second pass. QPS uses three samples of at least one second each, with actual completed-query counts recorded. The same benchmark-only overlay is applied to both library JARs; baseline library bytes are unchanged. Short-sample exploratory timings are archived separately. - -Microbenchmarks run one mode per JVM, one scoring thread, five timing passes, and 1,000 held-out queries for untimed exhaustive recall. Symmetric query encoding is excluded; scorer setup is included. Asymmetric projection is included once per timed query. The 64-target diagnostics measure setup amortization and are not graph QPS. - -Tests cover independent decoded-vector formulas, exact packed scalar/SIMD parity, all tail alignments, integer accumulator overflow, zero residuals, negative scores, serialization, unsigned landmark IDs, C=1 graph guards, and standalone C=2/64/256 across all bit widths. SIMD was checked at both 512-bit and forced 256-bit widths on JDK23. - - -# Final full-scan scorer comparison - -One thread, one mode per JVM, 32X compression, C=1, ITQ. Symmetric starts from encoded vectors; previous query encoding is excluded. Asymmetric raw-query projection is included once per query. Scorer setup is included for both. Five timing passes use 128 queries each; held-out exhaustive recall uses 1,000 queries and is outside the timer. Values are million scored vector pairs per second. - -| Dataset | Bits | Sym single scalar | Sym single SIMD | Sym block scalar | Sym block SIMD | Asym single SIMD | Asym block SIMD | Sym recall@10 | Asym recall@10 | -|---|---:|---:|---:|---:|---:|---:|---:|---:|---:| -| ada002 | 2 | 8.87 | 22.05 | 2.42 | 30.85 | 5.51 | 21.50 | 84.37% | 88.08% | -| cohere_english_v3 | 1 | 71.16 | 103.80 | 83.87 | 84.58 | 18.87 | 12.40 | 69.31% | 78.91% | -| cohere_english_v3 | 2 | 13.81 | 29.76 | 3.95 | 37.98 | 8.05 | 33.08 | 80.45% | 85.30% | -| cohere_english_v3 | 4 | 8.25 | 18.35 | 3.96 | 43.80 | 14.80 | 33.16 | 79.38% | 80.36% | -| e5_large_v2 | 2 | 14.44 | 29.67 | 3.93 | 38.00 | 8.11 | 32.76 | 78.77% | 83.95% | - -All four symmetric modes produced identical recall for each dataset/bit-depth case; both asymmetric modes also agreed. Kernel tests independently compare scores and arbitrary padding, rather than relying on recall agreement alone. Symmetric and asymmetric recall are expected to differ because symmetric scoring quantizes both operands. All widths here use the same 32X byte budget: changing bits changes projected dimensionality. These 4-bit results are not a 16X comparison. - -Scalar block throughput can be lower than scalar single throughput; block layout is intended to benefit SIMD. No claim is made that every scalar fallback is faster than every asymmetric SIMD path. - - -# Final graph comparison - -Means of two independent builds, with branch order reversed on the second pass. Baseline is ash-dev `67f532a2`, including its legacy incomplete symmetric construction scorer. Candidate uses corrected symmetric node-to-node scoring and existing asymmetric raw-vector insertion/query scoring. No deduplication changes. - -32X ASH, 2 bits, C=1, ITQ, M=64, efConstruction=200, NVQ reranking, k=10, 1× overquery, search pruning disabled, existing adaptive gamma=0.01. Build seconds include provider setup plus the existing graph-build/write timer; compressor training and initial vector encoding are excluded. Query throughput uses identical benchmark-only code for both branches, at least one second per sample, three samples per build. - -## 100k - -| Dataset | Build seconds: old / new | QPS: old / new | Visited: old / new | Recall@10 %: old / new | -|---|---:|---:|---:|---:| -| ada002-100k | 11.77 / 14.58 | 132,120 / 133,631 | 558.5 / 544.3 | 83.897 / 86.375 | -| cohere-english-v3-100k | 11.82 / 12.69 | 222,249 / 226,772 | 649.8 / 644.1 | 81.538 / 82.798 | -| e5-base-v2-100k | 10.11 / 10.89 | 315,885 / 367,709 | 648.2 / 644.9 | 77.808 / 78.931 | -| e5-large-v2-100k | 12.17 / 12.37 | 216,955 / 235,406 | 658.1 / 666.1 | 78.497 / 81.195 | -| e5-small-v2-100k | 8.27 / 9.14 | 585,295 / 561,934 | 719.2 / 691.5 | 59.259 / 60.414 | -| gecko-100k | 9.30 / 11.35 | 362,181 / 350,590 | 660.3 / 673.5 | 78.487 / 80.398 | -| openai-v3-large-1536-100k | 14.67 / 13.87 | 128,633 / 142,892 | 516.4 / 500.9 | 86.616 / 87.943 | -| openai-v3-large-3072-100k | 17.54 / 21.31 | 45,233 / 46,530 | 508.4 / 498.7 | 89.584 / 91.139 | -| openai-v3-small-1536-100k | 12.32 / 14.03 | 133,045 / 149,298 | 541.1 / 533.6 | 85.122 / 86.738 | - -## 1M - -| Dataset | Build seconds: old / new | QPS: old / new | Visited: old / new | Recall@10 %: old / new | -|---|---:|---:|---:|---:| -| ada002-1M | 128.45 / 157.90 | 122,692 / 134,936 | 773.3 / 745.4 | 79.273 / 82.669 | -| cap-1M | 127.57 / 150.08 | 125,609 / 140,925 | 762.7 / 751.7 | 73.302 / 78.889 | -| cohere-english-v3-1M | 189.94 / 121.57 | 208,169 / 214,930 | 888.3 / 889.6 | 64.447 / 69.632 | - -## Build-time ranges across repetitions - -| Dataset | Old seconds, range | New seconds, range | -|---|---:|---:| -| ada002-100k | 11.56–11.98 | 14.28–14.89 | -| ada002-1M | 128.02–128.88 | 153.57–162.23 | -| cap-1M | 125.08–130.06 | 144.10–156.05 | -| cohere-english-v3-100k | 11.71–11.92 | 12.45–12.94 | -| cohere-english-v3-1M | 135.08–244.79 | 116.87–126.27 | -| e5-base-v2-100k | 10.06–10.16 | 10.35–11.44 | -| e5-large-v2-100k | 11.38–12.97 | 12.24–12.49 | -| e5-small-v2-100k | 8.09–8.45 | 8.62–9.66 | -| gecko-100k | 8.35–10.25 | 11.32–11.38 | -| openai-v3-large-1536-100k | 13.16–16.19 | 13.84–13.90 | -| openai-v3-large-3072-100k | 16.66–18.41 | 21.09–21.52 | -| openai-v3-small-1536-100k | 10.99–13.65 | 13.96–14.10 | - -Cohere-1M baseline build time varies substantially (135–245 seconds); the mean improvement should not be interpreted as a stable fixed speedup. Across the nine 100k datasets, mean query QPS decreases by about 4.0% on E5-small and 3.2% on Gecko; it improves on the other seven. Recall increases on all datasets. Two builds do not establish statistical equivalence for small performance or recall differences. - - -# Correct symmetric versus asymmetric construction - -Two builds per mode. The asymmetric control projects each raw source vector once, caches its existing immutable asymmetric scorer, and uses it for node-ID construction comparisons. The symmetric mode starts from encoded nodes and uses cached header corrections. Both use asymmetric raw-vector insertion and query scoring. Setup is included below. This control avoids attributing repeated projection work to pair-kernel speed. It is a benchmark adapter, not a proposed production cache. - -| Dataset | Build seconds: asymmetric / symmetric | Symmetric speedup | Recall@10 %: asymmetric / symmetric | QPS: asymmetric / symmetric | Visited: asymmetric / symmetric | -|---|---:|---:|---:|---:|---:| -| ada002-100k | 19.70 / 14.58 | 1.35× | 86.243 / 86.375 | 143,930 / 133,631 | 546.8 / 544.3 | -| ada002-1M | 231.69 / 157.90 | 1.47× | 82.462 / 82.669 | 108,328 / 134,936 | 751.9 / 745.4 | -| cap-1M | 213.31 / 150.08 | 1.42× | 78.865 / 78.889 | 140,263 / 140,925 | 754.2 / 751.7 | -| cohere-english-v3-100k | 15.41 / 12.69 | 1.21× | 82.754 / 82.798 | 229,465 / 226,772 | 655.7 / 644.1 | -| cohere-english-v3-1M | 233.05 / 121.57 | 1.92× | 69.514 / 69.632 | 231,304 / 214,930 | 887.0 / 889.6 | -| e5-large-v2-100k | 15.70 / 12.37 | 1.27× | 81.089 / 81.195 | 221,213 / 235,406 | 669.0 / 666.1 | - -Mean symmetric construction recall is slightly higher in every case (approximately +0.02 to +0.21 percentage points); two builds are insufficient to claim all of those small differences are significant. Query QPS varies with the built graph and JVM: all modes retain the same asymmetric query scorer. In particular, cached-asymmetric Ada002-1M measured roughly 85k and 132k QPS across its two builds, and cached-asymmetric Cohere-1M build time varied from roughly 155 to 308 seconds. Reported means do not remove that uncertainty. - -At 1M, the asymmetric control retains approximately 3.7–5.6 GiB of float query-state arrays, plus object/array overhead. The symmetric scorer retains about 3.8 MiB of per-node header corrections at C=1, plus negligible centroid state. Encoded vectors and other shared index memory are excluded from both figures. - - -# Implementation and provenance - -Final implementation commit: `1707d916`. The final graph/scorer measurements use kernel source `1338d46f` with the identical `f98fcf92` throughput benchmark overlay on both branches. The subsequent API commit delegates the previously unimplemented `ASHVectors.diversityFunctionFor` to the tested scorer; the measured graph provider already called that scorer directly. No measured kernel was changed afterward. - -The final API regression run passed TestASHSymmetricScorer (7 tests), TestASHGraphSearch (2), TestASHScoringDispatch (4), and TestASHStandaloneVsFusedScoring (3). Earlier scalar/SIMD and forced 256-bit validation is described in [implementation notes](implementation-notes.md). Optional native gtest/Google Benchmark dependencies were unavailable; no native C++ test result is claimed. - -Source references: - -- Final paper: supplied `2606.07870v2 (3).pdf`, Appendix B (C=1 symmetric derivation). -- C++ reference: `https://github.com/tlwillke/ivf-ash-fastscan`, commit `56d78d0ff3aaaa61a02b94c6f49a97789706a717`; packed lookup and accumulation patterns in `ash_fastscan_avx512.cpp`, header/projection handling in `ash_projection_code.h` and `ash_query.cpp`, LUT setup in `ash_lut.cpp`. -- Java: `ASHSymmetricScorer` (encoded pair scoring and prepared factories), `PanamaVectorUtilSupport` (specialized SIMD kernels), `ASHVectors.diversityFunctionFor` (public API), `AsymmetricHashing` (unsigned landmark handling), `DistancesASH` (isolated timing and score oracle), `ThroughputBenchmark` (sustained samples). - -## Commits - -| Commit | Change | -|---|---| -| baa1027c | Complete symmetric ASH construction scoring for packed codes | -| f8e64213 | Benchmark prepared symmetric ASH queries in DistancesASH | -| 992f7c6c | Compare ASH construction scoring policies and setup costs | -| cc5d0d1b | Optimize symmetric ASH scoring from encoded vectors | -| 62bc6f6d | Measure ASH scoring with correct query setup and isolated modes | -| 1338d46f | Prevent boxed SIMD allocations during ASH graph pruning | -| f98fcf92 | Measure graph throughput over sustained query batches | -| 1707d916 | Expose symmetric ASH scoring through the diversity API | - -## Data and reproduction - -- [Graph observations, 48 builds](final-graph-results.csv) -- [Correct asymmetric construction controls, 12 builds](final-asymmetric-controls.csv) -- [Scorer measurements, 30 isolated cases](final-micro-results.csv) -- [Kernel experiments](kernel-experiments.md) -- [Implementation notes](implementation-notes.md) - -Remote evidence, frozen JARs, raw logs, profiles, runner configuration and drivers remain under `/mnt/raid10/jvector-bench/ted_willke/ash-symmetric-study`. Local report data is also committed under `docs/ash-symmetric-study`. Existing user YAML files were preserved. No merge or push was performed. - -Frozen artifact SHA-256: - -```text -22212ed3842676dec1b32558888c178601a5d94ca5a989d828c731f8e7b8762e /mnt/raid10/jvector-bench/ted_willke/ash-symmetric-study/candidate-final.jar -b19993e9a43a321428e613277afba3de15a9c5cc1bf1085d6579267d8e296f77 /mnt/raid10/jvector-bench/ted_willke/ash-symmetric-study/baseline.jar -40aede39e95da39ed82b6b373acd90554561a7edd5118a45912ae159dc9a44e2 /mnt/raid10/jvector-bench/ted_willke/ash-symmetric-study/throughput-overlay.jar -``` diff --git a/docs/benchmarks/native-ash-complete-budgets-20261029/construction.csv b/docs/benchmarks/native-ash-complete-budgets-20261029/construction.csv deleted file mode 100644 index a2338dfeb..000000000 --- a/docs/benchmarks/native-ash-complete-budgets-20261029/construction.csv +++ /dev/null @@ -1,17 +0,0 @@ -dataset,jdk,ash_bits_budget,algorithm,training_seconds,encoding_seconds,graph_write_seconds,process_seconds_including_validation,fresh_graph_recall_at_10 -MPNet,22,2,ASH,5.292379244,1.047888893,54.164598291,67.83305824548006,0.81888 -OpenAI3072,22,4,ASH,38.086685724,25.959572952,101.919147369,185.2930998262018,0.7896 -MPNet,25,2,ASH,5.328078086,0.952869518,34.225727643,46.53267274983227,0.81633 -OpenAI3072,25,4,ASH,36.395486952,25.752220845,106.236637281,186.0093028023839,0.787 -MPNet,22,4,ASH,4.450163238,1.325867775,41.280168348,54.49509896989912,0.87111 -OpenAI3072,22,2,ASH,38.293915111,27.356674768,86.742283949,168.8477511294186,0.772 -MPNet,25,4,ASH,4.783722329,1.052683121,36.517193728,48.428979087620974,0.86819 -OpenAI3072,25,2,ASH,41.538574207,26.930347871,85.792219557,171.2168671572581,0.778 -MPNet,22,2,PQ,2.729675109,1.081207084,44.438387038,52.27324899379164, -MPNet,22,4,PQ,6.137772914,1.70667817,50.786497509,62.748410480096936, -MPNet,25,2,PQ,2.774568465,1.048305647,33.769320929,41.54969112202525, -MPNet,25,4,PQ,4.652523221,1.745370325,48.549881485,58.950520511716604, -OpenAI3072,22,2,PQ,6.505794804,5.508848828,90.502056269,113.07548744231462, -OpenAI3072,22,4,PQ,19.649721716,7.64310692,257.818535867,295.41825035214424, -OpenAI3072,25,2,PQ,5.897795915,5.580369998,113.005143545,135.150859314017, -OpenAI3072,25,4,PQ,16.624147058,7.778564805,247.52862079,281.5635087201372, diff --git a/docs/benchmarks/native-ash-complete-budgets-20261029/queries.csv b/docs/benchmarks/native-ash-complete-budgets-20261029/queries.csv deleted file mode 100644 index 550533aa5..000000000 --- a/docs/benchmarks/native-ash-complete-budgets-20261029/queries.csv +++ /dev/null @@ -1,65 +0,0 @@ -dataset,jdk,ash_bits_budget,algorithm,threads,overquery,qps,recall_at_10,avg_visited,cv_pct -MPNet,22,2,ASH,1,1,11110.99464179856,0.8194,457.6786,5.908531619200125 -MPNet,22,2,ASH,1,2,8875.235693873283,0.94055,639.0262,0.21322087119190308 -MPNet,22,2,ASH,8,1,80378.2764372332,0.8194,457.6786,2.5685345814363902 -MPNet,22,2,ASH,8,2,62296.523830157785,0.94055,639.0262,8.754005476832623 -OpenAI3072,22,4,ASH,1,1,1026.4376092178372,0.7903,512.685,5.087713390174111 -OpenAI3072,22,4,ASH,1,2,1046.101981421671,0.8834,735.241,0.9862944129088163 -OpenAI3072,22,4,ASH,8,1,8258.710248857857,0.7903,512.685,3.4045657578531534 -OpenAI3072,22,4,ASH,8,2,7926.960479696699,0.8834,735.241,2.347372560147485 -MPNet,25,2,ASH,1,1,12117.702889285703,0.82017,455.8008,1.543338944623599 -MPNet,25,2,ASH,1,2,9286.185025696477,0.93971,636.613,0.25028073268698053 -MPNet,25,2,ASH,8,1,83549.5892088437,0.82017,455.8008,4.296693036410662 -MPNet,25,2,ASH,8,2,69337.75348751461,0.93971,636.613,0.909554792882135 -OpenAI3072,25,4,ASH,1,1,1011.9147946874974,0.7965,510.697,7.701434971355209 -OpenAI3072,25,4,ASH,1,2,1054.0483629342123,0.8875,730.922,0.89958827737423 -OpenAI3072,25,4,ASH,8,1,8559.176543886948,0.7965,510.697,1.8174480489639642 -OpenAI3072,25,4,ASH,8,2,7983.474801021447,0.8875,730.922,1.5482454681357327 -MPNet,22,4,ASH,1,1,9587.562121268436,0.86642,431.9821,6.030183415148024 -MPNet,22,4,ASH,1,2,7812.970974199045,0.93966,596.0551,1.1133190250185043 -MPNet,22,4,ASH,8,1,72202.01996457983,0.86642,431.9821,6.482810657090848 -MPNet,22,4,ASH,8,2,57661.64969036542,0.93966,596.0551,3.566408856618144 -OpenAI3072,22,2,ASH,1,1,1132.2179536928695,0.7792,530.783,5.109334879272743 -OpenAI3072,22,2,ASH,1,2,1135.6799508557433,0.898,767.32,0.4463337523790325 -OpenAI3072,22,2,ASH,8,1,8817.592177857054,0.7792,530.783,3.1367457872689894 -OpenAI3072,22,2,ASH,8,2,8458.498057300621,0.898,767.32,0.5326652275380236 -MPNet,25,4,ASH,1,1,10001.27046016388,0.86293,432.9807,3.5654096212332322 -MPNet,25,4,ASH,1,2,7791.30368847857,0.94033,598.6739,1.5362433499650072 -MPNet,25,4,ASH,8,1,74362.08331746703,0.86293,432.9807,1.8182089054404562 -MPNet,25,4,ASH,8,2,59343.83497734477,0.94033,598.6739,1.4923069624117167 -OpenAI3072,25,2,ASH,1,1,806.3072036656206,0.7676,532.42,5.822985480038383 -OpenAI3072,25,2,ASH,1,2,812.0022306103976,0.8917,770.301,0.2939501500646269 -OpenAI3072,25,2,ASH,8,1,7746.97320164018,0.7676,532.42,1.7332825152324094 -OpenAI3072,25,2,ASH,8,2,7387.744268677949,0.8917,770.301,1.1965951297520279 -MPNet,22,2,PQ,1,1,11929.667667640202,0.65128,417.1787,2.7650949170996433 -MPNet,22,2,PQ,1,2,9441.313656397691,0.82126,564.0539,0.8964322845663989 -MPNet,22,2,PQ,8,1,86214.6647201115,0.65128,417.1787,3.7263853453962215 -MPNet,22,2,PQ,8,2,68759.66645239317,0.82126,564.0539,1.3643932618207706 -MPNet,22,4,PQ,1,1,9642.851473210929,0.80733,439.2968,2.622788842647526 -MPNet,22,4,PQ,1,2,7455.724453990442,0.92753,604.3334,0.39769811018210266 -MPNet,22,4,PQ,8,1,74892.80322512507,0.80733,439.2968,5.192081755979211 -MPNet,22,4,PQ,8,2,57187.4193258142,0.92753,604.3334,3.516729681005034 -MPNet,25,2,PQ,1,1,11812.269458852594,0.65484,416.9763,1.7862019254375245 -MPNet,25,2,PQ,1,2,9294.266279454745,0.8221,563.8511,0.45328257510035846 -MPNet,25,2,PQ,8,1,76801.73087416003,0.65484,416.9763,1.187157927658352 -MPNet,25,2,PQ,8,2,71112.3291273773,0.8221,563.8511,0.9005253421370478 -MPNet,25,4,PQ,1,1,10001.00801293626,0.80751,438.5955,1.7967501215259827 -MPNet,25,4,PQ,1,2,7557.44798962551,0.92601,602.5851,0.193935742345163 -MPNet,25,4,PQ,8,1,74886.84115917748,0.80751,438.5955,2.803819611477874 -MPNet,25,4,PQ,8,2,56904.75254695084,0.92601,602.5851,2.2165909085661166 -OpenAI3072,22,2,PQ,1,1,3329.077370787888,0.6437,471.041,12.325595060464947 -OpenAI3072,22,2,PQ,1,2,2953.6549674392622,0.7985,653.509,0.7008072547277489 -OpenAI3072,22,2,PQ,8,1,27543.90063678985,0.6437,471.041,3.928911338675991 -OpenAI3072,22,2,PQ,8,2,22784.923597112218,0.7985,653.509,0.8717134512070802 -OpenAI3072,22,4,PQ,1,1,2143.912280952585,0.7691,500.603,7.951539104201971 -OpenAI3072,22,4,PQ,1,2,1871.3707850777212,0.877,704.466,0.8603045761797353 -OpenAI3072,22,4,PQ,8,1,18466.799971430668,0.7691,500.603,4.652696771917339 -OpenAI3072,22,4,PQ,8,2,14336.390737938298,0.877,704.466,0.8815646353224336 -OpenAI3072,25,2,PQ,1,1,2170.3203408432223,0.651,466.217,11.878205190359557 -OpenAI3072,25,2,PQ,1,2,2068.413509232902,0.8032,646.845,0.5559964617757023 -OpenAI3072,25,2,PQ,8,1,27772.466077225967,0.651,466.217,1.6263838740957284 -OpenAI3072,25,2,PQ,8,2,23213.773739290547,0.8032,646.845,1.0800416328665052 -OpenAI3072,25,4,PQ,1,1,2280.759101685725,0.7624,501.437,10.025887040180322 -OpenAI3072,25,4,PQ,1,2,1886.2499788294274,0.8727,705.403,0.5679737109169148 -OpenAI3072,25,4,PQ,8,1,15663.804691288786,0.7624,501.437,3.280105643236947 -OpenAI3072,25,4,PQ,8,2,12884.883579153513,0.8727,705.403,0.6822680726916507 diff --git a/docs/benchmarks/native-ash-complete-budgets-20261029/report.md b/docs/benchmarks/native-ash-complete-budgets-20261029/report.md deleted file mode 100644 index d0ee7eff3..000000000 --- a/docs/benchmarks/native-ash-complete-budgets-20261029/report.md +++ /dev/null @@ -1,50 +0,0 @@ -# Native ASH versus PQ: both MPNet and OpenAI3072 budgets - -Includes the previously omitted MPNet 4-bit and OpenAI3072 2-bit native-preparation reruns. ASH uses the native projection/symmetric implementation at 408df9b4 (code at 28e4c8c1), before the queued-batch POC. PQ remains the earlier full-study baseline; it was not recollected. All runs use BenchYAML. - -ASH: trained ITQ, C=1, projected dimension D/2. PQ uses exactly matched encoded byte budgets. Deferred parallel writes, fused graph vectors, NVQ reranking, M32, hierarchy and refinement retained. Queries use the same saved graphs as the original study; fresh construction uses separate newly trained models/graphs. - -Construction process time includes startup/loading and the standard validation query pass. It is not build-only. Each construction case has one fresh build; no build variance estimate is available. - -## Construction: PQ → ASH, seconds - -| Dataset | Bits | JDK | Training | Encoding | Graph/write | Process incl. validation | -|---|---:|---:|---:|---:|---:|---:| -| MPNet | 2 | 22 | 2.73 → 5.29 | 1.08 → 1.05 | 44.44 → 54.16 | 52.27 → 67.83 | -| MPNet | 2 | 25 | 2.77 → 5.33 | 1.05 → 0.95 | 33.77 → 34.23 | 41.55 → 46.53 | -| MPNet | 4 | 22 | 6.14 → 4.45 | 1.71 → 1.33 | 50.79 → 41.28 | 62.75 → 54.50 | -| MPNet | 4 | 25 | 4.65 → 4.78 | 1.75 → 1.05 | 48.55 → 36.52 | 58.95 → 48.43 | -| OpenAI3072 | 2 | 22 | 6.51 → 38.29 | 5.51 → 27.36 | 90.50 → 86.74 | 113.08 → 168.85 | -| OpenAI3072 | 2 | 25 | 5.90 → 41.54 | 5.58 → 26.93 | 113.01 → 85.79 | 135.15 → 171.22 | -| OpenAI3072 | 4 | 22 | 19.65 → 38.09 | 7.64 → 25.96 | 257.82 → 101.92 | 295.42 → 185.29 | -| OpenAI3072 | 4 | 25 | 16.62 → 36.40 | 7.78 → 25.75 | 247.53 → 106.24 | 281.56 → 186.01 | - -## Query throughput: PQ → ASH, QPS - -| Dataset | Bits | JDK | 1 core, 1× | 1 core, 2× | 8 cores, 1× | 8 cores, 2× | -|---|---:|---:|---:|---:|---:|---:| -| MPNet | 2 | 22 | 11,930 → 11,111 | 9,441 → 8,875 | 86,215 → 80,378 | 68,760 → 62,297 | -| MPNet | 2 | 25 | 11,812 → 12,118 | 9,294 → 9,286 | 76,802 → 83,550 | 71,112 → 69,338 | -| MPNet | 4 | 22 | 9,643 → 9,588 | 7,456 → 7,813 | 74,893 → 72,202 | 57,187 → 57,662 | -| MPNet | 4 | 25 | 10,001 → 10,001 | 7,557 → 7,791 | 74,887 → 74,362 | 56,905 → 59,344 | -| OpenAI3072 | 2 | 22 | 3,329 → 1,132 | 2,954 → 1,136 | 27,544 → 8,818 | 22,785 → 8,458 | -| OpenAI3072 | 2 | 25 | 2,170 → 806 | 2,068 → 812 | 27,772 → 7,747 | 23,214 → 7,388 | -| OpenAI3072 | 4 | 22 | 2,144 → 1,026 | 1,871 → 1,046 | 18,467 → 8,259 | 14,336 → 7,927 | -| OpenAI3072 | 4 | 25 | 2,281 → 1,012 | 1,886 → 1,054 | 15,664 → 8,559 | 12,885 → 7,983 | - -## Saved-graph recall@10: PQ → ASH, percent - -| Dataset | Bits | JDK | 1× | 2× | -|---|---:|---:|---:|---:| -| MPNet | 2 | 22 | 65.128 → 81.940 | 82.126 → 94.055 | -| MPNet | 2 | 25 | 65.484 → 82.017 | 82.210 → 93.971 | -| MPNet | 4 | 22 | 80.733 → 86.642 | 92.753 → 93.966 | -| MPNet | 4 | 25 | 80.751 → 86.293 | 92.601 → 94.033 | -| OpenAI3072 | 2 | 22 | 64.370 → 77.920 | 79.850 → 89.800 | -| OpenAI3072 | 2 | 25 | 65.100 → 76.760 | 80.320 → 89.170 | -| OpenAI3072 | 4 | 22 | 76.910 → 79.030 | 87.700 → 88.340 | -| OpenAI3072 | 4 | 25 | 76.240 → 79.650 | 87.270 → 88.750 | - -ASH retains its existing adaptive stopping policy and PQ its existing policy. Equal overquery is not an exact recall match or a quantizer-only comparison. Cohere and Ada are not recollected in this native-preparation subset. - -Data: [construction.csv](construction.csv), [queries.csv](queries.csv), [raw-ash-results.json](raw-ash-results.json). diff --git a/docs/benchmarks/native-ash-projection-symmetric-20261029/construction.csv b/docs/benchmarks/native-ash-projection-symmetric-20261029/construction.csv deleted file mode 100644 index 2bb3e003c..000000000 --- a/docs/benchmarks/native-ash-projection-symmetric-20261029/construction.csv +++ /dev/null @@ -1,5 +0,0 @@ -dataset,jdk,ash_bits,training_seconds,encoding_seconds,graph_write_seconds,process_seconds_including_validation,fresh_graph_recall_at_10 -mpnet-1m,22,2,5.292379244,1.047888893,54.164598291,67.83305824548006,0.81888 -mpnet-1m,25,2,5.328078086,0.952869518,34.225727643,46.53267274983227,0.81633 -openai-3072-1m,22,4,38.086685724,25.959572952,101.919147369,185.2930998262018,0.7896 -openai-3072-1m,25,4,36.395486952,25.752220845,106.236637281,186.0093028023839,0.787 diff --git a/docs/benchmarks/native-ash-projection-symmetric-20261029/design.md b/docs/benchmarks/native-ash-projection-symmetric-20261029/design.md deleted file mode 100644 index 7bcba1f07..000000000 --- a/docs/benchmarks/native-ash-projection-symmetric-20261029/design.md +++ /dev/null @@ -1,83 +0,0 @@ -# Native ASH projection and symmetric construction scoring - -ASH models prepare an immutable provider-specific projection when their Stiefel -transform is constructed or loaded. The default implementation keeps the -existing row-dot implementation, including Panama SIMD. The native provider -copies the row-major float matrix once into automatically managed shared native -memory. The extra matrix storage is included in `ramBytesUsed()` and is released -when its model is no longer reachable. It is not serialized; the model and graph -formats are unchanged. - -`ash_project_f32` computes the entire matrix-vector product in one Highway -dispatch. Four rows share query loads, with independent FMA chains and masked -tails. Every graph query still pays for its own projection. There is no batch of -unrelated queries, BLAS thread pool, per-row FFM call, or per-query matrix copy. -Small bounded products use a synchronous critical downcall with heap query and -output arrays. Larger products use an ordinary downcall with temporary native -query/output buffers so the JVM can reach safepoints during the dense multiply. -The matrix is shared read-only; temporary storage is invocation-local. - -`ash_symmetric_dot` evaluates encoded pairs used by graph construction: 1-bit -XOR/popcount; 2-bit weighted popcounts over packed sign/magnitude fields; 4-bit -unsigned-magnitude by signed-magnitude byte dot products. Integer accumulation -precedes a single factor of one quarter for 2/4-bit codes. Tail padding is ignored. -The Ice Lake vtable selects the same Highway source compiled with native -population-count and VNNI support, and Sapphire Rapids inherits that slot. -AVX-512 without those instructions, AVX2, scalar and ARM retain the portable -Highway implementations. The Java scorer still combines scales, centered -offsets, landmark terms and the existing similarity conversion. - -`ash_symmetric_lut_i16` handles byte-interleaved ranges in one call, retaining -the existing exact signed-short LUT. It uses 32-bit partial sums and bounded -chunks accumulated into 64-bit totals, rather than letting a short or integer -sum overflow. The graph builder's pair-oriented API uses the pair kernel; -callers of `ASHSymmetricScorer.blockScorerFor` use the native LUT kernel. Existing -C>1 formulas and the graph-neighborhood scorer are unchanged. The graph study -uses C=1 throughout. - -All three symbols are optional and looked up independently. Loading an older -compatible library preserves Java projection and symmetric implementations. -Java validates shapes, array bounds and output aliasing before passing pointers -to native code. Native kernels do not allocate, retain pointers or call Java. -Scorer diagnostics identify whether each operation is Highway or Java. - -## Reference implementation review - -Reviewed IVF ASH Fastscan at commit -[`56d78d0ff3aaaa61a02b94c6f49a97789706a717`](https://github.com/tlwillke/ivf-ash-fastscan/tree/56d78d0ff3aaaa61a02b94c6f49a97789706a717). -Its [`AshProjectionModel::transform_residual_batch`](https://github.com/tlwillke/ivf-ash-fastscan/blob/56d78d0ff3aaaa61a02b94c6f49a97789706a717/ash_transform.cpp#L727) -uses a contiguous input-major projection and BLAS matrix multiplication. Its -[`compute_lut_uint8`](https://github.com/tlwillke/ivf-ash-fastscan/blob/56d78d0ff3aaaa61a02b94c6f49a97789706a717/ash_query.cpp#L347) -path batches projections before building query-local LUTs. The -[`AVX-512 scan`](https://github.com/tlwillke/ivf-ash-fastscan/blob/56d78d0ff3aaaa61a02b94c6f49a97789706a717/ash_fastscan_avx512.cpp) -uses packed nibbles, byte shuffles and independent integer accumulators. - -The transferable principles here are preparing contiguous immutable weights, -amortizing native dispatch over a complete operation, reusing query loads, and -accumulating compact codes without materializing decoded vectors. This port -implements a single-query Highway matrix-vector operation because graph query -latency must include preparation for each query. The IVF implementation does -not provide a graph-construction symmetric scorer. Its quantized uint8 query -LUTs are not substituted for the graph's existing exact symmetric integer LUTs. -The kernels in this change are independent implementations of these operations. - -## Validation and measurement - -Targeted regression checks compare projection with a double-precision oracle, -packed pair dots with an independent decoder, and integer LUT ranges with -64-bit scalar accumulation. They cover dirty tail bits, irregular dimensions, -both FFM modes, concurrent projections, bounds/alias rejection and absent native -symbols. Both JDK 22 and JDK 25 are checked with normal dispatch and the existing -AVX3_DL, AVX3, AVX2 and SSE/scalar ISA caps. The generic kernel translation unit -also compiles for ARM NEON on Apple Silicon. SVE/SVE2 execution is not validated -on this host. - -Performance collection uses unchanged standard BenchYAML, existing inclusive -timers, normal query preparation, deferred writes and NVQ reranking. Before and -after query runs load the same saved model and graph, retain stock warmup and -samples, and use 1 or 8 scoring threads pinned to physical cores. Traced small -BenchYAML preflights confirm actual native calls; tracing is disabled for all -performance measurements. Separate fresh full-data builds verify construction. -The focused cases are MPNet at the 2-bit ASH budget and OpenAI3072 at the 4-bit -ASH budget on both JDKs. They are not a replacement for the earlier full PQ/ASH -matrix. Full results and measurement scope accompany the saved report. diff --git a/docs/benchmarks/native-ash-projection-symmetric-20261029/harness-identity.json b/docs/benchmarks/native-ash-projection-symmetric-20261029/harness-identity.json deleted file mode 100644 index eb0a19163..000000000 --- a/docs/benchmarks/native-ash-projection-symmetric-20261029/harness-identity.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "22": { - "byte_for_byte_identical": true, - "files": 222 - }, - "25": { - "byte_for_byte_identical": true, - "files": 222 - } -} diff --git a/docs/benchmarks/native-ash-projection-symmetric-20261029/implementation.patch b/docs/benchmarks/native-ash-projection-symmetric-20261029/implementation.patch deleted file mode 100644 index 318d90061..000000000 --- a/docs/benchmarks/native-ash-projection-symmetric-20261029/implementation.patch +++ /dev/null @@ -1,1157 +0,0 @@ -From 835bb81c002360fc885f704431d023abd372ef5e Mon Sep 17 00:00:00 2001 -From: Ted Willke -Date: Thu, 29 Oct 2026 06:16:25 +0000 -Subject: [PATCH 1/3] Project dense ASH queries with one Highway dispatch - ---- - .../jvector/quantization/ASHScorer.java | 14 +-- - .../jvector/quantization/ASHVectors.java | 15 +-- - .../quantization/AsymmetricHashing.java | 11 ++ - .../jvector/vector/VectorUtilSupport.java | 29 +++++ - .../jvector/vector/NativeASHProjection.java | 106 ++++++++++++++++++ - .../vector/NativeVectorUtilSupport.java | 9 +- - .../src/main/native/src/ash_projection-inl.h | 71 ++++++++++++ - .../native/src/jvector_simd_kernel_list.h | 6 + - .../main/native/src/jvector_simd_kernels.cpp | 1 + - 9 files changed, 237 insertions(+), 25 deletions(-) - create mode 100644 jvector-native/src/main/java/io/github/jbellis/jvector/vector/NativeASHProjection.java - create mode 100644 jvector-native/src/main/native/src/ash_projection-inl.h - -diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHScorer.java b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHScorer.java -index aa322fe3..edadbf83 100644 ---- a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHScorer.java -+++ b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHScorer.java -@@ -253,13 +253,6 @@ public final class ASHScorer { - final int D = ash.originalDimension; - final int C = ash.landmarkCount; - -- final float[][] A = ash.stiefelTransform.AFloat; // [d][D] -- -- final var vecUtil = -- io.github.jbellis.jvector.vector.VectorizationProvider -- .getInstance() -- .getVectorUtilSupport(); -- - // Materialize query once to avoid VectorFloat.get in inner loops. - final float[] qArr = new float[D]; - for (int k = 0; k < D; k++) { -@@ -269,12 +262,9 @@ public final class ASHScorer { - // Compute Aq once per query: - // qProj[j] = - final float[] qProj = new float[d]; -+ ash.stiefelTransform.project(qArr, qProj); - float sumQProj = 0f; -- for (int j = 0; j < d; j++) { -- float v = vecUtil.ashDotRow(A[j], qArr); -- qProj[j] = v; -- sumQProj += v; -- } -+ for (float value : qProj) sumQProj += value; - - // For each landmark c: - // tildeQ_c = Aq - Aμ_c (1-bit and generic multibit paths) -diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHVectors.java b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHVectors.java -index dd9b4f61..af33082b 100644 ---- a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHVectors.java -+++ b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHVectors.java -@@ -531,8 +531,7 @@ public class ASHVectors implements CompressedVectors { - float[] q = new float[ash.originalDimension]; - for (int i = 0; i < q.length; i++) q[i] = query.get(i); - float[] projected = new float[d]; -- VectorUtilSupport backend = VectorizationProvider.getInstance().getVectorUtilSupport(); -- for (int i = 0; i < d; i++) projected[i] = backend.ashDotRow(ash.stiefelTransform.AFloat[i], q); -+ ash.stiefelTransform.project(q, projected); - lut = new float[Math.multiplyExact(groups, 16)]; - FusedASHLayout.buildQueryLut(projected, d, ash.bitsPerDimension, lut); - dotQMu = new float[ash.landmarkCount]; -@@ -880,11 +879,6 @@ public class ASHVectors implements CompressedVectors { - final int D = ash.originalDimension; - final int C = ash.landmarkCount; - -- final float[][] A = ash.stiefelTransform.AFloat; -- -- final var vecUtil = -- VectorizationProvider.getInstance().getVectorUtilSupport(); -- - // q materialized once - final float[] qArr = new float[D]; - for (int i = 0; i < D; i++) { -@@ -893,12 +887,9 @@ public class ASHVectors implements CompressedVectors { - - // Aq - final float[] qProj = new float[d]; -+ ash.stiefelTransform.project(qArr, qProj); - float sumQProj = 0f; -- for (int j = 0; j < d; j++) { -- float v = vecUtil.ashDotRow(A[j], qArr); -- qProj[j] = v; -- sumQProj += v; -- } -+ for (float value : qProj) sumQProj += value; - - final float[] tildeQPool = new float[C * d]; - final float[] sumTildeQ = new float[C]; -diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/AsymmetricHashing.java b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/AsymmetricHashing.java -index 0023edd0..baa5b76e 100644 ---- a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/AsymmetricHashing.java -+++ b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/AsymmetricHashing.java -@@ -1285,6 +1285,14 @@ public class AsymmetricHashing implements VectorCompressor { -+ if (query.length != columns || out.length != matrix.length || query == out) -+ throw new IllegalArgumentException("ASH projection shape or aliasing mismatch"); -+ for (int row = 0; row < matrix.length; row++) out[row] = ashDotRow(matrix[row], query); -+ }; -+ } -+ - /** - * Dot product between a matrix row and a dense vector. - * Used by ASH projection: {@code y_i = }. -diff --git a/jvector-native/src/main/java/io/github/jbellis/jvector/vector/NativeASHProjection.java b/jvector-native/src/main/java/io/github/jbellis/jvector/vector/NativeASHProjection.java -new file mode 100644 -index 00000000..4503e82e ---- /dev/null -+++ b/jvector-native/src/main/java/io/github/jbellis/jvector/vector/NativeASHProjection.java -@@ -0,0 +1,106 @@ -+/* -+ * 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 java.lang.foreign.Arena; -+import java.lang.foreign.FunctionDescriptor; -+import java.lang.foreign.Linker; -+import java.lang.foreign.MemorySegment; -+import java.lang.foreign.SymbolLookup; -+import java.lang.invoke.MethodHandle; -+import java.util.Objects; -+ -+import static java.lang.foreign.ValueLayout.ADDRESS; -+import static java.lang.foreign.ValueLayout.JAVA_INT; -+ -+/** -+ * Immutable native row-major matrix for one complete ASH query projection per downcall. -+ * The automatically managed arena lives with the model; it is not thread confined. -+ * Query/output buffers are local to each invocation, so projections may run concurrently. -+ * Large projections use ordinary FFM and copy only the small query and result, allowing -+ * safepoints during the dense multiply. Matrix storage is never copied per query. -+ */ -+final class NativeASHProjection implements VectorUtilSupport.AshProjection { -+ private static final String SYMBOL = "ash_project_f32"; -+ private static final boolean TRACE = Boolean.getBoolean("jextract.trace.downcalls"); -+ // Conservative work bound for short, synchronous critical calls; not a timing claim. -+ private static final int MAX_CRITICAL_CELLS = 1 << 16; -+ private static final MemorySegment ADDRESS_OF_KERNEL = SymbolLookup.loaderLookup().find(SYMBOL).orElse(null); -+ private static final FunctionDescriptor DESCRIPTOR = -+ FunctionDescriptor.ofVoid(ADDRESS, ADDRESS, JAVA_INT, JAVA_INT, ADDRESS); -+ private static final MethodHandle CRITICAL = ADDRESS_OF_KERNEL == null ? null -+ : Linker.nativeLinker().downcallHandle(ADDRESS_OF_KERNEL, DESCRIPTOR, Linker.Option.critical(true)); -+ private static final MethodHandle ORDINARY = ADDRESS_OF_KERNEL == null ? null -+ : Linker.nativeLinker().downcallHandle(ADDRESS_OF_KERNEL, DESCRIPTOR); -+ -+ private final MemorySegment matrix; -+ private final int rows; -+ private final int columns; -+ -+ static boolean isAvailable() { return ADDRESS_OF_KERNEL != null; } -+ -+ NativeASHProjection(float[][] source) { -+ Objects.requireNonNull(source, "matrix"); -+ if (!isAvailable()) throw new UnsupportedOperationException("Native library does not provide " + SYMBOL); -+ rows = source.length; -+ if (rows == 0 || source[0].length == 0) -+ throw new IllegalArgumentException("ASH projection dimensions must be positive"); -+ columns = source[0].length; -+ for (float[] row : source) { -+ if (row.length != columns) throw new IllegalArgumentException("Ragged ASH projection"); -+ } -+ long rowBytes = (long) columns * Float.BYTES; -+ matrix = Arena.ofAuto().allocate(Math.multiplyExact((long) rows, rowBytes), 64); -+ for (int row = 0; row < rows; row++) -+ MemorySegment.copy(MemorySegment.ofArray(source[row]), 0, matrix, row * rowBytes, rowBytes); -+ } -+ -+ @Override -+ public long ramBytesUsed() { return matrix.byteSize(); } -+ -+ @Override -+ public void project(float[] query, float[] out) { -+ Objects.requireNonNull(query, "query"); -+ Objects.requireNonNull(out, "out"); -+ if (query.length != columns || out.length != rows || query == out) -+ throw new IllegalArgumentException("ASH projection shape or aliasing mismatch"); -+ if ((long) rows * columns <= MAX_CRITICAL_CELLS) { -+ invoke(CRITICAL, MemorySegment.ofArray(query), MemorySegment.ofArray(out)); -+ } else { -+ try (Arena arena = Arena.ofConfined()) { -+ long queryBytes = (long) columns * Float.BYTES; -+ MemorySegment buffers = arena.allocate(queryBytes + (long) rows * Float.BYTES, 64); -+ MemorySegment nativeQuery = buffers.asSlice(0, queryBytes); -+ MemorySegment nativeOut = buffers.asSlice(queryBytes); -+ MemorySegment.copy(MemorySegment.ofArray(query), 0, nativeQuery, 0, queryBytes); -+ invoke(ORDINARY, nativeQuery, nativeOut); -+ MemorySegment.copy(nativeOut, 0, MemorySegment.ofArray(out), 0, (long) rows * Float.BYTES); -+ } -+ } -+ } -+ -+ private void invoke(MethodHandle handle, MemorySegment query, MemorySegment out) { -+ if (TRACE) System.out.printf("%s(%d, %d)%n", SYMBOL, rows, columns); -+ try { -+ handle.invokeExact(matrix, query, rows, columns, out); -+ } catch (RuntimeException | Error error) { -+ throw error; -+ } catch (Throwable error) { -+ throw new AssertionError("Unexpected checked exception from " + SYMBOL, error); -+ } -+ } -+} -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 da754e64..d105d9b1 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 -@@ -91,6 +91,12 @@ class NativeVectorUtilSupport extends PanamaVectorUtilSupport - return NativeNVQArithmetic.sub(a, aOffset, b, bOffset, length); - } - -+ @Override -+ public AshProjection prepareAshProjection(float[][] matrix) { -+ return NativeASHProjection.isAvailable() -+ ? new NativeASHProjection(matrix) : super.prepareAshProjection(matrix); -+ } -+ - @Override - public boolean supportsAshNeighborhoodScoring() { - return NativeASHKernels.isAvailable(); -@@ -101,7 +107,8 @@ class NativeVectorUtilSupport extends PanamaVectorUtilSupport - String neighborhood = supportsAshNeighborhoodScoring() - ? "Highway native, ISA=" + getActiveIsa() - : "unavailable; using Java LUT"; -- return super.ashKernelDescription() + "; C=1 whole-neighborhood=" + neighborhood; -+ return super.ashKernelDescription() + "; C=1 whole-neighborhood=" + neighborhood -+ + "; dense projection=" + (NativeASHProjection.isAvailable() ? "Highway" : "Java"); - } - - @Override -diff --git a/jvector-native/src/main/native/src/ash_projection-inl.h b/jvector-native/src/main/native/src/ash_projection-inl.h -new file mode 100644 -index 00000000..51447f1f ---- /dev/null -+++ b/jvector-native/src/main/native/src/ash_projection-inl.h -@@ -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. -+ */ -+ -+#ifndef JVECTOR_ASH_PROJECTION_INL_H -+#define JVECTOR_ASH_PROJECTION_INL_H -+ -+// Included within JV_ISA. The immutable matrix is contiguous row-major and is -+// prepared once per model. A whole query crosses FFM once; no allocation, -+// callback, synchronization or pointer retention occurs in this leaf kernel. -+void ash_project_f32(const float *HWY_RESTRICT matrix, const float *HWY_RESTRICT query, -+ int rows, int cols, float *HWY_RESTRICT out) { -+ const hn::ScalableTag d; -+ const size_t lanes = hn::Lanes(d); -+ const size_t columns = static_cast(cols); -+ int row = 0; -+ // Four rows share each query load. Two chains per row hide FMA latency -+ // without changing the stored matrix or batching independent graph queries. -+ for (; rows - row >= 4; row += 4) { -+ const float *a = matrix + static_cast(row) * columns; -+ auto s0 = hn::Zero(d), s1 = hn::Zero(d), s2 = hn::Zero(d), s3 = hn::Zero(d); -+ auto t0 = hn::Zero(d), t1 = hn::Zero(d), t2 = hn::Zero(d), t3 = hn::Zero(d); -+ size_t col = 0; -+ for (; col + 2 * lanes <= columns; col += 2 * lanes) { -+ const auto q0 = hn::LoadU(d, query + col); -+ const auto q1 = hn::LoadU(d, query + col + lanes); -+ s0 = hn::MulAdd(hn::LoadU(d, a + col), q0, s0); -+ s1 = hn::MulAdd(hn::LoadU(d, a + columns + col), q0, s1); -+ s2 = hn::MulAdd(hn::LoadU(d, a + 2 * columns + col), q0, s2); -+ s3 = hn::MulAdd(hn::LoadU(d, a + 3 * columns + col), q0, s3); -+ t0 = hn::MulAdd(hn::LoadU(d, a + col + lanes), q1, t0); -+ t1 = hn::MulAdd(hn::LoadU(d, a + columns + col + lanes), q1, t1); -+ t2 = hn::MulAdd(hn::LoadU(d, a + 2 * columns + col + lanes), q1, t2); -+ t3 = hn::MulAdd(hn::LoadU(d, a + 3 * columns + col + lanes), q1, t3); -+ } -+ s0 = hn::Add(s0, t0); s1 = hn::Add(s1, t1); -+ s2 = hn::Add(s2, t2); s3 = hn::Add(s3, t3); -+ for (; col < columns; col += lanes) { -+ const size_t n = std::min(lanes, columns - col); -+ const auto q = hn::LoadN(d, query + col, n); -+ s0 = hn::MulAdd(hn::LoadN(d, a + col, n), q, s0); -+ s1 = hn::MulAdd(hn::LoadN(d, a + columns + col, n), q, s1); -+ s2 = hn::MulAdd(hn::LoadN(d, a + 2 * columns + col, n), q, s2); -+ s3 = hn::MulAdd(hn::LoadN(d, a + 3 * columns + col, n), q, s3); -+ } -+ out[row] = hn::ReduceSum(d, s0); out[row + 1] = hn::ReduceSum(d, s1); -+ out[row + 2] = hn::ReduceSum(d, s2); out[row + 3] = hn::ReduceSum(d, s3); -+ } -+ for (; row < rows; ++row) { -+ const float *a = matrix + static_cast(row) * columns; -+ auto sum = hn::Zero(d); -+ for (size_t col = 0; col < columns; col += lanes) { -+ const size_t n = std::min(lanes, columns - col); -+ sum = hn::MulAdd(hn::LoadN(d, a + col, n), hn::LoadN(d, query + col, n), sum); -+ } -+ out[row] = hn::ReduceSum(d, sum); -+ } -+} -+#endif -diff --git a/jvector-native/src/main/native/src/jvector_simd_kernel_list.h b/jvector-native/src/main/native/src/jvector_simd_kernel_list.h -index 31babe84..e8cb3455 100644 ---- a/jvector-native/src/main/native/src/jvector_simd_kernel_list.h -+++ b/jvector-native/src/main/native/src/jvector_simd_kernel_list.h -@@ -39,6 +39,10 @@ - // Output may be partially written on error. No allocation, pointer retention, - // callbacks, or blocking are permitted: ordinary graph calls use critical FFM. - -+// ASH projection ABI: positive rows/cols, borrowed contiguous row-major matrix, -+// query[cols], out[rows]; output must not alias either read-only input. -+// Callers validate buffer sizes; the kernel is a synchronous allocation-free leaf. -+ - // clang-format off - #define JVECTOR_SIMD_KERNEL_LIST \ - /* Vector similarity */ \ -@@ -68,6 +72,8 @@ - KERNEL_ENTRY(void, calculate_partial_sums_dot_f32, (const float *codebook, int codebookIndex, size_t size, int clusterCount, const float *query, int queryOffset, float *partialSums), (codebook, codebookIndex, size, clusterCount, query, queryOffset, partialSums)) \ - KERNEL_ENTRY(void, calculate_partial_sums_euclidean_f32, (const float *codebook, int codebookIndex, size_t size, int clusterCount, const float *query, int queryOffset, float *partialSums), (codebook, codebookIndex, size, clusterCount, query, queryOffset, partialSums)) \ - KERNEL_ENTRY(void, calculate_partial_sums_self_magnitude_f32, (const float *codebook, int codebookIndex, size_t size, int clusterCount, float *partialSums), (codebook, codebookIndex, size, clusterCount, partialSums)) \ -+ /* ASH preparation and exact packed construction scoring; synchronous leaf calls. */ \ -+ KERNEL_ENTRY(void, ash_project_f32, (const float *matrix, const float *query, int rows, int cols, float *out), (matrix, query, rows, cols, out)) \ - /* Whole-neighborhood C=1 ASH, one downcall including headers and similarity. */ \ - KERNEL_ENTRY(int, ash_score_neighborhood_c1_f32, (const unsigned char *codes, const float *lut, float landmarkDot, int groups, int blockSize, int capacity, float *out), (codes, lut, landmarkDot, groups, blockSize, capacity, out)) \ - /* NVQ kernels */ \ -diff --git a/jvector-native/src/main/native/src/jvector_simd_kernels.cpp b/jvector-native/src/main/native/src/jvector_simd_kernels.cpp -index 1ba0908a..1f52c670 100644 ---- a/jvector-native/src/main/native/src/jvector_simd_kernels.cpp -+++ b/jvector-native/src/main/native/src/jvector_simd_kernels.cpp -@@ -383,6 +383,7 @@ HWY_INLINE float L2SquareDistance(const float *a, - namespace JV_ISA { - - #include "ash_neighborhood-inl.h" -+#include "ash_projection-inl.h" - - HWY_FLATTEN float cosine_f32( - const float *a, size_t aoffset, const float *b, size_t boffset, size_t length) --- -2.39.5 - - -From d76c1fe80a90330dfa3845345585a5754a9e0739 Mon Sep 17 00:00:00 2001 -From: Ted Willke -Date: Thu, 29 Oct 2026 06:16:25 +0000 -Subject: [PATCH 2/3] Score symmetric ASH construction codes with Highway - ---- - .../vector/NativeASHSymmetricKernels.java | 135 +++++++++++++++ - .../vector/NativeVectorUtilSupport.java | 20 ++- - .../src/main/native/src/ash_symmetric-inl.h | 161 ++++++++++++++++++ - .../native/src/jvector_avx3_dl_kernels.cpp | 6 + - .../src/main/native/src/jvector_simd.cpp | 3 +- - .../native/src/jvector_simd_kernel_list.h | 12 +- - .../main/native/src/jvector_simd_kernels.cpp | 1 + - 7 files changed, 333 insertions(+), 5 deletions(-) - create mode 100644 jvector-native/src/main/java/io/github/jbellis/jvector/vector/NativeASHSymmetricKernels.java - create mode 100644 jvector-native/src/main/native/src/ash_symmetric-inl.h - -diff --git a/jvector-native/src/main/java/io/github/jbellis/jvector/vector/NativeASHSymmetricKernels.java b/jvector-native/src/main/java/io/github/jbellis/jvector/vector/NativeASHSymmetricKernels.java -new file mode 100644 -index 00000000..5f10f759 ---- /dev/null -+++ b/jvector-native/src/main/java/io/github/jbellis/jvector/vector/NativeASHSymmetricKernels.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 java.lang.foreign.Arena; -+import java.lang.foreign.FunctionDescriptor; -+import java.lang.foreign.Linker; -+import java.lang.foreign.MemorySegment; -+import java.lang.foreign.SymbolLookup; -+import java.lang.invoke.MethodHandle; -+import java.util.Objects; -+ -+import static java.lang.foreign.ValueLayout.ADDRESS; -+import static java.lang.foreign.ValueLayout.JAVA_FLOAT; -+import static java.lang.foreign.ValueLayout.JAVA_INT; -+ -+/** -+ * Checked optional Highway bindings for exact symmetric ASH integer arithmetic. -+ * Graph pair comparisons use one bounded critical call. Complete interleaved -+ * ranges use one LUT call; larger legal inputs use ordinary FFM with temporary -+ * native buffers. Neither kernel allocates, retains pointers, blocks or calls Java. -+ * Missing symbols preserve the provider's existing Java implementations. -+ */ -+final class NativeASHSymmetricKernels { -+ private static final int MAX_CRITICAL_WORK = 1 << 16; -+ private static final boolean TRACE = Boolean.getBoolean("jextract.trace.downcalls"); -+ private static final Binding PAIR = new Binding("ash_symmetric_dot", -+ FunctionDescriptor.of(JAVA_FLOAT, ADDRESS, ADDRESS, JAVA_INT, JAVA_INT)); -+ private static final Binding LUT = new Binding("ash_symmetric_lut_i16", -+ FunctionDescriptor.ofVoid(ADDRESS, ADDRESS, JAVA_INT, JAVA_INT, JAVA_INT, JAVA_INT, ADDRESS)); -+ -+ private NativeASHSymmetricKernels() {} -+ -+ static boolean hasPairKernel() { return PAIR.available(); } -+ static boolean hasLutKernel() { return LUT.available(); } -+ -+ static float dot(long[] aBits, byte[] aCodes, long[] bBits, byte[] bCodes, int dimensions, int bits) { -+ if (dimensions < 0 || (bits != 1 && bits != 2 && bits != 4)) -+ throw new IllegalArgumentException("Symmetric ASH requires nonnegative dimensions and bits in {1,2,4}"); -+ if (!PAIR.available()) throw new UnsupportedOperationException("Native symmetric pair kernel unavailable"); -+ MemorySegment a, b; -+ long bytes; -+ if (bits == 1) { -+ long words = (dimensions + 63L) / 64; -+ if (words > aBits.length || words > bBits.length) throw new IndexOutOfBoundsException("ASH bit arrays"); -+ a = MemorySegment.ofArray(aBits); b = MemorySegment.ofArray(bBits); bytes = words * Long.BYTES; -+ } else { -+ bytes = ((long) dimensions * bits + 7) / 8; -+ if (bytes > aCodes.length || bytes > bCodes.length) throw new IndexOutOfBoundsException("ASH code arrays"); -+ a = MemorySegment.ofArray(aCodes); b = MemorySegment.ofArray(bCodes); -+ } -+ if (dimensions <= MAX_CRITICAL_WORK) return invokePair(PAIR.critical, a, b, dimensions, bits); -+ try (Arena arena = Arena.ofConfined()) { -+ MemorySegment na = arena.allocate(bytes, Long.BYTES), nb = arena.allocate(bytes, Long.BYTES); -+ MemorySegment.copy(a, 0, na, 0, bytes); MemorySegment.copy(b, 0, nb, 0, bytes); -+ return invokePair(PAIR.ordinary, na, nb, dimensions, bits); -+ } -+ } -+ -+ static void scoreLut(byte[] codes, int groups, int stride, int lane, int count, -+ short[] lut, float[] out, int outOffset) { -+ Objects.requireNonNull(codes, "codes"); Objects.requireNonNull(lut, "lut"); Objects.requireNonNull(out, "out"); -+ if (!LUT.available()) throw new UnsupportedOperationException("Native symmetric LUT kernel unavailable"); -+ if (groups < 0 || stride <= 0) throw new IllegalArgumentException("ASH groups/stride"); -+ Objects.checkFromIndexSize(lane, count, stride); -+ Objects.checkFromIndexSize(outOffset, count, out.length); -+ long codeBytes = ((groups + 1L) / 2) * stride, lutBytes = (long) groups * 32 * Short.BYTES; -+ if (codeBytes > codes.length || lutBytes / Short.BYTES > lut.length) -+ throw new IndexOutOfBoundsException("ASH LUT/code arrays"); -+ if (count == 0) return; -+ MemorySegment source = MemorySegment.ofArray(codes), table = MemorySegment.ofArray(lut); -+ MemorySegment result = MemorySegment.ofArray(out).asSlice((long) outOffset * Float.BYTES); -+ // Charge padded SIMD lanes as well as active lanes (native width is capped at 16). -+ long vectorSlots = ((count + 15L) / 16) * 16; -+ if ((long) groups * vectorSlots <= MAX_CRITICAL_WORK) { -+ invokeLut(LUT.critical, source, table, groups, stride, lane, count, result); -+ } else { -+ try (Arena arena = Arena.ofConfined()) { -+ MemorySegment nc = arena.allocate(Math.max(1, codeBytes)); -+ MemorySegment nl = arena.allocate(Math.max(Short.BYTES, lutBytes), Short.BYTES); -+ MemorySegment no = arena.allocate((long) count * Float.BYTES, Float.BYTES); -+ MemorySegment.copy(source, 0, nc, 0, codeBytes); MemorySegment.copy(table, 0, nl, 0, lutBytes); -+ invokeLut(LUT.ordinary, nc, nl, groups, stride, lane, count, no); -+ MemorySegment.copy(no, 0, result, 0, (long) count * Float.BYTES); -+ } -+ } -+ } -+ -+ private static float invokePair(MethodHandle handle, MemorySegment a, MemorySegment b, int dimensions, int bits) { -+ if (TRACE) System.out.printf("%s(%d, %d)%n", PAIR.symbol, dimensions, bits); -+ try { -+ return (float) handle.invokeExact(a, b, dimensions, bits); -+ } catch (RuntimeException | Error error) { throw error; -+ } catch (Throwable error) { throw new AssertionError("Unexpected exception from " + PAIR.symbol, error); } -+ } -+ -+ private static void invokeLut(MethodHandle handle, MemorySegment codes, MemorySegment lut, -+ int groups, int stride, int lane, int count, MemorySegment out) { -+ if (TRACE) System.out.printf("%s(%d, %d)%n", LUT.symbol, groups, count); -+ try { -+ handle.invokeExact(codes, lut, groups, stride, lane, count, out); -+ } catch (RuntimeException | Error error) { throw error; -+ } catch (Throwable error) { throw new AssertionError("Unexpected exception from " + LUT.symbol, error); } -+ } -+ -+ private static final class Binding { -+ final String symbol; -+ final MethodHandle critical; -+ final MethodHandle ordinary; -+ -+ Binding(String symbol, FunctionDescriptor descriptor) { -+ this.symbol = symbol; -+ MemorySegment address = SymbolLookup.loaderLookup().find(symbol).orElse(null); -+ critical = address == null ? null : Linker.nativeLinker().downcallHandle( -+ address, descriptor, Linker.Option.critical(true)); -+ ordinary = address == null ? null : Linker.nativeLinker().downcallHandle(address, descriptor); -+ } -+ -+ boolean available() { return critical != null; } -+ } -+} -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 d105d9b1..b02b063e 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 -@@ -97,6 +97,22 @@ class NativeVectorUtilSupport extends PanamaVectorUtilSupport - ? new NativeASHProjection(matrix) : super.prepareAshProjection(matrix); - } - -+ @Override -+ public float ashSymmetricDot(long[] aBits, byte[] aCodes, long[] bBits, byte[] bCodes, -+ int dimensions, int bits) { -+ return NativeASHSymmetricKernels.hasPairKernel() -+ ? NativeASHSymmetricKernels.dot(aBits, aCodes, bBits, bCodes, dimensions, bits) -+ : super.ashSymmetricDot(aBits, aCodes, bBits, bCodes, dimensions, bits); -+ } -+ -+ @Override -+ public void ashSymmetricLutScore(byte[] codes, int groups, int stride, int lane, int count, -+ short[] lut, float[] out, int outOffset) { -+ if (NativeASHSymmetricKernels.hasLutKernel()) -+ NativeASHSymmetricKernels.scoreLut(codes, groups, stride, lane, count, lut, out, outOffset); -+ else super.ashSymmetricLutScore(codes, groups, stride, lane, count, lut, out, outOffset); -+ } -+ - @Override - public boolean supportsAshNeighborhoodScoring() { - return NativeASHKernels.isAvailable(); -@@ -108,7 +124,9 @@ class NativeVectorUtilSupport extends PanamaVectorUtilSupport - ? "Highway native, ISA=" + getActiveIsa() - : "unavailable; using Java LUT"; - return super.ashKernelDescription() + "; C=1 whole-neighborhood=" + neighborhood -- + "; dense projection=" + (NativeASHProjection.isAvailable() ? "Highway" : "Java"); -+ + "; dense projection=" + (NativeASHProjection.isAvailable() ? "Highway" : "Java") -+ + "; symmetric pair=" + (NativeASHSymmetricKernels.hasPairKernel() ? "Highway" : "Java") -+ + "; symmetric LUT=" + (NativeASHSymmetricKernels.hasLutKernel() ? "Highway" : "Java"); - } - - @Override -diff --git a/jvector-native/src/main/native/src/ash_symmetric-inl.h b/jvector-native/src/main/native/src/ash_symmetric-inl.h -new file mode 100644 -index 00000000..c43f0fea ---- /dev/null -+++ b/jvector-native/src/main/native/src/ash_symmetric-inl.h -@@ -0,0 +1,161 @@ -+/* -+ * 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. -+ */ -+ -+#ifndef JVECTOR_ASH_SYMMETRIC_INL_H -+#define JVECTOR_ASH_SYMMETRIC_INL_H -+ -+// Included within JV_ISA. All sizes/pointers are checked by the Java binding. -+// Products of doubled signed-magnitude components are exact integers; divide -+// by four only once, after accumulation. Padding bits never contribute. -+namespace { -+HWY_INLINE int AshDoubledComponent(const uint8_t *code, int j, int bits) { -+ const int perByte = 8 / bits; -+ const int field = code[j / perByte] >> ((j % perByte) * bits); -+ const int sign = 1 << (bits - 1); -+ const int magnitude = 2 * (field & (sign - 1)) + 1; -+ return (field & sign) ? magnitude : -magnitude; -+} -+ -+HWY_INLINE float AshBinaryPair(const uint64_t *a, const uint64_t *b, int dimensions) { -+ const hn::ScalableTag d; -+ const size_t lanes = hn::Lanes(d), words = static_cast(dimensions) / 64; -+ auto counts = hn::Zero(d); -+ // Integer lanes can hold all mismatches even at the maximum Java dimension. -+ for (size_t word = 0; word < words; word += lanes) { -+ const size_t n = std::min(lanes, words - word); -+ const auto diff = hn::Xor(hn::LoadN(d, a + word, n), hn::LoadN(d, b + word, n)); -+ counts = hn::Add(counts, hn::PopulationCount(diff)); -+ } -+ uint64_t mismatches = hn::ReduceSum(d, counts); -+ const int tail = dimensions & 63; -+ if (tail) mismatches += hwy::PopCount((a[words] ^ b[words]) & ((uint64_t{1} << tail) - 1)); -+ return static_cast(dimensions) - 2.0f * static_cast(mismatches); -+} -+ -+HWY_INLINE float AshTwoBitPair(const uint8_t *a, const uint8_t *b, int dimensions) { -+#if HWY_TARGET == HWY_SCALAR -+ int64_t total = 0; -+ for (int j = 0; j < dimensions; ++j) -+ total += AshDoubledComponent(a, j, 2) * AshDoubledComponent(b, j, 2); -+ return static_cast(total) * 0.25f; -+#else -+ const hn::ScalableTag d; -+ const auto slots = hn::Set(d, uint64_t{0x5555555555555555}); -+ const size_t lanes = hn::Lanes(d), words = static_cast(dimensions) / 32; -+ const hn::RebindToSigned di; -+ auto deltas = hn::Zero(di); -+ for (size_t word = 0; word < words; word += lanes) { -+ const size_t n = std::min(lanes, words - word); -+ // Canonical codes are little-endian packed fields. LoadU/LoadN permit -+ // byte alignment on all supported little-endian production targets. -+ const auto av = hn::LoadN(d, reinterpret_cast(a) + word, n); -+ const auto bv = hn::LoadN(d, reinterpret_cast(b) + word, n); -+ const auto neg = hn::And(hn::ShiftRight<1>(hn::Xor(av, bv)), slots); -+ const auto ma = hn::And(av, slots), mb = hn::And(bv, slots), both = hn::And(ma, mb); -+ // sign*(1+2*ma)*(1+2*mb), evaluated with weighted popcounts. -+ // Keep partial sums in registers; one horizontal reduction per pair. -+ auto delta = hn::ShiftLeft<1>(hn::Sub( -+ hn::Add(hn::PopulationCount(ma), hn::PopulationCount(mb)), hn::PopulationCount(neg))); -+ delta = hn::Sub(delta, hn::ShiftLeft<2>(hn::Add( -+ hn::PopulationCount(hn::And(ma, neg)), hn::PopulationCount(hn::And(mb, neg))))); -+ delta = hn::Add(delta, hn::ShiftLeft<2>(hn::PopulationCount(both))); -+ delta = hn::Sub(delta, hn::ShiftLeft<3>(hn::PopulationCount(hn::And(both, neg)))); -+ deltas = hn::Add(deltas, hn::BitCast(di, delta)); -+ } -+ int64_t total = static_cast(words) * 32 + hn::ReduceSum(di, deltas); -+ for (int j = static_cast(words * 32); j < dimensions; ++j) -+ total += AshDoubledComponent(a, j, 2) * AshDoubledComponent(b, j, 2); -+ return static_cast(total) * 0.25f; -+#endif -+} -+ -+HWY_INLINE float AshFourBitPair(const uint8_t *a, const uint8_t *b, int dimensions) { -+ int64_t total = 0; -+ int j = 0; -+#if HWY_TARGET != HWY_SCALAR -+ const hn::ScalableTag di; -+ const hn::Repartition db; -+ const hn::RebindToSigned ds; -+ const size_t bytes = static_cast(dimensions) / 2, lanes = hn::Lanes(db); -+ size_t offset = 0; -+ for (; offset + lanes <= bytes; offset += lanes) { -+ const auto av = hn::LoadU(db, a + offset), bv = hn::LoadU(db, b + offset); -+ auto sum = hn::Zero(di); -+ for (int shift = 0; shift < 8; shift += 4) { -+ const auto ax = hn::ShiftRightSame(av, shift), bx = hn::ShiftRightSame(bv, shift); -+ const auto am = hn::Add(hn::ShiftLeft<1>(hn::And(ax, hn::Set(db, 7))), hn::Set(db, 1)); -+ const auto bm = hn::BitCast(ds, hn::Add(hn::ShiftLeft<1>(hn::And(bx, hn::Set(db, 7))), hn::Set(db, 1))); -+ const auto sameSign = hn::Eq(hn::And(hn::Xor(ax, bx), hn::Set(db, 8)), hn::Zero(db)); -+ const auto signedB = hn::IfThenElse(hn::RebindMask(ds, sameSign), bm, hn::Neg(bm)); -+ // VNNI when supported; Highway supplies widening multiply otherwise. -+ sum = hn::SumOfMulQuadAccumulate(di, am, signedB, sum); -+ } -+ total += hn::ReduceSum(di, sum); -+ } -+ j = static_cast(2 * offset); -+#endif -+ for (; j < dimensions; ++j) total += AshDoubledComponent(a, j, 4) * AshDoubledComponent(b, j, 4); -+ return static_cast(total) * 0.25f; -+} -+} // namespace -+ -+float ash_symmetric_dot(const void *a, const void *b, int dimensions, int bits) { -+ if (bits == 1) return AshBinaryPair(static_cast(a), static_cast(b), dimensions); -+ if (bits == 2) return AshTwoBitPair(static_cast(a), static_cast(b), dimensions); -+ return AshFourBitPair(static_cast(a), static_cast(b), dimensions); -+} -+ -+#ifndef JVECTOR_ASH_PAIR_ONLY -+// Exact signed-short LUTs, unlike the IVF scan's approximate uint8 LUTs. Widen -+// before accumulation. At most 8192 groups enter an int32 subtotal, then spill -+// into int64 lane totals so arbitrary short entries and long codes cannot wrap. -+void ash_symmetric_lut_i16(const uint8_t *HWY_RESTRICT codes, const int16_t *HWY_RESTRICT lut, -+ int groups, int stride, int lane, int count, float *HWY_RESTRICT out) { -+ const hn::CappedTag d; -+ const hn::Rebind db; -+ const hn::Rebind ds; -+ const int lanes = static_cast(hn::Lanes(d)); -+ for (size_t base = 0; base < static_cast(count); base += lanes) { -+ const int active = static_cast(std::min(static_cast(lanes), static_cast(count) - base)); -+ int64_t totals[16] = {}; -+ for (int first = 0; first < groups;) { -+ const int end = first + std::min(8192, groups - first); -+ auto sum = hn::Zero(d); -+ for (int g = first; g < end; ++g) { -+ auto packed = hn::PromoteTo(d, hn::LoadN(db, codes + static_cast(g / 2) * stride + lane + base, active)); -+ const auto indices = hn::And(hn::ShiftRightSame(packed, 4 * (g & 1)), hn::Set(d, 15)); -+#if !HWY_HAVE_SCALABLE -+ if constexpr (hn::MaxLanes(d) == 16) { -+ const auto table = hn::PromoteTo(d, hn::LoadU(ds, lut + static_cast(g) * 32)); -+ sum = hn::Add(sum, hn::TableLookupLanes(table, hn::IndicesFromVec(d, indices))); -+ } else -+#endif -+ { -+ int32_t table[16]; -+ for (int i = 0; i < 16; ++i) table[i] = lut[static_cast(g) * 32 + i]; -+ sum = hn::Add(sum, hn::GatherIndex(d, table, indices)); -+ } -+ } -+ int32_t partial[16]; -+ hn::StoreU(sum, d, partial); -+ for (int i = 0; i < active; ++i) totals[i] += partial[i]; -+ first = end; -+ } -+ for (int i = 0; i < active; ++i) out[base + i] = static_cast(totals[i]) * 0.25f; -+ } -+} -+#endif -+#endif -diff --git a/jvector-native/src/main/native/src/jvector_avx3_dl_kernels.cpp b/jvector-native/src/main/native/src/jvector_avx3_dl_kernels.cpp -index ae8ab73b..95f61317 100644 ---- a/jvector-native/src/main/native/src/jvector_avx3_dl_kernels.cpp -+++ b/jvector-native/src/main/native/src/jvector_avx3_dl_kernels.cpp -@@ -32,6 +32,12 @@ - - namespace hn = hwy::HWY_NAMESPACE; - -+#include -+#include -+ - namespace AVX3_DL { -+#define JVECTOR_ASH_PAIR_ONLY -+#include "ash_symmetric-inl.h" -+#undef JVECTOR_ASH_PAIR_ONLY - - } // namespace AVX3_DL -diff --git a/jvector-native/src/main/native/src/jvector_simd.cpp b/jvector-native/src/main/native/src/jvector_simd.cpp -index ec69d5cf..34bc2ecc 100644 ---- a/jvector-native/src/main/native/src/jvector_simd.cpp -+++ b/jvector-native/src/main/native/src/jvector_simd.cpp -@@ -107,11 +107,12 @@ static const KernelVTable AVX3_vtable = { - }; - #undef KERNEL_ENTRY - --// AVX3_DL (Ice Lake) inherits all slots from AVX3 unchanged for now. -+// AVX3_DL inherits AVX3 and uses native popcount/byte-dot for ASH pairs. - // To override a slot: t.kernel_name = AVX3_DL::kernel_name; - // The implementation must exist in jvector_avx3_dl_kernels.cpp. - static const KernelVTable AVX3_DL_vtable = []() { - KernelVTable t = AVX3_vtable; -+ t.ash_symmetric_dot = AVX3_DL::ash_symmetric_dot; - return t; - }(); - -diff --git a/jvector-native/src/main/native/src/jvector_simd_kernel_list.h b/jvector-native/src/main/native/src/jvector_simd_kernel_list.h -index e8cb3455..cf0e23ed 100644 ---- a/jvector-native/src/main/native/src/jvector_simd_kernel_list.h -+++ b/jvector-native/src/main/native/src/jvector_simd_kernel_list.h -@@ -39,9 +39,13 @@ - // Output may be partially written on error. No allocation, pointer retention, - // callbacks, or blocking are permitted: ordinary graph calls use critical FFM. - --// ASH projection ABI: positive rows/cols, borrowed contiguous row-major matrix, --// query[cols], out[rows]; output must not alias either read-only input. --// Callers validate buffer sizes; the kernel is a synchronous allocation-free leaf. -+// ASH preparation/symmetric ABI contracts (all pointers are borrowed synchronously): -+// project: positive rows/cols, row-major matrix[rows*cols], query[cols], out[rows]. -+// pair: nonnegative dimensions, bits in {1,2,4}; binary uint64 words for bits=1, -+// canonical little-endian packed bytes otherwise. Padding bits are ignored. -+// LUT: nonnegative groups/count, positive stride, 0 <= lane <= lane+count <= stride; -+// codes[ceil(groups/2)*stride], lut[groups*32] signed shorts, out[count]. -+// Writable outputs must not alias read-only inputs. Callers validate buffer sizes. - - // clang-format off - #define JVECTOR_SIMD_KERNEL_LIST \ -@@ -74,6 +78,8 @@ - KERNEL_ENTRY(void, calculate_partial_sums_self_magnitude_f32, (const float *codebook, int codebookIndex, size_t size, int clusterCount, float *partialSums), (codebook, codebookIndex, size, clusterCount, partialSums)) \ - /* ASH preparation and exact packed construction scoring; synchronous leaf calls. */ \ - KERNEL_ENTRY(void, ash_project_f32, (const float *matrix, const float *query, int rows, int cols, float *out), (matrix, query, rows, cols, out)) \ -+ KERNEL_ENTRY(float, ash_symmetric_dot, (const void *a, const void *b, int dimensions, int bits), (a, b, dimensions, bits)) \ -+ KERNEL_ENTRY(void, ash_symmetric_lut_i16, (const unsigned char *codes, const int16_t *lut, int groups, int stride, int lane, int count, float *out), (codes, lut, groups, stride, lane, count, out)) \ - /* Whole-neighborhood C=1 ASH, one downcall including headers and similarity. */ \ - KERNEL_ENTRY(int, ash_score_neighborhood_c1_f32, (const unsigned char *codes, const float *lut, float landmarkDot, int groups, int blockSize, int capacity, float *out), (codes, lut, landmarkDot, groups, blockSize, capacity, out)) \ - /* NVQ kernels */ \ -diff --git a/jvector-native/src/main/native/src/jvector_simd_kernels.cpp b/jvector-native/src/main/native/src/jvector_simd_kernels.cpp -index 1f52c670..d2a38e2a 100644 ---- a/jvector-native/src/main/native/src/jvector_simd_kernels.cpp -+++ b/jvector-native/src/main/native/src/jvector_simd_kernels.cpp -@@ -384,6 +384,7 @@ namespace JV_ISA { - - #include "ash_neighborhood-inl.h" - #include "ash_projection-inl.h" -+#include "ash_symmetric-inl.h" - - HWY_FLATTEN float cosine_f32( - const float *a, size_t aoffset, const float *b, size_t boffset, size_t length) --- -2.39.5 - - -From 28e4c8c1c8f49db472a9d0e530c01d5dcbceb403 Mon Sep 17 00:00:00 2001 -From: Ted Willke -Date: Thu, 29 Oct 2026 06:17:17 +0000 -Subject: [PATCH 3/3] Cover native ASH preparation, exact scoring and - compatibility - ---- - docs/native-ash-preparation.md | 83 +++++++++++ - .../vector/NativeASHCompatibilityTest.java | 17 +++ - .../vector/NativeASHPreparationTest.java | 141 ++++++++++++++++++ - 3 files changed, 241 insertions(+) - create mode 100644 docs/native-ash-preparation.md - create mode 100644 jvector-native/src/test/java/io/github/jbellis/jvector/vector/NativeASHPreparationTest.java - -diff --git a/docs/native-ash-preparation.md b/docs/native-ash-preparation.md -new file mode 100644 -index 00000000..7bcba1f0 ---- /dev/null -+++ b/docs/native-ash-preparation.md -@@ -0,0 +1,83 @@ -+# Native ASH projection and symmetric construction scoring -+ -+ASH models prepare an immutable provider-specific projection when their Stiefel -+transform is constructed or loaded. The default implementation keeps the -+existing row-dot implementation, including Panama SIMD. The native provider -+copies the row-major float matrix once into automatically managed shared native -+memory. The extra matrix storage is included in `ramBytesUsed()` and is released -+when its model is no longer reachable. It is not serialized; the model and graph -+formats are unchanged. -+ -+`ash_project_f32` computes the entire matrix-vector product in one Highway -+dispatch. Four rows share query loads, with independent FMA chains and masked -+tails. Every graph query still pays for its own projection. There is no batch of -+unrelated queries, BLAS thread pool, per-row FFM call, or per-query matrix copy. -+Small bounded products use a synchronous critical downcall with heap query and -+output arrays. Larger products use an ordinary downcall with temporary native -+query/output buffers so the JVM can reach safepoints during the dense multiply. -+The matrix is shared read-only; temporary storage is invocation-local. -+ -+`ash_symmetric_dot` evaluates encoded pairs used by graph construction: 1-bit -+XOR/popcount; 2-bit weighted popcounts over packed sign/magnitude fields; 4-bit -+unsigned-magnitude by signed-magnitude byte dot products. Integer accumulation -+precedes a single factor of one quarter for 2/4-bit codes. Tail padding is ignored. -+The Ice Lake vtable selects the same Highway source compiled with native -+population-count and VNNI support, and Sapphire Rapids inherits that slot. -+AVX-512 without those instructions, AVX2, scalar and ARM retain the portable -+Highway implementations. The Java scorer still combines scales, centered -+offsets, landmark terms and the existing similarity conversion. -+ -+`ash_symmetric_lut_i16` handles byte-interleaved ranges in one call, retaining -+the existing exact signed-short LUT. It uses 32-bit partial sums and bounded -+chunks accumulated into 64-bit totals, rather than letting a short or integer -+sum overflow. The graph builder's pair-oriented API uses the pair kernel; -+callers of `ASHSymmetricScorer.blockScorerFor` use the native LUT kernel. Existing -+C>1 formulas and the graph-neighborhood scorer are unchanged. The graph study -+uses C=1 throughout. -+ -+All three symbols are optional and looked up independently. Loading an older -+compatible library preserves Java projection and symmetric implementations. -+Java validates shapes, array bounds and output aliasing before passing pointers -+to native code. Native kernels do not allocate, retain pointers or call Java. -+Scorer diagnostics identify whether each operation is Highway or Java. -+ -+## Reference implementation review -+ -+Reviewed IVF ASH Fastscan at commit -+[`56d78d0ff3aaaa61a02b94c6f49a97789706a717`](https://github.com/tlwillke/ivf-ash-fastscan/tree/56d78d0ff3aaaa61a02b94c6f49a97789706a717). -+Its [`AshProjectionModel::transform_residual_batch`](https://github.com/tlwillke/ivf-ash-fastscan/blob/56d78d0ff3aaaa61a02b94c6f49a97789706a717/ash_transform.cpp#L727) -+uses a contiguous input-major projection and BLAS matrix multiplication. Its -+[`compute_lut_uint8`](https://github.com/tlwillke/ivf-ash-fastscan/blob/56d78d0ff3aaaa61a02b94c6f49a97789706a717/ash_query.cpp#L347) -+path batches projections before building query-local LUTs. The -+[`AVX-512 scan`](https://github.com/tlwillke/ivf-ash-fastscan/blob/56d78d0ff3aaaa61a02b94c6f49a97789706a717/ash_fastscan_avx512.cpp) -+uses packed nibbles, byte shuffles and independent integer accumulators. -+ -+The transferable principles here are preparing contiguous immutable weights, -+amortizing native dispatch over a complete operation, reusing query loads, and -+accumulating compact codes without materializing decoded vectors. This port -+implements a single-query Highway matrix-vector operation because graph query -+latency must include preparation for each query. The IVF implementation does -+not provide a graph-construction symmetric scorer. Its quantized uint8 query -+LUTs are not substituted for the graph's existing exact symmetric integer LUTs. -+The kernels in this change are independent implementations of these operations. -+ -+## Validation and measurement -+ -+Targeted regression checks compare projection with a double-precision oracle, -+packed pair dots with an independent decoder, and integer LUT ranges with -+64-bit scalar accumulation. They cover dirty tail bits, irregular dimensions, -+both FFM modes, concurrent projections, bounds/alias rejection and absent native -+symbols. Both JDK 22 and JDK 25 are checked with normal dispatch and the existing -+AVX3_DL, AVX3, AVX2 and SSE/scalar ISA caps. The generic kernel translation unit -+also compiles for ARM NEON on Apple Silicon. SVE/SVE2 execution is not validated -+on this host. -+ -+Performance collection uses unchanged standard BenchYAML, existing inclusive -+timers, normal query preparation, deferred writes and NVQ reranking. Before and -+after query runs load the same saved model and graph, retain stock warmup and -+samples, and use 1 or 8 scoring threads pinned to physical cores. Traced small -+BenchYAML preflights confirm actual native calls; tracing is disabled for all -+performance measurements. Separate fresh full-data builds verify construction. -+The focused cases are MPNet at the 2-bit ASH budget and OpenAI3072 at the 4-bit -+ASH budget on both JDKs. They are not a replacement for the earlier full PQ/ASH -+matrix. Full results and measurement scope accompany the saved report. -diff --git a/jvector-native/src/test/java/io/github/jbellis/jvector/vector/NativeASHCompatibilityTest.java b/jvector-native/src/test/java/io/github/jbellis/jvector/vector/NativeASHCompatibilityTest.java -index 74ff784c..d41713f9 100644 ---- a/jvector-native/src/test/java/io/github/jbellis/jvector/vector/NativeASHCompatibilityTest.java -+++ b/jvector-native/src/test/java/io/github/jbellis/jvector/vector/NativeASHCompatibilityTest.java -@@ -33,6 +33,9 @@ class NativeASHCompatibilityTest { - assumeTrue(arch.equals("amd64") || arch.equals("x86_64")); - assumeTrue(LibraryLoader.loadJvector(), "Native jvector library not available"); - assertTrue(new NativeVectorUtilSupport().supportsAshNeighborhoodScoring()); -+ assertTrue(NativeASHProjection.isAvailable()); -+ assertTrue(NativeASHSymmetricKernels.hasPairKernel()); -+ assertTrue(NativeASHSymmetricKernels.hasLutKernel()); - } - - @Test -@@ -67,6 +70,20 @@ class NativeASHCompatibilityTest { - if (backend.supportsAshNeighborhoodScoring()) { - throw new AssertionError("ASH symbol unexpectedly available without a loaded native library"); - } -+ if (NativeASHProjection.isAvailable() || NativeASHSymmetricKernels.hasPairKernel() -+ || NativeASHSymmetricKernels.hasLutKernel()) { -+ throw new AssertionError("New ASH symbols unexpectedly available"); -+ } -+ float[] projected = new float[2]; -+ backend.prepareAshProjection(new float[][]{{1,2,3},{4,5,6}}) -+ .project(new float[]{2,3,4}, projected); -+ if (projected[0] != 20f || projected[1] != 47f) -+ throw new AssertionError("Java projection fallback failed"); -+ if (backend.ashSymmetricDot(null,new byte[]{0},null,new byte[]{0},2,4) != .5f) -+ throw new AssertionError("Java symmetric pair fallback failed"); -+ float[] symmetric = new float[1]; -+ backend.ashSymmetricLutScore(new byte[1],1,1,0,1,new short[32],symmetric,0); -+ if (symmetric[0] != 0f) throw new AssertionError("Java symmetric LUT fallback failed"); - float[] lut = new float[16]; - java.util.Arrays.fill(lut, .25f); - float[] out = new float[8]; -diff --git a/jvector-native/src/test/java/io/github/jbellis/jvector/vector/NativeASHPreparationTest.java b/jvector-native/src/test/java/io/github/jbellis/jvector/vector/NativeASHPreparationTest.java -new file mode 100644 -index 00000000..c27a6d95 ---- /dev/null -+++ b/jvector-native/src/test/java/io/github/jbellis/jvector/vector/NativeASHPreparationTest.java -@@ -0,0 +1,141 @@ -+/* -+ * 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.cnative.LibraryLoader; -+import org.junit.jupiter.api.BeforeAll; -+import org.junit.jupiter.api.Test; -+ -+import java.util.ArrayList; -+import java.util.Arrays; -+import java.util.Random; -+import java.util.concurrent.Executors; -+ -+import static org.junit.jupiter.api.Assertions.*; -+import static org.junit.jupiter.api.Assumptions.assumeTrue; -+ -+/** Independent arithmetic oracles cover vector tails, both FFM modes and shared-model use. */ -+class NativeASHPreparationTest { -+ @BeforeAll -+ static void loadLibrary() { -+ assumeTrue(LibraryLoader.loadJvector(), "Native library unavailable"); -+ assertTrue(NativeASHProjection.isAvailable()); -+ assertTrue(NativeASHSymmetricKernels.hasPairKernel()); -+ assertTrue(NativeASHSymmetricKernels.hasLutKernel()); -+ } -+ -+ @Test -+ void projectionMatchesDoubleOracleAndIsThreadSafe() throws Exception { -+ Random random = new Random(93124); -+ for (int[] shape : new int[][]{{1,1},{3,7},{4,15},{5,17},{17,31},{33,129},{257,513},{1536,3072}}) { -+ int rows = shape[0], columns = shape[1]; -+ float[][] matrix = new float[rows][columns]; -+ float[] query = new float[columns]; -+ for (float[] row : matrix) for (int j = 0; j < columns; j++) row[j] = random.nextFloat() - .5f; -+ for (int j = 0; j < columns; j++) query[j] = random.nextFloat() - .5f; -+ var projection = new NativeASHProjection(matrix); -+ assertEquals((long) rows * columns * Float.BYTES, projection.ramBytesUsed()); -+ float[] expected = new float[rows]; -+ double[] magnitude = new double[rows]; -+ for (int i = 0; i < rows; i++) { -+ double sum = 0; -+ for (int j = 0; j < columns; j++) { -+ double product = (double) matrix[i][j] * query[j]; -+ sum += product; magnitude[i] += Math.abs(product); -+ } -+ expected[i] = (float) sum; -+ } -+ float[] original = query.clone(); -+ try (var workers = Executors.newFixedThreadPool(4)) { -+ var tasks = new ArrayList>(); -+ for (int repeat = 0; repeat < 8; repeat++) tasks.add(() -> { -+ float[] out = new float[rows]; -+ projection.project(query, out); -+ for (int i = 0; i < rows; i++) assertEquals(expected[i], out[i], 2e-6 * magnitude[i] + 1e-7); -+ return null; -+ }); -+ for (var result : workers.invokeAll(tasks)) result.get(); -+ } -+ assertArrayEquals(original, query); -+ } -+ } -+ -+ @Test -+ void packedPairsMatchIndependentDecoderIncludingDirtyPadding() { -+ Random random = new Random(55191); -+ for (int bits : new int[]{1,2,4}) { -+ for (int dimensions : new int[]{0,1,2,3,7,15,16,17,31,32,33,63,64,65,127,128,129,257,384,512,768,1536,65537}) { -+ long[] aBits = new long[(dimensions + 63) / 64], bBits = aBits.clone(); -+ byte[] a = new byte[(dimensions * bits + 7) / 8], b = a.clone(); -+ for (int i = 0; i < aBits.length; i++) { aBits[i] = random.nextLong(); bBits[i] = random.nextLong(); } -+ random.nextBytes(a); random.nextBytes(b); -+ long total = 0; -+ for (int j = 0; j < dimensions; j++) { -+ if (bits == 1) total += (((aBits[j/64] ^ bBits[j/64]) >>> (j%64)) & 1) == 0 ? 1 : -1; -+ else total += (long) doubled(a,j,bits) * doubled(b,j,bits); -+ } -+ float expected = bits == 1 ? (float) total : total * .25f; -+ assertEquals(expected, NativeASHSymmetricKernels.dot(aBits,a,bBits,b,dimensions,bits), 0f); -+ assertEquals(expected, NativeASHSymmetricKernels.dot(bBits,b,aBits,a,dimensions,bits), 0f); -+ } -+ } -+ } -+ -+ private static int doubled(byte[] codes, int j, int bits) { -+ int field = (codes[j * bits / 8] >>> (j * bits % 8)) & ((1 << bits) - 1); -+ int sign = 1 << (bits - 1), magnitude = 2 * (field & (sign - 1)) + 1; -+ return (field & sign) == 0 ? -magnitude : magnitude; -+ } -+ -+ @Test -+ void integerLutHandlesOffsetsTailsAndOverflow() { -+ Random random = new Random(1021); -+ int stride = 37, lane = 3; -+ for (int groups : new int[]{0,1,2,31,63,64,65,257,4097,65539}) { -+ byte[] codes = new byte[((groups+1)/2)*stride]; random.nextBytes(codes); -+ short[] lut = new short[groups*32]; -+ for (int g = 0; g < groups; g++) for (int i = 0; i < 16; i++) { -+ short value = groups == 65539 ? Short.MAX_VALUE : (short) random.nextInt(); -+ lut[g*32+i] = lut[g*32+i+16] = value; -+ } -+ for (int count : new int[]{0,1,7,16,17,32,34}) { -+ float[] out = new float[count+2]; Arrays.fill(out, Float.NaN); -+ NativeASHSymmetricKernels.scoreLut(codes,groups,stride,lane,count,lut,out,1); -+ assertTrue(Float.isNaN(out[0])); assertTrue(Float.isNaN(out[count+1])); -+ for (int i = 0; i < count; i++) { -+ long sum = 0; -+ for (int g = 0; g < groups; g++) sum += lut[g*32+((codes[(g/2)*stride+lane+i] >>> (4*(g%2))) & 15)]; -+ assertEquals(sum*.25f,out[i+1],0f); -+ } -+ } -+ } -+ } -+ -+ @Test -+ void rejectsUnsafeInputsBeforeNativeCall() { -+ var projection = new NativeASHProjection(new float[][]{{1,2},{3,4}}); -+ float[] alias = {1,2}; -+ assertThrows(IllegalArgumentException.class, () -> projection.project(alias,alias)); -+ assertThrows(IllegalArgumentException.class, () -> projection.project(new float[1],new float[2])); -+ assertThrows(IllegalArgumentException.class, () -> new NativeASHProjection(new float[][]{{1},{2,3}})); -+ assertThrows(IndexOutOfBoundsException.class, () -> NativeASHSymmetricKernels.dot(null,new byte[1],null,new byte[1],9,2)); -+ assertThrows(IllegalArgumentException.class, () -> NativeASHSymmetricKernels.dot(null,null,null,null,-1,2)); -+ assertThrows(IllegalArgumentException.class, () -> NativeASHSymmetricKernels.dot(null,null,null,null,1,3)); -+ assertThrows(IndexOutOfBoundsException.class, () -> NativeASHSymmetricKernels.scoreLut(new byte[8],1,8,7,2,new short[32],new float[2],0)); -+ assertThrows(IndexOutOfBoundsException.class, () -> NativeASHSymmetricKernels.scoreLut(new byte[8],1,8,0,8,new short[31],new float[8],0)); -+ } -+} --- -2.39.5 - diff --git a/docs/benchmarks/native-ash-projection-symmetric-20261029/inputs.json b/docs/benchmarks/native-ash-projection-symmetric-20261029/inputs.json deleted file mode 100644 index 5c5766949..000000000 --- a/docs/benchmarks/native-ash-projection-symmetric-20261029/inputs.json +++ /dev/null @@ -1,98 +0,0 @@ -[ - { - "kind": "base", - "path": "/home/ted_willke/datasets-clean/mpnet-1m/wiki_mpnet_embeddings_base_999812.fvecs", - "bytes": 3075421712, - "width": 768, - "records": 999812, - "mtime_ns": 1779289837543238134 - }, - { - "kind": "query", - "path": "/home/ted_willke/datasets-clean/mpnet-1m/wiki_mpnet_embeddings_query_10000.fvecs", - "bytes": 30760000, - "width": 768, - "records": 10000, - "mtime_ns": 1779289837559238135 - }, - { - "kind": "gt", - "path": "/home/ted_willke/datasets-clean/mpnet-1m/wiki_mpnet_embeddings_gt_ip_100.ivecs", - "bytes": 4040000, - "width": 100, - "records": 10000, - "mtime_ns": 1779289837543238134 - }, - { - "kind": "base", - "path": "/home/ted_willke/datasets-clean/cohere-1m/cohere_wiki_en_flat_base_1m_norm.fvecs", - "bytes": 4100000000, - "width": 1024, - "records": 1000000, - "mtime_ns": 1778786535687938561 - }, - { - "kind": "query", - "path": "/home/ted_willke/datasets-clean/cohere-1m/cohere_wiki_en_flat_query_10k_norm.fvecs", - "bytes": 41000000, - "width": 1024, - "records": 10000, - "mtime_ns": 1778786535703938563 - }, - { - "kind": "gt", - "path": "/home/ted_willke/datasets-clean/cohere-1m/cohere_wiki_en_flat_gt_1m_ip_k100.ivecs", - "bytes": 4040000, - "width": 100, - "records": 10000, - "mtime_ns": 1778786533775938406 - }, - { - "kind": "base", - "path": "/home/ted_willke/datasets-clean/ada_002_1M_base_982790.fvecs", - "bytes": 6042192920, - "width": 1536, - "records": 982790, - "mtime_ns": 1775399354461867895 - }, - { - "kind": "query", - "path": "/home/ted_willke/datasets-clean/ada_002_1M_query_10000.fvecs", - "bytes": 61480000, - "width": 1536, - "records": 10000, - "mtime_ns": 1775399354529867849 - }, - { - "kind": "gt", - "path": "/home/ted_willke/datasets-clean/ada_002_1M_gt_ip_100.ivecs", - "bytes": 4040000, - "width": 100, - "records": 10000, - "mtime_ns": 1775399661489659318 - }, - { - "kind": "base", - "path": "/home/ted_willke/datasets-clean/openai-3072-1m/dbpedia_openai_3_large_3072_base_999000.fvecs", - "bytes": 12279708000, - "width": 3072, - "records": 999000, - "mtime_ns": 1779387195627505524 - }, - { - "kind": "query", - "path": "/home/ted_willke/datasets-clean/openai-3072-1m/dbpedia_openai_3_large_3072_query_1000.fvecs", - "bytes": 12292000, - "width": 3072, - "records": 1000, - "mtime_ns": 1779387195663505529 - }, - { - "kind": "gt", - "path": "/home/ted_willke/datasets-clean/openai-3072-1m/dbpedia_openai_3_large_3072_gt_ip_100.ivecs", - "bytes": 404000, - "width": 100, - "records": 1000, - "mtime_ns": 1779387338759522247 - } -] diff --git a/docs/benchmarks/native-ash-projection-symmetric-20261029/manifest.json b/docs/benchmarks/native-ash-projection-symmetric-20261029/manifest.json deleted file mode 100644 index 2842aeeed..000000000 --- a/docs/benchmarks/native-ash-projection-symmetric-20261029/manifest.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "construction.csv": "fe93d787530fa9f93de378b4c93db6d1de015945a71dfc53c38af44494711899", - "design.md": "541d488fe562454bafce18b00754bfe89e5a4c33e753dd805a16196a0e5f33c7", - "source.json": "18c0f9e4f3481cb26df94fd18b108185191a3792f55529043ba55f797e155dab", - "harness-identity.json": "e496ccfe48826cf451b5c4c0ce3b0386f8d2f0472376b5667d81e482e4d8d643", - "implementation.patch": "44c60767037a8048f78622ecf9f39a64403ceb34286af69af4125e1795d411fe", - "protocol.json": "a298b18e0807ca5d74f1b7040b82bc7fa9370352577aa7f1564744b2eb29d93a", - "inputs.json": "3552c2975db1a165a1af51403c403cc7ed05c7eee253d78741593bd259dfd025", - "projection-diagnostic.json": "bc2fa149e03303023b77ccd9a864ecb572a01861ab6221b684441cf48f4f9339", - "native-preflight.json": "b50fd7165fbcb21f5aeadeea2373b5990440579c868d81bf361bab3ed3d379d6", - "summary.json": "8728e6efb6d9f6efde3500849508fa11fb6591e65ac4a73c90a9afc9569826d4", - "native-isa.txt": "ac8ae32c1b7bc20d8e70308c6a3139d02f8eb8977963363ad742584457f73cc9", - "validation.json": "6d972dcc97402ef0725edaf8a0f3613717dcc2a6b8073d73f29c4277e033d2f1", - "raw-results.json": "636ab361c8896c83e94b833b1613799b632c8a94710a0fceff5df0f93a4643a1", - "queries.csv": "a73788c570896f8a2112523ad8b6ba62a1e0a3d8c8e96d88ed2546dd9fc701e1", - "report.md": "152f0ee714ce842ce0a620ac7c5974b70bbb3f4d96b589a625ff5b4e00a6a487" -} diff --git a/docs/benchmarks/native-ash-projection-symmetric-20261029/native-isa.txt b/docs/benchmarks/native-ash-projection-symmetric-20261029/native-isa.txt deleted file mode 100644 index a9cb835a2..000000000 --- a/docs/benchmarks/native-ash-projection-symmetric-20261029/native-isa.txt +++ /dev/null @@ -1 +0,0 @@ -ACTIVE_HIGHWAY_ISA=avx3_spr diff --git a/docs/benchmarks/native-ash-projection-symmetric-20261029/native-preflight.json b/docs/benchmarks/native-ash-projection-symmetric-20261029/native-preflight.json deleted file mode 100644 index 91eab52e3..000000000 --- a/docs/benchmarks/native-ash-projection-symmetric-20261029/native-preflight.json +++ /dev/null @@ -1,26 +0,0 @@ -[ - { - "log": "preflight-jdk22-2bit.log", - "project_calls": 128, - "symmetric_pair_calls": 243289, - "neighborhood_calls": 1139 - }, - { - "log": "preflight-jdk22-4bit.log", - "project_calls": 128, - "symmetric_pair_calls": 224915, - "neighborhood_calls": 1143 - }, - { - "log": "preflight-jdk25-2bit.log", - "project_calls": 128, - "symmetric_pair_calls": 256359, - "neighborhood_calls": 1130 - }, - { - "log": "preflight-jdk25-4bit.log", - "project_calls": 128, - "symmetric_pair_calls": 237446, - "neighborhood_calls": 1122 - } -] diff --git a/docs/benchmarks/native-ash-projection-symmetric-20261029/projection-diagnostic.json b/docs/benchmarks/native-ash-projection-symmetric-20261029/projection-diagnostic.json deleted file mode 100644 index 4aa208f7e..000000000 --- a/docs/benchmarks/native-ash-projection-symmetric-20261029/projection-diagnostic.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "scope": "Instrumented class overlay outside repository, accuracy-only BenchYAML on the saved OpenAI3072 graph; alternates native and Panama call order, thread CPU time, both methods consume the same prepared query; all 2000 pairs checked for agreement. Excluded from throughput tables.", - "results": [ - { - "jdk": "22", - "calls": 2000, - "native_cpu_total_ns": 1261210914, - "java_cpu_total_ns": 1264127945, - "native_cpu_median_ns": 629106, - "java_cpu_median_ns": 625767 - }, - { - "jdk": "25", - "calls": 2000, - "native_cpu_total_ns": 1258410413, - "java_cpu_total_ns": 1277275700, - "native_cpu_median_ns": 628222, - "java_cpu_median_ns": 625737 - } - ] -} diff --git a/docs/benchmarks/native-ash-projection-symmetric-20261029/protocol.json b/docs/benchmarks/native-ash-projection-symmetric-20261029/protocol.json deleted file mode 100644 index 96abaf170..000000000 --- a/docs/benchmarks/native-ash-projection-symmetric-20261029/protocol.json +++ /dev/null @@ -1,45 +0,0 @@ -{ - "entry": "io.github.jbellis.jvector.example.BenchYAML", - "datasets": [ - "MPNet 999812 base / 10000 queries, D768, ASH d384, 2 bits, 101 code bytes", - "OpenAI3072 999000 base / 1000 queries, D3072, ASH d1536, 4 bits, 773 code bytes" - ], - "jdks": [ - "22.0.2+9", - "25.0.2" - ], - "query_threads": [ - 1, - 8 - ], - "construction_physical_cores": 48, - "query_affinity": { - "1": "0", - "8": "0-7" - }, - "construction_affinity": "0-47", - "backend": "native provider in both arms; before uses Panama dense projection/symmetric helpers, after uses Highway for those operations", - "query_scope": "Same saved model and graph before/after; standard three random-query warmup passes, three measured samples over all supplied dataset queries; normal query preparation, graph traversal, fused neighborhood scoring and NVQ reranking included.", - "same_graph_cache_policy": "Existing unchanged saved graph/model caches, linked into isolated case directories. Original cold graph pages are loaded naturally by the standard harness; no manual cache conditioning.", - "construction_scope": "Four fresh after-implementation ASH builds, separate empty model/graph caches, production ITQ training, encoding, deferred parallel writes and NVQ reranking; existing BenchYAML phase timers.", - "total_process_scope": "Fresh build JVM wall time includes startup/loading, training/encoding, graph/write, one standard 1x recall pass, teardown. It is not build-only.", - "graph": { - "C": 1, - "M": 32, - "efConstruction": 100, - "overflow": 1.2, - "hierarchy": true, - "refinement": true, - "pruning": false, - "ash_adaptive_stopping": "existing policy", - "NVQ": "two 8-bit subvectors", - "deferred_writer_batch": 64 - }, - "caveats": [ - "Focused implementation validation, not a repeat of the full four-dataset PQ/ASH comparison.", - "One saved graph per comparison and one fresh construction per case; no graph-build variance estimate.", - "Java still combines symmetric scales/offsets/landmark terms; encoding row projections and standalone asymmetric packed helpers are outside this port.", - "Pre-final implementation runs archived in superseded-development after the range-counter/critical-work review; unchanged completed baseline query runs retained.", - "SVE/SVE2 runtime validation is unavailable." - ] -} diff --git a/docs/benchmarks/native-ash-projection-symmetric-20261029/queries.csv b/docs/benchmarks/native-ash-projection-symmetric-20261029/queries.csv deleted file mode 100644 index 0ecf018f9..000000000 --- a/docs/benchmarks/native-ash-projection-symmetric-20261029/queries.csv +++ /dev/null @@ -1,17 +0,0 @@ -dataset,jdk,ash_bits,threads,overquery,before_qps,after_qps,speedup,before_cv_pct,after_cv_pct,before_recall,after_recall,recall_delta_percentage_points,before_avg_visited,after_avg_visited -mpnet-1m,22,2,1,1.0,9534.411032461352,11110.99464179856,1.1653572102114633,5.624094003017182,5.908531619200125,0.8194,0.8194,0.0,457.6786,457.6786 -mpnet-1m,22,2,1,2.0,7996.33222133498,8875.235693873283,1.1099133262864322,0.8873558337502068,0.21322087119190308,0.94055,0.94055,0.0,639.0262,639.0262 -mpnet-1m,22,2,8,1.0,73300.48560157196,80378.2764372332,1.0965585804456044,1.1297386488833827,2.5685345814363902,0.8194,0.8194,0.0,457.6786,457.6786 -mpnet-1m,22,2,8,2.0,59440.87356386484,62296.523830157785,1.0480418623596566,2.42568979517639,8.754005476832623,0.94055,0.94055,0.0,639.0262,639.0262 -mpnet-1m,25,2,1,1.0,10441.19054756948,12117.702889285703,1.1605671627270977,0.5044069152625217,1.543338944623599,0.82017,0.82017,0.0,455.8008,455.8008 -mpnet-1m,25,2,1,2.0,8293.758257315945,9286.185025696477,1.1196594761494416,0.90459407773869,0.25028073268698053,0.93971,0.93971,0.0,636.613,636.613 -mpnet-1m,25,2,8,1.0,76547.2022487084,83549.5892088437,1.091478026033975,2.203891530228568,4.296693036410662,0.82017,0.82017,0.0,455.8008,455.8008 -mpnet-1m,25,2,8,2.0,61781.41999991404,69337.75348751461,1.1223075398333524,2.4886931258567033,0.909554792882135,0.93971,0.93971,0.0,636.613,636.613 -openai-3072-1m,22,4,1,1.0,1024.6329990152383,1026.4376092178372,1.0017612259260957,4.046430003138828,5.087713390174111,0.7903,0.7903,0.0,512.685,512.685 -openai-3072-1m,22,4,1,2.0,1030.0348655106716,1046.101981421671,1.015598613647931,1.1342650715278502,0.9862944129088163,0.8834,0.8834,0.0,735.241,735.241 -openai-3072-1m,22,4,8,1.0,7741.458630781505,8258.710248857857,1.0668157827543845,4.468815896859849,3.4045657578531534,0.7903,0.7903,0.0,512.685,512.685 -openai-3072-1m,22,4,8,2.0,7240.167989195661,7926.960479696699,1.0948586402312657,0.569470363321681,2.347372560147485,0.8834,0.8834,0.0,735.241,735.241 -openai-3072-1m,25,4,1,1.0,1062.2402591845162,1011.9147946874974,0.9526232751377229,5.091229038043828,7.701434971355209,0.7965,0.7965,0.0,510.697,510.697 -openai-3072-1m,25,4,1,2.0,1040.2391399169903,1054.0483629342123,1.0132750465612397,1.136075931031491,0.89958827737423,0.8875,0.8875,0.0,730.922,730.922 -openai-3072-1m,25,4,8,1.0,7843.681782186251,8559.176543886948,1.091219249017171,3.1821800596846606,1.8174480489639642,0.7965,0.7965,0.0,510.697,510.697 -openai-3072-1m,25,4,8,2.0,7463.83544647903,7983.474801021447,1.0696209553745655,0.5468623032724961,1.5482454681357327,0.8875,0.8875,0.0,730.922,730.922 diff --git a/docs/benchmarks/native-ash-projection-symmetric-20261029/raw-results.json b/docs/benchmarks/native-ash-projection-symmetric-20261029/raw-results.json deleted file mode 100644 index 03ed53088..000000000 --- a/docs/benchmarks/native-ash-projection-symmetric-20261029/raw-results.json +++ /dev/null @@ -1,1234 +0,0 @@ -[ - { - "label": "jdk22-mpnet-1m-2bit-ash-before-t1-before-t1", - "jdk": "22", - "version": "before", - "threads": 1, - "build": false, - "elapsed_process_seconds": 584.878967021592, - "rows": [ - { - "schema_version": "1", - "run_id": "jdk22-mpnet-1m-2bit-ash-before-t1-before-t1-20261029-060100Z", - "run_uuid": "a4f98879-0701-429c-a6f8-5aef4727c8cc", - "system_id": "1d71c58acd06", - "dataset_name": "mpnet-1m", - "M": "32", - "efConstruction": "100", - "neighborOverflow": "1.2", - "addHierarchy": "true", - "refineFinalGraph": "true", - "feature_set": "[NVQ_VECTORS, FUSED_ASH]", - "build_compressor": "\"AsymmetricHashing[origDim=768, encodedBits=808, bodyBits=768, quantizedDim=384, bitsPerDimension=2, optimizer=ITQ, landmarks=1]\"", - "search_compressor": "None", - "usePruning": "false", - "topK": "10", - "overquery": "1.0", - "rerankK": "10", - "search.throughput.avg_qps": "9534.411032461352", - "search.throughput.stddev_qps": "536.2242390996676", - "search.throughput.cv_pct": "5.624094003017182", - "search.count.avg_visited": "457.6786", - "search.accuracy.recall_at_10": "0.8194", - "construction.index_build_time_s": "", - "construction.index_quant_time_s.ASH.compute_time_s": "", - "construction.index_quant_time_s.ASH.encoding_time_s": "", - "construction.index_quant_time_s.NVQ.compute_time_s": "" - }, - { - "schema_version": "1", - "run_id": "jdk22-mpnet-1m-2bit-ash-before-t1-before-t1-20261029-060100Z", - "run_uuid": "a4f98879-0701-429c-a6f8-5aef4727c8cc", - "system_id": "1d71c58acd06", - "dataset_name": "mpnet-1m", - "M": "32", - "efConstruction": "100", - "neighborOverflow": "1.2", - "addHierarchy": "true", - "refineFinalGraph": "true", - "feature_set": "[NVQ_VECTORS, FUSED_ASH]", - "build_compressor": "\"AsymmetricHashing[origDim=768, encodedBits=808, bodyBits=768, quantizedDim=384, bitsPerDimension=2, optimizer=ITQ, landmarks=1]\"", - "search_compressor": "None", - "usePruning": "false", - "topK": "10", - "overquery": "2.0", - "rerankK": "20", - "search.throughput.avg_qps": "7996.33222133498", - "search.throughput.stddev_qps": "70.95592045206345", - "search.throughput.cv_pct": "0.8873558337502068", - "search.count.avg_visited": "639.0262", - "search.accuracy.recall_at_10": "0.94055", - "construction.index_build_time_s": "", - "construction.index_quant_time_s.ASH.compute_time_s": "", - "construction.index_quant_time_s.ASH.encoding_time_s": "", - "construction.index_quant_time_s.NVQ.compute_time_s": "" - } - ], - "log": "/home/ted_willke/benchmark-runs/ash-native-projection-symmetric-20261029/logs/jdk22-mpnet-1m-2bit-ash-before-t1-before-t1.log", - "finished": "2026-10-29T06:10:43.872168+00:00" - }, - { - "label": "jdk22-mpnet-1m-2bit-ash-before-t8-before-t8", - "jdk": "22", - "version": "before", - "threads": 8, - "build": false, - "elapsed_process_seconds": 19.901069899089634, - "rows": [ - { - "schema_version": "1", - "run_id": "jdk22-mpnet-1m-2bit-ash-before-t8-before-t8-20261029-061201Z", - "run_uuid": "5a10d29d-249d-493a-a0f9-fe4c6ce3a6a3", - "system_id": "1d71c58acd06", - "dataset_name": "mpnet-1m", - "M": "32", - "efConstruction": "100", - "neighborOverflow": "1.2", - "addHierarchy": "true", - "refineFinalGraph": "true", - "feature_set": "[NVQ_VECTORS, FUSED_ASH]", - "build_compressor": "\"AsymmetricHashing[origDim=768, encodedBits=808, bodyBits=768, quantizedDim=384, bitsPerDimension=2, optimizer=ITQ, landmarks=1]\"", - "search_compressor": "None", - "usePruning": "false", - "topK": "10", - "overquery": "1.0", - "rerankK": "10", - "search.throughput.avg_qps": "73300.48560157196", - "search.throughput.stddev_qps": "828.1039156601576", - "search.throughput.cv_pct": "1.1297386488833827", - "search.count.avg_visited": "457.6786", - "search.accuracy.recall_at_10": "0.8194", - "construction.index_build_time_s": "", - "construction.index_quant_time_s.ASH.compute_time_s": "", - "construction.index_quant_time_s.ASH.encoding_time_s": "", - "construction.index_quant_time_s.NVQ.compute_time_s": "" - }, - { - "schema_version": "1", - "run_id": "jdk22-mpnet-1m-2bit-ash-before-t8-before-t8-20261029-061201Z", - "run_uuid": "5a10d29d-249d-493a-a0f9-fe4c6ce3a6a3", - "system_id": "1d71c58acd06", - "dataset_name": "mpnet-1m", - "M": "32", - "efConstruction": "100", - "neighborOverflow": "1.2", - "addHierarchy": "true", - "refineFinalGraph": "true", - "feature_set": "[NVQ_VECTORS, FUSED_ASH]", - "build_compressor": "\"AsymmetricHashing[origDim=768, encodedBits=808, bodyBits=768, quantizedDim=384, bitsPerDimension=2, optimizer=ITQ, landmarks=1]\"", - "search_compressor": "None", - "usePruning": "false", - "topK": "10", - "overquery": "2.0", - "rerankK": "20", - "search.throughput.avg_qps": "59440.87356386484", - "search.throughput.stddev_qps": "1441.8512042023701", - "search.throughput.cv_pct": "2.42568979517639", - "search.count.avg_visited": "639.0262", - "search.accuracy.recall_at_10": "0.94055", - "construction.index_build_time_s": "", - "construction.index_quant_time_s.ASH.compute_time_s": "", - "construction.index_quant_time_s.ASH.encoding_time_s": "", - "construction.index_quant_time_s.NVQ.compute_time_s": "" - } - ], - "log": "/home/ted_willke/benchmark-runs/ash-native-projection-symmetric-20261029/logs/jdk22-mpnet-1m-2bit-ash-before-t8-before-t8.log", - "finished": "2026-10-29T06:12:21.240616+00:00" - }, - { - "label": "jdk22-mpnet-1m-2bit-ash-after-t1-after-t1", - "jdk": "22", - "version": "after", - "threads": 1, - "build": false, - "elapsed_process_seconds": 48.541612004861236, - "rows": [ - { - "schema_version": "1", - "run_id": "jdk22-mpnet-1m-2bit-ash-after-t1-after-t1-20261029-061541Z", - "run_uuid": "b0323815-8337-4caf-922e-53725467ff8e", - "system_id": "1d71c58acd06", - "dataset_name": "mpnet-1m", - "M": "32", - "efConstruction": "100", - "neighborOverflow": "1.2", - "addHierarchy": "true", - "refineFinalGraph": "true", - "feature_set": "[NVQ_VECTORS, FUSED_ASH]", - "build_compressor": "\"AsymmetricHashing[origDim=768, encodedBits=808, bodyBits=768, quantizedDim=384, bitsPerDimension=2, optimizer=ITQ, landmarks=1]\"", - "search_compressor": "None", - "usePruning": "false", - "topK": "10", - "overquery": "1.0", - "rerankK": "10", - "search.throughput.avg_qps": "11110.99464179856", - "search.throughput.stddev_qps": "656.4966316182995", - "search.throughput.cv_pct": "5.908531619200125", - "search.count.avg_visited": "457.6786", - "search.accuracy.recall_at_10": "0.8194", - "construction.index_build_time_s": "", - "construction.index_quant_time_s.ASH.compute_time_s": "", - "construction.index_quant_time_s.ASH.encoding_time_s": "", - "construction.index_quant_time_s.NVQ.compute_time_s": "" - }, - { - "schema_version": "1", - "run_id": "jdk22-mpnet-1m-2bit-ash-after-t1-after-t1-20261029-061541Z", - "run_uuid": "b0323815-8337-4caf-922e-53725467ff8e", - "system_id": "1d71c58acd06", - "dataset_name": "mpnet-1m", - "M": "32", - "efConstruction": "100", - "neighborOverflow": "1.2", - "addHierarchy": "true", - "refineFinalGraph": "true", - "feature_set": "[NVQ_VECTORS, FUSED_ASH]", - "build_compressor": "\"AsymmetricHashing[origDim=768, encodedBits=808, bodyBits=768, quantizedDim=384, bitsPerDimension=2, optimizer=ITQ, landmarks=1]\"", - "search_compressor": "None", - "usePruning": "false", - "topK": "10", - "overquery": "2.0", - "rerankK": "20", - "search.throughput.avg_qps": "8875.235693873283", - "search.throughput.stddev_qps": "18.923854866811357", - "search.throughput.cv_pct": "0.21322087119190308", - "search.count.avg_visited": "639.0262", - "search.accuracy.recall_at_10": "0.94055", - "construction.index_build_time_s": "", - "construction.index_quant_time_s.ASH.compute_time_s": "", - "construction.index_quant_time_s.ASH.encoding_time_s": "", - "construction.index_quant_time_s.NVQ.compute_time_s": "" - } - ], - "log": "/home/ted_willke/benchmark-runs/ash-native-projection-symmetric-20261029/logs/jdk22-mpnet-1m-2bit-ash-after-t1-after-t1.log", - "finished": "2026-10-29T06:16:29.049963+00:00" - }, - { - "label": "jdk22-mpnet-1m-2bit-ash-after-t8-after-t8", - "jdk": "22", - "version": "after", - "threads": 8, - "build": false, - "elapsed_process_seconds": 19.538603738881648, - "rows": [ - { - "schema_version": "1", - "run_id": "jdk22-mpnet-1m-2bit-ash-after-t8-after-t8-20261029-061629Z", - "run_uuid": "86977aa8-2dc3-4f4a-bfec-0aa6034ef473", - "system_id": "1d71c58acd06", - "dataset_name": "mpnet-1m", - "M": "32", - "efConstruction": "100", - "neighborOverflow": "1.2", - "addHierarchy": "true", - "refineFinalGraph": "true", - "feature_set": "[NVQ_VECTORS, FUSED_ASH]", - "build_compressor": "\"AsymmetricHashing[origDim=768, encodedBits=808, bodyBits=768, quantizedDim=384, bitsPerDimension=2, optimizer=ITQ, landmarks=1]\"", - "search_compressor": "None", - "usePruning": "false", - "topK": "10", - "overquery": "1.0", - "rerankK": "10", - "search.throughput.avg_qps": "80378.2764372332", - "search.throughput.stddev_qps": "2064.5438262528723", - "search.throughput.cv_pct": "2.5685345814363902", - "search.count.avg_visited": "457.6786", - "search.accuracy.recall_at_10": "0.8194", - "construction.index_build_time_s": "", - "construction.index_quant_time_s.ASH.compute_time_s": "", - "construction.index_quant_time_s.ASH.encoding_time_s": "", - "construction.index_quant_time_s.NVQ.compute_time_s": "" - }, - { - "schema_version": "1", - "run_id": "jdk22-mpnet-1m-2bit-ash-after-t8-after-t8-20261029-061629Z", - "run_uuid": "86977aa8-2dc3-4f4a-bfec-0aa6034ef473", - "system_id": "1d71c58acd06", - "dataset_name": "mpnet-1m", - "M": "32", - "efConstruction": "100", - "neighborOverflow": "1.2", - "addHierarchy": "true", - "refineFinalGraph": "true", - "feature_set": "[NVQ_VECTORS, FUSED_ASH]", - "build_compressor": "\"AsymmetricHashing[origDim=768, encodedBits=808, bodyBits=768, quantizedDim=384, bitsPerDimension=2, optimizer=ITQ, landmarks=1]\"", - "search_compressor": "None", - "usePruning": "false", - "topK": "10", - "overquery": "2.0", - "rerankK": "20", - "search.throughput.avg_qps": "62296.523830157785", - "search.throughput.stddev_qps": "5453.441107968352", - "search.throughput.cv_pct": "8.754005476832623", - "search.count.avg_visited": "639.0262", - "search.accuracy.recall_at_10": "0.94055", - "construction.index_build_time_s": "", - "construction.index_quant_time_s.ASH.compute_time_s": "", - "construction.index_quant_time_s.ASH.encoding_time_s": "", - "construction.index_quant_time_s.NVQ.compute_time_s": "" - } - ], - "log": "/home/ted_willke/benchmark-runs/ash-native-projection-symmetric-20261029/logs/jdk22-mpnet-1m-2bit-ash-after-t8-after-t8.log", - "finished": "2026-10-29T06:16:48.590488+00:00" - }, - { - "label": "jdk22-mpnet-1m-2bit-ash-after-build", - "jdk": "22", - "version": "after", - "threads": 1, - "build": true, - "elapsed_process_seconds": 67.83305824548006, - "rows": [ - { - "schema_version": "1", - "run_id": "jdk22-mpnet-1m-2bit-ash-after-build-20261029-061649Z", - "run_uuid": "474bf83b-a177-4ec6-a8d2-7cb5e1e3c329", - "system_id": "1d71c58acd06", - "dataset_name": "mpnet-1m", - "M": "32", - "efConstruction": "100", - "neighborOverflow": "1.2", - "addHierarchy": "true", - "refineFinalGraph": "true", - "feature_set": "[NVQ_VECTORS, FUSED_ASH]", - "build_compressor": "\"AsymmetricHashing[origDim=768, encodedBits=808, bodyBits=768, quantizedDim=384, bitsPerDimension=2, optimizer=ITQ, landmarks=1]\"", - "search_compressor": "None", - "usePruning": "false", - "topK": "10", - "overquery": "1.0", - "rerankK": "10", - "search.accuracy.recall_at_10": "0.81888", - "construction.index_build_time_s": "54.164598291", - "construction.index_quant_time_s.ASH.compute_time_s": "5.292379244", - "construction.index_quant_time_s.ASH.encoding_time_s": "1.047888893", - "construction.index_quant_time_s.NVQ.compute_time_s": "0.339563223" - } - ], - "log": "/home/ted_willke/benchmark-runs/ash-native-projection-symmetric-20261029/logs/jdk22-mpnet-1m-2bit-ash-after-build.log", - "finished": "2026-10-29T06:17:56.427633+00:00" - }, - { - "label": "jdk22-openai-3072-1m-4bit-ash-after-t1-after-t1", - "jdk": "22", - "version": "after", - "threads": 1, - "build": false, - "elapsed_process_seconds": 195.20415874477476, - "rows": [ - { - "schema_version": "1", - "run_id": "jdk22-openai-3072-1m-4bit-ash-after-t1-after-t1-20261029-061757Z", - "run_uuid": "ef1184fc-e6ae-44f8-a775-95d42aeb1989", - "system_id": "1d71c58acd06", - "dataset_name": "openai-3072-1m", - "M": "32", - "efConstruction": "100", - "neighborOverflow": "1.2", - "addHierarchy": "true", - "refineFinalGraph": "true", - "feature_set": "[NVQ_VECTORS, FUSED_ASH]", - "build_compressor": "\"AsymmetricHashing[origDim=3072, encodedBits=6184, bodyBits=6144, quantizedDim=1536, bitsPerDimension=4, optimizer=ITQ, landmarks=1]\"", - "search_compressor": "None", - "usePruning": "false", - "topK": "10", - "overquery": "1.0", - "rerankK": "10", - "search.throughput.avg_qps": "1026.4376092178372", - "search.throughput.stddev_qps": "52.22220368595892", - "search.throughput.cv_pct": "5.087713390174111", - "search.count.avg_visited": "512.685", - "search.accuracy.recall_at_10": "0.7903", - "construction.index_build_time_s": "", - "construction.index_quant_time_s.ASH.compute_time_s": "", - "construction.index_quant_time_s.ASH.encoding_time_s": "", - "construction.index_quant_time_s.NVQ.compute_time_s": "" - }, - { - "schema_version": "1", - "run_id": "jdk22-openai-3072-1m-4bit-ash-after-t1-after-t1-20261029-061757Z", - "run_uuid": "ef1184fc-e6ae-44f8-a775-95d42aeb1989", - "system_id": "1d71c58acd06", - "dataset_name": "openai-3072-1m", - "M": "32", - "efConstruction": "100", - "neighborOverflow": "1.2", - "addHierarchy": "true", - "refineFinalGraph": "true", - "feature_set": "[NVQ_VECTORS, FUSED_ASH]", - "build_compressor": "\"AsymmetricHashing[origDim=3072, encodedBits=6184, bodyBits=6144, quantizedDim=1536, bitsPerDimension=4, optimizer=ITQ, landmarks=1]\"", - "search_compressor": "None", - "usePruning": "false", - "topK": "10", - "overquery": "2.0", - "rerankK": "20", - "search.throughput.avg_qps": "1046.101981421671", - "search.throughput.stddev_qps": "10.317645396090365", - "search.throughput.cv_pct": "0.9862944129088163", - "search.count.avg_visited": "735.241", - "search.accuracy.recall_at_10": "0.8834", - "construction.index_build_time_s": "", - "construction.index_quant_time_s.ASH.compute_time_s": "", - "construction.index_quant_time_s.ASH.encoding_time_s": "", - "construction.index_quant_time_s.NVQ.compute_time_s": "" - } - ], - "log": "/home/ted_willke/benchmark-runs/ash-native-projection-symmetric-20261029/logs/jdk22-openai-3072-1m-4bit-ash-after-t1-after-t1.log", - "finished": "2026-10-29T06:21:11.642375+00:00" - }, - { - "label": "jdk22-openai-3072-1m-4bit-ash-before-t1-before-t1", - "jdk": "22", - "version": "before", - "threads": 1, - "build": false, - "elapsed_process_seconds": 78.98988528177142, - "rows": [ - { - "schema_version": "1", - "run_id": "jdk22-openai-3072-1m-4bit-ash-before-t1-before-t1-20261029-062112Z", - "run_uuid": "7dc810d9-479b-4df9-8be5-08b5f98553a9", - "system_id": "1d71c58acd06", - "dataset_name": "openai-3072-1m", - "M": "32", - "efConstruction": "100", - "neighborOverflow": "1.2", - "addHierarchy": "true", - "refineFinalGraph": "true", - "feature_set": "[NVQ_VECTORS, FUSED_ASH]", - "build_compressor": "\"AsymmetricHashing[origDim=3072, encodedBits=6184, bodyBits=6144, quantizedDim=1536, bitsPerDimension=4, optimizer=ITQ, landmarks=1]\"", - "search_compressor": "None", - "usePruning": "false", - "topK": "10", - "overquery": "1.0", - "rerankK": "10", - "search.throughput.avg_qps": "1024.6329990152383", - "search.throughput.stddev_qps": "41.46105709421377", - "search.throughput.cv_pct": "4.046430003138828", - "search.count.avg_visited": "512.685", - "search.accuracy.recall_at_10": "0.7903", - "construction.index_build_time_s": "", - "construction.index_quant_time_s.ASH.compute_time_s": "", - "construction.index_quant_time_s.ASH.encoding_time_s": "", - "construction.index_quant_time_s.NVQ.compute_time_s": "" - }, - { - "schema_version": "1", - "run_id": "jdk22-openai-3072-1m-4bit-ash-before-t1-before-t1-20261029-062112Z", - "run_uuid": "7dc810d9-479b-4df9-8be5-08b5f98553a9", - "system_id": "1d71c58acd06", - "dataset_name": "openai-3072-1m", - "M": "32", - "efConstruction": "100", - "neighborOverflow": "1.2", - "addHierarchy": "true", - "refineFinalGraph": "true", - "feature_set": "[NVQ_VECTORS, FUSED_ASH]", - "build_compressor": "\"AsymmetricHashing[origDim=3072, encodedBits=6184, bodyBits=6144, quantizedDim=1536, bitsPerDimension=4, optimizer=ITQ, landmarks=1]\"", - "search_compressor": "None", - "usePruning": "false", - "topK": "10", - "overquery": "2.0", - "rerankK": "20", - "search.throughput.avg_qps": "1030.0348655106716", - "search.throughput.stddev_qps": "11.683325704046414", - "search.throughput.cv_pct": "1.1342650715278502", - "search.count.avg_visited": "735.241", - "search.accuracy.recall_at_10": "0.8834", - "construction.index_build_time_s": "", - "construction.index_quant_time_s.ASH.compute_time_s": "", - "construction.index_quant_time_s.ASH.encoding_time_s": "", - "construction.index_quant_time_s.NVQ.compute_time_s": "" - } - ], - "log": "/home/ted_willke/benchmark-runs/ash-native-projection-symmetric-20261029/logs/jdk22-openai-3072-1m-4bit-ash-before-t1-before-t1.log", - "finished": "2026-10-29T06:22:30.634271+00:00" - }, - { - "label": "jdk22-openai-3072-1m-4bit-ash-before-t8-before-t8", - "jdk": "22", - "version": "before", - "threads": 8, - "build": false, - "elapsed_process_seconds": 26.530268570408225, - "rows": [ - { - "schema_version": "1", - "run_id": "jdk22-openai-3072-1m-4bit-ash-before-t8-before-t8-20261029-062231Z", - "run_uuid": "95fbca89-054e-492f-9033-345d64fad487", - "system_id": "1d71c58acd06", - "dataset_name": "openai-3072-1m", - "M": "32", - "efConstruction": "100", - "neighborOverflow": "1.2", - "addHierarchy": "true", - "refineFinalGraph": "true", - "feature_set": "[NVQ_VECTORS, FUSED_ASH]", - "build_compressor": "\"AsymmetricHashing[origDim=3072, encodedBits=6184, bodyBits=6144, quantizedDim=1536, bitsPerDimension=4, optimizer=ITQ, landmarks=1]\"", - "search_compressor": "None", - "usePruning": "false", - "topK": "10", - "overquery": "1.0", - "rerankK": "10", - "search.throughput.avg_qps": "7741.458630781505", - "search.throughput.stddev_qps": "345.95153394119274", - "search.throughput.cv_pct": "4.468815896859849", - "search.count.avg_visited": "512.685", - "search.accuracy.recall_at_10": "0.7903", - "construction.index_build_time_s": "", - "construction.index_quant_time_s.ASH.compute_time_s": "", - "construction.index_quant_time_s.ASH.encoding_time_s": "", - "construction.index_quant_time_s.NVQ.compute_time_s": "" - }, - { - "schema_version": "1", - "run_id": "jdk22-openai-3072-1m-4bit-ash-before-t8-before-t8-20261029-062231Z", - "run_uuid": "95fbca89-054e-492f-9033-345d64fad487", - "system_id": "1d71c58acd06", - "dataset_name": "openai-3072-1m", - "M": "32", - "efConstruction": "100", - "neighborOverflow": "1.2", - "addHierarchy": "true", - "refineFinalGraph": "true", - "feature_set": "[NVQ_VECTORS, FUSED_ASH]", - "build_compressor": "\"AsymmetricHashing[origDim=3072, encodedBits=6184, bodyBits=6144, quantizedDim=1536, bitsPerDimension=4, optimizer=ITQ, landmarks=1]\"", - "search_compressor": "None", - "usePruning": "false", - "topK": "10", - "overquery": "2.0", - "rerankK": "20", - "search.throughput.avg_qps": "7240.167989195661", - "search.throughput.stddev_qps": "41.23061095317257", - "search.throughput.cv_pct": "0.569470363321681", - "search.count.avg_visited": "735.241", - "search.accuracy.recall_at_10": "0.8834", - "construction.index_build_time_s": "", - "construction.index_quant_time_s.ASH.compute_time_s": "", - "construction.index_quant_time_s.ASH.encoding_time_s": "", - "construction.index_quant_time_s.NVQ.compute_time_s": "" - } - ], - "log": "/home/ted_willke/benchmark-runs/ash-native-projection-symmetric-20261029/logs/jdk22-openai-3072-1m-4bit-ash-before-t8-before-t8.log", - "finished": "2026-10-29T06:22:57.166623+00:00" - }, - { - "label": "jdk22-openai-3072-1m-4bit-ash-after-t8-after-t8", - "jdk": "22", - "version": "after", - "threads": 8, - "build": false, - "elapsed_process_seconds": 25.67743838299066, - "rows": [ - { - "schema_version": "1", - "run_id": "jdk22-openai-3072-1m-4bit-ash-after-t8-after-t8-20261029-062257Z", - "run_uuid": "fb46aace-439c-4218-a2f5-433394429de4", - "system_id": "1d71c58acd06", - "dataset_name": "openai-3072-1m", - "M": "32", - "efConstruction": "100", - "neighborOverflow": "1.2", - "addHierarchy": "true", - "refineFinalGraph": "true", - "feature_set": "[NVQ_VECTORS, FUSED_ASH]", - "build_compressor": "\"AsymmetricHashing[origDim=3072, encodedBits=6184, bodyBits=6144, quantizedDim=1536, bitsPerDimension=4, optimizer=ITQ, landmarks=1]\"", - "search_compressor": "None", - "usePruning": "false", - "topK": "10", - "overquery": "1.0", - "rerankK": "10", - "search.throughput.avg_qps": "8258.710248857857", - "search.throughput.stddev_qps": "281.17322117292355", - "search.throughput.cv_pct": "3.4045657578531534", - "search.count.avg_visited": "512.685", - "search.accuracy.recall_at_10": "0.7903", - "construction.index_build_time_s": "", - "construction.index_quant_time_s.ASH.compute_time_s": "", - "construction.index_quant_time_s.ASH.encoding_time_s": "", - "construction.index_quant_time_s.NVQ.compute_time_s": "" - }, - { - "schema_version": "1", - "run_id": "jdk22-openai-3072-1m-4bit-ash-after-t8-after-t8-20261029-062257Z", - "run_uuid": "fb46aace-439c-4218-a2f5-433394429de4", - "system_id": "1d71c58acd06", - "dataset_name": "openai-3072-1m", - "M": "32", - "efConstruction": "100", - "neighborOverflow": "1.2", - "addHierarchy": "true", - "refineFinalGraph": "true", - "feature_set": "[NVQ_VECTORS, FUSED_ASH]", - "build_compressor": "\"AsymmetricHashing[origDim=3072, encodedBits=6184, bodyBits=6144, quantizedDim=1536, bitsPerDimension=4, optimizer=ITQ, landmarks=1]\"", - "search_compressor": "None", - "usePruning": "false", - "topK": "10", - "overquery": "2.0", - "rerankK": "20", - "search.throughput.avg_qps": "7926.960479696699", - "search.throughput.stddev_qps": "186.07529515413572", - "search.throughput.cv_pct": "2.347372560147485", - "search.count.avg_visited": "735.241", - "search.accuracy.recall_at_10": "0.8834", - "construction.index_build_time_s": "", - "construction.index_quant_time_s.ASH.compute_time_s": "", - "construction.index_quant_time_s.ASH.encoding_time_s": "", - "construction.index_quant_time_s.NVQ.compute_time_s": "" - } - ], - "log": "/home/ted_willke/benchmark-runs/ash-native-projection-symmetric-20261029/logs/jdk22-openai-3072-1m-4bit-ash-after-t8-after-t8.log", - "finished": "2026-10-29T06:23:22.846942+00:00" - }, - { - "label": "jdk22-openai-3072-1m-4bit-ash-after-build", - "jdk": "22", - "version": "after", - "threads": 1, - "build": true, - "elapsed_process_seconds": 185.2930998262018, - "rows": [ - { - "schema_version": "1", - "run_id": "jdk22-openai-3072-1m-4bit-ash-after-build-20261029-062323Z", - "run_uuid": "8be0513a-d30c-4b12-a36f-23c179448814", - "system_id": "1d71c58acd06", - "dataset_name": "openai-3072-1m", - "M": "32", - "efConstruction": "100", - "neighborOverflow": "1.2", - "addHierarchy": "true", - "refineFinalGraph": "true", - "feature_set": "[NVQ_VECTORS, FUSED_ASH]", - "build_compressor": "\"AsymmetricHashing[origDim=3072, encodedBits=6184, bodyBits=6144, quantizedDim=1536, bitsPerDimension=4, optimizer=ITQ, landmarks=1]\"", - "search_compressor": "None", - "usePruning": "false", - "topK": "10", - "overquery": "1.0", - "rerankK": "10", - "search.accuracy.recall_at_10": "0.7896", - "construction.index_build_time_s": "101.919147369", - "construction.index_quant_time_s.ASH.compute_time_s": "38.086685724", - "construction.index_quant_time_s.ASH.encoding_time_s": "25.959572952", - "construction.index_quant_time_s.NVQ.compute_time_s": "1.164680616" - } - ], - "log": "/home/ted_willke/benchmark-runs/ash-native-projection-symmetric-20261029/logs/jdk22-openai-3072-1m-4bit-ash-after-build.log", - "finished": "2026-10-29T06:26:28.142317+00:00" - }, - { - "label": "jdk25-mpnet-1m-2bit-ash-before-t1-before-t1", - "jdk": "25", - "version": "before", - "threads": 1, - "build": false, - "elapsed_process_seconds": 298.95462897233665, - "rows": [ - { - "schema_version": "1", - "run_id": "jdk25-mpnet-1m-2bit-ash-before-t1-before-t1-20261029-062629Z", - "run_uuid": "b65573b2-735c-4885-b2a2-ced0a607f8a1", - "system_id": "99c688318adc", - "dataset_name": "mpnet-1m", - "M": "32", - "efConstruction": "100", - "neighborOverflow": "1.2", - "addHierarchy": "true", - "refineFinalGraph": "true", - "feature_set": "[NVQ_VECTORS, FUSED_ASH]", - "build_compressor": "\"AsymmetricHashing[origDim=768, encodedBits=808, bodyBits=768, quantizedDim=384, bitsPerDimension=2, optimizer=ITQ, landmarks=1]\"", - "search_compressor": "None", - "usePruning": "false", - "topK": "10", - "overquery": "1.0", - "rerankK": "10", - "search.throughput.avg_qps": "10441.19054756948", - "search.throughput.stddev_qps": "52.66608715767722", - "search.throughput.cv_pct": "0.5044069152625217", - "search.count.avg_visited": "455.8008", - "search.accuracy.recall_at_10": "0.82017", - "construction.index_build_time_s": "", - "construction.index_quant_time_s.ASH.compute_time_s": "", - "construction.index_quant_time_s.ASH.encoding_time_s": "", - "construction.index_quant_time_s.NVQ.compute_time_s": "" - }, - { - "schema_version": "1", - "run_id": "jdk25-mpnet-1m-2bit-ash-before-t1-before-t1-20261029-062629Z", - "run_uuid": "b65573b2-735c-4885-b2a2-ced0a607f8a1", - "system_id": "99c688318adc", - "dataset_name": "mpnet-1m", - "M": "32", - "efConstruction": "100", - "neighborOverflow": "1.2", - "addHierarchy": "true", - "refineFinalGraph": "true", - "feature_set": "[NVQ_VECTORS, FUSED_ASH]", - "build_compressor": "\"AsymmetricHashing[origDim=768, encodedBits=808, bodyBits=768, quantizedDim=384, bitsPerDimension=2, optimizer=ITQ, landmarks=1]\"", - "search_compressor": "None", - "usePruning": "false", - "topK": "10", - "overquery": "2.0", - "rerankK": "20", - "search.throughput.avg_qps": "8293.758257315945", - "search.throughput.stddev_qps": "75.02484601764363", - "search.throughput.cv_pct": "0.90459407773869", - "search.count.avg_visited": "636.613", - "search.accuracy.recall_at_10": "0.93971", - "construction.index_build_time_s": "", - "construction.index_quant_time_s.ASH.compute_time_s": "", - "construction.index_quant_time_s.ASH.encoding_time_s": "", - "construction.index_quant_time_s.NVQ.compute_time_s": "" - } - ], - "log": "/home/ted_willke/benchmark-runs/ash-native-projection-symmetric-20261029/logs/jdk25-mpnet-1m-2bit-ash-before-t1-before-t1.log", - "finished": "2026-10-29T06:31:27.100510+00:00" - }, - { - "label": "jdk25-mpnet-1m-2bit-ash-after-t1-after-t1", - "jdk": "25", - "version": "after", - "threads": 1, - "build": false, - "elapsed_process_seconds": 50.58799839951098, - "rows": [ - { - "schema_version": "1", - "run_id": "jdk25-mpnet-1m-2bit-ash-after-t1-after-t1-20261029-063128Z", - "run_uuid": "1c82d155-9879-437c-9003-c9c4199fcf2e", - "system_id": "99c688318adc", - "dataset_name": "mpnet-1m", - "M": "32", - "efConstruction": "100", - "neighborOverflow": "1.2", - "addHierarchy": "true", - "refineFinalGraph": "true", - "feature_set": "[NVQ_VECTORS, FUSED_ASH]", - "build_compressor": "\"AsymmetricHashing[origDim=768, encodedBits=808, bodyBits=768, quantizedDim=384, bitsPerDimension=2, optimizer=ITQ, landmarks=1]\"", - "search_compressor": "None", - "usePruning": "false", - "topK": "10", - "overquery": "1.0", - "rerankK": "10", - "search.throughput.avg_qps": "12117.702889285703", - "search.throughput.stddev_qps": "187.01722788412533", - "search.throughput.cv_pct": "1.543338944623599", - "search.count.avg_visited": "455.8008", - "search.accuracy.recall_at_10": "0.82017", - "construction.index_build_time_s": "", - "construction.index_quant_time_s.ASH.compute_time_s": "", - "construction.index_quant_time_s.ASH.encoding_time_s": "", - "construction.index_quant_time_s.NVQ.compute_time_s": "" - }, - { - "schema_version": "1", - "run_id": "jdk25-mpnet-1m-2bit-ash-after-t1-after-t1-20261029-063128Z", - "run_uuid": "1c82d155-9879-437c-9003-c9c4199fcf2e", - "system_id": "99c688318adc", - "dataset_name": "mpnet-1m", - "M": "32", - "efConstruction": "100", - "neighborOverflow": "1.2", - "addHierarchy": "true", - "refineFinalGraph": "true", - "feature_set": "[NVQ_VECTORS, FUSED_ASH]", - "build_compressor": "\"AsymmetricHashing[origDim=768, encodedBits=808, bodyBits=768, quantizedDim=384, bitsPerDimension=2, optimizer=ITQ, landmarks=1]\"", - "search_compressor": "None", - "usePruning": "false", - "topK": "10", - "overquery": "2.0", - "rerankK": "20", - "search.throughput.avg_qps": "9286.185025696477", - "search.throughput.stddev_qps": "23.241531920981814", - "search.throughput.cv_pct": "0.25028073268698053", - "search.count.avg_visited": "636.613", - "search.accuracy.recall_at_10": "0.93971", - "construction.index_build_time_s": "", - "construction.index_quant_time_s.ASH.compute_time_s": "", - "construction.index_quant_time_s.ASH.encoding_time_s": "", - "construction.index_quant_time_s.NVQ.compute_time_s": "" - } - ], - "log": "/home/ted_willke/benchmark-runs/ash-native-projection-symmetric-20261029/logs/jdk25-mpnet-1m-2bit-ash-after-t1-after-t1.log", - "finished": "2026-10-29T06:32:17.690674+00:00" - }, - { - "label": "jdk25-mpnet-1m-2bit-ash-after-t8-after-t8", - "jdk": "25", - "version": "after", - "threads": 8, - "build": false, - "elapsed_process_seconds": 18.72387741226703, - "rows": [ - { - "schema_version": "1", - "run_id": "jdk25-mpnet-1m-2bit-ash-after-t8-after-t8-20261029-063218Z", - "run_uuid": "67999814-171c-42bf-b7d2-5241e9055411", - "system_id": "99c688318adc", - "dataset_name": "mpnet-1m", - "M": "32", - "efConstruction": "100", - "neighborOverflow": "1.2", - "addHierarchy": "true", - "refineFinalGraph": "true", - "feature_set": "[NVQ_VECTORS, FUSED_ASH]", - "build_compressor": "\"AsymmetricHashing[origDim=768, encodedBits=808, bodyBits=768, quantizedDim=384, bitsPerDimension=2, optimizer=ITQ, landmarks=1]\"", - "search_compressor": "None", - "usePruning": "false", - "topK": "10", - "overquery": "1.0", - "rerankK": "10", - "search.throughput.avg_qps": "83549.5892088437", - "search.throughput.stddev_qps": "3589.869381486101", - "search.throughput.cv_pct": "4.296693036410662", - "search.count.avg_visited": "455.8008", - "search.accuracy.recall_at_10": "0.82017", - "construction.index_build_time_s": "", - "construction.index_quant_time_s.ASH.compute_time_s": "", - "construction.index_quant_time_s.ASH.encoding_time_s": "", - "construction.index_quant_time_s.NVQ.compute_time_s": "" - }, - { - "schema_version": "1", - "run_id": "jdk25-mpnet-1m-2bit-ash-after-t8-after-t8-20261029-063218Z", - "run_uuid": "67999814-171c-42bf-b7d2-5241e9055411", - "system_id": "99c688318adc", - "dataset_name": "mpnet-1m", - "M": "32", - "efConstruction": "100", - "neighborOverflow": "1.2", - "addHierarchy": "true", - "refineFinalGraph": "true", - "feature_set": "[NVQ_VECTORS, FUSED_ASH]", - "build_compressor": "\"AsymmetricHashing[origDim=768, encodedBits=808, bodyBits=768, quantizedDim=384, bitsPerDimension=2, optimizer=ITQ, landmarks=1]\"", - "search_compressor": "None", - "usePruning": "false", - "topK": "10", - "overquery": "2.0", - "rerankK": "20", - "search.throughput.avg_qps": "69337.75348751461", - "search.throughput.stddev_qps": "630.6648601224889", - "search.throughput.cv_pct": "0.909554792882135", - "search.count.avg_visited": "636.613", - "search.accuracy.recall_at_10": "0.93971", - "construction.index_build_time_s": "", - "construction.index_quant_time_s.ASH.compute_time_s": "", - "construction.index_quant_time_s.ASH.encoding_time_s": "", - "construction.index_quant_time_s.NVQ.compute_time_s": "" - } - ], - "log": "/home/ted_willke/benchmark-runs/ash-native-projection-symmetric-20261029/logs/jdk25-mpnet-1m-2bit-ash-after-t8-after-t8.log", - "finished": "2026-10-29T06:32:36.416722+00:00" - }, - { - "label": "jdk25-mpnet-1m-2bit-ash-before-t8-before-t8", - "jdk": "25", - "version": "before", - "threads": 8, - "build": false, - "elapsed_process_seconds": 19.61476370319724, - "rows": [ - { - "schema_version": "1", - "run_id": "jdk25-mpnet-1m-2bit-ash-before-t8-before-t8-20261029-063236Z", - "run_uuid": "de74c72c-244f-4704-9883-20f883957df8", - "system_id": "99c688318adc", - "dataset_name": "mpnet-1m", - "M": "32", - "efConstruction": "100", - "neighborOverflow": "1.2", - "addHierarchy": "true", - "refineFinalGraph": "true", - "feature_set": "[NVQ_VECTORS, FUSED_ASH]", - "build_compressor": "\"AsymmetricHashing[origDim=768, encodedBits=808, bodyBits=768, quantizedDim=384, bitsPerDimension=2, optimizer=ITQ, landmarks=1]\"", - "search_compressor": "None", - "usePruning": "false", - "topK": "10", - "overquery": "1.0", - "rerankK": "10", - "search.throughput.avg_qps": "76547.2022487084", - "search.throughput.stddev_qps": "1687.0173069862165", - "search.throughput.cv_pct": "2.203891530228568", - "search.count.avg_visited": "455.8008", - "search.accuracy.recall_at_10": "0.82017", - "construction.index_build_time_s": "", - "construction.index_quant_time_s.ASH.compute_time_s": "", - "construction.index_quant_time_s.ASH.encoding_time_s": "", - "construction.index_quant_time_s.NVQ.compute_time_s": "" - }, - { - "schema_version": "1", - "run_id": "jdk25-mpnet-1m-2bit-ash-before-t8-before-t8-20261029-063236Z", - "run_uuid": "de74c72c-244f-4704-9883-20f883957df8", - "system_id": "99c688318adc", - "dataset_name": "mpnet-1m", - "M": "32", - "efConstruction": "100", - "neighborOverflow": "1.2", - "addHierarchy": "true", - "refineFinalGraph": "true", - "feature_set": "[NVQ_VECTORS, FUSED_ASH]", - "build_compressor": "\"AsymmetricHashing[origDim=768, encodedBits=808, bodyBits=768, quantizedDim=384, bitsPerDimension=2, optimizer=ITQ, landmarks=1]\"", - "search_compressor": "None", - "usePruning": "false", - "topK": "10", - "overquery": "2.0", - "rerankK": "20", - "search.throughput.avg_qps": "61781.41999991404", - "search.throughput.stddev_qps": "1537.5499525945193", - "search.throughput.cv_pct": "2.4886931258567033", - "search.count.avg_visited": "636.613", - "search.accuracy.recall_at_10": "0.93971", - "construction.index_build_time_s": "", - "construction.index_quant_time_s.ASH.compute_time_s": "", - "construction.index_quant_time_s.ASH.encoding_time_s": "", - "construction.index_quant_time_s.NVQ.compute_time_s": "" - } - ], - "log": "/home/ted_willke/benchmark-runs/ash-native-projection-symmetric-20261029/logs/jdk25-mpnet-1m-2bit-ash-before-t8-before-t8.log", - "finished": "2026-10-29T06:32:56.034022+00:00" - }, - { - "label": "jdk25-mpnet-1m-2bit-ash-after-build", - "jdk": "25", - "version": "after", - "threads": 1, - "build": true, - "elapsed_process_seconds": 46.53267274983227, - "rows": [ - { - "schema_version": "1", - "run_id": "jdk25-mpnet-1m-2bit-ash-after-build-20261029-063256Z", - "run_uuid": "bdb3994e-9165-4e44-a660-4cc0e8706745", - "system_id": "99c688318adc", - "dataset_name": "mpnet-1m", - "M": "32", - "efConstruction": "100", - "neighborOverflow": "1.2", - "addHierarchy": "true", - "refineFinalGraph": "true", - "feature_set": "[NVQ_VECTORS, FUSED_ASH]", - "build_compressor": "\"AsymmetricHashing[origDim=768, encodedBits=808, bodyBits=768, quantizedDim=384, bitsPerDimension=2, optimizer=ITQ, landmarks=1]\"", - "search_compressor": "None", - "usePruning": "false", - "topK": "10", - "overquery": "1.0", - "rerankK": "10", - "search.accuracy.recall_at_10": "0.81633", - "construction.index_build_time_s": "34.225727643", - "construction.index_quant_time_s.ASH.compute_time_s": "5.328078086", - "construction.index_quant_time_s.ASH.encoding_time_s": "0.952869518", - "construction.index_quant_time_s.NVQ.compute_time_s": "0.307421481" - } - ], - "log": "/home/ted_willke/benchmark-runs/ash-native-projection-symmetric-20261029/logs/jdk25-mpnet-1m-2bit-ash-after-build.log", - "finished": "2026-10-29T06:33:42.568817+00:00" - }, - { - "label": "jdk25-openai-3072-1m-4bit-ash-after-t1-after-t1", - "jdk": "25", - "version": "after", - "threads": 1, - "build": false, - "elapsed_process_seconds": 72.91965845320374, - "rows": [ - { - "schema_version": "1", - "run_id": "jdk25-openai-3072-1m-4bit-ash-after-t1-after-t1-20261029-063343Z", - "run_uuid": "a2ac1bf0-43ce-4124-a7a4-bca7b4f2046b", - "system_id": "99c688318adc", - "dataset_name": "openai-3072-1m", - "M": "32", - "efConstruction": "100", - "neighborOverflow": "1.2", - "addHierarchy": "true", - "refineFinalGraph": "true", - "feature_set": "[NVQ_VECTORS, FUSED_ASH]", - "build_compressor": "\"AsymmetricHashing[origDim=3072, encodedBits=6184, bodyBits=6144, quantizedDim=1536, bitsPerDimension=4, optimizer=ITQ, landmarks=1]\"", - "search_compressor": "None", - "usePruning": "false", - "topK": "10", - "overquery": "1.0", - "rerankK": "10", - "search.throughput.avg_qps": "1011.9147946874974", - "search.throughput.stddev_qps": "77.93195987838018", - "search.throughput.cv_pct": "7.701434971355209", - "search.count.avg_visited": "510.697", - "search.accuracy.recall_at_10": "0.7965", - "construction.index_build_time_s": "", - "construction.index_quant_time_s.ASH.compute_time_s": "", - "construction.index_quant_time_s.ASH.encoding_time_s": "", - "construction.index_quant_time_s.NVQ.compute_time_s": "" - }, - { - "schema_version": "1", - "run_id": "jdk25-openai-3072-1m-4bit-ash-after-t1-after-t1-20261029-063343Z", - "run_uuid": "a2ac1bf0-43ce-4124-a7a4-bca7b4f2046b", - "system_id": "99c688318adc", - "dataset_name": "openai-3072-1m", - "M": "32", - "efConstruction": "100", - "neighborOverflow": "1.2", - "addHierarchy": "true", - "refineFinalGraph": "true", - "feature_set": "[NVQ_VECTORS, FUSED_ASH]", - "build_compressor": "\"AsymmetricHashing[origDim=3072, encodedBits=6184, bodyBits=6144, quantizedDim=1536, bitsPerDimension=4, optimizer=ITQ, landmarks=1]\"", - "search_compressor": "None", - "usePruning": "false", - "topK": "10", - "overquery": "2.0", - "rerankK": "20", - "search.throughput.avg_qps": "1054.0483629342123", - "search.throughput.stddev_qps": "9.48209551081115", - "search.throughput.cv_pct": "0.89958827737423", - "search.count.avg_visited": "730.922", - "search.accuracy.recall_at_10": "0.8875", - "construction.index_build_time_s": "", - "construction.index_quant_time_s.ASH.compute_time_s": "", - "construction.index_quant_time_s.ASH.encoding_time_s": "", - "construction.index_quant_time_s.NVQ.compute_time_s": "" - } - ], - "log": "/home/ted_willke/benchmark-runs/ash-native-projection-symmetric-20261029/logs/jdk25-openai-3072-1m-4bit-ash-after-t1-after-t1.log", - "finished": "2026-10-29T06:34:55.492583+00:00" - }, - { - "label": "jdk25-openai-3072-1m-4bit-ash-before-t1-before-t1", - "jdk": "25", - "version": "before", - "threads": 1, - "build": false, - "elapsed_process_seconds": 65.1370682939887, - "rows": [ - { - "schema_version": "1", - "run_id": "jdk25-openai-3072-1m-4bit-ash-before-t1-before-t1-20261029-063456Z", - "run_uuid": "26acb068-3141-40bc-a8f4-19b859da5a0a", - "system_id": "99c688318adc", - "dataset_name": "openai-3072-1m", - "M": "32", - "efConstruction": "100", - "neighborOverflow": "1.2", - "addHierarchy": "true", - "refineFinalGraph": "true", - "feature_set": "[NVQ_VECTORS, FUSED_ASH]", - "build_compressor": "\"AsymmetricHashing[origDim=3072, encodedBits=6184, bodyBits=6144, quantizedDim=1536, bitsPerDimension=4, optimizer=ITQ, landmarks=1]\"", - "search_compressor": "None", - "usePruning": "false", - "topK": "10", - "overquery": "1.0", - "rerankK": "10", - "search.throughput.avg_qps": "1062.2402591845162", - "search.throughput.stddev_qps": "54.081084529394104", - "search.throughput.cv_pct": "5.091229038043828", - "search.count.avg_visited": "510.697", - "search.accuracy.recall_at_10": "0.7965", - "construction.index_build_time_s": "", - "construction.index_quant_time_s.ASH.compute_time_s": "", - "construction.index_quant_time_s.ASH.encoding_time_s": "", - "construction.index_quant_time_s.NVQ.compute_time_s": "" - }, - { - "schema_version": "1", - "run_id": "jdk25-openai-3072-1m-4bit-ash-before-t1-before-t1-20261029-063456Z", - "run_uuid": "26acb068-3141-40bc-a8f4-19b859da5a0a", - "system_id": "99c688318adc", - "dataset_name": "openai-3072-1m", - "M": "32", - "efConstruction": "100", - "neighborOverflow": "1.2", - "addHierarchy": "true", - "refineFinalGraph": "true", - "feature_set": "[NVQ_VECTORS, FUSED_ASH]", - "build_compressor": "\"AsymmetricHashing[origDim=3072, encodedBits=6184, bodyBits=6144, quantizedDim=1536, bitsPerDimension=4, optimizer=ITQ, landmarks=1]\"", - "search_compressor": "None", - "usePruning": "false", - "topK": "10", - "overquery": "2.0", - "rerankK": "20", - "search.throughput.avg_qps": "1040.2391399169903", - "search.throughput.stddev_qps": "11.817906493765921", - "search.throughput.cv_pct": "1.136075931031491", - "search.count.avg_visited": "730.922", - "search.accuracy.recall_at_10": "0.8875", - "construction.index_build_time_s": "", - "construction.index_quant_time_s.ASH.compute_time_s": "", - "construction.index_quant_time_s.ASH.encoding_time_s": "", - "construction.index_quant_time_s.NVQ.compute_time_s": "" - } - ], - "log": "/home/ted_willke/benchmark-runs/ash-native-projection-symmetric-20261029/logs/jdk25-openai-3072-1m-4bit-ash-before-t1-before-t1.log", - "finished": "2026-10-29T06:36:00.631935+00:00" - }, - { - "label": "jdk25-openai-3072-1m-4bit-ash-before-t8-before-t8", - "jdk": "25", - "version": "before", - "threads": 8, - "build": false, - "elapsed_process_seconds": 25.41133080702275, - "rows": [ - { - "schema_version": "1", - "run_id": "jdk25-openai-3072-1m-4bit-ash-before-t8-before-t8-20261029-063601Z", - "run_uuid": "5ca5a2ff-fca7-4b6f-aa23-4f6277569709", - "system_id": "99c688318adc", - "dataset_name": "openai-3072-1m", - "M": "32", - "efConstruction": "100", - "neighborOverflow": "1.2", - "addHierarchy": "true", - "refineFinalGraph": "true", - "feature_set": "[NVQ_VECTORS, FUSED_ASH]", - "build_compressor": "\"AsymmetricHashing[origDim=3072, encodedBits=6184, bodyBits=6144, quantizedDim=1536, bitsPerDimension=4, optimizer=ITQ, landmarks=1]\"", - "search_compressor": "None", - "usePruning": "false", - "topK": "10", - "overquery": "1.0", - "rerankK": "10", - "search.throughput.avg_qps": "7843.681782186251", - "search.throughput.stddev_qps": "249.6000776178493", - "search.throughput.cv_pct": "3.1821800596846606", - "search.count.avg_visited": "510.697", - "search.accuracy.recall_at_10": "0.7965", - "construction.index_build_time_s": "", - "construction.index_quant_time_s.ASH.compute_time_s": "", - "construction.index_quant_time_s.ASH.encoding_time_s": "", - "construction.index_quant_time_s.NVQ.compute_time_s": "" - }, - { - "schema_version": "1", - "run_id": "jdk25-openai-3072-1m-4bit-ash-before-t8-before-t8-20261029-063601Z", - "run_uuid": "5ca5a2ff-fca7-4b6f-aa23-4f6277569709", - "system_id": "99c688318adc", - "dataset_name": "openai-3072-1m", - "M": "32", - "efConstruction": "100", - "neighborOverflow": "1.2", - "addHierarchy": "true", - "refineFinalGraph": "true", - "feature_set": "[NVQ_VECTORS, FUSED_ASH]", - "build_compressor": "\"AsymmetricHashing[origDim=3072, encodedBits=6184, bodyBits=6144, quantizedDim=1536, bitsPerDimension=4, optimizer=ITQ, landmarks=1]\"", - "search_compressor": "None", - "usePruning": "false", - "topK": "10", - "overquery": "2.0", - "rerankK": "20", - "search.throughput.avg_qps": "7463.83544647903", - "search.throughput.stddev_qps": "40.81690243508421", - "search.throughput.cv_pct": "0.5468623032724961", - "search.count.avg_visited": "730.922", - "search.accuracy.recall_at_10": "0.8875", - "construction.index_build_time_s": "", - "construction.index_quant_time_s.ASH.compute_time_s": "", - "construction.index_quant_time_s.ASH.encoding_time_s": "", - "construction.index_quant_time_s.NVQ.compute_time_s": "" - } - ], - "log": "/home/ted_willke/benchmark-runs/ash-native-projection-symmetric-20261029/logs/jdk25-openai-3072-1m-4bit-ash-before-t8-before-t8.log", - "finished": "2026-10-29T06:36:26.046141+00:00" - }, - { - "label": "jdk25-openai-3072-1m-4bit-ash-after-t8-after-t8", - "jdk": "25", - "version": "after", - "threads": 8, - "build": false, - "elapsed_process_seconds": 24.735586038790643, - "rows": [ - { - "schema_version": "1", - "run_id": "jdk25-openai-3072-1m-4bit-ash-after-t8-after-t8-20261029-063626Z", - "run_uuid": "8a406829-e956-43e9-9bd4-864ac2120474", - "system_id": "99c688318adc", - "dataset_name": "openai-3072-1m", - "M": "32", - "efConstruction": "100", - "neighborOverflow": "1.2", - "addHierarchy": "true", - "refineFinalGraph": "true", - "feature_set": "[NVQ_VECTORS, FUSED_ASH]", - "build_compressor": "\"AsymmetricHashing[origDim=3072, encodedBits=6184, bodyBits=6144, quantizedDim=1536, bitsPerDimension=4, optimizer=ITQ, landmarks=1]\"", - "search_compressor": "None", - "usePruning": "false", - "topK": "10", - "overquery": "1.0", - "rerankK": "10", - "search.throughput.avg_qps": "8559.176543886948", - "search.throughput.stddev_qps": "155.5585871042546", - "search.throughput.cv_pct": "1.8174480489639642", - "search.count.avg_visited": "510.697", - "search.accuracy.recall_at_10": "0.7965", - "construction.index_build_time_s": "", - "construction.index_quant_time_s.ASH.compute_time_s": "", - "construction.index_quant_time_s.ASH.encoding_time_s": "", - "construction.index_quant_time_s.NVQ.compute_time_s": "" - }, - { - "schema_version": "1", - "run_id": "jdk25-openai-3072-1m-4bit-ash-after-t8-after-t8-20261029-063626Z", - "run_uuid": "8a406829-e956-43e9-9bd4-864ac2120474", - "system_id": "99c688318adc", - "dataset_name": "openai-3072-1m", - "M": "32", - "efConstruction": "100", - "neighborOverflow": "1.2", - "addHierarchy": "true", - "refineFinalGraph": "true", - "feature_set": "[NVQ_VECTORS, FUSED_ASH]", - "build_compressor": "\"AsymmetricHashing[origDim=3072, encodedBits=6184, bodyBits=6144, quantizedDim=1536, bitsPerDimension=4, optimizer=ITQ, landmarks=1]\"", - "search_compressor": "None", - "usePruning": "false", - "topK": "10", - "overquery": "2.0", - "rerankK": "20", - "search.throughput.avg_qps": "7983.474801021447", - "search.throughput.stddev_qps": "123.60378680657276", - "search.throughput.cv_pct": "1.5482454681357327", - "search.count.avg_visited": "730.922", - "search.accuracy.recall_at_10": "0.8875", - "construction.index_build_time_s": "", - "construction.index_quant_time_s.ASH.compute_time_s": "", - "construction.index_quant_time_s.ASH.encoding_time_s": "", - "construction.index_quant_time_s.NVQ.compute_time_s": "" - } - ], - "log": "/home/ted_willke/benchmark-runs/ash-native-projection-symmetric-20261029/logs/jdk25-openai-3072-1m-4bit-ash-after-t8-after-t8.log", - "finished": "2026-10-29T06:36:50.784113+00:00" - }, - { - "label": "jdk25-openai-3072-1m-4bit-ash-after-build", - "jdk": "25", - "version": "after", - "threads": 1, - "build": true, - "elapsed_process_seconds": 186.0093028023839, - "rows": [ - { - "schema_version": "1", - "run_id": "jdk25-openai-3072-1m-4bit-ash-after-build-20261029-063651Z", - "run_uuid": "0a542ca8-fcb1-4cca-99ed-7fb5525b7635", - "system_id": "99c688318adc", - "dataset_name": "openai-3072-1m", - "M": "32", - "efConstruction": "100", - "neighborOverflow": "1.2", - "addHierarchy": "true", - "refineFinalGraph": "true", - "feature_set": "[NVQ_VECTORS, FUSED_ASH]", - "build_compressor": "\"AsymmetricHashing[origDim=3072, encodedBits=6184, bodyBits=6144, quantizedDim=1536, bitsPerDimension=4, optimizer=ITQ, landmarks=1]\"", - "search_compressor": "None", - "usePruning": "false", - "topK": "10", - "overquery": "1.0", - "rerankK": "10", - "search.accuracy.recall_at_10": "0.787", - "construction.index_build_time_s": "106.236637281", - "construction.index_quant_time_s.ASH.compute_time_s": "36.395486952", - "construction.index_quant_time_s.ASH.encoding_time_s": "25.752220845", - "construction.index_quant_time_s.NVQ.compute_time_s": "1.235392413" - } - ], - "log": "/home/ted_willke/benchmark-runs/ash-native-projection-symmetric-20261029/logs/jdk25-openai-3072-1m-4bit-ash-after-build.log", - "finished": "2026-10-29T06:39:56.796531+00:00" - } -] diff --git a/docs/benchmarks/native-ash-projection-symmetric-20261029/report.md b/docs/benchmarks/native-ash-projection-symmetric-20261029/report.md deleted file mode 100644 index 041116ea5..000000000 --- a/docs/benchmarks/native-ash-projection-symmetric-20261029/report.md +++ /dev/null @@ -1,57 +0,0 @@ -# Native ASH projection and symmetric construction - -Dense graph-query projection and encoded symmetric construction arithmetic now use Highway. Java/Panama and missing-symbol fallbacks remain available. Source and the reference implementation review are described in [design.md](design.md). - -Performance was collected through unchanged BenchYAML. Both arms use the native provider and the identical saved graph/model; **before** uses the inherited Panama projection/symmetric helpers and **after** uses the new Highway operations. Query preparation is included. These query runs measure the projection change; the construction pair kernel is exercised by separate fresh builds. - -## Query throughput — before → after, QPS - -| Dataset / budget | JDK | 1 thread, 1× | 1 thread, 2× | 8 threads, 1× | 8 threads, 2× | -|---|---:|---:|---:|---:|---:| -| MPNet, 2-bit | 22 | 9,534 → 11,111 (1.17×) | 7,996 → 8,875 (1.11×) | 73,300 → 80,378 (1.10×) | 59,441 → 62,297 (1.05×) | -| MPNet, 2-bit | 25 | 10,441 → 12,118 (1.16×) | 8,294 → 9,286 (1.12×) | 76,547 → 83,550 (1.09×) | 61,781 → 69,338 (1.12×) | -| OpenAI3072, 4-bit | 22 | 1,025 → 1,026 (1.00×) | 1,030 → 1,046 (1.02×) | 7,741 → 8,259 (1.07×) | 7,240 → 7,927 (1.09×) | -| OpenAI3072, 4-bit | 25 | 1,062 → 1,012 (0.95×) | 1,040 → 1,054 (1.01×) | 7,844 → 8,559 (1.09×) | 7,464 → 7,983 (1.07×) | - -Native QPS was higher in 15/16 matched settings, with a geometric mean ratio -of 1.075. The exception was OpenAI3072/JDK25/one thread/1×: 1,062 → 1,012 QPS, -a 4.7% decrease. The small OpenAI single-thread changes should not be presented -as an established improvement from these three-sample runs. - -A separate instrumented, accuracy-only BenchYAML diagnostic alternated both -projection methods for each of 2,000 queries, checked their outputs, and measured -calling-thread CPU time. Median native/Panama projection costs were 0.629/0.626 ms -on JDK22 and 0.628/0.626 ms on JDK25. Thus this diagnostic found similar isolated -projection cost at D=3072. It does not establish the cause of the full-query -4.7% difference or assign a hardware bottleneck. Diagnostic results are excluded -from the throughput tables and retained in [projection-diagnostic.json](projection-diagnostic.json). - -## Recall@10 — same saved graph, before → after (%) - -| Dataset / budget | JDK | 1× | 2× | -|---|---:|---:|---:| -| MPNet, 2-bit | 22 | 81.940 → 81.940 | 94.055 → 94.055 | -| MPNet, 2-bit | 25 | 82.017 → 82.017 | 93.971 → 93.971 | -| OpenAI3072, 4-bit | 22 | 79.030 → 79.030 | 88.340 → 88.340 | -| OpenAI3072, 4-bit | 25 | 79.650 → 79.650 | 88.750 → 88.750 | - -## Fresh construction — current implementation, seconds - -Existing BenchYAML phase timers are retained. Process wall time additionally includes startup/loading, one standard 1× recall pass and teardown. The fresh graphs below are separate from the identical saved graphs used for the throughput comparison. - -| Dataset / budget | JDK | Training | Encoding | Graph/write | Process incl. validation | Recall@10, 1× (%) | -|---|---:|---:|---:|---:|---:|---:| -| MPNet, 2-bit | 22 | 5.29 | 1.05 | 54.16 | 67.83 | 81.888 | -| MPNet, 2-bit | 25 | 5.33 | 0.95 | 34.23 | 46.53 | 81.633 | -| OpenAI3072, 4-bit | 22 | 38.09 | 25.96 | 101.92 | 185.29 | 78.960 | -| OpenAI3072, 4-bit | 25 | 36.40 | 25.75 | 106.24 | 186.01 | 78.700 | - -## Validation and limits - -All 60 targeted JDK/ISA regression checks passed. Four traced BenchYAML preflights confirmed actual native projection, symmetric pair and neighborhood calls. Independent ARM NEON and Highway scalar executables each passed 64 arithmetic cases. The same-graph maximum recall change was 0.00000 percentage points. Maximum throughput sample CV was 8.75%. - -Query runs use 1 or 8 physical cores; construction uses 48. Native kernels do not create workers or invoke multithreaded BLAS. Matrix preparation is once per immutable model; projection remains once per graph query. New models add rows × columns × 4 bytes of native matrix storage, included in model memory accounting; serialized formats are unchanged. - -This focused study does not repeat the entire four-dataset PQ/ASH matrix or establish construction variance. It does not measure standalone pair/LUT kernel throughput in isolation. Native libraries and Java wrappers are frozen per JDK. Earlier development runs and the interrupted build remain archived; they are excluded from these final comparisons. - -Data: [queries.csv](queries.csv), [construction.csv](construction.csv), [raw-results.json](raw-results.json). Reproduction: [protocol.json](protocol.json), [source.json](source.json), [validation.json](validation.json), [native-preflight.json](native-preflight.json), [implementation.patch](implementation.patch). diff --git a/docs/benchmarks/native-ash-projection-symmetric-20261029/source.json b/docs/benchmarks/native-ash-projection-symmetric-20261029/source.json deleted file mode 100644 index 88acba746..000000000 --- a/docs/benchmarks/native-ash-projection-symmetric-20261029/source.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "highway_commit": "16f5f5c768064d0bb52952888a14236bec75c104", - "active_highway_isa": "ACTIVE_HIGHWAY_ISA=avx3_spr", - "branch": "ash-dev", - "implementation_and_tests": "28e4c8c1c8f49db472a9d0e530c01d5dcbceb403", - "projection": "835bb81c002360fc885f704431d023abd372ef5e", - "symmetric": "d76c1fe80a90330dfa3845345585a5754a9e0739", - "baseline_repository_reference": "c0e8ab06ec7f26a96f53d7c7092205cc93d68788", - "baseline_runtime_source": "507b74e7ef8a0e12fcfe402deb4bf758c65758d5", - "benchyaml_unchanged": true, - "pushed": false, - "native_library_sha256": { - "22": "c187841b9124daf480606dd0ad709240fc8014cbcc306a03ebda46c1b22c227a", - "25": "c187841b9124daf480606dd0ad709240fc8014cbcc306a03ebda46c1b22c227a" - } -} diff --git a/docs/benchmarks/native-ash-projection-symmetric-20261029/summary.json b/docs/benchmarks/native-ash-projection-symmetric-20261029/summary.json deleted file mode 100644 index 3849eff87..000000000 --- a/docs/benchmarks/native-ash-projection-symmetric-20261029/summary.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "query_comparisons": 16, - "geometric_mean_qps_ratio": 1.0747262914811682, - "min_qps_ratio": 0.9526232751377229, - "max_qps_ratio": 1.1653572102114633, - "qps_wins": 15, - "max_abs_recall_change_percentage_points": 0.0, - "max_cv_pct": 8.754005476832623, - "fresh_graph_builds": 4 -} diff --git a/docs/benchmarks/native-ash-projection-symmetric-20261029/validation.json b/docs/benchmarks/native-ash-projection-symmetric-20261029/validation.json deleted file mode 100644 index 41d9c2cb5..000000000 --- a/docs/benchmarks/native-ash-projection-symmetric-20261029/validation.json +++ /dev/null @@ -1,70 +0,0 @@ -{ - "jdk_isa_checks": [ - { - "jdk": "22", - "isa": "auto", - "tests": 6, - "passed": true - }, - { - "jdk": "22", - "isa": "avx3_dl", - "tests": 6, - "passed": true - }, - { - "jdk": "22", - "isa": "avx3", - "tests": 6, - "passed": true - }, - { - "jdk": "22", - "isa": "avx2", - "tests": 6, - "passed": true - }, - { - "jdk": "22", - "isa": "sse42", - "tests": 6, - "passed": true - }, - { - "jdk": "25", - "isa": "auto", - "tests": 6, - "passed": true - }, - { - "jdk": "25", - "isa": "avx3_dl", - "tests": 6, - "passed": true - }, - { - "jdk": "25", - "isa": "avx3", - "tests": 6, - "passed": true - }, - { - "jdk": "25", - "isa": "avx2", - "tests": 6, - "passed": true - }, - { - "jdk": "25", - "isa": "sse42", - "tests": 6, - "passed": true - } - ], - "total_targeted_tests": 60, - "highway_scalar_arithmetic_checks": 64, - "highway_scalar_scope": "new ASH projection and symmetric headers only", - "arm_neon_arithmetic_checks": 64, - "arm_neon_hardware": "Apple Silicon arm64; Apple Clang 17; independent double/decoder/int64 oracles", - "sve_sve2_runtime_verified": false -} diff --git a/docs/benchmarks/native-pq-ash-graph-20261028/comparison.pdf b/docs/benchmarks/native-pq-ash-graph-20261028/comparison.pdf deleted file mode 100644 index 68796521e1fed9835a37a1d22f5549bafb778372..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 26364 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 diff --git a/docs/benchmarks/native-pq-ash-graph-20261028/comparison.png b/docs/benchmarks/native-pq-ash-graph-20261028/comparison.png deleted file mode 100644 index c9a268dab27477a00e5e971a700087ab30e4d860..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 285368 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⪼F+?3z~3EE+FR{|hFlX#HW# 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

    9Uux#q@IU)*#?=ffnLAYL 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

    =-C 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 diff --git a/docs/benchmarks/native-pq-ash-graph-20261028/comparisons.csv b/docs/benchmarks/native-pq-ash-graph-20261028/comparisons.csv deleted file mode 100644 index 8686b50ed..000000000 --- a/docs/benchmarks/native-pq-ash-graph-20261028/comparisons.csv +++ /dev/null @@ -1,65 +0,0 @@ -dataset,jdk,ash_bits_budget,code_bytes,query_threads,overquery,pq_recall_percent,ash_recall_percent,ash_recall_delta_percentage_points,pq_qps,ash_qps,ash_over_pq_qps -MPNet,22,2,101,1,1,65.128,81.94,16.811999999999998,11929.667667640202,9610.898579370603,0.8056300348953247 -MPNet,22,2,101,1,2,82.126,94.055,11.929000000000002,9441.313656397691,7822.315710403462,0.828519843221483 -MPNet,22,2,101,8,1,65.128,81.94,16.811999999999998,86214.6647201115,70989.65395938238,0.8234057882129937 -MPNet,22,2,101,8,2,82.126,94.055,11.929000000000002,68759.66645239317,59038.82008864344,0.8586257487086546 -MPNet,22,4,197,1,1,80.733,86.642,5.908999999999992,9642.851473210929,8629.758114420085,0.8949384047234009 -MPNet,22,4,197,1,2,92.753,93.96600000000001,1.213000000000008,7455.724453990442,6976.353944105883,0.9357043687916885 -MPNet,22,4,197,8,1,80.733,86.642,5.908999999999992,74892.80322512507,64520.16990414337,0.8615002660562465 -MPNet,22,4,197,8,2,92.753,93.96600000000001,1.213000000000008,57187.4193258142,52278.56016692018,0.9141619045453555 -MPNet,25,2,101,1,1,65.484,82.017,16.533,11812.269458852594,10357.12246572871,0.8768105487101517 -MPNet,25,2,101,1,2,82.21000000000001,93.971,11.760999999999996,9294.266279454745,8282.467440755714,0.891137308930384 -MPNet,25,2,101,8,1,65.484,82.017,16.533,76801.73087416003,73840.6240900836,0.9614447910174289 -MPNet,25,2,101,8,2,82.21000000000001,93.971,11.760999999999996,71112.3291273773,60433.593076138226,0.849832846395578 -MPNet,25,4,197,1,1,80.75099999999999,86.29299999999999,5.542000000000002,10001.00801293626,9153.966952460185,0.9153044313752743 -MPNet,25,4,197,1,2,92.601,94.033,1.4320000000000022,7557.44798962551,7114.7162102085695,0.9414178198745529 -MPNet,25,4,197,8,1,80.75099999999999,86.29299999999999,5.542000000000002,74886.84115917748,67390.32073511016,0.8998953580091206 -MPNet,25,4,197,8,2,92.601,94.033,1.4320000000000022,56904.75254695084,54357.67614973744,0.9552396542782281 -Cohere,22,2,133,1,1,59.431,63.903,4.472000000000001,9694.693384586766,6040.668013555356,0.623090156018672 -Cohere,22,2,133,1,2,78.292,81.99499999999999,3.7029999999999887,7699.839253969693,5224.424204577702,0.6785108146100871 -Cohere,22,2,133,8,1,59.431,63.903,4.472000000000001,73999.92617936898,47759.13958907922,0.6453944220608475 -Cohere,22,2,133,8,2,78.292,81.99499999999999,3.7029999999999887,59164.921945419286,39833.20097312,0.6732570527155745 -Cohere,22,4,261,1,1,65.081,70.693,5.611999999999995,7655.785166781064,5459.5384791205415,0.7131258728118214 -Cohere,22,4,261,1,2,81.039,83.397,2.358000000000004,5826.0870091323695,4609.658123480801,0.7912099692735758 -Cohere,22,4,261,8,1,65.081,70.693,5.611999999999995,57544.851973715246,41672.84985305652,0.7241803293210559 -Cohere,22,4,261,8,2,81.039,83.397,2.358000000000004,43365.895255088246,35207.00369774443,0.8118592615383282 -Cohere,25,2,133,1,1,59.13,63.975,4.844999999999999,9412.081288856038,6427.095638763094,0.6828559424335631 -Cohere,25,2,133,1,2,78.45,82.327,3.8769999999999953,7190.940014143866,5258.107673080131,0.7312128404266972 -Cohere,25,2,133,8,1,59.13,63.975,4.844999999999999,75637.85852751213,50148.40516839785,0.6630066760834737 -Cohere,25,2,133,8,2,78.45,82.327,3.8769999999999953,57862.554944797805,41901.690380100095,0.7241590078432462 -Cohere,25,4,261,1,1,65.53,70.59,5.060000000000002,7857.8449638838065,5662.326694410597,0.7205953693965406 -Cohere,25,4,261,1,2,81.365,83.696,2.331000000000003,5920.816567354704,4635.7196414662285,0.7829527546970384 -Cohere,25,4,261,8,1,65.53,70.59,5.060000000000002,57670.64664851298,42689.45179035281,0.7402284224509132 -Cohere,25,4,261,8,2,81.365,83.696,2.331000000000003,43804.78320505803,35644.71872678161,0.8137175011213342 -Ada002,22,2,197,1,1,54.986000000000004,78.88,23.89399999999999,7064.577838319312,3301.2900012077635,0.46730180865176113 -Ada002,22,2,197,1,2,75.347,92.685,17.338000000000008,5685.492109827316,2987.2294544484503,0.5254126462131666 -Ada002,22,2,197,8,1,54.986000000000004,78.88,23.89399999999999,53847.213088554396,24911.127856122304,0.46262613099687677 -Ada002,22,2,197,8,2,75.347,92.685,17.338000000000008,45513.06620677013,22127.98341769791,0.48618968709267807 -Ada002,22,4,389,1,1,73.555,83.231,9.675999999999988,5341.342726973948,2975.630205424497,0.5570940412412541 -Ada002,22,4,389,1,2,90.049,92.443,2.3939999999999912,4200.0630432290845,2698.9527131275813,0.6425981432537207 -Ada002,22,4,389,8,1,73.555,83.231,9.675999999999988,41038.5274305757,21870.622987666935,0.5329290390515391 -Ada002,22,4,389,8,2,90.049,92.443,2.3939999999999912,30912.637087924762,19258.54676558465,0.6229991543849074 -Ada002,25,2,197,1,1,54.594,79.06400000000001,24.470000000000006,7271.180761294964,3436.8450622860405,0.47266670642829045 -Ada002,25,2,197,1,2,75.03999999999999,92.718,17.67800000000001,5711.746382358798,3096.883668745141,0.5421955845781464 -Ada002,25,2,197,8,1,54.594,79.06400000000001,24.470000000000006,49406.674887911795,25148.668295078558,0.5090135766499765 -Ada002,25,2,197,8,2,75.03999999999999,92.718,17.67800000000001,43937.988185794646,22643.408793697028,0.5153492394314436 -Ada002,25,4,389,1,1,73.566,83.528,9.962000000000003,3664.671687680422,2958.5765700966863,0.8073237720155326 -Ada002,25,4,389,1,2,90.14999999999999,92.467,2.3170000000000073,3883.6968894121583,2649.82808625031,0.6822952876354343 -Ada002,25,4,389,8,1,73.566,83.528,9.962000000000003,26979.66563109857,21982.92028578378,0.8147958757667031 -Ada002,25,4,389,8,2,90.14999999999999,92.467,2.3170000000000073,22435.812911872614,20075.60298373417,0.8948016754548054 -OpenAI3072,22,2,389,1,1,64.37,77.92,13.549999999999997,3329.077370787888,1031.0076970878936,0.30969772770522497 -OpenAI3072,22,2,389,1,2,79.85,89.8,9.950000000000003,2953.6549674392622,1068.223215503725,0.36166147613031635 -OpenAI3072,22,2,389,8,1,64.37,77.92,13.549999999999997,27543.90063678985,7189.703298060613,0.26102705614823 -OpenAI3072,22,2,389,8,2,79.85,89.8,9.950000000000003,22784.923597112218,7037.381528041226,0.3088613177940676 -OpenAI3072,22,4,773,1,1,76.91,79.03,2.1200000000000045,2143.912280952585,1016.1509827131623,0.47397040995616907 -OpenAI3072,22,4,773,1,2,87.7,88.34,0.6400000000000006,1871.3707850777212,1030.8662536443949,0.5508615726314127 -OpenAI3072,22,4,773,8,1,76.91,79.03,2.1200000000000045,18466.799971430668,7507.82372210983,0.4065579165705438 -OpenAI3072,22,4,773,8,2,87.7,88.34,0.6400000000000006,14336.390737938298,7360.509055849552,0.5134143725848295 -OpenAI3072,25,2,389,1,1,65.10000000000001,76.75999999999999,11.659999999999982,2170.3203408432223,1003.0125753831542,0.4621495529979963 -OpenAI3072,25,2,389,1,2,80.32000000000001,89.17,8.849999999999994,2068.413509232902,1093.9677679889598,0.5288921983470665 -OpenAI3072,25,2,389,8,1,65.10000000000001,76.75999999999999,11.659999999999982,27772.466077225967,8050.426675043233,0.28987078974757524 -OpenAI3072,25,2,389,8,2,80.32000000000001,89.17,8.849999999999994,23213.773739290547,7893.088397249235,0.3400174605772849 -OpenAI3072,25,4,773,1,1,76.24,79.65,3.410000000000011,2280.759101685725,964.9450620510337,0.42308065825006946 -OpenAI3072,25,4,773,1,2,87.27000000000001,88.75,1.4799999999999898,1886.2499788294274,1033.6181669420773,0.5479751774913323 -OpenAI3072,25,4,773,8,1,76.24,79.65,3.410000000000011,15663.804691288786,7915.294433750889,0.5053238718019047 -OpenAI3072,25,4,773,8,2,87.27000000000001,88.75,1.4799999999999898,12884.883579153513,7489.111448400777,0.5812323722130815 diff --git a/docs/benchmarks/native-pq-ash-graph-20261028/construction.csv b/docs/benchmarks/native-pq-ash-graph-20261028/construction.csv deleted file mode 100644 index 5f91cefbf..000000000 --- a/docs/benchmarks/native-pq-ash-graph-20261028/construction.csv +++ /dev/null @@ -1,33 +0,0 @@ -dataset,jdk,ash_bits_budget,code_bytes,algorithm,training_seconds,encoding_seconds,graph_write_seconds,total_construction_validation_process_seconds,nvq_compute_seconds,index_cache_total_bytes,source -MPNet,22,2,101,PQ,2.729675109,1.081207084,44.438387038,52.27324899379164,0.275686754,4204460293,b44e25b71e3d9c5c7a2f164610b0fdbd099d91fa -MPNet,22,2,101,ASH,5.20501655,1.091514841,58.372648107,70.72278314642608,0.368510427,4205250935,507b74e7ef8a0e12fcfe402deb4bf758c65758d5 -MPNet,22,4,197,PQ,6.137772914,1.70667817,50.786497509,62.748410480096936,0.30227987,7278884955,b44e25b71e3d9c5c7a2f164610b0fdbd099d91fa -MPNet,22,4,197,ASH,4.690223155,1.194973879,42.028731887,54.33950508851558,0.46261499,7279672095,507b74e7ef8a0e12fcfe402deb4bf758c65758d5 -MPNet,25,2,101,PQ,2.774568465,1.048305647,33.769320929,41.54969112202525,0.260673416,4204456814,b44e25b71e3d9c5c7a2f164610b0fdbd099d91fa -MPNet,25,2,101,ASH,4.795862,1.001739035,31.631570707,43.574435736984015,0.364870626,4205249625,507b74e7ef8a0e12fcfe402deb4bf758c65758d5 -MPNet,25,4,197,PQ,4.652523221,1.745370325,48.549881485,58.950520511716604,0.330302452,7278883270,507b74e7ef8a0e12fcfe402deb4bf758c65758d5 -MPNet,25,4,197,ASH,4.454340959,1.201650817,43.289757796,54.83608163334429,0.358496614,7279677824,507b74e7ef8a0e12fcfe402deb4bf758c65758d5 -Cohere,22,2,133,PQ,2.969167141,1.324768325,35.40040222,44.332542745396495,0.35765844,5486775034,b44e25b71e3d9c5c7a2f164610b0fdbd099d91fa -Cohere,22,2,133,ASH,8.026500274,1.861498103,62.374692218,96.0135448006913,0.457021944,5488881546,507b74e7ef8a0e12fcfe402deb4bf758c65758d5 -Cohere,22,4,261,PQ,6.753853519,2.302340857,70.711462575,84.35468028020114,0.34194332,9586777730,b44e25b71e3d9c5c7a2f164610b0fdbd099d91fa -Cohere,22,4,261,ASH,6.624667012,2.166285985,49.729920663,66.18678959459066,0.488109724,9588877024,507b74e7ef8a0e12fcfe402deb4bf758c65758d5 -Cohere,25,2,133,PQ,3.084744323,1.32553116,40.656450049,49.729727069847286,0.323431596,5486780219,507b74e7ef8a0e12fcfe402deb4bf758c65758d5 -Cohere,25,2,133,ASH,7.498550613,1.643006753,32.965712237,52.83737094514072,0.49575556,5488878816,507b74e7ef8a0e12fcfe402deb4bf758c65758d5 -Cohere,25,4,261,PQ,6.571955191,2.233528007,66.54631899,80.15861915424466,0.338206073,9586776527,507b74e7ef8a0e12fcfe402deb4bf758c65758d5 -Cohere,25,4,261,ASH,6.455142236,2.062987324,46.251607145,62.538319007493556,0.722485568,9588881034,507b74e7ef8a0e12fcfe402deb4bf758c65758d5 -Ada002,22,2,197,PQ,3.932531018,2.19930205,48.118085742,60.269731011241674,0.533923396,7911344189,b44e25b71e3d9c5c7a2f164610b0fdbd099d91fa -Ada002,22,2,197,ASH,13.7121569,7.911204988,49.604614259,81.39980881009251,0.598817294,7917649911,507b74e7ef8a0e12fcfe402deb4bf758c65758d5 -Ada002,22,4,389,PQ,8.623554557,3.614520167,100.923429775,119.41658374574035,0.475411504,13955502981,b44e25b71e3d9c5c7a2f164610b0fdbd099d91fa -Ada002,22,4,389,ASH,10.947159808,7.425704863,56.9124183,85.24945241492242,0.557878664,13961799958,507b74e7ef8a0e12fcfe402deb4bf758c65758d5 -Ada002,25,2,197,PQ,3.724007606,2.046804322,42.696928604,54.51408803742379,0.522869417,7911343988,507b74e7ef8a0e12fcfe402deb4bf758c65758d5 -Ada002,25,2,197,ASH,13.824727702,8.277205551,45.395450927,81.49886683840305,0.563860348,7917644448,507b74e7ef8a0e12fcfe402deb4bf758c65758d5 -Ada002,25,4,389,PQ,8.80672428,3.506287152,106.252121239,125.37208096962422,0.494723635,13955502981,507b74e7ef8a0e12fcfe402deb4bf758c65758d5 -Ada002,25,4,389,ASH,13.053361983,7.324970714,60.391773285,90.95995359588414,0.787999096,13961804326,507b74e7ef8a0e12fcfe402deb4bf758c65758d5 -OpenAI3072,22,2,389,PQ,6.505794804,5.508848828,90.502056269,113.07548744231462,1.177500504,15723261549,b44e25b71e3d9c5c7a2f164610b0fdbd099d91fa -OpenAI3072,22,2,389,ASH,41.122194304,24.101615418,85.71443966,169.54594611562788,1.578240137,15754739676,507b74e7ef8a0e12fcfe402deb4bf758c65758d5 -OpenAI3072,22,4,773,PQ,19.649721716,7.64310692,257.818535867,295.41825035214424,1.012420177,28010961543,b44e25b71e3d9c5c7a2f164610b0fdbd099d91fa -OpenAI3072,22,4,773,ASH,39.53853562,27.206676416,124.325741592,208.43669655919075,1.118749346,28042434388,507b74e7ef8a0e12fcfe402deb4bf758c65758d5 -OpenAI3072,25,2,389,PQ,5.897795915,5.580369998,113.005143545,135.150859314017,1.272420274,15723256259,507b74e7ef8a0e12fcfe402deb4bf758c65758d5 -OpenAI3072,25,2,389,ASH,39.411565317,24.278880319,87.028014205,170.93929107300937,1.4630497,15754739812,507b74e7ef8a0e12fcfe402deb4bf758c65758d5 -OpenAI3072,25,4,773,PQ,16.624147058,7.778564805,247.52862079,281.5635087201372,0.962668797,28010963369,507b74e7ef8a0e12fcfe402deb4bf758c65758d5 -OpenAI3072,25,4,773,ASH,38.63113482,25.960155066,130.569217494,213.53237825259566,1.1549786,28042443654,507b74e7ef8a0e12fcfe402deb4bf758c65758d5 diff --git a/docs/benchmarks/native-pq-ash-graph-20261028/inputs.json b/docs/benchmarks/native-pq-ash-graph-20261028/inputs.json deleted file mode 100644 index 5c5766949..000000000 --- a/docs/benchmarks/native-pq-ash-graph-20261028/inputs.json +++ /dev/null @@ -1,98 +0,0 @@ -[ - { - "kind": "base", - "path": "/home/ted_willke/datasets-clean/mpnet-1m/wiki_mpnet_embeddings_base_999812.fvecs", - "bytes": 3075421712, - "width": 768, - "records": 999812, - "mtime_ns": 1779289837543238134 - }, - { - "kind": "query", - "path": "/home/ted_willke/datasets-clean/mpnet-1m/wiki_mpnet_embeddings_query_10000.fvecs", - "bytes": 30760000, - "width": 768, - "records": 10000, - "mtime_ns": 1779289837559238135 - }, - { - "kind": "gt", - "path": "/home/ted_willke/datasets-clean/mpnet-1m/wiki_mpnet_embeddings_gt_ip_100.ivecs", - "bytes": 4040000, - "width": 100, - "records": 10000, - "mtime_ns": 1779289837543238134 - }, - { - "kind": "base", - "path": "/home/ted_willke/datasets-clean/cohere-1m/cohere_wiki_en_flat_base_1m_norm.fvecs", - "bytes": 4100000000, - "width": 1024, - "records": 1000000, - "mtime_ns": 1778786535687938561 - }, - { - "kind": "query", - "path": "/home/ted_willke/datasets-clean/cohere-1m/cohere_wiki_en_flat_query_10k_norm.fvecs", - "bytes": 41000000, - "width": 1024, - "records": 10000, - "mtime_ns": 1778786535703938563 - }, - { - "kind": "gt", - "path": "/home/ted_willke/datasets-clean/cohere-1m/cohere_wiki_en_flat_gt_1m_ip_k100.ivecs", - "bytes": 4040000, - "width": 100, - "records": 10000, - "mtime_ns": 1778786533775938406 - }, - { - "kind": "base", - "path": "/home/ted_willke/datasets-clean/ada_002_1M_base_982790.fvecs", - "bytes": 6042192920, - "width": 1536, - "records": 982790, - "mtime_ns": 1775399354461867895 - }, - { - "kind": "query", - "path": "/home/ted_willke/datasets-clean/ada_002_1M_query_10000.fvecs", - "bytes": 61480000, - "width": 1536, - "records": 10000, - "mtime_ns": 1775399354529867849 - }, - { - "kind": "gt", - "path": "/home/ted_willke/datasets-clean/ada_002_1M_gt_ip_100.ivecs", - "bytes": 4040000, - "width": 100, - "records": 10000, - "mtime_ns": 1775399661489659318 - }, - { - "kind": "base", - "path": "/home/ted_willke/datasets-clean/openai-3072-1m/dbpedia_openai_3_large_3072_base_999000.fvecs", - "bytes": 12279708000, - "width": 3072, - "records": 999000, - "mtime_ns": 1779387195627505524 - }, - { - "kind": "query", - "path": "/home/ted_willke/datasets-clean/openai-3072-1m/dbpedia_openai_3_large_3072_query_1000.fvecs", - "bytes": 12292000, - "width": 3072, - "records": 1000, - "mtime_ns": 1779387195663505529 - }, - { - "kind": "gt", - "path": "/home/ted_willke/datasets-clean/openai-3072-1m/dbpedia_openai_3_large_3072_gt_ip_100.ivecs", - "bytes": 404000, - "width": 100, - "records": 1000, - "mtime_ns": 1779387338759522247 - } -] diff --git a/docs/benchmarks/native-pq-ash-graph-20261028/manifest.json b/docs/benchmarks/native-pq-ash-graph-20261028/manifest.json deleted file mode 100644 index 989252207..000000000 --- a/docs/benchmarks/native-pq-ash-graph-20261028/manifest.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "source": { - "head": "507b74e7ef8a0e12fcfe402deb4bf758c65758d5", - "branch": "ash-dev", - "main": "4f13ff42e93f2e7b16020de3aaeefe9372e0dd36", - "clean": true, - "benchyaml_changes": false, - "backup": "c8d8de8af52b39bc8d2f6947294ed1ec40b088c6", - "base_head": "b44e25b71e3d9c5c7a2f164610b0fdbd099d91fa", - "pca_fix": "507b74e7ef8a0e12fcfe402deb4bf758c65758d5", - "retained_pq_source": "b44e25b71e3d9c5c7a2f164610b0fdbd099d91fa", - "retained_pq_count": 9 - }, - "root": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028", - "complete": true, - "cases": 32, - "fresh_builds": 32, - "cached_query_runs": 64, - "query_metric_cells": 128, - "benchyaml_unchanged": true, - "total_process_scope": "fresh construction plus one standard recall@10 accuracy pass at 1x overquery; includes JVM startup, dataset loading, construction, accuracy and teardown; no subtraction or estimate" -} diff --git a/docs/benchmarks/native-pq-ash-graph-20261028/native-preflight-proof.json b/docs/benchmarks/native-pq-ash-graph-20261028/native-preflight-proof.json deleted file mode 100644 index bb63313db..000000000 --- a/docs/benchmarks/native-pq-ash-graph-20261028/native-preflight-proof.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "22": { - "dispatch": [ - "INFO: Highway ISA tier: avx3_spr, Env variable JVECTOR_MAX_ISA: (not set)" - ], - "native_calls": [ - "", - "", - "" - ] - }, - "25": { - "dispatch": [ - "INFO: Highway ISA tier: avx3_spr, Env variable JVECTOR_MAX_ISA: (not set)" - ], - "native_calls": [ - "", - "", - "", - "" - ] - } -} diff --git a/docs/benchmarks/native-pq-ash-graph-20261028/pca-fix-runtime-audit.json b/docs/benchmarks/native-pq-ash-graph-20261028/pca-fix-runtime-audit.json deleted file mode 100644 index cef8bff1e..000000000 --- a/docs/benchmarks/native-pq-ash-graph-20261028/pca-fix-runtime-audit.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "22": { - "changed_runtime_files": [ - "jvector-base/classes/io/github/jbellis/jvector/quantization/AsymmetricHashing.class" - ], - "all_other_runtime_files_identical": true - }, - "25": { - "changed_runtime_files": [ - "jvector-base/classes/io/github/jbellis/jvector/quantization/AsymmetricHashing.class" - ], - "all_other_runtime_files_identical": true - } -} diff --git a/docs/benchmarks/native-pq-ash-graph-20261028/protocol.json b/docs/benchmarks/native-pq-ash-graph-20261028/protocol.json deleted file mode 100644 index 79af15886..000000000 --- a/docs/benchmarks/native-pq-ash-graph-20261028/protocol.json +++ /dev/null @@ -1,141 +0,0 @@ -{ - "status": "complete", - "benchmark_entry_point": "io.github.jbellis.jvector.example.BenchYAML", - "algorithms": [ - "PQ", - "ASH" - ], - "backend": "native Highway for both", - "source_before_integration": "c8d8de8a", - "fetched_main": "4f13ff42e93f2e7b16020de3aaeefe9372e0dd36", - "preparation": [ - "Preserve a local pre-rebase backup ref; rebase ash-dev onto fetched main.", - "Resolve overlap between current ASH fused/native changes and main deferred writer/native batching changes.", - "Retain ASH format v7; use the same supported format for PQ.", - "Check actual native ASH whole-neighborhood dispatch and native PQ ADC through BenchYAML; forced provider alone does not establish ASH kernel capability.", - "Confirm deferred NVQ/fused complete-record serialization, 64 records per writer batch; preserve same behavior in both arms." - ], - "jdks": [ - "Temurin 22.0.2+9", - "Temurin 25.0.2" - ], - "construction_physical_cores": 48, - "construction_cpu_affinity": "0-47", - "query_threads": [ - 1, - 8 - ], - "query_cpu_affinity": { - "1": "0", - "8": "0-7" - }, - "launch_properties": { - "jvector.vectorization_provider": "native", - "jvector.ash.blockKernel": "simd", - "jvector.parallel_writer": "true", - "jvector.physical_core_count": "48", - "jvector.bench.queryThreads": "1 or 8" - }, - "jvm_options": [ - "--add-modules=jdk.incubator.vector", - "--enable-native-access=ALL-UNNAMED", - "-da", - "-Xms2g", - "-Xmx128g", - "-XX:ActiveProcessorCount=48" - ], - "native_isa_policy": "Normal Highway runtime dispatch; record actual ISA and confirm AVX-512 on this host. No production hardware special-casing.", - "graph": { - "outDegree": 32, - "efConstruction": 100, - "neighborOverflow": 1.2, - "addHierarchy": true, - "refineFinalGraph": true, - "fusedGraph": true, - "reranking": "NVQ", - "nvq_subvectors": 2, - "nvq_bits_per_dimension": 8, - "search_pruning": false, - "ash_adaptive_termination": "retain existing production policy; report its presence" - }, - "quantizers": { - "ASH": { - "optimizer": "ITQ", - "landmarkCount": 1, - "projectedDimension": "D/2", - "bitsPerDimension": [ - 2, - 4 - ], - "training": "production PCA/ITQ defaults, min(N,20D), max25 iterations with early stopping" - }, - "PQ": { - "K": 256, - "m": "exact matched bytes per vector", - "centerData": false, - "anisotropicThreshold": -1 - } - }, - "timing": { - "training": "Actual new quantizer training, including the learned ASH transform.", - "encoding": "Base-vector quantizer encoding, recorded separately.", - "graph_write": "Graph insertion/refinement plus deferred NVQ encoding and complete-record serialization; no NVQ encoding double counting.", - "total_process": "fresh construction plus one standard recall@10 accuracy pass at 1x overquery; includes JVM startup, dataset loading, construction, accuracy and teardown; no subtraction or estimate", - "cache_policy": "Each construction case uses a new empty isolated working directory. Verify both graph and compressor cache misses. Cache enabled solely to retain the new graph for subsequent queries." - }, - "queries": { - "topK": 10, - "overquery": [ - 1, - 2 - ], - "rerankCandidates": [ - 10, - 20 - ], - "same_graph_across_thread_counts": true, - "all_dataset_queries": true, - "warmup_and_samples": "stock BenchYAML throughput policy; retain raw samples and CV" - }, - "matrix": { - "datasets": 4, - "matched_budgets_per_dataset": 2, - "algorithms": 2, - "jdks": 2, - "fresh_graphs": 32, - "query_processes": 64, - "qps_cells": 128, - "distinct_graph_recall_cells": 64 - }, - "file_roles": { - "index-parameters": "Saved-graph query configurations (top10, 1x/2x).", - "build-parameters": "One standard recall@10 scenario with accuracy-only compute/display/log selection; provides existing construction CSV metrics without throughput warmup.", - "run-config.yml": "Existing construction metrics; separate accuracy-only build/validation and throughput/recall query selections.", - "dataset-catalog.yaml": "Exact already-cleaned inputs used in the full-scan study, copied into an isolated study catalog." - }, - "precision_note": "Equal stored ASH/PQ code bytes, including 5-byte ASH header. Shared model bytes and NVQ/graph storage are not part of that code-budget definition; retain actual total index sizes.", - "rebase_status": "completed_and_validated_with_BenchYAML", - "source_after_rebase": "65bc5454a34e7245e969697f707713e490cee9d7", - "rebase_validation": "16 small-fixture BenchYAML checks on both JDKs; full benchmark not started; inclusive construction timing still pending", - "source": "507b74e7ef8a0e12fcfe402deb4bf758c65758d5", - "benchyaml_code_unchanged_from_rebase": true, - "construction_metrics": "existing BenchYAML construction.index_quant_time_s.* and construction.index_build_time_s; no new timer", - "total_process_scope": "fresh construction plus one standard recall@10 accuracy pass at 1x overquery; includes JVM startup, dataset loading, construction, accuracy and teardown; no subtraction or estimate", - "processes": { - "fresh_build_and_accuracy": 32, - "cached_query": 64 - }, - "measurement_instrumentation": "No JIT/class-load logging, profiling, or per-downcall tracing. Existing writer selection debug only.", - "case_repetitions": 1, - "pca_correction": { - "commit": "507b74e7ef8a0e12fcfe402deb4bf758c65758d5", - "superseded_successful_ash_cases": 9, - "retained_pq_cases": 9, - "source_of_retained_pq": "b44e25b71e3d9c5c7a2f164610b0fdbd099d91fa", - "all_non_ash_runtime_files_identical": true, - "numerical_validation": "energy and eigenvector residuals ~1e-14 on both JDKs; 4 tall/wide BenchYAML checks passed", - "new_ash_pca_driver": "LAPACK DGESDD, output energy validated", - "benchyaml_unchanged": true - }, - "completed_at": "2026-10-29T03:47:13.800992+00:00" -} diff --git a/docs/benchmarks/native-pq-ash-graph-20261028/queries.csv b/docs/benchmarks/native-pq-ash-graph-20261028/queries.csv deleted file mode 100644 index d7a397888..000000000 --- a/docs/benchmarks/native-pq-ash-graph-20261028/queries.csv +++ /dev/null @@ -1,129 +0,0 @@ -dataset,jdk,ash_bits_budget,code_bytes,algorithm,query_threads,overquery,rerank_k,recall_at_10,qps,qps_stddev,qps_cv_percent,avg_visited -MPNet,22,2,101,PQ,1,1,10,0.65128,11929.667667640202,329.8666343047988,2.7650949170996433,417.1787 -MPNet,22,2,101,PQ,1,2,20,0.82126,9441.313656397691,84.63498370312523,0.8964322845663989,564.0539 -MPNet,22,2,101,PQ,8,1,10,0.65128,86214.6647201115,3212.690631712721,3.7263853453962215,417.1787 -MPNet,22,2,101,PQ,8,2,20,0.82126,68759.66645239317,938.1522559268892,1.3643932618207706,564.0539 -MPNet,22,2,101,ASH,1,1,10,0.8194,9610.898579370603,457.55703825931096,4.760814345096079,457.6786 -MPNet,22,2,101,ASH,1,2,20,0.94055,7822.315710403462,39.11300159050038,0.5000181920359105,639.0262 -MPNet,22,2,101,ASH,8,1,10,0.8194,70989.65395938238,2781.2699707500246,3.91785255403747,457.6786 -MPNet,22,2,101,ASH,8,2,20,0.94055,59038.82008864344,991.3419392083159,1.6791357579976567,639.0262 -MPNet,22,4,197,PQ,1,1,10,0.80733,9642.851473210929,252.91163255244882,2.622788842647526,439.2968 -MPNet,22,4,197,PQ,1,2,20,0.92753,7455.724453990442,29.65127525390488,0.39769811018210266,604.3334 -MPNet,22,4,197,PQ,8,1,10,0.80733,74892.80322512507,3888.4955727931288,5.192081755979211,439.2968 -MPNet,22,4,197,PQ,8,2,20,0.92753,57187.4193258142,2011.126949231717,3.516729681005034,604.3334 -MPNet,22,4,197,ASH,1,1,10,0.86642,8629.758114420085,522.9722821464711,6.0601035998054105,431.9821 -MPNet,22,4,197,ASH,1,2,20,0.93966,6976.353944105883,19.32133701087272,0.2769546551920112,596.0551 -MPNet,22,4,197,ASH,8,1,10,0.86642,64520.16990414337,1765.6384986179241,2.73656827197,431.9821 -MPNet,22,4,197,ASH,8,2,20,0.93966,52278.56016692018,2374.4805450263125,4.541977700695726,596.0551 -MPNet,25,2,101,PQ,1,1,10,0.65484,11812.269458852594,210.99098451189369,1.7862019254375245,416.9763 -MPNet,25,2,101,PQ,1,2,20,0.8221,9294.266279454745,42.12928952819675,0.45328257510035846,563.8511 -MPNet,25,2,101,PQ,8,1,10,0.65484,76801.73087416003,911.757836651423,1.187157927658352,416.9763 -MPNet,25,2,101,PQ,8,2,20,0.8221,71112.3291273773,640.384545175938,0.9005253421370478,563.8511 -MPNet,25,2,101,ASH,1,1,10,0.82017,10357.12246572871,15.687831200532978,0.15146901325578957,455.8008 -MPNet,25,2,101,ASH,1,2,20,0.93971,8282.467440755714,24.053931479168885,0.2904198736816784,636.613 -MPNet,25,2,101,ASH,8,1,10,0.82017,73840.6240900836,3301.781445008702,4.471497208610557,455.8008 -MPNet,25,2,101,ASH,8,2,20,0.93971,60433.593076138226,1524.9801313351495,2.5233980865805528,636.613 -MPNet,25,4,197,PQ,1,1,10,0.80751,10001.00801293626,179.69312362625553,1.7967501215259827,438.5955 -MPNet,25,4,197,PQ,1,2,20,0.92601,7557.44798962551,14.656592861029829,0.193935742345163,602.5851 -MPNet,25,4,197,PQ,8,1,10,0.80751,74886.84115917748,2099.6919388373026,2.803819611477874,438.5955 -MPNet,25,4,197,PQ,8,2,20,0.92601,56904.75254695084,1261.345571497758,2.2165909085661166,602.5851 -MPNet,25,4,197,ASH,1,1,10,0.86293,9153.966952460185,317.24481664160703,3.465653943139324,432.9807 -MPNet,25,4,197,ASH,1,2,20,0.94033,7114.7162102085695,46.99403949422588,0.6605188191033727,598.6739 -MPNet,25,4,197,ASH,8,1,10,0.86293,67390.32073511016,2298.9017978117477,3.4113234255821347,432.9807 -MPNet,25,4,197,ASH,8,2,20,0.94033,54357.67614973744,353.40026548673944,0.6501386566144558,598.6739 -Cohere,22,2,133,PQ,1,1,10,0.59431,9694.693384586766,325.22511177435325,3.3546714565662965,509.2777 -Cohere,22,2,133,PQ,1,2,20,0.78292,7699.839253969693,42.40381740356734,0.5507104240092522,715.1325 -Cohere,22,2,133,PQ,8,1,10,0.59431,73999.92617936898,2539.3386732272957,3.431542170828892,509.2777 -Cohere,22,2,133,PQ,8,2,20,0.78292,59164.921945419286,2035.060877910483,3.4396409409410924,715.1325 -Cohere,22,2,133,ASH,1,1,10,0.63903,6040.668013555356,274.3959501873643,4.542476917645787,567.867 -Cohere,22,2,133,ASH,1,2,20,0.81995,5224.424204577702,61.298718058874684,1.173310505780982,812.9255 -Cohere,22,2,133,ASH,8,1,10,0.63903,47759.13958907922,2166.440848887819,4.5361806505057,567.867 -Cohere,22,2,133,ASH,8,2,20,0.81995,39833.20097312,632.0039581039011,1.586626087444959,812.9255 -Cohere,22,4,261,PQ,1,1,10,0.65081,7655.785166781064,175.76862112499447,2.2958928091094513,507.7788 -Cohere,22,4,261,PQ,1,2,20,0.81039,5826.0870091323695,7.660713163915625,0.13148985162609977,712.6111 -Cohere,22,4,261,PQ,8,1,10,0.65081,57544.851973715246,1423.1567643985286,2.4731261191680254,507.7788 -Cohere,22,4,261,PQ,8,2,20,0.81039,43365.895255088246,343.3827570210426,0.7918267454209942,712.6111 -Cohere,22,4,261,ASH,1,1,10,0.70693,5459.5384791205415,302.9731487020324,5.549427847440271,540.8918 -Cohere,22,4,261,ASH,1,2,20,0.83397,4609.658123480801,55.2745257320803,1.1991024985241623,770.202 -Cohere,22,4,261,ASH,8,1,10,0.70693,41672.84985305652,708.8527180104267,1.7009941016991315,540.8918 -Cohere,22,4,261,ASH,8,2,20,0.83397,35207.00369774443,556.6259719487867,1.5810092126199524,770.202 -Cohere,25,2,133,PQ,1,1,10,0.5913,9412.081288856038,112.56406605696156,1.195952973655658,515.0213 -Cohere,25,2,133,PQ,1,2,20,0.7845,7190.940014143866,167.83733226091644,2.334011018459298,722.8076 -Cohere,25,2,133,PQ,8,1,10,0.5913,75637.85852751213,1813.227802143092,2.3972489933510714,515.0213 -Cohere,25,2,133,PQ,8,2,20,0.7845,57862.554944797805,522.6740498873122,0.903302749741962,722.8076 -Cohere,25,2,133,ASH,1,1,10,0.63975,6427.095638763094,169.83121886243185,2.642425574596182,565.5602 -Cohere,25,2,133,ASH,1,2,20,0.82327,5258.107673080131,63.42893861825412,1.2063073364395043,811.3677 -Cohere,25,2,133,ASH,8,1,10,0.63975,50148.40516839785,824.0291437743359,1.6431811560253098,565.5602 -Cohere,25,2,133,ASH,8,2,20,0.82327,41901.690380100095,454.39922005212,1.084441262226315,811.3677 -Cohere,25,4,261,PQ,1,1,10,0.6553,7857.8449638838065,139.06736295154764,1.7697901090022579,505.9876 -Cohere,25,4,261,PQ,1,2,20,0.81365,5920.816567354704,28.88024786803325,0.4877747442349213,710.3709 -Cohere,25,4,261,PQ,8,1,10,0.6553,57670.64664851298,1069.5685083266228,1.854615078005548,505.9876 -Cohere,25,4,261,PQ,8,2,20,0.81365,43804.78320505803,222.48459131611997,0.5079002223903946,710.3709 -Cohere,25,4,261,ASH,1,1,10,0.7059,5662.326694410597,193.66647724429077,3.420263218571671,539.3398 -Cohere,25,4,261,ASH,1,2,20,0.83696,4635.7196414662285,61.53701677204011,1.3274533736163694,770.3046 -Cohere,25,4,261,ASH,8,1,10,0.7059,42689.45179035281,153.1711013850193,0.3588031585349,539.3398 -Cohere,25,4,261,ASH,8,2,20,0.83696,35644.71872678161,417.585373344066,1.171521022636977,770.3046 -Ada002,22,2,197,PQ,1,1,10,0.54986,7064.577838319312,345.1160615646333,4.885161852031312,458.0818 -Ada002,22,2,197,PQ,1,2,20,0.75347,5685.492109827316,228.87502457014529,4.025597435524308,624.9491 -Ada002,22,2,197,PQ,8,1,10,0.54986,53847.213088554396,938.0679515199026,1.7420919258666252,458.0818 -Ada002,22,2,197,PQ,8,2,20,0.75347,45513.06620677013,369.25509208384045,0.8113166676274456,624.9491 -Ada002,22,2,197,ASH,1,1,10,0.7888,3301.2900012077635,172.72057577933134,5.2319116380609465,501.4557 -Ada002,22,2,197,ASH,1,2,20,0.92685,2987.2294544484503,75.4558191294618,2.5259465427771652,706.5122 -Ada002,22,2,197,ASH,8,1,10,0.7888,24911.127856122304,756.1189899379334,3.035265983559653,501.4557 -Ada002,22,2,197,ASH,8,2,20,0.92685,22127.98341769791,90.58944642512024,0.40938862215825333,706.5122 -Ada002,22,4,389,PQ,1,1,10,0.73555,5341.342726973948,197.28391094169163,3.6935265349179285,477.3403 -Ada002,22,4,389,PQ,1,2,20,0.90049,4200.0630432290845,27.34394359857975,0.6510365039082182,659.2552 -Ada002,22,4,389,PQ,8,1,10,0.73555,41038.5274305757,1647.1171751077788,4.013587421951686,477.3403 -Ada002,22,4,389,PQ,8,2,20,0.90049,30912.637087924762,630.078503069555,2.0382554269874285,659.2552 -Ada002,22,4,389,ASH,1,1,10,0.83231,2975.630205424497,207.2121051545478,6.96363764478548,475.3654 -Ada002,22,4,389,ASH,1,2,20,0.92443,2698.9527131275813,56.93823605602251,2.109641854007948,666.273 -Ada002,22,4,389,ASH,8,1,10,0.83231,21870.622987666935,2054.0557899992486,9.391848559401126,475.3654 -Ada002,22,4,389,ASH,8,2,20,0.92443,19258.54676558465,604.5771747646882,3.139266851874191,666.273 -Ada002,25,2,197,PQ,1,1,10,0.54594,7271.180761294964,164.92385726492031,2.2681853563979906,466.6089 -Ada002,25,2,197,PQ,1,2,20,0.7504,5711.746382358798,17.585559682677623,0.3078841129394695,636.743 -Ada002,25,2,197,PQ,8,1,10,0.54594,49406.674887911795,1948.6084898358451,3.9440186862536795,466.6089 -Ada002,25,2,197,PQ,8,2,20,0.7504,43937.988185794646,435.3744690698342,0.9908839413148024,636.743 -Ada002,25,2,197,ASH,1,1,10,0.79064,3436.8450622860405,85.67191578506873,2.4927488505426973,495.6071 -Ada002,25,2,197,ASH,1,2,20,0.92718,3096.883668745141,86.13452376442987,2.781329006114447,699.8394 -Ada002,25,2,197,ASH,8,1,10,0.79064,25148.668295078558,1423.1286862834309,5.658863004535029,495.6071 -Ada002,25,2,197,ASH,8,2,20,0.92718,22643.408793697028,202.7696533831723,0.8954908478250626,699.8394 -Ada002,25,4,389,PQ,1,1,10,0.73566,3664.671687680422,20.175300036192812,0.5505349934624812,474.1962 -Ada002,25,4,389,PQ,1,2,20,0.9015,3883.6968894121583,60.99347982756172,1.5705005195911101,656.8223 -Ada002,25,4,389,PQ,8,1,10,0.73566,26979.66563109857,1180.029335083216,4.373773015641214,474.1962 -Ada002,25,4,389,PQ,8,2,20,0.9015,22435.812911872614,174.55195220413958,0.7780059179927015,656.8223 -Ada002,25,4,389,ASH,1,1,10,0.83528,2958.5765700966863,117.34517913112595,3.9662714941087738,475.7187 -Ada002,25,4,389,ASH,1,2,20,0.92467,2649.82808625031,112.41385140796983,4.2423073402864855,664.8421 -Ada002,25,4,389,ASH,8,1,10,0.83528,21982.92028578378,927.6380025677681,4.2198124294144215,475.7187 -Ada002,25,4,389,ASH,8,2,20,0.92467,20075.60298373417,614.8878491828754,3.0628611737394644,664.8421 -OpenAI3072,22,2,389,PQ,1,1,10,0.6437,3329.077370787888,410.3285959728882,12.325595060464947,471.041 -OpenAI3072,22,2,389,PQ,1,2,20,0.7985,2953.6549674392622,20.699428291440878,0.7008072547277489,653.509 -OpenAI3072,22,2,389,PQ,8,1,10,0.6437,27543.90063678985,1082.175435232485,3.928911338675991,471.041 -OpenAI3072,22,2,389,PQ,8,2,20,0.7985,22784.923597112218,198.6192438432833,0.8717134512070802,653.509 -OpenAI3072,22,2,389,ASH,1,1,10,0.7792,1031.0076970878936,45.92267428032494,4.454154358889332,530.783 -OpenAI3072,22,2,389,ASH,1,2,20,0.898,1068.223215503725,5.4895348079781785,0.5138939809868825,767.32 -OpenAI3072,22,2,389,ASH,8,1,10,0.7792,7189.703298060613,411.1343239182431,5.718376779597353,530.783 -OpenAI3072,22,2,389,ASH,8,2,20,0.898,7037.381528041226,98.28375598243322,1.3965955318865506,767.32 -OpenAI3072,22,4,773,PQ,1,1,10,0.7691,2143.912280952585,170.47402337973324,7.951539104201971,500.603 -OpenAI3072,22,4,773,PQ,1,2,20,0.877,1871.3707850777212,16.099488501314273,0.8603045761797353,704.466 -OpenAI3072,22,4,773,PQ,8,1,10,0.7691,18466.799971430668,859.2042061471868,4.652696771917339,500.603 -OpenAI3072,22,4,773,PQ,8,2,20,0.877,14336.390737938298,126.38455072730488,0.8815646353224336,704.466 -OpenAI3072,22,4,773,ASH,1,1,10,0.7903,1016.1509827131623,48.29108771527011,4.752353590834606,512.685 -OpenAI3072,22,4,773,ASH,1,2,20,0.8834,1030.8662536443949,5.289502308681586,0.5131123741786817,735.241 -OpenAI3072,22,4,773,ASH,8,1,10,0.7903,7507.82372210983,379.60972307236995,5.056188545749353,512.685 -OpenAI3072,22,4,773,ASH,8,2,20,0.8834,7360.509055849552,197.69692384205354,2.6859137369709454,735.241 -OpenAI3072,25,2,389,PQ,1,1,10,0.651,2170.3203408432223,257.79510337346886,11.878205190359557,466.217 -OpenAI3072,25,2,389,PQ,1,2,20,0.8032,2068.413509232902,11.500305926225575,0.5559964617757023,646.845 -OpenAI3072,25,2,389,PQ,8,1,10,0.651,27772.466077225967,451.6869097187096,1.6263838740957284,466.217 -OpenAI3072,25,2,389,PQ,8,2,20,0.8032,23213.773739290547,250.71842094376962,1.0800416328665052,646.845 -OpenAI3072,25,2,389,ASH,1,1,10,0.7676,1003.0125753831542,80.02276597676175,7.978241543601064,532.42 -OpenAI3072,25,2,389,ASH,1,2,20,0.8917,1093.9677679889598,6.6161315083382,0.6047830385808011,770.301 -OpenAI3072,25,2,389,ASH,8,1,10,0.7676,8050.426675043233,454.19173972355793,5.641834377941449,532.42 -OpenAI3072,25,2,389,ASH,8,2,20,0.8917,7893.088397249235,67.54153630967923,0.8557048003316124,770.301 -OpenAI3072,25,4,773,PQ,1,1,10,0.7624,2280.759101685725,228.66633119364224,10.025887040180322,501.437 -OpenAI3072,25,4,773,PQ,1,2,20,0.8727,1886.2499788294274,10.71340400192702,0.5679737109169148,705.403 -OpenAI3072,25,4,773,PQ,8,1,10,0.7624,15663.804691288786,513.7893416245771,3.280105643236947,501.437 -OpenAI3072,25,4,773,PQ,8,2,20,0.8727,12884.883579153513,87.90944686405366,0.6822680726916507,705.403 -OpenAI3072,25,4,773,ASH,1,1,10,0.7965,964.9450620510337,132.69872297613534,13.751945908099502,510.697 -OpenAI3072,25,4,773,ASH,1,2,20,0.8875,1033.6181669420773,9.002443152648716,0.8709640987911547,730.922 -OpenAI3072,25,4,773,ASH,8,1,10,0.7965,7915.294433750889,199.40736877030656,2.519266597588988,510.697 -OpenAI3072,25,4,773,ASH,8,2,20,0.8875,7489.111448400777,101.00421873730507,1.34868094076599,730.922 diff --git a/docs/benchmarks/native-pq-ash-graph-20261028/raw-results.json b/docs/benchmarks/native-pq-ash-graph-20261028/raw-results.json deleted file mode 100644 index 8beae84b2..000000000 --- a/docs/benchmarks/native-pq-ash-graph-20261028/raw-results.json +++ /dev/null @@ -1,8098 +0,0 @@ -[ - { - "dataset": "mpnet-1m", - "display_name": "MPNet", - "original_dimensions": "768", - "projected_ash_dimensions": "384", - "base_vectors": "999812", - "queries": "10000", - "ash_bits": "2", - "algorithm": "PQ", - "code_bytes": "101", - "ash_encoded_bits": "", - "pq_m": "101", - "config": "mpnet-1m-2bit-budget-pq.yml", - "jdk": "22", - "label": "jdk22-mpnet-1m-2bit-pq", - "path": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk22-mpnet-1m-2bit-pq", - "source": "b44e25b71e3d9c5c7a2f164610b0fdbd099d91fa", - "started": "2026-10-29T00:49:17.628997+00:00", - "phases": { - "build": { - "elapsed_process_seconds": 52.27324899379164, - "log": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/logs/jdk22-mpnet-1m-2bit-pq-build.log", - "csv": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk22-mpnet-1m-2bit-pq/logging/jdk22-mpnet-1m-2bit-pq-build-20261029-004918Z/experiments.csv", - "rows": [ - { - "schema_version": "1", - "run_id": "jdk22-mpnet-1m-2bit-pq-build-20261029-004918Z", - "run_uuid": "2e4c89c4-62bd-4292-b2cc-632c940e6d75", - "system_id": "d940322e03a3", - "dataset_name": "mpnet-1m", - "M": "32", - "efConstruction": "100", - "neighborOverflow": "1.2", - "addHierarchy": "true", - "refineFinalGraph": "true", - "feature_set": "[NVQ_VECTORS, FUSED_PQ]", - "build_compressor": "\"ProductQuantization(M=101, clusters=256, centered=false)\"", - "search_compressor": "None", - "usePruning": "false", - "topK": "10", - "overquery": "1.0", - "rerankK": "10", - "search.accuracy.recall_at_10": "0.65128", - "construction.index_build_time_s": "44.438387038", - "construction.index_quant_time_s.NVQ.compute_time_s": "0.275686754", - "construction.index_quant_time_s.PQ.compute_time_s": "2.729675109", - "construction.index_quant_time_s.PQ.encoding_time_s": "1.081207084" - } - ], - "throughput_samples": [], - "finished": "2026-10-29T00:50:09.903112+00:00" - }, - "t1": { - "elapsed_process_seconds": 37.240249725058675, - "log": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/logs/jdk22-mpnet-1m-2bit-pq-t1.log", - "csv": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk22-mpnet-1m-2bit-pq/logging/jdk22-mpnet-1m-2bit-pq-t1-20261029-005011Z/experiments.csv", - "rows": [ - { - "schema_version": "1", - "run_id": "jdk22-mpnet-1m-2bit-pq-t1-20261029-005011Z", - "run_uuid": "201a4058-0405-43a4-90e5-70bf8255ef52", - "system_id": "d940322e03a3", - "dataset_name": "mpnet-1m", - "M": "32", - "efConstruction": "100", - "neighborOverflow": "1.2", - "addHierarchy": "true", - "refineFinalGraph": "true", - "feature_set": "[NVQ_VECTORS, FUSED_PQ]", - "build_compressor": "\"ProductQuantization(M=101, clusters=256, centered=false)\"", - "search_compressor": "None", - "usePruning": "false", - "topK": "10", - "overquery": "1.0", - "rerankK": "10", - "search.throughput.avg_qps": "11929.667667640202", - "search.throughput.stddev_qps": "329.8666343047988", - "search.throughput.cv_pct": "2.7650949170996433", - "search.count.avg_visited": "417.1787", - "search.accuracy.recall_at_10": "0.65128", - "construction.index_build_time_s": "", - "construction.index_quant_time_s.NVQ.compute_time_s": "", - "construction.index_quant_time_s.PQ.compute_time_s": "", - "construction.index_quant_time_s.PQ.encoding_time_s": "" - }, - { - "schema_version": "1", - "run_id": "jdk22-mpnet-1m-2bit-pq-t1-20261029-005011Z", - "run_uuid": "201a4058-0405-43a4-90e5-70bf8255ef52", - "system_id": "d940322e03a3", - "dataset_name": "mpnet-1m", - "M": "32", - "efConstruction": "100", - "neighborOverflow": "1.2", - "addHierarchy": "true", - "refineFinalGraph": "true", - "feature_set": "[NVQ_VECTORS, FUSED_PQ]", - "build_compressor": "\"ProductQuantization(M=101, clusters=256, centered=false)\"", - "search_compressor": "None", - "usePruning": "false", - "topK": "10", - "overquery": "2.0", - "rerankK": "20", - "search.throughput.avg_qps": "9441.313656397691", - "search.throughput.stddev_qps": "84.63498370312523", - "search.throughput.cv_pct": "0.8964322845663989", - "search.count.avg_visited": "564.0539", - "search.accuracy.recall_at_10": "0.82126", - "construction.index_build_time_s": "", - "construction.index_quant_time_s.NVQ.compute_time_s": "", - "construction.index_quant_time_s.PQ.compute_time_s": "", - "construction.index_quant_time_s.PQ.encoding_time_s": "" - } - ], - "throughput_samples": [ - { - "sample": "Test-0", - "queries": 20000, - "seconds": 1.731411 - }, - { - "sample": "Test-1", - "queries": 20000, - "seconds": 1.655472 - }, - { - "sample": "Test-2", - "queries": 20000, - "seconds": 1.645198 - }, - { - "sample": "Test-0", - "queries": 10000, - "seconds": 1.070246 - }, - { - "sample": "Test-1", - "queries": 10000, - "seconds": 1.053404 - }, - { - "sample": "Test-2", - "queries": 10000, - "seconds": 1.054045 - } - ], - "finished": "2026-10-29T00:50:47.144820+00:00" - }, - "t8": { - "elapsed_process_seconds": 17.557420658878982, - "log": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/logs/jdk22-mpnet-1m-2bit-pq-t8.log", - "csv": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk22-mpnet-1m-2bit-pq/logging/jdk22-mpnet-1m-2bit-pq-t8-20261029-005047Z/experiments.csv", - "rows": [ - { - "schema_version": "1", - "run_id": "jdk22-mpnet-1m-2bit-pq-t8-20261029-005047Z", - "run_uuid": "2cd0e0ee-b60f-497b-839a-eab0f3af2676", - "system_id": "d940322e03a3", - "dataset_name": "mpnet-1m", - "M": "32", - "efConstruction": "100", - "neighborOverflow": "1.2", - "addHierarchy": "true", - "refineFinalGraph": "true", - "feature_set": "[NVQ_VECTORS, FUSED_PQ]", - "build_compressor": "\"ProductQuantization(M=101, clusters=256, centered=false)\"", - "search_compressor": "None", - "usePruning": "false", - "topK": "10", - "overquery": "1.0", - "rerankK": "10", - "search.throughput.avg_qps": "86214.6647201115", - "search.throughput.stddev_qps": "3212.690631712721", - "search.throughput.cv_pct": "3.7263853453962215", - "search.count.avg_visited": "417.1787", - "search.accuracy.recall_at_10": "0.65128", - "construction.index_build_time_s": "", - "construction.index_quant_time_s.NVQ.compute_time_s": "", - "construction.index_quant_time_s.PQ.compute_time_s": "", - "construction.index_quant_time_s.PQ.encoding_time_s": "" - }, - { - "schema_version": "1", - "run_id": "jdk22-mpnet-1m-2bit-pq-t8-20261029-005047Z", - "run_uuid": "2cd0e0ee-b60f-497b-839a-eab0f3af2676", - "system_id": "d940322e03a3", - "dataset_name": "mpnet-1m", - "M": "32", - "efConstruction": "100", - "neighborOverflow": "1.2", - "addHierarchy": "true", - "refineFinalGraph": "true", - "feature_set": "[NVQ_VECTORS, FUSED_PQ]", - "build_compressor": "\"ProductQuantization(M=101, clusters=256, centered=false)\"", - "search_compressor": "None", - "usePruning": "false", - "topK": "10", - "overquery": "2.0", - "rerankK": "20", - "search.throughput.avg_qps": "68759.66645239317", - "search.throughput.stddev_qps": "938.1522559268892", - "search.throughput.cv_pct": "1.3643932618207706", - "search.count.avg_visited": "564.0539", - "search.accuracy.recall_at_10": "0.82126", - "construction.index_build_time_s": "", - "construction.index_quant_time_s.NVQ.compute_time_s": "", - "construction.index_quant_time_s.PQ.compute_time_s": "", - "construction.index_quant_time_s.PQ.encoding_time_s": "" - } - ], - "throughput_samples": [ - { - "sample": "Test-0", - "queries": 90000, - "seconds": 1.07914 - }, - { - "sample": "Test-1", - "queries": 90000, - "seconds": 1.052267 - }, - { - "sample": "Test-2", - "queries": 100000, - "seconds": 1.114646 - }, - { - "sample": "Test-0", - "queries": 70000, - "seconds": 1.00355 - }, - { - "sample": "Test-1", - "queries": 70000, - "seconds": 1.031114 - }, - { - "sample": "Test-2", - "queries": 70000, - "seconds": 1.01983 - } - ], - "finished": "2026-10-29T00:51:04.703720+00:00" - } - }, - "index_files": [ - { - "path": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk22-mpnet-1m-2bit-pq/index_cache/graph_mpnet_1m_FUSED_PQ_NVQ_VECTORS_M32_ef100_of1.2_alpha1.2_H1_R1_ProductQuantization_M_101_clusters_256_centered_false_", - "bytes": 4204460293 - } - ], - "finished": "2026-10-29T00:51:04.704834+00:00" - }, - { - "dataset": "mpnet-1m", - "display_name": "MPNet", - "original_dimensions": "768", - "projected_ash_dimensions": "384", - "base_vectors": "999812", - "queries": "10000", - "ash_bits": "4", - "algorithm": "PQ", - "code_bytes": "197", - "ash_encoded_bits": "", - "pq_m": "197", - "config": "mpnet-1m-4bit-budget-pq.yml", - "jdk": "22", - "label": "jdk22-mpnet-1m-4bit-pq", - "path": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk22-mpnet-1m-4bit-pq", - "source": "b44e25b71e3d9c5c7a2f164610b0fdbd099d91fa", - "started": "2026-10-29T00:53:34.216218+00:00", - "phases": { - "build": { - "elapsed_process_seconds": 62.748410480096936, - "log": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/logs/jdk22-mpnet-1m-4bit-pq-build.log", - "csv": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk22-mpnet-1m-4bit-pq/logging/jdk22-mpnet-1m-4bit-pq-build-20261029-005334Z/experiments.csv", - "rows": [ - { - "schema_version": "1", - "run_id": "jdk22-mpnet-1m-4bit-pq-build-20261029-005334Z", - "run_uuid": "7c75a71a-475d-4811-8931-b2d4d6d9ba53", - "system_id": "d940322e03a3", - "dataset_name": "mpnet-1m", - "M": "32", - "efConstruction": "100", - "neighborOverflow": "1.2", - "addHierarchy": "true", - "refineFinalGraph": "true", - "feature_set": "[NVQ_VECTORS, FUSED_PQ]", - "build_compressor": "\"ProductQuantization(M=197, clusters=256, centered=false)\"", - "search_compressor": "None", - "usePruning": "false", - "topK": "10", - "overquery": "1.0", - "rerankK": "10", - "search.accuracy.recall_at_10": "0.80733", - "construction.index_build_time_s": "50.786497509", - "construction.index_quant_time_s.NVQ.compute_time_s": "0.30227987", - "construction.index_quant_time_s.PQ.compute_time_s": "6.137772914", - "construction.index_quant_time_s.PQ.encoding_time_s": "1.70667817" - } - ], - "throughput_samples": [], - "finished": "2026-10-29T00:54:36.965571+00:00" - }, - "t1": { - "elapsed_process_seconds": 40.890649567358196, - "log": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/logs/jdk22-mpnet-1m-4bit-pq-t1.log", - "csv": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk22-mpnet-1m-4bit-pq/logging/jdk22-mpnet-1m-4bit-pq-t1-20261029-005438Z/experiments.csv", - "rows": [ - { - "schema_version": "1", - "run_id": "jdk22-mpnet-1m-4bit-pq-t1-20261029-005438Z", - "run_uuid": "110986bf-c4d0-4438-968d-c0f0bc3d6f99", - "system_id": "d940322e03a3", - "dataset_name": "mpnet-1m", - "M": "32", - "efConstruction": "100", - "neighborOverflow": "1.2", - "addHierarchy": "true", - "refineFinalGraph": "true", - "feature_set": "[NVQ_VECTORS, FUSED_PQ]", - "build_compressor": "\"ProductQuantization(M=197, clusters=256, centered=false)\"", - "search_compressor": "None", - "usePruning": "false", - "topK": "10", - "overquery": "1.0", - "rerankK": "10", - "search.throughput.avg_qps": "9642.851473210929", - "search.throughput.stddev_qps": "252.91163255244882", - "search.throughput.cv_pct": "2.622788842647526", - "search.count.avg_visited": "439.2968", - "search.accuracy.recall_at_10": "0.80733", - "construction.index_build_time_s": "", - "construction.index_quant_time_s.NVQ.compute_time_s": "", - "construction.index_quant_time_s.PQ.compute_time_s": "", - "construction.index_quant_time_s.PQ.encoding_time_s": "" - }, - { - "schema_version": "1", - "run_id": "jdk22-mpnet-1m-4bit-pq-t1-20261029-005438Z", - "run_uuid": "110986bf-c4d0-4438-968d-c0f0bc3d6f99", - "system_id": "d940322e03a3", - "dataset_name": "mpnet-1m", - "M": "32", - "efConstruction": "100", - "neighborOverflow": "1.2", - "addHierarchy": "true", - "refineFinalGraph": "true", - "feature_set": "[NVQ_VECTORS, FUSED_PQ]", - "build_compressor": "\"ProductQuantization(M=197, clusters=256, centered=false)\"", - "search_compressor": "None", - "usePruning": "false", - "topK": "10", - "overquery": "2.0", - "rerankK": "20", - "search.throughput.avg_qps": "7455.724453990442", - "search.throughput.stddev_qps": "29.65127525390488", - "search.throughput.cv_pct": "0.39769811018210266", - "search.count.avg_visited": "604.3334", - "search.accuracy.recall_at_10": "0.92753", - "construction.index_build_time_s": "", - "construction.index_quant_time_s.NVQ.compute_time_s": "", - "construction.index_quant_time_s.PQ.compute_time_s": "", - "construction.index_quant_time_s.PQ.encoding_time_s": "" - } - ], - "throughput_samples": [ - { - "sample": "Test-0", - "queries": 10000, - "seconds": 1.068192 - }, - { - "sample": "Test-1", - "queries": 10000, - "seconds": 1.0293 - }, - { - "sample": "Test-2", - "queries": 10000, - "seconds": 1.015063 - }, - { - "sample": "Test-0", - "queries": 10000, - "seconds": 1.347097 - }, - { - "sample": "Test-1", - "queries": 10000, - "seconds": 1.340087 - }, - { - "sample": "Test-2", - "queries": 10000, - "seconds": 1.336613 - } - ], - "finished": "2026-10-29T00:55:17.858173+00:00" - }, - "t8": { - "elapsed_process_seconds": 18.57581972051412, - "log": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/logs/jdk22-mpnet-1m-4bit-pq-t8.log", - "csv": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk22-mpnet-1m-4bit-pq/logging/jdk22-mpnet-1m-4bit-pq-t8-20261029-005518Z/experiments.csv", - "rows": [ - { - "schema_version": "1", - "run_id": "jdk22-mpnet-1m-4bit-pq-t8-20261029-005518Z", - "run_uuid": "280e8bdd-2528-4a21-a68f-c6eabc534695", - "system_id": "d940322e03a3", - "dataset_name": "mpnet-1m", - "M": "32", - "efConstruction": "100", - "neighborOverflow": "1.2", - "addHierarchy": "true", - "refineFinalGraph": "true", - "feature_set": "[NVQ_VECTORS, FUSED_PQ]", - "build_compressor": "\"ProductQuantization(M=197, clusters=256, centered=false)\"", - "search_compressor": "None", - "usePruning": "false", - "topK": "10", - "overquery": "1.0", - "rerankK": "10", - "search.throughput.avg_qps": "74892.80322512507", - "search.throughput.stddev_qps": "3888.4955727931288", - "search.throughput.cv_pct": "5.192081755979211", - "search.count.avg_visited": "439.2968", - "search.accuracy.recall_at_10": "0.80733", - "construction.index_build_time_s": "", - "construction.index_quant_time_s.NVQ.compute_time_s": "", - "construction.index_quant_time_s.PQ.compute_time_s": "", - "construction.index_quant_time_s.PQ.encoding_time_s": "" - }, - { - "schema_version": "1", - "run_id": "jdk22-mpnet-1m-4bit-pq-t8-20261029-005518Z", - "run_uuid": "280e8bdd-2528-4a21-a68f-c6eabc534695", - "system_id": "d940322e03a3", - "dataset_name": "mpnet-1m", - "M": "32", - "efConstruction": "100", - "neighborOverflow": "1.2", - "addHierarchy": "true", - "refineFinalGraph": "true", - "feature_set": "[NVQ_VECTORS, FUSED_PQ]", - "build_compressor": "\"ProductQuantization(M=197, clusters=256, centered=false)\"", - "search_compressor": "None", - "usePruning": "false", - "topK": "10", - "overquery": "2.0", - "rerankK": "20", - "search.throughput.avg_qps": "57187.4193258142", - "search.throughput.stddev_qps": "2011.126949231717", - "search.throughput.cv_pct": "3.516729681005034", - "search.count.avg_visited": "604.3334", - "search.accuracy.recall_at_10": "0.92753", - "construction.index_build_time_s": "", - "construction.index_quant_time_s.NVQ.compute_time_s": "", - "construction.index_quant_time_s.PQ.compute_time_s": "", - "construction.index_quant_time_s.PQ.encoding_time_s": "" - } - ], - "throughput_samples": [ - { - "sample": "Test-0", - "queries": 80000, - "seconds": 1.123119 - }, - { - "sample": "Test-1", - "queries": 80000, - "seconds": 1.012999 - }, - { - "sample": "Test-2", - "queries": 80000, - "seconds": 1.07419 - }, - { - "sample": "Test-0", - "queries": 60000, - "seconds": 1.013272 - }, - { - "sample": "Test-1", - "queries": 60000, - "seconds": 1.04976 - }, - { - "sample": "Test-2", - "queries": 60000, - "seconds": 1.08711 - } - ], - "finished": "2026-10-29T00:55:36.435469+00:00" - } - }, - "index_files": [ - { - "path": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk22-mpnet-1m-4bit-pq/index_cache/graph_mpnet_1m_FUSED_PQ_NVQ_VECTORS_M32_ef100_of1.2_alpha1.2_H1_R1_ProductQuantization_M_197_clusters_256_centered_false_", - "bytes": 7278884955 - } - ], - "finished": "2026-10-29T00:55:36.436064+00:00" - }, - { - "dataset": "cohere-english-v3-1M", - "display_name": "Cohere", - "original_dimensions": "1024", - "projected_ash_dimensions": "512", - "base_vectors": "1000000", - "queries": "10000", - "ash_bits": "2", - "algorithm": "PQ", - "code_bytes": "133", - "ash_encoded_bits": "", - "pq_m": "133", - "config": "cohere-english-v3-1M-2bit-budget-pq.yml", - "jdk": "22", - "label": "jdk22-cohere-english-v3-1M-2bit-pq", - "path": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk22-cohere-english-v3-1M-2bit-pq", - "source": "b44e25b71e3d9c5c7a2f164610b0fdbd099d91fa", - "started": "2026-10-29T00:57:41.695362+00:00", - "phases": { - "build": { - "elapsed_process_seconds": 44.332542745396495, - "log": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/logs/jdk22-cohere-english-v3-1M-2bit-pq-build.log", - "csv": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk22-cohere-english-v3-1M-2bit-pq/logging/jdk22-cohere-english-v3-1M-2bit-pq-build-20261029-005742Z/experiments.csv", - "rows": [ - { - "schema_version": "1", - "run_id": "jdk22-cohere-english-v3-1M-2bit-pq-build-20261029-005742Z", - "run_uuid": "f625315b-fe12-44b2-917b-5f1e9a0556f0", - "system_id": "d940322e03a3", - "dataset_name": "cohere-english-v3-1M", - "M": "32", - "efConstruction": "100", - "neighborOverflow": "1.2", - "addHierarchy": "true", - "refineFinalGraph": "true", - "feature_set": "[NVQ_VECTORS, FUSED_PQ]", - "build_compressor": "\"ProductQuantization(M=133, clusters=256, centered=false)\"", - "search_compressor": "None", - "usePruning": "false", - "topK": "10", - "overquery": "1.0", - "rerankK": "10", - "search.accuracy.recall_at_10": "0.59431", - "construction.index_build_time_s": "35.40040222", - "construction.index_quant_time_s.NVQ.compute_time_s": "0.35765844", - "construction.index_quant_time_s.PQ.compute_time_s": "2.969167141", - "construction.index_quant_time_s.PQ.encoding_time_s": "1.324768325" - } - ], - "throughput_samples": [], - "finished": "2026-10-29T00:58:26.028783+00:00" - }, - "t1": { - "elapsed_process_seconds": 39.70170094911009, - "log": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/logs/jdk22-cohere-english-v3-1M-2bit-pq-t1.log", - "csv": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk22-cohere-english-v3-1M-2bit-pq/logging/jdk22-cohere-english-v3-1M-2bit-pq-t1-20261029-005827Z/experiments.csv", - "rows": [ - { - "schema_version": "1", - "run_id": "jdk22-cohere-english-v3-1M-2bit-pq-t1-20261029-005827Z", - "run_uuid": "84406986-07ef-4a0e-824e-201f1fc77156", - "system_id": "d940322e03a3", - "dataset_name": "cohere-english-v3-1M", - "M": "32", - "efConstruction": "100", - "neighborOverflow": "1.2", - "addHierarchy": "true", - "refineFinalGraph": "true", - "feature_set": "[NVQ_VECTORS, FUSED_PQ]", - "build_compressor": "\"ProductQuantization(M=133, clusters=256, centered=false)\"", - "search_compressor": "None", - "usePruning": "false", - "topK": "10", - "overquery": "1.0", - "rerankK": "10", - "search.throughput.avg_qps": "9694.693384586766", - "search.throughput.stddev_qps": "325.22511177435325", - "search.throughput.cv_pct": "3.3546714565662965", - "search.count.avg_visited": "509.2777", - "search.accuracy.recall_at_10": "0.59431", - "construction.index_build_time_s": "", - "construction.index_quant_time_s.NVQ.compute_time_s": "", - "construction.index_quant_time_s.PQ.compute_time_s": "", - "construction.index_quant_time_s.PQ.encoding_time_s": "" - }, - { - "schema_version": "1", - "run_id": "jdk22-cohere-english-v3-1M-2bit-pq-t1-20261029-005827Z", - "run_uuid": "84406986-07ef-4a0e-824e-201f1fc77156", - "system_id": "d940322e03a3", - "dataset_name": "cohere-english-v3-1M", - "M": "32", - "efConstruction": "100", - "neighborOverflow": "1.2", - "addHierarchy": "true", - "refineFinalGraph": "true", - "feature_set": "[NVQ_VECTORS, FUSED_PQ]", - "build_compressor": "\"ProductQuantization(M=133, clusters=256, centered=false)\"", - "search_compressor": "None", - "usePruning": "false", - "topK": "10", - "overquery": "2.0", - "rerankK": "20", - "search.throughput.avg_qps": "7699.839253969693", - "search.throughput.stddev_qps": "42.40381740356734", - "search.throughput.cv_pct": "0.5507104240092522", - "search.count.avg_visited": "715.1325", - "search.accuracy.recall_at_10": "0.78292", - "construction.index_build_time_s": "", - "construction.index_quant_time_s.NVQ.compute_time_s": "", - "construction.index_quant_time_s.PQ.compute_time_s": "", - "construction.index_quant_time_s.PQ.encoding_time_s": "" - } - ], - "throughput_samples": [ - { - "sample": "Test-0", - "queries": 10000, - "seconds": 1.070627 - }, - { - "sample": "Test-1", - "queries": 10000, - "seconds": 1.024146 - }, - { - "sample": "Test-2", - "queries": 10000, - "seconds": 1.002052 - }, - { - "sample": "Test-0", - "queries": 10000, - "seconds": 1.305568 - }, - { - "sample": "Test-1", - "queries": 10000, - "seconds": 1.299381 - }, - { - "sample": "Test-2", - "queries": 10000, - "seconds": 1.291315 - } - ], - "finished": "2026-10-29T00:59:05.731799+00:00" - }, - "t8": { - "elapsed_process_seconds": 19.1846497124061, - "log": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/logs/jdk22-cohere-english-v3-1M-2bit-pq-t8.log", - "csv": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk22-cohere-english-v3-1M-2bit-pq/logging/jdk22-cohere-english-v3-1M-2bit-pq-t8-20261029-005906Z/experiments.csv", - "rows": [ - { - "schema_version": "1", - "run_id": "jdk22-cohere-english-v3-1M-2bit-pq-t8-20261029-005906Z", - "run_uuid": "86f7cde3-5919-4673-bfe0-9d411e749e8b", - "system_id": "d940322e03a3", - "dataset_name": "cohere-english-v3-1M", - "M": "32", - "efConstruction": "100", - "neighborOverflow": "1.2", - "addHierarchy": "true", - "refineFinalGraph": "true", - "feature_set": "[NVQ_VECTORS, FUSED_PQ]", - "build_compressor": "\"ProductQuantization(M=133, clusters=256, centered=false)\"", - "search_compressor": "None", - "usePruning": "false", - "topK": "10", - "overquery": "1.0", - "rerankK": "10", - "search.throughput.avg_qps": "73999.92617936898", - "search.throughput.stddev_qps": "2539.3386732272957", - "search.throughput.cv_pct": "3.431542170828892", - "search.count.avg_visited": "509.2777", - "search.accuracy.recall_at_10": "0.59431", - "construction.index_build_time_s": "", - "construction.index_quant_time_s.NVQ.compute_time_s": "", - "construction.index_quant_time_s.PQ.compute_time_s": "", - "construction.index_quant_time_s.PQ.encoding_time_s": "" - }, - { - "schema_version": "1", - "run_id": "jdk22-cohere-english-v3-1M-2bit-pq-t8-20261029-005906Z", - "run_uuid": "86f7cde3-5919-4673-bfe0-9d411e749e8b", - "system_id": "d940322e03a3", - "dataset_name": "cohere-english-v3-1M", - "M": "32", - "efConstruction": "100", - "neighborOverflow": "1.2", - "addHierarchy": "true", - "refineFinalGraph": "true", - "feature_set": "[NVQ_VECTORS, FUSED_PQ]", - "build_compressor": "\"ProductQuantization(M=133, clusters=256, centered=false)\"", - "search_compressor": "None", - "usePruning": "false", - "topK": "10", - "overquery": "2.0", - "rerankK": "20", - "search.throughput.avg_qps": "59164.921945419286", - "search.throughput.stddev_qps": "2035.060877910483", - "search.throughput.cv_pct": "3.4396409409410924", - "search.count.avg_visited": "715.1325", - "search.accuracy.recall_at_10": "0.78292", - "construction.index_build_time_s": "", - "construction.index_quant_time_s.NVQ.compute_time_s": "", - "construction.index_quant_time_s.PQ.compute_time_s": "", - "construction.index_quant_time_s.PQ.encoding_time_s": "" - } - ], - "throughput_samples": [ - { - "sample": "Test-0", - "queries": 80000, - "seconds": 1.096118 - }, - { - "sample": "Test-1", - "queries": 80000, - "seconds": 1.109183 - }, - { - "sample": "Test-2", - "queries": 80000, - "seconds": 1.04045 - }, - { - "sample": "Test-0", - "queries": 70000, - "seconds": 1.147772 - }, - { - "sample": "Test-1", - "queries": 60000, - "seconds": 1.053201 - }, - { - "sample": "Test-2", - "queries": 60000, - "seconds": 1.007762 - } - ], - "finished": "2026-10-29T00:59:24.917880+00:00" - } - }, - "index_files": [ - { - "path": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk22-cohere-english-v3-1M-2bit-pq/index_cache/graph_cohere_english_v3_1M_FUSED_PQ_NVQ_VECTORS_M32_ef100_of1.2_alpha1.2_H1_R1_ProductQuantization_M_133_clusters_256_centered_false_", - "bytes": 5486775034 - } - ], - "finished": "2026-10-29T00:59:24.918421+00:00" - }, - { - "dataset": "cohere-english-v3-1M", - "display_name": "Cohere", - "original_dimensions": "1024", - "projected_ash_dimensions": "512", - "base_vectors": "1000000", - "queries": "10000", - "ash_bits": "4", - "algorithm": "PQ", - "code_bytes": "261", - "ash_encoded_bits": "", - "pq_m": "261", - "config": "cohere-english-v3-1M-4bit-budget-pq.yml", - "jdk": "22", - "label": "jdk22-cohere-english-v3-1M-4bit-pq", - "path": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk22-cohere-english-v3-1M-4bit-pq", - "source": "b44e25b71e3d9c5c7a2f164610b0fdbd099d91fa", - "started": "2026-10-29T01:01:51.542173+00:00", - "phases": { - "build": { - "elapsed_process_seconds": 84.35468028020114, - "log": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/logs/jdk22-cohere-english-v3-1M-4bit-pq-build.log", - "csv": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk22-cohere-english-v3-1M-4bit-pq/logging/jdk22-cohere-english-v3-1M-4bit-pq-build-20261029-010151Z/experiments.csv", - "rows": [ - { - "schema_version": "1", - "run_id": "jdk22-cohere-english-v3-1M-4bit-pq-build-20261029-010151Z", - "run_uuid": "4c3a36bb-7db3-4e65-a81b-ec6494e5400b", - "system_id": "d940322e03a3", - "dataset_name": "cohere-english-v3-1M", - "M": "32", - "efConstruction": "100", - "neighborOverflow": "1.2", - "addHierarchy": "true", - "refineFinalGraph": "true", - "feature_set": "[NVQ_VECTORS, FUSED_PQ]", - "build_compressor": "\"ProductQuantization(M=261, clusters=256, centered=false)\"", - "search_compressor": "None", - "usePruning": "false", - "topK": "10", - "overquery": "1.0", - "rerankK": "10", - "search.accuracy.recall_at_10": "0.65081", - "construction.index_build_time_s": "70.711462575", - "construction.index_quant_time_s.NVQ.compute_time_s": "0.34194332", - "construction.index_quant_time_s.PQ.compute_time_s": "6.753853519", - "construction.index_quant_time_s.PQ.encoding_time_s": "2.302340857" - } - ], - "throughput_samples": [], - "finished": "2026-10-29T01:03:15.897796+00:00" - }, - "t1": { - "elapsed_process_seconds": 48.445268898271024, - "log": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/logs/jdk22-cohere-english-v3-1M-4bit-pq-t1.log", - "csv": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk22-cohere-english-v3-1M-4bit-pq/logging/jdk22-cohere-english-v3-1M-4bit-pq-t1-20261029-010317Z/experiments.csv", - "rows": [ - { - "schema_version": "1", - "run_id": "jdk22-cohere-english-v3-1M-4bit-pq-t1-20261029-010317Z", - "run_uuid": "63d21f1b-ced1-4a78-b185-5e3cbacae099", - "system_id": "d940322e03a3", - "dataset_name": "cohere-english-v3-1M", - "M": "32", - "efConstruction": "100", - "neighborOverflow": "1.2", - "addHierarchy": "true", - "refineFinalGraph": "true", - "feature_set": "[NVQ_VECTORS, FUSED_PQ]", - "build_compressor": "\"ProductQuantization(M=261, clusters=256, centered=false)\"", - "search_compressor": "None", - "usePruning": "false", - "topK": "10", - "overquery": "1.0", - "rerankK": "10", - "search.throughput.avg_qps": "7655.785166781064", - "search.throughput.stddev_qps": "175.76862112499447", - "search.throughput.cv_pct": "2.2958928091094513", - "search.count.avg_visited": "507.7788", - "search.accuracy.recall_at_10": "0.65081", - "construction.index_build_time_s": "", - "construction.index_quant_time_s.NVQ.compute_time_s": "", - "construction.index_quant_time_s.PQ.compute_time_s": "", - "construction.index_quant_time_s.PQ.encoding_time_s": "" - }, - { - "schema_version": "1", - "run_id": "jdk22-cohere-english-v3-1M-4bit-pq-t1-20261029-010317Z", - "run_uuid": "63d21f1b-ced1-4a78-b185-5e3cbacae099", - "system_id": "d940322e03a3", - "dataset_name": "cohere-english-v3-1M", - "M": "32", - "efConstruction": "100", - "neighborOverflow": "1.2", - "addHierarchy": "true", - "refineFinalGraph": "true", - "feature_set": "[NVQ_VECTORS, FUSED_PQ]", - "build_compressor": "\"ProductQuantization(M=261, clusters=256, centered=false)\"", - "search_compressor": "None", - "usePruning": "false", - "topK": "10", - "overquery": "2.0", - "rerankK": "20", - "search.throughput.avg_qps": "5826.0870091323695", - "search.throughput.stddev_qps": "7.660713163915625", - "search.throughput.cv_pct": "0.13148985162609977", - "search.count.avg_visited": "712.6111", - "search.accuracy.recall_at_10": "0.81039", - "construction.index_build_time_s": "", - "construction.index_quant_time_s.NVQ.compute_time_s": "", - "construction.index_quant_time_s.PQ.compute_time_s": "", - "construction.index_quant_time_s.PQ.encoding_time_s": "" - } - ], - "throughput_samples": [ - { - "sample": "Test-0", - "queries": 10000, - "seconds": 1.34114 - }, - { - "sample": "Test-1", - "queries": 10000, - "seconds": 1.294846 - }, - { - "sample": "Test-2", - "queries": 10000, - "seconds": 1.284013 - }, - { - "sample": "Test-0", - "queries": 10000, - "seconds": 1.718036 - }, - { - "sample": "Test-1", - "queries": 10000, - "seconds": 1.713843 - }, - { - "sample": "Test-2", - "queries": 10000, - "seconds": 1.717381 - } - ], - "finished": "2026-10-29T01:04:04.353570+00:00" - }, - "t8": { - "elapsed_process_seconds": 20.916853431612253, - "log": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/logs/jdk22-cohere-english-v3-1M-4bit-pq-t8.log", - "csv": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk22-cohere-english-v3-1M-4bit-pq/logging/jdk22-cohere-english-v3-1M-4bit-pq-t8-20261029-010404Z/experiments.csv", - "rows": [ - { - "schema_version": "1", - "run_id": "jdk22-cohere-english-v3-1M-4bit-pq-t8-20261029-010404Z", - "run_uuid": "923f84e7-6c51-45c0-b9ad-2e3093ccc6bd", - "system_id": "d940322e03a3", - "dataset_name": "cohere-english-v3-1M", - "M": "32", - "efConstruction": "100", - "neighborOverflow": "1.2", - "addHierarchy": "true", - "refineFinalGraph": "true", - "feature_set": "[NVQ_VECTORS, FUSED_PQ]", - "build_compressor": "\"ProductQuantization(M=261, clusters=256, centered=false)\"", - "search_compressor": "None", - "usePruning": "false", - "topK": "10", - "overquery": "1.0", - "rerankK": "10", - "search.throughput.avg_qps": "57544.851973715246", - "search.throughput.stddev_qps": "1423.1567643985286", - "search.throughput.cv_pct": "2.4731261191680254", - "search.count.avg_visited": "507.7788", - "search.accuracy.recall_at_10": "0.65081", - "construction.index_build_time_s": "", - "construction.index_quant_time_s.NVQ.compute_time_s": "", - "construction.index_quant_time_s.PQ.compute_time_s": "", - "construction.index_quant_time_s.PQ.encoding_time_s": "" - }, - { - "schema_version": "1", - "run_id": "jdk22-cohere-english-v3-1M-4bit-pq-t8-20261029-010404Z", - "run_uuid": "923f84e7-6c51-45c0-b9ad-2e3093ccc6bd", - "system_id": "d940322e03a3", - "dataset_name": "cohere-english-v3-1M", - "M": "32", - "efConstruction": "100", - "neighborOverflow": "1.2", - "addHierarchy": "true", - "refineFinalGraph": "true", - "feature_set": "[NVQ_VECTORS, FUSED_PQ]", - "build_compressor": "\"ProductQuantization(M=261, clusters=256, centered=false)\"", - "search_compressor": "None", - "usePruning": "false", - "topK": "10", - "overquery": "2.0", - "rerankK": "20", - "search.throughput.avg_qps": "43365.895255088246", - "search.throughput.stddev_qps": "343.3827570210426", - "search.throughput.cv_pct": "0.7918267454209942", - "search.count.avg_visited": "712.6111", - "search.accuracy.recall_at_10": "0.81039", - "construction.index_build_time_s": "", - "construction.index_quant_time_s.NVQ.compute_time_s": "", - "construction.index_quant_time_s.PQ.compute_time_s": "", - "construction.index_quant_time_s.PQ.encoding_time_s": "" - } - ], - "throughput_samples": [ - { - "sample": "Test-0", - "queries": 60000, - "seconds": 1.068123 - }, - { - "sample": "Test-1", - "queries": 60000, - "seconds": 1.016699 - }, - { - "sample": "Test-2", - "queries": 60000, - "seconds": 1.044446 - }, - { - "sample": "Test-0", - "queries": 50000, - "seconds": 1.1526 - }, - { - "sample": "Test-1", - "queries": 50000, - "seconds": 1.144115 - }, - { - "sample": "Test-2", - "queries": 50000, - "seconds": 1.162369 - } - ], - "finished": "2026-10-29T01:04:25.272033+00:00" - } - }, - "index_files": [ - { - "path": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk22-cohere-english-v3-1M-4bit-pq/index_cache/graph_cohere_english_v3_1M_FUSED_PQ_NVQ_VECTORS_M32_ef100_of1.2_alpha1.2_H1_R1_ProductQuantization_M_261_clusters_256_centered_false_", - "bytes": 9586777730 - } - ], - "finished": "2026-10-29T01:04:25.272647+00:00" - }, - { - "dataset": "ada002-1M", - "display_name": "Ada002", - "original_dimensions": "1536", - "projected_ash_dimensions": "768", - "base_vectors": "982790", - "queries": "10000", - "ash_bits": "2", - "algorithm": "PQ", - "code_bytes": "197", - "ash_encoded_bits": "", - "pq_m": "197", - "config": "ada002-1M-2bit-budget-pq.yml", - "jdk": "22", - "label": "jdk22-ada002-1M-2bit-pq", - "path": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk22-ada002-1M-2bit-pq", - "source": "b44e25b71e3d9c5c7a2f164610b0fdbd099d91fa", - "started": "2026-10-29T01:09:05.078508+00:00", - "phases": { - "build": { - "elapsed_process_seconds": 60.269731011241674, - "log": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/logs/jdk22-ada002-1M-2bit-pq-build.log", - "csv": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk22-ada002-1M-2bit-pq/logging/jdk22-ada002-1M-2bit-pq-build-20261029-010905Z/experiments.csv", - "rows": [ - { - "schema_version": "1", - "run_id": "jdk22-ada002-1M-2bit-pq-build-20261029-010905Z", - "run_uuid": "ebbe6ed1-d534-4503-8dd4-b80daf07636d", - "system_id": "d940322e03a3", - "dataset_name": "ada002-1M", - "M": "32", - "efConstruction": "100", - "neighborOverflow": "1.2", - "addHierarchy": "true", - "refineFinalGraph": "true", - "feature_set": "[NVQ_VECTORS, FUSED_PQ]", - "build_compressor": "\"ProductQuantization(M=197, clusters=256, centered=false)\"", - "search_compressor": "None", - "usePruning": "false", - "topK": "10", - "overquery": "1.0", - "rerankK": "10", - "search.accuracy.recall_at_10": "0.54986", - "construction.index_build_time_s": "48.118085742", - "construction.index_quant_time_s.NVQ.compute_time_s": "0.533923396", - "construction.index_quant_time_s.PQ.compute_time_s": "3.932531018", - "construction.index_quant_time_s.PQ.encoding_time_s": "2.19930205" - } - ], - "throughput_samples": [], - "finished": "2026-10-29T01:10:05.349154+00:00" - }, - "t1": { - "elapsed_process_seconds": 51.73143943399191, - "log": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/logs/jdk22-ada002-1M-2bit-pq-t1.log", - "csv": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk22-ada002-1M-2bit-pq/logging/jdk22-ada002-1M-2bit-pq-t1-20261029-011006Z/experiments.csv", - "rows": [ - { - "schema_version": "1", - "run_id": "jdk22-ada002-1M-2bit-pq-t1-20261029-011006Z", - "run_uuid": "37208c37-a5a6-4f35-ac91-9e3eccfc83f6", - "system_id": "d940322e03a3", - "dataset_name": "ada002-1M", - "M": "32", - "efConstruction": "100", - "neighborOverflow": "1.2", - "addHierarchy": "true", - "refineFinalGraph": "true", - "feature_set": "[NVQ_VECTORS, FUSED_PQ]", - "build_compressor": "\"ProductQuantization(M=197, clusters=256, centered=false)\"", - "search_compressor": "None", - "usePruning": "false", - "topK": "10", - "overquery": "1.0", - "rerankK": "10", - "search.throughput.avg_qps": "7064.577838319312", - "search.throughput.stddev_qps": "345.1160615646333", - "search.throughput.cv_pct": "4.885161852031312", - "search.count.avg_visited": "458.0818", - "search.accuracy.recall_at_10": "0.54986", - "construction.index_build_time_s": "", - "construction.index_quant_time_s.NVQ.compute_time_s": "", - "construction.index_quant_time_s.PQ.compute_time_s": "", - "construction.index_quant_time_s.PQ.encoding_time_s": "" - }, - { - "schema_version": "1", - "run_id": "jdk22-ada002-1M-2bit-pq-t1-20261029-011006Z", - "run_uuid": "37208c37-a5a6-4f35-ac91-9e3eccfc83f6", - "system_id": "d940322e03a3", - "dataset_name": "ada002-1M", - "M": "32", - "efConstruction": "100", - "neighborOverflow": "1.2", - "addHierarchy": "true", - "refineFinalGraph": "true", - "feature_set": "[NVQ_VECTORS, FUSED_PQ]", - "build_compressor": "\"ProductQuantization(M=197, clusters=256, centered=false)\"", - "search_compressor": "None", - "usePruning": "false", - "topK": "10", - "overquery": "2.0", - "rerankK": "20", - "search.throughput.avg_qps": "5685.492109827316", - "search.throughput.stddev_qps": "228.87502457014529", - "search.throughput.cv_pct": "4.025597435524308", - "search.count.avg_visited": "624.9491", - "search.accuracy.recall_at_10": "0.75347", - "construction.index_build_time_s": "", - "construction.index_quant_time_s.NVQ.compute_time_s": "", - "construction.index_quant_time_s.PQ.compute_time_s": "", - "construction.index_quant_time_s.PQ.encoding_time_s": "" - } - ], - "throughput_samples": [ - { - "sample": "Test-0", - "queries": 10000, - "seconds": 1.499813 - }, - { - "sample": "Test-1", - "queries": 10000, - "seconds": 1.382374 - }, - { - "sample": "Test-2", - "queries": 10000, - "seconds": 1.371309 - }, - { - "sample": "Test-0", - "queries": 10000, - "seconds": 1.84391 - }, - { - "sample": "Test-1", - "queries": 10000, - "seconds": 1.727664 - }, - { - "sample": "Test-2", - "queries": 10000, - "seconds": 1.710848 - } - ], - "finished": "2026-10-29T01:10:57.081969+00:00" - }, - "t8": { - "elapsed_process_seconds": 22.585593824274838, - "log": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/logs/jdk22-ada002-1M-2bit-pq-t8.log", - "csv": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk22-ada002-1M-2bit-pq/logging/jdk22-ada002-1M-2bit-pq-t8-20261029-011057Z/experiments.csv", - "rows": [ - { - "schema_version": "1", - "run_id": "jdk22-ada002-1M-2bit-pq-t8-20261029-011057Z", - "run_uuid": "d0fa1a11-20ec-4011-9ac5-cb17afe12b9e", - "system_id": "d940322e03a3", - "dataset_name": "ada002-1M", - "M": "32", - "efConstruction": "100", - "neighborOverflow": "1.2", - "addHierarchy": "true", - "refineFinalGraph": "true", - "feature_set": "[NVQ_VECTORS, FUSED_PQ]", - "build_compressor": "\"ProductQuantization(M=197, clusters=256, centered=false)\"", - "search_compressor": "None", - "usePruning": "false", - "topK": "10", - "overquery": "1.0", - "rerankK": "10", - "search.throughput.avg_qps": "53847.213088554396", - "search.throughput.stddev_qps": "938.0679515199026", - "search.throughput.cv_pct": "1.7420919258666252", - "search.count.avg_visited": "458.0818", - "search.accuracy.recall_at_10": "0.54986", - "construction.index_build_time_s": "", - "construction.index_quant_time_s.NVQ.compute_time_s": "", - "construction.index_quant_time_s.PQ.compute_time_s": "", - "construction.index_quant_time_s.PQ.encoding_time_s": "" - }, - { - "schema_version": "1", - "run_id": "jdk22-ada002-1M-2bit-pq-t8-20261029-011057Z", - "run_uuid": "d0fa1a11-20ec-4011-9ac5-cb17afe12b9e", - "system_id": "d940322e03a3", - "dataset_name": "ada002-1M", - "M": "32", - "efConstruction": "100", - "neighborOverflow": "1.2", - "addHierarchy": "true", - "refineFinalGraph": "true", - "feature_set": "[NVQ_VECTORS, FUSED_PQ]", - "build_compressor": "\"ProductQuantization(M=197, clusters=256, centered=false)\"", - "search_compressor": "None", - "usePruning": "false", - "topK": "10", - "overquery": "2.0", - "rerankK": "20", - "search.throughput.avg_qps": "45513.06620677013", - "search.throughput.stddev_qps": "369.25509208384045", - "search.throughput.cv_pct": "0.8113166676274456", - "search.count.avg_visited": "624.9491", - "search.accuracy.recall_at_10": "0.75347", - "construction.index_build_time_s": "", - "construction.index_quant_time_s.NVQ.compute_time_s": "", - "construction.index_quant_time_s.PQ.compute_time_s": "", - "construction.index_quant_time_s.PQ.encoding_time_s": "" - } - ], - "throughput_samples": [ - { - "sample": "Test-0", - "queries": 60000, - "seconds": 1.129328 - }, - { - "sample": "Test-1", - "queries": 60000, - "seconds": 1.092727 - }, - { - "sample": "Test-2", - "queries": 60000, - "seconds": 1.121407 - }, - { - "sample": "Test-0", - "queries": 50000, - "seconds": 1.088659 - }, - { - "sample": "Test-1", - "queries": 50000, - "seconds": 1.101554 - }, - { - "sample": "Test-2", - "queries": 50000, - "seconds": 1.105687 - } - ], - "finished": "2026-10-29T01:11:19.669032+00:00" - } - }, - "index_files": [ - { - "path": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk22-ada002-1M-2bit-pq/index_cache/graph_ada002_1M_FUSED_PQ_NVQ_VECTORS_M32_ef100_of1.2_alpha1.2_H1_R1_ProductQuantization_M_197_clusters_256_centered_false_", - "bytes": 7911344189 - } - ], - "finished": "2026-10-29T01:11:19.669627+00:00" - }, - { - "dataset": "ada002-1M", - "display_name": "Ada002", - "original_dimensions": "1536", - "projected_ash_dimensions": "768", - "base_vectors": "982790", - "queries": "10000", - "ash_bits": "4", - "algorithm": "PQ", - "code_bytes": "389", - "ash_encoded_bits": "", - "pq_m": "389", - "config": "ada002-1M-4bit-budget-pq.yml", - "jdk": "22", - "label": "jdk22-ada002-1M-4bit-pq", - "path": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk22-ada002-1M-4bit-pq", - "source": "b44e25b71e3d9c5c7a2f164610b0fdbd099d91fa", - "started": "2026-10-29T01:18:00.693272+00:00", - "phases": { - "build": { - "elapsed_process_seconds": 119.41658374574035, - "log": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/logs/jdk22-ada002-1M-4bit-pq-build.log", - "csv": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk22-ada002-1M-4bit-pq/logging/jdk22-ada002-1M-4bit-pq-build-20261029-011801Z/experiments.csv", - "rows": [ - { - "schema_version": "1", - "run_id": "jdk22-ada002-1M-4bit-pq-build-20261029-011801Z", - "run_uuid": "683c6d2a-adbd-4c97-b2a7-f186f0fdfe9d", - "system_id": "d940322e03a3", - "dataset_name": "ada002-1M", - "M": "32", - "efConstruction": "100", - "neighborOverflow": "1.2", - "addHierarchy": "true", - "refineFinalGraph": "true", - "feature_set": "[NVQ_VECTORS, FUSED_PQ]", - "build_compressor": "\"ProductQuantization(M=389, clusters=256, centered=false)\"", - "search_compressor": "None", - "usePruning": "false", - "topK": "10", - "overquery": "1.0", - "rerankK": "10", - "search.accuracy.recall_at_10": "0.73555", - "construction.index_build_time_s": "100.923429775", - "construction.index_quant_time_s.NVQ.compute_time_s": "0.475411504", - "construction.index_quant_time_s.PQ.compute_time_s": "8.623554557", - "construction.index_quant_time_s.PQ.encoding_time_s": "3.614520167" - } - ], - "throughput_samples": [], - "finished": "2026-10-29T01:20:00.111597+00:00" - }, - "t1": { - "elapsed_process_seconds": 65.49995384458452, - "log": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/logs/jdk22-ada002-1M-4bit-pq-t1.log", - "csv": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk22-ada002-1M-4bit-pq/logging/jdk22-ada002-1M-4bit-pq-t1-20261029-012001Z/experiments.csv", - "rows": [ - { - "schema_version": "1", - "run_id": "jdk22-ada002-1M-4bit-pq-t1-20261029-012001Z", - "run_uuid": "65040ead-21a1-4da9-b492-21b8f2525067", - "system_id": "d940322e03a3", - "dataset_name": "ada002-1M", - "M": "32", - "efConstruction": "100", - "neighborOverflow": "1.2", - "addHierarchy": "true", - "refineFinalGraph": "true", - "feature_set": "[NVQ_VECTORS, FUSED_PQ]", - "build_compressor": "\"ProductQuantization(M=389, clusters=256, centered=false)\"", - "search_compressor": "None", - "usePruning": "false", - "topK": "10", - "overquery": "1.0", - "rerankK": "10", - "search.throughput.avg_qps": "5341.342726973948", - "search.throughput.stddev_qps": "197.28391094169163", - "search.throughput.cv_pct": "3.6935265349179285", - "search.count.avg_visited": "477.3403", - "search.accuracy.recall_at_10": "0.73555", - "construction.index_build_time_s": "", - "construction.index_quant_time_s.NVQ.compute_time_s": "", - "construction.index_quant_time_s.PQ.compute_time_s": "", - "construction.index_quant_time_s.PQ.encoding_time_s": "" - }, - { - "schema_version": "1", - "run_id": "jdk22-ada002-1M-4bit-pq-t1-20261029-012001Z", - "run_uuid": "65040ead-21a1-4da9-b492-21b8f2525067", - "system_id": "d940322e03a3", - "dataset_name": "ada002-1M", - "M": "32", - "efConstruction": "100", - "neighborOverflow": "1.2", - "addHierarchy": "true", - "refineFinalGraph": "true", - "feature_set": "[NVQ_VECTORS, FUSED_PQ]", - "build_compressor": "\"ProductQuantization(M=389, clusters=256, centered=false)\"", - "search_compressor": "None", - "usePruning": "false", - "topK": "10", - "overquery": "2.0", - "rerankK": "20", - "search.throughput.avg_qps": "4200.0630432290845", - "search.throughput.stddev_qps": "27.34394359857975", - "search.throughput.cv_pct": "0.6510365039082182", - "search.count.avg_visited": "659.2552", - "search.accuracy.recall_at_10": "0.90049", - "construction.index_build_time_s": "", - "construction.index_quant_time_s.NVQ.compute_time_s": "", - "construction.index_quant_time_s.PQ.compute_time_s": "", - "construction.index_quant_time_s.PQ.encoding_time_s": "" - } - ], - "throughput_samples": [ - { - "sample": "Test-0", - "queries": 10000, - "seconds": 1.952556 - }, - { - "sample": "Test-1", - "queries": 10000, - "seconds": 1.851992 - }, - { - "sample": "Test-2", - "queries": 10000, - "seconds": 1.817209 - }, - { - "sample": "Test-0", - "queries": 10000, - "seconds": 2.394102 - }, - { - "sample": "Test-1", - "queries": 10000, - "seconds": 2.384918 - }, - { - "sample": "Test-2", - "queries": 10000, - "seconds": 2.363932 - } - ], - "finished": "2026-10-29T01:21:05.612921+00:00" - }, - "t8": { - "elapsed_process_seconds": 25.59007947705686, - "log": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/logs/jdk22-ada002-1M-4bit-pq-t8.log", - "csv": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk22-ada002-1M-4bit-pq/logging/jdk22-ada002-1M-4bit-pq-t8-20261029-012106Z/experiments.csv", - "rows": [ - { - "schema_version": "1", - "run_id": "jdk22-ada002-1M-4bit-pq-t8-20261029-012106Z", - "run_uuid": "50d32cd8-997a-4b71-ba10-645f69aa19b0", - "system_id": "d940322e03a3", - "dataset_name": "ada002-1M", - "M": "32", - "efConstruction": "100", - "neighborOverflow": "1.2", - "addHierarchy": "true", - "refineFinalGraph": "true", - "feature_set": "[NVQ_VECTORS, FUSED_PQ]", - "build_compressor": "\"ProductQuantization(M=389, clusters=256, centered=false)\"", - "search_compressor": "None", - "usePruning": "false", - "topK": "10", - "overquery": "1.0", - "rerankK": "10", - "search.throughput.avg_qps": "41038.5274305757", - "search.throughput.stddev_qps": "1647.1171751077788", - "search.throughput.cv_pct": "4.013587421951686", - "search.count.avg_visited": "477.3403", - "search.accuracy.recall_at_10": "0.73555", - "construction.index_build_time_s": "", - "construction.index_quant_time_s.NVQ.compute_time_s": "", - "construction.index_quant_time_s.PQ.compute_time_s": "", - "construction.index_quant_time_s.PQ.encoding_time_s": "" - }, - { - "schema_version": "1", - "run_id": "jdk22-ada002-1M-4bit-pq-t8-20261029-012106Z", - "run_uuid": "50d32cd8-997a-4b71-ba10-645f69aa19b0", - "system_id": "d940322e03a3", - "dataset_name": "ada002-1M", - "M": "32", - "efConstruction": "100", - "neighborOverflow": "1.2", - "addHierarchy": "true", - "refineFinalGraph": "true", - "feature_set": "[NVQ_VECTORS, FUSED_PQ]", - "build_compressor": "\"ProductQuantization(M=389, clusters=256, centered=false)\"", - "search_compressor": "None", - "usePruning": "false", - "topK": "10", - "overquery": "2.0", - "rerankK": "20", - "search.throughput.avg_qps": "30912.637087924762", - "search.throughput.stddev_qps": "630.078503069555", - "search.throughput.cv_pct": "2.0382554269874285", - "search.count.avg_visited": "659.2552", - "search.accuracy.recall_at_10": "0.90049", - "construction.index_build_time_s": "", - "construction.index_quant_time_s.NVQ.compute_time_s": "", - "construction.index_quant_time_s.PQ.compute_time_s": "", - "construction.index_quant_time_s.PQ.encoding_time_s": "" - } - ], - "throughput_samples": [ - { - "sample": "Test-0", - "queries": 40000, - "seconds": 1.021245 - }, - { - "sample": "Test-1", - "queries": 50000, - "seconds": 1.199723 - }, - { - "sample": "Test-2", - "queries": 50000, - "seconds": 1.182833 - }, - { - "sample": "Test-0", - "queries": 40000, - "seconds": 1.287881 - }, - { - "sample": "Test-1", - "queries": 40000, - "seconds": 1.323524 - }, - { - "sample": "Test-2", - "queries": 40000, - "seconds": 1.271585 - } - ], - "finished": "2026-10-29T01:21:31.204526+00:00" - } - }, - "index_files": [ - { - "path": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk22-ada002-1M-4bit-pq/index_cache/graph_ada002_1M_FUSED_PQ_NVQ_VECTORS_M32_ef100_of1.2_alpha1.2_H1_R1_ProductQuantization_M_389_clusters_256_centered_false_", - "bytes": 13955502981 - } - ], - "finished": "2026-10-29T01:21:31.205098+00:00" - }, - { - "dataset": "openai-3072-1m", - "display_name": "OpenAI3072", - "original_dimensions": "3072", - "projected_ash_dimensions": "1536", - "base_vectors": "999000", - "queries": "1000", - "ash_bits": "2", - "algorithm": "PQ", - "code_bytes": "389", - "ash_encoded_bits": "", - "pq_m": "389", - "config": "openai-3072-1m-2bit-budget-pq.yml", - "jdk": "22", - "label": "jdk22-openai-3072-1m-2bit-pq", - "path": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk22-openai-3072-1m-2bit-pq", - "source": "b44e25b71e3d9c5c7a2f164610b0fdbd099d91fa", - "started": "2026-10-29T01:26:07.149708+00:00", - "phases": { - "build": { - "elapsed_process_seconds": 113.07548744231462, - "log": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/logs/jdk22-openai-3072-1m-2bit-pq-build.log", - "csv": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk22-openai-3072-1m-2bit-pq/logging/jdk22-openai-3072-1m-2bit-pq-build-20261029-012607Z/experiments.csv", - "rows": [ - { - "schema_version": "1", - "run_id": "jdk22-openai-3072-1m-2bit-pq-build-20261029-012607Z", - "run_uuid": "9a4baf3c-1009-458a-8dd7-0a57154e4c1b", - "system_id": "d940322e03a3", - "dataset_name": "openai-3072-1m", - "M": "32", - "efConstruction": "100", - "neighborOverflow": "1.2", - "addHierarchy": "true", - "refineFinalGraph": "true", - "feature_set": "[NVQ_VECTORS, FUSED_PQ]", - "build_compressor": "\"ProductQuantization(M=389, clusters=256, centered=false)\"", - "search_compressor": "None", - "usePruning": "false", - "topK": "10", - "overquery": "1.0", - "rerankK": "10", - "search.accuracy.recall_at_10": "0.6437", - "construction.index_build_time_s": "90.502056269", - "construction.index_quant_time_s.NVQ.compute_time_s": "1.177500504", - "construction.index_quant_time_s.PQ.compute_time_s": "6.505794804", - "construction.index_quant_time_s.PQ.encoding_time_s": "5.508848828" - } - ], - "throughput_samples": [], - "finished": "2026-10-29T01:28:00.226162+00:00" - }, - "t1": { - "elapsed_process_seconds": 34.11608032975346, - "log": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/logs/jdk22-openai-3072-1m-2bit-pq-t1.log", - "csv": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk22-openai-3072-1m-2bit-pq/logging/jdk22-openai-3072-1m-2bit-pq-t1-20261029-012801Z/experiments.csv", - "rows": [ - { - "schema_version": "1", - "run_id": "jdk22-openai-3072-1m-2bit-pq-t1-20261029-012801Z", - "run_uuid": "3abbab77-6dc4-45ef-96fd-6c3dfaccd84a", - "system_id": "d940322e03a3", - "dataset_name": "openai-3072-1m", - "M": "32", - "efConstruction": "100", - "neighborOverflow": "1.2", - "addHierarchy": "true", - "refineFinalGraph": "true", - "feature_set": "[NVQ_VECTORS, FUSED_PQ]", - "build_compressor": "\"ProductQuantization(M=389, clusters=256, centered=false)\"", - "search_compressor": "None", - "usePruning": "false", - "topK": "10", - "overquery": "1.0", - "rerankK": "10", - "search.throughput.avg_qps": "3329.077370787888", - "search.throughput.stddev_qps": "410.3285959728882", - "search.throughput.cv_pct": "12.325595060464947", - "search.count.avg_visited": "471.041", - "search.accuracy.recall_at_10": "0.6437", - "construction.index_build_time_s": "", - "construction.index_quant_time_s.NVQ.compute_time_s": "", - "construction.index_quant_time_s.PQ.compute_time_s": "", - "construction.index_quant_time_s.PQ.encoding_time_s": "" - }, - { - "schema_version": "1", - "run_id": "jdk22-openai-3072-1m-2bit-pq-t1-20261029-012801Z", - "run_uuid": "3abbab77-6dc4-45ef-96fd-6c3dfaccd84a", - "system_id": "d940322e03a3", - "dataset_name": "openai-3072-1m", - "M": "32", - "efConstruction": "100", - "neighborOverflow": "1.2", - "addHierarchy": "true", - "refineFinalGraph": "true", - "feature_set": "[NVQ_VECTORS, FUSED_PQ]", - "build_compressor": "\"ProductQuantization(M=389, clusters=256, centered=false)\"", - "search_compressor": "None", - "usePruning": "false", - "topK": "10", - "overquery": "2.0", - "rerankK": "20", - "search.throughput.avg_qps": "2953.6549674392622", - "search.throughput.stddev_qps": "20.699428291440878", - "search.throughput.cv_pct": "0.7008072547277489", - "search.count.avg_visited": "653.509", - "search.accuracy.recall_at_10": "0.7985", - "construction.index_build_time_s": "", - "construction.index_quant_time_s.NVQ.compute_time_s": "", - "construction.index_quant_time_s.PQ.compute_time_s": "", - "construction.index_quant_time_s.PQ.encoding_time_s": "" - } - ], - "throughput_samples": [ - { - "sample": "Test-0", - "queries": 3000, - "seconds": 1.045207 - }, - { - "sample": "Test-1", - "queries": 4000, - "seconds": 1.157358 - }, - { - "sample": "Test-2", - "queries": 4000, - "seconds": 1.092645 - }, - { - "sample": "Test-0", - "queries": 3000, - "seconds": 1.023969 - }, - { - "sample": "Test-1", - "queries": 3000, - "seconds": 1.011295 - }, - { - "sample": "Test-2", - "queries": 3000, - "seconds": 1.011909 - } - ], - "finished": "2026-10-29T01:28:34.343565+00:00" - }, - "t8": { - "elapsed_process_seconds": 20.363484519533813, - "log": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/logs/jdk22-openai-3072-1m-2bit-pq-t8.log", - "csv": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk22-openai-3072-1m-2bit-pq/logging/jdk22-openai-3072-1m-2bit-pq-t8-20261029-012834Z/experiments.csv", - "rows": [ - { - "schema_version": "1", - "run_id": "jdk22-openai-3072-1m-2bit-pq-t8-20261029-012834Z", - "run_uuid": "cd053741-ba71-4eea-99b6-537ca2929843", - "system_id": "d940322e03a3", - "dataset_name": "openai-3072-1m", - "M": "32", - "efConstruction": "100", - "neighborOverflow": "1.2", - "addHierarchy": "true", - "refineFinalGraph": "true", - "feature_set": "[NVQ_VECTORS, FUSED_PQ]", - "build_compressor": "\"ProductQuantization(M=389, clusters=256, centered=false)\"", - "search_compressor": "None", - "usePruning": "false", - "topK": "10", - "overquery": "1.0", - "rerankK": "10", - "search.throughput.avg_qps": "27543.90063678985", - "search.throughput.stddev_qps": "1082.175435232485", - "search.throughput.cv_pct": "3.928911338675991", - "search.count.avg_visited": "471.041", - "search.accuracy.recall_at_10": "0.6437", - "construction.index_build_time_s": "", - "construction.index_quant_time_s.NVQ.compute_time_s": "", - "construction.index_quant_time_s.PQ.compute_time_s": "", - "construction.index_quant_time_s.PQ.encoding_time_s": "" - }, - { - "schema_version": "1", - "run_id": "jdk22-openai-3072-1m-2bit-pq-t8-20261029-012834Z", - "run_uuid": "cd053741-ba71-4eea-99b6-537ca2929843", - "system_id": "d940322e03a3", - "dataset_name": "openai-3072-1m", - "M": "32", - "efConstruction": "100", - "neighborOverflow": "1.2", - "addHierarchy": "true", - "refineFinalGraph": "true", - "feature_set": "[NVQ_VECTORS, FUSED_PQ]", - "build_compressor": "\"ProductQuantization(M=389, clusters=256, centered=false)\"", - "search_compressor": "None", - "usePruning": "false", - "topK": "10", - "overquery": "2.0", - "rerankK": "20", - "search.throughput.avg_qps": "22784.923597112218", - "search.throughput.stddev_qps": "198.6192438432833", - "search.throughput.cv_pct": "0.8717134512070802", - "search.count.avg_visited": "653.509", - "search.accuracy.recall_at_10": "0.7985", - "construction.index_build_time_s": "", - "construction.index_quant_time_s.NVQ.compute_time_s": "", - "construction.index_quant_time_s.PQ.compute_time_s": "", - "construction.index_quant_time_s.PQ.encoding_time_s": "" - } - ], - "throughput_samples": [ - { - "sample": "Test-0", - "queries": 27000, - "seconds": 1.026795 - }, - { - "sample": "Test-1", - "queries": 29000, - "seconds": 1.027868 - }, - { - "sample": "Test-2", - "queries": 29000, - "seconds": 1.031202 - }, - { - "sample": "Test-0", - "queries": 23000, - "seconds": 1.01969 - }, - { - "sample": "Test-1", - "queries": 23000, - "seconds": 1.00394 - }, - { - "sample": "Test-2", - "queries": 23000, - "seconds": 1.004843 - } - ], - "finished": "2026-10-29T01:28:54.708594+00:00" - } - }, - "index_files": [ - { - "path": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk22-openai-3072-1m-2bit-pq/index_cache/graph_openai_3072_1m_FUSED_PQ_NVQ_VECTORS_M32_ef100_of1.2_alpha1.2_H1_R1_ProductQuantization_M_389_clusters_256_centered_false_", - "bytes": 15723261549 - } - ], - "finished": "2026-10-29T01:28:54.709769+00:00" - }, - { - "dataset": "openai-3072-1m", - "display_name": "OpenAI3072", - "original_dimensions": "3072", - "projected_ash_dimensions": "1536", - "base_vectors": "999000", - "queries": "1000", - "ash_bits": "4", - "algorithm": "PQ", - "code_bytes": "773", - "ash_encoded_bits": "", - "pq_m": "773", - "config": "openai-3072-1m-4bit-budget-pq.yml", - "jdk": "22", - "label": "jdk22-openai-3072-1m-4bit-pq", - "path": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk22-openai-3072-1m-4bit-pq", - "source": "b44e25b71e3d9c5c7a2f164610b0fdbd099d91fa", - "started": "2026-10-29T01:33:40.791382+00:00", - "phases": { - "build": { - "elapsed_process_seconds": 295.41825035214424, - "log": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/logs/jdk22-openai-3072-1m-4bit-pq-build.log", - "csv": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk22-openai-3072-1m-4bit-pq/logging/jdk22-openai-3072-1m-4bit-pq-build-20261029-013341Z/experiments.csv", - "rows": [ - { - "schema_version": "1", - "run_id": "jdk22-openai-3072-1m-4bit-pq-build-20261029-013341Z", - "run_uuid": "40be9fef-948d-4db1-9c47-d49c990e4318", - "system_id": "d940322e03a3", - "dataset_name": "openai-3072-1m", - "M": "32", - "efConstruction": "100", - "neighborOverflow": "1.2", - "addHierarchy": "true", - "refineFinalGraph": "true", - "feature_set": "[NVQ_VECTORS, FUSED_PQ]", - "build_compressor": "\"ProductQuantization(M=773, clusters=256, centered=false)\"", - "search_compressor": "None", - "usePruning": "false", - "topK": "10", - "overquery": "1.0", - "rerankK": "10", - "search.accuracy.recall_at_10": "0.7691", - "construction.index_build_time_s": "257.818535867", - "construction.index_quant_time_s.NVQ.compute_time_s": "1.012420177", - "construction.index_quant_time_s.PQ.compute_time_s": "19.649721716", - "construction.index_quant_time_s.PQ.encoding_time_s": "7.64310692" - } - ], - "throughput_samples": [], - "finished": "2026-10-29T01:38:36.211693+00:00" - }, - "t1": { - "elapsed_process_seconds": 37.957580637186766, - "log": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/logs/jdk22-openai-3072-1m-4bit-pq-t1.log", - "csv": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk22-openai-3072-1m-4bit-pq/logging/jdk22-openai-3072-1m-4bit-pq-t1-20261029-013837Z/experiments.csv", - "rows": [ - { - "schema_version": "1", - "run_id": "jdk22-openai-3072-1m-4bit-pq-t1-20261029-013837Z", - "run_uuid": "7a3b16f1-54cf-4b10-80b0-3f090c3a84fd", - "system_id": "d940322e03a3", - "dataset_name": "openai-3072-1m", - "M": "32", - "efConstruction": "100", - "neighborOverflow": "1.2", - "addHierarchy": "true", - "refineFinalGraph": "true", - "feature_set": "[NVQ_VECTORS, FUSED_PQ]", - "build_compressor": "\"ProductQuantization(M=773, clusters=256, centered=false)\"", - "search_compressor": "None", - "usePruning": "false", - "topK": "10", - "overquery": "1.0", - "rerankK": "10", - "search.throughput.avg_qps": "2143.912280952585", - "search.throughput.stddev_qps": "170.47402337973324", - "search.throughput.cv_pct": "7.951539104201971", - "search.count.avg_visited": "500.603", - "search.accuracy.recall_at_10": "0.7691", - "construction.index_build_time_s": "", - "construction.index_quant_time_s.NVQ.compute_time_s": "", - "construction.index_quant_time_s.PQ.compute_time_s": "", - "construction.index_quant_time_s.PQ.encoding_time_s": "" - }, - { - "schema_version": "1", - "run_id": "jdk22-openai-3072-1m-4bit-pq-t1-20261029-013837Z", - "run_uuid": "7a3b16f1-54cf-4b10-80b0-3f090c3a84fd", - "system_id": "d940322e03a3", - "dataset_name": "openai-3072-1m", - "M": "32", - "efConstruction": "100", - "neighborOverflow": "1.2", - "addHierarchy": "true", - "refineFinalGraph": "true", - "feature_set": "[NVQ_VECTORS, FUSED_PQ]", - "build_compressor": "\"ProductQuantization(M=773, clusters=256, centered=false)\"", - "search_compressor": "None", - "usePruning": "false", - "topK": "10", - "overquery": "2.0", - "rerankK": "20", - "search.throughput.avg_qps": "1871.3707850777212", - "search.throughput.stddev_qps": "16.099488501314273", - "search.throughput.cv_pct": "0.8603045761797353", - "search.count.avg_visited": "704.466", - "search.accuracy.recall_at_10": "0.877", - "construction.index_build_time_s": "", - "construction.index_quant_time_s.NVQ.compute_time_s": "", - "construction.index_quant_time_s.PQ.compute_time_s": "", - "construction.index_quant_time_s.PQ.encoding_time_s": "" - } - ], - "throughput_samples": [ - { - "sample": "Test-0", - "queries": 2000, - "seconds": 1.004304 - }, - { - "sample": "Test-1", - "queries": 3000, - "seconds": 1.420222 - }, - { - "sample": "Test-2", - "queries": 3000, - "seconds": 1.28868 - }, - { - "sample": "Test-0", - "queries": 2000, - "seconds": 1.079459 - }, - { - "sample": "Test-1", - "queries": 2000, - "seconds": 1.06346 - }, - { - "sample": "Test-2", - "queries": 2000, - "seconds": 1.063446 - } - ], - "finished": "2026-10-29T01:39:14.171314+00:00" - }, - "t8": { - "elapsed_process_seconds": 21.19069182034582, - "log": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/logs/jdk22-openai-3072-1m-4bit-pq-t8.log", - "csv": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk22-openai-3072-1m-4bit-pq/logging/jdk22-openai-3072-1m-4bit-pq-t8-20261029-013914Z/experiments.csv", - "rows": [ - { - "schema_version": "1", - "run_id": "jdk22-openai-3072-1m-4bit-pq-t8-20261029-013914Z", - "run_uuid": "b58dc64c-c96e-4b6f-ad84-ed66e5cdcdb4", - "system_id": "d940322e03a3", - "dataset_name": "openai-3072-1m", - "M": "32", - "efConstruction": "100", - "neighborOverflow": "1.2", - "addHierarchy": "true", - "refineFinalGraph": "true", - "feature_set": "[NVQ_VECTORS, FUSED_PQ]", - "build_compressor": "\"ProductQuantization(M=773, clusters=256, centered=false)\"", - "search_compressor": "None", - "usePruning": "false", - "topK": "10", - "overquery": "1.0", - "rerankK": "10", - "search.throughput.avg_qps": "18466.799971430668", - "search.throughput.stddev_qps": "859.2042061471868", - "search.throughput.cv_pct": "4.652696771917339", - "search.count.avg_visited": "500.603", - "search.accuracy.recall_at_10": "0.7691", - "construction.index_build_time_s": "", - "construction.index_quant_time_s.NVQ.compute_time_s": "", - "construction.index_quant_time_s.PQ.compute_time_s": "", - "construction.index_quant_time_s.PQ.encoding_time_s": "" - }, - { - "schema_version": "1", - "run_id": "jdk22-openai-3072-1m-4bit-pq-t8-20261029-013914Z", - "run_uuid": "b58dc64c-c96e-4b6f-ad84-ed66e5cdcdb4", - "system_id": "d940322e03a3", - "dataset_name": "openai-3072-1m", - "M": "32", - "efConstruction": "100", - "neighborOverflow": "1.2", - "addHierarchy": "true", - "refineFinalGraph": "true", - "feature_set": "[NVQ_VECTORS, FUSED_PQ]", - "build_compressor": "\"ProductQuantization(M=773, clusters=256, centered=false)\"", - "search_compressor": "None", - "usePruning": "false", - "topK": "10", - "overquery": "2.0", - "rerankK": "20", - "search.throughput.avg_qps": "14336.390737938298", - "search.throughput.stddev_qps": "126.38455072730488", - "search.throughput.cv_pct": "0.8815646353224336", - "search.count.avg_visited": "704.466", - "search.accuracy.recall_at_10": "0.877", - "construction.index_build_time_s": "", - "construction.index_quant_time_s.NVQ.compute_time_s": "", - "construction.index_quant_time_s.PQ.compute_time_s": "", - "construction.index_quant_time_s.PQ.encoding_time_s": "" - } - ], - "throughput_samples": [ - { - "sample": "Test-0", - "queries": 18000, - "seconds": 1.030011 - }, - { - "sample": "Test-1", - "queries": 19000, - "seconds": 1.003889 - }, - { - "sample": "Test-2", - "queries": 19000, - "seconds": 1.000081 - }, - { - "sample": "Test-0", - "queries": 15000, - "seconds": 1.038658 - }, - { - "sample": "Test-1", - "queries": 15000, - "seconds": 1.043753 - }, - { - "sample": "Test-2", - "queries": 15000, - "seconds": 1.056617 - } - ], - "finished": "2026-10-29T01:39:35.363519+00:00" - } - }, - "index_files": [ - { - "path": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk22-openai-3072-1m-4bit-pq/index_cache/graph_openai_3072_1m_FUSED_PQ_NVQ_VECTORS_M32_ef100_of1.2_alpha1.2_H1_R1_ProductQuantization_M_773_clusters_256_centered_false_", - "bytes": 28010961543 - } - ], - "finished": "2026-10-29T01:39:35.364087+00:00" - }, - { - "dataset": "mpnet-1m", - "display_name": "MPNet", - "original_dimensions": "768", - "projected_ash_dimensions": "384", - "base_vectors": "999812", - "queries": "10000", - "ash_bits": "2", - "algorithm": "PQ", - "code_bytes": "101", - "ash_encoded_bits": "", - "pq_m": "101", - "config": "mpnet-1m-2bit-budget-pq.yml", - "jdk": "25", - "label": "jdk25-mpnet-1m-2bit-pq", - "path": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk25-mpnet-1m-2bit-pq", - "source": "b44e25b71e3d9c5c7a2f164610b0fdbd099d91fa", - "started": "2026-10-29T01:41:39.588096+00:00", - "phases": { - "build": { - "elapsed_process_seconds": 41.54969112202525, - "log": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/logs/jdk25-mpnet-1m-2bit-pq-build.log", - "csv": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk25-mpnet-1m-2bit-pq/logging/jdk25-mpnet-1m-2bit-pq-build-20261029-014139Z/experiments.csv", - "rows": [ - { - "schema_version": "1", - "run_id": "jdk25-mpnet-1m-2bit-pq-build-20261029-014139Z", - "run_uuid": "249222a9-be9f-416a-98b5-9e2224daabc7", - "system_id": "cd8205c1bae6", - "dataset_name": "mpnet-1m", - "M": "32", - "efConstruction": "100", - "neighborOverflow": "1.2", - "addHierarchy": "true", - "refineFinalGraph": "true", - "feature_set": "[NVQ_VECTORS, FUSED_PQ]", - "build_compressor": "\"ProductQuantization(M=101, clusters=256, centered=false)\"", - "search_compressor": "None", - "usePruning": "false", - "topK": "10", - "overquery": "1.0", - "rerankK": "10", - "search.accuracy.recall_at_10": "0.65484", - "construction.index_build_time_s": "33.769320929", - "construction.index_quant_time_s.NVQ.compute_time_s": "0.260673416", - "construction.index_quant_time_s.PQ.compute_time_s": "2.774568465", - "construction.index_quant_time_s.PQ.encoding_time_s": "1.048305647" - } - ], - "throughput_samples": [], - "finished": "2026-10-29T01:42:21.139469+00:00" - }, - "t1": { - "elapsed_process_seconds": 36.69188829232007, - "log": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/logs/jdk25-mpnet-1m-2bit-pq-t1.log", - "csv": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk25-mpnet-1m-2bit-pq/logging/jdk25-mpnet-1m-2bit-pq-t1-20261029-014222Z/experiments.csv", - "rows": [ - { - "schema_version": "1", - "run_id": "jdk25-mpnet-1m-2bit-pq-t1-20261029-014222Z", - "run_uuid": "9fb519f2-d57e-41eb-bd45-74657e5601b0", - "system_id": "cd8205c1bae6", - "dataset_name": "mpnet-1m", - "M": "32", - "efConstruction": "100", - "neighborOverflow": "1.2", - "addHierarchy": "true", - "refineFinalGraph": "true", - "feature_set": "[NVQ_VECTORS, FUSED_PQ]", - "build_compressor": "\"ProductQuantization(M=101, clusters=256, centered=false)\"", - "search_compressor": "None", - "usePruning": "false", - "topK": "10", - "overquery": "1.0", - "rerankK": "10", - "search.throughput.avg_qps": "11812.269458852594", - "search.throughput.stddev_qps": "210.99098451189369", - "search.throughput.cv_pct": "1.7862019254375245", - "search.count.avg_visited": "416.9763", - "search.accuracy.recall_at_10": "0.65484", - "construction.index_build_time_s": "", - "construction.index_quant_time_s.NVQ.compute_time_s": "", - "construction.index_quant_time_s.PQ.compute_time_s": "", - "construction.index_quant_time_s.PQ.encoding_time_s": "" - }, - { - "schema_version": "1", - "run_id": "jdk25-mpnet-1m-2bit-pq-t1-20261029-014222Z", - "run_uuid": "9fb519f2-d57e-41eb-bd45-74657e5601b0", - "system_id": "cd8205c1bae6", - "dataset_name": "mpnet-1m", - "M": "32", - "efConstruction": "100", - "neighborOverflow": "1.2", - "addHierarchy": "true", - "refineFinalGraph": "true", - "feature_set": "[NVQ_VECTORS, FUSED_PQ]", - "build_compressor": "\"ProductQuantization(M=101, clusters=256, centered=false)\"", - "search_compressor": "None", - "usePruning": "false", - "topK": "10", - "overquery": "2.0", - "rerankK": "20", - "search.throughput.avg_qps": "9294.266279454745", - "search.throughput.stddev_qps": "42.12928952819675", - "search.throughput.cv_pct": "0.45328257510035846", - "search.count.avg_visited": "563.8511", - "search.accuracy.recall_at_10": "0.8221", - "construction.index_build_time_s": "", - "construction.index_quant_time_s.NVQ.compute_time_s": "", - "construction.index_quant_time_s.PQ.compute_time_s": "", - "construction.index_quant_time_s.PQ.encoding_time_s": "" - } - ], - "throughput_samples": [ - { - "sample": "Test-0", - "queries": 20000, - "seconds": 1.728791 - }, - { - "sample": "Test-1", - "queries": 20000, - "seconds": 1.676884 - }, - { - "sample": "Test-2", - "queries": 20000, - "seconds": 1.674881 - }, - { - "sample": "Test-0", - "queries": 10000, - "seconds": 1.081375 - }, - { - "sample": "Test-1", - "queries": 10000, - "seconds": 1.074565 - }, - { - "sample": "Test-2", - "queries": 10000, - "seconds": 1.071901 - } - ], - "finished": "2026-10-29T01:42:57.832733+00:00" - }, - "t8": { - "elapsed_process_seconds": 17.65556924790144, - "log": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/logs/jdk25-mpnet-1m-2bit-pq-t8.log", - "csv": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk25-mpnet-1m-2bit-pq/logging/jdk25-mpnet-1m-2bit-pq-t8-20261029-014258Z/experiments.csv", - "rows": [ - { - "schema_version": "1", - "run_id": "jdk25-mpnet-1m-2bit-pq-t8-20261029-014258Z", - "run_uuid": "aa65257a-aa4d-4816-bdb7-0d6db95d4705", - "system_id": "cd8205c1bae6", - "dataset_name": "mpnet-1m", - "M": "32", - "efConstruction": "100", - "neighborOverflow": "1.2", - "addHierarchy": "true", - "refineFinalGraph": "true", - "feature_set": "[NVQ_VECTORS, FUSED_PQ]", - "build_compressor": "\"ProductQuantization(M=101, clusters=256, centered=false)\"", - "search_compressor": "None", - "usePruning": "false", - "topK": "10", - "overquery": "1.0", - "rerankK": "10", - "search.throughput.avg_qps": "76801.73087416003", - "search.throughput.stddev_qps": "911.757836651423", - "search.throughput.cv_pct": "1.187157927658352", - "search.count.avg_visited": "416.9763", - "search.accuracy.recall_at_10": "0.65484", - "construction.index_build_time_s": "", - "construction.index_quant_time_s.NVQ.compute_time_s": "", - "construction.index_quant_time_s.PQ.compute_time_s": "", - "construction.index_quant_time_s.PQ.encoding_time_s": "" - }, - { - "schema_version": "1", - "run_id": "jdk25-mpnet-1m-2bit-pq-t8-20261029-014258Z", - "run_uuid": "aa65257a-aa4d-4816-bdb7-0d6db95d4705", - "system_id": "cd8205c1bae6", - "dataset_name": "mpnet-1m", - "M": "32", - "efConstruction": "100", - "neighborOverflow": "1.2", - "addHierarchy": "true", - "refineFinalGraph": "true", - "feature_set": "[NVQ_VECTORS, FUSED_PQ]", - "build_compressor": "\"ProductQuantization(M=101, clusters=256, centered=false)\"", - "search_compressor": "None", - "usePruning": "false", - "topK": "10", - "overquery": "2.0", - "rerankK": "20", - "search.throughput.avg_qps": "71112.3291273773", - "search.throughput.stddev_qps": "640.384545175938", - "search.throughput.cv_pct": "0.9005253421370478", - "search.count.avg_visited": "563.8511", - "search.accuracy.recall_at_10": "0.8221", - "construction.index_build_time_s": "", - "construction.index_quant_time_s.NVQ.compute_time_s": "", - "construction.index_quant_time_s.PQ.compute_time_s": "", - "construction.index_quant_time_s.PQ.encoding_time_s": "" - } - ], - "throughput_samples": [ - { - "sample": "Test-0", - "queries": 80000, - "seconds": 1.053934 - }, - { - "sample": "Test-1", - "queries": 80000, - "seconds": 1.029219 - }, - { - "sample": "Test-2", - "queries": 80000, - "seconds": 1.04207 - }, - { - "sample": "Test-0", - "queries": 80000, - "seconds": 1.130137 - }, - { - "sample": "Test-1", - "queries": 80000, - "seconds": 1.131556 - }, - { - "sample": "Test-2", - "queries": 80000, - "seconds": 1.113431 - } - ], - "finished": "2026-10-29T01:43:15.489768+00:00" - } - }, - "index_files": [ - { - "path": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk25-mpnet-1m-2bit-pq/index_cache/graph_mpnet_1m_FUSED_PQ_NVQ_VECTORS_M32_ef100_of1.2_alpha1.2_H1_R1_ProductQuantization_M_101_clusters_256_centered_false_", - "bytes": 4204456814 - } - ], - "finished": "2026-10-29T01:43:15.490345+00:00" - }, - { - "dataset": "mpnet-1m", - "display_name": "MPNet", - "original_dimensions": "768", - "projected_ash_dimensions": "384", - "base_vectors": "999812", - "queries": "10000", - "ash_bits": "2", - "algorithm": "ASH", - "code_bytes": "101", - "ash_encoded_bits": "808", - "pq_m": "", - "config": "mpnet-1m-2bit-budget-ash.yml", - "jdk": "22", - "label": "jdk22-mpnet-1m-2bit-ash", - "path": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk22-mpnet-1m-2bit-ash", - "source": "507b74e7ef8a0e12fcfe402deb4bf758c65758d5", - "started": "2026-10-29T02:24:09.744714+00:00", - "phases": { - "build": { - "elapsed_process_seconds": 70.72278314642608, - "log": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/logs/jdk22-mpnet-1m-2bit-ash-build.log", - "csv": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk22-mpnet-1m-2bit-ash/logging/jdk22-mpnet-1m-2bit-ash-build-20261029-022410Z/experiments.csv", - "rows": [ - { - "schema_version": "1", - "run_id": "jdk22-mpnet-1m-2bit-ash-build-20261029-022410Z", - "run_uuid": "251c22bc-d7d2-411b-9d4a-2e9d99c46292", - "system_id": "941aee9b5750", - "dataset_name": "mpnet-1m", - "M": "32", - "efConstruction": "100", - "neighborOverflow": "1.2", - "addHierarchy": "true", - "refineFinalGraph": "true", - "feature_set": "[NVQ_VECTORS, FUSED_ASH]", - "build_compressor": "\"AsymmetricHashing[origDim=768, encodedBits=808, bodyBits=768, quantizedDim=384, bitsPerDimension=2, optimizer=ITQ, landmarks=1]\"", - "search_compressor": "None", - "usePruning": "false", - "topK": "10", - "overquery": "1.0", - "rerankK": "10", - "search.accuracy.recall_at_10": "0.8194", - "construction.index_build_time_s": "58.372648107", - "construction.index_quant_time_s.ASH.compute_time_s": "5.20501655", - "construction.index_quant_time_s.ASH.encoding_time_s": "1.091514841", - "construction.index_quant_time_s.NVQ.compute_time_s": "0.368510427" - } - ], - "throughput_samples": [], - "finished": "2026-10-29T02:25:20.468934+00:00" - }, - "t1": { - "elapsed_process_seconds": 48.48104625567794, - "log": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/logs/jdk22-mpnet-1m-2bit-ash-t1.log", - "csv": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk22-mpnet-1m-2bit-ash/logging/jdk22-mpnet-1m-2bit-ash-t1-20261029-022521Z/experiments.csv", - "rows": [ - { - "schema_version": "1", - "run_id": "jdk22-mpnet-1m-2bit-ash-t1-20261029-022521Z", - "run_uuid": "ce7e6667-4789-46f8-ab8b-ee86b0a16093", - "system_id": "941aee9b5750", - "dataset_name": "mpnet-1m", - "M": "32", - "efConstruction": "100", - "neighborOverflow": "1.2", - "addHierarchy": "true", - "refineFinalGraph": "true", - "feature_set": "[NVQ_VECTORS, FUSED_ASH]", - "build_compressor": "\"AsymmetricHashing[origDim=768, encodedBits=808, bodyBits=768, quantizedDim=384, bitsPerDimension=2, optimizer=ITQ, landmarks=1]\"", - "search_compressor": "None", - "usePruning": "false", - "topK": "10", - "overquery": "1.0", - "rerankK": "10", - "search.throughput.avg_qps": "9610.898579370603", - "search.throughput.stddev_qps": "457.55703825931096", - "search.throughput.cv_pct": "4.760814345096079", - "search.count.avg_visited": "457.6786", - "search.accuracy.recall_at_10": "0.8194", - "construction.index_build_time_s": "", - "construction.index_quant_time_s.ASH.compute_time_s": "", - "construction.index_quant_time_s.ASH.encoding_time_s": "", - "construction.index_quant_time_s.NVQ.compute_time_s": "" - }, - { - "schema_version": "1", - "run_id": "jdk22-mpnet-1m-2bit-ash-t1-20261029-022521Z", - "run_uuid": "ce7e6667-4789-46f8-ab8b-ee86b0a16093", - "system_id": "941aee9b5750", - "dataset_name": "mpnet-1m", - "M": "32", - "efConstruction": "100", - "neighborOverflow": "1.2", - "addHierarchy": "true", - "refineFinalGraph": "true", - "feature_set": "[NVQ_VECTORS, FUSED_ASH]", - "build_compressor": "\"AsymmetricHashing[origDim=768, encodedBits=808, bodyBits=768, quantizedDim=384, bitsPerDimension=2, optimizer=ITQ, landmarks=1]\"", - "search_compressor": "None", - "usePruning": "false", - "topK": "10", - "overquery": "2.0", - "rerankK": "20", - "search.throughput.avg_qps": "7822.315710403462", - "search.throughput.stddev_qps": "39.11300159050038", - "search.throughput.cv_pct": "0.5000181920359105", - "search.count.avg_visited": "639.0262", - "search.accuracy.recall_at_10": "0.94055", - "construction.index_build_time_s": "", - "construction.index_quant_time_s.ASH.compute_time_s": "", - "construction.index_quant_time_s.ASH.encoding_time_s": "", - "construction.index_quant_time_s.NVQ.compute_time_s": "" - } - ], - "throughput_samples": [ - { - "sample": "Test-0", - "queries": 10000, - "seconds": 1.099656 - }, - { - "sample": "Test-1", - "queries": 10000, - "seconds": 1.022938 - }, - { - "sample": "Test-2", - "queries": 10000, - "seconds": 1.003696 - }, - { - "sample": "Test-0", - "queries": 10000, - "seconds": 1.280488 - }, - { - "sample": "Test-1", - "queries": 10000, - "seconds": 1.283499 - }, - { - "sample": "Test-2", - "queries": 10000, - "seconds": 1.271258 - } - ], - "finished": "2026-10-29T02:26:08.951458+00:00" - }, - "t8": { - "elapsed_process_seconds": 19.761133586987853, - "log": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/logs/jdk22-mpnet-1m-2bit-ash-t8.log", - "csv": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk22-mpnet-1m-2bit-ash/logging/jdk22-mpnet-1m-2bit-ash-t8-20261029-022609Z/experiments.csv", - "rows": [ - { - "schema_version": "1", - "run_id": "jdk22-mpnet-1m-2bit-ash-t8-20261029-022609Z", - "run_uuid": "49f61b6b-92c8-4d0d-b433-5481edde0b81", - "system_id": "941aee9b5750", - "dataset_name": "mpnet-1m", - "M": "32", - "efConstruction": "100", - "neighborOverflow": "1.2", - "addHierarchy": "true", - "refineFinalGraph": "true", - "feature_set": "[NVQ_VECTORS, FUSED_ASH]", - "build_compressor": "\"AsymmetricHashing[origDim=768, encodedBits=808, bodyBits=768, quantizedDim=384, bitsPerDimension=2, optimizer=ITQ, landmarks=1]\"", - "search_compressor": "None", - "usePruning": "false", - "topK": "10", - "overquery": "1.0", - "rerankK": "10", - "search.throughput.avg_qps": "70989.65395938238", - "search.throughput.stddev_qps": "2781.2699707500246", - "search.throughput.cv_pct": "3.91785255403747", - "search.count.avg_visited": "457.6786", - "search.accuracy.recall_at_10": "0.8194", - "construction.index_build_time_s": "", - "construction.index_quant_time_s.ASH.compute_time_s": "", - "construction.index_quant_time_s.ASH.encoding_time_s": "", - "construction.index_quant_time_s.NVQ.compute_time_s": "" - }, - { - "schema_version": "1", - "run_id": "jdk22-mpnet-1m-2bit-ash-t8-20261029-022609Z", - "run_uuid": "49f61b6b-92c8-4d0d-b433-5481edde0b81", - "system_id": "941aee9b5750", - "dataset_name": "mpnet-1m", - "M": "32", - "efConstruction": "100", - "neighborOverflow": "1.2", - "addHierarchy": "true", - "refineFinalGraph": "true", - "feature_set": "[NVQ_VECTORS, FUSED_ASH]", - "build_compressor": "\"AsymmetricHashing[origDim=768, encodedBits=808, bodyBits=768, quantizedDim=384, bitsPerDimension=2, optimizer=ITQ, landmarks=1]\"", - "search_compressor": "None", - "usePruning": "false", - "topK": "10", - "overquery": "2.0", - "rerankK": "20", - "search.throughput.avg_qps": "59038.82008864344", - "search.throughput.stddev_qps": "991.3419392083159", - "search.throughput.cv_pct": "1.6791357579976567", - "search.count.avg_visited": "639.0262", - "search.accuracy.recall_at_10": "0.94055", - "construction.index_build_time_s": "", - "construction.index_quant_time_s.ASH.compute_time_s": "", - "construction.index_quant_time_s.ASH.encoding_time_s": "", - "construction.index_quant_time_s.NVQ.compute_time_s": "" - } - ], - "throughput_samples": [ - { - "sample": "Test-0", - "queries": 70000, - "seconds": 1.031367 - }, - { - "sample": "Test-1", - "queries": 80000, - "seconds": 1.092698 - }, - { - "sample": "Test-2", - "queries": 80000, - "seconds": 1.112894 - }, - { - "sample": "Test-0", - "queries": 60000, - "seconds": 1.00914 - }, - { - "sample": "Test-1", - "queries": 60000, - "seconds": 1.004135 - }, - { - "sample": "Test-2", - "queries": 60000, - "seconds": 1.036145 - } - ], - "finished": "2026-10-29T02:26:28.714597+00:00" - } - }, - "index_files": [ - { - "path": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk22-mpnet-1m-2bit-ash/index_cache/graph_mpnet_1m_FUSED_ASH_NVQ_VECTORS_M32_ef100_of1.2_alpha1.2_H1_R1_AsymmetricHashing_origDim_768_encodedBits_808_bodyBits_768_quantizedDim_384_bitsPerDimension_2_optimizer_ITQ_landmarks_1_", - "bytes": 4205250935 - } - ], - "finished": "2026-10-29T02:26:28.715263+00:00" - }, - { - "dataset": "mpnet-1m", - "display_name": "MPNet", - "original_dimensions": "768", - "projected_ash_dimensions": "384", - "base_vectors": "999812", - "queries": "10000", - "ash_bits": "4", - "algorithm": "ASH", - "code_bytes": "197", - "ash_encoded_bits": "1576", - "pq_m": "", - "config": "mpnet-1m-4bit-budget-ash.yml", - "jdk": "22", - "label": "jdk22-mpnet-1m-4bit-ash", - "path": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk22-mpnet-1m-4bit-ash", - "source": "507b74e7ef8a0e12fcfe402deb4bf758c65758d5", - "started": "2026-10-29T02:26:28.718332+00:00", - "phases": { - "build": { - "elapsed_process_seconds": 54.33950508851558, - "log": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/logs/jdk22-mpnet-1m-4bit-ash-build.log", - "csv": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk22-mpnet-1m-4bit-ash/logging/jdk22-mpnet-1m-4bit-ash-build-20261029-022629Z/experiments.csv", - "rows": [ - { - "schema_version": "1", - "run_id": "jdk22-mpnet-1m-4bit-ash-build-20261029-022629Z", - "run_uuid": "11824a2a-1b82-4fe0-a37a-18977fb7e5ea", - "system_id": "941aee9b5750", - "dataset_name": "mpnet-1m", - "M": "32", - "efConstruction": "100", - "neighborOverflow": "1.2", - "addHierarchy": "true", - "refineFinalGraph": "true", - "feature_set": "[NVQ_VECTORS, FUSED_ASH]", - "build_compressor": "\"AsymmetricHashing[origDim=768, encodedBits=1576, bodyBits=1536, quantizedDim=384, bitsPerDimension=4, optimizer=ITQ, landmarks=1]\"", - "search_compressor": "None", - "usePruning": "false", - "topK": "10", - "overquery": "1.0", - "rerankK": "10", - "search.accuracy.recall_at_10": "0.86642", - "construction.index_build_time_s": "42.028731887", - "construction.index_quant_time_s.ASH.compute_time_s": "4.690223155", - "construction.index_quant_time_s.ASH.encoding_time_s": "1.194973879", - "construction.index_quant_time_s.NVQ.compute_time_s": "0.46261499" - } - ], - "throughput_samples": [], - "finished": "2026-10-29T02:27:23.058936+00:00" - }, - "t1": { - "elapsed_process_seconds": 94.77228872384876, - "log": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/logs/jdk22-mpnet-1m-4bit-ash-t1.log", - "csv": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk22-mpnet-1m-4bit-ash/logging/jdk22-mpnet-1m-4bit-ash-t1-20261029-022724Z/experiments.csv", - "rows": [ - { - "schema_version": "1", - "run_id": "jdk22-mpnet-1m-4bit-ash-t1-20261029-022724Z", - "run_uuid": "202fed30-8b28-4ca0-9ba5-5ed8c185236a", - "system_id": "941aee9b5750", - "dataset_name": "mpnet-1m", - "M": "32", - "efConstruction": "100", - "neighborOverflow": "1.2", - "addHierarchy": "true", - "refineFinalGraph": "true", - "feature_set": "[NVQ_VECTORS, FUSED_ASH]", - "build_compressor": "\"AsymmetricHashing[origDim=768, encodedBits=1576, bodyBits=1536, quantizedDim=384, bitsPerDimension=4, optimizer=ITQ, landmarks=1]\"", - "search_compressor": "None", - "usePruning": "false", - "topK": "10", - "overquery": "1.0", - "rerankK": "10", - "search.throughput.avg_qps": "8629.758114420085", - "search.throughput.stddev_qps": "522.9722821464711", - "search.throughput.cv_pct": "6.0601035998054105", - "search.count.avg_visited": "431.9821", - "search.accuracy.recall_at_10": "0.86642", - "construction.index_build_time_s": "", - "construction.index_quant_time_s.ASH.compute_time_s": "", - "construction.index_quant_time_s.ASH.encoding_time_s": "", - "construction.index_quant_time_s.NVQ.compute_time_s": "" - }, - { - "schema_version": "1", - "run_id": "jdk22-mpnet-1m-4bit-ash-t1-20261029-022724Z", - "run_uuid": "202fed30-8b28-4ca0-9ba5-5ed8c185236a", - "system_id": "941aee9b5750", - "dataset_name": "mpnet-1m", - "M": "32", - "efConstruction": "100", - "neighborOverflow": "1.2", - "addHierarchy": "true", - "refineFinalGraph": "true", - "feature_set": "[NVQ_VECTORS, FUSED_ASH]", - "build_compressor": "\"AsymmetricHashing[origDim=768, encodedBits=1576, bodyBits=1536, quantizedDim=384, bitsPerDimension=4, optimizer=ITQ, landmarks=1]\"", - "search_compressor": "None", - "usePruning": "false", - "topK": "10", - "overquery": "2.0", - "rerankK": "20", - "search.throughput.avg_qps": "6976.353944105883", - "search.throughput.stddev_qps": "19.32133701087272", - "search.throughput.cv_pct": "0.2769546551920112", - "search.count.avg_visited": "596.0551", - "search.accuracy.recall_at_10": "0.93966", - "construction.index_build_time_s": "", - "construction.index_quant_time_s.ASH.compute_time_s": "", - "construction.index_quant_time_s.ASH.encoding_time_s": "", - "construction.index_quant_time_s.NVQ.compute_time_s": "" - } - ], - "throughput_samples": [ - { - "sample": "Test-0", - "queries": 10000, - "seconds": 1.245735 - }, - { - "sample": "Test-1", - "queries": 10000, - "seconds": 1.124347 - }, - { - "sample": "Test-2", - "queries": 10000, - "seconds": 1.115096 - }, - { - "sample": "Test-0", - "queries": 10000, - "seconds": 1.437032 - }, - { - "sample": "Test-1", - "queries": 10000, - "seconds": 1.434057 - }, - { - "sample": "Test-2", - "queries": 10000, - "seconds": 1.429174 - } - ], - "finished": "2026-10-29T02:28:57.836995+00:00" - }, - "t8": { - "elapsed_process_seconds": 21.507000694982708, - "log": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/logs/jdk22-mpnet-1m-4bit-ash-t8.log", - "csv": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk22-mpnet-1m-4bit-ash/logging/jdk22-mpnet-1m-4bit-ash-t8-20261029-022858Z/experiments.csv", - "rows": [ - { - "schema_version": "1", - "run_id": "jdk22-mpnet-1m-4bit-ash-t8-20261029-022858Z", - "run_uuid": "588bca5c-54ff-4315-9b55-445f634e7f44", - "system_id": "941aee9b5750", - "dataset_name": "mpnet-1m", - "M": "32", - "efConstruction": "100", - "neighborOverflow": "1.2", - "addHierarchy": "true", - "refineFinalGraph": "true", - "feature_set": "[NVQ_VECTORS, FUSED_ASH]", - "build_compressor": "\"AsymmetricHashing[origDim=768, encodedBits=1576, bodyBits=1536, quantizedDim=384, bitsPerDimension=4, optimizer=ITQ, landmarks=1]\"", - "search_compressor": "None", - "usePruning": "false", - "topK": "10", - "overquery": "1.0", - "rerankK": "10", - "search.throughput.avg_qps": "64520.16990414337", - "search.throughput.stddev_qps": "1765.6384986179241", - "search.throughput.cv_pct": "2.73656827197", - "search.count.avg_visited": "431.9821", - "search.accuracy.recall_at_10": "0.86642", - "construction.index_build_time_s": "", - "construction.index_quant_time_s.ASH.compute_time_s": "", - "construction.index_quant_time_s.ASH.encoding_time_s": "", - "construction.index_quant_time_s.NVQ.compute_time_s": "" - }, - { - "schema_version": "1", - "run_id": "jdk22-mpnet-1m-4bit-ash-t8-20261029-022858Z", - "run_uuid": "588bca5c-54ff-4315-9b55-445f634e7f44", - "system_id": "941aee9b5750", - "dataset_name": "mpnet-1m", - "M": "32", - "efConstruction": "100", - "neighborOverflow": "1.2", - "addHierarchy": "true", - "refineFinalGraph": "true", - "feature_set": "[NVQ_VECTORS, FUSED_ASH]", - "build_compressor": "\"AsymmetricHashing[origDim=768, encodedBits=1576, bodyBits=1536, quantizedDim=384, bitsPerDimension=4, optimizer=ITQ, landmarks=1]\"", - "search_compressor": "None", - "usePruning": "false", - "topK": "10", - "overquery": "2.0", - "rerankK": "20", - "search.throughput.avg_qps": "52278.56016692018", - "search.throughput.stddev_qps": "2374.4805450263125", - "search.throughput.cv_pct": "4.541977700695726", - "search.count.avg_visited": "596.0551", - "search.accuracy.recall_at_10": "0.93966", - "construction.index_build_time_s": "", - "construction.index_quant_time_s.ASH.compute_time_s": "", - "construction.index_quant_time_s.ASH.encoding_time_s": "", - "construction.index_quant_time_s.NVQ.compute_time_s": "" - } - ], - "throughput_samples": [ - { - "sample": "Test-0", - "queries": 70000, - "seconds": 1.118416 - }, - { - "sample": "Test-1", - "queries": 70000, - "seconds": 1.078232 - }, - { - "sample": "Test-2", - "queries": 70000, - "seconds": 1.059789 - }, - { - "sample": "Test-0", - "queries": 60000, - "seconds": 1.135203 - }, - { - "sample": "Test-1", - "queries": 60000, - "seconds": 1.104719 - }, - { - "sample": "Test-2", - "queries": 50000, - "seconds": 1.006659 - } - ], - "finished": "2026-10-29T02:29:19.345481+00:00" - } - }, - "index_files": [ - { - "path": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk22-mpnet-1m-4bit-ash/index_cache/graph_mpnet_1m_FUSED_ASH_NVQ_VECTORS_M32_ef100_of1.2_alpha1.2_H1_R1_AsymmetricHashing_origDim_768_encodedBits_1576_bodyBits_1536_quantizedDim_384_bitsPerDimension_4_optimizer_ITQ_landmarks_1_", - "bytes": 7279672095 - } - ], - "finished": "2026-10-29T02:29:19.346072+00:00" - }, - { - "dataset": "cohere-english-v3-1M", - "display_name": "Cohere", - "original_dimensions": "1024", - "projected_ash_dimensions": "512", - "base_vectors": "1000000", - "queries": "10000", - "ash_bits": "2", - "algorithm": "ASH", - "code_bytes": "133", - "ash_encoded_bits": "1064", - "pq_m": "", - "config": "cohere-english-v3-1M-2bit-budget-ash.yml", - "jdk": "22", - "label": "jdk22-cohere-english-v3-1M-2bit-ash", - "path": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk22-cohere-english-v3-1M-2bit-ash", - "source": "507b74e7ef8a0e12fcfe402deb4bf758c65758d5", - "started": "2026-10-29T02:29:19.350029+00:00", - "phases": { - "build": { - "elapsed_process_seconds": 96.0135448006913, - "log": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/logs/jdk22-cohere-english-v3-1M-2bit-ash-build.log", - "csv": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk22-cohere-english-v3-1M-2bit-ash/logging/jdk22-cohere-english-v3-1M-2bit-ash-build-20261029-022919Z/experiments.csv", - "rows": [ - { - "schema_version": "1", - "run_id": "jdk22-cohere-english-v3-1M-2bit-ash-build-20261029-022919Z", - "run_uuid": "8a102358-28d0-40ca-818b-b045809a6d2a", - "system_id": "941aee9b5750", - "dataset_name": "cohere-english-v3-1M", - "M": "32", - "efConstruction": "100", - "neighborOverflow": "1.2", - "addHierarchy": "true", - "refineFinalGraph": "true", - "feature_set": "[NVQ_VECTORS, FUSED_ASH]", - "build_compressor": "\"AsymmetricHashing[origDim=1024, encodedBits=1064, bodyBits=1024, quantizedDim=512, bitsPerDimension=2, optimizer=ITQ, landmarks=1]\"", - "search_compressor": "None", - "usePruning": "false", - "topK": "10", - "overquery": "1.0", - "rerankK": "10", - "search.accuracy.recall_at_10": "0.63903", - "construction.index_build_time_s": "62.374692218", - "construction.index_quant_time_s.ASH.compute_time_s": "8.026500274", - "construction.index_quant_time_s.ASH.encoding_time_s": "1.861498103", - "construction.index_quant_time_s.NVQ.compute_time_s": "0.457021944" - } - ], - "throughput_samples": [], - "finished": "2026-10-29T02:30:55.364534+00:00" - }, - "t1": { - "elapsed_process_seconds": 57.975656444206834, - "log": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/logs/jdk22-cohere-english-v3-1M-2bit-ash-t1.log", - "csv": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk22-cohere-english-v3-1M-2bit-ash/logging/jdk22-cohere-english-v3-1M-2bit-ash-t1-20261029-023056Z/experiments.csv", - "rows": [ - { - "schema_version": "1", - "run_id": "jdk22-cohere-english-v3-1M-2bit-ash-t1-20261029-023056Z", - "run_uuid": "1eeb22cf-41a0-46d1-8c55-05e9c10b5fe0", - "system_id": "941aee9b5750", - "dataset_name": "cohere-english-v3-1M", - "M": "32", - "efConstruction": "100", - "neighborOverflow": "1.2", - "addHierarchy": "true", - "refineFinalGraph": "true", - "feature_set": "[NVQ_VECTORS, FUSED_ASH]", - "build_compressor": "\"AsymmetricHashing[origDim=1024, encodedBits=1064, bodyBits=1024, quantizedDim=512, bitsPerDimension=2, optimizer=ITQ, landmarks=1]\"", - "search_compressor": "None", - "usePruning": "false", - "topK": "10", - "overquery": "1.0", - "rerankK": "10", - "search.throughput.avg_qps": "6040.668013555356", - "search.throughput.stddev_qps": "274.3959501873643", - "search.throughput.cv_pct": "4.542476917645787", - "search.count.avg_visited": "567.867", - "search.accuracy.recall_at_10": "0.63903", - "construction.index_build_time_s": "", - "construction.index_quant_time_s.ASH.compute_time_s": "", - "construction.index_quant_time_s.ASH.encoding_time_s": "", - "construction.index_quant_time_s.NVQ.compute_time_s": "" - }, - { - "schema_version": "1", - "run_id": "jdk22-cohere-english-v3-1M-2bit-ash-t1-20261029-023056Z", - "run_uuid": "1eeb22cf-41a0-46d1-8c55-05e9c10b5fe0", - "system_id": "941aee9b5750", - "dataset_name": "cohere-english-v3-1M", - "M": "32", - "efConstruction": "100", - "neighborOverflow": "1.2", - "addHierarchy": "true", - "refineFinalGraph": "true", - "feature_set": "[NVQ_VECTORS, FUSED_ASH]", - "build_compressor": "\"AsymmetricHashing[origDim=1024, encodedBits=1064, bodyBits=1024, quantizedDim=512, bitsPerDimension=2, optimizer=ITQ, landmarks=1]\"", - "search_compressor": "None", - "usePruning": "false", - "topK": "10", - "overquery": "2.0", - "rerankK": "20", - "search.throughput.avg_qps": "5224.424204577702", - "search.throughput.stddev_qps": "61.298718058874684", - "search.throughput.cv_pct": "1.173310505780982", - "search.count.avg_visited": "812.9255", - "search.accuracy.recall_at_10": "0.81995", - "construction.index_build_time_s": "", - "construction.index_quant_time_s.ASH.compute_time_s": "", - "construction.index_quant_time_s.ASH.encoding_time_s": "", - "construction.index_quant_time_s.NVQ.compute_time_s": "" - } - ], - "throughput_samples": [ - { - "sample": "Test-0", - "queries": 10000, - "seconds": 1.742422 - }, - { - "sample": "Test-1", - "queries": 10000, - "seconds": 1.593445 - }, - { - "sample": "Test-2", - "queries": 10000, - "seconds": 1.637423 - }, - { - "sample": "Test-0", - "queries": 10000, - "seconds": 1.937973 - }, - { - "sample": "Test-1", - "queries": 10000, - "seconds": 1.893198 - }, - { - "sample": "Test-2", - "queries": 10000, - "seconds": 1.911616 - } - ], - "finished": "2026-10-29T02:31:53.342118+00:00" - }, - "t8": { - "elapsed_process_seconds": 22.230311824940145, - "log": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/logs/jdk22-cohere-english-v3-1M-2bit-ash-t8.log", - "csv": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk22-cohere-english-v3-1M-2bit-ash/logging/jdk22-cohere-english-v3-1M-2bit-ash-t8-20261029-023153Z/experiments.csv", - "rows": [ - { - "schema_version": "1", - "run_id": "jdk22-cohere-english-v3-1M-2bit-ash-t8-20261029-023153Z", - "run_uuid": "b57355e7-3ab3-4119-a2ba-b9369571dd27", - "system_id": "941aee9b5750", - "dataset_name": "cohere-english-v3-1M", - "M": "32", - "efConstruction": "100", - "neighborOverflow": "1.2", - "addHierarchy": "true", - "refineFinalGraph": "true", - "feature_set": "[NVQ_VECTORS, FUSED_ASH]", - "build_compressor": "\"AsymmetricHashing[origDim=1024, encodedBits=1064, bodyBits=1024, quantizedDim=512, bitsPerDimension=2, optimizer=ITQ, landmarks=1]\"", - "search_compressor": "None", - "usePruning": "false", - "topK": "10", - "overquery": "1.0", - "rerankK": "10", - "search.throughput.avg_qps": "47759.13958907922", - "search.throughput.stddev_qps": "2166.440848887819", - "search.throughput.cv_pct": "4.5361806505057", - "search.count.avg_visited": "567.867", - "search.accuracy.recall_at_10": "0.63903", - "construction.index_build_time_s": "", - "construction.index_quant_time_s.ASH.compute_time_s": "", - "construction.index_quant_time_s.ASH.encoding_time_s": "", - "construction.index_quant_time_s.NVQ.compute_time_s": "" - }, - { - "schema_version": "1", - "run_id": "jdk22-cohere-english-v3-1M-2bit-ash-t8-20261029-023153Z", - "run_uuid": "b57355e7-3ab3-4119-a2ba-b9369571dd27", - "system_id": "941aee9b5750", - "dataset_name": "cohere-english-v3-1M", - "M": "32", - "efConstruction": "100", - "neighborOverflow": "1.2", - "addHierarchy": "true", - "refineFinalGraph": "true", - "feature_set": "[NVQ_VECTORS, FUSED_ASH]", - "build_compressor": "\"AsymmetricHashing[origDim=1024, encodedBits=1064, bodyBits=1024, quantizedDim=512, bitsPerDimension=2, optimizer=ITQ, landmarks=1]\"", - "search_compressor": "None", - "usePruning": "false", - "topK": "10", - "overquery": "2.0", - "rerankK": "20", - "search.throughput.avg_qps": "39833.20097312", - "search.throughput.stddev_qps": "632.0039581039011", - "search.throughput.cv_pct": "1.586626087444959", - "search.count.avg_visited": "812.9255", - "search.accuracy.recall_at_10": "0.81995", - "construction.index_build_time_s": "", - "construction.index_quant_time_s.ASH.compute_time_s": "", - "construction.index_quant_time_s.ASH.encoding_time_s": "", - "construction.index_quant_time_s.NVQ.compute_time_s": "" - } - ], - "throughput_samples": [ - { - "sample": "Test-0", - "queries": 50000, - "seconds": 1.098549 - }, - { - "sample": "Test-1", - "queries": 50000, - "seconds": 1.043299 - }, - { - "sample": "Test-2", - "queries": 60000, - "seconds": 1.203902 - }, - { - "sample": "Test-0", - "queries": 40000, - "seconds": 1.020939 - }, - { - "sample": "Test-1", - "queries": 50000, - "seconds": 1.236365 - }, - { - "sample": "Test-2", - "queries": 40000, - "seconds": 1.003038 - } - ], - "finished": "2026-10-29T02:32:15.573889+00:00" - } - }, - "index_files": [ - { - "path": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk22-cohere-english-v3-1M-2bit-ash/index_cache/graph_cohere_english_v3_1M_FUSED_ASH_NVQ_VECTORS_M32_ef100_of1.2_alpha1.2_H1_R1_AsymmetricHashing_origDim_1024_encodedBits_1064_bodyBits_1024_quantizedDim_512_bitsPerDimension_2_optimizer_ITQ_landmarks_1_", - "bytes": 5488881546 - } - ], - "finished": "2026-10-29T02:32:15.574452+00:00" - }, - { - "dataset": "cohere-english-v3-1M", - "display_name": "Cohere", - "original_dimensions": "1024", - "projected_ash_dimensions": "512", - "base_vectors": "1000000", - "queries": "10000", - "ash_bits": "4", - "algorithm": "ASH", - "code_bytes": "261", - "ash_encoded_bits": "2088", - "pq_m": "", - "config": "cohere-english-v3-1M-4bit-budget-ash.yml", - "jdk": "22", - "label": "jdk22-cohere-english-v3-1M-4bit-ash", - "path": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk22-cohere-english-v3-1M-4bit-ash", - "source": "507b74e7ef8a0e12fcfe402deb4bf758c65758d5", - "started": "2026-10-29T02:32:15.577584+00:00", - "phases": { - "build": { - "elapsed_process_seconds": 66.18678959459066, - "log": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/logs/jdk22-cohere-english-v3-1M-4bit-ash-build.log", - "csv": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk22-cohere-english-v3-1M-4bit-ash/logging/jdk22-cohere-english-v3-1M-4bit-ash-build-20261029-023216Z/experiments.csv", - "rows": [ - { - "schema_version": "1", - "run_id": "jdk22-cohere-english-v3-1M-4bit-ash-build-20261029-023216Z", - "run_uuid": "e2881a55-6ac5-489d-b4fc-053f77441716", - "system_id": "941aee9b5750", - "dataset_name": "cohere-english-v3-1M", - "M": "32", - "efConstruction": "100", - "neighborOverflow": "1.2", - "addHierarchy": "true", - "refineFinalGraph": "true", - "feature_set": "[NVQ_VECTORS, FUSED_ASH]", - "build_compressor": "\"AsymmetricHashing[origDim=1024, encodedBits=2088, bodyBits=2048, quantizedDim=512, bitsPerDimension=4, optimizer=ITQ, landmarks=1]\"", - "search_compressor": "None", - "usePruning": "false", - "topK": "10", - "overquery": "1.0", - "rerankK": "10", - "search.accuracy.recall_at_10": "0.70693", - "construction.index_build_time_s": "49.729920663", - "construction.index_quant_time_s.ASH.compute_time_s": "6.624667012", - "construction.index_quant_time_s.ASH.encoding_time_s": "2.166285985", - "construction.index_quant_time_s.NVQ.compute_time_s": "0.488109724" - } - ], - "throughput_samples": [], - "finished": "2026-10-29T02:33:21.765393+00:00" - }, - "t1": { - "elapsed_process_seconds": 68.6147497491911, - "log": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/logs/jdk22-cohere-english-v3-1M-4bit-ash-t1.log", - "csv": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk22-cohere-english-v3-1M-4bit-ash/logging/jdk22-cohere-english-v3-1M-4bit-ash-t1-20261029-023322Z/experiments.csv", - "rows": [ - { - "schema_version": "1", - "run_id": "jdk22-cohere-english-v3-1M-4bit-ash-t1-20261029-023322Z", - "run_uuid": "fbdbc373-5904-4bf9-abe0-e4a74d72f25c", - "system_id": "941aee9b5750", - "dataset_name": "cohere-english-v3-1M", - "M": "32", - "efConstruction": "100", - "neighborOverflow": "1.2", - "addHierarchy": "true", - "refineFinalGraph": "true", - "feature_set": "[NVQ_VECTORS, FUSED_ASH]", - "build_compressor": "\"AsymmetricHashing[origDim=1024, encodedBits=2088, bodyBits=2048, quantizedDim=512, bitsPerDimension=4, optimizer=ITQ, landmarks=1]\"", - "search_compressor": "None", - "usePruning": "false", - "topK": "10", - "overquery": "1.0", - "rerankK": "10", - "search.throughput.avg_qps": "5459.5384791205415", - "search.throughput.stddev_qps": "302.9731487020324", - "search.throughput.cv_pct": "5.549427847440271", - "search.count.avg_visited": "540.8918", - "search.accuracy.recall_at_10": "0.70693", - "construction.index_build_time_s": "", - "construction.index_quant_time_s.ASH.compute_time_s": "", - "construction.index_quant_time_s.ASH.encoding_time_s": "", - "construction.index_quant_time_s.NVQ.compute_time_s": "" - }, - { - "schema_version": "1", - "run_id": "jdk22-cohere-english-v3-1M-4bit-ash-t1-20261029-023322Z", - "run_uuid": "fbdbc373-5904-4bf9-abe0-e4a74d72f25c", - "system_id": "941aee9b5750", - "dataset_name": "cohere-english-v3-1M", - "M": "32", - "efConstruction": "100", - "neighborOverflow": "1.2", - "addHierarchy": "true", - "refineFinalGraph": "true", - "feature_set": "[NVQ_VECTORS, FUSED_ASH]", - "build_compressor": "\"AsymmetricHashing[origDim=1024, encodedBits=2088, bodyBits=2048, quantizedDim=512, bitsPerDimension=4, optimizer=ITQ, landmarks=1]\"", - "search_compressor": "None", - "usePruning": "false", - "topK": "10", - "overquery": "2.0", - "rerankK": "20", - "search.throughput.avg_qps": "4609.658123480801", - "search.throughput.stddev_qps": "55.2745257320803", - "search.throughput.cv_pct": "1.1991024985241623", - "search.count.avg_visited": "770.202", - "search.accuracy.recall_at_10": "0.83397", - "construction.index_build_time_s": "", - "construction.index_quant_time_s.ASH.compute_time_s": "", - "construction.index_quant_time_s.ASH.encoding_time_s": "", - "construction.index_quant_time_s.NVQ.compute_time_s": "" - } - ], - "throughput_samples": [ - { - "sample": "Test-0", - "queries": 10000, - "seconds": 1.950538 - }, - { - "sample": "Test-1", - "queries": 10000, - "seconds": 1.748415 - }, - { - "sample": "Test-2", - "queries": 10000, - "seconds": 1.807548 - }, - { - "sample": "Test-0", - "queries": 10000, - "seconds": 2.179653 - }, - { - "sample": "Test-1", - "queries": 10000, - "seconds": 2.14018 - }, - { - "sample": "Test-2", - "queries": 10000, - "seconds": 2.188862 - } - ], - "finished": "2026-10-29T02:34:30.389781+00:00" - }, - "t8": { - "elapsed_process_seconds": 24.113950551487505, - "log": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/logs/jdk22-cohere-english-v3-1M-4bit-ash-t8.log", - "csv": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk22-cohere-english-v3-1M-4bit-ash/logging/jdk22-cohere-english-v3-1M-4bit-ash-t8-20261029-023430Z/experiments.csv", - "rows": [ - { - "schema_version": "1", - "run_id": "jdk22-cohere-english-v3-1M-4bit-ash-t8-20261029-023430Z", - "run_uuid": "ce00b6b0-b5a2-48be-9233-8c09a7078282", - "system_id": "941aee9b5750", - "dataset_name": "cohere-english-v3-1M", - "M": "32", - "efConstruction": "100", - "neighborOverflow": "1.2", - "addHierarchy": "true", - "refineFinalGraph": "true", - "feature_set": "[NVQ_VECTORS, FUSED_ASH]", - "build_compressor": "\"AsymmetricHashing[origDim=1024, encodedBits=2088, bodyBits=2048, quantizedDim=512, bitsPerDimension=4, optimizer=ITQ, landmarks=1]\"", - "search_compressor": "None", - "usePruning": "false", - "topK": "10", - "overquery": "1.0", - "rerankK": "10", - "search.throughput.avg_qps": "41672.84985305652", - "search.throughput.stddev_qps": "708.8527180104267", - "search.throughput.cv_pct": "1.7009941016991315", - "search.count.avg_visited": "540.8918", - "search.accuracy.recall_at_10": "0.70693", - "construction.index_build_time_s": "", - "construction.index_quant_time_s.ASH.compute_time_s": "", - "construction.index_quant_time_s.ASH.encoding_time_s": "", - "construction.index_quant_time_s.NVQ.compute_time_s": "" - }, - { - "schema_version": "1", - "run_id": "jdk22-cohere-english-v3-1M-4bit-ash-t8-20261029-023430Z", - "run_uuid": "ce00b6b0-b5a2-48be-9233-8c09a7078282", - "system_id": "941aee9b5750", - "dataset_name": "cohere-english-v3-1M", - "M": "32", - "efConstruction": "100", - "neighborOverflow": "1.2", - "addHierarchy": "true", - "refineFinalGraph": "true", - "feature_set": "[NVQ_VECTORS, FUSED_ASH]", - "build_compressor": "\"AsymmetricHashing[origDim=1024, encodedBits=2088, bodyBits=2048, quantizedDim=512, bitsPerDimension=4, optimizer=ITQ, landmarks=1]\"", - "search_compressor": "None", - "usePruning": "false", - "topK": "10", - "overquery": "2.0", - "rerankK": "20", - "search.throughput.avg_qps": "35207.00369774443", - "search.throughput.stddev_qps": "556.6259719487867", - "search.throughput.cv_pct": "1.5810092126199524", - "search.count.avg_visited": "770.202", - "search.accuracy.recall_at_10": "0.83397", - "construction.index_build_time_s": "", - "construction.index_quant_time_s.ASH.compute_time_s": "", - "construction.index_quant_time_s.ASH.encoding_time_s": "", - "construction.index_quant_time_s.NVQ.compute_time_s": "" - } - ], - "throughput_samples": [ - { - "sample": "Test-0", - "queries": 50000, - "seconds": 1.215201 - }, - { - "sample": "Test-1", - "queries": 50000, - "seconds": 1.207892 - }, - { - "sample": "Test-2", - "queries": 50000, - "seconds": 1.177062 - }, - { - "sample": "Test-0", - "queries": 40000, - "seconds": 1.138557 - }, - { - "sample": "Test-1", - "queries": 40000, - "seconds": 1.117404 - }, - { - "sample": "Test-2", - "queries": 40000, - "seconds": 1.153018 - } - ], - "finished": "2026-10-29T02:34:54.505208+00:00" - } - }, - "index_files": [ - { - "path": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk22-cohere-english-v3-1M-4bit-ash/index_cache/graph_cohere_english_v3_1M_FUSED_ASH_NVQ_VECTORS_M32_ef100_of1.2_alpha1.2_H1_R1_AsymmetricHashing_origDim_1024_encodedBits_2088_bodyBits_2048_quantizedDim_512_bitsPerDimension_4_optimizer_ITQ_landmarks_1_", - "bytes": 9588877024 - } - ], - "finished": "2026-10-29T02:34:54.505808+00:00" - }, - { - "dataset": "ada002-1M", - "display_name": "Ada002", - "original_dimensions": "1536", - "projected_ash_dimensions": "768", - "base_vectors": "982790", - "queries": "10000", - "ash_bits": "2", - "algorithm": "ASH", - "code_bytes": "197", - "ash_encoded_bits": "1576", - "pq_m": "", - "config": "ada002-1M-2bit-budget-ash.yml", - "jdk": "22", - "label": "jdk22-ada002-1M-2bit-ash", - "path": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk22-ada002-1M-2bit-ash", - "source": "507b74e7ef8a0e12fcfe402deb4bf758c65758d5", - "started": "2026-10-29T02:34:54.510224+00:00", - "phases": { - "build": { - "elapsed_process_seconds": 81.39980881009251, - "log": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/logs/jdk22-ada002-1M-2bit-ash-build.log", - "csv": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk22-ada002-1M-2bit-ash/logging/jdk22-ada002-1M-2bit-ash-build-20261029-023454Z/experiments.csv", - "rows": [ - { - "schema_version": "1", - "run_id": "jdk22-ada002-1M-2bit-ash-build-20261029-023454Z", - "run_uuid": "4904a028-2365-4c2e-af75-012ece38c338", - "system_id": "941aee9b5750", - "dataset_name": "ada002-1M", - "M": "32", - "efConstruction": "100", - "neighborOverflow": "1.2", - "addHierarchy": "true", - "refineFinalGraph": "true", - "feature_set": "[NVQ_VECTORS, FUSED_ASH]", - "build_compressor": "\"AsymmetricHashing[origDim=1536, encodedBits=1576, bodyBits=1536, quantizedDim=768, bitsPerDimension=2, optimizer=ITQ, landmarks=1]\"", - "search_compressor": "None", - "usePruning": "false", - "topK": "10", - "overquery": "1.0", - "rerankK": "10", - "search.accuracy.recall_at_10": "0.7888", - "construction.index_build_time_s": "49.604614259", - "construction.index_quant_time_s.ASH.compute_time_s": "13.7121569", - "construction.index_quant_time_s.ASH.encoding_time_s": "7.911204988", - "construction.index_quant_time_s.NVQ.compute_time_s": "0.598817294" - } - ], - "throughput_samples": [], - "finished": "2026-10-29T02:36:15.911669+00:00" - }, - "t1": { - "elapsed_process_seconds": 137.43477927148342, - "log": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/logs/jdk22-ada002-1M-2bit-ash-t1.log", - "csv": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk22-ada002-1M-2bit-ash/logging/jdk22-ada002-1M-2bit-ash-t1-20261029-023617Z/experiments.csv", - "rows": [ - { - "schema_version": "1", - "run_id": "jdk22-ada002-1M-2bit-ash-t1-20261029-023617Z", - "run_uuid": "70f08959-7a9a-4467-8eda-7649c8913fce", - "system_id": "941aee9b5750", - "dataset_name": "ada002-1M", - "M": "32", - "efConstruction": "100", - "neighborOverflow": "1.2", - "addHierarchy": "true", - "refineFinalGraph": "true", - "feature_set": "[NVQ_VECTORS, FUSED_ASH]", - "build_compressor": "\"AsymmetricHashing[origDim=1536, encodedBits=1576, bodyBits=1536, quantizedDim=768, bitsPerDimension=2, optimizer=ITQ, landmarks=1]\"", - "search_compressor": "None", - "usePruning": "false", - "topK": "10", - "overquery": "1.0", - "rerankK": "10", - "search.throughput.avg_qps": "3301.2900012077635", - "search.throughput.stddev_qps": "172.72057577933134", - "search.throughput.cv_pct": "5.2319116380609465", - "search.count.avg_visited": "501.4557", - "search.accuracy.recall_at_10": "0.7888", - "construction.index_build_time_s": "", - "construction.index_quant_time_s.ASH.compute_time_s": "", - "construction.index_quant_time_s.ASH.encoding_time_s": "", - "construction.index_quant_time_s.NVQ.compute_time_s": "" - }, - { - "schema_version": "1", - "run_id": "jdk22-ada002-1M-2bit-ash-t1-20261029-023617Z", - "run_uuid": "70f08959-7a9a-4467-8eda-7649c8913fce", - "system_id": "941aee9b5750", - "dataset_name": "ada002-1M", - "M": "32", - "efConstruction": "100", - "neighborOverflow": "1.2", - "addHierarchy": "true", - "refineFinalGraph": "true", - "feature_set": "[NVQ_VECTORS, FUSED_ASH]", - "build_compressor": "\"AsymmetricHashing[origDim=1536, encodedBits=1576, bodyBits=1536, quantizedDim=768, bitsPerDimension=2, optimizer=ITQ, landmarks=1]\"", - "search_compressor": "None", - "usePruning": "false", - "topK": "10", - "overquery": "2.0", - "rerankK": "20", - "search.throughput.avg_qps": "2987.2294544484503", - "search.throughput.stddev_qps": "75.4558191294618", - "search.throughput.cv_pct": "2.5259465427771652", - "search.count.avg_visited": "706.5122", - "search.accuracy.recall_at_10": "0.92685", - "construction.index_build_time_s": "", - "construction.index_quant_time_s.ASH.compute_time_s": "", - "construction.index_quant_time_s.ASH.encoding_time_s": "", - "construction.index_quant_time_s.NVQ.compute_time_s": "" - } - ], - "throughput_samples": [ - { - "sample": "Test-0", - "queries": 10000, - "seconds": 3.196809 - }, - { - "sample": "Test-1", - "queries": 10000, - "seconds": 3.02829 - }, - { - "sample": "Test-2", - "queries": 10000, - "seconds": 2.878893 - }, - { - "sample": "Test-0", - "queries": 10000, - "seconds": 3.447542 - }, - { - "sample": "Test-1", - "queries": 10000, - "seconds": 3.308704 - }, - { - "sample": "Test-2", - "queries": 10000, - "seconds": 3.290837 - } - ], - "finished": "2026-10-29T02:38:33.347894+00:00" - }, - "t8": { - "elapsed_process_seconds": 37.58102198410779, - "log": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/logs/jdk22-ada002-1M-2bit-ash-t8.log", - "csv": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk22-ada002-1M-2bit-ash/logging/jdk22-ada002-1M-2bit-ash-t8-20261029-023833Z/experiments.csv", - "rows": [ - { - "schema_version": "1", - "run_id": "jdk22-ada002-1M-2bit-ash-t8-20261029-023833Z", - "run_uuid": "3f818f21-e7d0-48aa-8358-1624dc32f8a3", - "system_id": "941aee9b5750", - "dataset_name": "ada002-1M", - "M": "32", - "efConstruction": "100", - "neighborOverflow": "1.2", - "addHierarchy": "true", - "refineFinalGraph": "true", - "feature_set": "[NVQ_VECTORS, FUSED_ASH]", - "build_compressor": "\"AsymmetricHashing[origDim=1536, encodedBits=1576, bodyBits=1536, quantizedDim=768, bitsPerDimension=2, optimizer=ITQ, landmarks=1]\"", - "search_compressor": "None", - "usePruning": "false", - "topK": "10", - "overquery": "1.0", - "rerankK": "10", - "search.throughput.avg_qps": "24911.127856122304", - "search.throughput.stddev_qps": "756.1189899379334", - "search.throughput.cv_pct": "3.035265983559653", - "search.count.avg_visited": "501.4557", - "search.accuracy.recall_at_10": "0.7888", - "construction.index_build_time_s": "", - "construction.index_quant_time_s.ASH.compute_time_s": "", - "construction.index_quant_time_s.ASH.encoding_time_s": "", - "construction.index_quant_time_s.NVQ.compute_time_s": "" - }, - { - "schema_version": "1", - "run_id": "jdk22-ada002-1M-2bit-ash-t8-20261029-023833Z", - "run_uuid": "3f818f21-e7d0-48aa-8358-1624dc32f8a3", - "system_id": "941aee9b5750", - "dataset_name": "ada002-1M", - "M": "32", - "efConstruction": "100", - "neighborOverflow": "1.2", - "addHierarchy": "true", - "refineFinalGraph": "true", - "feature_set": "[NVQ_VECTORS, FUSED_ASH]", - "build_compressor": "\"AsymmetricHashing[origDim=1536, encodedBits=1576, bodyBits=1536, quantizedDim=768, bitsPerDimension=2, optimizer=ITQ, landmarks=1]\"", - "search_compressor": "None", - "usePruning": "false", - "topK": "10", - "overquery": "2.0", - "rerankK": "20", - "search.throughput.avg_qps": "22127.98341769791", - "search.throughput.stddev_qps": "90.58944642512024", - "search.throughput.cv_pct": "0.40938862215825333", - "search.count.avg_visited": "706.5122", - "search.accuracy.recall_at_10": "0.92685", - "construction.index_build_time_s": "", - "construction.index_quant_time_s.ASH.compute_time_s": "", - "construction.index_quant_time_s.ASH.encoding_time_s": "", - "construction.index_quant_time_s.NVQ.compute_time_s": "" - } - ], - "throughput_samples": [ - { - "sample": "Test-0", - "queries": 30000, - "seconds": 1.247963 - }, - { - "sample": "Test-1", - "queries": 30000, - "seconds": 1.181765 - }, - { - "sample": "Test-2", - "queries": 30000, - "seconds": 1.185375 - }, - { - "sample": "Test-0", - "queries": 30000, - "seconds": 1.350112 - }, - { - "sample": "Test-1", - "queries": 30000, - "seconds": 1.355977 - }, - { - "sample": "Test-2", - "queries": 30000, - "seconds": 1.361204 - } - ], - "finished": "2026-10-29T02:39:10.930443+00:00" - } - }, - "index_files": [ - { - "path": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk22-ada002-1M-2bit-ash/index_cache/graph_ada002_1M_FUSED_ASH_NVQ_VECTORS_M32_ef100_of1.2_alpha1.2_H1_R1_AsymmetricHashing_origDim_1536_encodedBits_1576_bodyBits_1536_quantizedDim_768_bitsPerDimension_2_optimizer_ITQ_landmarks_1_", - "bytes": 7917649911 - } - ], - "finished": "2026-10-29T02:39:10.931050+00:00" - }, - { - "dataset": "ada002-1M", - "display_name": "Ada002", - "original_dimensions": "1536", - "projected_ash_dimensions": "768", - "base_vectors": "982790", - "queries": "10000", - "ash_bits": "4", - "algorithm": "ASH", - "code_bytes": "389", - "ash_encoded_bits": "3112", - "pq_m": "", - "config": "ada002-1M-4bit-budget-ash.yml", - "jdk": "22", - "label": "jdk22-ada002-1M-4bit-ash", - "path": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk22-ada002-1M-4bit-ash", - "source": "507b74e7ef8a0e12fcfe402deb4bf758c65758d5", - "started": "2026-10-29T02:39:10.935661+00:00", - "phases": { - "build": { - "elapsed_process_seconds": 85.24945241492242, - "log": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/logs/jdk22-ada002-1M-4bit-ash-build.log", - "csv": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk22-ada002-1M-4bit-ash/logging/jdk22-ada002-1M-4bit-ash-build-20261029-023911Z/experiments.csv", - "rows": [ - { - "schema_version": "1", - "run_id": "jdk22-ada002-1M-4bit-ash-build-20261029-023911Z", - "run_uuid": "8946de01-da12-4754-8777-82a537627299", - "system_id": "941aee9b5750", - "dataset_name": "ada002-1M", - "M": "32", - "efConstruction": "100", - "neighborOverflow": "1.2", - "addHierarchy": "true", - "refineFinalGraph": "true", - "feature_set": "[NVQ_VECTORS, FUSED_ASH]", - "build_compressor": "\"AsymmetricHashing[origDim=1536, encodedBits=3112, bodyBits=3072, quantizedDim=768, bitsPerDimension=4, optimizer=ITQ, landmarks=1]\"", - "search_compressor": "None", - "usePruning": "false", - "topK": "10", - "overquery": "1.0", - "rerankK": "10", - "search.accuracy.recall_at_10": "0.83231", - "construction.index_build_time_s": "56.9124183", - "construction.index_quant_time_s.ASH.compute_time_s": "10.947159808", - "construction.index_quant_time_s.ASH.encoding_time_s": "7.425704863", - "construction.index_quant_time_s.NVQ.compute_time_s": "0.557878664" - } - ], - "throughput_samples": [], - "finished": "2026-10-29T02:40:36.188986+00:00" - }, - "t1": { - "elapsed_process_seconds": 307.9505597706884, - "log": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/logs/jdk22-ada002-1M-4bit-ash-t1.log", - "csv": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk22-ada002-1M-4bit-ash/logging/jdk22-ada002-1M-4bit-ash-t1-20261029-024037Z/experiments.csv", - "rows": [ - { - "schema_version": "1", - "run_id": "jdk22-ada002-1M-4bit-ash-t1-20261029-024037Z", - "run_uuid": "4fdc9d3d-e0e7-43d2-a9e5-d634ec46cbf6", - "system_id": "941aee9b5750", - "dataset_name": "ada002-1M", - "M": "32", - "efConstruction": "100", - "neighborOverflow": "1.2", - "addHierarchy": "true", - "refineFinalGraph": "true", - "feature_set": "[NVQ_VECTORS, FUSED_ASH]", - "build_compressor": "\"AsymmetricHashing[origDim=1536, encodedBits=3112, bodyBits=3072, quantizedDim=768, bitsPerDimension=4, optimizer=ITQ, landmarks=1]\"", - "search_compressor": "None", - "usePruning": "false", - "topK": "10", - "overquery": "1.0", - "rerankK": "10", - "search.throughput.avg_qps": "2975.630205424497", - "search.throughput.stddev_qps": "207.2121051545478", - "search.throughput.cv_pct": "6.96363764478548", - "search.count.avg_visited": "475.3654", - "search.accuracy.recall_at_10": "0.83231", - "construction.index_build_time_s": "", - "construction.index_quant_time_s.ASH.compute_time_s": "", - "construction.index_quant_time_s.ASH.encoding_time_s": "", - "construction.index_quant_time_s.NVQ.compute_time_s": "" - }, - { - "schema_version": "1", - "run_id": "jdk22-ada002-1M-4bit-ash-t1-20261029-024037Z", - "run_uuid": "4fdc9d3d-e0e7-43d2-a9e5-d634ec46cbf6", - "system_id": "941aee9b5750", - "dataset_name": "ada002-1M", - "M": "32", - "efConstruction": "100", - "neighborOverflow": "1.2", - "addHierarchy": "true", - "refineFinalGraph": "true", - "feature_set": "[NVQ_VECTORS, FUSED_ASH]", - "build_compressor": "\"AsymmetricHashing[origDim=1536, encodedBits=3112, bodyBits=3072, quantizedDim=768, bitsPerDimension=4, optimizer=ITQ, landmarks=1]\"", - "search_compressor": "None", - "usePruning": "false", - "topK": "10", - "overquery": "2.0", - "rerankK": "20", - "search.throughput.avg_qps": "2698.9527131275813", - "search.throughput.stddev_qps": "56.93823605602251", - "search.throughput.cv_pct": "2.109641854007948", - "search.count.avg_visited": "666.273", - "search.accuracy.recall_at_10": "0.92443", - "construction.index_build_time_s": "", - "construction.index_quant_time_s.ASH.compute_time_s": "", - "construction.index_quant_time_s.ASH.encoding_time_s": "", - "construction.index_quant_time_s.NVQ.compute_time_s": "" - } - ], - "throughput_samples": [ - { - "sample": "Test-0", - "queries": 10000, - "seconds": 3.645538 - }, - { - "sample": "Test-1", - "queries": 10000, - "seconds": 3.286067 - }, - { - "sample": "Test-2", - "queries": 10000, - "seconds": 3.184044 - }, - { - "sample": "Test-0", - "queries": 10000, - "seconds": 3.797537 - }, - { - "sample": "Test-1", - "queries": 10000, - "seconds": 3.664373 - }, - { - "sample": "Test-2", - "queries": 10000, - "seconds": 3.656852 - } - ], - "finished": "2026-10-29T02:45:44.142259+00:00" - }, - "t8": { - "elapsed_process_seconds": 51.60719249583781, - "log": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/logs/jdk22-ada002-1M-4bit-ash-t8.log", - "csv": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk22-ada002-1M-4bit-ash/logging/jdk22-ada002-1M-4bit-ash-t8-20261029-024544Z/experiments.csv", - "rows": [ - { - "schema_version": "1", - "run_id": "jdk22-ada002-1M-4bit-ash-t8-20261029-024544Z", - "run_uuid": "2fb21ff9-85df-4469-9c7f-72f5ae897b45", - "system_id": "941aee9b5750", - "dataset_name": "ada002-1M", - "M": "32", - "efConstruction": "100", - "neighborOverflow": "1.2", - "addHierarchy": "true", - "refineFinalGraph": "true", - "feature_set": "[NVQ_VECTORS, FUSED_ASH]", - "build_compressor": "\"AsymmetricHashing[origDim=1536, encodedBits=3112, bodyBits=3072, quantizedDim=768, bitsPerDimension=4, optimizer=ITQ, landmarks=1]\"", - "search_compressor": "None", - "usePruning": "false", - "topK": "10", - "overquery": "1.0", - "rerankK": "10", - "search.throughput.avg_qps": "21870.622987666935", - "search.throughput.stddev_qps": "2054.0557899992486", - "search.throughput.cv_pct": "9.391848559401126", - "search.count.avg_visited": "475.3654", - "search.accuracy.recall_at_10": "0.83231", - "construction.index_build_time_s": "", - "construction.index_quant_time_s.ASH.compute_time_s": "", - "construction.index_quant_time_s.ASH.encoding_time_s": "", - "construction.index_quant_time_s.NVQ.compute_time_s": "" - }, - { - "schema_version": "1", - "run_id": "jdk22-ada002-1M-4bit-ash-t8-20261029-024544Z", - "run_uuid": "2fb21ff9-85df-4469-9c7f-72f5ae897b45", - "system_id": "941aee9b5750", - "dataset_name": "ada002-1M", - "M": "32", - "efConstruction": "100", - "neighborOverflow": "1.2", - "addHierarchy": "true", - "refineFinalGraph": "true", - "feature_set": "[NVQ_VECTORS, FUSED_ASH]", - "build_compressor": "\"AsymmetricHashing[origDim=1536, encodedBits=3112, bodyBits=3072, quantizedDim=768, bitsPerDimension=4, optimizer=ITQ, landmarks=1]\"", - "search_compressor": "None", - "usePruning": "false", - "topK": "10", - "overquery": "2.0", - "rerankK": "20", - "search.throughput.avg_qps": "19258.54676558465", - "search.throughput.stddev_qps": "604.5771747646882", - "search.throughput.cv_pct": "3.139266851874191", - "search.count.avg_visited": "666.273", - "search.accuracy.recall_at_10": "0.92443", - "construction.index_build_time_s": "", - "construction.index_quant_time_s.ASH.compute_time_s": "", - "construction.index_quant_time_s.ASH.encoding_time_s": "", - "construction.index_quant_time_s.NVQ.compute_time_s": "" - } - ], - "throughput_samples": [ - { - "sample": "Test-0", - "queries": 30000, - "seconds": 1.538163 - }, - { - "sample": "Test-1", - "queries": 30000, - "seconds": 1.308847 - }, - { - "sample": "Test-2", - "queries": 30000, - "seconds": 1.293821 - }, - { - "sample": "Test-0", - "queries": 20000, - "seconds": 1.074196 - }, - { - "sample": "Test-1", - "queries": 20000, - "seconds": 1.009078 - }, - { - "sample": "Test-2", - "queries": 20000, - "seconds": 1.034287 - } - ], - "finished": "2026-10-29T02:46:35.751077+00:00" - } - }, - "index_files": [ - { - "path": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk22-ada002-1M-4bit-ash/index_cache/graph_ada002_1M_FUSED_ASH_NVQ_VECTORS_M32_ef100_of1.2_alpha1.2_H1_R1_AsymmetricHashing_origDim_1536_encodedBits_3112_bodyBits_3072_quantizedDim_768_bitsPerDimension_4_optimizer_ITQ_landmarks_1_", - "bytes": 13961799958 - } - ], - "finished": "2026-10-29T02:46:35.751683+00:00" - }, - { - "dataset": "openai-3072-1m", - "display_name": "OpenAI3072", - "original_dimensions": "3072", - "projected_ash_dimensions": "1536", - "base_vectors": "999000", - "queries": "1000", - "ash_bits": "2", - "algorithm": "ASH", - "code_bytes": "389", - "ash_encoded_bits": "3112", - "pq_m": "", - "config": "openai-3072-1m-2bit-budget-ash.yml", - "jdk": "22", - "label": "jdk22-openai-3072-1m-2bit-ash", - "path": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk22-openai-3072-1m-2bit-ash", - "source": "507b74e7ef8a0e12fcfe402deb4bf758c65758d5", - "started": "2026-10-29T02:46:35.756638+00:00", - "phases": { - "build": { - "elapsed_process_seconds": 169.54594611562788, - "log": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/logs/jdk22-openai-3072-1m-2bit-ash-build.log", - "csv": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk22-openai-3072-1m-2bit-ash/logging/jdk22-openai-3072-1m-2bit-ash-build-20261029-024636Z/experiments.csv", - "rows": [ - { - "schema_version": "1", - "run_id": "jdk22-openai-3072-1m-2bit-ash-build-20261029-024636Z", - "run_uuid": "565cf797-ba52-4b52-8c8e-55e293a4d413", - "system_id": "941aee9b5750", - "dataset_name": "openai-3072-1m", - "M": "32", - "efConstruction": "100", - "neighborOverflow": "1.2", - "addHierarchy": "true", - "refineFinalGraph": "true", - "feature_set": "[NVQ_VECTORS, FUSED_ASH]", - "build_compressor": "\"AsymmetricHashing[origDim=3072, encodedBits=3112, bodyBits=3072, quantizedDim=1536, bitsPerDimension=2, optimizer=ITQ, landmarks=1]\"", - "search_compressor": "None", - "usePruning": "false", - "topK": "10", - "overquery": "1.0", - "rerankK": "10", - "search.accuracy.recall_at_10": "0.7792", - "construction.index_build_time_s": "85.71443966", - "construction.index_quant_time_s.ASH.compute_time_s": "41.122194304", - "construction.index_quant_time_s.ASH.encoding_time_s": "24.101615418", - "construction.index_quant_time_s.NVQ.compute_time_s": "1.578240137" - } - ], - "throughput_samples": [], - "finished": "2026-10-29T02:49:25.303579+00:00" - }, - "t1": { - "elapsed_process_seconds": 55.10534284170717, - "log": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/logs/jdk22-openai-3072-1m-2bit-ash-t1.log", - "csv": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk22-openai-3072-1m-2bit-ash/logging/jdk22-openai-3072-1m-2bit-ash-t1-20261029-024926Z/experiments.csv", - "rows": [ - { - "schema_version": "1", - "run_id": "jdk22-openai-3072-1m-2bit-ash-t1-20261029-024926Z", - "run_uuid": "bf1a9dc9-3009-4ad5-9736-234f90bd4ade", - "system_id": "941aee9b5750", - "dataset_name": "openai-3072-1m", - "M": "32", - "efConstruction": "100", - "neighborOverflow": "1.2", - "addHierarchy": "true", - "refineFinalGraph": "true", - "feature_set": "[NVQ_VECTORS, FUSED_ASH]", - "build_compressor": "\"AsymmetricHashing[origDim=3072, encodedBits=3112, bodyBits=3072, quantizedDim=1536, bitsPerDimension=2, optimizer=ITQ, landmarks=1]\"", - "search_compressor": "None", - "usePruning": "false", - "topK": "10", - "overquery": "1.0", - "rerankK": "10", - "search.throughput.avg_qps": "1031.0076970878936", - "search.throughput.stddev_qps": "45.92267428032494", - "search.throughput.cv_pct": "4.454154358889332", - "search.count.avg_visited": "530.783", - "search.accuracy.recall_at_10": "0.7792", - "construction.index_build_time_s": "", - "construction.index_quant_time_s.ASH.compute_time_s": "", - "construction.index_quant_time_s.ASH.encoding_time_s": "", - "construction.index_quant_time_s.NVQ.compute_time_s": "" - }, - { - "schema_version": "1", - "run_id": "jdk22-openai-3072-1m-2bit-ash-t1-20261029-024926Z", - "run_uuid": "bf1a9dc9-3009-4ad5-9736-234f90bd4ade", - "system_id": "941aee9b5750", - "dataset_name": "openai-3072-1m", - "M": "32", - "efConstruction": "100", - "neighborOverflow": "1.2", - "addHierarchy": "true", - "refineFinalGraph": "true", - "feature_set": "[NVQ_VECTORS, FUSED_ASH]", - "build_compressor": "\"AsymmetricHashing[origDim=3072, encodedBits=3112, bodyBits=3072, quantizedDim=1536, bitsPerDimension=2, optimizer=ITQ, landmarks=1]\"", - "search_compressor": "None", - "usePruning": "false", - "topK": "10", - "overquery": "2.0", - "rerankK": "20", - "search.throughput.avg_qps": "1068.223215503725", - "search.throughput.stddev_qps": "5.4895348079781785", - "search.throughput.cv_pct": "0.5138939809868825", - "search.count.avg_visited": "767.32", - "search.accuracy.recall_at_10": "0.898", - "construction.index_build_time_s": "", - "construction.index_quant_time_s.ASH.compute_time_s": "", - "construction.index_quant_time_s.ASH.encoding_time_s": "", - "construction.index_quant_time_s.NVQ.compute_time_s": "" - } - ], - "throughput_samples": [ - { - "sample": "Test-0", - "queries": 1000, - "seconds": 1.000775 - }, - { - "sample": "Test-1", - "queries": 2000, - "seconds": 1.979926 - }, - { - "sample": "Test-2", - "queries": 2000, - "seconds": 1.845599 - }, - { - "sample": "Test-0", - "queries": 2000, - "seconds": 1.882245 - }, - { - "sample": "Test-1", - "queries": 2000, - "seconds": 1.87163 - }, - { - "sample": "Test-2", - "queries": 2000, - "seconds": 1.863027 - } - ], - "finished": "2026-10-29T02:50:20.411001+00:00" - }, - "t8": { - "elapsed_process_seconds": 24.599812522530556, - "log": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/logs/jdk22-openai-3072-1m-2bit-ash-t8.log", - "csv": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk22-openai-3072-1m-2bit-ash/logging/jdk22-openai-3072-1m-2bit-ash-t8-20261029-025020Z/experiments.csv", - "rows": [ - { - "schema_version": "1", - "run_id": "jdk22-openai-3072-1m-2bit-ash-t8-20261029-025020Z", - "run_uuid": "cd240602-c1d0-4cfc-bb42-a468c000885e", - "system_id": "941aee9b5750", - "dataset_name": "openai-3072-1m", - "M": "32", - "efConstruction": "100", - "neighborOverflow": "1.2", - "addHierarchy": "true", - "refineFinalGraph": "true", - "feature_set": "[NVQ_VECTORS, FUSED_ASH]", - "build_compressor": "\"AsymmetricHashing[origDim=3072, encodedBits=3112, bodyBits=3072, quantizedDim=1536, bitsPerDimension=2, optimizer=ITQ, landmarks=1]\"", - "search_compressor": "None", - "usePruning": "false", - "topK": "10", - "overquery": "1.0", - "rerankK": "10", - "search.throughput.avg_qps": "7189.703298060613", - "search.throughput.stddev_qps": "411.1343239182431", - "search.throughput.cv_pct": "5.718376779597353", - "search.count.avg_visited": "530.783", - "search.accuracy.recall_at_10": "0.7792", - "construction.index_build_time_s": "", - "construction.index_quant_time_s.ASH.compute_time_s": "", - "construction.index_quant_time_s.ASH.encoding_time_s": "", - "construction.index_quant_time_s.NVQ.compute_time_s": "" - }, - { - "schema_version": "1", - "run_id": "jdk22-openai-3072-1m-2bit-ash-t8-20261029-025020Z", - "run_uuid": "cd240602-c1d0-4cfc-bb42-a468c000885e", - "system_id": "941aee9b5750", - "dataset_name": "openai-3072-1m", - "M": "32", - "efConstruction": "100", - "neighborOverflow": "1.2", - "addHierarchy": "true", - "refineFinalGraph": "true", - "feature_set": "[NVQ_VECTORS, FUSED_ASH]", - "build_compressor": "\"AsymmetricHashing[origDim=3072, encodedBits=3112, bodyBits=3072, quantizedDim=1536, bitsPerDimension=2, optimizer=ITQ, landmarks=1]\"", - "search_compressor": "None", - "usePruning": "false", - "topK": "10", - "overquery": "2.0", - "rerankK": "20", - "search.throughput.avg_qps": "7037.381528041226", - "search.throughput.stddev_qps": "98.28375598243322", - "search.throughput.cv_pct": "1.3965955318865506", - "search.count.avg_visited": "767.32", - "search.accuracy.recall_at_10": "0.898", - "construction.index_build_time_s": "", - "construction.index_quant_time_s.ASH.compute_time_s": "", - "construction.index_quant_time_s.ASH.encoding_time_s": "", - "construction.index_quant_time_s.NVQ.compute_time_s": "" - } - ], - "throughput_samples": [ - { - "sample": "Test-0", - "queries": 7000, - "seconds": 1.040714 - }, - { - "sample": "Test-1", - "queries": 8000, - "seconds": 1.065217 - }, - { - "sample": "Test-2", - "queries": 8000, - "seconds": 1.090996 - }, - { - "sample": "Test-0", - "queries": 8000, - "seconds": 1.129425 - }, - { - "sample": "Test-1", - "queries": 7000, - "seconds": 1.010896 - }, - { - "sample": "Test-2", - "queries": 8000, - "seconds": 1.126072 - } - ], - "finished": "2026-10-29T02:50:45.013068+00:00" - } - }, - "index_files": [ - { - "path": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk22-openai-3072-1m-2bit-ash/index_cache/graph_openai_3072_1m_FUSED_ASH_NVQ_VECTORS_M32_ef100_of1.2_alpha1.2_H1_R1_AsymmetricHashing_origDim_3072_encodedBits_3112_bodyBits_3072_quantizedDim_1536_bitsPerDimension_2_optimizer_ITQ_landmarks_1_", - "bytes": 15754739676 - } - ], - "finished": "2026-10-29T02:50:45.013634+00:00" - }, - { - "dataset": "openai-3072-1m", - "display_name": "OpenAI3072", - "original_dimensions": "3072", - "projected_ash_dimensions": "1536", - "base_vectors": "999000", - "queries": "1000", - "ash_bits": "4", - "algorithm": "ASH", - "code_bytes": "773", - "ash_encoded_bits": "6184", - "pq_m": "", - "config": "openai-3072-1m-4bit-budget-ash.yml", - "jdk": "22", - "label": "jdk22-openai-3072-1m-4bit-ash", - "path": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk22-openai-3072-1m-4bit-ash", - "source": "507b74e7ef8a0e12fcfe402deb4bf758c65758d5", - "started": "2026-10-29T02:50:45.018363+00:00", - "phases": { - "build": { - "elapsed_process_seconds": 208.43669655919075, - "log": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/logs/jdk22-openai-3072-1m-4bit-ash-build.log", - "csv": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk22-openai-3072-1m-4bit-ash/logging/jdk22-openai-3072-1m-4bit-ash-build-20261029-025045Z/experiments.csv", - "rows": [ - { - "schema_version": "1", - "run_id": "jdk22-openai-3072-1m-4bit-ash-build-20261029-025045Z", - "run_uuid": "45451992-3ee3-4ed4-9fdb-d0141fefc43c", - "system_id": "941aee9b5750", - "dataset_name": "openai-3072-1m", - "M": "32", - "efConstruction": "100", - "neighborOverflow": "1.2", - "addHierarchy": "true", - "refineFinalGraph": "true", - "feature_set": "[NVQ_VECTORS, FUSED_ASH]", - "build_compressor": "\"AsymmetricHashing[origDim=3072, encodedBits=6184, bodyBits=6144, quantizedDim=1536, bitsPerDimension=4, optimizer=ITQ, landmarks=1]\"", - "search_compressor": "None", - "usePruning": "false", - "topK": "10", - "overquery": "1.0", - "rerankK": "10", - "search.accuracy.recall_at_10": "0.7903", - "construction.index_build_time_s": "124.325741592", - "construction.index_quant_time_s.ASH.compute_time_s": "39.53853562", - "construction.index_quant_time_s.ASH.encoding_time_s": "27.206676416", - "construction.index_quant_time_s.NVQ.compute_time_s": "1.118749346" - } - ], - "throughput_samples": [], - "finished": "2026-10-29T02:54:13.456146+00:00" - }, - "t1": { - "elapsed_process_seconds": 57.35175254754722, - "log": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/logs/jdk22-openai-3072-1m-4bit-ash-t1.log", - "csv": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk22-openai-3072-1m-4bit-ash/logging/jdk22-openai-3072-1m-4bit-ash-t1-20261029-025414Z/experiments.csv", - "rows": [ - { - "schema_version": "1", - "run_id": "jdk22-openai-3072-1m-4bit-ash-t1-20261029-025414Z", - "run_uuid": "272d124c-06ae-4833-bc54-2afb6f51c58c", - "system_id": "941aee9b5750", - "dataset_name": "openai-3072-1m", - "M": "32", - "efConstruction": "100", - "neighborOverflow": "1.2", - "addHierarchy": "true", - "refineFinalGraph": "true", - "feature_set": "[NVQ_VECTORS, FUSED_ASH]", - "build_compressor": "\"AsymmetricHashing[origDim=3072, encodedBits=6184, bodyBits=6144, quantizedDim=1536, bitsPerDimension=4, optimizer=ITQ, landmarks=1]\"", - "search_compressor": "None", - "usePruning": "false", - "topK": "10", - "overquery": "1.0", - "rerankK": "10", - "search.throughput.avg_qps": "1016.1509827131623", - "search.throughput.stddev_qps": "48.29108771527011", - "search.throughput.cv_pct": "4.752353590834606", - "search.count.avg_visited": "512.685", - "search.accuracy.recall_at_10": "0.7903", - "construction.index_build_time_s": "", - "construction.index_quant_time_s.ASH.compute_time_s": "", - "construction.index_quant_time_s.ASH.encoding_time_s": "", - "construction.index_quant_time_s.NVQ.compute_time_s": "" - }, - { - "schema_version": "1", - "run_id": "jdk22-openai-3072-1m-4bit-ash-t1-20261029-025414Z", - "run_uuid": "272d124c-06ae-4833-bc54-2afb6f51c58c", - "system_id": "941aee9b5750", - "dataset_name": "openai-3072-1m", - "M": "32", - "efConstruction": "100", - "neighborOverflow": "1.2", - "addHierarchy": "true", - "refineFinalGraph": "true", - "feature_set": "[NVQ_VECTORS, FUSED_ASH]", - "build_compressor": "\"AsymmetricHashing[origDim=3072, encodedBits=6184, bodyBits=6144, quantizedDim=1536, bitsPerDimension=4, optimizer=ITQ, landmarks=1]\"", - "search_compressor": "None", - "usePruning": "false", - "topK": "10", - "overquery": "2.0", - "rerankK": "20", - "search.throughput.avg_qps": "1030.8662536443949", - "search.throughput.stddev_qps": "5.289502308681586", - "search.throughput.cv_pct": "0.5131123741786817", - "search.count.avg_visited": "735.241", - "search.accuracy.recall_at_10": "0.8834", - "construction.index_build_time_s": "", - "construction.index_quant_time_s.ASH.compute_time_s": "", - "construction.index_quant_time_s.ASH.encoding_time_s": "", - "construction.index_quant_time_s.NVQ.compute_time_s": "" - } - ], - "throughput_samples": [ - { - "sample": "Test-0", - "queries": 1000, - "seconds": 1.02091 - }, - { - "sample": "Test-1", - "queries": 2000, - "seconds": 2.00389 - }, - { - "sample": "Test-2", - "queries": 2000, - "seconds": 1.867631 - }, - { - "sample": "Test-0", - "queries": 2000, - "seconds": 1.951248 - }, - { - "sample": "Test-1", - "queries": 2000, - "seconds": 1.937274 - }, - { - "sample": "Test-2", - "queries": 2000, - "seconds": 1.931929 - } - ], - "finished": "2026-10-29T02:55:10.810214+00:00" - }, - "t8": { - "elapsed_process_seconds": 24.646791006438434, - "log": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/logs/jdk22-openai-3072-1m-4bit-ash-t8.log", - "csv": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk22-openai-3072-1m-4bit-ash/logging/jdk22-openai-3072-1m-4bit-ash-t8-20261029-025511Z/experiments.csv", - "rows": [ - { - "schema_version": "1", - "run_id": "jdk22-openai-3072-1m-4bit-ash-t8-20261029-025511Z", - "run_uuid": "79ef4baf-0386-4b63-b687-dfbe2ea6ae1d", - "system_id": "941aee9b5750", - "dataset_name": "openai-3072-1m", - "M": "32", - "efConstruction": "100", - "neighborOverflow": "1.2", - "addHierarchy": "true", - "refineFinalGraph": "true", - "feature_set": "[NVQ_VECTORS, FUSED_ASH]", - "build_compressor": "\"AsymmetricHashing[origDim=3072, encodedBits=6184, bodyBits=6144, quantizedDim=1536, bitsPerDimension=4, optimizer=ITQ, landmarks=1]\"", - "search_compressor": "None", - "usePruning": "false", - "topK": "10", - "overquery": "1.0", - "rerankK": "10", - "search.throughput.avg_qps": "7507.82372210983", - "search.throughput.stddev_qps": "379.60972307236995", - "search.throughput.cv_pct": "5.056188545749353", - "search.count.avg_visited": "512.685", - "search.accuracy.recall_at_10": "0.7903", - "construction.index_build_time_s": "", - "construction.index_quant_time_s.ASH.compute_time_s": "", - "construction.index_quant_time_s.ASH.encoding_time_s": "", - "construction.index_quant_time_s.NVQ.compute_time_s": "" - }, - { - "schema_version": "1", - "run_id": "jdk22-openai-3072-1m-4bit-ash-t8-20261029-025511Z", - "run_uuid": "79ef4baf-0386-4b63-b687-dfbe2ea6ae1d", - "system_id": "941aee9b5750", - "dataset_name": "openai-3072-1m", - "M": "32", - "efConstruction": "100", - "neighborOverflow": "1.2", - "addHierarchy": "true", - "refineFinalGraph": "true", - "feature_set": "[NVQ_VECTORS, FUSED_ASH]", - "build_compressor": "\"AsymmetricHashing[origDim=3072, encodedBits=6184, bodyBits=6144, quantizedDim=1536, bitsPerDimension=4, optimizer=ITQ, landmarks=1]\"", - "search_compressor": "None", - "usePruning": "false", - "topK": "10", - "overquery": "2.0", - "rerankK": "20", - "search.throughput.avg_qps": "7360.509055849552", - "search.throughput.stddev_qps": "197.69692384205354", - "search.throughput.cv_pct": "2.6859137369709454", - "search.count.avg_visited": "735.241", - "search.accuracy.recall_at_10": "0.8834", - "construction.index_build_time_s": "", - "construction.index_quant_time_s.ASH.compute_time_s": "", - "construction.index_quant_time_s.ASH.encoding_time_s": "", - "construction.index_quant_time_s.NVQ.compute_time_s": "" - } - ], - "throughput_samples": [ - { - "sample": "Test-0", - "queries": 8000, - "seconds": 1.129005 - }, - { - "sample": "Test-1", - "queries": 8000, - "seconds": 1.022802 - }, - { - "sample": "Test-2", - "queries": 8000, - "seconds": 1.050429 - }, - { - "sample": "Test-0", - "queries": 8000, - "seconds": 1.11824 - }, - { - "sample": "Test-1", - "queries": 8000, - "seconds": 1.084117 - }, - { - "sample": "Test-2", - "queries": 8000, - "seconds": 1.059862 - } - ], - "finished": "2026-10-29T02:55:35.458471+00:00" - } - }, - "index_files": [ - { - "path": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk22-openai-3072-1m-4bit-ash/index_cache/graph_openai_3072_1m_FUSED_ASH_NVQ_VECTORS_M32_ef100_of1.2_alpha1.2_H1_R1_AsymmetricHashing_origDim_3072_encodedBits_6184_bodyBits_6144_quantizedDim_1536_bitsPerDimension_4_optimizer_ITQ_landmarks_1_", - "bytes": 28042434388 - } - ], - "finished": "2026-10-29T02:55:35.459052+00:00" - }, - { - "dataset": "mpnet-1m", - "display_name": "MPNet", - "original_dimensions": "768", - "projected_ash_dimensions": "384", - "base_vectors": "999812", - "queries": "10000", - "ash_bits": "2", - "algorithm": "ASH", - "code_bytes": "101", - "ash_encoded_bits": "808", - "pq_m": "", - "config": "mpnet-1m-2bit-budget-ash.yml", - "jdk": "25", - "label": "jdk25-mpnet-1m-2bit-ash", - "path": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk25-mpnet-1m-2bit-ash", - "source": "507b74e7ef8a0e12fcfe402deb4bf758c65758d5", - "started": "2026-10-29T02:55:35.463544+00:00", - "phases": { - "build": { - "elapsed_process_seconds": 43.574435736984015, - "log": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/logs/jdk25-mpnet-1m-2bit-ash-build.log", - "csv": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk25-mpnet-1m-2bit-ash/logging/jdk25-mpnet-1m-2bit-ash-build-20261029-025536Z/experiments.csv", - "rows": [ - { - "schema_version": "1", - "run_id": "jdk25-mpnet-1m-2bit-ash-build-20261029-025536Z", - "run_uuid": "2e08a76a-5d9a-493b-8f93-48294f83637d", - "system_id": "eafa45791612", - "dataset_name": "mpnet-1m", - "M": "32", - "efConstruction": "100", - "neighborOverflow": "1.2", - "addHierarchy": "true", - "refineFinalGraph": "true", - "feature_set": "[NVQ_VECTORS, FUSED_ASH]", - "build_compressor": "\"AsymmetricHashing[origDim=768, encodedBits=808, bodyBits=768, quantizedDim=384, bitsPerDimension=2, optimizer=ITQ, landmarks=1]\"", - "search_compressor": "None", - "usePruning": "false", - "topK": "10", - "overquery": "1.0", - "rerankK": "10", - "search.accuracy.recall_at_10": "0.82017", - "construction.index_build_time_s": "31.631570707", - "construction.index_quant_time_s.ASH.compute_time_s": "4.795862", - "construction.index_quant_time_s.ASH.encoding_time_s": "1.001739035", - "construction.index_quant_time_s.NVQ.compute_time_s": "0.364870626" - } - ], - "throughput_samples": [], - "finished": "2026-10-29T02:56:19.039914+00:00" - }, - "t1": { - "elapsed_process_seconds": 49.28656546585262, - "log": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/logs/jdk25-mpnet-1m-2bit-ash-t1.log", - "csv": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk25-mpnet-1m-2bit-ash/logging/jdk25-mpnet-1m-2bit-ash-t1-20261029-025620Z/experiments.csv", - "rows": [ - { - "schema_version": "1", - "run_id": "jdk25-mpnet-1m-2bit-ash-t1-20261029-025620Z", - "run_uuid": "5d3e6837-707d-4551-9052-9b697a01a619", - "system_id": "eafa45791612", - "dataset_name": "mpnet-1m", - "M": "32", - "efConstruction": "100", - "neighborOverflow": "1.2", - "addHierarchy": "true", - "refineFinalGraph": "true", - "feature_set": "[NVQ_VECTORS, FUSED_ASH]", - "build_compressor": "\"AsymmetricHashing[origDim=768, encodedBits=808, bodyBits=768, quantizedDim=384, bitsPerDimension=2, optimizer=ITQ, landmarks=1]\"", - "search_compressor": "None", - "usePruning": "false", - "topK": "10", - "overquery": "1.0", - "rerankK": "10", - "search.throughput.avg_qps": "10357.12246572871", - "search.throughput.stddev_qps": "15.687831200532978", - "search.throughput.cv_pct": "0.15146901325578957", - "search.count.avg_visited": "455.8008", - "search.accuracy.recall_at_10": "0.82017", - "construction.index_build_time_s": "", - "construction.index_quant_time_s.ASH.compute_time_s": "", - "construction.index_quant_time_s.ASH.encoding_time_s": "", - "construction.index_quant_time_s.NVQ.compute_time_s": "" - }, - { - "schema_version": "1", - "run_id": "jdk25-mpnet-1m-2bit-ash-t1-20261029-025620Z", - "run_uuid": "5d3e6837-707d-4551-9052-9b697a01a619", - "system_id": "eafa45791612", - "dataset_name": "mpnet-1m", - "M": "32", - "efConstruction": "100", - "neighborOverflow": "1.2", - "addHierarchy": "true", - "refineFinalGraph": "true", - "feature_set": "[NVQ_VECTORS, FUSED_ASH]", - "build_compressor": "\"AsymmetricHashing[origDim=768, encodedBits=808, bodyBits=768, quantizedDim=384, bitsPerDimension=2, optimizer=ITQ, landmarks=1]\"", - "search_compressor": "None", - "usePruning": "false", - "topK": "10", - "overquery": "2.0", - "rerankK": "20", - "search.throughput.avg_qps": "8282.467440755714", - "search.throughput.stddev_qps": "24.053931479168885", - "search.throughput.cv_pct": "0.2904198736816784", - "search.count.avg_visited": "636.613", - "search.accuracy.recall_at_10": "0.93971", - "construction.index_build_time_s": "", - "construction.index_quant_time_s.ASH.compute_time_s": "", - "construction.index_quant_time_s.ASH.encoding_time_s": "", - "construction.index_quant_time_s.NVQ.compute_time_s": "" - } - ], - "throughput_samples": [ - { - "sample": "Test-0", - "queries": 20000, - "seconds": 1.934062 - }, - { - "sample": "Test-1", - "queries": 20000, - "seconds": 1.928222 - }, - { - "sample": "Test-2", - "queries": 20000, - "seconds": 1.93084 - }, - { - "sample": "Test-0", - "queries": 10000, - "seconds": 1.211122 - }, - { - "sample": "Test-1", - "queries": 10000, - "seconds": 1.204165 - }, - { - "sample": "Test-2", - "queries": 10000, - "seconds": 1.206843 - } - ], - "finished": "2026-10-29T02:57:08.327785+00:00" - }, - "t8": { - "elapsed_process_seconds": 19.72120023984462, - "log": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/logs/jdk25-mpnet-1m-2bit-ash-t8.log", - "csv": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk25-mpnet-1m-2bit-ash/logging/jdk25-mpnet-1m-2bit-ash-t8-20261029-025708Z/experiments.csv", - "rows": [ - { - "schema_version": "1", - "run_id": "jdk25-mpnet-1m-2bit-ash-t8-20261029-025708Z", - "run_uuid": "8b03ec06-a11a-4dd9-8f41-bc0899ec533e", - "system_id": "eafa45791612", - "dataset_name": "mpnet-1m", - "M": "32", - "efConstruction": "100", - "neighborOverflow": "1.2", - "addHierarchy": "true", - "refineFinalGraph": "true", - "feature_set": "[NVQ_VECTORS, FUSED_ASH]", - "build_compressor": "\"AsymmetricHashing[origDim=768, encodedBits=808, bodyBits=768, quantizedDim=384, bitsPerDimension=2, optimizer=ITQ, landmarks=1]\"", - "search_compressor": "None", - "usePruning": "false", - "topK": "10", - "overquery": "1.0", - "rerankK": "10", - "search.throughput.avg_qps": "73840.6240900836", - "search.throughput.stddev_qps": "3301.781445008702", - "search.throughput.cv_pct": "4.471497208610557", - "search.count.avg_visited": "455.8008", - "search.accuracy.recall_at_10": "0.82017", - "construction.index_build_time_s": "", - "construction.index_quant_time_s.ASH.compute_time_s": "", - "construction.index_quant_time_s.ASH.encoding_time_s": "", - "construction.index_quant_time_s.NVQ.compute_time_s": "" - }, - { - "schema_version": "1", - "run_id": "jdk25-mpnet-1m-2bit-ash-t8-20261029-025708Z", - "run_uuid": "8b03ec06-a11a-4dd9-8f41-bc0899ec533e", - "system_id": "eafa45791612", - "dataset_name": "mpnet-1m", - "M": "32", - "efConstruction": "100", - "neighborOverflow": "1.2", - "addHierarchy": "true", - "refineFinalGraph": "true", - "feature_set": "[NVQ_VECTORS, FUSED_ASH]", - "build_compressor": "\"AsymmetricHashing[origDim=768, encodedBits=808, bodyBits=768, quantizedDim=384, bitsPerDimension=2, optimizer=ITQ, landmarks=1]\"", - "search_compressor": "None", - "usePruning": "false", - "topK": "10", - "overquery": "2.0", - "rerankK": "20", - "search.throughput.avg_qps": "60433.593076138226", - "search.throughput.stddev_qps": "1524.9801313351495", - "search.throughput.cv_pct": "2.5233980865805528", - "search.count.avg_visited": "636.613", - "search.accuracy.recall_at_10": "0.93971", - "construction.index_build_time_s": "", - "construction.index_quant_time_s.ASH.compute_time_s": "", - "construction.index_quant_time_s.ASH.encoding_time_s": "", - "construction.index_quant_time_s.NVQ.compute_time_s": "" - } - ], - "throughput_samples": [ - { - "sample": "Test-0", - "queries": 80000, - "seconds": 1.127333 - }, - { - "sample": "Test-1", - "queries": 80000, - "seconds": 1.094211 - }, - { - "sample": "Test-2", - "queries": 80000, - "seconds": 1.03298 - }, - { - "sample": "Test-0", - "queries": 70000, - "seconds": 1.139031 - }, - { - "sample": "Test-1", - "queries": 60000, - "seconds": 1.022482 - }, - { - "sample": "Test-2", - "queries": 70000, - "seconds": 1.144459 - } - ], - "finished": "2026-10-29T02:57:28.050575+00:00" - } - }, - "index_files": [ - { - "path": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk25-mpnet-1m-2bit-ash/index_cache/graph_mpnet_1m_FUSED_ASH_NVQ_VECTORS_M32_ef100_of1.2_alpha1.2_H1_R1_AsymmetricHashing_origDim_768_encodedBits_808_bodyBits_768_quantizedDim_384_bitsPerDimension_2_optimizer_ITQ_landmarks_1_", - "bytes": 4205249625 - } - ], - "finished": "2026-10-29T02:57:28.051165+00:00" - }, - { - "dataset": "mpnet-1m", - "display_name": "MPNet", - "original_dimensions": "768", - "projected_ash_dimensions": "384", - "base_vectors": "999812", - "queries": "10000", - "ash_bits": "4", - "algorithm": "ASH", - "code_bytes": "197", - "ash_encoded_bits": "1576", - "pq_m": "", - "config": "mpnet-1m-4bit-budget-ash.yml", - "jdk": "25", - "label": "jdk25-mpnet-1m-4bit-ash", - "path": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk25-mpnet-1m-4bit-ash", - "source": "507b74e7ef8a0e12fcfe402deb4bf758c65758d5", - "started": "2026-10-29T02:57:28.056368+00:00", - "phases": { - "build": { - "elapsed_process_seconds": 54.83608163334429, - "log": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/logs/jdk25-mpnet-1m-4bit-ash-build.log", - "csv": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk25-mpnet-1m-4bit-ash/logging/jdk25-mpnet-1m-4bit-ash-build-20261029-025728Z/experiments.csv", - "rows": [ - { - "schema_version": "1", - "run_id": "jdk25-mpnet-1m-4bit-ash-build-20261029-025728Z", - "run_uuid": "b3d374b8-3e2b-4449-93fa-6b96c0c5b24b", - "system_id": "eafa45791612", - "dataset_name": "mpnet-1m", - "M": "32", - "efConstruction": "100", - "neighborOverflow": "1.2", - "addHierarchy": "true", - "refineFinalGraph": "true", - "feature_set": "[NVQ_VECTORS, FUSED_ASH]", - "build_compressor": "\"AsymmetricHashing[origDim=768, encodedBits=1576, bodyBits=1536, quantizedDim=384, bitsPerDimension=4, optimizer=ITQ, landmarks=1]\"", - "search_compressor": "None", - "usePruning": "false", - "topK": "10", - "overquery": "1.0", - "rerankK": "10", - "search.accuracy.recall_at_10": "0.86293", - "construction.index_build_time_s": "43.289757796", - "construction.index_quant_time_s.ASH.compute_time_s": "4.454340959", - "construction.index_quant_time_s.ASH.encoding_time_s": "1.201650817", - "construction.index_quant_time_s.NVQ.compute_time_s": "0.358496614" - } - ], - "throughput_samples": [], - "finished": "2026-10-29T02:58:22.893449+00:00" - }, - "t1": { - "elapsed_process_seconds": 55.04718812182546, - "log": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/logs/jdk25-mpnet-1m-4bit-ash-t1.log", - "csv": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk25-mpnet-1m-4bit-ash/logging/jdk25-mpnet-1m-4bit-ash-t1-20261029-025823Z/experiments.csv", - "rows": [ - { - "schema_version": "1", - "run_id": "jdk25-mpnet-1m-4bit-ash-t1-20261029-025823Z", - "run_uuid": "b16c09e5-6408-4442-b9ce-026bbc48c28a", - "system_id": "eafa45791612", - "dataset_name": "mpnet-1m", - "M": "32", - "efConstruction": "100", - "neighborOverflow": "1.2", - "addHierarchy": "true", - "refineFinalGraph": "true", - "feature_set": "[NVQ_VECTORS, FUSED_ASH]", - "build_compressor": "\"AsymmetricHashing[origDim=768, encodedBits=1576, bodyBits=1536, quantizedDim=384, bitsPerDimension=4, optimizer=ITQ, landmarks=1]\"", - "search_compressor": "None", - "usePruning": "false", - "topK": "10", - "overquery": "1.0", - "rerankK": "10", - "search.throughput.avg_qps": "9153.966952460185", - "search.throughput.stddev_qps": "317.24481664160703", - "search.throughput.cv_pct": "3.465653943139324", - "search.count.avg_visited": "432.9807", - "search.accuracy.recall_at_10": "0.86293", - "construction.index_build_time_s": "", - "construction.index_quant_time_s.ASH.compute_time_s": "", - "construction.index_quant_time_s.ASH.encoding_time_s": "", - "construction.index_quant_time_s.NVQ.compute_time_s": "" - }, - { - "schema_version": "1", - "run_id": "jdk25-mpnet-1m-4bit-ash-t1-20261029-025823Z", - "run_uuid": "b16c09e5-6408-4442-b9ce-026bbc48c28a", - "system_id": "eafa45791612", - "dataset_name": "mpnet-1m", - "M": "32", - "efConstruction": "100", - "neighborOverflow": "1.2", - "addHierarchy": "true", - "refineFinalGraph": "true", - "feature_set": "[NVQ_VECTORS, FUSED_ASH]", - "build_compressor": "\"AsymmetricHashing[origDim=768, encodedBits=1576, bodyBits=1536, quantizedDim=384, bitsPerDimension=4, optimizer=ITQ, landmarks=1]\"", - "search_compressor": "None", - "usePruning": "false", - "topK": "10", - "overquery": "2.0", - "rerankK": "20", - "search.throughput.avg_qps": "7114.7162102085695", - "search.throughput.stddev_qps": "46.99403949422588", - "search.throughput.cv_pct": "0.6605188191033727", - "search.count.avg_visited": "598.6739", - "search.accuracy.recall_at_10": "0.94033", - "construction.index_build_time_s": "", - "construction.index_quant_time_s.ASH.compute_time_s": "", - "construction.index_quant_time_s.ASH.encoding_time_s": "", - "construction.index_quant_time_s.NVQ.compute_time_s": "" - } - ], - "throughput_samples": [ - { - "sample": "Test-0", - "queries": 10000, - "seconds": 1.136732 - }, - { - "sample": "Test-1", - "queries": 10000, - "seconds": 1.063359 - }, - { - "sample": "Test-2", - "queries": 10000, - "seconds": 1.079845 - }, - { - "sample": "Test-0", - "queries": 10000, - "seconds": 1.416096 - }, - { - "sample": "Test-1", - "queries": 10000, - "seconds": 1.40226 - }, - { - "sample": "Test-2", - "queries": 10000, - "seconds": 1.39838 - } - ], - "finished": "2026-10-29T02:59:17.941972+00:00" - }, - "t8": { - "elapsed_process_seconds": 20.877501280047, - "log": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/logs/jdk25-mpnet-1m-4bit-ash-t8.log", - "csv": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk25-mpnet-1m-4bit-ash/logging/jdk25-mpnet-1m-4bit-ash-t8-20261029-025918Z/experiments.csv", - "rows": [ - { - "schema_version": "1", - "run_id": "jdk25-mpnet-1m-4bit-ash-t8-20261029-025918Z", - "run_uuid": "f1e80eeb-5e67-4b34-a4db-6c8230e45ca3", - "system_id": "eafa45791612", - "dataset_name": "mpnet-1m", - "M": "32", - "efConstruction": "100", - "neighborOverflow": "1.2", - "addHierarchy": "true", - "refineFinalGraph": "true", - "feature_set": "[NVQ_VECTORS, FUSED_ASH]", - "build_compressor": "\"AsymmetricHashing[origDim=768, encodedBits=1576, bodyBits=1536, quantizedDim=384, bitsPerDimension=4, optimizer=ITQ, landmarks=1]\"", - "search_compressor": "None", - "usePruning": "false", - "topK": "10", - "overquery": "1.0", - "rerankK": "10", - "search.throughput.avg_qps": "67390.32073511016", - "search.throughput.stddev_qps": "2298.9017978117477", - "search.throughput.cv_pct": "3.4113234255821347", - "search.count.avg_visited": "432.9807", - "search.accuracy.recall_at_10": "0.86293", - "construction.index_build_time_s": "", - "construction.index_quant_time_s.ASH.compute_time_s": "", - "construction.index_quant_time_s.ASH.encoding_time_s": "", - "construction.index_quant_time_s.NVQ.compute_time_s": "" - }, - { - "schema_version": "1", - "run_id": "jdk25-mpnet-1m-4bit-ash-t8-20261029-025918Z", - "run_uuid": "f1e80eeb-5e67-4b34-a4db-6c8230e45ca3", - "system_id": "eafa45791612", - "dataset_name": "mpnet-1m", - "M": "32", - "efConstruction": "100", - "neighborOverflow": "1.2", - "addHierarchy": "true", - "refineFinalGraph": "true", - "feature_set": "[NVQ_VECTORS, FUSED_ASH]", - "build_compressor": "\"AsymmetricHashing[origDim=768, encodedBits=1576, bodyBits=1536, quantizedDim=384, bitsPerDimension=4, optimizer=ITQ, landmarks=1]\"", - "search_compressor": "None", - "usePruning": "false", - "topK": "10", - "overquery": "2.0", - "rerankK": "20", - "search.throughput.avg_qps": "54357.67614973744", - "search.throughput.stddev_qps": "353.40026548673944", - "search.throughput.cv_pct": "0.6501386566144558", - "search.count.avg_visited": "598.6739", - "search.accuracy.recall_at_10": "0.94033", - "construction.index_build_time_s": "", - "construction.index_quant_time_s.ASH.compute_time_s": "", - "construction.index_quant_time_s.ASH.encoding_time_s": "", - "construction.index_quant_time_s.NVQ.compute_time_s": "" - } - ], - "throughput_samples": [ - { - "sample": "Test-0", - "queries": 70000, - "seconds": 1.080794 - }, - { - "sample": "Test-1", - "queries": 70000, - "seconds": 1.024154 - }, - { - "sample": "Test-2", - "queries": 70000, - "seconds": 1.01369 - }, - { - "sample": "Test-0", - "queries": 60000, - "seconds": 1.101287 - }, - { - "sample": "Test-1", - "queries": 60000, - "seconds": 1.111956 - }, - { - "sample": "Test-2", - "queries": 60000, - "seconds": 1.098251 - } - ], - "finished": "2026-10-29T02:59:38.820953+00:00" - } - }, - "index_files": [ - { - "path": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk25-mpnet-1m-4bit-ash/index_cache/graph_mpnet_1m_FUSED_ASH_NVQ_VECTORS_M32_ef100_of1.2_alpha1.2_H1_R1_AsymmetricHashing_origDim_768_encodedBits_1576_bodyBits_1536_quantizedDim_384_bitsPerDimension_4_optimizer_ITQ_landmarks_1_", - "bytes": 7279677824 - } - ], - "finished": "2026-10-29T02:59:38.821529+00:00" - }, - { - "dataset": "mpnet-1m", - "display_name": "MPNet", - "original_dimensions": "768", - "projected_ash_dimensions": "384", - "base_vectors": "999812", - "queries": "10000", - "ash_bits": "4", - "algorithm": "PQ", - "code_bytes": "197", - "ash_encoded_bits": "", - "pq_m": "197", - "config": "mpnet-1m-4bit-budget-pq.yml", - "jdk": "25", - "label": "jdk25-mpnet-1m-4bit-pq", - "path": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk25-mpnet-1m-4bit-pq", - "source": "507b74e7ef8a0e12fcfe402deb4bf758c65758d5", - "started": "2026-10-29T02:59:38.826426+00:00", - "phases": { - "build": { - "elapsed_process_seconds": 58.950520511716604, - "log": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/logs/jdk25-mpnet-1m-4bit-pq-build.log", - "csv": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk25-mpnet-1m-4bit-pq/logging/jdk25-mpnet-1m-4bit-pq-build-20261029-025939Z/experiments.csv", - "rows": [ - { - "schema_version": "1", - "run_id": "jdk25-mpnet-1m-4bit-pq-build-20261029-025939Z", - "run_uuid": "2f8c19c7-bbdb-4b8a-b0a2-e379ecbae4e1", - "system_id": "eafa45791612", - "dataset_name": "mpnet-1m", - "M": "32", - "efConstruction": "100", - "neighborOverflow": "1.2", - "addHierarchy": "true", - "refineFinalGraph": "true", - "feature_set": "[NVQ_VECTORS, FUSED_PQ]", - "build_compressor": "\"ProductQuantization(M=197, clusters=256, centered=false)\"", - "search_compressor": "None", - "usePruning": "false", - "topK": "10", - "overquery": "1.0", - "rerankK": "10", - "search.accuracy.recall_at_10": "0.80751", - "construction.index_build_time_s": "48.549881485", - "construction.index_quant_time_s.NVQ.compute_time_s": "0.330302452", - "construction.index_quant_time_s.PQ.compute_time_s": "4.652523221", - "construction.index_quant_time_s.PQ.encoding_time_s": "1.745370325" - } - ], - "throughput_samples": [], - "finished": "2026-10-29T03:00:37.779050+00:00" - }, - "t1": { - "elapsed_process_seconds": 41.79283030703664, - "log": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/logs/jdk25-mpnet-1m-4bit-pq-t1.log", - "csv": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk25-mpnet-1m-4bit-pq/logging/jdk25-mpnet-1m-4bit-pq-t1-20261029-030038Z/experiments.csv", - "rows": [ - { - "schema_version": "1", - "run_id": "jdk25-mpnet-1m-4bit-pq-t1-20261029-030038Z", - "run_uuid": "069c9933-6c55-490a-b733-2e228f02f85f", - "system_id": "eafa45791612", - "dataset_name": "mpnet-1m", - "M": "32", - "efConstruction": "100", - "neighborOverflow": "1.2", - "addHierarchy": "true", - "refineFinalGraph": "true", - "feature_set": "[NVQ_VECTORS, FUSED_PQ]", - "build_compressor": "\"ProductQuantization(M=197, clusters=256, centered=false)\"", - "search_compressor": "None", - "usePruning": "false", - "topK": "10", - "overquery": "1.0", - "rerankK": "10", - "search.throughput.avg_qps": "10001.00801293626", - "search.throughput.stddev_qps": "179.69312362625553", - "search.throughput.cv_pct": "1.7967501215259827", - "search.count.avg_visited": "438.5955", - "search.accuracy.recall_at_10": "0.80751", - "construction.index_build_time_s": "", - "construction.index_quant_time_s.NVQ.compute_time_s": "", - "construction.index_quant_time_s.PQ.compute_time_s": "", - "construction.index_quant_time_s.PQ.encoding_time_s": "" - }, - { - "schema_version": "1", - "run_id": "jdk25-mpnet-1m-4bit-pq-t1-20261029-030038Z", - "run_uuid": "069c9933-6c55-490a-b733-2e228f02f85f", - "system_id": "eafa45791612", - "dataset_name": "mpnet-1m", - "M": "32", - "efConstruction": "100", - "neighborOverflow": "1.2", - "addHierarchy": "true", - "refineFinalGraph": "true", - "feature_set": "[NVQ_VECTORS, FUSED_PQ]", - "build_compressor": "\"ProductQuantization(M=197, clusters=256, centered=false)\"", - "search_compressor": "None", - "usePruning": "false", - "topK": "10", - "overquery": "2.0", - "rerankK": "20", - "search.throughput.avg_qps": "7557.44798962551", - "search.throughput.stddev_qps": "14.656592861029829", - "search.throughput.cv_pct": "0.193935742345163", - "search.count.avg_visited": "602.5851", - "search.accuracy.recall_at_10": "0.92601", - "construction.index_build_time_s": "", - "construction.index_quant_time_s.NVQ.compute_time_s": "", - "construction.index_quant_time_s.PQ.compute_time_s": "", - "construction.index_quant_time_s.PQ.encoding_time_s": "" - } - ], - "throughput_samples": [ - { - "sample": "Test-0", - "queries": 10000, - "seconds": 1.02106 - }, - { - "sample": "Test-1", - "queries": 20000, - "seconds": 1.977637 - }, - { - "sample": "Test-2", - "queries": 20000, - "seconds": 1.980943 - }, - { - "sample": "Test-0", - "queries": 10000, - "seconds": 1.324208 - }, - { - "sample": "Test-1", - "queries": 10000, - "seconds": 1.320287 - }, - { - "sample": "Test-2", - "queries": 10000, - "seconds": 1.325109 - } - ], - "finished": "2026-10-29T03:01:19.573312+00:00" - }, - "t8": { - "elapsed_process_seconds": 18.641796117648482, - "log": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/logs/jdk25-mpnet-1m-4bit-pq-t8.log", - "csv": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk25-mpnet-1m-4bit-pq/logging/jdk25-mpnet-1m-4bit-pq-t8-20261029-030119Z/experiments.csv", - "rows": [ - { - "schema_version": "1", - "run_id": "jdk25-mpnet-1m-4bit-pq-t8-20261029-030119Z", - "run_uuid": "aa0ea80e-50b2-4ea9-8920-cd7aec47e45e", - "system_id": "eafa45791612", - "dataset_name": "mpnet-1m", - "M": "32", - "efConstruction": "100", - "neighborOverflow": "1.2", - "addHierarchy": "true", - "refineFinalGraph": "true", - "feature_set": "[NVQ_VECTORS, FUSED_PQ]", - "build_compressor": "\"ProductQuantization(M=197, clusters=256, centered=false)\"", - "search_compressor": "None", - "usePruning": "false", - "topK": "10", - "overquery": "1.0", - "rerankK": "10", - "search.throughput.avg_qps": "74886.84115917748", - "search.throughput.stddev_qps": "2099.6919388373026", - "search.throughput.cv_pct": "2.803819611477874", - "search.count.avg_visited": "438.5955", - "search.accuracy.recall_at_10": "0.80751", - "construction.index_build_time_s": "", - "construction.index_quant_time_s.NVQ.compute_time_s": "", - "construction.index_quant_time_s.PQ.compute_time_s": "", - "construction.index_quant_time_s.PQ.encoding_time_s": "" - }, - { - "schema_version": "1", - "run_id": "jdk25-mpnet-1m-4bit-pq-t8-20261029-030119Z", - "run_uuid": "aa0ea80e-50b2-4ea9-8920-cd7aec47e45e", - "system_id": "eafa45791612", - "dataset_name": "mpnet-1m", - "M": "32", - "efConstruction": "100", - "neighborOverflow": "1.2", - "addHierarchy": "true", - "refineFinalGraph": "true", - "feature_set": "[NVQ_VECTORS, FUSED_PQ]", - "build_compressor": "\"ProductQuantization(M=197, clusters=256, centered=false)\"", - "search_compressor": "None", - "usePruning": "false", - "topK": "10", - "overquery": "2.0", - "rerankK": "20", - "search.throughput.avg_qps": "56904.75254695084", - "search.throughput.stddev_qps": "1261.345571497758", - "search.throughput.cv_pct": "2.2165909085661166", - "search.count.avg_visited": "602.5851", - "search.accuracy.recall_at_10": "0.92601", - "construction.index_build_time_s": "", - "construction.index_quant_time_s.NVQ.compute_time_s": "", - "construction.index_quant_time_s.PQ.compute_time_s": "", - "construction.index_quant_time_s.PQ.encoding_time_s": "" - } - ], - "throughput_samples": [ - { - "sample": "Test-0", - "queries": 80000, - "seconds": 1.101604 - }, - { - "sample": "Test-1", - "queries": 80000, - "seconds": 1.062819 - }, - { - "sample": "Test-2", - "queries": 80000, - "seconds": 1.042107 - }, - { - "sample": "Test-0", - "queries": 60000, - "seconds": 1.028486 - }, - { - "sample": "Test-1", - "queries": 60000, - "seconds": 1.063612 - }, - { - "sample": "Test-2", - "queries": 60000, - "seconds": 1.072108 - } - ], - "finished": "2026-10-29T03:01:38.217164+00:00" - } - }, - "index_files": [ - { - "path": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk25-mpnet-1m-4bit-pq/index_cache/graph_mpnet_1m_FUSED_PQ_NVQ_VECTORS_M32_ef100_of1.2_alpha1.2_H1_R1_ProductQuantization_M_197_clusters_256_centered_false_", - "bytes": 7278883270 - } - ], - "finished": "2026-10-29T03:01:38.217741+00:00" - }, - { - "dataset": "cohere-english-v3-1M", - "display_name": "Cohere", - "original_dimensions": "1024", - "projected_ash_dimensions": "512", - "base_vectors": "1000000", - "queries": "10000", - "ash_bits": "2", - "algorithm": "ASH", - "code_bytes": "133", - "ash_encoded_bits": "1064", - "pq_m": "", - "config": "cohere-english-v3-1M-2bit-budget-ash.yml", - "jdk": "25", - "label": "jdk25-cohere-english-v3-1M-2bit-ash", - "path": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk25-cohere-english-v3-1M-2bit-ash", - "source": "507b74e7ef8a0e12fcfe402deb4bf758c65758d5", - "started": "2026-10-29T03:01:38.223459+00:00", - "phases": { - "build": { - "elapsed_process_seconds": 52.83737094514072, - "log": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/logs/jdk25-cohere-english-v3-1M-2bit-ash-build.log", - "csv": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk25-cohere-english-v3-1M-2bit-ash/logging/jdk25-cohere-english-v3-1M-2bit-ash-build-20261029-030138Z/experiments.csv", - "rows": [ - { - "schema_version": "1", - "run_id": "jdk25-cohere-english-v3-1M-2bit-ash-build-20261029-030138Z", - "run_uuid": "d6db0329-4529-4300-bf72-f73647a74dd1", - "system_id": "eafa45791612", - "dataset_name": "cohere-english-v3-1M", - "M": "32", - "efConstruction": "100", - "neighborOverflow": "1.2", - "addHierarchy": "true", - "refineFinalGraph": "true", - "feature_set": "[NVQ_VECTORS, FUSED_ASH]", - "build_compressor": "\"AsymmetricHashing[origDim=1024, encodedBits=1064, bodyBits=1024, quantizedDim=512, bitsPerDimension=2, optimizer=ITQ, landmarks=1]\"", - "search_compressor": "None", - "usePruning": "false", - "topK": "10", - "overquery": "1.0", - "rerankK": "10", - "search.accuracy.recall_at_10": "0.63975", - "construction.index_build_time_s": "32.965712237", - "construction.index_quant_time_s.ASH.compute_time_s": "7.498550613", - "construction.index_quant_time_s.ASH.encoding_time_s": "1.643006753", - "construction.index_quant_time_s.NVQ.compute_time_s": "0.49575556" - } - ], - "throughput_samples": [], - "finished": "2026-10-29T03:02:31.062686+00:00" - }, - "t1": { - "elapsed_process_seconds": 57.36122320685536, - "log": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/logs/jdk25-cohere-english-v3-1M-2bit-ash-t1.log", - "csv": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk25-cohere-english-v3-1M-2bit-ash/logging/jdk25-cohere-english-v3-1M-2bit-ash-t1-20261029-030232Z/experiments.csv", - "rows": [ - { - "schema_version": "1", - "run_id": "jdk25-cohere-english-v3-1M-2bit-ash-t1-20261029-030232Z", - "run_uuid": "5d079417-a777-4bb9-ad82-62b0b12ff122", - "system_id": "eafa45791612", - "dataset_name": "cohere-english-v3-1M", - "M": "32", - "efConstruction": "100", - "neighborOverflow": "1.2", - "addHierarchy": "true", - "refineFinalGraph": "true", - "feature_set": "[NVQ_VECTORS, FUSED_ASH]", - "build_compressor": "\"AsymmetricHashing[origDim=1024, encodedBits=1064, bodyBits=1024, quantizedDim=512, bitsPerDimension=2, optimizer=ITQ, landmarks=1]\"", - "search_compressor": "None", - "usePruning": "false", - "topK": "10", - "overquery": "1.0", - "rerankK": "10", - "search.throughput.avg_qps": "6427.095638763094", - "search.throughput.stddev_qps": "169.83121886243185", - "search.throughput.cv_pct": "2.642425574596182", - "search.count.avg_visited": "565.5602", - "search.accuracy.recall_at_10": "0.63975", - "construction.index_build_time_s": "", - "construction.index_quant_time_s.ASH.compute_time_s": "", - "construction.index_quant_time_s.ASH.encoding_time_s": "", - "construction.index_quant_time_s.NVQ.compute_time_s": "" - }, - { - "schema_version": "1", - "run_id": "jdk25-cohere-english-v3-1M-2bit-ash-t1-20261029-030232Z", - "run_uuid": "5d079417-a777-4bb9-ad82-62b0b12ff122", - "system_id": "eafa45791612", - "dataset_name": "cohere-english-v3-1M", - "M": "32", - "efConstruction": "100", - "neighborOverflow": "1.2", - "addHierarchy": "true", - "refineFinalGraph": "true", - "feature_set": "[NVQ_VECTORS, FUSED_ASH]", - "build_compressor": "\"AsymmetricHashing[origDim=1024, encodedBits=1064, bodyBits=1024, quantizedDim=512, bitsPerDimension=2, optimizer=ITQ, landmarks=1]\"", - "search_compressor": "None", - "usePruning": "false", - "topK": "10", - "overquery": "2.0", - "rerankK": "20", - "search.throughput.avg_qps": "5258.107673080131", - "search.throughput.stddev_qps": "63.42893861825412", - "search.throughput.cv_pct": "1.2063073364395043", - "search.count.avg_visited": "811.3677", - "search.accuracy.recall_at_10": "0.82327", - "construction.index_build_time_s": "", - "construction.index_quant_time_s.ASH.compute_time_s": "", - "construction.index_quant_time_s.ASH.encoding_time_s": "", - "construction.index_quant_time_s.NVQ.compute_time_s": "" - } - ], - "throughput_samples": [ - { - "sample": "Test-0", - "queries": 10000, - "seconds": 1.603609 - }, - { - "sample": "Test-1", - "queries": 10000, - "seconds": 1.524258 - }, - { - "sample": "Test-2", - "queries": 10000, - "seconds": 1.542072 - }, - { - "sample": "Test-0", - "queries": 10000, - "seconds": 1.926879 - }, - { - "sample": "Test-1", - "queries": 10000, - "seconds": 1.897699 - }, - { - "sample": "Test-2", - "queries": 10000, - "seconds": 1.881452 - } - ], - "finished": "2026-10-29T03:03:28.425166+00:00" - }, - "t8": { - "elapsed_process_seconds": 22.270215313881636, - "log": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/logs/jdk25-cohere-english-v3-1M-2bit-ash-t8.log", - "csv": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk25-cohere-english-v3-1M-2bit-ash/logging/jdk25-cohere-english-v3-1M-2bit-ash-t8-20261029-030328Z/experiments.csv", - "rows": [ - { - "schema_version": "1", - "run_id": "jdk25-cohere-english-v3-1M-2bit-ash-t8-20261029-030328Z", - "run_uuid": "78e66c7b-0f61-4779-be5a-cbdc69a39952", - "system_id": "eafa45791612", - "dataset_name": "cohere-english-v3-1M", - "M": "32", - "efConstruction": "100", - "neighborOverflow": "1.2", - "addHierarchy": "true", - "refineFinalGraph": "true", - "feature_set": "[NVQ_VECTORS, FUSED_ASH]", - "build_compressor": "\"AsymmetricHashing[origDim=1024, encodedBits=1064, bodyBits=1024, quantizedDim=512, bitsPerDimension=2, optimizer=ITQ, landmarks=1]\"", - "search_compressor": "None", - "usePruning": "false", - "topK": "10", - "overquery": "1.0", - "rerankK": "10", - "search.throughput.avg_qps": "50148.40516839785", - "search.throughput.stddev_qps": "824.0291437743359", - "search.throughput.cv_pct": "1.6431811560253098", - "search.count.avg_visited": "565.5602", - "search.accuracy.recall_at_10": "0.63975", - "construction.index_build_time_s": "", - "construction.index_quant_time_s.ASH.compute_time_s": "", - "construction.index_quant_time_s.ASH.encoding_time_s": "", - "construction.index_quant_time_s.NVQ.compute_time_s": "" - }, - { - "schema_version": "1", - "run_id": "jdk25-cohere-english-v3-1M-2bit-ash-t8-20261029-030328Z", - "run_uuid": "78e66c7b-0f61-4779-be5a-cbdc69a39952", - "system_id": "eafa45791612", - "dataset_name": "cohere-english-v3-1M", - "M": "32", - "efConstruction": "100", - "neighborOverflow": "1.2", - "addHierarchy": "true", - "refineFinalGraph": "true", - "feature_set": "[NVQ_VECTORS, FUSED_ASH]", - "build_compressor": "\"AsymmetricHashing[origDim=1024, encodedBits=1064, bodyBits=1024, quantizedDim=512, bitsPerDimension=2, optimizer=ITQ, landmarks=1]\"", - "search_compressor": "None", - "usePruning": "false", - "topK": "10", - "overquery": "2.0", - "rerankK": "20", - "search.throughput.avg_qps": "41901.690380100095", - "search.throughput.stddev_qps": "454.39922005212", - "search.throughput.cv_pct": "1.084441262226315", - "search.count.avg_visited": "811.3677", - "search.accuracy.recall_at_10": "0.82327", - "construction.index_build_time_s": "", - "construction.index_quant_time_s.ASH.compute_time_s": "", - "construction.index_quant_time_s.ASH.encoding_time_s": "", - "construction.index_quant_time_s.NVQ.compute_time_s": "" - } - ], - "throughput_samples": [ - { - "sample": "Test-0", - "queries": 60000, - "seconds": 1.176934 - }, - { - "sample": "Test-1", - "queries": 50000, - "seconds": 1.013539 - }, - { - "sample": "Test-2", - "queries": 60000, - "seconds": 1.196812 - }, - { - "sample": "Test-0", - "queries": 50000, - "seconds": 1.185296 - }, - { - "sample": "Test-1", - "queries": 50000, - "seconds": 1.186408 - }, - { - "sample": "Test-2", - "queries": 50000, - "seconds": 1.208386 - } - ], - "finished": "2026-10-29T03:03:50.696834+00:00" - } - }, - "index_files": [ - { - "path": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk25-cohere-english-v3-1M-2bit-ash/index_cache/graph_cohere_english_v3_1M_FUSED_ASH_NVQ_VECTORS_M32_ef100_of1.2_alpha1.2_H1_R1_AsymmetricHashing_origDim_1024_encodedBits_1064_bodyBits_1024_quantizedDim_512_bitsPerDimension_2_optimizer_ITQ_landmarks_1_", - "bytes": 5488878816 - } - ], - "finished": "2026-10-29T03:03:50.697499+00:00" - }, - { - "dataset": "cohere-english-v3-1M", - "display_name": "Cohere", - "original_dimensions": "1024", - "projected_ash_dimensions": "512", - "base_vectors": "1000000", - "queries": "10000", - "ash_bits": "2", - "algorithm": "PQ", - "code_bytes": "133", - "ash_encoded_bits": "", - "pq_m": "133", - "config": "cohere-english-v3-1M-2bit-budget-pq.yml", - "jdk": "25", - "label": "jdk25-cohere-english-v3-1M-2bit-pq", - "path": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk25-cohere-english-v3-1M-2bit-pq", - "source": "507b74e7ef8a0e12fcfe402deb4bf758c65758d5", - "started": "2026-10-29T03:03:50.702501+00:00", - "phases": { - "build": { - "elapsed_process_seconds": 49.729727069847286, - "log": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/logs/jdk25-cohere-english-v3-1M-2bit-pq-build.log", - "csv": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk25-cohere-english-v3-1M-2bit-pq/logging/jdk25-cohere-english-v3-1M-2bit-pq-build-20261029-030351Z/experiments.csv", - "rows": [ - { - "schema_version": "1", - "run_id": "jdk25-cohere-english-v3-1M-2bit-pq-build-20261029-030351Z", - "run_uuid": "5fd555a2-5cfc-43a3-8c66-60186cf33a31", - "system_id": "eafa45791612", - "dataset_name": "cohere-english-v3-1M", - "M": "32", - "efConstruction": "100", - "neighborOverflow": "1.2", - "addHierarchy": "true", - "refineFinalGraph": "true", - "feature_set": "[NVQ_VECTORS, FUSED_PQ]", - "build_compressor": "\"ProductQuantization(M=133, clusters=256, centered=false)\"", - "search_compressor": "None", - "usePruning": "false", - "topK": "10", - "overquery": "1.0", - "rerankK": "10", - "search.accuracy.recall_at_10": "0.5913", - "construction.index_build_time_s": "40.656450049", - "construction.index_quant_time_s.NVQ.compute_time_s": "0.323431596", - "construction.index_quant_time_s.PQ.compute_time_s": "3.084744323", - "construction.index_quant_time_s.PQ.encoding_time_s": "1.32553116" - } - ], - "throughput_samples": [], - "finished": "2026-10-29T03:04:40.434152+00:00" - }, - "t1": { - "elapsed_process_seconds": 42.22823287732899, - "log": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/logs/jdk25-cohere-english-v3-1M-2bit-pq-t1.log", - "csv": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk25-cohere-english-v3-1M-2bit-pq/logging/jdk25-cohere-english-v3-1M-2bit-pq-t1-20261029-030441Z/experiments.csv", - "rows": [ - { - "schema_version": "1", - "run_id": "jdk25-cohere-english-v3-1M-2bit-pq-t1-20261029-030441Z", - "run_uuid": "2059abac-f3d9-4553-a989-d85ba4be1a02", - "system_id": "eafa45791612", - "dataset_name": "cohere-english-v3-1M", - "M": "32", - "efConstruction": "100", - "neighborOverflow": "1.2", - "addHierarchy": "true", - "refineFinalGraph": "true", - "feature_set": "[NVQ_VECTORS, FUSED_PQ]", - "build_compressor": "\"ProductQuantization(M=133, clusters=256, centered=false)\"", - "search_compressor": "None", - "usePruning": "false", - "topK": "10", - "overquery": "1.0", - "rerankK": "10", - "search.throughput.avg_qps": "9412.081288856038", - "search.throughput.stddev_qps": "112.56406605696156", - "search.throughput.cv_pct": "1.195952973655658", - "search.count.avg_visited": "515.0213", - "search.accuracy.recall_at_10": "0.5913", - "construction.index_build_time_s": "", - "construction.index_quant_time_s.NVQ.compute_time_s": "", - "construction.index_quant_time_s.PQ.compute_time_s": "", - "construction.index_quant_time_s.PQ.encoding_time_s": "" - }, - { - "schema_version": "1", - "run_id": "jdk25-cohere-english-v3-1M-2bit-pq-t1-20261029-030441Z", - "run_uuid": "2059abac-f3d9-4553-a989-d85ba4be1a02", - "system_id": "eafa45791612", - "dataset_name": "cohere-english-v3-1M", - "M": "32", - "efConstruction": "100", - "neighborOverflow": "1.2", - "addHierarchy": "true", - "refineFinalGraph": "true", - "feature_set": "[NVQ_VECTORS, FUSED_PQ]", - "build_compressor": "\"ProductQuantization(M=133, clusters=256, centered=false)\"", - "search_compressor": "None", - "usePruning": "false", - "topK": "10", - "overquery": "2.0", - "rerankK": "20", - "search.throughput.avg_qps": "7190.940014143866", - "search.throughput.stddev_qps": "167.83733226091644", - "search.throughput.cv_pct": "2.334011018459298", - "search.count.avg_visited": "722.8076", - "search.accuracy.recall_at_10": "0.7845", - "construction.index_build_time_s": "", - "construction.index_quant_time_s.NVQ.compute_time_s": "", - "construction.index_quant_time_s.PQ.compute_time_s": "", - "construction.index_quant_time_s.PQ.encoding_time_s": "" - } - ], - "throughput_samples": [ - { - "sample": "Test-0", - "queries": 10000, - "seconds": 1.077253 - }, - { - "sample": "Test-1", - "queries": 10000, - "seconds": 1.056583 - }, - { - "sample": "Test-2", - "queries": 10000, - "seconds": 1.053863 - }, - { - "sample": "Test-0", - "queries": 10000, - "seconds": 1.429143 - }, - { - "sample": "Test-1", - "queries": 10000, - "seconds": 1.37134 - }, - { - "sample": "Test-2", - "queries": 10000, - "seconds": 1.37297 - } - ], - "finished": "2026-10-29T03:05:22.663760+00:00" - }, - "t8": { - "elapsed_process_seconds": 18.73960914835334, - "log": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/logs/jdk25-cohere-english-v3-1M-2bit-pq-t8.log", - "csv": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk25-cohere-english-v3-1M-2bit-pq/logging/jdk25-cohere-english-v3-1M-2bit-pq-t8-20261029-030523Z/experiments.csv", - "rows": [ - { - "schema_version": "1", - "run_id": "jdk25-cohere-english-v3-1M-2bit-pq-t8-20261029-030523Z", - "run_uuid": "4696403e-eaa3-4cfc-9fc2-3da914ca060f", - "system_id": "eafa45791612", - "dataset_name": "cohere-english-v3-1M", - "M": "32", - "efConstruction": "100", - "neighborOverflow": "1.2", - "addHierarchy": "true", - "refineFinalGraph": "true", - "feature_set": "[NVQ_VECTORS, FUSED_PQ]", - "build_compressor": "\"ProductQuantization(M=133, clusters=256, centered=false)\"", - "search_compressor": "None", - "usePruning": "false", - "topK": "10", - "overquery": "1.0", - "rerankK": "10", - "search.throughput.avg_qps": "75637.85852751213", - "search.throughput.stddev_qps": "1813.227802143092", - "search.throughput.cv_pct": "2.3972489933510714", - "search.count.avg_visited": "515.0213", - "search.accuracy.recall_at_10": "0.5913", - "construction.index_build_time_s": "", - "construction.index_quant_time_s.NVQ.compute_time_s": "", - "construction.index_quant_time_s.PQ.compute_time_s": "", - "construction.index_quant_time_s.PQ.encoding_time_s": "" - }, - { - "schema_version": "1", - "run_id": "jdk25-cohere-english-v3-1M-2bit-pq-t8-20261029-030523Z", - "run_uuid": "4696403e-eaa3-4cfc-9fc2-3da914ca060f", - "system_id": "eafa45791612", - "dataset_name": "cohere-english-v3-1M", - "M": "32", - "efConstruction": "100", - "neighborOverflow": "1.2", - "addHierarchy": "true", - "refineFinalGraph": "true", - "feature_set": "[NVQ_VECTORS, FUSED_PQ]", - "build_compressor": "\"ProductQuantization(M=133, clusters=256, centered=false)\"", - "search_compressor": "None", - "usePruning": "false", - "topK": "10", - "overquery": "2.0", - "rerankK": "20", - "search.throughput.avg_qps": "57862.554944797805", - "search.throughput.stddev_qps": "522.6740498873122", - "search.throughput.cv_pct": "0.903302749741962", - "search.count.avg_visited": "722.8076", - "search.accuracy.recall_at_10": "0.7845", - "construction.index_build_time_s": "", - "construction.index_quant_time_s.NVQ.compute_time_s": "", - "construction.index_quant_time_s.PQ.compute_time_s": "", - "construction.index_quant_time_s.PQ.encoding_time_s": "" - } - ], - "throughput_samples": [ - { - "sample": "Test-0", - "queries": 80000, - "seconds": 1.086572 - }, - { - "sample": "Test-1", - "queries": 80000, - "seconds": 1.050674 - }, - { - "sample": "Test-2", - "queries": 80000, - "seconds": 1.036995 - }, - { - "sample": "Test-0", - "queries": 60000, - "seconds": 1.026278 - }, - { - "sample": "Test-1", - "queries": 60000, - "seconds": 1.041512 - }, - { - "sample": "Test-2", - "queries": 60000, - "seconds": 1.043198 - } - ], - "finished": "2026-10-29T03:05:41.404912+00:00" - } - }, - "index_files": [ - { - "path": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk25-cohere-english-v3-1M-2bit-pq/index_cache/graph_cohere_english_v3_1M_FUSED_PQ_NVQ_VECTORS_M32_ef100_of1.2_alpha1.2_H1_R1_ProductQuantization_M_133_clusters_256_centered_false_", - "bytes": 5486780219 - } - ], - "finished": "2026-10-29T03:05:41.405488+00:00" - }, - { - "dataset": "cohere-english-v3-1M", - "display_name": "Cohere", - "original_dimensions": "1024", - "projected_ash_dimensions": "512", - "base_vectors": "1000000", - "queries": "10000", - "ash_bits": "4", - "algorithm": "ASH", - "code_bytes": "261", - "ash_encoded_bits": "2088", - "pq_m": "", - "config": "cohere-english-v3-1M-4bit-budget-ash.yml", - "jdk": "25", - "label": "jdk25-cohere-english-v3-1M-4bit-ash", - "path": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk25-cohere-english-v3-1M-4bit-ash", - "source": "507b74e7ef8a0e12fcfe402deb4bf758c65758d5", - "started": "2026-10-29T03:05:41.411358+00:00", - "phases": { - "build": { - "elapsed_process_seconds": 62.538319007493556, - "log": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/logs/jdk25-cohere-english-v3-1M-4bit-ash-build.log", - "csv": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk25-cohere-english-v3-1M-4bit-ash/logging/jdk25-cohere-english-v3-1M-4bit-ash-build-20261029-030541Z/experiments.csv", - "rows": [ - { - "schema_version": "1", - "run_id": "jdk25-cohere-english-v3-1M-4bit-ash-build-20261029-030541Z", - "run_uuid": "ce09b98d-7b58-462c-82b9-55f053424f48", - "system_id": "eafa45791612", - "dataset_name": "cohere-english-v3-1M", - "M": "32", - "efConstruction": "100", - "neighborOverflow": "1.2", - "addHierarchy": "true", - "refineFinalGraph": "true", - "feature_set": "[NVQ_VECTORS, FUSED_ASH]", - "build_compressor": "\"AsymmetricHashing[origDim=1024, encodedBits=2088, bodyBits=2048, quantizedDim=512, bitsPerDimension=4, optimizer=ITQ, landmarks=1]\"", - "search_compressor": "None", - "usePruning": "false", - "topK": "10", - "overquery": "1.0", - "rerankK": "10", - "search.accuracy.recall_at_10": "0.7059", - "construction.index_build_time_s": "46.251607145", - "construction.index_quant_time_s.ASH.compute_time_s": "6.455142236", - "construction.index_quant_time_s.ASH.encoding_time_s": "2.062987324", - "construction.index_quant_time_s.NVQ.compute_time_s": "0.722485568" - } - ], - "throughput_samples": [], - "finished": "2026-10-29T03:06:43.952173+00:00" - }, - "t1": { - "elapsed_process_seconds": 64.48913117777556, - "log": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/logs/jdk25-cohere-english-v3-1M-4bit-ash-t1.log", - "csv": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk25-cohere-english-v3-1M-4bit-ash/logging/jdk25-cohere-english-v3-1M-4bit-ash-t1-20261029-030645Z/experiments.csv", - "rows": [ - { - "schema_version": "1", - "run_id": "jdk25-cohere-english-v3-1M-4bit-ash-t1-20261029-030645Z", - "run_uuid": "26d24777-8d6e-4c1e-919f-02fe017c9527", - "system_id": "eafa45791612", - "dataset_name": "cohere-english-v3-1M", - "M": "32", - "efConstruction": "100", - "neighborOverflow": "1.2", - "addHierarchy": "true", - "refineFinalGraph": "true", - "feature_set": "[NVQ_VECTORS, FUSED_ASH]", - "build_compressor": "\"AsymmetricHashing[origDim=1024, encodedBits=2088, bodyBits=2048, quantizedDim=512, bitsPerDimension=4, optimizer=ITQ, landmarks=1]\"", - "search_compressor": "None", - "usePruning": "false", - "topK": "10", - "overquery": "1.0", - "rerankK": "10", - "search.throughput.avg_qps": "5662.326694410597", - "search.throughput.stddev_qps": "193.66647724429077", - "search.throughput.cv_pct": "3.420263218571671", - "search.count.avg_visited": "539.3398", - "search.accuracy.recall_at_10": "0.7059", - "construction.index_build_time_s": "", - "construction.index_quant_time_s.ASH.compute_time_s": "", - "construction.index_quant_time_s.ASH.encoding_time_s": "", - "construction.index_quant_time_s.NVQ.compute_time_s": "" - }, - { - "schema_version": "1", - "run_id": "jdk25-cohere-english-v3-1M-4bit-ash-t1-20261029-030645Z", - "run_uuid": "26d24777-8d6e-4c1e-919f-02fe017c9527", - "system_id": "eafa45791612", - "dataset_name": "cohere-english-v3-1M", - "M": "32", - "efConstruction": "100", - "neighborOverflow": "1.2", - "addHierarchy": "true", - "refineFinalGraph": "true", - "feature_set": "[NVQ_VECTORS, FUSED_ASH]", - "build_compressor": "\"AsymmetricHashing[origDim=1024, encodedBits=2088, bodyBits=2048, quantizedDim=512, bitsPerDimension=4, optimizer=ITQ, landmarks=1]\"", - "search_compressor": "None", - "usePruning": "false", - "topK": "10", - "overquery": "2.0", - "rerankK": "20", - "search.throughput.avg_qps": "4635.7196414662285", - "search.throughput.stddev_qps": "61.53701677204011", - "search.throughput.cv_pct": "1.3274533736163694", - "search.count.avg_visited": "770.3046", - "search.accuracy.recall_at_10": "0.83696", - "construction.index_build_time_s": "", - "construction.index_quant_time_s.ASH.compute_time_s": "", - "construction.index_quant_time_s.ASH.encoding_time_s": "", - "construction.index_quant_time_s.NVQ.compute_time_s": "" - } - ], - "throughput_samples": [ - { - "sample": "Test-0", - "queries": 10000, - "seconds": 1.836672 - }, - { - "sample": "Test-1", - "queries": 10000, - "seconds": 1.746156 - }, - { - "sample": "Test-2", - "queries": 10000, - "seconds": 1.719548 - }, - { - "sample": "Test-0", - "queries": 10000, - "seconds": 2.190733 - }, - { - "sample": "Test-1", - "queries": 10000, - "seconds": 2.14009 - }, - { - "sample": "Test-2", - "queries": 10000, - "seconds": 2.141431 - } - ], - "finished": "2026-10-29T03:07:48.442803+00:00" - }, - "t8": { - "elapsed_process_seconds": 23.704103889875114, - "log": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/logs/jdk25-cohere-english-v3-1M-4bit-ash-t8.log", - "csv": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk25-cohere-english-v3-1M-4bit-ash/logging/jdk25-cohere-english-v3-1M-4bit-ash-t8-20261029-030748Z/experiments.csv", - "rows": [ - { - "schema_version": "1", - "run_id": "jdk25-cohere-english-v3-1M-4bit-ash-t8-20261029-030748Z", - "run_uuid": "32df842c-39cf-4d88-8329-8207f11f96f3", - "system_id": "eafa45791612", - "dataset_name": "cohere-english-v3-1M", - "M": "32", - "efConstruction": "100", - "neighborOverflow": "1.2", - "addHierarchy": "true", - "refineFinalGraph": "true", - "feature_set": "[NVQ_VECTORS, FUSED_ASH]", - "build_compressor": "\"AsymmetricHashing[origDim=1024, encodedBits=2088, bodyBits=2048, quantizedDim=512, bitsPerDimension=4, optimizer=ITQ, landmarks=1]\"", - "search_compressor": "None", - "usePruning": "false", - "topK": "10", - "overquery": "1.0", - "rerankK": "10", - "search.throughput.avg_qps": "42689.45179035281", - "search.throughput.stddev_qps": "153.1711013850193", - "search.throughput.cv_pct": "0.3588031585349", - "search.count.avg_visited": "539.3398", - "search.accuracy.recall_at_10": "0.7059", - "construction.index_build_time_s": "", - "construction.index_quant_time_s.ASH.compute_time_s": "", - "construction.index_quant_time_s.ASH.encoding_time_s": "", - "construction.index_quant_time_s.NVQ.compute_time_s": "" - }, - { - "schema_version": "1", - "run_id": "jdk25-cohere-english-v3-1M-4bit-ash-t8-20261029-030748Z", - "run_uuid": "32df842c-39cf-4d88-8329-8207f11f96f3", - "system_id": "eafa45791612", - "dataset_name": "cohere-english-v3-1M", - "M": "32", - "efConstruction": "100", - "neighborOverflow": "1.2", - "addHierarchy": "true", - "refineFinalGraph": "true", - "feature_set": "[NVQ_VECTORS, FUSED_ASH]", - "build_compressor": "\"AsymmetricHashing[origDim=1024, encodedBits=2088, bodyBits=2048, quantizedDim=512, bitsPerDimension=4, optimizer=ITQ, landmarks=1]\"", - "search_compressor": "None", - "usePruning": "false", - "topK": "10", - "overquery": "2.0", - "rerankK": "20", - "search.throughput.avg_qps": "35644.71872678161", - "search.throughput.stddev_qps": "417.585373344066", - "search.throughput.cv_pct": "1.171521022636977", - "search.count.avg_visited": "770.3046", - "search.accuracy.recall_at_10": "0.83696", - "construction.index_build_time_s": "", - "construction.index_quant_time_s.ASH.compute_time_s": "", - "construction.index_quant_time_s.ASH.encoding_time_s": "", - "construction.index_quant_time_s.NVQ.compute_time_s": "" - } - ], - "throughput_samples": [ - { - "sample": "Test-0", - "queries": 50000, - "seconds": 1.16983 - }, - { - "sample": "Test-1", - "queries": 50000, - "seconds": 1.175998 - }, - { - "sample": "Test-2", - "queries": 50000, - "seconds": 1.167951 - }, - { - "sample": "Test-0", - "queries": 40000, - "seconds": 1.108802 - }, - { - "sample": "Test-1", - "queries": 40000, - "seconds": 1.13504 - }, - { - "sample": "Test-2", - "queries": 40000, - "seconds": 1.123024 - } - ], - "finished": "2026-10-29T03:08:12.148410+00:00" - } - }, - "index_files": [ - { - "path": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk25-cohere-english-v3-1M-4bit-ash/index_cache/graph_cohere_english_v3_1M_FUSED_ASH_NVQ_VECTORS_M32_ef100_of1.2_alpha1.2_H1_R1_AsymmetricHashing_origDim_1024_encodedBits_2088_bodyBits_2048_quantizedDim_512_bitsPerDimension_4_optimizer_ITQ_landmarks_1_", - "bytes": 9588881034 - } - ], - "finished": "2026-10-29T03:08:12.148990+00:00" - }, - { - "dataset": "cohere-english-v3-1M", - "display_name": "Cohere", - "original_dimensions": "1024", - "projected_ash_dimensions": "512", - "base_vectors": "1000000", - "queries": "10000", - "ash_bits": "4", - "algorithm": "PQ", - "code_bytes": "261", - "ash_encoded_bits": "", - "pq_m": "261", - "config": "cohere-english-v3-1M-4bit-budget-pq.yml", - "jdk": "25", - "label": "jdk25-cohere-english-v3-1M-4bit-pq", - "path": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk25-cohere-english-v3-1M-4bit-pq", - "source": "507b74e7ef8a0e12fcfe402deb4bf758c65758d5", - "started": "2026-10-29T03:08:12.154688+00:00", - "phases": { - "build": { - "elapsed_process_seconds": 80.15861915424466, - "log": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/logs/jdk25-cohere-english-v3-1M-4bit-pq-build.log", - "csv": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk25-cohere-english-v3-1M-4bit-pq/logging/jdk25-cohere-english-v3-1M-4bit-pq-build-20261029-030812Z/experiments.csv", - "rows": [ - { - "schema_version": "1", - "run_id": "jdk25-cohere-english-v3-1M-4bit-pq-build-20261029-030812Z", - "run_uuid": "4dd6d1b4-c191-4a9a-83d2-b103d9a10dee", - "system_id": "eafa45791612", - "dataset_name": "cohere-english-v3-1M", - "M": "32", - "efConstruction": "100", - "neighborOverflow": "1.2", - "addHierarchy": "true", - "refineFinalGraph": "true", - "feature_set": "[NVQ_VECTORS, FUSED_PQ]", - "build_compressor": "\"ProductQuantization(M=261, clusters=256, centered=false)\"", - "search_compressor": "None", - "usePruning": "false", - "topK": "10", - "overquery": "1.0", - "rerankK": "10", - "search.accuracy.recall_at_10": "0.6553", - "construction.index_build_time_s": "66.54631899", - "construction.index_quant_time_s.NVQ.compute_time_s": "0.338206073", - "construction.index_quant_time_s.PQ.compute_time_s": "6.571955191", - "construction.index_quant_time_s.PQ.encoding_time_s": "2.233528007" - } - ], - "throughput_samples": [], - "finished": "2026-10-29T03:09:32.315540+00:00" - }, - "t1": { - "elapsed_process_seconds": 48.29118027724326, - "log": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/logs/jdk25-cohere-english-v3-1M-4bit-pq-t1.log", - "csv": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk25-cohere-english-v3-1M-4bit-pq/logging/jdk25-cohere-english-v3-1M-4bit-pq-t1-20261029-030933Z/experiments.csv", - "rows": [ - { - "schema_version": "1", - "run_id": "jdk25-cohere-english-v3-1M-4bit-pq-t1-20261029-030933Z", - "run_uuid": "c312af43-5328-4a55-a89d-f9bb90556565", - "system_id": "eafa45791612", - "dataset_name": "cohere-english-v3-1M", - "M": "32", - "efConstruction": "100", - "neighborOverflow": "1.2", - "addHierarchy": "true", - "refineFinalGraph": "true", - "feature_set": "[NVQ_VECTORS, FUSED_PQ]", - "build_compressor": "\"ProductQuantization(M=261, clusters=256, centered=false)\"", - "search_compressor": "None", - "usePruning": "false", - "topK": "10", - "overquery": "1.0", - "rerankK": "10", - "search.throughput.avg_qps": "7857.8449638838065", - "search.throughput.stddev_qps": "139.06736295154764", - "search.throughput.cv_pct": "1.7697901090022579", - "search.count.avg_visited": "505.9876", - "search.accuracy.recall_at_10": "0.6553", - "construction.index_build_time_s": "", - "construction.index_quant_time_s.NVQ.compute_time_s": "", - "construction.index_quant_time_s.PQ.compute_time_s": "", - "construction.index_quant_time_s.PQ.encoding_time_s": "" - }, - { - "schema_version": "1", - "run_id": "jdk25-cohere-english-v3-1M-4bit-pq-t1-20261029-030933Z", - "run_uuid": "c312af43-5328-4a55-a89d-f9bb90556565", - "system_id": "eafa45791612", - "dataset_name": "cohere-english-v3-1M", - "M": "32", - "efConstruction": "100", - "neighborOverflow": "1.2", - "addHierarchy": "true", - "refineFinalGraph": "true", - "feature_set": "[NVQ_VECTORS, FUSED_PQ]", - "build_compressor": "\"ProductQuantization(M=261, clusters=256, centered=false)\"", - "search_compressor": "None", - "usePruning": "false", - "topK": "10", - "overquery": "2.0", - "rerankK": "20", - "search.throughput.avg_qps": "5920.816567354704", - "search.throughput.stddev_qps": "28.88024786803325", - "search.throughput.cv_pct": "0.4877747442349213", - "search.count.avg_visited": "710.3709", - "search.accuracy.recall_at_10": "0.81365", - "construction.index_build_time_s": "", - "construction.index_quant_time_s.NVQ.compute_time_s": "", - "construction.index_quant_time_s.PQ.compute_time_s": "", - "construction.index_quant_time_s.PQ.encoding_time_s": "" - } - ], - "throughput_samples": [ - { - "sample": "Test-0", - "queries": 10000, - "seconds": 1.285561 - }, - { - "sample": "Test-1", - "queries": 10000, - "seconds": 1.247128 - }, - { - "sample": "Test-2", - "queries": 10000, - "seconds": 1.285941 - }, - { - "sample": "Test-0", - "queries": 10000, - "seconds": 1.69813 - }, - { - "sample": "Test-1", - "queries": 10000, - "seconds": 1.686727 - }, - { - "sample": "Test-2", - "queries": 10000, - "seconds": 1.682092 - } - ], - "finished": "2026-10-29T03:10:20.611113+00:00" - }, - "t8": { - "elapsed_process_seconds": 20.810178492218256, - "log": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/logs/jdk25-cohere-english-v3-1M-4bit-pq-t8.log", - "csv": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk25-cohere-english-v3-1M-4bit-pq/logging/jdk25-cohere-english-v3-1M-4bit-pq-t8-20261029-031020Z/experiments.csv", - "rows": [ - { - "schema_version": "1", - "run_id": "jdk25-cohere-english-v3-1M-4bit-pq-t8-20261029-031020Z", - "run_uuid": "afb8b82d-7bfe-4917-ae0b-07a0afbc7630", - "system_id": "eafa45791612", - "dataset_name": "cohere-english-v3-1M", - "M": "32", - "efConstruction": "100", - "neighborOverflow": "1.2", - "addHierarchy": "true", - "refineFinalGraph": "true", - "feature_set": "[NVQ_VECTORS, FUSED_PQ]", - "build_compressor": "\"ProductQuantization(M=261, clusters=256, centered=false)\"", - "search_compressor": "None", - "usePruning": "false", - "topK": "10", - "overquery": "1.0", - "rerankK": "10", - "search.throughput.avg_qps": "57670.64664851298", - "search.throughput.stddev_qps": "1069.5685083266228", - "search.throughput.cv_pct": "1.854615078005548", - "search.count.avg_visited": "505.9876", - "search.accuracy.recall_at_10": "0.6553", - "construction.index_build_time_s": "", - "construction.index_quant_time_s.NVQ.compute_time_s": "", - "construction.index_quant_time_s.PQ.compute_time_s": "", - "construction.index_quant_time_s.PQ.encoding_time_s": "" - }, - { - "schema_version": "1", - "run_id": "jdk25-cohere-english-v3-1M-4bit-pq-t8-20261029-031020Z", - "run_uuid": "afb8b82d-7bfe-4917-ae0b-07a0afbc7630", - "system_id": "eafa45791612", - "dataset_name": "cohere-english-v3-1M", - "M": "32", - "efConstruction": "100", - "neighborOverflow": "1.2", - "addHierarchy": "true", - "refineFinalGraph": "true", - "feature_set": "[NVQ_VECTORS, FUSED_PQ]", - "build_compressor": "\"ProductQuantization(M=261, clusters=256, centered=false)\"", - "search_compressor": "None", - "usePruning": "false", - "topK": "10", - "overquery": "2.0", - "rerankK": "20", - "search.throughput.avg_qps": "43804.78320505803", - "search.throughput.stddev_qps": "222.48459131611997", - "search.throughput.cv_pct": "0.5079002223903946", - "search.count.avg_visited": "710.3709", - "search.accuracy.recall_at_10": "0.81365", - "construction.index_build_time_s": "", - "construction.index_quant_time_s.NVQ.compute_time_s": "", - "construction.index_quant_time_s.PQ.compute_time_s": "", - "construction.index_quant_time_s.PQ.encoding_time_s": "" - } - ], - "throughput_samples": [ - { - "sample": "Test-0", - "queries": 60000, - "seconds": 1.061253 - }, - { - "sample": "Test-1", - "queries": 60000, - "seconds": 1.022813 - }, - { - "sample": "Test-2", - "queries": 60000, - "seconds": 1.037825 - }, - { - "sample": "Test-0", - "queries": 50000, - "seconds": 1.137074 - }, - { - "sample": "Test-1", - "queries": 50000, - "seconds": 1.139226 - }, - { - "sample": "Test-2", - "queries": 50000, - "seconds": 1.148042 - } - ], - "finished": "2026-10-29T03:10:41.422772+00:00" - } - }, - "index_files": [ - { - "path": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk25-cohere-english-v3-1M-4bit-pq/index_cache/graph_cohere_english_v3_1M_FUSED_PQ_NVQ_VECTORS_M32_ef100_of1.2_alpha1.2_H1_R1_ProductQuantization_M_261_clusters_256_centered_false_", - "bytes": 9586776527 - } - ], - "finished": "2026-10-29T03:10:41.423357+00:00" - }, - { - "dataset": "ada002-1M", - "display_name": "Ada002", - "original_dimensions": "1536", - "projected_ash_dimensions": "768", - "base_vectors": "982790", - "queries": "10000", - "ash_bits": "2", - "algorithm": "ASH", - "code_bytes": "197", - "ash_encoded_bits": "1576", - "pq_m": "", - "config": "ada002-1M-2bit-budget-ash.yml", - "jdk": "25", - "label": "jdk25-ada002-1M-2bit-ash", - "path": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk25-ada002-1M-2bit-ash", - "source": "507b74e7ef8a0e12fcfe402deb4bf758c65758d5", - "started": "2026-10-29T03:10:41.429665+00:00", - "phases": { - "build": { - "elapsed_process_seconds": 81.49886683840305, - "log": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/logs/jdk25-ada002-1M-2bit-ash-build.log", - "csv": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk25-ada002-1M-2bit-ash/logging/jdk25-ada002-1M-2bit-ash-build-20261029-031041Z/experiments.csv", - "rows": [ - { - "schema_version": "1", - "run_id": "jdk25-ada002-1M-2bit-ash-build-20261029-031041Z", - "run_uuid": "61d8b214-c8ac-4caf-a373-72d706e10c1a", - "system_id": "eafa45791612", - "dataset_name": "ada002-1M", - "M": "32", - "efConstruction": "100", - "neighborOverflow": "1.2", - "addHierarchy": "true", - "refineFinalGraph": "true", - "feature_set": "[NVQ_VECTORS, FUSED_ASH]", - "build_compressor": "\"AsymmetricHashing[origDim=1536, encodedBits=1576, bodyBits=1536, quantizedDim=768, bitsPerDimension=2, optimizer=ITQ, landmarks=1]\"", - "search_compressor": "None", - "usePruning": "false", - "topK": "10", - "overquery": "1.0", - "rerankK": "10", - "search.accuracy.recall_at_10": "0.79064", - "construction.index_build_time_s": "45.395450927", - "construction.index_quant_time_s.ASH.compute_time_s": "13.824727702", - "construction.index_quant_time_s.ASH.encoding_time_s": "8.277205551", - "construction.index_quant_time_s.NVQ.compute_time_s": "0.563860348" - } - ], - "throughput_samples": [], - "finished": "2026-10-29T03:12:02.930436+00:00" - }, - "t1": { - "elapsed_process_seconds": 133.8839788250625, - "log": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/logs/jdk25-ada002-1M-2bit-ash-t1.log", - "csv": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk25-ada002-1M-2bit-ash/logging/jdk25-ada002-1M-2bit-ash-t1-20261029-031204Z/experiments.csv", - "rows": [ - { - "schema_version": "1", - "run_id": "jdk25-ada002-1M-2bit-ash-t1-20261029-031204Z", - "run_uuid": "98adb99d-6dc8-4666-9ea0-7f5fc5dab68c", - "system_id": "eafa45791612", - "dataset_name": "ada002-1M", - "M": "32", - "efConstruction": "100", - "neighborOverflow": "1.2", - "addHierarchy": "true", - "refineFinalGraph": "true", - "feature_set": "[NVQ_VECTORS, FUSED_ASH]", - "build_compressor": "\"AsymmetricHashing[origDim=1536, encodedBits=1576, bodyBits=1536, quantizedDim=768, bitsPerDimension=2, optimizer=ITQ, landmarks=1]\"", - "search_compressor": "None", - "usePruning": "false", - "topK": "10", - "overquery": "1.0", - "rerankK": "10", - "search.throughput.avg_qps": "3436.8450622860405", - "search.throughput.stddev_qps": "85.67191578506873", - "search.throughput.cv_pct": "2.4927488505426973", - "search.count.avg_visited": "495.6071", - "search.accuracy.recall_at_10": "0.79064", - "construction.index_build_time_s": "", - "construction.index_quant_time_s.ASH.compute_time_s": "", - "construction.index_quant_time_s.ASH.encoding_time_s": "", - "construction.index_quant_time_s.NVQ.compute_time_s": "" - }, - { - "schema_version": "1", - "run_id": "jdk25-ada002-1M-2bit-ash-t1-20261029-031204Z", - "run_uuid": "98adb99d-6dc8-4666-9ea0-7f5fc5dab68c", - "system_id": "eafa45791612", - "dataset_name": "ada002-1M", - "M": "32", - "efConstruction": "100", - "neighborOverflow": "1.2", - "addHierarchy": "true", - "refineFinalGraph": "true", - "feature_set": "[NVQ_VECTORS, FUSED_ASH]", - "build_compressor": "\"AsymmetricHashing[origDim=1536, encodedBits=1576, bodyBits=1536, quantizedDim=768, bitsPerDimension=2, optimizer=ITQ, landmarks=1]\"", - "search_compressor": "None", - "usePruning": "false", - "topK": "10", - "overquery": "2.0", - "rerankK": "20", - "search.throughput.avg_qps": "3096.883668745141", - "search.throughput.stddev_qps": "86.13452376442987", - "search.throughput.cv_pct": "2.781329006114447", - "search.count.avg_visited": "699.8394", - "search.accuracy.recall_at_10": "0.92718", - "construction.index_build_time_s": "", - "construction.index_quant_time_s.ASH.compute_time_s": "", - "construction.index_quant_time_s.ASH.encoding_time_s": "", - "construction.index_quant_time_s.NVQ.compute_time_s": "" - } - ], - "throughput_samples": [ - { - "sample": "Test-0", - "queries": 10000, - "seconds": 2.958133 - }, - { - "sample": "Test-1", - "queries": 10000, - "seconds": 2.945865 - }, - { - "sample": "Test-2", - "queries": 10000, - "seconds": 2.828505 - }, - { - "sample": "Test-0", - "queries": 10000, - "seconds": 3.335919 - }, - { - "sample": "Test-1", - "queries": 10000, - "seconds": 3.18433 - }, - { - "sample": "Test-2", - "queries": 10000, - "seconds": 3.171987 - } - ], - "finished": "2026-10-29T03:14:16.818527+00:00" - }, - "t8": { - "elapsed_process_seconds": 34.95025229733437, - "log": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/logs/jdk25-ada002-1M-2bit-ash-t8.log", - "csv": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk25-ada002-1M-2bit-ash/logging/jdk25-ada002-1M-2bit-ash-t8-20261029-031417Z/experiments.csv", - "rows": [ - { - "schema_version": "1", - "run_id": "jdk25-ada002-1M-2bit-ash-t8-20261029-031417Z", - "run_uuid": "f8a361ec-ec21-432b-95ae-250e54310cf3", - "system_id": "eafa45791612", - "dataset_name": "ada002-1M", - "M": "32", - "efConstruction": "100", - "neighborOverflow": "1.2", - "addHierarchy": "true", - "refineFinalGraph": "true", - "feature_set": "[NVQ_VECTORS, FUSED_ASH]", - "build_compressor": "\"AsymmetricHashing[origDim=1536, encodedBits=1576, bodyBits=1536, quantizedDim=768, bitsPerDimension=2, optimizer=ITQ, landmarks=1]\"", - "search_compressor": "None", - "usePruning": "false", - "topK": "10", - "overquery": "1.0", - "rerankK": "10", - "search.throughput.avg_qps": "25148.668295078558", - "search.throughput.stddev_qps": "1423.1286862834309", - "search.throughput.cv_pct": "5.658863004535029", - "search.count.avg_visited": "495.6071", - "search.accuracy.recall_at_10": "0.79064", - "construction.index_build_time_s": "", - "construction.index_quant_time_s.ASH.compute_time_s": "", - "construction.index_quant_time_s.ASH.encoding_time_s": "", - "construction.index_quant_time_s.NVQ.compute_time_s": "" - }, - { - "schema_version": "1", - "run_id": "jdk25-ada002-1M-2bit-ash-t8-20261029-031417Z", - "run_uuid": "f8a361ec-ec21-432b-95ae-250e54310cf3", - "system_id": "eafa45791612", - "dataset_name": "ada002-1M", - "M": "32", - "efConstruction": "100", - "neighborOverflow": "1.2", - "addHierarchy": "true", - "refineFinalGraph": "true", - "feature_set": "[NVQ_VECTORS, FUSED_ASH]", - "build_compressor": "\"AsymmetricHashing[origDim=1536, encodedBits=1576, bodyBits=1536, quantizedDim=768, bitsPerDimension=2, optimizer=ITQ, landmarks=1]\"", - "search_compressor": "None", - "usePruning": "false", - "topK": "10", - "overquery": "2.0", - "rerankK": "20", - "search.throughput.avg_qps": "22643.408793697028", - "search.throughput.stddev_qps": "202.7696533831723", - "search.throughput.cv_pct": "0.8954908478250626", - "search.count.avg_visited": "699.8394", - "search.accuracy.recall_at_10": "0.92718", - "construction.index_build_time_s": "", - "construction.index_quant_time_s.ASH.compute_time_s": "", - "construction.index_quant_time_s.ASH.encoding_time_s": "", - "construction.index_quant_time_s.NVQ.compute_time_s": "" - } - ], - "throughput_samples": [ - { - "sample": "Test-0", - "queries": 30000, - "seconds": 1.276284 - }, - { - "sample": "Test-1", - "queries": 30000, - "seconds": 1.153854 - }, - { - "sample": "Test-2", - "queries": 30000, - "seconds": 1.156495 - }, - { - "sample": "Test-0", - "queries": 30000, - "seconds": 1.31178 - }, - { - "sample": "Test-1", - "queries": 30000, - "seconds": 1.334629 - }, - { - "sample": "Test-2", - "queries": 30000, - "seconds": 1.32847 - } - ], - "finished": "2026-10-29T03:14:51.770277+00:00" - } - }, - "index_files": [ - { - "path": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk25-ada002-1M-2bit-ash/index_cache/graph_ada002_1M_FUSED_ASH_NVQ_VECTORS_M32_ef100_of1.2_alpha1.2_H1_R1_AsymmetricHashing_origDim_1536_encodedBits_1576_bodyBits_1536_quantizedDim_768_bitsPerDimension_2_optimizer_ITQ_landmarks_1_", - "bytes": 7917644448 - } - ], - "finished": "2026-10-29T03:14:51.771473+00:00" - }, - { - "dataset": "ada002-1M", - "display_name": "Ada002", - "original_dimensions": "1536", - "projected_ash_dimensions": "768", - "base_vectors": "982790", - "queries": "10000", - "ash_bits": "2", - "algorithm": "PQ", - "code_bytes": "197", - "ash_encoded_bits": "", - "pq_m": "197", - "config": "ada002-1M-2bit-budget-pq.yml", - "jdk": "25", - "label": "jdk25-ada002-1M-2bit-pq", - "path": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk25-ada002-1M-2bit-pq", - "source": "507b74e7ef8a0e12fcfe402deb4bf758c65758d5", - "started": "2026-10-29T03:14:51.778435+00:00", - "phases": { - "build": { - "elapsed_process_seconds": 54.51408803742379, - "log": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/logs/jdk25-ada002-1M-2bit-pq-build.log", - "csv": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk25-ada002-1M-2bit-pq/logging/jdk25-ada002-1M-2bit-pq-build-20261029-031452Z/experiments.csv", - "rows": [ - { - "schema_version": "1", - "run_id": "jdk25-ada002-1M-2bit-pq-build-20261029-031452Z", - "run_uuid": "cda00aa3-88bc-4643-9c39-6ab3fca2be15", - "system_id": "eafa45791612", - "dataset_name": "ada002-1M", - "M": "32", - "efConstruction": "100", - "neighborOverflow": "1.2", - "addHierarchy": "true", - "refineFinalGraph": "true", - "feature_set": "[NVQ_VECTORS, FUSED_PQ]", - "build_compressor": "\"ProductQuantization(M=197, clusters=256, centered=false)\"", - "search_compressor": "None", - "usePruning": "false", - "topK": "10", - "overquery": "1.0", - "rerankK": "10", - "search.accuracy.recall_at_10": "0.54594", - "construction.index_build_time_s": "42.696928604", - "construction.index_quant_time_s.NVQ.compute_time_s": "0.522869417", - "construction.index_quant_time_s.PQ.compute_time_s": "3.724007606", - "construction.index_quant_time_s.PQ.encoding_time_s": "2.046804322" - } - ], - "throughput_samples": [], - "finished": "2026-10-29T03:15:46.294671+00:00" - }, - "t1": { - "elapsed_process_seconds": 51.58396946080029, - "log": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/logs/jdk25-ada002-1M-2bit-pq-t1.log", - "csv": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk25-ada002-1M-2bit-pq/logging/jdk25-ada002-1M-2bit-pq-t1-20261029-031547Z/experiments.csv", - "rows": [ - { - "schema_version": "1", - "run_id": "jdk25-ada002-1M-2bit-pq-t1-20261029-031547Z", - "run_uuid": "829ad00a-17e6-4512-b0ad-d3d51d2fddb4", - "system_id": "eafa45791612", - "dataset_name": "ada002-1M", - "M": "32", - "efConstruction": "100", - "neighborOverflow": "1.2", - "addHierarchy": "true", - "refineFinalGraph": "true", - "feature_set": "[NVQ_VECTORS, FUSED_PQ]", - "build_compressor": "\"ProductQuantization(M=197, clusters=256, centered=false)\"", - "search_compressor": "None", - "usePruning": "false", - "topK": "10", - "overquery": "1.0", - "rerankK": "10", - "search.throughput.avg_qps": "7271.180761294964", - "search.throughput.stddev_qps": "164.92385726492031", - "search.throughput.cv_pct": "2.2681853563979906", - "search.count.avg_visited": "466.6089", - "search.accuracy.recall_at_10": "0.54594", - "construction.index_build_time_s": "", - "construction.index_quant_time_s.NVQ.compute_time_s": "", - "construction.index_quant_time_s.PQ.compute_time_s": "", - "construction.index_quant_time_s.PQ.encoding_time_s": "" - }, - { - "schema_version": "1", - "run_id": "jdk25-ada002-1M-2bit-pq-t1-20261029-031547Z", - "run_uuid": "829ad00a-17e6-4512-b0ad-d3d51d2fddb4", - "system_id": "eafa45791612", - "dataset_name": "ada002-1M", - "M": "32", - "efConstruction": "100", - "neighborOverflow": "1.2", - "addHierarchy": "true", - "refineFinalGraph": "true", - "feature_set": "[NVQ_VECTORS, FUSED_PQ]", - "build_compressor": "\"ProductQuantization(M=197, clusters=256, centered=false)\"", - "search_compressor": "None", - "usePruning": "false", - "topK": "10", - "overquery": "2.0", - "rerankK": "20", - "search.throughput.avg_qps": "5711.746382358798", - "search.throughput.stddev_qps": "17.585559682677623", - "search.throughput.cv_pct": "0.3078841129394695", - "search.count.avg_visited": "636.743", - "search.accuracy.recall_at_10": "0.7504", - "construction.index_build_time_s": "", - "construction.index_quant_time_s.NVQ.compute_time_s": "", - "construction.index_quant_time_s.PQ.compute_time_s": "", - "construction.index_quant_time_s.PQ.encoding_time_s": "" - } - ], - "throughput_samples": [ - { - "sample": "Test-0", - "queries": 10000, - "seconds": 1.412259 - }, - { - "sample": "Test-1", - "queries": 10000, - "seconds": 1.356497 - }, - { - "sample": "Test-2", - "queries": 10000, - "seconds": 1.358555 - }, - { - "sample": "Test-0", - "queries": 10000, - "seconds": 1.745056 - }, - { - "sample": "Test-1", - "queries": 10000, - "seconds": 1.755746 - }, - { - "sample": "Test-2", - "queries": 10000, - "seconds": 1.751565 - } - ], - "finished": "2026-10-29T03:16:37.879925+00:00" - }, - "t8": { - "elapsed_process_seconds": 22.498855738900602, - "log": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/logs/jdk25-ada002-1M-2bit-pq-t8.log", - "csv": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk25-ada002-1M-2bit-pq/logging/jdk25-ada002-1M-2bit-pq-t8-20261029-031638Z/experiments.csv", - "rows": [ - { - "schema_version": "1", - "run_id": "jdk25-ada002-1M-2bit-pq-t8-20261029-031638Z", - "run_uuid": "81d4b3dd-d49a-4206-bfd9-34bf7e60f897", - "system_id": "eafa45791612", - "dataset_name": "ada002-1M", - "M": "32", - "efConstruction": "100", - "neighborOverflow": "1.2", - "addHierarchy": "true", - "refineFinalGraph": "true", - "feature_set": "[NVQ_VECTORS, FUSED_PQ]", - "build_compressor": "\"ProductQuantization(M=197, clusters=256, centered=false)\"", - "search_compressor": "None", - "usePruning": "false", - "topK": "10", - "overquery": "1.0", - "rerankK": "10", - "search.throughput.avg_qps": "49406.674887911795", - "search.throughput.stddev_qps": "1948.6084898358451", - "search.throughput.cv_pct": "3.9440186862536795", - "search.count.avg_visited": "466.6089", - "search.accuracy.recall_at_10": "0.54594", - "construction.index_build_time_s": "", - "construction.index_quant_time_s.NVQ.compute_time_s": "", - "construction.index_quant_time_s.PQ.compute_time_s": "", - "construction.index_quant_time_s.PQ.encoding_time_s": "" - }, - { - "schema_version": "1", - "run_id": "jdk25-ada002-1M-2bit-pq-t8-20261029-031638Z", - "run_uuid": "81d4b3dd-d49a-4206-bfd9-34bf7e60f897", - "system_id": "eafa45791612", - "dataset_name": "ada002-1M", - "M": "32", - "efConstruction": "100", - "neighborOverflow": "1.2", - "addHierarchy": "true", - "refineFinalGraph": "true", - "feature_set": "[NVQ_VECTORS, FUSED_PQ]", - "build_compressor": "\"ProductQuantization(M=197, clusters=256, centered=false)\"", - "search_compressor": "None", - "usePruning": "false", - "topK": "10", - "overquery": "2.0", - "rerankK": "20", - "search.throughput.avg_qps": "43937.988185794646", - "search.throughput.stddev_qps": "435.3744690698342", - "search.throughput.cv_pct": "0.9908839413148024", - "search.count.avg_visited": "636.743", - "search.accuracy.recall_at_10": "0.7504", - "construction.index_build_time_s": "", - "construction.index_quant_time_s.NVQ.compute_time_s": "", - "construction.index_quant_time_s.PQ.compute_time_s": "", - "construction.index_quant_time_s.PQ.encoding_time_s": "" - } - ], - "throughput_samples": [ - { - "sample": "Test-0", - "queries": 50000, - "seconds": 1.052748 - }, - { - "sample": "Test-1", - "queries": 50000, - "seconds": 1.013474 - }, - { - "sample": "Test-2", - "queries": 60000, - "seconds": 1.167542 - }, - { - "sample": "Test-0", - "queries": 50000, - "seconds": 1.151087 - }, - { - "sample": "Test-1", - "queries": 50000, - "seconds": 1.132495 - }, - { - "sample": "Test-2", - "queries": 50000, - "seconds": 1.130544 - } - ], - "finished": "2026-10-29T03:17:00.380447+00:00" - } - }, - "index_files": [ - { - "path": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk25-ada002-1M-2bit-pq/index_cache/graph_ada002_1M_FUSED_PQ_NVQ_VECTORS_M32_ef100_of1.2_alpha1.2_H1_R1_ProductQuantization_M_197_clusters_256_centered_false_", - "bytes": 7911343988 - } - ], - "finished": "2026-10-29T03:17:00.381014+00:00" - }, - { - "dataset": "ada002-1M", - "display_name": "Ada002", - "original_dimensions": "1536", - "projected_ash_dimensions": "768", - "base_vectors": "982790", - "queries": "10000", - "ash_bits": "4", - "algorithm": "ASH", - "code_bytes": "389", - "ash_encoded_bits": "3112", - "pq_m": "", - "config": "ada002-1M-4bit-budget-ash.yml", - "jdk": "25", - "label": "jdk25-ada002-1M-4bit-ash", - "path": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk25-ada002-1M-4bit-ash", - "source": "507b74e7ef8a0e12fcfe402deb4bf758c65758d5", - "started": "2026-10-29T03:17:00.388271+00:00", - "phases": { - "build": { - "elapsed_process_seconds": 90.95995359588414, - "log": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/logs/jdk25-ada002-1M-4bit-ash-build.log", - "csv": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk25-ada002-1M-4bit-ash/logging/jdk25-ada002-1M-4bit-ash-build-20261029-031700Z/experiments.csv", - "rows": [ - { - "schema_version": "1", - "run_id": "jdk25-ada002-1M-4bit-ash-build-20261029-031700Z", - "run_uuid": "8f9d1a2f-7230-4506-bd6d-7867d23e42b8", - "system_id": "eafa45791612", - "dataset_name": "ada002-1M", - "M": "32", - "efConstruction": "100", - "neighborOverflow": "1.2", - "addHierarchy": "true", - "refineFinalGraph": "true", - "feature_set": "[NVQ_VECTORS, FUSED_ASH]", - "build_compressor": "\"AsymmetricHashing[origDim=1536, encodedBits=3112, bodyBits=3072, quantizedDim=768, bitsPerDimension=4, optimizer=ITQ, landmarks=1]\"", - "search_compressor": "None", - "usePruning": "false", - "topK": "10", - "overquery": "1.0", - "rerankK": "10", - "search.accuracy.recall_at_10": "0.83528", - "construction.index_build_time_s": "60.391773285", - "construction.index_quant_time_s.ASH.compute_time_s": "13.053361983", - "construction.index_quant_time_s.ASH.encoding_time_s": "7.324970714", - "construction.index_quant_time_s.NVQ.compute_time_s": "0.787999096" - } - ], - "throughput_samples": [], - "finished": "2026-10-29T03:18:31.350232+00:00" - }, - "t1": { - "elapsed_process_seconds": 251.54309606924653, - "log": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/logs/jdk25-ada002-1M-4bit-ash-t1.log", - "csv": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk25-ada002-1M-4bit-ash/logging/jdk25-ada002-1M-4bit-ash-t1-20261029-031832Z/experiments.csv", - "rows": [ - { - "schema_version": "1", - "run_id": "jdk25-ada002-1M-4bit-ash-t1-20261029-031832Z", - "run_uuid": "85736aee-e6bc-4c19-b3bf-f4c91f5acce9", - "system_id": "eafa45791612", - "dataset_name": "ada002-1M", - "M": "32", - "efConstruction": "100", - "neighborOverflow": "1.2", - "addHierarchy": "true", - "refineFinalGraph": "true", - "feature_set": "[NVQ_VECTORS, FUSED_ASH]", - "build_compressor": "\"AsymmetricHashing[origDim=1536, encodedBits=3112, bodyBits=3072, quantizedDim=768, bitsPerDimension=4, optimizer=ITQ, landmarks=1]\"", - "search_compressor": "None", - "usePruning": "false", - "topK": "10", - "overquery": "1.0", - "rerankK": "10", - "search.throughput.avg_qps": "2958.5765700966863", - "search.throughput.stddev_qps": "117.34517913112595", - "search.throughput.cv_pct": "3.9662714941087738", - "search.count.avg_visited": "475.7187", - "search.accuracy.recall_at_10": "0.83528", - "construction.index_build_time_s": "", - "construction.index_quant_time_s.ASH.compute_time_s": "", - "construction.index_quant_time_s.ASH.encoding_time_s": "", - "construction.index_quant_time_s.NVQ.compute_time_s": "" - }, - { - "schema_version": "1", - "run_id": "jdk25-ada002-1M-4bit-ash-t1-20261029-031832Z", - "run_uuid": "85736aee-e6bc-4c19-b3bf-f4c91f5acce9", - "system_id": "eafa45791612", - "dataset_name": "ada002-1M", - "M": "32", - "efConstruction": "100", - "neighborOverflow": "1.2", - "addHierarchy": "true", - "refineFinalGraph": "true", - "feature_set": "[NVQ_VECTORS, FUSED_ASH]", - "build_compressor": "\"AsymmetricHashing[origDim=1536, encodedBits=3112, bodyBits=3072, quantizedDim=768, bitsPerDimension=4, optimizer=ITQ, landmarks=1]\"", - "search_compressor": "None", - "usePruning": "false", - "topK": "10", - "overquery": "2.0", - "rerankK": "20", - "search.throughput.avg_qps": "2649.82808625031", - "search.throughput.stddev_qps": "112.41385140796983", - "search.throughput.cv_pct": "4.2423073402864855", - "search.count.avg_visited": "664.8421", - "search.accuracy.recall_at_10": "0.92467", - "construction.index_build_time_s": "", - "construction.index_quant_time_s.ASH.compute_time_s": "", - "construction.index_quant_time_s.ASH.encoding_time_s": "", - "construction.index_quant_time_s.NVQ.compute_time_s": "" - } - ], - "throughput_samples": [ - { - "sample": "Test-0", - "queries": 10000, - "seconds": 3.533463 - }, - { - "sample": "Test-1", - "queries": 10000, - "seconds": 3.349437 - }, - { - "sample": "Test-2", - "queries": 10000, - "seconds": 3.267901 - }, - { - "sample": "Test-0", - "queries": 10000, - "seconds": 3.968156 - }, - { - "sample": "Test-1", - "queries": 10000, - "seconds": 3.679912 - }, - { - "sample": "Test-2", - "queries": 10000, - "seconds": 3.687363 - } - ], - "finished": "2026-10-29T03:22:42.897493+00:00" - }, - "t8": { - "elapsed_process_seconds": 52.648550101555884, - "log": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/logs/jdk25-ada002-1M-4bit-ash-t8.log", - "csv": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk25-ada002-1M-4bit-ash/logging/jdk25-ada002-1M-4bit-ash-t8-20261029-032243Z/experiments.csv", - "rows": [ - { - "schema_version": "1", - "run_id": "jdk25-ada002-1M-4bit-ash-t8-20261029-032243Z", - "run_uuid": "f139c9be-08a5-4a00-a58b-ae91ba665994", - "system_id": "eafa45791612", - "dataset_name": "ada002-1M", - "M": "32", - "efConstruction": "100", - "neighborOverflow": "1.2", - "addHierarchy": "true", - "refineFinalGraph": "true", - "feature_set": "[NVQ_VECTORS, FUSED_ASH]", - "build_compressor": "\"AsymmetricHashing[origDim=1536, encodedBits=3112, bodyBits=3072, quantizedDim=768, bitsPerDimension=4, optimizer=ITQ, landmarks=1]\"", - "search_compressor": "None", - "usePruning": "false", - "topK": "10", - "overquery": "1.0", - "rerankK": "10", - "search.throughput.avg_qps": "21982.92028578378", - "search.throughput.stddev_qps": "927.6380025677681", - "search.throughput.cv_pct": "4.2198124294144215", - "search.count.avg_visited": "475.7187", - "search.accuracy.recall_at_10": "0.83528", - "construction.index_build_time_s": "", - "construction.index_quant_time_s.ASH.compute_time_s": "", - "construction.index_quant_time_s.ASH.encoding_time_s": "", - "construction.index_quant_time_s.NVQ.compute_time_s": "" - }, - { - "schema_version": "1", - "run_id": "jdk25-ada002-1M-4bit-ash-t8-20261029-032243Z", - "run_uuid": "f139c9be-08a5-4a00-a58b-ae91ba665994", - "system_id": "eafa45791612", - "dataset_name": "ada002-1M", - "M": "32", - "efConstruction": "100", - "neighborOverflow": "1.2", - "addHierarchy": "true", - "refineFinalGraph": "true", - "feature_set": "[NVQ_VECTORS, FUSED_ASH]", - "build_compressor": "\"AsymmetricHashing[origDim=1536, encodedBits=3112, bodyBits=3072, quantizedDim=768, bitsPerDimension=4, optimizer=ITQ, landmarks=1]\"", - "search_compressor": "None", - "usePruning": "false", - "topK": "10", - "overquery": "2.0", - "rerankK": "20", - "search.throughput.avg_qps": "20075.60298373417", - "search.throughput.stddev_qps": "614.8878491828754", - "search.throughput.cv_pct": "3.0628611737394644", - "search.count.avg_visited": "664.8421", - "search.accuracy.recall_at_10": "0.92467", - "construction.index_build_time_s": "", - "construction.index_quant_time_s.ASH.compute_time_s": "", - "construction.index_quant_time_s.ASH.encoding_time_s": "", - "construction.index_quant_time_s.NVQ.compute_time_s": "" - } - ], - "throughput_samples": [ - { - "sample": "Test-0", - "queries": 30000, - "seconds": 1.434109 - }, - { - "sample": "Test-1", - "queries": 30000, - "seconds": 1.338809 - }, - { - "sample": "Test-2", - "queries": 30000, - "seconds": 1.326151 - }, - { - "sample": "Test-0", - "queries": 20000, - "seconds": 1.013557 - }, - { - "sample": "Test-1", - "queries": 30000, - "seconds": 1.443315 - }, - { - "sample": "Test-2", - "queries": 20000, - "seconds": 1.014773 - } - ], - "finished": "2026-10-29T03:23:35.547518+00:00" - } - }, - "index_files": [ - { - "path": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk25-ada002-1M-4bit-ash/index_cache/graph_ada002_1M_FUSED_ASH_NVQ_VECTORS_M32_ef100_of1.2_alpha1.2_H1_R1_AsymmetricHashing_origDim_1536_encodedBits_3112_bodyBits_3072_quantizedDim_768_bitsPerDimension_4_optimizer_ITQ_landmarks_1_", - "bytes": 13961804326 - } - ], - "finished": "2026-10-29T03:23:35.548122+00:00" - }, - { - "dataset": "ada002-1M", - "display_name": "Ada002", - "original_dimensions": "1536", - "projected_ash_dimensions": "768", - "base_vectors": "982790", - "queries": "10000", - "ash_bits": "4", - "algorithm": "PQ", - "code_bytes": "389", - "ash_encoded_bits": "", - "pq_m": "389", - "config": "ada002-1M-4bit-budget-pq.yml", - "jdk": "25", - "label": "jdk25-ada002-1M-4bit-pq", - "path": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk25-ada002-1M-4bit-pq", - "source": "507b74e7ef8a0e12fcfe402deb4bf758c65758d5", - "started": "2026-10-29T03:23:35.556325+00:00", - "phases": { - "build": { - "elapsed_process_seconds": 125.37208096962422, - "log": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/logs/jdk25-ada002-1M-4bit-pq-build.log", - "csv": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk25-ada002-1M-4bit-pq/logging/jdk25-ada002-1M-4bit-pq-build-20261029-032335Z/experiments.csv", - "rows": [ - { - "schema_version": "1", - "run_id": "jdk25-ada002-1M-4bit-pq-build-20261029-032335Z", - "run_uuid": "98332961-76ed-44b4-948b-dbaabfa599b6", - "system_id": "eafa45791612", - "dataset_name": "ada002-1M", - "M": "32", - "efConstruction": "100", - "neighborOverflow": "1.2", - "addHierarchy": "true", - "refineFinalGraph": "true", - "feature_set": "[NVQ_VECTORS, FUSED_PQ]", - "build_compressor": "\"ProductQuantization(M=389, clusters=256, centered=false)\"", - "search_compressor": "None", - "usePruning": "false", - "topK": "10", - "overquery": "1.0", - "rerankK": "10", - "search.accuracy.recall_at_10": "0.73566", - "construction.index_build_time_s": "106.252121239", - "construction.index_quant_time_s.NVQ.compute_time_s": "0.494723635", - "construction.index_quant_time_s.PQ.compute_time_s": "8.80672428", - "construction.index_quant_time_s.PQ.encoding_time_s": "3.506287152" - } - ], - "throughput_samples": [], - "finished": "2026-10-29T03:25:40.930514+00:00" - }, - "t1": { - "elapsed_process_seconds": 179.37926136236638, - "log": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/logs/jdk25-ada002-1M-4bit-pq-t1.log", - "csv": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk25-ada002-1M-4bit-pq/logging/jdk25-ada002-1M-4bit-pq-t1-20261029-032541Z/experiments.csv", - "rows": [ - { - "schema_version": "1", - "run_id": "jdk25-ada002-1M-4bit-pq-t1-20261029-032541Z", - "run_uuid": "c4431ce2-56d1-46b8-9ae3-33ece0b08428", - "system_id": "eafa45791612", - "dataset_name": "ada002-1M", - "M": "32", - "efConstruction": "100", - "neighborOverflow": "1.2", - "addHierarchy": "true", - "refineFinalGraph": "true", - "feature_set": "[NVQ_VECTORS, FUSED_PQ]", - "build_compressor": "\"ProductQuantization(M=389, clusters=256, centered=false)\"", - "search_compressor": "None", - "usePruning": "false", - "topK": "10", - "overquery": "1.0", - "rerankK": "10", - "search.throughput.avg_qps": "3664.671687680422", - "search.throughput.stddev_qps": "20.175300036192812", - "search.throughput.cv_pct": "0.5505349934624812", - "search.count.avg_visited": "474.1962", - "search.accuracy.recall_at_10": "0.73566", - "construction.index_build_time_s": "", - "construction.index_quant_time_s.NVQ.compute_time_s": "", - "construction.index_quant_time_s.PQ.compute_time_s": "", - "construction.index_quant_time_s.PQ.encoding_time_s": "" - }, - { - "schema_version": "1", - "run_id": "jdk25-ada002-1M-4bit-pq-t1-20261029-032541Z", - "run_uuid": "c4431ce2-56d1-46b8-9ae3-33ece0b08428", - "system_id": "eafa45791612", - "dataset_name": "ada002-1M", - "M": "32", - "efConstruction": "100", - "neighborOverflow": "1.2", - "addHierarchy": "true", - "refineFinalGraph": "true", - "feature_set": "[NVQ_VECTORS, FUSED_PQ]", - "build_compressor": "\"ProductQuantization(M=389, clusters=256, centered=false)\"", - "search_compressor": "None", - "usePruning": "false", - "topK": "10", - "overquery": "2.0", - "rerankK": "20", - "search.throughput.avg_qps": "3883.6968894121583", - "search.throughput.stddev_qps": "60.99347982756172", - "search.throughput.cv_pct": "1.5705005195911101", - "search.count.avg_visited": "656.8223", - "search.accuracy.recall_at_10": "0.9015", - "construction.index_build_time_s": "", - "construction.index_quant_time_s.NVQ.compute_time_s": "", - "construction.index_quant_time_s.PQ.compute_time_s": "", - "construction.index_quant_time_s.PQ.encoding_time_s": "" - } - ], - "throughput_samples": [ - { - "sample": "Test-0", - "queries": 10000, - "seconds": 2.746211 - }, - { - "sample": "Test-1", - "queries": 10000, - "seconds": 2.720442 - }, - { - "sample": "Test-2", - "queries": 10000, - "seconds": 2.719785 - }, - { - "sample": "Test-0", - "queries": 10000, - "seconds": 2.622408 - }, - { - "sample": "Test-1", - "queries": 10000, - "seconds": 2.550753 - }, - { - "sample": "Test-2", - "queries": 10000, - "seconds": 2.552719 - } - ], - "finished": "2026-10-29T03:28:40.311803+00:00" - }, - "t8": { - "elapsed_process_seconds": 31.153259568847716, - "log": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/logs/jdk25-ada002-1M-4bit-pq-t8.log", - "csv": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk25-ada002-1M-4bit-pq/logging/jdk25-ada002-1M-4bit-pq-t8-20261029-032840Z/experiments.csv", - "rows": [ - { - "schema_version": "1", - "run_id": "jdk25-ada002-1M-4bit-pq-t8-20261029-032840Z", - "run_uuid": "5d213a65-7421-40e0-bdc8-49ce38b80867", - "system_id": "eafa45791612", - "dataset_name": "ada002-1M", - "M": "32", - "efConstruction": "100", - "neighborOverflow": "1.2", - "addHierarchy": "true", - "refineFinalGraph": "true", - "feature_set": "[NVQ_VECTORS, FUSED_PQ]", - "build_compressor": "\"ProductQuantization(M=389, clusters=256, centered=false)\"", - "search_compressor": "None", - "usePruning": "false", - "topK": "10", - "overquery": "1.0", - "rerankK": "10", - "search.throughput.avg_qps": "26979.66563109857", - "search.throughput.stddev_qps": "1180.029335083216", - "search.throughput.cv_pct": "4.373773015641214", - "search.count.avg_visited": "474.1962", - "search.accuracy.recall_at_10": "0.73566", - "construction.index_build_time_s": "", - "construction.index_quant_time_s.NVQ.compute_time_s": "", - "construction.index_quant_time_s.PQ.compute_time_s": "", - "construction.index_quant_time_s.PQ.encoding_time_s": "" - }, - { - "schema_version": "1", - "run_id": "jdk25-ada002-1M-4bit-pq-t8-20261029-032840Z", - "run_uuid": "5d213a65-7421-40e0-bdc8-49ce38b80867", - "system_id": "eafa45791612", - "dataset_name": "ada002-1M", - "M": "32", - "efConstruction": "100", - "neighborOverflow": "1.2", - "addHierarchy": "true", - "refineFinalGraph": "true", - "feature_set": "[NVQ_VECTORS, FUSED_PQ]", - "build_compressor": "\"ProductQuantization(M=389, clusters=256, centered=false)\"", - "search_compressor": "None", - "usePruning": "false", - "topK": "10", - "overquery": "2.0", - "rerankK": "20", - "search.throughput.avg_qps": "22435.812911872614", - "search.throughput.stddev_qps": "174.55195220413958", - "search.throughput.cv_pct": "0.7780059179927015", - "search.count.avg_visited": "656.8223", - "search.accuracy.recall_at_10": "0.9015", - "construction.index_build_time_s": "", - "construction.index_quant_time_s.NVQ.compute_time_s": "", - "construction.index_quant_time_s.PQ.compute_time_s": "", - "construction.index_quant_time_s.PQ.encoding_time_s": "" - } - ], - "throughput_samples": [ - { - "sample": "Test-0", - "queries": 30000, - "seconds": 1.145837 - }, - { - "sample": "Test-1", - "queries": 30000, - "seconds": 1.135413 - }, - { - "sample": "Test-2", - "queries": 30000, - "seconds": 1.058755 - }, - { - "sample": "Test-0", - "queries": 30000, - "seconds": 1.346977 - }, - { - "sample": "Test-1", - "queries": 30000, - "seconds": 1.338338 - }, - { - "sample": "Test-2", - "queries": 30000, - "seconds": 1.32629 - } - ], - "finished": "2026-10-29T03:29:11.466599+00:00" - } - }, - "index_files": [ - { - "path": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk25-ada002-1M-4bit-pq/index_cache/graph_ada002_1M_FUSED_PQ_NVQ_VECTORS_M32_ef100_of1.2_alpha1.2_H1_R1_ProductQuantization_M_389_clusters_256_centered_false_", - "bytes": 13955502981 - } - ], - "finished": "2026-10-29T03:29:11.467171+00:00" - }, - { - "dataset": "openai-3072-1m", - "display_name": "OpenAI3072", - "original_dimensions": "3072", - "projected_ash_dimensions": "1536", - "base_vectors": "999000", - "queries": "1000", - "ash_bits": "2", - "algorithm": "ASH", - "code_bytes": "389", - "ash_encoded_bits": "3112", - "pq_m": "", - "config": "openai-3072-1m-2bit-budget-ash.yml", - "jdk": "25", - "label": "jdk25-openai-3072-1m-2bit-ash", - "path": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk25-openai-3072-1m-2bit-ash", - "source": "507b74e7ef8a0e12fcfe402deb4bf758c65758d5", - "started": "2026-10-29T03:29:11.475179+00:00", - "phases": { - "build": { - "elapsed_process_seconds": 170.93929107300937, - "log": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/logs/jdk25-openai-3072-1m-2bit-ash-build.log", - "csv": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk25-openai-3072-1m-2bit-ash/logging/jdk25-openai-3072-1m-2bit-ash-build-20261029-032911Z/experiments.csv", - "rows": [ - { - "schema_version": "1", - "run_id": "jdk25-openai-3072-1m-2bit-ash-build-20261029-032911Z", - "run_uuid": "c1037cea-ef15-41f1-8426-961eb0eea97c", - "system_id": "eafa45791612", - "dataset_name": "openai-3072-1m", - "M": "32", - "efConstruction": "100", - "neighborOverflow": "1.2", - "addHierarchy": "true", - "refineFinalGraph": "true", - "feature_set": "[NVQ_VECTORS, FUSED_ASH]", - "build_compressor": "\"AsymmetricHashing[origDim=3072, encodedBits=3112, bodyBits=3072, quantizedDim=1536, bitsPerDimension=2, optimizer=ITQ, landmarks=1]\"", - "search_compressor": "None", - "usePruning": "false", - "topK": "10", - "overquery": "1.0", - "rerankK": "10", - "search.accuracy.recall_at_10": "0.7676", - "construction.index_build_time_s": "87.028014205", - "construction.index_quant_time_s.ASH.compute_time_s": "39.411565317", - "construction.index_quant_time_s.ASH.encoding_time_s": "24.278880319", - "construction.index_quant_time_s.NVQ.compute_time_s": "1.4630497" - } - ], - "throughput_samples": [], - "finished": "2026-10-29T03:32:02.418174+00:00" - }, - "t1": { - "elapsed_process_seconds": 54.140950039960444, - "log": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/logs/jdk25-openai-3072-1m-2bit-ash-t1.log", - "csv": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk25-openai-3072-1m-2bit-ash/logging/jdk25-openai-3072-1m-2bit-ash-t1-20261029-033203Z/experiments.csv", - "rows": [ - { - "schema_version": "1", - "run_id": "jdk25-openai-3072-1m-2bit-ash-t1-20261029-033203Z", - "run_uuid": "730e3467-6a50-46bd-9269-058fe758ba3f", - "system_id": "eafa45791612", - "dataset_name": "openai-3072-1m", - "M": "32", - "efConstruction": "100", - "neighborOverflow": "1.2", - "addHierarchy": "true", - "refineFinalGraph": "true", - "feature_set": "[NVQ_VECTORS, FUSED_ASH]", - "build_compressor": "\"AsymmetricHashing[origDim=3072, encodedBits=3112, bodyBits=3072, quantizedDim=1536, bitsPerDimension=2, optimizer=ITQ, landmarks=1]\"", - "search_compressor": "None", - "usePruning": "false", - "topK": "10", - "overquery": "1.0", - "rerankK": "10", - "search.throughput.avg_qps": "1003.0125753831542", - "search.throughput.stddev_qps": "80.02276597676175", - "search.throughput.cv_pct": "7.978241543601064", - "search.count.avg_visited": "532.42", - "search.accuracy.recall_at_10": "0.7676", - "construction.index_build_time_s": "", - "construction.index_quant_time_s.ASH.compute_time_s": "", - "construction.index_quant_time_s.ASH.encoding_time_s": "", - "construction.index_quant_time_s.NVQ.compute_time_s": "" - }, - { - "schema_version": "1", - "run_id": "jdk25-openai-3072-1m-2bit-ash-t1-20261029-033203Z", - "run_uuid": "730e3467-6a50-46bd-9269-058fe758ba3f", - "system_id": "eafa45791612", - "dataset_name": "openai-3072-1m", - "M": "32", - "efConstruction": "100", - "neighborOverflow": "1.2", - "addHierarchy": "true", - "refineFinalGraph": "true", - "feature_set": "[NVQ_VECTORS, FUSED_ASH]", - "build_compressor": "\"AsymmetricHashing[origDim=3072, encodedBits=3112, bodyBits=3072, quantizedDim=1536, bitsPerDimension=2, optimizer=ITQ, landmarks=1]\"", - "search_compressor": "None", - "usePruning": "false", - "topK": "10", - "overquery": "2.0", - "rerankK": "20", - "search.throughput.avg_qps": "1093.9677679889598", - "search.throughput.stddev_qps": "6.6161315083382", - "search.throughput.cv_pct": "0.6047830385808011", - "search.count.avg_visited": "770.301", - "search.accuracy.recall_at_10": "0.8917", - "construction.index_build_time_s": "", - "construction.index_quant_time_s.ASH.compute_time_s": "", - "construction.index_quant_time_s.ASH.encoding_time_s": "", - "construction.index_quant_time_s.NVQ.compute_time_s": "" - } - ], - "throughput_samples": [ - { - "sample": "Test-0", - "queries": 1000, - "seconds": 1.047626 - }, - { - "sample": "Test-1", - "queries": 2000, - "seconds": 1.825855 - }, - { - "sample": "Test-2", - "queries": 1000, - "seconds": 1.042621 - }, - { - "sample": "Test-0", - "queries": 2000, - "seconds": 1.841041 - }, - { - "sample": "Test-1", - "queries": 2000, - "seconds": 1.821205 - }, - { - "sample": "Test-2", - "queries": 2000, - "seconds": 1.82251 - } - ], - "finished": "2026-10-29T03:32:56.564503+00:00" - }, - "t8": { - "elapsed_process_seconds": 25.17004393786192, - "log": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/logs/jdk25-openai-3072-1m-2bit-ash-t8.log", - "csv": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk25-openai-3072-1m-2bit-ash/logging/jdk25-openai-3072-1m-2bit-ash-t8-20261029-033256Z/experiments.csv", - "rows": [ - { - "schema_version": "1", - "run_id": "jdk25-openai-3072-1m-2bit-ash-t8-20261029-033256Z", - "run_uuid": "e7373dd6-efaa-4f56-ac5e-265918525dab", - "system_id": "eafa45791612", - "dataset_name": "openai-3072-1m", - "M": "32", - "efConstruction": "100", - "neighborOverflow": "1.2", - "addHierarchy": "true", - "refineFinalGraph": "true", - "feature_set": "[NVQ_VECTORS, FUSED_ASH]", - "build_compressor": "\"AsymmetricHashing[origDim=3072, encodedBits=3112, bodyBits=3072, quantizedDim=1536, bitsPerDimension=2, optimizer=ITQ, landmarks=1]\"", - "search_compressor": "None", - "usePruning": "false", - "topK": "10", - "overquery": "1.0", - "rerankK": "10", - "search.throughput.avg_qps": "8050.426675043233", - "search.throughput.stddev_qps": "454.19173972355793", - "search.throughput.cv_pct": "5.641834377941449", - "search.count.avg_visited": "532.42", - "search.accuracy.recall_at_10": "0.7676", - "construction.index_build_time_s": "", - "construction.index_quant_time_s.ASH.compute_time_s": "", - "construction.index_quant_time_s.ASH.encoding_time_s": "", - "construction.index_quant_time_s.NVQ.compute_time_s": "" - }, - { - "schema_version": "1", - "run_id": "jdk25-openai-3072-1m-2bit-ash-t8-20261029-033256Z", - "run_uuid": "e7373dd6-efaa-4f56-ac5e-265918525dab", - "system_id": "eafa45791612", - "dataset_name": "openai-3072-1m", - "M": "32", - "efConstruction": "100", - "neighborOverflow": "1.2", - "addHierarchy": "true", - "refineFinalGraph": "true", - "feature_set": "[NVQ_VECTORS, FUSED_ASH]", - "build_compressor": "\"AsymmetricHashing[origDim=3072, encodedBits=3112, bodyBits=3072, quantizedDim=1536, bitsPerDimension=2, optimizer=ITQ, landmarks=1]\"", - "search_compressor": "None", - "usePruning": "false", - "topK": "10", - "overquery": "2.0", - "rerankK": "20", - "search.throughput.avg_qps": "7893.088397249235", - "search.throughput.stddev_qps": "67.54153630967923", - "search.throughput.cv_pct": "0.8557048003316124", - "search.count.avg_visited": "770.301", - "search.accuracy.recall_at_10": "0.8917", - "construction.index_build_time_s": "", - "construction.index_quant_time_s.ASH.compute_time_s": "", - "construction.index_quant_time_s.ASH.encoding_time_s": "", - "construction.index_quant_time_s.NVQ.compute_time_s": "" - } - ], - "throughput_samples": [ - { - "sample": "Test-0", - "queries": 8000, - "seconds": 1.009207 - }, - { - "sample": "Test-1", - "queries": 9000, - "seconds": 1.052192 - }, - { - "sample": "Test-2", - "queries": 8000, - "seconds": 1.04293 - }, - { - "sample": "Test-0", - "queries": 8000, - "seconds": 1.023077 - }, - { - "sample": "Test-1", - "queries": 8000, - "seconds": 1.011716 - }, - { - "sample": "Test-2", - "queries": 8000, - "seconds": 1.005992 - } - ], - "finished": "2026-10-29T03:33:21.736125+00:00" - } - }, - "index_files": [ - { - "path": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk25-openai-3072-1m-2bit-ash/index_cache/graph_openai_3072_1m_FUSED_ASH_NVQ_VECTORS_M32_ef100_of1.2_alpha1.2_H1_R1_AsymmetricHashing_origDim_3072_encodedBits_3112_bodyBits_3072_quantizedDim_1536_bitsPerDimension_2_optimizer_ITQ_landmarks_1_", - "bytes": 15754739812 - } - ], - "finished": "2026-10-29T03:33:21.736748+00:00" - }, - { - "dataset": "openai-3072-1m", - "display_name": "OpenAI3072", - "original_dimensions": "3072", - "projected_ash_dimensions": "1536", - "base_vectors": "999000", - "queries": "1000", - "ash_bits": "2", - "algorithm": "PQ", - "code_bytes": "389", - "ash_encoded_bits": "", - "pq_m": "389", - "config": "openai-3072-1m-2bit-budget-pq.yml", - "jdk": "25", - "label": "jdk25-openai-3072-1m-2bit-pq", - "path": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk25-openai-3072-1m-2bit-pq", - "source": "507b74e7ef8a0e12fcfe402deb4bf758c65758d5", - "started": "2026-10-29T03:33:21.744280+00:00", - "phases": { - "build": { - "elapsed_process_seconds": 135.150859314017, - "log": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/logs/jdk25-openai-3072-1m-2bit-pq-build.log", - "csv": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk25-openai-3072-1m-2bit-pq/logging/jdk25-openai-3072-1m-2bit-pq-build-20261029-033322Z/experiments.csv", - "rows": [ - { - "schema_version": "1", - "run_id": "jdk25-openai-3072-1m-2bit-pq-build-20261029-033322Z", - "run_uuid": "2a9c1084-5e40-4ec4-b2a8-b79060cbb9ab", - "system_id": "eafa45791612", - "dataset_name": "openai-3072-1m", - "M": "32", - "efConstruction": "100", - "neighborOverflow": "1.2", - "addHierarchy": "true", - "refineFinalGraph": "true", - "feature_set": "[NVQ_VECTORS, FUSED_PQ]", - "build_compressor": "\"ProductQuantization(M=389, clusters=256, centered=false)\"", - "search_compressor": "None", - "usePruning": "false", - "topK": "10", - "overquery": "1.0", - "rerankK": "10", - "search.accuracy.recall_at_10": "0.651", - "construction.index_build_time_s": "113.005143545", - "construction.index_quant_time_s.NVQ.compute_time_s": "1.272420274", - "construction.index_quant_time_s.PQ.compute_time_s": "5.897795915", - "construction.index_quant_time_s.PQ.encoding_time_s": "5.580369998" - } - ], - "throughput_samples": [], - "finished": "2026-10-29T03:35:36.896859+00:00" - }, - "t1": { - "elapsed_process_seconds": 38.62861977890134, - "log": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/logs/jdk25-openai-3072-1m-2bit-pq-t1.log", - "csv": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk25-openai-3072-1m-2bit-pq/logging/jdk25-openai-3072-1m-2bit-pq-t1-20261029-033537Z/experiments.csv", - "rows": [ - { - "schema_version": "1", - "run_id": "jdk25-openai-3072-1m-2bit-pq-t1-20261029-033537Z", - "run_uuid": "8169c1eb-d6b4-4c43-9890-899a8c4762ce", - "system_id": "eafa45791612", - "dataset_name": "openai-3072-1m", - "M": "32", - "efConstruction": "100", - "neighborOverflow": "1.2", - "addHierarchy": "true", - "refineFinalGraph": "true", - "feature_set": "[NVQ_VECTORS, FUSED_PQ]", - "build_compressor": "\"ProductQuantization(M=389, clusters=256, centered=false)\"", - "search_compressor": "None", - "usePruning": "false", - "topK": "10", - "overquery": "1.0", - "rerankK": "10", - "search.throughput.avg_qps": "2170.3203408432223", - "search.throughput.stddev_qps": "257.79510337346886", - "search.throughput.cv_pct": "11.878205190359557", - "search.count.avg_visited": "466.217", - "search.accuracy.recall_at_10": "0.651", - "construction.index_build_time_s": "", - "construction.index_quant_time_s.NVQ.compute_time_s": "", - "construction.index_quant_time_s.PQ.compute_time_s": "", - "construction.index_quant_time_s.PQ.encoding_time_s": "" - }, - { - "schema_version": "1", - "run_id": "jdk25-openai-3072-1m-2bit-pq-t1-20261029-033537Z", - "run_uuid": "8169c1eb-d6b4-4c43-9890-899a8c4762ce", - "system_id": "eafa45791612", - "dataset_name": "openai-3072-1m", - "M": "32", - "efConstruction": "100", - "neighborOverflow": "1.2", - "addHierarchy": "true", - "refineFinalGraph": "true", - "feature_set": "[NVQ_VECTORS, FUSED_PQ]", - "build_compressor": "\"ProductQuantization(M=389, clusters=256, centered=false)\"", - "search_compressor": "None", - "usePruning": "false", - "topK": "10", - "overquery": "2.0", - "rerankK": "20", - "search.throughput.avg_qps": "2068.413509232902", - "search.throughput.stddev_qps": "11.500305926225575", - "search.throughput.cv_pct": "0.5559964617757023", - "search.count.avg_visited": "646.845", - "search.accuracy.recall_at_10": "0.8032", - "construction.index_build_time_s": "", - "construction.index_quant_time_s.NVQ.compute_time_s": "", - "construction.index_quant_time_s.PQ.compute_time_s": "", - "construction.index_quant_time_s.PQ.encoding_time_s": "" - } - ], - "throughput_samples": [ - { - "sample": "Test-0", - "queries": 3000, - "seconds": 1.598987 - }, - { - "sample": "Test-1", - "queries": 3000, - "seconds": 1.317101 - }, - { - "sample": "Test-2", - "queries": 3000, - "seconds": 1.272781 - }, - { - "sample": "Test-0", - "queries": 3000, - "seconds": 1.459214 - }, - { - "sample": "Test-1", - "queries": 3000, - "seconds": 1.448704 - }, - { - "sample": "Test-2", - "queries": 3000, - "seconds": 1.443333 - } - ], - "finished": "2026-10-29T03:36:15.529324+00:00" - }, - "t8": { - "elapsed_process_seconds": 22.55379414372146, - "log": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/logs/jdk25-openai-3072-1m-2bit-pq-t8.log", - "csv": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk25-openai-3072-1m-2bit-pq/logging/jdk25-openai-3072-1m-2bit-pq-t8-20261029-033615Z/experiments.csv", - "rows": [ - { - "schema_version": "1", - "run_id": "jdk25-openai-3072-1m-2bit-pq-t8-20261029-033615Z", - "run_uuid": "2a51d587-edca-4be1-93ab-17165a227c81", - "system_id": "eafa45791612", - "dataset_name": "openai-3072-1m", - "M": "32", - "efConstruction": "100", - "neighborOverflow": "1.2", - "addHierarchy": "true", - "refineFinalGraph": "true", - "feature_set": "[NVQ_VECTORS, FUSED_PQ]", - "build_compressor": "\"ProductQuantization(M=389, clusters=256, centered=false)\"", - "search_compressor": "None", - "usePruning": "false", - "topK": "10", - "overquery": "1.0", - "rerankK": "10", - "search.throughput.avg_qps": "27772.466077225967", - "search.throughput.stddev_qps": "451.6869097187096", - "search.throughput.cv_pct": "1.6263838740957284", - "search.count.avg_visited": "466.217", - "search.accuracy.recall_at_10": "0.651", - "construction.index_build_time_s": "", - "construction.index_quant_time_s.NVQ.compute_time_s": "", - "construction.index_quant_time_s.PQ.compute_time_s": "", - "construction.index_quant_time_s.PQ.encoding_time_s": "" - }, - { - "schema_version": "1", - "run_id": "jdk25-openai-3072-1m-2bit-pq-t8-20261029-033615Z", - "run_uuid": "2a51d587-edca-4be1-93ab-17165a227c81", - "system_id": "eafa45791612", - "dataset_name": "openai-3072-1m", - "M": "32", - "efConstruction": "100", - "neighborOverflow": "1.2", - "addHierarchy": "true", - "refineFinalGraph": "true", - "feature_set": "[NVQ_VECTORS, FUSED_PQ]", - "build_compressor": "\"ProductQuantization(M=389, clusters=256, centered=false)\"", - "search_compressor": "None", - "usePruning": "false", - "topK": "10", - "overquery": "2.0", - "rerankK": "20", - "search.throughput.avg_qps": "23213.773739290547", - "search.throughput.stddev_qps": "250.71842094376962", - "search.throughput.cv_pct": "1.0800416328665052", - "search.count.avg_visited": "646.845", - "search.accuracy.recall_at_10": "0.8032", - "construction.index_build_time_s": "", - "construction.index_quant_time_s.NVQ.compute_time_s": "", - "construction.index_quant_time_s.PQ.compute_time_s": "", - "construction.index_quant_time_s.PQ.encoding_time_s": "" - } - ], - "throughput_samples": [ - { - "sample": "Test-0", - "queries": 28000, - "seconds": 1.020852 - }, - { - "sample": "Test-1", - "queries": 29000, - "seconds": 1.025319 - }, - { - "sample": "Test-2", - "queries": 28000, - "seconds": 1.014293 - }, - { - "sample": "Test-0", - "queries": 24000, - "seconds": 1.038152 - }, - { - "sample": "Test-1", - "queries": 24000, - "seconds": 1.042343 - }, - { - "sample": "Test-2", - "queries": 24000, - "seconds": 1.021352 - } - ], - "finished": "2026-10-29T03:36:38.084606+00:00" - } - }, - "index_files": [ - { - "path": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk25-openai-3072-1m-2bit-pq/index_cache/graph_openai_3072_1m_FUSED_PQ_NVQ_VECTORS_M32_ef100_of1.2_alpha1.2_H1_R1_ProductQuantization_M_389_clusters_256_centered_false_", - "bytes": 15723256259 - } - ], - "finished": "2026-10-29T03:36:38.085181+00:00" - }, - { - "dataset": "openai-3072-1m", - "display_name": "OpenAI3072", - "original_dimensions": "3072", - "projected_ash_dimensions": "1536", - "base_vectors": "999000", - "queries": "1000", - "ash_bits": "4", - "algorithm": "ASH", - "code_bytes": "773", - "ash_encoded_bits": "6184", - "pq_m": "", - "config": "openai-3072-1m-4bit-budget-ash.yml", - "jdk": "25", - "label": "jdk25-openai-3072-1m-4bit-ash", - "path": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk25-openai-3072-1m-4bit-ash", - "source": "507b74e7ef8a0e12fcfe402deb4bf758c65758d5", - "started": "2026-10-29T03:36:38.093050+00:00", - "phases": { - "build": { - "elapsed_process_seconds": 213.53237825259566, - "log": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/logs/jdk25-openai-3072-1m-4bit-ash-build.log", - "csv": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk25-openai-3072-1m-4bit-ash/logging/jdk25-openai-3072-1m-4bit-ash-build-20261029-033638Z/experiments.csv", - "rows": [ - { - "schema_version": "1", - "run_id": "jdk25-openai-3072-1m-4bit-ash-build-20261029-033638Z", - "run_uuid": "6b505122-3861-46b7-97b6-11f4738cf959", - "system_id": "eafa45791612", - "dataset_name": "openai-3072-1m", - "M": "32", - "efConstruction": "100", - "neighborOverflow": "1.2", - "addHierarchy": "true", - "refineFinalGraph": "true", - "feature_set": "[NVQ_VECTORS, FUSED_ASH]", - "build_compressor": "\"AsymmetricHashing[origDim=3072, encodedBits=6184, bodyBits=6144, quantizedDim=1536, bitsPerDimension=4, optimizer=ITQ, landmarks=1]\"", - "search_compressor": "None", - "usePruning": "false", - "topK": "10", - "overquery": "1.0", - "rerankK": "10", - "search.accuracy.recall_at_10": "0.7965", - "construction.index_build_time_s": "130.569217494", - "construction.index_quant_time_s.ASH.compute_time_s": "38.63113482", - "construction.index_quant_time_s.ASH.encoding_time_s": "25.960155066", - "construction.index_quant_time_s.NVQ.compute_time_s": "1.1549786" - } - ], - "throughput_samples": [], - "finished": "2026-10-29T03:40:11.627475+00:00" - }, - "t1": { - "elapsed_process_seconds": 53.90180575661361, - "log": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/logs/jdk25-openai-3072-1m-4bit-ash-t1.log", - "csv": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk25-openai-3072-1m-4bit-ash/logging/jdk25-openai-3072-1m-4bit-ash-t1-20261029-034012Z/experiments.csv", - "rows": [ - { - "schema_version": "1", - "run_id": "jdk25-openai-3072-1m-4bit-ash-t1-20261029-034012Z", - "run_uuid": "faf18127-3224-4f0e-a5c9-7530ecefcc56", - "system_id": "eafa45791612", - "dataset_name": "openai-3072-1m", - "M": "32", - "efConstruction": "100", - "neighborOverflow": "1.2", - "addHierarchy": "true", - "refineFinalGraph": "true", - "feature_set": "[NVQ_VECTORS, FUSED_ASH]", - "build_compressor": "\"AsymmetricHashing[origDim=3072, encodedBits=6184, bodyBits=6144, quantizedDim=1536, bitsPerDimension=4, optimizer=ITQ, landmarks=1]\"", - "search_compressor": "None", - "usePruning": "false", - "topK": "10", - "overquery": "1.0", - "rerankK": "10", - "search.throughput.avg_qps": "964.9450620510337", - "search.throughput.stddev_qps": "132.69872297613534", - "search.throughput.cv_pct": "13.751945908099502", - "search.count.avg_visited": "510.697", - "search.accuracy.recall_at_10": "0.7965", - "construction.index_build_time_s": "", - "construction.index_quant_time_s.ASH.compute_time_s": "", - "construction.index_quant_time_s.ASH.encoding_time_s": "", - "construction.index_quant_time_s.NVQ.compute_time_s": "" - }, - { - "schema_version": "1", - "run_id": "jdk25-openai-3072-1m-4bit-ash-t1-20261029-034012Z", - "run_uuid": "faf18127-3224-4f0e-a5c9-7530ecefcc56", - "system_id": "eafa45791612", - "dataset_name": "openai-3072-1m", - "M": "32", - "efConstruction": "100", - "neighborOverflow": "1.2", - "addHierarchy": "true", - "refineFinalGraph": "true", - "feature_set": "[NVQ_VECTORS, FUSED_ASH]", - "build_compressor": "\"AsymmetricHashing[origDim=3072, encodedBits=6184, bodyBits=6144, quantizedDim=1536, bitsPerDimension=4, optimizer=ITQ, landmarks=1]\"", - "search_compressor": "None", - "usePruning": "false", - "topK": "10", - "overquery": "2.0", - "rerankK": "20", - "search.throughput.avg_qps": "1033.6181669420773", - "search.throughput.stddev_qps": "9.002443152648716", - "search.throughput.cv_pct": "0.8709640987911547", - "search.count.avg_visited": "730.922", - "search.accuracy.recall_at_10": "0.8875", - "construction.index_build_time_s": "", - "construction.index_quant_time_s.ASH.compute_time_s": "", - "construction.index_quant_time_s.ASH.encoding_time_s": "", - "construction.index_quant_time_s.NVQ.compute_time_s": "" - } - ], - "throughput_samples": [ - { - "sample": "Test-0", - "queries": 1000, - "seconds": 1.200646 - }, - { - "sample": "Test-1", - "queries": 2000, - "seconds": 1.821041 - }, - { - "sample": "Test-2", - "queries": 1000, - "seconds": 1.037692 - }, - { - "sample": "Test-0", - "queries": 2000, - "seconds": 1.954478 - }, - { - "sample": "Test-1", - "queries": 2000, - "seconds": 1.927239 - }, - { - "sample": "Test-2", - "queries": 2000, - "seconds": 1.923429 - } - ], - "finished": "2026-10-29T03:41:05.537347+00:00" - }, - "t8": { - "elapsed_process_seconds": 24.775268671102822, - "log": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/logs/jdk25-openai-3072-1m-4bit-ash-t8.log", - "csv": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk25-openai-3072-1m-4bit-ash/logging/jdk25-openai-3072-1m-4bit-ash-t8-20261029-034105Z/experiments.csv", - "rows": [ - { - "schema_version": "1", - "run_id": "jdk25-openai-3072-1m-4bit-ash-t8-20261029-034105Z", - "run_uuid": "06ea99f1-ef19-4f81-a1f3-baf497b2e990", - "system_id": "eafa45791612", - "dataset_name": "openai-3072-1m", - "M": "32", - "efConstruction": "100", - "neighborOverflow": "1.2", - "addHierarchy": "true", - "refineFinalGraph": "true", - "feature_set": "[NVQ_VECTORS, FUSED_ASH]", - "build_compressor": "\"AsymmetricHashing[origDim=3072, encodedBits=6184, bodyBits=6144, quantizedDim=1536, bitsPerDimension=4, optimizer=ITQ, landmarks=1]\"", - "search_compressor": "None", - "usePruning": "false", - "topK": "10", - "overquery": "1.0", - "rerankK": "10", - "search.throughput.avg_qps": "7915.294433750889", - "search.throughput.stddev_qps": "199.40736877030656", - "search.throughput.cv_pct": "2.519266597588988", - "search.count.avg_visited": "510.697", - "search.accuracy.recall_at_10": "0.7965", - "construction.index_build_time_s": "", - "construction.index_quant_time_s.ASH.compute_time_s": "", - "construction.index_quant_time_s.ASH.encoding_time_s": "", - "construction.index_quant_time_s.NVQ.compute_time_s": "" - }, - { - "schema_version": "1", - "run_id": "jdk25-openai-3072-1m-4bit-ash-t8-20261029-034105Z", - "run_uuid": "06ea99f1-ef19-4f81-a1f3-baf497b2e990", - "system_id": "eafa45791612", - "dataset_name": "openai-3072-1m", - "M": "32", - "efConstruction": "100", - "neighborOverflow": "1.2", - "addHierarchy": "true", - "refineFinalGraph": "true", - "feature_set": "[NVQ_VECTORS, FUSED_ASH]", - "build_compressor": "\"AsymmetricHashing[origDim=3072, encodedBits=6184, bodyBits=6144, quantizedDim=1536, bitsPerDimension=4, optimizer=ITQ, landmarks=1]\"", - "search_compressor": "None", - "usePruning": "false", - "topK": "10", - "overquery": "2.0", - "rerankK": "20", - "search.throughput.avg_qps": "7489.111448400777", - "search.throughput.stddev_qps": "101.00421873730507", - "search.throughput.cv_pct": "1.34868094076599", - "search.count.avg_visited": "730.922", - "search.accuracy.recall_at_10": "0.8875", - "construction.index_build_time_s": "", - "construction.index_quant_time_s.ASH.compute_time_s": "", - "construction.index_quant_time_s.ASH.encoding_time_s": "", - "construction.index_quant_time_s.NVQ.compute_time_s": "" - } - ], - "throughput_samples": [ - { - "sample": "Test-0", - "queries": 8000, - "seconds": 1.039072 - }, - { - "sample": "Test-1", - "queries": 8000, - "seconds": 1.005713 - }, - { - "sample": "Test-2", - "queries": 9000, - "seconds": 1.112189 - }, - { - "sample": "Test-0", - "queries": 8000, - "seconds": 1.05616 - }, - { - "sample": "Test-1", - "queries": 8000, - "seconds": 1.064529 - }, - { - "sample": "Test-2", - "queries": 8000, - "seconds": 1.084354 - } - ], - "finished": "2026-10-29T03:41:30.314433+00:00" - } - }, - "index_files": [ - { - "path": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk25-openai-3072-1m-4bit-ash/index_cache/graph_openai_3072_1m_FUSED_ASH_NVQ_VECTORS_M32_ef100_of1.2_alpha1.2_H1_R1_AsymmetricHashing_origDim_3072_encodedBits_6184_bodyBits_6144_quantizedDim_1536_bitsPerDimension_4_optimizer_ITQ_landmarks_1_", - "bytes": 28042443654 - } - ], - "finished": "2026-10-29T03:41:30.315002+00:00" - }, - { - "dataset": "openai-3072-1m", - "display_name": "OpenAI3072", - "original_dimensions": "3072", - "projected_ash_dimensions": "1536", - "base_vectors": "999000", - "queries": "1000", - "ash_bits": "4", - "algorithm": "PQ", - "code_bytes": "773", - "ash_encoded_bits": "", - "pq_m": "773", - "config": "openai-3072-1m-4bit-budget-pq.yml", - "jdk": "25", - "label": "jdk25-openai-3072-1m-4bit-pq", - "path": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk25-openai-3072-1m-4bit-pq", - "source": "507b74e7ef8a0e12fcfe402deb4bf758c65758d5", - "started": "2026-10-29T03:41:30.322936+00:00", - "phases": { - "build": { - "elapsed_process_seconds": 281.5635087201372, - "log": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/logs/jdk25-openai-3072-1m-4bit-pq-build.log", - "csv": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk25-openai-3072-1m-4bit-pq/logging/jdk25-openai-3072-1m-4bit-pq-build-20261029-034130Z/experiments.csv", - "rows": [ - { - "schema_version": "1", - "run_id": "jdk25-openai-3072-1m-4bit-pq-build-20261029-034130Z", - "run_uuid": "43b54b2a-4f1a-4229-aa99-1bfef4eeed5b", - "system_id": "eafa45791612", - "dataset_name": "openai-3072-1m", - "M": "32", - "efConstruction": "100", - "neighborOverflow": "1.2", - "addHierarchy": "true", - "refineFinalGraph": "true", - "feature_set": "[NVQ_VECTORS, FUSED_PQ]", - "build_compressor": "\"ProductQuantization(M=773, clusters=256, centered=false)\"", - "search_compressor": "None", - "usePruning": "false", - "topK": "10", - "overquery": "1.0", - "rerankK": "10", - "search.accuracy.recall_at_10": "0.7624", - "construction.index_build_time_s": "247.52862079", - "construction.index_quant_time_s.NVQ.compute_time_s": "0.962668797", - "construction.index_quant_time_s.PQ.compute_time_s": "16.624147058", - "construction.index_quant_time_s.PQ.encoding_time_s": "7.778564805" - } - ], - "throughput_samples": [], - "finished": "2026-10-29T03:46:11.888604+00:00" - }, - "t1": { - "elapsed_process_seconds": 39.05060641467571, - "log": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/logs/jdk25-openai-3072-1m-4bit-pq-t1.log", - "csv": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk25-openai-3072-1m-4bit-pq/logging/jdk25-openai-3072-1m-4bit-pq-t1-20261029-034612Z/experiments.csv", - "rows": [ - { - "schema_version": "1", - "run_id": "jdk25-openai-3072-1m-4bit-pq-t1-20261029-034612Z", - "run_uuid": "e70efc29-4365-409e-ac20-fec8da03aefb", - "system_id": "eafa45791612", - "dataset_name": "openai-3072-1m", - "M": "32", - "efConstruction": "100", - "neighborOverflow": "1.2", - "addHierarchy": "true", - "refineFinalGraph": "true", - "feature_set": "[NVQ_VECTORS, FUSED_PQ]", - "build_compressor": "\"ProductQuantization(M=773, clusters=256, centered=false)\"", - "search_compressor": "None", - "usePruning": "false", - "topK": "10", - "overquery": "1.0", - "rerankK": "10", - "search.throughput.avg_qps": "2280.759101685725", - "search.throughput.stddev_qps": "228.66633119364224", - "search.throughput.cv_pct": "10.025887040180322", - "search.count.avg_visited": "501.437", - "search.accuracy.recall_at_10": "0.7624", - "construction.index_build_time_s": "", - "construction.index_quant_time_s.NVQ.compute_time_s": "", - "construction.index_quant_time_s.PQ.compute_time_s": "", - "construction.index_quant_time_s.PQ.encoding_time_s": "" - }, - { - "schema_version": "1", - "run_id": "jdk25-openai-3072-1m-4bit-pq-t1-20261029-034612Z", - "run_uuid": "e70efc29-4365-409e-ac20-fec8da03aefb", - "system_id": "eafa45791612", - "dataset_name": "openai-3072-1m", - "M": "32", - "efConstruction": "100", - "neighborOverflow": "1.2", - "addHierarchy": "true", - "refineFinalGraph": "true", - "feature_set": "[NVQ_VECTORS, FUSED_PQ]", - "build_compressor": "\"ProductQuantization(M=773, clusters=256, centered=false)\"", - "search_compressor": "None", - "usePruning": "false", - "topK": "10", - "overquery": "2.0", - "rerankK": "20", - "search.throughput.avg_qps": "1886.2499788294274", - "search.throughput.stddev_qps": "10.71340400192702", - "search.throughput.cv_pct": "0.5679737109169148", - "search.count.avg_visited": "705.403", - "search.accuracy.recall_at_10": "0.8727", - "construction.index_build_time_s": "", - "construction.index_quant_time_s.NVQ.compute_time_s": "", - "construction.index_quant_time_s.PQ.compute_time_s": "", - "construction.index_quant_time_s.PQ.encoding_time_s": "" - } - ], - "throughput_samples": [ - { - "sample": "Test-0", - "queries": 3000, - "seconds": 1.487416 - }, - { - "sample": "Test-1", - "queries": 3000, - "seconds": 1.248072 - }, - { - "sample": "Test-2", - "queries": 3000, - "seconds": 1.238825 - }, - { - "sample": "Test-0", - "queries": 2000, - "seconds": 1.067022 - }, - { - "sample": "Test-1", - "queries": 2000, - "seconds": 1.05866 - }, - { - "sample": "Test-2", - "queries": 2000, - "seconds": 1.055302 - } - ], - "finished": "2026-10-29T03:46:50.940557+00:00" - }, - "t8": { - "elapsed_process_seconds": 22.850516594946384, - "log": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/logs/jdk25-openai-3072-1m-4bit-pq-t8.log", - "csv": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk25-openai-3072-1m-4bit-pq/logging/jdk25-openai-3072-1m-4bit-pq-t8-20261029-034651Z/experiments.csv", - "rows": [ - { - "schema_version": "1", - "run_id": "jdk25-openai-3072-1m-4bit-pq-t8-20261029-034651Z", - "run_uuid": "db0e04eb-cab5-422f-86bb-0e27f94aace0", - "system_id": "eafa45791612", - "dataset_name": "openai-3072-1m", - "M": "32", - "efConstruction": "100", - "neighborOverflow": "1.2", - "addHierarchy": "true", - "refineFinalGraph": "true", - "feature_set": "[NVQ_VECTORS, FUSED_PQ]", - "build_compressor": "\"ProductQuantization(M=773, clusters=256, centered=false)\"", - "search_compressor": "None", - "usePruning": "false", - "topK": "10", - "overquery": "1.0", - "rerankK": "10", - "search.throughput.avg_qps": "15663.804691288786", - "search.throughput.stddev_qps": "513.7893416245771", - "search.throughput.cv_pct": "3.280105643236947", - "search.count.avg_visited": "501.437", - "search.accuracy.recall_at_10": "0.7624", - "construction.index_build_time_s": "", - "construction.index_quant_time_s.NVQ.compute_time_s": "", - "construction.index_quant_time_s.PQ.compute_time_s": "", - "construction.index_quant_time_s.PQ.encoding_time_s": "" - }, - { - "schema_version": "1", - "run_id": "jdk25-openai-3072-1m-4bit-pq-t8-20261029-034651Z", - "run_uuid": "db0e04eb-cab5-422f-86bb-0e27f94aace0", - "system_id": "eafa45791612", - "dataset_name": "openai-3072-1m", - "M": "32", - "efConstruction": "100", - "neighborOverflow": "1.2", - "addHierarchy": "true", - "refineFinalGraph": "true", - "feature_set": "[NVQ_VECTORS, FUSED_PQ]", - "build_compressor": "\"ProductQuantization(M=773, clusters=256, centered=false)\"", - "search_compressor": "None", - "usePruning": "false", - "topK": "10", - "overquery": "2.0", - "rerankK": "20", - "search.throughput.avg_qps": "12884.883579153513", - "search.throughput.stddev_qps": "87.90944686405366", - "search.throughput.cv_pct": "0.6822680726916507", - "search.count.avg_visited": "705.403", - "search.accuracy.recall_at_10": "0.8727", - "construction.index_build_time_s": "", - "construction.index_quant_time_s.NVQ.compute_time_s": "", - "construction.index_quant_time_s.PQ.compute_time_s": "", - "construction.index_quant_time_s.PQ.encoding_time_s": "" - } - ], - "throughput_samples": [ - { - "sample": "Test-0", - "queries": 16000, - "seconds": 1.059179 - }, - { - "sample": "Test-1", - "queries": 17000, - "seconds": 1.054736 - }, - { - "sample": "Test-2", - "queries": 16000, - "seconds": 1.014739 - }, - { - "sample": "Test-0", - "queries": 13000, - "seconds": 1.012352 - }, - { - "sample": "Test-1", - "queries": 14000, - "seconds": 1.078079 - }, - { - "sample": "Test-2", - "queries": 13000, - "seconds": 1.013471 - } - ], - "finished": "2026-10-29T03:47:13.792536+00:00" - } - }, - "index_files": [ - { - "path": "/home/ted_willke/benchmark-runs/graph-pq-vs-trained-ash-native-jdk22-25-20261028/cases/jdk25-openai-3072-1m-4bit-pq/index_cache/graph_openai_3072_1m_FUSED_PQ_NVQ_VECTORS_M32_ef100_of1.2_alpha1.2_H1_R1_ProductQuantization_M_773_clusters_256_centered_false_", - "bytes": 28010963369 - } - ], - "finished": "2026-10-29T03:47:13.793206+00:00" - } -] diff --git a/docs/benchmarks/native-pq-ash-graph-20261028/report.md b/docs/benchmarks/native-pq-ash-graph-20261028/report.md deleted file mode 100644 index 9bffcbcea..000000000 --- a/docs/benchmarks/native-pq-ash-graph-20261028/report.md +++ /dev/null @@ -1,90 +0,0 @@ -# Native PQ versus trained ASH with graph indexing - -Standard BenchYAML with ASH PCA correction `507b74e7ef8a0e12fcfe402deb4bf758c65758d5`. Four complete cleaned datasets, both JDK 22 and JDK 25, two exact code-byte budgets. All 32 final graphs and 64 cached query processes completed. No changes to BenchYAML or its timers were used. - -A native PCA correctness failure interrupted the first collection. The QR SVD fast path returned incorrect decompositions on unchanged finite input with 48 MKL threads on both JDKs, sometimes reporting success. ASH PCA now uses a validated divide-and-conquer SVD. All ASH results below were recollected after the fix; earlier ASH graph records are archived as superseded. Nine completed PQ cases from source b44e25b7 were retained: every non-ASH runtime file was verified byte-for-byte identical after the correction. Each raw record retains its actual source SHA. See [the PCA investigation](../../ash-pca-native-svd.md) and [the runtime audit](pca-fix-runtime-audit.json). - -Both algorithms use the native Highway provider, format V7, M=32, efConstruction=100, overflow=1.2, hierarchy and final refinement enabled, deferred parallel complete-record writes (batch 64), and NVQ reranking (two 8-bit subvectors). ASH uses C=1, ITQ training with production defaults, projected dimension D/2, and 2 or 4 bits per projected dimension. PQ uses K=256, no centering and anisotropy -1. Search pruning is disabled. Each query count searches the identical saved graph. - -The existing configured system opts ASH into adaptive DOT_PRODUCT stopping; PQ uses its existing stopping path. `useSearchPruning: No` does not disable that separate ASH policy. The comparison includes these current construction and stopping policies; it does not isolate quantizer error alone. No policy was changed in response to measured results. - -Compression budgets match stored ASH/PQ code bytes, including ASH’s 5-byte header. Shared models, graph structure and NVQ storage are additional. “2-bit/4-bit budget” refers to the ASH setting, not PQ bits per original dimension. - -| Dataset | Base vectors | Queries | D → ASH d | Code bytes, 2-bit / 4-bit budget | -|---|---:|---:|---:|---:| -| MPNet | 999,812 | 10,000 | 768 → 384 | 101 / 197 | -| Cohere | 1,000,000 | 10,000 | 1024 → 512 | 133 / 261 | -| Ada002 | 982,790 | 10,000 | 1536 → 768 | 197 / 389 | -| OpenAI3072 | 999,000 | 1,000 | 3072 → 1536 | 389 / 773 | - -## Construction — seconds - -Each cell is **PQ → ASH**. Training, encoding and graph/write are the harness’s existing CSV measurements. They are reported as measured, without replacing the timers or subtracting work. - -**Total process** is externally measured wall time of the fresh construction JVM, including startup, dataset loading, construction, one standard recall@10 validation pass at 1×, and teardown. It includes that accuracy pass; it is not labeled build-only. QPS warmup and measurement run in separate cached JVMs and are excluded. NVQ setup/encoding and other setup costs remain part of the actual process; the displayed phase columns need not sum to total process. - -| Dataset | JDK | ASH bit budget | Training | Encoding | Graph/write | Total process incl. validation | -|---|---:|---:|---:|---:|---:|---:| -| MPNet | 22 | 2 | 2.73 → 5.21 | 1.08 → 1.09 | 44.44 → 58.37 | 52.27 → 70.72 | -| MPNet | 22 | 4 | 6.14 → 4.69 | 1.71 → 1.19 | 50.79 → 42.03 | 62.75 → 54.34 | -| MPNet | 25 | 2 | 2.77 → 4.80 | 1.05 → 1.00 | 33.77 → 31.63 | 41.55 → 43.57 | -| MPNet | 25 | 4 | 4.65 → 4.45 | 1.75 → 1.20 | 48.55 → 43.29 | 58.95 → 54.84 | -| Cohere | 22 | 2 | 2.97 → 8.03 | 1.32 → 1.86 | 35.40 → 62.37 | 44.33 → 96.01 | -| Cohere | 22 | 4 | 6.75 → 6.62 | 2.30 → 2.17 | 70.71 → 49.73 | 84.35 → 66.19 | -| Cohere | 25 | 2 | 3.08 → 7.50 | 1.33 → 1.64 | 40.66 → 32.97 | 49.73 → 52.84 | -| Cohere | 25 | 4 | 6.57 → 6.46 | 2.23 → 2.06 | 66.55 → 46.25 | 80.16 → 62.54 | -| Ada002 | 22 | 2 | 3.93 → 13.71 | 2.20 → 7.91 | 48.12 → 49.60 | 60.27 → 81.40 | -| Ada002 | 22 | 4 | 8.62 → 10.95 | 3.61 → 7.43 | 100.92 → 56.91 | 119.42 → 85.25 | -| Ada002 | 25 | 2 | 3.72 → 13.82 | 2.05 → 8.28 | 42.70 → 45.40 | 54.51 → 81.50 | -| Ada002 | 25 | 4 | 8.81 → 13.05 | 3.51 → 7.32 | 106.25 → 60.39 | 125.37 → 90.96 | -| OpenAI3072 | 22 | 2 | 6.51 → 41.12 | 5.51 → 24.10 | 90.50 → 85.71 | 113.08 → 169.55 | -| OpenAI3072 | 22 | 4 | 19.65 → 39.54 | 7.64 → 27.21 | 257.82 → 124.33 | 295.42 → 208.44 | -| OpenAI3072 | 25 | 2 | 5.90 → 39.41 | 5.58 → 24.28 | 113.01 → 87.03 | 135.15 → 170.94 | -| OpenAI3072 | 25 | 4 | 16.62 → 38.63 | 7.78 → 25.96 | 247.53 → 130.57 | 281.56 → 213.53 | - -## Queries — recall@10 and QPS - -Each cell is **PQ → ASH**. Recall is a percentage. 1× and 2× rerank 10 and 20 candidates respectively to return 10. QPS includes the graph search, normal query preparation and NVQ reranking. Construction uses 48 physical cores; throughput uses 1 or 8 scoring threads, pinned to 1 or 8 physical cores. Standard BenchYAML warmup and three measured samples are retained. No JIT compilation logs, per-call tracing or profiler runs were used for these measurements. - -### 2-bit ASH code-byte budget - -| Dataset | JDK | Recall 1× (%) | Recall 2× (%) | 1 thread, 1× QPS | 1 thread, 2× QPS | 8 threads, 1× QPS | 8 threads, 2× QPS | -|---|---:|---:|---:|---:|---:|---:|---:| -| MPNet | 22 | 65.13 → 81.94 | 82.13 → 94.06 | 11,930 → 9,611 | 9,441 → 7,822 | 86,215 → 70,990 | 68,760 → 59,039 | -| MPNet | 25 | 65.48 → 82.02 | 82.21 → 93.97 | 11,812 → 10,357 | 9,294 → 8,282 | 76,802 → 73,841 | 71,112 → 60,434 | -| Cohere | 22 | 59.43 → 63.90 | 78.29 → 81.99 | 9,695 → 6,041 | 7,700 → 5,224 | 74,000 → 47,759 | 59,165 → 39,833 | -| Cohere | 25 | 59.13 → 63.98 | 78.45 → 82.33 | 9,412 → 6,427 | 7,191 → 5,258 | 75,638 → 50,148 | 57,863 → 41,902 | -| Ada002 | 22 | 54.99 → 78.88 | 75.35 → 92.69 | 7,065 → 3,301 | 5,685 → 2,987 | 53,847 → 24,911 | 45,513 → 22,128 | -| Ada002 | 25 | 54.59 → 79.06 | 75.04 → 92.72 | 7,271 → 3,437 | 5,712 → 3,097 | 49,407 → 25,149 | 43,938 → 22,643 | -| OpenAI3072 | 22 | 64.37 → 77.92 | 79.85 → 89.80 | 3,329 → 1,031 | 2,954 → 1,068 | 27,544 → 7,190 | 22,785 → 7,037 | -| OpenAI3072 | 25 | 65.10 → 76.76 | 80.32 → 89.17 | 2,170 → 1,003 | 2,068 → 1,094 | 27,772 → 8,050 | 23,214 → 7,893 | - -### 4-bit ASH code-byte budget - -| Dataset | JDK | Recall 1× (%) | Recall 2× (%) | 1 thread, 1× QPS | 1 thread, 2× QPS | 8 threads, 1× QPS | 8 threads, 2× QPS | -|---|---:|---:|---:|---:|---:|---:|---:| -| MPNet | 22 | 80.73 → 86.64 | 92.75 → 93.97 | 9,643 → 8,630 | 7,456 → 6,976 | 74,893 → 64,520 | 57,187 → 52,279 | -| MPNet | 25 | 80.75 → 86.29 | 92.60 → 94.03 | 10,001 → 9,154 | 7,557 → 7,115 | 74,887 → 67,390 | 56,905 → 54,358 | -| Cohere | 22 | 65.08 → 70.69 | 81.04 → 83.40 | 7,656 → 5,460 | 5,826 → 4,610 | 57,545 → 41,673 | 43,366 → 35,207 | -| Cohere | 25 | 65.53 → 70.59 | 81.36 → 83.70 | 7,858 → 5,662 | 5,921 → 4,636 | 57,671 → 42,689 | 43,805 → 35,645 | -| Ada002 | 22 | 73.56 → 83.23 | 90.05 → 92.44 | 5,341 → 2,976 | 4,200 → 2,699 | 41,039 → 21,871 | 30,913 → 19,259 | -| Ada002 | 25 | 73.57 → 83.53 | 90.15 → 92.47 | 3,665 → 2,959 | 3,884 → 2,650 | 26,980 → 21,983 | 22,436 → 20,076 | -| OpenAI3072 | 22 | 76.91 → 79.03 | 87.70 → 88.34 | 2,144 → 1,016 | 1,871 → 1,031 | 18,467 → 7,508 | 14,336 → 7,361 | -| OpenAI3072 | 25 | 76.24 → 79.65 | 87.27 → 88.75 | 2,281 → 965 | 1,886 → 1,034 | 15,664 → 7,915 | 12,885 → 7,489 | - -## Observations - -- At 1× overquery, ASH has higher recall in 16/16 matched graph cases, ties in 0, and lower recall in 0. Recall differences range from +2.12 to +24.47 percentage points. -- At 2× overquery, ASH has higher recall in 16/16 matched graph cases, ties in 0, and lower recall in 0. Recall differences range from +0.64 to +17.68 percentage points. -- ASH has higher QPS in 0/64 matched query cells and lower QPS in 64. The geometric mean ASH/PQ QPS ratio is 0.627. -- ASH graph/write is faster in 12/16 matched cases; its geometric mean graph/write time ratio versus PQ is 0.819. -- ASH total construction/validation process time is lower in all eight 4-bit-budget cases and higher in all eight 2-bit-budget cases. These totals include the single accuracy validation pass described above. -- Query sample CV exceeds 10% in 4/128 cells (maximum 13.75%). queries.csv includes all CVs; raw-results.json and logs retain the sample query counts and durations. Each construction case has one fresh parallel graph build; these data do not estimate graph-build variance or isolate JDK effects from graph/training nondeterminism. - -ASH query preparation evaluates the dense projection Aq once per graph query: d=D/2 row dot products, each of length D. At D=3072 this is 1,536 × 3,072 = 4,718,592 multiply-add positions before LUT construction and graph scoring. The current projection and ASH symmetric construction scorers use the existing Java Vector API helpers inherited by the native provider; the L0 neighborhood scorer uses Highway. Both algorithms select NativeVectorizationProvider, but not every helper is a C++ Highway operation. This preparation and the current upper-layer scoring path are included in QPS. No runtime breakdown was profiled, so these results do not assign a measured fraction of query time to projection. - -Native ASH neighborhood calls, deferred writer selection and AVX-512 Highway dispatch were confirmed through BenchYAML preflights on both JDKs before the measured runs. After the PCA correction, every non-AsymmetricHashing runtime file was verified identical, including the native provider, FFM wrapper and shared library. The corrected PCA was separately validated through four tall/wide BenchYAML cases on both JDKs. Full runs force the native provider; forcing it fails on provider initialization errors. Normal production ISA dispatch was used, with no benchmark CPU special casing. - -Earlier ITQ-trained ASH full-scan comparisons warrant revalidation if they used the affected QR SVD path. The earlier scalar/Panama/native scorer parity results compare scoring of the same codes and are a separate question. Existing saved models are not rewritten by this correction; retraining and rebuilding caches is needed to adopt the corrected PCA. - -Raw data: [construction.csv](construction.csv), [queries.csv](queries.csv), [comparisons.csv](comparisons.csv), [raw-results.json](raw-results.json), [summary.json](summary.json). Exact inputs and protocol: [inputs.json](inputs.json), [protocol.json](protocol.json), [source.json](source.json). The evidence archive retains configurations, commands, raw console samples, harness CSV and system metadata. diff --git a/docs/benchmarks/native-pq-ash-graph-20261028/source.json b/docs/benchmarks/native-pq-ash-graph-20261028/source.json deleted file mode 100644 index 1b1705073..000000000 --- a/docs/benchmarks/native-pq-ash-graph-20261028/source.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "head": "507b74e7ef8a0e12fcfe402deb4bf758c65758d5", - "branch": "ash-dev", - "main": "4f13ff42e93f2e7b16020de3aaeefe9372e0dd36", - "clean": true, - "benchyaml_changes": false, - "backup": "c8d8de8af52b39bc8d2f6947294ed1ec40b088c6", - "base_head": "b44e25b71e3d9c5c7a2f164610b0fdbd099d91fa", - "pca_fix": "507b74e7ef8a0e12fcfe402deb4bf758c65758d5", - "retained_pq_source": "b44e25b71e3d9c5c7a2f164610b0fdbd099d91fa", - "retained_pq_count": 9 -} diff --git a/docs/benchmarks/native-pq-ash-graph-20261028/summary.json b/docs/benchmarks/native-pq-ash-graph-20261028/summary.json deleted file mode 100644 index 0ed4f0c1a..000000000 --- a/docs/benchmarks/native-pq-ash-graph-20261028/summary.json +++ /dev/null @@ -1,45 +0,0 @@ -{ - "recall_1x": { - "ash_higher": 16, - "ties": 0, - "ash_lower": 0, - "min_delta_pp": 2.1200000000000045, - "max_delta_pp": 24.470000000000006, - "mean_delta_pp": 10.2204375 - }, - "recall_2x": { - "ash_higher": 16, - "ties": 0, - "ash_lower": 0, - "min_delta_pp": 0.6400000000000006, - "max_delta_pp": 17.67800000000001, - "mean_delta_pp": 6.2031875 - }, - "qps": { - "ash_higher": 0, - "ties": 0, - "ash_lower": 64, - "geomean_ash_over_pq": 0.6268674626435508 - }, - "construction": { - "ash_faster_graphs": 12, - "geomean_ash_over_pq_graph": 0.8194326066810679, - "geomean_ash_over_pq_process": 1.0340460672778242, - "process_by_budget": { - "2": { - "ash_faster": 0, - "cases": 8 - }, - "4": { - "ash_faster": 8, - "cases": 8 - } - } - }, - "sampling": { - "qps_cells": 128, - "max_cv_percent": 13.751945908099502, - "cells_cv_above_10_percent": 4, - "fresh_graph_replicates_per_case": 1 - } -} diff --git a/docs/figure9-graph-study/branch-audit.md b/docs/figure9-graph-study/branch-audit.md deleted file mode 100644 index 2638eb546..000000000 --- a/docs/figure9-graph-study/branch-audit.md +++ /dev/null @@ -1,36 +0,0 @@ -# ASH integration audit — 19 September 2026 - -The authoritative checkout is `/home/ted_willke/jvector-ash` on the benchmark host. `ash-dev` was fast-forwarded from `67f532a2` to `638edc37`. No production code differs from the frozen ASH JAR used in this study. No push was performed. Existing untracked `pr522-bench-*.yml` files were preserved. - -| Branch/work | Audited tip | Decision | -|---|---|---| -| ash-symmetric-scorer | 638edc37 | Integrated all 10 commits: completed symmetric math and kernels, boxed-SIMD allocation fix, diversity API, microbenchmarks, sustained query timing, concurrency checks and report. | -| ash-adaptive-integration | 479a61e5 | Already an ancestor of ash-dev. Default gamma 0.01 is included through 28d9620a. | -| ash-training-optimization | 334185a5 | Already included: parallel encoding, divide-and-conquer SVD; subsequent 20D/early-stop default b2555f84 also included. | -| ash-query-performance | 21d2d211 | Already included; subsequent projection-tail fix 2715f71f, shared query setup b1fe8846 and bulk writes 67f532a2 included. | -| ash-neighbor-uniqueness | 638edc37 | Separate worktree, same baseline, no additional changes at audit time. Remains separate. | -| ash-unique-neighbors-integration / ash-unique-neighbors-score-study | 727ec140 | Excluded: unfinished uniqueness and compatibility/version-policy work, including version 8. | -| fix-neighbor-duplicates | 112ec68e | Excluded: PR522 node-ID ordering and related cleanup/format changes. | -| origin/ash-dev-rdevulap | 75fdd2a5 | Not merged: older native 1-bit masked-add/AVX512 tuning; not a validated replacement for current packed multibit Panama kernels. | -| origin/nb-ash-dev | 320f33db | Not merged: alternative multibit driver architecture; current scorer already implements the supported multibit math/kernels. Requires its own evaluation, not an integration cherry-pick. | -| pruning-fix | 6cfc6055 | Not merged: earlier L0 pruning policy; current ash-dev deliberately disables edge pruning. | -| origin/disable-pruning | 11d876cc | Equivalent intended behavior is already present in ash-dev's usePruning API; no old-branch replay. | -| origin/adaptive-term-feature | 2eda0678 | Original design superseded by ASH-specific integrated stopping rules; no merge. | -| origin/bq-adaptive | 63db574f | BQ experiment, not completed ASH work. | -| origin/main | 1a86718b | Frozen production PQ comparator. New file-versioning policy is not pulled into ASH during the separate compatibility discussion. | - -## Byte-write fix verification - -`67f532a2` replaces the per-byte loop in `FusedASH.writeBytes` with `out.write(bytes, 0, length)`. It is an ancestor of both the original benchmark commit and integrated ash-dev. `javap -p -c` of the actual frozen `study.jar` confirms one `IndexWriter.write:([BII)V` invocation and no per-byte loop. The upstream general vector bulk-write fix `c059e869` is also an ancestor of both ASH and latest-main PQ. - -The earlier statement about an absent cleanup fix referred to `804a1d1c` on the deduplication branch, not the byte-write fix. A small independently reproduced NodeArray duplicate-at-capacity patch was set aside after the user clarified the intended fix. It is **not applied**; the source is clean apart from this documentation. The preserved patch is outside the repository at `ash-integration-cleanup-unapplied.patch`. - -## Stashes - -`preserve-asymmetric-score-study` contains an old diagnostic mode superseded by the completed construction-scoring experiment. `preserve-DistancesPQ-before-PR522` contains additional full-scan PQ-mode instrumentation whose validation was paused; it remains preserved and is not treated as finished production work. Neither stash was applied or deleted. - -## Validation - -JDK23 Maven package completed successfully on the final integrated source. Seven focused classes passed in scalar and SIMD executions, 33 tests per execution: TestNodeArray, TestNeighbors, GraphIndexBuilderTest, TestASHSymmetricScorer, TestASHGraphSearch, TestASHScoringDispatch and TestASHStandaloneVsFusedScoring. Authoritative log: `/mnt/raid10/jvector-bench/ted_willke/ash-dev-integrated-tests.log`. - -Completed benchmark observations and indexes remain valid: the integration moved a branch pointer to the already-tested source. The interrupted CAP 2-bit ASH second attempt is archived separately and is rebuilt for a complete measurement set. Cohere is prioritized on resume, with GC logs, system sampling and short JFR profiles; those profiled timings are identified in the final analysis. diff --git a/docs/figure9-graph-study/cohere-investigation.md b/docs/figure9-graph-study/cohere-investigation.md deleted file mode 100644 index 7f0c85924..000000000 --- a/docs/figure9-graph-study/cohere-investigation.md +++ /dev/null @@ -1,39 +0,0 @@ -## Cohere construction investigation - -The first 4-bit Cohere runs were anomalous. Their timings are retained in the primary tables and ranges; they are not silently replaced with faster repeats. The integrated ash-dev code is identical to the frozen ASH production code used for those runs. - -| Cohere 4-bit run | ASH graph build/write | PQ graph build/write | -|---|---:|---:| -| Primary repetition 1, no JFR | 658.45 s | 1,224.67 s | -| Primary repetition 2, short JFR capture | 158.05 s | 320.37 s | -| Additional control, no startup JFR | 203.34 s | 350.11 s | - -ASH's first/second insertion times were 511.91/89.53 seconds; cleanup 82.69/5.49 seconds; flush 63.85/63.03 seconds. This localizes that anomaly to graph computation rather than the bulk byte-write fix or SSD flush throughput. The extra control's insertion/cleanup/flush times were 105.59/5.58/92.17 seconds. - -### What the evidence establishes - -- The byte-write fix is present in the actual frozen JAR. No production-code change caused the faster second repetition. -- Source dispatch and live profiles use the intended SIMD kernels for d=512. There is no dimension-based Java dispatch to a scalar ASH kernel. This does not by itself guarantee that every Vector API operation is optimally compiled. -- During the monitored Cohere runs there was ample available memory, no swap, and zero sampled memory-pressure PSI. Each build used a fresh JVM. The pre-resume host had 340 GiB available and no benchmark JVM running. These observations cannot reconstruct the complete system state of the first repetition. -- Full-JVM GC pause totals in the monitored runs were 0.93–2.48 seconds. Large allocation volumes exist, particularly in the 2-bit PQ run, but long stop-the-world pauses do not explain the monitored build costs. JFR shows boxed Vector API objects and array allocation, including NVQ's nvqLoss path. The sampled allocation weights are estimates, not exact per-method allocation accounting. -- The active PQ construction implementation is ImmutablePQVectors' cached centroid-pair lookup. In its 4-bit profile, assembleAndSumPQ_512 was the top frame in 54.1% of execution samples, ThreadLocal.get in 25.2%, and assembleAndSum512 in 14.2%. ASH's 4-bit profile was concentrated in packed asymmetric projection scoring and symmetric packed comparisons. These are sampled CPU-stack proportions, not an exact wall-time decomposition. -- Hardware cycles/instructions/cache counters are not exposed by this VM's perf interface. Therefore these measurements do not distinguish execution throughput from cache/memory stalls precisely. Historical sar files were stale and did not cover the first runs. - -### Diagnostic comparison counts - -A separate diagnostic class counted actual pair evaluations while preserving the pruning decisions and score arithmetic. It was not added to production code. Cohere-100k is a scale control, not a substitute for measuring the original 1M execution history. - -| Method / budget | Prune calls | Candidate checks | Pair comparisons | Diagnostic build/write | -|---|---:|---:|---:|---:| -| ASH 2-bit | 661,994 | 87,840,870 | 1,503,229,262 | 13.27 s | -| ASH 4-bit | 667,048 | 91,887,956 | 1,504,819,172 | 16.91 s | -| PQ matching 2-bit | 669,293 | 82,090,179 | 1,535,016,576 | 16.10 s | -| PQ matching 4-bit | 673,297 | 87,645,603 | 1,539,002,008 | 36.35 s | - -Pair counts barely change with bit budget in this control. It supports the expected increase in cost per score, rather than a multiplicative increase in pair count at this scale. It does not identify the cause of the historical 1M outlier. - -One initial diagnostic completed its build but failed in post-build reporting because an empty throughput key still enabled the benchmark. The configuration was corrected by omitting that key and the four diagnostics were rerun successfully. The failed attempt remains archived separately and is excluded from the table above. - -### Remaining limitation - -The first 1M outlier has not yet been causally explained. A missing byte-write fix and a fixed dimensionality fallback are not supported by the evidence. Runtime compilation and execution-state effects remain hypotheses; the original runs lacked the relevant GC/JIT/system traces. The additional controls completed without reproducing the large slowdown. Their full-JVM GC pauses totaled 0.676 seconds for ASH and 1.651 seconds for PQ; available memory stayed above 321/318 GiB, respectively, with zero sampled memory-pressure PSI. Compilation logs recorded class-loading/retry events, but those events alone establish neither a persistent kernel compilation failure nor the cause of the first outlier. No speculative production fix was applied. Do not use the outlying Cohere mean as a precise capacity-planning estimate. diff --git a/docs/figure9-graph-study/gc-summary.json b/docs/figure9-graph-study/gc-summary.json deleted file mode 100644 index 8ae31d930..000000000 --- a/docs/figure9-graph-study/gc-summary.json +++ /dev/null @@ -1,30 +0,0 @@ -[ - { - "case": "cohere-english-v3-1M-b2-pq-r2", - "pauses": 183, - "pause_s": 2.4758060000000004, - "max_pause_ms": 75.863, - "reclaimed_GiB": 1423.6875 - }, - { - "case": "cohere-english-v3-1M-b2-ash-r2", - "pauses": 48, - "pause_s": 1.017786, - "max_pause_ms": 80.523, - "reclaimed_GiB": 109.443359375 - }, - { - "case": "cohere-english-v3-1M-b4-pq-r2", - "pauses": 60, - "pause_s": 0.9321240000000003, - "max_pause_ms": 72.282, - "reclaimed_GiB": 166.974609375 - }, - { - "case": "cohere-english-v3-1M-b4-ash-r2", - "pauses": 51, - "pause_s": 1.042585, - "max_pause_ms": 92.298, - "reclaimed_GiB": 146.6640625 - } -] \ No newline at end of file diff --git a/docs/figure9-graph-study/graph-concurrency.png b/docs/figure9-graph-study/graph-concurrency.png deleted file mode 100644 index cf3eb9e4e1f92fa7a1cb6f475754577d01951fd2..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 287714 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 eKbCUD)yOTXly&JV}@aHXD0{GcS$o>KnGl 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 ~w~LEg#lX&HX9bz&XF4`_&_z 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

    K|Jx!COOO$0*k4CSYzfzv+{ouFR} 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

    I%v%s$q3fO`uTC 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 Je2w34{SSz)7SI3y diff --git a/docs/figure9-graph-study/graph-construction.png b/docs/figure9-graph-study/graph-construction.png deleted file mode 100644 index 5233732a292895f1dc779e13e4bd7586ce713b21..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 110420 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 XhkCpV*^Nihx7EfOXuMFra{qq-K`?Uz diff --git a/docs/figure9-graph-study/graph-qps-recall.png b/docs/figure9-graph-study/graph-qps-recall.png deleted file mode 100644 index e40ee3a8f537f8ac8b3da5bbd47f527bc6df4747..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 198585 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 NWL+WGQ8@C*-DnSZpm-PT?uB_~D38^w8`;Ghx2A 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

    @HSUn3w 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

    cNBD-B0HXbehZ*Mqjz}=&`hnOj{vs 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 S2F_Ln2Bl-F3UNm--u_>PZ-*-Y diff --git a/docs/figure9-graph-study/jars.sha256 b/docs/figure9-graph-study/jars.sha256 deleted file mode 100644 index 8c65bd19b..000000000 --- a/docs/figure9-graph-study/jars.sha256 +++ /dev/null @@ -1,4 +0,0 @@ -6b0a8c8591ea6f5e35abdadf0709b53fa440bff01441dfc21b8f11190bc152e8 /mnt/raid10/jvector-bench/ted_willke/figure9-graph/jvector-twenty.jar -a7e2c16516d5e296d1ba95741abaeccca8b3140abbcc71bf495eb5b2212207f4 /mnt/raid10/jvector-bench/ted_willke/figure9-graph/pq-main-twenty.jar -f8aa46d6f7ba9c7e8eb5cfc2606989ae8a4f0116294b1dbaec0c380a5ef41fcd /mnt/raid10/jvector-bench/ted_willke/figure9-graph/pq-main.jar -f9e4006ba1ea135d20beb2d780c065d68a472d1f04ea500fb26afec0b0f3c799 /mnt/raid10/jvector-bench/ted_willke/figure9-graph/study.jar diff --git a/docs/figure9-graph-study/methodology.md b/docs/figure9-graph-study/methodology.md deleted file mode 100644 index 022c8f923..000000000 --- a/docs/figure9-graph-study/methodology.md +++ /dev/null @@ -1,35 +0,0 @@ -# Figure 9 graph comparison — protocol - -Authoritative host: `ted_willke@34.169.37.132`, GCP c4-standard-96-lssd. ASH source: `/home/ted_willke/jvector-ash`, `ash-symmetric-scorer` commit `922b23e3`, plus benchmark-only explicit concurrency controls, subsequently committed as `638edc37`. PQ: verified GitHub origin/main `1a86718baa2d8b72a0d134711a682875888b9646`, isolated detached worktree. Identical ThroughputBenchmark source on both. No duplicate-ID changes. - -## Matrix - -Three datasets: Ada002-1M (982,790 base vectors) and CAP-1M (1,000,000), both D=1536; Cohere English v3-1M (1,000,000), D=1024. Each has 10,000 queries. User confirmed d=D/2 to match Figure 9: respectively 768, 768, 512. ASH b=2 and b=4. Graph ASH requires C=1. Production training remains 20D, max25 iterations with early stopping; paper IVF uses C=32 and 10D. The study does not change production training defaults. - -PQ uses production JVector K=256, uncentered, anisotropy disabled. The actual fused query path is `FusedPQDecoder.DotProductDecoder`, with float partial-sum tables and SIMD `VectorUtil.assembleAndSum`; it is not Faiss 4-bit FastScan. Match exact stored quantized-vector bytes including ASH's 5-byte header: M=197/389 for Ada002/CAP and M=133/261 for Cohere, with uneven PQ subvector dimensions supported. The nominal 32X/16X paper labels refer to payload only; actual graph code compression is slightly lower once the ASH header is counted. Faiss IVF in the paper uses packed 4-bit PQ; these are different production PQ implementations. - -Graph settings: M=64, efConstruction=200, overflow1.2, hierarchy/refinement enabled, fused neighborhoods, NVQ reranking, k=10 and rerankK=10/20 (overquery1/2), search pruning disabled. ASH retains production adaptive termination gamma0.01. PQ uses latest-main behavior, which has no ASH adaptive feature. ASH writes version7, latest-main PQ version6. This is a production-branch comparison, not an isolated quantizer ablation. - -## Timing and retention - -Two independent fresh compressor/graph builds per configuration (24 total), reversed ASH/PQ order on repetition2. No shared compressor cache across fresh repetitions. Each index is retained and reopened for a 48-query-worker concurrency pass. Explicit serial/concurrent checks compare complete ranked node IDs, scores and visited count for all test queries at each overquery setting. Recall and visited measurements are outside the throughput timer. - -Single-threaded QPS is the main result. Three timed samples, each at least3seconds and a complete query batch; raw durations and query counts logged. Existing synthetic warmup remains enabled. Query concurrency uses a dedicated 48-worker pool. Grid submits insertion to PhysicalCoreExecutor with 48 workers; GraphIndexBuilder cleanup uses its default common ForkJoinPool (normally parallelism 95 on this 96-logical-CPU JVM). Both branches use this arrangement. Workers are not explicitly pinned, unlike the paper’s 48 pinned build threads. Asymmetric projection/setup remains inside each query call for ASH. The graph uses corrected symmetric node comparisons and asymmetric raw-vector insertion/query scoring. - -JDK23, vector module, native access enabled, Xmx128g, ASH SIMD single/block kernels, production accumulator/unroll settings. Explicit `jvector-twenty` classpath supplies MemorySegmentReader; reader fallback warnings are checked. Temporary files and retained indexes are on RAID10 SSDs. These1M indexes fit RAM; warmed memory-mapped query throughput is not a larger-than-memory I/O result. - -Retain graph build/write time separately from training, base encoding and provider setup. Add NVQ compressor setup to these measured phases; their sum is a measured phase subtotal, excluding uninstrumented writer initialization overhead. Process elapsed time additionally includes dataset loading, JVM startup, warmup and queries and is not called construction time. - -The paper uses the same GCP c4-standard-96-lssd/Intel Xeon 6985P-C host class, 48 build cores and single-thread queries. Its IVF implementation is C++/AVX-512 with 4096 lists and an nprobe sweep; this graph experiment uses Java/JDK23 and two overquery settings. - -No IVF reruns. Published Figure9 panels are extracted from the supplied final PDF and shown separately from measured graph points. Any comparison across index types must retain the landmark/training/PQ implementation and reranking differences above. - -Remote evidence and reusable caches: `/mnt/raid10/jvector-bench/ted_willke/figure9-graph/`. Per-case directories contain YAMLs, `index_cache`, ASH `compressor_cache` or latest-main PQ `pq_cache`, logs and completion manifests. Frozen JARs and hashes are stored at the study root. - -## Interpretation - -The runtime profile confirms that graph construction uses `ImmutablePQVectors.diversityFunctionFor(DOT_PRODUCT)`, which caches centroid-pair partial sums and calls `VectorUtil.assembleAndSumPQ`. The base `PQVectors` implementation is not the active diversity scorer in these runs. Corrected ASH construction compares packed codes. Query scoring remains asymmetric in both configurations. - -Figure9 compares published IVF search curves. It does not report complete graph or IVF index-build times. Its ASH/PQ query paths do not use this graph's NVQ reranking configuration, so higher graph terminal recall must not be attributed solely to the quantizer. Graph construction is compared directly between the two measured graph implementations. - -The exact same graph is used for each serial/concurrent comparison; independent graph repetitions are used only for build/run variability. Two repetitions provide ranges, not narrow statistical confidence intervals. diff --git a/docs/figure9-graph-study/paper-panel-0.png b/docs/figure9-graph-study/paper-panel-0.png deleted file mode 100644 index a230e036b882f3c510536691de337d1881f62644..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 328985 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
    Hx$!MMnJoIYN_EMI5S)W~LSnwq! z$waqsGM6XJ51DD9UjV`?pV083mkmc|uSig+5JgA}rh#;2ITU}XCnkXzl;c%qth1h@ z^vatR4{z+}JYRx*x}f{=DDhGYZIY78SX2bQMC;w)+!jcVp$odCq(?mms3ftl9uVb! z6MTc&DSQoG=6af1`rx)CmMBF#VD-nN(CA1h)pT(|tSCZ)mDgVJl}*-?zYDm}>PwEV z6hRH$iu%_T8!cWs8%#LclV}%d8<`tb@Bp)qaxc&z((|m>bt0!Vvj#+5pOi1r zZlSd!5MR2E%7&ZstpU+v_KWck#M6yw4gtPerYa|w4Mn3!xTRg-W^iP~SS5(R{hVxRzkRx#zky4JrI_=kW zdBIBg&UQu_#Q=*fYv7g7Ea(jdOHG#c&7?RT!5N%~jqBsHo`aUNHNV;bI>64u=-J>f z4U35>nR?=6Qi2i;M`LOLOrEhEG+taBp3;KVP$oW2>DtkRZ%*hEYe$yZq|BXV3zCEs zFn@WjuZ3iXuawZTd`s97`UG$;A#-2aqw5=+XfJ6kgrA7ua&Z{;@-T7k3*o-dhiz~n z7ft=Gm3ae>gm+pXPs&>L-;>u7*jcGZKFLh}HkP~TNuO9u{dNNQ*jU@TIW1{j{rIpC zG?8-?{Iu4(`f+4{6v|qlTFW$5?BzaywD^7cWV;#P2=d*?8JYhs$Fr-D$Z?x<*9A}M zs+TptZ9rbj@y4vo$qks5>fYAb@b7+UX!2$}_e+V$BM>^D1`iO0KysMQ{5jqLZL2qj z^7$*M5-{3%SpSyGg~aAbJJB}YdLYraaIc`A6zTh~O?-~{0C^$}X2_!XZSS4=?StQv z{EBxC+(WGif(PgQ$Us29Ti;1{RY{W%4)VlTtaiL~B}S%zH&5nkvIrkHM~>VnANrgg zycacz69=dkx z^9h(bJgFM!2xr$YAo$}m0bG=n7q=~SFK+wa$xGd2*vkp;h1;#G!q9;Aw7Zp`*(800 zJRM#8X1+Z&b~7Fs;}f$DnhC!*4oDn;1FVwbOLAsa0OotJO4SJ3?xOL@gB-P*ngKkxxLjOlBofta2^{`Hv4_Qb-1oR z8mks+J;t;3KT6%^yiH=RS?X<3ew@9)ZOSC-e`)oUzOyB+{lyB{`-J4866``|6*YBJ zfy_A<`ws596RrbchCS}h2{AGDUSw^WQY#>Nc&BW6fI^*M-ocn?X?<{kYF%sTgf%!N zgjzH;6sUBUB2jpoZY@7*4$s8_;|k|tN-h_ow*}zW;MKPMYqMIpPx2K;qO`a1=scF; z0_T%uwvQ1!H#0!MTJRKeN@}(4$I}z(Y?<@N1u<{3 zqnkS+-esO$H)JrdCyjVxiccE-+4%TZ(uFs`!#2%_pGLBac+Y-b4dO0A2nQ<2jON_F z3>w+4sw`^Ff$e}}6}b#57<=z=iuTIL?$y=#)8AKiqR5bejvU@Je(OR{^?OwHD_f`- zJ72bqKs)GSy>O4C{)QR4BfR+a$*!}RJ@E70FwyYg*(;F*^a}+qt(5w=0psxN>N-0G zb6lLJw{p~*hauk{}XB+qgmcuS(6)y8j=6%oKJTqdu zs3!$<*-P^{o2CPTwA)m_jd!I5Gk)xk9M#Uw`WcaJ|2I)*_K~Q!LXbIt5R6l;Xb<*Tokw2UH=&!Qde(Jb zE}XBT9l_&Rv}{T^a0T&N1YWq$xmYzoGsZ3V|D17=IId&xqMEX@M1n8@7oW=(59WtD z24Ev*`=r=4Jxu2Qdk)f(x`}Ouf(U>>d7LaI9jspS%e8bPD~I2oRYJiW7RTzb36K$` zh3?qdH73%mXRv(CT;RCiwX1q1?Ek>;+J+uF!BzCE^$g|0rBjRexa+~XE97&*>8df? zcizs)(X=iwALo3)v*CxAxH8q*M`P8vwsR&gR9Ey}Y${2ScwH1m%K;nM_5nPH{HM47 zFO2~nQ&mNzLMGSSn|aJQjpe*p{kvSm*((}Rj3WIe6eLa^PkEVh^PO$N>s0UP0P*J9 z0c@E7%uU|u1=#87uxq}q7}4{#^P<=rWDOnM^#yh?PvE50 zwtr%X(6Mc=Kkv(#^fgMe;F?n3$@({yq_-3tLbc@o9!BO5PFb>qyd-Yky_CB*gDUmf zT`R`6juh|<>MO>ac}6-lAYNclDW-T|ZC?2qvj*1lXa$2$l$0_Og--KDbE|$^Bx|(M z6T<6+7~o^f3|51WCC$Z|vxOz^Oizk-)<0mit~b%tyuI3WeE0~f=#UTSjF#fjOkyM) zd67Q2dXWQhfi}>tF11)J#6X;{*8(_i4%DjjOY^M7mP?4$Xf@~!0=F73f7&g17NqR# z*nW+pOw;E2n_AN{8b9X47i>!EF2lJo@B z^7alXS}swtVuR<>pzIEpXz^^gr1I>sf*|qj>YbMOl*=4(p5 zUvROnG%169EG#*b0YIG@72WALY#B3qwwjLRj@5^%d?_$#1?(o!fEt=!oWjE7F?=@#&sN!H2e`P6tL>~ z&HPf$G128&sI;aE@jBk{WUOygv!RosGk~ zv!c{|n{`zyNVlyEMkd*m+HbJOQ1c{x2nbo@5DLF->~k0wo~&cZuJKx(okT+B24v@y3zO6*C%@xzp^c_tQ@Sp{bKS@kF&3zE&ra}rEJ-p zZ2v&l-0&_q@M#gr@IGu(#UZyYThPaKxHbS6;$XYGvz|%M@ux$7sWl5;;u->%YgD?k zn1nMIoR|gmT7;rAEkDpMUxh!sJ12U=JIzu=JH@-!ahX#PUqyc}Xi(`!3Y~Z&8w_TR zz*q)WqAoqNf0Y)=(P;7n_6OPcbp%irRu z*TX8@r@2AijF90Iz*&Y!fq8_Nw4NVQGMGInbruqXj`rS5-TafU}5P{spw% zZtgX>%)y)qM{$OO<&K2C3}VbMnbC4e36tw@DrmUpY=9Cab{L>Uux5^|BNwBwL{Ca- z3N%>#?jt$;4byFOOVYfn6_q!80ZbO^4mUTs3-OPuBNP(T8jinewC%QLof=1jnr#jP zVd&0a(X&8hW3^Z^9SB9 zpRy9OB!X8doTwm4F8PW$jJcNgo1hjib-e*fwka8vN{|BjJVUFkY@8uTWHNqNRp!1c7cqx6#n)7_WfofS2N z!O0%~oD>k(i9G?s_Jf8UP|)&n)xD;+`((QYT3+XT3b3Y2G5j?@X~qb74KkIqo&bac znxLa&HiM}(^3<3#0#J>;P+o%~q~71}(wh*(%REdQxIsa>Rsoye0<8KRG&)#exbCT(0%14T`jd&qpwU%69ThH^4XqO!=YBchD4E{uMIfj>m*Q zLN*Ar(Pl%egO;|>YjapvyQrcRwQT94#%|Z_TowI8yAP4EjB=5PyrO%BxGUV+bvY`l zKAq;%=xbS#mh(Qn9k74f<4AQ?w?YvK8sms8=^XV=T{uAxWS4%E_NIU^tWBXrMw2;49 zeYGsaS*%@~5=P_hl;PKMut7nRGqsmhpOW`8@!SySJmX>U-O*T7!LgI9s}xw3=@{8;({RTN{DL#l=0u zEb_b)P)E;DULY1z@r^)%T<;mtCdJFuhmQ53(nHo&jQ)U%EeHcC&gY&|AU@q%M3m`! z9t}mQ7|xT0Kkc_Mt}H4KiSacK8B7+S8By=2(!sCXVx|GwxjUpnD| zOncq|JOw%ccXiMw>)L>$auFDQfU6P+eo3x3-VzWdj8DmwPA(Nc!hXV}dZTFm_ell6 zfK8m>#e%P+n_n#?_zce8L>Vd;s;_AKele}1J5hMXPrWiKzM8u{e%C`FJxCI=P$>AG zj*lo2!7w2+91eLNbZ)nafo%Iwkq3>u(3eP9S7Y$iN7D`{(D?XzySzm1SOV}&y9~bs zuo-_0xTmVE9kx8;1k~`aSD+0@un$n{kCKZ2M0hPX0_pdguKDNnmk7zBO~YG+HyFBs zunfEIGs`)7Ry#G~BG1GRL&aEwBr1d~c>w&7)-8i)?<409xc=0t-nwWLaj5-CXRb^kE zE8E_7QP{y2oR_ODDwox5ar-kB(A$qk7pr|TC3iI@a_0d9Br%etlzHdYs99{tJZm?! z?QqB|>DU*V5gK9TGQbDMn~@P9zw~m4FwVsT8IT^x{&GJgYO~F+Rk#G$`+oPC)!Bj~ zws@}V?$?OSiwhV&b#rK4Cm@XqCZd}_(8R36+Uwktu+hdbB{{i=y}eO7Gk+7z9e!%0 z5z`_ty&8;9{ED@=hrUKgef;)k+LkX(*v&4zw>Ft`bWf zA32u#1kPU3PP-vTA&_LScyL)#$~9{o+kR#VV9owB>x)D1_-jLV0hDWYvF^Nt1s2tM zZ1xOS`5DFrlGGoqQnGLUS5{+afi%4LSQ6Pgbc2)WQ$R7dQ(COIFz#eWh8v(*Ts=*? zA0MH1Fu38XymW!?wQ$vl{Roh(`;&2G&Q!iHfIwxbLM_-3=x~G10^uz(Um`gg$+~8i zUx4}D9EQx&wihFb2HC5}Gx55Epg)s)ApCT5!Gw^SzG$6(7&BBS4@sp!F-M}M=6m3B)bxs63fc3UZ8p|k}Sode&yyZ+B z*o|y&3>{@zr$#T55soVhuNzjhGzIYmQ%)7LBgEh_b`k|$*t(W>;y&Q&d!h27k?lv5 zRN|`XD7VaRFLmW`L_My#q+|knwd2x+cgbt|MzQBUfsO!F4lu}PN+!KcI7Pl!$SaS* znlN4ct~GD^@Ev`&^wGOsA~>(_+(W5>BmZ=^e^IV+41`JJPy!-;ORr7>g|H-Cu4SQ^ zqboIvf9WKTZ`MfkrMc>j$`jTa4JC=^g7pc{n=>en9BW`v-3RsD`jCVJ(#ywo`M``r=WfCXdj~5CK(3mbZLzCvGp%BV;$Oa5)e1ni z=Koxo-z#lPB({lIDy>T-KzFbIv~Z|C5uI6{<+)9PggSm4f#2W1dJ2d)*w6RewWG(8 z`cF=zwEh5V})?q4bCU;L=s`+)Tc!c*+F_SMM^pqSolH1Dxbp8y>aU9DBUZ4%t?9Bq$t)8Awn zu#l#8;9z_O@+S>0kahik7cyY6XVg;5x_&+zTU4d-j%SLjnUg8?^YM`9F4cJ-SZdn9 z4xygQ%IPQf^PY#-S(TQNlv>y??;Sa8k(>)hz3%UGQZxadO7}VPjnr1t3rYbZLnu=G zk=O1s$(}x(hYWQxhZRlN;Fp}h7796aXg2%{R2t8b`)=K>(vY9&e^+-MuqZ6#lSU#X zlX)|FdC(DwMx`8`REbtQZor|hHhkZZ+*dc{2$^a8T16G|iJl#fKTi2~?my>)NV>o8 zj^hI+>q0HRnY=?_xwN8gzsU7Bc9uQ@k2Z2t1h6~%^vzP%3m`#wS4?GA4LdkurC!Qa zF_8h9BSqvMLW{b$a`smLcL5(l>Mk;FFBTvQQhtn2ynP^QhZQYNKv7$i6$wL0WPYC?x{s!fvnu_<_xV7Qh>&^0c;g@M|tu(ED zVuYtM8Y;7&;hPZetWh1S7Ub!^%)#;HNqWs8_@{&&RHzty(8-Dx=hc8)q&rkK;Z(eE z_{lRVzvrHgVLoEp)`+9&sRprzfoPKkM@G@BEn;xy|MyuSzt(HKH?CERi|L33>jpHj zK9!dx3G@bkb;XWq0hTYy87=Hw4%wKXuDD$FE?#ZhHWuV4F%P7D2rFRfDnAVg398AV z#$7^)U(gcYp9MSKqyN$dJfMjN@kH3ueDJ|5QUF#sI{kgN|NGwEFcM6b4nBrhgI^L- zH{Cete`yZ!Sp3T;%H(-dB>)eNB`vHm5&8O$jFNx!AN9^Upgw%QYZt~eELd{a8kOFjEB6R-LxOOA$uieRI8{3Ti{wmA1R zd5P~AL4k>SvVz|c?nUyStnMFMKr)g}v;v+c7N}l@Rf6K>PwQ)}4!%g0M@f@NF82*^ zx4ebTdW_>>QckTD!DPYA5^&)fav$E? zUw(>4a%4I2v}7LE)63TE43zA!ACSJM!o2U^MxHit@3FUcJO|;@AW^rAu`wEtSf}B? z!~XAWF$l)CYUI*HLzz+^yz+{VLgOt{Q<^RhLp5zJ%3+*>yVQGT*qLtGAH9vC|EN?3 zkSdi*5JwI+W_^Vmg1zL(WM_KVg7-cX5!(V2jG@ZN*^;++)Tjry<&e?j`-kNb1Q`q{ zlsH|lNH>k-?+_Llyxv6QXZ)n0}|MG}YfoR1Qf2{O@XHY<{g{YlfUArB*{I@)xnR z9dZSS^2W|+J%36<626)*7k+G*9sEC3y>(O+P`f=Wq9Ujuor0irgLFwtNq0(!bazTO z(nv^4w+!9g-Q5yH!_a(ZeD8bj@0)*I%f)iZoO7Podq4Xj7Q)6s0Om%`GZtl=+ckw! z;9uBy4_77;E;J|a-vR%Hhno$gT&rm?g#)@aNr6o*Yn^FVtGz~9uQo7aVK^V=WsVh{ z+(lbbsA+1(Yv0XQVSz(GA5`jfT{!i>?fAd%>X1C-J@upohHm)J!G8)>%($iWnJ#cZ zlk%_?JYJ&YUAg)4oql;kW?Nmi=ME|Et5U7;$$&?L^FpU^k;&oMiyIoq6|_`2@%-*_ zt|e>`t*IT@rW(zE`(XN#WTH@KUcKCshyQBJX_g&s<&f}7c?oS2E?)qimx7Q%IJ2?6 z^dWHi|9w^>ON<}6bo)TW+8}VjT{Z)tsV^cZ@pqD3IG=YWw5y&uo z5R=g&=3j(xsYjtgJ`*7OK%v2)d36tlyRsU5aG(D-_QC(W_17s~=<3OYh-^Hj@kM-R z4g$|EsYq6P*w<~Ii3?y41bXekJ1D~c>2cs|kv+fcYro5;#@w8NIyPJ%sN&tUx%KqY z7L7XsMe&!jtQfeEk_Ixl^9PT0mu4bv@N2hX4F5+#d6RI9m#%Mqf5!6#Kt;)iUxI$~ zx?Tr5)cZOy0AiI+yml#`23GL@_uk^T6Xo;ei_9BS z9vTZ~I!8j73!-8-J4g%vgbGW6@aokD&3|N8-)4x_G;BvaDD7&h4nN5CU^#cljH_xisz4HP)TF>|?L zI@RKW*@UhC4)F16P?m)DWbxU;8~|YTmfcbjH60*3w-+!P6QJY%Hcp0YGaCLHhrkbb zuY%PEnZU^sEFdWppcgK<*C7OU1Zx0CFFjo*^MSH{y(637T5D~zff_}~q>^ucmsd4_ z+g-HYO)uTS*CrIHCW7hl|NFRS`MAZo-%*iea?9@kG==H-iOBi12#L>h(phVkxC#}- zgUgK1GQ!*+OBm0o5eeykR9QD*8vP2;a=s>|VWRaagQn-4g_8lmnEPnBPbRTi?RY6W@!NdyamS`TwGT{~MxlZ3~dgw;0obZ*071;jT#0yENZ$dz7&hrt*o}aa2`OhW~TU7Xrji3LMFI^km zM}PUINn6sL`q_N}!wiOE)>9Kmgy3z>PDv|pO^g6LE~B->vR-{qY-r^+9PL=ve-kiCd(4#%351}rX(9$?2%@hIbD4o*wNnErRpnLtEm>1+) zYzFjj&WDw0Kp%au{Hi((4@K2w<6!UK0*`rzvMzSU#=EE75E((q=o2sE|GR6Tc(3PF z5a|16D=rHrp7XNwGoW)w1uc4cK0d=&if__@UG;mCQ&W8_cU|X4_*=HKj$mssu|OS0 z8ino(q-k^q!D)~5ist&iB`*cYCAwxrB;gY42OaHB5iP*XzJnxmN^iQU{ahngGDpa? zeyirvM+Z2^2#APL$@*qzgVzHzmk5QEDhA1L8zd_ma&U|D*SqzAUwY=BqO}w-C?4+Y zzIKzyw=N1ChZ8Q~E%E<;7D7QIg9~=>tO2TaMn~QIs+hs-?}6N_55+uW8$UT}y?Tlt zPinyHmVm-d)t<1oHy)N7(V9NQ_7jWM}C416D<;8H$$%g+56>=GBz z2np#g0|r_9O9>H(fvi8`G`(*PM5(9?y?YJgX&U$JHU2ti0H9tfZkDiHGy{k&nH!-sn;%<)|vNc4W8N+}g+ct?#ov3O5$)+^yznrCl9a>!!x0s6@!oNPq z1s9ZxL$ARy&lgDc3BWcSd{prtwze-@g6_cY>ozl+SW=N*IWF2k`0Q z%x6G_wgHtaqHqV~)$eV~ZhnJfb(n1LY*?uxgZYmpV2JE0VDc6xo=C8EkZj%(-ai^y zb-Y04TbSZ_`~f}=w)xdY6+90Y^nZYHkf~nvyv^TF?2tqLAZY!=@RG+zars7zM_yw* z$fU@~Y#A^e;Y01D$2eH%B7#^5=&!S>opjg zDELOIi-mj?b5EyOLDd&e_YBF6M?127rdi?cos%l=%$%vJ9828g1MRW3-S_bDtL~2{ z0+qO>OI_cywyh6NUTHPEkf0IsiwWtoU;rQTQyNc|P+aPu7P9+1If~b8Gzn2{`i5M& zL)wE_mbe`uLh8uXnCS9DZ&mzwJTFjC-v~s~P9LsyrONTT5`|y-y?>vZ{b|~mL)G}w z)s3_)PvCLrRhDfnMxIJJ*21C|9s5vOU=*jreS!U<+r`=|KXDOB*e_-2Qxalr&Y8-+6tRX*)6XM3$Iebcb+_2jk1!VO_V9#nDlOO(|^QI23aEQn9BK#qxp$ zBjzFTe1W9w41y4g!AwSZ?!4y%)YM#0&v{Ul%$MsOaZcP^-{$%cG3eC$+ToV2@8=LI zq)Yh?D~xXlY&d7R(S{M+au;>pA)#NIEq(^e6vRRTT{t zW0NnGRG*o(*^#!Bkdo^5``P@}Rp-h4&6&9|c&N-|Oay0=zZB9vg1|?|PLWwO zSGP^Y|KR!sLQ;c{s4JQPc{K>}u(7JSO-sFW>TZ2Fz~2mV<2iPIeXpkRbDf&mq|AM1 z?b}irx5kH}U>5ST=3yxbiB3pkceWdHv0Z55#-)Acx<74ona8rt&Y`}L@o50tv1B(z zo@%9!Wz3sKUX(0#Yhm-{>h0R?grcReq&UJ%wbiyG`bW;&+Z&Sb$1i2evH~5gBu4Q6Md7&*MoL-I_L!_&VyW)uB{mNQd2o1ProB%X6A&O z5b^N={%BOsr_Vm&SqPQqAmUQZ15^Rjf;g-O2e$d9fHzu&vL=hLVVAw-Jo#hvDyv=(Q#Nt>ON1vj>S7$h!lH&c+bu+cImmCgK}@;NDd@ z&j};q4M%3hS@oQG2mc9Sb|~+AzK&fc-LSu*wmBg``9Bh_Ob%(6!$uJD$L=a)I=ATW z-{^+nnOyV3^9@yhO3EKfyb2#4^+J`akheXfh|@6!N;iY3Jig5LY^#QahbYA&v6aCR zcg@e(#V75&NVRPmHz^%Em)fn+agV=#ufQSH)u9mDW7k>g2~zS(qkVVAU>*`zHUoqy zK|Rj@=ES2!wHjSy*%t2qxNg+Z-yFME$K%|U1g(td?m2$Bj_iMpoAzzWBe>;eNjMiB z8?)QH{%I%Wd;JqwXqw-w)k2&Q*jhXE0~3$O0)bex!0WX^*o2m~dr_P-^r7sV!|&}- zoRi8BY`UJh;9#|JuUppoKtU0jv2oTiy&YHTFw2NuI}>gTMWvXu%fZr1W4q^P6bIF= zFYzKhOC4H}tekv{i;pKKJ5_Oxh9akAlD;fX4GveOHrk_;ileUQOjSAHvB7Je9o^Q{ z?%1Mf%KJ&?qbbWt{>bs@va@5OGitdZ{dKa;l#FO8gqPH6PgzO6;j&*|Uhp?;;6G<{ z!>KNRqPhGi+vLf@cpFdI=eT-poH>tF3vx0ZRjqEAwYf4;`Y{CcZ@ zVa;Pig1)BFXoJR})0j%z23=-U3l_|3W|OK(9Wc9JI}ZvDA3nR~DXj`jedERV&SBm7 z)@P>X2{8rPn>ZAj%ssz4nD|gf^{I;5c^}hg`LVNG4_2k$d>RH!)6VMwsh#pZufI`s zd&=UARYI-lS~OdsZD->f@O#RCUN`(G#u$a*RH1K$O@)Z2i8Sb`C>*J2$SV?i>aL!L zio)})^wS*`J+km&;vpZN>#t&6?=4aZwEE$8u)H)@m`M;`QR5&-Jv6$c33@PU)%u?% zShXp{&5M6d3U$zg8Jq01f{;n2T7YM&#Nb;u*)QdVMy2+sgrTVEka?X%S4&Xh@HiiE7nDO{nPxcxezr;DpCr>>m zj3JN?*td8DNJpvc_M(92>|9gd7f?MY=+`PhaKksG)nF;-WfM7HqoZuHnL(VIl&O7M z2SU(e-cu7VZ(8#?m*={J8s=xt7Wd7U^?7ScA8G`DU$|vt%+z?4j?Vu>Pu4lPOiIUM zVpCe%rBwPSrw|UWRYvxmNSQI}4Gz@}rNN7-nVB;XJpTRcTpP;<;l@CCke}l5{!5ga z(*_W3VijkMVUOVN4>pf)%PST4TeW04oLShlePN4UC(z4@Xl!2#1w2Le{U}z%zj4U#GrP$ zq(V{jQfosL$*w$jlk~SDwIkc`CDD1fJz8E@>k?}CuCizrcO1yP5<3LY-L+d?H&7Q4 z^M`cg6gpWS&sh+0*&v9^YL8VB-1>kmqkk`-PV(!oO`2(If??k^ycC%-`!RE1Y`enrs}jFrwthixQ|htoYc*H*J00#XP|y)xm2VgAE)@eQ-S9%H3Sa+B*R@mEHTthro!IdpgKzseJ( z%A+e#9OR=oVvD4y`1?1+=)o3lkze``-f=qbHz<8iE=tI=XP{tKqQazlI&eWWpn|&N>FYJX{c?v5@YyIA)O!zFj52OIjfY)tra}o}v^i zsL4#?$4SezGFu@lU3hnsyeXV+qUG9JZf;Ii;Z3CwVNY4KPEUOdqfsHxu`JOnf)>qG zblkL{#=y@KuyK8%r2H5o&wF7HY&`jVkNqDUIdQswVyWdtl49p>Hm5Gn`qQkheQcF4 zP3+<`m zDO)#NMU}%rvYmeg7Fi{V8y>a4?FQA;(@cZ)a))8&%79;cGdU9nQYI1Uni9Nsp`gscy}w?#=S}bPJ`kss`ao?Q$^p@6EAN=Z~8~9<;KX z6^RcoN>CO5SdM*AY|(xl^QiLays#P@```9Zr-^pOw|Jqf-yFe7URj%g?ow%d#Wa!o>m&1D^!WLR^0Rj_Ft=6T z>iX5opHg*}$rH14bD3KC`VUWMX=7gr&0>fS)Jb~lh)`XRGj5ZSi#A;c<~^zZJ1?7q zjdZrMdJxjL>0>{O!-tMZ$fz72H@=?pl~+3ZuZ_}63J;k0@9h^G!-2BTF4}y^_gv0` zuohl;1R;tKQxm5p@O&6GB2n~I%bd}v5|X(6V1aj1TfghbXc7`WAFFDDOoQWHPSR|0 z2I({`wra);yD0Sv$=(>C?RlbP|qoh|2iYDP#NS!Y47YpMaJ*zwD#+lkf(wgUcWaX>q~uht<=#L zxNSpspJCd(5oX`uVSwZq4>bojn2o*ZX%$b-`*-iypRphG(bB_RFdTa3>RR9+qxv7$ zEcZ^I-8jiCk@83RuH{7NpD;2=?Ov}s=8QBx-wx`Fa}AtN+g#fZTG5CbfOzytD{<*? zd67|gj^Xn0a3nll;38dMpwp~>lTZpIxR|srZ#fpESVxzveb|}!+%uFFc!k_`v`X4Rj3W5W+YrL=)W({&$ydiE17ZQj61sR@EALG`(lPf zNCN%g=9+kNV+g(#L%#oc>0It>k1ZEp#aQf(K~`e~=J~EzX;+>3x^l9OvXYLDzmnrr zpps$n-6TwgK!uk*{3oZ1Q=Y4aNTAqqSD`{KovF5GnqYYBmcv>W>PiBZ<9|Lw!h)%C zUt{7=9=xmnWATKCXM72R$L0bBvd>R8PXXbx*mpY`Fy_OQFIj9To!Qc}3ZytM+^tB9 z4>zNMEPB2E?$@zFXCZ4jWU}shf@vuGt3Jdo!P3y=27a1`yvE{C{@|B2il}l+@sFjUbu0&aWr_P_fDnyISz_iwHds$x~0}=QN$`m^Zt2%IcFg(TzU+x9;w8G;;TsC09;qz5l6i z+BvyRU=l-fw_=&*@JUJ^1amlNYwMfR+ zwm?7G>E68#D&7zvvgw+G?AkYN)lOUU;@oVqEsk)Sm9N=z-qW3>EI}#mA5K2dKP$0N zl#fd(A&mi@eYnStN;-M=WP$NLl`H`TnQLF?6fq{sq+lB1Nnss-mB0l8S`z;3HTKy4 zf4B%PL`)R;#l#_my!ogf?H>GV$bx_$c8E&9yS~TO|06ny>!<>t5sQcireCz2IB?-# zd(k^e#x*-Bv+Mk3K}p4 zc3zmhPWus5T)#X@5)hJlv;6?!kV!phy{&wjNESHgxy-Xo3b z_`l;>Z?s;j`=*3JwpUDw7GrB4+l2S!jGlr7P(gvQJxPL7+!X*tii(crI!ca@kI3G@ z;tdIFt(D?98s2(s!Xm#%QN^4NlN#H_H(cz1eU{dd8xb;Y}2KPzrMICtsMsr&O+WLQY&!5w7|a@D^ltn>L<1K*FL zTXwprNviE4ZND}1OxsZ+vP@$(YuGgJ+k}|R*HKA9k1wv>ErrxeSAOAmrUe2;I2DrI zeFt1q(DMX2OmH-qzwxXcJ3TrIra=>dsP?9jUmcy ze0&Qd1@ba6uyUPEg2f!@DJEC3C<5c=$X!;?x5bNhIwVmju<_3ks6DU&n$H1p+)(VZ zbJ35jJ{1;(PDBG^|6qh3&d<;2(sPd>&!d#7*R$VPM@q?dE@F8IZ$d8SoY=Hh@ zjCRwOvn4Uo%N*z4~rMcj>K&hRl8EQ1o~5L%ABsd z(dNm8p_olSnVt^?#G>_Y9cDyUQ%Yiud-^KNRv+-Um?1W|MEqE%u-F#7G-zP8C-BrQ zSFbJ~eUN@Qmh}rTE_u!lHiw5k3~W%W>FEExoK0kRqmGVIltwwYzwH*ly_F$|_RlQ8As#Ry$Xuf)v~1DC}8(vO}b&e^$)?3fElEnEze|1{og zCnKJWPP#?6H&TY&Jcu+ktZ?8emKDJJ;8x=lE-~1f%zwPN>N+oInU~^sZjvO=GklqL z+h?U>wcz<_+5;VbeJd47;fGp8S|qL{4x^z162vuU3~%hgsPlqafR5SZaW4R1;4ixK z!bbS!ZBNMK7P1A($BwqUN{q&%<-G1#{!PNUs~3$-i}zM*H~ANL=|w!&`$V9&WgS1w z@j=O2HhbgpDLghhDFBWC1~u8~&8UOJv+^Xry#3Epn=?C7HJ(zn*UIqRv=lf;gQFiu zW~OZ-DE+8Vqda!){SQ7Q*_{fK^6Tj6XAU&RE^emXm69%3dnWegyq|y7M{jz7G=nZb zg{NTfRzA%oR^+Sd5*IuNBZG?+^~GoLmhHxmX~mMA{psx^!f;M4Nfs8ecLN;HLE;_r zZ{$yg3!$!cKW?SblU4*~>Cb+QeMv{>rKoQlh)TcLuky6XdNUp?j9#~{h9R(4BWH}$ zL$Ims_Lwyx$8;QYf}=*@{&Cu#Wa?`|3;I^Sp+g#+&v5`g6V3x`d;R9ra8^qdAlp~Y zS!K;=G_lmE%a7;!4+z$wzo6OWp+~t3wX5qkmF4zyO}4S2UeC6`%DwON*;C6w8Y8)Ut=G4AC@rI{#30iu7_Iqs z53f#XpMSgsC{Oy2W;E_UdB$B2f=h2{D82YCGR(_PLuIdNRJ1890Hj1F^F#h!gxw?x zd5-w%BVFp}M7U{h8=rM>$rMSSW`gW@bmtRyi?fj~5g*t0EW^Av^J}Bnbb*RWIXyGA zdL4UETJBNMryU1I6@D8#8!o&wT$ad(eoI$dY zv)cu(HA7^xo@VH>V>&=kU>*BXirqnxHG@OvcQ!I`xYwlCxj0n#D3S?O+1Nlm4Bn1? zmB%trWOnvJ94yD>&dosGM@4AuMW9B&nqAFcfHlb$ckwPPi5Cs^;sOBF0Sq_q`e@J< zR24F$^?q-cEY?K|s93hfYX@QrPYz>{*5SM|OE;?*Q*ij!81&<>YSxk{%+tU|U|;^X zqrBklX(_J$)}`PxD^_~?H|-Pyt?N`*52`!qWO#S0gk|i3vO03+g-YzFuo10$wQ}{O zno~OTc+Wy{&4w#ULM%D;Q6VLv`>k#4u{6mpSzSNS9LW_~3uZN|Zyau;rsm5|x3$~2blSs@im`)3 zWn(F*X?B*%gBfmWoi`!+n*L)yhYyh=ezixlX@YH$_|zLEVz<&EW+H_YRXJvV#&*TJ zFfcid88v)0ok$E#>uv8Ceh=yR$ouMYn9fV|QsZ%ZLa-JR#&eA|on7jTUFuDr9-q26 zw@h~+0~3Y=D0~SD<`rxlw(t>3Pya#jWnnXtt;FORr{JkPu3!^q&vATP8AlzFs2Y z#m_e}`8fxg`7p4zX0@t>{^i_Kk$)i;;uOPBL?YLhA4(Rpb3rtXwjxGaIdOCYtDWAu z-f0|PWv+YDv6)3S=a-vE{9Qt)SL?4%;tE8699L4mIJzkstqbQW<#N2PI7@Pt|JeE% z5L9lEFlvqcXir+9nZ0aKv*AR(!OdV#JUW6Ll{s{?2QbbLuAqf+Qr zAfnCB(3iZl@7ZI){J^t?s_a1jBr_Oze>Xps?aAg_z#c+#plHlEc;*hmJ>-BZ;DTNnjgpItJN;qr&GBSWXTcr#TN*wQ zyeb#p-EUf@C^Ye}qMLs8q*yM=dyZEErgKLYy#OmtK{RL_tNtP_wxG{*rj+OU!jw`u zk}WO~>*Tn;bY+hup%)^XT!}SF6CYbF_D}?pj?}6t`Fg;`D1Bln2m0q&OtlVy`5{t$ z{o&H(NU<+*l9P-W0%(U#qsR#jTX1P$yIz@$a}o+p`yxJ9uvuKnoU4yzn#k{R~$!95WL#Kvh!aJc(mIFC-wpTGo>BmDfij(cx$sg4VEOtWj1O)|La1qihY6Zl zfU`1LON!QXYA@4Y{0uM!?c;MCwFwft+*hpp-%4yW6&gOt6LBBk?xF|**qA}PF+2oE ziw%w*QI!Hmo<5lZPTY}wI{|XyauaIn>h*1a71PfwE~k6nFfl|Vg(JY+wr06NP}uqB zZ!p%_*|p*O9BIeQ-*l%Z-7r!n^#@pf<8{wH7LEf zG4)MfJPm-1B-0gY-%=mJQMH-b**1bRVqW!v*D_oQ&abG88Qwm*#BKo%$no*EKq6L( zTl~z>KyAOx{fUe_+qJfKLF&cMtP0BN;qxf8R1aQDc9ntTM`%u}v!!0I@A2MI4l6Hs zR$qjV<~uMIjjre6ExZ`il2M@oBzXnceEH0Xdf}c9-JaL(n0v}%mT~@m+{!E*rDofK z%#WTUBL{$k-;`9-n9OwEQ!EtE<~3Aw;=a57%C&H>1UOBmqvD~&yM6*pu}`k`_E6Lz zod&QhzEK!c`vQ|V!`*{H>8STgSxXj|Zt)RcDK((9i6~g9AvOfpLhdyN!PbNK$F-bB?2*>l_CpSpKYNJp! zJcReGXs~mr^vBntcXOs*$$|PJ26Ehc z(^%$lXwyOYzAr!ujaYy&^`VCeu<-3xUKXHPQiB?9tV}!O$$+grj-7Vdm-yI|4KkyL zRj6Zqh-Vc{(9%ZH|gVezXvS15(Rd-%u-3#B+fkR*C< ziRFgmm$(Gl^d}JywlPUqS1g~htQkbPu426=34D3{>GGkQ^#OAJ%3F%0suT^Rh?QyQb7?A;?|YAnD5*!cWrgugrHJXXhI0oEvVWPdhI% z*|aUz(UXQi-yt=l@=^4@ax10eCx-d<&XbLkQ|3~YFp%0?Ezgim|J|z=F1LF??o9jn zwRLi|LC_(VQIUxMiZ5qRUBvLEP&?Uc+sl{ihx|UEp)WY_0?6od8kJAg*u+386S8U7 zZ7_Ww1Mm!5RAQc7=g17vTM}P0Wf1^MjFzas*;#fS3F*e|Nz*Ndmb97Dz)RX}G@v!< z4GWeyDfGZl&$1oTnZdp>%q-$Fmz$f##MW+*@6CBc!@ZqwuhTV4TOTMv7V8S5So<9& z$A{xc-gm4f524XJ4ErdKw(u!J9*T>B+vi=6KSKa;eBKG7diVu;^B=`R9Z;H9>uUm4 zM&+O4-{*xA*dtk)>nv5p_fCueiV9_z%t=%6`3EF>-0)0U@uX|k8~}J`%%DrwE7K#? z6>sE*9E-6nM@m9dUm-+4dBJSWE1+qT0C;vMdA&Rp$Lh;zVw@eDj-WyLbs&5+xeK_jhi?t>a_& zaqj$Q^s#g$cU^{#3NI$G&y-QueoBq6>>kml4+mN;@_c9oHC8sw4dWbSn+l-bs@11< zmh9WpRW+{f?0!@ULM8uBaC2&Q#cHP1IwQi`iF+Zzy~ak8SffS)^aN8Mzu#WHKIYU& zfJPmQFlk9|MH^Td2PVvY7=a#9kkcllHI&G0HUKmk>@859#pZo{+mIuEE}6qzCX0L9 z$n}JEn?Y4e`_qC!#7}M#uLl^cS4bZ}-emQkBmYTnMeOdL>PWrpVvX$!z;eK#;h*DAy~BP)=WCA=Yabujr3 zc-^TY>r`wq^Ge5Y{-XIhT@+yVGye(>pk52RsMvtzAPqTA!(Yz(pADtnAjC zx#S9=B@cSEn0%>16k5P&E3Z3yl{3@L%#h^6>{I{xxzmQcY z%9R8yqam)%E9bruGw;f*EalG?<^)xSQ1;{7^H!&EW((!nh1 zfh7Zl#dMeVdc1&_r=P#Tz}R^r{E(%Mdo<;P2ND!eG4{72_3pMXV(@1t)ANty&vgjzd35 zWrX#0O!Ty)jRXUL8`RX+rF$nJh2S2GA7|cEy-`vt=%Ap<#X#Tux>tQ5u3#I&FggDK za|ky?JZLL;Ez6|_W>Os!b-*`atNe&ilF2cYJAeb!9++!ErG_Ccn zf2&f`BiVAd?xY(<>>_HaLBOBPq6>L^A9!dlPWlsNIF?O64y7tho3HAdQac?2%zxQE zWIsASK49a4vHY8{F;chr%M!vhfH8IMB_7#!Q`8+WA_nMY-&~klS=ZdDukpegs=sqi zFNsC_R4=dXppIu=jad?hY(XKXX+U05iEH)IO22Jfw)u`;IXmrow*GhV^V0`pC+2A( zy4U*ePOa_Y(b$7vW*si7N+)nRN9KP1%CFd1e~!AKpqGa0d2iqoNEPwHtQw!mMcy*= zVGlvVtab*67+bu(Z#>e492Q~l0fOt5HyBZ3ocf)0+6qAt4bl#4sU}wKM;1 ztLRYd3t@oTz_ZSi=@LV0e`TOS5b|r?oSB7uTr z!+|94;JJZRx;idcx;D^B_?*lAnfPa&4j|h=Crr>AgIFz~b`p|Ex835u%YMjOnxeZ_ zAZX?a?tRIo0pr-?KVcDW-_b6 zbCXAJ&|A-u+n2jmn3ww@p?i7GrE85Gx-6zQ03vJ!?I$*69*SV+<7^31w!&CJz0pU` zTXw#Mp{)A)>*&~TXjA(me$~BK_KtKWqOX5-eBYx<;^AK^)*KokR+7H5|0I?^N2hyn zQRZqc>Bo;Jpv=#@_&4IQn?h8j>Mp6^8R`eLXc2R)<}A@*{G>uxIpMK&MY*to*w(Pf zFAT-ji>`o7BmPrQNCNKc_0w+`=>Mp#9#@cR-y47|cImjJOTjpLH&I;wWz68bi%)^6 z&}cm=@p|?Vv0LAKO3Ihv*^#Ynz5BO0oT?$p3>zPc6g#u~qP6EfbeyH#51hG!ZVGe~ zk<;~6eq6INGoLbqkMTz8FqoFNz6eo&<@E}*kG3KdC;w$NIS?Hpds_OIlo`%Eg7;}&yfV}-D{@`A ziX3G7T$7OSgFUPpk~q-W@T-auRKAr&UINhJxa@zkIfhjr9ojXrXlVK&KZFV7S3QWcB2| zc1ANq`F6O_kFc;qMg3vWn~2qsYKh z?1j!NNvq7vre-V8Svf0bk=TQq{#PPD><1>UDhhGWJgWHmV)Yz|`TPVF?yp|ZWj>e* z&RLc}dbUs*eA&po|0P@B4YabAn*{Vj_2;H4BSv1Ojwbnd*0?cWJ6zPHE0`uG37w92 zvTH+DFXF>ZB@62T{EXKiW23z{Tgyc-kslV>H55&c_4XiKAen^(pud>_Og`J%-_V=L zW(5a8#u83ycM11O9%yUz#*}jO_NGca2~$rEu!W`DavjM4wY!BN>-_8%PEQ!x7# zsN0G<1(JnFst)lg;kHAz202Rd650`g^|atNaG2a=Uc33u#Z_u?EsbTsD#$}6zuY`! z71Qeuv0~1X#r5?OB-^M5$yu~Zf-5i~xBhDyP*50CkNiI8F4S&J(Z2x^F{7GM>`XN3 zk?x=;IpT|TP`bX$EmzHNE5)MukWl1@RoN!kh>oEN@N}>kyB3zxr@pM_RHc;KirK+(HZp(&1c2AFdu5Vpqqwm;b)I z<+Tir>ZZ(JKBS!=gMumsdetjKW%S9==+s49w52}nBWCv(B})GO=Xab5_eCIk1Z;%9 zN5y!FY4LjXl9Tm?09uihhT=1%7)TYZCo&XlrX62l&#RQEmJp|Di>Nv4r7_Gjm~(5e zmE_EN4V9YFu`u*;X=$gduB11OC;+b9e$f~J|G+m`r}0%|aQY5-e)$*Z-@yW6|GLrQ)uKbt+V05s>CUYw@AK}(A6%9%f7p|wVS*j8OO8y>KYzZ z7Z{mYw$~!jd2#SHZ^`oS`#%NTI$5He^+G>Cc3w!S*N9my+KE8wuE+OIKxSDlfs5db2G_l$_Rbsxpa$PDo z=;XIDVI1O?_8bjx&KFYpKAbD(;BYr}7otp6nVcI)*pzySaDDcza8B0Gy<~B1%s190 z(=RE+D6ZYID^HF6Q#sT#@!qQ7cXqb^!Qn&Rg@lZZusdq1``^ry9ZQ7@BgB=h7w3Ie z96+8>*ZrHaqZ|m7zwcKSbW07&RZ8L$)4a*a{;k0qWLYwem#=wxsMCqf%zIj!A=*yx zmCLW`X~Tz`zaHTwN}r(wZ@wHkdTKXGl*=rX5@0NGh-{vak7P!j9P5JqnU1Sg6q+Or zZ!dq_eMn~zRKSS*fd2D)*2SS4elIVA{KaXRorvi6ZPJ~PzA!z@uZE7UQDk;Y;-otk z;)Ti{XYeIa?M+AtV{F>$4yy-D62kBnnn6eLAx|55k~Tq158dkt7TsG9HB&P)U%w8+ zj;_vi8zs7ur5ajg9_&b<6?n`4y_pwN{=km$j1<)9xij}W33d+is?kD0eDDs1Y`IBP zTdi>Q5Z2*eSgjNpm-kgYQ_WknTXd@UlQ-jmfE;SEi`(Y7gV&NypVg5lZ-~zlU>bn1 zW`edRB>ijz*{;m?UZhikVM>zzh~@yFUSj9gqy#z#G9{Yp+QgL^r{lHl@AlR0b^kAj zu18Ee15+w4Yvr{R9qM}`^$V7h&o(9E8LKSyAaUro9*sH32a>Cu_$&VIG2a|o9ZaDD5mW)hkprTJ> zpm`+_qSOo){q<3IJRz;3X{*xUMCal?Y>lOcz31^IvtN%yl9&~@*KZj4MLw@>$LPKb`&P># zX|RqtP`Su?1?4tkR^r~NUoQ8YN7#4|MiJV9>u1WIN~XfimE!f!s;+4@m}i=CK1(&58t18EEUoeY*$u{Ycd`>P6~yUq zWXAjkr}U@LQxB{|yTwH<`?QTL_TK&x3B@%&WO5L)v(if)vCE6{0LHWr-OuhdWkiV# z>HswsmCB54^&Ov5VwkGjrt80}e{F4x1#8|#AJ2Y2I@Eh4lFdj+2~|D^&AKjpHebcbIg{mC95N>S zgs6I-KSYKH4Zo2OwB2vpN(vtqUzok6$qIdKPU=OXR)}KsSia$tU?1~>l?MGe5s!sne3O`xH#R1w?%@;)m*^KS-A)M7>aMcJ84eh5 zh=+Dj_E=4`rkxlf=NyZQc9T*{Ok|*VfvK%Crrf+l&Yg_+!u4Is5n1f&l%H3PrYPq1IvsM?| z#TUp7B7Y9{VQfT9jng#k3WLzSF4pdv)0!+9^G$Oc%=pQs8vE?EV)RX94 zA73aj8uUp<1i=m3OK8ZcDFB5vjkU!s^ME}(nfr0|eO2iAZ}~});gbVb2=3h~=jHp| z!!jt<{r;mOajS<-ct!K#vh6;ipmxlnmR;kXedmR(32&3A4oq=muq=CiYx`HxG9bgdW{Vd92V zX+q=noZ6FuOi#$HO$GrO z8IHmrzoe8QZ374k4W^ZnhDLYdlDhr%J(4)#T>y=$@jLM*JyVUbkd=^8eATU)oJpf> zB23?)-`+`EZNL1xCIJWAZW9YtuS%h6=| zXW+zY<0w+U_mnEl34aV+C#4NVo7 zGSvO>k5)~AR~{_0k6r31_Na%Kh`KyYS=s(+^+5pj=K-d8pl|)isy-CrP>(e~$6ok? zL-mw#RSTrs$=)MZS=Smz#VbUJx6jG+04`U7-yn(jPh)b@Drg-iq1@K^M^!5-l0%f7 z&^-tVyf%y8-Z0E^`DXtMQBvg0j}!s+9*j%7KB>PkyZF+=SN}g68^dC?Ur8si>MvAC z$;p+=7g^H?n+PU#+5A3<#7-B_6J$CwSS)8UfT66W*^5S(X>@nq1Eg697&CZVqe8A$ z2xJqT>`ojWG=_=?V`ZO>hy?7^A<&Ik+PZ_iiQ2*t3-SHU z3Ktj-I)6tJ25|_ag#Gz*ChY-drNO|&>Ldfb-k`Y!XkV0UhV39eL?7wooM?X~NhzKwpSlht@at?G*Q!s}&Y@O1K=VTH5%WBeh8knVY_wc%Xr#`>VnCxt5&|6welaG3S%p#& zASol&4sb2(`AM2yGjszYc287*Cu~-0XS@$M^V+Q4hSQ{x*NtI6?GpMu2f=KBe4~O1 zNMbLiifn7&&U)8#;^#z zg@nzC7B_1S6?!|*W+6^G7LgfVNBj zHUBxK5ex|e_WDqroPtb*R_o@x3DyYY9{Z>IqIe`$vj2A`mvP@gH^G7=?2|`UCTU1- zT7TV!%Kk|>K%ZpPk(2-B`Qo4UBg4zuwyu6c{HRE!8za9Zf0=R&-Xd^1Dgop7RKu4R zonL1q(`BFHoQuOyD*P-VF_td1Kh}P4WF|APQ9IN%Bxb-Ka!H#~kCne>aqhip-gxAB-x-5ynKt zzkEY$mdvoJr91%UUNsIO3Jh!py}U^&_fI@c zNzw333RKCQS;|VywZwTcv&+NSmXU^3@T^CTwsfb}3cu?OXaan0b@iiy)=p`SbE!Qs z>E-!*U?pf-x(b*ggl>b#3MRU`(w+=G^!wc{k7n15i6W)2R?bdgm4U6TFt{=_6zkJ# zZ<6uD&}L|NydSX{$7-xt3b}p!XU_l_gt?5WT=MeWN7QS*%CSAH6;)yk!w_+BRY=dm zw9mQeRLorUd@>A~=&h1+wVP(2nx_-;&$w%qys=xrtf{ONNR_O~9x6gfd@9?2CHh^z zNbWG9O$DyK23tI>w$Kz*)4>uwIs!FUeM*3`OrOPIZvG1D+Kf)6?eHbfT}7M7U25j? zg7LH?;m0}jTam)^QP|8h=pE5v)ADtBVqjnC@#T|0DwH}np6I<}aG@Ge7cBZv0 zBpY=)wVHpeoM2ulr7BfxUIy=8`75v;AzPhgTqe5@NESM+JnNp&3DodQVPXYID*P(( zTEEjDbe`rWkxovEX3Kqk^%k`TjEz5PM{cJFdpHvi|tSkSWnFua--qCo2?pEKs%lmxpV~ z74=Rh%`uE9dQ|ZCRASCCXj}VAmDhTG_8m1M6PY9ViH{Eo9K$%9!EI=awrwoTGO(&- z`E1~$0NBzsjyCpuQd12TJlcL+@i`v7lpLjk8!#8sBT4XOVNJxm`(EHYPLzXpRH;^} z<*6p~zEt#%1P5c@H{;i6EnQ4yOnDd2j*mmmTl(3z86pB(SI+OfzU8I1Mca3GD&NHH zyAY*q!Z4{qgOJec77+Ti-azX4uiq#xtB%I;W}YW`)GlLucQd&J!#$`z7GJT(5~R8k z5}PrT0}(^9+x+gp&jte*3q%YYcfhOFuTNeVl8{<?B3N>-^8cAjE5S-mLP@a8V($gX@pIXwXvz7S~iQ^Mf4C5WCIa zRvI~=&}|p?S?iOHZ2YB3*JWpv!G8a-zg@>m{W>SAAHP^tu?R!jheZcI!Ucs9w5T)L zDoNh_Buq?MkF8LO#@8Nuad$W2DX9e#H8r)9NL9H!eam*a1k6Q_7U`Y|M{^-mc02xd zk= zoG&&ucagFIUTNQlpFj7Y`I9bcj17W$lJ{x%?BW>(xom%Ui!2~A1O{;sCL>p1>Q3P^ z0Htxlb=!=42B&yC^Zr^~*+KCsA8ov4NRRU^l5%+04fG55 zp?NHo$C0qCW*f;}??dJ6i!3dT<3xAA;k#Jq zsvSg=zO)Q7IV;azLj%rmrNl3LfsP*}-YJux3t$>^bCjK4f7hcXRW9{bUkd|sT^v+) zq*PwQEFFU==#TZ-3~`P5;3hX>NQVDjHH?m&(%KrtkUowN>UWLu)^qpa(=2V@4{6$w zIa*^=(@@lYVZB&gVC;f37sOI4_}t3cKmPiiBmR z*tbaap&Kd<$F*YLkkCO*qon2&QT{Ltj}V-g=)n7?AhO2y=OP`-NbUt4g+Rg zT$xf^L)13e*0I}k`6hY#6xOzPMasBj-;~qq;AB92GReqr`ikr5k^l{`)==h^n5KGLdR%WYc)T7Crpg4WwP) zg)iA~;AG8g7w-ka1lRCrqg=75!o44VvzX31zo|QRq-HAcMFI1rdqG7Z`yeVvLdNf9 zULYe`+?f#dypzJg9Lr(Qb6_>|UH@)EfSzY)L~YZn_|mv2mY3i#mCzbzs197O<4X!| zQ?pM=nce8>F@M@$LF`^PriJ-FBzjoymbZV|iU2EmQ~yLA?qi>Q|C09LJKsEil8vLh z!fyGseKaf@L-Z6uc{V#w5jl2acZ>ASaZDZWYGabmx`IyS{O~iwBucB~STFG|#(bT@ ztY{M-UPKz--f=B^Du4ZDqo}AnpG84eVg!UKDl*zUn_3Ik4$psjX-t1FhQm|z7tBkY z*NJi1VAT-;n9#l^m4PF>EEY_}TJ+jEoI}xME_Mfpx=W3}GN{Ia&ly8!ijZ03TyqFv z)Yq7p>?cQxr)$vA!Tm*KfZ)`@;4to_*d+UGHtu9 zH#D-UR}m6K3vhZGDK3$cD)q?V%uL*5VL?wbDKy*-2`1WY{Mmi!$;P{NLY7kf3j3>% zgijxQuvN>lya{*t>0dLi-&MGRpA4}h5u#ajXDcP&`iAAf(qZ(4i(9_%7-pe8 zeXXhvqR%&$3)hp^d$Nj7eej+T5h%g7eC=6oJqL;HWZ`;+$^#LW{*};8gSxeMn?JS| zUIu{p>Tztj$RiNKJRPryAza(p`{VChBV;&BkS2*15SA4Cb=96AD6v1^P1h0Xpxo2T zG)AGMecFd!k}^Ld9S#mDw%vVbeh+2`I(Pm~0q?((?ND3qgX}b~W$WuOFAc2Y4QQBetl!(^5fy^wNEe5}%hGZaiax(fE-*;? zzOf@KyH2fNi=w~m8tz{2r0mf-Z0y4ttV*{9yy1oBTm3M(bR0#%Ie1wPoHwVsF0{kZ z!V@+^G#1f>xn}GI{*6Kv+Ns)bqA@3v*=P4-=;%mE3-5fkGc(|X)%on}bHRD{g@VlP zl3WFPg+isVfpKx9bGf3Pb#0dwroKNjb+j;TKv+PKNcp?LO?hZ^*x<0@#o|eqhG=tZ zz|IW8+cYWr4(}Tbeq2QsqlreKP7AR}0--cFlc<#Ffcz`T!INrv?x%?q0@)YGaL9!i z{H#LrteM0w4jav`{VvKoDR60ebHcG)#MUkdjhsfX1~g|Dc&lBjyh`KBapC(n+y|XW zpRPsIZ<_dFSl}ew7T0?4z`WmYdVS{=6WBTeUU*ea1^8_hpg{K>#!Mvj5!&z?k>Ea= z2CrV4M~7LEk<-+ornw2F!`9C3SHz$_{cb(!s(f%#@~TI;B#x0PJIO88d4{5&wynQ7oEY_}_WfoKB!_(Fa<{>>bMQx!MVwW$R5c*tE&d&IMM>MdU<_!&Fk?^joG-Y^ zPL1c2Kb}9r!O5&vCC~jhTuyd#>EqxN=EC+kS0R~`6CB^ekSjD&k)qeI%1@7WY$NF> zI>udAF!=NN3V|pFR;89VRt(m2(Z1^0@oRwChBY)8218mH*|YTpseUY(aF@mQOr*{5YD zD)-tUxCVd&%;W3$)+C}PGKD{nhs>|`&P0c&f9!{w$nDs3A~tYGkuU8|f97)AWMGEX zM*HHU7MX;C5%G(ykG=NYW6EVk`9m@dDQ}!e6xP5cq!l|Qr4(b9S+mA2+)(>T?~10= zLzU!{lMozIYlhUI>{Ah$8#I1AJM{FqZ1D$KzuI~1A<%;y)S}AQ$~v#}D#T{zp-s46 z*|AW01F>*R0by}$vKz(ph$!}pfzxmq06U6WuY*jIUA1T+GILwRWn_a@-v47fVAHF! ziE{H)gAC8V6ES+F!mrISLw*mH6RxPy666@A2^V{~u|cA%#*b_Y!?!I%)HQc1Ys3m|n z06Goi6Ou@XjNBEnC^Dcb#GB`{Tgs}4B78sI*jjA)1+%_ZONj3xZ-7yVobB(St6f5e z4e8whwq8DjWB%S(Vh1O?*9`DYlscO)%klhXsSeIE@X&15Rd@CtX(s7wTwAVIhKG?8 zvM{2SL9;DiTBBibx)3w%?bQ>jbpP1Okf-DYesT_02KV z&d^?=(}4`a`pPIMw2W*6I z)dD`h(o2YwCctVEil^-NA^CpY;HNQ0`}?ML+?H+~wQC-A<2e+$?={2>m*NW<)qfwf zb$-KLC~g+z%P_ZH6%IST5?6g7LHKuEv)? z79NXi&>YOw1Vo!F`@?PbNffqwH4CWJZ_0A^jt1{+)g^ryE4(tMgQbyjW)o)BX-K#n zZ(sImTZKFnrv>OscV@c09V?0`Uu5_Mv~0R9I?qq8Q0gU`kZ548rDil*wTf0??-R1y zc%NH{Mb&3{;qdV+p9^r8HBf&U{jTkBXk_T-kZv`ux`fU@MIoWBdN(mrO3vE}k+t`W zw%&uGbE$m9a|W>Nkqa`Y9{>Dp^ZmMMmu}dbMzf8bg>~tEkDcAk=ahWTzlsd!Ri>et zE7=eRuImpfemhx!W-y7t$qs6*LK;9VwX?yi6e<^f#q_|DCX-Mj%5&s+`rsmbXBIz~ zxxgnUps0?Z1aCIzEUxm||E39xuXvurE9-;SIMIb&njIaTE#YjbDUW%GyCkN9bz;}v z(s$fZ(7`K7WZlfnrAeJXTT@kXAG8pm*g_$L!)E$S1^ZiCgTznA(}=fkeO;xQo3I%g zEIM5%joVFzzR2Q8K3=XEjK|YjpykOq&Tvx0FDf7x(^IBNVLl(yHhKC2d~vU}_>J~G zYWNfxa0KjjvrD92#tRFLeErdQ$RHRv=_}fL96Nt%H~~Z^d5Q0RgMtDAQ>E-|8xJO} zt7HhNo#fTkl=~{3&~()<-3fquzd`@YAOu1MZ9#OB{IPa>g<9v*qQ&tV?ho9V)&oKB zpznVN04JV5*G-tXYQd%vsg_L8a1yI^c{W;3w$oV;BRnvw#y{xINgPKxPlqt#NS1gX zdA=0?E!@AznOV8d?{pP3?aWl$h&7Tzy%oYe^5(xqkDfA^}-I!~|PTh*0T+V8r7Njo|HGB}$|KNCi zc80+2_n7K%nSFYQ#pTeP{WAJF|+5wRzR z8t$!Cj$$RyCbKg;s1r+ViM$VC)WJNW9V6Rq7g+)Q=JdAPGn5N+l{pg9(&{?gDN+Y- zm0C4qF1?M*^#tV?z)s#Rx!J7|v5e1gnVXwS$jQZ=Sc48yT-^UamX2O-P0;a1Mwfo$ zj`m*qxyp)I8A(aM;z+bDw~Azi2UJm*tgo|)q!Cb0FiZ5ixQe+16~RrD5kicV~)Ban(%u*{fg_a-C3Jwn_w(2BGAw9 z=}KP+DthGZE|9NVy*pV-%NUpwkBc|YeJtE}V!66tvywhpm{?^udr3)IIlb=krAuEY zCIlEhdUvG#c8ciUl*Z^A;a)G)< zFLZ!AIVL*ak?tTI`}L;#XxYMbVRBTB1(hQ8HVA+^%Eoa#UahUaR?Rcy$gW{Ob-R#5 ztfgUn=m+g1)<>Qa@7`|Bp*`NDPWJWPy2#5ZQrIWDAe?HqI&dswRo8uk%`c)j1cN=; zP7F8_p1_f?vYMV=qmdoK!|@+k3dNxZ1Ww0$>?Vfwy@xQTao9eO&%&fr=Y#_VGvE>oZ~<3v&0Hi<*l~EJfWY5cdYx zQ_*14=v_@^@~N4vIv?H@Odm;(H2Vlcktp@~Pu(3xeQzVyn=W*E zXE1NyH;KJKOsZ@h3!V`}5vt6QmNHwrIh1K3GD0!7hUtoR{nAEMj#vw$`dyOv1d4R2 z4;ItTP$_feYop8h=A;@wp(boik_UIr!>JUl-*9g$G@7(q5pui%KC);QxyPw!=0VfHTPW#6jK4hnN#+e}5`qi0?>Ejjt|Hwd&0E<~ zSl$`XciDUNsq8>{z^F@on8cQ3uUeHQ>U-I7q!U51jbht@Sjtz&0`|^`AC(}B=+9{O z4b1%!s!fV(GrBTXr_VD+sgr{(b%}Ef@wi9n#*-(L!uN3K21}uR@K)CWH)5TkM4a%N zGX@KRTU~m5NsM(5ca*6Mldn}QH+2o_7t6bA{JMa|<3!a|)ZUx7kUXs`sd@`+6>~{9 zenZIvq^94kGc_C;+Uo%oOB9=?3Z{J^&!$&eqoY-=So8OM||iFK}MMG~O6R z0D(NMV_=W}mhD}o?W@=`*& zL7K$K6GTUsaec?>q;^6BctSAqB8AcHC9lxxxU0f$btEyzTymIo&ki;Rxe-LEJHfOE zUIodlB0d3GxWTNMbce@PXcT^XnNFrs&hqjIj2Z)8U*I5wmT-UsRKHKOnfQo@i9+3-Jp1@U~q;LX&mh)XU5M{99uInfSGA*cV;L(ZM>e6jan~$ zJiAJ*jCUi^ZD}P#w8C@XrK*yXYfaE;BMI$9L(8nU!ivM;S0~TAF8mX}dx;Ul-?-i; z4f-AHA?>QytG%@IMi1L*Xle0CNEnXChlfce)l>lR+(=s;0B<^HUtb?|655T7Oe|E{ ze5bUC@a*SS9J*})4v03f7+9Y&qN*Lpx>Vo${k+R&F>^azM%s1W69 zMjuCY(YHH0YgAJI5xrd)EE*n`kX{#(G#W+N;s43d(2%uCgyvbTv#6Muq)&Ni>AZ&z zysvK(@^T!zKhd+^yRnvU-tF~^dZOC>uri`XNTi5l{y?7L9`fYYFVBGuO|fV;-&IGOQoNOJL4t>+7sK@tgldBz#|P>CsxR( z7glM*^bi#E6x>8hZCsTnV$s3<(Ncq$gQb(t(E-$>sb|k!f@pSU0M_o8k#uwr5`7w4 zfEwCh;e-HDR;`d_1>(zn63+_1@#ZIb*sc$n)i4EPh3`U*R`)WJ3D(?!HM*Mhi7Kibf^0Y4UOUS ze8k3NUGrRK#=3T8b+z;$F3q#Kgb3_S^WqU3GCR_&iv0~?Pb<>zJKVoQ@$&WgCj_5&sfv0(1 z{PJqFqmt4=1lPPw7*83aMUL*`lEqN2@_OI0CUUO|uYSw|x6{zn^i-941$&JJim zAMSb2!s)d``W+}2kGs1%ohS&*nn}e~IPGk0-<%|Z6{eypm`)@QEf$-Orbv#5ho?Is z$E~)E9Zb=9aBV{5*4;v<<43Cdt0=``9`+P7qz%JR_I>t2n~|z(^CMg$LbuhAD`l3e z*kd?XJGMPf2R#}aPJAM*5|6%U@o*s@ZzAqs=(~6CV6n_S^9^KNw{!5=8uq&(F7D#$ zaavI2WNFC?59SH#yKkMx8*Y}Qw^Y2dr{x!3#4DK93nrbgI1?y#?SC25z_LX9JiGZP z3Gd5di7dHKcVCcf@6AqOxjPZI+rJLtVU7gi9JxyHiac9jzlB31= zIix!>v;a5v3aVX<^WMKICE3*~Zqj>i@qqR8jXe}0-=!iy@2R&~q zKJTI@$XFdj*D|>{K+42)>cQ^g&7s1qzE*~El*(J zR-z{*P3Fnm9tPbmw)MvD$>gi1#l@#gnT&zYE~Bj(cV;)CrYHA3vc0T_>HY`@wtlyt zU6^O1ke=#jc`riiIuMbDG1sw)5#0)47)tlg!mqxkYBekM zA-sS&&?wTajdk4D`ErQo&Y2#Lv?Yps;hOU4Z4`+BeUbaT?$)#2$~XH%2ryzSJwJ!a zl51j6;?bqBrhwE$?FU!GSY3*CSki_W!1{PLL8oc74W@!$EphGA&Yx}DM{P4Byv5&O zME1_qWFHu9U&kI}dsCQ56LV*-Z|r#_1K_1N9JwALFNx65{WSH)Qx2`Ond5N%uq4W# zjp;qFWt2m-%K&yolKc~Zi4=7W4^VRJdU2!tnm8TvF2{N5J@-v@&v@;aCgS3)dwx`!N@|GuJ% z`7P#}F2eH}pPN!#Zki;>WG5jdkBD>`U9LFq4X!jkulBY)zWNN&lxECQB2H{Vc$+xo+?9E0#o`SZZ<__mXHsR@5>HVz$FnE67F=Z`HQ3^lKS z{A-Y9Wj%G6At_)t4*yf@LVoS>;*e|cnh(g(G@zT-aJ;~w84jw6?*jkE@zJndnnRLE z0~yH*Ukc8n&nNu(?~gem&Uxz0#e6nCtW3yDqCW5P_bN(^{F&+{Z}fLBj|u(EX=V6O z=-3Qk%-aU=7#-eFJg}TgOG>wZiuvzr_}fR!wV3M|uvg->**W*s8c55`V3EyR&g+&S zbs_jbQgT`57X4GO*UqHTZmv#GmenPSasDd%aL)cd@YxlIr~1}twoEN9ejG`Ri7|~i zrf0}_0;6QO(YJ36KE4Qiw=I@!_&M7-!_6D z{jYj#+@1(<&uhyiyzoeI5+U6(RYtPRVFr4hiCm+=upnhC7iW8}4)2Wbdcoj?Bt6~% zKwjgR$jN}yncfxo8!s81MPbq8n6`@R1ZfsDibl;|fWHh8F1&_?G7k|cP-S+Vb;hGR zqI*^d((&%|y3~*NH*4;BSx-1!JZtr4$aj}P6C}nWiN7wl0jTv(lnCh=c}4J?z4jm|(enXS|Van&aa6?4j>ABSm zo-U%)8a{;}rjnB}S}DvcIR{cXyD?9CL`oaq@f z1~NE;XQZk)*rUV;MR~&HKHL52S&7}8q6`OScLWhU(Oa)zu$xnsB<$0fiy9oM)vCXj z0~(y$mPh1G-{=^3H*H;-U)!NgKe5@HTc5q$Fd=t9@(EH1u5-Ila&qopx`n(;@Y2LS zTQzu^ybOqcUlP&;BHUD+dQa}{J)AM}CIgnbrIn>Zciuwd^8Mj+=t7@keBW* za{KrMVM|-D9y{e^mgxf!>`vjP>2NtlK!Cr9(yDT;quux}ufv)>IGlA$yGdvh3r6%n zKp;%H5&U})I>eYECu5nTeE+9+2?!6CK@qs`)!j%qI)xWN%}PUiwg|5n)Q`XX_u;w( z4_Bf>ohdqCL9=#;0ive4TXT1=!ZZoOiWZ9sD5zUDNA=@WP)NlGMv~~I1L$(4nA>vq z7aUtpNX6fC>7TQMZQ<-i`tr;m7ijN|z86I({rXkwmv+I0!4(JUJ8vg1(Qs-baL644 z(#wiV`$LO$s`S`yg9YMX~DEbh4uKQVPeRpAQ%=%PsG<0HLpD`km^l%K0jH zUSA)QF7)clI9BLUKj?h_vxE!A0+)MDvz`A93fm_CyK;-%!!ErVBZy?ZgP*d31>3mm zcboHgr0GG`&Z?V3+l29wu1&O-G-v;~oqwg+V@X{ z7Vnzcf`Nn=s29w)yhS&TB7;O|pa)tQ4m3H_*JO--$I$>ng2dGI*}U-DhwkqFmPsfa zn%h8o@jPt~pbtO8AtR>^)<3I7#Eju4zh;fRQeULED8DK?iA{4Er(7cA{7R5kzBe(2 za$@Ye=RX}7-r!M%yne-Je5;9;R(S37l!8OZERsV+XY%+~Hx+Y{H>GnJGFvjwZ&U~>9yu-7*^-RE_`WS>V$VT>tk zUJ1G`a!k_cUEpYGu~lozrr|eh#-6iF%3FeSA;f~?890eauYpN?vQs~6$n5WI49E=w z?U|eZe&|bT4G95nkpu8IOGOA&#&X1qi!ZwLUNElC`QghQD0~}MKD^s{N8cHTty{`x z|6HN+&&K7l^I~i~lr+ld)d$Zmj~PAM%$G-}v@BM)HAB!{tv7}cuNdo64XVcBe<}Dk3x2pA&iQ<{JMVd|qWHMb_!Retm6r;;FQJcm0As zM-w2rR8U~+d;$}j+hVSww;s!V$Qj8n-h8;fk~*O|l7D?Ps(1Zs%cydpJXHJsvhRE= z_M85pXCv?ElQRX4sPPU^mQc5iq(t3|?YO4qBAdF4m|W1u3T}zf(nO04eo7Ry{PKkx z5o&UqBQhOh0eGsqNe>#H*}6C=t`-9c$dk{R7(AS<;1veialPW*Li=Ou&RXyUh5<^e zL4!s@WTqo&gOT%bVR&RJn(1pQ3=%W60e{aI80CnxozPj`9wx}&x;3Kat&m;<@*Uzld7i-b#O5;J5Ly`aj2Dg~+jd*s*-?)QL~*hOv8Kp~3KZXY;tY#lSxyPyCSz6< zT&PtOBA3YF8w$-rZ1MeD@b8Pr;5b~^C*Jr9XMx4;C;)r~wkd;_M2>?11IeQ1NYb4o z;6em2D6x$4$B6&-!zi(8e?ZM3!KZ!bRv=n4i= zMvSJ5u(FTezEwsL$xGDCA3~nEpI`TEU0Cx6u`(zKzJlcn5EUa+Uf^|jva9}R%2I;= zDmu;3$*he}&aV037k0h1p^Kkx{LEkpi~kasn!(Prd+;0847oT_k_PUu3jgzJcZ(-) ze)Z_!p`tt;eUP)9%q^OP;Zd}#tPyG#aWvBzT+J6xczrAbtJ1-#1m;-f;e$h3u~ayS zgd+El()a8j%1uVod*EnE4wzvRg;NkK0jL35B=wHE5|aAL*X}D8rhHMz z{Y*uF&4DNzm|-jl_OVU3eN7c2P4sK)*~S2ATxy+J5QEGnq= znk9KcL8>(RFh0W}Xdehps!m~)5!Fp@T8dqsGNgqG0s#2c_|ev#8M})L3mf(g zyn28}Pduy-Dubgt%Xfi(F}7$GsDJ{uxT^0i_OA20JJvVHbP|$dU)COM_Gg}Cdm$h{ zj=E#na<6~)0c7b$?A$f(ok=r>Qz~$aoB>UkKwhe(Ng~iuaWe1YFLws>*7}HjTS_~E zA;kHOq*xe10T$H><>TDW8&oTfUSmn&dPBch-TnQL5$=MJ=syfJneTRGi#{NIP1tL* zz5MGYuM)Lid}tH8tSSb5VO3P_feXjUFnJ29_TMubS!0Y0>bB~7@81QsYVcf&f6j>v zY?|Q0(XZ>p1s4u~El|`|zVsNW(IzHE0^ymUw!*lN@II!)nSg?#F*u7P1pShw3R_C5 z!+{HHWSP^^cN0YVUMdd)rH#jXYPVyd%z6M0LKT$E(JytKy5{$Yp!+{Bw-=GAYI&N- zfq2)B3q<+f>25)W!c@L4AB2AV7}_WPxfb>I_3NSQ1PH)Q^b=H3i>C+hxWXQ0IBugq zywKQ$|v(V1b@y*L$BC-_z+#$81KzF96nuX5lwWJMNxTzDNF7CU{cF0e@;D-^8 z!MBPR8@4X0D+EbzUKtSly2-@foQnvf|MNlH?k&^xzI(Pk| zfV+mVOl1V(3#1ZOSdFA~rUI-Fcm?5o+`b2GYeJaor~G?KMwZ0i7pH@LN$)*YNlDUs zj-qKI;^Lj`q>^wZ@Jrtjce%({G2<=u#iH)bkitcG07XgFQ!L13?*6q5ub9MjqW=5g z1F1QAd)sr&wIJ4yGNQvO4HS}K67BlYBXqyGyLJdgOe$?{f|O?AnP=?SVMw$5&u4J< ze>j69ozI)xqWd$2A8}$yUWx_^cc>q`2oQmXxQZaR*(TrxO2H={s#`oF#dG^u>gNEDFZ z56-J(zRVR2n~Ta}Mm9zv0@mKhU69n4H%9inNd044o>*Awtr)3$NM;SPH~#yP{Qck8 zGfxV}8R+~j#|D0{kATm657T)t|902&YVaWw1B9Cy*|`2sZ(+ju^LszJD0OK1l0s7> zk$G!IeD(|Yk2Sed*7(|W=Q@P}gefz61vy^-A-4U`cY-sxCc!!Br^?^2kEA}oCratw z9`SMm_Pl>y5sH>rM@{@f376jmI{uo= z9cO^J#`gD^LxQQVBQs3v-tb-e9BN{AAAQg9Z+Xn~|M?ERE32ibv`?Go2qJ3#SqYN& zR-%m1tDmXc;RKn~k86}&LLXZvtMP(go)hZ~NA_Q9!I;ZKa8=km>s~$L*c4~z6eg8K zmQwTcYPYk6V?2KCo%BC7y*nAN7akl*J7W$2+}w9*8{F6&&~x6-g0diW;rW#!t6~P6 z2cYh!504I<^Aa*6_0~MG`LrPPr;>VZefXZGD82jo57HrrjcteSg9BHC<8|e!--h*^ z+sJQZJdC+^gH#FmOUL=mFONmUx1z~c3QT$PGhyOo1u;J9-jgain~f)J+p4v~f@){~ z`+hLB!fa+|G?~v~`bL*dMVR#9+D&EwVz|QvhK2rj;vNB

    6slB}V^uAGN=bF|@r7Fv7?!&Xk4 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

    F7WlcF6~gYp5;j3*}{w=w~Wyx?u6*H=m2)1 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�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 C#3|eW diff --git a/docs/figure9-graph-study/paper-panel-2.png b/docs/figure9-graph-study/paper-panel-2.png deleted file mode 100644 index c6fdf1da9e8f980b6431bf222e977d6ffd8424b3..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 327966 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⪻yX@9GeCm=kCH0v>cn|)vG%}+tLEISH_%vJq!X!< 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

    Nb3$XCo`7)nz|9H4)R& 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.?a0m45aK$XGTzG5?c;)@;ANa^uw#umuT0v`+<_svoHyXyCbExfT^fJkS5L# zzRK;C&}d~If~7fTOBbcV@v%stP80TJSI&>AQI@Cjs&5)FfK^$DP?Xhd_Nu{?RT&HCJ}2u|9Tx*muY>yb}|4%LNl$~-$* zJUNR11<6)t&P8UCEhk_$-t_(%~JkAD_K&PVr1R9M?kwFs_y#{nbQmVRP-1wROJ!1hB2EU<?0a`qO$^T6B{ z9UqhShDneI^v+BB9QZo4@YAi58#Y75F*TKrg|BBhU+?uzUY@QWt0bmUx&|X!TGSGC zfzMD$QBg}?w-Wf`I{)&z@AUTW^YCqn`x=a#I<=NMLahebmO6u>FdP4WUcUhV$sW`k zMjlJUq{bG;l(#t_vQgX=xAucrU^R7enjn++skpDyOp)vV$x!lXtP$zzL1Y7IIh)ZI z9zSeT;P<6XsOD{AUcnOUE-_k1UH#b?Sy}Be!>Gsk3JGcM8JGN@$n{bIkCh9)H_VKW zLG$DSy85=OFK{6N`0NO({wjx;2N>@4PA`*yRWY#r*EV_c70pAm+SqmvL!j~O^oq>0 zqxYWug26{_A3eu779vfG_bP6j3uxZidD_Vd$HfHJzro*1kxuw}hx0#!`|R(CXA*UH z;?fa6#~Pf6dS3u6qu$GLr#*OgSRjplAhYakcW6a}yfL*foQWF)6T~)vuico^1g>XtcH9`hjB68$r>$$2yyv zVQv+}FLBd;$RGzD#!SPgtw5t-NG$)tysN~?#Gu>Zde=t*El~* zu*Z1aW{C9B3$^N&SYkOhQ3NXWUZ6tef}9vx#{?7xN&zF{Gftab*mm7{2{}OCh+wZ~ z|M7jER7atnl!o_*A9g2DqLKZ*;(=N{wS^jZ6{?A4UmnO+(E@H0^hspxB|$+oAVApy z7mH!3%ZKw-P~E1D)A4It>h2Ecfz0Y3suWGxkiOe%t8cWdf`XyIQ7 z%3x;5E1iSULG5~*sNEdxi<+PB90d{gEz-=u;)x}<6sOkwdrh~c9d0(2`vU-^QX-C~ zX8)!ii~`DTM2@8aOX@Dk;K!4nmHMyImgEB@kV>MYEQnBiF}5c8^5xADb+T-R5P?PI z9}ju^=wJVP($(+ll1fY*AH-Sf8VJ&-^Xl~!qxWz%{+JoPA$X49PrB%Y-2a|~+ZTy` z7*W6dL1P%EJpcX>!U1*;b7O(_R8=Y6XOAC`CkTxF|DLzor*H67lR+5^)OJ-+-Fdqe zzMZ&D@xej1Rtt4ia9(Z#KVR(Bf4$gieW21&EH4yOI#A+9tGA0wU0vuT9l}4hQ=%zw zJ7p4J%x$Qm0;58vAK%h+=D+5-iDDH8S8wGUgiJFs1t%B&;8@uul+ud@#7!=|NTu()(Z!3 mm8&RsNBjTzx4TeF;+Pt}&~T*mFDlBzD&A9-E4=&g`Tqdqxw2;f diff --git a/docs/figure9-graph-study/published-ivf-reference.png b/docs/figure9-graph-study/published-ivf-reference.png deleted file mode 100644 index 2b18e14095fad662c291f335026b633741531069..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 319379 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%0AHz=$bFAJ}eR4|} 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

    X#M=H?QG`mSmso%o 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 diff --git a/docs/figure9-graph-study/quality-results.csv b/docs/figure9-graph-study/quality-results.csv deleted file mode 100644 index 234071376..000000000 --- a/docs/figure9-graph-study/quality-results.csv +++ /dev/null @@ -1,9 +0,0 @@ -name,threads,overquery,qps,qps_cv,recall,visited,log -ada002-1M-b4-pq-r2,1,1.0,2213.5294641529763,2.656232061739049,0.78124,741.0503,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/ada002-1M-b4-pq-r2/quality-1.log -ada002-1M-b4-pq-r2,1,2.0,1697.0094228980922,0.5827720462316611,0.94098,1071.2316,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/ada002-1M-b4-pq-r2/quality-1.log -cap-1M-b2-ash-r2,1,1.0,2671.8321513333003,6.581649160723063,0.79039,751.5542,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/cap-1M-b2-ash-r2/quality-1.log -cap-1M-b2-ash-r2,1,2.0,2181.25379117744,1.411287086771372,0.94304,1096.9158,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/cap-1M-b2-ash-r2/quality-1.log -cap-1M-b4-pq-r2,1,1.0,2463.1515004291546,5.7424423017788095,0.77117,721.4095,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/cap-1M-b4-pq-r2/quality-1.log -cap-1M-b4-pq-r2,1,2.0,1936.7856067073103,0.9009159641326212,0.93402,1029.0857,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/cap-1M-b4-pq-r2/quality-1.log -cohere-english-v3-1M-b4-ash-r1,1,1.0,3659.88651139761,3.3352298962798885,0.79959,829.839,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/cohere-english-v3-1M-b4-ash-r1/quality-1.log -cohere-english-v3-1M-b4-ash-r1,1,2.0,2652.860877518716,0.8181655322064287,0.91214,1232.4703,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/cohere-english-v3-1M-b4-ash-r1/quality-1.log diff --git a/docs/figure9-graph-study/repeat-summary.json b/docs/figure9-graph-study/repeat-summary.json deleted file mode 100644 index a2085b556..000000000 --- a/docs/figure9-graph-study/repeat-summary.json +++ /dev/null @@ -1,37 +0,0 @@ -[ - { - "case": "cohere-1m-b4-ash", - "build_s": [ - "203.342001607" - ], - "gc_pause_s": 0.6757420000000003, - "max_gc_pause_ms": 38.576, - "compilation_events": { - "nmethod": 7407, - "make_not_entrant": 2082, - "deoptimized": 1528, - "failure": 1 - }, - "compilation_failures": { - "concurrent class loading": 1 - } - }, - { - "case": "cohere-1m-b4-pq", - "build_s": [ - "350.110698789" - ], - "gc_pause_s": 1.651136, - "max_gc_pause_ms": 69.35, - "compilation_events": { - "nmethod": 6856, - "make_not_entrant": 1841, - "deoptimized": 903, - "failure": 2 - }, - "compilation_failures": { - "retry without subsuming loads": 1, - "concurrent class loading": 1 - } - } -] \ No newline at end of file diff --git a/docs/figure9-graph-study/report-scripts/data.py b/docs/figure9-graph-study/report-scripts/data.py deleted file mode 100644 index c3e1bf3ab..000000000 --- a/docs/figure9-graph-study/report-scripts/data.py +++ /dev/null @@ -1,31 +0,0 @@ -# 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. - -"""Preserve primary observations and explicitly overlay predeclared longer retimings.""" -from pathlib import Path -import csv - -def load_rows(): - p=Path(__file__).resolve().parent.parent - rows=list(csv.DictReader((p/'results.csv').open())) - for x in rows:x['throughput_source']='primary-3s';x['original_qps']=x['qps'];x['original_qps_cv']=x['qps_cv'] - if (p/'quality-results.csv').exists(): - for q in csv.DictReader((p/'quality-results.csv').open()): - x=next(x for x in rows if x['name']==q['name'] and x['threads']==q['threads'] and float(x['overquery'])==float(q['overquery'])) - assert float(x['recall'])==float(q['recall']) and float(x['visited'])==float(q['visited']) - x.update(qps=q['qps'],qps_cv=q['qps_cv'],throughput_source='cached-10s: '+q['log']) - if len(rows)==96: - with (p/'reported-results.csv').open('w') as f: - w=csv.DictWriter(f,fieldnames=list(rows[0]));w.writeheader();w.writerows(rows) - return rows diff --git a/docs/figure9-graph-study/report-scripts/finalize.py b/docs/figure9-graph-study/report-scripts/finalize.py deleted file mode 100644 index bc2dcd3eb..000000000 --- a/docs/figure9-graph-study/report-scripts/finalize.py +++ /dev/null @@ -1,60 +0,0 @@ -# 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. - -from pathlib import Path -import csv,statistics,json,subprocess -p=Path(__file__).resolve().parent.parent -from data import load_rows -rows=load_rows() -assert len(rows)==96,len(rows) -for r in rows: - for k in ['bits','rep','threads','overquery','qps','recall','build_s','provider_s']: - r[k]=float(r[k]) if r[k] else 0. -by={} -for r in rows: - key=(r['dataset'],r['bits'],r['mode'],r['rep'],r['overquery']) - by.setdefault(key,{})[r['threads']]=r -for key,pair in by.items(): - assert set(pair)=={1,48},key - assert pair[1]['recall']==pair[48]['recall'],key - assert pair[1]['visited']==pair[48]['visited'],key -for r in rows:assert r['qps']>0 and 0<=r['recall']<=1 -subprocess.run(['python3',str(Path(__file__).with_name('tables.py'))],check=True) -subprocess.run(['python3',str(Path(__file__).with_name('plot.py'))],check=True) -def group(ds,b,m,o):return [r for r in rows if r['dataset']==ds and r['bits']==b and r['mode']==m and r['threads']==1 and r['overquery']==o] -def avg(rr,k):return statistics.mean(r[k] for r in rr) -dslist=['ada002-1M','cap-1M','cohere-english-v3-1M'] -rec_wins=qps_wins=build_wins=0 -for ds in dslist: - for b in (2,4): - a,q=group(ds,b,'ASH',1),group(ds,b,'PQ',1) - build_wins+=avg(a,'build_s')+avg(a,'provider_s')avg(q,'recall') - qps_wins+=avg(a,'qps')>avg(q,'qps') -text='# Production ASH versus latest-main PQ: Figure 9 graph study\n\n' -text+=f'Completed 24 fresh builds and 24 cached-index concurrency passes. ASH has higher mean recall at **{rec_wins}/12** matched query operating points, higher mean single-thread QPS at **{qps_wins}/12**, and faster graph construction plus scorer setup in **{build_wins}/6** configurations. These counts summarize the measured points; they do not imply a universal winner at matched recall.\n\n' -text+='The new symmetric scorer is used for encoded-node comparisons during construction. Production ASH queries remain asymmetric and include raw-query projection. Neither branch includes the separate duplicate-ID fix.\n\n' -text+='## Recommendation\n\nKeep the corrected symmetric scorer for construction and asymmetric scoring for raw queries. At these measured settings, ASH improves recall in every comparison and builds faster, but it has not universally beaten PQ on query throughput: Ada and CAP still have a throughput gap. Cohere wins both query metrics, while its original construction outlier remains unexplained. Prioritize measured score-kernel and ThreadLocal overhead before changing graph pruning policy; keep deduplication experiments separate.\n\n' -text+='## Plots\n\n![Single-thread graph QPS and recall](graph-qps-recall.png)\n\n![Construction phases](graph-construction.png)\n\n![Cached-index concurrency](graph-concurrency.png)\n\n' -text+='## Published IVF reference\n\n![Published Figure 9 panels, unchanged](published-ivf-reference.png)\n\nThe reference panels retain the paper’s original colors (red ASH, blue PQ); the new graph plots use blue ASH and red PQ. No IVF benchmark was rerun. These are architectural comparisons, with material differences: graph ASH uses C=1 and 20D training, while paper IVF uses C=32 and 10D; graph PQ uses JVector 8-bit ADC while paper PQ uses Faiss 4-bit FastScan; the graph runs use NVQ reranking. Increasing graph overquery also enlarges its search candidate set, so its gain is not solely a reranking ablation.\n\n' -text+='## Integration and validation\n\n[Branch audit](branch-audit.md) records the completed work integrated into ash-dev. The production baseline is unchanged from the original frozen ASH JAR; the byte-write fix was present throughout. Scalar and SIMD regression executions each passed 33 tests.\n\n' -for extra in ['cohere-investigation.md','timing-validation.md']: - if (p/extra).exists():text+=(p/extra).read_text()+'\n\n' -text+=(p/'tables.md').read_text()+'\n\n'+(p/'methodology.md').read_text() -text+='\n## Reusable artifacts\n\n[Reported observations](reported-results.csv) explicitly identify longer cached-query retimings and preserve original QPS/CV columns. [Original observations](results.csv) include each build repetition, both query concurrencies, timing variation, recall, visited counts and phase times. Every retained index has its own configuration and compressor cache. The remote query-only helper is `/mnt/raid10/jvector-bench/ted_willke/figure9-graph/retest.py`. For example:\n\n```sh\npython3 /mnt/raid10/jvector-bench/ted_willke/figure9-graph/retest.py ada002-1M-b2-ash-r1 --threads 1 --overquery 1.0,2.0\n```\n\nAll 48 serial/concurrent operating-point checks compare complete ranked results and visited counts for every supplied query at each index/overquery setting. Separate reported recall and visited metrics agree exactly between serial and concurrent passes. The concurrency harness is committed as `638edc37`.\n' -(p/'report.md').write_text(text) -print(dict(recall_wins=rec_wins,qps_wins=qps_wins,build_wins=build_wins)) diff --git a/docs/figure9-graph-study/report-scripts/plot.py b/docs/figure9-graph-study/report-scripts/plot.py deleted file mode 100644 index a5982e45d..000000000 --- a/docs/figure9-graph-study/report-scripts/plot.py +++ /dev/null @@ -1,85 +0,0 @@ -# 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. - -from pathlib import Path -import csv,statistics,json -import numpy as np -import matplotlib -matplotlib.use('Agg') -import matplotlib.pyplot as plt -from matplotlib.lines import Line2D -from matplotlib.patches import Patch -p=Path(__file__).resolve().parent.parent -from data import load_rows -rows=load_rows() -for r in rows: - for k in ['bits','rep','threads','overquery','qps','recall','visited','build_s','train_s','encode_s','provider_s','nvq_s']: - if k in r:r[k]=float(r[k]) if r[k] else 0. -datasets=[('ada002-1M','Ada002-1M'),('cap-1M','CAP-1M'),('cohere-english-v3-1M','Cohere-1M')] -colors={'ASH':'#145ee8','PQ':'#d62929'} -preliminary = len(rows) < 96 -plt.rcParams.update({'font.size':11,'axes.spines.top':False,'axes.spines.right':False,'savefig.dpi':180}) -def selected(ds,b,m,t,o):return [r for r in rows if r['dataset']==ds and r['bits']==b and r['mode']==m and r['threads']==t and r['overquery']==o] -def avg(rr,key):return statistics.mean(r[key] for r in rr) -fig,axs=plt.subplots(1,3,figsize=(15,4.7),layout='constrained') -for ax,(ds,title) in zip(axs,datasets): - for b in (2,4): - for mode in ('ASH','PQ'): - rr=[selected(ds,b,mode,1,o) for o in (1,2)] - if not all(rr):continue - xx=[avg(x,'recall')*100 for x in rr];yy=[avg(x,'qps') for x in rr] - ax.plot(xx,yy,color=colors[mode],ls='--' if b==2 else '-',marker='x',ms=7,lw=2) - for o,x,y in zip((1,2),xx,yy):ax.annotate(f'{o}×',(x,y),xytext=(5,5 if mode=='ASH' else -12),textcoords='offset points',fontsize=9,color=colors[mode]) - ax.margins(x=.08,y=.12);ax.set_title(title);ax.set_xlabel('Recall@10 (%)');ax.set_ylabel('Single-thread QPS');ax.grid(alpha=.2) -fig.suptitle('Production graph: ASH vs latest-main PQ · NVQ reranking' + (' (preliminary)' if preliminary else '')) -fig.legend(handles=[Line2D([],[],color=colors[m],ls=ls,lw=2,label=f'{m} · {label}') for b,ls,label in [(2,'--','32× nominal'),(4,'-','16× nominal')] for m in ['ASH','PQ']],loc='outside lower center',ncol=4,handlelength=3.5) -fig.savefig(p/'graph-qps-recall.png');plt.close(fig) -fig,axs=plt.subplots(1,3,figsize=(15,4.7),layout='constrained') -for ax,(ds,title) in zip(axs,datasets): - for x,b in enumerate((2,4)): - for shift,m in [(-.18,'ASH'),(.18,'PQ')]: - rr=selected(ds,b,m,1,1) - if not rr:continue - stages=[avg(rr,'train_s'),avg(rr,'encode_s')+avg(rr,'provider_s')+avg(rr,'nvq_s'),avg(rr,'build_s')] - bottom=0 - for val,hatch in zip(stages,['///','xxx','']): - ax.bar(x+shift,val,.32,bottom=bottom,color=colors[m],hatch=hatch,edgecolor='white',linewidth=.8) - bottom+=val - totals=[sum(z[k] for k in ['train_s','encode_s','provider_s','nvq_s','build_s']) for z in rr] - if len(totals)>1: ax.errorbar(x+shift,bottom,yerr=[[bottom-min(totals)],[max(totals)-bottom]],fmt='none',ecolor='black',capsize=4,lw=1) - ax.text(x+shift,max(totals)+2,f'{bottom:.0f}s',ha='center',fontsize=9) - ax.set_xticks([0,1],['32× nominal\n2-bit ASH','16× nominal\n4-bit ASH']);ax.set_title(title);ax.set_ylabel('Indexing phase seconds');ax.grid(axis='y',alpha=.2) -fig.suptitle('Fresh production index construction · ' + ('preliminary observations' if preliminary else 'mean of two builds; whiskers show range')) -fig.legend(handles=[Patch(facecolor=colors[m],label=m) for m in ['ASH','PQ']]+[Patch(facecolor='gray',edgecolor='white',hatch=h,label=t) for h,t in [('///','Training'),('xxx','Encoding + setup'),('','Graph build/write')]],loc='outside lower center',ncol=5) -fig.savefig(p/'graph-construction.png');plt.close(fig) -fig,axs=plt.subplots(1,3,figsize=(15,4.7),layout='constrained') -for ax,(ds,title) in zip(axs,datasets): - for b in (2,4): - for m in ['ASH','PQ']: - for o,mark in [(1,'o'),(2,'x')]: - rr=[selected(ds,b,m,t,o) for t in (1,48)] - if not all(rr):continue - yy=[avg(x,'qps') for x in rr] - ax.plot([1,48],yy,color=colors[m],ls='--' if b==2 else '-',marker=mark,lw=1.5) - ax.set_xscale('log');ax.set_yscale('log');ax.set_xticks([1,48],['1','48']);ax.set_title(title);ax.set_xlabel('Query workers');ax.set_ylabel('QPS');ax.grid(alpha=.2) -fig.suptitle('Same cached graphs: single-thread and concurrent query throughput') -fig.legend(handles=[Line2D([],[],color=colors[m],label=m) for m in ['ASH','PQ']]+[Line2D([],[],color='black',ls=ls,label=label) for ls,label in [('--','32× nominal'),('-','16× nominal')]]+[Line2D([],[],color='black',ls='',marker=m,label=f'{o}× overquery') for m,o in [('o',1),('x',2)]],loc='outside lower center',ncol=6,handlelength=3) -fig.savefig(p/'graph-concurrency.png');plt.close(fig) -# Published raster panels are shown unchanged; they are not new measurements. -fig,axs=plt.subplots(1,3,figsize=(15,5.2),layout='constrained') -for i,ax in enumerate(axs): - ax.imshow(plt.imread(p/f'paper-panel-{i}.png'));ax.axis('off') -fig.suptitle('Published IVF results — Figure 9 (unchanged; original paper colors)') -fig.savefig(p/'published-ivf-reference.png');plt.close(fig) -print('Plots written') diff --git a/docs/figure9-graph-study/report-scripts/tables.py b/docs/figure9-graph-study/report-scripts/tables.py deleted file mode 100644 index 457b355c5..000000000 --- a/docs/figure9-graph-study/report-scripts/tables.py +++ /dev/null @@ -1,64 +0,0 @@ -# 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. - -from pathlib import Path -import csv,statistics -p=Path(__file__).resolve().parent.parent -from data import load_rows -rows=load_rows() -for r in rows: - for k in ['bits','rep','threads','overquery','qps','recall','visited','build_s','train_s','encode_s','provider_s','nvq_s']: - if k in r:r[k]=float(r[k]) if r[k] else 0. -def sel(ds,b,m,t,o):return [r for r in rows if r['dataset']==ds and r['bits']==b and r['mode']==m and r['threads']==t and r['overquery']==o] -def mean(rr,key):return statistics.mean(x[key] for x in rr) -dslist=['ada002-1M','cap-1M','cohere-english-v3-1M'] -text='# Final graph results\n\nMeans of two independent builds per configuration. ASH/PQ values are shown in that order.\n\n' -text+='## Single-threaded queries\n\n| Dataset | ASH bits / PQ matching budget | Overquery | Recall@10 (%): ASH / PQ | QPS: ASH / PQ | ASH/PQ QPS | Recall difference (pp) |\n|---|---:|---:|---:|---:|---:|---:|\n' -for ds in dslist: - for b in (2,4): - for o in (1,2): - aa,pp=sel(ds,b,'ASH',1,o),sel(ds,b,'PQ',1,o) - if len(aa)!=2 or len(pp)!=2:continue - ar,pr=mean(aa,'recall')*100,mean(pp,'recall')*100 - aq,pq=mean(aa,'qps'),mean(pp,'qps') - text+=f'| {ds} | {b} | {o}× | {ar:.3f} / {pr:.3f} | {aq:,.0f} / {pq:,.0f} | {aq/pq:.2f}× | {ar-pr:+.3f} |\n' -text+='\n## Construction\n\nGraph column includes scorer-provider setup. Phase subtotal also includes compressor training, base encoding and NVQ compressor setup; it excludes dataset loading, JVM startup and uninstrumented writer initialization.\n\n| Dataset | Bits/budget | Graph + provider seconds: ASH / PQ | Training seconds: ASH / PQ | Encoding seconds: ASH / PQ | Measured phase subtotal seconds: ASH / PQ |\n|---|---:|---:|---:|---:|---:|\n' -for ds in dslist: - for b in (2,4): - rr=[sel(ds,b,m,1,1) for m in ['ASH','PQ']] - if any(len(x)!=2 for x in rr):continue - v=[] - for x in rr: - graph=mean(x,'build_s')+mean(x,'provider_s');train=mean(x,'train_s');enc=mean(x,'encode_s');total=graph+train+enc+mean(x,'nvq_s');v.append((graph,train,enc,total)) - vals=[' / '.join(f'{v[i][j]:.2f}' for i in (0,1)) for j in range(4)] - text+=f'| {ds} | {b} | '+' | '.join(vals)+' |\n' -text+='\n## Same cached indexes, 48 query workers\n\n| Dataset | Bits/budget | Overquery | Concurrent QPS: ASH / PQ | Speedup over one worker: ASH / PQ |\n|---|---:|---:|---:|---:|\n' -for ds in dslist: - for b in (2,4): - for o in (1,2): - aa,pp=sel(ds,b,'ASH',48,o),sel(ds,b,'PQ',48,o) - if len(aa)!=2 or len(pp)!=2:continue - aq,pq=mean(aa,'qps'),mean(pp,'qps');asq=mean(sel(ds,b,'ASH',1,o),'qps');psq=mean(sel(ds,b,'PQ',1,o),'qps') - text+=f'| {ds} | {b} | {o}× | {aq:,.0f} / {pq:,.0f} | {aq/asq:.2f}× / {pq/psq:.2f}× |\n' -text+='\n## Between-build ranges\n\nRanges of two observations, not confidence intervals.\n\n| Dataset | Bits/budget | Method | Overquery | Single-thread QPS range | Recall@10 (%) range | Graph + setup seconds range |\n|---|---:|---|---:|---:|---:|---:|\n' -for ds in dslist: - for b in (2,4): - for m in ['ASH','PQ']: - for o in (1,2): - rr=sel(ds,b,m,1,o) - if len(rr)!=2:continue - q=[x['qps'] for x in rr];r=[x['recall']*100 for x in rr];g=[x['build_s']+x['provider_s'] for x in rr] - text+=f'| {ds} | {b} | {m} | {o}× | {min(q):,.0f}–{max(q):,.0f} | {min(r):.3f}–{max(r):.3f} | {min(g):.2f}–{max(g):.2f} |\n' -(p/'tables.md').write_text(text) -print('Table rows available',len(rows)) diff --git a/docs/figure9-graph-study/report.md b/docs/figure9-graph-study/report.md deleted file mode 100644 index d223d8abe..000000000 --- a/docs/figure9-graph-study/report.md +++ /dev/null @@ -1,213 +0,0 @@ -# Production ASH versus latest-main PQ: Figure 9 graph study - -Completed 24 fresh builds and 24 cached-index concurrency passes. ASH has higher mean recall at **12/12** matched query operating points, higher mean single-thread QPS at **4/12**, and faster graph construction plus scorer setup in **6/6** configurations. These counts summarize the measured points; they do not imply a universal winner at matched recall. - -The new symmetric scorer is used for encoded-node comparisons during construction. Production ASH queries remain asymmetric and include raw-query projection. Neither branch includes the separate duplicate-ID fix. - -## Recommendation - -Keep the corrected symmetric scorer for construction and asymmetric scoring for raw queries. At these measured settings, ASH improves recall in every comparison and builds faster, but it has not universally beaten PQ on query throughput: Ada and CAP still have a throughput gap. Cohere wins both query metrics, while its original construction outlier remains unexplained. Prioritize measured score-kernel and ThreadLocal overhead before changing graph pruning policy; keep deduplication experiments separate. - -## Plots - -![Single-thread graph QPS and recall](graph-qps-recall.png) - -![Construction phases](graph-construction.png) - -![Cached-index concurrency](graph-concurrency.png) - -## Published IVF reference - -![Published Figure 9 panels, unchanged](published-ivf-reference.png) - -The reference panels retain the paper’s original colors (red ASH, blue PQ); the new graph plots use blue ASH and red PQ. No IVF benchmark was rerun. These are architectural comparisons, with material differences: graph ASH uses C=1 and 20D training, while paper IVF uses C=32 and 10D; graph PQ uses JVector 8-bit ADC while paper PQ uses Faiss 4-bit FastScan; the graph runs use NVQ reranking. Increasing graph overquery also enlarges its search candidate set, so its gain is not solely a reranking ablation. - -## Integration and validation - -[Branch audit](branch-audit.md) records the completed work integrated into ash-dev. The production baseline is unchanged from the original frozen ASH JAR; the byte-write fix was present throughout. Scalar and SIMD regression executions each passed 33 tests. - -## Cohere construction investigation - -The first 4-bit Cohere runs were anomalous. Their timings are retained in the primary tables and ranges; they are not silently replaced with faster repeats. The integrated ash-dev code is identical to the frozen ASH production code used for those runs. - -| Cohere 4-bit run | ASH graph build/write | PQ graph build/write | -|---|---:|---:| -| Primary repetition 1, no JFR | 658.45 s | 1,224.67 s | -| Primary repetition 2, short JFR capture | 158.05 s | 320.37 s | -| Additional control, no startup JFR | 203.34 s | 350.11 s | - -ASH's first/second insertion times were 511.91/89.53 seconds; cleanup 82.69/5.49 seconds; flush 63.85/63.03 seconds. This localizes that anomaly to graph computation rather than the bulk byte-write fix or SSD flush throughput. The extra control's insertion/cleanup/flush times were 105.59/5.58/92.17 seconds. - -### What the evidence establishes - -- The byte-write fix is present in the actual frozen JAR. No production-code change caused the faster second repetition. -- Source dispatch and live profiles use the intended SIMD kernels for d=512. There is no dimension-based Java dispatch to a scalar ASH kernel. This does not by itself guarantee that every Vector API operation is optimally compiled. -- During the monitored Cohere runs there was ample available memory, no swap, and zero sampled memory-pressure PSI. Each build used a fresh JVM. The pre-resume host had 340 GiB available and no benchmark JVM running. These observations cannot reconstruct the complete system state of the first repetition. -- Full-JVM GC pause totals in the monitored runs were 0.93–2.48 seconds. Large allocation volumes exist, particularly in the 2-bit PQ run, but long stop-the-world pauses do not explain the monitored build costs. JFR shows boxed Vector API objects and array allocation, including NVQ's nvqLoss path. The sampled allocation weights are estimates, not exact per-method allocation accounting. -- The active PQ construction implementation is ImmutablePQVectors' cached centroid-pair lookup. In its 4-bit profile, assembleAndSumPQ_512 was the top frame in 54.1% of execution samples, ThreadLocal.get in 25.2%, and assembleAndSum512 in 14.2%. ASH's 4-bit profile was concentrated in packed asymmetric projection scoring and symmetric packed comparisons. These are sampled CPU-stack proportions, not an exact wall-time decomposition. -- Hardware cycles/instructions/cache counters are not exposed by this VM's perf interface. Therefore these measurements do not distinguish execution throughput from cache/memory stalls precisely. Historical sar files were stale and did not cover the first runs. - -### Diagnostic comparison counts - -A separate diagnostic class counted actual pair evaluations while preserving the pruning decisions and score arithmetic. It was not added to production code. Cohere-100k is a scale control, not a substitute for measuring the original 1M execution history. - -| Method / budget | Prune calls | Candidate checks | Pair comparisons | Diagnostic build/write | -|---|---:|---:|---:|---:| -| ASH 2-bit | 661,994 | 87,840,870 | 1,503,229,262 | 13.27 s | -| ASH 4-bit | 667,048 | 91,887,956 | 1,504,819,172 | 16.91 s | -| PQ matching 2-bit | 669,293 | 82,090,179 | 1,535,016,576 | 16.10 s | -| PQ matching 4-bit | 673,297 | 87,645,603 | 1,539,002,008 | 36.35 s | - -Pair counts barely change with bit budget in this control. It supports the expected increase in cost per score, rather than a multiplicative increase in pair count at this scale. It does not identify the cause of the historical 1M outlier. - -One initial diagnostic completed its build but failed in post-build reporting because an empty throughput key still enabled the benchmark. The configuration was corrected by omitting that key and the four diagnostics were rerun successfully. The failed attempt remains archived separately and is excluded from the table above. - -### Remaining limitation - -The first 1M outlier has not yet been causally explained. A missing byte-write fix and a fixed dimensionality fallback are not supported by the evidence. Runtime compilation and execution-state effects remain hypotheses; the original runs lacked the relevant GC/JIT/system traces. The additional controls completed without reproducing the large slowdown. Their full-JVM GC pauses totaled 0.676 seconds for ASH and 1.651 seconds for PQ; available memory stayed above 321/318 GiB, respectively, with zero sampled memory-pressure PSI. Compilation logs recorded class-loading/retry events, but those events alone establish neither a persistent kernel compilation failure nor the cause of the first outlier. No speculative production fix was applied. Do not use the outlying Cohere mean as a precise capacity-planning estimate. - - -## Timing validation - -The predeclared rule retimed any case/JVM with a sample CV above 10%, using both overquery settings and at least 10 seconds per sample (three samples). Four cached-index JVMs were retimed. All recall and visited-count values matched the originals exactly. No reconstruction was performed. These longer observations supply QPS in the reported tables; original observations remain in results.csv and in the original columns of reported-results.csv. - -| Case | Overquery | Original QPS | Longer QPS | Original CV | Longer CV | -|---|---:|---:|---:|---:|---:| -| ada002-1M-b4-pq-r2 | 1× | 1,821 | 2,214 | 30.7% | 2.7% | -| ada002-1M-b4-pq-r2 | 2× | 1,588 | 1,697 | 14.3% | 0.6% | -| cap-1M-b2-ash-r2 | 1× | 2,258 | 2,672 | 23.8% | 6.6% | -| cap-1M-b2-ash-r2 | 2× | 2,053 | 2,181 | 2.7% | 1.4% | -| cap-1M-b4-pq-r2 | 1× | 1,997 | 2,463 | 18.8% | 5.7% | -| cap-1M-b4-pq-r2 | 2× | 1,628 | 1,937 | 15.1% | 0.9% | -| cohere-english-v3-1M-b4-ash-r1 | 1× | 2,915 | 3,660 | 18.9% | 3.3% | -| cohere-english-v3-1M-b4-ash-r1 | 2× | 2,319 | 2,653 | 2.8% | 0.8% | - - -# Final graph results - -Means of two independent builds per configuration. ASH/PQ values are shown in that order. - -## Single-threaded queries - -| Dataset | ASH bits / PQ matching budget | Overquery | Recall@10 (%): ASH / PQ | QPS: ASH / PQ | ASH/PQ QPS | Recall difference (pp) | -|---|---:|---:|---:|---:|---:|---:| -| ada002-1M | 2 | 1× | 82.724 / 61.028 | 2,087 / 2,627 | 0.79× | +21.696 | -| ada002-1M | 2 | 2× | 95.969 / 81.254 | 1,938 / 2,143 | 0.90× | +14.715 | -| ada002-1M | 4 | 1× | 88.771 / 78.085 | 1,817 / 2,255 | 0.81× | +10.686 | -| ada002-1M | 4 | 2× | 96.114 / 94.155 | 1,693 / 1,771 | 0.96× | +1.959 | -| cap-1M | 2 | 1× | 79.109 / 57.654 | 2,557 / 2,561 | 1.00× | +21.456 | -| cap-1M | 2 | 2× | 94.369 / 77.959 | 2,075 / 2,111 | 0.98× | +16.410 | -| cap-1M | 4 | 1× | 86.525 / 77.097 | 2,059 / 2,237 | 0.92× | +9.427 | -| cap-1M | 4 | 2× | 95.438 / 93.395 | 1,734 / 1,789 | 0.97× | +2.043 | -| cohere-english-v3-1M | 2 | 1× | 70.487 / 66.674 | 3,872 / 3,657 | 1.06× | +3.813 | -| cohere-english-v3-1M | 2 | 2× | 88.550 / 85.674 | 3,356 / 2,880 | 1.17× | +2.876 | -| cohere-english-v3-1M | 4 | 1× | 79.983 / 73.727 | 3,461 / 3,055 | 1.13× | +6.255 | -| cohere-english-v3-1M | 4 | 2× | 91.210 / 89.178 | 2,612 / 2,323 | 1.12× | +2.031 | - -## Construction - -Graph column includes scorer-provider setup. Phase subtotal also includes compressor training, base encoding and NVQ compressor setup; it excludes dataset loading, JVM startup and uninstrumented writer initialization. - -| Dataset | Bits/budget | Graph + provider seconds: ASH / PQ | Training seconds: ASH / PQ | Encoding seconds: ASH / PQ | Measured phase subtotal seconds: ASH / PQ | -|---|---:|---:|---:|---:|---:| -| ada002-1M | 2 | 156.20 / 309.93 | 9.43 / 6.69 | 7.65 / 4.73 | 173.79 / 321.90 | -| ada002-1M | 4 | 230.94 / 489.46 | 8.21 / 6.97 | 6.14 / 4.86 | 245.86 / 501.90 | -| cap-1M | 2 | 152.19 / 276.91 | 9.18 / 16.51 | 7.11 / 4.54 | 168.98 / 298.45 | -| cap-1M | 4 | 224.20 / 406.63 | 8.43 / 7.55 | 7.37 / 4.72 | 240.52 / 419.45 | -| cohere-english-v3-1M | 2 | 145.53 / 206.35 | 5.33 / 5.44 | 1.80 / 3.86 | 153.08 / 216.02 | -| cohere-english-v3-1M | 4 | 410.35 / 772.52 | 4.54 / 5.85 | 2.23 / 3.28 | 417.54 / 782.05 | - -## Same cached indexes, 48 query workers - -| Dataset | Bits/budget | Overquery | Concurrent QPS: ASH / PQ | Speedup over one worker: ASH / PQ | -|---|---:|---:|---:|---:| -| ada002-1M | 2 | 1× | 95,507 / 96,984 | 45.76× / 36.92× | -| ada002-1M | 2 | 2× | 74,420 / 81,508 | 38.40× / 38.03× | -| ada002-1M | 4 | 1× | 77,725 / 91,585 | 42.79× / 40.62× | -| ada002-1M | 4 | 2× | 58,574 / 69,742 | 34.60× / 39.39× | -| cap-1M | 2 | 1× | 95,767 / 99,468 | 37.45× / 38.84× | -| cap-1M | 2 | 2× | 78,606 / 85,994 | 37.88× / 40.74× | -| cap-1M | 4 | 1× | 82,883 / 91,129 | 40.26× / 40.74× | -| cap-1M | 4 | 2× | 62,894 / 71,334 | 36.28× / 39.86× | -| cohere-english-v3-1M | 2 | 1× | 155,768 / 138,016 | 40.23× / 37.74× | -| cohere-english-v3-1M | 2 | 2× | 112,761 / 109,256 | 33.60× / 37.94× | -| cohere-english-v3-1M | 4 | 1× | 129,480 / 126,769 | 37.41× / 41.49× | -| cohere-english-v3-1M | 4 | 2× | 94,590 / 95,306 | 36.21× / 41.03× | - -## Between-build ranges - -Ranges of two observations, not confidence intervals. - -| Dataset | Bits/budget | Method | Overquery | Single-thread QPS range | Recall@10 (%) range | Graph + setup seconds range | -|---|---:|---|---:|---:|---:|---:| -| ada002-1M | 2 | ASH | 1× | 1,996–2,178 | 82.642–82.806 | 151.60–160.79 | -| ada002-1M | 2 | ASH | 2× | 1,803–2,073 | 95.953–95.985 | 151.60–160.79 | -| ada002-1M | 2 | PQ | 1× | 2,506–2,749 | 60.969–61.087 | 305.36–314.50 | -| ada002-1M | 2 | PQ | 2× | 2,071–2,215 | 81.207–81.301 | 305.36–314.50 | -| ada002-1M | 4 | ASH | 1× | 1,748–1,885 | 88.768–88.774 | 230.19–231.68 | -| ada002-1M | 4 | ASH | 2× | 1,691–1,695 | 96.112–96.117 | 230.19–231.68 | -| ada002-1M | 4 | PQ | 1× | 2,214–2,296 | 78.046–78.124 | 471.03–507.89 | -| ada002-1M | 4 | PQ | 2× | 1,697–1,844 | 94.098–94.212 | 471.03–507.89 | -| cap-1M | 2 | ASH | 1× | 2,442–2,672 | 79.039–79.180 | 147.18–157.20 | -| cap-1M | 2 | ASH | 2× | 1,969–2,181 | 94.304–94.435 | 147.18–157.20 | -| cap-1M | 2 | PQ | 1× | 2,539–2,583 | 57.475–57.832 | 235.00–318.82 | -| cap-1M | 2 | PQ | 2× | 2,102–2,119 | 77.755–78.163 | 235.00–318.82 | -| cap-1M | 4 | ASH | 1× | 1,969–2,149 | 86.483–86.566 | 221.44–226.96 | -| cap-1M | 4 | ASH | 2× | 1,710–1,757 | 95.431–95.445 | 221.44–226.96 | -| cap-1M | 4 | PQ | 1× | 2,011–2,463 | 77.078–77.117 | 384.54–428.72 | -| cap-1M | 4 | PQ | 2× | 1,642–1,937 | 93.387–93.402 | 384.54–428.72 | -| cohere-english-v3-1M | 2 | ASH | 1× | 3,712–4,032 | 70.446–70.528 | 133.48–157.58 | -| cohere-english-v3-1M | 2 | ASH | 2× | 2,991–3,721 | 88.522–88.578 | 133.48–157.58 | -| cohere-english-v3-1M | 2 | PQ | 1× | 3,555–3,760 | 66.659–66.689 | 202.06–210.65 | -| cohere-english-v3-1M | 2 | PQ | 2× | 2,755–3,005 | 85.658–85.690 | 202.06–210.65 | -| cohere-english-v3-1M | 4 | ASH | 1× | 3,263–3,660 | 79.959–80.006 | 160.09–660.61 | -| cohere-english-v3-1M | 4 | ASH | 2× | 2,572–2,653 | 91.205–91.214 | 160.09–660.61 | -| cohere-english-v3-1M | 4 | PQ | 1× | 3,054–3,057 | 73.716–73.738 | 320.37–1224.67 | -| cohere-english-v3-1M | 4 | PQ | 2× | 2,296–2,349 | 89.174–89.183 | 320.37–1224.67 | - - -# Figure 9 graph comparison — protocol - -Authoritative host: `ted_willke@34.169.37.132`, GCP c4-standard-96-lssd. ASH source: `/home/ted_willke/jvector-ash`, `ash-symmetric-scorer` commit `922b23e3`, plus benchmark-only explicit concurrency controls, subsequently committed as `638edc37`. PQ: verified GitHub origin/main `1a86718baa2d8b72a0d134711a682875888b9646`, isolated detached worktree. Identical ThroughputBenchmark source on both. No duplicate-ID changes. - -## Matrix - -Three datasets: Ada002-1M (982,790 base vectors) and CAP-1M (1,000,000), both D=1536; Cohere English v3-1M (1,000,000), D=1024. Each has 10,000 queries. User confirmed d=D/2 to match Figure 9: respectively 768, 768, 512. ASH b=2 and b=4. Graph ASH requires C=1. Production training remains 20D, max25 iterations with early stopping; paper IVF uses C=32 and 10D. The study does not change production training defaults. - -PQ uses production JVector K=256, uncentered, anisotropy disabled. The actual fused query path is `FusedPQDecoder.DotProductDecoder`, with float partial-sum tables and SIMD `VectorUtil.assembleAndSum`; it is not Faiss 4-bit FastScan. Match exact stored quantized-vector bytes including ASH's 5-byte header: M=197/389 for Ada002/CAP and M=133/261 for Cohere, with uneven PQ subvector dimensions supported. The nominal 32X/16X paper labels refer to payload only; actual graph code compression is slightly lower once the ASH header is counted. Faiss IVF in the paper uses packed 4-bit PQ; these are different production PQ implementations. - -Graph settings: M=64, efConstruction=200, overflow1.2, hierarchy/refinement enabled, fused neighborhoods, NVQ reranking, k=10 and rerankK=10/20 (overquery1/2), search pruning disabled. ASH retains production adaptive termination gamma0.01. PQ uses latest-main behavior, which has no ASH adaptive feature. ASH writes version7, latest-main PQ version6. This is a production-branch comparison, not an isolated quantizer ablation. - -## Timing and retention - -Two independent fresh compressor/graph builds per configuration (24 total), reversed ASH/PQ order on repetition2. No shared compressor cache across fresh repetitions. Each index is retained and reopened for a 48-query-worker concurrency pass. Explicit serial/concurrent checks compare complete ranked node IDs, scores and visited count for all test queries at each overquery setting. Recall and visited measurements are outside the throughput timer. - -Single-threaded QPS is the main result. Three timed samples, each at least3seconds and a complete query batch; raw durations and query counts logged. Existing synthetic warmup remains enabled. Query concurrency uses a dedicated 48-worker pool. Grid submits insertion to PhysicalCoreExecutor with 48 workers; GraphIndexBuilder cleanup uses its default common ForkJoinPool (normally parallelism 95 on this 96-logical-CPU JVM). Both branches use this arrangement. Workers are not explicitly pinned, unlike the paper’s 48 pinned build threads. Asymmetric projection/setup remains inside each query call for ASH. The graph uses corrected symmetric node comparisons and asymmetric raw-vector insertion/query scoring. - -JDK23, vector module, native access enabled, Xmx128g, ASH SIMD single/block kernels, production accumulator/unroll settings. Explicit `jvector-twenty` classpath supplies MemorySegmentReader; reader fallback warnings are checked. Temporary files and retained indexes are on RAID10 SSDs. These1M indexes fit RAM; warmed memory-mapped query throughput is not a larger-than-memory I/O result. - -Retain graph build/write time separately from training, base encoding and provider setup. Add NVQ compressor setup to these measured phases; their sum is a measured phase subtotal, excluding uninstrumented writer initialization overhead. Process elapsed time additionally includes dataset loading, JVM startup, warmup and queries and is not called construction time. - -The paper uses the same GCP c4-standard-96-lssd/Intel Xeon 6985P-C host class, 48 build cores and single-thread queries. Its IVF implementation is C++/AVX-512 with 4096 lists and an nprobe sweep; this graph experiment uses Java/JDK23 and two overquery settings. - -No IVF reruns. Published Figure9 panels are extracted from the supplied final PDF and shown separately from measured graph points. Any comparison across index types must retain the landmark/training/PQ implementation and reranking differences above. - -Remote evidence and reusable caches: `/mnt/raid10/jvector-bench/ted_willke/figure9-graph/`. Per-case directories contain YAMLs, `index_cache`, ASH `compressor_cache` or latest-main PQ `pq_cache`, logs and completion manifests. Frozen JARs and hashes are stored at the study root. - -## Interpretation - -The runtime profile confirms that graph construction uses `ImmutablePQVectors.diversityFunctionFor(DOT_PRODUCT)`, which caches centroid-pair partial sums and calls `VectorUtil.assembleAndSumPQ`. The base `PQVectors` implementation is not the active diversity scorer in these runs. Corrected ASH construction compares packed codes. Query scoring remains asymmetric in both configurations. - -Figure9 compares published IVF search curves. It does not report complete graph or IVF index-build times. Its ASH/PQ query paths do not use this graph's NVQ reranking configuration, so higher graph terminal recall must not be attributed solely to the quantizer. Graph construction is compared directly between the two measured graph implementations. - -The exact same graph is used for each serial/concurrent comparison; independent graph repetitions are used only for build/run variability. Two repetitions provide ranges, not narrow statistical confidence intervals. - -## Reusable artifacts - -[Reported observations](reported-results.csv) explicitly identify longer cached-query retimings and preserve original QPS/CV columns. [Original observations](results.csv) include each build repetition, both query concurrencies, timing variation, recall, visited counts and phase times. Every retained index has its own configuration and compressor cache. The remote query-only helper is `/mnt/raid10/jvector-bench/ted_willke/figure9-graph/retest.py`. For example: - -```sh -python3 /mnt/raid10/jvector-bench/ted_willke/figure9-graph/retest.py ada002-1M-b2-ash-r1 --threads 1 --overquery 1.0,2.0 -``` - -All 48 serial/concurrent operating-point checks compare complete ranked results and visited counts for every supplied query at each index/overquery setting. Separate reported recall and visited metrics agree exactly between serial and concurrent passes. The concurrency harness is committed as `638edc37`. diff --git a/docs/figure9-graph-study/reported-results.csv b/docs/figure9-graph-study/reported-results.csv deleted file mode 100644 index 18a5d5d1b..000000000 --- a/docs/figure9-graph-study/reported-results.csv +++ /dev/null @@ -1,97 +0,0 @@ -name,dataset,D,d,bits,mode,rep,code_bytes,threads,overquery,build_s,qps,recall,visited,qps_cv,train_s,encode_s,nvq_s,provider_s,log,throughput_source,original_qps,original_qps_cv -ada002-1M-b2-ash-r1,ada002-1M,1536,768,2,ASH,1,197,1,1.0,157.840009863,2177.8102536704814,0.82642,743.6996,2.089696662700664,9.230167,7.706519616,0.546488417,2.95154,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/ada002-1M-b2-ash-r1/query-1.log,primary-3s,2177.8102536704814,2.089696662700664 -ada002-1M-b2-ash-r1,ada002-1M,1536,768,2,ASH,1,197,1,2.0,157.840009863,2072.775696510429,0.95953,1099.5859,0.6538369048721234,9.230167,7.706519616,0.546488417,2.95154,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/ada002-1M-b2-ash-r1/query-1.log,primary-3s,2072.775696510429,0.6538369048721234 -ada002-1M-b2-ash-r1,ada002-1M,1536,768,2,ASH,1,197,48,1.0,,95956.71345138094,0.82642,743.6996,1.504784845208021,,,0.0,0,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/ada002-1M-b2-ash-r1/query-48.log,primary-3s,95956.71345138094,1.504784845208021 -ada002-1M-b2-ash-r1,ada002-1M,1536,768,2,ASH,1,197,48,2.0,,74038.29496332126,0.95953,1099.5859,0.2560122257189981,,,0.0,0,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/ada002-1M-b2-ash-r1/query-48.log,primary-3s,74038.29496332126,0.2560122257189981 -ada002-1M-b2-pq-r1,ada002-1M,1536,768,2,PQ,1,197,1,1.0,305.357773341,2505.50863765249,0.60969,777.0072,3.3329151504713037,6.223289688,4.361790065,0.51526343,0,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/ada002-1M-b2-pq-r1/query-1.log,primary-3s,2505.50863765249,3.3329151504713037 -ada002-1M-b2-pq-r1,ada002-1M,1536,768,2,PQ,1,197,1,2.0,305.357773341,2070.9961907891375,0.81301,1088.5782,0.6403227236256307,6.223289688,4.361790065,0.51526343,0,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/ada002-1M-b2-pq-r1/query-1.log,primary-3s,2070.9961907891375,0.6403227236256307 -ada002-1M-b2-pq-r1,ada002-1M,1536,768,2,PQ,1,197,48,1.0,,98002.36666217295,0.60969,777.0072,0.4664038965243055,,,0.0,0,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/ada002-1M-b2-pq-r1/query-48.log,primary-3s,98002.36666217295,0.4664038965243055 -ada002-1M-b2-pq-r1,ada002-1M,1536,768,2,PQ,1,197,48,2.0,,83275.93656993553,0.81301,1088.5782,2.4477334633540697,,,0.0,0,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/ada002-1M-b2-pq-r1/query-48.log,primary-3s,83275.93656993553,2.4477334633540697 -ada002-1M-b4-ash-r1,ada002-1M,1536,768,4,ASH,1,389,1,1.0,227.142034764,1885.1372911497197,0.88774,712.952,8.466917289018335,8.071419789,5.700532906,0.497370085,3.051166,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/ada002-1M-b4-ash-r1/query-1.log,primary-3s,1885.1372911497197,8.466917289018335 -ada002-1M-b4-ash-r1,ada002-1M,1536,768,4,ASH,1,389,1,2.0,227.142034764,1694.774941404327,0.96112,1036.2085,1.440621537569398,8.071419789,5.700532906,0.497370085,3.051166,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/ada002-1M-b4-ash-r1/query-1.log,primary-3s,1694.774941404327,1.440621537569398 -ada002-1M-b4-ash-r1,ada002-1M,1536,768,4,ASH,1,389,48,1.0,,78535.16386753105,0.88774,712.952,1.0498522055276105,,,0.0,0,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/ada002-1M-b4-ash-r1/query-48.log,primary-3s,78535.16386753105,1.0498522055276105 -ada002-1M-b4-ash-r1,ada002-1M,1536,768,4,ASH,1,389,48,2.0,,58078.37660324255,0.96112,1036.2085,1.0265098943456157,,,0.0,0,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/ada002-1M-b4-ash-r1/query-48.log,primary-3s,58078.37660324255,1.0265098943456157 -ada002-1M-b4-pq-r1,ada002-1M,1536,768,4,PQ,1,389,1,1.0,471.033761676,2296.391366518768,0.78046,743.3911,3.454129477444065,6.66483986,4.78256411,0.619976129,0,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/ada002-1M-b4-pq-r1/query-1.log,primary-3s,2296.391366518768,3.454129477444065 -ada002-1M-b4-pq-r1,ada002-1M,1536,768,4,PQ,1,389,1,2.0,471.033761676,1844.3575579359417,0.94212,1075.2136,0.5016351987560125,6.66483986,4.78256411,0.619976129,0,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/ada002-1M-b4-pq-r1/query-1.log,primary-3s,1844.3575579359417,0.5016351987560125 -ada002-1M-b4-pq-r1,ada002-1M,1536,768,4,PQ,1,389,48,1.0,,88588.06947666063,0.78046,743.3911,1.2252863478487552,,,0.0,0,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/ada002-1M-b4-pq-r1/query-48.log,primary-3s,88588.06947666063,1.2252863478487552 -ada002-1M-b4-pq-r1,ada002-1M,1536,768,4,PQ,1,389,48,2.0,,69034.79529813363,0.94212,1075.2136,0.3523116662786264,,,0.0,0,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/ada002-1M-b4-pq-r1/query-48.log,primary-3s,69034.79529813363,0.3523116662786264 -cap-1M-b2-ash-r1,cap-1M,1536,768,2,ASH,1,197,1,1.0,144.164132004,2442.48519676128,0.7918,752.783,6.871960701628724,9.336766347,7.555819332,0.49760735,3.01653,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/cap-1M-b2-ash-r1/query-1.log,primary-3s,2442.48519676128,6.871960701628724 -cap-1M-b2-ash-r1,cap-1M,1536,768,2,ASH,1,197,1,2.0,144.164132004,1969.1891971271289,0.94435,1099.8606,1.0289141912055246,9.336766347,7.555819332,0.49760735,3.01653,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/cap-1M-b2-ash-r1/query-1.log,primary-3s,1969.1891971271289,1.0289141912055246 -cap-1M-b2-ash-r1,cap-1M,1536,768,2,ASH,1,197,48,1.0,,95279.65361270042,0.7918,752.783,0.9853988466271536,,,0.0,0,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/cap-1M-b2-ash-r1/query-48.log,primary-3s,95279.65361270042,0.9853988466271536 -cap-1M-b2-ash-r1,cap-1M,1536,768,2,ASH,1,197,48,2.0,,78168.15369319917,0.94435,1099.8606,0.552073063676741,,,0.0,0,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/cap-1M-b2-ash-r1/query-48.log,primary-3s,78168.15369319917,0.552073063676741 -cap-1M-b2-pq-r1,cap-1M,1536,768,2,PQ,1,197,1,1.0,234.99586654,2582.7721961649463,0.57832,725.6598,3.9486382814193726,17.339142317,4.496241169,0.495377823,0,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/cap-1M-b2-pq-r1/query-1.log,primary-3s,2582.7721961649463,3.9486382814193726 -cap-1M-b2-pq-r1,cap-1M,1536,768,2,PQ,1,197,1,2.0,234.99586654,2118.999904259021,0.78163,1010.7918,1.8779604116325714,17.339142317,4.496241169,0.495377823,0,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/cap-1M-b2-pq-r1/query-1.log,primary-3s,2118.999904259021,1.8779604116325714 -cap-1M-b2-pq-r1,cap-1M,1536,768,2,PQ,1,197,48,1.0,,102157.77848107649,0.57832,725.6598,0.8520534403998001,,,0.0,0,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/cap-1M-b2-pq-r1/query-48.log,primary-3s,102157.77848107649,0.8520534403998001 -cap-1M-b2-pq-r1,cap-1M,1536,768,2,PQ,1,197,48,2.0,,87196.68753139579,0.78163,1010.7918,0.6228168823421181,,,0.0,0,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/cap-1M-b2-pq-r1/query-48.log,primary-3s,87196.68753139579,0.6228168823421181 -cap-1M-b4-ash-r1,cap-1M,1536,768,4,ASH,1,389,1,1.0,223.669037493,1968.8190491505034,0.86483,710.3348,6.406771306054616,7.937175314,6.870664075,0.512322427,3.287562,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/cap-1M-b4-ash-r1/query-1.log,primary-3s,1968.8190491505034,6.406771306054616 -cap-1M-b4-ash-r1,cap-1M,1536,768,4,ASH,1,389,1,2.0,223.669037493,1757.2733497910124,0.95431,1021.7334,2.695601637716467,7.937175314,6.870664075,0.512322427,3.287562,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/cap-1M-b4-ash-r1/query-1.log,primary-3s,1757.2733497910124,2.695601637716467 -cap-1M-b4-ash-r1,cap-1M,1536,768,4,ASH,1,389,48,1.0,,83224.75726728224,0.86483,710.3348,1.42150330749097,,,0.0,0,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/cap-1M-b4-ash-r1/query-48.log,primary-3s,83224.75726728224,1.42150330749097 -cap-1M-b4-ash-r1,cap-1M,1536,768,4,ASH,1,389,48,2.0,,63126.66771149495,0.95431,1021.7334,0.6423858748901395,,,0.0,0,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/cap-1M-b4-ash-r1/query-48.log,primary-3s,63126.66771149495,0.6423858748901395 -cap-1M-b4-pq-r1,cap-1M,1536,768,4,PQ,1,389,1,1.0,428.719613174,2010.5628717956458,0.77078,724.1648,5.712025504481833,7.322717334,4.716799943,0.509658225,0,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/cap-1M-b4-pq-r1/query-1.log,primary-3s,2010.5628717956458,5.712025504481833 -cap-1M-b4-pq-r1,cap-1M,1536,768,4,PQ,1,389,1,2.0,428.719613174,1642.1041956039842,0.93387,1033.019,1.6530978389723041,7.322717334,4.716799943,0.509658225,0,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/cap-1M-b4-pq-r1/query-1.log,primary-3s,1642.1041956039842,1.6530978389723041 -cap-1M-b4-pq-r1,cap-1M,1536,768,4,PQ,1,389,48,1.0,,88276.39085252174,0.77078,724.1648,1.814849190790097,,,0.0,0,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/cap-1M-b4-pq-r1/query-48.log,primary-3s,88276.39085252174,1.814849190790097 -cap-1M-b4-pq-r1,cap-1M,1536,768,4,PQ,1,389,48,2.0,,72320.63990408325,0.93387,1033.019,0.5961574242426309,,,0.0,0,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/cap-1M-b4-pq-r1/query-48.log,primary-3s,72320.63990408325,0.5961574242426309 -cohere-english-v3-1M-b2-ash-r1,cohere-english-v3-1M,1024,512,2,ASH,1,133,1,1.0,131.540012311,4032.4153832742772,0.70528,884.6191,3.560891364246673,5.381116293,1.874303968,0.459574838,1.941501,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/cohere-english-v3-1M-b2-ash-r1/query-1.log,primary-3s,4032.4153832742772,3.560891364246673 -cohere-english-v3-1M-b2-ash-r1,cohere-english-v3-1M,1024,512,2,ASH,1,133,1,2.0,131.540012311,2990.537197603152,0.88578,1323.1688,5.124065470263526,5.381116293,1.874303968,0.459574838,1.941501,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/cohere-english-v3-1M-b2-ash-r1/query-1.log,primary-3s,2990.537197603152,5.124065470263526 -cohere-english-v3-1M-b2-ash-r1,cohere-english-v3-1M,1024,512,2,ASH,1,133,48,1.0,,154673.1592239879,0.70528,884.6191,4.393186725468967,,,0.0,0,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/cohere-english-v3-1M-b2-ash-r1/query-48.log,primary-3s,154673.1592239879,4.393186725468967 -cohere-english-v3-1M-b2-ash-r1,cohere-english-v3-1M,1024,512,2,ASH,1,133,48,2.0,,114552.33144904207,0.88578,1323.1688,0.6570685869485251,,,0.0,0,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/cohere-english-v3-1M-b2-ash-r1/query-48.log,primary-3s,114552.33144904207,0.6570685869485251 -cohere-english-v3-1M-b2-pq-r1,cohere-english-v3-1M,1024,512,2,PQ,1,133,1,1.0,202.056488632,3760.082855364721,0.66689,824.2477,2.4226242494143873,5.495381372,3.826856416,0.373458183,0,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/cohere-english-v3-1M-b2-pq-r1/query-1.log,primary-3s,3760.082855364721,2.4226242494143873 -cohere-english-v3-1M-b2-pq-r1,cohere-english-v3-1M,1024,512,2,PQ,1,133,1,2.0,202.056488632,3004.8417879055482,0.8569,1200.9145,1.4786229125049466,5.495381372,3.826856416,0.373458183,0,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/cohere-english-v3-1M-b2-pq-r1/query-1.log,primary-3s,3004.8417879055482,1.4786229125049466 -cohere-english-v3-1M-b2-pq-r1,cohere-english-v3-1M,1024,512,2,PQ,1,133,48,1.0,,137651.87500685465,0.66689,824.2477,1.0614539879575466,,,0.0,0,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/cohere-english-v3-1M-b2-pq-r1/query-48.log,primary-3s,137651.87500685465,1.0614539879575466 -cohere-english-v3-1M-b2-pq-r1,cohere-english-v3-1M,1024,512,2,PQ,1,133,48,2.0,,110493.6991190779,0.8569,1200.9145,1.8720195013611096,,,0.0,0,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/cohere-english-v3-1M-b2-pq-r1/query-48.log,primary-3s,110493.6991190779,1.8720195013611096 -cohere-english-v3-1M-b4-ash-r1,cohere-english-v3-1M,1024,512,4,ASH,1,261,1,1.0,658.453734072,3659.88651139761,0.79959,829.839,3.3352298962798885,4.541078397,2.222092598,0.357154296,2.160219,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/cohere-english-v3-1M-b4-ash-r1/query-1.log,cached-10s: /mnt/raid10/jvector-bench/ted_willke/figure9-graph/cohere-english-v3-1M-b4-ash-r1/quality-1.log,2914.921321324916,18.87768723717277 -cohere-english-v3-1M-b4-ash-r1,cohere-english-v3-1M,1024,512,4,ASH,1,261,1,2.0,658.453734072,2652.860877518716,0.91214,1232.4703,0.8181655322064287,4.541078397,2.222092598,0.357154296,2.160219,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/cohere-english-v3-1M-b4-ash-r1/query-1.log,cached-10s: /mnt/raid10/jvector-bench/ted_willke/figure9-graph/cohere-english-v3-1M-b4-ash-r1/quality-1.log,2318.7834081655997,2.8346436633059775 -cohere-english-v3-1M-b4-ash-r1,cohere-english-v3-1M,1024,512,4,ASH,1,261,48,1.0,,130101.16171643394,0.79959,829.839,2.5756810774540098,,,0.0,0,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/cohere-english-v3-1M-b4-ash-r1/query-48.log,primary-3s,130101.16171643394,2.5756810774540098 -cohere-english-v3-1M-b4-ash-r1,cohere-english-v3-1M,1024,512,4,ASH,1,261,48,2.0,,94805.88464088204,0.91214,1232.4703,0.0724673350571949,,,0.0,0,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/cohere-english-v3-1M-b4-ash-r1/query-48.log,primary-3s,94805.88464088204,0.0724673350571949 -cohere-english-v3-1M-b4-pq-r1,cohere-english-v3-1M,1024,512,4,PQ,1,261,1,1.0,1224.670977507,3056.927847735833,0.73716,792.6989,5.354685894084355,5.670114916,3.275320129,0.362849512,0,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/cohere-english-v3-1M-b4-pq-r1/query-1.log,primary-3s,3056.927847735833,5.354685894084355 -cohere-english-v3-1M-b4-pq-r1,cohere-english-v3-1M,1024,512,4,PQ,1,261,1,2.0,1224.670977507,2296.056472237398,0.89174,1155.4997,5.725529209257075,5.670114916,3.275320129,0.362849512,0,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/cohere-english-v3-1M-b4-pq-r1/query-1.log,primary-3s,2296.056472237398,5.725529209257075 -cohere-english-v3-1M-b4-pq-r1,cohere-english-v3-1M,1024,512,4,PQ,1,261,48,1.0,,125649.75380191854,0.73716,792.6989,2.0450484393226622,,,0.0,0,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/cohere-english-v3-1M-b4-pq-r1/query-48.log,primary-3s,125649.75380191854,2.0450484393226622 -cohere-english-v3-1M-b4-pq-r1,cohere-english-v3-1M,1024,512,4,PQ,1,261,48,2.0,,95476.75849465677,0.89174,1155.4997,0.24166504216312962,,,0.0,0,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/cohere-english-v3-1M-b4-pq-r1/query-48.log,primary-3s,95476.75849465677,0.24166504216312962 -ada002-1M-b2-pq-r2,ada002-1M,1536,768,2,PQ,2,197,1,1.0,314.49511595,2748.769882268208,0.61087,761.9973,3.034200291066349,7.164817247,5.105142127,0.58527832,0,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/ada002-1M-b2-pq-r2/query-1.log,primary-3s,2748.769882268208,3.034200291066349 -ada002-1M-b2-pq-r2,ada002-1M,1536,768,2,PQ,2,197,1,2.0,314.49511595,2215.193301397225,0.81207,1073.2808,1.0813312289988994,7.164817247,5.105142127,0.58527832,0,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/ada002-1M-b2-pq-r2/query-1.log,primary-3s,2215.193301397225,1.0813312289988994 -ada002-1M-b2-pq-r2,ada002-1M,1536,768,2,PQ,2,197,48,1.0,,95964.90321354134,0.61087,761.9973,2.651360702180009,,,0.0,0,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/ada002-1M-b2-pq-r2/query-48.log,primary-3s,95964.90321354134,2.651360702180009 -ada002-1M-b2-pq-r2,ada002-1M,1536,768,2,PQ,2,197,48,2.0,,79740.98409975477,0.81207,1073.2808,3.040241779525206,,,0.0,0,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/ada002-1M-b2-pq-r2/query-48.log,primary-3s,79740.98409975477,3.040241779525206 -ada002-1M-b2-ash-r2,ada002-1M,1536,768,2,ASH,2,197,1,1.0,148.462216427,1996.4088078542302,0.82806,743.1514,8.337650633903259,9.620892019,7.596049599,0.492858178,3.140455,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/ada002-1M-b2-ash-r2/query-1.log,primary-3s,1996.4088078542302,8.337650633903259 -ada002-1M-b2-ash-r2,ada002-1M,1536,768,2,ASH,2,197,1,2.0,148.462216427,1802.8315892472526,0.95985,1098.6193,1.3155639215124004,9.620892019,7.596049599,0.492858178,3.140455,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/ada002-1M-b2-ash-r2/query-1.log,primary-3s,1802.8315892472526,1.3155639215124004 -ada002-1M-b2-ash-r2,ada002-1M,1536,768,2,ASH,2,197,48,1.0,,95056.34990344389,0.82806,743.1514,1.7700623107441582,,,0.0,0,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/ada002-1M-b2-ash-r2/query-48.log,primary-3s,95056.34990344389,1.7700623107441582 -ada002-1M-b2-ash-r2,ada002-1M,1536,768,2,ASH,2,197,48,2.0,,74802.08869227431,0.95985,1098.6193,0.14584232924624438,,,0.0,0,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/ada002-1M-b2-ash-r2/query-48.log,primary-3s,74802.08869227431,0.14584232924624438 -ada002-1M-b4-pq-r2,ada002-1M,1536,768,4,PQ,2,389,1,1.0,507.890157909,2213.5294641529763,0.78124,741.0503,2.656232061739049,7.278564002,4.93304114,0.600501602,0,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/ada002-1M-b4-pq-r2/query-1.log,cached-10s: /mnt/raid10/jvector-bench/ted_willke/figure9-graph/ada002-1M-b4-pq-r2/quality-1.log,1820.6690398184126,30.736736316022316 -ada002-1M-b4-pq-r2,ada002-1M,1536,768,4,PQ,2,389,1,2.0,507.890157909,1697.0094228980922,0.94098,1071.2316,0.5827720462316611,7.278564002,4.93304114,0.600501602,0,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/ada002-1M-b4-pq-r2/query-1.log,cached-10s: /mnt/raid10/jvector-bench/ted_willke/figure9-graph/ada002-1M-b4-pq-r2/quality-1.log,1588.1010017830652,14.340092833165679 -ada002-1M-b4-pq-r2,ada002-1M,1536,768,4,PQ,2,389,48,1.0,,94582.40323816786,0.78124,741.0503,1.2108098886995722,,,0.0,0,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/ada002-1M-b4-pq-r2/query-48.log,primary-3s,94582.40323816786,1.2108098886995722 -ada002-1M-b4-pq-r2,ada002-1M,1536,768,4,PQ,2,389,48,2.0,,70449.31841046993,0.94098,1071.2316,0.701127293075968,,,0.0,0,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/ada002-1M-b4-pq-r2/query-48.log,primary-3s,70449.31841046993,0.701127293075968 -ada002-1M-b4-ash-r2,ada002-1M,1536,768,4,ASH,2,389,1,1.0,228.410139478,1747.928265482728,0.88768,705.7386,1.8283062091639757,8.356441471,6.579782222,0.647681467,3.269243,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/ada002-1M-b4-ash-r2/query-1.log,primary-3s,1747.928265482728,1.8283062091639757 -ada002-1M-b4-ash-r2,ada002-1M,1536,768,4,ASH,2,389,1,2.0,228.410139478,1690.787299577052,0.96117,1030.419,0.5222633104861074,8.356441471,6.579782222,0.647681467,3.269243,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/ada002-1M-b4-ash-r2/query-1.log,primary-3s,1690.787299577052,0.5222633104861074 -ada002-1M-b4-ash-r2,ada002-1M,1536,768,4,ASH,2,389,48,1.0,,76915.82888351154,0.88768,705.7386,2.018428409914763,,,0.0,0,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/ada002-1M-b4-ash-r2/query-48.log,primary-3s,76915.82888351154,2.018428409914763 -ada002-1M-b4-ash-r2,ada002-1M,1536,768,4,ASH,2,389,48,2.0,,59068.83275336893,0.96117,1030.419,0.42491432057404144,,,0.0,0,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/ada002-1M-b4-ash-r2/query-48.log,primary-3s,59068.83275336893,0.42491432057404144 -cap-1M-b2-pq-r2,cap-1M,1536,768,2,PQ,2,197,1,1.0,318.820383687,2538.509055353608,0.57475,737.8679,1.4309608739915773,15.680441056,4.578405703,0.490970401,0,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/cap-1M-b2-pq-r2/query-1.log,primary-3s,2538.509055353608,1.4309608739915773 -cap-1M-b2-pq-r2,cap-1M,1536,768,2,PQ,2,197,1,2.0,318.820383687,2102.1416711961683,0.77755,1024.8805,2.2009318201309274,15.680441056,4.578405703,0.490970401,0,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/cap-1M-b2-pq-r2/query-1.log,primary-3s,2102.1416711961683,2.2009318201309274 -cap-1M-b2-pq-r2,cap-1M,1536,768,2,PQ,2,197,48,1.0,,96777.26308815173,0.57475,737.8679,2.5873596884050936,,,0.0,0,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/cap-1M-b2-pq-r2/query-48.log,primary-3s,96777.26308815173,2.5873596884050936 -cap-1M-b2-pq-r2,cap-1M,1536,768,2,PQ,2,197,48,2.0,,84790.32448230755,0.77755,1024.8805,0.9254997826172315,,,0.0,0,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/cap-1M-b2-pq-r2/query-48.log,primary-3s,84790.32448230755,0.9254997826172315 -cap-1M-b2-ash-r2,cap-1M,1536,768,2,ASH,2,197,1,1.0,154.184889795,2671.8321513333003,0.79039,751.5542,6.581649160723063,9.027614868,6.660274635,0.508288027,3.013961,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/cap-1M-b2-ash-r2/query-1.log,cached-10s: /mnt/raid10/jvector-bench/ted_willke/figure9-graph/cap-1M-b2-ash-r2/quality-1.log,2258.191054953794,23.759659693908986 -cap-1M-b2-ash-r2,cap-1M,1536,768,2,ASH,2,197,1,2.0,154.184889795,2181.25379117744,0.94304,1096.9158,1.411287086771372,9.027614868,6.660274635,0.508288027,3.013961,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/cap-1M-b2-ash-r2/query-1.log,cached-10s: /mnt/raid10/jvector-bench/ted_willke/figure9-graph/cap-1M-b2-ash-r2/quality-1.log,2052.5449317362495,2.712071494538914 -cap-1M-b2-ash-r2,cap-1M,1536,768,2,ASH,2,197,48,1.0,,96253.66102754153,0.79039,751.5542,1.7058235542763742,,,0.0,0,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/cap-1M-b2-ash-r2/query-48.log,primary-3s,96253.66102754153,1.7058235542763742 -cap-1M-b2-ash-r2,cap-1M,1536,768,2,ASH,2,197,48,2.0,,79043.16466879538,0.94304,1096.9158,0.2782425279737823,,,0.0,0,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/cap-1M-b2-ash-r2/query-48.log,primary-3s,79043.16466879538,0.2782425279737823 -cap-1M-b4-pq-r2,cap-1M,1536,768,4,PQ,2,389,1,1.0,384.541696496,2463.1515004291546,0.77117,721.4095,5.7424423017788095,7.772478911,4.729168092,0.584808812,0,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/cap-1M-b4-pq-r2/query-1.log,cached-10s: /mnt/raid10/jvector-bench/ted_willke/figure9-graph/cap-1M-b4-pq-r2/quality-1.log,1997.0962184856235,18.76226207864229 -cap-1M-b4-pq-r2,cap-1M,1536,768,4,PQ,2,389,1,2.0,384.541696496,1936.7856067073103,0.93402,1029.0857,0.9009159641326212,7.772478911,4.729168092,0.584808812,0,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/cap-1M-b4-pq-r2/query-1.log,cached-10s: /mnt/raid10/jvector-bench/ted_willke/figure9-graph/cap-1M-b4-pq-r2/quality-1.log,1628.2176350293066,15.060018096648184 -cap-1M-b4-pq-r2,cap-1M,1536,768,4,PQ,2,389,48,1.0,,93982.41166067754,0.77117,721.4095,2.0385129815211256,,,0.0,0,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/cap-1M-b4-pq-r2/query-48.log,primary-3s,93982.41166067754,2.0385129815211256 -cap-1M-b4-pq-r2,cap-1M,1536,768,4,PQ,2,389,48,2.0,,70346.9921004846,0.93402,1029.0857,0.9724138237250509,,,0.0,0,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/cap-1M-b4-pq-r2/query-48.log,primary-3s,70346.9921004846,0.9724138237250509 -cap-1M-b4-ash-r2,cap-1M,1536,768,4,ASH,2,389,1,1.0,218.04224412,2149.0023927609095,0.86566,708.1348,8.837725956246652,8.929087608,7.874196863,0.512352717,3.399143,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/cap-1M-b4-ash-r2/query-1.log,primary-3s,2149.0023927609095,8.837725956246652 -cap-1M-b4-ash-r2,cap-1M,1536,768,4,ASH,2,389,1,2.0,218.04224412,1709.8616544967786,0.95445,1020.1297,2.1276746338547827,8.929087608,7.874196863,0.512352717,3.399143,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/cap-1M-b4-ash-r2/query-1.log,primary-3s,1709.8616544967786,2.1276746338547827 -cap-1M-b4-ash-r2,cap-1M,1536,768,4,ASH,2,389,48,1.0,,82541.79142378358,0.86566,708.1348,3.0493408284794836,,,0.0,0,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/cap-1M-b4-ash-r2/query-48.log,primary-3s,82541.79142378358,3.0493408284794836 -cap-1M-b4-ash-r2,cap-1M,1536,768,4,ASH,2,389,48,2.0,,62660.6088961071,0.95445,1020.1297,0.6444089703901766,,,0.0,0,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/cap-1M-b4-ash-r2/query-48.log,primary-3s,62660.6088961071,0.6444089703901766 -cohere-english-v3-1M-b2-pq-r2,cohere-english-v3-1M,1024,512,2,PQ,2,133,1,1.0,210.651240768,3554.791683888936,0.66659,824.1673,1.9970658008975664,5.380806686,3.888598589,0.374447596,0,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/cohere-english-v3-1M-b2-pq-r2/query-1.log,primary-3s,3554.791683888936,1.9970658008975664 -cohere-english-v3-1M-b2-pq-r2,cohere-english-v3-1M,1024,512,2,PQ,2,133,1,2.0,210.651240768,2755.1301538763946,0.85658,1199.3812,3.764086183772186,5.380806686,3.888598589,0.374447596,0,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/cohere-english-v3-1M-b2-pq-r2/query-1.log,primary-3s,2755.1301538763946,3.764086183772186 -cohere-english-v3-1M-b2-pq-r2,cohere-english-v3-1M,1024,512,2,PQ,2,133,48,1.0,,138379.5345558035,0.66659,824.1673,1.884179629700551,,,0.0,0,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/cohere-english-v3-1M-b2-pq-r2/query-48.log,primary-3s,138379.5345558035,1.884179629700551 -cohere-english-v3-1M-b2-pq-r2,cohere-english-v3-1M,1024,512,2,PQ,2,133,48,2.0,,108017.34345790854,0.85658,1199.3812,0.2428364162563542,,,0.0,0,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/cohere-english-v3-1M-b2-pq-r2/query-48.log,primary-3s,108017.34345790854,0.2428364162563542 -cohere-english-v3-1M-b2-ash-r2,cohere-english-v3-1M,1024,512,2,ASH,2,133,1,1.0,155.626287523,3711.8325622709335,0.70446,891.4132,3.439385278420078,5.27452495,1.718166483,0.399741568,1.952495,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/cohere-english-v3-1M-b2-ash-r2/query-1.log,primary-3s,3711.8325622709335,3.439385278420078 -cohere-english-v3-1M-b2-ash-r2,cohere-english-v3-1M,1024,512,2,ASH,2,133,1,2.0,155.626287523,3720.63426140302,0.88522,1327.2389,2.135700347945317,5.27452495,1.718166483,0.399741568,1.952495,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/cohere-english-v3-1M-b2-ash-r2/query-1.log,primary-3s,3720.63426140302,2.135700347945317 -cohere-english-v3-1M-b2-ash-r2,cohere-english-v3-1M,1024,512,2,ASH,2,133,48,1.0,,156862.83319312084,0.70446,891.4132,3.014477053063219,,,0.0,0,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/cohere-english-v3-1M-b2-ash-r2/query-48.log,primary-3s,156862.83319312084,3.014477053063219 -cohere-english-v3-1M-b2-ash-r2,cohere-english-v3-1M,1024,512,2,ASH,2,133,48,2.0,,110969.92932781803,0.88522,1327.2389,2.6712548800277496,,,0.0,0,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/cohere-english-v3-1M-b2-ash-r2/query-48.log,primary-3s,110969.92932781803,2.6712548800277496 -cohere-english-v3-1M-b4-pq-r2,cohere-english-v3-1M,1024,512,4,PQ,2,261,1,1.0,320.372937538,3053.848581254041,0.73738,789.9314,4.2108275645215425,6.033624081,3.279443062,0.426116483,0,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/cohere-english-v3-1M-b4-pq-r2/query-1.log,primary-3s,3053.848581254041,4.2108275645215425 -cohere-english-v3-1M-b4-pq-r2,cohere-english-v3-1M,1024,512,4,PQ,2,261,1,2.0,320.372937538,2349.17408087356,0.89183,1153.327,1.6478659973446461,6.033624081,3.279443062,0.426116483,0,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/cohere-english-v3-1M-b4-pq-r2/query-1.log,primary-3s,2349.17408087356,1.6478659973446461 -cohere-english-v3-1M-b4-pq-r2,cohere-english-v3-1M,1024,512,4,PQ,2,261,48,1.0,,127888.00395618335,0.73738,789.9314,0.6495444554179923,,,0.0,0,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/cohere-english-v3-1M-b4-pq-r2/query-48.log,primary-3s,127888.00395618335,0.6495444554179923 -cohere-english-v3-1M-b4-pq-r2,cohere-english-v3-1M,1024,512,4,PQ,2,261,48,2.0,,95135.73123335889,0.89183,1153.327,0.6440058638324528,,,0.0,0,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/cohere-english-v3-1M-b4-pq-r2/query-48.log,primary-3s,95135.73123335889,0.6440058638324528 -cohere-english-v3-1M-b4-ash-r2,cohere-english-v3-1M,1024,512,4,ASH,2,261,1,1.0,158.050770213,3262.9891204212204,0.80006,834.193,6.604247423409825,4.543623972,2.239317788,0.479592937,2.036372,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/cohere-english-v3-1M-b4-ash-r2/query-1.log,primary-3s,3262.9891204212204,6.604247423409825 -cohere-english-v3-1M-b4-ash-r2,cohere-english-v3-1M,1024,512,4,ASH,2,261,1,2.0,158.050770213,2571.738833639199,0.91205,1235.6143,2.7208643656202733,4.543623972,2.239317788,0.479592937,2.036372,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/cohere-english-v3-1M-b4-ash-r2/query-1.log,primary-3s,2571.738833639199,2.7208643656202733 -cohere-english-v3-1M-b4-ash-r2,cohere-english-v3-1M,1024,512,4,ASH,2,261,48,1.0,,128859.33340409711,0.80006,834.193,2.2828342408538718,,,0.0,0,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/cohere-english-v3-1M-b4-ash-r2/query-48.log,primary-3s,128859.33340409711,2.2828342408538718 -cohere-english-v3-1M-b4-ash-r2,cohere-english-v3-1M,1024,512,4,ASH,2,261,48,2.0,,94374.11028668427,0.91205,1235.6143,0.6084842422665558,,,0.0,0,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/cohere-english-v3-1M-b4-ash-r2/query-48.log,primary-3s,94374.11028668427,0.6084842422665558 diff --git a/docs/figure9-graph-study/results.csv b/docs/figure9-graph-study/results.csv deleted file mode 100644 index d5e46bc13..000000000 --- a/docs/figure9-graph-study/results.csv +++ /dev/null @@ -1,97 +0,0 @@ -name,dataset,D,d,bits,mode,rep,code_bytes,threads,overquery,build_s,qps,recall,visited,qps_cv,train_s,encode_s,nvq_s,provider_s,log -ada002-1M-b2-ash-r1,ada002-1M,1536,768,2,ASH,1,197,1,1.0,157.840009863,2177.8102536704814,0.82642,743.6996,2.089696662700664,9.230167,7.706519616,0.546488417,2.95154,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/ada002-1M-b2-ash-r1/query-1.log -ada002-1M-b2-ash-r1,ada002-1M,1536,768,2,ASH,1,197,1,2.0,157.840009863,2072.775696510429,0.95953,1099.5859,0.6538369048721234,9.230167,7.706519616,0.546488417,2.95154,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/ada002-1M-b2-ash-r1/query-1.log -ada002-1M-b2-ash-r1,ada002-1M,1536,768,2,ASH,1,197,48,1.0,,95956.71345138094,0.82642,743.6996,1.504784845208021,,,0.0,0,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/ada002-1M-b2-ash-r1/query-48.log -ada002-1M-b2-ash-r1,ada002-1M,1536,768,2,ASH,1,197,48,2.0,,74038.29496332126,0.95953,1099.5859,0.2560122257189981,,,0.0,0,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/ada002-1M-b2-ash-r1/query-48.log -ada002-1M-b2-pq-r1,ada002-1M,1536,768,2,PQ,1,197,1,1.0,305.357773341,2505.50863765249,0.60969,777.0072,3.3329151504713037,6.223289688,4.361790065,0.51526343,0,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/ada002-1M-b2-pq-r1/query-1.log -ada002-1M-b2-pq-r1,ada002-1M,1536,768,2,PQ,1,197,1,2.0,305.357773341,2070.9961907891375,0.81301,1088.5782,0.6403227236256307,6.223289688,4.361790065,0.51526343,0,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/ada002-1M-b2-pq-r1/query-1.log -ada002-1M-b2-pq-r1,ada002-1M,1536,768,2,PQ,1,197,48,1.0,,98002.36666217295,0.60969,777.0072,0.4664038965243055,,,0.0,0,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/ada002-1M-b2-pq-r1/query-48.log -ada002-1M-b2-pq-r1,ada002-1M,1536,768,2,PQ,1,197,48,2.0,,83275.93656993553,0.81301,1088.5782,2.4477334633540697,,,0.0,0,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/ada002-1M-b2-pq-r1/query-48.log -ada002-1M-b4-ash-r1,ada002-1M,1536,768,4,ASH,1,389,1,1.0,227.142034764,1885.1372911497197,0.88774,712.952,8.466917289018335,8.071419789,5.700532906,0.497370085,3.051166,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/ada002-1M-b4-ash-r1/query-1.log -ada002-1M-b4-ash-r1,ada002-1M,1536,768,4,ASH,1,389,1,2.0,227.142034764,1694.774941404327,0.96112,1036.2085,1.440621537569398,8.071419789,5.700532906,0.497370085,3.051166,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/ada002-1M-b4-ash-r1/query-1.log -ada002-1M-b4-ash-r1,ada002-1M,1536,768,4,ASH,1,389,48,1.0,,78535.16386753105,0.88774,712.952,1.0498522055276105,,,0.0,0,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/ada002-1M-b4-ash-r1/query-48.log -ada002-1M-b4-ash-r1,ada002-1M,1536,768,4,ASH,1,389,48,2.0,,58078.37660324255,0.96112,1036.2085,1.0265098943456157,,,0.0,0,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/ada002-1M-b4-ash-r1/query-48.log -ada002-1M-b4-pq-r1,ada002-1M,1536,768,4,PQ,1,389,1,1.0,471.033761676,2296.391366518768,0.78046,743.3911,3.454129477444065,6.66483986,4.78256411,0.619976129,0,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/ada002-1M-b4-pq-r1/query-1.log -ada002-1M-b4-pq-r1,ada002-1M,1536,768,4,PQ,1,389,1,2.0,471.033761676,1844.3575579359417,0.94212,1075.2136,0.5016351987560125,6.66483986,4.78256411,0.619976129,0,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/ada002-1M-b4-pq-r1/query-1.log -ada002-1M-b4-pq-r1,ada002-1M,1536,768,4,PQ,1,389,48,1.0,,88588.06947666063,0.78046,743.3911,1.2252863478487552,,,0.0,0,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/ada002-1M-b4-pq-r1/query-48.log -ada002-1M-b4-pq-r1,ada002-1M,1536,768,4,PQ,1,389,48,2.0,,69034.79529813363,0.94212,1075.2136,0.3523116662786264,,,0.0,0,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/ada002-1M-b4-pq-r1/query-48.log -cap-1M-b2-ash-r1,cap-1M,1536,768,2,ASH,1,197,1,1.0,144.164132004,2442.48519676128,0.7918,752.783,6.871960701628724,9.336766347,7.555819332,0.49760735,3.01653,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/cap-1M-b2-ash-r1/query-1.log -cap-1M-b2-ash-r1,cap-1M,1536,768,2,ASH,1,197,1,2.0,144.164132004,1969.1891971271289,0.94435,1099.8606,1.0289141912055246,9.336766347,7.555819332,0.49760735,3.01653,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/cap-1M-b2-ash-r1/query-1.log -cap-1M-b2-ash-r1,cap-1M,1536,768,2,ASH,1,197,48,1.0,,95279.65361270042,0.7918,752.783,0.9853988466271536,,,0.0,0,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/cap-1M-b2-ash-r1/query-48.log -cap-1M-b2-ash-r1,cap-1M,1536,768,2,ASH,1,197,48,2.0,,78168.15369319917,0.94435,1099.8606,0.552073063676741,,,0.0,0,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/cap-1M-b2-ash-r1/query-48.log -cap-1M-b2-pq-r1,cap-1M,1536,768,2,PQ,1,197,1,1.0,234.99586654,2582.7721961649463,0.57832,725.6598,3.9486382814193726,17.339142317,4.496241169,0.495377823,0,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/cap-1M-b2-pq-r1/query-1.log -cap-1M-b2-pq-r1,cap-1M,1536,768,2,PQ,1,197,1,2.0,234.99586654,2118.999904259021,0.78163,1010.7918,1.8779604116325714,17.339142317,4.496241169,0.495377823,0,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/cap-1M-b2-pq-r1/query-1.log -cap-1M-b2-pq-r1,cap-1M,1536,768,2,PQ,1,197,48,1.0,,102157.77848107649,0.57832,725.6598,0.8520534403998001,,,0.0,0,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/cap-1M-b2-pq-r1/query-48.log -cap-1M-b2-pq-r1,cap-1M,1536,768,2,PQ,1,197,48,2.0,,87196.68753139579,0.78163,1010.7918,0.6228168823421181,,,0.0,0,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/cap-1M-b2-pq-r1/query-48.log -cap-1M-b4-ash-r1,cap-1M,1536,768,4,ASH,1,389,1,1.0,223.669037493,1968.8190491505034,0.86483,710.3348,6.406771306054616,7.937175314,6.870664075,0.512322427,3.287562,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/cap-1M-b4-ash-r1/query-1.log -cap-1M-b4-ash-r1,cap-1M,1536,768,4,ASH,1,389,1,2.0,223.669037493,1757.2733497910124,0.95431,1021.7334,2.695601637716467,7.937175314,6.870664075,0.512322427,3.287562,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/cap-1M-b4-ash-r1/query-1.log -cap-1M-b4-ash-r1,cap-1M,1536,768,4,ASH,1,389,48,1.0,,83224.75726728224,0.86483,710.3348,1.42150330749097,,,0.0,0,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/cap-1M-b4-ash-r1/query-48.log -cap-1M-b4-ash-r1,cap-1M,1536,768,4,ASH,1,389,48,2.0,,63126.66771149495,0.95431,1021.7334,0.6423858748901395,,,0.0,0,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/cap-1M-b4-ash-r1/query-48.log -cap-1M-b4-pq-r1,cap-1M,1536,768,4,PQ,1,389,1,1.0,428.719613174,2010.5628717956458,0.77078,724.1648,5.712025504481833,7.322717334,4.716799943,0.509658225,0,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/cap-1M-b4-pq-r1/query-1.log -cap-1M-b4-pq-r1,cap-1M,1536,768,4,PQ,1,389,1,2.0,428.719613174,1642.1041956039842,0.93387,1033.019,1.6530978389723041,7.322717334,4.716799943,0.509658225,0,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/cap-1M-b4-pq-r1/query-1.log -cap-1M-b4-pq-r1,cap-1M,1536,768,4,PQ,1,389,48,1.0,,88276.39085252174,0.77078,724.1648,1.814849190790097,,,0.0,0,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/cap-1M-b4-pq-r1/query-48.log -cap-1M-b4-pq-r1,cap-1M,1536,768,4,PQ,1,389,48,2.0,,72320.63990408325,0.93387,1033.019,0.5961574242426309,,,0.0,0,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/cap-1M-b4-pq-r1/query-48.log -cohere-english-v3-1M-b2-ash-r1,cohere-english-v3-1M,1024,512,2,ASH,1,133,1,1.0,131.540012311,4032.4153832742772,0.70528,884.6191,3.560891364246673,5.381116293,1.874303968,0.459574838,1.941501,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/cohere-english-v3-1M-b2-ash-r1/query-1.log -cohere-english-v3-1M-b2-ash-r1,cohere-english-v3-1M,1024,512,2,ASH,1,133,1,2.0,131.540012311,2990.537197603152,0.88578,1323.1688,5.124065470263526,5.381116293,1.874303968,0.459574838,1.941501,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/cohere-english-v3-1M-b2-ash-r1/query-1.log -cohere-english-v3-1M-b2-ash-r1,cohere-english-v3-1M,1024,512,2,ASH,1,133,48,1.0,,154673.1592239879,0.70528,884.6191,4.393186725468967,,,0.0,0,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/cohere-english-v3-1M-b2-ash-r1/query-48.log -cohere-english-v3-1M-b2-ash-r1,cohere-english-v3-1M,1024,512,2,ASH,1,133,48,2.0,,114552.33144904207,0.88578,1323.1688,0.6570685869485251,,,0.0,0,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/cohere-english-v3-1M-b2-ash-r1/query-48.log -cohere-english-v3-1M-b2-pq-r1,cohere-english-v3-1M,1024,512,2,PQ,1,133,1,1.0,202.056488632,3760.082855364721,0.66689,824.2477,2.4226242494143873,5.495381372,3.826856416,0.373458183,0,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/cohere-english-v3-1M-b2-pq-r1/query-1.log -cohere-english-v3-1M-b2-pq-r1,cohere-english-v3-1M,1024,512,2,PQ,1,133,1,2.0,202.056488632,3004.8417879055482,0.8569,1200.9145,1.4786229125049466,5.495381372,3.826856416,0.373458183,0,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/cohere-english-v3-1M-b2-pq-r1/query-1.log -cohere-english-v3-1M-b2-pq-r1,cohere-english-v3-1M,1024,512,2,PQ,1,133,48,1.0,,137651.87500685465,0.66689,824.2477,1.0614539879575466,,,0.0,0,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/cohere-english-v3-1M-b2-pq-r1/query-48.log -cohere-english-v3-1M-b2-pq-r1,cohere-english-v3-1M,1024,512,2,PQ,1,133,48,2.0,,110493.6991190779,0.8569,1200.9145,1.8720195013611096,,,0.0,0,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/cohere-english-v3-1M-b2-pq-r1/query-48.log -cohere-english-v3-1M-b4-ash-r1,cohere-english-v3-1M,1024,512,4,ASH,1,261,1,1.0,658.453734072,2914.921321324916,0.79959,829.839,18.87768723717277,4.541078397,2.222092598,0.357154296,2.160219,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/cohere-english-v3-1M-b4-ash-r1/query-1.log -cohere-english-v3-1M-b4-ash-r1,cohere-english-v3-1M,1024,512,4,ASH,1,261,1,2.0,658.453734072,2318.7834081655997,0.91214,1232.4703,2.8346436633059775,4.541078397,2.222092598,0.357154296,2.160219,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/cohere-english-v3-1M-b4-ash-r1/query-1.log -cohere-english-v3-1M-b4-ash-r1,cohere-english-v3-1M,1024,512,4,ASH,1,261,48,1.0,,130101.16171643394,0.79959,829.839,2.5756810774540098,,,0.0,0,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/cohere-english-v3-1M-b4-ash-r1/query-48.log -cohere-english-v3-1M-b4-ash-r1,cohere-english-v3-1M,1024,512,4,ASH,1,261,48,2.0,,94805.88464088204,0.91214,1232.4703,0.0724673350571949,,,0.0,0,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/cohere-english-v3-1M-b4-ash-r1/query-48.log -cohere-english-v3-1M-b4-pq-r1,cohere-english-v3-1M,1024,512,4,PQ,1,261,1,1.0,1224.670977507,3056.927847735833,0.73716,792.6989,5.354685894084355,5.670114916,3.275320129,0.362849512,0,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/cohere-english-v3-1M-b4-pq-r1/query-1.log -cohere-english-v3-1M-b4-pq-r1,cohere-english-v3-1M,1024,512,4,PQ,1,261,1,2.0,1224.670977507,2296.056472237398,0.89174,1155.4997,5.725529209257075,5.670114916,3.275320129,0.362849512,0,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/cohere-english-v3-1M-b4-pq-r1/query-1.log -cohere-english-v3-1M-b4-pq-r1,cohere-english-v3-1M,1024,512,4,PQ,1,261,48,1.0,,125649.75380191854,0.73716,792.6989,2.0450484393226622,,,0.0,0,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/cohere-english-v3-1M-b4-pq-r1/query-48.log -cohere-english-v3-1M-b4-pq-r1,cohere-english-v3-1M,1024,512,4,PQ,1,261,48,2.0,,95476.75849465677,0.89174,1155.4997,0.24166504216312962,,,0.0,0,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/cohere-english-v3-1M-b4-pq-r1/query-48.log -ada002-1M-b2-pq-r2,ada002-1M,1536,768,2,PQ,2,197,1,1.0,314.49511595,2748.769882268208,0.61087,761.9973,3.034200291066349,7.164817247,5.105142127,0.58527832,0,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/ada002-1M-b2-pq-r2/query-1.log -ada002-1M-b2-pq-r2,ada002-1M,1536,768,2,PQ,2,197,1,2.0,314.49511595,2215.193301397225,0.81207,1073.2808,1.0813312289988994,7.164817247,5.105142127,0.58527832,0,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/ada002-1M-b2-pq-r2/query-1.log -ada002-1M-b2-pq-r2,ada002-1M,1536,768,2,PQ,2,197,48,1.0,,95964.90321354134,0.61087,761.9973,2.651360702180009,,,0.0,0,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/ada002-1M-b2-pq-r2/query-48.log -ada002-1M-b2-pq-r2,ada002-1M,1536,768,2,PQ,2,197,48,2.0,,79740.98409975477,0.81207,1073.2808,3.040241779525206,,,0.0,0,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/ada002-1M-b2-pq-r2/query-48.log -ada002-1M-b2-ash-r2,ada002-1M,1536,768,2,ASH,2,197,1,1.0,148.462216427,1996.4088078542302,0.82806,743.1514,8.337650633903259,9.620892019,7.596049599,0.492858178,3.140455,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/ada002-1M-b2-ash-r2/query-1.log -ada002-1M-b2-ash-r2,ada002-1M,1536,768,2,ASH,2,197,1,2.0,148.462216427,1802.8315892472526,0.95985,1098.6193,1.3155639215124004,9.620892019,7.596049599,0.492858178,3.140455,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/ada002-1M-b2-ash-r2/query-1.log -ada002-1M-b2-ash-r2,ada002-1M,1536,768,2,ASH,2,197,48,1.0,,95056.34990344389,0.82806,743.1514,1.7700623107441582,,,0.0,0,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/ada002-1M-b2-ash-r2/query-48.log -ada002-1M-b2-ash-r2,ada002-1M,1536,768,2,ASH,2,197,48,2.0,,74802.08869227431,0.95985,1098.6193,0.14584232924624438,,,0.0,0,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/ada002-1M-b2-ash-r2/query-48.log -ada002-1M-b4-pq-r2,ada002-1M,1536,768,4,PQ,2,389,1,1.0,507.890157909,1820.6690398184126,0.78124,741.0503,30.736736316022316,7.278564002,4.93304114,0.600501602,0,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/ada002-1M-b4-pq-r2/query-1.log -ada002-1M-b4-pq-r2,ada002-1M,1536,768,4,PQ,2,389,1,2.0,507.890157909,1588.1010017830652,0.94098,1071.2316,14.340092833165679,7.278564002,4.93304114,0.600501602,0,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/ada002-1M-b4-pq-r2/query-1.log -ada002-1M-b4-pq-r2,ada002-1M,1536,768,4,PQ,2,389,48,1.0,,94582.40323816786,0.78124,741.0503,1.2108098886995722,,,0.0,0,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/ada002-1M-b4-pq-r2/query-48.log -ada002-1M-b4-pq-r2,ada002-1M,1536,768,4,PQ,2,389,48,2.0,,70449.31841046993,0.94098,1071.2316,0.701127293075968,,,0.0,0,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/ada002-1M-b4-pq-r2/query-48.log -ada002-1M-b4-ash-r2,ada002-1M,1536,768,4,ASH,2,389,1,1.0,228.410139478,1747.928265482728,0.88768,705.7386,1.8283062091639757,8.356441471,6.579782222,0.647681467,3.269243,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/ada002-1M-b4-ash-r2/query-1.log -ada002-1M-b4-ash-r2,ada002-1M,1536,768,4,ASH,2,389,1,2.0,228.410139478,1690.787299577052,0.96117,1030.419,0.5222633104861074,8.356441471,6.579782222,0.647681467,3.269243,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/ada002-1M-b4-ash-r2/query-1.log -ada002-1M-b4-ash-r2,ada002-1M,1536,768,4,ASH,2,389,48,1.0,,76915.82888351154,0.88768,705.7386,2.018428409914763,,,0.0,0,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/ada002-1M-b4-ash-r2/query-48.log -ada002-1M-b4-ash-r2,ada002-1M,1536,768,4,ASH,2,389,48,2.0,,59068.83275336893,0.96117,1030.419,0.42491432057404144,,,0.0,0,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/ada002-1M-b4-ash-r2/query-48.log -cap-1M-b2-pq-r2,cap-1M,1536,768,2,PQ,2,197,1,1.0,318.820383687,2538.509055353608,0.57475,737.8679,1.4309608739915773,15.680441056,4.578405703,0.490970401,0,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/cap-1M-b2-pq-r2/query-1.log -cap-1M-b2-pq-r2,cap-1M,1536,768,2,PQ,2,197,1,2.0,318.820383687,2102.1416711961683,0.77755,1024.8805,2.2009318201309274,15.680441056,4.578405703,0.490970401,0,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/cap-1M-b2-pq-r2/query-1.log -cap-1M-b2-pq-r2,cap-1M,1536,768,2,PQ,2,197,48,1.0,,96777.26308815173,0.57475,737.8679,2.5873596884050936,,,0.0,0,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/cap-1M-b2-pq-r2/query-48.log -cap-1M-b2-pq-r2,cap-1M,1536,768,2,PQ,2,197,48,2.0,,84790.32448230755,0.77755,1024.8805,0.9254997826172315,,,0.0,0,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/cap-1M-b2-pq-r2/query-48.log -cap-1M-b2-ash-r2,cap-1M,1536,768,2,ASH,2,197,1,1.0,154.184889795,2258.191054953794,0.79039,751.5542,23.759659693908986,9.027614868,6.660274635,0.508288027,3.013961,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/cap-1M-b2-ash-r2/query-1.log -cap-1M-b2-ash-r2,cap-1M,1536,768,2,ASH,2,197,1,2.0,154.184889795,2052.5449317362495,0.94304,1096.9158,2.712071494538914,9.027614868,6.660274635,0.508288027,3.013961,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/cap-1M-b2-ash-r2/query-1.log -cap-1M-b2-ash-r2,cap-1M,1536,768,2,ASH,2,197,48,1.0,,96253.66102754153,0.79039,751.5542,1.7058235542763742,,,0.0,0,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/cap-1M-b2-ash-r2/query-48.log -cap-1M-b2-ash-r2,cap-1M,1536,768,2,ASH,2,197,48,2.0,,79043.16466879538,0.94304,1096.9158,0.2782425279737823,,,0.0,0,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/cap-1M-b2-ash-r2/query-48.log -cap-1M-b4-pq-r2,cap-1M,1536,768,4,PQ,2,389,1,1.0,384.541696496,1997.0962184856235,0.77117,721.4095,18.76226207864229,7.772478911,4.729168092,0.584808812,0,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/cap-1M-b4-pq-r2/query-1.log -cap-1M-b4-pq-r2,cap-1M,1536,768,4,PQ,2,389,1,2.0,384.541696496,1628.2176350293066,0.93402,1029.0857,15.060018096648184,7.772478911,4.729168092,0.584808812,0,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/cap-1M-b4-pq-r2/query-1.log -cap-1M-b4-pq-r2,cap-1M,1536,768,4,PQ,2,389,48,1.0,,93982.41166067754,0.77117,721.4095,2.0385129815211256,,,0.0,0,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/cap-1M-b4-pq-r2/query-48.log -cap-1M-b4-pq-r2,cap-1M,1536,768,4,PQ,2,389,48,2.0,,70346.9921004846,0.93402,1029.0857,0.9724138237250509,,,0.0,0,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/cap-1M-b4-pq-r2/query-48.log -cap-1M-b4-ash-r2,cap-1M,1536,768,4,ASH,2,389,1,1.0,218.04224412,2149.0023927609095,0.86566,708.1348,8.837725956246652,8.929087608,7.874196863,0.512352717,3.399143,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/cap-1M-b4-ash-r2/query-1.log -cap-1M-b4-ash-r2,cap-1M,1536,768,4,ASH,2,389,1,2.0,218.04224412,1709.8616544967786,0.95445,1020.1297,2.1276746338547827,8.929087608,7.874196863,0.512352717,3.399143,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/cap-1M-b4-ash-r2/query-1.log -cap-1M-b4-ash-r2,cap-1M,1536,768,4,ASH,2,389,48,1.0,,82541.79142378358,0.86566,708.1348,3.0493408284794836,,,0.0,0,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/cap-1M-b4-ash-r2/query-48.log -cap-1M-b4-ash-r2,cap-1M,1536,768,4,ASH,2,389,48,2.0,,62660.6088961071,0.95445,1020.1297,0.6444089703901766,,,0.0,0,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/cap-1M-b4-ash-r2/query-48.log -cohere-english-v3-1M-b2-pq-r2,cohere-english-v3-1M,1024,512,2,PQ,2,133,1,1.0,210.651240768,3554.791683888936,0.66659,824.1673,1.9970658008975664,5.380806686,3.888598589,0.374447596,0,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/cohere-english-v3-1M-b2-pq-r2/query-1.log -cohere-english-v3-1M-b2-pq-r2,cohere-english-v3-1M,1024,512,2,PQ,2,133,1,2.0,210.651240768,2755.1301538763946,0.85658,1199.3812,3.764086183772186,5.380806686,3.888598589,0.374447596,0,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/cohere-english-v3-1M-b2-pq-r2/query-1.log -cohere-english-v3-1M-b2-pq-r2,cohere-english-v3-1M,1024,512,2,PQ,2,133,48,1.0,,138379.5345558035,0.66659,824.1673,1.884179629700551,,,0.0,0,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/cohere-english-v3-1M-b2-pq-r2/query-48.log -cohere-english-v3-1M-b2-pq-r2,cohere-english-v3-1M,1024,512,2,PQ,2,133,48,2.0,,108017.34345790854,0.85658,1199.3812,0.2428364162563542,,,0.0,0,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/cohere-english-v3-1M-b2-pq-r2/query-48.log -cohere-english-v3-1M-b2-ash-r2,cohere-english-v3-1M,1024,512,2,ASH,2,133,1,1.0,155.626287523,3711.8325622709335,0.70446,891.4132,3.439385278420078,5.27452495,1.718166483,0.399741568,1.952495,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/cohere-english-v3-1M-b2-ash-r2/query-1.log -cohere-english-v3-1M-b2-ash-r2,cohere-english-v3-1M,1024,512,2,ASH,2,133,1,2.0,155.626287523,3720.63426140302,0.88522,1327.2389,2.135700347945317,5.27452495,1.718166483,0.399741568,1.952495,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/cohere-english-v3-1M-b2-ash-r2/query-1.log -cohere-english-v3-1M-b2-ash-r2,cohere-english-v3-1M,1024,512,2,ASH,2,133,48,1.0,,156862.83319312084,0.70446,891.4132,3.014477053063219,,,0.0,0,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/cohere-english-v3-1M-b2-ash-r2/query-48.log -cohere-english-v3-1M-b2-ash-r2,cohere-english-v3-1M,1024,512,2,ASH,2,133,48,2.0,,110969.92932781803,0.88522,1327.2389,2.6712548800277496,,,0.0,0,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/cohere-english-v3-1M-b2-ash-r2/query-48.log -cohere-english-v3-1M-b4-pq-r2,cohere-english-v3-1M,1024,512,4,PQ,2,261,1,1.0,320.372937538,3053.848581254041,0.73738,789.9314,4.2108275645215425,6.033624081,3.279443062,0.426116483,0,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/cohere-english-v3-1M-b4-pq-r2/query-1.log -cohere-english-v3-1M-b4-pq-r2,cohere-english-v3-1M,1024,512,4,PQ,2,261,1,2.0,320.372937538,2349.17408087356,0.89183,1153.327,1.6478659973446461,6.033624081,3.279443062,0.426116483,0,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/cohere-english-v3-1M-b4-pq-r2/query-1.log -cohere-english-v3-1M-b4-pq-r2,cohere-english-v3-1M,1024,512,4,PQ,2,261,48,1.0,,127888.00395618335,0.73738,789.9314,0.6495444554179923,,,0.0,0,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/cohere-english-v3-1M-b4-pq-r2/query-48.log -cohere-english-v3-1M-b4-pq-r2,cohere-english-v3-1M,1024,512,4,PQ,2,261,48,2.0,,95135.73123335889,0.89183,1153.327,0.6440058638324528,,,0.0,0,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/cohere-english-v3-1M-b4-pq-r2/query-48.log -cohere-english-v3-1M-b4-ash-r2,cohere-english-v3-1M,1024,512,4,ASH,2,261,1,1.0,158.050770213,3262.9891204212204,0.80006,834.193,6.604247423409825,4.543623972,2.239317788,0.479592937,2.036372,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/cohere-english-v3-1M-b4-ash-r2/query-1.log -cohere-english-v3-1M-b4-ash-r2,cohere-english-v3-1M,1024,512,4,ASH,2,261,1,2.0,158.050770213,2571.738833639199,0.91205,1235.6143,2.7208643656202733,4.543623972,2.239317788,0.479592937,2.036372,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/cohere-english-v3-1M-b4-ash-r2/query-1.log -cohere-english-v3-1M-b4-ash-r2,cohere-english-v3-1M,1024,512,4,ASH,2,261,48,1.0,,128859.33340409711,0.80006,834.193,2.2828342408538718,,,0.0,0,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/cohere-english-v3-1M-b4-ash-r2/query-48.log -cohere-english-v3-1M-b4-ash-r2,cohere-english-v3-1M,1024,512,4,ASH,2,261,48,2.0,,94374.11028668427,0.91205,1235.6143,0.6084842422665558,,,0.0,0,/mnt/raid10/jvector-bench/ted_willke/figure9-graph/cohere-english-v3-1M-b4-ash-r2/query-48.log diff --git a/docs/figure9-graph-study/system-summary.json b/docs/figure9-graph-study/system-summary.json deleted file mode 100644 index 7672b8df4..000000000 --- a/docs/figure9-graph-study/system-summary.json +++ /dev/null @@ -1,162 +0,0 @@ -[ - { - "case": "cohere-english-v3-1M-b2-pq-r2", - "pid": "2035635", - "from_utc": "2026-09-19T20:30:27.089599+00:00", - "to_utc": "2026-09-19T20:34:59.294364+00:00", - "min_available_GiB": 320.67454528808594, - "max_rss_GiB": 22.119491577148438, - "max_dirty_writeback_GiB": 3.08685302734375, - "max_memory_psi_avg10": 0.0 - }, - { - "case": "cohere-english-v3-1M-b2-pq-r2", - "pid": "2037555", - "from_utc": "2026-09-19T20:35:04.320821+00:00", - "to_utc": "2026-09-19T20:35:34.495364+00:00", - "min_available_GiB": 324.8975257873535, - "max_rss_GiB": 21.283061981201172, - "max_dirty_writeback_GiB": 0.0055694580078125, - "max_memory_psi_avg10": 0.0 - }, - { - "case": "cohere-english-v3-1M-b2-ash-r2", - "pid": "2038257", - "from_utc": "2026-09-19T20:35:39.520745+00:00", - "to_utc": "2026-09-19T20:39:31.266594+00:00", - "min_available_GiB": 316.5094299316406, - "max_rss_GiB": 23.954978942871094, - "max_dirty_writeback_GiB": 6.342803955078125, - "max_memory_psi_avg10": 0.0 - }, - { - "case": "cohere-english-v3-1M-b2-ash-r2", - "pid": "2039608", - "from_utc": "2026-09-19T20:39:36.294845+00:00", - "to_utc": "2026-09-19T20:40:06.513302+00:00", - "min_available_GiB": 321.39935302734375, - "max_rss_GiB": 25.008392333984375, - "max_dirty_writeback_GiB": 0.000514984130859375, - "max_memory_psi_avg10": 0.0 - }, - { - "case": "cohere-english-v3-1M-b4-pq-r2", - "pid": "2040168", - "from_utc": "2026-09-19T20:40:11.542975+00:00", - "to_utc": "2026-09-19T20:46:29.965677+00:00", - "min_available_GiB": 321.69421005249023, - "max_rss_GiB": 23.741764068603516, - "max_dirty_writeback_GiB": 5.1027984619140625, - "max_memory_psi_avg10": 0.0 - }, - { - "case": "cohere-english-v3-1M-b4-pq-r2", - "pid": "2042565", - "from_utc": "2026-09-19T20:46:40.022028+00:00", - "to_utc": "2026-09-19T20:47:10.261674+00:00", - "min_available_GiB": 324.74349212646484, - "max_rss_GiB": 23.483871459960938, - "max_dirty_writeback_GiB": 0.0050201416015625, - "max_memory_psi_avg10": 0.0 - }, - { - "case": "cohere-english-v3-1M-b4-ash-r2", - "pid": "2043265", - "from_utc": "2026-09-19T20:47:15.292182+00:00", - "to_utc": "2026-09-19T20:51:12.183433+00:00", - "min_available_GiB": 319.2004508972168, - "max_rss_GiB": 26.202648162841797, - "max_dirty_writeback_GiB": 8.726558685302734, - "max_memory_psi_avg10": 0.0 - }, - { - "case": "cohere-english-v3-1M-b4-ash-r2", - "pid": "2045044", - "from_utc": "2026-09-19T20:51:17.211849+00:00", - "to_utc": "2026-09-19T20:51:47.450795+00:00", - "min_available_GiB": 324.8402404785156, - "max_rss_GiB": 24.63471221923828, - "max_dirty_writeback_GiB": 0.001110076904296875, - "max_memory_psi_avg10": 0.0 - }, - { - "case": "cohere-english-v3-1M-b4-ash-r1", - "pid": "2057944", - "from_utc": "2026-09-19T21:19:22.866606+00:00", - "to_utc": "2026-09-19T21:21:19.922627+00:00", - "min_available_GiB": 328.5126152038574, - "max_rss_GiB": 20.657455444335938, - "max_dirty_writeback_GiB": 0.005218505859375, - "max_memory_psi_avg10": 0.0 - }, - { - "case": "cohere-100k-b2-ash", - "pid": "2058896", - "from_utc": "2026-09-19T21:21:45.134857+00:00", - "to_utc": "2026-09-19T21:22:00.303707+00:00", - "min_available_GiB": 333.24229431152344, - "max_rss_GiB": 6.707263946533203, - "max_dirty_writeback_GiB": 0.6126480102539062, - "max_memory_psi_avg10": 0.0 - }, - { - "case": "cohere-100k-b2-ash", - "pid": "2060298", - "from_utc": "2026-09-19T21:25:06.850904+00:00", - "to_utc": "2026-09-19T21:25:22.002665+00:00", - "min_available_GiB": 334.38719177246094, - "max_rss_GiB": 5.549938201904297, - "max_dirty_writeback_GiB": 0.8749160766601562, - "max_memory_psi_avg10": 0.0 - }, - { - "case": "cohere-100k-b2-pq", - "pid": "2060984", - "from_utc": "2026-09-19T21:25:27.082719+00:00", - "to_utc": "2026-09-19T21:25:47.338261+00:00", - "min_available_GiB": 318.1367721557617, - "max_rss_GiB": 21.780776977539062, - "max_dirty_writeback_GiB": 0.7725296020507812, - "max_memory_psi_avg10": 0.0 - }, - { - "case": "cohere-100k-b4-ash", - "pid": "2061586", - "from_utc": "2026-09-19T21:25:52.388155+00:00", - "to_utc": "2026-09-19T21:26:12.654560+00:00", - "min_available_GiB": 335.6449546813965, - "max_rss_GiB": 4.201637268066406, - "max_dirty_writeback_GiB": 1.6809310913085938, - "max_memory_psi_avg10": 0.0 - }, - { - "case": "cohere-100k-b4-pq", - "pid": "2062247", - "from_utc": "2026-09-19T21:26:17.706419+00:00", - "to_utc": "2026-09-19T21:26:58.288125+00:00", - "min_available_GiB": 333.78203201293945, - "max_rss_GiB": 6.060382843017578, - "max_dirty_writeback_GiB": 1.7176704406738281, - "max_memory_psi_avg10": 0.0 - }, - { - "case": "cohere-1m-b4-ash", - "pid": "2062996", - "from_utc": "2026-09-19T21:27:13.430324+00:00", - "to_utc": "2026-09-19T21:30:46.023376+00:00", - "min_available_GiB": 321.1495819091797, - "max_rss_GiB": 18.847640991210938, - "max_dirty_writeback_GiB": 3.8353195190429688, - "max_memory_psi_avg10": 0.0 - }, - { - "case": "cohere-1m-b4-pq", - "pid": "2064589", - "from_utc": "2026-09-19T21:30:51.080848+00:00", - "to_utc": "2026-09-19T21:36:51.520063+00:00", - "min_available_GiB": 317.8377342224121, - "max_rss_GiB": 21.94839859008789, - "max_dirty_writeback_GiB": 5.549713134765625, - "max_memory_psi_avg10": 0.0 - } -] \ No newline at end of file diff --git a/docs/figure9-graph-study/tables.md b/docs/figure9-graph-study/tables.md deleted file mode 100644 index 51dbb6e4e..000000000 --- a/docs/figure9-graph-study/tables.md +++ /dev/null @@ -1,81 +0,0 @@ -# Final graph results - -Means of two independent builds per configuration. ASH/PQ values are shown in that order. - -## Single-threaded queries - -| Dataset | ASH bits / PQ matching budget | Overquery | Recall@10 (%): ASH / PQ | QPS: ASH / PQ | ASH/PQ QPS | Recall difference (pp) | -|---|---:|---:|---:|---:|---:|---:| -| ada002-1M | 2 | 1× | 82.724 / 61.028 | 2,087 / 2,627 | 0.79× | +21.696 | -| ada002-1M | 2 | 2× | 95.969 / 81.254 | 1,938 / 2,143 | 0.90× | +14.715 | -| ada002-1M | 4 | 1× | 88.771 / 78.085 | 1,817 / 2,255 | 0.81× | +10.686 | -| ada002-1M | 4 | 2× | 96.114 / 94.155 | 1,693 / 1,771 | 0.96× | +1.959 | -| cap-1M | 2 | 1× | 79.109 / 57.654 | 2,557 / 2,561 | 1.00× | +21.456 | -| cap-1M | 2 | 2× | 94.369 / 77.959 | 2,075 / 2,111 | 0.98× | +16.410 | -| cap-1M | 4 | 1× | 86.525 / 77.097 | 2,059 / 2,237 | 0.92× | +9.427 | -| cap-1M | 4 | 2× | 95.438 / 93.395 | 1,734 / 1,789 | 0.97× | +2.043 | -| cohere-english-v3-1M | 2 | 1× | 70.487 / 66.674 | 3,872 / 3,657 | 1.06× | +3.813 | -| cohere-english-v3-1M | 2 | 2× | 88.550 / 85.674 | 3,356 / 2,880 | 1.17× | +2.876 | -| cohere-english-v3-1M | 4 | 1× | 79.983 / 73.727 | 3,461 / 3,055 | 1.13× | +6.255 | -| cohere-english-v3-1M | 4 | 2× | 91.210 / 89.178 | 2,612 / 2,323 | 1.12× | +2.031 | - -## Construction - -Graph column includes scorer-provider setup. Phase subtotal also includes compressor training, base encoding and NVQ compressor setup; it excludes dataset loading, JVM startup and uninstrumented writer initialization. - -| Dataset | Bits/budget | Graph + provider seconds: ASH / PQ | Training seconds: ASH / PQ | Encoding seconds: ASH / PQ | Measured phase subtotal seconds: ASH / PQ | -|---|---:|---:|---:|---:|---:| -| ada002-1M | 2 | 156.20 / 309.93 | 9.43 / 6.69 | 7.65 / 4.73 | 173.79 / 321.90 | -| ada002-1M | 4 | 230.94 / 489.46 | 8.21 / 6.97 | 6.14 / 4.86 | 245.86 / 501.90 | -| cap-1M | 2 | 152.19 / 276.91 | 9.18 / 16.51 | 7.11 / 4.54 | 168.98 / 298.45 | -| cap-1M | 4 | 224.20 / 406.63 | 8.43 / 7.55 | 7.37 / 4.72 | 240.52 / 419.45 | -| cohere-english-v3-1M | 2 | 145.53 / 206.35 | 5.33 / 5.44 | 1.80 / 3.86 | 153.08 / 216.02 | -| cohere-english-v3-1M | 4 | 410.35 / 772.52 | 4.54 / 5.85 | 2.23 / 3.28 | 417.54 / 782.05 | - -## Same cached indexes, 48 query workers - -| Dataset | Bits/budget | Overquery | Concurrent QPS: ASH / PQ | Speedup over one worker: ASH / PQ | -|---|---:|---:|---:|---:| -| ada002-1M | 2 | 1× | 95,507 / 96,984 | 45.76× / 36.92× | -| ada002-1M | 2 | 2× | 74,420 / 81,508 | 38.40× / 38.03× | -| ada002-1M | 4 | 1× | 77,725 / 91,585 | 42.79× / 40.62× | -| ada002-1M | 4 | 2× | 58,574 / 69,742 | 34.60× / 39.39× | -| cap-1M | 2 | 1× | 95,767 / 99,468 | 37.45× / 38.84× | -| cap-1M | 2 | 2× | 78,606 / 85,994 | 37.88× / 40.74× | -| cap-1M | 4 | 1× | 82,883 / 91,129 | 40.26× / 40.74× | -| cap-1M | 4 | 2× | 62,894 / 71,334 | 36.28× / 39.86× | -| cohere-english-v3-1M | 2 | 1× | 155,768 / 138,016 | 40.23× / 37.74× | -| cohere-english-v3-1M | 2 | 2× | 112,761 / 109,256 | 33.60× / 37.94× | -| cohere-english-v3-1M | 4 | 1× | 129,480 / 126,769 | 37.41× / 41.49× | -| cohere-english-v3-1M | 4 | 2× | 94,590 / 95,306 | 36.21× / 41.03× | - -## Between-build ranges - -Ranges of two observations, not confidence intervals. - -| Dataset | Bits/budget | Method | Overquery | Single-thread QPS range | Recall@10 (%) range | Graph + setup seconds range | -|---|---:|---|---:|---:|---:|---:| -| ada002-1M | 2 | ASH | 1× | 1,996–2,178 | 82.642–82.806 | 151.60–160.79 | -| ada002-1M | 2 | ASH | 2× | 1,803–2,073 | 95.953–95.985 | 151.60–160.79 | -| ada002-1M | 2 | PQ | 1× | 2,506–2,749 | 60.969–61.087 | 305.36–314.50 | -| ada002-1M | 2 | PQ | 2× | 2,071–2,215 | 81.207–81.301 | 305.36–314.50 | -| ada002-1M | 4 | ASH | 1× | 1,748–1,885 | 88.768–88.774 | 230.19–231.68 | -| ada002-1M | 4 | ASH | 2× | 1,691–1,695 | 96.112–96.117 | 230.19–231.68 | -| ada002-1M | 4 | PQ | 1× | 2,214–2,296 | 78.046–78.124 | 471.03–507.89 | -| ada002-1M | 4 | PQ | 2× | 1,697–1,844 | 94.098–94.212 | 471.03–507.89 | -| cap-1M | 2 | ASH | 1× | 2,442–2,672 | 79.039–79.180 | 147.18–157.20 | -| cap-1M | 2 | ASH | 2× | 1,969–2,181 | 94.304–94.435 | 147.18–157.20 | -| cap-1M | 2 | PQ | 1× | 2,539–2,583 | 57.475–57.832 | 235.00–318.82 | -| cap-1M | 2 | PQ | 2× | 2,102–2,119 | 77.755–78.163 | 235.00–318.82 | -| cap-1M | 4 | ASH | 1× | 1,969–2,149 | 86.483–86.566 | 221.44–226.96 | -| cap-1M | 4 | ASH | 2× | 1,710–1,757 | 95.431–95.445 | 221.44–226.96 | -| cap-1M | 4 | PQ | 1× | 2,011–2,463 | 77.078–77.117 | 384.54–428.72 | -| cap-1M | 4 | PQ | 2× | 1,642–1,937 | 93.387–93.402 | 384.54–428.72 | -| cohere-english-v3-1M | 2 | ASH | 1× | 3,712–4,032 | 70.446–70.528 | 133.48–157.58 | -| cohere-english-v3-1M | 2 | ASH | 2× | 2,991–3,721 | 88.522–88.578 | 133.48–157.58 | -| cohere-english-v3-1M | 2 | PQ | 1× | 3,555–3,760 | 66.659–66.689 | 202.06–210.65 | -| cohere-english-v3-1M | 2 | PQ | 2× | 2,755–3,005 | 85.658–85.690 | 202.06–210.65 | -| cohere-english-v3-1M | 4 | ASH | 1× | 3,263–3,660 | 79.959–80.006 | 160.09–660.61 | -| cohere-english-v3-1M | 4 | ASH | 2× | 2,572–2,653 | 91.205–91.214 | 160.09–660.61 | -| cohere-english-v3-1M | 4 | PQ | 1× | 3,054–3,057 | 73.716–73.738 | 320.37–1224.67 | -| cohere-english-v3-1M | 4 | PQ | 2× | 2,296–2,349 | 89.174–89.183 | 320.37–1224.67 | diff --git a/docs/figure9-graph-study/timing-validation.md b/docs/figure9-graph-study/timing-validation.md deleted file mode 100644 index 4bab23dce..000000000 --- a/docs/figure9-graph-study/timing-validation.md +++ /dev/null @@ -1,14 +0,0 @@ -## Timing validation - -The predeclared rule retimed any case/JVM with a sample CV above 10%, using both overquery settings and at least 10 seconds per sample (three samples). Four cached-index JVMs were retimed. All recall and visited-count values matched the originals exactly. No reconstruction was performed. These longer observations supply QPS in the reported tables; original observations remain in results.csv and in the original columns of reported-results.csv. - -| Case | Overquery | Original QPS | Longer QPS | Original CV | Longer CV | -|---|---:|---:|---:|---:|---:| -| ada002-1M-b4-pq-r2 | 1× | 1,821 | 2,214 | 30.7% | 2.7% | -| ada002-1M-b4-pq-r2 | 2× | 1,588 | 1,697 | 14.3% | 0.6% | -| cap-1M-b2-ash-r2 | 1× | 2,258 | 2,672 | 23.8% | 6.6% | -| cap-1M-b2-ash-r2 | 2× | 2,053 | 2,181 | 2.7% | 1.4% | -| cap-1M-b4-pq-r2 | 1× | 1,997 | 2,463 | 18.8% | 5.7% | -| cap-1M-b4-pq-r2 | 2× | 1,628 | 1,937 | 15.1% | 0.9% | -| cohere-english-v3-1M-b4-ash-r1 | 1× | 2,915 | 3,660 | 18.9% | 3.3% | -| cohere-english-v3-1M-b4-ash-r1 | 2× | 2,319 | 2,653 | 2.8% | 0.8% | diff --git a/docs/native-ash-preparation.md b/docs/native-ash-preparation.md index b6652e6b5..91e3cf1a1 100644 --- a/docs/native-ash-preparation.md +++ b/docs/native-ash-preparation.md @@ -205,7 +205,7 @@ BenchYAML preflights confirm actual native calls; tracing is disabled for all performance measurements. Separate fresh full-data builds verify construction. The focused cases are MPNet at the 2-bit ASH budget and OpenAI3072 at the 4-bit ASH budget on both JDKs. They are not a replacement for the earlier full PQ/ASH -matrix. Full results and measurement scope accompany the saved report. +matrix. ## FP32 encodeAll measurements (JDK 22, October 31, 2026) @@ -216,8 +216,8 @@ result packing. They are not timings of individual `encode()` calls. Training and graph/write are separate metrics. The first native packing cost is included. Both runs use physical CPUs 0–47 with memory interleaved across NUMA nodes 0 and -1. The before version is `16273e24` (per-vector Panama FP32 encoding); the after -code is `83e49aac` (Highway FP32 worker-local batches). Both use fresh model and +1. The before version encodes each vector with Panama FP32; the after version +encodes worker-local batches with Highway FP32. Both use fresh model and index caches, the native scoring provider, deferred parallel writes, NVQ reranking, and FP16 query projection. All eight serialized trained models are byte-identical between the two runs. Each cell is one full construction run; @@ -304,10 +304,6 @@ OpenAI3072 gain is modest; these measurements do not establish improvements for all other datasets, code widths or hardware. Median graph recall@10 was 81.901% versus 81.844% for MPNet and 79.280% versus 79.310% for OpenAI3072. Independent parallel graph rebuilds vary and do not isolate an encoding-only recall effect. -Training and graph-write timers/recall for every run are retained in the raw -CSVs; they are not manually added to inclusive harness timers. Original CSVs, -commands, class/source snapshots and live placement observations are preserved -under `benchmark-runs/ash-quantization-20261101` on the development server. ## ITQ training workspaces diff --git a/docs/pq-iso-compression-recall.md b/docs/pq-iso-compression-recall.md deleted file mode 100644 index 5d84343da..000000000 --- a/docs/pq-iso-compression-recall.md +++ /dev/null @@ -1,115 +0,0 @@ -# DistancesPQ full-scan recall at exact code sizes - -`DistancesPQ` supports an accuracy-only mode for comparison with the collected -`DistancesASH` neighborhood full-scan recall. It trains and encodes standard PQ, -prepares one production `PQDecoder.DotProductDecoder` lookup table per query, -scans every encoded base ordinal, and compares its approximate top ten with the -first ten IDs in the matching ground-truth file. It performs no graph search or -exact reranking. Independent queries can run concurrently; the run duration is -not a throughput benchmark. - -Enable `jvector.bench.recall=true` and `jvector.bench.recall-only=true`. Queries -are normalized in this mode to match the ASH neighborhood fixture policy. Base -vectors retain their input values. Exact score ties prefer smaller base IDs. -The existing throughput mode and its query policy remain the default. - -For a comparison of learned quantizers, run ASH with -`jvector.ash.optimizer=itq`. This uses the existing PCA/ITQ training defaults: -`min(N,20D)` sampled base vectors, up to 25 iterations, and early stopping. -Keep C=1, d=D/2 and the same bit width when reproducing the table below. -`DistancesASH` defaults to the RANDOM transform for its isolated kernel runs; -those results are an untrained control rather than a trained-ASH accuracy claim. -Use a fresh neighborhood fixture path for ITQ: an existing fixture replays its -stored model, so changing the optimizer property does not retrain that fixture. -Preserve the training log and verify the serialized optimizer and matrix hash. - -Set `jvector.pq.subspaces=M` for an exact M-byte code: PQ uses 256 centroids per -subspace, so each subspace requires one byte. PQ already supports uneven -subvector sizes. This setting and the legacy `jvector.pq.mFactor` are mutually -exclusive; absent either option the legacy factor of eight is preserved. - -For ASH with C=1, projected dimension D/2, and 2 or 4 bits per projected -coordinate, the packed per-vector budget is `5 + (D/2)*bits/8` bytes. The five -bytes cover the ASH vector header. The corresponding PQ budgets are: - -| Dataset dimension | ASH 2-bit / PQ bytes | ASH 4-bit / PQ bytes | -| ---: | ---: | ---: | -| 768 | 101 | 197 | -| 1024 | 133 | 261 | -| 1536 | 197 | 389 | -| 3072 | 389 | 773 | - -This matches per-vector code bytes, not total serialized index size. Shared -ASH transforms and PQ codebooks must be reported separately. PQ uses its normal -training policy (up to 128,000 sampled vectors, six clustering rounds, no global -centering, no anisotropic weighting). Centroid initialization is stochastic; -preserve the trained artifact to identify the actual model measured. - -Example, with dataset-specific paths and an existing output directory: - -```sh -java --add-modules=jdk.incubator.vector --enable-native-access=ALL-UNNAMED \ - -Djvector.vectorization_provider=panama \ - -Djvector.pq.subspaces=101 \ - -Djvector.bench.recall=true -Djvector.bench.recall-only=true \ - -Djvector.bench.recall.k=10 \ - -Djvector.bench.trainingThreads=48 -Djvector.bench.scoringThreads=48 \ - -Djvector.bench.recall.output=/results/mpnet-pq-101.tsv \ - -Djvector.bench.pq.artifact=/results/mpnet-pq-101.pqv \ - -cp "$JVECTOR_CLASSPATH" io.github.jbellis.jvector.example.DistancesPQ \ - /data/base.fvecs /data/query.fvecs /data/ground-truth.ivecs -``` - -Choose thread counts and JVM memory for the machine and dataset. No query limit -is needed: the default uses all queries. `jvector.bench.maxQueries` can restrict -the query prefix when explicitly desired. Use the same complete base and query -sets as the ASH measurement for a direct comparison. - -The log identifies the provider, ADC implementation, normalized-query SHA-256, -shared codebook size, and exact hit numerator/denominator. A small independent -reconstruction check validates ADC scores before the recall scan. The TSV stores -per-query IDs, scores and hits; the optional PQ artifact stores the trained -compressor and encoded base set. Output paths must not exist. No scalar -comparison is implied by the accuracy-only flag: SIMD selection is controlled -by the provider and available vector width. - -## Measured trained-ASH comparison - -JDK 22.0.2+9, Panama SIMD (512-bit), complete base sets and all queries, -C=1, d=D/2, 32-vector neighborhoods, no graph traversal or reranking. -ASH uses the unchanged ITQ training defaults described above. PQ uses its -normal K=256 training policy. Each case retains its trained model and rankings. - -| Dataset | ASH bits | Bytes/vector | ASH recall@10 | PQ recall@10 | ASH − PQ (pp) | -| --- | ---: | ---: | ---: | ---: | ---: | -| mpnet-1M | 2 | 101 | 88.011% | 82.040% | +5.971 | -| mpnet-1M | 4 | 197 | 95.569% | 89.478% | +6.091 | -| cohere-1M | 2 | 133 | 77.200% | 74.416% | +2.784 | -| cohere-1M | 4 | 261 | 91.421% | 84.496% | +6.925 | -| ada002-1M | 2 | 197 | 86.449% | 66.772% | +19.677 | -| ada002-1M | 4 | 389 | 94.217% | 81.731% | +12.486 | -| openai-3072-1M | 2 | 389 | 90.570% | 85.600% | +4.970 | -| openai-3072-1M | 4 | 773 | 96.170% | 92.130% | +4.040 | - -MPNet, Cohere and Ada002 use 10,000 queries each; OpenAI-3072 uses 1,000. -Cleaned base counts are respectively 999,812; 1,000,000; 982,790; and 999,000. -All eight cases favor trained ASH in this measurement. Shared model storage is -excluded from the matched code sizes: ASH models are approximately 1.18/2.10/4.72/18.89 MB -for these datasets, versus PQ's 0.79/1.05/1.57/3.15 MB. This is not equal total -serialized storage and does not compare with OPQ or Faiss 4-bit FastScan. - -The original collected ASH results used RANDOM transforms and remain controls. -For example, Cohere RANDOM recall was 59.898%/65.499%; trained recall is -77.200%/91.421%. No model or parameter was selected using recall. PQ has one -stochastic fresh fit per case, saved for reproduction. - -Source for the measured runs: `c136c5411a4fa207823d0b8f9cdc2ae780bcf6cc`. -Evidence on the benchmark host: `/home/ted_willke/benchmark-runs/pq-iso-ash-full-scan-recall-jdk22-20261028`. -Its `summary.csv` includes exact hit numerators, denominators and model hashes; -`validation.json` records independent ground-truth/ranking checks. All prepared -query hashes agree bit for bit. Each trained ASH fixture declares ITQ and has a -different matrix from its RANDOM counterpart. Logs and rankings are retained -for both methods, with PQ artifacts in `artifacts/` and ASH fixtures in -`ash-itq/fixtures/`. Source, protocol and outputs are also attached to the local -comparison report. These are accuracy runs with 48 independent query workers; -no throughput conclusion is drawn from their durations. diff --git a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/DistancesASH.java b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/DistancesASH.java index fc91ee0d1..128ca5214 100644 --- a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/DistancesASH.java +++ b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/DistancesASH.java @@ -1193,9 +1193,9 @@ public static void runGIST() throws IOException { public static void runCohere100k() throws IOException { System.out.println("Running Cohere-100k"); - var baseVectors = "/home/ted_willke/datasets-clean/cohere_english_v3_100k_base_99685.fvecs"; - var queryVectors = "/home/ted_willke/datasets-clean/cohere_english_v3_100k_query_10000.fvecs"; - var gtVectors = "/home/ted_willke/datasets-clean/cohere_english_v3_100k_gt_ip_100.ivecs"; + var baseVectors = "dataset_cache/public/cohere_english_v3_100k_base_99685.fvecs"; + var queryVectors = "dataset_cache/public/cohere_english_v3_100k_query_10000.fvecs"; + var gtVectors = "dataset_cache/public/cohere_english_v3_100k_gt_ip_100.ivecs"; testASHEncodings(baseVectors, queryVectors, gtVectors); } diff --git a/rat-excludes.txt b/rat-excludes.txt index 9e887f482..73e9cd4a0 100644 --- a/rat-excludes.txt +++ b/rat-excludes.txt @@ -40,10 +40,5 @@ jvector-native/src/main/native/third_party/** src/main/native/third_party/** jvector-native/src/target/meson-build/** jvector-native/target/meson-build/** -docs/figure9-graph-study/*.csv -docs/figure9-graph-study/jars.sha256 -docs/benchmarks/**/*.csv -docs/benchmarks/native-ash-projection-symmetric-20261029/native-isa.txt -docs/ash-symmetric-study/*.csv .run/*.run.xml bench-profiles/** From 955039f1f9938039bac1b39cdc18f6f7693ad4c8 Mon Sep 17 00:00:00 2001 From: Ted Willke Date: Mon, 9 Nov 2026 15:26:09 -0800 Subject: [PATCH 176/243] Default BenchYAML to a few public datasets and key output The default dataset list runs three public 100k datasets, ada002-1M and sift-128-euclidean; the rest stay listed but commented. The default run config prints throughput, latency, visited count, recall, heap and build time, with comments listing every option, and leaves CSV logging off. --- jvector-examples/yaml-configs/datasets.yml | 31 +++++----- jvector-examples/yaml-configs/run-config.yml | 59 ++++++++++++-------- 2 files changed, 52 insertions(+), 38 deletions(-) diff --git a/jvector-examples/yaml-configs/datasets.yml b/jvector-examples/yaml-configs/datasets.yml index 7ec47f6ef..3420dc00b 100644 --- a/jvector-examples/yaml-configs/datasets.yml +++ b/jvector-examples/yaml-configs/datasets.yml @@ -1,24 +1,27 @@ +# Datasets BenchYAML runs when no dataset is named on the command line. +# The defaults are a few public 100k and 1M datasets that download on first use +# (see dataset-catalogs/public-catalog.yaml). Uncomment others to include them. jvector-100k: + - cohere-english-v3-100k - ada002-100k - - openai-v3-large-3072-100k - - openai-v3-large-1536-100k - openai-v3-small-1536-100k - - gecko-100k - - cohere-english-v3-100k - - e5-small-v2-100k - - e5-base-v2-100k - - e5-large-v2-100k -#jvector-1M: -# - ada002-1M +# - gecko-100k +# - openai-v3-large-3072-100k +# - openai-v3-large-1536-100k +# - e5-small-v2-100k +# - e5-base-v2-100k +# - e5-large-v2-100k +jvector-1M: + - ada002-1M # - colbert-1M -#ann-benchmarks: +ann-benchmarks: + - sift-128-euclidean # - glove-25-angular # - glove-50-angular # - lastfm-64-dot # - glove-100-angular # - glove-200-angular # - nytimes-256-angular -# - sift-128-euclidean #regression-tests: # - cap-1M # - cap-6M @@ -30,12 +33,8 @@ jvector-100k: # - openai-3072-1m # - openai-1536-1m #other-datasets: -# - cap-1M -# - cap-6M -# - cohere-english-v3-1M -# - cohere-english-v3-10M # - mpnet-1m # - tahoe-x1-1m # - personahub-1m # - deep-image-96-angular # large files not yet supported -# - gist-960-euclidean # large files not yet supported \ No newline at end of file +# - gist-960-euclidean # large files not yet supported diff --git a/jvector-examples/yaml-configs/run-config.yml b/jvector-examples/yaml-configs/run-config.yml index e50a915db..6c5eb9abc 100644 --- a/jvector-examples/yaml-configs/run-config.yml +++ b/jvector-examples/yaml-configs/run-config.yml @@ -1,27 +1,42 @@ +# Run-level controls for BenchYAML (applies to the whole invocation) yamlSchemaVersion: 1 -onDiskIndexVersion: 7 +onDiskIndexVersion: 7 # 7 adds fused ASH graphs; 6 is the previous format + +# Benchmark stats to compute benchmarks: - throughput: [AVG] - latency: [AVG, P999] - count: [visited, expanded, expanded base layer] - accuracy: [recall] + throughput: [ AVG ] # [AVG, MEDIAN, MAX] + latency: [ AVG ] # [AVG, STD, P999] + count: [ visited ] # [visited, expanded, expanded base layer] + accuracy: [ recall ] # [recall, MAP] + +# Console display (omit/leave blank to print all computed outputs) console: benchmarks: - throughput: [AVG] - latency: [AVG, P999] - count: [visited, expanded, expanded base layer] - accuracy: [recall] + throughput: [ AVG ] # [AVG, MEDIAN, MAX] + latency: [ AVG ] # [AVG, STD, P999] + count: [ visited ] # [visited, expanded, expanded base layer] + accuracy: [ recall ] # [recall, MAP] metrics: - construction: [index_build_time_s, index_quant_time_s, search_quant_time_s] -logging: - dir: logging - jvectorRef: "ash-dev" - runId: "ash-64x-integrated-{ts}" - type: csv - benchmarks: - throughput: [AVG] - latency: [AVG, P999] - count: [visited, expanded, expanded base layer] - accuracy: [recall] - metrics: - construction: [index_build_time_s, index_quant_time_s, search_quant_time_s] + system: [ max_heap_mb, max_offheap_mb ] # [max_heap_mb, max_offheap_mb] + construction: [ index_build_time_s ] # [index_build_time_s, graph_build_time_s, flush_time_s, + # reopen_time_s, index_quant_time_s, search_quant_time_s] +# disk: [ total_file_size_mb ] # [total_file_size_mb, file_count] +# execution: [ response_mean_ms ] # queued or direct query execution: + # [response_mean_ms, response_p95_ms, response_p99_ms, mean_batch_size] + +# Experiments CSV logging (selection + run metadata) +#logging: +# dir: logging # set as desired +# jvectorRef: "no-tag" # tag or git sha (recommended) +# runId: "my-experiment-{ts}" # optional; template with {ts} (UTC timestamp). Default: "{ts}" +# type: csv # only csv is presently supported +# benchmarks: +# throughput: [ AVG ] # [AVG, MEDIAN, MAX] +# latency: [ AVG ] # [ AVG, STD, P999 ] +# count: [ visited ] # [ visited, expanded, expanded base layer ] +# accuracy: [ recall ] # [ recall, MAP ] +# metrics: +# system: [ max_heap_mb ] # [ max_heap_mb, max_offheap_mb ] +# disk: [ total_file_size_mb, file_count ] # [ total_file_size_mb, file_count ] +# construction: [ index_build_time_s ] # [ index_build_time_s, graph_build_time_s, flush_time_s, reopen_time_s, index_quant_time_s, search_quant_time_s ] +# execution: [ response_mean_ms ] # [ response_mean_ms, response_p95_ms, response_p99_ms, mean_batch_size ] From 3549f97eba55217fbfe1c0d76de6d324bc583d33 Mon Sep 17 00:00:00 2001 From: Ted Willke Date: Mon, 9 Nov 2026 15:26:45 -0800 Subject: [PATCH 177/243] Remove one-off PQ analysis tools and kernel tuning harnesses PQRecallEvaluator, the recall-only DistancesPQ mode, PQEntropyAnalysis, TestAshRunWithoutTraining, BenchASHKernels and TestASHKernelTuning served individual investigations. DistancesPQ, DistancesASH and DistancesFP remain as microbenchmarks. --- .../jbellis/jvector/example/DistancesPQ.java | 23 -- .../jvector/example/PQEntropyAnalysis.java | 198 --------------- .../jvector/example/PQRecallEvaluator.java | 240 ------------------ .../example/TestAshRunWithoutTraining.java | 63 ----- .../example/TestPQRecallEvaluator.java | 156 ------------ .../jvector/quantization/BenchASHKernels.java | 130 ---------- .../quantization/TestASHKernelTuning.java | 101 -------- 7 files changed, 911 deletions(-) delete mode 100644 jvector-examples/src/main/java/io/github/jbellis/jvector/example/PQEntropyAnalysis.java delete mode 100644 jvector-examples/src/main/java/io/github/jbellis/jvector/example/PQRecallEvaluator.java delete mode 100644 jvector-examples/src/main/java/io/github/jbellis/jvector/example/TestAshRunWithoutTraining.java delete mode 100644 jvector-tests/src/test/java/io/github/jbellis/jvector/example/TestPQRecallEvaluator.java delete mode 100644 jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/BenchASHKernels.java delete mode 100644 jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestASHKernelTuning.java diff --git a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/DistancesPQ.java b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/DistancesPQ.java index 84b066a83..cbe7746e7 100644 --- a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/DistancesPQ.java +++ b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/DistancesPQ.java @@ -44,10 +44,6 @@ public class DistancesPQ { * - Configurable training and single-thread scoring executors * - Compression budget selected with jvector.pq.subspaces (exact bytes), or jvector.pq.mFactor * - * Set jvector.bench.recall and jvector.bench.recall-only for a complete ADC recall scan - * with normalized queries, deterministic ties, optional rankings and a saved PQ artifact. - * This mode omits the throughput loops; it does not measure scoring speed. - * * Fast PQ path = PQDecoder (precomputedScoreFunctionFor). */ public static void testPQEncodings(String filenameBase, String filenameQueries, String filenameGT) throws IOException { @@ -57,11 +53,6 @@ public static void testPQEncodings(String filenameBase, String filenameQueries, final boolean RUN_RECALL_CHECK = Boolean.parseBoolean(System.getProperty("jvector.bench.recall", "false")); - final boolean recallOnly = Boolean.getBoolean("jvector.bench.recall-only"); - if (recallOnly && !RUN_RECALL_CHECK) { - throw new IllegalArgumentException("recall-only requires jvector.bench.recall=true"); - } - final int RECALL_K = Integer.getInteger("jvector.bench.recall.k", 10); @@ -78,12 +69,6 @@ public static void testPQEncodings(String filenameBase, String filenameQueries, if (vectors.isEmpty() || queries.isEmpty()) { throw new IllegalArgumentException("Base and query vectors must be nonempty"); } - if (recallOnly) { - // Match the query-normalization policy used by the neighborhood recall benchmark. - for (VectorFloat query : queries) { - VectorUtil.l2normalize(query); - } - } System.out.format("\t%d base and %d measured query vectors loaded%n", vectors.size(), queries.size()); final List> groundTruth; @@ -198,14 +183,6 @@ public static void testPQEncodings(String filenameBase, String filenameQueries, System.out.println("\tShared PQ compressor size = " + pq.compressorSize() + " bytes"); - if (recallOnly) { - try { - PQRecallEvaluator.run(pqVecs, queries, groundTruth, RECALL_K, simdExecutor); - } finally { - simdExecutor.shutdown(); - } - return; - } // ============================================================ // [1] Accuracy (NOT timed) diff --git a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/PQEntropyAnalysis.java b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/PQEntropyAnalysis.java deleted file mode 100644 index 8bc30717d..000000000 --- a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/PQEntropyAnalysis.java +++ /dev/null @@ -1,198 +0,0 @@ -/* - * 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; - -import io.github.jbellis.jvector.example.util.SiftLoader; -import io.github.jbellis.jvector.graph.ListRandomAccessVectorValues; -import io.github.jbellis.jvector.graph.RandomAccessVectorValues; -import io.github.jbellis.jvector.quantization.PQVectors; -import io.github.jbellis.jvector.quantization.ProductQuantization; -import io.github.jbellis.jvector.vector.types.VectorFloat; -import io.github.jbellis.jvector.util.PhysicalCoreExecutor; - -import java.io.IOException; -import java.util.List; -import java.util.concurrent.ForkJoinPool; - -/** - * Diagnostic tool to analyze Product Quantization centroid assignment entropy. - * - *

    - * Explains PQ scan-time behavior by measuring how uniformly vectors are - * assigned to centroids in each subspace. - *

    - * - *

    - * High entropy ⇒ many centroids exercised ⇒ poor cache locality and slower ADC. - * Low entropy ⇒ concentrated centroids ⇒ faster PQ scans. - *

    - * - *

    - * This is NOT a benchmark: - *

    - *
      - *
    • No timing loops
    • - *
    • No scoring
    • - *
    • No SIMD assumptions
    • - *
    - */ -public class PQEntropyAnalysis { - - public static void analyze( - String label, - String baseVectorsPath, - int encodedBits - ) throws IOException { - - System.out.println("--------------------------------------------------"); - System.out.println("PQ Entropy Analysis: " + label); - - // ------------------------------------------------------------ - // Load base vectors - // ------------------------------------------------------------ - List> vectors = SiftLoader.readFvecs(baseVectorsPath); - int dimension = vectors.get(0).length(); - - System.out.println("Vectors: " + vectors.size()); - System.out.println("Dimension: " + dimension); - System.out.println("Target compression: " + encodedBits + " bits"); - - // ------------------------------------------------------------ - // PQ parameters (match DistancesPQ) - // ------------------------------------------------------------ - // ASH: encodedBits = d / 4 → bytes = d / 32 - // PQ: 1 byte per subspace → M = d / 32 - final int M = Math.max(1, encodedBits / 8); - final int K = 256; - final boolean globallyCenter = false; - final float anisotropicThreshold = 0.0f; - - System.out.println("PQ params:"); - System.out.println(" M (subspaces) = " + M); - System.out.println(" K (clusters) = " + K); - - // ------------------------------------------------------------ - // Build PQ - // ------------------------------------------------------------ - RandomAccessVectorValues ravv = - new ListRandomAccessVectorValues(vectors, dimension); - - ForkJoinPool simdExecutor = PhysicalCoreExecutor.pool(); - - ProductQuantization pq = ProductQuantization.compute( - ravv, - M, - K, - globallyCenter, - anisotropicThreshold, - simdExecutor, - ForkJoinPool.commonPool() - ); - - PQVectors pqVecs = pq.encodeAll(ravv, simdExecutor); - - // ------------------------------------------------------------ - // Count centroid assignments - // ------------------------------------------------------------ - long[][] counts = new long[M][K]; - - for (int i = 0; i < pqVecs.count(); i++) { - var code = pqVecs.get(i); - for (int m = 0; m < M; m++) { - int c = Byte.toUnsignedInt(code.get(m)); - counts[m][c]++; - } - } - - // ------------------------------------------------------------ - // Compute entropy per subspace - // ------------------------------------------------------------ - double totalEntropy = 0.0; - - for (int m = 0; m < M; m++) { - long total = 0; - for (int k = 0; k < K; k++) { - total += counts[m][k]; - } - - double entropy = 0.0; - for (int k = 0; k < K; k++) { - long cnt = counts[m][k]; - if (cnt == 0) continue; - double p = (double) cnt / total; - entropy -= p * (Math.log(p) / Math.log(2)); - } - - totalEntropy += entropy; - } - - double avgEntropy = totalEntropy / M; - double effectiveCentroids = Math.pow(2.0, avgEntropy); - - // ------------------------------------------------------------ - // Report - // ------------------------------------------------------------ - System.out.printf("Subspaces (M): %d%n", M); - System.out.printf("Centroids per subspace (K): %d%n", K); - System.out.printf("Average entropy per subspace: %.4f bits%n", avgEntropy); - System.out.printf("Effective centroids used: %.1f%n", effectiveCentroids); - } - - // ------------------------------------------------------------------ - // Dataset runners (aligned with DistancesPQ / DistancesASH) - // ------------------------------------------------------------------ - - public static void runCohere100k() throws IOException { - analyze( - "Cohere-100k", - "./fvec/cohere-100k/cohere_embed-english-v3.0_1024_base_vectors_100000.fvec", - 256 - ); - } - - public static void runAda002() throws IOException { - analyze( - "ada_002", - "./fvec/ada-002/ada_002_100000_base_vectors.fvec", - 384 - ); - } - - public static void runOpenAI1536() throws IOException { - analyze( - "text-embedding-3-large-1536", - "./fvec/openai-v3-large-1536-100k/text-embedding-3-large_1536_100000_base_vectors.fvec", - 384 - ); - } - - public static void runOpenAI3072() throws IOException { - analyze( - "text-embedding-3-large-3072", - "./fvec/openai-v3-large-3072-100k/text-embedding-3-large_3072_100000_base_vectors.fvec", - 768 - ); - } - - public static void main(String[] args) throws IOException { - runCohere100k(); - runAda002(); - runOpenAI1536(); - runOpenAI3072(); - } -} - diff --git a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/PQRecallEvaluator.java b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/PQRecallEvaluator.java deleted file mode 100644 index 6d69c8f08..000000000 --- a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/PQRecallEvaluator.java +++ /dev/null @@ -1,240 +0,0 @@ -/* - * 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; - -import io.github.jbellis.jvector.disk.SimpleWriter; -import io.github.jbellis.jvector.graph.disk.OnDiskGraphIndex; -import io.github.jbellis.jvector.graph.similarity.ScoreFunction; -import io.github.jbellis.jvector.quantization.PQVectors; -import io.github.jbellis.jvector.vector.VectorSimilarityFunction; -import io.github.jbellis.jvector.vector.VectorUtil; -import io.github.jbellis.jvector.vector.VectorizationProvider; -import io.github.jbellis.jvector.vector.types.ByteSequence; -import io.github.jbellis.jvector.vector.types.VectorFloat; -import java.io.IOException; -import java.nio.ByteBuffer; -import java.nio.file.Files; -import java.nio.file.Path; -import java.nio.file.StandardOpenOption; -import java.security.MessageDigest; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.Comparator; -import java.util.List; -import java.util.PriorityQueue; -import java.util.concurrent.ForkJoinPool; -import java.util.concurrent.ForkJoinTask; -import java.util.concurrent.atomic.AtomicInteger; -import java.util.Locale; - -/** - * DistancesPQ accuracy-only evaluation using the production precomputed ADC scorer. - * Each query prepares one lookup table and scans the complete encoded base set. - * Exact ties prefer smaller original ordinals, matching neighborhood ASH recall. - * Optional outputs preserve per-query rankings and the trained/encoded PQ artifact. - */ -final class PQRecallEvaluator { - private PQRecallEvaluator() {} - - static void run(PQVectors vectors, List> queries, List> truth, int k, - ForkJoinPool executor) throws IOException { - if (queries.isEmpty() || k < 1 || k > vectors.count() || truth.size() < queries.size()) { - throw new IllegalArgumentException("Invalid recall size or missing ground truth"); - } - for (int q = 0; q < queries.size(); q++) { - if (truth.get(q).size() < k || truth.get(q).subList(0, k).stream().distinct().count() != k) { - throw new IllegalArgumentException("Ground truth must contain k distinct neighbors"); - } - for (int id : truth.get(q).subList(0, k)) { - if (id < 0 || id >= vectors.count()) { - throw new IllegalArgumentException("Ground-truth ordinal outside base set"); - } - } - } - var backend = VectorizationProvider.getInstance().getVectorUtilSupport(); - try { - var method = backend.getClass().getMethod( - "assembleAndSum", VectorFloat.class, int.class, ByteSequence.class, int.class, int.class); - System.out.println("PQ ADC backend: " + backend.getClass().getName() - + "; assembly=" + method.getDeclaringClass().getName()); - } catch (NoSuchMethodException error) { - throw new AssertionError("Missing PQ assembly contract", error); - } - var sample = vectors.precomputedScoreFunctionFor(queries.get(0), VectorSimilarityFunction.DOT_PRODUCT); - System.out.println("PQ ADC scorer: " + sample.getClass().getName()); - System.out.println("Normalized query SHA-256: " + queryHash(queries)); - // Compare the lookup scorer with full reconstruction outside the accuracy scan. - validate(vectors, queries); - String artifact = System.getProperty("jvector.bench.pq.artifact"); - if (artifact != null) { - Path target = Path.of(artifact); - if (Files.exists(target)) { - throw new IOException("PQ artifact already exists: " + target); - } - Path temporary = Files.createTempFile(target.toAbsolutePath().getParent(), ".pq-recall-", ".tmp"); - try { - try (var writer = new SimpleWriter(temporary)) { - vectors.write(writer, OnDiskGraphIndex.CURRENT_VERSION); - } - Files.move(temporary, target); - } finally { - Files.deleteIfExists(temporary); - } - } - String outputName = System.getProperty("jvector.bench.recall.output"); - if (outputName != null && Files.exists(Path.of(outputName))) { - throw new IOException("Recall output already exists: " + outputName); - } - Result[] results = new Result[queries.size()]; - var finished = new AtomicInteger(); - List> tasks = new ArrayList<>(); - int workers = Math.min(executor.getParallelism(), queries.size()); - for (int worker = 0; worker < workers; worker++) { - final int first = worker; - tasks.add(executor.submit(() -> { - for (int q = first; q < queries.size(); q += workers) { - var scorer = - vectors.precomputedScoreFunctionFor(queries.get(q), VectorSimilarityFunction.DOT_PRODUCT); - var result = rank(scorer, vectors.count(), truth.get(q).subList(0, k), k); - results[q] = result; - int count = finished.incrementAndGet(); - if (count % 100 == 0 || count == queries.size()) { - System.out.printf( - Locale.ROOT, "PQ recall progress: %d/%d queries complete%n", count, queries.size()); - } - } - })); - } - for (var task : tasks) { - task.join(); - } - long hits = Arrays.stream(results).mapToLong(r -> r.hits).sum(); - if (outputName != null) { - try (var out = Files.newBufferedWriter( - Path.of(outputName), StandardOpenOption.CREATE_NEW, StandardOpenOption.WRITE)) { - out.write("query\thits_at_" + k + "\tids_best_first\tscores_best_first\n"); - for (int q = 0; q < results.length; q++) { - var r = results[q]; - out.write(q + "\t" + r.hits + "\t" + Arrays.toString(r.ids) + "\t" + Arrays.toString(r.scores) - + "\n"); - } - } - } - System.out.printf(Locale.ROOT, - "PQ_RECALL queries=%d baseVectors=%d subspaces=%d codeBytes=%d hits=%d denominator=%d recall_at_%d=%.9f%n", - queries.size(), vectors.count(), vectors.getCompressor().getSubspaceCount(), - vectors.getCompressor().compressedVectorSize(), hits, (long) k * queries.size(), k, - hits / ((double) k * queries.size())); - } - - /** Independent decoded-vector check; never used for ranking or reranking. */ - static void validate(PQVectors vectors, List> queries) { - var types = VectorizationProvider.getInstance().getVectorTypeSupport(); - var decoded = types.createFloatVector(queries.get(0).length()); - double maximum = 0; - for (int q = 0; q < Math.min(4, queries.size()); q++) { - var scorer = vectors.precomputedScoreFunctionFor(queries.get(q), VectorSimilarityFunction.DOT_PRODUCT); - for (int i = 0; i < Math.min(256, vectors.count()); i++) { - int id = (int) ((long) i * vectors.count() / Math.min(256, vectors.count())); - vectors.getCompressor().decode(vectors.get(id), decoded); - float expected = (1f + VectorUtil.dotProduct(queries.get(q), decoded)) / 2f; - float actual = scorer.similarityTo(id); - maximum = Math.max(maximum, Math.abs(expected - actual)); - if (!Float.isFinite(expected) || !Float.isFinite(actual) - || Math.abs(expected - actual) > 3e-5 + 3e-5 * Math.abs(expected)) { - throw new AssertionError("PQ lookup/reconstruction mismatch at query=" + q + " ordinal=" + id); - } - } - } - System.out.printf(Locale.ROOT, "PQ decoded-vector validation maxAbsError=%.9g%n", maximum); - } - - static final class Result { - final int[] ids; - final float[] scores; - final int hits; - Result(int[] ids, float[] scores, int hits) { - this.ids = ids; - this.scores = scores; - this.hits = hits; - } - } - - /** Keep precisely k results; input ordinals remain in their original base order. */ - static Result rank(ScoreFunction scorer, int count, List truth, int k) { - if (k < 1 || k > count) { - throw new IllegalArgumentException("Recall k must be in [1, base count]"); - } - Comparator worst = (a, b) -> { - int compare = Float.compare(Float.intBitsToFloat((int) a[0]), Float.intBitsToFloat((int) b[0])); - return compare != 0 ? compare : Long.compare(b[1], a[1]); - }; - var heap = new PriorityQueue(k, worst); - for (int id = 0; id < count; id++) { - float score = scorer.similarityTo(id); - if (!Float.isFinite(score)) { - throw new IllegalStateException("Non-finite PQ score at ordinal " + id); - } - if (heap.size() < k) { - heap.add(new long[] {Float.floatToRawIntBits(score), id}); - } else { - var bottom = heap.peek(); - float threshold = Float.intBitsToFloat((int) bottom[0]); - if (score > threshold || (score == threshold && id < bottom[1])) { - heap.poll(); - heap.add(new long[] {Float.floatToRawIntBits(score), id}); - } - } - } - int[] ids = new int[k]; - float[] scores = new float[k]; - int hits = 0; - for (int rank = k - 1; rank >= 0; rank--) { - var candidate = heap.remove(); - ids[rank] = (int) candidate[1]; - scores[rank] = Float.intBitsToFloat((int) candidate[0]); - if (truth.contains(ids[rank])) { - hits++; - } - } - return new Result(ids, scores, hits); - } - - private static String queryHash(List> queries) { - try { - var digest = MessageDigest.getInstance("SHA-256"); - var buffer = ByteBuffer.allocate(8192); - for (var query : queries) { - for (int d = 0; d < query.length(); d++) { - if (buffer.remaining() < Float.BYTES) { - digest.update(buffer.array(), 0, buffer.position()); - buffer.clear(); - } - buffer.putFloat(query.get(d)); - } - } - digest.update(buffer.array(), 0, buffer.position()); - var text = new StringBuilder(); - for (byte b : digest.digest()) { - text.append(Character.forDigit((b >>> 4) & 15, 16)).append(Character.forDigit(b & 15, 16)); - } - return text.toString(); - } catch (java.security.NoSuchAlgorithmException impossible) { - throw new AssertionError(impossible); - } - } -} diff --git a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/TestAshRunWithoutTraining.java b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/TestAshRunWithoutTraining.java deleted file mode 100644 index 68a6ab339..000000000 --- a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/TestAshRunWithoutTraining.java +++ /dev/null @@ -1,63 +0,0 @@ -/* - * 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; - -import io.github.jbellis.jvector.quantization.AsymmetricHashing; -import org.apache.commons.math3.linear.MatrixUtils; -import org.apache.commons.math3.linear.RealMatrix; - -import java.util.Random; - -public class TestAshRunWithoutTraining { - - public static void main(String[] args) { - int originalDim = 1024; - int quantizedDim = 128; - - Random rng = new Random(42); - - // Method under test - AsymmetricHashing.StiefelTransform stiefelTransform = - AsymmetricHashing.runWithoutTraining(originalDim, quantizedDim, rng); - - // Shapes - System.out.println("A shape = " + - stiefelTransform.rows + " x " + stiefelTransform.cols); - System.out.println("W shape = " + - stiefelTransform.W.getRowDimension() + " x " + - stiefelTransform.W.getColumnDimension()); - - // Reconstruct A as a RealMatrix *for testing only* - RealMatrix A = - MatrixUtils.createRealMatrix(stiefelTransform.AData); - - RealMatrix W = stiefelTransform.W; - - // Sanity check: a few entries of A - System.out.println("First few entries of A:"); - for (int i = 0; i < Math.min(3, A.getRowDimension()); i++) { - for (int j = 0; j < Math.min(3, A.getColumnDimension()); j++) { - System.out.printf("%.4f ", A.getEntry(i, j)); - } - System.out.println(); - } - - // Check orthogonality: WᵀW ≈ I - RealMatrix I = W.transpose().multiply(W); - System.out.printf("I(0,0)=%.5f, I(0,1)=%.5f%n", - I.getEntry(0, 0), I.getEntry(0, 1)); - } -} diff --git a/jvector-tests/src/test/java/io/github/jbellis/jvector/example/TestPQRecallEvaluator.java b/jvector-tests/src/test/java/io/github/jbellis/jvector/example/TestPQRecallEvaluator.java deleted file mode 100644 index cfa51b1f4..000000000 --- a/jvector-tests/src/test/java/io/github/jbellis/jvector/example/TestPQRecallEvaluator.java +++ /dev/null @@ -1,156 +0,0 @@ -/* - * 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; - -import io.github.jbellis.jvector.disk.SimpleMappedReader; -import io.github.jbellis.jvector.graph.ListRandomAccessVectorValues; -import io.github.jbellis.jvector.graph.similarity.ScoreFunction; -import io.github.jbellis.jvector.quantization.PQVectors; -import io.github.jbellis.jvector.quantization.ProductQuantization; -import io.github.jbellis.jvector.vector.VectorSimilarityFunction; -import io.github.jbellis.jvector.vector.VectorUtil; -import io.github.jbellis.jvector.vector.VectorizationProvider; -import io.github.jbellis.jvector.vector.types.VectorFloat; -import java.nio.file.Files; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.List; -import java.util.Random; -import java.util.concurrent.ForkJoinPool; -import org.junit.Test; -import static org.junit.Assert.*; - -public class TestPQRecallEvaluator { - @Test - public void resolvesExactStoredByteBudgetsAndPreservesLegacyFactor() { - String exact = System.getProperty("jvector.pq.subspaces"); - String factor = System.getProperty("jvector.pq.mFactor"); - try { - System.clearProperty("jvector.pq.subspaces"); - System.clearProperty("jvector.pq.mFactor"); - assertEquals(96, DistancesPQ.subspaceCount(768)); - System.setProperty("jvector.pq.mFactor", "4"); - assertEquals(192, DistancesPQ.subspaceCount(768)); - assertThrows(IllegalArgumentException.class, () -> DistancesPQ.subspaceCount(767)); - System.clearProperty("jvector.pq.mFactor"); - for (int dimension : new int[] {768, 1024, 1536, 3072}) { - for (int bits : new int[] {2, 4}) { - int bytes = 5 + (dimension / 2) * bits / 8; - System.setProperty("jvector.pq.subspaces", Integer.toString(bytes)); - assertEquals(bytes, DistancesPQ.subspaceCount(dimension)); - } - } - for (String bad : new String[] {"0", "-1", "769", "bad"}) { - System.setProperty("jvector.pq.subspaces", bad); - assertThrows(IllegalArgumentException.class, () -> DistancesPQ.subspaceCount(768)); - } - System.setProperty("jvector.pq.subspaces", "101"); - System.setProperty("jvector.pq.mFactor", "8"); - assertThrows(IllegalArgumentException.class, () -> DistancesPQ.subspaceCount(768)); - } finally { - restore("jvector.pq.subspaces", exact); - restore("jvector.pq.mFactor", factor); - } - } - - @Test - public void scansFinalOrdinalAndResolvesBoundaryTiesById() { - float[] scores = {.1f, .8f, .9f, .8f, .8f, .2f, 1f}; - ScoreFunction.ApproximateScoreFunction scorer = i -> scores[i]; - var result = PQRecallEvaluator.rank(scorer, scores.length, List.of(6, 3, 1), 3); - assertArrayEquals(new int[] {6, 2, 1}, result.ids); - assertArrayEquals(new float[] {1f, .9f, .8f}, result.scores, 0f); - assertEquals(2, result.hits); - scores[6] = Float.NaN; - assertThrows(IllegalStateException.class, () -> PQRecallEvaluator.rank(scorer, scores.length, List.of(1), 1)); - } - - @Test - public void unevenPqRoundTripPreservesScoresAndPerQueryRankings() throws Exception { - var types = VectorizationProvider.getInstance().getVectorTypeSupport(); - List> base = new ArrayList<>(); - var random = new Random(1307); - for (int i = 0; i < 96; i++) { - float[] values = new float[10]; - for (int d = 0; d < values.length; d++) - values[d] = random.nextFloat() - .5f; - var vector = types.createFloatVector(values); - VectorUtil.l2normalize(vector); - base.add(vector); - } - var executor = new ForkJoinPool(2); - var folder = Files.createTempDirectory("pq-full-recall-"); - var artifact = folder.resolve("vectors.pqv"); - var rankings = folder.resolve("rankings.tsv"); - String oldArtifact = System.getProperty("jvector.bench.pq.artifact"); - String oldOutput = System.getProperty("jvector.bench.recall.output"); - try { - var ravv = new ListRandomAccessVectorValues(base, 10); - var pq = ProductQuantization.compute(ravv, 3, 16, false, -1f, executor, executor); - var encoded = pq.encodeAll(ravv, executor); - assertEquals(3, pq.compressedVectorSize()); - var queries = base.subList(0, 3); - PQRecallEvaluator.validate(encoded, queries); - List> truth = new ArrayList<>(); - for (var query : queries) { - var scorer = encoded.precomputedScoreFunctionFor(query, VectorSimilarityFunction.DOT_PRODUCT); - Integer[] all = new Integer[base.size()]; - Arrays.setAll(all, i -> i); - Arrays.sort(all, (a, b) -> { - int c = Float.compare(scorer.similarityTo(b), scorer.similarityTo(a)); - return c != 0 ? c : Integer.compare(a, b); - }); - var top = Arrays.asList(Arrays.copyOf(all, 10)); - truth.add(top); - var result = PQRecallEvaluator.rank(scorer, base.size(), top, 10); - assertArrayEquals(top.stream().mapToInt(Integer::intValue).toArray(), result.ids); - assertEquals(10, result.hits); - } - System.setProperty("jvector.bench.pq.artifact", artifact.toString()); - System.setProperty("jvector.bench.recall.output", rankings.toString()); - PQRecallEvaluator.run(encoded, queries, truth, 10, executor); - var rows = Files.readAllLines(rankings); - assertEquals(4, rows.size()); - for (int q = 0; q < queries.size(); q++) - assertTrue(rows.get(q + 1).startsWith(q + "\t10\t")); - try (var supplier = new SimpleMappedReader.Supplier(artifact); var reader = supplier.get()) { - var loaded = PQVectors.load(reader); - assertEquals(encoded.count(), loaded.count()); - for (var query : queries) { - var original = encoded.precomputedScoreFunctionFor(query, VectorSimilarityFunction.DOT_PRODUCT); - var restored = loaded.precomputedScoreFunctionFor(query, VectorSimilarityFunction.DOT_PRODUCT); - for (int i = 0; i < base.size(); i++) { - assertEquals(original.similarityTo(i), restored.similarityTo(i), 0f); - } - } - } - } finally { - executor.shutdown(); - restore("jvector.bench.pq.artifact", oldArtifact); - restore("jvector.bench.recall.output", oldOutput); - Files.deleteIfExists(artifact); - Files.deleteIfExists(rankings); - Files.deleteIfExists(folder); - } - } - - private static void restore(String key, String value) { - if (value == null) - System.clearProperty(key); - else - System.setProperty(key, value); - } -} diff --git a/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/BenchASHKernels.java b/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/BenchASHKernels.java deleted file mode 100644 index 842fe799e..000000000 --- a/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/BenchASHKernels.java +++ /dev/null @@ -1,130 +0,0 @@ -/* - * 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 io.github.jbellis.jvector.vector.VectorUtilSupport; -import io.github.jbellis.jvector.vector.VectorizationProvider; -import org.openjdk.jmh.annotations.*; -import java.util.Random; -import java.util.concurrent.TimeUnit; - -/** - * Kernel-only comparison: identical codes/query, 32 scores per invocation, no training - * or query preparation in timed work. Both paths traverse many blocks in permuted order. - * Each representation is at least workingSetMiB; choose a size larger than the machine's - * last-level cache for a cold-data experiment. Smaller sets measure cache-resident work. - * - *

    Use JMH forks (never -f 0): tuning is fixed when the backend is initialized. - * The default is one worker; -t N measures aggregate throughput over shared read-only - * codes with independent worker cursors and output buffers.

    - */ -@State(Scope.Benchmark) -@BenchmarkMode(Mode.Throughput) -@OutputTimeUnit(TimeUnit.SECONDS) -@Warmup(iterations = 3, time = 1) -@Measurement(iterations = 5, time = 1) -@Fork(value = 2, jvmArgsAppend = {"--add-modules=jdk.incubator.vector", "--enable-native-access=ALL-UNNAMED", "-Xmx8g"}) -@Threads(1) -public class BenchASHKernels { - @Param({"64", "384", "768"}) public int dimensions; - @Param({"2", "4"}) public int bits; - @Param({"1", "2", "4", "8"}) public int accumulators; - @Param({"1", "2", "4"}) public int unroll; - @Param({"1024"}) public int workingSetMiB; - - private VectorUtilSupport backend; - private byte[][] canonical; - private byte[][] packed; - private float[] query, lut; - private int groups; - private boolean tunedProjection; - - @State(Scope.Thread) - public static class Worker { - int cursor; - final float[] scores = new float[32]; - - @Setup(Level.Trial) - public void setup(org.openjdk.jmh.infra.ThreadParams thread) { - cursor = thread.getThreadIndex() * 15485863; - } - } - - @Setup(Level.Trial) - public void setup() { - System.setProperty("jvector.ash.lut.accumulators", Integer.toString(accumulators)); - System.setProperty("jvector.ash.lut.unroll", Integer.toString(unroll)); - System.setProperty("jvector.ash.projection.accumulators", Integer.toString(accumulators)); - System.setProperty("jvector.ash.projection.unroll", Integer.toString(unroll)); - backend = VectorizationProvider.getInstance().getVectorUtilSupport(); - tunedProjection = backend.usesAshProjectionTuning(); - if (!backend.supportsAshLutScoring() || !backend.supportsAshProjectionScoring()) { - throw new IllegalStateException("This benchmark requires the Panama ASH SIMD backend"); - } - if (!backend.ashKernelDescription().contains("LUT accumulators=" + accumulators + ", unroll=" + unroll)) { - throw new IllegalStateException("Tuning already initialized: run each parameter combination in its own JMH fork"); - } - if (workingSetMiB <= 0) throw new IllegalArgumentException("workingSetMiB must be positive"); - groups = FusedASHLayout.codeGroups(dimensions, bits); - int bytes = FusedASHLayout.canonicalCodeBytes(dimensions, bits); - long target = (long) workingSetMiB * 1024 * 1024; - int blocks = 1; - while ((long) blocks * bytes * 32 < target) blocks = Math.multiplyExact(blocks, 2); - canonical = new byte[Math.multiplyExact(blocks, 32)][bytes]; - packed = new byte[blocks][Math.multiplyExact(bytes, 32)]; - Random random = new Random(89712); - for (int b = 0; b < blocks; b++) { - for (int lane = 0; lane < 32; lane++) { - byte[] code = canonical[b * 32 + lane]; - random.nextBytes(code); - for (int i = 0; i < bytes; i++) packed[b][i * 32 + lane] = code[i]; - } - } - query = new float[dimensions]; - for (int i = 0; i < dimensions; i++) query[i] = random.nextFloat() - 0.5f; - lut = new float[Math.multiplyExact(groups, 16)]; - FusedASHLayout.buildQueryLut(query, dimensions, bits, lut); - System.out.println(backend.ashKernelDescription() + "; code bytes per representation=" + (long) blocks * bytes * 32); - } - - private int nextBlock(Worker worker) { - worker.cursor = (worker.cursor + 104729) & (packed.length - 1); - return worker.cursor; - } - - @Benchmark - @OperationsPerInvocation(32) - public float single(Worker worker) { - int base = nextBlock(worker) * 32; - float sum = 0; - for (int i = 0; i < 32; i++) { - sum += tunedProjection - ? backend.ashProjectionDotTuned(query, canonical[base + i], dimensions, bits) - : backend.ashProjectionDot(query, canonical[base + i], dimensions, bits); - } - return sum; - } - - @Benchmark - @OperationsPerInvocation(32) - public float block(Worker worker) { - backend.ashLutScore(packed[nextBlock(worker)], 0, groups, 32, 0, 32, lut, worker.scores, 0); - float sum = 0; - for (float score : worker.scores) sum += score; - return sum; - } -} diff --git a/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestASHKernelTuning.java b/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestASHKernelTuning.java deleted file mode 100644 index fb6e36681..000000000 --- a/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestASHKernelTuning.java +++ /dev/null @@ -1,101 +0,0 @@ -/* - * 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 io.github.jbellis.jvector.vector.VectorizationProvider; -import org.junit.Test; -import org.junit.Assume; -import java.nio.charset.StandardCharsets; -import java.nio.file.Path; -import java.util.ArrayList; -import java.util.List; -import java.util.concurrent.TimeUnit; -import static org.junit.Assert.*; - -/** Opt-in forked-JVM matrix, because tuning constants are initialized once per JVM. */ -public class TestASHKernelTuning { - @Test - public void allSettingsAndWidthsMatchScalarOracle() throws Exception { - Assume.assumeTrue(Boolean.getBoolean("jvector.test.ashTuningMatrix")); - Assume.assumeTrue(VectorizationProvider.getInstance().getVectorUtilSupport().supportsAshLutScoring()); - String architecture = System.getProperty("os.arch"); - int[] widths = architecture.equals("amd64") || architecture.equals("x86_64") - ? new int[]{16, 32, 64} : new int[]{0}; - for (int width : widths) { - for (int accumulators : new int[]{1, 2, 4, 8}) { - for (int unroll : new int[]{1, 2, 4}) { - var command = javaCommand(); - if (width != 0) command.add("-XX:MaxVectorSize=" + width); - command.add("-Djvector.test.requireAshSimd=true"); - for (String kernel : new String[]{"lut", "projection"}) { - command.add("-Djvector.ash." + kernel + ".accumulators=" + accumulators); - command.add("-Djvector.ash." + kernel + ".unroll=" + unroll); - } - command.add("org.junit.runner.JUnitCore"); - command.add(TestASHLutScoring.class.getName()); - command.add(TestASHScoringDispatch.class.getName()); - run(command, true, "width=" + width + ", accumulators=" + accumulators + ", unroll=" + unroll); - } - } - } - } - - @Test - public void invalidTuningFailsExplicitly() throws Exception { - Assume.assumeTrue(Boolean.getBoolean("jvector.test.ashTuningMatrix")); - Assume.assumeTrue(VectorizationProvider.getInstance().getVectorUtilSupport().supportsAshLutScoring()); - for (String name : new String[]{"lut.accumulators", "lut.unroll", "projection.accumulators", "projection.unroll"}) { - for (String invalid : new String[]{"0", "3", "16", "invalid"}) { - var command = javaCommand(); - command.add("-Djvector.ash." + name + "=" + invalid); - command.add(Probe.class.getName()); - run(command, false, "jvector.ash." + name); - } - } - } - - private static List javaCommand() { - return new ArrayList<>(List.of(Path.of(System.getProperty("java.home"), "bin", "java").toString(), - "-ea", "-Xmx256m", "--add-modules=jdk.incubator.vector", "--enable-native-access=ALL-UNNAMED", - "-cp", System.getProperty("java.class.path"))); - } - - private static void run(List command, boolean success, String label) throws Exception { - // Child output is small (JUnit summary and backend diagnostics); a temporary file - // prevents a full pipe from blocking the child while the parent waits with a timeout. - var output = java.nio.file.Files.createTempFile("ash-tuning-test-", ".log"); - try { - Process process = new ProcessBuilder(command).redirectErrorStream(true).redirectOutput(output.toFile()).start(); - if (!process.waitFor(60, TimeUnit.SECONDS)) { - process.destroyForcibly(); - fail("Timed out: " + label); - } - String text = java.nio.file.Files.readString(output, StandardCharsets.UTF_8); - if (success) assertEquals(label + "\n" + text, 0, process.exitValue()); - else { - assertNotEquals(label, 0, process.exitValue()); - assertTrue(text, text.contains(label)); - } - } finally { java.nio.file.Files.deleteIfExists(output); } - } - - public static class Probe { - public static void main(String[] ignored) { - System.out.println(VectorizationProvider.getInstance().getVectorUtilSupport().ashKernelDescription()); - } - } -} From 482117902669da80eb6d93c6778793fec60b8161 Mon Sep 17 00:00:00 2001 From: Ted Willke Date: Mon, 9 Nov 2026 15:47:30 -0800 Subject: [PATCH 178/243] Make the ASH vs PQ study runner portable The runner now takes its CPU and NUMA defaults from lscpu on the machine it runs on: construction on every physical core across all nodes, 1- and 8-core queries on one node. Repetition defaults match the published protocol of two builds and one query JVM per core count. The run configs no longer name a branch; the recorded source commit identifies each run. --- jvector-examples/run-ash-pq-benchmark.sh | 2 - jvector-examples/run-ash-pq-study.py | 53 ++++++++++++++++--- .../yaml-configs/ash-pq/run-construction.yml | 1 - .../yaml-configs/ash-pq/run-direct.yml | 1 - .../yaml-configs/ash-pq/run-queued.yml | 1 - .../yaml-configs/ash-pq/run-single-jvm.yml | 1 - 6 files changed, 45 insertions(+), 14 deletions(-) diff --git a/jvector-examples/run-ash-pq-benchmark.sh b/jvector-examples/run-ash-pq-benchmark.sh index 8ebb3cc0e..6cd9a977f 100755 --- a/jvector-examples/run-ash-pq-benchmark.sh +++ b/jvector-examples/run-ash-pq-benchmark.sh @@ -123,8 +123,6 @@ CASE_CONFIG="$CASE_DIRECTORY/jvector-examples/yaml-configs" mkdir -p "$CASE_CONFIG/dataset-catalogs" cp "$REPO_ROOT/jvector-examples/yaml-configs/dataset-metadata.yml" "$CASE_CONFIG/" cp "$REPO_ROOT/jvector-examples/yaml-configs/datasets.yml" "$CASE_CONFIG/" -# Remove only the catalog name created by the original version of this recipe. -rm -f "$CASE_CONFIG/dataset-catalogs/ash-pq-1m.yml" cp "$CATALOG" "$CASE_CONFIG/dataset-catalogs/ash-pq-local.yml" printf '%s\n' "$CASE_ID" > "$CASE_DIRECTORY/case-id.txt" mkdir -p "$CASE_DIRECTORY/runs" diff --git a/jvector-examples/run-ash-pq-study.py b/jvector-examples/run-ash-pq-study.py index 5203c4a98..b1fcbc850 100755 --- a/jvector-examples/run-ash-pq-study.py +++ b/jvector-examples/run-ash-pq-study.py @@ -28,17 +28,52 @@ import subprocess import time + + +def physical_cores(): + """One online hardware thread per physical core, in CPU order, as (cpu, node) pairs.""" + seen, cores = set(), [] + for line in subprocess.check_output(["lscpu", "-p=CPU,CORE,SOCKET,NODE"], text=True).splitlines(): + if line.startswith("#"): + continue + cpu, core, socket, node = (field or "0" for field in line.split(",")) + if (socket, core) not in seen: + seen.add((socket, core)) + cores.append((int(cpu), int(node))) + return cores + + +def cpu_ranges(cpus): + """Format CPU numbers as a numactl list, for example 0-7,48.""" + ranges, cpus = [], sorted(cpus) + start = end = cpus[0] + for cpu in cpus[1:] + [None]: + if cpu is not None and cpu == end + 1: + end = cpu + continue + ranges.append(str(start) if start == end else f"{start}-{end}") + if cpu is not None: + start = end = cpu + return ",".join(ranges) + + +# Defaults follow the published protocol on whatever machine runs it: construction on +# one thread of every physical core with memory interleaved across all NUMA nodes, and +# 1- and 8-core queries on the first physical cores of one node, with memory on that node. +cores = physical_cores() +query_node = cores[0][1] +query_cpus = [cpu for cpu, node in cores if node == query_node] parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--output", type=Path, required=True) parser.add_argument("--data", type=Path, required=True) -parser.add_argument("--build-cores", type=int, default=48) -parser.add_argument("--build-cpus", default="0-47") -parser.add_argument("--build-nodes", default="0,1") -parser.add_argument("--one-core-cpu", default="0") -parser.add_argument("--eight-core-cpus", default="0-7") -parser.add_argument("--query-node", default="0") -parser.add_argument("--build-repetitions", type=int, default=5) -parser.add_argument("--query-repetitions", type=int, default=3) +parser.add_argument("--build-cores", type=int, default=len(cores)) +parser.add_argument("--build-cpus", default=cpu_ranges([cpu for cpu, _ in cores])) +parser.add_argument("--build-nodes", default=",".join(str(n) for n in sorted({node for _, node in cores}))) +parser.add_argument("--one-core-cpu", default=str(query_cpus[0])) +parser.add_argument("--eight-core-cpus", default=cpu_ranges(query_cpus[:8]) if len(query_cpus) >= 8 else None) +parser.add_argument("--query-node", default=str(query_node)) +parser.add_argument("--build-repetitions", type=int, default=2) +parser.add_argument("--query-repetitions", type=int, default=1) parser.add_argument("--dataset-set", choices=("1m", "large"), default="1m") parser.add_argument("--algorithms", nargs="+", choices=("pq", "ash"), default=["pq", "ash"]) parser.add_argument("--query-modes", nargs="+", choices=("direct", "queued"), default=["direct", "queued"]) @@ -46,6 +81,8 @@ args = parser.parse_args() if min(args.build_cores, args.build_repetitions, args.query_repetitions) <= 0: parser.error("Core and repetition counts must be positive") +if args.eight_core_cpus is None: + parser.error("NUMA node %s has fewer than 8 physical cores; pass --eight-core-cpus and --query-node" % args.query_node) repo = Path(__file__).resolve().parent.parent helper = repo / "jvector-examples/run-ash-pq-benchmark.sh" source_ref = subprocess.check_output(["git", "rev-parse", "HEAD"], cwd=repo, text=True).strip() diff --git a/jvector-examples/yaml-configs/ash-pq/run-construction.yml b/jvector-examples/yaml-configs/ash-pq/run-construction.yml index 52f5f83c2..661177fc3 100644 --- a/jvector-examples/yaml-configs/ash-pq/run-construction.yml +++ b/jvector-examples/yaml-configs/ash-pq/run-construction.yml @@ -10,7 +10,6 @@ console: construction: [index_build_time_s, index_quant_time_s, search_quant_time_s] logging: dir: logging - jvectorRef: ash-dev runId: ash-pq-construction-{ts} type: csv benchmarks: diff --git a/jvector-examples/yaml-configs/ash-pq/run-direct.yml b/jvector-examples/yaml-configs/ash-pq/run-direct.yml index dd5c09aa0..1b5ef1ba1 100644 --- a/jvector-examples/yaml-configs/ash-pq/run-direct.yml +++ b/jvector-examples/yaml-configs/ash-pq/run-direct.yml @@ -14,7 +14,6 @@ console: count: [visited] logging: dir: logging - jvectorRef: ash-dev runId: ash-pq-direct-{ts} type: csv benchmarks: diff --git a/jvector-examples/yaml-configs/ash-pq/run-queued.yml b/jvector-examples/yaml-configs/ash-pq/run-queued.yml index d22eccc68..03d0e4319 100644 --- a/jvector-examples/yaml-configs/ash-pq/run-queued.yml +++ b/jvector-examples/yaml-configs/ash-pq/run-queued.yml @@ -17,7 +17,6 @@ console: execution: [response_mean_ms, response_p95_ms, response_p99_ms, mean_batch_size] logging: dir: logging - jvectorRef: ash-dev runId: ash-pq-queued-{ts} type: csv benchmarks: diff --git a/jvector-examples/yaml-configs/ash-pq/run-single-jvm.yml b/jvector-examples/yaml-configs/ash-pq/run-single-jvm.yml index fd8083962..e61168737 100644 --- a/jvector-examples/yaml-configs/ash-pq/run-single-jvm.yml +++ b/jvector-examples/yaml-configs/ash-pq/run-single-jvm.yml @@ -19,7 +19,6 @@ console: execution: [response_mean_ms, response_p95_ms, response_p99_ms, mean_batch_size] logging: dir: logging - jvectorRef: ash-dev runId: ash-pq-single-jvm-{ts} type: csv benchmarks: From e994c723eb6b63d2f63e17d1ddf40c0d9b287c53 Mon Sep 17 00:00:00 2001 From: Ted Willke Date: Mon, 9 Nov 2026 15:49:06 -0800 Subject: [PATCH 179/243] Summarize the ASH vs PQ results and make reproduction portable The study guide now opens with the final 6M and 10M results and a generic description of the measurement machine, then covers inputs, the study runner, single cases and measurements for any host. Run-history and correction notes are removed. --- docs/ash-pq-benchmark.md | 481 +++++++++++++++------------------------ 1 file changed, 189 insertions(+), 292 deletions(-) diff --git a/docs/ash-pq-benchmark.md b/docs/ash-pq-benchmark.md index 3733013a2..1ebb93419 100644 --- a/docs/ash-pq-benchmark.md +++ b/docs/ash-pq-benchmark.md @@ -14,41 +14,91 @@ See the License for the specific language governing permissions and limitations under the License. --> -# Reproducing fused PQ versus fused ASH with BenchYAML - -This comparison uses symmetric ASH construction. Use fresh graph caches -when comparing construction modes. The [BenchYAML Guide](benchyaml-guide.md) -describes the index configuration. See -[ASH configuration and defaults](ash-configuration.md) for model and runtime -settings. - -This comparison uses the branch's ordinary packaged JVector code and -`io.github.jbellis.jvector.example.BenchYAML`. The fixed [index configurations](../jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq) -have their own directory. The [run configurations and local dataset catalogs](../jvector-examples/yaml-configs/ash-pq) -are grouped separately. -No separate query harness, source overlay, profiling agent, manually linked -library, or dataset-specific projection setting is required. - -Projection settings and processor recommendations are documented in -[Choosing ASH query-projection precision](ash-projection-precision.md). -An explicitly requested INT8/FP32 comparison is a precision override to the -FP16 benchmark configuration below; record it with the command and CSV evidence. -Set `queryProjection` in the ASH compression parameters. The -[BenchYAML Guide](benchyaml-guide.md#ash-query-parameters) lists the values and -JVM precedence. ASH CSV rows record `ash_query_projection_requested` and -`ash_query_projection_effective`. The precision guide explains fallback labels. +# ASH versus PQ: results and how to reproduce them + +This study compares fused ASH and fused PQ graph indexes at matched code sizes: +both store the same bytes per vector, including ASH's five-byte header. It uses +ordinary packaged JVector code and `io.github.jbellis.jvector.example.BenchYAML`; +no separate query harness, source overlay or profiling agent is involved. + +The [index configurations](../jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq) +define each case. The [run configurations and dataset catalogs](../jvector-examples/yaml-configs/ash-pq) +define what is measured and where the input files are. Two scripts run them: +`run-ash-pq-benchmark.sh` runs one case in one JVM, and `run-ash-pq-study.py` +steps through a whole study. The [BenchYAML Guide](benchyaml-guide.md) describes +the index configuration, and [ASH configuration and defaults](ash-configuration.md) +describes model and runtime settings. + +## Results: 6M and 10M datasets + +ASH wins total construction in 6/6 configurations, recall in 12/12 cases and QPS +in 22/24 cases. The sum of all six construction times is 48.5% lower with ASH. + +**Total construction seconds** (lower is better) + +| Dataset | Compression | PQ | ASH | ASH change | +| --- | --- | ---: | ---: | ---: | +| MPNet 10M | 32x (b=2) | 343.7 | **238.1** | −30.7% | +| MPNet 10M | 16x (b=4) | 568.9 | **301.2** | −47.1% | +| Cohere 10M | 32x (b=2) | 478.0 | **263.3** | −44.9% | +| Cohere 10M | 16x (b=4) | 771.2 | **335.8** | −56.5% | +| CAP 6M | 32x (b=2) | 295.6 | **190.2** | −35.6% | +| CAP 6M | 16x (b=4) | 630.7 | **261.8** | −58.5% | +| **Sum** | | 3,088.1 | **1,590.5** | −48.5% | + +**Recall@10 and queries per second** (higher is better) + +| Dataset | Compression | Rerank | PQ recall | ASH recall | PQ QPS, 1 core | ASH QPS, 1 core | PQ QPS, 8 cores | ASH QPS, 8 cores | +| --- | --- | --- | ---: | ---: | ---: | ---: | ---: | ---: | +| MPNet 10M | 32x (b=2) | 1x | 0.54043 | **0.75532** | **9,349** | 9,012 | 77,609 | **81,421** | +| MPNet 10M | 32x (b=2) | 2x | 0.71512 | **0.88903** | **7,540** | 7,266 | 61,759 | **62,913** | +| MPNet 10M | 16x (b=4) | 1x | 0.72452 | **0.81330** | 7,224 | **9,860** | 63,221 | **76,619** | +| MPNet 10M | 16x (b=4) | 2x | 0.86195 | **0.90050** | 5,661 | **7,397** | 48,894 | **57,709** | +| Cohere 10M | 32x (b=2) | 1x | 0.52124 | **0.58682** | 7,642 | **9,067** | 62,520 | **72,500** | +| Cohere 10M | 32x (b=2) | 2x | 0.70611 | **0.76181** | 5,826 | **6,875** | 48,494 | **55,340** | +| Cohere 10M | 16x (b=4) | 1x | 0.59860 | **0.66033** | 6,145 | **8,339** | 50,152 | **65,660** | +| Cohere 10M | 16x (b=4) | 2x | 0.75846 | **0.78864** | 4,684 | **6,122** | 38,369 | **49,100** | +| CAP 6M | 32x (b=2) | 1x | 0.41240 | **0.72119** | 5,815 | **7,295** | 51,746 | **62,544** | +| CAP 6M | 32x (b=2) | 2x | 0.59705 | **0.88574** | 4,748 | **5,756** | 41,022 | **48,482** | +| CAP 6M | 16x (b=4) | 1x | 0.65664 | **0.80468** | 4,436 | **6,295** | 39,786 | **58,394** | +| CAP 6M | 16x (b=4) | 2x | 0.83966 | **0.90749** | 3,550 | **4,774** | 31,393 | **41,777** | + +Compression ratios are nominal, and b is ASH bits per projected dimension. +Rerank 1x and 2x rescore 10 and 20 candidates with NVQ. Each construction figure +is the mean of two fresh builds. QPS is the mean of three timed samples in one +query JVM per core count, run against the first build's index. + +Single-core QPS varies between graphs built from the same settings. On an +earlier MPNet 10M 32x graph, ASH measured 10,414 / 7,838 QPS on one core +(rerank 1x / 2x), above PQ. Repeated query runs on the reported graph averaged +about 9,200 / 7,400, below PQ. + +The 1M recipes (MPNet, Cohere, Ada002 and OpenAI-3072) use the same parameters +and protocol; their results are not part of this summary. + +### Measurement environment + +| | | +| --- | --- | +| CPU | One Intel Xeon 6985P-C socket: 48 physical cores, 2 threads per core, 504 MiB L3 | +| Memory | 354 GiB in two NUMA nodes of about 177 GiB | +| Storage | Local NVMe SSDs in RAID 10, XFS; the page cache was not dropped between runs | +| OS | Debian 12, Linux 6.1; transparent huge pages enabled=always, defrag=madvise | +| JVM | OpenJDK 22.0.2 (Temurin), `-Xms2g -Xmx128g`, default collector (G1, or Serial with one core) | +| Placement | Construction on one thread of each of the 48 cores, memory interleaved on both nodes. Queries on 1 or 8 cores of node 0, memory on node 0 | +| Native threads | BLAS and OpenMP thread counts equal the core count for construction and 1 for queries | + +Jobs ran one at a time with no other benchmark on the machine. PQ and ASH used +the same machine, JDK, placement, input files and graph parameters. ## Build the checkout -Use JDK 22 for performance measurements. Follow the repository's -[native prerequisites](../jvector-native/src/main/native/README.md), including -the compiler, Meson, Ninja and CMake. Native packaging fetches the pinned oneDNN -source and includes the statically linked ASH add-on by default. Reviewers need -the normal build dependencies and access to the input datasets. ASH training -also uses the installed BLAS/LAPACK provider; record that native provider with -the results, since a Java fallback does not reproduce native training throughput. - -From a clean checkout of the reported commit: +Use JDK 22 for performance measurements. Follow the +[native prerequisites](../jvector-native/src/main/native/README.md), including the +compiler, Meson, Ninja and CMake. Native packaging fetches the pinned oneDNN +source and includes the statically linked ASH add-on. ASH training also uses +the installed BLAS/LAPACK provider; record it with the results, since a Java +fallback does not reproduce native training throughput. ```bash export JAVA_HOME=/path/to/jdk-22 @@ -57,286 +107,133 @@ git submodule update --init --recursive mvn -B -pl jvector-examples -am -DskipTests package ``` -The standalone artifact is -`jvector-examples/target/jvector-examples--jar-with-dependencies.jar`. -Build once, before any benchmark process. Keep the checkout and JAR fixed -throughout a comparison. Native is explicitly required by the run command so a -failed native installation cannot silently become a scalar measurement. -Optimized FP16 execution additionally depends on the CPU's capabilities; the -portable FP32 fallback remains available on other systems. - -Relevant repository tests can be run independently: +This writes `jvector-examples/target/jvector-examples--jar-with-dependencies.jar`. +Build once, before any benchmark, and keep the checkout and JAR fixed throughout +a comparison. The scripts require a clean checkout so each run records an exact +commit. The run command requires the native provider, so a failed native +installation cannot silently become a scalar measurement. + +## Inputs and index parameters + +Obtain the normalized fvecs/ivecs files named in +[`catalog-1m.yml`](../jvector-examples/yaml-configs/ash-pq/catalog-1m.yml) and +[`catalog-large.yml`](../jvector-examples/yaml-configs/ash-pq/catalog-large.yml). +Cohere 1M, Ada002 1M and OpenAI-3072 1M are in the public dataset catalog +(see [dataset catalogs](../jvector-examples/yaml-configs/dataset-catalogs/datasets.md)); +the others must be obtained separately. Set `JVECTOR_BENCH_DATA_DIR` to the +absolute directory that holds them, keeping the relative file names in the +catalog. All use `DOT_PRODUCT` and `NO_SCRUB`. Use the exact base, query and +top-100 ground-truth files: a deduplicated base file without rebuilt ground +truth would change the experiment. + +| Dataset | Recipe prefix | Base vectors | Dimensions | Queries | Code bytes, b=2 / b=4 | +| --- | --- | ---: | ---: | ---: | ---: | +| MPNet 1M | `mpnet` | 999,812 | 768 | 10,000 | 101 / 197 | +| Cohere 1M | `cohere` | 1,000,000 | 1024 | 10,000 | 133 / 261 | +| Ada002 1M | `ada002` | 982,790 | 1536 | 10,000 | 197 / 389 | +| OpenAI-3072 1M | `openai3072` | 999,000 | 3072 | 1,000 | 389 / 773 | +| MPNet 10M | `mpnet10m` | 9,997,697 | 768 | 10,000 | 101 / 197 | +| Cohere 10M | `cohere10m` | 10,000,000 | 1024 | 10,000 | 133 / 261 | +| CAP 6M | `cap6m` | 6,070,488 | 1536 | 10,000 | 197 / 389 | + +ASH uses one landmark, an ITQ-trained transform to D/2 dimensions, and 2 or 4 +bits per projected dimension. PQ uses as many 8-bit subspaces as ASH's code +bytes, 256 centroids, no centering, and non-anisotropic training. The 2-bit and +4-bit labels describe the ASH cases, not PQ centroid precision. + +Both use an on-disk index (format 7) with fused graph features, NVQ reranking, +degree 32, `efConstruction=100`, neighbor overflow 1.2, hierarchy, final +refinement and the parallel writer, and return the top 10 at overquery 1 and 2. +ASH queries use queued execution with a maximum batch of 8 and automatic query +projection, which selects FP16 on supported CPUs. PQ queries use direct execution. + +## Run a study + +`run-ash-pq-study.py` runs every case of one dataset set in sequence, each in a +fresh JVM, and aggregates the CSV files. It contains no query loop or timer. ```bash -mvn -B -pl jvector-tests,jvector-examples -am test \ - -DskipSIMD=false -DskipScalar=true \ - '-Dtest=TestASH*,NativeASH*,TestPreparedASHQuery,TestBatchingExecutor,TestCallerBatchProcessor,TestQueuedWorkerLifetime,TestDeferredQueryReporting,QueryExecutionConfigTest,StandaloneASHBenchmarkConfigTest' \ - -Dsurefire.failIfNoSpecifiedTests=false +export JVECTOR_BENCH_DATA_DIR=/data/vectors +python3 jvector-examples/run-ash-pq-study.py --dataset-set large \ + --data "$JVECTOR_BENCH_DATA_DIR" --output /data/jvector-bench/ash-pq-large ``` -The production change is also tested on JDK 25. Those correctness runs are not -combined with the JDK 22 performance results. - -## Inputs and fixed index parameters - -Obtain the same normalized fvecs/ivecs inputs listed in -[`catalog-1m.yml`](../jvector-examples/yaml-configs/ash-pq/catalog-1m.yml). -Some datasets require separate access permission; the checkout contains neither -credentials nor private machine paths. Set `JVECTOR_BENCH_DATA_DIR` to their -absolute parent directory, preserving the relative file names in the catalog. -The four "1M" names describe the dataset families; the reporting artifacts -record the actual base/query counts, including the 1,000 OpenAI3072 queries. -All four use `DOT_PRODUCT` and `NO_SCRUB` from `dataset-metadata.yml`. - -| Dataset prefix | Input dimension | 2-bit ASH / matching PQ code bytes | 4-bit ASH / matching PQ code bytes | -| --- | ---: | ---: | ---: | -| `mpnet` | 768 | 101 | 197 | -| `cohere` | 1024 | 133 | 261 | -| `ada002` | 1536 | 197 | 389 | -| `openai3072` | 3072 | 389 | 773 | - -ASH uses one landmark, an ITQ-trained transform with D/2 output dimensions, and -2 or 4 bits per projected dimension. Its five-byte header is included in the -matched code budget. PQ uses that many 8-bit subspaces, 256 centroids, no -centering, and the default non-anisotropic training setting. The 2-bit/4-bit -labels describe the ASH cases, not PQ centroid precision. - -Native single scoring uses the existing canonical nibble-LUT kernel for b=2 -and the projection-code kernel for b=4. The b=2 LUT is prepared once per scorer, -with query-owned storage; it is not rebuilt for each candidate. Explicit native -selection rejects a missing required single-scoring symbol rather than silently -using the Java projection-code fallback. Fused neighborhood scoring and the -queued query batcher keep their existing native paths. - -Both algorithms use fused graph features, NVQ reranking, degree 32, -`efConstruction=100`, neighbor overflow 1.2, hierarchy and final refinement, -and top 10 at overquery 1 and 2. Native/oneDNN selection uses production defaults: -FP16 for supported ASH query projection, FP32 for encoding and training, and -Highway scoring. The deferred complete-record writer is enabled for both. +`--dataset-set 1m` (the default) runs the four 1M datasets. By default, each +configuration is built twice, in separate case directories, and one query JVM per +core count reuses the first build's index; PQ and ASH alternate order. CPU and +memory placement come from `lscpu` on the host: construction uses one thread on +every physical core with memory interleaved across all NUMA nodes, and queries +use the first 1 and 8 physical cores of one node with memory on that node. +`--help` lists the options for other placements and repetition counts, and +`--plan-only` prints the job list without running it. + +`plan.json` fixes the commit, JAR checksum and settings. `status.json`, +`results.json` and `summary.json` are updated after each invocation, and an +interrupted study resumes only with the same source and JAR. A process watchdog +(3 hours for large construction, 30 minutes otherwise) detects a hung +invocation; its wall clock is never reported as a measurement. Extra +construction repetitions release their caches after recording results. ## Run one case -The checked-in shell recipe only prepares a case directory, records the command -and invokes standalone BenchYAML. It contains no query loop or timing logic. -It checks that the CPU list contains the requested number of distinct online -physical cores, so two SMT siblings cannot accidentally count as two cores. -CPU counts are sizing hints; `numactl` enforces the actual allocation. +`run-ash-pq-benchmark.sh` prepares a case directory, records the command and runs +standalone BenchYAML. It checks that the CPU list names the requested number of +distinct online physical cores, so two SMT siblings cannot count as two cores. ```bash -export JVECTOR_BENCH_DATA_DIR=/data/normalized-vector-datasets +export JVECTOR_BENCH_DATA_DIR=/data/vectors export JVECTOR_BENCH_HEAP=128g BENCH_CASE=/data/jvector-bench/mpnet-2bit-ash-r1 -# Choose these CPU/node lists from lscpu on the actual host. -# The reference construction budget is 48 physical cores across two NUMA nodes. +# Construction: every physical core, memory on all nodes. Pick lists from lscpu. JVECTOR_BENCH_NUMA_POLICY=interleave:0,1 \ bash jvector-examples/run-ash-pq-benchmark.sh \ mpnet-2bit-ash.yml construction 48 0-47 "$BENCH_CASE" -# Reuse that model and graph. Each invocation is a fresh JVM. -bash jvector-examples/run-ash-pq-benchmark.sh \ - mpnet-2bit-ash.yml queued 1 0 "$BENCH_CASE" -bash jvector-examples/run-ash-pq-benchmark.sh \ - mpnet-2bit-ash.yml queued 8 0-7 "$BENCH_CASE" +# Queries reuse that model and graph, each in a fresh JVM. +bash jvector-examples/run-ash-pq-benchmark.sh mpnet-2bit-ash.yml queued 1 0 "$BENCH_CASE" +bash jvector-examples/run-ash-pq-benchmark.sh mpnet-2bit-ash.yml queued 8 0-7 "$BENCH_CASE" ``` -Query memory placement defaults to NUMA node 0. Adapt the physical CPU and -memory-node lists together on other hosts. Do not run cases concurrently or -share their CPU allocation with another experiment. The heap must fit the -machine; report any heap, CPU, NUMA or library differences from the reference. - -For the matching PQ case, replace `ash` with `pq`, use `direct` for the query -report selection, and use a separate case directory. Each index YAML selects -its delivery policy. The run YAML selects the metrics and reporting. Repeat with each prefix and bit count in the table. Construction refuses -nonempty model/index caches; query invocations require caches and reject a run -that rebuilt an index. These recipes set `useSavedIndexIfExists: Yes` so the -graph survives its construction JVM; most index parameter files leave it off -because saved indexes consume disk space. No existing indexes are automatically -deleted. - -For the full report, collect five fresh construction JVMs per case in separate -directories. Retain the first graph for queries, without choosing the best build. -Run three fresh query JVMs per algorithm and core budget against that first -graph. Alternate PQ and ASH order across repetitions. That is 80 builds and 96 -query JVMs for the four 1M datasets, with both overquery levels per query JVM. - -The optional checked-in study driver launches exactly these standalone invocations -sequentially and aggregates their CSV files. It has no query loop or performance -timer. For a host with the reference CPU/node numbering: - -```bash -python3 jvector-examples/run-ash-pq-study.py \ - --data "$JVECTOR_BENCH_DATA_DIR" --output /data/jvector-bench/full-study -``` +Query memory defaults to NUMA node 0 (`JVECTOR_BENCH_NUMA_POLICY=bind:0`). For the +matching PQ case, replace `ash` with `pq`, use `direct` instead of `queued`, and +use a separate case directory. For the larger datasets, set +`JVECTOR_BENCH_CATALOG=catalog-large.yml` for every invocation of the case. -To collect only the production ASH query batcher, without collecting PQ or -synchronous ASH controls, select `--algorithms ash --query-modes queued`. -A quick complete four-dataset pass uses one fresh build and one query JVM per -CPU budget for each case: +Construction refuses nonempty model and index caches, and query runs require +them. The recipes set `useSavedIndexIfExists: Yes` so the graph outlives its +construction JVM; nothing is deleted automatically. Do not run cases at the same +time or share their CPUs with other work. Report any heap, CPU, NUMA or library +differences from the measurement environment above. -```bash -python3 jvector-examples/run-ash-pq-study.py \ - --data "$JVECTOR_BENCH_DATA_DIR" --output /data/jvector-bench/ash-one-pass \ - --algorithms ash --query-modes queued \ - --build-repetitions 1 --query-repetitions 1 -``` - -This is eight fresh builds and sixteen query JVMs. Each query JVM reports both -overquery levels. Report these as single measurements, without a sample SD. -The selections only filter standalone invocations; the BenchYAML timing and -scoring paths are unchanged. Omit the selectors to retain the full original plan. - -Use `--plan-only` to inspect the full job list. CPU/node arguments are listed by -`--help` for hosts with different topology. `plan.json` fixes the commit, artifact -checksum and settings; `status.json`, `results.json` and `summary.json` are updated -between invocations. Completed invocations can be resumed only with the same -source and artifact. A 30-minute process watchdog detects a hung invocation; -its wall clock is never reported as a performance measurement. Extra construction -repetitions release their model/index caches after recording results. First-build -graphs and all raw reporting artifacts are retained. - -## Standalone Java entry point - -The shell recipe writes a complete copyable `runs//command.sh`, -including environment settings. Reviewers can execute that Java command directly. -For example, after the case-directory setup above, a one-core queued invocation -has this form (set `REPO_ROOT` and `BENCH_JAR` to the checkout and built artifact): - -```bash -cd "$BENCH_CASE" -MKL_NUM_THREADS=1 OMP_NUM_THREADS=1 OPENBLAS_NUM_THREADS=1 \ -GOTO_NUM_THREADS=1 MKL_DYNAMIC=FALSE ONEDNN_VERBOSE=0 \ -numactl --physcpubind=0 --membind=0 "$JAVA_HOME/bin/java" \ - --add-modules=jdk.incubator.vector --enable-native-access=ALL-UNNAMED \ - -da -Xms2g -Xmx128g -XX:ActiveProcessorCount=1 \ - -Djvector.vectorization_provider=native -Djvector.parallel_writer=true \ - -Djvector.physical_core_count=1 -Djvector.bench.queryThreads=4 \ - -Djvector.bench.verifyConcurrentResults=true -Djvector.bench.throughputMinSeconds=3.0 \ - "-Djvector.bench.sourceRef=$(git -C "$REPO_ROOT" rev-parse HEAD)" \ - "-Djvector.bench.runConfig=$REPO_ROOT/jvector-examples/yaml-configs/ash-pq/run-queued.yml" \ - -cp "$BENCH_JAR" io.github.jbellis.jvector.example.BenchYAML \ - "$REPO_ROOT/jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq/mpnet-2bit-ash.yml" -``` - -The case directory contains copies of the checked-in dataset catalog, dataset -metadata and dataset list because the standard loader resolves them relative to -the working directory. `jvector.bench.runConfig` selects a run YAML without -editing the default. Absolute index-YAML paths work directly. No benchmark -kernel switches or prototype classpaths are used. +Each run writes a complete `runs//command.sh`, including its +environment, so the Java command can be rerun directly. The case directory holds +copies of the catalog, dataset metadata and dataset list, because BenchYAML +resolves them relative to its working directory. ## Measurements and artifacts -* **Construction:** use BenchYAML's own inclusive training, encoding, NVQ and - graph/write timers. Do not add an external timer to these values. The two - overquery rows in a construction invocation describe one build, not two - repetitions. Cached-index invocations do not supply fresh construction times. -* **Throughput:** unchanged random-query warmups and measured full query passes, - including preparation, traversal and reranking. Each QPS sample lasts at least - three seconds and completes the final pass. Direct mode has 4C synchronous - callers; queued mode has at most C execution workers and admission 8C, with - default maximum batch 8 for ASH. PQ uses direct execution. -* **Unloaded latency:** BenchYAML submits and completes one query at a time, - with the same configured worker capacity. The timer ends when the call returns, - including preparation, execution and dispatch. This is not sustained latency - under an externally controlled arrival rate. -* **Queued loaded response:** supplementary samples during throughput include - admission and processing through the worker's completion callback. They end - before the submitting caller resumes. They measure bounded backlog, not a - Poisson arrival-rate curve; do not compare these percentiles as if they were - the unloaded measurement. Reporting aggregates completed samples only after - that query measurement group finishes. -* **Recall:** the same inputs, ground truth and request/result correlation are - used for both execution modes. The concurrency check also compares completed - queued results to searches on the preparing thread using the identical prepared - state, before handoff. Direct validation runs on the concurrent caller threads - and repeats traversal with one prepared score provider for each query. This checks ownership and concurrency without conflating - FP32 reduction differences between singleton GEMV and batched GEMM. Numerical - projection tests independently check both kernels against their rounded oracle. - The study preserves actual recall values: ASH queued improvements are accepted, - while any decline stops collection for investigation. Other modes still require - exact recall equality with the retained construction reference. - -CSV results, system information and dataset information are written under each -case's `logging` directory. The helper additionally records the immutable source -reference, JAR checksum, exact index/run YAML, Java version, CPU topology, Java -PID, console output and exit code under `runs`. Retain those artifacts together. -Report QPS and mean-latency means and sample standard deviations across JVMs, -and identify whether tail summaries are per-JVM medians/ranges or pooled samples. -Construction summaries use all five builds and retain their variance. - -Reporting deferral removes unnecessary compilation between measurement phases; -it does not guarantee that the JVM will never compile during a timed phase. -There is no compiler-quiescence gate or special warmup of measured query inputs. -Hardware, OS scheduling and parallel graph construction cause run variation; -reproduction means the same protocol and comparable distributions. - -## Larger-dataset comparison - -The same index parameters, native defaults, matched 2-bit/4-bit budgets, JDK 22, -48-core construction and 1-/8-core query protocol apply to the larger inputs. -The checked-in `catalog-large.yml` uses relative local paths under -`JVECTOR_BENCH_DATA_DIR`; no credentials or server-specific paths are required. -MPNet 10M uses 101/197 code bytes, Cohere 10M 133/261, and CAP 6M 197/389. - -| Dataset | Base vectors | Dimensions | Queries | -| --- | ---: | ---: | ---: | -| MPNet 10M | 9,997,697 | 768 | 10,000 | -| Cohere 10M | 10,000,000 | 1024 | 10,000 | -| CAP 6M | 6,070,488 | 1536 | 10,000 | - -Use the exact base, query and top-100 ground-truth files named in the catalog. -All use `DOT_PRODUCT` and `NO_SCRUB`; replacing a base file with a deduplicated -variant without rebuilding its ground truth would change the experiment. - -```bash -python3 jvector-examples/run-ash-pq-study.py --dataset-set large \ - --data /data/normalized-large-vector-datasets \ - --output /data/jvector-bench/large-study -``` - -Large construction invocations use a three-hour process watchdog, while query -invocations retain the 30-minute watchdog. These only detect an invocation that -failed to finish; their wall clock does not supply benchmark measurements. - -The large study has 60 fresh construction JVMs and 144 query JVMs: five builds -and three query repetitions per configuration, with both overquery levels -reported by each query JVM. It runs after the four-dataset 1M study, without -sharing CPUs with another measurement. Only the first graph per configuration -is retained for queries; redundant construction caches are released after -recording their results. Reserve sufficient storage for twelve retained -indexes plus one additional construction, and record the output volume. - -For a single case, select the same local catalog explicitly: - -```bash -JVECTOR_BENCH_CATALOG=catalog-large.yml \ -JVECTOR_BENCH_NUMA_POLICY=interleave:0,1 \ - bash jvector-examples/run-ash-pq-benchmark.sh \ - mpnet10m-2bit-ash.yml construction 48 0-47 /data/jvector-bench/mpnet10m-2bit-ash-r1 -``` - -The helper copies the selected checked-in catalog into the case directory and -records it alongside the index/run YAML and full standalone command. Use that -same catalog selection for subsequent direct/queued invocations. - -## Unloaded-latency warmup correction - -Unloaded latency has its own warmup through the same single-request execution -path used by measurement. Throughput's batched warmup alone does not exercise -the synchronous queue handoff/completion path. Before timing, both direct and -queued modes run three complete random-query warmup passes, each for at least -three seconds. A bounded set of random query buffers is refilled between warmup sweeps; -measured dataset queries are not used for warmup. The scoring operation and -timed latency interval are unchanged. Both phases use the same execution pass -and search-function instance, preventing separate call sites from leaving the -measured path cold after JIT inlining. Primitive timing storage avoids boxing -one temporary object per latency sample. No compiler-quietness gate is used. - -An ASH JFR profile identified compilation of the single-request completion, -queue and FP16 projection paths during the original latency measurement. -Old query results from that protocol must be identified separately. Since the -first-overquery latency phase precedes the next overquery's throughput phase, -corrected comparisons repeat the full query JVM, rather than replacing only -a latency cell. Construction results remain applicable when all construction -classes and native libraries are verified unchanged. Retain the source and -artifact provenance for each construction and query invocation. +* **Construction:** BenchYAML's training, encoding, NVQ and graph-write timers; + dataset loading and setup are excluded. Each construction invocation reports + two overquery rows from one build. Cached-index invocations report no + construction time. +* **Throughput:** random-query warmups, then timed full query passes including + preparation, traversal and reranking. Each sample lasts at least three seconds. + Direct mode uses 4 × cores callers; queued mode uses at most one worker per + core, admission 8 × cores and a maximum batch of 8. +* **Unloaded latency:** one query at a time through the same execution path, + after three warmup passes of at least three seconds. It is not latency under a + controlled arrival rate. +* **Queued response:** sampled during throughput, from admission to the worker's + completion callback. It measures a bounded backlog, not an arrival-rate curve. +* **Recall:** the same inputs and ground truth for every mode. Concurrent results + are checked against single-threaded searches with the same prepared query. + +CSV results, system information and dataset information go to each case's +`logging` directory. The scripts also record the source commit, JAR checksum, +index and run YAML, Java version, CPU topology, console output and exit code +under `runs`. Keep these artifacts together with any reported numbers. + +Hardware, OS scheduling and parallel graph construction cause run-to-run +variation; reproducing the study means the same protocol and comparable +distributions, not identical numbers. From 5f2cb464d0c05d9b88e715a6cc21f7ce072eff16 Mon Sep 17 00:00:00 2001 From: Ted Willke Date: Mon, 9 Nov 2026 15:50:03 -0800 Subject: [PATCH 180/243] Add an ASH performance page with the ASH vs PQ results docs/ash-performance.md holds the 6M and 10M construction, recall and QPS tables with the machine, JVM, placement, parameters and protocol behind them. The reproduction guide and README link to it. --- README.md | 3 + docs/ash-performance.md | 152 +++++++++++++++++++++++++++++++++++++++ docs/ash-pq-benchmark.md | 71 +++--------------- 3 files changed, 164 insertions(+), 62 deletions(-) create mode 100644 docs/ash-performance.md diff --git a/README.md b/README.md index c5076be44..e646729b6 100644 --- a/README.md +++ b/README.md @@ -53,6 +53,9 @@ For database integration with ASH, see [ASH query callers and CPU budgeting](./d For ASH model parameters, construction modes, precision, JVM flags and their defaults, see [ASH configuration and defaults](./docs/ash-configuration.md). +For ASH versus PQ construction time, recall and throughput on 6M and 10M datasets, +see [ASH performance](./docs/ash-performance.md). + The older step-by-step guide for JV can be found [here](./docs/legacy/jvector-step-by-step.md). New users should start with the tutorials mentioned earler, but the step-by-step guide contains useful commentary for advanced users. diff --git a/docs/ash-performance.md b/docs/ash-performance.md new file mode 100644 index 000000000..305563464 --- /dev/null +++ b/docs/ash-performance.md @@ -0,0 +1,152 @@ + + +# ASH performance + +This page compares fused ASH and fused PQ graph indexes on three large datasets, +at matched code sizes: both store the same bytes per vector, including ASH's +five-byte header. [Reproducing ASH versus PQ](ash-pq-benchmark.md) explains how +to run the same study on your own hardware. + +## Summary + +ASH wins total construction in 6/6 configurations, recall in 12/12 cases and QPS +in 22/24 cases. The sum of all six construction times is 48.5% lower with ASH. +The better value in each comparison is in bold. + +### Total construction seconds (lower wins) + +| Dataset | Compression | PQ | ASH | ASH change | Faster | +| --- | --- | ---: | ---: | ---: | --- | +| MPNet 10M | 32x (b=2) | 343.7 | **238.1** | −30.7% | ASH | +| MPNet 10M | 16x (b=4) | 568.9 | **301.2** | −47.1% | ASH | +| Cohere 10M | 32x (b=2) | 478.0 | **263.3** | −44.9% | ASH | +| Cohere 10M | 16x (b=4) | 771.2 | **335.8** | −56.5% | ASH | +| CAP 6M | 32x (b=2) | 295.6 | **190.2** | −35.6% | ASH | +| CAP 6M | 16x (b=4) | 630.7 | **261.8** | −58.5% | ASH | +| **Sum: six configurations** | | 3,088.1 | **1,590.5** | −48.5% | ASH | + +### Recall and throughput (higher wins) + +| Dataset | Compression | Rerank | Recall@10 PQ | Recall@10 ASH | QPS 1 core PQ | QPS 1 core ASH | QPS 8 cores PQ | QPS 8 cores ASH | +| --- | --- | --- | ---: | ---: | ---: | ---: | ---: | ---: | +| MPNet 10M | 32x (b=2) | 1x | 0.54043 | **0.75532** | **9,349** | 9,012 | 77,609 | **81,421** | +| MPNet 10M | 32x (b=2) | 2x | 0.71512 | **0.88903** | **7,540** | 7,266 | 61,759 | **62,913** | +| MPNet 10M | 16x (b=4) | 1x | 0.72452 | **0.81330** | 7,224 | **9,860** | 63,221 | **76,619** | +| MPNet 10M | 16x (b=4) | 2x | 0.86195 | **0.90050** | 5,661 | **7,397** | 48,894 | **57,709** | +| Cohere 10M | 32x (b=2) | 1x | 0.52124 | **0.58682** | 7,642 | **9,067** | 62,520 | **72,500** | +| Cohere 10M | 32x (b=2) | 2x | 0.70611 | **0.76181** | 5,826 | **6,875** | 48,494 | **55,340** | +| Cohere 10M | 16x (b=4) | 1x | 0.59860 | **0.66033** | 6,145 | **8,339** | 50,152 | **65,660** | +| Cohere 10M | 16x (b=4) | 2x | 0.75846 | **0.78864** | 4,684 | **6,122** | 38,369 | **49,100** | +| CAP 6M | 32x (b=2) | 1x | 0.41240 | **0.72119** | 5,815 | **7,295** | 51,746 | **62,544** | +| CAP 6M | 32x (b=2) | 2x | 0.59705 | **0.88574** | 4,748 | **5,756** | 41,022 | **48,482** | +| CAP 6M | 16x (b=4) | 1x | 0.65664 | **0.80468** | 4,436 | **6,295** | 39,786 | **58,394** | +| CAP 6M | 16x (b=4) | 2x | 0.83966 | **0.90749** | 3,550 | **4,774** | 31,393 | **41,777** | + +Compression ratios are nominal; ASH and PQ store the same bytes per vector, +including the ASH header. b is ASH bits per projected dimension. + +## Setup and method + +**Machine.** One Intel Xeon 6985P-C socket at 2.30 GHz base: 48 physical cores, +2 threads per core (96 hardware threads), 504 MiB L3. 354 GiB RAM in two NUMA +nodes of about 177 GiB each. Debian 12, Linux 6.1. Transparent huge pages: +enabled=always, defrag=madvise. + +**Storage.** Datasets, model caches and indexes are on local NVMe SSDs in Linux +md RAID 10 (512 KiB chunk, two near-copies), formatted XFS. The page cache was +not dropped between runs. + +**JVM.** OpenJDK 22.0.2 (Eclipse Temurin). Flags: +`--add-modules=jdk.incubator.vector --enable-native-access=ALL-UNNAMED -da -Xms2g -Xmx128g -XX:ActiveProcessorCount=`. +No collector is selected, so the JVM uses its default: G1 for the 8- and 48-core +runs and Serial for the 1-core runs, because `ActiveProcessorCount=1`. +`JAVA_TOOL_OPTIONS` and `JDK_JAVA_OPTIONS` are empty. + +**JVector system properties.** `-Djvector.vectorization_provider=native +-Djvector.parallel_writer=true -Djvector.physical_core_count= +-Djvector.bench.queryThreads=<4 × cores> -Djvector.bench.verifyConcurrentResults=true +-Djvector.bench.throughputMinSeconds=3.0`. ASH construction scoring is +symmetric-all, the default; no other ASH properties are set. + +**Native thread pools.** `MKL_NUM_THREADS`, `OMP_NUM_THREADS`, +`OPENBLAS_NUM_THREADS` and `GOTO_NUM_THREADS` equal the core count for +construction and 1 for queries; `MKL_DYNAMIC=FALSE`. + +**CPU and memory placement.** Each JVM starts under `numactl`. Construction uses +one hardware thread on each of the 48 physical cores, with memory interleaved +across both NUMA nodes. Queries use 1 or 8 physical cores on node 0, with memory +bound to node 0. + +**Datasets.** All use dot-product similarity on normalized vectors, 10,000 +queries and exact top-100 ground truth. MPNet 10M: 9,997,697 base vectors × 768 +dimensions. Cohere 10M (English v3): 10,000,000 × 1,024. CAP 6M (gte-Qwen2-1.5B +case law): 6,070,488 × 1,536. + +**Index parameters.** On-disk index format 7, fused graph, parallel writer. +Degree (M) 32, `efConstruction` 100, neighbor overflow 1.2, alpha 1.2, hierarchy +and final refinement on. ASH: ITQ optimizer, one landmark (C=1), symmetric-all +construction scoring, and b = 2 or 4 bits per projected dimension over D/2 +projected dimensions. Code size per vector, including the five-byte header: +MPNet 101 / 197 bytes, Cohere 133 / 261 bytes and CAP 197 / 389 bytes for +b = 2 / 4. PQ uses the same number of bytes as 8-bit subspaces with 256 +centroids. Reranking uses NVQ vectors stored in the graph. + +**Query parameters.** Top 10 returned. Overquery 1.0 and 2.0 (rerank 1x and 2x) +rescore 10 and 20 candidates with NVQ. Search pruning off. ASH uses queued +execution with a maximum batch of 8 and automatic query projection, which +resolves to FP16 on this CPU: one ASH worker per core (1 or 8), queue capacity 8 +per core, fed by 4 × cores caller threads (4 or 32); the mean batch size is 8.0. +PQ uses direct execution with 4 × cores caller threads. Concurrent results are +verified against single-threaded results. + +**Measurement protocol.** Each configuration was built twice from empty caches. +Construction time is quantizer training, vector encoding, NVQ training, and +graph construction and writing; dataset loading and setup are excluded. Each +construction figure is the mean of the two builds: + +| Configuration | ASH build 1 (s) | ASH build 2 (s) | +| --- | ---: | ---: | +| MPNet 10M 32x (b=2) | 227.3 | 249.0 | +| MPNet 10M 16x (b=4) | 317.6 | 284.8 | +| Cohere 10M 32x (b=2) | 291.2 | 235.3 | +| Cohere 10M 16x (b=4) | 348.2 | 323.4 | +| CAP 6M 32x (b=2) | 192.6 | 187.8 | +| CAP 6M 16x (b=4) | 260.7 | 262.9 | + +Query JVMs reuse the first build's index, one JVM for 1 core and one for 8 cores. +QPS is the mean of three timed samples of at least three seconds each; each JVM +runs about 90,000 rerank-1x queries in total. Recall@10 is the fraction of the +exact top 10 returned. Jobs ran one at a time, with no other benchmark process on +the machine. PQ was measured in an earlier run on the same machine, JDK, +placement, input files and graph parameters, with PQ codes sized to the same +stored bytes per vector as ASH. + +**Single-core variation.** Single-core QPS varies between graphs built from the +same settings. On an earlier MPNet 10M 32x graph, ASH measured 10,414 / 7,838 +QPS on one core (rerank 1x / 2x). Three further runs of the current code and of +the earlier code on the reported graph averaged 9,229 / 7,460 and 9,124 / 7,334 +QPS with identical recall, so the code performs the same; on this graph, ASH's +single-core figures for that case are below PQ's 9,349 / 7,540. + +**ASH memory.** Encoded ASH vectors are stored back to back in shared pages and +scored in place, without per-vector objects. Measured on 2M MPNet vectors as live +heap with and without the encoded vectors (Serial and G1 agree within 1%), ASH +retains 109 bytes per vector at b = 2 and 205 at b = 4, compared with 189 and 285 +for the earlier per-vector storage: 42% and 28% less. The codes occupy 101 and +197 bytes per vector on disk; the remaining 8 bytes hold the header scale and +offset as floats and a per-vector construction term. The library's own +accounting (`ramBytesUsed`) reports the same 109 and 205 bytes. diff --git a/docs/ash-pq-benchmark.md b/docs/ash-pq-benchmark.md index 1ebb93419..94c6865ac 100644 --- a/docs/ash-pq-benchmark.md +++ b/docs/ash-pq-benchmark.md @@ -29,67 +29,13 @@ steps through a whole study. The [BenchYAML Guide](benchyaml-guide.md) describes the index configuration, and [ASH configuration and defaults](ash-configuration.md) describes model and runtime settings. -## Results: 6M and 10M datasets - -ASH wins total construction in 6/6 configurations, recall in 12/12 cases and QPS -in 22/24 cases. The sum of all six construction times is 48.5% lower with ASH. - -**Total construction seconds** (lower is better) - -| Dataset | Compression | PQ | ASH | ASH change | -| --- | --- | ---: | ---: | ---: | -| MPNet 10M | 32x (b=2) | 343.7 | **238.1** | −30.7% | -| MPNet 10M | 16x (b=4) | 568.9 | **301.2** | −47.1% | -| Cohere 10M | 32x (b=2) | 478.0 | **263.3** | −44.9% | -| Cohere 10M | 16x (b=4) | 771.2 | **335.8** | −56.5% | -| CAP 6M | 32x (b=2) | 295.6 | **190.2** | −35.6% | -| CAP 6M | 16x (b=4) | 630.7 | **261.8** | −58.5% | -| **Sum** | | 3,088.1 | **1,590.5** | −48.5% | - -**Recall@10 and queries per second** (higher is better) - -| Dataset | Compression | Rerank | PQ recall | ASH recall | PQ QPS, 1 core | ASH QPS, 1 core | PQ QPS, 8 cores | ASH QPS, 8 cores | -| --- | --- | --- | ---: | ---: | ---: | ---: | ---: | ---: | -| MPNet 10M | 32x (b=2) | 1x | 0.54043 | **0.75532** | **9,349** | 9,012 | 77,609 | **81,421** | -| MPNet 10M | 32x (b=2) | 2x | 0.71512 | **0.88903** | **7,540** | 7,266 | 61,759 | **62,913** | -| MPNet 10M | 16x (b=4) | 1x | 0.72452 | **0.81330** | 7,224 | **9,860** | 63,221 | **76,619** | -| MPNet 10M | 16x (b=4) | 2x | 0.86195 | **0.90050** | 5,661 | **7,397** | 48,894 | **57,709** | -| Cohere 10M | 32x (b=2) | 1x | 0.52124 | **0.58682** | 7,642 | **9,067** | 62,520 | **72,500** | -| Cohere 10M | 32x (b=2) | 2x | 0.70611 | **0.76181** | 5,826 | **6,875** | 48,494 | **55,340** | -| Cohere 10M | 16x (b=4) | 1x | 0.59860 | **0.66033** | 6,145 | **8,339** | 50,152 | **65,660** | -| Cohere 10M | 16x (b=4) | 2x | 0.75846 | **0.78864** | 4,684 | **6,122** | 38,369 | **49,100** | -| CAP 6M | 32x (b=2) | 1x | 0.41240 | **0.72119** | 5,815 | **7,295** | 51,746 | **62,544** | -| CAP 6M | 32x (b=2) | 2x | 0.59705 | **0.88574** | 4,748 | **5,756** | 41,022 | **48,482** | -| CAP 6M | 16x (b=4) | 1x | 0.65664 | **0.80468** | 4,436 | **6,295** | 39,786 | **58,394** | -| CAP 6M | 16x (b=4) | 2x | 0.83966 | **0.90749** | 3,550 | **4,774** | 31,393 | **41,777** | - -Compression ratios are nominal, and b is ASH bits per projected dimension. -Rerank 1x and 2x rescore 10 and 20 candidates with NVQ. Each construction figure -is the mean of two fresh builds. QPS is the mean of three timed samples in one -query JVM per core count, run against the first build's index. - -Single-core QPS varies between graphs built from the same settings. On an -earlier MPNet 10M 32x graph, ASH measured 10,414 / 7,838 QPS on one core -(rerank 1x / 2x), above PQ. Repeated query runs on the reported graph averaged -about 9,200 / 7,400, below PQ. - -The 1M recipes (MPNet, Cohere, Ada002 and OpenAI-3072) use the same parameters -and protocol; their results are not part of this summary. - -### Measurement environment - -| | | -| --- | --- | -| CPU | One Intel Xeon 6985P-C socket: 48 physical cores, 2 threads per core, 504 MiB L3 | -| Memory | 354 GiB in two NUMA nodes of about 177 GiB | -| Storage | Local NVMe SSDs in RAID 10, XFS; the page cache was not dropped between runs | -| OS | Debian 12, Linux 6.1; transparent huge pages enabled=always, defrag=madvise | -| JVM | OpenJDK 22.0.2 (Temurin), `-Xms2g -Xmx128g`, default collector (G1, or Serial with one core) | -| Placement | Construction on one thread of each of the 48 cores, memory interleaved on both nodes. Queries on 1 or 8 cores of node 0, memory on node 0 | -| Native threads | BLAS and OpenMP thread counts equal the core count for construction and 1 for queries | - -Jobs ran one at a time with no other benchmark on the machine. PQ and ASH used -the same machine, JDK, placement, input files and graph parameters. +## Results + +[ASH performance](ash-performance.md) reports the 6M and 10M results: ASH wins +total construction in 6/6 configurations, recall in 12/12 cases and QPS in 22/24 +cases. It also describes the machine, JVM, placement and protocol used, so you +can note any differences in your own runs. The 1M recipes (MPNet, Cohere, Ada002 +and OpenAI-3072) use the same parameters and protocol. ## Build the checkout @@ -204,7 +150,8 @@ Construction refuses nonempty model and index caches, and query runs require them. The recipes set `useSavedIndexIfExists: Yes` so the graph outlives its construction JVM; nothing is deleted automatically. Do not run cases at the same time or share their CPUs with other work. Report any heap, CPU, NUMA or library -differences from the measurement environment above. +differences from the measurement environment in +[ASH performance](ash-performance.md). Each run writes a complete `runs//command.sh`, including its environment, so the Java command can be rerun directly. The case directory holds From 75826034339a91fae2f48ea622d8901a06f2a961 Mon Sep 17 00:00:00 2001 From: Ted Willke Date: Mon, 9 Nov 2026 15:54:08 -0800 Subject: [PATCH 181/243] Align the ASH and benchmark docs and fix broken links Link labels now match document titles, and the ASH versus PQ guide is titled Reproducing ASH versus PQ. The native preparation, PCA and neighborhood guides keep their design content without dated run records, server details or change history. The new docs share the license header, the examples README names the current version, and the native README and siftsmall links resolve. --- README.md | 2 +- docs/ash-configuration.md | 10 +- docs/ash-neighborhood-benchmark.md | 64 ++++----- docs/ash-pca-native-svd.md | 87 +++++-------- docs/ash-pq-benchmark.md | 2 +- docs/ash-query-integration.md | 6 +- docs/benchmarking.md | 8 +- docs/native-ash-preparation.md | 123 ++++++------------ jvector-examples/README.md | 6 +- .../index-parameters/studies/README.md | 10 +- jvector-native/src/main/native/README.md | 12 +- 11 files changed, 141 insertions(+), 189 deletions(-) diff --git a/README.md b/README.md index e646729b6..fdecdb13c 100644 --- a/README.md +++ b/README.md @@ -48,7 +48,7 @@ including runnable 100k-vector examples and optional integration with PQ and NVQ For writing completed graphs with FP or NVQ features, see [Deferred batch serialization](./docs/deferred-batch-writer.md). It works with application vector sources and preloaded data; dataset prefetching is optional. -For database integration with ASH, see [ASH query callers and CPU budgeting](./docs/ash-query-integration.md), covering caller concurrency, bounded admission, state ownership and latency validation. +For database integration with ASH, see [Integrating ASH queries into a database](./docs/ash-query-integration.md), covering caller concurrency, bounded admission, state ownership and latency validation. For ASH model parameters, construction modes, precision, JVM flags and their defaults, see [ASH configuration and defaults](./docs/ash-configuration.md). diff --git a/docs/ash-configuration.md b/docs/ash-configuration.md index ac0a002c1..ec475132d 100644 --- a/docs/ash-configuration.md +++ b/docs/ash-configuration.md @@ -137,7 +137,7 @@ precision. See the [precision guide](ash-projection-precision.md#benchyaml-runs) `MKL_NUM_THREADS`, `OMP_NUM_THREADS`, `OPENBLAS_NUM_THREADS` and `GOTO_NUM_THREADS` are native-runtime environment controls, not ASH model -parameters. Avoid nested native query pools. The standalone launcher sets them +parameters. Avoid nested native query pools. The ASH versus PQ study scripts set them to one for queries and the core budget for construction; it also sets `MKL_DYNAMIC=FALSE` and `ONEDNN_VERBOSE=0`. OS affinity (`numactl --physcpubind`) and memory placement (`--membind`, `--interleave`) @@ -153,7 +153,7 @@ writer selection, compression, and reranking. Set BenchYAML query delivery and batch limits in the [ASH search parameters](benchyaml-guide.md#ash-query-parameters). Database integrations select their own admission policy and executors. See -[ASH query callers and CPU budgeting](ash-query-integration.md). +[Integrating ASH queries into a database](ash-query-integration.md). | Setting | Default | Optional values / definition | | --- | --- | --- | @@ -170,12 +170,12 @@ report loaded response times and mean batch size. Unloaded latency and loaded response time describe different measurements. Independent build/query JVM repetitions are launch choices, not model defaults; preserve individual CSVs. -The standalone launcher accepts `JVECTOR_BENCH_DATA_DIR` (required local input +The ASH versus PQ case script (`run-ash-pq-benchmark.sh`) accepts `JVECTOR_BENCH_DATA_DIR` (required local input directory), `JVECTOR_BENCH_CATALOG` (`catalog-1m.yml`; optionally `catalog-large.yml`), `JVECTOR_BENCH_HEAP` (`128g`), and `JVECTOR_BENCH_NUMA_POLICY` (`bind:0`; alternatively `interleave:`). -These defaults belong to the launcher, not the library. See the -[BenchYAML guide](ash-pq-benchmark.md) for launch and artifact details. +These defaults belong to the script, not the library. See the +[Reproducing ASH versus PQ](ash-pq-benchmark.md) for launch and artifact details. ## Diagnostics diff --git a/docs/ash-neighborhood-benchmark.md b/docs/ash-neighborhood-benchmark.md index 20bcc1482..b5c0d4d62 100644 --- a/docs/ash-neighborhood-benchmark.md +++ b/docs/ash-neighborhood-benchmark.md @@ -1,3 +1,19 @@ + + # ASH neighborhood scoring and isolated full-scan benchmarks The graph decoder and `DistancesASH` use the same `ASHNeighborhoodScorer`. @@ -60,7 +76,7 @@ slot, so an `avx3_spr` dispatch report currently executes that AVX-512 implement exposes no public raw-pointer interface. The provider detects the optional ASH symbol when its ASH capability is queried, after library loading. Libraries without that symbol retain the existing Java LUT path; unrelated native operations do not -acquire a dependency on the new kernel. An explicitly requested native benchmark +acquire a dependency on this kernel. An explicitly requested native benchmark fails if that capability is absent, preventing a mislabeled fallback measurement. The binding validates dimensions, lengths, and LUT/output non-aliasing before C++. Native output is unspecified on error. Status values are part of the ABI: 0 means success, 1 an invalid C=1 landmark, and 2 an invalid shape. @@ -80,30 +96,22 @@ safepoints during the longer call. Both paths retain the one-neighborhood-per-ca contract. Enable `jextract.trace.downcalls=true` only for untimed diagnosis. Native float vectors are heap-backed. Their Vector API stores write directly to -the backing float array, which avoids the HotSpot 22 `intoMemorySegment` compiler -crash encountered during development. Compiler exclusions are not required by the -reviewed implementation. The regression suite exercises compiled stores at every +the backing float array, which avoids a HotSpot 22 `intoMemorySegment` compiler +crash without compiler exclusions. The regression suite exercises compiled stores at every float vector width and nonzero offsets. ## Build and launch with JDK 22 -The reported experiments use JDK 22. Runtime and toolchain selection belong to the -launcher and the repository's module requirements; the benchmark imposes no exact -runtime-version check. The examples module retains the repository's Java 16 -source/API compatibility; the native module uses the repository's JDK 22 FFM API. - -The native build uses the repository's existing Meson targets and compiler policy. -The pre-existing Sapphire Rapids target documents GCC 12+ or Clang 14+ requirements; -this ASH change adds no ISA target, compiler selection, or higher compiler floor. -For the reported benchmark server, the launcher sets `CXX=/usr/bin/g++-12`. -Production toolchains continue to use Meson's existing selection behavior. +Use JDK 22 for measurements; the benchmark does not check the runtime version. +The examples module keeps the repository's Java 16 source compatibility, and the +native module uses the JDK 22 FFM API. -The build script retains the original dependency installation, command tracing, -version check, and resource-publication behavior, including deleting the old -resource library before compilation. Its sole change is to omit `--wipe` when no -configured Meson tree exists, supporting first builds on older Meson releases. -Configured trees still wipe exactly as before. A failed build continues to stop -Maven and leaves no old resource library to package. +The native build uses the repository's Meson targets and compiler policy. The +Sapphire Rapids target requires GCC 12+ or Clang 14+; ASH adds no ISA target or +compiler requirement. If the default compiler is older, set `CXX`, for example +`CXX=/usr/bin/g++-12`. The build script omits `--wipe` when no Meson tree is +configured yet, so first builds work on older Meson releases. A failed build +stops Maven and leaves no stale resource library to package. In IntelliJ, run `io.github.jbellis.jvector.example.DistancesASH` with JDK 22 and these VM options, changing the fixture path as appropriate: @@ -135,8 +143,7 @@ these VM options, changing the fixture path as appropriate: ``` To create a fixture, supply `base.fvecs query.fvecs` as program arguments. An -optional third ground-truth argument is accepted for launcher compatibility and -is unused. Input loading reads only the requested prefixes; queries are normalized +optional third ground-truth argument is accepted and ignored. Input loading reads only the requested prefixes; queries are normalized and base vectors retain their original values. Models, encoded vectors, and packed neighborhoods are created outside timing. Replaying an existing fixture needs no program arguments and performs no training, encoding, or packing. @@ -145,7 +152,7 @@ Fixtures are published only after a complete write; existing destinations are preserved. Separate names are required for different bit widths or shapes. Replay checks version, lengths, model dimensions, query values, payload sizes, and truncation/trailing data. The printed SHA-256 identifies the exact stored inputs. -Fixture format version 1 and its existing serialized bytes remain compatible. +The fixture format is version 1. For command-line launches, build the reactor and resolve a classpath with the repository's Maven tooling. Set CPU affinity explicitly, for example @@ -181,7 +188,7 @@ count; input work does not grow with worker count. Actual preparation bottleneck contention, and scheduling overhead stay visible in the measured result. Validation is independent of the LUT kernel and projects queries in double -precision. It checks serialized header effects and uses the unchanged tolerance +precision. It checks serialized header effects and uses the tolerance `3e-5 + 3e-5 * abs(expected)`. Both expected and actual scores must be finite. Validation, fixture I/O, and worker creation are outside the timed scan stream. @@ -193,8 +200,8 @@ Panama provider. Keep fixture hash, query index, bit width, block shape, access order, JDK, heap, warmup, and affinity identical. The `Scorer:` line identifies the actual execution path; the native provider's kernel description identifies its ISA. -The initial Cohere fixtures fit in the server's shared last-level cache. These -measurements describe repeated in-memory scoring, without disk I/O. Results are +Small fixtures can fit in a CPU's shared last-level cache. These measurements +describe repeated in-memory scoring, without disk I/O. Results are specific to the tested workload and hardware. Pipelined full-scan throughput does not by itself describe adaptive graph traversal latency or graph-search scaling. @@ -212,9 +219,8 @@ not by itself describe adaptive graph traversal latency or graph-search scaling. | `scoringWarmupSeconds` | 1.0 | Warmup duration | | `validationNeighborhoods` | 1024 | Evenly spaced neighborhoods checked per saved query | -The former `neighborhoodsPerQuery` setting is rejected: a full scan always covers -the complete fixture. Malformed integer options fail rather than selecting a -silent fallback value. +A full scan always covers the complete fixture. Malformed integer options fail +rather than selecting a silent fallback value. ## Regression checks diff --git a/docs/ash-pca-native-svd.md b/docs/ash-pca-native-svd.md index 03e11d73e..766ad2f0a 100644 --- a/docs/ash-pca-native-svd.md +++ b/docs/ash-pca-native-svd.md @@ -1,3 +1,19 @@ + + # ASH PCA correctness PCA first computes FP64 covariance with DSYRK and its eigendecomposition with @@ -11,61 +27,30 @@ at the scale of accumulated FP64 error causes fallback to direct DGESDD. Zero energy, wide input and eigenvalue nonconvergence also use direct SVD. Its full right basis preserves null-space completion for underdetermined training. Invalid input, invalid native arguments and invalid workspace sizes are reported; -allocation failures are not swallowed. Models and graph formats are unchanged. +allocation failures are not swallowed. Model and graph formats are unchanged. Tests compare subspace projectors with an independent direct SVD, including repeated eigenvalues inside the retained subspace, ties at its boundary, ill-conditioned and rank-deficient matrices, wide inputs and input preservation. -## Historical DGESVD correctness issue - -During the native PQ/ASH graph comparison, JDK 25 MPNet ASH training failed in -native PCA with `DGESVD info=767`. The identical frozen-runtime retry failed too. -The 15,360-by-768 normalized residual matrix was finite, unchanged between calls, -and identical across the JDK 22/25 diagnostics (FNV fingerprint e9c6fe8880b17ba6). - -The installed MKL 2020.4.304 backend's multi-threaded, optimal-workspace QR SVD -path also returned success with incorrect results on JDK 22. For the same matrix: - -| Driver / configuration | Relative spectral-energy error | Eigenvector residual | -|---|---:|---:| -| DGESVD, optimal workspace, 48 threads | 1.733947% on a returned-success call; other calls failed | 0.635204 | -| DGESVD, minimum workspace, 48 threads | 5.91e-14 | 2.05e-14 | -| DGESDD, overwrite, 48 threads | 6.11e-14 | 1.35e-14 | -| DGESDD, thin, 48 threads | 6.11e-14 | 1.35e-14 | -| DGESVD, optimal workspace, 1 thread | 5.84e-14 | 1.95e-14 | - -Input squared Frobenius norm was approximately 15,360. A returned-success QR SVD -reported singular-value squared sum 15,626.33427256156. The independent residual -check evaluated `X^T X v - sigma^2 v` for leading and interior singular vectors -using Java arithmetic, without relying on BLAS for the check. - -These observations isolate a native QR SVD fast-path problem, rather than a JDK -selection issue or non-finite training input. They do not establish an upstream -vendor root-cause patch. Positive DGESVD status denotes QR nonconvergence; see -[the LAPACK contract](https://www.netlib.org/lapack/explore-html/d1/d7f/group__gesvd_gac6bd5d4e645049e49bb70691180abf07.html). - -The initial fix selected the portable divide-and-conquer DGESDD driver, already -used by ITQ orthogonalization and retained as the direct-SVD fallback. Tall matrices overwrite the scratch input with the -unused left vectors. Wide matrices request full right vectors so projections can +## Why PCA avoids the QR SVD driver + +Some LAPACK builds return incorrect results from the multi-threaded, +optimal-workspace QR SVD path (DGESVD). With MKL 2020.4, a finite, well-formed +15,360 × 768 training matrix produced either `DGESVD info=767` (QR +nonconvergence; see +[the LAPACK contract](https://www.netlib.org/lapack/explore-html/d1/d7f/group__gesvd_gac6bd5d4e645049e49bb70691180abf07.html)) +or a successful return with a 1.7% spectral-energy error. The same matrix was +accurate to about 1e-13 with DGESDD, with DGESVD on minimum workspace, or with +one thread. + +PCA therefore never calls DGESVD. The covariance path above is the primary +method, and the divide-and-conquer DGESDD driver, also used by ITQ +orthogonalization, is the fallback. Tall inputs overwrite a scratch copy with the +unused left vectors; wide inputs request full right vectors so projections can include null-space directions when training has fewer rows than dimensions. -Finite input, workspace size and singular-value energy are validated before -accepting the PCA basis. There are no CPU-, vendor-, bit-width- or JDK-specific -branches or runtime thread-count workarounds. - -BenchYAML and its timers are unchanged. Runtime validation uses BenchYAML on -both JDKs with 64-by-768 and 1,024-by-768 training sets, native ASH, deferred writes -and NVQ reranking. All four cases passed, including serial/concurrent result -checks. Added regression sources cover principal axes, wide null-space completion -and non-finite input; they compile for CI. Unit tests were not separately executed -for that initial study; the current regression suite executes the PCA checks. - -All graph measurements made with the earlier ASH PCA implementation are retained -as superseded evidence and recollected after the fix. PQ does not use this PCA -path; its completed measurements are retained after verifying that its runtime -classes are unchanged. Earlier ITQ-trained full-scan ASH comparisons also warrant -revalidation wherever they used the affected multi-threaded QR SVD backend. +There are no CPU-, vendor-, bit-width- or JDK-specific branches or thread-count +workarounds. -Existing saved models are not rewritten by this change. Retrain the ASH compressor -and rebuild its graph/model caches to adopt the corrected PCA. The graph study -uses fresh isolated caches for every corrected ASH construction case. +Saved models are not rewritten. Retrain the ASH compressor and rebuild its graph +to adopt the current PCA. diff --git a/docs/ash-pq-benchmark.md b/docs/ash-pq-benchmark.md index 94c6865ac..a806b659a 100644 --- a/docs/ash-pq-benchmark.md +++ b/docs/ash-pq-benchmark.md @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. --> -# ASH versus PQ: results and how to reproduce them +# Reproducing ASH versus PQ This study compares fused ASH and fused PQ graph indexes at matched code sizes: both store the same bytes per vector, including ASH's five-byte header. It uses diff --git a/docs/ash-query-integration.md b/docs/ash-query-integration.md index 6a0d65672..a983cc136 100644 --- a/docs/ash-query-integration.md +++ b/docs/ash-query-integration.md @@ -69,11 +69,11 @@ use `-XX:ActiveProcessorCount` consistently when appropriate. Neither property pins threads. Use the database's scheduler, OS affinity or container controls, and place query execution and index memory together on NUMA systems. -For an integration retaining only synchronous searches, the earlier caller +For an integration retaining only synchronous searches, the caller recommendation is 4C bounded caller threads, with the same physical CPU budget. That does not mean an asynchronous integration needs 4C execution workers. -Both execution modes are measured separately by the -[reproducible BenchYAML comparison](ash-pq-benchmark.md). +[Reproducing ASH versus PQ](ash-pq-benchmark.md) measures both execution modes +separately. ## Library responsibilities and host responsibilities diff --git a/docs/benchmarking.md b/docs/benchmarking.md index e0ba15437..5105f12a9 100644 --- a/docs/benchmarking.md +++ b/docs/benchmarking.md @@ -126,9 +126,9 @@ after cleanup. The parallel writer defaults to 64 records per batch. See the [BenchYAML Guide](benchyaml-guide.md#construction) for configuration and [Deferred batch serialization](deferred-batch-writer.md) for writer details. - ## Fused PQ and ASH comparison -The [standalone comparison recipe](ash-pq-benchmark.md) provides fixed, byte-matched -configurations for all four 1M dataset cases, native packaging commands, CPU/NUMA -placement, construction repetitions, and direct/queued query measurements. +[Reproducing ASH versus PQ](ash-pq-benchmark.md) provides byte-matched recipes +for the 1M and the 6M/10M datasets, scripts that run one case or a whole study, +and the measurement protocol. [ASH performance](ash-performance.md) reports the +results. diff --git a/docs/native-ash-preparation.md b/docs/native-ash-preparation.md index 91e3cf1a1..6fbc1bddb 100644 --- a/docs/native-ash-preparation.md +++ b/docs/native-ash-preparation.md @@ -1,3 +1,19 @@ + + # Native ASH projection and symmetric construction scoring ASH models prepare an immutable provider-specific projection when their Stiefel @@ -33,8 +49,7 @@ the existing exact signed-short LUT. It uses 32-bit partial sums and bounded chunks accumulated into 64-bit totals, rather than letting a short or integer sum overflow. The graph builder's pair-oriented API uses the pair kernel; callers of `ASHSymmetricScorer.blockScorerFor` use the native LUT kernel. Existing -C>1 formulas and the graph-neighborhood scorer are unchanged. The graph study -uses C=1 throughout. +C>1 formulas and the graph-neighborhood scorer are unchanged. Projection and symmetric symbols are optional and looked up independently. Loading an older compatible library preserves Java projection and symmetric implementations. @@ -179,14 +194,14 @@ uses packed nibbles, byte shuffles and independent integer accumulators. The transferable principles here are preparing contiguous immutable weights, amortizing native dispatch over a complete operation, reusing query loads, and -accumulating compact codes without materializing decoded vectors. This port +accumulating compact codes without materializing decoded vectors. JVector implements a single-query Highway matrix-vector operation because graph query latency must include preparation for each query. The IVF implementation does not provide a graph-construction symmetric scorer. Its quantized uint8 query LUTs are not substituted for the graph's existing exact symmetric integer LUTs. -The kernels in this change are independent implementations of these operations. +JVector's kernels are independent implementations of these operations. -## Validation and measurement +## Validation Targeted regression checks compare projection with a double-precision oracle, packed pair dots with an independent decoder, and integer LUT ranges with @@ -194,62 +209,20 @@ packed pair dots with an independent decoder, and integer LUT ranges with both FFM modes, concurrent projections, bounds/alias rejection and absent native symbols. Both JDK 22 and JDK 25 are checked with normal dispatch and the existing AVX3_DL, AVX3, AVX2 and SSE/scalar ISA caps. The generic kernel translation unit -also compiles for ARM NEON on Apple Silicon. SVE/SVE2 execution is not validated -on this host. - -Performance collection uses unchanged standard BenchYAML, existing inclusive -timers, normal query preparation, deferred writes and NVQ reranking. Before and -after query runs load the same saved model and graph, retain stock warmup and -samples, and use 1 or 8 scoring threads pinned to physical cores. Traced small -BenchYAML preflights confirm actual native calls; tracing is disabled for all -performance measurements. Separate fresh full-data builds verify construction. -The focused cases are MPNet at the 2-bit ASH budget and OpenAI3072 at the 4-bit -ASH budget on both JDKs. They are not a replacement for the earlier full PQ/ASH -matrix. - -## FP32 encodeAll measurements (JDK 22, October 31, 2026) - -These are standard BenchYAML `construction.index_quant_time_s.ASH.encoding_time_s` -measurements for four 1M-vector datasets, ITQ, C=1 and D/2. They time the complete -`encodeAll()` operation, including normalization, projection, quantization and -result packing. They are not timings of individual `encode()` calls. Training -and graph/write are separate metrics. The first native packing cost is included. - -Both runs use physical CPUs 0–47 with memory interleaved across NUMA nodes 0 and -1. The before version encodes each vector with Panama FP32; the after version -encodes worker-local batches with Highway FP32. Both use fresh model and -index caches, the native scoring provider, deferred parallel writes, NVQ -reranking, and FP16 query projection. All eight serialized trained models are -byte-identical between the two runs. Each cell is one full construction run; -these are not repeated-run averages. - -| Dataset | ASH case | Before (s) | After (s) | Speedup | -| --- | --- | ---: | ---: | ---: | -| MPNet | 2-bit | 1.140 | 0.800 | 1.43× | -| MPNet | 4-bit | 1.205 | 0.986 | 1.22× | -| Cohere | 2-bit | 1.699 | 0.960 | 1.77× | -| Cohere | 4-bit | 2.049 | 1.264 | 1.62× | -| Ada002 | 2-bit | 5.793 | 1.344 | 4.31× | -| Ada002 | 4-bit | 6.057 | 1.896 | 3.19× | -| OpenAI3072 | 2-bit | 20.621 | 3.641 | 5.66× | -| OpenAI3072 | 4-bit | 25.240 | 4.712 | 5.36× | - -The current code was checked on JDK 22 with native and Panama providers, and -native batch projection with the AVX2 and SSE/scalar ISA caps. The native module -passed 12 projection/FP16 tests; the general ASH suite passed 41 tests with two -provider-specific skips under native, and 39 with four skips under Panama. -Each lower-ISA projection pass ran four tests. The optional standalone Meson -C++ test targets were unavailable on this host; the Java native tests invoke -the actual dispatched C++ kernels and require the new batch symbol. - -Fresh graph recall was also recorded, but these independent parallel graph -builds do not isolate an encoding-only recall effect. Model and graph formats, -query FP16 arithmetic, and scoring kernels were not changed by this extension. - -## Preferred oneDNN FP32 encoding projection (November 1, 2026) +also compiles for ARM NEON on Apple Silicon. SVE/SVE2 execution is not yet validated. + +## Batched FP32 encoding + +`encodeAll()` projects worker-local batches with Highway FP32 instead of +projecting one vector at a time. On four 1M-vector datasets (ITQ, C=1, D/2, +48 physical cores) this reduced BenchYAML's ASH encoding time by 1.2x to 5.7x, +with the largest gains at higher dimensions, and produced byte-identical trained +models. Training and graph writing are measured separately. + +## oneDNN FP32 encoding projection The native provider automatically prefers the existing sequential oneDNN add-on -for FP32 `projectBatch()` when its new entry point is available. It executes row-major +for FP32 `projectBatch()` when its entry point is available. It executes row-major SGEMM as `[B,D] * transpose([d,D]) -> [B,d]`, with FP32 inputs, weights and outputs. The adapter shares the immutable Highway matrix, avoiding a second packed model copy, and reuses the existing calling-thread native input/output buffers. Its @@ -260,9 +233,8 @@ The sequential-runtime build assertion prevents nested oneDNN thread pools. Single-vector `encode()` retains Highway FP32 projection. The wrapper delegates query precision selection to the same original projection: FP16 queries remain FP16 in automatic/required modes, with their existing FP32 fallback. Database -encoding never converts to FP16. Java code selection and landmark correction -remain unchanged. The slower experimental native quantizer was archived outside -the production tree after repeated MPNet 2-bit regressions; it is not packaged. +encoding never converts to FP16. Code selection and landmark correction stay in +Java. The optional library retains its existing package/resource name for compatibility. Both FP32 and FP16 loaders validate ABI 1, handle missing/denied/unsupported @@ -271,8 +243,8 @@ with ABI 1 but without the FP32 symbol also keeps Highway. Unsupported oneDNN FP32 execution falls back to Highway batch projection; invalid arguments, allocation failures and unexpected runtime errors propagate. FP16 additionally checks representable range, and explicit `fp16` selection requires support. -No production selection flag, benchmark-specific path or BenchYAML change was -added. The existing `-Dnative.onednn=true` build packages both operations. +There is no selection flag; the `-Dnative.onednn=true` build packages both +operations. BLAS kernels can use different FP32 reduction orders for different batch shapes. The 9-bit reconstruction check therefore uses its existing per-component code @@ -284,26 +256,9 @@ shapes, batch tails/counts above 32, immutable model copies and concurrent model switching. A direct ABI test requires successful oneDNN SGEMM, preventing a Highway fallback from passing as a oneDNN test. -### Repeated BenchYAML measurements - -Each case used three alternating matched control/candidate pairs with JDK 22, -CPUs 0-47 and NUMA interleave across nodes 0 and 1. Both variants include the same -backing-array residual-normalization improvement and Java code generator. Both -use C=1, ITQ, D/2, deferred parallel writing and NVQ reranking. Each process builds -fresh model/index caches; input/output copies, library/JIT first use and scratch -allocation are included in BenchYAML's existing `encoding_time_s` field. These -are `encodeAll()` times, not standalone `encode()` or query-throughput results. - -| Case | Highway median (range), s | oneDNN median (range), s | Ratio of medians | -| --- | ---: | ---: | ---: | -| mpnet-1m, 2-bit | 0.665 (0.622-0.665) | 0.589 (0.550-0.604) | 1.13x | -| openai-3072-1m, 4-bit | 4.055 (3.996-4.092) | 3.926 (3.913-3.955) | 1.03x | - -oneDNN was faster in each of the three matched pairs for both datasets. The -OpenAI3072 gain is modest; these measurements do not establish improvements for -all other datasets, code widths or hardware. Median graph recall@10 was 81.901% -versus 81.844% for MPNet and 79.280% versus 79.310% for OpenAI3072. Independent -parallel graph rebuilds vary and do not isolate an encoding-only recall effect. +In repeated BenchYAML construction runs, oneDNN FP32 encoding was 1.13x faster +than Highway for MPNet 1M at 2 bits and 1.03x faster for OpenAI-3072 1M at +4 bits, with equivalent graph recall. ## ITQ training workspaces diff --git a/jvector-examples/README.md b/jvector-examples/README.md index b7a8486bc..fa3872822 100644 --- a/jvector-examples/README.md +++ b/jvector-examples/README.md @@ -26,10 +26,10 @@ From the repository root, using JDK 22 or newer: ```bash mvn package -DskipTests java --add-modules=jdk.incubator.vector --enable-native-access=ALL-UNNAMED -Xmx8g \ - -cp jvector-examples/target/jvector-examples-4.0.3-SNAPSHOT-jar-with-dependencies.jar \ + -cp jvector-examples/target/jvector-examples-4.1.0-SNAPSHOT-jar-with-dependencies.jar \ io.github.jbellis.jvector.example.HelloASHWorldInt8 java --add-modules=jdk.incubator.vector --enable-native-access=ALL-UNNAMED -Xmx8g \ - -cp jvector-examples/target/jvector-examples-4.0.3-SNAPSHOT-jar-with-dependencies.jar \ + -cp jvector-examples/target/jvector-examples-4.1.0-SNAPSHOT-jar-with-dependencies.jar \ io.github.jbellis.jvector.example.HelloASHWorldFp16 ``` @@ -72,7 +72,7 @@ and vector ownership; the second trains and encodes with PQ and NVQ. See source lifecycle, and integration guidance. ### SiftSmall -A simple benchmark for the sift dataset located in the [siftsmall](./siftsmall) directory in the project root. +A simple benchmark for the sift dataset located in the [siftsmall](../siftsmall) directory in the project root. > `mvn compile exec:exec@sift` diff --git a/jvector-examples/yaml-configs/index-parameters/studies/README.md b/jvector-examples/yaml-configs/index-parameters/studies/README.md index 66aebe99f..ec284556c 100644 --- a/jvector-examples/yaml-configs/index-parameters/studies/README.md +++ b/jvector-examples/yaml-configs/index-parameters/studies/README.md @@ -1,5 +1,11 @@ # Studies -Checked-in recipes for published comparisons. Run them with the study's own scripts so settings, repetitions and reporting match the published protocol. +Recipes for published comparisons. Run them with the study's scripts so settings, +repetitions and reporting match the published protocol. -- [ash-vs-pq](ash-vs-pq): matched ASH and PQ code budgets. See [docs/ash-pq-benchmark.md](../../../../docs/ash-pq-benchmark.md). +- [ash-vs-pq](ash-vs-pq): ASH and PQ at matched code sizes on 1M and 6M/10M + datasets. See [Reproducing ASH versus PQ](../../../../docs/ash-pq-benchmark.md) + and the results in [ASH performance](../../../../docs/ash-performance.md). +- [ash-vs-pq-single-jvm](ash-vs-pq-single-jvm): the MPNet 10M 2-bit ASH case in + one JVM, building once and querying with 1 and 8 workers, for the + [benchmark launcher](../../../bench-profiles/README.md). diff --git a/jvector-native/src/main/native/README.md b/jvector-native/src/main/native/README.md index 7f136e848..22daa74c0 100644 --- a/jvector-native/src/main/native/README.md +++ b/jvector-native/src/main/native/README.md @@ -164,8 +164,8 @@ The cross-compile file used for each direction lives in this directory: | File | Direction | |------|-----------| -| [`aarch64-cross.ini`](../aarch64-cross.ini) | x86_64 host → aarch64 target | -| [`x86_64-cross.ini`](../x86_64-cross.ini) | aarch64 host → x86_64 target | +| [`aarch64-cross.ini`](aarch64-cross.ini) | x86_64 host → aarch64 target | +| [`x86_64-cross.ini`](x86_64-cross.ini) | aarch64 host → x86_64 target | Meson reads the cross file (via `--cross-file`) and sets `host_machine.cpu_family` to the target arch, which causes `meson.build` to @@ -461,7 +461,7 @@ An unrecognised value is silently ignored and full CPU detection is used. ## Adding a new SIMD kernel The kernel registry is driven by the X-macro file -[`jvector_simd_kernel_list.h`](jvector_simd_kernel_list.h). A single entry +[`jvector_simd_kernel_list.h`](src/jvector_simd_kernel_list.h). A single entry there auto-generates: - the public C declaration in `jvector_simd.h` (exported to Java via FFM) @@ -706,7 +706,7 @@ if (max_isa != MaxIsa::SSE42 && max_isa != MaxIsa::AVX2 } ``` -If the required CPUID bits are not yet in [`jvector_cpu_features.h`](jvector_cpu_features.h), +If the required CPUID bits are not yet in [`jvector_cpu_features.h`](src/jvector_cpu_features.h), add them to the `CpuFeature` enum and populate them in `populate_cpu_features()`. @@ -736,7 +736,7 @@ HWY_FLATTEN float my_kernel(const float *a, size_t length) Add a `JV_REQUIRE_HWY_*` guard so the build fails loudly if the compiler selects the wrong Highway target for this translation unit. The existing checks for -`JV_REQUIRE_HWY_AVX3` and `JV_REQUIRE_HWY_AVX2` in [`assert_hwy_targets.h`](assert_hwy_targets.h) +`JV_REQUIRE_HWY_AVX3` and `JV_REQUIRE_HWY_AVX2` in [`assert_hwy_targets.h`](src/assert_hwy_targets.h) show the pattern — add an `#elif` block for the new tier: ```cpp @@ -1006,7 +1006,7 @@ retaining a singleton path. Applications continue to issue synchronous searches; no query executor or nested native thread pool is created. Base-vector encoding and training remain FP32. -Database developers should follow [ASH query callers and CPU budgeting](../../../../docs/ash-query-integration.md) +Database developers should follow [Integrating ASH queries into a database](../../../../docs/ash-query-integration.md) for caller concurrency, bounded admission, resource ownership and latency validation. The [maintainer design](../../../../docs/ash-query-batching-design.md) describes coordination separately. Native batch entry points accept counts above From dbe74d14022a789450f01f7f11ea6f62baa091a4 Mon Sep 17 00:00:00 2001 From: Ted Willke Date: Mon, 9 Nov 2026 16:09:55 -0800 Subject: [PATCH 182/243] Use ASH with main's graph settings in the dataset defaults Compressed dataset defaults use fused ASH at 32x (b=2, ITQ, one landmark) with main's graph settings: degree 32, efConstruction 100, overflow 1.2, hierarchy and refinement on, NVQ reranking. Uncompressed defaults store NVQ as on main. Search pruning is off everywhere, since it is disabled. Main's files keep their overquery lists and prefetch settings; new 6M and larger defaults query at overquery 1, like main's 10M files. --- .../yaml-configs/index-parameters/ada002-1M.yml | 5 ++--- .../yaml-configs/index-parameters/cap-1M.yml | 8 ++++---- .../yaml-configs/index-parameters/cap-6M.yml | 15 +++++++-------- .../yaml-configs/index-parameters/cohere-10M.yml | 15 +++++++-------- .../index-parameters/cohere-english-v3-1M.yml | 7 +++---- .../yaml-configs/index-parameters/colbert-1M.yml | 4 ++-- .../yaml-configs/index-parameters/default.yml | 6 +++--- .../index-parameters/dpr-gemma-10m.yml | 1 + .../index-parameters/dpr-gemma-1m.yml | 16 ++++++++-------- .../index-parameters/esmc-600-1m.yml | 15 +++++++-------- .../index-parameters/esmc-600-6m.yml | 15 +++++++-------- .../yaml-configs/index-parameters/gecko-100k.yml | 5 ++--- .../index-parameters/glove-100-angular.yml | 4 ++-- .../index-parameters/glove-200-angular.yml | 4 ++-- .../index-parameters/glove-25-angular.yml | 4 ++-- .../index-parameters/glove-50-angular.yml | 4 ++-- .../index-parameters/lastfm-64-dot.yml | 4 ++-- .../yaml-configs/index-parameters/mpnet-10m.yml | 6 +++--- .../yaml-configs/index-parameters/mpnet-1m.yml | 13 ++++++------- .../index-parameters/nytimes-256-angular.yml | 4 ++-- .../openai-v3-small-1536-100k.yml | 15 +++++++-------- .../index-parameters/personahub-1m.yml | 15 +++++++-------- .../index-parameters/sift-128-euclidean.yml | 4 ++-- .../index-parameters/tahoe-x1-1m.yml | 12 +++++------- 24 files changed, 95 insertions(+), 106 deletions(-) diff --git a/jvector-examples/yaml-configs/index-parameters/ada002-1M.yml b/jvector-examples/yaml-configs/index-parameters/ada002-1M.yml index 4eb1e418a..d75d633b0 100644 --- a/jvector-examples/yaml-configs/index-parameters/ada002-1M.yml +++ b/jvector-examples/yaml-configs/index-parameters/ada002-1M.yml @@ -6,8 +6,8 @@ dataset: ada002-1M construction: type: onDisk - outDegree: [64] - efConstruction: [200] + outDegree: [32] + efConstruction: [100] neighborOverflow: [1.2f] addHierarchy: [Yes] refineFinalGraph: [Yes] @@ -32,7 +32,6 @@ construction: search: topKOverquery: 10: [1.0, 2.0, 5.0, 10.0] -# 100: [1.0, 2.0] useSearchPruning: [No] compression: - type: ASH diff --git a/jvector-examples/yaml-configs/index-parameters/cap-1M.yml b/jvector-examples/yaml-configs/index-parameters/cap-1M.yml index 6158f8dee..2d58190c3 100644 --- a/jvector-examples/yaml-configs/index-parameters/cap-1M.yml +++ b/jvector-examples/yaml-configs/index-parameters/cap-1M.yml @@ -3,11 +3,12 @@ yamlSchemaVersion: 3 onDiskIndexVersion: 7 dataset: cap-1M +useDatasetPrefetching: false construction: type: onDisk - outDegree: [64] - efConstruction: [200] + outDegree: [32] + efConstruction: [100] neighborOverflow: [1.2f] addHierarchy: [Yes] refineFinalGraph: [Yes] @@ -31,8 +32,7 @@ construction: search: topKOverquery: - 10: [1.0, 2.0, 5.0, 10.0] -# 100: [1.0, 2.0] + 10: [1.0] useSearchPruning: [No] compression: - type: ASH diff --git a/jvector-examples/yaml-configs/index-parameters/cap-6M.yml b/jvector-examples/yaml-configs/index-parameters/cap-6M.yml index a3ff0a12d..33b246ed3 100644 --- a/jvector-examples/yaml-configs/index-parameters/cap-6M.yml +++ b/jvector-examples/yaml-configs/index-parameters/cap-6M.yml @@ -5,12 +5,12 @@ dataset: cap-6M construction: type: onDisk - outDegree: [64] - efConstruction: [200] - neighborOverflow: [1.6f] - addHierarchy: [No] + outDegree: [32] + efConstruction: [100] + neighborOverflow: [1.2f] + addHierarchy: [Yes] refineFinalGraph: [Yes] - fusedGraph: [No] + fusedGraph: [Yes] compression: - type: ASH parameters: @@ -25,13 +25,12 @@ construction: # k: 256 # anisotropicThreshold: -1.0 reranking: - type: FP32 + type: NVQ useSavedIndexIfExists: No # If yes, the system will attempt to locate a cached copy of the index instead of constructing search: topKOverquery: - 10: [1.00, 1.25, 1.56, 1.95, 2.44, 3.05, 3.81, 4.77, 5.96, 7.45, 9.31, 11.64] -# 100: [1.0] + 10: [1.0] useSearchPruning: [No] compression: - type: ASH diff --git a/jvector-examples/yaml-configs/index-parameters/cohere-10M.yml b/jvector-examples/yaml-configs/index-parameters/cohere-10M.yml index ab836ff70..76a8a78f2 100644 --- a/jvector-examples/yaml-configs/index-parameters/cohere-10M.yml +++ b/jvector-examples/yaml-configs/index-parameters/cohere-10M.yml @@ -5,12 +5,12 @@ dataset: cohere-10M construction: type: onDisk - outDegree: [64] - efConstruction: [200] - neighborOverflow: [1.6f] - addHierarchy: [No] + outDegree: [32] + efConstruction: [100] + neighborOverflow: [1.2f] + addHierarchy: [Yes] refineFinalGraph: [Yes] - fusedGraph: [No] + fusedGraph: [Yes] compression: - type: ASH parameters: @@ -25,13 +25,12 @@ construction: # k: 256 # anisotropicThreshold: -1.0 reranking: - type: FP32 + type: NVQ useSavedIndexIfExists: No # If yes, the system will attempt to locate a cached copy of the index instead of constructing search: topKOverquery: - 10: [1.00, 1.25, 1.56, 1.95, 2.44, 3.05, 3.81, 4.77, 5.96, 7.45, 9.31, 11.64] -# 100: [1.0] + 10: [1.0] useSearchPruning: [No] compression: - type: ASH diff --git a/jvector-examples/yaml-configs/index-parameters/cohere-english-v3-1M.yml b/jvector-examples/yaml-configs/index-parameters/cohere-english-v3-1M.yml index 8611fca4f..f63c37213 100644 --- a/jvector-examples/yaml-configs/index-parameters/cohere-english-v3-1M.yml +++ b/jvector-examples/yaml-configs/index-parameters/cohere-english-v3-1M.yml @@ -7,8 +7,8 @@ useDatasetPrefetching: false construction: type: onDisk - outDegree: [64] - efConstruction: [200] + outDegree: [32] + efConstruction: [100] neighborOverflow: [1.2f] addHierarchy: [Yes] refineFinalGraph: [Yes] @@ -32,8 +32,7 @@ construction: search: topKOverquery: - 10: [1.0, 2.0, 5.0, 10.0] -# 100: [1.0, 2.0] + 10: [1.0] useSearchPruning: [No] compression: - type: ASH diff --git a/jvector-examples/yaml-configs/index-parameters/colbert-1M.yml b/jvector-examples/yaml-configs/index-parameters/colbert-1M.yml index df5049fa5..2b312d9f9 100644 --- a/jvector-examples/yaml-configs/index-parameters/colbert-1M.yml +++ b/jvector-examples/yaml-configs/index-parameters/colbert-1M.yml @@ -9,7 +9,7 @@ construction: outDegree: [32] efConstruction: [100] neighborOverflow: [1.2f] - addHierarchy: [No] + addHierarchy: [Yes] refineFinalGraph: [Yes] fusedGraph: [No] compression: @@ -28,7 +28,7 @@ construction: # k: 256 # anisotropicThreshold: -1.0 reranking: - type: None + type: NVQ useSavedIndexIfExists: No search: diff --git a/jvector-examples/yaml-configs/index-parameters/default.yml b/jvector-examples/yaml-configs/index-parameters/default.yml index 8ae59d930..122b5b531 100644 --- a/jvector-examples/yaml-configs/index-parameters/default.yml +++ b/jvector-examples/yaml-configs/index-parameters/default.yml @@ -11,8 +11,8 @@ construction: # type: inMemory # Omit writerMode, fusedGraph, and useSavedIndexIfExists. # writerMode: parallel # Default for onDisk. # writerMode: sequential - outDegree: [64] - efConstruction: [200] + outDegree: [32] + efConstruction: [100] neighborOverflow: [1.2f] addHierarchy: [Yes] refineFinalGraph: [Yes] @@ -50,7 +50,7 @@ construction: search: topKOverquery: 10: [1.0, 2.0, 5.0, 10.0] -# 100: [1.0, 2.0] + 100: [1.0, 2.0] useSearchPruning: [No] compression: - type: ASH diff --git a/jvector-examples/yaml-configs/index-parameters/dpr-gemma-10m.yml b/jvector-examples/yaml-configs/index-parameters/dpr-gemma-10m.yml index 1b4b055ca..12e314013 100644 --- a/jvector-examples/yaml-configs/index-parameters/dpr-gemma-10m.yml +++ b/jvector-examples/yaml-configs/index-parameters/dpr-gemma-10m.yml @@ -2,6 +2,7 @@ yamlSchemaVersion: 3 onDiskIndexVersion: 7 dataset: dpr-gemma-10m +useDatasetPrefetching: false construction: type: onDisk diff --git a/jvector-examples/yaml-configs/index-parameters/dpr-gemma-1m.yml b/jvector-examples/yaml-configs/index-parameters/dpr-gemma-1m.yml index 59d87db33..d3be708e2 100644 --- a/jvector-examples/yaml-configs/index-parameters/dpr-gemma-1m.yml +++ b/jvector-examples/yaml-configs/index-parameters/dpr-gemma-1m.yml @@ -2,15 +2,16 @@ yamlSchemaVersion: 3 onDiskIndexVersion: 7 dataset: dpr-gemma-1m +useDatasetPrefetching: false construction: type: onDisk - outDegree: [64] - efConstruction: [200] - neighborOverflow: [1.6f] - addHierarchy: [No] + outDegree: [32] + efConstruction: [100] + neighborOverflow: [1.2f] + addHierarchy: [Yes] refineFinalGraph: [Yes] - fusedGraph: [No] + fusedGraph: [Yes] compression: - type: ASH parameters: @@ -25,13 +26,12 @@ construction: # k: 256 # anisotropicThreshold: -1.0 reranking: - type: FP32 + type: NVQ useSavedIndexIfExists: No # If yes, the system will attempt to locate a cached copy of the index instead of constructing search: topKOverquery: - 10: [1.00] -# 100: [1.0] + 10: [1.0] useSearchPruning: [No] compression: - type: ASH diff --git a/jvector-examples/yaml-configs/index-parameters/esmc-600-1m.yml b/jvector-examples/yaml-configs/index-parameters/esmc-600-1m.yml index 3bd50bd3e..fe8f3de59 100644 --- a/jvector-examples/yaml-configs/index-parameters/esmc-600-1m.yml +++ b/jvector-examples/yaml-configs/index-parameters/esmc-600-1m.yml @@ -5,12 +5,12 @@ dataset: esmc-600-1m construction: type: onDisk - outDegree: [64] - efConstruction: [200] - neighborOverflow: [1.6f] - addHierarchy: [No] + outDegree: [32] + efConstruction: [100] + neighborOverflow: [1.2f] + addHierarchy: [Yes] refineFinalGraph: [Yes] - fusedGraph: [No] + fusedGraph: [Yes] compression: - type: ASH parameters: @@ -25,13 +25,12 @@ construction: # k: 256 # anisotropicThreshold: -1.0 reranking: - type: FP32 + type: NVQ useSavedIndexIfExists: No # If yes, the system will attempt to locate a cached copy of the index instead of constructing search: topKOverquery: - 10: [1.00, 1.25, 1.56, 1.95, 2.44, 3.05, 3.81, 4.77, 5.96, 7.45, 9.31, 11.64] -# 100: [1.0] + 10: [1.0, 2.0, 5.0, 10.0] useSearchPruning: [No] compression: - type: ASH diff --git a/jvector-examples/yaml-configs/index-parameters/esmc-600-6m.yml b/jvector-examples/yaml-configs/index-parameters/esmc-600-6m.yml index 994207e9f..2d1298464 100644 --- a/jvector-examples/yaml-configs/index-parameters/esmc-600-6m.yml +++ b/jvector-examples/yaml-configs/index-parameters/esmc-600-6m.yml @@ -5,12 +5,12 @@ dataset: esmc-600-6m construction: type: onDisk - outDegree: [64] - efConstruction: [200] - neighborOverflow: [1.6f] - addHierarchy: [No] + outDegree: [32] + efConstruction: [100] + neighborOverflow: [1.2f] + addHierarchy: [Yes] refineFinalGraph: [Yes] - fusedGraph: [No] + fusedGraph: [Yes] compression: - type: ASH parameters: @@ -25,13 +25,12 @@ construction: # k: 256 # anisotropicThreshold: -1.0 reranking: - type: FP32 + type: NVQ useSavedIndexIfExists: No # If yes, the system will attempt to locate a cached copy of the index instead of constructing search: topKOverquery: - 10: [1.00, 1.25, 1.56, 1.95, 2.44, 3.05, 3.81, 4.77, 5.96, 7.45, 9.31, 11.64] -# 100: [1.0] + 10: [1.0] useSearchPruning: [No] compression: - type: ASH diff --git a/jvector-examples/yaml-configs/index-parameters/gecko-100k.yml b/jvector-examples/yaml-configs/index-parameters/gecko-100k.yml index fae9ff783..1f729599c 100644 --- a/jvector-examples/yaml-configs/index-parameters/gecko-100k.yml +++ b/jvector-examples/yaml-configs/index-parameters/gecko-100k.yml @@ -5,8 +5,8 @@ dataset: gecko-100k construction: type: onDisk - outDegree: [64] - efConstruction: [200] + outDegree: [32] + efConstruction: [100] neighborOverflow: [1.2f] addHierarchy: [Yes] refineFinalGraph: [Yes] @@ -31,7 +31,6 @@ construction: search: topKOverquery: 10: [1.0, 2.0, 5.0, 10.0] -# 100: [1.0, 2.0] useSearchPruning: [No] compression: - type: ASH diff --git a/jvector-examples/yaml-configs/index-parameters/glove-100-angular.yml b/jvector-examples/yaml-configs/index-parameters/glove-100-angular.yml index d3bf7db08..73786f909 100644 --- a/jvector-examples/yaml-configs/index-parameters/glove-100-angular.yml +++ b/jvector-examples/yaml-configs/index-parameters/glove-100-angular.yml @@ -10,7 +10,7 @@ construction: outDegree: [32] efConstruction: [100] neighborOverflow: [1.2f] - addHierarchy: [No] + addHierarchy: [Yes] refineFinalGraph: [Yes] fusedGraph: [No] compression: @@ -29,7 +29,7 @@ construction: # k: 256 # anisotropicThreshold: -1.0 reranking: - type: None + type: NVQ useSavedIndexIfExists: No search: diff --git a/jvector-examples/yaml-configs/index-parameters/glove-200-angular.yml b/jvector-examples/yaml-configs/index-parameters/glove-200-angular.yml index 0471e1284..47f834824 100644 --- a/jvector-examples/yaml-configs/index-parameters/glove-200-angular.yml +++ b/jvector-examples/yaml-configs/index-parameters/glove-200-angular.yml @@ -10,7 +10,7 @@ construction: outDegree: [32] efConstruction: [100] neighborOverflow: [1.2f] - addHierarchy: [No] + addHierarchy: [Yes] refineFinalGraph: [Yes] fusedGraph: [No] compression: @@ -29,7 +29,7 @@ construction: # k: 256 # anisotropicThreshold: -1.0 reranking: - type: None + type: NVQ useSavedIndexIfExists: No search: diff --git a/jvector-examples/yaml-configs/index-parameters/glove-25-angular.yml b/jvector-examples/yaml-configs/index-parameters/glove-25-angular.yml index de82a8a69..c382b8a8b 100644 --- a/jvector-examples/yaml-configs/index-parameters/glove-25-angular.yml +++ b/jvector-examples/yaml-configs/index-parameters/glove-25-angular.yml @@ -10,7 +10,7 @@ construction: outDegree: [32] efConstruction: [100] neighborOverflow: [1.2f] - addHierarchy: [No] + addHierarchy: [Yes] refineFinalGraph: [Yes] fusedGraph: [No] compression: @@ -29,7 +29,7 @@ construction: # k: 256 # anisotropicThreshold: -1.0 reranking: - type: None + type: NVQ useSavedIndexIfExists: No search: diff --git a/jvector-examples/yaml-configs/index-parameters/glove-50-angular.yml b/jvector-examples/yaml-configs/index-parameters/glove-50-angular.yml index 721ad09b3..2a0274b5d 100644 --- a/jvector-examples/yaml-configs/index-parameters/glove-50-angular.yml +++ b/jvector-examples/yaml-configs/index-parameters/glove-50-angular.yml @@ -10,7 +10,7 @@ construction: outDegree: [32] efConstruction: [100] neighborOverflow: [1.2f] - addHierarchy: [No] + addHierarchy: [Yes] refineFinalGraph: [Yes] fusedGraph: [No] compression: @@ -29,7 +29,7 @@ construction: # k: 256 # anisotropicThreshold: -1.0 reranking: - type: None + type: NVQ useSavedIndexIfExists: No search: diff --git a/jvector-examples/yaml-configs/index-parameters/lastfm-64-dot.yml b/jvector-examples/yaml-configs/index-parameters/lastfm-64-dot.yml index eab27443a..05a4c64a2 100644 --- a/jvector-examples/yaml-configs/index-parameters/lastfm-64-dot.yml +++ b/jvector-examples/yaml-configs/index-parameters/lastfm-64-dot.yml @@ -10,7 +10,7 @@ construction: outDegree: [32] efConstruction: [100] neighborOverflow: [1.2f] - addHierarchy: [No] + addHierarchy: [Yes] refineFinalGraph: [Yes] fusedGraph: [No] compression: @@ -29,7 +29,7 @@ construction: # k: 256 # anisotropicThreshold: -1.0 reranking: - type: None + type: NVQ useSavedIndexIfExists: No search: diff --git a/jvector-examples/yaml-configs/index-parameters/mpnet-10m.yml b/jvector-examples/yaml-configs/index-parameters/mpnet-10m.yml index 021f4adc4..887062150 100644 --- a/jvector-examples/yaml-configs/index-parameters/mpnet-10m.yml +++ b/jvector-examples/yaml-configs/index-parameters/mpnet-10m.yml @@ -15,7 +15,7 @@ construction: compression: - type: ASH parameters: - encodedBits: 808 + compressionRatio: 32 optimizer: ITQ landmarkCount: 1 bitsPerDimension: 2 @@ -27,12 +27,12 @@ construction: search: topKOverquery: - 10: [1.0, 2.0] + 10: [1.0] useSearchPruning: [No] compression: - type: ASH parameters: - encodedBits: 808 + compressionRatio: 32 optimizer: ITQ landmarkCount: 1 bitsPerDimension: 2 diff --git a/jvector-examples/yaml-configs/index-parameters/mpnet-1m.yml b/jvector-examples/yaml-configs/index-parameters/mpnet-1m.yml index 7d3657107..935b0a72f 100644 --- a/jvector-examples/yaml-configs/index-parameters/mpnet-1m.yml +++ b/jvector-examples/yaml-configs/index-parameters/mpnet-1m.yml @@ -5,12 +5,12 @@ dataset: mpnet-1m construction: type: onDisk - outDegree: [64] - efConstruction: [200] - neighborOverflow: [1.6f] + outDegree: [32] + efConstruction: [100] + neighborOverflow: [1.2f] addHierarchy: [Yes] refineFinalGraph: [Yes] - fusedGraph: [No] + fusedGraph: [Yes] compression: - type: ASH parameters: @@ -25,13 +25,12 @@ construction: # k: 256 # anisotropicThreshold: -1.0 reranking: - type: FP32 + type: NVQ useSavedIndexIfExists: No # If yes, the system will attempt to locate a cached copy of the index instead of constructing search: topKOverquery: - 10: [1.00, 1.25, 1.56, 1.95, 2.44, 3.05, 3.81, 4.77, 5.96, 7.45, 9.31, 11.64] -# 100: [1.0] + 10: [1.0, 2.0, 5.0, 10.0] useSearchPruning: [No] compression: - type: ASH diff --git a/jvector-examples/yaml-configs/index-parameters/nytimes-256-angular.yml b/jvector-examples/yaml-configs/index-parameters/nytimes-256-angular.yml index 7f9ed766f..006c414a3 100644 --- a/jvector-examples/yaml-configs/index-parameters/nytimes-256-angular.yml +++ b/jvector-examples/yaml-configs/index-parameters/nytimes-256-angular.yml @@ -10,7 +10,7 @@ construction: outDegree: [32] efConstruction: [100] neighborOverflow: [1.2f] - addHierarchy: [No] + addHierarchy: [Yes] refineFinalGraph: [Yes] fusedGraph: [No] compression: @@ -29,7 +29,7 @@ construction: # k: 256 # anisotropicThreshold: -1.0 reranking: - type: None + type: NVQ useSavedIndexIfExists: No search: diff --git a/jvector-examples/yaml-configs/index-parameters/openai-v3-small-1536-100k.yml b/jvector-examples/yaml-configs/index-parameters/openai-v3-small-1536-100k.yml index 9f6f2a05c..ad4d824bb 100644 --- a/jvector-examples/yaml-configs/index-parameters/openai-v3-small-1536-100k.yml +++ b/jvector-examples/yaml-configs/index-parameters/openai-v3-small-1536-100k.yml @@ -5,12 +5,12 @@ dataset: openai-v3-small-1536-100k construction: type: onDisk - outDegree: [64] - efConstruction: [200] - neighborOverflow: [1.6f] - addHierarchy: [No] + outDegree: [32] + efConstruction: [100] + neighborOverflow: [1.2f] + addHierarchy: [Yes] refineFinalGraph: [Yes] - fusedGraph: [No] + fusedGraph: [Yes] compression: - type: ASH parameters: @@ -25,13 +25,12 @@ construction: # k: 256 # anisotropicThreshold: -1.0 reranking: - type: FP32 + type: NVQ useSavedIndexIfExists: No # If yes, the system will attempt to locate a cached copy of the index instead of constructing search: topKOverquery: - 10: [1.00, 1.25, 1.56, 1.95, 2.44, 3.05, 3.81, 4.77, 5.96, 7.45, 9.31, 11.64] -# 100: [1.0] + 10: [1.0, 2.0, 5.0, 10.0] useSearchPruning: [No] compression: - type: ASH diff --git a/jvector-examples/yaml-configs/index-parameters/personahub-1m.yml b/jvector-examples/yaml-configs/index-parameters/personahub-1m.yml index 200c4622f..d5d8cba10 100644 --- a/jvector-examples/yaml-configs/index-parameters/personahub-1m.yml +++ b/jvector-examples/yaml-configs/index-parameters/personahub-1m.yml @@ -5,12 +5,12 @@ dataset: personahub-1m construction: type: onDisk - outDegree: [64] - efConstruction: [200] - neighborOverflow: [1.6f] - addHierarchy: [No] + outDegree: [32] + efConstruction: [100] + neighborOverflow: [1.2f] + addHierarchy: [Yes] refineFinalGraph: [Yes] - fusedGraph: [No] + fusedGraph: [Yes] compression: - type: ASH parameters: @@ -25,13 +25,12 @@ construction: # k: 256 # anisotropicThreshold: -1.0 reranking: - type: FP32 + type: NVQ useSavedIndexIfExists: No # If yes, the system will attempt to locate a cached copy of the index instead of constructing search: topKOverquery: - 10: [1.00, 1.25, 1.56, 1.95, 2.44, 3.05, 3.81, 4.77, 5.96, 7.45, 9.31, 11.64] -# 100: [1.0] + 10: [1.0, 2.0, 5.0, 10.0] useSearchPruning: [No] compression: - type: ASH diff --git a/jvector-examples/yaml-configs/index-parameters/sift-128-euclidean.yml b/jvector-examples/yaml-configs/index-parameters/sift-128-euclidean.yml index 44ce2626d..84035ca57 100644 --- a/jvector-examples/yaml-configs/index-parameters/sift-128-euclidean.yml +++ b/jvector-examples/yaml-configs/index-parameters/sift-128-euclidean.yml @@ -10,7 +10,7 @@ construction: outDegree: [32] efConstruction: [100] neighborOverflow: [1.2f] - addHierarchy: [No] + addHierarchy: [Yes] refineFinalGraph: [Yes] fusedGraph: [No] compression: @@ -22,7 +22,7 @@ construction: # k: 256 # anisotropicThreshold: -1.0 reranking: - type: None + type: NVQ useSavedIndexIfExists: No search: diff --git a/jvector-examples/yaml-configs/index-parameters/tahoe-x1-1m.yml b/jvector-examples/yaml-configs/index-parameters/tahoe-x1-1m.yml index 8caea1ed4..f02f79f9e 100644 --- a/jvector-examples/yaml-configs/index-parameters/tahoe-x1-1m.yml +++ b/jvector-examples/yaml-configs/index-parameters/tahoe-x1-1m.yml @@ -7,10 +7,10 @@ construction: type: onDisk outDegree: [32] efConstruction: [100] - neighborOverflow: [1.6f] - addHierarchy: [No] + neighborOverflow: [1.2f] + addHierarchy: [Yes] refineFinalGraph: [Yes] - fusedGraph: [No] + fusedGraph: [Yes] compression: - type: ASH parameters: @@ -25,14 +25,12 @@ construction: # k: 256 # anisotropicThreshold: -1.0 reranking: - type: FP32 + type: NVQ useSavedIndexIfExists: No # If yes, the system will attempt to locate a cached copy of the index instead of constructing search: topKOverquery: -# 10: [1.00, 1.25, 1.56, 1.95, 2.44, 3.05, 3.81, 4.77, 5.96, 7.45, 9.31, 11.64] - 10: [1.00, 1.25, 1.56, 1.95, 2.44] -# 100: [1.0, 1.25, 1.56, 1.95, 2.44] + 10: [1.0, 2.0, 5.0, 10.0] useSearchPruning: [No] compression: - type: ASH From 5bb6a9e63dab5911e9801b48c55dd69f8d4617e0 Mon Sep 17 00:00:00 2001 From: Ted Willke Date: Mon, 9 Nov 2026 16:10:45 -0800 Subject: [PATCH 183/243] Keep low-dimensional defaults unquantized and drop plot_output.py references The ann-benchmarks and ColBERT defaults are not quantized, so their files no longer list ASH or PQ alternatives. plot_output.py parsed the retired Bench console output and is unused, so the examples README no longer points to it. --- jvector-examples/README.md | 2 -- .../index-parameters/colbert-1M.yml | 26 ------------------- .../index-parameters/glove-100-angular.yml | 26 ------------------- .../index-parameters/glove-200-angular.yml | 26 ------------------- .../index-parameters/glove-25-angular.yml | 26 ------------------- .../index-parameters/glove-50-angular.yml | 26 ------------------- .../index-parameters/lastfm-64-dot.yml | 26 ------------------- .../index-parameters/nytimes-256-angular.yml | 26 ------------------- .../index-parameters/sift-128-euclidean.yml | 12 --------- 9 files changed, 196 deletions(-) diff --git a/jvector-examples/README.md b/jvector-examples/README.md index fa3872822..1cce8a61f 100644 --- a/jvector-examples/README.md +++ b/jvector-examples/README.md @@ -83,8 +83,6 @@ the best tradeoffs between recall and throughput. This benchmark requires `fvecs' versions of datasets from [https://github.com/erikbern/ann-benchmarks](https://github.com/erikbern/ann-benchmarks/blob/main/README.md#data-sets) to be downloaded to `dataset_cache` directory under the project root. -You can use [`plot_output.py`](./plot_output.py) to graph the [pareto-optimal points](https://en.wikipedia.org/wiki/Pareto_efficiency) found by `Bench`. - > `mvn compile exec:exec@bench` Some sample KNN datasets for testing based on ada-002 embeddings generated on wikipedia data are available in ivec/fvec format for testing at: diff --git a/jvector-examples/yaml-configs/index-parameters/colbert-1M.yml b/jvector-examples/yaml-configs/index-parameters/colbert-1M.yml index 2b312d9f9..0f1038d7a 100644 --- a/jvector-examples/yaml-configs/index-parameters/colbert-1M.yml +++ b/jvector-examples/yaml-configs/index-parameters/colbert-1M.yml @@ -14,19 +14,6 @@ construction: fusedGraph: [No] compression: - type: None -# To use ASH, comment out None in both sections. -# - type: ASH -# parameters: -# compressionRatio: 16 -# optimizer: ITQ -# landmarkCount: 1 -# bitsPerDimension: 2 -# To use PQ, comment out None in both sections. -# - type: PQ -# parameters: -# mFactor: 4 -# k: 256 -# anisotropicThreshold: -1.0 reranking: type: NVQ useSavedIndexIfExists: No @@ -38,16 +25,3 @@ search: useSearchPruning: [No] compression: - type: None -# To use ASH, comment out None in both sections. -# - type: ASH -# parameters: -# compressionRatio: 16 -# optimizer: ITQ -# landmarkCount: 1 -# bitsPerDimension: 2 -# To use PQ, comment out None in both sections. -# - type: PQ -# parameters: -# mFactor: 4 -# k: 256 -# anisotropicThreshold: -1.0 diff --git a/jvector-examples/yaml-configs/index-parameters/glove-100-angular.yml b/jvector-examples/yaml-configs/index-parameters/glove-100-angular.yml index 73786f909..ff0727ee2 100644 --- a/jvector-examples/yaml-configs/index-parameters/glove-100-angular.yml +++ b/jvector-examples/yaml-configs/index-parameters/glove-100-angular.yml @@ -15,19 +15,6 @@ construction: fusedGraph: [No] compression: - type: None -# To use ASH, comment out None in both sections. -# - type: ASH -# parameters: -# compressionRatio: 16 -# optimizer: ITQ -# landmarkCount: 1 -# bitsPerDimension: 2 -# To use PQ, comment out None in both sections. -# - type: PQ -# parameters: -# mFactor: 4 -# k: 256 -# anisotropicThreshold: -1.0 reranking: type: NVQ useSavedIndexIfExists: No @@ -39,16 +26,3 @@ search: useSearchPruning: [No] compression: - type: None -# To use ASH, comment out None in both sections. -# - type: ASH -# parameters: -# compressionRatio: 16 -# optimizer: ITQ -# landmarkCount: 1 -# bitsPerDimension: 2 -# To use PQ, comment out None in both sections. -# - type: PQ -# parameters: -# mFactor: 4 -# k: 256 -# anisotropicThreshold: -1.0 diff --git a/jvector-examples/yaml-configs/index-parameters/glove-200-angular.yml b/jvector-examples/yaml-configs/index-parameters/glove-200-angular.yml index 47f834824..da125ed27 100644 --- a/jvector-examples/yaml-configs/index-parameters/glove-200-angular.yml +++ b/jvector-examples/yaml-configs/index-parameters/glove-200-angular.yml @@ -15,19 +15,6 @@ construction: fusedGraph: [No] compression: - type: None -# To use ASH, comment out None in both sections. -# - type: ASH -# parameters: -# compressionRatio: 16 -# optimizer: ITQ -# landmarkCount: 1 -# bitsPerDimension: 2 -# To use PQ, comment out None in both sections. -# - type: PQ -# parameters: -# mFactor: 4 -# k: 256 -# anisotropicThreshold: -1.0 reranking: type: NVQ useSavedIndexIfExists: No @@ -39,16 +26,3 @@ search: useSearchPruning: [No] compression: - type: None -# To use ASH, comment out None in both sections. -# - type: ASH -# parameters: -# compressionRatio: 16 -# optimizer: ITQ -# landmarkCount: 1 -# bitsPerDimension: 2 -# To use PQ, comment out None in both sections. -# - type: PQ -# parameters: -# mFactor: 4 -# k: 256 -# anisotropicThreshold: -1.0 diff --git a/jvector-examples/yaml-configs/index-parameters/glove-25-angular.yml b/jvector-examples/yaml-configs/index-parameters/glove-25-angular.yml index c382b8a8b..4a57e451e 100644 --- a/jvector-examples/yaml-configs/index-parameters/glove-25-angular.yml +++ b/jvector-examples/yaml-configs/index-parameters/glove-25-angular.yml @@ -15,19 +15,6 @@ construction: fusedGraph: [No] compression: - type: None -# To use ASH, comment out None in both sections. -# - type: ASH -# parameters: -# compressionRatio: 16 -# optimizer: ITQ -# landmarkCount: 1 -# bitsPerDimension: 2 -# To use PQ, comment out None in both sections. -# - type: PQ -# parameters: -# mFactor: 4 -# k: 256 -# anisotropicThreshold: -1.0 reranking: type: NVQ useSavedIndexIfExists: No @@ -39,16 +26,3 @@ search: useSearchPruning: [No] compression: - type: None -# To use ASH, comment out None in both sections. -# - type: ASH -# parameters: -# compressionRatio: 16 -# optimizer: ITQ -# landmarkCount: 1 -# bitsPerDimension: 2 -# To use PQ, comment out None in both sections. -# - type: PQ -# parameters: -# mFactor: 4 -# k: 256 -# anisotropicThreshold: -1.0 diff --git a/jvector-examples/yaml-configs/index-parameters/glove-50-angular.yml b/jvector-examples/yaml-configs/index-parameters/glove-50-angular.yml index 2a0274b5d..cb22b1b9f 100644 --- a/jvector-examples/yaml-configs/index-parameters/glove-50-angular.yml +++ b/jvector-examples/yaml-configs/index-parameters/glove-50-angular.yml @@ -15,19 +15,6 @@ construction: fusedGraph: [No] compression: - type: None -# To use ASH, comment out None in both sections. -# - type: ASH -# parameters: -# compressionRatio: 16 -# optimizer: ITQ -# landmarkCount: 1 -# bitsPerDimension: 2 -# To use PQ, comment out None in both sections. -# - type: PQ -# parameters: -# mFactor: 4 -# k: 256 -# anisotropicThreshold: -1.0 reranking: type: NVQ useSavedIndexIfExists: No @@ -39,16 +26,3 @@ search: useSearchPruning: [No] compression: - type: None -# To use ASH, comment out None in both sections. -# - type: ASH -# parameters: -# compressionRatio: 16 -# optimizer: ITQ -# landmarkCount: 1 -# bitsPerDimension: 2 -# To use PQ, comment out None in both sections. -# - type: PQ -# parameters: -# mFactor: 4 -# k: 256 -# anisotropicThreshold: -1.0 diff --git a/jvector-examples/yaml-configs/index-parameters/lastfm-64-dot.yml b/jvector-examples/yaml-configs/index-parameters/lastfm-64-dot.yml index 05a4c64a2..5b00a76d3 100644 --- a/jvector-examples/yaml-configs/index-parameters/lastfm-64-dot.yml +++ b/jvector-examples/yaml-configs/index-parameters/lastfm-64-dot.yml @@ -15,19 +15,6 @@ construction: fusedGraph: [No] compression: - type: None -# To use ASH, comment out None in both sections. -# - type: ASH -# parameters: -# compressionRatio: 16 -# optimizer: ITQ -# landmarkCount: 1 -# bitsPerDimension: 2 -# To use PQ, comment out None in both sections. -# - type: PQ -# parameters: -# mFactor: 4 -# k: 256 -# anisotropicThreshold: -1.0 reranking: type: NVQ useSavedIndexIfExists: No @@ -39,16 +26,3 @@ search: useSearchPruning: [No] compression: - type: None -# To use ASH, comment out None in both sections. -# - type: ASH -# parameters: -# compressionRatio: 16 -# optimizer: ITQ -# landmarkCount: 1 -# bitsPerDimension: 2 -# To use PQ, comment out None in both sections. -# - type: PQ -# parameters: -# mFactor: 4 -# k: 256 -# anisotropicThreshold: -1.0 diff --git a/jvector-examples/yaml-configs/index-parameters/nytimes-256-angular.yml b/jvector-examples/yaml-configs/index-parameters/nytimes-256-angular.yml index 006c414a3..b344ace61 100644 --- a/jvector-examples/yaml-configs/index-parameters/nytimes-256-angular.yml +++ b/jvector-examples/yaml-configs/index-parameters/nytimes-256-angular.yml @@ -15,19 +15,6 @@ construction: fusedGraph: [No] compression: - type: None -# To use ASH, comment out None in both sections. -# - type: ASH -# parameters: -# compressionRatio: 16 -# optimizer: ITQ -# landmarkCount: 1 -# bitsPerDimension: 2 -# To use PQ, comment out None in both sections. -# - type: PQ -# parameters: -# mFactor: 4 -# k: 256 -# anisotropicThreshold: -1.0 reranking: type: NVQ useSavedIndexIfExists: No @@ -39,16 +26,3 @@ search: useSearchPruning: [No] compression: - type: None -# To use ASH, comment out None in both sections. -# - type: ASH -# parameters: -# compressionRatio: 16 -# optimizer: ITQ -# landmarkCount: 1 -# bitsPerDimension: 2 -# To use PQ, comment out None in both sections. -# - type: PQ -# parameters: -# mFactor: 4 -# k: 256 -# anisotropicThreshold: -1.0 diff --git a/jvector-examples/yaml-configs/index-parameters/sift-128-euclidean.yml b/jvector-examples/yaml-configs/index-parameters/sift-128-euclidean.yml index 84035ca57..a821b8eca 100644 --- a/jvector-examples/yaml-configs/index-parameters/sift-128-euclidean.yml +++ b/jvector-examples/yaml-configs/index-parameters/sift-128-euclidean.yml @@ -15,12 +15,6 @@ construction: fusedGraph: [No] compression: - type: None -# To use PQ, comment out None in both sections. -# - type: PQ -# parameters: -# mFactor: 4 -# k: 256 -# anisotropicThreshold: -1.0 reranking: type: NVQ useSavedIndexIfExists: No @@ -32,9 +26,3 @@ search: useSearchPruning: [No] compression: - type: None -# To use PQ, comment out None in both sections. -# - type: PQ -# parameters: -# mFactor: 4 -# k: 256 -# anisotropicThreshold: -1.0 From cab432e75d6e58f8e06a9a69b3e3251b3b24d8f8 Mon Sep 17 00:00:00 2001 From: Ted Willke Date: Mon, 9 Nov 2026 16:12:33 -0800 Subject: [PATCH 184/243] Keep uncompressed defaults scoring with source vectors Main searched uncompressed indexes with exact source vectors; its NVQ setting only wrote unused NVQ vectors. Schema 3 expresses that as reranking type None, which stores and scores the source vectors. --- jvector-examples/yaml-configs/index-parameters/colbert-1M.yml | 2 +- .../yaml-configs/index-parameters/glove-100-angular.yml | 2 +- .../yaml-configs/index-parameters/glove-200-angular.yml | 2 +- .../yaml-configs/index-parameters/glove-25-angular.yml | 2 +- .../yaml-configs/index-parameters/glove-50-angular.yml | 2 +- .../yaml-configs/index-parameters/lastfm-64-dot.yml | 2 +- .../yaml-configs/index-parameters/nytimes-256-angular.yml | 2 +- .../yaml-configs/index-parameters/sift-128-euclidean.yml | 2 +- 8 files changed, 8 insertions(+), 8 deletions(-) diff --git a/jvector-examples/yaml-configs/index-parameters/colbert-1M.yml b/jvector-examples/yaml-configs/index-parameters/colbert-1M.yml index 0f1038d7a..d14d806bb 100644 --- a/jvector-examples/yaml-configs/index-parameters/colbert-1M.yml +++ b/jvector-examples/yaml-configs/index-parameters/colbert-1M.yml @@ -15,7 +15,7 @@ construction: compression: - type: None reranking: - type: NVQ + type: None useSavedIndexIfExists: No search: diff --git a/jvector-examples/yaml-configs/index-parameters/glove-100-angular.yml b/jvector-examples/yaml-configs/index-parameters/glove-100-angular.yml index ff0727ee2..a930974da 100644 --- a/jvector-examples/yaml-configs/index-parameters/glove-100-angular.yml +++ b/jvector-examples/yaml-configs/index-parameters/glove-100-angular.yml @@ -16,7 +16,7 @@ construction: compression: - type: None reranking: - type: NVQ + type: None useSavedIndexIfExists: No search: diff --git a/jvector-examples/yaml-configs/index-parameters/glove-200-angular.yml b/jvector-examples/yaml-configs/index-parameters/glove-200-angular.yml index da125ed27..8df0496ac 100644 --- a/jvector-examples/yaml-configs/index-parameters/glove-200-angular.yml +++ b/jvector-examples/yaml-configs/index-parameters/glove-200-angular.yml @@ -16,7 +16,7 @@ construction: compression: - type: None reranking: - type: NVQ + type: None useSavedIndexIfExists: No search: diff --git a/jvector-examples/yaml-configs/index-parameters/glove-25-angular.yml b/jvector-examples/yaml-configs/index-parameters/glove-25-angular.yml index 4a57e451e..fa1a468be 100644 --- a/jvector-examples/yaml-configs/index-parameters/glove-25-angular.yml +++ b/jvector-examples/yaml-configs/index-parameters/glove-25-angular.yml @@ -16,7 +16,7 @@ construction: compression: - type: None reranking: - type: NVQ + type: None useSavedIndexIfExists: No search: diff --git a/jvector-examples/yaml-configs/index-parameters/glove-50-angular.yml b/jvector-examples/yaml-configs/index-parameters/glove-50-angular.yml index cb22b1b9f..6dd0775e6 100644 --- a/jvector-examples/yaml-configs/index-parameters/glove-50-angular.yml +++ b/jvector-examples/yaml-configs/index-parameters/glove-50-angular.yml @@ -16,7 +16,7 @@ construction: compression: - type: None reranking: - type: NVQ + type: None useSavedIndexIfExists: No search: diff --git a/jvector-examples/yaml-configs/index-parameters/lastfm-64-dot.yml b/jvector-examples/yaml-configs/index-parameters/lastfm-64-dot.yml index 5b00a76d3..e9507211a 100644 --- a/jvector-examples/yaml-configs/index-parameters/lastfm-64-dot.yml +++ b/jvector-examples/yaml-configs/index-parameters/lastfm-64-dot.yml @@ -16,7 +16,7 @@ construction: compression: - type: None reranking: - type: NVQ + type: None useSavedIndexIfExists: No search: diff --git a/jvector-examples/yaml-configs/index-parameters/nytimes-256-angular.yml b/jvector-examples/yaml-configs/index-parameters/nytimes-256-angular.yml index b344ace61..46fe062a6 100644 --- a/jvector-examples/yaml-configs/index-parameters/nytimes-256-angular.yml +++ b/jvector-examples/yaml-configs/index-parameters/nytimes-256-angular.yml @@ -16,7 +16,7 @@ construction: compression: - type: None reranking: - type: NVQ + type: None useSavedIndexIfExists: No search: diff --git a/jvector-examples/yaml-configs/index-parameters/sift-128-euclidean.yml b/jvector-examples/yaml-configs/index-parameters/sift-128-euclidean.yml index a821b8eca..f52bb4a1d 100644 --- a/jvector-examples/yaml-configs/index-parameters/sift-128-euclidean.yml +++ b/jvector-examples/yaml-configs/index-parameters/sift-128-euclidean.yml @@ -16,7 +16,7 @@ construction: compression: - type: None reranking: - type: NVQ + type: None useSavedIndexIfExists: No search: From a248c9b659e837e63b9d7211c2cc24eafadc8acb Mon Sep 17 00:00:00 2001 From: Ted Willke Date: Mon, 9 Nov 2026 16:13:36 -0800 Subject: [PATCH 185/243] Reject any reranker on full-precision search Search type None already scores with full-precision vectors, so NVQ reranking would only rescore with less precision. Schema 3 validation now rejects every reranker there, not only FP32. --- docs/benchyaml-guide.md | 4 ++-- .../jbellis/jvector/example/yaml/MultiConfig.java | 5 +++-- .../jvector/example/yaml/IndexParametersTest.java | 11 +++++++++++ 3 files changed, 16 insertions(+), 4 deletions(-) diff --git a/docs/benchyaml-guide.md b/docs/benchyaml-guide.md index d21745667..a5055fc77 100644 --- a/docs/benchyaml-guide.md +++ b/docs/benchyaml-guide.md @@ -129,8 +129,8 @@ selected scorer or layout requires a reranker, Grid throws an exception during configuration validation. The fused ASH and PQ scorer setup requires an FP32 or NVQ scoring feature. -Grid rejects settings that do not apply to the case and reranking that repeats -the traversal scorer with the same vectors and arithmetic. Explicit precision +Grid rejects settings that do not apply to the case. Search type `None` scores +with full-precision vectors, so it requires `reranking.type: None`. Explicit precision requests require support on the selected provider and machine. ## Quantizer parameters diff --git a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/yaml/MultiConfig.java b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/yaml/MultiConfig.java index f0baa557a..9d4dc5615 100644 --- a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/yaml/MultiConfig.java +++ b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/yaml/MultiConfig.java @@ -254,8 +254,9 @@ private void validateSchema3() { if (fused && !List.of("ASH", "PQ").contains(entry.type)) { throw new IllegalArgumentException("fusedGraph requires ASH or PQ search compression"); } - if ("None".equals(entry.type) && "FP32".equals(reranking)) { - throw new IllegalArgumentException("FP32 reranking repeats source-vector traversal scores. Use reranking.type None"); + if ("None".equals(entry.type) && !"None".equals(reranking)) { + throw new IllegalArgumentException("Search type None scores with full-precision vectors, so " + + reranking + " reranking does not apply. Use reranking.type None"); } } } diff --git a/jvector-examples/src/test/java/io/github/jbellis/jvector/example/yaml/IndexParametersTest.java b/jvector-examples/src/test/java/io/github/jbellis/jvector/example/yaml/IndexParametersTest.java index 120886b32..fd3115ad0 100644 --- a/jvector-examples/src/test/java/io/github/jbellis/jvector/example/yaml/IndexParametersTest.java +++ b/jvector-examples/src/test/java/io/github/jbellis/jvector/example/yaml/IndexParametersTest.java @@ -58,6 +58,17 @@ private File write(String name, String dataset) throws Exception { return file; } + @Test + public void fullPrecisionSearchRejectsAnyReranker() throws Exception { + for (var reranker : List.of("NVQ", "FP32")) { + var file = new File(directory.getRoot(), "rerank-" + reranker + ".yml"); + Files.writeString(file.toPath(), config("test").replace("reranking:\n type: None", "reranking:\n type: " + reranker)); + var e = assertThrows(IllegalArgumentException.class, () -> MultiConfig.getConfig(file.getPath())); + assertTrue(e.getMessage(), e.getMessage().endsWith("Search type None scores with full-precision vectors, so " + + reranker + " reranking does not apply. Use reranking.type None")); + } + } + @Test public void exactNameFindsTheDatasetDefault() throws Exception { write("mpnet-1m.yml", "mpnet-1m"); From 918d1aea3deadea017e0bb83b442a176508f15ce Mon Sep 17 00:00:00 2001 From: Ted Willke Date: Mon, 9 Nov 2026 16:24:47 -0800 Subject: [PATCH 186/243] Let the library set BLAS threads for ASH training Native BLAS libraries start a thread per CPU on first use, whatever the host's executors. ASH training now runs its BLAS and LAPACK calls in a BlasThreads scope: one thread by default, or the parallelism of the executor passed to a new AsymmetricHashing.initialize overload. The native provider applies the count per calling thread through MKL_Set_Num_Threads_Local or openblas_set_num_threads_local, or OpenBLAS's process-wide setting where that is all it has. BenchYAML passes its construction pool, and the one-JVM launcher no longer sets the BLAS thread variables. On the benchmark server with MKL, training without an executor starts no MKL threads, and with a 48-thread executor MKL starts 48. --- docs/ash-configuration.md | 27 +++- .../4.1.0/ash-blas-threads.feature.md | 14 ++ .../4.1.0/ash-query-batching.feature.md | 2 +- .../quantization/ASHCovariancePCA.java | 8 + .../quantization/ASHFloatOrthogonalizer.java | 8 + .../quantization/ASHOrthogonalizer.java | 8 + .../quantization/AsymmetricHashing.java | 37 +++++ .../jbellis/jvector/util/BlasThreads.java | 74 +++++++++ .../jvector/vector/VectorUtilSupport.java | 9 ++ jvector-examples/benchyaml-play.sh | 4 +- .../example/util/CompressorParameters.java | 8 +- .../jvector/vector/NativeBlasThreads.java | 105 +++++++++++++ .../vector/NativeVectorUtilSupport.java | 10 ++ .../jvector/vector/NativeBlasThreadsTest.java | 144 ++++++++++++++++++ .../jbellis/jvector/util/TestBlasThreads.java | 60 ++++++++ 15 files changed, 508 insertions(+), 10 deletions(-) create mode 100644 docs/release notes/4.1.0/ash-blas-threads.feature.md create mode 100644 jvector-base/src/main/java/io/github/jbellis/jvector/util/BlasThreads.java create mode 100644 jvector-native/src/main/java/io/github/jbellis/jvector/vector/NativeBlasThreads.java create mode 100644 jvector-native/src/test/java/io/github/jbellis/jvector/vector/NativeBlasThreadsTest.java create mode 100644 jvector-tests/src/test/java/io/github/jbellis/jvector/util/TestBlasThreads.java diff --git a/docs/ash-configuration.md b/docs/ash-configuration.md index ec475132d..1b7bbe9e0 100644 --- a/docs/ash-configuration.md +++ b/docs/ash-configuration.md @@ -135,13 +135,26 @@ precision. See the [precision guide](ash-projection-precision.md#benchyaml-runs) | `-Xms`, `-Xmx` | JVM ergonomics | Initial / maximum heap; account for resident input, model and construction memory. | | `-ea`, `-da` | Assertions normally disabled | Enable / disable Java assertions; independent of numerical precision. | -`MKL_NUM_THREADS`, `OMP_NUM_THREADS`, `OPENBLAS_NUM_THREADS` and -`GOTO_NUM_THREADS` are native-runtime environment controls, not ASH model -parameters. Avoid nested native query pools. The ASH versus PQ study scripts set them -to one for queries and the core budget for construction; it also sets -`MKL_DYNAMIC=FALSE` and `ONEDNN_VERBOSE=0`. OS affinity -(`numactl --physcpubind`) and memory placement (`--membind`, `--interleave`) -are deployment choices. +### BLAS threads + +ASH training is the only BLAS and LAPACK user. JVector sets the native BLAS +thread count for its own calls, so a host does not need to configure MKL or +OpenBLAS: training uses one BLAS thread, or as many as the executor passed to +`AsymmetricHashing.initialize(..., ForkJoinPool executor)`. Queries never call +BLAS. With the native provider, the count is applied per calling thread through +MKL's `MKL_Set_Num_Threads_Local` or OpenBLAS's `openblas_set_num_threads_local`; +OpenBLAS releases before 0.3.27 only have a process-wide setting, which JVector +sets for the duration of training. Other providers leave BLAS threading to +`MKL_NUM_THREADS`, `OMP_NUM_THREADS` and `OPENBLAS_NUM_THREADS`. +`io.github.jbellis.jvector.util.BlasThreads` applies the same limit to a host's +own BLAS work. + +BenchYAML trains in its construction pool, so BLAS uses `construction.workers` +threads (all physical cores by default). Set `MKL_DYNAMIC=FALSE` so MKL uses the +count it is given. The ASH versus PQ study scripts also set the thread variables, +to one for queries and the core budget for construction, as in the published +runs. OS affinity (`numactl --physcpubind`) and memory placement (`--membind`, +`--interleave`) are deployment choices. ## Graph and search parameters in BenchYAML diff --git a/docs/release notes/4.1.0/ash-blas-threads.feature.md b/docs/release notes/4.1.0/ash-blas-threads.feature.md new file mode 100644 index 000000000..3f9ddb444 --- /dev/null +++ b/docs/release notes/4.1.0/ash-blas-threads.feature.md @@ -0,0 +1,14 @@ +JVector now sets the native BLAS and LAPACK thread count for its own calls instead +of inheriting MKL's or OpenBLAS's default of one thread per CPU. ASH training uses +one BLAS thread, or as many as the executor passed to the new +`AsymmetricHashing.initialize(..., ForkJoinPool executor)` overload. With the native +provider, the limit is applied per calling thread through MKL or OpenBLAS, so a +database does not need to configure `MKL_NUM_THREADS`. `BlasThreads` applies the +same limit to other BLAS work. + +BenchYAML index YAML can set `construction.workers` and `search.workers`, each a +count or `max` for the platform's physical cores. A search worker sweep queries one +index. `benchyaml-play.sh` builds the checkout and runs one experiment in one JVM +from the command line or IntelliJ. + +See [ASH configuration and defaults](../../ash-configuration.md#blas-threads). diff --git a/docs/release notes/4.1.0/ash-query-batching.feature.md b/docs/release notes/4.1.0/ash-query-batching.feature.md index ddd5688ff..63afb8e67 100644 --- a/docs/release notes/4.1.0/ash-query-batching.feature.md +++ b/docs/release notes/4.1.0/ash-query-batching.feature.md @@ -10,4 +10,4 @@ paths, and source/concurrency metadata. The repository includes fixed fused PQ/A comparison configurations and standalone reproduction commands. See [query integration](../../ash-query-integration.md) and the -[BenchYAML reproduction guide](../../ash-pq-benchmark.md). +[Reproducing ASH versus PQ](../../ash-pq-benchmark.md). diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHCovariancePCA.java b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHCovariancePCA.java index 31118602d..7f60f4a6d 100644 --- a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHCovariancePCA.java +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHCovariancePCA.java @@ -20,6 +20,8 @@ import dev.ludovic.netlib.lapack.LAPACK; import org.netlib.util.intW; +import io.github.jbellis.jvector.util.BlasThreads; + import java.util.Objects; /** @@ -41,6 +43,12 @@ private ASHCovariancePCA() {} * reported rather than hidden by the fallback.

    */ static double[] projection(double[] x, int rows, int dims, int latent) { + try (var blas = BlasThreads.singleUnlessLimited()) { + return computeProjection(x, rows, dims, latent); + } + } + + private static double[] computeProjection(double[] x, int rows, int dims, int latent) { Objects.requireNonNull(x, "x"); if (rows <= 0 || dims <= 0 || latent <= 0 || latent > dims || (long) rows * dims > Integer.MAX_VALUE || (long) dims * dims > Integer.MAX_VALUE diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHFloatOrthogonalizer.java b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHFloatOrthogonalizer.java index 7a49c6f3e..467aba56d 100644 --- a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHFloatOrthogonalizer.java +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHFloatOrthogonalizer.java @@ -20,6 +20,8 @@ import dev.ludovic.netlib.lapack.LAPACK; import org.netlib.util.intW; +import io.github.jbellis.jvector.util.BlasThreads; + import java.util.Objects; /** @@ -82,6 +84,12 @@ final class ASHFloatOrthogonalizer { * including on failure; every successful call overwrites the whole output. */ void orthogonalize(double[] source, double[] destination) { + try (var blas = BlasThreads.singleUnlessLimited()) { + orthogonalizeInScope(source, destination); + } + } + + private void orthogonalizeInScope(double[] source, double[] destination) { Objects.requireNonNull(source, "source"); Objects.requireNonNull(destination, "destination"); if (source.length != elements || destination.length != elements || source == destination) { diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHOrthogonalizer.java b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHOrthogonalizer.java index 9d395a998..1fd33a490 100644 --- a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHOrthogonalizer.java +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHOrthogonalizer.java @@ -20,6 +20,8 @@ import dev.ludovic.netlib.lapack.LAPACK; import org.netlib.util.intW; +import io.github.jbellis.jvector.util.BlasThreads; + import java.util.Objects; /** @@ -75,6 +77,12 @@ final class ASHOrthogonalizer { * including on failure; every successful call overwrites the whole output. */ void orthogonalize(double[] source, double[] destination) { + try (var blas = BlasThreads.singleUnlessLimited()) { + orthogonalizeInScope(source, destination); + } + } + + private void orthogonalizeInScope(double[] source, double[] destination) { Objects.requireNonNull(source, "source"); Objects.requireNonNull(destination, "destination"); if (source.length != elements || destination.length != elements || source == destination) { diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/AsymmetricHashing.java b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/AsymmetricHashing.java index 653200ce4..f330130c0 100644 --- a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/AsymmetricHashing.java +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/AsymmetricHashing.java @@ -24,6 +24,7 @@ import io.github.jbellis.jvector.graph.disk.OnDiskGraphIndex; import io.github.jbellis.jvector.util.Accountable; import io.github.jbellis.jvector.util.CallerBatchProcessor; +import io.github.jbellis.jvector.util.BlasThreads; import io.github.jbellis.jvector.util.PhysicalCoreExecutor; import io.github.jbellis.jvector.util.RamUsageEstimator; import io.github.jbellis.jvector.vector.VectorUtil; @@ -305,6 +306,36 @@ public static AsymmetricHashing initialize(RandomAccessVectorValues ravv, int encodedBits, int landmarkCount, int bitsPerDimension) throws IOException { + try (var blas = BlasThreads.singleUnlessLimited()) { + return train(ravv, optimizer, encodedBits, landmarkCount, bitsPerDimension); + } + } + + /** + * Initialize ASH index-wide parameters, letting native BLAS and LAPACK use as many threads as + * {@code executor} has. Without an executor, training uses one BLAS thread (see + * {@link BlasThreads}); other training work uses the library's physical-core pool either way. + * + * @param executor the pool whose parallelism sets the BLAS thread count + * @see #initialize(RandomAccessVectorValues, int, int, int, int) + */ + public static AsymmetricHashing initialize(RandomAccessVectorValues ravv, + int optimizer, + int encodedBits, + int landmarkCount, + int bitsPerDimension, + ForkJoinPool executor) throws IOException { + Objects.requireNonNull(executor, "executor"); + try (var blas = BlasThreads.limit(Math.max(1, executor.getParallelism()))) { + return train(ravv, optimizer, encodedBits, landmarkCount, bitsPerDimension); + } + } + + private static AsymmetricHashing train(RandomAccessVectorValues ravv, + int optimizer, + int encodedBits, + int landmarkCount, + int bitsPerDimension) throws IOException { validateBitsPerDimension(bitsPerDimension); if (landmarkCount < 1 || landmarkCount > 256) { @@ -1521,6 +1552,12 @@ public StiefelTransform(RealMatrix W) { * @throws RuntimeException if the native SVD fails */ public static StiefelTransform runWithoutTraining(int D, int d, Random rng) { + try (var blas = BlasThreads.singleUnlessLimited()) { + return randomTransform(D, d, rng); + } + } + + private static StiefelTransform randomTransform(int D, int d, Random rng) { if (d <= 0 || d > D) { throw new IllegalArgumentException("Invalid d=" + d + " for D=" + D); } diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/util/BlasThreads.java b/jvector-base/src/main/java/io/github/jbellis/jvector/util/BlasThreads.java new file mode 100644 index 000000000..e85f2b93c --- /dev/null +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/util/BlasThreads.java @@ -0,0 +1,74 @@ +/* + * 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.util; + +import io.github.jbellis.jvector.vector.VectorizationProvider; + +/** + * Sets how many threads native BLAS and LAPACK may use for calls made on the current thread. + * + *

    Native BLAS libraries such as MKL and OpenBLAS otherwise start one thread per CPU on their + * first call, regardless of the host's own executors. JVector's BLAS calls therefore run inside a + * scope: one thread by default, or the size of the executor a caller passes to the operation. + * The native vectorization provider applies the limit through the library's per-thread setting + * (MKL {@code MKL_Set_Num_Threads_Local}, OpenBLAS {@code openblas_set_num_threads_local}), or, + * for OpenBLAS releases without it, the process-wide setting. Other providers leave BLAS threading + * to the library's own configuration, such as {@code MKL_NUM_THREADS}.

    + * + *

    Scopes nest: closing one restores the count that was in effect when it opened.

    + */ +public final class BlasThreads { + private static final ThreadLocal ACTIVE = new ThreadLocal<>(); + private static final Scope NONE = () -> { }; + + private BlasThreads() { + } + + /** Restores the previous BLAS thread count when closed. */ + public interface Scope extends AutoCloseable { + @Override + void close(); + } + + /** + * Uses {@code threads} BLAS threads for native calls on this thread until the scope closes. + * + * @throws IllegalArgumentException if {@code threads} is not positive + */ + public static Scope limit(int threads) { + if (threads < 1) throw new IllegalArgumentException("BLAS thread count must be positive, got " + threads); + var support = VectorizationProvider.getInstance().getVectorUtilSupport(); + Integer outer = ACTIVE.get(); + int previous = support.setBlasThreadsLocal(threads); + ACTIVE.set(threads); + return () -> { + if (previous >= 0) support.setBlasThreadsLocal(previous); + if (outer == null) ACTIVE.remove(); + else ACTIVE.set(outer); + }; + } + + /** One BLAS thread, unless an enclosing {@link #limit} scope on this thread already sets the count. */ + public static Scope singleUnlessLimited() { + return ACTIVE.get() != null ? NONE : limit(1); + } + + /** The count set by the innermost open scope on this thread, or 0 if none is open. */ + public static int current() { + Integer active = ACTIVE.get(); + return active == null ? 0 : active; + } +} diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/vector/VectorUtilSupport.java b/jvector-base/src/main/java/io/github/jbellis/jvector/vector/VectorUtilSupport.java index e16639a11..e4624e16d 100644 --- a/jvector-base/src/main/java/io/github/jbellis/jvector/vector/VectorUtilSupport.java +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/vector/VectorUtilSupport.java @@ -771,4 +771,13 @@ default float tryNvqGrowthRate(VectorFloat vector, float minValue, float maxV return Float.NaN; } + /** + * Sets the native BLAS thread count for calls made on the current thread, as + * {@link io.github.jbellis.jvector.util.BlasThreads} requests. Returns the setting to restore + * afterward, or -1 when this provider cannot control BLAS threads. + */ + default int setBlasThreadsLocal(int threads) { + return -1; + } + } diff --git a/jvector-examples/benchyaml-play.sh b/jvector-examples/benchyaml-play.sh index dac5359db..c0f9618c4 100755 --- a/jvector-examples/benchyaml-play.sh +++ b/jvector-examples/benchyaml-play.sh @@ -125,8 +125,10 @@ printf '\nRecipe %s\nCPUs %s (%s physical cores), memory interleaved on NU debug=() [[ -z ${BENCH_DEBUG:-} ]] || debug=("-agentlib:jdwp=transport=dt_socket,server=y,suspend=y,address=*:$BENCH_DEBUG") cd "$work" +# JVector sets BLAS threads for its own calls (one, or the construction pool's size), so the +# BLAS thread variables are cleared rather than set here. exec env -u JAVA_TOOL_OPTIONS -u JDK_JAVA_OPTIONS -u _JAVA_OPTIONS \ - MKL_NUM_THREADS="$cores" OMP_NUM_THREADS="$cores" OPENBLAS_NUM_THREADS="$cores" GOTO_NUM_THREADS="$cores" \ + -u MKL_NUM_THREADS -u OMP_NUM_THREADS -u OPENBLAS_NUM_THREADS -u GOTO_NUM_THREADS \ MKL_DYNAMIC=FALSE ONEDNN_VERBOSE=0 \ numactl --physcpubind="$cpus" --interleave="$nodes" "$jdk/bin/java" "${debug[@]}" \ --add-modules=jdk.incubator.vector --enable-native-access=ALL-UNNAMED -da -Xms2g "-Xmx${BENCH_HEAP:-128g}" \ diff --git a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/util/CompressorParameters.java b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/util/CompressorParameters.java index 40469c59f..cfab41f2d 100644 --- a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/util/CompressorParameters.java +++ b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/util/CompressorParameters.java @@ -23,9 +23,11 @@ import io.github.jbellis.jvector.quantization.NVQuantization; import io.github.jbellis.jvector.quantization.ProductQuantization; import io.github.jbellis.jvector.quantization.VectorCompressor; +import io.github.jbellis.jvector.util.PhysicalCoreExecutor; import io.github.jbellis.jvector.vector.VectorSimilarityFunction; import java.io.IOException; +import java.util.concurrent.ForkJoinTask; public abstract class CompressorParameters { public static final CompressorParameters NONE = new NoCompressionParameters(); @@ -204,12 +206,16 @@ public void validateFor(DataSet ds, boolean fused, String ashConstructionMode) { @Override public VectorCompressor computeCompressor(DataSet ds) { try { + // Native BLAS uses the construction pool's size when training runs in it, + // otherwise the library's physical-core pool. + var executor = ForkJoinTask.inForkJoinPool() ? ForkJoinTask.getPool() : PhysicalCoreExecutor.pool(); return AsymmetricHashing.initialize( ds.getBaseRavv(), optimizer, encodedBits, landmarkCount, - bitsPerDimension); + bitsPerDimension, + executor); } catch (IOException e) { throw new RuntimeException(e); } diff --git a/jvector-native/src/main/java/io/github/jbellis/jvector/vector/NativeBlasThreads.java b/jvector-native/src/main/java/io/github/jbellis/jvector/vector/NativeBlasThreads.java new file mode 100644 index 000000000..ee91640c1 --- /dev/null +++ b/jvector-native/src/main/java/io/github/jbellis/jvector/vector/NativeBlasThreads.java @@ -0,0 +1,105 @@ +/* + * 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 java.lang.foreign.Arena; +import java.lang.foreign.FunctionDescriptor; +import java.lang.foreign.Linker; +import java.lang.foreign.SymbolLookup; +import java.lang.foreign.ValueLayout; +import java.lang.invoke.MethodHandle; +import java.util.Optional; +import java.util.logging.Logger; + +/** + * Sets the BLAS thread count in the native library that netlib loads for JVector's BLAS and LAPACK + * calls: {@code dev.ludovic.netlib.blas.nativeLibPath}, else {@code dev.ludovic.netlib.blas.nativeLib}, + * else {@code libblas.so.3}. Opening it again returns the handle netlib uses. + */ +final class NativeBlasThreads { + private static final Logger LOG = Logger.getLogger(NativeBlasThreads.class.getName()); + private static final Control CONTROL = find(); + + private NativeBlasThreads() { + } + + /** Applies the count and returns the value to restore, or -1 when no supported library is loaded. */ + static int set(int threads) { + return CONTROL == null ? -1 : CONTROL.set(threads); + } + + /** The mechanism in use, for diagnostics. */ + static String description() { + return CONTROL == null ? "none" : CONTROL.name; + } + + private static final class Control { + final String name; + final MethodHandle setter; // int f(int) returning the previous value, or void f(int) + final MethodHandle getter; // int f(void), only for process-wide setters + final boolean global; + + Control(String name, MethodHandle setter, MethodHandle getter, boolean global) { + this.name = name; + this.setter = setter; + this.getter = getter; + this.global = global; + } + + int set(int threads) { + try { + if (!global) return (int) setter.invokeExact(threads); + int previous = (int) getter.invokeExact(); + if (previous != threads) setter.invokeExact(threads); + return previous; + } catch (Throwable t) { + throw new IllegalStateException("Setting " + name + " to " + threads + " failed", t); + } + } + } + + private static Control find() { + String path = System.getProperty("dev.ludovic.netlib.blas.nativeLibPath"); + String library = path != null ? path : System.getProperty("dev.ludovic.netlib.blas.nativeLib", "libblas.so.3"); + SymbolLookup lookup; + try { + lookup = SymbolLookup.libraryLookup(library, Arena.global()); + } catch (IllegalArgumentException | UnsupportedOperationException e) { + LOG.fine("No native BLAS library " + library + "; BLAS threads follow its own configuration."); + return null; + } + var linker = Linker.nativeLinker(); + var intToInt = FunctionDescriptor.of(ValueLayout.JAVA_INT, ValueLayout.JAVA_INT); + var intToVoid = FunctionDescriptor.ofVoid(ValueLayout.JAVA_INT); + var voidToInt = FunctionDescriptor.of(ValueLayout.JAVA_INT); + + // MKL's C interface is the mixed-case name; its lower- and upper-case exports are the + // Fortran interface, which takes a pointer. + Optional control = lookup.find("MKL_Set_Num_Threads_Local") + .map(s -> new Control("MKL thread-local threads", linker.downcallHandle(s, intToInt), null, false)) + .or(() -> lookup.find("openblas_set_num_threads_local") + .map(s -> new Control("OpenBLAS thread-local threads", linker.downcallHandle(s, intToInt), null, false))) + .or(() -> lookup.find("openblas_set_num_threads").flatMap(set -> lookup.find("openblas_get_num_threads") + .map(get -> new Control("OpenBLAS process-wide threads", + linker.downcallHandle(set, intToVoid), linker.downcallHandle(get, voidToInt), true)))); + if (control.isEmpty()) { + LOG.fine("Native BLAS library " + library + " has no thread-count setting; BLAS threads follow its own configuration."); + return null; + } + LOG.fine("BLAS threads are set through " + control.get().name + " in " + library); + return control.get(); + } +} 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 d6318792d..f3e6cdc5e 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 @@ -54,6 +54,16 @@ public String getActiveIsa() { .getString(0); } + @Override + public int setBlasThreadsLocal(int threads) { + return NativeBlasThreads.set(threads); + } + + /** How BLAS thread counts are applied: an MKL or OpenBLAS setting, or {@code none}. */ + public String getBlasThreadControl() { + return NativeBlasThreads.description(); + } + /** * Returns the value of {@code JVECTOR_MAX_ISA} that was in effect when the * native library was loaded, or {@code null} if the variable was absent or diff --git a/jvector-native/src/test/java/io/github/jbellis/jvector/vector/NativeBlasThreadsTest.java b/jvector-native/src/test/java/io/github/jbellis/jvector/vector/NativeBlasThreadsTest.java new file mode 100644 index 000000000..b946f7d6d --- /dev/null +++ b/jvector-native/src/test/java/io/github/jbellis/jvector/vector/NativeBlasThreadsTest.java @@ -0,0 +1,144 @@ +/* + * 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.graph.RandomAccessVectorValues; +import io.github.jbellis.jvector.quantization.AsymmetricHashing; +import io.github.jbellis.jvector.vector.types.VectorFloat; +import org.junit.jupiter.api.Test; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Random; +import java.util.concurrent.ForkJoinPool; +import java.util.concurrent.TimeUnit; +import java.util.regex.Pattern; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assumptions.assumeTrue; + +/** The native provider sets BLAS threads, and ASH training bounds them by its executor. */ +class NativeBlasThreadsTest { + private static boolean supported() { + return !"none".equals(NativeBlasThreads.description()); + } + + @Test + void setterReturnsTheValueToRestore() { + assumeTrue(supported(), "No native BLAS thread control on this host"); + int previous = NativeBlasThreads.set(3); + try { + assertEquals(3, NativeBlasThreads.set(previous), NativeBlasThreads.description()); + } finally { + NativeBlasThreads.set(previous); + } + } + + @Test + void trainingUsesOneBlasThreadWithoutAnExecutorAndTheExecutorSizeWithOne() throws Exception { + assumeTrue(supported() && Files.isDirectory(Path.of("/proc/self/task")), + "Needs native BLAS thread control and Linux thread accounting"); + int cores = Runtime.getRuntime().availableProcessors(); + assumeTrue(cores >= 16, "Needs enough CPUs to tell one BLAS thread from many"); + int single = nativeThreadsStarted("single"); + int pooled = nativeThreadsStarted("pooled"); + assertTrue(single <= 1, "Training without an executor started " + single + " native threads"); + assertTrue(pooled >= single + 8, "Training with a " + cores + "-thread executor started only " + + pooled + " native threads; the BLAS limit may not be applied"); + } + + /** Native-library threads a fresh JVM gains while training ASH with ITQ. */ + private static int nativeThreadsStarted(String mode) throws Exception { + var output = Files.createTempFile("blas-threads-", ".log"); + Process child = null; + try { + var builder = new ProcessBuilder(Path.of(System.getProperty("java.home"), "bin", "java").toString(), + "--add-modules=jdk.incubator.vector", "--enable-native-access=ALL-UNNAMED", + "-Djvector.vectorization_provider=native", "-cp", System.getProperty("java.class.path"), + TrainingProbe.class.getName(), mode) + .redirectErrorStream(true).redirectOutput(output.toFile()); + for (var name : new String[] {"MKL_NUM_THREADS", "OMP_NUM_THREADS", "OPENBLAS_NUM_THREADS", "GOTO_NUM_THREADS"}) { + builder.environment().remove(name); + } + child = builder.start(); + assertTrue(child.waitFor(120, TimeUnit.SECONDS), "Training probe timed out"); + String log = Files.readString(output); + assertEquals(0, child.exitValue(), log); + var match = Pattern.compile("native-threads-started=(\\d+)").matcher(log); + assertTrue(match.find(), log); + return Integer.parseInt(match.group(1)); + } finally { + if (child != null && child.isAlive()) child.destroyForcibly().waitFor(); + Files.deleteIfExists(output); + } + } + + public static final class TrainingProbe { + public static void main(String[] args) throws Exception { + var values = new Values(12_000, 512); + int before = nativeThreads(); + if ("pooled".equals(args[0])) { + var pool = new ForkJoinPool(Runtime.getRuntime().availableProcessors()); + AsymmetricHashing.initialize(values, AsymmetricHashing.ITQ, AsymmetricHashing.HEADER_BITS + 512, 1, 2, pool); + pool.shutdown(); + } else { + AsymmetricHashing.initialize(values, AsymmetricHashing.ITQ, AsymmetricHashing.HEADER_BITS + 512, 1, 2); + } + System.out.println("native-threads-started=" + Math.max(0, nativeThreads() - before)); + } + + /** + * Threads the JVM did not name. The JVM names its own and Java threads, so these are the + * threads native libraries such as MKL's OpenMP runtime start; they keep the process name. + */ + private static int nativeThreads() throws Exception { + int count = 0; + try (var tasks = Files.list(Path.of("/proc/self/task"))) { + for (var task : (Iterable) tasks::iterator) { + try { + if ("java".equals(Files.readString(task.resolve("comm")).trim())) count++; + } catch (java.nio.file.NoSuchFileException exited) { + // The thread ended while listing. + } + } + } + return count; + } + } + + private static final class Values implements RandomAccessVectorValues { + private final VectorFloat[] vectors; + + Values(int count, int dim) { + var types = VectorizationProvider.getInstance().getVectorTypeSupport(); + var random = new Random(7); + vectors = new VectorFloat[count]; + for (int i = 0; i < count; i++) { + float[] value = new float[dim]; + for (int j = 0; j < dim; j++) value[j] = (float) random.nextGaussian(); + vectors[i] = types.createFloatVector(value); + VectorUtil.l2normalize(vectors[i]); + } + } + + public int size() { return vectors.length; } + public int dimension() { return vectors[0].length(); } + public VectorFloat getVector(int i) { return vectors[i]; } + public boolean isValueShared() { return false; } + public RandomAccessVectorValues copy() { return this; } + } +} diff --git a/jvector-tests/src/test/java/io/github/jbellis/jvector/util/TestBlasThreads.java b/jvector-tests/src/test/java/io/github/jbellis/jvector/util/TestBlasThreads.java new file mode 100644 index 000000000..1f19c331b --- /dev/null +++ b/jvector-tests/src/test/java/io/github/jbellis/jvector/util/TestBlasThreads.java @@ -0,0 +1,60 @@ +/* + * 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.util; + +import org.junit.Test; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertThrows; + +/** BLAS thread scopes nest per thread and restore the enclosing count. */ +public class TestBlasThreads { + @Test + public void scopesNestAndRestore() { + assertEquals(0, BlasThreads.current()); + try (var outer = BlasThreads.limit(8)) { + assertEquals(8, BlasThreads.current()); + try (var single = BlasThreads.singleUnlessLimited()) { + assertEquals("An enclosing limit applies", 8, BlasThreads.current()); + } + try (var inner = BlasThreads.limit(2)) { + assertEquals(2, BlasThreads.current()); + } + assertEquals(8, BlasThreads.current()); + } + assertEquals(0, BlasThreads.current()); + try (var single = BlasThreads.singleUnlessLimited()) { + assertEquals(1, BlasThreads.current()); + } + assertEquals(0, BlasThreads.current()); + } + + @Test + public void scopesAreThreadLocal() throws Exception { + try (var scope = BlasThreads.limit(4)) { + int[] seen = new int[1]; + var other = new Thread(() -> seen[0] = BlasThreads.current()); + other.start(); + other.join(); + assertEquals(0, seen[0]); + } + } + + @Test + public void countMustBePositive() { + assertThrows(IllegalArgumentException.class, () -> BlasThreads.limit(0)); + } +} From 2ed32050148a0db678f2c8f68e71f9094a078cf9 Mon Sep 17 00:00:00 2001 From: Ted Willke Date: Mon, 9 Nov 2026 16:25:54 -0800 Subject: [PATCH 187/243] Add one-JVM recipes for every ASH vs PQ case Each of the 28 study cases now has a one-JVM form that builds with every physical core and queries the same index with 1 and 8 search workers, run by benchyaml-play.sh. The separate-JVM recipes and scripts remain as the protocol behind the published results. The study guide explains the difference: one-JVM query workers are not confined to their cores. --- docs/ash-pq-benchmark.md | 34 ++++++++++++-- jvector-examples/bench-profiles/README.md | 7 ++- .../index-parameters/studies/README.md | 17 +++---- .../ash-vs-pq-single-jvm/ada002-2bit-ash.yml | 46 +++++++++++++++++++ .../ash-vs-pq-single-jvm/ada002-2bit-pq.yml | 43 +++++++++++++++++ .../ash-vs-pq-single-jvm/ada002-4bit-ash.yml | 46 +++++++++++++++++++ .../ash-vs-pq-single-jvm/ada002-4bit-pq.yml | 43 +++++++++++++++++ .../ash-vs-pq-single-jvm/cap6m-2bit-ash.yml | 46 +++++++++++++++++++ .../ash-vs-pq-single-jvm/cap6m-2bit-pq.yml | 43 +++++++++++++++++ .../ash-vs-pq-single-jvm/cap6m-4bit-ash.yml | 46 +++++++++++++++++++ .../ash-vs-pq-single-jvm/cap6m-4bit-pq.yml | 43 +++++++++++++++++ .../ash-vs-pq-single-jvm/cohere-2bit-ash.yml | 46 +++++++++++++++++++ .../ash-vs-pq-single-jvm/cohere-2bit-pq.yml | 43 +++++++++++++++++ .../ash-vs-pq-single-jvm/cohere-4bit-ash.yml | 46 +++++++++++++++++++ .../ash-vs-pq-single-jvm/cohere-4bit-pq.yml | 43 +++++++++++++++++ .../cohere10m-2bit-ash.yml | 46 +++++++++++++++++++ .../cohere10m-2bit-pq.yml | 43 +++++++++++++++++ .../cohere10m-4bit-ash.yml | 46 +++++++++++++++++++ .../cohere10m-4bit-pq.yml | 43 +++++++++++++++++ .../ash-vs-pq-single-jvm/mpnet-2bit-ash.yml | 46 +++++++++++++++++++ .../ash-vs-pq-single-jvm/mpnet-2bit-pq.yml | 43 +++++++++++++++++ .../ash-vs-pq-single-jvm/mpnet-4bit-ash.yml | 46 +++++++++++++++++++ .../ash-vs-pq-single-jvm/mpnet-4bit-pq.yml | 43 +++++++++++++++++ .../ash-vs-pq-single-jvm/mpnet10m-2bit-pq.yml | 43 +++++++++++++++++ .../mpnet10m-4bit-ash.yml | 46 +++++++++++++++++++ .../ash-vs-pq-single-jvm/mpnet10m-4bit-pq.yml | 43 +++++++++++++++++ .../openai3072-2bit-ash.yml | 46 +++++++++++++++++++ .../openai3072-2bit-pq.yml | 43 +++++++++++++++++ .../openai3072-4bit-ash.yml | 46 +++++++++++++++++++ .../openai3072-4bit-pq.yml | 43 +++++++++++++++++ 30 files changed, 1244 insertions(+), 14 deletions(-) create mode 100644 jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq-single-jvm/ada002-2bit-ash.yml create mode 100644 jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq-single-jvm/ada002-2bit-pq.yml create mode 100644 jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq-single-jvm/ada002-4bit-ash.yml create mode 100644 jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq-single-jvm/ada002-4bit-pq.yml create mode 100644 jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq-single-jvm/cap6m-2bit-ash.yml create mode 100644 jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq-single-jvm/cap6m-2bit-pq.yml create mode 100644 jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq-single-jvm/cap6m-4bit-ash.yml create mode 100644 jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq-single-jvm/cap6m-4bit-pq.yml create mode 100644 jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq-single-jvm/cohere-2bit-ash.yml create mode 100644 jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq-single-jvm/cohere-2bit-pq.yml create mode 100644 jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq-single-jvm/cohere-4bit-ash.yml create mode 100644 jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq-single-jvm/cohere-4bit-pq.yml create mode 100644 jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq-single-jvm/cohere10m-2bit-ash.yml create mode 100644 jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq-single-jvm/cohere10m-2bit-pq.yml create mode 100644 jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq-single-jvm/cohere10m-4bit-ash.yml create mode 100644 jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq-single-jvm/cohere10m-4bit-pq.yml create mode 100644 jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq-single-jvm/mpnet-2bit-ash.yml create mode 100644 jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq-single-jvm/mpnet-2bit-pq.yml create mode 100644 jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq-single-jvm/mpnet-4bit-ash.yml create mode 100644 jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq-single-jvm/mpnet-4bit-pq.yml create mode 100644 jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq-single-jvm/mpnet10m-2bit-pq.yml create mode 100644 jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq-single-jvm/mpnet10m-4bit-ash.yml create mode 100644 jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq-single-jvm/mpnet10m-4bit-pq.yml create mode 100644 jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq-single-jvm/openai3072-2bit-ash.yml create mode 100644 jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq-single-jvm/openai3072-2bit-pq.yml create mode 100644 jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq-single-jvm/openai3072-4bit-ash.yml create mode 100644 jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq-single-jvm/openai3072-4bit-pq.yml diff --git a/docs/ash-pq-benchmark.md b/docs/ash-pq-benchmark.md index a806b659a..c80f3c80e 100644 --- a/docs/ash-pq-benchmark.md +++ b/docs/ash-pq-benchmark.md @@ -21,11 +21,14 @@ both store the same bytes per vector, including ASH's five-byte header. It uses ordinary packaged JVector code and `io.github.jbellis.jvector.example.BenchYAML`; no separate query harness, source overlay or profiling agent is involved. -The [index configurations](../jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq) -define each case. The [run configurations and dataset catalogs](../jvector-examples/yaml-configs/ash-pq) -define what is measured and where the input files are. Two scripts run them: -`run-ash-pq-benchmark.sh` runs one case in one JVM, and `run-ash-pq-study.py` -steps through a whole study. The [BenchYAML Guide](benchyaml-guide.md) describes +Each case comes in two forms. The published results used +[separate JVMs](../jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq) +for construction and for each query core budget, run by `run-ash-pq-benchmark.sh` +(one case) and `run-ash-pq-study.py` (a whole study). Future runs use +[one JVM per case](../jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq-single-jvm): +it builds the index once and queries it with 1 and 8 search workers. The +[run configurations and dataset catalogs](../jvector-examples/yaml-configs/ash-pq) +define what is measured and where the input files are. The [BenchYAML Guide](benchyaml-guide.md) describes the index configuration, and [ASH configuration and defaults](ash-configuration.md) describes model and runtime settings. @@ -158,6 +161,27 @@ environment, so the Java command can be rerun directly. The case directory holds copies of the catalog, dataset metadata and dataset list, because BenchYAML resolves them relative to its working directory. +## Run one case in one JVM + +`benchyaml-play.sh` builds the checkout, then runs one recipe in a single JVM on one +hardware thread of each physical core, with machine settings from a +[server profile](../jvector-examples/bench-profiles/README.md): + +```bash +BENCH_RUN_CONFIG=jvector-examples/yaml-configs/ash-pq/run-single-jvm.yml \ + jvector-examples/benchyaml-play.sh \ + jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq-single-jvm/mpnet10m-2bit-ash.yml +``` + +Each recipe sets `construction.workers: max` and `search.workers: [1, 8]`. +Construction, including BLAS during training, uses every physical core. Queries +then use one and eight search threads: direct PQ search runs on that many caller +threads, and queued ASH search on that many workers. All of the JVM's CPUs stay +available to other threads, such as garbage collection, so a 1-worker measurement +is not confined to one core as it is in the separate-JVM protocol. Compare +one-JVM results with each other, and check them against the published results +before replacing those. + ## Measurements and artifacts * **Construction:** BenchYAML's training, encoding, NVQ and graph-write timers; diff --git a/jvector-examples/bench-profiles/README.md b/jvector-examples/bench-profiles/README.md index 6c375f6fb..73071fd04 100644 --- a/jvector-examples/bench-profiles/README.md +++ b/jvector-examples/bench-profiles/README.md @@ -16,6 +16,8 @@ The index YAML sets concurrency: `construction.workers` for training, encoding, and writing, and `search.workers` for queries, each a number or `max` (the platform's physical cores). `search.workers` is a sweep, so `[1, 8]` queries the same index with 1 and then 8 workers. The launcher sets where the JVM may run, and the run YAML sets what is printed and logged. +JVector sets native BLAS threads itself (the construction pool's size during training), so the +launcher clears `MKL_NUM_THREADS` and the other BLAS thread variables. `BENCH_RUN_CONFIG` selects the run YAML (default `yaml-configs/run-config.yml`). `BENCH_DEBUG=` waits for a debugger on that port. @@ -36,4 +38,7 @@ by git because profiles hold machine paths. Each recipe runs in its own director ## IntelliJ The repository's `.run/` directory holds run configurations for experiments. Each names a recipe -and a run YAML, and takes machine settings from your profile. +and a run YAML, and takes machine settings from your profile. To add one for another recipe, +copy an existing file, then change its name and the recipe path in `SCRIPT_OPTIONS`; every case in +[ash-vs-pq-single-jvm](../yaml-configs/index-parameters/studies/ash-vs-pq-single-jvm) runs with +`run-single-jvm.yml`. diff --git a/jvector-examples/yaml-configs/index-parameters/studies/README.md b/jvector-examples/yaml-configs/index-parameters/studies/README.md index ec284556c..a42847097 100644 --- a/jvector-examples/yaml-configs/index-parameters/studies/README.md +++ b/jvector-examples/yaml-configs/index-parameters/studies/README.md @@ -1,11 +1,12 @@ # Studies -Recipes for published comparisons. Run them with the study's scripts so settings, -repetitions and reporting match the published protocol. +Recipes for published comparisons. See +[Reproducing ASH versus PQ](../../../../docs/ash-pq-benchmark.md) for how to run them, +and [ASH performance](../../../../docs/ash-performance.md) for the results. -- [ash-vs-pq](ash-vs-pq): ASH and PQ at matched code sizes on 1M and 6M/10M - datasets. See [Reproducing ASH versus PQ](../../../../docs/ash-pq-benchmark.md) - and the results in [ASH performance](../../../../docs/ash-performance.md). -- [ash-vs-pq-single-jvm](ash-vs-pq-single-jvm): the MPNet 10M 2-bit ASH case in - one JVM, building once and querying with 1 and 8 workers, for the - [benchmark launcher](../../../bench-profiles/README.md). +- [ash-vs-pq](ash-vs-pq): ASH and PQ at matched code sizes on 1M and 6M/10M datasets, + with separate JVMs for construction and each query core budget. This is the + protocol behind the published results; run it with the study's scripts. +- [ash-vs-pq-single-jvm](ash-vs-pq-single-jvm): the same cases, each in one JVM that + builds once and queries with 1 and 8 search workers. Run them with the + [benchmark launcher](../../../bench-profiles/README.md); future studies use this form. diff --git a/jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq-single-jvm/ada002-2bit-ash.yml b/jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq-single-jvm/ada002-2bit-ash.yml new file mode 100644 index 000000000..8eb3e1baf --- /dev/null +++ b/jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq-single-jvm/ada002-2bit-ash.yml @@ -0,0 +1,46 @@ +# ada002-1M: 1536 input dimensions, 197 code bytes per vector. +# The 2-bit label describes the ASH budget; PQ uses 197 8-bit subspaces. +# ASH uses C=1 and D/2 projected dimensions, including its five-byte header. +# One JVM per experiment: build with every physical core, then query the same index +# with 1 and 8 search workers. The settings otherwise match studies/ash-vs-pq. +yamlSchemaVersion: 3 +onDiskIndexVersion: 7 +dataset: ada002-1M + +construction: + type: onDisk + writerMode: parallel + workers: max + outDegree: [32] + efConstruction: [100] + neighborOverflow: [1.2f] + addHierarchy: [Yes] + refineFinalGraph: [Yes] + fusedGraph: [Yes] + compression: + - type: ASH + parameters: + encodedBits: 1576 + optimizer: ITQ + landmarkCount: 1 + bitsPerDimension: 2 + reranking: + type: NVQ + # One JVM builds and queries, so nothing needs to be saved for later JVMs. + useSavedIndexIfExists: No + +search: + topKOverquery: + 10: [1.0, 2.0] + useSearchPruning: [No] + workers: [1, 8] + compression: + - type: ASH + parameters: + queryProjection: auto + queryExecution: queued + maxBatchSize: 8 + encodedBits: 1576 + optimizer: ITQ + landmarkCount: 1 + bitsPerDimension: 2 diff --git a/jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq-single-jvm/ada002-2bit-pq.yml b/jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq-single-jvm/ada002-2bit-pq.yml new file mode 100644 index 000000000..4d37a27bb --- /dev/null +++ b/jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq-single-jvm/ada002-2bit-pq.yml @@ -0,0 +1,43 @@ +# ada002-1M: 1536 input dimensions, 197 code bytes per vector. +# The 2-bit label describes the ASH budget; PQ uses 197 8-bit subspaces. +# ASH uses C=1 and D/2 projected dimensions, including its five-byte header. +# One JVM per experiment: build with every physical core, then query the same index +# with 1 and 8 search workers. The settings otherwise match studies/ash-vs-pq. +yamlSchemaVersion: 3 +onDiskIndexVersion: 7 +dataset: ada002-1M + +construction: + type: onDisk + writerMode: parallel + workers: max + outDegree: [32] + efConstruction: [100] + neighborOverflow: [1.2f] + addHierarchy: [Yes] + refineFinalGraph: [Yes] + fusedGraph: [Yes] + compression: + - type: PQ + parameters: + m: 197 + k: 256 + centerData: "No" + anisotropicThreshold: -1.0 + reranking: + type: NVQ + # One JVM builds and queries, so nothing needs to be saved for later JVMs. + useSavedIndexIfExists: No + +search: + topKOverquery: + 10: [1.0, 2.0] + useSearchPruning: [No] + workers: [1, 8] + compression: + - type: PQ + parameters: + m: 197 + k: 256 + centerData: "No" + anisotropicThreshold: -1.0 diff --git a/jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq-single-jvm/ada002-4bit-ash.yml b/jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq-single-jvm/ada002-4bit-ash.yml new file mode 100644 index 000000000..ca2e4dee4 --- /dev/null +++ b/jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq-single-jvm/ada002-4bit-ash.yml @@ -0,0 +1,46 @@ +# ada002-1M: 1536 input dimensions, 389 code bytes per vector. +# The 4-bit label describes the ASH budget; PQ uses 389 8-bit subspaces. +# ASH uses C=1 and D/2 projected dimensions, including its five-byte header. +# One JVM per experiment: build with every physical core, then query the same index +# with 1 and 8 search workers. The settings otherwise match studies/ash-vs-pq. +yamlSchemaVersion: 3 +onDiskIndexVersion: 7 +dataset: ada002-1M + +construction: + type: onDisk + writerMode: parallel + workers: max + outDegree: [32] + efConstruction: [100] + neighborOverflow: [1.2f] + addHierarchy: [Yes] + refineFinalGraph: [Yes] + fusedGraph: [Yes] + compression: + - type: ASH + parameters: + encodedBits: 3112 + optimizer: ITQ + landmarkCount: 1 + bitsPerDimension: 4 + reranking: + type: NVQ + # One JVM builds and queries, so nothing needs to be saved for later JVMs. + useSavedIndexIfExists: No + +search: + topKOverquery: + 10: [1.0, 2.0] + useSearchPruning: [No] + workers: [1, 8] + compression: + - type: ASH + parameters: + queryProjection: auto + queryExecution: queued + maxBatchSize: 8 + encodedBits: 3112 + optimizer: ITQ + landmarkCount: 1 + bitsPerDimension: 4 diff --git a/jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq-single-jvm/ada002-4bit-pq.yml b/jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq-single-jvm/ada002-4bit-pq.yml new file mode 100644 index 000000000..dedfad158 --- /dev/null +++ b/jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq-single-jvm/ada002-4bit-pq.yml @@ -0,0 +1,43 @@ +# ada002-1M: 1536 input dimensions, 389 code bytes per vector. +# The 4-bit label describes the ASH budget; PQ uses 389 8-bit subspaces. +# ASH uses C=1 and D/2 projected dimensions, including its five-byte header. +# One JVM per experiment: build with every physical core, then query the same index +# with 1 and 8 search workers. The settings otherwise match studies/ash-vs-pq. +yamlSchemaVersion: 3 +onDiskIndexVersion: 7 +dataset: ada002-1M + +construction: + type: onDisk + writerMode: parallel + workers: max + outDegree: [32] + efConstruction: [100] + neighborOverflow: [1.2f] + addHierarchy: [Yes] + refineFinalGraph: [Yes] + fusedGraph: [Yes] + compression: + - type: PQ + parameters: + m: 389 + k: 256 + centerData: "No" + anisotropicThreshold: -1.0 + reranking: + type: NVQ + # One JVM builds and queries, so nothing needs to be saved for later JVMs. + useSavedIndexIfExists: No + +search: + topKOverquery: + 10: [1.0, 2.0] + useSearchPruning: [No] + workers: [1, 8] + compression: + - type: PQ + parameters: + m: 389 + k: 256 + centerData: "No" + anisotropicThreshold: -1.0 diff --git a/jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq-single-jvm/cap6m-2bit-ash.yml b/jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq-single-jvm/cap6m-2bit-ash.yml new file mode 100644 index 000000000..c8ce82c1c --- /dev/null +++ b/jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq-single-jvm/cap6m-2bit-ash.yml @@ -0,0 +1,46 @@ +# cap-6M: 1536 input dimensions, 197 code bytes per vector. +# The 2-bit label describes the ASH budget; PQ uses 197 8-bit subspaces. +# ASH uses C=1 and D/2 projected dimensions, including its five-byte header. +# One JVM per experiment: build with every physical core, then query the same index +# with 1 and 8 search workers. The settings otherwise match studies/ash-vs-pq. +yamlSchemaVersion: 3 +onDiskIndexVersion: 7 +dataset: cap-6M + +construction: + type: onDisk + writerMode: parallel + workers: max + outDegree: [32] + efConstruction: [100] + neighborOverflow: [1.2f] + addHierarchy: [Yes] + refineFinalGraph: [Yes] + fusedGraph: [Yes] + compression: + - type: ASH + parameters: + encodedBits: 1576 + optimizer: ITQ + landmarkCount: 1 + bitsPerDimension: 2 + reranking: + type: NVQ + # One JVM builds and queries, so nothing needs to be saved for later JVMs. + useSavedIndexIfExists: No + +search: + topKOverquery: + 10: [1.0, 2.0] + useSearchPruning: [No] + workers: [1, 8] + compression: + - type: ASH + parameters: + queryProjection: auto + queryExecution: queued + maxBatchSize: 8 + encodedBits: 1576 + optimizer: ITQ + landmarkCount: 1 + bitsPerDimension: 2 diff --git a/jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq-single-jvm/cap6m-2bit-pq.yml b/jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq-single-jvm/cap6m-2bit-pq.yml new file mode 100644 index 000000000..5ae84d9a6 --- /dev/null +++ b/jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq-single-jvm/cap6m-2bit-pq.yml @@ -0,0 +1,43 @@ +# cap-6M: 1536 input dimensions, 197 code bytes per vector. +# The 2-bit label describes the ASH budget; PQ uses 197 8-bit subspaces. +# ASH uses C=1 and D/2 projected dimensions, including its five-byte header. +# One JVM per experiment: build with every physical core, then query the same index +# with 1 and 8 search workers. The settings otherwise match studies/ash-vs-pq. +yamlSchemaVersion: 3 +onDiskIndexVersion: 7 +dataset: cap-6M + +construction: + type: onDisk + writerMode: parallel + workers: max + outDegree: [32] + efConstruction: [100] + neighborOverflow: [1.2f] + addHierarchy: [Yes] + refineFinalGraph: [Yes] + fusedGraph: [Yes] + compression: + - type: PQ + parameters: + m: 197 + k: 256 + centerData: "No" + anisotropicThreshold: -1.0 + reranking: + type: NVQ + # One JVM builds and queries, so nothing needs to be saved for later JVMs. + useSavedIndexIfExists: No + +search: + topKOverquery: + 10: [1.0, 2.0] + useSearchPruning: [No] + workers: [1, 8] + compression: + - type: PQ + parameters: + m: 197 + k: 256 + centerData: "No" + anisotropicThreshold: -1.0 diff --git a/jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq-single-jvm/cap6m-4bit-ash.yml b/jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq-single-jvm/cap6m-4bit-ash.yml new file mode 100644 index 000000000..378a0af94 --- /dev/null +++ b/jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq-single-jvm/cap6m-4bit-ash.yml @@ -0,0 +1,46 @@ +# cap-6M: 1536 input dimensions, 389 code bytes per vector. +# The 4-bit label describes the ASH budget; PQ uses 389 8-bit subspaces. +# ASH uses C=1 and D/2 projected dimensions, including its five-byte header. +# One JVM per experiment: build with every physical core, then query the same index +# with 1 and 8 search workers. The settings otherwise match studies/ash-vs-pq. +yamlSchemaVersion: 3 +onDiskIndexVersion: 7 +dataset: cap-6M + +construction: + type: onDisk + writerMode: parallel + workers: max + outDegree: [32] + efConstruction: [100] + neighborOverflow: [1.2f] + addHierarchy: [Yes] + refineFinalGraph: [Yes] + fusedGraph: [Yes] + compression: + - type: ASH + parameters: + encodedBits: 3112 + optimizer: ITQ + landmarkCount: 1 + bitsPerDimension: 4 + reranking: + type: NVQ + # One JVM builds and queries, so nothing needs to be saved for later JVMs. + useSavedIndexIfExists: No + +search: + topKOverquery: + 10: [1.0, 2.0] + useSearchPruning: [No] + workers: [1, 8] + compression: + - type: ASH + parameters: + queryProjection: auto + queryExecution: queued + maxBatchSize: 8 + encodedBits: 3112 + optimizer: ITQ + landmarkCount: 1 + bitsPerDimension: 4 diff --git a/jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq-single-jvm/cap6m-4bit-pq.yml b/jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq-single-jvm/cap6m-4bit-pq.yml new file mode 100644 index 000000000..108ab8145 --- /dev/null +++ b/jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq-single-jvm/cap6m-4bit-pq.yml @@ -0,0 +1,43 @@ +# cap-6M: 1536 input dimensions, 389 code bytes per vector. +# The 4-bit label describes the ASH budget; PQ uses 389 8-bit subspaces. +# ASH uses C=1 and D/2 projected dimensions, including its five-byte header. +# One JVM per experiment: build with every physical core, then query the same index +# with 1 and 8 search workers. The settings otherwise match studies/ash-vs-pq. +yamlSchemaVersion: 3 +onDiskIndexVersion: 7 +dataset: cap-6M + +construction: + type: onDisk + writerMode: parallel + workers: max + outDegree: [32] + efConstruction: [100] + neighborOverflow: [1.2f] + addHierarchy: [Yes] + refineFinalGraph: [Yes] + fusedGraph: [Yes] + compression: + - type: PQ + parameters: + m: 389 + k: 256 + centerData: "No" + anisotropicThreshold: -1.0 + reranking: + type: NVQ + # One JVM builds and queries, so nothing needs to be saved for later JVMs. + useSavedIndexIfExists: No + +search: + topKOverquery: + 10: [1.0, 2.0] + useSearchPruning: [No] + workers: [1, 8] + compression: + - type: PQ + parameters: + m: 389 + k: 256 + centerData: "No" + anisotropicThreshold: -1.0 diff --git a/jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq-single-jvm/cohere-2bit-ash.yml b/jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq-single-jvm/cohere-2bit-ash.yml new file mode 100644 index 000000000..ab4f1fc0b --- /dev/null +++ b/jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq-single-jvm/cohere-2bit-ash.yml @@ -0,0 +1,46 @@ +# cohere-english-v3-1M: 1024 input dimensions, 133 code bytes per vector. +# The 2-bit label describes the ASH budget; PQ uses 133 8-bit subspaces. +# ASH uses C=1 and D/2 projected dimensions, including its five-byte header. +# One JVM per experiment: build with every physical core, then query the same index +# with 1 and 8 search workers. The settings otherwise match studies/ash-vs-pq. +yamlSchemaVersion: 3 +onDiskIndexVersion: 7 +dataset: cohere-english-v3-1M + +construction: + type: onDisk + writerMode: parallel + workers: max + outDegree: [32] + efConstruction: [100] + neighborOverflow: [1.2f] + addHierarchy: [Yes] + refineFinalGraph: [Yes] + fusedGraph: [Yes] + compression: + - type: ASH + parameters: + encodedBits: 1064 + optimizer: ITQ + landmarkCount: 1 + bitsPerDimension: 2 + reranking: + type: NVQ + # One JVM builds and queries, so nothing needs to be saved for later JVMs. + useSavedIndexIfExists: No + +search: + topKOverquery: + 10: [1.0, 2.0] + useSearchPruning: [No] + workers: [1, 8] + compression: + - type: ASH + parameters: + queryProjection: auto + queryExecution: queued + maxBatchSize: 8 + encodedBits: 1064 + optimizer: ITQ + landmarkCount: 1 + bitsPerDimension: 2 diff --git a/jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq-single-jvm/cohere-2bit-pq.yml b/jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq-single-jvm/cohere-2bit-pq.yml new file mode 100644 index 000000000..97ef91b11 --- /dev/null +++ b/jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq-single-jvm/cohere-2bit-pq.yml @@ -0,0 +1,43 @@ +# cohere-english-v3-1M: 1024 input dimensions, 133 code bytes per vector. +# The 2-bit label describes the ASH budget; PQ uses 133 8-bit subspaces. +# ASH uses C=1 and D/2 projected dimensions, including its five-byte header. +# One JVM per experiment: build with every physical core, then query the same index +# with 1 and 8 search workers. The settings otherwise match studies/ash-vs-pq. +yamlSchemaVersion: 3 +onDiskIndexVersion: 7 +dataset: cohere-english-v3-1M + +construction: + type: onDisk + writerMode: parallel + workers: max + outDegree: [32] + efConstruction: [100] + neighborOverflow: [1.2f] + addHierarchy: [Yes] + refineFinalGraph: [Yes] + fusedGraph: [Yes] + compression: + - type: PQ + parameters: + m: 133 + k: 256 + centerData: "No" + anisotropicThreshold: -1.0 + reranking: + type: NVQ + # One JVM builds and queries, so nothing needs to be saved for later JVMs. + useSavedIndexIfExists: No + +search: + topKOverquery: + 10: [1.0, 2.0] + useSearchPruning: [No] + workers: [1, 8] + compression: + - type: PQ + parameters: + m: 133 + k: 256 + centerData: "No" + anisotropicThreshold: -1.0 diff --git a/jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq-single-jvm/cohere-4bit-ash.yml b/jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq-single-jvm/cohere-4bit-ash.yml new file mode 100644 index 000000000..2757381b7 --- /dev/null +++ b/jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq-single-jvm/cohere-4bit-ash.yml @@ -0,0 +1,46 @@ +# cohere-english-v3-1M: 1024 input dimensions, 261 code bytes per vector. +# The 4-bit label describes the ASH budget; PQ uses 261 8-bit subspaces. +# ASH uses C=1 and D/2 projected dimensions, including its five-byte header. +# One JVM per experiment: build with every physical core, then query the same index +# with 1 and 8 search workers. The settings otherwise match studies/ash-vs-pq. +yamlSchemaVersion: 3 +onDiskIndexVersion: 7 +dataset: cohere-english-v3-1M + +construction: + type: onDisk + writerMode: parallel + workers: max + outDegree: [32] + efConstruction: [100] + neighborOverflow: [1.2f] + addHierarchy: [Yes] + refineFinalGraph: [Yes] + fusedGraph: [Yes] + compression: + - type: ASH + parameters: + encodedBits: 2088 + optimizer: ITQ + landmarkCount: 1 + bitsPerDimension: 4 + reranking: + type: NVQ + # One JVM builds and queries, so nothing needs to be saved for later JVMs. + useSavedIndexIfExists: No + +search: + topKOverquery: + 10: [1.0, 2.0] + useSearchPruning: [No] + workers: [1, 8] + compression: + - type: ASH + parameters: + queryProjection: auto + queryExecution: queued + maxBatchSize: 8 + encodedBits: 2088 + optimizer: ITQ + landmarkCount: 1 + bitsPerDimension: 4 diff --git a/jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq-single-jvm/cohere-4bit-pq.yml b/jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq-single-jvm/cohere-4bit-pq.yml new file mode 100644 index 000000000..904e2be4f --- /dev/null +++ b/jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq-single-jvm/cohere-4bit-pq.yml @@ -0,0 +1,43 @@ +# cohere-english-v3-1M: 1024 input dimensions, 261 code bytes per vector. +# The 4-bit label describes the ASH budget; PQ uses 261 8-bit subspaces. +# ASH uses C=1 and D/2 projected dimensions, including its five-byte header. +# One JVM per experiment: build with every physical core, then query the same index +# with 1 and 8 search workers. The settings otherwise match studies/ash-vs-pq. +yamlSchemaVersion: 3 +onDiskIndexVersion: 7 +dataset: cohere-english-v3-1M + +construction: + type: onDisk + writerMode: parallel + workers: max + outDegree: [32] + efConstruction: [100] + neighborOverflow: [1.2f] + addHierarchy: [Yes] + refineFinalGraph: [Yes] + fusedGraph: [Yes] + compression: + - type: PQ + parameters: + m: 261 + k: 256 + centerData: "No" + anisotropicThreshold: -1.0 + reranking: + type: NVQ + # One JVM builds and queries, so nothing needs to be saved for later JVMs. + useSavedIndexIfExists: No + +search: + topKOverquery: + 10: [1.0, 2.0] + useSearchPruning: [No] + workers: [1, 8] + compression: + - type: PQ + parameters: + m: 261 + k: 256 + centerData: "No" + anisotropicThreshold: -1.0 diff --git a/jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq-single-jvm/cohere10m-2bit-ash.yml b/jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq-single-jvm/cohere10m-2bit-ash.yml new file mode 100644 index 000000000..c53752441 --- /dev/null +++ b/jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq-single-jvm/cohere10m-2bit-ash.yml @@ -0,0 +1,46 @@ +# cohere-english-v3-10M: 1024 input dimensions, 133 code bytes per vector. +# The 2-bit label describes the ASH budget; PQ uses 133 8-bit subspaces. +# ASH uses C=1 and D/2 projected dimensions, including its five-byte header. +# One JVM per experiment: build with every physical core, then query the same index +# with 1 and 8 search workers. The settings otherwise match studies/ash-vs-pq. +yamlSchemaVersion: 3 +onDiskIndexVersion: 7 +dataset: cohere-english-v3-10M + +construction: + type: onDisk + writerMode: parallel + workers: max + outDegree: [32] + efConstruction: [100] + neighborOverflow: [1.2f] + addHierarchy: [Yes] + refineFinalGraph: [Yes] + fusedGraph: [Yes] + compression: + - type: ASH + parameters: + encodedBits: 1064 + optimizer: ITQ + landmarkCount: 1 + bitsPerDimension: 2 + reranking: + type: NVQ + # One JVM builds and queries, so nothing needs to be saved for later JVMs. + useSavedIndexIfExists: No + +search: + topKOverquery: + 10: [1.0, 2.0] + useSearchPruning: [No] + workers: [1, 8] + compression: + - type: ASH + parameters: + queryProjection: auto + queryExecution: queued + maxBatchSize: 8 + encodedBits: 1064 + optimizer: ITQ + landmarkCount: 1 + bitsPerDimension: 2 diff --git a/jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq-single-jvm/cohere10m-2bit-pq.yml b/jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq-single-jvm/cohere10m-2bit-pq.yml new file mode 100644 index 000000000..c353b5200 --- /dev/null +++ b/jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq-single-jvm/cohere10m-2bit-pq.yml @@ -0,0 +1,43 @@ +# cohere-english-v3-10M: 1024 input dimensions, 133 code bytes per vector. +# The 2-bit label describes the ASH budget; PQ uses 133 8-bit subspaces. +# ASH uses C=1 and D/2 projected dimensions, including its five-byte header. +# One JVM per experiment: build with every physical core, then query the same index +# with 1 and 8 search workers. The settings otherwise match studies/ash-vs-pq. +yamlSchemaVersion: 3 +onDiskIndexVersion: 7 +dataset: cohere-english-v3-10M + +construction: + type: onDisk + writerMode: parallel + workers: max + outDegree: [32] + efConstruction: [100] + neighborOverflow: [1.2f] + addHierarchy: [Yes] + refineFinalGraph: [Yes] + fusedGraph: [Yes] + compression: + - type: PQ + parameters: + m: 133 + k: 256 + centerData: "No" + anisotropicThreshold: -1.0 + reranking: + type: NVQ + # One JVM builds and queries, so nothing needs to be saved for later JVMs. + useSavedIndexIfExists: No + +search: + topKOverquery: + 10: [1.0, 2.0] + useSearchPruning: [No] + workers: [1, 8] + compression: + - type: PQ + parameters: + m: 133 + k: 256 + centerData: "No" + anisotropicThreshold: -1.0 diff --git a/jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq-single-jvm/cohere10m-4bit-ash.yml b/jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq-single-jvm/cohere10m-4bit-ash.yml new file mode 100644 index 000000000..ed98bd31d --- /dev/null +++ b/jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq-single-jvm/cohere10m-4bit-ash.yml @@ -0,0 +1,46 @@ +# cohere-english-v3-10M: 1024 input dimensions, 261 code bytes per vector. +# The 4-bit label describes the ASH budget; PQ uses 261 8-bit subspaces. +# ASH uses C=1 and D/2 projected dimensions, including its five-byte header. +# One JVM per experiment: build with every physical core, then query the same index +# with 1 and 8 search workers. The settings otherwise match studies/ash-vs-pq. +yamlSchemaVersion: 3 +onDiskIndexVersion: 7 +dataset: cohere-english-v3-10M + +construction: + type: onDisk + writerMode: parallel + workers: max + outDegree: [32] + efConstruction: [100] + neighborOverflow: [1.2f] + addHierarchy: [Yes] + refineFinalGraph: [Yes] + fusedGraph: [Yes] + compression: + - type: ASH + parameters: + encodedBits: 2088 + optimizer: ITQ + landmarkCount: 1 + bitsPerDimension: 4 + reranking: + type: NVQ + # One JVM builds and queries, so nothing needs to be saved for later JVMs. + useSavedIndexIfExists: No + +search: + topKOverquery: + 10: [1.0, 2.0] + useSearchPruning: [No] + workers: [1, 8] + compression: + - type: ASH + parameters: + queryProjection: auto + queryExecution: queued + maxBatchSize: 8 + encodedBits: 2088 + optimizer: ITQ + landmarkCount: 1 + bitsPerDimension: 4 diff --git a/jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq-single-jvm/cohere10m-4bit-pq.yml b/jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq-single-jvm/cohere10m-4bit-pq.yml new file mode 100644 index 000000000..65f571e74 --- /dev/null +++ b/jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq-single-jvm/cohere10m-4bit-pq.yml @@ -0,0 +1,43 @@ +# cohere-english-v3-10M: 1024 input dimensions, 261 code bytes per vector. +# The 4-bit label describes the ASH budget; PQ uses 261 8-bit subspaces. +# ASH uses C=1 and D/2 projected dimensions, including its five-byte header. +# One JVM per experiment: build with every physical core, then query the same index +# with 1 and 8 search workers. The settings otherwise match studies/ash-vs-pq. +yamlSchemaVersion: 3 +onDiskIndexVersion: 7 +dataset: cohere-english-v3-10M + +construction: + type: onDisk + writerMode: parallel + workers: max + outDegree: [32] + efConstruction: [100] + neighborOverflow: [1.2f] + addHierarchy: [Yes] + refineFinalGraph: [Yes] + fusedGraph: [Yes] + compression: + - type: PQ + parameters: + m: 261 + k: 256 + centerData: "No" + anisotropicThreshold: -1.0 + reranking: + type: NVQ + # One JVM builds and queries, so nothing needs to be saved for later JVMs. + useSavedIndexIfExists: No + +search: + topKOverquery: + 10: [1.0, 2.0] + useSearchPruning: [No] + workers: [1, 8] + compression: + - type: PQ + parameters: + m: 261 + k: 256 + centerData: "No" + anisotropicThreshold: -1.0 diff --git a/jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq-single-jvm/mpnet-2bit-ash.yml b/jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq-single-jvm/mpnet-2bit-ash.yml new file mode 100644 index 000000000..3d8b8e6c8 --- /dev/null +++ b/jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq-single-jvm/mpnet-2bit-ash.yml @@ -0,0 +1,46 @@ +# mpnet-1m: 768 input dimensions, 101 code bytes per vector. +# The 2-bit label describes the ASH budget; PQ uses 101 8-bit subspaces. +# ASH uses C=1 and D/2 projected dimensions, including its five-byte header. +# One JVM per experiment: build with every physical core, then query the same index +# with 1 and 8 search workers. The settings otherwise match studies/ash-vs-pq. +yamlSchemaVersion: 3 +onDiskIndexVersion: 7 +dataset: mpnet-1m + +construction: + type: onDisk + writerMode: parallel + workers: max + outDegree: [32] + efConstruction: [100] + neighborOverflow: [1.2f] + addHierarchy: [Yes] + refineFinalGraph: [Yes] + fusedGraph: [Yes] + compression: + - type: ASH + parameters: + encodedBits: 808 + optimizer: ITQ + landmarkCount: 1 + bitsPerDimension: 2 + reranking: + type: NVQ + # One JVM builds and queries, so nothing needs to be saved for later JVMs. + useSavedIndexIfExists: No + +search: + topKOverquery: + 10: [1.0, 2.0] + useSearchPruning: [No] + workers: [1, 8] + compression: + - type: ASH + parameters: + queryProjection: auto + queryExecution: queued + maxBatchSize: 8 + encodedBits: 808 + optimizer: ITQ + landmarkCount: 1 + bitsPerDimension: 2 diff --git a/jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq-single-jvm/mpnet-2bit-pq.yml b/jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq-single-jvm/mpnet-2bit-pq.yml new file mode 100644 index 000000000..21e4da780 --- /dev/null +++ b/jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq-single-jvm/mpnet-2bit-pq.yml @@ -0,0 +1,43 @@ +# mpnet-1m: 768 input dimensions, 101 code bytes per vector. +# The 2-bit label describes the ASH budget; PQ uses 101 8-bit subspaces. +# ASH uses C=1 and D/2 projected dimensions, including its five-byte header. +# One JVM per experiment: build with every physical core, then query the same index +# with 1 and 8 search workers. The settings otherwise match studies/ash-vs-pq. +yamlSchemaVersion: 3 +onDiskIndexVersion: 7 +dataset: mpnet-1m + +construction: + type: onDisk + writerMode: parallel + workers: max + outDegree: [32] + efConstruction: [100] + neighborOverflow: [1.2f] + addHierarchy: [Yes] + refineFinalGraph: [Yes] + fusedGraph: [Yes] + compression: + - type: PQ + parameters: + m: 101 + k: 256 + centerData: "No" + anisotropicThreshold: -1.0 + reranking: + type: NVQ + # One JVM builds and queries, so nothing needs to be saved for later JVMs. + useSavedIndexIfExists: No + +search: + topKOverquery: + 10: [1.0, 2.0] + useSearchPruning: [No] + workers: [1, 8] + compression: + - type: PQ + parameters: + m: 101 + k: 256 + centerData: "No" + anisotropicThreshold: -1.0 diff --git a/jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq-single-jvm/mpnet-4bit-ash.yml b/jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq-single-jvm/mpnet-4bit-ash.yml new file mode 100644 index 000000000..e8551b11a --- /dev/null +++ b/jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq-single-jvm/mpnet-4bit-ash.yml @@ -0,0 +1,46 @@ +# mpnet-1m: 768 input dimensions, 197 code bytes per vector. +# The 4-bit label describes the ASH budget; PQ uses 197 8-bit subspaces. +# ASH uses C=1 and D/2 projected dimensions, including its five-byte header. +# One JVM per experiment: build with every physical core, then query the same index +# with 1 and 8 search workers. The settings otherwise match studies/ash-vs-pq. +yamlSchemaVersion: 3 +onDiskIndexVersion: 7 +dataset: mpnet-1m + +construction: + type: onDisk + writerMode: parallel + workers: max + outDegree: [32] + efConstruction: [100] + neighborOverflow: [1.2f] + addHierarchy: [Yes] + refineFinalGraph: [Yes] + fusedGraph: [Yes] + compression: + - type: ASH + parameters: + encodedBits: 1576 + optimizer: ITQ + landmarkCount: 1 + bitsPerDimension: 4 + reranking: + type: NVQ + # One JVM builds and queries, so nothing needs to be saved for later JVMs. + useSavedIndexIfExists: No + +search: + topKOverquery: + 10: [1.0, 2.0] + useSearchPruning: [No] + workers: [1, 8] + compression: + - type: ASH + parameters: + queryProjection: auto + queryExecution: queued + maxBatchSize: 8 + encodedBits: 1576 + optimizer: ITQ + landmarkCount: 1 + bitsPerDimension: 4 diff --git a/jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq-single-jvm/mpnet-4bit-pq.yml b/jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq-single-jvm/mpnet-4bit-pq.yml new file mode 100644 index 000000000..171bf684d --- /dev/null +++ b/jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq-single-jvm/mpnet-4bit-pq.yml @@ -0,0 +1,43 @@ +# mpnet-1m: 768 input dimensions, 197 code bytes per vector. +# The 4-bit label describes the ASH budget; PQ uses 197 8-bit subspaces. +# ASH uses C=1 and D/2 projected dimensions, including its five-byte header. +# One JVM per experiment: build with every physical core, then query the same index +# with 1 and 8 search workers. The settings otherwise match studies/ash-vs-pq. +yamlSchemaVersion: 3 +onDiskIndexVersion: 7 +dataset: mpnet-1m + +construction: + type: onDisk + writerMode: parallel + workers: max + outDegree: [32] + efConstruction: [100] + neighborOverflow: [1.2f] + addHierarchy: [Yes] + refineFinalGraph: [Yes] + fusedGraph: [Yes] + compression: + - type: PQ + parameters: + m: 197 + k: 256 + centerData: "No" + anisotropicThreshold: -1.0 + reranking: + type: NVQ + # One JVM builds and queries, so nothing needs to be saved for later JVMs. + useSavedIndexIfExists: No + +search: + topKOverquery: + 10: [1.0, 2.0] + useSearchPruning: [No] + workers: [1, 8] + compression: + - type: PQ + parameters: + m: 197 + k: 256 + centerData: "No" + anisotropicThreshold: -1.0 diff --git a/jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq-single-jvm/mpnet10m-2bit-pq.yml b/jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq-single-jvm/mpnet10m-2bit-pq.yml new file mode 100644 index 000000000..5ac79d3c3 --- /dev/null +++ b/jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq-single-jvm/mpnet10m-2bit-pq.yml @@ -0,0 +1,43 @@ +# mpnet-10m: 768 input dimensions, 101 code bytes per vector. +# The 2-bit label describes the ASH budget; PQ uses 101 8-bit subspaces. +# ASH uses C=1 and D/2 projected dimensions, including its five-byte header. +# One JVM per experiment: build with every physical core, then query the same index +# with 1 and 8 search workers. The settings otherwise match studies/ash-vs-pq. +yamlSchemaVersion: 3 +onDiskIndexVersion: 7 +dataset: mpnet-10m + +construction: + type: onDisk + writerMode: parallel + workers: max + outDegree: [32] + efConstruction: [100] + neighborOverflow: [1.2f] + addHierarchy: [Yes] + refineFinalGraph: [Yes] + fusedGraph: [Yes] + compression: + - type: PQ + parameters: + m: 101 + k: 256 + centerData: "No" + anisotropicThreshold: -1.0 + reranking: + type: NVQ + # One JVM builds and queries, so nothing needs to be saved for later JVMs. + useSavedIndexIfExists: No + +search: + topKOverquery: + 10: [1.0, 2.0] + useSearchPruning: [No] + workers: [1, 8] + compression: + - type: PQ + parameters: + m: 101 + k: 256 + centerData: "No" + anisotropicThreshold: -1.0 diff --git a/jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq-single-jvm/mpnet10m-4bit-ash.yml b/jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq-single-jvm/mpnet10m-4bit-ash.yml new file mode 100644 index 000000000..59bd21f45 --- /dev/null +++ b/jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq-single-jvm/mpnet10m-4bit-ash.yml @@ -0,0 +1,46 @@ +# mpnet-10m: 768 input dimensions, 197 code bytes per vector. +# The 4-bit label describes the ASH budget; PQ uses 197 8-bit subspaces. +# ASH uses C=1 and D/2 projected dimensions, including its five-byte header. +# One JVM per experiment: build with every physical core, then query the same index +# with 1 and 8 search workers. The settings otherwise match studies/ash-vs-pq. +yamlSchemaVersion: 3 +onDiskIndexVersion: 7 +dataset: mpnet-10m + +construction: + type: onDisk + writerMode: parallel + workers: max + outDegree: [32] + efConstruction: [100] + neighborOverflow: [1.2f] + addHierarchy: [Yes] + refineFinalGraph: [Yes] + fusedGraph: [Yes] + compression: + - type: ASH + parameters: + encodedBits: 1576 + optimizer: ITQ + landmarkCount: 1 + bitsPerDimension: 4 + reranking: + type: NVQ + # One JVM builds and queries, so nothing needs to be saved for later JVMs. + useSavedIndexIfExists: No + +search: + topKOverquery: + 10: [1.0, 2.0] + useSearchPruning: [No] + workers: [1, 8] + compression: + - type: ASH + parameters: + queryProjection: auto + queryExecution: queued + maxBatchSize: 8 + encodedBits: 1576 + optimizer: ITQ + landmarkCount: 1 + bitsPerDimension: 4 diff --git a/jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq-single-jvm/mpnet10m-4bit-pq.yml b/jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq-single-jvm/mpnet10m-4bit-pq.yml new file mode 100644 index 000000000..ff3339f7b --- /dev/null +++ b/jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq-single-jvm/mpnet10m-4bit-pq.yml @@ -0,0 +1,43 @@ +# mpnet-10m: 768 input dimensions, 197 code bytes per vector. +# The 4-bit label describes the ASH budget; PQ uses 197 8-bit subspaces. +# ASH uses C=1 and D/2 projected dimensions, including its five-byte header. +# One JVM per experiment: build with every physical core, then query the same index +# with 1 and 8 search workers. The settings otherwise match studies/ash-vs-pq. +yamlSchemaVersion: 3 +onDiskIndexVersion: 7 +dataset: mpnet-10m + +construction: + type: onDisk + writerMode: parallel + workers: max + outDegree: [32] + efConstruction: [100] + neighborOverflow: [1.2f] + addHierarchy: [Yes] + refineFinalGraph: [Yes] + fusedGraph: [Yes] + compression: + - type: PQ + parameters: + m: 197 + k: 256 + centerData: "No" + anisotropicThreshold: -1.0 + reranking: + type: NVQ + # One JVM builds and queries, so nothing needs to be saved for later JVMs. + useSavedIndexIfExists: No + +search: + topKOverquery: + 10: [1.0, 2.0] + useSearchPruning: [No] + workers: [1, 8] + compression: + - type: PQ + parameters: + m: 197 + k: 256 + centerData: "No" + anisotropicThreshold: -1.0 diff --git a/jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq-single-jvm/openai3072-2bit-ash.yml b/jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq-single-jvm/openai3072-2bit-ash.yml new file mode 100644 index 000000000..243ebb07f --- /dev/null +++ b/jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq-single-jvm/openai3072-2bit-ash.yml @@ -0,0 +1,46 @@ +# openai-3072-1m: 3072 input dimensions, 389 code bytes per vector. +# The 2-bit label describes the ASH budget; PQ uses 389 8-bit subspaces. +# ASH uses C=1 and D/2 projected dimensions, including its five-byte header. +# One JVM per experiment: build with every physical core, then query the same index +# with 1 and 8 search workers. The settings otherwise match studies/ash-vs-pq. +yamlSchemaVersion: 3 +onDiskIndexVersion: 7 +dataset: openai-3072-1m + +construction: + type: onDisk + writerMode: parallel + workers: max + outDegree: [32] + efConstruction: [100] + neighborOverflow: [1.2f] + addHierarchy: [Yes] + refineFinalGraph: [Yes] + fusedGraph: [Yes] + compression: + - type: ASH + parameters: + encodedBits: 3112 + optimizer: ITQ + landmarkCount: 1 + bitsPerDimension: 2 + reranking: + type: NVQ + # One JVM builds and queries, so nothing needs to be saved for later JVMs. + useSavedIndexIfExists: No + +search: + topKOverquery: + 10: [1.0, 2.0] + useSearchPruning: [No] + workers: [1, 8] + compression: + - type: ASH + parameters: + queryProjection: auto + queryExecution: queued + maxBatchSize: 8 + encodedBits: 3112 + optimizer: ITQ + landmarkCount: 1 + bitsPerDimension: 2 diff --git a/jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq-single-jvm/openai3072-2bit-pq.yml b/jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq-single-jvm/openai3072-2bit-pq.yml new file mode 100644 index 000000000..14b1ad4e7 --- /dev/null +++ b/jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq-single-jvm/openai3072-2bit-pq.yml @@ -0,0 +1,43 @@ +# openai-3072-1m: 3072 input dimensions, 389 code bytes per vector. +# The 2-bit label describes the ASH budget; PQ uses 389 8-bit subspaces. +# ASH uses C=1 and D/2 projected dimensions, including its five-byte header. +# One JVM per experiment: build with every physical core, then query the same index +# with 1 and 8 search workers. The settings otherwise match studies/ash-vs-pq. +yamlSchemaVersion: 3 +onDiskIndexVersion: 7 +dataset: openai-3072-1m + +construction: + type: onDisk + writerMode: parallel + workers: max + outDegree: [32] + efConstruction: [100] + neighborOverflow: [1.2f] + addHierarchy: [Yes] + refineFinalGraph: [Yes] + fusedGraph: [Yes] + compression: + - type: PQ + parameters: + m: 389 + k: 256 + centerData: "No" + anisotropicThreshold: -1.0 + reranking: + type: NVQ + # One JVM builds and queries, so nothing needs to be saved for later JVMs. + useSavedIndexIfExists: No + +search: + topKOverquery: + 10: [1.0, 2.0] + useSearchPruning: [No] + workers: [1, 8] + compression: + - type: PQ + parameters: + m: 389 + k: 256 + centerData: "No" + anisotropicThreshold: -1.0 diff --git a/jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq-single-jvm/openai3072-4bit-ash.yml b/jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq-single-jvm/openai3072-4bit-ash.yml new file mode 100644 index 000000000..0dbb97288 --- /dev/null +++ b/jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq-single-jvm/openai3072-4bit-ash.yml @@ -0,0 +1,46 @@ +# openai-3072-1m: 3072 input dimensions, 773 code bytes per vector. +# The 4-bit label describes the ASH budget; PQ uses 773 8-bit subspaces. +# ASH uses C=1 and D/2 projected dimensions, including its five-byte header. +# One JVM per experiment: build with every physical core, then query the same index +# with 1 and 8 search workers. The settings otherwise match studies/ash-vs-pq. +yamlSchemaVersion: 3 +onDiskIndexVersion: 7 +dataset: openai-3072-1m + +construction: + type: onDisk + writerMode: parallel + workers: max + outDegree: [32] + efConstruction: [100] + neighborOverflow: [1.2f] + addHierarchy: [Yes] + refineFinalGraph: [Yes] + fusedGraph: [Yes] + compression: + - type: ASH + parameters: + encodedBits: 6184 + optimizer: ITQ + landmarkCount: 1 + bitsPerDimension: 4 + reranking: + type: NVQ + # One JVM builds and queries, so nothing needs to be saved for later JVMs. + useSavedIndexIfExists: No + +search: + topKOverquery: + 10: [1.0, 2.0] + useSearchPruning: [No] + workers: [1, 8] + compression: + - type: ASH + parameters: + queryProjection: auto + queryExecution: queued + maxBatchSize: 8 + encodedBits: 6184 + optimizer: ITQ + landmarkCount: 1 + bitsPerDimension: 4 diff --git a/jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq-single-jvm/openai3072-4bit-pq.yml b/jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq-single-jvm/openai3072-4bit-pq.yml new file mode 100644 index 000000000..37d9a73e1 --- /dev/null +++ b/jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq-single-jvm/openai3072-4bit-pq.yml @@ -0,0 +1,43 @@ +# openai-3072-1m: 3072 input dimensions, 773 code bytes per vector. +# The 4-bit label describes the ASH budget; PQ uses 773 8-bit subspaces. +# ASH uses C=1 and D/2 projected dimensions, including its five-byte header. +# One JVM per experiment: build with every physical core, then query the same index +# with 1 and 8 search workers. The settings otherwise match studies/ash-vs-pq. +yamlSchemaVersion: 3 +onDiskIndexVersion: 7 +dataset: openai-3072-1m + +construction: + type: onDisk + writerMode: parallel + workers: max + outDegree: [32] + efConstruction: [100] + neighborOverflow: [1.2f] + addHierarchy: [Yes] + refineFinalGraph: [Yes] + fusedGraph: [Yes] + compression: + - type: PQ + parameters: + m: 773 + k: 256 + centerData: "No" + anisotropicThreshold: -1.0 + reranking: + type: NVQ + # One JVM builds and queries, so nothing needs to be saved for later JVMs. + useSavedIndexIfExists: No + +search: + topKOverquery: + 10: [1.0, 2.0] + useSearchPruning: [No] + workers: [1, 8] + compression: + - type: PQ + parameters: + m: 773 + k: 256 + centerData: "No" + anisotropicThreshold: -1.0 From a2d020dfac73be79f388c2eac983c787d55c5823 Mon Sep 17 00:00:00 2001 From: Ted Willke Date: Mon, 9 Nov 2026 16:35:53 -0800 Subject: [PATCH 188/243] Resolve the ASH TODOs - ASH models require on-disk format 7 or later, the first that can store ASH, when written and loaded; a test covers the version and magic-number checks, and write() documents the header. - ITQ and random training share one ProjectedBinaryQuantizer, since they differ only in the learned transform. - Remove the unused ashMaskedAddFlat_dense kernel from VectorUtilSupport and both implementations. - State that the training settings are the published defaults, and that ASH already prepares each query once per score function. --- .../jvector/quantization/ASHVectors.java | 7 +- .../quantization/AsymmetricHashing.java | 75 ++++++++----------- .../vector/DefaultVectorUtilSupport.java | 33 -------- .../jvector/vector/VectorUtilSupport.java | 3 - .../quantization/TestCompressedVectors.java | 49 ++++++++++++ .../vector/PanamaVectorUtilSupport.java | 55 -------------- 6 files changed, 81 insertions(+), 141 deletions(-) diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHVectors.java b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHVectors.java index 665f86502..aac32cd07 100644 --- a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHVectors.java +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHVectors.java @@ -1011,11 +1011,8 @@ public static LandmarkRunStats computeLandmarkRunStats(byte[] landmarks) { } /** - * For ASH, precomputed and non-precomputed scoring are currently identical. - * - *

    - * TODO: - * - Evaluate caching projected queries or other query-dependent state. + * Same as {@link #scoreFunctionFor}: ASH prepares the query once, projection included, + * when the score function is created, so there is nothing further to precompute. */ @Override public ScoreFunction.ApproximateScoreFunction precomputedScoreFunctionFor( diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/AsymmetricHashing.java b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/AsymmetricHashing.java index f330130c0..59c70352c 100644 --- a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/AsymmetricHashing.java +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/AsymmetricHashing.java @@ -58,9 +58,11 @@ public class AsymmetricHashing implements VectorCompressor landmark : landmarks) bytes = Math.addExact(bytes, landmark.ramBytesUsed()); this.heapBytes = bytes; } @@ -540,6 +537,13 @@ private static final class LandmarkProjections { * Loads an ASH instance from a RandomAccessReader. * This must mirror the ASH header writer exactly. */ + private static void checkFormatVersion(int version) { + if (version < MIN_FORMAT_VERSION || version > OnDiskGraphIndex.CURRENT_VERSION) { + throw new IllegalArgumentException("ASH requires on-disk format version " + MIN_FORMAT_VERSION + + " to " + OnDiskGraphIndex.CURRENT_VERSION + ", got " + version); + } + } + public static AsymmetricHashing load(RandomAccessReader in) throws IOException { int magic = in.readInt(); @@ -549,10 +553,7 @@ public static AsymmetricHashing load(RandomAccessReader in) throws IOException { ); } - int version = in.readInt(); - if (version > OnDiskGraphIndex.CURRENT_VERSION) { - throw new IllegalArgumentException("Unsupported ASH version " + version); - } + checkFormatVersion(in.readInt()); int originalDimension = in.readInt(); int encodedBits = in.readInt(); @@ -619,16 +620,18 @@ public static AsymmetricHashing load(RandomAccessReader in) throws IOException { /** - * TODO confirm handling versioning correctly - * TODO confirm handling magic number correctly - * Writes the ASH index header to IndexWriter (used for on-disk indexing) + * Writes the ASH model: the ASH magic number ({@code 0x75EC4015}), the on-disk graph format + * version, the dimensions, bit widths and optimizer, the landmarks, and the Stiefel transform. + * {@link #load} checks the magic number and version before reading the rest. + * * @param out the IndexWriter into which to write the object + * @param version the on-disk graph format version, from {@link #MIN_FORMAT_VERSION} to + * {@link OnDiskGraphIndex#CURRENT_VERSION} + * @throws IllegalArgumentException if the version cannot store ASH * @throws IOException if there is a problem writing to out. */ public void write(IndexWriter out, int version) throws IOException { - if (version > OnDiskGraphIndex.CURRENT_VERSION) { - throw new IllegalArgumentException("Unsupported version " + version); - } + checkFormatVersion(version); out.writeInt(MAGIC); out.writeInt(version); @@ -846,7 +849,8 @@ float quantizeBody(VectorFloat vector, byte[] outExtraBits); } - public static final class RandomBinaryQuantizer implements BinaryQuantizer { + /** Quantizes the projected residual body into sign words and extra magnitude bits. */ + public static final class ProjectedBinaryQuantizer implements BinaryQuantizer { // ThreadLocal to hold the reusable buffers. // Using a simple container class to avoid multiple ThreadLocal lookups. private static final ThreadLocal WORKSPACE = ThreadLocal.withInitial(Workspace::new); @@ -994,25 +998,6 @@ private static float quantizeFastScanProjectionCode( } } - public static final class ItqBinaryQuantizer implements BinaryQuantizer { - // Same quantization math for all optimizers; only the learned transform differs. - // TODO consolidate quantizeBody across learners/random. - private final RandomBinaryQuantizer delegate = new RandomBinaryQuantizer(); - - @Override - public float quantizeBody(VectorFloat vector, - VectorFloat mu, - float residualNorm, - int quantizedDim, - int bitsPerDimension, - StiefelTransform stiefel, - long[] outSignWords, - byte[] outExtraBits) { - return delegate.quantizeBody(vector, mu, residualNorm, quantizedDim, bitsPerDimension, - stiefel, outSignWords, outExtraBits); - } - } - // --------------------------------------------------------------------- // Quantized vector (per-vector data) // --------------------------------------------------------------------- @@ -1679,7 +1664,7 @@ private void encodeBatch(float[][] inputs, float[][] projected, float[] residual // Quantizers OR bits and nibbles into place, so reused scratch starts from zero. Arrays.fill(dest.binaryVector, 0L); Arrays.fill(dest.extraBits, (byte) 0); - float codeNorm = RandomBinaryQuantizer.quantizeProjected(projected[i], bitsPerDimension, + float codeNorm = ProjectedBinaryQuantizer.quantizeProjected(projected[i], bitsPerDimension, dest.binaryVector, dest.extraBits, absNorm); float correction = finishEncoding(dest, residualNorms[i], codeNorm); out.set(ordinals[i], dest); 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 415bbf0bb..bbfc820ba 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 @@ -579,39 +579,6 @@ public float ashMaskedAddFlat(float[] tildeQ, return sum; } - - @Override - public float ashMaskedAddFlat_dense(float[] tildeQ, - int qOffset, - long[] allPackedVectors, - int packedBase, - int d, - int words) { - float sum = 0.0f; - int baseDim = 0; - - for (int w = 0; w < words; w++) { - long word = allPackedVectors[packedBase + w]; - - // Standard bit-twiddling iteration (iterate only set bits) - while (word != 0L) { - int bit = Long.numberOfTrailingZeros(word); - int idx = baseDim + bit; - - // Bounds check required for the last word - if (idx < d) { - sum += tildeQ[qOffset + idx]; - } - - // Clear the lowest set bit - word &= (word - 1); - } - - baseDim += 64; - } - return sum; - } - @Override public float ashMaskedAdd_512(float[] tildeQ, int qOffset, diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/vector/VectorUtilSupport.java b/jvector-base/src/main/java/io/github/jbellis/jvector/vector/VectorUtilSupport.java index e4624e16d..170a63b89 100644 --- a/jvector-base/src/main/java/io/github/jbellis/jvector/vector/VectorUtilSupport.java +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/vector/VectorUtilSupport.java @@ -664,9 +664,6 @@ default float ashMaskedAddAllWords( // In VectorUtilSupport interface float ashMaskedAdd_512(float[] tildeQ, int qOffset, long[] allPackedVectors, int packedBase, int d, int words); - // In VectorUtilSupport interface - float ashMaskedAddFlat_dense(float[] tildeQ, int qOffset, long[] allPackedVectors, int packedBase, int d, int words); - /** * Computes maskedAdd for a block slice of vectors stored in packed block-column-major form. * For each lane in [0, blockLen), writes: diff --git a/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestCompressedVectors.java b/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestCompressedVectors.java index 456ae2d18..a700629bb 100644 --- a/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestCompressedVectors.java +++ b/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestCompressedVectors.java @@ -28,6 +28,8 @@ import org.junit.Test; import java.io.File; +import java.nio.ByteBuffer; +import java.nio.file.Files; import java.util.List; import static io.github.jbellis.jvector.TestUtil.createRandomVectors; @@ -36,6 +38,8 @@ import static java.lang.Math.abs; import static java.lang.Math.log; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; @ThreadLeakScope(ThreadLeakScope.Scope.NONE) public class TestCompressedVectors extends RandomizedTest { @@ -259,4 +263,49 @@ public void testRawEqualsPrecomputed() { public void testCenteringDisturbance() { } + + @Test + public void testAshRequiresFormatVersionSevenOrLater() throws Exception { + var vectors = createRandomVectors(256, 64); + vectors.forEach(VectorUtil::l2normalize); + var ravv = new ListRandomAccessVectorValues(vectors, 64); + var ash = AsymmetricHashing.initialize(ravv, AsymmetricHashing.RANDOM, + AsymmetricHashing.HEADER_BITS + 64, 1, 2); + int current = io.github.jbellis.jvector.graph.disk.OnDiskGraphIndex.CURRENT_VERSION; + + File file = File.createTempFile("ashtest", ".model"); + try (var writer = new SimpleWriter(file.toPath())) { + ash.write(writer, current); + } + try (var readerSupplier = new SimpleMappedReader.Supplier(file.toPath())) { + var loaded = AsymmetricHashing.load(readerSupplier.get()); + assertEquals(ash.originalDimension, loaded.originalDimension); + assertEquals(ash.quantizedDim, loaded.quantizedDim); + } + + File older = File.createTempFile("ashtest", ".model"); + try (var writer = new SimpleWriter(older.toPath())) { + var e = assertThrows(IllegalArgumentException.class, + () -> ash.write(writer, AsymmetricHashing.MIN_FORMAT_VERSION - 1)); + assertTrue(e.getMessage().endsWith("got " + (AsymmetricHashing.MIN_FORMAT_VERSION - 1)), e.getMessage()); + } + + // The header is the magic number, then the format version. + byte[] model = Files.readAllBytes(file.toPath()); + for (int version : new int[] {AsymmetricHashing.MIN_FORMAT_VERSION - 1, current + 1}) { + assertLoadFails(model, 4, version, "got " + version); + } + assertLoadFails(model, 0, 0x12345678, "Invalid ASH magic: 0x12345678"); + } + + private static void assertLoadFails(byte[] model, int offset, int value, String message) throws Exception { + byte[] patched = model.clone(); + ByteBuffer.wrap(patched, offset, Integer.BYTES).putInt(value); + File file = File.createTempFile("ashtest", ".model"); + Files.write(file.toPath(), patched); + try (var readerSupplier = new SimpleMappedReader.Supplier(file.toPath())) { + var e = assertThrows(IllegalArgumentException.class, () -> AsymmetricHashing.load(readerSupplier.get())); + assertTrue(e.getMessage().endsWith(message), e.getMessage()); + } + } } 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 517617be7..7586597d2 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 @@ -2293,61 +2293,6 @@ private static float reduce8( .reduceLanes(VectorOperators.ADD); } - // This version is optimized for dense masks and uses a masked load, plain add. - // It is branchless and fully unrolled. - // It only works for 64-bit aligned payloads and 16 lanes (AVX512). - public float ashMaskedAddFlat_dense(float[] tildeQ, - int qOffset, - long[] allPackedVectors, - int packedBase, - int d, - int words_not_used) { // TODO words is not used in this version - final VectorSpecies SPEC = FloatVector.SPECIES_PREFERRED; - final int LANES = SPEC.length(); - - // Typical case: d is 256/384 => multiple of 64 - // If you can guarantee that, you can delete all tail handling. - final int words = d >>> 6; - - Objects.checkFromIndexSize(qOffset, d, tildeQ.length); - Objects.checkFromIndexSize(packedBase, words, allPackedVectors.length); - - // 4 accumulators is fine for AVX-512; keep it. - FloatVector acc0 = FloatVector.zero(SPEC); - FloatVector acc1 = FloatVector.zero(SPEC); - FloatVector acc2 = FloatVector.zero(SPEC); - FloatVector acc3 = FloatVector.zero(SPEC); - - if (LANES == 16) { - for (int w = 0, qBase = qOffset; w < words; w++, qBase += 64) { - long bits = allPackedVectors[packedBase + w]; - - // Build masks from shifted bits (no laneMask needed) - VectorMask k0 = VectorMask.fromLong(SPEC, bits); - VectorMask k1 = VectorMask.fromLong(SPEC, bits >>> 16); - VectorMask k2 = VectorMask.fromLong(SPEC, bits >>> 32); - VectorMask k3 = VectorMask.fromLong(SPEC, bits >>> 48); - - // Masked load (zeroing) + unmasked add: branchless - acc0 = acc0.add(FloatVector.fromArray(SPEC, tildeQ, qBase + 0, k0)); - acc1 = acc1.add(FloatVector.fromArray(SPEC, tildeQ, qBase + 16, k1)); - acc2 = acc2.add(FloatVector.fromArray(SPEC, tildeQ, qBase + 32, k2)); - acc3 = acc3.add(FloatVector.fromArray(SPEC, tildeQ, qBase + 48, k3)); - } - } else { - // Generic fallback (still branchless and no laneMask) - for (int w = 0, qBase = qOffset; w < words; w++, qBase += 64) { - long bits = allPackedVectors[packedBase + w]; - for (int off = 0; off < 64; off += LANES) { - VectorMask k = VectorMask.fromLong(SPEC, bits >>> off); - acc0 = acc0.add(FloatVector.fromArray(SPEC, tildeQ, qBase + off, k)); - } - } - } - - return acc0.add(acc1).add(acc2).add(acc3).reduceLanes(VectorOperators.ADD); - } - // This approach uses masked adds instead of masked loads, and two accumulators (since this is slow) @Override public float ashMaskedAddAllWords(float[] tildeQ, From 564e2d5e138f393baba542f275c4395d31a88f18 Mon Sep 17 00:00:00 2001 From: Ted Willke Date: Mon, 9 Nov 2026 17:13:20 -0800 Subject: [PATCH 189/243] Test every selectable format version, including 6 with fused PQ and NVQ The round-trip test now covers versions 3 through the current one, single- and multi-layer, instead of stopping at 5 on the assumption that 6 was current. New tests write and reopen a version 6 hierarchical graph with fused PQ and NVQ, the layout hosts write until they adopt version 7, and check that a version 6 writer rejects fused ASH. --- .../graph/disk/TestOnDiskGraphIndex.java | 72 +++++++++++++++++-- 1 file changed, 66 insertions(+), 6 deletions(-) diff --git a/jvector-tests/src/test/java/io/github/jbellis/jvector/graph/disk/TestOnDiskGraphIndex.java b/jvector-tests/src/test/java/io/github/jbellis/jvector/graph/disk/TestOnDiskGraphIndex.java index 7d096b63e..92bf7f897 100644 --- a/jvector-tests/src/test/java/io/github/jbellis/jvector/graph/disk/TestOnDiskGraphIndex.java +++ b/jvector-tests/src/test/java/io/github/jbellis/jvector/graph/disk/TestOnDiskGraphIndex.java @@ -29,11 +29,13 @@ import io.github.jbellis.jvector.graph.TestVectorGraph; import io.github.jbellis.jvector.graph.disk.feature.Feature; import io.github.jbellis.jvector.graph.disk.feature.FeatureId; +import io.github.jbellis.jvector.graph.disk.feature.FusedASH; import io.github.jbellis.jvector.graph.disk.feature.FusedPQ; import io.github.jbellis.jvector.graph.disk.feature.InlineVectors; import io.github.jbellis.jvector.graph.disk.feature.NVQ; import io.github.jbellis.jvector.graph.disk.feature.SeparatedNVQ; import io.github.jbellis.jvector.graph.disk.feature.SeparatedVectors; +import io.github.jbellis.jvector.quantization.AsymmetricHashing; import io.github.jbellis.jvector.quantization.NVQuantization; import io.github.jbellis.jvector.quantization.PQVectors; import io.github.jbellis.jvector.quantization.ProductQuantization; @@ -379,18 +381,76 @@ public void testV0Write() throws IOException { @Test public void testVersionRoundTrip() throws Exception { - // Versions 3-5 predate V6's feature-reordering/footer-format changes but still need to - // round-trip correctly. Version 2 is covered separately by testV0Read/testV0Write, and - // version 6 (current) is exercised throughout the rest of this test class. - for (int version : new int[]{3, 4, 5}) { + // Every version a caller can select still round-trips, not only the current one: hosts + // write older formats until all of their readers support the newer one. Version 2 is + // covered separately by testV0Read/testV0Write. + for (int version = 3; version <= OnDiskGraphIndex.CURRENT_VERSION; version++) { versionRoundTrip(version, false); } - // Only versions 4 and 5 (of the ones under test here) support multi-layer graphs. - for (int version : new int[]{4, 5}) { + // Multi-layer graphs start at version 4. + for (int version = 4; version <= OnDiskGraphIndex.CURRENT_VERSION; version++) { versionRoundTrip(version, true); } } + /** Version 6 with fused PQ, NVQ and a hierarchy, as written by hosts that have not adopted version 7. */ + @Test + public void testFusedPQWithNVQAtVersion6() throws Exception { + var graph = new TestUtil.RandomlyConnectedGraphIndex( + List.of(new CommonHeader.LayerInfo(256, 8), new CommonHeader.LayerInfo(16, 4)), getRandom()); + int dimension = 64; + var vectors = TestUtil.createRandomVectors(graph.size(0), dimension); + var ravv = new ListRandomAccessVectorValues(vectors, dimension); + var pq = ProductQuantization.compute(ravv, 16, 256, false); + var pqv = (PQVectors) pq.encodeAll(ravv); + var nvq = NVQuantization.compute(ravv, 2); + var outputPath = testDirectory.resolve("version_6_fused_pq_nvq"); + + try (var writer = new OnDiskGraphIndexWriter.Builder(graph, outputPath) + .withVersion(6) + .with(new NVQ(nvq)) + .with(new FusedPQ(graph.getDegree(0), pq)) + .build()) + { + var view = graph.getView(); + writer.write(Map.of( + FeatureId.NVQ_VECTORS, ordinal -> new NVQ.State(nvq.encode(ravv.getVector(ordinal))), + FeatureId.FUSED_PQ, ordinal -> new FusedPQ.State(view, pqv, ordinal))); + } + + try (var readerSupplier = new SimpleMappedReader.Supplier(outputPath.toAbsolutePath()); + var onDiskGraph = OnDiskGraphIndex.load(readerSupplier)) + { + assertEquals(6, onDiskGraph.version); + assertEquals(EnumSet.of(FeatureId.NVQ_VECTORS, FeatureId.FUSED_PQ), onDiskGraph.features.keySet()); + assertEquals(graph.getMaxLevel(), onDiskGraph.getMaxLevel()); + TestUtil.assertGraphEquals(graph, onDiskGraph); + assertEquals(pq, ((FusedPQ) onDiskGraph.features.get(FeatureId.FUSED_PQ)).getPQ()); + assertEquals(nvq, ((NVQ) onDiskGraph.features.get(FeatureId.NVQ_VECTORS)).getNVQuantization()); + } + } + + @Test + public void testFusedASHRequiresVersion7() throws Exception { + var graph = new TestUtil.RandomlyConnectedGraphIndex(32, 4, getRandom()); + var ravv = new TestVectorGraph.CircularFloatVectorValues(graph.size(0)); + var e = assertThrows(IllegalArgumentException.class, () -> new OnDiskGraphIndexWriter.Builder(graph, + testDirectory.resolve("version_6_fused_ash")) + .withVersion(6) + .with(new InlineVectors(ravv.dimension())) + .with(fusedAshFeature(graph)) + .build()); + assertTrue(e.getMessage(), e.getMessage().startsWith("Feature FUSED_ASH is not supported by version 6")); + } + + private static FusedASH fusedAshFeature(ImmutableGraphIndex graph) throws java.io.IOException { + var vectors = TestUtil.createRandomVectors(graph.size(0), 64); + vectors.forEach(io.github.jbellis.jvector.vector.VectorUtil::l2normalize); + var ash = AsymmetricHashing.initialize(new ListRandomAccessVectorValues(vectors, 64), + AsymmetricHashing.RANDOM, AsymmetricHashing.HEADER_BITS + 64, 1, 2); + return new FusedASH(graph.getDegree(0), ash); + } + private void versionRoundTrip(int version, boolean hierarchical) throws Exception { ImmutableGraphIndex graph = hierarchical ? new TestUtil.RandomlyConnectedGraphIndex( From b8525fc0f47f14d02515f205501dfde420c282a2 Mon Sep 17 00:00:00 2001 From: Ted Willke Date: Mon, 9 Nov 2026 17:17:33 -0800 Subject: [PATCH 190/243] Document on-disk format version 7 in UPGRADING.md Version 7 adds only fused ASH; JVector 4.0.x rejects version 7 files. Hosts that need to roll back pass withVersion(6) and write(out, 6) until every reader runs 4.1, and version 6 and older files are written as before. Verified by writing Cassandra's format 4 and 6 layouts with this branch and reading them with 4.0.0-rc.8-hf1: identical graphs and search results. --- UPGRADING.md | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/UPGRADING.md b/UPGRADING.md index c99de606f..69732ec3b 100644 --- a/UPGRADING.md +++ b/UPGRADING.md @@ -1,3 +1,26 @@ +# Upgrading from 4.0.x to 4.1.x + +## On-disk format version 7 +- Version 7 of the on-disk graph format adds one feature, fused ASH (`FeatureId.FUSED_ASH`): ASH codes + stored in the graph alongside each node's neighbors. Apart from that feature, a version 7 file has + the version 6 layout. ASH models (`AsymmetricHashing`, `ASHVectors`) also require version 7 or later + when they are written or loaded. +- JVector 4.0.x reads formats up to 6 and rejects version 7 files. `OnDiskGraphIndex.CURRENT_VERSION`, + the default for `OnDiskGraphIndexWriter.Builder` and for `CompressedVectors.write(out)`, is now 7. +- To keep a rollback to 4.0.x possible, pass the version explicitly until every reader runs 4.1: + `withVersion(6)` on graph writer builders and `write(out, 6)` for compressors and compressed vectors. + Versions 6 and below still write inline vectors, NVQ, separated vectors and fused PQ exactly as before, + and 4.0.x reads them. A version 6 writer rejects fused ASH, and ASH models reject version 6. +- `OnDiskGraphIndexCompactor` always writes `CURRENT_VERSION`; it has no option to write an older version. + +## New features +- Asymmetric Hashing (ASH) quantization, stored separately or fused into the graph (format version 7), with + native FP16, FP32 and INT8 query projection and bounded queued query execution. See + [ASH configuration and defaults](docs/ash-configuration.md) and + [Integrating ASH queries into a database](docs/ash-query-integration.md). +- JVector sets the native BLAS thread count for ASH training: one thread by default, or the size of the + executor passed to `AsymmetricHashing.initialize(..., ForkJoinPool executor)`. + # Upgrading from 3.0.x to 4.0.x ## New features From 261b1ba0464b0536968d417671f9db6b39597eae Mon Sep 17 00:00:00 2001 From: Ted Willke Date: Mon, 9 Nov 2026 17:27:26 -0800 Subject: [PATCH 191/243] Write the 4.1.0 release notes for ASH as PR 796 entries 796.feature.md introduces ASH for integrators: the CIKM 2026 paper, JVector's in-memory, fused and separate-code support, native query projection, prepared and queued queries, and what to consider before adopting it, with links to the documentation. 796.enhancement.md summarizes the BenchYAML changes. The earlier 4.1.0 notes are folded into these, following the release-note naming rules. --- docs/release notes/4.1.0/796.enhancement.md | 15 +++ docs/release notes/4.1.0/796.feature.md | 101 ++++++++++++++++++ .../4.1.0/ash-blas-threads.feature.md | 14 --- .../ash-int8-query-projection.feature.md | 9 -- .../4.1.0/ash-query-batching.feature.md | 13 --- .../4.1.0/benchyaml-latency-warmup.bugfix.md | 1 - .../benchyaml-prepared-validation.bugfix.md | 1 - 7 files changed, 116 insertions(+), 38 deletions(-) create mode 100644 docs/release notes/4.1.0/796.enhancement.md create mode 100644 docs/release notes/4.1.0/796.feature.md delete mode 100644 docs/release notes/4.1.0/ash-blas-threads.feature.md delete mode 100644 docs/release notes/4.1.0/ash-int8-query-projection.feature.md delete mode 100644 docs/release notes/4.1.0/ash-query-batching.feature.md delete mode 100644 docs/release notes/4.1.0/benchyaml-latency-warmup.bugfix.md delete mode 100644 docs/release notes/4.1.0/benchyaml-prepared-validation.bugfix.md diff --git a/docs/release notes/4.1.0/796.enhancement.md b/docs/release notes/4.1.0/796.enhancement.md new file mode 100644 index 000000000..696a6fe7b --- /dev/null +++ b/docs/release notes/4.1.0/796.enhancement.md @@ -0,0 +1,15 @@ +### BenchYAML: ASH defaults, explicit pipelines and one-JVM experiments + +**Description** + +BenchYAML's index YAML schema 3 states each pipeline explicitly: index type, construction and +search compression, construction scoring, reranking, query execution (`direct` or `queued`), and +construction and search worker counts. Compressed dataset defaults now use fused ASH, and +`benchyaml-play.sh` builds the checkout and runs one experiment in one JVM from the command line or +IntelliJ. + +**How to Enable** + +Set `yamlSchemaVersion: 3` to use the new fields. See the [BenchYAML Guide](../../benchyaml-guide.md), +the [example pipelines](../../../jvector-examples/yaml-configs/index-parameters/README.md) and the +[benchmark launcher](../../../jvector-examples/bench-profiles/README.md). diff --git a/docs/release notes/4.1.0/796.feature.md b/docs/release notes/4.1.0/796.feature.md new file mode 100644 index 000000000..2e987b3d8 --- /dev/null +++ b/docs/release notes/4.1.0/796.feature.md @@ -0,0 +1,101 @@ +### Asymmetric Scalar Hashing (ASH) quantization + +**Description** + +JVector adds ASH (Asymmetric Scalar Hashing), introduced in +[ASH: Asymmetric Scalar Hashing With Learned Dimensionality Reduction for High-Fidelity Vector Quantization](https://arxiv.org/abs/2606.07870) +(Tepper and Willke, CIKM 2026). ASH is a data-driven encoder-decoder framework that applies +dimensionality reduction to database vectors via a learned orthonormal projection, followed by +scalar quantization, while keeping queries in their original form. This asymmetric design enables +higher accuracy than the best additive and scalar quantizers at iso-compression, while admitting +highly efficient similarity computations via SIMD operations. ASH has short learning and encoding +times, making it attractive for real-world deployment. + +This release brings ASH to a graph index for the first time. JVector stores each projected +dimension in 1 to 4 bits (up to 9 outside fused graphs), projects each query once, and scores it +against the stored codes with SIMD lookups. [ASH performance](../../ash-performance.md) compares +ASH with product quantization (PQ) in JVector graphs on 6M and 10M datasets. + +JVector supports ASH wherever it supports PQ: + +- **Training and encoding.** `AsymmetricHashing.initialize` trains a model with an ITQ-learned or + random projection, 1 to 256 landmarks, and a chosen bit budget. `encodeAll` produces + `ASHVectors`, which implement `CompressedVectors` and keep all codes in compact shared pages. +- **In-memory graphs.** `ASHVectors` score graph construction and search like other compressed + vectors. `BuildScoreProvider.ashBuildScoreProvider` builds the graph by comparing stored codes + with each other (symmetric scoring), so insertion needs no full-precision query projection. +- **Fused ASH on disk.** The `FusedASH` feature (`FeatureId.FUSED_ASH`) stores each node's + neighbors' ASH codes with its adjacency list and the model in the index header, so on-disk + search reads no separate code file. Fused ASH supports 1, 2 and 4 bits per dimension and + reranks with NVQ or full-precision vectors stored in the graph. +- **Separate ASH codes.** An on-disk graph with full-precision vectors can be searched with + `ASHVectors` held in memory, as with PQ. +- **Native query projection.** With the native vectorization provider, queries are projected in + FP16 where the CPU supports it, falling back to FP32. INT8 projection is available on request + for x86 processors with VNNI or AMX. Native builds include the oneDNN projection engine by + default; base-vector encoding uses FP32. +- **Prepared and queued queries.** `QueryPreparer` and `PreparedQuery` separate query preparation + from graph search. `BatchingExecutor` runs searches on host-owned workers with bounded + admission and prepares queries that are waiting at the same time in one batch. + +**How to Enable** + +ASH is opt-in; existing PQ, BQ and NVQ indexes and their defaults are unchanged. A fused ASH index +with NVQ reranking is built and searched as follows; the complete program is +[HelloASHWorldFp16](../../../jvector-examples/src/main/java/io/github/jbellis/jvector/example/HelloASHWorldFp16.java). + +```java +int b = 2, d = dimension / 2; // 2 bits for each of D/2 projected dimensions +var ash = AsymmetricHashing.initialize(vectors, AsymmetricHashing.ITQ, + AsymmetricHashing.HEADER_BITS + b * d, 1, b, PhysicalCoreExecutor.pool()); +var codes = ash.encodeAll(vectors, PhysicalCoreExecutor.pool()); +var scores = BuildScoreProvider.ashBuildScoreProvider(VectorSimilarityFunction.DOT_PRODUCT, codes); +// Build with GraphIndexBuilder and builder.addGraphNode(node, scores.searchProviderFor(node)), then write: +var writer = new OnDiskGraphIndexWriter.Builder(graph, path) + .withVersion(7).with(new FusedASH(maxDegree, ash)).with(new NVQ(nvq)).build(); + +// Search: prepare the query once, then search with NVQ reranking. +var preparer = index.queryPreparer(VectorSimilarityFunction.DOT_PRODUCT).orElseThrow(); +var prepared = preparer.newState(); +preparer.prepare(query, prepared); +var result = searcher.search(view.scoreProviderFor(prepared, true), 10, 20, 0.0f, 0.0f, Bits.ALL); +``` + +Query projection precision is selected with `-Djvector.ash.queryProjection` (`auto`, the default, +or `fp32`, `fp16`, `int8`). + +**Notes** + +- **On-disk format version 7.** Fused ASH and serialized ASH models require format version 7, + which this release introduces and which JVector 4.0.x cannot read. `CURRENT_VERSION` and the + writer defaults are now 7. Hosts that must be able to roll back should keep passing + `withVersion(6)` and `write(out, 6)` until every reader runs this release; version 6 and older + files are written and read as before. See [UPGRADING.md](../../../UPGRADING.md). +- **Similarity.** ASH scores dot product only. Use `DOT_PRODUCT` with normalized vectors for + cosine similarity. +- **Concurrency.** A prepared query, its searcher and its view belong to one in-flight search; + reuse them only after it completes. For asynchronous serving, size execution to the physical + cores allocated to search and use bounded admission; see + [Integrating ASH queries into a database](../../ash-query-integration.md). +- **Adaptive termination.** Searches of a fused ASH index through a prepared query stop when + further candidates are unlikely to improve the results (relative margin 0.01). Disable it with + `GraphSearcher.useAdaptiveTermination(false)`. +- **BLAS threads.** JVector sets the native BLAS thread count for ASH training: one thread, or + the size of the executor passed to `AsymmetricHashing.initialize`. Hosts no longer need + `MKL_NUM_THREADS` to keep MKL or OpenBLAS from starting a thread per CPU. +- **Compaction.** `OnDiskGraphIndexCompactor` does not yet compact fused ASH indexes. + +**Documentation** + +- [ASH configuration and defaults](../../ash-configuration.md): model parameters, construction + scoring, precision, providers and JVM settings. +- [Integrating ASH queries into a database](../../ash-query-integration.md): caller concurrency, + bounded admission, state ownership and latency validation. +- [Choosing ASH query-projection precision](../../ash-projection-precision.md): FP16, FP32 and + INT8 by processor family. +- [ASH query batching: maintainer design](../../ash-query-batching-design.md). +- [ASH performance](../../ash-performance.md) and + [Reproducing ASH versus PQ](../../ash-pq-benchmark.md). +- [Native build and oneDNN packaging](../../../jvector-native/src/main/native/README.md#recommended-onednn-build-and-package). +- [Hello ASH examples](../../../jvector-examples/README.md#hello-ash-world): INT8 and FP16 + projection, synchronous and queued search. diff --git a/docs/release notes/4.1.0/ash-blas-threads.feature.md b/docs/release notes/4.1.0/ash-blas-threads.feature.md deleted file mode 100644 index 3f9ddb444..000000000 --- a/docs/release notes/4.1.0/ash-blas-threads.feature.md +++ /dev/null @@ -1,14 +0,0 @@ -JVector now sets the native BLAS and LAPACK thread count for its own calls instead -of inheriting MKL's or OpenBLAS's default of one thread per CPU. ASH training uses -one BLAS thread, or as many as the executor passed to the new -`AsymmetricHashing.initialize(..., ForkJoinPool executor)` overload. With the native -provider, the limit is applied per calling thread through MKL or OpenBLAS, so a -database does not need to configure `MKL_NUM_THREADS`. `BlasThreads` applies the -same limit to other BLAS work. - -BenchYAML index YAML can set `construction.workers` and `search.workers`, each a -count or `max` for the platform's physical cores. A search worker sweep queries one -index. `benchyaml-play.sh` builds the checkout and runs one experiment in one JVM -from the command line or IntelliJ. - -See [ASH configuration and defaults](../../ash-configuration.md#blas-threads). diff --git a/docs/release notes/4.1.0/ash-int8-query-projection.feature.md b/docs/release notes/4.1.0/ash-int8-query-projection.feature.md deleted file mode 100644 index 6176eafc1..000000000 --- a/docs/release notes/4.1.0/ash-int8-query-projection.feature.md +++ /dev/null @@ -1,9 +0,0 @@ -ASH supports explicit INT8 query projection through the existing sequential -oneDNN add-on on qualified x86 VNNI/AMX processors. Select it with -`-Djvector.ash.queryProjection=int8`. Training, base encoding, model/index formats, -and the existing automatic FP16/FP32 policy are unchanged. The implementation -reuses the projection API, batching and staging, with explicit capability, -numerical-range and ABI guards. - -See [Choosing ASH query-projection precision](../../ash-projection-precision.md) -for processor-family recommendations and selection behavior. diff --git a/docs/release notes/4.1.0/ash-query-batching.feature.md b/docs/release notes/4.1.0/ash-query-batching.feature.md deleted file mode 100644 index 63afb8e67..000000000 --- a/docs/release notes/4.1.0/ash-query-batching.feature.md +++ /dev/null @@ -1,13 +0,0 @@ -Added reusable prepared-query and bounded asynchronous execution APIs. Compatible -ASH queries can use native FP16 batch projection before independent graph searches, -with immediate processing under light traffic and bounded admission under load. -Execution uses host-owned capacity, releases idle threads, contains callback -failures and drains accepted work on close. Existing synchronous search and index -formats remain supported. - -BenchYAML supports YAML-selected queued execution, explicit run/index configuration -paths, and source/concurrency metadata. The repository includes fixed fused PQ/ASH -comparison configurations and standalone reproduction commands. - -See [query integration](../../ash-query-integration.md) and the -[Reproducing ASH versus PQ](../../ash-pq-benchmark.md). diff --git a/docs/release notes/4.1.0/benchyaml-latency-warmup.bugfix.md b/docs/release notes/4.1.0/benchyaml-latency-warmup.bugfix.md deleted file mode 100644 index 7677a1d5a..000000000 --- a/docs/release notes/4.1.0/benchyaml-latency-warmup.bugfix.md +++ /dev/null @@ -1 +0,0 @@ -Warm BenchYAML unloaded-latency measurements through the same direct or queued single-request path used by timed queries. Three unrelated random-query warmup passes now precede measurement, each lasting at least three seconds and completing full query sets. This fixes queued single-request handoff and projection code first compiling during timed latency samples. Warmup refills reusable random-query buffers and does not access the measured query set. Warmup and measurement share the same execution pass and call site; latency samples use primitive storage. diff --git a/docs/release notes/4.1.0/benchyaml-prepared-validation.bugfix.md b/docs/release notes/4.1.0/benchyaml-prepared-validation.bugfix.md deleted file mode 100644 index ef75d6518..000000000 --- a/docs/release notes/4.1.0/benchyaml-prepared-validation.bugfix.md +++ /dev/null @@ -1 +0,0 @@ -BenchYAML now validates queued query handoff and concurrent direct search reuse using identical prepared arithmetic, avoiding false failures from rounding differences between singleton and batched projection. Numerical kernel and measured recall checks remain independent. From 6f5e6b4b8481021f6e8368f2cf328c6837a87d2e Mon Sep 17 00:00:00 2001 From: Ted Willke Date: Mon, 9 Nov 2026 17:34:17 -0800 Subject: [PATCH 192/243] Accept schema 1 index YAML in BenchYAML Schema 1 and 2 share one layout; schema 2 only marked files written after ASH was added. Index files from earlier releases, which declare schema 1, load again and plan the same grid as schema 2. The legacy-file warning now points to schema 3. --- .../jvector/example/yaml/MultiConfig.java | 7 +- .../example/IndexSchemaVersionTest.java | 68 +++++++++++++++++++ 2 files changed, 72 insertions(+), 3 deletions(-) create mode 100644 jvector-examples/src/test/java/io/github/jbellis/jvector/example/IndexSchemaVersionTest.java diff --git a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/yaml/MultiConfig.java b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/yaml/MultiConfig.java index 9d4dc5615..9755bc531 100644 --- a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/yaml/MultiConfig.java +++ b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/yaml/MultiConfig.java @@ -129,8 +129,9 @@ static MultiConfig getConfig(File configFile) throws FileNotFoundException { + configFile.getAbsolutePath()); } - // Validate supported versions - if (cfg.getYamlSchemaVersion() != 2 && cfg.getYamlSchemaVersion() != 3) { + // Validate supported versions. Schema 1 and 2 share one layout; schema 2 only + // marked files written after ASH was added, so earlier schema 1 files still load. + if (cfg.getYamlSchemaVersion() < 1 || cfg.getYamlSchemaVersion() > 3) { throw new IllegalArgumentException("Unsupported yamlSchemaVersion=" + cfg.getYamlSchemaVersion() + " in " + configFile.getAbsolutePath()); } @@ -151,7 +152,7 @@ static MultiConfig getConfig(File configFile) throws FileNotFoundException { // Legacy yamlSchemaVersion "0": lenient parsing (ignore unknown fields like search.benchmarks) if (WARNED_LEGACY.compareAndSet(false, true)) { System.err.println("WARNING: Deprecated legacy YAML schema detected (no yamlSchemaVersion). " - + "Unknown fields will be ignored. Please migrate configs to yamlSchemaVersion: 1 and run-config.yml."); + + "Unknown fields will be ignored. Please migrate configs to yamlSchemaVersion: 3 and run-config.yml."); } try { diff --git a/jvector-examples/src/test/java/io/github/jbellis/jvector/example/IndexSchemaVersionTest.java b/jvector-examples/src/test/java/io/github/jbellis/jvector/example/IndexSchemaVersionTest.java new file mode 100644 index 000000000..dadcbee0f --- /dev/null +++ b/jvector-examples/src/test/java/io/github/jbellis/jvector/example/IndexSchemaVersionTest.java @@ -0,0 +1,68 @@ +/* + * 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; + +import io.github.jbellis.jvector.example.yaml.MultiConfig; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +import java.io.File; +import java.nio.file.Files; +import java.util.stream.Collectors; + +import static org.junit.Assert.*; + +/** Index YAML schema versions BenchYAML accepts. */ +public class IndexSchemaVersionTest { + @Rule public final TemporaryFolder directory = new TemporaryFolder(); + + /** The layout of the dataset defaults in earlier releases, which declared schema 1. */ + private static final String BODY = "onDiskIndexVersion: 7\ndataset: test\n" + + "construction:\n outDegree: [32]\n efConstruction: [100]\n neighborOverflow: [1.2f]\n" + + " addHierarchy: [Yes]\n refineFinalGraph: [Yes]\n fusedGraph: [No]\n" + + " compression:\n - type: PQ\n parameters:\n mFactor: 8\n" + + " reranking:\n - NVQ\n useSavedIndexIfExists: No\n" + + "search:\n topKOverquery:\n 10: [1.0, 2.0]\n useSearchPruning: [No]\n" + + " compression:\n - type: PQ\n parameters:\n mFactor: 8\n"; + + private MultiConfig load(int schema) throws Exception { + var file = new File(directory.getRoot(), "schema" + schema + ".yml"); + Files.writeString(file.toPath(), "yamlSchemaVersion: " + schema + "\n" + BODY); + return MultiConfig.getConfig(file.getPath()); + } + + private static String plan(MultiConfig config) { + var spec = GridSpec.fromConfig(config); + return spec.buildPoints().stream().map(p -> p.reportedParameters().toString()).collect(Collectors.joining(";")) + + " | " + spec.search.cases().size() + " search cases"; + } + + @Test + public void schemaOneFilesFromEarlierReleasesLoadLikeSchemaTwo() throws Exception { + var one = load(1); + assertEquals(1, one.getYamlSchemaVersion()); + assertEquals(plan(load(2)), plan(one)); + } + + @Test + public void versionsOutsideOneToThreeAreRejected() { + for (int unsupported : new int[] {0, 4}) { + var e = assertThrows(IllegalArgumentException.class, () -> load(unsupported)); + assertTrue(e.getMessage(), e.getMessage().startsWith("Unsupported yamlSchemaVersion=" + unsupported)); + } + } +} From 19822965b8acfc6d4174b286ad9b789b8e513eea Mon Sep 17 00:00:00 2001 From: Ted Willke Date: Mon, 9 Nov 2026 17:38:11 -0800 Subject: [PATCH 193/243] Drop license comments from docs excluded from the license check docs/**/*.md is excluded from Apache RAT, and main's docs carry no license header; the new ASH and BenchYAML docs now match. --- docs/ash-configuration.md | 16 ---------------- docs/ash-neighborhood-benchmark.md | 16 ---------------- docs/ash-pca-native-svd.md | 16 ---------------- docs/ash-performance.md | 16 ---------------- docs/ash-pq-benchmark.md | 16 ---------------- docs/ash-projection-precision.md | 16 ---------------- docs/ash-query-batching-design.md | 16 ---------------- docs/ash-query-integration.md | 16 ---------------- docs/benchyaml-guide.md | 16 ---------------- docs/native-ash-preparation.md | 16 ---------------- 10 files changed, 160 deletions(-) diff --git a/docs/ash-configuration.md b/docs/ash-configuration.md index 1b7bbe9e0..6112bac7f 100644 --- a/docs/ash-configuration.md +++ b/docs/ash-configuration.md @@ -1,19 +1,3 @@ - - # ASH configuration and defaults ASH has independent settings for stored codes, construction, query projection, diff --git a/docs/ash-neighborhood-benchmark.md b/docs/ash-neighborhood-benchmark.md index b5c0d4d62..590fe2048 100644 --- a/docs/ash-neighborhood-benchmark.md +++ b/docs/ash-neighborhood-benchmark.md @@ -1,19 +1,3 @@ - - # ASH neighborhood scoring and isolated full-scan benchmarks The graph decoder and `DistancesASH` use the same `ASHNeighborhoodScorer`. diff --git a/docs/ash-pca-native-svd.md b/docs/ash-pca-native-svd.md index 766ad2f0a..cd8afb84c 100644 --- a/docs/ash-pca-native-svd.md +++ b/docs/ash-pca-native-svd.md @@ -1,19 +1,3 @@ - - # ASH PCA correctness PCA first computes FP64 covariance with DSYRK and its eigendecomposition with diff --git a/docs/ash-performance.md b/docs/ash-performance.md index 305563464..b8077f359 100644 --- a/docs/ash-performance.md +++ b/docs/ash-performance.md @@ -1,19 +1,3 @@ - - # ASH performance This page compares fused ASH and fused PQ graph indexes on three large datasets, diff --git a/docs/ash-pq-benchmark.md b/docs/ash-pq-benchmark.md index c80f3c80e..3bb8032d7 100644 --- a/docs/ash-pq-benchmark.md +++ b/docs/ash-pq-benchmark.md @@ -1,19 +1,3 @@ - - # Reproducing ASH versus PQ This study compares fused ASH and fused PQ graph indexes at matched code sizes: diff --git a/docs/ash-projection-precision.md b/docs/ash-projection-precision.md index 76f068191..56ccfc07c 100644 --- a/docs/ash-projection-precision.md +++ b/docs/ash-projection-precision.md @@ -1,19 +1,3 @@ - - # Choosing ASH query-projection precision Query-projection precision is independent of ASH's code parameters `b` and `d`. diff --git a/docs/ash-query-batching-design.md b/docs/ash-query-batching-design.md index d35e0640e..26ee34d4f 100644 --- a/docs/ash-query-batching-design.md +++ b/docs/ash-query-batching-design.md @@ -1,19 +1,3 @@ - - # ASH query batching: maintainer design Database integrators should use [the caller integration guide](ash-query-integration.md). diff --git a/docs/ash-query-integration.md b/docs/ash-query-integration.md index a983cc136..66465e526 100644 --- a/docs/ash-query-integration.md +++ b/docs/ash-query-integration.md @@ -1,19 +1,3 @@ - - # Integrating ASH queries into a database The database selects the physical-core budget **C** for search. Queries may be diff --git a/docs/benchyaml-guide.md b/docs/benchyaml-guide.md index a5055fc77..203599542 100644 --- a/docs/benchyaml-guide.md +++ b/docs/benchyaml-guide.md @@ -1,19 +1,3 @@ - - # BenchYAML Guide BenchYAML uses dataset configuration to build an index and measure search. diff --git a/docs/native-ash-preparation.md b/docs/native-ash-preparation.md index 6fbc1bddb..4246ba7cb 100644 --- a/docs/native-ash-preparation.md +++ b/docs/native-ash-preparation.md @@ -1,19 +1,3 @@ - - # Native ASH projection and symmetric construction scoring ASH models prepare an immutable provider-specific projection when their Stiefel From 2ec06af232adc6e78f90c750a787f69a518c89d1 Mon Sep 17 00:00:00 2001 From: Ted Willke Date: Mon, 9 Nov 2026 17:51:05 -0800 Subject: [PATCH 194/243] Explain landmarks and shared projection in the ASH release note JVector's graph indexes use one landmark, and query queuing and batching exist so that concurrent queries share one batched projection. --- docs/release notes/4.1.0/796.feature.md | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/docs/release notes/4.1.0/796.feature.md b/docs/release notes/4.1.0/796.feature.md index 2e987b3d8..c8c9f04e2 100644 --- a/docs/release notes/4.1.0/796.feature.md +++ b/docs/release notes/4.1.0/796.feature.md @@ -19,8 +19,9 @@ ASH with product quantization (PQ) in JVector graphs on 6M and 10M datasets. JVector supports ASH wherever it supports PQ: - **Training and encoding.** `AsymmetricHashing.initialize` trains a model with an ITQ-learned or - random projection, 1 to 256 landmarks, and a chosen bit budget. `encodeAll` produces + random projection, a number of landmarks, and a chosen bit budget. `encodeAll` produces `ASHVectors`, which implement `CompressedVectors` and keep all codes in compact shared pages. + The quantizer accepts 1 to 256 landmarks; JVector's graph indexes currently use one. - **In-memory graphs.** `ASHVectors` score graph construction and search like other compressed vectors. `BuildScoreProvider.ashBuildScoreProvider` builds the graph by comparing stored codes with each other (symmetric scoring), so insertion needs no full-precision query projection. @@ -34,9 +35,12 @@ JVector supports ASH wherever it supports PQ: FP16 where the CPU supports it, falling back to FP32. INT8 projection is available on request for x86 processors with VNNI or AMX. Native builds include the oneDNN projection engine by default; base-vector encoding uses FP32. -- **Prepared and queued queries.** `QueryPreparer` and `PreparedQuery` separate query preparation - from graph search. `BatchingExecutor` runs searches on host-owned workers with bounded - admission and prepares queries that are waiting at the same time in one batch. +- **Prepared and queued queries.** Each ASH query is projected once before its graph search, and + projecting several queries together in one matrix multiply costs less per query than projecting + them one at a time. `QueryPreparer` and `PreparedQuery` therefore separate projection from graph + search, and `BatchingExecutor` queues concurrent searches on host-owned workers with bounded + admission so that queries waiting at the same time share one batched projection. Concurrent + synchronous searches also share projections, up to `jvector.ash.queryBatchSize` (default 8). **How to Enable** From 66c0ef8b7d1ae90b498a918a80abb79cfa9dc913 Mon Sep 17 00:00:00 2001 From: Ted Willke Date: Mon, 9 Nov 2026 18:15:24 -0800 Subject: [PATCH 195/243] Declare netlib BLAS and LAPACK where the published jvector artifact inherits them The netlib dependencies were declared only by jvector-base, so the published jvector artifact, which repackages the modules and inherits the parent's dependencies, did not list them, and ASH training would fail for its users. They now sit with the parent's other shared dependencies. Drop com.github.fommil.netlib:core: the only class ASH used from it, org.netlib.util.intW, comes from arpack_combined_all, which dev.ludovic.netlib:blas and lapack already depend on. Remove the unused netlib and arpack dependency management entries. --- jvector-base/pom.xml | 17 +---------------- pom.xml | 36 +++++++++++------------------------- 2 files changed, 12 insertions(+), 41 deletions(-) diff --git a/jvector-base/pom.xml b/jvector-base/pom.xml index 8f105bff1..c5405dc9d 100644 --- a/jvector-base/pom.xml +++ b/jvector-base/pom.xml @@ -1,5 +1,5 @@ - @@ -12,21 +12,6 @@ jvector-base Base - - - com.github.fommil.netlib - core - - - dev.ludovic.netlib - blas - - - dev.ludovic.netlib - lapack - - - diff --git a/pom.xml b/pom.xml index 6d51e2948..83a0c01e1 100644 --- a/pom.xml +++ b/pom.xml @@ -207,34 +207,20 @@ snakeyaml 2.4 + + + dev.ludovic.netlib + blas + ${ludovic.netlib.version} + + + dev.ludovic.netlib + lapack + ${ludovic.netlib.version} + - - com.github.fommil.netlib - core - 1.1.2 - - - dev.ludovic.netlib - netlib - ${ludovic.netlib.version} - - - dev.ludovic.netlib - blas - ${ludovic.netlib.version} - - - dev.ludovic.netlib - lapack - ${ludovic.netlib.version} - - - dev.ludovic.netlib - arpack - ${ludovic.netlib.version} - org.junit.jupiter junit-jupiter-engine From c3d4c2cbc9d192dbc42646957ccadc549fcc6e2a Mon Sep 17 00:00:00 2001 From: Ted Willke Date: Mon, 9 Nov 2026 18:22:51 -0800 Subject: [PATCH 196/243] Rewrite the README around getting started, quantizer choice and BenchYAML Introduce ASH and fused ASH alongside PQ, BQ and NVQ, lead with the hello-world programs and BenchYAML, and add the ASH paper to the research list. Correct the description of fused PQ (it stores neighbors' codes, not codebooks), the modules in the published JAR, and the test commands. --- README.md | 353 +++++++++++++++++++++++++++--------------------------- 1 file changed, 177 insertions(+), 176 deletions(-) diff --git a/README.md b/README.md index fdecdb13c..a1e463d84 100644 --- a/README.md +++ b/README.md @@ -1,228 +1,229 @@ -## Introduction to approximate nearest neighbor search +# JVector + +JVector is a Java library for approximate nearest neighbor (ANN) vector search. It builds graph +indexes that combine an HNSW-style hierarchy with Vamana (the algorithm behind DiskANN) in every +layer, can be built concurrently and incrementally, and can be searched from disk with compressed +vectors. JVector targets Java 11 and uses SIMD acceleration on newer JVMs. + +- [Why graph indexes](#why-graph-indexes) +- [How JVector searches](#how-jvector-searches) +- [Getting started](#getting-started) +- [Choosing a quantizer](#choosing-a-quantizer) +- [Benchmarking with BenchYAML](#benchmarking-with-benchyaml) +- [SIMD and native acceleration](#simd-and-native-acceleration) +- [Research](#research) +- [Building and testing](#building-and-testing) + +## Why graph indexes + +Exact nearest neighbor search is prohibitively expensive at high dimension: methods that partition +space well in two or three dimensions, such as k-d trees, degrade to linear scans as dimension +grows ("[the curse of dimensionality](https://en.wikipedia.org/wiki/Curse_of_dimensionality)"). For +large datasets, an approximate answer in logarithmic time is usually more useful than an exact +answer in linear time. + +ANN indexes are either partition-based, like [LSH or IVF](https://www.datastax.com/guides/what-is-a-vector-index) +and [ScaNN](https://github.com/google-research/google-research/tree/master/scann), or graph-based, +like [HNSW](https://arxiv.org/abs/1603.09320) and +[DiskANN](https://www.microsoft.com/en-us/research/project/project-akupara-approximate-nearest-neighbor-search-for-large-scale-semantic-search/). +Graph indexes tend to be faster and, more importantly, can be built and updated incrementally, which +makes them a better fit for a general-purpose index than partitioning schemes that need the whole +dataset up front. + +## How JVector searches + +JVector's graph has an in-memory hierarchy of upper layers for fast navigation and a base layer that +can live on disk. Construction uses nonblocking concurrency control and scales linearly with cores: -Exact nearest neighbor search (k-nearest-neighbor or KNN) is prohibitively expensive at higher dimensions, because approaches to segment the search space that work in 2D or 3D like quadtree or k-d tree devolve to linear scans at higher dimensions. This is one aspect of what is called “[the curse of dimensionality](https://en.wikipedia.org/wiki/Curse_of_dimensionality).” - -With larger datasets, it is almost always more useful to get an approximate answer in logarithmic time, than the exact answer in linear time. This is abbreviated as ANN (approximate nearest neighbor) search. - -There are two broad categories of ANN index: -* Partition-based indexes, like [LSH or IVF](https://www.datastax.com/guides/what-is-a-vector-index) or [SCANN](https://github.com/google-research/google-research/tree/master/scann) -* Graph indexes, like [HNSW](https://arxiv.org/abs/1603.09320) or [DiskANN](https://www.microsoft.com/en-us/research/project/project-akupara-approximate-nearest-neighbor-search-for-large-scale-semantic-search/) - -Graph-based indexes tend to be simpler to implement and faster, but more importantly they can be constructed and updated incrementally. This makes them a much better fit for a general-purpose index than partitioning approaches that only work on static datasets that are completely specified up front. That is why all the major commercial vector indexes use graph approaches. - -JVector is a graph index that merges the DiskANN and HNSW family trees. -JVector borrows the hierarchical structure from HNSW, and uses Vamana (the algorithm behind DiskANN) within each layer. - - -## JVector Architecture - -JVector is a graph-based index that builds on the HNSW and DiskANN designs with composable extensions. - -JVector implements a multi-layer graph with nonblocking concurrency control, allowing construction to scale linearly with the number of cores: ![JVector scales linearly as thread count increases](https://github.com/jbellis/jvector/assets/42158/f0127bfc-6c45-48b9-96ea-95b2120da0d9) -The upper layers of the hierarchy are represented by an in-memory adjacency list per node. This allows for quick navigation with no IOs. -The bottom layer of the graph is represented by an on-disk adjacency list per node. JVector uses additional data stored inline to support two-pass searches, with the first pass powered by lossily compressed representations of the vectors kept in memory, and the second by a more accurate representation read from disk. The first pass can be performed with -* Product quantization (PQ), optionally with [anisotropic weighting](https://arxiv.org/abs/1908.10396) -* [Binary quantization](https://huggingface.co/blog/embedding-quantization) (BQ) -* Fused PQ, where PQ codebooks are written inline with the graph adjacency list +Searches run in two passes. The first pass traverses the graph using compact, approximate scores +from a quantizer; the second reranks the best candidates with a more accurate representation stored +in the index: -The second pass can be performed with -* Full resolution float32 vectors -* NVQ, which uses a non-uniform technique to quantize vectors with high-accuracy +| Pass | Options | +| --- | --- | +| Traversal | Product quantization (PQ), optionally with anisotropic weighting; binary quantization (BQ); Asymmetric Scalar Hashing (ASH); or full-precision vectors | +| Fused traversal | Fused PQ or fused ASH: each node's adjacency list carries its neighbors' codes, so the codes are read with the graph instead of held in memory | +| Reranking | Full-precision (FP32) vectors or NVQ, a high-accuracy non-uniform quantization | -[This two-pass design reduces memory usage and reduces latency while preserving accuracy](https://thenewstack.io/why-vector-size-matters/). +[This two-pass design reduces memory use and latency while preserving accuracy](https://thenewstack.io/why-vector-size-matters/). +JVector can also use two-pass searches while *building* an index, so indexes larger than memory can +be constructed, and searches stay logarithmic within a single index instead of merging results +from many: -Additionally, JVector is unique in offering the ability to construct the index itself using two-pass searches, allowing larger-than-memory indexes to be built: ![Much larger indexes](https://github.com/jbellis/jvector/assets/42158/34cb8094-68fa-4dc3-b3ce-4582fdbd77e1) -This is important because it allows you to take advantage of logarithmic search within a single index, instead of spilling over to linear-time merging of results from multiple indexes. - - -## Getting started with JVector - -Introductory tutorials for JVector are available in [docs/tutorials](./docs/tutorials/). Start with the [basic tutorial](./docs/tutorials/1-intro-tutorial.md) or review [VectorIntro.java](./jvector-examples/src/main/java/io/github/jbellis/jvector/example/tutorial/VectorIntro.java) for a simple example using JVector. +## Getting started -For file-backed input datasets, see [Batched input vector access](./docs/batched-vector-access.md), -including runnable 100k-vector examples and optional integration with PQ and NVQ. - -For writing completed graphs with FP or NVQ features, see [Deferred batch serialization](./docs/deferred-batch-writer.md). It works with application vector sources and preloaded data; dataset prefetching is optional. - -For database integration with ASH, see [Integrating ASH queries into a database](./docs/ash-query-integration.md), covering caller concurrency, bounded admission, state ownership and latency validation. - -For ASH model parameters, construction modes, precision, JVM flags and their defaults, -see [ASH configuration and defaults](./docs/ash-configuration.md). - -For ASH versus PQ construction time, recall and throughput on 6M and 10M datasets, -see [ASH performance](./docs/ash-performance.md). - -The older step-by-step guide for JV can be found [here](./docs/legacy/jvector-step-by-step.md). New users should start with the tutorials mentioned earler, but the step-by-step guide contains useful commentary for advanced users. - - -## SIMD provider selection - -On Java 22+ with the Vector API enabled, JVector automatically prefers its Google -Highway native provider when the native library is available. Native libraries are -bundled for Linux x86-64 (SSE4.2 or newer) and Linux AArch64. Use these JVM options: +JVector is published to Maven Central as `io.github.jbellis:jvector`. Run with these JVM options to +enable SIMD acceleration (see [SIMD and native acceleration](#simd-and-native-acceleration)): ```text --add-modules jdk.incubator.vector --enable-native-access=ALL-UNNAMED ``` -Omitting native access can produce restricted-method warnings. If native access is -denied by the runtime or JVM options, automatic selection falls back to Panama; -an explicitly forced native provider fails during initialization. For named-module -deployments, enable native access for the module containing JVector's native code. - -Operations implemented by Highway use native kernels; remaining operations use the -inherited Panama/Java Vector API implementation with native vector storage. Native -ISA selection remains automatic and respects the existing `JVECTOR_MAX_ISA` cap. - -| JVM property | Effect | -| --- | --- | -| No provider property | Prefer native on Java 22+; use Panama when native is unavailable | -| `-Djvector.enable_native_vectorization=false` | Disable automatic native selection | -| `-Djvector.vectorization_provider=panama` | Explicitly select Panama | -| `-Djvector.vectorization_provider=native` | Explicitly require native; fail if it cannot load | -| `-Djvector.vectorization_provider=default` | Explicitly select the scalar provider | - -The explicit `jvector.vectorization_provider` property takes precedence over the -native-enable property. The former -`jvector.experimental.enable_native_vectorization` name is a deprecated compatibility -alias; the new `jvector.enable_native_vectorization` name takes precedence when both -are supplied. Both names default to `true` when neither is supplied. Provider -selection happens once per JVM. Java 20/21 uses -Panama when available. Older runtimes, or runtimes without the Vector API module, -retain the scalar fallback. Neither Panama nor native is enabled by these defaults -when the existing runtime compatibility checks reject SIMD support. - -Native selection also changes the concrete vector type to `MemorySegmentVectorFloat`. -Use the `VectorFloat` interface instead of casting to `ArrayVectorFloat` or assuming -that `VectorFloat.get()` returns a `float[]`. Both built-in float vector types support -`FloatArray.array()` for array access and `copyFrom` between their storage types. -Native vector operations still require vectors created by the selected provider; -copy external vectors into provider-created vectors before passing them to those -operations. To retain the previous provider and storage choice, disable automatic -native selection with `-Djvector.enable_native_vectorization=false`. - -## The research behind the algorithms - -* Foundational work: [HNSW](https://ieeexplore.ieee.org/abstract/document/8594636) and [DiskANN](https://suhasjs.github.io/files/diskann_neurips19.pdf) papers, and [a higher level explainer](https://www.datastax.com/guides/hierarchical-navigable-small-worlds) -* [Anisotropic PQ paper](https://arxiv.org/abs/1908.10396) -* [Quicker ADC paper](https://arxiv.org/abs/1812.09162) -* [NVQ paper](https://arxiv.org/abs/2509.18471) - -## Developing and Testing -This project is organized as a [multimodule Maven build](https://maven.apache.org/guides/mini/guide-multiple-modules.html). The intent is to produce a multirelease jar suitable for use as -a dependency from any Java 11 code. When run on a Java 20+ JVM with the Vector module enabled, optimized vector -providers will be used. In general, the project is structured to be built with JDK 20+, but when `JAVA_HOME` is set to -Java 11 -> Java 19, certain build features will still be available. - -### Cloning - -This repository uses a Git submodule for [Google Highway](https://github.com/google/highway), located at -`jvector-native/src/main/native/third_party/highway`. After cloning, initialise it with: +The fastest way in is the hello-world programs in [jvector-examples](./jvector-examples). They download +small public datasets on first use. Build them once from the repository root: ```bash git submodule update --init +mvn -pl jvector-examples -am -DskipTests package +JAR=$(ls jvector-examples/target/jvector-examples-*-jar-with-dependencies.jar) ``` -Or clone with submodules in one step: +| Program | What it shows | +| --- | --- | +| [HelloVectorWorld](./jvector-examples/src/main/java/io/github/jbellis/jvector/example/HelloVectorWorld.java) | Build an in-memory graph and search it, in about twenty lines | +| [HelloASHWorldFp16](./jvector-examples/src/main/java/io/github/jbellis/jvector/example/HelloASHWorldFp16.java) | Train ASH, build a graph, write a fused ASH index with NVQ reranking, reopen it, and search synchronously and through the queued API | +| [HelloASHWorldInt8](./jvector-examples/src/main/java/io/github/jbellis/jvector/example/HelloASHWorldInt8.java) | The same with INT8 query projection, for processors with VNNI or AMX | +| [HelloBatchedVectors](./jvector-examples/src/main/java/io/github/jbellis/jvector/example/HelloBatchedVectors.java) and [HelloBatchedQuantization](./jvector-examples/src/main/java/io/github/jbellis/jvector/example/HelloBatchedQuantization.java) | Batched access to file-backed vectors, and encoding them with PQ and NVQ | ```bash -git clone --recurse-submodules +java --add-modules jdk.incubator.vector --enable-native-access=ALL-UNNAMED \ + -cp "$JAR" io.github.jbellis.jvector.example.HelloVectorWorld ``` -### Building native libraries +The [examples README](./jvector-examples/README.md) covers each program, and the +[tutorials](./docs/tutorials/) walk through the API step by step, starting with +[the introduction](./docs/tutorials/1-intro-tutorial.md). -**Native builds include oneDNN by default for ASH on JDK 22 or newer.** -It is automatically preferred for FP32 base-vector batch projection and FP16 -query projection on supported CPUs. Highway FP32 remains the portable fallback. -Install CMake 3.13+ in addition to the C++ compiler, Meson, and Ninja, then build -from the repository root: +Integrators should also read: -```bash -mvn -pl jvector-native -am package -``` +- [Batched input vector access](./docs/batched-vector-access.md) for file-backed input datasets. +- [Deferred batch serialization](./docs/deferred-batch-writer.md) for writing completed graphs. +- [UPGRADING.md](./UPGRADING.md) for API and on-disk format changes between releases. -Meson fetches the pinned oneDNN source and Maven packages its statically linked -add-on with the native JAR. Deploy that JAR with the native provider; no separate -oneDNN runtime installation or per-machine library symlinks are required. -Use `-Dnative.onednn=false` to build a Highway-only package with FP32 fallback. -See the [oneDNN installation and fallback details](./jvector-native/src/main/native/README.md#recommended-onednn-build-and-package). -For explicit INT8 query projection and processor-family recommendations, see -[Choosing ASH query-projection precision](./docs/ash-projection-precision.md). +## Choosing a quantizer +| Quantizer | Use it for | Notes | +| --- | --- | --- | +| **ASH** | Graph traversal at high compression | Most accurate at a given code size; fused or in memory; dot product only | +| **PQ** | Graph traversal at moderate compression | Fused or in memory; any similarity; optional anisotropic weighting | +| **BQ** | Very fast, coarse traversal | One bit per dimension | +| **NVQ** | Reranking | Stored in the graph; high accuracy at about a quarter of FP32's size | -The native SIMD library (`libjvector.so`) is built with [Meson](https://mesonbuild.com/) + [Ninja](https://ninja-build.org/) -and requires **g++ 11+**. Supported platforms: **Linux x86-64** (SSE4.2, AVX2, AVX-512) and **Linux AArch64** (NEON, SVE, SVE2). -The entry-point script is `jvector-native/src/main/native/src/build_native_lib.sh`. Run it from that directory. +### ASH -```bash -cd jvector-native/src/main/native/src -bash build_native_lib.sh -``` +ASH (Asymmetric Scalar Hashing) reduces the dimension of database vectors with a learned orthonormal +projection, then scalar-quantizes each projected dimension to a few bits, while keeping queries in +their original form. At the same code size it is more accurate than additive and scalar quantizers, +trains and encodes quickly, and scores with SIMD lookups. JVector is the first graph index to support +it. On 6M and 10M datasets at matched code sizes, fused ASH built faster than fused PQ, was more +accurate in every case, and was faster in nearly every case; see [ASH performance](./docs/ash-performance.md). + +Start with the HelloASHWorld programs above, then: + +- [ASH configuration and defaults](./docs/ash-configuration.md): model parameters, construction + scoring, precision and JVM settings. +- [Integrating ASH queries into a database](./docs/ash-query-integration.md): concurrency, bounded + admission and batched query projection. +- [Choosing ASH query-projection precision](./docs/ash-projection-precision.md): FP16, FP32 or INT8 + by processor. + +ASH scores dot product only, so use `DOT_PRODUCT` with normalized vectors for cosine similarity. +Fused ASH requires on-disk format version 7, which earlier JVector releases cannot read. Training +uses the system BLAS and LAPACK (MKL or OpenBLAS) when installed, with a slower Java fallback. + +## Benchmarking with BenchYAML -On a fresh Ubuntu machine you can install all required dependencies (g++, meson, ninja) and build in one step: +BenchYAML builds and measures indexes from YAML descriptions of datasets, index parameters and +measurements. From the repository root, this runs the default set of small public datasets, which +download on first use: ```bash -cd jvector-native/src/main/native/src -bash build_native_lib.sh --auto-install-deps +mvn compile exec:exec@bench -pl jvector-examples -am ``` -For other distributions the script will print the install commands needed. See -[`jvector-native/src/main/native/README.md`](./jvector-native/src/main/native/README.md) for full build options, -Maven integration, ISA dispatch details, and how to add new kernels. -The [native ABI compatibility guide](./jvector-native/src/main/native/README.md#native-abi-compatibility) -explains the supported ABI set, startup checks, and how to preserve compatibility -when changing native entry points. +Select datasets by name with `-DbenchArgs="cohere ada002"`, or run one index configuration by path, +such as an [example pipeline](./jvector-examples/yaml-configs/index-parameters/README.md). Compressed +datasets use fused ASH by default. ---- +- [How to run benchmarks](./docs/benchmarking.md): datasets, parameters, and output. +- [BenchYAML Guide](./docs/benchyaml-guide.md): every construction and search field. +- [Benchmark launcher](./jvector-examples/bench-profiles/README.md): rebuild and run one experiment + in one JVM from the command line or IntelliJ. +- [Reproducing ASH versus PQ](./docs/ash-pq-benchmark.md): the published comparison. -Base code is in [jvector-base](./jvector-base) and will be built for Java 11 releases, restricting language features and APIs -appropriately. Code in [jvector-twenty](./jvector-twenty) will be compiled for Java 20 language features/APIs and included in the final -multirelease jar targeting supported JVMs. [jvector-multirelease](./jvector-multirelease) packages [jvector-base](./jvector-base) and [jvector-twenty](./jvector-twenty) as a -multirelease jar for release. [jvector-examples](./jvector-examples) is an additional sibling module that uses the reactor-representation of -jvector-base/jvector-twenty to run example code. [jvector-tests](./jvector-tests) contains tests for the project, capable of running against -both Java 11 and Java 20+ JVMs. +## SIMD and native acceleration -To run tests, use `mvn test`. To run tests against Java 20+, use `mvn test`. To run tests against Java 11, use `mvn -Pjdk11 test`. -To run a single test class, use the Maven Surefire test filtering capability, e.g., -`mvn -Dsurefire.failIfNoSpecifiedTests=false -Dtest=TestNeighborArray test`. -You may also use method-level filtering and patterns, e.g., -`mvn -Dsurefire.failIfNoSpecifiedTests=false -Dtest=TestNeighborArray#testRetain* test`. -(The `failIfNoSpecifiedTests` option works around a quirk of surefire: it is happy to run `test` with submodules with empty test sets, -but as soon as you supply a filter, it wants at least one match in every submodule.) +On Java 22 and later, JVector prefers its native provider, built on +[Google Highway](https://github.com/google/highway), when the native library is available. Native +libraries are bundled for Linux x86-64 (SSE4.2 or newer) and Linux AArch64. Java 20 and 21 use the +Panama Vector API, and older runtimes use a scalar Java implementation. -You can run `SiftSmall` and `Bench` directly to get an idea of what all is going on here. `Bench` will automatically download required datasets to the `dataset_cache` directory. -The files used by `SiftSmall` can be found in the [siftsmall directory](./siftsmall) in the project root. +| JVM property | Effect | +| --- | --- | +| None | Prefer native on Java 22+, else Panama, else scalar | +| `-Djvector.enable_native_vectorization=false` | Disable automatic native selection | +| `-Djvector.vectorization_provider=panama` | Select Panama | +| `-Djvector.vectorization_provider=native` | Require native; fail if it cannot load | +| `-Djvector.vectorization_provider=default` | Select the scalar provider | -To run either class, you can use the Maven exec-plugin via the following incantations: +If native access is denied, automatic selection falls back to Panama. For named-module deployments, +enable native access for the module containing JVector's native code. Native ISA selection is +automatic and can be capped with `JVECTOR_MAX_ISA`. -> `mvn compile exec:exec@bench` +The native provider stores vectors as `MemorySegmentVectorFloat`. Use the `VectorFloat` interface +rather than casting to `ArrayVectorFloat`; `FloatArray.array()` gives array access, and native +operations need vectors created by the selected provider. -or for Sift: +ASH uses native kernels for query projection and scoring, and an optional add-on built on +[oneDNN](https://github.com/uxlfoundation/oneDNN) for FP16 and INT8 query projection and batch +encoding. Each falls back to a portable implementation when unavailable. -> `mvn compile exec:exec@sift` +## Research -`Bench` takes an optional `benchArgs` argument that can be set to a list of whitespace-separated regexes. If any of the -provided regexes match within a dataset name, that dataset will be included in the benchmark. For example, to run only the glove -and nytimes datasets, you could use: +- Foundations: the [HNSW](https://ieeexplore.ieee.org/abstract/document/8594636) and + [DiskANN](https://suhasjs.github.io/files/diskann_neurips19.pdf) papers, and + [a higher-level explainer](https://www.datastax.com/guides/hierarchical-navigable-small-worlds) +- [ASH: Asymmetric Scalar Hashing With Learned Dimensionality Reduction for High-Fidelity Vector Quantization](https://arxiv.org/abs/2606.07870) (CIKM 2026) +- [NVQ](https://arxiv.org/abs/2509.18471) +- [Anisotropic PQ](https://arxiv.org/abs/1908.10396) +- [Quicker ADC](https://arxiv.org/abs/1812.09162) -> `mvn compile exec:exec@bench -DbenchArgs="glove nytimes"` +## Building and testing -To run Sift/Bench without the JVM vector module available, you can use the following invocations: +JVector is a [multimodule Maven build](https://maven.apache.org/guides/mini/guide-multiple-modules.html) +that produces a multirelease JAR usable from Java 11: -> `mvn -Pjdk11 compile exec:exec@bench` +| Module | Contents | +| --- | --- | +| [jvector-base](./jvector-base) | Core library, compiled for Java 11 | +| [jvector-twenty](./jvector-twenty) | Panama Vector API provider, compiled for Java 20 | +| [jvector-native](./jvector-native) | Native provider and libraries, Java 22 | +| [jvector-multirelease](./jvector-multirelease) | Packages the three modules above as the published `jvector` JAR | +| [jvector-examples](./jvector-examples) | Examples, BenchYAML and benchmarks | +| [jvector-tests](./jvector-tests) | Tests, runnable on Java 11 and newer | -> `mvn -Pjdk11 compile exec:exec@sift` +Build with JDK 22 or newer. The Highway sources are a Git submodule: -The `... -Pjdk11` invocations will also work with `JAVA_HOME` pointing at a Java 11 installation. +```bash +git clone --recurse-submodules https://github.com/datastax/jvector.git +# or, in an existing clone: +git submodule update --init +``` + +The native library is built with [Meson](https://mesonbuild.com/) and [Ninja](https://ninja-build.org/) +and needs g++ 11 or newer; the oneDNN add-on also needs CMake 3.13 or newer, and Meson fetches its +pinned source. Maven builds both and packages them in the native JAR; `-Dnative.onednn=false` builds +without oneDNN. The [native library README](./jvector-native/src/main/native/README.md) covers build +options, ISA dispatch, adding kernels and +[ABI compatibility](./jvector-native/src/main/native/README.md#native-abi-compatibility). +On Ubuntu, `bash build_native_lib.sh --auto-install-deps` (in `jvector-native/src/main/native/src`) +installs the toolchain and builds the library. + +Run the tests with: -See [How to run benchmarks](./docs/benchmarking.md) for dataset setup and commands, and the -[BenchYAML Guide](./docs/benchyaml-guide.md) for construction and search configuration. +```bash +mvn test # all modules, with the SIMD providers on JDK 20+ +mvn -DskipScalar=false test # also run the scalar provider tests +mvn -Pjdk11 test # against Java 11 +mvn -Dsurefire.failIfNoSpecifiedTests=false -Dtest=TestNeighborArray test # one class +``` -To release, configure `~/.m2/settings.xml` to point to OSSRH and run `mvn -Prelease clean deploy`. +`-Dtest` also accepts methods and patterns, such as `TestNeighborArray#testRetain*`. +`failIfNoSpecifiedTests=false` is needed because Surefire otherwise requires a match in every module. ---- +To release, configure `~/.m2/settings.xml` for OSSRH and run `mvn -Prelease clean deploy`. From 1587e76fe2706bb1680ffcc859a39a152ba1f891 Mon Sep 17 00:00:00 2001 From: Ted Willke Date: Mon, 9 Nov 2026 18:25:58 -0800 Subject: [PATCH 197/243] Install CMake for oneDNN in Linux CI --- .github/workflows/run-bench.yml | 4 ++-- .github/workflows/run-compaction.yml | 4 ++-- .github/workflows/unit-tests-arm64.yaml | 4 ++-- .github/workflows/unit-tests.yaml | 8 ++++---- 4 files changed, 10 insertions(+), 10 deletions(-) diff --git a/.github/workflows/run-bench.yml b/.github/workflows/run-bench.yml index 131a6d5bb..e37032d16 100644 --- a/.github/workflows/run-bench.yml +++ b/.github/workflows/run-bench.yml @@ -92,9 +92,9 @@ jobs: - name: Set up GCC run: | sudo apt install -y gcc - - name: Install Meson and Ninja + - name: Install native build dependencies run: | - sudo apt update && sudo apt install -y meson ninja-build + sudo apt update && sudo apt install -y cmake meson ninja-build - uses: actions/checkout@v4 - name: Set up JDK ${{ matrix.jdk }} uses: actions/setup-java@v3 diff --git a/.github/workflows/run-compaction.yml b/.github/workflows/run-compaction.yml index 0f9496779..81196b309 100644 --- a/.github/workflows/run-compaction.yml +++ b/.github/workflows/run-compaction.yml @@ -57,9 +57,9 @@ jobs: steps: - name: Set up GCC run: sudo apt install -y gcc - - name: Install Meson and Ninja + - name: Install native build dependencies run: | - sudo apt update && sudo apt install -y meson ninja-build + sudo apt update && sudo apt install -y cmake meson ninja-build - uses: actions/checkout@v4 - name: Set up JDK ${{ matrix.jdk }} uses: actions/setup-java@v3 diff --git a/.github/workflows/unit-tests-arm64.yaml b/.github/workflows/unit-tests-arm64.yaml index a54844fc6..59e24f784 100644 --- a/.github/workflows/unit-tests-arm64.yaml +++ b/.github/workflows/unit-tests-arm64.yaml @@ -53,9 +53,9 @@ jobs: run: | sudo apt install -y gcc g++ - - name: Install Meson, Ninja, and GTest + - name: Install native build dependencies and GTest run: | - sudo apt update && sudo apt install -y meson ninja-build pkg-config libgtest-dev + sudo apt update && sudo apt install -y cmake meson ninja-build pkg-config libgtest-dev - uses: actions/checkout@v4 diff --git a/.github/workflows/unit-tests.yaml b/.github/workflows/unit-tests.yaml index 2fce3141e..6f8663419 100644 --- a/.github/workflows/unit-tests.yaml +++ b/.github/workflows/unit-tests.yaml @@ -50,9 +50,9 @@ jobs: - name: Set up GCC run: | sudo apt install -y gcc - - name: Install Meson, Ninja, and GTest + - name: Install native build dependencies and GTest run: | - sudo apt update && sudo apt install -y meson ninja-build pkg-config libgtest-dev + sudo apt update && sudo apt install -y cmake meson ninja-build pkg-config libgtest-dev - uses: actions/checkout@v4 - name: Initialize Git Submodules run: git submodule update --init @@ -159,10 +159,10 @@ jobs: if: matrix.os == 'ubuntu-latest' run: | sudo apt install -y gcc - - name: Install Meson and Ninja + - name: Install native build dependencies if: matrix.os == 'ubuntu-latest' run: | - sudo apt update && sudo apt install -y meson ninja-build + sudo apt update && sudo apt install -y cmake meson ninja-build - name: Compile, run tests, and package (JDK 22) run: mvn -B verify if: matrix.jdk == '22' From b16ffc114682142f4d32ed27bcccbd285e37b75b Mon Sep 17 00:00:00 2001 From: Ted Willke Date: Mon, 9 Nov 2026 18:30:35 -0800 Subject: [PATCH 198/243] Guard the ASH native subprocess test on hosts without the library --- .../jbellis/jvector/vector/NativeASHSingleScoringTest.java | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/jvector-native/src/test/java/io/github/jbellis/jvector/vector/NativeASHSingleScoringTest.java b/jvector-native/src/test/java/io/github/jbellis/jvector/vector/NativeASHSingleScoringTest.java index 04b523a15..9a8de0a4b 100644 --- a/jvector-native/src/test/java/io/github/jbellis/jvector/vector/NativeASHSingleScoringTest.java +++ b/jvector-native/src/test/java/io/github/jbellis/jvector/vector/NativeASHSingleScoringTest.java @@ -18,11 +18,13 @@ import io.github.jbellis.jvector.graph.*; import io.github.jbellis.jvector.graph.similarity.*; import io.github.jbellis.jvector.quantization.*; +import io.github.jbellis.jvector.vector.cnative.LibraryLoader; import io.github.jbellis.jvector.vector.types.VectorFloat; import java.util.*; import java.util.concurrent.*; import org.junit.jupiter.api.Test; import static org.junit.jupiter.api.Assertions.*; +import static org.junit.jupiter.api.Assumptions.assumeTrue; /** Native default dispatch must preserve independent, query-owned prepared state. */ class NativeASHSingleScoringTest { @@ -47,6 +49,10 @@ private static final class Values implements RandomAccessVectorValues { } @Test void defaultSingleScorersUseNativeWithoutSilentFallback() throws Exception { + boolean available = LibraryLoader.loadJvector(); + if (Boolean.getBoolean("jvector.test.requireNative")) assertTrue(available, "Native library required"); + else assumeTrue(available, "Native library unavailable"); + var output = java.nio.file.Files.createTempFile("ash-default-native-", ".log"); Process child = null; try { From f29789f1f7bfbe99a91fe8d7668c6e16f5f6e982 Mon Sep 17 00:00:00 2001 From: Ted Willke Date: Mon, 9 Nov 2026 18:52:37 -0800 Subject: [PATCH 199/243] Include KleidiAI objects in the ARM oneDNN add-on --- jvector-native/src/main/native/meson.build | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/jvector-native/src/main/native/meson.build b/jvector-native/src/main/native/meson.build index 46b81c868..00c13b19e 100644 --- a/jvector-native/src/main/native/meson.build +++ b/jvector-native/src/main/native/meson.build @@ -255,8 +255,13 @@ if get_option('onednn').enabled() # oneDNN's aggregate archive has no source files of its own. Meson's CMake # converter marks that aggregate non-PIC, although all constituent object # libraries are PIC. Re-archive those objects with the correct metadata. + onednn_archive_inputs = [onednn_project.target('dnnl').extract_all_objects(recursive: true)] + # The CMake build links KleidiAI as a separate static target on AArch64. + if 'kleidiai_ops' in onednn_project.target_list() + onednn_archive_inputs += [onednn_project.target('kleidiai_ops').extract_all_objects(recursive: true)] + endif onednn_objects = static_library('ash_onednn', - objects: onednn_project.target('dnnl').extract_all_objects(recursive: true), + objects: onednn_archive_inputs, pic: true, ) onednn_dependency = onednn_project.dependency('dnnl').partial_dependency( From 3507c3cbfd5ad47de5f7f85043bd846cd00a182d Mon Sep 17 00:00:00 2001 From: Ted Willke Date: Mon, 9 Nov 2026 19:44:50 -0800 Subject: [PATCH 200/243] Avoid the masked gather in Panama ASH projection scoring JDK 20's C2 miscompiles the masked AVX-512 gather in ashProjectionDot: on AVX-512 hosts it returns wrong ASH scores or crashes the JVM, while JDK 21 and later are correct. Inactive lanes of a partial block already gather a valid query element, so the gather now runs unmasked and the mask applies to the accumulation instead. On an AVX-512 host with Temurin 20.0.2, the jvector-tests suite passes (542 tests) without capping AVX; the ASH tests also pass on JDK 21, and mvn verify passes on JDK 22. --- .../jbellis/jvector/vector/PanamaVectorUtilSupport.java | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) 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 7586597d2..96d094a9c 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 @@ -297,12 +297,13 @@ public float ashProjectionDot(float[] query, byte[] code, int codeOffset, int di .convertShape(VectorOperators.I2F, ASH_FLOATS, 0)).add(0.5f); var values = magnitude.blend(magnitude.neg(), fields.and(sign) .compare(VectorOperators.EQ, 0).cast(ASH_FLOATS)); - // A masked gather may bounds-check inactive indices. Point those lanes at - // the first valid query element for a partial final block. + // For a partial final block, inactive lanes gather the first valid query + // element, so the gather itself needs no mask; the mask applies to the sum. + // A masked AVX-512 gather is miscompiled by JDK 20's C2, so avoid it. int[] gatherIndices = active == ASH_FLOATS.length() ? indices : (bitsPerDimension == 2 ? ASH_TWO_BIT_TAIL_INDICES : ASH_FOUR_BIT_TAIL_INDICES)[active]; - var q = FloatVector.fromArray(ASH_FLOATS, query, base * perByte + slot, gatherIndices, 0, mask); - sum = sum.add(q.mul(values)); + var q = FloatVector.fromArray(ASH_FLOATS, query, base * perByte + slot, gatherIndices, 0); + sum = sum.add(q.mul(values), mask); } } return sum.reduceLanes(VectorOperators.ADD); From 9b5819acc204c656e0feb142801f704a6c6ed401 Mon Sep 17 00:00:00 2001 From: Ted Willke Date: Mon, 9 Nov 2026 22:34:12 -0800 Subject: [PATCH 201/243] Mark the key ASH APIs as experimental AsymmetricHashing, ASHVectors, FusedASH, ASHQueryPreparer, the QueryPreparer and PreparedQuery interfaces, and BlasThreads may change in later releases without notice. --- .../io/github/jbellis/jvector/graph/disk/feature/FusedASH.java | 2 ++ .../github/jbellis/jvector/graph/similarity/PreparedQuery.java | 2 ++ .../github/jbellis/jvector/graph/similarity/QueryPreparer.java | 2 ++ .../github/jbellis/jvector/quantization/ASHQueryPreparer.java | 2 ++ .../java/io/github/jbellis/jvector/quantization/ASHVectors.java | 2 ++ .../github/jbellis/jvector/quantization/AsymmetricHashing.java | 2 ++ .../main/java/io/github/jbellis/jvector/util/BlasThreads.java | 2 ++ 7 files changed, 14 insertions(+) diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/graph/disk/feature/FusedASH.java b/jvector-base/src/main/java/io/github/jbellis/jvector/graph/disk/feature/FusedASH.java index 52e5aeceb..be9a00f39 100644 --- a/jvector-base/src/main/java/io/github/jbellis/jvector/graph/disk/feature/FusedASH.java +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/graph/disk/feature/FusedASH.java @@ -16,6 +16,7 @@ package io.github.jbellis.jvector.graph.disk.feature; +import io.github.jbellis.jvector.annotations.Experimental; import io.github.jbellis.jvector.disk.IndexWriter; import io.github.jbellis.jvector.disk.RandomAccessReader; import io.github.jbellis.jvector.graph.ImmutableGraphIndex; @@ -54,6 +55,7 @@ * Core standalone ASH may support additional bit widths, but those are not part * of the fused graph hot path.

    */ +@Experimental public class FusedASH extends AbstractFeature implements FusedFeature { private static final long INLINE_SOURCE_BYTES = RamUsageEstimator.shallowSizeOfInstance(FusedASHInlineSource.class); private final int maxDegree; diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/graph/similarity/PreparedQuery.java b/jvector-base/src/main/java/io/github/jbellis/jvector/graph/similarity/PreparedQuery.java index 8e3d46100..7711321ab 100644 --- a/jvector-base/src/main/java/io/github/jbellis/jvector/graph/similarity/PreparedQuery.java +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/graph/similarity/PreparedQuery.java @@ -15,6 +15,7 @@ */ package io.github.jbellis.jvector.graph.similarity; +import io.github.jbellis.jvector.annotations.Experimental; import io.github.jbellis.jvector.util.Accountable; import io.github.jbellis.jvector.vector.types.VectorFloat; @@ -28,6 +29,7 @@ * the usual executor, queue or other happens-before handoff. No executor or * request queue is created by this API. */ +@Experimental public interface PreparedQuery extends Accountable { /** Borrowed FP32 query snapshot for reranking; callers must not modify it. */ VectorFloat queryVector(); diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/graph/similarity/QueryPreparer.java b/jvector-base/src/main/java/io/github/jbellis/jvector/graph/similarity/QueryPreparer.java index c3d9575d6..6f8d84e54 100644 --- a/jvector-base/src/main/java/io/github/jbellis/jvector/graph/similarity/QueryPreparer.java +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/graph/similarity/QueryPreparer.java @@ -15,6 +15,7 @@ */ package io.github.jbellis.jvector.graph.similarity; +import io.github.jbellis.jvector.annotations.Experimental; import io.github.jbellis.jvector.util.Accountable; import io.github.jbellis.jvector.vector.types.VectorFloat; @@ -23,6 +24,7 @@ * thread. Distinct output states may be prepared concurrently. The caller owns * scheduling, admission, input lifetime during each call, and output reuse. */ +@Experimental public interface QueryPreparer extends Accountable { /** Allocates one reusable state. Shared model weights are not copied. */ PreparedQuery newState(); diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHQueryPreparer.java b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHQueryPreparer.java index dbf0ea8e9..02474a6e6 100644 --- a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHQueryPreparer.java +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHQueryPreparer.java @@ -15,6 +15,7 @@ */ package io.github.jbellis.jvector.quantization; +import io.github.jbellis.jvector.annotations.Experimental; import io.github.jbellis.jvector.graph.similarity.PreparedQuery; import io.github.jbellis.jvector.graph.similarity.QueryPreparer; import io.github.jbellis.jvector.util.RamUsageEstimator; @@ -29,6 +30,7 @@ * matrix as synchronous scoring, and calls an already formed batch directly. * Encoding and training representations are unaffected. */ +@Experimental public final class ASHQueryPreparer implements QueryPreparer { private final AsymmetricHashing ash; private final ASHScorer scorer; diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHVectors.java b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHVectors.java index aac32cd07..6d4aa10f9 100644 --- a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHVectors.java +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHVectors.java @@ -16,6 +16,7 @@ package io.github.jbellis.jvector.quantization; +import io.github.jbellis.jvector.annotations.Experimental; import io.github.jbellis.jvector.disk.IndexWriter; import io.github.jbellis.jvector.disk.RandomAccessReader; import io.github.jbellis.jvector.graph.similarity.ScoreFunction; @@ -47,6 +48,7 @@ * {@link FusedASHDecoder}, from the on-disk index, using the same similarity scale and * shared numerical kernels.

    */ +@Experimental public class ASHVectors implements CompressedVectors { final AsymmetricHashing ash; diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/AsymmetricHashing.java b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/AsymmetricHashing.java index 59c70352c..9bc47dc28 100644 --- a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/AsymmetricHashing.java +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/AsymmetricHashing.java @@ -16,6 +16,7 @@ package io.github.jbellis.jvector.quantization; +import io.github.jbellis.jvector.annotations.Experimental; import io.github.jbellis.jvector.annotations.VisibleForTesting; import io.github.jbellis.jvector.disk.IndexWriter; import io.github.jbellis.jvector.disk.RandomAccessReader; @@ -54,6 +55,7 @@ * orthonormal projection. The 1-bit case stores only sign bits; multibit ASH * stores either generic sign/extra bits or the C++-style fast-scan projection code for 2/4-bit ASH. */ +@Experimental public class AsymmetricHashing implements VectorCompressor, Accountable { private static final org.slf4j.Logger LOG = org.slf4j.LoggerFactory.getLogger(AsymmetricHashing.class); public static final int ITQ = 1, RANDOM = 2; diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/util/BlasThreads.java b/jvector-base/src/main/java/io/github/jbellis/jvector/util/BlasThreads.java index e85f2b93c..144bf16a4 100644 --- a/jvector-base/src/main/java/io/github/jbellis/jvector/util/BlasThreads.java +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/util/BlasThreads.java @@ -15,6 +15,7 @@ */ package io.github.jbellis.jvector.util; +import io.github.jbellis.jvector.annotations.Experimental; import io.github.jbellis.jvector.vector.VectorizationProvider; /** @@ -30,6 +31,7 @@ * *

    Scopes nest: closing one restores the count that was in effect when it opened.

    */ +@Experimental public final class BlasThreads { private static final ThreadLocal ACTIVE = new ThreadLocal<>(); private static final Scope NONE = () -> { }; From ade1ec909d742714e7def017e0ac6d2fc99917d4 Mon Sep 17 00:00:00 2001 From: Ted Willke Date: Mon, 9 Nov 2026 22:46:45 -0800 Subject: [PATCH 202/243] Use scalar ASH kernels on JDK 20 and drop masked memory ops from projection JDK 20's C2 miscompiles the masked AVX-512 loads and stores in the ASH Panama kernels, crashing the JVM or corrupting the heap (the crash can surface in unrelated PQ frames). JDK 20 is past end of life, so ASH uses its scalar kernels there; the check is a constant on later JDKs. The projection kernel now uses no masked loads or gathers: whole blocks are unmasked, and a partial final block loads unmasked when the array extends past it, masking only the register add. On a Xeon 6985P it is 9-34% faster on JDK 22 and 9-21% faster on JDK 25 across 100-3072 dimensions at 2 and 4 bits. Tail summation may now differ in the last bit by position, so the offset tests compare with a tolerance. --- .../jvector/quantization/TestASHCodes.java | 17 ++-- .../vector/PanamaVectorUtilSupport.java | 79 +++++++++++++------ 2 files changed, 66 insertions(+), 30 deletions(-) diff --git a/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestASHCodes.java b/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestASHCodes.java index 5403c091c..e278d2f98 100644 --- a/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestASHCodes.java +++ b/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestASHCodes.java @@ -157,8 +157,10 @@ public void asymmetricScoresMatchStandaloneVectors() throws Exception { // The 1-bit SIMD kernel accumulates in a different order. assertEquals("bits=1 dim=" + dim + " i=" + i, expected, actual, 1e-5f * Math.max(1f, Math.abs(expected))); } else { - assertEquals("bits=" + bits + " dim=" + dim + " i=" + i, - Float.floatToIntBits(expected), Float.floatToIntBits(actual)); + // A code's tail may be summed in vector or scalar order depending on + // where it sits in its array. + assertEquals("bits=" + bits + " dim=" + dim + " i=" + i, expected, actual, + 1e-5f * Math.max(1f, Math.abs(expected))); } } } @@ -227,10 +229,13 @@ public void offsetKernelsMatchZeroOffsetKernels() { // Exact length: no kernel may read query elements past the dimension count. float[] query = new float[dims]; for (int k = 0; k < dims; k++) query[k] = (float) random.nextGaussian(); - assertEquals(where, Float.floatToIntBits(vu.ashProjectionDot(query, a, dims, bits)), - Float.floatToIntBits(vu.ashProjectionDot(query, aPage, aOff, dims, bits))); - assertEquals(where, Float.floatToIntBits(vu.ashProjectionDotTuned(query, a, dims, bits)), - Float.floatToIntBits(vu.ashProjectionDotTuned(query, aPage, aOff, dims, bits))); + // Summation order may depend on the offset, so allow rounding differences. + float direct = vu.ashProjectionDot(query, a, dims, bits); + assertEquals(where, direct, vu.ashProjectionDot(query, aPage, aOff, dims, bits), + 1e-5f * Math.max(1f, Math.abs(direct))); + float tuned = vu.ashProjectionDotTuned(query, a, dims, bits); + assertEquals(where, tuned, vu.ashProjectionDotTuned(query, aPage, aOff, dims, bits), + 1e-5f * Math.max(1f, Math.abs(tuned))); int groups = FusedASHLayout.codeGroups(dims, bits); float[] lut = new float[groups * 16]; for (int k = 0; k < lut.length; k++) lut[k] = (float) random.nextGaussian(); 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 96d094a9c..31c26a91c 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 @@ -33,6 +33,10 @@ import java.util.List; class PanamaVectorUtilSupport implements VectorUtilSupport { + // JDK 20's C2 miscompiles the masked AVX-512 loads and stores in the ASH kernels, + // which crashes or corrupts the heap. JDK 20 is past end of life, so ASH uses the + // scalar kernels there. The constant folds away on later JDKs. + private static final boolean ASH_SIMD = Runtime.version().feature() != 20; private static final VectorSpecies ASH_FLOATS = FloatVector.SPECIES_PREFERRED; private static final VectorSpecies ASH_INTS = VectorSpecies.of( int.class, ASH_FLOATS.vectorShape()); @@ -58,6 +62,8 @@ private static int[] ashQueryIndices(int dimensionsPerByte) { return indices; } + // Indices for a partial block: inactive lanes repeat the first query element, so the + // gather stays in range without a mask. private static int[][] ashTailQueryIndices(int dimensionsPerByte) { int[][] tails = new int[ASH_FLOATS.length()][]; for (int active = 1; active < tails.length; active++) { @@ -110,7 +116,7 @@ public void ashSymmetricLutScore(byte[] codes, int groups, int stride, int lane, } @Override - public boolean supportsAshSymmetricScoring() { return true; } + public boolean supportsAshSymmetricScoring() { return ASH_SIMD; } @Override public float ashSymmetricDot(long[] aBits, byte[] aCodes, long[] bBits, byte[] bCodes, @@ -126,7 +132,7 @@ public float ashSymmetricDot(long[] aBits, byte[] aCodes, long[] bBits, byte[] b } @Override - public boolean supportsAshSymmetricDotAt() { return true; } + public boolean supportsAshSymmetricDotAt() { return ASH_SIMD; } @Override public float ashSymmetricDotAt(byte[] aCodes, int aOffset, byte[] bCodes, int bOffset, @@ -249,10 +255,10 @@ private static float ashSymmetricFourBit(byte[] aCodes, int aOff, byte[] bCodes, } @Override - public boolean supportsAshProjectionScoring() { return PanamaASHKernels.supported(); } + public boolean supportsAshProjectionScoring() { return ASH_SIMD && PanamaASHKernels.supported(); } @Override - public boolean supportsAshLutScoring() { return PanamaASHKernels.supported(); } + public boolean supportsAshLutScoring() { return ASH_SIMD && PanamaASHKernels.supported(); } @Override public String ashKernelDescription() { return PanamaASHKernels.description(); } @@ -282,31 +288,56 @@ public float ashProjectionDot(float[] query, byte[] code, int codeOffset, int di Objects.checkFromIndexSize(codeOffset, bytes, code.length); int sign = 1 << (bitsPerDimension - 1); int[] indices = bitsPerDimension == 2 ? ASH_TWO_BIT_QUERY_INDICES : ASH_FOUR_BIT_QUERY_INDICES; + int lanes = ASH_FLOATS.length(); + // No masked loads or gathers: JDK 20's C2 miscompiles masked AVX-512 memory + // operations here. Whole blocks need no mask. A partial final block loads its + // bytes unmasked when the array extends past them (extra lanes are discarded), + // gathers with indices that stay in range, and masks only the register addition. + // Otherwise the tail is scalar, so results may differ in the last bit by position. + int fullBytes = (dimensions / (lanes * perByte)) * lanes; + int loadBytes = ASH_BYTES.length(); FloatVector sum = FloatVector.zero(ASH_FLOATS); - for (int base = 0; base < bytes; base += ASH_FLOATS.length()) { - int activeBytes = Math.min(ASH_FLOATS.length(), bytes - base); - var packed = (IntVector) ByteVector.fromArray(ASH_BYTES, code, codeOffset + base, - ASH_BYTES.indexInRange(0, activeBytes)).convertShape(VectorOperators.B2I, ASH_INTS, 0); + int base = 0; + for (; base < fullBytes && base + loadBytes <= bytes; base += lanes) { + var packed = (IntVector) ByteVector.fromArray(ASH_BYTES, code, codeOffset + base) + .convertShape(VectorOperators.B2I, ASH_INTS, 0); + for (int slot = 0; slot < perByte; slot++) { + sum = sum.add(ashProjectionTerm(query, packed, base * perByte + slot, indices, slot, bitsPerDimension)); + } + } + if (base < bytes && bytes - base <= lanes && codeOffset + base + loadBytes <= code.length) { + var packed = (IntVector) ByteVector.fromArray(ASH_BYTES, code, codeOffset + base) + .convertShape(VectorOperators.B2I, ASH_INTS, 0); + int[][] tails = bitsPerDimension == 2 ? ASH_TWO_BIT_TAIL_INDICES : ASH_FOUR_BIT_TAIL_INDICES; for (int slot = 0; slot < perByte; slot++) { int remaining = dimensions - base * perByte - slot; if (remaining <= 0) break; - int active = Math.min(ASH_FLOATS.length(), (remaining - 1) / perByte + 1); - var mask = ASH_FLOATS.indexInRange(0, active); - var fields = packed.lanewise(VectorOperators.LSHR, slot * bitsPerDimension); - var magnitude = ((FloatVector) fields.and(sign - 1) - .convertShape(VectorOperators.I2F, ASH_FLOATS, 0)).add(0.5f); - var values = magnitude.blend(magnitude.neg(), fields.and(sign) - .compare(VectorOperators.EQ, 0).cast(ASH_FLOATS)); - // For a partial final block, inactive lanes gather the first valid query - // element, so the gather itself needs no mask; the mask applies to the sum. - // A masked AVX-512 gather is miscompiled by JDK 20's C2, so avoid it. - int[] gatherIndices = active == ASH_FLOATS.length() ? indices - : (bitsPerDimension == 2 ? ASH_TWO_BIT_TAIL_INDICES : ASH_FOUR_BIT_TAIL_INDICES)[active]; - var q = FloatVector.fromArray(ASH_FLOATS, query, base * perByte + slot, gatherIndices, 0); - sum = sum.add(q.mul(values), mask); + int active = Math.min(lanes, (remaining - 1) / perByte + 1); + int[] gather = active == lanes ? indices : tails[active]; + sum = sum.add(ashProjectionTerm(query, packed, base * perByte + slot, gather, slot, bitsPerDimension), + ASH_FLOATS.indexInRange(0, active)); } + base = bytes; + } + float total = sum.reduceLanes(VectorOperators.ADD); + for (int i = base * perByte; i < dimensions; i++) { + int field = (code[codeOffset + i / perByte] & 255) >>> ((i % perByte) * bitsPerDimension); + float magnitude = (field & (sign - 1)) + 0.5f; + total += query[i] * ((field & sign) == 0 ? -magnitude : magnitude); } - return sum.reduceLanes(VectorOperators.ADD); + return total; + } + + // One slot of a block: decode signed-magnitude fields and multiply by the gathered query. + private static FloatVector ashProjectionTerm(float[] query, IntVector packed, int queryOffset, + int[] gather, int slot, int bitsPerDimension) { + int sign = 1 << (bitsPerDimension - 1); + var fields = packed.lanewise(VectorOperators.LSHR, slot * bitsPerDimension); + var magnitude = ((FloatVector) fields.and(sign - 1) + .convertShape(VectorOperators.I2F, ASH_FLOATS, 0)).add(0.5f); + var values = magnitude.blend(magnitude.neg(), fields.and(sign) + .compare(VectorOperators.EQ, 0).cast(ASH_FLOATS)); + return FloatVector.fromArray(ASH_FLOATS, query, queryOffset, gather, 0).mul(values); } @Override @@ -1916,7 +1947,7 @@ public float ashDotRow(float[] Arow, float[] x) { @Override public boolean supportsAshMaskedLoad() { - return true; + return ASH_SIMD; } @Override From 9418c1920828b3de21f19dc92fb0ddbbed96c95a Mon Sep 17 00:00:00 2001 From: Ted Willke Date: Mon, 9 Nov 2026 22:46:45 -0800 Subject: [PATCH 203/243] Keep the 16-lane ASH neighborhood path off scalable SVE targets On SVE, MaxLanes is only an upper bound on the runtime vector length, so a 128-bit SVE machine took the 16-lane table-lookup path with 4 lanes and scored neighborhoods wrongly. Guard it as the symmetric kernel already does; x86 and NEON compile unchanged. --- .../src/main/native/src/ash_neighborhood-inl.h | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/jvector-native/src/main/native/src/ash_neighborhood-inl.h b/jvector-native/src/main/native/src/ash_neighborhood-inl.h index 807846823..24805e948 100644 --- a/jvector-native/src/main/native/src/ash_neighborhood-inl.h +++ b/jvector-native/src/main/native/src/ash_neighborhood-inl.h @@ -27,10 +27,14 @@ constexpr size_t kAshHeaderBytesPerLane = 5; template HWY_INLINE auto AshLookup(D d, const float *lut, I indices) { const hn::Rebind di; +#if !HWY_HAVE_SCALABLE + // On SVE, MaxLanes is only an upper bound on the runtime vector length. if constexpr (hn::MaxLanes(d) == 16) { return hn::TableLookupLanes(hn::LoadU(d, lut), hn::IndicesFromVec(d, hn::BitCast(di, indices))); - } else { - // AVX2/scalar do not have a register-wide 16-float permute. + } else +#endif + { + // AVX2, NEON, SVE and scalar do not have a register-wide 16-float permute. return hn::GatherIndex(d, lut, hn::BitCast(di, indices)); } } @@ -166,6 +170,7 @@ int ash_score_neighborhood_c1_f32(const unsigned char *HWY_RESTRICT codes, const // Widen traversal arithmetic so the final base increment cannot overflow int. for (size_t base = 0; base < static_cast(capacity); base += blockSize, codes += blockBytes) { const int count = std::min(blockSize, capacity - static_cast(base)); +#if !HWY_HAVE_SCALABLE if constexpr (hn::MaxLanes(d) == 16) { if (blockSize == 32 && count == 32) { int status = AshBlock32(d, codes, lut, groups, landmarkDot, out + base); @@ -174,6 +179,7 @@ int ash_score_neighborhood_c1_f32(const unsigned char *HWY_RESTRICT codes, const continue; } } +#endif for (int lane = 0; lane < count; lane += lanes) { const int active = std::min(lanes, count - lane); auto lo = hn::Zero(d), hi = hn::Zero(d); From 4fd3af07b08d8c4d8810573d3943ff0da1ccb17e Mon Sep 17 00:00:00 2001 From: Ted Willke Date: Mon, 9 Nov 2026 22:46:45 -0800 Subject: [PATCH 204/243] Exclude Meson's subproject lock and package cache from RAT Newer Meson writes subprojects/.wraplock (already gitignored), which failed the license check after a native build. --- jvector-native/pom.xml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/jvector-native/pom.xml b/jvector-native/pom.xml index a6c62c934..1759e0003 100644 --- a/jvector-native/pom.xml +++ b/jvector-native/pom.xml @@ -43,6 +43,8 @@ src/main/native/subprojects/onednn/** + src/main/native/subprojects/packagecache/** + src/main/native/subprojects/.wraplock From 14e82bf84ab41561ee3db0d309447a50aab18864 Mon Sep 17 00:00:00 2001 From: Ted Willke Date: Mon, 9 Nov 2026 22:46:45 -0800 Subject: [PATCH 205/243] Restore the regression-tests dataset section used by CI benchmarks AutoBenchYAML selects CI datasets from this section, which had been commented out. BenchYAML skips it unless a dataset is named, so default runs stay on the public datasets. --- .../jbellis/jvector/example/AutoBenchYAML.java | 2 +- .../jbellis/jvector/example/BenchYAML.java | 2 ++ .../jvector/example/yaml/DatasetCollection.java | 13 +++++++++++++ jvector-examples/yaml-configs/datasets.yml | 16 +++++++++------- 4 files changed, 25 insertions(+), 8 deletions(-) diff --git a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/AutoBenchYAML.java b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/AutoBenchYAML.java index 6113baa25..d81ad3bb1 100644 --- a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/AutoBenchYAML.java +++ b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/AutoBenchYAML.java @@ -53,7 +53,7 @@ */ public class AutoBenchYAML { private static final Logger logger = LoggerFactory.getLogger(AutoBenchYAML.class); - private static final String REGRESSION_TEST_KEY = "regression-tests"; + private static final String REGRESSION_TEST_KEY = DatasetCollection.REGRESSION_TESTS; public static void main(String[] args) throws IOException { try (var projectionSetting = new SearchParameters.AshQueryProjectionSetting()) { diff --git a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/BenchYAML.java b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/BenchYAML.java index 844894bdc..769787a70 100644 --- a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/BenchYAML.java +++ b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/BenchYAML.java @@ -69,6 +69,8 @@ private static void run(String[] args, SearchParameters.AshQueryProjectionSettin var datasetCollection = DatasetCollection.load(); var datasetNames = regex.isEmpty() && !configNames.isEmpty() ? List.of() + : regex.isEmpty() + ? datasetCollection.getAllExcept(DatasetCollection.REGRESSION_TESTS) : datasetCollection.getAll().stream().filter(dn -> pattern.matcher(dn).find()).collect(Collectors.toList()); List allConfigs = new ArrayList<>(); diff --git a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/yaml/DatasetCollection.java b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/yaml/DatasetCollection.java index fc1f7f351..3b4fb6e78 100644 --- a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/yaml/DatasetCollection.java +++ b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/yaml/DatasetCollection.java @@ -28,6 +28,8 @@ public class DatasetCollection { private static final String defaultFile = "./jvector-examples/yaml-configs/datasets.yml"; + /** The section CI benchmarks select from; its datasets need the protected catalog. */ + public static final String REGRESSION_TESTS = "regression-tests"; public final Map> datasetNames; @@ -56,6 +58,17 @@ public List getAll() { return allDatasetNames; } + /** All dataset names outside the given section. */ + public List getAllExcept(String section) { + List names = new ArrayList<>(); + for (var entry : datasetNames.entrySet()) { + if (!entry.getKey().equals(section) && entry.getValue() != null) { + names.addAll(entry.getValue()); + } + } + return names; + } + public List getSection(String section) { List sectionDatasetNames = new ArrayList<>(); for (var key : datasetNames.keySet()) { diff --git a/jvector-examples/yaml-configs/datasets.yml b/jvector-examples/yaml-configs/datasets.yml index 3420dc00b..abcd1175b 100644 --- a/jvector-examples/yaml-configs/datasets.yml +++ b/jvector-examples/yaml-configs/datasets.yml @@ -22,13 +22,15 @@ ann-benchmarks: # - glove-100-angular # - glove-200-angular # - nytimes-256-angular -#regression-tests: -# - cap-1M -# - cap-6M -# - cohere-english-v3-1M -# - cohere-english-v3-10M -# - dpr-gemma-1m -# - dpr-gemma-10m +# The regression-tests section is what AutoBenchYAML (CI) selects from. Its datasets come +# from a protected catalog, so BenchYAML skips this section unless a dataset is named. +regression-tests: + - cap-1M + - cap-6M + - cohere-english-v3-1M + - cohere-english-v3-10M + - dpr-gemma-1m + - dpr-gemma-10m #openai-unconfigured: # - openai-3072-1m # - openai-1536-1m From 0681448df7b1e2272094377285fec69a7364d861 Mon Sep 17 00:00:00 2001 From: Ted Willke Date: Tue, 10 Nov 2026 08:56:50 -0800 Subject: [PATCH 206/243] Split the oneDNN add-on into its own Meson project and fetch Highway as a wrap The oneDNN add-on (libjvector_ash_fp16) now lives in jvector-native/src/main/onednn with its own meson.build and onednn.wrap; the Highway project no longer has a oneDNN option. oneDNN stays statically linked so the jar carries one self-contained add-on, but because building it takes minutes, Maven now builds it only with -Dnative.onednn=true. CI and the release profile turn it on. Google Highway moves from a Git submodule to a Meson wrap pinned at the same revision, with a small overlay build file exposing its headers and abort.cc. --- .github/workflows/run-bench.yml | 7 +- .github/workflows/run-compaction.yml | 4 +- .github/workflows/unit-tests-arm64.yaml | 7 +- .github/workflows/unit-tests.yaml | 11 +-- .gitmodules | 3 - README.md | 20 ++--- docs/ash-pq-benchmark.md | 3 +- docs/ash-projection-precision.md | 2 +- jvector-native/pom.xml | 16 ++-- jvector-native/src/main/native/README.md | 68 +++++++++------- jvector-native/src/main/native/meson.build | 69 +++------------- .../src/main/native/src/build_native_lib.sh | 51 ++++++------ .../src/main/native/subprojects/.gitignore | 2 +- .../highway.wrap} | 10 ++- .../packagefiles/highway/meson.build | 27 +++++++ .../src/main/native/third_party/highway | 1 - jvector-native/src/main/onednn/meson.build | 78 +++++++++++++++++++ .../src/jvector_ash_fp16.cpp | 0 .../{native => onednn}/src/jvector_ash_fp16.h | 0 .../src/jvector_ash_int8.cpp | 0 .../src/main/onednn/subprojects/.gitignore | 3 + .../subprojects/onednn.wrap | 0 .../tests/test_ash_fp16.cpp | 0 23 files changed, 222 insertions(+), 160 deletions(-) delete mode 100644 .gitmodules rename jvector-native/src/main/native/{meson_options.txt => subprojects/highway.wrap} (65%) create mode 100644 jvector-native/src/main/native/subprojects/packagefiles/highway/meson.build delete mode 160000 jvector-native/src/main/native/third_party/highway create mode 100644 jvector-native/src/main/onednn/meson.build rename jvector-native/src/main/{native => onednn}/src/jvector_ash_fp16.cpp (100%) rename jvector-native/src/main/{native => onednn}/src/jvector_ash_fp16.h (100%) rename jvector-native/src/main/{native => onednn}/src/jvector_ash_int8.cpp (100%) create mode 100644 jvector-native/src/main/onednn/subprojects/.gitignore rename jvector-native/src/main/{native => onednn}/subprojects/onednn.wrap (100%) rename jvector-native/src/main/{native => onednn}/tests/test_ash_fp16.cpp (100%) diff --git a/.github/workflows/run-bench.yml b/.github/workflows/run-bench.yml index e37032d16..3841b5e7c 100644 --- a/.github/workflows/run-bench.yml +++ b/.github/workflows/run-bench.yml @@ -131,6 +131,8 @@ jobs: ref: ${{ matrix.branch }} fetch-depth: 0 + # Branches from before the Highway Meson wrap still use a submodule; + # this step is a no-op on newer ones. - name: Initialize Git Submodules run: git submodule update --init @@ -154,7 +156,7 @@ jobs: # Build the branch - name: Build branch - run: mvn -B -Punix-amd64-profile package --file pom.xml + run: mvn -B -Punix-amd64-profile -Dnative.onednn=true package --file pom.xml # Run the benchmark if jvector-examples exists - name: Run benchmark @@ -255,9 +257,6 @@ jobs: - name: Checkout repository uses: actions/checkout@v4 - - name: Initialize Git Submodules - run: git submodule update --init - - name: Download all benchmark results uses: actions/download-artifact@v4 with: diff --git a/.github/workflows/run-compaction.yml b/.github/workflows/run-compaction.yml index 81196b309..96e7bda49 100644 --- a/.github/workflows/run-compaction.yml +++ b/.github/workflows/run-compaction.yml @@ -74,11 +74,13 @@ jobs: ref: ${{ matrix.branch }} fetch-depth: 0 + # Branches from before the Highway Meson wrap still use a submodule; + # this step is a no-op on newer ones. - name: Initialize Git Submodules run: git submodule update --init - name: Build branch - run: mvn -B -Punix-amd64-profile package --file pom.xml + run: mvn -B -Punix-amd64-profile -Dnative.onednn=true package --file pom.xml - name: Run CompactorBenchmark id: run-benchmark diff --git a/.github/workflows/unit-tests-arm64.yaml b/.github/workflows/unit-tests-arm64.yaml index 59e24f784..8836d1e3f 100644 --- a/.github/workflows/unit-tests-arm64.yaml +++ b/.github/workflows/unit-tests-arm64.yaml @@ -59,9 +59,6 @@ jobs: - uses: actions/checkout@v4 - - name: Initialize Git Submodules - run: git submodule update --init - - name: Build test_simd_kernels (native C++) # Meson detects aarch64 and compiles all three ISA variants (neon/sve/sve2) # regardless of what the host CPU supports at runtime. @@ -110,7 +107,7 @@ jobs: env: JVECTOR_MAX_ISA: ${{ matrix.max_isa }} run: >- - mvn -B -Punix-amd64-profile -pl jvector-tests -am test + mvn -B -Punix-amd64-profile -Dnative.onednn=true -pl jvector-tests -am test -DTest_RequireSpecificVectorizationProvider=NativeVectorizationProvider -Dsurefire.failIfNoSpecifiedTests=false -Dtest=TestVectorizationProvider @@ -119,7 +116,7 @@ jobs: env: JVECTOR_MAX_ISA: ${{ matrix.max_isa }} run: >- - mvn -B -Punix-amd64-profile test + mvn -B -Punix-amd64-profile -Dnative.onednn=true test -DTest_RequireSpecificVectorizationProvider=NativeVectorizationProvider - name: Test Summary for (ARM64/max:${{ matrix.max_isa }},JDK${{ matrix.jdk }}) diff --git a/.github/workflows/unit-tests.yaml b/.github/workflows/unit-tests.yaml index 6f8663419..e6eb600cf 100644 --- a/.github/workflows/unit-tests.yaml +++ b/.github/workflows/unit-tests.yaml @@ -54,9 +54,6 @@ jobs: run: | sudo apt update && sudo apt install -y cmake meson ninja-build pkg-config libgtest-dev - uses: actions/checkout@v4 - - name: Initialize Git Submodules - run: git submodule update --init - - name: Build test_simd_kernels (native C++) working-directory: jvector-native/src/main/native run: | @@ -108,7 +105,7 @@ jobs: env: JVECTOR_MAX_ISA: ${{ matrix.max_isa }} run: >- - mvn -B -Punix-amd64-profile -pl jvector-tests -am test + mvn -B -Punix-amd64-profile -Dnative.onednn=true -pl jvector-tests -am test -DTest_RequireSpecificVectorizationProvider=NativeVectorizationProvider -Dsurefire.failIfNoSpecifiedTests=false -Dtest=TestVectorizationProvider @@ -118,7 +115,7 @@ jobs: env: JVECTOR_MAX_ISA: ${{ matrix.max_isa }} run: >- - mvn -B -Punix-amd64-profile test + mvn -B -Punix-amd64-profile -Dnative.onednn=true test -DTest_RequireSpecificVectorizationProvider=NativeVectorizationProvider - name: Test Summary for (ISA:isa-avx512f/max:${{ matrix.max_isa }},JDK${{ matrix.jdk }}) @@ -147,8 +144,6 @@ jobs: runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@v4 - - name: Initialize Git Submodules - run: git submodule update --init - name: Set up JDK uses: actions/setup-java@v3 with: @@ -164,7 +159,7 @@ jobs: run: | sudo apt update && sudo apt install -y cmake meson ninja-build - name: Compile, run tests, and package (JDK 22) - run: mvn -B verify + run: mvn -B -Dnative.onednn=true verify if: matrix.jdk == '22' - name: Compile, run tests, and package (JDK 20) run: mvn -B -Pjdk20 -am -pl jvector-tests test diff --git a/.gitmodules b/.gitmodules deleted file mode 100644 index 9ea15e3e5..000000000 --- a/.gitmodules +++ /dev/null @@ -1,3 +0,0 @@ -[submodule "jvector-native/src/main/native/third_party/highway"] - path = jvector-native/src/main/native/third_party/highway - url = https://github.com/google/highway.git diff --git a/README.md b/README.md index a1e463d84..2d4f799e8 100644 --- a/README.md +++ b/README.md @@ -68,8 +68,7 @@ The fastest way in is the hello-world programs in [jvector-examples](./jvector-e small public datasets on first use. Build them once from the repository root: ```bash -git submodule update --init -mvn -pl jvector-examples -am -DskipTests package +mvn -pl jvector-examples -am -DskipTests package -Dnative.onednn=true JAR=$(ls jvector-examples/target/jvector-examples-*-jar-with-dependencies.jar) ``` @@ -197,18 +196,11 @@ that produces a multirelease JAR usable from Java 11: | [jvector-examples](./jvector-examples) | Examples, BenchYAML and benchmarks | | [jvector-tests](./jvector-tests) | Tests, runnable on Java 11 and newer | -Build with JDK 22 or newer. The Highway sources are a Git submodule: - -```bash -git clone --recurse-submodules https://github.com/datastax/jvector.git -# or, in an existing clone: -git submodule update --init -``` - -The native library is built with [Meson](https://mesonbuild.com/) and [Ninja](https://ninja-build.org/) -and needs g++ 11 or newer; the oneDNN add-on also needs CMake 3.13 or newer, and Meson fetches its -pinned source. Maven builds both and packages them in the native JAR; `-Dnative.onednn=false` builds -without oneDNN. The [native library README](./jvector-native/src/main/native/README.md) covers build +Build with JDK 22 or newer. The native library is built with [Meson](https://mesonbuild.com/) and +[Ninja](https://ninja-build.org/) and needs g++ 11 or newer; Meson fetches the pinned Google Highway +source on first build. The optional oneDNN add-on for ASH query projection is a separate Meson +project; it also needs CMake 3.13 or newer and takes a few minutes to build, so Maven builds it only +with `-Dnative.onednn=true` (the `release` profile and CI turn it on). The [native library README](./jvector-native/src/main/native/README.md) covers build options, ISA dispatch, adding kernels and [ABI compatibility](./jvector-native/src/main/native/README.md#native-abi-compatibility). On Ubuntu, `bash build_native_lib.sh --auto-install-deps` (in `jvector-native/src/main/native/src`) diff --git a/docs/ash-pq-benchmark.md b/docs/ash-pq-benchmark.md index 3bb8032d7..e48e6fc00 100644 --- a/docs/ash-pq-benchmark.md +++ b/docs/ash-pq-benchmark.md @@ -36,8 +36,7 @@ fallback does not reproduce native training throughput. ```bash export JAVA_HOME=/path/to/jdk-22 export PATH="$JAVA_HOME/bin:$PATH" -git submodule update --init --recursive -mvn -B -pl jvector-examples -am -DskipTests package +mvn -B -pl jvector-examples -am -DskipTests package -Dnative.onednn=true ``` This writes `jvector-examples/target/jvector-examples--jar-with-dependencies.jar`. diff --git a/docs/ash-projection-precision.md b/docs/ash-projection-precision.md index 56ccfc07c..8d0b773f9 100644 --- a/docs/ash-projection-precision.md +++ b/docs/ash-projection-precision.md @@ -100,4 +100,4 @@ A projection-throughput gain does not imply the same graph-query speedup. - [oneDNN INT8 arithmetic](https://uxlfoundation.github.io/oneDNN/dev_guide_int8_computations.html). The repository pins its oneDNN version in -`jvector-native/src/main/native/subprojects/onednn.wrap`. +`jvector-native/src/main/onednn/subprojects/onednn.wrap`. diff --git a/jvector-native/pom.xml b/jvector-native/pom.xml index 1759e0003..bb704f7f1 100644 --- a/jvector-native/pom.xml +++ b/jvector-native/pom.xml @@ -16,8 +16,11 @@ true false - - true + + false + src/main/native/subprojects/highway/** + src/main/onednn/subprojects/onednn/** + src/main/*/subprojects/packagecache/** + src/main/*/subprojects/.wraplock @@ -137,6 +142,7 @@ release true + true diff --git a/jvector-native/src/main/native/README.md b/jvector-native/src/main/native/README.md index 22daa74c0..bcdfa37d5 100644 --- a/jvector-native/src/main/native/README.md +++ b/jvector-native/src/main/native/README.md @@ -41,9 +41,13 @@ aarch64-cross.ini - Meson cross-compile file: build aarch64 from an x86_64-cross.ini - Meson cross-compile file: build x86_64 from an aarch64 host jextract_generate_bindings.sh - Generate Java bindings from native headers using jextract build_native_lib.sh - Build the native library (native arch + optional cross-compile) -third_party/highway/ - Google Highway header-only library (git submodule) +subprojects/highway.wrap - Google Highway revision, fetched by Meson and used header-only ``` +The optional oneDNN add-on (`libjvector_ash_fp16`) is a separate Meson project in +[`../onednn`](../onednn), with its own `meson.build` and `subprojects/onednn.wrap`. +libjvector never depends on it. + --- ## How to build @@ -58,9 +62,12 @@ third_party/highway/ - Google Highway header-only library (git submodul | clang++ (AArch64) | Clang any recent | NEON tier always built; SVE/SVE2 require **Clang ≥ 22** — older Clang produces a NEON-only build with a warning | | [Meson](https://mesonbuild.com/) | 0.55 | `pip install meson` | | [Ninja](https://ninja-build.org/) | any | `sudo apt install ninja-build` | -| CMake (recommended ASH/oneDNN build) | 3.13+ | `sudo apt install cmake`; needed in addition to Meson and Ninja | +| CMake (oneDNN add-on only) | 3.13+ | `sudo apt install cmake`; needed in addition to Meson and Ninja | | JDK (native ASH) | 22+ | Final FFM API; build and run the native provider with supported native-access settings | -| Git submodules | — | `git submodule update --init` (needed once) | + +Meson fetches Google Highway at the revision pinned in `subprojects/highway.wrap` +on the first `meson setup`; no Git submodule is needed. Offline builds can +populate `subprojects/highway` with that revision beforehand. > **AArch64 compiler note:** On Clang < 22, the SVE and SVE2 tiers are > automatically skipped at configure time (`meson setup` prints a warning). @@ -68,12 +75,14 @@ third_party/highway/ - Google Highway header-only library (git submodul > runtime even on SVE2-capable hardware (e.g. Graviton 4). Upgrade to > Clang ≥ 22 or use GCC to get full SVE/SVE2 acceleration. -For ASH, include oneDNN: it is the preferred projection implementation for FP32 +For ASH, the oneDNN add-on is the preferred projection implementation for FP32 encoding batches and FP16/INT8 queries where supported. It is built from the pinned -source and bundled; users do not need a separately installed `libdnnl`. -A first online build needs access to the source URL in `subprojects/onednn.wrap`; -offline builds can populate that exact revision beforehand. On Ubuntu, install -the native build prerequisites with: +source and bundled; users do not need a separately installed `libdnnl`. Building +oneDNN takes a few minutes, so it is off by default and enabled with +`-Dnative.onednn=true` (Maven) or `true` as the script's third argument; CI and +the release profile enable it. A first online build needs access to the source URL +in `../onednn/subprojects/onednn.wrap`; offline builds can populate that exact +revision beforehand. On Ubuntu, install the native build prerequisites with: ```bash sudo apt-get install g++ meson ninja-build cmake @@ -93,22 +102,24 @@ Parameters (all optional): |-----------|---------|-------------| | `buildtype` | `release` | `release`, `debug`, or `debugoptimized` | | `crossarch` | `false` | Set to `true` to also cross-compile for the other arch | -| `onednn` | `true` | Bundle FP32 batch and FP16/INT8 query projection support; set to `false` for Highway-only builds | +| `onednn` | `false` | Set to `true` to also build and bundle the oneDNN add-on (FP32 batch and FP16/INT8 query projection) | -The default ASH build includes oneDNN: +To include oneDNN: ```bash -bash src/build_native_lib.sh release +bash src/build_native_lib.sh release false true ``` The script: 1. Detects the host arch via `uname -m` (x86_64 or aarch64). -2. Verifies prerequisites (g++, meson, ninja, Highway submodule). +2. Verifies prerequisites (g++, meson, ninja; cmake when oneDNN is on). 3. Always builds the **native arch** library into `target/meson-build-/`. 4. If `crossarch=true`, also cross-compiles the **other arch** library into `target/meson-build-/` using the bundled cross-compile file. 5. Copies each built `.so` to `jvector-native/target/meson-build/libjvector-.so` (relative to the repository root), which Maven includes as a resource. +6. If `onednn=true`, builds the separate `../onednn` project into + `target/meson-build-onednn-/` and copies `libjvector_ash_fp16-.so` beside it. The script does not strip or split debug symbols. To install all required dependencies automatically and then build, pass `--auto-install-deps` as the first argument: @@ -176,11 +187,12 @@ select the correct ISA variant list (x86 tiers vs AArch64 tiers) automatically. From the project root, the recommended ASH build includes oneDNN: ```bash -mvn -pl jvector-native -am package +mvn -pl jvector-native -am package -Dnative.onednn=true ``` -It requires the CMake prerequisite above. The commands below include oneDNN by -default; append `-Dnative.onednn=false` for a Highway-only package. +It requires the CMake prerequisite above. The commands below build only the +Highway library; append `-Dnative.onednn=true` to include oneDNN. The `release` +profile turns it on. **Native arch only — release (default):** ```bash @@ -901,39 +913,41 @@ unexpected execution failures retain distinct error categories across FFM. ### Recommended oneDNN build and package -Native Maven and direct-script builds include oneDNN by default on the supported -Linux platforms. Use JDK 22 or newer. It is +Native Maven and direct-script builds include oneDNN when asked to +(`-Dnative.onednn=true`, or `true` as the script's third argument) on the supported +Linux platforms; the `release` profile and CI turn it on. Use JDK 22 or newer. It is **automatically preferred** for FP32 encoding batches and supported FP16 queries; INT8 is available through explicit selection. Highway stays independent and supplies FP32 fallback when the add-on or the requested operation is unavailable: ```bash -mvn -pl jvector-native -am package +mvn -pl jvector-native -am package -Dnative.onednn=true ``` -For a direct native build, oneDNN is enabled without an additional argument: +For a direct native build: ```bash -bash src/build_native_lib.sh release +bash src/build_native_lib.sh release false true ``` The recommended oneDNN build requires CMake 3.13+ in addition to the native prerequisites. Meson -fetches the exact oneDNN revision pinned in `subprojects/onednn.wrap`; an offline -build can populate `subprojects/onednn` with that revision beforehand. oneDNN is +fetches the exact oneDNN revision pinned in `../onednn/subprojects/onednn.wrap`; an offline +build can populate `../onednn/subprojects/onednn` with that revision beforehand. oneDNN is built with the sequential CPU runtime, no GPU support, and only inference matrix-multiplication/reorder primitives. Its dispatcher selects supported CPU instructions at runtime; the build does not use `-march=native`. -oneDNN is statically linked into `libjvector_ash_fp16-.so`. Maven packages +oneDNN is statically linked into `libjvector_ash_fp16-.so`, so the jar +carries one self-contained add-on and needs no system oneDNN. Maven packages this library and the upstream license/third-party notices beside the Highway resource. The existing architecture-aware loader first tries the system library path, then extracts the matching classpath resource. No external `libdnnl` or benchmark-specific path is required. The resource keeps its existing FP16 name -for binary compatibility but supplies FP32 batch and FP16/INT8 query projection. Passing -`-Dnative.onednn=false` (or `false` as the script's third argument) leaves oneDNN -projection out of the package and removes -stale add-on resources from previous builds; Highway FP32 is then used. +for binary compatibility but supplies FP32 batch and FP16/INT8 query projection. +Without `-Dnative.onednn=true` (the default) the build leaves oneDNN projection out +of the package and removes stale add-on resources from previous builds; Highway FP32 +is then used. The optional component follows the native module's Linux platform support; cross-compilation still requires the corresponding target toolchain. diff --git a/jvector-native/src/main/native/meson.build b/jvector-native/src/main/native/meson.build index 00c13b19e..2ff11b244 100644 --- a/jvector-native/src/main/native/meson.build +++ b/jvector-native/src/main/native/meson.build @@ -18,10 +18,11 @@ project('jvector_simd_kernels', 'cpp', default_options: ['cpp_std=c++17'] ) -# Highway headers are used as an include directory only. -# HWY_COMPILE_ONLY_STATIC / HWY_COMPILE_ONLY_SCALAR bypass the +# Highway is fetched at the revision pinned in subprojects/highway.wrap and used +# header-only: HWY_COMPILE_ONLY_STATIC / HWY_COMPILE_ONLY_SCALAR bypass the # dynamic-dispatch runtime, so only headers are needed at compile time. -hwy_inc = include_directories('third_party/highway') +highway_proj = subproject('highway') +hwy_dep = highway_proj.get_variable('highway_dep') # Each ISA variant: name, JV_ISA namespace, and extra compiler flags. # These all compile jvector_simd_kernels.cpp (the generic kernel file). @@ -114,7 +115,7 @@ foreach isa : isa_variants lib = static_library( 'simdKernels_' + isa['name'], sources : 'src/jvector_simd_kernels.cpp', - include_directories: hwy_inc, + dependencies : hwy_dep, cpp_args : isa['args'] + ['-DJV_ISA=' + isa['namespace'], '-fvisibility=hidden'] ) isa_libs += lib @@ -127,7 +128,7 @@ if cpu == 'x86_64' avx3_dl_lib = static_library( 'simdKernels_avx3_dl', sources : 'src/jvector_avx3_dl_kernels.cpp', - include_directories: hwy_inc, + dependencies : hwy_dep, cpp_args : [ '-march=icelake-server', '-DHWY_COMPILE_ONLY_STATIC', @@ -143,7 +144,7 @@ if cpu == 'x86_64' avx3_spr_lib = static_library( 'simdKernels_avx3_spr', sources : 'src/jvector_avx3_spr_kernels.cpp', - include_directories: hwy_inc, + dependencies : hwy_dep, cpp_args : [ '-march=sapphirerapids', '-DHWY_COMPILE_ONLY_STATIC', @@ -163,8 +164,9 @@ endif vectorutil_lib = shared_library( 'jvector', sources : ['src/jvector_simd.cpp', - 'third_party/highway/hwy/abort.cc'], - include_directories: [include_directories('src'), hwy_inc], + highway_proj.get_variable('highway_abort_src')], + include_directories: include_directories('src'), + dependencies : hwy_dep, cpp_args : ['-DJVECTOR_BUILD', '-fvisibility=hidden'] + dispatch_defines, link_whole : isa_libs, version : meson.project_version(), @@ -233,54 +235,3 @@ else 'ERROR: bench_simd_kernels is not available because the benchmark library was not found. ' + 'Install libbenchmark-dev (apt) or google-benchmark (brew) and re-run: meson setup --wipe build']) endif -# Optional and independent of libjvector: machines without oneDNN continue to -# use Highway. Static linkage keeps the packaged add-on self-contained, while -# oneDNN's runtime dispatcher selects instructions supported by the host CPU. -if get_option('onednn').enabled() - cmake = import('cmake') - onednn_options = cmake.subproject_options() - onednn_options.add_cmake_defines({ - 'CMAKE_POSITION_INDEPENDENT_CODE': true, - 'ONEDNN_LIBRARY_TYPE': 'STATIC', - 'ONEDNN_CPU_RUNTIME': 'SEQ', - 'ONEDNN_GPU_RUNTIME': 'NONE', - 'ONEDNN_BUILD_GRAPH': false, - 'ONEDNN_BUILD_TESTS': false, - 'ONEDNN_BUILD_EXAMPLES': false, - 'ONEDNN_ENABLE_PRIMITIVE': 'MATMUL;REORDER', - 'ONEDNN_ENABLE_WORKLOAD': 'INFERENCE', - 'ONEDNN_ARCH_OPT_FLAGS': '', - }) - onednn_project = cmake.subproject('onednn', options: onednn_options) - # oneDNN's aggregate archive has no source files of its own. Meson's CMake - # converter marks that aggregate non-PIC, although all constituent object - # libraries are PIC. Re-archive those objects with the correct metadata. - onednn_archive_inputs = [onednn_project.target('dnnl').extract_all_objects(recursive: true)] - # The CMake build links KleidiAI as a separate static target on AArch64. - if 'kleidiai_ops' in onednn_project.target_list() - onednn_archive_inputs += [onednn_project.target('kleidiai_ops').extract_all_objects(recursive: true)] - endif - onednn_objects = static_library('ash_onednn', - objects: onednn_archive_inputs, - pic: true, - ) - onednn_dependency = onednn_project.dependency('dnnl').partial_dependency( - compile_args: true, includes: true, link_args: true) - ash_fp16_lib = shared_library('jvector_ash_fp16', - sources: ['src/jvector_ash_fp16.cpp', 'src/jvector_ash_int8.cpp'], - dependencies: onednn_dependency, - link_with: onednn_objects, - cpp_args: ['-fvisibility=hidden'], - link_args: ['-Wl,--exclude-libs,ALL'], - install: true, - ) - if gtest_dep.found() - ash_fp16_tests = executable('test_ash_fp16', - sources: 'tests/test_ash_fp16.cpp', - include_directories: include_directories('src'), - dependencies: gtest_dep, - link_with: ash_fp16_lib, - ) - test('ash_fp16', ash_fp16_tests, protocol: 'gtest', suite: 'ash_fp16') - endif -endif diff --git a/jvector-native/src/main/native/src/build_native_lib.sh b/jvector-native/src/main/native/src/build_native_lib.sh index 4fcc19a6c..9c28dd81f 100755 --- a/jvector-native/src/main/native/src/build_native_lib.sh +++ b/jvector-native/src/main/native/src/build_native_lib.sh @@ -27,9 +27,9 @@ set -x # --------------------------------------------------------------------------- REPO_ROOT="$(git -C "$(dirname "$0")" rev-parse --show-toplevel)" NATIVE_DIR="${REPO_ROOT}/jvector-native/src/main/native" +# Separate Meson project for the optional oneDNN add-on (libjvector_ash_fp16). +ONEDNN_DIR="${REPO_ROOT}/jvector-native/src/main/onednn" MODULE_ROOT="${REPO_ROOT}/jvector-native" - -HIGHWAY_DIR="${NATIVE_DIR}/third_party/highway" # Drop the .so files into target/meson-build/ so that mvn clean removes them # along with every other build artefact, and Maven's resource plugin picks them # up from there instead of from src/main/resources/. @@ -54,11 +54,12 @@ printf "BUILDTYPE=%s\n" "${BUILDTYPE}" CROSSARCH="${2:-false}" printf "CROSSARCH=%s\n" "${CROSSARCH}" -# oneDNN add-on for FP32 encoding batches and FP16 queries; libjvector stays independent. -ONEDNN="${3:-true}" +# Optional oneDNN add-on for FP32 encoding batches and FP16/INT8 queries, built +# from its own Meson project; libjvector stays independent. Off by default because +# building oneDNN takes minutes; CI and release builds pass true. +ONEDNN="${3:-false}" case "${ONEDNN}" in - true) ONEDNN_FEATURE=enabled ;; - false) ONEDNN_FEATURE=disabled ;; + true|false) ;; *) echo "ERROR: oneDNN flag must be true or false."; exit 1 ;; esac @@ -66,15 +67,8 @@ mkdir -p "${RESOURCES_DIR}" # target/meson-build/ is under target/, which IS cleaned by mvn clean. # src/main/resources/ is intentionally NOT written to any more. -# Check that the Google Highway submodule has been initialised -if [ ! -f "${HIGHWAY_DIR}/hwy/highway.h" ]; then - echo "ERROR: Google Highway submodule not found at ${HIGHWAY_DIR}." - echo " Run the following command from the repository root to fix this:" - echo "" - echo " git submodule update --init" - echo "" - exit 1 -fi +# Meson fetches Google Highway at the revision pinned in +# ${NATIVE_DIR}/subprojects/highway.wrap on first setup. # Desired minimum GCC version MIN_GCC_VERSION=11 @@ -124,7 +118,7 @@ build_native() { rm -rf "${OUT_SO}" # Reuse configured builds so repeated Maven phases keep incremental native outputs. - local setup_options=() + local setup_options=() cross_options=() if [[ -f "${BUILD_DIR}/meson-private/coredata.dat" ]]; then setup_options+=(--reconfigure) fi @@ -139,9 +133,6 @@ build_native() { exit 1 fi - meson setup "${BUILD_DIR}" "${NATIVE_DIR}" \ - "${setup_options[@]}" \ - --buildtype="${BUILDTYPE}" -Donednn="${ONEDNN_FEATURE}" else # ---- Cross-compile build ----------------------------------------------- # Only x86_64 <-> aarch64 is supported. @@ -158,13 +149,11 @@ build_native() { fi require_cmd "${CROSS_TOOLCHAIN}" "${CROSS_INSTALL}" - - meson setup "${BUILD_DIR}" "${NATIVE_DIR}" \ - "${setup_options[@]}" \ - --cross-file "${CROSS_FILE}" \ - --buildtype="${BUILDTYPE}" -Donednn="${ONEDNN_FEATURE}" + cross_options=(--cross-file "${CROSS_FILE}") fi + meson setup "${BUILD_DIR}" "${NATIVE_DIR}" \ + "${setup_options[@]}" "${cross_options[@]}" --buildtype="${BUILDTYPE}" meson compile -C "${BUILD_DIR}" # The versioned .so (e.g. libjvector.so.0.1.0) is the real file; symlinks point to it. @@ -178,10 +167,18 @@ build_native() { local FP16_OUT="${RESOURCES_DIR}/libjvector_ash_fp16-${ARCH}.so" if [ "${ONEDNN}" == "true" ]; then - cp "${BUILD_DIR}/libjvector_ash_fp16.so" "${FP16_OUT}" + local ONEDNN_BUILD_DIR="${MODULE_ROOT}/target/meson-build-onednn-${ARCH}" + local onednn_setup_options=() + if [[ -f "${ONEDNN_BUILD_DIR}/meson-private/coredata.dat" ]]; then + onednn_setup_options+=(--reconfigure) + fi + meson setup "${ONEDNN_BUILD_DIR}" "${ONEDNN_DIR}" \ + "${onednn_setup_options[@]}" "${cross_options[@]}" --buildtype="${BUILDTYPE}" + meson compile -C "${ONEDNN_BUILD_DIR}" + cp "${ONEDNN_BUILD_DIR}/libjvector_ash_fp16.so" "${FP16_OUT}" # License texts travel with the optional static dependency in either jar. - cp "${NATIVE_DIR}/subprojects/onednn/LICENSE" "${RESOURCES_DIR}/oneDNN-LICENSE.txt" - cp "${NATIVE_DIR}/subprojects/onednn/THIRD-PARTY-PROGRAMS" "${RESOURCES_DIR}/oneDNN-THIRD-PARTY.txt" + cp "${ONEDNN_DIR}/subprojects/onednn/LICENSE" "${RESOURCES_DIR}/oneDNN-LICENSE.txt" + cp "${ONEDNN_DIR}/subprojects/onednn/THIRD-PARTY-PROGRAMS" "${RESOURCES_DIR}/oneDNN-THIRD-PARTY.txt" else # A later build without the add-on must not package a stale enabled build. rm -f "${FP16_OUT}" diff --git a/jvector-native/src/main/native/subprojects/.gitignore b/jvector-native/src/main/native/subprojects/.gitignore index da926c526..cd52905d3 100644 --- a/jvector-native/src/main/native/subprojects/.gitignore +++ b/jvector-native/src/main/native/subprojects/.gitignore @@ -1,3 +1,3 @@ -/onednn/ +/highway/ /packagecache/ /.wraplock diff --git a/jvector-native/src/main/native/meson_options.txt b/jvector-native/src/main/native/subprojects/highway.wrap similarity index 65% rename from jvector-native/src/main/native/meson_options.txt rename to jvector-native/src/main/native/subprojects/highway.wrap index d827e4e40..54b3a4c8c 100644 --- a/jvector-native/src/main/native/meson_options.txt +++ b/jvector-native/src/main/native/subprojects/highway.wrap @@ -14,5 +14,11 @@ # limitations under the License. # -option('onednn', type: 'feature', value: 'enabled', - description: 'Build ASH FP32 encoding and FP16/INT8 query projection with sequential oneDNN') +# Highway is used header-only (each ISA tier is compiled with +# HWY_COMPILE_ONLY_STATIC), so only its include directory and abort.cc are needed. +[wrap-git] +directory = highway +url = https://github.com/google/highway.git +revision = 16f5f5c768064d0bb52952888a14236bec75c104 +depth = 1 +patch_directory = highway diff --git a/jvector-native/src/main/native/subprojects/packagefiles/highway/meson.build b/jvector-native/src/main/native/subprojects/packagefiles/highway/meson.build new file mode 100644 index 000000000..1c04d6a0b --- /dev/null +++ b/jvector-native/src/main/native/subprojects/packagefiles/highway/meson.build @@ -0,0 +1,27 @@ +# +# 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. +# + +# Overlay build file for the Highway wrap. Highway's own CMake build compiles its +# dynamic-dispatch runtime, which jvector does not use: each ISA tier is compiled +# with HWY_COMPILE_ONLY_STATIC, so only the headers and hwy::Abort are needed. +project('highway', 'cpp') + +highway_dep = declare_dependency( + include_directories: include_directories('.'), +) +# abort.cc was split out of targets.cc for projects that do their own dispatch +# (google/highway#1935). +highway_abort_src = files('hwy/abort.cc') diff --git a/jvector-native/src/main/native/third_party/highway b/jvector-native/src/main/native/third_party/highway deleted file mode 160000 index 16f5f5c76..000000000 --- a/jvector-native/src/main/native/third_party/highway +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 16f5f5c768064d0bb52952888a14236bec75c104 diff --git a/jvector-native/src/main/onednn/meson.build b/jvector-native/src/main/onednn/meson.build new file mode 100644 index 000000000..9a165a662 --- /dev/null +++ b/jvector-native/src/main/onednn/meson.build @@ -0,0 +1,78 @@ +# 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. + +# Optional ASH add-on: FP32 batch encoding and FP16/INT8 query projection with +# sequential oneDNN. It is a separate project from the Highway library +# (../native) and builds libjvector_ash_fp16, which Java loads on its own; +# libjvector never depends on it. +# +# oneDNN is linked statically so the packaged add-on is one self-contained +# library with no runtime dependency on a system oneDNN; oneDNN's own runtime +# dispatcher still selects the instructions the host CPU supports. Building +# oneDNN takes a few minutes, which is why Maven builds this project only with +# -Dnative.onednn=true (CI and the release profile turn it on). +project('jvector_ash_onednn', 'cpp', + version: '0.1.0', + meson_version: '>= 0.55.0', + default_options: ['cpp_std=c++17'] +) + +cmake = import('cmake') +onednn_options = cmake.subproject_options() +onednn_options.add_cmake_defines({ + 'CMAKE_POSITION_INDEPENDENT_CODE': true, + 'ONEDNN_LIBRARY_TYPE': 'STATIC', + 'ONEDNN_CPU_RUNTIME': 'SEQ', + 'ONEDNN_GPU_RUNTIME': 'NONE', + 'ONEDNN_BUILD_GRAPH': false, + 'ONEDNN_BUILD_TESTS': false, + 'ONEDNN_BUILD_EXAMPLES': false, + 'ONEDNN_ENABLE_PRIMITIVE': 'MATMUL;REORDER', + 'ONEDNN_ENABLE_WORKLOAD': 'INFERENCE', + 'ONEDNN_ARCH_OPT_FLAGS': '', +}) +onednn_project = cmake.subproject('onednn', options: onednn_options) +# oneDNN's aggregate archive has no source files of its own. Meson's CMake +# converter marks that aggregate non-PIC, although all constituent object +# libraries are PIC. Re-archive those objects with the correct metadata. +onednn_archive_inputs = [onednn_project.target('dnnl').extract_all_objects(recursive: true)] +# The CMake build links KleidiAI as a separate static target on AArch64. +if 'kleidiai_ops' in onednn_project.target_list() + onednn_archive_inputs += [onednn_project.target('kleidiai_ops').extract_all_objects(recursive: true)] +endif +onednn_objects = static_library('ash_onednn', + objects: onednn_archive_inputs, + pic: true, +) +onednn_dependency = onednn_project.dependency('dnnl').partial_dependency( + compile_args: true, includes: true, link_args: true) +ash_fp16_lib = shared_library('jvector_ash_fp16', + sources: ['src/jvector_ash_fp16.cpp', 'src/jvector_ash_int8.cpp'], + dependencies: onednn_dependency, + link_with: onednn_objects, + cpp_args: ['-fvisibility=hidden'], + link_args: ['-Wl,--exclude-libs,ALL'], + install: true, +) + +gtest_dep = dependency('gtest_main', required: false) +if gtest_dep.found() + ash_fp16_tests = executable('test_ash_fp16', + sources: 'tests/test_ash_fp16.cpp', + include_directories: include_directories('src'), + dependencies: gtest_dep, + link_with: ash_fp16_lib, + ) + test('ash_fp16', ash_fp16_tests, protocol: 'gtest', suite: 'ash_fp16') +endif diff --git a/jvector-native/src/main/native/src/jvector_ash_fp16.cpp b/jvector-native/src/main/onednn/src/jvector_ash_fp16.cpp similarity index 100% rename from jvector-native/src/main/native/src/jvector_ash_fp16.cpp rename to jvector-native/src/main/onednn/src/jvector_ash_fp16.cpp diff --git a/jvector-native/src/main/native/src/jvector_ash_fp16.h b/jvector-native/src/main/onednn/src/jvector_ash_fp16.h similarity index 100% rename from jvector-native/src/main/native/src/jvector_ash_fp16.h rename to jvector-native/src/main/onednn/src/jvector_ash_fp16.h diff --git a/jvector-native/src/main/native/src/jvector_ash_int8.cpp b/jvector-native/src/main/onednn/src/jvector_ash_int8.cpp similarity index 100% rename from jvector-native/src/main/native/src/jvector_ash_int8.cpp rename to jvector-native/src/main/onednn/src/jvector_ash_int8.cpp diff --git a/jvector-native/src/main/onednn/subprojects/.gitignore b/jvector-native/src/main/onednn/subprojects/.gitignore new file mode 100644 index 000000000..da926c526 --- /dev/null +++ b/jvector-native/src/main/onednn/subprojects/.gitignore @@ -0,0 +1,3 @@ +/onednn/ +/packagecache/ +/.wraplock diff --git a/jvector-native/src/main/native/subprojects/onednn.wrap b/jvector-native/src/main/onednn/subprojects/onednn.wrap similarity index 100% rename from jvector-native/src/main/native/subprojects/onednn.wrap rename to jvector-native/src/main/onednn/subprojects/onednn.wrap diff --git a/jvector-native/src/main/native/tests/test_ash_fp16.cpp b/jvector-native/src/main/onednn/tests/test_ash_fp16.cpp similarity index 100% rename from jvector-native/src/main/native/tests/test_ash_fp16.cpp rename to jvector-native/src/main/onednn/tests/test_ash_fp16.cpp From cfb9cdf2bd0576343d9dd433b15bf6c0ca0da931 Mon Sep 17 00:00:00 2001 From: Ted Willke Date: Tue, 10 Nov 2026 09:17:07 -0800 Subject: [PATCH 207/243] Quote the oneDNN Maven flag so PowerShell passes it intact on Windows --- .github/workflows/unit-tests.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/unit-tests.yaml b/.github/workflows/unit-tests.yaml index e6eb600cf..3453d57af 100644 --- a/.github/workflows/unit-tests.yaml +++ b/.github/workflows/unit-tests.yaml @@ -159,7 +159,7 @@ jobs: run: | sudo apt update && sudo apt install -y cmake meson ninja-build - name: Compile, run tests, and package (JDK 22) - run: mvn -B -Dnative.onednn=true verify + run: mvn -B "-Dnative.onednn=true" verify if: matrix.jdk == '22' - name: Compile, run tests, and package (JDK 20) run: mvn -B -Pjdk20 -am -pl jvector-tests test From 2281ff8f8a93e8e57f1c2c242efeaabeee31ea11 Mon Sep 17 00:00:00 2001 From: Ted Willke Date: Tue, 10 Nov 2026 12:57:48 -0800 Subject: [PATCH 208/243] Let the oneDNN FP32 ASH query view report batch projection NativeASHFP32BatchProjection implements projectBatch (oneDNN SGEMM, falling back to Highway's batch kernel), but inherited supportsBatchProjection() == false. With the add-on loaded, FP32 query views (queryProjection: fp32, or auto on CPUs without FP16 matmul support) therefore disabled queued batch preparation, so queries were prepared one at a time. The Highway-only view already reported true. --- .../vector/NativeASHFP32BatchProjection.java | 4 ++++ .../NativeASHFP32BatchProjectionTest.java | 24 +++++++++++++++++++ 2 files changed, 28 insertions(+) diff --git a/jvector-native/src/main/java/io/github/jbellis/jvector/vector/NativeASHFP32BatchProjection.java b/jvector-native/src/main/java/io/github/jbellis/jvector/vector/NativeASHFP32BatchProjection.java index 877ccca19..ab3b65716 100644 --- a/jvector-native/src/main/java/io/github/jbellis/jvector/vector/NativeASHFP32BatchProjection.java +++ b/jvector-native/src/main/java/io/github/jbellis/jvector/vector/NativeASHFP32BatchProjection.java @@ -92,6 +92,10 @@ public VectorUtilSupport.AshProjection forQueryScoring() { return query == fallback ? this : query; } + /** projectBatch always succeeds: oneDNN SGEMM, or the Highway batch kernel when oneDNN declines. */ + @Override + public boolean supportsBatchProjection() { return true; } + @Override public void projectBatch(float[][] inputs, float[][] outputs, int count) { VectorUtilSupport.AshProjection.validateBatch(inputs, outputs, count); diff --git a/jvector-native/src/test/java/io/github/jbellis/jvector/vector/NativeASHFP32BatchProjectionTest.java b/jvector-native/src/test/java/io/github/jbellis/jvector/vector/NativeASHFP32BatchProjectionTest.java index 416feeb78..0c92a2797 100644 --- a/jvector-native/src/test/java/io/github/jbellis/jvector/vector/NativeASHFP32BatchProjectionTest.java +++ b/jvector-native/src/test/java/io/github/jbellis/jvector/vector/NativeASHFP32BatchProjectionTest.java @@ -115,6 +115,30 @@ void queryFallbackRetainsOwnerIdentityAndDoesNotDoubleChargeWeights() { } } + @Test + void fp32QueryViewAdvertisesBatchingAndMatchesSingleQueries() { + String previous = System.getProperty("jvector.ash.queryProjection"); + try { + System.setProperty("jvector.ash.queryProjection", "fp32"); + Random random = new Random(5521); + float[][] matrix = random(random, 64, 128); + var query = prepared(matrix).forQueryScoring(); + // Queued ASH search prepares queries in batches only when the view says it can. + assertTrue(query.supportsBatchProjection()); + float[][] inputs = random(random, 8, 128); + float[][] batched = new float[8][64]; + query.projectBatch(inputs, batched, 8); + for (int i = 0; i < 8; i++) { + float[] single = new float[64]; + query.project(inputs[i], single); + for (int r = 0; r < 64; r++) assertEquals(single[r], batched[i][r], 1e-4f); + } + } finally { + if (previous == null) System.clearProperty("jvector.ash.queryProjection"); + else System.setProperty("jvector.ash.queryProjection", previous); + } + } + @Test void fp32ArithmeticSupportsArbitraryBatchSizesAndSimdTails() { Random random = new Random(32145); From e3a5ded57153d86c75b85ebba8fb4af07d2b8bbc Mon Sep 17 00:00:00 2001 From: Ted Willke Date: Tue, 10 Nov 2026 14:03:07 -0800 Subject: [PATCH 209/243] Key cached benchmark graphs by everything that shapes the index file The on-disk graph cache name now includes the dataset's vector count and dimension, the on-disk format version and the NVQ subvector layout, so profiles or files sharing a dataset name, and older-format graphs, no longer collide. The ASH query projection precision is part of the key only when construction scores with it; symmetric-all construction never projects, so FP32, FP16 and INT8 query runs reuse one graph instead of rebuilding it. --- .../example/CompressedOnDiskBuild.java | 7 +- .../github/jbellis/jvector/example/Grid.java | 11 ++-- .../jbellis/jvector/example/NVQProvider.java | 7 +- .../example/util/OnDiskGraphIndexCache.java | 37 +++++++++-- .../util/OnDiskGraphIndexCacheKeyTest.java | 64 +++++++++++++++++++ 5 files changed, 112 insertions(+), 14 deletions(-) create mode 100644 jvector-examples/src/test/java/io/github/jbellis/jvector/example/util/OnDiskGraphIndexCacheKeyTest.java diff --git a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/CompressedOnDiskBuild.java b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/CompressedOnDiskBuild.java index 78d11fac4..4369de0d2 100644 --- a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/CompressedOnDiskBuild.java +++ b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/CompressedOnDiskBuild.java @@ -106,12 +106,15 @@ public BuiltIndexes build(List> featureSets, BuildConfi Map, OnDiskGraphIndexCache.WriteHandle> handles = new LinkedHashMap<>(); try { + int baseCount = ds.getBaseRavv().size(), dimension = ds.getDimension(); for (Set fs : featureSets) { + int nvq = fs.contains(FeatureId.NVQ_VECTORS) ? NVQProvider.subvectors(dimension) : 0; var key = cacheIdentity == null ? cache.key(ds.getName(), fs, build.M, build.efConstruction, build.neighborOverflow, build.alpha, - build.addHierarchy, build.refineFinalGraph, buildCompressor) + build.addHierarchy, build.refineFinalGraph, buildCompressor, baseCount, dimension, nvq) : cache.keyWithCompressorId(ds.getName(), fs, build.M, build.efConstruction, build.neighborOverflow, - build.alpha, build.addHierarchy, build.refineFinalGraph, cacheIdentity); + build.alpha, build.addHierarchy, build.refineFinalGraph, cacheIdentity, + baseCount, dimension, nvq); var cached = cache.tryLoad(key); if (cached.isPresent()) { diff --git a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/Grid.java b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/Grid.java index 19bf3265a..a5e9c00eb 100644 --- a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/Grid.java +++ b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/Grid.java @@ -302,11 +302,14 @@ private static void runOneGraph(OnDiskGraphIndexCache cache, Supplier> fusedCodec = fused ? () -> getCompressor(searchParams, ds, constructionMetrics, Phase.INDEX, quantTypeOf(searchParams)) : null; + // The graph depends on query projection only when construction scores with it; + // symmetric-all scoring never projects, so its graphs are shared across precisions. + String ashMode = buildParams instanceof CompressorParameters.ASHParameters + ? GraphBuilderFactory.ashConstructionMode(build) : null; String cacheIdentity = "schema3:" + compressorId(buildParams, ds) - + (buildParams instanceof CompressorParameters.ASHParameters - ? ":" + GraphBuilderFactory.ashConstructionMode(build) + ":" - + System.getProperty("jvector.ash.queryProjection", "auto") - : "") + + (ashMode == null ? "" + : ":" + ashMode + ("symmetric-all".equals(ashMode) ? "" + : ":" + System.getProperty("jvector.ash.queryProjection", "auto"))) + (fused ? ":" + compressorId(searchParams, ds) : ""); strategy = new CompressedOnDiskBuild(cache, workDirectory, buildCodec, constructionMetrics, fusedCodec, cacheIdentity); diff --git a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/NVQProvider.java b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/NVQProvider.java index eeeb442cf..c6492b7dd 100644 --- a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/NVQProvider.java +++ b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/NVQProvider.java @@ -37,8 +37,13 @@ public void configure(FeatureWriteContext ctx) { ctx.supplyFromVector(FeatureId.NVQ_VECTORS, vector -> new NVQ.State(nvq.encode(vector))); } + /** Subvector count the benchmark uses for NVQ; also part of the on-disk graph cache key. */ + static int subvectors(int dimension) { + return dimension <= 2 ? 1 : 2; + } + static NVQuantization train(RandomAccessVectorValues vectors, VectorFloat mean) { - int nSubVectors = vectors.dimension() <= 2 ? 1 : 2; + int nSubVectors = subvectors(vectors.dimension()); return mean == null ? NVQuantization.compute(vectors, nSubVectors) : NVQuantization.create(mean, nSubVectors); } diff --git a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/util/OnDiskGraphIndexCache.java b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/util/OnDiskGraphIndexCache.java index 2da5134ec..f1b5d3227 100644 --- a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/util/OnDiskGraphIndexCache.java +++ b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/util/OnDiskGraphIndexCache.java @@ -129,6 +129,11 @@ public static final class CacheKey { public final boolean addHierarchy; public final boolean refineFinalGraph; public final String compressorId; + /** Base vectors in the dataset; two datasets or profiles sharing a name must not share a graph. */ + public final int baseCount; + public final int dimension; + /** NVQ subvectors when the feature set stores NVQ vectors, otherwise 0. */ + public final int nvqSubvectors; private CacheKey(String datasetName, Set featureSet, @@ -138,7 +143,10 @@ private CacheKey(String datasetName, float alpha, boolean addHierarchy, boolean refineFinalGraph, - String compressorId) { + String compressorId, + int baseCount, + int dimension, + int nvqSubvectors) { this.datasetName = datasetName; this.featureSet = featureSet; this.M = M; @@ -148,6 +156,9 @@ private CacheKey(String datasetName, this.addHierarchy = addHierarchy; this.refineFinalGraph = refineFinalGraph; this.compressorId = compressorId; + this.baseCount = baseCount; + this.dimension = dimension; + this.nvqSubvectors = nvqSubvectors; } } @@ -163,20 +174,28 @@ public CacheKey key(String datasetName, float alpha, boolean addHierarchy, boolean refineFinalGraph, - VectorCompressor buildCompressor) { + VectorCompressor buildCompressor, + int baseCount, + int dimension, + int nvqSubvectors) { Objects.requireNonNull(datasetName, "datasetName"); Objects.requireNonNull(featureSet, "featureSet"); Objects.requireNonNull(buildCompressor, "buildCompressor"); String compressorId = buildCompressor.toString().replaceAll("\\s+", ""); return keyWithCompressorId(datasetName, featureSet, M, efConstruction, neighborOverflow, alpha, - addHierarchy, refineFinalGraph, compressorId); + addHierarchy, refineFinalGraph, compressorId, baseCount, dimension, nvqSubvectors); } public CacheKey keyWithCompressorId(String datasetName, Set featureSet, int M, int efConstruction, float neighborOverflow, float alpha, - boolean addHierarchy, boolean refineFinalGraph, String compressorId) { + boolean addHierarchy, boolean refineFinalGraph, String compressorId, + int baseCount, int dimension, int nvqSubvectors) { + if (baseCount <= 0 || dimension <= 0 || nvqSubvectors < 0) + throw new IllegalArgumentException("Cache key needs a positive vector count and dimension"); + if (featureSet.contains(FeatureId.NVQ_VECTORS) != (nvqSubvectors > 0)) + throw new IllegalArgumentException("NVQ subvectors must be given exactly when NVQ vectors are stored"); return new CacheKey(datasetName, featureSet, M, efConstruction, neighborOverflow, alpha, - addHierarchy, refineFinalGraph, compressorId); + addHierarchy, refineFinalGraph, compressorId, baseCount, dimension, nvqSubvectors); } /** @@ -334,7 +353,6 @@ static Entry disabled() { return new Entry(true, "", Path.of(""), Path.of("")); } - // TODO include index and quantization version(s) in signature static Entry compute(Path cacheDir, CacheKey key) { String rawName = Paths.get(key.datasetName).getFileName().toString(); String datasetBase = rawName.replaceFirst("\\.[^.]+$", ""); @@ -344,9 +362,14 @@ static Entry compute(Path cacheDir, CacheKey key) { .sorted() .collect(Collectors.joining("-")); + // The on-disk format, the dataset's shape and the NVQ layout all change the file; the + // quantizer ids in compressorId carry their own model parameters and versions. String signature = String.join("_", datasetBase, - featureSetName, + "n" + key.baseCount, + "d" + key.dimension, + "fmt" + OnDiskGraphIndex.CURRENT_VERSION, + featureSetName + (key.nvqSubvectors > 0 ? "-nvq" + key.nvqSubvectors : ""), "M" + key.M, "ef" + key.efConstruction, "of" + key.neighborOverflow, diff --git a/jvector-examples/src/test/java/io/github/jbellis/jvector/example/util/OnDiskGraphIndexCacheKeyTest.java b/jvector-examples/src/test/java/io/github/jbellis/jvector/example/util/OnDiskGraphIndexCacheKeyTest.java new file mode 100644 index 000000000..f21947c30 --- /dev/null +++ b/jvector-examples/src/test/java/io/github/jbellis/jvector/example/util/OnDiskGraphIndexCacheKeyTest.java @@ -0,0 +1,64 @@ +/* + * 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.graph.disk.OnDiskGraphIndex; +import io.github.jbellis.jvector.graph.disk.feature.FeatureId; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +import java.util.EnumSet; +import java.util.Set; + +import static org.junit.Assert.*; + +/** Cache names must change exactly when something that shapes the index file changes. */ +public class OnDiskGraphIndexCacheKeyTest { + private static final Set FUSED_NVQ = EnumSet.of(FeatureId.FUSED_ASH, FeatureId.NVQ_VECTORS); + + @Rule + public TemporaryFolder dir = new TemporaryFolder(); + + private static String name(OnDiskGraphIndexCache cache, String dataset, int count, int dim, int nvq) { + var key = cache.keyWithCompressorId(dataset, FUSED_NVQ, 32, 100, 1.2f, 1.2f, true, true, + "schema3:ASH_x:symmetric-all", count, dim, nvq); + return cache.resolve(key).finalPath.getFileName().toString(); + } + + @Test + public void signatureCarriesShapeFormatAndNvqLayout() throws Exception { + var cache = OnDiskGraphIndexCache.initialize(dir.getRoot().toPath()); + String base = name(cache, "ds", 1_000_000, 768, 2); + assertTrue(base, base.contains("_n1000000_d768_fmt" + OnDiskGraphIndex.CURRENT_VERSION + "_")); + assertTrue(base, base.contains("_NVQ_VECTORS_nvq2_")); + assertEquals("identical inputs share one graph", base, name(cache, "ds", 1_000_000, 768, 2)); + assertNotEquals("a larger profile is a different graph", base, name(cache, "ds", 100_000_000, 768, 2)); + assertNotEquals("a different dimension is a different graph", base, name(cache, "ds", 1_000_000, 384, 2)); + assertNotEquals("a different NVQ layout is a different file", base, name(cache, "ds", 1_000_000, 768, 1)); + } + + @Test + public void nvqSubvectorsMustMatchTheFeatureSet() throws Exception { + var cache = OnDiskGraphIndexCache.initialize(dir.getRoot().toPath()); + assertThrows(IllegalArgumentException.class, () -> name(cache, "ds", 10, 4, 0)); + assertThrows(IllegalArgumentException.class, () -> cache.keyWithCompressorId("ds", + EnumSet.of(FeatureId.FUSED_ASH, FeatureId.INLINE_VECTORS), 32, 100, 1.2f, 1.2f, true, true, + "id", 10, 4, 2)); + assertThrows(IllegalArgumentException.class, () -> name(cache, "ds", 0, 4, 2)); + } +} From 6ae13f0155ae0bfdb165d6b12174e2a5c75e2279 Mon Sep 17 00:00:00 2001 From: Ted Willke Date: Tue, 10 Nov 2026 16:47:46 -0800 Subject: [PATCH 210/243] Let DistancesFP load public datasets by name and measure exact FP32 recall DistancesFP now takes a dataset name from the dataset catalog (default e5-small-v2-100k) instead of file paths, and scores with the dataset's similarity. With -Djvector.bench.recall=true it scans every query against the whole base, 32 queries per pass, and reports recall.k-recall@N against the dataset's ground truth, like DistancesASH and DistancesPQ. --- .../jbellis/jvector/example/DistancesFP.java | 206 ++++++++++++++---- 1 file changed, 167 insertions(+), 39 deletions(-) diff --git a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/DistancesFP.java b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/DistancesFP.java index c0fa93478..4e6b97168 100644 --- a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/DistancesFP.java +++ b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/DistancesFP.java @@ -17,61 +17,89 @@ package io.github.jbellis.jvector.example; import com.sun.management.ThreadMXBean; -import io.github.jbellis.jvector.example.util.SiftLoader; +import io.github.jbellis.jvector.example.benchmarks.datasets.DataSet; +import io.github.jbellis.jvector.example.benchmarks.datasets.DataSets; import io.github.jbellis.jvector.graph.ListRandomAccessVectorValues; import io.github.jbellis.jvector.graph.similarity.BuildScoreProvider; +import io.github.jbellis.jvector.vector.VectorSimilarityFunction; import io.github.jbellis.jvector.vector.VectorizationProvider; import io.github.jbellis.jvector.vector.types.VectorFloat; +import java.io.IOException; import java.lang.management.ManagementFactory; import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashSet; import java.util.List; import java.util.Locale; import java.util.SplittableRandom; +import java.util.concurrent.ForkJoinPool; +import java.util.concurrent.ForkJoinTask; import java.util.stream.IntStream; -import static io.github.jbellis.jvector.vector.VectorSimilarityFunction.DOT_PRODUCT; - /** - * Single-thread FP32 distance benchmark. Pass {@code base.fvecs query.fvecs} or - * {@code --synthetic dimensions}. Use separate JVMs with {@code jvector.vectorization_provider} - * set to {@code native}, {@code panama}, or {@code default} to compare backends. + * FP32 distance benchmark. Pass a dataset name from the public dataset catalog (default + * {@value #DEFAULT_DATASET}; it is downloaded and cached on first use) or {@code --synthetic dimensions} + * for random unit vectors. Use separate JVMs with {@code jvector.vectorization_provider} set to + * {@code native}, {@code panama}, or {@code default} to compare backends. * - *

    Properties (prefix {@code jvector.bench.}): {@code maxBaseVectors} (65536), - * {@code maxQueries} (16), {@code candidatesPerQuery} (entire base), - * {@code scoringWarmupPasses} (3), and {@code scoringSamples} (3). A smaller candidate - * count gives each query a different window of the selected traversal order.

    + *

    Timing mode (the default) runs on one thread. Properties (prefix {@code jvector.bench.}): + * {@code maxBaseVectors} (65536), {@code maxQueries} (16), {@code candidatesPerQuery} (entire base), + * {@code scoringWarmupPasses} (3), and {@code scoringSamples} (3). A smaller candidate count gives + * each query a different window of the selected traversal order. The direct mode calls the + * production similarity. The construction mode creates the same exact scorer used for graph + * insertion once per query and includes that setup in the measurement. Input loading and shuffling + * happen outside the measurements. The first pass is reported separately because it can initialize + * native vector views and compile code. Allocation measurements count Java heap bytes on the + * scoring thread.

    * - *

    The direct mode calls the production dot-product similarity. The construction mode - * creates the same exact scorer used for graph insertion once per query and includes that - * setup in the measurement. Input loading and shuffling happen outside the measurements. - * The first pass is reported separately because it can initialize native vector views and - * compile code. Allocation measurements count Java heap bytes on the scoring thread.

    + *

    Recall mode ({@code -Djvector.bench.recall=true}), like DistancesASH and DistancesPQ, scores + * every query against the whole base with the dataset's similarity and reports exact FP32 + * {@code recall.k}-recall@N against the dataset's ground truth for N = 10, 15, 20, 30, 40 and 50. + * It uses all base vectors and all queries (or {@code maxQueries}, if set), {@code recall.k} + * (default 10), and {@code scoringThreads} threads (default 1), and skips the timing measurements. + * A {@code recall.k}-recall@{@code recall.k} below 1.0 means the ground truth disagrees with an exact scan.

    */ public final class DistancesFP { private DistancesFP() {} - public static void main(String[] args) { - if (args.length != 2) { - throw new IllegalArgumentException("Usage: DistancesFP | --synthetic "); - } - int maxBase = positiveProperty("maxBaseVectors", 65_536); - int maxQueries = positiveProperty("maxQueries", 16); - int warmups = positiveProperty("scoringWarmupPasses", 3); - int samples = positiveProperty("scoringSamples", 3); - var provider = VectorizationProvider.getInstance(); - List> base; - List> queries; - if (args[0].equals("--synthetic")) { + static final String DEFAULT_DATASET = "e5-small-v2-100k"; + + public static void main(String[] args) throws IOException { + if (args.length == 2 && args[0].equals("--synthetic")) { int dimension = Integer.parseInt(args[1]); if (dimension <= 0) throw new IllegalArgumentException("Dimensions must be positive"); + int maxBase = positiveProperty("maxBaseVectors", 65_536); + int maxQueries = positiveProperty("maxQueries", 16); var random = new SplittableRandom(42); - base = syntheticVectors(provider, random, maxBase, dimension); - queries = syntheticVectors(provider, random, maxQueries, dimension); - } else { - base = SiftLoader.readFvecs(args[0], maxBase); - queries = SiftLoader.readFvecs(args[1], maxQueries); + var provider = VectorizationProvider.getInstance(); + timing(syntheticVectors(provider, random, maxBase, dimension), + syntheticVectors(provider, random, maxQueries, dimension), VectorSimilarityFunction.DOT_PRODUCT); + return; } + if (args.length > 1) { + throw new IllegalArgumentException("Usage: DistancesFP [dataset-name] | --synthetic "); + } + String name = args.length == 0 ? DEFAULT_DATASET : args[0]; + try (DataSet dataset = DataSets.loadDataSet(name) + .orElseThrow(() -> new IllegalArgumentException("Unknown dataset: " + name)).getDataSet()) { + System.out.printf(Locale.ROOT, "Dataset %s, similarity %s%n", dataset.getName(), dataset.getSimilarityFunction()); + if (Boolean.getBoolean("jvector.bench.recall")) { + recall(dataset); + } else { + var base = dataset.getBaseVectors(); + var queries = dataset.getQueryVectors(); + timing(base.subList(0, Math.min(base.size(), positiveProperty("maxBaseVectors", 65_536))), + queries.subList(0, Math.min(queries.size(), positiveProperty("maxQueries", 16))), + dataset.getSimilarityFunction()); + } + } + } + + private static void timing(List> base, List> queries, VectorSimilarityFunction similarity) { + int warmups = positiveProperty("scoringWarmupPasses", 3); + int samples = positiveProperty("scoringSamples", 3); + var provider = VectorizationProvider.getInstance(); if (base.isEmpty() || queries.isEmpty()) { throw new IllegalArgumentException("Base and query vectors must be nonempty"); } @@ -89,7 +117,7 @@ public static void main(String[] args) { } if (!alloc.isThreadAllocatedMemoryEnabled()) alloc.setThreadAllocatedMemoryEnabled(true); var scoreProvider = BuildScoreProvider.randomAccessScoreProvider( - new ListRandomAccessVectorValues(base, dimensions), DOT_PRODUCT); + new ListRandomAccessVectorValues(base, dimensions), similarity); var sequential = IntStream.range(0, base.size()).toArray(); var shuffled = sequential.clone(); var random = new SplittableRandom(43); @@ -104,12 +132,13 @@ public static void main(String[] args) { provider.getClass().getSimpleName(), dimensions, base.size(), queries.size(), candidates); System.out.println("phase,access,scorer,sample,scores,seconds,ns_per_score,million_scores_per_s," + "allocated_bytes,bytes_per_score,bytes_per_query,checksum"); - run("sequential", sequential, base, queries, candidates, scoreProvider, alloc, warmups, samples); - run("shuffled", shuffled, base, queries, candidates, scoreProvider, alloc, warmups, samples); + run("sequential", sequential, base, queries, candidates, similarity, scoreProvider, alloc, warmups, samples); + run("shuffled", shuffled, base, queries, candidates, similarity, scoreProvider, alloc, warmups, samples); } private static void run(String access, int[] order, List> base, List> queries, - int candidates, BuildScoreProvider provider, ThreadMXBean alloc, int warmups, int samples) { + int candidates, VectorSimilarityFunction similarity, BuildScoreProvider provider, + ThreadMXBean alloc, int warmups, int samples) { long thread = Thread.currentThread().getId(); long comparisons = (long) candidates * queries.size(); double expected = Double.NaN; @@ -118,7 +147,7 @@ private static void run(String access, int[] order, List> base, L for (int sample = -warmups; sample < samples; sample++) { long beforeBytes = alloc.getThreadAllocatedBytes(thread); long start = System.nanoTime(); - double checksum = score(order, base, queries, candidates, provider, construction); + double checksum = score(order, base, queries, candidates, similarity, provider, construction); long elapsed = System.nanoTime() - start; long bytes = alloc.getThreadAllocatedBytes(thread) - beforeBytes; if (!Double.isFinite(checksum)) throw new IllegalStateException("Non-finite score checksum"); @@ -138,7 +167,8 @@ private static void run(String access, int[] order, List> base, L } private static double score(int[] order, List> base, List> queries, - int candidates, BuildScoreProvider provider, boolean construction) { + int candidates, VectorSimilarityFunction similarity, BuildScoreProvider provider, + boolean construction) { double checksum = 0; for (int q = 0; q < queries.size(); q++) { var query = queries.get(q); @@ -148,12 +178,110 @@ private static double score(int[] order, List> base, List> base = dataset.getBaseVectors(); + List> allQueries = dataset.getQueryVectors(); + List> queries = allQueries.subList(0, + Math.min(allQueries.size(), positiveProperty("maxQueries", Integer.MAX_VALUE))); + List> groundTruth = dataset.getGroundTruth(); + if (base.isEmpty() || queries.isEmpty()) throw new IllegalArgumentException("Base and query vectors must be nonempty"); + if (groundTruth.size() < queries.size()) + throw new IllegalArgumentException("Ground truth has " + groundTruth.size() + " rows for " + queries.size() + " queries"); + if (maxAt > base.size()) throw new IllegalArgumentException("Recall needs at least " + maxAt + " base vectors"); + for (int q = 0; q < queries.size(); q++) { + if (groundTruth.get(q).size() < recallK) + throw new IllegalArgumentException("Ground truth row " + q + " has fewer than recall.k=" + recallK + " entries"); + } + int dimensions = base.get(0).length(); + System.out.printf(Locale.ROOT, "DistancesFP recall provider=%s dimensions=%d base=%d queries=%d recall.k=%d threads=%d%n", + VectorizationProvider.getInstance().getClass().getSimpleName(), dimensions, base.size(), + queries.size(), recallK, threads); + long start = System.nanoTime(); + double[] totals = new double[atValues.length]; + var pool = new ForkJoinPool(threads); + try { + int chunk = (queries.size() + threads - 1) / threads; + var tasks = new ArrayList>(); + for (int first = 0; first < queries.size(); first += chunk) { + int from = first, to = Math.min(first + chunk, queries.size()); + tasks.add(pool.submit(() -> { + double[] local = new double[atValues.length]; + int[][] ids = new int[QUERY_BLOCK][maxAt]; + float[][] scores = new float[QUERY_BLOCK][maxAt]; + int[] sizes = new int[QUERY_BLOCK]; + for (int block = from; block < to; block += QUERY_BLOCK) { + int count = Math.min(QUERY_BLOCK, to - block); + topScores(queries.subList(block, block + count), base, similarity, maxAt, ids, scores, sizes); + for (int b = 0; b < count; b++) { + var expected = new HashSet(groundTruth.get(block + b).subList(0, recallK)); + for (int a = 0; a < atValues.length; a++) { + int matches = 0; + for (int r = 0; r < Math.min(atValues[a], sizes[b]); r++) { + if (expected.contains(ids[b][r])) matches++; + } + local[a] += (double) matches / expected.size(); + } + } + } + return local; + })); + } + for (var task : tasks) { + double[] local = task.join(); + for (int a = 0; a < totals.length; a++) totals[a] += local[a]; + } + } finally { + pool.shutdown(); + } + for (int a = 0; a < atValues.length; a++) { + System.out.printf(Locale.ROOT, "\tFP32 exact %d-recall@%d = %.6f%n", recallK, atValues[a], totals[a] / queries.size()); + } + System.out.printf(Locale.ROOT, "\tRecall scan time: %.1f s%n", (System.nanoTime() - start) / 1e9); + } + + /** Queries scored per pass over the base, so each base vector is read from memory once per block. */ + private static final int QUERY_BLOCK = 32; + + /** + * For each query, fills ids[q]/scores[q] with its n highest similarity scores, best first, and sizes[q] + * with how many were filled. All queries share one pass over the base. + */ + private static void topScores(List> queries, List> base, + VectorSimilarityFunction similarity, int n, int[][] ids, float[][] scores, int[] sizes) { + Arrays.fill(sizes, 0); + for (int i = 0; i < base.size(); i++) { + var vector = base.get(i); + for (int q = 0; q < queries.size(); q++) { + float score = similarity.compare(queries.get(q), vector); + float[] top = scores[q]; + int size = sizes[q]; + if (size == n && score <= top[n - 1]) continue; + int[] topIds = ids[q]; + int pos = size < n ? size++ : n - 1; + while (pos > 0 && top[pos - 1] < score) { + top[pos] = top[pos - 1]; + topIds[pos] = topIds[pos - 1]; + pos--; + } + top[pos] = score; + topIds[pos] = i; + sizes[q] = size; + } + } + } + private static List> syntheticVectors(VectorizationProvider provider, SplittableRandom random, int count, int dimensions) { var support = provider.getVectorTypeSupport(); From e5bacc62402e5553dec8b4c0c65935f3af1ec5b0 Mon Sep 17 00:00:00 2001 From: Ted Willke Date: Tue, 10 Nov 2026 17:33:36 -0800 Subject: [PATCH 211/243] Score leftover ASH dimensions in one step in the native code-vs-code kernels Graph construction compares stored codes with each other. The 2-bit kernel scored the dimensions past the last whole 32-dimension word one at a time, and the 4-bit kernel did the same past the last whole SIMD vector, up to 127 dimensions on AVX-512. A 2-bit code at d=380 cost 66 ns per pair against 14 ns at d=384, and the IBM datapile 1M build took 172 s instead of 108 s. The default 4-bit d=192 code cost 132 ns per pair. The 2-bit tail is now one masked word scored with the same popcounts, and the 4-bit tail one zero-filled partial vector. On a Xeon 6985P-C, d=380 at 2 bits is 16.6 ns and d=192 at 4 bits is 14.5 ns; the IBM 1M 380x2 build takes 107 s. A new test checks every dimension from 1 to 400 at 2 and 4 bits against a per-dimension decode, with random padding bits. docs/ash-configuration.md now recommends a projected dimension that is a multiple of 64 and lists the measured costs. --- docs/ash-configuration.md | 38 +++++++++++++++ .../src/main/native/src/ash_symmetric-inl.h | 48 ++++++++++++++++++- .../vector/NativeASHSymmetricDotAtTest.java | 27 +++++++++++ 3 files changed, 111 insertions(+), 2 deletions(-) diff --git a/docs/ash-configuration.md b/docs/ash-configuration.md index 6112bac7f..cf0bb9522 100644 --- a/docs/ash-configuration.md +++ b/docs/ash-configuration.md @@ -28,6 +28,44 @@ For an exact payload, use `encodedBits = 40 + d*b`. For example, a 256-D input projected to 128 dimensions at four bits uses `encodedBits: 552`. Include the header when comparing memory budgets with other quantizers. +### Choosing the projected dimension for speed + +For the fastest construction and search, use `b` = 1, 2 or 4 and make `d` a +multiple of 64. The native kernels then process whole 64-bit words and whole +SIMD vectors, with no leftover dimensions: + +- Construction compares stored codes with each other. At 1 bit, the kernel + works in words of 64 dimensions. At 2 bits, it works in words of 32 + dimensions. At 4 bits, it works in vectors of up to 128 dimensions. +- Search builds per-query lookup tables in 16-byte chunks of code and projects + queries 32 output dimensions at a time. + +Other values of `d` are correct and handled in one extra masked step, so the +penalty is small. In the measurements below, a 2-bit code at `d = 380` costs +about 20-30% more per construction comparison than at `d = 384`, and search +QPS differs by about 1.5%. When a byte budget lands just short of an aligned +`d`, prefer the aligned value. For example, with a 384-D input and a +100-byte budget, `d = 384` at 2 bits (101 bytes, no dimension reduction) is a +better choice than `d = 380` (100 bytes). + +The other widths, `b` = 3 and 5 through 9, are supported by core ASH but use +a generic per-dimension Java path for scoring, and fused graphs do not +support them. They are slower than 1, 2 or 4 bits at every `d`. Fast native +kernels for these widths are left for future work. + +Construction comparison cost, in ns per code pair, on one pinned core with +the native provider, on an Intel Xeon 6985P-C and on an AWS m6id.4xlarge +(Ice Lake). + +| `b` | `d` | Xeon 6985P-C | m6id.4xlarge | +| --- | --- | --- | --- | +| 2 | 192 | 9.3 | 16.7 | +| 2 | 380 | 16.6 | 30.0 | +| 2 | 384 | 13.8 | 22.5 | +| 4 | 190 | 15.2 | 24.0 | +| 4 | 192 | 14.5 | 24.1 | +| 4 | 384 | 18.4 | 28.5 | + Training and base encoding retain their FP32 projection path; some training statistics and factorizations use FP64 internally. Query precision does not change the trained model, base encoding, stored-code layout or index format. diff --git a/jvector-native/src/main/native/src/ash_symmetric-inl.h b/jvector-native/src/main/native/src/ash_symmetric-inl.h index c43f0fea9..b647cb72e 100644 --- a/jvector-native/src/main/native/src/ash_symmetric-inl.h +++ b/jvector-native/src/main/native/src/ash_symmetric-inl.h @@ -76,12 +76,52 @@ HWY_INLINE float AshTwoBitPair(const uint8_t *a, const uint8_t *b, int dimension deltas = hn::Add(deltas, hn::BitCast(di, delta)); } int64_t total = static_cast(words) * 32 + hn::ReduceSum(di, deltas); - for (int j = static_cast(words * 32); j < dimensions; ++j) - total += AshDoubledComponent(a, j, 2) * AshDoubledComponent(b, j, 2); + // The last partial word: copy only its code bytes and clear the padding fields, which then + // contribute nothing. One word of popcounts instead of one decode per leftover dimension. + const int tail = dimensions & 31; + if (tail) { + // A masked load of the code bytes only; it never reads past the code. + const hn::FixedTag d8; + const hn::FixedTag d64; + const size_t tailBytes = (static_cast(tail) + 3) / 4; + uint64_t ta = hn::GetLane(hn::BitCast(d64, hn::LoadN(d8, a + words * 8, tailBytes))); + uint64_t tb = hn::GetLane(hn::BitCast(d64, hn::LoadN(d8, b + words * 8, tailBytes))); + const uint64_t fields = (uint64_t{1} << (2 * tail)) - 1, slot = uint64_t{0x5555555555555555}; + ta &= fields; + tb &= fields; + const uint64_t neg = ((ta ^ tb) >> 1) & slot, ma = ta & slot, mb = tb & slot, both = ma & mb; + int64_t delta = 2 * (static_cast(hwy::PopCount(ma)) + hwy::PopCount(mb) - hwy::PopCount(neg)); + delta -= 4 * (static_cast(hwy::PopCount(ma & neg)) + hwy::PopCount(mb & neg)); + delta += 4 * static_cast(hwy::PopCount(both)); + delta -= 8 * static_cast(hwy::PopCount(both & neg)); + total += tail + delta; + } return static_cast(total) * 0.25f; #endif } +#if HWY_TARGET != HWY_SCALAR +// The last partial vector of 4-bit codes, n bytes. Kept out of line so the full-vector loop +// compiles as before. LoadN zero-fills the lanes past the code; a zero byte decodes to two +// (+1)*(+1) products, which are subtracted. +HWY_NOINLINE int64_t AshFourBitPartial(const uint8_t *a, const uint8_t *b, size_t n) { + const hn::ScalableTag di; + const hn::Repartition db; + const hn::RebindToSigned ds; + const auto av = hn::LoadN(db, a, n), bv = hn::LoadN(db, b, n); + auto sum = hn::Zero(di); + for (int shift = 0; shift < 8; shift += 4) { + const auto ax = hn::ShiftRightSame(av, shift), bx = hn::ShiftRightSame(bv, shift); + const auto am = hn::Add(hn::ShiftLeft<1>(hn::And(ax, hn::Set(db, 7))), hn::Set(db, 1)); + const auto bm = hn::BitCast(ds, hn::Add(hn::ShiftLeft<1>(hn::And(bx, hn::Set(db, 7))), hn::Set(db, 1))); + const auto sameSign = hn::Eq(hn::And(hn::Xor(ax, bx), hn::Set(db, 8)), hn::Zero(db)); + const auto signedB = hn::IfThenElse(hn::RebindMask(ds, sameSign), bm, hn::Neg(bm)); + sum = hn::SumOfMulQuadAccumulate(di, am, signedB, sum); + } + return hn::ReduceSum(di, sum) - 2 * static_cast(hn::Lanes(db) - n); +} +#endif + HWY_INLINE float AshFourBitPair(const uint8_t *a, const uint8_t *b, int dimensions) { int64_t total = 0; int j = 0; @@ -105,6 +145,10 @@ HWY_INLINE float AshFourBitPair(const uint8_t *a, const uint8_t *b, int dimensio } total += hn::ReduceSum(di, sum); } + if (offset < bytes) { + total += AshFourBitPartial(a + offset, b + offset, bytes - offset); + offset = bytes; + } j = static_cast(2 * offset); #endif for (; j < dimensions; ++j) total += AshDoubledComponent(a, j, 4) * AshDoubledComponent(b, j, 4); diff --git a/jvector-native/src/test/java/io/github/jbellis/jvector/vector/NativeASHSymmetricDotAtTest.java b/jvector-native/src/test/java/io/github/jbellis/jvector/vector/NativeASHSymmetricDotAtTest.java index d1bcc5b33..17d047150 100644 --- a/jvector-native/src/test/java/io/github/jbellis/jvector/vector/NativeASHSymmetricDotAtTest.java +++ b/jvector-native/src/test/java/io/github/jbellis/jvector/vector/NativeASHSymmetricDotAtTest.java @@ -58,6 +58,33 @@ void offsetCodesMatchStandaloneCodes() { } } + /** Every dimension count, including partial words and vectors, must equal a per-dimension decode. */ + @Test + void codesMatchPerDimensionReference() { + Random random = new Random(20261111); + for (int bits : new int[]{2, 4}) { + for (int dimensions = 1; dimensions <= 400; dimensions++) { + int bytes = (dimensions * bits + 7) / 8; + byte[] a = new byte[bytes], b = new byte[bytes]; + // Random padding bits as well: the kernels must ignore them. + random.nextBytes(a); random.nextBytes(b); + long doubled = 0; + for (int j = 0; j < dimensions; j++) doubled += (long) component(a, j, bits) * component(b, j, bits); + assertEquals(doubled * 0.25f, NativeASHSymmetricKernels.dot(null, a, null, b, dimensions, bits), 0f, + "bits=" + bits + " d=" + dimensions); + } + } + } + + /** Doubled signed-magnitude component j: the sign is the field's high bit, the magnitude its low bits. */ + private static int component(byte[] code, int j, int bits) { + int perByte = 8 / bits; + int field = (code[j / perByte] & 0xFF) >>> ((j % perByte) * bits); + int sign = 1 << (bits - 1); + int magnitude = 2 * (field & (sign - 1)) + 1; + return (field & sign) != 0 ? magnitude : -magnitude; + } + @Test void offsetSignWordsMatchStandaloneSignWords() { Random random = new Random(20261109); From 18f936e79f3ead52d3483248183f928a1cad24ff Mon Sep 17 00:00:00 2001 From: Ted Willke Date: Tue, 10 Nov 2026 15:07:16 -0800 Subject: [PATCH 212/243] Build the ASH query LUT from per-dimension products buildQueryLut decoded and multiplied every term of every table entry, so a 512-dimension 2-bit query spent 8.5 us building its table on Ice Lake, as long as its INT8 projection. Form each product once per dimension and assemble the 16 entries of a group from them, adding in the same order so the table is bit-identical, including signed zeros and non-finite values. --- .../jvector/quantization/FusedASHLayout.java | 51 +++++++++++++- .../quantization/TestFusedASHQueryLut.java | 70 +++++++++++++++++++ 2 files changed, 120 insertions(+), 1 deletion(-) create mode 100644 jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestFusedASHQueryLut.java diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/FusedASHLayout.java b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/FusedASHLayout.java index 27668a90f..5e80b74d7 100644 --- a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/FusedASHLayout.java +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/FusedASHLayout.java @@ -316,7 +316,24 @@ public static void buildQueryLut(float[] qProj, int quantizedDim, int bitsPerDim throw new IllegalArgumentException("lut length is smaller than groups * 16"); } - for (int group = 0; group < groups; group++) { + // Every entry is still 0 + q[d0]*c0 + q[d1]*c1 + ..., added in slot order, + // so the table is bit-identical to the per-entry definition. Each product + // is formed once per dimension instead of once per entry, and the field + // decode is a constant table rather than a branch per term. + int complete = quantizedDim / groupDims; + if (bitsPerDimension == 2) { + for (int group = 0; group < complete; group++) lut2(qProj, group << 1, lut, group << 4); + } else if (bitsPerDimension == 1) { + for (int group = 0; group < complete; group++) lut1(qProj, group << 2, lut, group << 4); + } else { + for (int group = 0; group < complete; group++) { + float q = qProj[group]; + int base = group << 4; + for (int field = 0; field < 16; field++) lut[base + field] = 0f + q * DECODE_4BIT[field]; + } + } + // A partial final group adds only the dimensions that exist. + for (int group = complete; group < groups; group++) { int dimBase = group * groupDims; int lutBase = group << 4; for (int nibble = 0; nibble < 16; nibble++) { @@ -333,6 +350,38 @@ public static void buildQueryLut(float[] qProj, int quantizedDim, int bitsPerDim } } + private static final float[] DECODE_4BIT = new float[16]; + static { + for (int field = 0; field < 16; field++) DECODE_4BIT[field] = decodeProjectionComponent(field, 4); + } + + // Two 2-bit fields per nibble: entry = (0 + a[n & 3]) + b[n >>> 2]. + // Field values -1.5, -0.5, 0.5, 1.5 (sign bit high, magnitude low). + private static void lut2(float[] qProj, int dim, float[] lut, int base) { + float q0 = qProj[dim], q1 = qProj[dim + 1]; + float a0 = 0f + q0 * -0.5f, a1 = 0f + q0 * -1.5f, a2 = 0f + q0 * 0.5f, a3 = 0f + q0 * 1.5f; + float b0 = q1 * -0.5f, b1 = q1 * -1.5f, b2 = q1 * 0.5f, b3 = q1 * 1.5f; + lut[base] = a0 + b0; lut[base + 1] = a1 + b0; lut[base + 2] = a2 + b0; lut[base + 3] = a3 + b0; + lut[base + 4] = a0 + b1; lut[base + 5] = a1 + b1; lut[base + 6] = a2 + b1; lut[base + 7] = a3 + b1; + lut[base + 8] = a0 + b2; lut[base + 9] = a1 + b2; lut[base + 10] = a2 + b2; lut[base + 11] = a3 + b2; + lut[base + 12] = a0 + b3; lut[base + 13] = a1 + b3; lut[base + 14] = a2 + b3; lut[base + 15] = a3 + b3; + } + + // Four sign bits per nibble: entry = (((0 + ±q0) + ±q1) + ±q2) + ±q3. + private static void lut1(float[] qProj, int dim, float[] lut, int base) { + float p0 = qProj[dim] * 1.0f, n0 = qProj[dim] * -1.0f; + float p1 = qProj[dim + 1] * 1.0f, n1 = qProj[dim + 1] * -1.0f; + float p2 = qProj[dim + 2] * 1.0f, n2 = qProj[dim + 2] * -1.0f; + float p3 = qProj[dim + 3] * 1.0f, n3 = qProj[dim + 3] * -1.0f; + for (int nibble = 0; nibble < 16; nibble++) { + float sum = 0f + ((nibble & 1) != 0 ? p0 : n0); + sum += (nibble & 2) != 0 ? p1 : n1; + sum += (nibble & 4) != 0 ? p2 : n2; + sum += (nibble & 8) != 0 ? p3 : n3; + lut[base + nibble] = sum; + } + } + public static float scoreLane( byte[] block, int blockOffset, diff --git a/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestFusedASHQueryLut.java b/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestFusedASHQueryLut.java new file mode 100644 index 000000000..b06274dc4 --- /dev/null +++ b/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestFusedASHQueryLut.java @@ -0,0 +1,70 @@ +/* + * 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 org.junit.Test; +import java.util.Random; +import static org.junit.Assert.assertEquals; + +/** The specialized query LUT builder must equal the per-entry definition bit for bit. */ +public class TestFusedASHQueryLut { + private static void reference(float[] qProj, int quantizedDim, int bits, float[] lut) { + int groupDims = FusedASHLayout.projectionDimsPerNibble(bits); + int groups = FusedASHLayout.codeGroups(quantizedDim, bits); + for (int group = 0; group < groups; group++) { + for (int nibble = 0; nibble < 16; nibble++) { + float sum = 0f; + for (int slot = 0; slot < groupDims; slot++) { + int dim = group * groupDims + slot; + if (dim >= quantizedDim) break; + int field = (nibble >>> (slot * bits)) & ((1 << bits) - 1); + sum += qProj[dim] * FusedASHLayout.decodeProjectionComponent(field, bits); + } + lut[(group << 4) + nibble] = sum; + } + } + } + + @Test + public void matchesDefinitionForEveryWidthAndTail() { + Random random = new Random(7); + float[] specials = {0f, -0f, Float.MIN_VALUE, -Float.MIN_VALUE, Float.MAX_VALUE, -Float.MAX_VALUE, + Float.POSITIVE_INFINITY, Float.NEGATIVE_INFINITY, Float.NaN, 1e-30f, -3.25f}; + for (int bits : new int[] {1, 2, 4}) { + for (int dim : new int[] {1, 2, 3, 4, 5, 7, 8, 63, 64, 191, 192, 512, 513}) { + for (int trial = 0; trial < 20; trial++) { + float[] q = new float[dim + 3]; // Values past quantizedDim must be ignored. + for (int i = 0; i < q.length; i++) { + q[i] = trial % 3 == 0 ? specials[random.nextInt(specials.length)] + : (float) random.nextGaussian() * (trial % 2 == 0 ? 1e-3f : 10f); + } + int size = FusedASHLayout.codeGroups(dim, bits) * 16; + float[] expected = new float[size + 5], actual = new float[size + 5]; + reference(q, dim, bits, expected); + java.util.Arrays.fill(actual, 42f); + java.util.Arrays.fill(expected, size, expected.length, 42f); + FusedASHLayout.buildQueryLut(q, dim, bits, actual); + for (int i = 0; i < expected.length; i++) { + assertEquals("bits=" + bits + " dim=" + dim + " entry=" + i, + Float.floatToRawIntBits(expected[i]) == Float.floatToRawIntBits(actual[i]) + || (Float.isNaN(expected[i]) && Float.isNaN(actual[i])), true); + } + } + } + } + } +} From 045632e4786035522660d09e66cb06c8521be2bd Mon Sep 17 00:00:00 2001 From: Ted Willke Date: Tue, 10 Nov 2026 15:07:21 -0800 Subject: [PATCH 213/243] Use Highway for query-sized FP32 ASH projection batches With the oneDNN add-on, every FP32 projectBatch went to SGEMM. For 4 to 32 rows Highway's register-tiled kernel is faster (13.6 vs 21.7 us/row at 8 rows on Ice Lake); oneDNN stays faster for pairs and encoding-sized batches. Highway's batch kernel reads a packed copy of the FP32 matrix, built on first use and already paid by Highway-only builds. Query batching with the add-on now pays it too (2 MB for a 1024x512 model); ramBytesUsed reports it. --- .../jvector/vector/NativeASHFP32BatchProjection.java | 8 ++++++++ .../jvector/vector/NativeASHFP32BatchProjectionTest.java | 3 ++- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/jvector-native/src/main/java/io/github/jbellis/jvector/vector/NativeASHFP32BatchProjection.java b/jvector-native/src/main/java/io/github/jbellis/jvector/vector/NativeASHFP32BatchProjection.java index ab3b65716..d57639a91 100644 --- a/jvector-native/src/main/java/io/github/jbellis/jvector/vector/NativeASHFP32BatchProjection.java +++ b/jvector-native/src/main/java/io/github/jbellis/jvector/vector/NativeASHFP32BatchProjection.java @@ -69,6 +69,7 @@ private static MethodHandle load() { RamUsageEstimator.shallowSizeOfInstance(NativeASHProjection.class); private final NativeASHProjection fallback; + static final int HIGHWAY_MIN_ROWS = 4, HIGHWAY_MAX_ROWS = 32; static VectorUtilSupport.AshProjection prepare(NativeASHProjection fallback) { return Bindings.BATCH == null ? fallback : new NativeASHFP32BatchProjection(fallback); @@ -100,6 +101,13 @@ public VectorUtilSupport.AshProjection forQueryScoring() { public void projectBatch(float[][] inputs, float[][] outputs, int count) { VectorUtilSupport.AshProjection.validateBatch(inputs, outputs, count); if (count == 0) return; + // Highway's register-tiled kernel beats SGEMM for query-sized batches + // (about 2x at 8 rows on Ice Lake and Granite Rapids); oneDNN wins for + // pairs and for encoding-sized batches. Both accumulate in FP32. + if (count >= HIGHWAY_MIN_ROWS && count <= HIGHWAY_MAX_ROWS && fallback.supportsBatchProjection()) { + fallback.projectBatch(inputs, outputs, count); + return; + } final int rows = fallback.rows(), columns = fallback.columns(); if (inputs[0].length != columns || outputs[0].length != rows) throw new IllegalArgumentException("ASH projection batch shape mismatch"); diff --git a/jvector-native/src/test/java/io/github/jbellis/jvector/vector/NativeASHFP32BatchProjectionTest.java b/jvector-native/src/test/java/io/github/jbellis/jvector/vector/NativeASHFP32BatchProjectionTest.java index 0c92a2797..3a0d5bc70 100644 --- a/jvector-native/src/test/java/io/github/jbellis/jvector/vector/NativeASHFP32BatchProjectionTest.java +++ b/jvector-native/src/test/java/io/github/jbellis/jvector/vector/NativeASHFP32BatchProjectionTest.java @@ -155,7 +155,8 @@ void fp32ArithmeticSupportsArbitraryBatchSizesAndSimdTails() { for (int i = 0; i <= count; i++) assertArrayEquals(saved[i], inputs[i]); for (float value : outputs[count]) assertEquals(123f, value); } - assertEquals((long) shape[0] * shape[1] * Float.BYTES + // Batches of 4-32 rows ran on Highway, which packs one copy of the matrix. + assertEquals(2L * shape[0] * shape[1] * Float.BYTES + RamUsageEstimator.shallowSizeOfInstance(NativeASHProjection.class), projection.ramBytesUsed()); } } From 06610c034a98057571ce40df0fc579525f6a96e6 Mon Sep 17 00:00:00 2001 From: Ted Willke Date: Tue, 10 Nov 2026 15:53:51 -0800 Subject: [PATCH 214/243] Project INT8 ASH query tiles with a 256-bit VNNI kernel on non-AMX CPUs On Ice Lake, oneDNN's 8-row avx512_core_vnni matmul made the graph search that follows it 9-15% slower (same visited nodes, same prepared values, all of it search-thread CPU time), so batched INT8 queries lost to unbatched ones. Dense 512-bit integer dot products cause it: a zmm kernel with 4 or 8 rows per pass did the same, load-bound 1- and 2-row zmm passes did not, and the 8-row kernel on 256-bit registers did not while being the fastest of all. On AVX-512 VNNI CPUs without AMX, tiles of 2 to 8 rows now use this exact-integer ymm kernel; single rows keep oneDNN, whose 1-row kernel is faster and harmless. u8*s8 sums are exact in int32, so outputs are unchanged. AMX and AVX2-VNNI CPUs keep oneDNN for everything; JVECTOR_ASH_INT8_ONEDNN forces it for diagnosis. --- .../src/main/onednn/src/jvector_ash_int8.cpp | 120 ++++++++++++++++++ .../vector/NativeASHInt8ProjectionTest.java | 23 ++++ 2 files changed, 143 insertions(+) diff --git a/jvector-native/src/main/onednn/src/jvector_ash_int8.cpp b/jvector-native/src/main/onednn/src/jvector_ash_int8.cpp index 5d689d140..acfbdd2fc 100644 --- a/jvector-native/src/main/onednn/src/jvector_ash_int8.cpp +++ b/jvector-native/src/main/onednn/src/jvector_ash_int8.cpp @@ -23,6 +23,7 @@ #include #include #include +#include #include #include #include @@ -30,6 +31,9 @@ #include #include #include +#if defined(__x86_64__) || defined(_M_X64) +#include +#endif static_assert(DNNL_CPU_RUNTIME == DNNL_RUNTIME_SEQ, "Calling workers own parallelism"); static_assert(offsetof(jvector_ash_fp16_result, message) == sizeof(int32_t)); @@ -136,6 +140,82 @@ struct Kernel { } }; +// On Ice Lake, dense 512-bit integer dot products (oneDNN's 4- and 8-row +// avx512_core_vnni matmul, or an equivalent zmm kernel) slowed the graph search +// that follows them by 9-15%, so batched INT8 queries lost to unbatched ones. +// Load-bound 1- and 2-row zmm kernels did not, and neither does the same 8-row +// kernel on 256-bit registers, which is also the fastest. The prepared values, +// the visited nodes and the search thread's CPU time were otherwise unchanged. +// Query tiles are at most 8 rows, so on AVX-512 VNNI CPUs without AMX this +// exact-integer ymm kernel serves every tile of 2-8 rows: u8*s8 sums in int32 +// are exact, so outputs equal oneDNN's. AMX and AVX2-VNNI CPUs keep oneDNN, as does +// JVECTOR_ASH_INT8_ONEDNN (a diagnostic override) when set. +bool direct_vnni_available() { +#if defined(__x86_64__) || defined(_M_X64) + if (std::getenv("JVECTOR_ASH_INT8_ONEDNN")) return false; // Diagnostic: force oneDNN. + switch (get_effective_cpu_isa()) { + case cpu_isa::avx512_core_vnni: + case cpu_isa::avx512_core_bf16: return true; + default: return false; + } +#else + return false; +#endif +} + +#if defined(__x86_64__) || defined(_M_X64) +// NB blocks of 16 output rows (two 8-lane halves each) for every query row of +// the tile: 2*M*NB independent int32 accumulators hide the dot-product latency. +// Missing final blocks recompute block 0 and are not stored. +template +__attribute__((target("avx512f,avx512vl,avx512vnni"))) +void vnni_tile(const uint8_t* query, int stride, const int8_t* packed, int groups, int blocks, int32_t* out, int out_stride) { + for (int block = 0; block < blocks; block += NB) { + const int8_t* w[NB]; + for (int j = 0; j < NB; ++j) + w[j] = packed + size_t(block + j < blocks ? block + j : block) * groups * 64; + __m256i lo[M][NB], hi[M][NB]; + for (int i = 0; i < M; ++i) + for (int j = 0; j < NB; ++j) lo[i][j] = hi[i][j] = _mm256_setzero_si256(); + for (int g = 0; g < groups; ++g) { + __m256i b0[NB], b1[NB]; + for (int j = 0; j < NB; ++j) { + b0[j] = _mm256_loadu_si256(reinterpret_cast(w[j] + size_t(g) * 64)); + b1[j] = _mm256_loadu_si256(reinterpret_cast(w[j] + size_t(g) * 64 + 32)); + } + for (int i = 0; i < M; ++i) { + int32_t four; + std::memcpy(&four, query + size_t(i) * stride + size_t(g) * 4, sizeof(four)); + __m256i a = _mm256_set1_epi32(four); + for (int j = 0; j < NB; ++j) { + lo[i][j] = _mm256_dpbusd_epi32(lo[i][j], a, b0[j]); + hi[i][j] = _mm256_dpbusd_epi32(hi[i][j], a, b1[j]); + } + } + } + for (int i = 0; i < M; ++i) + for (int j = 0; j < NB && block + j < blocks; ++j) { + auto* row = out + size_t(i) * out_stride + size_t(block + j) * 16; + _mm256_storeu_si256(reinterpret_cast<__m256i*>(row), lo[i][j]); + _mm256_storeu_si256(reinterpret_cast<__m256i*>(row + 8), hi[i][j]); + } + } +} + +void vnni_gemm(int count, const uint8_t* query, int stride, const int8_t* packed, int groups, int blocks, + int32_t* out, int out_stride) { + switch (count) { + case 2: vnni_tile<2, 2>(query, stride, packed, groups, blocks, out, out_stride); break; + case 3: vnni_tile<3, 2>(query, stride, packed, groups, blocks, out, out_stride); break; + case 4: vnni_tile<4, 2>(query, stride, packed, groups, blocks, out, out_stride); break; + case 5: vnni_tile<5, 1>(query, stride, packed, groups, blocks, out, out_stride); break; + case 6: vnni_tile<6, 1>(query, stride, packed, groups, blocks, out, out_stride); break; + case 7: vnni_tile<7, 1>(query, stride, packed, groups, blocks, out, out_stride); break; + default: vnni_tile<8, 1>(query, stride, packed, groups, blocks, out, out_stride); break; + } +} +#endif + struct Context { const uint64_t id = next_id.fetch_add(1, std::memory_order_relaxed); const int rows, columns; @@ -149,6 +229,10 @@ struct Context { std::array, kBatchTile + 1> ready{}; std::mutex mutex; std::atomic retained_bytes{0}; + // Set when the ymm VNNI kernel replaces oneDNN for tiles of 2-8 rows (see vnni_tile). + const bool direct = direct_vnni_available(); + const int padded_columns = (columns + 3) & ~3, padded_rows = (rows + 15) & ~15; + std::unique_ptr packed; Context(const float* matrix, int r, int c) : rows(r), columns(c), scales(r), sums(r) { for (auto& entry : ready) entry.store(nullptr, std::memory_order_relaxed); @@ -160,6 +244,18 @@ struct Context { target + size_t(row) * columns, 0, &sums[row]); retained_bytes.store(descriptor.get_size() + sizeof(Context) + size_t(rows) * (sizeof(float) + sizeof(int32_t)), std::memory_order_relaxed); + if (direct) { + // [row block of 16][column group of 4][16 rows][4 columns], zero padded. + size_t bytes = size_t(padded_rows) * padded_columns; + packed.reset(new int8_t[bytes]()); + for (int row = 0; row < rows; ++row) + for (int column = 0; column < columns; ++column) + packed[(size_t(row / 16) * (padded_columns / 4) + column / 4) * 64 + + (row % 16) * 4 + column % 4] = target[size_t(row) * columns + column]; + retained_bytes.fetch_add(bytes, std::memory_order_relaxed); + } + // Single rows always use oneDNN: its 1-row kernel is load-bound, does not + // slow the following search, and is faster than vnni_tile<1>. kernel(1); // Check required hardware/primitive during model initialization. } @@ -246,6 +342,30 @@ JV_ASH_INT8_TARGET int jvector_ash_int8_project_batch(void* pointer, const float throw std::invalid_argument("INT8 batch size overflow"); for (int first = 0; first < count;) { int size = std::min(kBatchTile, count - first); +#if defined(__x86_64__) || defined(_M_X64) + if (context.direct && size > 1) { + thread_local std::vector quantized; + thread_local std::vector dots; + thread_local std::array scales; + size_t qbytes = size_t(kBatchTile) * context.padded_columns; + size_t dbytes = size_t(kBatchTile) * context.padded_rows; + // Padding columns meet zero weights, so stale bytes there add nothing. + if (quantized.size() < qbytes) quantized.assign(qbytes, 0); + if (dots.size() < dbytes) dots.resize(dbytes); + for (int i = 0; i < size; ++i) + scales[i] = quantize(input + size_t(first + i) * context.columns, context.columns, + quantized.data() + size_t(i) * context.padded_columns, 128); + vnni_gemm(size, quantized.data(), context.padded_columns, context.packed.get(), + context.padded_columns / 4, context.padded_rows / 16, dots.data(), context.padded_rows); + for (int i = 0; i < size; ++i) + for (int row = 0; row < context.rows; ++row) { + int32_t dot = dots[size_t(i) * context.padded_rows + row] - 128 * context.sums[row]; + output[size_t(first + i) * context.rows + row] = float(dot) * scales[i] * context.scales[row]; + } + first += size; + continue; + } +#endif const auto& kernel = context.kernel(size); auto& buffers = workspaces[size]; if (buffers.context_id != context.id) buffers.initialize(context, kernel); diff --git a/jvector-native/src/test/java/io/github/jbellis/jvector/vector/NativeASHInt8ProjectionTest.java b/jvector-native/src/test/java/io/github/jbellis/jvector/vector/NativeASHInt8ProjectionTest.java index 71780c480..2679bfa48 100644 --- a/jvector-native/src/test/java/io/github/jbellis/jvector/vector/NativeASHInt8ProjectionTest.java +++ b/jvector-native/src/test/java/io/github/jbellis/jvector/vector/NativeASHInt8ProjectionTest.java @@ -156,6 +156,29 @@ void fullRangeProductsDoNotSaturateThroughAccumulatorDimensionBound() { assertThrows(IllegalArgumentException.class, () -> prepare(new float[32][65536])); } + @Test + void everyTileSizeMatchesSingletonsThroughAccumulatorDimensionBound() { + // Tiles of 2-8 rows may use a different integer kernel than single rows; + // exact int32 sums must make every tile size agree bit for bit. + for (int columns : new int[]{3, 1023, 65535}) { + float[][] matrix = matrix(47, columns, columns); + for (int column = 0; column < columns; column++) matrix[1][column] = column % 2 == 0 ? 1f : -1f; + var projection = prepare(matrix); + float[][] inputs = matrix(8, columns, columns + 1); + Arrays.fill(inputs[7], 1f); // Saturated codes against the alternating row. + for (int count = 1; count <= 8; count++) { + float[][] outputs = new float[count][47]; + projection.projectBatch(inputs, outputs, count); + for (int i = 0; i < count; i++) { + float[] singleton = new float[47]; + projection.project(inputs[i], singleton); + assertArrayEquals(singleton, outputs[i], "columns=" + columns + " count=" + count + " row=" + i); + if (columns < 65535) assertArrayEquals(oracle(matrix, inputs[i]), outputs[i], 1e-7f); + } + } + } + } + @Test void fp32ScalesMatchPrototypeAndUnderflowFailsBeforeConversion() { for (float weight : new float[]{0, Float.MIN_NORMAL, 1e-38f, 1f}) { From c9261a6198a805a69bdd230ff9cbd5612ddabf0c Mon Sep 17 00:00:00 2001 From: Ted Willke Date: Tue, 10 Nov 2026 23:30:00 -0800 Subject: [PATCH 215/243] Tighten the ASH neighborhood kernel helpers per review AshLookup and AshPromoteHalf take the concrete u32 and u16 vector types for their tag and return VFromD. The half-float infinity/NaN repair is limited to x86 SSE targets, since NEON converts those values correctly. Big-endian header halves are swapped with ReverseLaneBytes, the score is finished with two fused multiply-adds, and the 4-bit projection dot uses its register lookup on every AVX-512 target. --- .../main/native/src/ash_neighborhood-inl.h | 24 ++++++++++++------- 1 file changed, 15 insertions(+), 9 deletions(-) diff --git a/jvector-native/src/main/native/src/ash_neighborhood-inl.h b/jvector-native/src/main/native/src/ash_neighborhood-inl.h index 24805e948..81ee317e5 100644 --- a/jvector-native/src/main/native/src/ash_neighborhood-inl.h +++ b/jvector-native/src/main/native/src/ash_neighborhood-inl.h @@ -25,7 +25,8 @@ constexpr int kAshInvalidLandmark = 1; constexpr int kAshInvalidShape = 2; constexpr size_t kAshHeaderBytesPerLane = 5; -template HWY_INLINE auto AshLookup(D d, const float *lut, I indices) { +template +HWY_INLINE hn::VFromD AshLookup(D d, const float *lut, hn::VFromD> indices) { const hn::Rebind di; #if !HWY_HAVE_SCALABLE // On SVE, MaxLanes is only an upper bound on the runtime vector length. @@ -39,12 +40,15 @@ template HWY_INLINE auto AshLookup(D d, const float *lut, I i } } -template HWY_INLINE auto AshPromoteHalf(D d, V bits) { +template +HWY_INLINE hn::VFromD AshPromoteHalf(D d, hn::VFromD> bits) { const hn::Rebind df16; auto value = hn::PromoteTo(d, hn::BitCast(df16, bits)); +#if HWY_TARGET <= HWY_SSE4 if constexpr (hn::MaxLanes(d) < 8) { // Highway's SSE software conversion assumes finite half values. Repair - // exponent 31 explicitly; AVX2/AVX-512 use hardware F16 conversion. + // exponent 31 explicitly; AVX2/AVX-512 use hardware F16 conversion and + // NEON converts infinities and NaNs correctly. const hn::Rebind du; const auto wide = hn::PromoteTo(du, bits); const auto exponent = hn::And(wide, hn::Set(du, 0x7c00)); @@ -54,6 +58,7 @@ template HWY_INLINE auto AshPromoteHalf(D d, V bits) { const auto ieee = hn::BitCast(d, hn::Or(hn::Or(sign, mantissa), hn::Set(du, 0x7f800000))); value = hn::IfThenElse(hn::RebindMask(d, special), ieee, value); } +#endif return value; } @@ -70,12 +75,13 @@ HWY_INLINE int AshFinish(D d, const unsigned char *header, int blockSize, int la // Disk f16 values are big-endian; Highway's x86 loads interpret native byte order. auto scaleBits = hn::LoadN(dh, reinterpret_cast(header) + lane, count); auto offsetBits = hn::LoadN(dh, reinterpret_cast(header + 2 * blockSize) + lane, count); - scaleBits = hn::Or(hn::ShiftLeft<8>(scaleBits), hn::ShiftRight<8>(scaleBits)); - offsetBits = hn::Or(hn::ShiftLeft<8>(offsetBits), hn::ShiftRight<8>(offsetBits)); + scaleBits = hn::ReverseLaneBytes(scaleBits); + offsetBits = hn::ReverseLaneBytes(offsetBits); const auto scale = AshPromoteHalf(d, scaleBits); const auto offset = AshPromoteHalf(d, offsetBits); - const auto raw = hn::Add(hn::Add(hn::Mul(scale, dot), hn::Set(d, landmarkDot)), offset); - const auto similarity = hn::Mul(hn::Add(hn::Set(d, 1.0f), raw), hn::Set(d, 0.5f)); + const auto raw = hn::Add(hn::MulAdd(scale, dot, hn::Set(d, landmarkDot)), offset); + const auto half = hn::Set(d, 0.5f); + const auto similarity = hn::MulAdd(raw, half, half); const auto result = hn::IfThenElse(hn::IsNaN(similarity), similarity, hn::Max(hn::Zero(d), similarity)); hn::StoreN(result, d, out, count); return kAshSuccess; @@ -215,7 +221,7 @@ float ash_projection_dot_4bit_f32(const float *HWY_RESTRICT query, const hn::Rebind du; const size_t lanes = hn::Lanes(d); const size_t count = static_cast(dimensions); -#if HWY_TARGET == HWY_AVX3 || HWY_TARGET == HWY_AVX3_DL || HWY_TARGET == HWY_AVX3_SPR +#if HWY_TARGET <= HWY_AVX3 // Exact signed-magnitude levels: keep them in one register instead of // converting magnitudes and constructing sign masks for every vector. alignas(64) static constexpr float levels[16] = { @@ -232,7 +238,7 @@ float ash_projection_dot_4bit_f32(const float *HWY_RESTRICT query, for (; i + 2 * lanes <= count; i += 2 * lanes) { const auto packed = hn::PromoteTo(du, hn::LoadU(db, code + i / 2)); const auto hiCode = hn::ShiftRight<4>(packed); -#if HWY_TARGET == HWY_AVX3 || HWY_TARGET == HWY_AVX3_DL || HWY_TARGET == HWY_AVX3_SPR +#if HWY_TARGET <= HWY_AVX3 // VPERMPS uses only the low four index bits. Use the intrinsic to avoid // a redundant mask and Highway IndicesFromVec debug checks on upper bits. const auto loValue = hn::VFromD{_mm512_permutexvar_ps(packed.raw, table.raw)}; From b9835df78105d5830b77f5caf2311146ddf9de82 Mon Sep 17 00:00:00 2001 From: Ted Willke Date: Wed, 11 Nov 2026 00:38:44 -0800 Subject: [PATCH 216/243] Drop scalar-target ASH kernel paths and guard 16-lane permutes for AVX-512 The native library builds only SSE4 and wider x86 targets and NEON or SVE on Arm, so the HWY_SCALAR branches of the symmetric 2- and 4-bit kernels never compiled. The register-wide 16-lane table permutes in the symmetric LUT and neighborhood kernels are AVX-512 instructions, so they are now guarded by HWY_TARGET <= HWY_AVX3 instead of !HWY_HAVE_SCALABLE. --- .../src/main/native/src/ash_neighborhood-inl.h | 6 +++--- .../src/main/native/src/ash_symmetric-inl.h | 17 ++--------------- 2 files changed, 5 insertions(+), 18 deletions(-) diff --git a/jvector-native/src/main/native/src/ash_neighborhood-inl.h b/jvector-native/src/main/native/src/ash_neighborhood-inl.h index 81ee317e5..a3b87c744 100644 --- a/jvector-native/src/main/native/src/ash_neighborhood-inl.h +++ b/jvector-native/src/main/native/src/ash_neighborhood-inl.h @@ -28,8 +28,8 @@ constexpr size_t kAshHeaderBytesPerLane = 5; template HWY_INLINE hn::VFromD AshLookup(D d, const float *lut, hn::VFromD> indices) { const hn::Rebind di; -#if !HWY_HAVE_SCALABLE - // On SVE, MaxLanes is only an upper bound on the runtime vector length. +#if HWY_TARGET <= HWY_AVX3 + // A register-wide 16-float permute is an AVX-512 instruction. if constexpr (hn::MaxLanes(d) == 16) { return hn::TableLookupLanes(hn::LoadU(d, lut), hn::IndicesFromVec(d, hn::BitCast(di, indices))); } else @@ -176,7 +176,7 @@ int ash_score_neighborhood_c1_f32(const unsigned char *HWY_RESTRICT codes, const // Widen traversal arithmetic so the final base increment cannot overflow int. for (size_t base = 0; base < static_cast(capacity); base += blockSize, codes += blockBytes) { const int count = std::min(blockSize, capacity - static_cast(base)); -#if !HWY_HAVE_SCALABLE +#if HWY_TARGET <= HWY_AVX3 if constexpr (hn::MaxLanes(d) == 16) { if (blockSize == 32 && count == 32) { int status = AshBlock32(d, codes, lut, groups, landmarkDot, out + base); diff --git a/jvector-native/src/main/native/src/ash_symmetric-inl.h b/jvector-native/src/main/native/src/ash_symmetric-inl.h index b647cb72e..9d97e8bd9 100644 --- a/jvector-native/src/main/native/src/ash_symmetric-inl.h +++ b/jvector-native/src/main/native/src/ash_symmetric-inl.h @@ -46,12 +46,6 @@ HWY_INLINE float AshBinaryPair(const uint64_t *a, const uint64_t *b, int dimensi } HWY_INLINE float AshTwoBitPair(const uint8_t *a, const uint8_t *b, int dimensions) { -#if HWY_TARGET == HWY_SCALAR - int64_t total = 0; - for (int j = 0; j < dimensions; ++j) - total += AshDoubledComponent(a, j, 2) * AshDoubledComponent(b, j, 2); - return static_cast(total) * 0.25f; -#else const hn::ScalableTag d; const auto slots = hn::Set(d, uint64_t{0x5555555555555555}); const size_t lanes = hn::Lanes(d), words = static_cast(dimensions) / 32; @@ -97,10 +91,8 @@ HWY_INLINE float AshTwoBitPair(const uint8_t *a, const uint8_t *b, int dimension total += tail + delta; } return static_cast(total) * 0.25f; -#endif } -#if HWY_TARGET != HWY_SCALAR // The last partial vector of 4-bit codes, n bytes. Kept out of line so the full-vector loop // compiles as before. LoadN zero-fills the lanes past the code; a zero byte decodes to two // (+1)*(+1) products, which are subtracted. @@ -120,12 +112,9 @@ HWY_NOINLINE int64_t AshFourBitPartial(const uint8_t *a, const uint8_t *b, size_ } return hn::ReduceSum(di, sum) - 2 * static_cast(hn::Lanes(db) - n); } -#endif HWY_INLINE float AshFourBitPair(const uint8_t *a, const uint8_t *b, int dimensions) { int64_t total = 0; - int j = 0; -#if HWY_TARGET != HWY_SCALAR const hn::ScalableTag di; const hn::Repartition db; const hn::RebindToSigned ds; @@ -149,9 +138,7 @@ HWY_INLINE float AshFourBitPair(const uint8_t *a, const uint8_t *b, int dimensio total += AshFourBitPartial(a + offset, b + offset, bytes - offset); offset = bytes; } - j = static_cast(2 * offset); -#endif - for (; j < dimensions; ++j) total += AshDoubledComponent(a, j, 4) * AshDoubledComponent(b, j, 4); + for (int j = static_cast(2 * offset); j < dimensions; ++j) total += AshDoubledComponent(a, j, 4) * AshDoubledComponent(b, j, 4); return static_cast(total) * 0.25f; } } // namespace @@ -181,7 +168,7 @@ void ash_symmetric_lut_i16(const uint8_t *HWY_RESTRICT codes, const int16_t *HWY for (int g = first; g < end; ++g) { auto packed = hn::PromoteTo(d, hn::LoadN(db, codes + static_cast(g / 2) * stride + lane + base, active)); const auto indices = hn::And(hn::ShiftRightSame(packed, 4 * (g & 1)), hn::Set(d, 15)); -#if !HWY_HAVE_SCALABLE +#if HWY_TARGET <= HWY_AVX3 if constexpr (hn::MaxLanes(d) == 16) { const auto table = hn::PromoteTo(d, hn::LoadU(ds, lut + static_cast(g) * 32)); sum = hn::Add(sum, hn::TableLookupLanes(table, hn::IndicesFromVec(d, indices))); From 415b00957b8abd0e650eb7f7fbfa9887afdfc6e7 Mon Sep 17 00:00:00 2001 From: Ted Willke Date: Wed, 11 Nov 2026 03:46:34 -0800 Subject: [PATCH 217/243] Use unmasked loads for full vectors and one accumulator per ASH code pair The 1- and 2-bit pair kernels now load full vectors with LoadU and mask only the last partial vector. The 4-bit kernel keeps one int32 accumulator across the loop and reduces once per pair. --- .../src/main/native/src/ash_symmetric-inl.h | 68 ++++++++++++------- 1 file changed, 42 insertions(+), 26 deletions(-) diff --git a/jvector-native/src/main/native/src/ash_symmetric-inl.h b/jvector-native/src/main/native/src/ash_symmetric-inl.h index 9d97e8bd9..7c04324fd 100644 --- a/jvector-native/src/main/native/src/ash_symmetric-inl.h +++ b/jvector-native/src/main/native/src/ash_symmetric-inl.h @@ -34,10 +34,14 @@ HWY_INLINE float AshBinaryPair(const uint64_t *a, const uint64_t *b, int dimensi const size_t lanes = hn::Lanes(d), words = static_cast(dimensions) / 64; auto counts = hn::Zero(d); // Integer lanes can hold all mismatches even at the maximum Java dimension. - for (size_t word = 0; word < words; word += lanes) { - const size_t n = std::min(lanes, words - word); - const auto diff = hn::Xor(hn::LoadN(d, a + word, n), hn::LoadN(d, b + word, n)); - counts = hn::Add(counts, hn::PopulationCount(diff)); + // Full vectors use plain loads; only the last partial vector is masked. + size_t word = 0; + for (; word + lanes <= words; word += lanes) { + counts = hn::Add(counts, hn::PopulationCount(hn::Xor(hn::LoadU(d, a + word), hn::LoadU(d, b + word)))); + } + if (word < words) { + const size_t n = words - word; + counts = hn::Add(counts, hn::PopulationCount(hn::Xor(hn::LoadN(d, a + word, n), hn::LoadN(d, b + word, n)))); } uint64_t mismatches = hn::ReduceSum(d, counts); const int tail = dimensions & 63; @@ -45,30 +49,40 @@ HWY_INLINE float AshBinaryPair(const uint64_t *a, const uint64_t *b, int dimensi return static_cast(dimensions) - 2.0f * static_cast(mismatches); } +// sign*(1+2*ma)*(1+2*mb) for each 2-bit field pair in av and bv, less one per field, evaluated +// with weighted popcounts and summed per 64-bit lane. +template +HWY_INLINE hn::VFromD> AshTwoBitDelta(D d, hn::VFromD av, hn::VFromD bv) { + const auto slots = hn::Set(d, uint64_t{0x5555555555555555}); + const auto neg = hn::And(hn::ShiftRight<1>(hn::Xor(av, bv)), slots); + const auto ma = hn::And(av, slots), mb = hn::And(bv, slots), both = hn::And(ma, mb); + auto delta = hn::ShiftLeft<1>(hn::Sub( + hn::Add(hn::PopulationCount(ma), hn::PopulationCount(mb)), hn::PopulationCount(neg))); + delta = hn::Sub(delta, hn::ShiftLeft<2>(hn::Add( + hn::PopulationCount(hn::And(ma, neg)), hn::PopulationCount(hn::And(mb, neg))))); + delta = hn::Add(delta, hn::ShiftLeft<2>(hn::PopulationCount(both))); + delta = hn::Sub(delta, hn::ShiftLeft<3>(hn::PopulationCount(hn::And(both, neg)))); + return hn::BitCast(hn::RebindToSigned(), delta); +} + HWY_INLINE float AshTwoBitPair(const uint8_t *a, const uint8_t *b, int dimensions) { const hn::ScalableTag d; - const auto slots = hn::Set(d, uint64_t{0x5555555555555555}); const size_t lanes = hn::Lanes(d), words = static_cast(dimensions) / 32; - const hn::RebindToSigned di; - auto deltas = hn::Zero(di); - for (size_t word = 0; word < words; word += lanes) { - const size_t n = std::min(lanes, words - word); - // Canonical codes are little-endian packed fields. LoadU/LoadN permit - // byte alignment on all supported little-endian production targets. - const auto av = hn::LoadN(d, reinterpret_cast(a) + word, n); - const auto bv = hn::LoadN(d, reinterpret_cast(b) + word, n); - const auto neg = hn::And(hn::ShiftRight<1>(hn::Xor(av, bv)), slots); - const auto ma = hn::And(av, slots), mb = hn::And(bv, slots), both = hn::And(ma, mb); - // sign*(1+2*ma)*(1+2*mb), evaluated with weighted popcounts. - // Keep partial sums in registers; one horizontal reduction per pair. - auto delta = hn::ShiftLeft<1>(hn::Sub( - hn::Add(hn::PopulationCount(ma), hn::PopulationCount(mb)), hn::PopulationCount(neg))); - delta = hn::Sub(delta, hn::ShiftLeft<2>(hn::Add( - hn::PopulationCount(hn::And(ma, neg)), hn::PopulationCount(hn::And(mb, neg))))); - delta = hn::Add(delta, hn::ShiftLeft<2>(hn::PopulationCount(both))); - delta = hn::Sub(delta, hn::ShiftLeft<3>(hn::PopulationCount(hn::And(both, neg)))); - deltas = hn::Add(deltas, hn::BitCast(di, delta)); + // Canonical codes are little-endian packed fields. LoadU/LoadN permit + // byte alignment on all supported little-endian production targets. + const auto *wa = reinterpret_cast(a), *wb = reinterpret_cast(b); + // Keep partial sums in registers; one horizontal reduction per pair. Full vectors use + // plain loads; only the last partial vector is masked. + auto deltas = hn::Zero(hn::RebindToSigned()); + size_t word = 0; + for (; word + lanes <= words; word += lanes) { + deltas = hn::Add(deltas, AshTwoBitDelta(d, hn::LoadU(d, wa + word), hn::LoadU(d, wb + word))); + } + if (word < words) { + const size_t n = words - word; + deltas = hn::Add(deltas, AshTwoBitDelta(d, hn::LoadN(d, wa + word, n), hn::LoadN(d, wb + word, n))); } + const hn::RebindToSigned di; int64_t total = static_cast(words) * 32 + hn::ReduceSum(di, deltas); // The last partial word: copy only its code bytes and clear the padding fields, which then // contribute nothing. One word of popcounts instead of one decode per leftover dimension. @@ -119,10 +133,12 @@ HWY_INLINE float AshFourBitPair(const uint8_t *a, const uint8_t *b, int dimensio const hn::Repartition db; const hn::RebindToSigned ds; const size_t bytes = static_cast(dimensions) / 2, lanes = hn::Lanes(db); + // One accumulator for the whole pair. Each pass adds at most 8 * 15 * 15 = 1800 per int32 + // lane, so a lane overflows only past about a million vectors, far beyond any Java code. + auto sum = hn::Zero(di); size_t offset = 0; for (; offset + lanes <= bytes; offset += lanes) { const auto av = hn::LoadU(db, a + offset), bv = hn::LoadU(db, b + offset); - auto sum = hn::Zero(di); for (int shift = 0; shift < 8; shift += 4) { const auto ax = hn::ShiftRightSame(av, shift), bx = hn::ShiftRightSame(bv, shift); const auto am = hn::Add(hn::ShiftLeft<1>(hn::And(ax, hn::Set(db, 7))), hn::Set(db, 1)); @@ -132,8 +148,8 @@ HWY_INLINE float AshFourBitPair(const uint8_t *a, const uint8_t *b, int dimensio // VNNI when supported; Highway supplies widening multiply otherwise. sum = hn::SumOfMulQuadAccumulate(di, am, signedB, sum); } - total += hn::ReduceSum(di, sum); } + total += hn::ReduceSum(di, sum); if (offset < bytes) { total += AshFourBitPartial(a + offset, b + offset, bytes - offset); offset = bytes; From 9ac0457096f94c8e12f36fadb93fb76463196afd Mon Sep 17 00:00:00 2001 From: Ted Willke Date: Wed, 11 Nov 2026 08:10:19 -0800 Subject: [PATCH 218/243] Let DistancesASH load public datasets by name and score every ASH bit width DistancesASH now takes a dataset name from the public catalog (default e5-small-v2-100k), or base, query and ground-truth files, instead of hard-coded local paths. It accepts 1 to 9 bits per dimension for asymmetric scoring and recall; widths other than 1, 2 and 4 use the portable scorers. --- .../jbellis/jvector/example/DistancesASH.java | 183 ++++++------------ 1 file changed, 57 insertions(+), 126 deletions(-) diff --git a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/DistancesASH.java b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/DistancesASH.java index 128ca5214..bacdc4b68 100644 --- a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/DistancesASH.java +++ b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/DistancesASH.java @@ -16,6 +16,8 @@ package io.github.jbellis.jvector.example; +import io.github.jbellis.jvector.example.benchmarks.datasets.DataSet; +import io.github.jbellis.jvector.example.benchmarks.datasets.DataSets; import io.github.jbellis.jvector.example.util.SiftLoader; import io.github.jbellis.jvector.graph.ListRandomAccessVectorValues; import io.github.jbellis.jvector.graph.similarity.ScoreFunction; @@ -35,8 +37,26 @@ import java.util.concurrent.ForkJoinPool; import java.util.concurrent.ForkJoinTask; +/** + * ASH scoring benchmark. Pass a dataset name from the public dataset catalog (default + * {@value #DEFAULT_DATASET}; it is downloaded and cached on first use), or base, query and + * ground-truth files ({@code .fvecs}, {@code .fvecs}, {@code .ivecs}). ASH scores inner products, so the + * dataset should use dot-product similarity, or cosine similarity with normalized vectors. + * + *

    The compressor is set by {@code jvector.ash.bitsPerDimension} (1 to 9, default 4), + * {@code jvector.ash.quantizedDimensions} (default half the input dimension), + * {@code jvector.ash.landmarkCount} (default 1) and {@code jvector.ash.optimizer} ({@code random} or + * {@code itq}). Every width uses the production scorers: 1, 2 and 4 bits have SIMD kernels, and the + * other widths use the portable scalar scorers. Measurements are selected with {@code jvector.bench.} + * properties: {@code recall} (exhaustive recall against the ground truth; add + * {@code recall.block-scoring} to rank with block scoring), {@code single-scoring} and + * {@code block-scoring} (throughput), {@code symmetric-scoring} (code-to-code scoring), + * {@code maxQueries}, {@code recall.k} and {@code scoringThreads}.

    + */ public class DistancesASH { + static final String DEFAULT_DATASET = "e5-small-v2-100k"; + static final String blockMode = System.getProperty("jvector.ash.blockKernel", "auto").toLowerCase(); static final String singleMode = System.getProperty("jvector.ash.singleKernel", "auto").toLowerCase(); @@ -257,7 +277,7 @@ void fill(int start, int count, float[] out) { /** Only query encoding is excluded. Every timed query creates its actual scoring state. */ private static void benchmarkSymmetric(ASHVectors vectors, List> queries, - List> groundTruth, int[] newToOld, + List> groundTruth, int[] newToOld, int recallK, boolean recall, ForkJoinPool executor) { var ash = vectors.getCompressor(); int passes = Integer.getInteger("jvector.bench.scoringPasses", 5); @@ -423,7 +443,7 @@ private static double scanWithSetup(java.util.function.IntFunction f /** Paired projection comparison; optional recall uses the normal full-scan scorers. */ private static void benchmarkInt8Projection(ASHVectors encoded, List> queries, - List> truth, int[] newToOld, ForkJoinPool executor) throws IOException { + List> truth, int[] newToOld, ForkJoinPool executor) throws IOException { var outputDir=java.nio.file.Path.of(System.getProperty("jvector.bench.outputDir", ".")); java.nio.file.Files.createDirectories(outputDir); var model=outputDir.resolve("model-b"+encoded.getCompressor().bitsPerDimension+".ash"); @@ -546,7 +566,7 @@ private static void benchmarkInt8Projection(ASHVectors encoded, List> queries, - List> truth,int[] newToOld,int count,ForkJoinPool executor) { + List> truth,int[] newToOld,int count,ForkJoinPool executor) { var tasks=new ArrayList>(); for(int worker=0;worker> vectors, List> allQueries, + List> groundTruth) throws IOException { // ------------------------------------------------------------ // Benchmark configuration (runtime flags) // ------------------------------------------------------------ @@ -619,20 +651,11 @@ public static void testASHEncodings(String filenameBase, String filenameQueries, // How many ASH landmarks to use, C = [1, 256] final int landmarkCount = Integer.getInteger("jvector.ash.landmarkCount", 1); - List> vectors = SiftLoader.readFvecs(filenameBase); - List> allQueries = SiftLoader.readFvecs(filenameQueries); int maxQueries = Integer.getInteger("jvector.bench.maxQueries", allQueries.size()); if (maxQueries <= 0) throw new IllegalArgumentException("maxQueries must be positive"); List> queries = allQueries.subList(0, Math.min(maxQueries, allQueries.size())); - final List> groundTruth; - if (RUN_RECALL_CHECK) { - if (filenameGT == null || filenameGT.isBlank()) { - throw new IllegalArgumentException( - "Recall requires a ground-truth filename"); - } - groundTruth = SiftLoader.readIvecs(filenameGT); - } else { - groundTruth = List.of(); + if (RUN_RECALL_CHECK && groundTruth.size() < queries.size()) { + throw new IllegalArgumentException("Recall requires ground truth for every query"); } // ASH normalization policy: @@ -656,11 +679,8 @@ public static void testASHEncodings(String filenameBase, String filenameQueries, final int quantizedDimensions = Integer.getInteger("jvector.ash.quantizedDimensions", dimension / 2); - if (bitsPerDimension < 1 || bitsPerDimension > 9 - || (!Boolean.getBoolean("jvector.bench.symmetric-scoring") - && bitsPerDimension != 1 && bitsPerDimension != 2 && bitsPerDimension != 4)) { - throw new IllegalArgumentException( - "bitsPerDimension must be 1, 2, or 4 (symmetric comparison also supports widths 3 through 9): " + bitsPerDimension); + if (bitsPerDimension < 1 || bitsPerDimension > 9) { + throw new IllegalArgumentException("bitsPerDimension must be 1 through 9: " + bitsPerDimension); } if (quantizedDimensions <= 0 || quantizedDimensions > dimension) { @@ -906,8 +926,8 @@ public static void testASHEncodings(String filenameBase, String filenameQueries, } logProgress("\t[stage] Computing " + RECALL_K + "-Recall@K with " - + (COMPARE_ALL_RECALL ? "ASH LUT block, single-vector and scalar" - : RUN_BLOCK_RECALL ? "ASH LUT block" : "ASH single-vector") + " scoring..."); + + (COMPARE_ALL_RECALL ? "ASH block, single-vector and scalar" + : RUN_BLOCK_RECALL ? "ASH block" : "ASH single-vector") + " scoring..."); for (int start = 0; start < queries.size(); start += chunkSize) { final int s = start; final int e = Math.min(start + chunkSize, queries.size()); @@ -970,8 +990,8 @@ public static void testASHEncodings(String filenameBase, String filenameQueries, for (int aIdx = 0; aIdx < totalRecall.length; aIdx++) totalRecall[aIdx] += local[aIdx]; } - String[] scorerNames = COMPARE_ALL_RECALL ? new String[]{"LUT-block", "single", "scalar"} - : new String[]{RUN_BLOCK_RECALL ? "LUT-block" : "single"}; + String[] scorerNames = COMPARE_ALL_RECALL ? new String[]{"block", "single", "scalar"} + : new String[]{RUN_BLOCK_RECALL ? "block" : "single"}; for (int scorer = 0; scorer < scorerNames.length; scorer++) { for (int aIdx = 0; aIdx < atValues.length; aIdx++) { System.out.format(java.util.Locale.ROOT, "\tASH %s %d-recall@%d = %.6f%n", @@ -1172,98 +1192,8 @@ public static void testASHEncodings(String filenameBase, String filenameQueries, } } - public static void runSIFT() throws IOException { - System.out.println("Running siftsmall"); - - var baseVectors = "siftsmall/siftsmall_base.fvecs"; - var queryVectors = "siftsmall/siftsmall_query.fvecs"; - var gtVectors = ""; - testASHEncodings(baseVectors, queryVectors, gtVectors); - } - - public static void runGIST() throws IOException { - System.out.println("Running GIST"); - - var baseVectors = "./fvec/gist/gist_base.fvecs"; - var queryVectors = "./fvec/gist/gist_query.fvecs"; - var gtVectors = ""; - testASHEncodings(baseVectors, queryVectors, gtVectors); - } - - public static void runCohere100k() throws IOException { - System.out.println("Running Cohere-100k"); - - var baseVectors = "dataset_cache/public/cohere_english_v3_100k_base_99685.fvecs"; - var queryVectors = "dataset_cache/public/cohere_english_v3_100k_query_10000.fvecs"; - var gtVectors = "dataset_cache/public/cohere_english_v3_100k_gt_ip_100.ivecs"; - testASHEncodings(baseVectors, queryVectors, gtVectors); - } - - public static void runADA() throws IOException { - System.out.println("Running ada-002"); - - var baseVectors = "./fvec/ada-002/ada_002_100000_base_vectors.fvec"; - var queryVectors = "./fvec/ada-002/ada_002_100000_query_vectors_10000.fvec"; - var gtVectors = "./fvec/ada-002/ada_002_100000_indices_query_10000.ivec"; - testASHEncodings(baseVectors, queryVectors, gtVectors); - } - - public static void runADANoZeros() throws IOException { - System.out.println("Running ada-002-no-zeros"); - - var baseVectors = "./fvec/ada-002-no-zeros/ada_002_100000_base_vectors_no_zeros.fvec"; - var queryVectors = "./fvec/ada-002-no-zeros/ada_002_100000_query_vectors_10000_no_zeros.fvec"; - var gtVectors = "./fvec/ada-002-no-zeros/ada-002_gt_no_zeros.ivec"; - testASHEncodings(baseVectors, queryVectors, gtVectors); - } - - public static void runColbert() throws IOException { - System.out.println("Running colbertv2"); - - var baseVectors = "./fvec/wikipedia_squad/1M/colbertv2.0_128_base_vectors_1000000.fvec"; - var queryVectors = "./fvec/wikipedia_squad/1M/colbertv2.0_128_query_vectors_100000.fvec"; - var gtVectors = ""; - testASHEncodings(baseVectors, queryVectors, gtVectors); - } - - public static void runOpenai1536() throws IOException { - System.out.println("Running text-embedding-3-large_1536"); - - var baseVectors = "./fvec/openai-v3-large-1536-100k/openai_v3_large_1536_100k_base_98716.fvecs"; - var queryVectors = "./fvec/openai-v3-large-1536-100k/openai_v3_large_1536_100k_query_10000.fvecs"; - var gtVectors = "./fvec/openai-v3-large-1536-100k/openai_v3_large_1536_100k_gt_ip_100.ivecs"; - testASHEncodings(baseVectors, queryVectors, gtVectors); - } - - public static void runOpenai3072() throws IOException { - System.out.println("Running text-embedding-3-large_3072"); - - var baseVectors = "./fvec/openai-v3-large-3072-100k/text-embedding-3-large_3072_100000_base_vectors.fvec"; - var queryVectors = "./fvec/openai-v3-large-3072-100k/text-embedding-3-large_3072_100000_query_vectors_10000.fvec"; - var gtVectors = "./fvec/openai-v3-large-3072-100k/text-embedding-3-large_3072_100000_indices_query_10000.ivec"; - testASHEncodings(baseVectors, queryVectors, gtVectors); - } - - public static void runCap6m() throws IOException { - System.out.println("Running cap-6m"); - - var baseVectors = "./fvec/cap-6m/Caselaw_gte-Qwen2-1.5B_embeddings_base_6m_norm_shuffle.fvecs"; - var queryVectors = "./fvec/cap-6m/Caselaw_gte-Qwen2-1.5B_embeddings_query_10k_norm_shuffle.fvecs"; - var gtVectors = "./fvec/cap-6m/cap_6m_gt_norm_shuffle_ip_k100.ivecs"; - testASHEncodings(baseVectors, queryVectors, gtVectors); - } - - public static void runCohere10m() throws IOException { - System.out.println("Running cohere-10m"); - - var baseVectors = "./fvec/cohere-10m/cohere_wiki_en_flat_base_10m_norm.fvecs"; - var queryVectors = "./fvec/cohere-10m/cohere_wiki_en_flat_query_10k_norm.fvecs"; - var gtVectors = "./fvec/cohere-10m/cohere_wiki_en_flat_gt_10m_ip_k100.ivecs"; - testASHEncodings(baseVectors, queryVectors, gtVectors); - } - public static void main(String[] args) throws IOException { - // Dispatch before the legacy argument modes: saved fixtures need no positional inputs. + // Dispatch before the dataset modes: saved fixtures need no positional inputs. if (Boolean.getBoolean("jvector.bench.neighborhood-scoring")) { ASHNeighborhoodBenchmark.run(args); return; @@ -1272,18 +1202,19 @@ public static void main(String[] args) throws IOException { testASHEncodings(args[0], args[1], args[2]); return; } - if (args.length != 0) { - throw new IllegalArgumentException("Expected base.fvecs query.fvecs ground-truth.ivecs"); + if (args.length > 1) { + throw new IllegalArgumentException( + "Usage: DistancesASH [dataset-name] | "); + } + String name = args.length == 0 ? DEFAULT_DATASET : args[0]; + try (DataSet dataset = DataSets.loadDataSet(name) + .orElseThrow(() -> new IllegalArgumentException("Unknown dataset: " + name)).getDataSet()) { + var similarity = dataset.getSimilarityFunction(); + if (similarity != VectorSimilarityFunction.DOT_PRODUCT && similarity != VectorSimilarityFunction.COSINE) { + throw new IllegalArgumentException("ASH scores inner products; dataset " + name + " uses " + similarity); + } + System.out.println("Running " + dataset.getName() + ", similarity " + similarity); + testASHEncodings(dataset.getBaseVectors(), dataset.getQueryVectors(), dataset.getGroundTruth()); } -// runSIFT(); -// runGIST(); -// runColbert(); -// runCohere100k(); -// runADA(); -// runADANoZeros(); - runOpenai1536(); -// runOpenai3072(); -// runCap6m(); -// runCohere10m(); } } From 40544d2f87e17fc7dd1e8bca5cbb7e4392e9ca20 Mon Sep 17 00:00:00 2001 From: Mark Wolters Date: Wed, 11 Nov 2026 17:02:58 -0500 Subject: [PATCH 219/243] removing hard coded version from Feature (#797) --- .../graph/disk/AbstractGraphIndexFormat.java | 2 +- .../graph/disk/GraphIndexFormatV6.java | 2 +- .../jvector/graph/disk/feature/Feature.java | 19 +++ .../jvector/graph/disk/feature/FusedASH.java | 8 +- .../jvector/graph/disk/feature/FusedPQ.java | 8 +- .../graph/disk/feature/InlineVectors.java | 7 + .../jvector/graph/disk/feature/NVQ.java | 8 +- .../graph/disk/feature/SeparatedNVQ.java | 8 +- .../graph/disk/feature/SeparatedVectors.java | 7 + .../graph/disk/TestFeatureHeaderVersion.java | 121 ++++++++++++++++++ 10 files changed, 184 insertions(+), 6 deletions(-) create mode 100644 jvector-tests/src/test/java/io/github/jbellis/jvector/graph/disk/TestFeatureHeaderVersion.java diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/graph/disk/AbstractGraphIndexFormat.java b/jvector-base/src/main/java/io/github/jbellis/jvector/graph/disk/AbstractGraphIndexFormat.java index d43497944..12bb1d28a 100644 --- a/jvector-base/src/main/java/io/github/jbellis/jvector/graph/disk/AbstractGraphIndexFormat.java +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/graph/disk/AbstractGraphIndexFormat.java @@ -381,7 +381,7 @@ public int commonHeaderSize() { public void writeHeaderFeatures(IndexWriter out, Map features) throws IOException { // we restrict pre-version-3 writers to INLINE_VECTORS features, so we don't need additional version-handling here for (Feature writer : features.values()) { - writer.writeHeader(out); + writer.writeHeader(out, getVersion()); } } diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/graph/disk/GraphIndexFormatV6.java b/jvector-base/src/main/java/io/github/jbellis/jvector/graph/disk/GraphIndexFormatV6.java index 6c97cd73d..a9a46ed5c 100644 --- a/jvector-base/src/main/java/io/github/jbellis/jvector/graph/disk/GraphIndexFormatV6.java +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/graph/disk/GraphIndexFormatV6.java @@ -118,7 +118,7 @@ public void writeHeaderFeatures(IndexWriter out, Map { + nvq.write(out, v); + out.writeLong(1234L); + }), + new Case(FeatureId.FUSED_ASH, fusedAsh, (out, v) -> { + ash.write(out, v); + out.writeInt(blockSize); + })); + + for (int version = 2; version <= OnDiskGraphIndex.CURRENT_VERSION; version++) { + var format = GraphIndexFormatFactory.forVersion(version); + for (var c : cases) { + if (!format.getSupportedFeatures().contains(c.id)) continue; + var features = new EnumMap(FeatureId.class); + features.put(c.id, c.feature); + byte[] written = bytes(out -> format.writeHeaderFeatures(out, features)); + int v = version; + byte[] expected = bytes(out -> c.body.write(out, v)); + // The format's own framing (feature ids, counts) precedes the header. + byte[] header = Arrays.copyOfRange(written, written.length - expected.length, written.length); + assertArrayEquals(c.id + " at version " + version, expected, header); + } + } + } + + private static final class Case { + final FeatureId id; + final Feature feature; + final Body body; + + Case(FeatureId id, Feature feature, Body body) { + this.id = id; + this.feature = feature; + this.body = body; + } + } + + private interface Writes { + void to(IndexWriter out) throws IOException; + } + + private static byte[] bytes(Writes writes) throws IOException { + try (var out = ByteBufferIndexWriter.create(1024 * 1024, false)) { + writes.to(out); + ByteBuffer data = out.getWrittenData(); + byte[] result = new byte[data.remaining()]; + data.get(result); + return result; + } + } +} From 56d598f4dff777e9eefe11fceb9c1cae41af6f28 Mon Sep 17 00:00:00 2001 From: Ted Willke Date: Wed, 11 Nov 2026 14:39:02 -0800 Subject: [PATCH 220/243] Give the fvec read-progress tests room for class loading on slow CI runners The 1 s timeouts only guard against a read loop that never ends, but they also cover the test class's first use of FvecFileVectorValues; on the JDK 24 AVX-512 runner that pushed one 32-read test just over the limit. 10 s still catches a spin. --- .../github/jbellis/jvector/disk/TestFvecReadProgress.java | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/jvector-tests/src/test/java/io/github/jbellis/jvector/disk/TestFvecReadProgress.java b/jvector-tests/src/test/java/io/github/jbellis/jvector/disk/TestFvecReadProgress.java index 06b1f0880..ac6780ea7 100644 --- a/jvector-tests/src/test/java/io/github/jbellis/jvector/disk/TestFvecReadProgress.java +++ b/jvector-tests/src/test/java/io/github/jbellis/jvector/disk/TestFvecReadProgress.java @@ -24,7 +24,9 @@ import static org.junit.Assert.*; public class TestFvecReadProgress { - @Test(timeout = 1000) public void repeatedZeroReadsFailInsteadOfSpinning() { + // The timeouts only catch a read loop that never ends; they include one-time class loading, + // which can take over a second on a busy CI runner. + @Test(timeout = 10_000) public void repeatedZeroReadsFailInsteadOfSpinning() { var channel = new TestChannel(-1); var failure = assertThrows(IOException.class, () -> FvecFileVectorValues.readFully(channel, ByteBuffer.allocate(1), 123)); @@ -32,7 +34,7 @@ public class TestFvecReadProgress { assertEquals(16, channel.calls); } - @Test(timeout = 1000) public void progressResetsTheZeroReadLimitAndPreservesOffsets() throws Exception { + @Test(timeout = 10_000) public void progressResetsTheZeroReadLimitAndPreservesOffsets() throws Exception { var channel = new TestChannel(15); var buffer = ByteBuffer.allocate(4); buffer.position(2); @@ -43,7 +45,7 @@ public class TestFvecReadProgress { assertEquals((byte) 124, buffer.get(3)); } - @Test(timeout = 1000) public void eofStillFailsImmediately() { + @Test(timeout = 10_000) public void eofStillFailsImmediately() { var channel = new TestChannel(0) { @Override public int read(ByteBuffer buffer, long offset) { return -1; } }; From 62598a9e54e2d3cef146677accd789f1757d43e5 Mon Sep 17 00:00:00 2001 From: Ted Willke Date: Wed, 11 Nov 2026 14:40:08 -0800 Subject: [PATCH 221/243] Revert "Give the fvec read-progress tests room for class loading on slow CI runners" This reverts commit 56d598f4dff777e9eefe11fceb9c1cae41af6f28. --- .../github/jbellis/jvector/disk/TestFvecReadProgress.java | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/jvector-tests/src/test/java/io/github/jbellis/jvector/disk/TestFvecReadProgress.java b/jvector-tests/src/test/java/io/github/jbellis/jvector/disk/TestFvecReadProgress.java index ac6780ea7..06b1f0880 100644 --- a/jvector-tests/src/test/java/io/github/jbellis/jvector/disk/TestFvecReadProgress.java +++ b/jvector-tests/src/test/java/io/github/jbellis/jvector/disk/TestFvecReadProgress.java @@ -24,9 +24,7 @@ import static org.junit.Assert.*; public class TestFvecReadProgress { - // The timeouts only catch a read loop that never ends; they include one-time class loading, - // which can take over a second on a busy CI runner. - @Test(timeout = 10_000) public void repeatedZeroReadsFailInsteadOfSpinning() { + @Test(timeout = 1000) public void repeatedZeroReadsFailInsteadOfSpinning() { var channel = new TestChannel(-1); var failure = assertThrows(IOException.class, () -> FvecFileVectorValues.readFully(channel, ByteBuffer.allocate(1), 123)); @@ -34,7 +32,7 @@ public class TestFvecReadProgress { assertEquals(16, channel.calls); } - @Test(timeout = 10_000) public void progressResetsTheZeroReadLimitAndPreservesOffsets() throws Exception { + @Test(timeout = 1000) public void progressResetsTheZeroReadLimitAndPreservesOffsets() throws Exception { var channel = new TestChannel(15); var buffer = ByteBuffer.allocate(4); buffer.position(2); @@ -45,7 +43,7 @@ public class TestFvecReadProgress { assertEquals((byte) 124, buffer.get(3)); } - @Test(timeout = 10_000) public void eofStillFailsImmediately() { + @Test(timeout = 1000) public void eofStillFailsImmediately() { var channel = new TestChannel(0) { @Override public int read(ByteBuffer buffer, long offset) { return -1; } }; From 9514019493bdce2605fbb1c5e8ea5785f7c6d48e Mon Sep 17 00:00:00 2001 From: Ted Willke Date: Wed, 11 Nov 2026 10:39:15 -0800 Subject: [PATCH 222/243] Fold the ASH landmark term into every offset and give every bit width fast scoring Every stored offset now includes -scale *
    , as 2- and 4-bit codes already did, so asymmetric scoring at any width and landmark count needs only Aq. This removes the per-landmark centered queries A(q - mu_c), their preparation and the masked-add entry points that existed only for them. Code-to-code scoring adds the term back from the stored code. The FP64 projection copy used for landmark projections now holds the FP32 values that models persist, so a trained model and its reloaded copy derive identical landmark terms. 3- and 5- to 9-bit codes are now offset-binary fields: dimension j is the unsigned b-bit field at bits [j*b, (j+1)*b), with value field - (2^b - 1)/2. The values match the previous sign-plus-magnitude-bits layout, which needed a separate sign plane padded to 64-bit words. Because each field is linear in its bits, any 4 bits of a code have a 16-entry query table, so these widths use the existing nibble-LUT kernels for block and single-vector scoring, and fused graphs accept every width from 1 to 9. In fused graphs these widths store field blocks: each unit of eight dimensions holds every lane's b code bytes contiguously. The query Aq is rounded once to INT8, and a new native entry, ash_score_neighborhood_i8_c1, gathers each lane's fields, rotates them into bytes (vpmultishiftqb) and accumulates exact integer dot products (vpdpbusd); it is compiled for the AVX3_DL tier, with Highway's equivalents elsewhere. On a 32-lane neighborhood it is 1.19x faster than the LUT kernel at 3 bits and 2.05x at 8 bits on an Ice Lake class CPU, and far faster where AVX-512 is unavailable. Rounding the query moved exhaustive recall@10 on 1M datasets by at most 0.0003. A Java path with the same arithmetic covers other providers and multiple landmarks. 1-, 2- and 4-bit layouts and kernels are unchanged. Old and new code matched exhaustive recall@10 within 0.00015 at widths 1, 2, 3, 4 and 8 and C = 1, 16 and 64 on three 100k datasets and DPR-Gemma 1M, with the same scoring and encoding throughput at 1 and 2 bits. --- docs/ash-configuration.md | 23 ++- docs/ash-neighborhood-benchmark.md | 20 ++- .../jvector/graph/disk/feature/FusedASH.java | 16 +- .../jvector/quantization/ASHInt8Query.java | 88 ++++++++++ .../quantization/ASHNeighborhoodScorer.java | 33 +++- .../quantization/ASHQueryPreparer.java | 3 - .../jvector/quantization/ASHScorer.java | 115 ++++++------- .../quantization/ASHSymmetricScorer.java | 60 +++---- .../jvector/quantization/ASHVectors.java | 131 +++++---------- .../quantization/AsymmetricHashing.java | 157 ++++++++++-------- .../quantization/FusedASHBlockPacker.java | 2 +- .../jvector/quantization/FusedASHLayout.java | 144 +++++++++++++--- .../vector/DefaultVectorUtilSupport.java | 32 ---- .../jvector/vector/VectorUtilSupport.java | 119 ++++--------- .../example/ASHNeighborhoodValidator.java | 27 ++- .../jbellis/jvector/example/DistancesASH.java | 26 ++- .../example/util/CompressorParameters.java | 3 - .../jvector/vector/NativeASHKernels.java | 72 ++++++++ .../vector/NativeVectorUtilSupport.java | 15 +- .../main/native/src/ash_neighborhood-inl.h | 155 +++++++++++++++++ .../native/src/jvector_avx3_dl_kernels.cpp | 4 + .../src/main/native/src/jvector_simd.cpp | 1 + .../native/src/jvector_simd_kernel_list.h | 2 + .../example/TestASHNeighborhoodBenchmark.java | 2 +- .../quantization/TestASHBatchEncoding.java | 45 ++--- .../quantization/TestASHGraphSearch.java | 2 +- .../quantization/TestASHInt8Neighborhood.java | 132 +++++++++++++++ .../quantization/TestASHLutScoring.java | 26 ++- .../quantization/TestASHQueryPreparation.java | 9 +- .../quantization/TestASHScoringDispatch.java | 7 - .../TestASHStandaloneVsFusedScoring.java | 84 +--------- .../quantization/TestASHSymmetricScorer.java | 24 ++- .../TestFusedASHBlockPacking.java | 17 +- .../quantization/TestFusedASHQueryLut.java | 36 ++++ .../quantization/TestPreparedASHQuery.java | 11 +- .../vector/PanamaVectorUtilSupport.java | 152 +---------------- 36 files changed, 1041 insertions(+), 754 deletions(-) create mode 100644 jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHInt8Query.java create mode 100644 jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestASHInt8Neighborhood.java diff --git a/docs/ash-configuration.md b/docs/ash-configuration.md index cf0bb9522..05c7dd8fb 100644 --- a/docs/ash-configuration.md +++ b/docs/ash-configuration.md @@ -20,7 +20,7 @@ the codes stored with a fused ASH graph. | `optimizer` | Required | `ITQ` (`1`): iterative-quantization transform. `RANDOM` (`2`): random transform. | | `encodedBits` | Exactly one of this and `compressionRatio` | Total stored bits including the 40-bit / five-byte header. The positive payload `encodedBits - 40` must be divisible by `bitsPerDimension`. | | `compressionRatio` | Alternative to `encodedBits` | BenchYAML accepts `8`, `16`, `32`, `64`, `128`, `256`. Resolves a payload near the requested FP32-to-code ratio, with a minimum 64-bit payload and whole projected dimensions; inspect the printed resolved size. | -| `bitsPerDimension` (`b`) | `1` | Integer `1` through `9` in core ASH. Fused ASH supports `1`, `2`, `4`; optimized multibit formats use `2` or `4`. Stored-code precision is independent of query-projection arithmetic. | +| `bitsPerDimension` (`b`) | `1` | Integer `1` through `9`, all supported by fused ASH. `1`, `2` and `4` have the most optimized kernels (see below). Stored-code precision is independent of query-projection arithmetic. | | `landmarkCount` (`C`) | `1` | Integer `1` through `256` for the quantizer. Symmetric graph-construction scoring currently requires `C=1` and dot-product similarity. | | Projected dimension (`d`) | Derived, not a separate YAML field | `d = (encodedBits - 40) / b`, with `1 <= d <= D` for input dimension `D`. Increasing `d` or `b` increases code size. | @@ -48,10 +48,13 @@ QPS differs by about 1.5%. When a byte budget lands just short of an aligned 100-byte budget, `d = 384` at 2 bits (101 bytes, no dimension reduction) is a better choice than `d = 380` (100 bytes). -The other widths, `b` = 3 and 5 through 9, are supported by core ASH but use -a generic per-dimension Java path for scoring, and fused graphs do not -support them. They are slower than 1, 2 or 4 bits at every `d`. Fast native -kernels for these widths are left for future work. +The other widths, `b` = 3 and 5 through 9, store each dimension as an unsigned +`b`-bit field. Search scores their graph neighborhoods with the projected query +rounded to INT8 and integer dot products (VNNI on x86 processors that have it), +so make `d` a multiple of 8. Their other asymmetric scoring uses the same +4-bit query tables as 2 and 4 bits. Construction still compares their codes +with a portable per-dimension path, so building is slower than at 1, 2 or 4 +bits; native code-to-code kernels for these widths are left for future work. Construction comparison cost, in ns per code pair, on one pinned core with the native provider, on an Intel Xeon 6985P-C and on an AWS m6id.4xlarge @@ -103,9 +106,13 @@ so changing it does not invalidate an existing cached graph. ### In-memory code layout `ASHVectors` keeps one copy of the codes, in compact pages. Each vector's code -body (sign words, projection code or extra bits) sits back to back with the -others, with no per-vector object, array or padding. Scale, offset and landmark -are decoded once into three parallel arrays. Every scorer, symmetric and +body sits back to back with the others: sign words at 1 bit, sign-magnitude +projection codes at 2 and 4 bits, and offset-binary fields at the other widths +(dimension `j` is the unsigned field at bits `[j*b, (j+1)*b)`, with value +`field - (2^b - 1)/2`). Every stored offset includes the landmark term +`-scale * `, so asymmetric scoring needs only `Aq`. Bodies have +no per-vector object, array or padding. Scale, offset and landmark are decoded +once into three parallel arrays. Every scorer, symmetric and asymmetric, reads the codes in place, and the encoder writes straight into the pages. diff --git a/docs/ash-neighborhood-benchmark.md b/docs/ash-neighborhood-benchmark.md index 590fe2048..695fe6283 100644 --- a/docs/ash-neighborhood-benchmark.md +++ b/docs/ash-neighborhood-benchmark.md @@ -27,16 +27,16 @@ so each decoder belongs to one graph view/search thread. Upper-layer scoring and L0 neighborhoods share one query projection. A failed neighborhood read or score invalidates the decoder's cached origin before any output can be reused. -For one-bit ASH, canonical scoring uses `A(q - mu)` while fused scoring uses `Aq`. -`FusedASHLayout.packForFused` adjusts the one-bit offset accordingly. Two/four-bit -canonical offsets already contain that correction. The writer and fixture builder -use the shared helper rather than maintaining separate scoring formulas. +Canonical and fused scoring both use `Aq`: every stored offset already includes +the landmark term `-scale * `, so `FusedASHLayout.packForFused` +copies the header as stored. The writer and fixture builder use that shared +helper rather than maintaining separate scoring formulas. ## Native C=1 execution With `jvector.vectorization_provider=native` and `jvector.ash.blockKernel=auto` or `simd`, C=1 neighborhoods use Highway. The native -path supports 1/2/4 bits per projected dimension, block widths 8/16/32, partial +path supports every bit width (see below for 3 and 5 to 9 bits), block widths 8/16/32, partial blocks, and neighborhoods containing multiple blocks. C>1 retains the existing scorer. Selecting the Panama provider retains the Panama kernel; selecting `blockKernel=scalar` disables acceleration for the LUT scoring path. @@ -54,6 +54,16 @@ conversion. AVX2 uses indexed float LUT loads; the SSE software conversion inclu explicit handling for NaN and infinity. AVX3_DL and AVX3_SPR inherit the AVX3 ASH slot, so an `avx3_spr` dispatch report currently executes that AVX-512 implementation. +Offset-binary widths (3 and 5 to 9 bits) use a second entry, +`ash_score_neighborhood_i8_c1`, over field blocks (`[unit][lane][b bytes]`, eight +dimensions per unit). The projected query is rounded to INT8 once per query. Each +lane's eight fields are gathered into one 64-bit word, rotated into separate bytes +(`vpmultishiftqb` on AVX-512 VBMI) and multiplied with the query by integer +quad-dot instructions (`vpdpbusd` with VNNI); Highway supplies the equivalent +operations on other targets. Sums are exact integers; only the query rounding +differs from FP32 scoring, which moved exhaustive recall@10 on 1M datasets by at +most 0.0003. The header finish is shared with the LUT entry. + ### FFM safety and lifetime `NativeASHKernels` lives outside the jextract-generated `cnative` package and diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/graph/disk/feature/FusedASH.java b/jvector-base/src/main/java/io/github/jbellis/jvector/graph/disk/feature/FusedASH.java index 556b64d64..73f962b94 100644 --- a/jvector-base/src/main/java/io/github/jbellis/jvector/graph/disk/feature/FusedASH.java +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/graph/disk/feature/FusedASH.java @@ -234,20 +234,8 @@ private void packForFused(byte[] dest, int neighborIndex, AsymmetricHashing.Quan ash.bitsPerDimension, blockSize); - if (ash.bitsPerDimension == 1) { - FusedASHLayout.packForFused(dest, blockOffset, lane, vector, ash, blockSize); - } else { - // For 2-bit and 4-bit ASH, the canonical vector already stores the - // C++ projection-mode adjusted offset used by FusedASH scoring. - FusedASHLayout.packQuantizedVector( - dest, - blockOffset, - lane, - vector, - ash.quantizedDim, - ash.bitsPerDimension, - blockSize); - } + // The canonical vector already stores the projection-mode offset used by FusedASH scoring. + FusedASHLayout.packForFused(dest, blockOffset, lane, vector, ash, blockSize); } private static void writeBytes(IndexWriter out, byte[] bytes, int length) throws IOException { diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHInt8Query.java b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHInt8Query.java new file mode 100644 index 000000000..8902d9a8d --- /dev/null +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHInt8Query.java @@ -0,0 +1,88 @@ +/* + * 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; + +/** + * A projected query {@code Aq} rounded to INT8 for scoring offset-binary codes (3 and 5 to 9 bits) in graph + * neighborhoods. With {@code s = 127 / max|Aq|}, the dot product with a code's unsigned fields {@code u} is + * exact integer arithmetic followed by {@code ( - center) * invScale}, where + * {@code center = (2^b - 1)/2 * sum(q)} removes the offset-binary bias. Rounding the query changes scores by + * well under a code step; exhaustive recall@10 on 1M datasets moved by at most 0.0003. + * + *

    Instances are query-owned scratch: {@link #prepare} overwrites them for the next query.

    + */ +final class ASHInt8Query { + final int quantizedDim; + final int bits; + final int units; + /** The rounded query, {@code units * 8} entries; zero past {@code quantizedDim}. */ + final byte[] digits; + float invScale; + float center; + + ASHInt8Query(int quantizedDim, int bits) { + if (!AsymmetricHashing.usesOffsetBinaryCode(bits)) { + throw new IllegalArgumentException("INT8 neighborhood queries require offset-binary codes; got " + bits); + } + this.quantizedDim = quantizedDim; + this.bits = bits; + this.units = FusedASHLayout.fieldUnits(quantizedDim); + this.digits = new byte[units * 8]; + } + + /** Quantizes {@code qProj[0, quantizedDim)}. A non-finite component makes every score NaN. */ + void prepare(float[] qProj) { + float max = 0f; + for (int j = 0; j < quantizedDim; j++) max = Math.max(max, Math.abs(qProj[j])); + java.util.Arrays.fill(digits, (byte) 0); + if (!Float.isFinite(max)) { + invScale = Float.NaN; + center = 0f; + return; + } + if (max == 0f) { + invScale = 0f; + center = 0f; + return; + } + float scale = 127f / max; + long sum = 0; + for (int j = 0; j < quantizedDim; j++) { + int q = Math.max(-127, Math.min(127, Math.round(qProj[j] * scale))); + digits[j] = (byte) q; + sum += q; + } + invScale = max / 127f; + center = (float) (((1 << bits) - 1) * 0.5 * sum); + } + + /** + * The dot product of the query with one lane of a field-block neighborhood block, with the native + * kernel's arithmetic: an exact integer sum, then {@code (sum - center) * invScale}. + */ + float laneDot(byte[] block, int blockOffset, int lane, int blockSize) { + int b = bits; + long dot = 0; + for (int unit = 0; unit < units; unit++) { + int base = blockOffset + (unit * blockSize + lane) * b; + for (int k = 0; k < 8; k++) { + dot += digits[unit * 8 + k] * AsymmetricHashing.offsetBinaryField(block, base, k, b); + } + } + return ((float) dot - center) * invScale; + } +} diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHNeighborhoodScorer.java b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHNeighborhoodScorer.java index b73dcfed3..0eaf40925 100644 --- a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHNeighborhoodScorer.java +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHNeighborhoodScorer.java @@ -45,6 +45,9 @@ public final class ASHNeighborhoodScorer { private final float[] lut; private final float[] dotQMu; private final VectorUtilSupport neighborhoodBackend; + /** Offset-binary widths: the query as two INT8 digits for field-block neighborhoods; otherwise null. */ + private final ASHInt8Query int8Query; + private final VectorUtilSupport int8Backend; private final ASHLutKernel kernel = new ASHLutKernel(); /** @@ -103,8 +106,19 @@ private static ASHScorer.QueryPrecompute prepareQuery( this.lut = reusableLut == null ? new float[lutSize] : reusableLut; if (!alreadyPrepared) FusedASHLayout.buildQueryLut(query.qProj, dimensions, bits, lut); var backend = VectorizationProvider.getInstance().getVectorUtilSupport(); - this.neighborhoodBackend = - kernel.isSimd() && dotQMu.length == 1 && backend.supportsAshNeighborhoodScoring() ? backend : null; + if (FusedASHLayout.usesFieldBlocks(bits)) { + // The LUT above still serves canonical scoring of upper-layer nodes. + this.int8Query = new ASHInt8Query(dimensions, bits); + this.int8Query.prepare(query.qProj); + this.int8Backend = kernel.isSimd() && dotQMu.length == 1 && backend.supportsAshInt8NeighborhoodScoring() + ? backend : null; + this.neighborhoodBackend = null; + } else { + this.int8Query = null; + this.int8Backend = null; + this.neighborhoodBackend = + kernel.isSimd() && dotQMu.length == 1 && backend.supportsAshNeighborhoodScoring() ? backend : null; + } } /** @@ -125,11 +139,20 @@ public void score(byte[] packed, float[] out) { neighborhoodBackend.ashScoreNeighborhood(packed, groups, blockSize, maxDegree, lut, dotQMu[0], out); return; } + if (int8Backend != null) { + int8Backend.ashScoreNeighborhoodInt8(packed, bits, int8Query.units, blockSize, maxDegree, int8Query.digits, + int8Query.invScale, int8Query.center, dotQMu[0], out); + return; + } for (int block = 0; block < blocks; block++) { int offset = FusedASHLayout.blockOffset(block, dimensions, bits, blockSize); int base = block * blockSize; int count = Math.min(blockSize, maxDegree - base); - kernel.score(packed, offset, groups, blockSize, 0, count, lut, out, base); + if (int8Query != null) { + for (int lane = 0; lane < count; lane++) out[base + lane] = int8Query.laneDot(packed, offset, lane, blockSize); + } else { + kernel.score(packed, offset, groups, blockSize, 0, count, lut, out, base); + } for (int lane = 0; lane < count; lane++) { int landmark = FusedASHLayout.readLandmark(packed, offset, lane, dimensions, bits, blockSize); if (landmark >= dotQMu.length) { @@ -145,7 +168,9 @@ public void score(byte[] packed, float[] out) { @Override public String toString() { - Object implementation = neighborhoodBackend == null ? kernel : "native Highway whole-neighborhood FFM"; + Object implementation = neighborhoodBackend != null ? "native Highway whole-neighborhood FFM" + : int8Backend != null ? "native Highway whole-neighborhood FFM, INT8 field blocks" + : int8Query != null ? "Java INT8 field blocks" : kernel; return "ASHNeighborhoodScorer[" + implementation + ", blockSize=" + blockSize + ", capacity=" + maxDegree + "]"; } diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHQueryPreparer.java b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHQueryPreparer.java index 02474a6e6..f79bcec00 100644 --- a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHQueryPreparer.java +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHQueryPreparer.java @@ -176,9 +176,6 @@ static final class State implements PreparedQuery { + RamUsageEstimator.sizeOf(prepared.qProj) + RamUsageEstimator.sizeOf(lut) + RamUsageEstimator.sizeOf(prepared.dotQMuByLandmark); if (backing != input) sum += RamUsageEstimator.shallowSizeOf(backing); - // Empty generic arrays are shared by fast-scan states. - if (prepared.tildeQPool.length != 0) sum += RamUsageEstimator.sizeOf(prepared.tildeQPool); - if (prepared.sumTildeQByLandmark.length != 0) sum += RamUsageEstimator.sizeOf(prepared.sumTildeQByLandmark); this.bytes = sum; } diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHScorer.java b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHScorer.java index 9b474da6a..ec713ef19 100644 --- a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHScorer.java +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHScorer.java @@ -117,11 +117,14 @@ CodeScorer codeScorerFor(QueryPrecompute qp) { if (AsymmetricHashing.usesFastScanProjectionCode(ash.bitsPerDimension)) { return fastScanProjectionDotProductScorer(qp); } - - if (ASHVectors.AshSingleKernel.fromProperty() == ASHVectors.AshSingleKernel.SIMD) { - throw new UnsupportedOperationException("SIMD ASH projection scoring supports 2 and 4 bits per dimension"); + var mode = ASHVectors.AshSingleKernel.fromProperty(); + var backend = VectorizationProvider.getInstance().getVectorUtilSupport(); + if (mode == ASHVectors.AshSingleKernel.SIMD && !backend.supportsAshCanonicalLutScoring()) { + throw new IllegalStateException("ASH single-vector SIMD requested, but backend " + + backend.getClass().getName() + " does not support canonical LUT scoring"); } - return genericMultibitDotProductScorer(qp); + return genericMultibitDotProductScorer(qp, + mode != ASHVectors.AshSingleKernel.SCALAR && backend.supportsAshCanonicalLutScoring()); } /** Reuses a fused scorer's table for direct nodes when the backend supports it. */ @@ -130,7 +133,7 @@ ASHScoreFunction scoreFunctionFor(QueryPrecompute qp, float[] lut) { var backend = VectorizationProvider.getInstance().getVectorUtilSupport(); // Two coordinates share each 2-bit LUT entry. Four-bit codes instead // use the backend's contiguous projection dot, avoiding a 16x larger table. - if (ash.bitsPerDimension == 2 + if ((ash.bitsPerDimension == 2 || AsymmetricHashing.usesOffsetBinaryCode(ash.bitsPerDimension)) && mode != ASHVectors.AshSingleKernel.SCALAR && backend.supportsAshCanonicalLutScoring()) { int groups = FusedASHLayout.codeGroups(qp.d, ash.bitsPerDimension); @@ -151,8 +154,7 @@ CodeScorer codeScorerFor(QueryPrecompute qp, boolean simd) { if (AsymmetricHashing.usesFastScanProjectionCode(ash.bitsPerDimension)) { return fastScanProjectionDotProductScorer(qp, simd); } - if (simd) throw new UnsupportedOperationException("SIMD ASH requires bits 1, 2, or 4"); - return genericMultibitDotProductScorer(qp); + return genericMultibitDotProductScorer(qp, simd); } /** Adapts a code scorer to standalone vectors, whose arrays start at offset zero. */ @@ -168,10 +170,9 @@ private CodeScorer oneBitDotProductScorer(QueryPrecompute qp) { return (bits, bitsOffset, body, bodyOffset, scale, offset, c) -> { assert c < C : "Invalid landmark id " + c + " for landmarkCount=" + C; - // maskedAdd = , where b ∈ {0,1}^d is stored as packed longs. + // maskedAdd = , where b ∈ {0,1}^d is stored as packed longs. float maskedAdd = 0f; - final int tildeBase = c * d; int dimBase = 0; for (int w = 0; w < words && dimBase < d; w++, dimBase += 64) { long word = bits[bitsOffset + w]; @@ -179,15 +180,15 @@ private CodeScorer oneBitDotProductScorer(QueryPrecompute qp) { int bit = Long.numberOfTrailingZeros(word); int idx = dimBase + bit; if (idx < d) { - maskedAdd += qp.tildeQPool[tildeBase + idx]; + maskedAdd += qp.qProj[idx]; } word &= (word - 1); } } - // 1-bit ASH dot-product approximation: - // scale * (2 - ) + + offset - return scale * (2f * maskedAdd - qp.sumTildeQByLandmark[c]) + // 1-bit ASH dot-product approximation, with folded into the offset: + // scale * + + offset, where = 2 - + return scale * (2f * maskedAdd - qp.sumQProj) + qp.dotQMuByLandmark[c] + offset; }; @@ -205,7 +206,7 @@ CodeScorer scalarCodeScorerFor(VectorFloat query) { if (AsymmetricHashing.usesFastScanProjectionCode(ash.bitsPerDimension)) { return fastScanProjectionDotProductScorer(qp, false); } - return genericMultibitDotProductScorer(qp); + return genericMultibitDotProductScorer(qp, false); } private CodeScorer fastScanProjectionDotProductScorer(QueryPrecompute qp) { @@ -261,44 +262,35 @@ private CodeScorer fastScanProjectionDotProductScorer(QueryPrecompute qp, boolea }; } - private CodeScorer genericMultibitDotProductScorer(QueryPrecompute qp) { + /** + * Offset-binary codes (3 and 5 to 9 bits): {@code scale * + + offset}. The SIMD + * path sums the code's 4-bit groups through a query LUT prepared once per scorer; the scalar path + * decodes each field and is the independent reference. + */ + private CodeScorer genericMultibitDotProductScorer(QueryPrecompute qp, boolean simd) { final int d = qp.d; final int C = qp.C; final int bitsPerDimension = ash.bitsPerDimension; - final int exBits = bitsPerDimension - 1; - final int signMagnitudeOffset = 1 << exBits; - final float centerBias = -(signMagnitudeOffset - 0.5f); - - return (signBits, signOffset, extraBits, extraOffset, scale, offset, c) -> { + if (simd) { + final var backend = VectorizationProvider.getInstance().getVectorUtilSupport(); + final int groups = FusedASHLayout.codeGroups(d, bitsPerDimension); + // Query-owned: prepared scorers may outlive this thread or run concurrently. + final float[] lut = new float[Math.multiplyExact(groups, 16)]; + FusedASHLayout.buildQueryLut(qp.qProj, d, bitsPerDimension, lut); + return (signBits, signOffset, code, codeOffset, scale, offset, c) -> + scale * backend.ashCanonicalLutDot(code, codeOffset, groups, lut) + qp.dotQMuByLandmark[c] + offset; + } + return (signBits, signOffset, code, codeOffset, scale, offset, c) -> { assert c < C : "Invalid landmark id " + c + " for landmarkCount=" + C; - - final int tildeBase = c * d; - - float ip = 0f; - for (int j = 0; j < d; j++) { - int sign = AsymmetricHashing.QuantizedVector.getBit(signBits, signOffset, j) ? 1 : 0; - int extra = AsymmetricHashing.QuantizedVector.readExtraCode(extraBits, extraOffset, j, exBits); - - // Matches the C++ generic scalar reconstruction formula: - // (sign << exBits) + extra - (2^exBits - 0.5) - float code = (float) ((sign << exBits) + extra) + centerBias; - ip += qp.tildeQPool[tildeBase + j] * code; - } - - return scale * ip + qp.dotQMuByLandmark[c] + offset; + return scale * AsymmetricHashing.dotOffsetBinaryCode(qp.qProj, code, codeOffset, d, bitsPerDimension) + + qp.dotQMuByLandmark[c] + offset; }; } - private static final float[] EMPTY = new float[0]; - - /** Allocates only the preparation terms used by this model's scoring formula. */ + /** Allocates the preparation terms of one query: {@code Aq} and the landmark dots. */ QueryPrecompute newQueryPrecompute() { - int d = ash.quantizedDim; - int c = ash.landmarkCount; - boolean generic = !AsymmetricHashing.usesFastScanProjectionCode(ash.bitsPerDimension); - return new QueryPrecompute(d, c, new float[d], - generic ? new float[Math.multiplyExact(c, d)] : EMPTY, - generic ? new float[c] : EMPTY, new float[c]); + return new QueryPrecompute(ash.quantizedDim, ash.landmarkCount, new float[ash.quantizedDim], + new float[ash.landmarkCount]); } /** Independently owned preparation for retained standalone scorers. */ @@ -330,40 +322,29 @@ void finishQuery(VectorFloat query, QueryPrecompute prepared) { for (int c = 0; c < ash.landmarkCount; c++) { prepared.dotQMuByLandmark[c] = VectorUtil.dotProduct(query, ash.landmarks[c]); } - // Projection codes already include landmark adjustment in their stored offset. - // Neither their direct scorer nor their LUT needs the generic centered vectors. - if (AsymmetricHashing.usesFastScanProjectionCode(ash.bitsPerDimension)) return; - float sumQProj = 0f; - for (float value : prepared.qProj) sumQProj += value; - for (int c = 0; c < ash.landmarkCount; c++) { - prepared.sumTildeQByLandmark[c] = sumQProj - ash.landmarkProjSum[c]; - int base = c * ash.quantizedDim; - for (int j = 0; j < ash.quantizedDim; j++) { - prepared.tildeQPool[base + j] = prepared.qProj[j] - ash.landmarkProj[c][j]; - } - } + // Stored offsets include the landmark term, so every scorer needs only Aq and, for 1-bit + // codes, its sum. + prepared.sumQProj = sum(prepared.qProj); + } + + /** Left-to-right FP32 sum, the order every 1-bit scorer uses for {@code }. */ + static float sum(float[] values) { + float total = 0f; + for (float value : values) total += value; + return total; } static final class QueryPrecompute { final int d; final int C; final float[] qProj; // [d], Aq - final float[] tildeQPool; // [C * d], Aq - Aμ_c - final float[] sumTildeQByLandmark; // [C] final float[] dotQMuByLandmark; // [C] + float sumQProj; // , set with qProj - QueryPrecompute( - int d, - int C, - float[] qProj, - float[] tildeQPool, - float[] sumTildeQByLandmark, - float[] dotQMuByLandmark) { + QueryPrecompute(int d, int C, float[] qProj, float[] dotQMuByLandmark) { this.d = d; this.C = C; this.qProj = qProj; - this.tildeQPool = tildeQPool; - this.sumTildeQByLandmark = sumTildeQByLandmark; this.dotQMuByLandmark = dotQMuByLandmark; } } diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHSymmetricScorer.java b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHSymmetricScorer.java index c17827261..ed2ec6fa0 100644 --- a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHSymmetricScorer.java +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHSymmetricScorer.java @@ -67,18 +67,15 @@ public ASHSymmetricScorer(ASHVectors vectors, Kernel kernel) { landmarkDots[a][b] = landmarkDots[b][a] = VectorUtil.dotProduct(ash.landmarks[a], ash.landmarks[b]); } } - if (AsymmetricHashing.usesFastScanProjectionCode(bits)) { - if (vectors.symmetricOffsets != null) { - centeredOffsets = vectors.symmetricOffsets; - } else { - centeredOffsets = new float[codes.size]; - for (int i = 0; i < centeredOffsets.length; i++) { - centeredOffsets[i] = ash.centeredOffset(codes.scales[i], codes.offsets[i], codes.landmarks[i], - codes.bodyPage(i), codes.bodyOffset(i)); - } - } + // Stored offsets include -scale *
    ; symmetric scoring adds it back. + if (vectors.symmetricOffsets != null) { + centeredOffsets = vectors.symmetricOffsets; } else { - centeredOffsets = null; + centeredOffsets = new float[codes.size]; + for (int i = 0; i < centeredOffsets.length; i++) { + centeredOffsets[i] = ash.centeredOffset(codes.scales[i], codes.offsets[i], codes.landmarks[i], + signs(i), codes.signOffset(i), body(i), codes.bodyOffset(i)); + } } retainedBytes = RamUsageEstimator.shallowSizeOfInstance(ASHSymmetricScorer.class) + AsymmetricHashing.estimateArray2DBytes(landmarkDots) @@ -103,7 +100,7 @@ private float centeredOffset(AsymmetricHashing.QuantizedVector vector) { return ash.centeredOffset(vector); } - private float offset(int node) { return centeredOffsets == null ? codes.offsets[node] : centeredOffsets[node]; } + private float offset(int node) { return centeredOffsets[node]; } private long[] signs(int node) { return codes.signWords == 0 ? null : codes.signPage(node); } @@ -262,8 +259,7 @@ public void scoreRange(int start, int count, float[] out) { } }; } - boolean supported = bits == 1 ? backend.supportsAshMaskedLoad() - : (bits == 2 || bits == 4) && backend.supportsAshLutScoring(); + boolean supported = bits == 1 ? backend.supportsAshMaskedLoad() : backend.supportsAshLutScoring(); if (kernel == Kernel.SIMD && !supported) throw new UnsupportedOperationException("Symmetric block SIMD is unavailable for this backend/bit width"); return vectors.blockScorerFor(preparedState(query), blockSize, kernel != Kernel.SCALAR && supported); } @@ -275,29 +271,21 @@ private ASHScorer.QueryPrecompute preparedState(AsymmetricHashing.QuantizedVecto if (cq >= landmarks) throw new IllegalArgumentException("Invalid query landmark " + cq); float[] code = new float[dimensions]; float[] qProj = new float[dimensions]; - float ownProjection = 0; for (int j = 0; j < dimensions; j++) { code[j] = component(query, j, bits); qProj[j] = ash.landmarkProj[cq][j] + query.scale * code[j]; - ownProjection += code[j] * ash.landmarkProj[cq][j]; } - float correction = AsymmetricHashing.usesFastScanProjectionCode(bits) - ? query.offset : query.offset - query.scale * ownProjection; - float[] tilde = new float[Math.multiplyExact(landmarks, dimensions)]; - float[] sums = new float[landmarks]; + // The query's stored offset already includes its own landmark term. + float correction = query.offset; float[] dots = new float[landmarks]; for (int c = 0; c < landmarks; c++) { - float dot = 0, sum = 0; - for (int j = 0; j < dimensions; j++) { - dot += code[j] * ash.landmarkProj[c][j]; - float value = qProj[j] - ash.landmarkProj[c][j]; - tilde[c * dimensions + j] = value; - sum += value; - } - sums[c] = sum; + float dot = 0; + for (int j = 0; j < dimensions; j++) dot += code[j] * ash.landmarkProj[c][j]; dots[c] = landmarkDots[cq][c] + query.scale * dot + correction; } - return new ASHScorer.QueryPrecompute(dimensions, landmarks, qProj, tilde, sums, dots); + var prepared = new ASHScorer.QueryPrecompute(dimensions, landmarks, qProj, dots); + prepared.sumQProj = ASHScorer.sum(qProj); + return prepared; } private static float component(AsymmetricHashing.QuantizedVector v, int j, int bits) { @@ -308,10 +296,7 @@ private static float component(AsymmetricHashing.QuantizedVector v, int j, int b private static float component(long[] signs, int signOffset, byte[] body, int bodyOffset, int j, int bits) { if (bits == 1) return AsymmetricHashing.QuantizedVector.getBit(signs, signOffset, j) ? 1f : -1f; if (bits == 2 || bits == 4) return AsymmetricHashing.projectionComponent(body, bodyOffset, j, bits); - int ex = bits - 1; - int code = ((AsymmetricHashing.QuantizedVector.getBit(signs, signOffset, j) ? 1 : 0) << ex) - + AsymmetricHashing.QuantizedVector.readExtraCode(body, bodyOffset, j, ex); - return code - ((1 << ex) - 0.5f); + return AsymmetricHashing.decodeOffsetBinary(AsymmetricHashing.offsetBinaryField(body, bodyOffset, j, bits), bits); } private static float symmetric2ScalarWords(byte[] ac, int aOffset, byte[] bc, int bOffset, int d) { @@ -380,15 +365,12 @@ static float codeDot(long[] aSigns, int aSignOffset, byte[] aBody, int aBodyOffs } return sum * 0.25f; } - // Generic sign+extra representation supports the remaining widths through 9 bits. - int extra = bits - 1; + // Offset-binary fields (3 and 5 to 9 bits): products of doubled values are exact integers. int bias = (1 << bits) - 1; long sum = 0; for (int j = 0; j < d; j++) { - int x = ((AsymmetricHashing.QuantizedVector.getBit(aSigns, aSignOffset, j) ? 1 : 0) << extra) - + AsymmetricHashing.QuantizedVector.readExtraCode(aBody, aBodyOffset, j, extra); - int y = ((AsymmetricHashing.QuantizedVector.getBit(bSigns, bSignOffset, j) ? 1 : 0) << extra) - + AsymmetricHashing.QuantizedVector.readExtraCode(bBody, bBodyOffset, j, extra); + int x = AsymmetricHashing.offsetBinaryField(aBody, aBodyOffset, j, bits); + int y = AsymmetricHashing.offsetBinaryField(bBody, bBodyOffset, j, bits); sum += (long) (2 * x - bias) * (2 * y - bias); } return sum * 0.25f; diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHVectors.java b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHVectors.java index 6d4aa10f9..c89523aff 100644 --- a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHVectors.java +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHVectors.java @@ -253,7 +253,7 @@ private final class SimdASHScoreFunction implements ScoreFunction.ApproximateSco private final VectorUtilSupport vecUtil; // Query state and the stored sign pages, read in place - private final float[] tildeQPool; + private final float[] qProj; private final ASHCodes codes; private final int d; @@ -269,8 +269,7 @@ private final class SimdASHScoreFunction implements ScoreFunction.ApproximateSco this.d = qp.d; this.words = ASHVectors.this.words; // access outer class field - // 1. Grab direct reference to the pool - this.tildeQPool = qp.tildeQPool; + this.qProj = qp.qProj; this.codes = ASHVectors.this.codes; } @@ -279,19 +278,18 @@ private final class SimdASHScoreFunction implements ScoreFunction.ApproximateSco public float similarityTo(int node2) { final int c = landmarks[node2] & 0xFF; - final int qOffset = c * d; // Run existing optimized 1-bit kernel on the node's sign words in its page. final float maskedAdd = vecUtil.ashMaskedAdd_512( - tildeQPool, - qOffset, + qProj, + 0, codes.signPage(node2), codes.signOffset(node2), d, words ); - return ASHScorer.toSimilarity(scales[node2] * (2f * maskedAdd - qp.sumTildeQ[c]) + return ASHScorer.toSimilarity(scales[node2] * (2f * maskedAdd - qp.sumQProj) + qp.dotQMu[c] + offsets[node2]); } @@ -405,19 +403,12 @@ public ASHBlockScorer blockScorerFor( throw new IllegalArgumentException("blockSize must be > 0"); } - if (ash.bitsPerDimension == 2 || ash.bitsPerDimension == 4) { + // Every multibit width scores 4-bit groups of its canonical code through the query LUT. + if (ash.bitsPerDimension != 1) { FusedASHLayout.validateBlockSize(blockSize); return new LutASHBlockScorer(query, blockSize); } - // Other bit widths retain their canonical scalar reconstruction. - if (ash.bitsPerDimension != 1) { - if (AshBlockKernel.fromProperty() == AshBlockKernel.SIMD) { - throw new UnsupportedOperationException("SIMD ASH block scoring supports bitsPerDimension 1, 2 and 4"); - } - return blockScorerFor(query, similarityFunction); - } - final AshBlockKernel kernel = AshBlockKernel.fromProperty(); final boolean supportsSimd = VectorizationProvider.getInstance().getVectorUtilSupport().supportsAshMaskedLoad(); if (kernel == AshBlockKernel.SIMD && !supportsSimd) { @@ -459,23 +450,20 @@ public ASHBlockScorer blockScorerFor( // Package-private prepared-state entry points shared by symmetric scoring. ScoreFunction.ApproximateScoreFunction scoreFunctionFor(ASHScorer.QueryPrecompute qp, boolean simd) { if (ash.bitsPerDimension == 1 && simd) { - return new SimdASHScoreFunction(new QueryPrecompute(qp.d, qp.C, qp.tildeQPool, - qp.sumTildeQByLandmark, qp.dotQMuByLandmark)); + return new SimdASHScoreFunction(new QueryPrecompute(qp.d, qp.C, qp.qProj, + qp.sumQProj, qp.dotQMuByLandmark)); } return nodeScoreFunction(scorer.codeScorerFor(qp, simd)); } ASHBlockScorer blockScorerFor(ASHScorer.QueryPrecompute prepared, int blockSize, boolean simd) { if (blockSize <= 0) throw new IllegalArgumentException("blockSize must be > 0"); - if (ash.bitsPerDimension == 2 || ash.bitsPerDimension == 4) { + if (ash.bitsPerDimension != 1) { FusedASHLayout.validateBlockSize(blockSize); return new LutASHBlockScorer(prepared, blockSize, simd); } - if (ash.bitsPerDimension != 1) { - return new LegacyASHBlockScorer(nodeScoreFunction(scorer.codeScorerFor(prepared, simd)), codes.size); - } - var qp = new QueryPrecompute(prepared.d, prepared.C, prepared.tildeQPool, - prepared.sumTildeQByLandmark, prepared.dotQMuByLandmark); + var qp = new QueryPrecompute(prepared.d, prepared.C, prepared.qProj, + prepared.sumQProj, prepared.dotQMuByLandmark); if (!simd) return new ScalarASHBlockScorer(codes, scales, offsets, landmarks, qp); ensurePackedBits(blockSize); return new SimdASHBlockScorer(codes.size, scales, offsets, landmarks, packedBits, blockSize, qp); @@ -613,8 +601,8 @@ private static final class ScalarASHBlockScorer implements ASHBlockScorer { private final float[] offsets; private final byte[] landmarks; - private final float[] tildeQPool; // [C * d] - private final float[] sumTildeQ; // [C] + private final float[] qProj; // [d] + private final float sumQProj; private final float[] dotQMu; // [C] private final int d; private final int words; @@ -635,8 +623,8 @@ private static final class ScalarASHBlockScorer implements ASHBlockScorer { this.d = qp.d; this.words = AsymmetricHashing.QuantizedVector.wordsForDims(d); - this.tildeQPool = qp.tildeQPool; - this.sumTildeQ = qp.sumTildeQ; + this.qProj = qp.qProj; + this.sumQProj = qp.sumQProj; this.dotQMu = qp.dotQMu; } @@ -653,11 +641,10 @@ public void scoreRange(int start, int count, float[] out) { final int idx = start + i; final int c = landmarks[idx] & 0xFF; // unsigned [0,C) - final int base = c * d; float maskedAdd = 0f; - // bit-walk against landmark-specific q̃ + // bit-walk against Aq; the landmark term is folded into the offset final long[] bits = codes.signPage(idx); final int bitsOffset = codes.signOffset(idx); int baseDim = 0; @@ -666,13 +653,13 @@ public void scoreRange(int start, int count, float[] out) { while (word != 0L) { int bit = Long.numberOfTrailingZeros(word); int j = baseDim + bit; - if (j < d) maskedAdd += tildeQPool[base + j]; + if (j < d) maskedAdd += qProj[j]; word &= (word - 1); } } out[i] = ASHScorer.toSimilarity( - scales[idx] * (2f * maskedAdd - sumTildeQ[c]) + scales[idx] * (2f * maskedAdd - sumQProj) + dotQMu[c] + offsets[idx]); } @@ -715,14 +702,10 @@ private static final class SimdASHBlockScorer implements ASHBlockScorer { private final float[] maskedAdds; // [blockSize] - private final float[] tildeQPool; // qp.tildeQPool [C*d] - private final float[] tildeQScratch; // [d] - private final float[] sumTildeQ; // qp.sumTildeQ [C] + private final float[] qProj; // qp.qProj [d] + private final float sumQProj; private final float[] dotQMu; // qp.dotQMu [C] - // Cache which landmark's q̃ is currently loaded into tildeQScratch - private int lastC = -1; - private final VectorUtilSupport vecUtil; SimdASHBlockScorer( @@ -749,13 +732,13 @@ private static final class SimdASHBlockScorer implements ASHBlockScorer { this.maskedAdds = new float[blockSize]; - this.tildeQPool = qp.tildeQPool; - this.tildeQScratch = new float[d]; - this.sumTildeQ = qp.sumTildeQ; + this.qProj = qp.qProj; + this.sumQProj = qp.sumQProj; this.dotQMu = qp.dotQMu; } - // SIMD kernel is invoked on homogeneous landmark runs; this method splits mixed ranges into runs. + // The masked add reads only Aq, so one kernel call covers each packed block's lanes in the range, + // whatever their landmarks; each lane adds its own landmark's . @Override public void scoreRange(int start, int count, float[] out) { if (count < 0) throw new IllegalArgumentException("count must be >= 0"); @@ -769,39 +752,13 @@ public void scoreRange(int start, int count, float[] out) { final int end = start + count; while (ord < end) { - // Identify landmark for this run - final int c = landmarks[ord] & 0xFF; - final int base = c * d; - - // Compute packed-block boundary info final int blockId = ord / blockSize; - final int blockBase = blockId * blockSize; - final int laneStart = ord - blockBase; - - // Max we can do without crossing packed-block boundary or request end - final int maxLen = Math.min(blockSize - laneStart, end - ord); - - // Clamp further so we do NOT cross a landmark boundary inside this packed block - int runLen = 1; - while (runLen < maxLen && ((landmarks[ord + runLen] & 0xFF) == c)) { - runLen++; - } - - assert runLen >= 1 && runLen <= maxLen; - assert laneStart >= 0 && laneStart + runLen <= blockSize; - - final float runSumTildeQ = sumTildeQ[c]; - final float runDotQMu = dotQMu[c]; + final int laneStart = ord - blockId * blockSize; + final int runLen = Math.min(blockSize - laneStart, end - ord); final int blockWordBase = blockId * words * blockSize; - // Load q̃_c into contiguous scratch for the unpooled kernel - if (c != lastC) { - System.arraycopy(tildeQPool, base, tildeQScratch, 0, d); - lastC = c; - } - vecUtil.ashMaskedAddBlockAllWords( - tildeQScratch, + qProj, d, packedBits, blockWordBase, @@ -814,11 +771,10 @@ public void scoreRange(int start, int count, float[] out) { for (int lane = 0; lane < runLen; lane++) { int idx = ord + lane; - float m = maskedAdds[lane]; out[idx - start] = ASHScorer.toSimilarity( // scale the 0/1 stored bits to +/-1 for processing. - scales[idx] * (2f * m - runSumTildeQ) - + runDotQMu + scales[idx] * (2f * maskedAdds[lane] - sumQProj) + + dotQMu[landmarks[idx] & 0xFF] + offsets[idx]); } @@ -845,42 +801,29 @@ private QueryPrecompute precomputeQuery(VectorFloat query) { // Aq final float[] qProj = new float[d]; ash.stiefelTransform.projectQuery(qArr, qProj); - float sumQProj = 0f; - for (float value : qProj) sumQProj += value; - - final float[] tildeQPool = new float[C * d]; - final float[] sumTildeQ = new float[C]; final float[] dotQMu = new float[C]; - for (int c = 0; c < C; c++) { dotQMu[c] = VectorUtil.dotProduct(query, ash.landmarks[c]); - sumTildeQ[c] = sumQProj - ash.landmarkProjSum[c]; - - final float[] muProj = ash.landmarkProj[c]; - final int base = c * d; - for (int j = 0; j < d; j++) { - tildeQPool[base + j] = qProj[j] - muProj[j]; - } } - return new QueryPrecompute(d, C, tildeQPool, sumTildeQ, dotQMu); + return new QueryPrecompute(d, C, qProj, ASHScorer.sum(qProj), dotQMu); } static final class QueryPrecompute { final int d; final int C; - final float[] tildeQPool; // [C * d] - final float[] sumTildeQ; // [C] + final float[] qProj; // [d], Aq + final float sumQProj; // final float[] dotQMu; // [C] QueryPrecompute(int d, int C, - float[] tildeQPool, - float[] sumTildeQ, + float[] qProj, + float sumQProj, float[] dotQMu) { this.d = d; this.C = C; - this.tildeQPool = tildeQPool; - this.sumTildeQ = sumTildeQ; + this.qProj = qProj; + this.sumQProj = sumQProj; this.dotQMu = dotQMu; } } diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/AsymmetricHashing.java b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/AsymmetricHashing.java index 9bc47dc28..a9521f590 100644 --- a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/AsymmetricHashing.java +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/AsymmetricHashing.java @@ -706,8 +706,68 @@ static boolean usesFastScanProjectionCode(int bitsPerDimension) { return bitsPerDimension == 2 || bitsPerDimension == 4; } - static boolean supportsFusedAshBits(int bitsPerDimension) { - return bitsPerDimension == 1 || bitsPerDimension == 2 || bitsPerDimension == 4; + /** + * Whether codes are offset-binary fields: dimension {@code j} is the unsigned {@code b}-bit field at bits + * {@code [j*b, (j+1)*b)} of the little-endian body, with value {@code field - (2^b - 1) / 2}. Every width + * except 1 bit (sign words) and the 2/4-bit sign-magnitude projection codes uses this layout. + */ + static boolean usesOffsetBinaryCode(int bitsPerDimension) { + return bitsPerDimension != 1 && !usesFastScanProjectionCode(bitsPerDimension); + } + + /** Field {@code j} of an offset-binary code whose body starts at {@code codeOffset}. */ + static int offsetBinaryField(byte[] code, int codeOffset, int j, int bitsPerDimension) { + int bit = j * bitsPerDimension, at = codeOffset + (bit >>> 3), shift = bit & 7; + int window = code[at] & 0xFF; + if (shift + bitsPerDimension > 8) window |= (code[at + 1] & 0xFF) << 8; + if (shift + bitsPerDimension > 16) window |= (code[at + 2] & 0xFF) << 16; + return (window >>> shift) & ((1 << bitsPerDimension) - 1); + } + + /** ORs {@code field} into dimension {@code j} of a zeroed offset-binary code. */ + static void setOffsetBinaryField(byte[] code, int j, int bitsPerDimension, int field) { + int bit = j * bitsPerDimension; + for (int t = 0; t < bitsPerDimension; t++, bit++) { + if ((field & (1 << t)) != 0) code[bit >>> 3] |= (byte) (1 << (bit & 7)); + } + } + + /** The value of an offset-binary field: an odd multiple of one half, centered on zero. */ + static float decodeOffsetBinary(int field, int bitsPerDimension) { + return field - ((1 << bitsPerDimension) - 1) * 0.5f; + } + + /** {@code } for an offset-binary code starting at {@code codeOffset}. */ + static float dotOffsetBinaryCode(float[] v, byte[] code, int codeOffset, int quantizedDim, int bitsPerDimension) { + float sum = 0f; + for (int j = 0; j < quantizedDim; j++) { + sum += v[j] * decodeOffsetBinary(offsetBinaryField(code, codeOffset, j, bitsPerDimension), bitsPerDimension); + } + return sum; + } + + /** + * {@code }, the landmark term that every stored offset includes as + * {@code -scale * }, so that asymmetric scoring needs only {@code Aq}. + */ + float landmarkCodeDot(int landmark, long[] signs, int signOffset, byte[] body, int bodyOffset) { + float[] projected = landmarkProj[landmark]; + if (usesFastScanProjectionCode(bitsPerDimension)) { + return dotProjectionCode(projected, body, bodyOffset, quantizedDim, bitsPerDimension); + } + if (bitsPerDimension != 1) return dotOffsetBinaryCode(projected, body, bodyOffset, quantizedDim, bitsPerDimension); + // Signs s = 2b - 1, so = 2 * (sum of A mu over set bits) - sum(A mu). + float positive = 0f; + int words = QuantizedVector.wordsForDims(quantizedDim); + for (int w = 0, base = 0; w < words; w++, base += 64) { + long word = signs[signOffset + w]; + while (word != 0L) { + int j = base + Long.numberOfTrailingZeros(word); + if (j < quantizedDim) positive += projected[j]; + word &= word - 1; + } + } + return 2f * positive - landmarkProjSum[landmark]; } static int projectionDimsPerNibble(int bitsPerDimension) { @@ -825,15 +885,18 @@ static float dotProjectionCode(float[] projectedVector, byte[] projectionCode, i /** Header-calibrated correction for symmetric scoring, using the stored code/header. */ float centeredOffset(QuantizedVector vector) { - return centeredOffset(vector.scale, vector.offset, vector.landmark, vector.extraBits, 0); + return centeredOffset(vector.scale, vector.offset, vector.landmark, vector.binaryVector, 0, vector.extraBits, 0); } - /** As {@link #centeredOffset(QuantizedVector)} for a header and a body stored at {@code bodyOffset}. */ - float centeredOffset(float scale, float offset, byte landmark, byte[] body, int bodyOffset) { + /** + * As {@link #centeredOffset(QuantizedVector)} for a header with sign words at {@code signOffset} + * and a body at {@code bodyOffset}; only the array the bit width stores is read. + */ + float centeredOffset(float scale, float offset, byte landmark, long[] signs, int signOffset, + byte[] body, int bodyOffset) { int c = landmark & 255; if (c >= landmarkCount) throw new IllegalArgumentException("Invalid landmark " + c); - if (!usesFastScanProjectionCode(bitsPerDimension)) return offset; - return offset + scale * dotProjectionCode(landmarkProj[c], body, bodyOffset, quantizedDim, bitsPerDimension); + return offset + scale * landmarkCodeDot(c, signs, signOffset, body, bodyOffset); } // --------------------------------------------------------------------- @@ -925,9 +988,6 @@ private static float quantizeProjected(float[] proj, int bitsPerDimension, for (int j = 0; j < quantizedDim; j++) { float acc = proj[j]; projNorm2 += (double) acc * (double) acc; - if (!usesFastScanProjectionCode(bitsPerDimension) && acc >= 0.0f) { - QuantizedVector.setBit(outSignWords, j); - } } final double invProjNorm = 1.0 / Math.max(Math.sqrt(projNorm2), 1e-20); @@ -947,14 +1007,14 @@ private static float quantizeProjected(float[] proj, int bitsPerDimension, outExtraBits); } + // Offset-binary: magnitude index q is field 2^(b-1) + q when positive, 2^(b-1) - 1 - q otherwise. + final int half = 1 << exBits; double codeNorm2 = 0.0; for (int j = 0; j < quantizedDim; j++) { int q = Math.min((int) (t * absNorm[j] + K_EPS), maxCode); double mag = q + 0.5; codeNorm2 += mag * mag; - - int extraCode = (proj[j] < 0.0f) ? ((~q) & maxCode) : q; - QuantizedVector.writeExtraCode(outExtraBits, j, exBits, extraCode); + setOffsetBinaryField(outExtraBits, j, bitsPerDimension, proj[j] >= 0.0f ? half + q : half - 1 - q); } return (float) Math.sqrt(Math.max(codeNorm2, 1e-20)); @@ -1018,8 +1078,8 @@ public long ramBytesUsed() { public float scale; // ||x_i - μ_i*||_2 / ||encoded body||_2 public float offset; // offset_i = - ||μ_i*||^2 public byte landmark; // c_i*, unsigned [0, C) - public long[] binaryVector; // sign bits; bit=1 means positive code value - public byte[] extraBits; // packed extra magnitude/sign-complement bits for multibit ASH + public long[] binaryVector; // 1-bit codes only: sign bits; bit=1 means positive code value + public byte[] extraBits; // multibit codes only: packed fields (see usesOffsetBinaryCode) private QuantizedVector(float scale, float offset, @@ -1037,23 +1097,18 @@ public static int wordsForDims(int quantizedDim) { return (quantizedDim + 63) >>> 6; } + /** Sign words: only 1-bit codes store them. */ public static int signWordsForDims(int quantizedDim, int bitsPerDimension) { - return usesFastScanProjectionCode(bitsPerDimension) ? 0 : wordsForDims(quantizedDim); - } - - public static int extraBytesForDims(int quantizedDim, int bitsPerDimension) { - int exBits = bitsPerDimension - 1; - if (exBits <= 0) { - return 0; - } - return (quantizedDim * exBits + 7) >>> 3; + return bitsPerDimension == 1 ? wordsForDims(quantizedDim) : 0; } + /** Body bytes: the packed fields of every multibit code, none for 1-bit codes. */ public static int bodyBytesForDims(int quantizedDim, int bitsPerDimension) { + if (bitsPerDimension == 1) return 0; if (usesFastScanProjectionCode(bitsPerDimension)) { return projectionCodeBytesForDims(quantizedDim, bitsPerDimension); } - return extraBytesForDims(quantizedDim, bitsPerDimension); + return Math.toIntExact(((long) quantizedDim * bitsPerDimension + 7) >>> 3); } public static QuantizedVector createEmpty(int quantizedDim) { @@ -1232,29 +1287,6 @@ static boolean getBit(long[] words, int wordOffset, int bitIndex) { return ((words[wordOffset + (bitIndex >>> 6)] >>> (bitIndex & 63)) & 1L) != 0L; } - static void writeExtraCode(byte[] extraBits, int dim, int exBits, int code) { - int bitPos = dim * exBits; - for (int bit = 0; bit < exBits; bit++, bitPos++) { - if ((code & (1 << bit)) != 0) { - extraBits[bitPos >>> 3] |= (byte) (1 << (bitPos & 7)); - } - } - } - - static int readExtraCode(byte[] extraBits, int dim, int exBits) { - return readExtraCode(extraBits, 0, dim, exBits); - } - - /** As {@link #readExtraCode(byte[], int, int)} for extra bits starting at {@code byteOffset}. */ - static int readExtraCode(byte[] extraBits, int byteOffset, int dim, int exBits) { - int code = 0; - int bitPos = dim * exBits; - for (int bit = 0; bit < exBits; bit++, bitPos++) { - code |= ((extraBits[byteOffset + (bitPos >>> 3)] >>> (bitPos & 7)) & 1) << bit; - } - return code; - } - /** * Compares two ASH quantized vectors for exact encoded equality. * @@ -1483,13 +1515,14 @@ public StiefelTransform(RealMatrix W) { this.rows = quantizedDim; this.cols = originalDim; - // Explicitly materialize A = Wᵀ into row-major arrays. + // Explicitly materialize A = Wᵀ into row-major arrays. Models persist A in FP32, so AData holds + // those FP32 values: a trained model and its reloaded copy then derive identical landmark terms. this.AData = new double[quantizedDim][originalDim]; for (int i = 0; i < quantizedDim; i++) { final double[] row = this.AData[i]; for (int j = 0; j < originalDim; j++) { // A[i][j] = W[j][i] - row[j] = W.getEntry(j, i); + row[j] = (float) W.getEntry(j, i); } } @@ -1589,7 +1622,7 @@ public ASHVectors encodeAll(RandomAccessVectorValues ravv, ForkJoinPool simdExec // Encoded straight into the compact store; no per-vector objects are retained. final ASHCodes out = ASHCodes.allocate(n, quantizedDim, bitsPerDimension); // Fill while each encoded vector is hot; never reread the raw input for setup. - final float[] symmetricOffsets = usesFastScanProjectionCode(bitsPerDimension) ? new float[n] : null; + final float[] symmetricOffsets = new float[n]; final var ravvSupplier = ravv.threadLocalSupplier(); var tasks = new java.util.ArrayList>(parallelism); Throwable failure = null; @@ -1761,24 +1794,12 @@ private float finishEncoding(QuantizedVector dest, float residualNorm, float cod final float rawOffset = dest.offset; final float scale = codeNorm > 0f ? residualNorm / codeNorm : 0f; - float finalOffset = rawOffset; - float landmarkDot = 0f; - if (usesFastScanProjectionCode(bitsPerDimension)) { - // C++ projection-mode optimization: - // offset = - ||μ||² - scale * - // so query-time scoring can use Aq instead of A(q - μ). - landmarkDot = dotProjectionCode( - landmarkProj[c], - dest.extraBits, - quantizedDim, - bitsPerDimension); - finalOffset -= scale * landmarkDot; - } - + // Projection-mode offset: offset = - ||μ||² - scale * , so query-time + // scoring can use Aq instead of A(q - μ). A 1-bit code is its ±1 signs. + final float landmarkDot = landmarkCodeDot(c, dest.binaryVector, 0, dest.extraBits, 0); dest.scale = roundToFloat16(scale); - dest.offset = roundToFloat16(finalOffset); - return usesFastScanProjectionCode(bitsPerDimension) - ? dest.offset + dest.scale * landmarkDot : dest.offset; + dest.offset = roundToFloat16(rawOffset - scale * landmarkDot); + return dest.offset + dest.scale * landmarkDot; } // --------------------------------------------------------------------- diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/FusedASHBlockPacker.java b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/FusedASHBlockPacker.java index 81eeec244..17c5fe9c1 100644 --- a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/FusedASHBlockPacker.java +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/FusedASHBlockPacker.java @@ -36,7 +36,7 @@ private FusedASHBlockPacker() {} public static void pack(byte[] destination, int offset, AsymmetricHashing.QuantizedVector[] vectors, int first, int count, int dimension, int bits, int blockSize) { - if (bits == 1) { + if (bits == 1 || FusedASHLayout.usesFieldBlocks(bits)) { for (int lane = 0; lane < count; lane++) { FusedASHLayout.packQuantizedVector(destination, offset, lane, vectors[first + lane], dimension, bits, blockSize); diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/FusedASHLayout.java b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/FusedASHLayout.java index 5e80b74d7..e24c758fa 100644 --- a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/FusedASHLayout.java +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/FusedASHLayout.java @@ -43,6 +43,16 @@ * {@code 8}, {@code 16}, and {@code 32} without copying the IVF-specific B32 * addressing scheme.

    * + *

    Offset-binary codes (3 and 5 to 9 bits per dimension) use field blocks instead, scored with an + * INT8 query: each unit of eight dimensions holds every lane's {@code b} code bytes contiguously,

    + * + *
    + *   byteIndex = blockOffset + (unit * blockSize + lane) * b + k,   k in [0, b)
    + * 
    + * + *

    so one 64-bit word per lane holds that unit's eight fields. Both layouts are followed by the + * same per-lane headers.

    + * *

    Block and neighborhood byte-size calculations use checked arithmetic. Shapes whose * serialized storage cannot fit a Java array fail with {@link ArithmeticException}.

    */ @@ -54,14 +64,20 @@ private FusedASHLayout() {} public static final int LANDMARK_BYTES = Byte.BYTES; public static final int HEADER_BYTES_PER_LANE = AsymmetricHashing.HEADER_BYTES; + /** + * Every ASH width is supported. Each lane's canonical code is a bit stream scanned in 4-bit + * groups: 1-bit signs and 2/4-bit sign-magnitude fields fill whole groups, and the offset-binary + * fields of the other widths are linear in their bits, so any group of 4 bits has a 16-entry + * query table. + */ public static boolean supportsBitsPerDimension(int bitsPerDimension) { - return bitsPerDimension == 1 || bitsPerDimension == 2 || bitsPerDimension == 4; + return bitsPerDimension >= 1 && bitsPerDimension <= 9; } public static void validateBitsPerDimension(int bitsPerDimension) { if (!supportsBitsPerDimension(bitsPerDimension)) { throw new IllegalArgumentException( - "FusedASH supports bitsPerDimension in {1,2,4}; got " + bitsPerDimension); + "FusedASH supports bitsPerDimension 1 through 9; got " + bitsPerDimension); } } @@ -81,28 +97,78 @@ public static int chooseBlockSize(int maxDegree) { return 32; } + /** Dimensions per 4-bit group for the widths whose fields fill whole groups: 1, 2 and 4 bits. */ public static int projectionDimsPerNibble(int bitsPerDimension) { - validateBitsPerDimension(bitsPerDimension); + if (bitsPerDimension != 1 && bitsPerDimension != 2 && bitsPerDimension != 4) { + throw new IllegalArgumentException("Whole-nibble fields require 1, 2 or 4 bits; got " + bitsPerDimension); + } return 4 / bitsPerDimension; } + /** 4-bit groups in one canonical code: {@code ceil(quantizedDim * bits / 4)}. */ public static int codeGroups(int quantizedDim, int bitsPerDimension) { if (quantizedDim <= 0) { throw new IllegalArgumentException("quantizedDim must be > 0"); } - int groupDims = projectionDimsPerNibble(bitsPerDimension); - return (quantizedDim - 1) / groupDims + 1; + validateBitsPerDimension(bitsPerDimension); + return Math.toIntExact(((long) quantizedDim * bitsPerDimension + 3) / 4); } public static int canonicalCodeBytes(int quantizedDim, int bitsPerDimension) { return (codeGroups(quantizedDim, bitsPerDimension) - 1) / 2 + 1; } + /** + * Whether the graph neighborhood body uses field blocks: offset-binary codes (3 and 5 to 9 bits) are stored + * as {@code [unit][lane][bits bytes]}, where a unit is eight dimensions, so each lane's eight fields are + * contiguous for INT8 dot products. Other widths store byte {@code p} of every lane's code in row {@code p}. + */ + public static boolean usesFieldBlocks(int bitsPerDimension) { + return AsymmetricHashing.usesOffsetBinaryCode(bitsPerDimension); + } + + /** Eight-dimension units in a field-block code. */ + public static int fieldUnits(int quantizedDim) { + if (quantizedDim <= 0) { + throw new IllegalArgumentException("quantizedDim must be > 0"); + } + return (quantizedDim - 1) / 8 + 1; + } + + /** Body bytes of one graph neighborhood block, in this width's layout. */ public static int blockBodyBytes(int quantizedDim, int bitsPerDimension, int blockSize) { + validateBlockSize(blockSize); + if (usesFieldBlocks(bitsPerDimension)) { + return Math.multiplyExact(Math.multiplyExact(fieldUnits(quantizedDim), bitsPerDimension), blockSize); + } + return nibbleBlockBodyBytes(quantizedDim, bitsPerDimension, blockSize); + } + + /** Body bytes of one block whose rows hold byte {@code p} of every lane's code (the LUT layout). */ + public static int nibbleBlockBodyBytes(int quantizedDim, int bitsPerDimension, int blockSize) { validateBlockSize(blockSize); return Math.multiplyExact(canonicalCodeBytes(quantizedDim, bitsPerDimension), blockSize); } + /** + * Copies one lane's canonical code out of a graph neighborhood block into {@code dest}, which must hold + * {@link #canonicalCodeBytes} bytes. For 1-bit codes these are the packed sign nibbles. + */ + public static void readLaneCode(byte[] block, int blockOffset, int lane, int quantizedDim, int bitsPerDimension, + int blockSize, byte[] dest) { + validateLane(lane, blockSize); + int bytes = canonicalCodeBytes(quantizedDim, bitsPerDimension); + if (usesFieldBlocks(bitsPerDimension)) { + int b = bitsPerDimension; + for (int i = 0; i < bytes; i++) { + int unit = i / b; + dest[i] = block[blockOffset + (unit * blockSize + lane) * b + i % b]; + } + return; + } + for (int i = 0; i < bytes; i++) dest[i] = block[blockOffset + i * blockSize + lane]; + } + public static int blockHeaderBytes(int blockSize) { validateBlockSize(blockSize); return HEADER_BYTES_PER_LANE * blockSize; @@ -174,8 +240,9 @@ public static void setFlatNibble(byte[] code, int group, int nibble) { : ((old & 0x0F) | (n << 4))); } + /** Value of a 1-bit sign or a 2/4-bit sign-magnitude field. */ public static float decodeProjectionComponent(int field, int bitsPerDimension) { - validateBitsPerDimension(bitsPerDimension); + projectionDimsPerNibble(bitsPerDimension); if (bitsPerDimension == 1) { return field != 0 ? 1.0f : -1.0f; } @@ -217,6 +284,21 @@ public static void packQuantizedVector( setPackedNibble(block, blockOffset, lane, group, blockSize, signNibbleFromWords(vector.binaryVector, group, quantizedDim)); } + } else if (usesFieldBlocks(bitsPerDimension)) { + byte[] code = vector.extraBits; + int bytes = canonicalCodeBytes(quantizedDim, bitsPerDimension); + if (code == null || code.length < bytes) { + throw new IllegalArgumentException("ASH projection code is missing or too short"); + } + // Bytes past the canonical code, in the last unit, are zero padding. + int b = bitsPerDimension, units = fieldUnits(quantizedDim); + for (int unit = 0; unit < units; unit++) { + int destination = blockOffset + (unit * blockSize + lane) * b; + for (int k = 0; k < b; k++) { + int i = unit * b + k; + block[destination + k] = i < bytes ? code[i] : 0; + } + } } else { byte[] code = vector.extraBits; int bytes = canonicalCodeBytes(quantizedDim, bitsPerDimension); @@ -240,10 +322,9 @@ public static void packQuantizedVector( /** * Packs one canonical ASH vector into the graph's fused neighborhood representation. * - *

    The one-bit canonical scorer uses {@code A(q - mu)}, whereas fused scoring uses - * {@code Aq} for every bit width. Only one-bit offsets therefore need the adjustment - * {@code offset - scale * dot(A(mu), code)}. Two/four-bit offsets already contain it. - * Keeping this conversion here makes benchmark fixtures use the graph writer's exact bytes.

    + *

    Fused scoring uses {@code Aq} for every bit width. Canonical 1/2/4-bit offsets already + * contain the landmark adjustment {@code -scale * dot(A(mu), code)}, so the header is copied as + * stored. Benchmark fixtures call this to use the graph writer's exact bytes.

    * * @param block destination containing a complete serialized block * @param blockOffset byte offset of that block @@ -262,16 +343,6 @@ public static void packForFused(byte[] block, int blockOffset, int lane, } packQuantizedVector(block, blockOffset, lane, vector, ash.quantizedDim, ash.bitsPerDimension, blockSize); - if (ash.bitsPerDimension == 1) { - float landmarkDot = 0f; - for (int d = 0; d < ash.quantizedDim; d++) { - float projected = ash.landmarkProj[landmark][d]; - boolean positive = ((vector.binaryVector[d >>> 6] >>> (d & 63)) & 1L) != 0; - landmarkDot += positive ? projected : -projected; - } - writeLaneHeader(block, blockOffset, lane, ash.quantizedDim, 1, blockSize, - vector.scale, vector.offset - vector.scale * landmarkDot, vector.landmark); - } } public static void writeLaneHeader( @@ -306,6 +377,10 @@ public static int readLandmark(byte[] block, int blockOffset, int lane, int quan } public static void buildQueryLut(float[] qProj, int quantizedDim, int bitsPerDimension, float[] lut) { + if (AsymmetricHashing.usesOffsetBinaryCode(bitsPerDimension)) { + buildOffsetBinaryLut(qProj, quantizedDim, bitsPerDimension, lut); + return; + } validateBitsPerDimension(bitsPerDimension); int groupDims = projectionDimsPerNibble(bitsPerDimension); int groups = codeGroups(quantizedDim, bitsPerDimension); @@ -350,6 +425,35 @@ public static void buildQueryLut(float[] qProj, int quantizedDim, int bitsPerDim } } + /** + * Table for offset-binary codes. Group {@code k} covers code bits {@code 4k..4k+3}; bit {@code i} is + * bit {@code i % b} of dimension {@code i / b} and adds {@code 2^(i % b) * q[i / b]}. Each dimension's + * centering term {@code -(2^b - 1)/2 * q[j]} is added to every entry of the group holding its bit 0, + * so each lookup sum includes it exactly once. + */ + private static void buildOffsetBinaryLut(float[] qProj, int quantizedDim, int bits, float[] lut) { + int groups = codeGroups(quantizedDim, bits); + if (qProj.length < quantizedDim) { + throw new IllegalArgumentException("qProj length is smaller than quantizedDim"); + } + if (lut.length < groups * 16) { + throw new IllegalArgumentException("lut length is smaller than groups * 16"); + } + float center = ((1 << bits) - 1) * 0.5f; + float[] weight = new float[4]; + for (int group = 0; group < groups; group++) { + float base = 0f; + for (int slot = 0; slot < 4; slot++) { + int bit = 4 * group + slot, dim = bit / bits, power = bit % bits; + weight[slot] = dim < quantizedDim ? qProj[dim] * (float) (1 << power) : 0f; + if (dim < quantizedDim && power == 0) base -= center * qProj[dim]; + } + int at = group << 4; + lut[at] = base; + for (int n = 1; n < 16; n++) lut[at + n] = lut[at + (n & (n - 1))] + weight[Integer.numberOfTrailingZeros(n)]; + } + } + private static final float[] DECODE_4BIT = new float[16]; static { for (int field = 0; field < 16; field++) DECODE_4BIT[field] = decodeProjectionComponent(field, 4); 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 bbfc820ba..fd88339a4 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 @@ -547,38 +547,6 @@ public float nvqUniformLoss(VectorFloat vector, float minValue, float maxValu return squaredSum; } - @Override - public float ashMaskedAddFlat(float[] tildeQ, - int qOffset, - long[] allPackedVectors, - int packedBase, - int d, - int words) { - float sum = 0.0f; - int baseDim = 0; - - for (int w = 0; w < words; w++) { - long word = allPackedVectors[packedBase + w]; - - // Standard bit-twiddling iteration (iterate only set bits) - while (word != 0L) { - int bit = Long.numberOfTrailingZeros(word); - int idx = baseDim + bit; - - // Bounds check required for the last word - if (idx < d) { - sum += tildeQ[qOffset + idx]; - } - - // Clear the lowest set bit - word &= (word - 1); - } - - baseDim += 64; - } - return sum; - } - @Override public float ashMaskedAdd_512(float[] tildeQ, int qOffset, diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/vector/VectorUtilSupport.java b/jvector-base/src/main/java/io/github/jbellis/jvector/vector/VectorUtilSupport.java index 170a63b89..a9f856b6e 100644 --- a/jvector-base/src/main/java/io/github/jbellis/jvector/vector/VectorUtilSupport.java +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/vector/VectorUtilSupport.java @@ -186,6 +186,36 @@ default void ashScoreNeighborhood(byte[] codes, int groups, int blockSize, int c throw new UnsupportedOperationException("Native ASH neighborhood scoring is unavailable"); } + /** Whether {@link #ashScoreNeighborhoodInt8} is implemented. */ + default boolean supportsAshInt8NeighborhoodScoring() { return false; } + + /** + * Scores one complete C=1 FusedASH neighborhood of offset-binary codes (3 and 5 to 9 bits) with an + * INT8 query. Each block's body is {@code [unit][lane][bits bytes]} for {@code units} units of + * eight dimensions, followed by the usual headers. Per lane, + * {@code dot = ( - center) * invScale}, and the result is + * {@code max(0, (1 + scale * dot + landmarkDot + offset) / 2)}. + * + * @param codes complete packed neighborhood, with full serialized storage for its final block + * @param bits bits per dimension: 3, 5, 6, 7, 8 or 9 + * @param units positive number of eight-dimension units + * @param blockSize serialized block width: 8, 16, or 32 + * @param capacity positive number of lanes to score, including graph padding + * @param q INT8 query, {@code units * 8} entries + * @param invScale reciprocal of the query quantization scale + * @param center {@code (2^bits - 1)/2 * sum(q)} + * @param landmarkDot query dot product with the single landmark + * @param out receives {@code capacity} similarities; later elements are left unchanged + * @throws UnsupportedOperationException if this provider has no implementation + * @throws IllegalArgumentException for an invalid shape + * @throws IndexOutOfBoundsException if an array is too small + * @throws IllegalStateException if a scored lane contains a nonzero landmark ID + */ + default void ashScoreNeighborhoodInt8(byte[] codes, int bits, int units, int blockSize, int capacity, + byte[] q, float invScale, float center, float landmarkDot, float[] out) { + throw new UnsupportedOperationException("Native INT8 ASH neighborhood scoring is unavailable"); + } + /** Calculates the dot product of the given float arrays. */ float dotProduct(VectorFloat a, VectorFloat b); @@ -622,45 +652,6 @@ default float ashDotRow(float[] Arow, float[] x) { return acc; } - /** - * Computes maskedAdd for a single vector stored in packed *by-vector* form. - * Returns: - * {@code maskedAdd = } - * - * packedBits layout: - * packedBits[packedBase + w] is 64-bit word w for this vector. - * - * Default implementation is scalar bit-walk with a register accumulator. - * SIMD backends may override. - */ - default float ashMaskedAddAllWords( - float[] tildeQ, - int d, - long[] packedBits, - int packedBase, // ord * nWords (by-vector contiguous) - int nWords - ) { - float maskedAdd = 0f; // register accumulator (critical!) - - for (int w = 0; w < nWords; w++) { - long word = packedBits[packedBase + w]; - int baseDim = w * 64; - - // Walk set bits (fast when sparse; matches your block fallback style) - while (word != 0L) { - int bit = Long.numberOfTrailingZeros(word); - int idx = baseDim + bit; - if (idx < d) maskedAdd += tildeQ[idx]; - word &= (word - 1); - } - } - - return maskedAdd; - } - - // In VectorUtilSupport interface - float ashMaskedAddFlat(float[] tildeQ, int qOffset, long[] allPackedVectors, int packedBase, int d, int words); - // In VectorUtilSupport interface float ashMaskedAdd_512(float[] tildeQ, int qOffset, long[] allPackedVectors, int packedBase, int d, int words); @@ -709,56 +700,6 @@ default void ashMaskedAddBlockAllWords( } } - /** - * Computes maskedAdd for a block slice using a pooled landmark-specific query buffer. - * - *

    This is identical to {@link #ashMaskedAddBlockAllWords(float[], int, long[], int, int, int, int, int, float[])} - * except that the query vector is read from {@code tildeQPool} at offsets {@code tildeBase + j}.

    - * - *

    tildeQPool layout: - *

      - *
    • {@code tildeQPool[tildeBase + j]} is q̃_c[j] for the caller-selected landmark c
    • - *
    - * - *

    Default implementation is scalar bit-walk with register accumulation, reading from the pool.

    - */ - default void ashMaskedAddBlockAllWordsPooled( - float[] tildeQPool, - int tildeBase, // base offset into tildeQPool for this landmark (e.g., c * d) - int d, - long[] packedBits, - int packedBase, // blockId * nWords * blockSize - int nWords, - int blockSize, - int laneStart, - int blockLen, - float[] outMaskedAdd - ) { - assert tildeBase >= 0; - assert laneStart >= 0; - assert blockLen >= 0; - assert laneStart + blockLen <= blockSize; - assert outMaskedAdd.length >= blockLen : "outMaskedAdd too small"; - - for (int lane = 0; lane < blockLen; lane++) { - float maskedAdd = 0f; // register accumulator - int laneIndex = laneStart + lane; - - for (int w = 0; w < nWords; w++) { - long word = packedBits[packedBase + (w * blockSize) + laneIndex]; - int baseDim = w * 64; - - while (word != 0L) { - int bit = Long.numberOfTrailingZeros(word); - int j = baseDim + bit; - if (j < d) maskedAdd += tildeQPool[tildeBase + j]; - word &= (word - 1); - } - } - - outMaskedAdd[lane] = maskedAdd; - } - } /** * Optionally performs the existing coarse/fine NVQ growth-rate search in one * call. Returns NaN when unsupported; the caller then uses its Java search. diff --git a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/ASHNeighborhoodValidator.java b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/ASHNeighborhoodValidator.java index 98bc6b4f2..796c73e7d 100644 --- a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/ASHNeighborhoodValidator.java +++ b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/ASHNeighborhoodValidator.java @@ -25,6 +25,7 @@ * Untimed decoded-component oracle, independent of the scoring LUT and block-scoring implementation. * It uses the model's selected query precision and checks the serialized graph headers. * Projection arithmetic is covered separately by the provider's FP32 and FP16 oracle tests. + * Offset-binary (3 and 5 to 9 bit) neighborhoods score an INT8-rounded query, which the oracle applies too. * The tolerance permits FP32 reduction-order differences, without introducing LUT quantization. */ final class ASHNeighborhoodValidator { @@ -46,6 +47,16 @@ static void validate(ASHNeighborhoodFixture fixture) { } float[] projected = new float[fixture.ash.quantizedDim]; fixture.ash.stiefelTransform.projectQuery(input, projected); + if (FusedASHLayout.usesFieldBlocks(fixture.ash.bitsPerDimension)) { + // Offset-binary neighborhoods score an INT8 query: round to 127 / max|Aq| steps, as the format does. + float max = 0f; + for (float v : projected) max = Math.max(max, Math.abs(v)); + if (max > 0f) { + for (int d = 0; d < projected.length; d++) { + projected[d] = Math.max(-127, Math.min(127, Math.round(projected[d] * (127f / max)))) * (max / 127f); + } + } + } float[] scores = new float[fixture.capacity]; for (int n = 0; n < neighborhoods; n++) { int index = (int) ((long) n * fixture.neighborhoods.length / neighborhoods); @@ -56,12 +67,18 @@ static void validate(ASHNeighborhoodFixture fixture) { i / fixture.blockSize, projected.length, fixture.ash.bitsPerDimension, fixture.blockSize); int lane = i % fixture.blockSize, bits = fixture.ash.bitsPerDimension; double dot = 0; + byte[] code = new byte[FusedASHLayout.canonicalCodeBytes(projected.length, bits)]; + FusedASHLayout.readLaneCode(packed, offset, lane, projected.length, bits, fixture.blockSize, code); for (int d = 0; d < projected.length; d++) { - int perNibble = 4 / bits; - int nibble = - FusedASHLayout.getPackedNibble(packed, offset, lane, d / perNibble, fixture.blockSize); - int field = nibble >>> ((d % perNibble) * bits) & ((1 << bits) - 1); - dot += (double) projected[d] * FusedASHLayout.decodeProjectionComponent(field, bits); + // Field d is bits [d*b, (d+1)*b) of the lane's canonical code. + int field = 0; + for (int k = 0; k < bits; k++) { + int bit = d * bits + k; + field |= ((code[bit >>> 3] >>> (bit & 7)) & 1) << k; + } + dot += (double) projected[d] * (bits == 1 || bits == 2 || bits == 4 + ? FusedASHLayout.decodeProjectionComponent(field, bits) + : field - ((1 << bits) - 1) / 2.0); } float expected = ASHScorer.toSimilarity((float) (FusedASHLayout.readScale(packed, offset, lane, projected.length, bits, fixture.blockSize) diff --git a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/DistancesASH.java b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/DistancesASH.java index bacdc4b68..3eaf58304 100644 --- a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/DistancesASH.java +++ b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/DistancesASH.java @@ -46,8 +46,8 @@ *

    The compressor is set by {@code jvector.ash.bitsPerDimension} (1 to 9, default 4), * {@code jvector.ash.quantizedDimensions} (default half the input dimension), * {@code jvector.ash.landmarkCount} (default 1) and {@code jvector.ash.optimizer} ({@code random} or - * {@code itq}). Every width uses the production scorers: 1, 2 and 4 bits have SIMD kernels, and the - * other widths use the portable scalar scorers. Measurements are selected with {@code jvector.bench.} + * {@code itq}). Every width uses the production scorers: 1-bit codes use masked adds, and the other + * widths sum 4-bit groups of their codes through a query table. Measurements are selected with {@code jvector.bench.} * properties: {@code recall} (exhaustive recall against the ground truth; add * {@code recall.block-scoring} to rank with block scoring), {@code single-scoring} and * {@code block-scoring} (throughput), {@code symmetric-scoring} (code-to-code scoring), @@ -212,13 +212,13 @@ private static void addRecall(int[] ranked, int[] groundTruth, int[] newToOld, private static double symmetricComponent(AsymmetricHashing.QuantizedVector v, int j, int bits) { if (bits == 1) return ((v.binaryVector[j / 64] >>> (j % 64)) & 1L) == 0 ? -1 : 1; if (bits != 2 && bits != 4) { - int code = 0; - for (int k = 0; k < bits - 1; k++) { - int pos = j * (bits - 1) + k; - code |= ((v.extraBits[pos / 8] >>> (pos % 8)) & 1) << k; + // Offset-binary fields: bits [j*b, (j+1)*b) little-endian, value field - (2^b - 1) / 2. + int field = 0; + for (int k = 0; k < bits; k++) { + int pos = j * bits + k; + field |= ((v.extraBits[pos / 8] >>> (pos % 8)) & 1) << k; } - code |= ((v.binaryVector[j / 64] >>> (j % 64)) & 1L) << (bits - 1); - return code - ((1 << (bits - 1)) - 0.5); + return field - ((1 << bits) - 1) / 2.0; } int field = (v.extraBits[j * bits / 8] >>> (j * bits % 8)) & ((1 << bits) - 1); double magnitude = (field & ((1 << (bits - 1)) - 1)) + 0.5; @@ -229,20 +229,16 @@ private static double decodedSymmetric(AsymmetricHashing ash, AsymmetricHashing.QuantizedVector a, AsymmetricHashing.QuantizedVector b) { int ca = a.landmark & 255, cb = b.landmark & 255; - double dot = 0, ownA = 0, ownB = 0, crossA = 0, crossB = 0; + double dot = 0, crossA = 0, crossB = 0; for (int j = 0; j < ash.quantizedDim; j++) { double x = symmetricComponent(a, j, ash.bitsPerDimension); double y = symmetricComponent(b, j, ash.bitsPerDimension); dot += x * y; - ownA += x * ash.landmarkProj[ca][j]; - ownB += y * ash.landmarkProj[cb][j]; crossA += x * ash.landmarkProj[cb][j]; crossB += y * ash.landmarkProj[ca][j]; } + // Stored offsets already include each code's own landmark term. double correctionA = a.offset, correctionB = b.offset; - if (ash.bitsPerDimension != 2 && ash.bitsPerDimension != 4) { - correctionA -= a.scale * ownA; correctionB -= b.scale * ownB; - } double centroidDot = 0; for (int j = 0; j < ash.originalDimension; j++) { centroidDot += (double) ash.landmarks[ca].get(j) * ash.landmarks[cb].get(j); @@ -300,7 +296,7 @@ private static void benchmarkSymmetric(ASHVectors vectors, List> && (ash.landmarkCount == 1 || (ash.bitsPerDimension == 1 ? backend.supportsAshMaskedLoad() : backend.supportsAshProjectionScoring())); boolean hasBlockSimd = ash.bitsPerDimension == 1 ? backend.supportsAshMaskedLoad() - : (ash.bitsPerDimension == 2 || ash.bitsPerDimension == 4) && backend.supportsAshLutScoring(); + : backend.supportsAshLutScoring(); List names = new ArrayList<>(); List> factories = new ArrayList<>(); names.add("symmetric-single-scalar"); diff --git a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/util/CompressorParameters.java b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/util/CompressorParameters.java index cfab41f2d..b2fcfa3c4 100644 --- a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/util/CompressorParameters.java +++ b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/util/CompressorParameters.java @@ -182,9 +182,6 @@ public void validateFor(DataSet ds, boolean fused, String ashConstructionMode) { if (landmarkCount < 1 || landmarkCount > 256 || landmarkCount > ds.getBaseRavv().size()) { throw new IllegalArgumentException("ASH landmarkCount must be between 1 and 256 and fit the training set"); } - if (fused && bitsPerDimension != 1 && bitsPerDimension != 2 && bitsPerDimension != 4) { - throw new IllegalArgumentException("Fused ASH requires 1, 2, or 4 bits per dimension"); - } if (ashConstructionMode != null) { switch (ashConstructionMode) { case "symmetric": diff --git a/jvector-native/src/main/java/io/github/jbellis/jvector/vector/NativeASHKernels.java b/jvector-native/src/main/java/io/github/jbellis/jvector/vector/NativeASHKernels.java index d6d5a4289..6e511516f 100644 --- a/jvector-native/src/main/java/io/github/jbellis/jvector/vector/NativeASHKernels.java +++ b/jvector-native/src/main/java/io/github/jbellis/jvector/vector/NativeASHKernels.java @@ -238,6 +238,78 @@ private static int invoke(MethodHandle handle, MemorySegment codes, MemorySegmen } } + private static final String INT8_SYMBOL = "ash_score_neighborhood_i8_c1"; + private static final MemorySegment ADDRESS_OF_INT8 = SymbolLookup.loaderLookup().find(INT8_SYMBOL).orElse(null); + private static final FunctionDescriptor INT8_DESCRIPTOR = FunctionDescriptor.of(JAVA_INT, ADDRESS, ADDRESS, + JAVA_FLOAT, JAVA_FLOAT, JAVA_FLOAT, JAVA_INT, JAVA_INT, JAVA_INT, JAVA_INT, ADDRESS); + private static final MethodHandle CRITICAL_INT8 = ADDRESS_OF_INT8 == null ? null + : Linker.nativeLinker().downcallHandle(ADDRESS_OF_INT8, INT8_DESCRIPTOR, Linker.Option.critical(true)); + private static final MethodHandle ORDINARY_INT8 = ADDRESS_OF_INT8 == null ? null + : Linker.nativeLinker().downcallHandle(ADDRESS_OF_INT8, INT8_DESCRIPTOR); + + /** Whether the library scores offset-binary neighborhoods with an INT8 query. */ + static boolean isInt8NeighborhoodAvailable() { return ADDRESS_OF_INT8 != null; } + + /** + * Scores an offset-binary neighborhood ({@code [unit][lane][bits bytes]} body per block, then the + * headers) with an INT8 query of {@code units * 8} entries. + */ + static void scoreNeighborhoodInt8(byte[] codes, int bits, int units, int blockSize, int capacity, + byte[] q, float invScale, float center, float landmarkDot, float[] out) { + if (!isInt8NeighborhoodAvailable()) { + throw new UnsupportedOperationException("Native library does not provide " + INT8_SYMBOL); + } + Objects.requireNonNull(codes, "codes"); + Objects.requireNonNull(q, "q"); + Objects.requireNonNull(out, "out"); + if (units <= 0 || capacity <= 0 || bits < 3 || bits > 9 || bits == 4 + || (blockSize != 8 && blockSize != 16 && blockSize != 32)) { + throw new IllegalArgumentException("Expected offset-binary bits, positive units/capacity and blockSize in {8,16,32}"); + } + long blockCount = (capacity - 1L) / blockSize + 1; + long packedBytes = blockCount * (((long) units * bits + HEADER_BYTES_PER_LANE) * blockSize); + long queryBytes = (long) units * 8; + if (packedBytes > codes.length || queryBytes > q.length || capacity > out.length) { + throw new IndexOutOfBoundsException("ASH arrays are smaller than the configured neighborhood"); + } + int status; + if ((long) units * blockCount * blockSize <= MAX_CRITICAL_LANE_GROUPS) { + // These heap segments are valid only for this synchronous, short leaf call. + status = invokeInt8(CRITICAL_INT8, MemorySegment.ofArray(codes), MemorySegment.ofArray(q), + invScale, center, landmarkDot, bits, units, blockSize, capacity, MemorySegment.ofArray(out)); + } else { + try (Arena arena = Arena.ofConfined()) { + MemorySegment nativeCodes = arena.allocate(packedBytes); + MemorySegment nativeQuery = arena.allocate(queryBytes); + MemorySegment nativeOut = arena.allocate((long) capacity * Float.BYTES, Float.BYTES); + MemorySegment.copy(MemorySegment.ofArray(codes), 0, nativeCodes, 0, packedBytes); + MemorySegment.copy(MemorySegment.ofArray(q), 0, nativeQuery, 0, queryBytes); + status = invokeInt8(ORDINARY_INT8, nativeCodes, nativeQuery, invScale, center, landmarkDot, + bits, units, blockSize, capacity, nativeOut); + if (status == 0) { + MemorySegment.copy(nativeOut, 0, MemorySegment.ofArray(out), 0, (long) capacity * Float.BYTES); + } + } + } + checkStatus(status); + } + + private static int invokeInt8(MethodHandle handle, MemorySegment codes, MemorySegment q, + float invScale, float center, float landmarkDot, int bits, int units, int blockSize, int capacity, + MemorySegment out) { + if (TRACE_DOWNCALLS) { + System.out.printf("%s(%d, %d, %d, %d)%n", INT8_SYMBOL, bits, units, blockSize, capacity); + } + try { + return (int) handle.invokeExact(codes, q, invScale, center, landmarkDot, bits, units, blockSize, + capacity, out); + } catch (RuntimeException | Error error) { + throw error; + } catch (Throwable error) { + throw new AssertionError("Unexpected checked exception from " + INT8_SYMBOL, error); + } + } + private static void checkStatus(int status) { // These values are the C ABI contract documented in jvector_simd_kernel_list.h. switch (status) { 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 f3e6cdc5e..e8affe405 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 @@ -188,7 +188,8 @@ public String ashKernelDescription() { return super.ashKernelDescription() + "; C=1 whole-neighborhood=" + neighborhood + "; dense projection=" + (NativeASHProjection.isAvailable() ? "Highway" : "Java") + "; symmetric pair=" + (NativeASHSymmetricKernels.hasPairKernel() ? "Highway" : "Java") - + "; symmetric LUT=" + (NativeASHSymmetricKernels.hasLutKernel() ? "Highway" : "Java"); + + "; symmetric LUT=" + (NativeASHSymmetricKernels.hasLutKernel() ? "Highway" : "Java") + + "; INT8 offset-binary neighborhood=" + (supportsAshInt8NeighborhoodScoring() ? "Highway" : "Java"); } @Override @@ -197,6 +198,18 @@ public void ashScoreNeighborhood(byte[] codes, int groups, int blockSize, int ca NativeASHKernels.scoreNeighborhood(codes, groups, blockSize, capacity, lut, landmarkDot, out); } + @Override + public boolean supportsAshInt8NeighborhoodScoring() { + return NativeASHKernels.isInt8NeighborhoodAvailable(); + } + + @Override + public void ashScoreNeighborhoodInt8(byte[] codes, int bits, int units, int blockSize, int capacity, + byte[] q, float invScale, float center, float landmarkDot, float[] out) { + NativeASHKernels.scoreNeighborhoodInt8(codes, bits, units, blockSize, capacity, q, invScale, center, + landmarkDot, out); + } + @Override protected FloatVector fromVectorFloat(VectorSpecies SPEC, VectorFloat vector, int offset) { return FloatVector.fromMemorySegment(SPEC, ((MemorySegmentVectorFloat) vector).get(), vector.offset(offset), ByteOrder.LITTLE_ENDIAN); diff --git a/jvector-native/src/main/native/src/ash_neighborhood-inl.h b/jvector-native/src/main/native/src/ash_neighborhood-inl.h index a3b87c744..f025a74db 100644 --- a/jvector-native/src/main/native/src/ash_neighborhood-inl.h +++ b/jvector-native/src/main/native/src/ash_neighborhood-inl.h @@ -126,6 +126,7 @@ HWY_INLINE int AshBlock32(D d, const unsigned char *codes, const float *lut, int } // namespace +#ifndef JVECTOR_ASH_INT8_NEIGHBORHOOD_ONLY // Canonical vectors pack consecutive groups into low/high nibbles of each byte. // Gather from the same query LUT as the neighborhood kernel, without gathering // projected query coordinates or repeatedly decoding signed-magnitude components. @@ -210,6 +211,159 @@ int ash_score_neighborhood_c1_f32(const unsigned char *HWY_RESTRICT codes, const return kAshSuccess; } +#endif // JVECTOR_ASH_INT8_NEIGHBORHOOD_ONLY + +namespace { +// Offset-binary neighborhoods (3 and 5 to 9 bits). The body is [unit][lane][bits bytes]: one unit is +// eight dimensions, so each lane's eight fields fill exactly `bits` bytes. A vector group holds one +// 64-bit word per lane. Bytes are gathered into each lane's word, every field is rotated into its own +// byte, and products with an INT8 query digit accumulate four dimensions per 32-bit lane. +constexpr int kAshInt8MaxBits = 9; + +// Eight consecutive query digits as one 64-bit word, for broadcasting to every lane's word. +HWY_INLINE uint64_t AshQueryWord(const int8_t *digits) { + uint64_t word; + hwy::CopyBytes<8>(digits, &word); + return word; +} + +// Per-lane exact sums: each lane's two 32-bit halves hold dimensions 0-3 and 4-7 of every unit. +template +HWY_INLINE hn::VFromD> AshLaneSums(D64 d64, D32 /*d32*/, hn::VFromD acc) { + const hn::RebindToSigned di64; + const hn::Rebind di32; + const auto wide = hn::BitCast(di64, acc); + const auto sum = hn::Add(hn::ShiftRight<32>(hn::ShiftLeft<32>(wide)), hn::ShiftRight<32>(wide)); + return hn::ConvertTo(hn::Rebind(), hn::DemoteTo(di32, sum)); +} + +// Unpacks one lane group's fields for one unit and accumulates their products with the query word. +// Nine-bit fields add their top bits to `top`, which the caller scales by 256. +template +HWY_INLINE void AshAccumulateFields(D64 d64, D8 d8, D32 d32, int bits, size_t lanes, const unsigned char *src, + VI8 qv, Idx idxA, Idx idxB, hn::VFromD rotLo, + hn::VFromD rotHi, hn::VFromD lowMask, hn::MFromD lastField, + hn::VFromD &acc, hn::VFromD &top) { + if (bits <= 8) { + // A group needs lanes * bits <= Lanes(d8) bytes; later bytes are ignored, and the header after the + // body keeps the full-width load inside the block. + auto fields = hn::LoadU(d8, src); + if (bits < 8) { + fields = hn::TableLookupLanes(fields, idxA); + fields = hn::And(hn::BitCast(d8, hn::MultiRotateRight(hn::BitCast(d64, fields), rotLo)), lowMask); + } + acc = hn::SumOfMulQuadAccumulate(d32, fields, qv, acc); + return; + } + // lanes * 9 bytes; the second table may extend past the block, so load it bounded. + const size_t span = lanes * static_cast(bits); + const auto t0 = hn::LoadN(d8, src, std::min(span, hn::Lanes(d8))); + const auto t1 = hn::LoadN(d8, src + hn::Lanes(d8), span > hn::Lanes(d8) ? span - hn::Lanes(d8) : 0); + const auto viewA = hn::BitCast(d64, hn::TwoTablesLookupLanes(d8, t0, t1, idxA)); + const auto viewB = hn::BitCast(d64, hn::TwoTablesLookupLanes(d8, t0, t1, idxB)); + const auto low = hn::IfThenElse(lastField, hn::BitCast(d8, hn::MultiRotateRight(viewB, rotLo)), + hn::BitCast(d8, hn::MultiRotateRight(viewA, rotLo))); + const auto high = hn::And(hn::IfThenElse(lastField, hn::BitCast(d8, hn::MultiRotateRight(viewB, rotHi)), + hn::BitCast(d8, hn::MultiRotateRight(viewA, rotHi))), + hn::Set(d8, uint8_t{1})); + acc = hn::SumOfMulQuadAccumulate(d32, low, qv, acc); + top = hn::SumOfMulQuadAccumulate(d32, high, qv, top); +} +} // namespace + +// Whole-neighborhood C=1 ASH for offset-binary codes with an INT8 query: per lane, +// dot = ( - center) * invScale, then the header terms and similarity. q holds units * 8 entries +// (zero past the projected dimension). Returns kAshSuccess, kAshInvalidLandmark or kAshInvalidShape. +int ash_score_neighborhood_i8_c1(const unsigned char *HWY_RESTRICT codes, const int8_t *HWY_RESTRICT q, + float invScale, float center, float landmarkDot, int bits, int units, + int blockSize, int capacity, float *HWY_RESTRICT out) { + if (codes == nullptr || q == nullptr || out == nullptr || units <= 0 || capacity <= 0 || bits < 3 || + bits > kAshInt8MaxBits || bits == 4 || (blockSize != 8 && blockSize != 16 && blockSize != 32)) { + return kAshInvalidShape; + } + // At most eight 64-bit lanes per group, so groups divide every block size. + const hn::CappedTag d64; + const hn::Repartition d8; + const hn::Repartition di8; + const hn::Repartition d32; + const hn::Rebind df; + const size_t lanes = hn::Lanes(d64); + const size_t b = static_cast(bits); + const size_t rowBytes = b * static_cast(blockSize); + const size_t bodyBytes = static_cast(units) * rowBytes; + const size_t blockBytes = bodyBytes + kAshHeaderBytesPerLane * blockSize; + + // Gather and rotation controls. Lane l's bytes start at l * bits; field k starts at bit k * bits. + // Nine-bit fields span nine bytes per lane: two eight-byte views (bytes 0-7 and 1-8) supply the low + // byte and top bit of fields 0-6 and of field 7 respectively. + HWY_ALIGN uint8_t gatherA[64], gatherB[64], rotateLo[64], rotateHi[64]; + for (size_t l = 0; l < lanes; l++) { + for (size_t k = 0; k < 8; k++) { + const size_t i = 8 * l + k; + if (bits <= 8) { + gatherA[i] = static_cast(l * b + std::min(k, b - 1)); + gatherB[i] = gatherA[i]; + rotateLo[i] = static_cast(k * b); + rotateHi[i] = 0; + } else { + gatherA[i] = static_cast(l * b + k); + gatherB[i] = static_cast(l * b + 1 + k); + rotateLo[i] = static_cast(k < 7 ? k * b : 55); + rotateHi[i] = static_cast(k < 7 ? k * b + 8 : 63); + } + } + } + const auto lowMask = hn::Set(d8, static_cast(bits >= 8 ? 0xFF : (1u << bits) - 1)); + const auto rotLo = hn::Load(d8, rotateLo), rotHi = hn::Load(d8, rotateHi); + // Byte 7 of every lane's word takes field 7 from the shifted view. + const auto lastField = hn::Eq(hn::And(hn::Iota(d8, 0), hn::Set(d8, uint8_t{7})), hn::Set(d8, uint8_t{7})); + const auto idxA = hn::IndicesFromVec(d8, hn::Load(d8, gatherA)); + const auto idxB = hn::IndicesFromVec(d8, hn::Load(d8, gatherB)); + + // Up to four lane groups share each pass over a block: every unit row is read once, contiguously, and + // its query word is broadcast once. With eight 64-bit lanes, one pass covers a 32-lane block. + const size_t groupBytes = lanes * b; + for (size_t base = 0; base < static_cast(capacity); base += blockSize, codes += blockBytes) { + const int count = std::min(blockSize, capacity - static_cast(base)); + const size_t groups = (static_cast(count) + lanes - 1) / lanes; + for (size_t first = 0; first < groups; first += 4) { + const size_t n = std::min(4, groups - first); + auto a0 = hn::Zero(d32), a1 = hn::Zero(d32), a2 = hn::Zero(d32), a3 = hn::Zero(d32); + auto t0 = hn::Zero(d32), t1 = hn::Zero(d32), t2 = hn::Zero(d32), t3 = hn::Zero(d32); + const unsigned char *row = codes + first * groupBytes; + for (int u = 0; u < units; u++, row += rowBytes) { + const auto qv = hn::BitCast(di8, hn::Set(d64, AshQueryWord(q + 8 * u))); + AshAccumulateFields(d64, d8, d32, bits, lanes, row, qv, idxA, idxB, rotLo, rotHi, lowMask, lastField, + a0, t0); + if (n > 1) + AshAccumulateFields(d64, d8, d32, bits, lanes, row + groupBytes, qv, idxA, idxB, rotLo, rotHi, + lowMask, lastField, a1, t1); + if (n > 2) + AshAccumulateFields(d64, d8, d32, bits, lanes, row + 2 * groupBytes, qv, idxA, idxB, rotLo, + rotHi, lowMask, lastField, a2, t2); + if (n > 3) + AshAccumulateFields(d64, d8, d32, bits, lanes, row + 3 * groupBytes, qv, idxA, idxB, rotLo, + rotHi, lowMask, lastField, a3, t3); + } + // Vectors are passed by value (no arrays), which scalable-vector targets require. + const auto finish = [&](size_t i, hn::VFromD sum, hn::VFromD top) -> int { + const auto acc = bits > 8 ? hn::Add(sum, hn::ShiftLeft<8>(top)) : sum; + const auto dot = hn::Mul(hn::Sub(AshLaneSums(d64, d32, acc), hn::Set(df, center)), hn::Set(df, invScale)); + const int lane = static_cast((first + i) * lanes); + const int active = std::min(static_cast(lanes), count - lane); + return AshFinish(df, codes + bodyBytes, blockSize, lane, active, landmarkDot, dot, out + base + lane); + }; + int status = finish(0, a0, t0); + if (!status && n > 1) status = finish(1, a1, t1); + if (!status && n > 2) status = finish(2, a2, t2); + if (!status && n > 3) status = finish(3, a3, t3); + if (status) return status; + } + } + return kAshSuccess; +} + +#ifndef JVECTOR_ASH_INT8_NEIGHBORHOOD_ONLY // A 4-bit nibble represents one coordinate, so a full query LUT is 16 times // larger than the projection. Decode consecutive coordinates in registers and // keep the projection access contiguous instead of gathering a large table. @@ -264,5 +418,6 @@ float ash_projection_dot_4bit_f32(const float *HWY_RESTRICT query, } return sum; } +#endif // JVECTOR_ASH_INT8_NEIGHBORHOOD_ONLY #endif // JVECTOR_ASH_NEIGHBORHOOD_INL_H diff --git a/jvector-native/src/main/native/src/jvector_avx3_dl_kernels.cpp b/jvector-native/src/main/native/src/jvector_avx3_dl_kernels.cpp index 95f61317d..9f7b597b9 100644 --- a/jvector-native/src/main/native/src/jvector_avx3_dl_kernels.cpp +++ b/jvector-native/src/main/native/src/jvector_avx3_dl_kernels.cpp @@ -38,6 +38,10 @@ namespace hn = hwy::HWY_NAMESPACE; namespace AVX3_DL { #define JVECTOR_ASH_PAIR_ONLY #include "ash_symmetric-inl.h" +// The INT8 neighborhood kernel needs VBMI byte permutes and multishifts and VNNI dot products. +#define JVECTOR_ASH_INT8_NEIGHBORHOOD_ONLY +#include "ash_neighborhood-inl.h" +#undef JVECTOR_ASH_INT8_NEIGHBORHOOD_ONLY #undef JVECTOR_ASH_PAIR_ONLY } // namespace AVX3_DL diff --git a/jvector-native/src/main/native/src/jvector_simd.cpp b/jvector-native/src/main/native/src/jvector_simd.cpp index 34bc2ecc8..ab687f474 100644 --- a/jvector-native/src/main/native/src/jvector_simd.cpp +++ b/jvector-native/src/main/native/src/jvector_simd.cpp @@ -113,6 +113,7 @@ static const KernelVTable AVX3_vtable = { static const KernelVTable AVX3_DL_vtable = []() { KernelVTable t = AVX3_vtable; t.ash_symmetric_dot = AVX3_DL::ash_symmetric_dot; + t.ash_score_neighborhood_i8_c1 = AVX3_DL::ash_score_neighborhood_i8_c1; return t; }(); diff --git a/jvector-native/src/main/native/src/jvector_simd_kernel_list.h b/jvector-native/src/main/native/src/jvector_simd_kernel_list.h index 9215262cf..933e1886d 100644 --- a/jvector-native/src/main/native/src/jvector_simd_kernel_list.h +++ b/jvector-native/src/main/native/src/jvector_simd_kernel_list.h @@ -90,6 +90,8 @@ KERNEL_ENTRY(float, ash_projection_dot_4bit_f32, (const float *query, const unsigned char *code, int dimensions), (query, code, dimensions)) \ /* Whole-neighborhood C=1 ASH, one downcall including headers and similarity. */ \ KERNEL_ENTRY(int, ash_score_neighborhood_c1_f32, (const unsigned char *codes, const float *lut, float landmarkDot, int groups, int blockSize, int capacity, float *out), (codes, lut, landmarkDot, groups, blockSize, capacity, out)) \ + /* Whole-neighborhood C=1 ASH for offset-binary codes (3, 5-9 bits) with an INT8 query. */ \ + KERNEL_ENTRY(int, ash_score_neighborhood_i8_c1, (const unsigned char *codes, const int8_t *q, float invScale, float center, float landmarkDot, int bits, int units, int blockSize, int capacity, float *out), (codes, q, invScale, center, landmarkDot, bits, units, blockSize, capacity, out)) \ /* NVQ kernels */ \ KERNEL_ENTRY(void, nvq_quantize_8bit, (const float *vector, size_t length, float alpha, float x0, float minValue, float maxValue, unsigned char *destination), (vector, length, alpha, x0, minValue, maxValue, destination)) \ KERNEL_ENTRY(float, nvq_loss, (const float *vector, size_t length, float alpha, float x0, float minValue, float maxValue, int nBits), (vector, length, alpha, x0, minValue, maxValue, nBits)) \ diff --git a/jvector-tests/src/test/java/io/github/jbellis/jvector/example/TestASHNeighborhoodBenchmark.java b/jvector-tests/src/test/java/io/github/jbellis/jvector/example/TestASHNeighborhoodBenchmark.java index 3b9f4ab73..c025b2487 100644 --- a/jvector-tests/src/test/java/io/github/jbellis/jvector/example/TestASHNeighborhoodBenchmark.java +++ b/jvector-tests/src/test/java/io/github/jbellis/jvector/example/TestASHNeighborhoodBenchmark.java @@ -43,7 +43,7 @@ public void serializedNeighborhoodsMatchOracleAcrossWidthsAndTails() throws Exce vectors.add(vector); } var values = new ListRandomAccessVectorValues(vectors, 64); - for (int bits : new int[] {1, 2, 4}) { + for (int bits : new int[] {1, 2, 3, 4}) { var ash = AsymmetricHashing.initialize( values, AsymmetricHashing.RANDOM, AsymmetricHashing.HEADER_BITS + 33 * bits, 1, bits); ASHVectors encoded = (ASHVectors) ash.encodeAll(values); diff --git a/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestASHBatchEncoding.java b/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestASHBatchEncoding.java index 8f3e565d7..368074154 100644 --- a/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestASHBatchEncoding.java +++ b/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestASHBatchEncoding.java @@ -57,23 +57,21 @@ private static void assertCode(AsymmetricHashing.QuantizedVector expected, assertArrayEquals(expected.extraBits, actual.extraBits); } - private static void assertHighPrecisionCodeCompatible(AsymmetricHashing.QuantizedVector expected, - AsymmetricHashing.QuantizedVector actual, - int dimensions) { + private static void assertHighPrecisionCodeCompatible(AsymmetricHashing ash, + AsymmetricHashing.QuantizedVector expected, + AsymmetricHashing.QuantizedVector actual) { assertEquals(expected.landmark, actual.landmark); - assertEquals(expected.offset, actual.offset, 0f); - assertArrayEquals(expected.binaryVector, actual.binaryVector); - // Different FP32 reduction orders can cross a 9-bit code boundary. - // Check the reconstructed component within one step from each code, - // rather than requiring bit equality of that discontinuous quantizer. - for (int d = 0; d < dimensions; d++) { - boolean positive = AsymmetricHashing.QuantizedVector.getBit(expected.binaryVector, d); - int e = AsymmetricHashing.QuantizedVector.readExtraCode(expected.extraBits, d, 8); - int a = AsymmetricHashing.QuantizedVector.readExtraCode(actual.extraBits, d, 8); - if (!positive) { e = (~e) & 255; a = (~a) & 255; } - assertEquals(expected.scale * (e + .5), actual.scale * (a + .5), - expected.scale + actual.scale + 1e-7); + // Different FP32 reduction orders can cross a 9-bit code boundary. Check each reconstructed + // component within one step, rather than requiring bit equality of that discontinuous quantizer. + // The stored offset folds in -scale * , so compare the code-independent centered offset. + int bits = ash.bitsPerDimension; + for (int d = 0; d < ash.quantizedDim; d++) { + float e = AsymmetricHashing.decodeOffsetBinary(AsymmetricHashing.offsetBinaryField(expected.extraBits, 0, d, bits), bits); + float a = AsymmetricHashing.decodeOffsetBinary(AsymmetricHashing.offsetBinaryField(actual.extraBits, 0, d, bits), bits); + assertEquals(expected.scale * e, actual.scale * a, expected.scale + actual.scale + 1e-7); } + float e = ash.centeredOffset(expected), a = ash.centeredOffset(actual); + assertEquals(e, a, 2e-3f * Math.max(1f, Math.abs(e))); } private static final class Values implements RandomAccessVectorValues { @@ -132,10 +130,10 @@ public void bulkMatchesSingleForTailsMissingValuesAndBorrowedStorage() throws Ex // 1/2/3/4-bit checks; use the existing reconstruction // error bound for discontinuous 9-bit codes/half scales. if (bits == 9 && input[i] != null) - assertHighPrecisionCodeCompatible(firstBulkResult.get(i), actual.get(i), ash.quantizedDim); + assertHighPrecisionCodeCompatible(ash, firstBulkResult.get(i), actual.get(i)); else assertCode(firstBulkResult.get(i), actual.get(i)); if (bits == 9 && input[i] != null) - assertHighPrecisionCodeCompatible(expected, actual.get(i), ash.quantizedDim); + assertHighPrecisionCodeCompatible(ash, expected, actual.get(i)); else assertCode(expected, actual.get(i)); } catch (AssertionError failure) { @@ -153,7 +151,7 @@ public void bulkMatchesSingleForTailsMissingValuesAndBorrowedStorage() throws Ex @Test public void correctionsAreComputedInTheEncodingPassAndSharedWithoutChangingCodesOrScores() throws Exception { - for (int bits : new int[]{2,4}) for (int landmarks : new int[]{1,3}) { + for (int bits : new int[]{1,2,3,4,9}) for (int landmarks : new int[]{1,3}) { var ash = model(bits, landmarks); var input = vectors(97); input[31] = null; @@ -168,9 +166,14 @@ public void correctionsAreComputedInTheEncodingPassAndSharedWithoutChangingCodes AsymmetricHashing.QuantizedVector[] codes = new AsymmetricHashing.QuantizedVector[input.length]; for (int i = 0; i < codes.length; i++) { codes[i] = input[i] == null ? AsymmetricHashing.QuantizedVector.createEmpty(ash.quantizedDim, bits) : ash.encode(input[i]); - assertCode(codes[i], bulk.get(i)); - float expected = bulk.get(i).offset + bulk.get(i).scale * AsymmetricHashing.dotProjectionCode( - ash.landmarkProj[bulk.get(i).landmark & 255], bulk.get(i).extraBits, ash.quantizedDim, bits); + if (bits == 9 && input[i] != null) { + // Singleton and batch FP32 reductions can cross a 9-bit code boundary; score the batch codes. + assertHighPrecisionCodeCompatible(ash, codes[i], bulk.get(i)); + codes[i] = bulk.get(i); + } else { + assertCode(codes[i], bulk.get(i)); + } + float expected = ash.centeredOffset(bulk.get(i)); assertEquals(Float.floatToRawIntBits(expected), Float.floatToRawIntBits(bulk.symmetricOffsets[i])); } var uncached = new ASHVectors(ash, codes); diff --git a/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestASHGraphSearch.java b/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestASHGraphSearch.java index 4a4c663eb..804a166b3 100644 --- a/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestASHGraphSearch.java +++ b/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestASHGraphSearch.java @@ -61,7 +61,7 @@ public void negativeEntryReachesBaseLayerWithStandaloneAndFusedScoring() throws @Override public int getDimension() { return 64; } }; boolean simd = provider.getVectorUtilSupport().supportsAshLutScoring(); - for (int bits : new int[]{1, 2, 4}) { + for (int bits : new int[]{1, 2, 3, 4, 9}) { var ash = AsymmetricHashing.initialize(values, AsymmetricHashing.RANDOM, AsymmetricHashing.HEADER_BITS + 33 * bits, 1, bits); var encoded = ash.encodeAll(values, ForkJoinPool.commonPool()); diff --git a/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestASHInt8Neighborhood.java b/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestASHInt8Neighborhood.java new file mode 100644 index 000000000..7c610a3b2 --- /dev/null +++ b/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestASHInt8Neighborhood.java @@ -0,0 +1,132 @@ +/* + * 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 io.github.jbellis.jvector.vector.VectorizationProvider; +import org.junit.Test; + +import java.util.Arrays; +import java.util.Random; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertThrows; + +/** Offset-binary field-block neighborhoods scored with an INT8 query, in Java and natively. */ +public class TestASHInt8Neighborhood { + private static final int[] WIDTHS = {3, 5, 6, 7, 8, 9}; + + private static AsymmetricHashing.QuantizedVector randomCode(Random random, int dimension, int bits) throws Exception { + var constructor = AsymmetricHashing.QuantizedVector.class.getDeclaredConstructor( + float.class, float.class, byte.class, long[].class, byte[].class); + constructor.setAccessible(true); + byte[] code = new byte[FusedASHLayout.canonicalCodeBytes(dimension, bits)]; + for (int j = 0; j < dimension; j++) { + AsymmetricHashing.setOffsetBinaryField(code, j, bits, random.nextInt(1 << bits)); + } + return constructor.newInstance(0.5f + random.nextFloat(), random.nextFloat() - 0.5f, (byte) 0, + new long[0], code); + } + + @Test + public void javaInt8MatchesExactDotsAndNativeMatchesJava() throws Exception { + var backend = VectorizationProvider.getInstance().getVectorUtilSupport(); + Random random = new Random(20261111); + for (int bits : WIDTHS) { + for (int dimension : new int[]{1, 7, 8, 9, 63, 64, 65, 192, 383}) { + for (int blockSize : new int[]{8, 16, 32}) { + int capacity = 2 * blockSize + 3; + int blocks = FusedASHLayout.blocksPerNode(capacity, blockSize); + byte[] packed = new byte[FusedASHLayout.featureSize(capacity, dimension, bits, blockSize)]; + var vectors = new AsymmetricHashing.QuantizedVector[blocks * blockSize]; + for (int i = 0; i < vectors.length; i++) { + vectors[i] = randomCode(random, dimension, bits); + int offset = FusedASHLayout.blockOffset(i / blockSize, dimension, bits, blockSize); + FusedASHLayout.packQuantizedVector(packed, offset, i % blockSize, vectors[i], dimension, bits, blockSize); + } + float[] q = new float[dimension]; + double maxAbs = 0; + for (int j = 0; j < dimension; j++) { + q[j] = (float) random.nextGaussian(); + maxAbs = Math.max(maxAbs, Math.abs(q[j])); + } + var query = new ASHInt8Query(dimension, bits); + query.prepare(q); + float landmarkDot = random.nextFloat() - 0.5f; + float[] java = new float[capacity + 2]; + Arrays.fill(java, -7f); + double center = ((1 << bits) - 1) / 2.0; + for (int i = 0; i < capacity; i++) { + int offset = FusedASHLayout.blockOffset(i / blockSize, dimension, bits, blockSize); + int lane = i % blockSize; + byte[] code = new byte[FusedASHLayout.canonicalCodeBytes(dimension, bits)]; + FusedASHLayout.readLaneCode(packed, offset, lane, dimension, bits, blockSize, code); + assertArrayEquals("lane code bits=" + bits + " d=" + dimension, vectors[i].extraBits, code); + double exact = 0, valueSum = 0; + for (int j = 0; j < dimension; j++) { + double value = AsymmetricHashing.offsetBinaryField(code, 0, j, bits) - center; + exact += q[j] * value; + valueSum += Math.abs(value); + } + float dot = query.laneDot(packed, offset, lane, blockSize); + // Each rounded component is within half a step, maxAbs / 254, of the query. + assertEquals("bits=" + bits + " d=" + dimension, exact, dot, + maxAbs / 254 * valueSum * 1.001 + 1e-5 * Math.max(1, Math.abs(exact))); + // The packed header holds FP16 scale and offset. + java[i] = ASHScorer.toSimilarity( + FusedASHLayout.readScale(packed, offset, lane, dimension, bits, blockSize) * dot + + landmarkDot + FusedASHLayout.readOffset(packed, offset, lane, dimension, bits, blockSize)); + } + if (!backend.supportsAshInt8NeighborhoodScoring()) continue; + float[] nativeOut = new float[capacity + 2]; + Arrays.fill(nativeOut, -7f); + backend.ashScoreNeighborhoodInt8(packed, bits, query.units, blockSize, capacity, + query.digits, query.invScale, query.center, landmarkDot, nativeOut); + for (int i = 0; i < capacity; i++) { + // Exact integer sums; the FP32 finish may round differently (no FMA below AVX2). + assertEquals("native bits=" + bits + " d=" + dimension + " bs=" + blockSize + " lane=" + i, + java[i], nativeOut[i], 1e-5f * Math.max(1f, Math.abs(java[i]))); + } + assertEquals("Lanes past the capacity stay untouched", -7f, nativeOut[capacity], 0f); + } + } + } + } + + @Test + public void nativeRejectsNonzeroLandmarksAndBadShapes() throws Exception { + var backend = VectorizationProvider.getInstance().getVectorUtilSupport(); + if (!backend.supportsAshInt8NeighborhoodScoring()) return; + Random random = new Random(5); + int dimension = 24, bits = 5, blockSize = 8; + byte[] packed = new byte[FusedASHLayout.featureSize(blockSize, dimension, bits, blockSize)]; + for (int lane = 0; lane < blockSize; lane++) { + var v = randomCode(random, dimension, bits); + if (lane == 3) v.landmark = 1; + FusedASHLayout.packQuantizedVector(packed, 0, lane, v, dimension, bits, blockSize); + } + var query = new ASHInt8Query(dimension, bits); + query.prepare(new float[]{1, -2, 3, 0.5f, 0, 0, 0, 0, 1, 1, 1, 1, 0, 0, 0, 0, 2, 2, 2, 2, 0, 0, 0, 1}); + float[] out = new float[blockSize]; + assertThrows(IllegalStateException.class, () -> backend.ashScoreNeighborhoodInt8(packed, bits, query.units, + blockSize, blockSize, query.digits, query.invScale, query.center, 0f, out)); + assertThrows(IllegalArgumentException.class, () -> backend.ashScoreNeighborhoodInt8(packed, 4, query.units, + blockSize, blockSize, query.digits, query.invScale, query.center, 0f, out)); + assertThrows(IndexOutOfBoundsException.class, () -> backend.ashScoreNeighborhoodInt8(packed, bits, + query.units + 1, blockSize, blockSize, query.digits, query.invScale, query.center, 0f, out)); + } +} diff --git a/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestASHLutScoring.java b/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestASHLutScoring.java index eeddc70f7..5d25f1567 100644 --- a/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestASHLutScoring.java +++ b/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestASHLutScoring.java @@ -66,7 +66,10 @@ public void packedBlocksMatchDecodedComponents() { assertTrue(backend.supportsAshProjectionScoring()); } Random random = new Random(87342); - for (int bits : new int[]{1, 2, 4}) { + for (int bits : new int[]{1, 2, 3, 4, 5, 8, 9}) { + boolean offsetBinary = bits != 1 && bits != 2 && bits != 4; + // Offset-binary entries carry weights up to 2^(b-1); scale the FP32 tolerance with them. + float widthScale = offsetBinary ? (1 << bits) : 1; for (int d : new int[]{1, 2, 3, 4, 7, 15, 16, 17, 31, 32, 33, 63, 64, 65, 127, 129, 384, 768, 769}) { int groups = FusedASHLayout.codeGroups(d, bits); float[] q = new float[d]; @@ -75,7 +78,7 @@ public void packedBlocksMatchDecodedComponents() { FusedASHLayout.buildQueryLut(q, d, bits, lut); for (int size : new int[]{8, 16, 32}) { int offset = 7; - byte[] packed = new byte[offset + FusedASHLayout.blockBodyBytes(d, bits, size)]; + byte[] packed = new byte[offset + FusedASHLayout.nibbleBlockBodyBytes(d, bits, size)]; random.nextBytes(packed); // Padding is deliberately nonzero. float[] expected = new float[size]; for (int lane = 0; lane < size; lane++) { @@ -86,12 +89,25 @@ public void packedBlocksMatchDecodedComponents() { FusedASHLayout.setFlatNibble(canonical, g, nibble); } for (int i = 0; i < d; i++) { + if (offsetBinary) { + // Offset-binary: field i is bits [i*b, (i+1)*b), value field - (2^b - 1)/2. + int field = 0; + for (int k = 0; k < bits; k++) { + int bit = i * bits + k; + field |= ((canonical[bit >>> 3] >>> (bit & 7)) & 1) << k; + } + expected[lane] += q[i] * (field - ((1 << bits) - 1) / 2f); + continue; + } int field = (canonical[i * bits / 8] & 255) >>> (i * bits % 8); int sign = 1 << (bits - 1); float magnitude = bits == 1 ? 1 : (field & (sign - 1)) + 0.5f; expected[lane] += q[i] * ((field & sign) == 0 ? -magnitude : magnitude); } - if (bits != 1) { + if (offsetBinary) { + assertEquals("canonical LUT bits=" + bits + " d=" + d, expected[lane], + backend.ashCanonicalLutDot(canonical, groups, lut), tolerance(expected[lane]) * widthScale); + } else if (bits != 1) { assertEquals("single bits=" + bits + " d=" + d, expected[lane], backend.usesAshProjectionTuning() ? backend.ashProjectionDotTuned(q, canonical, d, bits) @@ -109,8 +125,8 @@ public void packedBlocksMatchDecodedComponents() { assertTrue(Float.isNaN(actual[count + 2])); for (int i = 0; i < count; i++) { String label = "bits=" + bits + " d=" + d + " size=" + size + " lane=" + (start + i); - assertEquals(label, expected[start + i], actual[2 + i], tolerance(expected[start + i])); - assertEquals(label, expected[start + i], scalar[2 + i], tolerance(expected[start + i])); + assertEquals(label, expected[start + i], actual[2 + i], tolerance(expected[start + i]) * widthScale); + assertEquals(label, expected[start + i], scalar[2 + i], tolerance(expected[start + i]) * widthScale); } } } diff --git a/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestASHQueryPreparation.java b/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestASHQueryPreparation.java index 6aeede5a8..e578e6701 100644 --- a/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestASHQueryPreparation.java +++ b/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestASHQueryPreparation.java @@ -70,8 +70,9 @@ public void fastScanSkipsGenericTermsAndStandaloneScorersStayIndependent() throw var scorer = new ASHScorer(ash); var first = scorer.precomputeQuery(vectors[0]); boolean fastScan = bits == 2 || bits == 4; - assertEquals(fastScan ? 0 : 66, first.tildeQPool.length); - assertEquals(fastScan ? 0 : 2, first.sumTildeQByLandmark.length); + // Every width folds the landmark term into its offsets, so only Aq and its sum are prepared. + assertEquals(ash.quantizedDim, first.qProj.length); + assertEquals(ash.landmarkCount, first.dotQMuByLandmark.length); float[] projection = first.qProj.clone(); float[] landmarkDots = first.dotQMuByLandmark.clone(); // Publicly retained prepared state must never borrow another query's arrays. @@ -97,7 +98,7 @@ private static Object field(Object object, String name) throws Exception { @Test public void fusedScratchReusesArraysAndRejectsStaleDecoders() throws Exception { var vectors = inputs(); - for (int bits : new int[]{1, 2, 4}) { + for (int bits : new int[]{1, 2, 3, 4, 9}) { var ash = model(vectors, bits); var encoded = ash.encodeAll(MockVectorValues.fromValues(vectors), ForkJoinPool.commonPool()); int degree = 8, block = 8; @@ -168,7 +169,7 @@ public void fusedScratchReusesArraysAndRejectsStaleDecoders() throws Exception { @Test public void onDiskViewsOwnIndependentScratchOnSameThread() throws Exception { var vectors = inputs(); - for (int bits : new int[]{1, 2, 4}) { + for (int bits : new int[]{1, 2, 3, 4, 9}) { var ash = model(vectors, bits); var encoded = ash.encodeAll(MockVectorValues.fromValues(vectors), ForkJoinPool.commonPool()); var graph = new TestUtil.RandomlyConnectedGraphIndex(vectors.length, 8, new Random(9182)); diff --git a/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestASHScoringDispatch.java b/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestASHScoringDispatch.java index 0e104510a..937727205 100644 --- a/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestASHScoringDispatch.java +++ b/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestASHScoringDispatch.java @@ -172,13 +172,6 @@ private static void checkFused(AsymmetricHashing ash, ASHVectors vectors, int offset = FusedASHLayout.blockOffset(i / size, ash.quantizedDim, ash.bitsPerDimension, size); var v = vectors.get(i); FusedASHLayout.packQuantizedVector(bytes, offset, i % size, v, ash.quantizedDim, ash.bitsPerDimension, size); - if (ash.bitsPerDimension == 1) { - float landmarkDot = 0; - for (int d = 0; d < ash.quantizedDim; d++) landmarkDot += ash.landmarkProj[v.landmark & 255][d] - * (((v.binaryVector[d >>> 6] >>> (d & 63)) & 1) == 0 ? -1 : 1); - FusedASHLayout.writeLaneHeader(bytes, offset, i % size, ash.quantizedDim, 1, size, - v.scale, v.offset - v.scale * landmarkDot, v.landmark); - } } int[] reads = {0}; var source = new FusedASHDecoder.PackedNeighborhoods() { diff --git a/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestASHStandaloneVsFusedScoring.java b/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestASHStandaloneVsFusedScoring.java index a8666720e..4e22b59ea 100644 --- a/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestASHStandaloneVsFusedScoring.java +++ b/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestASHStandaloneVsFusedScoring.java @@ -226,13 +226,7 @@ private static void compareStandaloneAndFused(int bitsPerDimension) maximumDeltaFused); } - /** - * Mirrors FusedASH.packForFused(). - * - * Multibit vectors already contain the projection-mode adjusted offset. - * One-bit vectors require the same adjustment performed by - * FusedASH.packOneBitForFused(). - */ + /** Mirrors FusedASH.packForFused(): canonical 1/2/4-bit offsets already contain the landmark term. */ private static void packForFused( byte[] destination, int blockOffset, @@ -240,80 +234,8 @@ private static void packForFused( AsymmetricHashing.QuantizedVector vector, AsymmetricHashing ash, int blockSize) { - if (ash.bitsPerDimension == 1) { - packOneBitForFused( - destination, - blockOffset, - lane, - vector, - ash, - blockSize); - } else { - FusedASHLayout.packQuantizedVector( - destination, - blockOffset, - lane, - vector, - ash.quantizedDim, - ash.bitsPerDimension, - blockSize); - } - } - - /** - * Mirrors FusedASH.packOneBitForFused(). - */ - private static void packOneBitForFused( - byte[] destination, - int blockOffset, - int lane, - AsymmetricHashing.QuantizedVector vector, - AsymmetricHashing ash, - int blockSize) { - int groups = FusedASHLayout.codeGroups( - ash.quantizedDim, - ash.bitsPerDimension); - - for (int group = 0; group < groups; group++) { - int nibble = FusedASHLayout.signNibbleFromWords( - vector.binaryVector, - group, - ash.quantizedDim); - - FusedASHLayout.setPackedNibble( - destination, - blockOffset, - lane, - group, - blockSize, - nibble); - } - - int landmark = vector.landmark & 0xFF; - - assertTrue( - "Invalid landmark " + landmark - + " for landmarkCount=" + ash.landmarkCount, - landmark < ash.landmarkCount); - - float landmarkDot = dotOneBitProjectionCode( - ash.landmarkProj[landmark], - vector.binaryVector, - ash.quantizedDim); - - float adjustedOffset = - vector.offset - vector.scale * landmarkDot; - - FusedASHLayout.writeLaneHeader( - destination, - blockOffset, - lane, - ash.quantizedDim, - ash.bitsPerDimension, - blockSize, - vector.scale, - adjustedOffset, - vector.landmark); + FusedASHLayout.packQuantizedVector(destination, blockOffset, lane, vector, + ash.quantizedDim, ash.bitsPerDimension, blockSize); } private static float dotOneBitProjectionCode( diff --git a/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestASHSymmetricScorer.java b/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestASHSymmetricScorer.java index fdcaf44b6..bb5446e2d 100644 --- a/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestASHSymmetricScorer.java +++ b/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestASHSymmetricScorer.java @@ -38,13 +38,13 @@ private static float component(AsymmetricHashing.QuantizedVector v, int j, int b float magnitude = (field & ((1 << (bits - 1)) - 1)) + 0.5f; return (field & (1 << (bits - 1))) == 0 ? -magnitude : magnitude; } - int code = 0; - for (int k = 0; k < bits - 1; k++) { - int pos = j * (bits - 1) + k; - code |= ((v.extraBits[pos / 8] >>> (pos % 8)) & 1) << k; + // Offset-binary fields: bits [j*b, (j+1)*b) little-endian, value field - (2^b - 1) / 2. + int field = 0; + for (int k = 0; k < bits; k++) { + int pos = j * bits + k; + field |= ((v.extraBits[pos / 8] >>> (pos % 8)) & 1) << k; } - code |= ((v.binaryVector[j / 64] >>> (j % 64)) & 1) << (bits - 1); - return code - ((1 << (bits - 1)) - 0.5f); + return field - ((1 << bits) - 1) * 0.5f; } @Test public void packedDotMatchesIndependentDecoderAndSimdIncludingTails() { @@ -165,15 +165,14 @@ private void checkAppendixBReconstruction() throws Exception { ma += x * ash.landmarks[0].get(k); mb += y * ash.landmarks[0].get(k); } - double oa = a.offset, ob = b.offset; - if (bits == 2 || bits == 4) { oa += a.scale * ma; ob += b.scale * mb; } + // Stored offsets include -scale * at every width. + double oa = a.offset + a.scale * ma, ob = b.offset + b.scale * mb; double expected = a.scale * b.scale * dot + oa + ob + VectorUtil.dotProduct(ash.landmarks[0], ash.landmarks[0]); assertEquals(expected, scorer.dotProduct(i,j), 2e-5); // ASH preserves the original landmark dot via the header, rather than // substituting the reconstructed source's landmark dot. double sourceCorrection = a.offset; - if (bits != 2 && bits != 4) sourceCorrection -= a.scale * ma; float decodedAsymmetric = new ASHScorer(ash) .scoreFunctionFor(decodedX, VectorSimilarityFunction.DOT_PRODUCT).similarityTo(b); assertEquals(decodedAsymmetric + sourceCorrection, scorer.dotProduct(i,j), 2e-5); @@ -203,7 +202,7 @@ private void checkAppendixBReconstruction() throws Exception { try (var out = io.github.jbellis.jvector.disk.ByteBufferIndexWriter.create(1024*1024,false)) { encoded.write(out, io.github.jbellis.jvector.graph.disk.OnDiskGraphIndex.CURRENT_VERSION); var reloaded = ASHVectors.load(new io.github.jbellis.jvector.disk.ByteBufferReader(out.getWrittenData())); - assertEquals(scorer.dotProduct(2,7), new ASHSymmetricScorer(reloaded).dotProduct(2,7), 0f); + assertEquals("reloaded bits=" + bits, scorer.dotProduct(2,7), new ASHSymmetricScorer(reloaded).dotProduct(2,7), 0f); } } } @@ -234,7 +233,7 @@ private void checkAppendixBReconstruction() throws Exception { var input = inputs(80,40); for (int d = 0; d < 40; d++) input[1].set(d, -input[0].get(d)); var values = MockVectorValues.fromValues(input); - for (int bits : new int[]{1,2,4}) { + for (int bits : new int[]{1,2,3,4,9}) { var ash = AsymmetricHashing.initialize(values,AsymmetricHashing.RANDOM, AsymmetricHashing.HEADER_BITS+31*bits,1,bits); var encoded = ash.encodeAll(values, java.util.concurrent.ForkJoinPool.commonPool()); @@ -282,9 +281,6 @@ private void checkAppendixBReconstruction() throws Exception { * (ash.landmarks[cb].get(k) + b.scale*y); } double correctionA = a.offset, correctionB = b.offset; - if (bits != 2 && bits != 4) { - correctionA -= a.scale*pa; correctionB -= b.scale*pb; - } expected += correctionA + correctionB; assertEquals(expected, scalar.dotProduct(i,j), 2e-5); assertEquals(scalar.dotProduct(i,j), scalar.dotProduct(j,i), 0f); diff --git a/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestFusedASHBlockPacking.java b/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestFusedASHBlockPacking.java index c97228248..33fe45ca8 100644 --- a/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestFusedASHBlockPacking.java +++ b/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestFusedASHBlockPacking.java @@ -27,6 +27,19 @@ public class TestFusedASHBlockPacking { private static void reference(byte[] destination, int offset, int lane, AsymmetricHashing.QuantizedVector vector, int dimension, int bits, int blockSize) { + if (bits != 1 && bits != 2 && bits != 4) { + // Field blocks: unit u holds each lane's code bytes [u*b, (u+1)*b), zero past the code. + int units = (dimension + 7) / 8, bytes = FusedASHLayout.canonicalCodeBytes(dimension, bits); + for (int u = 0; u < units; u++) { + for (int k = 0; k < bits; k++) { + int i = u * bits + k; + destination[offset + (u * blockSize + lane) * bits + k] = i < bytes ? vector.extraBits[i] : 0; + } + } + FusedASHLayout.writeLaneHeader(destination, offset, lane, dimension, bits, blockSize, + vector.scale, vector.offset, vector.landmark); + return; + } int groups = FusedASHLayout.codeGroups(dimension, bits); for (int group = 0; group < groups; group++) { int nibble = bits == 1 @@ -51,7 +64,7 @@ public void testPackingMatchesOriginalLayout() throws Exception { int[] large = {383, 384, 385, 767, 768, 769, 1536, 3072, 3073}; System.arraycopy(large, 0, dimensions, 257, large.length); int cases = 0; - for (int bits : new int[]{1, 2, 4}) { + for (int bits : new int[]{1, 2, 3, 4, 9}) { for (int dimension : dimensions) { for (int blockSize : new int[]{8, 16, 32}) { var vectors = new AsymmetricHashing.QuantizedVector[blockSize + 3]; @@ -95,6 +108,6 @@ public void testPackingMatchesOriginalLayout() throws Exception { } } } - assertEquals(49_476, cases); + assertEquals(82_460, cases); } } diff --git a/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestFusedASHQueryLut.java b/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestFusedASHQueryLut.java index b06274dc4..a9012323c 100644 --- a/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestFusedASHQueryLut.java +++ b/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestFusedASHQueryLut.java @@ -67,4 +67,40 @@ public void matchesDefinitionForEveryWidthAndTail() { } } } + + /** Offset-binary widths: summing each 4-bit group's entry reproduces the decoded dot product. */ + @Test + public void offsetBinaryLutSumsMatchDecodedDots() { + Random random = new Random(11); + for (int bits : new int[] {3, 5, 6, 7, 8, 9}) { + for (int dim : new int[] {1, 2, 3, 5, 7, 8, 9, 63, 64, 65, 191, 192, 383}) { + float[] q = new float[dim + 3]; + for (int i = 0; i < q.length; i++) q[i] = (float) random.nextGaussian(); + int groups = FusedASHLayout.codeGroups(dim, bits); + float[] lut = new float[groups * 16]; + FusedASHLayout.buildQueryLut(q, dim, bits, lut); + for (int trial = 0; trial < 20; trial++) { + // Random fields, plus random padding bits past the last field that must be ignored. + byte[] code = new byte[(groups + 1) / 2]; + random.nextBytes(code); + int used = dim * bits; + for (int bit = used; bit < code.length * 8; bit++) code[bit >>> 3] &= (byte) ~(1 << (bit & 7)); + double expected = 0, scale = 0; + for (int j = 0; j < dim; j++) { + int field = AsymmetricHashing.offsetBinaryField(code, 0, j, bits); + double value = field - ((1 << bits) - 1) / 2.0; + expected += q[j] * value; + scale += Math.abs(q[j]) * ((1 << bits) - 1); + } + float actual = 0f; + for (int g = 0; g < groups; g++) { + int nibble = ((code[g >>> 1] & 0xFF) >>> ((g & 1) * 4)) & 15; + actual += lut[(g << 4) + nibble]; + } + assertEquals("bits=" + bits + " dim=" + dim, expected, actual, 1e-5 * Math.max(1, scale)); + assertEquals(expected, AsymmetricHashing.dotOffsetBinaryCode(q, code, 0, dim, bits), 1e-5 * Math.max(1, scale)); + } + } + } + } } diff --git a/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestPreparedASHQuery.java b/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestPreparedASHQuery.java index 00a2cf9db..f6cb1d32b 100644 --- a/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestPreparedASHQuery.java +++ b/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestPreparedASHQuery.java @@ -65,7 +65,7 @@ private static AsymmetricHashing model(VectorFloat[] input, int bits) throws @Test public void batchMatchesSinglePreparationForEveryFusedBitCountAndOwnsInputs() throws Exception { var values = inputs(97); - for (int bits : new int[]{1, 2, 4}) { + for (int bits : new int[]{1, 2, 3, 4, 9}) { var ash = model(values, bits); var preparer = new ASHQueryPreparer(ash); var states = new PreparedQuery[17]; @@ -79,7 +79,12 @@ public void batchMatchesSinglePreparationForEveryFusedBitCountAndOwnsInputs() th var actual = ASHQueryPreparer.state(ash, states[i], true); assertArrayEquals(expected.prepared.qProj, actual.prepared.qProj, 2e-6f); assertArrayEquals(expected.prepared.dotQMuByLandmark, actual.prepared.dotQMuByLandmark, 0f); - assertArrayEquals(expected.lut, actual.lut, 5e-6f); + // Offset-binary entries weight Aq by up to 2^(b-1), amplifying batch/single projection + // differences of up to 2e-6 by the same factor. + float lutTolerance = bits == 1 || bits == 2 || bits == 4 ? 5e-6f : 2e-6f * (1 << bits); + for (int e = 0; e < expected.lut.length; e++) { + assertEquals(expected.lut[e], actual.lut[e], lutTolerance * Math.max(1f, Math.abs(expected.lut[e]))); + } } } var mutable = values[0].copy(); @@ -201,7 +206,7 @@ public void realDiskSearchFilteringAndResumeUsePreparedStateWithoutReprojection( @Override public List maxDegrees() { return List.of(16, 0); } @Override public int getDimension() { return 64; } }; - for (int bits : new int[]{1, 2, 4}) { + for (int bits : new int[]{1, 2, 3, 4, 9}) { var ash = model(values, bits); var encoded = ash.encodeAll(ravv, ForkJoinPool.commonPool()); var path = Files.createTempFile("ash-prepared-query-", ".index"); 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 31c26a91c..10bf725aa 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 @@ -1950,8 +1950,11 @@ public boolean supportsAshMaskedLoad() { return ASH_SIMD; } - @Override - public float ashMaskedAddFlat(float[] tildeQ, + private static final int ASH_512_ACCUMULATORS = + Integer.getInteger("jvector.ash.512.accumulators", 8); + + /** Species-width-independent masked add; the fallback for machines without 16 float lanes. */ + private float ashMaskedAddFlat(float[] tildeQ, int qOffset, long[] allPackedVectors, int packedBase, @@ -2039,9 +2042,6 @@ public float ashMaskedAddFlat(float[] tildeQ, return acc0.add(acc1).add(acc2).add(acc3).reduceLanes(VectorOperators.ADD); } - private static final int ASH_512_ACCUMULATORS = - Integer.getInteger("jvector.ash.512.accumulators", 8); - public float ashMaskedAdd_512( float[] tildeQ, int qOffset, @@ -2325,81 +2325,6 @@ private static float reduce8( .reduceLanes(VectorOperators.ADD); } - // This approach uses masked adds instead of masked loads, and two accumulators (since this is slow) - @Override - public float ashMaskedAddAllWords(float[] tildeQ, - int d, - long[] packedBits, - int packedBase, - int words) { - final VectorSpecies SPEC = FloatVector.SPECIES_PREFERRED; - final int LANES = SPEC.length(); - final int upperBoundSafe = d - LANES; - - // Two accumulators to hide floating-point addition latency (4 cycles) - FloatVector acc1 = FloatVector.zero(SPEC); - FloatVector acc2 = FloatVector.zero(SPEC); - float scalarAcc = 0f; - - for (int w = 0; w < words; w++) { - long wordBits = packedBits[packedBase + w]; - // REMOVED: if (wordBits == 0) continue; (useless for dense data) - - int baseDim = w * 64; - - // Toggle to alternate between acc1 and acc2 - boolean toggle = false; - - for (int off = 0; off < 64; off += LANES) { - int qBase = baseDim + off; - - // Keep the fast integer check! - if (qBase >= d) break; - - long maskBits; - if (LANES == 16) maskBits = (wordBits >>> off) & 0xFFFFL; - else if (LANES == 8) maskBits = (wordBits >>> off) & 0xFFL; - else if (LANES == 4) maskBits = (wordBits >>> off) & 0xFL; - else { - // Fallback for unusual widths - long m = wordBits >>> off; - for (int b = 0; b < LANES; b++) { - if (((m >>> b) & 1L) != 0L) { - int idx = qBase + b; - if (idx < d) scalarAcc += tildeQ[idx]; - } - } - continue; - } - - // Still worth checking if the local chunk is 0 to skip the ADD - if (maskBits == 0L) continue; - - VectorMask bitMask = VectorMask.fromLong(SPEC, maskBits); - FloatVector loaded; - - // Fast Unmasked Load (Still critical for dense data) - if (qBase <= upperBoundSafe) { - loaded = FloatVector.fromArray(SPEC, tildeQ, qBase); - } else { - VectorMask rangeMask = SPEC.indexInRange(qBase, d); - loaded = FloatVector.fromArray(SPEC, tildeQ, qBase, bitMask.and(rangeMask)); - } - - // Alternate accumulators to parallelize the ADDs - if (!toggle) { - acc1 = acc1.add(loaded, bitMask); - } else { - acc2 = acc2.add(loaded, bitMask); - } - toggle = !toggle; - } - } - - // Merge the two pipelines - return acc1.add(acc2).reduceLanes(VectorOperators.ADD) + scalarAcc; - } - @Override public void ashMaskedAddBlockAllWords(float[] tildeQ, int d, @@ -2481,73 +2406,6 @@ public void ashMaskedAddBlockAllWords(float[] tildeQ, } } - @Override - public void ashMaskedAddBlockAllWordsPooled(float[] tildeQPool, - int tildeBase, - int d, - long[] packedBits, - int blockWordBase, - int words, - int blockSize, - int laneStart, - int blockLen, - float[] outMaskedAdd) { - - final VectorSpecies SPEC = FloatVector.SPECIES_PREFERRED; - final int LANES = SPEC.length(); - - for (int lane = 0; lane < blockLen; lane++) { - FloatVector accVec = FloatVector.zero(SPEC); - float scalarAcc = 0f; - - int laneIndex = laneStart + lane; - - for (int w = 0; w < words; w++) { - long wordBits = packedBits[blockWordBase + (w * blockSize) + laneIndex]; - int baseDim = w * 64; - - for (int off = 0; off < 64 && (baseDim + off) < d; off += LANES) { - int qBase = baseDim + off; - - VectorMask inRange = SPEC.indexInRange(qBase, d); - - long maskBits; - if (LANES == 16) { - maskBits = (wordBits >>> off) & 0xFFFFL; - } else if (LANES == 8) { - maskBits = (wordBits >>> off) & 0xFFL; - } else if (LANES == 4) { - maskBits = (wordBits >>> off) & 0xFL; - } else { - long m = wordBits >>> off; - float s = 0f; - for (int b = 0; b < LANES; b++) { - if (((m >>> b) & 1L) != 0L) { - int idx = qBase + b; - if (idx < d) s += tildeQPool[tildeBase + idx]; - } - } - scalarAcc += s; - continue; - } - - if (maskBits == 0L) continue; - - VectorMask bitMask = - VectorMask.fromLong(SPEC, maskBits).and(inRange); - - // pooled masked load: note tildeBase + qBase - FloatVector masked = - FloatVector.fromArray(SPEC, tildeQPool, tildeBase + qBase, bitMask); - - accVec = accVec.add(masked); - } - } - - outMaskedAdd[lane] = accVec.reduceLanes(VectorOperators.ADD) + scalarAcc; - } - } - private static void ashMaskedAddBlockWordLutNibble(float[] tildeQ, int baseDim, int d, From b6dc6f2eaccf1de94f91bf84ba30372fb96605ef Mon Sep 17 00:00:00 2001 From: Ted Willke Date: Wed, 11 Nov 2026 10:39:27 -0800 Subject: [PATCH 223/243] Prefer INT8 ASH query projection with a two-digit query and the direct VNNI kernel Automatic query projection now tries INT8 first, then FP16 with its FP32 range fallback, then FP32. INT8 was the fastest projection on every CPU measured: 1.03-1.10x FP16 on an AMX CPU and 1.2-1.5x FP32 on Ice Lake class CPUs. The query is now quantized to two INT8 digits with one scale: a coarse digit and its rounding remainder times 254. Rounding the query, not the matrix rows, caused nearly all INT8 projection error; with the second digit, exhaustive recall@10 on ada002-100k matched FP32 projection within 0.0002 at 2 and 4 bits, where single-digit INT8 lost 0.0018 and 0.0048. Both digits run through the same exact-integer kernel, and the oneDNN path computes the same result. On every AVX-512 VNNI CPU the direct tile kernel now serves single queries and batches: 512-bit tiles on CPUs with AMX, where they beat oneDNN's AMX path, and 256-bit tiles otherwise, where wide integer dot products slow the following graph search. oneDNN remains for AVX2-VNNI CPUs without AVX-512. --- docs/ash-configuration.md | 2 +- docs/ash-projection-precision.md | 31 ++-- docs/ash-query-integration.md | 2 +- docs/benchyaml-guide.md | 2 +- docs/native-ash-preparation.md | 10 +- .../jvector/vector/NativeASHProjection.java | 13 +- .../src/main/onednn/src/jvector_ash_int8.cpp | 152 ++++++++++++++---- .../vector/NativeASHFP16ProjectionTest.java | 10 +- .../NativeASHFP32BatchProjectionTest.java | 19 ++- .../vector/NativeASHInt8ProjectionTest.java | 27 +++- 10 files changed, 198 insertions(+), 70 deletions(-) diff --git a/docs/ash-configuration.md b/docs/ash-configuration.md index 05c7dd8fb..098443b4c 100644 --- a/docs/ash-configuration.md +++ b/docs/ash-configuration.md @@ -130,7 +130,7 @@ not build them. | JVM property | Default | Optional values and definition | | --- | --- | --- | -| `jvector.ash.queryProjection` | `auto` | `auto`: prefer optimized FP16, falling back to FP32. `fp32`: FP32. `fp16`: require optimized FP16 with FP32 accumulation/output. `int8`: require INT8 projection with integer accumulation and FP32 output. Explicit unsupported precisions fail; `auto` never selects INT8. | +| `jvector.ash.queryProjection` | `auto` | `auto`: prefer INT8 where an integer dot-product backend exists, then optimized FP16, falling back to FP32. `fp32`: FP32. `fp16`: require optimized FP16 with FP32 accumulation/output. `int8`: require INT8 projection with integer accumulation and FP32 output. Explicit unsupported precisions fail; `auto` never selects INT8. | | `jvector.ash.singleKernel` | `auto` | `auto`, `scalar`, `simd`; single-vector ASH scoring kernels, subject to provider/format support. | | `jvector.ash.blockKernel` | `auto` | `auto`, `scalar`, `simd`; blocked/fused ASH scoring kernels, subject to provider/format support. | | `jvector.ash.queryBatchSize` | `8` | Positive integer below `Integer.MAX_VALUE`; maximum opportunistic batch for model-owned synchronous query preparation. Separate from the queued batch limit below. | diff --git a/docs/ash-projection-precision.md b/docs/ash-projection-precision.md index 8d0b773f9..678e81c5d 100644 --- a/docs/ash-projection-precision.md +++ b/docs/ash-projection-precision.md @@ -11,12 +11,12 @@ on your data before deploying FP16 or INT8. | Processor family | Setting | Why | |---|---|---| -| Intel Xeon Cascade Lake, Cooper Lake, Ice Lake | `int8` | Fast integer projection; the required FP16 path is unavailable. | -| Intel Xeon Sapphire Rapids, Emerald Rapids, Granite Rapids | `fp16` | Fast FP16 projection with less quantization than INT8. | -| Intel Xeon Sierra Forest | `int8` | Integer acceleration is the useful low-precision path. | -| AMD Zen 4 and Zen 5: EPYC, Ryzen, Threadripper | `int8` | VNNI integer acceleration; these CPUs lack the FP16 instructions required by this backend. | -| Older Intel Xeon and AMD Zen 1–3 | `fp32` | Use the established FP32 path. | -| Arm Neoverse / AWS Graviton and Apple Silicon | `fp32` | Use the established supported path for this release. | +| Intel Xeon Cascade Lake, Cooper Lake, Ice Lake | `auto` (INT8) | Fast integer projection with 256-bit tiles; the required FP16 path is unavailable. | +| Intel Xeon Sapphire Rapids, Emerald Rapids, Granite Rapids | `auto` (INT8) | INT8 with 512-bit tiles is faster than AMX FP16; use `fp16` where recall at low overquery matters more. | +| Intel Xeon Sierra Forest | `auto` (INT8) | Integer acceleration (AVX-VNNI through oneDNN) is the useful low-precision path. | +| AMD Zen 4 and Zen 5: EPYC, Ryzen, Threadripper | `auto` (INT8) | AVX-512 VNNI integer acceleration; these CPUs lack the FP16 instructions required by this backend. | +| Older Intel Xeon and AMD Zen 1–3 | `auto` (FP32) | Without VNNI or FP16 support, `auto` keeps the established FP32 path. | +| Arm Neoverse / AWS Graviton and Apple Silicon | `auto` (FP32) | The established supported path for this release. | For query throughput and latency on Intel Ice Lake: @@ -30,15 +30,22 @@ Set `jvector.ash.queryProjection` before the model's first query use: | Setting | Behavior | |---|---| -| `auto` (default) | Try optimized FP16; retain the existing FP32 fallback if FP16 is unavailable or outside its supported range. Never select INT8 automatically. | +| `auto` (default) | Use INT8 where an integer dot-product backend is available (x86 VNNI or AMX); otherwise try optimized FP16, with the existing FP32 fallback if FP16 is unavailable or outside its supported range. | | `fp32` | Use the existing FP32 projection. | | `fp16` | Require optimized FP16 projection with FP32 accumulation and output. Report an error if unavailable or outside the supported range. | | `int8` | Require optimized INT8 projection with signed 32-bit integer accumulation and FP32 output. Report an error if unavailable; never silently substitute FP16 or FP32. | -`auto` preserves the existing precision policy. It does not promise to select -the fastest precision. INT8 remains an explicit accuracy/performance choice. - -oneDNN chooses the CPU implementation within the selected precision at runtime. +`auto` selects INT8 first because it was the fastest projection on every CPU +measured: 1.03-1.10x FP16 on an AMX CPU and 1.2-1.5x FP32 on Ice Lake class CPUs, +across nine 1M datasets. The query is quantized to two INT8 digits with one scale: +a coarse digit and its rounding remainder, times 254. Nearly all INT8 projection +error came from rounding the query; with the second digit, exhaustive recall +matches FP32 projection, while the matrix keeps one INT8 digit per row. + +On AVX-512 VNNI CPUs, INT8 uses JVector's exact-integer tile kernel for single queries +and batches: 512-bit tiles on CPUs with AMX, and 256-bit tiles otherwise, where wide +integer dot products slow the graph search that follows. oneDNN serves INT8 on other +CPUs and chooses the CPU implementation within FP16 at runtime. Explicit FP16 and INT8 settings fail clearly if the required backend is unavailable, including when a virtual machine does not expose the needed hardware features. @@ -56,7 +63,7 @@ The default is `auto`. Use one projection mode per JVM. - `ash_query_projection_requested`: the selected setting after JVM/YAML precedence. - `ash_query_projection_effective`: the prepared projection's actual precision. - `auto` can resolve to `fp16` or `fp32`; `fp16+fp32` means the automatic FP16 + `auto` can resolve to `int8`, `fp16` or `fp32`; `fp16+fp32` means the automatic FP16 view has used its FP32 range fallback. The columns are blank for non-ASH cases. Effective precision is blank if no diff --git a/docs/ash-query-integration.md b/docs/ash-query-integration.md index 66465e526..d73bf49d0 100644 --- a/docs/ash-query-integration.md +++ b/docs/ash-query-integration.md @@ -8,7 +8,7 @@ batch. An isolated request starts as soon as its execution capacity is available The existing synchronous `GraphSearcher` API remains available. The prepared-query and bounded-execution APIs are optional and do not change index formats, write -ordering, FP32 encoding/training, or the existing automatic FP16/FP32 precision policy. +ordering, FP32 encoding/training, or the automatic query-projection precision policy. See [Choosing ASH query-projection precision](ash-projection-precision.md) for processor-family recommendations and projection settings. diff --git a/docs/benchyaml-guide.md b/docs/benchyaml-guide.md index 203599542..024377dbc 100644 --- a/docs/benchyaml-guide.md +++ b/docs/benchyaml-guide.md @@ -92,7 +92,7 @@ Place `queryExecution` and `maxBatchSize` in that map on an ASH **search** entry | Field | Values | Description | | --- | --- | --- | -| `queryProjection` | `auto`, `fp32`, `fp16`, `int8` | Optional. `auto` is the default and selects FP16 where supported, with FP32 fallback. `fp32` selects FP32 projection. `fp16` and `int8` require their selected paths. The mode applies to ASH search and asymmetric construction scoring. Explicit ASH settings in one JVM must agree. Entries that omit it inherit the JVM mode, which defaults to `auto`. The `jvector.ash.queryProjection` JVM property overrides YAML. | +| `queryProjection` | `auto`, `fp32`, `fp16`, `int8` | Optional. `auto` is the default and selects INT8 where an integer dot-product backend exists, then FP16 where supported, with FP32 fallback. `fp32` selects FP32 projection. `fp16` and `int8` require their selected paths. The mode applies to ASH search and asymmetric construction scoring. Explicit ASH settings in one JVM must agree. Entries that omit it inherit the JVM mode, which defaults to `auto`. The `jvector.ash.queryProjection` JVM property overrides YAML. | | `queryExecution` | `direct`, `queued` | `direct` runs searches on the calling threads. `queued` sends searches to workers and batches query preparation when supported. Defaults to `direct`. | | `maxBatchSize` | Positive integer | Caps the number of queries prepared together in queued mode. Defaults to 8. The value must fit the executor limits at the selected worker count. | diff --git a/docs/native-ash-preparation.md b/docs/native-ash-preparation.md index 4246ba7cb..b9e951f9e 100644 --- a/docs/native-ash-preparation.md +++ b/docs/native-ash-preparation.md @@ -215,8 +215,8 @@ controls were introduced. The library's existing `encodeAll()` bound remains 32. The sequential-runtime build assertion prevents nested oneDNN thread pools. Single-vector `encode()` retains Highway FP32 projection. The wrapper delegates -query precision selection to the same original projection: FP16 queries remain -FP16 in automatic/required modes, with their existing FP32 fallback. Database +query precision selection to the same original projection: automatic mode prefers +INT8, then FP16 with its existing FP32 fallback. Database encoding never converts to FP16. Code selection and landmark correction stay in Java. @@ -232,9 +232,9 @@ operations. BLAS kernels can use different FP32 reduction orders for different batch shapes. The 9-bit reconstruction check therefore uses its existing per-component code -step error bound for worker/batch comparisons as well as singleton comparisons; -sign bits, offsets and the full 1/2/3/4-bit code/header checks remain exact in -those tests. Separate tests compare oneDNN output to a double-precision oracle, +step error bound for worker/batch comparisons as well as singleton comparisons, +and compares the code-independent centered offset within FP16 rounding; the full +1/2/3/4-bit code/header checks remain exact in those tests. Separate tests compare oneDNN output to a double-precision oracle, cover FP32 values outside FP16 precision/range, caller aliasing and invalid shapes, batch tails/counts above 32, immutable model copies and concurrent model switching. A direct ABI test requires successful oneDNN SGEMM, preventing a diff --git a/jvector-native/src/main/java/io/github/jbellis/jvector/vector/NativeASHProjection.java b/jvector-native/src/main/java/io/github/jbellis/jvector/vector/NativeASHProjection.java index c1a012d60..18bcde3aa 100644 --- a/jvector-native/src/main/java/io/github/jbellis/jvector/vector/NativeASHProjection.java +++ b/jvector-native/src/main/java/io/github/jbellis/jvector/vector/NativeASHProjection.java @@ -201,13 +201,24 @@ public VectorUtilSupport.AshProjection forQueryScoring() { switch (mode) { case "fp32": return this; case "int8": return NativeASHInt8Projection.prepare(matrix, rows, columns); - case "auto": return NativeASHFP16Projection.prepare(matrix, rows, columns, this, false); + case "auto": + // INT8 where an integer dot-product backend exists, else FP16, else this FP32 projection. + try { + return NativeASHInt8Projection.prepare(matrix, rows, columns); + } catch (UnsupportedOperationException unavailable) { + return fp16WithFallback(); + } case "fp16": return NativeASHFP16Projection.prepare(matrix, rows, columns, this, true); default: throw new IllegalArgumentException( "jvector.ash.queryProjection must be auto, fp32, fp16 or int8: " + mode); } } + /** Optimized FP16 where available and in range, else this FP32 projection: automatic mode's second choice. */ + VectorUtilSupport.AshProjection fp16WithFallback() { + return NativeASHFP16Projection.prepare(matrix, rows, columns, this, false); + } + private void invoke(MethodHandle handle, MemorySegment query, MemorySegment out) { if (TRACE) System.out.printf("%s(%d, %d)%n", SYMBOL, rows, columns); try { diff --git a/jvector-native/src/main/onednn/src/jvector_ash_int8.cpp b/jvector-native/src/main/onednn/src/jvector_ash_int8.cpp index acfbdd2fc..480eb5732 100644 --- a/jvector-native/src/main/onednn/src/jvector_ash_int8.cpp +++ b/jvector-native/src/main/onednn/src/jvector_ash_int8.cpp @@ -140,22 +140,36 @@ struct Kernel { } }; -// On Ice Lake, dense 512-bit integer dot products (oneDNN's 4- and 8-row -// avx512_core_vnni matmul, or an equivalent zmm kernel) slowed the graph search -// that follows them by 9-15%, so batched INT8 queries lost to unbatched ones. -// Load-bound 1- and 2-row zmm kernels did not, and neither does the same 8-row -// kernel on 256-bit registers, which is also the fastest. The prepared values, -// the visited nodes and the search thread's CPU time were otherwise unchanged. -// Query tiles are at most 8 rows, so on AVX-512 VNNI CPUs without AMX this -// exact-integer ymm kernel serves every tile of 2-8 rows: u8*s8 sums in int32 -// are exact, so outputs equal oneDNN's. AMX and AVX2-VNNI CPUs keep oneDNN, as does -// JVECTOR_ASH_INT8_ONEDNN (a diagnostic override) when set. +// On every AVX-512 VNNI CPU an exact-integer tile kernel serves every query tile of 1-8 rows: +// u8*s8 sums in int32 are exact, so outputs equal oneDNN's. Across nine 1M datasets it beat +// oneDNN (AMX included) for single rows and batches. Its register width follows the CPU: +// - Without AMX (Ice Lake class), dense 512-bit integer dot products (oneDNN's 4- and 8-row +// avx512_core_vnni matmul, or a zmm kernel) slowed the graph search that follows them by +// 9-15%; the same tiles on 256-bit registers did not, and are the fastest. +// - With AMX (Sapphire Rapids and later), 512-bit tiles are 1-4% faster than oneDNN's AMX path. +// AVX2-VNNI CPUs without AVX-512 keep oneDNN, as does JVECTOR_ASH_INT8_ONEDNN (a diagnostic +// override) when set. bool direct_vnni_available() { #if defined(__x86_64__) || defined(_M_X64) if (std::getenv("JVECTOR_ASH_INT8_ONEDNN")) return false; // Diagnostic: force oneDNN. switch (get_effective_cpu_isa()) { case cpu_isa::avx512_core_vnni: - case cpu_isa::avx512_core_bf16: return true; + case cpu_isa::avx512_core_bf16: + case cpu_isa::avx512_core_amx: + case cpu_isa::avx512_core_amx_fp16: return __builtin_cpu_supports("avx512vnni"); + default: return false; + } +#else + return false; +#endif +} + +// 512-bit tiles where AMX marks a core whose 512-bit integer units do not slow later search. +bool direct_uses_zmm() { +#if defined(__x86_64__) || defined(_M_X64) + switch (get_effective_cpu_isa()) { + case cpu_isa::avx512_core_amx: + case cpu_isa::avx512_core_amx_fp16: return true; default: return false; } #else @@ -163,6 +177,18 @@ bool direct_vnni_available() { #endif } +// The query as two INT8 digits with one scale: input / scale ~ coarse + fine / 254. The second digit +// holds the coarse digit's rounding remainder, which accounts for nearly all INT8 projection error +// (rounding the matrix rows costs far less). Both digits are stored with the +128 offset. +JV_ASH_INT8_TARGET float quantize_two_digits(const float* input, int count, uint8_t* coarse, uint8_t* fine) { + float scale = quantize(input, count, coarse, 128); + for (int i = 0; i < count; ++i) { + float remainder = input[i] / scale - float(int(coarse[i]) - 128); + fine[i] = uint8_t(std::clamp(int(std::nearbyint(remainder * 254.0f)), -127, 127) + 128); + } + return scale; +} + #if defined(__x86_64__) || defined(_M_X64) // NB blocks of 16 output rows (two 8-lane halves each) for every query row of // the tile: 2*M*NB independent int32 accumulators hide the dot-product latency. @@ -202,9 +228,51 @@ void vnni_tile(const uint8_t* query, int stride, const int8_t* packed, int group } } +// The same tile on 512-bit registers: one load covers a block's 16 rows x 4 columns. +template +__attribute__((target("avx512f,avx512vl,avx512vnni"))) +void vnni_tile_zmm(const uint8_t* query, int stride, const int8_t* packed, int groups, int blocks, int32_t* out, int out_stride) { + for (int block = 0; block < blocks; block += NB) { + const int8_t* w[NB]; + for (int j = 0; j < NB; ++j) + w[j] = packed + size_t(block + j < blocks ? block + j : block) * groups * 64; + __m512i acc[M][NB]; + for (int i = 0; i < M; ++i) + for (int j = 0; j < NB; ++j) acc[i][j] = _mm512_setzero_si512(); + for (int g = 0; g < groups; ++g) { + __m512i b[NB]; + for (int j = 0; j < NB; ++j) b[j] = _mm512_loadu_si512(w[j] + size_t(g) * 64); + for (int i = 0; i < M; ++i) { + int32_t four; + std::memcpy(&four, query + size_t(i) * stride + size_t(g) * 4, sizeof(four)); + __m512i a = _mm512_set1_epi32(four); + for (int j = 0; j < NB; ++j) acc[i][j] = _mm512_dpbusd_epi32(acc[i][j], a, b[j]); + } + } + for (int i = 0; i < M; ++i) + for (int j = 0; j < NB && block + j < blocks; ++j) + _mm512_storeu_si512(out + size_t(i) * out_stride + size_t(block + j) * 16, acc[i][j]); + } +} + +void vnni_gemm_zmm(int count, const uint8_t* query, int stride, const int8_t* packed, int groups, int blocks, + int32_t* out, int out_stride) { + switch (count) { + case 1: vnni_tile_zmm<1, 4>(query, stride, packed, groups, blocks, out, out_stride); break; + case 2: vnni_tile_zmm<2, 4>(query, stride, packed, groups, blocks, out, out_stride); break; + case 3: vnni_tile_zmm<3, 2>(query, stride, packed, groups, blocks, out, out_stride); break; + case 4: vnni_tile_zmm<4, 2>(query, stride, packed, groups, blocks, out, out_stride); break; + case 5: vnni_tile_zmm<5, 2>(query, stride, packed, groups, blocks, out, out_stride); break; + case 6: vnni_tile_zmm<6, 2>(query, stride, packed, groups, blocks, out, out_stride); break; + case 7: vnni_tile_zmm<7, 2>(query, stride, packed, groups, blocks, out, out_stride); break; + default: vnni_tile_zmm<8, 2>(query, stride, packed, groups, blocks, out, out_stride); break; + } +} + void vnni_gemm(int count, const uint8_t* query, int stride, const int8_t* packed, int groups, int blocks, int32_t* out, int out_stride) { switch (count) { + case 1: vnni_tile<1, 4>(query, stride, packed, groups, blocks, out, out_stride); break; case 2: vnni_tile<2, 2>(query, stride, packed, groups, blocks, out, out_stride); break; case 3: vnni_tile<3, 2>(query, stride, packed, groups, blocks, out, out_stride); break; case 4: vnni_tile<4, 2>(query, stride, packed, groups, blocks, out, out_stride); break; @@ -229,8 +297,9 @@ struct Context { std::array, kBatchTile + 1> ready{}; std::mutex mutex; std::atomic retained_bytes{0}; - // Set when the ymm VNNI kernel replaces oneDNN for tiles of 2-8 rows (see vnni_tile). + // Set when the direct VNNI kernel replaces oneDNN for every tile (see direct_vnni_available). const bool direct = direct_vnni_available(); + const bool zmm = direct && direct_uses_zmm(); const int padded_columns = (columns + 3) & ~3, padded_rows = (rows + 15) & ~15; std::unique_ptr packed; @@ -343,24 +412,32 @@ JV_ASH_INT8_TARGET int jvector_ash_int8_project_batch(void* pointer, const float for (int first = 0; first < count;) { int size = std::min(kBatchTile, count - first); #if defined(__x86_64__) || defined(_M_X64) - if (context.direct && size > 1) { - thread_local std::vector quantized; - thread_local std::vector dots; + if (context.direct) { + thread_local std::vector coarse, fine; + thread_local std::vector coarseDots, fineDots; thread_local std::array scales; size_t qbytes = size_t(kBatchTile) * context.padded_columns; size_t dbytes = size_t(kBatchTile) * context.padded_rows; // Padding columns meet zero weights, so stale bytes there add nothing. - if (quantized.size() < qbytes) quantized.assign(qbytes, 0); - if (dots.size() < dbytes) dots.resize(dbytes); + if (coarse.size() < qbytes) { coarse.assign(qbytes, 0); fine.assign(qbytes, 0); } + if (coarseDots.size() < dbytes) { coarseDots.resize(dbytes); fineDots.resize(dbytes); } for (int i = 0; i < size; ++i) - scales[i] = quantize(input + size_t(first + i) * context.columns, context.columns, - quantized.data() + size_t(i) * context.padded_columns, 128); - vnni_gemm(size, quantized.data(), context.padded_columns, context.packed.get(), - context.padded_columns / 4, context.padded_rows / 16, dots.data(), context.padded_rows); + scales[i] = quantize_two_digits(input + size_t(first + i) * context.columns, context.columns, + coarse.data() + size_t(i) * context.padded_columns, + fine.data() + size_t(i) * context.padded_columns); + const auto gemm = context.zmm ? vnni_gemm_zmm : vnni_gemm; + gemm(size, coarse.data(), context.padded_columns, context.packed.get(), context.padded_columns / 4, + context.padded_rows / 16, coarseDots.data(), context.padded_rows); + gemm(size, fine.data(), context.padded_columns, context.packed.get(), context.padded_columns / 4, + context.padded_rows / 16, fineDots.data(), context.padded_rows); + constexpr float kInv254 = 1.0f / 254.0f; for (int i = 0; i < size; ++i) for (int row = 0; row < context.rows; ++row) { - int32_t dot = dots[size_t(i) * context.padded_rows + row] - 128 * context.sums[row]; - output[size_t(first + i) * context.rows + row] = float(dot) * scales[i] * context.scales[row]; + size_t at = size_t(i) * context.padded_rows + row; + int32_t c = coarseDots[at] - 128 * context.sums[row]; + int32_t f = fineDots[at] - 128 * context.sums[row]; + output[size_t(first + i) * context.rows + row] = + (float(c) + float(f) * kInv254) * scales[i] * context.scales[row]; } first += size; continue; @@ -369,23 +446,36 @@ JV_ASH_INT8_TARGET int jvector_ash_int8_project_batch(void* pointer, const float const auto& kernel = context.kernel(size); auto& buffers = workspaces[size]; if (buffers.context_id != context.id) buffers.initialize(context, kernel); + // Two query digits, as in the direct kernel: the same matmul runs on the coarse digits, then + // on the fine digits, reusing the workspace buffers. auto* quantized = static_cast(buffers.input.get_data_handle()); + thread_local std::vector fine; + thread_local std::vector coarseSums; + size_t qbytes = size_t(size) * context.columns, sbytes = size_t(size) * context.rows; + if (fine.size() < qbytes) fine.resize(qbytes); + if (coarseSums.size() < sbytes) coarseSums.resize(sbytes); for (int i = 0; i < size; ++i) - buffers.scales[i] = quantize(input + size_t(first + i) * context.columns, - context.columns, quantized + size_t(i) * context.columns, 128); + buffers.scales[i] = quantize_two_digits(input + size_t(first + i) * context.columns, + context.columns, quantized + size_t(i) * context.columns, fine.data() + size_t(i) * context.columns); const dnnl_exec_arg_t args[] = {{DNNL_ARG_SRC, buffers.input.get()}, {DNNL_ARG_WEIGHTS, kernel.weights.get()}, {DNNL_ARG_DST, buffers.output.get()}, {DNNL_ARG_SCRATCHPAD, buffers.scratch.get()}}; - error::wrap_c_api(dnnl_primitive_execute(kernel.operation.get(), buffers.execution->get(), 4, args), - "INT8 projection execution failed"); - buffers.execution->wait(); const auto* sums = static_cast(buffers.output.get_data_handle()); + for (int pass = 0; pass < 2; ++pass) { + if (pass == 1) std::memcpy(quantized, fine.data(), qbytes); + error::wrap_c_api(dnnl_primitive_execute(kernel.operation.get(), buffers.execution->get(), 4, args), + "INT8 projection execution failed"); + buffers.execution->wait(); + if (pass == 0) std::memcpy(coarseSums.data(), sums, sbytes * sizeof(int32_t)); + } + constexpr float kInv254 = 1.0f / 254.0f; for (int i = 0; i < size; ++i) for (int row = 0; row < context.rows; ++row) { - int32_t dot = sums[size_t(i) * context.rows + row] - 128 * context.sums[row]; - // Preserve the prototype's FP32 rescaling and evaluation order. + size_t at = size_t(i) * context.rows + row; + int32_t c = coarseSums[at] - 128 * context.sums[row]; + int32_t f = sums[at] - 128 * context.sums[row]; output[size_t(first + i) * context.rows + row] = - float(dot) * buffers.scales[i] * context.scales[row]; + (float(c) + float(f) * kInv254) * buffers.scales[i] * context.scales[row]; } first += size; // Cannot overflow even when count approaches INT_MAX. } diff --git a/jvector-native/src/test/java/io/github/jbellis/jvector/vector/NativeASHFP16ProjectionTest.java b/jvector-native/src/test/java/io/github/jbellis/jvector/vector/NativeASHFP16ProjectionTest.java index e36cbfea6..2d86a5476 100644 --- a/jvector-native/src/test/java/io/github/jbellis/jvector/vector/NativeASHFP16ProjectionTest.java +++ b/jvector-native/src/test/java/io/github/jbellis/jvector/vector/NativeASHFP16ProjectionTest.java @@ -161,7 +161,8 @@ void batchGuardsAndAutomaticRangeFallback() { assertThrows(IllegalArgumentException.class, () -> projection.projectBatch(inputs, outputs, 2)); for (float[] out : outputs) assertArrayEquals(new float[]{42,42}, out); inputs[1][1] = 70000f; - var automatic = view(new NativeASHProjection(matrix), "auto"); + // Automatic FP16 (auto's choice where INT8 is unavailable) falls back to FP32 out of range. + var automatic = new NativeASHProjection(matrix).fp16WithFallback(); automatic.projectBatch(inputs, outputs, 2); checkOracle(matrix, inputs[0], outputs[0], true); checkOracle(matrix, inputs[1], outputs[1], false); @@ -314,12 +315,13 @@ void guardsShapesAliasesAndRangeWithoutDamagingSubsequentCalls() { } @Test - void defaultPrefersFp16ButFp32AndAutomaticRangeFallbackRemainAvailable() { + void automaticFp16PrefersFp16ButFp32AndRangeFallbackRemainAvailable() { var original = new NativeASHProjection(new float[][]{{.25f, -.5f}}); assertSame(original, view(original, "fp32")); var explicit = required(original); assertInstanceOf(NativeASHFP16Projection.class, explicit); - var automatic = view(original, null); + // auto selects INT8 first where available; its FP16 choice keeps the FP32 range fallback. + var automatic = original.fp16WithFallback(); assertInstanceOf(NativeASHFP16Projection.class, automatic); assertEquals("fp16", automatic.operandPrecision()); float[] query = {70000, 2}, out = new float[1], expected = new float[1]; @@ -328,7 +330,7 @@ void defaultPrefersFp16ButFp32AndAutomaticRangeFallbackRemainAvailable() { assertArrayEquals(expected, out); assertEquals("fp16+fp32", automatic.operandPrecision()); var largeMatrix = new NativeASHProjection(new float[][]{{70000}}); - assertSame(largeMatrix, view(largeMatrix, "auto")); + assertSame(largeMatrix, largeMatrix.fp16WithFallback()); assertThrows(IllegalArgumentException.class, () -> view(largeMatrix, "fp16")); assertThrows(IllegalArgumentException.class, () -> view(original, "typo")); } diff --git a/jvector-native/src/test/java/io/github/jbellis/jvector/vector/NativeASHFP32BatchProjectionTest.java b/jvector-native/src/test/java/io/github/jbellis/jvector/vector/NativeASHFP32BatchProjectionTest.java index 3a0d5bc70..ca4a9f10d 100644 --- a/jvector-native/src/test/java/io/github/jbellis/jvector/vector/NativeASHFP32BatchProjectionTest.java +++ b/jvector-native/src/test/java/io/github/jbellis/jvector/vector/NativeASHFP32BatchProjectionTest.java @@ -101,14 +101,21 @@ void queryFallbackRetainsOwnerIdentityAndDoesNotDoubleChargeWeights() { assertSame(projection, projection.forQueryScoring()); assertEquals(bytes, projection.ramBytesUsed()); System.setProperty("jvector.ash.queryProjection", "auto"); - // Finite FP32 weights outside FP16 range force the automatic FP32 fallback. var fullRange = prepared(new float[][]{{70000f}}); - assertSame(fullRange, fullRange.forQueryScoring()); + var automatic = fullRange.forQueryScoring(); float[] out = new float[1]; - fullRange.forQueryScoring().project(new float[]{2f}, out); - assertEquals(140000f, out[0]); - assertEquals(Float.BYTES + RamUsageEstimator.shallowSizeOfInstance(NativeASHProjection.class), - fullRange.ramBytesUsed()); + automatic.project(new float[]{2f}, out); + if (automatic instanceof NativeASHInt8Projection) { + // auto prefers INT8 where available; a one-column operand is exact in INT8. + assertEquals(140000f, out[0], 140000f * 1e-6f); + } else { + // Without INT8, finite FP32 weights outside FP16 range force the automatic FP32 fallback, + // which keeps the owning view's identity and charges the matrix once. + assertSame(fullRange, automatic); + assertEquals(140000f, out[0]); + assertEquals(Float.BYTES + RamUsageEstimator.shallowSizeOfInstance(NativeASHProjection.class), + fullRange.ramBytesUsed()); + } } finally { if (previous == null) System.clearProperty("jvector.ash.queryProjection"); else System.setProperty("jvector.ash.queryProjection", previous); diff --git a/jvector-native/src/test/java/io/github/jbellis/jvector/vector/NativeASHInt8ProjectionTest.java b/jvector-native/src/test/java/io/github/jbellis/jvector/vector/NativeASHInt8ProjectionTest.java index 2679bfa48..c614a01a7 100644 --- a/jvector-native/src/test/java/io/github/jbellis/jvector/vector/NativeASHInt8ProjectionTest.java +++ b/jvector-native/src/test/java/io/github/jbellis/jvector/vector/NativeASHInt8ProjectionTest.java @@ -75,15 +75,23 @@ private static int code(float value, float scale) { return Math.max(-127, Math.min(127, (int) Math.rint(value / scale))); } + // The query is two INT8 digits: value / scale ~ coarse + fine / 254, with fine the coarse remainder. + private static int fine(float value, float scale) { + return Math.max(-127, Math.min(127, (int) Math.rint((value / scale - code(value, scale)) * 254f))); + } + private static float[] oracle(float[][] matrix, float[] query) { float queryScale = scale(query); float[] out = new float[matrix.length]; for (int row = 0; row < matrix.length; row++) { float weightScale = scale(matrix[row]); - long sum = 0; - for (int column = 0; column < query.length; column++) - sum += (long) code(query[column], queryScale) * code(matrix[row][column], weightScale); - out[row] = (float) sum * queryScale * weightScale; + long coarse = 0, fine = 0; + for (int column = 0; column < query.length; column++) { + int weight = code(matrix[row][column], weightScale); + coarse += (long) code(query[column], queryScale) * weight; + fine += (long) fine(query[column], queryScale) * weight; + } + out[row] = ((float) coarse + (float) fine * (1.0f / 254.0f)) * queryScale * weightScale; } return out; } @@ -117,7 +125,7 @@ void integerOraclePaddingTailsAndSingletonAgree() { } @Test - void halfStepsUseNearestEvenForQueriesAndWeights() { + void halfStepWeightsUseNearestEvenAndQueryHalfStepsAreExact() { float[] ties = {.5f, 1.5f, 2.5f, 3.5f, -.5f, -1.5f, -2.5f, -3.5f}; float[] expected = {0, 2, 2, 4, 0, -2, -2, -4}; float[][] weights = new float[32][64]; @@ -132,9 +140,11 @@ void halfStepsUseNearestEvenForQueriesAndWeights() { var projection = prepare(weights); float[] output = new float[32]; projection.project(query, output); + // The query's fine digit carries each half step (127 / 254), so queries are exact; weights still + // round half steps to the nearest even code. for (int row = 0; row < ties.length; row++) { - assertEquals(127f * expected[row], output[row]); - assertEquals(expected[row] * expected[row], output[row + 8]); + assertEquals(127f * ties[row], output[row], 1e-4f); + assertEquals(ties[row] * expected[row], output[row + 8], 1e-4f); } assertArrayEquals(oracle(weights, query), output); } @@ -297,8 +307,9 @@ void precisionSelectionPreservesOriginalFp32AndAutomaticPolicy() { assertArrayEquals(before, after); System.setProperty(PROPERTY, "fp32"); assertSame(original, original.forQueryScoring()); + // auto prefers INT8 wherever this backend is available. System.setProperty(PROPERTY, "auto"); - assertFalse(original.forQueryScoring() instanceof NativeASHInt8Projection); + assertInstanceOf(NativeASHInt8Projection.class, original.forQueryScoring()); } finally { if (saved == null) System.clearProperty(PROPERTY); else System.setProperty(PROPERTY, saved); } From 55b6b0a79e3368bd6d8c5cf5a9c6d00716402bae Mon Sep 17 00:00:00 2001 From: Ted Willke Date: Wed, 11 Nov 2026 15:03:30 -0800 Subject: [PATCH 224/243] Force SIMD in the ASH negative-entry test only where the backend offers it Offset-binary codes (3 and 5-9 bits) score single vectors with SIMD only when the backend supports canonical LUT scoring. The Panama backend supports the 2- and 4-bit LUT kernels but not that one, so forcing SIMD there threw by design and failed the test on Windows, where the native library is not loaded. The test now requests SIMD per bit width only when the backend can honor it. --- .../jbellis/jvector/quantization/TestASHGraphSearch.java | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestASHGraphSearch.java b/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestASHGraphSearch.java index 804a166b3..92c962757 100644 --- a/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestASHGraphSearch.java +++ b/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestASHGraphSearch.java @@ -60,8 +60,12 @@ public void negativeEntryReachesBaseLayerWithStandaloneAndFusedScoring() throws @Override public List maxDegrees() { return List.of(16, 0); } @Override public int getDimension() { return 64; } }; - boolean simd = provider.getVectorUtilSupport().supportsAshLutScoring(); + var backend = provider.getVectorUtilSupport(); for (int bits : new int[]{1, 2, 3, 4, 9}) { + // Forced SIMD must be available for this code format: offset-binary codes (3 and 5-9 bits) + // also need canonical LUT scoring, which only some backends provide. + boolean simd = backend.supportsAshLutScoring() + && (!AsymmetricHashing.usesOffsetBinaryCode(bits) || backend.supportsAshCanonicalLutScoring()); var ash = AsymmetricHashing.initialize(values, AsymmetricHashing.RANDOM, AsymmetricHashing.HEADER_BITS + 33 * bits, 1, bits); var encoded = ash.encodeAll(values, ForkJoinPool.commonPool()); From c1a1d5b14da99b385267a1c6006ab7d4b392e5f4 Mon Sep 17 00:00:00 2001 From: Ted Willke Date: Wed, 11 Nov 2026 15:21:03 -0800 Subject: [PATCH 225/243] Address PR review: packed-bits race, shared BLAS scopes, search provider, and API defaults - ASHVectors publishes 1-bit packed sign words as one immutable holder after they are filled, so concurrent first queries never score a partial array or one packed for another block size. - NativeBlasThreads reference counts scopes on a process-wide BLAS setter (OpenBLAS before 0.3.27): the first scope sets the count and the last restores it, so overlapping scopes on other threads no longer undo each other. - GraphSearcher.searchOneLayer uses the score provider it is given for adaptive termination. - VectorUtilSupport.ashMaskedAdd_512 has a portable default, so outside implementations compile. - KMeansPlusPlusClusterer reports its verbose progress through the logger. - MemorySegmentReader and MMapReader read single bytes without allocating. --- .../jbellis/jvector/graph/GraphSearcher.java | 4 +- .../jvector/quantization/ASHVectors.java | 52 ++++++--- .../quantization/KMeansPlusPlusClusterer.java | 26 ++--- .../vector/DefaultVectorUtilSupport.java | 31 ----- .../jvector/vector/VectorUtilSupport.java | 36 +++++- .../jvector/example/util/MMapReader.java | 5 + .../jvector/disk/MemorySegmentReader.java | 5 + .../jvector/vector/NativeBlasThreads.java | 30 ++++- .../jvector/vector/NativeBlasThreadsTest.java | 41 +++++++ .../quantization/TestASHMemoryAccounting.java | 2 +- .../quantization/TestASHPackedBitsRace.java | 107 ++++++++++++++++++ 11 files changed, 269 insertions(+), 70 deletions(-) create mode 100644 jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestASHPackedBitsRace.java 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 e7f995f77..c62186822 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 @@ -441,7 +441,7 @@ public void searchOneLayer(SearchScoreProvider scoreProvider, boolean adaptive = adaptiveTerminationEnabled && scoreProvider.supportsAdaptiveDotProductTermination() && level == 0 && threshold == 0.0f && rerankK > 0; - if (adaptive) resetAdaptiveTermination(rerankK, acceptOrdsThisLayer); + if (adaptive) resetAdaptiveTermination(scoreProvider, rerankK, acceptOrdsThisLayer); // TopK and filtered pruning are disabled. Threshold searches retain their // legacy threshold early-termination path inside ScoreTrackerFactory. @@ -544,7 +544,7 @@ SearchResult resume(int topK, int rerankK, float threshold, float rerankFloor) { return reranking(topK, rerankK, rerankFloor); } - private void resetAdaptiveTermination(int rerankK, Bits accepted) { + private void resetAdaptiveTermination(SearchScoreProvider scoreProvider, int rerankK, Bits accepted) { adaptiveTermination.reset(rerankK, adaptiveTerminationGamma, scoreProvider.adaptiveScoresAreRawDotProducts()); adaptiveResults.clear(); adaptiveResults.setMaxSize(rerankK); diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHVectors.java b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHVectors.java index c89523aff..9bf53282c 100644 --- a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHVectors.java +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHVectors.java @@ -110,10 +110,20 @@ static AshBlockKernel fromProperty() { private final int d; private final int words; - // Packed block-column-major sign bits (built lazily per blockSize, 1-bit path only) - private int packedBlockSize = -1; - private volatile long[] packedBits = null; - private int packedBlockCount = 0; + // Packed block-column-major sign bits (built lazily per blockSize, 1-bit path only). Published + // whole, after it is filled, so concurrent queries never see a partial array or another size. + private static final class PackedBits { + final int blockSize; + final long[] words; + + PackedBits(int blockSize, long[] words) { + this.blockSize = blockSize; + this.words = words; + } + } + + private volatile PackedBits packedBits; + private final Object packedBitsLock = new Object(); // Immutable, body-only nibble blocks shared by queries. Header arrays contain // decoded binary16 values; the serialized header remains 40 bits per vector. @@ -434,14 +444,12 @@ public ASHBlockScorer blockScorerFor( // ------------------------------------------------------------ // SIMD path // ------------------------------------------------------------ - ensurePackedBits(blockSize); - return new SimdASHBlockScorer( codes.size, scales, offsets, landmarks, - packedBits, + packedBitsFor(blockSize), blockSize, qp ); @@ -465,8 +473,7 @@ ASHBlockScorer blockScorerFor(ASHScorer.QueryPrecompute prepared, int blockSize, var qp = new QueryPrecompute(prepared.d, prepared.C, prepared.qProj, prepared.sumQProj, prepared.dotQMuByLandmark); if (!simd) return new ScalarASHBlockScorer(codes, scales, offsets, landmarks, qp); - ensurePackedBits(blockSize); - return new SimdASHBlockScorer(codes.size, scales, offsets, landmarks, packedBits, blockSize, qp); + return new SimdASHBlockScorer(codes.size, scales, offsets, landmarks, packedBitsFor(blockSize), blockSize, qp); } private byte[][] packProjectionBlocks(int blockSize) { @@ -554,21 +561,32 @@ public void scoreRange(int start, int count, float[] out) { public String description() { return toString(); } } - private void ensurePackedBits(int blockSize) { + /** The packed sign words for {@code blockSize}; callers score with exactly the array returned. */ + private long[] packedBitsFor(int blockSize) { if (ash.bitsPerDimension != 1) { throw new IllegalStateException("Packed sign-bit block cache supports only 1-bit ASH"); } if (blockSize <= 0) { throw new IllegalArgumentException("blockSize must be > 0"); } - if (packedBits != null && packedBlockSize == blockSize) { - return; + PackedBits current = packedBits; + if (current != null && current.blockSize == blockSize) { + return current.words; + } + synchronized (packedBitsLock) { + current = packedBits; + if (current == null || current.blockSize != blockSize) { + current = new PackedBits(blockSize, packBits(blockSize)); + packedBits = current; + } + return current.words; } + } + private long[] packBits(int blockSize) { final int n = codes.size; - packedBlockSize = blockSize; - packedBlockCount = (n + blockSize - 1) / blockSize; - packedBits = new long[packedBlockCount * words * blockSize]; + final int packedBlockCount = (n + blockSize - 1) / blockSize; + final long[] packedBits = new long[packedBlockCount * words * blockSize]; for (int b = 0; b < packedBlockCount; b++) { int baseOrd = b * blockSize; @@ -583,6 +601,7 @@ private void ensurePackedBits(int blockSize) { // remaining lanes default to 0 } } + return packedBits; } /** @@ -1030,8 +1049,9 @@ private long containerBytes() { @Override public long ramBytesUsed() { ASHSymmetricScorer symmetric = diversityScorer; + PackedBits packed = packedBits; return ash.ramBytesUsed() + containerBytes + codes.ramBytesUsed() + projectionCacheBytes.get() - + RamUsageEstimator.shallowSizeOf(packedBits) + + (packed == null ? 0 : RamUsageEstimator.shallowSizeOf(packed.words)) + (symmetric == null ? 0 : symmetric.ramBytesUsed()); } diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/KMeansPlusPlusClusterer.java b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/KMeansPlusPlusClusterer.java index f913eb91d..dc05709b8 100644 --- a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/KMeansPlusPlusClusterer.java +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/KMeansPlusPlusClusterer.java @@ -37,6 +37,8 @@ * A KMeans++ implementation for float vectors. Optimizes to use SIMD vector instructions if available. */ public class KMeansPlusPlusClusterer { + private static final org.slf4j.Logger LOG = org.slf4j.LoggerFactory.getLogger(KMeansPlusPlusClusterer.class); + private static final VectorTypeSupport vectorTypeSupport = VectorizationProvider.getInstance().getVectorTypeSupport(); // Observability flags (disabled by default) @@ -151,25 +153,23 @@ public VectorFloat cluster(int unweightedIterations, int anisotropicIteration long t1 = VERBOSE_KMEANS_TIMING ? System.nanoTime() : 0L; if (VERBOSE_KMEANS) { - System.out.printf( - "KMeans unweighted iter %d: %d/%d reassigned (%.3f%%)%n", + LOG.info(String.format("KMeans unweighted iter %d: %d/%d reassigned (%.3f%%)", i, changedCount, n, - 100.0 * changedCount / n + 100.0 * changedCount / n) ); } if (VERBOSE_KMEANS_TIMING) { - System.out.printf( - " time = %.3f ms%n", - (t1 - t0) * 1e-6 + LOG.info(String.format(" time = %.3f ms", + (t1 - t0) * 1e-6) ); } if (changedCount <= threshold) { if (VERBOSE_KMEANS) { - System.out.println("KMeans unweighted converged early"); + LOG.info("KMeans unweighted converged early"); } break; } @@ -185,25 +185,23 @@ public VectorFloat cluster(int unweightedIterations, int anisotropicIteration long t1 = VERBOSE_KMEANS_TIMING ? System.nanoTime() : 0L; if (VERBOSE_KMEANS) { - System.out.printf( - "KMeans anisotropic iter %d: %d/%d reassigned (%.3f%%)%n", + LOG.info(String.format("KMeans anisotropic iter %d: %d/%d reassigned (%.3f%%)", i, changedCount, n, - 100.0 * changedCount / n + 100.0 * changedCount / n) ); } if (VERBOSE_KMEANS_TIMING) { - System.out.printf( - " time = %.3f ms%n", - (t1 - t0) * 1e-6 + LOG.info(String.format(" time = %.3f ms", + (t1 - t0) * 1e-6) ); } if (changedCount <= threshold) { if (VERBOSE_KMEANS) { - System.out.println("KMeans anisotropic converged early"); + LOG.info("KMeans anisotropic converged early"); } break; } 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 fd88339a4..92b12ab92 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 @@ -547,37 +547,6 @@ public float nvqUniformLoss(VectorFloat vector, float minValue, float maxValu return squaredSum; } - @Override - public float ashMaskedAdd_512(float[] tildeQ, - int qOffset, - long[] allPackedVectors, - int packedBase, - int d, - int words) { - float sum = 0.0f; - int baseDim = 0; - - for (int w = 0; w < words; w++) { - long word = allPackedVectors[packedBase + w]; - - // Standard bit-twiddling iteration (iterate only set bits) - while (word != 0L) { - int bit = Long.numberOfTrailingZeros(word); - int idx = baseDim + bit; - - // Bounds check required for the last word - if (idx < d) { - sum += tildeQ[qOffset + idx]; - } - - // Clear the lowest set bit - word &= (word - 1); - } - - baseDim += 64; - } - return sum; - } } diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/vector/VectorUtilSupport.java b/jvector-base/src/main/java/io/github/jbellis/jvector/vector/VectorUtilSupport.java index a9f856b6e..0086ac67f 100644 --- a/jvector-base/src/main/java/io/github/jbellis/jvector/vector/VectorUtilSupport.java +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/vector/VectorUtilSupport.java @@ -652,8 +652,40 @@ default float ashDotRow(float[] Arow, float[] x) { return acc; } - // In VectorUtilSupport interface - float ashMaskedAdd_512(float[] tildeQ, int qOffset, long[] allPackedVectors, int packedBase, int d, int words); + /** + * Sums {@code tildeQ[qOffset + i]} over the set bits {@code i < d} of one vector's packed sign words. + * The default is portable scalar code; SIMD backends override it. + */ + default float ashMaskedAdd_512(float[] tildeQ, + int qOffset, + long[] allPackedVectors, + int packedBase, + int d, + int words) { + float sum = 0.0f; + int baseDim = 0; + + for (int w = 0; w < words; w++) { + long word = allPackedVectors[packedBase + w]; + + // Standard bit-twiddling iteration (iterate only set bits) + while (word != 0L) { + int bit = Long.numberOfTrailingZeros(word); + int idx = baseDim + bit; + + // Bounds check required for the last word + if (idx < d) { + sum += tildeQ[qOffset + idx]; + } + + // Clear the lowest set bit + word &= (word - 1); + } + + baseDim += 64; + } + return sum; + } /** * Computes maskedAdd for a block slice of vectors stored in packed block-column-major form. diff --git a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/util/MMapReader.java b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/util/MMapReader.java index 79a921a46..b7db5156c 100644 --- a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/util/MMapReader.java +++ b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/util/MMapReader.java @@ -45,6 +45,11 @@ public long getPosition() { return position; } + public byte readByte() { + return buffer.memory().getByte(position++); + } + + @Override public int readInt() { try { return buffer.memory().getInt(position); diff --git a/jvector-native/src/main/java/io/github/jbellis/jvector/disk/MemorySegmentReader.java b/jvector-native/src/main/java/io/github/jbellis/jvector/disk/MemorySegmentReader.java index 0a5fde49e..d50925001 100644 --- a/jvector-native/src/main/java/io/github/jbellis/jvector/disk/MemorySegmentReader.java +++ b/jvector-native/src/main/java/io/github/jbellis/jvector/disk/MemorySegmentReader.java @@ -93,6 +93,11 @@ public void readFully(long[] vector) { position += vector.length * 8L; } + @Override + public byte readByte() { + return memory.get(ValueLayout.JAVA_BYTE, position++); + } + @Override public int readInt() { var k = memory.get(intLayout, position); diff --git a/jvector-native/src/main/java/io/github/jbellis/jvector/vector/NativeBlasThreads.java b/jvector-native/src/main/java/io/github/jbellis/jvector/vector/NativeBlasThreads.java index ee91640c1..7186695d3 100644 --- a/jvector-native/src/main/java/io/github/jbellis/jvector/vector/NativeBlasThreads.java +++ b/jvector-native/src/main/java/io/github/jbellis/jvector/vector/NativeBlasThreads.java @@ -46,11 +46,23 @@ static String description() { return CONTROL == null ? "none" : CONTROL.name; } - private static final class Control { + /** + * Thread-local setters apply and restore per thread. A process-wide setter is shared by every + * thread, so its scopes are reference counted: the first open scope saves the process count and + * applies its own, later scopes join without changing it, and the saved count returns when the + * last scope closes. A scope's restore value is then {@link #RELEASE}, which closes that scope. + */ + static final class Control { + /** Returned to process-wide scopes; passing it back releases the scope. Never a real count. */ + static final int RELEASE = Integer.MAX_VALUE; + final String name; final MethodHandle setter; // int f(int) returning the previous value, or void f(int) final MethodHandle getter; // int f(void), only for process-wide setters final boolean global; + private int openScopes; // process-wide only, guarded by this + private int savedCount; // process count before the first open scope + private int appliedCount; // count applied by the first open scope Control(String name, MethodHandle setter, MethodHandle getter, boolean global) { this.name = name; @@ -62,9 +74,19 @@ private static final class Control { int set(int threads) { try { if (!global) return (int) setter.invokeExact(threads); - int previous = (int) getter.invokeExact(); - if (previous != threads) setter.invokeExact(threads); - return previous; + synchronized (this) { + if (threads == RELEASE) { + if (openScopes == 0) return appliedCount; + if (--openScopes == 0 && savedCount != appliedCount) setter.invokeExact(savedCount); + return appliedCount; + } + if (openScopes++ == 0) { + savedCount = (int) getter.invokeExact(); + appliedCount = threads; + if (savedCount != threads) setter.invokeExact(threads); + } + return RELEASE; + } } catch (Throwable t) { throw new IllegalStateException("Setting " + name + " to " + threads + " failed", t); } diff --git a/jvector-native/src/test/java/io/github/jbellis/jvector/vector/NativeBlasThreadsTest.java b/jvector-native/src/test/java/io/github/jbellis/jvector/vector/NativeBlasThreadsTest.java index b946f7d6d..b03448375 100644 --- a/jvector-native/src/test/java/io/github/jbellis/jvector/vector/NativeBlasThreadsTest.java +++ b/jvector-native/src/test/java/io/github/jbellis/jvector/vector/NativeBlasThreadsTest.java @@ -33,6 +33,47 @@ /** The native provider sets BLAS threads, and ASH training bounds them by its executor. */ class NativeBlasThreadsTest { + // A fake process-wide library: one count shared by every thread, like openblas_set_num_threads. + private static int processCount; + private static void fakeSet(int threads) { processCount = threads; } + private static int fakeGet() { return processCount; } + + private static NativeBlasThreads.Control processWide() throws Exception { + var lookup = java.lang.invoke.MethodHandles.lookup(); + var set = lookup.findStatic(NativeBlasThreadsTest.class, "fakeSet", + java.lang.invoke.MethodType.methodType(void.class, int.class)); + var get = lookup.findStatic(NativeBlasThreadsTest.class, "fakeGet", + java.lang.invoke.MethodType.methodType(int.class)); + return new NativeBlasThreads.Control("fake process-wide", set, get, true); + } + + @Test + void overlappingProcessWideScopesHoldTheCountUntilTheLastCloses() throws Exception { + var control = processWide(); + processCount = 48; + int a = control.set(1); // scope A opens on one thread + int b = control.set(1); // scope B opens on another before A closes + assertEquals(1, processCount); + control.set(a); // A closes while B is still open + assertEquals(1, processCount, "B must keep its single thread"); + control.set(b); // B closes last + assertEquals(48, processCount, "The process count must be restored"); + control.set(b); // a repeated restore changes nothing + assertEquals(48, processCount); + } + + @Test + void aConflictingProcessWideScopeDoesNotChangeTheHeldCount() throws Exception { + var control = processWide(); + processCount = 48; + int a = control.set(1); + int b = control.set(4); // cannot change a count another thread is using + assertEquals(1, processCount); + control.set(b); + control.set(a); + assertEquals(48, processCount); + } + private static boolean supported() { return !"none".equals(NativeBlasThreads.description()); } diff --git a/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestASHMemoryAccounting.java b/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestASHMemoryAccounting.java index 42eb6c19e..b3df11bec 100644 --- a/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestASHMemoryAccounting.java +++ b/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestASHMemoryAccounting.java @@ -290,7 +290,7 @@ public void oneBitSingleScoringAddsNoCopyAndBlockLayoutIsCharged() throws Except var query = ash.landmarks[0]; for (int block : new int[]{8,16,8}) { vectors.blockScorerFor(query, VectorSimilarityFunction.DOT_PRODUCT, block); - long[] packed = (long[])field(vectors, "packedBits"); + long[] packed = (long[])field(field(vectors, "packedBits"), "words"); // Query preparation may initialize provider weights; subtract model growth. long own = vectors.ramBytesUsed()-ash.ramBytesUsed(); assertTrue(packed != null); diff --git a/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestASHPackedBitsRace.java b/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestASHPackedBitsRace.java new file mode 100644 index 000000000..31bfd4be6 --- /dev/null +++ b/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestASHPackedBitsRace.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.quantization; + +import io.github.jbellis.jvector.graph.MockVectorValues; +import io.github.jbellis.jvector.vector.VectorSimilarityFunction; +import io.github.jbellis.jvector.vector.VectorUtil; +import io.github.jbellis.jvector.vector.VectorizationProvider; +import io.github.jbellis.jvector.vector.types.VectorFloat; +import org.junit.Test; + +import java.util.ArrayList; +import java.util.List; +import java.util.Random; +import java.util.concurrent.Callable; +import java.util.concurrent.CyclicBarrier; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.ForkJoinPool; +import java.util.concurrent.Future; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assume.assumeTrue; + +/** Concurrent first queries on a fresh 1-bit collection must score exactly like a single query. */ +public class TestASHPackedBitsRace { + private static final String BLOCK = "jvector.ash.blockKernel"; + + @Test + public void concurrentFirstBlockScorersSeeFullyPackedBits() throws Exception { + assumeTrue(VectorizationProvider.getInstance().getVectorUtilSupport().supportsAshMaskedLoad()); + String old = System.getProperty(BLOCK); + System.setProperty(BLOCK, "simd"); + ExecutorService pool = Executors.newFixedThreadPool(2); + try { + var type = VectorizationProvider.getInstance().getVectorTypeSupport(); + int n = 20_000; + VectorFloat[] values = new VectorFloat[n]; + Random random = new Random(5512); + for (int i = 0; i < n; i++) { + values[i] = type.createFloatVector(64); + for (int j = 0; j < 64; j++) values[i].set(j, random.nextFloat() - .5f); + VectorUtil.l2normalize(values[i]); + } + var ash = AsymmetricHashing.initialize(MockVectorValues.fromValues(values), AsymmetricHashing.RANDOM, + AsymmetricHashing.HEADER_BITS + 64, 1, 1); + var encoded = ash.encodeAll(MockVectorValues.fromValues(values), ForkJoinPool.commonPool()); + var codes = new AsymmetricHashing.QuantizedVector[n]; + for (int i = 0; i < n; i++) codes[i] = encoded.get(i); + VectorFloat query = values[7]; + + int[] sizes = {16, 32}; + float[][] expected = new float[sizes.length][]; + for (int s = 0; s < sizes.length; s++) { + expected[s] = score(new ASHVectors(ash, codes), query, sizes[s]); + } + + for (int round = 0; round < 40; round++) { + // Even rounds race two queries with the same block size, odd rounds with different sizes. + var vectors = new ASHVectors(ash, codes); + int a = 0, b = round % 2; + var barrier = new CyclicBarrier(2); + List> tasks = new ArrayList<>(); + for (int s : new int[]{a, b}) { + tasks.add(() -> { + barrier.await(); + return score(vectors, query, sizes[s]); + }); + } + List> results = pool.invokeAll(tasks); + assertArrayEquals("round " + round, expected[a], results.get(0).get(), 0f); + assertArrayEquals("round " + round, expected[b], results.get(1).get(), 0f); + } + } finally { + pool.shutdownNow(); + if (old == null) System.clearProperty(BLOCK); else System.setProperty(BLOCK, old); + } + } + + private static float[] score(ASHVectors vectors, VectorFloat query, int blockSize) { + var scorer = vectors.blockScorerFor(query, VectorSimilarityFunction.DOT_PRODUCT, blockSize); + int n = vectors.count(); + float[] out = new float[n]; + float[] block = new float[blockSize]; + // Score from the last block backwards so a reader reaches unfilled blocks first. + for (int start = (n - 1) / blockSize * blockSize; start >= 0; start -= blockSize) { + int count = Math.min(blockSize, n - start); + scorer.scoreRange(start, count, block); + System.arraycopy(block, 0, out, start, count); + } + return out; + } +} From a12542ddd3b8436b64db1a9d68d28c859d22eb04 Mon Sep 17 00:00:00 2001 From: Ted Willke Date: Wed, 11 Nov 2026 15:50:08 -0800 Subject: [PATCH 226/243] Keep the native build quiet unless JVECTOR_NATIVE_BUILD_VERBOSE is set Every Maven build printed each shell command of build_native_lib.sh plus the full Meson and Ninja output. By default the script now prints one line when each library starts building and one with its build time when it finishes. Meson and Ninja output goes to target/meson-build-.setup.log and .compile.log, and a failing step prints the end of its log and the log's path. Errors and the script's own warnings still print. Setting JVECTOR_NATIVE_BUILD_VERBOSE=1 restores all output, including the command trace. --- jvector-native/src/main/native/README.md | 5 ++ .../src/main/native/src/build_native_lib.sh | 48 ++++++++++++++----- 2 files changed, 42 insertions(+), 11 deletions(-) diff --git a/jvector-native/src/main/native/README.md b/jvector-native/src/main/native/README.md index bcdfa37d5..cd8f90ee3 100644 --- a/jvector-native/src/main/native/README.md +++ b/jvector-native/src/main/native/README.md @@ -132,6 +132,11 @@ This is the easiest way to get started on a fresh Ubuntu machine. For other distributions the script will print an error indicating which install commands need to be added. +The build is quiet by default: it prints one line when each library starts and finishes, while +Meson and Ninja output goes to `target/meson-build-.setup.log` and `.compile.log` and is +printed only if a step fails. Set `JVECTOR_NATIVE_BUILD_VERBOSE=1` to +print everything, including each command the script runs. + ### Cross-compilation Both `.so` files can be produced on a single machine without access to the diff --git a/jvector-native/src/main/native/src/build_native_lib.sh b/jvector-native/src/main/native/src/build_native_lib.sh index 9c28dd81f..d032b19ac 100755 --- a/jvector-native/src/main/native/src/build_native_lib.sh +++ b/jvector-native/src/main/native/src/build_native_lib.sh @@ -2,8 +2,29 @@ # fail on error set -e -# print commands as they are executed -set -x +# Quiet by default: Meson and Ninja output goes to a log in the build directory and is shown +# only if a step fails. JVECTOR_NATIVE_BUILD_VERBOSE=1 prints everything, including each command. +VERBOSE="${JVECTOR_NATIVE_BUILD_VERBOSE:-}" +if [ -n "${VERBOSE}" ]; then set -x; fi + +# Prints a status line only in verbose mode. +info() { + if [ -n "${VERBOSE}" ]; then printf "$@"; fi +} + +# Runs a build step. Quiet mode keeps its output in and prints the log's end on failure. +# Usage: run_step +run_step() { + local log="$1"; shift + if [ -n "${VERBOSE}" ]; then "$@"; return; fi + mkdir -p "$(dirname "${log}")" + if ! "$@" > "${log}" 2>&1; then + echo "ERROR: native build step failed: $*" >&2 + tail -n 60 "${log}" >&2 + echo "Full log: ${log} (rerun with JVECTOR_NATIVE_BUILD_VERBOSE=1 to print everything)" >&2 + exit 1 + fi +} # Copyright DataStax, Inc. # @@ -36,7 +57,7 @@ MODULE_ROOT="${REPO_ROOT}/jvector-native" RESOURCES_DIR="${MODULE_ROOT}/target/meson-build" if [ "$1" == "--auto-install-deps" ] ; then AUTO_INSTALL_DEPS=true ; shift ; fi -printf "AUTO_INSTALL_DEPS=%s\n" "${AUTO_INSTALL_DEPS}" +info "AUTO_INSTALL_DEPS=%s\n" "${AUTO_INSTALL_DEPS}" # Accept buildtype parameter (default: release) BUILDTYPE="${1:-release}" @@ -45,14 +66,14 @@ if [ "$BUILDTYPE" != "release" ] && [ "$BUILDTYPE" != "debug" ] && [ "$BUILDTYPE echo " Valid values: release, debug, debugoptimized" BUILDTYPE="release" fi -printf "BUILDTYPE=%s\n" "${BUILDTYPE}" +info "BUILDTYPE=%s\n" "${BUILDTYPE}" # Accept crossarch flag (default: false). # When true, builds both the native arch library AND cross-compiles for the other arch. # On x86_64: builds libjvector-x86_64.so + cross-compiles libjvector-aarch64.so # On aarch64: builds libjvector-aarch64.so + cross-compiles libjvector-x86_64.so CROSSARCH="${2:-false}" -printf "CROSSARCH=%s\n" "${CROSSARCH}" +info "CROSSARCH=%s\n" "${CROSSARCH}" # Optional oneDNN add-on for FP32 encoding batches and FP16/INT8 queries, built # from its own Meson project; libjvector stays independent. Off by default because @@ -102,7 +123,7 @@ fi # Detect the host CPU architecture. # --------------------------------------------------------------------------- HOST_ARCH="$(uname -m)" # e.g. x86_64 or aarch64 -printf "HOST_ARCH=%s\n" "${HOST_ARCH}" +info "HOST_ARCH=%s\n" "${HOST_ARCH}" # --------------------------------------------------------------------------- # build_native @@ -152,9 +173,11 @@ build_native() { cross_options=(--cross-file "${CROSS_FILE}") fi - meson setup "${BUILD_DIR}" "${NATIVE_DIR}" \ + echo "jvector-native: building the native library for ${ARCH} (the first build can take a few minutes)" + local started=${SECONDS} + run_step "${BUILD_DIR}.setup.log" meson setup "${BUILD_DIR}" "${NATIVE_DIR}" \ "${setup_options[@]}" "${cross_options[@]}" --buildtype="${BUILDTYPE}" - meson compile -C "${BUILD_DIR}" + run_step "${BUILD_DIR}.compile.log" meson compile -C "${BUILD_DIR}" # The versioned .so (e.g. libjvector.so.0.1.0) is the real file; symlinks point to it. SOFILE=$(find "${BUILD_DIR}" -maxdepth 1 -name 'libjvector.so.*' -type f | head -1) @@ -163,7 +186,7 @@ build_native() { exit 1 fi cp "${SOFILE}" "${OUT_SO}" - printf "Built: %s\n" "${OUT_SO}" + echo "jvector-native: built $(basename "${OUT_SO}") in $((SECONDS - started)) s" local FP16_OUT="${RESOURCES_DIR}/libjvector_ash_fp16-${ARCH}.so" if [ "${ONEDNN}" == "true" ]; then @@ -172,10 +195,13 @@ build_native() { if [[ -f "${ONEDNN_BUILD_DIR}/meson-private/coredata.dat" ]]; then onednn_setup_options+=(--reconfigure) fi - meson setup "${ONEDNN_BUILD_DIR}" "${ONEDNN_DIR}" \ + echo "jvector-native: building the oneDNN add-on for ${ARCH} (the first build can take several minutes)" + started=${SECONDS} + run_step "${ONEDNN_BUILD_DIR}.setup.log" meson setup "${ONEDNN_BUILD_DIR}" "${ONEDNN_DIR}" \ "${onednn_setup_options[@]}" "${cross_options[@]}" --buildtype="${BUILDTYPE}" - meson compile -C "${ONEDNN_BUILD_DIR}" + run_step "${ONEDNN_BUILD_DIR}.compile.log" meson compile -C "${ONEDNN_BUILD_DIR}" cp "${ONEDNN_BUILD_DIR}/libjvector_ash_fp16.so" "${FP16_OUT}" + echo "jvector-native: built $(basename "${FP16_OUT}") in $((SECONDS - started)) s" # License texts travel with the optional static dependency in either jar. cp "${ONEDNN_DIR}/subprojects/onednn/LICENSE" "${RESOURCES_DIR}/oneDNN-LICENSE.txt" cp "${ONEDNN_DIR}/subprojects/onednn/THIRD-PARTY-PROGRAMS" "${RESOURCES_DIR}/oneDNN-THIRD-PARTY.txt" From ba3293401475e30f17254284a2dbe615f5e294c6 Mon Sep 17 00:00:00 2001 From: Ted Willke Date: Wed, 11 Nov 2026 16:08:07 -0800 Subject: [PATCH 227/243] Show FP32 for uncompressed build and search stages in the BenchYAML pipeline line A compression of None means the stage scores full-precision vectors, so the pipeline line now says FP32 instead of None (e.g. 'inMemory: build FP32 -> search FP32 -> rerank None'). Rerank keeps None, since that step is not done. --- .../io/github/jbellis/jvector/example/yaml/MultiConfig.java | 3 ++- .../jbellis/jvector/example/yaml/IndexParametersTest.java | 4 +++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/yaml/MultiConfig.java b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/yaml/MultiConfig.java index 9755bc531..80c396b0b 100644 --- a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/yaml/MultiConfig.java +++ b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/yaml/MultiConfig.java @@ -293,7 +293,8 @@ private String pipeline(boolean detailed) { private static String describe(List entries, boolean detailed) { var parts = new ArrayList(); for (var entry : entries) { - String part = entry.type; + // No compression means the stage scores full-precision vectors; say so. + String part = "None".equals(entry.type) ? "FP32" : entry.type; if (detailed && "ASH".equals(entry.type)) { Map parameters = entry.parameters == null ? Map.of() : entry.parameters; Object bits = parameters.get("bitsPerDimension"); diff --git a/jvector-examples/src/test/java/io/github/jbellis/jvector/example/yaml/IndexParametersTest.java b/jvector-examples/src/test/java/io/github/jbellis/jvector/example/yaml/IndexParametersTest.java index fd3115ad0..a4d617c1c 100644 --- a/jvector-examples/src/test/java/io/github/jbellis/jvector/example/yaml/IndexParametersTest.java +++ b/jvector-examples/src/test/java/io/github/jbellis/jvector/example/yaml/IndexParametersTest.java @@ -216,7 +216,9 @@ public void pipelineIsInferredFromTheFields() throws Exception { assertEquals("onDisk: build ASH-2b symmetric -> search fused ASH-2b -> rerank NVQ", compressed.describePipeline()); assertEquals("onDisk: build ASH -> search fused ASH -> rerank NVQ", compressed.pipelineSignature()); var memory = MultiConfig.getConfig(INDEX_PARAMETERS.resolve("in-memory-ash/mpnet-1m.yml").toString()); - assertEquals("inMemory: build None -> search ASH-2b -> rerank FP32", memory.describePipeline()); + assertEquals("inMemory: build FP32 -> search ASH-2b -> rerank FP32", memory.describePipeline()); + var uncompressed = MultiConfig.getConfig(INDEX_PARAMETERS.resolve("glove-100-angular.yml").toString()); + assertEquals("onDisk: build FP32 -> search FP32 -> rerank None", uncompressed.describePipeline()); var landmarks = MultiConfig.getConfig(INDEX_PARAMETERS.resolve("ash-options/ada002-1M-4-landmarks.yml").toString()); assertEquals("onDisk: build ASH-2b 4 landmarks asymmetric -> search fused ASH-2b 4 landmarks -> rerank NVQ", landmarks.describePipeline()); From 8b4adddb89de67a5016f3e31852691e470680681 Mon Sep 17 00:00:00 2001 From: Ted Willke Date: Wed, 11 Nov 2026 16:15:39 -0800 Subject: [PATCH 228/243] Print native build progress only for first builds and real rebuilds Every Maven run printed a start and finish line per native library even when Ninja had nothing to do, so repeated builds (IDE reimports, multi-step runs) filled the console. The start line now prints only for an unconfigured build directory, the slow first build, and the finish line only when Ninja rebuilt something; an up-to-date build prints nothing. --- jvector-native/src/main/native/README.md | 7 ++++--- .../src/main/native/src/build_native_lib.sh | 21 +++++++++++++++---- 2 files changed, 21 insertions(+), 7 deletions(-) diff --git a/jvector-native/src/main/native/README.md b/jvector-native/src/main/native/README.md index cd8f90ee3..c1a69275d 100644 --- a/jvector-native/src/main/native/README.md +++ b/jvector-native/src/main/native/README.md @@ -132,10 +132,11 @@ This is the easiest way to get started on a fresh Ubuntu machine. For other distributions the script will print an error indicating which install commands need to be added. -The build is quiet by default: it prints one line when each library starts and finishes, while +The build is quiet by default. A first build prints one line when each library starts and one +when it finishes; a later build prints a finish line only for a library it actually rebuilt. Meson and Ninja output goes to `target/meson-build-.setup.log` and `.compile.log` and is -printed only if a step fails. Set `JVECTOR_NATIVE_BUILD_VERBOSE=1` to -print everything, including each command the script runs. +printed only if a step fails. Set `JVECTOR_NATIVE_BUILD_VERBOSE=1` to print everything, +including each command the script runs. ### Cross-compilation diff --git a/jvector-native/src/main/native/src/build_native_lib.sh b/jvector-native/src/main/native/src/build_native_lib.sh index d032b19ac..73e646f41 100755 --- a/jvector-native/src/main/native/src/build_native_lib.sh +++ b/jvector-native/src/main/native/src/build_native_lib.sh @@ -12,6 +12,19 @@ info() { if [ -n "${VERBOSE}" ]; then printf "$@"; fi } +# A library's progress lines: the start line only for a first (unconfigured) build, which is the slow +# case, and the finish line only when Ninja rebuilt something, so an up-to-date build prints nothing. +# Usage: announce_start ; report_built +announce_start() { + if [[ ! -f "$1/meson-private/coredata.dat" ]]; then + echo "jvector-native: building $2 (the first build can take $3)" + fi +} +report_built() { + if [ -z "${VERBOSE}" ] && grep -q "ninja: no work to do" "$1" 2>/dev/null; then return; fi + echo "jvector-native: built $2 in $((SECONDS - $3)) s" +} + # Runs a build step. Quiet mode keeps its output in and prints the log's end on failure. # Usage: run_step run_step() { @@ -173,7 +186,7 @@ build_native() { cross_options=(--cross-file "${CROSS_FILE}") fi - echo "jvector-native: building the native library for ${ARCH} (the first build can take a few minutes)" + announce_start "${BUILD_DIR}" "the native library for ${ARCH}" "a few minutes" local started=${SECONDS} run_step "${BUILD_DIR}.setup.log" meson setup "${BUILD_DIR}" "${NATIVE_DIR}" \ "${setup_options[@]}" "${cross_options[@]}" --buildtype="${BUILDTYPE}" @@ -186,7 +199,7 @@ build_native() { exit 1 fi cp "${SOFILE}" "${OUT_SO}" - echo "jvector-native: built $(basename "${OUT_SO}") in $((SECONDS - started)) s" + report_built "${BUILD_DIR}.compile.log" "$(basename "${OUT_SO}")" "${started}" local FP16_OUT="${RESOURCES_DIR}/libjvector_ash_fp16-${ARCH}.so" if [ "${ONEDNN}" == "true" ]; then @@ -195,13 +208,13 @@ build_native() { if [[ -f "${ONEDNN_BUILD_DIR}/meson-private/coredata.dat" ]]; then onednn_setup_options+=(--reconfigure) fi - echo "jvector-native: building the oneDNN add-on for ${ARCH} (the first build can take several minutes)" + announce_start "${ONEDNN_BUILD_DIR}" "the oneDNN add-on for ${ARCH}" "several minutes" started=${SECONDS} run_step "${ONEDNN_BUILD_DIR}.setup.log" meson setup "${ONEDNN_BUILD_DIR}" "${ONEDNN_DIR}" \ "${onednn_setup_options[@]}" "${cross_options[@]}" --buildtype="${BUILDTYPE}" run_step "${ONEDNN_BUILD_DIR}.compile.log" meson compile -C "${ONEDNN_BUILD_DIR}" cp "${ONEDNN_BUILD_DIR}/libjvector_ash_fp16.so" "${FP16_OUT}" - echo "jvector-native: built $(basename "${FP16_OUT}") in $((SECONDS - started)) s" + report_built "${ONEDNN_BUILD_DIR}.compile.log" "$(basename "${FP16_OUT}")" "${started}" # License texts travel with the optional static dependency in either jar. cp "${ONEDNN_DIR}/subprojects/onednn/LICENSE" "${RESOURCES_DIR}/oneDNN-LICENSE.txt" cp "${ONEDNN_DIR}/subprojects/onednn/THIRD-PARTY-PROGRAMS" "${RESOURCES_DIR}/oneDNN-THIRD-PARTY.txt" From c7ec5d4ce0e01482bc756515a82da97020a62731 Mon Sep 17 00:00:00 2001 From: Ted Willke Date: Wed, 11 Nov 2026 16:28:41 -0800 Subject: [PATCH 229/243] Reconfigure native builds only when their setup arguments change Every Maven run reran meson setup --reconfigure. For the oneDNN add-on that regenerated its CMake subproject and relinked the library each time, adding 2-3 s and a 'built' line to every build. Each build directory now records its setup arguments and is reconfigured only when they change; meson compile still regenerates on its own when a meson.build file changes. An up-to-date build now takes about 0.3 s and prints nothing. --- .../src/main/native/src/build_native_lib.sh | 32 +++++++++++-------- 1 file changed, 19 insertions(+), 13 deletions(-) diff --git a/jvector-native/src/main/native/src/build_native_lib.sh b/jvector-native/src/main/native/src/build_native_lib.sh index 73e646f41..97f45e628 100755 --- a/jvector-native/src/main/native/src/build_native_lib.sh +++ b/jvector-native/src/main/native/src/build_native_lib.sh @@ -25,6 +25,21 @@ report_built() { echo "jvector-native: built $2 in $((SECONDS - $3)) s" } +# Configures only when it is new or its setup arguments changed. Reconfiguring an +# unchanged build regenerates the oneDNN CMake subproject and forces a relink on every build; +# meson compile still regenerates by itself when a meson.build file changes. +# Usage: setup_if_needed +setup_if_needed() { + local build_dir="$1" source_dir="$2"; shift 2 + local stamp="${build_dir}.setup-args" wanted="${source_dir} $*" + if [[ -f "${build_dir}/meson-private/coredata.dat" ]]; then + if [[ -f "${stamp}" && "$(cat "${stamp}")" == "${wanted}" ]]; then return; fi + set -- --reconfigure "$@" + fi + run_step "${build_dir}.setup.log" meson setup "${build_dir}" "${source_dir}" "$@" + printf '%s' "${wanted}" > "${stamp}" +} + # Runs a build step. Quiet mode keeps its output in and prints the log's end on failure. # Usage: run_step run_step() { @@ -151,11 +166,8 @@ build_native() { rm -rf "${OUT_SO}" - # Reuse configured builds so repeated Maven phases keep incremental native outputs. - local setup_options=() cross_options=() - if [[ -f "${BUILD_DIR}/meson-private/coredata.dat" ]]; then - setup_options+=(--reconfigure) - fi + # Configured builds are reused, so repeated Maven phases keep incremental native outputs. + local cross_options=() if [ "${ARCH}" == "${HOST_ARCH}" ]; then # ---- Native build ------------------------------------------------------ @@ -188,8 +200,7 @@ build_native() { announce_start "${BUILD_DIR}" "the native library for ${ARCH}" "a few minutes" local started=${SECONDS} - run_step "${BUILD_DIR}.setup.log" meson setup "${BUILD_DIR}" "${NATIVE_DIR}" \ - "${setup_options[@]}" "${cross_options[@]}" --buildtype="${BUILDTYPE}" + setup_if_needed "${BUILD_DIR}" "${NATIVE_DIR}" "${cross_options[@]}" --buildtype="${BUILDTYPE}" run_step "${BUILD_DIR}.compile.log" meson compile -C "${BUILD_DIR}" # The versioned .so (e.g. libjvector.so.0.1.0) is the real file; symlinks point to it. @@ -204,14 +215,9 @@ build_native() { local FP16_OUT="${RESOURCES_DIR}/libjvector_ash_fp16-${ARCH}.so" if [ "${ONEDNN}" == "true" ]; then local ONEDNN_BUILD_DIR="${MODULE_ROOT}/target/meson-build-onednn-${ARCH}" - local onednn_setup_options=() - if [[ -f "${ONEDNN_BUILD_DIR}/meson-private/coredata.dat" ]]; then - onednn_setup_options+=(--reconfigure) - fi announce_start "${ONEDNN_BUILD_DIR}" "the oneDNN add-on for ${ARCH}" "several minutes" started=${SECONDS} - run_step "${ONEDNN_BUILD_DIR}.setup.log" meson setup "${ONEDNN_BUILD_DIR}" "${ONEDNN_DIR}" \ - "${onednn_setup_options[@]}" "${cross_options[@]}" --buildtype="${BUILDTYPE}" + setup_if_needed "${ONEDNN_BUILD_DIR}" "${ONEDNN_DIR}" "${cross_options[@]}" --buildtype="${BUILDTYPE}" run_step "${ONEDNN_BUILD_DIR}.compile.log" meson compile -C "${ONEDNN_BUILD_DIR}" cp "${ONEDNN_BUILD_DIR}/libjvector_ash_fp16.so" "${FP16_OUT}" report_built "${ONEDNN_BUILD_DIR}.compile.log" "$(basename "${FP16_OUT}")" "${started}" From cba592728c9ac5afa969829fe90900475d41dae5 Mon Sep 17 00:00:00 2001 From: Ted Willke Date: Wed, 11 Nov 2026 16:29:51 -0800 Subject: [PATCH 230/243] Report the CPU and INT8 settings when the INT8 projection tests fail The INT8 projection tests fail on one CI runner by one or two ULPs against the oracle but pass on every machine we can reproduce on. The tile and oracle assertions now name the CPU vendor, model, the flags that select the INT8 path (VNNI, AMX, AVX-512 FP16/BF16) and the oneDNN override, so a failure shows which path ran. The message is built only when an assertion fails. --- .../vector/NativeASHInt8ProjectionTest.java | 40 +++++++++++++++++-- 1 file changed, 36 insertions(+), 4 deletions(-) diff --git a/jvector-native/src/test/java/io/github/jbellis/jvector/vector/NativeASHInt8ProjectionTest.java b/jvector-native/src/test/java/io/github/jbellis/jvector/vector/NativeASHInt8ProjectionTest.java index c614a01a7..fea2bafc1 100644 --- a/jvector-native/src/test/java/io/github/jbellis/jvector/vector/NativeASHInt8ProjectionTest.java +++ b/jvector-native/src/test/java/io/github/jbellis/jvector/vector/NativeASHInt8ProjectionTest.java @@ -54,6 +54,33 @@ static void loadNative() { else assumeTrue(available, "Native library unavailable"); } + private static final String[] PATH_FLAGS = {"fma", "avx2", "avx_vnni", "avx512f", "avx512vl", "avx512_vnni", + "avx512_bf16", "avx512_fp16", "amx_tile", "amx_int8"}; + + /** The CPU and settings that select the INT8 path, for failure messages; built only when an assertion fails. */ + private static String cpuContext() { + String vendor = "?", model = "?", flags = ""; + try { + for (String line : Files.readAllLines(Path.of("/proc/cpuinfo"))) { + int colon = line.indexOf(':'); + if (colon < 0) continue; + String key = line.substring(0, colon).trim(), value = line.substring(colon + 1).trim(); + if (key.equals("vendor_id") && vendor.equals("?")) vendor = value; + else if (key.equals("model name") && model.equals("?")) model = value; + else if (key.equals("flags") && flags.isEmpty()) { + var present = new java.util.HashSet<>(Arrays.asList(value.split("\\s+"))); + var kept = new ArrayList(); + for (String flag : PATH_FLAGS) if (present.contains(flag)) kept.add(flag); + flags = String.join(" ", kept); + } + } + } catch (java.io.IOException | RuntimeException e) { + model = "unreadable /proc/cpuinfo (" + e + ")"; + } + return " [cpu: " + vendor + ", " + model + "; flags: " + flags + "; JVECTOR_ASH_INT8_ONEDNN=" + + System.getenv("JVECTOR_ASH_INT8_ONEDNN") + "; os.arch=" + System.getProperty("os.arch") + "]"; + } + private static VectorUtilSupport.AshProjection prepare(float[][] weights) { var base = new NativeASHProjection(weights); try { @@ -182,8 +209,10 @@ void everyTileSizeMatchesSingletonsThroughAccumulatorDimensionBound() { for (int i = 0; i < count; i++) { float[] singleton = new float[47]; projection.project(inputs[i], singleton); - assertArrayEquals(singleton, outputs[i], "columns=" + columns + " count=" + count + " row=" + i); - if (columns < 65535) assertArrayEquals(oracle(matrix, inputs[i]), outputs[i], 1e-7f); + final int c = count, r = i; + assertArrayEquals(singleton, outputs[i], () -> "columns=" + columns + " count=" + c + " row=" + r + cpuContext()); + if (columns < 65535) assertArrayEquals(oracle(matrix, inputs[i]), outputs[i], 1e-7f, + () -> "oracle, columns=" + columns + " count=" + c + " row=" + r + cpuContext()); } } } @@ -265,8 +294,11 @@ void concurrentFirstUseAndAlternatingModelsAndShapes() throws Exception { float[][] inputs = matrix(count, weights[model][0].length, seed * 100L + trial); float[][] outputs = new float[count][weights[model].length]; projections[model].projectBatch(inputs, outputs, count); - for (int i = 0; i < count; i++) - assertArrayEquals(oracle(weights[model], inputs[i]), outputs[i], 1e-7f); + for (int i = 0; i < count; i++) { + final int m = model, c = count, r = i; + assertArrayEquals(oracle(weights[model], inputs[i]), outputs[i], 1e-7f, + () -> "oracle, model=" + m + " count=" + c + " row=" + r + cpuContext()); + } } return null; }); From 925ca36f3aed6c552093e208bfb6ff63fa11720e Mon Sep 17 00:00:00 2001 From: Ted Willke Date: Wed, 11 Nov 2026 16:42:56 -0800 Subject: [PATCH 231/243] Let the packed-bits race test tolerate a one-ULP reduction difference Vector API lane reductions do not fix their summation order, so an interpreted and a compiled scoring of the same 1-bit block can differ by one ULP; under JDK 25 the test's exact comparison failed on that alone (e.g. 0.61249167 vs 0.6124917). It now compares within 1e-5. Against the code before the fix it still fails every run, with errors of 0.02 to 0.09. --- .../jvector/quantization/TestASHPackedBitsRace.java | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestASHPackedBitsRace.java b/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestASHPackedBitsRace.java index 31bfd4be6..6a6011f31 100644 --- a/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestASHPackedBitsRace.java +++ b/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestASHPackedBitsRace.java @@ -39,6 +39,7 @@ /** Concurrent first queries on a fresh 1-bit collection must score exactly like a single query. */ public class TestASHPackedBitsRace { private static final String BLOCK = "jvector.ash.blockKernel"; + private static final float TOLERANCE = 1e-5f; @Test public void concurrentFirstBlockScorersSeeFullyPackedBits() throws Exception { @@ -82,8 +83,11 @@ public void concurrentFirstBlockScorersSeeFullyPackedBits() throws Exception { }); } List> results = pool.invokeAll(tasks); - assertArrayEquals("round " + round, expected[a], results.get(0).get(), 0f); - assertArrayEquals("round " + round, expected[b], results.get(1).get(), 0f); + // Vector API lane reductions do not fix their summation order, so an early (interpreted) and a later + // (compiled) scoring of the same data can differ by an ULP. A partial or wrong-size array is off by + // about 0.1, so this tolerance still catches the race by four orders of magnitude. + assertArrayEquals("round " + round, expected[a], results.get(0).get(), TOLERANCE); + assertArrayEquals("round " + round, expected[b], results.get(1).get(), TOLERANCE); } } finally { pool.shutdownNow(); From f24ea173a7723da161cc5b3d94de3abb0a064964 Mon Sep 17 00:00:00 2001 From: Ted Willke Date: Wed, 11 Nov 2026 17:24:25 -0800 Subject: [PATCH 232/243] Compile the INT8 projection library without floating-point contraction --- jvector-native/src/main/onednn/meson.build | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/jvector-native/src/main/onednn/meson.build b/jvector-native/src/main/onednn/meson.build index 9a165a662..ce24a1479 100644 --- a/jvector-native/src/main/onednn/meson.build +++ b/jvector-native/src/main/onednn/meson.build @@ -61,7 +61,9 @@ ash_fp16_lib = shared_library('jvector_ash_fp16', sources: ['src/jvector_ash_fp16.cpp', 'src/jvector_ash_int8.cpp'], dependencies: onednn_dependency, link_with: onednn_objects, - cpp_args: ['-fvisibility=hidden'], + # No contraction: a compiler may otherwise fuse the INT8 final step, c + f / 254, into one FMA + # on some builds, which rounds differently from the unfused FP32 arithmetic of the Java path. + cpp_args: ['-fvisibility=hidden', '-ffp-contract=off'], link_args: ['-Wl,--exclude-libs,ALL'], install: true, ) From 9c85d62aeda8f1ec886cd9b87e8969acfffc3adb Mon Sep 17 00:00:00 2001 From: Ted Willke Date: Wed, 11 Nov 2026 22:17:18 -0800 Subject: [PATCH 233/243] Initialize the fvec reader before the read-progress tests' timeouts start FvecFileVectorValues loads the vectorization provider, possibly a native library, when its class initializes. That happened inside whichever timed test ran first and took 111 ms of a 1 s limit on an idle server, more on a busy CI runner. Initializing the class in @BeforeClass leaves each timeout covering only the read loop it guards; the limit is unchanged. --- .../jbellis/jvector/disk/TestFvecReadProgress.java | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/jvector-tests/src/test/java/io/github/jbellis/jvector/disk/TestFvecReadProgress.java b/jvector-tests/src/test/java/io/github/jbellis/jvector/disk/TestFvecReadProgress.java index 06b1f0880..31283b02f 100644 --- a/jvector-tests/src/test/java/io/github/jbellis/jvector/disk/TestFvecReadProgress.java +++ b/jvector-tests/src/test/java/io/github/jbellis/jvector/disk/TestFvecReadProgress.java @@ -15,6 +15,7 @@ */ package io.github.jbellis.jvector.disk; +import org.junit.BeforeClass; import org.junit.Test; import java.io.EOFException; import java.io.IOException; @@ -24,6 +25,14 @@ import static org.junit.Assert.*; public class TestFvecReadProgress { + /** + * Initialize FvecFileVectorValues, which loads the vectorization provider (possibly a native library), before + * any timed test, so each timeout covers only the read loop it guards against spinning. + */ + @BeforeClass public static void initializeOutsideTheTimeouts() throws ClassNotFoundException { + Class.forName(FvecFileVectorValues.class.getName(), true, TestFvecReadProgress.class.getClassLoader()); + } + @Test(timeout = 1000) public void repeatedZeroReadsFailInsteadOfSpinning() { var channel = new TestChannel(-1); var failure = assertThrows(IOException.class, () -> From 795bef3b714261ece27d21c1755cc5d131908cbb Mon Sep 17 00:00:00 2001 From: Ted Willke Date: Wed, 11 Nov 2026 22:45:11 -0800 Subject: [PATCH 234/243] Combine the INT8 query digits exactly so no compiler can fuse the final step The projected value is (coarse + fine / 254) * scales. Instead of turning off floating-point contraction for the whole library, combine the two digit dot products exactly in 64-bit integers first, coarse * 254 + fine, so the remaining steps are one conversion and multiplications only. With no product added to anything there is nothing to fuse into an FMA, every build rounds the same way, and the combine loop stays free to vectorize. The test oracle uses the same expression, so the INT8 tests still compare exactly. --- jvector-native/src/main/onednn/meson.build | 4 +--- .../src/main/onednn/src/jvector_ash_int8.cpp | 16 +++++++++++----- .../vector/NativeASHInt8ProjectionTest.java | 3 ++- 3 files changed, 14 insertions(+), 9 deletions(-) diff --git a/jvector-native/src/main/onednn/meson.build b/jvector-native/src/main/onednn/meson.build index ce24a1479..9a165a662 100644 --- a/jvector-native/src/main/onednn/meson.build +++ b/jvector-native/src/main/onednn/meson.build @@ -61,9 +61,7 @@ ash_fp16_lib = shared_library('jvector_ash_fp16', sources: ['src/jvector_ash_fp16.cpp', 'src/jvector_ash_int8.cpp'], dependencies: onednn_dependency, link_with: onednn_objects, - # No contraction: a compiler may otherwise fuse the INT8 final step, c + f / 254, into one FMA - # on some builds, which rounds differently from the unfused FP32 arithmetic of the Java path. - cpp_args: ['-fvisibility=hidden', '-ffp-contract=off'], + cpp_args: ['-fvisibility=hidden'], link_args: ['-Wl,--exclude-libs,ALL'], install: true, ) diff --git a/jvector-native/src/main/onednn/src/jvector_ash_int8.cpp b/jvector-native/src/main/onednn/src/jvector_ash_int8.cpp index 480eb5732..235543bd3 100644 --- a/jvector-native/src/main/onednn/src/jvector_ash_int8.cpp +++ b/jvector-native/src/main/onednn/src/jvector_ash_int8.cpp @@ -189,6 +189,15 @@ JV_ASH_INT8_TARGET float quantize_two_digits(const float* input, int count, uint return scale; } +// One projected value from the coarse and fine digit dot products: (coarse + fine / 254) * scales. The digits are +// combined exactly in integers first, coarse * 254 + fine, so the floating-point steps are one conversion and +// multiplications only. No product is added to anything, so no compiler can fuse a step into an FMA, and every +// build rounds exactly as the Java test oracle does. 64 bits hold the sum: |coarse| < 65536 * 127 * 127. +inline float combine_digits(int32_t coarse, int32_t fine, float query_scale, float row_scale) { + constexpr float kInv254 = 1.0f / 254.0f; + return float(int64_t(coarse) * 254 + fine) * kInv254 * query_scale * row_scale; +} + #if defined(__x86_64__) || defined(_M_X64) // NB blocks of 16 output rows (two 8-lane halves each) for every query row of // the tile: 2*M*NB independent int32 accumulators hide the dot-product latency. @@ -430,14 +439,12 @@ JV_ASH_INT8_TARGET int jvector_ash_int8_project_batch(void* pointer, const float context.padded_rows / 16, coarseDots.data(), context.padded_rows); gemm(size, fine.data(), context.padded_columns, context.packed.get(), context.padded_columns / 4, context.padded_rows / 16, fineDots.data(), context.padded_rows); - constexpr float kInv254 = 1.0f / 254.0f; for (int i = 0; i < size; ++i) for (int row = 0; row < context.rows; ++row) { size_t at = size_t(i) * context.padded_rows + row; int32_t c = coarseDots[at] - 128 * context.sums[row]; int32_t f = fineDots[at] - 128 * context.sums[row]; - output[size_t(first + i) * context.rows + row] = - (float(c) + float(f) * kInv254) * scales[i] * context.scales[row]; + output[size_t(first + i) * context.rows + row] = combine_digits(c, f, scales[i], context.scales[row]); } first += size; continue; @@ -468,14 +475,13 @@ JV_ASH_INT8_TARGET int jvector_ash_int8_project_batch(void* pointer, const float buffers.execution->wait(); if (pass == 0) std::memcpy(coarseSums.data(), sums, sbytes * sizeof(int32_t)); } - constexpr float kInv254 = 1.0f / 254.0f; for (int i = 0; i < size; ++i) for (int row = 0; row < context.rows; ++row) { size_t at = size_t(i) * context.rows + row; int32_t c = coarseSums[at] - 128 * context.sums[row]; int32_t f = sums[at] - 128 * context.sums[row]; output[size_t(first + i) * context.rows + row] = - (float(c) + float(f) * kInv254) * buffers.scales[i] * context.scales[row]; + combine_digits(c, f, buffers.scales[i], context.scales[row]); } first += size; // Cannot overflow even when count approaches INT_MAX. } diff --git a/jvector-native/src/test/java/io/github/jbellis/jvector/vector/NativeASHInt8ProjectionTest.java b/jvector-native/src/test/java/io/github/jbellis/jvector/vector/NativeASHInt8ProjectionTest.java index fea2bafc1..ff61f2a3b 100644 --- a/jvector-native/src/test/java/io/github/jbellis/jvector/vector/NativeASHInt8ProjectionTest.java +++ b/jvector-native/src/test/java/io/github/jbellis/jvector/vector/NativeASHInt8ProjectionTest.java @@ -118,7 +118,8 @@ private static float[] oracle(float[][] matrix, float[] query) { coarse += (long) code(query[column], queryScale) * weight; fine += (long) fine(query[column], queryScale) * weight; } - out[row] = ((float) coarse + (float) fine * (1.0f / 254.0f)) * queryScale * weightScale; + // As the native kernels: combine the digits exactly, then one conversion and multiplications only. + out[row] = (float) (coarse * 254 + fine) * (1.0f / 254.0f) * queryScale * weightScale; } return out; } From aad0da6d475ca9354d8a35467f6f7c77aee0d894 Mon Sep 17 00:00:00 2001 From: Ted Willke Date: Thu, 12 Nov 2026 06:58:49 -0800 Subject: [PATCH 235/243] Measure up to three windows in the zero-allocation tests A JIT deoptimization inside the measured window can make the JVM's own VarHandle linkage allocate a few hundred bytes. Require one of up to three windows to be allocation-free; a real allocation appears in every window. --- .../jvector/quantization/TestASHMemoryAccounting.java | 11 ++++++++--- .../jvector/util/TestCallerBatchProcessor.java | 11 ++++++++--- 2 files changed, 16 insertions(+), 6 deletions(-) diff --git a/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestASHMemoryAccounting.java b/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestASHMemoryAccounting.java index b3df11bec..803f49688 100644 --- a/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestASHMemoryAccounting.java +++ b/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestASHMemoryAccounting.java @@ -430,10 +430,15 @@ public void repeatedModelAndVectorAccountingDoesNotAllocate() throws Exception { // Exercise exactly the measured call site before reading the allocation counter. for (int i = 0; i < 100; i++) accountingResult = readAccounting(ash, vectors, 2048); + // A JIT deoptimization inside a window can make the JVM's own VarHandle linkage allocate, so measure up to + // three windows and require one of them to be allocation-free. A real allocation shows up in every window. long thread = Thread.currentThread().getId(); - long before = bean.getThreadAllocatedBytes(thread); - accountingResult = readAccounting(ash, vectors, 2048); - long allocated = bean.getThreadAllocatedBytes(thread) - before; + long allocated = Long.MAX_VALUE; + for (int window = 0; window < 3 && allocated != 0; window++) { + long before = bean.getThreadAllocatedBytes(thread); + accountingResult = readAccounting(ash, vectors, 2048); + allocated = Math.min(allocated, bean.getThreadAllocatedBytes(thread) - before); + } assertEquals("Polling retained ASH memory must not allocate", 0L, allocated); } diff --git a/jvector-tests/src/test/java/io/github/jbellis/jvector/util/TestCallerBatchProcessor.java b/jvector-tests/src/test/java/io/github/jbellis/jvector/util/TestCallerBatchProcessor.java index bc3b20f59..c53e271c3 100644 --- a/jvector-tests/src/test/java/io/github/jbellis/jvector/util/TestCallerBatchProcessor.java +++ b/jvector-tests/src/test/java/io/github/jbellis/jvector/util/TestCallerBatchProcessor.java @@ -314,10 +314,15 @@ public void apply(Integer value, int[] output) { for (int worker = 0; worker < 8; worker++) work.add(() -> { int[] output = new int[1]; for (int i = 0; i < 20000; i++) processor.process(1, output); + // A JIT deoptimization inside a window can make the JVM's own VarHandle linkage allocate, so measure + // up to three windows and require one to be allocation-free. A real allocation shows up in every one. long tid = Thread.currentThread().getId(); - long before = bean.getThreadAllocatedBytes(tid); - for (int i = 0; i < 2000; i++) processor.process(1, output); - long bytes = bean.getThreadAllocatedBytes(tid) - before; + long bytes = Long.MAX_VALUE; + for (int window = 0; window < 3 && bytes != 0; window++) { + long before = bean.getThreadAllocatedBytes(tid); + for (int i = 0; i < 2000; i++) processor.process(1, output); + bytes = Math.min(bytes, bean.getThreadAllocatedBytes(tid) - before); + } assertEquals(1, output[0]); return bytes; }); From 5cb70f30022934396a03f75c22682f9cd0e65b9d Mon Sep 17 00:00:00 2001 From: Ted Willke Date: Thu, 12 Nov 2026 07:25:21 -0800 Subject: [PATCH 236/243] Guard the read-progress tests with a call budget instead of timeouts A one-second timeout measured the CI machine as well as the read loop and failed under load after the test body had finished. The test channel now fails after a fixed number of read calls, which catches a spinning reader deterministically, and the EOF test also checks its call count. --- .../jvector/disk/TestFvecReadProgress.java | 27 ++++++++++--------- 1 file changed, 14 insertions(+), 13 deletions(-) diff --git a/jvector-tests/src/test/java/io/github/jbellis/jvector/disk/TestFvecReadProgress.java b/jvector-tests/src/test/java/io/github/jbellis/jvector/disk/TestFvecReadProgress.java index 31283b02f..adfdc1a2a 100644 --- a/jvector-tests/src/test/java/io/github/jbellis/jvector/disk/TestFvecReadProgress.java +++ b/jvector-tests/src/test/java/io/github/jbellis/jvector/disk/TestFvecReadProgress.java @@ -15,7 +15,6 @@ */ package io.github.jbellis.jvector.disk; -import org.junit.BeforeClass; import org.junit.Test; import java.io.EOFException; import java.io.IOException; @@ -24,16 +23,14 @@ import java.nio.channels.*; import static org.junit.Assert.*; +/** + * A reader that spins would call the test channel without end, so the channel fails after a fixed number of calls + * instead of the tests relying on a wall-clock timeout, which a slow or busy machine can exceed. + */ public class TestFvecReadProgress { - /** - * Initialize FvecFileVectorValues, which loads the vectorization provider (possibly a native library), before - * any timed test, so each timeout covers only the read loop it guards against spinning. - */ - @BeforeClass public static void initializeOutsideTheTimeouts() throws ClassNotFoundException { - Class.forName(FvecFileVectorValues.class.getName(), true, TestFvecReadProgress.class.getClassLoader()); - } + private static final int MAX_CALLS = 1000; - @Test(timeout = 1000) public void repeatedZeroReadsFailInsteadOfSpinning() { + @Test public void repeatedZeroReadsFailInsteadOfSpinning() { var channel = new TestChannel(-1); var failure = assertThrows(IOException.class, () -> FvecFileVectorValues.readFully(channel, ByteBuffer.allocate(1), 123)); @@ -41,7 +38,7 @@ public class TestFvecReadProgress { assertEquals(16, channel.calls); } - @Test(timeout = 1000) public void progressResetsTheZeroReadLimitAndPreservesOffsets() throws Exception { + @Test public void progressResetsTheZeroReadLimitAndPreservesOffsets() throws Exception { var channel = new TestChannel(15); var buffer = ByteBuffer.allocate(4); buffer.position(2); @@ -52,20 +49,24 @@ public class TestFvecReadProgress { assertEquals((byte) 124, buffer.get(3)); } - @Test(timeout = 1000) public void eofStillFailsImmediately() { + @Test public void eofStillFailsImmediately() { var channel = new TestChannel(0) { - @Override public int read(ByteBuffer buffer, long offset) { return -1; } + @Override public int read(ByteBuffer buffer, long offset) { countCall(); return -1; } }; assertThrows(EOFException.class, () -> FvecFileVectorValues.readFully(channel, ByteBuffer.allocate(1), 0)); + assertEquals(1, channel.calls); } private static class TestChannel extends FileChannel { final int zerosPerByte; int calls, zeros; TestChannel(int zerosPerByte) { this.zerosPerByte = zerosPerByte; } + void countCall() { + if (++calls > MAX_CALLS) throw new AssertionError("readFully kept reading after " + MAX_CALLS + " calls"); + } @Override public int read(ByteBuffer buffer, long offset) { - calls++; + countCall(); if (zerosPerByte < 0 || zeros++ < zerosPerByte) return 0; zeros = 0; buffer.put((byte) offset); From 2cf238b53c7f9c8326725907c9036097eadd3720 Mon Sep 17 00:00:00 2001 From: Ted Willke Date: Wed, 11 Nov 2026 22:39:43 -0800 Subject: [PATCH 237/243] Defer search candidates that can no longer be expanded A layer search expands a scored node only while it ranks among the best rerankK accepted nodes scored so far, or, with adaptive termination, within the cutoff of the rerankK-th; that bar only rises. A node that misses it when scored can never be expanded by that search, so it now goes to an unsorted deferred list instead of the candidate heap, and returns to the candidates at the next layer search, so later layers and resume() see the same candidates as before. Most scored neighbors are rejected with one comparison, as in DiskANN's bounded search list. The same top-rerankK heap drives adaptive termination, replacing its membership hash set: the visited set already keeps scored nodes unique, so the extra offer on each pop and the membership lookups are gone. Threshold searches keep every candidate. Results, scores, and visited and expanded counts are unchanged; a test compares bounded and unbounded searchers across stopping rules, filters, hierarchy and resume(). --- .../jvector/graph/AdaptiveTermination.java | 16 +++ .../jbellis/jvector/graph/GraphSearcher.java | 98 ++++++++++++----- .../jvector/graph/TestBoundedCandidates.java | 102 ++++++++++++++++++ 3 files changed, 188 insertions(+), 28 deletions(-) create mode 100644 jvector-tests/src/test/java/io/github/jbellis/jvector/graph/TestBoundedCandidates.java diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/graph/AdaptiveTermination.java b/jvector-base/src/main/java/io/github/jbellis/jvector/graph/AdaptiveTermination.java index daa3ec21f..79357896c 100644 --- a/jvector-base/src/main/java/io/github/jbellis/jvector/graph/AdaptiveTermination.java +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/graph/AdaptiveTermination.java @@ -111,6 +111,22 @@ Decision shouldTerminate(float candidateScore, NodeQueue discoveredResults) { : Decision.CONTINUE; } + /** + * The lowest score a candidate may have and still be continued when the k-th best score found is + * {@code kthScore}: candidates scoring below it are past the cutoff, which only tightens as the search finds + * better nodes. Rounded down, so it never excludes a candidate {@link #shouldTerminate} would continue; when + * this rule cannot interpret the scores it returns negative infinity, leaving the standard rule to decide. + */ + float lowestContinuableScore(float kthScore) { + double kthDistance = scoreToDistance(kthScore); + if (unsupportedScoreObserved || Double.isNaN(kthDistance) || Double.isInfinite(kthDistance)) { + return Float.NEGATIVE_INFINITY; + } + double cutoff = (1.0d + gamma) * kthDistance + EPSILON; + double score = rawDotProductScores ? 1.0d - 2.0d * cutoff : 1.0d - cutoff; + return Math.nextDown(Math.nextDown((float) score)); + } + private double scoreToDistance(float score) { if (!Float.isFinite(score)) { return Double.NaN; 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 c62186822..63d0c431c 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 @@ -55,6 +55,9 @@ public class GraphSearcher implements Closeable { private final NodeQueue rerankedResults; private final IntHashSet visited; private final NodesUnsorted evictedResults; + // Scored nodes that the current layer search can no longer expand (see searchOneLayer); they return to the + // candidates at the next layer search, so a later layer or resume() sees the same candidates as before. + private final NodesUnsorted deferredCandidates; // Search parameters that we save here for use by resume() private Bits acceptOrds; @@ -62,11 +65,15 @@ public class GraphSearcher implements Closeable { private CachingReranker cachingReranker; private boolean pruneSearch; + private boolean boundCandidates = true; private boolean adaptiveTerminationEnabled = true; private float adaptiveTerminationGamma = AdaptiveTermination.DEFAULT_GAMMA; private final AdaptiveTermination adaptiveTermination = new AdaptiveTermination(); - private final NodeQueue adaptiveResults = new NodeQueue(new BoundedLongHeap(100), NodeQueue.Order.MIN_HEAP); - private final IntHashSet adaptiveResultMembers = new IntHashSet(); + // The best rerankK accepted nodes scored so far in the current layer search, worst on top. + private final NodeQueue bestScored = new NodeQueue(new BoundedLongHeap(100), NodeQueue.Order.MIN_HEAP); + // Once bestScored is full: the lowest score a newly scored node may have and still be expanded. + private float expandBar; + private boolean adaptiveBar; private final ScoreTracker.ScoreTrackerFactory scoreTrackerFactory; private int visitedCount; @@ -87,6 +94,7 @@ protected GraphSearcher(ImmutableGraphIndex.View view) { this.view = view; this.candidates = new NodeQueue(new GrowableLongHeap(100), NodeQueue.Order.MAX_HEAP); this.evictedResults = new NodesUnsorted(100); + this.deferredCandidates = new NodesUnsorted(100); this.approximateResults = new NodeQueue(new BoundedLongHeap(100), NodeQueue.Order.MIN_HEAP); this.rerankedResults = new NodeQueue(new BoundedLongHeap(100), NodeQueue.Order.MIN_HEAP); this.visited = new IntHashSet(); @@ -143,6 +151,11 @@ public void usePruning(boolean usage) { pruneSearch = false; } + /** For tests: whether layer searches defer candidates they can no longer expand (the default). */ + void boundCandidates(boolean enabled) { + this.boundCandidates = enabled; + } + /** Enable or disable adaptive stopping for score providers that explicitly support it. */ public void useAdaptiveTermination(boolean enabled) { adaptiveTerminationEnabled = enabled; @@ -368,6 +381,7 @@ public void initializeInternal(SearchScoreProvider scoreProvider, NodeAtLevel en // reset the scratch data structures approximateResults.clear(); evictedResults.clear(); + deferredCandidates.clear(); candidates.clear(); visited.clear(); @@ -384,10 +398,11 @@ public void initializeInternal(SearchScoreProvider scoreProvider, NodeAtLevel en private boolean stopSearch(NodeQueue localCandidates, ScoreTracker scoreTracker, int rerankK, float threshold, boolean adaptive) { if (adaptive && approximateResults.size() >= rerankK) { - var decision = adaptiveTermination.shouldTerminate(localCandidates.topScore(), adaptiveResults); + var decision = adaptiveTermination.shouldTerminate(localCandidates.topScore(), bestScored); if (decision == AdaptiveTermination.Decision.CONTINUE) return false; + // A candidate among the best scored nodes is still expanded. if (decision == AdaptiveTermination.Decision.TERMINATE) - return !adaptiveResultMembers.contains(localCandidates.topNode()); + return localCandidates.topScore() < bestScored.topScore(); } if (approximateResults.size() >= rerankK && localCandidates.topScore() < approximateResults.topScore()) return true; @@ -441,7 +456,26 @@ public void searchOneLayer(SearchScoreProvider scoreProvider, boolean adaptive = adaptiveTerminationEnabled && scoreProvider.supportsAdaptiveDotProductTermination() && level == 0 && threshold == 0.0f && rerankK > 0; - if (adaptive) resetAdaptiveTermination(scoreProvider, rerankK, acceptOrdsThisLayer); + if (adaptive) { + adaptiveTermination.reset(rerankK, adaptiveTerminationGamma, scoreProvider.adaptiveScoresAreRawDotProducts()); + } + // Bounded candidates: a scored node is expanded only while it ranks among the best rerankK accepted + // nodes scored so far (or, adaptively, within the cutoff of the rerankK-th), and that bar only rises. + // So a node that misses it when scored can never be expanded by this layer search: it is deferred + // instead of entering the candidate heap. Threshold searches keep every candidate. + boolean bounded = boundCandidates && threshold <= 0.0f && rerankK > 0; + boolean trackBest = bounded || adaptive; + deferredCandidates.foreach(candidates::push); + deferredCandidates.clear(); + bestScored.clear(); + expandBar = Float.NEGATIVE_INFINITY; + adaptiveBar = adaptive; + if (trackBest) { + bestScored.setMaxSize(rerankK); + candidates.foreach((node, score) -> { + if (acceptOrdsThisLayer.get(node) && score >= threshold) offerScored(node, score, rerankK); + }); + } // TopK and filtered pruning are disabled. Threshold searches retain their // legacy threshold early-termination path inside ScoreTrackerFactory. @@ -449,10 +483,16 @@ public void searchOneLayer(SearchScoreProvider scoreProvider, var scoreFunction = scoreProvider.scoreFunction(); ImmutableGraphIndex.NeighborProcessor neighborProcessor = (node2, score) -> { - if (adaptive && acceptOrdsThisLayer.get(node2)) offerAdaptiveResult(node2, score, rerankK); + visitedCount++; scoreTracker.track(score); + if (trackBest && acceptOrdsThisLayer.get(node2) && score >= threshold) offerScored(node2, score, rerankK); + if (bounded) { + if (!mayExpand(score, rerankK)) { + deferredCandidates.add(node2, score); + return; + } + } candidates.push(node2, score); - visitedCount++; }; // the main search loop @@ -466,7 +506,6 @@ public void searchOneLayer(SearchScoreProvider scoreProvider, int topCandidateNode = candidates.pop(); if (acceptOrdsThisLayer.get(topCandidateNode) && topCandidateScore >= threshold) { addTopCandidate(topCandidateNode, topCandidateScore, rerankK); - if (adaptive) offerAdaptiveResult(topCandidateNode, topCandidateScore, rerankK); } // skip edge loading if we've found a local maximum and we have enough results @@ -544,29 +583,32 @@ SearchResult resume(int topK, int rerankK, float threshold, float rerankFloor) { return reranking(topK, rerankK, rerankFloor); } - private void resetAdaptiveTermination(SearchScoreProvider scoreProvider, int rerankK, Bits accepted) { - adaptiveTermination.reset(rerankK, adaptiveTerminationGamma, scoreProvider.adaptiveScoresAreRawDotProducts()); - adaptiveResults.clear(); - adaptiveResults.setMaxSize(rerankK); - adaptiveResultMembers.clear(); - candidates.foreach((node, score) -> { - if (accepted.get(node)) offerAdaptiveResult(node, score, rerankK); - }); - } - - private void offerAdaptiveResult(int node, float score, int rerankK) { - if (adaptiveResultMembers.contains(node)) return; - if (adaptiveResults.size() < rerankK) { - adaptiveResults.push(node, score); - adaptiveResultMembers.add(node); - } else if (score > adaptiveResults.topScore()) { - int evicted = adaptiveResults.topNode(); - adaptiveResults.push(node, score); - adaptiveResultMembers.remove(evicted); - adaptiveResultMembers.add(node); + /** + * Keeps {@code node} if it is among the best {@code rerankK} accepted nodes scored so far, and raises the + * expansion bar when the rerankK-th best score rises. + */ + private void offerScored(int node, float score, int rerankK) { + if (bestScored.size() < rerankK) { + bestScored.push(node, score); + if (bestScored.size() == rerankK) updateExpandBar(); + } else if (score > bestScored.topScore()) { + bestScored.push(node, score); + updateExpandBar(); } } + /** The rerankK-th best score, or adaptively the lowest score within its cutoff, whichever is lower. */ + private void updateExpandBar() { + float kth = bestScored.topScore(); + expandBar = adaptiveBar ? Math.min(kth, adaptiveTermination.lowestContinuableScore(kth)) : kth; + } + + /** Whether a node just scored can still be expanded by the current layer search; see searchOneLayer. */ + private boolean mayExpand(float score, int rerankK) { + // A NaN score compares false against every bar, as it does in the stopping rule, so it is kept. + return bestScored.size() < rerankK || !(score < expandBar); + } + @SuppressWarnings("StatementWithEmptyBody") private void addTopCandidate(int topCandidateNode, float topCandidateScore, int rerankK) { // add the new node to the results queue, and any evicted node to evictedResults in case we resume later diff --git a/jvector-tests/src/test/java/io/github/jbellis/jvector/graph/TestBoundedCandidates.java b/jvector-tests/src/test/java/io/github/jbellis/jvector/graph/TestBoundedCandidates.java new file mode 100644 index 000000000..fb4d1f605 --- /dev/null +++ b/jvector-tests/src/test/java/io/github/jbellis/jvector/graph/TestBoundedCandidates.java @@ -0,0 +1,102 @@ +/* + * 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.graph.similarity.DefaultSearchScoreProvider; +import io.github.jbellis.jvector.util.Bits; +import io.github.jbellis.jvector.util.FixedBitSet; +import io.github.jbellis.jvector.vector.VectorSimilarityFunction; +import io.github.jbellis.jvector.vector.types.VectorFloat; +import org.junit.Test; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; +import java.util.Random; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; + +/** + * Deferring candidates that a layer search can no longer expand must not change any search: results, scores, + * visited and expanded counts match a searcher that keeps every candidate, for standard and adaptive stopping, + * filters, hierarchical graphs and repeated resume() calls. + */ +public class TestBoundedCandidates { + @Test + public void boundedSearchesMatchUnboundedOnes() throws IOException { + var random = new Random(42); + int dimension = 24, count = 3000; + var list = new ArrayList>(); + for (int i = 0; i < count; i++) list.add(TestUtil.randomVector(random, dimension)); + var vectors = new ListRandomAccessVectorValues(list, dimension); + for (var similarity : VectorSimilarityFunction.values()) { + for (boolean hierarchy : new boolean[] {false, true}) { + var builder = new GraphIndexBuilder(BuildScoreProvider.randomAccessScoreProvider(vectors, similarity), + dimension, 16, 60, 1.2f, 1.2f, hierarchy); + var graph = builder.build(vectors); + try (var bounded = new GraphSearcher(graph); var unbounded = new GraphSearcher(graph)) { + unbounded.boundCandidates(false); + for (int q = 0; q < 40; q++) { + var query = TestUtil.randomVector(random, dimension); + Bits accept = q % 3 == 0 ? Bits.ALL : randomFilter(random, count, q % 3 == 1 ? 0.7 : 0.1); + int topK = new int[] {1, 5, 10}[q % 3]; + int rerankK = topK * new int[] {1, 2, 5}[(q / 3) % 3]; + boolean adaptive = q % 2 == 0; + String where = similarity + " hierarchy " + hierarchy + " query " + q; + var b = bounded.search(provider(query, similarity, vectors, adaptive), topK, rerankK, 0f, 0f, accept); + var u = unbounded.search(provider(query, similarity, vectors, adaptive), topK, rerankK, 0f, 0f, accept); + assertSame(where, u, b); + for (int r = 0; r < 3; r++) { + assertSame(where + " resume " + r, unbounded.resume(topK, rerankK), bounded.resume(topK, rerankK)); + } + } + } + } + } + } + + private static DefaultSearchScoreProvider provider(VectorFloat query, VectorSimilarityFunction similarity, + RandomAccessVectorValues vectors, boolean adaptive) { + var exact = DefaultSearchScoreProvider.exact(query, similarity, vectors); + return new DefaultSearchScoreProvider(exact.scoreFunction(), null, adaptive); + } + + private static Bits randomFilter(Random random, int count, double density) { + var bits = new FixedBitSet(count); + for (int i = 0; i < count; i++) if (random.nextDouble() < density) bits.set(i); + return bits; + } + + private static void assertSame(String where, SearchResult expected, SearchResult actual) { + assertEquals(where + " visited", expected.getVisitedCount(), actual.getVisitedCount()); + assertEquals(where + " expanded", expected.getExpandedCount(), actual.getExpandedCount()); + assertEquals(where + " expanded base layer", expected.getExpandedCountBaseLayer(), actual.getExpandedCountBaseLayer()); + assertArrayEquals(where + " nodes", nodes(expected), nodes(actual)); + for (int i = 0; i < expected.getNodes().length; i++) { + assertEquals(where + " score " + i, expected.getNodes()[i].score, actual.getNodes()[i].score, 0f); + } + } + + private static int[] nodes(SearchResult result) { + int[] nodes = new int[result.getNodes().length]; + for (int i = 0; i < nodes.length; i++) nodes[i] = result.getNodes()[i].node; + return nodes; + } +} From 650af8514e2ed55db2169cbff704055879bc2678 Mon Sep 17 00:00:00 2001 From: Ted Willke Date: Thu, 12 Nov 2026 07:15:58 -0800 Subject: [PATCH 238/243] Score ASH construction neighbors in one native call Graph views gather an expanded node's unvisited neighbors and score them in one batch when the score function supports it. ASH symmetric construction scoring gathers the neighbors' codes into one contiguous buffer and scores them in one bounded native call that reuses the pair kernel, so batch and single scores are identical. --- .../jvector/graph/OnHeapGraphIndex.java | 30 ++++++++++++ .../graph/similarity/ScoreFunction.java | 17 +++++++ .../quantization/ASHSymmetricScorer.java | 47 ++++++++++++++++-- .../jvector/vector/VectorUtilSupport.java | 13 +++++ .../vector/NativeASHSymmetricKernels.java | 49 +++++++++++++++++++ .../vector/NativeVectorUtilSupport.java | 13 +++++ .../src/main/native/src/ash_symmetric-inl.h | 7 +++ .../src/main/native/src/jvector_simd.cpp | 1 + .../native/src/jvector_simd_kernel_list.h | 1 + .../jvector/quantization/TestASHCodes.java | 7 +++ .../quantization/TestASHSymmetricScorer.java | 26 ++++++++++ 11 files changed, 206 insertions(+), 5 deletions(-) diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/graph/OnHeapGraphIndex.java b/jvector-base/src/main/java/io/github/jbellis/jvector/graph/OnHeapGraphIndex.java index 4a49049a9..9c05f0917 100644 --- a/jvector-base/src/main/java/io/github/jbellis/jvector/graph/OnHeapGraphIndex.java +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/graph/OnHeapGraphIndex.java @@ -474,6 +474,24 @@ public boolean hasNext() { } } + /** Per-thread buffers for scoring one node's unvisited neighbors as a batch; they grow to the largest list. */ + private static final ThreadLocal BATCH_SCRATCH = ThreadLocal.withInitial(BatchScratch::new); + + private static final class BatchScratch { + int[] nodes = new int[64]; + float[] scores = new float[64]; + + /** Stores {@code node} at {@code index}, growing both buffers if needed; returns the new count. */ + int add(int index, int node) { + if (index == nodes.length) { + nodes = java.util.Arrays.copyOf(nodes, 2 * index); + scores = new float[2 * index]; + } + nodes[index] = node; + return index + 1; + } + } + private class FrozenView implements View { @Override public NodesIterator getNeighborsIterator(int level, int node) { @@ -483,6 +501,18 @@ public NodesIterator getNeighborsIterator(int level, int node) { @Override public void processNeighbors(int level, int node, ScoreFunction scoreFunction, IntMarker visited, NeighborProcessor neighborProcessor) { + if (scoreFunction.supportsBatchScoring()) { + // Gather the unvisited neighbors, score them in one batch, then hand them on in neighbor order. + var batch = BATCH_SCRATCH.get(); + int count = 0; + for (var it = getNeighborsIterator(level, node); it.hasNext(); ) { + var friendOrd = it.nextInt(); + if (visited.mark(friendOrd)) count = batch.add(count, friendOrd); + } + scoreFunction.similarityTo(batch.nodes, count, batch.scores); + for (int i = 0; i < count; i++) neighborProcessor.process(batch.nodes[i], batch.scores[i]); + return; + } for (var it = getNeighborsIterator(level, node); it.hasNext(); ) { var friendOrd = it.nextInt(); if (visited.mark(friendOrd)) { diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/graph/similarity/ScoreFunction.java b/jvector-base/src/main/java/io/github/jbellis/jvector/graph/similarity/ScoreFunction.java index 9ae58fab1..cf6a2e12b 100644 --- a/jvector-base/src/main/java/io/github/jbellis/jvector/graph/similarity/ScoreFunction.java +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/graph/similarity/ScoreFunction.java @@ -40,6 +40,23 @@ public interface ScoreFunction { */ float similarityTo(int node2); + /** + * Scores {@code count} nodes: {@code scores[i]} is {@code similarityTo(nodes[i])} for {@code 0 <= i < count}. + * Implementations that report {@link #supportsBatchScoring()} score the nodes together, for example in one + * native call, and must give the same scores as {@link #similarityTo(int)}. + */ + default void similarityTo(int[] nodes, int count, float[] scores) { + for (int i = 0; i < count; i++) scores[i] = similarityTo(nodes[i]); + } + + /** + * @return true if {@link #similarityTo(int[], int, float[])} is faster than one {@link #similarityTo(int)} per + * node, so callers should gather the nodes they need before scoring them + */ + default boolean supportsBatchScoring() { + return false; + } + /** * Computes the similarity to the neighborIndex-th neighbor of origin. * Before calling this function, enableSimilarityToNeighbors must be called first with the same origin. diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHSymmetricScorer.java b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHSymmetricScorer.java index ed2ec6fa0..ada0f905e 100644 --- a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHSymmetricScorer.java +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHSymmetricScorer.java @@ -102,6 +102,18 @@ private float centeredOffset(AsymmetricHashing.QuantizedVector vector) { private float offset(int node) { return centeredOffsets[node]; } + /** Per-thread buffer that batch scoring gathers codes into; it grows to the largest batch. */ + private static final class GatherBuffer { + private static final ThreadLocal PER_THREAD = ThreadLocal.withInitial(GatherBuffer::new); + private byte[] bytes = new byte[0]; + } + + private static byte[] gatherBuffer(int bytes) { + var buffer = GatherBuffer.PER_THREAD.get(); + if (buffer.bytes.length < bytes) buffer.bytes = new byte[Math.max(bytes, 2 * buffer.bytes.length)]; + return buffer.bytes; + } + private long[] signs(int node) { return codes.signWords == 0 ? null : codes.signPage(node); } private byte[] body(int node) { return codes.bodyBytes == 0 ? null : codes.bodyPage(node); } @@ -160,11 +172,36 @@ private ScoreFunction.ApproximateScoreFunction c1ScoreFunction(long[] querySigns final float[] offsets = centeredOffsets; final int d = dimensions, b = bits; final io.github.jbellis.jvector.vector.VectorUtilSupport vu = backend; - return node -> { - float dot = (queryScale * scales[node]) - * vu.ashSymmetricDotAt(queryBody, queryBodyOffset, pages[node >>> shift], (node & mask) * stride, d, b) - + (queryOffset + offsets[node]) + landmarkDot; - return ASHScorer.toSimilarity(dot); + final boolean batch = vu.supportsAshSymmetricDotRows(); + return new ScoreFunction.ApproximateScoreFunction() { + @Override + public float similarityTo(int node) { + float dot = (queryScale * scales[node]) + * vu.ashSymmetricDotAt(queryBody, queryBodyOffset, pages[node >>> shift], (node & mask) * stride, d, b) + + (queryOffset + offsets[node]) + landmarkDot; + return ASHScorer.toSimilarity(dot); + } + + @Override + public boolean supportsBatchScoring() { + return batch; + } + + /** Gathers the codes into one contiguous buffer and scores them in one call; same scores as above. */ + @Override + public void similarityTo(int[] nodes, int count, float[] scores) { + byte[] rows = gatherBuffer(count * stride); + for (int i = 0; i < count; i++) { + int node = nodes[i]; + System.arraycopy(pages[node >>> shift], (node & mask) * stride, rows, i * stride, stride); + } + vu.ashSymmetricDotRows(queryBody, queryBodyOffset, rows, stride, count, d, b, scores); + for (int i = 0; i < count; i++) { + int node = nodes[i]; + float dot = (queryScale * scales[node]) * scores[i] + (queryOffset + offsets[node]) + landmarkDot; + scores[i] = ASHScorer.toSimilarity(dot); + } + } }; } return node -> { diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/vector/VectorUtilSupport.java b/jvector-base/src/main/java/io/github/jbellis/jvector/vector/VectorUtilSupport.java index 0086ac67f..570639f92 100644 --- a/jvector-base/src/main/java/io/github/jbellis/jvector/vector/VectorUtilSupport.java +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/vector/VectorUtilSupport.java @@ -87,6 +87,19 @@ default float ashSymmetricDotAt(byte[] aCodes, int aOffset, byte[] bCodes, int b throw new UnsupportedOperationException("Offset symmetric ASH SIMD is unavailable on this backend"); } + /** Whether {@link #ashSymmetricDotRows} scores all rows in one call rather than one call per row. */ + default boolean supportsAshSymmetricDotRows() { return false; } + + /** + * Symmetric 2/4-bit dot products of one code against {@code count} codes stored {@code stride} bytes apart from + * the start of {@code rows}: {@code out[i]} equals {@link #ashSymmetricDotAt} of the query and row {@code i}. + * Requires {@link #supportsAshSymmetricDotAt()}. + */ + default void ashSymmetricDotRows(byte[] query, int queryOffset, byte[] rows, int stride, int count, + int dimensions, int bits, float[] out) { + for (int i = 0; i < count; i++) out[i] = ashSymmetricDotAt(query, queryOffset, rows, i * stride, dimensions, bits); + } + /** * Symmetric 1-bit dot product, {@code dimensions - 2 * mismatches}, of sign words stored at offsets * inside larger arrays. Same result as {@link #ashSymmetricDot} with {@code bits == 1}. diff --git a/jvector-native/src/main/java/io/github/jbellis/jvector/vector/NativeASHSymmetricKernels.java b/jvector-native/src/main/java/io/github/jbellis/jvector/vector/NativeASHSymmetricKernels.java index 7f644ab48..8e2446123 100644 --- a/jvector-native/src/main/java/io/github/jbellis/jvector/vector/NativeASHSymmetricKernels.java +++ b/jvector-native/src/main/java/io/github/jbellis/jvector/vector/NativeASHSymmetricKernels.java @@ -53,10 +53,13 @@ final class NativeASHSymmetricKernels { private static final boolean TRACE = Boolean.getBoolean("jextract.trace.downcalls"); private static final String PAIR_SYMBOL = "ash_symmetric_dot"; private static final String LUT_SYMBOL = "ash_symmetric_lut_i16"; + private static final String ROWS_SYMBOL = "ash_symmetric_dot_rows"; private static final FunctionDescriptor PAIR_DESCRIPTOR = FunctionDescriptor.of(JAVA_FLOAT, ADDRESS, ADDRESS, JAVA_INT, JAVA_INT); private static final FunctionDescriptor LUT_DESCRIPTOR = FunctionDescriptor.ofVoid(ADDRESS, ADDRESS, JAVA_INT, JAVA_INT, JAVA_INT, JAVA_INT, ADDRESS); + private static final FunctionDescriptor ROWS_DESCRIPTOR = + FunctionDescriptor.ofVoid(ADDRESS, ADDRESS, JAVA_INT, JAVA_INT, JAVA_INT, JAVA_INT, ADDRESS); private static final MemorySegment PAIR_ADDRESS = find(PAIR_SYMBOL); private static final MemorySegment LUT_ADDRESS = find(LUT_SYMBOL); // Must stay static final: see the class comment. @@ -64,11 +67,13 @@ final class NativeASHSymmetricKernels { private static final MethodHandle PAIR_ORDINARY = downcall(PAIR_ADDRESS, PAIR_DESCRIPTOR, false); private static final MethodHandle LUT_CRITICAL = downcall(LUT_ADDRESS, LUT_DESCRIPTOR, true); private static final MethodHandle LUT_ORDINARY = downcall(LUT_ADDRESS, LUT_DESCRIPTOR, false); + private static final MethodHandle ROWS_CRITICAL = downcall(find(ROWS_SYMBOL), ROWS_DESCRIPTOR, true); private NativeASHSymmetricKernels() {} static boolean hasPairKernel() { return PAIR_CRITICAL != null; } static boolean hasLutKernel() { return LUT_CRITICAL != null; } + static boolean hasRowsKernel() { return ROWS_CRITICAL != null; } static float dot(long[] aBits, byte[] aCodes, long[] bBits, byte[] bCodes, int dimensions, int bits) { if (dimensions < 0 || (bits != 1 && bits != 2 && bits != 4)) throw badPairShape(); @@ -119,6 +124,46 @@ static float binaryDotAt(long[] aBits, int aOffset, long[] bBits, int bOffset, i words * Long.BYTES, dimensions, 1); } + /** + * 2/4-bit query code against {@code count} codes {@code stride} bytes apart: {@code out[i]} equals + * {@link #dotAt} of the query and row {@code i}. Each critical call scores at most + * {@code MAX_CRITICAL_WORK} dimensions in total. + */ + static void dotRows(byte[] query, int queryOffset, byte[] rows, int stride, int count, + int dimensions, int bits, float[] out) { + if (dimensions < 0 || count < 0 || (bits != 2 && bits != 4)) throw badOffsetShape(); + if (ROWS_CRITICAL == null) throw rowsUnavailable(); + long bytes = ((long) dimensions * bits + 7) / 8; + if (stride < bytes || queryOffset < 0 || queryOffset + bytes > query.length || count > out.length + || (count > 0 && (long) (count - 1) * stride + bytes > rows.length)) + throw outOfBounds("ASH code row ranges"); + if (count == 0) return; + if (TRACE) trace(ROWS_SYMBOL, dimensions, count); + if (dimensions > MAX_CRITICAL_WORK) { + for (int i = 0; i < count; i++) out[i] = dotAt(query, queryOffset, rows, i * stride, dimensions, bits); + return; + } + int perCall = Math.max(1, MAX_CRITICAL_WORK / Math.max(1, dimensions)); + for (int first = 0; first < count; first += perCall) { + rowsCritical(query, queryOffset, rows, first * stride, stride, Math.min(perCall, count - first), + dimensions, bits, out, first); + } + } + + // Hot path: as in pairCritical, the sliced segments stay local to invokeExact. + private static void rowsCritical(byte[] query, int queryOffset, byte[] rows, int rowsOffset, int stride, + int count, int dimensions, int bits, float[] out, int outOffset) { + try { + ROWS_CRITICAL.invokeExact(MemorySegment.ofArray(query).asSlice(queryOffset), + MemorySegment.ofArray(rows).asSlice(rowsOffset), stride, count, dimensions, bits, + MemorySegment.ofArray(out).asSlice((long) outOffset * Float.BYTES)); + } catch (RuntimeException | Error e) { + throw e; + } catch (Throwable t) { + throw unexpected(ROWS_SYMBOL, t); + } + } + private static float pairCriticalAt(long[] a, int aOffset, long[] b, int bOffset, int dimensions) { try { return (float) PAIR_CRITICAL.invokeExact(MemorySegment.ofArray(a).asSlice((long) aOffset * Long.BYTES), @@ -255,6 +300,10 @@ private static UnsupportedOperationException pairUnavailable() { return new UnsupportedOperationException("Native symmetric pair kernel unavailable"); } + private static UnsupportedOperationException rowsUnavailable() { + return new UnsupportedOperationException("Native symmetric rows kernel unavailable"); + } + private static UnsupportedOperationException lutUnavailable() { return new UnsupportedOperationException("Native symmetric LUT kernel unavailable"); } 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 e8affe405..2e1356b7e 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 @@ -132,6 +132,19 @@ public float ashSymmetricDotAt(byte[] aCodes, int aOffset, byte[] bCodes, int bO : super.ashSymmetricDotAt(aCodes, aOffset, bCodes, bOffset, dimensions, bits); } + @Override + public boolean supportsAshSymmetricDotRows() { + return NativeASHSymmetricKernels.hasRowsKernel(); + } + + @Override + public void ashSymmetricDotRows(byte[] query, int queryOffset, byte[] rows, int stride, int count, + int dimensions, int bits, float[] out) { + if (NativeASHSymmetricKernels.hasRowsKernel()) + NativeASHSymmetricKernels.dotRows(query, queryOffset, rows, stride, count, dimensions, bits, out); + else super.ashSymmetricDotRows(query, queryOffset, rows, stride, count, dimensions, bits, out); + } + @Override public void ashSymmetricLutScore(byte[] codes, int groups, int stride, int lane, int count, short[] lut, float[] out, int outOffset) { diff --git a/jvector-native/src/main/native/src/ash_symmetric-inl.h b/jvector-native/src/main/native/src/ash_symmetric-inl.h index 7c04324fd..ff416f081 100644 --- a/jvector-native/src/main/native/src/ash_symmetric-inl.h +++ b/jvector-native/src/main/native/src/ash_symmetric-inl.h @@ -165,6 +165,13 @@ float ash_symmetric_dot(const void *a, const void *b, int dimensions, int bits) return AshFourBitPair(static_cast(a), static_cast(b), dimensions); } +// Scores one code against count contiguous codes stride bytes apart, each exactly as ash_symmetric_dot. +void ash_symmetric_dot_rows(const void *query, const void *rows, int stride, int count, int dimensions, int bits, + float *HWY_RESTRICT out) { + const auto *row = static_cast(rows); + for (int i = 0; i < count; ++i) out[i] = ash_symmetric_dot(query, row + static_cast(i) * stride, dimensions, bits); +} + #ifndef JVECTOR_ASH_PAIR_ONLY // Exact signed-short LUTs, unlike the IVF scan's approximate uint8 LUTs. Widen // before accumulation. At most 8192 groups enter an int32 subtotal, then spill diff --git a/jvector-native/src/main/native/src/jvector_simd.cpp b/jvector-native/src/main/native/src/jvector_simd.cpp index ab687f474..a2ce74f33 100644 --- a/jvector-native/src/main/native/src/jvector_simd.cpp +++ b/jvector-native/src/main/native/src/jvector_simd.cpp @@ -113,6 +113,7 @@ static const KernelVTable AVX3_vtable = { static const KernelVTable AVX3_DL_vtable = []() { KernelVTable t = AVX3_vtable; t.ash_symmetric_dot = AVX3_DL::ash_symmetric_dot; + t.ash_symmetric_dot_rows = AVX3_DL::ash_symmetric_dot_rows; t.ash_score_neighborhood_i8_c1 = AVX3_DL::ash_score_neighborhood_i8_c1; return t; }(); diff --git a/jvector-native/src/main/native/src/jvector_simd_kernel_list.h b/jvector-native/src/main/native/src/jvector_simd_kernel_list.h index 933e1886d..8e995acd1 100644 --- a/jvector-native/src/main/native/src/jvector_simd_kernel_list.h +++ b/jvector-native/src/main/native/src/jvector_simd_kernel_list.h @@ -83,6 +83,7 @@ KERNEL_ENTRY(void, ash_project_f32, (const float *matrix, const float *query, int rows, int cols, float *out), (matrix, query, rows, cols, out)) \ KERNEL_ENTRY(void, ash_project_batch_f32, (const float *matrix, const float *inputs, int rows, int cols, int count, float *out), (matrix, inputs, rows, cols, count, out)) \ KERNEL_ENTRY(float, ash_symmetric_dot, (const void *a, const void *b, int dimensions, int bits), (a, b, dimensions, bits)) \ + KERNEL_ENTRY(void, ash_symmetric_dot_rows, (const void *query, const void *rows, int stride, int count, int dimensions, int bits, float *out), (query, rows, stride, count, dimensions, bits, out)) \ KERNEL_ENTRY(void, ash_symmetric_lut_i16, (const unsigned char *codes, const int16_t *lut, int groups, int stride, int lane, int count, float *out), (codes, lut, groups, stride, lane, count, out)) \ /* Canonical ASH dot using the prepared query LUT. */ \ KERNEL_ENTRY(float, ash_canonical_lut_dot_f32, (const unsigned char *code, const float *lut, int groups), (code, lut, groups)) \ diff --git a/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestASHCodes.java b/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestASHCodes.java index e278d2f98..3d5887dda 100644 --- a/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestASHCodes.java +++ b/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestASHCodes.java @@ -272,6 +272,13 @@ public void offsetKernelsMatchZeroOffsetKernels() { assertThrows(IllegalArgumentException.class, () -> vu.ashSymmetricDotAt(page, 0, page, 0, 8, 1)); assertThrows(IndexOutOfBoundsException.class, () -> vu.ashSymmetricDotAt(page, 150, page, 0, 384, 2)); vu.ashSymmetricDotAt(page, 104, page, 0, 384, 2); + // Two 96-byte rows at stride 100: the second ends inside the array, a third would not. + float[] out = new float[3]; + vu.ashSymmetricDotRows(page, 0, page, 100, 2, 384, 2, out); + assertEquals(Float.floatToIntBits(vu.ashSymmetricDotAt(page, 0, page, 100, 384, 2)), Float.floatToIntBits(out[1])); + assertThrows(IndexOutOfBoundsException.class, () -> vu.ashSymmetricDotRows(page, 0, page, 105, 2, 384, 2, out)); + assertThrows(IndexOutOfBoundsException.class, () -> vu.ashSymmetricDotRows(page, 0, page, 100, 3, 384, 2, out)); + assertThrows(IllegalArgumentException.class, () -> vu.ashSymmetricDotRows(page, 0, page, 100, 1, 384, 1, out)); } } diff --git a/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestASHSymmetricScorer.java b/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestASHSymmetricScorer.java index bb5446e2d..9dbecaa64 100644 --- a/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestASHSymmetricScorer.java +++ b/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestASHSymmetricScorer.java @@ -229,6 +229,32 @@ private void checkAppendixBReconstruction() throws Exception { assertThrows(IllegalArgumentException.class, () -> BuildScoreProvider.ashBuildScoreProvider(VectorSimilarityFunction.DOT_PRODUCT, encoded)); } + @Test public void batchScoresMatchSingleScoresBitForBit() throws Exception { + // 1024 projected dimensions put 512 (2-bit) or 256 (4-bit) codes in each 256 KiB page, so a batch gathers + // codes from several pages; 700 codes exceed one bounded native call. + var input = inputs(1200, 1024); + var values = MockVectorValues.fromValues(input); + var random = new Random(41); + for (int bits : new int[]{2, 4}) { + var ash = AsymmetricHashing.initialize(values, AsymmetricHashing.RANDOM, + AsymmetricHashing.HEADER_BITS + 1024 * bits, 1, bits); + var encoded = ash.encodeAll(values, java.util.concurrent.ForkJoinPool.commonPool()); + var scorer = new ASHSymmetricScorer(encoded); + for (int origin : new int[]{0, 777, 1199}) { + var sf = scorer.scoreFunctionFor(origin); + for (int count : new int[]{0, 1, 7, 40, 700}) { + int[] nodes = random.ints(count, 0, input.length).toArray(); + float[] scores = new float[count]; + sf.similarityTo(nodes, count, scores); + for (int i = 0; i < count; i++) { + assertEquals("bits=" + bits + " origin=" + origin + " node=" + nodes[i], + Float.floatToIntBits(sf.similarityTo(nodes[i])), Float.floatToIntBits(scores[i])); + } + } + } + } + } + @Test public void constructionWorksWithPackedCodesAndNegativeSimilarities() throws Exception { var input = inputs(80,40); for (int d = 0; d < 40; d++) input[1].set(d, -input[0].get(d)); From 66aa6055cfb45d333815c6604c33416d0c4906b1 Mon Sep 17 00:00:00 2001 From: Ted Willke Date: Thu, 12 Nov 2026 11:16:47 -0800 Subject: [PATCH 239/243] Always stop ASH searches adaptively unless the searcher turns it off ASH score functions now declare that they require adaptive stopping, and GraphSearcher stops adaptively whenever the score function requires it, whatever SearchScoreProvider wraps it. An ASH search therefore cannot fall back to the standard rule because an application wrapped its scores in a provider that did not ask for adaptive stopping; GraphSearcher.useAdaptiveTermination(false) remains the explicit override. A test checks plain, adaptive-requesting and application-written providers against each other and the override against a standard search. --- .../jbellis/jvector/graph/GraphSearcher.java | 5 +- .../graph/similarity/ScoreFunction.java | 20 ++++ .../quantization/ASHSymmetricScorer.java | 8 +- .../jvector/quantization/ASHVectors.java | 12 +- .../jvector/quantization/FusedASHDecoder.java | 2 +- .../quantization/TestASHAdaptiveStopping.java | 104 ++++++++++++++++++ 6 files changed, 139 insertions(+), 12 deletions(-) create mode 100644 jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestASHAdaptiveStopping.java 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 63d0c431c..9e18461d8 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 @@ -453,8 +453,11 @@ public void searchOneLayer(SearchScoreProvider scoreProvider, try { assert approximateResults.size() == 0; // should be cleared by setEntryPointsFromPreviousLayer approximateResults.setMaxSize(rerankK); + // ASH score functions require adaptive stopping whatever provider wraps them; only + // useAdaptiveTermination(false) turns it off. boolean adaptive = adaptiveTerminationEnabled - && scoreProvider.supportsAdaptiveDotProductTermination() + && (scoreProvider.supportsAdaptiveDotProductTermination() + || scoreProvider.scoreFunction().requiresAdaptiveTermination()) && level == 0 && threshold == 0.0f && rerankK > 0; if (adaptive) { adaptiveTermination.reset(rerankK, adaptiveTerminationGamma, scoreProvider.adaptiveScoresAreRawDotProducts()); diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/graph/similarity/ScoreFunction.java b/jvector-base/src/main/java/io/github/jbellis/jvector/graph/similarity/ScoreFunction.java index cf6a2e12b..078023d47 100644 --- a/jvector-base/src/main/java/io/github/jbellis/jvector/graph/similarity/ScoreFunction.java +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/graph/similarity/ScoreFunction.java @@ -82,6 +82,14 @@ default boolean supportsSimilarityToNeighbors() { return false; } + /** + * Whether a top-K search scored by this function always stops adaptively, whatever + * {@link SearchScoreProvider} wraps it. Only {@code GraphSearcher.useAdaptiveTermination(false)} overrides it. + */ + default boolean requiresAdaptiveTermination() { + return false; + } + interface ExactScoreFunction extends ScoreFunction { default boolean isExact() { @@ -94,4 +102,16 @@ default boolean isExact() { return false; } } + + /** + * An approximate score function whose top-K searches always stop adaptively unless the searcher's adaptive + * stopping is turned off explicitly. Every ASH score function is one, so ASH graph searches cannot fall back to + * the standard rule by being wrapped in a provider that does not ask for adaptive stopping. + */ + interface AdaptiveApproximateScoreFunction extends ApproximateScoreFunction { + @Override + default boolean requiresAdaptiveTermination() { + return true; + } + } } diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHSymmetricScorer.java b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHSymmetricScorer.java index ada0f905e..220f55916 100644 --- a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHSymmetricScorer.java +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHSymmetricScorer.java @@ -147,19 +147,19 @@ public float dotProduct(int a, int b) { + ((xs * crossA + ys * crossB) + (offset(a) + offset(b))) + landmarkDots[ca][cb]; } - public ScoreFunction.ApproximateScoreFunction scoreFunctionFor(int node) { + public ScoreFunction.AdaptiveApproximateScoreFunction scoreFunctionFor(int node) { if (landmarks != 1) return scoreFunctionFor(vectors.get(node)); return c1ScoreFunction(signs(node), codes.signOffset(node), body(node), codes.bodyOffset(node), codes.scales[node], offset(node)); } /** The operand must already be encoded with this instance's compressor. */ - public ScoreFunction.ApproximateScoreFunction scoreFunctionFor(AsymmetricHashing.QuantizedVector query) { + public ScoreFunction.AdaptiveApproximateScoreFunction scoreFunctionFor(AsymmetricHashing.QuantizedVector query) { if (landmarks != 1) return vectors.scoreFunctionFor(preparedState(query), simd); return c1ScoreFunction(query.binaryVector, 0, query.extraBits, 0, query.scale, centeredOffset(query)); } - private ScoreFunction.ApproximateScoreFunction c1ScoreFunction(long[] querySigns, int querySignOffset, + private ScoreFunction.AdaptiveApproximateScoreFunction c1ScoreFunction(long[] querySigns, int querySignOffset, byte[] queryBody, int queryBodyOffset, float queryScale, float queryOffset) { final ASHCodes c = codes; @@ -173,7 +173,7 @@ private ScoreFunction.ApproximateScoreFunction c1ScoreFunction(long[] querySigns final int d = dimensions, b = bits; final io.github.jbellis.jvector.vector.VectorUtilSupport vu = backend; final boolean batch = vu.supportsAshSymmetricDotRows(); - return new ScoreFunction.ApproximateScoreFunction() { + return new ScoreFunction.AdaptiveApproximateScoreFunction() { @Override public float similarityTo(int node) { float dot = (queryScale * scales[node]) diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHVectors.java b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHVectors.java index 9bf53282c..a68c2850a 100644 --- a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHVectors.java +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHVectors.java @@ -199,7 +199,7 @@ public static ASHVectors load(RandomAccessReader in) throws IOException { } @Override - public ScoreFunction.ApproximateScoreFunction scoreFunctionFor( + public ScoreFunction.AdaptiveApproximateScoreFunction scoreFunctionFor( VectorFloat query, VectorSimilarityFunction similarityFunction) { @@ -240,7 +240,7 @@ public ScoreFunction.ApproximateScoreFunction scoreFunctionFor( * Applies {@code f} to stored codes in place, in graph similarity units. One lambda per storage * shape keeps each call site free of null checks for the array a bit width does not use. */ - private ScoreFunction.ApproximateScoreFunction nodeScoreFunction(ASHScorer.CodeScorer f) { + private ScoreFunction.AdaptiveApproximateScoreFunction nodeScoreFunction(ASHScorer.CodeScorer f) { final ASHCodes c = codes; if (c.signWords == 0) { return node -> ASHScorer.toSimilarity(f.dot(null, 0, c.bodyPage(node), c.bodyOffset(node), @@ -258,7 +258,7 @@ private ScoreFunction.ApproximateScoreFunction nodeScoreFunction(ASHScorer.CodeS // ASH single vector scoring (1-bit only, reads the stored sign pages in place) // ============================================================================ - private final class SimdASHScoreFunction implements ScoreFunction.ApproximateScoreFunction { + private final class SimdASHScoreFunction implements ScoreFunction.AdaptiveApproximateScoreFunction { private final QueryPrecompute qp; private final VectorUtilSupport vecUtil; @@ -456,7 +456,7 @@ public ASHBlockScorer blockScorerFor( } // Package-private prepared-state entry points shared by symmetric scoring. - ScoreFunction.ApproximateScoreFunction scoreFunctionFor(ASHScorer.QueryPrecompute qp, boolean simd) { + ScoreFunction.AdaptiveApproximateScoreFunction scoreFunctionFor(ASHScorer.QueryPrecompute qp, boolean simd) { if (ash.bitsPerDimension == 1 && simd) { return new SimdASHScoreFunction(new QueryPrecompute(qp.d, qp.C, qp.qProj, qp.sumQProj, qp.dotQMuByLandmark)); @@ -979,7 +979,7 @@ public static LandmarkRunStats computeLandmarkRunStats(byte[] landmarks) { * when the score function is created, so there is nothing further to precompute. */ @Override - public ScoreFunction.ApproximateScoreFunction precomputedScoreFunctionFor( + public ScoreFunction.AdaptiveApproximateScoreFunction precomputedScoreFunctionFor( VectorFloat query, VectorSimilarityFunction similarityFunction) { return scoreFunctionFor(query, similarityFunction); @@ -987,7 +987,7 @@ public ScoreFunction.ApproximateScoreFunction precomputedScoreFunctionFor( /** Symmetric node-to-node dot-product similarity from the stored encoded vectors. */ @Override - public ScoreFunction.ApproximateScoreFunction diversityFunctionFor( + public ScoreFunction.AdaptiveApproximateScoreFunction diversityFunctionFor( int node1, VectorSimilarityFunction similarityFunction) { if (similarityFunction != VectorSimilarityFunction.DOT_PRODUCT) diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/FusedASHDecoder.java b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/FusedASHDecoder.java index 54006a142..2173bc0e8 100644 --- a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/FusedASHDecoder.java +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/FusedASHDecoder.java @@ -64,7 +64,7 @@ * similarity = max(0, (1 + rawDotProduct) / 2) * */ -public final class FusedASHDecoder implements ScoreFunction.ApproximateScoreFunction { +public final class FusedASHDecoder implements ScoreFunction.AdaptiveApproximateScoreFunction { /** * Source of fused neighborhood bytes. The implementation is provided by the graph feature, diff --git a/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestASHAdaptiveStopping.java b/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestASHAdaptiveStopping.java new file mode 100644 index 000000000..808bd8bd3 --- /dev/null +++ b/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestASHAdaptiveStopping.java @@ -0,0 +1,104 @@ +/* + * 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 io.github.jbellis.jvector.TestUtil; +import io.github.jbellis.jvector.graph.GraphIndexBuilder; +import io.github.jbellis.jvector.graph.GraphSearcher; +import io.github.jbellis.jvector.graph.ImmutableGraphIndex; +import io.github.jbellis.jvector.graph.MockVectorValues; +import io.github.jbellis.jvector.graph.SearchResult; +import io.github.jbellis.jvector.graph.similarity.BuildScoreProvider; +import io.github.jbellis.jvector.graph.similarity.DefaultSearchScoreProvider; +import io.github.jbellis.jvector.graph.similarity.ScoreFunction; +import io.github.jbellis.jvector.graph.similarity.SearchScoreProvider; +import io.github.jbellis.jvector.util.Bits; +import io.github.jbellis.jvector.vector.VectorSimilarityFunction; +import io.github.jbellis.jvector.vector.VectorUtil; +import io.github.jbellis.jvector.vector.types.VectorFloat; +import org.junit.Test; + +import java.util.Arrays; +import java.util.Random; +import java.util.concurrent.ForkJoinPool; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +/** ASH searches stop adaptively whatever provider wraps their scores; only the searcher's switch turns it off. */ +public class TestASHAdaptiveStopping { + @Test + public void ashScoresAlwaysStopAdaptivelyUnlessTheSearcherSaysOtherwise() throws Exception { + var random = new Random(17); + int count = 3000, dimension = 64; + var input = new VectorFloat[count]; + for (int i = 0; i < count; i++) { + input[i] = TestUtil.randomVector(random, dimension); + VectorUtil.l2normalize(input[i]); + } + var values = MockVectorValues.fromValues(input); + var ash = AsymmetricHashing.initialize(values, AsymmetricHashing.RANDOM, + AsymmetricHashing.HEADER_BITS + 32 * 2, 1, 2); + var encoded = ash.encodeAll(values, ForkJoinPool.commonPool()); + var bsp = BuildScoreProvider.ashBuildScoreProvider(VectorSimilarityFunction.DOT_PRODUCT, encoded); + try (var builder = new GraphIndexBuilder(bsp, dimension, 16, 50, 1.2f, 1.2f, true, true)) { + for (int i = 0; i < count; i++) builder.addGraphNode(i, input[i]); + builder.cleanup(); + ImmutableGraphIndex graph = builder.getGraph(); + for (int q = 0; q < 20; q++) { + var query = TestUtil.randomVector(random, dimension); + VectorUtil.l2normalize(query); + var asf = encoded.precomputedScoreFunctionFor(query, VectorSimilarityFunction.DOT_PRODUCT); + assertTrue(asf.requiresAdaptiveTermination()); + var requested = search(graph, new DefaultSearchScoreProvider(asf, null, true), true); + // Plain wrappers that do not ask for adaptive stopping still get it. + assertSame(requested, search(graph, new DefaultSearchScoreProvider(asf), true)); + assertSame(requested, search(graph, new DefaultSearchScoreProvider(asf, null), true)); + assertSame(requested, search(graph, customProvider(asf), true)); + // The searcher's switch is the override: the search is then the standard one. + ScoreFunction.ApproximateScoreFunction unmarked = asf::similarityTo; + var standard = search(graph, new DefaultSearchScoreProvider(unmarked), true); + assertSame(standard, search(graph, new DefaultSearchScoreProvider(asf, null, true), false)); + } + } + } + + private static SearchResult search(ImmutableGraphIndex graph, SearchScoreProvider provider, boolean adaptive) + throws Exception { + try (var searcher = new GraphSearcher(graph)) { + searcher.useAdaptiveTermination(adaptive); + return searcher.search(provider, 10, 10, 0.0f, 0.0f, Bits.ALL); + } + } + + /** A provider written by an application, which knows nothing about adaptive stopping. */ + private static SearchScoreProvider customProvider(ScoreFunction scoreFunction) { + return new SearchScoreProvider() { + @Override public ScoreFunction scoreFunction() { return scoreFunction; } + @Override public ScoreFunction.ExactScoreFunction reranker() { return null; } + @Override public ScoreFunction.ExactScoreFunction exactScoreFunction() { return null; } + }; + } + + private static void assertSame(SearchResult expected, SearchResult actual) { + assertArrayEquals(Arrays.stream(expected.getNodes()).mapToInt(n -> n.node).toArray(), + Arrays.stream(actual.getNodes()).mapToInt(n -> n.node).toArray()); + assertEquals(expected.getVisitedCount(), actual.getVisitedCount()); + assertEquals(expected.getExpandedCount(), actual.getExpandedCount()); + } +} From 4394de14cdd6c1a9e7d4293f5190de73c03ddc6f Mon Sep 17 00:00:00 2001 From: Ted Willke Date: Thu, 12 Nov 2026 16:36:34 -0800 Subject: [PATCH 240/243] Score ASH dot products on the exact unclamped scale ASH clamped its DOT_PRODUCT similarity at zero, so every estimate at or below -1 tied. That was a workaround for hierarchy descent starting from a zero floor, which now starts from negative infinity. Use (1 + dot) / 2 unclamped in Java and native, the scale exact DOT_PRODUCT scoring uses. Add TestUnnormalizedVectors: dot-product search over vectors whose lengths vary 100x must rank by the full dot product for exact, PQ and ASH scoring, by brute force and through a graph, judged against the same directions at unit length and against norm-blind ranking. --- .../quantization/ASHNeighborhoodScorer.java | 2 +- .../jvector/quantization/ASHScorer.java | 13 +- .../jvector/quantization/ASHVectors.java | 4 +- .../jvector/quantization/FusedASHDecoder.java | 2 +- .../main/native/src/ash_neighborhood-inl.h | 4 +- .../quantization/TestASHGraphSearch.java | 17 +- .../quantization/TestUnnormalizedVectors.java | 201 ++++++++++++++++++ 7 files changed, 228 insertions(+), 15 deletions(-) create mode 100644 jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestUnnormalizedVectors.java diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHNeighborhoodScorer.java b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHNeighborhoodScorer.java index 0eaf40925..5a60bf06a 100644 --- a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHNeighborhoodScorer.java +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHNeighborhoodScorer.java @@ -27,7 +27,7 @@ * *

    Construction computes the query projection, landmark terms, and nibble LUT once. Each * {@link #score} call then performs lookup, binary16 header decoding, landmark correction, and - * conversion to the graph's nonnegative DOT_PRODUCT similarity. The graph decoder and isolated + * conversion to the graph's DOT_PRODUCT similarity, (1 + dot) / 2, unclamped. The graph decoder and isolated * benchmark share this implementation.

    * *

    Publicly constructed prepared state is independently owned and immutable. Such instances may be shared across threads with independent output diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHScorer.java b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHScorer.java index ec713ef19..d67693d01 100644 --- a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHScorer.java +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHScorer.java @@ -55,15 +55,14 @@ */ public final class ASHScorer { /** - * Converts a raw ASH dot-product estimate to the graph's DOT_PRODUCT similarity - * scale. Graph search uses zero as its minimum similarity, including during - * hierarchy descent; passing a negative raw dot product can lose the entry point. - * Clamp the lower tail because quantization can estimate a dot product below -1. - * Kernels and this class's ASHScoreFunction retain raw dot products; node and - * block scorer adapters must apply this conversion exactly once. + * Converts a raw ASH dot-product estimate to the graph's DOT_PRODUCT similarity scale, (1 + dot) / 2, the same + * scale exact DOT_PRODUCT scoring uses. The estimate is not clamped: like an exact dot product of vectors that are + * not unit length, it can fall below -1 or above 1, and ranking needs those values. Graph search starts hierarchy + * descent from negative infinity, so negative similarities do not lose the entry point. Kernels and this class's + * ASHScoreFunction retain raw dot products; node and block scorer adapters must apply this conversion exactly once. */ public static float toSimilarity(float dotProduct) { - return Math.max(0f, (1f + dotProduct) / 2f); + return (1f + dotProduct) / 2f; } private final AsymmetricHashing ash; diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHVectors.java b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHVectors.java index a68c2850a..eec7092a3 100644 --- a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHVectors.java +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHVectors.java @@ -32,8 +32,8 @@ /** * Container for ASH-compressed vectors with scalar, SIMD, and block scoring. - * All public node and block scorers return nonnegative DOT_PRODUCT similarities, - * {@code max(0, (1 + rawDotProduct) / 2)}, rather than raw ASH dot products. + * All public node and block scorers return DOT_PRODUCT similarities on the exact scale, + * {@code (1 + rawDotProduct) / 2} (unclamped), rather than raw ASH dot products. * *

    * This class implements the functionality required for: diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/FusedASHDecoder.java b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/FusedASHDecoder.java index 2173bc0e8..5f50ba63d 100644 --- a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/FusedASHDecoder.java +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/FusedASHDecoder.java @@ -61,7 +61,7 @@ * *

      *   rawDotProduct = scale * <Aq, code> + <q, μ> + offset
    - *   similarity = max(0, (1 + rawDotProduct) / 2)
    + *   similarity = (1 + rawDotProduct) / 2   (unclamped, the exact DOT_PRODUCT scale)
      * 
    */ public final class FusedASHDecoder implements ScoreFunction.AdaptiveApproximateScoreFunction { diff --git a/jvector-native/src/main/native/src/ash_neighborhood-inl.h b/jvector-native/src/main/native/src/ash_neighborhood-inl.h index f025a74db..598f6a4f9 100644 --- a/jvector-native/src/main/native/src/ash_neighborhood-inl.h +++ b/jvector-native/src/main/native/src/ash_neighborhood-inl.h @@ -81,9 +81,9 @@ HWY_INLINE int AshFinish(D d, const unsigned char *header, int blockSize, int la const auto offset = AshPromoteHalf(d, offsetBits); const auto raw = hn::Add(hn::MulAdd(scale, dot, hn::Set(d, landmarkDot)), offset); const auto half = hn::Set(d, 0.5f); + // (1 + dot) / 2, unclamped, as ASHScorer.toSimilarity. const auto similarity = hn::MulAdd(raw, half, half); - const auto result = hn::IfThenElse(hn::IsNaN(similarity), similarity, hn::Max(hn::Zero(d), similarity)); - hn::StoreN(result, d, out, count); + hn::StoreN(similarity, d, out, count); return kAshSuccess; } diff --git a/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestASHGraphSearch.java b/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestASHGraphSearch.java index 92c962757..3e49e3932 100644 --- a/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestASHGraphSearch.java +++ b/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestASHGraphSearch.java @@ -123,12 +123,25 @@ public void negativeEntryReachesBaseLayerWithStandaloneAndFusedScoring() throws } @Test - public void dotProductSimilarityHasNonnegativeLowerBound() { - assertEquals(0f, ASHScorer.toSimilarity(-1.25f), 0f); + public void dotProductSimilarityUsesTheExactScaleUnclamped() { + assertEquals(-0.125f, ASHScorer.toSimilarity(-1.25f), 0f); assertEquals(0f, ASHScorer.toSimilarity(-1f), 0f); assertEquals(0.375f, ASHScorer.toSimilarity(-0.25f), 0f); assertEquals(0.5f, ASHScorer.toSimilarity(0f), 0f); assertEquals(1f, ASHScorer.toSimilarity(1f), 0f); + assertEquals(3f, ASHScorer.toSimilarity(5f), 0f); + for (float dot : new float[] {-7.5f, -1.25f, 0f, 0.75f, 4f}) { + float a = 0.3f * dot, b = 0.7f * dot; + assertEquals(VectorSimilarityFunction.DOT_PRODUCT.compare(vector(a), vector(1f)) + - VectorSimilarityFunction.DOT_PRODUCT.compare(vector(b), vector(1f)), + ASHScorer.toSimilarity(a) - ASHScorer.toSimilarity(b), 1e-6f); + } + } + + private static VectorFloat vector(float value) { + var v = VectorizationProvider.getInstance().getVectorTypeSupport().createFloatVector(1); + v.set(0, value); + return v; } private static void restore(String name, String value) { diff --git a/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestUnnormalizedVectors.java b/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestUnnormalizedVectors.java new file mode 100644 index 000000000..1d4ef699d --- /dev/null +++ b/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestUnnormalizedVectors.java @@ -0,0 +1,201 @@ +/* + * 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 io.github.jbellis.jvector.graph.GraphIndexBuilder; +import io.github.jbellis.jvector.graph.GraphSearcher; +import io.github.jbellis.jvector.graph.ImmutableGraphIndex; +import io.github.jbellis.jvector.graph.ListRandomAccessVectorValues; +import io.github.jbellis.jvector.graph.similarity.BuildScoreProvider; +import io.github.jbellis.jvector.graph.similarity.DefaultSearchScoreProvider; +import io.github.jbellis.jvector.graph.similarity.ScoreFunction; +import io.github.jbellis.jvector.util.Bits; +import io.github.jbellis.jvector.vector.VectorSimilarityFunction; +import io.github.jbellis.jvector.vector.VectorUtil; +import io.github.jbellis.jvector.vector.VectorizationProvider; +import io.github.jbellis.jvector.vector.types.VectorFloat; +import org.junit.Test; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashSet; +import java.util.List; +import java.util.Random; +import java.util.Set; +import java.util.concurrent.ForkJoinPool; +import java.util.function.IntFunction; + +import static org.junit.Assert.assertTrue; + +/** + * Dot-product search over vectors whose lengths vary 100x must rank by the full dot product, norms included, for exact, + * PQ and ASH scoring, both by brute force and through a graph built with that scoring. The data is chosen so that a + * scorer that ignored norms (ranking by direction alone) would miss most of the true neighbors. Each scorer is judged + * against itself on the same directions at unit length, so its accuracy at a given budget does not decide the result: + * a scorer that keeps norm information is at least as accurate on the mixed lengths and clearly above norm-blind ranking. + */ +public class TestUnnormalizedVectors { + private static final int N = 1000, DIM = 64, QUERIES = 100, K = 10; + // Recall@10 over 100 queries moves by about 0.01 between backends and graph builds; allow several times that. + private static final double NOISE = 0.05; + // How far above norm-blind ranking a norm-aware scorer must be. + private static final double MARGIN = 0.1; + + @Test + public void graphSearchRanksByTheFullDotProduct() throws Exception { + var vts = VectorizationProvider.getInstance().getVectorTypeSupport(); + var random = new Random(20261112); + List> base = new ArrayList<>(N), unitBase = new ArrayList<>(N); + for (int i = 0; i < N; i++) { + var v = randomVector(vts, random); + base.add(v); + unitBase.add(unit(v)); + } + List> queries = new ArrayList<>(QUERIES), unitQueries = new ArrayList<>(QUERIES); + for (int i = 0; i < QUERIES; i++) { + var q = randomVector(vts, random); + queries.add(q); + unitQueries.add(unit(q)); + } + + // The norm-blind ranking (cosine) against the dot-product truth: what a scorer ignoring norms would score. + List> dotTruth = truth(queries, base), cosineTruth = new ArrayList<>(); + for (var q : queries) cosineTruth.add(topK(i -> VectorUtil.cosine(q, base.get(i)))); + double normBlind = recall(cosineTruth, dotTruth); + System.out.format("norm-blind (cosine) recall@%d against the dot-product truth: %.3f%n", K, normBlind); + + // The same directions at unit length are the control: a scorer as accurate on mixed lengths as there loses + // nothing to the norms; one less accurate on mixed lengths does. + List failures = new ArrayList<>(); + var mixed = runAll("mixed lengths", base, queries, dotTruth); + var unitResults = runAll("unit length ", unitBase, unitQueries, truth(unitQueries, unitBase)); + for (var name : mixed.keySet()) { + double[] m = mixed.get(name), u = unitResults.get(name); + System.out.format("%-22s mixed scan %.3f graph %.3f | unit scan %.3f graph %.3f | mixed - unit %+.3f%n", + name, m[0], m[1], u[0], u[1], m[0] - u[0]); + for (int i = 0; i < 2; i++) { + String path = i == 0 ? "scan" : "graph"; + if (!(m[i] >= u[i] - NOISE)) failures.add(name + " " + path + " loses to norms: " + m[i] + " < unit " + u[i]); + if (!(m[i] >= normBlind + MARGIN)) failures.add(name + " " + path + " near norm-blind: " + m[i]); + } + } + assertTrue("the data must separate norm-aware from norm-blind ranking: " + normBlind, normBlind < 0.5); + assertTrue("must rank by the full dot product: " + failures, failures.isEmpty()); + } + + /** Recall of every scorer configuration on one dataset; budgets are matched at 16 and 32 bytes per vector. */ + private static java.util.Map runAll(String label, List> base, + List> queries, List> truth) + throws java.io.IOException { + var ravv = new ListRandomAccessVectorValues(base, DIM); + var vsf = VectorSimilarityFunction.DOT_PRODUCT; + var results = new java.util.LinkedHashMap(); + results.put("exact (256 B)", check(label, "exact (256 B)", BuildScoreProvider.randomAccessScoreProvider(ravv, vsf), + ravv, queries, truth, q -> DefaultSearchScoreProvider.exact(q, vsf, ravv).scoreFunction())); + // PQ: 16 B = 16 subspaces of 4 dimensions; 32 B = 32 subspaces of 2; 256 centroids each. + for (int m : new int[] {16, 32}) { + var pqv = ProductQuantization.compute(ravv, m, 256, false).encodeAll(ravv, ForkJoinPool.commonPool()); + String name = "PQ " + m + "x8 (" + m + " B)"; + results.put(name, check(label, name, BuildScoreProvider.pqBuildScoreProvider(vsf, pqv), ravv, queries, truth, + q -> pqv.precomputedScoreFunctionFor(q, vsf))); + } + // ASH, one landmark, 5-byte header: 16 B = 44 dims x 2 bits or 22 x 4; 32 B = 54 x 4 (or 64 x 2 = 21 B). + int[][] ash = {{2, 44}, {4, 22}, {4, 54}, {2, 64}}; + for (int[] config : ash) { + int bits = config[0], dims = config[1]; + int totalBits = AsymmetricHashing.HEADER_BITS + dims * bits; + var ashv = AsymmetricHashing.initialize(ravv, AsymmetricHashing.RANDOM, totalBits, 1, bits) + .encodeAll(ravv, ForkJoinPool.commonPool()); + String name = "ASH " + dims + "x" + bits + " (" + (totalBits / 8) + " B)"; + results.put(name, check(label, name, BuildScoreProvider.ashBuildScoreProvider(vsf, ashv), ravv, queries, + truth, q -> ashv.precomputedScoreFunctionFor(q, vsf))); + } + return results; + } + + private static List> truth(List> queries, List> base) { + List> truth = new ArrayList<>(); + for (var q : queries) truth.add(topK(i -> VectorUtil.dotProduct(q, base.get(i)))); + return truth; + } + + private static VectorFloat unit(VectorFloat v) { + var u = v.copy(); + VectorUtil.l2normalize(u); + return u; + } + + /** Random direction, length log-uniform in [0.1, 10]. */ + private static VectorFloat randomVector(io.github.jbellis.jvector.vector.types.VectorTypeSupport vts, Random r) { + var v = vts.createFloatVector(DIM); + for (int d = 0; d < DIM; d++) v.set(d, (float) r.nextGaussian()); + VectorUtil.l2normalize(v); + VectorUtil.scale(v, (float) Math.pow(10, -1 + 2 * r.nextDouble())); + return v; + } + + /** + * Brute-force and graph recall@K against {@code truth}, as {scan, graph}. The graph is built with the same scoring + * and searched with adaptive stopping off. + */ + private static double[] check(String label, String name, BuildScoreProvider bsp, ListRandomAccessVectorValues ravv, + List> queries, List> truth, + java.util.function.Function, ScoreFunction> scorerFor) { + List> scan = new ArrayList<>(), graphResults = new ArrayList<>(); + ImmutableGraphIndex graph; + try (var builder = new GraphIndexBuilder(bsp, DIM, 16, 100, 1.2f, 1.2f, false)) { + graph = builder.build(ravv); + } catch (Exception e) { + throw new RuntimeException(e); + } + try (var searcher = new GraphSearcher(graph)) { + searcher.useAdaptiveTermination(false); + for (var q : queries) { + var sf = scorerFor.apply(q); + scan.add(topK(sf::similarityTo)); + var result = searcher.search(new DefaultSearchScoreProvider(sf), K, 100, 0f, 0f, Bits.ALL); + var found = new HashSet(); + for (var ns : result.getNodes()) found.add(ns.node); + graphResults.add(found); + } + } catch (Exception e) { + throw new RuntimeException(e); + } + double scanRecall = recall(scan, truth), graphRecall = recall(graphResults, truth); + System.out.format("%s %-22s brute-force recall@%d %.3f, graph recall@%d %.3f%n", + label, name, K, scanRecall, K, graphRecall); + return new double[] {scanRecall, graphRecall}; + } + + private static Set topK(IntFunction score) { + Integer[] ids = new Integer[N]; + float[] scores = new float[N]; + for (int i = 0; i < N; i++) { + ids[i] = i; + scores[i] = score.apply(i); + } + Arrays.sort(ids, (a, b) -> Float.compare(scores[b], scores[a])); + return new HashSet<>(Arrays.asList(ids).subList(0, K)); + } + + private static double recall(List> found, List> truth) { + int hits = 0; + for (int i = 0; i < truth.size(); i++) { + for (int id : found.get(i)) if (truth.get(i).contains(id)) hits++; + } + return hits / (double) (truth.size() * K); + } +} From c7f0ea5962c68742ddcb16b72ad4637f856fae43 Mon Sep 17 00:00:00 2001 From: Ted Willke Date: Thu, 12 Nov 2026 19:09:41 -0800 Subject: [PATCH 241/243] Make adaptive stopping norm-aware, bounded, and the default for every scorer Adaptive stopping read graph scores as distances between unit-length vectors, so on vectors of other lengths it fell back to the standard rule or misjudged the margin, and it applied only to ASH. It now reads scores as true metric distances: d^2 = 1 / score - 1 for Euclidean, 1 - score for cosine, and for dot products the distance after the standard reduction of inner-product search to nearest-neighbor search, measured from an upper bound on the query's dot products, its length times the largest stored vector length. The margin, about 0.005 on the distance, means the same thing for every similarity function and every vector length, and on unit-length vectors the rule decides as before. A search that stops adaptively expands at least the nodes the standard rule would, and at most the nodes a standard search with twice the beam would. Without the cap, a relative margin admits very many candidates when distances are concentrated. The bar below which scored nodes are skipped never falls during a search, so skipped nodes stay skipped. The largest vector length comes from the vectors. ASH and PQ models record it for every vector they encode or whose codes they hold, and save it with the model; the inline and separated vector features and compaction store it in the index header, at format version 7. A length that is unknown, as for a model loaded from an older format, keeps dot-product searches on the standard rule. Searches through DefaultSearchScoreProvider.exact and forSimilarity, and all ASH searches, stop adaptively; providers built with the plain constructors keep the standard rule, and GraphSearcher.useAdaptiveTermination(false) turns adaptive stopping off. --- .../jvector/graph/AdaptiveTermination.java | 187 ++++++++++++------ .../jbellis/jvector/graph/GraphSearcher.java | 65 ++++-- .../graph/disk/AbstractGraphIndexFormat.java | 2 +- .../jvector/graph/disk/CompactWriter.java | 5 +- .../graph/disk/GraphIndexFormatV6.java | 2 +- .../jvector/graph/disk/OnDiskGraphIndex.java | 21 +- .../jvector/graph/disk/feature/Feature.java | 5 + .../jvector/graph/disk/feature/FusedPQ.java | 5 + .../graph/disk/feature/InlineVectors.java | 50 ++++- .../graph/disk/feature/MaxVectorNorm.java | 60 ++++++ .../graph/disk/feature/SeparatedVectors.java | 28 ++- .../graph/similarity/BuildScoreProvider.java | 10 +- .../DefaultSearchScoreProvider.java | 60 ++++-- .../graph/similarity/ScoreFunction.java | 10 + .../graph/similarity/SearchScoreProvider.java | 18 +- .../jvector/quantization/ASHScorer.java | 12 ++ .../jvector/quantization/ASHVectors.java | 82 ++++++-- .../quantization/AsymmetricHashing.java | 66 ++++++- .../jvector/quantization/FusedASHDecoder.java | 8 + .../jvector/quantization/FusedPQDecoder.java | 11 ++ .../quantization/ImmutablePQVectors.java | 13 ++ .../quantization/MutablePQVectors.java | 2 + .../jvector/quantization/PQDecoder.java | 14 ++ .../jvector/quantization/PQVectors.java | 13 +- .../quantization/ProductQuantization.java | 50 ++++- .../jbellis/jvector/util/MaxVectorLength.java | 74 +++++++ .../jvector/vector/VectorUtilSupport.java | 4 +- .../example/ASHNeighborhoodFixture.java | 6 +- .../jbellis/jvector/example/DistancesASH.java | 5 +- .../github/jbellis/jvector/example/Grid.java | 19 +- .../jbellis/jvector/example/IPCService.java | 2 +- .../diagnostics/ASHBuildScoreExperiment.java | 8 +- .../graph/AdaptiveTerminationTest.java | 176 ++++++++++++++--- .../graph/TestAdaptiveStoppingBounds.java | 86 ++++++++ .../jvector/graph/TestBoundedCandidates.java | 2 +- .../graph/disk/TestFeatureHeaderVersion.java | 12 ++ .../disk/TestOnDiskGraphIndexCompactor.java | 8 + .../graph/disk/TestOnDiskVectorNorms.java | 96 +++++++++ .../TestAdaptiveStoppingDefault.java | 120 +++++++++++ .../quantization/TestASHAdaptiveStopping.java | 4 +- .../quantization/TestASHEncodedNorms.java | 83 ++++++++ .../quantization/TestASHGraphSearch.java | 4 +- .../quantization/TestASHQueryPreparation.java | 2 +- .../quantization/TestASHSymmetricScorer.java | 3 +- .../quantization/TestPQEncodedNorms.java | 102 ++++++++++ .../quantization/TestPreparedASHQuery.java | 2 +- .../quantization/TestUnnormalizedVectors.java | 68 +++++-- 47 files changed, 1495 insertions(+), 190 deletions(-) create mode 100644 jvector-base/src/main/java/io/github/jbellis/jvector/graph/disk/feature/MaxVectorNorm.java create mode 100644 jvector-base/src/main/java/io/github/jbellis/jvector/util/MaxVectorLength.java create mode 100644 jvector-tests/src/test/java/io/github/jbellis/jvector/graph/TestAdaptiveStoppingBounds.java create mode 100644 jvector-tests/src/test/java/io/github/jbellis/jvector/graph/disk/TestOnDiskVectorNorms.java create mode 100644 jvector-tests/src/test/java/io/github/jbellis/jvector/graph/similarity/TestAdaptiveStoppingDefault.java create mode 100644 jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestASHEncodedNorms.java create mode 100644 jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestPQEncodedNorms.java diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/graph/AdaptiveTermination.java b/jvector-base/src/main/java/io/github/jbellis/jvector/graph/AdaptiveTermination.java index 79357896c..7dfc09428 100644 --- a/jvector-base/src/main/java/io/github/jbellis/jvector/graph/AdaptiveTermination.java +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/graph/AdaptiveTermination.java @@ -15,26 +15,47 @@ */ package io.github.jbellis.jvector.graph; +import io.github.jbellis.jvector.vector.VectorSimilarityFunction; + /** - * Adaptive termination for graph DOT_PRODUCT similarity scores. - * - *

    The adaptive rule stops when the best unexpanded candidate is farther than - * the kth-best discovered result by a relative factor: + * Distance-adaptive termination: a top-K search stops when the best unexpanded candidate is farther from the query + * than the k-th best result found, by a relative margin: * *

    - *   d(q, candidate) > (1 + gamma) * d_k
    + *   d(q, candidate) > (1 + gamma) * d(q, k-th best)
      * 
    * - *

    GraphSearcher works with higher-is-better normalized scores. We convert - * those scores back to a squared-L2-like scale for normalized vectors using - * {@code distance = 1 - score}. If a query produces scores - * outside the supported range, callers must fall back to standard termination. + *

    {@code d} is a true metric distance, so {@code gamma} means the same thing for every similarity function and + * whatever the lengths of the vectors. Scores are read back as distances as follows; the rule compares squared + * distances against {@code (1 + gamma)^2}, and any positive scale factor cancels: + * + *

      + *
    • EUCLIDEAN: the score is {@code 1 / (1 + d^2)}, so {@code d^2 = 1 / score - 1}.
    • + *
    • COSINE: the score is {@code (1 + cos) / 2}, and {@code d^2 = 2 - 2 cos} is proportional to {@code 1 - score}.
    • + *
    • DOT_PRODUCT: the score is {@code (1 + q.x) / 2}. With {@code B} an upper bound on {@code q.x}, such as the + * query's length times the largest stored vector length, {@code B - q.x} is proportional to the squared Euclidean + * distance between the query and the vector after the standard reduction of inner-product search to nearest-neighbor + * search (each vector scaled into the unit ball and lifted onto the unit sphere by one extra coordinate, the query + * scaled to unit length). In score units that is {@code top - score} with {@code top = (1 + B) / 2}. An approximate + * score above {@code top} raises {@code top} to it, which only loosens the margin. On unit-length vectors + * {@code B = 1} and the rule reads {@code 1 - score}, as cosine does.
    • + *
    + * + *

    The search never stops earlier than the standard rule would, and never later than a standard search with + * {@value #CAP_FACTOR} times the beam would: the searcher keeps the {@value #CAP_FACTOR}{@code k}-th best score found + * and passes it to {@link #raiseCap}, and candidates scoring below it stop the search whatever the margin. The cap + * bounds the work on data whose distances are concentrated, where a relative margin admits many candidates. + * + *

    A non-finite score, or a dot-product search whose bound is unknown, leaves the standard rule to decide. */ final class AdaptiveTermination { - static final float DEFAULT_GAMMA = 0.01f; + /** The default margin on the metric distance: 1% on squared distances. */ + static final float DEFAULT_GAMMA = (float) (Math.sqrt(1.01d) - 1.0d); + + /** Adaptive stopping searches no further than a standard search with this many times the beam. */ + static final int CAP_FACTOR = 2; - private static final double MIN_SCORE = 0.0d; - private static final double MAX_SCORE = 1.0d; + // Room for float rounding in the scores, relative to the scale of the distances compared. private static final double EPSILON = 1e-6d; enum Decision { @@ -44,32 +65,62 @@ enum Decision { } private int k; - private float gamma; + /** (1 + gamma)^2: the margin on squared distances. */ + private double factor; + /** Euclidean scores are read as 1 / score - 1; the others as top - score. */ + private boolean euclidean; + /** In score units, the score of a distance of zero (linear forms only). Rises with any higher score observed. */ + private float top; + /** The cap: the CAP_FACTOR * k-th best score found, once known. Only rises. */ + private float capScore; + /** + * The highest bar {@link #lowestContinuableScore} has returned. Callers skip nodes scored below a bar, so the rule + * never continues a candidate below it; otherwise a rising {@code top} would lower the bar after such a skip. + */ + private float floor; private boolean unsupportedScoreObserved; - private boolean rawDotProductScores; AdaptiveTermination() { - reset(1, DEFAULT_GAMMA); + reset(1, DEFAULT_GAMMA, VectorSimilarityFunction.COSINE, Float.NaN); } - void reset(int k) { - reset(k, DEFAULT_GAMMA); - } - - void reset(int k, float gamma) { - reset(k, gamma, false); - } - - void reset(int k, float gamma, boolean rawDotProductScores) { + /** + * Prepares for a search of the {@code k} best results scored by {@code similarity}. {@code dotProductBound} is an + * upper bound on the query's dot product with any stored vector, needed for DOT_PRODUCT and ignored otherwise. + * + * @return whether this rule can decide the search; if not, the standard rule must be used + */ + boolean reset(int k, float gamma, VectorSimilarityFunction similarity, float dotProductBound) { if (k <= 0) { throw new IllegalArgumentException("k must be > 0"); } validateGamma(gamma); - this.k = k; - this.gamma = gamma; + this.factor = (1.0d + gamma) * (1.0d + gamma); this.unsupportedScoreObserved = false; - this.rawDotProductScores = rawDotProductScores; + this.capScore = Float.NEGATIVE_INFINITY; + this.floor = Float.NEGATIVE_INFINITY; + switch (similarity) { + case EUCLIDEAN: + euclidean = true; + top = Float.NaN; + return true; + case COSINE: + euclidean = false; + top = 1.0f; + return true; + case DOT_PRODUCT: + euclidean = false; + if (!Float.isFinite(dotProductBound) || dotProductBound < 0) { + unsupportedScoreObserved = true; + return false; + } + top = (1.0f + dotProductBound) / 2.0f; + return true; + default: + unsupportedScoreObserved = true; + return false; + } } static void validateGamma(float gamma) { @@ -78,6 +129,21 @@ static void validateGamma(float gamma) { } } + /** + * Records a score among the best found. A score above the current zero-distance point raises it, so every + * distance stays nonnegative. + */ + void observe(float score) { + if (!euclidean && score > top && score != Float.POSITIVE_INFINITY) { + top = score; + } + } + + /** Raises the cap to {@code capScore}, the {@value #CAP_FACTOR}{@code k}-th best score found. */ + void raiseCap(float capScore) { + if (capScore > this.capScore) this.capScore = capScore; + } + /** * Decides whether the current best unexpanded candidate should terminate * the search under the distance-adaptive rule. @@ -89,52 +155,59 @@ Decision shouldTerminate(float candidateScore, NodeQueue discoveredResults) { if (discoveredResults.size() < k) { return Decision.CONTINUE; } - - double candidateDistance = scoreToDistance(candidateScore); - double kthDistance = scoreToDistance(discoveredResults.topScore()); - - if (Double.isNaN(candidateDistance) || Double.isNaN(kthDistance)) { + float kthScore = discoveredResults.topScore(); + if (Float.isNaN(candidateScore) || Float.isNaN(kthScore) || kthScore == Float.POSITIVE_INFINITY) { unsupportedScoreObserved = true; return Decision.FALL_BACK_TO_STANDARD; } - - if (Double.isInfinite(kthDistance)) { + if (kthScore == Float.NEGATIVE_INFINITY || candidateScore == Float.POSITIVE_INFINITY) { return Decision.CONTINUE; } - if (Double.isInfinite(candidateDistance)) { + if (candidateScore == Float.NEGATIVE_INFINITY || candidateScore < capScore || candidateScore < floor) { return Decision.TERMINATE; } - - double cutoff = (1.0d + gamma) * kthDistance; - return candidateDistance > cutoff + EPSILON - ? Decision.TERMINATE - : Decision.CONTINUE; + if (euclidean) { + if (!(kthScore > 0.0f)) { + return Decision.CONTINUE; + } + if (!(candidateScore > 0.0f)) { + return Decision.TERMINATE; + } + double kthDistance = Math.max(0.0d, 1.0d / kthScore - 1.0d); + double candidateDistance = 1.0d / candidateScore - 1.0d; + return candidateDistance > factor * kthDistance + EPSILON * (1.0d + kthDistance) + ? Decision.TERMINATE : Decision.CONTINUE; + } + double t = top; + return t - candidateScore > factor * (t - kthScore) + slack(t) + ? Decision.TERMINATE : Decision.CONTINUE; } /** * The lowest score a candidate may have and still be continued when the k-th best score found is - * {@code kthScore}: candidates scoring below it are past the cutoff, which only tightens as the search finds - * better nodes. Rounded down, so it never excludes a candidate {@link #shouldTerminate} would continue; when - * this rule cannot interpret the scores it returns negative infinity, leaving the standard rule to decide. + * {@code kthScore}, the cap included. Rounded down, so it never excludes a candidate {@link #shouldTerminate} + * would continue, and it never falls during a search. When this rule cannot interpret the scores it returns + * negative infinity, leaving the standard rule to decide. */ float lowestContinuableScore(float kthScore) { - double kthDistance = scoreToDistance(kthScore); - if (unsupportedScoreObserved || Double.isNaN(kthDistance) || Double.isInfinite(kthDistance)) { + if (unsupportedScoreObserved || !Float.isFinite(kthScore)) { return Float.NEGATIVE_INFINITY; } - double cutoff = (1.0d + gamma) * kthDistance + EPSILON; - double score = rawDotProductScores ? 1.0d - 2.0d * cutoff : 1.0d - cutoff; - return Math.nextDown(Math.nextDown((float) score)); + double score; + if (euclidean) { + if (!(kthScore > 0.0f)) return Float.NEGATIVE_INFINITY; + double kthDistance = Math.max(0.0d, 1.0d / kthScore - 1.0d); + score = 1.0d / (1.0d + factor * kthDistance + EPSILON * (1.0d + kthDistance)); + } else { + double t = top; + score = t - factor * (t - kthScore) - slack(t); + } + float bar = Math.max(capScore, Math.nextDown(Math.nextDown((float) score))); + if (bar > floor) floor = bar; + return floor; } - private double scoreToDistance(float score) { - if (!Float.isFinite(score)) { - return Double.NaN; - } - if (score < (rawDotProductScores ? -1.0d : MIN_SCORE) - EPSILON || score > MAX_SCORE + EPSILON) { - return Double.NaN; - } - double boundedScore = Math.min(MAX_SCORE, Math.max(rawDotProductScores ? -1.0d : MIN_SCORE, score)); - return rawDotProductScores ? (1.0d - boundedScore) / 2.0d : 1.0d - boundedScore; + private static double slack(double top) { + return EPSILON * Math.max(1.0d, Math.abs(top)); } } 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 9e18461d8..4ebfc5f58 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 @@ -71,6 +71,10 @@ public class GraphSearcher implements Closeable { private final AdaptiveTermination adaptiveTermination = new AdaptiveTermination(); // The best rerankK accepted nodes scored so far in the current layer search, worst on top. private final NodeQueue bestScored = new NodeQueue(new BoundedLongHeap(100), NodeQueue.Order.MIN_HEAP); + // Adaptively: the best CAP_FACTOR * rerankK accepted nodes scored so far, whose worst score caps the search. + private final NodeQueue capScored = new NodeQueue(new BoundedLongHeap(AdaptiveTermination.CAP_FACTOR * 100), + NodeQueue.Order.MIN_HEAP); + private int capSize; // Once bestScored is full: the lowest score a newly scored node may have and still be expanded. private float expandBar; private boolean adaptiveBar; @@ -156,12 +160,21 @@ void boundCandidates(boolean enabled) { this.boundCandidates = enabled; } - /** Enable or disable adaptive stopping for score providers that explicitly support it. */ + /** + * Enable or disable adaptive stopping for top-K searches whose score providers name their similarity function + * ({@link SearchScoreProvider#adaptiveSimilarity}) or whose score functions require it. Enabled by default. A search + * that stops adaptively expands at least what the standard rule would and at most what a standard search with + * twice the beam would. + */ public void useAdaptiveTermination(boolean enabled) { adaptiveTerminationEnabled = enabled; } - /** Configure the relative squared-distance margin for adaptive stopping. */ + /** + * As {@link #useAdaptiveTermination(boolean)}, with {@code gamma} the relative margin on the metric distance: a + * search stops when the best unexpanded candidate is more than {@code 1 + gamma} times as far from the query as + * the k-th best result found. The default is about 0.005. + */ public void useAdaptiveTermination(boolean enabled, float gamma) { AdaptiveTermination.validateGamma(gamma); adaptiveTerminationEnabled = enabled; @@ -455,13 +468,8 @@ public void searchOneLayer(SearchScoreProvider scoreProvider, approximateResults.setMaxSize(rerankK); // ASH score functions require adaptive stopping whatever provider wraps them; only // useAdaptiveTermination(false) turns it off. - boolean adaptive = adaptiveTerminationEnabled - && (scoreProvider.supportsAdaptiveDotProductTermination() - || scoreProvider.scoreFunction().requiresAdaptiveTermination()) - && level == 0 && threshold == 0.0f && rerankK > 0; - if (adaptive) { - adaptiveTermination.reset(rerankK, adaptiveTerminationGamma, scoreProvider.adaptiveScoresAreRawDotProducts()); - } + boolean adaptive = adaptiveTerminationEnabled && level == 0 && threshold == 0.0f && rerankK > 0 + && startAdaptiveTermination(scoreProvider, rerankK); // Bounded candidates: a scored node is expanded only while it ranks among the best rerankK accepted // nodes scored so far (or, adaptively, within the cutoff of the rerankK-th), and that bar only rises. // So a node that misses it when scored can never be expanded by this layer search: it is deferred @@ -471,6 +479,9 @@ public void searchOneLayer(SearchScoreProvider scoreProvider, deferredCandidates.foreach(candidates::push); deferredCandidates.clear(); bestScored.clear(); + capScored.clear(); + capSize = AdaptiveTermination.CAP_FACTOR * rerankK; + if (adaptive) capScored.setMaxSize(capSize); expandBar = Float.NEGATIVE_INFINITY; adaptiveBar = adaptive; if (trackBest) { @@ -591,18 +602,42 @@ SearchResult resume(int topK, int rerankK, float threshold, float rerankFloor) { * expansion bar when the rerankK-th best score rises. */ private void offerScored(int node, float score, int rerankK) { - if (bestScored.size() < rerankK) { - bestScored.push(node, score); - if (bestScored.size() == rerankK) updateExpandBar(); - } else if (score > bestScored.topScore()) { + // Every score above the best so far passes through here, so adaptive stopping sees the highest score found. + boolean changed = false; + if (adaptiveBar && (capScored.size() < capSize || score > capScored.topScore())) { + capScored.push(node, score); + changed = true; + } + if (bestScored.size() < rerankK || score > bestScored.topScore()) { bestScored.push(node, score); - updateExpandBar(); + if (adaptiveBar) adaptiveTermination.observe(score); + changed = true; } + // The bar depends on both the rerankK-th best and the cap, so it is refreshed when either moves. + if (changed && bestScored.size() == rerankK) updateExpandBar(); } - /** The rerankK-th best score, or adaptively the lowest score within its cutoff, whichever is lower. */ + /** + * Prepares adaptive stopping for this layer search when the provider asks for it, or its score function requires + * it (scoring DOT_PRODUCT), and the rule can read the scores as distances; otherwise the standard rule decides. + */ + private boolean startAdaptiveTermination(SearchScoreProvider scoreProvider, int rerankK) { + var scoreFunction = scoreProvider.scoreFunction(); + var similarity = scoreProvider.adaptiveSimilarity(); + if (similarity == null && scoreFunction.requiresAdaptiveTermination()) { + similarity = VectorSimilarityFunction.DOT_PRODUCT; + } + if (similarity == null) return false; + float bound = similarity == VectorSimilarityFunction.DOT_PRODUCT ? scoreProvider.dotProductBound() : Float.NaN; + return adaptiveTermination.reset(rerankK, adaptiveTerminationGamma, similarity, bound); + } + + /** The rerankK-th best score, or adaptively the lowest score within its cutoff and the cap, whichever is lower. */ private void updateExpandBar() { float kth = bestScored.topScore(); + if (adaptiveBar && capScored.size() >= capSize) { + adaptiveTermination.raiseCap(capScored.topScore()); + } expandBar = adaptiveBar ? Math.min(kth, adaptiveTermination.lowestContinuableScore(kth)) : kth; } diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/graph/disk/AbstractGraphIndexFormat.java b/jvector-base/src/main/java/io/github/jbellis/jvector/graph/disk/AbstractGraphIndexFormat.java index 12bb1d28a..18854ecdc 100644 --- a/jvector-base/src/main/java/io/github/jbellis/jvector/graph/disk/AbstractGraphIndexFormat.java +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/graph/disk/AbstractGraphIndexFormat.java @@ -389,7 +389,7 @@ public void writeHeaderFeatures(IndexWriter out, Map features) { int size = this.commonHeaderSize(); - size += features.values().stream().mapToInt(Feature::headerSize).sum(); + size += features.values().stream().mapToInt(f -> f.headerSize(getVersion())).sum(); return size; } diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/graph/disk/CompactWriter.java b/jvector-base/src/main/java/io/github/jbellis/jvector/graph/disk/CompactWriter.java index 73da527d8..29f2149f6 100644 --- a/jvector-base/src/main/java/io/github/jbellis/jvector/graph/disk/CompactWriter.java +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/graph/disk/CompactWriter.java @@ -51,6 +51,7 @@ final class CompactWriter implements AutoCloseable { private final RandomAccessWriter writer; private final int recordSize; private final long startOffset; + private final InlineVectors inlineVectorFeature; private final int headerSize; private final Header header; private final FusedFeature fusedFeature; @@ -111,7 +112,7 @@ final class CompactWriter implements AutoCloseable { this.entryNodePqCode = null; Map featureMap = new LinkedHashMap<>(); - InlineVectors inlineVectorFeature = new InlineVectors(dimension); + this.inlineVectorFeature = new InlineVectors(dimension); featureMap.put(FeatureId.INLINE_VECTORS, inlineVectorFeature); if (fusedFeature != null) { featureMap.put(fusedFeature.id(), fusedFeature); @@ -310,6 +311,8 @@ public WriteResult writeInlineNodeRecord(int ordinal, VectorFloat vec, Select for(int i = 0; i < vec.length(); ++i) { bwriter.writeFloat(vec.get(i)); } + // The footer's copy of the header, which loading reads, then stores the largest length written. + inlineVectorFeature.recordWritten(vec); // write fused PQ // since we build a graph in a streaming way, diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/graph/disk/GraphIndexFormatV6.java b/jvector-base/src/main/java/io/github/jbellis/jvector/graph/disk/GraphIndexFormatV6.java index a9a46ed5c..0d53356ca 100644 --- a/jvector-base/src/main/java/io/github/jbellis/jvector/graph/disk/GraphIndexFormatV6.java +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/graph/disk/GraphIndexFormatV6.java @@ -128,7 +128,7 @@ public int headerSize(Map features) { // In V6, this accounts for the number of features and the ordinal of each feature size += Integer.BYTES + features.size() * Integer.BYTES; - size += features.values().stream().mapToInt(Feature::headerSize).sum(); + size += features.values().stream().mapToInt(f -> f.headerSize(getVersion())).sum(); return size; } diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/graph/disk/OnDiskGraphIndex.java b/jvector-base/src/main/java/io/github/jbellis/jvector/graph/disk/OnDiskGraphIndex.java index 1249b9da1..c2aa46cc1 100644 --- a/jvector-base/src/main/java/io/github/jbellis/jvector/graph/disk/OnDiskGraphIndex.java +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/graph/disk/OnDiskGraphIndex.java @@ -740,7 +740,24 @@ public void close() throws IOException { @Override public ScoreFunction.ExactScoreFunction rerankerFor(VectorFloat queryVector, VectorSimilarityFunction vsf) { if (features.containsKey(FeatureId.INLINE_VECTORS)) { - return RandomAccessVectorValues.super.rerankerFor(queryVector, vsf); + // Exact scores of the inline vectors, reporting the dot-product bound adaptive stopping needs: the + // query's length times the largest stored length (NaN when the header did not store it). + float maxNorm = ((InlineVectors) features.get(FeatureId.INLINE_VECTORS)).maxVectorNorm(); + return new ScoreFunction.ExactScoreFunction() { + private final VectorFloat scratch = vectorTypeSupport.createFloatVector(dimension); + + @Override + public float similarityTo(int node2) { + getVectorInto(node2, scratch, 0); + return vsf.compare(queryVector, scratch); + } + + @Override + public float dotProductBound() { + return (float) Math.sqrt(io.github.jbellis.jvector.vector.VectorUtil.dotProduct(queryVector, queryVector)) + * maxNorm; + } + }; } else if (features.containsKey(FeatureId.NVQ_VECTORS)) { return ((NVQ) features.get(FeatureId.NVQ_VECTORS)).rerankerFor(queryVector, vsf, this); } else { @@ -755,7 +772,7 @@ public io.github.jbellis.jvector.graph.similarity.SearchScoreProvider scoreProvi if (feature == null) throw new UnsupportedOperationException("Prepared query scoring requires FusedASH"); var asf = feature.approximateScoreFunctionFor(prepared, this); var rr = rerank ? rerankerFor(prepared.queryVector(), VectorSimilarityFunction.DOT_PRODUCT) : null; - return new io.github.jbellis.jvector.graph.similarity.DefaultSearchScoreProvider(asf, rr, true); + return new io.github.jbellis.jvector.graph.similarity.DefaultSearchScoreProvider(asf, rr, io.github.jbellis.jvector.vector.VectorSimilarityFunction.DOT_PRODUCT); } @Override diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/graph/disk/feature/Feature.java b/jvector-base/src/main/java/io/github/jbellis/jvector/graph/disk/feature/Feature.java index 1602aa2fa..16b4e145b 100644 --- a/jvector-base/src/main/java/io/github/jbellis/jvector/graph/disk/feature/Feature.java +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/graph/disk/feature/Feature.java @@ -34,6 +34,11 @@ default boolean isFused() { int headerSize(); + /** The bytes {@link #writeHeader(IndexWriter, int)} writes at {@code version}. */ + default int headerSize(int version) { + return headerSize(); + } + int featureSize(); /** diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/graph/disk/feature/FusedPQ.java b/jvector-base/src/main/java/io/github/jbellis/jvector/graph/disk/feature/FusedPQ.java index a8d39aa1b..4067a33fb 100644 --- a/jvector-base/src/main/java/io/github/jbellis/jvector/graph/disk/feature/FusedPQ.java +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/graph/disk/feature/FusedPQ.java @@ -95,6 +95,11 @@ public int headerSize() { return pq.compressorSize(); } + @Override + public int headerSize(int version) { + return pq.compressorSize(version); + } + @Override public int featureSize() { return pq.compressedVectorSize() * maxDegree; diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/graph/disk/feature/InlineVectors.java b/jvector-base/src/main/java/io/github/jbellis/jvector/graph/disk/feature/InlineVectors.java index cac33f6f2..efb40b11c 100644 --- a/jvector-base/src/main/java/io/github/jbellis/jvector/graph/disk/feature/InlineVectors.java +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/graph/disk/feature/InlineVectors.java @@ -25,6 +25,7 @@ import io.github.jbellis.jvector.vector.types.VectorTypeSupport; import java.io.IOException; +import java.io.UncheckedIOException; /** * Implements the storage of full-resolution vectors inline into an OnDiskGraphIndex. These can be used for exact scoring. @@ -32,9 +33,26 @@ public class InlineVectors extends AbstractFeature { private static final VectorTypeSupport vectorTypeSupport = VectorizationProvider.getInstance().getVectorTypeSupport(); private final int dimension; + // The largest length of the vectors this feature has written, for the header. + private final MaxVectorNorm written = new MaxVectorNorm(); + // The largest length stored in a loaded index's header; NaN when unknown. + private final float stored; public InlineVectors(int dimension) { + this(dimension, Float.NaN); + } + + private InlineVectors(int dimension, float stored) { this.dimension = dimension; + this.stored = stored; + } + + /** + * The largest length of the stored vectors: from the header of a loaded index, or of the vectors written so far. + * NaN when unknown, as for an index written before format version 7 or by a writer that wrote its header first. + */ + public float maxVectorNorm() { + return Float.isNaN(stored) ? written.get() : stored; } @Override @@ -44,7 +62,12 @@ public FeatureId id() { @Override public int headerSize() { - return 0; + return headerSize(OnDiskGraphIndex.CURRENT_VERSION); + } + + @Override + public int headerSize(int version) { + return MaxVectorNorm.headerSize(version); } public int featureSize() { @@ -56,23 +79,38 @@ public int dimension() { } static InlineVectors load(CommonHeader header, RandomAccessReader reader) { - return new InlineVectors(header.dimension); + try { + return new InlineVectors(header.dimension, MaxVectorNorm.read(reader, header.version)); + } catch (IOException e) { + throw new UncheckedIOException(e); + } } @Deprecated @Override - public void writeHeader(IndexWriter out) { + public void writeHeader(IndexWriter out) throws IOException { writeHeader(out, OnDiskGraphIndex.CURRENT_VERSION); } @Override - public void writeHeader(IndexWriter out, int version) { - // common header contains dimension, which is sufficient + public void writeHeader(IndexWriter out, int version) throws IOException { + // The common header holds the dimension; from version 7 the header also stores the largest vector length. + written.write(out, version); + } + + /** + * Counts {@code vector} in the largest length this feature's header stores, for writers that serialize inline + * vectors themselves instead of through {@link #writeInline}. + */ + public void recordWritten(VectorFloat vector) { + written.record(vector); } @Override public void writeInline(IndexWriter out, Feature.State state) throws IOException { - vectorTypeSupport.writeFloatVector(out, ((InlineVectors.State) state).vector); + var vector = ((InlineVectors.State) state).vector; + written.record(vector); + vectorTypeSupport.writeFloatVector(out, vector); } public static class State implements Feature.State { diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/graph/disk/feature/MaxVectorNorm.java b/jvector-base/src/main/java/io/github/jbellis/jvector/graph/disk/feature/MaxVectorNorm.java new file mode 100644 index 000000000..beb538209 --- /dev/null +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/graph/disk/feature/MaxVectorNorm.java @@ -0,0 +1,60 @@ +/* + * 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.disk.feature; + +import io.github.jbellis.jvector.disk.IndexWriter; +import io.github.jbellis.jvector.disk.RandomAccessReader; +import io.github.jbellis.jvector.vector.types.VectorFloat; + +import io.github.jbellis.jvector.util.MaxVectorLength; + +import java.io.IOException; + +/** + * The largest length of the full-precision vectors a feature stores, kept in its header from format version + * {@value #VERSION}. Dot-product searches that stop adaptively need it; without it they stop by the standard rule. + * + *

    A feature records each vector as it writes it. A header written before any vector, as a sequential writer writes + * it, stores NaN (unknown); a writer that rewrites the header after the vectors stores their largest length. + */ +final class MaxVectorNorm { + /** The first on-disk format version whose full-precision vector headers store the largest length. */ + static final int VERSION = 7; + + private final MaxVectorLength written = new MaxVectorLength(); + + void record(VectorFloat vector) { + written.record(vector); + } + + /** The largest length recorded, or NaN if none was. */ + float get() { + return written.get(); + } + + static int headerSize(int version) { + return version >= VERSION ? Float.BYTES : 0; + } + + void write(IndexWriter out, int version) throws IOException { + if (version >= VERSION) out.writeFloat(get()); + } + + /** The stored largest length, NaN when unknown or when {@code version} predates it. */ + static float read(RandomAccessReader in, int version) throws IOException { + return version >= VERSION ? in.readFloat() : Float.NaN; + } +} diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/graph/disk/feature/SeparatedVectors.java b/jvector-base/src/main/java/io/github/jbellis/jvector/graph/disk/feature/SeparatedVectors.java index b399574af..af1861a6f 100644 --- a/jvector-base/src/main/java/io/github/jbellis/jvector/graph/disk/feature/SeparatedVectors.java +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/graph/disk/feature/SeparatedVectors.java @@ -30,10 +30,27 @@ public class SeparatedVectors extends AbstractSeparatedFeature { private static final VectorTypeSupport vectorTypeSupport = VectorizationProvider.getInstance().getVectorTypeSupport(); private final int dimension; private long offset; + // The largest length of the vectors this feature has written, for the header. + private final MaxVectorNorm written = new MaxVectorNorm(); + // The largest length stored in a loaded index's header; NaN when unknown. + private final float stored; public SeparatedVectors(int dimension, long offset) { + this(dimension, offset, Float.NaN); + } + + private SeparatedVectors(int dimension, long offset, float stored) { this.dimension = dimension; this.offset = offset; + this.stored = stored; + } + + /** + * The largest length of the stored vectors: from the header of a loaded index, or of the vectors written so far. + * NaN when unknown, as for an index written before format version 7. + */ + public float maxVectorNorm() { + return Float.isNaN(stored) ? written.get() : stored; } @Override @@ -53,7 +70,12 @@ public FeatureId id() { @Override public int headerSize() { - return Long.BYTES; + return headerSize(OnDiskGraphIndex.CURRENT_VERSION); + } + + @Override + public int headerSize(int version) { + return Long.BYTES + MaxVectorNorm.headerSize(version); } @Override @@ -70,12 +92,14 @@ public void writeHeader(IndexWriter out) throws IOException { @Override public void writeHeader(IndexWriter out, int version) throws IOException { out.writeLong(offset); + written.write(out, version); } @Override public void writeSeparately(IndexWriter out, State state_) throws IOException { var state = (InlineVectors.State) state_; if (state.vector != null) { + written.record(state.vector); vectorTypeSupport.writeFloatVector(out, state.vector); } else { // Write zeros for missing vector @@ -90,7 +114,7 @@ public void writeSeparately(IndexWriter out, State state_) throws IOException { static SeparatedVectors load(CommonHeader header, RandomAccessReader reader) { try { long offset = reader.readLong(); - return new SeparatedVectors(header.dimension, offset); + return new SeparatedVectors(header.dimension, offset, MaxVectorNorm.read(reader, header.version)); } catch (IOException e) { throw new UncheckedIOException(e); } diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/graph/similarity/BuildScoreProvider.java b/jvector-base/src/main/java/io/github/jbellis/jvector/graph/similarity/BuildScoreProvider.java index be6208051..e8ff4c516 100644 --- a/jvector-base/src/main/java/io/github/jbellis/jvector/graph/similarity/BuildScoreProvider.java +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/graph/similarity/BuildScoreProvider.java @@ -156,8 +156,9 @@ public VectorFloat approximateCentroid() { @Override public SearchScoreProvider searchProviderFor(VectorFloat vector) { + // Construction keeps the standard stopping rule for full-precision scores. var vc = vectorsCopy.get(); - return DefaultSearchScoreProvider.exact(vector, similarityFunction, vc); + return new DefaultSearchScoreProvider(DefaultSearchScoreProvider.exactScoreFunction(vector, similarityFunction, vc)); } @Override @@ -314,12 +315,15 @@ public VectorFloat approximateCentroid() { @Override public SearchScoreProvider searchProviderFor(VectorFloat vector) { // Query-time asymmetric scoring is already implemented by ASHVectors. - return new DefaultSearchScoreProvider(ashv.precomputedScoreFunctionFor(vector, vsf), null, true); + return new DefaultSearchScoreProvider(ashv.precomputedScoreFunctionFor(vector, vsf), null, VectorSimilarityFunction.DOT_PRODUCT); } @Override public SearchScoreProvider searchProviderFor(int node1) { - return new DefaultSearchScoreProvider(symmetric.scoreFunctionFor(node1), null, true); + // A stored node's length is not kept; the largest encoded length bounds it. + float m = ash.maxEncodedNorm(); + return new DefaultSearchScoreProvider(symmetric.scoreFunctionFor(node1), null, + VectorSimilarityFunction.DOT_PRODUCT, m * m); } @Override diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/graph/similarity/DefaultSearchScoreProvider.java b/jvector-base/src/main/java/io/github/jbellis/jvector/graph/similarity/DefaultSearchScoreProvider.java index 87509dc4f..c11ec54ee 100644 --- a/jvector-base/src/main/java/io/github/jbellis/jvector/graph/similarity/DefaultSearchScoreProvider.java +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/graph/similarity/DefaultSearchScoreProvider.java @@ -24,8 +24,8 @@ public final class DefaultSearchScoreProvider implements SearchScoreProvider { private final ScoreFunction scoreFunction; private final ScoreFunction.ExactScoreFunction reranker; - private final boolean adaptiveDotProductTermination; - private final boolean adaptiveScoresAreRawDotProducts; + private final VectorSimilarityFunction adaptiveSimilarity; + private final float dotProductBound; /** * @param scoreFunction the primary, fast scoring function @@ -44,28 +44,54 @@ public DefaultSearchScoreProvider(ScoreFunction scoreFunction) { *

    * Most often it will be convenient to get the reranker either using `RandomAccessVectorValues.rerankerFor` * or `ScoringView.rerankerFor`. + *

    + * Searches with this provider stop by the standard rule, since it cannot tell what its scores measure; use + * {@link #forSimilarity} to let top-K searches stop adaptively where the similarity function supports it. */ public DefaultSearchScoreProvider(ScoreFunction scoreFunction, ScoreFunction.ExactScoreFunction reranker) { - this(scoreFunction, reranker, false, false); + this(scoreFunction, reranker, null); } - public DefaultSearchScoreProvider(ScoreFunction scoreFunction, ScoreFunction.ExactScoreFunction reranker, boolean adaptiveDotProductTermination) { - this(scoreFunction, reranker, adaptiveDotProductTermination, false); + /** + * @param adaptiveSimilarity the similarity function {@code scoreFunction} scores by, letting top-K searches stop + * adaptively; null keeps the standard rule + */ + public DefaultSearchScoreProvider(ScoreFunction scoreFunction, ScoreFunction.ExactScoreFunction reranker, + VectorSimilarityFunction adaptiveSimilarity) { + this(scoreFunction, reranker, adaptiveSimilarity, Float.NaN); } - public DefaultSearchScoreProvider(ScoreFunction scoreFunction, ScoreFunction.ExactScoreFunction reranker, boolean adaptiveDotProductTermination, boolean adaptiveScoresAreRawDotProducts) { + /** + * @param dotProductBound for DOT_PRODUCT scores, an upper bound on the dot product between the query and any + * stored vector, used in place of the score function's own; NaN defers to it + */ + public DefaultSearchScoreProvider(ScoreFunction scoreFunction, ScoreFunction.ExactScoreFunction reranker, + VectorSimilarityFunction adaptiveSimilarity, float dotProductBound) { assert scoreFunction != null; this.scoreFunction = scoreFunction; this.reranker = reranker; - this.adaptiveDotProductTermination = adaptiveDotProductTermination; - this.adaptiveScoresAreRawDotProducts = adaptiveScoresAreRawDotProducts; + this.adaptiveSimilarity = adaptiveSimilarity; + this.dotProductBound = dotProductBound; + } + + /** + * A provider for scores of {@code similarity} whose top-K searches stop adaptively. Dot-product searches need + * the score function's {@link ScoreFunction#dotProductBound} and keep the standard rule without one. + * {@link io.github.jbellis.jvector.graph.GraphSearcher#useAdaptiveTermination} turns adaptive stopping off. + */ + public static DefaultSearchScoreProvider forSimilarity(ScoreFunction scoreFunction, + ScoreFunction.ExactScoreFunction reranker, + VectorSimilarityFunction similarity) { + return new DefaultSearchScoreProvider(scoreFunction, reranker, similarity); } @Override - public boolean supportsAdaptiveDotProductTermination() { return adaptiveDotProductTermination; } + public VectorSimilarityFunction adaptiveSimilarity() { return adaptiveSimilarity; } @Override - public boolean adaptiveScoresAreRawDotProducts() { return adaptiveScoresAreRawDotProducts; } + public float dotProductBound() { + return Float.isNaN(dotProductBound) ? scoreFunction.dotProductBound() : dotProductBound; + } public ScoreFunction scoreFunction() { return scoreFunction; @@ -82,19 +108,25 @@ public ScoreFunction.ExactScoreFunction exactScoreFunction() { } /** - * A SearchScoreProvider for a single-pass search based on exact similarity. + * A SearchScoreProvider for a single-pass search based on exact similarity, stopping adaptively where + * {@code vsf} supports it (see {@link #forSimilarity}). * Generally only suitable when your RandomAccessVectorValues is entirely in-memory, * e.g. during construction. */ public static DefaultSearchScoreProvider exact(VectorFloat v, VectorSimilarityFunction vsf, RandomAccessVectorValues ravv) { + return forSimilarity(exactScoreFunction(v, vsf, ravv), null, vsf); + } + + /** Exact scores of {@code v} against {@code ravv}, safe for concurrent use. */ + static ScoreFunction.ExactScoreFunction exactScoreFunction(VectorFloat v, VectorSimilarityFunction vsf, + RandomAccessVectorValues ravv) { // don't use ESF.reranker, we need thread safety here - var sf = new ScoreFunction.ExactScoreFunction() { + return new ScoreFunction.ExactScoreFunction() { @Override public float similarityTo(int node2) { return vsf.compare(v, ravv.getVector(node2)); } }; - return new DefaultSearchScoreProvider(sf); } /** @@ -110,6 +142,6 @@ public float similarityTo(int node2) { return vsf.compare(v, ravv.getVector(graphToRavvOrdMap[node2])); } }; - return new DefaultSearchScoreProvider(sf); + return forSimilarity(sf, null, vsf); } } \ No newline at end of file diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/graph/similarity/ScoreFunction.java b/jvector-base/src/main/java/io/github/jbellis/jvector/graph/similarity/ScoreFunction.java index 078023d47..611638c4a 100644 --- a/jvector-base/src/main/java/io/github/jbellis/jvector/graph/similarity/ScoreFunction.java +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/graph/similarity/ScoreFunction.java @@ -90,6 +90,16 @@ default boolean requiresAdaptiveTermination() { return false; } + /** + * For DOT_PRODUCT scores, an upper bound on the dot product this function can score between its query and any + * stored vector, such as the query's length times the largest stored vector length; NaN when unknown. Adaptive + * stopping reads dot-product scores as distances only when it is known, so it must hold for every vector the + * search can reach. Called once per search. + */ + default float dotProductBound() { + return Float.NaN; + } + interface ExactScoreFunction extends ScoreFunction { default boolean isExact() { diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/graph/similarity/SearchScoreProvider.java b/jvector-base/src/main/java/io/github/jbellis/jvector/graph/similarity/SearchScoreProvider.java index ab7d493dd..dab2e2296 100644 --- a/jvector-base/src/main/java/io/github/jbellis/jvector/graph/similarity/SearchScoreProvider.java +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/graph/similarity/SearchScoreProvider.java @@ -16,6 +16,8 @@ package io.github.jbellis.jvector.graph.similarity; +import io.github.jbellis.jvector.vector.VectorSimilarityFunction; + /** Encapsulates comparing node distances to a specific vector for GraphSearcher. */ public interface SearchScoreProvider { @@ -25,9 +27,17 @@ public interface SearchScoreProvider { ScoreFunction.ExactScoreFunction exactScoreFunction(); - /** Approximate scores use graph DOT_PRODUCT similarity and support adaptive termination. */ - default boolean supportsAdaptiveDotProductTermination() { return false; } + /** + * The similarity function whose scores {@link #scoreFunction} returns, when top-K searches with this provider may + * stop adaptively; null keeps the standard rule. Score functions that require adaptive stopping + * ({@link ScoreFunction#requiresAdaptiveTermination}) score DOT_PRODUCT and stop adaptively regardless. + */ + default VectorSimilarityFunction adaptiveSimilarity() { return null; } - /** Whether the supported ASH score is a raw dot product in [-1, 1]. */ - default boolean adaptiveScoresAreRawDotProducts() { return false; } + /** + * For DOT_PRODUCT scores, an upper bound on the dot product between the query and any vector the search can + * reach; NaN when unknown, which keeps the standard rule. By default the score function's + * {@link ScoreFunction#dotProductBound}. + */ + default float dotProductBound() { return scoreFunction().dotProductBound(); } } \ No newline at end of file diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHScorer.java b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHScorer.java index d67693d01..a602e4310 100644 --- a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHScorer.java +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHScorer.java @@ -324,6 +324,17 @@ void finishQuery(VectorFloat query, QueryPrecompute prepared) { // Stored offsets include the landmark term, so every scorer needs only Aq and, for 1-bit // codes, its sum. prepared.sumQProj = sum(prepared.qProj); + prepared.queryNorm = (float) Math.sqrt(VectorUtil.dotProduct(query, query)); + } + + /** + * An upper bound on the dot products ASH estimates for {@code prepared}'s query: its length times the largest + * encoded vector length. A query prepared from a stored code, whose length is not kept, is bounded by the + * largest length. Estimates above it raise adaptive stopping's bound, which only loosens the margin. + */ + float dotProductBound(QueryPrecompute prepared) { + float m = ash.maxEncodedNorm(); + return (Float.isNaN(prepared.queryNorm) ? m : prepared.queryNorm) * m; } /** Left-to-right FP32 sum, the order every 1-bit scorer uses for {@code }. */ @@ -339,6 +350,7 @@ static final class QueryPrecompute { final float[] qProj; // [d], Aq final float[] dotQMuByLandmark; // [C] float sumQProj; // , set with qProj + float queryNorm = Float.NaN; // ||q||, when prepared from a raw query QueryPrecompute(int d, int C, float[] qProj, float[] dotQMuByLandmark) { this.d = d; diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHVectors.java b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHVectors.java index eec7092a3..526ba0d29 100644 --- a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHVectors.java +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHVectors.java @@ -139,6 +139,8 @@ private static final class PackedBits { public ASHVectors(AsymmetricHashing ash, AsymmetricHashing.QuantizedVector[] compressedVectors) { this(ash, ASHCodes.copyOf(compressedVectors, ash.quantizedDim, ash.bitsPerDimension), null); + // The codes may come from another instance of the model, so the lengths they encode are counted here. + ash.recordEncodedCodes(compressedVectors); } /** Takes ownership of {@code codes}, which must not change afterward. */ @@ -210,7 +212,7 @@ public ScoreFunction.AdaptiveApproximateScoreFunction scoreFunctionFor( // ASHScorer dispatches canonical 2/4-bit projection scoring to SIMD when available. // The packed masked-add path below is specific to one-bit codes. if (ash.bitsPerDimension != 1) { - return nodeScoreFunction(scorer.codeScorerFor(scorer.precomputeQuery(query))); + return nodeScoreFunction(scorer.precomputeQuery(query)); } final AshSingleKernel kernel = AshSingleKernel.fromProperty(); @@ -218,7 +220,7 @@ public ScoreFunction.AdaptiveApproximateScoreFunction scoreFunctionFor( // Scalar forced if (kernel == AshSingleKernel.SCALAR) { - return nodeScoreFunction(scorer.codeScorerFor(scorer.precomputeQuery(query))); + return nodeScoreFunction(scorer.precomputeQuery(query)); } // AUTO/SIMD: use SIMD scorer only when backend supports masked-load kernel; @@ -233,25 +235,60 @@ public ScoreFunction.AdaptiveApproximateScoreFunction scoreFunctionFor( if (kernel == AshSingleKernel.SIMD) { throw new IllegalStateException("ASH single-vector SIMD is unavailable on " + vecUtil.getClass().getName()); } - return nodeScoreFunction(scorer.codeScorerFor(scorer.precomputeQuery(query))); + return nodeScoreFunction(scorer.precomputeQuery(query)); } /** * Applies {@code f} to stored codes in place, in graph similarity units. One lambda per storage * shape keeps each call site free of null checks for the array a bit width does not use. */ - private ScoreFunction.AdaptiveApproximateScoreFunction nodeScoreFunction(ASHScorer.CodeScorer f) { + private ScoreFunction.AdaptiveApproximateScoreFunction nodeScoreFunction(ASHScorer.QueryPrecompute qp) { + return nodeScoreFunction(scorer.codeScorerFor(qp), qp); + } + + private ScoreFunction.AdaptiveApproximateScoreFunction nodeScoreFunction(ASHScorer.CodeScorer f, + ASHScorer.QueryPrecompute qp) { final ASHCodes c = codes; if (c.signWords == 0) { - return node -> ASHScorer.toSimilarity(f.dot(null, 0, c.bodyPage(node), c.bodyOffset(node), - scales[node], offsets[node], landmarks[node] & 0xFF)); + return new BoundedScoreFunction(qp) { + @Override + public float similarityTo(int node) { + return ASHScorer.toSimilarity(f.dot(null, 0, c.bodyPage(node), c.bodyOffset(node), + scales[node], offsets[node], landmarks[node] & 0xFF)); + } + }; } if (c.bodyBytes == 0) { - return node -> ASHScorer.toSimilarity(f.dot(c.signPage(node), c.signOffset(node), null, 0, - scales[node], offsets[node], landmarks[node] & 0xFF)); + return new BoundedScoreFunction(qp) { + @Override + public float similarityTo(int node) { + return ASHScorer.toSimilarity(f.dot(c.signPage(node), c.signOffset(node), null, 0, + scales[node], offsets[node], landmarks[node] & 0xFF)); + } + }; + } + return new BoundedScoreFunction(qp) { + @Override + public float similarityTo(int node) { + return ASHScorer.toSimilarity(f.dot(c.signPage(node), c.signOffset(node), + c.bodyPage(node), c.bodyOffset(node), scales[node], offsets[node], landmarks[node] & 0xFF)); + } + }; + } + + /** An ASH score function for one prepared query, reporting the query's dot-product bound. */ + private abstract class BoundedScoreFunction implements ScoreFunction.AdaptiveApproximateScoreFunction { + private final ASHScorer.QueryPrecompute qp; + + /** {@code qp} null leaves the bound unknown. */ + BoundedScoreFunction(ASHScorer.QueryPrecompute qp) { + this.qp = qp; + } + + @Override + public float dotProductBound() { + return qp == null ? Float.NaN : scorer.dotProductBound(qp); } - return node -> ASHScorer.toSimilarity(f.dot(c.signPage(node), c.signOffset(node), - c.bodyPage(node), c.bodyOffset(node), scales[node], offsets[node], landmarks[node] & 0xFF)); } // ============================================================================ @@ -284,6 +321,12 @@ private final class SimdASHScoreFunction implements ScoreFunction.AdaptiveApprox this.codes = ASHVectors.this.codes; } + @Override + public float dotProductBound() { + float m = ash.maxEncodedNorm(); + return (Float.isNaN(qp.queryNorm) ? m : qp.queryNorm) * m; + } + @Override public float similarityTo(int node2) { @@ -370,7 +413,7 @@ public ASHBlockScorer blockScorerFor( "ASH block scorer supports DOT_PRODUCT only"); } - return new LegacyASHBlockScorer(nodeScoreFunction(scorer.scalarCodeScorerFor(query)), codes.size); + return new LegacyASHBlockScorer(nodeScoreFunction(scorer.scalarCodeScorerFor(query), null), codes.size); } /** @@ -459,9 +502,9 @@ public ASHBlockScorer blockScorerFor( ScoreFunction.AdaptiveApproximateScoreFunction scoreFunctionFor(ASHScorer.QueryPrecompute qp, boolean simd) { if (ash.bitsPerDimension == 1 && simd) { return new SimdASHScoreFunction(new QueryPrecompute(qp.d, qp.C, qp.qProj, - qp.sumQProj, qp.dotQMuByLandmark)); + qp.sumQProj, qp.dotQMuByLandmark, qp.queryNorm)); } - return nodeScoreFunction(scorer.codeScorerFor(qp, simd)); + return nodeScoreFunction(scorer.codeScorerFor(qp, simd), qp); } ASHBlockScorer blockScorerFor(ASHScorer.QueryPrecompute prepared, int blockSize, boolean simd) { @@ -825,7 +868,8 @@ private QueryPrecompute precomputeQuery(VectorFloat query) { dotQMu[c] = VectorUtil.dotProduct(query, ash.landmarks[c]); } - return new QueryPrecompute(d, C, qProj, ASHScorer.sum(qProj), dotQMu); + return new QueryPrecompute(d, C, qProj, ASHScorer.sum(qProj), dotQMu, + (float) Math.sqrt(VectorUtil.dotProduct(query, query))); } static final class QueryPrecompute { @@ -834,16 +878,26 @@ static final class QueryPrecompute { final float[] qProj; // [d], Aq final float sumQProj; // final float[] dotQMu; // [C] + final float queryNorm; // ||q||, or NaN when prepared from a stored code QueryPrecompute(int d, int C, float[] qProj, float sumQProj, float[] dotQMu) { + this(d, C, qProj, sumQProj, dotQMu, Float.NaN); + } + + QueryPrecompute(int d, int C, + float[] qProj, + float sumQProj, + float[] dotQMu, + float queryNorm) { this.d = d; this.C = C; this.qProj = qProj; this.sumQProj = sumQProj; this.dotQMu = dotQMu; + this.queryNorm = queryNorm; } } diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/AsymmetricHashing.java b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/AsymmetricHashing.java index a9521f590..c28cbb0ed 100644 --- a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/AsymmetricHashing.java +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/AsymmetricHashing.java @@ -157,6 +157,30 @@ public class AsymmetricHashing implements VectorCompressor - ||mu||^2) + ||mu||^2}, with {@code ||x - mu|| = scale * ||code||}. + * Exact up to the half-precision rounding of the header. + */ + float encodedSquaredNorm(QuantizedVector vector) { + int c = vector.landmark & 255; + return vector.scale * vector.scale * codeSquaredNorm(vector) + 2f * centeredOffset(vector) + landmarkNormSq[c]; + } + + /** + * Counts vectors encoded by another instance of this model in {@link #maxEncodedNorm}, from their codes, with a + * margin for the half-precision headers. + */ + void recordEncodedCodes(QuantizedVector[] vectors) { + for (var vector : vectors) { + if (vector != null) recordEncodedSquaredNorm(encodedSquaredNorm(vector) * HEADER_NORM_MARGIN); + } + } + /** * As {@link #centeredOffset(QuantizedVector)} for a header with sign words at {@code signOffset} * and a body at {@code bodyOffset}; only the array the bit width stores is read. @@ -1777,6 +1838,8 @@ private float prepareEncoding(VectorFloat vector, QuantizedVector dest) { assert quantizedDim > 0; final float rawOffset = dotXMu - landmarkNormSq[c]; + // ||x||^2 = ||x - mu||^2 + 2 - ||mu||^2, from the terms already computed. + recordEncodedSquaredNorm(sqDist + 2f * dotXMu - landmarkNormSq[c]); // Header fields needed before quantization. The final offset may be // adjusted after quantization for C++-style fast-scan projection codes. @@ -2715,6 +2778,7 @@ public int compressorSize() { size += Integer.BYTES; // stiefel rows size += Integer.BYTES; // stiefel cols size += Float.BYTES * quantizedDim * originalDimension; + size += Float.BYTES; // largest encoded vector length return size; } diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/FusedASHDecoder.java b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/FusedASHDecoder.java index 5f50ba63d..e1c860948 100644 --- a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/FusedASHDecoder.java +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/FusedASHDecoder.java @@ -128,6 +128,7 @@ public QueryScratch(AsymmetricHashing ash, int maxDegree, int blockSize) { private final Int2ObjectHashMap hierarchyCachedFeatures; private final InlineSourceAccessor inlineSourceAccessor; private final ASHScorer.ASHScoreFunction sourceScorer; + private final float dotProductBound; private final int maxDegree; @@ -256,6 +257,13 @@ private FusedASHDecoder( externalPrepared == null ? (scratch == null ? null : scratch.lut) : externalPrepared.lut, externalPrepared != null); this.sourceScorer = scorer.scoreFunctionFor(preparedQuery, neighborhoodScorer.queryLut()); + this.dotProductBound = scorer.dotProductBound(preparedQuery); + } + + /** The query's length times the largest encoded vector length, taken when this decoder was prepared. */ + @Override + public float dotProductBound() { + return dotProductBound; } public static FusedASHDecoder newDecoder( diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/FusedPQDecoder.java b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/FusedPQDecoder.java index b52d0ca66..282da9db1 100644 --- a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/FusedPQDecoder.java +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/FusedPQDecoder.java @@ -124,6 +124,17 @@ public DotProductDecoder(FusedPQ.PackedNeighbors neighbors, ProductQuantization protected float distanceToScore(float distance) { return (distance + 1) / 2; } + + /** PQ scores the centered query against centered reconstructions, so their lengths bound the score. */ + @Override + public float dotProductBound() { + var center = pq.globalCentroid; + double squared = VectorUtil.dotProduct(query, query); + if (center != null) { + squared += VectorUtil.dotProduct(center, center) - 2.0 * VectorUtil.dotProduct(query, center); + } + return (float) Math.sqrt(Math.max(0.0, squared)) * pq.maxEncodedNorm(); + } } static class EuclideanDecoder extends FusedPQDecoder { diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ImmutablePQVectors.java b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ImmutablePQVectors.java index 8df05b675..e8afe756a 100644 --- a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ImmutablePQVectors.java +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ImmutablePQVectors.java @@ -37,11 +37,24 @@ public class ImmutablePQVectors extends PQVectors { * @param vectorsPerChunk the number of vectors per chunk */ public ImmutablePQVectors(ProductQuantization pq, ByteSequence[] compressedDataChunks, int vectorCount, int vectorsPerChunk) { + this(pq, compressedDataChunks, vectorCount, vectorsPerChunk, true); + } + + /** + * @param recordCodes whether {@code pq} must count these codes' lengths in its + * {@link ProductQuantization#maxEncodedNorm}: true unless it encoded them, or they were saved + * with it + */ + ImmutablePQVectors(ProductQuantization pq, ByteSequence[] compressedDataChunks, int vectorCount, + int vectorsPerChunk, boolean recordCodes) { super(pq); this.compressedDataChunks = compressedDataChunks; this.vectorCount = vectorCount; this.vectorsPerChunk = vectorsPerChunk; this.codebookPartialSumsMap = new ConcurrentHashMap<>(); + if (recordCodes) { + for (int i = 0; i < vectorCount; i++) pq.recordCode(getChunk(i), getOffsetInChunk(i)); + } } @Override diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/MutablePQVectors.java b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/MutablePQVectors.java index b47d2b9ce..c4ceb75d4 100644 --- a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/MutablePQVectors.java +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/MutablePQVectors.java @@ -63,6 +63,8 @@ public void setZero(int ordinal) { // increase count first so get() works vectorCount.updateAndGet(current -> max(current, ordinal + 1)); get(ordinal).zero(); + // A zero code reconstructs to each subspace's first centroid, which searches can reach. + pq.recordCode(get(ordinal), 0); } private synchronized void ensureChunkCapacity(int ordinal) { diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/PQDecoder.java b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/PQDecoder.java index ced0124fd..73cf40c28 100644 --- a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/PQDecoder.java +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/PQDecoder.java @@ -59,8 +59,22 @@ protected float decodedSimilarity(ByteSequence encoded, int offset, int lengt } static class DotProductDecoder extends CachingDecoder { + private final VectorFloat query; + public DotProductDecoder(PQVectors cv, VectorFloat query) { super(cv, query, VectorSimilarityFunction.DOT_PRODUCT); + this.query = query; + } + + /** PQ scores the centered query against centered reconstructions, so their lengths bound the score. */ + @Override + public float dotProductBound() { + var center = cv.pq.globalCentroid; + double squared = VectorUtil.dotProduct(query, query); + if (center != null) { + squared += VectorUtil.dotProduct(center, center) - 2.0 * VectorUtil.dotProduct(query, center); + } + return (float) Math.sqrt(Math.max(0.0, squared)) * cv.maxReconstructedNorm(); } @Override diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/PQVectors.java b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/PQVectors.java index 5e5f172dc..0581d77cc 100644 --- a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/PQVectors.java +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/PQVectors.java @@ -52,6 +52,15 @@ protected PQVectors(ProductQuantization pq) { this.pq = pq; } + /** + * The largest length of any stored vector's reconstruction, relative to the global centroid if any, or more: + * the PQ model's {@link ProductQuantization#maxEncodedNorm}, which counts every code it encoded or holds. With the + * centered query's length it bounds every dot product PQ scores. NaN when unknown. + */ + public float maxReconstructedNorm() { + return pq.maxEncodedNorm(); + } + public static ImmutablePQVectors load(RandomAccessReader in) throws IOException { // pq codebooks var pq = ProductQuantization.load(in); @@ -72,7 +81,7 @@ public static ImmutablePQVectors load(RandomAccessReader in) throws IOException chunks[layout.fullSizeChunks] = vectorTypeSupport.readByteSequence(in, layout.lastChunkBytes); } - return new ImmutablePQVectors(pq, chunks, vectorCount, layout.fullChunkVectors); + return new ImmutablePQVectors(pq, chunks, vectorCount, layout.fullChunkVectors, false); } public static PQVectors load(RandomAccessReader in, long offset) throws IOException { @@ -146,7 +155,7 @@ private static ImmutablePQVectors encodeAndBuild(ProductQuantization pq, int vec else VectorAccess.forEach(ravv, vectorCount, ordinalsMapping, simdExecutor, encode); - return new ImmutablePQVectors(pq, chunks, vectorCount, layout.fullChunkVectors); + return new ImmutablePQVectors(pq, chunks, vectorCount, layout.fullChunkVectors, false); } @Override 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 ba96d8f2f..bc80c310f 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 @@ -72,6 +72,11 @@ public class ProductQuantization implements VectorCompressor>, A final int[][] subvectorSizesAndOffsets; final float anisotropicThreshold; // parallel cost multiplier private final float[][] centroidNormsSquared; // precomputed norms of the centroids, for encoding + // The largest length of any reconstruction this model has encoded or holds codes for. Unknown for a model loaded + // from a format that did not store it, since it cannot know the lengths of the codes encoded before. + private final io.github.jbellis.jvector.util.MaxVectorLength maxEncoded = new io.github.jbellis.jvector.util.MaxVectorLength(); + /** The first on-disk format version that stores {@link #maxEncodedNorm}. */ + static final int MAX_NORM_VERSION = 7; private final ThreadLocal> partialSums; // for dot product, euclidean, and cosine partials private final AtomicReference> partialSquaredMagnitudes; // for cosine partials @@ -439,6 +444,31 @@ public void encodeTo(VectorFloat vector, ByteSequence dest) { encodeAnisotropic(vector, dest); else encodeUnweighted(vector, dest); + recordCode(dest, 0); + } + + /** The squared length of the reconstruction of the code at {@code offset}, relative to the global centroid. */ + float reconstructedSquaredNorm(ByteSequence code, int offset) { + float sum = 0; + for (int m = 0; m < M; m++) sum += centroidNormsSquared[m][Byte.toUnsignedInt(code.get(offset + m))]; + return sum; + } + + /** Counts the code at {@code offset} in {@link #maxEncodedNorm}. */ + void recordCode(ByteSequence code, int offset) { + maxEncoded.recordSquared(reconstructedSquaredNorm(code, offset)); + } + + /** + * The largest length of the reconstruction, relative to the global centroid if any, of any vector this model has + * encoded or holds codes for, including vectors encoded before it was saved and loaded. With the centered query's + * length it bounds every dot product PQ scores, which adaptive stopping needs to read PQ scores as distances. + * NaN until the model encodes or holds a code; positive infinity when unknown, for a model loaded from a format + * older than version {@value #MAX_NORM_VERSION}, which did not store it. In both cases dot-product searches stop + * by the standard rule. + */ + public float maxEncodedNorm() { + return maxEncoded.get(); } /** @@ -608,6 +638,10 @@ public void write(IndexWriter out, int version) throws IOException assert codebook.length() == clusterCount * subvectorSizesAndOffsets[i][0]; vectorTypeSupport.writeFloatVector(out, codebook); } + + if (version >= MAX_NORM_VERSION) { + out.writeFloat(maxEncodedNorm()); + } } /** @@ -641,7 +675,12 @@ public VectorFloat createCodebookPartialSums(VectorSimilarityFunction vectorS @Override public int compressorSize() { - int size = 0; + return compressorSize(OnDiskGraphIndex.CURRENT_VERSION); + } + + /** The bytes {@link #write} writes at {@code version}, which must be 3 or later. */ + public int compressorSize(int version) { + int size = version >= MAX_NORM_VERSION ? Float.BYTES : 0; // largest encoded length size += Integer.BYTES; // MAGIC size += Integer.BYTES; // STORAGE_VERSION size += Integer.BYTES; // globalCentroidLength @@ -701,7 +740,14 @@ public static ProductQuantization load(RandomAccessReader in) throws IOException codebooks[m] = codebook; } - return new ProductQuantization(codebooks, clusters, subvectorSizes, globalCentroid, anisotropicThreshold); + var pq = new ProductQuantization(codebooks, clusters, subvectorSizes, globalCentroid, anisotropicThreshold); + if (version >= MAX_NORM_VERSION) { + pq.maxEncoded.restore(in.readFloat()); + } else { + // Older formats did not store the largest length, so codes encoded with this model have unknown lengths. + pq.maxEncoded.markUnknown(); + } + return pq; } @Override diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/util/MaxVectorLength.java b/jvector-base/src/main/java/io/github/jbellis/jvector/util/MaxVectorLength.java new file mode 100644 index 000000000..228564e58 --- /dev/null +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/util/MaxVectorLength.java @@ -0,0 +1,74 @@ +/* + * 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.util; + +import io.github.jbellis.jvector.vector.VectorUtil; +import io.github.jbellis.jvector.vector.types.VectorFloat; + +import java.util.concurrent.atomic.AtomicInteger; + +/** + * The largest length of a set of vectors, recorded as they are added, from many threads at once. Dot-product searches + * that stop adaptively bound their scores with it. It is in one of three states: + * + *

      + *
    • none recorded yet: {@link #get} is NaN, and the first recorded length sets it;
    • + *
    • known: {@link #get} is the largest length recorded, and longer lengths raise it;
    • + *
    • unknown: {@link #get} is positive infinity, and it stays so. Use it when vectors exist whose lengths were not + * recorded, such as vectors encoded by a model loaded from a format that did not store their largest length; a + * length recorded later could not bound them.
    • + *
    + * + *

    {@link #get} and {@link #restore} round-trip all three states through one float, for storage. + */ +public final class MaxVectorLength { + private static final int NONE = -1; + private static final int UNKNOWN = Float.floatToIntBits(Float.POSITIVE_INFINITY); + + // The largest squared length as float bits: nonnegative floats order like their bits, NONE orders below every + // length and UNKNOWN above, so one compare-and-set loop keeps the maximum in every state. + private final AtomicInteger squaredBits = new AtomicInteger(NONE); + + /** Records a vector's length. */ + public void record(VectorFloat vector) { + recordSquared(VectorUtil.dotProduct(vector, vector)); + } + + /** Records a squared length; a NaN length makes the largest length unknown. */ + public void recordSquared(float squaredLength) { + int bits = Float.isNaN(squaredLength) ? UNKNOWN : Float.floatToIntBits(Math.max(0f, squaredLength)); + int current; + while (bits > (current = squaredBits.get())) { + if (squaredBits.compareAndSet(current, bits)) return; + } + } + + /** Makes the largest length unknown for good. */ + public void markUnknown() { + squaredBits.set(UNKNOWN); + } + + /** The largest length recorded: NaN if none was, positive infinity if unknown. */ + public float get() { + int bits = squaredBits.get(); + return bits == NONE ? Float.NaN : (float) Math.sqrt(Float.intBitsToFloat(bits)); + } + + /** Sets the state from a value {@link #get} returned, as when loading a saved one. */ + public void restore(float stored) { + squaredBits.set(Float.isNaN(stored) ? NONE : Float.floatToIntBits(Math.abs(stored) * Math.abs(stored))); + } +} diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/vector/VectorUtilSupport.java b/jvector-base/src/main/java/io/github/jbellis/jvector/vector/VectorUtilSupport.java index 570639f92..d3c6ec76c 100644 --- a/jvector-base/src/main/java/io/github/jbellis/jvector/vector/VectorUtilSupport.java +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/vector/VectorUtilSupport.java @@ -176,7 +176,7 @@ default void ashLutScore(byte[] codes, int offset, int groups, int stride, /** * Scores one complete C=1 FusedASH neighborhood, including all serialized blocks and headers. - * The result is {@code max(0, (1 + rawDotProduct) / 2)}, matching graph DOT_PRODUCT similarity. + * The result is {@code (1 + rawDotProduct) / 2}, unclamped, matching graph DOT_PRODUCT similarity. * *

    Inputs are read-only for the call; output storage must be distinct from {@code lut}. * Calls may share immutable inputs across threads when their output arrays are separate. @@ -207,7 +207,7 @@ default void ashScoreNeighborhood(byte[] codes, int groups, int blockSize, int c * INT8 query. Each block's body is {@code [unit][lane][bits bytes]} for {@code units} units of * eight dimensions, followed by the usual headers. Per lane, * {@code dot = ( - center) * invScale}, and the result is - * {@code max(0, (1 + scale * dot + landmarkDot + offset) / 2)}. + * {@code (1 + scale * dot + landmarkDot + offset) / 2}, unclamped. * * @param codes complete packed neighborhood, with full serialized storage for its final block * @param bits bits per dimension: 3, 5, 6, 7, 8 or 9 diff --git a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/ASHNeighborhoodFixture.java b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/ASHNeighborhoodFixture.java index bc39af030..2198640b0 100644 --- a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/ASHNeighborhoodFixture.java +++ b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/ASHNeighborhoodFixture.java @@ -274,9 +274,9 @@ static ASHNeighborhoodFixture read(Path path) throws IOException { } private static void validateModelEnvelope(byte[] model) throws IOException { - // Fixture V1 embeds ASH's eight-int prefix, one landmark, and its row-major transform. - // Check declared dimensions against the bytes before ASH.load allocates model arrays. - int fixedModelBytes = 11 * Integer.BYTES; + // Fixture V1 embeds ASH's eight-int prefix, one landmark, its row-major transform, and the largest + // encoded vector length. Check declared dimensions against the bytes before ASH.load allocates model arrays. + int fixedModelBytes = 11 * Integer.BYTES + Float.BYTES; if (model.length < fixedModelBytes) { throw new IOException("Truncated fixture model"); } diff --git a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/DistancesASH.java b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/DistancesASH.java index 3eaf58304..3c9cad4b4 100644 --- a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/DistancesASH.java +++ b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/DistancesASH.java @@ -243,8 +243,9 @@ private static double decodedSymmetric(AsymmetricHashing ash, for (int j = 0; j < ash.originalDimension; j++) { centroidDot += (double) ash.landmarks[ca].get(j) * ash.landmarks[cb].get(j); } - return Math.max(0, (1 + (double) a.scale * b.scale * dot + a.scale * crossA - + b.scale * crossB + correctionA + correctionB + centroidDot) / 2); + // Graph DOT_PRODUCT similarity, unclamped as the library scores it. + return (1 + (double) a.scale * b.scale * dot + a.scale * crossA + + b.scale * crossB + correctionA + correctionB + centroidDot) / 2; } private static final class ScanScorer { diff --git a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/Grid.java b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/Grid.java index a5e9c00eb..e0f494302 100644 --- a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/Grid.java +++ b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/Grid.java @@ -47,6 +47,7 @@ import io.github.jbellis.jvector.graph.disk.feature.NVQ; import io.github.jbellis.jvector.quantization.NVQVectors; import io.github.jbellis.jvector.quantization.ProductQuantization; +import io.github.jbellis.jvector.vector.VectorSimilarityFunction; import io.github.jbellis.jvector.vector.VectorizationProvider; import io.github.jbellis.jvector.quantization.VectorCompressor; import io.github.jbellis.jvector.util.ExplicitThreadLocal; @@ -849,7 +850,14 @@ public synchronized void configureQueryExecution(String mode, int batchSize, Int private final ExplicitThreadLocal searchers = ExplicitThreadLocal.withInitial(() -> { - return new GraphSearcher(index); + var searcher = new GraphSearcher(index); + // Benchmark knobs: jvector.bench.adaptive=false turns adaptive stopping off; jvector.bench.adaptiveGamma + // sets its margin. + String gamma = System.getProperty("jvector.bench.adaptiveGamma"); + boolean adaptive = Boolean.parseBoolean(System.getProperty("jvector.bench.adaptive", "true")); + if (gamma != null) searcher.useAdaptiveTermination(adaptive, Float.parseFloat(gamma)); + else searcher.useAdaptiveTermination(adaptive); + return searcher; }); ConfiguredSystem(DataSet ds, ImmutableGraphIndex index, CompressedVectors cv, Set features) { @@ -869,6 +877,11 @@ public synchronized void configureQueryExecution(String mode, int batchSize, Int this.exactVectors = ds.getBaseRavv().threadLocalSupplier(); } + /** Top-K searches stop adaptively, reading scores as distances under the dataset's similarity function. */ + private VectorSimilarityFunction adaptiveSimilarity() { + return ds.getSimilarityFunction(); + } + /** Metadata from the same ASH model used for query scoring. */ public AsymmetricHashing ashQueryProjectionModel() { if (features.contains(FeatureId.FUSED_ASH) && index instanceof OnDiskGraphIndex) { @@ -904,7 +917,7 @@ public SearchScoreProvider scoreProviderFor(VectorFloat queryVector, Immutabl asf = cv.precomputedScoreFunctionFor(queryVector, ds.getSimilarityFunction()); } var rr = scoringView.rerankerFor(queryVector, ds.getSimilarityFunction()); - return new DefaultSearchScoreProvider(asf, rr, features.contains(FeatureId.FUSED_ASH) || cv instanceof ASHVectors); + return new DefaultSearchScoreProvider(asf, rr, adaptiveSimilarity()); } private SearchScoreProvider configuredScorer(VectorFloat query, ImmutableGraphIndex.View view) { @@ -933,7 +946,7 @@ private SearchScoreProvider configuredScorer(VectorFloat query, ImmutableGrap reranker = nvq.rerankerFor(query, similarity, diskView); } } - return new DefaultSearchScoreProvider(score, reranker, features.contains(FeatureId.FUSED_ASH) || cv instanceof ASHVectors); + return new DefaultSearchScoreProvider(score, reranker, adaptiveSimilarity()); } public GraphSearcher getSearcher() { diff --git a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/IPCService.java b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/IPCService.java index 599369ddb..c0eb3ab7b 100644 --- a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/IPCService.java +++ b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/IPCService.java @@ -275,7 +275,7 @@ String search(String input, SessionContext ctx) { var rr = view instanceof ImmutableGraphIndex.ScoringView ? ((ImmutableGraphIndex.ScoringView) view).rerankerFor(queryVector, ctx.similarityFunction) : ctx.ravv.rerankerFor(queryVector, ctx.similarityFunction); - var ssp = new DefaultSearchScoreProvider(sf, rr, ctx.cv instanceof io.github.jbellis.jvector.quantization.ASHVectors); + var ssp = DefaultSearchScoreProvider.forSimilarity(sf, rr, ctx.similarityFunction); r = new GraphSearcher(ctx.index).search(ssp, searchEf, Bits.ALL); } catch (Exception e) { throw new RuntimeException(e); diff --git a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/diagnostics/ASHBuildScoreExperiment.java b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/diagnostics/ASHBuildScoreExperiment.java index e831b544e..40c6a02d1 100644 --- a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/diagnostics/ASHBuildScoreExperiment.java +++ b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/diagnostics/ASHBuildScoreExperiment.java @@ -51,10 +51,10 @@ public static BuildScoreProvider create(VectorSimilarityFunction vsf, ASHVectors public boolean isExact() { return false; } public VectorFloat approximateCentroid() { return encoded.getCompressor().landmarks[0].copy(); } public SearchScoreProvider searchProviderFor(VectorFloat x) { - return new DefaultSearchScoreProvider(encoded.precomputedScoreFunctionFor(x,vsf),null,true); + return new DefaultSearchScoreProvider(encoded.precomputedScoreFunctionFor(x,vsf),null,io.github.jbellis.jvector.vector.VectorSimilarityFunction.DOT_PRODUCT); } public SearchScoreProvider searchProviderFor(int node) { - return new DefaultSearchScoreProvider(symmetric.scoreFunctionFor(node),null,true); + return new DefaultSearchScoreProvider(symmetric.scoreFunctionFor(node),null,io.github.jbellis.jvector.vector.VectorSimilarityFunction.DOT_PRODUCT); } public SearchScoreProvider diversityProviderFor(int node) { return searchProviderFor(node); } }; @@ -68,7 +68,7 @@ public SearchScoreProvider searchProviderFor(int node) { io.github.jbellis.jvector.util.PhysicalCoreExecutor.pool().submit(() -> java.util.stream.IntStream.range(0, cached.length).parallel().forEach(node -> cached[node] = new DefaultSearchScoreProvider( - encoded.precomputedScoreFunctionFor(views.get().getVector(node), vsf), null, true))) + encoded.precomputedScoreFunctionFor(views.get().getVector(node), vsf), null, io.github.jbellis.jvector.vector.VectorSimilarityFunction.DOT_PRODUCT))) .join(); long landmarks = encoded.getCompressor().landmarkCount; long floats = (long) cached.length * ((1L + landmarks) * encoded.getCompressor().quantizedDim + 2 * landmarks); @@ -81,7 +81,7 @@ public SearchScoreProvider searchProviderFor(int node) { public boolean isExact() { return false; } public VectorFloat approximateCentroid() { return encoded.getCompressor().landmarks[0].copy(); } public SearchScoreProvider searchProviderFor(VectorFloat x) { - return new DefaultSearchScoreProvider(encoded.precomputedScoreFunctionFor(x,vsf),null,true); + return new DefaultSearchScoreProvider(encoded.precomputedScoreFunctionFor(x,vsf),null,io.github.jbellis.jvector.vector.VectorSimilarityFunction.DOT_PRODUCT); } public SearchScoreProvider searchProviderFor(int node) { return cached == null ? searchProviderFor(views.get().getVector(node)) : cached[node]; } public SearchScoreProvider diversityProviderFor(int node) { return searchProviderFor(node); } diff --git a/jvector-tests/src/test/java/io/github/jbellis/jvector/graph/AdaptiveTerminationTest.java b/jvector-tests/src/test/java/io/github/jbellis/jvector/graph/AdaptiveTerminationTest.java index e35ce8b74..3bcf52013 100644 --- a/jvector-tests/src/test/java/io/github/jbellis/jvector/graph/AdaptiveTerminationTest.java +++ b/jvector-tests/src/test/java/io/github/jbellis/jvector/graph/AdaptiveTerminationTest.java @@ -16,54 +16,174 @@ package io.github.jbellis.jvector.graph; import io.github.jbellis.jvector.util.BoundedLongHeap; +import io.github.jbellis.jvector.vector.VectorSimilarityFunction; import org.junit.jupiter.api.Test; + +import java.util.Random; + +import static io.github.jbellis.jvector.graph.AdaptiveTermination.Decision.CONTINUE; +import static io.github.jbellis.jvector.graph.AdaptiveTermination.Decision.FALL_BACK_TO_STANDARD; +import static io.github.jbellis.jvector.graph.AdaptiveTermination.Decision.TERMINATE; +import static io.github.jbellis.jvector.vector.VectorSimilarityFunction.COSINE; +import static io.github.jbellis.jvector.vector.VectorSimilarityFunction.DOT_PRODUCT; +import static io.github.jbellis.jvector.vector.VectorSimilarityFunction.EUCLIDEAN; import static org.junit.jupiter.api.Assertions.*; class AdaptiveTerminationTest { + /** The gamma whose squared-distance factor (1 + gamma)^2 is {@code factor}. */ + private static float gammaFor(double factor) { + return (float) (Math.sqrt(factor) - 1); + } + private static NodeQueue results(float... scores) { NodeQueue q = new NodeQueue(new BoundedLongHeap(scores.length), NodeQueue.Order.MIN_HEAP); for (int i = 0; i < scores.length; i++) q.push(i, scores[i]); return q; } + private static AdaptiveTermination rule(int k, double factor, VectorSimilarityFunction similarity, float bound) { + var t = new AdaptiveTermination(); + assertTrue(t.reset(k, gammaFor(factor), similarity, bound)); + return t; + } + @Test void requiresKResults() { - var termination = new AdaptiveTermination(); - termination.reset(2, 0.1f); - assertEquals(AdaptiveTermination.Decision.CONTINUE, - termination.shouldTerminate(0.1f, results(0.9f))); + var t = rule(2, 1.1, COSINE, Float.NaN); + assertEquals(CONTINUE, t.shouldTerminate(0.1f, results(0.9f))); } - @Test void usesDotProductDistanceMargin() { - var termination = new AdaptiveTermination(); - termination.reset(1, 0.1f); + @Test void cosineReadsOneMinusScore() { + var t = rule(1, 1.1, COSINE, Float.NaN); NodeQueue q = results(0.9f); - assertEquals(AdaptiveTermination.Decision.CONTINUE, termination.shouldTerminate(0.89f, q)); - assertEquals(AdaptiveTermination.Decision.TERMINATE, termination.shouldTerminate(0.88f, q)); + assertEquals(CONTINUE, t.shouldTerminate(0.89f, q)); + assertEquals(TERMINATE, t.shouldTerminate(0.88f, q)); + } + + @Test void euclideanReadsSquaredDistance() { + // Score 0.5 is d^2 = 1; with factor 1.1 the cutoff is d^2 = 1.1, score 1 / 2.1 = 0.476. + var t = rule(1, 1.1, EUCLIDEAN, Float.NaN); + NodeQueue q = results(0.5f); + assertEquals(CONTINUE, t.shouldTerminate(0.48f, q)); + assertEquals(TERMINATE, t.shouldTerminate(0.47f, q)); } - @Test void invalidApproximateScoresFallBack() { - var termination = new AdaptiveTermination(); - termination.reset(1); - assertEquals(AdaptiveTermination.Decision.FALL_BACK_TO_STANDARD, - termination.shouldTerminate(1.1f, results(0.9f))); - assertEquals(AdaptiveTermination.Decision.FALL_BACK_TO_STANDARD, - termination.shouldTerminate(0.1f, results(0.9f))); + @Test void dotProductReadsDistanceFromTheBound() { + // Bound 9: top = 5. The k-th dot product 7 scores 4, distance 1; factor 1.1 puts the cutoff at score 3.9. + var t = rule(1, 1.1, DOT_PRODUCT, 9f); + NodeQueue q = results(4f); + assertEquals(CONTINUE, t.shouldTerminate(3.95f, q)); + assertEquals(TERMINATE, t.shouldTerminate(3.85f, q)); } - @Test void resetClearsFallbackState() { - var termination = new AdaptiveTermination(); - termination.reset(1); - termination.shouldTerminate(Float.NaN, results(0.9f)); - termination.reset(1, 0.1f); - assertEquals(AdaptiveTermination.Decision.TERMINATE, - termination.shouldTerminate(0.88f, results(0.9f))); + @Test void dotProductDecisionsDoNotDependOnScale() { + var random = new Random(7); + for (int i = 0; i < 10_000; i++) { + float bound = 1 + 9 * random.nextFloat(); + float kthDot = bound * (2 * random.nextFloat() - 1); + float candidateDot = kthDot - bound * random.nextFloat(); + double margin = Math.abs((bound - candidateDot) - 1.02 * (bound - kthDot)) / bound; + if (margin < 1e-4) continue; // too close to the cutoff for float scores at every scale + AdaptiveTermination.Decision reference = null; + for (float scale : new float[] {0.01f, 1f, 100f}) { + var t = rule(1, 1.02, DOT_PRODUCT, bound * scale); + var decision = t.shouldTerminate((1 + candidateDot * scale) / 2, results((1 + kthDot * scale) / 2)); + if (reference == null) reference = decision; + assertEquals(reference, decision, "scale " + scale + " bound " + bound + " kth " + kthDot + " candidate " + candidateDot); + } + } } - @Test void rawDotProductBuildScoresUseTheSameDistanceRule() { + + @Test void matchesThePreviousRuleOnUnitLengthScores() { + // The rule this replaced read 1 - score as the distance and compared it against (1 + 0.01) times the k-th's. + var random = new Random(11); + for (var similarity : new VectorSimilarityFunction[] {COSINE, DOT_PRODUCT}) { + var t = new AdaptiveTermination(); + assertTrue(t.reset(1, AdaptiveTermination.DEFAULT_GAMMA, similarity, 1f)); + for (int i = 0; i < 100_000; i++) { + float kth = random.nextFloat(), candidate = kth * random.nextFloat(); + double previous = (1 - candidate) - (1.01 * (1 - kth) + 1e-6); + if (Math.abs(previous) < 1e-5) continue; + assertEquals(previous > 0 ? TERMINATE : CONTINUE, t.shouldTerminate(candidate, results(kth)), + similarity + " kth " + kth + " candidate " + candidate); + } + } + } + + @Test void scoresAboveTheBoundRaiseIt() { + var t = rule(1, 1.1, DOT_PRODUCT, 1f); + NodeQueue q = results(0.9f); + assertEquals(TERMINATE, t.shouldTerminate(0.88f, q)); + // An approximate score of 1.2 moves the zero point up, which loosens the margin: 0.32 <= 1.1 * 0.3. + t.observe(1.2f); + t.observe(1.1f); + assertEquals(CONTINUE, t.shouldTerminate(0.88f, q)); + assertEquals(TERMINATE, t.shouldTerminate(0.85f, q)); + } + + @Test void theCapStopsCandidatesTheMarginWouldContinue() { + // A wide margin continues 0.5 against a k-th best of 0.9, until the cap rises above it. + var t = rule(1, 4.0, COSINE, Float.NaN); + NodeQueue q = results(0.9f); + assertEquals(CONTINUE, t.shouldTerminate(0.7f, q)); + t.raiseCap(0.8f); + assertEquals(TERMINATE, t.shouldTerminate(0.7f, q)); + assertEquals(CONTINUE, t.shouldTerminate(0.85f, q)); + assertTrue(t.lowestContinuableScore(0.9f) >= 0.8f); + // The cap only rises. + t.raiseCap(0.5f); + assertEquals(TERMINATE, t.shouldTerminate(0.7f, q)); + } + + @Test void theBarNeverFallsDuringASearch() { + var t = rule(1, 1.1, DOT_PRODUCT, 1f); + float before = t.lowestContinuableScore(0.9f); + // An approximate score above the bound raises the zero point, which would lower a recomputed bar. + t.observe(1.5f); + assertTrue(t.lowestContinuableScore(0.9f) >= before); + assertEquals(TERMINATE, t.shouldTerminate(Math.nextDown(before), results(0.9f))); + } + + @Test void dotProductWithoutABoundUsesTheStandardRule() { var t = new AdaptiveTermination(); - t.reset(1, 0.1f, true); - assertEquals(AdaptiveTermination.Decision.CONTINUE, t.shouldTerminate(0.78f, results(0.8f))); - assertEquals(AdaptiveTermination.Decision.TERMINATE, t.shouldTerminate(0.76f, results(0.8f))); - assertEquals(AdaptiveTermination.Decision.FALL_BACK_TO_STANDARD, t.shouldTerminate(-1.1f, results(0.8f))); + assertFalse(t.reset(1, 0.01f, DOT_PRODUCT, Float.NaN)); + assertEquals(FALL_BACK_TO_STANDARD, t.shouldTerminate(0.1f, results(0.9f))); + assertEquals(Float.NEGATIVE_INFINITY, t.lowestContinuableScore(0.9f)); + assertFalse(t.reset(1, 0.01f, DOT_PRODUCT, Float.POSITIVE_INFINITY)); + assertFalse(t.reset(1, 0.01f, DOT_PRODUCT, -1f)); } + @Test void nonFiniteScoresFallBackUntilReset() { + var t = rule(1, 1.1, COSINE, Float.NaN); + assertEquals(FALL_BACK_TO_STANDARD, t.shouldTerminate(Float.NaN, results(0.9f))); + assertEquals(FALL_BACK_TO_STANDARD, t.shouldTerminate(0.1f, results(0.9f))); + assertTrue(t.reset(1, gammaFor(1.1), COSINE, Float.NaN)); + assertEquals(TERMINATE, t.shouldTerminate(0.88f, results(0.9f))); + assertEquals(CONTINUE, t.shouldTerminate(Float.POSITIVE_INFINITY, results(0.9f))); + assertEquals(TERMINATE, t.shouldTerminate(Float.NEGATIVE_INFINITY, results(0.9f))); + } + + @Test void theExpansionBarNeverExcludesAContinuedCandidate() { + var random = new Random(5); + for (var similarity : VectorSimilarityFunction.values()) { + for (int i = 0; i < 20_000; i++) { + float bound = 10 * random.nextFloat(); + var t = new AdaptiveTermination(); + assertTrue(t.reset(1, 0.05f * random.nextFloat(), similarity, bound)); + float kth, candidate; + if (similarity == EUCLIDEAN) { + kth = random.nextFloat(); + candidate = kth * random.nextFloat(); + } else { + float top = (1 + bound) / 2; + kth = top - top * random.nextFloat(); + candidate = kth - top * random.nextFloat(); + if (random.nextInt(4) == 0) t.observe(top + random.nextFloat()); + } + float bar = t.lowestContinuableScore(kth); + if (t.shouldTerminate(candidate, results(kth)) == CONTINUE) { + assertTrue(candidate >= bar, similarity + " kth " + kth + " candidate " + candidate + " bar " + bar); + } + } + } + } } diff --git a/jvector-tests/src/test/java/io/github/jbellis/jvector/graph/TestAdaptiveStoppingBounds.java b/jvector-tests/src/test/java/io/github/jbellis/jvector/graph/TestAdaptiveStoppingBounds.java new file mode 100644 index 000000000..7b489ac0a --- /dev/null +++ b/jvector-tests/src/test/java/io/github/jbellis/jvector/graph/TestAdaptiveStoppingBounds.java @@ -0,0 +1,86 @@ +/* + * 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.graph.similarity.DefaultSearchScoreProvider; +import io.github.jbellis.jvector.util.Bits; +import io.github.jbellis.jvector.vector.VectorSimilarityFunction; +import io.github.jbellis.jvector.vector.VectorUtil; +import io.github.jbellis.jvector.vector.types.VectorFloat; +import org.junit.Test; + +import java.util.ArrayList; +import java.util.List; +import java.util.Random; + +import static org.junit.Assert.assertTrue; + +/** + * Adaptive stopping only ever extends a search: it expands at least the nodes the standard rule would, and, capped, + * at most the nodes a standard search with twice the beam would, whatever the margin, the data and the similarity. + */ +public class TestAdaptiveStoppingBounds { + private static final int N = 2000, DIM = 32, QUERIES = 30; + + @Test + public void adaptiveStoppingStaysBetweenTheStandardRuleAndTwiceItsBeam() throws Exception { + var random = new Random(31); + for (boolean unitLength : new boolean[] {true, false}) { + List> base = new ArrayList<>(); + float maxNorm = 0; + for (int i = 0; i < N; i++) { + var v = TestUtil.randomVector(random, DIM); + VectorUtil.l2normalize(v); + if (!unitLength) VectorUtil.scale(v, (float) Math.pow(10, -1 + 2 * random.nextDouble())); + base.add(v); + maxNorm = Math.max(maxNorm, (float) Math.sqrt(VectorUtil.dotProduct(v, v))); + } + var vectors = new ListRandomAccessVectorValues(base, DIM); + for (var similarity : VectorSimilarityFunction.values()) { + if (similarity == VectorSimilarityFunction.COSINE && !unitLength) continue; + ImmutableGraphIndex graph; + try (var builder = new GraphIndexBuilder(BuildScoreProvider.randomAccessScoreProvider(vectors, similarity), + DIM, 16, 64, 1.2f, 1.2f, false)) { + graph = builder.build(vectors); + } + try (var standard = new GraphSearcher(graph); var adaptive = new GraphSearcher(graph)) { + standard.useAdaptiveTermination(false); + for (int q = 0; q < QUERIES; q++) { + var query = TestUtil.randomVector(random, DIM); + float bound = (float) Math.sqrt(VectorUtil.dotProduct(query, query)) * maxNorm; + var sf = DefaultSearchScoreProvider.exact(query, similarity, vectors).scoreFunction(); + var provider = new DefaultSearchScoreProvider(sf, null, similarity, bound); + for (int k : new int[] {5, 10}) { + int least = standard.search(provider, k, k, 0f, 0f, Bits.ALL).getExpandedCountBaseLayer(); + int most = standard.search(provider, k, 2 * k, 0f, 0f, Bits.ALL).getExpandedCountBaseLayer(); + // The default margin, and one wide enough to search the whole graph without the cap. + for (float gamma : new float[] {0.005f, 1f}) { + adaptive.useAdaptiveTermination(true, gamma); + int expanded = adaptive.search(provider, k, k, 0f, 0f, Bits.ALL).getExpandedCountBaseLayer(); + String where = similarity + (unitLength ? " unit" : " mixed") + " query " + q + " k " + k + + " gamma " + gamma + ": expanded " + expanded; + assertTrue(where + " < standard " + least, expanded >= least); + assertTrue(where + " > standard at twice the beam " + most, expanded <= most); + } + } + } + } + } + } + } +} diff --git a/jvector-tests/src/test/java/io/github/jbellis/jvector/graph/TestBoundedCandidates.java b/jvector-tests/src/test/java/io/github/jbellis/jvector/graph/TestBoundedCandidates.java index fb4d1f605..75b348301 100644 --- a/jvector-tests/src/test/java/io/github/jbellis/jvector/graph/TestBoundedCandidates.java +++ b/jvector-tests/src/test/java/io/github/jbellis/jvector/graph/TestBoundedCandidates.java @@ -75,7 +75,7 @@ public void boundedSearchesMatchUnboundedOnes() throws IOException { private static DefaultSearchScoreProvider provider(VectorFloat query, VectorSimilarityFunction similarity, RandomAccessVectorValues vectors, boolean adaptive) { var exact = DefaultSearchScoreProvider.exact(query, similarity, vectors); - return new DefaultSearchScoreProvider(exact.scoreFunction(), null, adaptive); + return new DefaultSearchScoreProvider(exact.scoreFunction(), null, adaptive ? similarity : null); } private static Bits randomFilter(Random random, int count, double density) { diff --git a/jvector-tests/src/test/java/io/github/jbellis/jvector/graph/disk/TestFeatureHeaderVersion.java b/jvector-tests/src/test/java/io/github/jbellis/jvector/graph/disk/TestFeatureHeaderVersion.java index 5b1f24781..e163e9e20 100644 --- a/jvector-tests/src/test/java/io/github/jbellis/jvector/graph/disk/TestFeatureHeaderVersion.java +++ b/jvector-tests/src/test/java/io/github/jbellis/jvector/graph/disk/TestFeatureHeaderVersion.java @@ -26,6 +26,8 @@ import io.github.jbellis.jvector.graph.disk.feature.FeatureId; import io.github.jbellis.jvector.graph.disk.feature.FusedASH; import io.github.jbellis.jvector.graph.disk.feature.FusedPQ; +import io.github.jbellis.jvector.graph.disk.feature.SeparatedVectors; +import io.github.jbellis.jvector.graph.disk.feature.InlineVectors; import io.github.jbellis.jvector.graph.disk.feature.NVQ; import io.github.jbellis.jvector.graph.disk.feature.SeparatedNVQ; import io.github.jbellis.jvector.quantization.AsymmetricHashing; @@ -40,6 +42,7 @@ import java.util.List; import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; /** * A feature header embedded in a graph serializes its compressor at the graph's version, not at @@ -75,6 +78,14 @@ public void featureHeadersAreWrittenAtTheGraphVersion() throws IOException { new Case(FeatureId.FUSED_ASH, fusedAsh, (out, v) -> { ash.write(out, v); out.writeInt(blockSize); + }), + // Full-precision vectors store their largest length from version 7: unknown until any are written. + new Case(FeatureId.INLINE_VECTORS, new InlineVectors(DIMENSION), (out, v) -> { + if (v >= 7) out.writeFloat(Float.NaN); + }), + new Case(FeatureId.SEPARATED_VECTORS, new SeparatedVectors(DIMENSION, 4321L), (out, v) -> { + out.writeLong(4321L); + if (v >= 7) out.writeFloat(Float.NaN); })); for (int version = 2; version <= OnDiskGraphIndex.CURRENT_VERSION; version++) { @@ -89,6 +100,7 @@ public void featureHeadersAreWrittenAtTheGraphVersion() throws IOException { // The format's own framing (feature ids, counts) precedes the header. byte[] header = Arrays.copyOfRange(written, written.length - expected.length, written.length); assertArrayEquals(c.id + " at version " + version, expected, header); + assertEquals(c.id + " header size at version " + version, expected.length, c.feature.headerSize(version)); } } } diff --git a/jvector-tests/src/test/java/io/github/jbellis/jvector/graph/disk/TestOnDiskGraphIndexCompactor.java b/jvector-tests/src/test/java/io/github/jbellis/jvector/graph/disk/TestOnDiskGraphIndexCompactor.java index 40be0aecf..be07207a2 100644 --- a/jvector-tests/src/test/java/io/github/jbellis/jvector/graph/disk/TestOnDiskGraphIndexCompactor.java +++ b/jvector-tests/src/test/java/io/github/jbellis/jvector/graph/disk/TestOnDiskGraphIndexCompactor.java @@ -277,6 +277,14 @@ public void testExactVectorValuesAfterCompaction() throws Exception { OnDiskGraphIndex compacted = OnDiskGraphIndex.load(rsOut); assertEquals(2 * n, compacted.size(0)); + // The compacted header stores the largest length of the vectors it holds, for adaptive dot-product stopping. + float maxLength = 0; + for (var v : vecs0) maxLength = Math.max(maxLength, (float) Math.sqrt(io.github.jbellis.jvector.vector.VectorUtil.dotProduct(v, v))); + for (var v : vecs1) maxLength = Math.max(maxLength, (float) Math.sqrt(io.github.jbellis.jvector.vector.VectorUtil.dotProduct(v, v))); + var inline = (io.github.jbellis.jvector.graph.disk.feature.InlineVectors) + compacted.getFeatures().get(io.github.jbellis.jvector.graph.disk.feature.FeatureId.INLINE_VECTORS); + assertEquals(maxLength, inline.maxVectorNorm(), maxLength * 1e-6f); + var view = compacted.getView(); VectorFloat buf = vectorTypeSupport.createFloatVector(dim); diff --git a/jvector-tests/src/test/java/io/github/jbellis/jvector/graph/disk/TestOnDiskVectorNorms.java b/jvector-tests/src/test/java/io/github/jbellis/jvector/graph/disk/TestOnDiskVectorNorms.java new file mode 100644 index 000000000..c03c60de0 --- /dev/null +++ b/jvector-tests/src/test/java/io/github/jbellis/jvector/graph/disk/TestOnDiskVectorNorms.java @@ -0,0 +1,96 @@ +/* + * 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.disk; + +import io.github.jbellis.jvector.TestUtil; +import io.github.jbellis.jvector.disk.SimpleMappedReader; +import io.github.jbellis.jvector.graph.ListRandomAccessVectorValues; +import io.github.jbellis.jvector.graph.disk.feature.Feature; +import io.github.jbellis.jvector.graph.disk.feature.FeatureId; +import io.github.jbellis.jvector.graph.disk.feature.InlineVectors; +import io.github.jbellis.jvector.graph.disk.feature.SeparatedVectors; +import io.github.jbellis.jvector.vector.VectorSimilarityFunction; +import io.github.jbellis.jvector.vector.VectorUtil; +import io.github.jbellis.jvector.vector.types.VectorFloat; +import org.junit.Test; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.Random; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +/** + * On-disk graphs store the largest length of their full-precision vectors from format version 7, so dot-product + * searches over them can stop adaptively; older formats leave it unknown, and those searches stop by the standard rule. + */ +public class TestOnDiskVectorNorms { + private static final int N = 300, DIM = 16; + + @Test + public void fullPrecisionHeadersStoreTheLargestLength() throws Exception { + var random = new Random(37); + List> vectors = new ArrayList<>(); + float maxNorm = 0; + for (int i = 0; i < N; i++) { + var v = TestUtil.randomVector(random, DIM); + VectorUtil.scale(v, (float) Math.pow(10, -1 + 2 * random.nextDouble())); + vectors.add(v); + maxNorm = Math.max(maxNorm, (float) Math.sqrt(VectorUtil.dotProduct(v, v))); + } + var ravv = new ListRandomAccessVectorValues(vectors, DIM); + var graph = new TestUtil.RandomlyConnectedGraphIndex(N, 8, random); + var query = TestUtil.randomVector(random, DIM); + float queryNorm = (float) Math.sqrt(VectorUtil.dotProduct(query, query)); + Path directory = Files.createTempDirectory("ondisk-norms"); + + for (int version : new int[] {OnDiskGraphIndex.CURRENT_VERSION, 6}) { + var path = directory.resolve("inline-" + version); + try (var writer = new OnDiskGraphIndexWriter.Builder(graph, path).withVersion(version) + .with(new InlineVectors(DIM)).build()) { + writer.write(Feature.singleStateFactory(FeatureId.INLINE_VECTORS, + node -> new InlineVectors.State(ravv.getVector(node)))); + } + try (var readers = new SimpleMappedReader.Supplier(path); + var index = OnDiskGraphIndex.load(readers); + var view = index.getView()) { + float stored = ((InlineVectors) index.getFeatures().get(FeatureId.INLINE_VECTORS)).maxVectorNorm(); + float bound = view.rerankerFor(query, VectorSimilarityFunction.DOT_PRODUCT).dotProductBound(); + if (version >= 7) { + assertEquals(maxNorm, stored, maxNorm * 1e-6f); + assertEquals(queryNorm * maxNorm, bound, queryNorm * maxNorm * 1e-5f); + } else { + assertTrue("version " + version + ": " + stored, Float.isNaN(stored)); + assertTrue(Float.isNaN(bound)); + } + } + } + + var path = directory.resolve("separated"); + try (var writer = new OnDiskGraphIndexWriter.Builder(graph, path).with(new SeparatedVectors(DIM, 0L)).build()) { + writer.write(Feature.singleStateFactory(FeatureId.SEPARATED_VECTORS, + node -> new InlineVectors.State(ravv.getVector(node)))); + } + try (var readers = new SimpleMappedReader.Supplier(path); + var index = OnDiskGraphIndex.load(readers)) { + float stored = ((SeparatedVectors) index.getFeatures().get(FeatureId.SEPARATED_VECTORS)).maxVectorNorm(); + assertEquals(maxNorm, stored, maxNorm * 1e-6f); + } + } +} diff --git a/jvector-tests/src/test/java/io/github/jbellis/jvector/graph/similarity/TestAdaptiveStoppingDefault.java b/jvector-tests/src/test/java/io/github/jbellis/jvector/graph/similarity/TestAdaptiveStoppingDefault.java new file mode 100644 index 000000000..d9654b188 --- /dev/null +++ b/jvector-tests/src/test/java/io/github/jbellis/jvector/graph/similarity/TestAdaptiveStoppingDefault.java @@ -0,0 +1,120 @@ +/* + * 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.similarity; + +import io.github.jbellis.jvector.TestUtil; +import io.github.jbellis.jvector.disk.SimpleMappedReader; +import io.github.jbellis.jvector.disk.SimpleWriter; +import io.github.jbellis.jvector.graph.ListRandomAccessVectorValues; +import io.github.jbellis.jvector.graph.disk.OnDiskGraphIndex; +import io.github.jbellis.jvector.quantization.AsymmetricHashing; +import io.github.jbellis.jvector.quantization.ProductQuantization; +import io.github.jbellis.jvector.vector.VectorSimilarityFunction; +import io.github.jbellis.jvector.vector.VectorUtil; +import io.github.jbellis.jvector.vector.types.VectorFloat; +import org.junit.Test; + +import java.io.File; +import java.util.ArrayList; +import java.util.List; +import java.util.Random; +import java.util.concurrent.ForkJoinPool; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + +/** + * Top-K searches stop adaptively for every similarity function a provider names, and dot-product searches know an + * upper bound on the dot products they score, whatever the vectors' lengths. + */ +public class TestAdaptiveStoppingDefault { + @Test + public void providersNameTheSimilarityTheirScoresMeasure() { + var random = new Random(3); + var vectors = new ListRandomAccessVectorValues(List.of(TestUtil.randomVector(random, 8), + TestUtil.randomVector(random, 8)), 8); + var query = TestUtil.randomVector(random, 8); + for (var similarity : VectorSimilarityFunction.values()) { + assertEquals(similarity, DefaultSearchScoreProvider.exact(query, similarity, vectors).adaptiveSimilarity()); + var sf = DefaultSearchScoreProvider.exact(query, similarity, vectors).scoreFunction(); + assertEquals(similarity, DefaultSearchScoreProvider.forSimilarity(sf, null, similarity).adaptiveSimilarity()); + // A provider that cannot tell what its scores measure keeps the standard rule. + assertNull(new DefaultSearchScoreProvider(sf).adaptiveSimilarity()); + assertNull(new DefaultSearchScoreProvider(sf, null).adaptiveSimilarity()); + // Graph construction keeps the standard rule for full-precision scores. + assertNull(BuildScoreProvider.randomAccessScoreProvider(vectors, similarity) + .searchProviderFor(query).adaptiveSimilarity()); + } + } + + @Test + public void dotProductBoundsHoldForVectorsOfAnyLength() throws Exception { + var random = new Random(17); + int n = 500, dim = 32; + List> base = new ArrayList<>(); + float maxNorm = 0; + for (int i = 0; i < n; i++) { + var v = TestUtil.randomVector(random, dim); + VectorUtil.scale(v, (float) Math.pow(10, -1 + 2 * random.nextDouble())); + base.add(v); + maxNorm = Math.max(maxNorm, (float) Math.sqrt(VectorUtil.dotProduct(v, v))); + } + var ravv = new ListRandomAccessVectorValues(base, dim); + var query = TestUtil.randomVector(random, dim); + VectorUtil.scale(query, 3f); + float queryNorm = (float) Math.sqrt(VectorUtil.dotProduct(query, query)); + var dot = VectorSimilarityFunction.DOT_PRODUCT; + + // A list of vectors does not track lengths, so dot-product searches over it keep the standard rule. + assertTrue(Float.isNaN(DefaultSearchScoreProvider.exact(query, dot, ravv).dotProductBound())); + + // A caller that knows the bound can supply it. + var exact = DefaultSearchScoreProvider.exact(query, dot, ravv).scoreFunction(); + assertEquals(queryNorm * maxNorm, + new DefaultSearchScoreProvider(exact, null, dot, queryNorm * maxNorm).dotProductBound(), 0f); + + // PQ bounds every dot product it scores, from its reconstructions. + for (boolean center : new boolean[] {false, true}) { + var pq = ProductQuantization.compute(ravv, 8, 32, center); + var pqv = pq.encodeAll(ravv, ForkJoinPool.commonPool()); + var sf = pqv.precomputedScoreFunctionFor(query, dot); + float bound = sf.dotProductBound(); + assertTrue(Float.isFinite(bound)); + for (int i = 0; i < n; i++) { + float estimate = 2 * sf.similarityTo(i) - 1; + assertTrue("PQ center " + center + " node " + i + ": " + estimate + " > " + bound, + estimate <= bound * (1 + 1e-5f) + 1e-5f); + } + } + + // ASH records the largest length it encodes and keeps it through a save and load. + var ash = AsymmetricHashing.initialize(ravv, AsymmetricHashing.RANDOM, AsymmetricHashing.HEADER_BITS + 32 * 2, 1, 2); + var ashv = ash.encodeAll(ravv, ForkJoinPool.commonPool()); + assertEquals(maxNorm, ash.maxEncodedNorm(), maxNorm * 1e-4f); + assertEquals(queryNorm * ash.maxEncodedNorm(), ashv.precomputedScoreFunctionFor(query, dot).dotProductBound(), + queryNorm * maxNorm * 1e-5f); + File file = File.createTempFile("ash-norm", ".model"); + file.deleteOnExit(); + try (var writer = new SimpleWriter(file.toPath())) { + ash.write(writer, OnDiskGraphIndex.CURRENT_VERSION); + } + assertEquals(ash.compressorSize(), file.length()); + try (var readerSupplier = new SimpleMappedReader.Supplier(file.toPath())) { + assertEquals(ash.maxEncodedNorm(), AsymmetricHashing.load(readerSupplier.get()).maxEncodedNorm(), 0f); + } + } +} diff --git a/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestASHAdaptiveStopping.java b/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestASHAdaptiveStopping.java index 808bd8bd3..f91bffd9e 100644 --- a/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestASHAdaptiveStopping.java +++ b/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestASHAdaptiveStopping.java @@ -65,7 +65,7 @@ public void ashScoresAlwaysStopAdaptivelyUnlessTheSearcherSaysOtherwise() throws VectorUtil.l2normalize(query); var asf = encoded.precomputedScoreFunctionFor(query, VectorSimilarityFunction.DOT_PRODUCT); assertTrue(asf.requiresAdaptiveTermination()); - var requested = search(graph, new DefaultSearchScoreProvider(asf, null, true), true); + var requested = search(graph, new DefaultSearchScoreProvider(asf, null, VectorSimilarityFunction.DOT_PRODUCT), true); // Plain wrappers that do not ask for adaptive stopping still get it. assertSame(requested, search(graph, new DefaultSearchScoreProvider(asf), true)); assertSame(requested, search(graph, new DefaultSearchScoreProvider(asf, null), true)); @@ -73,7 +73,7 @@ public void ashScoresAlwaysStopAdaptivelyUnlessTheSearcherSaysOtherwise() throws // The searcher's switch is the override: the search is then the standard one. ScoreFunction.ApproximateScoreFunction unmarked = asf::similarityTo; var standard = search(graph, new DefaultSearchScoreProvider(unmarked), true); - assertSame(standard, search(graph, new DefaultSearchScoreProvider(asf, null, true), false)); + assertSame(standard, search(graph, new DefaultSearchScoreProvider(asf, null, VectorSimilarityFunction.DOT_PRODUCT), false)); } } } diff --git a/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestASHEncodedNorms.java b/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestASHEncodedNorms.java new file mode 100644 index 000000000..762d7fbe9 --- /dev/null +++ b/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestASHEncodedNorms.java @@ -0,0 +1,83 @@ +/* + * 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 io.github.jbellis.jvector.TestUtil; +import io.github.jbellis.jvector.disk.SimpleMappedReader; +import io.github.jbellis.jvector.disk.SimpleWriter; +import io.github.jbellis.jvector.graph.ListRandomAccessVectorValues; +import io.github.jbellis.jvector.graph.disk.OnDiskGraphIndex; +import io.github.jbellis.jvector.vector.VectorUtil; +import io.github.jbellis.jvector.vector.types.VectorFloat; +import org.junit.Test; + +import java.io.File; +import java.util.ArrayList; +import java.util.List; +import java.util.Random; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +/** + * ASH knows the largest length of the vectors it holds codes for, however they were encoded: by the same model + * instance, or by another instance of the model whose codes are wrapped afterwards. + */ +public class TestASHEncodedNorms { + @Test + public void codesRecoverTheLengthsTheyEncode() throws Exception { + var random = new Random(23); + int n = 400, dim = 48; + List> base = new ArrayList<>(); + float maxNorm = 0; + for (int i = 0; i < n; i++) { + var v = TestUtil.randomVector(random, dim); + VectorUtil.scale(v, (float) Math.pow(10, -1 + 2 * random.nextDouble())); + base.add(v); + maxNorm = Math.max(maxNorm, (float) Math.sqrt(VectorUtil.dotProduct(v, v))); + } + var ravv = new ListRandomAccessVectorValues(base, dim); + for (int bits : new int[] {1, 2, 3, 4, 9}) { + for (int landmarks : new int[] {1, 4}) { + String where = bits + " bits, " + landmarks + " landmarks"; + var ash = AsymmetricHashing.initialize(ravv, AsymmetricHashing.RANDOM, + AsymmetricHashing.HEADER_BITS + 40 * bits, landmarks, bits); + // A second instance of the same model, saved before it encoded anything. + File file = File.createTempFile("ash-model", ".bin"); + file.deleteOnExit(); + try (var writer = new SimpleWriter(file.toPath())) { + ash.write(writer, OnDiskGraphIndex.CURRENT_VERSION); + } + AsymmetricHashing other; + try (var readers = new SimpleMappedReader.Supplier(file.toPath())) { + other = AsymmetricHashing.load(readers.get()); + } + var codes = new AsymmetricHashing.QuantizedVector[n]; + for (int i = 0; i < n; i++) { + codes[i] = ash.encode(base.get(i)); + float exact = VectorUtil.dotProduct(base.get(i), base.get(i)); + float fromCode = ash.encodedSquaredNorm(codes[i]); + assertEquals(where + " node " + i, exact, fromCode, exact * 2e-3f + 1e-4f); + } + assertEquals(where, maxNorm, ash.maxEncodedNorm(), maxNorm * 1e-5f); + // Wrapping the codes with the other instance counts them, never below their true lengths. + new ASHVectors(other, codes); + assertTrue(where + ": " + other.maxEncodedNorm(), other.maxEncodedNorm() >= maxNorm); + assertEquals(where, maxNorm, other.maxEncodedNorm(), maxNorm * 5e-3f); + } + } + } +} diff --git a/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestASHGraphSearch.java b/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestASHGraphSearch.java index 3e49e3932..99d2dfddf 100644 --- a/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestASHGraphSearch.java +++ b/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestASHGraphSearch.java @@ -90,7 +90,7 @@ public void negativeEntryReachesBaseLayerWithStandaloneAndFusedScoring() throws try (var searcher = new GraphSearcher(graph)) { searcher.usePruning(false); var scores = new DefaultSearchScoreProvider(standalone, - n -> VectorSimilarityFunction.DOT_PRODUCT.compare(query, input[n]), true); + n -> VectorSimilarityFunction.DOT_PRODUCT.compare(query, input[n]), VectorSimilarityFunction.DOT_PRODUCT); assertEquals(10, searcher.search(scores, 10, 10, 0f, 0f, Bits.ALL).getNodes().length); } for (int n = 0; n < input.length; n++) { @@ -103,7 +103,7 @@ public void negativeEntryReachesBaseLayerWithStandaloneAndFusedScoring() throws var fused = view.approximateScoreFunctionFor(query, VectorSimilarityFunction.DOT_PRODUCT); assertEquals(standalone.similarityTo(0), fused.similarityTo(0), 0.00005f); var scores = new DefaultSearchScoreProvider(fused, - view.rerankerFor(query, VectorSimilarityFunction.DOT_PRODUCT), true); + view.rerankerFor(query, VectorSimilarityFunction.DOT_PRODUCT), VectorSimilarityFunction.DOT_PRODUCT); assertEquals(10, searcher.search(scores, 10, 10, 0f, 0f, Bits.ALL).getNodes().length); fused.enableSimilarityToNeighbors(0); var neighbors = view.getNeighborsIterator(0, 0); diff --git a/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestASHQueryPreparation.java b/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestASHQueryPreparation.java index e578e6701..f61390f30 100644 --- a/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestASHQueryPreparation.java +++ b/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestASHQueryPreparation.java @@ -202,7 +202,7 @@ public void onDiskViewsOwnIndependentScratchOnSameThread() throws Exception { var searchView = (OnDiskGraphIndex.View) searcher.getView(); var approximate = searchView.approximateScoreFunctionFor(vectors[3], DOT); var provider = new DefaultSearchScoreProvider(approximate, - searchView.rerankerFor(vectors[3], DOT), true); + searchView.rerankerFor(vectors[3], DOT), DOT); approximate.enableSimilarityToNeighbors(0); float original = approximate.similarityToNeighbor(0, 0); assertEquals(5, searcher.search(provider, 5, 10, 0f, 0f, Bits.ALL).getNodes().length); diff --git a/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestASHSymmetricScorer.java b/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestASHSymmetricScorer.java index 9dbecaa64..847f7b029 100644 --- a/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestASHSymmetricScorer.java +++ b/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestASHSymmetricScorer.java @@ -265,7 +265,8 @@ private void checkAppendixBReconstruction() throws Exception { var encoded = ash.encodeAll(values, java.util.concurrent.ForkJoinPool.commonPool()); var bsp = BuildScoreProvider.ashBuildScoreProvider(VectorSimilarityFunction.DOT_PRODUCT,encoded); assertTrue(new ASHSymmetricScorer(encoded).dotProduct(0,1) < 0f); - assertFalse(bsp.searchProviderFor(0).adaptiveScoresAreRawDotProducts()); + assertEquals(VectorSimilarityFunction.DOT_PRODUCT, bsp.searchProviderFor(0).adaptiveSimilarity()); + assertTrue(Float.isFinite(bsp.searchProviderFor(0).dotProductBound())); try (var builder = new GraphIndexBuilder(bsp,40,16,40,1.2f,1.2f,true,true)) { for (int i=0;i> base = new ArrayList<>(); + for (int i = 0; i < n; i++) { + var v = TestUtil.randomVector(random, dim); + VectorUtil.scale(v, (float) Math.pow(10, -1 + 2 * random.nextDouble())); + base.add(v); + } + var ravv = new ListRandomAccessVectorValues(base, dim); + var pq = ProductQuantization.compute(ravv, 4, 32, false); + var pqv = pq.encodeAll(ravv, ForkJoinPool.commonPool()); + float expected = 0; + for (int i = 0; i < n; i++) expected = Math.max(expected, (float) Math.sqrt(pq.reconstructedSquaredNorm(pqv.get(i), 0))); + assertEquals(expected, pq.maxEncodedNorm(), 0f); + assertEquals(expected, pqv.maxReconstructedNorm(), 0f); + + // Saved and loaded at the current version, the model keeps it, and the header size accounts for it. + var current = roundTrip(pq, OnDiskGraphIndex.CURRENT_VERSION); + assertEquals(expected, current.maxEncodedNorm(), 0f); + + // An older format did not store it: unknown (infinite), and it stays unknown after encoding more, since the + // lengths of the codes encoded before cannot be known. Dot-product searches then keep the standard rule. + var older = roundTrip(pq, ProductQuantization.MAX_NORM_VERSION - 1); + assertEquals(Float.POSITIVE_INFINITY, older.maxEncodedNorm(), 0f); + older.encode(base.get(0)); + assertEquals(Float.POSITIVE_INFINITY, older.maxEncodedNorm(), 0f); + var query = TestUtil.randomVector(random, dim); + var olderVectors = older.encodeAll(ravv, ForkJoinPool.commonPool()); + assertTrue(!Float.isFinite(olderVectors.precomputedScoreFunctionFor(query, VectorSimilarityFunction.DOT_PRODUCT) + .dotProductBound())); + // Unknown survives a save and load at the current version too. + assertEquals(Float.POSITIVE_INFINITY, roundTrip(older, OnDiskGraphIndex.CURRENT_VERSION).maxEncodedNorm(), 0f); + + // Codes wrapped with another instance of the model are counted in it. + var fresh = roundTrip(pq, OnDiskGraphIndex.CURRENT_VERSION); + var empty = new ProductQuantization(fresh.codebooks, fresh.getClusterCount(), fresh.subvectorSizesAndOffsets, + fresh.globalCentroid, fresh.anisotropicThreshold); + // Nothing recorded yet: no bound, so a model saved now does not claim one. + assertTrue(Float.isNaN(empty.maxEncodedNorm())); + assertTrue(Float.isNaN(roundTrip(empty, OnDiskGraphIndex.CURRENT_VERSION).maxEncodedNorm())); + var codes = new ByteSequence[n]; + for (int i = 0; i < n; i++) codes[i] = pqv.get(i).copy(); + empty.createCompressedVectors(codes); + assertEquals(expected, empty.maxEncodedNorm(), 0f); + } + + private static ProductQuantization roundTrip(ProductQuantization pq, int version) throws Exception { + File file = File.createTempFile("pq-norm", ".bin"); + file.deleteOnExit(); + try (var writer = new SimpleWriter(file.toPath())) { + pq.write(writer, version); + } + assertEquals("version " + version, pq.compressorSize(version), file.length()); + try (var readers = new SimpleMappedReader.Supplier(file.toPath())) { + return ProductQuantization.load(readers.get()); + } + } +} diff --git a/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestPreparedASHQuery.java b/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestPreparedASHQuery.java index f6cb1d32b..a0f541bd4 100644 --- a/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestPreparedASHQuery.java +++ b/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestPreparedASHQuery.java @@ -228,7 +228,7 @@ public void realDiskSearchFilteringAndResumeUsePreparedStateWithoutReprojection( preparer.prepareBatch(new VectorFloat[]{values[q]}, new PreparedQuery[]{state}, 1); var provider = queuedView.scoreProviderFor(state, true); var ordinary = new DefaultSearchScoreProvider( - directView.approximateScoreFunctionFor(values[q], DOT), directView.rerankerFor(values[q], DOT), true); + directView.approximateScoreFunctionFor(values[q], DOT), directView.rerankerFor(values[q], DOT), DOT); Bits accepted = n -> (n & 1) == 0; assertSameResults(direct.search(ordinary, 3, 17, 0f, 0f, accepted), queued.search(provider, 3, 17, 0f, 0f, accepted)); diff --git a/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestUnnormalizedVectors.java b/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestUnnormalizedVectors.java index 1d4ef699d..b9e188ea2 100644 --- a/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestUnnormalizedVectors.java +++ b/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestUnnormalizedVectors.java @@ -46,6 +46,8 @@ * scorer that ignored norms (ranking by direction alone) would miss most of the true neighbors. Each scorer is judged * against itself on the same directions at unit length, so its accuracy at a given budget does not decide the result: * a scorer that keeps norm information is at least as accurate on the mixed lengths and clearly above norm-blind ranking. + * Graph searches that stop adaptively must engage on both datasets and find what searches stopping by the standard + * rule find. */ public class TestUnnormalizedVectors { private static final int N = 1000, DIM = 64, QUERIES = 100, K = 10; @@ -53,6 +55,8 @@ public class TestUnnormalizedVectors { private static final double NOISE = 0.05; // How far above norm-blind ranking a norm-aware scorer must be. private static final double MARGIN = 0.1; + // How much recall adaptive stopping may give up against the standard rule. + private static final double ADAPTIVE_LOSS = 0.02; @Test public void graphSearchRanksByTheFullDotProduct() throws Exception { @@ -84,13 +88,18 @@ public void graphSearchRanksByTheFullDotProduct() throws Exception { var unitResults = runAll("unit length ", unitBase, unitQueries, truth(unitQueries, unitBase)); for (var name : mixed.keySet()) { double[] m = mixed.get(name), u = unitResults.get(name); - System.out.format("%-22s mixed scan %.3f graph %.3f | unit scan %.3f graph %.3f | mixed - unit %+.3f%n", - name, m[0], m[1], u[0], u[1], m[0] - u[0]); + System.out.format("%-22s mixed scan %.3f graph %.3f adaptive %.3f | unit scan %.3f graph %.3f adaptive %.3f | mixed - unit %+.3f%n", + name, m[0], m[1], m[2], u[0], u[1], u[2], m[0] - u[0]); for (int i = 0; i < 2; i++) { String path = i == 0 ? "scan" : "graph"; if (!(m[i] >= u[i] - NOISE)) failures.add(name + " " + path + " loses to norms: " + m[i] + " < unit " + u[i]); if (!(m[i] >= normBlind + MARGIN)) failures.add(name + " " + path + " near norm-blind: " + m[i]); } + for (var r : new double[][] {m, u}) { + String which = r == m ? " mixed" : " unit"; + if (!(r[2] >= r[1] - ADAPTIVE_LOSS)) failures.add(name + which + " adaptive " + r[2] + " < standard " + r[1]); + if (r[3] == 0) failures.add(name + which + " adaptive stopping never changed a search"); + } } assertTrue("the data must separate norm-aware from norm-blind ranking: " + normBlind, normBlind < 0.5); assertTrue("must rank by the full dot product: " + failures, failures.isEmpty()); @@ -102,9 +111,28 @@ private static java.util.Map runAll(String label, List(); results.put("exact (256 B)", check(label, "exact (256 B)", BuildScoreProvider.randomAccessScoreProvider(ravv, vsf), - ravv, queries, truth, q -> DefaultSearchScoreProvider.exact(q, vsf, ravv).scoreFunction())); + ravv, queries, truth, q -> { + var exact = DefaultSearchScoreProvider.exact(q, vsf, ravv).scoreFunction(); + float bound = (float) Math.sqrt(VectorUtil.dotProduct(q, q)) * largest; + return new ScoreFunction.ExactScoreFunction() { + @Override + public float similarityTo(int node) { + return exact.similarityTo(node); + } + + @Override + public float dotProductBound() { + return bound; + } + }; + })); // PQ: 16 B = 16 subspaces of 4 dimensions; 32 B = 32 subspaces of 2; 256 centroids each. for (int m : new int[] {16, 32}) { var pqv = ProductQuantization.compute(ravv, m, 256, false).encodeAll(ravv, ForkJoinPool.commonPool()); @@ -148,36 +176,48 @@ private static VectorFloat randomVector(io.github.jbellis.jvector.vector.type } /** - * Brute-force and graph recall@K against {@code truth}, as {scan, graph}. The graph is built with the same scoring - * and searched with adaptive stopping off. + * Recall@K against {@code truth} by brute force, by graph search stopping by the standard rule, and by graph + * search stopping adaptively, then the fraction of queries whose adaptive search visited a different number of + * nodes: {scan, graph, adaptive, changed}. The graph is built with the same scoring. */ private static double[] check(String label, String name, BuildScoreProvider bsp, ListRandomAccessVectorValues ravv, List> queries, List> truth, java.util.function.Function, ScoreFunction> scorerFor) { - List> scan = new ArrayList<>(), graphResults = new ArrayList<>(); + List> scan = new ArrayList<>(), graphResults = new ArrayList<>(), adaptiveResults = new ArrayList<>(); + int changed = 0; ImmutableGraphIndex graph; try (var builder = new GraphIndexBuilder(bsp, DIM, 16, 100, 1.2f, 1.2f, false)) { graph = builder.build(ravv); } catch (Exception e) { throw new RuntimeException(e); } - try (var searcher = new GraphSearcher(graph)) { + try (var searcher = new GraphSearcher(graph); var adaptive = new GraphSearcher(graph)) { searcher.useAdaptiveTermination(false); for (var q : queries) { var sf = scorerFor.apply(q); scan.add(topK(sf::similarityTo)); - var result = searcher.search(new DefaultSearchScoreProvider(sf), K, 100, 0f, 0f, Bits.ALL); - var found = new HashSet(); - for (var ns : result.getNodes()) found.add(ns.node); - graphResults.add(found); + var result = searcher.search(new DefaultSearchScoreProvider(sf, null, VectorSimilarityFunction.DOT_PRODUCT), + K, 100, 0f, 0f, Bits.ALL); + graphResults.add(nodes(result)); + var adaptiveResult = adaptive.search(new DefaultSearchScoreProvider(sf, null, VectorSimilarityFunction.DOT_PRODUCT), + K, 100, 0f, 0f, Bits.ALL); + adaptiveResults.add(nodes(adaptiveResult)); + if (adaptiveResult.getVisitedCount() != result.getVisitedCount()) changed++; } } catch (Exception e) { throw new RuntimeException(e); } double scanRecall = recall(scan, truth), graphRecall = recall(graphResults, truth); - System.out.format("%s %-22s brute-force recall@%d %.3f, graph recall@%d %.3f%n", - label, name, K, scanRecall, K, graphRecall); - return new double[] {scanRecall, graphRecall}; + double adaptiveRecall = recall(adaptiveResults, truth); + System.out.format("%s %-22s brute-force recall@%d %.3f, graph recall@%d %.3f, adaptive %.3f (%d of %d searches changed)%n", + label, name, K, scanRecall, K, graphRecall, adaptiveRecall, changed, queries.size()); + return new double[] {scanRecall, graphRecall, adaptiveRecall, changed / (double) queries.size()}; + } + + private static Set nodes(io.github.jbellis.jvector.graph.SearchResult result) { + var found = new HashSet(); + for (var ns : result.getNodes()) found.add(ns.node); + return found; } private static Set topK(IntFunction score) { From 905915dfcdb6ee07134eed9cc0fee67bd20b7a69 Mon Sep 17 00:00:00 2001 From: Ted Willke Date: Thu, 12 Nov 2026 19:09:43 -0800 Subject: [PATCH 242/243] Document norm-aware adaptive stopping and the format 7 length fields --- UPGRADING.md | 11 ++++++++--- docs/release notes/4.1.0/796.feature.md | 17 ++++++++++++----- 2 files changed, 20 insertions(+), 8 deletions(-) diff --git a/UPGRADING.md b/UPGRADING.md index 69732ec3b..8a2c81203 100644 --- a/UPGRADING.md +++ b/UPGRADING.md @@ -2,9 +2,14 @@ ## On-disk format version 7 - Version 7 of the on-disk graph format adds one feature, fused ASH (`FeatureId.FUSED_ASH`): ASH codes - stored in the graph alongside each node's neighbors. Apart from that feature, a version 7 file has - the version 6 layout. ASH models (`AsymmetricHashing`, `ASHVectors`) also require version 7 or later - when they are written or loaded. + stored in the graph alongside each node's neighbors. It also stores the largest vector length that + adaptive dot-product stopping needs: one float at the end of each serialized PQ model, and of the + inline-vector and separated-vector feature headers. Otherwise a version 7 file has the version 6 layout. + ASH models (`AsymmetricHashing`, `ASHVectors`) also require version 7 or later when they are written or + loaded. +- Dot-product searches over indexes and PQ models written before version 7 stop by the standard rule, + since their largest vector length is unknown; rewriting them at version 7 does not change that for codes + encoded before, so a PQ model loaded from an older format keeps it unknown. - JVector 4.0.x reads formats up to 6 and rejects version 7 files. `OnDiskGraphIndex.CURRENT_VERSION`, the default for `OnDiskGraphIndexWriter.Builder` and for `CompressedVectors.write(out)`, is now 7. - To keep a rollback to 4.0.x possible, pass the version explicitly until every reader runs 4.1: diff --git a/docs/release notes/4.1.0/796.feature.md b/docs/release notes/4.1.0/796.feature.md index c8c9f04e2..0592d59ed 100644 --- a/docs/release notes/4.1.0/796.feature.md +++ b/docs/release notes/4.1.0/796.feature.md @@ -75,15 +75,22 @@ or `fp32`, `fp16`, `int8`). writer defaults are now 7. Hosts that must be able to roll back should keep passing `withVersion(6)` and `write(out, 6)` until every reader runs this release; version 6 and older files are written and read as before. See [UPGRADING.md](../../../UPGRADING.md). -- **Similarity.** ASH scores dot product only. Use `DOT_PRODUCT` with normalized vectors for - cosine similarity. +- **Similarity.** ASH scores dot product only, on vectors of any length: its similarity is + `(1 + dot) / 2` without a lower bound, the scale exact dot-product scoring uses, so vectors rank by + their full dot products. Use `DOT_PRODUCT` with normalized vectors for cosine similarity. - **Concurrency.** A prepared query, its searcher and its view belong to one in-flight search; reuse them only after it completes. For asynchronous serving, size execution to the physical cores allocated to search and use bounded admission; see [Integrating ASH queries into a database](../../ash-query-integration.md). -- **Adaptive termination.** Searches of a fused ASH index through a prepared query stop when - further candidates are unlikely to improve the results (relative margin 0.01). Disable it with - `GraphSearcher.useAdaptiveTermination(false)`. +- **Adaptive termination.** Top-K searches stop when the best unexpanded candidate is more than + `1 + gamma` times as far from the query as the k-th best result found (gamma about 0.005), reading + scores as true distances for dot-product, cosine and Euclidean similarity on vectors of any length. + For dot products the distance is measured from the query's length times the largest stored vector + length, which ASH and PQ models and the full-precision vector features record and store at format + version 7. A search expands at least the nodes the standard rule would and at most the nodes a + standard search with twice the beam would. All ASH searches, and searches through + `DefaultSearchScoreProvider.exact` and `forSimilarity`, stop adaptively; providers built with the plain + constructors keep the standard rule. Disable it with `GraphSearcher.useAdaptiveTermination(false)`. - **BLAS threads.** JVector sets the native BLAS thread count for ASH training: one thread, or the size of the executor passed to `AsymmetricHashing.initialize`. Hosts no longer need `MKL_NUM_THREADS` to keep MKL or OpenBLAS from starting a thread per CPU. From 34123e6b6faa84ca9c58354e23f81a546112b376 Mon Sep 17 00:00:00 2001 From: Ted Willke Date: Thu, 12 Nov 2026 21:55:39 -0800 Subject: [PATCH 243/243] Make adaptive stopping experimental and off by default Measured against standard stopping swept over overquery, at 1M and 10M, with ASH and PQ scores, with and without hierarchy, adaptive stopping gave less recall at the same throughput or nodes visited: raising the overquery reranks more candidates, which adaptive stopping does not. GraphSearcher therefore disables it by default, and useAdaptiveTermination is marked experimental. Searches that enable it behave as before. Graph construction keeps its behavior: the builder's searchers enable adaptive stopping, which applies where the build provider asks for it, as ASH's does. --- docs/release notes/4.1.0/796.feature.md | 18 +++++++++--------- .../jvector/graph/GraphIndexBuilder.java | 6 ++++++ .../jbellis/jvector/graph/GraphSearcher.java | 16 +++++++++------- .../similarity/DefaultSearchScoreProvider.java | 13 +++++++------ .../graph/similarity/ScoreFunction.java | 9 ++++----- .../graph/similarity/SearchScoreProvider.java | 5 +++-- .../github/jbellis/jvector/example/Grid.java | 6 +++--- .../jvector/graph/TestBoundedCandidates.java | 3 +++ .../quantization/TestASHAdaptiveStopping.java | 10 +++++++++- .../quantization/TestUnnormalizedVectors.java | 1 + 10 files changed, 54 insertions(+), 33 deletions(-) diff --git a/docs/release notes/4.1.0/796.feature.md b/docs/release notes/4.1.0/796.feature.md index 0592d59ed..4f2559e10 100644 --- a/docs/release notes/4.1.0/796.feature.md +++ b/docs/release notes/4.1.0/796.feature.md @@ -82,15 +82,15 @@ or `fp32`, `fp16`, `int8`). reuse them only after it completes. For asynchronous serving, size execution to the physical cores allocated to search and use bounded admission; see [Integrating ASH queries into a database](../../ash-query-integration.md). -- **Adaptive termination.** Top-K searches stop when the best unexpanded candidate is more than - `1 + gamma` times as far from the query as the k-th best result found (gamma about 0.005), reading - scores as true distances for dot-product, cosine and Euclidean similarity on vectors of any length. - For dot products the distance is measured from the query's length times the largest stored vector - length, which ASH and PQ models and the full-precision vector features record and store at format - version 7. A search expands at least the nodes the standard rule would and at most the nodes a - standard search with twice the beam would. All ASH searches, and searches through - `DefaultSearchScoreProvider.exact` and `forSimilarity`, stop adaptively; providers built with the plain - constructors keep the standard rule. Disable it with `GraphSearcher.useAdaptiveTermination(false)`. +- **Adaptive termination (experimental, off by default).** `GraphSearcher.useAdaptiveTermination(true)` + makes top-K searches stop when the best unexpanded candidate is more than `1 + gamma` times as far + from the query as the k-th best result found (gamma about 0.005), reading scores as true distances for + dot-product, cosine and Euclidean similarity on vectors of any length. For dot products the distance is + measured from the query's length times the largest stored vector length, which ASH and PQ models and + the full-precision vector features record and store at format version 7. A search expands at least the + nodes the standard rule would and at most the nodes a standard search with twice the beam would. With + compressed scores and reranking, raising the overquery has so far given more recall at the same + throughput, so it stays off unless enabled. Graph construction with ASH keeps using it. - **BLAS threads.** JVector sets the native BLAS thread count for ASH training: one thread, or the size of the executor passed to `AsymmetricHashing.initialize`. Hosts no longer need `MKL_NUM_THREADS` to keep MKL or OpenBLAS from starting a thread per CPU. 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 ec7ad410a..70fed19b7 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 @@ -539,6 +539,9 @@ private GraphIndexBuilder(BuildScoreProvider scoreProvider, this.searchers = ExplicitThreadLocal.withInitial(() -> { var gs = new GraphSearcher(graph); gs.usePruning(false); + // Construction keeps adaptive stopping where the build provider asks for it, as ASH's does; the other + // build providers name no similarity function, so their searches keep the standard rule. + gs.useAdaptiveTermination(true); return gs; }); @@ -633,6 +636,9 @@ private GraphIndexBuilder(BuildScoreProvider buildScoreProvider, int dimension, this.searchers = ExplicitThreadLocal.withInitial(() -> { var gs = new GraphSearcher(graph); gs.usePruning(false); + // Construction keeps adaptive stopping where the build provider asks for it, as ASH's does; the other + // build providers name no similarity function, so their searches keep the standard rule. + gs.useAdaptiveTermination(true); return gs; }); 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 4ebfc5f58..4cfbe65fa 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 @@ -66,7 +66,7 @@ public class GraphSearcher implements Closeable { private boolean pruneSearch; private boolean boundCandidates = true; - private boolean adaptiveTerminationEnabled = true; + private boolean adaptiveTerminationEnabled = false; private float adaptiveTerminationGamma = AdaptiveTermination.DEFAULT_GAMMA; private final AdaptiveTermination adaptiveTermination = new AdaptiveTermination(); // The best rerankK accepted nodes scored so far in the current layer search, worst on top. @@ -161,11 +161,13 @@ void boundCandidates(boolean enabled) { } /** - * Enable or disable adaptive stopping for top-K searches whose score providers name their similarity function - * ({@link SearchScoreProvider#adaptiveSimilarity}) or whose score functions require it. Enabled by default. A search - * that stops adaptively expands at least what the standard rule would and at most what a standard search with - * twice the beam would. + * Experimental: enable or disable adaptive stopping for top-K searches whose score providers name their similarity + * function ({@link SearchScoreProvider#adaptiveSimilarity}) or whose score functions require it. Disabled by + * default. A search that stops adaptively expands at least what the standard rule would and at most what a + * standard search with twice the beam would. With compressed scores and reranking, raising the overquery has so + * far given more recall at the same throughput. */ + @Experimental public void useAdaptiveTermination(boolean enabled) { adaptiveTerminationEnabled = enabled; } @@ -175,6 +177,7 @@ public void useAdaptiveTermination(boolean enabled) { * search stops when the best unexpanded candidate is more than {@code 1 + gamma} times as far from the query as * the k-th best result found. The default is about 0.005. */ + @Experimental public void useAdaptiveTermination(boolean enabled, float gamma) { AdaptiveTermination.validateGamma(gamma); adaptiveTerminationEnabled = enabled; @@ -466,8 +469,7 @@ public void searchOneLayer(SearchScoreProvider scoreProvider, try { assert approximateResults.size() == 0; // should be cleared by setEntryPointsFromPreviousLayer approximateResults.setMaxSize(rerankK); - // ASH score functions require adaptive stopping whatever provider wraps them; only - // useAdaptiveTermination(false) turns it off. + // When enabled, ASH score functions stop adaptively whatever provider wraps them. boolean adaptive = adaptiveTerminationEnabled && level == 0 && threshold == 0.0f && rerankK > 0 && startAdaptiveTermination(scoreProvider, rerankK); // Bounded candidates: a scored node is expanded only while it ranks among the best rerankK accepted diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/graph/similarity/DefaultSearchScoreProvider.java b/jvector-base/src/main/java/io/github/jbellis/jvector/graph/similarity/DefaultSearchScoreProvider.java index c11ec54ee..640e55019 100644 --- a/jvector-base/src/main/java/io/github/jbellis/jvector/graph/similarity/DefaultSearchScoreProvider.java +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/graph/similarity/DefaultSearchScoreProvider.java @@ -46,7 +46,7 @@ public DefaultSearchScoreProvider(ScoreFunction scoreFunction) { * or `ScoringView.rerankerFor`. *

    * Searches with this provider stop by the standard rule, since it cannot tell what its scores measure; use - * {@link #forSimilarity} to let top-K searches stop adaptively where the similarity function supports it. + * {@link #forSimilarity} to let top-K searches stop adaptively where the searcher enables it. */ public DefaultSearchScoreProvider(ScoreFunction scoreFunction, ScoreFunction.ExactScoreFunction reranker) { this(scoreFunction, reranker, null); @@ -75,9 +75,10 @@ public DefaultSearchScoreProvider(ScoreFunction scoreFunction, ScoreFunction.Exa } /** - * A provider for scores of {@code similarity} whose top-K searches stop adaptively. Dot-product searches need - * the score function's {@link ScoreFunction#dotProductBound} and keep the standard rule without one. - * {@link io.github.jbellis.jvector.graph.GraphSearcher#useAdaptiveTermination} turns adaptive stopping off. + * A provider for scores of {@code similarity} whose top-K searches stop adaptively when the searcher enables the + * experimental adaptive stopping ({@link io.github.jbellis.jvector.graph.GraphSearcher#useAdaptiveTermination}). + * Dot-product searches need the score function's {@link ScoreFunction#dotProductBound} and keep the standard rule + * without one. */ public static DefaultSearchScoreProvider forSimilarity(ScoreFunction scoreFunction, ScoreFunction.ExactScoreFunction reranker, @@ -108,8 +109,8 @@ public ScoreFunction.ExactScoreFunction exactScoreFunction() { } /** - * A SearchScoreProvider for a single-pass search based on exact similarity, stopping adaptively where - * {@code vsf} supports it (see {@link #forSimilarity}). + * A SearchScoreProvider for a single-pass search based on exact similarity, which may stop adaptively where the + * searcher enables it (see {@link #forSimilarity}). * Generally only suitable when your RandomAccessVectorValues is entirely in-memory, * e.g. during construction. */ diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/graph/similarity/ScoreFunction.java b/jvector-base/src/main/java/io/github/jbellis/jvector/graph/similarity/ScoreFunction.java index 611638c4a..4b49c4b47 100644 --- a/jvector-base/src/main/java/io/github/jbellis/jvector/graph/similarity/ScoreFunction.java +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/graph/similarity/ScoreFunction.java @@ -83,8 +83,8 @@ default boolean supportsSimilarityToNeighbors() { } /** - * Whether a top-K search scored by this function always stops adaptively, whatever - * {@link SearchScoreProvider} wraps it. Only {@code GraphSearcher.useAdaptiveTermination(false)} overrides it. + * Whether a top-K search scored by this function stops adaptively whatever {@link SearchScoreProvider} wraps it, + * when the searcher's experimental adaptive stopping is enabled ({@code GraphSearcher.useAdaptiveTermination}). */ default boolean requiresAdaptiveTermination() { return false; @@ -114,9 +114,8 @@ default boolean isExact() { } /** - * An approximate score function whose top-K searches always stop adaptively unless the searcher's adaptive - * stopping is turned off explicitly. Every ASH score function is one, so ASH graph searches cannot fall back to - * the standard rule by being wrapped in a provider that does not ask for adaptive stopping. + * An approximate score function whose top-K searches stop adaptively, when the searcher's experimental adaptive + * stopping is enabled, whatever provider wraps it. Every ASH score function is one. */ interface AdaptiveApproximateScoreFunction extends ApproximateScoreFunction { @Override diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/graph/similarity/SearchScoreProvider.java b/jvector-base/src/main/java/io/github/jbellis/jvector/graph/similarity/SearchScoreProvider.java index dab2e2296..5beacdd22 100644 --- a/jvector-base/src/main/java/io/github/jbellis/jvector/graph/similarity/SearchScoreProvider.java +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/graph/similarity/SearchScoreProvider.java @@ -29,8 +29,9 @@ public interface SearchScoreProvider { /** * The similarity function whose scores {@link #scoreFunction} returns, when top-K searches with this provider may - * stop adaptively; null keeps the standard rule. Score functions that require adaptive stopping - * ({@link ScoreFunction#requiresAdaptiveTermination}) score DOT_PRODUCT and stop adaptively regardless. + * stop adaptively; null keeps the standard rule. Adaptive stopping is experimental and applies only where the + * searcher enables it ({@code GraphSearcher.useAdaptiveTermination}). Score functions that require adaptive + * stopping ({@link ScoreFunction#requiresAdaptiveTermination}) score DOT_PRODUCT and stop adaptively regardless. */ default VectorSimilarityFunction adaptiveSimilarity() { return null; } diff --git a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/Grid.java b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/Grid.java index e0f494302..2e265c6de 100644 --- a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/Grid.java +++ b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/Grid.java @@ -851,10 +851,10 @@ public synchronized void configureQueryExecution(String mode, int batchSize, Int private final ExplicitThreadLocal searchers = ExplicitThreadLocal.withInitial(() -> { var searcher = new GraphSearcher(index); - // Benchmark knobs: jvector.bench.adaptive=false turns adaptive stopping off; jvector.bench.adaptiveGamma - // sets its margin. + // Benchmark knobs for the experimental adaptive stopping, off by default as in the library: + // jvector.bench.adaptive=true turns it on; jvector.bench.adaptiveGamma sets its margin. String gamma = System.getProperty("jvector.bench.adaptiveGamma"); - boolean adaptive = Boolean.parseBoolean(System.getProperty("jvector.bench.adaptive", "true")); + boolean adaptive = Boolean.parseBoolean(System.getProperty("jvector.bench.adaptive", "false")); if (gamma != null) searcher.useAdaptiveTermination(adaptive, Float.parseFloat(gamma)); else searcher.useAdaptiveTermination(adaptive); return searcher; diff --git a/jvector-tests/src/test/java/io/github/jbellis/jvector/graph/TestBoundedCandidates.java b/jvector-tests/src/test/java/io/github/jbellis/jvector/graph/TestBoundedCandidates.java index 75b348301..d71bffac5 100644 --- a/jvector-tests/src/test/java/io/github/jbellis/jvector/graph/TestBoundedCandidates.java +++ b/jvector-tests/src/test/java/io/github/jbellis/jvector/graph/TestBoundedCandidates.java @@ -52,6 +52,9 @@ public void boundedSearchesMatchUnboundedOnes() throws IOException { dimension, 16, 60, 1.2f, 1.2f, hierarchy); var graph = builder.build(vectors); try (var bounded = new GraphSearcher(graph); var unbounded = new GraphSearcher(graph)) { + // Adaptive stopping is experimental and off by default; enabled here so its bar is covered too. + bounded.useAdaptiveTermination(true); + unbounded.useAdaptiveTermination(true); unbounded.boundCandidates(false); for (int q = 0; q < 40; q++) { var query = TestUtil.randomVector(random, dimension); diff --git a/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestASHAdaptiveStopping.java b/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestASHAdaptiveStopping.java index f91bffd9e..8311c4837 100644 --- a/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestASHAdaptiveStopping.java +++ b/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestASHAdaptiveStopping.java @@ -40,7 +40,10 @@ import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertTrue; -/** ASH searches stop adaptively whatever provider wraps their scores; only the searcher's switch turns it off. */ +/** + * Adaptive stopping is experimental and off by default. Enabled on the searcher, ASH searches stop adaptively whatever + * provider wraps their scores; otherwise they stop by the standard rule. + */ public class TestASHAdaptiveStopping { @Test public void ashScoresAlwaysStopAdaptivelyUnlessTheSearcherSaysOtherwise() throws Exception { @@ -74,6 +77,11 @@ public void ashScoresAlwaysStopAdaptivelyUnlessTheSearcherSaysOtherwise() throws ScoreFunction.ApproximateScoreFunction unmarked = asf::similarityTo; var standard = search(graph, new DefaultSearchScoreProvider(unmarked), true); assertSame(standard, search(graph, new DefaultSearchScoreProvider(asf, null, VectorSimilarityFunction.DOT_PRODUCT), false)); + // A searcher that does not enable it stops by the standard rule. + try (var searcher = new GraphSearcher(graph)) { + assertSame(standard, searcher.search(new DefaultSearchScoreProvider(asf, null, VectorSimilarityFunction.DOT_PRODUCT), + 10, 10, 0.0f, 0.0f, Bits.ALL)); + } } } } diff --git a/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestUnnormalizedVectors.java b/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestUnnormalizedVectors.java index b9e188ea2..1b2d48b1f 100644 --- a/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestUnnormalizedVectors.java +++ b/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestUnnormalizedVectors.java @@ -193,6 +193,7 @@ private static double[] check(String label, String name, BuildScoreProvider bsp, } try (var searcher = new GraphSearcher(graph); var adaptive = new GraphSearcher(graph)) { searcher.useAdaptiveTermination(false); + adaptive.useAdaptiveTermination(true); for (var q : queries) { var sf = scorerFor.apply(q); scan.add(topK(sf::similarityTo));

    Te3!2TlX6T_rQ2+b&8 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%0AA^dRh~-gl*z^n*xbKMyuQxv9$2ihrQ8 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

    Close rejects new submissions and waits for accepted work and callbacks. + * Do not close from a callback or block a host executor thread needed to drain this + * service. The host must not cancel or discard already accepted drain tasks. */ public final class BatchingExecutor implements AutoCloseable { - /** Thread-safe operations over independently owned requests. */ + /** Callbacks must be thread-safe. Completion must return promptly. */ public interface Operation { - /** Identity key for compatible preparation; null requests are prepared alone. */ + /** + * Immutable identity key for compatible preparation, or null to prepare + * alone. Called on the submitting thread, outside internal locks. + */ Object batchKey(T request); - /** Borrowed read-only list, valid only for this call; never retained. */ + /** Borrowed read-only list, valid only during this call; never retain it. */ void prepareBatch(List requests) throws Exception; - /** Runs one prepared request, without any batch-wide completion barrier. */ + /** Executes one prepared request, without a batch-wide completion barrier. */ void execute(T request) throws Exception; - /** Called exactly once per accepted request; must not throw. */ + /** + * Called exactly once for accepted work. The request may be recycled here. + * Exceptions are retained for inspection and close, without poisoning work. + */ void complete(T request, Throwable failure); } - private static final long CLOSED = Long.MIN_VALUE; - private static final class Slot { T request; } - private final ArrayBlockingQueue> available, pending, ready; - private final ReentrantLock drain = new ReentrantLock(); - private final Semaphore work = new Semaphore(0); - private final AtomicLong admission = new AtomicLong(); - private final AtomicReference callbackFailure = new AtomicReference<>(); - private final CountDownLatch stopped; + // Keep the entry on host threads but clear its value on exit. Removing it + // would allocate a fresh ThreadLocalMap entry for every isolated drain task. + private static final ThreadLocal> CURRENT = new ThreadLocal<>(); + + private final ReentrantLock lock = new ReentrantLock(); + private final Condition drained = lock.newCondition(); + private final Executor executor; private final Operation operation; - private final int capacity, workerCount, batchSize; + private final int capacity, batchSize, workerLimit; + private final Object[] pending, pendingKeys, ready; + private final Throwable[] pendingFailures; + private int pendingHead, pendingTail, pendingSize; + private int pendingGroups, tailGroupSize; + private int readyHead, readyTail, readySize; + private final ArrayDeque idleWorkers; private final List workers; - private final ThreadLocal inWorker = new ThreadLocal<>(); + private int activeWorkers, busyWorkers, callbacks; + private volatile int outstanding; + private volatile boolean closed; + private final AtomicLong callbackFailures = new AtomicLong(); + private final AtomicReference firstCallbackFailure = new AtomicReference<>(); /** - * @param capacity total accepted requests, including preparing and executing - * @param batchSize maximum currently available compatible requests per preparation - * @param executor caller-owned asynchronous execution capacity; completion - * callbacks run on these workers and must return promptly + * @param workerCount maximum concurrent preparation/execution drain tasks + * @param capacity maximum requests queued, preparing or executing; excludes callbacks + * @param batchSize maximum currently waiting compatible requests per preparation + * @param executor caller-owned asynchronous execution capacity + * @param operation request preparation, execution and completion operations */ public BatchingExecutor(int workerCount, int capacity, int batchSize, Executor executor, Operation operation) { - if (workerCount <= 0 || capacity <= 0 || batchSize <= 0 || batchSize > capacity) - throw new IllegalArgumentException("Workers, capacity and batch size must be positive; batch size <= capacity"); - this.workerCount = workerCount; + if (workerCount <= 0 || capacity <= 0 || batchSize <= 0 + || batchSize > capacity || batchSize == Integer.MAX_VALUE) + throw new IllegalArgumentException("Workers, capacity and batch size must be positive; " + + "batch size must fit its histogram and not exceed capacity"); + this.executor = Objects.requireNonNull(executor, "executor"); + this.operation = Objects.requireNonNull(operation, "operation"); + if (executor instanceof ThreadPoolExecutor) { + var policy = ((ThreadPoolExecutor) executor).getRejectedExecutionHandler(); + if (policy instanceof ThreadPoolExecutor.DiscardPolicy + || policy instanceof ThreadPoolExecutor.DiscardOldestPolicy + || policy instanceof ThreadPoolExecutor.CallerRunsPolicy) + throw new IllegalArgumentException("Executor must reject explicitly without discarding or running inline"); + } this.capacity = capacity; this.batchSize = batchSize; - this.operation = Objects.requireNonNull(operation, "operation"); - Objects.requireNonNull(executor, "executor"); - available = new ArrayBlockingQueue<>(capacity); - pending = new ArrayBlockingQueue<>(capacity); - ready = new ArrayBlockingQueue<>(capacity); - for (int i = 0; i < capacity; i++) available.add(new Slot<>()); - stopped = new CountDownLatch(workerCount); - workers = new ArrayList<>(workerCount); - for (int i = 0; i < workerCount; i++) workers.add(new Worker()); - int submitted = 0; - try { - for (Worker worker : workers) { executor.execute(worker); submitted++; } - } catch (RuntimeException | Error failure) { - admission.getAndUpdate(value -> value | CLOSED); - for (int i = submitted; i < workerCount; i++) stopped.countDown(); - work.release(submitted); - awaitStopped(); - throw failure; + this.workerLimit = Math.min(workerCount, capacity); + pending = new Object[capacity]; + pendingKeys = new Object[capacity]; + pendingFailures = new Throwable[capacity]; + ready = new Object[capacity]; + idleWorkers = new ArrayDeque<>(workerLimit); + workers = new ArrayList<>(workerLimit); + for (int i = 0; i < workerLimit; i++) { + Worker worker = new Worker(); + workers.add(worker); + idleWorkers.addLast(worker); } } /** - * Accepts without waiting, returning false at the total admission limit. - * Rejection leaves ownership with the caller. Close rejects further requests. - * Inputs must not be mutated or submitted twice before completion. + * Accepts without waiting for capacity. Returns false at the admission limit; + * throws after close. A true return transfers ownership until completion. */ public boolean trySubmit(T request) { Objects.requireNonNull(request, "request"); - if (callbackFailure.get() != null) - throw new IllegalStateException("A completion callback failed", callbackFailure.get()); - for (;;) { - long count = admission.get(); - if (count < 0) throw new RejectedExecutionException("Batching executor is closed"); - if (count == capacity) return false; - if (admission.compareAndSet(count, count + 1)) break; - } - // Completion returns the slot before releasing its admission count. - Slot slot = available.remove(); - slot.request = request; - pending.add(slot); - work.release(); + if (closed) throw new RejectedExecutionException("Batching executor is closed"); + if (outstanding == capacity) return false; + Object key = null; + Throwable failure = null; + try { key = operation.batchKey(request); } + catch (Throwable error) { failure = error; } + Worker launches; + lock.lock(); + try { + if (closed) throw new RejectedExecutionException("Batching executor is closed"); + if (outstanding == capacity) return false; + int previousTail = pendingTail == 0 ? capacity - 1 : pendingTail - 1; + if (pendingSize != 0 && failure == null && key != null + && pendingFailures[previousTail] == null && pendingKeys[previousTail] == key + && tailGroupSize < batchSize) { + tailGroupSize++; + } else { + pendingGroups++; + tailGroupSize = 1; + } + pending[pendingTail] = request; + pendingKeys[pendingTail] = key; + pendingFailures[pendingTail] = failure; + pendingTail = advance(pendingTail); + pendingSize++; + outstanding++; + launches = reserveWorkers(); + } finally { lock.unlock(); } + launch(launches); return true; } + private int advance(int position) { return position == capacity - 1 ? 0 : position + 1; } + + /** Called under lock; links reusable tasks rather than allocating a launch list. */ + private Worker reserveWorkers() { + // Track compatible groups at admission rather than scanning the queue or + // treating unrelated models as one batch and underutilizing the budget. + int target = (int) Math.min(workerLimit, (long) busyWorkers + readySize + pendingGroups); + Worker first = null, last = null; + while (activeWorkers < target) { + Worker worker = idleWorkers.removeFirst(); + activeWorkers++; + if (last == null) first = worker; + else last.nextLaunch = worker; + last = worker; + } + return first; + } + + /** Executor calls and operation callbacks are always outside lock. */ + private void launch(Worker first) { + while (first != null) { + Worker worker = first; + first = worker.nextLaunch; + worker.nextLaunch = null; + worker.launchingThread.set(Thread.currentThread()); + try { executor.execute(worker); } + catch (RuntimeException | Error failure) { dispatchFailed(worker, failure); } + finally { + // A host worker may later dequeue a task that it submitted. + // Only execution on the submitting call stack is inline. CAS + // cannot clear the marker of a reused task launched elsewhere. + worker.launchingThread.compareAndSet(Thread.currentThread(), null); + } + } + } + + private void dispatchFailed(Worker worker, Throwable failure) { + // Allocation is confined to dispatch failure, never ordinary submission. + List failed = null; + lock.lock(); + try { + worker.launchingThread.set(null); + activeWorkers--; + idleWorkers.addLast(worker); + if (activeWorkers == 0) { + failed = new ArrayList<>(pendingSize + readySize); + while (readySize != 0) failed.add(takeReady()); + while (pendingSize != 0) failed.add(takePending()); + pendingGroups = 0; + } + signalDrained(); + } finally { lock.unlock(); } + if (failed != null) for (T request : failed) finish(request, failure); + // Another accepted drain task, if present, will process the queues. + } + + @SuppressWarnings("unchecked") + private T takePending() { + T value = (T) pending[pendingHead]; + pending[pendingHead] = null; + pendingKeys[pendingHead] = null; + pendingFailures[pendingHead] = null; + pendingHead = advance(pendingHead); + pendingSize--; + if (pendingSize == 0) tailGroupSize = 0; + return value; + } + + @SuppressWarnings("unchecked") + private T takeReady() { + T value = (T) ready[readyHead]; + ready[readyHead] = null; + readyHead = advance(readyHead); + readySize--; + return value; + } + private final class Worker implements Runnable { - final ArrayList> slots = new ArrayList<>(batchSize); final ArrayList requests = new ArrayList<>(batchSize); final List borrowedRequests = Collections.unmodifiableList(requests); - final long[] histogram = new long[batchSize + 1]; + final AtomicLongArray histogram = new AtomicLongArray(batchSize + 1); + Worker nextLaunch; + final AtomicReference launchingThread = new AtomicReference<>(); + boolean busy, alreadyPrepared; + Throwable preparationFailure; + + /** Acquires the next work, or atomically releases this task's worker lease. */ + boolean takeWork() { + lock.lock(); + try { + if (busy) { busyWorkers--; busy = false; } + requests.clear(); + preparationFailure = null; + if (readySize != 0) { + requests.add(takeReady()); + alreadyPrepared = true; + } else if (pendingSize != 0) { + Object key = pendingKeys[pendingHead]; + preparationFailure = pendingFailures[pendingHead]; + requests.add(takePending()); + alreadyPrepared = false; + while (preparationFailure == null && key != null + && requests.size() < batchSize && pendingSize != 0 + && pendingKeys[pendingHead] == key && pendingFailures[pendingHead] == null) + requests.add(takePending()); + pendingGroups--; + } else { + activeWorkers--; + idleWorkers.addLast(this); + signalDrained(); + return false; + } + busyWorkers++; + busy = true; + return true; + } finally { lock.unlock(); } + } @Override public void run() { - inWorker.set(Boolean.TRUE); + if (Thread.currentThread() == launchingThread.get()) + throw new RejectedExecutionException("Executor must schedule work asynchronously"); + launchingThread.set(null); + BatchingExecutor previous = CURRENT.get(); + CURRENT.set(BatchingExecutor.this); try { - for (;;) { - if (admission.get() == CLOSED) return; - work.acquireUninterruptibly(); - Slot next = ready.poll(); - if (next != null) { - Throwable failure = null; - try { operation.execute(next.request); } - catch (Throwable error) { failure = error; } - finish(next, failure); - continue; + while (takeWork()) { + Throwable failure = preparationFailure; + if (!alreadyPrepared && failure == null) { + try { + operation.prepareBatch(borrowedRequests); + histogram.incrementAndGet(requests.size()); + } catch (Throwable error) { failure = error; } } - drain.lock(); - try { - next = pending.poll(); - if (next == null) continue; - slots.add(next); - requests.add(next.request); - Object key = operation.batchKey(next.request); - while (key != null && slots.size() < batchSize) { - Slot head = pending.peek(); - if (head == null || operation.batchKey(head.request) != key) break; - next = pending.remove(); - // Each submission signaled one pending request. This worker - // already consumed the first signal; retire the additional - // ones as their requests are folded into this preparation. - // Another worker may already have consumed a signal and be - // waiting for drain, so acquisition must never block here. - work.tryAcquire(); - slots.add(next); - requests.add(next.request); - } - } catch (Throwable failure) { - // A key callback failure cannot strand already removed work. - for (Slot slot : slots) finish(slot, failure); - slots.clear(); requests.clear(); + if (failure != null) { + for (T request : requests) finish(request, failure); continue; - } finally { drain.unlock(); } - Throwable failure = null; - try { operation.prepareBatch(borrowedRequests); histogram[slots.size()]++; } - catch (Throwable error) { failure = error; } - if (failure == null) { - int size = slots.size(); - // Keep one search on the preparing worker for every - // group size. Singleton and full-batch traffic use the - // same execution path; other workers can take the rest. - Slot first = slots.get(0); - for (int i = 1; i < size; i++) ready.add(slots.get(i)); - if (size > 1) work.release(size - 1); - try { operation.execute(first.request); } - catch (Throwable error) { failure = error; } - finish(first, failure); - } else { - for (Slot slot : slots) finish(slot, failure); } - slots.clear(); requests.clear(); + if (requests.size() > 1) { + Worker launches; + lock.lock(); + try { + for (int i = 1; i < requests.size(); i++) { + ready[readyTail] = requests.get(i); + readyTail = advance(readyTail); + readySize++; + } + launches = reserveWorkers(); + } finally { lock.unlock(); } + launch(launches); + } + T first = requests.get(0); + try { operation.execute(first); } + catch (Throwable error) { failure = error; } + finish(first, failure); } } finally { - inWorker.remove(); - stopped.countDown(); + CURRENT.set(previous); // Do not retain the service/model on host threads. } } } - private void finish(Slot slot, Throwable failure) { - T request = slot.request; - slot.request = null; - available.add(slot); - long count = admission.decrementAndGet(); + private void finish(T request, Throwable failure) { + lock.lock(); + try { + // Release admission before the callback may recycle the request. + callbacks++; + outstanding--; + } finally { lock.unlock(); } + BatchingExecutor previous = CURRENT.get(); + boolean entering = previous != this; + if (entering) CURRENT.set(this); try { operation.complete(request, failure); } - catch (Throwable error) { callbackFailure.compareAndSet(null, error); } - if (count == CLOSED) work.release(workerCount); + catch (Throwable error) { + firstCallbackFailure.compareAndSet(null, error); + callbackFailures.incrementAndGet(); + } finally { + if (entering) CURRENT.set(previous); + lock.lock(); + try { callbacks--; signalDrained(); } + finally { lock.unlock(); } + } } - /** Snapshot of successful preparation groups; exact when all requests have completed. */ + private void signalDrained() { + if (closed && outstanding == 0 && activeWorkers == 0 && callbacks == 0) + drained.signalAll(); + } + + /** Atomic snapshot; exact after all accepted requests have completed. */ public long[] batchHistogram() { long[] result = new long[batchSize + 1]; for (Worker worker : workers) - for (int i = 1; i < result.length; i++) result[i] += worker.histogram[i]; + for (int i = 1; i < result.length; i++) result[i] += worker.histogram.get(i); return result; } - /** Accepted requests in either queue or preparation/execution, excluding completion callbacks. */ - public int outstanding() { return (int) (admission.get() & Long.MAX_VALUE); } + /** Accepted work, excluding completion callbacks. */ + public int outstanding() { return outstanding; } + + /** Number of completion callbacks that threw, without blocking later submissions. */ + public long completionFailureCount() { return callbackFailures.get(); } + /** First callback exception for monitoring, or null when no callback failed. */ + public Throwable firstCompletionFailure() { return firstCallbackFailure.get(); } + + /** Drains accepted work, preserves interrupts, then reports any callback failure. */ @Override public void close() { - if (Boolean.TRUE.equals(inWorker.get())) - throw new IllegalStateException("Cannot close a batching executor from its worker callback"); - admission.getAndUpdate(value -> value | CLOSED); - work.release(workerCount); - awaitStopped(); - if (callbackFailure.get() != null) - throw new IllegalStateException("A completion callback failed", callbackFailure.get()); - } - - private void awaitStopped() { - boolean interrupted = false; - for (;;) { - try { stopped.await(); break; } - catch (InterruptedException e) { interrupted = true; } - } - if (interrupted) Thread.currentThread().interrupt(); + if (CURRENT.get() == this) + throw new IllegalStateException("Cannot close a batching executor from its operation or callback"); + lock.lock(); + try { + closed = true; + while (outstanding != 0 || activeWorkers != 0 || callbacks != 0) + drained.awaitUninterruptibly(); + } finally { lock.unlock(); } + Throwable failure = firstCallbackFailure.get(); + if (failure != null) + throw new IllegalStateException(completionFailureCount() + " completion callbacks failed", failure); } } diff --git a/jvector-tests/src/test/java/io/github/jbellis/jvector/util/TestBatchingExecutor.java b/jvector-tests/src/test/java/io/github/jbellis/jvector/util/TestBatchingExecutor.java index 5c14c7abb..db4f4d00b 100644 --- a/jvector-tests/src/test/java/io/github/jbellis/jvector/util/TestBatchingExecutor.java +++ b/jvector-tests/src/test/java/io/github/jbellis/jvector/util/TestBatchingExecutor.java @@ -65,7 +65,7 @@ public void complete(Integer value, Throwable error) { } @Test(timeout=15000) - public void drainingABatchDoesNotAccumulateDuplicateWakeups() throws Exception { + public void drainingABatchDoesNotRetainIdleSharedExecutorThreads() throws Exception { var executor = Executors.newSingleThreadExecutor(); var firstEntered = new CountDownLatch(1); var firstRelease = new CountDownLatch(1); @@ -89,18 +89,294 @@ public void complete(Integer value, Throwable error) { for (int i = 1; i < 9; i++) assertTrue(service.trySubmit(i)); firstRelease.countDown(); assertTrue(batchEntered.await(5, TimeUnit.SECONDS)); - // Seven prepared requests remain runnable. Counting their original - // pending signals again would grow notifications under continuous load. - var field = BatchingExecutor.class.getDeclaredField("work"); - field.setAccessible(true); - assertEquals(7, ((Semaphore) field.get(service)).availablePermits()); batchRelease.countDown(); assertTrue(done.await(5, TimeUnit.SECONDS)); assertNull(failure.get()); + assertEquals(Integer.valueOf(42), executor.submit(() -> 42).get(5, TimeUnit.SECONDS)); } finally { firstRelease.countDown(); batchRelease.countDown(); } } finally { firstRelease.countDown(); batchRelease.countDown(); executor.shutdownNow(); } } + @Test(timeout=15000) + public void sharedExecutorNeedsNoPermanentlyReservedThreads() throws Exception { + var executor = Executors.newSingleThreadExecutor(); + var completed = new CountDownLatch(1); + try (var service = new BatchingExecutor(4, 8, 8, executor, new IntOperation() { + public void complete(Integer request, Throwable error) { assertNull(error); completed.countDown(); } + })) { + assertTrue(service.trySubmit(1)); + assertTrue(completed.await(5, TimeUnit.SECONDS)); + assertEquals(Integer.valueOf(7), executor.submit(() -> 7).get(5, TimeUnit.SECONDS)); + } finally { executor.shutdownNow(); } + } + + @Test(timeout=15000) + public void completionFailureIsContainedAndReportedWithoutPoisoningLaterRequests() throws Exception { + var executor = Executors.newSingleThreadExecutor(); + var error = new IllegalStateException("callback failure"); + var second = new CountDownLatch(1); + var completed = new AtomicInteger(); + var service = new BatchingExecutor(1, 8, 8, executor, new IntOperation() { + public void complete(Integer request, Throwable failure) { + assertNull(failure); + completed.incrementAndGet(); + if (request == 0) throw error; + second.countDown(); + } + }); + try { + assertTrue(service.trySubmit(0)); + assertTrue(service.trySubmit(1)); + assertTrue(second.await(5, TimeUnit.SECONDS)); + assertEquals(2, completed.get()); + assertEquals(1, service.completionFailureCount()); + assertSame(error, service.firstCompletionFailure()); + var failure = assertThrows(IllegalStateException.class, service::close); + assertSame(error, failure.getCause()); + assertEquals(0, service.outstanding()); + } finally { executor.shutdownNow(); } + } + + @Test(timeout=15000) + public void keyFailureCompletionDoesNotHoldInternalLock() throws Exception { + var executor = Executors.newSingleThreadExecutor(); + var submitter = Executors.newSingleThreadExecutor(); + var done = new CountDownLatch(2); + var first = new IllegalArgumentException("key failure"); + var errors = new AtomicReference(); + var reference = new AtomicReference>(); + var service = new BatchingExecutor(1, 8, 8, executor, new IntOperation() { + public Object batchKey(Integer request) { if (request == 0) throw first; return null; } + public void complete(Integer request, Throwable failure) { + try { + if (request == 0) { + assertSame(first, failure); + assertTrue(submitter.submit(() -> reference.get().trySubmit(1)).get(5, TimeUnit.SECONDS)); + } else assertNull(failure); + } catch (Throwable error) { errors.compareAndSet(null, error); } + finally { done.countDown(); } + } + }); + reference.set(service); + try (service) { + assertTrue(service.trySubmit(0)); + assertTrue(done.await(7, TimeUnit.SECONDS)); + assertNull(errors.get()); + } finally { submitter.shutdownNow(); executor.shutdownNow(); } + } + + @Test(timeout=15000) + public void rejectedDispatchCompletesAcceptedWorkAndLaterSubmissionCanSucceed() throws Exception { + var executor = Executors.newSingleThreadExecutor(); + var launches = new AtomicInteger(); + Executor sometimesRejects = work -> { + if (launches.getAndIncrement() == 0) throw new RejectedExecutionException("busy host"); + executor.execute(work); + }; + var failures = new AtomicInteger(); + var completed = new CountDownLatch(2); + try (var service = new BatchingExecutor(1, 8, 8, sometimesRejects, new IntOperation() { + public void complete(Integer request, Throwable failure) { + if (failure != null) { assertTrue(failure instanceof RejectedExecutionException); failures.incrementAndGet(); } + completed.countDown(); + } + })) { + assertTrue(service.trySubmit(0)); + assertTrue(service.trySubmit(1)); + assertTrue(completed.await(5, TimeUnit.SECONDS)); + assertEquals(1, failures.get()); + assertEquals(0, service.completionFailureCount()); + } finally { executor.shutdownNow(); } + } + + @Test(timeout=15000) + public void directExecutorDoesNotMakeAsynchronousSubmissionExecuteInline() { + var executions = new AtomicInteger(); + var error = new AtomicReference(); + try (var service = new BatchingExecutor(1, 8, 8, Runnable::run, new IntOperation() { + public void execute(Integer request) { executions.incrementAndGet(); } + public void complete(Integer request, Throwable failure) { error.set(failure); } + })) { + assertTrue(service.trySubmit(0)); + assertEquals(0, executions.get()); + assertTrue(error.get() instanceof RejectedExecutionException); + assertEquals(0, service.outstanding()); + } + } + + @Test + public void knownDiscardAndCallerRunsPoliciesAreRejectedBeforeAdmission() { + for (var policy : List.of(new ThreadPoolExecutor.DiscardPolicy(), + new ThreadPoolExecutor.DiscardOldestPolicy(), new ThreadPoolExecutor.CallerRunsPolicy())) { + var executor = new ThreadPoolExecutor(1, 1, 0, TimeUnit.MILLISECONDS, + new ArrayBlockingQueue(1), policy); + try { + assertThrows(IllegalArgumentException.class, + () -> new BatchingExecutor(1, 8, 8, executor, new IntOperation() { + public void complete(Integer request, Throwable failure) {} + })); + } finally { executor.shutdownNow(); } + } + } + + @Test(timeout=15000) + public void deferredTaskMayLaterRunOnItsOriginalSubmittingThread() { + var scheduled = new ArrayBlockingQueue(1); + var executions = new AtomicInteger(); + var completed = new AtomicInteger(); + try (var service = new BatchingExecutor(1, 8, 8, scheduled::add, new IntOperation() { + public void execute(Integer request) { executions.incrementAndGet(); } + public void complete(Integer request, Throwable failure) { assertNull(failure); completed.incrementAndGet(); } + })) { + assertTrue(service.trySubmit(1)); + assertEquals(0, executions.get()); + scheduled.remove().run(); + assertEquals(1, executions.get()); + assertEquals(1, completed.get()); + } + } + + @Test(timeout=15000) + public void incompatibleQueuedRequestsUseTheFullWorkerBudget() throws Exception { + var executor = Executors.newFixedThreadPool(4); + var scheduled = new ArrayBlockingQueue(4); + var entered = new CountDownLatch(4); + var done = new CountDownLatch(4); + var failure = new AtomicReference(); + try (var service = new BatchingExecutor(4, 8, 8, scheduled::add, new IntOperation() { + public Object batchKey(Integer request) { return null; } + public void execute(Integer request) throws Exception { + entered.countDown(); + if (!entered.await(5, TimeUnit.SECONDS)) throw new IllegalStateException("Budget underutilized"); + } + public void complete(Integer request, Throwable error) { + if (error != null) failure.compareAndSet(null, error); + done.countDown(); + } + })) { + for (int i = 0; i < 4; i++) assertTrue(service.trySubmit(i)); + assertEquals(4, scheduled.size()); + for (int i = 0; i < 4; i++) executor.execute(scheduled.remove()); + assertTrue(done.await(7, TimeUnit.SECONDS)); + assertNull(failure.get()); + } finally { executor.shutdownNow(); } + } + + @Test(timeout=15000) + public void closeWaitsForCallbacksAndPreservesInterruptStatus() throws Exception { + var executor = Executors.newSingleThreadExecutor(); + var closer = Executors.newSingleThreadExecutor(); + var entered = new CountDownLatch(1); + var release = new CountDownLatch(1); + var service = new BatchingExecutor(1, 8, 8, executor, new IntOperation() { + public void complete(Integer request, Throwable failure) { + entered.countDown(); + boolean interrupted = false; + for (;;) { + try { release.await(); break; } + catch (InterruptedException error) { interrupted = true; } + } + if (interrupted) Thread.currentThread().interrupt(); + } + }); + try { + assertTrue(service.trySubmit(0)); + assertTrue(entered.await(5, TimeUnit.SECONDS)); + assertEquals(0, service.outstanding()); + var closed = closer.submit(() -> { + Thread.currentThread().interrupt(); + service.close(); + return Thread.currentThread().isInterrupted(); + }); + assertThrows(TimeoutException.class, () -> closed.get(20, TimeUnit.MILLISECONDS)); + release.countDown(); + assertTrue(closed.get(5, TimeUnit.SECONDS)); + } finally { release.countDown(); service.close(); executor.shutdownNow(); closer.shutdownNow(); } + } + + @Test(timeout=15000) + public void concurrentSubmitAndCloseCompleteEveryAcceptedRequestExactlyOnce() throws Exception { + var executor = Executors.newFixedThreadPool(4); + var producers = Executors.newFixedThreadPool(4); + var accepted = new AtomicInteger(); + var completions = new AtomicIntegerArray(2000); + var first = new CountDownLatch(1); + var start = new CountDownLatch(1); + var failure = new AtomicReference(); + var service = new BatchingExecutor(4, 32, 8, executor, new IntOperation() { + public void complete(Integer request, Throwable error) { + if (error != null) failure.compareAndSet(null, error); + if (completions.incrementAndGet(request) != 1) + failure.compareAndSet(null, new AssertionError("Duplicate completion")); + } + }); + try { + var futures = new java.util.ArrayList>(); + for (int producer = 0; producer < 4; producer++) { + int offset = producer * 500; + futures.add(producers.submit(() -> { + start.await(); + for (int i = 0; i < 500; i++) { + try { + while (!service.trySubmit(offset + i)) Thread.yield(); + accepted.incrementAndGet(); first.countDown(); + } catch (RejectedExecutionException closed) { return null; } + } + return null; + })); + } + start.countDown(); + assertTrue(first.await(5, TimeUnit.SECONDS)); + service.close(); + for (var future : futures) future.get(5, TimeUnit.SECONDS); + assertNull(failure.get()); + int completed = 0; + for (int i = 0; i < 2000; i++) completed += completions.get(i); + assertEquals(accepted.get(), completed); + } finally { start.countDown(); service.close(); producers.shutdownNow(); executor.shutdownNow(); } + } + + @Test(timeout=15000) + public void boundedRingsWrapUnderConcurrentMixedModelTraffic() throws Exception { + int count = 20000; + var executor = Executors.newFixedThreadPool(4); + var producers = Executors.newFixedThreadPool(4); + Object[] keys = {new Object(), new Object()}; + var done = new CountDownLatch(count); + var completed = new AtomicIntegerArray(count); + var failure = new AtomicReference(); + try (var service = new BatchingExecutor(4, 32, 8, executor, new IntOperation() { + public Object batchKey(Integer request) { return keys[(request / 3) & 1]; } + public void prepareBatch(List values) { + Object key = batchKey(values.get(0)); + for (Integer value : values) assertSame(key, batchKey(value)); + } + public void complete(Integer request, Throwable error) { + if (error != null) failure.compareAndSet(null, error); + if (completed.incrementAndGet(request) != 1) + failure.compareAndSet(null, new AssertionError("Duplicate completion")); + done.countDown(); + } + })) { + var futures = new java.util.ArrayList>(); + for (int producer = 0; producer < 4; producer++) { + int offset = producer * (count / 4); + futures.add(producers.submit(() -> { + for (int i = 0; i < count / 4; i++) + while (!service.trySubmit(offset + i)) Thread.yield(); + })); + } + for (var future : futures) future.get(10, TimeUnit.SECONDS); + assertTrue(done.await(3, TimeUnit.SECONDS)); + assertNull(failure.get()); + long queries = 0; + var histogram = service.batchHistogram(); + for (int i = 1; i < histogram.length; i++) queries += i * histogram[i]; + assertEquals(count, queries); + for (int i = 0; i < count; i++) assertEquals(1, completed.get(i)); + } finally { executor.shutdownNow(); producers.shutdownNow(); } + } + @Test(timeout=15000) public void isolatedRequestsStartImmediatelyAtCapacityEightAndRecordsAreReused() throws Exception { var executor = Executors.newFixedThreadPool(2); From c89b5b744bef9ace4bef5fd3937021a2c12c7a99 Mon Sep 17 00:00:00 2001 From: Ted Willke Date: Wed, 4 Nov 2026 03:18:08 +0000 Subject: [PATCH 136/243] Guard queued query ownership and reuse bounded execution capacity --- .../github/jbellis/jvector/example/Grid.java | 19 ++++++++++-- .../benchmarks/QueuedQueryExecution.java | 30 ++++++++++++++----- .../example/TestDeferredQueryReporting.java | 16 ++++++++++ 3 files changed, 55 insertions(+), 10 deletions(-) diff --git a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/Grid.java b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/Grid.java index c66bcdd7e..6bfbc805d 100644 --- a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/Grid.java +++ b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/Grid.java @@ -1399,12 +1399,25 @@ public int queryWorkerCount() { /** Shared across this configured index's warmups and all query measurements. */ public synchronized java.util.concurrent.ExecutorService queryWorkers() { if (closed) throw new IllegalStateException("Configured system is closed"); - if (queuedQueryWorkers == null) - queuedQueryWorkers = java.util.concurrent.Executors.newFixedThreadPool(queryWorkerCount()); + if (queuedQueryWorkers == null) { + int count = queryWorkerCount(); + // Drain tasks are reusable; an array queue avoids a linked queue + // node allocation on every isolated query activation. + queuedQueryWorkers = new java.util.concurrent.ThreadPoolExecutor(count, count, + 0L, java.util.concurrent.TimeUnit.MILLISECONDS, + new java.util.concurrent.ArrayBlockingQueue(count), + new java.util.concurrent.ThreadPoolExecutor.AbortPolicy()); + } return queuedQueryWorkers; } - public void configureQueryExecution(String mode, int batchSize) { + /** Select before launching queries; workers and search-owned state are reused. */ + public synchronized void configureQueryExecution(String mode, int batchSize) { + if (closed) throw new IllegalStateException("Configured system is closed"); + if (!"direct".equals(mode) && !"queued".equals(mode)) + throw new IllegalArgumentException("Query execution must be direct or queued"); + if (batchSize <= 0 || batchSize == Integer.MAX_VALUE) + throw new IllegalArgumentException("Query batch size must be positive and fit its histogram"); queuedExecution = "queued".equals(mode); queryBatchSize = batchSize; if (queuedExecution && queryPreparer == null) diff --git a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/benchmarks/QueuedQueryExecution.java b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/benchmarks/QueuedQueryExecution.java index efe9d3e8b..e430cb312 100644 --- a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/benchmarks/QueuedQueryExecution.java +++ b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/benchmarks/QueuedQueryExecution.java @@ -25,9 +25,9 @@ import org.agrona.collections.LongArrayList; import java.util.Arrays; import java.util.List; +import java.util.Objects; import java.util.concurrent.ArrayBlockingQueue; import java.util.concurrent.CountDownLatch; -import java.util.concurrent.ExecutorService; import java.util.concurrent.Semaphore; import java.util.concurrent.atomic.AtomicReference; import java.util.concurrent.atomic.LongAdder; @@ -44,7 +44,6 @@ public final class QueuedQueryExecution implements AutoCloseable { private final int topK, rerankK; private final boolean usePruning; private final QueryPreparer preparer; - private final ExecutorService workers; private final BatchingExecutor execution; private final ArrayBlockingQueue free; private LongArrayList measuredResponseTimes = new LongArrayList(); @@ -87,7 +86,10 @@ public static final class Pass { } public QueuedQueryExecution(ConfiguredSystem system, int topK, int rerankK, boolean usePruning) { - this.system = system; this.topK = topK; this.rerankK = rerankK; this.usePruning = usePruning; + if (topK <= 0 || rerankK < topK) + throw new IllegalArgumentException("Require 0 < topK <= rerankK"); + this.system = Objects.requireNonNull(system, "system"); + this.topK = topK; this.rerankK = rerankK; this.usePruning = usePruning; this.preparer = system.queryPreparer(); int cores = system.queryWorkerCount(); int batchSize = system.queryBatchSize(); @@ -99,8 +101,8 @@ public QueuedQueryExecution(ConfiguredSystem system, int topK, int rerankK, bool free = new ArrayBlockingQueue<>(capacity); for (int i = 0; i < capacity; i++) free.add(new Request(preparer == null ? null : preparer.newState())); var rows = ThreadLocal.withInitial(() -> new Rows(batchSize)); - workers = system.queryWorkers(); - execution = new BatchingExecutor<>(cores, capacity, batchSize, workers, + var workers = system.queryWorkers(); + execution = new BatchingExecutor<>(cores, capacity, batchSize, workers, new BatchingExecutor.Operation() { public Object batchKey(Request request) { return preparer != null && preparer.supportsBatchPreparation() ? preparer : null; @@ -162,13 +164,26 @@ private void release(Request request) { free.add(request); } + private void submit(Request request) { + boolean accepted = false; + try { + accepted = execution.trySubmit(request); + if (!accepted) throw new IllegalStateException("Admission accounting mismatch"); + } finally { + // A throwing or rejected submission never transferred ownership. + if (!accepted) release(request); + } + } + public Pass runPass(int count, IntFunction> source, QueryTimeRecorder recorder, boolean capture) { + if (count < 0) throw new IllegalArgumentException("Query count must be nonnegative"); + Objects.requireNonNull(source, "source"); Pass pass = new Pass(count, source, recorder == null ? NO_TIMING : recorder, capture); for (int i = 0; i < count; i++) { long submittedAt = System.nanoTime(); // Includes waiting for admission. Request request = acquire(); request.pass = pass; request.ordinal = i; request.submittedAt = submittedAt; - if (!execution.trySubmit(request)) { release(request); throw new IllegalStateException("Admission accounting mismatch"); } + submit(request); } boolean interrupted = false; for (;;) { @@ -183,10 +198,11 @@ public Pass runPass(int count, IntFunction> source, QueryTimeReco /** One submitted request with normal queue/batch capacity; not a singleton setting. */ public SearchResult executeSingle(VectorFloat query) { + Objects.requireNonNull(query, "query"); long submittedAt = System.nanoTime(); Request request = acquire(); request.pass = null; request.query = query; request.submittedAt = submittedAt; - if (!execution.trySubmit(request)) { release(request); throw new IllegalStateException("Admission accounting mismatch"); } + submit(request); request.completed.acquireUninterruptibly(); try { rethrow(request.failure); return request.result; } finally { release(request); } diff --git a/jvector-examples/src/test/java/io/github/jbellis/jvector/example/TestDeferredQueryReporting.java b/jvector-examples/src/test/java/io/github/jbellis/jvector/example/TestDeferredQueryReporting.java index e53069430..36482ce29 100644 --- a/jvector-examples/src/test/java/io/github/jbellis/jvector/example/TestDeferredQueryReporting.java +++ b/jvector-examples/src/test/java/io/github/jbellis/jvector/example/TestDeferredQueryReporting.java @@ -139,6 +139,22 @@ public List runBenchmark(Grid.ConfiguredSystem system, int k, int r, } } + @Test(timeout=10000) + public void rejectedSubmissionReturnsItsPooledRequestAndInputGuardsPrecedeAllocation() throws Exception { + try (var cs = system()) { + assertThrows(IllegalArgumentException.class, () -> new QueuedQueryExecution(cs, 0, 1, false)); + assertThrows(IllegalArgumentException.class, () -> new QueuedQueryExecution(cs, 2, 1, false)); + var queued = new QueuedQueryExecution(cs, 1, 1, false); + assertThrows(IllegalArgumentException.class, () -> queued.runPass(-1, i -> null, null, false)); + assertThrows(NullPointerException.class, () -> queued.runPass(0, null, null, false)); + assertThrows(NullPointerException.class, () -> queued.executeSingle(null)); + queued.close(); + var query = cs.getDataSet().getQueryVectors().get(0); + for (int i = 0; i < 100; i++) + assertThrows(java.util.concurrent.RejectedExecutionException.class, () -> queued.executeSingle(query)); + } + } + @Test public void completedSnapshotSurvivesExecutorCloseAndCanOnlyBeTransferredOnce() throws Exception { try (var cs = system()) { From d716ac60ecf3d506192993c379ce3439d0083959 Mon Sep 17 00:00:00 2001 From: Ted Willke Date: Wed, 4 Nov 2026 03:18:08 +0000 Subject: [PATCH 137/243] Make fused ASH and PQ studies reproducible with standalone BenchYAML --- jvector-examples/run-ash-pq-benchmark.sh | 169 +++++++++++ jvector-examples/run-ash-pq-study.py | 280 ++++++++++++++++++ .../jbellis/jvector/example/BenchYAML.java | 16 +- .../example/reporting/RunArtifacts.java | 4 +- .../example/reporting/RunReporting.java | 16 +- .../example/reporting/SysInfoWriter.java | 2 +- .../jvector/example/yaml/RunConfig.java | 18 +- .../StandaloneASHBenchmarkConfigTest.java | 168 +++++++++++ .../yaml-configs/ash-pq/ada002-2bit-ash.yml | 38 +++ .../yaml-configs/ash-pq/ada002-2bit-pq.yml | 38 +++ .../yaml-configs/ash-pq/ada002-4bit-ash.yml | 38 +++ .../yaml-configs/ash-pq/ada002-4bit-pq.yml | 38 +++ .../yaml-configs/ash-pq/catalog-1m.yml | 22 ++ .../yaml-configs/ash-pq/cohere-2bit-ash.yml | 38 +++ .../yaml-configs/ash-pq/cohere-2bit-pq.yml | 38 +++ .../yaml-configs/ash-pq/cohere-4bit-ash.yml | 38 +++ .../yaml-configs/ash-pq/cohere-4bit-pq.yml | 38 +++ .../yaml-configs/ash-pq/mpnet-2bit-ash.yml | 38 +++ .../yaml-configs/ash-pq/mpnet-2bit-pq.yml | 38 +++ .../yaml-configs/ash-pq/mpnet-4bit-ash.yml | 38 +++ .../yaml-configs/ash-pq/mpnet-4bit-pq.yml | 38 +++ .../ash-pq/openai3072-2bit-ash.yml | 38 +++ .../ash-pq/openai3072-2bit-pq.yml | 38 +++ .../ash-pq/openai3072-4bit-ash.yml | 38 +++ .../ash-pq/openai3072-4bit-pq.yml | 38 +++ .../yaml-configs/ash-pq/run-construction.yml | 21 ++ .../yaml-configs/ash-pq/run-direct.yml | 26 ++ .../yaml-configs/ash-pq/run-queued.yml | 30 ++ 28 files changed, 1363 insertions(+), 17 deletions(-) create mode 100755 jvector-examples/run-ash-pq-benchmark.sh create mode 100755 jvector-examples/run-ash-pq-study.py create mode 100644 jvector-examples/src/test/java/io/github/jbellis/jvector/example/benchmarks/StandaloneASHBenchmarkConfigTest.java create mode 100644 jvector-examples/yaml-configs/ash-pq/ada002-2bit-ash.yml create mode 100644 jvector-examples/yaml-configs/ash-pq/ada002-2bit-pq.yml create mode 100644 jvector-examples/yaml-configs/ash-pq/ada002-4bit-ash.yml create mode 100644 jvector-examples/yaml-configs/ash-pq/ada002-4bit-pq.yml create mode 100644 jvector-examples/yaml-configs/ash-pq/catalog-1m.yml create mode 100644 jvector-examples/yaml-configs/ash-pq/cohere-2bit-ash.yml create mode 100644 jvector-examples/yaml-configs/ash-pq/cohere-2bit-pq.yml create mode 100644 jvector-examples/yaml-configs/ash-pq/cohere-4bit-ash.yml create mode 100644 jvector-examples/yaml-configs/ash-pq/cohere-4bit-pq.yml create mode 100644 jvector-examples/yaml-configs/ash-pq/mpnet-2bit-ash.yml create mode 100644 jvector-examples/yaml-configs/ash-pq/mpnet-2bit-pq.yml create mode 100644 jvector-examples/yaml-configs/ash-pq/mpnet-4bit-ash.yml create mode 100644 jvector-examples/yaml-configs/ash-pq/mpnet-4bit-pq.yml create mode 100644 jvector-examples/yaml-configs/ash-pq/openai3072-2bit-ash.yml create mode 100644 jvector-examples/yaml-configs/ash-pq/openai3072-2bit-pq.yml create mode 100644 jvector-examples/yaml-configs/ash-pq/openai3072-4bit-ash.yml create mode 100644 jvector-examples/yaml-configs/ash-pq/openai3072-4bit-pq.yml create mode 100644 jvector-examples/yaml-configs/ash-pq/run-construction.yml create mode 100644 jvector-examples/yaml-configs/ash-pq/run-direct.yml create mode 100644 jvector-examples/yaml-configs/ash-pq/run-queued.yml diff --git a/jvector-examples/run-ash-pq-benchmark.sh b/jvector-examples/run-ash-pq-benchmark.sh new file mode 100755 index 000000000..19f1ad88c --- /dev/null +++ b/jvector-examples/run-ash-pq-benchmark.sh @@ -0,0 +1,169 @@ +#!/usr/bin/env bash +# 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. +set -euo pipefail + +if [[ $# != 5 ]]; then + echo "Usage: $0 " >&2 + echo "Example: $0 mpnet-2bit-ash.yml queued 8 0-7 /data/bench/mpnet-2bit-ash" >&2 + exit 2 +fi +REPO_ROOT="$(git -C "$(dirname "$0")" rev-parse --show-toplevel)" +CONFIG_NAME="$1" +MODE="$2" +CORES="$3" +CPU_LIST="$4" +CASE_DIRECTORY="$5" +case "$MODE" in construction|direct|queued) ;; *) echo "Invalid mode: $MODE" >&2; exit 2;; esac +if ! [[ "$CORES" =~ ^[1-9][0-9]*$ ]]; then echo "Physical core count must be positive" >&2; exit 2; fi +for command in java numactl lscpu sha256sum awk; do + command -v "$command" >/dev/null || { echo "Required command not found: $command" >&2; exit 2; } +done +if ! [[ "$CPU_LIST" =~ ^[0-9]+(-[0-9]+)?(,[0-9]+(-[0-9]+)?)*$ ]]; then + echo "CPU list must contain CPU numbers or ranges, such as 0 or 0-7." >&2 + exit 2 +fi +lscpu --parse=CPU,CORE,SOCKET,ONLINE | awk -F, -v selection="$CPU_LIST" -v budget="$CORES" ' + BEGIN { + count = split(selection, pieces, ",") + for (i = 1; i <= count; i++) { + n = split(pieces[i], bounds, "-") + first = bounds[1] + 0; last = n == 1 ? first : bounds[2] + 0 + if (last < first || last - first + 1 > budget) { bad = 1; exit 2 } + for (cpu = first; cpu <= last; cpu++) chosen[cpu] = 1 + } + } + !/^#/ && ($1 in chosen) { + if ($4 == "N") { bad = 1; exit 2 } + seen[$1] = 1; physical[$3 ":" $2] = 1 + } + END { + for (cpu in chosen) wanted++ + for (cpu in seen) found++ + for (core in physical) cores++ + if (bad || wanted != budget || found != budget || cores != budget) { + print "CPU selection must contain exactly the budgeted number of distinct online physical cores." > "/dev/stderr" + exit 2 + } + }' +DATA_ROOT="${JVECTOR_BENCH_DATA_DIR:-}" +if [[ "$DATA_ROOT" != /* || ! -d "$DATA_ROOT" ]]; then + echo "Set JVECTOR_BENCH_DATA_DIR to the absolute local dataset directory; see the catalog template." >&2 + exit 2 +fi +if [[ -n "${JAVA_TOOL_OPTIONS:-}" || -n "${JDK_JAVA_OPTIONS:-}" ]]; then + echo "Clear JAVA_TOOL_OPTIONS/JDK_JAVA_OPTIONS so the recorded Java command is complete." >&2 + exit 2 +fi +git -C "$REPO_ROOT" diff --quiet +git -C "$REPO_ROOT" diff --cached --quiet +SOURCE_REF="$(git -C "$REPO_ROOT" rev-parse HEAD)" +JAVA_BIN="$(command -v java)" +CONFIG="$REPO_ROOT/jvector-examples/yaml-configs/ash-pq/$CONFIG_NAME" +RUN_CONFIG="$REPO_ROOT/jvector-examples/yaml-configs/ash-pq/run-$MODE.yml" +[[ -f "$CONFIG" && "$CONFIG_NAME" != run-* && "$CONFIG_NAME" != catalog-* && "$CONFIG_NAME" != */* ]] || { + echo "Unknown index config: $CONFIG_NAME" >&2; exit 2; +} + +shopt -s nullglob +JARS=("$REPO_ROOT"/jvector-examples/target/jvector-examples-*-jar-with-dependencies.jar) +if [[ ${#JARS[@]} != 1 ]]; then + echo "Build exactly one examples assembly with Maven; see docs/ash-pq-benchmark.md." >&2 + exit 2 +fi +JAR="${JARS[0]}" +mkdir -p "$CASE_DIRECTORY" +CASE_DIRECTORY="$(cd "$CASE_DIRECTORY" && pwd)" +CASE_ID="$(sha256sum "$CONFIG" | cut -d ' ' -f 1)" +if [[ -f "$CASE_DIRECTORY/case-id.txt" && "$(cat "$CASE_DIRECTORY/case-id.txt")" != "$CASE_ID" ]]; then + echo "Case directory belongs to a different index configuration; use its matching YAML." >&2 + exit 2 +fi +if [[ "$MODE" == construction ]]; then + for cache in compressor_cache index_cache; do + if [[ -d "$CASE_DIRECTORY/$cache" ]] && [[ -n "$(ls -A "$CASE_DIRECTORY/$cache")" ]]; then + echo "Fresh construction requires empty caches; use a new case directory." >&2 + exit 2 + fi + done +else + [[ -d "$CASE_DIRECTORY/index_cache" && -d "$CASE_DIRECTORY/compressor_cache" ]] || { + echo "Construct this case first; query runs must reuse its model and graph." >&2; exit 2; + } +fi + +# The standard loader resolves metadata/catalogs relative to the working +# directory. Install only the checked-in local mapping, avoiding ambiguous +# duplicate aliases from other dataset catalogs. Numerical code is all in the JAR. +CASE_CONFIG="$CASE_DIRECTORY/jvector-examples/yaml-configs" +mkdir -p "$CASE_CONFIG/dataset-catalogs" +cp "$REPO_ROOT/jvector-examples/yaml-configs/dataset-metadata.yml" "$CASE_CONFIG/" +cp "$REPO_ROOT/jvector-examples/yaml-configs/datasets.yml" "$CASE_CONFIG/" +cp "$REPO_ROOT/jvector-examples/yaml-configs/ash-pq/catalog-1m.yml" "$CASE_CONFIG/dataset-catalogs/ash-pq-1m.yml" +printf '%s\n' "$CASE_ID" > "$CASE_DIRECTORY/case-id.txt" +mkdir -p "$CASE_DIRECTORY/runs" +RUN_DIRECTORY="$(mktemp -d "$CASE_DIRECTORY/runs/$MODE-c$CORES-XXXXXX")" +cp "$CONFIG" "$RUN_DIRECTORY/index.yml" +cp "$RUN_CONFIG" "$RUN_DIRECTORY/run.yml" +printf '%s\n' "$SOURCE_REF" > "$RUN_DIRECTORY/source-ref.txt" +sha256sum "$JAR" > "$RUN_DIRECTORY/jar.sha256" +"$JAVA_BIN" -version > "$RUN_DIRECTORY/java-version.txt" 2>&1 +lscpu --extended=CPU,CORE,SOCKET,NODE,ONLINE > "$RUN_DIRECTORY/cpu-topology.txt" + +# Native query kernels execute on the Java search workers. Construction has +# its own CPU budget for training and encoding. Avoid nested query thread pools. +NATIVE_THREADS=1 +if [[ "$MODE" == construction ]]; then NATIVE_THREADS="$CORES"; fi +export MKL_NUM_THREADS="$NATIVE_THREADS" OMP_NUM_THREADS="$NATIVE_THREADS" +export OPENBLAS_NUM_THREADS="$NATIVE_THREADS" GOTO_NUM_THREADS="$NATIVE_THREADS" +export MKL_DYNAMIC=FALSE ONEDNN_VERBOSE=0 + +NUMA_POLICY="${JVECTOR_BENCH_NUMA_POLICY:-bind:0}" +case "$NUMA_POLICY" in + bind:*) MEMORY_ARGS=("--membind=${NUMA_POLICY#bind:}");; + interleave:*) MEMORY_ARGS=("--interleave=${NUMA_POLICY#interleave:}");; + *) echo "JVECTOR_BENCH_NUMA_POLICY must be bind: or interleave:" >&2; exit 2;; +esac +JAVA_ARGS=(--add-modules=jdk.incubator.vector --enable-native-access=ALL-UNNAMED + -da -Xms2g "-Xmx${JVECTOR_BENCH_HEAP:-128g}" "-XX:ActiveProcessorCount=$CORES" + -Djvector.vectorization_provider=native -Djvector.parallel_writer=true + "-Djvector.physical_core_count=$CORES" "-Djvector.bench.queryThreads=$((4 * CORES))" + -Djvector.bench.verifyConcurrentResults=true -Djvector.bench.throughputMinSeconds=3.0 + "-Djvector.bench.sourceRef=$SOURCE_REF" "-Djvector.bench.runConfig=$RUN_CONFIG" + -cp "$JAR" io.github.jbellis.jvector.example.BenchYAML "$CONFIG") +LAUNCH=(numactl "--physcpubind=$CPU_LIST" "${MEMORY_ARGS[@]}" "$JAVA_BIN" "${JAVA_ARGS[@]}") +{ + printf 'cd %q\n' "$CASE_DIRECTORY" + printf 'JVECTOR_BENCH_DATA_DIR=%q ' "$DATA_ROOT" + printf 'MKL_NUM_THREADS=%q OMP_NUM_THREADS=%q OPENBLAS_NUM_THREADS=%q GOTO_NUM_THREADS=%q MKL_DYNAMIC=FALSE ONEDNN_VERBOSE=0 ' \ + "$MKL_NUM_THREADS" "$OMP_NUM_THREADS" "$OPENBLAS_NUM_THREADS" "$GOTO_NUM_THREADS" + printf '%q ' "${LAUNCH[@]}" + printf '\n' +} > "$RUN_DIRECTORY/command.sh" +echo "Artifacts: $RUN_DIRECTORY" +cd "$CASE_DIRECTORY" +"${LAUNCH[@]}" > "$RUN_DIRECTORY/console.log" 2>&1 & +JVM_PID=$! +printf '%s\n' "$JVM_PID" > "$RUN_DIRECTORY/jvm.pid" +trap 'kill "$JVM_PID" 2>/dev/null || true; wait "$JVM_PID" 2>/dev/null || true; exit 130' INT TERM +STATUS=0 +wait "$JVM_PID" || STATUS=$? +trap - INT TERM +cat "$RUN_DIRECTORY/console.log" +printf '%s\n' "$STATUS" > "$RUN_DIRECTORY/exit-code.txt" +if [[ "$STATUS" != 0 ]]; then exit "$STATUS"; fi +if [[ "$MODE" != construction ]] && grep -q 'Building graph index' "$RUN_DIRECTORY/console.log"; then + echo "Query invocation rebuilt an index; do not treat this as a cached-index result." >&2 + exit 1 +fi diff --git a/jvector-examples/run-ash-pq-study.py b/jvector-examples/run-ash-pq-study.py new file mode 100755 index 000000000..3a032d20f --- /dev/null +++ b/jvector-examples/run-ash-pq-study.py @@ -0,0 +1,280 @@ +#!/usr/bin/env python3 +"""Orchestrate the checked-in standalone BenchYAML recipe; no query loop or performance measurement code.""" +# 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. +from pathlib import Path +import argparse +import collections +import csv +import hashlib +import json +import os +import re +import shutil +import signal +import statistics +import sys +import subprocess +import time + +parser = argparse.ArgumentParser(description=__doc__) +parser.add_argument("--output", type=Path, required=True) +parser.add_argument("--data", type=Path, required=True) +parser.add_argument("--build-cores", type=int, default=48) +parser.add_argument("--build-cpus", default="0-47") +parser.add_argument("--build-nodes", default="0,1") +parser.add_argument("--one-core-cpu", default="0") +parser.add_argument("--eight-core-cpus", default="0-7") +parser.add_argument("--query-node", default="0") +parser.add_argument("--build-repetitions", type=int, default=5) +parser.add_argument("--query-repetitions", type=int, default=3) +parser.add_argument("--plan-only", action="store_true") +args = parser.parse_args() +if min(args.build_cores, args.build_repetitions, args.query_repetitions) <= 0: + parser.error("Core and repetition counts must be positive") +repo = Path(__file__).resolve().parent.parent +helper = repo / "jvector-examples/run-ash-pq-benchmark.sh" +source_ref = subprocess.check_output(["git", "rev-parse", "HEAD"], cwd=repo, text=True).strip() +jars = list((repo / "jvector-examples/target").glob("jvector-examples-*-jar-with-dependencies.jar")) +if len(jars) != 1: + parser.error("Build exactly one standalone examples assembly before starting") +jar_sha = hashlib.sha256(jars[0].read_bytes()).hexdigest() +output = args.output.resolve() +data = args.data.resolve() +if not data.is_dir(): + parser.error("Dataset directory does not exist") +jobs = [] +for group, (prefix, bits) in enumerate((p, b) for p in ("openai3072", "mpnet", "cohere", "ada002") for b in (2, 4)): + for repetition in range(1, args.build_repetitions + 1): + algorithms = ("pq", "ash") if (group + repetition) % 2 else ("ash", "pq") + for algorithm in algorithms: + jobs.append(dict(prefix=prefix, bits=bits, algorithm=algorithm, mode="construction", + cores=args.build_cores, cpus=args.build_cpus, repetition=repetition, + numa="interleave:" + args.build_nodes)) + for repetition in range(1, args.query_repetitions + 1): + algorithms = ("pq", "ash") if (group + repetition) % 2 else ("ash", "pq") + modes = ("direct", "queued") if repetition % 2 else ("queued", "direct") + for cores, cpus in ((1, args.one_core_cpu), (8, args.eight_core_cpus)): + for mode in modes: + for algorithm in algorithms: + jobs.append(dict(prefix=prefix, bits=bits, algorithm=algorithm, mode=mode, + cores=cores, cpus=cpus, repetition=repetition, + numa="bind:" + args.query_node)) +for job in jobs: + job["id"] = "{prefix}-{bits}bit-{algorithm}-{mode}-c{cores}-r{repetition}".format(**job) + case_repetition = job["repetition"] if job["mode"] == "construction" else 1 + job["case"] = str(output / "cases" / f"{job['prefix']}-{job['bits']}bit-{job['algorithm']}-r{case_repetition}") + job["config"] = f"{job['prefix']}-{job['bits']}bit-{job['algorithm']}.yml" +plan = dict(source_ref=source_ref, jar_sha256=jar_sha, data=str(data), jobs=jobs, + construction_jobs=sum(j["mode"] == "construction" for j in jobs), + query_jobs=sum(j["mode"] != "construction" for j in jobs)) +if args.plan_only: + print(json.dumps(plan, indent=2)) + raise SystemExit(0) +output.mkdir(parents=True, exist_ok=True) + + +def save(path, value): + temp = path.with_suffix(path.suffix + ".tmp") + temp.write_text(json.dumps(value, indent=2) + "\n") + temp.replace(path) + + +if (output / "plan.json").exists(): + if json.loads((output / "plan.json").read_text()) != plan: + raise RuntimeError("Existing campaign has different source/artifact/settings; use a new output directory") +else: + save(output / "plan.json", plan) +results_file = output / "results.json" +results = json.loads(results_file.read_text()) if results_file.exists() else [] +complete_ids = {result["job"]["id"] for result in results} +process = None + + +def stop(signum, frame): + if process is not None and process.poll() is None: + process.send_signal(signal.SIGTERM) # The checked-in shell forwards it to its JVM. + try: + process.wait(timeout=10) + except subprocess.TimeoutExpired: + process.kill() + save(output / "status.json", dict(state="stopped", completed=len(results), planned=len(jobs))) + raise SystemExit(128 + signum) + + +signal.signal(signal.SIGINT, stop) +signal.signal(signal.SIGTERM, stop) + + +def report_failure(kind, error, traceback): + if process is not None and process.poll() is None: + process.terminate() + try: + process.wait(timeout=10) + except subprocess.TimeoutExpired: + process.kill() + save(output / "status.json", dict(state="failed", error=str(error), + current=globals().get("job"), completed=len(results), planned=len(jobs))) + sys.__excepthook__(kind, error, traceback) + + +sys.excepthook = report_failure + + +def cpu_set(specification): + result = set() + for item in specification.split(","): + limits = item.split("-") + first, last = int(limits[0]), int(limits[-1]) + result.update(range(first, last + 1)) + return result + + +def wait_idle(): + """Read-only check between JVMs; never gates compilation inside a benchmark.""" + while True: + conflicts = [] + for directory in Path("/proc").iterdir(): + if not directory.name.isdigit() or int(directory.name) == os.getpid(): + continue + try: + command = (directory / "cmdline").read_bytes().replace(b"\0", b" ").decode(errors="replace") + if "/JetBrains/" not in command and re.search( + r"BenchYAML|DistancesASH|DistancesPQ|org\.junit\.runner|surefirebooter|(?:^|/)cc1plus(?: |$)", command): + conflicts.append(int(directory.name)) + except (FileNotFoundError, PermissionError, ProcessLookupError): + pass + if not conflicts: + return + save(output / "status.json", dict(state="waiting_for_other_work", pids=conflicts, + completed=len(results), planned=len(jobs))) + time.sleep(5) + + +def summarize(): + groups = collections.defaultdict(list) + for result in results: + job = result["job"] + for row in result["rows"]: + if job["mode"] == "construction" and float(row["overquery"]) != 1: + continue # Two overquery rows describe one construction invocation. + key = (job["prefix"], job["bits"], job["algorithm"], job["mode"], job["cores"], row["overquery"]) + groups[key].append(row) + records = [] + for key, rows in sorted(groups.items()): + record = dict(zip(("dataset", "bits", "algorithm", "mode", "cores", "overquery"), key)) + record["jvms"] = len(rows) + metrics = {} + for name in rows[0]: + if not name.startswith(("construction.", "search.")): + continue + if not all(row.get(name) for row in rows): + continue + values = [float(row[name]) for row in rows] + if any(marker in name for marker in ("p999", "p95", "p99")): + metrics[name] = dict(median_of_jvm_values=statistics.median(values), + minimum=min(values), maximum=max(values)) + else: + metrics[name] = dict(mean=statistics.mean(values), + sample_sd=statistics.stdev(values) if len(values) > 1 else None, + minimum=min(values), maximum=max(values)) + record["metrics"] = metrics + records.append(record) + save(output / "summary.json", records) + + +for job in jobs: + if job["id"] in complete_ids: + continue + wait_idle() + assert subprocess.check_output(["git", "rev-parse", "HEAD"], cwd=repo, text=True).strip() == source_ref, "Source changed during the campaign" + save(output / "status.json", dict(state="running", current=job, + completed=len(results), planned=len(jobs))) + case = Path(job["case"]) + before = set((case / "runs").glob("*")) if (case / "runs").exists() else set() + argv = ["bash", str(helper), job["config"], job["mode"], str(job["cores"]), job["cpus"], str(case)] + env = os.environ.copy() + env.update(JVECTOR_BENCH_DATA_DIR=str(data), JVECTOR_BENCH_NUMA_POLICY=job["numa"]) + log_path = output / (job["id"] + ".log") + print("START " + job["id"], flush=True) + libraries = set() + run_directory = None + with log_path.open("w") as log: + process = subprocess.Popen(argv, cwd=repo, env=env, stdout=log, stderr=subprocess.STDOUT) + started = time.monotonic() + while process.poll() is None: + if time.monotonic() - started > 1800: + process.terminate() + process.wait(timeout=15) + raise RuntimeError("BenchYAML exceeded the 30-minute process watchdog; inspect " + str(log_path)) + if run_directory is None: + created = set((case / "runs").glob("*")) - before + if len(created) == 1: + run_directory = created.pop() + if run_directory is not None and (run_directory / "jvm.pid").exists(): + pid = (run_directory / "jvm.pid").read_text().strip() + try: + proc = Path("/proc") / pid + status = (proc / "status").read_text() + if "\nName:\tjava\n" in "\n" + status: + mask = next(line.split(":", 1)[1].strip() for line in status.splitlines() + if line.startswith("Cpus_allowed_list:")) + assert cpu_set(mask) == cpu_set(job["cpus"]), (mask, job["cpus"]) + libraries.update(line.split(maxsplit=5)[-1] for line in (proc / "maps").read_text().splitlines() + if any(token in line for token in ("libjvector", "libdnnl", "libmkl", "libopenblas", "libnetlib"))) + with (run_directory / "placement.jsonl").open("a") as placement: + placement.write(json.dumps(dict(cpu_mask=mask, libraries=sorted(libraries))) + "\n") + except (FileNotFoundError, ProcessLookupError): + pass + time.sleep(1) + if process.returncode: + save(output / "status.json", dict(state="failed", job=job, log=str(log_path), exit_code=process.returncode)) + raise RuntimeError("Standalone BenchYAML failed: " + str(log_path)) + created = set((case / "runs").glob("*")) - before + assert len(created) == 1 + run_directory = created.pop() + assert (run_directory / "source-ref.txt").read_text().strip() == source_ref + assert (run_directory / "jar.sha256").read_text().split()[0] == jar_sha, "Artifact changed during the campaign" + # A new invocation contributes exactly one CSV, identified by its run mode + # and the source reference recorded by the normal reporting system. + csvs = sorted((case / "logging").rglob("experiments.csv"), key=lambda path: path.stat().st_mtime_ns) + assert csvs + rows = list(csv.DictReader(csvs[-1].open())) + assert len(rows) == 2 and {float(row["overquery"]) for row in rows} == {1, 2} + assert all("FUSED_" + job["algorithm"].upper() in row["feature_set"] and "NVQ_VECTORS" in row["feature_set"] for row in rows) + assert any("libjvector-" in library for library in libraries), "Native provider was not observed" + if job["mode"] == "construction": + assert all(row.get("construction.index_build_time_s") for row in rows) + else: + assert all(row.get("search.throughput.avg_qps") and row.get("search.latency.mean_ms") for row in rows) + reference = next(result for result in results if result["job"]["prefix"] == job["prefix"] + and result["job"]["bits"] == job["bits"] and result["job"]["algorithm"] == job["algorithm"] + and result["job"]["mode"] == "construction" and result["job"]["repetition"] == 1) + assert [row["search.accuracy.recall_at_10"] for row in rows] == [row["search.accuracy.recall_at_10"] for row in reference["rows"]], "Execution-policy recall regression" + result = dict(job=job, rows=rows, csv=str(csvs[-1]), artifacts=str(run_directory), libraries=sorted(libraries)) + results.append(result) + complete_ids.add(job["id"]) + save(results_file, results) + summarize() + if job["mode"] == "construction" and job["repetition"] != 1: + # Only these extra graphs have no future use. Keep first-build indexes, + # raw CSVs, metadata and console logs; remove no unrelated workspace. + assert case.parent == output / "cases" + for name in ("index_cache", "compressor_cache"): + shutil.rmtree(case / name) + print("PASS " + job["id"], flush=True) +save(output / "complete.json", dict(source_ref=source_ref, jar_sha256=jar_sha, + construction_jobs=plan["construction_jobs"], query_jobs=plan["query_jobs"], completed=len(results))) +save(output / "status.json", dict(state="complete", completed=len(results), planned=len(jobs))) +print("COMPLETE", flush=True) diff --git a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/BenchYAML.java b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/BenchYAML.java index 01b74bc4d..9ec01014a 100644 --- a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/BenchYAML.java +++ b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/BenchYAML.java @@ -45,8 +45,14 @@ public static void main(String[] args) throws IOException { } Grid.configureWriterForBenchmark(); + // A file path is not a dataset regular expression (and may contain + // spaces, brackets or platform-specific path separators). + List configNames = Arrays.stream(args).filter(Objects::nonNull) + .filter(s -> s.endsWith(".yml")).collect(Collectors.toList()); + String regex = Arrays.stream(args) .filter(Objects::nonNull) + .filter(s -> !s.endsWith(".yml")) .flatMap(s -> Arrays.stream(s.split("\\s"))) .filter(s -> !s.isEmpty()) .collect(Collectors.joining("|")); @@ -55,7 +61,8 @@ public static void main(String[] args) throws IOException { System.out.println("Heap space available is " + Runtime.getRuntime().maxMemory()); var datasetCollection = DatasetCollection.load(); - var datasetNames = datasetCollection.getAll().stream().filter(dn -> pattern.matcher(dn).find()).collect(Collectors.toList()); + var datasetNames = regex.isEmpty() && !configNames.isEmpty() ? List.of() + : datasetCollection.getAll().stream().filter(dn -> pattern.matcher(dn).find()).collect(Collectors.toList()); List allConfigs = new ArrayList<>(); @@ -73,14 +80,10 @@ public static void main(String[] args) throws IOException { } // get the list of YAML files from args - List configNames = (args == null) ? Collections.emptyList() : Arrays.stream(args) - .filter(Objects::nonNull) - .filter(s -> s.endsWith(".yml")) - .collect(Collectors.toList()); if (!configNames.isEmpty()) { for (var configName : configNames) { - MultiConfig config = MultiConfig.getDefaultConfig(configName); + MultiConfig config = MultiConfig.getConfig(configName); allConfigs.add(config); } } @@ -96,6 +99,7 @@ public static void main(String[] args) throws IOException { RunConfig runCfg = RunConfig.loadDefault(); artifacts = RunArtifacts.open(runCfg, allConfigs); } catch (java.io.FileNotFoundException e) { + if (System.getProperty("jvector.bench.runConfig") != null) throw e; // Legacy yamlSchemaVersion "0" behavior: no run-config.yml // - logging disabled // - console shows compute selection diff --git a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/reporting/RunArtifacts.java b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/reporting/RunArtifacts.java index 3e91d51b3..4d697cd7a 100644 --- a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/reporting/RunArtifacts.java +++ b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/reporting/RunArtifacts.java @@ -52,8 +52,8 @@ private RunArtifacts withQueryExecution(RunConfig.QueryExecution settings) { if (settings == null) return this; if (!"direct".equals(settings.mode) && !"queued".equals(settings.mode)) throw new IllegalArgumentException("queryExecution.mode must be direct or queued"); - if (settings.maxBatchSize <= 0) - throw new IllegalArgumentException("queryExecution.maxBatchSize must be positive"); + if (settings.maxBatchSize <= 0 || settings.maxBatchSize == Integer.MAX_VALUE) + throw new IllegalArgumentException("queryExecution.maxBatchSize must be positive and fit its histogram"); queryExecutionMode = settings.mode; queryBatchSize = settings.maxBatchSize; return this; diff --git a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/reporting/RunReporting.java b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/reporting/RunReporting.java index 9cb21e24a..aaa206850 100644 --- a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/reporting/RunReporting.java +++ b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/reporting/RunReporting.java @@ -63,9 +63,9 @@ public static RunReporting open(RunConfig runCfg) throws IOException { String runId = RunIdUtil.renderRunId(runIdTemplate, clock); // Run metadata - String jvectorRef = (runCfg != null && runCfg.logging != null && runCfg.logging.jvectorRef != null) - ? runCfg.logging.jvectorRef - : ""; + String configuredRef = (runCfg != null && runCfg.logging != null && runCfg.logging.jvectorRef != null) + ? runCfg.logging.jvectorRef : ""; + String jvectorRef = System.getProperty("jvector.bench.sourceRef", configuredRef); UUID runUuid = UUID.randomUUID(); Instant createdAt = clock.instant(); @@ -74,7 +74,15 @@ public static RunReporting open(RunConfig runCfg) throws IOException { Files.createDirectories(runDir); Integer buildThreads = detectBuildThreads(); - Integer queryThreads = java.util.concurrent.ForkJoinPool.getCommonPoolParallelism(); + Integer queryThreads; + if (runCfg != null && runCfg.queryExecution != null && "queued".equals(runCfg.queryExecution.mode)) { + queryThreads = Math.max(1, Math.min(Runtime.getRuntime().availableProcessors(), + io.github.jbellis.jvector.util.PhysicalCoreExecutor.getPhysicalCoreCount())); + } else { + int selected = Integer.getInteger("jvector.bench.queryThreads", 0); + if (selected < 0) throw new IllegalArgumentException("queryThreads must be nonnegative"); + queryThreads = selected == 0 ? java.util.concurrent.ForkJoinPool.getCommonPoolParallelism() : selected; + } String systemId = SysInfoWriter.writeSysInfo( runDir, diff --git a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/reporting/SysInfoWriter.java b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/reporting/SysInfoWriter.java index 4a30196a2..e218b2943 100644 --- a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/reporting/SysInfoWriter.java +++ b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/reporting/SysInfoWriter.java @@ -70,7 +70,7 @@ public static String writeSysInfo(Path runDir, threads.put("parallel_stream_parallelism", fjp); // Query benchmark characteristics - threads.put("query_throughput_parallelism", fjp); + threads.put("query_throughput_parallelism", queryThreads == null ? fjp : queryThreads); threads.put("query_latency_threads", 1); // Raw common pool parallelism diff --git a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/yaml/RunConfig.java b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/yaml/RunConfig.java index 85bc639f5..0f97effe1 100644 --- a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/yaml/RunConfig.java +++ b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/yaml/RunConfig.java @@ -22,6 +22,8 @@ import java.io.FileInputStream; import java.io.FileNotFoundException; import java.io.InputStream; +import java.io.IOException; +import java.io.UncheckedIOException; import java.util.List; import java.util.Map; @@ -58,8 +60,9 @@ public static class QueryExecution { /** Logging selection and run-level logging settings. */ public RunLogging logging; + /** Select an explicit run YAML without editing the checkout's default file. */ public static RunConfig loadDefault() throws FileNotFoundException { - return load(defaultDirectory + defaultRunFile); + return load(System.getProperty("jvector.bench.runConfig", defaultDirectory + defaultRunFile)); } public static RunConfig load(String configName) throws FileNotFoundException { @@ -70,9 +73,16 @@ public static RunConfig load(String configName) throws FileNotFoundException { if (!configFile.exists()) { throw new FileNotFoundException(configFile.getAbsolutePath()); } - InputStream inputStream = new FileInputStream(configFile); - Yaml yaml = new Yaml(); - return yaml.loadAs(inputStream, RunConfig.class); + try (InputStream inputStream = new FileInputStream(configFile)) { + Yaml yaml = new Yaml(); + RunConfig config = yaml.loadAs(inputStream, RunConfig.class); + if (config == null) throw new IllegalArgumentException("Empty run configuration: " + configFile); + return config; + } catch (FileNotFoundException failure) { + throw failure; + } catch (IOException failure) { + throw new UncheckedIOException("Cannot read run configuration: " + configFile, failure); + } } /** Run-level logging settings + selection for experiments.csv. */ diff --git a/jvector-examples/src/test/java/io/github/jbellis/jvector/example/benchmarks/StandaloneASHBenchmarkConfigTest.java b/jvector-examples/src/test/java/io/github/jbellis/jvector/example/benchmarks/StandaloneASHBenchmarkConfigTest.java new file mode 100644 index 000000000..03f77e434 --- /dev/null +++ b/jvector-examples/src/test/java/io/github/jbellis/jvector/example/benchmarks/StandaloneASHBenchmarkConfigTest.java @@ -0,0 +1,168 @@ +/* + * 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.benchmarks; + +import io.github.jbellis.jvector.example.yaml.MultiConfig; +import io.github.jbellis.jvector.example.yaml.RunConfig; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; +import org.yaml.snakeyaml.Yaml; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Map; + +import static org.junit.Assert.*; + +/** Fair code budgets and independent selection of checked-in run/index YAML. */ +public class StandaloneASHBenchmarkConfigTest { + private static final Path CONFIGS = Path.of("jvector-examples/yaml-configs/ash-pq"); + @Rule public final TemporaryFolder directory = new TemporaryFolder(); + + @Test + public void explicitRunFileDoesNotRequireEditingTheDefaultYaml() throws Exception { + var path = directory.newFile("run.yml").toPath().toAbsolutePath(); + Files.writeString(path, "yamlSchemaVersion: 1\nqueryExecution:\n mode: queued\n"); + String previous = System.getProperty("jvector.bench.runConfig"); + try { + System.setProperty("jvector.bench.runConfig", path.toString()); + assertEquals("queued", RunConfig.loadDefault().queryExecution.mode); + Files.writeString(path, ""); + assertThrows(IllegalArgumentException.class, RunConfig::loadDefault); + } finally { + if (previous == null) System.clearProperty("jvector.bench.runConfig"); + else System.setProperty("jvector.bench.runConfig", previous); + } + } + + @Test + public void yamlPathsAreNotRegexesAndMissingExplicitRunFileFailsBeforeDataLoading() throws Exception { + var index = directory.newFile("index with [literal.yml").toPath(); + Files.copy(CONFIGS.resolve("mpnet-2bit-ash.yml"), index, + java.nio.file.StandardCopyOption.REPLACE_EXISTING); + var missing = directory.getRoot().toPath().resolve("missing-run.yml"); + String previous = System.getProperty("jvector.bench.runConfig"); + try { + System.setProperty("jvector.bench.runConfig", missing.toString()); + var failure = assertThrows(java.io.FileNotFoundException.class, + () -> io.github.jbellis.jvector.example.BenchYAML.main(new String[]{index.toString()})); + assertTrue(failure.getMessage().contains("missing-run.yml")); + } finally { + if (previous == null) System.clearProperty("jvector.bench.runConfig"); + else System.setProperty("jvector.bench.runConfig", previous); + } + } + + @SuppressWarnings("unchecked") + private Map yaml(Path path) throws Exception { + return new Yaml().load(Files.readString(path)); + } + + @SuppressWarnings("unchecked") + private Map compression(Map section) { + return ((List>) section.get("compression")).get(0); + } + + @Test + @SuppressWarnings("unchecked") + public void allPairsMatchBytesAndUseFusedGraphsNvqAndTheSameSearchParameters() throws Exception { + var dimensions = Map.of("mpnet", 768, "cohere", 1024, "ada002", 1536, "openai3072", 3072); + for (var entry : dimensions.entrySet()) { + for (int bits : new int[]{2, 4}) { + Map previousConstruction = null; + String previousDataset = null; + for (String algorithm : List.of("ash", "pq")) { + Path path = CONFIGS.resolve(entry.getKey() + "-" + bits + "bit-" + algorithm + ".yml"); + assertNotNull(MultiConfig.getConfig(path.toAbsolutePath().toString())); + var root = yaml(path); + var construction = (Map) root.get("construction"); + var search = (Map) root.get("search"); + assertEquals(List.of(true), construction.get("fusedGraph")); + assertEquals(List.of("NVQ"), construction.get("reranking")); + assertEquals(List.of(32), construction.get("outDegree")); + assertEquals(List.of(100), construction.get("efConstruction")); + assertEquals(compression(construction), compression(search)); + assertEquals(Map.of(10, List.of(1.0, 2.0)), search.get("topKOverquery")); + var parameters = (Map) compression(construction).get("parameters"); + int expectedBits = 40 + entry.getValue() / 2 * bits; + if (algorithm.equals("ash")) { + assertEquals(expectedBits, parameters.get("encodedBits")); + assertEquals(1, parameters.get("landmarkCount")); + assertEquals(bits, parameters.get("bitsPerDimension")); + assertEquals("ITQ", parameters.get("optimizer")); + } else { + assertEquals(expectedBits / 8, parameters.get("m")); + assertEquals(256, parameters.get("k")); + assertEquals(previousDataset, root.get("dataset")); + for (String field : List.of("outDegree", "efConstruction", "neighborOverflow", + "addHierarchy", "refineFinalGraph", "fusedGraph", "reranking", "useSavedIndexIfExists")) + assertEquals(previousConstruction.get(field), construction.get(field)); + } + previousDataset = (String) root.get("dataset"); + previousConstruction = construction; + } + } + } + } + + @Test + public void runModesKeepStandardBenchmarkSelectionsAndQueuedBatchDefaults() throws Exception { + for (String mode : List.of("construction", "direct", "queued")) { + var config = RunConfig.load(CONFIGS.resolve("run-" + mode + ".yml").toString()); + assertEquals(mode.equals("queued") ? "queued" : "direct", config.queryExecution.mode); + assertEquals(8, config.queryExecution.maxBatchSize); + assertEquals(List.of("recall"), config.benchmarks.get("accuracy")); + if (!mode.equals("construction")) { + assertEquals(List.of("AVG"), config.benchmarks.get("throughput")); + assertEquals(List.of("AVG", "STD", "P999"), config.benchmarks.get("latency")); + } + } + } + + @Test + @SuppressWarnings("unchecked") + public void provenanceRecordsTheActualSourceAndSelectedQueryParallelism() throws Exception { + String oldThreads = System.getProperty("jvector.bench.queryThreads"); + String oldRef = System.getProperty("jvector.bench.sourceRef"); + try { + System.setProperty("jvector.bench.queryThreads", "7"); + System.setProperty("jvector.bench.sourceRef", "test-source-reference"); + var config = new RunConfig(); + config.logging = new RunConfig.RunLogging(); + config.logging.dir = directory.getRoot().getAbsolutePath(); + config.logging.jvectorRef = "old-label"; + for (String mode : List.of("direct", "queued")) { + config.queryExecution.mode = mode; + config.logging.runId = mode; + var reporting = io.github.jbellis.jvector.example.reporting.RunReporting.open(config); + var info = yaml(reporting.run().runDir().resolve("sys_info.json")); + assertEquals("test-source-reference", info.get("jvector_ref")); + var threads = (Map) info.get("threads"); + int expected = mode.equals("direct") ? 7 : Math.max(1, + Math.min(Runtime.getRuntime().availableProcessors(), + io.github.jbellis.jvector.util.PhysicalCoreExecutor.getPhysicalCoreCount())); + assertEquals(expected, threads.get("query_throughput_parallelism")); + } + } finally { + if (oldThreads == null) System.clearProperty("jvector.bench.queryThreads"); + else System.setProperty("jvector.bench.queryThreads", oldThreads); + if (oldRef == null) System.clearProperty("jvector.bench.sourceRef"); + else System.setProperty("jvector.bench.sourceRef", oldRef); + } + } +} diff --git a/jvector-examples/yaml-configs/ash-pq/ada002-2bit-ash.yml b/jvector-examples/yaml-configs/ash-pq/ada002-2bit-ash.yml new file mode 100644 index 000000000..2510c661d --- /dev/null +++ b/jvector-examples/yaml-configs/ash-pq/ada002-2bit-ash.yml @@ -0,0 +1,38 @@ +# ada002-1M: 1536 input dimensions, 197 code bytes per vector. +# The 2-bit label describes the ASH budget; PQ uses 197 8-bit subspaces. +# ASH uses C=1 and D/2 projected dimensions, including its five-byte header. +# See docs/ash-pq-benchmark.md for construction, direct and queued run commands. +yamlSchemaVersion: 2 +onDiskIndexVersion: 7 +dataset: ada002-1M + +construction: + outDegree: [32] + efConstruction: [100] + neighborOverflow: [1.2f] + addHierarchy: [Yes] + refineFinalGraph: [Yes] + fusedGraph: [Yes] + compression: + - type: ASH + parameters: + encodedBits: 1576 + optimizer: ITQ + landmarkCount: 1 + bitsPerDimension: 2 + reranking: [NVQ] + # A fresh case directory measures training/encoding/construction. Later JVMs + # in that directory reuse exactly the same model and index for querying. + useSavedIndexIfExists: Yes + +search: + topKOverquery: + 10: [1.0, 2.0] + useSearchPruning: [No] + compression: + - type: ASH + parameters: + encodedBits: 1576 + optimizer: ITQ + landmarkCount: 1 + bitsPerDimension: 2 diff --git a/jvector-examples/yaml-configs/ash-pq/ada002-2bit-pq.yml b/jvector-examples/yaml-configs/ash-pq/ada002-2bit-pq.yml new file mode 100644 index 000000000..16dbc7d36 --- /dev/null +++ b/jvector-examples/yaml-configs/ash-pq/ada002-2bit-pq.yml @@ -0,0 +1,38 @@ +# ada002-1M: 1536 input dimensions, 197 code bytes per vector. +# The 2-bit label describes the ASH budget; PQ uses 197 8-bit subspaces. +# ASH uses C=1 and D/2 projected dimensions, including its five-byte header. +# See docs/ash-pq-benchmark.md for construction, direct and queued run commands. +yamlSchemaVersion: 2 +onDiskIndexVersion: 7 +dataset: ada002-1M + +construction: + outDegree: [32] + efConstruction: [100] + neighborOverflow: [1.2f] + addHierarchy: [Yes] + refineFinalGraph: [Yes] + fusedGraph: [Yes] + compression: + - type: PQ + parameters: + m: 197 + k: 256 + centerData: "No" + anisotropicThreshold: -1.0 + reranking: [NVQ] + # A fresh case directory measures training/encoding/construction. Later JVMs + # in that directory reuse exactly the same model and index for querying. + useSavedIndexIfExists: Yes + +search: + topKOverquery: + 10: [1.0, 2.0] + useSearchPruning: [No] + compression: + - type: PQ + parameters: + m: 197 + k: 256 + centerData: "No" + anisotropicThreshold: -1.0 diff --git a/jvector-examples/yaml-configs/ash-pq/ada002-4bit-ash.yml b/jvector-examples/yaml-configs/ash-pq/ada002-4bit-ash.yml new file mode 100644 index 000000000..a3e1fee95 --- /dev/null +++ b/jvector-examples/yaml-configs/ash-pq/ada002-4bit-ash.yml @@ -0,0 +1,38 @@ +# ada002-1M: 1536 input dimensions, 389 code bytes per vector. +# The 4-bit label describes the ASH budget; PQ uses 389 8-bit subspaces. +# ASH uses C=1 and D/2 projected dimensions, including its five-byte header. +# See docs/ash-pq-benchmark.md for construction, direct and queued run commands. +yamlSchemaVersion: 2 +onDiskIndexVersion: 7 +dataset: ada002-1M + +construction: + outDegree: [32] + efConstruction: [100] + neighborOverflow: [1.2f] + addHierarchy: [Yes] + refineFinalGraph: [Yes] + fusedGraph: [Yes] + compression: + - type: ASH + parameters: + encodedBits: 3112 + optimizer: ITQ + landmarkCount: 1 + bitsPerDimension: 4 + reranking: [NVQ] + # A fresh case directory measures training/encoding/construction. Later JVMs + # in that directory reuse exactly the same model and index for querying. + useSavedIndexIfExists: Yes + +search: + topKOverquery: + 10: [1.0, 2.0] + useSearchPruning: [No] + compression: + - type: ASH + parameters: + encodedBits: 3112 + optimizer: ITQ + landmarkCount: 1 + bitsPerDimension: 4 diff --git a/jvector-examples/yaml-configs/ash-pq/ada002-4bit-pq.yml b/jvector-examples/yaml-configs/ash-pq/ada002-4bit-pq.yml new file mode 100644 index 000000000..4527ddcfc --- /dev/null +++ b/jvector-examples/yaml-configs/ash-pq/ada002-4bit-pq.yml @@ -0,0 +1,38 @@ +# ada002-1M: 1536 input dimensions, 389 code bytes per vector. +# The 4-bit label describes the ASH budget; PQ uses 389 8-bit subspaces. +# ASH uses C=1 and D/2 projected dimensions, including its five-byte header. +# See docs/ash-pq-benchmark.md for construction, direct and queued run commands. +yamlSchemaVersion: 2 +onDiskIndexVersion: 7 +dataset: ada002-1M + +construction: + outDegree: [32] + efConstruction: [100] + neighborOverflow: [1.2f] + addHierarchy: [Yes] + refineFinalGraph: [Yes] + fusedGraph: [Yes] + compression: + - type: PQ + parameters: + m: 389 + k: 256 + centerData: "No" + anisotropicThreshold: -1.0 + reranking: [NVQ] + # A fresh case directory measures training/encoding/construction. Later JVMs + # in that directory reuse exactly the same model and index for querying. + useSavedIndexIfExists: Yes + +search: + topKOverquery: + 10: [1.0, 2.0] + useSearchPruning: [No] + compression: + - type: PQ + parameters: + m: 389 + k: 256 + centerData: "No" + anisotropicThreshold: -1.0 diff --git a/jvector-examples/yaml-configs/ash-pq/catalog-1m.yml b/jvector-examples/yaml-configs/ash-pq/catalog-1m.yml new file mode 100644 index 000000000..812486bf0 --- /dev/null +++ b/jvector-examples/yaml-configs/ash-pq/catalog-1m.yml @@ -0,0 +1,22 @@ +# Local mapping for the exact normalized benchmark inputs. Obtain the datasets +# separately, then point JVECTOR_BENCH_DATA_DIR at the directory containing these +# files/subdirectories. No private machine path or download credentials belong here. +_defaults: + cache_dir: ${JVECTOR_BENCH_DATA_DIR} + +mpnet-1m: + base: mpnet-1m/wiki_mpnet_embeddings_base_999812.fvecs + query: mpnet-1m/wiki_mpnet_embeddings_query_10000.fvecs + gt: mpnet-1m/wiki_mpnet_embeddings_gt_ip_100.ivecs +cohere-english-v3-1M: + base: cohere-1m/cohere_wiki_en_flat_base_1m_norm.fvecs + query: cohere-1m/cohere_wiki_en_flat_query_10k_norm.fvecs + gt: cohere-1m/cohere_wiki_en_flat_gt_1m_ip_k100.ivecs +ada002-1M: + base: ada_002_1M_base_982790.fvecs + query: ada_002_1M_query_10000.fvecs + gt: ada_002_1M_gt_ip_100.ivecs +openai-3072-1m: + base: openai-3072-1m/dbpedia_openai_3_large_3072_base_999000.fvecs + query: openai-3072-1m/dbpedia_openai_3_large_3072_query_1000.fvecs + gt: openai-3072-1m/dbpedia_openai_3_large_3072_gt_ip_100.ivecs diff --git a/jvector-examples/yaml-configs/ash-pq/cohere-2bit-ash.yml b/jvector-examples/yaml-configs/ash-pq/cohere-2bit-ash.yml new file mode 100644 index 000000000..3148f1981 --- /dev/null +++ b/jvector-examples/yaml-configs/ash-pq/cohere-2bit-ash.yml @@ -0,0 +1,38 @@ +# cohere-english-v3-1M: 1024 input dimensions, 133 code bytes per vector. +# The 2-bit label describes the ASH budget; PQ uses 133 8-bit subspaces. +# ASH uses C=1 and D/2 projected dimensions, including its five-byte header. +# See docs/ash-pq-benchmark.md for construction, direct and queued run commands. +yamlSchemaVersion: 2 +onDiskIndexVersion: 7 +dataset: cohere-english-v3-1M + +construction: + outDegree: [32] + efConstruction: [100] + neighborOverflow: [1.2f] + addHierarchy: [Yes] + refineFinalGraph: [Yes] + fusedGraph: [Yes] + compression: + - type: ASH + parameters: + encodedBits: 1064 + optimizer: ITQ + landmarkCount: 1 + bitsPerDimension: 2 + reranking: [NVQ] + # A fresh case directory measures training/encoding/construction. Later JVMs + # in that directory reuse exactly the same model and index for querying. + useSavedIndexIfExists: Yes + +search: + topKOverquery: + 10: [1.0, 2.0] + useSearchPruning: [No] + compression: + - type: ASH + parameters: + encodedBits: 1064 + optimizer: ITQ + landmarkCount: 1 + bitsPerDimension: 2 diff --git a/jvector-examples/yaml-configs/ash-pq/cohere-2bit-pq.yml b/jvector-examples/yaml-configs/ash-pq/cohere-2bit-pq.yml new file mode 100644 index 000000000..a41f3cf3a --- /dev/null +++ b/jvector-examples/yaml-configs/ash-pq/cohere-2bit-pq.yml @@ -0,0 +1,38 @@ +# cohere-english-v3-1M: 1024 input dimensions, 133 code bytes per vector. +# The 2-bit label describes the ASH budget; PQ uses 133 8-bit subspaces. +# ASH uses C=1 and D/2 projected dimensions, including its five-byte header. +# See docs/ash-pq-benchmark.md for construction, direct and queued run commands. +yamlSchemaVersion: 2 +onDiskIndexVersion: 7 +dataset: cohere-english-v3-1M + +construction: + outDegree: [32] + efConstruction: [100] + neighborOverflow: [1.2f] + addHierarchy: [Yes] + refineFinalGraph: [Yes] + fusedGraph: [Yes] + compression: + - type: PQ + parameters: + m: 133 + k: 256 + centerData: "No" + anisotropicThreshold: -1.0 + reranking: [NVQ] + # A fresh case directory measures training/encoding/construction. Later JVMs + # in that directory reuse exactly the same model and index for querying. + useSavedIndexIfExists: Yes + +search: + topKOverquery: + 10: [1.0, 2.0] + useSearchPruning: [No] + compression: + - type: PQ + parameters: + m: 133 + k: 256 + centerData: "No" + anisotropicThreshold: -1.0 diff --git a/jvector-examples/yaml-configs/ash-pq/cohere-4bit-ash.yml b/jvector-examples/yaml-configs/ash-pq/cohere-4bit-ash.yml new file mode 100644 index 000000000..bcf28ed35 --- /dev/null +++ b/jvector-examples/yaml-configs/ash-pq/cohere-4bit-ash.yml @@ -0,0 +1,38 @@ +# cohere-english-v3-1M: 1024 input dimensions, 261 code bytes per vector. +# The 4-bit label describes the ASH budget; PQ uses 261 8-bit subspaces. +# ASH uses C=1 and D/2 projected dimensions, including its five-byte header. +# See docs/ash-pq-benchmark.md for construction, direct and queued run commands. +yamlSchemaVersion: 2 +onDiskIndexVersion: 7 +dataset: cohere-english-v3-1M + +construction: + outDegree: [32] + efConstruction: [100] + neighborOverflow: [1.2f] + addHierarchy: [Yes] + refineFinalGraph: [Yes] + fusedGraph: [Yes] + compression: + - type: ASH + parameters: + encodedBits: 2088 + optimizer: ITQ + landmarkCount: 1 + bitsPerDimension: 4 + reranking: [NVQ] + # A fresh case directory measures training/encoding/construction. Later JVMs + # in that directory reuse exactly the same model and index for querying. + useSavedIndexIfExists: Yes + +search: + topKOverquery: + 10: [1.0, 2.0] + useSearchPruning: [No] + compression: + - type: ASH + parameters: + encodedBits: 2088 + optimizer: ITQ + landmarkCount: 1 + bitsPerDimension: 4 diff --git a/jvector-examples/yaml-configs/ash-pq/cohere-4bit-pq.yml b/jvector-examples/yaml-configs/ash-pq/cohere-4bit-pq.yml new file mode 100644 index 000000000..619fe60b9 --- /dev/null +++ b/jvector-examples/yaml-configs/ash-pq/cohere-4bit-pq.yml @@ -0,0 +1,38 @@ +# cohere-english-v3-1M: 1024 input dimensions, 261 code bytes per vector. +# The 4-bit label describes the ASH budget; PQ uses 261 8-bit subspaces. +# ASH uses C=1 and D/2 projected dimensions, including its five-byte header. +# See docs/ash-pq-benchmark.md for construction, direct and queued run commands. +yamlSchemaVersion: 2 +onDiskIndexVersion: 7 +dataset: cohere-english-v3-1M + +construction: + outDegree: [32] + efConstruction: [100] + neighborOverflow: [1.2f] + addHierarchy: [Yes] + refineFinalGraph: [Yes] + fusedGraph: [Yes] + compression: + - type: PQ + parameters: + m: 261 + k: 256 + centerData: "No" + anisotropicThreshold: -1.0 + reranking: [NVQ] + # A fresh case directory measures training/encoding/construction. Later JVMs + # in that directory reuse exactly the same model and index for querying. + useSavedIndexIfExists: Yes + +search: + topKOverquery: + 10: [1.0, 2.0] + useSearchPruning: [No] + compression: + - type: PQ + parameters: + m: 261 + k: 256 + centerData: "No" + anisotropicThreshold: -1.0 diff --git a/jvector-examples/yaml-configs/ash-pq/mpnet-2bit-ash.yml b/jvector-examples/yaml-configs/ash-pq/mpnet-2bit-ash.yml new file mode 100644 index 000000000..a807ebbea --- /dev/null +++ b/jvector-examples/yaml-configs/ash-pq/mpnet-2bit-ash.yml @@ -0,0 +1,38 @@ +# mpnet-1m: 768 input dimensions, 101 code bytes per vector. +# The 2-bit label describes the ASH budget; PQ uses 101 8-bit subspaces. +# ASH uses C=1 and D/2 projected dimensions, including its five-byte header. +# See docs/ash-pq-benchmark.md for construction, direct and queued run commands. +yamlSchemaVersion: 2 +onDiskIndexVersion: 7 +dataset: mpnet-1m + +construction: + outDegree: [32] + efConstruction: [100] + neighborOverflow: [1.2f] + addHierarchy: [Yes] + refineFinalGraph: [Yes] + fusedGraph: [Yes] + compression: + - type: ASH + parameters: + encodedBits: 808 + optimizer: ITQ + landmarkCount: 1 + bitsPerDimension: 2 + reranking: [NVQ] + # A fresh case directory measures training/encoding/construction. Later JVMs + # in that directory reuse exactly the same model and index for querying. + useSavedIndexIfExists: Yes + +search: + topKOverquery: + 10: [1.0, 2.0] + useSearchPruning: [No] + compression: + - type: ASH + parameters: + encodedBits: 808 + optimizer: ITQ + landmarkCount: 1 + bitsPerDimension: 2 diff --git a/jvector-examples/yaml-configs/ash-pq/mpnet-2bit-pq.yml b/jvector-examples/yaml-configs/ash-pq/mpnet-2bit-pq.yml new file mode 100644 index 000000000..aedf1da07 --- /dev/null +++ b/jvector-examples/yaml-configs/ash-pq/mpnet-2bit-pq.yml @@ -0,0 +1,38 @@ +# mpnet-1m: 768 input dimensions, 101 code bytes per vector. +# The 2-bit label describes the ASH budget; PQ uses 101 8-bit subspaces. +# ASH uses C=1 and D/2 projected dimensions, including its five-byte header. +# See docs/ash-pq-benchmark.md for construction, direct and queued run commands. +yamlSchemaVersion: 2 +onDiskIndexVersion: 7 +dataset: mpnet-1m + +construction: + outDegree: [32] + efConstruction: [100] + neighborOverflow: [1.2f] + addHierarchy: [Yes] + refineFinalGraph: [Yes] + fusedGraph: [Yes] + compression: + - type: PQ + parameters: + m: 101 + k: 256 + centerData: "No" + anisotropicThreshold: -1.0 + reranking: [NVQ] + # A fresh case directory measures training/encoding/construction. Later JVMs + # in that directory reuse exactly the same model and index for querying. + useSavedIndexIfExists: Yes + +search: + topKOverquery: + 10: [1.0, 2.0] + useSearchPruning: [No] + compression: + - type: PQ + parameters: + m: 101 + k: 256 + centerData: "No" + anisotropicThreshold: -1.0 diff --git a/jvector-examples/yaml-configs/ash-pq/mpnet-4bit-ash.yml b/jvector-examples/yaml-configs/ash-pq/mpnet-4bit-ash.yml new file mode 100644 index 000000000..83b4f1d69 --- /dev/null +++ b/jvector-examples/yaml-configs/ash-pq/mpnet-4bit-ash.yml @@ -0,0 +1,38 @@ +# mpnet-1m: 768 input dimensions, 197 code bytes per vector. +# The 4-bit label describes the ASH budget; PQ uses 197 8-bit subspaces. +# ASH uses C=1 and D/2 projected dimensions, including its five-byte header. +# See docs/ash-pq-benchmark.md for construction, direct and queued run commands. +yamlSchemaVersion: 2 +onDiskIndexVersion: 7 +dataset: mpnet-1m + +construction: + outDegree: [32] + efConstruction: [100] + neighborOverflow: [1.2f] + addHierarchy: [Yes] + refineFinalGraph: [Yes] + fusedGraph: [Yes] + compression: + - type: ASH + parameters: + encodedBits: 1576 + optimizer: ITQ + landmarkCount: 1 + bitsPerDimension: 4 + reranking: [NVQ] + # A fresh case directory measures training/encoding/construction. Later JVMs + # in that directory reuse exactly the same model and index for querying. + useSavedIndexIfExists: Yes + +search: + topKOverquery: + 10: [1.0, 2.0] + useSearchPruning: [No] + compression: + - type: ASH + parameters: + encodedBits: 1576 + optimizer: ITQ + landmarkCount: 1 + bitsPerDimension: 4 diff --git a/jvector-examples/yaml-configs/ash-pq/mpnet-4bit-pq.yml b/jvector-examples/yaml-configs/ash-pq/mpnet-4bit-pq.yml new file mode 100644 index 000000000..4556eee09 --- /dev/null +++ b/jvector-examples/yaml-configs/ash-pq/mpnet-4bit-pq.yml @@ -0,0 +1,38 @@ +# mpnet-1m: 768 input dimensions, 197 code bytes per vector. +# The 4-bit label describes the ASH budget; PQ uses 197 8-bit subspaces. +# ASH uses C=1 and D/2 projected dimensions, including its five-byte header. +# See docs/ash-pq-benchmark.md for construction, direct and queued run commands. +yamlSchemaVersion: 2 +onDiskIndexVersion: 7 +dataset: mpnet-1m + +construction: + outDegree: [32] + efConstruction: [100] + neighborOverflow: [1.2f] + addHierarchy: [Yes] + refineFinalGraph: [Yes] + fusedGraph: [Yes] + compression: + - type: PQ + parameters: + m: 197 + k: 256 + centerData: "No" + anisotropicThreshold: -1.0 + reranking: [NVQ] + # A fresh case directory measures training/encoding/construction. Later JVMs + # in that directory reuse exactly the same model and index for querying. + useSavedIndexIfExists: Yes + +search: + topKOverquery: + 10: [1.0, 2.0] + useSearchPruning: [No] + compression: + - type: PQ + parameters: + m: 197 + k: 256 + centerData: "No" + anisotropicThreshold: -1.0 diff --git a/jvector-examples/yaml-configs/ash-pq/openai3072-2bit-ash.yml b/jvector-examples/yaml-configs/ash-pq/openai3072-2bit-ash.yml new file mode 100644 index 000000000..1b51ca920 --- /dev/null +++ b/jvector-examples/yaml-configs/ash-pq/openai3072-2bit-ash.yml @@ -0,0 +1,38 @@ +# openai-3072-1m: 3072 input dimensions, 389 code bytes per vector. +# The 2-bit label describes the ASH budget; PQ uses 389 8-bit subspaces. +# ASH uses C=1 and D/2 projected dimensions, including its five-byte header. +# See docs/ash-pq-benchmark.md for construction, direct and queued run commands. +yamlSchemaVersion: 2 +onDiskIndexVersion: 7 +dataset: openai-3072-1m + +construction: + outDegree: [32] + efConstruction: [100] + neighborOverflow: [1.2f] + addHierarchy: [Yes] + refineFinalGraph: [Yes] + fusedGraph: [Yes] + compression: + - type: ASH + parameters: + encodedBits: 3112 + optimizer: ITQ + landmarkCount: 1 + bitsPerDimension: 2 + reranking: [NVQ] + # A fresh case directory measures training/encoding/construction. Later JVMs + # in that directory reuse exactly the same model and index for querying. + useSavedIndexIfExists: Yes + +search: + topKOverquery: + 10: [1.0, 2.0] + useSearchPruning: [No] + compression: + - type: ASH + parameters: + encodedBits: 3112 + optimizer: ITQ + landmarkCount: 1 + bitsPerDimension: 2 diff --git a/jvector-examples/yaml-configs/ash-pq/openai3072-2bit-pq.yml b/jvector-examples/yaml-configs/ash-pq/openai3072-2bit-pq.yml new file mode 100644 index 000000000..2d0cdfb52 --- /dev/null +++ b/jvector-examples/yaml-configs/ash-pq/openai3072-2bit-pq.yml @@ -0,0 +1,38 @@ +# openai-3072-1m: 3072 input dimensions, 389 code bytes per vector. +# The 2-bit label describes the ASH budget; PQ uses 389 8-bit subspaces. +# ASH uses C=1 and D/2 projected dimensions, including its five-byte header. +# See docs/ash-pq-benchmark.md for construction, direct and queued run commands. +yamlSchemaVersion: 2 +onDiskIndexVersion: 7 +dataset: openai-3072-1m + +construction: + outDegree: [32] + efConstruction: [100] + neighborOverflow: [1.2f] + addHierarchy: [Yes] + refineFinalGraph: [Yes] + fusedGraph: [Yes] + compression: + - type: PQ + parameters: + m: 389 + k: 256 + centerData: "No" + anisotropicThreshold: -1.0 + reranking: [NVQ] + # A fresh case directory measures training/encoding/construction. Later JVMs + # in that directory reuse exactly the same model and index for querying. + useSavedIndexIfExists: Yes + +search: + topKOverquery: + 10: [1.0, 2.0] + useSearchPruning: [No] + compression: + - type: PQ + parameters: + m: 389 + k: 256 + centerData: "No" + anisotropicThreshold: -1.0 diff --git a/jvector-examples/yaml-configs/ash-pq/openai3072-4bit-ash.yml b/jvector-examples/yaml-configs/ash-pq/openai3072-4bit-ash.yml new file mode 100644 index 000000000..9c0c4f983 --- /dev/null +++ b/jvector-examples/yaml-configs/ash-pq/openai3072-4bit-ash.yml @@ -0,0 +1,38 @@ +# openai-3072-1m: 3072 input dimensions, 773 code bytes per vector. +# The 4-bit label describes the ASH budget; PQ uses 773 8-bit subspaces. +# ASH uses C=1 and D/2 projected dimensions, including its five-byte header. +# See docs/ash-pq-benchmark.md for construction, direct and queued run commands. +yamlSchemaVersion: 2 +onDiskIndexVersion: 7 +dataset: openai-3072-1m + +construction: + outDegree: [32] + efConstruction: [100] + neighborOverflow: [1.2f] + addHierarchy: [Yes] + refineFinalGraph: [Yes] + fusedGraph: [Yes] + compression: + - type: ASH + parameters: + encodedBits: 6184 + optimizer: ITQ + landmarkCount: 1 + bitsPerDimension: 4 + reranking: [NVQ] + # A fresh case directory measures training/encoding/construction. Later JVMs + # in that directory reuse exactly the same model and index for querying. + useSavedIndexIfExists: Yes + +search: + topKOverquery: + 10: [1.0, 2.0] + useSearchPruning: [No] + compression: + - type: ASH + parameters: + encodedBits: 6184 + optimizer: ITQ + landmarkCount: 1 + bitsPerDimension: 4 diff --git a/jvector-examples/yaml-configs/ash-pq/openai3072-4bit-pq.yml b/jvector-examples/yaml-configs/ash-pq/openai3072-4bit-pq.yml new file mode 100644 index 000000000..00d8c7d25 --- /dev/null +++ b/jvector-examples/yaml-configs/ash-pq/openai3072-4bit-pq.yml @@ -0,0 +1,38 @@ +# openai-3072-1m: 3072 input dimensions, 773 code bytes per vector. +# The 4-bit label describes the ASH budget; PQ uses 773 8-bit subspaces. +# ASH uses C=1 and D/2 projected dimensions, including its five-byte header. +# See docs/ash-pq-benchmark.md for construction, direct and queued run commands. +yamlSchemaVersion: 2 +onDiskIndexVersion: 7 +dataset: openai-3072-1m + +construction: + outDegree: [32] + efConstruction: [100] + neighborOverflow: [1.2f] + addHierarchy: [Yes] + refineFinalGraph: [Yes] + fusedGraph: [Yes] + compression: + - type: PQ + parameters: + m: 773 + k: 256 + centerData: "No" + anisotropicThreshold: -1.0 + reranking: [NVQ] + # A fresh case directory measures training/encoding/construction. Later JVMs + # in that directory reuse exactly the same model and index for querying. + useSavedIndexIfExists: Yes + +search: + topKOverquery: + 10: [1.0, 2.0] + useSearchPruning: [No] + compression: + - type: PQ + parameters: + m: 773 + k: 256 + centerData: "No" + anisotropicThreshold: -1.0 diff --git a/jvector-examples/yaml-configs/ash-pq/run-construction.yml b/jvector-examples/yaml-configs/ash-pq/run-construction.yml new file mode 100644 index 000000000..cae967a5a --- /dev/null +++ b/jvector-examples/yaml-configs/ash-pq/run-construction.yml @@ -0,0 +1,21 @@ +# Used by standalone BenchYAML via -Djvector.bench.runConfig=. +yamlSchemaVersion: 1 +onDiskIndexVersion: 7 +queryExecution: + mode: direct +benchmarks: + accuracy: [recall] +console: + benchmarks: + accuracy: [recall] + metrics: + construction: [index_build_time_s, index_quant_time_s, search_quant_time_s] +logging: + dir: logging + jvectorRef: ash-dev + runId: ash-pq-construction-{ts} + type: csv + benchmarks: + accuracy: [recall] + metrics: + construction: [index_build_time_s, index_quant_time_s, search_quant_time_s] diff --git a/jvector-examples/yaml-configs/ash-pq/run-direct.yml b/jvector-examples/yaml-configs/ash-pq/run-direct.yml new file mode 100644 index 000000000..b4115f19c --- /dev/null +++ b/jvector-examples/yaml-configs/ash-pq/run-direct.yml @@ -0,0 +1,26 @@ +# Used by standalone BenchYAML via -Djvector.bench.runConfig=. +yamlSchemaVersion: 1 +onDiskIndexVersion: 7 +queryExecution: + mode: direct +benchmarks: + throughput: [AVG] + latency: [AVG, STD, P999] + accuracy: [recall] + count: [visited] +console: + benchmarks: + throughput: [AVG] + latency: [AVG, STD, P999] + accuracy: [recall] + count: [visited] +logging: + dir: logging + jvectorRef: ash-dev + runId: ash-pq-direct-{ts} + type: csv + benchmarks: + throughput: [AVG] + latency: [AVG, STD, P999] + accuracy: [recall] + count: [visited] diff --git a/jvector-examples/yaml-configs/ash-pq/run-queued.yml b/jvector-examples/yaml-configs/ash-pq/run-queued.yml new file mode 100644 index 000000000..6af615e3d --- /dev/null +++ b/jvector-examples/yaml-configs/ash-pq/run-queued.yml @@ -0,0 +1,30 @@ +# Used by standalone BenchYAML via -Djvector.bench.runConfig=. +yamlSchemaVersion: 1 +onDiskIndexVersion: 7 +queryExecution: + mode: queued +benchmarks: + throughput: [AVG] + latency: [AVG, STD, P999] + accuracy: [recall] + count: [visited] +console: + benchmarks: + throughput: [AVG] + latency: [AVG, STD, P999] + accuracy: [recall] + count: [visited] + metrics: + execution: [response_mean_ms, response_p95_ms, response_p99_ms, mean_batch_size] +logging: + dir: logging + jvectorRef: ash-dev + runId: ash-pq-queued-{ts} + type: csv + benchmarks: + throughput: [AVG] + latency: [AVG, STD, P999] + accuracy: [recall] + count: [visited] + metrics: + execution: [response_mean_ms, response_p95_ms, response_p99_ms, mean_batch_size] From cb5f97085b733fb537b9be449944039860fcaa76 Mon Sep 17 00:00:00 2001 From: Ted Willke Date: Wed, 4 Nov 2026 03:18:08 +0000 Subject: [PATCH 138/243] Document asynchronous query integration and reproducible benchmark protocol --- docs/ash-pq-benchmark.md | 225 +++++++++++++ docs/ash-query-integration.md | 307 ++++++++++-------- docs/ash-queued-query-poc.md | 10 +- docs/benchmarking.md | 7 + .../4.1.0/ash-query-batching.feature.md | 13 + 5 files changed, 415 insertions(+), 147 deletions(-) create mode 100644 docs/ash-pq-benchmark.md create mode 100644 docs/release notes/4.1.0/ash-query-batching.feature.md diff --git a/docs/ash-pq-benchmark.md b/docs/ash-pq-benchmark.md new file mode 100644 index 000000000..51636d16a --- /dev/null +++ b/docs/ash-pq-benchmark.md @@ -0,0 +1,225 @@ + + +# Reproducing fused PQ versus fused ASH with BenchYAML + +This comparison uses the branch's ordinary packaged JVector code and +`io.github.jbellis.jvector.example.BenchYAML`. The fixed index configurations, +run configurations and local dataset catalog are in +[`jvector-examples/yaml-configs/ash-pq`](../jvector-examples/yaml-configs/ash-pq). +No separate query harness, source overlay, profiling agent, manually linked +library, or dataset-specific projection setting is required. + +## Build the checkout + +Use JDK 22 for performance measurements. Follow the repository's +[native prerequisites](../jvector-native/src/main/native/README.md), including +the compiler, Meson, Ninja and CMake. Native packaging fetches the pinned oneDNN +source and includes the statically linked ASH add-on by default. Reviewers need +the normal build dependencies and access to the input datasets. ASH training +also uses the installed BLAS/LAPACK provider; record that native provider with +the results, since a Java fallback does not reproduce native training throughput. + +From a clean checkout of the reported commit: + +```bash +export JAVA_HOME=/path/to/jdk-22 +export PATH="$JAVA_HOME/bin:$PATH" +git submodule update --init --recursive +mvn -B -pl jvector-examples -am -DskipTests package +``` + +The standalone artifact is +`jvector-examples/target/jvector-examples--jar-with-dependencies.jar`. +Build once, before any benchmark process. Keep the checkout and JAR fixed +throughout a comparison. Native is explicitly required by the run command so a +failed native installation cannot silently become a scalar measurement. +Optimized FP16 execution additionally depends on the CPU's capabilities; the +portable FP32 fallback remains available on other systems. + +Relevant repository tests can be run independently: + +```bash +mvn -B -pl jvector-tests,jvector-examples -am test \ + -DskipSIMD=false -DskipScalar=true \ + '-Dtest=TestASH*,NativeASH*,TestPreparedASHQuery,TestBatchingExecutor,TestCallerBatchProcessor,TestQueuedWorkerLifetime,TestDeferredQueryReporting,QueryExecutionConfigTest,StandaloneASHBenchmarkConfigTest' \ + -Dsurefire.failIfNoSpecifiedTests=false +``` + +The production change is also tested on JDK 25. Those correctness runs are not +combined with the JDK 22 performance results. + +## Inputs and fixed index parameters + +Obtain the same normalized fvecs/ivecs inputs listed in +[`catalog-1m.yml`](../jvector-examples/yaml-configs/ash-pq/catalog-1m.yml). +Some datasets require separate access permission; the checkout contains neither +credentials nor private machine paths. Set `JVECTOR_BENCH_DATA_DIR` to their +absolute parent directory, preserving the relative file names in the catalog. +The four "1M" names describe the dataset families; the reporting artifacts +record the actual base/query counts, including the 1,000 OpenAI3072 queries. +All four use `DOT_PRODUCT` and `NO_SCRUB` from `dataset-metadata.yml`. + +| Dataset prefix | Input dimension | 2-bit ASH / matching PQ code bytes | 4-bit ASH / matching PQ code bytes | +| --- | ---: | ---: | ---: | +| `mpnet` | 768 | 101 | 197 | +| `cohere` | 1024 | 133 | 261 | +| `ada002` | 1536 | 197 | 389 | +| `openai3072` | 3072 | 389 | 773 | + +ASH uses one landmark, an ITQ-trained transform with D/2 output dimensions, and +2 or 4 bits per projected dimension. Its five-byte header is included in the +matched code budget. PQ uses that many 8-bit subspaces, 256 centroids, no +centering, and the default non-anisotropic training setting. The 2-bit/4-bit +labels describe the ASH cases, not PQ centroid precision. + +Both algorithms use fused graph features, NVQ reranking, degree 32, +`efConstruction=100`, neighbor overflow 1.2, hierarchy and final refinement, +and top 10 at overquery 1 and 2. Native/oneDNN selection uses production defaults: +FP16 for supported ASH query projection, FP32 for encoding and training, and +Highway scoring. The deferred complete-record writer is enabled for both. + +## Run one case + +The checked-in shell recipe only prepares a case directory, records the command +and invokes standalone BenchYAML. It contains no query loop or timing logic. +It checks that the CPU list contains the requested number of distinct online +physical cores, so two SMT siblings cannot accidentally count as two cores. +CPU counts are sizing hints; `numactl` enforces the actual allocation. + +```bash +export JVECTOR_BENCH_DATA_DIR=/data/normalized-vector-datasets +export JVECTOR_BENCH_HEAP=128g +BENCH_CASE=/data/jvector-bench/mpnet-2bit-ash-r1 + +# Choose these CPU/node lists from lscpu on the actual host. +# The reference construction budget is 48 physical cores across two NUMA nodes. +JVECTOR_BENCH_NUMA_POLICY=interleave:0,1 \ + bash jvector-examples/run-ash-pq-benchmark.sh \ + mpnet-2bit-ash.yml construction 48 0-47 "$BENCH_CASE" + +# Reuse that model and graph. Each invocation is a fresh JVM. +bash jvector-examples/run-ash-pq-benchmark.sh \ + mpnet-2bit-ash.yml direct 1 0 "$BENCH_CASE" +bash jvector-examples/run-ash-pq-benchmark.sh \ + mpnet-2bit-ash.yml queued 1 0 "$BENCH_CASE" +bash jvector-examples/run-ash-pq-benchmark.sh \ + mpnet-2bit-ash.yml direct 8 0-7 "$BENCH_CASE" +bash jvector-examples/run-ash-pq-benchmark.sh \ + mpnet-2bit-ash.yml queued 8 0-7 "$BENCH_CASE" +``` + +Query memory placement defaults to NUMA node 0. Adapt the physical CPU and +memory-node lists together on other hosts. Do not run cases concurrently or +share their CPU allocation with another experiment. The heap must fit the +machine; report any heap, CPU, NUMA or library differences from the reference. + +Replace `ash` with `pq` and use a separate case directory for its matching PQ +case. Repeat with each prefix and bit count in the table. Construction refuses +nonempty model/index caches; query invocations require caches and reject a run +that rebuilt an index. No existing indexes are automatically deleted. + +For the full report, collect five fresh construction JVMs per case in separate +directories. Retain the first graph for queries, without choosing the best build. +Run three fresh query JVMs per algorithm, core budget and execution mode against +that first graph. Alternate PQ/ASH and direct/queued order across repetitions. +That is 80 builds and 192 query JVMs; each query JVM reports both overquery levels. + +The optional checked-in study driver launches exactly these standalone invocations +sequentially and aggregates their CSV files. It has no query loop or performance +timer. For a host with the reference CPU/node numbering: + +```bash +python3 jvector-examples/run-ash-pq-study.py \ + --data "$JVECTOR_BENCH_DATA_DIR" --output /data/jvector-bench/full-study +``` + +Use `--plan-only` to inspect the full job list. CPU/node arguments are listed by +`--help` for hosts with different topology. `plan.json` fixes the commit, artifact +checksum and settings; `status.json`, `results.json` and `summary.json` are updated +between invocations. Completed invocations can be resumed only with the same +source and artifact. A 30-minute process watchdog detects a hung invocation; +its wall clock is never reported as a performance measurement. Extra construction +repetitions release their model/index caches after recording results. First-build +graphs and all raw reporting artifacts are retained. + +## Standalone Java entry point + +The shell recipe writes a complete copyable `runs//command.sh`, +including environment settings. Reviewers can execute that Java command directly. +For example, after the case-directory setup above, a one-core queued invocation +has this form (set `REPO_ROOT` and `BENCH_JAR` to the checkout and built artifact): + +```bash +cd "$BENCH_CASE" +MKL_NUM_THREADS=1 OMP_NUM_THREADS=1 OPENBLAS_NUM_THREADS=1 \ +GOTO_NUM_THREADS=1 MKL_DYNAMIC=FALSE ONEDNN_VERBOSE=0 \ +numactl --physcpubind=0 --membind=0 "$JAVA_HOME/bin/java" \ + --add-modules=jdk.incubator.vector --enable-native-access=ALL-UNNAMED \ + -da -Xms2g -Xmx128g -XX:ActiveProcessorCount=1 \ + -Djvector.vectorization_provider=native -Djvector.parallel_writer=true \ + -Djvector.physical_core_count=1 -Djvector.bench.queryThreads=4 \ + -Djvector.bench.verifyConcurrentResults=true -Djvector.bench.throughputMinSeconds=3.0 \ + "-Djvector.bench.sourceRef=$(git -C "$REPO_ROOT" rev-parse HEAD)" \ + "-Djvector.bench.runConfig=$REPO_ROOT/jvector-examples/yaml-configs/ash-pq/run-queued.yml" \ + -cp "$BENCH_JAR" io.github.jbellis.jvector.example.BenchYAML \ + "$REPO_ROOT/jvector-examples/yaml-configs/ash-pq/mpnet-2bit-ash.yml" +``` + +The case directory contains copies of the checked-in dataset catalog, dataset +metadata and dataset list because the standard loader resolves them relative to +the working directory. `jvector.bench.runConfig` selects a run YAML without +editing the default. Absolute index-YAML paths work directly. No benchmark +kernel switches or prototype classpaths are used. + +## Measurements and artifacts + +* **Construction:** use BenchYAML's own inclusive training, encoding, NVQ and + graph/write timers. Do not add an external timer to these values. The two + overquery rows in a construction invocation describe one build, not two + repetitions. Cached-index invocations do not supply fresh construction times. +* **Throughput:** unchanged random-query warmups and measured full query passes, + including preparation, traversal and reranking. Each QPS sample lasts at least + three seconds and completes the final pass. Direct mode has 4C synchronous + callers; queued mode has at most C execution workers and admission 8C, with + default maximum batch 8. PQ uses the same queued policy without ASH preparation. +* **Unloaded latency:** BenchYAML submits and completes one query at a time, + with the same configured worker capacity. The timer ends when the call returns, + including preparation, execution and dispatch. This is not sustained latency + under an externally controlled arrival rate. +* **Queued loaded response:** supplementary samples during throughput include + admission and processing through the worker's completion callback. They end + before the submitting caller resumes. They measure bounded backlog, not a + Poisson arrival-rate curve; do not compare these percentiles as if they were + the unloaded measurement. Reporting aggregates completed samples only after + that query measurement group finishes. +* **Recall:** the same inputs, ground truth and request/result correlation are + used for both execution modes. The concurrency check also compares completed + queued results to serial searches. Require no execution-policy recall regression. + +CSV results, system information and dataset information are written under each +case's `logging` directory. The helper additionally records the immutable source +reference, JAR checksum, exact index/run YAML, Java version, CPU topology, Java +PID, console output and exit code under `runs`. Retain those artifacts together. +Report QPS and mean-latency means and sample standard deviations across JVMs, +and identify whether tail summaries are per-JVM medians/ranges or pooled samples. +Construction summaries use all five builds and retain their variance. + +Reporting deferral removes unnecessary compilation between measurement phases; +it does not guarantee that the JVM will never compile during a timed phase. +There is no compiler-quiescence gate or special warmup of measured query inputs. +Hardware, OS scheduling and parallel graph construction cause run variation; +reproduction means the same protocol and comparable distributions. diff --git a/docs/ash-query-integration.md b/docs/ash-query-integration.md index 5630393f2..8ccbe53c7 100644 --- a/docs/ash-query-integration.md +++ b/docs/ash-query-integration.md @@ -14,146 +14,167 @@ See the License for the specific language governing permissions and limitations under the License. --> -# Integrating ASH search into a database - -ASH search uses the application's calling threads. A search is synchronous: -its result or exception is returned to the caller when the work finishes. -JVector does not create an ASH query executor. Concurrent searches sharing an ASH -model can automatically combine projection work; isolated requests start -immediately without waiting for more queries. Applications submit ordinary, -independent searches and do not construct batches. - -## Recommended caller configuration - -Let **C** be the physical cores allocated to search, after reserving capacity for -other database work. Count the cores available to this service or partition, -not every core in the host and not both SMT siblings as two physical cores. -Use one shared admission budget across indexes that compete for those cores. -A request that fans out to several indexes consumes several search slots. - -Use **4C caller workers** and keep the default maximum projection batch size of -**8**. Apply the same rule across models and compression levels. The integration -selects its existing search CPU budget C; worker capacity is derived from it. -No model-specific concurrency setting or separate projection executor is needed. - -| Search CPU budget C | Caller worker capacity 4C | -| --- | --- | -| 1 core | 4 workers | -| 2 cores | 8 workers | -| 4 cores | 16 workers | -| 8 cores | 32 workers | -| 16 cores | 64 workers | -| 32 cores | 128 workers | -| 48 cores | 192 workers | - -This is the recommended configuration, not a requirement to maintain 4C requests -in flight. Under light traffic, one request starts immediately and the remaining -workers stay idle. Under load, additional callers allow searches to progress -while others wait for shared computation or I/O. They do not add CPU capacity. -Keep the worker capacity fixed as arrival rate changes; let bounded admission -control overload instead of creating more workers or waiting to fill a batch. - -Validate end-to-end latency against the database's service objectives. More -runnable callers than cores can introduce CPU scheduling tails. Record p50, p95, -p99 and deadline misses as well as QPS; the recommendation does not guarantee an -improvement in every latency percentile. - -Use a bounded platform-thread pool or an equivalent database scheduler for -these synchronous calls. A large number of tasks or virtual threads is not by -itself evidence that enough independent searches can execute concurrently. -Blocking native computation occupies the executing thread; test the scheduler -that will actually run in production. - -## Tell JVector about the allocation - -Before initializing JVector, set the existing JVM property -`-Djvector.physical_core_count=C` to the physical-core budget assigned to search. -ASH also respects the JVM's `availableProcessors()` limit, using the smaller of -these two counts. The property is a sizing hint: it does **not** pin threads or -reserve cores, and it also sizes other JVector physical-core executors. Use the -database's CPU controls, OS affinity, or container allocation to enforce the -budget. If necessary, use the JVM's `-XX:ActiveProcessorCount=C` consistently -with that allocation. - -On NUMA machines, place search callers and index memory together where practical. -Validate placement rather than assuming a thread-count property controls it. -Budget concurrent index construction, compaction and training separately from -query traffic so they cannot silently consume the search allocation. - -Keep ASH's default maximum projection batch size of **8**. No other ASH query -coordination setting is needed. The optional JVM property -`-Djvector.ash.queryBatchSize=1` disables query combining for comparison or -operational rollback. This property is read when each ASH model is constructed -or loaded; set it before doing so. It does not change compression, the index -format, or FP32 base-vector encoding/training. Batch size does not set the -application's queue depth or concurrent search count. - -For native deployment, follow the repository's -[oneDNN build and packaging instructions](../jvector-native/src/main/native/README.md#recommended-onednn-build-and-package). -JDK 22+ native access and the Vector API must be enabled as described in the -[provider instructions](../README.md#simd-provider-selection). The packaged -oneDNN query implementation runs on the calling threads without a nested native -thread pool. Retain that model; adding native query threads on top of the -application's callers oversubscribes the CPU budget. When optimized projection -is unavailable, JVector retains its existing projection fallback. - -## Admission, state ownership and completion - -The database owns arrival scheduling, overload handling and completion delivery: - -1. Admit a request to a bounded search executor or reject/backpressure it when - that executor and its bounded queue are full. Size the admission queue from - the request latency budget; an unbounded queue turns overload into long - delays and retained request memory. -2. Reuse the loaded index and its immutable ASH model across searches. Give - each active search exclusive use of a `GraphSearcher`, its view and mutable - scoring/scratch state. A pool can reuse that state after a call completes; - concurrent calls must not share one searcher's mutable state. Creating a - separate model per request prevents sharing and repeatedly allocates matrices. -3. Obtain the query vector from the database's normal request/source API before - invoking the synchronous search. Keep it and any borrowed buffers valid and - unmodified until the call returns. ASH does not fetch queries from an input - source or take ownership of the database's request queue. -4. Return the result or exception through the database's existing response path. - An asynchronous integration may complete an application-owned promise after - the synchronous call finishes. JVector does not create a future per query. - -A request timeout may stop the database waiting for a result, but must not cause -its buffers, searcher, or index/model resources to be reused or closed while -native work is still running. Release them only after the actual call finishes. -Interrupts are preserved; they do not abandon borrowed buffers in another -caller's computation. Apply deadlines and cancellation at admission and at safe -boundaries supported by the integration. Include waiting for admission in -end-to-end latency. - -## Validate the integration - -Use the database's normal query mix and scheduler, with native dependencies and -CPU/NUMA placement matching deployment. Compare the default against batch size 1 -under the same placement and caller budget: - -- **Isolated requests:** submit one query at a time to the normal 4C-worker - executor with the default batch size. There should be no deliberate delay to - collect more queries. One active request is a traffic scenario, not a different - worker configuration. -- **Sustained backlog:** keep the recommended 4C callers supplied with work. - Record achieved QPS, CPU utilization, memory and p50/p95/p99 - acceptance-to-completion latency. Keep the 4:1 worker/core ratio when comparing - different CPU budgets. -- **Intermediate and bursty traffic:** test several offered rates below - saturation, including a Poisson arrival stream and realistic bursts. Report - offered and achieved QPS separately and keep late requests in the latency - measurement instead of dropping them. - -Measure the entire real search, including query preparation and reranking. -Check recall against the same queries and ground truth, and require no regression -when changing execution policy. Repeat matched runs to distinguish scheduling -variance from a persistent regression. A projection-only speedup is useful -analysis, but does not establish an end-to-end search improvement. - -Warm each loaded model through the real query path before serving latency-sensitive -traffic if deployment permits. Include representative concurrency as well as -isolated searches during warmup. Query-specific matrices, native kernels and caller -scratch are created lazily. Include model-retained memory in the index budget and -per-active-caller scratch in the caller budget; increasing the caller count has a -memory cost even when the model is shared. +# Integrating ASH queries into a database + +The database selects the physical-core budget **C** for search. Queries may be +submitted independently without blocking a producer thread for every outstanding +request. JVector prepares currently waiting compatible queries together, then +executes each graph search independently. It never waits for a timer to fill a +batch. An isolated request starts as soon as its execution capacity is available. + +The existing synchronous `GraphSearcher` API remains available. The prepared-query +and bounded-execution APIs are optional and do not change index formats, write +ordering, FP32 encoding/training, or the default FP16 query precision selection. + +## Recommended asynchronous allocation + +Use these defaults for the search CPU budget assigned by the database: + +* At most **C** execution workers, shared across the indexes competing for those + cores. Producers do not need a worker thread for every submitted query. +* Maximum preparation batch **8** and admission **8C**. These are fixed defaults, + not settings to tune for particular datasets. Admission includes pending, + preparing and executing requests; completion callbacks are excluded. +* One immutable model representation per loaded model, with reusable prepared + state and exclusive search state for each active request. + +| Physical search cores C | Maximum execution workers | Request admission | +| --- | ---: | ---: | +| 1 | 1 | 8 | +| 2 | 2 | 16 | +| 4 | 4 | 32 | +| 8 | 8 | 64 | +| 16 | 16 | 128 | +| 32 | 32 | 256 | +| 48 | 48 | 384 | + +The integration needs to select only C. Do not expose internal batching policy +as database configuration. Larger explicit batches remain supported by the +numerical API; this recommendation does not impose an eight-row API limit. +The defaults bound memory and waiting but cannot guarantee a time limit under +arbitrary overload. Reject or backpressure arrivals when admission is exhausted. + +Choose physical cores, not both SMT siblings counted separately. Reserve a +separate budget for construction, compaction and other database work. Tell +JVector the allocation before initialization using `jvector.physical_core_count`; +use `-XX:ActiveProcessorCount` consistently when appropriate. Neither property +pins threads. Use the database's scheduler, OS affinity or container controls, +and place query execution and index memory together on NUMA systems. + +For an integration retaining only synchronous searches, the earlier caller +recommendation is 4C bounded caller threads, with the same physical CPU budget. +That does not mean an asynchronous integration needs 4C execution workers. +Both execution modes are measured separately by the +[reproducible BenchYAML comparison](ash-pq-benchmark.md). + +## Library responsibilities and host responsibilities + +`ImmutableGraphIndex.queryPreparer(similarity)` returns an optional +`QueryPreparer`. Cache it with the loaded model. `newState()` creates reusable +`PreparedQuery` buffers. `prepare` and `prepareBatch` snapshot FP32 query inputs, +compute the projection and scoring data, and retain no traversal state. ASH +uses its selected native query representation; encoding and training remain +FP32. Unsupported providers retain their existing fallback behavior. + +`BatchingExecutor` is a reusable two-stage executor, independent of ASH and +graph types. Its operation supplies a compatibility key, preparation, execution +and completion. A key is an immutable identity for the numerical model, not a +dataset name or mutable query buffer. Use null when preparation cannot benefit +from batching, as with the current PQ query path. Distinct keys never share a +preparation call. Mixed-model requests retain FIFO pending order; grouping only +collects compatible requests currently adjacent at the front. + +The host owns the executor and maps its request objects to these operations: + +```java +int searchCores = databaseSearchBudget; +int maximumBatch = 8; +int admission = Math.multiplyExact(searchCores, maximumBatch); +var searches = new BatchingExecutor(searchCores, admission, maximumBatch, + databaseSearchExecutor, operation); +if (!searches.trySubmit(request)) { + // The host still owns this request: reject or apply bounded backpressure. +} +``` + +The supplied executor must schedule asynchronously and explicitly reject work +it cannot accept. Do not use direct execution, caller-runs fallback or silent +discard. The service validates known unsuitable thread-pool policies. It submits +at most C reusable drain tasks and releases their execution capacity when idle; +it does not permanently park C tasks in the database's pool. A dedicated host +pool may use an array-backed work queue to avoid a queue-node allocation for +each task submission. Do not submit synchronous waiters to a pool whose entire +capacity they would occupy while waiting for this same pool to run searches. + +The execution operation borrows an exclusive `GraphSearcher`/view and binds the +prepared state through `ScoringView.scoreProviderFor(prepared, true)`. It then +uses the existing search, filtering and reranking API. Models without a preparer +use ordinary scoring. Numerical batches call the native batch kernel directly, +without a second scheduling layer. The preparing worker executes the first +search and publishes the others, allowing parallel traversal without waiting +for the whole batch to finish. + +## Input, result and resource ownership + +An accepted request owns stable input data until preparation completes; keeping +it stable through completion is the simplest contract. Source APIs that reuse +a scratch vector must copy into request-owned reusable storage before fetching +the next vector. A batch must not consist of several references to one source +buffer that is overwritten on each fetch. The database may queue many independent +requests from one producer, provided each request's inputs have this lifetime. + +Each active traversal exclusively owns its searcher, view and mutable scoring +state. `PreparedQuery` owns the query snapshot and numerical buffers; its +`ramBytesUsed()` excludes shared model weights. Do not prepare it again until +every search and resumable iterator borrowing it has finished. Publishing it +to another thread requires a normal queue/executor handoff. Generation checks +detect reuse of a prepared state that is still bound to a scoring provider. + +Completion returns each result or error through that request's callback. Use +the database's request identifier/promise/response destination to correlate it; +results need not return on the original worker or finish in submission order. +Completion callbacks must return promptly and must not wait for other work that +needs the same execution budget. Admission is released before completion so +callbacks can recycle a bounded request record safely. + +For a completed top-K `SearchResult`, request state can be recycled once the +callback has copied or transferred its result. A resumable iterator instead +retains an exclusive search session: keep its searcher, view and prepared state +leased until iteration finishes or closes. Do not return a worker's reusable +searcher to the pool while such an iterator is live. Budget retained sessions +and application-held results separately from the executor's admission count. + +This executor schedules read-only query work. It does not reorder additions, +deletions, flushes or publication of indexes. Retain the database's existing +ordering, visibility and view-consistency rules for those operations. + +A request timeout can stop the client waiting; it does not cancel a native call +or release borrowed buffers. Drain accepted work before closing model/index +resources. Interruption is preserved by close, which waits for callbacks too. +Close the batching service before shutting down its host executor; forcibly +discarding already accepted host tasks violates the execution contract. + +Preparation, search and explicit dispatch failures are delivered once to each +accepted request. A throwing completion callback is contained: inspect +`completionFailureCount()` and `firstCompletionFailure()` for monitoring. Later +submissions remain usable; graceful close reports retained callback failures +after draining. Call close from the lifecycle owner, not a service operation +or callback. Pool only bounded request/state storage, and include per-request +scratch, host task queues and retained sessions in the integration's memory budget. + +## Deployment and measurement + +Build/package native dependencies through the repository's +[oneDNN instructions](../jvector-native/src/main/native/README.md#recommended-onednn-build-and-package). +Enable Vector API modules and native access as described in the provider guide. +The preferred query implementation runs on the host workers without a nested +native thread pool; avoid adding native worker counts on top of that CPU budget. + +Validate production-like CPU/NUMA placement and actual traffic. Isolated queries +must launch without deliberate batching delay; sustained backlog should create +larger batches; intermediate arrivals should neither require a different +configuration nor an unbounded queue. Include preparation, traversal, reranking, +admission and dispatch in the metrics where they occur. Compare distributions, +serious stalls and achieved QPS, with unchanged recall and explicit overload +behavior. Use the checked-in BenchYAML recipe for reproducible library comparisons; +its unloaded latency and bounded-backlog response samples are different metrics. diff --git a/docs/ash-queued-query-poc.md b/docs/ash-queued-query-poc.md index f997de616..1244117af 100644 --- a/docs/ash-queued-query-poc.md +++ b/docs/ash-queued-query-poc.md @@ -1,8 +1,10 @@ -# Queued ASH query POC: review guide +# Queued ASH query experiments and review history -This branch preserves the experimental queued-query architecture for independent -review. It has a reproducible unloaded tail-latency regression and is not a -finished production recommendation. No Cassandra changes are included. +This document preserves the earlier POC revisions and their measured limitations. +The production integration and standalone benchmark protocol are documented in +[ASH query integration](ash-query-integration.md) and the +[reproduction guide](ash-pq-benchmark.md). The historical tables below do not +claim final-candidate performance. No Cassandra changes are included. ## Base and scope diff --git a/docs/benchmarking.md b/docs/benchmarking.md index 5809b9152..39c52465a 100644 --- a/docs/benchmarking.md +++ b/docs/benchmarking.md @@ -123,3 +123,10 @@ BenchYAML and AutoBenchYAML default to parallel complete-record serialization selects the sequential path, which encodes FP/NVQ features during insertion. Dataset prefetching is a separate option and is not required. See [Deferred batch serialization](deferred-batch-writer.md) for activation and caller examples. + + +## Fused PQ and ASH comparison + +The [standalone comparison recipe](ash-pq-benchmark.md) provides fixed, byte-matched +configurations for all four 1M dataset cases, native packaging commands, CPU/NUMA +placement, construction repetitions, and direct/queued query measurements. diff --git a/docs/release notes/4.1.0/ash-query-batching.feature.md b/docs/release notes/4.1.0/ash-query-batching.feature.md new file mode 100644 index 000000000..ddd5688ff --- /dev/null +++ b/docs/release notes/4.1.0/ash-query-batching.feature.md @@ -0,0 +1,13 @@ +Added reusable prepared-query and bounded asynchronous execution APIs. Compatible +ASH queries can use native FP16 batch projection before independent graph searches, +with immediate processing under light traffic and bounded admission under load. +Execution uses host-owned capacity, releases idle threads, contains callback +failures and drains accepted work on close. Existing synchronous search and index +formats remain supported. + +BenchYAML supports YAML-selected queued execution, explicit run/index configuration +paths, and source/concurrency metadata. The repository includes fixed fused PQ/ASH +comparison configurations and standalone reproduction commands. + +See [query integration](../../ash-query-integration.md) and the +[BenchYAML reproduction guide](../../ash-pq-benchmark.md). From d144364fae4079ccb5ed9b71d95079a6bd602a27 Mon Sep 17 00:00:00 2001 From: Ted Willke Date: Wed, 4 Nov 2026 03:18:08 +0000 Subject: [PATCH 139/243] Restore standard license checks for ASH sources and generated reports --- docs/figure9-graph-study/report-scripts/data.py | 14 ++++++++++++++ .../report-scripts/finalize.py | 14 ++++++++++++++ docs/figure9-graph-study/report-scripts/plot.py | 14 ++++++++++++++ docs/figure9-graph-study/report-scripts/tables.py | 14 ++++++++++++++ .../jvector/graph/AdaptiveTermination.java | 15 +++++++++++++++ .../jvector/quantization/ASHBlockScorer.java | 15 +++++++++++++++ .../example/TestAshRunWithoutTraining.java | 15 +++++++++++++++ .../jvector/graph/AdaptiveTerminationTest.java | 15 +++++++++++++++ rat-excludes.txt | 5 +++++ 9 files changed, 121 insertions(+) diff --git a/docs/figure9-graph-study/report-scripts/data.py b/docs/figure9-graph-study/report-scripts/data.py index f2f5cddfc..c3e1bf3ab 100644 --- a/docs/figure9-graph-study/report-scripts/data.py +++ b/docs/figure9-graph-study/report-scripts/data.py @@ -1,3 +1,17 @@ +# 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. + """Preserve primary observations and explicitly overlay predeclared longer retimings.""" from pathlib import Path import csv diff --git a/docs/figure9-graph-study/report-scripts/finalize.py b/docs/figure9-graph-study/report-scripts/finalize.py index 5625ba5f6..bc2dcd3eb 100644 --- a/docs/figure9-graph-study/report-scripts/finalize.py +++ b/docs/figure9-graph-study/report-scripts/finalize.py @@ -1,3 +1,17 @@ +# 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. + from pathlib import Path import csv,statistics,json,subprocess p=Path(__file__).resolve().parent.parent diff --git a/docs/figure9-graph-study/report-scripts/plot.py b/docs/figure9-graph-study/report-scripts/plot.py index ce9ab5028..a5982e45d 100644 --- a/docs/figure9-graph-study/report-scripts/plot.py +++ b/docs/figure9-graph-study/report-scripts/plot.py @@ -1,3 +1,17 @@ +# 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. + from pathlib import Path import csv,statistics,json import numpy as np diff --git a/docs/figure9-graph-study/report-scripts/tables.py b/docs/figure9-graph-study/report-scripts/tables.py index 7cc1bbacf..457b355c5 100644 --- a/docs/figure9-graph-study/report-scripts/tables.py +++ b/docs/figure9-graph-study/report-scripts/tables.py @@ -1,3 +1,17 @@ +# 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. + from pathlib import Path import csv,statistics p=Path(__file__).resolve().parent.parent diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/graph/AdaptiveTermination.java b/jvector-base/src/main/java/io/github/jbellis/jvector/graph/AdaptiveTermination.java index 1d0983e96..daa3ec21f 100644 --- a/jvector-base/src/main/java/io/github/jbellis/jvector/graph/AdaptiveTermination.java +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/graph/AdaptiveTermination.java @@ -1,3 +1,18 @@ +/* + * 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; /** diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHBlockScorer.java b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHBlockScorer.java index 0398bdfba..9a1c058f0 100644 --- a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHBlockScorer.java +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHBlockScorer.java @@ -1,3 +1,18 @@ +/* + * 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; /** diff --git a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/TestAshRunWithoutTraining.java b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/TestAshRunWithoutTraining.java index 9e6ee5566..68a6ab339 100644 --- a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/TestAshRunWithoutTraining.java +++ b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/TestAshRunWithoutTraining.java @@ -1,3 +1,18 @@ +/* + * 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; import io.github.jbellis.jvector.quantization.AsymmetricHashing; diff --git a/jvector-tests/src/test/java/io/github/jbellis/jvector/graph/AdaptiveTerminationTest.java b/jvector-tests/src/test/java/io/github/jbellis/jvector/graph/AdaptiveTerminationTest.java index 8b0102eed..e35ce8b74 100644 --- a/jvector-tests/src/test/java/io/github/jbellis/jvector/graph/AdaptiveTerminationTest.java +++ b/jvector-tests/src/test/java/io/github/jbellis/jvector/graph/AdaptiveTerminationTest.java @@ -1,3 +1,18 @@ +/* + * 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.util.BoundedLongHeap; diff --git a/rat-excludes.txt b/rat-excludes.txt index dbc696f0a..4702f7689 100644 --- a/rat-excludes.txt +++ b/rat-excludes.txt @@ -40,3 +40,8 @@ jvector-native/src/main/native/third_party/** src/main/native/third_party/** jvector-native/src/target/meson-build/** jvector-native/target/meson-build/** +docs/figure9-graph-study/*.csv +docs/figure9-graph-study/jars.sha256 +docs/benchmarks/**/*.csv +docs/benchmarks/native-ash-projection-symmetric-20261029/native-isa.txt +docs/ash-symmetric-study/*.csv From 1fe0dda3cc3c5c79e95ecd78c2fba35d9409b6e8 Mon Sep 17 00:00:00 2001 From: Ted Willke Date: Wed, 4 Nov 2026 05:41:37 +0000 Subject: [PATCH 140/243] Warm the actual single-request path before BenchYAML latency measurements --- docs/ash-pq-benchmark.md | 19 +++++ .../4.1.0/benchyaml-latency-warmup.bugfix.md | 1 + .../example/benchmarks/LatencyBenchmark.java | 61 ++++++++++++++- .../example/benchmarks/TestLatencyWarmup.java | 74 +++++++++++++++++++ 4 files changed, 153 insertions(+), 2 deletions(-) create mode 100644 docs/release notes/4.1.0/benchyaml-latency-warmup.bugfix.md create mode 100644 jvector-examples/src/test/java/io/github/jbellis/jvector/example/benchmarks/TestLatencyWarmup.java diff --git a/docs/ash-pq-benchmark.md b/docs/ash-pq-benchmark.md index 51636d16a..89c775d6d 100644 --- a/docs/ash-pq-benchmark.md +++ b/docs/ash-pq-benchmark.md @@ -223,3 +223,22 @@ it does not guarantee that the JVM will never compile during a timed phase. There is no compiler-quiescence gate or special warmup of measured query inputs. Hardware, OS scheduling and parallel graph construction cause run variation; reproduction means the same protocol and comparable distributions. + +## Unloaded-latency warmup correction + +Unloaded latency has its own warmup through the same single-request execution +path used by measurement. Throughput's batched warmup alone does not exercise +the synchronous queue handoff/completion path. Before timing, both direct and +queued modes run three complete random-query warmup passes, each for at least +three seconds. The query buffer is reused after each synchronous completion; +measured dataset queries are not used for warmup. The scoring operation and +timed latency interval are unchanged. No compiler-quietness gate is used. + +An ASH JFR profile identified compilation of the single-request completion, +queue and FP16 projection paths during the original latency measurement. +Old query results from that protocol must be identified separately. Since the +first-overquery latency phase precedes the next overquery's throughput phase, +corrected comparisons repeat the full query JVM, rather than replacing only +a latency cell. Construction results remain applicable when all construction +classes and native libraries are verified unchanged. Retain the source and +artifact provenance for each construction and query invocation. diff --git a/docs/release notes/4.1.0/benchyaml-latency-warmup.bugfix.md b/docs/release notes/4.1.0/benchyaml-latency-warmup.bugfix.md new file mode 100644 index 000000000..695bff6de --- /dev/null +++ b/docs/release notes/4.1.0/benchyaml-latency-warmup.bugfix.md @@ -0,0 +1 @@ +Warm BenchYAML unloaded-latency measurements through the same direct or queued single-request path used by timed queries. Three unrelated random-query warmup passes now precede measurement, each lasting at least three seconds and completing full query sets. This fixes queued single-request handoff and projection code first compiling during timed latency samples. Warmup reuses one input buffer and does not access the measured query set. diff --git a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/benchmarks/LatencyBenchmark.java b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/benchmarks/LatencyBenchmark.java index 12ad6983e..de8cd8e15 100644 --- a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/benchmarks/LatencyBenchmark.java +++ b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/benchmarks/LatencyBenchmark.java @@ -19,16 +19,26 @@ import java.util.List; import java.util.ArrayList; import java.util.Collections; +import java.util.Objects; +import java.util.concurrent.ThreadLocalRandom; +import java.util.concurrent.TimeUnit; +import java.util.function.Consumer; +import java.util.function.LongSupplier; import io.github.jbellis.jvector.example.Grid.ConfiguredSystem; import io.github.jbellis.jvector.graph.SearchResult; +import io.github.jbellis.jvector.vector.VectorUtil; +import io.github.jbellis.jvector.vector.VectorizationProvider; +import io.github.jbellis.jvector.vector.types.VectorFloat; /** - * Measures per‐query latency (mean and standard deviation) over N runs, - * and counts correct top‐K results. + * Measures unloaded per-query latency after random-query warmup through the + * same execution policy used by the timed requests. */ public class LatencyBenchmark extends AbstractQueryBenchmark { private static final String DEFAULT_FORMAT = ".3f"; + private static final int WARMUP_PASSES = 3; + private static final long MIN_WARMUP_NANOS = TimeUnit.SECONDS.toNanos(3); private boolean computeAvgLatency; private boolean computeLatencySTD; @@ -87,6 +97,41 @@ public LatencyBenchmark displayP999Latency(String format) { return this; } + /** + * Warm complete random-query passes for at least the minimum time per pass. + * The action must complete before returning: one query buffer is reused. + * A monotonic clock is supplied so the coverage/minimum-duration contract + * can be tested without sleeping. This does not inspect compiler activity. + */ + static long warmup(int dimension, int queriesPerPass, int passes, long minimumNanos, + Consumer> search, LongSupplier clock) { + if (dimension <= 0 || queriesPerPass <= 0 || passes <= 0 || minimumNanos < 0) + throw new IllegalArgumentException("Require positive dimension, query count and passes, and nonnegative duration"); + Objects.requireNonNull(search, "search"); + Objects.requireNonNull(clock, "clock"); + var query = VectorizationProvider.getInstance().getVectorTypeSupport().createFloatVector(dimension); + var random = ThreadLocalRandom.current(); + long completed = 0; + for (int pass = 0; pass < passes; pass++) { + long started = clock.getAsLong(); + do { + for (int i = 0; i < queriesPerPass; i++) { + boolean nonZero = false; + for (int d = 0; d < dimension; d++) { + float value = random.nextFloat(); + query.set(d, value); + nonZero |= value != 0; + } + if (!nonZero) query.set(0, 1); + VectorUtil.l2normalize(query); + search.accept(query); + completed++; + } + } while (clock.getAsLong() - started < minimumNanos); + } + return completed; + } + @Override public String getBenchmarkName() { return "LatencyBenchmark"; @@ -114,6 +159,18 @@ public List runBenchmark( try (QueuedQueryExecution queued = cs.usesQueuedExecution() ? new QueuedQueryExecution(cs, topK, rerankK, usePruning) : null) { + // Throughput warms batched submission. Unloaded latency uses a distinct + // synchronous handoff/completion path, so it needs its own warmup on the + // same workers and request buffers. Random inputs avoid warming the + // measured queries. Do not format reports between warmup and timing. + warmup(cs.getDataSet().getDimension(), totalQueries, WARMUP_PASSES, MIN_WARMUP_NANOS, + query -> { + SearchResult result = queued == null + ? QueryExecutor.executeQuery(cs, topK, rerankK, usePruning, query) + : queued.executeSingle(query); + SINK += result.getVisitedCount(); + }, System::nanoTime); + // Run the full set of queries queryRuns times for (int run = 0; run < queryRuns; run++) { for (int i = 0; i < totalQueries; i++) { diff --git a/jvector-examples/src/test/java/io/github/jbellis/jvector/example/benchmarks/TestLatencyWarmup.java b/jvector-examples/src/test/java/io/github/jbellis/jvector/example/benchmarks/TestLatencyWarmup.java new file mode 100644 index 000000000..ee28006d2 --- /dev/null +++ b/jvector-examples/src/test/java/io/github/jbellis/jvector/example/benchmarks/TestLatencyWarmup.java @@ -0,0 +1,74 @@ +/* + * 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.benchmarks; + +import org.junit.Test; +import java.util.Collections; +import java.util.IdentityHashMap; +import java.util.HashSet; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicLong; +import static org.junit.Assert.*; + +/** Exercises the actual warmup coverage, input ownership, timing and failure contract. */ +public class TestLatencyWarmup { + @Test + public void completePassesMeetTheDurationFloorAndReuseOnlyOneRandomInputBuffer() { + var clock = new AtomicLong(); + var identities = Collections.newSetFromMap(new IdentityHashMap()); + var samples = new HashSet(); + long completed = LatencyBenchmark.warmup(8, 3, 2, 10, query -> { + identities.add(query); + samples.add(query.get(0)); + double norm = 0; + for (int d = 0; d < 8; d++) norm += query.get(d) * query.get(d); + assertEquals(1.0, norm, 1e-5); + clock.addAndGet(2); + }, clock::get); + assertEquals(12, completed); // Two complete 3-query sweeps per timed pass. + assertEquals(24, clock.get()); + assertEquals(1, identities.size()); + assertTrue(samples.size() > 1); + } + + @Test + public void minimumQueryCoverageStillAppliesWithNoTimeFloor() { + var calls = new AtomicInteger(); + assertEquals(21, LatencyBenchmark.warmup(8, 7, 3, 0, + query -> calls.incrementAndGet(), () -> 0)); + assertEquals(21, calls.get()); + } + + @Test + public void failedWarmupPropagatesInsteadOfProducingLatencyMetrics() { + var error = new IllegalStateException("search failed"); + assertSame(error, assertThrows(IllegalStateException.class, + () -> LatencyBenchmark.warmup(8, 1, 1, 0, query -> { throw error; }, () -> 0))); + } + + @Test + public void invalidInputsFailBeforeWarmupWork() { + var calls = new AtomicInteger(); + for (int[] args : new int[][]{{0,1,1,0}, {8,0,1,0}, {8,1,0,0}, {8,1,1,-1}}) { + assertThrows(IllegalArgumentException.class, () -> LatencyBenchmark.warmup( + args[0], args[1], args[2], args[3], query -> calls.incrementAndGet(), () -> 0)); + } + assertEquals(0, calls.get()); + assertThrows(NullPointerException.class, () -> LatencyBenchmark.warmup(8,1,1,0,null,()->0)); + assertThrows(NullPointerException.class, () -> LatencyBenchmark.warmup(8,1,1,0,query->{},null)); + } +} From f0e40e3ea3b07bab7694c6b850ce75d1e5bc76ee Mon Sep 17 00:00:00 2001 From: Ted Willke Date: Wed, 4 Nov 2026 06:05:22 +0000 Subject: [PATCH 141/243] Share the latency execution pass between warmup and measurement --- docs/ash-pq-benchmark.md | 7 +- .../4.1.0/benchyaml-latency-warmup.bugfix.md | 2 +- .../example/benchmarks/LatencyBenchmark.java | 173 +++++++++--------- .../example/benchmarks/TestLatencyWarmup.java | 93 +++++++--- 4 files changed, 155 insertions(+), 120 deletions(-) diff --git a/docs/ash-pq-benchmark.md b/docs/ash-pq-benchmark.md index 89c775d6d..8e6cdcb69 100644 --- a/docs/ash-pq-benchmark.md +++ b/docs/ash-pq-benchmark.md @@ -230,9 +230,12 @@ Unloaded latency has its own warmup through the same single-request execution path used by measurement. Throughput's batched warmup alone does not exercise the synchronous queue handoff/completion path. Before timing, both direct and queued modes run three complete random-query warmup passes, each for at least -three seconds. The query buffer is reused after each synchronous completion; +three seconds. A bounded set of random query buffers is refilled between warmup sweeps; measured dataset queries are not used for warmup. The scoring operation and -timed latency interval are unchanged. No compiler-quietness gate is used. +timed latency interval are unchanged. Both phases use the same execution pass +and search-function instance, preventing separate call sites from leaving the +measured path cold after JIT inlining. Primitive timing storage avoids boxing +one temporary object per latency sample. No compiler-quietness gate is used. An ASH JFR profile identified compilation of the single-request completion, queue and FP16 projection paths during the original latency measurement. diff --git a/docs/release notes/4.1.0/benchyaml-latency-warmup.bugfix.md b/docs/release notes/4.1.0/benchyaml-latency-warmup.bugfix.md index 695bff6de..7677a1d5a 100644 --- a/docs/release notes/4.1.0/benchyaml-latency-warmup.bugfix.md +++ b/docs/release notes/4.1.0/benchyaml-latency-warmup.bugfix.md @@ -1 +1 @@ -Warm BenchYAML unloaded-latency measurements through the same direct or queued single-request path used by timed queries. Three unrelated random-query warmup passes now precede measurement, each lasting at least three seconds and completing full query sets. This fixes queued single-request handoff and projection code first compiling during timed latency samples. Warmup reuses one input buffer and does not access the measured query set. +Warm BenchYAML unloaded-latency measurements through the same direct or queued single-request path used by timed queries. Three unrelated random-query warmup passes now precede measurement, each lasting at least three seconds and completing full query sets. This fixes queued single-request handoff and projection code first compiling during timed latency samples. Warmup refills reusable random-query buffers and does not access the measured query set. Warmup and measurement share the same execution pass and call site; latency samples use primitive storage. diff --git a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/benchmarks/LatencyBenchmark.java b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/benchmarks/LatencyBenchmark.java index de8cd8e15..ca7ca7076 100644 --- a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/benchmarks/LatencyBenchmark.java +++ b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/benchmarks/LatencyBenchmark.java @@ -18,11 +18,11 @@ import java.util.List; import java.util.ArrayList; -import java.util.Collections; +import java.util.Arrays; import java.util.Objects; import java.util.concurrent.ThreadLocalRandom; import java.util.concurrent.TimeUnit; -import java.util.function.Consumer; +import java.util.function.Function; import java.util.function.LongSupplier; import io.github.jbellis.jvector.example.Grid.ConfiguredSystem; @@ -98,38 +98,87 @@ public LatencyBenchmark displayP999Latency(String format) { } /** - * Warm complete random-query passes for at least the minimum time per pass. - * The action must complete before returning: one query buffer is reused. - * A monotonic clock is supplied so the coverage/minimum-duration contract - * can be tested without sleeping. This does not inspect compiler activity. + * The same pass and search-function instance serve warmup and measurement. + * Separate dispatch sites can leave the measured call cold after the JVM + * inlines the warmup site. Random query buffers are allocated once and + * refilled between warmup sweeps, without touching measured query contents. */ - static long warmup(int dimension, int queriesPerPass, int passes, long minimumNanos, - Consumer> search, LongSupplier clock) { - if (dimension <= 0 || queriesPerPass <= 0 || passes <= 0 || minimumNanos < 0) - throw new IllegalArgumentException("Require positive dimension, query count and passes, and nonnegative duration"); + static long[] measure(VectorFloat[] queries, int queryRuns, int warmupPasses, + long minimumNanos, Function, SearchResult> search, + LongSupplier clock) { + Objects.requireNonNull(queries, "queries"); Objects.requireNonNull(search, "search"); Objects.requireNonNull(clock, "clock"); - var query = VectorizationProvider.getInstance().getVectorTypeSupport().createFloatVector(dimension); - var random = ThreadLocalRandom.current(); - long completed = 0; - for (int pass = 0; pass < passes; pass++) { + if (queries.length == 0 || queryRuns <= 0 || warmupPasses <= 0 || minimumNanos < 0) + throw new IllegalArgumentException("Require queries, positive run counts, and nonnegative warmup duration"); + int dimension = Objects.requireNonNull(queries[0], "query").length(); + if (dimension <= 0) throw new IllegalArgumentException("Query dimension must be positive"); + long[] times = new long[Math.multiplyExact(queries.length, queryRuns)]; + var vectors = VectorizationProvider.getInstance().getVectorTypeSupport(); + VectorFloat[] randomInputs = new VectorFloat[queries.length]; + for (int i = 0; i < randomInputs.length; i++) randomInputs[i] = vectors.createFloatVector(dimension); + + for (int phase = 0; phase <= warmupPasses; phase++) { + boolean warming = phase < warmupPasses; + VectorFloat[] inputs = warming ? randomInputs : queries; + int repeats = warming ? 1 : queryRuns; + int offset = 0; long started = clock.getAsLong(); do { - for (int i = 0; i < queriesPerPass; i++) { - boolean nonZero = false; - for (int d = 0; d < dimension; d++) { - float value = random.nextFloat(); - query.set(d, value); - nonZero |= value != 0; - } - if (!nonZero) query.set(0, 1); - VectorUtil.l2normalize(query); - search.accept(query); - completed++; - } - } while (clock.getAsLong() - started < minimumNanos); + if (warming) { randomize(randomInputs, dimension); offset = 0; } + for (int run = 0; run < repeats; run++) + offset = runPass(inputs, search, times, offset); + } while (warming && clock.getAsLong() - started < minimumNanos); + } + return times; + } + + private static void randomize(VectorFloat[] inputs, int dimension) { + var random = ThreadLocalRandom.current(); + for (var query : inputs) { + boolean nonZero = false; + for (int d = 0; d < dimension; d++) { + float value = random.nextFloat(); + query.set(d, value); + nonZero |= value != 0; + } + if (!nonZero) query.set(0, 1); + VectorUtil.l2normalize(query); + } + } + + /** No warmup/measurement branch or allocation at the timed call site. */ + private static int runPass(VectorFloat[] queries, Function, SearchResult> search, + long[] times, int offset) { + Objects.checkFromIndexSize(offset, queries.length, times.length); + for (int i = 0; i < queries.length; i++) { + long start = System.nanoTime(); + SearchResult result = search.apply(queries[i]); + long duration = System.nanoTime() - start; + times[offset++] = duration; + SINK += result.getVisitedCount(); + } + return offset; + } + + /** Summarize only the completed measured samples; warmup overwrites no input data. */ + List metrics(long[] latencies) { + double mean = 0, m2 = 0; + int count = 0; + for (long duration : latencies) { + count++; + double delta = duration - mean; + mean += delta / count; + m2 += delta * (duration - mean); } - return completed; + double standardDeviation = count > 0 ? Math.sqrt(m2 / count) / 1e6 : 0; + Arrays.sort(latencies); + int index = Math.max(0, Math.min(latencies.length - 1, (int) Math.ceil(0.999 * latencies.length) - 1)); + var result = new ArrayList(); + if (computeAvgLatency) result.add(Metric.of("search.latency.mean_ms", "Mean Latency (ms)", formatAvgLatency, mean / 1e6)); + if (computeLatencySTD) result.add(Metric.of("search.latency.std_ms", "STD Latency (ms)", formatLatencySTD, standardDeviation)); + if (computeP999Latency) result.add(Metric.of("search.latency.p999_ms", "p999 Latency (ms)", formatP999Latency, latencies[index] / 1e6)); + return result; } @Override @@ -149,70 +198,14 @@ public List runBenchmark( throw new IllegalArgumentException("At least one parameter must be set to true"); } - int totalQueries = cs.getDataSet().getQueryVectors().size(); - double mean = 0.0; - double m2 = 0.0; - int count = 0; - - // Collect every query latency - List latencies = new ArrayList<>(totalQueries * queryRuns); - + VectorFloat[] queries = cs.getDataSet().getQueryVectors().toArray(new VectorFloat[0]); try (QueuedQueryExecution queued = cs.usesQueuedExecution() ? new QueuedQueryExecution(cs, topK, rerankK, usePruning) : null) { - // Throughput warms batched submission. Unloaded latency uses a distinct - // synchronous handoff/completion path, so it needs its own warmup on the - // same workers and request buffers. Random inputs avoid warming the - // measured queries. Do not format reports between warmup and timing. - warmup(cs.getDataSet().getDimension(), totalQueries, WARMUP_PASSES, MIN_WARMUP_NANOS, - query -> { - SearchResult result = queued == null - ? QueryExecutor.executeQuery(cs, topK, rerankK, usePruning, query) - : queued.executeSingle(query); - SINK += result.getVisitedCount(); - }, System::nanoTime); - - // Run the full set of queries queryRuns times - for (int run = 0; run < queryRuns; run++) { - for (int i = 0; i < totalQueries; i++) { - long start = System.nanoTime(); - SearchResult sr = queued == null ? QueryExecutor.executeQuery( - cs, topK, rerankK, usePruning, i) - : queued.executeSingle(cs.getDataSet().getQueryVectors().get(i)); - long duration = System.nanoTime() - start; - // record latency for percentile computation - latencies.add(duration); - SINK += sr.getVisitedCount(); - - // Welford’s online algorithm for variance - count++; - double delta = duration - mean; - mean += delta / count; - m2 += delta * (duration - mean); - } - } - - mean /= 1e6; - double standardDeviation = (count > 0) ? Math.sqrt(m2 / count) / 1e6: 0.0; - - // Compute 99.9th percentile - Collections.sort(latencies); - int idx = (int)Math.ceil(0.999 * latencies.size()) - 1; - if (idx < 0) idx = 0; - if (idx >= latencies.size()) idx = latencies.size() - 1; - double p999Latency = latencies.get(idx) / 1e6; - - var list = new ArrayList(); - if (computeAvgLatency) { - list.add(Metric.of("search.latency.mean_ms", "Mean Latency (ms)", formatAvgLatency, mean)); - } - if (computeLatencySTD) { - list.add(Metric.of("search.latency.std_ms", "STD Latency (ms)", formatLatencySTD, standardDeviation)); + Function, SearchResult> search = query -> queued == null + ? QueryExecutor.executeQuery(cs, topK, rerankK, usePruning, query) + : queued.executeSingle(query); + long[] times = measure(queries, queryRuns, WARMUP_PASSES, MIN_WARMUP_NANOS, search, System::nanoTime); + return metrics(times); } - if (computeP999Latency) { - list.add(Metric.of("search.latency.p999_ms", "p999 Latency (ms)", formatP999Latency, p999Latency)); - } - return list; - } - } } diff --git a/jvector-examples/src/test/java/io/github/jbellis/jvector/example/benchmarks/TestLatencyWarmup.java b/jvector-examples/src/test/java/io/github/jbellis/jvector/example/benchmarks/TestLatencyWarmup.java index ee28006d2..5112c378b 100644 --- a/jvector-examples/src/test/java/io/github/jbellis/jvector/example/benchmarks/TestLatencyWarmup.java +++ b/jvector-examples/src/test/java/io/github/jbellis/jvector/example/benchmarks/TestLatencyWarmup.java @@ -16,6 +16,9 @@ package io.github.jbellis.jvector.example.benchmarks; +import io.github.jbellis.jvector.graph.SearchResult; +import io.github.jbellis.jvector.vector.VectorizationProvider; +import io.github.jbellis.jvector.vector.types.VectorFloat; import org.junit.Test; import java.util.Collections; import java.util.IdentityHashMap; @@ -24,51 +27,87 @@ import java.util.concurrent.atomic.AtomicLong; import static org.junit.Assert.*; -/** Exercises the actual warmup coverage, input ownership, timing and failure contract. */ +/** Checks the shared pass, random input ownership, timing floor and metric isolation. */ public class TestLatencyWarmup { + private static final SearchResult RESULT = new SearchResult(new SearchResult.NodeScore[0], 1, 0, 0, 0, 0); + + private VectorFloat[] queries(int count) { + var result = new VectorFloat[count]; + for (int i = 0; i < count; i++) + result[i] = VectorizationProvider.getInstance().getVectorTypeSupport() + .createFloatVector(new float[]{1,0,0,0,0,0,0,0}); + return result; + } + @Test - public void completePassesMeetTheDurationFloorAndReuseOnlyOneRandomInputBuffer() { + public void warmupAndMeasurementUseTheSameOperationAndExcludeWarmupSamples() { + var measured = queries(3); + var originals = Collections.newSetFromMap(new IdentityHashMap()); + Collections.addAll(originals, measured); + var warmIdentities = Collections.newSetFromMap(new IdentityHashMap()); + var values = new HashSet(); var clock = new AtomicLong(); - var identities = Collections.newSetFromMap(new IdentityHashMap()); - var samples = new HashSet(); - long completed = LatencyBenchmark.warmup(8, 3, 2, 10, query -> { - identities.add(query); - samples.add(query.get(0)); - double norm = 0; - for (int d = 0; d < 8; d++) norm += query.get(d) * query.get(d); - assertEquals(1.0, norm, 1e-5); + var warmCalls = new AtomicInteger(); + var measuredCalls = new AtomicInteger(); + long[] samples = LatencyBenchmark.measure(measured, 2, 2, 10, query -> { + if (originals.contains(query)) { + measuredCalls.incrementAndGet(); + assertEquals(12, warmCalls.get()); + assertEquals(1, query.get(0), 0); + assertEquals(0, query.get(1), 0); + } else { + warmCalls.incrementAndGet(); + warmIdentities.add(query); + values.add(query.get(0)); + double norm = 0; + for (int d = 0; d < 8; d++) norm += query.get(d) * query.get(d); + assertEquals(1, norm, 1e-5); + } clock.addAndGet(2); + return RESULT; }, clock::get); - assertEquals(12, completed); // Two complete 3-query sweeps per timed pass. - assertEquals(24, clock.get()); - assertEquals(1, identities.size()); - assertTrue(samples.size() > 1); + assertEquals(12, warmCalls.get()); + assertEquals(6, measuredCalls.get()); + assertEquals(6, samples.length); + assertEquals(3, warmIdentities.size()); // Reused across every warmup sweep. + assertTrue(values.size() > 3); + for (long value : samples) assertTrue(value >= 0); } @Test - public void minimumQueryCoverageStillAppliesWithNoTimeFloor() { + public void minimumQueryCoverageAppliesWithoutATimeFloor() { var calls = new AtomicInteger(); - assertEquals(21, LatencyBenchmark.warmup(8, 7, 3, 0, - query -> calls.incrementAndGet(), () -> 0)); - assertEquals(21, calls.get()); + long[] samples = LatencyBenchmark.measure(queries(7), 2, 3, 0, query -> { + calls.incrementAndGet(); return RESULT; + }, () -> 0); + assertEquals(35, calls.get()); + assertEquals(14, samples.length); + } + + @Test + public void statisticsUseOnlySuppliedMeasuredSamples() { + var metrics = LatencyBenchmark.createDefault().displayLatencySTD().displayP999Latency() + .metrics(new long[]{3_000_000, 1_000_000, 2_000_000}); + assertEquals(2, metrics.stream().filter(m -> m.getKey().equals("search.latency.mean_ms")).findFirst().orElseThrow().getValue(), 1e-9); + assertEquals(Math.sqrt(2.0 / 3), metrics.stream().filter(m -> m.getKey().equals("search.latency.std_ms")).findFirst().orElseThrow().getValue(), 1e-9); + assertEquals(3, metrics.stream().filter(m -> m.getKey().equals("search.latency.p999_ms")).findFirst().orElseThrow().getValue(), 1e-9); } @Test - public void failedWarmupPropagatesInsteadOfProducingLatencyMetrics() { + public void failedWarmupPropagatesBeforeAnyMeasuredSearch() { var error = new IllegalStateException("search failed"); assertSame(error, assertThrows(IllegalStateException.class, - () -> LatencyBenchmark.warmup(8, 1, 1, 0, query -> { throw error; }, () -> 0))); + () -> LatencyBenchmark.measure(queries(1), 1, 1, 0, query -> { throw error; }, () -> 0))); } @Test - public void invalidInputsFailBeforeWarmupWork() { + public void invalidInputsFailBeforeSearch() { var calls = new AtomicInteger(); - for (int[] args : new int[][]{{0,1,1,0}, {8,0,1,0}, {8,1,0,0}, {8,1,1,-1}}) { - assertThrows(IllegalArgumentException.class, () -> LatencyBenchmark.warmup( - args[0], args[1], args[2], args[3], query -> calls.incrementAndGet(), () -> 0)); - } + for (int[] args : new int[][]{{0,1,1,0}, {1,0,1,0}, {1,1,0,0}, {1,1,1,-1}}) + assertThrows(IllegalArgumentException.class, () -> LatencyBenchmark.measure( + queries(args[0]), args[1], args[2], args[3], query -> {calls.incrementAndGet();return RESULT;}, () -> 0)); assertEquals(0, calls.get()); - assertThrows(NullPointerException.class, () -> LatencyBenchmark.warmup(8,1,1,0,null,()->0)); - assertThrows(NullPointerException.class, () -> LatencyBenchmark.warmup(8,1,1,0,query->{},null)); + assertThrows(NullPointerException.class, () -> LatencyBenchmark.measure(queries(1),1,1,0,null,()->0)); + assertThrows(NullPointerException.class, () -> LatencyBenchmark.measure(queries(1),1,1,0,query->RESULT,null)); } } From 504606c43f4fb45f05da9a4976bdd5a48aabc70b Mon Sep 17 00:00:00 2001 From: Ted Willke Date: Wed, 4 Nov 2026 08:47:59 +0000 Subject: [PATCH 142/243] Validate queued query handoff using identical prepared arithmetic --- docs/ash-pq-benchmark.md | 8 +- .../benchyaml-prepared-validation.bugfix.md | 1 + jvector-examples/run-ash-pq-study.py | 10 +- .../benchmarks/QueuedQueryExecution.java | 46 +++++- .../benchmarks/ThroughputBenchmark.java | 25 +-- .../example/TestQueuedResultValidation.java | 147 ++++++++++++++++++ 6 files changed, 220 insertions(+), 17 deletions(-) create mode 100644 docs/release notes/4.1.0/benchyaml-prepared-validation.bugfix.md create mode 100644 jvector-examples/src/test/java/io/github/jbellis/jvector/example/TestQueuedResultValidation.java diff --git a/docs/ash-pq-benchmark.md b/docs/ash-pq-benchmark.md index 8e6cdcb69..c46da5565 100644 --- a/docs/ash-pq-benchmark.md +++ b/docs/ash-pq-benchmark.md @@ -208,7 +208,13 @@ kernel switches or prototype classpaths are used. that query measurement group finishes. * **Recall:** the same inputs, ground truth and request/result correlation are used for both execution modes. The concurrency check also compares completed - queued results to serial searches. Require no execution-policy recall regression. + queued results to searches on the preparing thread using the identical prepared + state, before handoff. This checks ownership and concurrency without conflating + FP32 reduction differences between singleton GEMV and batched GEMM. Numerical + projection tests independently check both kernels against their rounded oracle. + The study preserves actual recall values: ASH queued improvements are accepted, + while any decline stops collection for investigation. Other modes still require + exact recall equality with the retained construction reference. CSV results, system information and dataset information are written under each case's `logging` directory. The helper additionally records the immutable source diff --git a/docs/release notes/4.1.0/benchyaml-prepared-validation.bugfix.md b/docs/release notes/4.1.0/benchyaml-prepared-validation.bugfix.md new file mode 100644 index 000000000..a4305531c --- /dev/null +++ b/docs/release notes/4.1.0/benchyaml-prepared-validation.bugfix.md @@ -0,0 +1 @@ +BenchYAML now validates queued search handoff against the same prepared query state, avoiding false failures from rounding differences between singleton and batched projection. Numerical kernel and recall checks remain independent. diff --git a/jvector-examples/run-ash-pq-study.py b/jvector-examples/run-ash-pq-study.py index 3a032d20f..0fb7b83df 100755 --- a/jvector-examples/run-ash-pq-study.py +++ b/jvector-examples/run-ash-pq-study.py @@ -261,7 +261,15 @@ def summarize(): reference = next(result for result in results if result["job"]["prefix"] == job["prefix"] and result["job"]["bits"] == job["bits"] and result["job"]["algorithm"] == job["algorithm"] and result["job"]["mode"] == "construction" and result["job"]["repetition"] == 1) - assert [row["search.accuracy.recall_at_10"] for row in rows] == [row["search.accuracy.recall_at_10"] for row in reference["rows"]], "Execution-policy recall regression" + actual_recall = [float(row["search.accuracy.recall_at_10"]) for row in rows] + reference_recall = [float(row["search.accuracy.recall_at_10"]) for row in reference["rows"]] + if job["algorithm"] == "ash" and job["mode"] == "queued": + # Batch GEMM may differ from singleton GEMV by FP32 reduction rounding. + # Keep the actual values; an improvement is not a regression. Stop on + # any decline for investigation rather than silently applying a tolerance. + assert all(actual >= expected for actual, expected in zip(actual_recall, reference_recall)), "Execution-policy recall regression" + else: + assert actual_recall == reference_recall, "Execution-policy recall regression" result = dict(job=job, rows=rows, csv=str(csvs[-1]), artifacts=str(run_directory), libraries=sorted(libraries)) results.append(result) complete_ids.add(job["id"]) diff --git a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/benchmarks/QueuedQueryExecution.java b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/benchmarks/QueuedQueryExecution.java index e430cb312..909966a40 100644 --- a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/benchmarks/QueuedQueryExecution.java +++ b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/benchmarks/QueuedQueryExecution.java @@ -76,11 +76,14 @@ public static final class Pass { final LongAdder visited = new LongAdder(); final long[] responseTimes; public final SearchResult[] results; - Pass(int count, IntFunction> inputs, QueryTimeRecorder recorder, boolean capture) { + // Only validation passes allocate a reference result for each query. + final SearchResult[] references; + Pass(int count, IntFunction> inputs, QueryTimeRecorder recorder, boolean capture, boolean validate) { this.inputs = inputs; this.recorder = recorder; completed = new CountDownLatch(count); responseTimes = new long[count]; results = capture ? new SearchResult[count] : null; + references = validate ? new SearchResult[count] : null; } public long visited() { return visited.sum(); } } @@ -110,7 +113,10 @@ public Object batchKey(Request request) { public void prepareBatch(List requests) { for (Request request : requests) if (request.pass != null) request.query = request.pass.inputs.apply(request.ordinal); - if (preparer == null) return; + if (preparer == null) { + recordReferences(requests); + return; + } Rows buffers = rows.get(); try { for (int i = 0; i < requests.size(); i++) { @@ -118,11 +124,25 @@ public void prepareBatch(List requests) { buffers.states[i] = requests.get(i).state; } preparer.prepareBatch(buffers.queries, buffers.states, requests.size()); + recordReferences(requests); } finally { Arrays.fill(buffers.queries, 0, requests.size(), null); Arrays.fill(buffers.states, 0, requests.size(), null); } } + private void recordReferences(List requests) { + // Before publishing prepared work, search on the preparing + // thread with exactly the same numerical state. GEMV and + // GEMM can round differently; comparing against a separate + // singleton projection would conflate that with a race. + Pass pass = requests.get(0).pass; + if (pass == null || pass.references == null) return; + for (Request request : requests) { + request.pass.references[request.ordinal] = request.state == null + ? QueryExecutor.executeQuery(system, topK, rerankK, usePruning, request.query) + : QueryExecutor.executePreparedQuery(system, topK, rerankK, usePruning, request.state); + } + } public void execute(Request request) { request.result = request.state == null ? QueryExecutor.executeQuery(system, topK, rerankK, usePruning, request.query) @@ -176,9 +196,29 @@ private void submit(Request request) { } public Pass runPass(int count, IntFunction> source, QueryTimeRecorder recorder, boolean capture) { + return runPass(count, source, recorder, capture, false); + } + + /** + * Checks handoff and result ownership using the same prepared arithmetic on + * the preparing thread and the executing thread. Runs outside measurement; + * native numerical tests and AccuracyBenchmark check projection/recall. + */ + public void verifyConcurrentResults(int count, IntFunction> source) { + if (measuring) throw new IllegalStateException("Validation cannot run during measurement"); + Pass pass = runPass(count, source, null, true, true); + for (int i = 0; i < count; i++) { + if (!Arrays.equals(pass.references[i].getNodes(), pass.results[i].getNodes()) + || pass.references[i].getVisitedCount() != pass.results[i].getVisitedCount()) + throw new IllegalStateException("Concurrent prepared query mismatch at " + i); + } + } + + private Pass runPass(int count, IntFunction> source, + QueryTimeRecorder recorder, boolean capture, boolean validate) { if (count < 0) throw new IllegalArgumentException("Query count must be nonnegative"); Objects.requireNonNull(source, "source"); - Pass pass = new Pass(count, source, recorder == null ? NO_TIMING : recorder, capture); + Pass pass = new Pass(count, source, recorder == null ? NO_TIMING : recorder, capture, validate); for (int i = 0; i < count; i++) { long submittedAt = System.nanoTime(); // Includes waiting for admission. Request request = acquire(); diff --git a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/benchmarks/ThroughputBenchmark.java b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/benchmarks/ThroughputBenchmark.java index d09cc5634..f71b5785c 100644 --- a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/benchmarks/ThroughputBenchmark.java +++ b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/benchmarks/ThroughputBenchmark.java @@ -191,18 +191,19 @@ private List runConfiguredBenchmark(ConfiguredSystem cs, int topK, int r ? new QueuedQueryExecution(cs, topK, rerankK, usePruning) : null) { if (Boolean.getBoolean("jvector.bench.verifyConcurrentResults")) { int count = cs.getDataSet().getQueryVectors().size(); - SearchResult[] serial = new SearchResult[count]; - for (int i = 0; i < count; i++) - serial[i] = QueryExecutor.executeQuery(cs, topK, rerankK, usePruning, i); - SearchResult[] queuedResults = queued == null ? null : queued.runPass(count, - cs.getDataSet().getQueryVectors()::get, null, true).results; - queryStream(count).forEach(i -> { - SearchResult actual = queuedResults == null - ? QueryExecutor.executeQuery(cs, topK, rerankK, usePruning, i) : queuedResults[i]; - if (!Arrays.equals(serial[i].getNodes(), actual.getNodes()) - || (queued == null && serial[i].getVisitedCount() != actual.getVisitedCount())) - throw new IllegalStateException("Concurrent query mismatch at " + i); - }); + if (queued != null) { + queued.verifyConcurrentResults(count, cs.getDataSet().getQueryVectors()::get); + } else { + SearchResult[] serial = new SearchResult[count]; + for (int i = 0; i < count; i++) + serial[i] = QueryExecutor.executeQuery(cs, topK, rerankK, usePruning, i); + queryStream(count).forEach(i -> { + SearchResult actual = QueryExecutor.executeQuery(cs, topK, rerankK, usePruning, i); + if (!Arrays.equals(serial[i].getNodes(), actual.getNodes()) + || serial[i].getVisitedCount() != actual.getVisitedCount()) + throw new IllegalStateException("Concurrent query mismatch at " + i); + }); + } System.out.printf("CONCURRENCY_VALIDATION queries=%d topK=%d rerankK=%d status=PASS%n", count, topK, rerankK); } diff --git a/jvector-examples/src/test/java/io/github/jbellis/jvector/example/TestQueuedResultValidation.java b/jvector-examples/src/test/java/io/github/jbellis/jvector/example/TestQueuedResultValidation.java new file mode 100644 index 000000000..cd0b3e0d3 --- /dev/null +++ b/jvector-examples/src/test/java/io/github/jbellis/jvector/example/TestQueuedResultValidation.java @@ -0,0 +1,147 @@ +/* + * 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; + +import io.github.jbellis.jvector.example.benchmarks.QueuedQueryExecution; +import io.github.jbellis.jvector.example.benchmarks.datasets.SimpleDataSet; +import io.github.jbellis.jvector.graph.ImmutableGraphIndex; +import io.github.jbellis.jvector.graph.NodesIterator; +import io.github.jbellis.jvector.graph.similarity.*; +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 org.junit.Test; +import java.util.ArrayList; +import java.util.List; +import java.util.Set; +import java.util.concurrent.atomic.AtomicInteger; +import static org.junit.Assert.*; + +/** Separate numerical batch differences from corrupted prepared-state handoffs. */ +public class TestQueuedResultValidation { + private static VectorFloat vector(float value) { + return VectorizationProvider.getInstance().getVectorTypeSupport().createFloatVector(new float[]{value}); + } + + private static final class State implements PreparedQuery { + VectorFloat query; + float projected; + int searches; + public VectorFloat queryVector() { return query; } + public long ramBytesUsed() { return 0; } + } + + private static final class Preparer implements QueryPreparer { + public PreparedQuery newState() { return new State(); } + public boolean supportsBatchPreparation() { return true; } + public long ramBytesUsed() { return 0; } + public void prepare(VectorFloat query, PreparedQuery output) { + State state = (State) output; + state.query = query; state.projected = query.get(0); state.searches = 0; + } + public void prepareBatch(VectorFloat[] inputs, PreparedQuery[] outputs, int count) { + for (int i = 0; i < count; i++) { + prepare(inputs[i], outputs[i]); + // Deliberately distinguish batch arithmetic from singleton + // arithmetic. Validation must compare the same prepared state. + ((State) outputs[i]).projected += .00001f; + } + } + } + + private static final class Graph implements ImmutableGraphIndex { + final AtomicInteger bindings = new AtomicInteger(); + final boolean corrupt; + Graph(boolean corrupt) { this.corrupt = corrupt; } + public NodesIterator getNodes(int level) { return new NodesIterator.ArrayNodesIterator(new int[]{0}); } + public int maxDegree() { return 1; } + public List maxDegrees() { return List.of(1); } + public int getDimension() { return 1; } + public boolean isHierarchical() { return false; } + public int getMaxLevel() { return 0; } + public int getDegree(int level) { return 1; } + public double getAverageDegree(int level) { return 0; } + public int size(int level) { return 1; } + public long ramBytesUsed() { return 0; } + public void close() {} + public ScoringView getView() { + return new ScoringView() { + public NodesIterator getNeighborsIterator(int level, int node) { return NodesIterator.EMPTY_NODE_ITERATOR; } + public void processNeighbors(int level, int node, ScoreFunction score, IntMarker visited, NeighborProcessor processor) {} + public int size() { return 1; } + public NodeAtLevel entryNode() { return new NodeAtLevel(0, 0); } + public Bits liveNodes() { return Bits.ALL; } + public boolean contains(int level, int node) { return level == 0 && node == 0; } + public void close() {} + public ScoreFunction.ExactScoreFunction rerankerFor(VectorFloat query, VectorSimilarityFunction similarity) { + return node -> query.get(0); + } + public ScoreFunction.ApproximateScoreFunction approximateScoreFunctionFor(VectorFloat query, VectorSimilarityFunction similarity) { + return node -> query.get(0); + } + public SearchScoreProvider scoreProviderFor(PreparedQuery prepared, boolean rerank) { + State state = (State) prepared; + bindings.incrementAndGet(); + float score = state.projected + (corrupt && state.searches++ > 0 ? .1f : 0); + return new DefaultSearchScoreProvider((ScoreFunction.ExactScoreFunction) node -> score); + } + }; + } + } + + private Grid.ConfiguredSystem system(Graph graph, int workers) { + var queries = new ArrayList>(); + for (int i = 0; i < 97; i++) queries.add(vector(.1f + i / 1000f)); + var data = new SimpleDataSet("queued-validation", VectorSimilarityFunction.DOT_PRODUCT, + List.of(vector(1)), queries, java.util.Collections.nCopies(queries.size(), List.of(0))); + return new Grid.ConfiguredSystem(data, graph, null, Set.of()) { + final Preparer preparer = new Preparer(); + @Override public int queryWorkerCount() { return workers; } + @Override public QueryPreparer queryPreparer() { return preparer; } + }; + } + + @Test(timeout=20000) + public void identicalPreparedMathValidatesAcrossWorkersAndNormalPassDoesNotSearchTwice() throws Exception { + for (int workers : new int[]{1, 3}) { + var graph = new Graph(false); + try (var cs = system(graph, workers); var queued = new QueuedQueryExecution(cs, 1, 1, false)) { + var queries = cs.getDataSet().getQueryVectors(); + queued.verifyConcurrentResults(queries.size(), queries::get); + assertEquals(2 * queries.size(), graph.bindings.get()); + graph.bindings.set(0); + var pass = queued.runPass(queries.size(), queries::get, null, true); + assertEquals(queries.size(), graph.bindings.get()); + for (int i = 0; i < queries.size(); i++) + assertEquals(queries.get(i).get(0) + .00001f, pass.results[i].getNodes()[0].score, 0); + queued.verifyConcurrentResults(0, i -> { throw new AssertionError("Empty validation fetched input"); }); + queued.beginMeasurements(); + assertThrows(IllegalStateException.class, () -> queued.verifyConcurrentResults(0, queries::get)); + } + } + } + + @Test(timeout=10000) + public void corruptedPreparedStateStillFailsValidation() throws Exception { + try (var cs = system(new Graph(true), 2); var queued = new QueuedQueryExecution(cs, 1, 1, false)) { + var queries = cs.getDataSet().getQueryVectors(); + var error = assertThrows(IllegalStateException.class, + () -> queued.verifyConcurrentResults(queries.size(), queries::get)); + assertTrue(error.getMessage().startsWith("Concurrent prepared query mismatch at ")); + } + } +} From 55727c0e58d2bc8efee62a8507bbcb13266dd451 Mon Sep 17 00:00:00 2001 From: Ted Willke Date: Wed, 4 Nov 2026 12:27:24 +0000 Subject: [PATCH 143/243] Use consistent prepared arithmetic in direct benchmark validation --- docs/ash-pq-benchmark.md | 3 +- .../benchyaml-prepared-validation.bugfix.md | 2 +- .../example/benchmarks/QueryExecutor.java | 20 ++++++++++ .../benchmarks/ThroughputBenchmark.java | 14 +++---- .../example/TestQueuedResultValidation.java | 40 +++++++++++++++++++ 5 files changed, 68 insertions(+), 11 deletions(-) diff --git a/docs/ash-pq-benchmark.md b/docs/ash-pq-benchmark.md index c46da5565..6f56c9a6d 100644 --- a/docs/ash-pq-benchmark.md +++ b/docs/ash-pq-benchmark.md @@ -209,7 +209,8 @@ kernel switches or prototype classpaths are used. * **Recall:** the same inputs, ground truth and request/result correlation are used for both execution modes. The concurrency check also compares completed queued results to searches on the preparing thread using the identical prepared - state, before handoff. This checks ownership and concurrency without conflating + state, before handoff. Direct validation runs on the concurrent caller threads + and repeats traversal with one prepared score provider for each query. This checks ownership and concurrency without conflating FP32 reduction differences between singleton GEMV and batched GEMM. Numerical projection tests independently check both kernels against their rounded oracle. The study preserves actual recall values: ASH queued improvements are accepted, diff --git a/docs/release notes/4.1.0/benchyaml-prepared-validation.bugfix.md b/docs/release notes/4.1.0/benchyaml-prepared-validation.bugfix.md index a4305531c..ef75d6518 100644 --- a/docs/release notes/4.1.0/benchyaml-prepared-validation.bugfix.md +++ b/docs/release notes/4.1.0/benchyaml-prepared-validation.bugfix.md @@ -1 +1 @@ -BenchYAML now validates queued search handoff against the same prepared query state, avoiding false failures from rounding differences between singleton and batched projection. Numerical kernel and recall checks remain independent. +BenchYAML now validates queued query handoff and concurrent direct search reuse using identical prepared arithmetic, avoiding false failures from rounding differences between singleton and batched projection. Numerical kernel and measured recall checks remain independent. diff --git a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/benchmarks/QueryExecutor.java b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/benchmarks/QueryExecutor.java index d67fb4a2f..184ff2546 100644 --- a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/benchmarks/QueryExecutor.java +++ b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/benchmarks/QueryExecutor.java @@ -22,6 +22,26 @@ import io.github.jbellis.jvector.vector.types.VectorFloat; public class QueryExecutor { + /** + * Outside measurement, checks repeated searches on each caller's owned + * searcher using one prepared provider. Concurrent callers may form native + * projection batches; their floating-point reduction can differ slightly + * from a separate singleton preparation. Kernel-oracle and recall checks + * validate that numerical work independently. + */ + public static void verifyConcurrentQuery(ConfiguredSystem cs, int topK, int rerankK, + boolean usePruning, int ordinal) { + var searcher = cs.getSearcher(); + searcher.usePruning(usePruning); + var query = cs.getDataSet().getQueryVectors().get(ordinal); + var provider = cs.scoreProviderFor(query, searcher.getView()); + var expected = searcher.search(provider, topK, rerankK, 0.0f, 0.0f, Bits.ALL); + var actual = searcher.search(provider, topK, rerankK, 0.0f, 0.0f, Bits.ALL); + if (!java.util.Arrays.equals(expected.getNodes(), actual.getNodes()) + || expected.getVisitedCount() != actual.getVisitedCount()) + throw new IllegalStateException("Concurrent prepared query mismatch at " + ordinal); + } + /** Executes only traversal/reranking after a caller has prepared the query. */ public static SearchResult executePreparedQuery(ConfiguredSystem cs, int topK, int rerankK, boolean usePruning, io.github.jbellis.jvector.graph.similarity.PreparedQuery prepared) { diff --git a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/benchmarks/ThroughputBenchmark.java b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/benchmarks/ThroughputBenchmark.java index f71b5785c..06421f18a 100644 --- a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/benchmarks/ThroughputBenchmark.java +++ b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/benchmarks/ThroughputBenchmark.java @@ -194,15 +194,11 @@ private List runConfiguredBenchmark(ConfiguredSystem cs, int topK, int r if (queued != null) { queued.verifyConcurrentResults(count, cs.getDataSet().getQueryVectors()::get); } else { - SearchResult[] serial = new SearchResult[count]; - for (int i = 0; i < count; i++) - serial[i] = QueryExecutor.executeQuery(cs, topK, rerankK, usePruning, i); - queryStream(count).forEach(i -> { - SearchResult actual = QueryExecutor.executeQuery(cs, topK, rerankK, usePruning, i); - if (!Arrays.equals(serial[i].getNodes(), actual.getNodes()) - || serial[i].getVisitedCount() != actual.getVisitedCount()) - throw new IllegalStateException("Concurrent query mismatch at " + i); - }); + // Direct callers may naturally combine projections too. Keep + // the prepared provider fixed while checking per-thread search + // reuse; comparing GEMV with GEMM would test rounding instead. + queryStream(count).forEach(i -> + QueryExecutor.verifyConcurrentQuery(cs, topK, rerankK, usePruning, i)); } System.out.printf("CONCURRENCY_VALIDATION queries=%d topK=%d rerankK=%d status=PASS%n", count, topK, rerankK); diff --git a/jvector-examples/src/test/java/io/github/jbellis/jvector/example/TestQueuedResultValidation.java b/jvector-examples/src/test/java/io/github/jbellis/jvector/example/TestQueuedResultValidation.java index cd0b3e0d3..614281ac1 100644 --- a/jvector-examples/src/test/java/io/github/jbellis/jvector/example/TestQueuedResultValidation.java +++ b/jvector-examples/src/test/java/io/github/jbellis/jvector/example/TestQueuedResultValidation.java @@ -144,4 +144,44 @@ public void corruptedPreparedStateStillFailsValidation() throws Exception { assertTrue(error.getMessage().startsWith("Concurrent prepared query mismatch at ")); } } + private Grid.ConfiguredSystem directSystem(boolean corrupt, AtomicInteger preparations) { + List> queries = java.util.Collections.nCopies(97, vector(.3f)); + var data = new SimpleDataSet("direct-validation", VectorSimilarityFunction.DOT_PRODUCT, + List.of(vector(1)), queries, java.util.Collections.nCopies(queries.size(), List.of(0))); + return new Grid.ConfiguredSystem(data, new Graph(false), null, Set.of()) { + @Override public SearchScoreProvider scoreProviderFor(VectorFloat query, ImmutableGraphIndex.View view) { + preparations.incrementAndGet(); + // Like GEMV/GEMM reduction rounding, preparation on different + // caller threads may produce distinct, individually valid math. + final float value = query.get(0) + Thread.currentThread().getId() * .00001f; + final int[] calls = {0}; + return new DefaultSearchScoreProvider((ScoreFunction.ExactScoreFunction) node -> + value + (corrupt && calls[0]++ > 0 ? .1f : 0)); + } + }; + } + + @Test(timeout=20000) + public void concurrentDirectValidationPreparesOneProviderPerInput() throws Exception { + var preparations = new AtomicInteger(); + try (var cs = directSystem(false, preparations)) { + java.util.stream.IntStream.range(0, cs.getDataSet().getQueryVectors().size()).parallel() + .forEach(i -> io.github.jbellis.jvector.example.benchmarks.QueryExecutor + .verifyConcurrentQuery(cs, 1, 1, false, i)); + assertEquals(cs.getDataSet().getQueryVectors().size(), preparations.get()); + } + } + + @Test(timeout=10000) + public void directValidationStillDetectsChangedScoresFromThePreparedProvider() throws Exception { + var preparations = new AtomicInteger(); + try (var cs = directSystem(true, preparations)) { + var error = assertThrows(IllegalStateException.class, + () -> io.github.jbellis.jvector.example.benchmarks.QueryExecutor + .verifyConcurrentQuery(cs, 1, 1, false, 0)); + assertTrue(error.getMessage().startsWith("Concurrent prepared query mismatch at ")); + assertEquals(1, preparations.get()); + } + } + } From 8eca9c35ec42a948f7da68c597d5f6a6ef4e8f3d Mon Sep 17 00:00:00 2001 From: Ted Willke Date: Wed, 4 Nov 2026 13:17:14 +0000 Subject: [PATCH 144/243] Add reproducible large-dataset fused ASH and PQ studies --- docs/ash-pq-benchmark.md | 49 +++++++++++++++++++ jvector-examples/run-ash-pq-benchmark.sh | 9 +++- jvector-examples/run-ash-pq-study.py | 15 ++++-- .../StandaloneASHBenchmarkConfigTest.java | 3 +- .../yaml-configs/ash-pq/cap6m-2bit-ash.yml | 38 ++++++++++++++ .../yaml-configs/ash-pq/cap6m-2bit-pq.yml | 38 ++++++++++++++ .../yaml-configs/ash-pq/cap6m-4bit-ash.yml | 38 ++++++++++++++ .../yaml-configs/ash-pq/cap6m-4bit-pq.yml | 38 ++++++++++++++ .../yaml-configs/ash-pq/catalog-large.yml | 18 +++++++ .../ash-pq/cohere10m-2bit-ash.yml | 38 ++++++++++++++ .../yaml-configs/ash-pq/cohere10m-2bit-pq.yml | 38 ++++++++++++++ .../ash-pq/cohere10m-4bit-ash.yml | 38 ++++++++++++++ .../yaml-configs/ash-pq/cohere10m-4bit-pq.yml | 38 ++++++++++++++ .../yaml-configs/ash-pq/mpnet10m-2bit-ash.yml | 38 ++++++++++++++ .../yaml-configs/ash-pq/mpnet10m-2bit-pq.yml | 38 ++++++++++++++ .../yaml-configs/ash-pq/mpnet10m-4bit-ash.yml | 38 ++++++++++++++ .../yaml-configs/ash-pq/mpnet10m-4bit-pq.yml | 38 ++++++++++++++ .../yaml-configs/dataset-metadata.yml | 4 ++ 18 files changed, 547 insertions(+), 7 deletions(-) create mode 100644 jvector-examples/yaml-configs/ash-pq/cap6m-2bit-ash.yml create mode 100644 jvector-examples/yaml-configs/ash-pq/cap6m-2bit-pq.yml create mode 100644 jvector-examples/yaml-configs/ash-pq/cap6m-4bit-ash.yml create mode 100644 jvector-examples/yaml-configs/ash-pq/cap6m-4bit-pq.yml create mode 100644 jvector-examples/yaml-configs/ash-pq/catalog-large.yml create mode 100644 jvector-examples/yaml-configs/ash-pq/cohere10m-2bit-ash.yml create mode 100644 jvector-examples/yaml-configs/ash-pq/cohere10m-2bit-pq.yml create mode 100644 jvector-examples/yaml-configs/ash-pq/cohere10m-4bit-ash.yml create mode 100644 jvector-examples/yaml-configs/ash-pq/cohere10m-4bit-pq.yml create mode 100644 jvector-examples/yaml-configs/ash-pq/mpnet10m-2bit-ash.yml create mode 100644 jvector-examples/yaml-configs/ash-pq/mpnet10m-2bit-pq.yml create mode 100644 jvector-examples/yaml-configs/ash-pq/mpnet10m-4bit-ash.yml create mode 100644 jvector-examples/yaml-configs/ash-pq/mpnet10m-4bit-pq.yml diff --git a/docs/ash-pq-benchmark.md b/docs/ash-pq-benchmark.md index 6f56c9a6d..46c3fad27 100644 --- a/docs/ash-pq-benchmark.md +++ b/docs/ash-pq-benchmark.md @@ -231,6 +231,55 @@ There is no compiler-quiescence gate or special warmup of measured query inputs. Hardware, OS scheduling and parallel graph construction cause run variation; reproduction means the same protocol and comparable distributions. +## Larger-dataset comparison + +The same index parameters, native defaults, matched 2-bit/4-bit budgets, JDK 22, +48-core construction and 1-/8-core query protocol apply to the larger inputs. +The checked-in `catalog-large.yml` uses relative local paths under +`JVECTOR_BENCH_DATA_DIR`; no credentials or server-specific paths are required. +MPNet 10M uses 101/197 code bytes, Cohere 10M 133/261, and CAP 6M 197/389. + +| Dataset | Base vectors | Dimensions | Queries | +| --- | ---: | ---: | ---: | +| MPNet 10M | 9,997,697 | 768 | 10,000 | +| Cohere 10M | 10,000,000 | 1024 | 10,000 | +| CAP 6M | 6,070,488 | 1536 | 10,000 | + +Use the exact base, query and top-100 ground-truth files named in the catalog. +All use `DOT_PRODUCT` and `NO_SCRUB`; replacing a base file with a deduplicated +variant without rebuilding its ground truth would change the experiment. + +```bash +python3 jvector-examples/run-ash-pq-study.py --dataset-set large \ + --data /data/normalized-large-vector-datasets \ + --output /data/jvector-bench/large-study +``` + +Large construction invocations use a three-hour process watchdog, while query +invocations retain the 30-minute watchdog. These only detect an invocation that +failed to finish; their wall clock does not supply benchmark measurements. + +The large study has 60 fresh construction JVMs and 144 query JVMs: five builds +and three query repetitions per configuration, with both overquery levels +reported by each query JVM. It runs after the four-dataset 1M study, without +sharing CPUs with another measurement. Only the first graph per configuration +is retained for queries; redundant construction caches are released after +recording their results. Reserve sufficient storage for twelve retained +indexes plus one additional construction, and record the output volume. + +For a single case, select the same local catalog explicitly: + +```bash +JVECTOR_BENCH_CATALOG=catalog-large.yml \ +JVECTOR_BENCH_NUMA_POLICY=interleave:0,1 \ + bash jvector-examples/run-ash-pq-benchmark.sh \ + mpnet10m-2bit-ash.yml construction 48 0-47 /data/jvector-bench/mpnet10m-2bit-ash-r1 +``` + +The helper copies the selected checked-in catalog into the case directory and +records it alongside the index/run YAML and full standalone command. Use that +same catalog selection for subsequent direct/queued invocations. + ## Unloaded-latency warmup correction Unloaded latency has its own warmup through the same single-request execution diff --git a/jvector-examples/run-ash-pq-benchmark.sh b/jvector-examples/run-ash-pq-benchmark.sh index 19f1ad88c..12328740d 100755 --- a/jvector-examples/run-ash-pq-benchmark.sh +++ b/jvector-examples/run-ash-pq-benchmark.sh @@ -72,6 +72,10 @@ SOURCE_REF="$(git -C "$REPO_ROOT" rev-parse HEAD)" JAVA_BIN="$(command -v java)" CONFIG="$REPO_ROOT/jvector-examples/yaml-configs/ash-pq/$CONFIG_NAME" RUN_CONFIG="$REPO_ROOT/jvector-examples/yaml-configs/ash-pq/run-$MODE.yml" +CATALOG_NAME="${JVECTOR_BENCH_CATALOG:-catalog-1m.yml}" +case "$CATALOG_NAME" in catalog-1m.yml|catalog-large.yml) ;; *) echo "Unknown dataset catalog: $CATALOG_NAME" >&2; exit 2;; esac +CATALOG="$REPO_ROOT/jvector-examples/yaml-configs/ash-pq/$CATALOG_NAME" +[[ -f "$CATALOG" ]] || { echo "Dataset catalog not found: $CATALOG" >&2; exit 2; } [[ -f "$CONFIG" && "$CONFIG_NAME" != run-* && "$CONFIG_NAME" != catalog-* && "$CONFIG_NAME" != */* ]] || { echo "Unknown index config: $CONFIG_NAME" >&2; exit 2; } @@ -110,12 +114,15 @@ CASE_CONFIG="$CASE_DIRECTORY/jvector-examples/yaml-configs" mkdir -p "$CASE_CONFIG/dataset-catalogs" cp "$REPO_ROOT/jvector-examples/yaml-configs/dataset-metadata.yml" "$CASE_CONFIG/" cp "$REPO_ROOT/jvector-examples/yaml-configs/datasets.yml" "$CASE_CONFIG/" -cp "$REPO_ROOT/jvector-examples/yaml-configs/ash-pq/catalog-1m.yml" "$CASE_CONFIG/dataset-catalogs/ash-pq-1m.yml" +# Remove only the catalog name created by the original version of this recipe. +rm -f "$CASE_CONFIG/dataset-catalogs/ash-pq-1m.yml" +cp "$CATALOG" "$CASE_CONFIG/dataset-catalogs/ash-pq-local.yml" printf '%s\n' "$CASE_ID" > "$CASE_DIRECTORY/case-id.txt" mkdir -p "$CASE_DIRECTORY/runs" RUN_DIRECTORY="$(mktemp -d "$CASE_DIRECTORY/runs/$MODE-c$CORES-XXXXXX")" cp "$CONFIG" "$RUN_DIRECTORY/index.yml" cp "$RUN_CONFIG" "$RUN_DIRECTORY/run.yml" +cp "$CATALOG" "$RUN_DIRECTORY/catalog.yml" printf '%s\n' "$SOURCE_REF" > "$RUN_DIRECTORY/source-ref.txt" sha256sum "$JAR" > "$RUN_DIRECTORY/jar.sha256" "$JAVA_BIN" -version > "$RUN_DIRECTORY/java-version.txt" 2>&1 diff --git a/jvector-examples/run-ash-pq-study.py b/jvector-examples/run-ash-pq-study.py index 0fb7b83df..ccd069e75 100755 --- a/jvector-examples/run-ash-pq-study.py +++ b/jvector-examples/run-ash-pq-study.py @@ -39,6 +39,7 @@ parser.add_argument("--query-node", default="0") parser.add_argument("--build-repetitions", type=int, default=5) parser.add_argument("--query-repetitions", type=int, default=3) +parser.add_argument("--dataset-set", choices=("1m", "large"), default="1m") parser.add_argument("--plan-only", action="store_true") args = parser.parse_args() if min(args.build_cores, args.build_repetitions, args.query_repetitions) <= 0: @@ -55,7 +56,8 @@ if not data.is_dir(): parser.error("Dataset directory does not exist") jobs = [] -for group, (prefix, bits) in enumerate((p, b) for p in ("openai3072", "mpnet", "cohere", "ada002") for b in (2, 4)): +prefixes = ("openai3072", "mpnet", "cohere", "ada002") if args.dataset_set == "1m" else ("mpnet10m", "cohere10m", "cap6m") +for group, (prefix, bits) in enumerate((p, b) for p in prefixes for b in (2, 4)): for repetition in range(1, args.build_repetitions + 1): algorithms = ("pq", "ash") if (group + repetition) % 2 else ("ash", "pq") for algorithm in algorithms: @@ -76,7 +78,8 @@ case_repetition = job["repetition"] if job["mode"] == "construction" else 1 job["case"] = str(output / "cases" / f"{job['prefix']}-{job['bits']}bit-{job['algorithm']}-r{case_repetition}") job["config"] = f"{job['prefix']}-{job['bits']}bit-{job['algorithm']}.yml" -plan = dict(source_ref=source_ref, jar_sha256=jar_sha, data=str(data), jobs=jobs, +watchdog_minutes = {"construction": 180 if args.dataset_set == "large" else 30, "query": 30} +plan = dict(source_ref=source_ref, jar_sha256=jar_sha, data=str(data), dataset_set=args.dataset_set, watchdog_minutes=watchdog_minutes, jobs=jobs, construction_jobs=sum(j["mode"] == "construction" for j in jobs), query_jobs=sum(j["mode"] != "construction" for j in jobs)) if args.plan_only: @@ -205,7 +208,8 @@ def summarize(): before = set((case / "runs").glob("*")) if (case / "runs").exists() else set() argv = ["bash", str(helper), job["config"], job["mode"], str(job["cores"]), job["cpus"], str(case)] env = os.environ.copy() - env.update(JVECTOR_BENCH_DATA_DIR=str(data), JVECTOR_BENCH_NUMA_POLICY=job["numa"]) + env.update(JVECTOR_BENCH_DATA_DIR=str(data), JVECTOR_BENCH_NUMA_POLICY=job["numa"], + JVECTOR_BENCH_CATALOG="catalog-large.yml" if args.dataset_set == "large" else "catalog-1m.yml") log_path = output / (job["id"] + ".log") print("START " + job["id"], flush=True) libraries = set() @@ -214,10 +218,11 @@ def summarize(): process = subprocess.Popen(argv, cwd=repo, env=env, stdout=log, stderr=subprocess.STDOUT) started = time.monotonic() while process.poll() is None: - if time.monotonic() - started > 1800: + limit_minutes = watchdog_minutes["construction" if job["mode"] == "construction" else "query"] + if time.monotonic() - started > limit_minutes * 60: process.terminate() process.wait(timeout=15) - raise RuntimeError("BenchYAML exceeded the 30-minute process watchdog; inspect " + str(log_path)) + raise RuntimeError(f"BenchYAML exceeded the {limit_minutes}-minute process watchdog; inspect {log_path}") if run_directory is None: created = set((case / "runs").glob("*")) - before if len(created) == 1: diff --git a/jvector-examples/src/test/java/io/github/jbellis/jvector/example/benchmarks/StandaloneASHBenchmarkConfigTest.java b/jvector-examples/src/test/java/io/github/jbellis/jvector/example/benchmarks/StandaloneASHBenchmarkConfigTest.java index 03f77e434..450c60522 100644 --- a/jvector-examples/src/test/java/io/github/jbellis/jvector/example/benchmarks/StandaloneASHBenchmarkConfigTest.java +++ b/jvector-examples/src/test/java/io/github/jbellis/jvector/example/benchmarks/StandaloneASHBenchmarkConfigTest.java @@ -81,7 +81,8 @@ private Map compression(Map section) { @Test @SuppressWarnings("unchecked") public void allPairsMatchBytesAndUseFusedGraphsNvqAndTheSameSearchParameters() throws Exception { - var dimensions = Map.of("mpnet", 768, "cohere", 1024, "ada002", 1536, "openai3072", 3072); + var dimensions = Map.of("mpnet", 768, "cohere", 1024, "ada002", 1536, "openai3072", 3072, + "mpnet10m", 768, "cohere10m", 1024, "cap6m", 1536); for (var entry : dimensions.entrySet()) { for (int bits : new int[]{2, 4}) { Map previousConstruction = null; diff --git a/jvector-examples/yaml-configs/ash-pq/cap6m-2bit-ash.yml b/jvector-examples/yaml-configs/ash-pq/cap6m-2bit-ash.yml new file mode 100644 index 000000000..d1e6c52bd --- /dev/null +++ b/jvector-examples/yaml-configs/ash-pq/cap6m-2bit-ash.yml @@ -0,0 +1,38 @@ +# cap-6M: 1536 input dimensions, 197 code bytes per vector. +# The 2-bit label describes the ASH budget; PQ uses 197 8-bit subspaces. +# ASH uses C=1 and D/2 projected dimensions, including its five-byte header. +# See docs/ash-pq-benchmark.md for construction, direct and queued run commands. +yamlSchemaVersion: 2 +onDiskIndexVersion: 7 +dataset: cap-6M + +construction: + outDegree: [32] + efConstruction: [100] + neighborOverflow: [1.2f] + addHierarchy: [Yes] + refineFinalGraph: [Yes] + fusedGraph: [Yes] + compression: + - type: ASH + parameters: + encodedBits: 1576 + optimizer: ITQ + landmarkCount: 1 + bitsPerDimension: 2 + reranking: [NVQ] + # A fresh case directory measures training/encoding/construction. Later JVMs + # in that directory reuse exactly the same model and index for querying. + useSavedIndexIfExists: Yes + +search: + topKOverquery: + 10: [1.0, 2.0] + useSearchPruning: [No] + compression: + - type: ASH + parameters: + encodedBits: 1576 + optimizer: ITQ + landmarkCount: 1 + bitsPerDimension: 2 diff --git a/jvector-examples/yaml-configs/ash-pq/cap6m-2bit-pq.yml b/jvector-examples/yaml-configs/ash-pq/cap6m-2bit-pq.yml new file mode 100644 index 000000000..3e3e50fb6 --- /dev/null +++ b/jvector-examples/yaml-configs/ash-pq/cap6m-2bit-pq.yml @@ -0,0 +1,38 @@ +# cap-6M: 1536 input dimensions, 197 code bytes per vector. +# The 2-bit label describes the ASH budget; PQ uses 197 8-bit subspaces. +# ASH uses C=1 and D/2 projected dimensions, including its five-byte header. +# See docs/ash-pq-benchmark.md for construction, direct and queued run commands. +yamlSchemaVersion: 2 +onDiskIndexVersion: 7 +dataset: cap-6M + +construction: + outDegree: [32] + efConstruction: [100] + neighborOverflow: [1.2f] + addHierarchy: [Yes] + refineFinalGraph: [Yes] + fusedGraph: [Yes] + compression: + - type: PQ + parameters: + m: 197 + k: 256 + centerData: "No" + anisotropicThreshold: -1.0 + reranking: [NVQ] + # A fresh case directory measures training/encoding/construction. Later JVMs + # in that directory reuse exactly the same model and index for querying. + useSavedIndexIfExists: Yes + +search: + topKOverquery: + 10: [1.0, 2.0] + useSearchPruning: [No] + compression: + - type: PQ + parameters: + m: 197 + k: 256 + centerData: "No" + anisotropicThreshold: -1.0 diff --git a/jvector-examples/yaml-configs/ash-pq/cap6m-4bit-ash.yml b/jvector-examples/yaml-configs/ash-pq/cap6m-4bit-ash.yml new file mode 100644 index 000000000..e04932487 --- /dev/null +++ b/jvector-examples/yaml-configs/ash-pq/cap6m-4bit-ash.yml @@ -0,0 +1,38 @@ +# cap-6M: 1536 input dimensions, 389 code bytes per vector. +# The 4-bit label describes the ASH budget; PQ uses 389 8-bit subspaces. +# ASH uses C=1 and D/2 projected dimensions, including its five-byte header. +# See docs/ash-pq-benchmark.md for construction, direct and queued run commands. +yamlSchemaVersion: 2 +onDiskIndexVersion: 7 +dataset: cap-6M + +construction: + outDegree: [32] + efConstruction: [100] + neighborOverflow: [1.2f] + addHierarchy: [Yes] + refineFinalGraph: [Yes] + fusedGraph: [Yes] + compression: + - type: ASH + parameters: + encodedBits: 3112 + optimizer: ITQ + landmarkCount: 1 + bitsPerDimension: 4 + reranking: [NVQ] + # A fresh case directory measures training/encoding/construction. Later JVMs + # in that directory reuse exactly the same model and index for querying. + useSavedIndexIfExists: Yes + +search: + topKOverquery: + 10: [1.0, 2.0] + useSearchPruning: [No] + compression: + - type: ASH + parameters: + encodedBits: 3112 + optimizer: ITQ + landmarkCount: 1 + bitsPerDimension: 4 diff --git a/jvector-examples/yaml-configs/ash-pq/cap6m-4bit-pq.yml b/jvector-examples/yaml-configs/ash-pq/cap6m-4bit-pq.yml new file mode 100644 index 000000000..d9ccc0e9d --- /dev/null +++ b/jvector-examples/yaml-configs/ash-pq/cap6m-4bit-pq.yml @@ -0,0 +1,38 @@ +# cap-6M: 1536 input dimensions, 389 code bytes per vector. +# The 4-bit label describes the ASH budget; PQ uses 389 8-bit subspaces. +# ASH uses C=1 and D/2 projected dimensions, including its five-byte header. +# See docs/ash-pq-benchmark.md for construction, direct and queued run commands. +yamlSchemaVersion: 2 +onDiskIndexVersion: 7 +dataset: cap-6M + +construction: + outDegree: [32] + efConstruction: [100] + neighborOverflow: [1.2f] + addHierarchy: [Yes] + refineFinalGraph: [Yes] + fusedGraph: [Yes] + compression: + - type: PQ + parameters: + m: 389 + k: 256 + centerData: "No" + anisotropicThreshold: -1.0 + reranking: [NVQ] + # A fresh case directory measures training/encoding/construction. Later JVMs + # in that directory reuse exactly the same model and index for querying. + useSavedIndexIfExists: Yes + +search: + topKOverquery: + 10: [1.0, 2.0] + useSearchPruning: [No] + compression: + - type: PQ + parameters: + m: 389 + k: 256 + centerData: "No" + anisotropicThreshold: -1.0 diff --git a/jvector-examples/yaml-configs/ash-pq/catalog-large.yml b/jvector-examples/yaml-configs/ash-pq/catalog-large.yml new file mode 100644 index 000000000..1b79cf3da --- /dev/null +++ b/jvector-examples/yaml-configs/ash-pq/catalog-large.yml @@ -0,0 +1,18 @@ +# Exact normalized larger datasets. Set JVECTOR_BENCH_DATA_DIR to their common parent. +_defaults: + cache_dir: ${JVECTOR_BENCH_DATA_DIR} + +mpnet-10m: + base: mpnet-10m/wiki_mpnet_embeddings_base_9997697.fvecs + query: mpnet-10m/wiki_mpnet_embeddings_query_10000.fvecs + gt: mpnet-10m/wiki_mpnet_embeddings_gt_ip_100.ivecs + +cohere-english-v3-10M: + base: cohere-10m/cohere_wiki_en_flat_base_10m_norm.fvecs + query: cohere-10m/cohere_wiki_en_flat_query_10k_norm.fvecs + gt: cohere-10m/cohere_wiki_en_flat_gt_10m_ip_k100.ivecs + +cap-6M: + base: cap-6m/Caselaw_gte-Qwen2-1.5B_embeddings_base_6m_norm_shuffle.fvecs + query: cap-6m/Caselaw_gte-Qwen2-1.5B_embeddings_query_10k_norm_shuffle.fvecs + gt: cap-6m/cap_6m_gt_norm_shuffle_ip_k100.ivecs diff --git a/jvector-examples/yaml-configs/ash-pq/cohere10m-2bit-ash.yml b/jvector-examples/yaml-configs/ash-pq/cohere10m-2bit-ash.yml new file mode 100644 index 000000000..611c5eb63 --- /dev/null +++ b/jvector-examples/yaml-configs/ash-pq/cohere10m-2bit-ash.yml @@ -0,0 +1,38 @@ +# cohere-english-v3-10M: 1024 input dimensions, 133 code bytes per vector. +# The 2-bit label describes the ASH budget; PQ uses 133 8-bit subspaces. +# ASH uses C=1 and D/2 projected dimensions, including its five-byte header. +# See docs/ash-pq-benchmark.md for construction, direct and queued run commands. +yamlSchemaVersion: 2 +onDiskIndexVersion: 7 +dataset: cohere-english-v3-10M + +construction: + outDegree: [32] + efConstruction: [100] + neighborOverflow: [1.2f] + addHierarchy: [Yes] + refineFinalGraph: [Yes] + fusedGraph: [Yes] + compression: + - type: ASH + parameters: + encodedBits: 1064 + optimizer: ITQ + landmarkCount: 1 + bitsPerDimension: 2 + reranking: [NVQ] + # A fresh case directory measures training/encoding/construction. Later JVMs + # in that directory reuse exactly the same model and index for querying. + useSavedIndexIfExists: Yes + +search: + topKOverquery: + 10: [1.0, 2.0] + useSearchPruning: [No] + compression: + - type: ASH + parameters: + encodedBits: 1064 + optimizer: ITQ + landmarkCount: 1 + bitsPerDimension: 2 diff --git a/jvector-examples/yaml-configs/ash-pq/cohere10m-2bit-pq.yml b/jvector-examples/yaml-configs/ash-pq/cohere10m-2bit-pq.yml new file mode 100644 index 000000000..db032d63a --- /dev/null +++ b/jvector-examples/yaml-configs/ash-pq/cohere10m-2bit-pq.yml @@ -0,0 +1,38 @@ +# cohere-english-v3-10M: 1024 input dimensions, 133 code bytes per vector. +# The 2-bit label describes the ASH budget; PQ uses 133 8-bit subspaces. +# ASH uses C=1 and D/2 projected dimensions, including its five-byte header. +# See docs/ash-pq-benchmark.md for construction, direct and queued run commands. +yamlSchemaVersion: 2 +onDiskIndexVersion: 7 +dataset: cohere-english-v3-10M + +construction: + outDegree: [32] + efConstruction: [100] + neighborOverflow: [1.2f] + addHierarchy: [Yes] + refineFinalGraph: [Yes] + fusedGraph: [Yes] + compression: + - type: PQ + parameters: + m: 133 + k: 256 + centerData: "No" + anisotropicThreshold: -1.0 + reranking: [NVQ] + # A fresh case directory measures training/encoding/construction. Later JVMs + # in that directory reuse exactly the same model and index for querying. + useSavedIndexIfExists: Yes + +search: + topKOverquery: + 10: [1.0, 2.0] + useSearchPruning: [No] + compression: + - type: PQ + parameters: + m: 133 + k: 256 + centerData: "No" + anisotropicThreshold: -1.0 diff --git a/jvector-examples/yaml-configs/ash-pq/cohere10m-4bit-ash.yml b/jvector-examples/yaml-configs/ash-pq/cohere10m-4bit-ash.yml new file mode 100644 index 000000000..d681f9ece --- /dev/null +++ b/jvector-examples/yaml-configs/ash-pq/cohere10m-4bit-ash.yml @@ -0,0 +1,38 @@ +# cohere-english-v3-10M: 1024 input dimensions, 261 code bytes per vector. +# The 4-bit label describes the ASH budget; PQ uses 261 8-bit subspaces. +# ASH uses C=1 and D/2 projected dimensions, including its five-byte header. +# See docs/ash-pq-benchmark.md for construction, direct and queued run commands. +yamlSchemaVersion: 2 +onDiskIndexVersion: 7 +dataset: cohere-english-v3-10M + +construction: + outDegree: [32] + efConstruction: [100] + neighborOverflow: [1.2f] + addHierarchy: [Yes] + refineFinalGraph: [Yes] + fusedGraph: [Yes] + compression: + - type: ASH + parameters: + encodedBits: 2088 + optimizer: ITQ + landmarkCount: 1 + bitsPerDimension: 4 + reranking: [NVQ] + # A fresh case directory measures training/encoding/construction. Later JVMs + # in that directory reuse exactly the same model and index for querying. + useSavedIndexIfExists: Yes + +search: + topKOverquery: + 10: [1.0, 2.0] + useSearchPruning: [No] + compression: + - type: ASH + parameters: + encodedBits: 2088 + optimizer: ITQ + landmarkCount: 1 + bitsPerDimension: 4 diff --git a/jvector-examples/yaml-configs/ash-pq/cohere10m-4bit-pq.yml b/jvector-examples/yaml-configs/ash-pq/cohere10m-4bit-pq.yml new file mode 100644 index 000000000..049dbc30d --- /dev/null +++ b/jvector-examples/yaml-configs/ash-pq/cohere10m-4bit-pq.yml @@ -0,0 +1,38 @@ +# cohere-english-v3-10M: 1024 input dimensions, 261 code bytes per vector. +# The 4-bit label describes the ASH budget; PQ uses 261 8-bit subspaces. +# ASH uses C=1 and D/2 projected dimensions, including its five-byte header. +# See docs/ash-pq-benchmark.md for construction, direct and queued run commands. +yamlSchemaVersion: 2 +onDiskIndexVersion: 7 +dataset: cohere-english-v3-10M + +construction: + outDegree: [32] + efConstruction: [100] + neighborOverflow: [1.2f] + addHierarchy: [Yes] + refineFinalGraph: [Yes] + fusedGraph: [Yes] + compression: + - type: PQ + parameters: + m: 261 + k: 256 + centerData: "No" + anisotropicThreshold: -1.0 + reranking: [NVQ] + # A fresh case directory measures training/encoding/construction. Later JVMs + # in that directory reuse exactly the same model and index for querying. + useSavedIndexIfExists: Yes + +search: + topKOverquery: + 10: [1.0, 2.0] + useSearchPruning: [No] + compression: + - type: PQ + parameters: + m: 261 + k: 256 + centerData: "No" + anisotropicThreshold: -1.0 diff --git a/jvector-examples/yaml-configs/ash-pq/mpnet10m-2bit-ash.yml b/jvector-examples/yaml-configs/ash-pq/mpnet10m-2bit-ash.yml new file mode 100644 index 000000000..0af8a3061 --- /dev/null +++ b/jvector-examples/yaml-configs/ash-pq/mpnet10m-2bit-ash.yml @@ -0,0 +1,38 @@ +# mpnet-10m: 768 input dimensions, 101 code bytes per vector. +# The 2-bit label describes the ASH budget; PQ uses 101 8-bit subspaces. +# ASH uses C=1 and D/2 projected dimensions, including its five-byte header. +# See docs/ash-pq-benchmark.md for construction, direct and queued run commands. +yamlSchemaVersion: 2 +onDiskIndexVersion: 7 +dataset: mpnet-10m + +construction: + outDegree: [32] + efConstruction: [100] + neighborOverflow: [1.2f] + addHierarchy: [Yes] + refineFinalGraph: [Yes] + fusedGraph: [Yes] + compression: + - type: ASH + parameters: + encodedBits: 808 + optimizer: ITQ + landmarkCount: 1 + bitsPerDimension: 2 + reranking: [NVQ] + # A fresh case directory measures training/encoding/construction. Later JVMs + # in that directory reuse exactly the same model and index for querying. + useSavedIndexIfExists: Yes + +search: + topKOverquery: + 10: [1.0, 2.0] + useSearchPruning: [No] + compression: + - type: ASH + parameters: + encodedBits: 808 + optimizer: ITQ + landmarkCount: 1 + bitsPerDimension: 2 diff --git a/jvector-examples/yaml-configs/ash-pq/mpnet10m-2bit-pq.yml b/jvector-examples/yaml-configs/ash-pq/mpnet10m-2bit-pq.yml new file mode 100644 index 000000000..e1a184bf4 --- /dev/null +++ b/jvector-examples/yaml-configs/ash-pq/mpnet10m-2bit-pq.yml @@ -0,0 +1,38 @@ +# mpnet-10m: 768 input dimensions, 101 code bytes per vector. +# The 2-bit label describes the ASH budget; PQ uses 101 8-bit subspaces. +# ASH uses C=1 and D/2 projected dimensions, including its five-byte header. +# See docs/ash-pq-benchmark.md for construction, direct and queued run commands. +yamlSchemaVersion: 2 +onDiskIndexVersion: 7 +dataset: mpnet-10m + +construction: + outDegree: [32] + efConstruction: [100] + neighborOverflow: [1.2f] + addHierarchy: [Yes] + refineFinalGraph: [Yes] + fusedGraph: [Yes] + compression: + - type: PQ + parameters: + m: 101 + k: 256 + centerData: "No" + anisotropicThreshold: -1.0 + reranking: [NVQ] + # A fresh case directory measures training/encoding/construction. Later JVMs + # in that directory reuse exactly the same model and index for querying. + useSavedIndexIfExists: Yes + +search: + topKOverquery: + 10: [1.0, 2.0] + useSearchPruning: [No] + compression: + - type: PQ + parameters: + m: 101 + k: 256 + centerData: "No" + anisotropicThreshold: -1.0 diff --git a/jvector-examples/yaml-configs/ash-pq/mpnet10m-4bit-ash.yml b/jvector-examples/yaml-configs/ash-pq/mpnet10m-4bit-ash.yml new file mode 100644 index 000000000..01f122311 --- /dev/null +++ b/jvector-examples/yaml-configs/ash-pq/mpnet10m-4bit-ash.yml @@ -0,0 +1,38 @@ +# mpnet-10m: 768 input dimensions, 197 code bytes per vector. +# The 4-bit label describes the ASH budget; PQ uses 197 8-bit subspaces. +# ASH uses C=1 and D/2 projected dimensions, including its five-byte header. +# See docs/ash-pq-benchmark.md for construction, direct and queued run commands. +yamlSchemaVersion: 2 +onDiskIndexVersion: 7 +dataset: mpnet-10m + +construction: + outDegree: [32] + efConstruction: [100] + neighborOverflow: [1.2f] + addHierarchy: [Yes] + refineFinalGraph: [Yes] + fusedGraph: [Yes] + compression: + - type: ASH + parameters: + encodedBits: 1576 + optimizer: ITQ + landmarkCount: 1 + bitsPerDimension: 4 + reranking: [NVQ] + # A fresh case directory measures training/encoding/construction. Later JVMs + # in that directory reuse exactly the same model and index for querying. + useSavedIndexIfExists: Yes + +search: + topKOverquery: + 10: [1.0, 2.0] + useSearchPruning: [No] + compression: + - type: ASH + parameters: + encodedBits: 1576 + optimizer: ITQ + landmarkCount: 1 + bitsPerDimension: 4 diff --git a/jvector-examples/yaml-configs/ash-pq/mpnet10m-4bit-pq.yml b/jvector-examples/yaml-configs/ash-pq/mpnet10m-4bit-pq.yml new file mode 100644 index 000000000..e25dcf8d0 --- /dev/null +++ b/jvector-examples/yaml-configs/ash-pq/mpnet10m-4bit-pq.yml @@ -0,0 +1,38 @@ +# mpnet-10m: 768 input dimensions, 197 code bytes per vector. +# The 4-bit label describes the ASH budget; PQ uses 197 8-bit subspaces. +# ASH uses C=1 and D/2 projected dimensions, including its five-byte header. +# See docs/ash-pq-benchmark.md for construction, direct and queued run commands. +yamlSchemaVersion: 2 +onDiskIndexVersion: 7 +dataset: mpnet-10m + +construction: + outDegree: [32] + efConstruction: [100] + neighborOverflow: [1.2f] + addHierarchy: [Yes] + refineFinalGraph: [Yes] + fusedGraph: [Yes] + compression: + - type: PQ + parameters: + m: 197 + k: 256 + centerData: "No" + anisotropicThreshold: -1.0 + reranking: [NVQ] + # A fresh case directory measures training/encoding/construction. Later JVMs + # in that directory reuse exactly the same model and index for querying. + useSavedIndexIfExists: Yes + +search: + topKOverquery: + 10: [1.0, 2.0] + useSearchPruning: [No] + compression: + - type: PQ + parameters: + m: 197 + k: 256 + centerData: "No" + anisotropicThreshold: -1.0 diff --git a/jvector-examples/yaml-configs/dataset-metadata.yml b/jvector-examples/yaml-configs/dataset-metadata.yml index 914a486c6..dc82ef39e 100644 --- a/jvector-examples/yaml-configs/dataset-metadata.yml +++ b/jvector-examples/yaml-configs/dataset-metadata.yml @@ -131,3 +131,7 @@ esmc-600-1M: esmc-600-6M: similarity_function: DOT_PRODUCT load_behavior: NO_SCRUB + +mpnet-10m: + similarity_function: DOT_PRODUCT + load_behavior: NO_SCRUB From e1066c11c776bef4fd72be0478a5afd505b32a38 Mon Sep 17 00:00:00 2001 From: Ted Willke Date: Wed, 4 Nov 2026 21:32:33 +0000 Subject: [PATCH 145/243] Optimize native four-bit ASH single scoring with fixed lookup --- .../src/main/native/src/ash_neighborhood-inl.h | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/jvector-native/src/main/native/src/ash_neighborhood-inl.h b/jvector-native/src/main/native/src/ash_neighborhood-inl.h index 25dde55e3..807846823 100644 --- a/jvector-native/src/main/native/src/ash_neighborhood-inl.h +++ b/jvector-native/src/main/native/src/ash_neighborhood-inl.h @@ -209,20 +209,36 @@ float ash_projection_dot_4bit_f32(const float *HWY_RESTRICT query, const hn::Rebind du; const size_t lanes = hn::Lanes(d); const size_t count = static_cast(dimensions); +#if HWY_TARGET == HWY_AVX3 || HWY_TARGET == HWY_AVX3_DL || HWY_TARGET == HWY_AVX3_SPR + // Exact signed-magnitude levels: keep them in one register instead of + // converting magnitudes and constructing sign masks for every vector. + alignas(64) static constexpr float levels[16] = { + -.5f, -1.5f, -2.5f, -3.5f, -4.5f, -5.5f, -6.5f, -7.5f, + .5f, 1.5f, 2.5f, 3.5f, 4.5f, 5.5f, 6.5f, 7.5f}; + const auto table = hn::LoadU(d, levels); +#else const auto magnitudeMask = hn::Set(du, 7); const auto signMask = hn::Set(du, 8); const auto half = hn::Set(d, 0.5f); +#endif auto low = hn::Zero(d), high = hn::Zero(d); size_t i = 0; for (; i + 2 * lanes <= count; i += 2 * lanes) { const auto packed = hn::PromoteTo(du, hn::LoadU(db, code + i / 2)); const auto hiCode = hn::ShiftRight<4>(packed); +#if HWY_TARGET == HWY_AVX3 || HWY_TARGET == HWY_AVX3_DL || HWY_TARGET == HWY_AVX3_SPR + // VPERMPS uses only the low four index bits. Use the intrinsic to avoid + // a redundant mask and Highway IndicesFromVec debug checks on upper bits. + const auto loValue = hn::VFromD{_mm512_permutexvar_ps(packed.raw, table.raw)}; + const auto hiValue = hn::VFromD{_mm512_permutexvar_ps(hiCode.raw, table.raw)}; +#else auto loValue = hn::Add(hn::ConvertTo(d, hn::And(packed, magnitudeMask)), half); auto hiValue = hn::Add(hn::ConvertTo(d, hn::And(hiCode, magnitudeMask)), half); loValue = hn::IfThenElse(hn::RebindMask(d, hn::Eq(hn::And(packed, signMask), hn::Zero(du))), hn::Neg(loValue), loValue); hiValue = hn::IfThenElse(hn::RebindMask(d, hn::Eq(hn::And(hiCode, signMask), hn::Zero(du))), hn::Neg(hiValue), hiValue); +#endif hn::VFromD qLow, qHigh; hn::LoadInterleaved2(d, query + i, qLow, qHigh); low = hn::MulAdd(qLow, loValue, low); From a7a97dfb69ebe93424afe3e0d2f1a299e84526c9 Mon Sep 17 00:00:00 2001 From: Ted Willke Date: Wed, 4 Nov 2026 23:35:54 +0000 Subject: [PATCH 146/243] Use native two-bit ASH single scoring with explicit fallback guard --- docs/ash-pq-benchmark.md | 24 +++++ .../jvector/quantization/ASHScorer.java | 14 +++ .../jvector/vector/VectorUtilSupport.java | 8 ++ jvector-examples/run-ash-pq-study.py | 4 + .../vector/NativeVectorUtilSupport.java | 19 ++++ .../vector/NativeASHCompatibilityTest.java | 11 +++ .../vector/NativeASHSingleScoringTest.java | 92 +++++++++++++++++++ 7 files changed, 172 insertions(+) create mode 100644 jvector-native/src/test/java/io/github/jbellis/jvector/vector/NativeASHSingleScoringTest.java diff --git a/docs/ash-pq-benchmark.md b/docs/ash-pq-benchmark.md index 46c3fad27..41cfd1f6d 100644 --- a/docs/ash-pq-benchmark.md +++ b/docs/ash-pq-benchmark.md @@ -86,6 +86,13 @@ matched code budget. PQ uses that many 8-bit subspaces, 256 centroids, no centering, and the default non-anisotropic training setting. The 2-bit/4-bit labels describe the ASH cases, not PQ centroid precision. +Native single scoring uses the existing canonical nibble-LUT kernel for b=2 +and the projection-code kernel for b=4. The b=2 LUT is prepared once per scorer, +with query-owned storage; it is not rebuilt for each candidate. Explicit native +selection rejects a missing required single-scoring symbol rather than silently +using the Java projection-code fallback. Fused neighborhood scoring and the +queued query batcher keep their existing native paths. + Both algorithms use fused graph features, NVQ reranking, degree 32, `efConstruction=100`, neighbor overflow 1.2, hierarchy and final refinement, and top 10 at overquery 1 and 2. Native/oneDNN selection uses production defaults: @@ -147,6 +154,23 @@ python3 jvector-examples/run-ash-pq-study.py \ --data "$JVECTOR_BENCH_DATA_DIR" --output /data/jvector-bench/full-study ``` +To collect only the production ASH query batcher, without collecting PQ or +synchronous ASH controls, select `--algorithms ash --query-modes queued`. +A quick complete four-dataset pass uses one fresh build and one query JVM per +CPU budget for each case: + +```bash +python3 jvector-examples/run-ash-pq-study.py \ + --data "$JVECTOR_BENCH_DATA_DIR" --output /data/jvector-bench/ash-one-pass \ + --algorithms ash --query-modes queued \ + --build-repetitions 1 --query-repetitions 1 +``` + +This is eight fresh builds and sixteen query JVMs. Each query JVM reports both +overquery levels. Report these as single measurements, without a sample SD. +The selections only filter standalone invocations; the BenchYAML timing and +scoring paths are unchanged. Omit the selectors to retain the full original plan. + Use `--plan-only` to inspect the full job list. CPU/node arguments are listed by `--help` for hosts with different topology. `plan.json` fixes the commit, artifact checksum and settings; `status.json`, `results.json` and `summary.json` are updated diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHScorer.java b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHScorer.java index 93bec5336..bd33fa5b3 100644 --- a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHScorer.java +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHScorer.java @@ -207,6 +207,20 @@ private ASHScoreFunction fastScanProjectionDotProductScoreFunction(QueryPrecompu final int bitsPerDimension = ash.bitsPerDimension; final var backend = VectorizationProvider.getInstance().getVectorUtilSupport(); + if (simd && "native".equalsIgnoreCase( + System.getProperty("jvector.vectorization_provider", "").trim())) { + backend.requireNativeAshSingleScoring(bitsPerDimension); + } + // The native two-bit kernel consumes a nibble LUT, prepared once per scorer. + // Keep it query-owned: prepared scorers may outlive this thread or run concurrently. + if (simd && bitsPerDimension == 2 && backend.supportsNativeAshCanonicalLutScoring()) { + final int groups = FusedASHLayout.codeGroups(d, bitsPerDimension); + final float[] lut = new float[Math.multiplyExact(groups, 16)]; + FusedASHLayout.buildQueryLut(qp.qProj, d, bitsPerDimension, lut); + return v -> v.scale * backend.ashCanonicalLutDot(v.extraBits, groups, lut) + + qp.dotQMuByLandmark[v.landmark & 255] + v.offset; + } + // Keep tuning dispatch out of the per-vector default kernel. Its compact // compilation shape matters for end-to-end scoring throughput on HotSpot. if (simd && backend.usesAshProjectionTuning()) { diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/vector/VectorUtilSupport.java b/jvector-base/src/main/java/io/github/jbellis/jvector/vector/VectorUtilSupport.java index 35dccd3c5..296920de0 100644 --- a/jvector-base/src/main/java/io/github/jbellis/jvector/vector/VectorUtilSupport.java +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/vector/VectorUtilSupport.java @@ -76,6 +76,14 @@ default float ashSymmetricDot(long[] aBits, byte[] aCodes, long[] bBits, byte[] /** Whether canonical ASH codes can use an already prepared nibble LUT in SIMD. */ default boolean supportsAshCanonicalLutScoring() { return false; } + /** Whether canonical LUT scoring is implemented by the native backend. */ + default boolean supportsNativeAshCanonicalLutScoring() { return false; } + + /** Fail before scoring when the caller explicitly requires a native ASH single scorer. */ + default void requireNativeAshSingleScoring(int bitsPerDimension) { + throw new IllegalStateException("Native ASH single scoring is unavailable for b=" + bitsPerDimension); + } + /** Projection dot only; callers apply the vector's scale, offset and landmark term. */ default float ashCanonicalLutDot(byte[] code, int groups, float[] lut) { return ASHLutScoring.dotCanonical(code, groups, lut); diff --git a/jvector-examples/run-ash-pq-study.py b/jvector-examples/run-ash-pq-study.py index ccd069e75..b9c330d73 100755 --- a/jvector-examples/run-ash-pq-study.py +++ b/jvector-examples/run-ash-pq-study.py @@ -40,6 +40,8 @@ parser.add_argument("--build-repetitions", type=int, default=5) parser.add_argument("--query-repetitions", type=int, default=3) parser.add_argument("--dataset-set", choices=("1m", "large"), default="1m") +parser.add_argument("--algorithms", nargs="+", choices=("pq", "ash"), default=["pq", "ash"]) +parser.add_argument("--query-modes", nargs="+", choices=("direct", "queued"), default=["direct", "queued"]) parser.add_argument("--plan-only", action="store_true") args = parser.parse_args() if min(args.build_cores, args.build_repetitions, args.query_repetitions) <= 0: @@ -73,6 +75,8 @@ jobs.append(dict(prefix=prefix, bits=bits, algorithm=algorithm, mode=mode, cores=cores, cpus=cpus, repetition=repetition, numa="bind:" + args.query_node)) +jobs = [job for job in jobs if job["algorithm"] in args.algorithms + and (job["mode"] == "construction" or job["mode"] in args.query_modes)] for job in jobs: job["id"] = "{prefix}-{bits}bit-{algorithm}-{mode}-c{cores}-r{repetition}".format(**job) case_repetition = job["repetition"] if job["mode"] == "construction" else 1 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 5354d5700..97287496b 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 @@ -33,6 +33,8 @@ */ class NativeVectorUtilSupport extends PanamaVectorUtilSupport { + private static final boolean REQUIRE_NATIVE_ASH = "native".equalsIgnoreCase( + System.getProperty("jvector.vectorization_provider", "").trim()); public NativeVectorUtilSupport() {} /** Select once at provider creation; leave the wide scan and its call site unchanged. */ @@ -118,11 +120,28 @@ public boolean supportsAshNeighborhoodScoring() { return NativeASHKernels.isAvailable(); } + @Override + public boolean supportsNativeAshCanonicalLutScoring() { return NativeASHKernels.isCanonicalLutAvailable(); } + + @Override + public void requireNativeAshSingleScoring(int bitsPerDimension) { + if (bitsPerDimension == 2 && NativeASHKernels.isCanonicalLutAvailable()) return; + if (bitsPerDimension == 4 && NativeASHKernels.isFourBitDotAvailable()) return; + throw new IllegalStateException("Required native ASH single scorer is unavailable for b=" + + bitsPerDimension + (bitsPerDimension == 2 + ? " (ash_canonical_lut_dot_f32)" : " (ash_projection_dot_4bit_f32)")); + } + @Override public float ashProjectionDot(float[] query, byte[] code, int dimensions, int bitsPerDimension) { if (bitsPerDimension == 4 && NativeASHKernels.isFourBitDotAvailable()) { return NativeASHKernels.dotFourBit(query, code, dimensions); } + if (REQUIRE_NATIVE_ASH) { + requireNativeAshSingleScoring(bitsPerDimension); + if (bitsPerDimension == 2) + throw new IllegalStateException("Native b=2 scoring requires its prepared LUT; use ASHScorer"); + } return super.ashProjectionDot(query, code, dimensions, bitsPerDimension); } diff --git a/jvector-native/src/test/java/io/github/jbellis/jvector/vector/NativeASHCompatibilityTest.java b/jvector-native/src/test/java/io/github/jbellis/jvector/vector/NativeASHCompatibilityTest.java index 6fc68634d..fdf0bc490 100644 --- a/jvector-native/src/test/java/io/github/jbellis/jvector/vector/NativeASHCompatibilityTest.java +++ b/jvector-native/src/test/java/io/github/jbellis/jvector/vector/NativeASHCompatibilityTest.java @@ -35,6 +35,9 @@ void packagedLibraryProvidesNeighborhoodScoring() { assertTrue(new NativeVectorUtilSupport().supportsAshNeighborhoodScoring()); assertTrue(new NativeVectorUtilSupport().supportsAshCanonicalLutScoring()); assertTrue(NativeASHKernels.isFourBitDotAvailable()); + var backend = new NativeVectorUtilSupport(); + assertDoesNotThrow(() -> backend.requireNativeAshSingleScoring(2)); + assertDoesNotThrow(() -> backend.requireNativeAshSingleScoring(4)); assertTrue(NativeASHProjection.isAvailable()); assertTrue(NativeASHSymmetricKernels.hasPairKernel()); assertTrue(NativeASHSymmetricKernels.hasLutKernel()); @@ -76,6 +79,14 @@ public static void main(String[] args) { || NativeASHSymmetricKernels.hasLutKernel()) { throw new AssertionError("New ASH symbols unexpectedly available"); } + for (int bits : new int[] {2, 4}) { + try { + backend.requireNativeAshSingleScoring(bits); + throw new AssertionError("Missing native b=" + bits + " single scorer was accepted"); + } catch (IllegalStateException expected) { + if (!expected.getMessage().contains("b=" + bits)) throw expected; + } + } float[] projected = new float[2]; backend.prepareAshProjection(new float[][]{{1,2,3},{4,5,6}}) .project(new float[]{2,3,4}, projected); diff --git a/jvector-native/src/test/java/io/github/jbellis/jvector/vector/NativeASHSingleScoringTest.java b/jvector-native/src/test/java/io/github/jbellis/jvector/vector/NativeASHSingleScoringTest.java new file mode 100644 index 000000000..04b523a15 --- /dev/null +++ b/jvector-native/src/test/java/io/github/jbellis/jvector/vector/NativeASHSingleScoringTest.java @@ -0,0 +1,92 @@ +/* + * 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.graph.*; +import io.github.jbellis.jvector.graph.similarity.*; +import io.github.jbellis.jvector.quantization.*; +import io.github.jbellis.jvector.vector.types.VectorFloat; +import java.util.*; +import java.util.concurrent.*; +import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.*; + +/** Native default dispatch must preserve independent, query-owned prepared state. */ +class NativeASHSingleScoringTest { + private static final class Values implements RandomAccessVectorValues { + final VectorFloat[] vectors; + Values(int count, int dim) { + var types = VectorizationProvider.getInstance().getVectorTypeSupport(); + var random = new Random(51237); + vectors = new VectorFloat[count]; + for (int i = 0; i < count; i++) { + float[] value = new float[dim]; + for (int j = 0; j < dim; j++) value[j] = random.nextFloat() - .5f; + vectors[i] = types.createFloatVector(value); + VectorUtil.l2normalize(vectors[i]); + } + } + public int size() { return vectors.length; } + public int dimension() { return vectors[0].length(); } + public VectorFloat getVector(int i) { return vectors[i]; } + public boolean isValueShared() { return false; } + public RandomAccessVectorValues copy() { return this; } + } + + @Test void defaultSingleScorersUseNativeWithoutSilentFallback() throws Exception { + var output = java.nio.file.Files.createTempFile("ash-default-native-", ".log"); + Process child = null; + try { + child = new ProcessBuilder(java.nio.file.Path.of(System.getProperty("java.home"), "bin", "java").toString(), + "--add-modules=jdk.incubator.vector", "--enable-native-access=ALL-UNNAMED", + "-Djvector.vectorization_provider=native", "-cp", System.getProperty("java.class.path"), + DefaultSingleProbe.class.getName()).redirectErrorStream(true).redirectOutput(output.toFile()).start(); + assertTrue(child.waitFor(30, java.util.concurrent.TimeUnit.SECONDS)); + assertEquals(0, child.exitValue(), java.nio.file.Files.readString(output)); + } finally { + if (child != null && child.isAlive()) child.destroyForcibly().waitFor(); + java.nio.file.Files.deleteIfExists(output); + } + } + + public static final class DefaultSingleProbe { + public static void main(String[] args) throws Exception { verifyDefaultNativeSingle(); } + } + + private static void verifyDefaultNativeSingle() throws Exception { + assertEquals("native", System.getProperty("jvector.vectorization_provider")); + for (int bits : new int[] {2, 4}) { + var values = new Values(40, 64); + var ash = AsymmetricHashing.initialize(values, AsymmetricHashing.RANDOM, + AsymmetricHashing.HEADER_BITS + 33 * bits, 1, bits); + var encoded = ash.encodeAll(values, ForkJoinPool.commonPool()); + var expected = new float[40]; + encoded.blockScorerFor(values.getVector(0), VectorSimilarityFunction.DOT_PRODUCT) + .scoreRange(0, 40, expected); + var single = encoded.scoreFunctionFor(values.getVector(0), VectorSimilarityFunction.DOT_PRODUCT); + var prepared = encoded.precomputedScoreFunctionFor(values.getVector(0), VectorSimilarityFunction.DOT_PRODUCT); + encoded.scoreFunctionFor(values.getVector(7), VectorSimilarityFunction.DOT_PRODUCT); + for (int i = 0; i < 40; i++) { + assertEquals(expected[i], single.similarityTo(i), 2e-5f); + assertEquals(expected[i], prepared.similarityTo(i), 2e-5f); + } + } + // A regression to the old direct b=2 dispatch must fail rather than silently use Java. + assertThrows(IllegalStateException.class, () -> new NativeVectorUtilSupport() + .ashProjectionDot(new float[2], new byte[1], 2, 2)); + } + +} From e9b19910a29177491ea48dca37fe009ad4d6ca96 Mon Sep 17 00:00:00 2001 From: Ted Willke Date: Thu, 5 Nov 2026 00:53:20 +0000 Subject: [PATCH 147/243] Add standard MPNet-10M ASH index configuration --- .../index-parameters/mpnet-10M.yml | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) create mode 100644 jvector-examples/yaml-configs/index-parameters/mpnet-10M.yml diff --git a/jvector-examples/yaml-configs/index-parameters/mpnet-10M.yml b/jvector-examples/yaml-configs/index-parameters/mpnet-10M.yml new file mode 100644 index 000000000..64c654387 --- /dev/null +++ b/jvector-examples/yaml-configs/index-parameters/mpnet-10M.yml @@ -0,0 +1,36 @@ +# MPNet-10M: ASH(b=2,d=384), nominal 32x compression. +# Fused graph scoring, NVQ reranking, and saved model/index reuse. +yamlSchemaVersion: 2 +onDiskIndexVersion: 7 +dataset: mpnet-10m + +construction: + outDegree: [32] + efConstruction: [100] + neighborOverflow: [1.2f] + addHierarchy: [Yes] + refineFinalGraph: [Yes] + fusedGraph: [Yes] + compression: + - type: ASH + parameters: + encodedBits: 808 + optimizer: ITQ + landmarkCount: 1 + bitsPerDimension: 2 + reranking: [NVQ] + # A fresh case directory measures training/encoding/construction. Later JVMs + # in that directory reuse exactly the same model and index for querying. + useSavedIndexIfExists: Yes + +search: + topKOverquery: + 10: [1.0, 2.0] + useSearchPruning: [No] + compression: + - type: ASH + parameters: + encodedBits: 808 + optimizer: ITQ + landmarkCount: 1 + bitsPerDimension: 2 From 8582de58f719042d607146ce3971efa848d2c313 Mon Sep 17 00:00:00 2001 From: Ted Willke Date: Thu, 5 Nov 2026 01:09:00 +0000 Subject: [PATCH 148/243] Include construction timings in queued BenchYAML reports --- jvector-examples/yaml-configs/ash-pq/run-queued.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/jvector-examples/yaml-configs/ash-pq/run-queued.yml b/jvector-examples/yaml-configs/ash-pq/run-queued.yml index 6af615e3d..65049e295 100644 --- a/jvector-examples/yaml-configs/ash-pq/run-queued.yml +++ b/jvector-examples/yaml-configs/ash-pq/run-queued.yml @@ -15,6 +15,7 @@ console: accuracy: [recall] count: [visited] metrics: + construction: [index_build_time_s, index_quant_time_s, search_quant_time_s] execution: [response_mean_ms, response_p95_ms, response_p99_ms, mean_batch_size] logging: dir: logging @@ -27,4 +28,5 @@ logging: accuracy: [recall] count: [visited] metrics: + construction: [index_build_time_s, index_quant_time_s, search_quant_time_s] execution: [response_mean_ms, response_p95_ms, response_p99_ms, mean_batch_size] From 8cdf18b0fdbaf99b7ad44b97a0330b86df47ba46 Mon Sep 17 00:00:00 2001 From: Ted Willke Date: Thu, 5 Nov 2026 01:21:39 +0000 Subject: [PATCH 149/243] Keep benchmark diagnostics separate from result tables --- .../github/jbellis/jvector/example/Grid.java | 26 ++++++++++++++++--- .../benchmarks/QueuedQueryExecution.java | 2 -- .../benchmarks/ThroughputBenchmark.java | 18 +++++++------ 3 files changed, 32 insertions(+), 14 deletions(-) diff --git a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/Grid.java b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/Grid.java index 6bfbc805d..aa35d9084 100644 --- a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/Grid.java +++ b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/Grid.java @@ -880,6 +880,19 @@ private static void testConfiguration(ConfiguredSystem cs, for (var usePruning : usePruningGrid) { BenchmarkTablePrinter printer = new BenchmarkTablePrinter(); + var queryParams = ordered("usePruning", usePruning, + "executionMode", cs.usesQueuedExecution() ? "queued" : "direct"); + if (cs.usesQueuedExecution()) { + queryParams.put("workers", cs.queryWorkerCount()); + queryParams.put("queueCapacity", Math.multiplyExact(Math.max(8, cs.queryBatchSize()), cs.queryWorkerCount())); + queryParams.put("maxBatchSize", cs.queryBatchSize()); + var preparer = cs.queryPreparer(); + queryParams.put("batchPreparation", preparer != null && preparer.supportsBatchPreparation()); + } else { + int threads = Integer.getInteger("jvector.bench.queryThreads", 0); + queryParams.put("callerThreads", threads == 0 ? "common pool" : threads); + } + // Print the config once per index and query configuration (not per topK) printer.printConfig( ordered( // Index configuration @@ -890,9 +903,7 @@ private static void testConfiguration(ConfiguredSystem cs, "addHierarchy", addHierarchy, "refineFinalGraph", refineFinalGraph ), - ordered( // Query configuration - "usePruning", usePruning - ) + queryParams ); for (var topK : topKGrid.keySet()) { @@ -903,6 +914,9 @@ private static void testConfiguration(ConfiguredSystem cs, // Force header to re-print for this topK (columns change) printer.resetTable(); + // Complete measurements and optional diagnostics before printing the table. + // Otherwise the next overquery's messages appear between its rows. + List> displayRows = new ArrayList<>(); for (var overquery : topKGrid.get(topK)) { int rerankK = (int) (topK * overquery); @@ -916,7 +930,7 @@ private static void testConfiguration(ConfiguredSystem cs, // Apply console selection and print var displayResults = consoleSel.apply(results, consoleResolved); - printer.printRow(overquery, displayResults); + displayRows.add(displayResults); // Apply log selection and write CSV row String searchCompressorString = (cs.cv == null) ? "None" : String.valueOf(cs.cv.getCompressor()); @@ -938,6 +952,10 @@ private static void testConfiguration(ConfiguredSystem cs, logOutputs ); } + var overqueries = topKGrid.get(topK); + for (int i = 0; i < overqueries.size(); i++) { + printer.printRow(overqueries.get(i), displayRows.get(i)); + } printer.printFooter(); } } diff --git a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/benchmarks/QueuedQueryExecution.java b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/benchmarks/QueuedQueryExecution.java index 909966a40..bb3823d61 100644 --- a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/benchmarks/QueuedQueryExecution.java +++ b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/benchmarks/QueuedQueryExecution.java @@ -167,8 +167,6 @@ public void complete(Request request, Throwable failure) { } } }); - System.out.printf("QUERY_EXECUTION mode=queued workers=%d capacity=%d max_batch_size=%d optimized_batch=%s%n", - cores, capacity, batchSize, preparer != null && preparer.supportsBatchPreparation()); } private Request acquire() { diff --git a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/benchmarks/ThroughputBenchmark.java b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/benchmarks/ThroughputBenchmark.java index 06421f18a..84cc8a18a 100644 --- a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/benchmarks/ThroughputBenchmark.java +++ b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/benchmarks/ThroughputBenchmark.java @@ -155,7 +155,6 @@ private List runWithReporting( if (cs.usesQueuedExecution()) return runConfiguredBenchmark(cs, topK, rerankK, usePruning, queryRuns, deferredReports); int threads = Integer.getInteger("jvector.bench.queryThreads", 0); if (threads < 0) throw new IllegalArgumentException("queryThreads must be nonnegative"); - System.out.printf("QUERY_CONCURRENCY threads=%s%n", threads == 0 ? "common-pool" : threads); if (threads > 1) { var pool = new java.util.concurrent.ForkJoinPool(threads); try { @@ -200,8 +199,11 @@ private List runConfiguredBenchmark(ConfiguredSystem cs, int topK, int r queryStream(count).forEach(i -> QueryExecutor.verifyConcurrentQuery(cs, topK, rerankK, usePruning, i)); } - System.out.printf("CONCURRENCY_VALIDATION queries=%d topK=%d rerankK=%d status=PASS%n", - count, topK, rerankK); + if (diagnosticLevel != DiagnosticLevel.NONE) { + diagnostics.console(String.format(java.util.Locale.ROOT, + "[Validation] Passed concurrent-result checks for %,d queries (topK=%d, rerankK=%d)", + count, topK, rerankK)); + } } @@ -298,13 +300,13 @@ private List runConfiguredBenchmark(ConfiguredSystem cs, int topK, int r } while (System.nanoTime() - startTime < minSampleNanos); SINK += visitedAdder.sum(); double elapsedSec = (System.nanoTime() - startTime) / 1e9; - System.out.printf(java.util.Locale.ROOT, - "THROUGHPUT_SAMPLE phase=%s queries=%d seconds=%.6f%n", - testPhase, completedQueries, elapsedSec); + if (diagnosticLevel != DiagnosticLevel.NONE) { + diagnostics.console(String.format(java.util.Locale.ROOT, + "[%s] Completed %,d queries in %.3f s (%,.1f QPS)", + testPhase, completedQueries, elapsedSec, completedQueries / elapsedSec)); + } return completedQueries / elapsedSec; }); - - diagnostics.console("Test Run " + testRun + ": " + qpsSamples[testRun] + " QPS\n"); } // Performance variance analysis From ed55e1fcdf567612093cf130baa92eb09f32b919 Mon Sep 17 00:00:00 2001 From: Ted Willke Date: Thu, 5 Nov 2026 01:56:20 +0000 Subject: [PATCH 150/243] Reuse ASH encoding corrections during construction setup --- .../quantization/ASHSymmetricScorer.java | 16 +++--- .../jvector/quantization/ASHVectors.java | 21 +++++++- .../quantization/AsymmetricHashing.java | 41 +++++++++++---- .../quantization/TestASHBatchEncoding.java | 51 ++++++++++++++++++- .../quantization/TestASHScoringDispatch.java | 12 ++++- .../quantization/TestASHSymmetricScorer.java | 12 ++++- 6 files changed, 129 insertions(+), 24 deletions(-) diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHSymmetricScorer.java b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHSymmetricScorer.java index e70645400..0039ba208 100644 --- a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHSymmetricScorer.java +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHSymmetricScorer.java @@ -61,14 +61,18 @@ public ASHSymmetricScorer(ASHVectors vectors, Kernel kernel) { } } if (AsymmetricHashing.usesFastScanProjectionCode(bits)) { - centeredOffsets = new float[vectors.count()]; - for (int i = 0; i < centeredOffsets.length; i++) centeredOffsets[i] = centeredOffset(vectors.get(i)); + if (vectors.symmetricOffsets != null) { + centeredOffsets = vectors.symmetricOffsets; + } else { + centeredOffsets = new float[vectors.count()]; + for (int i = 0; i < centeredOffsets.length; i++) centeredOffsets[i] = centeredOffset(vectors.get(i)); + } } else { centeredOffsets = null; } retainedBytes = RamUsageEstimator.shallowSizeOfInstance(ASHSymmetricScorer.class) + AsymmetricHashing.estimateArray2DBytes(landmarkDots) - + RamUsageEstimator.shallowSizeOf(centeredOffsets); + + (vectors.symmetricOffsets == null ? RamUsageEstimator.shallowSizeOf(centeredOffsets) : 0); } /** Owned cache storage only; the shared compressor and vectors are charged by ASHVectors. */ @@ -84,11 +88,7 @@ private boolean selectSimd(Kernel kernel) { public String description() { return "symmetric " + (simd ? "SIMD" : "scalar") + ", bits=" + bits + ", C=" + landmarks; } private float centeredOffset(AsymmetricHashing.QuantizedVector vector) { - int c = vector.landmark & 255; - if (c >= landmarks) throw new IllegalArgumentException("Invalid landmark " + c); - if (!AsymmetricHashing.usesFastScanProjectionCode(bits)) return vector.offset; - return vector.offset + vector.scale * AsymmetricHashing.dotProjectionCode( - ash.landmarkProj[c], vector.extraBits, dimensions, bits); + return ash.centeredOffset(vector); } private float offset(int node) { return centeredOffsets == null ? vectors.get(node).offset : centeredOffsets[node]; } diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHVectors.java b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHVectors.java index 4de67e716..e206f52e3 100644 --- a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHVectors.java +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHVectors.java @@ -53,6 +53,10 @@ public class ASHVectors implements CompressedVectors { final ASHScorer scorer; private final long vectorDataBytes; private final long containerBytes; + // Immutable, optional construction cache produced by the bounded encoding workers. + // Raw input vectors are never retained. Loaded/individually encoded collections use + // the scorer's existing on-demand preparation instead. + final float[] symmetricOffsets; private final java.util.concurrent.atomic.AtomicLong projectionCacheBytes = new java.util.concurrent.atomic.AtomicLong(); // Conservatively include a key, node and initial-sized table per cached layout. @@ -125,6 +129,14 @@ static AshBlockKernel fromProperty() { */ public ASHVectors(AsymmetricHashing ash, AsymmetricHashing.QuantizedVector[] compressedVectors) { + this(ash, compressedVectors, null); + } + + ASHVectors(AsymmetricHashing ash, AsymmetricHashing.QuantizedVector[] compressedVectors, + float[] symmetricOffsets) { + if (symmetricOffsets != null && symmetricOffsets.length != compressedVectors.length) + throw new IllegalArgumentException("Symmetric offsets must match encoded vector count"); + this.symmetricOffsets = symmetricOffsets; this.ash = ash; this.compressedVectors = compressedVectors; this.scorer = new ASHScorer(ash); @@ -154,7 +166,9 @@ private ASHVectors(AsymmetricHashing ash, float[] scales, float[] offsets, byte[] landmarks, - long vectorDataBytes) { + long vectorDataBytes, + float[] symmetricOffsets) { + this.symmetricOffsets = symmetricOffsets; this.ash = ash; this.compressedVectors = compressedVectors; this.scorer = new ASHScorer(ash); @@ -999,6 +1013,7 @@ public LandmarkOrder reorderByLandmarkFast() { final AsymmetricHashing.QuantizedVector[] newVecs = new AsymmetricHashing.QuantizedVector[n]; final float[] newScales = new float[n]; final float[] newOffsets = new float[n]; + final float[] newSymmetricOffsets = symmetricOffsets == null ? null : new float[n]; final byte[] newLandmarks = new byte[n]; final int[] newToOld = new int[n]; @@ -1010,6 +1025,7 @@ public LandmarkOrder reorderByLandmarkFast() { newVecs[newOrd] = this.compressedVectors[oldOrd]; newScales[newOrd] = this.scales[oldOrd]; newOffsets[newOrd] = this.offsets[oldOrd]; + if (newSymmetricOffsets != null) newSymmetricOffsets[newOrd] = symmetricOffsets[oldOrd]; newLandmarks[newOrd] = this.landmarks[oldOrd]; newToOld[newOrd] = oldOrd; @@ -1020,7 +1036,7 @@ public LandmarkOrder reorderByLandmarkFast() { oldToNew[newToOld[newOrd]] = newOrd; } - ASHVectors reordered = new ASHVectors(ash, newVecs, newScales, newOffsets, newLandmarks, vectorDataBytes); + ASHVectors reordered = new ASHVectors(ash, newVecs, newScales, newOffsets, newLandmarks, vectorDataBytes, newSymmetricOffsets); return new LandmarkOrder(reordered, newToOld, oldToNew, landmarkOffsets); } @@ -1142,6 +1158,7 @@ private long containerBytes() { + RamUsageEstimator.shallowSizeOf(compressedVectors) + RamUsageEstimator.sizeOf(scales) + RamUsageEstimator.sizeOf(offsets) + RamUsageEstimator.sizeOf(landmarks) + RamUsageEstimator.shallowSizeOf(scorer) + + RamUsageEstimator.shallowSizeOf(symmetricOffsets) + RamUsageEstimator.shallowSizeOf(projectionBlocks) + RamUsageEstimator.shallowSizeOf(projectionCacheBytes); } diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/AsymmetricHashing.java b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/AsymmetricHashing.java index f61f72179..2ff2065ae 100644 --- a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/AsymmetricHashing.java +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/AsymmetricHashing.java @@ -765,6 +765,15 @@ static float dotProjectionCode( return sum; } + /** Header-calibrated correction for symmetric scoring, using the stored code/header. */ + float centeredOffset(QuantizedVector vector) { + int c = vector.landmark & 255; + if (c >= landmarkCount) throw new IllegalArgumentException("Invalid landmark " + c); + if (!usesFastScanProjectionCode(bitsPerDimension)) return vector.offset; + return vector.offset + vector.scale * dotProjectionCode( + landmarkProj[c], vector.extraBits, quantizedDim, bitsPerDimension); + } + // --------------------------------------------------------------------- // Quantization strategies // --------------------------------------------------------------------- @@ -1512,6 +1521,8 @@ public ASHVectors encodeAll(RandomAccessVectorValues ravv, ForkJoinPool simdExec final int parallelism = Math.max(1, simdExecutor.getParallelism()); final int chunkSize = (int) (((long) n + parallelism - 1) / parallelism); final QuantizedVector[] out = new QuantizedVector[n]; + // Fill while each encoded vector is hot; never reread the raw input for setup. + final float[] symmetricOffsets = usesFastScanProjectionCode(bitsPerDimension) ? new float[n] : null; final var ravvSupplier = ravv.threadLocalSupplier(); var tasks = new java.util.ArrayList>(parallelism); Throwable failure = null; @@ -1519,7 +1530,7 @@ public ASHVectors encodeAll(RandomAccessVectorValues ravv, ForkJoinPool simdExec for (int start = 0; start < n;) { final int first = start; final int end = (int) Math.min((long) n, (long) first + chunkSize); - tasks.add(simdExecutor.submit(() -> encodeRange(ravvSupplier.get(), out, first, end))); + tasks.add(simdExecutor.submit(() -> encodeRange(ravvSupplier.get(), out, symmetricOffsets, first, end))); start = end; } } catch (RuntimeException | Error error) { @@ -1536,10 +1547,11 @@ public ASHVectors encodeAll(RandomAccessVectorValues ravv, ForkJoinPool simdExec } if (failure instanceof Error) throw (Error) failure; if (failure != null) throw (RuntimeException) failure; - return new ASHVectors(this, out); + return new ASHVectors(this, out, symmetricOffsets); } - private void encodeRange(RandomAccessVectorValues source, QuantizedVector[] out, int start, int end) { + private void encodeRange(RandomAccessVectorValues source, QuantizedVector[] out, + float[] symmetricOffsets, int start, int end) { int capacity = Math.min(ENCODING_BATCH_SIZE, end - start); float[][] inputs = new float[capacity][originalDimension]; float[][] projected = new float[capacity][quantizedDim]; @@ -1551,27 +1563,32 @@ private void encodeRange(RandomAccessVectorValues source, QuantizedVector[] out, VectorFloat vector = source.getVector(i); QuantizedVector dest = QuantizedVector.createEmpty(quantizedDim, bitsPerDimension); out[i] = dest; - if (vector == null) continue; + if (vector == null) { + if (symmetricOffsets != null) symmetricOffsets[i] = centeredOffset(dest); + continue; + } float residualNorm = prepareEncoding(vector, dest); normalizeResidual(vector, landmarks[dest.landmark & 0xFF], residualNorm, inputs[count]); residualNorms[count] = residualNorm; ordinals[count] = i; if (++count == capacity) { - encodeBatch(inputs, projected, residualNorms, ordinals, count, absNorm, out); + encodeBatch(inputs, projected, residualNorms, ordinals, count, absNorm, out, symmetricOffsets); count = 0; } } - if (count != 0) encodeBatch(inputs, projected, residualNorms, ordinals, count, absNorm, out); + if (count != 0) encodeBatch(inputs, projected, residualNorms, ordinals, count, absNorm, out, symmetricOffsets); } private void encodeBatch(float[][] inputs, float[][] projected, float[] residualNorms, - int[] ordinals, int count, double[] absNorm, QuantizedVector[] out) { + int[] ordinals, int count, double[] absNorm, QuantizedVector[] out, + float[] symmetricOffsets) { stiefelTransform.projectBatch(inputs, projected, count); for (int i = 0; i < count; i++) { QuantizedVector dest = out[ordinals[i]]; float codeNorm = RandomBinaryQuantizer.quantizeProjected(projected[i], bitsPerDimension, dest.binaryVector, dest.extraBits, absNorm); - finishEncoding(dest, residualNorms[i], codeNorm); + float correction = finishEncoding(dest, residualNorms[i], codeNorm); + if (symmetricOffsets != null) symmetricOffsets[ordinals[i]] = correction; } } @@ -1656,17 +1673,19 @@ private float prepareEncoding(VectorFloat vector, QuantizedVector dest) { } /** Applies the existing code-dependent header adjustment and FP16 storage rounding. */ - private void finishEncoding(QuantizedVector dest, float residualNorm, float codeNorm) { + /** Returns the symmetric correction using the same dot product and stored half headers. */ + private float finishEncoding(QuantizedVector dest, float residualNorm, float codeNorm) { final int c = dest.landmark & 0xFF; final float rawOffset = dest.offset; final float scale = codeNorm > 0f ? residualNorm / codeNorm : 0f; float finalOffset = rawOffset; + float landmarkDot = 0f; if (usesFastScanProjectionCode(bitsPerDimension)) { // C++ projection-mode optimization: // offset = - ||μ||² - scale * // so query-time scoring can use Aq instead of A(q - μ). - float landmarkDot = dotProjectionCode( + landmarkDot = dotProjectionCode( landmarkProj[c], dest.extraBits, quantizedDim, @@ -1676,6 +1695,8 @@ private void finishEncoding(QuantizedVector dest, float residualNorm, float code dest.scale = roundToFloat16(scale); dest.offset = roundToFloat16(finalOffset); + return usesFastScanProjectionCode(bitsPerDimension) + ? dest.offset + dest.scale * landmarkDot : dest.offset; } // --------------------------------------------------------------------- diff --git a/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestASHBatchEncoding.java b/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestASHBatchEncoding.java index a359afb9a..8f3e565d7 100644 --- a/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestASHBatchEncoding.java +++ b/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestASHBatchEncoding.java @@ -80,7 +80,12 @@ private static final class Values implements RandomAccessVectorValues { private final VectorFloat[] vectors; private final boolean shared; private final VectorFloat scratch; + private final AtomicInteger reads; Values(VectorFloat[] vectors, boolean shared) { + this(vectors, shared, null); + } + Values(VectorFloat[] vectors, boolean shared, AtomicInteger reads) { + this.reads = reads; this.vectors = vectors; this.shared = shared; scratch = VectorizationProvider.getInstance().getVectorTypeSupport().createFloatVector(DIMENSION); @@ -88,8 +93,9 @@ private static final class Values implements RandomAccessVectorValues { @Override public int size() { return vectors.length; } @Override public int dimension() { return DIMENSION; } @Override public boolean isValueShared() { return shared; } - @Override public RandomAccessVectorValues copy() { return new Values(vectors, shared); } + @Override public RandomAccessVectorValues copy() { return new Values(vectors, shared, reads); } @Override public VectorFloat getVector(int ordinal) { + if (reads != null) reads.incrementAndGet(); VectorFloat vector = vectors[ordinal]; if (vector == null || !shared) return vector; for (int d = 0; d < DIMENSION; d++) scratch.set(d, vector.get(d)); @@ -145,6 +151,49 @@ public void bulkMatchesSingleForTailsMissingValuesAndBorrowedStorage() throws Ex } } + @Test + public void correctionsAreComputedInTheEncodingPassAndSharedWithoutChangingCodesOrScores() throws Exception { + for (int bits : new int[]{2,4}) for (int landmarks : new int[]{1,3}) { + var ash = model(bits, landmarks); + var input = vectors(97); + input[31] = null; + input[96] = ash.landmarks[0].copy(); + AtomicInteger reads = new AtomicInteger(); + var source = new Values(input, true, reads); + ForkJoinPool pool = new ForkJoinPool(3); + try { + var bulk = ash.encodeAll(source, pool); + assertEquals("Read each raw vector exactly once", input.length, reads.get()); + assertNotNull(bulk.symmetricOffsets); + AsymmetricHashing.QuantizedVector[] codes = new AsymmetricHashing.QuantizedVector[input.length]; + for (int i = 0; i < codes.length; i++) { + codes[i] = input[i] == null ? AsymmetricHashing.QuantizedVector.createEmpty(ash.quantizedDim, bits) : ash.encode(input[i]); + assertCode(codes[i], bulk.get(i)); + float expected = bulk.get(i).offset + bulk.get(i).scale * AsymmetricHashing.dotProjectionCode( + ash.landmarkProj[bulk.get(i).landmark & 255], bulk.get(i).extraBits, ash.quantizedDim, bits); + assertEquals(Float.floatToRawIntBits(expected), Float.floatToRawIntBits(bulk.symmetricOffsets[i])); + } + var uncached = new ASHVectors(ash, codes); + var prepared = new ASHSymmetricScorer(bulk); + var reference = new ASHSymmetricScorer(uncached); + var offsets = ASHSymmetricScorer.class.getDeclaredField("centeredOffsets"); + offsets.setAccessible(true); + assertSame(bulk.symmetricOffsets, offsets.get(prepared)); + assertSame(bulk.symmetricOffsets, offsets.get(new ASHSymmetricScorer(bulk))); + assertEquals(io.github.jbellis.jvector.util.RamUsageEstimator.shallowSizeOf(bulk.symmetricOffsets), + bulk.ramBytesUsed() - uncached.ramBytesUsed()); + var reordered = bulk.reorderByLandmarkFast(); + var reorderedScorer = new ASHSymmetricScorer(reordered.vectors); + for (int i = 0; i < codes.length; i++) for (int j = 0; j < codes.length; j++) { + assertEquals(reference.dotProduct(i, j), prepared.dotProduct(i, j), 0f); + assertEquals(prepared.dotProduct(reordered.newToOld[i], reordered.newToOld[j]), + reorderedScorer.dotProduct(i, j), 0f); + } + assertEquals("Scorer creation/reordering must not reread raw input", input.length, reads.get()); + } finally { pool.shutdown(); } + } + } + @Test public void encodeToClearsReusedCodesAndEncodingIgnoresQueryPrecision() throws Exception { String old = System.getProperty("jvector.ash.queryProjection"); diff --git a/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestASHScoringDispatch.java b/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestASHScoringDispatch.java index 27c78e9ea..0e104510a 100644 --- a/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestASHScoringDispatch.java +++ b/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestASHScoringDispatch.java @@ -58,8 +58,16 @@ public void projectionSimdHandlesPartialBlocks() { for (int i = 0; i < dimensions; i++) query[i] = random.nextFloat() - 0.5f; random.nextBytes(code); float expected = AsymmetricHashing.dotProjectionCode(query, code, dimensions, bits); - assertEquals("bits=" + bits + " dimensions=" + dimensions, expected, - backend.ashProjectionDot(query, code, dimensions, bits), 0.0002f); + float actual; + if (bits == 2 && backend.supportsNativeAshCanonicalLutScoring()) { + int groups = FusedASHLayout.codeGroups(dimensions, bits); + float[] lut = new float[groups * 16]; + FusedASHLayout.buildQueryLut(query, dimensions, bits, lut); + actual = backend.ashCanonicalLutDot(code, groups, lut); + } else { + actual = backend.ashProjectionDot(query, code, dimensions, bits); + } + assertEquals("bits=" + bits + " dimensions=" + dimensions, expected, actual, 0.0002f); } } } diff --git a/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestASHSymmetricScorer.java b/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestASHSymmetricScorer.java index e9f363cf1..fdcaf44b6 100644 --- a/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestASHSymmetricScorer.java +++ b/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestASHSymmetricScorer.java @@ -71,7 +71,17 @@ private static float component(AsymmetricHashing.QuantizedVector v, int j, int b assertEquals("symmetric SIMD bits=" + bits + " d=" + d, actual, backend.ashSymmetricDot(a.binaryVector, a.extraBits, b.binaryVector, b.extraBits, d, bits), 0f); } - if ((bits == 2 || bits == 4) && backend.supportsAshProjectionScoring()) { + if (bits == 2 && backend.supportsNativeAshCanonicalLutScoring()) { + // Native b=2 deliberately requires prepared state; keep its raw-entry guard. + if ("native".equalsIgnoreCase(System.getProperty("jvector.vectorization_provider", "").trim())) { + assertThrows(IllegalStateException.class, + () -> backend.ashProjectionDot(unpacked, b.extraBits, unpacked.length, 2)); + } + int groups = FusedASHLayout.codeGroups(d, bits); + float[] lut = new float[groups * 16]; + FusedASHLayout.buildQueryLut(unpacked, d, bits, lut); + assertEquals(actual, backend.ashCanonicalLutDot(b.extraBits, groups, lut), 0f); + } else if ((bits == 2 || bits == 4) && backend.supportsAshProjectionScoring()) { assertEquals(actual, backend.ashProjectionDot(unpacked, b.extraBits, d, bits), 0f); } } From 4d71ec8a0ba72a0f630a33b342e400b7889aab33 Mon Sep 17 00:00:00 2001 From: Ted Willke Date: Thu, 5 Nov 2026 05:05:40 +0000 Subject: [PATCH 151/243] Reuse input-scan means for NVQ reranking --- .../jbellis/jvector/graph/VectorMean.java | 168 +++++++++++++ .../quantization/AsymmetricHashing.java | 37 +-- .../jvector/quantization/NVQuantization.java | 5 +- .../github/jbellis/jvector/example/Grid.java | 22 +- .../jbellis/jvector/graph/TestVectorMean.java | 222 ++++++++++++++++++ 5 files changed, 433 insertions(+), 21 deletions(-) create mode 100644 jvector-base/src/main/java/io/github/jbellis/jvector/graph/VectorMean.java create mode 100644 jvector-tests/src/test/java/io/github/jbellis/jvector/graph/TestVectorMean.java diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/graph/VectorMean.java b/jvector-base/src/main/java/io/github/jbellis/jvector/graph/VectorMean.java new file mode 100644 index 000000000..6a93fe644 --- /dev/null +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/graph/VectorMean.java @@ -0,0 +1,168 @@ +/* + * 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 java.util.Arrays; +import java.util.HashSet; +import java.util.Objects; +import java.util.Random; + +/** + * Collects an original-coordinate mean during an existing, explicit input scan. + * This utility does not read vectors. Database integrations may feed already-read + * vectors to {@link #accept}; bulk encoders can use {@link #duringScan} instead. + * Neither loaders nor vector compressors need to implement a statistics interface. + */ +public final class VectorMean { + /** A bounded uniform sample, independent of the input size and quantizer. */ + public static final int DEFAULT_SAMPLE_SIZE = 16384; + + private final int size; + private final int dimension; + private final int[] ordinals; + private final boolean[] seen; + private final double[] sum; + private int visited; + private int count; + + public VectorMean(int size, int dimension) { + this(size, dimension, DEFAULT_SAMPLE_SIZE, 0); + } + + /** maxSamples >= size collects the full mean; otherwise samples without replacement. */ + public VectorMean(int size, int dimension, int maxSamples, long seed) { + if (size <= 0 || dimension <= 0 || maxSamples <= 0) + throw new IllegalArgumentException("Positive size, dimension and sample limit required"); + this.size = size; + this.dimension = dimension; + int n = Math.min(size, maxSamples); + ordinals = new int[n]; + // Floyd's algorithm selects in O(sample size), without scanning all ordinals. + var selected = new HashSet(); + var random = new Random(seed); + int i = 0; + for (int j = size - n; j < size; j++) { + int ordinal = random.nextInt(j + 1); + if (!selected.add(ordinal)) { + selected.add(j); + ordinal = j; + } + ordinals[i++] = ordinal; + } + Arrays.sort(ordinals); + seen = new boolean[n]; + sum = new double[dimension]; + } + + /** + * Observe an already-read, untransformed vector at its source ordinal. A borrowed + * buffer is consumed immediately, never retained. Repeated ordinals are ignored. + * Null/missing vectors are excluded from the mean, but count toward scan completion. + */ + public void accept(int ordinal, VectorFloat vector) { + Objects.checkIndex(ordinal, size); + int sample = Arrays.binarySearch(ordinals, ordinal); + if (sample >= 0) acceptSample(sample, vector); + } + + private synchronized void acceptSample(int sample, VectorFloat vector) { + if (seen[sample]) return; + if (vector != null) { + if (vector.length() != dimension) throw new IllegalArgumentException("Vector dimension mismatch"); + // Validate before mutating state, including on a failed/retried scan. + for (int d = 0; d < dimension; d++) { + if (!Float.isFinite(vector.get(d))) throw new IllegalArgumentException("Non-finite vector component"); + } + for (int d = 0; d < dimension; d++) sum[d] += vector.get(d); + count++; + } + seen[sample] = true; + visited++; + } + + public synchronized boolean isComplete() { return visited == ordinals.length; } + public synchronized int count() { return count; } + public int sampleSize() { return ordinals.length; } + + /** Return an owned snapshot. Fails if the selected input scan is incomplete or empty. */ + public synchronized VectorFloat mean() { + if (!isComplete()) throw new IllegalStateException("Incomplete mean collection"); + if (count == 0) throw new IllegalStateException("No present vectors in mean sample"); + var mean = VectorizationProvider.getInstance().getVectorTypeSupport().createFloatVector(dimension); + for (int d = 0; d < dimension; d++) mean.set(d, (float) (sum[d] / count)); + return mean; + } + + /** + * Scope collection to ONE explicit bulk scan. Range/selection cursors observe + * vectors before handing them to the consumer. Point reads are delegated without + * collection, so arbitrary training/search accesses cannot become statistics. + * The wrapper does not own the source; closing a cursor still releases its reads. + * Do not pass this wrapper to training or reuse it for another dataset. + */ + public BatchedVectorValues duringScan(RandomAccessVectorValues source) { + if (source.size() != size || source.dimension() != dimension) + throw new IllegalArgumentException("Mean/source shape mismatch"); + return new BatchedVectorValues() { + public int size() { return source.size(); } + public int dimension() { return source.dimension(); } + public boolean isValueShared() { return source.isValueShared(); } + public VectorFloat getVector(int ordinal) { return source.getVector(ordinal); } + public void getVectorInto(int ordinal, VectorFloat destination, int offset) { + source.getVectorInto(ordinal, destination, offset); + } + public RandomAccessVectorValues copy() { return duringScan(source.copy()); } + public VectorCursor openRange(int start, int end) { + var cursor = VectorAccess.openRange(source, start, end); + int first = Arrays.binarySearch(ordinals, start); + return observe(cursor, first >= 0 ? first : -first - 1, true); + } + public VectorCursor openSelection(int[] requested, int offset, int count) { + return observe(VectorAccess.openSelection(source, requested, offset, count), 0, false); + } + }; + } + + private VectorCursor observe(VectorCursor cursor, int firstSample, boolean range) { + return new VectorCursor() { + int nextSample = firstSample; + public boolean next() { + try { + if (!cursor.next()) return false; + int ordinal = cursor.ordinal(); + if (range) { + if (nextSample < ordinals.length && ordinals[nextSample] == ordinal) + acceptSample(nextSample++, cursor.vector()); + } else { + accept(ordinal, cursor.vector()); + } + return true; + } catch (RuntimeException | Error failure) { + try { close(); } catch (RuntimeException | Error closing) { failure.addSuppressed(closing); } + throw failure; + } + } + public int ordinal() { return cursor.ordinal(); } + public VectorFloat vector() { return cursor.vector(); } + public boolean isValueShared() { return cursor.isValueShared(); } + public void close() { cursor.close(); } + }; + } +} diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/AsymmetricHashing.java b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/AsymmetricHashing.java index 2ff2065ae..8149efaf1 100644 --- a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/AsymmetricHashing.java +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/AsymmetricHashing.java @@ -20,6 +20,7 @@ import io.github.jbellis.jvector.disk.IndexWriter; import io.github.jbellis.jvector.disk.RandomAccessReader; import io.github.jbellis.jvector.graph.RandomAccessVectorValues; +import io.github.jbellis.jvector.graph.VectorAccess; import io.github.jbellis.jvector.graph.disk.OnDiskGraphIndex; import io.github.jbellis.jvector.util.Accountable; import io.github.jbellis.jvector.util.CallerBatchProcessor; @@ -1559,22 +1560,28 @@ private void encodeRange(RandomAccessVectorValues source, QuantizedVector[] out, int[] ordinals = new int[capacity]; double[] absNorm = new double[quantizedDim]; int count = 0; - for (int i = start; i < end; i++) { - VectorFloat vector = source.getVector(i); - QuantizedVector dest = QuantizedVector.createEmpty(quantizedDim, bitsPerDimension); - out[i] = dest; - if (vector == null) { - if (symmetricOffsets != null) symmetricOffsets[i] = centeredOffset(dest); - continue; - } - float residualNorm = prepareEncoding(vector, dest); - normalizeResidual(vector, landmarks[dest.landmark & 0xFF], residualNorm, inputs[count]); - residualNorms[count] = residualNorm; - ordinals[count] = i; - if (++count == capacity) { - encodeBatch(inputs, projected, residualNorms, ordinals, count, absNorm, out, symmetricOffsets); - count = 0; + try (var cursor = VectorAccess.openRange(source, start, end)) { + int expected = start; + while (cursor.next()) { + int i = cursor.ordinal(); + if (i != expected++) throw new IllegalStateException("Unexpected encoding ordinal"); + VectorFloat vector = cursor.vector(); + QuantizedVector dest = QuantizedVector.createEmpty(quantizedDim, bitsPerDimension); + out[i] = dest; + if (vector == null) { + if (symmetricOffsets != null) symmetricOffsets[i] = centeredOffset(dest); + continue; + } + float residualNorm = prepareEncoding(vector, dest); + normalizeResidual(vector, landmarks[dest.landmark & 0xFF], residualNorm, inputs[count]); + residualNorms[count] = residualNorm; + ordinals[count] = i; + if (++count == capacity) { + encodeBatch(inputs, projected, residualNorms, ordinals, count, absNorm, out, symmetricOffsets); + count = 0; + } } + if (expected != end) throw new IllegalStateException("Incomplete encoding range"); } if (count != 0) encodeBatch(inputs, projected, residualNorms, ordinals, count, absNorm, out, symmetricOffsets); } diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/NVQuantization.java b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/NVQuantization.java index 6ebc11701..151446070 100644 --- a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/NVQuantization.java +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/NVQuantization.java @@ -158,7 +158,10 @@ public static NVQuantization compute(RandomAccessVectorValues ravv, int nSubVect /** * Creates a NVQuantization instance by using the global mean and computing the data structures used to divide each - * vector into subvectors. + * vector into subvectors. The mean can be collected during any earlier input scan, + * for example with {@link io.github.jbellis.jvector.graph.VectorMean}, independently + * of the primary quantizer or loader. This method does not read input vectors. + * Use {@link #compute} when no precomputed mean is available. * * @param scaledGlobalMean the mean of the graph (its average vector) * @param nSubVectors number of subvectors diff --git a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/Grid.java b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/Grid.java index aa35d9084..59ff4d0af 100644 --- a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/Grid.java +++ b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/Grid.java @@ -39,6 +39,7 @@ import io.github.jbellis.jvector.graph.ImmutableGraphIndex; import io.github.jbellis.jvector.graph.GraphIndexBuilder; import io.github.jbellis.jvector.graph.VectorAccess; +import io.github.jbellis.jvector.graph.VectorMean; import io.github.jbellis.jvector.graph.GraphSearcher; import io.github.jbellis.jvector.graph.RandomAccessVectorValues; import io.github.jbellis.jvector.graph.disk.*; @@ -430,9 +431,15 @@ private static BuildOnDiskResult buildOnDisk(List> feat } } + // Collect original-coordinate statistics during bulk encoding, before any + // compressor-specific transformation. No loader or quantizer mean contract. + VectorMean mean = featureSets.stream().anyMatch(f -> f.contains(FeatureId.NVQ_VECTORS)) + ? new VectorMean(floatVectors.size(), floatVectors.dimension()) : null; + var encodingVectors = mean == null ? floatVectors : mean.duringScan(floatVectors); CompressedVectors buildCv = (constructionMetrics != null) - ? constructionMetrics.index(buildQuantType).timeEncode(() -> buildCompressor.encodeAll(floatVectors)) - : buildCompressor.encodeAll(floatVectors); + ? constructionMetrics.index(buildQuantType).timeEncode(() -> buildCompressor.encodeAll(encodingVectors)) + : buildCompressor.encodeAll(encodingVectors); + VectorFloat inputMean = mean != null && mean.isComplete() ? mean.mean() : null; // Optional ASH symmetric-vs-asymmetric debug (off by default) // Enable with: -Djvector.ash.debugSymmetric=true @@ -505,6 +512,7 @@ private static BuildOnDiskResult buildOnDisk(List> feat floatVectors, pqCompressorForFeatures, ashVectors, + inputMean, constructionMetrics); var writer = bws.builder.build(); writers.put(features, writer); @@ -682,6 +690,7 @@ private static BuilderWithSuppliers builderWithSuppliers(Set features RandomAccessVectorValues floatVectors, ProductQuantization pq, ASHVectors ashVectors, + VectorFloat inputMean, ConstructionMetrics constructionMetrics) throws FileNotFoundException { @@ -719,9 +728,12 @@ private static BuilderWithSuppliers builderWithSuppliers(Set features break; case NVQ_VECTORS: int nSubVectors = floatVectors.dimension() == 2 ? 1 : 2; + Supplier trainNVQ = () -> inputMean == null + ? NVQuantization.compute(floatVectors, nSubVectors) + : NVQuantization.create(inputMean, nSubVectors); var nvq = (constructionMetrics != null) - ? constructionMetrics.index("NVQ").timeCompute(() -> NVQuantization.compute(floatVectors, nSubVectors)) - : NVQuantization.compute(floatVectors, nSubVectors); + ? constructionMetrics.index("NVQ").timeCompute(trainNVQ) + : trainNVQ.get(); builder.with(new NVQ(nvq)); inputSuppliers.put(FeatureId.NVQ_VECTORS, vector -> new NVQ.State(nvq.encode(vector))); suppliers.put(FeatureId.NVQ_VECTORS, ordinal -> new NVQ.State(nvq.encode(writerVectors.get().getVector(ordinal)))); @@ -823,7 +835,7 @@ private static Map, ImmutableGraphIndex> buildInMemory(List vector(float... values) { + return VectorizationProvider.getInstance().getVectorTypeSupport().createFloatVector(values); + } + + private static class BorrowedValues implements RandomAccessVectorValues { + final List> vectors; + final AtomicInteger reads; + final VectorFloat scratch; + BorrowedValues(List> vectors, AtomicInteger reads) { + this.vectors = vectors; + this.reads = reads; + scratch = vector(new float[vectors.get(0).length()]); + } + public int size() { return vectors.size(); } + public int dimension() { return scratch.length(); } + public boolean isValueShared() { return true; } + public RandomAccessVectorValues copy() { return new BorrowedValues(vectors, reads); } + public VectorFloat getVector(int ordinal) { + reads.incrementAndGet(); + var v = vectors.get(ordinal); + if (v == null) return null; + scratch.copyFrom(v, 0, 0, dimension()); + return scratch; + } + } + + @Test public void fullMeanMatchesExistingNVQWithoutAnotherRead() { + var input = List.of(vector(1, 2), vector(3, 6), vector(5, 4)); + var reads = new AtomicInteger(); + var source = new BorrowedValues(input, reads); + var collector = new VectorMean(3, 2, 3, 7); + var pool = new ForkJoinPool(3); + try { VectorAccess.forEach(collector.duringScan(source), pool, (i, v) -> {}); } + finally { pool.shutdown(); } + assertEquals(3, reads.get()); + assertEquals(3, collector.count()); + var mean = collector.mean(); + var reused = NVQuantization.create(mean, 1); + assertEquals(3, reads.get()); + var original = NVQuantization.compute(source, 1); + for (int d = 0; d < 2; d++) assertEquals(original.globalMean.get(d), reused.globalMean.get(d), 1e-6); + mean.set(0, -100); + assertEquals(3, collector.mean().get(0), 0); + } + + @Test public void deterministicSampleIsAccurateAndIgnoresRepeatedAccess() { + int n = 100000, dimensions = 4; + var one = new VectorMean(n, dimensions); + var two = new VectorMean(n, dimensions); + var v = vector(new float[dimensions]); + var random = new Random(19); + double[] exact = new double[dimensions]; + for (int i = 0; i < n; i++) { + for (int d = 0; d < dimensions; d++) { + float value = (float) (random.nextGaussian() + d); + v.set(d, value); + exact[d] += value; + } + one.accept(i, v); + one.accept(i, v); // repetitions never weight a sample twice + two.accept(i, v); + } + assertEquals(VectorMean.DEFAULT_SAMPLE_SIZE, one.count()); + for (int d = 0; d < dimensions; d++) { + assertEquals(one.mean().get(d), two.mean().get(d), 0); + assertEquals(exact[d] / n, one.mean().get(d), .025); + } + } + + @Test public void sampledMeanRetainsNVQReconstructionQuality() { + int n = 20000, dimensions = 16; + var random = new Random(28); + var inputs = new ArrayList>(); + var collector = new VectorMean(n, dimensions); + for (int i = 0; i < n; i++) { + var v = vector(new float[dimensions]); + for (int d = 0; d < dimensions; d++) v.set(d, (float) (random.nextGaussian() + d)); + inputs.add(v); + collector.accept(i, v); + } + var full = NVQuantization.compute(new ListRandomAccessVectorValues(inputs, dimensions), 2); + var sampled = NVQuantization.create(collector.mean(), 2); + double fullError = 0, sampledError = 0; + for (int i = 0; i < 100; i++) { + fullError += full.reconstructionError(inputs.get(i)); + sampledError += sampled.reconstructionError(inputs.get(i)); + } + assertTrue("Sampled mean changed NVQ reconstruction error materially: " + sampledError + " vs " + fullError, + sampledError <= fullError * 1.05); + } + + @Test public void pointReadsDoNotCountAndIncompleteScanFails() { + var source = new BorrowedValues(List.of(vector(1), vector(2), vector(3)), new AtomicInteger()); + var mean = new VectorMean(3, 1, 3, 0); + var scan = mean.duringScan(source); + for (int i = 0; i < 3; i++) scan.getVector(i); + assertEquals(0, mean.count()); + assertThrows(IllegalStateException.class, mean::mean); + try (var cursor = VectorAccess.openRange(scan.copy(), 0, 2)) { while (cursor.next()) {} } + assertFalse(mean.isComplete()); + try (var cursor = VectorAccess.openSelection(scan, new int[]{2, 0, 2}, 0, 3)) { while (cursor.next()) {} } + assertEquals(3, mean.count()); + assertEquals(2, mean.mean().get(0), 0); + } + + @Test public void databasePushInputNeedsNoLoaderOrWrapper() { + var mean = new VectorMean(4, 2, 4, 0); + mean.accept(3, vector(7, 8)); + mean.accept(1, vector(3, 4)); + mean.accept(0, vector(1, 2)); + mean.accept(2, vector(5, 6)); + assertEquals(4, mean.mean().get(0), 0); + assertEquals(5, mean.mean().get(1), 0); + } + + @Test public void invalidValuesAndMissingInputsAreHandledExplicitly() { + assertThrows(IllegalArgumentException.class, () -> new VectorMean(0, 2)); + assertThrows(IllegalArgumentException.class, () -> new VectorMean(1, 0)); + var mean = new VectorMean(2, 2, 2, 0); + assertThrows(IllegalArgumentException.class, () -> mean.accept(0, vector(1))); + assertThrows(IllegalArgumentException.class, () -> mean.accept(0, vector(1, Float.NaN))); + assertThrows(IllegalArgumentException.class, () -> mean.accept(0, vector(1, Float.POSITIVE_INFINITY))); + mean.accept(0, vector(1, 2)); + mean.accept(1, null); + assertTrue(mean.isComplete()); + assertEquals(1, mean.count()); + assertEquals(1, mean.mean().get(0), 0); + var empty = new VectorMean(1, 1); + empty.accept(0, null); + assertThrows(IllegalStateException.class, empty::mean); + } + + @Test public void existingEncodersCollectOriginalCoordinatesWithOneReadEach() throws Exception { + var input = new ArrayList>(); + var random = new Random(81); + for (int i = 0; i < 128; i++) { + var v = vector(new float[16]); + for (int d = 0; d < 16; d++) v.set(d, random.nextFloat() + 2); + input.add(v); + } + var resident = new ListRandomAccessVectorValues(input, 16); + var pq = ProductQuantization.compute(resident, 2, 4, true); + var ash = AsymmetricHashing.initialize(resident, AsymmetricHashing.RANDOM, + AsymmetricHashing.HEADER_BITS + 8 * 2, 1, 2); + var pool = new ForkJoinPool(4); + try { + for (var compressor : new VectorCompressor[]{pq, ash, new BinaryQuantization(16)}) { + var reads = new AtomicInteger(); + var source = new BorrowedValues(input, reads); + var collector = new VectorMean(128, 16, 128, 0); + compressor.encodeAll(collector.duringScan(source), pool); + assertEquals(compressor.getClass().getName(), 128, reads.get()); + assertEquals(128, collector.count()); + var nvq = NVQuantization.create(collector.mean(), 2); + assertEquals(128, reads.get()); + var full = NVQuantization.compute(source, 2); + for (int d = 0; d < 16; d++) assertEquals(full.globalMean.get(d), nvq.globalMean.get(d), 2e-6); + } + } finally { pool.shutdown(); } + } + + @Test public void rangeSourceKeepsBatchAccessAndClosesCursors() { + var reads = new AtomicInteger(); + var closes = new AtomicInteger(); + var values = new BorrowedValues(List.of(vector(2), vector(4), vector(6)), reads); + BatchedVectorValues batched = new BatchedVectorValues() { + public int size() { return values.size(); } + public int dimension() { return values.dimension(); } + public boolean isValueShared() { return true; } + public RandomAccessVectorValues copy() { return this; } + public VectorFloat getVector(int ordinal) { throw new AssertionError("Point read used"); } + public VectorCursor openSelection(int[] ordinals, int offset, int count) { throw new AssertionError(); } + public VectorCursor openRange(int start, int end) { + var cursor = VectorAccess.openRange(values, start, end); + return new VectorCursor() { + public boolean next() { return cursor.next(); } + public int ordinal() { return cursor.ordinal(); } + public VectorFloat vector() { return cursor.vector(); } + public boolean isValueShared() { return true; } + public void close() { closes.incrementAndGet(); cursor.close(); } + }; + } + }; + var mean = new VectorMean(3, 1); + try (var cursor = mean.duringScan(batched).openRange(0, 3)) { while (cursor.next()) {} } + assertEquals(3, reads.get()); + assertEquals(1, closes.get()); + assertEquals(4, mean.mean().get(0), 0); + } +} From 31b5d7e5d4e696d59f739e7bf382f128b577b4d6 Mon Sep 17 00:00:00 2001 From: Ted Willke Date: Fri, 6 Nov 2026 01:32:12 +0000 Subject: [PATCH 152/243] Add opt-in oneDNN INT8 ASH query projection Keep FP32 training/encoding, FP16/FP32 implementations, automatic precision selection, and existing compiler settings unchanged. Reuse model-owned query views, caller-owned batching, FFM staging, and bounded native scratch for INT8 projection. Require a qualified full-range VNNI/AMX backend, validate the optional ABI, and map native errors without silent precision fallback. Preserve the prototype's FP32 scales, rounding, integer accumulation, and rescaling. Target only INT8 computation functions for AVX2 so portable builds retain direct rounding instructions. Add precision-selection and processor guidance across the developer guides, release notes, and existing DistancesASH projection comparison. Cover numerical boundaries, concurrency, lifecycle, unsupported libraries/ISA, and unchanged precision defaults. Validation: both JDK22 and JDK25 focused suites pass (36 production checks plus a task-owned actual-prototype equivalence check; 12 optimized-FP16 hardware skips on Ice Lake). 36,704 prototype outputs are bit-exact per JDK. All 392 existing native compiler commands and the FP16 object remain identical. DistancesASH b2/b4 throughput validation passes. BenchYAML Cohere1M graph validation is continuing with raw CSV/source/artifact receipts. --- README.md | 2 + docs/ash-pq-benchmark.md | 5 + docs/ash-projection-precision.md | 99 +++++ docs/ash-query-batching-design.md | 22 +- docs/ash-query-integration.md | 5 +- docs/native-ash-preparation.md | 10 +- .../ash-int8-query-projection.feature.md | 9 + .../jvector/vector/VectorUtilSupport.java | 4 +- .../jbellis/jvector/example/DistancesASH.java | 163 ++++++++ jvector-native/pom.xml | 2 +- .../vector/NativeASHInt8Projection.java | 196 ++++++++++ .../jvector/vector/NativeASHProjection.java | 7 +- .../jvector/vector/cnative/LibraryLoader.java | 2 +- jvector-native/src/main/native/README.md | 109 ++++-- jvector-native/src/main/native/meson.build | 2 +- .../src/main/native/meson_options.txt | 2 +- .../src/main/native/src/jvector_ash_fp16.h | 16 + .../src/main/native/src/jvector_ash_int8.cpp | 275 +++++++++++++ .../vector/NativeASHFP16ProjectionTest.java | 6 + .../vector/NativeASHInt8ProjectionTest.java | 368 ++++++++++++++++++ 20 files changed, 1251 insertions(+), 53 deletions(-) create mode 100644 docs/ash-projection-precision.md create mode 100644 docs/release notes/4.1.0/ash-int8-query-projection.feature.md create mode 100644 jvector-native/src/main/java/io/github/jbellis/jvector/vector/NativeASHInt8Projection.java create mode 100644 jvector-native/src/main/native/src/jvector_ash_int8.cpp create mode 100644 jvector-native/src/test/java/io/github/jbellis/jvector/vector/NativeASHInt8ProjectionTest.java diff --git a/README.md b/README.md index 7eaa0057e..745da9dce 100644 --- a/README.md +++ b/README.md @@ -145,6 +145,8 @@ add-on with the native JAR. Deploy that JAR with the native provider; no separat oneDNN runtime installation or per-machine library symlinks are required. Use `-Dnative.onednn=false` to build a Highway-only package with FP32 fallback. See the [oneDNN installation and fallback details](./jvector-native/src/main/native/README.md#recommended-onednn-build-and-package). +For explicit INT8 query projection and processor-family recommendations, see +[Choosing ASH query-projection precision](./docs/ash-projection-precision.md). The native SIMD library (`libjvector.so`) is built with [Meson](https://mesonbuild.com/) + [Ninja](https://ninja-build.org/) diff --git a/docs/ash-pq-benchmark.md b/docs/ash-pq-benchmark.md index 41cfd1f6d..ac7f4fd78 100644 --- a/docs/ash-pq-benchmark.md +++ b/docs/ash-pq-benchmark.md @@ -23,6 +23,11 @@ run configurations and local dataset catalog are in No separate query harness, source overlay, profiling agent, manually linked library, or dataset-specific projection setting is required. +Projection settings and processor recommendations are documented in +[Choosing ASH query-projection precision](ash-projection-precision.md). +An explicitly requested INT8/FP32 comparison is a precision override to the +FP16 benchmark configuration below; record it with the command and CSV evidence. + ## Build the checkout Use JDK 22 for performance measurements. Follow the repository's diff --git a/docs/ash-projection-precision.md b/docs/ash-projection-precision.md new file mode 100644 index 000000000..18d405df1 --- /dev/null +++ b/docs/ash-projection-precision.md @@ -0,0 +1,99 @@ + + +# Choosing ASH query-projection precision + +Query-projection precision is independent of ASH's code parameters `b` and `d`. +Selecting INT8 projection does not change the stored two-bit or four-bit ASH +codes. Training and base-vector encoding retain their existing FP32 behavior. + +## Recommended setting + +These recommendations concern CPU query throughput and latency. Validate recall +on your data before deploying FP16 or INT8. + +| Processor family | Setting | Why | +|---|---|---| +| Intel Xeon Cascade Lake, Cooper Lake, Ice Lake | `int8` | Fast integer projection; the required FP16 path is unavailable. | +| Intel Xeon Sapphire Rapids, Emerald Rapids, Granite Rapids | `fp16` | Fast FP16 projection with less quantization than INT8. | +| Intel Xeon Sierra Forest | `int8` | Integer acceleration is the useful low-precision path. | +| AMD Zen 4 and Zen 5: EPYC, Ryzen, Threadripper | `int8` | VNNI integer acceleration; these CPUs lack the FP16 instructions required by this backend. | +| Older Intel Xeon and AMD Zen 1–3 | `fp32` | Use the established FP32 path. | +| Arm Neoverse / AWS Graviton and Apple Silicon | `fp32` | Use the established supported path for this release. | + +For query throughput and latency on Intel Ice Lake: + +```text +-Djvector.ash.queryProjection=int8 +``` + +## Available settings + +Set `jvector.ash.queryProjection` before the model's first query use: + +| Setting | Behavior | +|---|---| +| `auto` (default) | Try optimized FP16; retain the existing FP32 fallback if FP16 is unavailable or outside its supported range. Never select INT8 automatically. | +| `fp32` | Use the existing FP32 projection. | +| `fp16` | Require optimized FP16 projection with FP32 accumulation and output. Report an error if unavailable or outside the supported range. | +| `int8` | Require optimized INT8 projection with signed 32-bit integer accumulation and FP32 output. Report an error if unavailable; never silently substitute FP16 or FP32. | + +`auto` preserves the existing precision policy. It does not promise to select +the fastest precision. INT8 remains an explicit accuracy/performance choice. + +oneDNN chooses the CPU implementation within the selected precision at runtime. +Explicit FP16 and INT8 settings fail clearly if the required backend is unavailable, including +when a virtual machine does not expose the needed hardware features. + +The model caches its query-projection view on first use. Changing the property +after initialization does not reconfigure an existing view. + +## Effect on graph construction + +Default graph construction projects an inserted base vector when it searches +for neighbors. The query-projection setting can therefore affect construction +as well as application queries. + +BenchYAML's `-Djvector.bench.ashConstruction=symmetric-all` uses encoded symmetric +scoring for insertion and diversity. It does not use dense query projection +for insertion. + +Neither construction mode changes training or base-encoding precision. To +measure query-projection effects independently, query the same saved graph +under each precision in separate JVM runs. + +## Deployment + +FP16 and INT8 require the native vector provider and the oneDNN add-on included +by the standard native build. See the [native development guide](../jvector-native/src/main/native/README.md#recommended-onednn-build-and-package) +for build commands, supported runtime behavior, diagnostics and tests. + +## Validate a selection + +Use `DistancesASH` to check projection throughput and numerical behavior. +Use standard BenchYAML configurations to measure graph QPS, recall and latency +at the application's physical-core budget. Compare query precisions on the +same graph; measure construction separately when insertion precision changes. +A projection-throughput gain does not imply the same graph-query speedup. + +## References + +- [Processor instruction sets](https://gcc.gnu.org/onlinedocs/gcc-15.1.0/gcc/x86-Options.html). +- [oneDNN CPU runtime dispatch](https://github.com/uxlfoundation/oneDNN/blob/main/README.md). +- [oneDNN INT8 arithmetic](https://uxlfoundation.github.io/oneDNN/dev_guide_int8_computations.html). + +The repository pins its oneDNN version in +`jvector-native/src/main/native/subprojects/onednn.wrap`. diff --git a/docs/ash-query-batching-design.md b/docs/ash-query-batching-design.md index 1d87cb244..d35e0640e 100644 --- a/docs/ash-query-batching-design.md +++ b/docs/ash-query-batching-design.md @@ -21,13 +21,13 @@ They do not need to know or configure the coordination described here. ## Projection and policy are separate -`NativeASHFP16Projection.projectBatch` accepts independent FP32 query rows and -caller-owned FP32 output rows. The native implementation converts inputs to FP16, -uses FP16 weights with strict FP32 accumulation/output, and executes true oneDNN -matrix multiplication. The API accepts arbitrary batch counts; its internal -micro-tiles of at most eight rows are an implementation choice, not an API limit. -Singletons use the existing direct kernel. Training and base-vector encoding -continue through the separate FP32 projection object. +The selected `AshProjection` receives independent FP32 query rows and caller-owned +FP32 output rows. FP16 uses converted FP16 operands with FP32 accumulation/output; +explicit INT8 uses quantized operands with integer accumulation and FP32 output. +Both native query backends use oneDNN, accept arbitrary batch counts and execute +bounded tiles of up to eight rows. Training and base-vector encoding continue +through the separate FP32 projection object. Precision selection belongs to the +[precision guide](ash-projection-precision.md), not to batching policy. `CallerBatchProcessor` is a generic synchronous computation combiner, independent of ASH, FFM and oneDNN. Its `Operation` accepts borrowed input/output objects and @@ -101,7 +101,9 @@ A projection explicitly advertises bulk support. Providers without a real batch kernel bypass coordination. A missing batch ABI preserves compatibility with older native libraries. A native UNSUPPORTED response disables subsequent batch attempts on that FP16 projection and uses the existing singleton fallback. -Other failures remain errors rather than being silently hidden. +Explicit INT8 requires its batch ABI and a qualified exact-integer primitive; +unsupported INT8 calls remain errors rather than changing precision. Other +failures remain errors rather than being silently hidden. The query-specific projection and coordinator are initialized together on first query use and published through the volatile query projection. Encoding/training @@ -124,7 +126,9 @@ combining, FIFO progress, bounded operation counts, repeated record reuse, interruption, reentry, batch/direct failures, completion/park races, and steady state allocation. Native tests compare singleton and batch results with an independent FP16-rounded reference, including tails and counts above eight, -aliasing, validation, fallback and retained-memory growth. +aliasing, validation, fallback and retained-memory growth. INT8 uses an independent +integer oracle and tests concurrent first use, model switching, numeric range +checks and missing/unsupported add-ons. Use BenchYAML for graph measurements. Compare batch size 1 and the default under matched affinity, CPU budget, callers, index/query data and warmup. Check isolated, diff --git a/docs/ash-query-integration.md b/docs/ash-query-integration.md index 8ccbe53c7..a69a9d247 100644 --- a/docs/ash-query-integration.md +++ b/docs/ash-query-integration.md @@ -24,7 +24,10 @@ batch. An isolated request starts as soon as its execution capacity is available The existing synchronous `GraphSearcher` API remains available. The prepared-query and bounded-execution APIs are optional and do not change index formats, write -ordering, FP32 encoding/training, or the default FP16 query precision selection. +ordering, FP32 encoding/training, or the existing automatic FP16/FP32 precision policy. + +See [Choosing ASH query-projection precision](ash-projection-precision.md) for +processor-family recommendations and projection settings. ## Recommended asynchronous allocation diff --git a/docs/native-ash-preparation.md b/docs/native-ash-preparation.md index f5fa5c704..f1a612f68 100644 --- a/docs/native-ash-preparation.md +++ b/docs/native-ash-preparation.md @@ -42,6 +42,12 @@ Java validates shapes, array bounds and output aliasing before passing pointers to native code. Native kernels do not allocate, retain pointers or call Java. Scorer diagnostics identify whether each operation is Highway or Java. +## Query-projection precision + +See [Choosing ASH query-projection precision](ash-projection-precision.md) for hardware recommendations and selection behavior. Explicit INT8 uses the existing immutable query-view interface, batch validation, caller-owned scheduling and Java thread-local staging. Its model-owned native context holds quantized weights, scales and distinct oneDNN packed layouts; compatible batch shapes share packed weights. Calling-thread scratch retains no model. + +The optional oneDNN ABI and status structure are shared with FP16. INT8 checks ABI compatibility before binding, distinguishes unsupported hardware, invalid operands/ranges, allocation failures and runtime failures, and never silently substitutes another precision. Full-range integer dot products require a qualified VNNI or AMX implementation; legacy intermediate saturation is rejected. Scales, quantization and output rescaling retain the prototype’s FP32 arithmetic. Nonfinite operands and a nonzero operand whose scale rounds to zero are rejected before integer conversion. Output rescaling retains ordinary FP32 overflow behavior. INT8 has no unconditional native console output. The original FP16 and FP32 implementations and automatic-selection policy are unchanged. + ## Direct-node scoring and query scratch Fused 2-bit direct-node scoring uses `ash_canonical_lut_dot_f32` and reuses the @@ -80,7 +86,7 @@ it does not claim an allocation-free search API. `StiefelTransform.project()` in FP32, and apply the existing code quantizer and header adjustment. This uses the prepared Highway projection with the native provider and Panama SIMD with the Panama provider. Encoding never requests the -query-only FP16 view. Header scale and offset retain their existing FP16 storage +query-only FP16 or INT8 view. Header scale and offset retain their existing FP16 storage format; database projection precision remains FP32. `encodeAll()` replaces per-vector projection with bounded batches on each worker @@ -357,7 +363,7 @@ This structural change does not select a different BLAS/LAPACK backend. auxiliary landmark arrays, the Stiefel matrices (including row/outer arrays), model objects, provider-owned native FP32 backing and its adapter/delegate objects, optional Highway batch backing, -and a distinct FP16 query representation when initialized. Immutable Java sizes +and a distinct FP16 or INT8 query representation when initialized. Immutable Java sizes are cached during construction; reading the estimate does not scan matrices or create them. FP32 query selection and automatic FP16 fallback preserve the original projection view's identity, so shared FP32 backing is charged once. diff --git a/docs/release notes/4.1.0/ash-int8-query-projection.feature.md b/docs/release notes/4.1.0/ash-int8-query-projection.feature.md new file mode 100644 index 000000000..6176eafc1 --- /dev/null +++ b/docs/release notes/4.1.0/ash-int8-query-projection.feature.md @@ -0,0 +1,9 @@ +ASH supports explicit INT8 query projection through the existing sequential +oneDNN add-on on qualified x86 VNNI/AMX processors. Select it with +`-Djvector.ash.queryProjection=int8`. Training, base encoding, model/index formats, +and the existing automatic FP16/FP32 policy are unchanged. The implementation +reuses the projection API, batching and staging, with explicit capability, +numerical-range and ABI guards. + +See [Choosing ASH query-projection precision](../../ash-projection-precision.md) +for processor-family recommendations and selection behavior. diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/vector/VectorUtilSupport.java b/jvector-base/src/main/java/io/github/jbellis/jvector/vector/VectorUtilSupport.java index 296920de0..8fd736f7d 100644 --- a/jvector-base/src/main/java/io/github/jbellis/jvector/vector/VectorUtilSupport.java +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/vector/VectorUtilSupport.java @@ -536,8 +536,10 @@ default AshProjection forQueryScoring() { case "fp32": return this; case "fp16": throw new UnsupportedOperationException( "This provider does not support FP16 ASH query projection"); + case "int8": throw new UnsupportedOperationException( + "This provider does not support INT8 ASH query projection"); default: throw new IllegalArgumentException( - "jvector.ash.queryProjection must be auto, fp32 or fp16: " + mode); + "jvector.ash.queryProjection must be auto, fp32, fp16 or int8: " + mode); } } diff --git a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/DistancesASH.java b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/DistancesASH.java index 3cbbfe75f..fc91ee0d1 100644 --- a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/DistancesASH.java +++ b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/DistancesASH.java @@ -418,6 +418,159 @@ private static double scanWithSetup(java.util.function.IntFunction f return sum; } + + private static volatile double projectionSink; + + /** Paired projection comparison; optional recall uses the normal full-scan scorers. */ + private static void benchmarkInt8Projection(ASHVectors encoded, List> queries, + List> truth, int[] newToOld, ForkJoinPool executor) throws IOException { + var outputDir=java.nio.file.Path.of(System.getProperty("jvector.bench.outputDir", ".")); + java.nio.file.Files.createDirectories(outputDir); + var model=outputDir.resolve("model-b"+encoded.getCompressor().bitsPerDimension+".ash"); + try(var writer=new io.github.jbellis.jvector.disk.SimpleWriter(model)) { + encoded.getCompressor().write(writer,io.github.jbellis.jvector.graph.disk.OnDiskGraphIndex.CURRENT_VERSION); + } + final AsymmetricHashing copy; + try(var reader=new io.github.jbellis.jvector.disk.SimpleReader(model)) {copy=AsymmetricHashing.load(reader);} + if(!java.util.Arrays.deepEquals(copy.stiefelTransform.AFloat,encoded.getCompressor().stiefelTransform.AFloat)) + throw new AssertionError("Projection matrix changed on model round trip"); + var sharedCodes=new AsymmetricHashing.QuantizedVector[encoded.count()]; + for(int i=0;i1e-6+Math.abs(oracle)*1e-5)throw new AssertionError("VNNI integer-dot oracle mismatch"); + } + } + } + System.out.printf(java.util.Locale.ROOT, + "PROJECTION_ACCURACY queries=%d relative_l2_error=%.9f max_absolute_error=%.9f max_integer_oracle_error=%.9f%n", + inputs.length,Math.sqrt(squared/referenceSquared),maxError,maxOracleError); + try(var csv=new java.io.PrintWriter(outputDir.resolve("projection-b"+copy.bitsPerDimension+".csv").toFile())) { + csv.println("mode,batch,sample,queries,seconds,queries_per_second,ns_per_query,checksum"); + if(Boolean.parseBoolean(System.getProperty("jvector.bench.projectionTiming","true"))) { + long minimum=(long)(Double.parseDouble(System.getProperty("jvector.bench.projectionSeconds","3"))*1e9); + for(int batch:new int[]{1,2,4,8}) { + float[][] in=new float[batch][],out=new float[batch][copy.quantizedDim]; + // Batch/single quantization must produce the same projected state. + for(int i=0;i1e-6) + throw new AssertionError("INT8 batch/single mismatch"); + } + for(int pass=-1;pass<3;pass++)for(int order=0;order<2;order++) { + int mode=(order+Math.max(0,pass))%2; + long start=System.nanoTime(),elapsed;long count=0;int position=0;double checksum=0; + do { + for(int chunk=0;chunk<128;chunk++) { + for(int i=0;i=0) { + double seconds=elapsed/1e9; + csv.printf(java.util.Locale.ROOT,"%s,%d,%d,%d,%.9f,%.6f,%.3f,%.9f%n", + names[mode],batch,pass+1,count,seconds,count/seconds,(double)elapsed/count,checksum);csv.flush(); + System.out.printf(java.util.Locale.ROOT,"PROJECTION_THROUGHPUT mode=%s batch=%d sample=%d queries_per_second=%.3f ns_per_query=%.3f%n", + names[mode],batch,pass+1,count/seconds,(double)elapsed/count); + } + } + } + } + } + // Recall is optional, as in the other DistancesASH modes. Projection + // timing still uses every selected query when exhaustive scans are off. + if (!truth.isEmpty()) { + // Every selected query scans ALL encoded base vectors; no ANN traversal or reranking. + int[] atValues={10,20};int capacity=20; + try(var csv=new java.io.PrintWriter(outputDir.resolve("scan-b"+copy.bitsPerDimension+".csv").toFile())) { + csv.println("mode,bits,dimensions,threads,base_vectors,queries,seconds,scan_queries_per_second,recall10_at10,recall10_at20"); + // Warm the actual fused full-scan and top-k code on both arms. + for(int mode=0;mode<2;mode++)scanProjectionRecall(arms[mode],queries,truth,newToOld,Math.min(32,queries.size()),executor); + for(int mode=0;mode<2;mode++) { + long start=System.nanoTime(); + double[] totals=scanProjectionRecall(arms[mode],queries,truth,newToOld,queries.size(),executor); + double seconds=(System.nanoTime()-start)/1e9; + csv.printf(java.util.Locale.ROOT,"%s,%d,%d,%d,%d,%d,%.9f,%.6f,%.9f,%.9f%n", + names[mode],copy.bitsPerDimension,copy.quantizedDim,executor.getParallelism(),encoded.count(),queries.size(), + seconds,queries.size()/seconds,totals[0]/queries.size(),totals[1]/queries.size());csv.flush(); + System.out.printf(java.util.Locale.ROOT,"PROJECTION_SCAN mode=%s queries=%d vectors=%d seconds=%.3f recall10_at10=%.6f recall10_at20=%.6f%n", + names[mode],queries.size(),encoded.count(),seconds,totals[0]/queries.size(),totals[1]/queries.size()); + } + } + } + System.out.println("PROJECTION_TIMING one calling thread; INT8 quantization, FFM staging, matmul and rescaling included; immutable matrix packing excluded"); + } + + private static double[] scanProjectionRecall(ASHVectors vectors,List> queries, + List> truth,int[] newToOld,int count,ForkJoinPool executor) { + var tasks=new ArrayList>(); + for(int worker=0;worker{ + double[] totals=new double[2];float[] scores=new float[32]; + for(int q=first;q((a,b)->Float.compare(Float.intBitsToFloat((int)a[0]),Float.intBitsToFloat((int)b[0]))); + for(int start=0;starttrue false - + true diff --git a/jvector-native/src/main/java/io/github/jbellis/jvector/vector/NativeASHInt8Projection.java b/jvector-native/src/main/java/io/github/jbellis/jvector/vector/NativeASHInt8Projection.java new file mode 100644 index 000000000..d34151438 --- /dev/null +++ b/jvector-native/src/main/java/io/github/jbellis/jvector/vector/NativeASHInt8Projection.java @@ -0,0 +1,196 @@ +/* + * 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.cnative.LibraryLoader; +import java.lang.foreign.Arena; +import java.lang.foreign.FunctionDescriptor; +import java.lang.foreign.Linker; +import java.lang.foreign.MemoryLayout; +import java.lang.foreign.MemorySegment; +import java.lang.foreign.SymbolLookup; +import java.lang.invoke.MethodHandle; +import java.util.Objects; + +import static java.lang.foreign.ValueLayout.ADDRESS; +import static java.lang.foreign.ValueLayout.JAVA_BYTE; +import static java.lang.foreign.ValueLayout.JAVA_INT; +import static java.lang.foreign.ValueLayout.JAVA_LONG; + +/** + * Immutable, model-owned oneDNN INT8 query view. Training/encoding retain FP32. + * Reuses the existing projection contract, caller-owned batching and calling-thread + * staging. Native scratch is thread-local and retains no model or packed weights. + * Explicit INT8 selection never silently substitutes another precision. + */ +final class NativeASHInt8Projection implements VectorUtilSupport.AshProjection { + private static final int OK = 0, UNSUPPORTED = 1, INVALID_ARGUMENT = 2, + RANGE = 3, NO_MEMORY = 4; + // The optional add-on shares the existing ABI-1 caller-owned result structure. + private static final MemoryLayout RESULT_LAYOUT = MemoryLayout.structLayout( + JAVA_INT, MemoryLayout.sequenceLayout(256, JAVA_BYTE)); + + private static final class Bindings { + static final Bindings INSTANCE = load(); + final MethodHandle create, destroy, batch, bytes; + + private Bindings(SymbolLookup lookup) throws Throwable { + MethodHandle abi = bind(lookup, "jvector_ash_fp16_abi", FunctionDescriptor.of(JAVA_INT)); + if ((int) abi.invokeExact() != 1) + throw new UnsatisfiedLinkError("Unsupported ASH projection add-on ABI"); + create = bind(lookup, "jvector_ash_int8_create", + FunctionDescriptor.of(ADDRESS, ADDRESS, JAVA_INT, JAVA_INT, ADDRESS)); + destroy = bind(lookup, "jvector_ash_int8_destroy", FunctionDescriptor.ofVoid(ADDRESS)); + batch = bind(lookup, "jvector_ash_int8_project_batch", + FunctionDescriptor.of(JAVA_INT, ADDRESS, ADDRESS, JAVA_INT, ADDRESS, ADDRESS)); + bytes = bind(lookup, "jvector_ash_int8_bytes", FunctionDescriptor.of(JAVA_LONG, ADDRESS)); + } + + private static MethodHandle bind(SymbolLookup lookup, String name, FunctionDescriptor descriptor) { + MemorySegment symbol = lookup.find(name) + .orElseThrow(() -> new UnsatisfiedLinkError("Missing ASH INT8 symbol: " + name)); + return Linker.nativeLinker().downcallHandle(symbol, descriptor); + } + + private static Bindings load() { + if (!LibraryLoader.loadAshFp16()) return null; + try { + return new Bindings(SymbolLookup.loaderLookup()); + } catch (UnsatisfiedLinkError | IllegalCallerException | SecurityException + | UnsupportedOperationException unavailable) { + return null; + } catch (RuntimeException | Error failure) { + throw failure; + } catch (Throwable failure) { + throw new AssertionError("Unexpected ASH INT8 binding failure", failure); + } + } + } + + private final MemorySegment context; + private final int rows, columns; + + private NativeASHInt8Projection(MemorySegment context, int rows, int columns) { + this.context = context; + this.rows = rows; + this.columns = columns; + } + + static VectorUtilSupport.AshProjection prepare(MemorySegment matrix, int rows, int columns) { + Bindings bindings = Bindings.INSTANCE; + if (bindings == null) + throw new UnsupportedOperationException("ASH INT8 query projection add-on is unavailable; " + + "build with -Dnative.onednn=true or select fp32/auto"); + try (Arena setup = Arena.ofConfined()) { + MemorySegment status = setup.allocate(RESULT_LAYOUT); + MemorySegment pointer = (MemorySegment) bindings.create.invokeExact(matrix, rows, columns, status); + int code = status.get(JAVA_INT, 0); + if (pointer.address() == 0) throw failure(code, status); + if (code != OK) { + bindings.destroy.invokeExact(pointer); + throw failure(code, status); + } + MemorySegment owned; + try { + owned = pointer.reinterpret(1, Arena.ofAuto(), NativeASHInt8Projection::destroy); + } catch (Throwable failure) { + bindings.destroy.invokeExact(pointer); + throw failure; + } + return new NativeASHInt8Projection(owned, rows, columns); + } catch (RuntimeException | Error failure) { + throw failure; + } catch (Throwable failure) { + throw new AssertionError("Unexpected ASH INT8 initialization failure", failure); + } + } + + private static void destroy(MemorySegment pointer) { + try { + Bindings.INSTANCE.destroy.invokeExact(pointer); + } catch (Throwable failure) { + throw new AssertionError("Unexpected ASH INT8 cleanup failure", failure); + } + } + + private static RuntimeException failure(int code, MemorySegment status) { + String message = "ASH INT8 projection: " + status.getString(JAVA_INT.byteSize()); + if (code == NO_MEMORY) throw new OutOfMemoryError(message); + if (code == UNSUPPORTED) return new UnsupportedOperationException(message); + if (code == INVALID_ARGUMENT || code == RANGE) return new IllegalArgumentException(message); + return new IllegalStateException(message + " (status " + code + ")"); + } + + /** Retained native model storage, including lazily packed batch weights. */ + @Override + public long ramBytesUsed() { + try { + return (long) Bindings.INSTANCE.bytes.invokeExact(context); + } catch (Throwable failure) { + throw new AssertionError("Unexpected ASH INT8 accounting failure", failure); + } + } + + @Override + public boolean supportsBatchProjection() { return true; } + + @Override + public void project(float[] query, float[] output) { + Objects.requireNonNull(query, "query"); + Objects.requireNonNull(output, "output"); + if (query.length != columns || output.length != rows || query == output) + throw new IllegalArgumentException("ASH projection shape or aliasing mismatch"); + long inputBytes = (long) columns * Float.BYTES; + long outputBytes = (long) rows * Float.BYTES; + var buffers = NativeASHProjection.buffers(inputBytes, outputBytes); + MemorySegment.copy(MemorySegment.ofArray(query), 0, buffers.input, 0, inputBytes); + execute(buffers, 1); + MemorySegment.copy(buffers.output, 0, MemorySegment.ofArray(output), 0, outputBytes); + } + + private void execute(NativeASHProjection.Buffers buffers, int count) { + if (buffers.status.byteSize() == 0) buffers.status = Arena.ofAuto().allocate(RESULT_LAYOUT); + try { + int status = (int) Bindings.INSTANCE.batch.invokeExact( + context, buffers.input, count, buffers.output, buffers.status); + if (status != OK) throw failure(status, buffers.status); + } catch (RuntimeException | Error failure) { + throw failure; + } catch (Throwable failure) { + throw new AssertionError("Unexpected ASH INT8 execution failure", failure); + } + } + + @Override + public void projectBatch(float[][] queries, float[][] outputs, int count) { + VectorUtilSupport.AshProjection.validateBatch(queries, outputs, count); + if (count == 0) return; + if (queries[0].length != columns || outputs[0].length != rows) + throw new IllegalArgumentException("ASH INT8 projection batch shape mismatch"); + long inputRowBytes = (long) columns * Float.BYTES; + long outputRowBytes = (long) rows * Float.BYTES; + var buffers = NativeASHProjection.buffers( + Math.multiplyExact(count, inputRowBytes), Math.multiplyExact(count, outputRowBytes)); + for (int i = 0; i < count; i++) + MemorySegment.copy(MemorySegment.ofArray(queries[i]), 0, buffers.input, + i * inputRowBytes, inputRowBytes); + execute(buffers, count); + for (int i = 0; i < count; i++) + MemorySegment.copy(buffers.output, i * outputRowBytes, + MemorySegment.ofArray(outputs[i]), 0, outputRowBytes); + } +} diff --git a/jvector-native/src/main/java/io/github/jbellis/jvector/vector/NativeASHProjection.java b/jvector-native/src/main/java/io/github/jbellis/jvector/vector/NativeASHProjection.java index b3f2f991b..c1a012d60 100644 --- a/jvector-native/src/main/java/io/github/jbellis/jvector/vector/NativeASHProjection.java +++ b/jvector-native/src/main/java/io/github/jbellis/jvector/vector/NativeASHProjection.java @@ -54,13 +54,13 @@ final class NativeASHProjection implements VectorUtilSupport.AshProjection { FunctionDescriptor.ofVoid(ADDRESS, ADDRESS, JAVA_INT, JAVA_INT, JAVA_INT, ADDRESS))) .orElse(null); - // Calling-thread storage is shared across models and FP32/FP16 views. It + // Calling-thread storage is shared across models and FP32/FP16/INT8 views. It // holds no model reference, grows only when necessary and dies with the thread. private static final ThreadLocal BUFFERS = ThreadLocal.withInitial(Buffers::new); static final class Buffers { MemorySegment input = MemorySegment.NULL, output = MemorySegment.NULL; - // Allocated only by the FP16 view; carries errors without native thread affinity. + // Allocated only by the FP16/INT8 views; carries errors without native thread affinity. MemorySegment status = MemorySegment.NULL; void ensure(long inputBytes, long outputBytes) { @@ -200,10 +200,11 @@ public VectorUtilSupport.AshProjection forQueryScoring() { String mode = System.getProperty("jvector.ash.queryProjection", "auto"); switch (mode) { case "fp32": return this; + case "int8": return NativeASHInt8Projection.prepare(matrix, rows, columns); case "auto": return NativeASHFP16Projection.prepare(matrix, rows, columns, this, false); case "fp16": return NativeASHFP16Projection.prepare(matrix, rows, columns, this, true); default: throw new IllegalArgumentException( - "jvector.ash.queryProjection must be auto, fp32 or fp16: " + mode); + "jvector.ash.queryProjection must be auto, fp32, fp16 or int8: " + mode); } } diff --git a/jvector-native/src/main/java/io/github/jbellis/jvector/vector/cnative/LibraryLoader.java b/jvector-native/src/main/java/io/github/jbellis/jvector/vector/cnative/LibraryLoader.java index 21259fd33..6cae930be 100644 --- a/jvector-native/src/main/java/io/github/jbellis/jvector/vector/cnative/LibraryLoader.java +++ b/jvector-native/src/main/java/io/github/jbellis/jvector/vector/cnative/LibraryLoader.java @@ -61,7 +61,7 @@ static String resourceNameForArch(String library) { /// blocks loading (`SecurityException`). The bundled copy isn't retried after access denial. public static boolean loadJvector() { return loadLibrary("jvector"); } - /** Loads the optional oneDNN add-on for FP32 base batches and FP16 queries; Highway stays independent. */ + /** Loads the optional oneDNN add-on for FP32 base batches and FP16/INT8 queries; Highway stays independent. */ public static boolean loadAshFp16() { return loadLibrary("jvector_ash_fp16"); } private static boolean loadLibrary(String library) { diff --git a/jvector-native/src/main/native/README.md b/jvector-native/src/main/native/README.md index a2dd0e8c3..7f136e848 100644 --- a/jvector-native/src/main/native/README.md +++ b/jvector-native/src/main/native/README.md @@ -69,7 +69,7 @@ third_party/highway/ - Google Highway header-only library (git submodul > Clang ≥ 22 or use GCC to get full SVE/SVE2 acceleration. For ASH, include oneDNN: it is the preferred projection implementation for FP32 -encoding batches and FP16 queries where supported. It is built from the pinned +encoding batches and FP16/INT8 queries where supported. It is built from the pinned source and bundled; users do not need a separately installed `libdnnl`. A first online build needs access to the source URL in `subprojects/onednn.wrap`; offline builds can populate that exact revision beforehand. On Ubuntu, install @@ -93,7 +93,7 @@ Parameters (all optional): |-----------|---------|-------------| | `buildtype` | `release` | `release`, `debug`, or `debugoptimized` | | `crossarch` | `false` | Set to `true` to also cross-compile for the other arch | -| `onednn` | `true` | Bundle FP32 batch and FP16 query projection support; set to `false` for Highway-only builds | +| `onednn` | `true` | Bundle FP32 batch and FP16/INT8 query projection support; set to `false` for Highway-only builds | The default ASH build includes oneDNN: @@ -853,39 +853,58 @@ When changing the native interface: support by itself. -## ASH FP16 query projection + +## ASH query projection -ASH base-vector encoding retains FP32; batch projection prefers oneDNN and -single-vector projection uses Highway. Training uses the BLAS/LAPACK provider. Query scoring -lazily prepares a separate immutable FP16 representation of the same trained -matrix. Incoming query operands are converted to FP16; accumulation and output -are strictly FP32. The serialized model and graph format do not change. +ASH base-vector encoding retains FP32: batch projection prefers oneDNN and +single-vector projection uses Highway. Training uses the BLAS/LAPACK provider. +Query scoring can prepare a separate immutable FP16 or explicit INT8 view of +the same trained projection matrix. Model and graph formats do not change. -The runtime property `jvector.ash.queryProjection` selects the query arithmetic: +Use [Choosing ASH query-projection precision](../../../../docs/ash-projection-precision.md) +for processor-family recommendations, settings and when selection takes effect. +This section describes native build, arithmetic, ownership and diagnostics. -| Value | Behavior | -|-------|----------| -| `auto` (default) | Prefer optimized native FP16; use the original FP32 projection if the optional library, CPU/model kernel or operand range is unsupported. | -| `fp32` | Keep the original provider's FP32 projection. | -| `fp16` | Require native FP16; report unsupported configurations or operands explicitly. | +### FP16 behavior -The setting is read when each model's query projection is first prepared. Set it -before searching; changing it does not mutate an already prepared model. Other -providers retain their original projection in automatic mode. Explicit FP16 -requires the native provider. Input shape/aliasing errors, allocation failures -and execution errors are reported rather than silently converted to fallback. -In automatic mode an individual query outside the finite FP16 range uses FP32. -Strict FP16 accepts only finite matrix/query values with magnitude at most -65504. Underflow and rounding follow IEEE binary16 arithmetic. Reduced operand -precision can change scores and rankings slightly; compare recall for the target -workload when selecting a precision. +FP16 uses FP16 operands with strict FP32 accumulation/output. The existing +`auto` policy prefers optimized FP16 and retains its FP32 fallback. An individual +query outside the finite FP16 range uses FP32 in automatic mode. Required `fp16` +accepts only finite matrix/query values with magnitude at most 65504; underflow +and rounding follow IEEE binary16 arithmetic. Input shape/aliasing errors, +allocation failures and execution errors are reported rather than hidden. + +### INT8 behavior + +Required `int8` prepares symmetric signed weights per output row and quantizes +each query independently. oneDNN computes unsigned-input/signed-weight products +with signed 32-bit accumulation. Correcting the input offset and dequantizing +produces FP32 output. Scales, quantization and rescaling use FP32 arithmetic. A nonzero operand whose +scale rounds to zero is rejected before integer conversion. Nonfinite operands +are rejected; output rescaling retains ordinary FP32 overflow behavior. + +On x86, only the INT8 quantization and batch-projection functions use an AVX2 +compiler target. The generic model-creation guard runs first and requires a +qualified VNNI/AMX backend, whose ISA includes AVX2. This keeps rounding in native +instructions without changing FP16/FP32 or library-wide compiler settings. + +The qualified VNNI/AMX kernels preserve full-range integer dot products. Legacy +kernels can saturate at a 16-bit intermediate; INT8 rejects them rather than +silently changing the quantization range. Both the effective oneDNN ISA and the +selected primitive are checked. CPU vendor names do not control dispatch. +The 65535-column bound prevents signed 32-bit accumulation overflow. + +Explicit INT8 reports unsupported backends without substituting another +precision. The loader validates the existing add-on ABI before binding its +symbols. Invalid operands, numeric range failures, allocation failures and +unexpected execution failures retain distinct error categories across FFM. ### Recommended oneDNN build and package Native Maven and direct-script builds include oneDNN by default on the supported Linux platforms. Use JDK 22 or newer. It is **automatically preferred** for FP32 encoding batches and supported FP16 queries; -there is no additional runtime opt-in. Highway stays independent and supplies +INT8 is available through explicit selection. Highway stays independent and supplies FP32 fallback when the add-on or the requested operation is unavailable: @@ -911,7 +930,7 @@ this library and the upstream license/third-party notices beside the Highway resource. The existing architecture-aware loader first tries the system library path, then extracts the matching classpath resource. No external `libdnnl` or benchmark-specific path is required. The resource keeps its existing FP16 name -for binary compatibility but supplies both FP32 and FP16 projection. Passing +for binary compatibility but supplies FP32 batch and FP16/INT8 query projection. Passing `-Dnative.onednn=false` (or `false` as the script's third argument) leaves oneDNN projection out of the package and removes stale add-on resources from previous builds; Highway FP32 is then used. @@ -923,13 +942,13 @@ oneDNN SGEMM without converting database operands to FP16. Single-vector encodin continues to use Highway FP32. The FP32 loader validates the same ABI as FP16; missing/older add-ons and unsupported operations keep Highway. FP16 automatic mode additionally falls back for operands outside FP16's finite range. Explicit -`fp16` mode requires support; invalid inputs, allocation failures and execution +`fp16` and `int8` modes require support; invalid inputs, allocation failures and execution errors propagate. See [implementation and measured encoding scope](../../../../docs/native-ash-preparation.md#preferred-onednn-fp32-encoding-projection-november-1-2026). ### Ownership, parallelism and tests -Each model owns its immutable prepared FP16 weights and a single-query execution -plan. Java automatic arenas release the native context when it becomes +Each model owns its immutable prepared low-precision weights and execution +plans. Java automatic arenas release the native context when it becomes unreachable. Each calling thread reuses its conversion, staging and scratch buffers; neither thread-local cache retains the model or weights. Calls use ordinary FFM downcalls. oneDNN starts no search workers or nested thread pool. @@ -938,18 +957,41 @@ Concurrent callers use the same immutable model with separate workspaces. The native-module Java tests retain FP32 coverage and check FP16 against an independent rounded-operand oracle, irregular and large dimensions, numeric boundaries, input guards, model switching, concurrent platform/virtual threads, -query-only precision and loading without the optional library. On a machine +query-only precision and loading without the optional library. INT8 tests additionally +cover full-range integer accumulation, FP32 scale underflow/overflow guards, batch tails, +concurrent model switching, lazy packed-weight accounting, ABI compatibility and +required-mode rejection of legacy integer kernels. On a machine known to support FP16, require both oneDNN paths rather than capability skips with: ```bash mvn -pl jvector-native -am test -Dnative.onednn=true -Djvector.test.requireFp16=true -Djvector.test.requireFp32OneDnn=true ``` +On VNNI/AMX hardware, require INT8 execution instead of capability skips: + +```bash +mvn -pl jvector-native -am test -Djvector.test.requireInt8=true +``` + +No INT8 diagnostics are printed unconditionally. For a separate diagnostic run, +use oneDNN's standard verbose mode to inspect the selected primitive: + +```text +ONEDNN_VERBOSE=1 java ... -Djvector.ash.queryProjection=int8 ... +``` + +Keep verbose diagnostics out of reported timing runs. + With Google Test installed, Meson also builds `test_ash_fp16`; run it with `meson test -C --suite ash_fp16 --print-errorlogs`. +For projection throughput, use the built-in `DistancesASH` comparison with +`-Djvector.bench.int8-projection=true`. The existing `jvector.bench.recall` +setting controls optional exhaustive scans; leave it false for projection-only +measurements. Graph recall and latency are measured separately with BenchYAML. + For performance validation, use the standard BenchYAML query path on identical -saved graphs for `fp32` and `auto`/`fp16`. Include normal query preparation in +saved graphs for `fp32`, `auto`/`fp16` or explicit `int8`. Include normal query preparation in its timers and compare recall@10 as well as QPS. Control CPU affinity and NUMA memory placement consistently across runs; otherwise projection locality can obscure the precision comparison. Do not include compilation or another active @@ -958,8 +1000,9 @@ benchmark in the query measurement interval. ### Concurrent query integration -ASH can combine independent query projections into true FP16 oneDNN matrix -multiplication while retaining the direct singleton path. Applications continue +ASH can combine independent queries when the selected projection advertises +batch support. FP16 and INT8 use true oneDNN matrix multiplication while +retaining a singleton path. Applications continue to issue synchronous searches; no query executor or nested native thread pool is created. Base-vector encoding and training remain FP32. diff --git a/jvector-native/src/main/native/meson.build b/jvector-native/src/main/native/meson.build index a8a078f2d..46b81c868 100644 --- a/jvector-native/src/main/native/meson.build +++ b/jvector-native/src/main/native/meson.build @@ -262,7 +262,7 @@ if get_option('onednn').enabled() onednn_dependency = onednn_project.dependency('dnnl').partial_dependency( compile_args: true, includes: true, link_args: true) ash_fp16_lib = shared_library('jvector_ash_fp16', - sources: 'src/jvector_ash_fp16.cpp', + sources: ['src/jvector_ash_fp16.cpp', 'src/jvector_ash_int8.cpp'], dependencies: onednn_dependency, link_with: onednn_objects, cpp_args: ['-fvisibility=hidden'], diff --git a/jvector-native/src/main/native/meson_options.txt b/jvector-native/src/main/native/meson_options.txt index 789fb6507..d827e4e40 100644 --- a/jvector-native/src/main/native/meson_options.txt +++ b/jvector-native/src/main/native/meson_options.txt @@ -15,4 +15,4 @@ # option('onednn', type: 'feature', value: 'enabled', - description: 'Build ASH FP32 encoding and FP16 query projection with sequential oneDNN') + description: 'Build ASH FP32 encoding and FP16/INT8 query projection with sequential oneDNN') diff --git a/jvector-native/src/main/native/src/jvector_ash_fp16.h b/jvector-native/src/main/native/src/jvector_ash_fp16.h index e08101821..1000994f7 100644 --- a/jvector-native/src/main/native/src/jvector_ash_fp16.h +++ b/jvector-native/src/main/native/src/jvector_ash_fp16.h @@ -71,6 +71,22 @@ JV_ASH_FP16_EXPORT int jvector_ash_fp16_project_batch(void* context, const float JV_ASH_FP16_EXPORT int jvector_ash_fp32_project_batch(const float* matrix, const float* input, int rows, int columns, int count, float* output); +/* Explicit query-only INT8 view. Reuses ABI 1 and its status/result structure. + * create requires a qualified x86 VNNI/AMX primitive and finite FP32 operands. + * columns <= 65535 bounds the full-range integer accumulator. Training and + * encoding never use this view. No C++ exception crosses the ABI. + * bytes includes retained plain/packed weights, scales and kernel wrappers; + * caller scratch and opaque oneDNN/JIT allocations are excluded, as for FP16. */ +JV_ASH_FP16_EXPORT void* jvector_ash_int8_create(const float* matrix, int rows, + int columns, jvector_ash_fp16_result* result); +JV_ASH_FP16_EXPORT void jvector_ash_int8_destroy(void* context); +JV_ASH_FP16_EXPORT uint64_t jvector_ash_int8_bytes(const void* context); +/* Contiguous FP32 input [count][columns], output [count][rows]. Positive count + * has no tile-size cap. Java validates shapes/nonaliasing and publishes output + * only after success. On failure the caller must discard native output. */ +JV_ASH_FP16_EXPORT int jvector_ash_int8_project_batch(void* context, + const float* input, int count, float* output, jvector_ash_fp16_result* result); + #ifdef __cplusplus } #endif diff --git a/jvector-native/src/main/native/src/jvector_ash_int8.cpp b/jvector-native/src/main/native/src/jvector_ash_int8.cpp new file mode 100644 index 000000000..5d689d140 --- /dev/null +++ b/jvector-native/src/main/native/src/jvector_ash_int8.cpp @@ -0,0 +1,275 @@ +/* + * 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. + */ + +// Query-only INT8 representation; FP32/FP16 implementations remain independent. +#include "jvector_ash_fp16.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +static_assert(DNNL_CPU_RUNTIME == DNNL_RUNTIME_SEQ, "Calling workers own parallelism"); +static_assert(offsetof(jvector_ash_fp16_result, message) == sizeof(int32_t)); +static_assert(sizeof(jvector_ash_fp16_result) == sizeof(int32_t) + 256); + +namespace { +using namespace dnnl; +constexpr int kBatchTile = 8; // Matches the existing FP16 native execution tile, not an API limit. +constexpr int kMaxColumns = 65535; // 255 * 127 * D remains within signed int32. +std::atomic next_id{1}; +struct Unsupported : std::runtime_error { using std::runtime_error::runtime_error; }; + +int set_error(jvector_ash_fp16_result* result, int status, const char* message) noexcept { + result->status = status; + std::snprintf(result->message, sizeof(result->message), "%s", message); + return status; +} + +// Translate every native failure before it reaches FFM, using the existing ABI-1 statuses. +int current_error(jvector_ash_fp16_result* result) noexcept { + try { throw; } + catch (const Unsupported& e) { return set_error(result, JV_ASH_FP16_UNSUPPORTED, e.what()); } + catch (const dnnl::error& e) { + return set_error(result, e.status == dnnl_unimplemented ? JV_ASH_FP16_UNSUPPORTED + : e.status == dnnl_out_of_memory ? JV_ASH_FP16_NO_MEMORY + : e.status == dnnl_invalid_arguments ? JV_ASH_FP16_INVALID_ARGUMENT + : JV_ASH_FP16_ERROR, e.what()); + } + catch (const std::bad_alloc& e) { return set_error(result, JV_ASH_FP16_NO_MEMORY, e.what()); } + catch (const std::invalid_argument& e) { return set_error(result, JV_ASH_FP16_INVALID_ARGUMENT, e.what()); } + catch (const std::out_of_range& e) { return set_error(result, JV_ASH_FP16_RANGE, e.what()); } + catch (const std::exception& e) { return set_error(result, JV_ASH_FP16_ERROR, e.what()); } + catch (...) { return set_error(result, JV_ASH_FP16_ERROR, "Unknown INT8 projection failure"); } +} + +bool integer_dot_available() { +#if defined(__x86_64__) || defined(_M_X64) + // Honor oneDNN's effective ISA, including its documented runtime ISA cap. + switch (get_effective_cpu_isa()) { + case cpu_isa::avx2_vnni: + case cpu_isa::avx2_vnni_2: + case cpu_isa::avx512_core_vnni: + case cpu_isa::avx512_core_bf16: + case cpu_isa::avx512_core_fp16: + case cpu_isa::avx512_core_amx: + case cpu_isa::avx512_core_amx_fp16: return true; + default: return false; + } +#else + return false; // Arm backends require separate qualification of their exact-dot primitive. +#endif +} + +// Every qualified x86 VNNI/AMX backend includes AVX2. Keep the entry-point +// hardware check generic; specialize only these INT8 computation functions. +// This preserves direct FP32 rounding instructions in the portable library. +#if defined(__x86_64__) || defined(_M_X64) +#define JV_ASH_INT8_TARGET __attribute__((target("avx2"))) +#else +#define JV_ASH_INT8_TARGET +#endif + +// Share the prototype's FP32 quantization between matrix/query preparation. +// Reject a nonzero operand whose scale rounds to zero, before division and +// integer conversion. Do not change the ordinary arithmetic or rounding. +template +JV_ASH_INT8_TARGET float quantize(const float* input, int count, Code* output, int offset, int32_t* sum = nullptr) { + float maxabs = 0; + for (int i = 0; i < count; ++i) { + if (!std::isfinite(input[i])) throw std::invalid_argument("Nonfinite INT8 operand"); + maxabs = std::max(maxabs, std::fabs(input[i])); + } + float scale = maxabs == 0 ? 1.0f : maxabs / 127.0f; + if (scale == 0) throw std::invalid_argument("INT8 operand scale underflows FP32; rescale input"); + int32_t total = 0; + for (int i = 0; i < count; ++i) { + int q = std::clamp(int(std::nearbyint(input[i] / scale)), -127, 127); + output[i] = Code(q + offset); + total += q; + } + if (sum) *sum = total; + return scale; +} + +struct Kernel { + matmul::primitive_desc descriptor; + matmul operation; + memory weights; + Kernel(const engine& cpu, int rows, int columns, int count) { + primitive_attr attributes; + attributes.set_scratchpad_mode(scratchpad_mode::user); + descriptor = matmul::primitive_desc(cpu, + memory::desc({count, columns}, memory::data_type::u8, memory::format_tag::ab), + memory::desc({columns, rows}, memory::data_type::s8, memory::format_tag::any), + memory::desc({count, rows}, memory::data_type::s32, memory::format_tag::ab), attributes); + // The pinned oneDNN exposes the selected implementation through this + // public descriptor query. Accept its VNNI AND AMX integer kernels; + // legacy u8*s8 kernels can saturate at a 16-bit intermediate and must not + // silently compute a different dot product. Do not gate on CPU vendor. + const char* implementation = descriptor.impl_info_str(); + if (!std::strstr(implementation, "vnni") && !std::strstr(implementation, "amx")) + throw Unsupported("No qualified full-range VNNI/AMX INT8 projection kernel"); + operation = matmul(descriptor); + } +}; + +struct Context { + const uint64_t id = next_id.fetch_add(1, std::memory_order_relaxed); + const int rows, columns; + engine cpu{engine::kind::cpu, 0}; + std::vector scales; + std::vector sums; + // Retained once so additional oneDNN layouts never require another FP32 + // matrix copy or requantization. Compatible batch kernels share packed weights. + memory plain; + std::array, kBatchTile + 1> kernels{}; + std::array, kBatchTile + 1> ready{}; + std::mutex mutex; + std::atomic retained_bytes{0}; + + Context(const float* matrix, int r, int c) : rows(r), columns(c), scales(r), sums(r) { + for (auto& entry : ready) entry.store(nullptr, std::memory_order_relaxed); + auto descriptor = memory::desc({columns, rows}, memory::data_type::s8, memory::dims{1, columns}); + plain = memory(descriptor, cpu); + auto* target = static_cast(plain.get_data_handle()); + for (int row = 0; row < rows; ++row) + scales[row] = quantize(matrix + size_t(row) * columns, columns, + target + size_t(row) * columns, 0, &sums[row]); + retained_bytes.store(descriptor.get_size() + sizeof(Context) + + size_t(rows) * (sizeof(float) + sizeof(int32_t)), std::memory_order_relaxed); + kernel(1); // Check required hardware/primitive during model initialization. + } + + const Kernel& kernel(int count) { + auto* prepared = ready[count].load(std::memory_order_acquire); + if (!prepared) { + std::lock_guard guard(mutex); + prepared = ready[count].load(std::memory_order_relaxed); + if (!prepared) { + auto next = std::make_unique(cpu, rows, columns, count); + uint64_t added = sizeof(Kernel); + for (const auto& other : kernels) + if (other && other->weights.get_desc() == next->descriptor.weights_desc()) { + next->weights = other->weights; + break; + } + if (!next->weights) { + next->weights = memory(next->descriptor.weights_desc(), cpu); + stream setup(cpu); + reorder(plain, next->weights).execute(setup, plain, next->weights); + setup.wait(); + added += next->descriptor.weights_desc().get_size(); + } + kernels[count] = std::move(next); + retained_bytes.fetch_add(added, std::memory_order_release); + prepared = kernels[count].get(); + ready[count].store(prepared, std::memory_order_release); + } + } + return *prepared; + } +}; + +struct Workspace { + uint64_t context_id = 0; + std::unique_ptr execution; + memory input, output, scratch; + std::array scales{}; + void initialize(const Context& context, const Kernel& kernel) { + context_id = 0; // Never publish partially replaced buffers after allocation failure. + execution = std::make_unique(context.cpu); + input = memory(kernel.descriptor.src_desc(), context.cpu); + output = memory(kernel.descriptor.dst_desc(), context.cpu); + scratch = memory(kernel.descriptor.scratchpad_desc(), context.cpu); + context_id = context.id; + } +}; +// Bounded per-thread slots hold no Context or packed weights. +thread_local std::array workspaces; +} + +extern "C" { +void* jvector_ash_int8_create(const float* matrix, int rows, int columns, jvector_ash_fp16_result* result) { + if (!result) return nullptr; + result->status = JV_ASH_FP16_OK; + result->message[0] = 0; + try { + if (!matrix || rows <= 0 || columns <= 0 + || uint64_t(rows) * uint64_t(columns) > std::numeric_limits::max() / sizeof(float)) + throw std::invalid_argument("Invalid INT8 matrix pointer or dimensions"); + if (columns > kMaxColumns) throw std::out_of_range("INT8 accumulator dimension exceeds 65535"); + if (!integer_dot_available()) throw Unsupported("INT8 projection requires an enabled x86 VNNI/AMX backend"); + return new Context(matrix, rows, columns); + } catch (...) { current_error(result); return nullptr; } +} + +void jvector_ash_int8_destroy(void* pointer) { delete static_cast(pointer); } + +uint64_t jvector_ash_int8_bytes(const void* pointer) { + return pointer ? static_cast(pointer)->retained_bytes.load(std::memory_order_acquire) : 0; +} + +JV_ASH_INT8_TARGET int jvector_ash_int8_project_batch(void* pointer, const float* input, int count, + float* output, jvector_ash_fp16_result* result) { + if (!result) return JV_ASH_FP16_INVALID_ARGUMENT; + result->status = JV_ASH_FP16_OK; + result->message[0] = 0; + try { + if (!pointer || !input || !output || count <= 0 || input == output) + throw std::invalid_argument("Invalid INT8 projection buffers or count"); + auto& context = *static_cast(pointer); + if (uint64_t(count) > std::numeric_limits::max() / sizeof(float) + / uint64_t(std::max(context.rows, context.columns))) + throw std::invalid_argument("INT8 batch size overflow"); + for (int first = 0; first < count;) { + int size = std::min(kBatchTile, count - first); + const auto& kernel = context.kernel(size); + auto& buffers = workspaces[size]; + if (buffers.context_id != context.id) buffers.initialize(context, kernel); + auto* quantized = static_cast(buffers.input.get_data_handle()); + for (int i = 0; i < size; ++i) + buffers.scales[i] = quantize(input + size_t(first + i) * context.columns, + context.columns, quantized + size_t(i) * context.columns, 128); + const dnnl_exec_arg_t args[] = {{DNNL_ARG_SRC, buffers.input.get()}, + {DNNL_ARG_WEIGHTS, kernel.weights.get()}, {DNNL_ARG_DST, buffers.output.get()}, + {DNNL_ARG_SCRATCHPAD, buffers.scratch.get()}}; + error::wrap_c_api(dnnl_primitive_execute(kernel.operation.get(), buffers.execution->get(), 4, args), + "INT8 projection execution failed"); + buffers.execution->wait(); + const auto* sums = static_cast(buffers.output.get_data_handle()); + for (int i = 0; i < size; ++i) + for (int row = 0; row < context.rows; ++row) { + int32_t dot = sums[size_t(i) * context.rows + row] - 128 * context.sums[row]; + // Preserve the prototype's FP32 rescaling and evaluation order. + output[size_t(first + i) * context.rows + row] = + float(dot) * buffers.scales[i] * context.scales[row]; + } + first += size; // Cannot overflow even when count approaches INT_MAX. + } + return JV_ASH_FP16_OK; + } catch (...) { return current_error(result); } +} +} diff --git a/jvector-native/src/test/java/io/github/jbellis/jvector/vector/NativeASHFP16ProjectionTest.java b/jvector-native/src/test/java/io/github/jbellis/jvector/vector/NativeASHFP16ProjectionTest.java index 77156fb00..e564d511c 100644 --- a/jvector-native/src/test/java/io/github/jbellis/jvector/vector/NativeASHFP16ProjectionTest.java +++ b/jvector-native/src/test/java/io/github/jbellis/jvector/vector/NativeASHFP16ProjectionTest.java @@ -402,6 +402,12 @@ public static void main(String[] args) { view(original, "fp16"); throw new AssertionError("Explicit FP16 silently fell back"); } catch (UnsupportedOperationException expected) { /* expected */ } + for (int attempt = 0; attempt < 2; attempt++) { + try { + view(original, "int8"); + throw new AssertionError("Explicit INT8 silently fell back"); + } catch (UnsupportedOperationException expected) { /* no initializer poisoning */ } + } var transform = new AsymmetricHashing.StiefelTransform(new Array2DRowRealMatrix(new double[][]{{1}, {2}})); System.setProperty(PROPERTY, "fp16"); float[] out = new float[1]; diff --git a/jvector-native/src/test/java/io/github/jbellis/jvector/vector/NativeASHInt8ProjectionTest.java b/jvector-native/src/test/java/io/github/jbellis/jvector/vector/NativeASHInt8ProjectionTest.java new file mode 100644 index 000000000..715d33e42 --- /dev/null +++ b/jvector-native/src/test/java/io/github/jbellis/jvector/vector/NativeASHInt8ProjectionTest.java @@ -0,0 +1,368 @@ +/* + * 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.cnative.LibraryLoader; +import java.lang.foreign.Arena; +import java.lang.foreign.FunctionDescriptor; +import java.lang.foreign.Linker; +import java.lang.foreign.MemorySegment; +import java.lang.foreign.SymbolLookup; +import java.lang.invoke.MethodHandles; +import java.lang.reflect.InvocationTargetException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Optional; +import java.util.Random; +import java.util.concurrent.Callable; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.api.parallel.ResourceLock; + +import static java.lang.foreign.ValueLayout.JAVA_INT; +import static org.junit.jupiter.api.Assertions.*; +import static org.junit.jupiter.api.Assumptions.assumeTrue; + +/** INT8 numerical, lifecycle, dispatch and compatibility regression tests. */ +@ResourceLock("java.lang.System.properties") +class NativeASHInt8ProjectionTest { + private static final String PROPERTY = "jvector.ash.queryProjection"; + + @BeforeAll + static void loadNative() { + boolean available = LibraryLoader.loadJvector(); + if (Boolean.getBoolean("jvector.test.requireInt8")) assertTrue(available, "Native library required"); + else assumeTrue(available, "Native library unavailable"); + } + + private static VectorUtilSupport.AshProjection prepare(float[][] weights) { + var base = new NativeASHProjection(weights); + try { + return NativeASHInt8Projection.prepare(base.matrix(), base.rows(), base.columns()); + } catch (UnsupportedOperationException unsupported) { + if (Boolean.getBoolean("jvector.test.requireInt8")) throw unsupported; + assumeTrue(false, unsupported.getMessage()); + throw unsupported; + } + } + + private static float scale(float[] values) { + float max = 0; + for (float value : values) max = Math.max(max, Math.abs(value)); + return max == 0 ? 1 : max / 127f; + } + + private static int code(float value, float scale) { + return Math.max(-127, Math.min(127, (int) Math.rint(value / scale))); + } + + private static float[] oracle(float[][] matrix, float[] query) { + float queryScale = scale(query); + float[] out = new float[matrix.length]; + for (int row = 0; row < matrix.length; row++) { + float weightScale = scale(matrix[row]); + long sum = 0; + for (int column = 0; column < query.length; column++) + sum += (long) code(query[column], queryScale) * code(matrix[row][column], weightScale); + out[row] = (float) sum * queryScale * weightScale; + } + return out; + } + + private static float[][] matrix(int rows, int columns, long seed) { + var random = new Random(seed); + float[][] matrix = new float[rows][columns]; + for (float[] row : matrix) + for (int i = 0; i < row.length; i++) row[i] = random.nextFloat() - .5f; + return matrix; + } + + @Test + void integerOraclePaddingTailsAndSingletonAgree() { + float[][] matrix = matrix(33, 65, 19); + matrix[0] = new float[65]; + var projection = prepare(matrix); + float[][] inputs = matrix(17, 65, 20), outputs = new float[18][33]; + inputs[0] = new float[65]; + Arrays.fill(outputs[17], 42); + for (int count : new int[]{0,1,2,3,4,5,6,7,8,9,17}) { + projection.projectBatch(inputs, outputs, count); + for (int i = 0; i < count; i++) { + assertArrayEquals(oracle(matrix, inputs[i]), outputs[i], 1e-7f); + float[] singleton = new float[33]; + projection.project(inputs[i], singleton); + assertArrayEquals(singleton, outputs[i]); + } + for (float value : outputs[17]) assertEquals(42f, value); + } + } + + @Test + void halfStepsUseNearestEvenForQueriesAndWeights() { + float[] ties = {.5f, 1.5f, 2.5f, 3.5f, -.5f, -1.5f, -2.5f, -3.5f}; + float[] expected = {0, 2, 2, 4, 0, -2, -2, -4}; + float[][] weights = new float[32][64]; + float[] query = new float[64]; + query[63] = 127f; // Exact unit scale for both quantization paths. + for (int row = 0; row < ties.length; row++) { + weights[row][row] = 127f; + query[row] = ties[row]; + weights[row + 8][row] = ties[row]; + weights[row + 8][62] = 127f; + } + var projection = prepare(weights); + float[] output = new float[32]; + projection.project(query, output); + for (int row = 0; row < ties.length; row++) { + assertEquals(127f * expected[row], output[row]); + assertEquals(expected[row] * expected[row], output[row + 8]); + } + assertArrayEquals(oracle(weights, query), output); + } + + @Test + void fullRangeProductsDoNotSaturateThroughAccumulatorDimensionBound() { + for (int columns : new int[]{768, 65535}) { + float[][] matrix = new float[32][columns]; + float[] query = new float[columns]; + Arrays.fill(query, 1f); + for (int row = 0; row < matrix.length; row++) Arrays.fill(matrix[row], row % 2 == 0 ? 1f : -1f); + var projection = prepare(matrix); + float[] output = new float[32]; + projection.project(query, output); + assertArrayEquals(oracle(matrix, query), output); + assertEquals(columns, output[0], .02f); + assertEquals(-columns, output[1], .02f); + } + assertThrows(IllegalArgumentException.class, () -> prepare(new float[32][65536])); + } + + @Test + void fp32ScalesMatchPrototypeAndUnderflowFailsBeforeConversion() { + for (float weight : new float[]{0, Float.MIN_NORMAL, 1e-38f, 1f}) { + float[][] weights = new float[32][64]; + for (float[] row : weights) Arrays.fill(row, weight); + var projection = prepare(weights); + for (float value : new float[]{0, Float.MIN_NORMAL, .25f}) { + float[] query = new float[64], output = new float[32]; + Arrays.fill(query, value); + projection.project(query, output); + assertArrayEquals(oracle(weights, query), output); + } + } + float[][] tinyWeights = new float[32][64]; + for (float[] row : tinyWeights) Arrays.fill(row, Float.MIN_VALUE); + assertThrows(IllegalArgumentException.class, () -> prepare(tinyWeights)); + var projection = prepare(matrix(32,64,100)); + float[] query = new float[64], output = new float[32]; + Arrays.fill(query, Float.MIN_VALUE); + Arrays.fill(output, 42f); + assertThrows(IllegalArgumentException.class, () -> projection.project(query, output)); + for (float value : output) assertEquals(42f, value); + } + + @Test + void invalidInputDoesNotPublishPartialBatchResults() { + var projection = prepare(matrix(32, 64, 1)); + float[][] inputs = matrix(9, 64, 2), outputs = new float[9][32]; + for (float[] row : outputs) Arrays.fill(row, 42); + assertThrows(IllegalArgumentException.class, () -> projection.projectBatch(inputs, outputs, -1)); + assertThrows(IllegalArgumentException.class, () -> projection.projectBatch(inputs, outputs, 10)); + assertThrows(IllegalArgumentException.class, () -> projection.projectBatch(inputs, inputs, 2)); + assertThrows(IllegalArgumentException.class, () -> projection.projectBatch(inputs, + new float[][]{outputs[0], outputs[0]}, 2)); + for (float invalid : new float[]{Float.NaN, Float.POSITIVE_INFINITY, Float.NEGATIVE_INFINITY}) { + inputs[8][0] = invalid; + assertThrows(IllegalArgumentException.class, () -> projection.projectBatch(inputs, outputs, 9)); + for (float[] row : outputs) for (float value : row) assertEquals(42f, value); + } + assertThrows(NullPointerException.class, () -> projection.project(null, new float[32])); + assertThrows(NullPointerException.class, () -> projection.project(new float[64], null)); + assertThrows(IllegalArgumentException.class, () -> projection.project(new float[1], new float[32])); + assertThrows(IllegalArgumentException.class, () -> projection.project(new float[64], new float[1])); + float[][] invalidMatrix = matrix(32, 64, 3); + invalidMatrix[0][0] = Float.NaN; + assertThrows(IllegalArgumentException.class, () -> prepare(invalidMatrix)); + } + + @Test + void fp32ResultOverflowMatchesPrototype() { + float[][] weights = new float[32][64]; + for (int row = 0; row < weights.length; row++) + Arrays.fill(weights[row], row % 2 == 0 ? 1f : -1f); + var projection = prepare(weights); + float[] input = new float[64], output = new float[32]; + Arrays.fill(input, Float.MAX_VALUE); + projection.project(input, output); + assertArrayEquals(oracle(weights, input), output); + assertEquals(Float.POSITIVE_INFINITY, output[0]); + assertEquals(Float.NEGATIVE_INFINITY, output[1]); + } + + @Test + void concurrentFirstUseAndAlternatingModelsAndShapes() throws Exception { + float[][][] weights = {matrix(33,65,3), matrix(64,128,4)}; + VectorUtilSupport.AshProjection[] projections = {prepare(weights[0]), prepare(weights[1])}; + try (var pool = Executors.newFixedThreadPool(16)) { + var work = new ArrayList>(); + for (int thread = 0; thread < 16; thread++) { + final int seed = thread; + work.add(() -> { + for (int trial = 0; trial < 16; trial++) { + int model = (trial + seed) % 2, count = 1 + (trial + seed) % 17; + float[][] inputs = matrix(count, weights[model][0].length, seed * 100L + trial); + float[][] outputs = new float[count][weights[model].length]; + projections[model].projectBatch(inputs, outputs, count); + for (int i = 0; i < count; i++) + assertArrayEquals(oracle(weights[model], inputs[i]), outputs[i], 1e-7f); + } + return null; + }); + } + for (var task : pool.invokeAll(work)) task.get(); + } + } + + @Test + void packedMemoryAccountingStabilizesAfterAllBatchShapes() { + var projection = prepare(matrix(64,128,5)); + long previous = projection.ramBytesUsed(); + for (int count = 1; count <= 8; count++) { + projection.projectBatch(matrix(count,128,count), new float[count][64], count); + assertTrue(projection.ramBytesUsed() >= previous); + previous = projection.ramBytesUsed(); + } + long prepared = projection.ramBytesUsed(); + for (int count = 8; count >= 1; count--) + projection.projectBatch(matrix(count,128,count), new float[count][64], count); + assertEquals(prepared, projection.ramBytesUsed()); + } + + @Test + void precisionSelectionPreservesOriginalFp32AndAutomaticPolicy() { + assertTrue(LibraryLoader.loadJvector()); + prepare(matrix(32,64,6)); // Require/qualify this backend before testing its selection. + var original = new NativeASHProjection(matrix(32,64,6)); + String saved = System.getProperty(PROPERTY); + try { + float[] query = matrix(1,64,7)[0], before = new float[32], after = new float[32]; + original.project(query, before); + System.setProperty(PROPERTY, "int8"); + assertInstanceOf(NativeASHInt8Projection.class, original.forQueryScoring()); + original.project(query, after); + assertArrayEquals(before, after); + System.setProperty(PROPERTY, "fp32"); + assertSame(original, original.forQueryScoring()); + System.setProperty(PROPERTY, "auto"); + assertFalse(original.forQueryScoring() instanceof NativeASHInt8Projection); + } finally { + if (saved == null) System.clearProperty(PROPERTY); else System.setProperty(PROPERTY, saved); + } + } + + @Test + void modelCachesQueryViewAndKeepsEncodingSeparate() { + prepare(matrix(32,64,8)); + double[][] transform = new double[64][32]; + float[][] weights = matrix(32,64,9); + for (int row = 0; row < 32; row++) + for (int column = 0; column < 64; column++) transform[column][row] = weights[row][column]; + String saved = System.getProperty(PROPERTY); + try { + System.setProperty(PROPERTY,"int8"); + var model = new io.github.jbellis.jvector.quantization.AsymmetricHashing.StiefelTransform( + new org.apache.commons.math3.linear.Array2DRowRealMatrix(transform)); + float[] query = matrix(1,64,10)[0], encoding = new float[32], projected = new float[32]; + model.project(query,encoding); + model.projectQueryDirect(query,projected); + assertArrayEquals(oracle(model.AFloat,query),projected,1e-7f); + System.setProperty(PROPERTY,"fp32"); + float[] cached = new float[32], encodedAgain = new float[32]; + model.projectQueryDirect(query,cached); + model.project(query,encodedAgain); + assertArrayEquals(projected,cached); + assertArrayEquals(encoding,encodedAgain); + } finally { + if (saved == null) System.clearProperty(PROPERTY); else System.setProperty(PROPERTY,saved); + } + } + + @Test + void abiVersionAndMissingSymbolsAreRejectedBeforeCalls() throws Exception { + Class type = Class.forName(NativeASHInt8Projection.class.getName() + "$Bindings"); + var constructor = type.getDeclaredConstructor(SymbolLookup.class); + constructor.setAccessible(true); + try (var arena = Arena.ofConfined()) { + for (int version : new int[]{0,1}) { + MemorySegment abi = Linker.nativeLinker().upcallStub(MethodHandles.constant(int.class, version), + FunctionDescriptor.of(JAVA_INT), arena); + SymbolLookup lookup = name -> name.equals("jvector_ash_fp16_abi") ? Optional.of(abi) : Optional.empty(); + var failure = assertThrows(InvocationTargetException.class, () -> constructor.newInstance(lookup)); + assertInstanceOf(UnsatisfiedLinkError.class, failure.getCause()); + } + } + } + + @Test + void nativeStatusesMapToTheirDocumentedJavaFailures() throws Exception { + var failure = NativeASHInt8Projection.class.getDeclaredMethod("failure", int.class, MemorySegment.class); + failure.setAccessible(true); + try (var arena = Arena.ofConfined()) { + var status = arena.allocate(260,4); + status.setString(4,"fixture"); + assertInstanceOf(UnsupportedOperationException.class, failure.invoke(null,1,status)); + assertInstanceOf(IllegalArgumentException.class, failure.invoke(null,2,status)); + assertInstanceOf(IllegalArgumentException.class, failure.invoke(null,3,status)); + assertInstanceOf(IllegalStateException.class, failure.invoke(null,5,status)); + var error = assertThrows(InvocationTargetException.class, () -> failure.invoke(null,4,status)); + assertInstanceOf(OutOfMemoryError.class, error.getCause()); + } + } + + @Test + void legacyIsaCannotSilentlyRunSaturatingFallback(@TempDir Path temporary) throws Exception { + String classpath = System.getProperty("surefire.test.class.path", System.getProperty("java.class.path")); + Path log = temporary.resolve("legacy-isa.log"); + var builder = new ProcessBuilder(Path.of(System.getProperty("java.home"),"bin/java").toString(), + "--add-modules=jdk.incubator.vector", "--enable-native-access=ALL-UNNAMED", + "-Djvector.test.requireInt8=true", "-cp",classpath, LegacyIsaProbe.class.getName()); + builder.environment().put("ONEDNN_MAX_CPU_ISA","AVX2"); + builder.environment().put("DNNL_MAX_CPU_ISA","AVX2"); + Process child = builder.redirectErrorStream(true).redirectOutput(log.toFile()).start(); + try { + assertTrue(child.waitFor(30,TimeUnit.SECONDS)); + assertEquals(0,child.exitValue(),Files.readString(log)); + } finally { + if (child.isAlive()) { child.destroyForcibly(); child.waitFor(5,TimeUnit.SECONDS); } + } + } + + public static final class LegacyIsaProbe { + public static void main(String[] args) { + assertTrue(LibraryLoader.loadJvector()); + try { + prepare(new float[32][64]); + throw new AssertionError("INT8 silently accepted legacy ISA"); + } catch (UnsupportedOperationException expected) { /* required mode fails clearly */ } + } + } +} From 9e7f6393492d8130db628f495a2552e11d1c970f Mon Sep 17 00:00:00 2001 From: Ted Willke Date: Fri, 6 Nov 2026 01:50:19 +0000 Subject: [PATCH 153/243] Default ASH construction to symmetric-all and document configuration --- README.md | 3 + docs/ash-configuration.md | 175 ++++++++++++++++++ docs/ash-pq-benchmark.md | 6 + docs/ash-projection-precision.md | 17 +- docs/ash-query-integration.md | 2 + .../github/jbellis/jvector/example/Grid.java | 3 +- .../diagnostics/ASHBuildScoreExperiment.java | 2 +- .../example/TestGridASHConstructionMode.java | 113 +++++++++++ 8 files changed, 312 insertions(+), 9 deletions(-) create mode 100644 docs/ash-configuration.md create mode 100644 jvector-tests/src/test/java/io/github/jbellis/jvector/example/TestGridASHConstructionMode.java diff --git a/README.md b/README.md index 745da9dce..2c04f433e 100644 --- a/README.md +++ b/README.md @@ -50,6 +50,9 @@ For writing completed graphs with FP or NVQ features, see [Deferred batch serial For database integration with ASH, see [ASH query callers and CPU budgeting](./docs/ash-query-integration.md), covering caller concurrency, bounded admission, state ownership and latency validation. +For ASH model parameters, construction modes, precision, JVM flags and their defaults, +see [ASH configuration and defaults](./docs/ash-configuration.md). + The older step-by-step guide for JV can be found [here](./docs/legacy/jvector-step-by-step.md). New users should start with the tutorials mentioned earler, but the step-by-step guide contains useful commentary for advanced users. diff --git a/docs/ash-configuration.md b/docs/ash-configuration.md new file mode 100644 index 000000000..d1163f914 --- /dev/null +++ b/docs/ash-configuration.md @@ -0,0 +1,175 @@ + + +# ASH configuration and defaults + +ASH has independent settings for stored codes, construction, query projection, +and query execution. This reference covers library parameters and BenchYAML +controls. A recipe supplies its own graph/compression parameters; its chosen +numbers are not universal library defaults. + +Set JVM properties before the affected model, provider or executor is initialized. +Query views and pools are cached; changing a property later does not reconfigure +existing objects. CPU-count properties do not pin threads. + +## Stored codes and training + +BenchYAML puts these under `compression[].parameters` with `type: ASH`. +Construction and search compression occupy separate YAML sections. A fused ASH +graph reuses its stored codes for search, without a second search encoding. + +| Parameter | Default / requirement | Optional values and definition | +| --- | --- | --- | +| `optimizer` | Required | `ITQ` (`1`): iterative-quantization transform. `RANDOM` (`2`): random transform. | +| `encodedBits` | Exactly one of this and `compressionRatio` | Total stored bits including the 40-bit / five-byte header. The positive payload `encodedBits - 40` must be divisible by `bitsPerDimension`. | +| `compressionRatio` | Alternative to `encodedBits` | BenchYAML accepts `8`, `16`, `32`, `64`, `128`, `256`. Resolves a payload near the requested FP32-to-code ratio, with a minimum 64-bit payload and whole projected dimensions; inspect the printed resolved size. | +| `bitsPerDimension` (`b`) | `1` | Integer `1` through `9` in core ASH. Fused ASH supports `1`, `2`, `4`; optimized multibit formats use `2` or `4`. Stored-code precision is independent of query-projection arithmetic. | +| `landmarkCount` (`C`) | `1` | Integer `1` through `256` for the quantizer. Symmetric graph-construction scoring currently requires `C=1` and dot-product similarity. | +| Projected dimension (`d`) | Derived, not a separate YAML field | `d = (encodedBits - 40) / b`, with `1 <= d <= D` for input dimension `D`. Increasing `d` or `b` increases code size. | + +For an exact payload, use `encodedBits = 40 + d*b`. For example, a 256-D +input projected to 128 dimensions at four bits uses `encodedBits: 552`. +Include the header when comparing memory budgets with other quantizers. + +Training and base encoding retain their FP32 projection path; some training +statistics and factorizations use FP64 internally. Query precision does not +change the trained model, base encoding, stored-code layout or index format. + +## Graph construction + +BenchYAML defaults to **`symmetric-all`**. To restore the earlier mixed policy: + +```text +-Djvector.bench.ashConstruction=symmetric +``` + +| Property value | Insertion search | Existing-node / diversity scoring | +| --- | --- | --- | +| `symmetric-all` **(default)** | Encoded vector against encoded vectors; no dense insertion-query projection | Symmetric encoded scoring | +| `symmetric` | Original vector used as an asymmetric query | Symmetric encoded scoring | +| `symmetric-scalar` | Original vector used as an asymmetric query | Scalar symmetric scoring; diagnostic control | +| `asymmetric` | Original vector used as an asymmetric query | Original vector used as an asymmetric query; diagnostic control | +| `asymmetric-cached` | Original vector used as an asymmetric query | Cached asymmetric query state per node; diagnostic control with extra memory use | + +`jvector.bench.ashConstruction` controls **BenchYAML/Grid**, not every library +builder. For library integration, obtain a provider using +`BuildScoreProvider.ashBuildScoreProvider(similarity, encoded)`. Use +`builder.addGraphNode(node, provider.searchProviderFor(node))` for fully symmetric +insertion. The overload taking an original vector retains asymmetric query +preparation. Both policies use the same stored ASH vectors; application queries +continue to use asymmetric scoring. + +Construction policy can change the graph, recall and QPS. Use fresh indexes +when comparing modes: the graph cache key does not include this JVM property, +so changing it does not invalidate an existing cached graph. + +## Query projection and scoring + +| JVM property | Default | Optional values and definition | +| --- | --- | --- | +| `jvector.ash.queryProjection` | `auto` | `auto`: prefer optimized FP16, falling back to FP32. `fp32`: FP32. `fp16`: require optimized FP16 with FP32 accumulation/output. `int8`: require INT8 projection with integer accumulation and FP32 output. Explicit unsupported precisions fail; `auto` never selects INT8. | +| `jvector.ash.singleKernel` | `auto` | `auto`, `scalar`, `simd`; single-vector ASH scoring kernels, subject to provider/format support. | +| `jvector.ash.blockKernel` | `auto` | `auto`, `scalar`, `simd`; blocked/fused ASH scoring kernels, subject to provider/format support. | +| `jvector.ash.queryBatchSize` | `8` | Positive integer below `Integer.MAX_VALUE`; maximum opportunistic batch for model-owned synchronous query preparation. Separate from the queued batch limit below. | + +See [Choosing ASH query-projection precision](ash-projection-precision.md) for +processor guidance and FP16/INT8 requirements. Under `symmetric-all`, query +precision affects application queries, not insertion search. Under mixed or +asymmetric construction, it can affect insertion too. + +## Providers, CPU budgets and serialization + +| Setting | Default | Optional values and definition | +| --- | --- | --- | +| `jvector.vectorization_provider` | Automatic selection | `native`, `panama`, `default` (scalar). Supported Java 22+ runtimes prefer native, with existing fallbacks. Explicit `native` requires successful loading. | +| `jvector.enable_native_vectorization` | `true` | `true` / `false`; automatic native selection. Explicit provider selection overrides it. Deprecated `jvector.experimental.enable_native_vectorization` is a lower-precedence alias. | +| `jvector.physical_core_count` | OS topology detection bounded by available processors; fallback heuristic | Positive physical-core budget for JVector's bounded pool and queued workers. Construction and queries can use separate budgets/processes. | +| `-XX:ActiveProcessorCount` | JVM/container detection | Positive processor count exposed to the JVM; coordinate with allocated physical cores. Does not set affinity. | +| `jvector.parallel_writer` | `true` in BenchYAML | `true` / `false`; parallel graph serialization, with deferred feature production. Separate from an application's `GraphIndexBuilderConfig` selection. | +| `--add-modules=jdk.incubator.vector` | Not added by the application | Enables the Vector API module needed by SIMD providers on supported runtimes. | +| `--enable-native-access=ALL-UNNAMED` | JVM native-access policy | Native access for classpath deployments. Named-module deployments should enable the relevant module. | +| `-Xms`, `-Xmx` | JVM ergonomics | Initial / maximum heap; account for resident input, model and construction memory. | +| `-ea`, `-da` | Assertions normally disabled | Enable / disable Java assertions; independent of numerical precision. | + +`MKL_NUM_THREADS`, `OMP_NUM_THREADS`, `OPENBLAS_NUM_THREADS` and +`GOTO_NUM_THREADS` are native-runtime environment controls, not ASH model +parameters. Avoid nested native query pools. The standalone launcher sets them +to one for queries and the core budget for construction; it also sets +`MKL_DYNAMIC=FALSE` and `ONEDNN_VERBOSE=0`. OS affinity +(`numactl --physcpubind`) and memory placement (`--membind`, `--interleave`) +are deployment choices. + +## Graph and search parameters in BenchYAML + +Index YAML supplies these grids explicitly. Recipe values are choices, not +omitted-field library defaults. + +| YAML field | Values / definition | +| --- | --- | +| `construction.outDegree` | Positive graph degree grid (`M`); adjacency capacity and graph size. | +| `construction.efConstruction` | Positive construction search-width grid; work spent finding neighbors. | +| `construction.neighborOverflow` | Neighbor-list overflow factor before pruning. | +| `construction.addHierarchy` | `Yes` / `No`; upper graph layers. | +| `construction.refineFinalGraph` | `Yes` / `No`; final graph refinement. | +| `construction.fusedGraph` | `Yes` / `No`; store search codes with graph neighborhoods. | +| `construction.compression` | Build quantizer definitions, including ASH parameters above. | +| `construction.reranking` | `NVQ` or `FP`; accurate reranking / inline vector representation. | +| `construction.useSavedIndexIfExists` | `Yes` / `No`; reuse matching cached graph. Change cache directories when changing construction policy. | +| `search.topKOverquery` | Final `topK` mapped to rerank multipliers; `10: [1.0, 2.0]` requests 10 or 20 candidates before returning 10 results. | +| `search.useSearchPruning` | `Yes` / `No`; traversal pruning. | +| `search.compression` | Search quantizer definitions for non-fused indexes. Fused ASH reuses graph codes. | +| `yamlSchemaVersion`, `onDiskIndexVersion` | Explicit configuration schema and index format; match the supported loader/writer. | + +## Query delivery and measurement controls + +These controls belong to BenchYAML. A database integration owns its admission +policy and executors; see [ASH query callers and CPU budgeting](ash-query-integration.md). + +| Setting | Default | Optional values / definition | +| --- | --- | --- | +| Run YAML `queryExecution.mode` | `direct` | `direct`: synchronous callers. `queued`: bounded, opportunistically batched preparation and independent graph searches. | +| Run YAML `queryExecution.maxBatchSize` | `8` | Positive queued batch limit. With `C` workers, admission is `max(8, maxBatchSize) * C`. Eight gives admission `8C`, without a batching timer. | +| `jvector.bench.queryThreads` | `0` | Direct caller count: `0` uses common pool, `1` is serial, larger positive values use a dedicated caller pool. Does not set queued worker count. | +| `jvector.bench.verifyConcurrentResults` | `false` | `true` / `false`; validate concurrent results against reference results. | +| `jvector.bench.throughputMinSeconds` | `1.0` | Minimum throughput sample duration in seconds. | +| `jvector.bench.runConfig` | `jvector-examples/yaml-configs/run-config.yml` | Run YAML path: execution, measured benchmarks, console and CSV fields. | +| `jvector.bench.sourceRef` | Unset | Source identity for provenance; no numerical effect. | + +Run YAML `benchmarks`, `console` and `logging` select computed/reported throughput, +recall, latency and visit counts. Construction separately reports quantizer +training, encoding, NVQ training and graph/write. Queued execution can also +report loaded response times and mean batch size. Unloaded latency and loaded +response time describe different measurements. Independent build/query JVM +repetitions are launch choices, not model defaults; preserve individual CSVs. + +The standalone launcher accepts `JVECTOR_BENCH_DATA_DIR` (required local input +directory), `JVECTOR_BENCH_CATALOG` (`catalog-1m.yml`; optionally +`catalog-large.yml`), `JVECTOR_BENCH_HEAP` (`128g`), and +`JVECTOR_BENCH_NUMA_POLICY` (`bind:0`; alternatively `interleave:`). +These defaults belong to the launcher, not the library. See the +[BenchYAML guide](ash-pq-benchmark.md) for launch and artifact details. + +## Diagnostics + +`jvector.ash.debugSymmetric` defaults to `false`. Its optional construction +check is bounded by `jvector.ash.debugSymmetric.pairs` (`2000`), +`jvector.ash.debugSymmetric.subsetN` (`50000`) and +`jvector.ash.debugSymmetric.topK` (`100`). Keep diagnostics off for normal timing. + +Implementation references: [YAML compression parsing](../jvector-examples/src/main/java/io/github/jbellis/jvector/example/yaml/Compression.java), +[ASH model](../jvector-base/src/main/java/io/github/jbellis/jvector/quantization/AsymmetricHashing.java), +[construction modes](../jvector-examples/src/main/java/io/github/jbellis/jvector/example/diagnostics/ASHBuildScoreExperiment.java), +and [run configuration](../jvector-examples/src/main/java/io/github/jbellis/jvector/example/yaml/RunConfig.java). diff --git a/docs/ash-pq-benchmark.md b/docs/ash-pq-benchmark.md index ac7f4fd78..f3e2c7a06 100644 --- a/docs/ash-pq-benchmark.md +++ b/docs/ash-pq-benchmark.md @@ -16,6 +16,12 @@ limitations under the License. # Reproducing fused PQ versus fused ASH with BenchYAML +BenchYAML's default ASH construction mode is `symmetric-all`. The JVM property +`-Djvector.bench.ashConstruction=symmetric` restores the earlier mixed policy. +Use fresh graph caches when changing construction mode. See the general +[ASH configuration and defaults guide](ash-configuration.md) for parameters, +supported values, scope and defaults. + This comparison uses the branch's ordinary packaged JVector code and `io.github.jbellis.jvector.example.BenchYAML`. The fixed index configurations, run configurations and local dataset catalog are in diff --git a/docs/ash-projection-precision.md b/docs/ash-projection-precision.md index 18d405df1..7f53b4d52 100644 --- a/docs/ash-projection-precision.md +++ b/docs/ash-projection-precision.md @@ -63,13 +63,16 @@ after initialization does not reconfigure an existing view. ## Effect on graph construction -Default graph construction projects an inserted base vector when it searches -for neighbors. The query-projection setting can therefore affect construction -as well as application queries. - -BenchYAML's `-Djvector.bench.ashConstruction=symmetric-all` uses encoded symmetric -scoring for insertion and diversity. It does not use dense query projection -for insertion. +BenchYAML defaults to `symmetric-all` construction: insertion and diversity use +encoded symmetric scoring, without dense query projection for insertion. Query +precision therefore affects application queries, not insertion under this default. + +Use `-Djvector.bench.ashConstruction=symmetric` to restore mixed construction: +insertion projects the original vector as an asymmetric query, while diversity +uses symmetric scoring. Query precision can then affect construction too. +Library callers explicitly choose an encoded node score provider or an +original-vector insertion overload; the BenchYAML flag does not globally +reconfigure library builders. See [ASH configuration and defaults](ash-configuration.md#graph-construction). Neither construction mode changes training or base-encoding precision. To measure query-projection effects independently, query the same saved graph diff --git a/docs/ash-query-integration.md b/docs/ash-query-integration.md index a69a9d247..12628906c 100644 --- a/docs/ash-query-integration.md +++ b/docs/ash-query-integration.md @@ -28,6 +28,8 @@ ordering, FP32 encoding/training, or the existing automatic FP16/FP32 precision See [Choosing ASH query-projection precision](ash-projection-precision.md) for processor-family recommendations and projection settings. +The [ASH configuration reference](ash-configuration.md) lists model parameters, +construction modes, runtime flags and their defaults. ## Recommended asynchronous allocation diff --git a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/Grid.java b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/Grid.java index 59ff4d0af..a36a074ae 100644 --- a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/Grid.java +++ b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/Grid.java @@ -474,7 +474,8 @@ private static BuildOnDiskResult buildOnDisk(List> feat } System.out.printf("Build score provider setup: %.6f seconds%n", (System.nanoTime()-providerStart)/1e9); - final boolean allSymmetric = ashVectors != null && "symmetric-all".equals(System.getProperty("jvector.bench.ashConstruction")); + final boolean allSymmetric = ashVectors != null && "symmetric-all".equals( + System.getProperty("jvector.bench.ashConstruction", "symmetric-all")); final PQVectors pqFinal = pq; final ASHVectors ashVectorsFinal = ashVectors; diff --git a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/diagnostics/ASHBuildScoreExperiment.java b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/diagnostics/ASHBuildScoreExperiment.java index 754b7e23a..bea094df1 100644 --- a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/diagnostics/ASHBuildScoreExperiment.java +++ b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/diagnostics/ASHBuildScoreExperiment.java @@ -29,7 +29,7 @@ public final class ASHBuildScoreExperiment { private ASHBuildScoreExperiment() {} public static BuildScoreProvider create(VectorSimilarityFunction vsf, ASHVectors encoded, RandomAccessVectorValues raw) { - String mode=System.getProperty("jvector.bench.ashConstruction","symmetric"); + String mode=System.getProperty("jvector.bench.ashConstruction","symmetric-all"); System.out.println("ASH construction scoring mode: " + mode); if (mode.equals("symmetric") || mode.equals("symmetric-all")) { return BuildScoreProvider.ashBuildScoreProvider(vsf,encoded); diff --git a/jvector-tests/src/test/java/io/github/jbellis/jvector/example/TestGridASHConstructionMode.java b/jvector-tests/src/test/java/io/github/jbellis/jvector/example/TestGridASHConstructionMode.java new file mode 100644 index 000000000..0847835fb --- /dev/null +++ b/jvector-tests/src/test/java/io/github/jbellis/jvector/example/TestGridASHConstructionMode.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.example; + +import io.github.jbellis.jvector.TestUtil; +import io.github.jbellis.jvector.example.benchmarks.datasets.DataSet; +import io.github.jbellis.jvector.example.reporting.RunArtifacts; +import io.github.jbellis.jvector.example.util.CompressorParameters; +import io.github.jbellis.jvector.example.util.OnDiskGraphIndexCache; +import io.github.jbellis.jvector.graph.ListRandomAccessVectorValues; +import io.github.jbellis.jvector.graph.RandomAccessVectorValues; +import io.github.jbellis.jvector.graph.disk.feature.FeatureId; +import io.github.jbellis.jvector.quantization.AsymmetricHashing; +import io.github.jbellis.jvector.util.PhysicalCoreExecutor; +import io.github.jbellis.jvector.vector.VectorSimilarityFunction; +import io.github.jbellis.jvector.vector.VectorUtil; +import io.github.jbellis.jvector.vector.VectorizationProvider; +import io.github.jbellis.jvector.vector.types.VectorFloat; +import org.junit.Assume; +import org.junit.Test; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.EnumSet; +import java.util.List; +import java.util.Map; +import java.util.Random; +import java.util.UUID; +import java.util.stream.Collectors; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertFalse; + +/** Exercises Grid's insertion policy, not just the printed construction-mode label. */ +public class TestGridASHConstructionMode { + private static final String PROPERTY = "jvector.bench.ashConstruction"; + + @Test(timeout = 120_000) + public void defaultBuildMatchesExplicitSymmetricAllAndLegacyOverrideStillWorks() throws Exception { + // Sequential insertion makes the serialized graph a deterministic comparison. + Assume.assumeTrue("run with -Djvector.physical_core_count=1", + PhysicalCoreExecutor.getPhysicalCoreCount() == 1); + var vts = VectorizationProvider.getInstance().getVectorTypeSupport(); + var random = new Random(42); + var list = new ArrayList>(); + for (int n = 0; n < 256; n++) { + var vector = vts.createFloatVector(32); + for (int d = 0; d < 32; d++) vector.set(d, random.nextFloat() - 0.4f); + VectorUtil.l2normalize(vector); + list.add(vector); + } + var vectors = new ListRandomAccessVectorValues(list, 32); + String name = "ash-construction-mode-" + UUID.randomUUID(); + DataSet ds = new DataSet() { + public int getDimension() { return 32; } + public String getName() { return name; } + public VectorSimilarityFunction getSimilarityFunction() { return VectorSimilarityFunction.DOT_PRODUCT; } + public RandomAccessVectorValues getBaseRavv() { return vectors; } + public List> getBaseVectors() { return list; } + public List> getQueryVectors() { return List.of(list.get(0)); } + public List> getGroundTruth() { return List.of(List.of(0)); } + }; + var params = new CompressorParameters.ASHParameters(AsymmetricHashing.RANDOM, + AsymmetricHashing.HEADER_BITS + 16 * 2, 1, 2); + String previous = System.getProperty(PROPERTY); + Path work = Files.createTempDirectory("ash-construction-mode"); + try { + byte[] defaultGraph = build(ds, params, work.resolve("default"), null); + byte[] explicitGraph = build(ds, params, work.resolve("explicit"), "symmetric-all"); + assertArrayEquals("unset property must use encoded queries for insertion", explicitGraph, defaultGraph); + byte[] legacyGraph = build(ds, params, work.resolve("legacy"), "symmetric"); + assertFalse("the legacy override must still select raw-query insertion", + Arrays.equals(defaultGraph, legacyGraph)); + } finally { + if (previous == null) System.clearProperty(PROPERTY); + else System.setProperty(PROPERTY, previous); + TestUtil.deleteQuietly(work); + Files.deleteIfExists(Path.of("compressor_cache").resolve(params.idStringFor(ds))); + } + } + + private static byte[] build(DataSet ds, CompressorParameters params, Path work, String mode) throws Exception { + if (mode == null) System.clearProperty(PROPERTY); + else System.setProperty(PROPERTY, mode); + Files.createDirectories(work); + Path saved = work.resolve("index_cache"); + Grid.runOneGraph(OnDiskGraphIndexCache.initialize(saved), + List.of(EnumSet.of(FeatureId.NVQ_VECTORS, FeatureId.FUSED_ASH)), + 8, 32, 1.2f, false, false, ignored -> params, List.of(), + Map.of(10, List.of(1.0)), List.of(false), RunArtifacts.disabled(), ds, work); + try (var files = Files.list(saved)) { + var indexes = files.filter(p -> p.getFileName().toString().startsWith("graph_")) + .collect(Collectors.toList()); + if (indexes.size() != 1) throw new AssertionError("Expected one freshly built index: " + indexes); + return Files.readAllBytes(indexes.get(0)); + } + } +} From 2e683e646ee507e806a751f3ed32bcac7768880f Mon Sep 17 00:00:00 2001 From: Ted Willke Date: Fri, 6 Nov 2026 02:08:55 +0000 Subject: [PATCH 154/243] Expose ASH projection precision in BenchYAML run YAML Add optional ash.queryProjection with existing JVM-property precedence and auto defaults. Apply YAML selection only for the invocation and restore it on exit. Append requested/effective precision columns to ASH CSV rows using the actual prepared projection, including observed automatic FP32 range fallback. Leave existing CSV columns, escaping/writer, YAML loading, scoring/timing, native math and compiler settings unchanged. Update precision, configuration, integration and benchmark guides and add commented run-file examples. Preserve the symmetric-all default from 4bff1e5b. Validate focused configuration/reporting and precision tests on JDK22/25, including symmetric-all default tests; standard BenchYAML smoke checks cover omission, FP32/INT8 YAML, JVM override and expected unsupported-FP16 failure. Surgical source audit confirms no incidental parser/writer/scoring changes. --- docs/ash-configuration.md | 17 ++++ docs/ash-pq-benchmark.md | 4 + docs/ash-projection-precision.md | 26 +++++ docs/ash-query-integration.md | 3 + .../quantization/AsymmetricHashing.java | 6 ++ .../jvector/vector/VectorUtilSupport.java | 3 + .../jbellis/jvector/example/BenchYAML.java | 7 ++ .../github/jbellis/jvector/example/Grid.java | 10 ++ .../reporting/ExperimentsSchemaV1.java | 4 +- .../example/reporting/RunArtifacts.java | 9 ++ .../jvector/example/yaml/RunConfig.java | 39 ++++++++ .../benchmarks/AshProjectionConfigTest.java | 95 +++++++++++++++++++ .../AshProjectionReportingTest.java | 78 +++++++++++++++ .../yaml-configs/ash-pq/run-queued.yml | 3 + jvector-examples/yaml-configs/run-config.yml | 3 + .../vector/NativeASHFP16Projection.java | 6 ++ .../vector/NativeASHInt8Projection.java | 3 + .../vector/NativeASHFP16ProjectionTest.java | 2 + .../vector/NativeASHInt8ProjectionTest.java | 4 +- 19 files changed, 320 insertions(+), 2 deletions(-) create mode 100644 jvector-examples/src/test/java/io/github/jbellis/jvector/example/benchmarks/AshProjectionConfigTest.java create mode 100644 jvector-examples/src/test/java/io/github/jbellis/jvector/example/benchmarks/AshProjectionReportingTest.java diff --git a/docs/ash-configuration.md b/docs/ash-configuration.md index d1163f914..ac9f0a8a6 100644 --- a/docs/ash-configuration.md +++ b/docs/ash-configuration.md @@ -90,6 +90,23 @@ processor guidance and FP16/INT8 requirements. Under `symmetric-all`, query precision affects application queries, not insertion search. Under mixed or asymmetric construction, it can affect insertion too. +### BenchYAML precision selection + +The run YAML can select the same precision without a JVM option: + +```yaml +ash: + queryProjection: int8 # auto, fp16, fp32, or int8 +``` + +An explicit `-Djvector.ash.queryProjection=...` overrides YAML. Omitting the +setting preserves the existing JVM setting or `auto` default. Use one precision +per BenchYAML JVM. ASH CSV rows record `ash_query_projection_requested` and +`ash_query_projection_effective`; the latter is read from the prepared view, +with `fp16+fp32` indicating observed automatic FP32 range fallback. Non-ASH +rows have blank precision columns; an unused projection has blank effective +precision. See the [precision guide](ash-projection-precision.md#benchyaml-runs). + ## Providers, CPU budgets and serialization | Setting | Default | Optional values and definition | diff --git a/docs/ash-pq-benchmark.md b/docs/ash-pq-benchmark.md index f3e2c7a06..124854b40 100644 --- a/docs/ash-pq-benchmark.md +++ b/docs/ash-pq-benchmark.md @@ -33,6 +33,10 @@ Projection settings and processor recommendations are documented in [Choosing ASH query-projection precision](ash-projection-precision.md). An explicitly requested INT8/FP32 comparison is a precision override to the FP16 benchmark configuration below; record it with the command and CSV evidence. +BenchYAML also accepts `ash.queryProjection` (`auto`, `fp16`, `fp32`, `int8`) in +its run YAML. An explicit JVM precision property overrides YAML; omission keeps +the existing defaults. ASH CSV rows record `ash_query_projection_requested` and +`ash_query_projection_effective`; see the precision guide for fallback labels. ## Build the checkout diff --git a/docs/ash-projection-precision.md b/docs/ash-projection-precision.md index 7f53b4d52..6e654840b 100644 --- a/docs/ash-projection-precision.md +++ b/docs/ash-projection-precision.md @@ -61,6 +61,32 @@ when a virtual machine does not expose the needed hardware features. The model caches its query-projection view on first use. Changing the property after initialization does not reconfigure an existing view. +## BenchYAML runs + +Select precision in the **run configuration**, independently of the index YAML: + +```yaml +ash: + queryProjection: int8 # auto, fp16, fp32, or int8 +``` + +Select that run file with `-Djvector.bench.runConfig=/path/to/run.yml`. +An explicit `-Djvector.ash.queryProjection=...` overrides the YAML value. When +neither is supplied, the existing `auto` policy applies. Omitting `ash` does +not change existing runs. Unsupported explicit selections still fail clearly. +Use one precision per BenchYAML JVM; start a new run after changing it. + +`experiments.csv` adds two columns for ASH cases: + +- `ash_query_projection_requested`: the selected setting after JVM/YAML precedence. +- `ash_query_projection_effective`: the prepared projection's actual precision. + `auto` can resolve to `fp16` or `fp32`; `fp16+fp32` means the automatic FP16 + view has used its FP32 range fallback. + +The columns are blank for non-ASH cases. Effective precision is blank if no +query projection was initialized. Reporting reads the existing view after +measurement; it does not prepare another view or infer precision from the CPU. + ## Effect on graph construction BenchYAML defaults to `symmetric-all` construction: insertion and diversity use diff --git a/docs/ash-query-integration.md b/docs/ash-query-integration.md index 12628906c..aa7d39c5e 100644 --- a/docs/ash-query-integration.md +++ b/docs/ash-query-integration.md @@ -30,6 +30,9 @@ See [Choosing ASH query-projection precision](ash-projection-precision.md) for processor-family recommendations and projection settings. The [ASH configuration reference](ash-configuration.md) lists model parameters, construction modes, runtime flags and their defaults. +BenchYAML researchers can select `ash.queryProjection` in the run YAML; the +precision guide describes JVM overrides and the requested/effective precision +columns in `experiments.csv`. ## Recommended asynchronous allocation diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/AsymmetricHashing.java b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/AsymmetricHashing.java index 8149efaf1..5d7d9c81c 100644 --- a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/AsymmetricHashing.java +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/AsymmetricHashing.java @@ -1347,6 +1347,12 @@ public long[] queryProjectionBatchHistogram() { return projection == null || queryBatches == null ? null : queryBatches.batchHistogram(); } + /** Reports the prepared view without initializing or changing query execution. */ + public String initializedQueryProjectionPrecision() { + var ready = queryProjection; + return ready == null ? null : ready.operandPrecision(); + } + private VectorUtilSupport.AshProjection queryProjection() { var ready = queryProjection; if (ready == null) { diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/vector/VectorUtilSupport.java b/jvector-base/src/main/java/io/github/jbellis/jvector/vector/VectorUtilSupport.java index 8fd736f7d..9b151af6c 100644 --- a/jvector-base/src/main/java/io/github/jbellis/jvector/vector/VectorUtilSupport.java +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/vector/VectorUtilSupport.java @@ -543,6 +543,9 @@ default AshProjection forQueryScoring() { } } + /** Operand precision of this prepared view; reporting must not initialize another view. */ + default String operandPrecision() { return "fp32"; } + /** Additional backing storage, excluding the caller-owned matrix. */ default long ramBytesUsed() { return 0L; } } diff --git a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/BenchYAML.java b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/BenchYAML.java index 9ec01014a..5184e836b 100644 --- a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/BenchYAML.java +++ b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/BenchYAML.java @@ -36,6 +36,12 @@ public class BenchYAML { public static void main(String[] args) throws IOException { + try (var projectionSetting = new RunConfig.AshQueryProjectionSetting()) { + run(args, projectionSetting); + } + } + + private static void run(String[] args, RunConfig.AshQueryProjectionSetting projectionSetting) throws IOException { // args is one of: // - a list of regexes, possibly needing to be split by whitespace. // - a list of YAML files @@ -97,6 +103,7 @@ public static void main(String[] args) throws IOException { RunArtifacts artifacts; try { RunConfig runCfg = RunConfig.loadDefault(); + projectionSetting.apply(runCfg); artifacts = RunArtifacts.open(runCfg, allConfigs); } catch (java.io.FileNotFoundException e) { if (System.getProperty("jvector.bench.runConfig") != null) throw e; diff --git a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/Grid.java b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/Grid.java index a36a074ae..82632bda7 100644 --- a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/Grid.java +++ b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/Grid.java @@ -962,6 +962,7 @@ private static void testConfiguration(ConfiguredSystem cs, topK, overquery, rerankK, + cs.ashQueryProjectionModel(), logOutputs ); } @@ -1472,6 +1473,15 @@ public synchronized void configureQueryExecution(String mode, int batchSize) { this.exactVectors = ds.getBaseRavv().threadLocalSupplier(); } + /** Metadata from the same ASH model used for query scoring. */ + public AsymmetricHashing ashQueryProjectionModel() { + if (features.contains(FeatureId.FUSED_ASH) && index instanceof OnDiskGraphIndex) { + var feature = ((OnDiskGraphIndex) index).getFeatures().get(FeatureId.FUSED_ASH); + return feature instanceof FusedASH ? ((FusedASH) feature).getASH() : null; + } + return cv instanceof ASHVectors ? ((ASHVectors) cv).getCompressor() : null; + } + public SearchScoreProvider scoreProviderFor(VectorFloat queryVector, ImmutableGraphIndex.View view) { var scoringView = (ImmutableGraphIndex.ScoringView) view; ScoreFunction.ApproximateScoreFunction asf; diff --git a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/reporting/ExperimentsSchemaV1.java b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/reporting/ExperimentsSchemaV1.java index f3e728ddf..32b1f268c 100644 --- a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/reporting/ExperimentsSchemaV1.java +++ b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/reporting/ExperimentsSchemaV1.java @@ -64,7 +64,9 @@ public static List fixedColumns() { "usePruning", "topK", "overquery", - "rerankK" + "rerankK", + "ash_query_projection_requested", + "ash_query_projection_effective" ); } diff --git a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/reporting/RunArtifacts.java b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/reporting/RunArtifacts.java index 4d697cd7a..a0a8c1cf0 100644 --- a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/reporting/RunArtifacts.java +++ b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/reporting/RunArtifacts.java @@ -22,6 +22,7 @@ import io.github.jbellis.jvector.example.yaml.MetricSelection; import io.github.jbellis.jvector.example.yaml.RunConfig; import io.github.jbellis.jvector.graph.disk.feature.FeatureId; +import io.github.jbellis.jvector.quantization.AsymmetricHashing; import java.io.IOException; import java.io.UncheckedIOException; @@ -42,6 +43,8 @@ */ public final class RunArtifacts { + private final String ashQueryProjectionRequested = + System.getProperty(RunConfig.ASH_QUERY_PROJECTION_PROPERTY, "auto"); private String queryExecutionMode = "direct"; private int queryBatchSize = 8; @@ -214,6 +217,7 @@ public void logRow(String datasetName, int topK, double overquery, int rerankK, + AsymmetricHashing ashQueryModel, List logOutputs) { if (experimentsWriter == null || run == null) { return; @@ -236,6 +240,11 @@ public void logRow(String datasetName, rerankK ); + fixed.put("ash_query_projection_requested", ashQueryModel == null ? "" : ashQueryProjectionRequested); + String effective = ashQueryModel == null ? null + : ashQueryModel.stiefelTransform.initializedQueryProjectionPrecision(); + fixed.put("ash_query_projection_effective", effective == null ? "" : effective); + try { experimentsWriter.appendRow(fixed, logOutputs); } catch (IOException e) { diff --git a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/yaml/RunConfig.java b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/yaml/RunConfig.java index 0f97effe1..d0f3c7dc6 100644 --- a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/yaml/RunConfig.java +++ b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/yaml/RunConfig.java @@ -43,6 +43,45 @@ public class RunConfig { public int yamlSchemaVersion; public int onDiskIndexVersion; + /** Optional ASH run settings; omission preserves JVM properties and library defaults. */ + public Ash ash; + + public static class Ash { + public String queryProjection; + } + + public static final String ASH_QUERY_PROJECTION_PROPERTY = "jvector.ash.queryProjection"; + + /** Applies only the optional setting for this invocation and restores it on exit. */ + public static final class AshQueryProjectionSetting implements AutoCloseable { + private final String original = System.getProperty(ASH_QUERY_PROJECTION_PROPERTY); + private boolean applied; + + public void apply(RunConfig config) { + String yaml = config.ash == null ? null : config.ash.queryProjection; + if (yaml != null) validatePrecision(yaml); + String selected = original != null ? original : yaml; + if (selected != null) validatePrecision(selected); + if (original == null && yaml != null) { + System.setProperty(ASH_QUERY_PROJECTION_PROPERTY, yaml); + applied = true; + } + } + + private static void validatePrecision(String value) { + if (!List.of("auto", "fp32", "fp16", "int8").contains(value)) + throw new IllegalArgumentException("ash.queryProjection must be auto, fp32, fp16 or int8: " + value); + } + + @Override + public void close() { + if (applied) { + System.clearProperty(ASH_QUERY_PROJECTION_PROPERTY); + applied = false; + } + } + } + /** Query delivery policy; omitted settings preserve the synchronous benchmark. */ public QueryExecution queryExecution = new QueryExecution(); diff --git a/jvector-examples/src/test/java/io/github/jbellis/jvector/example/benchmarks/AshProjectionConfigTest.java b/jvector-examples/src/test/java/io/github/jbellis/jvector/example/benchmarks/AshProjectionConfigTest.java new file mode 100644 index 000000000..ddbcba5d9 --- /dev/null +++ b/jvector-examples/src/test/java/io/github/jbellis/jvector/example/benchmarks/AshProjectionConfigTest.java @@ -0,0 +1,95 @@ +/* + * 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.benchmarks; + +import io.github.jbellis.jvector.example.yaml.RunConfig; +import org.junit.Before; +import org.junit.After; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; +import java.nio.file.Files; +import static org.junit.Assert.*; + +public class AshProjectionConfigTest { + @Rule public final TemporaryFolder temporary = new TemporaryFolder(); + private String original; + @Before public void isolateProperty() { + original = System.getProperty(RunConfig.ASH_QUERY_PROJECTION_PROPERTY); + System.clearProperty(RunConfig.ASH_QUERY_PROJECTION_PROPERTY); + } + @After public void restoreProperty() { + if (original == null) System.clearProperty(RunConfig.ASH_QUERY_PROJECTION_PROPERTY); + else System.setProperty(RunConfig.ASH_QUERY_PROJECTION_PROPERTY, original); + } + private RunConfig yaml(String body) throws Exception { + var file = temporary.newFile().toPath(); + Files.writeString(file, "yamlSchemaVersion: 1\n" + body); + return RunConfig.load(file.toString()); + } + @Test public void omissionDoesNotSetAPropertyOrChangeExistingDefaults() throws Exception { + var config = yaml(""); + assertNull(config.ash); + try (var setting = new RunConfig.AshQueryProjectionSetting()) { + setting.apply(config); + assertNull(System.getProperty(RunConfig.ASH_QUERY_PROJECTION_PROPERTY)); + assertEquals("direct", config.queryExecution.mode); + assertEquals(8, config.queryExecution.maxBatchSize); + } + assertNull(System.getProperty(RunConfig.ASH_QUERY_PROJECTION_PROPERTY)); + } + @Test public void eachYamlSelectionIsAppliedAndRestored() throws Exception { + for (String mode : new String[]{"auto", "fp32", "fp16", "int8"}) { + var config = yaml("ash:\n queryProjection: " + mode + "\n"); + try (var setting = new RunConfig.AshQueryProjectionSetting()) { + setting.apply(config); + assertEquals(mode, System.getProperty(RunConfig.ASH_QUERY_PROJECTION_PROPERTY)); + } + assertNull(System.getProperty(RunConfig.ASH_QUERY_PROJECTION_PROPERTY)); + } + } + @Test public void explicitJvmSettingOverridesYamlAndIsPreserved() throws Exception { + System.setProperty(RunConfig.ASH_QUERY_PROJECTION_PROPERTY, "fp32"); + try (var setting = new RunConfig.AshQueryProjectionSetting()) { + setting.apply(yaml("ash:\n queryProjection: int8\n")); + assertEquals("fp32", System.getProperty(RunConfig.ASH_QUERY_PROJECTION_PROPERTY)); + } + assertEquals("fp32", System.getProperty(RunConfig.ASH_QUERY_PROJECTION_PROPERTY)); + } + @Test public void failedRunsRestoreOnlyTheirTemporarySetting() throws Exception { + var config = yaml("ash:\n queryProjection: int8\n"); + assertThrows(IllegalStateException.class, () -> { + try (var setting = new RunConfig.AshQueryProjectionSetting()) { + setting.apply(config); + throw new IllegalStateException("failed run"); + } + }); + assertNull(System.getProperty(RunConfig.ASH_QUERY_PROJECTION_PROPERTY)); + } + @Test public void invalidNewValuesFailWithoutChangingTheProperty() throws Exception { + var bad = yaml("ash:\n queryProjection: pf32\n"); + try (var setting = new RunConfig.AshQueryProjectionSetting()) { + assertThrows(IllegalArgumentException.class, () -> setting.apply(bad)); + } + assertNull(System.getProperty(RunConfig.ASH_QUERY_PROJECTION_PROPERTY)); + System.setProperty(RunConfig.ASH_QUERY_PROJECTION_PROPERTY, "unknown"); + try (var setting = new RunConfig.AshQueryProjectionSetting()) { + assertThrows(IllegalArgumentException.class, () -> setting.apply(new RunConfig())); + } + assertEquals("unknown", System.getProperty(RunConfig.ASH_QUERY_PROJECTION_PROPERTY)); + } +} diff --git a/jvector-examples/src/test/java/io/github/jbellis/jvector/example/benchmarks/AshProjectionReportingTest.java b/jvector-examples/src/test/java/io/github/jbellis/jvector/example/benchmarks/AshProjectionReportingTest.java new file mode 100644 index 000000000..6d2344b2b --- /dev/null +++ b/jvector-examples/src/test/java/io/github/jbellis/jvector/example/benchmarks/AshProjectionReportingTest.java @@ -0,0 +1,78 @@ +/* + * 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.benchmarks; + +import io.github.jbellis.jvector.example.benchmarks.datasets.SimpleDataSet; +import io.github.jbellis.jvector.example.reporting.*; +import io.github.jbellis.jvector.example.yaml.RunConfig; +import io.github.jbellis.jvector.graph.disk.feature.FeatureId; +import io.github.jbellis.jvector.quantization.AsymmetricHashing; +import io.github.jbellis.jvector.vector.VectorSimilarityFunction; +import io.github.jbellis.jvector.vector.VectorizationProvider; +import org.junit.Test; +import org.junit.Rule; +import org.junit.rules.TemporaryFolder; +import java.nio.file.Files; +import java.util.*; +import static org.junit.Assert.*; + +public class AshProjectionReportingTest { + @Rule public final TemporaryFolder temporary = new TemporaryFolder(); + @Test public void csvAddsOnlyTwoColumnsAndReportsPreparedViewWithoutInitializingIt() throws Exception { + String key = RunConfig.ASH_QUERY_PROJECTION_PROPERTY, previous = System.getProperty(key); + System.setProperty(key, "fp32"); + try { + assertEquals(List.of("schema_version","run_id","run_uuid","system_id","dataset_name", + "M","efConstruction","neighborOverflow","addHierarchy","refineFinalGraph", + "feature_set","build_compressor","search_compressor","usePruning","topK","overquery","rerankK"), + ExperimentsSchemaV1.fixedColumns().subList(0,17)); + assertEquals(List.of("ash_query_projection_requested","ash_query_projection_effective"), + ExperimentsSchemaV1.fixedColumns().subList(17,19)); + var vectors = VectorizationProvider.getInstance().getVectorTypeSupport(); + var first = vectors.createFloatVector(new float[]{1,0,0,0}); + var second = vectors.createFloatVector(new float[]{0,1,0,0}); + var ds = new SimpleDataSet("precision-metadata", VectorSimilarityFunction.DOT_PRODUCT, + List.of(first,second), List.of(first), List.of(List.of(0))); + var model = AsymmetricHashing.initialize(ds.getBaseRavv(), AsymmetricHashing.RANDOM, 48, 1, 2); + assertNull(model.stiefelTransform.initializedQueryProjectionPrecision()); + var config = new RunConfig(); + config.benchmarks = Map.of("accuracy",List.of("recall")); + config.logging = new RunConfig.RunLogging(); + config.logging.type = "csv"; + config.logging.dir = temporary.getRoot().toString(); + config.logging.benchmarks = config.benchmarks; + var artifacts = RunArtifacts.open(config,List.of()); + var metrics = List.of(Metric.of("search.accuracy.recall_at_10","recall",".5f",1)); + artifacts.logRow("precision-metadata",32,100,1.2f,true,true,Set.of(FeatureId.FUSED_ASH),"ASH","None",false,10,1,10,model,metrics); + assertNull(model.stiefelTransform.initializedQueryProjectionPrecision()); + model.stiefelTransform.projectQueryDirect(new float[]{1,0,0,0},new float[model.quantizedDim]); + assertEquals("fp32",model.stiefelTransform.initializedQueryProjectionPrecision()); + artifacts.logRow("precision-metadata",32,100,1.2f,true,true,Set.of(FeatureId.FUSED_ASH),"ASH","None",false,10,2,20,model,metrics); + artifacts.logRow("precision-metadata",32,100,1.2f,true,true,Set.of(FeatureId.FUSED_PQ),"PQ","None",false,10,1,10,null,metrics); + var lines = Files.readAllLines(artifacts.experimentsWriter().path()); + assertEquals(4,lines.size()); + assertEquals("fp32",lines.get(1).split(",",-1)[17]); + assertEquals("",lines.get(1).split(",",-1)[18]); + assertEquals("fp32",lines.get(2).split(",",-1)[17]); + assertEquals("fp32",lines.get(2).split(",",-1)[18]); + assertEquals("",lines.get(3).split(",",-1)[17]); + assertEquals("",lines.get(3).split(",",-1)[18]); + } finally { + if(previous==null)System.clearProperty(key);else System.setProperty(key,previous); + } + } +} diff --git a/jvector-examples/yaml-configs/ash-pq/run-queued.yml b/jvector-examples/yaml-configs/ash-pq/run-queued.yml index 65049e295..8f98a5d0e 100644 --- a/jvector-examples/yaml-configs/ash-pq/run-queued.yml +++ b/jvector-examples/yaml-configs/ash-pq/run-queued.yml @@ -1,6 +1,9 @@ # Used by standalone BenchYAML via -Djvector.bench.runConfig=. yamlSchemaVersion: 1 onDiskIndexVersion: 7 +# Optional ASH precision; omission preserves JVM settings and the auto default. +# ash: +# queryProjection: int8 # auto, fp16, fp32, int8; explicit -D overrides YAML queryExecution: mode: queued benchmarks: diff --git a/jvector-examples/yaml-configs/run-config.yml b/jvector-examples/yaml-configs/run-config.yml index e50a915db..0c0a9b2a9 100644 --- a/jvector-examples/yaml-configs/run-config.yml +++ b/jvector-examples/yaml-configs/run-config.yml @@ -1,5 +1,8 @@ yamlSchemaVersion: 1 onDiskIndexVersion: 7 +# Optional ASH precision; omission preserves JVM settings and the auto default. +# ash: +# queryProjection: int8 # auto, fp16, fp32, int8; explicit -D overrides YAML benchmarks: throughput: [AVG] latency: [AVG, P999] diff --git a/jvector-native/src/main/java/io/github/jbellis/jvector/vector/NativeASHFP16Projection.java b/jvector-native/src/main/java/io/github/jbellis/jvector/vector/NativeASHFP16Projection.java index fcee993a3..e49cc53c4 100644 --- a/jvector-native/src/main/java/io/github/jbellis/jvector/vector/NativeASHFP16Projection.java +++ b/jvector-native/src/main/java/io/github/jbellis/jvector/vector/NativeASHFP16Projection.java @@ -94,6 +94,8 @@ private static Bindings load() { private final int rows, columns; private volatile long retainedBytes; private volatile boolean batchAvailable; + // Set only on the existing automatic FP32 range-fallback branch. + private volatile boolean fp32FallbackUsed; private static final java.lang.invoke.VarHandle RETAINED_BYTES; static { try { @@ -175,6 +177,9 @@ private static RuntimeException failure(int code, MemorySegment result) { @Override public long ramBytesUsed() { return retainedBytes; } + @Override + public String operandPrecision() { return fp32FallbackUsed ? "fp16+fp32" : "fp16"; } + @Override public boolean supportsBatchProjection() { return batchAvailable; } @@ -239,6 +244,7 @@ public void project(float[] query, float[] out) { context, buffers.input, buffers.output, buffers.status); if (status == RANGE && fallback != null) { fallback.project(query, out); + fp32FallbackUsed = true; return; } if (status != OK) throw failure(status, buffers.status); diff --git a/jvector-native/src/main/java/io/github/jbellis/jvector/vector/NativeASHInt8Projection.java b/jvector-native/src/main/java/io/github/jbellis/jvector/vector/NativeASHInt8Projection.java index d34151438..1636d26de 100644 --- a/jvector-native/src/main/java/io/github/jbellis/jvector/vector/NativeASHInt8Projection.java +++ b/jvector-native/src/main/java/io/github/jbellis/jvector/vector/NativeASHInt8Projection.java @@ -145,6 +145,9 @@ public long ramBytesUsed() { } } + @Override + public String operandPrecision() { return "int8"; } + @Override public boolean supportsBatchProjection() { return true; } diff --git a/jvector-native/src/test/java/io/github/jbellis/jvector/vector/NativeASHFP16ProjectionTest.java b/jvector-native/src/test/java/io/github/jbellis/jvector/vector/NativeASHFP16ProjectionTest.java index e564d511c..e36cbfea6 100644 --- a/jvector-native/src/test/java/io/github/jbellis/jvector/vector/NativeASHFP16ProjectionTest.java +++ b/jvector-native/src/test/java/io/github/jbellis/jvector/vector/NativeASHFP16ProjectionTest.java @@ -321,10 +321,12 @@ void defaultPrefersFp16ButFp32AndAutomaticRangeFallbackRemainAvailable() { assertInstanceOf(NativeASHFP16Projection.class, explicit); var automatic = view(original, null); assertInstanceOf(NativeASHFP16Projection.class, automatic); + assertEquals("fp16", automatic.operandPrecision()); float[] query = {70000, 2}, out = new float[1], expected = new float[1]; original.project(query, expected); automatic.project(query, out); assertArrayEquals(expected, out); + assertEquals("fp16+fp32", automatic.operandPrecision()); var largeMatrix = new NativeASHProjection(new float[][]{{70000}}); assertSame(largeMatrix, view(largeMatrix, "auto")); assertThrows(IllegalArgumentException.class, () -> view(largeMatrix, "fp16")); diff --git a/jvector-native/src/test/java/io/github/jbellis/jvector/vector/NativeASHInt8ProjectionTest.java b/jvector-native/src/test/java/io/github/jbellis/jvector/vector/NativeASHInt8ProjectionTest.java index 715d33e42..71780c480 100644 --- a/jvector-native/src/test/java/io/github/jbellis/jvector/vector/NativeASHInt8ProjectionTest.java +++ b/jvector-native/src/test/java/io/github/jbellis/jvector/vector/NativeASHInt8ProjectionTest.java @@ -267,7 +267,9 @@ void precisionSelectionPreservesOriginalFp32AndAutomaticPolicy() { float[] query = matrix(1,64,7)[0], before = new float[32], after = new float[32]; original.project(query, before); System.setProperty(PROPERTY, "int8"); - assertInstanceOf(NativeASHInt8Projection.class, original.forQueryScoring()); + var int8 = original.forQueryScoring(); + assertInstanceOf(NativeASHInt8Projection.class, int8); + assertEquals("int8", int8.operandPrecision()); original.project(query, after); assertArrayEquals(before, after); System.setProperty(PROPERTY, "fp32"); From 4e36c9b87ad755d653b7e4a8f92018a3d8485096 Mon Sep 17 00:00:00 2001 From: Ted Willke Date: Fri, 6 Nov 2026 02:28:01 +0000 Subject: [PATCH 155/243] Move BenchYAML ASH precision into index search parameters Read search.queryProjection from index-parameters YAML and restore run-config to collection/reporting settings. Include the default auto selection in ASH recipes and align the guides. Preserve JVM override precedence, projection implementations, existing defaults, and requested/effective CSV metadata. Validate the existing one-precision-per-JVM constraint before loading models. Validation: 17 focused tests on each of JDK 22 and 25; Maven package; ordinary BenchYAML cached Cohere graph checks for index-YAML int8 and auto selections. --- docs/ash-configuration.md | 7 +- docs/ash-pq-benchmark.md | 4 +- docs/ash-projection-precision.md | 10 +- docs/ash-query-integration.md | 2 +- .../jbellis/jvector/example/BenchYAML.java | 8 +- .../example/reporting/RunArtifacts.java | 3 +- .../jvector/example/yaml/RunConfig.java | 39 ------- .../example/yaml/SearchParameters.java | 48 +++++++++ .../benchmarks/AshProjectionConfigTest.java | 100 +++++++++++------- .../AshProjectionReportingTest.java | 3 +- .../yaml-configs/ash-pq/ada002-2bit-ash.yml | 1 + .../yaml-configs/ash-pq/ada002-4bit-ash.yml | 1 + .../yaml-configs/ash-pq/cap6m-2bit-ash.yml | 1 + .../yaml-configs/ash-pq/cap6m-4bit-ash.yml | 1 + .../yaml-configs/ash-pq/cohere-2bit-ash.yml | 1 + .../yaml-configs/ash-pq/cohere-4bit-ash.yml | 1 + .../ash-pq/cohere10m-2bit-ash.yml | 1 + .../ash-pq/cohere10m-4bit-ash.yml | 1 + .../yaml-configs/ash-pq/mpnet-2bit-ash.yml | 1 + .../yaml-configs/ash-pq/mpnet-4bit-ash.yml | 1 + .../yaml-configs/ash-pq/mpnet10m-2bit-ash.yml | 1 + .../yaml-configs/ash-pq/mpnet10m-4bit-ash.yml | 1 + .../ash-pq/openai3072-2bit-ash.yml | 1 + .../ash-pq/openai3072-4bit-ash.yml | 1 + .../yaml-configs/ash-pq/run-queued.yml | 3 - .../index-parameters/ada002-1M.yml | 1 + .../yaml-configs/index-parameters/cap-10M.yml | 1 + .../yaml-configs/index-parameters/cap-1M.yml | 1 + .../yaml-configs/index-parameters/cap-6M.yml | 1 + .../index-parameters/cohere-10M.yml | 1 + .../cohere-english-v3-10M.yml | 1 + .../index-parameters/cohere-english-v3-1M.yml | 1 + .../yaml-configs/index-parameters/default.yml | 1 + .../index-parameters/dpr-gemma-10M.yml | 1 + .../index-parameters/dpr-gemma-1M.yml | 1 + .../index-parameters/esmc-600-1M.yml | 1 + .../index-parameters/esmc-600-6M.yml | 1 + .../index-parameters/gecko-100k.yml | 1 + .../index-parameters/mpnet-10M.yml | 1 + .../index-parameters/mpnet-1M.yml | 1 + .../index-parameters/openai-v3-small-100k.yml | 1 + .../index-parameters/personahub-1M.yml | 1 + .../index-parameters/tahoe-x1-1M.yml | 1 + jvector-examples/yaml-configs/run-config.yml | 3 - 44 files changed, 166 insertions(+), 96 deletions(-) diff --git a/docs/ash-configuration.md b/docs/ash-configuration.md index ac9f0a8a6..77bf8ee64 100644 --- a/docs/ash-configuration.md +++ b/docs/ash-configuration.md @@ -92,16 +92,17 @@ asymmetric construction, it can affect insertion too. ### BenchYAML precision selection -The run YAML can select the same precision without a JVM option: +The index-parameters YAML can select the same precision under `search`: ```yaml -ash: +search: queryProjection: int8 # auto, fp16, fp32, or int8 ``` An explicit `-Djvector.ash.queryProjection=...` overrides YAML. Omitting the setting preserves the existing JVM setting or `auto` default. Use one precision -per BenchYAML JVM. ASH CSV rows record `ash_query_projection_requested` and +per BenchYAML JVM; conflicting ASH selections in one invocation are rejected. +ASH CSV rows record `ash_query_projection_requested` and `ash_query_projection_effective`; the latter is read from the prepared view, with `fp16+fp32` indicating observed automatic FP32 range fallback. Non-ASH rows have blank precision columns; an unused projection has blank effective diff --git a/docs/ash-pq-benchmark.md b/docs/ash-pq-benchmark.md index 124854b40..852b28829 100644 --- a/docs/ash-pq-benchmark.md +++ b/docs/ash-pq-benchmark.md @@ -33,8 +33,8 @@ Projection settings and processor recommendations are documented in [Choosing ASH query-projection precision](ash-projection-precision.md). An explicitly requested INT8/FP32 comparison is a precision override to the FP16 benchmark configuration below; record it with the command and CSV evidence. -BenchYAML also accepts `ash.queryProjection` (`auto`, `fp16`, `fp32`, `int8`) in -its run YAML. An explicit JVM precision property overrides YAML; omission keeps +BenchYAML also accepts `search.queryProjection` (`auto`, `fp16`, `fp32`, `int8`) in +the index-parameters YAML. An explicit JVM precision property overrides YAML; omission keeps the existing defaults. ASH CSV rows record `ash_query_projection_requested` and `ash_query_projection_effective`; see the precision guide for fallback labels. diff --git a/docs/ash-projection-precision.md b/docs/ash-projection-precision.md index 6e654840b..2234deecf 100644 --- a/docs/ash-projection-precision.md +++ b/docs/ash-projection-precision.md @@ -63,18 +63,20 @@ after initialization does not reconfigure an existing view. ## BenchYAML runs -Select precision in the **run configuration**, independently of the index YAML: +Select precision under `search` in the **index-parameters YAML**: ```yaml -ash: +search: queryProjection: int8 # auto, fp16, fp32, or int8 ``` -Select that run file with `-Djvector.bench.runConfig=/path/to/run.yml`. +For example, edit `jvector-examples/yaml-configs/index-parameters/mpnet-10M.yml`. +Run-config continues to select data collection and reporting. An explicit `-Djvector.ash.queryProjection=...` overrides the YAML value. When -neither is supplied, the existing `auto` policy applies. Omitting `ash` does +neither is supplied, the existing `auto` policy applies. Omitting `search.queryProjection` does not change existing runs. Unsupported explicit selections still fail clearly. Use one precision per BenchYAML JVM; start a new run after changing it. +Conflicting ASH precisions across index configurations in one invocation are rejected. `experiments.csv` adds two columns for ASH cases: diff --git a/docs/ash-query-integration.md b/docs/ash-query-integration.md index aa7d39c5e..aff0f5d16 100644 --- a/docs/ash-query-integration.md +++ b/docs/ash-query-integration.md @@ -30,7 +30,7 @@ See [Choosing ASH query-projection precision](ash-projection-precision.md) for processor-family recommendations and projection settings. The [ASH configuration reference](ash-configuration.md) lists model parameters, construction modes, runtime flags and their defaults. -BenchYAML researchers can select `ash.queryProjection` in the run YAML; the +BenchYAML researchers can select `search.queryProjection` in the index-parameters YAML; the precision guide describes JVM overrides and the requested/effective precision columns in `experiments.csv`. diff --git a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/BenchYAML.java b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/BenchYAML.java index 5184e836b..0655abcae 100644 --- a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/BenchYAML.java +++ b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/BenchYAML.java @@ -23,6 +23,7 @@ import io.github.jbellis.jvector.example.yaml.DatasetCollection; import io.github.jbellis.jvector.example.yaml.MultiConfig; import io.github.jbellis.jvector.example.yaml.RunConfig; +import io.github.jbellis.jvector.example.yaml.SearchParameters; import java.io.IOException; import java.security.InvalidParameterException; @@ -36,12 +37,12 @@ public class BenchYAML { public static void main(String[] args) throws IOException { - try (var projectionSetting = new RunConfig.AshQueryProjectionSetting()) { + try (var projectionSetting = new SearchParameters.AshQueryProjectionSetting()) { run(args, projectionSetting); } } - private static void run(String[] args, RunConfig.AshQueryProjectionSetting projectionSetting) throws IOException { + private static void run(String[] args, SearchParameters.AshQueryProjectionSetting projectionSetting) throws IOException { // args is one of: // - a list of regexes, possibly needing to be split by whitespace. // - a list of YAML files @@ -99,11 +100,12 @@ private static void run(String[] args, RunConfig.AshQueryProjectionSetting proje return; } + projectionSetting.apply(allConfigs); + // Set up reporting for run RunArtifacts artifacts; try { RunConfig runCfg = RunConfig.loadDefault(); - projectionSetting.apply(runCfg); artifacts = RunArtifacts.open(runCfg, allConfigs); } catch (java.io.FileNotFoundException e) { if (System.getProperty("jvector.bench.runConfig") != null) throw e; diff --git a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/reporting/RunArtifacts.java b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/reporting/RunArtifacts.java index a0a8c1cf0..9e2694e6e 100644 --- a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/reporting/RunArtifacts.java +++ b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/reporting/RunArtifacts.java @@ -21,6 +21,7 @@ import io.github.jbellis.jvector.example.yaml.MultiConfig; import io.github.jbellis.jvector.example.yaml.MetricSelection; import io.github.jbellis.jvector.example.yaml.RunConfig; +import io.github.jbellis.jvector.example.yaml.SearchParameters; import io.github.jbellis.jvector.graph.disk.feature.FeatureId; import io.github.jbellis.jvector.quantization.AsymmetricHashing; @@ -44,7 +45,7 @@ public final class RunArtifacts { private final String ashQueryProjectionRequested = - System.getProperty(RunConfig.ASH_QUERY_PROJECTION_PROPERTY, "auto"); + System.getProperty(SearchParameters.ASH_QUERY_PROJECTION_PROPERTY, "auto"); private String queryExecutionMode = "direct"; private int queryBatchSize = 8; diff --git a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/yaml/RunConfig.java b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/yaml/RunConfig.java index d0f3c7dc6..0f97effe1 100644 --- a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/yaml/RunConfig.java +++ b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/yaml/RunConfig.java @@ -43,45 +43,6 @@ public class RunConfig { public int yamlSchemaVersion; public int onDiskIndexVersion; - /** Optional ASH run settings; omission preserves JVM properties and library defaults. */ - public Ash ash; - - public static class Ash { - public String queryProjection; - } - - public static final String ASH_QUERY_PROJECTION_PROPERTY = "jvector.ash.queryProjection"; - - /** Applies only the optional setting for this invocation and restores it on exit. */ - public static final class AshQueryProjectionSetting implements AutoCloseable { - private final String original = System.getProperty(ASH_QUERY_PROJECTION_PROPERTY); - private boolean applied; - - public void apply(RunConfig config) { - String yaml = config.ash == null ? null : config.ash.queryProjection; - if (yaml != null) validatePrecision(yaml); - String selected = original != null ? original : yaml; - if (selected != null) validatePrecision(selected); - if (original == null && yaml != null) { - System.setProperty(ASH_QUERY_PROJECTION_PROPERTY, yaml); - applied = true; - } - } - - private static void validatePrecision(String value) { - if (!List.of("auto", "fp32", "fp16", "int8").contains(value)) - throw new IllegalArgumentException("ash.queryProjection must be auto, fp32, fp16 or int8: " + value); - } - - @Override - public void close() { - if (applied) { - System.clearProperty(ASH_QUERY_PROJECTION_PROPERTY); - applied = false; - } - } - } - /** Query delivery policy; omitted settings preserve the synchronous benchmark. */ public QueryExecution queryExecution = new QueryExecution(); diff --git a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/yaml/SearchParameters.java b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/yaml/SearchParameters.java index dabce268a..7da16c6fb 100644 --- a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/yaml/SearchParameters.java +++ b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/yaml/SearchParameters.java @@ -20,6 +20,54 @@ import java.util.Map; public class SearchParameters extends CommonParameters { + /** ASH query arithmetic; omitted settings preserve JVM properties and auto. */ + public String queryProjection; + + public static final String ASH_QUERY_PROJECTION_PROPERTY = "jvector.ash.queryProjection"; + + /** Resolve one precision before loading models; cached views cannot change mid-JVM. */ + public static final class AshQueryProjectionSetting implements AutoCloseable { + private final String original = System.getProperty(ASH_QUERY_PROJECTION_PROPERTY); + private boolean applied; + + public void apply(List configs) { + String selected = null; + boolean explicitYaml = false; + for (MultiConfig config : configs) { + String yaml = config.search == null ? null : config.search.queryProjection; + if (yaml != null) validatePrecision(yaml); + if (!hasAsh(config.construction) && !hasAsh(config.search)) continue; + String mode = original != null ? original : yaml == null ? "auto" : yaml; + validatePrecision(mode); + if (selected != null && !selected.equals(mode)) + throw new IllegalArgumentException("Different ASH queryProjection settings require separate BenchYAML JVMs"); + selected = mode; + explicitYaml |= yaml != null; + } + if (original == null && explicitYaml && selected != null) { + System.setProperty(ASH_QUERY_PROJECTION_PROPERTY, selected); + applied = true; + } + } + + private static boolean hasAsh(CommonParameters parameters) { + return parameters != null && parameters.compression != null + && parameters.compression.stream().anyMatch(c -> c != null && "ASH".equals(c.type)); + } + + private static void validatePrecision(String value) { + if (!List.of("auto", "fp32", "fp16", "int8").contains(value)) + throw new IllegalArgumentException("search.queryProjection must be auto, fp32, fp16 or int8: " + value); + } + + @Override public void close() { + if (applied) { + System.clearProperty(ASH_QUERY_PROJECTION_PROPERTY); + applied = false; + } + } + } + public Map> topKOverquery; public List useSearchPruning; diff --git a/jvector-examples/src/test/java/io/github/jbellis/jvector/example/benchmarks/AshProjectionConfigTest.java b/jvector-examples/src/test/java/io/github/jbellis/jvector/example/benchmarks/AshProjectionConfigTest.java index ddbcba5d9..f60b25042 100644 --- a/jvector-examples/src/test/java/io/github/jbellis/jvector/example/benchmarks/AshProjectionConfigTest.java +++ b/jvector-examples/src/test/java/io/github/jbellis/jvector/example/benchmarks/AshProjectionConfigTest.java @@ -17,6 +17,9 @@ package io.github.jbellis.jvector.example.benchmarks; import io.github.jbellis.jvector.example.yaml.RunConfig; +import io.github.jbellis.jvector.example.yaml.SearchParameters; +import io.github.jbellis.jvector.example.yaml.MultiConfig; +import java.util.List; import org.junit.Before; import org.junit.After; import org.junit.Rule; @@ -29,67 +32,92 @@ public class AshProjectionConfigTest { @Rule public final TemporaryFolder temporary = new TemporaryFolder(); private String original; @Before public void isolateProperty() { - original = System.getProperty(RunConfig.ASH_QUERY_PROJECTION_PROPERTY); - System.clearProperty(RunConfig.ASH_QUERY_PROJECTION_PROPERTY); + original = System.getProperty(SearchParameters.ASH_QUERY_PROJECTION_PROPERTY); + System.clearProperty(SearchParameters.ASH_QUERY_PROJECTION_PROPERTY); } @After public void restoreProperty() { - if (original == null) System.clearProperty(RunConfig.ASH_QUERY_PROJECTION_PROPERTY); - else System.setProperty(RunConfig.ASH_QUERY_PROJECTION_PROPERTY, original); + if (original == null) System.clearProperty(SearchParameters.ASH_QUERY_PROJECTION_PROPERTY); + else System.setProperty(SearchParameters.ASH_QUERY_PROJECTION_PROPERTY, original); } - private RunConfig yaml(String body) throws Exception { + private MultiConfig yaml(String body) throws Exception { var file = temporary.newFile().toPath(); - Files.writeString(file, "yamlSchemaVersion: 1\n" + body); - return RunConfig.load(file.toString()); + Files.writeString(file, "yamlSchemaVersion: 2\nonDiskIndexVersion: 7\ndataset: precision-test\n" + + "construction:\n compression:\n - type: ASH\n parameters:\n encodedBits: 48\n optimizer: RANDOM\n bitsPerDimension: 2\n" + + (body.isEmpty() ? "search: {}\n" : body)); + return MultiConfig.getConfig(file.toString()); } @Test public void omissionDoesNotSetAPropertyOrChangeExistingDefaults() throws Exception { var config = yaml(""); - assertNull(config.ash); - try (var setting = new RunConfig.AshQueryProjectionSetting()) { - setting.apply(config); - assertNull(System.getProperty(RunConfig.ASH_QUERY_PROJECTION_PROPERTY)); - assertEquals("direct", config.queryExecution.mode); - assertEquals(8, config.queryExecution.maxBatchSize); + assertNull(config.search.queryProjection); + try (var setting = new SearchParameters.AshQueryProjectionSetting()) { + setting.apply(List.of(config)); + assertNull(System.getProperty(SearchParameters.ASH_QUERY_PROJECTION_PROPERTY)); + assertEquals("direct", new RunConfig().queryExecution.mode); + assertEquals(8, new RunConfig().queryExecution.maxBatchSize); } - assertNull(System.getProperty(RunConfig.ASH_QUERY_PROJECTION_PROPERTY)); + assertNull(System.getProperty(SearchParameters.ASH_QUERY_PROJECTION_PROPERTY)); } @Test public void eachYamlSelectionIsAppliedAndRestored() throws Exception { for (String mode : new String[]{"auto", "fp32", "fp16", "int8"}) { - var config = yaml("ash:\n queryProjection: " + mode + "\n"); - try (var setting = new RunConfig.AshQueryProjectionSetting()) { - setting.apply(config); - assertEquals(mode, System.getProperty(RunConfig.ASH_QUERY_PROJECTION_PROPERTY)); + var config = yaml("search:\n queryProjection: " + mode + "\n"); + try (var setting = new SearchParameters.AshQueryProjectionSetting()) { + setting.apply(List.of(config)); + assertEquals(mode, System.getProperty(SearchParameters.ASH_QUERY_PROJECTION_PROPERTY)); } - assertNull(System.getProperty(RunConfig.ASH_QUERY_PROJECTION_PROPERTY)); + assertNull(System.getProperty(SearchParameters.ASH_QUERY_PROJECTION_PROPERTY)); } } @Test public void explicitJvmSettingOverridesYamlAndIsPreserved() throws Exception { - System.setProperty(RunConfig.ASH_QUERY_PROJECTION_PROPERTY, "fp32"); - try (var setting = new RunConfig.AshQueryProjectionSetting()) { - setting.apply(yaml("ash:\n queryProjection: int8\n")); - assertEquals("fp32", System.getProperty(RunConfig.ASH_QUERY_PROJECTION_PROPERTY)); + System.setProperty(SearchParameters.ASH_QUERY_PROJECTION_PROPERTY, "fp32"); + try (var setting = new SearchParameters.AshQueryProjectionSetting()) { + setting.apply(List.of(yaml("search:\n queryProjection: int8\n"))); + assertEquals("fp32", System.getProperty(SearchParameters.ASH_QUERY_PROJECTION_PROPERTY)); } - assertEquals("fp32", System.getProperty(RunConfig.ASH_QUERY_PROJECTION_PROPERTY)); + assertEquals("fp32", System.getProperty(SearchParameters.ASH_QUERY_PROJECTION_PROPERTY)); } @Test public void failedRunsRestoreOnlyTheirTemporarySetting() throws Exception { - var config = yaml("ash:\n queryProjection: int8\n"); + var config = yaml("search:\n queryProjection: int8\n"); assertThrows(IllegalStateException.class, () -> { - try (var setting = new RunConfig.AshQueryProjectionSetting()) { - setting.apply(config); + try (var setting = new SearchParameters.AshQueryProjectionSetting()) { + setting.apply(List.of(config)); throw new IllegalStateException("failed run"); } }); - assertNull(System.getProperty(RunConfig.ASH_QUERY_PROJECTION_PROPERTY)); + assertNull(System.getProperty(SearchParameters.ASH_QUERY_PROJECTION_PROPERTY)); } @Test public void invalidNewValuesFailWithoutChangingTheProperty() throws Exception { - var bad = yaml("ash:\n queryProjection: pf32\n"); - try (var setting = new RunConfig.AshQueryProjectionSetting()) { - assertThrows(IllegalArgumentException.class, () -> setting.apply(bad)); + var bad = yaml("search:\n queryProjection: pf32\n"); + try (var setting = new SearchParameters.AshQueryProjectionSetting()) { + assertThrows(IllegalArgumentException.class, () -> setting.apply(List.of(bad))); } - assertNull(System.getProperty(RunConfig.ASH_QUERY_PROJECTION_PROPERTY)); - System.setProperty(RunConfig.ASH_QUERY_PROJECTION_PROPERTY, "unknown"); - try (var setting = new RunConfig.AshQueryProjectionSetting()) { - assertThrows(IllegalArgumentException.class, () -> setting.apply(new RunConfig())); + assertNull(System.getProperty(SearchParameters.ASH_QUERY_PROJECTION_PROPERTY)); + System.setProperty(SearchParameters.ASH_QUERY_PROJECTION_PROPERTY, "unknown"); + try (var setting = new SearchParameters.AshQueryProjectionSetting()) { + assertThrows(IllegalArgumentException.class, () -> setting.apply(List.of(yaml("")))); } - assertEquals("unknown", System.getProperty(RunConfig.ASH_QUERY_PROJECTION_PROPERTY)); + assertEquals("unknown", System.getProperty(SearchParameters.ASH_QUERY_PROJECTION_PROPERTY)); } + @Test public void conflictingAshPrecisionsFailBeforeModelsAreLoaded() throws Exception { + var first = yaml("search:\n queryProjection: int8\n"); + var second = yaml("search:\n queryProjection: fp32\n"); + try (var setting = new SearchParameters.AshQueryProjectionSetting()) { + assertThrows(IllegalArgumentException.class, () -> setting.apply(List.of(first,second))); + } + assertNull(System.getProperty(SearchParameters.ASH_QUERY_PROJECTION_PROPERTY)); + } + @Test public void runConfigRejectsAlgorithmParameters() throws Exception { + var file = temporary.newFile().toPath(); + Files.writeString(file,"yamlSchemaVersion: 1\nash:\n queryProjection: int8\n"); + assertThrows(org.yaml.snakeyaml.error.YAMLException.class, () -> RunConfig.load(file.toString())); + } + @Test public void pqConfigurationDoesNotOverrideAshSelection() throws Exception { + var ash = yaml("search:\n queryProjection: int8\n"); + var pq = yaml(""); + pq.construction.compression.get(0).type = "PQ"; + try (var setting = new SearchParameters.AshQueryProjectionSetting()) { + setting.apply(List.of(ash,pq)); + assertEquals("int8",System.getProperty(SearchParameters.ASH_QUERY_PROJECTION_PROPERTY)); + } + } + } diff --git a/jvector-examples/src/test/java/io/github/jbellis/jvector/example/benchmarks/AshProjectionReportingTest.java b/jvector-examples/src/test/java/io/github/jbellis/jvector/example/benchmarks/AshProjectionReportingTest.java index 6d2344b2b..cd4524ab0 100644 --- a/jvector-examples/src/test/java/io/github/jbellis/jvector/example/benchmarks/AshProjectionReportingTest.java +++ b/jvector-examples/src/test/java/io/github/jbellis/jvector/example/benchmarks/AshProjectionReportingTest.java @@ -19,6 +19,7 @@ import io.github.jbellis.jvector.example.benchmarks.datasets.SimpleDataSet; import io.github.jbellis.jvector.example.reporting.*; import io.github.jbellis.jvector.example.yaml.RunConfig; +import io.github.jbellis.jvector.example.yaml.SearchParameters; import io.github.jbellis.jvector.graph.disk.feature.FeatureId; import io.github.jbellis.jvector.quantization.AsymmetricHashing; import io.github.jbellis.jvector.vector.VectorSimilarityFunction; @@ -33,7 +34,7 @@ public class AshProjectionReportingTest { @Rule public final TemporaryFolder temporary = new TemporaryFolder(); @Test public void csvAddsOnlyTwoColumnsAndReportsPreparedViewWithoutInitializingIt() throws Exception { - String key = RunConfig.ASH_QUERY_PROJECTION_PROPERTY, previous = System.getProperty(key); + String key = SearchParameters.ASH_QUERY_PROJECTION_PROPERTY, previous = System.getProperty(key); System.setProperty(key, "fp32"); try { assertEquals(List.of("schema_version","run_id","run_uuid","system_id","dataset_name", diff --git a/jvector-examples/yaml-configs/ash-pq/ada002-2bit-ash.yml b/jvector-examples/yaml-configs/ash-pq/ada002-2bit-ash.yml index 2510c661d..7a25716c6 100644 --- a/jvector-examples/yaml-configs/ash-pq/ada002-2bit-ash.yml +++ b/jvector-examples/yaml-configs/ash-pq/ada002-2bit-ash.yml @@ -26,6 +26,7 @@ construction: useSavedIndexIfExists: Yes search: + queryProjection: auto # auto, fp16, fp32, int8; explicit -D overrides YAML topKOverquery: 10: [1.0, 2.0] useSearchPruning: [No] diff --git a/jvector-examples/yaml-configs/ash-pq/ada002-4bit-ash.yml b/jvector-examples/yaml-configs/ash-pq/ada002-4bit-ash.yml index a3e1fee95..c7fd51871 100644 --- a/jvector-examples/yaml-configs/ash-pq/ada002-4bit-ash.yml +++ b/jvector-examples/yaml-configs/ash-pq/ada002-4bit-ash.yml @@ -26,6 +26,7 @@ construction: useSavedIndexIfExists: Yes search: + queryProjection: auto # auto, fp16, fp32, int8; explicit -D overrides YAML topKOverquery: 10: [1.0, 2.0] useSearchPruning: [No] diff --git a/jvector-examples/yaml-configs/ash-pq/cap6m-2bit-ash.yml b/jvector-examples/yaml-configs/ash-pq/cap6m-2bit-ash.yml index d1e6c52bd..bf9afb5d9 100644 --- a/jvector-examples/yaml-configs/ash-pq/cap6m-2bit-ash.yml +++ b/jvector-examples/yaml-configs/ash-pq/cap6m-2bit-ash.yml @@ -26,6 +26,7 @@ construction: useSavedIndexIfExists: Yes search: + queryProjection: auto # auto, fp16, fp32, int8; explicit -D overrides YAML topKOverquery: 10: [1.0, 2.0] useSearchPruning: [No] diff --git a/jvector-examples/yaml-configs/ash-pq/cap6m-4bit-ash.yml b/jvector-examples/yaml-configs/ash-pq/cap6m-4bit-ash.yml index e04932487..e5dae3e21 100644 --- a/jvector-examples/yaml-configs/ash-pq/cap6m-4bit-ash.yml +++ b/jvector-examples/yaml-configs/ash-pq/cap6m-4bit-ash.yml @@ -26,6 +26,7 @@ construction: useSavedIndexIfExists: Yes search: + queryProjection: auto # auto, fp16, fp32, int8; explicit -D overrides YAML topKOverquery: 10: [1.0, 2.0] useSearchPruning: [No] diff --git a/jvector-examples/yaml-configs/ash-pq/cohere-2bit-ash.yml b/jvector-examples/yaml-configs/ash-pq/cohere-2bit-ash.yml index 3148f1981..c1660b39c 100644 --- a/jvector-examples/yaml-configs/ash-pq/cohere-2bit-ash.yml +++ b/jvector-examples/yaml-configs/ash-pq/cohere-2bit-ash.yml @@ -26,6 +26,7 @@ construction: useSavedIndexIfExists: Yes search: + queryProjection: auto # auto, fp16, fp32, int8; explicit -D overrides YAML topKOverquery: 10: [1.0, 2.0] useSearchPruning: [No] diff --git a/jvector-examples/yaml-configs/ash-pq/cohere-4bit-ash.yml b/jvector-examples/yaml-configs/ash-pq/cohere-4bit-ash.yml index bcf28ed35..d885556e8 100644 --- a/jvector-examples/yaml-configs/ash-pq/cohere-4bit-ash.yml +++ b/jvector-examples/yaml-configs/ash-pq/cohere-4bit-ash.yml @@ -26,6 +26,7 @@ construction: useSavedIndexIfExists: Yes search: + queryProjection: auto # auto, fp16, fp32, int8; explicit -D overrides YAML topKOverquery: 10: [1.0, 2.0] useSearchPruning: [No] diff --git a/jvector-examples/yaml-configs/ash-pq/cohere10m-2bit-ash.yml b/jvector-examples/yaml-configs/ash-pq/cohere10m-2bit-ash.yml index 611c5eb63..998be6c69 100644 --- a/jvector-examples/yaml-configs/ash-pq/cohere10m-2bit-ash.yml +++ b/jvector-examples/yaml-configs/ash-pq/cohere10m-2bit-ash.yml @@ -26,6 +26,7 @@ construction: useSavedIndexIfExists: Yes search: + queryProjection: auto # auto, fp16, fp32, int8; explicit -D overrides YAML topKOverquery: 10: [1.0, 2.0] useSearchPruning: [No] diff --git a/jvector-examples/yaml-configs/ash-pq/cohere10m-4bit-ash.yml b/jvector-examples/yaml-configs/ash-pq/cohere10m-4bit-ash.yml index d681f9ece..8e027a7af 100644 --- a/jvector-examples/yaml-configs/ash-pq/cohere10m-4bit-ash.yml +++ b/jvector-examples/yaml-configs/ash-pq/cohere10m-4bit-ash.yml @@ -26,6 +26,7 @@ construction: useSavedIndexIfExists: Yes search: + queryProjection: auto # auto, fp16, fp32, int8; explicit -D overrides YAML topKOverquery: 10: [1.0, 2.0] useSearchPruning: [No] diff --git a/jvector-examples/yaml-configs/ash-pq/mpnet-2bit-ash.yml b/jvector-examples/yaml-configs/ash-pq/mpnet-2bit-ash.yml index a807ebbea..2de051545 100644 --- a/jvector-examples/yaml-configs/ash-pq/mpnet-2bit-ash.yml +++ b/jvector-examples/yaml-configs/ash-pq/mpnet-2bit-ash.yml @@ -26,6 +26,7 @@ construction: useSavedIndexIfExists: Yes search: + queryProjection: auto # auto, fp16, fp32, int8; explicit -D overrides YAML topKOverquery: 10: [1.0, 2.0] useSearchPruning: [No] diff --git a/jvector-examples/yaml-configs/ash-pq/mpnet-4bit-ash.yml b/jvector-examples/yaml-configs/ash-pq/mpnet-4bit-ash.yml index 83b4f1d69..faa63e405 100644 --- a/jvector-examples/yaml-configs/ash-pq/mpnet-4bit-ash.yml +++ b/jvector-examples/yaml-configs/ash-pq/mpnet-4bit-ash.yml @@ -26,6 +26,7 @@ construction: useSavedIndexIfExists: Yes search: + queryProjection: auto # auto, fp16, fp32, int8; explicit -D overrides YAML topKOverquery: 10: [1.0, 2.0] useSearchPruning: [No] diff --git a/jvector-examples/yaml-configs/ash-pq/mpnet10m-2bit-ash.yml b/jvector-examples/yaml-configs/ash-pq/mpnet10m-2bit-ash.yml index 0af8a3061..16beec797 100644 --- a/jvector-examples/yaml-configs/ash-pq/mpnet10m-2bit-ash.yml +++ b/jvector-examples/yaml-configs/ash-pq/mpnet10m-2bit-ash.yml @@ -26,6 +26,7 @@ construction: useSavedIndexIfExists: Yes search: + queryProjection: auto # auto, fp16, fp32, int8; explicit -D overrides YAML topKOverquery: 10: [1.0, 2.0] useSearchPruning: [No] diff --git a/jvector-examples/yaml-configs/ash-pq/mpnet10m-4bit-ash.yml b/jvector-examples/yaml-configs/ash-pq/mpnet10m-4bit-ash.yml index 01f122311..561583df2 100644 --- a/jvector-examples/yaml-configs/ash-pq/mpnet10m-4bit-ash.yml +++ b/jvector-examples/yaml-configs/ash-pq/mpnet10m-4bit-ash.yml @@ -26,6 +26,7 @@ construction: useSavedIndexIfExists: Yes search: + queryProjection: auto # auto, fp16, fp32, int8; explicit -D overrides YAML topKOverquery: 10: [1.0, 2.0] useSearchPruning: [No] diff --git a/jvector-examples/yaml-configs/ash-pq/openai3072-2bit-ash.yml b/jvector-examples/yaml-configs/ash-pq/openai3072-2bit-ash.yml index 1b51ca920..b7d32ac23 100644 --- a/jvector-examples/yaml-configs/ash-pq/openai3072-2bit-ash.yml +++ b/jvector-examples/yaml-configs/ash-pq/openai3072-2bit-ash.yml @@ -26,6 +26,7 @@ construction: useSavedIndexIfExists: Yes search: + queryProjection: auto # auto, fp16, fp32, int8; explicit -D overrides YAML topKOverquery: 10: [1.0, 2.0] useSearchPruning: [No] diff --git a/jvector-examples/yaml-configs/ash-pq/openai3072-4bit-ash.yml b/jvector-examples/yaml-configs/ash-pq/openai3072-4bit-ash.yml index 9c0c4f983..34a6e1a95 100644 --- a/jvector-examples/yaml-configs/ash-pq/openai3072-4bit-ash.yml +++ b/jvector-examples/yaml-configs/ash-pq/openai3072-4bit-ash.yml @@ -26,6 +26,7 @@ construction: useSavedIndexIfExists: Yes search: + queryProjection: auto # auto, fp16, fp32, int8; explicit -D overrides YAML topKOverquery: 10: [1.0, 2.0] useSearchPruning: [No] diff --git a/jvector-examples/yaml-configs/ash-pq/run-queued.yml b/jvector-examples/yaml-configs/ash-pq/run-queued.yml index 8f98a5d0e..65049e295 100644 --- a/jvector-examples/yaml-configs/ash-pq/run-queued.yml +++ b/jvector-examples/yaml-configs/ash-pq/run-queued.yml @@ -1,9 +1,6 @@ # Used by standalone BenchYAML via -Djvector.bench.runConfig=. yamlSchemaVersion: 1 onDiskIndexVersion: 7 -# Optional ASH precision; omission preserves JVM settings and the auto default. -# ash: -# queryProjection: int8 # auto, fp16, fp32, int8; explicit -D overrides YAML queryExecution: mode: queued benchmarks: diff --git a/jvector-examples/yaml-configs/index-parameters/ada002-1M.yml b/jvector-examples/yaml-configs/index-parameters/ada002-1M.yml index f57ba9beb..f68088ff1 100644 --- a/jvector-examples/yaml-configs/index-parameters/ada002-1M.yml +++ b/jvector-examples/yaml-configs/index-parameters/ada002-1M.yml @@ -29,6 +29,7 @@ construction: useSavedIndexIfExists: No # If yes, the system will attempt to locate a cached copy of the index instead of constructing search: + queryProjection: auto # auto, fp16, fp32, int8; explicit -D overrides YAML topKOverquery: 10: [1.0, 2.0, 5.0, 10.0] # 100: [1.0, 2.0] diff --git a/jvector-examples/yaml-configs/index-parameters/cap-10M.yml b/jvector-examples/yaml-configs/index-parameters/cap-10M.yml index 99166a920..5d5e6e2c7 100644 --- a/jvector-examples/yaml-configs/index-parameters/cap-10M.yml +++ b/jvector-examples/yaml-configs/index-parameters/cap-10M.yml @@ -29,6 +29,7 @@ construction: useSavedIndexIfExists: No search: + queryProjection: auto # auto, fp16, fp32, int8; explicit -D overrides YAML topKOverquery: 10: [1.0] useSearchPruning: [No] diff --git a/jvector-examples/yaml-configs/index-parameters/cap-1M.yml b/jvector-examples/yaml-configs/index-parameters/cap-1M.yml index 892b8f34d..f5a4485a5 100644 --- a/jvector-examples/yaml-configs/index-parameters/cap-1M.yml +++ b/jvector-examples/yaml-configs/index-parameters/cap-1M.yml @@ -29,6 +29,7 @@ construction: useSavedIndexIfExists: No # If yes, the system will attempt to locate a cached copy of the index instead of constructing search: + queryProjection: auto # auto, fp16, fp32, int8; explicit -D overrides YAML topKOverquery: 10: [1.0, 2.0, 5.0, 10.0] # 100: [1.0, 2.0] diff --git a/jvector-examples/yaml-configs/index-parameters/cap-6M.yml b/jvector-examples/yaml-configs/index-parameters/cap-6M.yml index 0e588f5f7..5f33db39f 100644 --- a/jvector-examples/yaml-configs/index-parameters/cap-6M.yml +++ b/jvector-examples/yaml-configs/index-parameters/cap-6M.yml @@ -28,6 +28,7 @@ construction: useSavedIndexIfExists: Yes # If yes, the system will attempt to locate a cached copy of the index instead of constructing search: + queryProjection: auto # auto, fp16, fp32, int8; explicit -D overrides YAML topKOverquery: 10: [1.00, 1.25, 1.56, 1.95, 2.44, 3.05, 3.81, 4.77, 5.96, 7.45, 9.31, 11.64] # 100: [1.0] diff --git a/jvector-examples/yaml-configs/index-parameters/cohere-10M.yml b/jvector-examples/yaml-configs/index-parameters/cohere-10M.yml index 3a555acc0..bfabfdd4d 100644 --- a/jvector-examples/yaml-configs/index-parameters/cohere-10M.yml +++ b/jvector-examples/yaml-configs/index-parameters/cohere-10M.yml @@ -28,6 +28,7 @@ construction: useSavedIndexIfExists: Yes # If yes, the system will attempt to locate a cached copy of the index instead of constructing search: + queryProjection: auto # auto, fp16, fp32, int8; explicit -D overrides YAML topKOverquery: 10: [1.00, 1.25, 1.56, 1.95, 2.44, 3.05, 3.81, 4.77, 5.96, 7.45, 9.31, 11.64] # 100: [1.0] diff --git a/jvector-examples/yaml-configs/index-parameters/cohere-english-v3-10M.yml b/jvector-examples/yaml-configs/index-parameters/cohere-english-v3-10M.yml index c803d8264..c641d7747 100644 --- a/jvector-examples/yaml-configs/index-parameters/cohere-english-v3-10M.yml +++ b/jvector-examples/yaml-configs/index-parameters/cohere-english-v3-10M.yml @@ -29,6 +29,7 @@ construction: useSavedIndexIfExists: No search: + queryProjection: auto # auto, fp16, fp32, int8; explicit -D overrides YAML topKOverquery: 10: [1.0] useSearchPruning: [No] diff --git a/jvector-examples/yaml-configs/index-parameters/cohere-english-v3-1M.yml b/jvector-examples/yaml-configs/index-parameters/cohere-english-v3-1M.yml index d9b2b608c..e23035817 100644 --- a/jvector-examples/yaml-configs/index-parameters/cohere-english-v3-1M.yml +++ b/jvector-examples/yaml-configs/index-parameters/cohere-english-v3-1M.yml @@ -30,6 +30,7 @@ construction: useSavedIndexIfExists: No # If yes, the system will attempt to locate a cached copy of the index instead of constructing search: + queryProjection: auto # auto, fp16, fp32, int8; explicit -D overrides YAML topKOverquery: 10: [1.0, 2.0, 5.0, 10.0] # 100: [1.0, 2.0] diff --git a/jvector-examples/yaml-configs/index-parameters/default.yml b/jvector-examples/yaml-configs/index-parameters/default.yml index 6950a42b6..0eb58996e 100644 --- a/jvector-examples/yaml-configs/index-parameters/default.yml +++ b/jvector-examples/yaml-configs/index-parameters/default.yml @@ -31,6 +31,7 @@ construction: useSavedIndexIfExists: No # If yes, the system will attempt to locate a cached copy of the index instead of constructing search: + queryProjection: auto # auto, fp16, fp32, int8; explicit -D overrides YAML topKOverquery: 10: [1.0, 2.0, 5.0, 10.0] # 100: [1.0, 2.0] diff --git a/jvector-examples/yaml-configs/index-parameters/dpr-gemma-10M.yml b/jvector-examples/yaml-configs/index-parameters/dpr-gemma-10M.yml index cd920f05a..43811df46 100644 --- a/jvector-examples/yaml-configs/index-parameters/dpr-gemma-10M.yml +++ b/jvector-examples/yaml-configs/index-parameters/dpr-gemma-10M.yml @@ -28,6 +28,7 @@ construction: useSavedIndexIfExists: No search: + queryProjection: auto # auto, fp16, fp32, int8; explicit -D overrides YAML topKOverquery: 10: [1.0] useSearchPruning: [No] diff --git a/jvector-examples/yaml-configs/index-parameters/dpr-gemma-1M.yml b/jvector-examples/yaml-configs/index-parameters/dpr-gemma-1M.yml index b418c2bb5..089b235ec 100644 --- a/jvector-examples/yaml-configs/index-parameters/dpr-gemma-1M.yml +++ b/jvector-examples/yaml-configs/index-parameters/dpr-gemma-1M.yml @@ -28,6 +28,7 @@ construction: useSavedIndexIfExists: Yes # If yes, the system will attempt to locate a cached copy of the index instead of constructing search: + queryProjection: auto # auto, fp16, fp32, int8; explicit -D overrides YAML topKOverquery: 10: [1.00] # 100: [1.0] diff --git a/jvector-examples/yaml-configs/index-parameters/esmc-600-1M.yml b/jvector-examples/yaml-configs/index-parameters/esmc-600-1M.yml index 648cd29ce..91e87d0a9 100644 --- a/jvector-examples/yaml-configs/index-parameters/esmc-600-1M.yml +++ b/jvector-examples/yaml-configs/index-parameters/esmc-600-1M.yml @@ -28,6 +28,7 @@ construction: useSavedIndexIfExists: Yes # If yes, the system will attempt to locate a cached copy of the index instead of constructing search: + queryProjection: auto # auto, fp16, fp32, int8; explicit -D overrides YAML topKOverquery: 10: [1.00, 1.25, 1.56, 1.95, 2.44, 3.05, 3.81, 4.77, 5.96, 7.45, 9.31, 11.64] # 100: [1.0] diff --git a/jvector-examples/yaml-configs/index-parameters/esmc-600-6M.yml b/jvector-examples/yaml-configs/index-parameters/esmc-600-6M.yml index 1321eecb2..b15c4e8e2 100644 --- a/jvector-examples/yaml-configs/index-parameters/esmc-600-6M.yml +++ b/jvector-examples/yaml-configs/index-parameters/esmc-600-6M.yml @@ -28,6 +28,7 @@ construction: useSavedIndexIfExists: Yes # If yes, the system will attempt to locate a cached copy of the index instead of constructing search: + queryProjection: auto # auto, fp16, fp32, int8; explicit -D overrides YAML topKOverquery: 10: [1.00, 1.25, 1.56, 1.95, 2.44, 3.05, 3.81, 4.77, 5.96, 7.45, 9.31, 11.64] # 100: [1.0] diff --git a/jvector-examples/yaml-configs/index-parameters/gecko-100k.yml b/jvector-examples/yaml-configs/index-parameters/gecko-100k.yml index 831c7f8a0..6d9ca6576 100644 --- a/jvector-examples/yaml-configs/index-parameters/gecko-100k.yml +++ b/jvector-examples/yaml-configs/index-parameters/gecko-100k.yml @@ -28,6 +28,7 @@ construction: useSavedIndexIfExists: No # If yes, the system will attempt to locate a cached copy of the index instead of constructing search: + queryProjection: auto # auto, fp16, fp32, int8; explicit -D overrides YAML topKOverquery: 10: [1.0, 2.0, 5.0, 10.0] # 100: [1.0, 2.0] diff --git a/jvector-examples/yaml-configs/index-parameters/mpnet-10M.yml b/jvector-examples/yaml-configs/index-parameters/mpnet-10M.yml index 64c654387..588dbbb02 100644 --- a/jvector-examples/yaml-configs/index-parameters/mpnet-10M.yml +++ b/jvector-examples/yaml-configs/index-parameters/mpnet-10M.yml @@ -24,6 +24,7 @@ construction: useSavedIndexIfExists: Yes search: + queryProjection: auto # auto, fp16, fp32, int8; explicit -D overrides YAML topKOverquery: 10: [1.0, 2.0] useSearchPruning: [No] diff --git a/jvector-examples/yaml-configs/index-parameters/mpnet-1M.yml b/jvector-examples/yaml-configs/index-parameters/mpnet-1M.yml index 72f880722..9c3140b95 100644 --- a/jvector-examples/yaml-configs/index-parameters/mpnet-1M.yml +++ b/jvector-examples/yaml-configs/index-parameters/mpnet-1M.yml @@ -28,6 +28,7 @@ construction: useSavedIndexIfExists: Yes # If yes, the system will attempt to locate a cached copy of the index instead of constructing search: + queryProjection: auto # auto, fp16, fp32, int8; explicit -D overrides YAML topKOverquery: 10: [1.00, 1.25, 1.56, 1.95, 2.44, 3.05, 3.81, 4.77, 5.96, 7.45, 9.31, 11.64] # 100: [1.0] diff --git a/jvector-examples/yaml-configs/index-parameters/openai-v3-small-100k.yml b/jvector-examples/yaml-configs/index-parameters/openai-v3-small-100k.yml index ca8fd0c73..6ea8cdc74 100644 --- a/jvector-examples/yaml-configs/index-parameters/openai-v3-small-100k.yml +++ b/jvector-examples/yaml-configs/index-parameters/openai-v3-small-100k.yml @@ -28,6 +28,7 @@ construction: useSavedIndexIfExists: Yes # If yes, the system will attempt to locate a cached copy of the index instead of constructing search: + queryProjection: auto # auto, fp16, fp32, int8; explicit -D overrides YAML topKOverquery: 10: [1.00, 1.25, 1.56, 1.95, 2.44, 3.05, 3.81, 4.77, 5.96, 7.45, 9.31, 11.64] # 100: [1.0] diff --git a/jvector-examples/yaml-configs/index-parameters/personahub-1M.yml b/jvector-examples/yaml-configs/index-parameters/personahub-1M.yml index 74cbdfc74..42608ede4 100644 --- a/jvector-examples/yaml-configs/index-parameters/personahub-1M.yml +++ b/jvector-examples/yaml-configs/index-parameters/personahub-1M.yml @@ -28,6 +28,7 @@ construction: useSavedIndexIfExists: Yes # If yes, the system will attempt to locate a cached copy of the index instead of constructing search: + queryProjection: auto # auto, fp16, fp32, int8; explicit -D overrides YAML topKOverquery: 10: [1.00, 1.25, 1.56, 1.95, 2.44, 3.05, 3.81, 4.77, 5.96, 7.45, 9.31, 11.64] # 100: [1.0] diff --git a/jvector-examples/yaml-configs/index-parameters/tahoe-x1-1M.yml b/jvector-examples/yaml-configs/index-parameters/tahoe-x1-1M.yml index 8461bca2e..5d277bbf6 100644 --- a/jvector-examples/yaml-configs/index-parameters/tahoe-x1-1M.yml +++ b/jvector-examples/yaml-configs/index-parameters/tahoe-x1-1M.yml @@ -28,6 +28,7 @@ construction: useSavedIndexIfExists: Yes # If yes, the system will attempt to locate a cached copy of the index instead of constructing search: + queryProjection: auto # auto, fp16, fp32, int8; explicit -D overrides YAML topKOverquery: # 10: [1.00, 1.25, 1.56, 1.95, 2.44, 3.05, 3.81, 4.77, 5.96, 7.45, 9.31, 11.64] 10: [1.00, 1.25, 1.56, 1.95, 2.44] diff --git a/jvector-examples/yaml-configs/run-config.yml b/jvector-examples/yaml-configs/run-config.yml index 0c0a9b2a9..e50a915db 100644 --- a/jvector-examples/yaml-configs/run-config.yml +++ b/jvector-examples/yaml-configs/run-config.yml @@ -1,8 +1,5 @@ yamlSchemaVersion: 1 onDiskIndexVersion: 7 -# Optional ASH precision; omission preserves JVM settings and the auto default. -# ash: -# queryProjection: int8 # auto, fp16, fp32, int8; explicit -D overrides YAML benchmarks: throughput: [AVG] latency: [AVG, P999] From 9f3ebaa033c930077280cf450f69052dc2c6d704 Mon Sep 17 00:00:00 2001 From: Ted Willke Date: Fri, 6 Nov 2026 02:50:22 +0000 Subject: [PATCH 156/243] Add introductory fused ASH graph examples for INT8 and FP16 Show public e5-small 100k data loading, ASH training and encoding, symmetric graph construction, fused ASH/NVQ disk storage, and reopened top-ten search. Reuse the encoding scan to collect the NVQ mean and document how to run each self-contained example on supported hardware. Validation: package succeeded; INT8 completed on Ice Lake and FP16 completed on the idle Xeon 6985P-C server. Both built, saved, reopened, and queried the 99,443-vector graph using the public dataset. --- jvector-examples/README.md | 41 +++++++ .../jvector/example/HelloASHWorldFp16.java | 112 ++++++++++++++++++ .../jvector/example/HelloASHWorldInt8.java | 112 ++++++++++++++++++ 3 files changed, 265 insertions(+) create mode 100644 jvector-examples/src/main/java/io/github/jbellis/jvector/example/HelloASHWorldFp16.java create mode 100644 jvector-examples/src/main/java/io/github/jbellis/jvector/example/HelloASHWorldInt8.java diff --git a/jvector-examples/README.md b/jvector-examples/README.md index 46990445b..d213addb4 100644 --- a/jvector-examples/README.md +++ b/jvector-examples/README.md @@ -2,6 +2,47 @@ JVector comes with the following sample programs to try: +### Hello ASH world + +Two self-contained introductions build and query a fused ASH disk index over the +public `e5-small-v2-100k` dataset (downloaded and cached automatically): + +- [HelloASHWorldInt8](src/main/java/io/github/jbellis/jvector/example/HelloASHWorldInt8.java): + INT8 query projection for Intel Ice Lake and other supported INT8 processors. +- [HelloASHWorldFp16](src/main/java/io/github/jbellis/jvector/example/HelloASHWorldFp16.java): + native FP16 query projection for Intel Sapphire Rapids and newer FP16 processors. + +Both teach the same five steps: load vectors, train/encode ASH, construct the graph, +write fused ASH neighborhoods with NVQ reranking vectors, then reopen and search. +They use ASH(b=2,d=192) for 384-dimensional inputs. Stored-code precision (`b`) +is independent of query-projection precision; training and encoding remain FP32. +NVQ's mean is collected during ASH encoding, avoiding another input scan. + +Build the examples with the native provider and oneDNN support described in the +[native build guide](../jvector-native/src/main/native/README.md#recommended-onednn-build-and-package). +From the repository root, using JDK 22 or newer: + +```bash +mvn package -DskipTests +java --add-modules=jdk.incubator.vector --enable-native-access=ALL-UNNAMED -Xmx8g \ + -cp jvector-examples/target/jvector-examples-4.0.3-SNAPSHOT-jar-with-dependencies.jar \ + io.github.jbellis.jvector.example.HelloASHWorldInt8 +java --add-modules=jdk.incubator.vector --enable-native-access=ALL-UNNAMED -Xmx8g \ + -cp jvector-examples/target/jvector-examples-4.0.3-SNAPSHOT-jar-with-dependencies.jar \ + io.github.jbellis.jvector.example.HelloASHWorldFp16 +``` + +Run the program matching your CPU; explicit unsupported precisions fail instead +of silently changing precision. Each saves `ash-int8.graph` or `ash-fp16.graph` +and prints the top ten vector ordinals, similarity scores and visited-node count +for the first held-out query. An optional argument selects a new output path; +existing files are never overwritten. Original vectors are not needed to reopen +the finished index. + +The examples use a single query to introduce the search API. For a service with +concurrent requests, see [bounded query batching and state ownership](../docs/ash-query-integration.md). +For more hardware choices, see [projection precision](../docs/ash-projection-precision.md). + ### Batched file-backed vectors Two hello-world programs use public-catalog `e5-small-v2-100k`, or accept an existing diff --git a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/HelloASHWorldFp16.java b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/HelloASHWorldFp16.java new file mode 100644 index 000000000..dd1c4d1ec --- /dev/null +++ b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/HelloASHWorldFp16.java @@ -0,0 +1,112 @@ +/* + * 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; + +import io.github.jbellis.jvector.disk.ReaderSupplierFactory; +import io.github.jbellis.jvector.example.benchmarks.datasets.DataSets; +import io.github.jbellis.jvector.graph.GraphIndexBuilder; +import io.github.jbellis.jvector.graph.GraphSearcher; +import io.github.jbellis.jvector.graph.ImmutableGraphIndex; +import io.github.jbellis.jvector.graph.VectorMean; +import io.github.jbellis.jvector.graph.disk.OnDiskGraphIndex; +import io.github.jbellis.jvector.graph.disk.OnDiskParallelGraphIndexWriter; +import io.github.jbellis.jvector.graph.disk.OrdinalMapper; +import io.github.jbellis.jvector.graph.disk.feature.FeatureId; +import io.github.jbellis.jvector.graph.disk.feature.FusedASH; +import io.github.jbellis.jvector.graph.disk.feature.NVQ; +import io.github.jbellis.jvector.graph.similarity.BuildScoreProvider; +import io.github.jbellis.jvector.quantization.AsymmetricHashing; +import io.github.jbellis.jvector.quantization.NVQuantization; +import io.github.jbellis.jvector.util.Bits; +import io.github.jbellis.jvector.util.ExplicitThreadLocal; +import io.github.jbellis.jvector.util.PhysicalCoreExecutor; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Map; +import java.util.stream.IntStream; + +/** A first ASH graph: public 100k vectors, compressed disk storage, and top-10 search. + * Uses native FP16 query projection on Intel Sapphire Rapids or newer FP16 hardware. + */ +public class HelloASHWorldFp16 { + public static void main(String[] args) throws Exception { + // Select before loading any model. Training/encoding still use FP32. + System.setProperty("jvector.vectorization_provider", "native"); + System.setProperty("jvector.ash.queryProjection", "fp16"); + Path path = args.length == 0 ? Path.of("ash-fp16.graph") : Path.of(args[0]); + if (Files.exists(path)) throw new IllegalArgumentException("Choose a new index path: " + path); + + // 1. Download/cache a public dataset: normalized 384-dimensional embeddings. + try (var dataset = DataSets.loadDataSet("e5-small-v2-100k").orElseThrow().getDataSet()) { + var base = dataset.getBaseRavv(); + var similarity = dataset.getSimilarityFunction(); + System.out.printf("Loaded %,d vectors, %d dimensions%n", base.size(), base.dimension()); + + // 2. Learn ASH(b=2,d=192): half as many dimensions, 2 stored bits each. + // The API's bit budget includes a small per-vector header (40 bits). + int b = 2, d = base.dimension() / 2; + var ash = AsymmetricHashing.initialize(base, AsymmetricHashing.ITQ, + AsymmetricHashing.HEADER_BITS + b * d, 1, b); + // Collect NVQ's mean during encoding instead of scanning the source again. + var mean = new VectorMean(base.size(), base.dimension()); + var codes = ash.encodeAll(mean.duringScan(base), PhysicalCoreExecutor.pool()); + var nvq = NVQuantization.create(mean.mean(), 1); + + // 3. Construct a hierarchical graph using symmetric ASH code comparisons. + var scores = BuildScoreProvider.ashBuildScoreProvider(similarity, codes); + try (var builder = new GraphIndexBuilder(scores, base.dimension(), + 32, 100, 1.2f, 1.2f, true, true)) { + PhysicalCoreExecutor.pool().submit(() -> + IntStream.range(0, base.size()).parallel().forEach(node -> + builder.addGraphNode(node, scores.searchProviderFor(node)))).join(); + builder.cleanup(); + + // 4. Save neighbor-local ASH blocks plus NVQ vectors for reranking. + // Encode NVQ while the parallel writer emits complete records. + var vectors = base.threadLocalSupplier(); + try (var views = ExplicitThreadLocal.withInitial(() -> builder.getGraph().getView()); + var writer = new OnDiskParallelGraphIndexWriter.Builder(builder.getGraph(), path) + .withParallelWorkerThreads(PhysicalCoreExecutor.getPhysicalCoreCount()) + .withMapper(new OrdinalMapper.IdentityMapper(base.size() - 1)) + .with(new FusedASH(32, ash)).with(new NVQ(nvq)).build()) { + writer.write(Map.of( + FeatureId.FUSED_ASH, node -> new FusedASH.State(views.get(), codes, node), + FeatureId.NVQ_VECTORS, node -> new NVQ.State(nvq.encode(vectors.get().getVector(node))))); + } + } + System.out.println("Saved " + path.toAbsolutePath()); + + // 5. Reopen the self-contained index; the model is stored in its header. + try (var readers = ReaderSupplierFactory.open(path); + var graph = OnDiskGraphIndex.load(readers); + var searcher = new GraphSearcher(graph)) { + var preparer = graph.queryPreparer(similarity).orElseThrow(); + var prepared = preparer.newState(); + preparer.prepare(dataset.getQueryVectors().get(0), prepared); + var view = (ImmutableGraphIndex.ScoringView) searcher.getView(); + // Fused ASH finds candidates; NVQ reranks 20 candidates down to 10. + // One searcher/prepared state per in-flight query; reuse after completion. + var result = searcher.search(view.scoreProviderFor(prepared, true), + 10, 20, 0.0f, 0.0f, Bits.ALL); + System.out.println("Top 10 for query 0 (vector ordinal, similarity score):"); + for (var hit : result.getNodes()) System.out.printf(" %d %.6f%n", hit.node, hit.score); + System.out.println("Visited nodes: " + result.getVisitedCount()); + } + } + } +} diff --git a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/HelloASHWorldInt8.java b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/HelloASHWorldInt8.java new file mode 100644 index 000000000..773f7c5dc --- /dev/null +++ b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/HelloASHWorldInt8.java @@ -0,0 +1,112 @@ +/* + * 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; + +import io.github.jbellis.jvector.disk.ReaderSupplierFactory; +import io.github.jbellis.jvector.example.benchmarks.datasets.DataSets; +import io.github.jbellis.jvector.graph.GraphIndexBuilder; +import io.github.jbellis.jvector.graph.GraphSearcher; +import io.github.jbellis.jvector.graph.ImmutableGraphIndex; +import io.github.jbellis.jvector.graph.VectorMean; +import io.github.jbellis.jvector.graph.disk.OnDiskGraphIndex; +import io.github.jbellis.jvector.graph.disk.OnDiskParallelGraphIndexWriter; +import io.github.jbellis.jvector.graph.disk.OrdinalMapper; +import io.github.jbellis.jvector.graph.disk.feature.FeatureId; +import io.github.jbellis.jvector.graph.disk.feature.FusedASH; +import io.github.jbellis.jvector.graph.disk.feature.NVQ; +import io.github.jbellis.jvector.graph.similarity.BuildScoreProvider; +import io.github.jbellis.jvector.quantization.AsymmetricHashing; +import io.github.jbellis.jvector.quantization.NVQuantization; +import io.github.jbellis.jvector.util.Bits; +import io.github.jbellis.jvector.util.ExplicitThreadLocal; +import io.github.jbellis.jvector.util.PhysicalCoreExecutor; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Map; +import java.util.stream.IntStream; + +/** A first ASH graph: public 100k vectors, compressed disk storage, and top-10 search. + * Uses INT8 query projection on Intel Ice Lake (or another supported INT8 CPU). + */ +public class HelloASHWorldInt8 { + public static void main(String[] args) throws Exception { + // Select before loading any model. Training/encoding still use FP32. + System.setProperty("jvector.vectorization_provider", "native"); + System.setProperty("jvector.ash.queryProjection", "int8"); + Path path = args.length == 0 ? Path.of("ash-int8.graph") : Path.of(args[0]); + if (Files.exists(path)) throw new IllegalArgumentException("Choose a new index path: " + path); + + // 1. Download/cache a public dataset: normalized 384-dimensional embeddings. + try (var dataset = DataSets.loadDataSet("e5-small-v2-100k").orElseThrow().getDataSet()) { + var base = dataset.getBaseRavv(); + var similarity = dataset.getSimilarityFunction(); + System.out.printf("Loaded %,d vectors, %d dimensions%n", base.size(), base.dimension()); + + // 2. Learn ASH(b=2,d=192): half as many dimensions, 2 stored bits each. + // The API's bit budget includes a small per-vector header (40 bits). + int b = 2, d = base.dimension() / 2; + var ash = AsymmetricHashing.initialize(base, AsymmetricHashing.ITQ, + AsymmetricHashing.HEADER_BITS + b * d, 1, b); + // Collect NVQ's mean during encoding instead of scanning the source again. + var mean = new VectorMean(base.size(), base.dimension()); + var codes = ash.encodeAll(mean.duringScan(base), PhysicalCoreExecutor.pool()); + var nvq = NVQuantization.create(mean.mean(), 1); + + // 3. Construct a hierarchical graph using symmetric ASH code comparisons. + var scores = BuildScoreProvider.ashBuildScoreProvider(similarity, codes); + try (var builder = new GraphIndexBuilder(scores, base.dimension(), + 32, 100, 1.2f, 1.2f, true, true)) { + PhysicalCoreExecutor.pool().submit(() -> + IntStream.range(0, base.size()).parallel().forEach(node -> + builder.addGraphNode(node, scores.searchProviderFor(node)))).join(); + builder.cleanup(); + + // 4. Save neighbor-local ASH blocks plus NVQ vectors for reranking. + // Encode NVQ while the parallel writer emits complete records. + var vectors = base.threadLocalSupplier(); + try (var views = ExplicitThreadLocal.withInitial(() -> builder.getGraph().getView()); + var writer = new OnDiskParallelGraphIndexWriter.Builder(builder.getGraph(), path) + .withParallelWorkerThreads(PhysicalCoreExecutor.getPhysicalCoreCount()) + .withMapper(new OrdinalMapper.IdentityMapper(base.size() - 1)) + .with(new FusedASH(32, ash)).with(new NVQ(nvq)).build()) { + writer.write(Map.of( + FeatureId.FUSED_ASH, node -> new FusedASH.State(views.get(), codes, node), + FeatureId.NVQ_VECTORS, node -> new NVQ.State(nvq.encode(vectors.get().getVector(node))))); + } + } + System.out.println("Saved " + path.toAbsolutePath()); + + // 5. Reopen the self-contained index; the model is stored in its header. + try (var readers = ReaderSupplierFactory.open(path); + var graph = OnDiskGraphIndex.load(readers); + var searcher = new GraphSearcher(graph)) { + var preparer = graph.queryPreparer(similarity).orElseThrow(); + var prepared = preparer.newState(); + preparer.prepare(dataset.getQueryVectors().get(0), prepared); + var view = (ImmutableGraphIndex.ScoringView) searcher.getView(); + // Fused ASH finds candidates; NVQ reranks 20 candidates down to 10. + // One searcher/prepared state per in-flight query; reuse after completion. + var result = searcher.search(view.scoreProviderFor(prepared, true), + 10, 20, 0.0f, 0.0f, Bits.ALL); + System.out.println("Top 10 for query 0 (vector ordinal, similarity score):"); + for (var hit : result.getNodes()) System.out.printf(" %d %.6f%n", hit.node, hit.score); + System.out.println("Visited nodes: " + result.getVisitedCount()); + } + } + } +} From 9aebbefba5dcada8df552fbc7bb8628ad8a6fb41 Mon Sep 17 00:00:00 2001 From: Ted Willke Date: Fri, 6 Nov 2026 05:11:21 +0000 Subject: [PATCH 157/243] Demonstrate synchronous and queued ASH searches in hello examples Add a shared host-side BatchingExecutor adapter with request-owned preparation, worker-owned searchers, completion futures, bounded admission and orderly shutdown. Show both search APIs in the INT8 and FP16 introductions. Validation: both examples compile and package. The INT8 example freshly built and reopened the public 100k graph, completed eight queued requests, and returned identical top-ten results and visited counts for query zero through both APIs. FP16 execution awaits availability of the separate FP16 server. --- jvector-examples/README.md | 25 +++- .../jvector/example/HelloASHQueuedSearch.java | 112 ++++++++++++++++++ .../jvector/example/HelloASHWorldFp16.java | 12 +- .../jvector/example/HelloASHWorldInt8.java | 12 +- 4 files changed, 153 insertions(+), 8 deletions(-) create mode 100644 jvector-examples/src/main/java/io/github/jbellis/jvector/example/HelloASHQueuedSearch.java diff --git a/jvector-examples/README.md b/jvector-examples/README.md index d213addb4..b7a8486bc 100644 --- a/jvector-examples/README.md +++ b/jvector-examples/README.md @@ -12,8 +12,9 @@ public `e5-small-v2-100k` dataset (downloaded and cached automatically): - [HelloASHWorldFp16](src/main/java/io/github/jbellis/jvector/example/HelloASHWorldFp16.java): native FP16 query projection for Intel Sapphire Rapids and newer FP16 processors. -Both teach the same five steps: load vectors, train/encode ASH, construct the graph, -write fused ASH neighborhoods with NVQ reranking vectors, then reopen and search. +Both teach the same six steps: load vectors, train/encode ASH, construct the graph, +write fused ASH neighborhoods with NVQ reranking vectors, reopen and search +synchronously, then submit individual queries through the queued API. They use ASH(b=2,d=192) for 384-dimensional inputs. Stored-code precision (`b`) is independent of query-projection precision; training and encoding remain FP32. NVQ's mean is collected during ASH encoding, avoiding another input scan. @@ -34,13 +35,25 @@ java --add-modules=jdk.incubator.vector --enable-native-access=ALL-UNNAMED -Xmx8 Run the program matching your CPU; explicit unsupported precisions fail instead of silently changing precision. Each saves `ash-int8.graph` or `ash-fp16.graph` -and prints the top ten vector ordinals, similarity scores and visited-node count -for the first held-out query. An optional argument selects a new output path; +and prints top-ten vector ordinals, similarity scores and visited-node counts +for the first held-out query using both synchronous and queued search. An optional +argument selects a new output path; existing files are never overwritten. Original vectors are not needed to reopen the finished index. -The examples use a single query to introduce the search API. For a service with -concurrent requests, see [bounded query batching and state ownership](../docs/ash-query-integration.md). +The synchronous section introduces preparation and graph search for one query. +The queued section submits eight independent queries using two application-assigned +workers, admission 16 and maximum preparation batch 8. The shared +[HelloASHQueuedSearch](src/main/java/io/github/jbellis/jvector/example/HelloASHQueuedSearch.java) +shows the host integration explicitly: compatible-query preparation, worker-owned +searchers, completion futures, admission rejection and orderly shutdown. Callers +submit individual requests; the queue prepares currently waiting compatible work +together, without a fill timer. Batch sizes depend on what is waiting at that moment. +Each request owns its input snapshot and prepared state through completion. + +For clarity, this small example allocates request records and preparation arrays. +Services can reuse bounded storage as described in +[bounded query batching and state ownership](../docs/ash-query-integration.md). For more hardware choices, see [projection precision](../docs/ash-projection-precision.md). ### Batched file-backed vectors diff --git a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/HelloASHQueuedSearch.java b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/HelloASHQueuedSearch.java new file mode 100644 index 000000000..bd3bd98bb --- /dev/null +++ b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/HelloASHQueuedSearch.java @@ -0,0 +1,112 @@ +/* + * 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; + +import io.github.jbellis.jvector.graph.GraphSearcher; +import io.github.jbellis.jvector.graph.ImmutableGraphIndex; +import io.github.jbellis.jvector.graph.SearchResult; +import io.github.jbellis.jvector.graph.disk.OnDiskGraphIndex; +import io.github.jbellis.jvector.graph.similarity.PreparedQuery; +import io.github.jbellis.jvector.util.BatchingExecutor; +import io.github.jbellis.jvector.util.Bits; +import io.github.jbellis.jvector.util.ExplicitThreadLocal; +import io.github.jbellis.jvector.vector.VectorSimilarityFunction; +import io.github.jbellis.jvector.vector.types.VectorFloat; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.Executors; +import java.util.concurrent.RejectedExecutionException; + +/** The small host-side queue adapter shared by the two ASH hello-world programs. */ +final class HelloASHQueuedSearch { + private HelloASHQueuedSearch() {} + + // Inputs and prepared buffers belong to one request until its callback completes. + private static final class Request { + final VectorFloat query; + final PreparedQuery prepared; + final CompletableFuture completion = new CompletableFuture<>(); + SearchResult result; + + Request(VectorFloat query, PreparedQuery prepared) { + this.query = query.copy(); + this.prepared = prepared; + } + } + + static List search(OnDiskGraphIndex graph, VectorSimilarityFunction similarity, + List> queries, int searchCores) throws Exception { + var preparer = graph.queryPreparer(similarity).orElseThrow(); + int maximumBatch = 8, admission = 8 * searchCores; + + // The application supplies execution threads. Each worker owns its searcher. + // Close the queue first (drains callbacks), then searchers, then the executor. + var executor = Executors.newFixedThreadPool(searchCores); + try (var searchers = ExplicitThreadLocal.withInitial(() -> new GraphSearcher(graph)); + var queue = new BatchingExecutor(searchCores, admission, maximumBatch, + executor, new BatchingExecutor.Operation() { + public Object batchKey(Request request) { + return preparer; // Only queries for this same model can share preparation. + } + + public void prepareBatch(List batch) { + // These small arrays keep the example simple; each call owns its arrays. + var inputs = new VectorFloat[batch.size()]; + var states = new PreparedQuery[batch.size()]; + for (int i = 0; i < batch.size(); i++) { + inputs[i] = batch.get(i).query; + states[i] = batch.get(i).prepared; + } + if (batch.size() == 1) preparer.prepare(inputs[0], states[0]); + else preparer.prepareBatch(inputs, states, batch.size()); + } + + public void execute(Request request) { + var searcher = searchers.get(); + var view = (ImmutableGraphIndex.ScoringView) searcher.getView(); + request.result = searcher.search(view.scoreProviderFor(request.prepared, true), + 10, 20, 0.0f, 0.0f, Bits.ALL); + } + + public void complete(Request request, Throwable failure) { + if (failure == null) request.completion.complete(request.result); + else request.completion.completeExceptionally(failure); + } + })) { + var requests = new ArrayList(); + // Submit individual queries. The queue batches currently waiting work; + // it never waits for a timer or requires the caller to assemble a batch. + for (var query : queries) { + var request = new Request(query, preparer.newState()); + if (!queue.trySubmit(request)) { + // Rejected requests remain caller-owned: reject or apply backpressure. + throw new RejectedExecutionException("Query admission is full"); + } + requests.add(request); + } + // Waiting happens on the caller, outside the executor needed to run searches. + // Futures preserve submission order even if callbacks arrive out of order. + var results = new ArrayList(); + for (var request : requests) results.add(request.completion.join()); + return results; + } finally { + executor.shutdown(); // Accepted work was drained by queue.close(), never discarded. + } + } +} diff --git a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/HelloASHWorldFp16.java b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/HelloASHWorldFp16.java index dd1c4d1ec..0a5573776 100644 --- a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/HelloASHWorldFp16.java +++ b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/HelloASHWorldFp16.java @@ -103,9 +103,19 @@ public static void main(String[] args) throws Exception { // One searcher/prepared state per in-flight query; reuse after completion. var result = searcher.search(view.scoreProviderFor(prepared, true), 10, 20, 0.0f, 0.0f, Bits.ALL); - System.out.println("Top 10 for query 0 (vector ordinal, similarity score):"); + System.out.println("Synchronous top 10 for query 0 (vector ordinal, similarity score):"); for (var hit : result.getNodes()) System.out.printf(" %d %.6f%n", hit.node, hit.score); System.out.println("Visited nodes: " + result.getVisitedCount()); + + // 6. The same search through the queued API. The application assigns + // two search workers; the queue uses admission=8C and maximum batch=8. + // Submit eight independent queries without waiting between submissions. + var queued = HelloASHQueuedSearch.search(graph, similarity, + dataset.getQueryVectors().subList(0, 8), 2); + System.out.println("Queued searches completed: " + queued.size()); + System.out.println("Queued top 10 for query 0 (vector ordinal, similarity score):"); + for (var hit : queued.get(0).getNodes()) System.out.printf(" %d %.6f%n", hit.node, hit.score); + System.out.println("Visited nodes: " + queued.get(0).getVisitedCount()); } } } diff --git a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/HelloASHWorldInt8.java b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/HelloASHWorldInt8.java index 773f7c5dc..0aa0a17f1 100644 --- a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/HelloASHWorldInt8.java +++ b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/HelloASHWorldInt8.java @@ -103,9 +103,19 @@ public static void main(String[] args) throws Exception { // One searcher/prepared state per in-flight query; reuse after completion. var result = searcher.search(view.scoreProviderFor(prepared, true), 10, 20, 0.0f, 0.0f, Bits.ALL); - System.out.println("Top 10 for query 0 (vector ordinal, similarity score):"); + System.out.println("Synchronous top 10 for query 0 (vector ordinal, similarity score):"); for (var hit : result.getNodes()) System.out.printf(" %d %.6f%n", hit.node, hit.score); System.out.println("Visited nodes: " + result.getVisitedCount()); + + // 6. The same search through the queued API. The application assigns + // two search workers; the queue uses admission=8C and maximum batch=8. + // Submit eight independent queries without waiting between submissions. + var queued = HelloASHQueuedSearch.search(graph, similarity, + dataset.getQueryVectors().subList(0, 8), 2); + System.out.println("Queued searches completed: " + queued.size()); + System.out.println("Queued top 10 for query 0 (vector ordinal, similarity score):"); + for (var hit : queued.get(0).getNodes()) System.out.printf(" %d %.6f%n", hit.node, hit.score); + System.out.println("Visited nodes: " + queued.get(0).getVisitedCount()); } } } From 090faaac6bfd52d1a28a0e4a84862df2c97d481b Mon Sep 17 00:00:00 2001 From: Ted Willke Date: Fri, 6 Nov 2026 17:55:05 +0000 Subject: [PATCH 158/243] Preserve incremental native builds across Maven phases --- jvector-native/src/main/native/src/build_native_lib.sh | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/jvector-native/src/main/native/src/build_native_lib.sh b/jvector-native/src/main/native/src/build_native_lib.sh index a3b067a6d..4fcc19a6c 100755 --- a/jvector-native/src/main/native/src/build_native_lib.sh +++ b/jvector-native/src/main/native/src/build_native_lib.sh @@ -123,10 +123,10 @@ build_native() { rm -rf "${OUT_SO}" - # Older Meson versions only accept --wipe for an already configured build. + # Reuse configured builds so repeated Maven phases keep incremental native outputs. local setup_options=() if [[ -f "${BUILD_DIR}/meson-private/coredata.dat" ]]; then - setup_options+=(--wipe) + setup_options+=(--reconfigure) fi if [ "${ARCH}" == "${HOST_ARCH}" ]; then From bac007edb3bfe3e1553ca282450cdbfe8b0a71de Mon Sep 17 00:00:00 2001 From: Ted Willke Date: Sat, 7 Nov 2026 20:03:56 +0000 Subject: [PATCH 159/243] Add BenchYAML configuration guide --- README.md | 3 +- docs/ash-configuration.md | 56 +++------- docs/ash-pq-benchmark.md | 18 +-- docs/ash-projection-precision.md | 27 ++--- docs/ash-query-integration.md | 6 +- docs/benchmarking.md | 20 ++-- docs/benchyaml-guide.md | 186 +++++++++++++++++++++++++++++++ docs/deferred-batch-writer.md | 14 +-- 8 files changed, 242 insertions(+), 88 deletions(-) create mode 100644 docs/benchyaml-guide.md diff --git a/README.md b/README.md index 2c04f433e..c5076be44 100644 --- a/README.md +++ b/README.md @@ -217,7 +217,8 @@ To run Sift/Bench without the JVM vector module available, you can use the follo The `... -Pjdk11` invocations will also work with `JAVA_HOME` pointing at a Java 11 installation. -For more information on running benchmarks, go through [docs/benchmarking.md](./docs/benchmarking.md). +See [How to run benchmarks](./docs/benchmarking.md) for dataset setup and commands, and the +[BenchYAML Guide](./docs/benchyaml-guide.md) for construction and search configuration. To release, configure `~/.m2/settings.xml` to point to OSSRH and run `mvn -Prelease clean deploy`. diff --git a/docs/ash-configuration.md b/docs/ash-configuration.md index 77bf8ee64..2b351b59b 100644 --- a/docs/ash-configuration.md +++ b/docs/ash-configuration.md @@ -18,8 +18,8 @@ limitations under the License. ASH has independent settings for stored codes, construction, query projection, and query execution. This reference covers library parameters and BenchYAML -controls. A recipe supplies its own graph/compression parameters; its chosen -numbers are not universal library defaults. +controls. The [BenchYAML Guide](benchyaml-guide.md) defines the index YAML +fields. Recipes specify their own graph and compression values. Set JVM properties before the affected model, provider or executor is initialized. Query views and pools are cached; changing a property later does not reconfigure @@ -28,8 +28,8 @@ existing objects. CPU-count properties do not pin threads. ## Stored codes and training BenchYAML puts these under `compression[].parameters` with `type: ASH`. -Construction and search compression occupy separate YAML sections. A fused ASH -graph reuses its stored codes for search, without a second search encoding. +Construction compression controls graph scoring. Search compression selects +the codes stored with a fused ASH graph. | Parameter | Default / requirement | Optional values and definition | | --- | --- | --- | @@ -50,7 +50,9 @@ change the trained model, base encoding, stored-code layout or index format. ## Graph construction -BenchYAML defaults to **`symmetric-all`**. To restore the earlier mixed policy: +BenchYAML selects construction scoring through `compression[].scoring` on +an ASH construction entry. Omission uses the JVM policy, which defaults to +`symmetric-all`. For example, this JVM property selects mixed scoring: ```text -Djvector.bench.ashConstruction=symmetric @@ -92,16 +94,9 @@ asymmetric construction, it can affect insertion too. ### BenchYAML precision selection -The index-parameters YAML can select the same precision under `search`: - -```yaml -search: - queryProjection: int8 # auto, fp16, fp32, or int8 -``` - -An explicit `-Djvector.ash.queryProjection=...` overrides YAML. Omitting the -setting preserves the existing JVM setting or `auto` default. Use one precision -per BenchYAML JVM; conflicting ASH selections in one invocation are rejected. +Set `queryProjection` in the `parameters` map of an ASH compression entry. +See [ASH query parameters](benchyaml-guide.md#ash-query-parameters) for values +and JVM precedence. ASH CSV rows record `ash_query_projection_requested` and `ash_query_projection_effective`; the latter is read from the prepared view, with `fp16+fp32` indicating observed automatic FP32 range fallback. Non-ASH @@ -116,7 +111,6 @@ precision. See the [precision guide](ash-projection-precision.md#benchyaml-runs) | `jvector.enable_native_vectorization` | `true` | `true` / `false`; automatic native selection. Explicit provider selection overrides it. Deprecated `jvector.experimental.enable_native_vectorization` is a lower-precedence alias. | | `jvector.physical_core_count` | OS topology detection bounded by available processors; fallback heuristic | Positive physical-core budget for JVector's bounded pool and queued workers. Construction and queries can use separate budgets/processes. | | `-XX:ActiveProcessorCount` | JVM/container detection | Positive processor count exposed to the JVM; coordinate with allocated physical cores. Does not set affinity. | -| `jvector.parallel_writer` | `true` in BenchYAML | `true` / `false`; parallel graph serialization, with deferred feature production. Separate from an application's `GraphIndexBuilderConfig` selection. | | `--add-modules=jdk.incubator.vector` | Not added by the application | Enables the Vector API module needed by SIMD providers on supported runtimes. | | `--enable-native-access=ALL-UNNAMED` | JVM native-access policy | Native access for classpath deployments. Named-module deployments should enable the relevant module. | | `-Xms`, `-Xmx` | JVM ergonomics | Initial / maximum heap; account for resident input, model and construction memory. | @@ -132,38 +126,22 @@ are deployment choices. ## Graph and search parameters in BenchYAML -Index YAML supplies these grids explicitly. Recipe values are choices, not -omitted-field library defaults. - -| YAML field | Values / definition | -| --- | --- | -| `construction.outDegree` | Positive graph degree grid (`M`); adjacency capacity and graph size. | -| `construction.efConstruction` | Positive construction search-width grid; work spent finding neighbors. | -| `construction.neighborOverflow` | Neighbor-list overflow factor before pruning. | -| `construction.addHierarchy` | `Yes` / `No`; upper graph layers. | -| `construction.refineFinalGraph` | `Yes` / `No`; final graph refinement. | -| `construction.fusedGraph` | `Yes` / `No`; store search codes with graph neighborhoods. | -| `construction.compression` | Build quantizer definitions, including ASH parameters above. | -| `construction.reranking` | `NVQ` or `FP`; accurate reranking / inline vector representation. | -| `construction.useSavedIndexIfExists` | `Yes` / `No`; reuse matching cached graph. Change cache directories when changing construction policy. | -| `search.topKOverquery` | Final `topK` mapped to rerank multipliers; `10: [1.0, 2.0]` requests 10 or 20 candidates before returning 10 results. | -| `search.useSearchPruning` | `Yes` / `No`; traversal pruning. | -| `search.compression` | Search quantizer definitions for non-fused indexes. Fused ASH reuses graph codes. | -| `yamlSchemaVersion`, `onDiskIndexVersion` | Explicit configuration schema and index format; match the supported loader/writer. | +Use the [BenchYAML Guide](benchyaml-guide.md) for index type, graph parameters, +writer selection, compression, and reranking. ## Query delivery and measurement controls -These controls belong to BenchYAML. A database integration owns its admission -policy and executors; see [ASH query callers and CPU budgeting](ash-query-integration.md). +Set BenchYAML query delivery and batch limits in the +[ASH search parameters](benchyaml-guide.md#ash-query-parameters). Database +integrations select their own admission policy and executors. See +[ASH query callers and CPU budgeting](ash-query-integration.md). | Setting | Default | Optional values / definition | | --- | --- | --- | -| Run YAML `queryExecution.mode` | `direct` | `direct`: synchronous callers. `queued`: bounded, opportunistically batched preparation and independent graph searches. | -| Run YAML `queryExecution.maxBatchSize` | `8` | Positive queued batch limit. With `C` workers, admission is `max(8, maxBatchSize) * C`. Eight gives admission `8C`, without a batching timer. | | `jvector.bench.queryThreads` | `0` | Direct caller count: `0` uses common pool, `1` is serial, larger positive values use a dedicated caller pool. Does not set queued worker count. | | `jvector.bench.verifyConcurrentResults` | `false` | `true` / `false`; validate concurrent results against reference results. | | `jvector.bench.throughputMinSeconds` | `1.0` | Minimum throughput sample duration in seconds. | -| `jvector.bench.runConfig` | `jvector-examples/yaml-configs/run-config.yml` | Run YAML path: execution, measured benchmarks, console and CSV fields. | +| `jvector.bench.runConfig` | `jvector-examples/yaml-configs/run-config.yml` | Run YAML path for measured benchmarks, console and CSV fields. | | `jvector.bench.sourceRef` | Unset | Source identity for provenance; no numerical effect. | Run YAML `benchmarks`, `console` and `logging` select computed/reported throughput, diff --git a/docs/ash-pq-benchmark.md b/docs/ash-pq-benchmark.md index 852b28829..4dd42488b 100644 --- a/docs/ash-pq-benchmark.md +++ b/docs/ash-pq-benchmark.md @@ -16,11 +16,11 @@ limitations under the License. # Reproducing fused PQ versus fused ASH with BenchYAML -BenchYAML's default ASH construction mode is `symmetric-all`. The JVM property -`-Djvector.bench.ashConstruction=symmetric` restores the earlier mixed policy. -Use fresh graph caches when changing construction mode. See the general -[ASH configuration and defaults guide](ash-configuration.md) for parameters, -supported values, scope and defaults. +This comparison uses symmetric ASH construction. Use fresh graph caches +when comparing construction modes. The [BenchYAML Guide](benchyaml-guide.md) +describes the index configuration. See +[ASH configuration and defaults](ash-configuration.md) for model and runtime +settings. This comparison uses the branch's ordinary packaged JVector code and `io.github.jbellis.jvector.example.BenchYAML`. The fixed index configurations, @@ -33,10 +33,10 @@ Projection settings and processor recommendations are documented in [Choosing ASH query-projection precision](ash-projection-precision.md). An explicitly requested INT8/FP32 comparison is a precision override to the FP16 benchmark configuration below; record it with the command and CSV evidence. -BenchYAML also accepts `search.queryProjection` (`auto`, `fp16`, `fp32`, `int8`) in -the index-parameters YAML. An explicit JVM precision property overrides YAML; omission keeps -the existing defaults. ASH CSV rows record `ash_query_projection_requested` and -`ash_query_projection_effective`; see the precision guide for fallback labels. +Set `queryProjection` in the ASH compression parameters. The +[BenchYAML Guide](benchyaml-guide.md#ash-query-parameters) lists the values and +JVM precedence. ASH CSV rows record `ash_query_projection_requested` and +`ash_query_projection_effective`. The precision guide explains fallback labels. ## Build the checkout diff --git a/docs/ash-projection-precision.md b/docs/ash-projection-precision.md index 2234deecf..76f068191 100644 --- a/docs/ash-projection-precision.md +++ b/docs/ash-projection-precision.md @@ -63,20 +63,10 @@ after initialization does not reconfigure an existing view. ## BenchYAML runs -Select precision under `search` in the **index-parameters YAML**: - -```yaml -search: - queryProjection: int8 # auto, fp16, fp32, or int8 -``` - -For example, edit `jvector-examples/yaml-configs/index-parameters/mpnet-10M.yml`. -Run-config continues to select data collection and reporting. -An explicit `-Djvector.ash.queryProjection=...` overrides the YAML value. When -neither is supplied, the existing `auto` policy applies. Omitting `search.queryProjection` does -not change existing runs. Unsupported explicit selections still fail clearly. -Use one precision per BenchYAML JVM; start a new run after changing it. -Conflicting ASH precisions across index configurations in one invocation are rejected. +Set `queryProjection` in the `parameters` map of an ASH compression entry. +The [BenchYAML Guide](benchyaml-guide.md#ash-query-parameters) defines the +accepted values. An explicit `-Djvector.ash.queryProjection=...` overrides YAML. +The default is `auto`. Use one projection mode per JVM. `experiments.csv` adds two columns for ASH cases: @@ -95,12 +85,11 @@ BenchYAML defaults to `symmetric-all` construction: insertion and diversity use encoded symmetric scoring, without dense query projection for insertion. Query precision therefore affects application queries, not insertion under this default. -Use `-Djvector.bench.ashConstruction=symmetric` to restore mixed construction: -insertion projects the original vector as an asymmetric query, while diversity -uses symmetric scoring. Query precision can then affect construction too. +Select `scoring: mixed` on the ASH construction entry to use asymmetric +insertion and symmetric diversity scoring. Query projection then applies +during insertion. Library callers explicitly choose an encoded node score provider or an -original-vector insertion overload; the BenchYAML flag does not globally -reconfigure library builders. See [ASH configuration and defaults](ash-configuration.md#graph-construction). +original-vector insertion overload for their graph builders. See [ASH configuration and defaults](ash-configuration.md#graph-construction). Neither construction mode changes training or base-encoding precision. To measure query-projection effects independently, query the same saved graph diff --git a/docs/ash-query-integration.md b/docs/ash-query-integration.md index aff0f5d16..6a0d65672 100644 --- a/docs/ash-query-integration.md +++ b/docs/ash-query-integration.md @@ -30,9 +30,9 @@ See [Choosing ASH query-projection precision](ash-projection-precision.md) for processor-family recommendations and projection settings. The [ASH configuration reference](ash-configuration.md) lists model parameters, construction modes, runtime flags and their defaults. -BenchYAML researchers can select `search.queryProjection` in the index-parameters YAML; the -precision guide describes JVM overrides and the requested/effective precision -columns in `experiments.csv`. +The [BenchYAML Guide](benchyaml-guide.md#ash-query-parameters) describes +projection and query delivery settings. The precision guide explains the +requested and effective precision columns in `experiments.csv`. ## Recommended asynchronous allocation diff --git a/docs/benchmarking.md b/docs/benchmarking.md index 39c52465a..79c98f032 100644 --- a/docs/benchmarking.md +++ b/docs/benchmarking.md @@ -1,6 +1,7 @@ # How to run benchmarks -JVector comes with a built-in benchmarking system in `jvector-examples/.../BenchYAML.java`. +JVector runs dataset benchmarks with BenchYAML. See the +[BenchYAML Guide](benchyaml-guide.md) for the construction and search fields. To run a benchmark - Decide which dataset(s) you want to benchmark. A dataset consists of @@ -52,13 +53,14 @@ some-other-category: You can specify a custom set of a parameters for any given dataset by creating a file called `.yml`, with `` replaced by the actual name of the dataset. This is the same as the identifier used in `datasets.yml`. The format of this file is exactly the same as `default.yml`. -Refer to `default.yml` for a list of all options. +See the [BenchYAML Guide](benchyaml-guide.md) for the field reference and an +example index configuration. Most parameters can be specified as an array. For these parameters, a separate benchmark is run for each value of the parameter. If multiple parameters are specified as arrays, a benchmark is run for each combination (i.e. taking the Cartesian product). For example: ```yaml construction: - M: [32, 64] - ef: [100, 200] + outDegree: [32, 64] + efConstruction: [100, 200] ``` will build and benchmark four graphs, one for each combination of M and ef in {(32, 100), (64, 100), (32, 200), (64, 200)}. This is particularly useful when running a Grid search to identify the best performing parameters. @@ -118,11 +120,11 @@ For remote datasets, use `base_url` to specify where files should be downloaded ## Final index serialization -BenchYAML and AutoBenchYAML default to parallel complete-record serialization -(default batch size 64) after graph cleanup. `-Djvector.parallel_writer=false` -selects the sequential path, which encodes FP/NVQ features during insertion. -Dataset prefetching is a separate option and is not required. See -[Deferred batch serialization](deferred-batch-writer.md) for activation and caller examples. +For an on-disk index, set `construction.writerMode` to `sequential` or +`parallel` in the index parameters. Both writers serialize the completed graph +after cleanup. The parallel writer defaults to 64 records per batch. See the +[BenchYAML Guide](benchyaml-guide.md#construction) for configuration and +[Deferred batch serialization](deferred-batch-writer.md) for writer details. ## Fused PQ and ASH comparison diff --git a/docs/benchyaml-guide.md b/docs/benchyaml-guide.md new file mode 100644 index 000000000..8fd201f57 --- /dev/null +++ b/docs/benchyaml-guide.md @@ -0,0 +1,186 @@ + + +# BenchYAML Guide + +BenchYAML uses dataset configuration to build an index and measure search. +Each case produces one index. Construction scoring determines the graph +topology, search scoring determines how queries traverse that graph, and +reranking optionally rescores the candidates. + +## Configuration files + +- [Index parameters](../jvector-examples/yaml-configs/index-parameters) describe + construction and search for a dataset. A dataset-specific file takes + precedence over `default.yml`. +- [Dataset catalogs](../jvector-examples/yaml-configs/dataset-catalogs) locate + the base vectors, query vectors, and ground truth. + [Dataset metadata](../jvector-examples/yaml-configs/dataset-metadata.yml) + supplies the similarity function. Vector types come from the dataset. +- [Run configuration](../jvector-examples/yaml-configs/run-config.yml) selects + which measurements to compute and which to display or log. + +See [How to run benchmarks](benchmarking.md) for dataset setup and commands. + +## Construction + +Choose `inMemory` or `onDisk` for the case. To measure both index forms, use +separate cases with matching graph settings. The graph settings and +`fusedGraph` are lists of sweep values. `useSavedIndexIfExists` is a scalar. +Each `compression` list entry has a `type` and its own settings. + +| Field | Values | Description | +| --- | --- | --- | +| `type` | `inMemory`, `onDisk` | `inMemory` keeps the graph in memory for search. `onDisk` serializes the completed graph and reopens it for search. | +| `writerMode` | `sequential`, `parallel` | Selects the writer after graph cleanup for `onDisk`. Defaults to `parallel`. | +| `useSavedIndexIfExists` | `Yes`, `No` | Controls reuse of a compatible `onDisk` index. A cache hit skips fresh construction and serialization measurements. | +| `outDegree` | Positive integers | Maximum final neighborhood degree, also called M. | +| `efConstruction` | Positive integers | Search width used to find candidates during insertion. | +| `neighborOverflow` | Finite numbers ≥ 1.0 | Temporary neighborhood capacity relative to final degree. | +| `addHierarchy` | `Yes`, `No` | Builds upper graph layers. | +| `refineFinalGraph` | `Yes`, `No` | Refines the graph during final cleanup. | +| `fusedGraph` | `Yes`, `No` | Applies to `onDisk`. `Yes` stores ASH or PQ neighbor codes alongside the graph records. `search.compression` selects the codec. | +| `compression[].type` | `None`, `ASH`, `PQ`, `BQ` | Selects the representation for construction scoring. `None` uses source vectors. | +| `compression[].parameters` | [Quantizer parameters](#quantizer-parameters) | Supplies training and encoding settings for this entry. | +| `compression[].scoring` | `symmetric`, `asymmetric`, `mixed` | Optional for ASH construction. `symmetric` scores encoded vectors during insertion and pruning. `asymmetric` scores source queries against codes for both operations. `mixed` uses asymmetric insertion and symmetric pruning. | +| `reranking.type` | `None`, `FP32`, `NVQ` | `FP32` rescores candidates with source vectors. `NVQ` rescores them with NVQ codes. `None` or omission skips reranking where the scorer and layout allow it. | + +ASH construction uses symmetric scoring by default. When `scoring` is omitted, +the configured ASH construction policy applies. See +[ASH configuration and defaults](ash-configuration.md#graph-construction) +for the JVM setting. + +## Search + +Search uses the index selected by `construction.type`. Changes to stored features +define separate index cases. Search sweeps such as `topKOverquery` use the index +for their case. + +| Field | Values | Description | +| --- | --- | --- | +| `topKOverquery` | Map of positive integer K to lists of finite multipliers ≥ 1 | Required for search. The integer part of K × multiplier is the candidate budget. Return up to K results, with a second scoring pass when configured. | +| `useSearchPruning` | List of `Yes` and `No` values | Enables traversal pruning for supported scorers and similarity functions. | +| `compression[].type` | `None`, `ASH`, `PQ`, `BQ` | Selects the traversal representation. `None` scores source vectors. | +| `compression[].parameters` | [Quantizer parameters](#quantizer-parameters) | Supplies encoding settings for traversal. Reuse matching construction models and codes where possible. | + +For a fused disk case, Grid writes the selected search codes over the completed +topology. The same ASH model can serve construction and search when their +quantizer settings match. + +### ASH query parameters + +Place `queryProjection` in the `parameters` map of an ASH compression entry. +Place `queryExecution` and `maxBatchSize` in that map on an ASH **search** entry. + +| Field | Values | Description | +| --- | --- | --- | +| `queryProjection` | `auto`, `fp32`, `fp16`, `int8` | Optional. `auto` is the default and selects FP16 where supported, with FP32 fallback. `fp32` selects FP32 projection. `fp16` and `int8` require their selected paths. The mode applies to ASH search and asymmetric construction scoring. All ASH entries in one JVM must resolve to one mode. The `jvector.ash.queryProjection` JVM property overrides YAML. | +| `queryExecution` | `direct`, `queued` | `direct` runs searches on the calling threads. `queued` sends searches to workers and batches query preparation when supported. Defaults to `direct`. | +| `maxBatchSize` | Positive integer | Caps the number of queries prepared together in queued mode. Defaults to 8. The value must fit the executor limits at the selected worker count. | + +FP16 projection uses FP32 accumulation and output. INT8 projection uses integer +accumulation and FP32 output. Fully symmetric construction scores encoded +vectors directly. See the [ASH precision guide](ash-projection-precision.md) +for provider and hardware requirements. + +### Validate the index and scorer + +Grid validates the selected scorer, layout, similarity function, and precision +before training or construction. Disk writer, cache, and fused layout settings +apply to `onDisk` cases. Fused disk search requires ASH or PQ search compression. + +With `reranking.type: None` or an omitted reranker, search returns up to K results +ordered by traversal score, including compressed scores where supported. If the +selected scorer or layout requires a reranker, Grid throws an exception during +configuration validation. The fused ASH and PQ scorer setup requires an FP32 or +NVQ scoring feature. + +Grid rejects settings that do not apply to the case and reranking that repeats +the traversal scorer with the same vectors and arithmetic. Explicit precision +requests require support on the selected provider and machine. + +## Quantizer parameters + +The fields below belong to the `parameters` map of their compression entry. +D is the dataset dimension. + +| Quantizer and field | Values | Description | +| --- | --- | --- | +| ASH `optimizer` | `ITQ`, `RANDOM` | Training transform. Required for a new model. | +| ASH `compressionRatio` | 8, 16, 32, 64, 128, 256 | Nominal FP32-to-code ratio. The parser rounds to whole projected dimensions and uses a minimum payload. Specify either `compressionRatio` or `encodedBits`. | +| ASH `encodedBits` | Positive integer bit budget | Includes a 40-bit header. The payload must be divisible by `bitsPerDimension` and yield 1 through D projected dimensions. | +| ASH `bitsPerDimension` | 1–9 in core ASH. 1, 2, or 4 for fused ASH. | Bits per projected dimension in the stored codes. Defaults to 1. | +| ASH `landmarkCount` | 1–256 | Number of landmarks. Defaults to 1. ASH graph scoring uses dot-product similarity. Symmetric and mixed construction require one landmark. | +| PQ `m` | Integer from 1 through D | Number of subspaces. Specify either `m` or `mFactor`. | +| PQ `mFactor` | Positive integer yielding a valid `m` | Chooses the subspace count by integer division of D by `mFactor`. | +| PQ `k` | Integer from 1 through 256 | Centroids per subspace. Defaults to 256. Training requires at least k vectors. Fused PQ requires 256. | +| PQ `centerData` | `"Yes"`, `"No"` | Centers data before encoding. Omission enables centering for Euclidean similarity only. Quote these values in the YAML parameter map. | +| PQ `anisotropicThreshold` | −1.0 ≤ value < 1.0 | Controls anisotropic training on unit-normalized input. The default −1 disables anisotropic weighting. | +| BQ parameters | Omitted | Uses sign codes and Hamming scoring to approximate cosine similarity. | + +NVQ reranking uses the built-in encoder settings. BenchYAML chooses the +subvector count and uses eight bits per dimension. + +## Example index parameters + +These sections describe source-vector graph construction followed by fused ASH +disk search with NVQ reranking on a 768-dimensional dot-product dataset. + +```yaml +construction: + type: onDisk + writerMode: parallel + useSavedIndexIfExists: No + outDegree: [32] + efConstruction: [100] + neighborOverflow: [1.2] + addHierarchy: [Yes] + refineFinalGraph: [Yes] + fusedGraph: [Yes] + compression: + - type: None + reranking: + type: NVQ + +search: + topKOverquery: + 10: [1.0, 2.0] + useSearchPruning: [No] + compression: + - type: ASH + parameters: + optimizer: ITQ + encodedBits: 808 + bitsPerDimension: 2 + landmarkCount: 1 + queryProjection: fp16 + queryExecution: queued + maxBatchSize: 8 +``` + +For a source-vector memory case, select `inMemory`, use `None` compression in +both sections, and omit the disk settings. Use `reranking.type: None` to +measure search with traversal scores alone. + +## Related guides + +- [How to run benchmarks](benchmarking.md) covers dataset setup and invocation. +- [Fused PQ and ASH comparison](ash-pq-benchmark.md) specifies the matched + compression budgets, machine setup, and measurement procedure. +- [ASH configuration and defaults](ash-configuration.md) covers model and + runtime settings. +- [Deferred batch serialization](deferred-batch-writer.md) explains writer + batching and feature production. diff --git a/docs/deferred-batch-writer.md b/docs/deferred-batch-writer.md index b36015074..b2bff17bf 100644 --- a/docs/deferred-batch-writer.md +++ b/docs/deferred-batch-writer.md @@ -104,10 +104,9 @@ remains caller-owned and must outlive the write. ## Dataset prefetching in Grid -When the parallel writer is selected, Grid defers its inline NVQ or FP feature serialization -until after cleanup. Its final serialization suppliers consume worker-local ranges through -`VectorAccess.openRange`. With the sequential writer, which calls suppliers on one -thread, Grid keeps encoding these features on the insertion threads and writing them inline. +For a parallel write, Grid supplies NVQ or FP32 features after cleanup. +Final serialization suppliers consume worker-local ranges through +`VectorAccess.openRange`. Sources implementing the buffered-access capability use that implementation; ordinary sources retain the ordinary-access fallback. Borrowed vectors are consumed before advancing a cursor, and ranges are released after the workers finish. @@ -119,10 +118,9 @@ final write; their existing vector-source and incremental-write APIs remain vali ## Running the benchmark writer paths -BenchYAML and AutoBenchYAML select the parallel writer by default. To use the -sequential writer instead, add `-Djvector.parallel_writer=false` to the Java VM -options. In that mode, Grid encodes FP/NVQ features during insertion and preserves -the earlier inline-write path. No YAML setting is needed. +Select `construction.writerMode` in the +[BenchYAML index parameters](benchyaml-guide.md#construction). The default is +`parallel`. Both writer modes serialize the graph after cleanup. Grid sizes its writer pool from `PhysicalCoreExecutor`; no machine-specific worker count is embedded in this change. Dataset prefetching is a separate optional setting From 3f42a42899f403356c8d326f52ad571375bb2c2d Mon Sep 17 00:00:00 2001 From: Ted Willke Date: Sun, 8 Nov 2026 00:14:00 +0000 Subject: [PATCH 160/243] Add BenchYAML schema 3 construction and search options Wire index type, writer mode, construction scoring, traversal, and reranking through Grid. Move ASH delivery to the search compressor and migrate the checked-in recipes. Restore the shared compressor cache and cover the new paths with integration tests. --- docs/ash-pq-benchmark.md | 17 +- docs/ash-queued-query-poc.md | 10 +- docs/benchyaml-guide.md | 18 +- jvector-examples/run-ash-pq-benchmark.sh | 9 + jvector-examples/run-ash-pq-study.py | 12 +- .../jvector/example/AutoBenchYAML.java | 53 ++-- .../jbellis/jvector/example/BuildConfig.java | 21 ++ .../example/CompressedOnDiskBuild.java | 161 +++++++++---- .../jvector/example/ConstructionMetrics.java | 29 ++- .../example/ExperimentsLogListener.java | 1 + .../jvector/example/FeatureProviders.java | 9 +- .../jvector/example/FeatureWriteContext.java | 24 +- .../jvector/example/GraphBuilderFactory.java | 71 +++++- .../github/jbellis/jvector/example/Grid.java | 204 +++++++++++++--- .../jbellis/jvector/example/GridSpec.java | 63 ++++- .../jvector/example/InMemoryBuild.java | 29 +-- .../jbellis/jvector/example/NVQProvider.java | 12 +- .../jbellis/jvector/example/SearchConfig.java | 25 ++ .../diagnostics/ASHBuildScoreExperiment.java | 10 +- .../reporting/ExperimentsSchemaV1.java | 20 ++ .../reporting/LoggingSchemaPlanner.java | 17 +- .../example/reporting/RunArtifacts.java | 39 ++- .../example/reporting/RunReporting.java | 42 +++- .../example/reporting/SysInfoWriter.java | 2 +- .../example/util/CompressorParameters.java | 55 +++++ .../example/util/OnDiskGraphIndexCache.java | 10 +- .../example/yaml/CommonParameters.java | 10 + .../jvector/example/yaml/Compression.java | 90 ++++++- .../example/yaml/ConstructionParameters.java | 60 ++++- .../jvector/example/yaml/MultiConfig.java | 56 ++++- .../jvector/example/yaml/RunConfig.java | 8 - .../example/yaml/SearchParameters.java | 55 ++++- .../benchmarks/AshProjectionConfigTest.java | 2 - .../benchmarks/QueryExecutionConfigTest.java | 32 +-- .../StandaloneASHBenchmarkConfigTest.java | 32 ++- .../yaml-configs/ash-pq/ada002-2bit-ash.yml | 11 +- .../yaml-configs/ash-pq/ada002-2bit-pq.yml | 7 +- .../yaml-configs/ash-pq/ada002-4bit-ash.yml | 11 +- .../yaml-configs/ash-pq/ada002-4bit-pq.yml | 7 +- .../yaml-configs/ash-pq/cap6m-2bit-ash.yml | 11 +- .../yaml-configs/ash-pq/cap6m-2bit-pq.yml | 7 +- .../yaml-configs/ash-pq/cap6m-4bit-ash.yml | 11 +- .../yaml-configs/ash-pq/cap6m-4bit-pq.yml | 7 +- .../yaml-configs/ash-pq/cohere-2bit-ash.yml | 11 +- .../yaml-configs/ash-pq/cohere-2bit-pq.yml | 7 +- .../yaml-configs/ash-pq/cohere-4bit-ash.yml | 11 +- .../yaml-configs/ash-pq/cohere-4bit-pq.yml | 7 +- .../ash-pq/cohere10m-2bit-ash.yml | 11 +- .../yaml-configs/ash-pq/cohere10m-2bit-pq.yml | 7 +- .../ash-pq/cohere10m-4bit-ash.yml | 11 +- .../yaml-configs/ash-pq/cohere10m-4bit-pq.yml | 7 +- .../yaml-configs/ash-pq/mpnet-2bit-ash.yml | 11 +- .../yaml-configs/ash-pq/mpnet-2bit-pq.yml | 7 +- .../yaml-configs/ash-pq/mpnet-4bit-ash.yml | 11 +- .../yaml-configs/ash-pq/mpnet-4bit-pq.yml | 7 +- .../yaml-configs/ash-pq/mpnet10m-2bit-ash.yml | 11 +- .../yaml-configs/ash-pq/mpnet10m-2bit-pq.yml | 7 +- .../yaml-configs/ash-pq/mpnet10m-4bit-ash.yml | 11 +- .../yaml-configs/ash-pq/mpnet10m-4bit-pq.yml | 7 +- .../ash-pq/openai3072-2bit-ash.yml | 11 +- .../ash-pq/openai3072-2bit-pq.yml | 7 +- .../ash-pq/openai3072-4bit-ash.yml | 11 +- .../ash-pq/openai3072-4bit-pq.yml | 7 +- .../yaml-configs/ash-pq/run-construction.yml | 2 - .../yaml-configs/ash-pq/run-direct.yml | 2 - .../yaml-configs/ash-pq/run-queued.yml | 2 - .../index-parameters/ada002-1M.yml | 6 +- .../yaml-configs/index-parameters/cap-10M.yml | 6 +- .../yaml-configs/index-parameters/cap-1M.yml | 6 +- .../yaml-configs/index-parameters/cap-6M.yml | 6 +- .../index-parameters/cohere-10M.yml | 6 +- .../cohere-english-v3-10M.yml | 6 +- .../index-parameters/cohere-english-v3-1M.yml | 6 +- .../index-parameters/colbert-1M.yml | 5 +- .../yaml-configs/index-parameters/default.yml | 56 +++-- .../index-parameters/dpr-gemma-10M.yml | 6 +- .../index-parameters/dpr-gemma-1M.yml | 6 +- .../index-parameters/esmc-600-1M.yml | 6 +- .../index-parameters/esmc-600-6M.yml | 6 +- .../index-parameters/gecko-100k.yml | 6 +- .../index-parameters/glove-100-angular.yml | 5 +- .../index-parameters/glove-200-angular.yml | 5 +- .../index-parameters/glove-25-angular.yml | 5 +- .../index-parameters/glove-50-angular.yml | 5 +- .../index-parameters/lastfm-64-dot.yml | 5 +- .../index-parameters/mpnet-10M.yml | 7 +- .../index-parameters/mpnet-1M.yml | 6 +- .../index-parameters/nytimes-256-angular.yml | 5 +- .../index-parameters/openai-v3-small-100k.yml | 6 +- .../index-parameters/personahub-1M.yml | 6 +- .../index-parameters/sift-128-euclidean.yml | 5 +- .../index-parameters/tahoe-x1-1M.yml | 6 +- .../example/TestCompressedBuildPreflight.java | 2 +- .../example/TestDatasetPrefetching.java | 2 +- .../example/TestGridASHIntegration.java | 228 +++++++++++++++++- .../TestGridDeferredFeatureParallelism.java | 2 +- .../jbellis/jvector/example/TestGridSpec.java | 39 +++ 97 files changed, 1613 insertions(+), 425 deletions(-) diff --git a/docs/ash-pq-benchmark.md b/docs/ash-pq-benchmark.md index 4dd42488b..52e31f937 100644 --- a/docs/ash-pq-benchmark.md +++ b/docs/ash-pq-benchmark.md @@ -134,12 +134,8 @@ JVECTOR_BENCH_NUMA_POLICY=interleave:0,1 \ mpnet-2bit-ash.yml construction 48 0-47 "$BENCH_CASE" # Reuse that model and graph. Each invocation is a fresh JVM. -bash jvector-examples/run-ash-pq-benchmark.sh \ - mpnet-2bit-ash.yml direct 1 0 "$BENCH_CASE" bash jvector-examples/run-ash-pq-benchmark.sh \ mpnet-2bit-ash.yml queued 1 0 "$BENCH_CASE" -bash jvector-examples/run-ash-pq-benchmark.sh \ - mpnet-2bit-ash.yml direct 8 0-7 "$BENCH_CASE" bash jvector-examples/run-ash-pq-benchmark.sh \ mpnet-2bit-ash.yml queued 8 0-7 "$BENCH_CASE" ``` @@ -149,16 +145,17 @@ memory-node lists together on other hosts. Do not run cases concurrently or share their CPU allocation with another experiment. The heap must fit the machine; report any heap, CPU, NUMA or library differences from the reference. -Replace `ash` with `pq` and use a separate case directory for its matching PQ -case. Repeat with each prefix and bit count in the table. Construction refuses +For the matching PQ case, replace `ash` with `pq`, use `direct` for the query +report selection, and use a separate case directory. Each index YAML selects +its delivery policy. The run YAML selects the metrics and reporting. Repeat with each prefix and bit count in the table. Construction refuses nonempty model/index caches; query invocations require caches and reject a run that rebuilt an index. No existing indexes are automatically deleted. For the full report, collect five fresh construction JVMs per case in separate directories. Retain the first graph for queries, without choosing the best build. -Run three fresh query JVMs per algorithm, core budget and execution mode against -that first graph. Alternate PQ/ASH and direct/queued order across repetitions. -That is 80 builds and 192 query JVMs; each query JVM reports both overquery levels. +Run three fresh query JVMs per algorithm and core budget against that first +graph. Alternate PQ and ASH order across repetitions. That is 80 builds and 96 +query JVMs for the four 1M datasets, with both overquery levels per query JVM. The optional checked-in study driver launches exactly these standalone invocations sequentially and aggregates their CSV files. It has no query loop or performance @@ -234,7 +231,7 @@ kernel switches or prototype classpaths are used. including preparation, traversal and reranking. Each QPS sample lasts at least three seconds and completes the final pass. Direct mode has 4C synchronous callers; queued mode has at most C execution workers and admission 8C, with - default maximum batch 8. PQ uses the same queued policy without ASH preparation. + default maximum batch 8 for ASH. PQ uses direct execution. * **Unloaded latency:** BenchYAML submits and completes one query at a time, with the same configured worker capacity. The timer ends when the call returns, including preparation, execution and dispatch. This is not sustained latency diff --git a/docs/ash-queued-query-poc.md b/docs/ash-queued-query-poc.md index 1244117af..e0f67a231 100644 --- a/docs/ash-queued-query-poc.md +++ b/docs/ash-queued-query-poc.md @@ -66,13 +66,9 @@ Close/drain the batching executor before shutting down its supplied workers. ## BenchYAML configuration -Select the mode in the existing run YAML; use the usual dataset/index YAMLs: - -```yaml -queryExecution: - mode: queued - maxBatchSize: 8 -``` +Set `queryExecution: queued` and `maxBatchSize: 8` in the parameters of +the ASH search compression entry. See the +[BenchYAML guide](benchyaml-guide.md#ash-query-parameters) for placement. The default mode is `direct`. Setting `maxBatchSize: 1` is a no-batching control with the same admission budget; it is not how isolated production traffic is diff --git a/docs/benchyaml-guide.md b/docs/benchyaml-guide.md index 8fd201f57..5edfdcbf9 100644 --- a/docs/benchyaml-guide.md +++ b/docs/benchyaml-guide.md @@ -33,6 +33,9 @@ reranking optionally rescores the candidates. - [Run configuration](../jvector-examples/yaml-configs/run-config.yml) selects which measurements to compute and which to display or log. +Each index-parameter file sets `yamlSchemaVersion: 3`, the current +`onDiskIndexVersion`, and its `dataset` name. + See [How to run benchmarks](benchmarking.md) for dataset setup and commands. ## Construction @@ -49,7 +52,7 @@ Each `compression` list entry has a `type` and its own settings. | `useSavedIndexIfExists` | `Yes`, `No` | Controls reuse of a compatible `onDisk` index. A cache hit skips fresh construction and serialization measurements. | | `outDegree` | Positive integers | Maximum final neighborhood degree, also called M. | | `efConstruction` | Positive integers | Search width used to find candidates during insertion. | -| `neighborOverflow` | Finite numbers ≥ 1.0 | Temporary neighborhood capacity relative to final degree. | +| `neighborOverflow` | Finite numbers ≥ 1.0 | Temporary neighborhood capacity relative to final degree. Java float suffixes such as `1.2f` are accepted. | | `addHierarchy` | `Yes`, `No` | Builds upper graph layers. | | `refineFinalGraph` | `Yes`, `No` | Refines the graph during final cleanup. | | `fusedGraph` | `Yes`, `No` | Applies to `onDisk`. `Yes` stores ASH or PQ neighbor codes alongside the graph records. `search.compression` selects the codec. | @@ -67,7 +70,10 @@ for the JVM setting. Search uses the index selected by `construction.type`. Changes to stored features define separate index cases. Search sweeps such as `topKOverquery` use the index -for their case. +for their case. When CSV logging is enabled, the row also records the index +type, writer mode, reranker, ASH construction mode, and query delivery settings +including the number of callers or workers. A run with mixed delivery policies +reports thread counts per case in the CSV. | Field | Values | Description | | --- | --- | --- | @@ -77,8 +83,10 @@ for their case. | `compression[].parameters` | [Quantizer parameters](#quantizer-parameters) | Supplies encoding settings for traversal. Reuse matching construction models and codes where possible. | For a fused disk case, Grid writes the selected search codes over the completed -topology. The same ASH model can serve construction and search when their -quantizer settings match. +topology. Matching ASH or PQ settings reuse the construction model and codes. +With a non-fused layout, compressed traversal uses a separate in-memory code +store. With source-vector disk search and no reranker, the inline source +vectors are the sole vector feature. ### ASH query parameters @@ -87,7 +95,7 @@ Place `queryExecution` and `maxBatchSize` in that map on an ASH **search** entry | Field | Values | Description | | --- | --- | --- | -| `queryProjection` | `auto`, `fp32`, `fp16`, `int8` | Optional. `auto` is the default and selects FP16 where supported, with FP32 fallback. `fp32` selects FP32 projection. `fp16` and `int8` require their selected paths. The mode applies to ASH search and asymmetric construction scoring. All ASH entries in one JVM must resolve to one mode. The `jvector.ash.queryProjection` JVM property overrides YAML. | +| `queryProjection` | `auto`, `fp32`, `fp16`, `int8` | Optional. `auto` is the default and selects FP16 where supported, with FP32 fallback. `fp32` selects FP32 projection. `fp16` and `int8` require their selected paths. The mode applies to ASH search and asymmetric construction scoring. Explicit ASH settings in one JVM must agree. Entries that omit it inherit the JVM mode, which defaults to `auto`. The `jvector.ash.queryProjection` JVM property overrides YAML. | | `queryExecution` | `direct`, `queued` | `direct` runs searches on the calling threads. `queued` sends searches to workers and batches query preparation when supported. Defaults to `direct`. | | `maxBatchSize` | Positive integer | Caps the number of queries prepared together in queued mode. Defaults to 8. The value must fit the executor limits at the selected worker count. | diff --git a/jvector-examples/run-ash-pq-benchmark.sh b/jvector-examples/run-ash-pq-benchmark.sh index 12328740d..a1178fe1f 100755 --- a/jvector-examples/run-ash-pq-benchmark.sh +++ b/jvector-examples/run-ash-pq-benchmark.sh @@ -80,6 +80,15 @@ CATALOG="$REPO_ROOT/jvector-examples/yaml-configs/ash-pq/$CATALOG_NAME" echo "Unknown index config: $CONFIG_NAME" >&2; exit 2; } +if [[ "$MODE" != construction ]]; then + # The checked-in recipes put delivery under the ASH search parameters. + EXECUTION="$(sed -nE 's/^[[:space:]]+queryExecution:[[:space:]]*(direct|queued)[[:space:]]*(#.*)?$/\1/p' "$CONFIG")" + if [[ "${EXECUTION:-direct}" != "$MODE" ]]; then + echo "The index YAML selects ${EXECUTION:-direct}. Use its matching query report selection." >&2 + exit 2 + fi +fi + shopt -s nullglob JARS=("$REPO_ROOT"/jvector-examples/target/jvector-examples-*-jar-with-dependencies.jar) if [[ ${#JARS[@]} != 1 ]]; then diff --git a/jvector-examples/run-ash-pq-study.py b/jvector-examples/run-ash-pq-study.py index b9c330d73..5203c4a98 100755 --- a/jvector-examples/run-ash-pq-study.py +++ b/jvector-examples/run-ash-pq-study.py @@ -68,13 +68,12 @@ numa="interleave:" + args.build_nodes)) for repetition in range(1, args.query_repetitions + 1): algorithms = ("pq", "ash") if (group + repetition) % 2 else ("ash", "pq") - modes = ("direct", "queued") if repetition % 2 else ("queued", "direct") for cores, cpus in ((1, args.one_core_cpu), (8, args.eight_core_cpus)): - for mode in modes: - for algorithm in algorithms: - jobs.append(dict(prefix=prefix, bits=bits, algorithm=algorithm, mode=mode, - cores=cores, cpus=cpus, repetition=repetition, - numa="bind:" + args.query_node)) + for algorithm in algorithms: + mode = "queued" if algorithm == "ash" else "direct" + jobs.append(dict(prefix=prefix, bits=bits, algorithm=algorithm, mode=mode, + cores=cores, cpus=cpus, repetition=repetition, + numa="bind:" + args.query_node)) jobs = [job for job in jobs if job["algorithm"] in args.algorithms and (job["mode"] == "construction" or job["mode"] in args.query_modes)] for job in jobs: @@ -266,6 +265,7 @@ def summarize(): if job["mode"] == "construction": assert all(row.get("construction.index_build_time_s") for row in rows) else: + assert all(row["query_execution"] == job["mode"] for row in rows) assert all(row.get("search.throughput.avg_qps") and row.get("search.latency.mean_ms") for row in rows) reference = next(result for result in results if result["job"]["prefix"] == job["prefix"] and result["job"]["bits"] == job["bits"] and result["job"]["algorithm"] == job["algorithm"] diff --git a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/AutoBenchYAML.java b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/AutoBenchYAML.java index 2d48791bc..6113baa25 100644 --- a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/AutoBenchYAML.java +++ b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/AutoBenchYAML.java @@ -24,6 +24,7 @@ import io.github.jbellis.jvector.example.benchmarks.datasets.DataSets; import io.github.jbellis.jvector.example.yaml.DatasetCollection; import io.github.jbellis.jvector.example.yaml.MultiConfig; +import io.github.jbellis.jvector.example.yaml.SearchParameters; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -55,6 +56,12 @@ public class AutoBenchYAML { private static final String REGRESSION_TEST_KEY = "regression-tests"; public static void main(String[] args) throws IOException { + try (var projectionSetting = new SearchParameters.AshQueryProjectionSetting()) { + run(args, projectionSetting); + } + } + + private static void run(String[] args, SearchParameters.AshQueryProjectionSetting projectionSetting) throws IOException { // Check for --output argument (required for this class) String outputPath = null; for (int i = 0; i < args.length - 1; i++) { @@ -114,32 +121,30 @@ public static void main(String[] args) throws IOException { // Add results from checkpoint if present results.addAll(checkpointManager.getCompletedResults()); - // Process datasets from regex patterns - if (!datasetNames.isEmpty()) { - for (var datasetName : datasetNames) { - // Skip already completed datasets - if (checkpointManager.isDatasetCompleted(datasetName)) { - logger.info("Skipping already completed dataset: {}", datasetName); - continue; - } + // Resolve the process-wide ASH projection before any regression builds start. + var configs = new LinkedHashMap(); + for (var datasetName : datasetNames) { + if (checkpointManager.isDatasetCompleted(datasetName)) { + logger.info("Skipping already completed dataset: {}", datasetName); + continue; + } + try { + var name = datasetName.endsWith(".hdf5") + ? datasetName.substring(0, datasetName.length() - ".hdf5".length()) : datasetName; + var config = finalConfigPath == null ? MultiConfig.getDefaultConfig(name) : MultiConfig.getConfig(finalConfigPath); + if (finalConfigPath == null || config.dataset == null || config.dataset.isEmpty()) config.dataset = name; + configs.put(datasetName, config); + } catch (Exception e) { + logger.error("Exception while processing dataset {}", datasetName, e); + } + } + projectionSetting.apply(configs.values().stream().filter(MultiConfig::usesSchema3).collect(Collectors.toList())); + if (!configs.isEmpty()) { + for (var entry : configs.entrySet()) { + var datasetName = entry.getKey(); + var config = entry.getValue(); try { - String normalizedDatasetName = datasetName; - if (normalizedDatasetName.endsWith(".hdf5")) { - normalizedDatasetName = normalizedDatasetName.substring(0, normalizedDatasetName.length() - ".hdf5".length()); - } - - MultiConfig config; - if (finalConfigPath != null) { - config = MultiConfig.getConfig(finalConfigPath); - // Override dataset name if not specified in custom config - if (config.dataset == null || config.dataset.isEmpty()) { - config.dataset = normalizedDatasetName; - } - } else { - config = MultiConfig.getDefaultConfig(normalizedDatasetName); - config.dataset = normalizedDatasetName; - } logger.info("Using configuration: {}", config); var loading = BaseVectorLoading.forBenchmark(config); diff --git a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/BuildConfig.java b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/BuildConfig.java index 44effda63..82be1f9e8 100644 --- a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/BuildConfig.java +++ b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/BuildConfig.java @@ -43,6 +43,11 @@ public final class BuildConfig { public final boolean refineFinalGraph; /** Compression used for graph construction, or null to build from full-precision vectors. */ public final Function buildCompressor; + /** Explicit index settings for schema 3. Legacy callers leave indexType unset. */ + public final String indexType; + public final String writerMode; + public final String rerankingType; + public final String ashConstructionMode; private BuildConfig(Builder b) { this.M = b.M; @@ -52,6 +57,10 @@ private BuildConfig(Builder b) { this.addHierarchy = b.addHierarchy; this.refineFinalGraph = b.refineFinalGraph; this.buildCompressor = b.buildCompressor; + this.indexType = b.indexType; + this.writerMode = b.writerMode; + this.rerankingType = b.rerankingType; + this.ashConstructionMode = b.ashConstructionMode; } /** @@ -69,6 +78,10 @@ public Map reportedParameters() { params.put("neighborOverflow", neighborOverflow); params.put("addHierarchy", addHierarchy); params.put("refineFinalGraph", refineFinalGraph); + if (indexType != null) params.put("type", indexType); + if (writerMode != null) params.put("writerMode", writerMode); + if (rerankingType != null) params.put("reranking", rerankingType); + if (ashConstructionMode != null) params.put("ashConstruction", ashConstructionMode); return Collections.unmodifiableMap(params); } @@ -84,6 +97,10 @@ public static final class Builder { private Boolean addHierarchy; private Boolean refineFinalGraph; private Function buildCompressor; + private String indexType; + private String writerMode; + private String rerankingType; + private String ashConstructionMode; private Builder() {} @@ -94,6 +111,10 @@ private Builder() {} public Builder addHierarchy(boolean addHierarchy) { this.addHierarchy = addHierarchy; return this; } public Builder refineFinalGraph(boolean refineFinalGraph) { this.refineFinalGraph = refineFinalGraph; return this; } public Builder buildCompressor(Function buildCompressor) { this.buildCompressor = buildCompressor; return this; } + public Builder indexType(String indexType) { this.indexType = indexType; return this; } + public Builder writerMode(String writerMode) { this.writerMode = writerMode; return this; } + public Builder rerankingType(String rerankingType) { this.rerankingType = rerankingType; return this; } + public Builder ashConstructionMode(String mode) { this.ashConstructionMode = mode; return this; } /** @throws IllegalStateException if a parameter without a default was not set */ public BuildConfig build() { diff --git a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/CompressedOnDiskBuild.java b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/CompressedOnDiskBuild.java index 3c6d1f6f9..8c056c631 100644 --- a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/CompressedOnDiskBuild.java +++ b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/CompressedOnDiskBuild.java @@ -28,6 +28,7 @@ import io.github.jbellis.jvector.graph.disk.feature.Feature; import io.github.jbellis.jvector.graph.disk.feature.FeatureId; import io.github.jbellis.jvector.quantization.BQVectors; +import io.github.jbellis.jvector.quantization.CompressedVectors; import io.github.jbellis.jvector.quantization.ASHVectors; import io.github.jbellis.jvector.quantization.AsymmetricHashing; import io.github.jbellis.jvector.quantization.PQVectors; @@ -46,7 +47,7 @@ import java.util.List; import java.util.Map; import java.util.Set; -import java.util.function.IntFunction; +import java.util.function.Supplier; import java.util.stream.IntStream; /** @@ -56,20 +57,42 @@ final class CompressedOnDiskBuild implements IndexBuildStrategy { private final OnDiskGraphIndexCache cache; private final Path workDirectory; - private final VectorCompressor buildCompressor; + private VectorCompressor buildCompressor; + private final Supplier> buildCodec; private final ConstructionMetrics constructionMetrics; + private final Supplier> fusedCompressor; + private final String cacheIdentity; /** @param constructionMetrics construction metrics sink, or null when not reporting */ CompressedOnDiskBuild(OnDiskGraphIndexCache cache, Path workDirectory, VectorCompressor buildCompressor, ConstructionMetrics constructionMetrics) { + this(cache, workDirectory, buildCompressor, constructionMetrics, null, null, null); + } + + CompressedOnDiskBuild(OnDiskGraphIndexCache cache, Path workDirectory, Supplier> buildCodec, + ConstructionMetrics constructionMetrics, Supplier> fusedCompressor, + String cacheIdentity) { + this(cache, workDirectory, null, constructionMetrics, buildCodec, fusedCompressor, cacheIdentity); + } + + private CompressedOnDiskBuild(OnDiskGraphIndexCache cache, Path workDirectory, VectorCompressor buildCompressor, + ConstructionMetrics constructionMetrics, Supplier> buildCodec, + Supplier> fusedCompressor, String cacheIdentity) { + this.buildCodec = buildCodec; + this.fusedCompressor = fusedCompressor; + this.cacheIdentity = cacheIdentity; this.cache = cache; this.workDirectory = workDirectory; this.buildCompressor = buildCompressor; this.constructionMetrics = constructionMetrics; } + VectorCompressor buildCompressor() { + return buildCompressor; + } + @Override public BuiltIndexes build(List> featureSets, BuildConfig build, DataSet ds) throws IOException { Map, ImmutableGraphIndex> indexes = new LinkedHashMap<>(); @@ -82,45 +105,48 @@ public BuiltIndexes build(List> featureSets, BuildConfi // Map feature sets to their cache handles for the build phase Map, OnDiskGraphIndexCache.WriteHandle> handles = new LinkedHashMap<>(); - for (Set fs : featureSets) { - var key = cache.key(ds.getName(), fs, build.M, build.efConstruction, build.neighborOverflow, build.alpha, - build.addHierarchy, build.refineFinalGraph, buildCompressor); - var cached = cache.tryLoad(key); + try { + for (Set fs : featureSets) { + var key = cacheIdentity == null + ? cache.key(ds.getName(), fs, build.M, build.efConstruction, build.neighborOverflow, build.alpha, + build.addHierarchy, build.refineFinalGraph, buildCompressor) + : cache.keyWithCompressorId(ds.getName(), fs, build.M, build.efConstruction, build.neighborOverflow, + build.alpha, build.addHierarchy, build.refineFinalGraph, cacheIdentity); + var cached = cache.tryLoad(key); - if (cached.isPresent()) { - System.out.printf("%s: Using cached graph index for %s%n", key.datasetName, fs); - indexes.put(fs, cached.get()); - try { fileSizes.put(fs, Files.size(cache.resolve(key).finalPath)); } catch (IOException ignored) {} - } else { - missing.add(fs); - if (cache.isEnabled()) { - var handle = cache.beginWrite(key, OnDiskGraphIndexCache.Overwrite.ALLOW); - // Log cache miss / build start - System.out.printf("%s: Building graph index (cached enabled) for %s%n", key.datasetName, fs); - // Prepare the atomic write handle immediately - handles.put(fs, handle); + if (cached.isPresent()) { + System.out.printf("%s: Using cached graph index for %s%n", key.datasetName, fs); + indexes.put(fs, cached.get()); + try { fileSizes.put(fs, Files.size(cache.resolve(key).finalPath)); } catch (IOException ignored) {} } else { - System.out.printf("%s: Building graph index (cache disabled) for %s%n", key.datasetName, fs); + missing.add(fs); + if (cache.isEnabled()) { + var handle = cache.beginWrite(key, OnDiskGraphIndexCache.Overwrite.ALLOW); + // Log cache miss / build start + System.out.printf("%s: Building graph index (cached enabled) for %s%n", key.datasetName, fs); + // Prepare the atomic write handle immediately + handles.put(fs, handle); + } else { + System.out.printf("%s: Building graph index (cache disabled) for %s%n", key.datasetName, fs); + } } } - } - Double buildTimeS = null; - if (!missing.isEmpty()) { - // At least one index needs to be built (b/c not in cache or cache is disabled) - // We pass the handles map so buildMissing knows exactly where to write - try { - buildTimeS = buildMissing(missing, build, ds, outputDir, handles, indexes, fileSizes); - } catch (IOException | RuntimeException e) { - // A rejected or failed build must not leave the cache's temp files behind. - for (var handle : handles.values()) { - try { handle.close(); } - catch (IOException suppressed) { e.addSuppressed(suppressed); } - } - throw e; + Double buildTimeS = missing.isEmpty() ? null + : buildMissing(missing, build, ds, outputDir, handles, indexes, fileSizes); + return new BuiltIndexes(indexes, fileSizes, buildTimeS); + } catch (IOException | RuntimeException e) { + // A failed build must release cached readers and discard uncommitted writes. + for (var handle : handles.values()) { + try { handle.close(); } + catch (IOException suppressed) { e.addSuppressed(suppressed); } } + for (var index : indexes.values()) { + try { index.close(); } + catch (IOException suppressed) { e.addSuppressed(suppressed); } + } + throw e; } - return new BuiltIndexes(indexes, fileSizes, buildTimeS); } /** Builds one graph for {@code featureSets}, loads each written index into {@code indexes}, and returns the build time. */ @@ -134,6 +160,13 @@ private double buildMissing(List> featureSets, { Files.createDirectories(outputDir); + if (build.indexType != null) { + buildCompressor = buildCodec == null ? null : buildCodec.get(); + double seconds = buildAndFlush(featureSets, build, ds, outputDir, handles); + reopen(featureSets, outputDir, handles, indexes, fileSizes); + return seconds; + } + var floatVectors = ds.getBaseRavv(); String quantType = buildCompressor instanceof ProductQuantization ? "PQ" @@ -225,13 +258,7 @@ private double buildMissing(List> featureSets, try (var vectors = new FinalWriteVectors(floatVectors); var view = builder.getGraph().getView(); var closeWriter = writer) { - Map> writeSuppliers = new EnumMap<>(FeatureId.class); - if (deferFeatures) { - ctx.vectorSuppliers().forEach((id, supplier) -> - writeSuppliers.put(id, ordinal -> supplier.apply(vectors.get(ordinal)))); - } - ctx.afterBuildSuppliers().forEach((id, supplier) -> writeSuppliers.put(id, supplier.apply(view))); - writer.write(writeSuppliers); + writer.write(ctx.completedSuppliers(view, deferFeatures ? vectors::get : null)); } catch (IOException e) { throw new UncheckedIOException(e); } @@ -243,6 +270,55 @@ private double buildMissing(List> featureSets, (System.nanoTime()-cleanupEnd)/1e9); System.out.format("Build and write %s in %ss%n", featureSets, totalTime); + reopen(featureSets, outputDir, handles, indexes, fileSizes); + return totalTime; + } + + /** Builds the topology before preparing search features and writing the disk index. */ + private double buildAndFlush(List> featureSets, BuildConfig build, DataSet ds, Path outputDir, + Map, OnDiskGraphIndexCache.WriteHandle> handles) throws IOException { + var vectors = ds.getBaseRavv(); + var mean = featureSets.stream().anyMatch(features -> features.contains(FeatureId.NVQ_VECTORS)) + ? new VectorMean(vectors.size(), vectors.dimension()) : null; + var buildVectors = mean == null ? vectors : mean.duringScan(vectors); + var encoded = Grid.encodeForConstruction(buildCompressor, buildVectors, constructionMetrics); + + long start = System.nanoTime(); + try (var graph = GraphBuilderFactory.buildGraph(ds, build, encoded)) { + double buildSeconds = (System.nanoTime() - start) / 1e9; + var codec = fusedCompressor == null ? null : fusedCompressor.get(); + CompressedVectors searchCodes = codec == null ? null : codec == buildCompressor + ? encoded + : Grid.encodeForConstruction(codec, mean != null && !mean.isComplete() ? buildVectors : vectors, + constructionMetrics); + var inputMean = mean != null && mean.isComplete() ? mean.mean() : null; + double flushSeconds = 0; + int n = 0; + for (var features : featureSets) { + Path path = handles.containsKey(features) ? handles.get(features).writePath() : outputDir.resolve("graph" + n++); + var ctx = FeatureProviders.configureWriter(features, graph, path, vectors, + searchCodes instanceof PQVectors ? (PQVectors) searchCodes : null, + searchCodes instanceof ASHVectors ? (ASHVectors) searchCodes : null, + inputMean, constructionMetrics, build.writerMode); + start = System.nanoTime(); + try (var input = new FinalWriteVectors(vectors); + var view = graph.getView(); + var writer = ctx.buildWriter()) { + writer.write(ctx.completedSuppliers(view, input::get)); + } + flushSeconds += (System.nanoTime() - start) / 1e9; + } + double totalTime = buildSeconds + flushSeconds; + System.out.printf("Graph phases: buildAndCleanup=%.6f flush=%.6f seconds%n", buildSeconds, flushSeconds); + System.out.format("Build and write %s in %ss%n", featureSets, totalTime); + return totalTime; + } + } + + private static void reopen(List> featureSets, Path outputDir, + Map, OnDiskGraphIndexCache.WriteHandle> handles, + Map, ImmutableGraphIndex> indexes, + Map, Long> fileSizes) throws IOException { // Commit the index to cache atomically for (var entry : handles.entrySet()) { System.out.printf("Committing %s build to graph index cache...%n", entry.getKey()); @@ -250,7 +326,7 @@ private double buildMissing(List> featureSets, } // open indexes and capture per-feature-set file sizes - n = 0; + int n = 0; for (var features : featureSets) { Path loadPath = handles.containsKey(features) ? handles.get(features).finalPath() @@ -259,7 +335,6 @@ private double buildMissing(List> featureSets, indexes.put(features, OnDiskGraphIndex.load(ReaderSupplierFactory.open(loadPath))); fileSizes.put(features, Files.size(loadPath)); } - return totalTime; } /// Write one node's original-vector features to every writer during insertion. The borrowed diff --git a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/ConstructionMetrics.java b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/ConstructionMetrics.java index 809ffa3e5..d3fd7de21 100644 --- a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/ConstructionMetrics.java +++ b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/ConstructionMetrics.java @@ -43,7 +43,13 @@ final class ConstructionMetrics { // Search phase (reset per evaluated config) private final Map searchByType = new HashMap<>(); - ConstructionMetrics() {} + private final boolean sumIndexQuantTimes; + + ConstructionMetrics() { this(false); } + + ConstructionMetrics(boolean sumIndexQuantTimes) { + this.sumIndexQuantTimes = sumIndexQuantTimes; + } /** Clear search-phase quant timings for the next evaluated (index, compression) configuration. */ void resetSearch() { @@ -71,16 +77,16 @@ void appendTo(List out) { } // Index-construction quant timings - appendQuant(out, "construction.index_quant_time_s", "Idx", indexByType); + appendQuant(out, "construction.index_quant_time_s", "Idx", indexByType, sumIndexQuantTimes); // Search-phase quant timings (note: search.* namespace matches other query-time metrics) - appendQuant(out, "search.search_quant_time_s", "Search", searchByType); + appendQuant(out, "search.search_quant_time_s", "Search", searchByType, false); } private static void appendQuant(List out, String prefix, String phaseLabel, - Map byType) { + Map byType, boolean sumTimes) { final String hPrefix = (phaseLabel == null || phaseLabel.isBlank()) ? "" : (phaseLabel + " "); for (var e : byType.entrySet()) { @@ -88,16 +94,19 @@ private static void appendQuant(List out, QuantStats qs = e.getValue(); // Compute - if (qs.computeTimeS != null) { + if (!qs.computeTimesS.isEmpty()) { + double computeTime = sumTimes ? qs.computeTimesS.stream().mapToDouble(Double::doubleValue).sum() + : qs.computeTimesS.get(qs.computeTimesS.size() - 1); out.add(Metric.of(prefix + "." + qt + ".compute_time_s", - hPrefix + qt + " compute (s)", ".2f", qs.computeTimeS)); + hPrefix + qt + " compute (s)", ".2f", computeTime)); } // Encoding (usually single; suffix only if multiple) int n = qs.encodeTimesS.size(); - if (n == 1) { + if (n > 0 && (n == 1 || sumTimes)) { + double encodeTime = qs.encodeTimesS.stream().mapToDouble(Double::doubleValue).sum(); out.add(Metric.of(prefix + "." + qt + ".encoding_time_s", - hPrefix + qt + " encoding (s)", ".2f", qs.encodeTimesS.get(0))); + hPrefix + qt + " encoding (s)", ".2f", encodeTime)); } else if (n > 1) { for (int i = 0; i < n; i++) { out.add(Metric.of(prefix + "." + qt + ".encoding_time_s." + i, @@ -108,7 +117,7 @@ private static void appendQuant(List out, } private static final class QuantStats { - Double computeTimeS; + final List computeTimesS = new ArrayList<>(); final List encodeTimesS = new ArrayList<>(); } @@ -125,7 +134,7 @@ T timeCompute(java.util.function.Supplier f) { if (qs == null) return f.get(); long t0 = System.nanoTime(); try { return f.get(); } - finally { qs.computeTimeS = (System.nanoTime() - t0) / 1_000_000_000.0; } + finally { qs.computeTimesS.add((System.nanoTime() - t0) / 1_000_000_000.0); } } T timeEncode(java.util.function.Supplier f) { diff --git a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/ExperimentsLogListener.java b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/ExperimentsLogListener.java index 2444d38c5..4e401edde 100644 --- a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/ExperimentsLogListener.java +++ b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/ExperimentsLogListener.java @@ -60,6 +60,7 @@ public void onQueryResult(SearchContext ctx, QueryRow row) { row.overquery, row.rerankK, ctx.ashQueryProjectionModel, + ctx.queryParameters, selection.apply(row.metrics, resolved) ); } diff --git a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/FeatureProviders.java b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/FeatureProviders.java index 2976c429c..9385f9bc0 100644 --- a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/FeatureProviders.java +++ b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/FeatureProviders.java @@ -99,7 +99,14 @@ static FeatureWriteContext configureWriter(Set features, ImmutableGra Path outPath, RandomAccessVectorValues vectors, PQVectors buildPQ, ASHVectors buildASH, VectorFloat inputMean, ConstructionMetrics metrics) throws FileNotFoundException { - var writer = GraphBuilderFactory.writerBuilder(graph, outPath, vectors.size()); + return configureWriter(features, graph, outPath, vectors, buildPQ, buildASH, inputMean, metrics, null); + } + + static FeatureWriteContext configureWriter(Set features, ImmutableGraphIndex graph, + Path outPath, RandomAccessVectorValues vectors, + PQVectors buildPQ, ASHVectors buildASH, VectorFloat inputMean, + ConstructionMetrics metrics, String writerMode) throws FileNotFoundException { + var writer = GraphBuilderFactory.writerBuilder(graph, outPath, vectors.size(), writerMode); var ctx = new FeatureWriteContext(writer, graph, vectors, buildPQ, buildASH, inputMean, metrics); for (var id : features) { require(id).configure(ctx); diff --git a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/FeatureWriteContext.java b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/FeatureWriteContext.java index 988ce6443..b99281366 100644 --- a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/FeatureWriteContext.java +++ b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/FeatureWriteContext.java @@ -18,6 +18,7 @@ import io.github.jbellis.jvector.graph.ImmutableGraphIndex; import io.github.jbellis.jvector.graph.RandomAccessVectorValues; +import io.github.jbellis.jvector.graph.disk.AbstractGraphIndexWriter; import io.github.jbellis.jvector.graph.disk.RandomAccessOnDiskGraphIndexWriter; import io.github.jbellis.jvector.graph.disk.feature.Feature; import io.github.jbellis.jvector.graph.disk.feature.FeatureId; @@ -34,7 +35,7 @@ /** What a {@link FeatureProvider} needs to add its feature to one graph index writer. */ final class FeatureWriteContext { - private final RandomAccessOnDiskGraphIndexWriter.Builder writer; + private final AbstractGraphIndexWriter.Builder writer; private final ImmutableGraphIndex graph; private final RandomAccessVectorValues vectors; // File-backed sources reuse scratch vectors; ordinal suppliers need a view per worker. @@ -44,7 +45,6 @@ final class FeatureWriteContext { private final VectorFloat inputMean; private final ConstructionMetrics metrics; - private final Map> ordinalSuppliers = new EnumMap<>(FeatureId.class); private final Map, Feature.State>> vectorSuppliers = new EnumMap<>(FeatureId.class); private final Map>> afterBuildSuppliers = new EnumMap<>(FeatureId.class); @@ -53,7 +53,7 @@ final class FeatureWriteContext { * @param buildPQ PQ-encoded build vectors, or null for full-precision and non-PQ builds * @param metrics construction metrics sink, or null when not reporting */ - FeatureWriteContext(RandomAccessOnDiskGraphIndexWriter.Builder writer, + FeatureWriteContext(AbstractGraphIndexWriter.Builder writer, ImmutableGraphIndex graph, RandomAccessVectorValues vectors, PQVectors buildPQ, @@ -61,7 +61,7 @@ final class FeatureWriteContext { this(writer, graph, vectors, buildPQ, null, null, metrics); } - FeatureWriteContext(RandomAccessOnDiskGraphIndexWriter.Builder writer, ImmutableGraphIndex graph, + FeatureWriteContext(AbstractGraphIndexWriter.Builder writer, ImmutableGraphIndex graph, RandomAccessVectorValues vectors, PQVectors buildPQ, ASHVectors buildASH, VectorFloat inputMean, ConstructionMetrics metrics) { this.buildASH = buildASH; @@ -95,7 +95,6 @@ void add(Feature feature) { */ void supplyFromVector(FeatureId id, Function, Feature.State> encode) { vectorSuppliers.put(id, encode); - ordinalSuppliers.put(id, ordinal -> encode.apply(writerVectors.get().getVector(ordinal))); } /** Registers a feature whose state needs the completed graph, such as fused neighbor codes. */ @@ -112,7 +111,20 @@ RandomAccessOnDiskGraphIndexWriter buildWriter() throws IOException { return writer.build(); } - Map> ordinalSuppliers() { return ordinalSuppliers; } + Map> completedSuppliers(ImmutableGraphIndex.View view) { + return completedSuppliers(view, ordinal -> writerVectors.get().getVector(ordinal)); + } + + Map> completedSuppliers(ImmutableGraphIndex.View view, + IntFunction> readVector) { + var suppliers = new EnumMap>(FeatureId.class); + if (readVector != null) { + vectorSuppliers.forEach((id, supplier) -> + suppliers.put(id, ordinal -> supplier.apply(readVector.apply(ordinal)))); + } + afterBuildSuppliers.forEach((id, supplier) -> suppliers.put(id, supplier.apply(view))); + return suppliers; + } Map, Feature.State>> vectorSuppliers() { return vectorSuppliers; } diff --git a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/GraphBuilderFactory.java b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/GraphBuilderFactory.java index 3b2f85135..ccef03cae 100644 --- a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/GraphBuilderFactory.java +++ b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/GraphBuilderFactory.java @@ -17,7 +17,13 @@ package io.github.jbellis.jvector.example; import io.github.jbellis.jvector.example.util.FilteredForkJoinPool; +import io.github.jbellis.jvector.example.benchmarks.datasets.DataSet; +import io.github.jbellis.jvector.example.diagnostics.ASHBuildScoreExperiment; import io.github.jbellis.jvector.graph.GraphIndexBuilder; +import io.github.jbellis.jvector.graph.VectorAccess; +import io.github.jbellis.jvector.graph.disk.AbstractGraphIndexWriter; +import io.github.jbellis.jvector.graph.disk.OnDiskGraphIndexWriter; +import io.github.jbellis.jvector.graph.disk.OnDiskParallelGraphIndexWriter; import io.github.jbellis.jvector.graph.ImmutableGraphIndex; import io.github.jbellis.jvector.graph.RandomAccessVectorValues; import io.github.jbellis.jvector.graph.disk.OrdinalMapper; @@ -31,7 +37,9 @@ import io.github.jbellis.jvector.vector.VectorSimilarityFunction; import java.io.FileNotFoundException; +import java.io.IOException; import java.nio.file.Path; +import java.util.stream.IntStream; /** * The only place the benchmark harness constructs core graph builders and writers, so a change to @@ -67,15 +75,20 @@ static GraphIndexBuilder forCompressed(CompressedVectors encoded, static BuildScoreProvider compressedScores(CompressedVectors encoded, VectorSimilarityFunction similarity, RandomAccessVectorValues raw) { + return compressedScores(encoded, similarity, raw, ASHBuildScoreExperiment.defaultMode()); + } + + private static BuildScoreProvider compressedScores(CompressedVectors encoded, VectorSimilarityFunction similarity, + RandomAccessVectorValues raw, String ashMode) { BuildScoreProvider bsp; if (encoded instanceof PQVectors) { bsp = BuildScoreProvider.pqBuildScoreProvider(similarity, (PQVectors) encoded); } else if (encoded instanceof BQVectors) { bsp = BuildScoreProvider.bqBuildScoreProvider((BQVectors) encoded); } else if (encoded instanceof ASHVectors) { - bsp = io.github.jbellis.jvector.example.diagnostics.ASHBuildScoreExperiment.create(similarity, (ASHVectors) encoded, raw); + bsp = ASHBuildScoreExperiment.create(similarity, (ASHVectors) encoded, raw, ashMode); } else { - throw new IllegalArgumentException("Disk construction supports PQ, BQ or ASH build compression"); + throw new IllegalArgumentException("Construction supports PQ, BQ or ASH build compression"); } return bsp; } @@ -85,6 +98,60 @@ static GraphIndexBuilder forCompressed(BuildScoreProvider bsp, int dimension, Bu build.alpha, build.addHierarchy, build.refineFinalGraph); } + static String ashConstructionMode(BuildConfig build) { + return build.ashConstructionMode == null ? ASHBuildScoreExperiment.defaultMode() : build.ashConstructionMode; + } + + /** Builds and cleans up the topology before any disk features are written. */ + static ImmutableGraphIndex buildGraph(DataSet ds, BuildConfig build, CompressedVectors encoded) throws IOException { + var vectors = ds.getBaseRavv(); + var similarity = ds.getSimilarityFunction(); + if (encoded == null) { + try (var builder = forFullPrecision(vectors, similarity, build)) { + if (!ds.useDatasetPrefetching()) return builder.build(vectors); + VectorAccess.forEach(vectors, PhysicalCoreExecutor.pool(), builder::addGraphNode); + builder.cleanup(); + return builder.getGraph(); + } + } + String ashMode = ashConstructionMode(build); + var scores = compressedScores(encoded, similarity, vectors, ashMode); + boolean symmetricInsertion = encoded instanceof ASHVectors && "symmetric-all".equals(ashMode); + try (var builder = forCompressed(scores, vectors.dimension(), build)) { + if (ds.useDatasetPrefetching()) { + VectorAccess.forEach(vectors, PhysicalCoreExecutor.pool(), (node, vector) -> { + if (symmetricInsertion) builder.addGraphNode(node, scores.searchProviderFor(node)); + else builder.addGraphNode(node, vector); + }); + } else { + var views = vectors.threadLocalSupplier(); + PhysicalCoreExecutor.pool().submit(() -> + IntStream.range(0, vectors.size()).parallel().forEach(node -> { + if (symmetricInsertion) builder.addGraphNode(node, scores.searchProviderFor(node)); + else builder.addGraphNode(node, views.get().getVector(node)); + })).join(); + } + builder.cleanup(); + return builder.getGraph(); + } + } + + static AbstractGraphIndexWriter.Builder writerBuilder( + ImmutableGraphIndex graph, Path outPath, int size, String mode) throws FileNotFoundException { + if (mode == null) return writerBuilder(graph, outPath, size); + AbstractGraphIndexWriter.Builder builder; + if ("sequential".equals(mode)) { + builder = new OnDiskGraphIndexWriter.Builder(graph, outPath); + } else if ("parallel".equals(mode)) { + builder = new OnDiskParallelGraphIndexWriter.Builder(graph, outPath) + .withParallelWorkerThreads(PhysicalCoreExecutor.getPhysicalCoreCount()); + } else { + throw new IllegalArgumentException("Unknown writer mode: " + mode); + } + builder.withMapper(new OrdinalMapper.IdentityMapper(size - 1)); + return builder; + } + /** A writer builder with identity ordinals and one parallel worker per physical core. */ static RandomAccessOnDiskGraphIndexWriter.Builder writerBuilder(ImmutableGraphIndex graph, Path outPath, int size) throws FileNotFoundException { diff --git a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/Grid.java b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/Grid.java index 7b3398124..768e26296 100644 --- a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/Grid.java +++ b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/Grid.java @@ -43,6 +43,11 @@ import io.github.jbellis.jvector.quantization.AsymmetricHashing; import io.github.jbellis.jvector.quantization.ASHVectors; import io.github.jbellis.jvector.graph.disk.feature.FusedASH; +import io.github.jbellis.jvector.graph.disk.feature.FusedPQ; +import io.github.jbellis.jvector.graph.disk.feature.NVQ; +import io.github.jbellis.jvector.quantization.NVQVectors; +import io.github.jbellis.jvector.quantization.ProductQuantization; +import io.github.jbellis.jvector.vector.VectorizationProvider; import io.github.jbellis.jvector.quantization.VectorCompressor; import io.github.jbellis.jvector.util.ExplicitThreadLocal; import io.github.jbellis.jvector.util.PhysicalCoreExecutor; @@ -62,7 +67,7 @@ */ public class Grid { - private static final String pqCacheDir = "pq_cache"; + private static final String compressorCacheDir = "compressor_cache"; private static final String indexCacheDir = "index_cache"; @@ -122,6 +127,8 @@ private static void runGrid(DataSet ds, { // Resolve every point first, so an incomplete sweep fails before any build. final List buildPoints = spec.buildPoints(); + var searchCases = spec.search.cases(); + validateCases(ds, buildPoints, searchCases, spec); boolean success = false; // Always use a fresh temp directory for per-run artifacts @@ -135,7 +142,16 @@ private static void runGrid(DataSet ds, try { for (var build : buildPoints) { - runOneGraph(cache, spec.featureSets, build, spec.search, ds, workDir, benchmarks, listeners, artifacts); + for (var search : searchCases) { + var features = spec.featuresFor(build, search); + if (build.indexType == null) { + runOneGraph(cache, features, build, search, ds, workDir, benchmarks, listeners, artifacts); + } else { + for (var featureSet : features) { + runOneGraph(cache, List.of(featureSet), build, search, ds, workDir, benchmarks, listeners, artifacts); + } + } + } } success = true; // Helps with Exception attribution } finally { @@ -159,6 +175,42 @@ private static void runGrid(DataSet ds, } } + private static void validateCases(DataSet ds, List builds, List searches, GridSpec spec) { + boolean usesAshProjection = false; + for (var build : builds) { + if (build.indexType == null) continue; + var construction = build.buildCompressor.apply(ds); + var ashMode = construction instanceof CompressorParameters.ASHParameters + ? GraphBuilderFactory.ashConstructionMode(build) : null; + construction.validateFor(ds, false, ashMode); + usesAshProjection |= ashMode != null && !"symmetric-all".equals(ashMode); + for (var search : searches) { + var traversal = search.searchCompressors.get(0).apply(ds); + boolean fused = spec.featuresFor(build, search).stream().anyMatch(Grid::hasFusedScoring); + traversal.validateFor(ds, fused, null); + usesAshProjection |= traversal instanceof CompressorParameters.ASHParameters; + if ("queued".equals(search.compression.queryExecution()) + && (long) Math.max(8, search.compression.maxBatchSize()) * queryWorkerCountForMachine() > Integer.MAX_VALUE) { + throw new IllegalArgumentException("ASH maxBatchSize exceeds the queue limit at the selected worker count"); + } + } + } + String projection = System.getProperty("jvector.ash.queryProjection", "auto"); + if (usesAshProjection && ("fp16".equals(projection) || "int8".equals(projection))) { + // Use the same provider and source dimension as the configured projection. + VectorizationProvider.getInstance().getVectorUtilSupport() + .prepareAshProjection(new float[1][ds.getDimension()]).forQueryScoring(); + } + } + + private static boolean hasFusedScoring(Set features) { + return FeatureProviders.anyMatch(features, FeatureProvider::providesApproximateScoring); + } + + private static int queryWorkerCountForMachine() { + return Math.max(1, Math.min(Runtime.getRuntime().availableProcessors(), PhysicalCoreExecutor.getPhysicalCoreCount())); + } + /** * Iterates through the path and deletes everything inside, then the path itself. */ @@ -225,7 +277,7 @@ private static void runOneGraph(OnDiskGraphIndexCache cache, List listeners, RunArtifacts artifacts) throws IOException { // Prepare to collect index construction metrics for reporting.... - var constructionMetrics = new ConstructionMetrics(); + var constructionMetrics = new ConstructionMetrics(build.indexType != null); // Capture initial memory and disk state try (var diagnostics = new BenchmarkDiagnostics(getDiagnosticLevel())) { @@ -235,23 +287,42 @@ private static void runOneGraph(OnDiskGraphIndexCache cache, System.out.printf("%s: Dataset similarity function is %s%n", ds.getName(), ds.getSimilarityFunction()); // Resolve build compressor (and label quant type) so we can record compute time - VectorCompressor buildCompressorObj = null; - String buildQuantType = null; + var buildParams = build.buildCompressor == null ? CompressorParameters.NONE : build.buildCompressor.apply(ds); + Supplier> buildCodec = () -> buildParams == CompressorParameters.NONE + ? null : getCompressor(buildParams, ds, constructionMetrics, Phase.INDEX, quantTypeOf(buildParams)); - if (build.buildCompressor != null) { - var buildParams = build.buildCompressor.apply(ds); - buildQuantType = quantTypeOf(buildParams); // "PQ", "BQ", or null - buildCompressorObj = getCompressor(build.buildCompressor, ds, constructionMetrics, Phase.INDEX, buildQuantType); + VectorCompressor buildCompressorObj = null; + IndexBuildStrategy strategy; + if ("onDisk".equals(build.indexType)) { + var searchParams = search.searchCompressors.get(0).apply(ds); + boolean fused = featureSets.stream().anyMatch(Grid::hasFusedScoring); + Supplier> fusedCodec = fused + ? () -> getCompressor(searchParams, ds, constructionMetrics, Phase.INDEX, quantTypeOf(searchParams)) + : null; + String cacheIdentity = "schema3:" + compressorId(buildParams, ds) + + (buildParams instanceof CompressorParameters.ASHParameters + ? ":" + GraphBuilderFactory.ashConstructionMode(build) + ":" + + System.getProperty("jvector.ash.queryProjection", "auto") + : "") + + (fused ? ":" + compressorId(searchParams, ds) : ""); + strategy = new CompressedOnDiskBuild(cache, workDirectory, buildCodec, constructionMetrics, + fusedCodec, cacheIdentity); + } else { + buildCompressorObj = buildCodec.get(); + if ("inMemory".equals(build.indexType)) { + strategy = new InMemoryBuild(workDirectory, + encodeForConstruction(buildCompressorObj, ds.getBaseRavv(), constructionMetrics)); + } else { + strategy = buildCompressorObj == null ? new InMemoryBuild(workDirectory) + : new CompressedOnDiskBuild(cache, workDirectory, buildCompressorObj, constructionMetrics); + } } - - // Used in logging - String buildCompressorString = - (buildCompressorObj == null) ? "None" : String.valueOf(buildCompressorObj); - - IndexBuildStrategy strategy = (buildCompressorObj == null) - ? new InMemoryBuild(workDirectory) - : new CompressedOnDiskBuild(cache, workDirectory, buildCompressorObj, constructionMetrics); var built = strategy.build(featureSets, build, ds); + if (strategy instanceof CompressedOnDiskBuild) { + buildCompressorObj = ((CompressedOnDiskBuild) strategy).buildCompressor(); + } + final String buildCompressorString = buildCompressorObj == null + ? compressorId(buildParams, ds) : buildCompressorObj.toString(); // Null when every index came from the index cache. Read it from here, not the per-dataset map, // which still holds the previous build config's time in that case. Double indexBuildTimeS = built.buildTimeS; @@ -284,13 +355,15 @@ private static void runOneGraph(OnDiskGraphIndexCache cache, constructionMetrics.resetSearch(); CompressedVectors cv; - if (FeatureProviders.anyMatch(featureSetForIndex, FeatureProvider::providesApproximateScoring)) { + if (hasFusedScoring(featureSetForIndex)) { cv = null; - System.out.format("%s: configured to use FUSED PQ, skipping vector compression%n", ds.getName()); + System.out.format("%s: using fused traversal codes from the disk index%n", ds.getName()); } else { var searchParams = cpSupplier.apply(ds); String searchQuantType = quantTypeOf(searchParams); // "PQ", "BQ", or null - var compressor = getCompressor(cpSupplier, ds, constructionMetrics, Phase.SEARCH, searchQuantType); + var compressor = build.indexType != null && searchParams == CompressorParameters.NONE + ? null + : getCompressor(searchParams, ds, constructionMetrics, Phase.SEARCH, searchQuantType); if (compressor == null) { cv = null; @@ -309,11 +382,22 @@ private static void runOneGraph(OnDiskGraphIndexCache cache, } } - try (var cs = new ConfiguredSystem(ds, index, cv, featureSetForIndex)) { - cs.configureQueryExecution(artifacts.queryExecutionMode(), artifacts.queryBatchSize()); + NVQVectors memoryReranker = null; + if ("inMemory".equals(build.indexType) && "NVQ".equals(build.rerankingType)) { + var base = ds.getBaseRavv(); + var nvq = constructionMetrics.index("NVQ").timeCompute(() -> NVQProvider.train(base, null)); + memoryReranker = (NVQVectors) constructionMetrics.index("NVQ").timeEncode(() -> nvq.encodeAll(base)); + } + try (var cs = new ConfiguredSystem(ds, index, cv, featureSetForIndex, build.rerankingType, + memoryReranker)) { + if (search.compression != null) { + cs.configureQueryExecution(search.compression.queryExecution(), search.compression.maxBatchSize()); + } + var traversal = cv == null ? null : cv.getCompressor(); + if (traversal == null && build.indexType != null) traversal = cs.fusedCompressor(); var ctx = new SearchContext(ds, build, search, features, featureSetForIndex, buildCompressorString, cpSupplier, - (cv == null) ? "None" : String.valueOf(cv.getCompressor()), + traversal == null ? "None" : traversal.toString(), indexBuildTimeS, indexFileSizes.get(features), buildSnapshot, queryParameters(cs), cs.ashQueryProjectionModel()); testConfiguration(cs, ctx, constructionMetrics, workDirectory, benchmarks, listeners); @@ -584,18 +668,17 @@ private static List regressionBenchmarks() { *

    Te3!2TlX6T_rQ2+b&8 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%0AA^dRh~-gl*z^n*xbKMyuQxv9$2ihrQ8 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