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.
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×.
distinct_100kmembersort_presorteddistinctsum_igrade_igroup_100ksort_fmax_fgroup_10kgroup_100dotgroup_10sum_ffindmmax_64scan_fmsum_16asofmavg_256join_innertablesortarith_maskqsql_selectAmber is faster on 18 of 24 operations, slower on 6. Bars are log-scaled; the centre line is parity.
| operation | C | Ambernat | Amber | AmberqSQL | PeachQ | ngn/k | CBQN | J | NumPy | pandas | Polars | DuckDB |
|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Reductions & vector arithmetic | ||||||||||||
sum_fsum of 10M float64 | 5.21 | 3.93 | 4.63 | skip | 4.58 | 5.38 | 1.05 | 4.03 | 4.94 | 9.60 | 3.79 | 12.0 |
sum_isum of 10M integers 0..999 (engines differ in element width) | 4.23 | 0.809 | 0.801 | skip | 2.30 | 1.62 | 1.27 | 5.05 | 3.65 | 3.77 | 4.12 | 10.8 |
max_fmax of 10M float64 | 9.88 | 4.46 | 4.26 | skip | 4.68 | 12.5 | 1.18 | 4.43 | 3.91 | 13.0 | 4.91 | 35.8 |
dotdot product of two 10M float64 vectors | 8.44 | 6.24 | 5.85 | skip | 8.50 | 15.8 | 6.58 | 27.7 | 6.00 | 7.20 | 12.6 | 21.2 |
arith_mask(a*b)+c under a boolean mask | 9.89 | 15.2 | 14.5 | skip | 364 | 51.5 | 50.0 | 50.7 | 44.9 | 95.1 | 55.8 | 56.7 |
scan_frunning sum over 10M float64, materialised | 9.24 | 8.36 | 9.11 | skip | 30.9 | 207 | 9.41 | 21.7 | 23.4 | 40.9 | 32.5 | 1659 |
| Sort & grade | ||||||||||||
sort_fascending sort, 10M float64 from 1000 distinct values | 89.8 | 29.5 | 31.2 | skip | 930 | 521 | 6.31 | 5258 | 53.7 | 856 | 96.1 | 856 |
sort_presortedsort of already-sorted input (best case) | 109 | 7.73 | 8.82 | skip | 667 | 263 | 1.35 | 94.2 | 59.6 | 114 | 18.2 | 624 |
grade_igrade-up (argsort) of 10M int64 | 89.1 | 24.9 | 34.2 | skip | 40.9 | 123 | 34.6 | 71.8 | 615 | 770 | 254 | 23.2 |
tablesort3-column table sorted by two keys | 74.1 | 93.7 | 105 | skip | 1372 | skip | skip | skip | 207 | 118 | 438 | 384 |
| Search, distinct & group-by | ||||||||||||
findfirst index of a value in 10M elements | 14.6 | 11.7 | 13.4 | skip | 82.0 | 1153 | 18.2 | 52.2 | 127 | skip | skip | 37.8 |
membermembership of 10M against a 10M set | 203 | 9.38 | 10.6 | skip | 143 | 2037 | 15.6 | 31.9 | 53.5 | 70.6 | 30.8 | 43.2 |
distinctdistinct over ~10 groups | 33.3 | 3.81 | 5.25 | skip | 525 | 219 | 5.19 | 34.0 | 300 | 45.6 | 85.9 | 40.1 |
distinct_100kdistinct over 100k groups | 67.5 | 2.20 | 7.78 | skip | 1859 | 362 | 44.3 | 48.5 | 709 | 60.9 | 116 | 87.1 |
group_10group-by, 10 groups | 22.5 | 16.7 | 17.6 | 22.9 | 359 | 49.5 | 27.4 | 40.7 | 241 | 55.6 | 33.7 | 33.2 |
group_100group-by, 100 groups | 30.9 | 21.2 | 25.9 | 28.7 | 418 | 117 | 62.0 | 38.9 | 789 | 58.4 | 31.9 | 35.1 |
group_10kgroup-by, 10k groups | 53.2 | 26.6 | 28.0 | 31.3 | 1664 | 177 | 120 | 41.9 | 1093 | 120 | 316 | 92.1 |
group_100kgroup-by, 100k groups | 118 | 33.3 | 39.2 | 45.7 | 4196 | 439 | 241 | 60.1 | 704 | 133 | 355 | 142 |
| Joins | ||||||||||||
join_innerinner join on an int key | 2.64 | 2.91 | 2.87 | 6.11 | 11.4 | 141 | 2.14 | 6.58 | 22.3 | 53.4 | 11.2 | 7.22 |
asofas-of join, the tick-desk workload | 7.28 | 7.50 | 7.29 | skip | 216 | skip | skip | skip | 25.5 | 70.6 | 15.8 | 212 |
| Moving windows | ||||||||||||
msum_16moving sum, window 16 | 11.4 | 11.0 | 10.8 | skip | 416 | 256 | 28.7 | 56.2 | 122 | 122 | 49.6 | 1034 |
mavg_256moving average, window 256 | 11.6 | 12.6 | 13.1 | skip | 10257 | 259 | 50.6 | 95.5 | 142 | 129 | 53.3 | 1334 |
mmax_64moving max, window 64 | 16.3 | 13.8 | 15.0 | skip | 1989 | skip | skip | skip | 452 | 134 | 58.9 | 1471 |
| qSQL-shaped | ||||||||||||
qsql_selectselect ... by ... from - the full query path | 2.46 | 4.60 | 5.21 | 3.93 | 123 | skip | skip | skip | 46.5 | 16.2 | 12.1 | 9.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.
| operation | 100,000 | 1,000,000 | 10,000,000 | 100k→10M |
|---|---|---|---|---|
sum_f | 0.006 | 0.253 | 3.42 | ×569 |
sort_f | 0.135 | 2.32 | 29.3 | ×217 |
group_10k | 0.324 | 2.73 | 28.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.
| operation | Amber (ms) | best (ms) | by | headroom |
|---|---|---|---|---|
sort_presorted | 7.73 | 1.35 | CBQN | ×5.73 |
sort_f | 29.5 | 6.31 | CBQN | ×4.67 |
max_f | 4.46 | 1.18 | CBQN | ×3.78 |
sum_f | 3.93 | 1.05 | CBQN | ×3.75 |
qsql_select | 4.60 | 2.46 | C | ×1.87 |
arith_mask | 15.2 | 9.89 | C | ×1.54 |
join_inner | 2.91 | 2.14 | CBQN | ×1.36 |
tablesort | 93.7 | 74.1 | C | ×1.26 |
mavg_256 | 12.6 | 11.6 | C | ×1.08 |
grade_i | 24.9 | 23.2 | DuckDB | ×1.07 |
dot | 6.24 | 6.00 | NumPy | ×1.04 |
asof | 7.50 | 7.28 | C | ×1.03 |
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.
| Workload | C | Amber | Amber qSQL | ngn/k | CBQN | J | Uiua | NumPy | Julia | DuckDB |
|---|---|---|---|---|---|---|---|---|---|---|
Vector arithmetic + masksum((x*2.5)+y where x>50), 10M |
8.26 | 19.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.91 | 9.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.33 | 35.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.83 | 2.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.
| Engine | Peer group | Mode | Startup baseline |
|---|---|---|---|
| C (-O3) | baseline | kernel | — |
| Amber | array primitives | kernel | — |
| Amber qSQL | query layer | kernel | — |
| ngn/k | array primitives | net | 1.61 ms |
| CBQN | array primitives | kernel | 2.78 ms |
| J | array primitives | net | 44.61 ms |
| Uiua | array primitives | net | 9.57 ms |
| NumPy | array primitives | kernel | — |
| Julia | scalar loops (JIT) | kernel | — |
| DuckDB | query layer | kernel | 11.40 ms |
Two shortcuts that were taken out
+/!10000000is O(1) in Amber.arfinsrc/3.cfolds a sum over a range inton(n-1)/2, so the oldvecsumbenchmark “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-1every 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
| Universe | Cells | Amber | NumPy | NumPy ahead by |
|---|---|---|---|---|
| U = 25 | 0.54 M | 3 ms | 0.7 ms | 4.3× |
| U = 50 | 1.08 M | 6 ms | 1.8 ms | 3.3× |
| U = 100 | 2.16 M | 13 ms | 7.1 ms | 1.8× |
| U = 200 | 4.32 M | 28 ms | 14.6 ms | 1.9× |
| U = 400 | 8.64 M | 55 ms | 29.2 ms | 1.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:
| Observations | 6 | 12 | 24 | 48 | 96 | 120 |
|---|---|---|---|---|---|---|
| Amber µs | 1,040 | 1,164 | 1,230 | 1,391 | 1,900 | 2,388 |
| NumPy µs | 228 | 464 | 1,217 | 3,225 | 7,103 | 9,040 |
| Amber ÷ NumPy | 4.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.
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 doubles | 2.0.1 | 2.4.1 | Speedup |
|---|---|---|---|
maxs | 688 µs | 21 µs | 33× |
mins | 695 µs | 26 µs | 27× |
sums | 370 µs | 13 µs | 28× |
prds | 372 µs | 14 µs | 27× |
And the ones a quant writes all day, on a 1,000,000-point price path:
|\p32.3 ms → 1.7 ms
|/1-p%|\p33.7 ms → 2.5 ms
-1+*\1+r18.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 baskets | 2 threads | 4 threads | 14 threads |
|---|---|---|---|
serial each | 465 ms | 487 ms | 451 ms |
peach, one basket per task | 391 ms | 314 ms | 215 ms |
peach, 4 chunks | 366 ms | 311 ms | 299 ms |
peach, 14 chunks | 378 ms | 324 ms | 232 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:
| Rows | plain | sorted (`s) | Speedup |
|---|---|---|---|
| 100 k | 3.2 ms | 0.41 ms | 7× |
| 500 k | 16.8 ms | 0.94 ms | 17× |
| 2 M | 184 ms | 2.0 ms | 93× |
| 5 M | 827 ms | 2.9 ms | 288× |
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:
== 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-endWhat 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.
| Release | Change | Effect |
|---|---|---|
| 1.9.1 | the select … by … from layer groups and probes on raw column vectors instead of boxing one K object per row | group-by 24.7× inner join 19.3× |
| 1.9.2 | integer ? (find) builds an index over its left argument instead of scanning it per probe | 180.95 ms → 5.66 ms 32× |
| 1.9.2 | float +/ uses four independent accumulators so it vectorises; array payloads are cache-line aligned | 8.9 ms → 6.3 ms 1.41× |
| 1.9.3 | peach ships worker results over the -8! binary wire instead of formatting and reparsing text | 500k @ 4 threads ~23 ms |
| 1.9.5 | single-pass O(N) sliding windows + a key-carrying LSD radix sort | moving windows up to 273.9× multi-col sort 29.6× · as-of join 16.0× |
| 2.0.0 | table group-by keys on the interned 4-byte symbol id instead of a per-character string sort | 587 ms → 34 ms 17× |
| 2.1.0 | one-pass fused group aggregate (`gagg) behind gsum and select … by, 10M rows, 100k groups | 222 ms → 30 ms 7.4× |
| 2.1.0 | one-pass membership (`memb behind in), 10M values against 1,000 keys | 51 ms → 9.6 ms 5.3× |
| 2.1.0 | bitmap distinct, 10M values, 100k distinct | 181 ms → 20 ms 9.0× |
| 2.1.0 | direct-table slice pass in the as-of join, 1M trades × 200k quotes | 37 ms → 7.8 ms 4.8× |
| 2.1.0 | counting sort for integral-valued floats, no grade, no gather, 10M float64 | 197 ms → 58 ms 3.4× |
| 2.1.0 | compiler idiom fusion: +/x*y, +/x@&m, x@&m, a+s*b, 10M | dot 14.1 → 6.8 ms · mask 63 → 23 ms 2.1× / 2.7× |
| 2.1.0 | moving windows without the input copy or the per-call mapping: msum[16;x], 10M | 43 ms → 16 ms 2.7× |
| 2.1.0 | src/simd.c compiled twice (AVX2 + baseline) and selected at load time: the portable build matches the native one | — |
| 2.2.0 | keys-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 float64 | 304.7 ms → 119.4 ms 2.6× |
| 2.2.0 | the float range scan split from the integrality test, and floor() dropped from it. 10M float64, already sorted | 20.6 ms → 3.6 ms 5.8× |
| 2.2.0 | float distinct grows its hash with the distinct count instead of allocating 2n slots up front. 10M float64 | 26.7 ms → 13.6 ms 2.0× |
| 2.2.0 | compiler idiom fusion: +/(a±s*b)@&m in one masked pass, 10M | 20.1 ms → 13.3 ms 1.5× |
| 2.2.0 | select stops materialising the filtered table on the paths that ignore it: select from t, 10M rows | 5.7 ms → 0.7 ms 8.2× / 19× wide |
| 2.3.0 | integer distinct, 10M values (and with ~100k distinct) | 1.9× / 3.9× |
| 2.3.0 | sorting a float column of whole numbers that is already sorted skips a pass, 10M | 2.6× |
| 2.3.0 | mmax/mmin as van Herk/Gil-Werman, the same cost at any window; msum/mavg as blocked running sums | 2.3× at 64 / 1.5× at 16 |
| 2.3.0 | find on int64 keys; inner join | 1.4× / 1.2× |
| 2.4.0 | masked float sums without the fused kernel, equal to the unfused expression bit for bit: +/x@&x>50, 10M | 48.8 ms → 10.3 ms 4.7× |
| 2.4.0 | the same with arithmetic in it: +/((x*2.5)+y)@&x>50, 10M | 56.1 ms → 26.5 ms 2.1× |
| 2.4.0 | float find, 1,000 lookups in 1M floats | 0.109 ms → 0.061 ms 1.8× |
| 2.4.0 | tail calls reuse the frame: {$[x;o x-1;0]}1000000 | 'stack at 2048 → 66 ms |
| 2.4.1 | reverse 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.0 | cor in one C pass; prev/next on floats as a typed shift; differ as one compare | 3× / ~100× / 7–17× |
| 2.6.0 | find, 10M probes, on 14 threads (2.5.0 on one) | 14.5 ms → 3.2 ms 4.6× |
| 2.6.0 | x in y and int distinct, 10M, 14 threads | 9.3 → 2.8 ms · 4.3 → 1.3 ms 3.4× / 3.3× |
| 2.6.0 | float +/ in fixed 64K blocks over 14 threads, the same answer at any thread count, 10M | 4.2 ms → 1.4 ms 2.9× |
| 2.6.0 | three-column table sort, parallel radix sort, 10M rows, 14 threads | 89.5 ms → 48.4 ms 1.9× |
| 2.7.0 | loading 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.0 | select 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 row | 981 ms → 6 ms 160× |
Running them yourself
./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, sortsThe 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.