Skip to content

Add Asymmetric Scalar Hashing (ASH) quantization - #796

Draft
tlwillke wants to merge 264 commits into
mainfrom
ash-dev
Draft

tlwillke wants to merge 264 commits into
mainfrom
ash-dev

Conversation

@tlwillke

@tlwillke tlwillke commented Nov 10, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

Adds ASH (Asymmetric Scalar Hashing) quantization, introduced in ASH: Asymmetric Scalar Hashing With Learned Dimensionality Reduction for High-Fidelity Vector Quantization (Tepper and Willke, CIKM 2026). ASH reduces the dimension of database vectors with a learned orthonormal projection, then quantizes each projected dimension to a few bits, while keeping queries in their original form. At the same code size it is more accurate than additive and scalar quantizers, trains and encodes quickly, and scores with SIMD lookups. This PR brings ASH to a graph index for the first time.

  • Training and encoding. AsymmetricHashing trains an ITQ or random projection; encodeAll produces ASHVectors, a CompressedVectors with codes in compact shared pages. Graph indexes use one landmark.
  • In-memory graphs. ASHVectors score construction and search. BuildScoreProvider.ashBuildScoreProvider builds by comparing stored codes (symmetric scoring).
  • Fused ASH. FusedASH (FeatureId.FUSED_ASH) stores neighbors' codes with each adjacency list and the model in the index header, at 1, 2 or 4 bits per dimension, with NVQ or full-precision reranking.
  • Query projection. Native FP16 by default with FP32 fallback; INT8 on request for VNNI/AMX processors. Native builds include the oneDNN projection engine.
  • Shared projection. QueryPreparer/PreparedQuery separate projection from search, and BatchingExecutor queues concurrent searches with bounded admission so that waiting queries share one batched projection.
  • BLAS threads. JVector sets the native BLAS thread count for ASH training (one, or the size of the executor passed in), instead of MKL or OpenBLAS starting a thread per CPU.
  • BenchYAML. Schema 3 index YAML with explicit pipelines and worker counts; compressed dataset defaults use fused ASH; a launcher runs one experiment in one JVM.

Release notes: 796.feature.md, 796.enhancement.md. Documentation starts at ASH configuration and defaults and Integrating ASH queries into a database.

Compatibility

  • On-disk format version 7 adds only fused ASH; serialized ASH models also require it. JVector 4.0.x rejects version 7 files. CURRENT_VERSION and the writer defaults are now 7.
  • Formats 6 and below are unchanged. A host that passes withVersion(6) and write(out, 6) keeps writing files 4.0.x reads, and a version 6 writer rejects fused ASH. DataStax Cassandra already passes its SAI-selected version to every JVector write, so it will not write version 7 until it adds an SAI version that selects it. See UPGRADING.md.
  • PQ, BQ and NVQ behavior and their defaults are unchanged. OnDiskGraphIndexCompactor does not yet compact fused ASH indexes.

Dependencies

  • New Maven dependencies: dev.ludovic.netlib:blas and dev.ludovic.netlib:lapack 3.0.3 (MIT), which bring net.sourceforge.f2j:arpack_combined_all 0.1 (BSD). They are used only for ASH training, and the published jvector artifact declares them. netlib calls the system BLAS and LAPACK, such as MKL or OpenBLAS, and falls back to Java implementations when none is installed, at a cost in training time.
  • oneDNN 3.14.0 (Apache 2.0) for native query projection and batch encoding. Meson fetches it at a pinned commit, and it is linked statically into a separate add-on library, libjvector_ash_fp16-<arch>.so (19 MB on x86-64), shipped in the native JAR with its license and third-party notices. Building it needs CMake 3.13 or later; -Dnative.onednn=false builds without it.

Native interface

  • The native ABI stays at 1. The ASH kernels in libjvector (query projection, symmetric construction scoring, neighborhood and single-vector scoring) are compatible additions under the existing handshake. Java looks each one up separately, so an older native library keeps the Java implementations; an explicitly native ASH benchmark fails instead of measuring a fallback.
  • The oneDNN add-on has its own ABI query (jvector_ash_fp16_abi, currently 1) and status codes, and no C++ exception crosses it. It loads only if present and compatible; otherwise FP16 and INT8 projection are unavailable and queries use the Highway FP32 path. See Native ABI compatibility.

