Benchmarks

Everything here is Amber 2.4.1. Unless a section says otherwise it was measured on one laptop (Intel Core Ultra 7 255U, WSL2, one core per run) in one sitting, and every answer was checked before its time counted. 2.6 added threads inside primitives; these tables stay one core per engine, and what the threads buy is on the performance page.

18/24
operations faster than hand-written C
13/24
fastest of all 12 engines, one thread each
15/20
ahead of CBQN where both run the op
0
wrong answers: every cell is checked bit for bit
Correctness gate

Every answer is an integer that fits a float64 exactly, so the summation order can't change it. Each engine's answer is compared exactly against the C reference and a wrong one prints WRONG instead of a time; each engine also checksums its own input, so a generator slip shows up as BADDATA. You can't win a cell by computing something cheaper.

The engine matrix: 24 operations, 12 engines

The widest run there is: 24 operations against every array language and columnar engine that installs on one machine (PeachQ, ngn/k, CBQN, J, NumPy, pandas, Polars, DuckDB and a hand-written C reference), all on one thread. The spec is bench/scout/SCOUT_SPEC.md, the driver bench/scout/scout.py, and this block is generated straight from its results.json, so it can't drift from the repo.

Against the 2.2.0 run on the same laptop: distinct_100k 4.7× faster, sort_presorted 2.7×, mmax_64 2.1×, distinct 2.0×, find 1.6×, the moving sums 1.4–1.5× and sort_f 1.35×.

CPUIntel(R) Core(TM) Ultra 7 255U · 14 cores
SIMDavx avx2 bmi2 sse4_2
OSLinux-6.18.33.2-microsoft-standard-WSL2-x86_64-with-glibc2.39
RowsN = 10,000,000 · 5 timed runs, 2 warm-up
Amberbuild e8892b9 (2.4.1)
operationC fasterAmber fasterAmber / C
distinct_100k
30.66× faster2.20 / 67.5 ms
member
21.61× faster9.38 / 203 ms
sort_presorted
14.07× faster7.73 / 109 ms
distinct
8.75× faster3.81 / 33.3 ms
sum_i
5.23× faster0.809 / 4.23 ms
grade_i
3.58× faster24.9 / 89.1 ms
group_100k
3.54× faster33.3 / 118 ms
sort_f
3.05× faster29.5 / 89.8 ms
max_f
2.21× faster4.46 / 9.88 ms
group_10k
2.00× faster26.6 / 53.2 ms
group_100
1.46× faster21.2 / 30.9 ms
dot
1.35× faster6.24 / 8.44 ms
group_10
1.35× faster16.7 / 22.5 ms
sum_f
1.33× faster3.93 / 5.21 ms
find
1.25× faster11.7 / 14.6 ms
mmax_64
1.19× faster13.8 / 16.3 ms
scan_f
1.11× faster8.36 / 9.24 ms
msum_16
1.04× faster11.0 / 11.4 ms
asof
1.03× slower7.50 / 7.28 ms
mavg_256
1.08× slower12.6 / 11.6 ms
join_inner
1.10× slower2.91 / 2.64 ms
tablesort
1.26× slower93.7 / 74.1 ms
arith_mask
1.54× slower15.2 / 9.89 ms
qsql_select
1.87× slower4.60 / 2.46 ms

Amber is faster on 18 of 24 operations, slower on 6. Bars are log-scaled; the centre line is parity.

