Feasible, unforgiving, and not the next thing to do

The verdict, as DISCUSS.md item 15. One function goes from Tast to machine
code and answers correctly, so the question is not whether it can be done.

Three findings decide the shape. Layout is already owned -- emit.ml computes
C struct layout for DWARF and is tested against LLVM's own answer -- so the
silent-drift risk item 10 feared most does not arise. The C boundary is the
easy half, because check.ml already rejects aggregates in a declare and the
shim flattens them. And the hard half was not on anyone's list: Flan calling
Flan passes aggregates by value, and LLVM's lowering of a first-class struct
is per-field rather than the C psABI -- { i8, float } comes back in al and
xmm0 where C would pack it into rax, and a %vec return takes a hidden sret
pointer that does not appear in the define line. The internal convention is
an implementation, not a document.

The audit stands on its own: overflow, shifts and evaluation order are
defined; division by zero, INT64_MIN/-1, the float cast, Uninit and
unreachable are not. Uninit is the one that bites, because poison is where
the two backends are supposed to differ.

Unloading: the shadow stack answers the running half and every dev-build
function is on it -- only the slot table is gated, not the frame. It cannot
answer the pointed-into half, which BUILT.md says is the actual reason
nothing is dlclose'd. Escaped function values need a rule the language does
not have.
This commit is contained in:
Joseph Ferano 2026-09-13 09:22:04 +07:00
parent c73f05b052
commit e403e74b90
2 changed files with 207 additions and 12 deletions

View File

