Fil-C on Nix

Filnix wiki

nanofil/docs/archive/handoff.md

Prolog C Compiler with Fil-C Safety — Handoff

Archived: a record of finished work, kept as written. nanofil's archive lists the others.

Oct 23, 2026 · @Mikael Brockman

Purpose and direction

The goal is a C compiler written in Prolog. Its IR and backend take QBE as inspiration and foundation, and it emits memory-safe code under Fil-C's model. This document hands off what we learned in one research session: how the Fil-C pass works, what QBE and cproc look like inside, how to think about QBE's mutable style in Prolog, and what Markus Triska's The Power of Prolog implies for the design.

Direction, as agreed so far:

How Fil-C's pizlonator works

The pass is one 17,522-line file, FilPizlonator.cpp, read at commit cec28ab (2026-10-22). About 4,500 lines of it (roughly 8493–13037) validate inline asm, and a large part is the check-scheduling optimizer. The core lowering is regular and much smaller.

Objects. Every object has a 16-byte header just before its payload, {upper, aux}. aux packs a 48-bit pointer to a shadow array with 16 flag bits above it: global, readonly, free, a special type (function = 1) and alignment. See struct filc_object in filc_runtime.h.

Flight pointers. A pointer in a register or SSA value is the pair {ptr, lower}. lower points at the payload, so lower[-16] is upper and lower[-8] is aux.

Invisible capabilities. In memory, a pointer field stores only its 8-byte address, so the payload keeps C's layout. The lower goes in the aux array at the same offset, aux_base + (ptr - lower). The aux array is allocated lazily on the first pointer store (filc_object_ensure_aux_ptr_outline); an object with no aux yields lower = 0 for any pointer loaded from it. A low bit of 1 in an aux word marks an atomic box, an indirection used for atomically stored pointers.

Access checks. buildCheck emits up to six per access of size S, merged per canonical pointer into one cold failure block:

  1. lower != 0.
  2. Alignment, only when needed (on x86, pointer-typed accesses and atomics).
  3. For writes, (aux >> 48) & (READONLY|FREE) == 0; that flags byte sits at lower - 2.
  4. ptr >= lower.
  5. ptr + S <= upper.
  6. Not freed. This check is skipped when both bounds are checked, which implies free collapses the bounds (inferred, not verified in the runtime).

A failure calls filc_optimized_access_check_fail(flightptr, &origin), where the origin is static data: size, alignment, write flag and source locations.

Pointer load from the heap. Load the lower from the aux slot and handle the box bit, then load the address from the payload.

Pointer store to the heap.

  1. Ensure the aux array exists.
  2. Store barrier: if lower != 0 and filc_current_marking_state != 0, call filc_store_barrier_for_lower_slow(thread, lower).
  3. Store the lower to aux.
  4. Store the address to the payload.

Storing an integer over a pointer slot leaves aux untouched, so the capability survives and only the address changes.

Precise stack roots. Each function pushes filc_frame {parent, origin, lowers[N]} onto thread->top_frame. The pass runs SSA liveness, colors pointer-carrying values into lowers[] slots, and stores each new pointer's lower right after the instruction that defines it. The GC finds N through frame->origin->origin_node->num_lowers_ish, a field of a static filc_function_origin. Callers root the arguments they pass; callees do not root their incoming arguments.

Safepoints. Every function entry checks the stack limit. Loop back-edges get a pollcheck, test byte [thread+8], 0xE, with a call to filc_pollcheck_slow when set. A separate pass, DeleteRedundantPollchecks.cpp, removes pollchecks dominated by another in the same loop.

Globals are reached only through getters. Each global g gets:

The first getter call registers the global with the GC (filc_global_initialization_start) and runs a filc_constant_relocation[] table that fills pointer fields by calling other getters.

Functions. Each function becomes an implementation, an alias, a getter and a function object (symbols in the ABI section). The function object's payload is {fast_entry, generic_entry, signature}. Its header has upper == lower, a zero-size object that cannot be read or written, and its aux pointer points at itself. An indirect call checks lower != 0, ptr == aux_ptr(lower), special type = function, and the signature.

Other rules.

Fil-C ABI cheat sheet (x86-64)

This is what a compiler must emit to link against clang-built Fil-C code. It was read from the pass at cec28ab. Where a detail comes from LLVM's register assignment rather than the pass itself, it should be checked by compiling probes with filcc -S.

