sweet-search 2.6.9 → 2.6.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,293 @@
1
+ /**
2
+ * Resident-slab Hamming kernel for the binary HNSW hot path.
3
+ *
4
+ * The legacy wasmHammingDistance() copies BOTH 64-byte operands into WASM
5
+ * linear memory on every call — at M=64/ef=400 a query pays thousands of
6
+ * memcpy+FFI round-trips, construction ~10⁴–10⁵ per insert. This kernel keeps
7
+ * every indexed vector RESIDENT in its own WASM instance (mirrored from a
8
+ * contiguous JS slab), so a distance is a single FFI call with zero copies:
9
+ * measured ~85ns/dist vs ~490ns/dist for the copy-per-call path (M3 Max,
10
+ * 64-byte vectors). When WASM is unavailable it falls back to an unrolled
11
+ * SWAR popcount over the JS slab (~190ns/dist vs ~600ns for the byte-LUT).
12
+ *
13
+ * The JS slab is the source of truth; per-vector Uint8Array subarray views
14
+ * keep the existing `vectors[i].binary` contract intact. The WASM mirror is
15
+ * a private instance of simd-distance.wasm (its exported memory is module-
16
+ * internal), so the shared instance used by wasmInt8BatchDot & friends keeps
17
+ * its scratch-at-offset-0 layout untouched.
18
+ *
19
+ * All arithmetic is integer Hamming — every path returns bit-exact results.
20
+ */
21
+
22
+ import { readFileSync } from 'fs';
23
+ import { fileURLToPath } from 'url';
24
+ import { dirname, join } from 'path';
25
+
26
+ // WASM memory layout (all offsets fixed so memory.grow never moves them):
27
+ // [0, 256) staged query (256B covers 2048-bit vectors)
28
+ // [256, 256+4*CAP) batch index scratch (u32 node indices)
29
+ // [.., +4*CAP) batch distance output (u32)
30
+ // [SLAB_BASE, ...) resident vector slab
31
+ // BATCH_CAP is far above the max graph degree (2*M0 ≈ 256), so a whole
32
+ // neighbor block always fits in one hamming_batch call.
33
+ const QUERY_SLOT_BYTES = 256;
34
+ const BATCH_CAP = 4096;
35
+ const BATCH_IDX_PTR = QUERY_SLOT_BYTES;
36
+ const BATCH_OUT_PTR = BATCH_IDX_PTR + BATCH_CAP * 4;
37
+ const SLAB_BASE = BATCH_OUT_PTR + BATCH_CAP * 4;
38
+ const WASM_PAGE = 65536;
39
+
40
+ let cachedModule; // WebAssembly.Module | null once resolved
41
+
42
+ function getWasmModule() {
43
+ if (cachedModule !== undefined) return cachedModule;
44
+ try {
45
+ const here = dirname(fileURLToPath(import.meta.url));
46
+ const bytes = readFileSync(join(here, 'simd-distance.wasm'));
47
+ // Sync compile is fine: the module is ~1KB.
48
+ cachedModule = new WebAssembly.Module(bytes);
49
+ } catch {
50
+ cachedModule = null;
51
+ }
52
+ return cachedModule;
53
+ }
54
+
55
+ // SWAR popcount of one u32 (subtract-shift-mask + multiply reduction).
56
+ // Exact for all inputs.
57
+ function popcnt32(x) {
58
+ x -= (x >>> 1) & 0x55555555;
59
+ x = (x & 0x33333333) + ((x >>> 2) & 0x33333333);
60
+ x = (x + (x >>> 4)) & 0x0f0f0f0f;
61
+ return Math.imul(x, 0x01010101) >>> 24;
62
+ }
63
+
64
+ export class HammingSlab {
65
+ /**
66
+ * @param {number} dimension - vector size in BYTES
67
+ */
68
+ constructor(dimension) {
69
+ this.dim = dimension;
70
+ this.words = dimension >>> 2; // full u32 words
71
+ this.tailBytes = dimension & 3; // non-multiple-of-4 remainder
72
+ this.capacity = 0; // vectors
73
+ this.count = 0;
74
+ this.slab = new Uint8Array(0); // JS source of truth
75
+ this.slabU32 = new Uint32Array(0);
76
+ // Query scratch (JS fallback path): word view + byte view over one buffer
77
+ this.queryU32 = new Uint32Array(Math.ceil(dimension / 4));
78
+ this.queryBytes = new Uint8Array(this.queryU32.buffer, 0, dimension);
79
+
80
+ // Batch scratch: callers fill batchIdx[0..n) with node indices, call
81
+ // batchToQuery(n)/batchToVector(t, n), and read distances from the
82
+ // returned batchOut. One FFI call per block instead of one per neighbor.
83
+ this.batchIdx = new Uint32Array(BATCH_CAP);
84
+ this.batchOut = new Uint32Array(BATCH_CAP);
85
+ this._wasmIdxU32 = null; // cached views over WASM memory (rebuilt on grow)
86
+ this._wasmOutU32 = null;
87
+
88
+ // WASM mirror (optional)
89
+ this.wasm = null;
90
+ this.wasmMem = null;
91
+ const mod = getWasmModule();
92
+ if (mod) {
93
+ try {
94
+ const instance = new WebAssembly.Instance(mod);
95
+ this.wasm = instance.exports;
96
+ this.wasmMem = new Uint8Array(this.wasm.memory.buffer);
97
+ } catch {
98
+ this.wasm = null;
99
+ }
100
+ }
101
+ }
102
+
103
+ _wasmView() {
104
+ if (this.wasm && this.wasmMem.buffer !== this.wasm.memory.buffer) {
105
+ this.wasmMem = new Uint8Array(this.wasm.memory.buffer);
106
+ this._wasmIdxU32 = null;
107
+ this._wasmOutU32 = null;
108
+ }
109
+ return this.wasmMem;
110
+ }
111
+
112
+ /** Grow to hold at least `count` vectors. Existing views stay valid until
113
+ * a grow re-points them — callers must use the views returned by set(). */
114
+ ensure(count) {
115
+ if (count <= this.capacity) return false;
116
+ let newCap = Math.max(1024, this.capacity * 2);
117
+ while (newCap < count) newCap *= 2;
118
+ const newSlab = new Uint8Array(newCap * this.dim);
119
+ newSlab.set(this.slab.subarray(0, this.count * this.dim));
120
+ this.slab = newSlab;
121
+ this.slabU32 = new Uint32Array(newSlab.buffer, 0, (newCap * this.dim) >>> 2);
122
+ this.capacity = newCap;
123
+ if (this.wasm) {
124
+ const needed = SLAB_BASE + newCap * this.dim;
125
+ const have = this.wasm.memory.buffer.byteLength;
126
+ if (needed > have) {
127
+ try {
128
+ this.wasm.memory.grow(Math.ceil((needed - have) / WASM_PAGE));
129
+ } catch {
130
+ // Growth refused (host memory pressure) — drop the mirror, JS
131
+ // fallback keeps everything exact.
132
+ this.wasm = null;
133
+ }
134
+ }
135
+ }
136
+ return true;
137
+ }
138
+
139
+ /**
140
+ * Write vector bytes at index (append or overwrite) into the JS slab and
141
+ * the WASM mirror. Returns the canonical subarray view for storage on the
142
+ * node record.
143
+ */
144
+ set(idx, bytes) {
145
+ this.ensure(idx + 1);
146
+ const off = idx * this.dim;
147
+ const n = Math.min(bytes.length, this.dim);
148
+ this.slab.set(n === bytes.length ? bytes : bytes.subarray(0, n), off);
149
+ if (n < this.dim) this.slab.fill(0, off + n, off + this.dim);
150
+ if (this.wasm) {
151
+ this._wasmView().set(this.slab.subarray(off, off + this.dim), SLAB_BASE + off);
152
+ }
153
+ if (idx >= this.count) this.count = idx + 1;
154
+ return this.slab.subarray(off, off + this.dim);
155
+ }
156
+
157
+ /** Re-point a node's canonical view after grows (rarely needed by callers
158
+ * that persist views long-term across many inserts). */
159
+ view(idx) {
160
+ const off = idx * this.dim;
161
+ return this.slab.subarray(off, off + this.dim);
162
+ }
163
+
164
+ /** Stage the query for distToQuery(). Shorter inputs are zero-padded. */
165
+ setQuery(bytes) {
166
+ const n = Math.min(bytes.length, this.dim);
167
+ this.queryBytes.set(n === bytes.length ? bytes : bytes.subarray(0, n));
168
+ if (n < this.dim) this.queryBytes.fill(0, n);
169
+ if (this.wasm) {
170
+ this._wasmView().set(this.queryBytes, 0);
171
+ }
172
+ }
173
+
174
+ /** Hamming distance from the staged query to vector idx. */
175
+ distToQuery(idx) {
176
+ if (this.wasm) {
177
+ return this.wasm.hamming_distance(0, SLAB_BASE + idx * this.dim, this.dim);
178
+ }
179
+ if (this.tailBytes === 0) {
180
+ if (this.words === 16) return swar16(this.queryU32, 0, this.slabU32, idx << 4);
181
+ const s = this.slabU32;
182
+ const q = this.queryU32;
183
+ const o = idx * this.words;
184
+ let c = 0;
185
+ for (let w = 0; w < this.words; w++) c += popcnt32(q[w] ^ s[o + w]);
186
+ return c;
187
+ }
188
+ // dim not a multiple of 4: per-vector slab offsets are word-unaligned,
189
+ // so fall back to a byte loop.
190
+ const off = idx * this.dim;
191
+ let c = 0;
192
+ for (let i = 0; i < this.dim; i++) c += popcnt32(this.queryBytes[i] ^ this.slab[off + i]);
193
+ return c;
194
+ }
195
+
196
+ /** Hamming distance between two resident vectors. */
197
+ dist(aIdx, bIdx) {
198
+ if (this.wasm) {
199
+ return this.wasm.hamming_distance(
200
+ SLAB_BASE + aIdx * this.dim,
201
+ SLAB_BASE + bIdx * this.dim,
202
+ this.dim
203
+ );
204
+ }
205
+ if (this.tailBytes === 0) {
206
+ if (this.words === 16) return swar16(this.slabU32, aIdx << 4, this.slabU32, bIdx << 4);
207
+ const s = this.slabU32;
208
+ const ao = aIdx * this.words;
209
+ const bo = bIdx * this.words;
210
+ let c = 0;
211
+ for (let w = 0; w < this.words; w++) c += popcnt32(s[ao + w] ^ s[bo + w]);
212
+ return c;
213
+ }
214
+ const aOff = aIdx * this.dim;
215
+ const bOff = bIdx * this.dim;
216
+ let c = 0;
217
+ for (let i = 0; i < this.dim; i++) c += popcnt32(this.slab[aOff + i] ^ this.slab[bOff + i]);
218
+ return c;
219
+ }
220
+
221
+ /**
222
+ * Batch: distances from the staged query to batchIdx[0..n). Results land
223
+ * in (and are returned as) batchOut. One FFI call per block on the WASM
224
+ * path; order-preserving and bit-exact on every path.
225
+ */
226
+ batchToQuery(n) {
227
+ return this._batch(0, n);
228
+ }
229
+
230
+ /** Batch: distances from resident vector targetIdx to batchIdx[0..n). */
231
+ batchToVector(targetIdx, n) {
232
+ if (this.wasm) return this._batch(SLAB_BASE + targetIdx * this.dim, n);
233
+ const idx = this.batchIdx;
234
+ const out = this.batchOut;
235
+ for (let i = 0; i < n; i++) out[i] = this.dist(targetIdx, idx[i]);
236
+ return out;
237
+ }
238
+
239
+ _batch(qPtr, n) {
240
+ if (this.wasm && this.wasm.hamming_batch && n <= BATCH_CAP) {
241
+ this._wasmView(); // refresh cached views if memory grew
242
+ if (this._wasmIdxU32 === null) {
243
+ this._wasmIdxU32 = new Uint32Array(this.wasm.memory.buffer, BATCH_IDX_PTR, BATCH_CAP);
244
+ this._wasmOutU32 = new Uint32Array(this.wasm.memory.buffer, BATCH_OUT_PTR, BATCH_CAP);
245
+ }
246
+ const wIdx = this._wasmIdxU32;
247
+ const idx = this.batchIdx;
248
+ for (let i = 0; i < n; i++) wIdx[i] = idx[i];
249
+ this.wasm.hamming_batch(qPtr, SLAB_BASE, this.dim, BATCH_IDX_PTR, n, BATCH_OUT_PTR);
250
+ const wOut = this._wasmOutU32;
251
+ const out = this.batchOut;
252
+ for (let i = 0; i < n; i++) out[i] = wOut[i];
253
+ return out;
254
+ }
255
+ const idx = this.batchIdx;
256
+ const out = this.batchOut;
257
+ for (let i = 0; i < n; i++) out[i] = this.distToQuery(idx[i]);
258
+ return out;
259
+ }
260
+
261
+ reset() {
262
+ this.count = 0;
263
+ // Keep allocation; contents are overwritten by set() before reads.
264
+ }
265
+ }
266
+
267
+ /**
268
+ * Fully-unrolled 16-word (64-byte / 512-bit) SWAR Hamming with deferred
269
+ * reduction in ≤7-word groups — 7 words × 8/byte-lane × 4 lanes = 224 < 256,
270
+ * so the final multiply's top byte can't overflow. Bit-exact.
271
+ */
272
+ function swar16(A, ao, B, bo) {
273
+ let x, a = 0, b = 0, c = 0;
274
+ x = A[ao] ^ B[bo]; x -= (x >>> 1) & 0x55555555; x = (x & 0x33333333) + ((x >>> 2) & 0x33333333); a += (x + (x >>> 4)) & 0x0f0f0f0f;
275
+ x = A[ao + 1] ^ B[bo + 1]; x -= (x >>> 1) & 0x55555555; x = (x & 0x33333333) + ((x >>> 2) & 0x33333333); a += (x + (x >>> 4)) & 0x0f0f0f0f;
276
+ x = A[ao + 2] ^ B[bo + 2]; x -= (x >>> 1) & 0x55555555; x = (x & 0x33333333) + ((x >>> 2) & 0x33333333); a += (x + (x >>> 4)) & 0x0f0f0f0f;
277
+ x = A[ao + 3] ^ B[bo + 3]; x -= (x >>> 1) & 0x55555555; x = (x & 0x33333333) + ((x >>> 2) & 0x33333333); a += (x + (x >>> 4)) & 0x0f0f0f0f;
278
+ x = A[ao + 4] ^ B[bo + 4]; x -= (x >>> 1) & 0x55555555; x = (x & 0x33333333) + ((x >>> 2) & 0x33333333); a += (x + (x >>> 4)) & 0x0f0f0f0f;
279
+ x = A[ao + 5] ^ B[bo + 5]; x -= (x >>> 1) & 0x55555555; x = (x & 0x33333333) + ((x >>> 2) & 0x33333333); a += (x + (x >>> 4)) & 0x0f0f0f0f;
280
+ x = A[ao + 6] ^ B[bo + 6]; x -= (x >>> 1) & 0x55555555; x = (x & 0x33333333) + ((x >>> 2) & 0x33333333); a += (x + (x >>> 4)) & 0x0f0f0f0f;
281
+ x = A[ao + 7] ^ B[bo + 7]; x -= (x >>> 1) & 0x55555555; x = (x & 0x33333333) + ((x >>> 2) & 0x33333333); b += (x + (x >>> 4)) & 0x0f0f0f0f;
282
+ x = A[ao + 8] ^ B[bo + 8]; x -= (x >>> 1) & 0x55555555; x = (x & 0x33333333) + ((x >>> 2) & 0x33333333); b += (x + (x >>> 4)) & 0x0f0f0f0f;
283
+ x = A[ao + 9] ^ B[bo + 9]; x -= (x >>> 1) & 0x55555555; x = (x & 0x33333333) + ((x >>> 2) & 0x33333333); b += (x + (x >>> 4)) & 0x0f0f0f0f;
284
+ x = A[ao + 10] ^ B[bo + 10]; x -= (x >>> 1) & 0x55555555; x = (x & 0x33333333) + ((x >>> 2) & 0x33333333); b += (x + (x >>> 4)) & 0x0f0f0f0f;
285
+ x = A[ao + 11] ^ B[bo + 11]; x -= (x >>> 1) & 0x55555555; x = (x & 0x33333333) + ((x >>> 2) & 0x33333333); b += (x + (x >>> 4)) & 0x0f0f0f0f;
286
+ x = A[ao + 12] ^ B[bo + 12]; x -= (x >>> 1) & 0x55555555; x = (x & 0x33333333) + ((x >>> 2) & 0x33333333); b += (x + (x >>> 4)) & 0x0f0f0f0f;
287
+ x = A[ao + 13] ^ B[bo + 13]; x -= (x >>> 1) & 0x55555555; x = (x & 0x33333333) + ((x >>> 2) & 0x33333333); b += (x + (x >>> 4)) & 0x0f0f0f0f;
288
+ x = A[ao + 14] ^ B[bo + 14]; x -= (x >>> 1) & 0x55555555; x = (x & 0x33333333) + ((x >>> 2) & 0x33333333); c += (x + (x >>> 4)) & 0x0f0f0f0f;
289
+ x = A[ao + 15] ^ B[bo + 15]; x -= (x >>> 1) & 0x55555555; x = (x & 0x33333333) + ((x >>> 2) & 0x33333333); c += (x + (x >>> 4)) & 0x0f0f0f0f;
290
+ return (Math.imul(a, 0x01010101) >>> 24)
291
+ + (Math.imul(b, 0x01010101) >>> 24)
292
+ + (Math.imul(c, 0x01010101) >>> 24);
293
+ }
Binary file
@@ -403,9 +403,35 @@ export function wasmMaxSimDequant(queryFlat, docInt8, numQ, numD, dim, min, scal
403
403
  return maxsimExports.maxsim_dequant(qPtr, dPtr, numQ, numD, dim, min, scale);
404
404
  }
