selfhost+cstage+test: graduate frame growth to first-use+fail-loud (#15)

Subsumes #36. Drop wwstage scanlocals pre-pass; both stages converge on
first-use+fail-loud frame growth, rule-10 polarity DOWN to leaner side.
#36's surfaces (frame-total divergence on match-arm case-let; sibling
offset divergence in variadic+iter+match-prev compositions) close
naturally — running-max c.frame includes every first-use binding.

selfhost/cmd/wcc: add atlocals persistent @-prefix registry surviving
cgblock save/restore; add cgoutbuf/cgoutmode/cgout_enable/disable/flush
for deferred prologue (emit body to buffer, finalise c.frame, then
TEXT/SUBQ + flush); localadd @-prefix dedups against atlocals +
fail-louds on size-grow (rule 7 — no silent truncate); cgreturn-tagged
routes through @retscr (was colliding with @tagscr on arg-widen sizes);
variadic gather esz uses raw primsize (rune->4) not slotsize (rune->8)
— matches cstage and fixes the #36 sibling runtime miscompile in
non-leaf variadic+iter+match-prev callees.

cmd/w6c/cgen.c: drop the over-allocation hack ("for byte-id with
wwstage scanlocals reservation") since wwstage no longer over-reserves;
add fail-loud on @sretscr size-grow; @tagscr sites pass actual slot_sz
instead of stale c.tagscrsz.

748_size_strategy_convergence: table-driven 4 rows x 2 stages
(tag_variadic_runearm, trim_iter_match_prev, variadic_gather_rune_stride,
leaf_baseline). Each exercises a #36 surface shape; 8/8 ok.

Net -1565 lines. Sister latents filed as cosmetic (cs/ws frame size
drift on multiple-variadic-call fns): labelseq drift + varargseq
stuck at 0 — both bootstrap-byte-id safe (ww2==ww3==ww4 holds since
both ww2 and ww3 are wwstage outputs).

make test 122/122; ww2==ww3==ww4 byte-id holds via 995_self_rebuild.
This commit is contained in:
2026-05-19 02:13:58 +09:00
parent 7a278c1a2d
commit 5609d0456f
10 changed files with 1197 additions and 2469 deletions

View File

@@ -377,6 +377,13 @@ type structinfo = struct {
type local = struct {
name: str,
off: i32,
sz: i32, // allocated slot size; carried so @-prefix reuse can
// fail-loud (rule 7) if a later site needs a larger
// slot than the first allocation pinned. Per #15/#26c
// size-strategy convergence — wwstage dropped its
// scanlocals pre-pass, so @tagscr/@retscr/@sretscr/
// @tagbase are sized at first-use; subsequent uses
// must fit.
tnode: *node, // declared type expr (nkind.N_TNAME / nkind.N_TPTR / ...) or nil
lnext: *local,
};
@@ -422,6 +429,17 @@ def DEFER_MAX: i32 = 16;
type cgen = struct {
a: *arena,
locals: *local,
// atlocals — persistent registry of `@`-prefix scratch slots
// for the current fn. cgblock save/restores c.locals to scope
// inner shadows (post-#27); a return/cgindex/cgwidentaggedstore
// inside one block must not reallocate @retscr/@tagscr when a
// sibling block uses them again. cgblock leaves atlocals alone
// so the slot offsets survive. localadd checks here first for
// @-prefix names; localfind falls back here when c.locals misses
// an @-name. Pre-#15 this was a handful of named offsets on the
// cgen (c.retscroff / c.sretargoff / c.sretscroff); post-#15
// every @-name flows through the same registry.
atlocals: *local,
frame: i32,
lastwasreturn: i32,
labelseq: i32,
@@ -451,55 +469,34 @@ type cgen = struct {
yieldbuf: *str, // stack of match end labels for yield
defertop: i32,
deferbuf: **node, // stack of deferred exprs (LIFO at return)
// Variadic-call gather state. scanlocals walks the body in pre-
// order DFS and assigns per-call scratch names `@vararg_d_N` /
// `@vararg_sl_N` using this counter; cgcall resets and walks in
// the same order so the names line up at emission time.
// Variadic-call gather state. cgcall assigns per-call scratch
// names `@vararg_d_N` / `@vararg_sl_N` using this counter;
// post #15 the seq is bumped only at emit time so a single
// sequence is observed (the scanlocals pre-pass was dropped).
varargseq: i32,
// Max @tagscr slot_sz across all reservation sites in the current
// function. scanlocals bumps; every emit-time `localadd("@tagscr",
// ...)` passes this same size so the first allocation lands a slot
// big enough for every later user. Single source of truth — pins
// rob's "scan + emit lockstep" invariant. Reset per cgfn.
tagscrsz: i32,
// Live @retscr offset (#14). c.locals-based `@`-prefix dedup in
// localadd is unwound by cgblock save/restore (post-#27), so a
// second `return` in a sibling/outer block reallocates a fresh
// slot — emit grew the frame past what scanlocals reserved, and
// the stomp landed below SP. retscroff is the persistent SSoT:
// 0 means "not yet allocated"; first emit-site sets it, every
// later emit reuses. Mirrors c.tagscrsz pattern (#38) but tracks
// offset, not size (per-fn return type is fixed, so size is too).
retscroff: i32,
// System V AMD64 sret discipline (#23). Plain TY_STRUCT returns
// with size > 24B are passed via a hidden first-arg pointer
// (RDI) to a caller-prealloc dest; the callee writes through
// that pointer and returns it in RAX.
//
// sretargoff — callee-side @sretarg slot (8B, holds saved RDI).
// Set in cgfn prologue when the fn's return type
// triggers sret. 0 means N/A.
// sretdestoff — caller-side dest BP offset, propagated from a
// receive site (cglet / cgassign ident) to the
// nested cgexpr → cgcall so the call emits
// `LEAQ off(BP), DI` instead of allocating a
// scratch. 0 means no receiver wired.
// sretscroff — per-fn @sretscr discard slot, used by sret CALLs
// whose result has no named receiver. Single-slot
// SSoT mirroring c.retscroff; the scanlocals walk
// 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,
//
// The single-slot caches for @sretarg / @sretscr / @retscr that
// used to live here are gone: localadd's `@`-prefix dedup against
// c.locals (fail-loud on size grow) is the SSoT now. cgenstmt /
// cgenexpr resolve `@sretarg` via localfind when they need the
// saved RDI.
sretdestoff: i32,
sretscroff: i32,
sretscrsz: i32,
sretforward: i32,
};
@@ -518,16 +515,12 @@ type letvar = struct {
fn cgeninit(c: *cgen, a: *arena) void = {
c.a = a;
c.locals = nil;
c.atlocals = nil;
c.frame = 0;
c.lastwasreturn = 0;
c.labelseq = 0;
c.varargseq = 0;
c.tagscrsz = 0;
c.retscroff = 0;
c.sretargoff = 0;
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
@@ -542,10 +535,9 @@ fn cgeninit(c: *cgen, a: *arena) void = {
};
// localalloc — append a slot for `name` without dedup. Used for
// match-arm bindings, which C cgen allocates via cgexpr's by-value
// match-arm bindings, which cstage allocates via cgexpr's by-value
// `locals` list — so two separate matches each get fresh slots even
// when their bind names collide. scanlocals follows the same rule
// for nkind.N_MCASE.
// when their bind names collide.
fn localalloc(c: *cgen, name: str, sz: i32, tnode: *node) i32 = {
let asz: i32 = sz;
if (asz < 8) { asz = 8; };
@@ -555,6 +547,7 @@ fn localalloc(c: *cgen, name: str, sz: i32, tnode: *node) i32 = {
let l: *local = amalloc(c.a, 48u64): *local;
l.name = name;
l.off = off;
l.sz = asz;
l.tnode = tnode;
l.lnext = c.locals;
c.locals = l;
@@ -570,6 +563,7 @@ fn localaddstack(c: *cgen, name: str, tnode: *node, off: i32) void = {
let l: *local = amalloc(c.a, 48u64): *local;
l.name = name;
l.off = off;
l.sz = 0;
l.tnode = tnode;
l.lnext = c.locals;
c.locals = l;
@@ -579,86 +573,60 @@ fn localadd(c: *cgen, name: str, sz: i32, tnode: *node) i32 = {
// User-let path (post-#27): always allocate a fresh slot per
// binding. Pre-fix this deduped by name to share one slot
// across same-name lets in disjoint scopes — inherited from
// C cgen's localoff. Both stages had the same silent-stack-
// cstage's localoff. Both stages had the same silent-stack-
// corruption bug: an inner 8B `let a: i64` allocated first
// would force a later outer `let a: [128]u8` onto the 8B slot,
// and `a[127]` would write at +119(BP), past the saved RIP.
// Localfind walks head-first, so the most-recent binding still
// wins lookups inside its scope. Tnode is carried on the
// freshly-pushed entry, so type dispatch in cgenutil never
// sees a stale predecessor.
//
// Synthetic scratch slots (`@tagscr`, `@retscr`, `@tagbase`)
// keep the per-fn dedup. Each scratch is sized identically
// across its call sites and intended to be shared — the
// scanlocals pre-pass also dedups via scanseenmark, so frame
// reservation and emit-time allocation stay in sync. The
// `@`-prefix carve-out preserves that contract; user names
// can never start with `@` (lexer-rejected).
//
// @retscr (#14) routes through c.retscroff instead of c.locals.
// The c.locals-based dedup is unwound by cgblock save/restore
// (post-#27): a return inside an `if` block adds @retscr to
// c.locals; on block exit, c.locals reverts and a sibling/outer
// return reallocates a fresh slot. Scan had reserved one slot;
// emit grew the frame past the reservation and the second
// site's writes landed below SP. c.retscroff is per-fn state
// that survives cgblock save/restore and pins single-slot.
// `@`-prefix scratch slots (`@tagscr`, `@retscr`, `@tagbase`,
// `@sretarg`, `@sretscr`, `@match_spill`, `@vararg_*`) share
// one slot per name per fn. Post #15/#26c the slot is sized
// at first use and reused by every later caller; a later
// caller asking for a larger slot than the first allocation
// pinned fatals (rule 7 — surface, don't silently corrupt
// the frame: the pinned offset already neighbours other
// locals so the slot can't grow in place). Mirrors cstage's
// cg_tagscr / cg_retscr / cg_sretscr same-fn caches in
// cmd/w6c/cgen.c (#26 / #15).
if (name.len > 0) {
if (name[0] == 64u8) { // '@'
if (streq(name, "@retscr")) {
if (c.retscroff != 0) { return c.retscroff; };
let off: i32 = localalloc(c, name, sz, tnode);
c.retscroff = off;
return off;
};
// @sretarg / @sretscr (#23): same single-slot SSoT
// pattern as @retscr. @sretarg holds the saved hidden
// RDI for sret callees (8B, set once per fn at the
// prologue); @sretscr is the caller-side discard slot
// for sret CALLs whose result is dropped.
if (streq(name, "@sretarg")) {
if (c.sretargoff != 0) { return c.sretargoff; };
let off: i32 = localalloc(c, name, sz, tnode);
c.sretargoff = off;
return off;
};
if (streq(name, "@sretscr")) {
if (c.sretscroff != 0) { return c.sretscroff; };
let off: i32 = localalloc(c, name, sz, tnode);
c.sretscroff = off;
return off;
};
let cur: *local = c.locals;
let asz: i32 = sz;
if (asz < 8) { asz = 8; };
if ((asz & 7) != 0) { asz = (asz + 7) & ~7; };
let cur: *local = c.atlocals;
for (cur != nil) {
let cn: str = cur.name;
if (streq(cn, name)) {
if (asz > cur.sz) {
// rule-7 surface, post-#15: pinned slot
// offset can't grow in place.
let msg: str = "localadd: @-prefix slot grew within fn\n";
os.write(2, msg.ptr, msg.len: u64);
os.exit(1);
};
cur.tnode = tnode;
return cur.off;
};
cur = cur.lnext;
};
// First use: allocate via localalloc (bumps c.frame +
// pushes to c.locals so localfind sees it within this
// block) and pin a parallel entry in c.atlocals so the
// allocation survives cgblock save/restore.
let off: i32 = localalloc(c, name, sz, tnode);
let at: *local = amalloc(c.a, 48u64): *local;
at.name = name;
at.off = off;
at.sz = asz;
at.tnode = tnode;
at.lnext = c.atlocals;
c.atlocals = at;
return off;
};
};
return localalloc(c, name, sz, tnode);
};
// scanseenmark — called by scanlocals on every let / match-bind
// site. Returns true if `name` is already tracked in c.locals (so
// the slot will be shared at emission time — no new frame bump).
// Otherwise appends a name-only stub and returns false. Stubs are
// thrown away when cgfn resets c.locals before emission.
fn scanseenmark(c: *cgen, name: str) bool = {
if (localfindnode(c, name) != nil) { return true; };
let l: *local = amalloc(c.a, 48u64): *local;
l.name = name;
l.off = 0;
l.tnode = nil;
l.lnext = c.locals;
c.locals = l;
return false;
};
fn localfindnode(c: *cgen, name: str) *local = {
let l: *local = c.locals;
for (l != nil) {
@@ -666,6 +634,18 @@ fn localfindnode(c: *cgen, name: str) *local = {
if (streq(ln, name)) { return l; };
l = l.lnext;
};
// @-prefix scratch slots survive cgblock save/restore via
// c.atlocals; a localfindnode from a sibling/outer block must
// still resolve them.
if (name.len > 0) {
if (name[0] == 64u8) {
let a: *local = c.atlocals;
for (a != nil) {
if (streq(a.name, name)) { return a; };
a = a.lnext;
};
};
};
return nil;
};
@@ -684,21 +664,92 @@ fn localfind(c: *cgen, name: str) i32 = {
};
l = l.lnext;
};
if (name.len > 0) {
if (name[0] == 64u8) {
let a: *local = c.atlocals;
for (a != nil) {
if (streq(a.name, name)) { return a.off; };
a = a.lnext;
};
};
};
return 0;
};
// ---- emit helpers ---------------------------------------------------
fn emitline(s: str) void = { os.write(1, s.ptr, s.len: u64); };
// Cgfn defers its prologue (TEXT / SUBQ) until after the body so the
// frame size reflects every emit-time localadd — the scanlocals pre-
// pass that previously pre-computed it was dropped per #15/#26c. The
// body is captured into cgoutbuf while cgoutmode != 0, then flushed
// after the prologue is written to stdout. Module-level state so the
// existing emitline/emitint/emitlabel/emitsymname callers don't have
// to thread a *cgen they don't already hold. Mirrors cstage's deferred
// Prog-chain emit (cmd/w6c/cgen.c cgfn allocates `subsp`/`text` up
// front and patches `from.offset` after the body finishes).
let cgoutbuf: *u8 = nil;
let cgoutbufcap: i32 = 0;
let cgoutbuflen: i32 = 0;
let cgoutmode: i32 = 0;
let cgoutarena: *arena = nil;
def CGOUT_INIT_CAP: i32 = 65536;
fn cgout_grow(need: i32) void = {
if (need <= cgoutbufcap) { return; };
let want: i32 = cgoutbufcap;
if (want == 0) { want = CGOUT_INIT_CAP; };
for (want < need) { want = want * 2; };
let p: *u8 = amalloc(cgoutarena, want: u64): *u8;
let i: i32 = 0;
for (i < cgoutbuflen) {
p[i] = cgoutbuf[i];
i += 1;
};
cgoutbuf = p;
cgoutbufcap = want;
};
fn cgout_enable(a: *arena) void = {
cgoutarena = a;
cgoutbuflen = 0;
cgoutmode = 1;
};
fn cgout_disable() void = { cgoutmode = 0; };
fn cgout_flush() void = {
if (cgoutbuflen > 0) {
os.write(1, cgoutbuf, cgoutbuflen: u64);
cgoutbuflen = 0;
};
};
fn emitbytes(p: *u8, n: u64) void = {
if (cgoutmode != 0) {
let nn: i32 = n: i32;
cgout_grow(cgoutbuflen + nn);
let i: i32 = 0;
for (i < nn) {
cgoutbuf[cgoutbuflen + i] = p[i];
i += 1;
};
cgoutbuflen += nn;
} else {
os.write(1, p, n);
};
};
fn emitline(s: str) void = { emitbytes(s.ptr, s.len: u64); };
fn emitint(v: i64) void = {
let s: str = strconv.i64tos(v, strconv.base.DEC);
os.write(1, s.ptr, s.len: u64);
emitbytes(s.ptr, s.len: u64);
};
fn emituint(v: u64) void = {
let s: str = strconv.u64tos(v, strconv.base.DEC);
os.write(1, s.ptr, s.len: u64);
emitbytes(s.ptr, s.len: u64);
};
// emitdispreg — print "disp(reg)" or "(reg)" when disp == 0, the
@@ -755,7 +806,7 @@ fn mklabel(c: *cgen, prefix: str) str = {
};
fn emitlabel(s: str) void = {
os.write(1, s.ptr, s.len: u64);
emitbytes(s.ptr, s.len: u64);
emitline(":\n");
};
@@ -1076,7 +1127,7 @@ fn emitdatawbyte(b: u8) void = {
else { bb[0] = (hi - 10u8) + 97u8; };
if (lo < 10u8) { bb[1] = lo + 48u8; }
else { bb[1] = (lo - 10u8) + 97u8; };
os.write(1, bb.ptr, 2u64);
emitbytes( bb.ptr, 2u64);
return;
};
if (b >= 127u8) {
@@ -1088,12 +1139,12 @@ fn emitdatawbyte(b: u8) void = {
else { bb[0] = (hi - 10u8) + 97u8; };
if (lo < 10u8) { bb[1] = lo + 48u8; }
else { bb[1] = (lo - 10u8) + 97u8; };
os.write(1, bb.ptr, 2u64);
emitbytes( bb.ptr, 2u64);
return;
};
let bb: [1]u8;
bb[0] = b;
os.write(1, bb.ptr, 1u64);
emitbytes( bb.ptr, 1u64);
};
// letpreintern — intern strlits referenced from top-level str-let
@@ -1258,7 +1309,7 @@ fn emitletdataw(c: *cgen, file: *node) void = {
emitline("DATAR ");
emitsymname(c, nm);
emitline("+0(SB),");
os.write(1, lab.ptr, lab.len: u64);
emitbytes( lab.ptr, lab.len: u64);
emitline("(SB)\n");
} else {
// zero-init: accept no rhs, nil,
@@ -1426,12 +1477,12 @@ fn emitdefconstants(c: *cgen, file: *node) void = {
emitline("DATA ");
if (d.exported == 0) {
if (d.nmod.len > 0) {
os.write(1, d.nmod.ptr, d.nmod.len: u64);
os.write(1, ".".ptr, 1u64);
emitbytes( d.nmod.ptr, d.nmod.len: u64);
emitbytes( ".".ptr, 1u64);
};
};
let nm: str = d.str;
os.write(1, nm.ptr, nm.len: u64);
emitbytes( nm.ptr, nm.len: u64);
emitline("(SB),\"");
let i: i32 = 0;
let n: u64 = v;
@@ -1452,7 +1503,7 @@ fn emitdefconstants(c: *cgen, file: *node) void = {
else { bb[0] = (hi - 10u8) + 97u8; };
if (lo < 10u8) { bb[1] = lo + 48u8; }
else { bb[1] = (lo - 10u8) + 97u8; };
os.write(1, bb.ptr, 2u64);
emitbytes( bb.ptr, 2u64);
} else {
if (b >= 127u8) {
emitline("\\x");
@@ -1463,11 +1514,11 @@ fn emitdefconstants(c: *cgen, file: *node) void = {
else { bb[0] = (hi - 10u8) + 97u8; };
if (lo < 10u8) { bb[1] = lo + 48u8; }
else { bb[1] = (lo - 10u8) + 97u8; };
os.write(1, bb.ptr, 2u64);
emitbytes( bb.ptr, 2u64);
} else {
let bb: [1]u8;
bb[0] = b;
os.write(1, bb.ptr, 1u64);
emitbytes( bb.ptr, 1u64);
};
};
};};
@@ -1487,7 +1538,7 @@ fn emitdatasection(c: *cgen) void = {
for (s != nil) {
emitline("DATA ");
let lab: str = s.label;
os.write(1, lab.ptr, lab.len: u64);
emitbytes( lab.ptr, lab.len: u64);
emitline("(SB),\"");
let bs: str = s.bytes;
let i: i32 = 0;
@@ -1508,7 +1559,7 @@ fn emitdatasection(c: *cgen) void = {
else { bb[0] = (hi - 10u8) + 97u8; };
if (lo < 10u8) { bb[1] = lo + 48u8; }
else { bb[1] = (lo - 10u8) + 97u8; };
os.write(1, bb.ptr, 2u64);
emitbytes( bb.ptr, 2u64);
} else {
if (b >= 127u8) {
emitline("\\x");
@@ -1519,11 +1570,11 @@ fn emitdatasection(c: *cgen) void = {
else { bb[0] = (hi - 10u8) + 97u8; };
if (lo < 10u8) { bb[1] = lo + 48u8; }
else { bb[1] = (lo - 10u8) + 97u8; };
os.write(1, bb.ptr, 2u64);
emitbytes( bb.ptr, 2u64);
} else {
let bb: [1]u8;
bb[0] = b;
os.write(1, bb.ptr, 1u64);
emitbytes( bb.ptr, 1u64);
};
};
};};};};};
@@ -1891,15 +1942,15 @@ fn emitsymname(c: *cgen, ident: str) void = {
let resolved: str = ffiresolve(c, ident);
if (resolved.ptr != ident.ptr) {
// FFI hit — emit the mapped linker symbol verbatim.
os.write(1, resolved.ptr, resolved.len: u64);
emitbytes( resolved.ptr, resolved.len: u64);
return;
};
let mod: str = modlookup(c, ident);
if (mod.len > 0) {
os.write(1, mod.ptr, mod.len: u64);
os.write(1, ".".ptr, 1u64);
emitbytes( mod.ptr, mod.len: u64);
emitbytes( ".".ptr, 1u64);
};
os.write(1, ident.ptr, ident.len: u64);
emitbytes( ident.ptr, ident.len: u64);
};
// emitfnname — write the asm symbol name for a fn `ident`, threading
@@ -1910,15 +1961,15 @@ fn emitsymname(c: *cgen, ident: str) void = {
fn emitfnname(c: *cgen, ident: str, hint: str) void = {
let resolved: str = ffiresolve(c, ident);
if (resolved.ptr != ident.ptr) {
os.write(1, resolved.ptr, resolved.len: u64);
emitbytes( resolved.ptr, resolved.len: u64);
return;
};
let mod: str = modlookupforfn(c, ident, hint);
if (mod.len > 0) {
os.write(1, mod.ptr, mod.len: u64);
os.write(1, ".".ptr, 1u64);
emitbytes( mod.ptr, mod.len: u64);
emitbytes( ".".ptr, 1u64);
};
os.write(1, ident.ptr, ident.len: u64);
emitbytes( ident.ptr, ident.len: u64);
};
// ---- FFI map ---------------------------------------------------------