# 1-bit binary quantization (`WITH (bit_width = 1)`) — design + status **Status: SHIPPED in v2.6.0 — `bit_width = 1` builds, scans, inserts and vacuums.** The foundation (reloption + rerank default + pure-Rust sign-BQ core) landed earlier; v2.6.0 wired the encode path, the Hamming scan kernel, `aminsert`, VACUUM and `turbovec_check`. **IVF + 1-bit (`WITH (lists = N, bit_width = 1)`) is composed after v2.6.0 — see §7.** Two corrections to what this doc originally specified, both recorded below in place: - **No wire-version bump.** §4 said "bump `VERSION` 7 → 8". That was written when v7 was current; by the time the encode path landed the format was already at v8, and the right discriminator turned out to be a **new `kind` byte (`KIND_BQ = 3`)**, not a version bump. Existing indexes keep `kind = SINGLE/COLBERT/GRAPH` and decode byte-identically — **no REINDEX**. The `bq_mean_*` fields sit at page offset 316, which was reserved-and-zero on every prior version. - **IVF + 1-bit composes** (§7). It was rejected in v2.6.0 (the cell-contiguous layout and per-cell Hamming scan were unwired); it is now implemented, still at wire version **8** with `kind = KIND_BQ` plus the existing v4 IVF chain fields. No wire bump was needed. Prior offline study: `.agent/notes/BQ_HNSW_FEASIBILITY.md` (measured recall + storage; findings respected here, not re-derived). --- ## 1. Is 1-bit TurboQuant-at-1-bit, or sign-BQ? — sign-BQ. (kernel evidence) **Decisive:** the pinned `turbovec` crate (rev `befc4cbf73ef40e440232ae597888c71fe1ba50c`) hard-rejects `bit_width < 2` in BOTH constructors: - `TurboQuantIndex::new(dim, bit_width)` — `turbovec/src/lib.rs:250`: `if !(2..=4).contains(&bit_width) { return Err(ConstructError::BitWidthOutOfRange(bit_width)) }`. - `TurboQuantIndex::new_lazy(bit_width)` — `lib.rs:286`: same check. `IdMapIndex::new` (the wrapper every pg_turbovec build/scan path calls) delegates to these, so **turbovec cannot build a 1-bit index at all.** 1-bit is therefore a *distinct scheme*: **sign binary quantization (sign-BQ)** — the DiskANN/pgvector/Qdrant coarse code: | | TurboQuant (2/3/4-bit) | sign-BQ (1-bit, this feature) | |---|---|---| | code | rotated + Lloyd-Max codes | per-coord sign bit (after centering) | | distance | rotated dot via LUT | Hamming (`popcount(XOR)`) coarse, then exact heap rerank | | per-vec storage | `dim/8 * bit_width` + 4 B scale | `dim/8`, **no scale** | | rotation / codebook | yes | no | | metric | cosine / IP | angular ≈ cosine (matches the AM's opclasses) | Even a *hypothetical* TurboQuant-at-1-bit would degenerate to the sign bit but keep the f32 LUT scorer + 4 B scale — giving up BQ's whole point (integer popcount speed, half the storage). The scheme that wins storage/latency is sign-BQ + Hamming, exactly what the feasibility study measured. --- ## 2. What landed (this branch) 1. **Reloption `WITH (bit_width = 1)`** (`src/index/options.rs`): the `bit_width` range is now `1..=4` (was `2..=4`). `bit_width = 1` with `graph = true` is rejected (the sign-BQ scan kernel is flat/IVF, not Vamana, yet). The GUC *default* stays `2..=4` — BQ is opt-in per the study ("never a default"; unusable on non-zero-centered data). 2. **Rerank default auto-widens for 1-bit — but only below 256-d.** (`src/guc.rs::hi_dim_rerank_candidate_count`): gained a `bit_width` param, so a 1-bit index computes `effective_dim = max(dim, 256)` instead of `dim`. **Read the consequence carefully, because an earlier version of this doc overstated it.** The `auto` window is `clamp(effective_dim, 256..=1024)`. For a 1-bit index that is `clamp(max(dim,256), 256..=1024)`; for 2/3/4-bit it is `clamp(dim, 256..=1024)`. Those are **the same value for every `dim >= 256`** — so the 1-bit special case is a **no-op at 256-d and above**, and only widens the window for `dim < 256`: | dim | 1-bit `auto` window | 2-bit `auto` window | special case does | |---:|---:|---:|---| | 64 | 256 | 32 | **widens 8×** | | 128 | 256 | 32 | **widens 8×** | | 192 | 256 | 32 | **widens 8×** | | 256 | 256 | 256 | nothing | | 768 | 768 | 768 | nothing | | 1536 | 1024 | 1024 | nothing | So a 1-bit-vs-2-bit comparison at `dim >= 256` and default settings compares **equal** windows, and any recall or latency difference there is the quantizer, not the knob. Below 256-d the windows differ and the comparison must control for it. (Verified against the driver's mirror of the Rust clamp during the 2026-09-09 dim sweep, which is what caught the overstatement.) Reuses the EXISTING `xs_recheckorderby` / `search_k` / `oversample` machinery — no new rerank mechanism. A user override past the floor still wins (`user_count.max(floor)`). 3. **Pure-Rust sign-BQ core** (`src/index/onebit.rs`, fully unit-tested, no pgrx cluster needed): - `pack_signs` / `unpack_signs`: MSB-first sign packing, SAME layout as `bitvec.rs` / Postgres `bit` (so the SQL Hamming/popcount kernel and the future index scorer share one convention). - `corpus_mean` + `center`: **the footgun fix — mean-centering.** The naive sign-at-zero rule sets every bit to 1 on dense-positive data (GIST: R@10 = 0.0). Subtracting the per-dim corpus mean before the sign splits each dimension. On zero-centered text embeddings the mean is ~0 (centering is a near-no-op). - `is_degenerate`: detects the pathological all-same-sign-after- centering case (every code identical, Hamming uniformly 0) so the build can ERROR instead of shipping an all-ones landmine. - `codes_stride(dim) == dim/8` — exactly **half** the 2-bit stride `dim/8 * 2` (unit-asserted). 4. **Footgun-safe half-state** (`src/index/build.rs`): until the encode path is wired, a `bit_width = 1` build raises a clear ERROR ("not yet implemented ... see docs/ONEBIT_BQ.md") at the single `ambuild` choke point — NOT a panic (turbovec's `IdMapIndex::new` would `expect()`-panic), NOT a silent success. A 1-bit index can never come into existence, so `aminsert`/scan paths are unreachable for it. `#[pg_test] pg_index_am_onebit_errors_clearly_not_panic` asserts this. --- ## 3. Storage (confirmed) Per-vector on-disk codes for a flat/IVF index: | bits | codes/vec | + scale | note | |---|---|---|---| | 1 (sign-BQ) | `dim/8` | **none** | half of 2-bit | | 2 | `dim/8 * 2` | 4 B | | | 4 | `dim/8 * 4` | 4 B | | 1536d: 1-bit = 192 B, 2-bit = 384 B + 4. The `codes_stride` unit test asserts `2bit == 2 * 1bit`. sign-BQ also drops the per-vector scale (4 B), the persisted rotation matrix (`dim*dim*4` O(1)), the Lloyd-Max codebook, and the blocked chain — so the on-disk win is slightly more than exactly 2× at the O(1) terms. The mean vector (`dim * 4` bytes, O(1)) is the only NEW header. --- ## 4. Wire-format impact — **YES, a bump is required** (flagged) A 1-bit index needs a wire bump (`VERSION` 7 -> 8) because a sign-BQ relfile is NOT byte-decodable by the current reader: - **no scales chain, no codebook, no rotation chain** — the reader must know not to expect them (the meta-page chain-offset fields would be ambiguous otherwise). - **a NEW mean-vector chain** (`dim` f32) the current meta page has no field for. - the codes-chain stride is `dim/8` (bit_width = 1), which the existing `codes_stride(1, dim)` math already produces — that part is fine. So the bump is real and NOT additive-decodable the way v4->v5->v6 were. Consequences the integration must handle: - bump `page::VERSION` 7 -> 8 **and** `EXPECTED_WIRE_FORMAT_VERSION` in `lib.rs` (the `wire_format_version_is_stable` test). - existing v7 indexes decode byte-identical (a v8 binary reads v7 as before) — so **no REINDEX for existing 2/4-bit indexes**; only a 1-bit index is new-build-only. - add `is_legacy_v7()` if a future bump needs it (the current `is_legacy_v6` gate stays as-is). - migration matrix row in `docs/UPGRADING.md`; a `migrations/NNN_*.sql` file (empty is fine — additive). - **sequencing:** this is a MINOR bump. If it ships in the same release as another wire change, co-design the single bump; otherwise it rebases onto whatever `VERSION` is current. **This branch does NOT bump the wire format** (VERSION untouched, patch- safe) precisely because the encode path that WOULD change the wire is not landed. The bump lands with the encode path, not before. --- ## 5. What shipped (v2.6.0) — was "remaining work" Not landed here because it (a) is a real new scan kernel + wire path that can't be validated end-to-end in the shared-cluster sandbox, and (b) crosses build/scan/relfile/page/cache. The spec: 1. **Encode branch** (`build.rs`, gated `bit_width == 1`): compute `corpus_mean` over the (normalised) corpus, `center` each vector, `is_degenerate` check (ERROR with a `bit_width >= 2` hint if it trips), `pack_signs` into the codes chain. Bypass `IdMapIndex::new` entirely — no scales/rotation/codebook/blocked. This is the parallel analog of the existing flat/IVF encode, minus the turbovec call. 2. **Meta-page v8** (`page.rs`): a `bq: bool` (or reuse `kind`), a mean-vector chain (first/count/bytes), scales/rotation/codebook counts = 0. `relfile.rs` write/read for the BQ shape. 3. **Scan kernel** (`cache.rs`): a `ScanHandle::Bq(Arc)` variant holding packed codes + slot_to_id + the mean. `search(query, k)` = center the query by the persisted mean, `pack_signs`, then top-k by Hamming (`popcount(q XOR code[i])`, ascending). Start SCALAR (`bitvec.rs::hamming_distance` is the correct reference); SIMD `popcount` is a follow-up (the v1.7.3-class scalar-fallback correctness lesson applies — test scalar first). [Resolved: §7 — the follow-up landed as a CPU-independent wide-word kernel; AVX2 was measured and declined.] The AM's `xs_recheckorderby` already reranks the top-k exactly against the heap — compose, don't reinvent. 4. **`#[pg_test]`** (the study's ask): build a `bit_width = 1` index over zero-centered synthetic data, assert R@10 >= 0.9 WITH the rerank on a favorable set, assert storage is ~half the `bit_width = 2` index over the same data (via `pg_relation_size`), and assert the all-positive footgun case works-via-centering-or-errors-clearly (never silent garbage). The pure-Rust `onebit` tests already cover center/pack/ degenerate correctness; the pg_test covers end-to-end recall+storage. 5. Wire bump + migration + `UPGRADING.md` row (§4). IVF `WITH (lists = N, bit_width = 1)` composes (cell-contiguous sign codes + Hamming per-cell) — implemented after v2.6.0, see §7; the graph kind is explicitly excluded (rejected in `options.rs`). --- ## 6. As-built notes (v2.6.0) What differed from the spec above, and the bugs found wiring it: 1. **`KIND_BQ = 3`, not a version bump** (see the Status note). A BQ relfile has: a codes chain of `dim/8` packed sign bits per vector, an ids chain, and a corpus-mean chain (`dim` f32). No scales, no codebook, no rotation/TQ+, no blocked chain. `MetaPageData::plan_bq` is a **separate** constructor rather than a flag on `plan_with_blocked`, so a bug in the BQ layout cannot change the layout of any existing index. 2. **Three instances of the v1.24.0 corruption class, found and fixed.** `write_tombstones_and_meta`, the tombstone placement inside the rewrite path, and `MetaPageData::total_blocks()` each summed chain page counts *without* `bq_mean_count`. On a BQ index that would have placed the tombstone chain **on top of the mean vector** and under-sized the relation — the identical shape of the v1.24.0 graph bug (which omitted `graph_count`). Found by auditing every chain-offset sum in the tree, not just the path being added. 3. **Degeneracy must be checked on the CENTERED corpus.** The first implementation checked the raw corpus, which rejects exactly the dense-positive corpora this feature exists to handle (they *are* degenerate raw — every sign bit is 1 — and index fine after centering). The existing unit test `all_positive_is_degenerate_raw_but_centering_fixes_it` says so in its name. CI caught it. The guard still fires for a corpus collapsed *after* centering (constant / near-constant), where Hamming is uniformly 0 and results would be arbitrary. 4. **The mean is NOT recomputed on `aminsert`.** Recomputing it would invalidate every sign code already packed against the old mean, so a single insert would silently degrade the whole index's ranking. The build-time mean is treated as fixed; drift is a REINDEX concern, which matches the build-then-serve model the reloption's guidance already sets out. 5. **VACUUM is tombstone-only**, sharing the graph kind's path (`graph_tombstone_dead` was already kind-agnostic slot arithmetic). Compacting would require rewriting the whole packed codes chain and renumbering every slot. 6. **`turbovec_check` skips the scales validation for BQ only.** BQ has no scales chain, so the v2.2.2 check that closed the scan-fatal blind spot would otherwise report every BQ index corrupt. It reports `kind = 'bq'`. 7. **The Hamming kernel is wide-word, and deliberately NOT SIMD.** `hamming` folds 8 bytes at a time through `u64::count_ones` (one `POPCNT` per 8 bytes instead of per byte) with a byte-wise tail. That tail loop IS the original scalar kernel and is the only path below `dim = 64`, so it stays live and covered. There is **no** `is_x86_feature_detected!`, no `target_feature`, no `unsafe`, and no runtime dispatch anywhere in the module: every machine executes the same instruction sequence over the same word decomposition, so this cannot become a second v1.7.3 (where a mis-specialised kernel returned *wrong* ANN results on pre-AVX2 CPUs). An AVX2 variant WAS written, proven bit-identical and benchmarked; it was declined — the numbers are in §7 below. Ties break toward the lower slot, and since Hamming over `dim` bits has only `dim + 1` distinct values, ties are the common case — which is why the AM's exact rerank does the fine ranking and `hi_dim_rerank` treats a 1-bit index as high-dim at any `dim` (which only changes the window below 256-d — see item 2). ### Still open - **Graph + 1-bit** — rejected in `options.rs` (and the graph kind is deprecated as of v2.5.0, so this will not be pursued). - ~~**Recall at scale.**~~ **DONE 2026-09-08.** Measured on `arnold` (AVX2) over 250k x 1024-d Cohere-wiki with 100 held-out queries and exact ground truth: 3.98x smaller than 4-bit, 2.02x smaller than 2-bit, but 2.7-6.1x the latency at matched recall (ratios CONFIRMED by a clean re-run on 2026-09-10 after the host's load problem was fixed: 2.75x / 6.09x, with recall reproducing exactly and the original absolute ms ~15% pessimistic -- see BQ_RECALL_BENCH.md 0) and a 25x wider rerank window needed to clear R@10 >= 0.99. All four pre-registered predictions held. See `docs/BQ_RECALL_BENCH.md` § 0 and `benches/results/bq_frontier_20260908/`. - ~~**IVF + 1-bit at 1M.**~~ **MEASURED 2026-09-10** (`BQ_RECALL_BENCH.md` § 0.6e, `benches/results/bq_1m_20260910/`). On a real 1M x 1024-d Cohere corpus the crossover EXISTS but is narrow: IVF beats flat by 47% at R@10 >= 0.90 and 38% at >= 0.95, loses by 12% at >= 0.98, and cannot reach >= 0.99 at all (per-probe ceiling 0.986). For bit_width >= 2 flat wins at every target. IVF storage overhead halves at 1M (+3.1% vs +6.0% at 250k). Two-axis rule: 1-bit + n >= ~1M + target <= ~0.95 -> lists = N; else flat. - ~~**IVF + 1-bit.**~~ **MEASURED 2026-09-09** (`docs/BQ_RECALL_BENCH.md` § 0.6a, `benches/results/bq_ivf_20260909/`). It builds and scans; storage overhead over flat BQ is +6.0 % (a fixed ~8.5 B/vector of IVF metadata, proportionally worst for the smallest codes). **At 250k, flat BQ dominates it**: IVF imposes a per-probe-count recall CEILING a wider rerank window cannot break (probes=8 saturates at R@10 0.846 from window 256 through 2000), whereas flat reaches 0.994. That is a scale-dependent result — 250k is below where IVF's scan-cost advantage pays — so it is a documented boundary, not a verdict. - ~~**Dimension sweep.**~~ **DONE 2026-09-09** (`docs/BQ_RECALL_BENCH.md` § 0.6c, `benches/results/bq_dimsweep_20260909/`). 1-bit's penalty shrinks monotonically with dim: the window it needs versus 2-bit for R@10 ≥ 0.95 goes 125× (256-d) → 25× (512-d) → 8× (1024-d), and storage improves too (1.90× → 1.97× vs 2-bit, since fixed per-index overhead amortises away). **1-bit is a high-dimension technique** — at 256-d it needs to rerank 6.4 % of the corpus for R@10 ≥ 0.99 and is effectively unusable. The 1024-d arm reproduced the published § 0 recall bit-identically at all 7 windows, which validates both. Caveat: low dims are prefix slices, not native embeddings, so the trend is an upper bound on dim-sensitivity. - **Real 1M+ scale** — still open. A synthetic 1M attempt produced unusable recall because the generated corpus was statistically unrankable (nn1→nn100 spread 6.6–10.4 % vs 37–268 % on a real corpus); discarded with a post-mortem in `benches/results/bq_scale_20260909/DISCARDED.md`. Needs a REAL 1M corpus. The open question is whether the rerank window needed for a given recall grows with `n`. - **Cell-aware incremental INSERT for IVF+BQ** — `aminsert` appends and degrades to a flat Hamming scan (§7 note 4). A real cell-aware insert needs slot insertion + cell-directory renumbering + tombstone-index remapping. - **Out-of-core IVF+BQ** — the BQ scan is RAM-resident (`cache::BqIndex` holds the whole codes chain). The TurboQuant IVF path has an OOC variant (`OocIvfIndex`, per-cell gather off the buffer manager); BQ does not. This matters less than for TurboQuant — 1-bit codes are `dim/8` bytes, so the resident set is 2-4× smaller than the equivalent 2/4-bit index — but it is the reason a >RAM corpus should still use `bit_width >= 2`. --- ## 7. IVF + 1-bit (`WITH (lists = N, bit_width = 1)`) — as built Composed after v2.6.0. Wire version stays **8**; the shape is `kind = KIND_BQ` plus the existing v4 IVF chain fields, so no bump was needed and no existing index is affected. **On-disk shape.** Codes (`dim/8` per slot, CELL-CONTIGUOUS) → ids (cell order, with soft-assign duplicates) → corpus mean (`dim` f32) → coarse centroids (`lists * dim` f32) → cell directory (`lists * 12` bytes) → tombstone bitmap (after a VACUUM). Six chains — more than any other kind carries — which is why the chain-offset work below was the riskiest part. 1. **Chain offsets: `bq_mean_count` added to two more running sums.** `MetaPageData::set_ivf_chains` and `set_graph_chain` each summed the preceding chains' page counts WITHOUT `bq_mean_count`. For `set_ivf_chains` that is live corruption on an IVF+BQ index: the coarse-centroid chain would be placed ON TOP of the corpus mean, so the centring vector every sign code and every query depends on would be overwritten by centroid bytes. For `set_graph_chain` it is a regression guard only (graph + 1-bit is rejected in `options.rs`, so no index has both chains). This is the FOURTH occurrence of this class: v1.24.0 omitted `graph_count`, v2.6.0 found three sites omitting `bq_mean_count`, and these two were the remaining ones. `plan_bq_with_ivf_chains_never_overlap` asserts pairwise no-overlap over every present chain and was verified to FAIL when the omission is reintroduced. 2. **No rotation, deliberately.** A TurboQuant IVF index trains its coarse cells in the ROTATED space because that is the space its per-vector fine quantizer encodes in — coarse and fine must agree. A BQ index has no rotated fine space (its code is the sign of the *centred raw* coordinate), so rotating would cost an O(dim²) `materialize_rotation_matrix`, a GEMM per build block and a `rotate_query` per scan, and align to nothing. Cells therefore live in the raw L2-normalised space, and `scan::ivf_setup_and_search` skips the rotation for a BQ handle (`ScanHandle::is_bq()`) to match. Getting this asymmetric is the sharpest failure mode: a rotated query against un-rotated centroids probes the wrong cells and collapses recall silently — which is what the per-id self-neighbour assertions in `onebit_ivf_builds_scans_and_beats_twobit_storage` are there to catch. 3. **The mean is permutation-invariant, and computed for free.** It is accumulated during the assign sweep (`onebit::accumulate_sums` per spill block, `finish_mean` at the end) over the spill in SPILL order, so it is BIT-IDENTICAL to the flat build's whole-corpus mean — the cell permutation cannot change it, and neither can `maintenance_work_mem` (block size). Note the mean must be over ROWS, not slots: soft assignment makes `n_slots > n_rows`, so a slot-order mean would be duplicate-weighted. `streamed_mean_matches_whole_corpus_mean` gates the block-size invariance. 4. **`aminsert` DEGRADES to flat, observably.** Placing a row in its cell would mean shifting every later slot and renumbering the whole cell directory (and remapping every tombstone bit, which is slot-indexed). Rather than get that subtly wrong, the insert appends and drops the cell metadata: the index falls back to a flat Hamming scan — slower, never wrong (a full scan can only improve recall), never silently lost. Crucially `lists` is PRESERVED and `ivf_degraded` stamped, so `turbovec.index_is_degraded()` returns `true` and `ambeginscan` emits the throttled degradation WARNING naming the index. REINDEX restores the cells. (The TurboQuant IVF insert path degrades too but blanks `lists` outright, so it is NOT reportable — this path is strictly better on that axis.) 5. **Two bugs found in the EXISTING flat-BQ `aminsert`**, both fixed here because the IVF work runs through the same function: - It did not re-persist the tombstone bitmap. `plan_bq` plans a fresh meta with the tombstone fields zeroed, so every insert after a VACUUM silently RESURRECTED every deleted row. This is the M2 bug the graph kind fixed in v2.1.0 via `write_full_with_prepared_graph_and_tombstones`; the BQ path shipped without the equivalent. - It appended unconditionally, so re-inserting an existing heap TID (an UPDATE of the indexed column that reuses the TID) added a SECOND slot for the same row — unbounded growth under repeated upserts, and a duplicate id, which is the exact shape the flat kind's bijection guard treats as corruption. It now overwrites the existing slot(s) in place and clears any tombstone bit on them. It also gained the graph path's row-count drift guard (chains must agree with `meta.n_vectors` before being extended). 6. **VACUUM needed no change.** `vacuum.rs` already routes `meta.is_graph() || meta.is_bq()` to the kind-agnostic `graph_tombstone_dead` (pure slot-index bitmap arithmetic), and tombstone bits are slot-indexed, which is exactly what the cell-contiguous layout is ordered by. `write_tombstones_and_meta` already counted `bq_mean_count` (fixed in v2.6.0) and counts `coarse_count`/`cell_dir_count`. 7. **`turbovec_check` gained two BQ validations**, both inside the same ShareLock as the meta/ids read (the v1.29.1 monitor-consistency invariant): the corpus mean must be `dim` f32 (a missing mean is scan-FATAL — the scan ERRORs rather than serve uncentred results), and an IVF+BQ cell directory must PARTITION the slots. Without the second, a torn directory silently mis-probes instead of failing — the same blind-spot class as the 2026-09-05 scales field report. 8. **One Hamming heap, two callers.** `onebit::topk_hamming_slots` takes an arbitrary slot iterator; `topk_hamming` is that function over `0..n`. The flat and cell-restricted scans therefore cannot diverge on the tie-break, and the tie-break is slot-id based rather than arrival-order based — load-bearing because `coarse_probe` yields cells in distance order, so slots arrive out of ascending order. An out-of-range slot (torn cell directory) is skipped, not panicked on. ### Not verified The `#[pg_test]`s in this change were **written but not run**: pgrx binds a fixed port with a shared data dir and sibling agents were using it, and this box's rustc miscompiles the turbovec crate (`llvm.x86.avx512.vpdpbusd.512` intrinsic signature mismatch) so `cargo pgrx test` cannot build here at all. What WAS run: `cargo check --features "pg18 pg_test"` (clean), the pure-Rust `onebit` (19) and `page` (25) unit tests extracted into standalone crates (all pass), and fail-before verification that the new chain-offset and tie-break tests genuinely fail when the bug they guard is reintroduced. CI is the real gate for the `#[pg_test]`s. The recall / storage / latency frontier **has since been measured** — see [`docs/BQ_RECALL_BENCH.md`](BQ_RECALL_BENCH.md) § 0 for the results (flat at 1024-d, the IVF+BQ arm, and the 256/512/1024-d dimension sweep), § 0.5 for what they do not license, and § 0.6d for the mandatory pre-flight probe before trusting any synthetic corpus. The driver is `benches/scripts/bq/bq_frontier.py`; artefacts are under `benches/results/`. --- ## 7. Hamming kernel: what was measured, and why AVX2 was declined "SIMD popcount" was listed as open under §6 note 7. It was investigated. **Outcome: shipped the safe wide-word path (measured 4.4–4.8× at embedding dims), declined the AVX2 intrinsics path (only ~1.8× further, and *slower* than wide-word below `dim = 512`).** ### 7.1 What was tried Four kernels, all producing `popcount(a XOR b)`: | kernel | how | `unsafe` | CPU-dependent | |---|---|---|---| | `bytewise` (was shipped) | `u8::count_ones` per byte | no | no | | **`u64` (now shipped)** | `u64::count_ones` per 8 bytes + byte tail | no | **no** | | `u64x4` | as above, 4 independent accumulators, 32 B/iter | no | no | | `avx2` | `pshufb` nibble-LUT + `vpsadbw`, 32 B/iter | yes | **yes** | ### 7.2 Latency — the shipped change vs what it replaced The **exact in-tree `topk_hamming`** against the verbatim pre-change byte-wise kernel. Median of 5–7 reps; agreement asserted on every timed fixture. Host: Intel Core Ultra 7 258V (Lunar Lake, AVX2, **no AVX-512**), random packed codes, `k` = the two ends of the BQ rerank window. | n | dim | stride | k | old (byte-wise) | new (`u64`) | speedup | |---:|---:|---:|---:|---:|---:|---:| | 100k | 128 | 16 B | 10 | 1.15 ms | 0.52 ms | **2.23×** | | 100k | 128 | 16 B | 800 | 1.53 ms | 0.90 ms | **1.71×** | | 100k | 768 | 96 B | 10 | 6.13 ms | 1.28 ms | **4.81×** | | 100k | 768 | 96 B | 800 | 6.57 ms | 1.48 ms | **4.46×** | | 100k | 1536 | 192 B | 10 | 11.94 ms | 2.67 ms | **4.48×** | | 100k | 1536 | 192 B | 800 | 12.06 ms | 3.21 ms | **3.76×** | | 1M | 768 | 96 B | 10 | 59.04 ms | 13.66 ms | **4.32×** | | 1M | 768 | 96 B | 800 | 61.01 ms | 13.88 ms | **4.39×** | | 4M | 768 | 96 B | 10 | 237.9 ms | 54.0 ms | **4.40×** | | 4M | 768 | 96 B | 800 | 249.7 ms | 57.4 ms | **4.35×** | The speedup is stable from 9 MiB (L3-resident) to 366 MiB (firmly DRAM), so the scan is **not** memory-bandwidth-bound at these sizes: the byte-wise kernel ran at ~1.4 GiB/s, the wide-word one at ~6.2 GiB/s and AVX2 at ~11 GiB/s, all far below this host's DRAM bandwidth. The win is smallest at low `dim` (`dim = 128` is 16 B/vector, only two words) and largest once several words fit per row, which is where BQ is actually used — BQ exists for 768/1024/1536-d embeddings. ### 7.3 Why AVX2 was declined AVX2 vs the shipped wide-word kernel, same host, `n` = 200k, `k` = 10, full `topk` including the heap, median of 9 reps: | dim | stride | byte-wise | `u64` (shipped) | AVX2 | `u64` vs byte | AVX2 vs byte | **AVX2 vs `u64`** | |---:|---:|---:|---:|---:|---:|---:|---:| | 32 | 4 B | 1.17 ms | 1.27 ms | 1.73 ms | 0.92× | 0.68× | **0.72×** | | 64 | 8 B | 1.44 ms | 0.80 ms | 1.38 ms | 1.81× | 1.05× | **0.58×** | | 128 | 16 B | 2.75 ms | 1.27 ms | 1.82 ms | 2.16× | 1.51× | **0.70×** | | 256 | 32 B | 5.08 ms | 1.17 ms | 1.24 ms | 4.33× | 4.08× | **0.94×** | | 384 | 48 B | 7.51 ms | 1.93 ms | 2.10 ms | 3.90× | 3.58× | **0.92×** | | 512 | 64 B | 9.93 ms | 2.42 ms | 1.69 ms | 4.10× | 5.88× | **1.43×** | | 768 | 96 B | 14.99 ms | 3.30 ms | 1.88 ms | 4.54× | 7.96× | **1.75×** | | 1024 | 128 B | 22.35 ms | 4.52 ms | 2.50 ms | 4.94× | 8.95× | **1.81×** | | 1536 | 192 B | 31.47 ms | 6.79 ms | 3.78 ms | 4.63× | 8.32× | **1.80×** | | 3072 | 384 B | 64.74 ms | 13.63 ms | 6.73 ms | 4.75× | 9.63× | **1.81×** | Read the last column: **AVX2 is a net LOSS below `dim = 512`** (the 32-byte main loop never runs at `dim <= 256`, so short rows pay the dispatch and setup for nothing) and worth at most ~1.8× above it. In log terms the wide-word change captures **~73% of the total available reduction** (byte-wise → AVX2) at `dim = 768`, and 69–80% across `dim` 512–3072 — for zero `unsafe` and zero CPU-dependent behaviour. Against that ~1.8× on long rows, the AVX2 path costs: - a second `unsafe` block on the scan hot path; - a `dim`-dependent dispatch threshold, i.e. a second axis of CPU/shape-dependent divergence in a project that already needs a CI `layout` matrix axis because turbovec's two scoring layouts diverged enough to flip near-ties; - a path CI **cannot** exercise both sides of. GitHub runners are AVX2, `is_x86_feature_detected!` is a runtime check, and `-C target-feature=-avx2` would not force the fallback at runtime — the same blind spot documented in `docs/CI.md` that let the v1.7.3 bug ship. The shipped kernel has none of those properties: one code path, every CPU, every arch. Also measured and rejected: `u64x4` (four independent accumulators) is **slower** than the plain single-accumulator `u64` loop below `dim = 1024` (distance-only, n=100k: 2.02× vs 2.66× over byte-wise at `dim = 768`; it only pulls ahead at `dim = 1536`, 3.46× vs 2.89×) — LLVM already extracts the ILP, and the manual unroll mostly adds a tail. A deferred-SAD AVX2 variant (reduce `vpsadbw` once every 31 iterations instead of every iteration) was also written and proven bit-identical; it matched plain AVX2 within noise, because at these strides the reduction is not the bottleneck. ### 7.4 The bit-identity proof The change is a word-size refactor, but "obviously a pure refactor" is exactly what was believed about the kernel that shipped wrong ANN results in v1.7.3. So it is proven, in-tree, as plain `#[test]`s (no cluster — they run under both CI matrix lanes and under a bare `cargo test --lib`): - `hamming_agrees_with_bitwise_reference_across_dims` — **5720 random code pairs across 143 dims** (every `dim` in `1..=130`, so every `dim % 8` and `dim % 64` residue and every sub-word length, plus 255, 256, 257, 383, 384, 511, 512, 768, 960, 1000, 1024, 1536, 3072) against a bit-by-bit MSB-first reference that shares no code and no word decomposition with the kernel. Also asserts symmetry and zero self-distance on every fixture, and cross-checks the old byte-wise kernel against the same reference. - `hamming_extremes_agree_at_every_word_boundary` — all-zero vs all-ones is exactly `dim` at 15 dims straddling byte and word boundaries (7/8/9, 63/64/65, 71/72, 127/128/129), the case random fixtures never draw. - `hamming_counts_sign_disagreements_on_real_vectors` — ties the kernel to the *semantics*: pack real f32 vectors, assert the packed-code Hamming equals a direct count of sign disagreements on the f32s. - `topk_hamming_agrees_with_bitwise_brute_force_including_ties` — **825 top-k cases** (11 dims × 5 corpus sizes × 3 query kinds × 5 `k`) asserting the full `(distance, slot)` sequence, tie order included, equals a brute-force sort scored by the independent reference. Query kinds include the all-zero code, which maximally saturates ties. - `topk_fixtures_really_are_tie_dense` — guards the above against passing vacuously by asserting the fixtures genuinely collide (≤ `dim + 1` distinct distances over 333 rows). - `topk_tie_break_prefers_the_lower_slot` — the tie-break contract in isolation, on an all-identical corpus where every slot ties. **Mutation-tested** (each mutation applied to a copy of the module and the suite re-run): dropping the byte tail, `from_be_bytes` on one operand only (endianness divergence), `AND` for `XOR`, silently skipping the last word, and `count_zeros` for `count_ones` each fail 3–6 tests. Flipping the top-k tie-break direction, or removing the tie clause while reversing the visit order, each fail exactly the three top-k tests. The AVX2 and `u64x4` candidates were held to the same bar in the scratch harness and also passed — they were declined on cost/benefit, **not** because they disagreed. One substantive finding en route: under the current ascending visit order the `d == worst && slot < worst_slot` half of the heap condition **never fires** (0 firings in 4.2M evaluations over tie-saturated corpora) — a tie is already resolved by arriving later. It is kept, and now documented, because it makes the tie-break a property of the *comparison* rather than of the loop order: `topk_tie_break_prefers_the_ lower_slot` still passes with the loop reversed, and fails if the clause or the order is broken alone. A future chunked or parallel BQ scan can therefore reorder safely. ### 7.5 What was NOT verified - **No `#[pg_test]` / `cargo pgrx test` run.** Sibling agents held the shared pgrx cluster (one fixed port, one data dir — see `AGENTS.md`). Verified instead: `cargo check --no-default-features --features "pg18 pg_test"` clean in-tree, `cargo fmt --check` clean, and the module extracted verbatim into a dependency-free scratch crate where all 19 `onebit` tests pass. The kernel is pure and has no Postgres surface, and `BqIndex::search` is unchanged, so the `#[pg_test]` risk is the usual full-suite regression check, not kernel correctness. - **Single host, single microarchitecture.** Numbers are from one Lunar Lake laptop CPU (AVX2, no AVX-512). Not re-measured on `arnold` (the project's AVX2 latency host), `meh` (pre-AVX2), or `rv` (riscv64). The wide-word kernel has no CPU-feature dispatch, so *correctness* is architecture-independent by construction; the *ratios* are not, and per `AGENTS.md` a published latency claim must come from `arnold`. Cross-checked one way: forcing `-C target-feature=-popcnt` (so `count_ones` lowers to SWAR rather than `POPCNT`, a proxy for the scalar-path hosts) still gives 2.40× / 4.62× / 4.69× at dim 128/768/1536, against 2.69× / 4.51× / 4.22× with `POPCNT` enabled — the win comes from doing 1/8 as many count operations, not from the `POPCNT` instruction. - **No end-to-end query latency.** These are kernel microbenchmarks. A real BQ `ORDER BY` also pays planning, buffer reads and the exact rerank, so the end-to-end improvement will be smaller than 4.4× by whatever fraction of query time the Hamming scan represents. That fraction was not measured. - **Big-endian.** Argued from the algebra (popcount of XOR is invariant under any shared byte permutation) and asserted by the bitwise reference tests, but no big-endian machine was available to run them on.