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:
| File | What changed |
|---|---|
a.h | the type enum, the type tables, and the TU/TP macros that classify a tag |
p.c | the literal scanner — 2026.07.30, 10:00:05.000, 2026.07.30D09:30:00.000000000 parse directly |
s.c | the formatter — a value auto-displays in its own format |
2.c | type-aware arithmetic: date - date is days, date + n is a date, time + time is a time |
c.c | the casts: "D"$, "T"$, "P"$, and `i$ to get the raw number back |
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 itThe 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.
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 races | None possible — copy-on-write heap per worker | Every refcount becomes a contended atomic |
| Cost to serial code | Zero | An atomic increment on every single value, forever |
| Cost to parallel code | A serialise + pipe + deserialise per chunk | Nothing |
| Precedent | How 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.
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> `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=1The 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.
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.
(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.
// 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.
#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.
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 0The 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
`sBy 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.