Fil-C on Nix

Filnix wiki

nanofil/docs/vector-access.md

Full-width vector access checks (V1): contract (reviewed, implemented)

The frontend lowers GCC/clang vector types to scalar IR, in 8-byte chunks (element-sized for float elements). filcc checks an 8-, 16-, 32- or 64-byte vector access as one access, with its alignment requirement capped at 8: a misaligned _mm_load_si128 reports "alignment requirement of 8 bytes not met", and _mm_storeu_si128 one byte short reports "cannot write 16 bytes when upper - ptr = 15" with nothing written. To keep that observation, nanofil needs one check of the full width before any chunk is accessed. This is a check-shape change, so it comes as a contract and an Oracle review before code, like B1 and M7's rounds.

GNU vector types and the SSE through AVX2 builtins are on by default; --no-vectors (or --vectors=off, or NANOFIL_VECTORS=0) refuses every vector type, as nanofil did before.

The terms

Unchecked IR gains one statement:

Term Meaning
access(K, Addr, Size, Align) traps exactly as a K access of Size bytes at Addr with alignment Align would, and accesses nothing

The frontend emits it immediately before the chunked loads or stores it covers, which are ordinary l (or s/d) accesses at Addr, Addr+8, and so on (4-byte s chunks at Addr+4, ... for a float vector).

Checked IR gains one statement:

Term Meaning
touch(tmp(Q)) consumes the proof Q and does nothing else

pizlonate turns access(K, A, Size, Align) into

let(tmp(Q), a, check(0, Size, K, Align), [A]),
touch(tmp(Q))

so the invariant that each check is immediately followed by its one use (docs/pizlonation.md) is unchanged. Validator rules:

The chunk accesses after it are pizlonated as they are today: each gets its own check. On one thread nothing between the full check and a chunk can make a chunk check fail: pizlonate polls only on back edges, no pass inserts calls, integer stores never ensure aux, and signals wait for safepoints. So the reference path's single-threaded observation is filcc's: the full-width trap, before any chunk, or none.

A tolerated race. Another thread's free (or munmap) landing between the full check and chunk k makes chunk k's check trap after chunks 0..k−1 are written. filcc cannot produce that: its one check passed, and free only sets upper = lower and the free flag, leaving the payload for exactly such races (filc_runtime.h 169–177, filc_runtime.c 3172–3194), so its single vector access completes without a trap. This adds a trap after partial effects, which M7's contract ("no trap is added or moved earlier") does not cover, so it is tolerated on its own terms: the trap is a correct Fil-C trap on a freed object, the program races on the object's lifetime, and filcc shows the same partial-then- trap behaviour for any access it splits, such as a struct copy. It is observable in two ways: a munmap of a MAP_SHARED mapping can leave a torn record in the file (chunks 0..k−1 written, then the range remapped, filc_runtime.c 9948–9976), and a thread that passed its own check before the free can read the partial payload. The alternative is to have the chunks read Q: reuse already rewires them when the chunk address is A + const of single-definition temps, so under renew (the -O default) and guard the race is already gone for well-shaped chunks, and the same rewiring could run for access pairs on the reference path. V1 keeps the reference path's per-chunk checks.

An alternative is to drop touch and let the first chunk access consume the full-width check (a 16-byte check covers an 8-byte raw access at offset 0). It needs no new term, but pizlonate would have to pair access with the next instruction, which then depends on the frontend's emission order. touch keeps the pairing local.

Semantics

Trap order and text

The full-width check comes first, so a vector access that would run past the end traps before any chunk is written, with filcc's size in the reason. A constant offset is not folded into the base; that matches nanofil's scalar accesses, not filcc at -O1 (which reports "cannot write 33 bytes when upper - ptr = 32" for b+17 on a 32-byte object, as it reports "25 bytes" for a long at b+25). That difference is pre-existing and out of scope here.

Pointer slots

A vector store is a run of integer (or float) chunk stores. In Fil-C an integer or float store writes only the payload and leaves an existing capability in the aux word alone (storeValueRecurseAfterCheck, docs/abi-findings.md); the interpreter does the same (width_word, stored_aux(none, ...)), and so does the emitter. So a vector store over pointer slots keeps their capabilities, as filcc's does, and a vector load of pointer slots reads their address words only.

