Generics: how it works

A generic function is compiled once, whatever it is called with. Inside it, a value whose type mentions a type parameter is a pointer at the value in its real layout together with that value's type descriptor -- the runtime's TyDesc -- and the descriptors the body needs are built at run time from one descriptor per type parameter, which the caller hands over.

This describes the implementation as it stands in October 2026, on all four engines. The language-level rules are Section 8.7 of the spec. How it got here is Generics by Reference, which replaced the erasure that Generics and Specialization describes.

Contents

The shape

Nothing is converted at a boundary. A u32 passed for a T is a pointer at the u32; a (u8, u32) passed for a (T, u32) is a pointer at the (u8, u32). Code outside generics is untouched: a type that mentions no type parameter is laid out and passed as it always was.

The invariant the whole thing rests on:

A value's descriptor is always to hand: its static type's, or its type's entry in the function's descriptor table. A static type describes a layout only where it mentions no type parameter.

Types in the IR

IrType::Param(u32) is a type parameter by position, kept inside the types around it: (T, u32) is Tuple([Param(0), U32]). A function's parameter and return types are as written (IrType::from_type_hint_generic), and so is every value and slot type in its body.

Layout has one rule for them: a dynamic type is laid out as a pointer. The static offset helpers (aggregate_field_offsets, option_payload_offset and the rest) are never asked about a dynamic type; every site that would ask takes the descriptor path instead.

IrType::Data is the language's own data type and nothing else. T bound to data is a pointer at a data, like any other binding.

Descriptors

The table. FunctionContext::descriptor_table lists the dynamic types the body uses, each a type expression over the function's own parameters (ir::descriptor_table, computed when the unit is lowered). At entry the function fills a table of their descriptors. Every instruction that needs the descriptor of a dynamic type reads its entry. A type parameter's own entry is the descriptor handed over for it.

Building. Each entry has a template: the static descriptor of its type expression, with a TyTag::Param descriptor wherever a type parameter stands. Filling the table asks the runtime to instantiate each template against the type-parameter descriptors (dtlv_rti_tydesc_instantiate), and the runtime caches what it builds per runtime handle.

The one-entry cache. Each function also keeps the last call's type-parameter descriptors and the table they built. A call repeating the binding costs a compare per type parameter and skips the fill. Every engine has one: a writable data object in Cranelift, a static in C, and IrLayout::table_cache in the interpreter. A call site alternating between two bindings refills every time; see A generic's descriptor-table cache holds one binding.

Call sites. A Call carries bindings, what the site binds each of the callee's type parameters to, written over the caller's own. Each becomes a descriptor: a static one when the binding is concrete, or the caller's own table entry when it mentions the caller's parameters, forwarding included. The interpreter resolves that once per site, as a BindingRef (Static or Table).

Nothing about a callee's descriptors comes from its body, so a callee's calling convention depends on its signature alone, and a call at a growing type -- f<T> calling f<[T]> -- builds one more descriptor per level of recursion. Every type parameter still has to be determined at a call site, since the caller has to have a descriptor to pass.

Storage

Each dynamic value and slot owns its storage. A move copies desc.size bytes into the destination's storage rather than handing over the pointer, since a value defined in a loop reuses its storage every iteration.

The dynamic frame. After filling the table, a function with dynamic values bumps the shadow stack once for all of them, each sized and aligned from its descriptor, and points each dynamic place there. The shadow stack grows with the number of IR values, never with the iteration count.

Release. The function records the shadow stack's top and limit at entry and restores both on every return; the stack grows by chunks, so restoring only the top would leave the limit in a newer chunk. Unwinding the interpreter gives back each popped frame's space. A datalove error or a panic that skips compiled epilogues is covered by the runtime's reset, which resets the shadow stack to its base.

Values that leave are copied out: a return into the caller's storage, a push into a collection, a store into an aggregate. Nothing keeps a pointer into a frame's shadow space after it returns.

Block arguments are a parallel move. Copying bytes one argument at a time breaks if a jump passes a block's own parameters back to it permuted, so lowering never emits one, and checks (ir::permuted_block_args).

Calls

Per dynamic parameter, by mode:

Mode Passed Who owns what
ref a pointer at the caller's value the caller, throughout
mut a pointer at the caller's value the caller; the callee writes in place
in a pointer at the caller's storage the callee owns the value; the caller frees the storage
out a pointer at storage for the result the callee writes; the caller destroys what was there first
return a pointer at storage for the result as out

The callee does not take a descriptor per argument: its table entry for the parameter's type describes it. Then come the type-parameter descriptors, in declaration order.

