Engine internals
Amber is a C99 interpreter descended from ngn/k, with a q 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 terse on purpose: 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 | The thread pool (par_run) and the parallel kernels; the pool behind peach. |
ser.c | Amber's own binary format, `ser / `des: what peach and column files use. |
qipc.c | q's wire format: -8! / -9!, hopen, \p and who may connect, the .z handlers, logs and -11!. |
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 with 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 keeps a pool of POSIX threads that stays up between calls.
par_run(t,fn,ctx) runs fn(ctx,i) for i in [0,t), with
i==0 on the calling thread, and everything parallel goes through it. AMBER_THREADS
sets the size (default: the online CPU count, cap 64). peach uses its own pool
(peachpool.c) and hands out 1,024-item morsels.
Threads inside primitives (2.6)
Each kernel has its own cut-off, so small inputs never pay for a hand-off:
| What | Where | From | How |
|---|---|---|---|
| Sort and grade | v.c | 32K | MSD split on the top key bits, then each bucket sorted LSD on its own thread; skewed data falls back per pass. |
Find, in | f.c | 32K probes | The table is built once, the probes are split. |
The aj match | a.c | 128K | Probes split the same way. |
Gather x@i | a.c | 64K | Index slices per thread. |
| Distinct of ints | f.c | 256K | First-index tables per slice, the minimum wins, results in first-appearance order. |
| Group aggregates | o.c | 64K rows | Per-thread tables merged in order. Float sum and avg add per 256K-row block, from 256K rows, up to 32K groups. |
| Float sum, dot | parallel.c | 1M | Fixed 64K blocks, block sums added in order: the same answer at any thread count. |
| Float max, min | parallel.c | 1M | Always the serial answer: a NaN or a zero result is redone serially. |
| Fused trees | b.c, 2.c | 2048 | 512-item blocks split over threads (see below). |
AMBER_THREADS=1 turns all of it off. Everything gives the one-thread answer, bit for bit,
except float sum and dot from 1M: their fixed blocks make the last digits differ from a plain
left-to-right sum, but never between thread counts.
The arena, the SIMD layer and the branch-free as-of join were built together, and down to the metal is the account of that, including the measurements that decided each threshold.
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.
Fusion of element-wise trees (2.6)
Inside a lambda, a tree of + - * % & | over variables and numbers (two operations or more,
< > = allowed at the root, +/ on top) compiles to bF j c p followed by the
usual code. At run time fzrun (src/2.c) takes it only when the leaves are float or int
vectors of 2048 items or more; it runs the tree 512 items at a time in small buffers that stay in cache, the
blocks split over threads, and jumps past the usual code. Floats go through the very kernel function the
unfused verb calls, ints keep the width the unfused result would have, so the bits match. Anything else falls
through to the usual code. tests/test_fusion_diff.k checks it against the unfused build.
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 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. |