Conversation
4ba8100 to
5603560
Compare
| @@ -55,7 +55,7 @@ let rec can_prop_and_rm binders (Pexpr (_, _, pe_)) = | |||
| | PEif _ | PElet _ | PEcase _ | PEconstrained _ -> | |||
| false (* not worth extra complexity *) | |||
| | PEcall _ | PEcfunction _ -> | |||
There was a problem hiding this comment.
why do you say that PEcall always returns a tuple?
There was a problem hiding this comment.
(Off by one line?) My comment is slightly misleading because I was being too brief. I think I just want to say something like
- Propagating a call might duplicate a lot of work
- It may (but is not guaranteed) to return a tuple.
There was a problem hiding this comment.
Although I did just realise, it's unsound to propagate error and UB and maybe even catch_exceptional_condition values since they need to be evaluated strictly.
There was a problem hiding this comment.
#1006 I've fixed the copy-prop issues here
| (* [pe_free_syms pe] is the set of all (free) symbols mentioned in [pe] | ||
| The pointer syms we care about are always bound in effectful expressions, | ||
| hence we can skip tracking variables bound inside [pe] *) | ||
| let rec pe_free_syms (Pexpr (_, _, pe_)) = |
There was a problem hiding this comment.
The function name and comment is misleading, it seems to collect all symbols (including with shadowing when symbol ids are reused in different scopes?). Even if (for now?) the caller doesn't care about variables bound in pexpr I'd rather have all binders properly handled here.
There was a problem hiding this comment.
the same for action_escaping_syms with symbol bound in SeqRMW?
| match act_ with | ||
| | Store0 (_, ctype_pe, addr_pe, val_pe, _) -> | ||
| Pset.union (addr_indirect addr_pe) (pes_free_syms [ctype_pe; val_pe]) | ||
| | Load0 (_, addr_pe, _) | Kill (_, addr_pe) -> |
There was a problem hiding this comment.
why isn't the first operand of Load0 (ctype of lvalue) used, unlike similar operands for Store0 and others?
There was a problem hiding this comment.
That's an oversight, it should be used for completeness.
| bare-PEsym address argument is excluded; everything else is included. *) | ||
| let action_escaping_syms act_ = | ||
| (* addr_indirect addr_pe: if not a bare PEsym, all syms in addr_pe are bad *) | ||
| let addr_indirect addr_pe = |
There was a problem hiding this comment.
don't we care about expressions like let x = y in x?
There was a problem hiding this comment.
copy_propagation will have taken care of it (let _ = unit in y)
| | Pexpr (_, _, PEsym _) -> true | ||
| | _ -> false in | ||
| let is_escaped param = | ||
| (* [Pmap.find] throws [Not_found] (if the code is wrong) which gets |
There was a problem hiding this comment.
Presumable the same happens when the Option.get fails?
There was a problem hiding this comment.
Surprisingly, no. I was confused by this as well. I can try debug it if you want to get to the bottom of it.
There was a problem hiding this comment.
So I asked Claude to investigate - at first it seemed reasonable, but then I asked it to provide a C source and then it realised that wasn't quite right. After it did a few experiments, it concluded at Pmap is not compiled with debug info, and that's the root cause.
First response, incorrect
Short version
Not_found is a constant (argument-less) exception constructor, so OCaml
allocates it once as a shared singleton. Every raise Not_found in the
entire program raises the same physical value. The runtime's backtrace buffer
only resets when the raised value is different from the last one it recorded —
so a fresh raise Not_found from your Pmap.find doesn't clear the stale trace
left over from parse-time Not_found raises.
Invalid_argument "option is None" carries a string payload → freshly allocated,
distinct value every time → buffer resets → correct location.
The mechanism in detail
The backtrace isn't attached to the exception value; it's a single per-domain
mutable buffer in the runtime. On a normal (trace-recording) raise, the unwinder
calls caml_stash_backtrace, which is roughly:
void caml_stash_backtrace(value exn, ...) {
if (exn != Caml_state->backtrace_last_exn) { /* physical comparison */
Caml_state->backtrace_pos = 0; /* reset only if different exn */
caml_modify_..._root(&Caml_state->backtrace_last_exn, exn);
}
/* append frames as we unwind */
}That exn != backtrace_last_exn guard exists so a genuine re-raise
(with e -> ... raise e) appends to the existing trace instead of clobbering
it. But it keys off physical identity of the exception value.
Now combine that with two facts confirmed in your tree:
parsers/c/c_lexer.mll:671doestry Hashtbl.find lexicon id with Not_found -> ...
on every identifier — i.e. the lexer raises and catchesNot_found
constantly while parsing.Stdlib.Hashtbl.find_rec(hashtbl.ml:536) andPmap.find(lem'spmap.ml:81)
both raiseNot_foundwith a plainraise, notraise_notrace.
So the timeline is:
- During lexing,
Hashtbl.findraises theNot_foundsingleton. It's a new
value vs. whatever came before → buffer reset → records
hashtbl.ml:536 / c_lexer.mll:671.backtrace_last_exn := Not_found. The
lexer catches it; catching does not resetbacktrace_last_exn. - Long later, your
Pmap.findin mem2reg raisesNot_found— the same
singleton.exn == backtrace_last_exn, so the buffer is not reset. You
get the stale lexer location.
That's why the trace is both wrong and points at "the lexer of all places" —
that's just the last place a Not_found got a fresh recording.
With Pmap.lookup + Option.get, the internal Not_found is caught by
lookup's try ... with Not_found -> None, and the exception that actually
escapes is Invalid_argument "..." — a distinct, payload-carrying value →
buffer reset → clean mem2reg.ml:114.
Re: your hypothesis
It's not really about installing/removing try/with handlers (those only
determine where unwinding stops). The confusing backtrace comes from the global
buffer's reset condition interacting with constant-constructor sharing. Handlers
matter only insofar as the lexer's handler is what let that stale Not_found
trace survive to be reused.
Practical takeaways
- The current code (
Option.get @@ Pmap.lookup ...) is the right call precisely
for this reason — keep it. The comment is essentially correct, just slightly
mis-attributes it to "a handler" rather than the shared-value buffer. - If you ever want an informative trace and a useful message, raise a custom
exception with a payload, e.g.exception Mem2reg_internal of string/
... of Symbol.sym. Because it's a distinct allocated value, it always resets
the buffer and points at the real site. - This is a general OCaml gotcha for any argument-less exception (
Not_found,
Exit,End_of_file,Stack_overflow): treat their backtraces with suspicion.
There was a problem hiding this comment.
I've opened a PR on Lem, rems-project/lem#41 but for now I've figured out a slightly better work-around.
There was a problem hiding this comment.
ok, tangentially why aren't you using Stdlib.Map here? We only have Pmap in the Lem generated code because it matches the typeclass codegen.
There was a problem hiding this comment.
No particular reason tbh; I do the same in const-prop too. Want me to change it to Stdlib.Map?
|
|
||
| let extend_pe_delta pe bty_env val_env written = | ||
| let written = Pset.elements written in | ||
| let pes = written |> List.map (fun sym -> pmap_find sym val_env) in |
There was a problem hiding this comment.
pmap_find sym val_env fails when there is a declaration under an if, e.g.
int main(void)
{
if (1) {
int x;
}
}(the same if the declaration is in the else branch)
There was a problem hiding this comment.
Good spot! Fixed in subsequent commits.
There was a problem hiding this comment.
you asked me to remind you to investigate why locals inside a branch are still not leaking (despite block-scope kills)
The previous analysis of which pexpr can be propagated was poorly explained and also unsound: values which could potentially not terminate, error, or cause UB in Core's strict setting should be left alone. This shouldn't affect the upcoming mem2reg because that only relies on symbols being propagated correctly.
This commit modifies the copy-prop to also propagate under case branches. It does so by adding a new switch to analyze_pat_pexpr so that it _doesn't_ change the type of the pattern to unit, and replace the scrutinee with Unit, to maintain consitency across branches. Woohoo! Might be nice to simplify the pattern but this could affect source location and annotations.
It started off as a copy-propagation pass just for symbols but it's long passed that now so it's been renamed as such to minimise further confusion.
Add the SW_mem2reg switch, an identity-stub Core pass (core_mem2reg.ml), pipeline wiring, and 10 CI tests covering simple scalars, branches, loops, address-taken variables, structs, and mixed cases. All 10 tests pass with the stub (identity transform). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Phase 1: run all pre-existing CI tests with --sw mem2reg and verify output is unchanged (regression safety). Phase 2: run --pp core --sw mem2reg on the 10 new mem2reg tests and count create( occurrences to verify promotable variables are eliminated. - run-mem2reg.sh: thin dispatcher that runs phase1 then phase2 - run-mem2reg-phase1.sh: regression check — --sw mem2reg must not change output of existing CI tests (0001–0340) - run-mem2reg-phase2.sh: elimination check — counts create() in Core IR for all 10 mem2reg tests (0341–0350); current expected values reflect the stub pass, with target values noted in a comment Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Replaces the identity-transform stub with a promotability analysis:
find all Create(PrefSource)-bound pointer syms in each Proc and determine
which ones are only ever used as Load0/Store0/Kill addresses (never
address-taken or passed to arbitrary expressions).
Key details:
- sym_occurs_in_{pexpr,expr,action}: conservative occurrence check
- classify_action: precise Load0/Store0/Kill address classification
- collect_uses: handles the Core load-alias idiom
(let weak tmp = pure(ptr) in load(ty, tmp)) via a special Ewseq arm
- collect_creates: finds Esseq-bound Create(PrefSource _) syms
- Debug output at level 3: "[mem2reg] f: N promotable: [...]"
- Note in collect_creates on the via-pointer calling-convention assumption
and how CN's value-passing convention would allow extending this to
non-address-taken function parameters
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Under Normal_callconv only PrefSource (C locals) are promoted; under Inner_arg_callconv PrefFunArg Create bindings are also included, since in that convention the callee owns the argument slot. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
check_definitely_init now correctly handles: - Ewseq: e2 receives already_init (not ia1), since Neg stores in e1 are not sequenced before e2; init_after = ia1 || ia2 - Eunseq: all arms receive already_init; safe/init_after both require AND across arms (no arm's result is visible to siblings) - SeqRMW: classified as Use_seqrmw (promotable); (safe=already_init, init_after=true) — atomically reads then writes Add no_mixed_unseq_uses predicate: blocks promotion when a write arm and >=2 mentioning arms coexist in an Eunseq, preserving Cerberus's unsequenced-race detector for cases like `x + (x = 42)`. find_promotable now requires all three predicates: use_is_promotable && check_definitely_init && no_mixed_unseq_uses Add seven CI tests (0351-0357) covering Ewseq promotion, uninit/init unsequenced races, read-only Eunseq arms, post/pre-increment SeqRMW, and x+(x++) UB detection. Update run-mem2reg-phase2.sh and tests.sh. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
See doc/history/2026-03-18_mem2reg-esave-erun.md for the full design. Previously, any occurrence of a CREATE-bound sym in an Esave's args or body conservatively blocked promotion. The fix rests on two invariants of the elaborator: 1. Every CREATE-bound local pointer sym that appears inside an Esave body is passed in as a bare PEsym default arg — it is never referenced directly from the body under its outer name. (Non-CREATE-bound syms such as function parameters may appear freely; the invariant is narrowly about CREATE-bound locals.) Consequently, if a sym is not aliased by any param, it cannot reach the body, and the analysis can skip it entirely. 2. Erun is terminal: control jumps unconditionally into the Esave body and the sequential continuation is unreachable, so init_after = true. Erun args for CREATE-bound syms are bare PEsym aliases of the target Esave's params, so alias resolution via find_single_direct_alias is sufficient and no structural occurrence check is needed. Both collect_uses and check_definitely_init use the (cached) result of recursing on the Esave body using the param_sym which is an alias of the Erun arg or Esave default arg. They then combine that with any existing information about the arg itself to calculate the result. New infrastructure: - collect_esave_defs: pre-walk that builds a memo table of Esave definitions (label_sym → params + body), enabling Erun sites to resolve forward references without re-traversal. - find_single_direct_alias: finds the unique param whose default is a bare PEsym equal to the outer sym. - Memoised collect_uses / check_definitely_init: results cached per (label_sym, param_sym); a sentinel is written before the recursive call to handle back-edge Eruns without diverging. For collect_uses the sentinel [] is exact (a back-edge Erun carries no semantic use). For check_definitely_init the sentinel (true, true) is a sound over-approximation: any load-before-store on an exit path sets safe=false through &&-propagation before the back-edge Erun is reached, so the sentinel cannot mask a real unsafety. - expr_writes_sym and no_mixed_unseq_uses updated with the same param-alias indirection for Esave. New CI tests: 0361–0364 cover pre-init loop reads, escaping writes, nested loops, and uninit loads inside loops. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The definite-initialisation check previously used a plain bool for "is sym initialised after this expression", conflating two distinct notions: a Paction Pos store (committed before any Esseq continuation) and a Paction Neg store inside Ewseq (unsequenced w.r.t. the Ewseq continuation). This made the analysis overly conservative for the common case of compound literals, whose Core IR is: Ewseq(_, Store0(Paction Pos, ptr, val), Load0(ptr)) The Pos store is sequenced before the Load in memory-model terms, but the old bool could not express this, so the Load was conservatively rejected and the variable was not promoted. Replace the bool with a three-element lattice Write_kind = No_write < Weak < Strong. Esseq promotion now treats any write (Weak or Strong) as initialised for its continuation; Ewseq only propagates Strong. Load/SeqRMW safety checks use is_strong. Branch and unsequenced joins use meet (&&); sequential joins use join (||). Adds test 0365-mem2reg_compound_lit.c verifying that an int variable initialised via a compound literal produces the correct result under --sw mem2reg, and adds it to run-mem2reg-phase2.sh with the stub create-count of 2 (fully-working target: 0). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Records the finding that the Write_kind refactoring does not change promotable-variable counts in practice: check_definitely_init checks for Load0(PEsym sym) directly, but C-generated Core always loads locals through an intermediate alias (Ewseq(tmp, pure(sym), load(tmp))), so the Ewseq rule change never fires. Includes annotated Core output for the compound-literal test to show why a_508 is filtered by collect_uses (Use_other) before check_definitely_init is reached, and why x is promotable under both old and new analysis for unrelated reasons. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Replaces the three-pass promotability analysis (check_definitely_init, expr_writes_sym, no_mixed_unseq_uses) with a single abstract interpretation, sequentialisable, that tracks Mem_event sequences and an env (Uninit / Init pe / DelayedInit pe / Killed) through the IR. Key improvements over the old Write_kind lattice approach: - Tracks the concrete stored value (pexpr) so the transform phase can substitute it directly at Load sites without a second traversal. - DelayedInit models Neg-polarity (Ewseq) stores, blocking any Load/Store on the same sym before the sequence point. - SeqRMW is handled: atomically reads the current value, substitutes it into the update expression, and records the result as Init. - Eunseq sequencing-violation detection is now integrated: raises Not_sequentialisable if a write arm coexists with any other arm that mentions sym. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Regrettably, this commit fixes up many small and big things in mem2reg. - Mem_event => Event - Mem_event.t list => Set.Make(Event).t - Remove DelayedInit and fix handling of Neg_stores in wseq - Remove alias case for wseq in collect_uses (handled by copy_prop pass) - Use Core_aux.unsafe_subst_pexpr instead of hand-rolled version - Use Pexpr (.. PEundef ..) for env "value" of non-returning case-branches to retain pattern-completeness - Handle End and Epar same as Eunseq - Fix the Ebound case to keep the env
Transform wasn't correct for empty set - oops! Commit handles (_: (bty1, ..)) case now, and doesn't wrap an Eif if there are no writes in it.
This commit removes the extra argument to Proc and renames the module for mem2reg. It also exposes via the mli a function to just do the analysis without the transform (which is what CN will use).
It's used extensively in the upcoming jump/where changes so it pays to be correct.
The set of variables `written` inside an expression may include variables local to that expression (e.g. inside the branch of an Eif), but the pre-existing code didn't take that into account when propogating writes outwards by filtering out the out-of-scope ones. This commit fixes this and adds a test case for it. Excellent spot by Kayvan during review.
Previously, the unit results of the branches of an if/case were bound using a variable inside a mem2reg transform (when the set of variables written across all branches is non-empty). This is unnecessary so this commit removes that. It also uses sseq instead of wseq, which is sound when the second expression is pure, and results in cleaner pretty printing.
| | Kill (_, addr_pe) -> | ||
| addr_indirect addr_pe | ||
| | Load0 (ctype_pe, addr_pe, _) -> | ||
| Pset.union (addr_indirect addr_pe) (pe_free_syms ctype_pe) |
There was a problem hiding this comment.
don't look at the ctype_pe syms
| 2. Conflate the parameter symbols of the Esave, with the default | ||
| arguments of the Esave (for non-return Esaves). | ||
| NOTE: For return Esaves, since the body is always a pure expression, | ||
| the return parameter symbol will not end up in the footprint. *) |
There was a problem hiding this comment.
can you add a related comment in the elaboration file
|
I addressed the comments and added a flag to enforce UB if strict_reads is enabled. I also added an analysis for checking for use-after-free errors. Two things I need to do to tidy up the new commits:
|
The 'cn' in uninit_cn.c referred to the CN backend, but that was cryptic enough to be mistaken for a typo. Rename it to uninit_unused.c, which says what the test actually is - a local which is neither written nor read - and record inside the file why such a local is worth testing: the usual elaboration always writes to a local at least once, so this shape only arises via CN, and it is the reason find_promotable starts from the not-escaped vars rather than from the ones with a write footprint. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AYuKpLUhVFA1SYuYgd1CmL
The transform now turns a load of a promoted variable into a case analysis raising UB011 on an unspecified value, but only when the strict_reads switch is on. Nothing in tests/mem2reg exercised that, because transform.json does not pass strict_reads, which is why this commit changes none of its goldens. So this commit also adds tests/mem2reg/strict.json: the same suite run with strict_reads on, without --pp core, so the goldens are just the result of executing the transformed program. Everything in the directory behaves identically under both configurations except the new tests/mem2reg/uninit_used.c, which is UB011 under strict_reads and an unspecified value without it. --- For posterity, this commit message also records some thoughts on a (now deleted) experiment to try catch use-after-free errors directly in the mem2reg transform: It looks like trying to error on kills in the transform is just a bit too tricky - what happens when one branch of an if kills but another doesn't? I don't think the elaboration produces things like this, but it's worth thinking about. I think the best thing to do would be to add a new use-after-kill check for non-address taken symbols. It doesn't look like the all-kill/none-kill checks are working in the sequence function so I am not sure about the right approach...
This commit adds a use-after-free check in the analysis phase of the mem2reg rewrite. This is partly because I found it difficult to think about and write a transform that worked in the presence of Eif and Eruns (since one branch could kill an allocation but another might not, and control does not return from an Erun). In practice, this error will never happen in this context since it would signal an elaboration error: the lifetimes for all local variables is determined syntactically. Nonetheless, it's here now so that this assumption is documented and checked. The analysis has a monadic flavour due to the 'after' in 'use-after-free', which is quite different from the applicative flavour of the analysis for data-races/sequencing. Hence it's written as a separate pass rather than integrated into the latter. Right now there's just an assertion that the set of symbols for which there might be a use-after-free is always empty, since we always deal with C-elaborated Core, rather than manually crafted, Core files. The test added here, tests/mem2reg/one_branch_kill.c, is the program the Erun case is written against: it is derived from tests/ci/0112-call_in_label.c, which is the one shape in the test suite where one branch of an if kills before a run and the other does not.
This PR adds support for an analysis which determines which function-local variables don't have their addresses escaped and are sequentialisable (with respect to weak- and un-sequenced races), and a transform which replaces the create, store, load and kill actions on those variables with pure variable and expression rebindings instead.
Though each commit is self-contained, the code changed a lot during this process and so it's probably worth just reviewing it as a whole and squashing it rather than merging each commit (which can be kept for posterity). I'll write a proper commit message in the case of a squash.