Two point one adds no syntax. What it removes is work: intermediate vectors the engine wrote to memory
and read straight back, index lists it built only to gather through, a thread team it spun up to sum eighty
megabytes, and a whole class of kernels that ran on two doubles at a time because the portable build could not
assume the CPU had AVX2. Every result is bit-identical to 2.0.1 — two differential scripts in tests/
compare 1.1 million lines of output between the binaries — and on the 23-operation scout matrix Amber went
from beating CBQN on 4 of the 19 operations both implement to beating it on 11.
Where the time actually went
Before changing anything I traced every benchmark operation from its K expression through the bytecode to
the kernel and counted bytes. The picture was consistent: the kernels were mostly fine, the traffic
between them was not. +/x*y over ten million doubles wrote an 80 MB product and read it back
— 240 MB moved for a dot product that needs 160. +/(y+2.5*x)@&x>50 moved about 850 MB
where the hand-written C reference moves 160. A group-by built one index vector per group, gathered the
values through it, and called an interpreted lambda per group. Membership was ~^y?x: a find
producing eight bytes per element, a null test, a not — three passes for a boolean. The as-of join spent 32
of its 39 milliseconds not joining anything but building the per-trade quote slice through a generic hash.
None of that is a kernel problem. It is what a straightforward array evaluator does: evaluate each primitive completely before the next one starts. The fix is to let the compiler see the shape.
Idiom fusion
Amber's compiler already recognised one idiom — *|x as last. 2.1 adds a small
recogniser for a handful of shapes and compiles each to one fused primitive:
+/x*y +/x=y +/x<y +/x>y / sum of products, count of matches -- no product or mask vector
+/x@&m / sum of the masked elements -- no compressed vector
x@&m / compress by mask -- no index vector
a+s*b a-s*b (s a literal) / one pass, no s*b vector
x@<x x@>x / sort by value -- no grade, no gatherThe rule that keeps this honest: every fused primitive falls back to the very primitives it replaces for
any operand shape it does not handle. A generic list, a mask with a 2 in it, a NaN, a negative zero, a mask
longer than the vector — all of them take the old path and produce the old answer. The fused dot product even
keeps the unfused summation tree (and forbids FMA contraction), so +/x*y is the same bits it was,
just twice as fast. \\disasm +/x*y shows the call.
Group-by in one pass
The old select sum px by sym grouped with =, which returns a dictionary of index
vectors, then gathered each column through each vector into a sub-table and applied the aggregate lambda per
group. Correct, general, and — at a hundred thousand groups — a hundred thousand lambda calls over eighty
megabytes of scattered gathers. The C reference does acc[key] += v in one pass.
2.1 adds that kernel: `gagg takes an aggregate name, a key column, a value column and an optional
row mask, and returns the keys in first-appearance order (exactly the order = produces), the
aggregates, and the first row of each group. A direct table serves small key ranges; a hash that grows with the
distinct count serves the rest. Seven library verbs wrap it —
gsum[sym;px] gavg gmin gmax gcount[sym] gfirst glast / -> keys!values
select sum px, avg sz by sym from t where sz>250 / the same path: the where-clause becomes the mask— and the qSQL rewriter routes every select agg col … by whose items are simple aggregates over
real columns through it, with the where-clause handed in as a mask so the filtered table is never built.
Anything else keeps the generic path. Group-by at 10, 100, 10k and 100k groups went from 48, 110, 168 and
222 ms to 20, 21, 22 and 30 — ahead of CBQN, DuckDB and Polars at every cardinality.
One pass for the rest
in now runs on `memb: the set is indexed once (a bitmap over its range when that fits
eight megabytes, else a hash sized to the set) and each element costs one probe writing one byte — 51 ms to
9.6 on ten million values against a thousand keys. ? at high cardinality had been sizing its hash
table to the input length; a 10M-element input got a 256 MB table, random-access bound even with
a few thousand distinct values. It now uses a bitmap up to 226 keys and a table that grows with the
distinct count: 181 ms to 20 at 100k distinct. The as-of join's slice pass builds a direct table for a
single narrow key column instead of hashing every trade: 37 ms to 7.8, which is what the C reference
takes for one binary search per row.
Sorting a price column
A tick table's prices, sizes and ids are integral values wearing a float coat. Sorting one by
x@<x used to grade (a key-plus-index radix), then gather through the permutation — an 80 MB
random read. 2.1 scans the column once, at vector width, for "finite, integral, small range, no negative
zero"; when that holds it counting-sorts the values directly, and when the column is already sorted it returns
it. 197 ms became 58. Multi-column xasc packs the columns' significant key bytes into one
64-bit key when they fit and runs a single radix, the way the C reference sorts its (sym, px) table.
That scan found a bug on the way: the counting grade's signbit test for -0.0 had been
folded away by the build's -fno-signed-zeros, so a vector containing a negative zero was graded with
it equal to 0.0. The code's own comment says it must sort strictly before. It now does — one of the
two deliberate behaviour changes in this release. The other: every ascending value sort returns its result
flagged `s, so the next ?, in or aj on it takes the O(log n) path
without anyone typing `sa.
The portable build runs AVX2
./build.sh deliberately passes no -march, so the binary runs on any x86-64. That pinned
every kernel in src/simd.c to SSE2 — two doubles per register — and the native build was 10–25%
faster on several operations. 2.1 compiles that file with
__attribute__((target_clones("avx2","default"))): GCC emits both bodies and an ifunc resolver
picks the AVX2 one at load time on a CPU that has it. The portable binary now matches the native one on every
kernel in that file, and `simd[] tells you which body it chose.
The reductions also stop opening an OpenMP thread team above a million elements. With
OMP_NUM_THREADS unset — every ordinary user — that was fourteen threads summing 80 MB slower
than one core could. Multi-core work is peach; it always was.
The numbers
| operation (10M, one core, ms) | 2.0.1 | 2.1.0 | CBQN | C ref |
|---|---|---|---|---|
+/x*y | 14.1 | 6.8 | 6.8 | 8.2 |
+/(y+2.5*x)@&x>50 | 62.6 | 23.4 | 52.7 | 7.9 |
| group-sum, 100 groups | 109.6 | 20.9 | 62.7 | 43.9 |
| group-sum, 100k groups | 222.3 | 29.9 | 262.2 | 78.6 |
h in kr, 1,000 keys | 51.2 | 9.6 | 15.7 | 93.3 |
?g, 100k distinct | 180.8 | 20.1 | 45.4 | 67.8 |
| as-of join, 1M × 200k | 37.3 | 7.8 | — | 7.8 |
x@<x, float64, 1,000 distinct | 197.1 | 58.3 | 7.0 | 108.4 |
msum[16;x] | 43.2 | 16.3 | 28.2 | 11.1 |
select sum px by sym … where, 2M rows | 10.8 | 5.1 | — | 2.5 |
Same machine, same day, bench/scout, every answer bit-exact against the C reference.
The full 23 × 12 matrix is on the benchmarks page.
What is still CBQN's
Five operations: sum_f, max_f, sort_f, sort_presorted and
distinct. All five are the same fact. BQN has no user-visible float/int distinction, so CBQN stores
this dataset's 0..999 "float" values as 16-bit integers and streams a quarter of the bytes; its sort is a
counting sort over i16 storage. At equal width Amber's +/ is at the memory roofline and CBQN's
+´ on real doubles is a scalar loop. K types are visible — @x must say `F —
so matching those cells means a float vector whose payload is narrow, widened on demand. That is the
next architectural item, and it is the only one left that fusion cannot reach.
The fastest pass over an array is the one you never make.
Every change in 2.1.0 is that sentence applied somewhere specific. The engine is at
github.com/BonucciAndrea/amber;
the changelog lists every kernel and every behaviour difference, and the two differential scripts in
tests/ are how you check that a build of your own still says the same thing the last one did.