Coalton compiles to Common Lisp with static type declarations and performs several optimization passes before handing code to the host Lisp compiler. Understanding these optimizations helps when writing allocation-free inner loops and numerical code.
Note: This page covers advanced material—including compiler transformations, runtime representations, and Common Lisp code generation—and is not required for ordinary Coalton programming. It is intended for programmers who want to understand how Coalton compiles their code or who need to squeeze maximum time and memory performance out of critical paths.
Note also that while Coalton’s own AST rewrites (such as inlining, specialization, and scalar replacement) are portable across Common Lisp implementations, low-level code generation details—such as unboxed register allocation from (declare (type ...)) and stack allocation from (declare (dynamic-extent ...))—depend on the host Lisp compiler. The discussion below assumes SBCL, and not all low-level optimizations are guaranteed on every Common Lisp implementation.
Table Of Contents
Development vs. Release Mode
By default, Coalton compiles in development mode, which prioritizes interactive redefinition at the REPL (for example, compiling algebraic data types as CLOS classes so types and methods can be redefined).
Setting the environment variable COALTON_ENV=release before compiling Coalton (or configuring (:compiler-mode "release") via :coalton-config) enables release mode, which:
- Compiles algebraic data types and structs as
cl:defstructdefinitions withsb-ext:freeze-typeon SBCL. - Emits concrete Common Lisp
(declare (type ...))annotations on function parameters,let/let*bindings,for/for*bindings, andmatchpattern variables. - Makes the final branch of exhaustive
matchexpressions unconditional so the Lisp compiler does not synthesize a deadnilreturn path, preserving tail-call optimization and concrete return types. - Enables scalar replacement of non-escaping structs, single-constructor data types (such as
Tuple), and branching sum types (such asOptionalandResult), as well as unboxing of non-escapingcoalton/cell:Cellbindings. - Enables multi-entrypoint calling conventions (
:unpackedand:unboxed) so direct calls across non-inlined functions pass and return flattened struct/tuple fields, sum tags and payloads, and 64-bit numbers/floats without heap allocation. - Enables stack allocation (
dynamic-extent) of freshly constructedmatchscrutinees whose pattern bindings do not escape.
Local Cell Unboxing
Imperative accumulation inside for loops and local algorithms often uses coalton/cell:Cell. At runtime, an escaping Cell :a is a heap-allocated struct whose slot has Lisp type t (which requires boxing machine words such as U64 or F64).
In release mode, after inlining, the compiler detects local bindings of (cell:new init) that do not escape their lexical scope and rewrites them into typed mutable Common Lisp lexical variables (let + (declare (type ...)) + setq):
cell:readlowers to a direct variable read.cell:write!lowers to(setq var value).cell:swap!lowers to a temporary binding of the old value followed bysetq.- Inlined helpers built on them (
cell:update!,cell:update-swap!,cell:increment!,cell:decrement!,cell:push!, andcell:pop!) are unboxed automatically. Cellbindings initialized acrossif,cond, ormatchbranches (where every returning branch constructs aCellwithcell:new), as well as invariantCellbindings inforandfor*clauses (e.g.,(for ((acc (cell:new 0) acc) ...) ...)), are also unboxed.
For example:
(declare accumulate (U64 * F64 -> U64 * F64))
(define (accumulate n delta)
(let count = (cell:new 0))
(let total = (cell:new 0.0d0))
(for ((declare i U64)
(i 0 (+ i 1)))
:while (< i n)
(cell:increment! count)
(cell:write! total (+ (cell:read total) delta))
(cell:swap! count (+ (cell:read count) i)))
(values (cell:read count) (cell:read total)))
In release mode, neither count nor total allocates a cell-internal struct. Instead, they are bound as local lexical variables declared (type (unsigned-byte 64) ...) and (type double-float ...), allowing SBCL to keep both accumulators in CPU registers with zero heap allocation.
A Cell is considered non-escaping when it is only accessed through the operations above—including inside for/for* loops, match/if/cond branches, immediate anonymous lambda applications ((fn (...) ...) ...), and non-escaping local helper functions (let/let*). Non-returning control transfers in initializer branches (return, break, continue, throw, or error) are also permitted. If a Cell is returned, stored in an escaping data structure, passed to an uninlined function, or captured by an escaping closure, it retains its heap-allocated cell-internal representation.
Scalar Replacement of Structs, Tuples, and Algebraic Data Types
Structs and Single-Constructor Data Types
In release mode, locally constructed immutable structs (define-struct) and single-constructor algebraic data types (define-type, such as Tuple) that are consumed only through generated field readers (.field), irrefutable match destructuring (including nested patterns), or inlined helpers (such as fst, snd, swap, and bimap) are replaced by typed scalar local bindings for their fields:
(define-struct Pair
(left U64)
(right F64))
(declare sum-pair (U64 * F64 -> F64))
(define (sum-pair n x)
(let p = (Pair n x))
(+ (into (.left p)) (.right p)))
All field initializers still evaluate once, in left-to-right order, before the body runs, without allocating the enclosing Pair or Tuple instance. This also applies when a single-constructor value is produced across local if, cond, or match branches—the branches return the unboxed field values via cl:values into a typed cl:multiple-value-bind.
Branching Sum Types (Optional, Result, and Custom ADTs)
In release mode, when a local binding is initialized across if, cond, or match branches by known constructors of a sum type (such as Optional, Result, List, or a user-defined define-type) and is only consumed by local match expressions, scalar replacement unboxes both the variant tag and the payload fields across multiple passes:
- A two-variant sum (such as
Some/NoneorOk/Err) uses an unboxedBooleantag that compiles tocl:if, while sums with three or more variants use an unboxedUFixtag. - Each distinct payload type is bound to a typed scalar local (e.g.,
(type (unsigned-byte 64) ...)or(type double-float ...)), eliminating both heap wrapper allocation (Result,cl-optional) and number boxing (U64,F64). - Branches that transfer control with
return,break,continue,throw, orerrordo not prevent unboxing of the remaining constructor branches. - Even when an
Optional :avalue is not scalar-replaced (for example, when received as a function parameter), matching(Some x)when:ais statically disjoint fromOptionalbindsxdirectly with its concrete Lisp type without calling a runtime unwrap helper:
(declare step-value (U64 -> U64))
(define (step-value acc)
(let ((opt (if (== (coalton/bits:and acc 1) 0)
(Some (+ acc 3))
None))
(tup (if (== (coalton/bits:and acc 2) 0)
(Tuple (+ acc 5) 1)
(Tuple (+ acc 7) 2)))
(res (match opt
((Some v) (Ok (+ v (coalton/tuple:fst tup))))
((None) (Err (+ acc (coalton/tuple:snd tup)))))))
(match res
((Ok good) (+ good 11))
((Err bad) (+ bad 11)))))
In release mode, opt, tup, and res are all scalar-replaced into unboxed Boolean tags and (unsigned-byte 64) locals with zero heap allocation.
Calling Conventions and Multi-Entrypoint Functions
Scalar replacement eliminates allocations within a single function body, but recursive functions, large functions, and cross-file calls cannot always be inlined. In release mode (or when explicitly annotated with (abi ...)), Coalton compiles eligible top-level functions and instance methods into multiple entrypoints:
:lisp(Boxed Common Lisp Entrypoint,f):- Bound to
(cl:fdefinition 'f)and the global lexical valuef. - Uses standard boxed Common Lisp arguments and return values so higher-order calls (
(map f xs)), first-class function values,call-coalton-function, keyword arguments, and REPL callers work unchanged.
- Bound to
:unpacked(Flattened Composite Entrypoint,f%UNPACKED):- Portable across all Common Lisp implementations (
cl:defun+cl:values). - Flattens single-constructor product parameters (
define-structand single-constructordefine-typeADTs such asTuple) into separate positional arguments when the parameter is only destructured inside the function. - Returns single-constructor products as multiple values (
cl:values) and non-recursive concrete sum types (such asOptionalandResult) as a discriminant tag followed by payload values.
- Portable across all Common Lisp implementations (
:unboxed(Unboxed Register Entrypoint,f%UNBOXED):- Available on SBCL (
:x86-64and:arm64) when type annotations are enabled. - Combines the flattened
:unpackedsignature with unboxed register passing soU64,I64,F32, andF64arguments and return values remain in CPU general-purpose and floating-point registers across non-inlined and cross-file call boundaries without heap boxing.
- Available on SBCL (
When a direct call (f a1 ... an) targets a function with a specialized entrypoint, the compiler calls f%UNBOXED (or f%UNPACKED) directly and cancels intermediate pack/unpack steps across chained calls and match expressions:
(coalton-toplevel
(define-struct Vec3
(x F64)
(y F64)
(z F64))
(abi :unboxed)
(declare vec3-fma (Vec3 * F64 * Vec3 -> Vec3))
(define (vec3-fma a scale b)
(Vec3 (+ (* (.x a) scale) (.x b))
(+ (* (.y a) scale) (.y b))
(+ (* (.z a) scale) (.z b))))
(abi :unboxed)
(declare safe-div (U64 * U64 -> Optional U64))
(define (safe-div num den)
(if (== den 0)
None
(Some (coalton/math/integral:quot num den))))
(abi :unboxed)
(declare div-or-default (U64 * U64 * U64 -> U64))
(define (div-or-default num den fallback)
(match (safe-div num den)
((Some q) (+ q 1))
((None) fallback))))
In this example:
vec3-fma%UNBOXEDaccepts 7 unboxeddouble-floatarguments in floating-point registers and returns the 3double-floatcomponents of the resultingVec3in registers without allocating aVec3struct or boxing floats.safe-div%UNBOXEDreturns itsBooleantag andU64quotient in registers, anddiv-or-default%UNBOXEDbranches directly on the returned tag without allocating anOptionalwrapper or boxing 64-bitU64integers.- If a struct or tuple parameter is also used as a whole value inside a function (for example, stored into a data structure, passed to a higher-order function, or captured in the variable list of a
lispform), it is passed as a single boxed value rather than being unpacked and re-allocated inside the callee.
Stack Allocation (dynamic-extent)
Non-Escaping Local Functions
When a local function (fn) is bound in let or let* and does not escape its lexical scope, Coalton emits (declare (dynamic-extent #'fn-name ...)) on the generated flet or labels binding:
(declare sum-with-step (U64 -> U64))
(define (sum-with-step n)
(let acc = (cell:new 0))
(let ((step! (fn (x)
(cell:write! acc (+ (cell:read acc) x)))))
(for ((declare i U64)
(i 0 (+ i 1)))
:while (< i n)
(step! i)
(step! 1))
(cell:read acc)))
Because step! is only called directly and never returned or stored, step! is declared dynamic-extent and acc is unboxed into a typed U64 lexical variable, so the entire function runs without heap allocation. Local functions passed as the callback to cell:update! or cell:update-swap!, or called only by other non-escaping local functions, also receive dynamic-extent.
match Scrutinees and rec Loops
- Immediate
matchon constructors: In release mode, when amatchexpression scrutinizes a freshly constructed algebraic data value and none of the pattern-bound variables escape the branch bodies, the scrutinee is bound with(declare (dynamic-extent ...)). - Tail-recursive
recloops:reccompiles to a locallabelsloop and automatically eliminates typeclass dictionary parameters that resolve to context-free global instances once the loop’s types are concrete. - Multiple return values: Returning multiple values with
valuesand destructuring them with(let (values a b) = ...)uses Common Lisp multiple values (cl:values/cl:multiple-value-bind) rather than allocating aTuple.
Directives for Controlling Optimization
Coalton provides several directives for guiding the optimizer:
inlineandnoinline: Control function inlining at definition sites or individual call sites. Inlining higher-order helpers and small constructors often exposes localCells and structs to scalar replacement.abi: Control the calling convention (:auto,:lisp,:unpacked, or:unboxed) and worker entrypoints generated for a top-level function or instance method.specialize: Register type-directed rewrite rules that replace generic typeclass-constrained functions with specialized implementations when concrete types are known.monomorphize: Inline and specialize a polymorphic definition at its call sites to eliminate runtime dictionary dispatch.repr: Choose the underlying runtime representation of adefine-type(:enum,:transparent,:lisp, or:native).likelyandunlikely: Provide branch probability hints on conditionals.unsafe: Locally compile a body with(optimize (safety 0) (sb-c::type-check 0)).
Inspecting Generated Code
To verify whether a function unboxes its locals or stack-allocates closures, use pprint-coalton-codegen to inspect the generated Common Lisp code, or cl:disassemble at the Lisp REPL (see Debugging Operators).