Which side gives an in argument up. A type parameter is never a copy type, but a copy value bound to one is copied, not moved. So for a dynamic argument the caller asks the argument's own descriptor (TyDesc::PLAIN), not the callee's type, and every engine marks a moved argument dead after the call on that answer -- at run time in compiled code, natives included.

Operations on a dynamic value

The instruction set has no generic-specific instructions; a backend implements an instruction whose operand is dynamic from the descriptor.

Operation On a dynamic value
move, store, load, block argument copy desc.size bytes into the destination's storage
move of a reference copy the pointer
read through a reference copy or clone desc.size bytes into the destination's storage
clone dtlv_rti_clone_local into the destination's storage
drop dtlv_rti_any_destroy_local; the storage goes with the frame
field, element, payload reference ptr plus the part's offset, from the descriptor; the part's own table entry describes it
tuple, struct, option, term, enum, result construction each part stored at its offset from the destination's descriptor
option and result tests the tag, at offset 0 whatever the payload
list, set, map, tensor, table literals built by the runtime from the table entry's descriptor
bounded operations (fixedint, float) dyn_ops on (ptr, tydesc) pairs
the order an ord bound admits the runtime's structural comparison, dtlv_rti_cmp_total_local, reached through sys/std/ord's natives and the set and map operations
debuglog the runtime's printer on the pair

What a reference to a dynamic type points at is the table entry for its pointee type, so a projection works out only where the part is.

Natives

The rider ABI is (ptr, tydesc) per argument, the result through an out pair, and then a descriptor for each type parameter no argument determines -- one appearing only in the return, like fixedint_zero<T>(): T. NativeContext::undetermined_type_params lists those positions, and a call's bindings at them are the descriptors (NativeContext::undetermined_bindings). A generic calling a native passes each dynamic argument's pointer with its table entry, and the trailing descriptors come from its table.

A rider is never handed a data standing in for something else: a result slot is in the real layout, and an argument is what the caller has. See The Native Rider ABI.

What the runtime provides

Function Answers
dtlv_rti_tydesc_instantiate a template's descriptor for a binding, cached per handle
dtlv_rti_field_offset / dtlv_rti_field_tydesc where a tuple or struct field is, and what it is
dtlv_rti_payload_offset / dtlv_rti_payload_tydesc the same for an option's, a result's, a term's, or an enum variant's payload
dtlv_rti_shadow_alloc shadow stack space when the current chunk has no room
dtlv_rti_mem_alloc_raw_local / dtlv_rti_mem_free_raw_local scratch for a tensor literal of a dynamic element type

The shadow stack's top and limit are in RtHeader (shadow_top, shadow_limit), so compiled code bumps and restores inline and calls the runtime only when a chunk is full. It is per runtime handle, so reentrant.

These are the runtime's rather than open-coded per backend for one reason: four derivations of one layout rule is where the bugs come from. TyInfoTuple is {num_fields, fields} and TyInfoStruct is {fields, num_fields}, and an option's payload, a result's and an enum variant's are each placed by a different rule.

Built descriptors are cached by the template's address and the parameters' addresses, outside the handle's allocator so reset does not free them. A miss for a type that has a static descriptor elsewhere builds a duplicate, which is harmless: descriptors compare structurally (same_tydesc).

What each engine does

The interpreter. A dynamic value or slot has a null static descriptor in IrLayout, and the frame accessors (Frame::value, value_dest, slot, slot_dest) read the pointer and the table entry (value_entries, slot_entries) when they see one, and hand the walker a (ptr, tydesc) pair, so its instructions, already driven by the descriptor in hand, mostly needed nothing. A reference to a dynamic type is described by the entry for what it points at. enter_dynamic fills the table and takes shadow space after Frame::enter; leave_dynamic gives it back. A parameter is one word too, a pointer at the argument, described by the callee's own descriptor or its table entry (IrLayout::param_places): the interpreter's calls hand over pointers, as compiled code's do.

The bytecode runs a generic's body: an instruction on a dynamic value runs on the walker (Op::Ir), except a move and a return (CopyDyn, ReturnDyn, which name the frame word holding the descriptor: a table entry or a parameter's). A call with bindings is planned like any other, and a generic wrapper around a native is forwarded straight to the native.

Cranelift, AOT and JIT (codegen/dynamic.rs). A dynamic value is an aggregate whose Cranelift value is the address of its shadow storage. Its descriptor is its table entry (tydesc_value); asking for a static descriptor of a dynamic type is an error, so a site that wanted a symbol fails to compile rather than compiles wrong. Sizes come from the descriptor (value_size, copy_value_bytes), offsets from the runtime.

