avoid extremely long array data source lines, and redundant label comments

This commit is contained in:
Irmen de Jong committed 2026-09-06 16:18:31 +02:00
1 parent bbfb09e467
commit e074fe614f
6 files changed
+248 -121

No files matched your search

@@ -3,28 +3,44 @@ package prog8.codegen.intermediate
import prog8.intermediate.*
/*
* Graph-coloring register allocator (present form: a MEMORY-SLOT packer, not a
* hardware-register allocator).
* ============================================================================
* PROTOTYPE - NOT FOR PRODUCTION USE
* ============================================================================
*
* What it does today:
* - Per subroutine, it builds a CFG, computes intraprocedural liveness,
* derives live intervals, builds a conflict graph, and greedily colors
* virtual registers into SHARED MEMORY SLOTS in the flat `p8_regfile` BSS
* block (coalescing vregs whose live ranges do not overlap).
* - Slots are drawn from a single GLOBAL pool (startSlot = maxReg + 1) shared
* by all subroutines, because the regfile is one shared memory block.
* - It does NOT model CALL clobbering (no caller/callee-saved interference
* edges), so packing a caller and its callee into the same slot lets the
* callee overwrite the caller's value. Combined with the flat shared
* regfile this is unsound, which is why this packer is DISABLED in
* production (its only call site in IRCodeGen.generate() is commented out);
* it currently runs only under TestRegisterPacker.
* This is a PROTOTYPE memory-slot packer and the intended reusable foundation
* for a future hardware-register allocator targeting the m68k backend. It is
* NOT a complete or production-ready register allocator in its current form.
* It is DISABLED in production: its only call site in IRCodeGen.generate() is
* commented out, and it currently runs only under TestRegisterPacker.
*
* Reuse: the liveness / interval / conflict-graph / coloring / rewrite machinery
* here is the reusable foundation for the true m68k hardware-register allocator
* described in m68k-register-allocation.md. That design repoints the color step
* at real D/A/FP registers, adds CALL-aware interference (kill caller-saved,
* preserve callee-saved), and adds spilling + prologue/epilogue emission.
* What this prototype demonstrates:
* - A working graph-coloring register allocator skeleton: CFG construction,
* intraprocedural liveness analysis (gen/kill fixed-point), live interval
* derivation, conflict-graph construction, greedy coloring, and IR rewrite.
* - Coalescing of virtual registers into shared memory slots in the flat
* `p8_regfile` BSS block (vregs with non-overlapping live ranges share a
* slot).
*
* What this prototype DOES NOT do (and why it is not production-ready):
* - No hardware-register allocation: slots are memory addresses in a flat
* BSS regfile, not physical D/A/FP registers.
* - No CALL clobbering: a callee packed into the same slot as a caller's
* live value silently overwrites it (see bug 3 below).
* - No register classes: no distinction between DATA, ADDRESS, and FPU
* registers (required by the m68k backend).
* - No spilling, prologue/epilogue emission, or CALL-aware interference.
* - Known-unsound liveness for complex control flow (nested loops,
* conditionals, early returns) - see bug 2 below.
*
* Intended reuse path (per ideas/m68k-register-allocation.md):
* - Stage 1: Make the m68k backend's instruction selection location-agnostic
* (operand() indirection returning "d3" if allocated, "p8_regfile+N" if
* spilled) - prerequisite bulk work, no behavior change.
* - Stage 2: Replace this packer's interference model with a correct one
* (CALL-aware, class-constrained) and point the coloring step at real
* D2-D6 / A2-A4 / FP2-FP7 registers. Six open design decisions remain
* in Stage 2 (see design doc section 7.1).
* - Stage 3: Delete subsumed peephole optimizations, update test assertions.
*/
/*
+28 -11
View File
@@ -444,8 +444,7 @@ internal class AsmGen(val program: IRProgram, internal val target: ICompilationT
is IRLoopChunk -> translateLoopChunk(element)
is IRInlineAsmChunk -> emitRaw(element.assembly)
is IRInlineBinaryChunk -> {
val bytes = element.data.joinToString(",") { "$${it.toString(16).padStart(2, '0')}" }
emitLine("dc.b $bytes")
emitInlineBinaryData(element.data)
}
}
}
@@ -496,8 +495,7 @@ internal class AsmGen(val program: IRProgram, internal val target: ICompilationT
is IRInlineBinaryChunk -> {
val cl = chunk.label?.let { fixNameSymbols(it) }
if (cl != null) emitLabel(cl)
val bytes = chunk.data.joinToString(",") { "$${it.toString(16).padStart(2, '0')}" }
emitLine("dc.b $bytes")
emitInlineBinaryData(chunk.data)
}
}
}
@@ -570,8 +568,7 @@ internal class AsmGen(val program: IRProgram, internal val target: ICompilationT
is IRInlineBinaryChunk -> {
val cl = chunk.label?.let { fixNameSymbols(it) }
if(cl!=null) emitLabel(cl)
val bytes = chunk.data.joinToString(",") { "$${it.toString(16).padStart(2, '0')}" }
emitLine("dc.b $bytes")
emitInlineBinaryData(chunk.data)
}
}
}
@@ -933,6 +930,26 @@ internal class AsmGen(val program: IRProgram, internal val target: ICompilationT
private fun chipramBlockLabels(): Set<String> =
program.blocks.filter { it.options.amigaChipram }.map { it.label }.toSet()
private fun emitChunkedDc(directive: String, values: List<String>, comment: String = "", maxPerLine: Int = 32) {
if(values.isEmpty())
return
var first = true
for(chunk in values.chunked(maxPerLine)) {
val text = "$directive ${chunk.joinToString(",")}"
if(first && comment.isNotEmpty()) {
emitLine(text, comment)
first = false
} else {
emitLine(text)
}
}
}
private fun emitInlineBinaryData(data: Collection<UByte>) {
val hexBytes = data.map { "$${it.toString(16).padStart(2, '0')}" }
emitChunkedDc("dc.b", hexBytes)
}
private fun emitInitializedVariable(v: IRStStaticVariable) {
val dt = v.dt
val label = fixNameSymbols(v.name)
@@ -940,10 +957,10 @@ internal class AsmGen(val program: IRProgram, internal val target: ICompilationT
when {
dt.isString && init is IRVariableInitializer.Str -> {
val bytes = program.encoding.encodeString(init.text, init.encoding)
val bytesStr = if(bytes.isNotEmpty()) bytes.joinToString(",") { it.toString(10) } + "," else ""
val values = bytes.map { it.toString(10) } + "0"
emitLine("ALIGN 2")
emitRaw("$label:")
emitLine("dc.b ${bytesStr}0", v.name)
emitChunkedDc("dc.b", values, v.name)
}
dt.isArray && init is IRVariableInitializer.Array -> {
if(dt.sub==BaseDataType.STRUCT_INSTANCE) {
@@ -991,17 +1008,17 @@ internal class AsmGen(val program: IRProgram, internal val target: ICompilationT
1 -> {
emitLine("ALIGN 2")
emitRaw("$label:")
emitLine("dc.b ${values.joinToString(",")}", v.name)
emitChunkedDc("dc.b", values, v.name)
}
2 -> {
emitLine("ALIGN 2")
emitRaw("$label:")
emitLine("dc.w ${values.joinToString(",")}", v.name)
emitChunkedDc("dc.w", values, v.name)
}
4 -> {
emitLine("ALIGN $elemSize")
emitRaw("$label:")
emitLine("dc.l ${values.joinToString(",")}", v.name)
emitChunkedDc("dc.l", values, v.name)
}
else -> error("expected array element size 1,2 or 4 for ${v.name}")
}
@@ -6,8 +6,6 @@ import prog8.intermediate.IRDataType
import prog8.intermediate.IRInstruction
import prog8.intermediate.Opcode
private fun staticSymbolComment(label: String?): String = label ?: ""
/**
* Load an array index into d0 for use with `(a0,d0.w)` addressing.
* Word indices can be loaded directly; byte indices must be zero-extended
@@ -72,7 +70,7 @@ internal fun AsmGen.translateLoadStore(insn: IRInstruction, suppressRegfileStore
Opcode.LOADM -> {
val dst = r1 ?: error("LOADM needs reg1")
emitLine("move${dtSuffix(type)} $target, ${regAddr(dst)}", staticSymbolComment(label))
emitLine("move${dtSuffix(type)} $target, ${regAddr(dst)}")
}
Opcode.LOADR -> {
@@ -135,15 +133,15 @@ internal fun AsmGen.translateLoadStore(insn: IRInstruction, suppressRegfileStore
Opcode.STOREM -> {
val src = r1 ?: error("STOREM needs reg1")
emitLine("move${dtSuffix(type)} ${regAddr(src)}, $target", staticSymbolComment(label))
emitLine("move${dtSuffix(type)} ${regAddr(src)}, $target")
}
Opcode.STOREIM -> {
val value = imm ?: error("STOREIM needs immediate value")
if(value == 0)
emitLine("clr$s $target", staticSymbolComment(label))
emitLine("clr$s $target")
else
emitLine("move$s #$value, $target", staticSymbolComment(label))
emitLine("move$s #$value, $target")
}
Opcode.STOREX -> {
@@ -175,7 +173,7 @@ internal fun AsmGen.translateLoadStore(insn: IRInstruction, suppressRegfileStore
}
Opcode.STOREZM -> {
emitLine("clr${dtSuffix(type)} $target", staticSymbolComment(label))
emitLine("clr${dtSuffix(type)} $target")
}
Opcode.STOREZI -> {
@@ -65,19 +65,6 @@ class TestInstructionSelectionOptimizations : FunSpec({
lines.count { it == "adda.l #65535,a0" } shouldBe 1
}
test("annotates direct static field accesses") {
val lines = generateAsm(
tempRoot.resolve("test-m68k-field-comments"),
listOf(
IRInstruction(Opcode.LOADM, IRDataType.WORD, reg1 = 1, labelSymbol = "p8b_ship.p8v_cash"),
IRInstruction(Opcode.STOREM, IRDataType.WORD, reg1 = 1, labelSymbol = "p8b_ship.p8v_cash")
)
)
lines.any { it.endsWith("; p8b_ship.p8v_cash") } shouldBe true
lines.count { it.endsWith("; p8b_ship.p8v_cash") } shouldBe 2
}
test("uses moveq for zero extension and only representable immediate returns") {
val lines = generateAsm(
tempRoot.resolve("test-m68k-control"),
@@ -164,6 +164,14 @@ internal class AsmGen(val program: IRProgram, private val target: ICompilationTa
output.appendLine(code)
}
private fun emitInlineBinaryData(data: Collection<UByte>) {
val hexBytes = data.map { asmHexByte(it.toInt()) }
for(chunk in hexBytes.chunked(32)) {
if(chunk.isNotEmpty())
emitRaw(" .byte ${chunk.joinToString(", ")}")
}
}
// === register helpers ===
// Virtual register file layout: variable-size registers at p8_regfile + offset[reg]
@@ -610,7 +618,7 @@ internal class AsmGen(val program: IRProgram, private val target: ICompilationTa
}
is IRLoopChunk -> translateLoopChunk(element)
is IRInlineAsmChunk -> emitRaw(element.assembly)
is IRInlineBinaryChunk -> emitRaw(" .byte ${element.data.joinToString(",") { asmHexByte(it.toInt()) }}")
is IRInlineBinaryChunk -> emitInlineBinaryData(element.data)
}
}
val endDirective = if (block.options.forceOutput) ".bend" else ".pend"
@@ -663,7 +671,7 @@ internal class AsmGen(val program: IRProgram, private val target: ICompilationTa
is IRInlineBinaryChunk -> {
val cl = chunk.label
if (cl != null) emitLabel(cl)
emitRaw(" .byte ${chunk.data.joinToString(",") { asmHexByte(it.toInt()) }}")
emitInlineBinaryData(chunk.data)
}
}
}
@@ -763,7 +771,7 @@ internal class AsmGen(val program: IRProgram, private val target: ICompilationTa
is IRInlineBinaryChunk -> {
val cl = chunk.label
if (cl != null) emitRaw(" $cl:")
emitRaw(" .byte ${chunk.data.joinToString(",") { asmHexByte(it.toInt()) }}")
emitInlineBinaryData(chunk.data)
}
}
}
@@ -833,7 +841,7 @@ internal class AsmGen(val program: IRProgram, private val target: ICompilationTa
is IRInlineBinaryChunk -> {
val cl = chunk.label
if(cl!=null) emitLabel(cl)
emitRaw(" .byte ${chunk.data.joinToString(",") { asmHexByte(it.toInt()) }}")
emitInlineBinaryData(chunk.data)
}
}
}
@@ -1083,24 +1091,37 @@ internal class AsmGen(val program: IRProgram, private val target: ICompilationTa
when {
dt.isSplitWordArray(target) -> {
// _lsb / _msb halves via helper array
// Emit _lsb/_msb halves directly as chunked .byte lines.
// (Avoids a single giant := helper-array line for large arrays.)
val numElements = v.length?.toInt() ?: 0
val halfBytes = numElements // each element is 1 byte per half
val values: List<String> = when (init) {
is IRVariableInitializer.Array -> init.elements.map {
when (it) {
is IRStSymbolicReference.Numeric -> it.value.toInt().toString()
is IRStSymbolicReference.BoolValue -> if (it.value) "1" else "0"
is IRStSymbolicReference.Symbol -> fixNameSymbols(it.name)
}
val elements = when (init) {
is IRVariableInitializer.Array -> init.elements
else -> List(numElements) { IRStSymbolicReference.Numeric(0.0) }
}
val lsbValues = elements.map {
when (it) {
is IRStSymbolicReference.Numeric -> (it.value.toInt() and 0xFF).toString()
is IRStSymbolicReference.BoolValue -> if (it.value) "1" else "0"
is IRStSymbolicReference.Symbol -> "<${fixNameSymbols(it.name)}"
}
else -> List(halfBytes) { "0" }
}
val msbValues = elements.map {
when (it) {
is IRStSymbolicReference.Numeric -> ((it.value.toInt() shr 8) and 0xFF).toString()
is IRStSymbolicReference.BoolValue -> "0"
is IRStSymbolicReference.Symbol -> ">${fixNameSymbols(it.name)}"
}
}
emitLabel("${label}_lsb")
for (chunk in lsbValues.chunked(32)) {
if(chunk.isNotEmpty())
emitLine(".byte ${chunk.joinToString(", ")}")
}
emitLabel("${label}_msb")
for (chunk in msbValues.chunked(32)) {
if(chunk.isNotEmpty())
emitLine(".byte ${chunk.joinToString(", ")}")
}
val parts = values.joinToString(",")
val arrLabel = label.replace(".", "_") + "_init_words"
emitRaw("$arrLabel := $parts")
emitLine("${label}_lsb .byte <$arrLabel")
emitLine("${label}_msb .byte >$arrLabel")
}
dt.isString -> {
+139 -51
View File
@@ -149,27 +149,58 @@ asmsub via slot annotations.
The asmsub argument/return slots (D0–D2, FP0–FP1) are deliberately in the
**caller-saved** set, matching their volatile nature.
**D0, D1, A0, A1 are pure scratch — never allocated to a vreg.** These four
registers combine two roles, both of which mean "never holds a long-lived
value across an instruction": (a) transient scratch *within* a single IR
opcode's expansion (accumulator, index, pointer, second operand), and (b) the
CALL/return boundary (§2.7). A vreg is therefore only ever allocated to D2–D6,
A2–A4, or FP0–FP7 (minus the reserved registers of §2.5), or spilled. This is a
deliberate simplification, not a limitation discovered later:
**This split is the industry-standard M68k convention, not a Prog8
invention.** The System V ABI and GCC/LLVM use exactly this division:
D0/D1/A0/A1 (and FP0/FP1) caller-saved scratch, D2–D7/A2–A6 (and FP2–FP7)
callee-saved. The only deliberate deviations are the reserved registers of
§2.5 (D7 loop counter, A5 frame pointer, A6 AmigaOS library base in place of
SVR4's A6-as-frame-pointer), which are target-specific necessities. Keep the
split as-is; do not make the caller-saved registers callee-saved and do not
abolish their scratch role — both would diverge from SVR4/GCC and add
complexity for no conformance benefit.
**D0, D1, A0, A1 are scratch for the translators, but the allocator MAY use
them for call-free vregs.** These four registers combine two roles: (a)
transient scratch *within* a single IR opcode's expansion (accumulator, index,
pointer, second operand), and (b) the CALL/return boundary (§2.7). Both roles
mean "never holds a value live across a `CALL`". That is the only hard
constraint. Within it, the allocator may *also* allocate a vreg to a
caller-saved register when that vreg's **entire live range contains no
`CALL`** — such a vreg never needs the callee to preserve the register, so it
enjoys the caller-saved "free to use, nobody saves it" property. This is
standard practice (GCC/LLVM allocate caller-saved registers to short-lived,
call-free values) and it widens the effective pool for the common case at no
convention cost. A vreg whose live range *does* cross a `CALL` is placed in a
callee-saved register (D2–D6, A2–A4, FP2–FP7) or spilled, per §2.4.
Keeping these registers scratch for the *translators* (independent of whether
the allocator uses them for call-free vregs) is a deliberate simplification,
not a limitation discovered later:
- It formalizes existing reality — the translators already grab d0/d1/a0
blindly (`loadRegOrZeroExtendToD0`, `loadPointerToA0`, `loadIndexToD0`), and
returns already go through d0.
returns already go through d0. A scratch register used within one opcode
never crosses a `CALL`, so it cannot conflict with an allocated vreg that
also never crosses a `CALL`.
- It removes the need for any allocator-aware scratch reservation protocol: a
translator may always *use* d0/d1/a0/a1 freely, because they never hold an
allocated value. Multi-scratch expansions (e.g. STOREX needing index+base+
value simultaneously) are automatically safe.
translator may always *use* d0/d1/a0/a1 freely for a within-opcode temporary,
because such a temporary never overlaps a call-crossing live range.
Multi-scratch expansions (e.g. STOREX needing index+base+value
simultaneously) are automatically safe.
- It gives class-mismatched indexed/indirect operands a uniform fallback: move
the value to a0/a1 (pointer) or d0/d1 (index/accumulator) first.
The cost is a smaller allocatable pool — 5 data (D2–D6), 3 address (A2–A4),
8 FP — which is exactly the register-pressure question the Stage-2 success
metrics must measure on real programs.
The one correctness rule the allocator must enforce: **a vreg allocated to a
caller-saved register must be spilled or moved to a callee-saved register
before any `CALL` it is live across.** The allocator already models "CALL
kills caller-saved" (§2.4), so this is a placement constraint, not new
machinery.
The allocatable pool is therefore: 5 callee-saved data (D2–D6), 3 callee-saved
address (A2–A4), 6 callee-saved FP (FP2–FP7) — plus the caller-saved D0/D1/
A0/A1/FP0/FP1 usable only for call-free live ranges. Whether this is enough is
exactly the register-pressure question the Stage-2 success metrics must
measure on real programs.
### 2.4 What a CALL means for liveness
@@ -310,20 +341,33 @@ The liveness results drive three decisions:
registers.
The disabled `RegisterPacker` (`codeGenIntermediate/RegisterPacker.kt`)
contains reusable analysis machinery (CFG building, gen/kill dataflow, interval
construction, greedy coloring), but **do not reuse its interference model**: it
derives conflicts from instruction-level register aliasing and judges interval
overlap against a flat concatenation of chunks in layout order — which is not
an execution order once loops exist (a value live in a loop header overlaps
values whose intervals lie "later" in layout order but execute on an earlier
iteration). That is the actual root cause of the TextElite failure (two
genuinely-overlapping registers packed into one slot; symptom was an infinite
loop printing spaces), not the dataflow, which converges correctly for the CFG
as built. Second suspect to debug if validation fails: `buildCFG` edge
construction (calls, early returns, fall-through, jump tables).
contains analysis machinery that is worth reusing as a starting point. Each
component needs scrutiny before reuse:
Validate liveness against nested loops, conditionals, early returns, and
jump-table dispatch before trusting the allocator.
**Likely reusable:**
- **gen/kill dataflow** — standard iterative fixed-point; converges correctly
given a correct CFG.
- **Interval construction** — backward-scan per chunk.
- **Greedy coloring** — standard algorithm.
- **IR rewrite** — mechanical regnum replacement.
**Needs validation before reuse:**
- **CFG edge construction** (`buildCFG`) — must be validated against calls,
early returns, fall-through, and jump-table dispatch. The dataflow
converges correctly *given a correct CFG*, but whether the CFG itself
captures all control-flow shapes correctly is unproven.
**Do not reuse:**
- **Interference model** — derives conflicts from instruction-level register
aliasing and judges interval overlap against a flat concatenation of chunks
in layout order. Layout order is not execution order once loops exist (a
value live in a loop header overlaps values whose intervals lie "later" in
layout order but execute on an earlier iteration). Replace with a
liveness-derived interference graph: an edge between any two vregs that are
simultaneously live at any program point.
Validate the new interference model against nested loops, conditionals, early
returns, and jump-table dispatch before trusting the allocator.
### 3.2 Interprocedural (NOT required)
@@ -345,9 +389,39 @@ The allocator is class-aware because the m68k has distinct register files:
- **FPU registers (FPn):** `float` values (32-bit 68881 singles).
Each class has its own interference graph (or a class-tagged unified graph),
sized by the available registers in that class after removing the reserved
registers of §2.5: 5 D (D2–D6), 3 A (A2–A4), 8 FP (FP0–FP7), given that
D0/D1/A0/A1 are reserved as pure scratch (§2.3).
sized by the available callee-saved registers in that class after removing the
reserved registers of §2.5: 5 D (D2–D6), 3 A (A2–A4), 6 FP (FP2–FP7). The
caller-saved D0/D1/A0/A1 and FP0/FP1 are additionally allocatable, but only for
vregs whose live range crosses no `CALL` (§2.3).
### 4.0 Expected demand per class (where the pressure actually is)
The three classes are not under equal pressure, and this shapes both the pool
sizing and what the empirical gate (§7.1 #6) must measure.
- **Data (D2–D6) is the bottleneck.** Every integer intermediate, array index,
loop variable, and general arithmetic value competes for these 5 callee-saved
registers (plus caller-saved D0/D1 for call-free ranges, §2.3). If any pool
is too small, it is this one.
- **Address (A2–A4) is rarely pressured.** Two different "pointer" populations
must not be conflated:
- *Short-lived pointer temporaries* (very common): the POINTER-typed IR
values produced by array indexing, struct field access, and `&var`
(`ADDR .p`, `ADD .p +offset`, `LOADI .p`). These are computed, used once
for a `(a0)` / `(a0,d0.w)` access, then dead. Per §2.3 they flow through
the pure-scratch A0/A1 and never enter the allocatable A2–A4 pool, so their
frequency is irrelevant to ADDRESS-pool sizing.
- *Long-lived pointer variables* (uncommon in typical Prog8 code): a
user-declared `^^type`/`pointer` value kept and dereferenced repeatedly is
the only real candidate for an A2–A4 allocation (so `(a2)`/`(a2,d3.w)`
works in place, §4.1). Most subroutines have zero or one of these, so 3
address registers is comfortable headroom.
- **FPU (FP0–FP7) is likely over-provisioned.** Float usage in typical Prog8
programs is rarer still than pointer usage, so 8 FPU registers will almost
never be a constraint.
Consequence: the empirical gate (§7.1 #6) should focus its measurement on the
data pool, not the address or FP pools. See §7.1 #6.
### 4.1 Class requirements flow from translator to allocator
@@ -562,9 +636,10 @@ hardcoded scratch register outside the D0/D1/A0/A1 set.
callee-saved split, the reserved set (§2.5), return-value locations (§2.7),
asmsub arg slots.
2. **Implement/repair intraprocedural liveness and interference** (§3.1: reuse
the packer's CFG/dataflow machinery with the interference model fixed to be
liveness-derived; validate on nested loops, conditionals, early returns,
switches).
the packer's gen/kill dataflow, interval construction, and greedy coloring;
validate CFG edge construction against nested loops, conditionals, early
returns, and jump tables; replace the interference model with a
liveness-derived one).
3. **Class-aware greedy colouring**: map vregs → physical D/A/FP registers,
treating `CALL` as killing caller-saved registers.
4. **Wire the mapping into `operand()` / `storeOperand()` / `fpOperand()`.**
@@ -577,13 +652,19 @@ hardcoded scratch register outside the D0/D1/A0/A1 set.
7. **Pin the return-value boundary (§2.7):** callee moves results to D0/FP0
before `rts`; the caller consumes them from there.
8. **Enforce the asmsub/extsub clobber policy (§2.6).**
9. **Decide the fate of `ImmediateCallOptimization`** (`AsmGen.kt`). This pass
does cross-instruction immediate forwarding into calls by reasoning about
which regfile slots are dead — a second, overlapping liveness analysis that
does not go through `operand()`. Either reimplement it on top of the
allocator's liveness (it becomes a call-argument register-assignment
problem) or restrict it to spilled operands. Leaving it untouched guarantees
it conflicts with the allocator.
9. **Restrict `ImmediateCallOptimization` to spilled operands** (`AsmGen.kt`).
This pass does cross-instruction immediate forwarding into calls by
reasoning about which regfile slots are dead — a second, overlapping
liveness analysis that does not go through `operand()`. Leaving it
untouched guarantees it conflicts with the allocator. **Decision (taken):
restrict it to spilled operands** — it only applies to vregs the allocator
has spilled (those that actually live in `p8_regfile`/stack); register-
allocated arguments are handled by the allocator's own call-boundary
marshalling instead. This keeps the pass (and the inline-memcopy path that
depends on it) working during allocator bring-up with minimal change. The
eventual goal is to delete it entirely: when the register-parameter calling
convention lands (§2.1 evolution), fold immediate-forwarding into the
call-boundary marshalling on allocator liveness (Stage 3 teardown).
10. **Optionally reset `RegisterPool` per subroutine** to allow vreg reuse
(§5).
@@ -615,15 +696,22 @@ delegable:
edges at call points; and since D0/D1/A0/A1 are never vreg-allocated
(§2.3), decide whether they appear in the interference graph at all or are
handled purely by CALL-boundary marshalling.
5. **Fate of `ImmediateCallOptimization` (Stage 2 step 9).** An explicit
either/or — reimplement on allocator liveness, or restrict to spilled
operands. A human decision, not delegable.
6. **Empirical validation gate (human judgment, stays open by design).** "Are
5 D / 3 A / 8 FP enough registers?" (§2.3 cost) and "does liveness survive
the TextElite corpus?" (§3.1) are hypotheses to *measure*, not tasks. An
agent can run the measurement, but a human judges the result and decides
whether to revisit the scratch-reservation tradeoff if register pressure is
too high.
5. **Fate of `ImmediateCallOptimization` (Stage 2 step 9) — DECIDED.**
Restrict it to spilled operands for allocator bring-up; delete it and fold
immediate-forwarding into call-boundary marshalling when the
register-parameter convention lands. See Stage 2 step 9.
6. **Empirical validation gate (human judgment, stays open by design).**
"Are the per-class pools big enough?" (§2.3 cost) and "does liveness
survive programs with complex control flow?" (§3.1) are hypotheses to
*measure*, not tasks. Per the demand analysis (§4.0) the measurement
should focus on the **data pool (D2–D6)** — the address pool (A2–A4) and
FP pool (FP0–FP7) are expected to have ample headroom because long-lived
pointer and float variables are uncommon in typical Prog8 code. An agent
can run the measurement, but a human judges the result and decides whether
the pool is adequate. Note the caller-saved D0/D1/A0/A1 are already
allocatable for call-free live ranges (§2.3), which relieves data-register
pressure for the common case; the measurement determines whether that
suffices or whether the convention itself needs revisiting.
### Stage 3 — teardown of the old mitigation layer
@@ -644,7 +732,7 @@ produces (spill-then-reload bounces, immediate forwarding that matches literal
### Success metrics (tie back to §0)
For a set of representative programs (the m68k examples, TextElite):
For a set of representative programs (the m68k examples, and real-world programs with complex control flow):
- Fraction of vreg operand references that resolve to CPU registers vs
`p8_regfile` — should be the large majority; regfile BSS size should shrink
@@ -668,7 +756,7 @@ structure) covering:
- Simple non-overlapping vregs coalesced into one hardware register.
- Overlapping live ranges forced into different registers.
- Cross-chunk liveness (vreg live across multiple code chunks).
- Nested loops and conditionals (the old TextElite failure case).
- Nested loops and conditionals (layout-order vs execution-order intervals).
- Early returns / multiple exit points (prologue/epilogue symmetry).
- Value live across a `CALL` kept in a callee-saved register or spilled.
- Caller-saved register correctly spilled around a call.