Validation

  • Full suite: mvn -B clean install -DskipSIMD=false -DskipScalar=true on JDK 22 with the native provider: 825 tests, 0 failures or errors, 6 skipped.
  • Rollback reads: files written by this branch in DataStax Cassandra's current layouts (format 6 with fused PQ, NVQ and a hierarchy; format 6 with NVQ; format 4 with inline vectors; separate PQ vectors at versions 4 and 6) were read with this branch and with JVector 4.0.0-rc.8-hf1 from Maven Central. Headers, features and all-layer adjacency were identical; top-10 node IDs matched for all 75 searches, and scores differed by at most 1e-6.
  • Format versions: round trips for every selectable version (3 through 7, single- and multi-layer), version 6 with fused PQ, NVQ and a hierarchy, rejection of fused ASH at version 6, and ASH model version and magic-number checks.
  • BLAS threads: with MKL, ASH training without an executor starts no MKL threads, and with a 48-thread executor it starts 48.
  • Planned during review: the full suite and benchmarks on JDK 25.

Benchmarking

Fused ASH versus fused PQ at the same stored bytes per vector, including ASH's 5-byte header, on three large datasets. ASH has lower construction time in 6 of 6 configurations, higher recall in 12 of 12 cases and higher QPS in 22 of 24.

Construction

Total construction seconds (training, encoding, NVQ, graph build and write); lower is better.

Dataset Compression PQ ASH ASH change
MPNet 10M 32x (b=2) 343.7 238.1 -30.7%
MPNet 10M 16x (b=4) 568.9 301.2 -47.1%
Cohere 10M 32x (b=2) 478.0 263.3 -44.9%
Cohere 10M 16x (b=4) 771.2 335.8 -56.5%
CAP 6M 32x (b=2) 295.6 190.2 -35.6%
CAP 6M 16x (b=4) 630.7 261.8 -58.5%
Sum 3,088.1 1,590.5 -48.5%

Recall and throughput

Values: PQ → ASH. Rerank 1x and 2x rescore 10 and 20 candidates with NVQ.

Dataset Compression Rerank Recall@10 QPS, 1 core QPS, 8 cores
MPNet 10M 32x (b=2) 1x 0.5404 → 0.7553 9,349 → 9,012 77,609 → 81,421
MPNet 10M 32x (b=2) 2x 0.7151 → 0.8890 7,540 → 7,266 61,759 → 62,913
MPNet 10M 16x (b=4) 1x 0.7245 → 0.8133 7,224 → 9,860 63,221 → 76,619
MPNet 10M 16x (b=4) 2x 0.8620 → 0.9005 5,661 → 7,397 48,894 → 57,709
Cohere 10M 32x (b=2) 1x 0.5212 → 0.5868 7,642 → 9,067 62,520 → 72,500
Cohere 10M 32x (b=2) 2x 0.7061 → 0.7618 5,826 → 6,875 48,494 → 55,340
Cohere 10M 16x (b=4) 1x 0.5986 → 0.6603 6,145 → 8,339 50,152 → 65,660
Cohere 10M 16x (b=4) 2x 0.7585 → 0.7886 4,684 → 6,122 38,369 → 49,100
CAP 6M 32x (b=2) 1x 0.4124 → 0.7212 5,815 → 7,295 51,746 → 62,544
CAP 6M 32x (b=2) 2x 0.5971 → 0.8857 4,748 → 5,756 41,022 → 48,482
CAP 6M 16x (b=4) 1x 0.6566 → 0.8047 4,436 → 6,295 39,786 → 58,394
CAP 6M 16x (b=4) 2x 0.8397 → 0.9075 3,550 → 4,774 31,393 → 41,777

PQ is marginally faster (about 4%) in the two MPNet 10M 32x cases on one core. Single-core QPS varies between graphs built from the same settings: an earlier ASH graph for that case measured 10,414 / 7,838 QPS, above PQ.

Method

One Intel Xeon 6985P-C socket (48 physical cores, two NUMA nodes), JDK 22, native provider. On-disk format 7, fused graph, degree 32, efConstruction 100, overflow 1.2, hierarchy and refinement on, NVQ reranking, search pruning off, top 10. ASH: ITQ, one landmark, D/2 projected dimensions, symmetric construction scoring, queued execution with batches of up to 8 and FP16 query projection. PQ: 8-bit subspaces sized to the same bytes, direct execution. Construction used one thread on each of 48 cores with memory interleaved across both nodes; queries ran in separate JVMs on 1 or 8 cores of one node. Construction is the mean of two builds; QPS is the mean of three samples of at least 3 s against the first build. PQ results come from an earlier run with the same machine, JDK, placement, inputs and graph parameters. Full details: ASH performance. The study can be rerun with Reproducing ASH versus PQ. During review we plan to rerun these datasets with the one-JVM recipes, run the full set of 1M datasets, and repeat both on JDK 25.

