A release build stops calling into a registry that is switched off

emit.ml:1918 drops the allocation registry's notes in a release build --
the checker builds a Tast.Rt it cannot know is unwanted, because it does
not know whether this is a dev build. The x86 backend had no counterpart
and emitted the calls for real: correct, since the registry answers
nothing when it is disabled, but one call per container operation into a
function that returns immediately.

The guard is emit.ml's byte for byte, strict > 17 included: bare
flan_dev_reg_note is the runtime's own C entry point and is never a
Tast.Rt, and what check.ml builds is the _vec, _map and _pool wrappers.
emit.ml drops the note before the arguments are walked so that taking the
address of the container does not leave an escaped alloca behind; here the
arguments are not touched until call_rt, so answering () is already early
enough.

Measured, call sites of flan_dev_reg_note in the disassembly:

  vec.flan       46 -> 1     (--dev: 46)
  maps.flan      55 -> 1     (--dev: 55)
  registry.flan  36 -> 1     (--dev: 36)

The one left in each is not emitted code -- it is inside the runtime's own
flan_dev_reg_note_vec. An LLVM release build of vec.flan has the same one,
so the two backends now agree.

HANDOFF-x86-debug.md is the stub for item 6, which is next, and it leads
with the finding that changes that item's plan: .loc does not work against
a backend that emits .byte blobs, so the line table has to be written out
by hand.
This commit is contained in:
Joseph Ferano 2026-09-13 23:14:32 +07:00
parent eacf7c489f
commit 853bc35be7
2 changed files with 101 additions and 0 deletions

84
HANDOFF-x86-debug.md Normal file
View File

@ -0,0 +1,84 @@
# Handoff — dropping the registry notes, and debug information for `--x86`
Branch `dev-loop`, worktree `agent-a123a91f32f4c9779`, from `957ba07`. Items **2** and **6** of
`HANDOFF-x86-rt.md` §6.
## The finding that changes the brief, read this first
**`.loc` does not work in this backend's output, and no amount of care makes it work.** The brief called line
tables "by far the least work, since `as` will build `.debug_line` for you from `.loc` directives". That is
true of a backend that emits mnemonics. This one emits `.byte` blobs — the header says so and says why — and
GAS builds its line table from `dwarf2_emit_insn`, which only runs out of `md_assemble`. No instruction is
ever assembled, so nothing flushes the pending `.loc`.
What GAS does instead is flush the pending `.loc` when it sees the *next* `.loc`, at whatever the location
counter has reached by then. Measured on `as` 2.44:
```
.file 1 "foo.flan"
main: .loc 1 10 1
.byte 0x55
.byte 0x48,0x89,0xe5
.loc 1 11 1
.byte 0xb8,0x07,0x00,0x00,0x00
.loc 1 12 1
.byte 0xc9
.byte 0xc3
```
```
foo.flan 10 0x4 x <- should be 0x0
foo.flan 11 0x9 x <- should be 0x4
foo.flan - 0xb <- line 12 has no row at all
```
Every row is one statement late and the last statement in the function gets none. Interposing labels between
the `.loc` and the `.byte` does not help; it was tested. Do **not** try to correct this by shifting the `.loc`
directives back by one statement — the last-statement hole is a correctness gap, not just fragility, and the
behaviour being exploited is undocumented.
So the line table is written out by hand here, as raw `.byte` / `.uleb128` / `.quad` in `.debug_line`, which is
the same thing this backend already does for instructions. Scope grew accordingly; see the plan below.
## Plan, in the order it is being done
1. **Item 2 — `flan_dev_reg_note` dropped in a release build.** Done; see below.
2. A hand-written `.debug_line`, plus the minimal `.debug_info` / `.debug_abbrev` CU that makes a debugger
*find* it. These are one step and not two: `readelf` will read a bare `.debug_line`, but gdb reaches a line
table only by walking `.debug_info` and following `DW_AT_stmt_list`.
3. `DW_TAG_subprogram` DIEs, so `break` and `list` scope by function. Note that gdb already names frames from
the ELF symbol table, which this backend emits via `.globl`, so this buys less than it looks like.
4. `.cfi` — only if it can be placed correctly against `.byte` output. If it cannot, that is said plainly and
gdb's prologue analyser is left to recognise `push rbp; mov rbp,rsp; sub rsp,N`, which is canonical.
5. Locals and types, only if everything above is solid and committed.
## Item 2 — done
`lib/x86.ml`, `prim`'s `Tast.Rt` dispatch, one arm above the general one, mirroring `lib/emit.ml:1918` test for
test including the strict `> 17`: bare `flan_dev_reg_note` is the runtime's own C entry point and is never a
`Tast.Rt`; what `check.ml` builds is the `_vec`, `_map` and `_pool` wrappers. The node's type is `Unit`, so the
body is `()` and `dst` is untouched.
`emit.ml` is careful to drop the note *before* the arguments are walked, because emitting the address of the
container only to discard the call would leave an escaped alloca that mem2reg refuses. Here the arguments are
not touched until `call_rt`, so the guard is already early enough.
Measured by disassembly, `objdump -d | grep -c 'call.*flan_dev_reg_note'`:
| program | `--x86` release | `--x86 --dev` | LLVM release |
|---|---|---|---|
| `vec.flan` | 46 → **1** | 46 | 1 |
| `maps.flan` | 55 → **1** | 55 | — |
| `registry.flan` | 36 → **1** | 36 | — |
The one that remains in every release build is not emitted code: it is inside the runtime's own
`flan_dev_reg_note_vec` in `flan_rt.c`, which calls `flan_dev_reg_note`. LLVM's release build has exactly the
same one, so the two backends now agree.
## Baseline
Measured on this tree, not recalled. (Filled in below as it is measured.)
## Open questions
(none yet)

View File

@ -2245,6 +2245,23 @@ and prim f (e : Tast.expr) (p : Tast.prim) (args : Tast.expr list) dst =
let l = lvalue f a in
addr_into f ~reg:rax l;
store_int f.b ~src:rax ~mm:(lmem f dst ~scratch:r11) ~size:8
(* The allocation registry's notes are the one runtime family a release
build drops on the floor, and [emit.ml:1918] drops it with this same
guard the checker builds a [Tast.Rt] it does not know is unwanted,
because it does not know whether this is a dev build and does not have
to. [emit.ml] is careful to drop it before the arguments are walked, so
that taking the address of the container being described does not leave
an escaped alloca for mem2reg to refuse; here the arguments are not
touched until [call_rt], so answering [()] is already early enough. The
test is [emit.ml]'s byte for byte, strict [>] included: bare
[flan_dev_reg_note] is the runtime's own entry point and is never a
[Tast.Rt]; what [check.ml] builds is the [_vec], [_map] and [_pool]
wrappers, each of which is longer than the prefix. The node's type is
[Unit], so there is nothing to store and [dst] is untouched. *)
| Tast.Rt sym, _
when (not f.md.Emit.dev)
&& String.length sym > 17
&& String.equal (String.sub sym 0 17) "flan_dev_reg_note" -> ()
| Tast.Rt sym, _ -> call_rt f ~sym ~args ~rty:t dst
| Tast.Cast target, [ a ] -> cast f a target dst
| _ -> unsupported "primitive with %d arguments" (List.length args)