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), which is what makes a structure linked this way position independent: move or map the whole array anywhere and 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: map the pool anywhere and the links hold, because they are measured from its start. The value is an index in disguise: &P[i] encodes as i * sizeof(T) with no base in sight, and the index is recovered by 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 one difference is behind every row below.

2. Speed

Measured, baseline compiler against the flag, 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:

The net over the suite is small and one-sided by workload: relink-heavy rows gain, build-once rows lose a few percent, walkers are level. Under clang the gains are large; under v145 they are within noise except lru.

3. Expressivity

4. Compiler complexity

5. Options

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

B. Switch to base-relative. Fastest for relink and index-conversion workloads, gives cross-array links, and offsets that are indices. 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 -- and then stores and loads 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. Costs: the syntax, two emitter paths, and the exactness rule; 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; every build-once structure would stay self-relative and pay nothing.

D. Compiler's choice per array. 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, and 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, which suggests 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 there exactly -- no fixpoint over parameter classes and no hidden arguments; a call site passing a reference rooted in a different pool is simply a different specialization, as every other root difference already is. The in pool form is restricted to global pools, which is where the note expected the relink-heavy structures to be; a local or parameter pool keeps the self-relative form, which is why graph stays self-relative (its pool is a local of main), as intended.

lru was rewritten onto it: the map holds Node&<u32 in pool>? in 8-byte slots and relinks through them, pool.free(pool.index_of(n)) closes the loop, and the pool's base is held in one local per function; the index form is kept as lru_indices.goose. Against it 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 the Rust arena's 2,237 and the C++ arena's 2,413/2,261: level with both under clang, 1.45x behind under v145, where the base-plus-offset load is scheduled worse.