17 KiB
Handoff — the x86 backend, wired to the dev loop
Branch dev-loop, worktree agent-aaf84e55296df121c, from f459352. Items 1, 2 and 3 of
HANDOFF-x86-redef.md's "What remains": the new-name path, the transient thunk, and the wiring that lets
flan dev choose the backend for a host and its modules together.
All three landed, and the numbers say the lane was worth it. A C-c C-c round trip through a real daemon
is 62–66ms on LLVM and 27–30ms on --x86 on this machine; C-x C-e is 60ms and 24ms. The build inside
those is 53ms and 17–18ms. Details and caveats in §Measured.
There is one finding that is not good news and it is in §The merged daemon: flan dev --x86 refuses the
merged daemon and needs --two-process, because the compiler's macro module is a third way the two backends
can meet in one process and flan.abi.x86 does not guard it.
What was built
| file | what |
|---|---|
lib/x86.ml — loc's new Lslot, fnctx.slot |
a name the host was never built with: the address lives in a module-local slot the installer fills by string. Lgot's shape with Sym where it has Got |
lib/x86.ml — redefinition's installer |
flan_dev_cell / flan_dev_global lookups, an init image per new global, the consts republish, and a real frame so those calls are made on an aligned stack |
lib/x86.ml — flan_reload_call, flan_reload_transient |
what an expression evaluation compiles to, and the claim the agent unloads on |
lib/x86.ml — string_const |
now bumps Emit.m.nstr, which is the third transient condition |
lib/session.ml — t.x86, change.x86, redefinition |
the backend choice, as a session setting, made in one place |
lib/dev.ml — build_module, module_ext, Dev.start ?x86 |
the daemon picking builder and extension off the change, and the two refusals |
lib/dev.ml — rename_program_main_asm, merged_executable's fork |
an --x86 merged host, which builds and is currently refused (see below) |
bin/main.ml |
flan dev --x86, flan reload --x86, and a bare Failure printed as a sentence |
test/programs/reload-v6.flan |
a run-time-new global with a value of its own |
test/test_reload.ml |
the x86 section runs all four modules against the LLVM transcript; the refusal it asserted is gone |
test/test_dev.ml |
an --x86 daemon driven through C-c C-c, C-x C-e, a literal, a new defvar and a new defn; plus the merged refusal |
1. The new-name path
A function or a defvar the running process has no symbol for. ELF cannot grow one, so the address is asked
for by string at install time and parked in a slot this module defines — flan.cellp.<n> for a function's
cell, flan.gp.<n> for a global's storage, spelled as Emit.cellptr and Emit.globalptr spell them so the
two sides read against each other.
The reference side is one new loc case and nothing else. Lslot loads the slot and answers
Reg (scratch, d), which is exactly what Lgot already did with Got where this has Sym. Every site that
reaches a cell already double-loads, so no call site, no place expression and no sym_loc caller had to learn
a third case. fnctx.slot is a second predicate rather than a widened ext because the two answer different
questions: ext says "the host's, reach it through the GOT", slot says "nobody's yet, reach it through a
slot I filled". It defaults to fun _ -> None, so the whole-program path emits byte-identical output and
survey.sh goes on being a structural check on all of this.
Three things that would each have cost a session:
flan_reload_installhad to become a function with a frame. It was a run of loads and stores plusret, which was fine while it called nothing. The moment it callsflan_dev_cell, rsp is 8-mod-16 at the call and glibc'smovapsinsidestrcmpfaults — a failure that reads as a backend bug and is an ABI bug. Its shape is nowemit_globals_init's, down to owning the null transfer cell no caller hands it.- A new global's declared value has to travel with it.
flan_dev_globalcopies it onto the allocation the first time the name is interned and ignores it after, which is where "a reload must not reset the program's state" lives.emit.mlfolds that value into an LLVM constant and this file has no folder, so the image is a module-local.bssbuffer written by the initialiser lowered as ordinary code — which is the bargainemit_globals_data's comment already makes for a whole program, and it needs no second evaluator that could disagree with the first about what a struct literal means. - Order inside the installer is load-bearing and is not checkable from outside. Every lookup resolves
before any body is published; publishing first exposes a function whose slots are still null to every call
site in the host.
emit.mlsays the same andtest_reload.mlchecks it there by grepping the IR text. There is no text to grep on this side, so the guarantee is the loop order and the comment above it.
reload-v6.flan is new and is the reason the fixture set was not enough. v3's extra is declared zero, which
calloc also gives, so a run-time-new global whose image never arrived would still have passed. v6's tuning
is 42 and the host prints 88.
Republishing a defconst came free once the rest was there — one store of the new constant into the host's
global, which is what emit.ml does — so that refusal is gone too.
2. The transient thunk
flan_reload_call is emit_main without the argv and the exit: no caller hands it a transfer channel, so it
owns a null cell on its own frame and passes that cell's address on. Sixteen bytes of frame rather than eight,
because rsp has to be 16-aligned at the call.
The thunk is excluded from everything else the module does — no cell, no publish, no registry slot. There are
4096 slots and an expression evaluated in a loop would exhaust them, and a module with nothing pointing into it
is what lets the agent dlclose it at all.
@flan_reload_transient is that claim, under emit.ml's three conditions. The third is about data rather
than text and is the one that can be got wrong in the dangerous direction: a string literal lives in this
module's image, an expression may store one anywhere it likes, and a global left pointing into an unmapped
image is silent garbage rather than a fault. So the count is kept where the literals are made — string_const
bumps the same Emit.m.nstr field emit.ml counts on. Two deliberate decisions in that:
- A float constant is not counted. It is a label in the same
.rodataand it is loaded, never retained. Counting rodata bytes would be the wrong test;emit.mlkeeps frame descriptors in a separatenfifor exactly this reason and says so. - The installer's own registry name strings are counted. That is right rather than incidental: a module
that interned a name left something behind, and
flan_dev.cstrdups it precisely because the module that passed it may go away.
3. The wiring
The choice is a session setting, not a per-command flag, and it is spelled exactly as debug already is:
one field on Session.t, set once in Dev.start, carried on every Session.change the session emits.
session.ml's own comment on debug gives the reason and it is the same one — the modules have to match the
process they are loaded into. Session.redefinition is the single place that picks a backend, so the six call
sites cannot disagree and the refusal has one home; change.x86 rides beside change.ir so the text and the
builder can never come from two different answers to the same question.
There is no fallback and there must not be one. If X86.redefinition refuses a form the daemon reports the
refusal. Quietly building an LLVM module instead is precisely the crossed pair flan.abi.x86 exists to refuse
at dlopen. A refusal reaches the editor as a diagnostic like any other: X86.Unsupported is re-raised as a
Loc.Error at the form it is about, because every caller already handles that and none handled the other, and
a daemon that died on the first unsupported form would be worse than one that says so and stays up.
flan reload got the same flag at the same time. A command that could build a module for a host the other
backend compiled is how the crossed pair was reachable from the CLI at all; HANDOFF-x86-aggregates.md's
two-line reproduction no longer has a second half.
--x86 --debug is refused for the daemon, and this is a decision rather than an oversight.
flan build --x86 --debug stays allowed — X86.program emits a hand-written DWARF 4 unit — but
X86.redefinition emits none, so a --debug session would build a host with a line table and then send it
modules without one. A breakpoint set on a line in the buffer would fire before the first C-c C-c and stop
firing after it. Accepting the flag and ignoring it would be worse.
The merged daemon, which is the finding
flan dev --x86 refuses the merged daemon and needs --two-process.
A merged build is the program and the compiler in one process. The compiler expands macros by dlopening a
module Build.macro_module made — through Emit.program, always, and cached on disk under
~/.cache/flan/objcache by the macro source, not by the backend. A merged host is linked -rdynamic so a
redefinition module can reach its cells, and that exports every flan.* body it has. So the macro module's own
copy of a prelude function is interposed by the host's.
With an LLVM host both halves are LLVM and nobody notices. With an --x86 host the caller is LLVM and the body
it lands in is this backend's. Measured, before the refusal went in:
Thread 2 "program" received signal SIGSEGV
#0 0x0000000000403884 in flan[clamp] () <- the --x86 host's body
#1 0x00007ffff6f6b58c in flan.macro[clamp] () from ~/.cache/flan/objcache/flan-macros-....so
#2 flan_macro_call (dynload_stubs.c:68)
...
#19 camlFlan__Session.create_inner (lib/session.ml:117)
#20 camlFlan__Dev.merged_setup (lib/dev.ml:3013)
During the first macro expansion, before the program had started. flan dev --x86 printed its build line
and exited 139.
Three things worth stating about it:
flan.abi.x86does not catch this and was never meant to. It guards a redefinition module.HANDOFF-x86-abi-marker.mdreasoned explicitly that macro modules are unaffected becauseBuild.macro_modulecallsEmit.programwithout~dev, so a macro module neither defines nor requires a marker — and that reasoning was correct for what it covered. This is a third path, and it exists only because the merged daemon puts the compiler and the program in one address space.-Bsymbolicis the wrong fix, though it is one line. The macro module links its ownflan_rt.c, and binding symbolically would bind those calls to a copy of the runtimeflan_rt_initnever ran on — a second bug hiding behind a green suite — and it would change the cached object for the LLVM path, which is the default and must not move.-Bsymbolic-functionshas the same defect.- The honest fix is hidden visibility on a macro module's Flan bodies. That leaves the C runtime symbols binding to the host's exactly as they do now and stops only the Flan ones from being interposed. It still changes the cached object for both backends, so it wants a lane and a suite run of its own.
--two-process has no such meeting: the compiler is a separate binary that LLVM built, the macro module is
loaded into it and never into the program, and the only thing crossing between them is a redefinition module
— which this session now builds with the same backend as the host.
start_merged keeps its --x86 plumbing (rename_program_main_asm, the merged_executable fork, the
FLAN_DEV_X86 environment variable). It is unreachable today. It is the half that is right and it is what will
be wanted the day the macro module is fixed: the --x86 merged host does build and link, and it is the macro
module and nothing about the host that stops it.
Measured
One machine, llc/clang as installed here, warm caches. This machine's llc is slower than the one
docs/BUILT.md was written on — 42–48ms against that file's 15–17ms — so both rows below are measured here
rather than compared against that table. The shape of the answer is what matters and it does not depend on
which machine: llc is replaced by as, and as is five times cheaper.
flan reload, five runs each, on one changed defn:
| LLVM | --x86 |
|
|---|---|---|
| codegen | llc 42–48ms |
as 8.0–8.4ms |
| link | ld -shared 9.7–11.4ms |
ld -shared 8.9–9.3ms |
Read timing.llc_ms carefully: Build.shared_x86 puts the assembler's time in that field, because the
record is shared between the two. flan reload now prints as or llc according to what ran, so its output
does not mislead; anything reading the field directly still has to know.
The round trip a user feels, through a real flan dev --two-process daemon on programs/dev-repl.flan,
measured at the socket — so this includes checking the form, emitting, building, delivering to the agent and
waiting for a frame boundary:
| LLVM | --x86 |
|
|---|---|---|
C-c C-c (eval, one defn) |
62, 66, 65ms | 27, 28, 30ms |
| of which the build reports | 53.0, 52.6, 53.5ms | 17.9, 17.2, 18.2ms |
C-x C-e (eval-expr) |
61, 60, 60ms | 24, 24, 25ms |
Better than twice as fast end to end, and three times on the build. That is the claim the lane was written to test and it holds.
Two things the numbers say that the headline does not:
ld -sharedis now the bulk of the--x86build. 9ms of an 18ms build, and it did not move between the two backends.docs/BUILT.mdrecords it at 3ms on the other machine; whatever the cause, the next millisecond in this loop is in the linker and not in codegen. Nothing here tried to move it.- About 9ms of the round trip is neither codegen nor link. 27ms at the socket against an 18ms build leaves
the frontend re-checking the program, the wire, the
dlopenand the wait for a frame boundary.docs/BUILT.mdputs the re-check "under 10ms" and that is consistent. It was 14% of the LLVM round trip and is 33% of this one, which is the ordinary consequence of removing the big term: the next thing worth measuring has changed.
Verification
before (f459352) |
after | |
|---|---|---|
dune test --root . |
exit 0, 232 checks, 0 failures | exit 0, 232 checks, 0 failures, three runs |
spike/x86/survey.sh |
103 MATCH / 0 DIFFER / 0 REFUSED / 0 NOX86 | 103 / 0 / 0 / 0 |
| skip breakdown | 28 does-not-compile / 8 no-main / 2 runs-forever | 28 / 9 / 2 |
spike/x86/cells.sh |
4/4 ok | 4/4 ok |
bash web/examples/check.sh |
green | green |
The no-main count moving from 8 to 9 is reload-v6.flan existing: survey.sh globs
test/programs/*.flan, and a program with no main fails the LLVM link and is classified there before the x86
build is attempted. reload-agg.flan moved it from 6 to 8 for the same reason. The MATCH count is what
must not move, and it did not.
The survey is the measurement that could have moved and did not, which is why it is run: Lslot and
fnctx.slot default to the whole-program answer and string_const now mutates md.Emit.nstr on that path
too, harmlessly, because nothing there reads it. The survey is what says so structurally rather than by
argument.
One more trap, and it wasted a run here. dune test caches a test's result: the second and third runs of an
unchanged tree print nothing and exit 0 without having executed anything. If what you want is three real runs
— which is what the flakiness in this area asks for — it is dune test --root . --force.
Run dune test --root . without a pipe — piping to tail gives you tail's exit status, and the
dev-robust fixture puts ld and clang failure text in the output either way. Run the survey detached
(setsid timeout 2400 spike/x86/survey.sh > log 2>&1 </dev/null) or a signal to the harness's process group
comes back as 143, which is not a result. And do not run the survey while dune test is running: both take the
_build lock, and the loser reports a lock error rather than a count.
What remains
- The macro module, so the merged daemon can be
--x86. §The merged daemon above has the mechanism, the measurement and the fix that should be tried. This is the largest remaining piece and it is what stands betweenflan dev --x86and being the default shape of the loop. - The linker. 9ms, unchanged between backends, now the bulk of an
--x86build. - DWARF from
X86.redefinition, which is whatflan dev --x86 --debugis refused for. X86.program ~macros, if the macro module is ever to be built by this backend rather than merely made immune to interposition. Not needed for the fix recommended above.test/test_emacs.mlgot no--x86case, deliberately. It drives the Elisp client against a daemon, and the client neither knows nor can say which backend a session uses — the flag isflan dev's and the protocol above it is identical. The real editor path for this lane istest_dev.ml, which drives the daemon's own verbs and is where the case went. Worth revisiting only if--x86ever grows a spelling in the protocol.- Items 2–7 of
HANDOFF-x86-rt.md§6, unchanged. And still worth doing: runspike/x86/survey.shin CI.