AmberPost 04
Building Amber on ngn/k  ·  Part 04 of 05
04

Down to the metal: temporal types, SIMD, the arena, and a branch-free as-of join

The point where Amber stopped being a library written in K and started being an engine written in C. Four releases, five kernels, and one 16 MB bump allocator that exists purely to keep malloc out of the tick path.

v1.6 – v1.9 2026-05-19·22 min read ·Andrea Bonucci

By 1.5 Amber was a q vocabulary written in K, running on someone else's excellent interpreter. It was correct, it was pleasant to use, and on ten million rows it was not fast enough to be interesting. The next four releases are where that changed — where the parts of the system that a tick session actually spends its time in stopped being K and became C.

Five pieces, roughly in the order they landed: real temporal types, a SIMD kernel library, a multithreaded vector engine, a bump arena, and a branch-free as-of join. Plus one thing I removed, which I think is the more useful story.

1.7 — temporal types that are actually types

Since 1.1 Amber had temporal functions: hms, stime, minbar, xbar, all operating on integers by convention. A time was "milliseconds since midnight, if everyone agrees". Nothing enforced it, nothing displayed it, and — as post two describes — storing a formatted time instead of a numeric one silently broke every join.

1.7 made date, time and timestamp first-class C-level types with their own type tags. That meant touching five files:

FileWhat changed
a.hthe type enum, the type tables, and the TU/TP macros that classify a tag
p.cthe literal scanner — 2026.07.30, 10:00:05.000, 2026.07.30D09:30:00.000000000 parse directly
s.cthe formatter — a value auto-displays in its own format
2.ctype-aware arithmetic: date - date is days, date + n is a date, time + time is a time
c.cthe casts: "D"$, "T"$, "P"$, and `i$ to get the raw number back
amber>
2026.07.30                          / date
10:00:00.000 + 00:00:05.000         / 10:00:05.000
2026.08.15 - 2026.07.30             / 16  (days)
2026.07.30D09:30:00.000000000       / timestamp: ns since 2000.01.01
year 2026.07.30                     / 2026
`i$2026.07.30                       / 9709 — the raw value, when you want it

The crucial decision, and the one I would make again: columns keep numeric storage, exactly as kdb does internally. The type tag changes how a value parses, prints and combines. It does not change the bytes.

