refactor(mnemopi): keep only production-backed native kernels after wrapper-level benchmarking
This commit is contained in:
@@ -9,7 +9,7 @@
|
||||
|
||||
use napi::{
|
||||
Error, Result, Status,
|
||||
bindgen_prelude::{Float32Array, Float64Array, Uint8Array, Uint32Array},
|
||||
bindgen_prelude::{Float32Array, Float64Array, Uint32Array},
|
||||
};
|
||||
use napi_derive::napi;
|
||||
|
||||
@@ -63,37 +63,6 @@ fn cosine_one(a: &[f64], b: &[f64]) -> f64 {
|
||||
dot / (norm_a.sqrt() * norm_b.sqrt())
|
||||
}
|
||||
|
||||
/// Cosine similarity of `query` against a batch of candidate vectors.
|
||||
///
|
||||
/// `candidates` is `n` vectors flattened row-major at `dim` elements per row
|
||||
/// (callers zero-pad shorter vectors, which matches the TS `?? 0` missing
|
||||
/// element semantics). Returns one score per candidate, bit-identical to
|
||||
/// calling the TS `cosineSimilarity(query, candidate)` per row.
|
||||
#[napi]
|
||||
pub fn cosine_similarity_batch(
|
||||
query: Float64Array,
|
||||
candidates: Float64Array,
|
||||
dim: u32,
|
||||
) -> Result<Float64Array> {
|
||||
let dim = dim as usize;
|
||||
let cands: &[f64] = &candidates;
|
||||
if dim == 0 {
|
||||
if !cands.is_empty() {
|
||||
return invalid("candidates must be empty when dim is 0");
|
||||
}
|
||||
return Ok(Float64Array::new(Vec::new()));
|
||||
}
|
||||
if !cands.len().is_multiple_of(dim) {
|
||||
return invalid("candidates length must be a multiple of dim");
|
||||
}
|
||||
let q: &[f64] = &query;
|
||||
let scores: Vec<f64> = cands
|
||||
.chunks_exact(dim)
|
||||
.map(|row| cosine_one(q, row))
|
||||
.collect();
|
||||
Ok(Float64Array::new(scores))
|
||||
}
|
||||
|
||||
/// All pairs `(i, j)` with `i < j` whose cosine similarity meets `threshold`.
|
||||
///
|
||||
/// `vectors` is `count` vectors flattened row-major at `dim` `f64` elements
|
||||
@@ -205,123 +174,6 @@ pub fn vector_index_top_k(
|
||||
Ok(VectorTopK { indices: Uint32Array::new(indices), scores: Float64Array::new(scores) })
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn hamming_one(query: &[u8], row: &[u8]) -> u32 {
|
||||
let shared = query.len().min(row.len());
|
||||
let mut distance = 0u32;
|
||||
for i in 0..shared {
|
||||
distance += (query[i] ^ row[i]).count_ones();
|
||||
}
|
||||
for &byte in &query[shared..] {
|
||||
distance += byte.count_ones();
|
||||
}
|
||||
for &byte in &row[shared..] {
|
||||
distance += byte.count_ones();
|
||||
}
|
||||
distance
|
||||
}
|
||||
|
||||
/// Hamming distance of `query` against a batch of packed binary vectors.
|
||||
///
|
||||
/// `candidates` is flattened row-major at `stride` bytes per row.
|
||||
/// `lengths[i]` gives the meaningful byte length of row `i` (clamped to
|
||||
/// `stride`); omit it when every row is exactly `stride` bytes. Semantics
|
||||
/// match the TS `hammingDistance` exactly, including popcounting the
|
||||
/// unmatched tail of whichever side is longer.
|
||||
#[napi]
|
||||
pub fn hamming_distance_batch(
|
||||
query: Uint8Array,
|
||||
candidates: Uint8Array,
|
||||
stride: u32,
|
||||
lengths: Option<Uint32Array>,
|
||||
) -> Result<Uint32Array> {
|
||||
let stride = stride as usize;
|
||||
let cands: &[u8] = &candidates;
|
||||
let q: &[u8] = &query;
|
||||
let count = match &lengths {
|
||||
Some(lens) => lens.len(),
|
||||
None if stride == 0 => {
|
||||
if cands.is_empty() {
|
||||
return Ok(Uint32Array::new(Vec::new()));
|
||||
}
|
||||
return invalid("stride must be positive when lengths is omitted");
|
||||
},
|
||||
None => {
|
||||
if !cands.len().is_multiple_of(stride) {
|
||||
return invalid("candidates length must be a multiple of stride");
|
||||
}
|
||||
cands.len() / stride
|
||||
},
|
||||
};
|
||||
if count * stride > cands.len() {
|
||||
return invalid("candidates shorter than count * stride");
|
||||
}
|
||||
let mut distances: Vec<u32> = Vec::with_capacity(count);
|
||||
for i in 0..count {
|
||||
let len = match &lengths {
|
||||
Some(lens) => (lens[i] as usize).min(stride),
|
||||
None => stride,
|
||||
};
|
||||
let row = &cands[i * stride..i * stride + len];
|
||||
distances.push(hamming_one(q, row));
|
||||
}
|
||||
Ok(Uint32Array::new(distances))
|
||||
}
|
||||
|
||||
/// Dimension-masked Hamming distance of `query` against a batch of packed
|
||||
/// binary vectors.
|
||||
///
|
||||
/// `candidates` is flattened row-major at `stride` bytes per row and
|
||||
/// `dims[i]` is the bit dimension compared for row `i`. Bytes beyond either
|
||||
/// side's data read as `0`, and a trailing partial byte is masked to the top
|
||||
/// `dims[i] % 8` bits — exactly the TS `hammingDistanceForDimension`.
|
||||
#[napi]
|
||||
pub fn hamming_distance_for_dim_batch(
|
||||
query: Uint8Array,
|
||||
candidates: Uint8Array,
|
||||
stride: u32,
|
||||
dims: Uint32Array,
|
||||
) -> Result<Uint32Array> {
|
||||
let stride = stride as usize;
|
||||
let cands: &[u8] = &candidates;
|
||||
let q: &[u8] = &query;
|
||||
let count = dims.len();
|
||||
if count * stride > cands.len() {
|
||||
return invalid("candidates shorter than dims.length * stride");
|
||||
}
|
||||
let mut distances: Vec<u32> = Vec::with_capacity(count);
|
||||
for i in 0..count {
|
||||
let row = &cands[i * stride..(i + 1) * stride];
|
||||
let dim = dims[i] as usize;
|
||||
let whole_bytes = dim >> 3;
|
||||
// Bytes beyond either side's data read as 0, so the XOR reduces to a
|
||||
// plain popcount over whichever side still has data. Sliced loops keep
|
||||
// the hot path branch-free and autovectorizable (see `hamming_one`).
|
||||
let q_end = q.len().min(whole_bytes);
|
||||
let row_end = row.len().min(whole_bytes);
|
||||
let shared = q_end.min(row_end);
|
||||
let mut distance = 0u32;
|
||||
for byte in 0..shared {
|
||||
distance += (q[byte] ^ row[byte]).count_ones();
|
||||
}
|
||||
for &byte in &q[shared..q_end] {
|
||||
distance += byte.count_ones();
|
||||
}
|
||||
for &byte in &row[shared..row_end] {
|
||||
distance += byte.count_ones();
|
||||
}
|
||||
let remaining_bits = dim & 7;
|
||||
if remaining_bits > 0 {
|
||||
let mask = (0xffu16 << (8 - remaining_bits)) as u8;
|
||||
let a = q.get(whole_bytes).copied().unwrap_or(0);
|
||||
let b = row.get(whole_bytes).copied().unwrap_or(0);
|
||||
distance += ((a ^ b) & mask).count_ones();
|
||||
}
|
||||
distances.push(distance);
|
||||
}
|
||||
Ok(Uint32Array::new(distances))
|
||||
}
|
||||
|
||||
/// ECMA-262 `\s` (`WhiteSpace` ∪ `LineTerminator`), which differs from Rust's
|
||||
/// `char::is_whitespace` (JS additionally includes U+FEFF).
|
||||
#[inline]
|
||||
@@ -456,7 +308,7 @@ pub fn mmr_rerank_indices(
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{cosine_one, hamming_one, is_js_whitespace, jaccard_sorted, word_set};
|
||||
use super::{cosine_one, is_js_whitespace, jaccard_sorted, word_set};
|
||||
|
||||
#[test]
|
||||
fn cosine_matches_reference_semantics() {
|
||||
@@ -470,13 +322,6 @@ mod tests {
|
||||
assert!((sim - expect).abs() < 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hamming_counts_unmatched_tails() {
|
||||
assert_eq!(hamming_one(&[0xff, 0x0f], &[0x0f]), 4 + 4);
|
||||
assert_eq!(hamming_one(&[], &[0xff]), 8);
|
||||
assert_eq!(hamming_one(&[0b1010], &[0b0101]), 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn word_set_matches_js_tokenizer() {
|
||||
let set = word_set("Hello\u{00a0}WORLD hello\u{feff}world");
|
||||
|
||||
@@ -1,207 +1,207 @@
|
||||
{
|
||||
"sha": "4ba69285dfb939e4fc802fa66c3eac76cd075b3b",
|
||||
"date": "2026-07-22T09:44:06.872Z",
|
||||
"sha": "3c84e2c410afae163fb1a778a4f7011354cc893c",
|
||||
"date": "2026-07-22T11:33:29.804Z",
|
||||
"scenario": "dim=384, stride=48B, warmup=20, iterations=200 (adaptive for O(n²) rows, see per-row fields), crossing-inclusive",
|
||||
"runtime": "bun 1.3.14",
|
||||
"rows": [
|
||||
{
|
||||
"kernel": "cosineSimilarityBatch",
|
||||
"count": 10,
|
||||
"ts_us": 10.61,
|
||||
"native_us": 3.35,
|
||||
"speedup": 3.16,
|
||||
"ts_us": 9.85,
|
||||
"native_us": 3.2,
|
||||
"speedup": 3.08,
|
||||
"ts_iterations": 200,
|
||||
"native_iterations": 200
|
||||
},
|
||||
{
|
||||
"kernel": "cosineSimilarityBatch",
|
||||
"count": 100,
|
||||
"ts_us": 42.48,
|
||||
"native_us": 25.04,
|
||||
"speedup": 1.7,
|
||||
"ts_iterations": 200,
|
||||
"native_iterations": 200
|
||||
},
|
||||
{
|
||||
"kernel": "cosineSimilarityBatch",
|
||||
"count": 1000,
|
||||
"ts_us": 470.41,
|
||||
"native_us": 248.07,
|
||||
"speedup": 1.9,
|
||||
"ts_iterations": 200,
|
||||
"native_iterations": 200
|
||||
},
|
||||
{
|
||||
"kernel": "cosineSimilarityBatch",
|
||||
"count": 10000,
|
||||
"ts_us": 3963.14,
|
||||
"native_us": 2454.54,
|
||||
"speedup": 1.61,
|
||||
"ts_iterations": 200,
|
||||
"native_iterations": 200
|
||||
},
|
||||
{
|
||||
"kernel": "vectorIndexTopK",
|
||||
"count": 10,
|
||||
"ts_us": 5.59,
|
||||
"native_us": 2.97,
|
||||
"speedup": 1.88,
|
||||
"ts_iterations": 200,
|
||||
"native_iterations": 200
|
||||
},
|
||||
{
|
||||
"kernel": "vectorIndexTopK",
|
||||
"count": 100,
|
||||
"ts_us": 31.47,
|
||||
"native_us": 17.37,
|
||||
"speedup": 1.81,
|
||||
"ts_iterations": 200,
|
||||
"native_iterations": 200
|
||||
},
|
||||
{
|
||||
"kernel": "vectorIndexTopK",
|
||||
"count": 1000,
|
||||
"ts_us": 325.12,
|
||||
"native_us": 166.88,
|
||||
"speedup": 1.95,
|
||||
"ts_iterations": 200,
|
||||
"native_iterations": 200
|
||||
},
|
||||
{
|
||||
"kernel": "vectorIndexTopK",
|
||||
"count": 10000,
|
||||
"ts_us": 3774.16,
|
||||
"native_us": 1869.57,
|
||||
"speedup": 2.02,
|
||||
"ts_iterations": 200,
|
||||
"native_iterations": 200
|
||||
},
|
||||
{
|
||||
"kernel": "hammingDistanceBatch",
|
||||
"count": 10,
|
||||
"ts_us": 3.1,
|
||||
"native_us": 0.59,
|
||||
"speedup": 5.27,
|
||||
"ts_iterations": 200,
|
||||
"native_iterations": 200
|
||||
},
|
||||
{
|
||||
"kernel": "hammingDistanceBatch",
|
||||
"count": 100,
|
||||
"ts_us": 7.61,
|
||||
"native_us": 0.74,
|
||||
"speedup": 10.24,
|
||||
"ts_iterations": 200,
|
||||
"native_iterations": 200
|
||||
},
|
||||
{
|
||||
"kernel": "hammingDistanceBatch",
|
||||
"count": 1000,
|
||||
"ts_us": 29.27,
|
||||
"native_us": 3.83,
|
||||
"speedup": 7.64,
|
||||
"ts_iterations": 200,
|
||||
"native_iterations": 200
|
||||
},
|
||||
{
|
||||
"kernel": "hammingDistanceBatch",
|
||||
"count": 10000,
|
||||
"ts_us": 252.75,
|
||||
"native_us": 39.02,
|
||||
"speedup": 6.48,
|
||||
"ts_iterations": 200,
|
||||
"native_iterations": 200
|
||||
},
|
||||
{
|
||||
"kernel": "cosineSimilarityPairs",
|
||||
"count": 10,
|
||||
"ts_us": 18.55,
|
||||
"native_us": 11.64,
|
||||
"speedup": 1.59,
|
||||
"ts_iterations": 200,
|
||||
"native_iterations": 200
|
||||
},
|
||||
{
|
||||
"kernel": "cosineSimilarityPairs",
|
||||
"count": 100,
|
||||
"ts_us": 1955.97,
|
||||
"native_us": 1201.4,
|
||||
"ts_us": 41.15,
|
||||
"native_us": 25.18,
|
||||
"speedup": 1.63,
|
||||
"ts_iterations": 200,
|
||||
"native_iterations": 200
|
||||
},
|
||||
{
|
||||
"kernel": "cosineSimilarityPairs",
|
||||
"kernel": "cosineSimilarityBatch",
|
||||
"count": 1000,
|
||||
"ts_us": 197715.54,
|
||||
"native_us": 122119.79,
|
||||
"ts_us": 398.55,
|
||||
"native_us": 242.88,
|
||||
"speedup": 1.64,
|
||||
"ts_iterations": 200,
|
||||
"native_iterations": 200
|
||||
},
|
||||
{
|
||||
"kernel": "cosineSimilarityBatch",
|
||||
"count": 10000,
|
||||
"ts_us": 3936.21,
|
||||
"native_us": 2431.53,
|
||||
"speedup": 1.62,
|
||||
"ts_iterations": 200,
|
||||
"native_iterations": 200
|
||||
},
|
||||
{
|
||||
"kernel": "searchExactVectorIndex (topK wrapper)",
|
||||
"count": 10,
|
||||
"ts_us": 5.82,
|
||||
"native_us": 6.44,
|
||||
"speedup": 0.9,
|
||||
"ts_iterations": 200,
|
||||
"native_iterations": 200
|
||||
},
|
||||
{
|
||||
"kernel": "searchExactVectorIndex (topK wrapper)",
|
||||
"count": 100,
|
||||
"ts_us": 36.99,
|
||||
"native_us": 19.48,
|
||||
"speedup": 1.9,
|
||||
"ts_iterations": 200,
|
||||
"native_iterations": 200
|
||||
},
|
||||
{
|
||||
"kernel": "searchExactVectorIndex (topK wrapper)",
|
||||
"count": 1000,
|
||||
"ts_us": 310.52,
|
||||
"native_us": 165.81,
|
||||
"speedup": 1.87,
|
||||
"ts_iterations": 200,
|
||||
"native_iterations": 200
|
||||
},
|
||||
{
|
||||
"kernel": "searchExactVectorIndex (topK wrapper)",
|
||||
"count": 10000,
|
||||
"ts_us": 3513.57,
|
||||
"native_us": 1884.54,
|
||||
"speedup": 1.86,
|
||||
"ts_iterations": 200,
|
||||
"native_iterations": 200
|
||||
},
|
||||
{
|
||||
"kernel": "hammingDistanceBatch (incl. packing)",
|
||||
"count": 10,
|
||||
"ts_us": 3.07,
|
||||
"native_us": 1.39,
|
||||
"speedup": 2.21,
|
||||
"ts_iterations": 200,
|
||||
"native_iterations": 200
|
||||
},
|
||||
{
|
||||
"kernel": "hammingDistanceBatch (incl. packing)",
|
||||
"count": 100,
|
||||
"ts_us": 7.09,
|
||||
"native_us": 3.97,
|
||||
"speedup": 1.79,
|
||||
"ts_iterations": 200,
|
||||
"native_iterations": 200
|
||||
},
|
||||
{
|
||||
"kernel": "hammingDistanceBatch (incl. packing)",
|
||||
"count": 1000,
|
||||
"ts_us": 34.25,
|
||||
"native_us": 26.11,
|
||||
"speedup": 1.31,
|
||||
"ts_iterations": 200,
|
||||
"native_iterations": 200
|
||||
},
|
||||
{
|
||||
"kernel": "hammingDistanceBatch (incl. packing)",
|
||||
"count": 10000,
|
||||
"ts_us": 221.43,
|
||||
"native_us": 207.45,
|
||||
"speedup": 1.07,
|
||||
"ts_iterations": 200,
|
||||
"native_iterations": 200
|
||||
},
|
||||
{
|
||||
"kernel": "cosineSimilarityPairs (incl. flatten+adjacency)",
|
||||
"count": 10,
|
||||
"ts_us": 246.34,
|
||||
"native_us": 22.08,
|
||||
"speedup": 11.16,
|
||||
"ts_iterations": 200,
|
||||
"native_iterations": 200
|
||||
},
|
||||
{
|
||||
"kernel": "cosineSimilarityPairs (incl. flatten+adjacency)",
|
||||
"count": 100,
|
||||
"ts_us": 5767.19,
|
||||
"native_us": 1381.02,
|
||||
"speedup": 4.18,
|
||||
"ts_iterations": 200,
|
||||
"native_iterations": 200
|
||||
},
|
||||
{
|
||||
"kernel": "cosineSimilarityPairs (incl. flatten+adjacency)",
|
||||
"count": 1000,
|
||||
"ts_us": 530658.9,
|
||||
"native_us": 131050.53,
|
||||
"speedup": 4.05,
|
||||
"ts_iterations": 10,
|
||||
"native_iterations": 10
|
||||
},
|
||||
{
|
||||
"kernel": "hammingDistanceForDimBatch",
|
||||
"kernel": "hammingDistanceForDimBatch (incl. packing)",
|
||||
"count": 10,
|
||||
"ts_us": 2.45,
|
||||
"native_us": 0.6,
|
||||
"speedup": 4.12,
|
||||
"ts_us": 2.52,
|
||||
"native_us": 1.95,
|
||||
"speedup": 1.29,
|
||||
"ts_iterations": 200,
|
||||
"native_iterations": 200
|
||||
},
|
||||
{
|
||||
"kernel": "hammingDistanceForDimBatch",
|
||||
"kernel": "hammingDistanceForDimBatch (incl. packing)",
|
||||
"count": 100,
|
||||
"ts_us": 5.83,
|
||||
"native_us": 0.92,
|
||||
"speedup": 6.33,
|
||||
"ts_us": 5.88,
|
||||
"native_us": 4.16,
|
||||
"speedup": 1.41,
|
||||
"ts_iterations": 200,
|
||||
"native_iterations": 200
|
||||
},
|
||||
{
|
||||
"kernel": "hammingDistanceForDimBatch",
|
||||
"kernel": "hammingDistanceForDimBatch (incl. packing)",
|
||||
"count": 1000,
|
||||
"ts_us": 21.69,
|
||||
"native_us": 5.16,
|
||||
"speedup": 4.2,
|
||||
"ts_us": 27.69,
|
||||
"native_us": 28.69,
|
||||
"speedup": 0.97,
|
||||
"ts_iterations": 200,
|
||||
"native_iterations": 200
|
||||
},
|
||||
{
|
||||
"kernel": "hammingDistanceForDimBatch",
|
||||
"kernel": "hammingDistanceForDimBatch (incl. packing)",
|
||||
"count": 10000,
|
||||
"ts_us": 202.24,
|
||||
"native_us": 68.09,
|
||||
"speedup": 2.97,
|
||||
"ts_us": 231.52,
|
||||
"native_us": 235.98,
|
||||
"speedup": 0.98,
|
||||
"ts_iterations": 200,
|
||||
"native_iterations": 200
|
||||
},
|
||||
{
|
||||
"kernel": "mmrRerankIndices (via mmrRerank)",
|
||||
"count": 10,
|
||||
"ts_us": 302.81,
|
||||
"native_us": 14.38,
|
||||
"speedup": 21.05,
|
||||
"ts_us": 331.56,
|
||||
"native_us": 15.1,
|
||||
"speedup": 21.95,
|
||||
"ts_iterations": 200,
|
||||
"native_iterations": 200
|
||||
},
|
||||
{
|
||||
"kernel": "mmrRerankIndices (via mmrRerank)",
|
||||
"count": 100,
|
||||
"ts_us": 7775.68,
|
||||
"native_us": 197.42,
|
||||
"speedup": 39.39,
|
||||
"ts_us": 7965.61,
|
||||
"native_us": 220.67,
|
||||
"speedup": 36.1,
|
||||
"ts_iterations": 200,
|
||||
"native_iterations": 200
|
||||
},
|
||||
{
|
||||
"kernel": "mmrRerankIndices (via mmrRerank)",
|
||||
"count": 1000,
|
||||
"ts_us": 73795.1,
|
||||
"native_us": 2331.18,
|
||||
"speedup": 31.66,
|
||||
"ts_us": 78042.9,
|
||||
"native_us": 2639.47,
|
||||
"speedup": 29.57,
|
||||
"ts_iterations": 200,
|
||||
"native_iterations": 200
|
||||
}
|
||||
],
|
||||
"sink": 823409556.8853176
|
||||
"sink": 823377224.8853176
|
||||
}
|
||||
|
||||
@@ -6,12 +6,8 @@
|
||||
* Run from the repo root: `bun packages/mnemopi/bench/native-vectors.bench.ts`
|
||||
*/
|
||||
import {
|
||||
cosineSimilarityBatch,
|
||||
cosineSimilarityPairs,
|
||||
hammingDistanceBatch,
|
||||
hammingDistanceForDimBatch,
|
||||
} from "@oh-my-pi/pi-natives";
|
||||
import { hammingDistance, hammingDistanceForDimension } from "../src/core/binary-vectors";
|
||||
import { jaccardSimilarity, mmrRerank } from "../src/core/mmr";
|
||||
import { searchExactVectorIndex } from "../src/core/vector-index";
|
||||
import { cosineSimilarity } from "../src/core/vector-math";
|
||||
@@ -64,22 +60,6 @@ function pushRow(kernel: string, count: number, ts: { ns: number; iterations: nu
|
||||
const rows: Row[] = [];
|
||||
const rng = makeRng(0xbe4c4);
|
||||
|
||||
for (const count of COUNTS) {
|
||||
const query = Float64Array.from({ length: DIM }, () => rng() * 2 - 1);
|
||||
const flat = new Float64Array(count * DIM);
|
||||
for (let i = 0; i < flat.length; i += 1) flat[i] = rng() * 2 - 1;
|
||||
|
||||
const ts = timeNs(() => {
|
||||
for (let row = 0; row < count; row += 1) {
|
||||
sink += cosineSimilarity(query, flat.subarray(row * DIM, (row + 1) * DIM));
|
||||
}
|
||||
});
|
||||
const native = timeNs(() => {
|
||||
sink += cosineSimilarityBatch(query, flat, DIM)[0] ?? 0;
|
||||
});
|
||||
pushRow("cosineSimilarityBatch", count, ts, native);
|
||||
}
|
||||
|
||||
// vectorIndexTopK is measured through the public wrapper searchExactVectorIndex,
|
||||
// so the native side pays the production per-call query conversion, guards, and
|
||||
// hit-array construction. The TS side replicates the pre-native wrapper body.
|
||||
@@ -113,33 +93,6 @@ for (const count of COUNTS) {
|
||||
pushRow("searchExactVectorIndex (topK wrapper)", count, ts, native);
|
||||
}
|
||||
|
||||
// hammingDistanceBatch: the native side pays FastBinarySearch.search's per-call
|
||||
// packing (stride scan, packed/lengths allocation, full copy) before crossing.
|
||||
for (const count of COUNTS) {
|
||||
const query = Uint8Array.from({ length: STRIDE }, () => Math.floor(rng() * 256));
|
||||
const backing = new Uint8Array(count * STRIDE);
|
||||
for (let i = 0; i < backing.length; i += 1) backing[i] = Math.floor(rng() * 256);
|
||||
const vectors: Uint8Array[] = [];
|
||||
for (let i = 0; i < count; i += 1) vectors.push(backing.subarray(i * STRIDE, (i + 1) * STRIDE));
|
||||
|
||||
const ts = timeNs(() => {
|
||||
for (let i = 0; i < count; i += 1) sink += hammingDistance(query, vectors[i] ?? new Uint8Array());
|
||||
});
|
||||
const native = timeNs(() => {
|
||||
let stride = 0;
|
||||
for (const vector of vectors) if (vector.length > stride) stride = vector.length;
|
||||
const packed = new Uint8Array(vectors.length * stride);
|
||||
const lengths = new Uint32Array(vectors.length);
|
||||
for (let i = 0; i < vectors.length; i += 1) {
|
||||
const vector = vectors[i] ?? new Uint8Array();
|
||||
lengths[i] = vector.length;
|
||||
packed.set(vector, i * stride);
|
||||
}
|
||||
sink += hammingDistanceBatch(query, packed, stride, lengths)[0] ?? 0;
|
||||
});
|
||||
pushRow("hammingDistanceBatch (incl. packing)", count, ts, native);
|
||||
}
|
||||
|
||||
// cosineSimilarityPairs: O(n²) pair scan. The TS baseline is the pre-native
|
||||
// clusterBySimilarity loop over per-item vectors; the native side pays the
|
||||
// production flatten (dim scan + Float64Array fill) before crossing. Capped at
|
||||
@@ -192,34 +145,6 @@ for (const count of COUNTS.filter(n => n <= 1000)) {
|
||||
pushRow("cosineSimilarityPairs (incl. flatten+adjacency)", count, ts, native);
|
||||
}
|
||||
|
||||
// hammingDistanceForDimBatch: ragged/dim-masked variant used by
|
||||
// BinaryVectorStore.search; the native side pays the production per-call
|
||||
// packed/dims allocation and copy before crossing.
|
||||
for (const count of COUNTS) {
|
||||
const query = Uint8Array.from({ length: STRIDE }, () => Math.floor(rng() * 256));
|
||||
const packed = new Uint8Array(count * STRIDE);
|
||||
for (let i = 0; i < packed.length; i += 1) packed[i] = Math.floor(rng() * 256);
|
||||
const dims = Uint32Array.from({ length: count }, () => (rng() < 0.5 ? DIM : DIM / 2));
|
||||
const vectors: Uint8Array[] = [];
|
||||
for (let i = 0; i < count; i += 1) vectors.push(packed.subarray(i * STRIDE, (i + 1) * STRIDE));
|
||||
|
||||
const ts = timeNs(() => {
|
||||
for (let i = 0; i < count; i += 1) {
|
||||
sink += hammingDistanceForDimension(query, vectors[i] ?? new Uint8Array(), dims[i] ?? DIM);
|
||||
}
|
||||
});
|
||||
const native = timeNs(() => {
|
||||
const packed2 = new Uint8Array(vectors.length * STRIDE);
|
||||
const dims2 = new Uint32Array(vectors.length);
|
||||
for (let i = 0; i < vectors.length; i += 1) {
|
||||
const vector = vectors[i] ?? new Uint8Array();
|
||||
dims2[i] = dims[i] ?? DIM;
|
||||
packed2.set(vector, i * STRIDE);
|
||||
}
|
||||
sink += hammingDistanceForDimBatch(query, packed2, STRIDE, dims2)[0] ?? 0;
|
||||
});
|
||||
pushRow("hammingDistanceForDimBatch (incl. packing)", count, ts, native);
|
||||
}
|
||||
|
||||
// mmrRerank production paths: the TS side wraps jaccardSimilarity in a lambda,
|
||||
// defeating the identity check so the exact pre-native selection loop runs; the
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import type { Database } from "bun:sqlite";
|
||||
import { hammingDistanceBatch, hammingDistanceForDimBatch } from "@oh-my-pi/pi-natives";
|
||||
|
||||
import { embeddingDim, type VecType } from "../config";
|
||||
import { closeQuietly, type DatabasePath, openDatabase } from "../db";
|
||||
@@ -148,7 +147,7 @@ export function hammingDistance(binaryA: Uint8Array | ArrayBuffer, binaryB: Uint
|
||||
return distance;
|
||||
}
|
||||
|
||||
export function hammingDistanceForDimension(
|
||||
function hammingDistanceForDimension(
|
||||
binaryA: Uint8Array | ArrayBuffer,
|
||||
binaryB: Uint8Array | ArrayBuffer,
|
||||
dim: number,
|
||||
@@ -227,31 +226,15 @@ export class BinaryVectorStore {
|
||||
const rows = this.conn
|
||||
.query(`SELECT memory_id, binary_vector, original_dim, magnitude FROM ${this.tableName}`)
|
||||
.all() as VectorRow[];
|
||||
// Native batch kernel: pack all rows at a fixed stride (zero-padded,
|
||||
// matching the TS `?? 0` out-of-range reads) and compute every
|
||||
// dimension-masked distance in one N-API crossing.
|
||||
const stride = BYTES_PER_VECTOR;
|
||||
const packed = new Uint8Array(rows.length * stride);
|
||||
const dims = new Uint32Array(rows.length);
|
||||
const comparedDims: number[] = new Array(rows.length);
|
||||
for (let i = 0; i < rows.length; i += 1) {
|
||||
const row = rows[i];
|
||||
if (row === undefined) continue;
|
||||
const results: BinaryVectorSearchResult[] = [];
|
||||
for (const row of rows) {
|
||||
const storedDim = Math.max(0, Math.min(EMBEDDING_DIM, Math.trunc(toFiniteNumber(row.original_dim))));
|
||||
const comparedDim = Math.min(queryDim, storedDim);
|
||||
comparedDims[i] = comparedDim;
|
||||
dims[i] = comparedDim;
|
||||
const bytes = bytesFromBlob(row.binary_vector);
|
||||
packed.set(bytes.length > stride ? bytes.subarray(0, stride) : bytes, i * stride);
|
||||
}
|
||||
const distances = hammingDistanceForDimBatch(queryBinary, packed, stride, dims);
|
||||
const results: BinaryVectorSearchResult[] = [];
|
||||
for (let i = 0; i < rows.length; i += 1) {
|
||||
const distance = distances[i] ?? 0;
|
||||
const distance = hammingDistanceForDimension(queryBinary, bytesFromBlob(row.binary_vector), comparedDim);
|
||||
results.push({
|
||||
memory_id: rows[i]?.memory_id ?? "",
|
||||
memory_id: row.memory_id,
|
||||
distance,
|
||||
score: informationTheoreticScore(distance, comparedDims[i] ?? 0),
|
||||
score: informationTheoreticScore(distance, comparedDim),
|
||||
});
|
||||
}
|
||||
results.sort((a, b) => b.score - a.score || a.memory_id.localeCompare(b.memory_id));
|
||||
@@ -319,22 +302,9 @@ export class FastBinarySearch {
|
||||
|
||||
search(queryBinary: Uint8Array | ArrayBuffer, topK = 10): BinaryVectorSearchResult[] {
|
||||
const query = queryBinary instanceof Uint8Array ? queryBinary : new Uint8Array(queryBinary);
|
||||
// Native batch kernel: pack all vectors at the max byte length and
|
||||
// compute every Hamming distance in one N-API crossing. Per-row
|
||||
// lengths preserve the TS unmatched-tail popcount semantics.
|
||||
let stride = 0;
|
||||
for (const vector of this.vectors) if (vector.length > stride) stride = vector.length;
|
||||
const packed = new Uint8Array(this.vectors.length * stride);
|
||||
const lengths = new Uint32Array(this.vectors.length);
|
||||
for (let i = 0; i < this.vectors.length; i += 1) {
|
||||
const vector = this.vectors[i] ?? new Uint8Array();
|
||||
lengths[i] = vector.length;
|
||||
packed.set(vector, i * stride);
|
||||
}
|
||||
const distances = hammingDistanceBatch(query, packed, stride, lengths);
|
||||
const results: BinaryVectorSearchResult[] = [];
|
||||
for (let i = 0; i < this.vectors.length; i += 1) {
|
||||
const distance = distances[i] ?? 0;
|
||||
const distance = hammingDistance(query, this.vectors[i] ?? new Uint8Array());
|
||||
results.push({
|
||||
memory_id: this.memoryIds[i] ?? "",
|
||||
distance,
|
||||
|
||||
@@ -1,13 +1,5 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import {
|
||||
cosineSimilarityBatch,
|
||||
cosineSimilarityPairs,
|
||||
hammingDistanceBatch,
|
||||
hammingDistanceForDimBatch,
|
||||
mmrRerankIndices,
|
||||
vectorIndexTopK,
|
||||
} from "@oh-my-pi/pi-natives";
|
||||
import { hammingDistance, hammingDistanceForDimension } from "../src/core/binary-vectors";
|
||||
import { cosineSimilarityPairs, mmrRerankIndices, vectorIndexTopK } from "@oh-my-pi/pi-natives";
|
||||
import { jaccardSimilarity, mmrRerank } from "../src/core/mmr";
|
||||
import { buildExactVectorIndex, searchExactVectorIndex } from "../src/core/vector-index";
|
||||
import { cosineSimilarity } from "../src/core/vector-math";
|
||||
@@ -32,24 +24,6 @@ function expectClose(actual: number, expected: number): void {
|
||||
}
|
||||
|
||||
describe("native vector kernel parity", () => {
|
||||
test("cosineSimilarityBatch matches TS cosineSimilarity per row", () => {
|
||||
const rng = makeRng(0xc051e);
|
||||
const dim = 384;
|
||||
const count = 200;
|
||||
const query = Float64Array.from({ length: dim }, () => rng() * 2 - 1);
|
||||
const candidates = new Float64Array(count * dim);
|
||||
for (let i = 0; i < candidates.length; i += 1) candidates[i] = rng() * 2 - 1;
|
||||
// Sprinkle non-finite values to exercise the finite_or_zero path.
|
||||
candidates[3] = Number.NaN;
|
||||
candidates[dim + 7] = Number.POSITIVE_INFINITY;
|
||||
const scores = cosineSimilarityBatch(query, candidates, dim);
|
||||
expect(scores.length).toBe(count);
|
||||
for (let row = 0; row < count; row += 1) {
|
||||
const expected = cosineSimilarity(query, candidates.subarray(row * dim, (row + 1) * dim));
|
||||
expectClose(scores[row] ?? Number.NaN, expected);
|
||||
}
|
||||
});
|
||||
|
||||
test("cosineSimilarityPairs matches the TS pairwise threshold loop", () => {
|
||||
const rng = makeRng(0x9a175);
|
||||
const dim = 64;
|
||||
@@ -98,45 +72,6 @@ describe("native vector kernel parity", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("hammingDistanceBatch is exactly equal to TS hammingDistance", () => {
|
||||
const rng = makeRng(0xba7c4);
|
||||
const stride = 48; // 384-dim binarized
|
||||
const count = 128;
|
||||
const query = Uint8Array.from({ length: stride }, () => Math.floor(rng() * 256));
|
||||
const packed = new Uint8Array(count * stride);
|
||||
const lengths = new Uint32Array(count);
|
||||
const vectors: Uint8Array[] = [];
|
||||
for (let i = 0; i < count; i += 1) {
|
||||
const len = i % 7 === 0 ? Math.floor(rng() * stride) : stride; // ragged rows
|
||||
const vector = Uint8Array.from({ length: len }, () => Math.floor(rng() * 256));
|
||||
vectors.push(vector);
|
||||
lengths[i] = len;
|
||||
packed.set(vector, i * stride);
|
||||
}
|
||||
const distances = hammingDistanceBatch(query, packed, stride, lengths);
|
||||
for (let i = 0; i < count; i += 1) {
|
||||
expect(distances[i]).toBe(hammingDistance(query, vectors[i] ?? new Uint8Array()));
|
||||
}
|
||||
});
|
||||
|
||||
test("hammingDistanceForDimBatch is exactly equal to TS hammingDistanceForDimension", () => {
|
||||
const rng = makeRng(0xd1235);
|
||||
const stride = 48;
|
||||
const count = 96;
|
||||
const query = Uint8Array.from({ length: stride }, () => Math.floor(rng() * 256));
|
||||
const packed = new Uint8Array(count * stride);
|
||||
const dims = new Uint32Array(count);
|
||||
for (let i = 0; i < count; i += 1) {
|
||||
dims[i] = Math.floor(rng() * (stride * 8 + 1)); // includes partial-byte tails and 0
|
||||
for (let b = 0; b < stride; b += 1) packed[i * stride + b] = Math.floor(rng() * 256);
|
||||
}
|
||||
const distances = hammingDistanceForDimBatch(query, packed, stride, dims);
|
||||
for (let i = 0; i < count; i += 1) {
|
||||
const row = packed.subarray(i * stride, (i + 1) * stride);
|
||||
expect(distances[i]).toBe(hammingDistanceForDimension(query, row, dims[i] ?? 0));
|
||||
}
|
||||
});
|
||||
|
||||
test("mmrRerankIndices selects identical index sequences to the TS loop", () => {
|
||||
const rng = makeRng(0x33a11);
|
||||
const words = ["alpha", "beta", "gamma", "delta", "epsilon", "zeta", "eta", "theta", "iota", "kappa"];
|
||||
|
||||
Vendored
-32
@@ -470,16 +470,6 @@ export interface ContextLine {
|
||||
*/
|
||||
export declare function copyToClipboard(text: string): void
|
||||
|
||||
/**
|
||||
* Cosine similarity of `query` against a batch of candidate vectors.
|
||||
*
|
||||
* `candidates` is `n` vectors flattened row-major at `dim` elements per row
|
||||
* (callers zero-pad shorter vectors, which matches the TS `?? 0` missing
|
||||
* element semantics). Returns one score per candidate, bit-identical to
|
||||
* calling the TS `cosineSimilarity(query, candidate)` per row.
|
||||
*/
|
||||
export declare function cosineSimilarityBatch(query: Float64Array, candidates: Float64Array, dim: number): Float64Array
|
||||
|
||||
/**
|
||||
* All pairs `(i, j)` with `i < j` whose cosine similarity meets `threshold`.
|
||||
*
|
||||
@@ -830,28 +820,6 @@ export interface GrepResult {
|
||||
skippedOversized?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Hamming distance of `query` against a batch of packed binary vectors.
|
||||
*
|
||||
* `candidates` is flattened row-major at `stride` bytes per row.
|
||||
* `lengths[i]` gives the meaningful byte length of row `i` (clamped to
|
||||
* `stride`); omit it when every row is exactly `stride` bytes. Semantics
|
||||
* match the TS `hammingDistance` exactly, including popcounting the
|
||||
* unmatched tail of whichever side is longer.
|
||||
*/
|
||||
export declare function hammingDistanceBatch(query: Uint8Array, candidates: Uint8Array, stride: number, lengths?: Uint32Array | undefined | null): Uint32Array
|
||||
|
||||
/**
|
||||
* Dimension-masked Hamming distance of `query` against a batch of packed
|
||||
* binary vectors.
|
||||
*
|
||||
* `candidates` is flattened row-major at `stride` bytes per row and
|
||||
* `dims[i]` is the bit dimension compared for row `i`. Bytes beyond either
|
||||
* side's data read as `0`, and a trailing partial byte is masked to the top
|
||||
* `dims[i] % 8` bits — exactly the TS `hammingDistanceForDimension`.
|
||||
*/
|
||||
export declare function hammingDistanceForDimBatch(query: Uint8Array, candidates: Uint8Array, stride: number, dims: Uint32Array): Uint32Array
|
||||
|
||||
/**
|
||||
* Quick check if content matches a pattern.
|
||||
*
|
||||
|
||||
@@ -30,7 +30,6 @@ export const astGrep = nativeBindings.astGrep;
|
||||
export const astMatch = nativeBindings.astMatch;
|
||||
export const blockRangeAt = nativeBindings.blockRangeAt;
|
||||
export const copyToClipboard = nativeBindings.copyToClipboard;
|
||||
export const cosineSimilarityBatch = nativeBindings.cosineSimilarityBatch;
|
||||
export const cosineSimilarityPairs = nativeBindings.cosineSimilarityPairs;
|
||||
export const countTokens = nativeBindings.countTokens;
|
||||
export const detectMacOSAppearance = nativeBindings.detectMacOSAppearance;
|
||||
@@ -43,8 +42,6 @@ export const getSupportedLanguages = nativeBindings.getSupportedLanguages;
|
||||
export const getWorkProfile = nativeBindings.getWorkProfile;
|
||||
export const glob = nativeBindings.glob;
|
||||
export const grep = nativeBindings.grep;
|
||||
export const hammingDistanceBatch = nativeBindings.hammingDistanceBatch;
|
||||
export const hammingDistanceForDimBatch = nativeBindings.hammingDistanceForDimBatch;
|
||||
export const hasMatch = nativeBindings.hasMatch;
|
||||
export const highlightCode = nativeBindings.highlightCode;
|
||||
export const htmlToMarkdown = nativeBindings.htmlToMarkdown;
|
||||
|
||||
Reference in New Issue
Block a user