vectrojs 0.0.1 → 0.1.1
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/CHANGELOG.md +89 -0
- package/README.md +828 -4
- package/bin/vectro.mjs +82 -0
- package/dist/vectro.wasm +0 -0
- package/mcp/server.mjs +288 -0
- package/package.json +84 -6
- package/src/arrow.mjs +905 -0
- package/src/bitpack.mjs +135 -0
- package/src/browser.mjs +129 -0
- package/src/bytes.mjs +60 -0
- package/src/csv-kernel.mjs +205 -0
- package/src/csv.mjs +270 -0
- package/src/dataset.mjs +1763 -0
- package/src/dates.mjs +139 -0
- package/src/emitter.mjs +109 -0
- package/src/engine.mjs +881 -0
- package/src/files-browser.mjs +104 -0
- package/src/files-node.mjs +59 -0
- package/src/frames.mjs +191 -0
- package/src/fuse.mjs +512 -0
- package/src/interp.mjs +305 -0
- package/src/join.mjs +534 -0
- package/src/kernels.mjs +312 -0
- package/src/ndjson.mjs +136 -0
- package/src/node.mjs +120 -0
- package/src/pack.mjs +114 -0
- package/src/parallel-sort.mjs +99 -0
- package/src/partition-kernels.mjs +388 -0
- package/src/partition.mjs +395 -0
- package/src/plan.mjs +551 -0
- package/src/portable.mjs +21 -0
- package/src/rank-kernels.mjs +81 -0
- package/src/rows.mjs +91 -0
- package/src/serve/catalog.mjs +148 -0
- package/src/serve/flight.mjs +270 -0
- package/src/serve/formats.mjs +87 -0
- package/src/serve/http.mjs +182 -0
- package/src/serve/index.mjs +9 -0
- package/src/serve/odata.mjs +291 -0
- package/src/serve/pg.mjs +588 -0
- package/src/serve/pgcatalog.mjs +220 -0
- package/src/serve/pgtypes.mjs +199 -0
- package/src/sketch.mjs +75 -0
- package/src/sort-kernels.mjs +117 -0
- package/src/sort.mjs +102 -0
- package/src/split-worker-core.mjs +252 -0
- package/src/split-worker-node.mjs +8 -0
- package/src/split-worker.mjs +7 -0
- package/src/split.mjs +741 -0
- package/src/sql.mjs +1558 -0
- package/src/sqltext.mjs +132 -0
- package/src/static-kernels.mjs +164 -0
- package/src/stream-worker.mjs +553 -0
- package/src/table-kernels.mjs +323 -0
- package/src/table.mjs +2247 -0
- package/src/textmap.mjs +159 -0
- package/src/vectors.mjs +138 -0
- package/src/wasm.mjs +254 -0
- package/src/window.mjs +160 -0
- package/src/worker-browser.mjs +21 -0
- package/src/worker-loop.mjs +106 -0
- package/src/worker.mjs +22 -0
- package/src/zones.mjs +75 -0
package/src/join.mjs
ADDED
|
@@ -0,0 +1,534 @@
|
|
|
1
|
+
// Hash join: left.join(right, { on, how }). The right table is the build
|
|
2
|
+
// side (put the smaller table there), the left one is probed.
|
|
3
|
+
//
|
|
4
|
+
// 1. Number the right table's distinct keys in one shared hash table, on
|
|
5
|
+
// every thread (the group-by kernel), and keep the table.
|
|
6
|
+
// 2. Look up every left row's key in it, on every thread (no inserts).
|
|
7
|
+
// 3. List the right rows of each key side by side (a counting sort), and
|
|
8
|
+
// make the pairs (left row, right row): left rows in order, each with
|
|
9
|
+
// its matches in right-table order.
|
|
10
|
+
// 4. Gather every column in pair order, on every thread.
|
|
11
|
+
//
|
|
12
|
+
// Text keys match by label (the two tables have their own dictionaries).
|
|
13
|
+
// A key with a missing part (NaN) never matches, as NULL in SQL. With
|
|
14
|
+
// how: 'left', left rows without a match are kept once, with NaN (numbers)
|
|
15
|
+
// or '' (text) in the right table's columns.
|
|
16
|
+
|
|
17
|
+
import { generateHashGroups, generateHashProbe } from './table-kernels.mjs';
|
|
18
|
+
import { gatherG } from './parallel-sort.mjs';
|
|
19
|
+
import { maxUnits } from './kernels.mjs';
|
|
20
|
+
import { I32, F64, Fn, buildModule, get, set, i32c, block, loop, end, br, brIf, i32add, i32geu } from './wasm.mjs';
|
|
21
|
+
|
|
22
|
+
function kernel(engine, name, make) {
|
|
23
|
+
engine._tableKernels ??= new Map();
|
|
24
|
+
let v = engine._tableKernels.get(name);
|
|
25
|
+
if (!v) { v = engine._register(make()); engine._tableKernels.set(name, v); }
|
|
26
|
+
return v;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function parseOn(on) {
|
|
30
|
+
if (typeof on === 'string' || Array.isArray(on)) { const k = [on].flat(); return [k, k]; }
|
|
31
|
+
if (on && on.left != null && on.right != null) return [[on.left].flat(), [on.right].flat()];
|
|
32
|
+
throw new Error("vectro: join needs on: 'key', ['key1', 'key2'] or { left: 'id', right: 'customer_id' }");
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// L, R: tables without filters. makeTable(cols, n) builds the result.
|
|
36
|
+
export function* joinTablesG(L, R, opts = {}, makeTable) {
|
|
37
|
+
const { on, how = 'inner', suffix = '_right' } = opts;
|
|
38
|
+
const engine = L.engine;
|
|
39
|
+
if (R.engine !== engine) throw new Error('vectro: join needs two tables of the same engine');
|
|
40
|
+
if (how !== 'inner' && how !== 'left') throw new Error(`vectro: join how must be 'inner' or 'left', got "${how}"`);
|
|
41
|
+
if (L.type !== R.type) throw new Error('vectro: join needs two tables of the same number type (both float64 or both float32)');
|
|
42
|
+
const [lk, rk] = parseOn(on);
|
|
43
|
+
if (!lk.length || lk.length !== rk.length) throw new Error('vectro: join needs as many left keys as right keys');
|
|
44
|
+
const t = L.type === Float32Array ? 'f32' : 'f64';
|
|
45
|
+
const nk = lk.length, nL = L.n, nR = R.n;
|
|
46
|
+
const made = []; // engine arrays to free at the end
|
|
47
|
+
const alloc = (Type, n) => { const a = engine.alloc(Type, n); made.push(a); return a; };
|
|
48
|
+
// While a dataset runs the same join chunk after chunk, the right table's
|
|
49
|
+
// side (hash table, rows per key) is built once and kept in
|
|
50
|
+
// engine._joinBuilds (freed when the run ends).
|
|
51
|
+
const cache = engine._joinBuilds ?? null;
|
|
52
|
+
const cacheKey = `${how}|${rk.join(',')}|${lk.map((n) => dictId(L.cols.get(n)?.dict)).join(',')}`;
|
|
53
|
+
const target = R.cols.get(rk[0])?.data ?? R; // (views of one table share its arrays: one build for all of them)
|
|
54
|
+
let build = cache?.get(target)?.get(`${cacheKey}|${rk.map((n) => arrayId(R.cols.get(n)?.data)).join(',')}`) ?? null, ownBuild = false;
|
|
55
|
+
try {
|
|
56
|
+
const leftKeys = [];
|
|
57
|
+
for (let k = 0; k < nk; k++) {
|
|
58
|
+
const a = L.cols.get(lk[k]), b = R.cols.get(rk[k]);
|
|
59
|
+
if (!a) throw new Error(`vectro: join: the left table has no column "${lk[k]}"`);
|
|
60
|
+
if (!b) throw new Error(`vectro: join: the right table has no column "${rk[k]}"`);
|
|
61
|
+
if (a.strings || b.strings) throw new Error(`vectro: join: can't join on free-text column "${a.strings ? lk[k] : rk[k]}" (load it as a category)`);
|
|
62
|
+
if (!a.dict !== !b.dict && nL && nR) throw new Error(`vectro: join: "${lk[k]}" and "${rk[k]}" must both be text or both numbers`);
|
|
63
|
+
leftKeys.push(a.data);
|
|
64
|
+
}
|
|
65
|
+
if (!build) {
|
|
66
|
+
build = yield* buildRight(engine, t, L, R, lk, rk, how);
|
|
67
|
+
if (cache) { if (!cache.has(target)) cache.set(target, new Map()); cache.get(target).set(`${cacheKey}|${rk.map((n) => arrayId(R.cols.get(n)?.data)).join(',')}`, build); } else ownBuild = true;
|
|
68
|
+
}
|
|
69
|
+
const { K, start, rows } = build;
|
|
70
|
+
|
|
71
|
+
// 2. each left row's group number, or -1
|
|
72
|
+
const lid = alloc(Int32Array, nL);
|
|
73
|
+
const hit = opts.unmatched ? newHits(engine, build, alloc) : null;
|
|
74
|
+
let marked = false;
|
|
75
|
+
if (engine.jit) marked = yield* probeG(engine, t, build, leftKeys, lid, -1, hit);
|
|
76
|
+
else probeJS(engine, build, leftKeys, lid);
|
|
77
|
+
if (hit) opts.unmatched.result = yield* unmatchedRightG(engine, R, build, hit, marked ? null : lid, nL, t);
|
|
78
|
+
|
|
79
|
+
// 3. the pairs (left rows in order, each with its matches in right-table order)
|
|
80
|
+
const left = how === 'left';
|
|
81
|
+
const total = countPairs(engine.live(lid), nL, start, build.rowOf, left);
|
|
82
|
+
const li = alloc(Uint32Array, total), ri = alloc(Uint32Array, total);
|
|
83
|
+
makePairs(engine.live(lid), nL, nR, start, rows, build.rowOf, left, engine.live(li), engine.live(ri));
|
|
84
|
+
|
|
85
|
+
// 4. gather; right columns that share the left keys' names are left out
|
|
86
|
+
const gather = function* (src, idx) {
|
|
87
|
+
if (engine.jit) return yield* gatherG(engine, src, idx, total, t);
|
|
88
|
+
const dst = engine.alloc(L.type, total), a = engine.live(src), ix = engine.live(idx);
|
|
89
|
+
for (let i = 0; i < total; i++) dst[i] = a[ix[i]];
|
|
90
|
+
return dst;
|
|
91
|
+
};
|
|
92
|
+
const out = new Map();
|
|
93
|
+
for (const [name, c] of L.cols) out.set(name, { ...c, data: yield* gather(c.data, li), zones: undefined });
|
|
94
|
+
const sameKey = new Set(rk.filter((name, k) => lk[k] === name));
|
|
95
|
+
for (const [name, c] of R.cols) {
|
|
96
|
+
if (sameKey.has(name)) continue;
|
|
97
|
+
let src = c.data, dict = c.dict, codes = c.codes;
|
|
98
|
+
if (how === 'left') { // one more row at the end: the value for "no match"
|
|
99
|
+
let x = build.extended.get(`${name}|${arrayId(c.data)}`);
|
|
100
|
+
if (!x) {
|
|
101
|
+
const ext = engine.alloc(R.type, nR + 1);
|
|
102
|
+
build.arrays.push(ext);
|
|
103
|
+
const s = engine.live(ext);
|
|
104
|
+
s.set(engine.live(c.data).subarray(0, nR));
|
|
105
|
+
if (dict) {
|
|
106
|
+
let e = codes.get('');
|
|
107
|
+
if (e === undefined) { dict = [...dict, '']; codes = new Map(codes).set('', dict.length - 1); e = dict.length - 1; }
|
|
108
|
+
s[nR] = e;
|
|
109
|
+
} else s[nR] = NaN;
|
|
110
|
+
build.extended.set(`${name}|${arrayId(c.data)}`, (x = { data: ext, dict, codes }));
|
|
111
|
+
}
|
|
112
|
+
({ data: src, dict, codes } = x);
|
|
113
|
+
}
|
|
114
|
+
let outName = name;
|
|
115
|
+
while (out.has(outName)) outName += suffix;
|
|
116
|
+
out.set(outName, { data: yield* gather(src, ri), dict, codes, strings: c.strings, date: c.date, zones: undefined });
|
|
117
|
+
}
|
|
118
|
+
for (const c of out.values()) c.data = engine.live(c.data);
|
|
119
|
+
return makeTable(out, total);
|
|
120
|
+
} finally {
|
|
121
|
+
for (const a of made) engine.free(a);
|
|
122
|
+
if (ownBuild) freeBuild(engine, build);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Unique right keys (each key one right row, e.g. an ID column), as a view:
|
|
127
|
+
// an inner join keeps the left rows that have a match and a left join keeps
|
|
128
|
+
// them all, so instead of pairing rows, the right columns are looked up for
|
|
129
|
+
// every left row (in group-number order, "no match" last), and the left
|
|
130
|
+
// columns stay as they are (packed ones too). An inner join's "has a match"
|
|
131
|
+
// becomes the result's filter (column __vectro_found). Used chunk after
|
|
132
|
+
// chunk by datasets (opts.view). Returns null when the right keys repeat.
|
|
133
|
+
// collectLeft(): the left table's matching rows (a generator)
|
|
134
|
+
// makeView(cols, n, pred, left): the result, which takes over `left`'s columns
|
|
135
|
+
export const FOUND = '__vectro_found';
|
|
136
|
+
export function* joinViewG(L, R, opts = {}, collectLeft, makeView) {
|
|
137
|
+
const { on, how = 'inner', suffix = '_right' } = opts;
|
|
138
|
+
const engine = L.engine;
|
|
139
|
+
if (R.engine !== engine || L.type !== R.type || (how !== 'inner' && how !== 'left')) return null;
|
|
140
|
+
const [lk, rk] = parseOn(on);
|
|
141
|
+
if (!lk.length || lk.length !== rk.length) return null;
|
|
142
|
+
for (let k = 0; k < lk.length; k++) {
|
|
143
|
+
const a = L.cols.get(lk[k]), b = R.cols.get(rk[k]);
|
|
144
|
+
if (!a || !b || a.strings || b.strings || !a.dict !== !b.dict) return null; // (the normal path reports it)
|
|
145
|
+
}
|
|
146
|
+
const t = L.type === Float32Array ? 'f32' : 'f64';
|
|
147
|
+
const cache = engine._joinBuilds ?? null;
|
|
148
|
+
const cacheKey = `${how}|${rk.join(',')}|${lk.map((n) => dictId(L.cols.get(n)?.dict)).join(',')}`;
|
|
149
|
+
const target = R.cols.get(rk[0])?.data ?? R; // (views of one table share its arrays: one build for all of them)
|
|
150
|
+
let build = cache?.get(target)?.get(`${cacheKey}|${rk.map((n) => arrayId(R.cols.get(n)?.data)).join(',')}`) ?? null, ownBuild = false;
|
|
151
|
+
const made = [];
|
|
152
|
+
const alloc = (Type, n) => { const a = engine.alloc(Type, n); made.push(a); return a; };
|
|
153
|
+
try {
|
|
154
|
+
if (!build) {
|
|
155
|
+
build = yield* buildRight(engine, t, L, R, lk, rk, how);
|
|
156
|
+
if (cache) { if (!cache.has(target)) cache.set(target, new Map()); cache.get(target).set(`${cacheKey}|${rk.map((n) => arrayId(R.cols.get(n)?.data)).join(',')}`, build); } else ownBuild = true;
|
|
157
|
+
}
|
|
158
|
+
if (!build.rowOf || !engine.jit) return null;
|
|
159
|
+
const left = yield* collectLeft();
|
|
160
|
+
const n = left.n, K = build.K;
|
|
161
|
+
// left keys as plain numbers for the probe (packed keys decoded)
|
|
162
|
+
const keys = [];
|
|
163
|
+
for (const name of lk) {
|
|
164
|
+
const c = left.cols.get(name);
|
|
165
|
+
if (!c.enc) { keys.push(c.data); continue; }
|
|
166
|
+
const plain = alloc(L.type, n);
|
|
167
|
+
if (n) yield* engine.evalG('x', { x: { data: c.data, enc: c.enc, n, type: t } }, plain);
|
|
168
|
+
keys.push(plain);
|
|
169
|
+
}
|
|
170
|
+
const idx = alloc(Uint32Array, n);
|
|
171
|
+
const hit = opts.unmatched ? newHits(engine, build, alloc) : null;
|
|
172
|
+
let marked = false;
|
|
173
|
+
if (build.lut) { if (n) marked = yield* probeG(engine, t, build, keys, idx, K, hit); } // (misses go straight to slot K)
|
|
174
|
+
else {
|
|
175
|
+
const lid = alloc(Int32Array, n);
|
|
176
|
+
if (n) yield* probeG(engine, t, build, keys, lid);
|
|
177
|
+
noMatchAt(engine.live(lid), engine.live(idx), n, K);
|
|
178
|
+
}
|
|
179
|
+
if (hit) opts.unmatched.result = yield* unmatchedRightG(engine, R, build, hit, marked ? null : idx, n, t);
|
|
180
|
+
// right columns in group-number order (made once per build), then looked up
|
|
181
|
+
const byGroup = (name, c) => {
|
|
182
|
+
let x = build.byGroup?.get(name);
|
|
183
|
+
if (x) return x;
|
|
184
|
+
build.byGroup ??= new Map();
|
|
185
|
+
const data = engine.alloc(R.type, K + 1);
|
|
186
|
+
build.arrays.push(data);
|
|
187
|
+
let dict = c?.dict ?? null, codes = c?.codes ?? null, missing = NaN;
|
|
188
|
+
if (dict && how === 'left') { // "no match" reads as ''
|
|
189
|
+
missing = codes.get('');
|
|
190
|
+
if (missing === undefined) { dict = [...dict, '']; codes = new Map(codes).set('', dict.length - 1); missing = dict.length - 1; }
|
|
191
|
+
}
|
|
192
|
+
const d = engine.live(data), src = c ? engine.live(c.data) : null, rowOf = build.rowOf;
|
|
193
|
+
for (let g = 0; g < K; g++) { const r = rowOf[g]; d[g] = r < 0 ? missing : src ? src[r] : 1; }
|
|
194
|
+
d[K] = missing;
|
|
195
|
+
build.byGroup.set(name, (x = { data, dict, codes, date: c?.date, strings: c?.strings ?? null })); // (free text: its places in the right table's text, as the other join path keeps them)
|
|
196
|
+
return x;
|
|
197
|
+
};
|
|
198
|
+
const cols = new Map(left.cols);
|
|
199
|
+
const sameKey = new Set(rk.filter((name, k) => lk[k] === name));
|
|
200
|
+
const looked = [];
|
|
201
|
+
for (const [name, c] of R.cols) {
|
|
202
|
+
if (sameKey.has(name)) continue;
|
|
203
|
+
let outName = name;
|
|
204
|
+
while (cols.has(outName)) outName += suffix;
|
|
205
|
+
const x = byGroup(`c:${name}|${arrayId(c.data)}`, c);
|
|
206
|
+
cols.set(outName, { data: null, dict: x.dict, codes: x.codes, date: x.date, strings: x.strings });
|
|
207
|
+
looked.push([outName, x]);
|
|
208
|
+
}
|
|
209
|
+
if (how === 'inner') looked.push([FOUND, byGroup('found', null)]), cols.set(FOUND, { data: null, dict: null, codes: null });
|
|
210
|
+
for (const [outName, x] of looked) cols.get(outName).data = yield* gatherG(engine, x.data, idx, n, t);
|
|
211
|
+
for (const c of cols.values()) c.data = engine.live(c.data);
|
|
212
|
+
return makeView(cols, n, how === 'inner' ? `${FOUND} == 1` : null, left, looked.map(([nm]) => nm));
|
|
213
|
+
} finally {
|
|
214
|
+
for (const a of made) engine.free(a);
|
|
215
|
+
if (ownBuild) freeBuild(engine, build);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// (RIGHT / FULL JOIN) which right rows some left row matched: hit[g + 1]
|
|
220
|
+
// is 1 once a probe found group g (hit[0] and hit[K + 1] take the misses,
|
|
221
|
+
// -1 or K). The dense probe marks them as it goes; otherwise one pass over
|
|
222
|
+
// the probe's group numbers does.
|
|
223
|
+
function newHits(engine, build, alloc) { const h = alloc(Int32Array, build.K + 2); engine.live(h).fill(0); return h; }
|
|
224
|
+
|
|
225
|
+
// The right rows no left row matched, in right-table order, with all of R's
|
|
226
|
+
// columns: { cols, n }. ids: the probe's group numbers (n of them) when
|
|
227
|
+
// the probe didn't mark hit itself.
|
|
228
|
+
function* unmatchedRightG(engine, R, build, hit, ids, n, t) {
|
|
229
|
+
const nR = R.n;
|
|
230
|
+
let rows = null;
|
|
231
|
+
try {
|
|
232
|
+
if (ids && n) {
|
|
233
|
+
if (engine.jit) {
|
|
234
|
+
const k = kernel(engine, 'markhits', () => markHitsModule(engine.isShared));
|
|
235
|
+
yield* engine._unitsG(k, n, [engine.live(ids).byteOffset, engine.live(hit).byteOffset + 4]);
|
|
236
|
+
} else { const g = engine.live(ids), h = engine.live(hit); for (let i = 0; i < n; i++) h[g[i] + 1] = 1; }
|
|
237
|
+
}
|
|
238
|
+
let h = engine.live(hit), rid = engine.live(build.rid);
|
|
239
|
+
let u = 0;
|
|
240
|
+
for (let r = 0; r < nR; r++) u += 1 - h[rid[r] + 1];
|
|
241
|
+
rows = engine.alloc(Uint32Array, u);
|
|
242
|
+
const rv = engine.live(rows);
|
|
243
|
+
h = engine.live(hit); rid = engine.live(build.rid); // (the allocation may have moved memory)
|
|
244
|
+
if (u) for (let r = 0, o = 0; r < nR; r++) if (!h[rid[r] + 1]) rv[o++] = r;
|
|
245
|
+
const cols = new Map();
|
|
246
|
+
for (const [name, c] of R.cols) {
|
|
247
|
+
let data;
|
|
248
|
+
if (engine.jit && u) data = yield* gatherG(engine, c.data, rows, u, t);
|
|
249
|
+
else { data = engine.alloc(R.type, u); const a = engine.live(c.data), d = engine.live(data); for (let i = 0; i < u; i++) d[i] = a[rv[i]]; }
|
|
250
|
+
cols.set(name, { data: engine.live(data), dict: c.dict, codes: c.codes, strings: c.strings, date: c.date });
|
|
251
|
+
}
|
|
252
|
+
return { cols, n: u };
|
|
253
|
+
} finally {
|
|
254
|
+
if (rows) engine.free(rows);
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// mark(start, end, unit, ids, hit): hit[ids[i]] = 1 (int32s; threads
|
|
259
|
+
// racing on one slot all store 1)
|
|
260
|
+
function markHitsModule(shared) {
|
|
261
|
+
const f = new Fn('k', [I32, I32, I32, I32, I32], []);
|
|
262
|
+
const [start, stop, , ids, hit] = [0, 1, 2, 3, 4];
|
|
263
|
+
const i = f.local(I32), at = f.local(I32);
|
|
264
|
+
f.emit(get(start), set(i));
|
|
265
|
+
f.emit(block, loop, get(i), get(stop), i32geu, brIf(1));
|
|
266
|
+
f.emit(get(i), i32c(2), [0x74], get(ids), i32add, [0x28, 2, 0], i32c(2), [0x74], get(hit), i32add, set(at)); // &hit[ids[i]]
|
|
267
|
+
f.emit(get(at), [0x28, 2, 0], [0x45], [0x04, 0x40], get(at), i32c(1), [0x36, 2, 0], end); // (stored only while still 0: the slot's cache line stays shared)
|
|
268
|
+
f.emit(get(i), i32c(1), i32add, set(i), br(0), end, end);
|
|
269
|
+
return buildModule([f], shared);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// GROUP BY right columns over an inner join on unique right keys (see
|
|
273
|
+
// Table._lookupGroupAgg): instead of joined rows, one int32 lookup array
|
|
274
|
+
// from the left key straight to a group number, so the group kernel adds
|
|
275
|
+
// each matching left row into its group as it reads the key. Groups are the
|
|
276
|
+
// distinct tuples of the right columns `rightKeys` (numbers ascending, NaN
|
|
277
|
+
// last; text in dictionary order), numbered in that order. Made once per
|
|
278
|
+
// right table and kept with its hash table while a dataset runs (freed
|
|
279
|
+
// with it). Null when the right keys repeat or aren't whole numbers in a
|
|
280
|
+
// small range (no direct lookup array): the usual join path then runs.
|
|
281
|
+
// -> { lut, min, size, G, values: [Float64Array(G)] per key, meta: [{ dict, codes, date }] }
|
|
282
|
+
export function* lookupGroupsG(L, R, on, rightKeys) {
|
|
283
|
+
const engine = L.engine;
|
|
284
|
+
if (!engine.jit || R.engine !== engine || L.type !== R.type) return null;
|
|
285
|
+
const a = L.cols.get(on.left), b = R.cols.get(on.right);
|
|
286
|
+
if (!a || !b || a.strings || b.strings || a.dict || b.dict) return null; // (number keys; text keys: the usual path)
|
|
287
|
+
const cols = rightKeys.map((n) => R.cols.get(n));
|
|
288
|
+
if (cols.some((c) => !c || c.strings)) return null;
|
|
289
|
+
const cache = engine._joinBuilds ?? null;
|
|
290
|
+
if (!cache) return null; // (one-off joins: the usual path)
|
|
291
|
+
const sig = `lookup|${rightKeys.map((n) => arrayId(R.cols.get(n).data)).join(',')}`;
|
|
292
|
+
let x = cache.get(b.data)?.get(sig);
|
|
293
|
+
if (x !== undefined) return x;
|
|
294
|
+
engine._joinBuildCount = (engine._joinBuildCount ?? 0) + 1; // (tests: once per run, as the hash build)
|
|
295
|
+
x = directLookup(engine, R, b, cols);
|
|
296
|
+
if (!cache.has(b.data)) cache.set(b.data, new Map());
|
|
297
|
+
cache.get(b.data).set(sig, x ?? null); // (null: tried, doesn't apply; freed with the builds)
|
|
298
|
+
return x;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// One pass over the right rows: keys must be whole numbers in a small range
|
|
302
|
+
// and unique (a repeat: null), group tuples numbered in sorted order.
|
|
303
|
+
function directLookup(engine, R, b, cols) {
|
|
304
|
+
const nR = R.n, keys = engine.live(b.data);
|
|
305
|
+
const kr = keyRange(keys, nR);
|
|
306
|
+
if (!kr) return null;
|
|
307
|
+
const [lo, hi] = kr, size = hi - lo + 1;
|
|
308
|
+
if (!(size <= Math.max(4 * nR, 1 << 16) && size <= 2 ** 26)) return null;
|
|
309
|
+
const nk = cols.length, src = cols.map((c) => engine.live(c.data));
|
|
310
|
+
let lut;
|
|
311
|
+
try { lut = engine.alloc(Int32Array, size); } catch (err) { if (err instanceof RangeError) return null; throw err; } // (no room: the usual join path)
|
|
312
|
+
const T = engine.live(lut);
|
|
313
|
+
T.fill(-1);
|
|
314
|
+
let G, values, ok;
|
|
315
|
+
const one = nk === 1 ? wholeRange(src[0], nR) : null;
|
|
316
|
+
if (one && one[1] - one[0] + 1 <= Math.max(nR, 1 << 16)) { // one whole-number column in a small range: its own group number
|
|
317
|
+
G = one[1] - one[0] + 1;
|
|
318
|
+
values = [Float64Array.from({ length: G }, (_, g) => one[0] + g)];
|
|
319
|
+
ok = fillDirect(T, keys, src[0], nR, lo, one[0]);
|
|
320
|
+
} else {
|
|
321
|
+
const index = new Map(), firstRow = [], at = new Int32Array(nR);
|
|
322
|
+
for (let r = 0; r < nR; r++) {
|
|
323
|
+
let id = '';
|
|
324
|
+
for (let k = 0; k < nk; k++) { const v = src[k][r]; id += (v === v ? (v === 0 ? 0 : v) : 'NaN') + ','; } // (-0 as 0, NaN alike)
|
|
325
|
+
let c = index.get(id);
|
|
326
|
+
if (c === undefined) { c = firstRow.length; index.set(id, c); firstRow.push(r); }
|
|
327
|
+
at[r] = c;
|
|
328
|
+
}
|
|
329
|
+
G = firstRow.length;
|
|
330
|
+
const cmp = (x, y) => (x === y || (x !== x && y !== y) ? 0 : x !== x ? 1 : y !== y ? -1 : x < y ? -1 : 1); // NaN last
|
|
331
|
+
const order = Array.from({ length: G }, (_, i) => i).sort((i, j) => { for (let k = 0; k < nk; k++) { const d = cmp(src[k][firstRow[i]], src[k][firstRow[j]]); if (d) return d; } return 0; });
|
|
332
|
+
const rank = new Int32Array(G);
|
|
333
|
+
order.forEach((i, r) => { rank[i] = r; });
|
|
334
|
+
for (let r = 0; r < nR; r++) at[r] = rank[at[r]];
|
|
335
|
+
values = src.map((s) => Float64Array.from({ length: G }, (_, g) => { const v = s[firstRow[order[g]]]; return v === 0 ? 0 : v; }));
|
|
336
|
+
ok = fillDirect(T, keys, at, nR, lo, 0);
|
|
337
|
+
}
|
|
338
|
+
if (!ok) { engine.free(lut); return null; } // the right keys repeat
|
|
339
|
+
return { arrays: [lut], lut, min: lo, size, G, values, meta: cols.map((c) => ({ dict: c.dict ?? null, codes: c.dict ? c.codes : null, date: c.date })) };
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// (plain functions: hot loops)
|
|
343
|
+
function keyRange(keys, n) { // whole-number keys (NaN skipped): [min, max], else null
|
|
344
|
+
let lo = Infinity, hi = -Infinity;
|
|
345
|
+
for (let r = 0; r < n; r++) { const v = keys[r]; if (v !== v) continue; if (v !== Math.floor(v)) return null; if (v < lo) lo = v; if (v > hi) hi = v; }
|
|
346
|
+
return lo === Infinity ? null : [lo, hi];
|
|
347
|
+
}
|
|
348
|
+
function wholeRange(v, n) { // every value whole (no NaN): [min, max], else null
|
|
349
|
+
let lo = Infinity, hi = -Infinity;
|
|
350
|
+
for (let r = 0; r < n; r++) { const x = v[r]; if (x !== Math.floor(x)) return null; if (x < lo) lo = x; if (x > hi) hi = x; }
|
|
351
|
+
return lo === Infinity ? null : [lo, hi];
|
|
352
|
+
}
|
|
353
|
+
function fillDirect(T, keys, code, n, lo, base) { // T[key - lo] = code[r] - base; false on a repeated key
|
|
354
|
+
for (let r = 0; r < n; r++) {
|
|
355
|
+
const v = keys[r];
|
|
356
|
+
if (v !== v) continue; // (a missing key matches nothing)
|
|
357
|
+
const j = v - lo;
|
|
358
|
+
if (T[j] !== -1) return false;
|
|
359
|
+
T[j] = code[r] - base;
|
|
360
|
+
}
|
|
361
|
+
return true;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
function noMatchAt(lid, idx, n, K) { for (let i = 0; i < n; i++) { const g = lid[i]; idx[i] = g < 0 ? K : g; } }
|
|
365
|
+
|
|
366
|
+
// (plain functions: hot loops run faster outside generators)
|
|
367
|
+
function countPairs(found, nL, start, rowOf, left) {
|
|
368
|
+
let total = 0;
|
|
369
|
+
if (rowOf) { // unique right keys (an ID column): one right row per key
|
|
370
|
+
if (left) return nL;
|
|
371
|
+
for (let i = 0; i < nL; i++) if (found[i] >= 0) total++;
|
|
372
|
+
return total;
|
|
373
|
+
}
|
|
374
|
+
for (let i = 0; i < nL; i++) {
|
|
375
|
+
const g = found[i];
|
|
376
|
+
const m = g >= 0 ? start[g + 1] - start[g] : 0;
|
|
377
|
+
total += m || (left ? 1 : 0);
|
|
378
|
+
}
|
|
379
|
+
return total;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
function makePairs(f, nL, nR, start, rows, rowOf, left, a, b) {
|
|
383
|
+
let o = 0;
|
|
384
|
+
if (rowOf) {
|
|
385
|
+
for (let i = 0; i < nL; i++) {
|
|
386
|
+
const g = f[i];
|
|
387
|
+
if (g >= 0) { a[o] = i; b[o++] = rowOf[g]; } else if (left) { a[o] = i; b[o++] = nR; }
|
|
388
|
+
}
|
|
389
|
+
return;
|
|
390
|
+
}
|
|
391
|
+
for (let i = 0; i < nL; i++) {
|
|
392
|
+
const g = f[i];
|
|
393
|
+
if (g >= 0) for (let j = start[g]; j < start[g + 1]; j++) { a[o] = i; b[o++] = rows[j]; }
|
|
394
|
+
else if (left) { a[o] = i; b[o++] = nR; } // row nR: the "no match" row
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
const ids = new WeakMap();
|
|
399
|
+
let nextId = 1;
|
|
400
|
+
const idOf = (x) => { if (!x) return 0; let id = ids.get(x); if (!id) ids.set(x, (id = nextId++)); return id; };
|
|
401
|
+
const dictId = idOf, arrayId = idOf;
|
|
402
|
+
|
|
403
|
+
export function freeBuild(engine, build) { for (const a of build?.arrays ?? []) engine.free(a); }
|
|
404
|
+
|
|
405
|
+
// Step 1: the right table's side. Keys as numbers (text keys as the left
|
|
406
|
+
// table's codes), numbered in one hash table; then the right rows of each
|
|
407
|
+
// group number side by side: rows[start[g] .. start[g + 1]).
|
|
408
|
+
function* buildRight(engine, t, L, R, lk, rk, how) {
|
|
409
|
+
const nk = rk.length, nR = R.n;
|
|
410
|
+
const arrays = [];
|
|
411
|
+
const alloc = (Type, n) => { const a = engine.alloc(Type, n); arrays.push(a); return a; };
|
|
412
|
+
const build = { arrays, extended: new Map(), K: 0, start: null, rows: null, rightKeys: [] };
|
|
413
|
+
engine._joinBuildCount = (engine._joinBuildCount ?? 0) + 1; // (tests: a dataset hashes the right side once)
|
|
414
|
+
try {
|
|
415
|
+
for (let k = 0; k < nk; k++) {
|
|
416
|
+
const a = L.cols.get(lk[k]), b = R.cols.get(rk[k]);
|
|
417
|
+
if (!b.dict) { build.rightKeys.push(b.data); continue; }
|
|
418
|
+
const lut = b.dict.map((label) => a.codes?.get(label) ?? NaN); // a label the left table lacks matches nothing
|
|
419
|
+
const mapped = alloc(L.type, nR);
|
|
420
|
+
const src = engine.live(b.data), dst = engine.live(mapped);
|
|
421
|
+
for (let i = 0; i < nR; i++) dst[i] = lut[src[i]];
|
|
422
|
+
build.rightKeys.push(mapped);
|
|
423
|
+
}
|
|
424
|
+
const rid = alloc(L.type, nR);
|
|
425
|
+
if (engine.jit) {
|
|
426
|
+
const room = nR + 64 * maxUnits(Math.max(1, engine.T)); // every group number < room
|
|
427
|
+
if (t === 'f32' && room > 2 ** 24) throw new RangeError('vectro: join on a float32 table is limited to 16 million right-side rows');
|
|
428
|
+
let cap = 1024;
|
|
429
|
+
while (cap < room * 2) cap *= 2;
|
|
430
|
+
const slots = alloc(Int32Array, cap), keyBits = alloc(Float64Array, room * nk), counter = alloc(Int32Array, 4);
|
|
431
|
+
engine.live(slots).fill(-1);
|
|
432
|
+
engine.live(counter)[0] = 0;
|
|
433
|
+
const p = (a) => engine.live(a).byteOffset;
|
|
434
|
+
const k = kernel(engine, `hashgroups|${t}|${nk}`, () => generateHashGroups(t, nk, engine.isShared));
|
|
435
|
+
const { parts } = yield* engine._fusedG(k, nR, [...build.rightKeys, rid], [p(slots), cap - 1, p(keyBits), room, p(counter)], 1);
|
|
436
|
+
if (parts.some((x) => x !== 0)) throw new Error('vectro: join hash table overflowed'); // can't happen: room > rows
|
|
437
|
+
Object.assign(build, { slots, keyBits, cap, K: engine.live(counter)[0] });
|
|
438
|
+
} else {
|
|
439
|
+
const R = build.rightKeys.map((a) => engine.live(a));
|
|
440
|
+
const index = new Map(), r = engine.live(rid);
|
|
441
|
+
for (let i = 0; i < r.length; i++) {
|
|
442
|
+
const key = keyOf(R, i) ?? `NaN${i}`; // (NaN keys get a number of their own; nothing finds it)
|
|
443
|
+
let g = index.get(key);
|
|
444
|
+
if (g === undefined) { g = index.size; index.set(key, g); }
|
|
445
|
+
r[i] = g;
|
|
446
|
+
}
|
|
447
|
+
Object.assign(build, { index, K: index.size });
|
|
448
|
+
}
|
|
449
|
+
const ids = engine.live(rid), K = build.K;
|
|
450
|
+
if (nk === 1 && engine.jit) { // whole-number keys in a small range: a direct lookup array too
|
|
451
|
+
const keys = engine.live(build.rightKeys[0]);
|
|
452
|
+
let lo = Infinity, hi = -Infinity, whole = true;
|
|
453
|
+
for (let r = 0; r < nR && whole; r++) { const v = keys[r]; if (v !== v) continue; if (v !== Math.floor(v)) whole = false; if (v < lo) lo = v; if (v > hi) hi = v; }
|
|
454
|
+
const size = hi - lo + 1;
|
|
455
|
+
if (whole && lo !== Infinity && size <= Math.max(4 * nR, 1 << 16) && size <= 2 ** 26) {
|
|
456
|
+
const lut = alloc(Int32Array, size);
|
|
457
|
+
const L = engine.live(lut), k2 = engine.live(build.rightKeys[0]), g2 = engine.live(rid);
|
|
458
|
+
L.fill(-1);
|
|
459
|
+
for (let r = 0; r < nR; r++) { const v = k2[r]; if (v === v) L[v - lo] = g2[r]; }
|
|
460
|
+
Object.assign(build, { lut, lutMin: lo, lutSize: size });
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
const start = new Int32Array(K + 1);
|
|
464
|
+
for (let r = 0; r < nR; r++) start[ids[r] + 1]++;
|
|
465
|
+
for (let g = 0; g < K; g++) start[g + 1] += start[g];
|
|
466
|
+
const rows = new Int32Array(nR), fill = start.slice(0, K);
|
|
467
|
+
for (let r = 0; r < nR; r++) rows[fill[ids[r]]++] = r;
|
|
468
|
+
// unique right keys (an ID column): each group number's one row, if any
|
|
469
|
+
// (the parallel build reserves numbers in batches, so some go unused)
|
|
470
|
+
let rowOf = new Int32Array(K);
|
|
471
|
+
for (let g = 0; g < K; g++) {
|
|
472
|
+
const m = start[g + 1] - start[g];
|
|
473
|
+
if (m > 1) { rowOf = null; break; }
|
|
474
|
+
rowOf[g] = m ? rows[start[g]] : -1;
|
|
475
|
+
}
|
|
476
|
+
Object.assign(build, { start, rows, rowOf, rid });
|
|
477
|
+
return build;
|
|
478
|
+
} catch (err) {
|
|
479
|
+
freeBuild(engine, build);
|
|
480
|
+
throw err;
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
// Step 2 on every thread: look up the left keys (no inserts).
|
|
485
|
+
// hit (RIGHT / FULL JOIN): the dense probe also marks the groups it finds
|
|
486
|
+
// (see newHits); returns whether it did.
|
|
487
|
+
function* probeG(engine, t, build, leftKeys, lid, miss = -1, hit = null) {
|
|
488
|
+
const p = (a) => engine.live(a).byteOffset;
|
|
489
|
+
if (build.lut) { // whole-number keys in a small range: a direct lookup (lut[key - min])
|
|
490
|
+
const k = kernel(engine, `denseprobe|${t}|${hit ? 'mark' : ''}`, () => denseProbeModule(t, engine.isShared, !!hit));
|
|
491
|
+
yield* engine._unitsG(k, engine.live(lid).length, [p(leftKeys[0]), p(build.lut), p(lid), build.lutMin, build.lutSize, miss, ...(hit ? [p(hit) + 4] : [])]);
|
|
492
|
+
return !!hit;
|
|
493
|
+
}
|
|
494
|
+
const probe = kernel(engine, `hashprobe|${t}|${leftKeys.length}`, () => generateHashProbe(t, leftKeys.length, engine.isShared));
|
|
495
|
+
yield* engine._fusedG(probe, engine.live(lid).length, [...leftKeys, lid], [p(build.slots), build.cap - 1, p(build.keyBits)], 0);
|
|
496
|
+
return false;
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
// probe(start, end, unit, keys, lut, out, min, size, miss): out[i] = lut[key - min]
|
|
500
|
+
// for a whole-number key in [min, min + size) that the right table has
|
|
501
|
+
// (lut holds -1 for keys it lacks), else miss.
|
|
502
|
+
function denseProbeModule(t, shared, mark = false) {
|
|
503
|
+
const f = new Fn('k', [I32, I32, I32, I32, I32, I32, F64, I32, I32, ...(mark ? [I32] : [])], []);
|
|
504
|
+
const [start, stop, , keys, lut, out, min, size, miss, hit] = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
|
|
505
|
+
const i = f.local(I32), v = f.local(F64), g = f.local(I32), ok = f.local(I32), x = f.local(I32), at = f.local(I32);
|
|
506
|
+
const bytes = t === 'f32' ? 4 : 8;
|
|
507
|
+
f.emit(get(start), set(i));
|
|
508
|
+
f.emit(block, loop, get(i), get(stop), i32geu, brIf(1));
|
|
509
|
+
f.emit(get(i), i32c(Math.log2(bytes)), [0x74], get(keys), i32add, t === 'f32' ? [0x2a, 2, 0, 0xbb] : [0x2b, 3, 0], get(min), [0xa1], set(v)); // v = key - min
|
|
510
|
+
f.emit(get(v), [0xfc, 0x02], set(g)); // g = trunc_sat(v)
|
|
511
|
+
f.emit(get(g), get(size), [0x49], get(g), [0xb7], get(v), [0x61], [0x71], set(ok)); // ok = g < size (unsigned) && f64(g) == v
|
|
512
|
+
f.emit(get(g), i32c(0), get(ok), [0x1b], i32c(2), [0x74], get(lut), i32add, [0x28, 2, 0], set(x)); // x = lut[ok ? g : 0]
|
|
513
|
+
f.emit(get(i), i32c(2), [0x74], get(out), i32add); // address of out[i]
|
|
514
|
+
f.emit(get(x), get(miss), get(ok), get(x), i32c(0), [0x4e], [0x71], [0x1b]); // ok && x >= 0 ? x : miss
|
|
515
|
+
f.emit([0x36, 2, 0]);
|
|
516
|
+
if (mark) { // a match: hit[x] = 1 (stored only while still 0, so the slot's cache line stays shared)
|
|
517
|
+
f.emit(get(ok), get(x), i32c(0), [0x4e], [0x71], [0x04, 0x40]);
|
|
518
|
+
f.emit(get(x), i32c(2), [0x74], get(hit), i32add, set(at));
|
|
519
|
+
f.emit(get(at), [0x28, 2, 0], [0x45], [0x04, 0x40], get(at), i32c(1), [0x36, 2, 0], end, end);
|
|
520
|
+
}
|
|
521
|
+
f.emit(get(i), i32c(1), i32add, set(i), br(0), end, end);
|
|
522
|
+
return buildModule([f], shared);
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
// The same in plain JavaScript (engines without the WebAssembly compiler).
|
|
526
|
+
const keyOf = (cols, i) => {
|
|
527
|
+
let key = '';
|
|
528
|
+
for (const c of cols) { const v = c[i]; if (v !== v) return null; key += (v === 0 ? 0 : v) + ','; }
|
|
529
|
+
return key;
|
|
530
|
+
};
|
|
531
|
+
function probeJS(engine, build, leftKeys, lid) {
|
|
532
|
+
const L = leftKeys.map((a) => engine.live(a)), l = engine.live(lid);
|
|
533
|
+
for (let i = 0; i < l.length; i++) { const key = keyOf(L, i); l[i] = key === null ? -1 : build.index.get(key) ?? -1; }
|
|
534
|
+
}
|