eigen-db 1.0.0
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 +3 -0
- package/dist/eigen-db.js +446 -0
- package/dist/eigen-db.js.map +1 -0
- package/dist/eigen-db.umd.cjs +2 -0
- package/dist/eigen-db.umd.cjs.map +1 -0
- package/package.json +27 -0
- package/src/lib/__tests__/compute.bench.ts +70 -0
- package/src/lib/__tests__/compute.test.ts +121 -0
- package/src/lib/__tests__/lexicon.test.ts +96 -0
- package/src/lib/__tests__/memory-manager.test.ts +94 -0
- package/src/lib/__tests__/result-set.test.ts +90 -0
- package/src/lib/__tests__/vector-db.test.ts +443 -0
- package/src/lib/__tests__/wasm-compute.test.ts +152 -0
- package/src/lib/compute.ts +48 -0
- package/src/lib/errors.ts +10 -0
- package/src/lib/index.ts +14 -0
- package/src/lib/lexicon.ts +95 -0
- package/src/lib/memory-manager.ts +147 -0
- package/src/lib/result-set.ts +89 -0
- package/src/lib/simd-binary.ts +11 -0
- package/src/lib/simd.wat +220 -0
- package/src/lib/storage.ts +111 -0
- package/src/lib/types.ts +39 -0
- package/src/lib/vector-db.ts +346 -0
- package/src/lib/wasm-compute.ts +41 -0
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lexicon: length-prefixed UTF-8 encoding for text strings.
|
|
3
|
+
*
|
|
4
|
+
* Format: Each entry is [4-byte uint32 length][UTF-8 bytes]
|
|
5
|
+
* This allows efficient sequential reading and appending.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const encoder = new TextEncoder();
|
|
9
|
+
const decoder = new TextDecoder();
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Encodes an array of strings into a length-prefixed binary format.
|
|
13
|
+
*/
|
|
14
|
+
export function encodeLexicon(texts: string[]): Uint8Array {
|
|
15
|
+
const encoded = texts.map((t) => encoder.encode(t));
|
|
16
|
+
const totalSize = encoded.reduce((sum, e) => sum + 4 + e.byteLength, 0);
|
|
17
|
+
|
|
18
|
+
const buffer = new ArrayBuffer(totalSize);
|
|
19
|
+
const view = new DataView(buffer);
|
|
20
|
+
const bytes = new Uint8Array(buffer);
|
|
21
|
+
let offset = 0;
|
|
22
|
+
|
|
23
|
+
for (const e of encoded) {
|
|
24
|
+
view.setUint32(offset, e.byteLength, true); // little-endian
|
|
25
|
+
offset += 4;
|
|
26
|
+
bytes.set(e, offset);
|
|
27
|
+
offset += e.byteLength;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
return bytes;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Decodes all strings from a length-prefixed binary buffer.
|
|
35
|
+
*/
|
|
36
|
+
export function decodeLexicon(data: Uint8Array): string[] {
|
|
37
|
+
const result: string[] = [];
|
|
38
|
+
const view = new DataView(data.buffer, data.byteOffset, data.byteLength);
|
|
39
|
+
let offset = 0;
|
|
40
|
+
|
|
41
|
+
while (offset < data.byteLength) {
|
|
42
|
+
const len = view.getUint32(offset, true);
|
|
43
|
+
offset += 4;
|
|
44
|
+
const text = decoder.decode(data.subarray(offset, offset + len));
|
|
45
|
+
result.push(text);
|
|
46
|
+
offset += len;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return result;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Decodes a single string at a given index from the lexicon.
|
|
54
|
+
* Returns the string and the byte offset of the next entry.
|
|
55
|
+
*/
|
|
56
|
+
export function decodeLexiconAt(data: Uint8Array, index: number): string {
|
|
57
|
+
const view = new DataView(data.buffer, data.byteOffset, data.byteLength);
|
|
58
|
+
let offset = 0;
|
|
59
|
+
|
|
60
|
+
for (let i = 0; i < index; i++) {
|
|
61
|
+
const len = view.getUint32(offset, true);
|
|
62
|
+
offset += 4 + len;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const len = view.getUint32(offset, true);
|
|
66
|
+
offset += 4;
|
|
67
|
+
return decoder.decode(data.subarray(offset, offset + len));
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Builds an index of byte offsets for each entry in the lexicon.
|
|
72
|
+
* Enables O(1) access to any entry by index.
|
|
73
|
+
*/
|
|
74
|
+
export function buildLexiconIndex(data: Uint8Array): Uint32Array {
|
|
75
|
+
const offsets: number[] = [];
|
|
76
|
+
const view = new DataView(data.buffer, data.byteOffset, data.byteLength);
|
|
77
|
+
let offset = 0;
|
|
78
|
+
|
|
79
|
+
while (offset < data.byteLength) {
|
|
80
|
+
offsets.push(offset);
|
|
81
|
+
const len = view.getUint32(offset, true);
|
|
82
|
+
offset += 4 + len;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return new Uint32Array(offsets);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Decodes a string at a given byte offset in the lexicon.
|
|
90
|
+
*/
|
|
91
|
+
export function decodeLexiconAtOffset(data: Uint8Array, byteOffset: number): string {
|
|
92
|
+
const view = new DataView(data.buffer, data.byteOffset, data.byteLength);
|
|
93
|
+
const len = view.getUint32(byteOffset, true);
|
|
94
|
+
return decoder.decode(data.subarray(byteOffset + 4, byteOffset + 4 + len));
|
|
95
|
+
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Memory Manager for WASM shared memory.
|
|
3
|
+
*
|
|
4
|
+
* Memory Layout:
|
|
5
|
+
* [ 0x00000 ] -> Query Vector Buffer (Fixed, dimensions * 4 bytes, aligned to 64KB page)
|
|
6
|
+
* [ DB_OFFSET ] -> Vector Database (Grows dynamically)
|
|
7
|
+
* [ Dynamic ] -> Scores Buffer (Mapped after DB during search)
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/** WASM page size is 64KB */
|
|
11
|
+
const PAGE_SIZE = 65536;
|
|
12
|
+
|
|
13
|
+
/** Maximum WASM memory: ~4GB (65536 pages of 64KB each) */
|
|
14
|
+
const MAX_PAGES = 65536;
|
|
15
|
+
|
|
16
|
+
export class MemoryManager {
|
|
17
|
+
readonly memory: WebAssembly.Memory;
|
|
18
|
+
readonly dimensions: number;
|
|
19
|
+
readonly queryOffset: number;
|
|
20
|
+
readonly dbOffset: number;
|
|
21
|
+
private _vectorCount: number;
|
|
22
|
+
|
|
23
|
+
constructor(dimensions: number, initialVectorCount: number = 0) {
|
|
24
|
+
this.dimensions = dimensions;
|
|
25
|
+
|
|
26
|
+
// Query buffer: dimensions * 4 bytes, aligned to page boundary
|
|
27
|
+
this.queryOffset = 0;
|
|
28
|
+
const queryBytes = dimensions * 4;
|
|
29
|
+
this.dbOffset = Math.ceil(queryBytes / PAGE_SIZE) * PAGE_SIZE;
|
|
30
|
+
|
|
31
|
+
// Calculate initial memory needed
|
|
32
|
+
const dbBytes = initialVectorCount * dimensions * 4;
|
|
33
|
+
const totalBytes = this.dbOffset + dbBytes;
|
|
34
|
+
const initialPages = Math.max(1, Math.ceil(totalBytes / PAGE_SIZE));
|
|
35
|
+
|
|
36
|
+
this.memory = new WebAssembly.Memory({ initial: initialPages });
|
|
37
|
+
this._vectorCount = initialVectorCount;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Current number of vectors stored */
|
|
41
|
+
get vectorCount(): number {
|
|
42
|
+
return this._vectorCount;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Byte offset where the scores buffer starts (right after DB) */
|
|
46
|
+
get scoresOffset(): number {
|
|
47
|
+
return this.dbOffset + this._vectorCount * this.dimensions * 4;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Total bytes needed for scores buffer */
|
|
51
|
+
get scoresBytes(): number {
|
|
52
|
+
return this._vectorCount * 4;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Maximum vectors that can be stored given the 4GB WASM memory limit.
|
|
57
|
+
* Accounts for query buffer, DB space, and scores buffer.
|
|
58
|
+
*/
|
|
59
|
+
get maxVectors(): number {
|
|
60
|
+
const availableBytes = MAX_PAGES * PAGE_SIZE - this.dbOffset;
|
|
61
|
+
// Each vector needs: dimensions * 4 bytes (DB) + 4 bytes (score)
|
|
62
|
+
const bytesPerVector = this.dimensions * 4 + 4;
|
|
63
|
+
return Math.floor(availableBytes / bytesPerVector);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Ensures memory is large enough for the current DB + scores buffer.
|
|
68
|
+
* Calls memory.grow() if needed.
|
|
69
|
+
*/
|
|
70
|
+
ensureCapacity(additionalVectors: number): void {
|
|
71
|
+
const newTotal = this._vectorCount + additionalVectors;
|
|
72
|
+
const requiredBytes =
|
|
73
|
+
this.dbOffset + newTotal * this.dimensions * 4 + newTotal * 4; // DB + scores
|
|
74
|
+
const currentBytes = this.memory.buffer.byteLength;
|
|
75
|
+
|
|
76
|
+
if (requiredBytes > currentBytes) {
|
|
77
|
+
const pagesNeeded = Math.ceil((requiredBytes - currentBytes) / PAGE_SIZE);
|
|
78
|
+
const currentPages = currentBytes / PAGE_SIZE;
|
|
79
|
+
if (currentPages + pagesNeeded > MAX_PAGES) {
|
|
80
|
+
throw new Error("WASM memory limit exceeded");
|
|
81
|
+
}
|
|
82
|
+
this.memory.grow(pagesNeeded);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Write a query vector into the query buffer region.
|
|
88
|
+
*/
|
|
89
|
+
writeQuery(vector: Float32Array): void {
|
|
90
|
+
new Float32Array(this.memory.buffer, this.queryOffset, this.dimensions).set(vector);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Append vectors to the database region.
|
|
95
|
+
* Returns the byte offset where the new vectors were written.
|
|
96
|
+
*/
|
|
97
|
+
appendVectors(vectors: Float32Array[]): number {
|
|
98
|
+
const startOffset = this.dbOffset + this._vectorCount * this.dimensions * 4;
|
|
99
|
+
let offset = startOffset;
|
|
100
|
+
for (const vec of vectors) {
|
|
101
|
+
new Float32Array(this.memory.buffer, offset, this.dimensions).set(vec);
|
|
102
|
+
offset += this.dimensions * 4;
|
|
103
|
+
}
|
|
104
|
+
this._vectorCount += vectors.length;
|
|
105
|
+
return startOffset;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Load raw vector bytes directly into the database region.
|
|
110
|
+
* Used for bulk loading from OPFS.
|
|
111
|
+
*/
|
|
112
|
+
loadVectorBytes(data: Uint8Array, vectorCount: number): void {
|
|
113
|
+
new Uint8Array(this.memory.buffer, this.dbOffset, data.byteLength).set(data);
|
|
114
|
+
this._vectorCount = vectorCount;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Read the scores buffer as a Float32Array view.
|
|
119
|
+
*/
|
|
120
|
+
readScores(): Float32Array {
|
|
121
|
+
return new Float32Array(this.memory.buffer, this.scoresOffset, this._vectorCount);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Read the DB region for a specific vector index.
|
|
126
|
+
*/
|
|
127
|
+
readVector(index: number): Float32Array {
|
|
128
|
+
const offset = this.dbOffset + index * this.dimensions * 4;
|
|
129
|
+
return new Float32Array(this.memory.buffer, offset, this.dimensions);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Write a vector to a specific slot in the database region.
|
|
134
|
+
*/
|
|
135
|
+
writeVector(index: number, vector: Float32Array): void {
|
|
136
|
+
const offset = this.dbOffset + index * this.dimensions * 4;
|
|
137
|
+
new Float32Array(this.memory.buffer, offset, this.dimensions).set(vector);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Reset the vector count to zero, logically clearing the database.
|
|
142
|
+
* WASM memory is not freed but will be overwritten on next writes.
|
|
143
|
+
*/
|
|
144
|
+
reset(): void {
|
|
145
|
+
this._vectorCount = 0;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LAZY RESULT SET
|
|
3
|
+
*
|
|
4
|
+
* Holds pointers to sorted TypedArrays. Prevents JS heap overflow when K is massive.
|
|
5
|
+
* Strings are only instantiated from the Lexicon when explicitly requested.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export interface ResultItem {
|
|
9
|
+
key: string;
|
|
10
|
+
score: number;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export type KeyResolver = (index: number) => string;
|
|
14
|
+
|
|
15
|
+
export class ResultSet {
|
|
16
|
+
/** Total number of results */
|
|
17
|
+
readonly length: number;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Sorted indices into the original database (by descending score).
|
|
21
|
+
* sortedIndices[0] is the index of the best match.
|
|
22
|
+
*/
|
|
23
|
+
private readonly sortedIndices: Uint32Array;
|
|
24
|
+
|
|
25
|
+
/** Raw scores array (not sorted, indexed by original DB position) */
|
|
26
|
+
private readonly scores: Float32Array;
|
|
27
|
+
|
|
28
|
+
/** Function to lazily resolve key from the slot index */
|
|
29
|
+
private readonly resolveKey: KeyResolver;
|
|
30
|
+
|
|
31
|
+
constructor(
|
|
32
|
+
scores: Float32Array,
|
|
33
|
+
sortedIndices: Uint32Array,
|
|
34
|
+
resolveKey: KeyResolver,
|
|
35
|
+
topK: number,
|
|
36
|
+
) {
|
|
37
|
+
this.scores = scores;
|
|
38
|
+
this.sortedIndices = sortedIndices;
|
|
39
|
+
this.resolveKey = resolveKey;
|
|
40
|
+
this.length = Math.min(topK, sortedIndices.length);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Sort scores and return a ResultSet with lazy key resolution.
|
|
45
|
+
*
|
|
46
|
+
* @param scores - Float32Array of scores (one per DB vector)
|
|
47
|
+
* @param resolveKey - Function to resolve key by original index
|
|
48
|
+
* @param topK - Maximum number of results to include
|
|
49
|
+
*/
|
|
50
|
+
static fromScores(
|
|
51
|
+
scores: Float32Array,
|
|
52
|
+
resolveKey: KeyResolver,
|
|
53
|
+
topK: number,
|
|
54
|
+
): ResultSet {
|
|
55
|
+
const n = scores.length;
|
|
56
|
+
|
|
57
|
+
// Create index array for sorting
|
|
58
|
+
const indices = new Uint32Array(n);
|
|
59
|
+
for (let i = 0; i < n; i++) indices[i] = i;
|
|
60
|
+
|
|
61
|
+
// Sort indices by descending score
|
|
62
|
+
indices.sort((a, b) => scores[b] - scores[a]);
|
|
63
|
+
|
|
64
|
+
return new ResultSet(scores, indices, resolveKey, topK);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Fetch a single result by its rank (0 is best match) */
|
|
68
|
+
get(rank: number): ResultItem {
|
|
69
|
+
if (rank < 0 || rank >= this.length) {
|
|
70
|
+
throw new RangeError(`Rank ${rank} out of bounds [0, ${this.length})`);
|
|
71
|
+
}
|
|
72
|
+
const dbIndex = this.sortedIndices[rank];
|
|
73
|
+
return {
|
|
74
|
+
key: this.resolveKey(dbIndex),
|
|
75
|
+
score: this.scores[dbIndex],
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Helper for UI pagination. Instantiates strings only for the requested page. */
|
|
80
|
+
getPage(page: number, pageSize: number): ResultItem[] {
|
|
81
|
+
const start = page * pageSize;
|
|
82
|
+
const end = Math.min(start + pageSize, this.length);
|
|
83
|
+
const results: ResultItem[] = [];
|
|
84
|
+
for (let i = start; i < end; i++) {
|
|
85
|
+
results.push(this.get(i));
|
|
86
|
+
}
|
|
87
|
+
return results;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
// AUTO-GENERATED - Do not edit. Run: npx tsx scripts/compile-wat.ts
|
|
2
|
+
const SIMD_WASM_BASE64 = "AGFzbQEAAAABDgJgAn9/AGAFf39/f38AAg8BA2VudgZtZW1vcnkCAAEDAwIAAQcaAglub3JtYWxpemUAAApzZWFyY2hfYWxsAAEKsgQCtQIFAX8BewN9AXsDf/0MAAAAAAAAAAAAAAAAAAAAACEDIAFBfHEhCEEAIQICQANAIAIgCE8NASAAIAJBAnRqIQogAyAK/QAEACAK/QAEAP3mAf3kASEDIAJBBGohAgwACwsgA/0fACAD/R8BkiAD/R8CIAP9HwOSkiEEIAghCQJAA0AgCSABTw0BIAAgCUECdGohCiAEIAoqAgAgCioCAJSSIQQgCUEBaiEJDAALCyAEkSEFIAVDAAAAAFsEQA8LQwAAgD8gBZUhBiAG/RMhB0EAIQICQANAIAIgCE8NASAAIAJBAnRqIQogCiAK/QAEACAH/eYB/QsEACACQQRqIQIMAAsLIAghCQJAA0AgCSABTw0BIAAgCUECdGohCiAKIAoqAgAgBpQ4AgAgCUEBaiEJDAALCwv4AQQCfwF7AX0GfyAEQXxxIQogBEECdCEOQQAhBQJAA0AgBSADTw0BIAEgBSAObGohCf0MAAAAAAAAAAAAAAAAAAAAACEHQQAhBgJAA0AgBiAKTw0BIAAgBkECdGohDCAJIAZBAnRqIQ0gByAM/QAEACAN/QAEAP3mAf3kASEHIAZBBGohBgwACwsgB/0fACAH/R8BkiAH/R8CIAf9HwOSkiEIIAohCwJAA0AgCyAETw0BIAAgC0ECdGohDCAJIAtBAnRqIQ0gCCAMKgIAIA0qAgCUkiEIIAtBAWohCwwACwsgAiAFQQJ0aiAIOAIAIAVBAWohBQwACwsL";
|
|
3
|
+
|
|
4
|
+
export function getSimdWasmBinary(): Uint8Array {
|
|
5
|
+
const binaryString = atob(SIMD_WASM_BASE64);
|
|
6
|
+
const bytes = new Uint8Array(binaryString.length);
|
|
7
|
+
for (let i = 0; i < binaryString.length; i++) {
|
|
8
|
+
bytes[i] = binaryString.charCodeAt(i);
|
|
9
|
+
}
|
|
10
|
+
return bytes;
|
|
11
|
+
}
|
package/src/lib/simd.wat
ADDED
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
(module
|
|
2
|
+
;; Import shared memory from JavaScript host
|
|
3
|
+
(import "env" "memory" (memory 1))
|
|
4
|
+
|
|
5
|
+
;; normalize(ptr: i32, dimensions: i32)
|
|
6
|
+
;; Normalizes a vector in-place to unit length using SIMD.
|
|
7
|
+
;; ptr: byte offset of the vector in memory
|
|
8
|
+
;; dimensions: number of f32 elements (must be a multiple of 4 for SIMD path)
|
|
9
|
+
(func (export "normalize") (param $ptr i32) (param $dim i32)
|
|
10
|
+
(local $i i32)
|
|
11
|
+
(local $sum_vec v128)
|
|
12
|
+
(local $sum f32)
|
|
13
|
+
(local $mag f32)
|
|
14
|
+
(local $inv_mag f32)
|
|
15
|
+
(local $inv_vec v128)
|
|
16
|
+
(local $simd_end i32)
|
|
17
|
+
(local $remainder i32)
|
|
18
|
+
(local $offset i32)
|
|
19
|
+
|
|
20
|
+
;; Phase 1: Compute sum of squares using SIMD (4 floats at a time)
|
|
21
|
+
(local.set $sum_vec (v128.const f32x4 0 0 0 0))
|
|
22
|
+
(local.set $simd_end (i32.and (local.get $dim) (i32.const -4))) ;; dim & ~3
|
|
23
|
+
(local.set $i (i32.const 0))
|
|
24
|
+
|
|
25
|
+
(block $break_sum
|
|
26
|
+
(loop $loop_sum
|
|
27
|
+
(br_if $break_sum (i32.ge_u (local.get $i) (local.get $simd_end)))
|
|
28
|
+
(local.set $offset (i32.add (local.get $ptr) (i32.shl (local.get $i) (i32.const 2))))
|
|
29
|
+
(local.set $sum_vec
|
|
30
|
+
(f32x4.add
|
|
31
|
+
(local.get $sum_vec)
|
|
32
|
+
(f32x4.mul
|
|
33
|
+
(v128.load (local.get $offset))
|
|
34
|
+
(v128.load (local.get $offset))
|
|
35
|
+
)
|
|
36
|
+
)
|
|
37
|
+
)
|
|
38
|
+
(local.set $i (i32.add (local.get $i) (i32.const 4)))
|
|
39
|
+
(br $loop_sum)
|
|
40
|
+
)
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
;; Horizontal sum of SIMD lanes
|
|
44
|
+
(local.set $sum
|
|
45
|
+
(f32.add
|
|
46
|
+
(f32.add
|
|
47
|
+
(f32x4.extract_lane 0 (local.get $sum_vec))
|
|
48
|
+
(f32x4.extract_lane 1 (local.get $sum_vec))
|
|
49
|
+
)
|
|
50
|
+
(f32.add
|
|
51
|
+
(f32x4.extract_lane 2 (local.get $sum_vec))
|
|
52
|
+
(f32x4.extract_lane 3 (local.get $sum_vec))
|
|
53
|
+
)
|
|
54
|
+
)
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
;; Handle remainder elements (dim % 4)
|
|
58
|
+
(local.set $remainder (local.get $simd_end))
|
|
59
|
+
(block $break_rem_sum
|
|
60
|
+
(loop $loop_rem_sum
|
|
61
|
+
(br_if $break_rem_sum (i32.ge_u (local.get $remainder) (local.get $dim)))
|
|
62
|
+
(local.set $offset (i32.add (local.get $ptr) (i32.shl (local.get $remainder) (i32.const 2))))
|
|
63
|
+
(local.set $sum
|
|
64
|
+
(f32.add
|
|
65
|
+
(local.get $sum)
|
|
66
|
+
(f32.mul
|
|
67
|
+
(f32.load (local.get $offset))
|
|
68
|
+
(f32.load (local.get $offset))
|
|
69
|
+
)
|
|
70
|
+
)
|
|
71
|
+
)
|
|
72
|
+
(local.set $remainder (i32.add (local.get $remainder) (i32.const 1)))
|
|
73
|
+
(br $loop_rem_sum)
|
|
74
|
+
)
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
;; Compute magnitude and check for zero
|
|
78
|
+
(local.set $mag (f32.sqrt (local.get $sum)))
|
|
79
|
+
(if (f32.eq (local.get $mag) (f32.const 0))
|
|
80
|
+
(then (return))
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
;; Phase 2: Divide each element by magnitude using SIMD
|
|
84
|
+
(local.set $inv_mag (f32.div (f32.const 1) (local.get $mag)))
|
|
85
|
+
(local.set $inv_vec (f32x4.splat (local.get $inv_mag)))
|
|
86
|
+
(local.set $i (i32.const 0))
|
|
87
|
+
|
|
88
|
+
(block $break_norm
|
|
89
|
+
(loop $loop_norm
|
|
90
|
+
(br_if $break_norm (i32.ge_u (local.get $i) (local.get $simd_end)))
|
|
91
|
+
(local.set $offset (i32.add (local.get $ptr) (i32.shl (local.get $i) (i32.const 2))))
|
|
92
|
+
(v128.store
|
|
93
|
+
(local.get $offset)
|
|
94
|
+
(f32x4.mul
|
|
95
|
+
(v128.load (local.get $offset))
|
|
96
|
+
(local.get $inv_vec)
|
|
97
|
+
)
|
|
98
|
+
)
|
|
99
|
+
(local.set $i (i32.add (local.get $i) (i32.const 4)))
|
|
100
|
+
(br $loop_norm)
|
|
101
|
+
)
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
;; Handle remainder elements
|
|
105
|
+
(local.set $remainder (local.get $simd_end))
|
|
106
|
+
(block $break_rem_norm
|
|
107
|
+
(loop $loop_rem_norm
|
|
108
|
+
(br_if $break_rem_norm (i32.ge_u (local.get $remainder) (local.get $dim)))
|
|
109
|
+
(local.set $offset (i32.add (local.get $ptr) (i32.shl (local.get $remainder) (i32.const 2))))
|
|
110
|
+
(f32.store
|
|
111
|
+
(local.get $offset)
|
|
112
|
+
(f32.mul
|
|
113
|
+
(f32.load (local.get $offset))
|
|
114
|
+
(local.get $inv_mag)
|
|
115
|
+
)
|
|
116
|
+
)
|
|
117
|
+
(local.set $remainder (i32.add (local.get $remainder) (i32.const 1)))
|
|
118
|
+
(br $loop_rem_norm)
|
|
119
|
+
)
|
|
120
|
+
)
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
;; search_all(query_ptr: i32, db_ptr: i32, scores_ptr: i32, db_size: i32, dimensions: i32)
|
|
124
|
+
;; Computes dot products of query against every vector in the database.
|
|
125
|
+
;; Uses 128-bit SIMD for 4-wide f32 multiply-accumulate.
|
|
126
|
+
(func (export "search_all") (param $query_ptr i32) (param $db_ptr i32) (param $scores_ptr i32) (param $db_size i32) (param $dim i32)
|
|
127
|
+
(local $i i32)
|
|
128
|
+
(local $j i32)
|
|
129
|
+
(local $acc v128)
|
|
130
|
+
(local $dot f32)
|
|
131
|
+
(local $vec_ptr i32)
|
|
132
|
+
(local $simd_end i32)
|
|
133
|
+
(local $remainder i32)
|
|
134
|
+
(local $q_offset i32)
|
|
135
|
+
(local $v_offset i32)
|
|
136
|
+
(local $bytes_per_vec i32)
|
|
137
|
+
|
|
138
|
+
(local.set $simd_end (i32.and (local.get $dim) (i32.const -4)))
|
|
139
|
+
(local.set $bytes_per_vec (i32.shl (local.get $dim) (i32.const 2)))
|
|
140
|
+
(local.set $i (i32.const 0))
|
|
141
|
+
|
|
142
|
+
(block $break_outer
|
|
143
|
+
(loop $loop_outer
|
|
144
|
+
(br_if $break_outer (i32.ge_u (local.get $i) (local.get $db_size)))
|
|
145
|
+
|
|
146
|
+
;; Pointer to the i-th database vector
|
|
147
|
+
(local.set $vec_ptr
|
|
148
|
+
(i32.add (local.get $db_ptr) (i32.mul (local.get $i) (local.get $bytes_per_vec)))
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
;; SIMD dot product accumulator
|
|
152
|
+
(local.set $acc (v128.const f32x4 0 0 0 0))
|
|
153
|
+
(local.set $j (i32.const 0))
|
|
154
|
+
|
|
155
|
+
(block $break_inner
|
|
156
|
+
(loop $loop_inner
|
|
157
|
+
(br_if $break_inner (i32.ge_u (local.get $j) (local.get $simd_end)))
|
|
158
|
+
(local.set $q_offset (i32.add (local.get $query_ptr) (i32.shl (local.get $j) (i32.const 2))))
|
|
159
|
+
(local.set $v_offset (i32.add (local.get $vec_ptr) (i32.shl (local.get $j) (i32.const 2))))
|
|
160
|
+
(local.set $acc
|
|
161
|
+
(f32x4.add
|
|
162
|
+
(local.get $acc)
|
|
163
|
+
(f32x4.mul
|
|
164
|
+
(v128.load (local.get $q_offset))
|
|
165
|
+
(v128.load (local.get $v_offset))
|
|
166
|
+
)
|
|
167
|
+
)
|
|
168
|
+
)
|
|
169
|
+
(local.set $j (i32.add (local.get $j) (i32.const 4)))
|
|
170
|
+
(br $loop_inner)
|
|
171
|
+
)
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
;; Horizontal sum of SIMD accumulator
|
|
175
|
+
(local.set $dot
|
|
176
|
+
(f32.add
|
|
177
|
+
(f32.add
|
|
178
|
+
(f32x4.extract_lane 0 (local.get $acc))
|
|
179
|
+
(f32x4.extract_lane 1 (local.get $acc))
|
|
180
|
+
)
|
|
181
|
+
(f32.add
|
|
182
|
+
(f32x4.extract_lane 2 (local.get $acc))
|
|
183
|
+
(f32x4.extract_lane 3 (local.get $acc))
|
|
184
|
+
)
|
|
185
|
+
)
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
;; Handle remainder elements (dim % 4)
|
|
189
|
+
(local.set $remainder (local.get $simd_end))
|
|
190
|
+
(block $break_rem
|
|
191
|
+
(loop $loop_rem
|
|
192
|
+
(br_if $break_rem (i32.ge_u (local.get $remainder) (local.get $dim)))
|
|
193
|
+
(local.set $q_offset (i32.add (local.get $query_ptr) (i32.shl (local.get $remainder) (i32.const 2))))
|
|
194
|
+
(local.set $v_offset (i32.add (local.get $vec_ptr) (i32.shl (local.get $remainder) (i32.const 2))))
|
|
195
|
+
(local.set $dot
|
|
196
|
+
(f32.add
|
|
197
|
+
(local.get $dot)
|
|
198
|
+
(f32.mul
|
|
199
|
+
(f32.load (local.get $q_offset))
|
|
200
|
+
(f32.load (local.get $v_offset))
|
|
201
|
+
)
|
|
202
|
+
)
|
|
203
|
+
)
|
|
204
|
+
(local.set $remainder (i32.add (local.get $remainder) (i32.const 1)))
|
|
205
|
+
(br $loop_rem)
|
|
206
|
+
)
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
;; Store score for vector i
|
|
210
|
+
(f32.store
|
|
211
|
+
(i32.add (local.get $scores_ptr) (i32.shl (local.get $i) (i32.const 2)))
|
|
212
|
+
(local.get $dot)
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
(local.set $i (i32.add (local.get $i) (i32.const 1)))
|
|
216
|
+
(br $loop_outer)
|
|
217
|
+
)
|
|
218
|
+
)
|
|
219
|
+
)
|
|
220
|
+
)
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Storage abstraction for append-only binary files.
|
|
3
|
+
* Supports OPFS for browser and in-memory for testing.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export interface StorageProvider {
|
|
7
|
+
/** Read the entire contents of a file. Returns empty Uint8Array if file doesn't exist. */
|
|
8
|
+
readAll(fileName: string): Promise<Uint8Array>;
|
|
9
|
+
|
|
10
|
+
/** Append data to a file (creates if it doesn't exist). */
|
|
11
|
+
append(fileName: string, data: Uint8Array): Promise<void>;
|
|
12
|
+
|
|
13
|
+
/** Write data to a file, replacing all existing content. */
|
|
14
|
+
write(fileName: string, data: Uint8Array): Promise<void>;
|
|
15
|
+
|
|
16
|
+
/** Delete the storage directory and all files. */
|
|
17
|
+
destroy(): Promise<void>;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* OPFS-backed storage provider for browser environments.
|
|
22
|
+
* Uses Origin Private File System for high-performance persistent storage.
|
|
23
|
+
*/
|
|
24
|
+
export class OPFSStorageProvider implements StorageProvider {
|
|
25
|
+
private dirHandle: FileSystemDirectoryHandle | null = null;
|
|
26
|
+
private dirName: string;
|
|
27
|
+
|
|
28
|
+
constructor(dirName: string) {
|
|
29
|
+
this.dirName = dirName;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
private async getDir(): Promise<FileSystemDirectoryHandle> {
|
|
33
|
+
if (!this.dirHandle) {
|
|
34
|
+
const root = await navigator.storage.getDirectory();
|
|
35
|
+
this.dirHandle = await root.getDirectoryHandle(this.dirName, { create: true });
|
|
36
|
+
}
|
|
37
|
+
return this.dirHandle;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async readAll(fileName: string): Promise<Uint8Array> {
|
|
41
|
+
try {
|
|
42
|
+
const dir = await this.getDir();
|
|
43
|
+
const fileHandle = await dir.getFileHandle(fileName);
|
|
44
|
+
const file = await fileHandle.getFile();
|
|
45
|
+
const buffer = await file.arrayBuffer();
|
|
46
|
+
return new Uint8Array(buffer);
|
|
47
|
+
} catch {
|
|
48
|
+
return new Uint8Array(0);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async append(fileName: string, data: Uint8Array): Promise<void> {
|
|
53
|
+
const dir = await this.getDir();
|
|
54
|
+
const fileHandle = await dir.getFileHandle(fileName, { create: true });
|
|
55
|
+
const writable = await fileHandle.createWritable({ keepExistingData: true });
|
|
56
|
+
const file = await fileHandle.getFile();
|
|
57
|
+
await writable.seek(file.size);
|
|
58
|
+
await writable.write(data as unknown as BufferSource);
|
|
59
|
+
await writable.close();
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async write(fileName: string, data: Uint8Array): Promise<void> {
|
|
63
|
+
const dir = await this.getDir();
|
|
64
|
+
const fileHandle = await dir.getFileHandle(fileName, { create: true });
|
|
65
|
+
const writable = await fileHandle.createWritable({ keepExistingData: false });
|
|
66
|
+
await writable.write(data as unknown as BufferSource);
|
|
67
|
+
await writable.close();
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async destroy(): Promise<void> {
|
|
71
|
+
const root = await navigator.storage.getDirectory();
|
|
72
|
+
await root.removeEntry(this.dirName, { recursive: true });
|
|
73
|
+
this.dirHandle = null;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* In-memory storage provider for testing.
|
|
79
|
+
*/
|
|
80
|
+
export class InMemoryStorageProvider implements StorageProvider {
|
|
81
|
+
private files = new Map<string, Uint8Array[]>();
|
|
82
|
+
|
|
83
|
+
async readAll(fileName: string): Promise<Uint8Array> {
|
|
84
|
+
const chunks = this.files.get(fileName);
|
|
85
|
+
if (!chunks || chunks.length === 0) return new Uint8Array(0);
|
|
86
|
+
|
|
87
|
+
const totalSize = chunks.reduce((sum, c) => sum + c.byteLength, 0);
|
|
88
|
+
const result = new Uint8Array(totalSize);
|
|
89
|
+
let offset = 0;
|
|
90
|
+
for (const chunk of chunks) {
|
|
91
|
+
result.set(chunk, offset);
|
|
92
|
+
offset += chunk.byteLength;
|
|
93
|
+
}
|
|
94
|
+
return result;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async append(fileName: string, data: Uint8Array): Promise<void> {
|
|
98
|
+
if (!this.files.has(fileName)) {
|
|
99
|
+
this.files.set(fileName, []);
|
|
100
|
+
}
|
|
101
|
+
this.files.get(fileName)!.push(new Uint8Array(data));
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
async write(fileName: string, data: Uint8Array): Promise<void> {
|
|
105
|
+
this.files.set(fileName, [new Uint8Array(data)]);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async destroy(): Promise<void> {
|
|
109
|
+
this.files.clear();
|
|
110
|
+
}
|
|
111
|
+
}
|