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.

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 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.

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.cThe thread pool (par_run) and the parallel kernels; the pool behind peach.
ser.cAmber's own binary format, `ser / `des: what peach and column files use.
qipc.cq's wire format: -8! / -9!, hopen, \p and who may connect, the .z handlers, logs and -11!.
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 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.

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 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:

WhatWhereFromHow
Sort and gradev.c32KMSD split on the top key bits, then each bucket sorted LSD on its own thread; skewed data falls back per pass.
Find, inf.c32K probesThe table is built once, the probes are split.
The aj matcha.c128KProbes split the same way.
Gather x@ia.c64KIndex slices per thread.
Distinct of intsf.c256KFirst-index tables per slice, the minimum wins, results in first-appearance order.
Group aggregateso.c64K rowsPer-thread tables merged in order. Float sum and avg add per 256K-row block, from 256K rows, up to 32K groups.
Float sum, dotparallel.c1MFixed 64K blocks, block sums added in order: the same answer at any thread count.
Float max, minparallel.c1MAlways the serial answer: a NaN or a zero result is redone serially.
Fused treesb.c, 2.c2048512-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

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.