luv

Workshop wiki

sb-simd.org

Field notes on sb-simd

Scope and method #J8K2QV

These notes study sb-simd, Marco Heisig's SIMD library for SBCL, from its final upstream state: the GitHub repository was archived in August 2025 with the notice that its code merged into SBCL as contrib/sb-simd, which is now the living copy. We read the archived tree (about 9,500 lines; the largest files are the package definitions and the scalar fallback definitions) and its texinfo manual, which became the "SIMD Programming" chapter of the SBCL manual.

The study has an unusual empirical component. It began while the luv development machine still ran Homebrew SBCL 2.5.10 on macOS arm64, when part of the question was simply whether any of this library existed there. The historical answer, developed in #L2W6KT, was no. Luv now supplies its own SBCL 2.6.7, so #W9K4DN and #P6N4SV record the present project reality.

As with the Box3D notes, sb-simd is a landmark, not an authority. It is the most serious public answer to "what should SIMD programming in Common Lisp feel like," and several of its ideas transfer beyond any one instruction set.

The library is a database that generates itself #T6N9WH

sb-simd is not a hand-written binding layer. It is a compile-time database of records — CLOS objects describing value types, instructions, loads, stores, casts, comparisons, reducers — from which nearly everything else is generated by macros that iterate over the database: SBCL VOPs, defknown declarations, inline wrapper functions, compiler macros, transforms, and typed array accessors.

An instruction set is itself a first-class record: a name (:sse, :avx2), a dedicated package computed from the name (SB-SIMD-AVX2), an inclusion DAG (:fma includes :avx2 includes :avx down the chain), and a test thunk — a runtime availability predicate, deliberately re-evaluated because a dumped image may be restarted on a different CPU. A representative instruction record is one line:

two-arg-f32.4+#:addps
f32.4
f32.4f32.4
:cost2:encoding:sse:associativet

— name, assembler mnemonic, result and argument types, and options from which a VOP, a wrapper, and constant-folding behavior are all derived.

The naming scheme is the most immediately stealable idea. Scalars are f32, f64, u8 through u64, s8 through s64; SIMD types are X.Y meaning Y elements of X — f32.4, f64.4, u8.32; every operation is X.Y-OP (f64.4+, u8.16-max, f32.4-if, f64.4-aref). When a record named f64.4+ is created, the name is parsed to find the scalar f64+ and the SIMD op registers itself as that scalar's vectorization — the naming convention is load-bearing metadata.

Every SIMD operation has a same-named scalar mirror with identical fixed evaluation order. The manual gives three reasons: uniform naming, reference implementations for testing (the test suite checks each SIMD function against its mirror on random inputs), and food for a future auto-vectorizer. The mirror-as-test-oracle idea deserves note: the portable definition is not dead documentation but the executable specification the fast path is checked against.

The generic package promises scalars; availability is a runtime property #C3F7XM

A subtlety easy to get wrong secondhand: the generic SB-SIMD package is not a portable SIMD API with scalar fallback. It defines only scalars — typed arithmetic, comparisons returning masks, typed aref over specialized arrays, an index type, and the instruction-set-case dispatch macro. It has no availability test because portable Lisp is always available. Everything with a dot in its name lives only in the instruction-set packages.

For unavailable instruction sets, the library still defines every function — as a stub that signals a missing-instruction error at runtime. The API surface always exists; availability is a runtime property, checked by predicates or dispatched over with instruction-set-case, whose jump index is recomputed on image restart via an init hook. The manual's motivating story is a game developed on an AVX2 machine, dumped as an image, and started on a customer's SSE2 machine: same fasl, different dispatch.

The much-mentioned "fake VOPs" are also not a portability layer. They fill instruction gaps within x86-64: SSE has no horizontal add, so one is synthesized from shuffles and adds; missing unsigned comparisons are synthesized by sign-flipping around the signed ones. Some fake VOPs call x86-64-only SBCL internals at read time — which is one of the reasons the library cannot even load elsewhere, as the next figure shows.

Before SBCL 2.6.7, arm64 was absent in three instructive ways #L2W6KT

Empirical results from this machine (Homebrew SBCL 2.5.10, macOS arm64):

With three small patches the system does load — and the result clarifies what the degraded design was worth. Scalar f64+ works; every dotted-name operation signals missing-instruction; every pack type degenerates to T. The graceful-degradation path was real in intent but never exercised off x86-64, and what it delivers is nothing beyond plain declared Common Lisp.

There is no NEON anywhere in the archived tree. At SBCL 2.5.10 the honest summary was therefore: on this machine, Lisp-level SIMD does not exist, and the nearest paths to wide arithmetic are scalar SBCL code, a small foreign shim, x86-64 SBCL under Rosetta, or the GPU luv already programs. #R9M4PV weighs these for the physics experiment — but the summary aged fast: the successor tree overturned it within a year of the archive (#W9K4DN).

The story changed in SBCL 2.6.7 #W9K4DN