@ -704,3 +704,201 @@ measurement.
and the render-thunk-per-inspection all get deleted. It is the bulk of the work and the whole of the prize.
5. **Keep `llc` + `ld` + `dlopen` exactly as they are.** Item 13's third option. Nothing here argues against it.
6. **Measure what is left.** Then, and only then, item 13 step 4.
## 15. The backend spike, answered: feasible, and the obstacle is not the one anyone expected
Item 13's step 4, run early and deliberately out of order, as a spike rather than as a decision. **Feasible.** A
function written in Flan goes through the ordinary frontend, is lowered to x86-64 by hand, is written into an `mmap`
and is called, and it answers correctly. That took an afternoon and no reference material beyond `objdump`.
The apparatus is `spike/backend/`: `x86.ml` (an instruction selector), `jit_stubs.c` (three calls OCaml cannot make
for itself, plus the C side of the ABI probes), `probe.flan`, `driver.ml` and `run.sh`. `bash spike/backend/run.sh`
reproduces everything below. There is no dune file under `spike/`, nothing is wired into the build, and
`dune test --root . -j 1` is green either side of it.
**The headline is not the arithmetic.** Ten checks pass, and one of them failed first and mattered: a C callee that
does a 16-byte aligned spill and reports whether it was entered aligned. Called plainly it passed; called from inside
a binary operator it returned `-1`. The evaluator spills its left operand across the right operand's evaluation, so a
call written in the right operand runs with `rsp` 8 bytes out. **That is the raylib crash, reproduced on day one of a
backend that does almost nothing.** It is fixed with a depth counter and an assertion, and it is the clearest single
argument that this work is *tractable but unforgiving*: nothing about the wrong version looked wrong, and only a probe
built to catch it caught it.
### Question 1 — does one function work end to end
Yes. The frontend is the real one — `Reader`, `Parse`, `Load`, `Check` — so what is lowered is the same `Tast.fn`
`emit.ml` gets, not a literal typed to make the exercise come out. Eleven of the 83 functions in a trivial program
(the prelude is most of them) lower with no special handling, including `space?`, `digit?` and `upper-ascii`, which
nobody wrote for this.
The emitter is the trivial one the brief allows: every slot is a stack slot at `rbp - 8(i+1)`, every value is computed
into `rax`, a binary operator spills its left operand. Two registers, no allocator, no liveness. `spike-add` is 58
bytes where clang `-O0` would spend about 20, and that is the correct trade for a debug build.
Proved by comparing numbers, not by reading bytes. `SPIKE_DISASM=1` disassembles the buffers that ran, and that is a
debugging aid kept out of the pass/fail path on purpose: a disassembly that reads correctly beside a function
answering 656 when it should answer 650 is the normal outcome of hand-encoding.
### Question 2 — the real shape of the work
**Layout is already owned, and this is the best news in the report.** `emit.ml`'s `lay` / `lay_fields` /
`payload_lay` compute C struct layout — offsets, padding, tail padding, the union payload blob — because DWARF needs
member offsets as integer literals and `getelementptr` cannot supply one. They are acceptance-tested against LLVM's
own answer for the same struct type. So the drift risk item 10 fears most, *two backends disagreeing silently about
where a field is*, does not arise: there is one layout calculator and a new backend calls it.
Against `tast.ml`'s `expr_kind`, in four buckets:
| | nodes |
|---|---|
| **Done in the spike** | `Int` `Bool` `Local` `Do` `Let` `If` `Return` `Set`/`Plocal`, arithmetic, comparison, bitwise, `Call`, `Rt` |
| **Mechanical** | `While` `Break` `Continue` (the jump patching exists), `Global` `Str` `Zero` `Uninit`, the rest of `place`, `Field` `Deref` `Addr`, `Arr`, `Some_` `None_` `UnwrapSome`, `Match` on a tag |
| **Bulky, not hard** | floats — a second register file, SSE encodings, `Cast`'s eight conversions, and the SSE half of the calling convention. Perhaps a third of the total instruction work for a small fraction of the programs |
| **Fiddly** | aggregate copy on assignment (a struct `store` *is* the copy `spec-memory.md` requires), `Make` `MakeCase` `CaseField` over the payload blob, `CallPtr`, `FnAddr`'s three cases and the cell load behind `Fnval` |
| **No plan** | `Handled` `Signal` `RestartCase` `InvokeRestart` `WithAlloc`, the transfer-channel guard after every call, the landing pad, and `fdefers` on the transfer exit path |
The last row is the one to take seriously. The spike never emitted a guard or a pad, and the guard is on *every call
site* in the real thing — `emit.ml`'s `guard`, `current_pad`, `emit_restart_case` and `emit_with_alloc` are several
hundred lines of control flow that a second backend reimplements from the spec rather than copies. Conditions are not
an advanced feature to defer: `spec-conditions.md` is load-bearing in the prelude already.
### Question 3 — the SysV boundary, and the obstacle nobody named
**The C boundary is the easy half, and `BUILT.md` is why.** `check.ml` rejects an aggregate in a `declare` signature
and the generated shim flattens every struct, so no Flan-emitted call ever passes one to C. A string or slice crosses
as `ptr`+`len` — two arguments, which is the only counting subtlety. The spike calls an eight-argument C function
correctly, including the two that go on the stack, and sets `al` for the variadic case. **No aggregate classifier is
needed for raylib. That is a large piece of `plan.org`'s "three classifiers to write and keep correct forever" that
simply does not apply.**
**The hard half is Flan calling Flan, and it was found by reading `signature`.** That function spells each parameter
with `ll ty` and flattens nothing. Emitting a trivial program and looking at the `define` lines:
```
define i64 @"flan.take-slice"(%slice %p0, i64 %p1, ptr %xfer)
define { i8, i32 } @"flan.index-of-i32"(%slice %p0, i32 %p1, ptr %xfer)
define { i8, float } @"flan.min-f32"(%slice %p0, ptr %xfer)
define { i8, %slice } @"flan.split-next!"(ptr %p0, ptr %xfer)
define %vec @"flan.filter-i32"(%slice %p0, ptr %p1, ptr %xfer)
```
The prelude is wall to wall aggregates by value. And what LLVM does with them, measured by `objdump` on clang's own
output rather than read off a table:
- `%slice` argument → `rdi`:`rsi`, two registers, and the next argument shifts along.
- `{ i8, i64 }` return → tag in `al`, value in `rdx`.
- `{ i8, float }` return → tag in `al`, value in **`xmm0`**. C's psABI would classify that single eightbyte as
INTEGER and pack both into `rax`. **LLVM's lowering of a first-class aggregate in IR is per-field, and it is not
the C ABI.**
- `%vec` return (six words) → a hidden `sret` pointer in `rdi`, the real arguments shifted along behind it, and the
pointer returned in `rax`. **That pointer does not appear in the `define` line at all.**
So **the internal calling convention is not specified anywhere. It is whatever LLVM's backend does with a first-class
struct, discoverable only by disassembling.** That is the sharpest obstacle in this report, and it is sharper than
raylib for three reasons: the reference is an implementation rather than a document; the failure mode is a garbage
field rather than a link error; and it is not stable by contract across LLVM versions, which is exactly the coupling
`plan.org` chose text IR to avoid.
It also forces the decision that determines everything else:
1. **Redefinitions only** — the custom backend emits a new body into an LLVM-built host. Incremental, testable one
function at a time, and the path that fits the dev loop. It requires matching LLVM's aggregate convention
bit-exactly, including the mixed integer/SSE case above.
2. **The whole dev build** — the custom backend owns both sides and *picks* the convention: every aggregate by
pointer, nothing classified, done. No matching problem at all. But it needs complete node coverage on day one,
conditions included, and there is no partial version that runs.
The spike leaned on option 1 without noticing, because the probe called C and C is the flattened half. A real attempt
has to choose deliberately.
### Question 4 — where the language leans on LLVM instead of defining itself
The audit, which stands on its own whatever happens to the backend. Each row is drift you do not get if the language
answers it.
| | today | defined? |
|---|---|---|
| Integer overflow | no `nsw`/`nuw`, "arithmetic wraps (plan.org, Types)" | **yes** |
| Shift count | masked to the operand width; a literal out of range is rejected by `check` | **yes** |
| Evaluation order | `map_lr`, and the comment says left-to-right is *required, not a preference* | **yes** |
| Division by zero | nothing. `prelude.ml` calls a remainder by zero "immediate undefined behaviour" and routes around it | **no** |
| `INT64_MIN / -1` | nothing, and it is a separate case. LLVM says undefined; x86 `idiv` raises `SIGFPE` | **no** |
| `f64``i64` out of range | `fptosi`, undefined in LLVM; x86 `cvttsd2si` answers the "integer indefinite" value | **no** |
| `Uninit` | emitted as `poison` | **no**, and see below |
| `unreachable` | after a `noreturn` call, and after an exhaustive `match` | **no** |
| Alignment | no explicit `align` on loads and stores; LLVM uses the type's ABI alignment | implicitly, via `lay` |
Two are worth more than a table row.
**`Uninit``poison` is the one that actually bites, and it bites in the direction item 10 fears.** A hand backend
gives a stable garbage value: whatever the stack slot held. LLVM's optimiser may reason from poison and delete the
code that reads it. So `(uninit)` is the one construct where the two backends are *supposed* to differ and where
"works in dev, breaks when shipped" is the expected outcome rather than a bug. The language should say what reading an
uninitialised value means before a second backend exists, not after.
**`unreachable` is the cheap one.** The spike emits `ud2`: a defined `SIGILL` at the instruction that fell through.
LLVM's `unreachable` is undefined behaviour and licenses the optimiser to delete the path. Defining it as a trap costs
two bytes and turns a class of miscompile into a crash with an address.
None of this needs a backend. It is a session with `plan.org` and six `check.ml` cases.
### Question 5 — unloading code, which is the prize
**The shadow stack is better than expected and still not sufficient, and the two halves of that are separate
questions.**
*The running half — and the shadow stack does answer it.* The frame push in `emit_fn` is inside a plain `if m.dev`
and is **not** gated: only the *slot table* is gated on `named && n > 0`, and a function with no named slot still
pushes a frame with a null `slots`. So every active Flan function in a dev build is on the chain, lifted handler
clauses included, and each frame points at the `flan_fninfo` belonging to the module it was compiled into — so the
pointer identifies not just the function but *which body*. "No frame on the chain names this body" is answerable
today, with no DWARF and no unwinder.
Two caveats on that half. The pop happens before `leave; ret`, so a body is briefly executing with no record —
irrelevant if reclamation happens at a safe point on the same thread, fatal if another thread reclaims while the game
thread is returning. And the chain is a plain global, not thread-local, which `flan_dev.c` states and justifies.
*The pointed-into half, which the shadow stack cannot see and which is the actual reason nothing is `dlclose`d today.*
`BUILT.md` is explicit, and it is not the reason the brief assumed: "a cell holds an address inside a module's text;
unloading it leaves every call site pointing at unmapped memory. The rule is about being *pointed into*." Owning the
code object answers most of this — you own the cells, so redefinition drops the old body's last cell reference — but
not all of it:
- `FnAddr (Fnval n)` **loads the cell and yields a raw body address**, which can then be stored in a struct, a `Vec`
or a global. Nothing records that it happened.
- `FnAddr (Flanfn _)` and `(Rtfn _)` bypass the cell *by design*`tast.ml` says they "must never take that path" —
so a `Map`'s hash and equality pair and a handler-bind clause's address are raw pointers into a specific body, held
in data.
So: **frames are tracked, escaped code pointers are not.** Unloading needs a rule the language does not have yet. The
cheapest honest one is deferred reclamation — retire a body when no frame names it *and* an epoch has passed with no
new capture — and the cleanest is to make a function value a cell pointer rather than a body pointer, which costs one
indirection on `CallPtr` in dev builds and makes the whole question go away. That second option is worth writing down
now whatever happens to the backend, because it is a change to what a `Fn` value *is*.
### The verdict
**Feasible, unforgiving, and not the next thing to do.**
Feasible: the instruction selection is easy, layout is already owned and tested, the C boundary is already flattened,
and one function ran on day one. Nothing here argues the way item 10 feared — the divergence hazard is real but it is
concentrated in three named places (`Uninit`, division, the float cast), not spread through the whole of arithmetic.
Unforgiving: the internal aggregate convention is defined by LLVM's implementation and not by any document, the
alignment rule is invisible until raylib crashes somewhere else, and conditions are a second full implementation of
`spec-conditions.md` rather than a port.
Not next: item 13's order still holds, and the spike does not disturb it. Transport is 41µs of a 21ms redefinition and
code generation is 19 of the 21, so the *speed* case remains what item 13 said it was. What this spike adds is that
the **introspection** case is also not free — unloading needs a rule about escaped function values that nothing in the
language has, and that rule is worth having whether or not a backend is ever written.
**What to do first if it went ahead**, in order, and the first two are worth doing regardless:
1. **Define the six undefined cases** (question 4). No backend required, and every one is drift avoided rather than
drift managed.
2. **Decide what a `Fn` value is** — body pointer or cell pointer — and write it down. This is the unloading question
and it is a language question, not a backend one.
3. **Choose option 1 or option 2 from question 3**, deliberately. Everything else follows from it.
4. **Only then**, and only if 3 says so, grow `spike/backend/x86.ml` from the node table in question 2 — floats
first, because they gate most of the prelude, and conditions last, because they are the only row with no plan.

