The Gallina backend: a nanofil IR compiler extracted from Rocq
November 2026. A campaign plan, written before any of it is built. It extends the formal campaign, which today scopes "implementation correctness" (native lowering) out of proof. This campaign moves the lowering itself into Rocq, so that claim comes within reach later.
The ambition
nanofil's middle and backend, from the IR to machine code, become Gallina functions in the Rocq development. Extraction turns them into a standalone nanofil IR compiler: first OCaml, then C++ through Bloomberg's Crane, then that C++ built by Fil-C as library code a program can call at run time.
Four things are wanted from it:
- Speed. The Prolog compiler spends about 650G user
instructions on
sqlite3.cwithout-Oand about 2000G with it (perf.md, item 10). A compiler of this shape in a compiled functional language should need a small fraction of that. The speed is wanted for JIT use and for very large files, not for everyday C builds; the scope note below says why. - A JIT component. A program that generates code at
run time, such as pcre2, can hand the compiler nanofil IR instead of
writing machine code itself. The regex experiment already does this with
the Prolog compiler,
as,ldanddlopen; what it lacks is a compiler fast enough to wait for. Built by Fil-C, the compiler is also a memory-safe library such a program can link. - Less mirroring.
Syntax.v,Cfg.vandValidate.vtranscribeir.plandcfg.plby hand, and gates check that the two agree. A backend written against the model's own definitions has nothing to mirror, and each stage that moves lets its Prolog twin retire. - More proofs. A lowering that is a Gallina function can be the subject of a theorem. So can a frontend that targets the IR: a MetaRocq extractor from typed λ□ to nanofil IR could be reasoned about through the IR semantics the model already has.
Scope: the C frontend stays in Prolog. That is not because it is cheap. Measured at M1, the frontend is about 40% of the plain SQLite compile, and 325 of the 347 s the 281-module test corpus takes, because headers dominate an ordinary translation unit. So a perfect backend barely speeds up everyday C compiles.
The campaign's speed goal serves two other uses:
- JIT use, such as pcre2 patterns compiled from IR, where there is no C to parse and the backend is the whole compile;
- very large files, where the backend is more than half of it.
The scope is mbrock's, and so is the reason: "i excluded the c frontend from the campaign scope mostly because it's big and messy and i am kinda mostly driven to do all this from thinking about jit etc". The frontend is something to reconsider later, outside this campaign.
What exists
The Prolog pipeline after the frontend
Lines are wc -l of nanofil/src today. guide.md walks one function through all of it.
- Middle end,
-Oonly:inline.pl707,promote.pl901,copy.pl329,simplify.pl794,local_storage.pl134, withescape.pl283,alias.pl167 andopt.pl154 to drive them.cfg.pl599 gives them the CFG, dominators, loops and liveness. - The checked stage:
pizlonate.pl267 states one check before each access, aflightfor each pointer computation and a poll on each backward edge.ir.pl2859 holds the grammar andvalid_module/1.check_reuse.pl1196 and its companions (half_check.pl,schedule.pl,check_share.pl) are the optional--checksevidence passes. - The Fil-C backend:
filc_abi.pl510 plans each function's frame, roots, entry and exit actions and call layouts.frame_slots.pl261 places slots.filc_amd64.pl3718 emits instructions, access checks, function objects, data and TLS.amd64.pl850 holds the scalar selection (compute_op//5) thatfilc_amd64.plreuses.asm.pl230 is the line terms and the AT&T printer. - Registers:
amd64_regs.pl1119 allocates, andamd64_regs_check.pl1395 withamd64_contract.pl230 checks each allocation by translation validation.
Without -O the path is the reference
path: pizlonate, validate,
filc_abi:lower_function/5, then filc_amd64
with --registers=stack, where every temporary lives in a
frame slot, and asm:print_asm/1. Its other defaults are
--checks=reference, --branches=reference and
--memcpy=call. About 5,800 lines of Prolog are on it, most
of them in filc_amd64.pl.
The Rocq model
In nanofil/rocq/theories, on Rocq 9.1 with stdpp and
Iris:
Term.vis ground Prolog terms.Syntax.vis the checked IR's core subset as inductives (op,inst,jump,block,func, the module's items).Maps.vis name maps andCfg.vtranscribescfg_idoms/2anddom_intervals/3.Semantics.vis the small-step meaning (step,step_rel) overValues.v,Memory.v,Ops.vandIeee.v, with the runtime as the module type inRuntime.v.Machine.vis the machine the proofs run on.Validate.visvalid, the validator, andSoundness.vprovesvalid_no_uncheckedabout it.Transport.vdecodes a printed module intoSyntax.v(decode_module); a function outside the subset stays asIOutsidewith its term.Extract.valready extractsdecode_module,judgeandjudge_validto OCaml, with zarith integers and native strings.extraction/main.mlreads Prolog canonical terms by hand, andmakelinksnanofil-model.rocq/gallina.plprints a checked module as a Gallinatermfor theNAMECode.vfixtures.
The syntax covers the checked stage only. It has no unchecked
load or store, and no vectors,
cas and atomics, setjmp,
asm_fixed or touch. Of Lua 5.4.7, every
function but one decodes into it.
The first version
The first version is the reference path and nothing else:
- the asm line terms and their printer;
- the frame and ABI plan;
- stack-homed emission for the model's core subset;
- pizlonation, with the model's
validbetween it and emission.
It reads IR and writes assembly text for GNU as, the same text the Prolog reference emission prints.
It leaves out:
- the whole middle end (inline, promote, copy, simplify, local storage, escape and alias analysis);
- the check-reuse passes, and emission of the reuse forms they make
(
renew,half_check,check_limitand the rest); - the
amd64_regsallocator, its two checkers and thetempsandpointersmodes, with--operands,--branches=selected,--memcpy=inlineand--share; - plain (non-Fil-C) mode, and every construct outside
Syntax.v's subset. A module with anIOutsidefunction is refused, and the Prolog compiler compiles it as before.
"The fast simple register mode", as I read it.
Prolog has three register modes today: stack,
temps and pointers. Only stack is
simple, and it is the reference every other mode is checked against, so
the first version ports stack. A register allocator that is
fast and simple by design (one pass, block-local or linear scan, no
translation checker because it is proved instead) is its own later
milestone, M7. It is not a port of amd64_regs.pl: that
allocator and its checkers are half of -O's compile
time.
Reusing the model's definitions
The backend imports the model; it does not copy it.
- The input type is
Nanofil.Syntax.func. There is no second inductive for the checked IR.class,width,op,value,inst,jumpand the data items are the onesSemantics.vgives meaning to andValidate.vjudges. - Names are
Term.name, maps areMaps.v, block order and dominators areCfg.v, and decoding isTransport.decode_module. - The pipeline calls
Validate.validitself. Its theorem then speaks about exactly the module the backend lowers. - What is new belongs to the backend alone: asm lines and operands, frame plans, and the emission functions.
The new files live in their own project,
nanofil/rocq/backend/, with
-R ../theories Nanofil and -R . Backend, on
the pattern of rocq/spikes. Its Nix check compiles against
the model's compiled theories and is not part of the quick
nanofil-rocq gate, so that gate does not grow. It touches
no file of the program logic or the libc contracts.
Two places need more than the model has, and both go to the model's owner as requests instead of being worked around:
- The unchecked stage. Pizlonation's input has
load,storeand unproven calls. Recommendation: a backend-ownedUnchecked.vthat reusesclass,value,opandjumpand adds only the unchecked instruction forms, soSyntax.instand every proof by cases over it are untouched. - Constructs outside the subset. Vectors, atomics,
setjmpand fixed-register asm reach the backend only whenSyntax.vand the semantics gain them. The backend never invents a private constructor for one.
Checking it against the Prolog compiler
The Prolog reference emission is the oracle, and the standard is byte identity.
- Per stage. Each ported stage gets a Prolog command that prints its output as canonical terms: the asm lines, the frame plan, the pizlonated module. The extracted stage must produce the same terms, or the same text.
- End to end. For every module of the corpus, the
assembly from the extracted compiler equals
nanofil compile's. The corpus is what exists:tests/filc/*.ir,tests/c,tests/c/prog, random modules fromtests/gen.plby seed, and the whole programs (hello, zlib, bzip2, Lua, SQLite). - By behaviour.
difftest.shandprogtest.shrun with the extracted backend swapped in, for the day the two are allowed to differ. - Coverage is reported, not assumed. Each run prints
how many functions were compiled, and how many modules were refused for
an
IOutsidefunction.
Byte identity holds until the Gallina backend is the reference. From then on a stage's Prolog twin is retired instead of kept in step, and that is where the mirroring goes away.
The quick subset of these tests runs in the backend's own Nix check. The large programs run in the package checks, which already exist and already run after a push.
Extraction
OCaml first. It is the extraction Rocq ships, the
model already uses it, and the driver, zarith and the Nix build are in
place. The compiler's entry points are Gallina functions from text to
text, so the OCaml driver only reads a file and writes one. Text is
built as lists of parts and joined once, as asm.pl does:
SQLite's assembly is about 100 MB.
Then Crane. Crane is a Rocq plugin
forked from the OCaml extraction. By its README it emits C++23 that
needs no runtime system and manages memory with shared_ptr,
is LGPL-2.1, installs against Rocq 9.0 and is under active development
with parts still experimental. Known risks, each settled in M6 before
anything depends on it:
- our pin is Rocq 9.1.1, so the plugin may need porting or a second Rocq in the build;
- arbitrary-precision
Zneeds a C++ home (a small bigint, or checked fixed-width integers where a bound is proved); - reference counting has a cost OCaml's collector does not, and deep
recursion over long lists needs care in C++ as it did in OCaml
(
Extract.vrecords one such overflow).
Crane is an alpha, and we expect to patch it. Its site says so, and it will not take all of Gallina. Three rules follow.
- The backend stays in a plain subset: first-order inductives,
structural fixpoints,
list,option,Z, strings and records. No coinductives, no universe polymorphism, no type classes or functors on the extracted path, and no local fixpoint that is mutually recursive with its enclosing function (Crane's manual lists that shape among those its loop transform declines). The OCaml extraction does not care, and any second extractor will. - Crane's own switches are tried first. Its reference manual has type
mappings (
Crane Extract Inductive),Set Crane Arenafor region allocation of recursive fields,Set Crane NonAtomicRcfor a non-atomic count in place ofshared_ptr,Set Crane Reusefor in-place reuse andSet Crane Loopifyto turn recursion into loops. A compiler that runs once per module or per pattern and then frees everything is the case arenas are made for. - Past the switches, it is our fork. A change to what Crane emits is a patch in a set we own, applied by the Nix build to a pinned Crane revision, and offered upstream where it is general. The differential tests are what keep a patched extractor honest: the C++ build must print the same bytes as the OCaml build.
The fallback is Gallina to C. If Crane costs more to bend than it saves, CertiRocq (formerly CertiCoq) compiles Gallina to Clight, which any C compiler takes, and brings a small C runtime with its own collector; the Codegen plugin is a second, narrower route to C. Either gives C that filcc can build, so the library and JIT steps below do not depend on Crane. Neither is tried unless M6 fails, and whether CertiRocq's runtime (its tagged values and its collector) is at home under Fil-C is the first thing to find out then.
The Gallina is written with the second extraction in mind from the
start: no OCaml-only Extract Constant on a hot path without
a C++ twin, and no hand-written OCaml that the compiler's result depends
on. The canonical-term reader in main.ml is such code
today, and M4 replaces it with a Gallina one.
The path to a Fil-C library and a JIT
The first JIT needs no encoder and no new way to load code. The regex
experiment (its
README, pcre2-jit.md)
already runs one. Behind pcre2_jit_compile, a patched
libpcre2 prints a pattern's bytecode as terms and runs a compiler,
rxc, on a cache miss. The experiment's regex compiler,
itself Prolog, turns the terms into unchecked nanofil IR, then
rxc runs nanofil -O compile, as
and ld -shared. The library loads the shared object with
dlopen and keeps it in a cache directory. The Fil-C runtime
the code calls is already in the loading process, so the trusted runtime
does not change.
What that route lacks is a fast compiler. A miss costs 0.15 to 0.8 s
per pattern on SWI-Prolog, and nearly all of it is
nanofil -O compile: as and the link together
take under a tenth of a second even for a module of 36 functions. On
Fil-C Trealla, which is what a libpcre2 with its compiler inside would
run, the median is 2.4 s. sljit takes 0.1 ms. So the steps are:
- The extracted compiler in
rxc.nanofil-backend compiletakes the place ofnanofil -O compile. The regex compiler emits unchecked IR, so this needs pizlonation (M4) and nothing after it. The regex compiler itself stays Prolog for now (0.07 s for 36 patterns). - Code worth running. The first version's code is
what Prolog makes without
-O, which the experiment measured at 5 to 10 times slower than-Ocode. That is about the interpreter's speed on some patterns, so the route is not yet worth switching on. With-O's middle end and check passes but stack homes, the code is only 1.1 to 1.7 times slower than full-O. So for pcre2 the check passes matter far more than a register allocator. M5 measures which of them buy the difference on the regex compiler's IR, and those are ported first. libnanofil, pizlonated. Crane's output is plain C++23, and Fil-C is a C++ compiler (clang 20 with libc++), so the extracted compiler is built with filcc and given a C interface: IR text or a term tree in, assembly text out. libpcre2 links it and calls it in the process; the hook still runsasandldand loads withdlopen. It is memory-safe against any input, by Fil-C, and checked by the same differential tests as the OCaml build. Crane's README asks for Clang 19 or later; whether Fil-C's libc++ has everything the output uses is found out in M8.
Two further steps are options for later, taken only if measurement asks for them:
- An encoder and a shared-object writer in Gallina,
in place of
asandld, checked against them byte for byte. The result is still loaded withdlopen, so this too leaves the runtime alone. It removes two process starts per miss and the need for binutils where the program runs. - Installing code in the process, without a file.
Fil-C's
mmaprefusesPROT_EXEC, so a program cannot run bytes it wrote. The pinned staging runtime has one JIT (filc_llvm_jit.c,filc_llvm_jit.h), which compiles LLVM IR built through a curated C API and places the code in its own heap; it loads LLVM into the process and is refused to setuid programs. A nanofil entry of that kind would be a change to the trusted runtime. It would get a design note and an Oracle review before any code, and it is mbrock's decision.
The dlopen route trusts the cache directory, as the
experiment's README says: what is in it is loaded as code. The validator
is the asset on the compiler's side. valid is already
Gallina, already extracted and already proved
(valid_no_unchecked), so the compiler checks a client's IR
after pizlonation and before emitting anything.
Performance targets
These are targets. The figures known before the campaign are perf.md's:
sqlite3.c, 3106 functions and 218k checked instructions: 651.6G user instructions without-O, about 2000G with it. In a loaded run emission was 33 s of the plain compile and 151 of 269 s at-O.- Across 909 translation units,
-Ocosts 2.17× plain.
Targets, each measured in perf stat user instructions on
the same input and the same stages:
- OCaml, M3: the ported stages take at most a
twentieth of the Prolog stages' instructions on
sqlite3.cand onlvm.c. - OCaml, after M5: at most a fiftieth, with checked IR to assembly for all of SQLite inside 10G instructions.
- Crane C++: within 2× of the OCaml build.
- Pizlonated: within 4× of the unsafe C++ build, the top of Fil-C's usual range.
- As pcre2's compiler: IR to assembly for one pattern
in at most 10 ms, against 0.15 to 0.6 s in Prolog, so that a miss is
bounded by the regex compiler,
asandldat about a tenth of a second. The code's speed has its own target once M5 has measured the passes: within 2× of-Ocode on the experiment's nine benchmark patterns.
A stage that misses its target gets a profile and a note of where the time goes before the next stage starts.
The baseline, measured at M1. CPU seconds of the plain compile in SWI-Prolog, stage by stage, on one core of an orb (a 2.6 GHz Xeon):
- SQLite 3.51.2's amalgamation with
nix/sqlite.nix's flags, preprocessed: 141,500 lines in, 5.52 million asm lines and 135 MB out. Frontend 35.9, pizlonate 6.3, validate 13.6, emit 20.6, print 12.8. The stages this campaign ports are 53.3 of 89.2 s. - The 281 modules of the test corpus together: frontend 325.5, pizlonate 2.3, validate 5.6, emit 9.9, print 3.2. Their headers dominate, so the corpus tests sameness and SQLite tests speed. With the frontend at 94% of the corpus's time, no backend makes those compiles much faster.
At M1 the extracted printer printed SQLite's lines in 5.0 s, which is 2.5 times Prolog's speed, within 230 MB. Two things follow:
- Printing is where Prolog is nearest to native code, because SWI writes its text in C. The Gallina printer makes a string per digit and a list cell per part. So this ratio says little about emission.
- A twentieth of 53.3 s is 2.7 s, and the printer alone takes 5.0. The M3 target cannot be met unless the printer gets faster too, so M3 profiles it first and M5 owns what remains.
The profile, at the start of M3.
perf record with DWARF call graphs, on SQLite, in CPU
seconds: an orb has no hardware counters, so instructions are not
counted here. The targets for the two parts are a twentieth of Prolog's
stages: 0.64 s for printing, and 1.0 s for the plans and the emission
together.
The printer went from 5.0 s to 1.1 s:
- To 2.1 s by reading 64 lines at a time and not 4096. The terms, lines and parts of a large chunk outlived OCaml's 2 MB minor heap and were copied to the major one. This was the driver's fault and not the printer's.
- To 1.8 s in
decimal. The extracted code builds a number or a list written inside a function again at each call, so every digit built the number 10 three times and the list of ten digits once. They are definitions of their own now. The bound on the digits was the count of the number's bits, taken a bit at a time; it is 18 for numbers below 10^18. - To 1.5 s with one division per digit, and with zarith's conversions
between
Zandnatwhere the stdlib's count by ones. - To 1.1 s by filling a buffer per chunk; each part was a write.
What remains is a list cell per part and a string per digit.
The plans went from 7.8 s to 2.6 s:
- To 3.7 s by taking OCaml's hash of a string for a name's bucket, as the model's extraction does. The Gallina hash is eight integer operations per character.
- To 2.6 s with a 32 MB minor heap in the gate.
Of what remains, liveness is a third, the first and last touch of
each temporary a quarter, the fusing of checks a fifth, and the lowering
of instructions a tenth. No one function stands out. About half of all
of it is finding and adding names in maps: a name is a term such as
tmp(v12), hashed and compared as one at every step. The
repair is to number a function's temporaries once and key the maps and
sets by number, which changes every pass of M2 and belongs to M5. The
gate's 1.4 GB for SQLite, nearly all of it the module read as terms,
goes with M4's reader.
Both parts still miss their targets, by less than twice for printing and by more than twice for the plans before any emission. M3's emission is written with this in mind, with no number or list written inside a loop.
Milestones
Each ends with its gate green and a short note of what it found.
M0. The plan and the project. This document.
rocq/backend/ with its _RocqProject, a build
script, extraction to a nanofil-backend binary and a Nix
check outside the quick gate. Gate: the empty project builds, extracts
and passes parse.sh.
Done. make -C rocq/backend builds
nanofil-backend in two seconds. It compiles only the model
files the backend imports, which is Term.v so far, so it
does not wait for the model's proofs. The flake checks are
nanofil-backend, which takes ten seconds once its inputs
are built, and nanofil-backend-full. They have their own
workflow, and the quick Rocq gate does not read the directory.
M1. Asm lines and the printer. Asm.v:
lines, directives, operands and expressions as inductives, a decoder
from terms, and the printer. A Prolog command prints a module's line
list as terms. Gate: for the corpus, the printed text equals
nanofil compile's, byte for byte; the per-stage baseline is
measured and recorded here.
Done. The command is
nanofil asm-terms FILE TEXT, which writes the text and
prints the lines it is the text of. Asm.v has the types and
the printer, AsmTerm.v the decoder, and
rocq/backend/tests/asm.sh is the gate. The text is the same
for all 280 Fil-C modules of tests/filc,
tests/c and tests/c/prog, with and without
-O, and for SQLite's 5.52 million lines. Seven files are
skipped: six that nanofil refuses without their test's flags, and one
plain module. Found:
- A
matchon string literals extracts to a decision tree per bit: the decoder was 11,701 lines of OCaml. Names are compared withString.eqbinstead, and it is 1,713. - A driver that holds a module's terms, lines and parts needs 4 GB for SQLite. It now works a chunk of lines at a time.
- The baseline is under Performance targets above.
M2. Frames and the ABI plan.
filc_abi.pl and frame_slots.pl: slots, roots,
entry and exit actions, call layouts, signatures. Gate: the plan of
every corpus function in the subset equals Prolog's, as terms.
Done. The command is nanofil abi-terms FILE,
which prints the checked module, the plan of each function and the plans
of its weak thunks. Plan.v has the plan's types, whose
instructions are Syntax.v's. Fuse.v joins a
check to its access, Liveness.v and
FrameSlots.v give temporaries their slots,
Abi.v builds the plan, and Order.v is Prolog's
standard order of terms, which the plans are sorted by.
IrTerm.v and PlanTerm.v write a plan as
Prolog's term, and rocq/backend/tests/abi.sh is the gate.
Only the reference path is compared, without -O.
On the 280 modules of the corpus, the plans of 1089 functions and all
280 lists of thunks are the same terms. The backend refuses no function
of the subset. The model keeps 576 functions outside it, and they are
passed over: 535 for touch, 12 for asm_fixed,
11 for fence, 9 for setjmp and 9 for
raw_cas. On SQLite all 3106 plans and the thunks are the
same, with nothing outside. Found:
filc_abi.pl'sadapter_return/2zeroed too few aux words when a tuple with a float came back. Its test for a float leftKbound to the first float's class, and the list of zeros then kept only the cells of that class. Fortuple([l, s])the adapter zeroedaux(1)and leftaux(0)as it was, which let a caller reading the result as a pointer use a stale capability. Prolog is fixed (21ca173), andAbi.v, which had followed it to give the same plan, now zeroes the aux of every cell that holds no pointer.touchis the pizlonatedaccess, and it keeps a third of the corpus's functions out of the gate. It is the first construct to ask the model's owner for.- Building SQLite's plans took 7.8 s in OCaml, more than the whole M3 target of 2.7 s. The profile under Performance targets brought it to 2.6 s and says where the rest goes.
M3. Stack-homed emission.
filc_amd64.pl's reference emission and
amd64.pl's scalar selection, in pieces: scalars, compares
and jumps first; then checks, flights, raw accesses and polls; then
calls, variadics, function objects, data, aliases and TLS. Gate:
whole-module byte identity on the corpus, hello, zlib and Lua, with the
first performance target.
M4. Pizlonation and a standalone compiler.
Unchecked.v, Pizlonate.v and a Gallina reader
for IR text. The pipeline is read, pizlonate, valid, lower,
emit, print. Gate: the pizlonated module equals Prolog's as a term, and
nanofil-backend compile FILE.ir equals
nanofil compile FILE.ir with no Prolog in the pipeline.
M5. Speed, and pcre2 through rxc.
Profile, fix data structures, record the numbers in perf.md's style.
rxc gets a switch that runs nanofil-backend in
place of the Prolog compile. Gate: the second performance target; the
experiment's checks pass with the extracted compiler; the miss time and
the code's speed are recorded, with a list of the -O passes
that code needs.
M6. Crane. Build Crane in Nix against our Rocq, extract the same entry points to C++, compile with clang++ and run the same differential tests. Gate: byte identity again, and the C++ target. If Crane cannot extract the development, the note says what stopped it, and the order of remedies is: change the Gallina, use a Crane switch, patch Crane, fall back to CertiRocq.
M7. A fast simple register allocator. Designed for a proof, not ported. Gate: difftests and package checks by behaviour, and a measured run-time gain over stack homes on the benchmark gate.
M8. The Fil-C library. The Crane output built by
filcc as libnanofil and called in the process by the pcre2
hook, which still runs as and ld and loads
with dlopen. Gate: the pizlonated library passes the
differential tests and the experiment's checks.
M9. Hybrid -O. Emission of the reuse
forms, so Prolog's middle end and check passes can feed the extracted
backend. This is where users of -O first feel the
speed.
Later. The middle end and the check passes in
Gallina, each replacing a *_refines run-time check with a
theorem, the ones pcre2's code needs first. An encoder and a
shared-object writer, and installing code in the process, if measurement
asks for them.
Outside this campaign. The C frontend. It is where an everyday C compile spends its time, so it is the thing to reconsider once the backend is done, as its own campaign.
M1 to M3 can run in parallel once M1's Asm.v types are
fixed, because the plan and the emission pieces own different files.
The proofs it enables
None of these is promised by the milestones above. They are what becomes statable.
- Pizlonation is valid.
valid (pizlonate m) = Some truefor every well-formed unchecked module. Withvalid_no_uncheckedit says no pizlonated module ever runs unchecked, with no validator run in the trust path. - Structural facts about lowering. Frame slots do not
overlap. Every pointer live across a poll or a call is a root. A check's
emitted sequence branches to a trap exactly when the model's
Checkfails. - Implementation correctness. A semantics for the asm
subset the backend emits, and a simulation from
step_relto it, one instruction kind at a time, starting with scalar selection againstOps.v. This is the row formal.md leaves out of proof today. - Proved passes instead of checked ones. A register
allocator or a check-reuse pass with a refinement theorem needs no
translation checker at compile time, which is where half of
-Ogoes. - A λ□ extractor. MetaRocq erases Rocq terms to λ□,
with a proof for the untyped erasure and a typed variant that keeps
types. A compiler from λ□ to nanofil IR, written in Gallina, would have
a correctness statement in terms of
Semantics.v, and its output would need no collector of its own, because Fil-C's is underneath. At the end of that road the extracted backend compiles itself to nanofil IR, and neither OCaml nor C++ is between the Gallina and the machine code.
Decisions wanted
Each has a recommendation; work proceeds on it unless told otherwise.
- What the first version's register mode is.
Recommended: stack homes now, a new simple allocator at M7, never a port
of
amd64_regs.pl. - Where the compiler starts. Recommended: checked IR
first (M1 to M3), since that is what
Syntax.vdescribes, then pizlonation on a backend-ownedUnchecked.v(M4). - Byte identity as the standard, until the Gallina backend is the reference. Recommended: yes; it is the cheapest strong test there is, and the snapshot gate already works this way.
- The first JIT is the regex experiment's route:
as,ld -sharedanddlopen, with the extracted compiler inrxcfrom M5. Settled by mbrock. An encoder and installing code in the process are later options; the second is a runtime change and would wait for its own review. - What pcre2 needs after M5. The first version's code
has none of
-O's check elimination. Recommended: port the check passes M5 finds decisive before the M7 allocator, since the experiment shows they are worth 5 to 10× and registers 1.1 to 1.7×.