operationCAmbernatAmberAmberqSQLPeachQngn/kCBQNJNumPypandasPolarsDuckDB
Reductions & vector arithmetic
sum_fsum of 10M float645.213.934.63skip4.585.381.054.034.949.603.7912.0
sum_isum of 10M integers 0..999 (engines differ in element width)4.230.8090.801skip2.301.621.275.053.653.774.1210.8
max_fmax of 10M float649.884.464.26skip4.6812.51.184.433.9113.04.9135.8
dotdot product of two 10M float64 vectors8.446.245.85skip8.5015.86.5827.76.007.2012.621.2
arith_mask(a*b)+c under a boolean mask9.8915.214.5skip36451.550.050.744.995.155.856.7
scan_frunning sum over 10M float64, materialised9.248.369.11skip30.92079.4121.723.440.932.51659
Sort & grade
sort_fascending sort, 10M float64 from 1000 distinct values89.829.531.2skip9305216.31525853.785696.1856
sort_presortedsort of already-sorted input (best case)1097.738.82skip6672631.3594.259.611418.2624
grade_igrade-up (argsort) of 10M int6489.124.934.2skip40.912334.671.861577025423.2
tablesort3-column table sorted by two keys74.193.7105skip1372skipskipskip207118438384
Search, distinct & group-by
findfirst index of a value in 10M elements14.611.713.4skip82.0115318.252.2127skipskip37.8
membermembership of 10M against a 10M set2039.3810.6skip143203715.631.953.570.630.843.2
distinctdistinct over ~10 groups33.33.815.25skip5252195.1934.030045.685.940.1
distinct_100kdistinct over 100k groups67.52.207.78skip185936244.348.570960.911687.1
group_10group-by, 10 groups22.516.717.622.935949.527.440.724155.633.733.2
group_100group-by, 100 groups30.921.225.928.741811762.038.978958.431.935.1
group_10kgroup-by, 10k groups53.226.628.031.3166417712041.9109312031692.1
group_100kgroup-by, 100k groups11833.339.245.7419643924160.1704133355142
Joins
join_innerinner join on an int key2.642.912.876.1111.41412.146.5822.353.411.27.22
asofas-of join, the tick-desk workload7.287.507.29skip216skipskipskip25.570.615.8212
Moving windows
msum_16moving sum, window 1611.411.010.8skip41625628.756.212212249.61034
mavg_256moving average, window 25611.612.613.1skip1025725950.695.514212953.31334
mmax_64moving max, window 6416.313.815.0skip1989skipskipskip45213458.91471
qSQL-shaped
qsql_selectselect ... by ... from - the full query path2.464.605.213.93123skipskipskip46.516.212.19.05

Milliseconds, lower is better. Amber-coloured is the fastest single-threaded engine in that row. skip means the engine has no faithful way to express the operation under SCOUT_SPEC.md, not that it failed.

operation100,0001,000,00010,000,000100k→10M
sum_f0.0060.2533.42×569
sort_f0.1352.3229.3×217
group_10k0.3242.7328.1×87

Amber (native build), milliseconds. A 100× growth for a 100× row count is linear; below 100× is superlinear cache behaviour paying off, above it is the algorithm's log factor or a cache cliff.

operationAmber (ms)best (ms)byheadroom
sort_presorted7.731.35CBQN×5.73
sort_f29.56.31CBQN×4.67
max_f4.461.18CBQN×3.78
sum_f3.931.05CBQN×3.75
qsql_select4.602.46C×1.87
arith_mask15.29.89C×1.54
join_inner2.912.14CBQN×1.36
tablesort93.774.1C×1.26
mavg_25612.611.6C×1.08
grade_i24.923.2DuckDB×1.07
dot6.246.00NumPy×1.04
asof7.507.28C×1.03
Two rows moved the wrong way

group_10k and group_100k are 16% and 28% slower than the 2.2.0 run (26.6 vs 22.5 ms, 33.3 vs 23.9 ms). Haven't pinned it down yet, so it stays up here until I do.

Where CBQN still wins, and why

BQN has no float/int split, so CBQN keeps this dataset's 0..999 “floats” as 16-bit ints and streams a quarter of the bytes on sum_f, max_f, sort_f and sort_presorted. That's its number model more than its kernels. join_inner is a real CBQN win (2.1 vs 2.9 ms).

Two rows that flatter Amber

sum_i: Amber stores these 0..999 ints in 2 bytes each, C and NumPy read real int64. Shift the same vector past 232 so Amber has to use 8 bytes too and +/ takes 3.3 ms, which is the honest 64-bit number.

sort_f: 10M “floats” with only 1000 distinct whole values is exactly what the counting sort picks up. On random float64 the same build takes ~291 ms. Prices are whole ticks, so the fast path fires on real data too, but if you're comparing sort algorithms use the second number.

The CI table

Four workloads, ten engines, re-run by CI on every push (bench/run_comparative.py, spec in bench/SPEC.md). This one runs on a small 2-core cloud box, so it doesn't line up with the laptop numbers above. Median of 5 runs after 2 warm-ups, kernel time only, ms; the small number under each is the multiple of C.