…pooled version for performance purposes though.
… scoring. Also made a slight change to limit copying query info from query pool (only at landmark run boundaries) during block scoring.
… scoring. Also made a slight change to limit copying query info from query pool (only at landmark run boundaries) during block scoring.
…ding payload alignment exception for recall benchmarking purposes.
…ed vectors. Recall and perf look acceptable.
…ed layout with header interleaving. Introduced ashMaskedAdd_512.
Merge remote-tracking branch 'origin/main' into ash-dev
…to reduce conversions and memory traffic without changing training behavior. Iterations are ~2.6X faster.
The INT8 projection tests fail on one CI runner by one or two ULPs against the oracle but pass on
every machine we can reproduce on. The tile and oracle assertions now name the CPU vendor, model,
the flags that select the INT8 path (VNNI, AMX, AVX-512 FP16/BF16) and the oneDNN override, so a
failure shows which path ran. The message is built only when an assertion fails.
Vector API lane reductions do not fix their summation order, so an interpreted and a compiled
scoring of the same 1-bit block can differ by one ULP; under JDK 25 the test's exact comparison
failed on that alone (e.g. 0.61249167 vs 0.6124917). It now compares within 1e-5. Against the
code before the fix it still fails every run, with errors of 0.02 to 0.09.
return pointer ? static_cast<const Context*>(pointer)->retained_bytes.load(std::memory_order_acquire) : 0;
}

JV_ASH_INT8_TARGET int jvector_ash_int8_project_batch(void* pointer, const float* input, int count,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

With the addition of hand written VNNI kernels, this ideally belongs to the Highway module which has all the infrastructure to deal with SIMD code (compile time and runtime dispatch). Only problem is I am not sure how to cleanly separate the AMX portion which relies on onednn.

…tart

FvecFileVectorValues loads the vectorization provider, possibly a native library, when
its class initializes. That happened inside whichever timed test ran first and took
111 ms of a 1 s limit on an idle server, more on a busy CI runner. Initializing the
class in @BeforeClass leaves each timeout covering only the read loop it guards; the
limit is unchanged.
…al step

The projected value is (coarse + fine / 254) * scales. Instead of turning off
floating-point contraction for the whole library, combine the two digit dot products
exactly in 64-bit integers first, coarse * 254 + fine, so the remaining steps are one
conversion and multiplications only. With no product added to anything there is
nothing to fuse into an FMA, every build rounds the same way, and the combine loop
stays free to vectorize. The test oracle uses the same expression, so the INT8 tests
still compare exactly.
}

