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

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:

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

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.

Pool lifetime follows the code unit:

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:

The language decision

Two semantics were considered for a by-value use of a const of a non-copy type:

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:

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:

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

  1. Done. The ownership rule: moving out of a non-copy const or const parameter requires @, with fixtures and documentation updated to match.
  2. Done. Pools and staticref for module-level and script-level consts, on all four backends. See As built.
  3. Done. Function-local consts, and const parameters through specialization, by promoting them in the IR. See As built.
  4. Pooling read-only non-copy literals.
  5. Borrowing match and destructuring if, which remove most of the @s step 1 adds.
  6. 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.

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