Skip to content

Add Asymmetric Scalar Hashing (ASH) quantization - #796

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

tlwillke wants to merge 254 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.

…hout loading all vectors first). Updated DistancesASH and DistancesPQ to take advantage of this. The original SiftLoader remains intact.
…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.
…unners

The 1 s timeouts only guard against a read loop that never ends, but they also
cover the test class's first use of FvecFileVectorValues; on the JDK 24 AVX-512
runner that pushed one 32-read test just over the limit. 10 s still catches a spin.
… fast scoring

Every stored offset now includes -scale * <A mu_c, code>, as 2- and 4-bit
codes already did, so asymmetric scoring at any width and landmark count
needs only Aq. This removes the per-landmark centered queries A(q - mu_c),
their preparation and the masked-add entry points that existed only for
them. Code-to-code scoring adds the term back from the stored code. The FP64
projection copy used for landmark projections now holds the FP32 values that
models persist, so a trained model and its reloaded copy derive identical
landmark terms.

3- and 5- to 9-bit codes are now offset-binary fields: dimension j is the
unsigned b-bit field at bits [j*b, (j+1)*b), with value field - (2^b - 1)/2.
The values match the previous sign-plus-magnitude-bits layout, which needed
a separate sign plane padded to 64-bit words. Because each field is linear in
its bits, any 4 bits of a code have a 16-entry query table, so these widths
use the existing nibble-LUT kernels for block and single-vector scoring, and
fused graphs accept every width from 1 to 9.

In fused graphs these widths store field blocks: each unit of eight
dimensions holds every lane's b code bytes contiguously. The query Aq is
rounded once to INT8, and a new native entry, ash_score_neighborhood_i8_c1,
gathers each lane's fields, rotates them into bytes (vpmultishiftqb) and
accumulates exact integer dot products (vpdpbusd); it is compiled for the
AVX3_DL tier, with Highway's equivalents elsewhere. On a 32-lane neighborhood
it is 1.19x faster than the LUT kernel at 3 bits and 2.05x at 8 bits on an
Ice Lake class CPU, and far faster where AVX-512 is unavailable. Rounding the
query moved exhaustive recall@10 on 1M datasets by at most 0.0003. A Java
path with the same arithmetic covers other providers and multiple landmarks.

1-, 2- and 4-bit layouts and kernels are unchanged. Old and new code matched
exhaustive recall@10 within 0.00015 at widths 1, 2, 3, 4 and 8 and C = 1, 16
and 64 on three 100k datasets and DPR-Gemma 1M, with the same scoring and
encoding throughput at 1 and 2 bits.
…t VNNI kernel

Automatic query projection now tries INT8 first, then FP16 with its FP32 range
fallback, then FP32. INT8 was the fastest projection on every CPU measured:
1.03-1.10x FP16 on an AMX CPU and 1.2-1.5x FP32 on Ice Lake class CPUs.

The query is now quantized to two INT8 digits with one scale: a coarse digit
and its rounding remainder times 254. Rounding the query, not the matrix rows,
caused nearly all INT8 projection error; with the second digit, exhaustive
recall@10 on ada002-100k matched FP32 projection within 0.0002 at 2 and 4 bits,
where single-digit INT8 lost 0.0018 and 0.0048. Both digits run through the same
exact-integer kernel, and the oneDNN path computes the same result.

On every AVX-512 VNNI CPU the direct tile kernel now serves single queries and
batches: 512-bit tiles on CPUs with AMX, where they beat oneDNN's AMX path, and
256-bit tiles otherwise, where wide integer dot products slow the following
graph search. oneDNN remains for AVX2-VNNI CPUs without AVX-512.
…rs it

Offset-binary codes (3 and 5-9 bits) score single vectors with SIMD only when the
backend supports canonical LUT scoring. The Panama backend supports the 2- and
4-bit LUT kernels but not that one, so forcing SIMD there threw by design and
failed the test on Windows, where the native library is not loaded. The test now
requests SIMD per bit width only when the backend can honor it.
…der, and API defaults

- ASHVectors publishes 1-bit packed sign words as one immutable holder after they are filled, so
  concurrent first queries never score a partial array or one packed for another block size.
- NativeBlasThreads reference counts scopes on a process-wide BLAS setter (OpenBLAS before 0.3.27):
  the first scope sets the count and the last restores it, so overlapping scopes on other threads
  no longer undo each other.
- GraphSearcher.searchOneLayer uses the score provider it is given for adaptive termination.
- VectorUtilSupport.ashMaskedAdd_512 has a portable default, so outside implementations compile.
- KMeansPlusPlusClusterer reports its verbose progress through the logger.
- MemorySegmentReader and MMapReader read single bytes without allocating.
Every Maven build printed each shell command of build_native_lib.sh plus the full Meson and Ninja
output. By default the script now prints one line when each library starts building and one with
its build time when it finishes. Meson and Ninja output goes to target/meson-build-<arch>.setup.log
and .compile.log, and a failing step prints the end of its log and the log's path. Errors and the
script's own warnings still print. Setting JVECTOR_NATIVE_BUILD_VERBOSE=1 restores all output,
including the command trace.
…ipeline line

A compression of None means the stage scores full-precision vectors, so the pipeline line now says
FP32 instead of None (e.g. 'inMemory: build FP32 -> search FP32 -> rerank None'). Rerank keeps
None, since that step is not done.
Every Maven run printed a start and finish line per native library even when Ninja had nothing to
do, so repeated builds (IDE reimports, multi-step runs) filled the console. The start line now
prints only for an unconfigured build directory, the slow first build, and the finish line only
when Ninja rebuilt something; an up-to-date build prints nothing.
@tlwillke
tlwillke requested a review from MarkWolters November 12, 2026 00:23
Every Maven run reran meson setup --reconfigure. For the oneDNN add-on that regenerated its CMake
subproject and relinked the library each time, adding 2-3 s and a 'built' line to every build.
Each build directory now records its setup arguments and is reconfigured only when they change;
meson compile still regenerates on its own when a meson.build file changes. An up-to-date build
now takes about 0.3 s and prints nothing.
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.
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.

4 participants