Consts Built Once
A plan for giving a const of a linear type one home for the life of the program, built once, and making every use of it a borrow of that home rather than a fresh copy on the heap. A feasibility study, written 2026-10-06; nothing here is implemented.
Contents
- Why
- Where things stand
- The model
- Why the runtime is ready
- Why lowering is mostly ready
- Backends
- The obstacle: function-local consts
- The other passes
- The language decision
- Literals
- Order of work
- Open questions
Why
A const of a linear type is rebuilt on the heap every time it is mentioned, and every time the code mentioning it runs. A function that borrows a const list allocates the list, borrows it and frees it on every call. Compile-time evaluation saves computing the value but not constructing it, so a large table computed at compile time is no cheaper to read than one computed at run time.
The semantics that make sense are that a const is an immutable ref binding to a value fully constructed once, in storage that lives as long as the code that names it.
Where things stand
After compile-time evaluation the instruction that defined a const is replaced
by an Instruction::Const carrying the evaluated ConstValue
(datafun-const/src/inline.rs). A reference from a function to a module-level
or script-level const is lowered by lower_var_operand in
datafun-lower/src/expr.rs as a fresh Const of its own. A function that
reads a list const twice by value and once by borrow lowers to:
v3 = const [1int, 2int, 3int] // the binding itself
v4 = const [1int, 2int, 3int] // let a = LST
v5 = const [1int, 2int, 3int] // let b = LST
v6 = pack Tuple2 {v4, v5}
drop v3
return v6
and a borrow is a borrow of such a temporary:
v0 = const [1int, 2int, 3int]
v1 = call @0 m3.u0(v0) // list.len(ref LST)
drop v0
Every backend builds a heap value for each Const it executes:
- Cranelift (
datafun-cranelift/src/codegen/constants.rs) keeps a string's bytes in static data but copies them to the heap withdtlv_rti_string_from_bytes, and builds a list by writing elements to a stack buffer and callinglist_build_from_slice; sets and maps likewise. - The C backend (
datafun-c-aot/src/codegen.rs) creates a list withdtlv_rti_list_create_localand pushes each element. - The interpreter (
write_constindatafun-interp/src/consts.rs) builds from theConstValuethe same way.
Only string bytes and type descriptors are static today.
The model
A const of a non-copy type gets one slot in a const pool attached to the code
unit that owns it, built once from its ConstValue before any code using it
runs. A new IR instruction gives its address:
vN = staticref #k // vN: Ref(T)
Every use goes through that ref exactly as a use of a ref parameter does.
Moving out of a const is an error, as moving out of a ref parameter is, and
CONST@ lowers to clone *vN (see
the language decision). Copy-type consts
are unaffected and stay immediate Const literals.
Why the runtime is ready
- Values are plain pointer graphs.
List,String,Int,Map,Set,TensorandTableinrtdt/src/lib.rsarerepr(C)structs of pointers, lengths and capacities, with no reference counts and no type descriptor pointers.DataandErrorare two words naming a static type descriptor and a value (rtdt/src/anypack.rs). The same layout is valid in any memory. - Reads do not write. In
rti/src/table.rsevery read takes its source as*const:eq,cmp,clone,debuglog, thelen,get,contains,key_atandvalue_atentries,get_value_ref,tensor_get,table_get. The*mutentries are mutations (push,set,insert,remove,reserve,shrink_to_fit), reachable only throughmutandout, and a const is neither asettarget nor amutoroutargument.DropViaRefis emitted only on theoutandmutargument paths indatafun-lower/src/expr.rs.tensor_slice_localtakes*mutbut no backend emits it; indexing a borrowed tensor goes throughtensorindexref. - Refs do not escape. Borrows are not first-class, and every way of turning one into a value deep-copies. No pointer into a pool outlives the code that took it, so a pool only has to live as long as its code unit.
- The allocator never sees pool memory.
AllocLocalinrt/src/c/impls/alloc.rskeeps per-heap free lists, and freeing a pointer it did not hand out would corrupt it, but a pool value is never owned by running code and so is never dropped by it. A clone out of the pool allocates on the caller's heap as any clone does.
Why lowering is mostly ready
A ref parameter is already an immutable ref binding, and its uses are already
lowered as reads through the pointer, in every backend:
v1 = eq p1, v0 // s == "x"
v3 = getfieldref p2.1 // p.n
v5 = add *v3, v4 // p.n + 1
v12 = listelementref p0[v7] // xs[0]!
v13 = clone *v12 // xs[0]!@
v14 = clone p1 // s@
along with passing it on as a ref argument and tensorindexref through
nested tensor indexes. A const mention lowered as staticref produces a value
of type Ref(T) that all of these accept as Operand::ValueRef.
For a module-level or script-level const the value is known before a function
naming it is lowered (const_bindings on LowerCtx), so the change is local
to lower_var_operand: emit staticref for a non-copy type instead of a fresh
Const, and register the value in the unit's pool.
Backends
The first version builds each pool once at load, into memory that nothing
frees, reusing the code every backend already has for building a ConstValue
at an address.
- Interpreter. When a unit is loaded, build its pool with
write_const, allocating from a dedicatedRtLocalthat lives as long as the unit.staticrefwrites the slot's address. The bytecode engine needs the instruction too. - JIT. Same process, so it shares the interpreter's pool and embeds the address as an immediate. The pool must be built before the function is compiled and outlive the compiled code.
- Cranelift AOT and C AOT. Emit an init function that runs before the
script, building each slot into a global with the existing const builders
and a second runtime instance, created as
rt/src/c/mod.rscreates the first.staticrefis the global's address.
Pool lifetime follows the code unit:
- Scripts run in parallel over shared modules only read the pool, which is complete before any of them starts.
- A REPL line or a reactive recompile replaces the unit, and its pool with it. Since refs cannot escape, nothing outlives the swap.
- Pools are destroyed at shutdown, or allocated with leak tracking off, so
DATALOVE_LEAK_CHECKstays meaningful.
Later: prebuilt images
For AOT the pool can instead be laid out at compile time as read-only data
with relocations, which costs nothing at startup, shares pages between
processes, and faults on a write. That needs a serializer from ConstValue to
every runtime layout. B+tree nodes are the hard part, and rtdt already has
compute_map_*_node_layout and its set counterparts. One way is to run the
existing builders against an allocator that bump-allocates into an image
buffer, then find the pointer fields with the same type-descriptor walk that
clone and destroy use, and emit a relocation for each. Cranelift's
DataDescription takes data relocations; C can express them as address
constants in static initializers. Nothing in the first version depends on this.
The obstacle: function-local consts
A function body is lowered once, before its consts are evaluated, and the inline pass writes the values in afterwards (phases 2 to 4 in the compiler guide's script pipeline). By then the uses of a function-local const are shaped as uses of an owned binding:
v3 = const [1int, 2int, 3int]
v4 = call @0 m3.u0(v3) // ref LST
drop v3
Turning v3 into a Ref after the fact means retyping it and deleting its
drops. That looked fragile while a const could be moved, so this plan proposed
lowering the function again once its consts had values. Once step 1 refused
moves out of a const it stopped being fragile, and step 3 did the rewrite
instead; see As built.
The other passes
Every pass over IR has to know the instruction and keep the pool with its unit:
- display and serialization in
datafun-ir - dead-code elimination (
datafun-const/src/dce.rs), which must not countstaticrefas having side effects - const inlining (
datafun-const/src/inline.rs) - the inliner (
datafun-inline), which moves instructions between units and must carry pool entries with them, or point at the source unit's pool - specialization (
compiler/src/specialize.rs)
The language decision
Two semantics were considered for a by-value use of a const of a non-copy type:
- Option A: an implicit clone. The current surface semantics, with only borrows and reads made free. Purely an implementation change.
- Option B: moving out of a const requires
@, as moving out of arefparameter does.
Option B is chosen (2026-10-06). A const is an immutable ref binding in
the language as well as in the implementation, so its rules are the rules of a
ref parameter, and the cost of a clone is visible where it is paid. A const
parameter of a non-copy type is the same: inside the specialized body it is a
ref binding, and ret xs becomes ret xs@.
What this changes:
- Ownership analysis treats a const mention as a borrowed place. A move out
of one is a diagnostic in the D003 family, with the same help that D001
gives: insert
@, at the place to insert it. match, the destructuringif, andletdestructuring move their scrutinee, and on arefbinding each is D003 today. No form takes apart a borrowed enum, option or struct without cloning it, so until borrowing forms exist these are writtenmatch CONST@,if CONST@ |x|andlet {a, b} = CONST@. Borrowingmatchandifare worth having forrefparameters regardless, and once they exist the@goes away in the common case.- Source compatibility. Existing code that moves a linear const, such as
let a = LST, stops compiling and needsLST@. Copy-type consts, which is every const insys/stdtoday, are unaffected. - Documentation.
botspec.md(the "Const" paragraphs under bindings, and 8.4),mandocs/const-eval.mdand the constants section ofmandocs/datafun.mdall said that reading a const does not move it and needs no@.botspec.mdis updated; the two inmandocsare not yet.
The rule does not depend on pools: it can land first, on its own, against
today's lowering, where CONST@ is a clone of a fresh Const. Landing it
first means the source change is made once, and the pool work after it changes
no observable behavior.
Landed (2026-10-06), as described in the compiler guide's ownership section. Two things it needed that this plan did not foresee:
- Const arguments. A const argument is a bare const name in a by-value
position, but it is not a move. Ownership reads
call_targetsfor the callee's const parameters and skips those arguments. Lowering first gave the call a copy of its own, because an unspecialized call consumed the argument and a specialized one dropped it. Const parameters are now passed by reference instead, so the call borrows; see As built. - Lowering's per-read copies are gone. Lowering used to clone a
function-local const or a const parameter at each read, so that reading did
not consume it. With moves refused, a borrow now uses the binding itself, and
@is the one clone. A module-level or script-level const named from a function is still a freshConstper mention, which is what pools replace.
Literals
Non-copy literals have the same problem as consts, more often. s == "x"
lowers to
v0 = const "x"
v1 = eq p1, v0
drop v0
and allocates and frees a string each time it runs; bigint literals likewise
(v4 = const 1int). Once pools and staticref exist, any non-copy Const
whose value is only read can be pooled. In ordinary code that is likely a
larger win than named consts.
Order of work
- Done. The ownership rule: moving out of a non-copy const or const
parameter requires
@, with fixtures and documentation updated to match. - Done. Pools and
staticreffor module-level and script-level consts, on all four backends. See As built. - Done. Function-local consts, and const parameters through specialization, by promoting them in the IR. See As built.
- Pooling read-only non-copy literals.
- Borrowing
matchand destructuringif, which remove most of the@s step 1 adds. - Optionally, prebuilt read-only images for AOT.
As built
Step 2 landed 2026-10-06, and differs from the plan above in three ways.
The value is in the instruction. StaticRef { dest, value } holds its
ConstValue in an Arc rather than indexing a table on the unit. Inlining,
specialization and serialization copy the instruction and the Arc with it,
so nothing has to carry entries between units, and the Arc's address is an
identity a backend can key on cheaply. That settles two of the questions this
plan had open: a pool belongs to whoever executes the code, not to a unit or a
module, and the inliner does nothing special.
The interpreter's pool is per interpreter, built on first use.
IrInterpreter keeps a StaticPool: keyed by the Arc's address, holding the
Arc so the address cannot be reused, and falling back to the value and its
descriptor so equal consts share an entry. An entry is built with
write_const the first time a StaticRef for it executes, on the
interpreter's own heap, and destroyed in IrInterpreter's Drop, before the
runtime shuts down and checks for leaks. Each script executor and each CTFE
evaluator has its own interpreter, so parallel scripts never share one.
Where each backend finds it.
- The bytecode engine has no op for it and runs it on the IR walker.
- The jit resolves each
StaticRefagainst the interpreter's pool when it compiles a function (record_calltakes the interpreter for this) and embeds the address. The engine belongs to the interpreter, so compiled code cannot outlive the pool. - Cranelift AOT declares a data object per distinct const, and C AOT an array
defined in
script.cand declaredexternin each module's file.__dtlv_statics_initbuilds them all afterdtlv_rti_init, with the existing const builders, and__dtlv_statics_finidestroys them beforedtlv_rti_shutdown. There is no second runtime: the program has one heap.
Const arguments were at first left as Const, built for the call and
dropped by it. That changed after step 3; see below.
std_tests/145_static_consts runs consts of most shapes through all four
backends under the leak check.
Step 3 promotes in the IR rather than lowering again. After const
inlining, a function-local const is a value defined by a Const.
promote_function_consts in datafun-const turns each such value of a
non-copy type into a StaticRef: it retypes the value as a
reference, rewrites every use to read through it (with
map_operands_in_instruction, the walk that substitutes parameters, now
general), and deletes its drops. That is sound only because no use consumes
it, which the ownership rule guarantees. It runs on module functions in
lower_module after const inlining, and on a script unit's functions after
specialization, which reads const arguments out of the Const instructions
the pass replaces. A const naming a const parameter in the unspecialized
original is an ordinary binding and is left alone, as is everything under
skip_const_inlining. A const left with no use once its drop is gone, as
one passed only as a const argument is, is removed by dead-code
elimination. std_tests/146_static_function_consts covers both kinds on all
four backends.
Const parameters are passed by reference. A function lowers each const
parameter as a ref parameter, which ownership treats as borrowed, so its
body never drops one, and a call lowers a const argument as a ref argument:
the static for an outer const, the binding for a local one, the parameter
itself for one passed on. Specialization reads the value through a
StaticRef as well as a Const, drops nothing when it takes the argument
away, and defines a copy's const parameter at entry as a StaticRef of a
non-copy type, or a Const of a copy one, rather than leaving it to
promotion. So no call builds a const argument, specialized or not.
Open questions
- An interpreter's pool is never evicted. A long REPL session that keeps editing units which name consts keeps every value it has built.
- Whether the bytecode engine wants
staticrefas an op that holds the address, rather than running on the IR walker at each execution. - How large a const has to be before building it at startup is worse than building it on use, if any are never read. Lazy construction behind a once-flag is an alternative for the AOT init function.