commit 99899453fcf7fad2a7ba0eed9e341c78298e86cf
parent 942c3090611c0ac05b3c024bb3a5b8c380863d86
Author: Ryan Sepassi <rsepassi@gmail.com>
Date: Sat, 13 Jun 2026 17:01:31 -0700
perf(cg): -O0 signed load-with-extend (ldrsb/ldrsh) via a frontend widening signed load (Lever 4)
A signed narrow load was `ldrb/ldrh ; sxtb/sxth` (two insns) where the
aarch64 `ldrsb`/`ldrsh` does it in one. kit's CG integer types are
sign-agnostic (signedness lives in the CV_SEXT convert, not the type), so
`nd_load` cannot know the loaded value feeds a sign-extend and must emit a
zero-extending load. L3 already handled the *unsigned* half (a zero-extending
load fills the register, so a following CV_ZEXT is elided). This adds the
signed mirror: a frontend widening signed load.
Seam (mirror of L3, gated by a backend capability):
- A signedness rider on the load: new MF_SEXT_LOAD bit (internal MemFlag,
backed by public KIT_CG_MEM_SEXT_LOAD; translated in api_mem_from_access).
- Frontend (pcg_load): a non-bit-field signed byte/short lvalue read sets the
flag. Such a value is only ever widened by CV_SEXT (pcg_convert emits sext
for signed sources, zext for unsigned), so the rider is unconditional and
the L3 zext-elision can never misfire on it.
- aa64 (aa_emit_mem): for an MF_SEXT_LOAD byte/half integer load, emit the
load with opc=10 (ldrsb/ldrsh into the X register, sign-extending the whole
register) across every addressing mode. New aa_*_op_v encoder variants carry
the load opcode; the existing helpers stay plain-LDR wrappers.
- Shared NDT: nd_load records load_sext (instead of load_zext) when the access
is MF_SEXT_LOAD and the backend advertises the new NativeRegInfo.ndt_load_sext
capability; nd_convert then drops a CV_SEXT whose source is a load_sext local,
exactly as it drops a CV_ZEXT after load_zext. Cleared on spill/invalidate/
overwrite (a plain reload zero-extends, so the real extend runs on reload).
Cross-arch correctness: the capability is on for aa64 (honors the flag) and
off (default) for x86-64/riscv64 — there the frontend flag is a no-op, the
backend emits a plain zero-extending load, and the CV_SEXT convert runs
normally. Other backends (opt recorder, c_target, wasm, check) ignore the bit.
sqlite3.c (-c, aa64-macos), vs base 942c3090, byte-deterministic:
.text 0x16d170 -> 0x16c478 (-3,832 B, -958 insns, -0.256%)
sxtb -449, sxth -497 (946 converts elided); ldrsb +421, ldrsh +493,
ldursb +15 (929 sign-extending loads); ldrb -735, ldrh -586.
No change to mov/stur/ldur/sub/movk — purely the targeted lever.
Gates: test-toy 1392/0; test-cg-api/test-isa/test-asm/test-opt/test-parse
(3920+129)/0; smoke-x64/smoke-rv64 3/0 (capability-off arches correct).
Differential vs /usr/bin/cc -O0 on signed+unsigned char/short arrays/fields
with most-negative values (-1/-128/-32768), promotion to int/long, sign-
dependent arithmetic and comparisons, and (unsigned char) truncation of a
sign-extended register: MATCH at kit -O0 and -O1.
Diffstat:
9 files changed, 137 insertions(+), 37 deletions(-)
diff --git a/include/kit/cg.h b/include/kit/cg.h
@@ -295,6 +295,12 @@ typedef enum KitCgMemAccessFlag {
/* Access is an externally observable side effect and must not be merged,
* removed, or reordered across other volatile accesses. */
KIT_CG_MEM_VOLATILE = 1u << 0,
+ /* Codegen hint (loads only): the loaded value is a signed narrow integer that
+ * the frontend will only ever widen by sign-extension, so the backend is free
+ * to emit a single sign-extending load and the -O0 path may then elide the
+ * following sign-extend convert. A backend that does not exploit it just emits
+ * a plain load; semantics are unchanged. Ignored on stores and bit-fields. */
+ KIT_CG_MEM_SEXT_LOAD = 1u << 1,
} KitCgMemAccessFlag;
typedef struct KitCgMemAccess {
diff --git a/lang/c/parse/cg_adapter.c b/lang/c/parse/cg_adapter.c
@@ -528,6 +528,15 @@ void pcg_load(Parser* p) {
KitCgMemAccess access = pcg_mem_id(p, pcg_top_cg_id(p), ty);
/* Snapshot bit-field geometry before materialize clears the aux. */
PcgLvAux bf = lv ? *lv : (PcgLvAux){0};
+ /* Widening signed load (Lever 4): a non-bit-field signed byte/short load is
+ * only ever widened by sign-extension (the integer-promotion CV_SEXT), so
+ * mark the access so the backend can fold the load + sign-extend into one
+ * ldrsb/ldrsh and the -O0 path can drop the redundant CV_SEXT. Plain hint;
+ * a backend that ignores it stays correct. */
+ if (bf.bit_width == 0 && type_is_int(ty) && pcg_type_is_signed(ty) &&
+ pcg_sizeof(p, ty) < 4u) {
+ access.flags |= KIT_CG_MEM_SEXT_LOAD;
+ }
/* Build the PLACE the strict load requires. A trivial local already has its
* PLACE on the CG stack (push_local) and loads directly; anything else is
* reduced to a single pointer (materialize, or a pointer-rvalue base) and
diff --git a/src/arch/aa64/isa.h b/src/arch/aa64/isa.h
@@ -751,6 +751,10 @@ static inline int aa64_addsub_imm_fits(i64 imm, u32* imm12_out, u32* sh_out) {
#define AA64_LDST_SIZE_64 3u
#define AA64_LDST_OPC_STR 0u
#define AA64_LDST_OPC_LDR 1u
+/* opc=10 -> sign-extending load into the 64-bit X register (LDRSB/LDRSH/LDRSW
+ * Xt); opc=11 is the 32-bit W form. Codegen uses the X form so a narrow signed
+ * load fills the whole register (the -O0 widening-signed-load lever). */
+#define AA64_LDST_OPC_LDRS_X 2u
#define AA64_LDST_UIMM_FAMILY_MATCH 0x39000000u
#define AA64_LDST_UIMM_FAMILY_MASK 0x3B000000u /* bits 29:27 + bits 25:24 */
diff --git a/src/arch/aa64/native.c b/src/arch/aa64/native.c
@@ -501,15 +501,22 @@ static void aa_emit_add_i64(AANativeTarget* a, u32 rd, u32 rn, i64 off) {
aa_emit32(mc, aa64_add(1, rd, rn, rd));
}
-static u32 aa_ldur_v(u32 size, u32 v, u32 rt, u32 rn, i32 simm9) {
+/* Unscaled load with an explicit load opcode (AA64_LDST_OPC_LDR for a plain
+ * zero-extending load, AA64_LDST_OPC_LDRS_X for a sign-extending ldursb/ldursh
+ * into the X register). */
+static u32 aa_ldur_op_v(u32 size, u32 v, u32 ld_opc, u32 rt, u32 rn, i32 simm9) {
return aa64_ldst_simm9_pack((AA64LdStSimm9){.size = size,
.V = v,
- .opc = AA64_LDST_OPC_LDR,
+ .opc = ld_opc,
.imm9 = (u32)simm9 & 0x1ffu,
.Rn = rn,
.Rt = rt});
}
+static u32 aa_ldur_v(u32 size, u32 v, u32 rt, u32 rn, i32 simm9) {
+ return aa_ldur_op_v(size, v, AA64_LDST_OPC_LDR, rt, rn, simm9);
+}
+
static u32 aa_stur_v(u32 size, u32 v, u32 rt, u32 rn, i32 simm9) {
return aa64_ldst_simm9_pack((AA64LdStSimm9){.size = size,
.V = v,
@@ -519,16 +526,24 @@ static u32 aa_stur_v(u32 size, u32 v, u32 rt, u32 rn, i32 simm9) {
.Rt = rt});
}
-static u32 aa_ldr_uimm_v(u32 size, u32 v, u32 rt, u32 rn, u32 byte_off) {
+/* Scaled load with an explicit load opcode (see aa_ldur_op_v). The scale shift
+ * is the access size for plain LDR; a sign-extending narrow load (opc=LDRS_X)
+ * uses the same byte-size scale (the encoded imm12 is byte_off >> size). */
+static u32 aa_ldr_uimm_op_v(u32 size, u32 v, u32 ld_opc, u32 rt, u32 rn,
+ u32 byte_off) {
u32 sc = byte_off >> size;
return aa64_ldst_uimm_pack((AA64LdStUimm){.size = size,
.V = v,
- .opc = AA64_LDST_OPC_LDR,
+ .opc = ld_opc,
.imm12 = sc,
.Rn = rn,
.Rt = rt});
}
+static u32 aa_ldr_uimm_v(u32 size, u32 v, u32 rt, u32 rn, u32 byte_off) {
+ return aa_ldr_uimm_op_v(size, v, AA64_LDST_OPC_LDR, rt, rn, byte_off);
+}
+
static u32 aa_str_uimm_v(u32 size, u32 v, u32 rt, u32 rn, u32 byte_off) {
u32 sc = byte_off >> size;
return aa64_ldst_uimm_pack((AA64LdStUimm){.size = size,
@@ -547,12 +562,21 @@ static u32 aa_str_uimm(u32 size, u32 rt, u32 rn, u32 byte_off) {
return aa_str_uimm_v(size, 0, rt, rn, byte_off);
}
+/* Register-offset load/store with an explicit load opcode (ld_opc is consulted
+ * only when load != 0; AA64_LDST_OPC_LDRS_X gives a sign-extending ldrsb/ldrsh
+ * into the X register). */
+static u32 aa_ldst_regoff_op_v(u32 size, u32 v, u32 load, u32 ld_opc, u32 rt,
+ u32 rn, u32 rm, u32 scaled) {
+ return ((size & 3u) << 30) | 0x38200800u | ((v & 1u) << 26) |
+ ((load ? ld_opc : AA64_LDST_OPC_STR) << 22) | ((rm & 0x1fu) << 16) |
+ (3u << 13) | ((scaled & 1u) << 12) | ((rn & 0x1fu) << 5) |
+ (rt & 0x1fu);
+}
+
static u32 aa_ldst_regoff_v(u32 size, u32 v, u32 load, u32 rt, u32 rn, u32 rm,
u32 scaled) {
- return ((size & 3u) << 30) | 0x38200800u | ((v & 1u) << 26) |
- ((load ? AA64_LDST_OPC_LDR : AA64_LDST_OPC_STR) << 22) |
- ((rm & 0x1fu) << 16) | (3u << 13) | ((scaled & 1u) << 12) |
- ((rn & 0x1fu) << 5) | (rt & 0x1fu);
+ return aa_ldst_regoff_op_v(size, v, load, AA64_LDST_OPC_LDR, rt, rn, rm,
+ scaled);
}
static u32 aa_mrs_tpidr_el0(u32 rt) { return 0xd53bd040u | (rt & 0x1fu); }
@@ -827,7 +851,7 @@ static void aa_emit_mem_q(AANativeTarget* a, int load, NativeLoc reg,
static void aa_emit_mem(AANativeTarget* a, int load, NativeLoc reg,
NativeAddr addr, MemAccess mem) {
- u32 base, rt, sz;
+ u32 base, rt, sz, ld_opc;
i32 off;
MCEmitter* mc = a->base.mc;
rt = loc_reg(reg);
@@ -842,6 +866,15 @@ static void aa_emit_mem(AANativeTarget* a, int load, NativeLoc reg,
return;
}
if (native_loc_is_fp(reg) && sz < 2u) sz = 2u;
+ /* Lever 4: a signed narrow integer load (MF_SEXT_LOAD, byte/half, integer
+ * register) becomes a sign-extending load into the X register (ldrsb/ldrsh,
+ * opc=10) so it fills the whole register in one instruction — the cg layer
+ * then drops the redundant CV_SEXT. Every other load (incl. fp, word, dword)
+ * keeps the plain zero-extending LDR. */
+ ld_opc = (load && (mem.flags & MF_SEXT_LOAD) && !native_loc_is_fp(reg) &&
+ sz <= 1u)
+ ? AA64_LDST_OPC_LDRS_X
+ : AA64_LDST_OPC_LDR;
/* Far fixed-slot fast path (Lever 1 / Fix B). A plain 4/8-byte frame slot
* whose top-record fp offset is past stur's ±256 range would otherwise cost
* `sub xN,x29,#off (+movk) ; ldur` (2-3 insns). Instead emit a one-word
@@ -883,18 +916,19 @@ static void aa_emit_mem(AANativeTarget* a, int load, NativeLoc reg,
mc_emit_reloc_at(mc, mc->section_id, pos, R_AARCH64_LD64_GOT_LO12_NC,
addr.base.global.sym, 0, 0, 0);
if (addend) aa_emit_add_i64(a, scratch, scratch, addend);
- aa_emit32(mc, load
- ? aa_ldur_v(sz, native_loc_is_fp(reg), rt, scratch, 0)
- : aa_stur_v(sz, native_loc_is_fp(reg), rt, scratch, 0));
+ aa_emit32(mc, load ? aa_ldur_op_v(sz, native_loc_is_fp(reg), ld_opc, rt,
+ scratch, 0)
+ : aa_stur_v(sz, native_loc_is_fp(reg), rt, scratch, 0));
return;
}
aa_emit32(mc, aa64_adrp(scratch, 0, 0));
mc_emit_reloc_at(mc, mc->section_id, pos, R_AARCH64_ADR_PREL_PG_HI21,
addr.base.global.sym, addend, 0, 0);
pos = mc_pos(mc);
- aa_emit32(mc,
- load ? aa_ldr_uimm_v(sz, native_loc_is_fp(reg), rt, scratch, 0)
- : aa_str_uimm_v(sz, native_loc_is_fp(reg), rt, scratch, 0));
+ aa_emit32(mc, load ? aa_ldr_uimm_op_v(sz, native_loc_is_fp(reg), ld_opc, rt,
+ scratch, 0)
+ : aa_str_uimm_v(sz, native_loc_is_fp(reg), rt, scratch,
+ 0));
mc_emit_reloc_at(mc, mc->section_id, pos, aa_ldst_reloc_for_size(sz),
addr.base.global.sym, addend, 0, 0);
return;
@@ -916,25 +950,27 @@ static void aa_emit_mem(AANativeTarget* a, int load, NativeLoc reg,
} else {
aa_panic(a, "unsupported memory address scale");
}
- aa_emit32(mc, aa_ldst_regoff_v(sz, native_loc_is_fp(reg), load, rt,
- use_base, addr.index.reg, scaled));
+ aa_emit32(mc, aa_ldst_regoff_op_v(sz, native_loc_is_fp(reg), load, ld_opc,
+ rt, use_base, addr.index.reg, scaled));
return;
}
if (off >= 0 && (((u32)off & ((1u << sz) - 1u)) == 0) &&
((u32)off >> sz) <= 0xfffu) {
- aa_emit32(
- mc, load
- ? aa_ldr_uimm_v(sz, native_loc_is_fp(reg), rt, base, (u32)off)
- : aa_str_uimm_v(sz, native_loc_is_fp(reg), rt, base, (u32)off));
+ aa_emit32(mc, load ? aa_ldr_uimm_op_v(sz, native_loc_is_fp(reg), ld_opc, rt,
+ base, (u32)off)
+ : aa_str_uimm_v(sz, native_loc_is_fp(reg), rt, base,
+ (u32)off));
return;
}
if (off >= -256 && off <= 255) {
- aa_emit32(mc, load ? aa_ldur_v(sz, native_loc_is_fp(reg), rt, base, off)
+ aa_emit32(mc, load ? aa_ldur_op_v(sz, native_loc_is_fp(reg), ld_opc, rt,
+ base, off)
: aa_stur_v(sz, native_loc_is_fp(reg), rt, base, off));
return;
}
aa_emit_add_imm(a, AA_TMP1, base, off);
- aa_emit32(mc, load ? aa_ldur_v(sz, native_loc_is_fp(reg), rt, AA_TMP1, 0)
+ aa_emit32(mc, load ? aa_ldur_op_v(sz, native_loc_is_fp(reg), ld_opc, rt,
+ AA_TMP1, 0)
: aa_stur_v(sz, native_loc_is_fp(reg), rt, AA_TMP1, 0));
}
@@ -4228,6 +4264,10 @@ static const NativeRegInfo aa_reg_info = {
/* x0 is clobbered only by calls (div/mul take explicit operands), so a
* scalar call result can stay cached in it — see L4 Phase 1 in nd_call. */
.ndt_result_reg_stable = 1u,
+ /* aa_emit_mem emits ldrsb/ldrsh (opc=10, X form) for an MF_SEXT_LOAD narrow
+ * integer load, filling the whole register — so the -O0 NDT may drop the
+ * following CV_SEXT (Lever 4). */
+ .ndt_load_sext = 1u,
.resolve_name = aa_resolve_name,
.asm_operand_reg_ok = aa_asm_operand_reg_ok,
.asm_constraint_reg = aa_asm_constraint_reg,
diff --git a/src/arch/native_target.h b/src/arch/native_target.h
@@ -183,6 +183,14 @@ struct NativeRegInfo {
* in it would be silently clobbered before its consumer. */
u8 ndt_result_reg_stable;
+ /* True when this backend honors MF_SEXT_LOAD by emitting a sign-extending
+ * load that fills the whole register (aa64 ldrsb/ldrsh -> X), so the shared
+ * -O0 NDT may record load_sext on the loaded local and elide the following
+ * CV_SEXT. Clear (the default) on backends whose narrow load zero-extends
+ * regardless of the flag (x86-64, riscv64 today): there the convert must run,
+ * so the flag is a no-op and the extend is emitted normally. */
+ u8 ndt_load_sext;
+
/* Map a register name to its (Reg, class). `name` is the raw spelling
* ("rax", "x8", "a7"); the caller resolves any Sym to its bytes first so this
* stays pool-free. Returns 0 on success, non-zero for a non-register name. */
diff --git a/src/cg/cgir.h b/src/cg/cgir.h
@@ -246,6 +246,16 @@ typedef enum MemFlag {
MF_READONLY = 1u << 3,
MF_WRITEONLY = 1u << 4,
MF_UNALIGNED = 1u << 5,
+ /* Sign-extending narrow load (the C frontend's widening-signed-load lever):
+ * the value comes from a signed char/short/int lvalue whose only widening is a
+ * sign-extension to a wider type, so a backend that honors this flag emits a
+ * single sign-extending load (aa64 ldrsb/ldrsh into the X register, filling
+ * the whole register) instead of a zero-extending load plus a CV_SEXT convert.
+ * Non-bitfield integer loads only. A backend that does not honor it ignores
+ * the bit and emits a plain load; the shared -O0 NDT only elides the following
+ * CV_SEXT when the backend advertises NativeRegInfo.ndt_load_sext, so leaving
+ * the convert in place stays correct there. */
+ MF_SEXT_LOAD = 1u << 6,
} MemFlag;
typedef enum AliasKind {
diff --git a/src/cg/native_direct_target.c b/src/cg/native_direct_target.c
@@ -704,6 +704,7 @@ static void nd_flush_local(NativeDirectTarget* d, CGLocal local) {
l->reg = REG_NONE;
l->dirty = 0;
l->load_zext = 0;
+ l->load_sext = 0;
}
/* Drop a cache entry without writing it back, for when a store supersedes the
@@ -716,6 +717,7 @@ static void nd_invalidate_local(NativeDirectTarget* d, CGLocal local) {
l->reg = REG_NONE;
l->dirty = 0;
l->load_zext = 0;
+ l->load_sext = 0;
}
/* Spill the whole cache to memory and empty it. The list is sorted ascending,
@@ -1046,6 +1048,7 @@ static void nd_dst_writeback(NativeDirectTarget* d, Operand dst, NativeLoc dr) {
dst.type == l->type && nd_local_cacheable(d, l)) {
l->dirty = 1;
l->load_zext = 0; /* a fresh value; nd_load re-sets this for narrow loads */
+ l->load_sext = 0; /* "" (re-set only by an MF_SEXT_LOAD load) */
d->scratch_used[l->cls] &= ~(1u << dr.v.reg); /* unpin, keep cached */
return;
}
@@ -1628,15 +1631,23 @@ static void nd_load(CgTarget* t, Operand dst, Operand addr, MemAccess mem) {
ND_REQUIRE_NATIVE(d, load, "target does not emit loads");
d->native->load(d->native, reg, naddr, mem);
nd_dst_writeback(d, dst, reg);
- /* A narrow integer load zero-extends the whole register (ldrb/ldrh/ldr-w
- * clear the upper bits), so a subsequent CV_ZEXT that widens this value is a
- * no-op. Record it when the result stayed register-resident so nd_convert can
- * drop the extend. (nd_dst_writeback just cleared the flag for this local.) */
+ /* A narrow integer load fills the whole register: a plain ldrb/ldrh/ldr-w
+ * zero-extends, and (when the backend advertises ndt_load_sext and the access
+ * is MF_SEXT_LOAD) a sign-extending ldrsb/ldrsh sign-extends. Either way a
+ * subsequent matching widen (CV_ZEXT / CV_SEXT) is a no-op, so record which
+ * one this load produced — register-resident only — so nd_convert can drop the
+ * extend. (nd_dst_writeback just cleared both flags for this local.) */
if (dst.kind == OPK_LOCAL && size < (u64)t->c->target.ptr_size &&
nd_class_for_type(d, dst.type) == NATIVE_REG_INT &&
reg.kind == NATIVE_LOC_REG) {
NativeDirectLocal* l = nd_local(d, dst.v.local);
- if (l->reg == reg.v.reg) l->load_zext = 1;
+ if (l->reg == reg.v.reg) {
+ if ((mem.flags & MF_SEXT_LOAD) && d->reg_info &&
+ d->reg_info->ndt_load_sext)
+ l->load_sext = 1;
+ else
+ l->load_zext = 1;
+ }
}
nd_addr_temps_release(d, &temps);
}
@@ -1943,13 +1954,16 @@ static void nd_convert(CgTarget* t, ConvKind op, Operand dst, Operand src) {
* collapse to nothing in the arch move/convert path. A class-crossing convert
* (GPR<->FPR fmov) is excluded by nd_rename_killed_to_dst's same-class guard
* and falls through below. */
- /* A CV_ZEXT whose source came straight from a narrow integer load is a no-op:
- * the load already zero-extended the whole register (load_zext). Detect it
- * before the rename, which transfers (and would clear) the source's flag. */
- int zext_noop = 0;
- if (op == CV_ZEXT && src.kind == OPK_LOCAL) {
+ /* A CV_ZEXT (resp. CV_SEXT) whose source came straight from a narrow integer
+ * load is a no-op: the load already zero-extended (resp. sign-extended, via an
+ * MF_SEXT_LOAD ldrsb/ldrsh that filled the whole register) the value, so the
+ * widen is redundant for any wider destination. Detect it before the rename,
+ * which transfers (and would clear) the source's flag. */
+ int ext_noop = 0;
+ if (src.kind == OPK_LOCAL && (op == CV_ZEXT || op == CV_SEXT)) {
NativeDirectLocal* sl = nd_local(d, src.v.local);
- zext_noop = sl->reg != REG_NONE && sl->load_zext && src.type == sl->type;
+ u8 has = op == CV_ZEXT ? sl->load_zext : sl->load_sext;
+ ext_noop = sl->reg != REG_NONE && has && src.type == sl->type;
}
{
Reg r = nd_rename_killed_to_dst(d, dst, src);
@@ -1958,9 +1972,9 @@ static void nd_convert(CgTarget* t, ConvKind op, Operand dst, Operand src) {
sr = nd_loc_reg(d, src.type, cls, r);
dr = nd_loc_reg(d, dst.type, cls, r);
/* Renaming the source register to the destination already placed the
- * zero-extended value where the result wants it — skip the redundant
- * extend; the upper bits are provably clear. */
- if (!zext_noop) {
+ * extended value where the result wants it — skip the redundant extend;
+ * the upper bits are provably the correct zero/sign fill. */
+ if (!ext_noop) {
if (d->native->convert_rr)
d->native->convert_rr(d->native, op, native_reg_loc_of(dr),
native_reg_loc_of(sr));
diff --git a/src/cg/native_direct_target.h b/src/cg/native_direct_target.h
@@ -48,6 +48,14 @@ typedef struct NativeDirectLocal {
* (nd_dst_writeback) and when the register is dropped (flush/invalidate).
* Meaningful only while reg != REG_NONE. */
u8 load_zext;
+ /* The cached register holds this local's value SIGN-extended to the full
+ * register, from an MF_SEXT_LOAD narrow load that the backend honored with a
+ * sign-extending load (aa64 ldrsb/ldrsh, gated on ndt_load_sext). While set, a
+ * CV_SEXT widening it to any wider integer is a no-op and is elided by
+ * nd_convert. Set only in nd_load; cleared exactly like load_zext (a spill +
+ * plain reload would zero-extend, so clearing it forces the real extend to run
+ * on the reloaded value — see nd_flush_local / nd_invalidate_local). */
+ u8 load_sext;
u32 last_use; /* d->use_tick at the most recent cache touch (LRU victim key)
*/
/* Intrusive doubly-linked list of currently-cached locals, in insertion
diff --git a/src/cg/value.c b/src/cg/value.c
@@ -280,6 +280,7 @@ MemAccess api_mem_from_access(KitCg* g, const Operand* lv,
if (access.align) m.align = access.align;
m.addr_space = (u16)access.address_space;
if (access.flags & KIT_CG_MEM_VOLATILE) m.flags |= MF_VOLATILE;
+ if (access.flags & KIT_CG_MEM_SEXT_LOAD) m.flags |= MF_SEXT_LOAD;
if (!access.align || (ty && access.align < abi_cg_alignof(g->c->abi, ty))) {
m.flags |= MF_UNALIGNED;
}