Symbols for a function f

Symbol What it is
pizlonatedFIP<sig>_f Implementation, with f's linkage
pizlonatedFI<sig>_f External alias of the implementation; what direct callers call
pizlonated_f Getter returning {ptr, lower} to the function object
pizlonatedFO_f Function object: header plus {fast_entry, generic_entry, signature}

A caller of an external foo also emits a weak hidden thunk named pizlonatedFI<sig>_foo. If the real definition has the same signature, the linker picks its strong alias and the call is direct. Otherwise the thunk calls the getter, checks the signature and falls back to the generic convention. That is link-time type checking done with symbol names.

Signature encoding

C prototype Symbol
void f(void) pizlonatedFI1_f
void *malloc(size_t) pizlonatedFI142_malloc
size_t strlen(const char *) pizlonatedFI1066_strlen
long f(long, long) pizlonatedFI1598_f
int main(int, char **) pizlonatedFI11839_main

Fast convention. It is the plain C calling convention with arguments (thread, callee_lower, args…), so thread is in RDI and callee_lower in RSI. Each pointer argument takes two GPRs (ptr, lower). LLVM probably splits a pointer pair across R9 and the stack when registers run out; verify before relying on it. The return is the LLVM struct {i1 has_exception, rets…}. By LLVM's RetCC_X86Common, has_exception comes back in AL and integer or pointer results in RDX, or RDX:RCX for a pointer. Floats return in XMM0/XMM1. For direct calls, callee_lower is undefined.

Generic convention. The function type is {i1, i64 ret_size} fn(thread, callee_lower, args_size). Arguments are laid out with each aligned to max(8, alignment). The first 256 bytes go to the thread's inline payload buffer and the parallel aux buffer; the rest go to the outline buffers. Return values come back through the same buffers.

Getters have the type {ptr, lower} getter(thread, origin) and return in RAX:RDX.

