vectrojs 0.1.0 → 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 CHANGED
@@ -1,5 +1,23 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.1
4
+
5
+ A performance release. No API changes.
6
+
7
+ **Faster group-by**
8
+ - SQL `SUM` no longer computes a hidden count of non-missing values when the
9
+ column has none missing, and sum, mean, min and max skip the per-row
10
+ missing-value check on such columns.
11
+ - On many threads, a group-by remembers its key range and merges the
12
+ threads' partial results with a WebAssembly kernel instead of a JavaScript
13
+ loop. This also removes the 31-thread limit for queries with many groups.
14
+ - Measured on 2M orders (`SELECT product_id, sum(amount) ... GROUP BY 1`,
15
+ `compare/bench-2m-groupby.mjs`, numbers in `compare/results.json`): with
16
+ 1,000 products, 3.0 ms on 1 thread (DuckDB 2.0: 6.8 ms) and 2.3 ms on 8
17
+ threads (DuckDB 2.0: 2.4 ms). Before this release Vectro was 1.9x slower
18
+ than DuckDB on 8 threads in this case; it is now level or faster at every
19
+ size from 100 to 100,000 products.
20
+
3
21
  ## 0.1.0 (first release)
4
22
 
5
23
  The first public release. Everything below is new.
package/README.md CHANGED
@@ -5,7 +5,7 @@ and compiles each query, filter included, into one SIMD WebAssembly loop, then
5
5
  runs it on every CPU core. It runs in Node, in browsers (any page) and on
6
6
  Cloudflare Workers, with no native addon, compiler or server.
7
7
 
8
- > Version 0.1.0, the first release. Node 20+ and modern browsers. MIT license.
8
+ > Version 0.1.1. Node 20+ and modern browsers. MIT license.
9
9
 
10
10
  **Website and docs: [vectrojs.dev](https://vectrojs.dev)**. [Quick start](https://vectrojs.dev/docs/quick-start) · [API reference](https://vectrojs.dev/api) · [Race it in your browser](https://vectrojs.dev/live) · [Benchmarks](https://vectrojs.dev/benchmarks)
11
11
 
@@ -82,10 +82,10 @@ What changed: [CHANGELOG.md](CHANGELOG.md). Problems and ideas:
82
82
 
83
83
  | Keys | Vectro | DuckDB |
84
84
  |---|---|---|
85
- | region + day (2,920 pairs) | 8 ms | 34 ms |
86
- | price (100K amounts) | 23 ms | 42 ms |
87
- | account (1M big IDs) | 29 ms | 61 ms |
88
- | account + region (5.7M pairs) | 42 ms | 115 ms |
85
+ | region + day (2,920 pairs) | 6 ms | 39 ms |
86
+ | price (100K amounts) | 27 ms | 49 ms |
87
+ | account (1M big IDs) | 36 ms | 88 ms |
88
+ | account + region (5.7M pairs) | 50 ms | 139 ms |
89
89
 
90
90
  Vectro's groups also come back sorted by key; DuckDB's come back unsorted.
91
91
  - **Missing values** are NaN for numbers and `''` for text. `sum`, `mean`,
@@ -124,9 +124,9 @@ What changed: [CHANGELOG.md](CHANGELOG.md). Problems and ideas:
124
124
  by the hash table first, or, with many of them, sorted by key range on
125
125
  every thread (as a group-by with many groups) and counted in runs; values
126
126
  a whole step apart (IDs like 1e9 + 7919k) get the byte map too. On 10M rows
127
- vs DuckDB (`compare/bench-distinct.mjs`): 365 days 4 ms vs 4; regions per
128
- day 7 ms vs 31; 1M accounts 9 ms vs 44 (1M random IDs up to 2^40, no common
129
- step: 24 ms vs 46); accounts per region (5.7M pairs) 30 ms vs 85.
127
+ vs DuckDB (`compare/bench-distinct.mjs`): 365 days 5 ms vs 5; regions per
128
+ day 9 ms vs 36; 1M accounts 11 ms vs 51 (1M random IDs up to 2^40, no common
129
+ step, from an earlier run: 24 ms vs 46); accounts per region (5.7M pairs) 35 ms vs 103.
130
130
  - **Compressed columns:** `table.compress()` (or `engine.table(cols, { compress:
131
131
  true })`, `parseCSV(text, { compress: true })`) stores each number and
132
132
  category column in 1, 2 or 4 bytes per row when every value comes back
@@ -198,11 +198,11 @@ What changed: [CHANGELOG.md](CHANGELOG.md). Problems and ideas:
198
198
  dates, and comparing text with `<` / `>`. Each function runs once per
199
199
  distinct value, not per row (a category column has few labels), and the
200
200
  answer is a category column again, so it groups, sorts and joins like any
201
- other: `GROUP BY UPPER(region)` on 10M rows 30 ms (DuckDB 42), `region ||
202
- '-' || qty` as a key 85 ms (92), `STRFTIME(ts, '%Y-%m')` 48 ms (61; times
203
- are rounded to the month first), ILIKE on a column 29 ms (46). A CASE whose
201
+ other: `GROUP BY UPPER(region)` on 10M rows 31 ms (DuckDB 47), `region ||
202
+ '-' || qty` as a key 85 ms (106), `STRFTIME(ts, '%Y-%m')` 51 ms (70; times
203
+ are rounded to the month first), ILIKE on a column 30 ms (54). A CASE whose
204
204
  results are all quoted labels is worked out as label codes in the fused
205
- pass (CASE text + group 5.7 ms vs 21). Missing text
205
+ pass (CASE text + group 6.2 ms vs 25). Missing text
206
206
  is `''`, as everywhere in Vectro, and positions count characters from 1 as
207
207
  in DuckDB.
208
208
 
@@ -210,7 +210,7 @@ What changed: [CHANGELOG.md](CHANGELOG.md). Problems and ideas:
210
210
  rows (FULL: plus the left rows without a match) followed by the right rows
211
211
  whose keys no left row has. The join marks which right rows it matched as
212
212
  it probes, so the rows without a match come from the same pass (RIGHT JOIN
213
- + group 27 vs 54 ms; FULL JOIN + count 17 vs 26 ms).
213
+ + group 30 vs 64 ms; FULL JOIN + count 19 vs 29 ms).
214
214
 