Middle end

access has no result and must not be removed or reordered:

Reuse, renew and guard

The M7 passes match a check followed by a scalar access, so they need these clauses for touch; without them it is opaque, which would be sound but would wipe every fact in the block (state_none), slowing the -O default (renew) around every vector access:

Known differences from filcc

Tests and plants (planned)

Review

Oracle reviewed the first draft: the term pair is the right shape and touch beats having the first chunk consume the check, but it found that touch was invisible to M7 (opaque, wiping facts), that a missing pizlonate clause failed open through a filc_amd64 inst clause, that the planned partial-write test could not fail, that "the chunk checks cannot fail" is false under a racing free, and that renew relates only equal offsets. All are folded in above. Its second round found the blockers resolved, corrected the race paragraph (an added trap, not the converse of M7's lost one, and observable through munmap and other threads' reads) and replaced two vacuous tests; folded in too.

As built

The IR side follows the contract. ir:proof_use/6 is the one place a check, renew or renew_if accepts what follows it: a covered heap raw access or a touch of its own proof. check_reuse keeps a check and its touch and adds their fact, so under --checks=reuse, renew (the -O default) and guard the chunks read the full-width proof at their offsets with no checks of their own; the full-width check is never a reuse or renew target in V1.

Tests (tests/unit.pl):

Natively, the frontend's tests/c/prog/vector-access.c matches filcc under --checks=reference, reuse, renew and guard, with and without -O, and with --registers=temps, under FUGC_SCRIBBLE/FUGC_VERIFY: every reason for loads and stores, ptr >= upper, pointer slots keeping their capabilities, and MAP_SHARED pages unchanged after the trap. Its freed cases call free through an opaque pointer: clang -O1 folds a direct free then access into "cannot access pointer to free object", for scalars too (pre-existing, like offset folding).

Plants (unit = the unit tests; native = vector-access.c, plain and -O):

Plant Caught by
1. pizlonate leaves access in place unit, native
2. pizlonate drops the full-width check unit (partial write), native
3. Size taken from the chunk unit, native
4. Align dropped to 1 unit, native
5. a stray touch accepted unit
5b. a touch of another proof accepted unit
6. no cfg:inst_uses(touch) unit, native
7. no escape clause unit, native -O
8. touch not evidence-preserving unit (reuse), native -O
8b. check+touch not a reuse source unit (reuse)
9. the interpreter's access a no-op in Fil-C unit (partial write)
10. simplify drops access unit (kept)
11. reuse lets a narrower fact cover a wider check existing reuse and renew unit tests, native -O
12. the interpreter's access ignores K and Align unit (misaligned and read-only cases)
14. copy:inst_reps does not substitute in access unit (kept), native -O
17. no plain-backend clause unit (plain module emitted)
18. valid_inst(access) takes any address class unit (an l address refused)
19. Size 8 refused unit
20. pizlonate checks Size 8 at alignment 1, like a long unit (misaligned case, reuse)
21. pizlonate checks Size 8 as 4 bytes unit (partial write over float chunks, reuse)
22. the interpreter skips a Size-8 access unit (partial write)
23. the emitter tests a Size-8 check at alignment 1 unit (emitted alignment test)

Size 8 (fftw's __m64 halves) was added later, reviewed by Oracle with no blocker; 19–23 are its plants. Size 4 stays out until needed. For _mm_loadl_pi and friends the frontend must take Align from the access, not the vector type: clang reads through a packed struct, so filcc checks alignment 1 there.

Oracle reviewed the implementation and found no blocker; 12–18 are its plants, and its should-fixes (the tests above, the escape clause's place) are folded in. Equivalent mutants that pass: simplify not substituting into access, or not counting its operand (simplify turns a numbered duplicate into a copy, which the copy propagation it runs afterwards rewrites everywhere, and its use counts serve only boolean rewrites); transparent(touch) and the unchecked_access entries in filc_amd64, which valid IR never reaches (the reuse clause consumes check and touch together; the validator runs first). The frontend accepts 8-, 16- and 32-byte vectors (tests/c/prog/vector-8byte.c, vector-access.c and vector-access-256.c trap natively against filcc); the 64-byte size is covered by unit tests only.