AmberPost 07
Building Amber on ngn/k  ·  Part 07 of 07
07

Two point one: the engine gets fused

A performance release with no new syntax to learn: every result is bit-identical to 2.0.1, the engine just stops writing things it never needed to write. The comparative matrix moved from four wins against CBQN to eleven.

v2.1.0 2026-09-14·11 min read ·Andrea Bonucci

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 gather

The 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.12.1.0CBQNC ref
+/x*y14.16.86.88.2
+/(y+2.5*x)@&x>5062.623.452.77.9
group-sum, 100 groups109.620.962.743.9
group-sum, 100k groups222.329.9262.278.6
h in kr, 1,000 keys51.29.615.793.3
?g, 100k distinct180.820.145.467.8
as-of join, 1M × 200k37.37.87.8
x@<x, float64, 1,000 distinct197.158.37.0108.4
msum[16;x]43.216.328.211.1
select sum px by sym … where, 2M rows10.85.12.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.

Part of Building Amber on ngn/k, a seven-part series on what I added to a K interpreter to turn it into a columnar engine for market data.