215
215
  `compare/check-sql.mjs` runs 40 such queries (CTEs, set operations,
216
216
  correlated subqueries, windows, percentiles, text functions on awkward
@@ -219,15 +219,15 @@ What changed: [CHANGELOG.md](CHANGELOG.md). Problems and ideas:
219
219
  correlation on anything but equalities, MIN / MAX of text, and STRING_AGG.
220
220
  The MCP server has
221
221
  a `sql` tool. The same SQL text vs DuckDB on 10M orders + 100K customers
222
- (`compare/bench-sql.mjs`, answers checked, one run): Vectro is faster on all
223
- 20 queries: filtered totals 3.4 vs 10.1 ms, group by region 7.8 vs
224
- 30.7 ms, join + filter + group 17.3 vs 29.0 ms, top 10 customers 12.9 vs
225
- 42.5 ms, distinct customers per month 30.8 vs 70.6 ms, top 20 rows 8.2 vs
226
- 22.3 ms, CASE inside SUM 7.6 vs 35.2 ms, IN (subquery) 19.7 vs 25.2 ms,
227
- correlated subquery 16.0 vs 50.6 ms, top 3 per region with RANK 36 vs
228
- 119 ms, ROW_NUMBER over all rows 124 vs 179 ms, running total by day 8.6
229
- vs 14.6 ms, MEDIAN per region 33.6 vs 71.7 ms, FULL JOIN + count 16.9 vs
230
- 25.5 ms. A filter like `rn <= 3` on ROW_NUMBER or RANK keeps
222
+ (`compare/bench-sql.mjs`, answers checked, best of 5): Vectro is faster on all
223
+ 20 queries (1.3–6.0x): filtered totals 4.3 vs 10.1 ms, group by region 7.4 vs
224
+ 36.8 ms, join + filter + group 19.8 vs 32.8 ms, top 10 customers 13.1 vs
225
+ 50.3 ms, distinct customers per month 34.2 vs 82.5 ms, top 20 rows 9.1 vs
226
+ 25.3 ms, CASE inside SUM 6.6 vs 39.6 ms, IN (subquery) 23.4 vs 29.7 ms,
227
+ correlated subquery 16.8 vs 61.4 ms, top 3 per region with RANK 38 vs
228
+ 139 ms, ROW_NUMBER over all rows 129 vs 221 ms, running total by day 9.8
229
+ vs 17.3 ms, MEDIAN per region 39.1 vs 83.9 ms, FULL JOIN + count 19.3 vs
230
+ 29.3 ms. A filter like `rn <= 3` on ROW_NUMBER or RANK keeps
231
231
  only the rows that can rank that high before sorting (picked on every
232
232
  thread); percentiles of a few partitions count values in buckets on every
233
233
  thread and select only within the wanted buckets. Window functions and
@@ -257,10 +257,10 @@ What changed: [CHANGELOG.md](CHANGELOG.md). Problems and ideas:
257
257
 
258
258
  | Join | Vectro | DuckDB |
259
259
  |---|---|---|
260
- | × 100K customers | 65 ms | 222 ms |
261
- | × 1M accounts | 135 ms | 255 ms |
262
- | left join, half the keys match | 76 ms | 191 ms |
263
- | two keys: region + day (2,920 rows) | 64 ms | 167 ms |
260
+ | × 100K customers | 77 ms | 210 ms |
261
+ | × 1M accounts | 151 ms | 251 ms |
262
+ | left join, half the keys match | 82 ms | 206 ms |
263
+ | two keys: region + day (2,920 rows) | 61 ms | 189 ms |
264
264
  - **Text functions** on category columns: `startsWith(col, 'x')`,
265
265
  `endsWith`, `contains`, `in(col, 'a', 'b')` and `like(col, 'EU-%')`. They run
266
266
  once per distinct label (not per row) and become a few range checks on the
@@ -282,8 +282,8 @@ What changed: [CHANGELOG.md](CHANGELOG.md). Problems and ideas:
282
282
  of 50,000+ rows run on every core: a parallel radix sort (11-bit digits,
283
283
  digits that never change are skipped, whole numbers sort as offsets), then
284
284
  every column is gathered in the new order on every core. Sorting 10M rows
285
- by `-price` takes ~130 ms (one thread: ~350 ms; a JavaScript comparator
286
- sort: ~4.6 s).
285
+ by `-price` takes ~117 ms (one thread: ~350 ms; a JavaScript comparator
286
+ sort: ~4.1 s).
287
287
  - **Async API:** every query has an `…Async` version that runs on the worker
288
288
  threads while your server or page keeps working.
289
289
  - **Expressions** are a small, safe language: numbers, columns, `+ - * /`,
@@ -383,25 +383,27 @@ formulas exact in both, answers compared; best of 3):
383
383
 
