Engine internals
Amber is a C99 interpreter descended from ngn/k, with a q/kdb+ vocabulary layered over it and a set of native kernels underneath. This page is the map: what a value is, how a primitive finds its implementation, and which layer to change when something is slow.
The evaluator, parser and heap descend from ngn/k, which is why Amber is AGPLv3. The q layer, the join family, attributes, the temporal system, the finance kernels, the wire protocol and the C API are Amber's own. The five posts starting at the interpreter underneath are the long version of that story, in the order it was built.
The source map
The core is deliberately terse — single-letter translation units, a macro vocabulary that makes a kernel fit on one line. That is inherited style, and it is load-bearing: the whole evaluator fits in cache. Here is what lives where.
| File | What is in it |
|---|---|
a.h, g.h | The macro vocabulary. Type tags, accessor macros (xF, zn, ztF), loop and error-propagation macros. Read these first or nothing else parses. |
p.c | Parser, including Amber's temporal-literal scanner and ([]…) table literals. |
b.c, e.c | Bytecode and the evaluator. |
1.c, 2.c | Monadic and dyadic element-wise primitives. |
3.c | Reductions and scans. The / and \ adverb families and their per-type specialisations. |
w.c | Adverb dispatch — decides whether a reduce or scan takes a fast kernel or the generic boxed loop. |
f.c | Find, group and the attribute-aware search paths. |
v.c | Vectorisation helpers and argmin/argmax. |
a.c | Joins, including the branch-free aj/wj lower-bound kernel. |
simd.c, simd.h | The SIMD layer: AVX2, NEON and a scalar fallback behind one interface. |
arena.c | The thread-local bump arena for kernel scratch. |
parallel.c, peachpool.c | OpenMP thresholds and the fork pool behind peach. |
ser.c | -8! / -9! binary serialisation. |
ar.c | Arrow C Data Interface — zero-dependency interop. |
ext.c, ext.h | The extension seam for out-of-tree kernels. |
How a primitive gets specialised
This is the part worth understanding, because it explains most performance surprises. A primitive does not have one implementation — it has a dispatcher that picks one based on the argument types, and a generic fallback for everything the dispatcher does not recognise.
Take reduction. The dispatcher accepts integer, float and char data, then routes by verb:
A3(arf,/*010*/Q(xtv)Q(xv<11)Q(!y||ytzfc)Q(ztZFC)
...
G(&dexf,admf,subf,admf,___f,___f,mmmf,mmmf,___f,___f,___f)[xv](x,y,z))Each entry is a specialised kernel: admf is float add/multiply, mmmf is
float min/max with a SIMD fast path. ___f is the generic one — a loop that allocates an
atom per element and goes through the generic dyadic apply. Falling into ___f costs
roughly 50–100× a specialised kernel, and nothing warns you.
The scan dispatcher, ars, has the same shape. Until recently its type guard read
Q(ztZC) — integer and char, no float — which meant every maxs over doubles
fell into the generic path. Widening that guard and adding the two missing kernels was a twelve-line
change worth
15–36×. The lesson generalises: when a primitive is
inexplicably slow on one type, check the dispatcher's guard before you look anywhere else.
Float min/max does not compare IEEE doubles directly in the general path. It folds them into an
order-preserving integer domain (of1), runs the integer kernel, and folds back
(of0). Two extra linear passes buy exact, consistent NaN and signed-zero behaviour —
and mean a new float kernel can reuse the integer one instead of re-deriving the semantics.
The scratch arena
Kernels that need temporary space take it from a per-thread bump arena rather than
malloc. Allocation is a pointer increment; the whole arena is reset at the end of the
operation. arena_init(0) selects the default 16 MB slab.
That number is worth knowing because it is a cliff rather than a slope. A 17.3 MB intermediate does not cost 8% more than a 16 MB one — it falls off the arena onto the general allocator, and the measured tensor-build time in the benchmark table jumps roughly tenfold between U = 100 and U = 200 for exactly that reason. The capacity is a compile-time constant with no environment override today.
SIMD and parallelism
simd.c presents one interface — simd_sum_f64, simd_max_f64,
simd_min_i64 and friends — over AVX2, NEON and a portable scalar implementation, selected
at build time. Kernels call the interface; nothing above simd.h knows which backend it
got. ./amber reports the active one in its self-test.
Above that, parallel.c holds an element-count threshold (PAR_THRESHOLD,
100,000) below which an operation stays single-threaded because the OpenMP fork costs more than it
saves. peach is separate and coarser: it forks processes from a pool. Both have a
crossover, and on a small core count with large intermediates
it can sit above your workload entirely.
Multiversioned kernels and idiom fusion (2.1)
src/simd.c is compiled with __attribute__((target_clones("avx2","default"))) on
x86-64 ELF builds without -march: GCC emits an AVX2 body and a baseline body for every kernel and an
ifunc resolver picks one at load time, so the portable ./build.sh binary runs AVX2 wherever the CPU has
it and still starts on any x86-64. `simd[] reports vec256-mv when the AVX2 body was
selected. The compiler (src/b.c, fus()) recognises a handful of expression shapes —
+/x*y, +/x@&m, x@&m, a+s*b, x@<x — and emits
one fused primitive through verb slots 27–28 of the v1/v2/v8 tables, which have no source character
and are reachable only from that bytecode; each fused entry point falls back to the primitives it replaces for
any operand shape it does not handle. The one-pass group aggregate (`gagg, src/o.c) and
membership (`memb, src/f.c) are ordinary backtick builtins behind gsum and
in.
Attributes
One byte at offset −13 of every array carries the column attribute: `s
sorted, `u unique, `p parted, `g grouped. It is a promise, not
a description — the kernel believes it without checking, because checking costs exactly what believing
saves. Setting it is one store; reading it is one load from a cache line the kernel was fetching
anyway. Everything after that is kernel selection.
Full treatment, including what happens when the promise is false, is in one byte at offset −13.
Where to make a change
| Symptom | Look here first |
|---|---|
| A primitive is slow on one type only | The dispatcher guard in 3.c / w.c — you are probably in a generic fallback. |
| Slow regardless of type, cost flat as data grows | Not the engine. Your call count — see Writing fast Amber. |
| A cliff at a particular size | The 16 MB arena in arena.c, or PAR_THRESHOLD. |
| Wrong answers on edge values | The of1/of0 fold, or a fast path that skipped a NaN check. |
| You want a new domain kernel | The ext/ seam — no core changes, no fork. |