Thread struct offsets (computed from the pass's struct layout)

Offset Field
0 stack_limit (entry check: cmp [thread], rsp; jae filc_stack_overflow_failure)
8 state (pollcheck mask 0xE)
12 tid
16 top_frame
32 unwind_registers[2]
64 cookie_ptr
128 cc_inline_buffer (256 bytes)
384 cc_inline_aux_buffer (256 bytes)
640 cc_outline_buffer
648 cc_outline_aux_buffer
656 cc_outline_size
3072 Thread-local allocators (inline allocation fast path)

Runtime entry points a compiler calls most: filc_allocate, filc_allocate_with_alignment, filc_store_barrier_for_lower_slow, filc_object_ensure_aux_ptr_outline, filc_optimized_access_check_fail, filc_check_function_call_fail, filc_pollcheck_slow, filc_memmove, filc_memset, filc_promote_args_to_heap (va_start), filc_get_next_ptr_bytes_for_va_arg, filc_load_ptr_atomic_with_manual_tracking_outline and filc_store_ptr_atomic_outline. The last two are usable as slow but correct outline helpers for every pointer load and store in a first version.

QBE and cproc: what's inside

QBE plus cproc is a full C11 compiler in about 22k lines with a real SSA optimizer, slightly smaller than TCC. QBE was read at upstream c081897 (2026-05-13). The upstream c9x.me git only serves dumb HTTP, which broke through the sandbox proxy, so we used Michael Forney's sr.ht mirror, whose newest branch sits one commit above upstream. cproc was read at d1c53dd (2026-06-01).

Codebase Files Code lines (cloc) Notes
QBE 37 ~13.9k 1,447 commits, ~1,290 by Quentin Carbonneaux; tests, docs and minic excluded
cproc 25 ~8.0k 375 test files (.c with expected .qbe output)

QBE layout.

The pipeline is one readable function, func() in main.c:

abi0 → cfg → promote → ssa → alias → loadopt → coalesce → gvn → simplcfg
→ gcm → [ifconvert] → abi1 → simpl → isel → live → loop → cost → spill → rega → emit

The real passes are interleaved with fillcfg, filluse, filldom, filllive and fillalias, which recompute cached analyses after each rewrite.

QBE has no pointer type. Its value classes are w, l, s, d, and a pointer is just an l. cproc turns 1 + x into %.3 =l add extern $x, %.2. Fil-C's pass depends on knowing which values are pointers, so this is the main reason our IR will diverge from QBE.

QBE already has the analyses a check optimizer needs.

cproc has its own small in-memory IR (struct func, inst, value) with clean choke points in qbe.c (1,542 lines): funcload, funcstore, funccopy, funcalloc and convert. Its output is literal; struct assignment, for example, is an unrolled word-by-word load and store sequence. The rest of the frontend is expr.c (1,441), decl.c (1,199) and pp.c (624). Since cproc knows C types, it is a natural place to mark pointer values before the backend sees them.

Related. qbe-go is an early Go reimplementation of QBE (18 commits, ARM64 only, builder API only). It shows that one person can reasonably reimplement QBE in another language. Its README's goal of bit-perfect equivalence is aspirational, since it uses linear scan where QBE does SSA-based spilling with its own greedy allocator.

QBE's mutation habits, mapped to Prolog

QBE's "mutates everything" is really five separate habits, and each maps to Prolog differently. The common move is to separate the IR from facts derived from it, which QBE keeps tangled together on the same structs.

The setting. QBE handles one function at a time in an arena freed afterwards (freeall() in main.c), so mutation never escapes a function.

QBE habit Where you see it Prolog counterpart
Recomputed analyses The fill* calls throughout func() Tabled relations over the IR (live_in/2, idom/2, uses/2); the fill* calls disappear
Backwards block rebuild emit() writes downward, *--curi = …, then idup copies the result back A DCG from old instructions to new; difference lists replace the buffer
In-place operand patching Copy elimination rewriting Refs A substitution assoc mapped over the IR; with temps as logic variables, copy propagation is unification
Annotations passed between passes Tmp.slot, hint, cost, width, alias; rega's RMap Facts keyed by temp, or an assoc threaded through a foldl; visit marks become visited sets or tabling
CFG surgery simplcfg merges blocks; spill and rega split edges Write new block terms; labels are atoms, so rewiring is relabeling

An instruction-selection pass as a DCG might look like this. Note the cut, which the purity section below argues against:

sel([]) --> [].
sel([let(D,K,mul,[X,const(N)])|Is]) --> { pow2(N, S) }, !, [let(D,K,shl,[X,const(S)])], sel(Is).
sel([I|Is]) --> [I], sel(Is).

When analyses must see changes. Two options came up:

Logic variables as temps are tempting, but they cannot be database keys, and tabling compares terms only up to variable renaming. Use ground names such as tmp(42) in the IR, and keep logic variables for local pattern matching such as isel templates.

What does not carry over. The 3+29-bit Ref, the 16-byte Ins and the global insb buffer are about cache footprint and zero allocation. Prolog terms like let(tmp(3), l, add, [tmp(1), const(4)]) are slower but far easier to read, and that speed cost does not matter for a compiler that handles one function at a time.

Lessons from The Power of Prolog

Markus Triska's The Power of Prolog is directly relevant: Triska also wrote the CLP(Z) library Trealla ships (library/clpz.pl, 8k lines), so the book is the design philosophy behind the libraries we would build on. These are the chapters read, with their main points.

Proposed design

The design is a pure Prolog pipeline from C source to assembly, with one IR whose semantics are defined relationally. Impurity is kept to a thin driver. The pieces, in the order we expect to build them:

[embedded content: proposed compiler pipeline · 8 stages, 4 supports]

Every stage is a pure relation; the IR interpreter checks each pass, and real QBE is a reference only in phase one.

  1. Clean IR terms.
    • Operands: tmp(N), const(N), slot(N), global(Name).
    • Jumps: goto(L), branch(V, L1, L2), ret(V), unreachable.
    • Instructions: let(Dest, Class, Op, Args).
    • The discriminating functor goes in the first argument so indexing dispatches.
    • Phase one keeps QBE's classes w, l, s, d. Phase two adds a pointer class (p) and, after pizlonation, flight pointers as explicit (ptr, lower) pairs.
  2. A relational IR interpreter. Instruction semantics are defined with CLP(Z), e.g. Z #= (X + Y) mod 2^64 for add on l. This is the reference semantics and the test oracle. Because it is pure, it runs in every direction:
    • Translation validation: run a function before and after a pass on the same symbolic inputs, and ask for an input where the results differ.
    • Test generation: fix an output and ask which inputs produce it.
    • Superoptimization: search for a shorter instruction sequence satisfying the same relation.
  3. QBE-equivalent middle end. Passes are pure relations pass(Fn0, Fn), with pass state threaded through semicontext DCGs as in Thinking in States. Analyses (CFG, dominators, liveness, alias, loops) are tabled relations. The driver asserts each IR version as facts so tables have small keys; that boundary is the one deliberate impure step.
  4. Instruction selection without cut. Each rule states its full applicability conditions with if_/3 or reified tests. The predicate then enumerates every valid tiling on backtracking, so cost-driven selection is collect-and-minimize, lburg style. A too-general rule shows up as an extra solution instead of a silent wrong choice.
  5. Register allocation. State the problem in CLP(Z) with a cost to minimize. Our measurements show proving uncolorability explodes, so it needs a search budget and a greedy fallback modeled on QBE's spill-then-assign approach.
  6. Emission and block layout. A DCG produces assembly text. Forward branch targets are unbound variables bound once a block's address is known, which does TCC-style backpatching with no machinery.
  7. Our own C frontend. Model it on cproc: a DCG preprocessor and parser, with types known when lowering. It hands IR terms straight to the backend, so text IR is needed only for debugging and for differential tests against real QBE.
  8. Pizlonation as an IR pass. Turn pointer-class values into flight pointers. Insert checks using the alias relation's base-plus-offset facts to merge ranges. Record lowers in a frame, add pollchecks at loop back-edges, and lower calls to Fil-C's fast or generic conventions so output links with clang-built Fil-C code. Start with outline runtime helpers for pointer loads and stores, then inline fast paths.

Kept pragmatic on purpose:

A cheaper first target, for comparison. An earlier idea was a TCC fork emitting the Fil-C ABI. TCC's two-register SValue (r/r2) fits flight pointers, its spills before calls make frame rooting simple, and backward gjmp_addr marks loop back-edges. The Prolog route trades that quick win for analyses and testability.

Trealla Prolog as host

Trealla is a strong host. We built it from head (ba473a6, 2026-10-23) with make NOSSL=1 ISOCLINE=1, because the sandbox lacks histedit.h. It is about 67k lines of plain C99, with the standard library compiled into the binary. It builds for WASI, Go and Cosmopolitan, and embeds as libtrealla.a. A compiler written in it could ship as one binary, run in a browser, or link into a C program.

Features that matter here

The libraries are Scryer-style (dcgs, pio, charsio, clpz), so code should mostly port between the two.

Measurements (sandbox machine). The CFG generator adds random back-edges, so nearly everything is live everywhere, which is much worse than real code.

Workload Size Result
Tabled liveness (3 rules) 200 blocks / 300 temps → 40k facts 0.35 s
Tabled liveness 500 / 800 → 265k facts 2.5 s
Tabled liveness 1000 / 1500 → 976k facts 8.6 s
Worklist liveness (assoc + ordsets) 200 / 300 0.51 s
Worklist liveness 500 / 800 4.8 s
Worklist liveness 1000 / 1500 Killed (out of memory)
CLP(Z) coloring, ff 150 temps, 1,618 edges, 12–14 regs 0.13–0.14 s
CLP(Z) coloring 60 temps, 935 edges, 14 regs 0.05 s
CLP(Z) coloring Same graph, 10 regs Unfinished after 120 s
DCG parse 51k-line, 1.5 MB QBE-style function 2.3 s (about 22k lines/s)

The declarative tabled liveness is both the shortest version and the fastest.

Pitfalls hit

Trealla also looks like a good candidate to build under Fil-C: plain C99, a plain Makefile and an existing sanitizer test target. A memory-safe Prolog running this compiler would make a good Filnix demo.

Open questions and risks

Sources

Code read, with commits

The Power of Prolog — contents, Logical Purity, Data Structures, Global Variables, Thinking in States, Definite Clause Grammars, Meta-interpreters, Memoization, Efficiency, Declarative Debugging, Macros.

Scratch files from the session, in the sandbox scratchpad /tmp/claude-0/-home-claude/38676654-aba4-5d33-a2b7-97a4d63695a0/scratchpad/. They are not durable, so copy anything worth keeping.