384
384
  | Query | 100M rows: Vectro | DuckDB | 1 billion rows: Vectro | DuckDB |
385
385
  |---|---|---|---|---|
386
- | scan everything (count, revenue) | 49 ms | 61 ms | 460 ms | 648 ms |
387
- | filter: region and quantity | 70 ms | 72 ms | 665 ms | 723 ms |
388
- | the last day (1 chunk read) | 3 ms | 1 ms | 3 ms | 5 ms |
389
- | group by region | 114 ms | 124 ms | 1.13 s | 1.24 s |
390
- | top 10 of 1M customers | 460 ms | 487 ms | 4.46 s | 4.62 s |
391
- | top 10 rows by price | 10 ms | 87 ms | 11 ms | 827 ms |
392
- | join 1M customers, group by tier | 84 ms | 68 ms | 745 ms | 587 ms |
393
- | distinct customers per region | 247 ms | 517 ms | 2.45 s | 6.50 s |
386
+ | scan everything (count, revenue) | 70 ms | 73 ms | 478 ms | 662 ms |
387
+ | filter: region and quantity | 100 ms | 87 ms | 691 ms | 729 ms |
388
+ | the last day (1 chunk read) | 3 ms | 1 ms | 3 ms | 4 ms |
389
+ | group by region | 142 ms | 149 ms | 1.05 s | 1.25 s |
390
+ | top 10 of 1M customers | 529 ms | 637 ms | 4.39 s | 4.50 s |
391
+ | top 10 rows by price | 15 ms | 100 ms | 11 ms | 843 ms |
392
+ | join 1M customers, group by tier | 126 ms | 85 ms | 880 ms | 627 ms |
393
+ | distinct customers per region | 268 ms | 650 ms | 2.43 s | 6.84 s |
394
394
  | file on disk | 0.5 GB | 0.5 GB | 5.1 GB | 5.4 GB |
