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:
— 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.
Mentioned in: Select the phase once, then run closed loops, The story changed in SBCL 2.6.7
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):
- The build ships twenty contribs; sb-simd is not among them, and
(require :sb-simd)fails. SBCL builds the contrib only for x86-64. - The host has no substrate: no
sb-ext:simd-pack, no:sb-simd-packfeature, zero SIMD-related symbols inSB-EXTorSB-KERNEL. SBCL's arm64 backend uses NEON internally for selected library routines (string and small-integerposition), but exposes no user-facing SIMD types through 2.5.x. - Loading the archived source from scratch fails in three successive ways,
each localizing an x86-64 assumption: a package-lock violation interning
the
SIMD-PACKprimitive-type name into an arm64SB-VMthat lacks it; a duplicate-storage-class assertion in the one VOP-generating file that forgets to check instruction-set availability; and a read-time error on anSB-EXTinternal symbol that only exists on x86-64.
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).
Mentioned in: Select the phase once, then run closed loops, Scope and method, The story changed in SBCL 2.6.7
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:
- SBCL 2.6.7 (released 2026-07-28) announces: "the SB-SIMD contrib now supports ARM64. (Thanks to Sylvia Harrington)" — alongside AVX-512 support on x86-64 and further SIMD instruction work credited to Arthur Miller.
- The substrate came first: Harrington's commit "arm64: Implement
SIMD-PACK" adds
src/compiler/arm64/simd-pack.lisp, and the build now enables:sb-simd-packon arm64. The user-facing backend follows as two new instruction sets in the contrib::arm64(immediate constants, in the role:x86-64plays) and:neon, availability-tested and included the same way the x86-64 sets are. - The NEON set is the full 128-bit menu:
f32.4,f64.2, and all integer packs fromu8.16tos64.2, with broadcasts, typed arefs and SAP references, arithmetic, min/max, square root, mask-returning comparisons, horizontal and pairwise reductions, zips, unzips, transposes, and lane extract/insert. Missing comparison directions are synthesized as fake VOPs — the gap-filling tradition of #C3F7XM continues on the new architecture. The 256-bit types remain x86-64-only, which makes NEON's width exactly the four lanes Box3D standardized on (#T3C8FV). - Luv no longer depends on the host package manager for this transition. Its flake builds SBCL 2.6.7 and its launchers enter that environment, so the empirical failures of #L2W6KT describe a preserved historical probe, not the current development image.
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.
Mentioned in: Select the phase once, then run closed loops, Scope and method, Before SBCL 2.6.7, arm64 was absent in three instructive ways
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.
Mentioned in: Scope and method
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:
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:
sb-simd-neon:f32.4-sqrtraises SIGILL whenever it executes, on any operand: the vector square root's encoding is wrong. Every other operation the kernels use -- arithmetic, min and max, comparisons,f32.4-bit-select, and, and-not, or,make-f32.4,f32.4-values,f32.4-aref-- is correct. The kernels take their square roots through the scalar unit lane by lane, which the bitwise-agreement test shows is what the vector instruction would give.- The compiler folds a SIMD operation on constant arguments at compile
time by calling its out-of-line function, and that function is a stub:
a form like
(f32.4-sqrt (make-f32.4 -1.0 0.0 4.0 2.0))typed at a REPL killed the durable image with SIGILL inside IR1 optimization. Never hand a wide operation constant arguments in a form the compiler will see.
Both are worth reporting upstream; neither touches the layout lesson of #T3C8FV, which held.
Mentioned in: Bitwise agreement is the wide kernel's contract
What sb-simd teaches now that it can run here #E8M3JC
Ideas worth carrying into luv independent of instruction sets:
- The
X.Ynaming discipline — element type and width as one lexical token, operations suffixed onto it — makes wide code readable and makes the scalar/wide relationship mechanical. A luv math layer can use that relationship directly for paired scalar and native implementations. - The record-driven generation pattern: describe operations as data, then generate definitions, declarations, and accessors by iterating the database. Luv already lives this pattern in its s-expression SPIR-V assembler and its reified Vulkan invocations; a numerics layer generated from operation records would be stylistically at home.
- The scalar mirror as executable specification, with the test suite checking fast against portable on random inputs. If luv ever grows a foreign or GPU kernel for a hot loop, the Lisp definition should remain as its oracle.
- Capability as data with runtime dispatch: instruction sets as records with availability predicates, and one dispatch macro whose decision survives image dumping. Substitute "GPU device features" or "foreign shim present" and the pattern applies directly to luv's situation.
- A cautionary lesson: the portability seam nobody exercises does not work. sb-simd's scalar-fallback load path existed in design and failed in fact, in three places, because it was never run off x86-64. The luv HAL's untested-second-backend worry (#A7N4XP) is the same worry.
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.
Mentioned in: Select the phase once, then run closed loops, Bitwise agreement is the wide kernel's contract, The story changed in SBCL 2.6.7
Addition over compatible quantities.
Test whether one compatible scalar is at least another.
Subtraction or unary negation.
Empirical results from this machine (Homebrew SBCL 2.5.10, macOS arm64): – The build ships twenty contribs; sb-simd is not among them, and (require :sb-simd) fails. SBCL builds the contrib only for x86-64. – The host has no substrate: no sb-ext:simd-pack, no :sb-simd-pack feature, zero SIMD-related symbols in SB-EXT…
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: – SBCL 2.6.7 (released 2026-07-28) announces: "the SB-SIMD contrib now supports ARM64. (Thanks to Sylvia Harrington)" — alongside AVX-512 support on x86-64 and…
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)…
The hot path of a physics step must not dispatch per site. The arrangement #R3F8YC anticipates — a generic operation selects a representation or phase once, borrows specialized arrays, and runs an ordinary optimized loop — maps directly onto how Box3D structures a step: prepare (gather from domains into per-step…
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…
Worth recording precisely, since luv can now experiment with SIMD in Lisp (see the sb-simd field notes): Box3D's SIMD is one abstraction header (src/simd.h) defining b3FloatW as SSE2, NEON, or a scalar struct of four floats, always width four. On top of it sit wide vector and symmetric-matrix types and exactly one…
Ideas worth carrying into luv independent of instruction sets: – The X.Y naming discipline — element type and width as one lexical token, operations suffixed onto it — makes wide code readable and makes the scalar/wide relationship mechanical. A luv math layer can use that relationship directly for paired scalar and…
The documentation claims bit-identical results across thread counts and platforms. The code backs this with converging mechanisms, none of which is a "determinism flag": – Work stealing changes only which worker computes a block, never the data or its internal order. Colored blocks write disjoint bodies;…
The claim #E8M3JC recommends -- the scalar definition is the oracle of the fast one -- is made strictly here: after 120 steps of a 200-ball pile, or 200 steps of a 1000-ball one, the state hash of every position and velocity is identical between the scalar and the NEON families. What buys it: – the wide arithmetic…
The luv system's modules now make the intended boundaries literal without turning every implementation layer into a public ASDF system: hal/gpu.lisp contains no Vulkan or Metal calls. It names providers, devices, queues, resources, descriptors, command structs, encoders, passes, finish, submit, submitted-work-done,…
acc2, acc3, acc4 at offsets 4, 8, 12 ...