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.

Provenance

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.

FileWhat is in it
a.h, g.hThe macro vocabulary. Type tags, accessor macros (xF, zn, ztF), loop and error-propagation macros. Read these first or nothing else parses.
p.cParser, including Amber's temporal-literal scanner and ([]…) table literals.
b.c, e.cBytecode and the evaluator.
1.c, 2.cMonadic and dyadic element-wise primitives.
3.cReductions and scans. The / and \ adverb families and their per-type specialisations.
w.cAdverb dispatch — decides whether a reduce or scan takes a fast kernel or the generic boxed loop.
f.cFind, group and the attribute-aware search paths.
v.cVectorisation helpers and argmin/argmax.
a.cJoins, including the branch-free aj/wj lower-bound kernel.
simd.c, simd.hThe SIMD layer: AVX2, NEON and a scalar fallback behind one interface.
arena.cThe thread-local bump arena for kernel scratch.
parallel.c, peachpool.cOpenMP thresholds and the fork pool behind peach.
ser.c-8! / -9! binary serialisation.
ar.cArrow C Data Interface — zero-dependency interop.
ext.c, ext.hThe 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:

src/3.c — the reduce dispatcher
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.

Order-preserving float fold

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

SymptomLook here first
A primitive is slow on one type onlyThe dispatcher guard in 3.c / w.c — you are probably in a generic fallback.
Slow regardless of type, cost flat as data growsNot the engine. Your call count — see Writing fast Amber.
A cliff at a particular sizeThe 16 MB arena in arena.c, or PAR_THRESHOLD.
Wrong answers on edge valuesThe of1/of0 fold, or a fast path that skipped a NaN check.
You want a new domain kernelThe ext/ seam — no core changes, no fork.