395
- | peak memory (RSS) | 343 MB | 3.1 GB | **0.37 GB** | 6.6 GB |
395
+ | peak memory (RSS) | 344 MB | 2.9 GB | **0.37 GB** | 6.9 GB |
396
396
 
397
397
  A billion rows would take 37 GB as plain columns in memory (this laptop has
398
- 16 GB); Vectro's engine never used more than 163 MB for them. Vectro is level
399
- or faster on 7 of the 8 queries. Top rows read only the blocks of rows whose
398
+ 16 GB); Vectro's engine never used more than 163 MB for them. At a billion
399
+ rows Vectro is level or faster on 7 of the 8 queries (only the join is
400
+ slower, 1.4x); at 100M rows it is slower on three: the filter (1.1x), the
401
+ last day (3 vs 1 ms) and the join (1.5x). Top rows read only the blocks of rows whose
400
402
  stored min / max can still beat the rows kept so far (here 1 chunk of 239),
401
403
  so they take the same few milliseconds at any size. A join whose right side
402
404
  has unique whole-number keys and a GROUP BY on its columns looks each left
403
405
  key's group up in one array instead of joining rows; DuckDB is still faster
404
- there (1.3x).
406
+ there (1.4x at a billion rows, 1.5x at 100M).
405
407
 
406
408
  ## SQL over HTTP: Excel, Power BI, Google Sheets, pandas
407
409
 
@@ -589,9 +591,9 @@ no index to build or tune, results identical to brute force.
589
591
 
590
592
  | Embeddings | Vectro | Plain JS loop (Float32Array) |
591
593
  |---|---|---|
592
- | 100,000 (154 MB) | 11.7 ms | 35.9 ms (3.1×) |
593
- | 200,000 (307 MB) | 13.8 ms | 71.5 ms (5.2×) |
594
- | 500,000 (768 MB) | 22.8 ms | 179.3 ms (7.9×) |
594
+ | 100,000 (154 MB) | 9.3 ms | 36.6 ms (3.9×) |
595
+ | 200,000 (307 MB) | 13.6 ms | 72.6 ms (5.3×) |
596
+ | 500,000 (768 MB), an earlier run | 22.8 ms | 179.3 ms (7.9×) |
595
597
 
596
598
  With a `SplitEngine`, every worker searches its own rows in one round trip
597
599
  and the page keeps the best k. In Chrome (`examples/check/vectors.html?n=200000`),
@@ -763,25 +765,30 @@ iPhone) and Cloudflare Workers.
763
765
 
764
766
  | Query | Vectro | DuckDB 2.0 | SQLite* | Arquero |
765
767
  |---|---|---|---|---|
766
- | Filtered revenue | 3.3 ms | 9.9 ms | 757 ms | 65 ms |
767
- | Dashboard, 4 aggregates | 3.6 ms | 22.8 ms | 345 ms | 169 ms |
768
- | Group by region (8 groups) | 7.7 ms | 35.1 ms | 2.75 s | 1.02 s |
769
- | Group by customer (100K groups) | 11.0 ms | 11.7 ms | 4.32 s | 723 ms |
770
- | Filter into a new table | 5.4 ms | 59.6 ms | | 123 ms |
771
- | New column | 3.3 ms | 25.6 ms | 1.75 s | 114 ms |
768
+ | Filtered revenue | 3.2 ms | 10.1 ms | 772 ms | 74 ms |
769
+ | Dashboard, 4 aggregates | 4.1 ms | 23.9 ms | 359 ms | 171 ms |
770
+ | Group by region (8 groups) | 6.5 ms | 39.7 ms | 2.59 s | 1.00 s |
771
+ | Group by customer (100K groups) | 8.1 ms | 13.2 ms | 3.90 s | 772 ms |
772
+ | Filter into a new table | 6.4 ms | 55.7 ms | 540 ms | 124 ms |
773
+ | New column | 3.7 ms | 32.3 ms | 1.50 s | 125 ms |
772
774
 
