commit 14bd1c60e822fe089b54af53117d9f501ec91f03
parent bf084c97cb134114880b7f0c936697e14cfb33da
Author: Ryan Sepassi <rsepassi@gmail.com>
Date: Tue, 16 Jun 2026 10:13:03 -0700
cg: a volatile local load must emit a real observable memory access
kit_cg_load served a volatile load of a register-resident scalar local
(RES_FIXED_LOCAL) from the local's value residency, handing back the
register value with NO memory access. The volatile boundary clears the
const shadow, but the value-residency fast path was not gated on the
observable flag, so a volatile load whose result is unused emitted no
IR load at all -- leaving x=40; binop x+2 with the constant still live.
This was latent (the CG has produced the load-less shape for the
volatile-shadow shape for some time) and only became a code-quality test
failure once the O1 same-block const tracker (O1-PATTERNS L9) gained the
ability to fold the resulting 40+2 across a single block: test/api/
cg_type_test.c:1298 then saw the volatile function fold to movz w0,#42
identically to the fully-folded baseline.
Fix at the root: for a volatile (observable) load of a source-local,
force the local to a real memory home via api_lvalue_addr (exactly as
address-taking already does) and load through an OPK_INDIRECT place. The
load is then a genuine observable memory access (mem flags VOLATILE),
which DCE/promote_scalar_locals correctly refuse to eliminate. Non-
volatile local folding is unchanged.
Fixes test/api/cg_type_test.c:1298 (cg_api_test 289/289).
Diffstat:
1 file changed, 19 insertions(+), 0 deletions(-)
diff --git a/src/cg/memory.c b/src/cg/memory.c
@@ -342,6 +342,25 @@ void kit_cg_load(KitCg* g, KitCgMemAccess access) {
return;
}
+ /* A volatile load is an externally observable access: it must emit a real
+ * memory read even when its result is unused, and it must never be served
+ * from the local's register residency or its const shadow. The shadow was
+ * already cleared by the boundary above (so api_local_const_load declines),
+ * but a register-resident scalar local (RES_FIXED_LOCAL) would otherwise be
+ * handed back below with no memory access at all -- the optimizer then DCEs
+ * the would-be load and folds the constant through it. Force the local to a
+ * real memory home (materializing a frame slot, exactly as address-taking
+ * does) and load through it so the access survives. */
+ if (base.source_local != KIT_CG_LOCAL_NONE &&
+ (access.flags & KIT_CG_MEM_VOLATILE) && base.op.kind == OPK_LOCAL) {
+ KitCgTypeId pty = cg_type_ptr_to(g->c, base_ty);
+ Operand addr = api_lvalue_addr(g, &base, pty);
+ /* api_lvalue_addr does not consume the place (it forces the home and
+ * returns its address); retarget base to load through that home. The
+ * general load path below issues the single api_release(&base). */
+ base = api_make_lv(api_op_indirect(addr.v.local, 0, base_ty), base_ty);
+ }
+
/* Source-local constant load. */
if (!is_bitfield && base.source_local != KIT_CG_LOCAL_NONE &&
api_local_const_load(g, base.source_local, access, &dst)) {