mirror of
https://github.com/irmen/prog8.git
synced 2026-10-22 05:02:58 +00:00
IR: uniform traversal
This commit is contained in:
1 parent
bc62b2de81
commit
bbfb09e467
6 files changed
+741
-711
No files matched your search
@@ -1,6 +1,5 @@
|
||||
package prog8.codegen.intermediate
|
||||
|
||||
import prog8.intermediate.indexRegType
|
||||
import prog8.intermediate.*
|
||||
|
||||
/*
|
||||
@@ -74,13 +73,11 @@ object RegisterPacker {
|
||||
fun pack(irProg: IRProgram) {
|
||||
val indexRegType = irProg.options.compTarget.indexRegType
|
||||
val allRegTypes = mutableMapOf<Int, IRDataType>()
|
||||
irProg.foreachCodeChunk { chunk ->
|
||||
for (instr in chunk.instructions) {
|
||||
val (written, read) = getRegisterAccess(instr)
|
||||
for (r in written + read) {
|
||||
val dt = getRegisterType(instr, r, indexRegType)
|
||||
allRegTypes.putIfAbsent(r, dt)
|
||||
}
|
||||
irProg.forEachInstruction { instr ->
|
||||
val (written, read) = getRegisterAccess(instr)
|
||||
for (r in written + read) {
|
||||
val dt = getRegisterType(instr, r, indexRegType)
|
||||
allRegTypes.putIfAbsent(r, dt)
|
||||
}
|
||||
}
|
||||
val beforeCount = allRegTypes.size
|
||||
@@ -104,20 +101,18 @@ object RegisterPacker {
|
||||
fun rebuildTypeMap(irProg: IRProgram): Map<RegisterNum, IRDataType> {
|
||||
val indexRegType = irProg.options.compTarget.indexRegType
|
||||
val newTypes = mutableMapOf<RegisterNum, IRDataType>()
|
||||
irProg.foreachCodeChunk { chunk ->
|
||||
for (instr in chunk.instructions) {
|
||||
for ((reg, regNum) in listOf(instr.reg1 to 1, instr.reg2 to 2, instr.reg3 to 3)) {
|
||||
if (reg != null)
|
||||
newTypes.putIfAbsent(RegisterNum(reg), determineIntRegType(instr, regNum, indexRegType))
|
||||
}
|
||||
instr.fpReg1?.let { fpr -> newTypes.putIfAbsent(RegisterNum(fpr.value), IRDataType.FLOAT) }
|
||||
instr.fpReg2?.let { fpr -> newTypes.putIfAbsent(RegisterNum(fpr.value), IRDataType.FLOAT) }
|
||||
instr.fcallArgs?.let { fc ->
|
||||
for (a in fc.arguments)
|
||||
newTypes.putIfAbsent(RegisterNum(a.reg.registerNum.value), a.reg.dt)
|
||||
for (r in fc.returns)
|
||||
newTypes.putIfAbsent(RegisterNum(r.registerNum.value), IRDataType.BYTE)
|
||||
}
|
||||
irProg.forEachInstruction { instr ->
|
||||
for ((reg, regNum) in listOf(instr.reg1 to 1, instr.reg2 to 2, instr.reg3 to 3)) {
|
||||
if (reg != null)
|
||||
newTypes.putIfAbsent(RegisterNum(reg), determineIntRegType(instr, regNum, indexRegType))
|
||||
}
|
||||
instr.fpReg1?.let { fpr -> newTypes.putIfAbsent(RegisterNum(fpr.value), IRDataType.FLOAT) }
|
||||
instr.fpReg2?.let { fpr -> newTypes.putIfAbsent(RegisterNum(fpr.value), IRDataType.FLOAT) }
|
||||
instr.fcallArgs?.let { fc ->
|
||||
for (a in fc.arguments)
|
||||
newTypes.putIfAbsent(RegisterNum(a.reg.registerNum.value), a.reg.dt)
|
||||
for (r in fc.returns)
|
||||
newTypes.putIfAbsent(RegisterNum(r.registerNum.value), IRDataType.BYTE)
|
||||
}
|
||||
}
|
||||
return newTypes
|
||||
|
||||
@@ -603,27 +603,25 @@ private fun extractIrMetrics(program: prog8.intermediate.IRProgram): IrMetrics {
|
||||
val readRegs = mutableSetOf<Int>()
|
||||
val writeRegs = mutableSetOf<Int>()
|
||||
|
||||
// Count chunks and instructions from all subroutines
|
||||
// Count chunks and instructions from all subroutines (loop-aware via shared traversal)
|
||||
program.allSubs().forEach { sub ->
|
||||
sub.chunks.forEach { chunk ->
|
||||
chunks++
|
||||
instructions += chunk.instructions.size
|
||||
|
||||
// Extract register usage from each chunk
|
||||
chunk.instructions.forEach { ins ->
|
||||
ins.reg1?.let { readRegs.add(it) }
|
||||
ins.reg2?.let { readRegs.add(it) }
|
||||
ins.reg3?.let {
|
||||
if (ins.opcode == Opcode.CONCAT || ins.opcode == Opcode.EXT ||
|
||||
ins.opcode == Opcode.EXTS) {
|
||||
readRegs.add(it)
|
||||
} else {
|
||||
writeRegs.add(it)
|
||||
}
|
||||
sub.forEachChunk { chunks++ }
|
||||
sub.forEachInstruction { ins ->
|
||||
instructions++
|
||||
|
||||
// Extract register usage from each instruction
|
||||
ins.reg1?.let { readRegs.add(it) }
|
||||
ins.reg2?.let { readRegs.add(it) }
|
||||
ins.reg3?.let {
|
||||
if (ins.opcode == Opcode.CONCAT || ins.opcode == Opcode.EXT ||
|
||||
ins.opcode == Opcode.EXTS) {
|
||||
readRegs.add(it)
|
||||
} else {
|
||||
writeRegs.add(it)
|
||||
}
|
||||
ins.fpReg1?.let { readRegs.add(it.value) }
|
||||
ins.fpReg2?.let { readRegs.add(it.value) }
|
||||
}
|
||||
ins.fpReg1?.let { readRegs.add(it.value) }
|
||||
ins.fpReg2?.let { readRegs.add(it.value) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -641,12 +639,10 @@ private fun extractIrMetrics(program: prog8.intermediate.IRProgram): IrMetrics {
|
||||
private fun extractCodeInstructions(program: prog8.intermediate.IRProgram): List<String> {
|
||||
val code = mutableListOf<String>()
|
||||
|
||||
// Get all instructions from all subroutines
|
||||
// Get all instructions from all subroutines (loop-aware via shared traversal)
|
||||
program.allSubs().forEach { sub ->
|
||||
sub.chunks.forEach { chunk ->
|
||||
chunk.instructions.forEach { ins ->
|
||||
code.add(ins.toString())
|
||||
}
|
||||
sub.forEachInstruction { ins ->
|
||||
code.add(ins.toString())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,285 +0,0 @@
|
||||
# IR Preparation for M68k Register Allocation
|
||||
|
||||
**Status: proposed, not implemented.** This is preparatory work for the
|
||||
deferred hardware register allocator described in
|
||||
`ideas/m68k-register-allocation.md`.
|
||||
|
||||
**Priority: high prerequisite for the hardware allocator, but not a standalone
|
||||
user-visible feature.** The P0 items should be completed before implementing
|
||||
physical register assignment. The P1 items can follow before the allocator is
|
||||
enabled in production.
|
||||
|
||||
## Goal
|
||||
|
||||
Make the IR a complete, trustworthy input for register allocation. In
|
||||
particular, the future allocator must be able to see every instruction,
|
||||
understand every virtual-register read and write, recognize calls and opaque
|
||||
code, and validate the control-flow/dataflow assumptions it relies on.
|
||||
|
||||
The preparation must not change generated code, the calling convention, or the
|
||||
behavior of any existing backend. It should reduce risk and implementation
|
||||
scope for the later allocator while remaining useful to current optimizers and
|
||||
backends.
|
||||
|
||||
## Scope
|
||||
|
||||
This plan covers:
|
||||
|
||||
- Uniform recursive traversal of all IR chunks, including `IRLoopChunk` bodies.
|
||||
- Centralized register use/definition information for every instruction.
|
||||
- Explicit abstract effects for call-like instructions.
|
||||
- Lightweight register class and addressing hints.
|
||||
- An IR verifier focused on dataflow and allocator prerequisites.
|
||||
- Recursive, allocation-aware instruction rewriting infrastructure.
|
||||
|
||||
This plan does not cover:
|
||||
|
||||
- Assigning virtual registers to D, A, or FP registers.
|
||||
- Choosing or changing the m68k calling convention.
|
||||
- Spilling, reload insertion, stack frames, or callee-saved prologues.
|
||||
- Resetting the global `RegisterPool` per subroutine.
|
||||
- Encoding physical m68k register numbers in generic IR.
|
||||
|
||||
## 1. Uniform IR Traversal (P0)
|
||||
|
||||
Several analyses currently iterate `IRCodeChunk`s directly. That silently
|
||||
misses instructions nested inside `IRLoopChunk.body` unless each caller adds
|
||||
special handling.
|
||||
|
||||
Add shared traversal helpers near `IRCodeChunkBase` or `IRSubroutine`, for
|
||||
example:
|
||||
|
||||
```kotlin
|
||||
fun IRSubroutine.forEachChunk(action: (IRCodeChunkBase) -> Unit)
|
||||
fun IRSubroutine.forEachInstruction(action: (IRInstruction) -> Unit)
|
||||
```
|
||||
|
||||
Traversal must recurse through nested loop chunks and preserve a stable,
|
||||
deterministic order. It should visit inline assembly and binary chunks as
|
||||
chunks, even though they do not contain ordinary IR instructions.
|
||||
|
||||
Migrate these consumers to the helpers where applicable:
|
||||
|
||||
- `RegisterPacker` register/type collection and rewriting.
|
||||
- `IRProgram.registersUsed()` and related validation.
|
||||
- IR serialization checks and statistics.
|
||||
- Future liveness and verifier code.
|
||||
|
||||
Acceptance criteria:
|
||||
|
||||
- A register used only inside a loop body is found by every register-collection
|
||||
path.
|
||||
- Nested `IRLoopChunk`s are handled without duplicated recursion code.
|
||||
- Existing IR output and backend output remain unchanged.
|
||||
|
||||
## 2. Centralized Register Effects (P0)
|
||||
|
||||
The current allocator prototype derives reads and writes through its own
|
||||
`getRegisterAccess()` logic. Other code derives related information separately,
|
||||
which makes omissions likely as new instructions are added.
|
||||
|
||||
Give each `IRInstruction` a single authoritative description of its virtual
|
||||
register effects, conceptually:
|
||||
|
||||
```kotlin
|
||||
data class RegisterEffect(
|
||||
val reg: RegisterNum,
|
||||
val type: IRDataType,
|
||||
val kind: AccessKind // READ, WRITE, or READ_WRITE
|
||||
)
|
||||
|
||||
data class RegisterEffects(
|
||||
val effects: List<RegisterEffect>
|
||||
)
|
||||
```
|
||||
|
||||
The implementation must account for:
|
||||
|
||||
- `reg1`, `reg2`, and `reg3` according to the instruction format.
|
||||
- `fpReg1` and `fpReg2`.
|
||||
- Indexed address and index operands.
|
||||
- Function-call arguments and return registers.
|
||||
- Multi-byte extraction, concatenation, and conversion instructions.
|
||||
- Status flags separately from virtual registers where relevant.
|
||||
|
||||
Keep type information available alongside the effects, either through a
|
||||
separate query or an effect entry containing the `IRDataType`. Do not infer a
|
||||
hardware register class here; that remains a backend concern.
|
||||
|
||||
Migrate `RegisterPacker` and register validation to this API. Add tests for
|
||||
every instruction family that currently has special handling in
|
||||
`getRegisterAccess()`. This migration validates the new API, but does not
|
||||
re-enable the memory-slot packer in production;
|
||||
its global-slot and call-clobber unsoundness remains out of scope for this plan.
|
||||
|
||||
Acceptance criteria:
|
||||
|
||||
- Liveness, register counting, and validation agree on every instruction's
|
||||
reads and writes.
|
||||
- Adding a new register-bearing instruction requires updating one central
|
||||
effect definition and its tests.
|
||||
- Existing IR generation and serialized IR remain compatible.
|
||||
- `IRFileWriter` and `IRFileReader` round-trip any new metadata unchanged.
|
||||
|
||||
## 3. Abstract Call Effects (P0)
|
||||
|
||||
The IR identifies calls for control-flow purposes, but does not explicitly
|
||||
describe their dataflow effect. A future allocator needs to recognize all
|
||||
instructions that can cross a call boundary, including indirect, far, and
|
||||
system calls.
|
||||
|
||||
Add abstract metadata, for example:
|
||||
|
||||
```kotlin
|
||||
enum class CallEffect {
|
||||
None,
|
||||
Ordinary,
|
||||
Indirect,
|
||||
Far,
|
||||
Syscall,
|
||||
Opaque
|
||||
}
|
||||
```
|
||||
|
||||
The exact enum is open to implementation, but it should distinguish at least:
|
||||
|
||||
- Ordinary calls whose arguments and returns are described by
|
||||
`FunctionCallArgs`.
|
||||
- Calls with opaque or target-defined clobbers.
|
||||
- Inline assembly or other chunks that must act as an opaque barrier.
|
||||
|
||||
This metadata must not claim specific D/A/FP clobbers in generic IR. The m68k
|
||||
backend can later map abstract call effects to its selected convention.
|
||||
|
||||
Use the metadata initially for analysis and verification only. Do not change
|
||||
register allocation or generated assembly as part of this step.
|
||||
|
||||
Acceptance criteria:
|
||||
|
||||
- `CALL`, `CALLI`, `CALLFAR`, `CALLFARVB`, and `SYSCALL` are classified
|
||||
consistently.
|
||||
- Call arguments are reads and call returns are writes in the centralized
|
||||
effects API.
|
||||
- A verifier can identify values live across a call without knowing a target
|
||||
subroutine's implementation.
|
||||
|
||||
## 4. Register Class and Addressing Hints (P1)
|
||||
|
||||
The existing IR types already distinguish pointers, integers, and floating
|
||||
values. Preserve that information in a small, backend-neutral form so the
|
||||
m68k allocator does not need to reverse-engineer instruction operands.
|
||||
|
||||
Possible hints include:
|
||||
|
||||
```kotlin
|
||||
enum class RegisterClassHint {
|
||||
Any,
|
||||
Data,
|
||||
Address,
|
||||
Floating
|
||||
}
|
||||
```
|
||||
|
||||
Use hints conservatively:
|
||||
|
||||
- Floating registers require `Floating`.
|
||||
- Pointer and address operands prefer `Address`.
|
||||
- Ordinary integer values prefer `Data`.
|
||||
- Values that can legally use either class remain `Any`.
|
||||
|
||||
For indexed operations, expose operand roles such as base address, index, and
|
||||
scalar value. Do not force an allocation decision or introduce m68k-specific
|
||||
register numbers into the IR.
|
||||
|
||||
Acceptance criteria:
|
||||
|
||||
- `LOADX`, `STOREX`, and `STOREZX` expose enough information to identify their
|
||||
base and index operands.
|
||||
- Floating operations cannot accidentally be treated as integer values.
|
||||
- Existing backends ignore the hints and produce the same output.
|
||||
|
||||
## 5. IR Dataflow Verifier (P0)
|
||||
|
||||
Add an opt-in verifier, initially usable from tests and diagnostic compiler
|
||||
paths. It should validate assumptions needed by liveness and allocation:
|
||||
|
||||
- Every referenced virtual register has a known type.
|
||||
- Every register read has a valid definition or is a subroutine input.
|
||||
- Register effects include nested loop bodies.
|
||||
- Call arguments and return registers have valid descriptions.
|
||||
- Multi-byte values are used consistently with their declared width.
|
||||
- Control-flow labels and chunk successors are valid.
|
||||
- Every loop body has a valid enclosing loop and termination structure.
|
||||
|
||||
The verifier should report the subroutine, chunk label, instruction index, and
|
||||
register involved. It should not reject intentionally opaque inline assembly;
|
||||
instead, it should record that the chunk is a conservative barrier.
|
||||
|
||||
Add regression cases for nested loops, conditional branches, early returns,
|
||||
switch-like dispatch, recursion-shaped call graphs, and calls with hardware
|
||||
argument slots.
|
||||
|
||||
## 6. Recursive Rewriting Support (P1)
|
||||
|
||||
The current rewriting path should be generalized so transformations apply to
|
||||
instructions inside nested chunks as well as top-level chunks.
|
||||
|
||||
For this preparatory phase, the rewrite operation can remain a virtual-register
|
||||
rename. Structure it so a later allocator can replace a virtual operand with a
|
||||
physical location or a spill/reload sequence without adding separate loop
|
||||
handling.
|
||||
|
||||
The rewrite API should preserve:
|
||||
|
||||
- Chunk labels and successor links.
|
||||
- Loop trip counts and loop nesting.
|
||||
- Inline assembly and binary chunks.
|
||||
- Source or diagnostic locations, where present.
|
||||
|
||||
Add a test that renames a register used only in a nested loop and verifies that
|
||||
all references, including loop-body references, are changed exactly once.
|
||||
|
||||
## 7. Suggested Implementation Order
|
||||
|
||||
1. **P0:** Add recursive traversal helpers and migrate register collection.
|
||||
2. **P0:** Add centralized register effects and migrate `RegisterPacker`.
|
||||
3. **P0:** Add abstract call-effect metadata and update liveness/verifier
|
||||
consumers.
|
||||
4. **P0:** Add the opt-in dataflow verifier and enable it in IR-focused tests.
|
||||
5. **P1:** Add recursive rewriting and tests for nested loops.
|
||||
6. **P1:** Add register class and addressing hints.
|
||||
|
||||
Each step should be behavior-preserving for all current targets. The existing
|
||||
memory-slot packer should remain disabled in production until its correctness
|
||||
issues are separately resolved.
|
||||
|
||||
The P0 milestone is the minimum gate for starting the hardware allocator. The
|
||||
P1 milestone should be completed before the allocator is used for optimized
|
||||
production output, but can be developed incrementally alongside its prototype.
|
||||
|
||||
## 8. Testing and Migration
|
||||
|
||||
- Add unit tests for every new traversal/effect/verifier component before
|
||||
migrating existing consumers.
|
||||
- Run `gradle build --console=plain` after each step; generated code for all
|
||||
targets must remain byte-identical where the new code is not enabled.
|
||||
- Expose the verifier through a hidden compiler flag or test helper first;
|
||||
enable it by default only after it passes on the full test suite.
|
||||
- Keep `RegisterPacker` disabled in production until the separate correctness
|
||||
issues in its global slot model are resolved.
|
||||
|
||||
## 9. Handoff to the Hardware Allocator
|
||||
|
||||
This preparation is complete when the future m68k allocator can consume:
|
||||
|
||||
- A complete recursive instruction stream.
|
||||
- Authoritative read/write sets.
|
||||
- Known call boundaries and opaque barriers.
|
||||
- Type and class preferences.
|
||||
- Verified CFG and dataflow assumptions.
|
||||
- A rewrite API that does not special-case loop bodies.
|
||||
|
||||
The hardware allocator can then be implemented independently, starting with
|
||||
integer data registers and no spilling, followed by callee-saved handling,
|
||||
spilling, address registers, and FPU registers as described in
|
||||
`ideas/m68k-register-allocation.md`.
|
||||
+534
-284
@@ -1,18 +1,34 @@
|
||||
# M68k Register Allocation and Calling Convention Design
|
||||
|
||||
**Status: not implemented / design only.** Listed as "Deferred" in `docs/source/todo.rst`.
|
||||
**Status: design in progress — convention decided, allocator internals not yet fully decided.** Listed as "Deferred" in `docs/source/todo.rst`. The calling convention (§2), register classes (§4), spilling strategy (§5), prologue/epilogue (§6), and the Stage 0/1/3 execution work (§7) are decided and delegable. The Stage-2 allocator internals are **not yet fully decided** — see §7.1 Open Decision Points.
|
||||
|
||||
Related design: `m68k-stack-memory-model.md` (stack frames for locals; reserves
|
||||
A5 as the future frame pointer and A6 for AmigaOS library bases — both are
|
||||
reflected in the register split below).
|
||||
|
||||
This document describes the design for a **true register allocator** for the
|
||||
m68k code generation backend (`codeGenM68k`). It supersedes the earlier
|
||||
memory-slot packing approach in `codeGenIntermediate/RegisterPacker.kt`, which
|
||||
only compressed the *memory* register file (`p8_regfile` BSS block) and never
|
||||
placed values in real CPU registers.
|
||||
m68k code generation backend (`codeGenM68k`).
|
||||
|
||||
The goal here is different and bigger: keep virtual register (vreg) values in
|
||||
the m68k's actual hardware registers (D0–D7, A0–A6, FP0–FP7) for as long as
|
||||
possible, and only spill to memory under register pressure. This yields both
|
||||
speed (no load/store round-trips) and smaller code, which is the entire point
|
||||
of targeting a register-rich 32-bit CPU.
|
||||
## 0. Goals (in priority order)
|
||||
|
||||
1. **Values live in CPU registers.** Virtual-register values must reside in the
|
||||
m68k's actual hardware registers (D0–D6, A0–A4, FP0–FP7) for as long as they
|
||||
are live. The in-memory `p8_regfile` block is demoted to a spill-only area
|
||||
(and shrinks accordingly). Memory residence is the exception, not the
|
||||
default. This is the whole point of targeting a register-rich 32-bit CPU.
|
||||
2. **Dramatically smaller and better assembly, as a *consequence* of goal 1.**
|
||||
When operands are already in registers, the load/compute/store round-trips
|
||||
disappear and register-to-register instructions (`add.l d1, d0`) fall out
|
||||
naturally. Smaller/faster code that is produced by bolting on post-hoc
|
||||
peepholes does not count toward this goal — it must emerge from the codegen
|
||||
structure itself.
|
||||
3. **The backend becomes intrinsically register-oriented, not patched.** The
|
||||
allocator must be a first-class input to instruction selection, not a
|
||||
post-processing or peephole layer over memory-everywhere codegen (see §1.1).
|
||||
|
||||
Non-goals: matching a C ABI's generality (no stack arguments, no varargs),
|
||||
squeezing the last cycle out of hot loops (later micro-work), and applying
|
||||
this to the 6502 targets.
|
||||
|
||||
---
|
||||
|
||||
@@ -21,20 +37,59 @@ of targeting a register-rich 32-bit CPU.
|
||||
The m68k backend today (`AsmGen.kt`) spills **every** virtual register to a
|
||||
single flat BSS memory block:
|
||||
|
||||
- `regFileLayout` (`AsmGen.kt:157-169`) lays out **all** registers returned by
|
||||
- `regFileLayout` lays out **all** registers returned by
|
||||
`program.registersUsed().regsTypes` — program-wide — into one block labelled
|
||||
`p8_regfile` (label constant at `:100`).
|
||||
- `regAddr(reg)` (`AsmGen.kt:188`) returns `p8_regfile+offset`, so every IR instruction becomes a
|
||||
`p8_regfile`.
|
||||
- `regAddr(reg)` returns `p8_regfile+offset`, so every IR instruction becomes a
|
||||
load from memory into D0/D1, compute, store result back to memory.
|
||||
- `RegisterPool` (`codeGenIntermediate/RegisterPool.kt`) assigns globally
|
||||
unique, monotonically increasing register numbers; `nextRegister` is never
|
||||
reset per subroutine.
|
||||
- The VM's register file is a fixed `Array(99999)` and is irrelevant here; only
|
||||
the m68k (and new6502) backends actually consume the IR regfile as memory.
|
||||
- The VM's register file is irrelevant here; only the m68k (and new6502)
|
||||
backends actually consume the IR regfile as memory.
|
||||
|
||||
This works and is correct, but it throws away the m68k's main advantage: real
|
||||
registers. The design below keeps the correctness of the current scheme while
|
||||
adding a proper allocator.
|
||||
registers.
|
||||
|
||||
### 1.1 Lesson from the first attempt: a bolt-on allocator does not work
|
||||
|
||||
A first implementation of this plan was tried and the result was underwhelming
|
||||
on every axis that matters: fragmented and complicated compiler changes, only
|
||||
marginally smaller output, and "allocation" that behaved like a
|
||||
post-processing/peephole step rather than making codegen intrinsically
|
||||
register-oriented. Root cause, and the constraint it imposes:
|
||||
|
||||
**The backend's instruction selection is hardwired to memory operands.** Every
|
||||
IR instruction translator (`InstrArithmetic`, `InstrBitwise`, `InstrControl`,
|
||||
`InstrLoadStore`, `InstrBranch`, `InstrSyscall` — roughly 320 call sites of
|
||||
`regAddr()` / `floatRegFileAddr()` across ~4,100 lines) emits each vreg as an
|
||||
*absolute memory operand*:
|
||||
|
||||
```
|
||||
INC -> addq.b #1, p8_regfile+N
|
||||
ADDR -> (load src into d0) add.b d0, p8_regfile+N
|
||||
```
|
||||
|
||||
An absolute memory operand and a CPU-register operand are **different
|
||||
instruction selections**, not different spellings of the same operand. An
|
||||
allocator layered on top of this backend cannot turn `p8_regfile+N` into `d3`
|
||||
by changing an address computation; it has to either (a) intercept inside
|
||||
`regAddr()` and return a register name as a string, which breaks every site
|
||||
that assumed a memory operand (address-of, byte sub-access via `regAddrByte`,
|
||||
load-op-store fused patterns, inline-asm references), or (b) add post-hoc
|
||||
peepholes that rewrite the memory-operand patterns the translators just
|
||||
emitted. Both were tried; both produce the fragmented, low-yield result above,
|
||||
and (b) is literally what the existing `AsmOptimizer` regfile peepholes already
|
||||
do.
|
||||
|
||||
**Consequence: allocation must come *first*, structurally.** Instruction
|
||||
selection must be made location-agnostic *before* any allocator exists
|
||||
(Stage 1 in §7), so that "which physical register holds this vreg" is an input
|
||||
to codegen, not a rewrite of its output.
|
||||
|
||||
Sections 2–6 describe the design; §7 gives the implementation order. Of those
|
||||
stages, **Stage 0 (preparations) can be started immediately** — it depends only
|
||||
on the convention decisions in §2, not on the allocator existing.
|
||||
|
||||
---
|
||||
|
||||
@@ -42,63 +97,191 @@ adding a proper allocator.
|
||||
|
||||
### 2.1 No stack arguments
|
||||
|
||||
Prog8 already has a stack-free calling convention (see `AsmGen.kt:87-91`):
|
||||
Prog8 already has a stack-free calling convention:
|
||||
|
||||
- **Normal subs:** the caller writes arguments into the callee's *parameter
|
||||
variables* (memory/BSS, possibly ZP) before the `jsr`; the callee reads them
|
||||
from there.
|
||||
variables* (memory/BSS) before the `jsr`; the callee reads them from there.
|
||||
- **asmsub/extsub:** arguments go into fixed hardware slots via
|
||||
`CallingConventionSlot` (`InstrControl.kt:645`): slots 10..17 → D0..D7,
|
||||
18..24 → A0..A6, 25..32 → FP0..FP7. In practice asmsub args/returns use
|
||||
D0–D2 and FP0–FP1.
|
||||
- **Returns:** come back through virtual registers mapped to the caller's
|
||||
result register.
|
||||
`CallingConventionSlot`: slots 10..17 → D0..D7, 18..24 → A0..A6,
|
||||
25..32 → FP0..FP7. In practice asmsub args/returns use D0–D2 and FP0–FP1.
|
||||
|
||||
Because arguments are already in memory (or in volatile hardware slots), a value
|
||||
passed to a callee and still needed afterward is *already spilled* in its
|
||||
parameter/parent variable. No stack-frame argument handling is needed, which
|
||||
removes a whole class of complexity that a C-style ABI has.
|
||||
Because arguments are already in memory (or in volatile hardware slots), a
|
||||
value passed to a callee and still needed afterward is *already spilled* in its
|
||||
parameter variable. No stack-frame argument handling is needed.
|
||||
|
||||
**This memory-parameter convention is the *interim* convention, not the
|
||||
permanent one.** `m68k-stack-memory-model.md` §6.1 will later replace it with a
|
||||
register/stack hybrid in which parameters are passed in registers (and excess
|
||||
on the stack) and copied into the callee's A5 frame. The allocator's design is
|
||||
already compatible with that evolution — the register classes (§4), the pinned
|
||||
CALL/return boundary (§2.7), and frame-slot spilling (§5) all carry over — but
|
||||
a future reader should not treat §2.1 as fixed. When the stack model lands,
|
||||
§2.1 is the section that changes: parameter passing moves from "caller writes
|
||||
callee's memory variables" to "vregs marshalled into argument registers /
|
||||
frame slots at the call boundary," which the allocator then models like any
|
||||
other vreg→register assignment.
|
||||
|
||||
### 2.2 No save/restore at the CALL
|
||||
|
||||
The `CALL` instruction itself does **no** register save/restore. It is a bare
|
||||
`jsr`/`bsr`, exactly like today. The VM's `CallSiteContext`
|
||||
(`VirtualMachine.kt:64`) already carries only the return address, so this
|
||||
constraint is already satisfied by the runtime.
|
||||
`jsr`/`bsr`, exactly like today.
|
||||
|
||||
### 2.3 Register split (m68k SVR4-style)
|
||||
|
||||
The convention splits the 16 general registers and 8 FPU registers:
|
||||
### 2.3 Register split
|
||||
|
||||
| Registers | Class | Saved by | Role |
|
||||
|----------------------|--------------------|----------|------|
|
||||
| D0, D1, A0, A1 | caller-saved (volatile) | caller (around call if live) | scratch, asmsub arg/return slots, temporaries |
|
||||
| D2–D7, A2–A5 | callee-saved (preserved) | callee (prologue/epilogue) | values that survive calls |
|
||||
| D2–D6, A2–A4 | callee-saved (preserved) | callee (prologue/epilogue) | values that survive calls |
|
||||
| FP0, FP1 | caller-saved | caller | float scratch / return |
|
||||
| FP2–FP7 | callee-saved | callee | float values that survive calls |
|
||||
| A6 | frame pointer (callee-saved) | callee | stack frame |
|
||||
| D7 | reserved (not allocatable) | — | physical `repeat` loop counter (§2.5) |
|
||||
| A5 | reserved (not allocatable) | — | future frame pointer (§2.5) |
|
||||
| A6 | reserved (not allocatable) | — | AmigaOS library-base register (§2.5) |
|
||||
| A7 | stack pointer | — | hardware stack |
|
||||
|
||||
Return-value locations: D0 (int/ptr), D0:D1 (long long), FP0 (float/double),
|
||||
A0 (struct/array pointer return).
|
||||
Return-value locations are pinned at the CALL boundary (§2.7): D0 for integer
|
||||
and pointer results (byte/word results occupy the low part of D0; Prog8 `long`
|
||||
is 32-bit and fits D0), FP0 for float results (32-bit 68881 singles). Pointer
|
||||
results return in D0, matching the backend's current default
|
||||
(`translateReturnValue`); explicit A-register return slots remain possible for
|
||||
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:
|
||||
|
||||
- It formalizes existing reality — the translators already grab d0/d1/a0
|
||||
blindly (`loadRegOrZeroExtendToD0`, `loadPointerToA0`, `loadIndexToD0`), and
|
||||
returns already go through d0.
|
||||
- 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.
|
||||
- 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.
|
||||
|
||||
### 2.4 What a CALL means for liveness
|
||||
|
||||
Because of the split, every `CALL` behaves uniformly and the allocator applies a
|
||||
single rule regardless of the target subroutine:
|
||||
Because of the split, every `CALL` behaves uniformly and the allocator applies
|
||||
a single rule regardless of the target subroutine:
|
||||
|
||||
- **CALL kills all caller-saved registers** (D0, D1, A0, A1, FP0, FP1). Any
|
||||
value the caller holds in a caller-saved register and needs after the call
|
||||
must either be spilled to memory before the call or kept in a callee-saved
|
||||
register instead.
|
||||
- **CALL preserves all callee-saved registers** (D2–D7, A2–A5, FP2–FP7). The
|
||||
- **CALL preserves all callee-saved registers** (D2–D6, A2–A4, FP2–FP7). The
|
||||
callee promises to save/restore any it uses, so the caller's values there
|
||||
survive the call untouched.
|
||||
|
||||
This uniform rule is what makes the next two sections work.
|
||||
This uniform rule is what makes the rest of the design work: allocation is
|
||||
purely subroutine-by-subroutine, with no call graph, no call-tree propagation,
|
||||
and no cross-subroutine coordination. Indirect calls (`CALLI`) and recursion
|
||||
are handled by the same rule.
|
||||
|
||||
### 2.5 Reserved registers (D7, A5, A6)
|
||||
|
||||
Three registers are excluded from the allocatable pool entirely, on both m68k
|
||||
targets:
|
||||
|
||||
- **D7 — physical `repeat` loop counter.** The backend uses d7 as the loop
|
||||
counter for `repeat` loops, and backend helper routines called via `jsr` from
|
||||
generated code clobber d7 *without any IR-visible CALL* (see the note in
|
||||
`IRProgram.kt` near `usedRegisters`). The allocator cannot model these
|
||||
clobbers, so D7 stays reserved until the loop-counter mechanism is reworked
|
||||
to request a register from the allocator.
|
||||
- **A5 — future frame pointer.** Reserved for the stack-frame memory model in
|
||||
`m68k-stack-memory-model.md` (`link a5,#-N` / `unlk a5`). Reserving it from
|
||||
the start avoids a flag day when frames land.
|
||||
- **A6 — AmigaOS library-base register.** On amiga500 the backend loads a
|
||||
library base into A6 around every OS call (`move.l 4.w,a6` or
|
||||
`move.l sys.xxxBase,a6`, then `jsr Symbol(a6)`; see CALLFAR in
|
||||
`InstrControl.kt`). These writes have no IR-visible call edge, so an
|
||||
allocated value in A6 would be silently corrupted. qemu68k has no
|
||||
library-base convention, but A6 is excluded there too so both m68k targets
|
||||
share one allocation model.
|
||||
|
||||
Note this deliberately deviates from SVR4, which uses A6 as the frame pointer;
|
||||
Prog8's m68k targets use A5 for that role instead, because A6 is spoken for by
|
||||
the AmigaOS ABI.
|
||||
|
||||
Net allocatable set for vregs: 5 data registers (D2–D6), 3 address registers
|
||||
(A2–A4), 8 FPU registers (FP0–FP7) — since D0/D1/A0/A1 are pure scratch (§2.3)
|
||||
and D7/A5/A6 are reserved (above).
|
||||
|
||||
### 2.6 asmsub/extsub clobber policy
|
||||
|
||||
`CpuRegister` includes D0–D7/A0–A6/FP0–FP7, so a hand-written `asmsub` or
|
||||
`extsub` can declare (or worse, fail to declare) clobbering a callee-saved or
|
||||
reserved register. That would break the uniform CALL rule of §2.4.
|
||||
|
||||
Policy:
|
||||
|
||||
- **`clobbers(...)` may only name caller-saved registers** (D0, D1, A0, A1,
|
||||
FP0, FP1). Declaring a callee-saved or reserved register is a compile error;
|
||||
hand-written assembly that needs D2–D6/A2–A4/FP2–FP7 must save and restore
|
||||
them itself. (A looser variant — honoring the declared clobber set as extra
|
||||
interference at that call site — is possible but abandons the "every CALL is
|
||||
identical" simplification; only add it if a real use case demands it.)
|
||||
- **Inline asmsubs** are pasted directly into the instruction stream, so the
|
||||
same rule applies to their bodies: no writes to callee-saved or reserved
|
||||
registers without restoring them.
|
||||
- **Interrupt/exception handlers** (e.g. VertB handlers on amiga500) are
|
||||
entered asynchronously, not through a CALL: they must save and restore
|
||||
*every* register they touch, including caller-saved ones, because there is no
|
||||
call site where the interrupted code could have spilled. Handler entry stubs
|
||||
need their own prologue convention.
|
||||
|
||||
### 2.7 Return-value marshalling at the CALL boundary
|
||||
|
||||
With per-subroutine allocation the caller cannot know which physical register
|
||||
the callee allocated to its result vreg, so the boundary is pinned:
|
||||
|
||||
- **Single scalar/pointer result:** the callee moves it into D0 before `rts`;
|
||||
the caller's allocator treats D0 as defined by the CALL and consumes or moves
|
||||
it from there. This matches the current backend default
|
||||
(`translateReturnValue`: non-float returns go through d0).
|
||||
- **Single float result:** same, through FP0.
|
||||
- **Multi-value returns:** unchanged from today (memory slots / LOADHR).
|
||||
Register-based multi-return is a later optimization.
|
||||
- **asmsub/extsub:** unchanged — explicit slot annotations, restricted by §2.6
|
||||
to the caller-saved set.
|
||||
|
||||
### 2.8 The role of LOADHR / STOREHR under allocation
|
||||
|
||||
`LOADHR` / `STOREHR` are the IR's only mechanism for moving a vreg to or from a
|
||||
**named hardware register** (slots 10..32 = D0–D7/A0–A6/FP0–FP7). They serve
|
||||
two distinct purposes:
|
||||
|
||||
1. **asmsub/extsub argument and return marshalling** (semantic, required). The
|
||||
IR must be able to say "this argument goes in D0". These opcodes are the
|
||||
correct hook for the §2.1 / §2.6 boundary and are **not** removed.
|
||||
2. **Spill/return scaffolding** (today: LOADHR out of a memory slot into D0 to
|
||||
pass an arg, STOREHR the result back to memory). This is where the current
|
||||
backend wastes memory round-trips.
|
||||
|
||||
Under the allocator, case 2 mostly collapses and case 1 becomes cheap, because
|
||||
the *vreg side* of LOADHR/STOREHR must also resolve through `operand()`
|
||||
(§7 Stage 1), not unconditionally through `p8_regfile`:
|
||||
|
||||
- `STOREHR rX, slot=D0` where rX is allocated to d0 → **elided (no-op)**.
|
||||
- `STOREHR rX, slot=D0` where rX is allocated to d3 → `move.l d3, d0`.
|
||||
- `LOADHR rX, slot=D0` where rX is allocated to d0 → **elided**.
|
||||
|
||||
So the design keeps the opcodes but makes most of them no-ops or single
|
||||
register-to-register moves. (Deliberately *not* "get rid of LOADHR/STOREHR":
|
||||
removing them would require expressing asmsub register binding outside the IR
|
||||
operand model, a worse design.)
|
||||
|
||||
---
|
||||
|
||||
@@ -112,53 +295,42 @@ each subroutine:
|
||||
1. Build a CFG of the subroutine's code chunks.
|
||||
2. Compute liveness via gen/kill + iterative dataflow → `liveIn`/`liveOut` per
|
||||
chunk.
|
||||
3. Walk instructions to build live intervals (register, start, end, type).
|
||||
4. Build a conflict graph from overlapping live ranges and cross-type
|
||||
incompatibilities.
|
||||
5. Greedy-colour intervals onto physical registers, respecting register
|
||||
classes (Section 4).
|
||||
3. Build the interference graph from simultaneous liveness: an edge between any
|
||||
two vregs that are live at the same point.
|
||||
4. Greedy-colour vregs onto physical registers, respecting register classes
|
||||
(§4), and spill what doesn't fit (§5).
|
||||
|
||||
The liveness results drive three decisions:
|
||||
|
||||
- **Call-spill:** which vregs are live across a `CALL` → must be in
|
||||
callee-saved registers or spilled to memory before the call.
|
||||
- **Callee-saved usage:** which callee-saved registers the subroutine itself
|
||||
uses → emit prologue saves / epilogue restores only for those.
|
||||
uses → emit prologue saves / epilogue restores only for those (§6).
|
||||
- **Spilling:** when register pressure exceeds the available physical
|
||||
registers.
|
||||
|
||||
> Note: the disabled `RegisterPacker` already contains intraprocedural
|
||||
> liveness infrastructure, but it had bugs on complex control flow (nested
|
||||
> loops/conditionals, early returns — see the old TextElite failure). Any
|
||||
> liveness implementation must be validated against those CFG shapes before
|
||||
> use. Reusing and repairing the existing infrastructure is preferred over a
|
||||
> from-scratch rewrite.
|
||||
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).
|
||||
|
||||
Validate liveness against nested loops, conditionals, early returns, and
|
||||
jump-table dispatch before trusting the allocator.
|
||||
|
||||
### 3.2 Interprocedural (NOT required)
|
||||
|
||||
No cross-subroutine liveness analysis is needed. The uniform calling
|
||||
convention (Section 2.4) means the allocator treats every `CALL` identically:
|
||||
kill caller-saved, preserve callee-saved. The caller does not need to know
|
||||
which registers a callee clobbers, and the callee does not need to know its
|
||||
callers' usage.
|
||||
|
||||
Consequences:
|
||||
|
||||
- **Indirect calls (`CALLI`) and recursion** are handled by the same uniform
|
||||
rule — no target knowledge, no call-graph walk required.
|
||||
- Interprocedural analysis is only an *optimization*:
|
||||
- *Leaf-subroutine detection*: a subroutine containing no `CALL` has no
|
||||
callee that could clobber it, but it must still preserve callee-saved
|
||||
registers **for its own caller**, so leaf status does not remove the need
|
||||
to save callee-saved registers it uses. (It mainly avoids any additional
|
||||
defensive reasoning.)
|
||||
- *Shrink-wrapping*: defer callee-saved saves to only the paths that actually
|
||||
call, instead of the prologue. A later optimization, not needed for
|
||||
correctness.
|
||||
|
||||
This is a key simplification versus the old packer, which needed call-graph
|
||||
propagation or depth ranges precisely because it operated on a flat shared
|
||||
*memory* regfile with no per-call save mechanism.
|
||||
No cross-subroutine liveness analysis is needed: the uniform calling convention
|
||||
(§2.4) makes every `CALL` identical. Interprocedural analysis remains available
|
||||
only as a later *optimization* (leaf-subroutine detection, shrink-wrapping of
|
||||
callee-saved saves); it is never needed for correctness.
|
||||
|
||||
---
|
||||
|
||||
@@ -170,261 +342,328 @@ The allocator is class-aware because the m68k has distinct register files:
|
||||
- **Address registers (An):** pointers and address arithmetic; prefer keeping
|
||||
pointers in A-registers so `(An)` / `(An)+` / `-(An)` / `(An,d0)` addressing
|
||||
modes are usable.
|
||||
- **FPU registers (FPn):** `float`/`double` values.
|
||||
- **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 (8 D, 8 A, 8 FP, minus the
|
||||
fixed roles like A6/A7).
|
||||
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).
|
||||
|
||||
### 4.1 Class requirements flow from translator to allocator
|
||||
|
||||
Addressing modes are class-locked by the hardware: the base of `(An, ...)` and
|
||||
`(off, An)` must be an address register, the index of `(An, Dn.w)` must be a
|
||||
data register. A vreg used as a pointer base must therefore be **constrained**
|
||||
to the ADDRESS class, and one used as an array index to the DATA class, at
|
||||
allocation time — otherwise the allocator will place it in the wrong class and
|
||||
the translator must fall back to copying into scratch a0/a1/d0/d1, erasing the
|
||||
in-place benefit. The translator communicates this to the allocator as a
|
||||
per-operand class constraint; when the constraint is honored the addressing
|
||||
mode is emitted with the vreg in place (`(a2, d3.w)`), and when it cannot be
|
||||
(register pressure) the translator falls back to the always-free scratch
|
||||
registers.
|
||||
|
||||
### 4.2 RegClass lives in codeGenM68k, not on CpuRegister
|
||||
|
||||
Register class is an allocator-internal concept and is deliberately **not**
|
||||
added to the shared `CpuRegister` enum (`codeCore`). That enum's only roles are
|
||||
the asmsub/extsub `clobbers` sets and the `RegisterOrPair.fromCpuRegister()`
|
||||
slot mapping — a user-facing asmsub vocabulary shared by all backends,
|
||||
including 6502 and VM which have no m68k class notion. The class of a register
|
||||
is also trivially derivable from its identity, so there is nothing to store on
|
||||
the enum. Instead, `codeGenM68k` defines its own:
|
||||
|
||||
```kotlin
|
||||
enum class RegClass { DATA, ADDRESS, FPU }
|
||||
|
||||
fun regClassOf(r: CpuRegister): RegClass = when (r) {
|
||||
CpuRegister.D0..CpuRegister.D7 -> RegClass.DATA
|
||||
CpuRegister.A0..CpuRegister.A6 -> RegClass.ADDRESS
|
||||
CpuRegister.FP0..CpuRegister.FP7 -> RegClass.FPU
|
||||
else -> error("6502 register $r has no m68k class")
|
||||
}
|
||||
```
|
||||
|
||||
The allocator keeps its own richer physical-register representation (class +
|
||||
free/allocated + spill slot) keyed off this. The one legitimate consumer of the
|
||||
class function outside the allocator is the §2.6 clobber-policy check, which
|
||||
operates on the existing `clobbers: Set<CpuRegister>` and rejects any
|
||||
callee-saved or reserved register.
|
||||
|
||||
---
|
||||
|
||||
## 5. Per-subroutine vreg reuse
|
||||
|
||||
Virtual register numbers may be reused across subroutines (the `RegisterPool`
|
||||
can reset its counter per subroutine). This is safe *because* no value is live
|
||||
in a shared hardware register across a `CALL` (guaranteed by the convention in
|
||||
Section 2.4): when subroutine B uses D5 and clobbers it, any caller-live value
|
||||
was either already in memory or in a callee-saved register that B preserved.
|
||||
|
||||
Benefits:
|
||||
|
||||
- The `p8_regfile` BSS block (now only a spill area) shrinks to the
|
||||
worst-case single-subroutine spill set rather than the program-wide total.
|
||||
- Fewer distinct vreg numbers simplifies analysis and the regfile layout.
|
||||
|
||||
Reusing numbers requires no extra machinery beyond the convention already
|
||||
described.
|
||||
|
||||
---
|
||||
|
||||
## 6. Spilling
|
||||
## 5. Spilling
|
||||
|
||||
When register pressure exceeds the physical registers available in a class, the
|
||||
allocator spills vregs to memory:
|
||||
allocator spills vregs to memory, inserting stores/loads at definition/use
|
||||
points. Three possible spill targets, with different soundness properties:
|
||||
|
||||
- **BSS regfile:** the existing `p8_regfile` block, now demoted from primary
|
||||
storage to a spill area.
|
||||
- **System stack:** push/pop around the live range (standard for a true
|
||||
allocator; distinct from "stack arguments", which Prog8 does not use).
|
||||
- Spill stores/loads are inserted by the allocator at definition/use points.
|
||||
- **Hardware stack (preferred):** push/pop around the live range. Per-invocation
|
||||
storage, so recursion-safe with no cross-subroutine coordination.
|
||||
- **Frame slots:** once the A5 frame model (`m68k-stack-memory-model.md`) lands,
|
||||
spills become frame-resident; its frame layout already budgets "compiler
|
||||
spill slots". Same soundness as stack spills, with cheaper addressing
|
||||
(`move.l -8(a5),d0` instead of push/pop pairs).
|
||||
- **BSS regfile:** the existing `p8_regfile` block, demoted to a spill area.
|
||||
Static storage, so **not reentrant** (sound today only because Prog8 has no
|
||||
recursion) and **shared by every subroutine**: a caller spilling its r5 to
|
||||
offset X collides with a callee spilling its own r5 to the same offset. BSS
|
||||
spill slots therefore have to be program-wide unique, which reintroduces the
|
||||
globality the convention otherwise avoids. Interrupt handlers must also never
|
||||
share BSS spill slots with the code they can interrupt.
|
||||
|
||||
The callee-saved prologue save/restore (Section 7) is the spill mechanism for
|
||||
values that must survive a call; it is *not* a `CALL`-time save/restore.
|
||||
**Backend helper calls must be allocator-visible.** The backend emits several
|
||||
`bsr` calls to runtime helpers that have *no IR-visible CALL edge* — the
|
||||
longmath routines (`p8_udivmod32`, `p8_sdivmod32`, `p8_umult32`, `p8_smult32`
|
||||
in `amiga500/m68k_prog8_longmath.asm`), `math._sqrt_*`, `prog8_lib.strcmp`.
|
||||
These take arguments in and return values in d0/d1 (caller-saved scratch), so
|
||||
under the convention each is a call that kills D0/D1/A0/A1/FP0/FP1. Every such
|
||||
emitted `bsr` must be modeled in the allocator as a call boundary — otherwise
|
||||
the allocator will hand a live value to d0 and the helper will silently eat it.
|
||||
The helpers themselves need no changes beyond honoring the §2.6 clobber policy
|
||||
(and the d7 reservation, §2.5); no new IR-layer helper infrastructure is
|
||||
required. The callee-saved prologue/epilogue (§6) is emitted inline per
|
||||
subroutine by `AsmGen`, not as a shared helper routine you `bsr` to.
|
||||
|
||||
The callee-saved prologue save/restore (§6) is the spill mechanism for values
|
||||
that must survive a call; it is *not* a `CALL`-time save/restore.
|
||||
|
||||
Recursion soundness has two halves: callee-saved prologue saves are already
|
||||
per-invocation, but spills must be stack/frame-based too before recursion is
|
||||
fully sound.
|
||||
|
||||
Per-subroutine vreg-number reuse (resetting `RegisterPool` per subroutine) is a
|
||||
safe optional extra *because* no value is live in a shared hardware register
|
||||
across a `CALL` — but the associated spill-area shrink only applies to
|
||||
stack/frame spills, per the BSS caveat above.
|
||||
|
||||
---
|
||||
|
||||
## 7. Prologue / Epilogue (callee-saved)
|
||||
## 6. Prologue / Epilogue (callee-saved)
|
||||
|
||||
For each subroutine, the codegen emits:
|
||||
|
||||
- At entry: save the callee-saved registers the subroutine actually uses
|
||||
(e.g. `movem.l d2-d7/a2-a5, -(sp)`), determined intraprocedurally.
|
||||
- At every exit (`rts`): restore them (`movem.l (sp)+, d2-d7/a2-a5`).
|
||||
(e.g. `movem.l d2-d6/a2-a4, -(sp)`), determined intraprocedurally.
|
||||
- At every exit (`rts`): restore them (`movem.l (sp)+, d2-d6/a2-a4`).
|
||||
|
||||
Only registers that are used are saved, keeping the cost minimal. This is the
|
||||
standard, cheap mechanism that makes per-subroutine register reuse and
|
||||
recursion sound — and it is precisely why the old packer's "save/restore is
|
||||
expensive" objection (which assumed saving the whole memory regfile) does not
|
||||
apply here: only a handful of hardware registers are involved.
|
||||
Only registers that are used are saved, keeping the cost minimal. The reserved
|
||||
registers (D7, A5, A6) never appear in the save mask by construction — the
|
||||
allocator never hands them out. A subroutine that uses callee-saved FPU
|
||||
registers gets a matching `fmovem fp2-fpN,-(sp)` / `fmovem (sp)+,fp2-fpN` pair.
|
||||
|
||||
---
|
||||
|
||||
## 8. Relationship to the (disabled) RegisterPacker
|
||||
## 7. Implementation Outline
|
||||
|
||||
`codeGenIntermediate/RegisterPacker.kt` is a graph-coloring register allocator
|
||||
— the same algorithm family as the true m68k allocator proposed here. It
|
||||
already implements, per subroutine: CFG construction, intraprocedural liveness
|
||||
(gen/kill fixed-point), live-interval building, interval merging, conflict-graph
|
||||
construction, greedy coloring, and instruction rewriting. Its only production
|
||||
call site (`IRCodeGen.kt:102-107`) is commented out, so today it runs only under
|
||||
`TestRegisterPacker`; its file header comment documents what it does in its
|
||||
present form.
|
||||
The order below is load-bearing. The failed first attempt (§1.1) implemented
|
||||
the *allocator* first and tried to retrofit the codegen onto it. That is
|
||||
backwards: instruction selection must become location-agnostic **first**, so
|
||||
that "where does this vreg live" is an input to codegen, not a rewrite of its
|
||||
output.
|
||||
|
||||
In its present form it is a **memory-slot** packer, not a hardware-register
|
||||
allocator:
|
||||
### Stage 0 — preparations (independently mergeable, no behavior change)
|
||||
|
||||
- It coalesces virtual registers with non-overlapping live ranges into *shared
|
||||
memory slots* in the flat `p8_regfile` BSS block.
|
||||
- It assigns slots from a **single global slot pool** (`startSlot = maxReg + 1`,
|
||||
shared `globalSlotTypes`), because the regfile is one block shared by all
|
||||
subroutines.
|
||||
- It never models `CALL` clobbering — there are no interference edges for
|
||||
caller/callee-saved registers at call sites. Packing a caller and its callee
|
||||
into the same slot lets the callee overwrite the caller's value. This is
|
||||
precisely the old plan's §4 "fundamentally unsound for a flat shared regfile"
|
||||
problem, and it is why the packer is disabled.
|
||||
These steps de-risk Stages 1–2 and can be done before any allocator exists.
|
||||
Each is small, contained, and verifiable on its own.
|
||||
|
||||
### 8.1 What is shared (the reusable core)
|
||||
1. **Catalog every `bsr`/`jsr` the backend emits and flag those with no IR
|
||||
call edge.** *Goal:* produce the definitive list of hidden call boundaries
|
||||
(the longmath helpers, `math._sqrt_*`, `prog8_lib.strcmp`, startup routines)
|
||||
that the allocator must treat as killing caller-saved registers (§5). Pure
|
||||
survey work; turns a class of "allocator silently corrupts a live value"
|
||||
bugs into a known, enumerated set.
|
||||
2. **Build the byte-identical-output test harness.** *Goal:* a corpus of
|
||||
`.p8ir` → asm where any diff fails the test. This is the Stage-1 gate; having
|
||||
it ready first means Stage 1 (the large, mechanical refactor) is reviewable
|
||||
the moment it lands, rather than trusting it blindly.
|
||||
3. **Introduce the scratch-register reservation API on `AsmGen`** and migrate
|
||||
the scattered ad hoc d0/d1/a0 `emitLine` references to it, keeping output
|
||||
byte-identical. *Goal:* remove the single biggest source of
|
||||
allocator-vs-handwritten-code register conflicts *before* the allocator is
|
||||
added, so Stage 2 doesn't have to chase clobbers through the translators.
|
||||
4. **Make the helper call sites convention-clean.** *Goal:* give each runtime
|
||||
helper a declared, caller-saved-only clobber set per §2.6 (and stop the
|
||||
ad hoc d7 clobbering noted in §2.5). Contained change that removes the
|
||||
"d7 clobbered regardless of IR state" special case and makes the helpers
|
||||
ordinary citizens of the convention.
|
||||
5. **Survey the reach of `ImmediateCallOptimization`.** *Goal:* know how large
|
||||
the Stage-2 step-9 decision (reimplement on allocator liveness vs restrict
|
||||
to spilled operands) actually is, so it can be scheduled rather than
|
||||
discovered late.
|
||||
|
||||
The true m68k allocator reuses the packer's machinery rather than rewriting it:
|
||||
### Stage 1 — location-agnostic instruction selection (prerequisite; the bulk of the work)
|
||||
|
||||
- CFG building (`buildCFG`).
|
||||
- Intraprocedural liveness (`computeLiveness`) — the gen/kill iterative
|
||||
dataflow.
|
||||
- Live-interval construction and merging (including the "always merge same
|
||||
register's intervals" fix so a value survives gaps between uses).
|
||||
- Conflict-graph construction (`buildConflictGraph`).
|
||||
- The greedy-color loop and instruction rewriting (`greedyColor`, `rewrite`).
|
||||
Replace every use of `regAddr(reg)` / `floatRegFileAddr(reg)` in *operand
|
||||
position* with a single indirection on `AsmGen`:
|
||||
|
||||
These mechanics are correct and carry over directly.
|
||||
```kotlin
|
||||
fun operand(reg: Int): String // "d3" if allocated, "p8_regfile+N" if spilled
|
||||
fun storeOperand(reg: Int): String // same, for destinations
|
||||
fun fpOperand(fpReg: Int): String // "fp2" / "p8_fregfile+N"
|
||||
```
|
||||
|
||||
### 8.2 What differs (the upgrade)
|
||||
All ~320 translator call sites then emit e.g. `add${s} d0, ${operand(dst)}`
|
||||
without knowing or caring where the value resides. Initially the mapping is
|
||||
empty and every query returns the regfile address — **stage 1 is behavior
|
||||
neutral by construction**, which is exactly what makes it safe to land and
|
||||
review on its own. Notes:
|
||||
|
||||
| Aspect | RegisterPacker (present form) | True m68k allocator |
|
||||
|-------------------|--------------------------------------|---------------------|
|
||||
| Color target | open-ended **memory slots** in `p8_regfile` | scarce **hardware registers** D0–D7 / A0–A6 / FP0–FP7, split by class |
|
||||
| Resource scarcity | none — `slot` just grows until free | hard cap → must **spill** to memory when full |
|
||||
| CALL handling | ignored (no interference edges) | `CALL` kills caller-saved, preserves callee-saved |
|
||||
| Code emission | only renumbers instructions | also emits prologue/epilogue `movem` saves for callee-saved regs |
|
||||
| Spilling | never happens | inserted under register pressure |
|
||||
| Inter-subroutine | global slot pool, coordination to avoid collisions | fully independent per sub (convention guarantees soundness) |
|
||||
- The sites that genuinely require memory (address-of `&`, byte sub-access via
|
||||
`regAddrByte`, inline-asm chunk references to `p8_regfile`) keep calling
|
||||
`regAddr()` directly. These define the "must be spilled" set for the
|
||||
allocator essentially for free.
|
||||
- The vreg side of `LOADHR`/`STOREHR` also goes through `operand()` so they
|
||||
degrade to register moves or no-ops under allocation (§2.8).
|
||||
|
||||
### 8.3 Why the calling convention makes it simpler
|
||||
**Indexed/indirect opcodes are a separate, non-mechanical sub-task.** Most
|
||||
translators fit the "swap `regAddr` for `operand()`" pattern, but a minority do
|
||||
not, because they *select* addressing modes around hardcoded scratch registers
|
||||
rather than merely naming an operand:
|
||||
|
||||
The central contrast is **interprocedural vs intraprocedural soundness**:
|
||||
- `LOADX` / `STOREX` use indexed addressing `(a0,d0.w*scale)`: the index vreg
|
||||
is forced into d0 via `loadIndexToD0` and the base into a0. Under allocation
|
||||
the index should resolve to its assigned register (`(a0,d3.w)`) and the scale
|
||||
handling (`muls.w #scale, d0`, `add.w d0,d0`, `lsl.w #2, d0`) must emit
|
||||
against that register, not hardcoded d0.
|
||||
- `LOADI` / `STOREI` / `STOREZX` force the pointer vreg into a0 via
|
||||
`loadPointerToA0` and then use `(off,a0)`. Under allocation a pointer vreg
|
||||
resident in a2 should become `(off,a2)` directly.
|
||||
- The float variants of these (`emitFloatMemBinary`, `emitFloatMemUnary`, etc.)
|
||||
have the same structure around `p8_fregfile` + a0.
|
||||
|
||||
- **Current RegisterPacker: soundness REQUIRES the full-program call tree.**
|
||||
Because the regfile is one flat memory block shared by every subroutine, two
|
||||
subroutines packed into the same slot number will clobber each other if one
|
||||
can call the other. To be sound, the packer must therefore reason
|
||||
interprocedurally — build the call graph and ensure a callee's slots never
|
||||
collide with any caller's live slots (the old plan's call-graph-aware
|
||||
allocation, §4(a)), or fall back to depth-range disjoint slots (§5), or
|
||||
save/restore around calls (§4(b)). All of these require analyzing the
|
||||
whole-program call tree. As written, the packer packs each sub independently
|
||||
into a shared global pool and is therefore unsound — precisely because this
|
||||
interprocedural reasoning is missing.
|
||||
For these, the §1.1 lesson ("different instruction selection, not different
|
||||
operand spelling") applies *within* Stage 1 itself: the translator must pick
|
||||
the addressing mode from the allocated register class (index register → Dn,
|
||||
pointer → An), which is genuine per-opcode re-selection, not a string swap.
|
||||
Treat them as a distinct work item with their own tests; do not assume the
|
||||
blanket `operand()` refactor covers them.
|
||||
|
||||
- **m68k allocator with the call convention: allocation is purely
|
||||
subroutine-by-subroutine.** Because the convention makes every `CALL` behave
|
||||
uniformly — kill caller-saved registers, preserve callee-saved registers via
|
||||
the callee's own prologue/epilogue — the allocator never needs to know which
|
||||
subroutine is the target. Each subroutine is colored in isolation onto the
|
||||
same fixed physical registers, and the result is sound by construction. No
|
||||
call graph, no call-tree propagation, no depth ranges. Indirect calls
|
||||
(`CALLI`) and recursion are handled identically (each invocation has its own
|
||||
stack frame for the prologue saves).
|
||||
**Scratch registers need only enforcement, not a reservation protocol.**
|
||||
Because D0/D1/A0/A1 are decided to be pure scratch that never holds an
|
||||
allocated vreg (§2.3), Stage 1 does *not* need an allocator-aware
|
||||
acquire/release system. The work is reduced to: (a) route the existing helpers
|
||||
(`loadRegOrZeroExtendToD0`, `loadPointerToA0`, `loadIndexToD0`) and the
|
||||
scattered `emitLine` references to d0/d1/a0/a1 through named constants
|
||||
(`SCRATCH_D0`, etc.) so there is a single place that owns the scratch set; and
|
||||
(b) add a debug-time assertion that the allocator never assigns a vreg to any
|
||||
of D0/D1/A0/A1/D7/A5/A6. This keeps the byte-identical checkpoint easily
|
||||
attainable and removes what would otherwise be the largest hidden design item
|
||||
in Stage 1. The one thing translators must *not* do is introduce a new
|
||||
hardcoded scratch register outside the D0/D1/A0/A1 set.
|
||||
|
||||
This removes the *hardest* complexity of the packer, which was never the
|
||||
coloring itself but **cross-subroutine soundness**:
|
||||
- **Checkpoint before starting Stage 2:** with an empty allocation map, the
|
||||
generated assembly must be byte-identical to the pre-change output for a
|
||||
corpus of test programs.
|
||||
|
||||
- **No global slot pool / collision coordination.** The packer needed
|
||||
`startSlot = maxReg + 1` plus a shared `globalSlotTypes` map so packed
|
||||
subroutines would not stomp each other in the flat regfile. The true allocator
|
||||
just maps each sub onto the *same fixed physical registers*; because no value
|
||||
is live in a shared register across a `CALL`, per-subroutine allocation is
|
||||
automatically sound and fully independent. No depth ranges, no call-graph
|
||||
propagation, no `skipRegs` juggling.
|
||||
- **Per-subroutine vreg reuse comes for free.** The `RegisterPool` can be
|
||||
reset per subroutine; the convention guarantees no clobber.
|
||||
- **Interprocedural analysis is optional, not required.** It can still be used
|
||||
for optimizations (leaf-sub detection to skip callee-saved saves,
|
||||
shrink-wrapping), but it is NOT needed for correctness — unlike the packer,
|
||||
where interprocedural reasoning is mandatory for soundness.
|
||||
### Stage 2 — the allocator decides the mapping, per subroutine
|
||||
|
||||
So the *allocation problem* becomes the textbook per-subroutine graph coloring
|
||||
the packer already demonstrates — minus the globality that made the packer's
|
||||
design convoluted and bug-prone.
|
||||
|
||||
### 8.4 What the true allocator adds that the packer dodged
|
||||
|
||||
It is not strictly fewer lines, because the true allocator takes on three things
|
||||
the packer entirely avoided:
|
||||
|
||||
1. **Register classes.** Real m68k splits D / A / FP files (pointers prefer A,
|
||||
floats in FP). That requires class-aware coloring, more structure than the
|
||||
packer's single undifferentiated slot space (which only had a float-vs-int
|
||||
type-compatibility check).
|
||||
2. **Spilling.** The packer never spills; it just grows `slot` until free. The
|
||||
true allocator has only ~16 general + 8 FP registers, so it *must* spill
|
||||
under pressure (pick a victim, insert store/load around the live range, to
|
||||
the stack or the BSS regfile).
|
||||
3. **Prologue/epilogue emission.** The packer only renumbers instructions; the
|
||||
true allocator emits `movem` save/restore for the callee-saved registers a
|
||||
subroutine uses.
|
||||
|
||||
**Net:** the convention trades the packer's *worst* complexity (interprocedural
|
||||
soundness, global coordination) for the *standard, well-understood* complexity
|
||||
of class-aware coloring + spilling. The result is more tractable and far less
|
||||
error-prone, simpler where it mattered most — just not literally fewer lines
|
||||
once spilling and classes are accounted for.
|
||||
|
||||
Once a true allocator is operational, the memory-slot packer becomes redundant
|
||||
for m68k (the regfile is only a spill area); on 6502, where "allocation" is
|
||||
mostly zero-page placement, the packing mindset remains relevant (see below).
|
||||
|
||||
---
|
||||
|
||||
## 9. Relationship to the 6502 (new6502) backend
|
||||
|
||||
This document is **m68k-specific**. On the 6502 the situation differs:
|
||||
|
||||
- Only three real registers (A, X, Y), and only A does arithmetic; X/Y are
|
||||
index registers. There is no wide register file to exploit.
|
||||
- The effective register file is **zero page** (256 bytes of fast RAM). A
|
||||
"register allocator" there means placing hot vregs into ZP (vs 4-cycle
|
||||
absolute `p8_regfile` access) plus transient A/X/Y use during expression
|
||||
evaluation — much closer to the old packer's memory-slot goal.
|
||||
- The same calling convention (no stack args, no save/restore at CALL,
|
||||
caller/callee-saved split applied to A/X/Y and ZP scratch) applies, but the
|
||||
payoff is far smaller than on m68k.
|
||||
|
||||
A 6502-specific design is left to a separate document; the depth-range packing
|
||||
approach from `RegisterPacker.kt` remains directly relevant there.
|
||||
|
||||
---
|
||||
|
||||
## 10. Implementation Outline
|
||||
|
||||
1. **Define convention constants** in the m68k backend: which registers are
|
||||
caller-saved vs callee-saved, return-value locations, asmsub arg slots.
|
||||
2. **Implement/repair intraprocedural liveness** (reuse `RegisterPacker`
|
||||
machinery; validate on complex CFGs: nested loops, conditionals, early
|
||||
returns, switches).
|
||||
1. **Define convention constants** in the m68k backend: the caller-saved vs
|
||||
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).
|
||||
3. **Class-aware greedy colouring**: map vregs → physical D/A/FP registers,
|
||||
treating `CALL` as killing caller-saved registers.
|
||||
4. **Emit prologue/epilogue** `movem` saves for the callee-saved registers the
|
||||
subroutine uses.
|
||||
5. **Spill under pressure** to the BSS regfile or the system stack.
|
||||
6. **Update `AsmGen`** to emit register-to-register instructions (e.g.
|
||||
`add.l d1, d0`) for allocated vregs instead of `p8_regfile` memory
|
||||
load/store round-trips.
|
||||
7. **Optionally reset `RegisterPool` per subroutine** to allow vreg reuse and
|
||||
shrink the spill area.
|
||||
4. **Wire the mapping into `operand()` / `storeOperand()` / `fpOperand()`.**
|
||||
Register-to-register code (`add.l d1, d0`) now falls out of the existing
|
||||
translators with no per-opcode special cases — this is the payoff of
|
||||
Stage 1.
|
||||
5. **Emit prologue/epilogue** `movem` saves for the callee-saved registers the
|
||||
subroutine uses (§6).
|
||||
6. **Spill under pressure**, preferably to the hardware stack (§5).
|
||||
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.
|
||||
10. **Optionally reset `RegisterPool` per subroutine** to allow vreg reuse
|
||||
(§5).
|
||||
|
||||
### Example (conceptual)
|
||||
### 7.1 Open Decision Points (not yet fully decided — needed before Stage 2 is delegable)
|
||||
|
||||
Current spill model:
|
||||
Stages 0, 1, and 3 are decided and can be handed to an agent as-is. Stage 2's
|
||||
*internals* still have open design questions where an agent would otherwise be
|
||||
designing rather than executing. These must be decided before Stage 2 is
|
||||
delegable:
|
||||
|
||||
```asm
|
||||
move.l p8_regfile+0, d0
|
||||
move.l p8_regfile+4, d1
|
||||
add.l d1, d0
|
||||
move.l d0, p8_regfile+8
|
||||
```
|
||||
1. **`operand()` / `storeOperand()` width semantics.** The signature is shown
|
||||
(`fun operand(reg: Int): String`) but the width-marshalling is unspecified:
|
||||
when a vreg is register-resident but used at a different width (byte vreg in
|
||||
a word op), does `operand()` zero/sign-extend, and into *which* register?
|
||||
Today `loadRegOrZeroExtendToD0` does this against d0; under allocation the
|
||||
extension has to target a defined register. This is the most-encountered
|
||||
decision across the ~320 call sites.
|
||||
2. **The class-constraint mechanism (§4.1 says *what*, not *how*).** We know
|
||||
index→DATA and pointer→ADDRESS, but not how the translator *communicates*
|
||||
the constraint to the allocator: a per-vreg constraint recorded in a
|
||||
pre-pass, a hint at allocation-call time, or an IR-level annotation. The
|
||||
constraint-propagation plumbing is undesigned.
|
||||
3. **Spill-slot allocation policy (§5 lists targets, not policy).** How spill
|
||||
slots are assigned per subroutine, whether spill code is inserted during
|
||||
coloring (rewriting the IR) or emitted at use points, and the push/pop vs
|
||||
frame-slot *mechanism* given the A5 frame model does not yet exist.
|
||||
4. **How liveness models CALL interference and the scratch registers
|
||||
concretely.** "CALL kills D0/D1/A0/A1/FP0/FP1" must be realized as kill
|
||||
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.
|
||||
|
||||
With the allocator (values already resident in registers):
|
||||
### Stage 3 — teardown of the old mitigation layer
|
||||
|
||||
```asm
|
||||
add.l d1, d0 ; both operands already in registers
|
||||
```
|
||||
Much of `AsmOptimizer` exists only to undo what memory-everywhere codegen
|
||||
produces (spill-then-reload bounces, immediate forwarding that matches literal
|
||||
`p8_regfile+N` operand strings — see the many `REGFILE-DEPENDENT` comments and
|
||||
`TestInstructionSelectionOptimizations`). Once Stage 2 lands:
|
||||
|
||||
Plus a subroutine prologue/epilogue for any callee-saved registers used:
|
||||
- Delete the peepholes that the allocator subsumes rather than adapting them.
|
||||
Keeping both is how the first attempt became "fragmented and complicated".
|
||||
- The instruction-selection optimizations that remain meaningful (e.g. constant
|
||||
folding into `moveq`, addressing-mode choices for true memory operands) stay,
|
||||
but must match against `operand()` output, not literal regfile strings.
|
||||
- Expect heavy churn in `TestInstructionSelectionOptimizations.kt`, which has
|
||||
~100 tests asserting exact `p8_regfile` instruction sequences. Rewrite these
|
||||
to assert properties (allocated vregs never appear as `p8_regfile` operands;
|
||||
specific sequences for known-small examples) instead of full listings.
|
||||
|
||||
```asm
|
||||
sub_label:
|
||||
movem.l d2-d7/a2-a5, -(sp) ; save used callee-saved regs
|
||||
; ... body ...
|
||||
movem.l (sp)+, d2-d7/a2-a5 ; restore before return
|
||||
rts
|
||||
```
|
||||
### Success metrics (tie back to §0)
|
||||
|
||||
For a set of representative programs (the m68k examples, TextElite):
|
||||
|
||||
- Fraction of vreg operand references that resolve to CPU registers vs
|
||||
`p8_regfile` — should be the large majority; regfile BSS size should shrink
|
||||
to a small spill area. Caveat: because multi-value returns stay memory-based
|
||||
(§2.7) and asmsub marshalling keeps a foot in the regfile (§2.8), the regfile
|
||||
remains load-bearing for programs that use those features; the "small spill
|
||||
area" target applies to single-return, low-asmsub programs.
|
||||
- Total instruction count and executable hunk size — expect a large reduction
|
||||
from eliminated load/store round-trips, *without* any peephole pass enabled.
|
||||
- Code shape review: generated subroutines should read as register-to-register
|
||||
code with prologue/epilogue, not as regfile memory ops with patches.
|
||||
|
||||
---
|
||||
|
||||
## 11. Test Coverage
|
||||
## 8. Test Coverage
|
||||
|
||||
Unit tests (in `codeGenM68k` and/or reusing `TestRegisterPacker.kt` structure)
|
||||
must cover:
|
||||
First, the Stage-1 gate (byte-identical output with an empty allocation map).
|
||||
Then, unit tests (in `codeGenM68k` and/or reusing `TestRegisterPacker.kt`
|
||||
structure) covering:
|
||||
|
||||
- Simple non-overlapping vregs coalesced into one hardware register.
|
||||
- Overlapping live ranges forced into different registers.
|
||||
@@ -438,3 +677,14 @@ must cover:
|
||||
- asmsub/extsub argument slots (D0–D2, FP0–FP1) and return values.
|
||||
- Float values routed through FP registers.
|
||||
- Pointers preferred into address registers.
|
||||
- Reserved registers (D7, A5, A6) never allocated, even under extreme register
|
||||
pressure.
|
||||
- Values live in callee-saved registers across a `repeat` loop survive the
|
||||
loop's use of d7.
|
||||
- An asmsub declaring a callee-saved or reserved register in `clobbers(...)`
|
||||
is rejected at compile time.
|
||||
- Return value marshalled through D0/FP0 across a call, including when the
|
||||
caller's destination vreg is allocated to a different physical register.
|
||||
- Caller and callee both spilling in a call chain: stack/frame spills do not
|
||||
collide; BSS spill slots are program-wide unique.
|
||||
- Interrupt handlers save/restore every register they touch.
|
||||
@@ -74,46 +74,34 @@ class IRProgram(val name: String,
|
||||
fun allSubs(): Sequence<IRSubroutine> = blocks.asSequence().flatMap { it.children.filterIsInstance<IRSubroutine>() }
|
||||
fun allAsmSubs(): Sequence<IRAsmSubroutine> = blocks.asSequence().flatMap { it.children.filterIsInstance<IRAsmSubroutine>() }
|
||||
fun foreachSub(operation: (sub: IRSubroutine) -> Unit) = allSubs().forEach { operation(it) }
|
||||
fun foreachCodeChunk(operation: (chunk: IRCodeChunkBase) -> Unit) {
|
||||
fun recurse(chunk: IRCodeChunkBase) {
|
||||
operation(chunk)
|
||||
if(chunk is IRLoopChunk) chunk.body.forEach { recurse(it) }
|
||||
}
|
||||
allSubs().flatMap { it.chunks }.forEach { recurse(it) }
|
||||
allAsmSubs().forEach { recurse(it.asmChunk) }
|
||||
recurse(globalInits)
|
||||
// Note: globalInits body never contains loops, but handle uniformly via recurse
|
||||
// globalInits is itself counted above via recurse
|
||||
|
||||
/**
|
||||
* Visit every chunk in the program in a stable deterministic order,
|
||||
* recursing through nested [IRLoopChunk] bodies. Inline assembly and
|
||||
* binary chunks are visited as chunks even though they carry no IR instructions.
|
||||
*/
|
||||
fun forEachChunk(action: (chunk: IRCodeChunkBase) -> Unit) {
|
||||
allSubs().flatMap { it.chunks }.forEach { it.forEachChunkRecursive(action) }
|
||||
allAsmSubs().forEach { it.asmChunk.forEachChunkRecursive(action) }
|
||||
// Top-level block chunks (not inside a subroutine) are rare but must not be missed.
|
||||
blocks.asSequence()
|
||||
.flatMap { it.children.asSequence() }
|
||||
.filterIsInstance<IRCodeChunkBase>()
|
||||
.forEach { it.forEachChunkRecursive(action) }
|
||||
globalInits.forEachChunkRecursive(action)
|
||||
}
|
||||
|
||||
// helper to count instructions including inside loops
|
||||
private fun countInstructionsRecursive(chunk: IRCodeChunkBase): Int {
|
||||
return when(chunk) {
|
||||
is IRLoopChunk -> chunk.body.sumOf { countInstructionsRecursive(it) }
|
||||
else -> chunk.instructions.size
|
||||
}
|
||||
}
|
||||
private fun countChunksRecursive(chunk: IRCodeChunkBase): Int {
|
||||
return when(chunk) {
|
||||
is IRLoopChunk -> 1 + chunk.body.sumOf { countChunksRecursive(it) }
|
||||
else -> 1
|
||||
}
|
||||
fun forEachInstruction(action: (IRInstruction) -> Unit) {
|
||||
forEachChunk { chunk -> chunk.instructions.forEach(action) }
|
||||
}
|
||||
|
||||
fun foreachCodeChunk(operation: (chunk: IRCodeChunkBase) -> Unit) = forEachChunk(operation)
|
||||
|
||||
fun countCodeElements(): Pair<Int, Int> {
|
||||
var numInstr = 0
|
||||
var numChunks = 0
|
||||
// count via top-level chunks recursively
|
||||
allSubs().flatMap { it.chunks }.forEach { chunk ->
|
||||
numChunks += countChunksRecursive(chunk)
|
||||
numInstr += countInstructionsRecursive(chunk)
|
||||
}
|
||||
allAsmSubs().forEach { chunk ->
|
||||
numChunks += countChunksRecursive(chunk.asmChunk)
|
||||
numInstr += countInstructionsRecursive(chunk.asmChunk)
|
||||
}
|
||||
numChunks += countChunksRecursive(globalInits)
|
||||
numInstr += countInstructionsRecursive(globalInits)
|
||||
forEachChunk { numChunks++ }
|
||||
forEachInstruction { numInstr++ }
|
||||
return Pair(numInstr, numChunks)
|
||||
}
|
||||
|
||||
@@ -124,29 +112,12 @@ class IRProgram(val name: String,
|
||||
}
|
||||
|
||||
fun getChunkWithLabel(label: String): IRCodeChunkBase {
|
||||
fun search(chunk: IRCodeChunkBase): IRCodeChunkBase? {
|
||||
if(chunk.label==label) return chunk
|
||||
if(chunk is IRLoopChunk) {
|
||||
for(c in chunk.body) {
|
||||
val found = search(c)
|
||||
if(found!=null) return found
|
||||
}
|
||||
}
|
||||
return null
|
||||
var found: IRCodeChunkBase? = null
|
||||
forEachChunk { chunk ->
|
||||
if(found==null && chunk.label==label)
|
||||
found = chunk
|
||||
}
|
||||
for(sub in allSubs()) {
|
||||
for(chunk in sub.chunks) {
|
||||
val found = search(chunk)
|
||||
if(found!=null) return found
|
||||
}
|
||||
}
|
||||
for(sub in allAsmSubs()) {
|
||||
val found = search(sub.asmChunk)
|
||||
if(found!=null) return found
|
||||
}
|
||||
val foundGlobal = search(globalInits)
|
||||
if(foundGlobal!=null) return foundGlobal
|
||||
throw NoSuchElementException("no chunk with label '$label'")
|
||||
return found ?: throw NoSuchElementException("no chunk with label '$label'")
|
||||
}
|
||||
|
||||
fun addGlobalInits(chunk: IRCodeChunk) {
|
||||
@@ -164,10 +135,7 @@ class IRProgram(val name: String,
|
||||
|
||||
fun linkChunks() {
|
||||
fun collectLabels(chunk: IRCodeChunkBase, map: MutableMap<String?, IRCodeChunkBase>) {
|
||||
map[chunk.label] = chunk
|
||||
if(chunk is IRLoopChunk) {
|
||||
chunk.body.forEach { collectLabels(it, map) }
|
||||
}
|
||||
chunk.forEachChunkRecursive { map[it.label] = it }
|
||||
}
|
||||
fun getLabeledChunks(): Map<String?, IRCodeChunkBase> {
|
||||
val result = mutableMapOf<String?, IRCodeChunkBase>()
|
||||
@@ -418,29 +386,14 @@ class IRProgram(val name: String,
|
||||
// on 32-bit targets LOADX/STOREX/STOREZX use a word index register (0-32767) instead of a byte (0-255)
|
||||
val indexRegType = options.compTarget.indexRegType
|
||||
|
||||
globalInits.instructions.forEach {
|
||||
it.addUsedRegistersCounts(
|
||||
readRegsCounts,
|
||||
writeRegsCounts,
|
||||
readFpRegsCounts,
|
||||
writeFpRegsCounts,
|
||||
regsTypes,
|
||||
globalInits,
|
||||
indexRegType
|
||||
)
|
||||
}
|
||||
|
||||
blocks.forEach {block ->
|
||||
block.children.forEach { child ->
|
||||
when(child) {
|
||||
is IRAsmSubroutine -> addUsed(child.usedRegisters(), child)
|
||||
is IRCodeChunk -> addUsed(child.usedRegisters(indexRegType), child)
|
||||
is IRInlineAsmChunk -> addUsed(child.usedRegisters(indexRegType), child)
|
||||
is IRInlineBinaryChunk -> addUsed(child.usedRegisters(indexRegType), child)
|
||||
is IRLoopChunk -> addUsed(child.usedRegisters(indexRegType), child)
|
||||
is IRSubroutine -> child.chunks.forEach { chunk -> addUsed(chunk.usedRegisters(indexRegType), child) }
|
||||
}
|
||||
}
|
||||
// Aggregate via uniform traversal. Loop containers themselves carry no
|
||||
// instructions; only leaf chunks contribute, so nested bodies are
|
||||
// counted exactly once. Inline-asm chunks contribute via their parsed
|
||||
// usedRegisters() because their instructions list is empty.
|
||||
forEachChunk { chunk ->
|
||||
if(chunk is IRLoopChunk)
|
||||
return@forEachChunk
|
||||
addUsed(chunk.usedRegisters(indexRegType), chunk)
|
||||
}
|
||||
return RegistersUsed(readRegsCounts, writeRegsCounts, readFpRegsCounts, writeFpRegsCounts, regsTypes)
|
||||
}
|
||||
@@ -599,21 +552,10 @@ class IRProgram(val name: String,
|
||||
*/
|
||||
fun verifyRegisterTypes(registerTypes: Map<RegisterNum, IRDataType>) {
|
||||
val indexRegType = options.compTarget.indexRegType
|
||||
for(block in blocks) {
|
||||
for(bc in block.children) {
|
||||
when(bc) {
|
||||
is IRAsmSubroutine -> bc.usedRegisters().validate(registerTypes, null)
|
||||
is IRCodeChunk -> bc.usedRegisters(indexRegType).validate(registerTypes, bc)
|
||||
is IRInlineAsmChunk -> bc.usedRegisters(indexRegType).validate(registerTypes, bc)
|
||||
is IRInlineBinaryChunk -> bc.usedRegisters(indexRegType).validate(registerTypes, bc)
|
||||
is IRLoopChunk -> bc.usedRegisters(indexRegType).validate(registerTypes, bc)
|
||||
is IRSubroutine -> {
|
||||
for(sc in bc.chunks) {
|
||||
sc.usedRegisters(indexRegType).validate(registerTypes, sc)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
forEachChunk { chunk ->
|
||||
if(chunk is IRLoopChunk)
|
||||
return@forEachChunk
|
||||
chunk.usedRegisters(indexRegType).validate(registerTypes, chunk)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -680,6 +622,20 @@ class IRSubroutine(
|
||||
|
||||
override fun isEmpty(): Boolean = chunks.isEmpty() || chunks.all { it.isEmpty() }
|
||||
override fun isNotEmpty(): Boolean = !isEmpty()
|
||||
|
||||
/**
|
||||
* Visit every chunk in the subroutine in stable deterministic depth-first
|
||||
* order, recursing through nested [IRLoopChunk] bodies. The loop chunk
|
||||
* itself is visited before its body. Inline assembly and binary chunks
|
||||
* are visited as chunks even though they carry no IR instructions.
|
||||
*/
|
||||
fun forEachChunk(action: (IRCodeChunkBase) -> Unit) {
|
||||
chunks.forEach { it.forEachChunkRecursive(action) }
|
||||
}
|
||||
|
||||
fun forEachInstruction(action: (IRInstruction) -> Unit) {
|
||||
forEachChunk { chunk -> chunk.instructions.forEach(action) }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -716,6 +672,16 @@ sealed class IRCodeChunkBase(override val label: String?, var next: IRCodeChunkB
|
||||
abstract override fun isEmpty(): Boolean
|
||||
abstract override fun isNotEmpty(): Boolean
|
||||
abstract fun usedRegisters(indexRegType: IRDataType = IRDataType.BYTE): RegistersUsed
|
||||
|
||||
/**
|
||||
* Visit this chunk and, for [IRLoopChunk], all nested body chunks
|
||||
* depth-first. The receiver is visited first, then its body in order.
|
||||
*/
|
||||
fun forEachChunkRecursive(action: (IRCodeChunkBase) -> Unit) {
|
||||
action(this)
|
||||
if(this is IRLoopChunk)
|
||||
body.forEach { it.forEachChunkRecursive(action) }
|
||||
}
|
||||
}
|
||||
|
||||
class IRCodeChunk(label: String?, next: IRCodeChunkBase?): IRCodeChunkBase(label, next) {
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
import io.kotest.core.spec.style.FunSpec
|
||||
import io.kotest.matchers.shouldBe
|
||||
import prog8.code.core.CompilationOptions
|
||||
import prog8.code.core.OutputType
|
||||
import prog8.code.core.Position
|
||||
import prog8.code.core.ZeropageType
|
||||
import prog8.code.target.VMTarget
|
||||
import prog8.intermediate.*
|
||||
|
||||
class TestIRTraversal: FunSpec({
|
||||
|
||||
fun makeProgram(sub: IRSubroutine): IRProgram {
|
||||
val target = VMTarget()
|
||||
val options = CompilationOptions.builder(target)
|
||||
.output(OutputType.RAW)
|
||||
.zeropage(ZeropageType.DONTUSE)
|
||||
.noSysInit(true)
|
||||
.compilerVersion("99.99")
|
||||
.build()
|
||||
val prog = IRProgram("test-traversal", IRSymbolTable(), options, target)
|
||||
val block = IRBlock("p8b_main", false, IRBlock.Options(), Position.DUMMY)
|
||||
block += sub
|
||||
prog.addBlock(block)
|
||||
return prog
|
||||
}
|
||||
|
||||
fun makeNestedLoopSub(): IRSubroutine {
|
||||
val sub = IRSubroutine("p8b_main.p8s_start", emptyList(), emptyList(), Position.DUMMY)
|
||||
val head = IRCodeChunk("p8b_main.p8s_start", null)
|
||||
head += IRInstruction(Opcode.LOAD, IRDataType.BYTE, reg1=1, immediate=10)
|
||||
sub += head
|
||||
|
||||
// inner-most body uses r99 exclusively inside nested loop
|
||||
val innerBody = IRCodeChunk(null, null)
|
||||
innerBody += IRInstruction(Opcode.LOAD, IRDataType.BYTE, reg1=99, immediate=42)
|
||||
innerBody += IRInstruction(Opcode.STOREM, IRDataType.BYTE, reg1=99, labelSymbol="sym")
|
||||
val innerLoop = IRLoopChunk("p8_label_inner", 2, mutableListOf(innerBody))
|
||||
|
||||
val outerBody1 = IRCodeChunk(null, null)
|
||||
outerBody1 += IRInstruction(Opcode.NOP)
|
||||
val outerBody2 = IRCodeChunk(null, null)
|
||||
outerBody2 += IRInstruction(Opcode.NOP)
|
||||
val outerLoop = IRLoopChunk("p8_label_outer", 3, mutableListOf(outerBody1, innerLoop, outerBody2))
|
||||
sub += outerLoop
|
||||
|
||||
val tail = IRCodeChunk(null, null)
|
||||
tail += IRInstruction(Opcode.RETURN)
|
||||
sub += tail
|
||||
return sub
|
||||
}
|
||||
|
||||
test("forEachChunk visits nested loop bodies in deterministic order") {
|
||||
val sub = makeNestedLoopSub()
|
||||
val visited = mutableListOf<IRCodeChunkBase>()
|
||||
sub.forEachChunk { visited += it }
|
||||
// head, outer loop, outerBody1, inner loop, innerBody, outerBody2, tail
|
||||
visited.size shouldBe 7
|
||||
visited[0].label shouldBe "p8b_main.p8s_start"
|
||||
(visited[1] as IRLoopChunk).label shouldBe "p8_label_outer"
|
||||
(visited[3] as IRLoopChunk).label shouldBe "p8_label_inner"
|
||||
visited[4].instructions.size shouldBe 2
|
||||
}
|
||||
|
||||
test("forEachInstruction finds register used only inside nested loop") {
|
||||
val sub = makeNestedLoopSub()
|
||||
val regs = mutableSetOf<Int?>()
|
||||
sub.forEachInstruction { regs += it.reg1 }
|
||||
(99 in regs) shouldBe true
|
||||
}
|
||||
|
||||
test("program forEachInstruction and registersUsed find loop-body register") {
|
||||
val prog = makeProgram(makeNestedLoopSub())
|
||||
val regs = mutableSetOf<Int?>()
|
||||
prog.forEachInstruction { regs += it.reg1 }
|
||||
(99 in regs) shouldBe true
|
||||
|
||||
val used = prog.registersUsed()
|
||||
(RegisterNum(99) in used.readRegs || RegisterNum(99) in used.writeRegs) shouldBe true
|
||||
}
|
||||
|
||||
test("program forEachInstruction visits top-level chunks and nested loops") {
|
||||
val prog = makeProgram(makeNestedLoopSub())
|
||||
val block = prog.blocks.first()
|
||||
val topLevel = IRCodeChunk(null, null)
|
||||
topLevel += IRInstruction(Opcode.LOAD, IRDataType.BYTE, reg1=77, immediate=1)
|
||||
block += topLevel
|
||||
val loopBody = IRCodeChunk(null, null)
|
||||
loopBody += IRInstruction(Opcode.LOAD, IRDataType.BYTE, reg1=88, immediate=2)
|
||||
block += IRLoopChunk("p8_label_top_level", 2, mutableListOf(loopBody))
|
||||
|
||||
val regs = mutableSetOf<Int?>()
|
||||
prog.forEachInstruction { regs += it.reg1 }
|
||||
|
||||
(77 in regs) shouldBe true
|
||||
(88 in regs) shouldBe true
|
||||
}
|
||||
|
||||
test("chunk recursive helper visits self then body") {
|
||||
val body = IRCodeChunk(null, null)
|
||||
body += IRInstruction(Opcode.NOP)
|
||||
val loop = IRLoopChunk("p8_label_gen_1", 5, mutableListOf(body))
|
||||
val visited = mutableListOf<IRCodeChunkBase>()
|
||||
loop.forEachChunkRecursive { visited += it }
|
||||
visited.size shouldBe 2
|
||||
(visited[0] is IRLoopChunk) shouldBe true
|
||||
(visited[1] is IRCodeChunk) shouldBe true
|
||||
}
|
||||
})
|
||||
Reference in new issue
Block a user