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.
- package/README.md +15 -0
- package/core/cli.js +7 -0
- package/core/graph/graph-expansion.js +48 -6
- package/core/incremental-indexing/application/production-li-delta.mjs +28 -3
- package/core/incremental-indexing/application/production-reconciler-helpers.mjs +48 -11
- package/core/incremental-indexing/application/production-reconciler.mjs +23 -5
- package/core/incremental-indexing/application/reconciler.mjs +1 -1
- package/core/indexing/artifact-builder.js +5 -3
- package/core/indexing/index-maintainer.mjs +49 -6
- package/core/indexing/model-pool.js +26 -0
- package/core/indexing/rss-budget.mjs +65 -0
- package/core/infrastructure/hamming-kernel.js +293 -0
- package/core/infrastructure/maxsim.wasm +0 -0
- package/core/infrastructure/simd-distance.js +39 -6
- package/core/infrastructure/simd-distance.wasm +0 -0
- package/core/infrastructure/tombstone-bitmap-reader.js +34 -0
- package/core/ranking/late-interaction-index.js +31 -3
- package/core/ranking/late-interaction-model.js +25 -4
- package/core/search/search-semantic.js +8 -1
- package/core/vector-store/binary-heap.js +12 -3
- package/core/vector-store/binary-hnsw-index.js +679 -136
- package/package.json +8 -8
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
}
|
|
Binary file
|
|
@@ -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
|
-
|
|
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 =
|
|
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
|
|
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
|
-
|
|
171
|
-
|
|
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];
|