> 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/BatchingExecutor.java b/jvector-base/src/main/java/io/github/jbellis/jvector/util/BatchingExecutor.java
new file mode 100644
index 000000000..fa0fa0952
--- /dev/null
+++ b/jvector-base/src/main/java/io/github/jbellis/jvector/util/BatchingExecutor.java
@@ -0,0 +1,394 @@
+/*
+ * 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.ArrayDeque;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.Objects;
+import java.util.concurrent.Executor;
+import java.util.concurrent.RejectedExecutionException;
+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 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.
+ *
+ * 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.
+ *
+ *
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.
+ *
+ *
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 {
+ /** Callbacks must be thread-safe. Completion must return promptly. */
+ public interface Operation {
+ /**
+ * 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 during this call; never retain it. */
+ void prepareBatch(List requests) throws Exception;
+ /** Executes one prepared request, without a batch-wide completion barrier. */
+ void execute(T request) throws Exception;
+ /**
+ * 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);
+ }
+
+ // 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, 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 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 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 || 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.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 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 (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 requests = new ArrayList<>(batchSize);
+ final List borrowedRequests = Collections.unmodifiableList(requests);
+ 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() {
+ 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 {
+ while (takeWork()) {
+ Throwable failure = preparationFailure;
+ if (!alreadyPrepared && failure == null) {
+ try {
+ operation.prepareBatch(borrowedRequests);
+ histogram.incrementAndGet(requests.size());
+ } catch (Throwable error) { failure = error; }
+ }
+ if (failure != null) {
+ for (T request : requests) finish(request, failure);
+ continue;
+ }
+ 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 {
+ CURRENT.set(previous); // Do not retain the service/model on host threads.
+ }
+ }
+ }
+
+ 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) {
+ firstCallbackFailure.compareAndSet(null, error);
+ callbackFailures.incrementAndGet();
+ } finally {
+ if (entering) CURRENT.set(previous);
+ lock.lock();
+ try { callbacks--; signalDrained(); }
+ finally { lock.unlock(); }
+ }
+ }
+
+ 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.get(i);
+ return result;
+ }
+
+ /** 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 (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-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..144bf16a4
--- /dev/null
+++ b/jvector-base/src/main/java/io/github/jbellis/jvector/util/BlasThreads.java
@@ -0,0 +1,76 @@
+/*
+ * 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.annotations.Experimental;
+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.
+ */
+@Experimental
+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/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-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/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-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..27a63597c
--- /dev/null
+++ b/jvector-base/src/main/java/io/github/jbellis/jvector/vector/ASHLutScoring.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.vector;
+
+import java.util.Objects;
+
+/** Portable reference and bounds contract for interleaved ASH nibble blocks. */
+public final class ASHLutScoring {
+ 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 (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) {
+ 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[codeOffset + (group >>> 1)] >>> ((group & 1) * 4)) & 15;
+ sum += lut[group * 16 + nibble];
+ }
+ return sum;
+ }
+
+ 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/DefaultVectorUtilSupport.java b/jvector-base/src/main/java/io/github/jbellis/jvector/vector/DefaultVectorUtilSupport.java
index 5843dc5f6..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,4 +547,6 @@ public float nvqUniformLoss(VectorFloat> vector, float minValue, float maxValu
return squaredSum;
}
+
+
}
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 9d663d10c..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
@@ -35,6 +35,200 @@
*/
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 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. */
+ 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 {@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");
+ }
+
+ /** 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}.
+ * 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; }
+
+ /** 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 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(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[codeOffset + 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);
+ }
+
+ /**
+ * 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 (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.
+ * 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");
+ }
+
+ /** 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 (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
+ * @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);
@@ -343,6 +537,214 @@ default long nvqCosine8bitPacked(VectorFloat> vector, ByteSequence> bytes, f
*/
float nvqUniformLoss(VectorFloat> vector, float minValue, float maxValue, int nBits);
+ // ------------------------------------------------------------------
+ // 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;
+ }
+
+ /** 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);
+
+ /**
+ * 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]);
+ }
+
+ /**
+ * Whether this view has a genuine multi-vector projection implementation.
+ * Query dispatch uses this capability to avoid coordinating providers whose
+ * batch method simply repeats singleton calls. This is a capability flag,
+ * not a performance promise, and may become false after a native fallback.
+ */
+ default boolean supportsBatchProjection() { return false; }
+
+ /** 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.
+ * 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");
+ 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, fp16 or int8: " + mode);
+ }
+ }
+
+ /** 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; }
+ }
+
+ /**
+ * 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 = }.
+ *
+ * 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;
+ }
+
+ /**
+ * 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.
+ * For each lane in [0, blockLen), writes:
+ * {@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.
+ * SIMD backends may override.
+ */
+ 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;
+ }
+ }
+
/**
* Optionally performs the existing coarse/fine NVQ growth-rate search in one
* call. Returns NaN when unsupported; the caller then uses its Java search.
@@ -352,4 +754,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/README.md b/jvector-examples/README.md
index 46990445b..1cce8a61f 100644
--- a/jvector-examples/README.md
+++ b/jvector-examples/README.md
@@ -2,6 +2,60 @@
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 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.
+
+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.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.1.0-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 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 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
Two hello-world programs use public-catalog `e5-small-v2-100k`, or accept an existing
@@ -18,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`
@@ -29,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/bench-profiles/README.md b/jvector-examples/bench-profiles/README.md
new file mode 100644
index 000000000..73071fd04
--- /dev/null
+++ b/jvector-examples/bench-profiles/README.md
@@ -0,0 +1,44 @@
+# 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.
+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.
+
+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. 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/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..c0f9618c4
--- /dev/null
+++ b/jvector-examples/benchyaml-play.sh
@@ -0,0 +1,141 @@
+#!/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"
+# 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 \
+ -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}" \
+ "-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/run-ash-pq-benchmark.sh b/jvector-examples/run-ash-pq-benchmark.sh
new file mode 100755
index 000000000..6cd9a977f
--- /dev/null
+++ b/jvector-examples/run-ash-pq-benchmark.sh
@@ -0,0 +1,183 @@
+#!/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/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
+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;
+}
+
+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
+ 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 "$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
+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..b1fcbc850
--- /dev/null
+++ b/jvector-examples/run-ash-pq-study.py
@@ -0,0 +1,334 @@
+#!/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
+
+
+
+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=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"])
+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")
+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()
+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 = []
+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:
+ 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")
+ for cores, cpus in ((1, args.one_core_cpu), (8, args.eight_core_cpus)):
+ 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:
+ 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"
+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:
+ 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"],
+ 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()
+ 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:
+ 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(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:
+ 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["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"]
+ and result["job"]["mode"] == "construction" and result["job"]["repetition"] == 1)
+ 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"])
+ 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/ASHNeighborhoodBenchmark.java b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/ASHNeighborhoodBenchmark.java
new file mode 100644
index 000000000..b359e4964
--- /dev/null
+++ b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/ASHNeighborhoodBenchmark.java
@@ -0,0 +1,199 @@
+/*
+ * 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 && !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());
+ 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=%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",
+ 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);
+ if (Boolean.getBoolean("jvector.bench.recall")) {
+ ASHNeighborhoodRecall.run(fixture, threads);
+ return;
+ }
+ 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..2198640b0
--- /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, 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");
+ }
+ 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/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-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..796c73e7d
--- /dev/null
+++ b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/ASHNeighborhoodValidator.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.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 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 {
+ 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);
+ // 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 < 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);
+ 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);
+ 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;
+ 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++) {
+ // 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)
+ * 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/AutoBenchYAML.java b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/AutoBenchYAML.java
index 2d48791bc..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
@@ -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;
@@ -52,9 +53,15 @@
*/
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()) {
+ 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/BenchYAML.java b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/BenchYAML.java
index 8eb6ee09f..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
@@ -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,6 +37,12 @@
public class BenchYAML {
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 {
// args is one of:
// - a list of regexes, possibly needing to be split by whitespace.
// - a list of YAML files
@@ -45,17 +52,26 @@ 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("|"));
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 = datasetCollection.getAll().stream().filter(dn -> pattern.matcher(dn).find()).collect(Collectors.toList());
+ 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<>();
@@ -68,19 +84,13 @@ public static void main(String[] args) throws IOException {
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
- 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);
}
}
@@ -90,12 +100,15 @@ public static void main(String[] args) throws IOException {
return;
}
+ projectionSetting.apply(allConfigs);
+
// Set up reporting for run
RunArtifacts artifacts;
try {
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
@@ -121,6 +134,9 @@ public static void main(String[] args) throws IOException {
var loading = BaseVectorLoading.forBenchmark(config);
String datasetName = config.dataset;
+ String pipeline = config.describePipeline();
+ 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 44effda63..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
@@ -43,6 +43,13 @@ 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;
+ /** 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;
@@ -52,6 +59,11 @@ 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;
+ this.workers = b.workers;
}
/**
@@ -69,6 +81,11 @@ 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);
+ if (workers != null) params.put("constructionWorkers", workers);
return Collections.unmodifiableMap(params);
}
@@ -84,6 +101,11 @@ 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 Integer workers;
private Builder() {}
@@ -94,6 +116,11 @@ 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; }
+ 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 bdc27d796..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
@@ -21,12 +21,16 @@
import io.github.jbellis.jvector.example.util.OnDiskGraphIndexCache;
import io.github.jbellis.jvector.graph.ImmutableGraphIndex;
import io.github.jbellis.jvector.graph.VectorAccess;
+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.RandomAccessOnDiskGraphIndexWriter;
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;
import io.github.jbellis.jvector.quantization.ProductQuantization;
import io.github.jbellis.jvector.quantization.VectorCompressor;
@@ -43,30 +47,52 @@
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;
/**
- * Builds the graph from PQ- or BQ-encoded vectors while writing each feature set to disk. Feature sets
+ * Builds the graph from PQ-, BQ- or ASH-encoded vectors while writing each feature set to disk. Feature sets
* already in the index cache are loaded, and only the rest are built.
*/
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 extends Set> featureSets, BuildConfig build, DataSet ds) throws IOException {
Map, ImmutableGraphIndex> indexes = new LinkedHashMap<>();
@@ -79,45 +105,51 @@ public BuiltIndexes build(List extends Set> 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 {
+ 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, baseCount, dimension, nvq)
+ : cache.keyWithCompressorId(ds.getName(), fs, build.M, build.efConstruction, build.neighborOverflow,
+ build.alpha, build.addHierarchy, build.refineFinalGraph, cacheIdentity,
+ baseCount, dimension, nvq);
+ 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. */
@@ -131,27 +163,46 @@ private double buildMissing(List> featureSets,
{
Files.createDirectories(outputDir);
+ if (build.indexType != null) {
+ 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);
+ if (constructionMetrics != null) constructionMetrics.reopenTimeS = (System.nanoTime() - reopenStart) / 1e9;
+ return seconds;
+ }
+
var floatVectors = ds.getBaseRavv();
- // Record the encoding time if asked for....
- var encoded = (constructionMetrics != null)
- ? constructionMetrics.index(buildCompressor instanceof ProductQuantization ? "PQ" : "BQ")
- .timeEncode(() -> buildCompressor.encodeAll(floatVectors))
- : buildCompressor.encodeAll(floatVectors);
- if (!(encoded instanceof PQVectors) && !(encoded instanceof BQVectors))
- throw new IllegalArgumentException("Disk construction supports PQ or BQ build compression");
+ String quantType = buildCompressor instanceof ProductQuantization ? "PQ"
+ : buildCompressor instanceof AsymmetricHashing ? "ASH" : "BQ";
+ 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);
+ var encoded = constructionMetrics == null ? buildCompressor.encodeAll(encodingVectors)
+ : constructionMetrics.index(quantType).timeEncode(() -> buildCompressor.encodeAll(encodingVectors));
+ if (!(encoded instanceof PQVectors) && !(encoded instanceof BQVectors) && !(encoded instanceof ASHVectors))
+ throw new IllegalArgumentException("Disk construction supports PQ, BQ or ASH build compression");
+ var inputMean = mean != null && mean.isComplete() ? mean.mean() : null;
PQVectors pq = encoded instanceof PQVectors ? (PQVectors) encoded : null;
- for (var features : featureSets) {
- for (var id : features) {
- var provider = FeatureProviders.get(id);
- if (provider != null) provider.checkCompressedBuild(pq);
- }
+ ASHVectors ash = encoded instanceof ASHVectors ? (ASHVectors) encoded : null;
+ if (ash != null && Boolean.getBoolean("jvector.ash.debugSymmetric")) {
+ io.github.jbellis.jvector.example.diagnostics.ASHScoreDebug.run(floatVectors, ash,
+ Integer.getInteger("jvector.ash.debugSymmetric.pairs", 2000),
+ Integer.getInteger("jvector.ash.debugSymmetric.subsetN", 50000),
+ Integer.getInteger("jvector.ash.debugSymmetric.topK", 100), new java.util.Random(123));
}
+ for (var features : featureSets) for (var id : features) FeatureProviders.require(id).checkCompressedBuild(encoded);
+ boolean allSymmetric = ash != null && "symmetric-all".equals(
+ System.getProperty("jvector.bench.ashConstruction", "symmetric-all"));
// Compressed scores only guide construction; searches rerank with a feature's stored vectors.
if (featureSets.stream().noneMatch(fs -> FeatureProviders.anyMatch(fs, FeatureProvider::providesRerankVectors))) {
throw new IllegalStateException("Bench looks for either NVQ_VECTORS or INLINE_VECTORS feature set for scoring compressed builds.");
}
- var builder = GraphBuilderFactory.forCompressed(encoded, ds.getSimilarityFunction(), floatVectors.dimension(), build);
+ long providerStart = System.nanoTime();
+ var bsp = GraphBuilderFactory.compressedScores(encoded, ds.getSimilarityFunction(), floatVectors);
+ var builder = GraphBuilderFactory.forCompressed(bsp, floatVectors.dimension(), build);
+ System.out.printf("Build score provider setup: %.6f seconds%n", (System.nanoTime() - providerStart) / 1e9);
Map, RandomAccessOnDiskGraphIndexWriter> writers = new LinkedHashMap<>();
Map, FeatureWriteContext> contexts = new LinkedHashMap<>();
@@ -165,7 +216,7 @@ private double buildMissing(List> featureSets,
} else {
graphPath = outputDir.resolve("graph" + n++);
}
- var ctx = FeatureProviders.configureWriter(features, builder.getGraph(), graphPath, floatVectors, pq, constructionMetrics);
+ var ctx = FeatureProviders.configureWriter(features, builder.getGraph(), graphPath, floatVectors, pq, ash, inputMean, constructionMetrics);
writers.put(features, ctx.buildWriter());
contexts.put(features, ctx);
}
@@ -187,7 +238,8 @@ private double buildMissing(List> featureSets,
if (ds.useDatasetPrefetching()) {
VectorAccess.forEach(floatVectors, PhysicalCoreExecutor.pool(), (node, vector) -> {
if (!deferFeatures) writeInlineStates(writers, contexts, node, vector);
- builder.addGraphNode(node, vector);
+ if (allSymmetric) builder.addGraphNode(node, bsp.searchProviderFor(node));
+ else builder.addGraphNode(node, vector);
});
} else {
var vv = floatVectors.threadLocalSupplier();
@@ -195,11 +247,14 @@ private double buildMissing(List> featureSets,
IntStream.range(0, floatVectors.size()).parallel().forEach(node -> {
var vector = vv.get().getVector(node);
if (!deferFeatures) writeInlineStates(writers, contexts, 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 -> {
@@ -208,21 +263,72 @@ 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);
}
});
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);
+ 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,
+ 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
+ : GraphBuilderFactory.inConstructionPool(build, fusedCompressor::get);
+ CompressedVectors searchCodes = codec == null ? null : codec == buildCompressor
+ ? encoded
+ : Grid.encodeForConstruction(codec, mean != null && !mean.isComplete() ? buildVectors : vectors,
+ constructionMetrics, GraphBuilderFactory.constructionPool(build));
+ 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, GraphBuilderFactory.constructionThreads(build));
+ start = System.nanoTime();
+ 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));
+ }
+ flushSeconds += (System.nanoTime() - start) / 1e9;
+ }
+ double totalTime = buildSeconds + flushSeconds;
+ if (constructionMetrics != null) {
+ constructionMetrics.graphBuildTimeS = buildSeconds;
+ constructionMetrics.flushTimeS = flushSeconds;
+ }
+ 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());
@@ -230,7 +336,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()
@@ -239,7 +345,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/ConsoleReportListener.java b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/ConsoleReportListener.java
index 07c605674..1e134109d 100644
--- a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/ConsoleReportListener.java
+++ b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/ConsoleReportListener.java
@@ -23,12 +23,15 @@
import java.util.LinkedHashMap;
import java.util.Map;
+import java.util.ArrayList;
+import java.util.List;
/** Prints a results table per pruning setting and topK, showing the console selection from run-config.yml. */
final class ConsoleReportListener implements GridListener {
private final RunArtifacts artifacts;
// Per (index, search compressor), so missing-metric warnings repeat for each configuration.
+ private final List rows = new ArrayList<>();
private SearchSelection selection;
private BenchmarkTablePrinter printer;
private ReportingSelectionResolver.ResolvedSelection resolved;
@@ -50,7 +53,10 @@ public void onPruningStart(SearchContext ctx, boolean usePruning) {
Map indexConfig = new LinkedHashMap<>();
indexConfig.put("featureSetForIndex", ctx.indexFeatures);
indexConfig.putAll(ctx.build.reportedParameters());
- printer.printConfig(indexConfig, Map.of("usePruning", usePruning));
+ var queryConfig = new LinkedHashMap();
+ queryConfig.put("usePruning", usePruning);
+ queryConfig.putAll(ctx.queryParameters);
+ printer.printConfig(indexConfig, queryConfig);
}
@Override
@@ -59,17 +65,19 @@ public void onTopKStart(SearchContext ctx, boolean usePruning, int topK) {
resolved = selection.resolveForTopK(topK);
// Force header to re-print for this topK (columns change)
printer.resetTable();
+ rows.clear();
}
@Override
public void onQueryResult(SearchContext ctx, QueryRow row) {
// Best-effort runtime availability warnings (warn once per key)
selection.warnMissing(row.metrics, resolved);
- printer.printRow(row.overquery, selection.apply(row.metrics, resolved));
+ rows.add(row);
}
@Override
public void onTopKEnd(SearchContext ctx, boolean usePruning, int topK) {
+ for (var row : rows) printer.printRow(row.overquery, selection.apply(row.metrics, resolved));
printer.printFooter();
}
}
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/ConstructionMetrics.java b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/ConstructionMetrics.java
index 809ffa3e5..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<>();
@@ -43,7 +48,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() {
@@ -70,17 +81,25 @@ 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);
+ 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 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,
- Map byType) {
+ Map byType, boolean sumTimes) {
final String hPrefix = (phaseLabel == null || phaseLabel.isBlank()) ? "" : (phaseLabel + " ");
for (var e : byType.entrySet()) {
@@ -88,16 +107,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 +130,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 +147,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/DistancesASH.java b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/DistancesASH.java
new file mode 100644
index 000000000..3c9cad4b4
--- /dev/null
+++ b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/DistancesASH.java
@@ -0,0 +1,1217 @@
+/*
+ * 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.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;
+import io.github.jbellis.jvector.quantization.ASHBlockScorer;
+import io.github.jbellis.jvector.quantization.CompressedVectors;
+
+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 java.io.IOException;
+import java.util.List;
+import java.util.ArrayList;
+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-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),
+ * {@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();
+
+ private static void printScorerInfo(String label, Object scorer) {
+ var backend =
+ io.github.jbellis.jvector.vector.VectorizationProvider.getInstance()
+ .getVectorUtilSupport();
+
+ System.out.println(
+ "\t[" + label + "] scorer implementation = "
+ + (scorer instanceof ASHBlockScorer
+ ? ((ASHBlockScorer) scorer).description() : scorer.getClass().getName())
+ + " (singleKernel=" + singleMode
+ + ", blockKernel=" + blockMode
+ + ", supportsAshMaskedLoad=" + backend.supportsAshMaskedLoad()
+ + ", supportsAshLutScoring=" + backend.supportsAshLutScoring()
+ + ", supportsAshProjectionScoring=" + backend.supportsAshProjectionScoring()
+ + ", " + backend.ashKernelDescription()
+ + ")"
+ );
+ }
+
+ private static void logProgress(String msg) {
+ System.out.println(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);
+ }
+
+ /** 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;
+
+ throw new IllegalArgumentException(
+ "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";
+ 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;
+ }
+ }
+
+ /** 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) {
+ // 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;
+ }
+ 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;
+ return (field & (1 << (bits - 1))) == 0 ? -magnitude : magnitude;
+ }
+
+ private static double decodedSymmetric(AsymmetricHashing ash,
+ AsymmetricHashing.QuantizedVector a,
+ AsymmetricHashing.QuantizedVector b) {
+ int ca = a.landmark & 255, cb = b.landmark & 255;
+ 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;
+ 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;
+ double centroidDot = 0;
+ for (int j = 0; j < ash.originalDimension; j++) {
+ centroidDot += (double) ash.landmarks[ca].get(j) * ash.landmarks[cb].get(j);
+ }
+ // 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 {
+ 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 extends List> groundTruth, int[] newToOld,
+ int recallK, boolean recall, ForkJoinPool executor) {
+ var ash = vectors.getCompressor();
+ 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));
+ 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 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));
+ 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()
+ : 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])));
+ }
+ 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);
+ }
+ }
+ System.out.printf(java.util.Locale.ROOT,"SYMMETRIC_VALIDATION mode=%s max_abs_error=%.9g%n",names.get(mode),maxError);
+ }
+ if (recall) {
+ if (recallK < 1 || recallK > vectors.count() || groundTruth.size() < queries.size()) {
+ throw new IllegalArgumentException("Invalid recall size or missing ground truth");
+ }
+ 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 < 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);
+ }
+ }
+ 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());
+ }
+ }
+ 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);
+ }
+ double[][] times = new double[names.size()][passes];
+ for (int pass = 0; pass < passes; pass++) {
+ 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*timingVectors;
+ for (int mode = 0; mode < names.size(); mode++) {
+ java.util.Arrays.sort(times[mode]);
+ double median = times[mode][passes/2];
+ System.out.printf(java.util.Locale.ROOT,
+ "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 scanWithSetup(java.util.function.IntFunction factory,
+ 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;
+ float[] scratch = new float[32];
+ for (int q = first; q < queries; q += workers) sum += factory.apply(q).scan(count,scratch);
+ return sum;
+ }));
+ }
+ double sum = 0;
+ for (var task : tasks) sum += task.join();
+ 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 extends 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