405
405
 
406
+ // Query-copy-once session for per-candidate WASM MaxSim loops.
407
+ //
408
+ // The query lives at the fixed offset DATA_OFFSET, so within one query the
409
+ // caller prepares it once and each per-candidate call skips the Q×dim×4-byte
410
+ // memcpy. The query Float32Array's identity is NOT a safe cache key — callers
411
+ // pool and mutate that buffer across queries — hence an explicit session id
412
+ // that the caller must re-obtain per query.
413
+ let _wasmQuerySession = 0;
414
+ let _wasmQueryPrepared = null; // { session, qBytes, memBuffer }
415
+
416
+ export function wasmMaxSimPrepareQuery(queryFlat, numQ, dim) {
417
+ if (!maxsimExports) return 0;
418
+ const qBytes = numQ * dim * 4;
419
+ if (!maxsimMem || maxsimMem.buffer !== maxsimExports.memory.buffer) {
420
+ maxsimMem = new Uint8Array(maxsimExports.memory.buffer);
421
+ }
422
+ if (qBytes + 1024 > maxsimMem.length) return 0;
423
+ maxsimMem.set(new Uint8Array(queryFlat.buffer, queryFlat.byteOffset, qBytes), DATA_OFFSET);
424
+ _wasmQuerySession += 1;
425
+ _wasmQueryPrepared = { session: _wasmQuerySession, qBytes, memBuffer: maxsimExports.memory.buffer };
426
+ return _wasmQuerySession;
427
+ }
428
+
406
429
  // Shared layout: copy query + doc + per-token params into WASM memory, return pointers.