View File

@ -91,15 +91,12 @@ int64_t spike_probe_align(int64_t x) {
return x + (int64_t)(v[0] + v[1]);
}
/* A double in xmm0 alongside integers, and al = number of vector registers
* used is *not* required here because this is not variadic -- which is itself
* the thing to record. */
double spike_probe_f(int64_t a, double x, int64_t b, double y) {
return (double)a + x * 2.0 + (double)b * 100.0 + y * 200.0;
}
/* A small struct by value. BUILT.md's "Why the FFI goes through a C shim"
* says Flan never emits one of these -- the shim flattens it. This is here to
* measure what the shim is saving us from, not because the backend needs it. */
typedef struct { float x, y; } spike_vec2;
float spike_probe_struct(spike_vec2 v, float s) { return v.x * s + v.y; }
/* No float probe either, for a plainer reason: this emitter has no SSE, so
* there is nothing here that could call one. Floats are counted as work in
* item 15 rather than claimed as done.
*
* And no struct-by-value probe, and that is a finding rather than an
* omission: check.ml rejects an aggregate in a [declare] signature and the
* generated shim flattens every one, so no Flan-emitted call ever passes a
* struct to C. The aggregate problem is real but it is on the Flan-to-Flan
* side, which is measured in DISCUSS.md item 15 and not from here. */