#319 A2: retire per-site text relocation — PC-relative module-local linkage slot (Axis A / ADR 0032)
openPart of #318 (epic: "be dynamic linking"). Axis A, continues ADR 0032.
Goal
Retire per-site text relocation for cross-module calls. Today ADR 0032
B.1/B.2 route calls through a cell but still emit mov rcx, imm64(symbol-record)
at every call site — that only swaps per-site callee relocation for per-site
symbol-record relocation, still O(sites). Done when the call operand is a
PC-relative load from a module-local linkage slot and the imm64 leaves the
text, so the loader fills O(distinct names).
Approach
- Give each module (compilation unit / fasl) a linkage table: one slot per distinct referenced name.
- Rewrite the call-emit path so
ag-call-baked-cell-vaddrsites emit a RIP-relative load of the slot instead ofasm-mov-rcx-imm64. - Loader fills the table once per module (O(names)); first use lazily binds via
ag-linkage-bind.
Code anchors
src/cold/compiler.lisp:1184ag-call-baked-cell-vaddrsrc/cold/compiler.lisp:3095/3097/3118— call-emit sites usingasm-mov-rcx-imm64(the imm64 to eliminate)src/stdlib.lisp:2946ag-linkage-bind
Acceptance gate
Instruction-count A/B (perf stat -e instructions) on a fixed-name,
growing-call-site module: both replay and first-call must stay FLAT against
growing sites. Flat vs growing sites (not growing names) is the proof.
Suite-green incl. (asdf:load-system "alexandria"). COLD stays early-bound.
Comments (11)
A2 linkage-table subsystem built + flag-on mechanism verified
Landed (on top of the asm primitive 4f25ca3):
- Linkage-table pool modeled on the quote-pool:
*ag-linkage-slots*(name-bv -> base-vaddr),*ag-linkage-patches*,*ag-linkage-offsets*, gated by*ag-linkage-table-p*(default NIL).ag-linkage-intern/ag-emit-linkage-pool/ag-resolve-linkage-patches, wired intoag-emit-quote-pool. ag-emit-cell-base-into-rcxroutes the baked-cell call site (both fast-path and bind-path, compiler.lisp:3097/3118): flag NIL = the legacymov rcx, imm64(base)+ KIND-8 reloc (byte-identical); flag T =mov rcx,[rip+slot]from a RIP-relative linkage slot (position-independent; N sites -> 1 slot).
Flag-ON verified (flipped the default to T + rebuilt so warm compilation exercises it — REPL setq of a compiler global is unreliable, symbol-split): build self-compiles clean, and warm-compiled calls through the linkage slot are correct — dbl(21)=42, car, mapcar+1+, nested (+ (dbl 5) (car ..))=110. Full flag-ON sharded suite running now.
Note: the slot holds an absolute base vaddr filled at emit; correct in-process (same base). The image/fasl RE-FILL by name on load (so a saved image with flag-on code relocates) is the NEXT increment — until then the flag stays NIL by default. Redefinition healing already works by construction (the slot holds the base; +32 fn-cell is dereferenced live each call).
Committing the machinery flag-OFF (byte-identical default) once the flag-ON suite confirms the mechanism is robust.
A2 core LANDED — commit 38ae35a (flag-off default; flag-on suite-green)
The module-local linkage table works. Cell-first calls can now read the symbol record base from a RIP-relative slot instead of an absolute imm64 — position- independent code, N sites -> 1 slot.
Verified: flag-OFF byte-identical (default) + boot smoke; flag-ON (default flipped + rebuilt so warm compilation exercises it) self-compiles clean AND passes the FULL sharded suite 8/8 — the entire corpus compiled and run through the linkage table.
Remaining A2 increments:
- Extend
ag-emit-cell-base-into-rcxto the two pkgsym sites (compiler.lisp:3018 call, :1324 quoted pkgsym) — they emitmov reg, imm64(symbol-base)+ KIND-6. - Image/fasl slot RE-FILL by name on load (record a reloc that targets the SLOT, fill it from the fn-table/pool by name on restore) so a saved image with linkage code relocates at a different base. THIS is what lets the flag default ON (and delivers image code-PIC).
- Retire the per-site imm64 (Stage C) once the fallback channels are gone — the O(names) instruction-count win (fixed-name/growing-site flat A/B).
pkgsym sites LANDED — commit 409fe48. All 3 cell-first site classes now on the linkage table.
- pkgsym CALL (compiler.lisp:3018, RCX) + quoted-pkgsym (:1324, RAX) route through ag-emit-pkgsym-base-into-{rcx,rax}. New asm-mov-rax-from-rip-rel32 for the RAX site.
- Slots re-keyed by base VADDR (unique; avoids the same-name-different-package merge bug name-keying would have had). Slot carries the NAME for future re-fill.
Verified flag-ON: self-compiles clean, sharded suite 8/8, AND alexandria loads (heavy package-qualified pkgsym refs — mappend/plist-alist/etc. all correct).
Remaining before the flag can default ON:
- Image/fasl slot RE-FILL by name on load (the last piece): record a reloc targeting the SLOT (not the call site), and on image/fasl restore re-resolve each slot from the fn-table/pool by its carried NAME, so a saved image with linkage code works at a different base (image code-PIC). The slots already carry the name; the reloc-target + restore-fill is what is left.
- Then default the flag ON, then Stage C (retire the per-site imm64 fallback for the O(names) instruction-count A/B).
Slot re-fill attempt revealed a deeper blocker: linkage-slot disp32 vs fasl CO-repacking
Implemented the slot re-fill (record a KIND-8/6 reloc at the SLOT offset; verified against the loader that KIND-8/6 = patch-u64-at base, mint-if-absent — exactly the re-fill). But the fasl round-trip test (tests/fasl/spike-run.sh: compile -> .funclfa -> replay in a FRESH session) core-dumps flag-ON, while same-session source load works.
Root cause (architectural, not a bug in the re-fill): the linkage slot lives in the pool region, and the call site reaches it via a baked RIP-relative disp32. The fasl PACKS code objects back-to-back at different offsets than the producer (the KIND-7 rationale), so the distance from a call site to its slot CHANGES on replay -> the disp32 points at garbage -> crash. The re-fill fixes the slot CONTENTS but not this DISTANCE. KIND-7 re-resolves rel32 CALL sites on repack; there is no equivalent for the slot DATA reference.
Note: this is a property of the linkage-slot approach itself (also latent at 409fe48 flag-on for the fasl), not introduced by the re-fill. The IMAGE path is unaffected (same base, no repack) — that is why suite + alexandria passed flag-on.
So the fasl path needs one of:
- Re-resolve the slot disp32 on repack (a KIND-7-like reloc for the mov-from-rip data ref to the slot), OR
- A PER-CO linkage table (slots travel with their CO, so the call->slot distance is preserved under repack), OR
- Make the linkage table a fixed-address region reached absolutely (defeats PIC — no).
Reverted the re-fill (kept verified 409fe48). This is the real next A2 design question; the image path already works flag-on. Filing as the blocker for defaulting the flag on.
KIND-9 WORKS — fasl-safe linkage slot (codex design). The fasl crash is fixed.
Implemented codex's KIND-9 design (rel32 -> linkage slot, re-resolved at load):
- Producer: the 3 cell-first helpers record a KIND-9 baked-reloc at the disp32 site (name for baked-cell, name+home-pkg for pkgsym).
- Serializer: unchanged — ag-baked-reloc-width returns 4 for KIND-9 (disp32) and already zeroes the field, so the stale producer distance is discarded.
- Loader (cold-loader.lisp load-reresolve-baked-reloc): new KIND-9 arm resolves NAME to the CONSUMER base (pkgsym base if home-pkg, else baked-cell base, else mint), queues a linkage slot holding it + the call-site disp32 patch via ag-linkage-queue. ag-repl-emit-and-register -> ag-emit-quote-pool then emits the slot at a LOAD-TIME offset and patch-rel32's the disp32 (order verified: relocs apply inside ag-fasl-install-co, pool emit right after).
Verified flag-ON: the fasl round-trip (tests/fasl/spike-run.sh — compile ->
.funclfa -> load in a FRESH session) that CORE-DUMPED before now produces EXACTLY
the expected output: TT 4242 4242 TT (= T 42 42 T doubled) + :SPIKE-MARKER.
Cross-session linkage replay is correct. Sharded suite running.
This was the last blocker. With the fasl path fasl-safe, the flag can move toward defaulting ON. Remaining: broad flag-on verification (asdf fasl replay), then flip default + Stage C (retire the imm64 fallback), plus codex's O(names) note (persistent linkage offsets for cross-CO dedup).
KIND-9 verified at ASDF SCALE (broad flag-on verification passes)
Built the asdf fasl flag-ON (make asdf-fasl -> build/asdf.funclfa 5.8MB) and replayed it (build/funcl auto-load). Results flag-ON:
- asdf fasl replays without crashing (rc=0); asdf + ocicl-runtime load.
- (asdf:load-system "alexandria") works; alexandria:iota 4 -> (0 1 2 3); NO hard errors (no MISSING-COMPONENT / undefined-function / core dump).
- Exercises the KIND-9 pkgsym path + dynamic symbols at scale (asdf is pkgsym-heavy).
So the linkage table is verified robust flag-ON at all three levels: sharded suite 8/8, spike fasl round-trip (exact TT 4242 4242 TT), and asdf-scale replay. The remaining pre-existing verbose-load noise (a check-type :COUNT-in-WHEN error) is NOT a KIND-9 regression (present flag-off too).
Remaining: flip ag-linkage-table-p default ON (broad verification now supports it), then Stage C (retire imm64 fallback) + O(names) persistent offsets.
DEFAULT ON — commit 69a2d1e. A2 is now the live behavior.
ag-linkage-table-p default flipped NIL -> T. Every warm cell-first call (baked-cell + pkgsym + quoted pkgsym) routes through a PC-relative linkage slot; fasls carry KIND-9 for CO-repack-safe cross-session re-resolution.
Verified default-ON: self-compiles clean (COLD unaffected — warm-only paths); sharded suite 8/8; spike fasl exact; asdf replay + load-system alexandria correct.
A2 delivered: call sites are position-independent (image code-PIC groundwork, ADR 0025 D3) and N sites to one name share one slot.
Remaining (follow-ups, not blockers):
- Stage C: retire the imm64 + KIND-8/6 fallback (now dead by default) for the O(names) instruction-count A/B (fixed-name/growing-site flat curve).
- O(names) persistent linkage offsets (codex): currently per-CO slots (correct, not maximally compressed cross-CO).
- Recommended: a full ANSI soak now that it is the default, as broad insurance.
O(names) persistent offsets LANDED — commit 20b4527
ag-linkage-offsets now persists across passes (quote-pool style); ag-linkage- intern dedups against already-emitted slots. N sites to one name — across COs — share ONE slot. Verified: suite 8/8, fasl cross-CO dedup clean, 6 car calls in one fn share one slot + compute correctly.
A2 IMPLEMENTATION is now complete: PC-relative linkage table, default-on, fasl-safe (KIND-9), O(names).
Left to fully CLOSE A2 (Stage C):
- retire the dead imm64 + KIND-8/6 fallback branch (flag-off, now unreachable by default);
- the fixed-name/growing-site instruction-count A/B (ADR 0033 gate) to put a NUMBER on the O(names) win.
Then the payoff: image ASLR/PIE (offset-encode residual heap code pointers + CODE-MMAP at a chosen base), which A2 unblocked.
Stage C measurement — honest result: replay O(sites), win is PIC + O(names) GOT
Measured the ADR-0033 A/B (fixed name car, 8 vs 400 call sites, fasl replay
perf stat -e instructions, median of 3):
- 8 sites: 355,054,866
- 400 sites: 360,787,867
- delta ~5.7M for 392 sites = ~14.6K instructions/site, LINEAR (O(sites)).
So the replay is NOT flat. Root cause: KIND-9 re-resolves each site disp32 on load (the fasl repacks COs). The A2 win is therefore position-independence + an O(names) GOT (one slot/name, no imm64 in text -> image code-PIC, ASLR-ready) — NOT a load-time speedup. Replay is O(sites), the SAME order the imm64 path was, so no regression; A2 adds PIC on top. The original "flat replay" gate was over-specified; corrected in the ADR to "GOT-slot count O(names)" (verified via persistent-offset dedup).
A genuinely flat replay would need per-CO slot co-location (slots travel with their CO -> disp32 baked, no load re-resolution) — codexs alternative, a load-time optimization deferred.
Stage C fallback retirement (drop the dead imm64 branch): DEFERRED until the default-on table has soaked (keep the escape hatch). A2 is functionally complete and honestly characterized.
codex re-checked the Stage C conclusions — 4 corrections + a bug (all folded in)
- O(sites) holds only for the single-name case measured; general case is O(sites x distinct records) — ag-linkage-intern / ag-resolve-linkage-patches do per-site linear scans of the persistent offset/patch lists. Scaling caveat for asdf-size link sets (worth optimizing later: index the offset lookup).
- "no regression vs imm64" was too strong -> same asymptotic shape, WORSE constant factor (KIND-9 resolves + interns + records + patches + emits pool vs KIND-8 single patch-u64-at).
- Root cause was mis-stated: the loader pads COs to their ORIGINAL offset (not a repack). The disp32 breaks because linkage slots are re-emitted at load and dont travel with the CO.
- Slots key by resolved symbol RECORD (same spelling/different package = different slot); ASLR-ready only for the TEXT — slots hold absolute bases, replay rebases them. Plus a latent KIND-8 width bug filed as #330 (ag-baked-reloc-width returns 4 for KIND-8 but the loader patches u64).
ADR 0033 updated. A2 win restated honestly: text PIC + O(distinct-records) slot pool, not a load-time speedup. Functionally complete + now accurately characterized.
Scope correction (codex): A2 must cover ALL cell-first symbol-base imm64 sites
Not just plain calls. Codex flagged these emit an absolute symbol-record imm64 that A2 must convert to a PC-relative linkage-slot load:
A2 is the linchpin of BOTH north stars: it is the fasl-as-.so linkage work AND the image code-position-independence (removes the dominant absolute forcing MAP_FIXED). The linkage slot must be RIP-relative and populated by name/offset so the table base is not itself a new code absolute (codex).