From e074fe614f1a15210ddb2a4fa992a9af67122e23 Mon Sep 17 00:00:00 2001 From: Irmen de Jong Date: Sat, 5 Sep 2026 00:07:08 +0200 Subject: [PATCH] avoid extremely long array data source lines, and redundant label comments --- .../codegen/intermediate/RegisterPacker.kt | 56 ++++-- codeGenM68k/src/prog8/codegen/m68k/AsmGen.kt | 39 +++- .../src/prog8/codegen/m68k/InstrLoadStore.kt | 12 +- .../TestInstructionSelectionOptimizations.kt | 13 -- .../src/prog8/codegen/new6502/AsmGen.kt | 59 ++++-- ideas/m68k-register-allocation.md | 190 +++++++++++++----- 6 files changed, 248 insertions(+), 121 deletions(-) diff --git a/codeGenIntermediate/src/prog8/codegen/intermediate/RegisterPacker.kt b/codeGenIntermediate/src/prog8/codegen/intermediate/RegisterPacker.kt index 25bea6a72..b386ef3c2 100644 --- a/codeGenIntermediate/src/prog8/codegen/intermediate/RegisterPacker.kt +++ b/codeGenIntermediate/src/prog8/codegen/intermediate/RegisterPacker.kt @@ -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. */ /* diff --git a/codeGenM68k/src/prog8/codegen/m68k/AsmGen.kt b/codeGenM68k/src/prog8/codegen/m68k/AsmGen.kt index 62d17ff04..75155da13 100644 --- a/codeGenM68k/src/prog8/codegen/m68k/AsmGen.kt +++ b/codeGenM68k/src/prog8/codegen/m68k/AsmGen.kt @@ -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 = program.blocks.filter { it.options.amigaChipram }.map { it.label }.toSet() + private fun emitChunkedDc(directive: String, values: List, 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) { + 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}") } diff --git a/codeGenM68k/src/prog8/codegen/m68k/InstrLoadStore.kt b/codeGenM68k/src/prog8/codegen/m68k/InstrLoadStore.kt index b20af1923..376cfac78 100644 --- a/codeGenM68k/src/prog8/codegen/m68k/InstrLoadStore.kt +++ b/codeGenM68k/src/prog8/codegen/m68k/InstrLoadStore.kt @@ -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 -> { diff --git a/codeGenM68k/test/TestInstructionSelectionOptimizations.kt b/codeGenM68k/test/TestInstructionSelectionOptimizations.kt index 999d73ea3..b13ea8437 100644 --- a/codeGenM68k/test/TestInstructionSelectionOptimizations.kt +++ b/codeGenM68k/test/TestInstructionSelectionOptimizations.kt @@ -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"), diff --git a/codeGenNew6502/src/prog8/codegen/new6502/AsmGen.kt b/codeGenNew6502/src/prog8/codegen/new6502/AsmGen.kt index b4cadb50e..3fe62f045 100644 --- a/codeGenNew6502/src/prog8/codegen/new6502/AsmGen.kt +++ b/codeGenNew6502/src/prog8/codegen/new6502/AsmGen.kt @@ -164,6 +164,14 @@ internal class AsmGen(val program: IRProgram, private val target: ICompilationTa output.appendLine(code) } + private fun emitInlineBinaryData(data: Collection) { + 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 = 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 -> { diff --git a/ideas/m68k-register-allocation.md b/ideas/m68k-register-allocation.md index 9fbd95c1c..be5b4fbbc 100644 --- a/ideas/m68k-register-allocation.md +++ b/ideas/m68k-register-allocation.md @@ -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.