So xasc still works on a time column. The `s attribute still applies. The as-of join kernel still binary-searches raw int64 nanoseconds without knowing or caring that they are timestamps. A temporal type that had introduced a boxed representation would have been prettier and would have cost every single one of those.

The epoch

Dates count days from 2000-01-01; timestamps count nanoseconds from the same instant; a time is milliseconds since midnight and carries no date at all. Same convention as kdb+, and it buys thirty years at the end of the int64 range that matters — 1707–2292 instead of Unix nanoseconds' 1677–2262. It also means every integration has to convert explicitly, and none of them do it silently, because a quiet thirty-year shift is the worst class of bug there is: it does not crash, it produces a chart that looks fine.

1.7 — the first C kernels

Three functions moved out of K in the same release, chosen because profiling said so, not because they looked slow.

wj, the window join (wjc in i.c) was an interpreted per-row loop. It became a vectorised binary-search range probe per row plus a contiguous slice sweep for the standard reducers — first last min max sum avg count. About 2× in a throttled sandbox, more on real cores, and bit-exact against the interpreted version for the non-floating reducers. Arbitrary aggregators still fall back to wjK, because supporting "any function" means calling back into the interpreter and there is no point pretending otherwise.

ema was a per-element scan; it became a single O(n) C sweep, verified identical.

The Arrow C Data Interface arrived here too — src/ar.c, arrow.export and arrow.import, about ninety lines with no libarrow linkage. Export is zero-copy: the child buffers[1] pointers alias Amber's column payloads directly, and the release callback drops the Amber refcount. Import copies, because Amber's inline object header precludes adopting a foreign buffer — the asymmetry I described in post one, showing up for the first time as a user-visible property.

1.6 — peach, and choosing processes over threads

peach[f;y] applies f to the items of y in parallel. The implementation forks AMBER_THREADS worker processes, each applies f to a slice, serialises its result down a pipe, and the parent deserialises and concatenates.

Fork, not threads. That is a real decision and it has real consequences.

Fork (chosen)Shared-memory threads
Data racesNone possible — copy-on-write heap per workerEvery refcount becomes a contended atomic
Cost to serial codeZeroAn atomic increment on every single value, forever
Cost to parallel codeA serialise + pipe + deserialise per chunkNothing
PrecedentHow kdb+ gets multi-core

The trade is explicit: peach pays a per-chunk transfer cost so that single-threaded code pays nothing. That makes it excellent for coarse-grained, compute-heavy per-item work and pointless for a cheap reduction, where the fork overhead dominates. examples/peach.k exists to let you find the crossover on your own hardware rather than take my word for it.

Worker count defaults to the online CPU count, detected via sysconf — not a hardcoded 4, which is what it was originally and which either oversubscribed a small box or left a big one idle. AMBER_THREADS=1 forces serial.

1.9 — SIMD, and the ladder above it

The vector kernels are src/simd.{h,c}: simd_add_i64/f64, simd_mul_i64/f64, simd_sum_i64/f64, operating on plain int64_t* and double* arrays. Three backends — AVX2 via <immintrin.h>, NEON via <arm_neon.h>, and a scalar C99 fallback — selected at compile time.

Above them sits src/parallel.{h,c}, which splits arrays above PAR_THRESHOLD (100,000 elements) into one contiguous chunk per POSIX thread and hands each chunk to the SIMD kernel. Below the threshold it calls the kernel directly with no thread overhead, because for a small array the pthread_create is the cost.

a + b two float64 vectors n < PAR_THRESHOLD? yes — under 100,000 no parallel.c — one chunk / thread pthread_create × AMBER_THREADS simd.c — simd_add_f64 / simd_mul_f64 / simd_sum_f64 plain double* in, double* out. no K objects, no refcounts, no branches on type. BACKEND — SELECTED AT COMPILE TIME, REPORTED AT RUN TIME BY `simd 0 AVX2 <immintrin.h> · x86_64 · 4 doubles/op NEON <arm_neon.h> · aarch64 · 2 doubles/op scalar portable C99 · always built Below the threshold the thread pool is never touched: for a small array the pthread_create is the cost.
Three layers, one decision each: is it big enough to thread, and which kernel was compiled in. Neither question involves the interpreter.

Both layers self-report, which matters more than it sounds when the answer to "why is this slow" is often "you are on the scalar path":

amber>
amber> `simd 0
simd: backend=scalar n=400009 simd_add=1.44ms scalar_add=1.98ms ok=1
1
amber> \\                       (rebuilt with AMBER_NATIVE=1)
simd: backend=avx2 n=400009 simd_add=1.51ms scalar_add=1.76ms ok=1
One thing I have not verified on hardware

The NEON path was written against the real ARM64 intrinsics and reviewed carefully, but the machine I developed on has no ARM cross-compiler, so it has never been executed on Apple Silicon. That is in the README too. A benchmark table with an unqualified ARM column would have been a claim I could not support, and the fix is to say so, not to quietly not mention it.

The build flag that has to be probed

AMBER_NATIVE=1 asks for a machine-tuned build. The obvious implementation — -march=native — is x86 syntax that Apple's clang rejects outright on Apple Silicon, so the obvious implementation breaks every arm64 CI runner. build.sh probes: it tries -march=native, falls back to -mcpu=native on aarch64, and falls back again to a portable build. So the flag succeeds everywhere rather than being a platform-specific landmine.

1.9 — the arena

Evaluating an expression produces transient buffers: an index vector, a mask, a scratch array for a kernel. In a refcounted heap each of those is a malloc and a free, and the allocator's tail latency lands directly in your tick path.

src/arena.{h,c} is a thread-local 16 MB bump allocator. Reserved at startup, rewound once per evaluation cycle. Allocation is a pointer increment; freeing is not a thing that happens. There is a leak-free overflow path for the rare allocation that exceeds the slab, and a self-test builtin (`arn 0) that exercises bump, reset and the overflow path — asserted by test.k, because an allocator nobody tests is an allocator.