A hunch that "someone has surely done arm64 SIMD by now" turned out to be right, and recently. Checked against the SBCL repository and NEWS on 2026-08-11:

Two figures on this page are thereby dated, and deliberately left standing with this correction beside them: the archive study was accurate about its object and wrong as a forecast. A landmark studied at a commit stays still; its successors do not. What has not changed: the record-driven design absorbed a second architecture largely by adding instruction-set files, which is the strongest evidence yet for the pattern #E8M3JC recommends borrowing.

Luv now treats SIMD availability as a project invariant #P6N4SV

The flake pins the toolchain rather than accepting whichever sbcl appears first on the host PATH. It exposes SBCL 2.6.7 and the Lisp environment for aarch64-darwin, aarch64-linux, and x86_64-linux; ./sly and scripts/luv enter that environment themselves. For the two CPU architectures luv supports, (require :sb-simd) is therefore a project assumption rather than an optional local upgrade.

The arm64 path has been checked in the actual durable SLY image, not merely in flake evaluation: the image reports SBCL 2.6.7 on ARM64, provides the SB-SIMD-NEON package, computes four-lane f32.4 addition correctly, and disassembles the typed addition to FADD V0.4S, V0.4S, V1.4S. The x86-64 side uses sb-simd's established x86 instruction-set family from the same SBCL source. The guarantee is that the contrib, pack substrate, and native 128-bit path exist; optional AVX, AVX2, and AVX-512 use must still be chosen by runtime capability dispatch.

This changes the engineering default. A first numerical atelier can be written directly as paired scalar and four-wide Lisp kernels. A foreign NEON shim is no longer preparatory work, and a CLOS generic function can select the numerical phase once while the phase uses sb-simd operations over borrowed specialized arrays. Scalar mirrors and tails remain essential for testing and odd element counts, not because ARM64 lacks a backend.

What a kernel looks like #V4R8DP

The manual's canonical example, a four-way unrolled AVX dot product, condenses the whole programming model. The analogous four-float f32.4 shape is now directly available through the NEON package on arm64; the programming pattern survives even where the element count differs:

do
index0
+index16
acc1
f64.40
f64.4+acc1
f64.4*
f64.4-row-major-arefa
+index0
f64.4-row-major-arefb
+index0

acc2, acc3, acc4 at offsets 4, 8, 12 ...

>=index
-n16

horizontal collapse, then a scalar tail loop

f64.4-horizontal+
f64.4+acc1acc2acc3acc4

The idioms: arrays declared as (simple-array double-float (*)); (optimize speed); the cast function doubling as constructor and broadcast, so (f64.4 0) seeds an accumulator and (f64.4+ x 5) just works; several independent accumulators unrolled by hand to hide floating-point latency; an explicit horizontal reduction; an explicit scalar epilogue for the tail. Fused multiply-add is a distinct function the programmer calls, never an optimization the compiler applies.

Everything depends on inlining plus SBCL's type inference: wrappers are inline, casts have transforms, and in a well-typed loop each operation compiles to its one instruction with values living in vector registers. The flip side is a set of sharp edges the manual is candid about: packs crossing a non-inlined function boundary get heap-boxed; there is no autovectorization (a vectorizer table exists in the records, but nothing in the tree consumes it); bounds checks on the multi-dimensional accessors are policy-gated and vanish under (safety 0); nothing constrains array alignment, which only the non-temporal accessors care about; and comparisons return all-ones/all-zero masks, not booleans, with selection done by blend operations.

Evaluation order is fixed because reproducibility is a goal #Q5B9NF

A quiet design decision connects sb-simd to the Box3D determinism discussion #D2V7MK: scalar and SIMD operations fix their internal evaluation order, so n-ary f64.4+ builds a balanced reduction tree — good for instruction-level parallelism, and always the same tree. Results are reproducible run to run, though deliberately not identical to what cl:+'s left-to-right folding would produce. Box3D pays a mirror-image cost from the other side: it refuses hardware FMA in its wide path so that SIMD and scalar solvers agree bitwise. Both projects treat "which additions happened in which order" as part of the observable contract, which is exactly the stance a deterministic luv simulation would have to take before its first parallel or wide kernel — retrofitting it means re-deriving every reduction in the codebase.

Two sharp edges met in 2.6.7's NEON port #4UVLSQ

Building the physics kernels (#7PAQ3M) on the new arm64 backend found two defects, recorded here so the next kernel does not rediscover them:

Both are worth reporting upstream; neither touches the layout lesson of #T3C8FV, which held.

What sb-simd teaches now that it can run here #E8M3JC

Ideas worth carrying into luv independent of instruction sets:

What should not be imitated is the assumption that wide arithmetic is where a small simulation's time goes. Box3D — a mature, profile-driven engine — vectorizes exactly one loop (#T3C8FV). The prerequisite it built first was a data layout with a disjointness invariant. The four-wide layout is now shared by luv's arm64 and x86-64 targets even though their instruction encodings differ. Wider paths remain optional; the layout and its disjointness proof still come first.