WorkloadCAmberAmber qSQLngn/kCBQNJUiuaNumPyJuliaDuckDB
Vector arithmetic + masksum((x*2.5)+y where x>50), 10M 8.2619.992.42×37.984.60×255.5630.9×31.823.85×109.7013.3×801.4397.0×27.263.30×7.820.95×22.002.66×
Reductionssum + max + dot, 10M 21.919.220.42×17.010.78×236.7110.8×3.960.18×101.074.61×743.7234.0×8.760.40×21.931.00×29.001.32×
Group-by100 groups over 10M rows 6.3335.065.54×40.536.40×279.3444.1×29.654.68×137.8821.8×1,566.32247×11.531.82×5.430.86×14.002.21×
Inner join1M rows against 1,000 sparse keys 0.832.412.90×5.136.18×364.35439×1.802.17×90.85109×—11.3213.6×9.9211.9×9.0010.8×

Amber is in there twice on purpose: Amber is array-primitive code, the fair peer of ngn/k, CBQN, J and Uiua; Amber qSQL goes through select … by … from, the fair peer of DuckDB's planner. The gap between the two rows is the query layer's cost.

How each engine is timed

kernel: the engine times its own kernel with a monotonic clock. net: no usable clock inside the language, so it's total process time minus a measured startup baseline.

EnginePeer groupModeStartup baseline
C (-O3)baselinekernel—
Amberarray primitiveskernel—
Amber qSQLquery layerkernel—
ngn/karray primitivesnet1.61 ms
CBQNarray primitiveskernel2.78 ms
Jarray primitivesnet44.61 ms
Uiuaarray primitivesnet9.57 ms
NumPyarray primitiveskernel—
Juliascalar loops (JIT)kernel—
DuckDBquery layerkernel11.40 ms
Two shortcuts that were taken out
  • +/!10000000 is O(1) in Amber. arf in src/3.c folds a sum over a range into n(n-1)/2, so the old vecsum benchmark “won” by never touching the 10M elements. All data is materialised before the clock starts now.
  • A dense-key join is just an index. With right keys 0..K-1 every array language answers with one gather while DuckDB still builds a hash table. Right keys are sparse and unsorted now, so everyone does a real lookup.

Measured by someone else

Everything else on this page is Amber measuring itself against a spec in this repo. The Array Language Benchmarks Game isn't: its author picks the problems, writes the rules, runs every entry on their own machine (an i5-1135g7, 2 GB cap) and publishes the table. Amber has four entries there next to BQN, growler/k, Goal, q, Kap and C: spectralnorm and collatz in the current round, knucleotide and nbody in round 1.

The figures aren't copied here because the round is open and they move every week; read them on the game's own site, next to every entry's source. There's no restriction on algorithms, so a row compares two programs at least as much as two languages.

Against NumPy, on a real workload

One application kernel instead of a synthetic suite: a structured-payoff backtest, against a line-for-line NumPy port of the same algorithm (same tensor, same three scans, np.maximum.accumulate where Amber uses maxs). NumPy 2.5.2, both on one core, warm, best of repeated runs. It isn't flattering everywhere, which is the point.

Building the tensor: NumPy wins

UniverseCellsAmberNumPyNumPy ahead by
U = 250.54 M3 ms0.7 ms4.3×
U = 501.08 M6 ms1.8 ms3.3×
U = 1002.16 M13 ms7.1 ms1.8×
U = 2004.32 M28 ms14.6 ms1.9×
U = 4008.64 M55 ms29.2 ms1.9×

S = 1800 start dates, n = 12 observations. The tensor at U = 100 is 17.3 MB and the U = 400 price history 14.7 MB. The first build after a cold start costs 2–4× a warm one, so build it when a session opens, not on the first query.

Running a product: Amber wins once there are enough observations

Amber pays a fixed cost per operation (allocate, dispatch) that barely moves as the data inside it grows; NumPy's per-operation cost is tiny, so its time tracks the data. Time per basket:

Observations612244896120
Amber µs1,0401,1641,2301,3911,9002,388
NumPy µs2284641,2173,2257,1039,040
Amber ÷ NumPy4.6×2.5×1.0×0.43×0.27×0.26×