The point is not throughput. It is jitter. A general allocator is fast on average and occasionally is not, and "occasionally" in a per-tick loop is a tail latency number somebody will ask about.

Alignment, and a measurement that surprised me

The arena originally used plain malloc and 16-byte alignment; SIMD wanted 32, so it moved to posix_memalign. Later I probed the main heap and found something worse: HD, the array header size, was 32 bytes, which put every payload pointer exactly 32 bytes past a 64-byte boundary — ptr % 64 == 32 for every allocation size — splitting a cache line on the first wide access of every array in the system. Moving HD to 64 fixed it, and because that touches the core buddy allocator it was re-validated under AddressSanitizer and UndefinedBehaviorSanitizer across every suite plus the fuzzer.

1.9 — the as-of join, in C

This is the one the whole engine is for. aj matches each trade to the most recent quote at or before it, per symbol. In 1.0 it was a scalar search per row. In 1.5 it became one vectorised bin per group (~6×). In 1.9 it became a kernel.

QUOTES — SORTED BY (sym, time), `p ON sym, `s ON time AAPLrows 0 … 41,203 GOOGrows 41,204 … 92,880 MSFTrows 92,881 … each symbol owns ONE contiguous slice — that is what `p buys, and it is why the kernel never scans for group boundaries ONE TRADE GOOG @ 34,201,118,402,000 ajlb() — branch-free lower_bound over the GOOG slice THE PROBE lo hi mid k[v < xl] = i — the comparison lowers to a cmov. No data-dependent branch, so nothing to mispredict. 5,000,000 trades against 10,000,000 quotes: 704 ms. The scratch match vector comes from the arena, so the loop never calls malloc.
Sorted by (sym, time), each symbol owns one contiguous slice. The probe is a lower_bound whose comparison lowers to a cmov, so there is no data-dependent branch to mispredict.

Three properties, and they compound:

Branch-free

A textbook binary search branches on the comparison, and a binary search over random keys mispredicts roughly half the time. Five million probes × half a mispredict × ~15 cycles of pipeline flush is the entire runtime. The kernel instead writes k[v < xl] = i — indexing a two-slot array rather than jumping — which the compiler lowers to a conditional move. Nothing to predict, nothing to flush.

It is allowed to assume the layout

The kernel needs each symbol's rows to be contiguous and its times non-decreasing. Proving that is a pass over the data. Instead the marshalling layer checks whether the grouping column already carries `s or `p and, if it does, passes a flag that tells the kernel to skip the proof. That is post three cashing in.

src/a.c
// Optional third argument: the caller has established from the COLUMN
// ATTRIBUTES (`s sorted / `p parted, see _at() in a.h) that the group keys are
// already confined to contiguous runs. That is precisely what pass 3 below
// spends its time proving, so an attributed column lets us skip it outright --
// the whole point of carrying an attribute is not having to re-derive it.
B trust = _n(x)==3 && _t(e[2])!=tA && _v(e[2])!=0;

The type switch is hoisted out of the row loop

The group-boundary pass has to compare adjacent elements of every grouping column, and those columns can be any integer width or a symbol. The naive shape puts a type dispatch inside the row loop. This one puts the loop inside a macro and the macro inside the switch: one dispatch per column, not one per element.

src/a.c
#define AJS_NE(T) {CO T*RES p=_V(c); for(U r=1;r<n;r++) chg[r] |= (UC)(p[r]!=p[r-1]);}
F(ng, A c=gc[i];
  switch(_t(c)){
   case tG: case tC: AJS_NE(G) break;
   case tH:          AJS_NE(H) break;
   case tI: case tS: AJS_NE(I) break;   // tS stores packed 32-bit ids
   case tL:          AJS_NE(L) break;
   case tF:          AJS_NE(F) break;
   default: return x(al(0));            // unknown type: sort, don't guess
  })

Note the default: an unrecognised column type does not get a guess, it gets the slow-but-correct path. That is the rule everywhere in this layer — a fast path that is occasionally wrong is worse than no fast path.

Result: 5,000,000 trades against 10,000,000 quotes in 704 ms. The scratch match vector comes from the arena, so the loop never touches malloc. The pure-K reference, ajmK, is still in the tree — a differential test against a slow implementation you trust is worth far more than the disk it uses.

1.9 — the disassembler, and the honest deviation

The brief I set myself for this release included "add a compiler and a bytecode VM". I did not, and the reason is worth stating because it is the most useful thing in this post.

b.c already is the real compiler and VM. ngn/k compiles every expression to a flat opcode array plus a constant pool and runs it on a stack machine (cr(), cpl(), run()); the AST is never walked at eval time. Adding a second, disconnected VM would have produced a demo that ran alongside the engine and told you nothing about it.

So src/vm.{h,c} is a disassembler instead. It mirrors b.c's real opcode table byte for byte and decodes the actual bytecode Amber produces, with a self-consistency check — the decode loop must consume exactly the bytecode length.

amber> \disasm (1+2)*3-4
locals (0):
constants (2):
  #0  -1
  #1  3
bytecode (6 bytes):
    0  MONAD    1
    1  CONST    #0
    2  CONSTDYAD const#1 dyad=3
    5  MONAD    0

The constant pool holds 3 and -1, not 1, 2, 3, 4: the compiler constant-folded 1+2 and 3-4 before emitting. That is precisely the kind of fact a disassembler for the real VM surfaces and a from-scratch reimplementation would not, and I only know it because I built the honest version.

The thing I deleted

The same release added \hl <expr> — a command that echoed one line back with ANSI syntax colour. It shipped, and then I removed it.

It only ever colourised a line you had explicitly run. That is not what "live syntax highlighting" means; live highlighting colours your keystrokes as you type them, and doing that would have meant rewriting the REPL's raw-input loop. The feature was a plausible-looking thing that satisfied the words of the goal and none of its substance, and shipping it would have made the roadmap item look done.

Deleting it is in the changelog with that explanation. Eighteen months later I did rewrite the input loop, for entirely different reasons, and got the capability properly — post five.

1.9 — the audit

1.9 was also a memory-safety and undefined-behaviour pass over the whole tree, and it is the least glamorous and most important thing in this post. Real bugs found and fixed, in both categories:

  • memory safety and UB in the core — the kind that ASan finds and code review does not
  • language semantics — cases where a primitive was subtly wrong at an edge and no test covered it

What came out of it, besides the fixes, is tests/test_matrix.k: a 309-case combinatorial matrix — every primitive × every element type × sizes 0, 1, 10 and 100,000+ — crossing the SIMD and PAR_THRESHOLD boundaries deliberately, and asserted as invariants (shape, algebraic identity, vector-kernel-versus-scalar-reference) rather than frozen literals.

The distinction matters. A test that asserts a frozen literal tells you the output changed. A test that asserts simd_add(a,b) == scalar_add(a,b) at sizes either side of the threshold tells you which of your two implementations is wrong, and it keeps working when you add a third.

Where this left things

704 ms
as-of join, 5M trades × 10M quotes
151 ms
1-minute VWAP over 5M trades
0.1 ms
50-period EMA, tacit, straight into the C kernel
2261×
find on 5M rows, with `s

By the end of 1.9 the parts of Amber a tick session spends its time in were C: the join, the vector arithmetic, the reductions, the window join, the EMA, the temporal arithmetic. The parts that were still K — the query layer, the group-by, the probe — were about to become the next bottleneck, and finding that out is where the last post starts.

Part of Building Amber on ngn/k, a five-part series on what I added to a K interpreter to turn it into a columnar engine for market data.