The JIT compiles through the same codegen. The bridge into JIT code and the trampoline out of it carry the type-parameter descriptors after the arguments, and so does a planned call from the bytecode into JIT code. A function whose arguments and descriptors together exceed MAX_DIRECT_ARGS words is left to the interpreter. OSR reads the table and the dynamic places from the interpreter's frame (OsrSpec::table_offset).

C AOT follows Cranelift's choices in C. The signature ends with one const dtlv_tydesc_t* tp{i} per type parameter; the prologue declares the table as locals __t{i}, fills it through a static one-entry cache, and bumps shadow space inline (dtlv_shadow_bump). A dynamic place is a pointer in the frame, and a dynamic type's descriptor is (*__t{i}) wherever a static symbol's name would go.

Both compiled backends have a gate, check_dynamic_support, refusing a function that uses an instruction on a dynamic value their codegen does not handle, by name. It admits everything sys/std and the fixtures use. Under the JIT a refusal leaves the function to the interpreter (--jit-stats lists it, ENGINE_TESTS_JIT_REFUSALS=1 makes the engine suite print them); under AOT it fails the build.

What is refused

No bounds, so no operations on a T. A value of a bare type parameter can be moved, dropped, cloned, printed, stored, returned and handed on. x + y and x == y are F026 whatever the call site binds. Printing works because the descriptor travelling with the value is enough to format it.

A bound admits only its own operations. float and fixedint give the operators their types share, dispatched on descriptors through dyn_ops; bare + under fixedint is still F026. ord gives no operator at all -- .< and == on a T is ord are F026 -- only the order sys/std/ord and sets and maps use. == on an aggregate holding a type parameter is F026 even when the parameter is bounded. A call site that binds a bounded parameter to a type the bound does not admit is F016.

A set or map over a T with no ord bound is F061.

A const parameter whose type mentions a type parameter is F077: specialization makes one copy per const value, and that copy is still generic.

A type parameter no argument or expectation determines is F011, since the caller has no descriptor to pass. The types cannot be written at a call either: f<u32>(1) is not a call.

An instruction a compiled backend cannot do on a dynamic value is refused by check_dynamic_support (see the engines above): under the JIT the function is left to the interpreter, under AOT the build fails.

Why not monomorphization

A dynamic value costs at run time: a T is read through a pointer, a move is a copy of desc.size bytes, a call fills a descriptor table (or compares against its cache), and type-directed operations are calls into the runtime.

What it buys is what a language with a REPL needs. Compile time is O(1) in the number of instantiations -- a new one appearing in the REPL compiles nothing. There is nothing to invalidate, because a generic function is one salsa-tracked entity, and a generic's body never contributes to its callers' calling convention. Monomorphization needs to see every call site before it can emit the callee, which is exactly what a REPL cannot give.

Monomorphizing what is hot is Specializing Generics: an optimization over a complete implementation, not a different design.

Where the tests are

crates/datalove-cli/tests/fixtures/backend/ runs the interpreter, the JIT and both AOT backends and compares them, so a recorded output is what all four agree on. The generic ones:

Fixture Covers
06_out_params out through a generic
07-09 owned containers, tuples, terms and enums through a generic
10_generic_indexing reading an element whose type a generic cannot name
15_borrowed_generic_aggregate field offsets from the descriptor under ref and mut
16-19, 21 element and part borrows and writes, map and tensor indexes
20_generic_argument_from_a_var moving a var into a generic
22_comptime_and_generic const and type parameters together
23_descriptors_across_jit a JIT-compiled caller handing over descriptors
28, 29, 34 which side drops an in argument, in and out of loops
30_generic_return_bound_to_unit a dynamic return bound to ()
31_generic_enum_payload enum payloads sized from the descriptor
32_tensors_and_tables_over_a_type_parameter tensor and table literals over a T
33_growing_types calls at a growing type
35_alternating_bindings the one-entry cache missing every call

crates/datalove-datafun/tests/fixtures/std_tests/ has the language-level coverage: 102-105 (forwarding), 113 (return-position inference), 114-120 (building collections, recursion, deep types), and the bound tests 124-127. Engine fixture module_068_growing_type is a growing type on every engine, and deep_recursion_tests.rs checks that generic recursion under the JIT is not bounded by the Rust stack.

Four engines agreeing is load-bearing here. Most faults in this area have been silent -- a wrong answer, not a crash -- and some were identical in all four, which comparing them does not catch. Those need a fixture that says what the answer should be.