773
775
  Best of 5, each system using all 8 cores (Arquero is single-threaded), the
774
- same answers from every system. \*SQLite from an earlier run
775
- (`npm run bench:sqlite`).
776
+ same answers from every system. Vectro is 1.6–8.7x faster than DuckDB here
777
+ (3–9x on five of the six, 1.6x with 100K groups). \*SQLite from a separate
778
+ run (`npm run bench:sqlite`, best of 3).
776
779
 
777
780
  Run them yourself: `cd compare && npm install && npm run bench`. Timings vary
778
781
  with the machine's state (battery, background load), so compare systems in the
779
- same run. More comparisons in `compare/`: SQL (`bench-sql.mjs`: Vectro faster
782
+ same run. The numbers in this README were measured on 1 November 2026
783
+ (commit b2a3033). These runs shared the laptop with other jobs (load average 3–8 for
784
+ the in-memory runs, up to 15 for the billion-row run), and ratios move by up
785
+ to about ±30% between runs. More comparisons in `compare/`: SQL (`bench-sql.mjs`: Vectro faster
780
786
  on all 20 queries), joins (`bench-join.mjs`: faster on all 4), group-by
781
- (`bench-groupby.mjs`: faster on all 4, 2.1–2.7x with millions of groups),
787
+ (`bench-groupby.mjs`: faster on all 4, 2.4–2.8x with millions of groups),
782
788
  distinct counts (`bench-distinct.mjs`: level or faster on all 4). On files
783
789
  on disk (`bench-dataset.mjs`) DuckDB is still faster at joins with a GROUP
784
- BY on the joined table (1.2–1.3x).
790
+ BY on the joined table (1.4–1.5x), and at 100M rows also at the filter (1.1x)
791
+ and the last-day lookup (3 vs 1 ms).
785
792
 
786
793
  ## Layout
787
794
 
package/mcp/server.mjs CHANGED
@@ -14,7 +14,7 @@ import { createInterface } from 'node:readline';
14
14
  import { Engine, sql } from '../src/node.mjs';
15
15
  import { LoadError, safePath as safePathIn, loadFile, columnsOf, kindOf } from '../src/serve/catalog.mjs';
16
16
 
17
- const VERSION = '0.1.0';
17
+ const VERSION = '0.1.1';
18
18
  const PROTOCOLS = ['2025-06-18', '2025-03-26', '2024-11-05'];
19
19
  const MAX_ROWS = 500, DEFAULT_ROWS = 50;
20
20
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vectrojs",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "Multi-core analytics engine for JavaScript: columnar tables, SQL and fused SIMD WebAssembly kernels for Node, browsers and edge runtimes, with no native code. Serves tables to Power BI, Tableau, Metabase, Grafana, pandas and Polars.",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/table.mjs CHANGED
@@ -31,6 +31,10 @@ const IDENTITY = { sum: 0, count: 0, mean: 0, min: Infinity, max: -Infinity };
31
31
  const MAX_GROUP_SLOTS = 2 ** 28; // threads * aggregations * groups (2GB of float64)
32
32
  const PARALLEL_SORT_ROWS = 50_000; // smaller tables sort on the calling thread
33
33
  const TIME_STEPS = [86_400_000, 3_600_000, 60_000, 1_000]; // a day, an hour, a minute, a second (ms): see #directGroups
34
+ // Column facts worked out once and remembered with the column's array
35
+ // (arrays are never written once in a table):
36
+ const MISSING = new WeakMap(); // { n: rows, missing: how many missing values }
37
+ const KEY_RANGE = new WeakMap(); // { n: rows, range: [min, max, how many aren't whole] } (see #directGroups)
34
38
 
35
39
  export class Table {
36
40
  // cols: Map name -> { data, dict, codes, strings }
@@ -578,6 +582,19 @@ export class Table {
578
582
  return Math.floor((engine._directSlots ?? DIRECT_SLOTS) / (engine.T * totals));
579
583
  }
580
584
 
585
+ // Is src a number column of this table with no missing values? Counted
586
+ // once per column (one pass on every thread) and remembered with its array.
587
+ *#noMissingG(src) {
588
+ const c = src && IDENT.test(src) ? this.cols.get(src) : null;
589
+ if (!c || c.dict || c.strings) return false;
590
+ let known = MISSING.get(c.data);
591
+ if (known?.n !== this.n) {
592
+ const k = c.enc ? { data: c.data, enc: c.enc, n: this.n, type: this.type === Float32Array ? 'f32' : 'f64' } : c.data;
593
+ MISSING.set(c.data, (known = { n: this.n, missing: (yield* this.engine.evalG(['count(k != k)'], { k }))[0] }));
594
+ }
595
+ return known.missing === 0;
596
+ }
597
+
581
598
  // Keys small enough to be group numbers as they are: a text column's codes
582
599
  // or whole numbers 0..2^24, several combined as k1 * size2 + k2..., while
583
600
  // the accumulators stay small (or no larger than the rows). Else null.
@@ -590,7 +607,9 @@ export class Table {
590
607
  if (!this.n) { sizes.push(0); offsets.push(0); steps.push(1); continue; }
591
608
  // whole numbers in a range under 2^24 (from their minimum: 202301..202412, IDs from 1e9, negatives)
592
609
  const k = c.enc ? { data: c.data, enc: c.enc, n: this.n, type: this.type === Float32Array ? 'f32' : 'f64' } : c.data;
593
- const [lo, hi, frac] = yield* engine.evalG(['min(k)', 'max(k)', 'count(k != floor(k))'], { k });
610
+ let known = KEY_RANGE.get(c.data);
611
+ if (known?.n !== this.n) KEY_RANGE.set(c.data, (known = { n: this.n, range: yield* engine.evalG(['min(k)', 'max(k)', 'count(k != floor(k))'], { k }) }));
612
+ const [lo, hi, frac] = known.range;
594
613
  if (frac > 0 || !Number.isFinite(lo) || !Number.isFinite(hi)) return null;
595
614
  // or times a whole number of days (hours, minutes, seconds) apart: numbered by that unit
596
615
  let step = 1;
@@ -604,9 +623,16 @@ export class Table {
604
623
  offsets.push(lo);
605
624
  steps.push(step);
606
625
  }
626
+ const fits = (K) => !(K > 2 ** 24 || engine.T * J * K * (engine.jit && K <= 1024 ? 4 : 1) > (engine._maxGroupSlots ?? MAX_GROUP_SLOTS) || (K > 65_536 && K > 8 * this.n));
627
+ // One key from a small positive number (IDs from 1, years from 1990):
628
+ // numbered from 0 with a few groups left empty, so the key column itself
629
+ // is the group number and needs no copy.
630
+ if (keys.length === 1 && steps[0] === 1 && offsets[0] > 0 && offsets[0] <= Math.max(1024, sizes[0]) && offsets[0] + sizes[0] <= maxK && fits(offsets[0] + sizes[0])) {
631
+ sizes[0] += offsets[0];
632
+ offsets[0] = 0;
633
+ }
607
634
  const K = sizes.reduce((a, b) => a * b, 1);
608
- const C = engine.jit && K <= 1024 ? 4 : 1;
609
- if (K > 2 ** 24 || engine.T * J * K * C > (engine._maxGroupSlots ?? MAX_GROUP_SLOTS) || (K > 65_536 && K > 8 * this.n)) return null;
635
+ if (!fits(K)) return null;
610
636
  if (K > maxK) { if (info) info.K = K; return null; }
611
637
  const below = sizes.map((_, k) => sizes.slice(k + 1).reduce((a, b) => a * b, 1));
612
638
  const keyColumn = (k, keep) => directKeys(keep, below[k], sizes[k], steps[k], offsets[k]); // key k of each kept group (codes for text)
@@ -747,12 +773,20 @@ export class Table {
747
773
  *#runGroupsG(keys, groups, allItems, merged) {
748
774
  const { engine } = this;
749
775
  const { K } = groups;
750
- const items = allItems.filter((it) => !DISTINCT.has(it.fn));
776
+ // Over a column with no missing values, count(x) is the row count (always
777
+ // counted) and sum / mean / min / max need no check for missing values:
778
+ // SQL's SUM(x) is sum(x) and count(x), so this takes a whole accumulator off.
779
+ const full = new Set();
780
+ for (const it of allItems) if (!DISTINCT.has(it.fn) && (yield* this.#noMissingG(it.raw))) full.add(it);
781
+ const rowCount = (it) => it.fn === 'count' && (!it.arg || full.has(it));
782
+ const items = allItems.filter((it) => !DISTINCT.has(it.fn) && !rowCount(it));
751
783
  // means need the count of values present: one more kernel item each
752
- const kernelItems = [...items, ...items.filter((it) => it.present).map((it) => ({ fn: 'count', arg: it.present, of: it }))]; // means, mins, maxes
784
+ const kernelItems = [...items.map((it) => (full.has(it) ? { fn: it.fn, arg: it.raw } : it)),
785
+ ...items.filter((it) => it.present && !full.has(it)).map((it) => ({ fn: 'count', arg: it.present, of: it }))]; // means, mins, maxes
753
786
  const totals = yield* this.#groupTotalsG(groups, kernelItems, merged);
754
787
  if (!totals) return null; // (lookup groups the kernels can't take)
755
- const presentOf = (it) => totals[1 + kernelItems.findIndex((k) => k.of === it)];
788
+ const totalOf = (it) => (rowCount(it) ? totals[0] : totals[1 + items.indexOf(it)]);
789
+ const presentOf = (it) => (full.has(it) ? totals[0] : totals[1 + kernelItems.findIndex((k) => k.of === it)]);
756
790
  // Keep groups that have rows, sorted by key (codes for text keys, so
757
791
  // dictionary order; NaN last), and build whole columns.
758
792
  let keep = keptGroups(totals[0], K);
@@ -768,7 +802,7 @@ export class Table {
768
802
  }
769
803
  // each item's value for every group number
770
804
  const perGroup = allItems.map((it) => {
771
- const i = items.indexOf(it), v = i >= 0 ? totals[i + 1] : it.perGroup;
805
+ const v = DISTINCT.has(it.fn) ? it.perGroup : totalOf(it);
772
806
  return it.fn === 'mean' ? ratioOf(v, presentOf(it)) : it.fn === 'min' || it.fn === 'max' ? presentOnly(v, presentOf(it)) : v;
773
807
  });
774
808
  if (engine.jit && m >= PARALLEL_SORT_ROWS) return yield* this.#groupTableG(keys, groups, allItems, perGroup, keep);
@@ -963,29 +997,34 @@ export class Table {
963
997
  interpretGroup(engine, t, analysis.items, predAst, cols, merged, groups.data, acc, K, this.n);
964
998
  }
965
999
 
966
- // Combine threads (and copies) into one value per group and aggregation.
1000
+ // Combine threads (and copies) into one value per group and aggregation:
1001
+ // the thread blocks by fused passes added into thread 0's, 15 blocks a
1002
+ // pass (kernels, so fast from the first query; small ones run on this
1003
+ // thread), then the C copies of each group by a plain loop.
967
1004
  totals = [];
968
1005
  for (const [j, it] of analysis.items.entries()) {
969
- const op = it.reduce === 'min' ? Math.min : it.reduce === 'max' ? Math.max : (a, b) => a + b;
970
- if (C > 1 || T === 1 || !engine.jit) { // a plain loop is enough
971
- const a = engine.live(acc);
972
- const out = new Float64Array(K).fill(IDENTITY[it.reduce]);
973
- for (let s = 0; s < T; s++) {
974
- const [lo] = slots(s, j);
975
- for (let g = 0; g < K; g++) for (let c = 0; c < C; c++) out[g] = op(out[g], a[lo + g * C + c]);
1006
+ let from = T;
1007
+ if (T > 1 && engine.jit) {
1008
+ for (let s0 = 1; s0 < T; s0 += 15) {
1009
+ const vars = { t0: acc.subarray(...slots(0, j)) };
1010
+ for (let s = s0; s < Math.min(T, s0 + 15); s++) vars[`t${s}`] = acc.subarray(...slots(s, j));
1011
+ const vnames = Object.keys(vars);
1012
+ const expr = it.reduce === 'min' || it.reduce === 'max'
1013
+ ? vnames.slice(1).reduce((e, nm) => `${it.reduce}(${e}, ${nm})`, vnames[0])
1014
+ : vnames.join(' + ');
1015
+ yield* engine.evalG(expr, vars, vars.t0);
976
1016
  }
977
- totals.push(out);
978
- continue;
1017
+ from = 1;
1018
+ }
1019
+ const a = engine.live(acc);
1020
+ if (C === 1 && from === 1) { const [lo, hi] = slots(0, j); totals.push(a.slice(lo, hi)); continue; }
1021
+ const op = it.reduce === 'min' ? Math.min : it.reduce === 'max' ? Math.max : (x, y) => x + y;
1022
+ const out = new Float64Array(K).fill(IDENTITY[it.reduce]);
1023
+ for (let s = 0; s < from; s++) {
1024
+ const [lo] = slots(s, j);
1025
+ for (let g = 0; g < K; g++) for (let c = 0; c < C; c++) out[g] = op(out[g], a[lo + g * C + c]);
979
1026
  }
980
- // many groups: one fused pass over all thread blocks, written into thread 0's
981
- const vars = {};
982
- for (let s = 0; s < T; s++) vars[`t${s}`] = acc.subarray(...slots(s, j));
983
- const vnames = Object.keys(vars);
984
- const expr = it.reduce === 'min' || it.reduce === 'max'
985
- ? vnames.slice(1).reduce((e, nm) => `${it.reduce}(${e}, ${nm})`, vnames[0])
986
- : vnames.join(' + ');
987
- yield* engine.evalG(expr, vars, vars.t0);
988
- totals.push(Float64Array.from(engine.live(vars.t0)));
1027
+ totals.push(out);
989
1028
  }
990
1029
  engine.free(acc);
991
1030
  }
@@ -1821,12 +1860,14 @@ function checkName(name) {
1821
1860
  // (mean divides by the values present), min and max skip them, count(x)
1822
1861
  // counts the values present (count(condition) still counts where it holds).
1823
1862
  // arg is the bound source; returns the aggregation item.
1863
+ // (raw: the argument as given; where it is a column with no missing values,
1864
+ // the group-by uses it as it is and takes counts from the row count)
1824
1865
  function missingRules(fn, arg) {
1825
1866
  const present = `(${arg}) == (${arg})`;
1826
- if (fn === 'count') return { fn, arg: isMask(parse(arg)) ? arg : present };
1827
- if (fn === 'min') return { fn, arg: `(${present} ? (${arg}) : Infinity)`, present }; // (present: NaN when nothing is)
1828
- if (fn === 'max') return { fn, arg: `(${present} ? (${arg}) : -Infinity)`, present };
1829
- return fn === 'mean' ? { fn, arg: `(${present} ? (${arg}) : 0)`, present } : { fn, arg: `(${present} ? (${arg}) : 0)` }; // sum, mean
1867
+ if (fn === 'count') return isMask(parse(arg)) ? { fn, arg } : { fn, arg: present, raw: arg };
1868
+ if (fn === 'min') return { fn, arg: `(${present} ? (${arg}) : Infinity)`, present, raw: arg }; // (present: NaN when nothing is)
1869
+ if (fn === 'max') return { fn, arg: `(${present} ? (${arg}) : -Infinity)`, present, raw: arg };
1870
+ return fn === 'mean' ? { fn, arg: `(${present} ? (${arg}) : 0)`, present, raw: arg } : { fn, arg: `(${present} ? (${arg}) : 0)`, raw: arg }; // sum, mean
1830
1871
  }
1831
1872
 
1832
1873
  // a category column given as codes into a labels array: { codes, labels }