Goose

Relative references: self-relative or base-relative?

A relative reference (T&<u8>, T&<u16>, T&<u32>, T&<u64>, T&<varint>, spec §3.9) is a reference stored as a narrow offset. This note compares two encodings. At the time of the experiment, the spec defined the first; the second was built behind a flag on the branch opt/pool-relative and measured (bench/adoption.md 3.1). The comparison covers semantics, cost, and supported uses. The Outcome section records the subsequent implementation of both forms. The numbers are from the benchmark suite at the large size unless stated otherwise.

1. What each encoding means

Self-relative (the original form): the stored value is target - &field, the distance from the field's own address to the target, signed. Loading is &field + offset; storing needs both addresses. Zero means null for the optional form. The invariant the checker enforces is that the target lies in the same root array as the field (§3.9). This invariant is what makes a structure linked this way position independent: if the whole array is moved or mapped anywhere, every link still holds, because both ends moved together.

Base-relative: the stored value is target - base(P) where P is the pool the target lives in, plus one so that zero stays null. Loading is base(P) + offset - 1; storing is target - base(P). The invariant is that the target lies in P, and that P is known wherever the value is stored or loaded. A structure linked this way is position independent as a pool: if the pool is mapped anywhere, the links still hold, because they are measured from the pool's start. The value is effectively an index: &P[i] encodes as i * sizeof(T) without involving the base, and the index is recovered with one shift.

The two agree on width, on null, on what may be pointed at, and on the serialization of a whole array. They differ in what the address arithmetic needs to know (the field's address, or the pool's base), and that difference explains every row below.

2. Speed

The baseline compiler was measured against the flag, on both toolchains (v145 / clang):

row base-relative vs self-relative why
lru (relinks six links per hit, converts index to reference and back) 1.04 / 1.41 store needs no field address and no range check; &pool[i] is i * 16
graph (one-pass linked adjacency, pointer chase) 1.03 / 1.18 load is base + off with base loop-invariant in a register
bintrees, tree, interp, scene (build once, walk) 0.93-0.96 / 0.93-1.00 a hot recursion had to carry the base as a hidden argument
calc does not compile a plain reference read back out of a container has no base (§4 below)

Per operation, on the pointer-chasing path:

Over the suite, the net effect is small, and its direction depends on the workload: relink-heavy rows gain, build-once rows lose a few percent, and walkers are level. Under clang the gains are large; under v145 they are within noise except for lru.

3. Expressivity

4. Compiler complexity

5. Options

A. Keep self-relative only. This is the simplest option. It is position independent per array, works with inexact roots, and the store-time check is gone. It leaves lru 1.45x behind the Rust arena under v145 and 1.19x behind under clang. It also leaves "4-byte link from a map into a pool" inexpressible, so such programs keep indices, which is also what the Rust and C++ arenas do.

B. Switch to base-relative. This is the fastest option for relink and index-conversion workloads, and it gives cross-array links and offsets that are indices. It adds hidden-argument overhead to recursion, changes what every narrow width means, makes copies and queues need rebasing, and makes a reference read back out of a container unusable wherever two pools of its type are in scope. The last point is a language-level restriction the current encoding does not have.

C. Both, chosen per field. Self-relative stays the default and the only form without a named pool. A field may instead be declared relative to a named pool (a global pool, or a parameter or local that is a pool). Stores and loads for that field then use that pool's base, cross-array links are allowed into that pool, and the width bounds the pool. The checker admits a store into such a field only from a reference whose root is exactly that pool. This option needs the syntax, two emitter paths, and the exactness rule. It needs no fixpoint and no hidden arguments, because the pool is named at the declaration rather than discovered per call site. lru's map would hold Node&<u32 in pool> in 8-byte slots and relink through them, and every build-once structure would stay self-relative at no extra cost.

D. Compiler's choice per array. This is not viable: the encoding changes what a declared width means, so it has to be visible in the source.

Recommendation

Option C preserves existing programs and their meaning while allowing links between arrays. It improves relinking against a named pool without adding overhead to recursion elsewhere. The only new analysis it needs, exact roots for read-back references, is the rooting change already in progress. The syntax is the open question. T&<u32 in pool> reads naturally for a global pool. For a local or parameter pool, the name is in scope at the declaration of the field's struct only when the struct is declared inside the function. This suggests that the pool-named form is mostly a global-pool feature, and that is where the relink-heavy structures live.

Outcome

Option C was implemented: T&<u32 in pool> where pool is a global grow-only array (spec §3.9), plus a.index_of(r) (§3.3). The pool is named at the field's declaration and is part of the type, so a store measures from that global's base and a load is rooted exactly there. There is no fixpoint over parameter classes and there are no hidden arguments. A call site that passes a reference rooted in a different pool is a different specialization, as every other root difference already is. The in pool form is restricted to global pools, which is where this note expected the relink-heavy structures to be. A local or parameter pool keeps the self-relative form. This is why graph stays self-relative (its pool is a local of main), as intended.

lru was rewritten to use it. The map holds Node&<u32 in pool>? in 8-byte slots and relinks through them, pool.free(pool.index_of(n)) turns a reference back into a slot to free it, and the pool's base is held in one local per function. The index form is kept as lru_indices.goose. Compared with the index form, the reference form is level under v145 and 1.31x faster under clang. No other benchmark's generated C changed. In the suite, the row is 3,255/2,133 ms, against 2,237 for the Rust arena and 2,413/2,261 for the C++ arena. That is level with both under clang, and 1.45x behind under v145, where the base-plus-offset load is scheduled worse.