Amber's cost goes up 2.3× while the data grows 20×; NumPy's goes up 40×. They cross at about 24 observations (it was ~43 a few releases ago). A three-year quarterly note has 12 and belongs to NumPy; a ten-year monthly one has 120, where Amber is 3.8× faster.

Read it the right way round

Both halves are the same code. Amber's cost is about how many primitives you call, not how much data each one touches. Writing fast Amber turns that into a rule you can apply.

Float scans

Up to 2.0 every float +\, *\, &\ and |\ (so sums, prds, mins, maxs on doubles) went through a generic loop that boxed an atom per element. Since 2.1 they're native. Same laptop, 2.0.1 against 2.4.1, best of 7:

21,600 doubles2.0.12.4.1Speedup
maxs688 µs21 µs33×
mins695 µs26 µs27×
sums370 µs13 µs28×
prds372 µs14 µs27×

And the ones a quant writes all day, on a 1,000,000-point price path:

19×
running max |\p
32.3 ms → 1.7 ms
14×
max drawdown |/1-p%|\p
33.7 ms → 2.5 ms
11×
cumulative return -1+*\1+r
18.3 ms → 1.6 ms

Min and max go through the same order-preserving integer domain as the reductions, so a scan and a reduce agree bit for bit on NaN and ±0.0.

Multicore: what peach buys you

A basket sweep is embarrassingly parallel. peach is a persistent thread pool now (no fork, no copying the tensor to a child), so even one basket per task pays off. 400 baskets through the payoff kernel above, best of 5:

400 baskets2 threads4 threads14 threads
serial each465 ms487 ms451 ms
peach, one basket per task391 ms314 ms215 ms
peach, 4 chunks366 ms311 ms299 ms
peach, 14 chunks378 ms324 ms232 ms

Same 400 answers in every configuration. It tops out around 2.1× on this laptop (2 performance cores, the rest efficiency cores); on heavier per-item work it scales further, e.g. {avg x?1.0} over 8 × 800k draws goes from 25.1 ms to 6.2 ms on 8 threads (4.0×).

Sorted attributes

bench.k times 5,000 lookups with ? (find) on the same column, with and without the sorted attribute. Without it, find builds a hash of the whole column every call; with it (asc sets it) it's a binary search per lookup. Same answers, very different times:

Rowsplainsorted (`s)Speedup
100 k3.2 ms0.41 ms7×
500 k16.8 ms0.94 ms17×
2 M184 ms2.0 ms93×
5 M827 ms2.9 ms288×

A real HFT run

demo/hft_demo.k on 500,000 trades and a million quotes: generate the tape, VWAP per symbol, a 50-period EMA and an as-of join of every trade to its quote. The script prints this itself:

./amber demo/hft_demo.k
== benchmark summary ========================================
stage      ms
gentq   418.7
vwap     10.2
ema       0.1
asof     15.0
------------------------------------------------------------
total: 444.1 ms for 500000 trades end-to-end

What each release moved

Each row is a before/after of one change, measured on the machine of its day, so read rows on their own, not against each other. The full tables are in docs/BENCHMARKS.md.

ReleaseChangeEffect
1.9.1the select … by … from layer groups and probes on raw column vectors instead of boxing one K object per rowgroup-by 24.7×
inner join 19.3×
1.9.2integer ? (find) builds an index over its left argument instead of scanning it per probe180.95 ms → 5.66 ms
32×
1.9.2float +/ uses four independent accumulators so it vectorises; array payloads are cache-line aligned8.9 ms → 6.3 ms
1.41×
1.9.3peach ships worker results over the -8! binary wire instead of formatting and reparsing text500k @ 4 threads
~23 ms
1.9.5single-pass O(N) sliding windows + a key-carrying LSD radix sortmoving windows up to 273.9×
multi-col sort 29.6× · as-of join 16.0×
2.0.0table group-by keys on the interned 4-byte symbol id instead of a per-character string sort587 ms → 34 ms
17×
2.1.0one-pass fused group aggregate (`gagg) behind gsum and select … by, 10M rows, 100k groups222 ms → 30 ms
7.4×
2.1.0one-pass membership (`memb behind in), 10M values against 1,000 keys51 ms → 9.6 ms
5.3×
2.1.0bitmap distinct, 10M values, 100k distinct181 ms → 20 ms
9.0×
2.1.0direct-table slice pass in the as-of join, 1M trades × 200k quotes37 ms → 7.8 ms
4.8×
2.1.0counting sort for integral-valued floats, no grade, no gather, 10M float64197 ms → 58 ms
3.4×
2.1.0compiler idiom fusion: +/x*y, +/x@&m, x@&m, a+s*b, 10Mdot 14.1 → 6.8 ms · mask 63 → 23 ms
2.1× / 2.7×
2.1.0moving windows without the input copy or the per-call mapping: msum[16;x], 10M43 ms → 16 ms
2.7×
2.1.0src/simd.c compiled twice (AVX2 + baseline) and selected at load time: the portable build matches the native one—
2.2.0keys-only radix sort: the order-preserving radix key is a bijection, so the permutation is applied to the keys and unfolded rather than gathering the input. 10M non-integral float64304.7 ms → 119.4 ms
2.6×
2.2.0the float range scan split from the integrality test, and floor() dropped from it. 10M float64, already sorted20.6 ms → 3.6 ms
5.8×
2.2.0float distinct grows its hash with the distinct count instead of allocating 2n slots up front. 10M float6426.7 ms → 13.6 ms
2.0×
2.2.0compiler idiom fusion: +/(a±s*b)@&m in one masked pass, 10M20.1 ms → 13.3 ms
1.5×
2.2.0select stops materialising the filtered table on the paths that ignore it: select from t, 10M rows5.7 ms → 0.7 ms
8.2× / 19× wide
2.3.0integer distinct, 10M values (and with ~100k distinct)1.9× / 3.9×
2.3.0sorting a float column of whole numbers that is already sorted skips a pass, 10M2.6×
2.3.0mmax/mmin as van Herk/Gil-Werman, the same cost at any window; msum/mavg as blocked running sums2.3× at 64 / 1.5× at 16
2.3.0find on int64 keys; inner join1.4× / 1.2×
2.4.0masked float sums without the fused kernel, equal to the unfused expression bit for bit: +/x@&x>50, 10M48.8 ms → 10.3 ms
4.7×
2.4.0the same with arithmetic in it: +/((x*2.5)+y)@&x>50, 10M56.1 ms → 26.5 ms
2.1×
2.4.0float find, 1,000 lookups in 1M floats0.109 ms → 0.061 ms
1.8×
2.4.0tail calls reuse the frame: {$[x;o x-1;0]}1000000'stack at 2048 → 66 ms
2.4.1reverse as one copy, 8 bytes at a time; 0 :':x and m!x (power of two) in one pass. The bignum collatz loop, 218~300 ms → ~255 ms
1.18×
2.5.0cor in one C pass; prev/next on floats as a typed shift; differ as one compare3× / ~100× / 7–17×
2.6.0find, 10M probes, on 14 threads (2.5.0 on one)14.5 ms → 3.2 ms
4.6×
2.6.0x in y and int distinct, 10M, 14 threads9.3 → 2.8 ms · 4.3 → 1.3 ms
3.4× / 3.3×
2.6.0float +/ in fixed 64K blocks over 14 threads, the same answer at any thread count, 10M4.2 ms → 1.4 ms
2.9×
2.6.0three-column table sort, parallel radix sort, 10M rows, 14 threads89.5 ms → 48.4 ms
1.9×
2.7.0loading a splayed table, 10M rows × 4 columns: binary column files mapped into memory (they used to be Amber text read back with eval)~6 ms
one mmap per column
2.7.0select sum sz by date from trade over 10 date partitions of 1M rows: run once per partition instead of building the date column row by row981 ms → 6 ms
160×

Running them yourself

shell
./amber bench.k                        # sorted attributes vs plain find
./amber demo/hft_demo.k < /dev/null    # the HFT run (edit N at the top)
python3 bench/scout/scout.py --build    # the 24 x 12 engine matrix
python3 bench/run_comparative.py        # the ten-engine CI table
bash bench/run_suite.sh                 # vs numpy, pandas, Polars, DuckDB
./amber bench-std.k                     # moving windows, sorts

The per-engine query files are in bench/queries/, separately tuned scripts rather than one file reused, and each amber_*.k says in its header what was tried and why.