145 Commits
Author SHA1 Message Date
Andrey VasnetsovandClaude Opus 5.5 6a923f9d43 Split EncodedStorage into read and write traits (#11312)
EncodedStorage is now the read trait and EncodedStorageWrite extends it
with upsert_vector/upsert_many/flusher, so immutable storages implement
only the read side. The same split is applied to EncodedVectors
(EncodedVectorsWrite) and MultivectorOffsetsStorage
(MultivectorOffsetsStorageWrite), removing the Unsupported upserts and
no-op flushers on read-only types. QuantizedVectorStorage now decides
immutability in one exhaustive match. TurboVectorBlob drops flusher,
since all its implementations are immutable.

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-23 12:00:24 +02:00
56fafa831e Score stored vectors only in batches, so a failed read returns an error (#11116)
* Return a failed read from single-point scoring instead of panicking

Follow-up to #11064. The dense, multi-dense, and turbo scorers read the
point in `QueryScorer::score_stored` through their infallible getters, so
HNSW search still panicked when its entry point could not be read.

`score_stored` is removed from `QueryScorer` and its 14 implementations.
Its only caller was `RawScorer::score_point`, which is now a provided
method that scores a batch of one through `score_points`. Every scorer's
batch path reports a failed read, and the quantized scorers already scored
a single point this way.

Two batch paths still panicked on a failed read:
- the immutable dense storage on a synchronous backend read each vector
  through `get_vector`;
- the quantized multi-vector storage's in-RAM path read the offset with
  `get_offset` and the sub-vectors with `expect`. The chunked read-only
  offsets returned an empty default instead, which silently scored the
  point `-inf`.
Both now return the error.

Found by fault injection against object storage.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012FyoTtcVeNR7DjV6nuWF3f

* Score several points in one batch where callers can

`RawScorer::score_point` is a batch of one. The callers that score more
than one point now batch:

- HNSW greedy entry search re-scored the entry point on every level,
  although the previous level's batch had already scored it. It now
  carries the scored point down, as the batched and inline-vector
  searches do.
- The graph healer scores a point's deleted neighbors in one batch.
- MMR compared every remaining candidate with every selected point each
  round, through a lazily filled n-by-n matrix. It now keeps each
  candidate's maximum similarity to the selected points and updates it
  with the newly selected point only. For dense and sparse vectors,
  whose metrics are symmetric, the remaining candidates are scored
  against that point in one batch. Multi-vector MaxSim is not
  symmetric, so each candidate is still scored as the query, one at a
  time.

`test_mmr_matches_pairwise_reference` checks that MMR selects the same
points in the same order as the pairwise algorithm did.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012FyoTtcVeNR7DjV6nuWF3f

* Drop the infallible single-point score from EncodedVectors

`EncodedVectors::score_point` read the point through the infallible
`get_vector_data`, so it would panic on a failed read. Nothing in
`segment` called it: every scorer goes through `score_points`. It is
removed from the trait and from the PQ, TQ, U8 and binary encoders.

The quantized multi-vector storage implemented it with an `expect` on
its sub-vector read. It now overrides `score_points` with its batched
path, which returns the read error.

`TurboMultiScoring::score_point_max_similarity` had no callers and its
implementations panicked on a failed read; it is removed.

The quantization tests score a single point through `score_points`
with a batch of one. The benches score through `score_bytes` on the
stored vector, as the removed method did, so results stay comparable.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Keep the pairwise MMR scoring

Reverts the MMR part of "Score several points in one batch where callers
can". Scoring candidates in one batch against the newest selected point
flips the query and the stored vector, which needed a separate path for
multi-vector MaxSim, as it is not symmetric. MMR goes back to the lazy
similarity matrix, which already returns a failed read as an error.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: generall <andrey@qdrant.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 18:26:41 +02:00
Jojii 428f083997 Fix: Skip missing vectors in quantization calibration (#11110)
* Fix: missing named vectors influencing quantization calibrationn

* Use mask instead of vectors.
2026-10-13 09:56:22 +02:00
Daniel BorosandLuis Cossío b087fa6994 fix: make quantized storage batch reads fallible (#11062)
* fix: make quantized storage batch reads fallible

* remove extra helper

---------

Co-authored-by: Luis Cossío <luis.cossio@outlook.com>
2026-10-09 23:02:21 +02:00
xzfc fad34e8a0f common::{counter,uio_trace} => common::ambient (#11040)
* Normalize Cargo.toml

* 🤖 ambient: move uio_trace into common::ambient::trace

File split and path updates only; names unchanged.

* 🤖 ambient: move counter into common::ambient

File split and path updates only; names unchanged.

* 🤖 ambient: rename items after the move

HwHandoff → Handoff, HwScope → Scope, HwFuture[Ext] → AmbientFuture[Ext],
uio_trace::Request → IoRequest, hardware_usage_accumulator → handoff, etc.

* 🤖 ambient: rename local variables after the move

_hw → _scope, hw_acc → ctx or handoff, etc.

* 🤖 ambient: add hw scope annotations to tests, edge loading and prefetching
2026-10-09 15:48:50 +00:00
Arnaud GourlayandClaude Opus 5.5 dd2acb9d89 [AI] perf: VNNI kernels for the scalar-quantized u8 dot product (#10975)
* perf: VNNI kernels for the scalar-quantized u8 dot product

The int8 dot product (Dot, Cosine and L2) ran the AVX2 C kernel built for
haswell: per 32 bytes a maddubs, two widenings to i32 and two adds, on
one accumulator. On CPUs with AVX512-VNNI or AVX-VNNI one vpdpbusd does
the multiply, the pairwise sum and the accumulation for 64 or 32 bytes.

score_dot_x86 picks AVX512-VNNI, then AVX-VNNI, then the existing AVX2
kernel. Codes are clamped to 0..=127, so the unsigned-by-signed products
are exact and the result is the same integer as the scalar kernel.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* refactor: dispatch VNNI from score_bytes, not inside the AVX2 path

score_point_avx ran VNNI kernels on capable CPUs, so the AVX2 tests and
benches no longer exercised AVX2 there. The VNNI kernels now get their own
score_point_avx512_vnni / score_point_avx_vnni methods (plus _internal),
selected in score_bytes and score_internal ahead of AVX2, and
score_point_avx is back to the AVX2 kernel.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-09 11:23:43 +02:00
6c75188f52 Turbo storages: don't drop the page cache of in-RAM storages after the index build (#11042)
* Turbo storages opened in RAM report is_on_disk() == false

The single-file turbo storages (mmap and io_uring) always reported
is_on_disk() == true, even when opened with populate. After building the
HNSW index the segment builder drops the page cache of on-disk vector
storages, so the in-RAM turbo storage was then read back from disk with
MADV_POPULATE_READ on a random-advised mapping when the segment loaded:
~3 GB at ~30 MB/s on dbpedia-1536, about 105 s of a 230 s index phase.

Report !populate, as the dense storages do.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019inxbwm1wokue8vUje63p8

* Read-only turbo storage opened with populate reports is_on_disk() == false

The read-only flat turbo storage hard-coded on_disk: true even when the
load profile asked it to populate, the same misreport fixed for the
writable storage.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Read-only HNSW reports the residency of the graph it actually holds

is_on_disk was taken from the configured memory before the load-profile
override, so a demoted cold graph reported false, and a graph borrowed
from a graph-inline vector storage was ignored. Report the loaded graph's
residency, or the deferred load's.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Forward is_on_disk through read-only map and numeric field indexes

ReadOnlyFieldIndex::is_on_disk panicked with todo!() for map and numeric
indexes.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Sparse search counts IO from the opened inverted index, not the config

Low-memory mode opens an in-RAM configured sparse index as mmap, which the
configured index type still reported as in RAM.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Segment builder drops page cache by what the opened components report

Dense vectors asked the opened storage while sparse vectors, sparse index
and payload storage asked the config. Ask the opened component everywhere.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Rename the runtime is_on_disk() of opened components to is_cold()

The runtime method shared its name with the config-level is_on_disk() and
with the heap-vs-mmap is_on_disk flags of the field-index builders, while
every caller (IO accounting, page-cache drops, memory telemetry, the
follow-storage quantization default) asks one question: were the pages
left on disk at open? Name it after Memory::is_cold, which has the same
meaning on the config side, and state that contract on the traits.

Config-type is_on_disk() methods keep their name for now.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Report residency, not the mmap layout, from is_cold()

Several mmap-backed components answered is_cold() with a constant true
even when their placement had populated them:

- QuantizedStorage and InvertedIndexCompressedMmap, populated after open
  for the cached placement: track populate()/clear_cache().
- the on-disk map, numeric, geo and full-text field indexes, opened with
  populate for the cached placement: record it at open.
- ChunkedVectors read the populate flag persisted when the directory was
  created, not the one it was opened with; drop the persisted flag.

The persisted field-index StorageType::Mmap { is_on_disk } records the
mmap-vs-heap variant, so it no longer derives from is_cold().

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* One cold flag per component instead of populate/on_disk pairs

- turbo finalize took populate and on_disk, always passed as complements:
  take cold, and derive the deletion flags' populate from it.
- HNSWIndex cached its graph's residency in a separate is_on_disk field;
  ask the graph.
- the residency flags threaded through the HNSW build, the turbo storages
  and the follow-storage quantization default (on_disk_vector_storage) are
  named cold, after what they carry.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Field indexes take Memory instead of an is_on_disk variant flag

The immutable field-index builders and read-only opens took is_on_disk,
meaning "the mmap variant rather than the heap one" and, on the
read-only side, also "don't populate". The read-only side then lost the
cached placement: an mmap-variant index whose schema asks for cached was
opened unpopulated, while the writable open populates it.

- builders and read-only opens take the placement; is_heap() picks the
  variant and the read-only opens populate, low-memory clamp included,
  exactly as the writable open does. The read-only geo open no longer
  ignores low-memory mode when picking its variant.
- StorageType::immutable_memory() resolves the persisted variant plus the
  schema placement, shared by the writable selector and the read-only
  dispatcher; its Rust field is on_disk_variant, still is_on_disk on disk.
- a load-profile override goes through Memory::with_populate_override,
  so a warm-ward override primes an mmap index instead of loading it into
  heap, as for every other component.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Config placement methods are is_cold(), sparse index layout is is_mmap()

Memory::is_on_disk() duplicated Memory::is_cold(); delete it. The config
methods derived from the memory placement (vector data, HNSW, payload
storage and field-index schemas) report is_cold() like the opened
components, so one name means one thing on both sides.
SparseIndexType::is_on_disk() reported the mmap index type, which a
cached placement also uses: name it is_mmap().

Legacy on_disk fields and from_on_disk() conversions keep their name.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Persisted field index layout is an ImmutableLayout enum, not a bool

StorageType::Mmap { layout: ImmutableLayout::{Mmap, Heap} } names which
in-memory variant an immutable index was built for. Still persisted as the
legacy `is_on_disk` bool, covered by a wire-format test.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Field index builders apply the cached placement

A non-appendable build is installed as-is by live index creation, so a
cached index came out unpopulated and served cold until reload. Builders
now populate like an open (populate_on_open, low-memory clamp included).
GeoIndex::OnDisk forwarded a hard-coded cold, now the opened residency.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Andrey Vasnetsov <andrey@qdrant.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 23:03:12 +02:00
24f67eb18e Add the Turbo16 vector datatype (#11024)
* Add the Turbo16 vector datatype

TurboQuant at 16 bits per element: the shared Hadamard rotation, then a
per-vector absmax scale onto the uniform grid [-32767, 32767], stored as
little-endian i16. Like Turbo8 there is no codebook: the code is the
value, and the per-vector scale cancels in the existing `l2 / ||c||`
renormalization. A vector takes `2 * dim + 4` bytes (`+ 8` for
Euclid/Manhattan) - the size of Float16 plus the extras - with ~7x lower
reconstruction error than Float16.

- quantization: `TQBits::Bits16` (Normal mode only, no TQ+). New
  `simd/query16bit` with one set of kernels for both scoring paths:
  `madd_epi16` on SSE4.1 / AVX2 / AVX-512BW, `vmull_s16` + `vpadalq_s32`
  on NEON. A pair of full-grid products fills an i32 lane, so the x86
  kernels convert each pair sum to f32 (exact products, f32
  accumulation); NEON widens into i64 and is exact. The asymmetric query
  is quantized onto the same grid with its own scale.
- API/segment: `Datatype::Turbo16` (REST "turbo16", gRPC `Turbo16 = 6`),
  served by the existing turbo storages. Rejected for sparse vectors.
- Tests: kernel parity/overflow, turbo storage tests over Bits16,
  model-test candidate, integration cases, test_turbo16_storage.py,
  a 16-bit cold-score criterion bench.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019inxbwm1wokue8vUje63p8

* NEON: four accumulator chains and 16/8-code steps in the 16-bit kernel

Measured on Neoverse N2 (criterion `query16bit_score_cold`): equal to the
two-chain loop with the vectors in L2, 1-12% faster from DRAM. The 16- and
8-code steps leave at most 7 codes to the scalar tail instead of 15.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019inxbwm1wokue8vUje63p8

---------

Co-authored-by: Andrey Vasnetsov <andrey@qdrant.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 17:26:33 +02:00
qdrant-cloud-bot 8b5d4aeaae fix(quantization): use a single /arch:AVX2 flag for MSVC-like compilers (#10754)
* fix(quantization): use a single /arch:AVX2 flag for MSVC-like compilers

Multiple `/arch:` flags are not cumulative — clang-cl and MSVC take the last
one. Passing `/arch:SSE2` after `/arch:AVX2` downgraded the ISA so clang-cl
(cargo-xwin) rejected SSSE3/SSE4.1/AVX2 intrinsics. `/arch:AVX2` alone matches
`-march=haswell` used on the non-MSVC path.

Fixes #10753

* chore: rustfmt placeholder.rs for pre-push hook

Pre-existing formatting drift on dev that blocks `cargo +nightly fmt --check`.

* Revert unrelated rustfmt change to placeholder.rs

Local rustfmt was outdated vs CI nightly; the reformatted form failed
`cargo +nightly fmt --check` on CI. Restore the file to match `dev`.
2026-10-08 08:24:24 +02:00
Arnaud GourlayandClaude Opus 5.5 2a92512f18 perf: Gather the PQ k-means sample in one pass (#10976)
find_centroids took each chunk's training subset straight from `data`,
walking it again for every chunk up to the last sampled vector: 384
passes over the storage for a 1536-d collection at x16 compression, to
collect the same 10,000 vectors each time.

The sample is now gathered once (gather_sample) and each chunk's subset
is cut from it (chunk_subset). The subsets, and so the centroids, are
unchanged. Peak memory grows from one chunk's subset to the whole
sample, sample_size x dim floats (about 61 MB at d=1536).

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-07 23:02:16 +02:00
xzfc 7aa1641047 Ambient counters (#10967)
* common::reason

* Add ambient per-thread hw usage context

* Cleanup
2026-10-07 18:57:09 +00:00
Andrey VasnetsovandClaude Opus 5.5 5dc74ceac5 [UpdateOnly] Encode quantized rows with the standalone encoders (#11004)
`UpdateOnlyQuantizedVectors` held an `EncodedVectorsBin`/`EncodedVectorsTQ`
only to call `append_many` on it. It now loads an `EncoderBin`/`EncoderTQ`
from the persisted metadata and hands the encoded rows to its chunked storage
directly. Behavior is unchanged.

`reopen_for_write` and the inherent `append_many`/`flusher` on both encoded
vectors types have no callers left and are removed.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 11:25:55 +02:00
Andrey VasnetsovandClaude Opus 5.5 727106423f [quantization] Add standalone Binary and TurboQuant encoders (#11003)
`EncoderBin` and `EncoderTQ` load the metadata a quantized storage persisted
and encode vectors the same way, without holding a storage. A writer that
persists the encoded rows itself can use them instead of going through
`EncodedStorageWrite`.

The Binary encoding helpers move from `EncodedVectorsBin` onto
`EncodedBinVector`, so both paths share them.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 11:22:08 +02:00
20711cc490 Add the Turbo8 vector datatype (#10874)
TurboQuant at 8 bits per element: the shared Hadamard rotation, then a
per-vector absmax scale onto the uniform grid [-127, 127], stored as
s + 128. There is no codebook, so the SIMD kernels need no lookup: the
code is the value (`maddubs` / `VPDPBUSD` on x86, `SDOT` after one XOR on
aarch64). The per-vector scale is not stored; TurboQuant's existing
`l2 / ||c||` renormalization cancels it in scoring and dequantization.
A vector takes `dim + 4` bytes (`dim + 8` for Euclid/Manhattan).

- quantization: `TQBits::Bits8`; asymmetric scoring reuses `QuerySimd`
  as the `PLANES = 1` width; new `simd/query8bit` with exact symmetric
  kernels (SSE4.1, AVX2, AVX-512 VNNI, NEON, NEON+SDOT). TQ+ is not
  supported at 8 bits. On AVX2, batches of 8-bit vectors over 512 bytes
  go through the per-vector kernel (the batch loop was up to 2.9x slower
  on some Zen 3 hosts).
- API/segment: `Datatype::Turbo8` (REST "turbo8", gRPC `Turbo8 = 5`),
  served by the existing turbo storages with the bit width passed from
  the datatype. Rejected for sparse vectors.
- Tests: kernel parity/saturation, turbo storage tests over both widths,
  model-test candidate, integration cases, test_turbo8_storage.py.


Claude-Session: https://claude.ai/code/session_01FNETeXVTeP72vQpadiUK54

Co-authored-by: Andrey Vasnetsov <andrey@qdrant.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 17:51:03 +02:00
tellet-q 64ca59bf96 Make quantization stop-condition tests timing-independent (#10910) 2026-10-02 11:42:14 +02:00
qdrant-cloud-bot dec9cd9310 Rename MmapFlusher to Flusher (#10496)
The type is used by RAM and other non-mmap storages (often as a no-op),
so the Mmap-prefixed name was misleading.
2026-09-07 14:56:46 +00:00
EduardoandClaude Opus 5 c2137f95c0 test: cover Bits1_5 in TurboQuant test matrices (#10448)
Nine bit-width matrices in `turboquant::quantization` omitted
`TQBits::Bits1_5`, so the variant went unexercised there. This is the gap
#10390 closed for `quantize_output_byte_length`, where a `quantized_size`
double-padding bug had slipped through a hardcoded [Bits1, Bits2, Bits4]
list.

Add `Bits1_5` to the seven matrices that accept it, including the rstest
cases of `score_precomputed_dispatches_all_bit_widths`, whose name already
claims to cover every bit width.

The two `Unpadded` rotation tests keep their lists: `TurboQuantizer::new`
asserts "Bits1_5 requires TQRotation::Padded", since `Bits1_5` rotates
into its x1.5 padding and an unpadded rotation would leave half the codes
carrying nothing. Each list now names that invariant, so the omission
reads as deliberate rather than as the same oversight.
`unpadded_rotation_matches_padded_for_padding_free_dims` also claimed
multiples of 8 are padding-free "for every supported bit width", which
`Bits1_5` falsified: `padded_dim(8)` is 16.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 11:33:15 +02:00
61ffec42ab Fall back to per-vector TQ scoring for scattered ids (#10381)
* Fall back to per-vector TQ scoring when runs are short

Run-batched scoring pays off on plain and dense filtered scans but
regresses HNSW, where neighbor ids rarely form consecutive runs and batch
setup dominates. Gate both EncodedVectorsTQ::score_points and Turbo
score_query_batch on offsets_worth_batch_scoring, which takes the run path
only when the ids split into runs averaging BATCH_SCORE_MIN_MEAN_RUN
vectors or more.

The average decides, not the longest run: a sorted id list -- what a
filtered scan hands the scorer -- already contains adjacent pairs at ~1%
density while its runs still average one vector, so a "contains a run of
>= 2" test sends those down the run path to pay setup per vector, measured
at up to +75% against the better path. An average also stays independent
of the batch size the driver slices ids into, which a longest-run test does
not. The threshold comes from the measured crossover -- mean run 2.0-3.6,
stable across dims 128/512/1024, both RAM storages and the 1/2/4-bit
widths -- and a fully contiguous block is recognized in O(1), so a plain
scan pays nothing for the gate.

* io_uring: never gate run-batched scoring

The gate exists because run batching costs setup that short runs do not
repay on RAM and mmap storages. io_uring is the opposite: one run-granular
read beats the batched per-vector path at every density measured -- 27% on
HNSW-shaped id lists, 43% at 25% filter density, 93% on a full scan --
because per-request submission and completion bookkeeping dominates once
the data sits in the page cache. Gating it costs 36% on HNSW-shaped lists.

Add EncodedStorage::prefers_run_reads, defaulting to false so every storage
keeps its current routing, and override it for the single-file quantized
storage when its backend is io_uring. Remote backends (object stores, a
gRPC peer) deliberately keep the per-vector path: their reads pipeline
across a batch, while run-granular reads would serialize the round trips.

QuantizedStorage::is_in_ram_or_mmap() still reports true for every backend,
which is what routes io_uring into the gate in the first place. Correcting
that would also change how the multivector storage picks between its
in-memory and uring scoring paths, so it is left to a separate change.

* Rename prefers_run_reads to prefers_contiguous_reads

"Run reads" is easy to misread as "execute reads"; contiguous makes the
storage I/O preference explicit.

* QuantizedStorage::for_each_run: pipeline run reads on async backends

With `prefers_contiguous_reads()` true for io_uring, every batch goes
through `for_each_run`, which read each run synchronously: a scattered
id list (HNSW neighbours) waited on one disk read per vector, where
`for_each_in_batch` kept the whole batch in flight through `read_batch`.
Submit all runs of a batch together, still one read per run, so
scattered reads stay pipelined while a scan still reads each run in one
request.  Backends without async reads keep the sequential loop.

`turbo_vector_search` (dim 1024, 200k vectors, 4096 shuffled ids per
iteration) against dev: cold scattered io_uring 187 ms -> 26.5 ms
(dev 29.5 ms); the warm scan keeps 198 ms -> 21 ms.  Warm scattered
lands at 5.06 ms (dev 4.67 ms), giving up the 3.68 ms of synchronous
reads, which only holds with the data already in the page cache.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VRiRaiPsm5CEBgpQ7VAQab

* Iterate consecutive runs and share the run-scoring gate

`for_each_consecutive_run` becomes `consecutive_runs()`, a lazy iterator
over `Run { first, start, len }`, so a storage can feed runs straight into
a read pipeline. `QuantizedStorage::for_each_run` loses the intermediate
`Vec` and the duplicated `ReadRange` construction: the pipelined branch
maps the iterator into `read_batch`, the synchronous branch keeps the
per-run `Sequential`/`Random` hint that picks between mmap's two mappings.

The routing condition duplicated at both scoring call sites moves into
`EncodedStorage::prefers_run_scoring`: same expression, one place.

`for_each_run`'s contract no longer promises run order: pipelined
backends report reads as they complete, so callers address results by
`first`. Add a contract test over the mmap and disk-cache backends; the
latter is the async-capable backend that runs on every platform and
covers the `read_batch` branch io_uring takes on Linux, which no test
exercised before.

Measured on Apple M3 against 1f2d1264e, interleaved A/B/B/A: the run
path is unchanged on all four storages (-0.3%, +0.0%, +0.9%, -1.1%,
within replicate noise).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* Rename Run to ConsecutiveRun

`Run` on its own reads as "execute", the same ambiguity that got
`prefers_run_reads` renamed earlier in this branch. `ConsecutiveRun`
names what the value is and pairs with `consecutive_runs()`, the iterator
that yields it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* io_uring pipeline: submit eagerly only while reads are outstanding

`IoUringPipeline::wait()` called `submit_and_wait(0)` whenever a completion
was ready and anything was enqueued. `read_batch` enqueues one entry per
consumed completion, so on a warm page cache — where reads complete inline
at submission — that was one `io_uring_enter` per read. Scattered quantized
scoring over io_uring ran at ~630 ns/point warm against ~460 for the same
reads issued synchronously (which, without direct_io, are plain `pread`s).

Now the eager submission happens only while the kernel still has reads
outstanding (`in_progress` minus the completions already waiting in the
queue). When everything submitted so far has completed — the warm case —
the enqueued entries wait and go down together once the ready completions
run out. On a cold device nothing changes: a completion is answered with a
submission as before, so the in-flight depth never sags. Two fixed rules
tried first (submit only when nothing is ready; submit once half the queue
piled up) both cost the cold path, +6 % and +3 %, in proportion to how long
the device sat idle while ready completions were drained.

turbo_vector_search / turbo_uring_ab, Zen 4, `taskset -c 7`, prebuilt
binaries run alternately, cold rows with the page cache dropped:

  warm scattered, uring hnsw:   634 -> 472 ns/point  (-25 %)
  warm scattered, uring p0.25:  477 -> 369 ns/point  (-23 %)
  warm sequential, uring p1.00:  45 ->  45           (flat)
  cold scattered, uring:        29.9 -> 29.9 ms/iter (flat, 4 reps each within 0.7 %)
  mmap rows (control):          within ±2 %

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

---------

Co-authored-by: Ivan Pleshkov <pleshkov.ivan@gmail.com>
Co-authored-by: timvisee <tim@visee.me>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 17:57:27 +02:00
4d5fb7e6c7 TurboQuant AVX512 + Neon reduce four vectors at once (#10439)
* 4 way hsum for avx512 and neon

* Fix AVX512 regression

* Revert "Fix AVX512 regression"

This reverts commit 7da4676bdc.

* avx512: keep the per-vector reduction for two-byte queries

The shared group reduction is a clear win at QUERY_BYTES == 1 and a large
loss at 2. Measured on Zen 4 against this commit's parent: 4-bit batch
scoring +42% at dim 64 and +45% at 128, 2-bit +23%, while 1-bit gains -6%.

The cause is register pressure. Acc512<QUERY_BYTES> is
[[__m512i; 2]; QUERY_BYTES], so a group of four holds 8 ZMM at one query
byte but 16 at two, and the transpose needs every vector's lanes at once,
so nothing retires early. objdump on the batch kernel counts 0 ZMM spills
to the stack before this PR and 64 after -- and the two instantiations
that spill are exactly the two widths that regress.

Gate the shared reduction on QUERY_BYTES == 1 and let two-byte queries
reduce and release one accumulator at a time, as they did before. The
group width stays at four: shrinking it to two for those widths measured
much worse (+39...+73%), since four vectors x two chains is what covers
the VPDPBUSD latency.

ns per 512-vector run, medians of 4 interleaved reps, L2-resident pool,
against this commit's parent:

  width  dim     parent   this PR   with the gate
  1-bit    64      2680    -6.3 %          -6.3 %
  1-bit   128      2682    -6.3 %          -6.2 %
  1-bit   512      2525    -6.0 %          -7.2 %
  2-bit    64      2161   +23.5 %          +1.2 %
  2-bit   128      2162   +23.6 %          +1.2 %
  2-bit   512      3058   +24.7 %          +0.5 %
  4-bit    64      1486   +42.2 %          +3.4 %
  4-bit   128      1412   +45.0 %          +2.8 %
  4-bit   512      2971   +28.8 %          +1.2 %
  4-bit  1536      8681   +16.0 %          -1.0 %

Spills drop from 64 to 2. The few percent left at the smallest 4-bit dims
come from the `interleave` test now sitting inside the group loop instead
of outside it, where the compiler must keep the untaken branch live;
hoisting it back out measured +0.1...+0.2%, at the cost of duplicating
the loop.

NEON is untouched here and not measured on this machine.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* avx512: resolve the interleave choice outside the group loop

The gate on the previous commit left 4-bit batch scoring ~3% above the
parent at the smallest dims. The cause is the `interleave` test sitting
inside the group loop: the untaken arm stays live for the register
allocator, so the widths that never take it still pay for it.

Pass the choice as a const parameter instead, so the loop is compiled once
per shape and both arms fold away, and build the non-interleaved group with
array::from_fn rather than zero-initialising it first.

ns per 512-vector run, medians of 3 interleaved reps, L2-resident pool,
against the PR's parent:

  width  dim     parent   this PR   gate only   with this commit
  4-bit   64       1487   +42.6 %      +3.4 %             +0.0 %
  4-bit  128       1414   +44.9 %      +2.6 %             +0.0 %
  4-bit  512       2978   +28.5 %      +1.0 %             -0.2 %
  4-bit 1536       8814    +9.4 %      -3.6 %             -0.5 %
  2-bit   64       2160   +23.6 %      +1.2 %             +0.2 %
  2-bit  128       2156   +24.4 %      +0.9 %             +0.5 %
  2-bit  512       3059   +23.5 %      +0.8 %             +0.0 %
  1-bit   64       2679    -6.4 %      -6.5 %             -7.2 %
  1-bit  128       2682    -6.3 %      -6.5 %             -7.1 %
  1-bit  512       2518    -6.1 %      -6.2 %             -6.4 %
  1-bit 1536       5851    -2.9 %      -2.9 %             -2.7 %

The two-byte widths are back on the parent and the one-byte win grows a
little, since it was paying for the same branch.

One cell moves the other way: 2-bit at 1536 reads +2.5 % where the gate
alone read -3.0 %. That is the non-interleaved path this commit also
restructures, at the dim where this machine is bimodal; three reps do not
separate it from noise.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Ivan Pleshkov <pleshkov.ivan@gmail.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-02 16:40:27 +02:00
Ivan Pleshkov 6d973050c3 QuerySimd: hold the query planes in one allocation (#10441)
The planes were PLANES * QUERY_BYTES separate Vecs -- four for the 4-bit
width, sixteen for a 16-bit query at the 1-bit width. Scoring re-reads all
of them for every vector, so keeping them in that many allocations spreads
one small, permanently hot structure over as many pages, TLB entries and
prefetch streams as there are planes.

Put them in a single buffer instead, plane (b, k) at
(b * PLANES + k) * plane_len, and hand the kernels their slice through an
unchecked accessor: it is re-taken for every block of every vector, and a
bounds-checked one measured 28-36% slower than the previous layout.
2026-09-02 16:10:37 +02:00
Jojii bfc5428a8d 4-way reduction (#10437) 2026-09-02 12:13:21 +02:00
Ivan PleshkovandClaude Fable 5 4aa8066b75 Turbo4 batched scan (#10362)
* TurboQuantizer::score_precomputed_batch: score a contiguous run of vectors

Batch counterpart of `score_precomputed` for vectors stored back to
back at `quantized_size()`: the width's kernel scores the whole run of
codes in one `dotprod_batch` call, then a second pass applies each
vector's extras.  L1 dequantizes per vector and stays a plain loop.

Tested against per-vector `score_precomputed` for every width,
distance, and mode over run lengths that leave every group remainder.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* EncodedStorage::for_each_run: serve consecutive offsets as contiguous runs

`for_each_run(offsets, callback(first, count, bytes))` splits the
offsets into maximal runs of consecutive ids the storage can serve
from one contiguous slice, so a sequential scan resolves chunk lookups
and reads once per run instead of once per vector.  The default serves
every vector as its own run; `for_each_consecutive_run` is the shared
run detection for storages that override it, with a per-run cap for
chunk boundaries.  The test storage overrides it (its data is one flat
buffer).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* EncodedVectors::score_points: batched scoring entry point, run-batched for TQ

`score_points(query, offsets, scores)` scores a batch of points.  The
default keeps the per-vector loop the scorers run today, so SQ/PQ/BQ
are unchanged.  TurboQuant overrides it: on RAM/mmap storages it walks
`for_each_run` and scores each contiguous run with one
`score_precomputed_batch` call, hoisting the score inversion out of
the loop; backends with async reads keep the pipelined per-vector
path.  Non-consecutive offsets degrade to single-vector runs, so
scattered access keeps its previous cost.

Integration test: `score_points` vs `score_point` for every bit width
and mode, Dot and inverted L2, over sequential, scattered and
descending id orders.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Quantized storages: for_each_run over their contiguous regions

The RAM storage and both chunked mmap storages cap runs at their chunk
boundary and serve each run with one `get_many`; the single-file mmap
storage serves any run as one sequential read.  Unit test on the RAM
storage: runs cover every offset once, in order, with bytes identical
to per-point reads, across the internal chunk boundary.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* QuantizedQueryScorer: score batches through EncodedVectors::score_points

Routes `score_stored_batch` through the batched entry point, so
TurboQuant-as-quantization scans score contiguous runs with one kernel
call per run; SQ/PQ/BQ keep the per-vector loop via the default.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* TurboScoring::score_query_batch: run-batched scoring for Turbo4 storages

Adds the batch counterpart of `score_query_bytes` to the trait, with
one shared implementation over the storage's `EncodedStorage`:
consecutive ids are coalesced into contiguous runs, each run scored by
a single `score_precomputed_batch` call, and the metric sign applied
once over the batch.  Backends with async reads keep the pipelined
per-vector path.  `TurboQueryScorer::score_stored_batch` now calls it.

The batch-vs-single storage test grows to 8192 vectors so a full
ascending scan crosses a chunk boundary of the chunked backend, and
runs that scan on the chunked, mmap and io_uring backends.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* score_precomputed_batch: keep the extras pass in L1

The kernel pass and the extras pass now alternate over sub-runs of 64
vectors instead of each covering the whole run: for a run of several
hundred vectors the second pass otherwise refetched every vector's
extras from L2.  Measured with 512-vector runs from the full-scan
driver at dim 512: the regression against 64-vector runs went from
+11 % to +2 %.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Bench: exhaustive search over Turbo4 storages through the plain-index driver

`turbo4_full_scan` runs `BatchFilteredSearcher::peek_top_visible` —
the exact path of a non-indexed search — over 200k normalized random
vectors for Turbo4 as datatype (appendable chunked, in RAM) and Turbo4
as quantization (over a RAM dense storage), at dims 64 to 1024, so the
fixed per-point cost of the scan driver is measured next to the kernel.
`TURBO_SCAN_DIMS=64,128` narrows the dims while iterating.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-09-02 11:41:07 +02:00
Ivan PleshkovandClaude Fable 5 94f3abeb22 Turbo4 dotprod batch (#10392)
* QuerySimd::dotprod_batch: score a contiguous run of vectors in one call

The entry point for scanning a contiguous run of encoded vectors at a
stride: `out[v]` ← score of the vector at `data[v * stride..]`.  It
scores vector by vector for now; the SIMD batch kernels that share the
query loads across vectors follow.

Bench: `query{4,2,1}bit_dotprod_scan` — a hot query against runs of 512
consecutive vectors streaming from DRAM at the TurboQuant stride, per
vector and through `dotprod_batch`.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* QuerySimd: interleaved AVX-512 batch kernel with a fused reduction

Vectors up to four cache lines are scored in groups of four that share
every query block load and tail mask; the group's independent
accumulators keep `VPDPBUSD` saturated while one vector's reduction
overlaps with the next group's loads.  Longer vectors keep the
per-vector walk, since the hardware prefetcher streams four interleaved
byte streams far worse than one (measured at the 4-bit width: +10 % at
dim 512, 2× slower at dim 1024).

The per-vector reduction fuses the query bytes before the horizontal
sum — `low + K · high` in i32 lanes, then one tree that widens to i64
at the end — for vectors within a per-width lane bound derived from the
encoding (2040 bytes at 4 bits, 1020 at 2, 255 for the wide 1-bit
query; unbounded for a one-byte query).  A test pins the derivation to
the hand-computed 4-bit value and drives every width to its bound with
the heaviest possible inputs.

Bench: `batch_avx512_vnni` rows in the scan groups.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* QuerySimd: AVX2 batch kernel; one fused reduction for AVX2 and AVX-512

The AVX2 batch kernel scores vectors one at a time: its loop-carried
chain is a single `vpaddd` per accumulator (the `maddubs → madd`
products hang off the loads), so interleaving vectors only adds
register pressure on the 16 YMM registers — measured 10–15 % slower
with groups of two or four at the 4-bit width.

The AVX2 per-vector reduction now uses the same fused tree as the
AVX-512 one, within the same per-width lane bound; the bound test
drives both kernels.

Bench: `batch_avx2` rows in the scan groups.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* QuerySimd: copy the tail block in constant-size pieces

The SSE, AVX2 and NEON kernels run their last partial block on a
zero-padded copy of the remaining bytes.  A `len`-byte copy compiles to
a `memcpy` call plus a `memset` for the padding — and the call forces
the accumulators out of their registers around it.  Copy in power-of-
two pieces of constant size instead: `len` is the same for every vector
of a query, so the piece branches predict perfectly.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* QuerySimd: interleaved NEON batch kernels

The SDOT and plain NEON block loops take `N` vectors at a stride, and
the batch entry points score vectors up to four cache lines in groups
of four — the same policy as the AVX-512 kernel, with the group
threshold carried over from the AVX-512 measurement rather than tuned
on ARM hardware.

Bench: `batch_neon` and `batch_neon_sdot` rows in the scan groups.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-09-02 09:39:32 +02:00
Luis Cossío af76cdfa8c [updater] Batch upsert quantized vectors (#10417)
* upsert quantized vectors in batch

* fix bounds
2026-09-01 10:30:06 -04:00
Ivan PleshkovandClaude Fable 5 0f2b02ee11 Turbo4 query simd (#10391)
* TQ SIMD: one backend ladder, resolved once per query

The 2- and 4-bit kernels share the same preference order (AVX-512 VNNI
→ AVX2 → SSE → NEON + SDOT → NEON → scalar), spelled out six times as
chains of `is_x86_feature_detected!` — and `Query{2,4}bitSimd::dotprod`
re-ran its chain for every vector scored.

Move the ladder into one `simd::SimdBackend` enum with a single `detect()`.
The query types resolve it in `new()` and dispatch on the stored value;
the symmetric `score_{2,4}bit_internal*` entry points dispatch on
`SimdBackend::detect()`.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* QuerySimd: one query layout and scalar reference for every packing width

`Query{1,2,4}bitSimd` are three copies of the same idea — quantize the
query into i8 halves, multiply them against an integer codebook — each
with its own query layout and its own set of SIMD kernels.  Introduce
`simd::query::QuerySimd<PLANES>`, generic over the number of codes per
packed byte (2, 4 or 8), which the three widths will share.

The query halves are stored as planes, one per code position within a
byte: plane `k` entry `j` is the half of query dim `PLANES · j + k`.
That is the order the codes come out of raw data bytes with a shift and
a mask, so a kernel never has to unpack them into dim order.  Planes are
zero-padded to the widest SIMD block, so a partial last block on the
data side multiplies against zeros.

The widths contribute only their integer encoding (`Encoding`: codebook
table, offset, scale and query range); the 1-bit width gets one here —
`{0, 128}` with offset 64 on x86_64, `∓127` on aarch64 — chosen so the
query keeps full i8 halves.  Only the scalar reference exists yet; the
SIMD kernels follow, and the width types switch over once they're in.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* QuerySimd: AVX-512 VNNI kernel on the query planes

One ZMM of packed codes per block; for each plane the codes are shifted
down by the code width, masked and looked up in the codebook with one
`vpshufb`, then `VPDPBUSD` folds them into the plane's low and high
accumulators.  Two accumulator pairs per vector keep the VNNI latency
off the critical path at every width.  The last partial block is a
masked load whose dead lanes multiply against the planes' zero padding.

The shift count is an immediate, so the shift-by-width helper spells
out the three widths in a `match` — the only place the kernel is not
literally generic over `PLANES`.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* QuerySimd: NEON SDOT kernel on the query planes

The AVX-512 kernel's shape on 128-bit registers: one `TBL` codebook
lookup per plane, `SDOT` (inline asm — `vdotq_s32` is still unstable)
into two accumulator pairs.  The last partial block runs on a
zero-padded copy of the remaining bytes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* QuerySimd: AVX2 kernel on the query planes

One YMM of packed codes per block, one `vpshufb` lookup per plane and
`maddubs → madd` against ones into the same two accumulator pairs as
the VNNI kernel.  The `maddubs` pair sums stay inside i16 by the
per-width query bounds.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* QuerySimd: SSE and plain NEON kernels on the query planes

The 128-bit forms of the AVX2 and SDOT kernels: `maddubs → madd` on
XMM, `vmull_s8 → vpadalq_s16` on NEON without `dotprod`.  Every backend
of the shared query type now has its kernel.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Query4bitSimd: score through QuerySimd<2>

`Query4bitSimd` becomes an alias of the shared query type; its own
chunk-and-tail query layout and the per-backend kernels built on it go
away, along with the accuracy tests the shared module now runs for
every width.  What stays in `query4bit` is the 4-bit encoding and the
symmetric `score_4bit_internal*` paths.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Query2bitSimd: score through QuerySimd<4>

The 2-bit asymmetric kernels unpacked every 4 packed bytes into 16
centroid bytes through two `pshufb` / `TBL` pair-table lookups and a
zip before a single multiply-accumulate step — on AVX-512 that was four
128-bit unpacks and six lane inserts per pair of `VPDPBUSD`.  On the
query planes the same 16 codes cost one shift, one mask and one lookup
per plane, straight from a full-width load.

`Query2bitSimd` becomes an alias of the shared query type; its chunk
layout and per-backend kernels go away.  The pair-table unpack stays
for the symmetric `score_2bit_internal*` paths.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Query1bitSimd: score through QuerySimd<8>

The 1-bit asymmetric kernels bit-plane-transposed the query and scored
`Σ_b 2^b · popcount(data AND plane_b)` per 16-byte block — eight
AND + popcount + add steps on XMM (even with AVX-512, through the VL
forms) and a `BITS`-deep accumulator array.  On the query planes a
sign bit is just a one-bit code: shift, mask, a two-entry codebook
lookup and the same multiply-accumulate as the wider widths, on full
256-/512-bit registers.

`Query1bitSimd` becomes an alias of the shared query type.  Its query
width was a const parameter (8 bits by default, 16 for TQ+ through the
`Bits1Wide` variant); the shared encoding always carries 16-bit halves,
so the variant and the TQ+ special case go away.  The popcount kernels
stay for the symmetric `score_1bit_internal`, where XOR + popcount is
the right tool.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Bench: cold per-vector rows for every width

`query{4,2,1}bit_dotprod_cold` from one generic body — scalar reference,
public `dotprod` and each backend — so the widths can be compared on one
host.  `TURBO_SIMD_DIMS` narrows or widens the dims of a run and
`TURBO_SIMD_POOL_KB` shrinks the pool to L1 for hot-kernel numbers.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* QuerySimd: query bytes as a parameter; 8-bit queries for the 1-bit width

The shared kernels always carried two query bytes (a ~16-bit query),
which cost the 1-bit width its 8-bit-query speed: the old bit-plane
kernel scored a 16-byte vector in 4.9 ns hot (34.7 ns cold) against
9.1 ns (54.7 ns) through the planes, the difference being the second
byte's multiply-accumulates on a vector that fills a quarter of one
block.  Above 512 dims the planes win either way.

Make the number of query bytes a parameter: `QuerySimd<PLANES,
QUERY_BYTES>` with one plane per query byte and code position, and one
accumulator pair per query byte.  A one-byte query is scaled to the
range of a single byte, `RADIX / 2 − 1`.  `Query1bitSimd` is the
one-byte instance — at parity with the old kernel at small dims (cold
36.9 / 38.3 / 38.4 ns at d = 128 / 256 / 512) and 1.8× faster at 1536
(68 vs 121 ns) — and `Query1bitWideSimd` the two-byte one, which TQ+
selects through the `Bits1Wide` variant as before.  The 2- and 4-bit
widths keep two bytes.

Bench: `query1bit_wide_dotprod_cold` and a `query1bit_wide` row next
to BQ.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-09-01 14:15:17 +02:00
Ivan PleshkovandClaude Fable 5 24aa7faaeb Fix TurboQuantizer::quantized_size double-padding for Bits1_5 (#10390)
`quantized_size_for` pads its `dim` argument, so passing the already
padded `self.padded_dim` applied the x1.5 expansion of `Bits1_5` a
second time.  Nothing on disk depends on the value for that width: the
quantization path sizes its records with `quantized_size_for` from the
raw dim, and the Turbo datatype storages, which do use
`quantized_size()` as their record size, are fixed at Bits4, where the
padding is idempotent.  The wrong value only over-reserved the
`quantize` output buffer.

Compute the packed size from `padded_dim` directly and cover `Bits1_5`
in the byte-length test.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-31 09:17:11 +02:00
Anton Karpov b88becd3b5 docs: parameter names in doc comments that the signatures do not have (#10290)
11 names across 7 files. Renames that did not reach the comment above them
(further_searches for further_results, query_context for segment_query_context,
block_ranges for local_block_ranges, op for operation twice, request for
requests, max_threads for max_kmeans_threads), and 3 arguments that were
removed from a signature and left documented (is_on_disk, collection_params,
search_runtime_handle with timeout).

Documentation only, no behaviour change.
2026-08-21 15:36:27 +02:00
Arnaud GourlayandClaude Opus 5 0e33974697 Replace permutation_iterator with rand's index sampler (#10217)
`cargo audit` flags rand 0.7.3 as unsound (RUSTSEC-2026-0097), and
permutation_iterator 0.1.2 is its sole importer. The crate is
unmaintained, so the finding is permanent for as long as we depend on
it.

Everything we used it for is "pick k distinct random indices out of n",
which is exactly `rand::seq::index::sample` from the workspace rand.
Switch the three src call sites and the two benches over, and drop the
dependency. 11 crates leave Cargo.lock.

Also fix a comment in quantile.rs claiming the permutation was
deterministic per count: the old crate keyed itself from thread_rng on
every call, so it never was.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 13:51:08 +02:00
liam 362131f926 fix(quantization): skip cc compile when no SIMD sources were added (#10209) 2026-08-13 01:30:56 +00:00
1a8c9a6397 [UpdateOnly] implement the appendable quantized-vector overlay (dense, Binary/Turbo) (#10161)
* [UpdateOnly] implement the appendable quantized-vector overlay (dense, Binary/Turbo)

Appendable/plain segments can carry live quantized vectors today: PlainVectorIndex::
update_vector calls quantized_vectors.upsert_vector alongside the raw vector on every
insert (lib/segment/src/index/plain_vector_index/lifecycle.rs), auto-created for a
fresh segment when appendable_quantization is on and the method supports it
(QuantizationConfig::supports_appendable — Binary and Turbo only; Scalar/Product are
policy-gated off regardless of storage backend). The update-only vector-storage stack
(this PR's base) had no equivalent: UpdateOnlyVectorStorage::open never read
quantization_config, and nothing under vector_storage/*/update_only/ mentioned
quantization at all — a segment configured with quantization would silently lose it
end-to-end once written through this path.

This adds UpdateOnlyQuantizedVectors, mirroring QuantizedVectors' auto-create/reopen
behavior but scoped to dense (single-vector) Binary/Turbo — the two methods that
support incremental appends, matching current capability exactly (multivector support
is a follow-up: it needs its own append-only offsets storage, mirroring
MultivectorOffsetsStorageChunked the same way this mirrors QuantizedChunkedStorage).

The only new machinery is UpdateOnlyQuantizedChunkedStorage, an EncodedStorage backed
by UpdateOnlyChunkedVectors (append-only, S: UniversalAppend) instead of
ChunkedVectors' positional writes (S: UniversalWrite) — everything else reuses the
quantization crate's EncodedVectorsBin::encode/load and EncodedVectorsTQ::encode/load
completely unchanged, since both are already generic over the storage backend. It
writes files in the exact layout QuantizedChunkedStorage reads, so a promoted segment's
quantized data reads through the existing, unmodified reader with no new reading code.
UpdateOnlyChunkedVectors gains one addition: a `get` method to read back a single
vector, needed because EncodedVectors::load validates the storage's vector size by
reading vector 0 (skipped when the store is still empty).

Verified: the update-only writer's persisted bytes, read back through the standard
(non-update-only) QuantizedChunkedStorage + EncodedVectorsBin/TQ::load, match a
RAM-backed reference fed the same vectors one at a time through upsert_vector,
byte-for-byte, for both Binary and Turbo.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* [UpdateOnly] fix quantized reopen: resume writing shouldn't validate stored reads

The previous commit made reopening a non-empty quantized overlay panic
(EncodedVectorsBin/TQ::load validates a non-empty store by reading its
vector 0, which UpdateOnlyQuantizedChunkedStorage's write-only design
cannot serve) and worked around it with a redundant pre-check plus a
todo!(), narrowing the tests to single-session-only writes.

Both of those were the wrong fix. A writer resuming appends doesn't need
`load`'s read-and-validate — it only needs the fitted metadata (encoding,
stats) to keep encoding consistently, and that invariant already holds by
construction: every vector this writer ever encodes is sized from the same
`quantized_vector_size` `load` and the new path both read. Added
`EncodedVectorsBin`/`EncodedVectorsTQ::reopen_for_write` to the
quantization crate — identical to `load` minus the validating read — and
switched `open_existing` to it. `UpdateOnlyQuantizedChunkedStorage` stays
write-only as originally designed; no new read capability, no pre-check,
no todo.

Tests restored to the original two-writer split (write half, drop, reopen,
write the rest), now genuinely exercising resume-with-data instead of
avoiding it, and still passing byte-for-byte against the reference.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* [UpdateOnly] split EncodedStorage into EncodedStorageWrite + EncodedStorage

A write-only storage (the update-only quantized overlay) had to fake a
full EncodedStorage impl with unreachable!() read stubs just to satisfy
EncodedVectorsBin/TQ's generic bound. Split the trait so a write-only
backend only needs to implement EncodedStorageWrite; EncodedStorage adds
the read methods on top. The overlay now implements EncodedStorageWrite
alone — no panicking stand-ins for methods that don't exist.

* [UpdateOnly] remove UpdateOnlyQuantizedVectors::create

Nothing in this stack builds the first appendable segment of a
collection yet (that's still a todo!() in edge/src/update_only), so
create() had no real caller and open() had to guess from file absence
whether to invoke it. open() now only reopens an overlay create()
already persisted; the bootstrap logic moved into tests.rs as a
private fixture helper, since tests still need it to build fixtures.

* [UpdateOnly] fix CI: codespell typo and lint dead-code on unwired write path

codespell flagged "implementors" (wants "implementers") in two doc
comments. Separately, CI's lint job runs clippy without --all-targets,
so the update-only quantized write path — genuinely unreachable from
any non-test code until #10152 wires it into a segment — trips
-D warnings dead-code. Scope #![allow(dead_code)] to the two files
that are only exercised by their own tests today, and allow the now
test-only UpdateOnlyQuantizedChunkedStorageBuilder re-export.

* [UpdateOnly] fix ast-grep: use expect(dead_code) instead of allow

* fix CI: remove unused EncodedStorageWrite import in gpu vector storage

Left over from splitting EncodedStorage into EncodedStorageWrite +
EncodedStorage; only caught under --all-features since gpu is gated
behind a feature flag.

---------

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
Co-authored-by: qdrant-cloud-bot <111755117+qdrant-cloud-bot@users.noreply.github.com>
2026-08-11 10:57:47 +02:00
xzfc 75385df69f Remove dead code (#10030)
* Remove dead code

* Remove unused dependencies

* `allow(dead_code)` -> `expect(dead_code)`

* ast-grep: rule-tests/*-test.yml => tests/*-test.yml

For brevity.

* ast-grep: forbid allow(dead_code)
2026-07-30 20:59:31 +00:00
Ivan PleshkovandClaude Opus 5 e06bee8d46 Fix TQ quantized vector layout alignment (#10005)
`EncodedVectorsTQ::layout()` declared `align_of::<f32>()`, but the size it
reports is the packed dimensions plus a 4-byte-multiple extras trailer, which
is not a multiple of 4 for three quarters of all dimensions (e.g. dim=756,
Bits4: 378 + 4 = 382 bytes).

The claim was never true — the encoded storage packs vectors at
`id * quantized_vector_size` with no per-vector padding — and nothing relies on
it: packed dimensions are read through unaligned SIMD loads (`loadu` / `vld1`)
and the extras trailer through `f32::from_le_bytes` on a byte slice.

It is also actively harmful. Inline HNSW storage packs link vectors
back-to-back using this layout and rejects one whose size is not a multiple of
its alignment, so building an index with `inline_storage` enabled fails for
those dimensions — and retries forever as an optimization crashloop.

Use `align_of::<u8>()`, matching scalar and product quantization. Old links
files stay readable: both layouts are persisted in the file header and the
reader takes size and alignment from there, never from the live quantizer.

Add a test covering the `size % align == 0` invariant across awkward
dimensions, bit widths, distances and modes — `layout()` had no coverage, which
is why the mismatch went unnoticed on the multiple-of-32 dimensions everyone
uses in practice.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 11:34:11 +02:00
xzfc 7e99cdd86a UioResult (#9933) 2026-07-22 14:34:33 +00:00
Luis CossíoandClaude Fable 5 fca67539f4 Mandatory batch impls (#9935)
* make `EncodedStorage::for_each_batch` mandatory

* make `DenseVectorStorageRead::for_each_in_dense_batch` mandatory

* make `DenseTQVectorStorage::for_each_in_dense_batch` mandatory

* make `DenseTQVectorStorage::read_dense_tq_bytes` mandatory

* make `QueryScorer::score_stored_batch` mandatory

...and implement for tq multivectors

* [AI] make `IdTrackerRead::internal_versions_batch` mandatory

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* [AI] make `IdTrackerRead::external_ids_batch` mandatory

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* [AI] make `DiskMappingsSource::resolve_internal_batch` mandatory

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 09:21:48 -04:00
Arnaud GourlayandClaude Fable 5 c196d2eb1a Benches: use SmallRng instead of ChaCha12-based generators (#9887)
* Benches: use SmallRng instead of ChaCha12-based generators

All benchmarks used StdRng or rand::rng() (ThreadRng), both backed by the
ChaCha12 block cipher in rand 0.10. Benchmarks do not need crypto-strength
randomness, and several draw random values inside the timed closure, so
cipher work was included in the measurement itself.

Switch every bench target to SmallRng (Xoshiro256++), and key the HNSW
graph cache and sparse index cache by RNG algorithm so stale caches built
from the old generator are not reused against newly generated vectors.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Benches: replace free-function rand::random with local SmallRng

Addresses review: rand::random draws from the thread RNG (ChaCha12),
including inside the timed loop of the pq score benchmark.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 17:22:27 +02:00
Arnaud Gourlay 0d2f5c7e85 Miscellaneous cleanups (#9849) 2026-07-15 13:44:51 +02:00
Arnaud GourlayandClaude Fable 5 d4e0f354da Single inverse rotation in TurboQuant symmetric L1 scoring (#9835)
The inverse Hadamard rotation is linear, so it distributes over
subtraction: |R'd1 - R'd2| = |R'(d1 - d2)|. Subtract the dequantized
vectors in rotated space and inverse-rotate the difference once,
instead of inverse-rotating both vectors per score call.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 15:42:10 +02:00
Arnaud GourlayandClaude Fable 5 27672b8011 [AI] Speed up TurboQuant Hadamard rotation by ~3x (#9660)
* Speed up TurboQuant Hadamard rotation by ~3x

Profiling apply_inverse showed 44% of cycles in the Fisher-Yates swap
replay (serial LCG plus a 64-bit modulo per element), not in the WHT.

- Materialize the shared permutations as index maps once in
  HadamardRotation::new; each apply-time permutation becomes a flat
  gather pass ping-ponging between the vector and a thread-local
  scratch buffer. The replay path stays as the cfg(test) parity oracle.
- Fuse consecutive WHT outer butterfly stage pairs (h, 2h) into one
  pass over the array (AVX2), halving memory traffic for h >= 16.
- Fuse the normalization multiply into the transform's final-stage
  stores (wht_dispatch_scaled), removing the separate normalize pass.

Output is bit-identical on all paths: pinned by the existing
struct-vs-replay and SIMD-vs-scalar bit-equal tests plus a new
wht_dispatch_scaled parity test. NEON is unchanged.

Criterion hadamard bench (Zen 5): apply 2.8-3.7x faster across dims
128-4096 (1024: 6.98us -> 2.03us), apply_inverse 2.8-3.5x
(1024: 6.10us -> 2.05us).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Address review: harden gather contract, small-dim parity, grow-only scratch

- Mark gather_permuted as unsafe fn: the get_unchecked justification relied
  on a caller invariant (map indices in range) that the signature did not
  surface; document it as a # Safety contract instead.
- Add dims 5 and 50 to static_rotation_matches_struct_and_roundtrips to pin
  the map path against the replay oracle on degenerate chunk splits.
- Make the thread-local gather scratch grow-only, so threads alternating
  between dims no longer shrink and re-zero the buffer on every call.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Hard-assert input length in apply/apply_inverse

Fail fast at the public boundary: with debug_assert only, a release
build would WHT-normalize a wrong-length slice before the gather's
hard length asserts panic. Flagged by CodeRabbit.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 11:39:12 +02:00
273bbe1729 Fix use-after-free in u8-quantized vector reads on owning storages (#9801)
* Fix use-after-free in u8-quantized vector reads on owning storages

EncodedVectorsU8::get_vec_ptr extracted a raw pointer from the buffer
returned by EncodedStorage::get_vector_data and dropped the buffer
before the pointer was dereferenced. For storages returning
Cow::Borrowed (mmap) this happened to be sound, but for storages that
return Cow::Owned (disk-cache misses, uring backends) every user of
get_vec_ptr read freed memory: the internal SIMD scorers,
encode_internal_vector, and get_quantized_vector_offset_and_code, which
exported the dangling buffer through a safe &[u8].

Make parse_vec_data return the code as a slice borrowing the input, so
the borrow checker forces callers to keep the storage buffer alive
while reading it, and drop get_vec_ptr entirely. This matches how
encoded_vectors_binary already binds the buffers at its scoring sites.
Change get_quantized_vector_offset_and_code to return the code as a
sub-view of the buffer Cow itself, and adapt its GPU caller.

The new regression test drives these paths through a storage that
always returns owned buffers; under Miri it reproduces the
use-after-free on the previous code and passes with this fix.

Fixes #9799

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* additional assertion

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Ivan Pleshkov <pleshkov.ivan@gmail.com>
2026-07-13 16:40:25 +02:00
Arnaud GourlayandClaude Fable 5 cad112bb1c Fix Clippy 1.97 (#9716)
* Remove from_iter_instead_of_collect from workspace lints

The lint was removed from clippy (beta) and now triggers
renamed_and_removed_lints warnings in every crate.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Fix clippy::chunks_exact_to_as_chunks

Replace chunks_exact with a constant chunk size by as_chunks.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Fix clippy::needless_late_init

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Fix clippy::useless_borrows_in_formatting

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Fix clippy::uninlined_format_args

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Fix clippy::for_kv_map

Iterate map values directly instead of discarding keys.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Allow clippy::result_large_err on QueueProxyShard::new_from_version

The Err variant intentionally hands the LocalShard back to the caller.
Same pattern as the existing allow on ForwardProxyShard::new.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Allow clippy::result_unit_err on wait_for_consensus_commit

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 14:53:15 +02:00
Ivan Pleshkov 9c1b0ed9ee [TQDT] Quantization over tq strorage (#9507)
* quantization over tq strorage

are you happy fmt

are you happy clippy

rotation refactor

rotation refactor

fix tests

better test name

review remarks

dont rotate query in raw scorer

tq sources revert

quantized_scoring_datatype revert

simplify

are you happy fmt

* remove old comments

* review remarks
2026-07-01 15:03:37 +02:00
8c8a72d120 Remove read_multi_iter to fix macOS linker symbol overflow (#9643)
* remove unused iter_offsets

* Replace MultivectorOffsetsStorage::iter_offsets with callback-based for_each_offset

First step of removing the iterator-returning read API (whose deep,
composable generic types blow up mangled symbol size). Convert the
offsets read from an iterator to a callback the caller pushes into:

- trait method iter_offsets -> for_each_offset(ids, FnMut(usize, MultivectorOffset))
  returning common::universal_io::Result<()>
- Mmap impl now uses the callback read_batch (drops one read_iter use)
- Ram / Chunked impls push into the callback; Chunked still goes through
  iter_vectors for now (converted in a later step)
- the single caller (for_each_in_multi_batch) passes a closure

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* Implement read_batch directly on ReadPipeline, not via read_iter

read_batch now drives the pipeline itself (refill-then-wait loop, like
read_multi_iter) and invokes the callback per result, instead of
consuming the iterator returned by read_iter. A step toward removing the
iterator-returning read API.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* Remove the ReadMulti RPC from the StorageRead gRPC service

ReadMulti was the only real consumer of UniversalRead::read_multi (which
itself relies on read_multi_iter). Removing the RPC end-to-end clears the
path to dropping that read API. StorageReadService keeps all its other
RPCs (ListFiles, FileExists, FileLength, ReadBytes, ReadBytesStream,
ReadWhole, ReadBatch).

- proto: drop `rpc ReadMulti` + ReadMulti{Entry,Request,Response}
- regenerated lib/api + uio-client generated code; drop ReadMulti
  validation rules in lib/api/build.rs
- tonic: delete the read_multi handler + its 2 tests
- uio-client: delete Client::read_multi, the mock-server impl, and 2 tests

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* Remove UniversalRead::read_multi

Its only real consumer was the StorageRead ReadMulti gRPC handler (removed
in the previous commit); the two wrapper forwarders had no callers. Drop
the trait method and both forwarders (typed/read_only), and remove the
io_uring test that only existed to compare read_multi vs read_multi_iter
(read_multi_iter stays covered by the other tests). Another step toward
removing the iterator-returning read API.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* Drive ReadPipeline directly in gridstore read_from_pages

Replace the read_multi_iter call in Pages::read_from_pages with a direct
pipeline loop (refill-then-drain), scheduling each multi-page read on its
own page file. Behavior unchanged; another step toward removing the
iterator-returning read_multi_iter.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* Drive ReadPipeline directly in gridstore read_batch_from_pages

Replace the second read_multi_iter call (in Pages::read_batch_from_pages)
with a direct pipeline loop, scheduling each (ReadMeta, page, range) on its
own page file and propagating errors via GridstoreError. Single/multi-page
buffering and out-of-order reassembly are unchanged. No more read_multi_iter
in gridstore.

Measured overhead on warm mmap (both paths are zero-copy borrows): ~0.3 ns
per read of fixed control cost, flat across read sizes — well under 0.1% of
a real payload read.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* Drive ReadPipeline directly in on-disk postings with_posting_views

Replace read_iter in OnDiskPostings::with_posting_views with a direct
pipeline loop. wait_bytemuck yields a file-borrowed Cow, so postings are
still stored zero-copy in raw_postings (a read_batch swap would have forced
an owned copy of every posting list per query on the mmap backend).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* Read on-disk posting headers via read_batch, drop the HeadersBatch iterator

headers_iter now reads headers with the callback read_batch API: each header
is parsed (copied) out of the read bytes, so nothing borrows the file past the
read — no pipeline needed. Since the read is now eager, HeadersBatch holds the
collected Vec<HeaderResult> directly instead of a Box<dyn Iterator>, dropping
the boxing, the dynamic dispatch, and the struct's lifetime parameter.
with_posting_views takes the Vec and still pipelines the posting reads.

Removes the last read_iter use in this file.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* Use read_batch in simple_disk_cache populate_from

populate_from reads one byte per block only to fault blocks into the local
cache, discarding the bytes — a no-op-callback read_batch fits exactly. Drives
the same DiskCachePipeline as before; one less read_iter caller.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* Implement read_iter directly on ReadPipeline, not via read_multi_iter

read_iter now drives the pipeline itself (refill-then-wait loop, mirroring
read_bytes_iter) instead of mapping its ranges onto self and calling
read_multi_iter. Same signature and iterator contract, so all callers are
unchanged. Leaves iter_vectors as read_multi_iter's only remaining caller.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* Revert "Drive ReadPipeline directly in on-disk postings with_posting_views"

This reverts commit c02c41f17b.

* Add callback-based for_each_vector next to iter_vectors

for_each_vector drives the ReadPipeline directly across chunk files and
invokes a fallible callback per flattened multi-vector, returning
OperationResult, instead of returning an iterator built on read_multi_iter.
Callers will migrate onto it so iter_vectors (read_multi_iter's last caller)
can be removed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* Make dense for_each_in_batch / for_each_in_dense_batch fallible

Thread OperationResult up the dense batch-read path so io_uring read
errors propagate instead of being .expect()ed deep inside the storage.
The for_each_in_dense_batch scorer path (custom/metric query scorers)
now carries the Result to the infallible score() boundary where it is
.expect()ed; read_vectors keeps its () signature and .expect()s locally.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* Convert dense read_vectors to for_each_vector

The read-only and appendable dense storage read_vectors impls drove
ChunkedVectors::iter_vectors directly; switch them to the callback-based
for_each_vector and .expect() the result at the (infallible) read_vectors
boundary. Removes the last dense-path iter_vectors callers.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* Convert quantized for_each_offset to for_each_vector + OperationResult

The two chunked MultivectorOffsetsStorage impls drove iter_vectors to read
the offset table; switch them to the callback-based for_each_vector.
for_each_vector returns OperationResult, so upgrade the for_each_offset
trait (and all four impls) from universal_io::Result to OperationResult
(the universal_io -> Operation direction, via ?). No error downgrade.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* Convert EncodedStorage/EncodedVectors iter_batch to callback for_each_batch

The two chunked-mmap EncodedStorage impls drove ChunkedVectors::iter_vectors
to back iter_batch. Replace the iterator-returning iter_batch on both the
EncodedStorage and EncodedVectors traits (quantization crate) with a callback
for_each_batch(FnMut(usize, &[u8])), and switch the chunked impls to
for_each_vector. The callback is infallible: the chunked impls .expect() the
read internally, matching iter_vectors' prior panic-on-read-error behavior,
so no OperationError is downgraded. Scorers and the multivector readers adopt
the callback; the accumulating multivector path owns (to_vec) only when it
must buffer across components.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* Convert multivector read paths to for_each_vector

The read_only multivector free fn chained two iter_vectors (offsets feeding
vectors) - the recursive iterator nesting behind the worst symbol bloat.
Replace it with a callback for_each_vector that resolves the per-point
offsets into a Vec first, then drives ChunkedVectors::for_each_vector over
the flattened vectors. Migrate both multivector storages' read_vectors and
for_each_in_batch_multi accordingly, .expect()ing at their infallible
boundaries.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* Remove read_multi_iter and ChunkedVectors::iter_vectors

With every caller migrated to callback-based for_each_vector/for_each_batch,
delete the last iterator-returning multi-read APIs: ChunkedVectors::iter_vectors
(segment) and the read_multi_iter trait method plus its mmap override, the
TypedStorage/ReadOnly wrapper forwarders, and the two io_uring unit tests.

These deeply-nested monomorphized iterator types (read_multi_iter feeding
read_multi_iter) produced >1 MiB mangled drop_in_place symbols that overflowed
the macOS ld symbol-name limit; the callback rewrite eliminates them.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* Pass owned Cow through for_each_vector/for_each_batch to avoid a copy

The callback-based readers handed the callback a borrowed `&[u8]`/`&[T]`,
forcing the quantized multivector batch read to `to_vec()` each sub-vector
into its per-point buffer. But io_uring-like backends already return a freshly
owned buffer per read (`ACow::Owned`), so that was a redundant second copy.

Change `ChunkedVectorsRead::for_each_vector` and the `EncodedStorage` /
`EncodedVectors` `for_each_batch` callbacks to receive `Cow<[..]>` by value.
The buffering path now `into_owned()`s it — a move when the backend returned
owned (the case this path targets), a copy only for a borrowed Cow (mmap),
which never reaches this path. Immediate-use callers (scorers,
score_point_max_similarity, the dense/multivector readers) just deref the Cow;
the dense readers drop their now-redundant `Cow::Borrowed` wraps.

Also clarifies the multivector reader: `SubVectorOwner`/`owners`/
`sub_vector_offsets` naming, docs, and a corrected comment noting the per-point
buffer is what makes regrouping order-independent under out-of-order completion.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* Drop the removed ReadMulti JWT access test; fix clippy unwrap_or_default

The ReadMulti StorageRead RPC was removed earlier in this branch, so the
consensus JWT-access test (and its registry entry) for it must go too. Also
switch the multivector buffer's `or_insert_with(SmallVec::new)` to
`or_default()` per clippy::unwrap_or_default.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* Add MmapFile::read_batch

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: xzfc <xzfcpw@gmail.com>
2026-07-01 14:03:32 +02:00
Ritwij Aryan Parmar 014c158960 Fix scalar L2 quantized score shift (#9518)
* fix scalar L2 quantized score shift

* Format scalar quantization imports

* Fix quantization test clippy
2026-06-23 23:13:18 +02:00
Ivan Pleshkov 47767d19fd unpadded rotations (#9450) 2026-06-12 22:24:23 +02:00
c79ca1bdd1 feat: live_reload for read-only quantized vectors (#9375)
* feat: live_reload for read-only quantized vectors

* Propagate live_reload params through quantized chunked storage

Thread fs, deleted_points, new_points and hw_counter through the full
quantized live_reload chain instead of synthesizing empty deltas and a
disposable hardware counter at the leaf storages.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix: use LiveReload trait in chunked mmap

* fix: use LiveReload trait

* fix: compiler errors

---------

Co-authored-by: generall <andrey@vasnetsov.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 16:58:02 +02:00
Jojii 617d44bde8 [TQDT] TurboVectorStorage Implementation (#9331)
* Partial function impls for TurboVectorStorage

* create/load functionality + tests

# Conflicts:
#	lib/segment/src/vector_storage/turbo/mod.rs
#	lib/segment/src/vector_storage/turbo/turbo_encoded_vectors.rs

* Fix vectors are not rotated back

* update_from impl

* Rebase fixes

* Proper update_from for ChunkedMmap + better tests

* Improve test coverage

* Move update_from to new DenseTQVectorStorage trait

* Review remark quantization::DistanceType

* Add populate and clear_cache to TurboVectorStorage and TurboEncodedVectorStorage
2026-06-09 13:53:32 +02:00
Andrey VasnetsovandClaude Opus 4.8 80acb6c55d feat: read-only QuantizedVectors generic over UniversalRead (#9346)
* feat: add read-only QuantizedVectors generic over UniversalRead

Introduce `QuantizedVectorsRead<S>` / `QuantizedVectorStorageRead<S>`, a
read-only counterpart of `QuantizedVectors` organized like
`VectorStorageReadEnum`: generic over the `UniversalRead` backend `S`,
opened from existing on-disk data, with no create/upsert/builder path and
no disk writes.

Highlights:
- Keep both in-RAM (`*Ram`) and read-only mmap (`*Mmap`) variants; drop the
  appendable `*ChunkedMmap` variants (the only mutable ones).
- All bulk reads go through `S`: add `QuantizedRamStorage::from_universal_read`
  and `MultivectorOffsetsStorageRam::open`, and make
  `MultivectorOffsetsStorageMmap` generic over `S` (default `MmapFile`).
- Share scorer construction between the read-write and read-only enums via a
  `QuantizedScorerDispatch` trait, so only the per-variant match is duplicated
  while the datatype/distance and per-query dispatch live once in the builder.

Tested: read-only vs read-write scorer parity (scalar/binary/product, single
and multivector, RAM and mmap), covering both `raw_scorer` and
`raw_internal_scorer`.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor: route quantized RAM loading through UniversalRead

Follow-up to the read-only quantized work, removing direct-filesystem reads
and load-path duplication:

- `EncodedVectors{U8,PQ,Bin,TQ}::load` now take a `UniversalRead` filesystem
  and read metadata via `read_json_via` instead of `fs::read_to_string` —
  every read flows through universal IO. Single-vector `load` returns
  `common::universal_io::Result`; `validate_storage_vector_size` stays
  `std::io::Result`.
- Add `common::universal_io::OneshotFile<S>`: a thin RAII wrapper over any
  `UniversalRead` handle that evicts the data from cache via `clear_ram_cache`
  on drop (the universal-IO counterpart of `fs::OneshotFile`).
- Collapse `QuantizedRamStorage::{from_file, from_universal_read}` into one
  `from_file<S: UniversalRead>`. It reads the whole file in a single access
  (no separate `len()` round-trip — cheaper on S3-like backends) and loads via
  the new `VolatileChunkedVectors::extend`, which inserts one chunk per
  `copy_from_slice` instead of one vector at a time.
- RW callers pass the local `READ_FS` (mmap) backend; the read-only loader
  passes its `S`.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat: load appendable (chunked) quantization format read-only

`storage_type` only selects the on-disk layout, so the read-only quantized
storage must be able to load both — the immutable flat format and the
appendable chunked format (produced only by Binary/TurboQuant). Previously the
read-only loader rejected the Mutable layout outright.

- Add `QuantizedChunkedStorageRead<S>` and `MultivectorOffsetsStorageChunkedRead<S>`,
  read-only wrappers over the existing `ChunkedVectorsRead<_, S>` primitive
  (mirrors the dense read-view's chunked read storage). Generic over the
  `UniversalRead` backend, on-disk, no write path.
- Add `BinaryChunked`/`TQChunked` (+ multi) variants to `QuantizedVectorStorageRead`
  and wire them through every accessor and the scorer dispatch.
- Route `storage_type == Mutable` to the chunked read variants in the loader and
  drop the blanket rejection.
- Tests: parametrize the read-only/read-write parity tests over `storage_type`
  and add chunked (Mutable) cases for binary/turbo, single and multivector.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor: split quantized chunked & multivector storage into modules

`quantized_chunked_mmap_storage.rs` and `quantized_multivector_storage.rs` had
grown to hold several unrelated structures, making them hard to navigate.
Convert each into a module (pure code movement, no behavior change):

- quantized_chunked_mmap_storage/{read_write,read_only}.rs — the appendable
  mmap storage + builder vs. the read-only chunked storage.
- quantized_multivector_storage/{mod,offsets}.rs — the core
  `QuantizedMultivectorStorage` + offset traits stay in mod.rs; the four
  `MultivectorOffsetsStorage*` backends move to offsets.rs.

Public paths are unchanged via re-exports.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor: single source of truth for quantized vector size

The on-disk stride (bytes per quantized vector), including the binary
`u8`(multi)/`u128`(single) word-type choice, was independently re-derived in
every load/open site with no compile-time link — the kind of value-level
duplication that silently diverges (it already caused the binary multi
`u8`/`u128` bug).

Extract it into one place:
- `QuantizedVectors::quantized_vector_size(quantization_config, vector_parameters, is_multi)`
  and the `QuantizedVectorsConfig::quantized_vector_size(is_multi)` convenience.

Route every reader through it:
- read-only `open_single`/`open_multi` and the read-write `{scalar,pq,binary,turbo}`
  loaders now hoist `config.quantized_vector_size(is_multi)` once instead of
  recomputing the per-method formula per branch.

The create (write) path keeps its own computation for now; it stays guarded by
`validate_storage_vector_size` and the read-only/read-write parity tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor: flat-match quantized loaders on a shared QuantizedStorageKind

The loaders chose the concrete storage variant via a nested
`method × (storage_type / is_ram)` match, re-derived independently in the
read-only and read-write paths — hard to follow and easy to drift.

- Add `QuantizedStorageKind` + `QuantizedVectorsConfig::storage_kind(on_disk)`
  (and `is_ram(on_disk)`): the single place the method × backend decision lives.
- Both loaders now compute the kind once and use a flat 10-arm match:
  - read-only `open_single`/`open_multi`;
  - read-write `load_single`/`load_multi` (consolidating the four per-method
    `{scalar,pq,binary,turbo}/load.rs` files, which are removed).

Both matches are exhaustive over the same enum, so the read and read-write
variants can no longer fall out of sync without a compile error.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor: genericize write-capable chunked quantized storages over Fs

Drop the hardcoded `MmapFile` specialization from `QuantizedChunkedStorage`,
`QuantizedChunkedStorageBuilder`, and `MultivectorOffsetsStorageChunked`. They
now expose the `Fs` backend (defaulting to `MmapFile`) and accept the fs handle
as a parameter, matching the read-only variants.

Introduce a single shared `ReadFile` type alias and `READ_FS` value handle in
the `quantized_vectors` module root, used by the create, load, and storage enum
paths so the local-file backend is named in one place.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor: move is_in_ram_or_mmap match into UniversalKind

Deduplicate the identical kind-to-residency match in the read-only and
write-capable chunked quantized storages by adding
UniversalKind::is_in_ram_or_mmap.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix: adapt TurboVectorStorage to quantized rename + update_from move

Integration fix after rebasing onto dev's TurboQuant work:
- QuantizedChunkedMmapStorage -> QuantizedChunkedStorage<MmapFile>
- update_from moved off the VectorStorage trait onto an inherent method,
  matching the per-kind sub-trait refactor; TurboVectorStorage implements
  neither DenseVectorStorage<T> nor the other kind traits.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 12:56:57 +02:00
Tim Visée 4e54bbc94a Fix OOB heap read crash with malicious snapshot (#9268)
* Validate quantized u8 data size on load

* Validate other quantization types

* Add test

* Use fs_err
2026-06-03 15:17:04 +02:00
Arnaud Gourlay abc53d717f Remove unecesssary Clippy allows (#9267) 2026-06-02 16:54:27 +02:00
Jojii ac6b04a69e [TQDT] Static Rotations (#9106)
* static rotations

* Review remarks
2026-05-29 03:19:44 +02:00