407
430
  // Returns null if data doesn't fit in WASM memory.
408
- function _wasmPerTokenLayout(queryFlat, docData, minArray, scaleArray, tokenNorms, numQ, numD, dim, dBytes) {
431
+ // A live `querySession` from wasmMaxSimPrepareQuery (matching qBytes and
432
+ // memory buffer) means the query bytes already sit at DATA_OFFSET, so the
433
+ // query memcpy is skipped.
434
+ function _wasmPerTokenLayout(queryFlat, docData, minArray, scaleArray, tokenNorms, numQ, numD, dim, dBytes, querySession) {
409
435
  const qBytes = numQ * dim * 4;
410
436
  const paramBytes = numD * 4 * 3;
411
437
  if (!maxsimMem || maxsimMem.buffer !== maxsimExports.memory.buffer) {
@@ -419,7 +445,14 @@ function _wasmPerTokenLayout(queryFlat, docData, minArray, scaleArray, tokenNorm
419
445
  const scalePtr = minPtr + numD * 4;
420
446
  const normPtr = scalePtr + numD * 4;
421
447
 
422
- maxsimMem.set(new Uint8Array(queryFlat.buffer, queryFlat.byteOffset, qBytes), qPtr);
448
+ const queryAlreadyPlaced = querySession
449
+ && _wasmQueryPrepared
450
+ && _wasmQueryPrepared.session === querySession
451
+ && _wasmQueryPrepared.qBytes === qBytes
452
+ && _wasmQueryPrepared.memBuffer === maxsimExports.memory.buffer;
453
+ if (!queryAlreadyPlaced) {
454
+ maxsimMem.set(new Uint8Array(queryFlat.buffer, queryFlat.byteOffset, qBytes), qPtr);
455
+ }
423
456
  maxsimMem.set(new Uint8Array(docData.buffer, docData.byteOffset, dBytes), dPtr);
424
457
  maxsimMem.set(new Uint8Array(minArray.buffer, minArray.byteOffset, numD * 4), minPtr);
425
458
  maxsimMem.set(new Uint8Array(scaleArray.buffer, scaleArray.byteOffset, numD * 4), scalePtr);
@@ -428,16 +461,16 @@ function _wasmPerTokenLayout(queryFlat, docData, minArray, scaleArray, tokenNorm
428
461
  return { qPtr, dPtr, minPtr, scalePtr, normPtr };
429
462
  }
430
463
 
431
- export function wasmMaxSimDequantPerToken(queryFlat, docInt8, minArray, scaleArray, tokenNorms, numQ, numD, dim) {
464
+ export function wasmMaxSimDequantPerToken(queryFlat, docInt8, minArray, scaleArray, tokenNorms, numQ, numD, dim, querySession) {
432
465
  if (!maxsimExports?.maxsim_dequant_pertoken) return null;
433
- const ptrs = _wasmPerTokenLayout(queryFlat, docInt8, minArray, scaleArray, tokenNorms, numQ, numD, dim, numD * dim);
466
+ const ptrs = _wasmPerTokenLayout(queryFlat, docInt8, minArray, scaleArray, tokenNorms, numQ, numD, dim, numD * dim, querySession);
434
467
  if (!ptrs) return null;
435
468
  return maxsimExports.maxsim_dequant_pertoken(ptrs.qPtr, ptrs.dPtr, ptrs.minPtr, ptrs.scalePtr, ptrs.normPtr, numQ, numD, dim);
436
469
  }
437
470
 
438
- export function wasmMaxSimDequant4Bit(queryFlat, docPacked, minArray, scaleArray, tokenNorms, numQ, numD, dim) {
471
+ export function wasmMaxSimDequant4Bit(queryFlat, docPacked, minArray, scaleArray, tokenNorms, numQ, numD, dim, querySession) {
439
472
  if (!maxsimExports?.maxsim_dequant_4bit) return null;
440
- const ptrs = _wasmPerTokenLayout(queryFlat, docPacked, minArray, scaleArray, tokenNorms, numQ, numD, dim, numD * Math.ceil(dim / 2));
473
+ const ptrs = _wasmPerTokenLayout(queryFlat, docPacked, minArray, scaleArray, tokenNorms, numQ, numD, dim, numD * Math.ceil(dim / 2), querySession);
441
474
  if (!ptrs) return null;
442
475
  return maxsimExports.maxsim_dequant_4bit(ptrs.qPtr, ptrs.dPtr, ptrs.minPtr, ptrs.scalePtr, ptrs.normPtr, numQ, numD, dim);
443
476
  }
@@ -109,6 +109,40 @@ export function setBit(bitmap, index) {
109
109
  bitmap.payload[byte] |= mask;
110
110
  }
111
111
 
112
+ /**
113
+ * Count set bits with index < limitBits (clamped to capacity). Matches an
114
+ * isSet() loop over [0, limitBits) exactly — used for O(words) live-vector
115
+ * counting instead of an O(n) per-index probe.
116
+ */
117
+ export function popcountRange(bitmap, limitBits) {
118
+ if (!bitmap || limitBits <= 0) return 0;
119
+ const bits = Math.min(limitBits, bitmap.capacity);
120
+ const buf = bitmap.payload;
121
+ const fullBytes = bits >>> 3;
122
+ let count = 0;
123
+ let i = 0;
124
+ // SWAR popcount, 4 bytes at a time
125
+ for (; i + 4 <= fullBytes; i += 4) {
126
+ let x = buf.readUInt32LE(i);
127
+ x -= (x >>> 1) & 0x55555555;
128
+ x = (x & 0x33333333) + ((x >>> 2) & 0x33333333);
129
+ x = (x + (x >>> 4)) & 0x0f0f0f0f;
130
+ count += Math.imul(x, 0x01010101) >>> 24;
131
+ }
132
+ for (; i < fullBytes; i++) {
133
+ let b = buf[i];
134
+ while (b !== 0) { b &= b - 1; count += 1; }
135
+ }
136
+ const tailBits = bits & 7;
137
+ if (tailBits > 0) {
138
+ // Bits are LSB-first within a byte (see byteAndMask), so the low mask
139
+ // selects exactly the indices below `bits`.
140
+ let b = buf[fullBytes] & ((1 << tailBits) - 1);
141
+ while (b !== 0) { b &= b - 1; count += 1; }
142
+ }
143
+ return count;
144
+ }
145
+
112
146
  export function popcount(bitmap) {
113
147
  if (!bitmap) return 0;
114
148
  let count = 0;
@@ -15,7 +15,7 @@ import { existsSync, createWriteStream, createReadStream, statSync } from 'fs';
15
15
  import readline from 'readline';
16
16
  import path from 'path';
17
17
  import { DB_PATHS, LATE_INTERACTION_CONFIG } from '../infrastructure/config/index.js';
18
- import { wasmMaxSimF32, wasmMaxSimDequantPerToken, wasmMaxSimDequant4Bit, nativeMaxSimBatch, nativeMaxSimBatchPerToken, nativeMaxSimBatch4Bit, initWasm, isNativeMaxSimAvailable, isNativePerTokenAvailable, isNative4BitAvailable } from '../infrastructure/simd-distance.js';
18
+ import { wasmMaxSimF32, wasmMaxSimDequantPerToken, wasmMaxSimDequant4Bit, wasmMaxSimPrepareQuery, nativeMaxSimBatch, nativeMaxSimBatchPerToken, nativeMaxSimBatch4Bit, initWasm, isNativeMaxSimAvailable, isNativePerTokenAvailable, isNative4BitAvailable } from '../infrastructure/simd-distance.js';
19
19
  import { fastRotate, generateSignVector, calibrateWUSH, wushRotate } from '../infrastructure/quantization.js';
20
20
  import { poolTokens } from './late-interaction-model.js';
21
21
  import { loadBitmap, isSet } from '../infrastructure/tombstone-bitmap-reader.js';
@@ -1281,6 +1281,21 @@ export class LateInteractionIndex {
1281
1281
  }
1282
1282
 
1283
1283
  _loadSegmentStaleBitmap(segmentPath) {
1284
+ // Per-query memo: scoreWithLateInteraction checks the same segment 2-3×
1285
+ // per candidate, and each uncached check costs a statSync. Within one
1286
+ // scoring pass a single freshness check per segment is equivalent — a
1287
+ // tombstone landing mid-pass races identically either way.
1288
+ const memo = this._staleQueryMemo;
1289
+ if (memo) {
1290
+ if (memo.has(segmentPath)) return memo.get(segmentPath);
1291
+ const bitmap = this._loadSegmentStaleBitmapUncached(segmentPath);
1292
+ memo.set(segmentPath, bitmap);
1293
+ return bitmap;
1294
+ }
1295
+ return this._loadSegmentStaleBitmapUncached(segmentPath);
1296
+ }
1297
+
1298
+ _loadSegmentStaleBitmapUncached(segmentPath) {
1284
1299
  const sidecarPath = segmentPath + '.stale.bin';
1285
1300
  let stat;
1286
1301
  try {
@@ -1581,6 +1596,12 @@ export class LateInteractionIndex {
1581
1596
  // don't support importance weighting, so we must use the JS-tier weighted path.
1582
1597
  const nativeScored = new Set();
1583
1598
 
1599
+ // One tombstone freshness check (statSync) per segment for this scoring
1600
+ // pass; cleared after the synchronous scoring loops below. An exception
1601
+ // path can leave it set — the next pass overwrites it, and staleness is
1602
+ // bounded by one scoring pass either way.
1603
+ this._staleQueryMemo = new Map();
1604
+
1584
1605
  // Resolve a doc-lookup ID for each candidate. Graph-expanded candidates
1585
1606
  // carry `_liChunkId` (a chunk id pointing into the LI index) while their
1586
1607
  // public `id` is the entity id from the code graph. Honouring _liChunkId
@@ -1632,6 +1653,11 @@ export class LateInteractionIndex {
1632
1653
 
1633
1654
  // Tier 2 & 3: WASM fused dequant or JS fallback for candidates not scored natively.
1634
1655
  // Try WASM fused kernels first (avoids JS-side dequant), fall back to JS dequant + wasmMaxSimF32.
1656
+ // Stage the query bytes in WASM memory once — per-candidate calls below
1657
+ // skip the redundant Q×dim×4 query memcpy when the session id matches.
1658
+ const wasmQuerySession = (useFlatPath && !this.useTokenWeights)
1659
+ ? wasmMaxSimPrepareQuery(queryFlat, effectiveQueryTokens.length, scoringDim)
1660
+ : 0;
1635
1661
  for (const candidate of toScore) {
1636
1662
  if (nativeScored.has(candidate.id)) continue;
1637
1663
  const docId = lookupDocIdOf(candidate);
@@ -1649,7 +1675,7 @@ export class LateInteractionIndex {
1649
1675
  if (doc.quantBits === 4 && doc.minArray && doc.tokenNorms) {
1650
1676
  const wasmScore = wasmMaxSimDequant4Bit(
1651
1677
  queryFlat, doc.tokens, doc.minArray, doc.scaleArray, doc.tokenNorms,
1652
- effectiveQueryTokens.length, doc.numTokens, doc.dim,
1678
+ effectiveQueryTokens.length, doc.numTokens, doc.dim, wasmQuerySession,
1653
1679
  );
1654
1680
  if (wasmScore !== null) { pushScored(candidate, wasmScore); continue; }
1655
1681
  }
@@ -1658,7 +1684,7 @@ export class LateInteractionIndex {
1658
1684
  if (doc.minArray && doc.tokenNorms && doc.quantBits !== 4) {
1659
1685
  const wasmScore = wasmMaxSimDequantPerToken(
1660
1686
  queryFlat, doc.tokens, doc.minArray, doc.scaleArray, doc.tokenNorms,
1661
- effectiveQueryTokens.length, doc.numTokens, doc.dim,
1687
+ effectiveQueryTokens.length, doc.numTokens, doc.dim, wasmQuerySession,
1662
1688
  );
1663
1689
  if (wasmScore !== null) { pushScored(candidate, wasmScore); continue; }
1664
1690
  }
@@ -1693,6 +1719,8 @@ export class LateInteractionIndex {
1693
1719
  }
1694
1720
  }
1695
1721
 
1722
+ this._staleQueryMemo = null;
1723
+
1696
1724
  for (const candidate of pruned) pushFallback(candidate, { _pruned: true });
1697
1725
 
1698
1726
  scored.sort((a, b) => b.lateInteractionScore - a.lateInteractionScore);
@@ -26,6 +26,18 @@ let lateInteractionPipeline = null;
26
26
  let loadPromise = null;
27
27
  let lateInteractionRuntimeConfig = {
28
28
  intraOpThreads: null,
29
+ // G3-LI: background/maintainer ORT profile for the LI session. When truthy,
30
+ // the session is created with the CPU mem arena OFF and force_spinning_stop
31
+ // instead of allow_spinning — the same profile buildLocalSessionOptions
32
+ // applies to the dense session. ORT never returns arena memory once grown
33
+ // (#25325), and per-file reconcile encodes produce highly variable batch
34
+ // shapes, so an arena-ON LI session in the resident maintainer accrues
35
+ // monotonic RSS in 128MB arena-extension steps (measured: 354×128MB ≈ 34GB
36
+ // after one heavy edit day). Must be set BEFORE the first encode — the
37
+ // session singleton is built once; configuring after is a silent no-op.
38
+ // Default null/off everywhere else (search/query path keeps the foreground
39
+ // arena+spinning profile for latency).
40
+ background: null,
29
41
  };
30
42
 
31
43
  // Lightweight timing accumulators for profiling (Phase 6a).
@@ -90,6 +102,7 @@ export function configureLateInteractionRuntime(overrides = {}) {
90
102
  export function resetLateInteractionRuntime() {
91
103
  lateInteractionRuntimeConfig = {
92
104
  intraOpThreads: null,
105
+ background: null,
93
106
  };
94
107
  }
95
108
 
@@ -191,18 +204,26 @@ async function loadModel() {
191
204
  // 'extended' + memArena + memPattern: marginal overhead for no measurable gain.
192
205
  // Conclusion: keep LI session lean. Only proven-beneficial options added.
193
206
  const { getOptimizedGraphPath } = await import('../infrastructure/onnx-session-utils.js');
207
+ const liBackground = !!lateInteractionRuntimeConfig.background;
194
208
  const session = await ort.InferenceSession.create(onnxPath, {
195
209
  executionProviders: ['cpu'],
196
210
  logSeverityLevel: 3, // ERROR — silence ORT's expected "optimized model is machine-specific" warning
197
211
  intraOpNumThreads: lateInteractionRuntimeConfig.intraOpThreads ?? bestIntraOpThreads(),
198
212
  interOpNumThreads: 1,
199
213
  optimizedModelFilePath: getOptimizedGraphPath(modelConfig.hfId, 'lateon'),
214
+ // G3-LI: background (maintainer) profile disables the CPU mem arena — ORT
215
+ // never returns arena memory once grown (#25325), and variable-shaped
216
+ // per-file reconcile batches make the arena extend monotonically (128MB
217
+ // steps) in a resident daemon. Foreground keeps the arena for throughput.
218
+ enableCpuMemArena: !liBackground,
200
219
  // Thread spinning keeps ORT worker threads hot between batches — trades idle
201
- // CPU for lower per-batch latency during sustained indexing runs.
220
+ // CPU for lower per-batch latency during sustained indexing runs. The
221
+ // background profile parks workers after the last Run() instead (the
222
+ // maintainer sits idle 20-60s between ticks; ~14% re-spin latency cost).
202
223
  extra: {
203
- session: {
204
- intra_op: { allow_spinning: '1' },
205
- },
224
+ session: liBackground
225
+ ? { force_spinning_stop: '1' }
226
+ : { intra_op: { allow_spinning: '1' } },
206
227
  },
207
228
  });
208
229
  const coremlActive = false;
@@ -302,8 +302,15 @@ export async function semanticSearch3Stage(query, options = {}) {
302
302
  const validIndices = [];
303
303
  let missingInt8Count = 0;
304
304
 
305
+ // One stale-bitmap snapshot for the whole pool (getInt8Vector would stat
306
+ // the bitmap file once per candidate). Falls back per-id for injected
307
+ // index doubles without the batch method.
308
+ const poolIds = stage2Candidates.map((c) => c.id);
309
+ const poolInt8 = typeof this.binaryHnswIndex.getInt8VectorsForIds === 'function'
310
+ ? this.binaryHnswIndex.getInt8VectorsForIds(poolIds)
311
+ : poolIds.map((id) => this.binaryHnswIndex.getInt8Vector(id));
305
312
  for (let i = 0; i < stage2Candidates.length; i++) {
306
- const int8Vector = this.binaryHnswIndex.getInt8Vector(stage2Candidates[i].id);
313
+ const int8Vector = poolInt8[i];
307
314
  if (int8Vector) {
308
315
  int8Vectors.push(int8Vector);
309
316
  validIndices.push(i);
@@ -164,11 +164,20 @@ export class TypedMaxHeap {
164
164
  }
165
165
  }
166
166
 
167
- // Drain heap into sorted array (ascending by distance). Destructive.
167
+ // Drain heap into sorted order (ascending by distance). Destructive.
168
+ // The returned keys/vals are POOLED buffers reused by the next drain —
169
+ // callers must consume (or copy) them before draining again. Both call
170
+ // sites (searchLayer/searchLayerQuery) box the entries immediately, so
171
+ // this removes two typed-array allocations per query / per insert-level.
168
172
  drainSorted() {
169
173
  const n = this.size;
170
- const resultKeys = new Uint32Array(n);
171
- const resultVals = new Uint32Array(n);
174
+ if (!this._drainKeys || this._drainKeys.length < n) {
175
+ const cap = Math.max(64, n);
176
+ this._drainKeys = new Uint32Array(cap);
177
+ this._drainVals = new Uint32Array(cap);
178
+ }
179
+ const resultKeys = this._drainKeys;
180
+ const resultVals = this._drainVals;
172
181
  // Extract max repeatedly → fills from end → ascending order
173
182
  for (let i = n - 1; i >= 0; i--) {
174
183
  resultKeys[i] = this.keys[0];