CG stack API: parser-driven codegen without a shadow stack
Goal. Make the C parser drive KitCg directly, in the same single-pass
shape it has today, without maintaining a duplicate pcg value stack. The
codegen stack stays the liveness authority: a value/place is live exactly while
it is represented by a stack slot, and popping that slot is the cheap
single-pass death signal the native direct target and temporary reclaim already
use.
This is not a plan to replace the stack API with free-floating value handles. It
is a plan to (a) make stack slots rich enough for a language frontend to use them
as its expression stack, (b) move all effective-address folding behind the CG
API, (c) make codegen suppression a CG mode that preserves stack shape,
frontend slot facts, and constant facts, and (d) delete the pcg adapter
layer so the parser drives the CG API directly.
This is a clean-break rewrite, no backcompat. The implementation is phased only where subsystems are genuinely separable; each phase is a one-time cutover with the old path removed for that subsystem. There is no dual-stack compatibility mode and no byte-identity gate.
1. Decisions (resolved)
| # | Decision | Choice |
|---|---|---|
| D1 | Keep the stack API, add stack-owned inline frontend slot facts | yes |
| D2 | Free KitCgValue/KitCgPlace handles independent of stack lifetime |
rejected — loses the "on stack == live" invariant |
| D3 | Where the type/flags stack lives during suppression | CG unevaluated mode — KitCg maintains stack shape, inline lang facts, and constant facts while target emission is off |
| D4 | Effective-address folding | Automatic behind the CG API. The parser builds places naively (deref, field_at, elem); CG decides whether to fold into the fused [base + index*scale + offset] operand or materialize. No deferral or folding logic in the parser. |
| D5 | Success gate | Correctness + determinism (full suites pass, output deterministic). Emitted bytes MAY change vs today; byte-identity is not a gate. |
| D6 | Fused store_void / store_keep as CG ops |
in scope |
| D7 | The cg_adapter.c / pcg_* layer |
deleted entirely. The parser drives kit_cg_* directly and applies C facts via kit_cg_retag_top/set_top_flags. C-semantic operations survive as plain helper functions (op-enum maps, the conversion lattice, atomic/inline-asm/inc-dec shaping) — not a stack-mirroring layer, and not under a pcg_ prefix. |
| D8 | Call-arg stack-mark API (old §4.4) | dropped — C already passes args on the CG stack (kit_cg_call), so a mark API adds nothing |
| D9 | KitCgSlotInfo + new ops are public API |
forced — lang/ builds only against include/kit/cg.h (no src/cg includes) |
| D10 | Lang facts storage | inline in ApiSValue; final node size is 64 bytes. Pack kind/res/pinned/lvalue into a uint16_t flags word and make lang_flags uint16_t, giving room for const void* lang_type while preserving the ApiSValue <= 64 invariant (src/cg/value.c:14). |
| D11 | C integer-constant-expression evaluation | moves onto a CG constant-value model, with parser-owned C legality tracking. The parser evaluates an ICE by parsing it once through the normal path under unevaluated mode, checking the syntactic/semantic ICE guard, and reading the folded value from CG. The parser's duplicate cexpr_* grammar and cint_* arithmetic engine are deleted in the final ICE phase, not during the stack/place cutover. |
| D12 | Constant payload shape | stack-parallel constant payload, not OPK_IMM alone. kit_cg_top_const_int today only answers for <=64-bit immediate operands; the new model carries known integer bits + width + type independently of whether the emitted operand would be an immediate/local/runtime call. |
| D13 | __int128 constant policy |
support useful 128-bit integer bit-pattern folding in CG, but do not require 128-bit div/rem. CG already has i128/u128 types and emitted i128 div/rem lower through runtime helpers; those are dynamic code, not compile-time constants. Move the existing parser CConstInt add/sub/mul/bitwise/shift/compare/cast behavior into CG so current static i128 initializer coverage stays supported; i128 div/rem returns "unknown constant" until a true compile-time word-int division implementation is added. |
2. Current state (what we are removing)
The C frontend currently runs two stacks in lockstep:
- The real CG stack:
KitCg.stackofApiSValue(src/cg/internal.h:99).api_push/api_pop(src/cg/value.c:161/187) maintain-O0transient reference counts that feed cheap single-pass liveness and temp reclaim. The node already carriestype(CG type id), anlvaluebyte (PLACE vs VALUE), and anApiBitField(bit-field geometry of a PLACE). - The parser shadow stack:
Parser.cg_slot_stackofPcgSlot(lang/c/parse/parse_priv.h:250,cg_adapter.h:73). Each slot carriesconst Type*, a cachedcg_id, C value flags, and aPcgLvAuxeffective-address record (cg_adapter.h:50).
PcgSlot duplicates four things; only one is genuinely C-only:
PcgSlot field |
Status |
|---|---|
const Type* (C type) |
C-only; opaque to CG; must live somewhere |
cg_id (cached CG id) |
redundant — the CG node already has type |
| flags: LVALUE / MODIFIABLE / BITFIELD / NULL_PTR_CONST / REGISTER | LVALUE≈node lvalue, BITFIELD≈node bit-field; MODIFIABLE/NULL_PTR_CONST/REGISTER are C-only |
PcgLvAux offset/scale/base_kind/bit_* |
redundant with the CG place operand (OPK_INDIRECT base+index*scale+ofs) + node bit-field — kept only to defer the deref and avoid re-crossing C-layout |
CG already has a complete PLACE/VALUE addressing model (include/kit/cg.h:668):
push_local / deref(offset) / field(index) / elem / elem_scaled /
addr / load / store, with offset/index/scale folded into the OPK_INDIRECT
operand (kit_cg_field, src/cg/control.c:1157, folds the field offset into the
displacement and preserves any index/scale). toy and wasm use this model
directly. The C frontend bypasses kit_cg_field/kit_cg_elem and reimplements
the same folding in PcgLvAux (pcg_materialize_lv_to_ptr,
pcg_lv_to_memop_place, cg_adapter.c:585/647) purely to (1) defer the deref so
*p/p->f stay a pointer rvalue, and (2) avoid re-deriving an offset it already
knows. D4 deletes this: the folding moves behind the CG API.
The sqlite layer profile makes the cost visible: ~1.43 M kit_cg_* calls per
sqlite3.c, CG API ~19% of compile ticks, of which the stack/memory family is
~49%. The hot expression seam is paid twice — once per CG stack edit, once per
shadow-stack edit.
3. Invariants (load-bearing)
- Stack lifetime stays authoritative. A transient operand is live iff a live
ApiSValuestack entry references it.api_push/api_popremain the lifetime hooks. Inline lang facts and the constant payload never own lifetime. - No free expression handles. Every API returns either a local/symbol/label handle with existing lifetime rules, or a short-lived stack depth — never a separately-live expression value.
- The frontend can inspect and retag slots. Read top/top2/depth-N lang type
- flags; retag the result slot after an op.
- CG does not depend on C frontend types.
src/cgmust not includelang/cheaders or dereference the lang payload. The lang payload is an opaqueconst void*copied/dropped with the slot. - Backend boundaries stay intact.
CgTarget/ NDT /NativeTarget/ MC receive no C frontend state. The new place ops lower through the existing CgTarget contract — no backend changes. - The hot
ApiSValuenode stays<= 64bytes. Inline lang facts are allowed only because the packed layout keeps the node at exactly 64 bytes (D10). - No global state. Inline lang facts live on each
ApiSValue; the constant payload and unevaluated counter hang offKitCg. - No separate lang side allocation. Non-C frontends leave
lang_type=NULLandlang_flags=0; they pay the 64-byte node size but no extra side array or enable/disable machinery. - Constant facts are stack-owned CG facts, not backend facts. The constant
payload shadows the CG stack in lockstep, carries known/unknown integer bits
plus width/type, and has no
CgTarget, local, temp, or native-backend lifetime. Frontends read it only through public CG APIs; they never inspectApiSValueto decide whether a value is constant. - Constant tracking is always-on. The constant payload is a CG semantic
fact, not a C frontend feature and not an optimization-level feature. It is
maintained for every
KitCgstack slot in normal emitting mode and in unevaluated mode; there is nokit_cg_const_enableand no "constants off" mode.
4. The new architecture
4.1 One stack + inline opaque lang facts
ApiSValue carries the frontend facts directly. There is no KitCg.lang_side,
no enable call, and no per-frontend side allocation. lang_type is an opaque
pointer copied/dropped with the stack node; lang_flags is a frontend-defined
16-bit field. A separate always-on constant payload still shadows the same stack
depths; it is owned by KitCg, follows the stack in lockstep, and is independent
of the frontend type/flags fields.
Planned internal shape, preserving a 64-byte hot stack node:
typedef struct ApiSValue {
Operand op; /* 24 bytes: immediate/local/global/indirect operand */
ApiDelayed* delayed; /* off-node SV_CMP/SV_ARITH payload, else NULL */
const void* lang_type; /* opaque to CG; lang/c stores a const Type* */
KitCgTypeId type; /* CG type id */
KitCgLocal source_local; /* owned/fixed local tracking */
ApiBitField bitfield; /* 12 bytes; bit_width != 0 => bit-field PLACE */
uint16_t lang_flags; /* frontend-defined (C: LVALUE/MODIFIABLE/...) */
uint16_t flags; /* kind:2, res:2, pinned:1, lvalue:1, spare:10 */
} ApiSValue; /* 64 bytes, 8-byte aligned */
kind is one of SV_OPERAND, SV_CMP, or SV_ARITH; res is one of
RES_INHERENT, RES_LOCAL, or RES_FIXED_LOCAL; pinned and lvalue are
booleans. These fit in six bits, leaving ten spare bits in the packed word.
ApiDelayed remains an off-node pooled extension for delayed compare/arithmetic
fusion only; it is not a language-fact or constant-value store.
typedef struct KitCgSlotInfo {
KitCgTypeId cg_type; /* read-only echo of the node's CG type, for queries */
const void* lang_type; /* opaque to CG; lang/c stores a const Type* */
uint16_t lang_flags; /* frontend-defined (C: LVALUE/MODIFIABLE/...) */
} KitCgSlotInfo;
kit_cg_slot_info reads {lang_type, lang_flags} from ApiSValue and fills
cg_type from stack[i].type at query time. The C flags are frontend-private
bits and must fit in uint16_t; suggested C layout:
C_LVALUE /* a C lvalue (distinct from CG place-ness; e.g. a const lvalue) */
C_MODIFIABLE /* modifiable lvalue (not const/array/func/void) */
C_BITFIELD /* C-semantic bit-field marker (geometry lives on the CG place) */
C_NULL_PTR_CONST/* integer 0 / null-pointer-constant */
C_REGISTER /* `register` storage class — forbids `&` */
PcgLvAux.is_subobject disappears: with eager places (4.3) the place operand
is the exact sub-object, so the to_rvalue struct-materialize heuristic
(parse_expr.c:1040) collapses to "leave the place as-is."
4.2 Slot queries and retag
KitCgSlotInfo kit_cg_slot_info(KitCg*, uint32_t depth_from_top); /* depth 0 == top */
void kit_cg_retag_top(KitCg*, const void* lang_type, uint16_t lang_flags);
void kit_cg_retag_at(KitCg*, uint32_t depth_from_top,
const void* lang_type, uint16_t lang_flags);
void kit_cg_set_top_flags(KitCg*, uint16_t set, uint16_t clear);
kit_cg_stack_depth already exists (include/kit/cg.h:706). Retag touches only
the inline lang fields — never the node's CG type (the producing op sets that).
C-type changes that carry no CG op (qualifier strip on a struct lvalue; the
deref retype) become a retag. Today's C queries — pcg_top_type, pcg_top2_type,
pcg_top_is_lvalue/_modifiable_lvalue/_null_ptr_const/_bitfield/
_register, and the pcg_retag_* family — are replaced by direct
kit_cg_slot_info reads and kit_cg_retag_* calls at the parser sites (small
static inline accessors over the flag bits are fine; there is no pcg layer).
Producer rule: an op that produces a fresh result slot (load, call result,
binop/cmp result, convert, addr, push_*) clears that slot's inline lang facts
(lang_type=NULL, lang_flags=0). The parser stamps the C facts immediately after
via kit_cg_retag_top. Structural ops (dup/dup2/swap/rot3/drop)
move/copy whole ApiSValue nodes, so lang facts move with the value. This is
exactly today's "emit op, then pcg_retag_top" idiom, with structural movement
done automatically inside CG.
4.3 Eager places; folding behind the CG API (D4)
The parser builds places naively and immediately; CG folds. One new op is needed; the rest already exist.
/* PLACE -> PLACE: project to a sub-object at a known byte offset and field type.
* Folds the offset into the place operand (no record-layout lookup — the
* frontend already knows the offset from its own C layout). Errors if TOS is not
* a PLACE. Bit-field geometry, when present, is attached separately via
* kit_cg_field_bits. */
void kit_cg_field_at(KitCg*, int64_t byte_offset, KitCgTypeId field_type);
Composition (all folding decided inside CG, as kit_cg_field already does):
| C expression | CG op sequence | result |
|---|---|---|
x (local) |
push_local x |
PLACE |
*p |
deref 0 |
PLACE |
s.f |
… field_at(off_f, ty_f) |
PLACE |
p->f |
deref 0; field_at(off_f, ty_f) |
PLACE |
a[i] |
decay a→elem* (addr; bitcast); push i; elem 0 |
PLACE |
a[i].f |
…elem 0; field_at(off_f, ty_f) |
PLACE |
&e |
build place; addr |
VALUE(ptr) |
read of any lvalue e |
build place; load |
VALUE |
deref(0); field_at(off) collapses inside CG to one
OPK_INDIRECT(base=p, ofs=off) memop; elem; field_at keeps the fused
[base + i*scale + off] form (the kit_cg_field INDIRECT branch already does
this). The "stride differs from access type" case (arr[i].f) is handled by the
natural elem (scales by sizeof(elem)) then field_at (adds off_f)
composition — so the C frontend no longer needs kit_cg_elem_scaled for it.
Folding intelligence that currently lives in the parser moves into the CG place ops, where it belongs:
- constant offset →
OPK_INDIRECTdisplacement (no extra add); - dynamic index → fused
[base + i*scale + off]; - out-of-int32 displacement → materialize an explicit
base+ofspointer; - the widening-signed-load hint (
KIT_CG_MEM_SEXT_LOAD, today set inpcg_load,cg_adapter.c:540) is decided insidekit_cg_loadfrom the access type; - bit-field geometry rides the place via
kit_cg_field_bits(src/cg/control.c:1253), set by the frontend right afterfield_at.
pcg_materialize_lv_to_ptr, pcg_lv_to_memop_place, pcg_lv_member,
pcg_lv_subscript, pcg_decay_array, and the whole PcgLvAux struct are
deleted. kit_cg_field (by index) and kit_cg_elem/kit_cg_elem_scaled
stay for toy/wasm.
4.4 Fused load/store (D6)
void kit_cg_load(KitCg*, KitCgMemAccess); /* [place] -> [value] (exists) */
void kit_cg_store(KitCg*, KitCgMemAccess); /* [place, value] -> [] (exists) */
void kit_cg_store_keep(KitCg*, KitCgMemAccess); /* [place, value] -> [value] */
kit_cg_store already is the [place,value] -> [] primitive; the parser names
the discard intent through it (store_void == store, kept as the documented
spelling). kit_cg_store_keep adds the C assignment-expression value
preservation (a = b, if ((x=f()))) as one op, so the parser drops the
dup/rot3/swap choreography it does today (cg_adapter.c:769-773). CG keeps
the delayed-RHS-into-local fast path (kit_cg_store, src/cg/memory.c:506-551)
in both forms.
4.5 Unevaluated mode: fold + types, zero emission (D3, D11)
Unevaluated mode is a CG execution mode, not the full definition of C "unevaluated operand" legality. It serves two implementation needs that share one stack machine:
- Type/diagnostic-only parsing:
sizeofnon-VLA operands,_Genericcontrolling expressions and inactive associations, statically-dead expression arms,extern inlinebodies when the TU only needs diagnostics, and other parse paths that must compute C result types/flags without emitting code. - Constant-value parsing: ICE and static arithmetic-constant contexts where the parser must parse the real expression grammar, suppress target emission, then read the folded value from CG (§4.7).
These uses share the same CG mechanics, but not the same C legality rules. CG answers "what value/type would this expression stack produce if it can be folded"; the parser's constant guard (§4.7) answers "is this syntax permitted in this C constant-expression category."
void kit_cg_unevaluated_push(KitCg*); /* enter fold+type-only mode (nestable) */
void kit_cg_unevaluated_pop(KitCg*);
Contract while unevaluated > 0:
- Maintained: stack depth, inline lang facts, and the constant payload.
Every producing operation leaves a stack slot of the requested CG type when a
type is available; structural ops move/copy
ApiSValuenodes and update the constant payload in lockstep with the value stack. - Folded: integer
push/cast/unop/binop/cmpupdate the constant payload using the CG fold core. A producing op that cannot fold pushes an unknown constant payload, not an emitted value. Unknown is the value-level "not a constant" signal; it is distinct from "illegal C ICE syntax." - Skipped: every
CgTarget/emission call, temp allocation, local allocation, const-local store/load tracking,-O0ref accounting, and aggregate-place validation that exists only to protect emitted operands. Handle-returning APIs that the parser reaches while suppressed return benign dummy handles; those handles exist only to keep one parse path shaped correctly and cannot be used to recover emitted values later. - Early op rule: every stack-mutating op must test unevaluated mode before
it forces operands into locals, allocates temps/locals, materializes delayed
values, calls target hooks, or updates emitted-code-only analyses. In this
path the op performs only its declared stack effect, inline-lang preservation
or clearing, constant-payload movement, and constant-payload folding/unknown
marking.
This matters for ops that are "structural" in API shape but not in the current
implementation:
dupcan emit a copy today,deref/addrcan allocate a pointer temp, and place/field/elem ops can materialize address arithmetic. - File-scope safe: the mode touches no function state, so it works before a function is open. This is required for file-scope array bounds, enum values, bit-field widths, and static initializers.
- Type-only tolerant: pure type/diagnostic paths may use
KIT_CG_TYPE_NONEwhen no CG type is needed. Constant folding still requires real CG integer types so CG knows width and signedness.
&&, ||, and ?: need explicit handling; they are not solved by making
labels and branches no-op. In emitting mode they keep their normal lowering
through labels, branches, and temporaries. In unevaluated mode the parser's
control-shape helpers parse each syntactic operand once, compute the C result
type/flags from KitCgSlotInfo, and push a known or unknown result constant:
&&and||can fold from the known left operand without requiring the short-circuited operand to be value-known, but the skipped operand is still parsed for types and diagnostics under the correct C "not evaluated" guard.?:folds when the condition is known and the selected arm is known; the unselected arm is still parsed for type merging and diagnostics under the correct guard. When the condition is unknown, the result constant is unknown even if both arms happen to have the same value, unless CG later grows a deliberate meet rule.
The important boundary is that the parser may keep semantic branches for C
control-shape constructs. What disappears is parser-owned emission bookkeeping:
Parser.suppress_codegen, pcg_emit_enabled, duplicate shadow-stack updates,
and forks whose only purpose was "emit this CG op or do not emit it." Parser-side
semantic side-effects in suppressed regions (VLA-size bookkeeping, _Generic
association collection, diagnostics) still run on the normal parse path.
4.6 Calls (D8)
C call arguments already flow through the CG stack: the C call path drives
kit_cg_call(nargs, fn_type, attrs), which consumes the callee+args segment.
There is no second frontend arg list to remove. No mark API is added. The
parser stamps the result slot's C type via retag.
4.7 Constant-expression evaluation on CG (D11)
C integer-constant-expression evaluation stops being a parser subsystem. Today
eval_const_int → cexpr_cond (parse_expr.c) is a complete second expression
grammar (cexpr_mul/add/shift/rel/eq/band/bxor/bor/land/…) plus
a second constant-arithmetic engine (cint_*, with 128-bit lo/hi, casts,
conversions) — and it re-implements offsetof / __builtin_constant_p /
enum-constant handling separately from the real parser, a standing drift hazard.
All of it is deleted in the ICE cutover phase.
"A real constant-value model" means CG no longer treats "constant" as a property
of ApiSValue.operand == OPK_IMM. The current kit_cg_top_const_int can only
answer for <=64-bit immediate operands. The new model is a stack-parallel
constant payload owned by KitCg: every CG stack slot has known/unknown state
plus integer bits, width, signedness, and result CG type. It can represent a
folded value even when normal emission would have produced a local, a wide value
lowered through a runtime helper, or no emitted value at all because unevaluated
mode is active.
Proposed public shape:
typedef struct KitCgConstInt {
uint64_t lo; /* low bits, always truncated to width */
uint64_t hi; /* high bits for width > 64 */
uint16_t width; /* 1..128 for integer constants */
uint8_t is_signed;
uint8_t known;
} KitCgConstInt;
int kit_cg_top_const_int_ex(KitCg*, KitCgConstInt* out);
int kit_cg_top_const_i64(KitCg*, int64_t* out); /* convenience */
void kit_cg_push_const_int(KitCg*, KitCgTypeId type, const KitCgConstInt* v);
The exact names can change, but the surface must be width-complete and must not
require frontends to inspect ApiSValue or know which operands are immediates.
Constant payload policy:
- Always-on: every
KitCgmaintains the payload for every value-stack slot, regardless of frontend, optimization level, or unevaluated depth. This matches CG's existing design pressure: the semantic layer already folds immediate arithmetic, delayed arithmetic/compare, and local-constant loads for-O0code quality, and C already queries constant-ness during normal expression parsing. - Stack fact, not operand fact:
kit_cg_top_const_int_exreads the payload, notApiSValue.op.kind.OPK_IMMbecomes only one way to seed a known payload. Values materialized as locals, delayed arith/cmp nodes, or wide constants may still carry known payload bits. - Unknown is explicit: fresh runtime values (
loadfrom unknown memory, call results, atomics, volatile access, inline asm, address values, and unsupported arithmetic such as i128 div/rem) produce an unknown payload. - Local-constant forwarding is separate:
ApiSourceLocal.const_*tracking may seed the payload for an actual emitted load from a tracked local, but it is still an emitting-mode optimization. The parser'sCConstGuarddecides whether a local identifier is a legal C constant-expression operand; ordinary locals do not become ICE operands just because CG can forward their current value.
Fold coverage required before the ICE cutover:
- <=64-bit integers: add/sub/mul, div/rem, bitwise ops, shifts, comparisons, integer casts, and booleanization. Div/rem by zero must fail cleanly with a diagnostic path the parser can report; it must not silently produce an arbitrary constant.
- 128-bit integers: move the existing parser
CConstInttwo-limb behavior for add/sub/mul, bitwise ops, shifts, comparisons, and casts into CG so existing static i128 initializer coverage remains supported. Compile-time i128 div/rem is not required in this plan: emitted i128 div/rem lowers through runtime helpers, and helper calls are dynamic code. CG marks i128 div/rem payloads unknown until a true compile-time word-int division implementation is added.
The replacement parser helper is one pass over the normal grammar plus a guard:
CConstEval eval_const_int(Parser* p, SrcLoc loc, CConstKind kind) {
CConstGuardMark mark = c_const_guard_push(p, kind);
kit_cg_unevaluated_push(p->cg);
parse_conditional(p); /* the real grammar */
int ok_const = kit_cg_top_const_int_ex(p->cg, &value);
KitCgSlotInfo info = kit_cg_slot_info(p->cg, 0);
kit_cg_drop(p->cg);
kit_cg_unevaluated_pop(p->cg);
int ok_guard = c_const_guard_pop(p, mark);
if (!ok_guard || !ok_const || !c_type_is_integer(info.lang_type))
perr(loc, "integer constant expression required");
return (CConstEval){ value, info.lang_type };
}
The syntactic/semantic guard is mandatory. A foldable result is not the same thing as a legal C integer constant expression:
(0, 1)folds to1, but the comma operator is not permitted in an ICE when it is evaluated.(x = 1, 1)andf(), 1can have foldable tails, but assignment and function call syntax still make the evaluated expression illegal.sizeof(x++)can be valid when the operand is non-VLA because the increment is not evaluated; the guard must understand these not-evaluated exceptions rather than reject tokens blindly.enumconstants are ICE operands; ordinary objects are not, even if some optimization or local-const tracking could know their value.- Floating constants are only allowed in the narrow C cases such as the immediate operand of a cast to integer type; the fold payload alone cannot encode that syntactic permission.
Implement the guard as parser mode/counter state, not a second expression
grammar. Normal parse routines call small note functions when active, for
example "comma operator in integer constant expression" or "assignment in static
initializer constant." The guard also has explicit "not evaluated" submodes for
sizeof non-VLA operands and short-circuited &&/||/?: arms. At the end,
both checks must pass: the guard says the syntax is legal for the requested C
constant category, and CG says the stack top has a known value of the required
type.
Minimum guard event list for the ICE cutover:
- evaluated comma operator;
- evaluated assignment and compound assignment;
- evaluated pre/post increment and decrement;
- evaluated function call, including builtin calls that are not specifically permitted by the active constant category;
- identifier/reference classification: enum constants are legal ICE operands, ordinary objects/functions are not, regardless of any CG local-const fact;
- floating constant use, including the narrow case where a floating constant is immediately cast to an integer type and the broader cases where it is illegal for ICE;
- pointer/address expressions, string-literal addresses, label addresses, and relocation-like constants, which belong to the static-address category rather than C ICE;
sizeof/_Alignof: type-name forms are immediate constants; expression forms enter a not-evaluated submode unless the operand is a VLA, where the VLA size expression is evaluated and must be guarded normally;_Generic: the controlling expression is not evaluated; selected and unselected associations follow the standard's type/diagnostic rules, and only the selected expression contributes the resulting value category;offsetof: accepted through the parser's existing builtin path, but member designator syntax must stay constrained to the builtin's rules;- short-circuit
&&/||and?:: skipped operands are parsed under the correct not-evaluated submode; selected/evaluated operands are guarded normally; - unsupported arithmetic that CG marks unknown, including i128 div/rem in this plan, reports "constant expression required" only after the guard has also accepted the syntax.
Keep the categories separate:
- C ICE: integer result plus the C ICE guard; used by case labels, enum
values, bit-field widths, array bounds that require ICE,
alignas, and_Static_assert. - Static arithmetic constants: can reuse the CG payload and normal parser path, but may have different C legality from ICE.
- Static address/relocation constants:
&x + 4, string-literal addresses, and designated-init relocations remain inparse_init.cfor this plan. A later project can move them onto a CG constant-data model, but this plan does not.
5. What moves where
Today in pcg / Parser |
Target owner |
|---|---|
PcgSlot.type (const Type*) |
inline ApiSValue.lang_type (opaque) |
PcgSlot.cg_id |
gone — read the node type via kit_cg_slot_info |
PcgSlot.flags (C value flags) |
inline ApiSValue.lang_flags |
Parser.cg_slot_stack / cg_type_sp / cg_type_cap |
gone — the CG stack is the typed stack |
PcgLvAux (offset/scale/base_kind/bit_*) + pcg_materialize_lv_to_ptr / pcg_lv_to_memop_place / pcg_lv_member / pcg_lv_subscript / pcg_decay_array |
gone — CG place ops (deref/field_at/elem/addr/field_bits) fold automatically |
Parser.suppress_codegen + pcg_emit_enabled forks |
CG unevaluated mode; parser keeps only semantic forks required by C control-shape constructs |
pcg_dup/swap/drop (mirror onto shadow stack) |
direct kit_cg_dup/swap/drop (inline lang facts move with ApiSValue; CG updates the constant payload) |
assignment dup/rot3/swap sequences |
kit_cg_store / kit_cg_store_keep |
cg_adapter.c / cg_adapter.h / the pcg_* layer |
deleted — parser drives kit_cg_* directly |
cexpr_* grammar + cint_* constant-arith engine (parse_expr.c) |
deleted in the ICE cutover — parser owns the legality guard; CG owns constant-value folding (§4.7) |
| C conversions, usual-arithmetic-conversions, binop/cmp/atomic op maps, lvalue-legality, null-ptr/register/bitfield C rules | plain helper functions the parser calls (no stack mechanics, no pcg_ prefix) |
After the cutover there is no adapter layer. The parser calls kit_cg_*
directly and stamps C facts with kit_cg_retag_top/set_top_flags. The
genuinely C-semantic operations survive as ordinary functions in the parse module
— the op-enum maps, the conversion lattice (today's pcg_convert), inc/dec,
call/atomic/intrinsic/inline-asm shaping — each driving kit_cg_* + retag
directly, with no parallel stack and no EA state.
6. Op-by-op: inline lang + constant lockstep
Every stack-mutating CG op maintains inline lang facts and the constant payload so depths stay in sync. Grouped by effect:
- Producers (push a fresh slot, clear inline lang facts, set known/unknown
const):
push_int,push_float,push_null,push_local,push_local_addr,push_symbol_addr,push_label_addr,load,addr,deref,field/field_at/elem/elem_scaled(consume then push a place — clear),alloca,vararg_next, call result,atomic_load/atomic_rmw/atomic_cmpxchgresult,intrinsicresult, overflow-builtin result,inline_asmoutputs. - Retypers (1→1, keep depth):
trunc/sext/zext/bitcast/fpext/fptrunc/int<->float/ptr<->int,int_unop/fp_unop. Result inline lang facts are cleared; parser retags. The constant payload is converted/folded when CG can do so, otherwise marked unknown. - Combiners (N→1):
int_binop/fp_binop/int_cmp/fp_cmp(2→1),field/elem(consume base/index),store_keep(2→1),call/call_symbol(N→0/1),va_copy(2→0),atomic_store(2→0),atomic_cmpxchg(3→…). Integer combiners fold the constant payload when operands are known and the op is supported; otherwise the result payload is unknown. - Pure structural:
dup(copy topApiSValue+ constant payload),dup2,swap,rot3,drop,store/store_void(2→0). These move inline lang facts and constant payloads with no semantic change. - Consumers (→0):
branch_true/branch_false/switch/computed_goto,ret. - Scope edges:
api_scope_store_results/api_scope_push_resultsmove carriedApiSValueresults and their constant payloads when active. (C uses only void scopes, so this is inert for C, but must be correct for any future result-carrying frontend.)
Implementation choke points: api_push/api_pop (src/cg/value.c:161/187) and
the structural ops kit_cg_dup/dup2/swap/drop/rot3
(src/cg/memory.c:598-735). ApiSValue copies carry inline lang facts
automatically, while producers must clear them explicitly. Routing
constant-payload movement through api_push/api_pop (which every
producer/consumer already calls) covers most ops automatically; the structural
ops and scope-edge movers need explicit handling.
7. Cutover plan (clean break, gate = correctness + determinism)
No dual-stack interim, no byte-identity gate. Each phase is either CG infrastructure with no C frontend cutover, or a one-time frontend cutover that removes the old path for that subsystem. Do not land a phase where the parser's shadow stack and inline CG lang facts both authoritatively model live expressions.
- CG inline lang fields + slot queries. Add the packed 64-byte
ApiSValuelayout,KitCgSlotInfo,kit_cg_slot_info,kit_cg_retag_top/_at, andkit_cg_set_top_flags; wire producer clearing, structural copies, and scope-edge movers. Add focused CG API tests for producer clearing, structural/scope movement, and query/retag behavior. No C frontend change yet. - CG place + store ops. Add
kit_cg_field_atandkit_cg_store_keep; move the SEXT-load hint decision intokit_cg_load; confirmderef/elem/field_at/field_bitscompose to fused operands without parser help. Add targeted place tests for local/global load+store,s.f,p->f,a[i],a[i].f, bit-fields, displacement overflow, volatile/atomic accesses,&of each lvalue form, assignment value preservation,++/--, and compound assignment. - CG unevaluated + constant payload substrate. Add
kit_cg_unevaluated_push/pop, the stack-side constant payload, the width-complete constant query/push API, <=64 div/rem folding, and the required 128-bit bit-pattern folds excluding div/rem. No C frontend cutover yet. Add CG API tests for file-scope/no-function use, unknown constants including i128 div/rem, dummy handles, no target/local calls, nested unevaluated mode, and inline-lang/constant lockstep under suppression. - C frontend one-stack cutover. Use inline lang fields for C; replace
parser stack reads/writes with
kit_cg_slot_info+ retag; replace EA folding with eager places; replace assignment choreography withstore/store_keep; moveParser.suppress_codegenuses to CG unevaluated mode for type-only paths; deletecg_slot_stack,cg_type_sp,cg_type_cap,PcgSlot,PcgLvAux, and parser-owned effective-address folding. The existingcexpr_*/cint_*subsystem may remain only because ICE is a later subsystem; it must not keep or resurrect a live expression shadow stack. - Adapter deletion and helper relocation. Remove
cg_adapter.c/.hand thepcg_prefix. Move the C-semantic helpers (op maps, conversion lattice, inc/dec, atomic/intrinsic/inline-asm shaping) into ordinary parse-module functions that callkit_cg_*directly. Updatedoc/FRONTENDS.mdto describe the durable parser → CG contract. - ICE cutover. Add the parser
CConstGuard; reimplementeval_const_intover the normal parse path under unevaluated mode (§4.7); delete thecexpr_*grammar andcint_*engine. Re-test every ICE context: case labels, array sizes (block + file scope), enum values, bit-field widths,alignas,_Static_assert, designated-init indices, invalid ICE syntax that still folds,sizeofnot-evaluated exceptions, short-circuit exceptions, i128 constant expressions, i128 div/rem rejection/unknown behavior, and divide-by-zero diagnostics for supported div/rem widths.
Use targeted gates and redirect output to files per AGENTS.md:
- Inline-lang/constant substrate:
make test-cg-api test-toy. - C frontend cutover:
make test-cg-api test-parse test-pp test-toy. - Codegen-sensitive place/store changes:
make test-cg-api test-isa test-aa64-inlineplus targeted native smoke cases for x64/aa64/rv64 as appropriate. - Cross/portability: run
make test-cross TARGET=<selector> DEPTH=smokeafter the one-stack C cutover is green on native targets, and a broader portability pass before final handoff.make test-portis not a per-phase gate. - Determinism: same input → identical output across repeated runs; byte identity against the old implementation is not required.
8. Requirements checklist (the agent verifies against this)
- R1 Single liveness-authoritative value stack;
api_push/api_popunchanged as lifetime hooks. (§3.1) - R2 No free expression handles; only handles + stack depth escape. (§3.2)
- R3 Read/retag lang type+flags at top/top2/depth-N. (§4.2)
- R4 CG never dereferences the lang payload; it is
const void*. (§3.4) - R5 No backend (
CgTarget/NDT/NT/MC) change; place ops lower through existing contracts. (§3.5) - R6
ApiSValueis 64 bytes with inlineconst void* lang_type,uint16_t lang_flags, and a packeduint16_t flagsword. (§3.6, D10) - R7 No global state; inline lang facts live in
ApiSValue, while the constant payload and unevaluated counter hang offKitCg. (§3.7) - R8 No language side allocation or enable call; constant payload is always-on
for every
KitCg. (§3.8, §3.10) - R9 Suppression: type+flags+constant facts maintained, zero emission, zero temp/local alloc, no forced CG-type lowering for type-only paths; parser loses emission-bookkeeping forks, not semantic forks required by C control-shape constructs. Every op checks unevaluated mode before local/temp allocation, delayed materialization, target calls, or emitted-code-only analysis updates. (§4.5)
- R10 Eager places; all EA folding (const offset, dynamic index, displacement
overflow, SEXT-load hint) decided inside CG;
PcgLvAuxdeleted. (§4.3, D4) - R11 Bit-field geometry rides the CG place (
field_bits); C keeps a semanticC_BITFIELDflag forsizeof/&rejection. (§4.1, §4.3) - R12 C value flags (MODIFIABLE / NULL_PTR_CONST / REGISTER / LVALUE) fit in
uint16_t lang_flags. (§4.1) - R13
store/store_keepreplace assignment dup/rot3/swap. (§4.4) - R14 No call-mark API; C args stay on the CG stack. (§4.6, D8)
- R15 ICE evaluation moves to CG's constant-value model plus a parser-owned syntactic/semantic C constant guard. (§4.7, D11)
- R16 Gate = correctness + determinism, not byte-identity. (D5)
- R17
cg_adapter.c/.hand thepcg_prefix deleted; parser driveskit_cg_*directly; C-semantic helpers survive as plain functions. (§5, D7) - R18 inc/dec, compound assignment, va_arg, atomics, intrinsics, inline-asm, calls, returns all re-expressed on the new place ops + slot queries. (§6)
- R19
&&/||/?:have explicit unevaluated fold paths; they do not rely on no-op labels/branches to leave the right stack result. (§4.5) - R20
eval_const_intis reimplemented over the normal parse path withCConstGuard; thecexpr_*grammar andcint_*engine are deleted; all ICE contexts and invalid foldable-but-not-ICE cases are re-tested. (§4.7, §7.6) - R21 CG exposes an always-on, width-complete integer constant payload API;
frontends do not inspect
ApiSValueorOPK_IMMto determine constant-ness, and local-const forwarding is not C ICE legality. (§4.7, D12) - R22 <=64-bit div/rem folding moves into CG with clean divide-by-zero failure; 128-bit add/sub/mul/bitwise/shift/compare/cast folding moves into CG, while i128 div/rem returns unknown until a true compile-time word-int division implementation exists. (§4.7, D13)
- R23 Cutovers are one-time subsystem moves with old paths removed; no dual-stack or backcompat phase is introduced. (§7)
9. Expected payoff
CG API self time is ~19% of compile, half of it the stack/memory family, so the
directly visible ceiling of this cleanup is ~9-10% of compile. The real win is
larger only insofar as deleting the parser's duplicate stack + EA bookkeeping
also removes frontend work (one push/pop instead of two; no PcgLvAux
maintenance; no if (emit) branching). A realistic target is 5-10% total
compile improvement, plus the qualitative payoff: one expression stack, no
fragile choreography, "parse expression, drive CG," and a clean home for future
frontend instrumentation — with single-pass liveness and temp reclaim preserved.
10. Non-goals
- No AST pass; no handle-based expression IR.
- No collapse of parser / CG / NDT / NT / MC into one module.
- No C frontend type dependency from
src/cg. - No attempt to close the whole ~2.8× tcc compile-speed gap with this refactor.