extern "C" {
void* jvector_ash_int8_create(const float* matrix, int rows, int columns, jvector_ash_fp16_result* result) {

@r-devulap r-devulap Nov 12, 2026 •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was trying to think of ways we could untangle the SIMD portion in this file and move it Highway module. Could INT8 be modeled like the FP32 kernels? FP32 keeps the model in Java and calls a stateless Highway kernel (ash_project_batch_f32), so ISA dispatch comes from Highway. INT8 has C++ own the model and do its own ISA detection and VNNI intrinsics. If Java owned the int8 codes, scales and sums and a Highway kernel did the VNNI dot (SumOfMulQuadAccumulate maps to vpdpbusd), INT8 would benefit from all the Highway module infrastructure which we would avoid duplicating here. Java could still dispatch to oneDNN in the cases Highway doesn't cover, so the choice lives in one place. Is there a reason it can't?

A JIT deoptimization inside the measured window can make the JVM's own
VarHandle linkage allocate a few hundred bytes. Require one of up to three
windows to be allocation-free; a real allocation appears in every window.
A one-second timeout measured the CI machine as well as the read loop and failed under load
after the test body had finished. The test channel now fails after a fixed number of read calls,
which catches a spinning reader deterministically, and the EOF test also checks its call count.
A layer search expands a scored node only while it ranks among the best rerankK
accepted nodes scored so far, or, with adaptive termination, within the cutoff of the
rerankK-th; that bar only rises. A node that misses it when scored can never be
expanded by that search, so it now goes to an unsorted deferred list instead of the
candidate heap, and returns to the candidates at the next layer search, so later
layers and resume() see the same candidates as before. Most scored neighbors are
rejected with one comparison, as in DiskANN's bounded search list.

The same top-rerankK heap drives adaptive termination, replacing its membership hash
set: the visited set already keeps scored nodes unique, so the extra offer on each pop
and the membership lookups are gone. Threshold searches keep every candidate.
Results, scores, and visited and expanded counts are unchanged; a test compares bounded
and unbounded searchers across stopping rules, filters, hierarchy and resume().
Graph views gather an expanded node's unvisited neighbors and score them in one batch when the
score function supports it. ASH symmetric construction scoring gathers the neighbors' codes into
one contiguous buffer and scores them in one bounded native call that reuses the pair kernel, so
batch and single scores are identical.
ASH score functions now declare that they require adaptive stopping, and GraphSearcher stops adaptively
whenever the score function requires it, whatever SearchScoreProvider wraps it. An ASH search therefore
cannot fall back to the standard rule because an application wrapped its scores in a provider that
did not ask for adaptive stopping; GraphSearcher.useAdaptiveTermination(false) remains the explicit
override. A test checks plain, adaptive-requesting and application-written providers against each other
and the override against a standard search.
ASH clamped its DOT_PRODUCT similarity at zero, so every estimate at or
below -1 tied. That was a workaround for hierarchy descent starting from a
zero floor, which now starts from negative infinity. Use (1 + dot) / 2
unclamped in Java and native, the scale exact DOT_PRODUCT scoring uses.

Add TestUnnormalizedVectors: dot-product search over vectors whose
lengths vary 100x must rank by the full dot product for exact, PQ and ASH
scoring, by brute force and through a graph, judged against the same
directions at unit length and against norm-blind ranking.
… scorer

Adaptive stopping read graph scores as distances between unit-length vectors,
so on vectors of other lengths it fell back to the standard rule or misjudged
the margin, and it applied only to ASH. It now reads scores as true metric
distances: d^2 = 1 / score - 1 for Euclidean, 1 - score for cosine, and for
dot products the distance after the standard reduction of inner-product search
to nearest-neighbor search, measured from an upper bound on the query's dot
products, its length times the largest stored vector length. The margin, about
0.005 on the distance, means the same thing for every similarity function and
every vector length, and on unit-length vectors the rule decides as before.

A search that stops adaptively expands at least the nodes the standard rule
would, and at most the nodes a standard search with twice the beam would.
Without the cap, a relative margin admits very many candidates when distances
are concentrated. The bar below which scored nodes are skipped never falls
during a search, so skipped nodes stay skipped.

The largest vector length comes from the vectors. ASH and PQ models record it
for every vector they encode or whose codes they hold, and save it with the
model; the inline and separated vector features and compaction store it in the
index header, at format version 7. A length that is unknown, as for a model
loaded from an older format, keeps dot-product searches on the standard rule.

Searches through DefaultSearchScoreProvider.exact and forSimilarity, and all
ASH searches, stop adaptively; providers built with the plain constructors keep
the standard rule, and GraphSearcher.useAdaptiveTermination(false) turns
adaptive stopping off.
Measured against standard stopping swept over overquery, at 1M and 10M, with
ASH and PQ scores, with and without hierarchy, adaptive stopping gave less
recall at the same throughput or nodes visited: raising the overquery reranks
more candidates, which adaptive stopping does not. GraphSearcher therefore
disables it by default, and useAdaptiveTermination is marked experimental.
Searches that enable it behave as before.

Graph construction keeps its behavior: the builder's searchers enable adaptive
stopping, which applies where the build provider asks for it, as ASH's does.
Comment thread UPGRADING.md
`withVersion(6)` on graph writer builders and `write(out, 6)` for compressors and compressed vectors.
Versions 6 and below still write inline vectors, NVQ, separated vectors and fused PQ exactly as before,
and 4.0.x reads them. A version 6 writer rejects fused ASH, and ASH models reject version 6.
- `OnDiskGraphIndexCompactor` always writes `CURRENT_VERSION`; it has no option to write an older version.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This prevents the possibility of rollback as mentioned above for anyone who's using compaction. Compaction is a new enough feature that we might not need to care, on the other hand it seems a small enough change to add withVersion or similar support for the OnDiskGraphIndexCompactor. Does is it matter enough?

if ((int) abi.invokeExact() != 1)
throw new UnsatisfiedLinkError("Unsupported ASH projection add-on ABI");
create = bind(lookup, "jvector_ash_int8_create",
FunctionDescriptor.of(ADDRESS, ADDRESS, JAVA_INT, JAVA_INT, ADDRESS));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is it possible to use jextract to generate these bindings rather than maintaining them by hand, similar to the SIMD library? Otherwise we need to manually ensure consistency between C and Java.

Comment thread docs/release notes/4.1.0/796.feature.md Outdated
Comment on lines +35 to +36
FP16 where the CPU supports it, falling back to FP32. INT8 projection is available on request
for x86 processors with VNNI or AMX. Native builds include the oneDNN projection engine by

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The default auto uses int8 projection, so it's not only enabled "on request"

JV_ASH_INT8_TARGET float quantize(const float* input, int count, Code* output, int offset, int32_t* sum = nullptr) {
float maxabs = 0;
for (int i = 0; i < count; ++i) {
if (!std::isfinite(input[i])) throw std::invalid_argument("Nonfinite INT8 operand");

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks like an invalid input (NaN, inf) in any of the queries causes the entire batch to fail, and the error fans out to each input query, even the valid ones. This seems undesirable, can we propagate NaNs for that particular query instead? I think the fp16 version also has a similar check which causes the same issue.

float offset = readFloat16(in);

byte landmark;
if (in instanceof io.github.jbellis.jvector.disk.ByteReadable) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

RandomAccessReader extends ByteReadable, so this check is always true. Similarly for other places in this file and one in AshCodes

* 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<T> implements AutoCloseable {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Many of the new constructs are not marked @Experimental, for example this one. Is that intentional?

… and search-level query execution

Page alignment (format 7, opt-in through withPageAlignment) places base-layer records so none crosses
a page boundary: small records are packed per page, larger ones padded to whole pages when their last
page is at least half full. AsymmetricHashing.setQueryOptions sets query projection precision and batch
size in code; the system properties remain the defaults. BenchYAML gains construction.pageAlignment and
moves queryExecution and requestsPerWorker to the search level, for any compression.
…; add an mpnet-10m example

Adds a guide to building and serving indexes larger than memory, a BenchYAML example whose records fit
one 4 KB page, and updates the BenchYAML guide, ASH configuration, integration, precision and batching
docs, UPGRADING and the release note for the new controls. Aligns the docs with the code: the auto query
projection selects INT8 first.
…-memory run config

The run YAML gains a measurement section: evictIndex drops each index from the page cache before its first
search, and throughput settings choose warmup queries (random, base or none), warmup passes, warming until
JIT compilation settles, timed passes and the minimum seconds per pass, which replaces the
jvector.bench.throughputMinSeconds property. Execution metrics add p50 and p99.9 response times and page
reads per query, and now cover direct execution. The default run config is trimmed for a quick first run;
run-config-larger-than-memory.yml measures cold queries on a cold index.
…stead of re-encoding

A non-fused disk index (for example the DiskANN-style layout, with ASH codes in memory and NVQ vectors on
disk) now writes the build's ASH model and codes beside the index file during the flush. Opening the index,
whether just built or reused from the index cache, loads them, and a search whose ASH settings match the
stored model uses them as a deployed searcher would. Indexes without stored codes, PQ codes and mismatched
settings still encode at search time. At 10M vectors the codes take about 2 s to write and under 1 s to
load, where encoding them again took 12-14 s (263-284 s at 100M).
…s final

Eviction works on JDK 22 as on JDK 25 (checked on Linux: no pages of the evicted file stay resident), so the
cold-index test runs it from JDK 22 and the error message and benchmark guide say JDK 22+.
…nchYAML results

When a search loads the ASH codes stored with a non-fused index, search_quant_time_s reports
ASH.load_time_s in place of ASH.encoding_time_s, so a result row shows which of the two it did, for a
cached index as well. construction.codes_write_time_s is the part of the flush spent writing the codes,
and construction.codes_file_size_mb is the size of the codes file. Loading the codes after a build is
now timed on its own rather than inside reopen_time_s. The benchmark guide explains when codes are
stored or encoded.
…Guide

A new Results section lists the fixed experiments.csv columns, and for each benchmark and named
metric gives its run YAML selector, CSV key, console header and meaning. The guide's earlier
paragraphs on construction and execution metrics now point to it, and How to run benchmarks and
the README link to it.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

4.1.0 Release Label enhancement New feature or request performance improvement A contribution that aims to improve library performance, possibly along with functionality.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants