register packer does't work well

This commit is contained in:
Irmen de Jong committed 2026-07-16 20:33:27 +02:00
1 parent 3499121f9c
commit b0cf649c2d
9 files changed
+609 -111

No files matched your search

@@ -84,12 +84,15 @@ class IRCodeGen(
optimizer.optimize(options.optimize, errors)
// Register packing: reduce distinct virtual registers by coalescing non-overlapping live ranges.
// Only applies to non-virtual targets (the VM has unlimited registers and doesn't benefit).
if(options.optimize && options.compTarget.name!=VMTarget.NAME) {
RegisterPacker.pack(irProg)
registers.resetTypes(RegisterPacker.rebuildTypeMap(irProg))
wasPackingApplied = true
}
// Subroutines at different call depths should get disjoint slot ranges to avoid caller/callee
// clashes. Currently DISABLED - see register-packing.md for the plan and what needs fixing.
// TODO: re-enable once the depth-range approach is implemented.
//if(options.optimize && options.compTarget.name!=VMTarget.NAME) {
// RegisterPacker.pack(irProg)
// registers.resetTypes(RegisterPacker.rebuildTypeMap(irProg))
// irProg.wasPackingApplied = true
// wasPackingApplied = true
//}
irProg.validate()
@@ -27,7 +27,8 @@ object RegisterPacker {
val globalSlotTypes = mutableMapOf<Int, IRDataType>()
irProg.foreachSub { sub -> packSubroutine(sub, allRegTypes, globalSlotTypes, startSlot) }
val afterCount = irProg.registersUsed().let { (it.readRegs.keys + it.writeRegs.keys).size }
val afterTypes = rebuildTypeMap(irProg)
val afterCount = afterTypes.size
if (!irProg.options.quiet)
println("Register packing: $beforeCount -> $afterCount registers")
}
@@ -108,8 +109,9 @@ object RegisterPacker {
for (r in written) {
if (r in liveSet) {
val end = lastUse.getOrElse(r) { globalI }
val actualType = getRegisterType(instr, r)
registerIntervals.getOrPut(r) { mutableListOf() }
.add(Interval(r, globalI, end, registerTypes.getOrElse(r) { IRDataType.BYTE }))
.add(Interval(r, globalI, end, actualType))
liveSet.remove(r)
lastUse.remove(r)
}
@@ -127,21 +129,29 @@ object RegisterPacker {
if (registerIntervals.isEmpty())
return
// Merge adjacent/overlapping intervals for each register
// Merge adjacent/overlapping intervals for each register.
// Also merge non-overlapping intervals of the same register into a single
// contiguous range, because the value in the slot must persist across the
// gap to the next use (otherwise another register packed to the same slot
// would clobber it between intervals).
val mergedIntervals = mutableListOf<Interval>()
val skipRegs = mutableSetOf<Int>()
for ((reg, intervals) in registerIntervals) {
val sorted = intervals.sortedBy { it.start }
var current = sorted.first()
for (next in sorted.drop(1)) {
if (next.start <= current.end + 1) {
current = Interval(reg, current.start, maxOf(current.end, next.end), current.type)
} else {
mergedIntervals.add(current)
current = next
if (current.type != next.type && !typesCompatible(current.type, next.type)) {
skipRegs.add(reg)
}
// Always merge: same register's intervals are merged into a single
// contiguous range to preserve the value across gaps.
current = Interval(reg, current.start, maxOf(current.end, next.end), current.type)
}
mergedIntervals.add(current)
}
if (skipRegs.isNotEmpty()) {
mergedIntervals.removeAll { it.register in skipRegs }
}
val conflictGraph = buildConflictGraph(sub)
val packing = greedyColor(mergedIntervals, conflictGraph, allRegTypes, globalSlotTypes, startSlot)
@@ -1,6 +1,8 @@
import io.kotest.assertions.throwables.shouldThrow
import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.shouldBe
import io.kotest.matchers.shouldNotBe
import io.kotest.matchers.string.shouldContain
import prog8.code.core.CompilationOptions
import prog8.code.core.OutputType
import prog8.code.core.Position
@@ -396,4 +398,143 @@ class TestRegisterPacker: FunSpec({
for (rn in allRegNums)
rn shouldNotBe 5
}
test("strict registersUsed throws on incompatible types across subroutines") {
// Regression: registersUsed() in strict mode (no packing applied) must detect
// the same register number used with incompatible types in different subroutines.
val target = VMTarget()
val options = CompilationOptions.builder(target)
.output(OutputType.RAW)
.zeropage(ZeropageType.DONTUSE)
.noSysInit(true)
.compilerVersion("99.99")
.build()
val prog = IRProgram("test", IRSymbolTable(), options, target)
val block = IRBlock("p8b_main", false, IRBlock.Options(), Position.DUMMY)
// Sub 1: uses r37 as POINTER
val sub1 = IRSubroutine("p8b_main.sub1", emptyList(), emptyList(), Position.DUMMY)
val c1 = IRCodeChunk("p8b_main.sub1", null)
c1 += IRInstruction(Opcode.LOAD, IRDataType.POINTER, reg1=37, immediate=0x1000)
c1 += IRInstruction(Opcode.STOREM, IRDataType.POINTER, reg1=37, labelSymbol="sp")
sub1 += c1
// Sub 2: uses same r37 as BYTE (incompatible)
val sub2 = IRSubroutine("p8b_main.sub2", emptyList(), emptyList(), Position.DUMMY)
val c2 = IRCodeChunk("p8b_main.sub2", null)
c2 += IRInstruction(Opcode.LOAD, IRDataType.BYTE, reg1=37, immediate=42)
c2 += IRInstruction(Opcode.STOREM, IRDataType.BYTE, reg1=37, labelSymbol="sb")
sub2 += c2
block += sub1
block += sub2
prog.addBlock(block)
prog.linkChunks()
prog.validate()
// No packing applied - strict mode should detect the incompatibility
val ex = shouldThrow<IllegalArgumentException> {
prog.registersUsed()
}
ex.message shouldContain "register r37 given multiple types"
ex.message shouldContain "POINTER"
ex.message shouldContain "BYTE"
}
test("intra-subroutine cross-type register is skipped by packer") {
// When a register has non-overlapping intervals with incompatible types (e.g.
// BYTE and LONG in different chunks), the packer skips it entirely because its
// single-entry packing map can't handle a register with multiple types.
// After packing, the register retains its original number and is not rewritten.
// registersUsed() in permissive mode uses putIfAbsent and should not throw.
val target = VMTarget()
val options = CompilationOptions.builder(target)
.output(OutputType.RAW)
.zeropage(ZeropageType.DONTUSE)
.noSysInit(true)
.compilerVersion("99.99")
.build()
val prog = IRProgram("test", IRSymbolTable(), options, target)
val block = IRBlock("p8b_main", false, IRBlock.Options(), Position.DUMMY)
// Single subroutine with two chunks using same register with incompatible types
val sub = IRSubroutine("p8b_main.sub1", emptyList(), emptyList(), Position.DUMMY)
val c1 = IRCodeChunk("p8b_main.sub1", null)
c1 += IRInstruction(Opcode.LOAD, IRDataType.POINTER, reg1=37, immediate=0x1000)
c1 += IRInstruction(Opcode.STOREM, IRDataType.POINTER, reg1=37, labelSymbol="sp")
sub += c1
val c2 = IRCodeChunk("p8b_main.sub1.c2", c1)
c2 += IRInstruction(Opcode.LOAD, IRDataType.BYTE, reg1=37, immediate=42)
c2 += IRInstruction(Opcode.STOREM, IRDataType.BYTE, reg1=37, labelSymbol="sb")
sub += c2
block += sub
prog.addBlock(block)
prog.linkChunks()
prog.validate()
RegisterPacker.pack(prog)
prog.wasPackingApplied = true
// registersUsed() in permissive mode: putIfAbsent, should not throw
val used = prog.registersUsed()
// r37 should still appear (was skipped by packer)
used.regsTypes.containsKey(RegisterNum(37)) shouldBe true
}
test("disjoint intervals of same register must be merged into one contiguous range") {
// Regression: if register r1 has disjoint intervals (written in chunk 1,
// read in chunk 3 with a gap in chunk 2), the packer must merge them into
// a single interval spanning the gap. Otherwise another register (r2) with
// an interval in chunk 2 could share the same slot and clobber r1's value.
val target = VMTarget()
val options = CompilationOptions.builder(target)
.output(OutputType.RAW)
.zeropage(ZeropageType.DONTUSE)
.noSysInit(true)
.compilerVersion("99.99")
.build()
val prog = IRProgram("test", IRSymbolTable(), options, target)
val block = IRBlock("p8b_main", false, IRBlock.Options(), Position.DUMMY)
val sub = IRSubroutine("p8b_main.sub1", emptyList(), emptyList(), Position.DUMMY)
// Chunk 1: write r1 (value should persist to chunk 3)
val c1 = IRCodeChunk("p8b_main.sub1", null)
c1 += IRInstruction(Opcode.LOAD, IRDataType.BYTE, reg1=1, immediate=42)
c1 += IRInstruction(Opcode.STOREM, IRDataType.BYTE, reg1=1, labelSymbol="s1")
sub += c1
// Chunk 2: write and read r2 (this interval falls in the gap between r1's uses)
val c2 = IRCodeChunk("p8b_main.sub1.c2", c1)
c2 += IRInstruction(Opcode.LOAD, IRDataType.BYTE, reg1=2, immediate=99)
c2 += IRInstruction(Opcode.STOREM, IRDataType.BYTE, reg1=2, labelSymbol="s2")
sub += c2
// Chunk 3: read r1 (uses the value from chunk 1)
val c3 = IRCodeChunk("p8b_main.sub1.c3", c2)
c3 += IRInstruction(Opcode.LOADR, IRDataType.BYTE, reg1=3, reg2=1)
c3 += IRInstruction(Opcode.STOREM, IRDataType.BYTE, reg1=3, labelSymbol="s3")
sub += c3
block += sub
prog.addBlock(block)
prog.linkChunks()
prog.validate()
RegisterPacker.pack(prog)
// After packing, r1 and r2 must be in DIFFERENT slots because r1's
// merged interval spans [0..?] covering the gap, preventing r2
// from sharing the same slot.
val instructions = prog.instructions()
// Find the register numbers used for the STOREM to s1 (was r1) and s2 (was r2)
val s1Reg = instructions.first { it.labelSymbol=="s1" }.reg1
val s2Reg = instructions.first { it.labelSymbol=="s2" }.reg1
// They must be different (can't share a slot)
s1Reg shouldNotBe null
s2Reg shouldNotBe null
s1Reg shouldNotBe s2Reg
}
})
@@ -24,9 +24,9 @@ class New6502CodeGenerator(val retainSSA: Boolean,
// but you can also use the Intermediate Representation to build a codegen on:
val irCodeGen = IRCodeGen(program, symbolTable, options, errors, retainSSA, preassignedCallSiteIds)
val irProgram = irCodeGen.generate()
irProgram.verifyRegisterTypes(irCodeGen.registerTypes())
if (!irCodeGen.wasPackingApplied)
irProgram.verifyRegisterTypes(irCodeGen.registerTypes())
// TODO remove this later: also write the IR program to disk for now for debugging purposes
IRFileWriter(irProgram, null).write()
val gen = AsmGen(irProgram, irProgram.options.compTarget)
+3
View File
@@ -4,6 +4,8 @@ TODO
- need a bunch of type casting/conversion checks that test the handling of the 4-byte/long pointer datatype on the qemu68k target.
- some generated label names in the m68k codegen can maybe replaced by local/anonymous labels?
- amiga library structs: use more typed pointers if it knows the struct type , rather than using `pointer`
- the amiga LVO's in the p8ir (and thus in the .asm) end up as full unsigned longs dos.Open = $ffffffe2 these should be negative decimal numerics instead. extsub addresses up to $7ffffff should just be positive hexadecimals like before.
- is there a way to add the symbolic name for the LVO as comment after the assembly JSR -666(a6) ; Lib call name here Or maybe replace the numeric offset -666 by the sumbolic name itself.
m68k Codegen: FPU register allocation (68881 only has fp0-fp7)
@@ -87,6 +89,7 @@ Optimizations
- Port more benchmarks from https://thred.github.io/c-bench-64/ to prog8 and see how it stacks up. (see benchmark-c/ directory)
- Compilation speed: try to join multiple modifications in 1 result in the AST processors instead of returning it straight away every time
- various optimizers skip stuff if compTarget.name==VMTarget.NAME. Once new 6502 codegen is done from IR code, those 6502 only optimizations should probably be removed
- **Register packer** (`RegisterPacker.kt`) look in register-packing.md for all details
Dead Code Elimination bug in 64tass, for nested subroutines
+23 -11
View File
@@ -1,19 +1,31 @@
%import textio
%import graphics
; This example computes the first 20 values of the Fibonacci sequence.
; Note: this program can be compiled for multiple target systems.
; Note that the fibonacci subroutine keeps is state in two outer variables
; so that every call to it is able to produce the next single number in the sequence.
main {
sub start() {
str buffer = "?"*80
txt.print("fibonacci sequence\n")
repeat 60
sys.waitvsync()
repeat 21 {
txt.print_uw(fib_next())
txt.nl()
}
txt.print("Enter your name: ")
txt.print_ub(txt.input_chars(buffer))
txt.nl()
txt.print("Hello, ")
txt.print(buffer)
txt.print("!\n")
sys.poweroff_system()
}
uword fib_prev = 0
uword fib_current = 1
sub fib_next() -> uword {
uword result = fib_prev
uword new = fib_current + fib_prev
fib_prev = fib_current
fib_current = new
return result
}
}
@@ -63,6 +63,7 @@ class IRProgram(val name: String,
val asmSymbols = mutableMapOf<String, String>()
val globalInits = IRCodeChunk(null, null)
val blocks = mutableListOf<IRBlock>()
var wasPackingApplied = false
fun allSubs(): Sequence<IRSubroutine> = blocks.asSequence().flatMap { it.children.filterIsInstance<IRSubroutine>() }
fun allAsmSubs(): Sequence<IRAsmSubroutine> = blocks.asSequence().flatMap { it.children.filterIsInstance<IRAsmSubroutine>() }
@@ -275,7 +276,7 @@ class IRProgram(val name: String,
}
}
fun registersUsed(): RegistersUsed {
fun registersUsed(permissive: Boolean = wasPackingApplied): RegistersUsed {
val readRegsCounts = mutableMapOf<RegisterNum, Int>().withDefault { 0 }
val regsTypes = mutableMapOf<RegisterNum, IRDataType>()
val readFpRegsCounts = mutableMapOf<RegisterNum, Int>().withDefault { 0 }
@@ -287,18 +288,27 @@ class IRProgram(val name: String,
usedRegisters.writeRegs.forEach{ (reg, count) -> writeRegsCounts[reg] = writeRegsCounts.getValue(reg) + count }
usedRegisters.readFpRegs.forEach{ (reg, count) -> readFpRegsCounts[reg] = readFpRegsCounts.getValue(reg) + count }
usedRegisters.writeFpRegs.forEach{ (reg, count) -> writeFpRegsCounts[reg] = writeFpRegsCounts.getValue(reg) + count }
if(permissive) {
// After register packing, the same slot number may be used with different types
// in different subroutines (the packer's globalSlotTypes mechanism ensures type
// consistency within each subroutine). Use putIfAbsent for cross-subroutine types.
usedRegisters.regsTypes.forEach{ (reg, type) ->
val existingType = regsTypes[reg]
if (existingType!=null) {
if (existingType != type) {
// POINTER is compatible with WORD or LONG (size depends on target)
val compatible = (existingType==IRDataType.POINTER && type in setOf(IRDataType.WORD, IRDataType.LONG)) ||
(type==IRDataType.POINTER && existingType in setOf(IRDataType.WORD, IRDataType.LONG))
if(!compatible)
throw IllegalArgumentException("register $reg given multiple types! $existingType and $type ${this.name}<--${child.label ?: child}")
}
} else
regsTypes[reg] = type
regsTypes.putIfAbsent(reg, type)
}
} else {
usedRegisters.regsTypes.forEach{ (reg, type) ->
val existingType = regsTypes[reg]
if (existingType!=null) {
if (existingType != type) {
// POINTER is compatible with WORD or LONG (size depends on target)
val compatible = (existingType==IRDataType.POINTER && type in setOf(IRDataType.WORD, IRDataType.LONG)) ||
(type==IRDataType.POINTER && existingType in setOf(IRDataType.WORD, IRDataType.LONG))
if(!compatible)
throw IllegalArgumentException("register $reg given multiple types! $existingType and $type ${this.name}<--${child.label ?: child}")
}
} else
regsTypes[reg] = type
}
}
}
+391
View File
@@ -0,0 +1,391 @@
# Register Packing Design Plan
## Current Approach (Simplified, Recommended)
Instead of a single global slot pool shared across all subroutines
(which is unsound — callees clobber callers' values), **each call depth
gets its own fixed, non-overlapping range of slots.** Subroutines at
different depths never execute simultaneously, so their slots can occupy
the same physical memory without conflict — no save/restore needed, no
caller/callee clobbering.
```
Depth 0 (main, interrupt handlers): slots 0..255
Depth 1 (direct callees): slots 256..511
Depth 2: slots 512..767
...
```
This sidesteps the interprocedural analysis problem entirely, reuses
the existing graph-colouring allocator per subroutine with a single
parameter change (start slot per depth), and keeps per-subroutine
liveness analysis simple.
## Goal
Reduce the number of distinct virtual registers in the IR program by
coalescing registers whose live ranges do not overlap into the same slot.
This shrinks the register file (``p8_regfile`` BSS section) and makes the
generated assembly cleaner, which is especially important for memory-constrained
6502 targets.
The IR generator assigns fresh register numbers freely (one per value,
per operation). A non-trivial program can easily use hundreds of distinct
registers after code generation. Most of these have short, non-overlapping
live ranges and can share a single memory slot.
## Approach
The implementation lives in ``RegisterPacker.kt`` and is structured as a
standard graph-colouring register allocator:
```
IR program
|
v
1. Collect all register numbers and their types across the whole program.
2. For each subroutine separately:
a. Build a control-flow graph (CFG) of its code chunks.
b. Compute liveness (gen/kill + iterative dataflow) to determine
liveIn/liveOut per chunk.
c. Walk backward through each chunk's instructions to build live
intervals (register, start-index, end-index, type).
d. Merge intervals of the same register into contiguous ranges.
e. Build a conflict graph from instruction-level register aliasing
and cross-type incompatibilities.
f. Greedy-colour intervals using the conflict graph and a global
slot-type registry (shared across subroutines).
g. Rewrite instructions: replace each register with its assigned
slot number.
|
v
Packed IR program
```
## Current Status
The implementation exists and compiles but is **disabled** (the call in
``IRCodeGen.generate()`` is commented out). Two bugs were found during
testing:
### 1. Disjoint-interval value clobbering (cx16 Fibonacci)
A register with multiple disjoint live ranges (e.g. a loop counter written
at the loop entry and read at the loop exit, with no uses in between) was
split into separate intervals. The packer allowed another register to
share the same slot during the gap, overwriting the value before its next
read.
**Fix applied (but now disabled with the rest of the packer):** always
merge all intervals of the same register into a single contiguous range,
regardless of whether they overlap.
### 2. Complex control flow (m68k TextElite)
The liveness analysis produces incorrect live ranges for subroutines with
complex control flow (nested conditionals, loops, early returns). Two
registers with genuinely overlapping live ranges are assigned the same
slot, causing one to clobber the other's value at runtime. The symptom
was an infinite loop printing spaces (the galaxy map data was being read
from the wrong memory location).
**Root cause:** the iterative dataflow liveness analysis (gen/kill with
fixed-point iteration) does not correctly handle all CFG patterns present
in larger programs. The analysis appears to converge to a fixed point
that is not the true meet-over-all-paths solution for some loop and
conditional structures.
## Alternative: Intra-expression Register Reuse in RegisterPool
Instead of a separate packing pass, attack the problem at the source:
make ``IRCodeGen.kt``'s ``RegisterPool`` reuse registers within
expressions where lifetimes are trivially bounded.
**How it works today:** Every call to ``registers.next(dt)`` returns a
fresh, monotonically increasing register number. No register is ever
freed. The IR ends up with hundreds of short-lived temporaries that the
peephole optimizer later removes via ``removeDeadStores`` and friends.
This is wasteful because by the time the peephole optimizer runs,
temporary registers have already consumed pool space, affecting the
regfile layout and BSS size.
**Proposed change:** Add ``pushFrame()`` / ``popFrame()`` to
``RegisterPool``. Before translating an expression, push a mark. When
the expression completes, free all registers allocated since the mark
— except those *pinned* (the result register that gets stored to a
variable or used by the next statement).
::
pushFrame()
r1 = next(BYTE) ← temporary (freed on popFrame)
r2 = next(WORD) ← temporary
r3 = next(WORD) ← temporary
result = r1 + r2 ← produces r4 = next(WORD)
popFrame(pin = r4) ← frees r1, r2, r3; keeps r4
**Where to apply it:** Every expression in ``ExpressionCodeGen`` ends
with a known result register. After the expression's IR instructions
are emitted, all intermediate registers can be released. The result
register is *pinned* if it will be read by a subsequent statement
(e.g., assigned to a variable, used as a function argument).
**Benefits over the packer:**
- No liveness analysis at all — lifetimes are bounded by the
expression tree, not by dataflow over arbitrary CFGs.
- No cross-subroutine problems (each subroutine's pool is
independent).
- Produces cleaner IR from the start, reducing the peephole
optimizer's work.
- Simple to implement (stack of marks + a free list in
``RegisterPool``).
- Same memory savings: most registers are short-lived temporaries.
**Limitations:**
- Only captures within-expression reuse; registers live across
statements (loop counters, function arguments, values stored to
variables) still accumulate monotonically.
- Requires careful handling of control flow (if/while bodies need
separate frames).
- Some expression codegen paths (fcallArgs, string/array init) may
need extra pinning.
**Verdict:** Worth trying before re-attempting the packer. It targets
the same wasteful-allocation root cause, is much simpler to implement
correctly, and eliminates the need for a separate packing pass for the
majority of temporary registers. The depth-range packer would still be
useful for cross-statement reuse that intra-expression freeing misses,
but the urgency is lower once the RegisterPool stops leaking
temporaries.
## Consequences of the Current Implementation
### Register file slots are 4 bytes each
To work around the type-inconsistency problem (where a register can carry
different types -- BYTE, WORD, LONG, POINTER -- in different subroutines
after packing, and ``registersUsed()`` with ``putIfAbsent`` would only
record the first-seen type), the register file layout in both the new6502
and m68k codegens was changed to allocate **4 bytes per slot** (the
maximum data type size, LONG). This eliminates under-allocation at the
cost of wasting memory.
The waste is small when the packer is active (single-digit slot counts),
but with the packer disabled the regfile reverts to variable-size slots
based on each register's first-seen type, which is correct for
non-packed programs because registers there have consistent types.
If the packer is re-enabled, the 4-bytes-per-slot approach must be
reconsidered:
- **Problem:** allocating 4 bytes unconditionally inflates the BSS
section, which on a C64 (with ~38K RAM) could be a real constraint
for programs that already have hundreds of registers.
- **Alternative:** restore per-register max-size tracking. Instead of
using ``registersUsed().regsTypes`` (which gives one type per
register), scan all instructions with ``addUsedRegistersCounts`` to
find the **maximum** data type size actually used for each register.
This is more complex than the current simple lookup but avoids
wasting memory.
- **Simpler alternative after re-enabling:** accept 4 bytes per slot
as long as the packer keeps register counts low (single digits).
The BSS waste is negligible on targets with ample RAM (CX16, m68k),
and on C64 the packer would likely reduce registers to <10, making
40 bytes of BSS acceptable.
## What Needs to Be Fixed Before Re-enabling
### 1. Liveness analysis must be correct for all CFG shapes
The current iterative gen/kill dataflow is the weak point. It needs to be
validated against a test suite of small programs with nested loops,
conditionals, early returns, and switch-like dispatch patterns.
Options:
a) **Fix the existing dataflow.** The equations are standard:
```
liveOut[B] = union of liveIn[succ(B)]
liveIn[B] = gen[B] ∪ (liveOut[B] - kill[B])
```
The iteration uses a worklist or repeated passes until fixpoint.
The bug may be in how ``gen`` and ``kill`` are computed for each
chunk (they currently process instructions forward, adding to
``gen`` only if the register is not already in ``kill``). Verify
this matches the standard definition (a register is in ``gen`` if
it is used before being defined in the block).
b) **Replace with a more robust formulation.** Use a proper
iterative algorithm that tracks both ``liveUse`` and ``liveDef``
(or ``liveGen`` and ``liveKill``) and iterates until no changes.
The current implementation already does this but may have an error
in the gen/kill calculation.
### 2. Interval merging must not lose type information
The "always merge" fix for disjoint intervals merges all intervals of
the same register, but the merged interval keeps only the **first**
interval's type. If later intervals have incompatible types, the
register is skipped entirely (via ``skipRegs``). But if they have
compatible types (POINTER ↔ WORD/LONG via ``typesCompatible``), the
merged type is the first one, which may be the *narrower* type.
A WORD slot allocated from a POINTER-typed interval is fine (both are
2 bytes on 6502), but a BYTE slot from a POINTER interval would be
wrong. Currently this is handled by the regfile layout allocating
4 bytes unconditionally (when packing is active), which makes the
merged type irrelevant for sizing.
When reverting to variable-size slots, the interval must carry the
**widest** type of all merged sub-intervals, not just the first one.
### 3. SkipRegs must not silently discard registers
When a register is skipped because its intervals have incompatible types,
the register keeps its original number. The rest of the packer must not
assign this number as a packed slot to another register. Currently this
is guaranteed by ``startSlot = maxReg + 1`` (packed slots always exceed
the highest original register number). This invariant must be preserved.
### 4. Cross-subroutine slot collision (fundamental design issue)
The packer processes each subroutine independently, but assigns slots
from a single global pool (``p8_regfile`` is one flat memory block shared
by all subroutines). If subroutine A gets slot 41, calls B, and B's
packing also assigned it slot 41, B's writes clobber A's live values.
There are no caller/callee save semantics.
**The 902-to-15 reduction observed with TextElite is therefore not
plausible for a correct allocator.** The observed aggressive coalescing
is likely a side effect of incorrect liveness (intervals not covering
call sites properly) rather than genuine reuse. A correct per-subroutine
packing would need its slots to be disjoint from those of all callers
and callees, which drastically limits reuse.
Options to fix this:
a) **Call-graph-aware allocation.** Process subroutines in reverse
call order (leaves first). Each callee reports its used slot set
back to its callers, so the caller's packer can avoid those slot
numbers (or reserve them as incompatible types). This requires
building the call graph and propagating slot usage up the call
tree, which is non-trivial for indirect calls (``CALLI``) and
function pointers.
b) **Save/restore around calls.** After packing, insert save/restore
instructions at call boundaries for any register that is live
across the call and whose slot might be used by the callee. This
adds code size but keeps each subroutine's packing independent.
The callee's used slot set must be known, requiring some form of
interprocedural analysis (even if just per-callee analysis). On
6502 targets this is expensive (many instructions to push/pop to
the stack or a secondary save area).
c) **Reserve a fixed set of "caller-save" slots.** Instead of a
global flat regfile, split into a "local" section (per subroutine,
overlapping freely) and a "global" section (fixed, used for values
that must survive calls). This is a compromise: the local section
can be packed aggressively within each subroutine, while the global
section stays unpacked. It avoids interprocedural analysis at the
cost of less packing for call-spanning values.
None of these are quick fixes. The current per-subroutine independent
packing is fundamentally unsound for a flat register file, and this
must be addressed before re-enabling.
### 5. Recommended approach: subroutine-local disjoint slot ranges
Instead of fixing the cross-subroutine sharing (which requires
interprocedural analysis or save/restore code), sidestep it entirely:
**each call depth gets its own fixed, non-overlapping range of slots.**
Subroutines at different depths never execute simultaneously, so their
slots can occupy the same physical memory without conflict — no
save/restore needed, no caller/callee clobbering.
```
Depth 0 (main, interrupt handlers): slots 0..63
Depth 1 (direct callees): slots 64..127
Depth 2: slots 128..191
...
```
**How it works:**
1. Build the call graph from the IR (scan ``CALL`` / ``SYSCALL``
instructions; indirect ``CALLI`` falls back to a conservative
depth estimate).
2. Compute max call depth via DFS from each entry point. Detect
recursion — mark recursive subroutines as "unpacked" (keep
original register numbers).
3. Assign slot ranges per depth. The range width per depth is the
max register count needed by any subroutine at that depth, or a
fixed conservative size (e.g. 64).
4. Pack each subroutine independently using the current algorithm,
but constrained to its depth's range: start at ``depth_base``
instead of ``maxReg + 1``.
5. No cross-subroutine type conflicts because slots at different
depths are disjoint addresses.
**Benefits:**
- Sound by construction — no caller/callee clobbering.
- Reuses the existing packer logic with a single parameter change
(start slot per subroutine).
- No save/restore code generation anywhere.
- Liveness analysis only needs to be correct within a single
subroutine (much simpler than interprocedural).
**Tradeoffs:**
- If a subroutine needs more slots than its depth range allows,
packing is less aggressive (excess registers stay unpacked).
- Indirect calls (``CALLI``) need a conservative depth estimate or
those subroutines skip packing.
- Recursion detection is required; truly recursive calls cannot
use static depth ranges.
Per-subroutine packing won't yield headline reductions like 902→15,
but a realistic 60-70% reduction within each subroutine is still
significant — hundreds of bytes of BSS saved on a C64.
### 6. Is this still worthwhile if we later add a CPU register allocator?
It depends on the timeline:
**Short term (months):** Yes. A CPU register allocator is a major
undertaking (new analysis, code generator changes, calling convention
work). If it's not imminent, the packer delivers real memory savings
with modest engineering effort using the depth-range approach above.
**The packer and a future CPU allocator share core infrastructure:**
both need correct intraprocedural liveness analysis. Building the
packer's liveness properly now is not wasted — it becomes the
foundation for the CPU allocator. The interval analysis, conflict
graph, and greedy coloring can all be reused or adapted.
**Medium term (a year+):** If a CPU register allocator is on the
roadmap, the packer is a bridge solution. The CPU allocator will
replace the memory-based register file for hot values; the packer's
slot compaction becomes a fallback for spill values only. At that
point the packer's complexity (depth ranges, skipRegs, type merging)
may not be worth maintaining alongside the allocator.
**Verdict:** Implement the depth-range packer if BSS pressure is
hurting now. Design the liveness infrastructure to be shared with a
future CPU allocator. Revisit whether to keep the packer once the
allocator is operational.
### 7. Test coverage
The following scenarios must have unit tests (in ``TestRegisterPacker.kt``):
- Simple non-overlapping registers coalesced into one slot.
- Overlapping registers forced into different slots.
- Cross-chunk liveness (register live across multiple chunks).
- Disjoint intervals of the same register (the Fibonacci fix).
- Registers with incompatible types skipped (sqrt_long case).
- Registers with compatible types (POINTER ↔ WORD/LONG) sharing a slot.
- FCallArgs registers tracked correctly in the conflict graph.
- Nested loops and conditionals (the textelite case) -- these tests
need to be designed and added once the liveness bug is understood.
-72
View File
@@ -1,72 +0,0 @@
# Register Reuser - IR Register Packing Pass
Reduce distinct virtual registers in IR by coalescing non-overlapping live ranges per datatype.
## Algorithm (per subroutine)
### Step 1 — Build CFG
- Iterate `sub.chunks`, scan instructions for non-null `branchTarget`
- Unconditional branch → 1 successor; conditional branch → 2 (target + next); return/no branch → 0
- `IRInlineAsmChunk`/`IRInlineBinaryChunk` = opaque barriers (no liveness across)
- Build `successors: Map<IRCodeChunkBase, List<IRCodeChunkBase>>`
### Step 2 — Liveness analysis (backward data-flow)
- `liveOut[chunk] = ∪ { liveIn[s] for s in successors[chunk] }`
- `liveIn[chunk] = (liveOut[chunk] - written[chunk]) ∪ read[chunk]`
- Fixed-point iteration over chunks in reverse topological order
### Step 3 — Per-chunk register intervals
- Backward scan: `(firstDef[reg], lastUse[reg])` per register per chunk
- Registers in `liveIn[chunk]` extend to chunk start; `liveOut[chunk]` extend to chunk end
- Group by `IRDataType` (BYTE/WORD/LONG/FLOAT — separate pools)
### Step 4 — Greedy interval coloring (per chunk, per type)
- Sort intervals by `firstDef`
- For each interval, find a slot whose `lastUse < this.firstDef` or allocate new
- Build `packing: Map<Int, Int>` (original → packed register number)
### Step 5 — Rewrite instructions
- Remap `reg1`, `reg2`, `reg3`, `fpReg1`, `fpReg2` via `instr.copy()`
- Remap `FunctionCallArgs.arguments[].reg.registerNum` and `returns[].registerNum`
### Step 6 — Update register type map
- Rebuild next register counter and type map from max packed register
## Skipped
- `IRAsmSubroutine` — no parseable IR
- `globalInits` — small, infrequent
- `virtual` target — unlimited registers already
## Integration
In `IRCodeGen.generate()`, after `IRUnusedCodeRemover.optimize()` + final `linkChunks()`:
```kotlin
if(options.compTarget.name != "virtual") {
RegisterPacker.pack(irProg)
}
```
## Files
| File | Purpose |
|------|---------|
| `codeGenIntermediate/src/.../RegisterPacker.kt` | Implementation |
| `codeGenIntermediate/test/TestRegisterPacker.kt` | Test suite |
## Test Cases
1. **Simple coalescing** — two non-overlapping regs, same type → 1 slot
2. **No coalescing** — overlapping regs, same type → 3 slots
3. **Cross-chunk liveness** — register live across boundary, blocks reuse
4. **Different types share slots** — BYTE & WORD overlap → same slot number
5. **Unconditional branch** — CFG has only 1 successor, no liveness across skipped chunk
6. **Conditional branch** — CFG has 2 successors, liveness flows to both
7. **Multi-chunk subroutine** — 3 chunks, 8 regs → reduced count
8. **FP registers** — packed independently from integer regs
9. **Function call args** — `fcallArgs` register numbers remapped
10. **Empty subroutine** — no crash
11. **Register type map** — updated after packing
## Progress
- [x] Plan written
- [ ] Implement RegisterPacker.kt
- [ ] Integrate into IRCodeGen.kt
- [ ] Implement tests
- [ ] Build and run