cstage+selfhost+test: wire sret return-forwarding (#9)

Class A compile-time fatal retirement — `return f()` from an sret
callee bailed both stages with "sret return-forwarding for >24B
struct not wired (task #23)" at every site, forcing every caller
into a `let r = f(); return r;` workaround that materialised an
intermediate >24B copy in outer's frame. Forwarding now elides the
copy: outer reloads its own @sretarg into RDI for the inner CALL
via `MOVQ @sretarg(BP), DI` (NOT `LEAQ <local>, DI`), inner writes
directly into outer's caller-prealloc dest, RAX (inner's returned
dest pointer per the sret discipline) is already outer's return
value.

Wires 2 sites × 2 stages (same triangle as #23): caller arg-shift
in cgcall/pushargsrev gains an RDI-source switch via
cg_sret_forward / c.sretforward; callee return-arm in cgreturn
replaces the fail-loud abort with cgexpr-into-cgcall + epilogue.
The @sretscr scratch slot is still pre-allocated on the forwarding
branch (unused) — eliding would need AST-walk awareness in
scanlocals; symmetric-allocate is the simpler path and keeps
byte-id with non-forwarding callers.

Latent surfaced and filed during probe (NOT in this commit's
scope): multi-sret-receive in a single fn diverges between stages
— cstage always allocates @sretscr on first sret CALL, wwstage
only when sretdestoff == 0. Bootstrap stays green because the
selfhost corpus has zero >1-sret-receive call sites.

Tests:
  - 721_sret_struct_return gains 2 forwarding rows + a 4th asm-
    presence sentinel: at the inner CALL site inside outer fn, the
    RDI source must be `MOVQ -K(BP), DI` (reload of outer's saved
    @sretarg) NOT `LEAQ -K(BP), DI` (a temporary local would write
    inner's payload into outer's frame, not caller's dest).
  - 925_sret_struct_return_run gains 3 forwarding rows: simple
    quad forward, multi-arg inner (pair-by-value + scalar args
    alongside the hidden RDI), and slice-payload (decoder
    { i64, []u8 } — the utf8 iterator shape, asserts ptr/len/cap
    survive the @sretarg chain).

90/90 ok. 995_self_rebuild stays green (ww2==ww3==ww4 byte-id).
This commit is contained in:
2026-05-18 00:15:34 +09:00
parent 793734c1e0
commit dd274315a0
8 changed files with 359 additions and 57 deletions

View File

@@ -13145,10 +13145,24 @@ fn cgcall(c: *cgen, n: *node) void = {
// sret hidden first-arg (#23): load &dest into RDI AFTER all
// user-arg pops have finished — intidx started at 1 so RDI was
// never written. The CALL emit follows immediately.
//
// Forwarding (task #9 follow-up): when outer's `return f();`
// forwards through an sret callee, source RDI from outer's
// saved @sretarg — inner writes directly into outer's caller-
// prealloc dest. No temporary in outer's frame. The @sretscr
// slot stays reserved for byte-id with cstage; it goes unused
// on the forwarding branch.
if (sretcs > 0) {
emitline("\tLEAQ\t");
emitoff(sretcalloff: i64);
emitline("(BP), DI\n");
if (c.sretforward != 0) {
emitline("\tMOVQ\t");
emitoff(c.sretargoff: i64);
emitline("(BP), DI\n");
c.sretforward = 0;
} else {
emitline("\tLEAQ\t");
emitoff(sretcalloff: i64);
emitline("(BP), DI\n");
};
};
if (isfnptrcall) {
// Load fn-ptr field value into AX; CALL AX. We emit the
@@ -15838,16 +15852,28 @@ fn cgreturn(c: *cgen, n: *node) void = {
if (c.sretargoff != 0) {
let scs: i32 = sretretsize(c, c.fnret);
if (scs > 0) {
// `return f();` from a sret callee would silent-
// miscompile: cgexpr writes inner's result to
// @sretscr but outer never copies into *@sretarg
// and never sets RAX. Fail loud per CLAUDE.md
// rule 7; the `let r = f(); return r;` workaround
// is already wired and byte-id with cstage.
// sret return-forwarding (task #9 follow-up to
// #23): `return f();` where outer + inner both
// return the same >24B struct shape. Outer's
// @sretarg already holds its caller's prealloc
// dest; pass it to inner in RDI (set by cgcall
// via c.sretforward), inner writes directly
// there, inner's RAX (dest pointer) is already
// outer's return value. The trailing MOVQ
// @sretarg(BP), AX is redundant after inner's
// RET but kept for byte-id symmetry with the
// N_IDENT / N_STRUCTLIT arms below.
if (rhs.kind == nkind.N_CALL) {
let m: str = "ww: cgreturn: sret return-forwarding for >24B struct not wired (task #23)\n";
os.write(2, m.ptr, m.len: u64);
os.exit(1);
c.sretforward = 1;
cgexpr(c, rhs);
emitline("\tMOVQ\t");
emitoff(c.sretargoff: i64);
emitline("(BP), AX\n");
emitline("\tMOVQ\tBP, SP\n");
emitline("\tPOPQ\tBP\n");
emitline("\tRET\n");
c.lastwasreturn = 1;
return;
};
let okrhs: bool = false;
if (rhs.kind == nkind.N_IDENT) { okrhs = true; };
@@ -18440,10 +18466,17 @@ type cgen = struct {
// sums c.sretscrsz to pre-reserve.
// sretscrsz — max sret discard size in this fn (sums during
// scanlocals, consumed by localadd("@sretscr", ...)).
// sretforward — set by cgreturn `return f();` from an sret callee to
// signal cgcall: source RDI for inner from outer's
// saved @sretarg (MOVQ) instead of LEAQ'ing a local
// dest. Inner writes into outer's caller-prealloc;
// inner's RAX (the dest pointer) is already outer's
// return value. Cleared after cgcall consumes it.
sretargoff: i32,
sretdestoff: i32,
sretscroff: i32,
sretscrsz: i32,
sretforward: i32,
};
// Top-level mutable `let` registry. Mirrors cmd/w6c/cgen.c LetVar.
@@ -18471,6 +18504,7 @@ fn cgeninit(c: *cgen, a: *arena) void = {
c.sretdestoff = 0;
c.sretscroff = 0;
c.sretscrsz = 0;
c.sretforward = 0;
// Note: strlit_seq, strlits, ffis are *not* reset here; they
// persist across cgfn calls within one file. cgfile resets them
// at the start of each compilation unit.

View File

@@ -450,10 +450,17 @@ type cgen = struct {
// sums c.sretscrsz to pre-reserve.
// sretscrsz — max sret discard size in this fn (sums during
// scanlocals, consumed by localadd("@sretscr", ...)).
// sretforward — set by cgreturn `return f();` from an sret callee to
// signal cgcall: source RDI for inner from outer's
// saved @sretarg (MOVQ) instead of LEAQ'ing a local
// dest. Inner writes into outer's caller-prealloc;
// inner's RAX (the dest pointer) is already outer's
// return value. Cleared after cgcall consumes it.
sretargoff: i32,
sretdestoff: i32,
sretscroff: i32,
sretscrsz: i32,
sretforward: i32,
};
// Top-level mutable `let` registry. Mirrors cmd/w6c/cgen.c LetVar.
@@ -481,6 +488,7 @@ fn cgeninit(c: *cgen, a: *arena) void = {
c.sretdestoff = 0;
c.sretscroff = 0;
c.sretscrsz = 0;
c.sretforward = 0;
// Note: strlit_seq, strlits, ffis are *not* reset here; they
// persist across cgfn calls within one file. cgfile resets them
// at the start of each compilation unit.

View File

@@ -3151,10 +3151,24 @@ fn cgcall(c: *cgen, n: *node) void = {
// sret hidden first-arg (#23): load &dest into RDI AFTER all
// user-arg pops have finished — intidx started at 1 so RDI was
// never written. The CALL emit follows immediately.
//
// Forwarding (task #9 follow-up): when outer's `return f();`
// forwards through an sret callee, source RDI from outer's
// saved @sretarg — inner writes directly into outer's caller-
// prealloc dest. No temporary in outer's frame. The @sretscr
// slot stays reserved for byte-id with cstage; it goes unused
// on the forwarding branch.
if (sretcs > 0) {
emitline("\tLEAQ\t");
emitoff(sretcalloff: i64);
emitline("(BP), DI\n");
if (c.sretforward != 0) {
emitline("\tMOVQ\t");
emitoff(c.sretargoff: i64);
emitline("(BP), DI\n");
c.sretforward = 0;
} else {
emitline("\tLEAQ\t");
emitoff(sretcalloff: i64);
emitline("(BP), DI\n");
};
};
if (isfnptrcall) {
// Load fn-ptr field value into AX; CALL AX. We emit the

View File

@@ -300,16 +300,28 @@ fn cgreturn(c: *cgen, n: *node) void = {
if (c.sretargoff != 0) {
let scs: i32 = sretretsize(c, c.fnret);
if (scs > 0) {
// `return f();` from a sret callee would silent-
// miscompile: cgexpr writes inner's result to
// @sretscr but outer never copies into *@sretarg
// and never sets RAX. Fail loud per CLAUDE.md
// rule 7; the `let r = f(); return r;` workaround
// is already wired and byte-id with cstage.
// sret return-forwarding (task #9 follow-up to
// #23): `return f();` where outer + inner both
// return the same >24B struct shape. Outer's
// @sretarg already holds its caller's prealloc
// dest; pass it to inner in RDI (set by cgcall
// via c.sretforward), inner writes directly
// there, inner's RAX (dest pointer) is already
// outer's return value. The trailing MOVQ
// @sretarg(BP), AX is redundant after inner's
// RET but kept for byte-id symmetry with the
// N_IDENT / N_STRUCTLIT arms below.
if (rhs.kind == nkind.N_CALL) {
let m: str = "ww: cgreturn: sret return-forwarding for >24B struct not wired (task #23)\n";
os.write(2, m.ptr, m.len: u64);
os.exit(1);
c.sretforward = 1;
cgexpr(c, rhs);
emitline("\tMOVQ\t");
emitoff(c.sretargoff: i64);
emitline("(BP), AX\n");
emitline("\tMOVQ\tBP, SP\n");
emitline("\tPOPQ\tBP\n");
emitline("\tRET\n");
c.lastwasreturn = 1;
return;
};
let okrhs: bool = false;
if (rhs.kind == nkind.N_IDENT) { okrhs = true; };

View File

@@ -13145,10 +13145,24 @@ fn cgcall(c: *cgen, n: *node) void = {
// sret hidden first-arg (#23): load &dest into RDI AFTER all
// user-arg pops have finished — intidx started at 1 so RDI was
// never written. The CALL emit follows immediately.
//
// Forwarding (task #9 follow-up): when outer's `return f();`
// forwards through an sret callee, source RDI from outer's
// saved @sretarg — inner writes directly into outer's caller-
// prealloc dest. No temporary in outer's frame. The @sretscr
// slot stays reserved for byte-id with cstage; it goes unused
// on the forwarding branch.
if (sretcs > 0) {
emitline("\tLEAQ\t");
emitoff(sretcalloff: i64);
emitline("(BP), DI\n");
if (c.sretforward != 0) {
emitline("\tMOVQ\t");
emitoff(c.sretargoff: i64);
emitline("(BP), DI\n");
c.sretforward = 0;
} else {
emitline("\tLEAQ\t");
emitoff(sretcalloff: i64);
emitline("(BP), DI\n");
};
};
if (isfnptrcall) {
// Load fn-ptr field value into AX; CALL AX. We emit the
@@ -15838,16 +15852,28 @@ fn cgreturn(c: *cgen, n: *node) void = {
if (c.sretargoff != 0) {
let scs: i32 = sretretsize(c, c.fnret);
if (scs > 0) {
// `return f();` from a sret callee would silent-
// miscompile: cgexpr writes inner's result to
// @sretscr but outer never copies into *@sretarg
// and never sets RAX. Fail loud per CLAUDE.md
// rule 7; the `let r = f(); return r;` workaround
// is already wired and byte-id with cstage.
// sret return-forwarding (task #9 follow-up to
// #23): `return f();` where outer + inner both
// return the same >24B struct shape. Outer's
// @sretarg already holds its caller's prealloc
// dest; pass it to inner in RDI (set by cgcall
// via c.sretforward), inner writes directly
// there, inner's RAX (dest pointer) is already
// outer's return value. The trailing MOVQ
// @sretarg(BP), AX is redundant after inner's
// RET but kept for byte-id symmetry with the
// N_IDENT / N_STRUCTLIT arms below.
if (rhs.kind == nkind.N_CALL) {
let m: str = "ww: cgreturn: sret return-forwarding for >24B struct not wired (task #23)\n";
os.write(2, m.ptr, m.len: u64);
os.exit(1);
c.sretforward = 1;
cgexpr(c, rhs);
emitline("\tMOVQ\t");
emitoff(c.sretargoff: i64);
emitline("(BP), AX\n");
emitline("\tMOVQ\tBP, SP\n");
emitline("\tPOPQ\tBP\n");
emitline("\tRET\n");
c.lastwasreturn = 1;
return;
};
let okrhs: bool = false;
if (rhs.kind == nkind.N_IDENT) { okrhs = true; };
@@ -18440,10 +18466,17 @@ type cgen = struct {
// sums c.sretscrsz to pre-reserve.
// sretscrsz — max sret discard size in this fn (sums during
// scanlocals, consumed by localadd("@sretscr", ...)).
// sretforward — set by cgreturn `return f();` from an sret callee to
// signal cgcall: source RDI for inner from outer's
// saved @sretarg (MOVQ) instead of LEAQ'ing a local
// dest. Inner writes into outer's caller-prealloc;
// inner's RAX (the dest pointer) is already outer's
// return value. Cleared after cgcall consumes it.
sretargoff: i32,
sretdestoff: i32,
sretscroff: i32,
sretscrsz: i32,
sretforward: i32,
};
// Top-level mutable `let` registry. Mirrors cmd/w6c/cgen.c LetVar.
@@ -18471,6 +18504,7 @@ fn cgeninit(c: *cgen, a: *arena) void = {
c.sretdestoff = 0;
c.sretscroff = 0;
c.sretscrsz = 0;
c.sretforward = 0;
// Note: strlit_seq, strlits, ffis are *not* reset here; they
// persist across cgfn calls within one file. cgfile resets them
// at the start of each compilation unit.