11 KiB
New6502 Codegen Performance Optimizations
The new6502 codegen has NO CPU register allocation. IR virtual registers live
in a memory "register file" (p8_regfile, in BSS), with variable-size slots
per register type. Every instruction that touches a register does so through
that memory slot using the A/X/Y registers as scratch. The file is sized
dynamically from registersUsed(), so only the actually-used registers cost
memory. Long (32-bit) operations in particular are expensive: each one is 4
separate load/op/store round trips through memory.
This document lists improvements ordered roughly by impact and ease, in the same spirit as the m68k analysis (codeGenM68k/docs/m68k-optimizations.md). Items that fix outright correctness bugs are flagged [BUG].
What is already good
Some patterns are already tight and should be preserved:
- STOREZM / STOREIM zeroing uses
stzon 65C02 (cx16) targets - WORD add/sub with immediate is emitted as a single carry-propagating
clc/adc/sec/sbcchain on the slot, no extra temp - BYTE INC/DEC on 65C02 uses
ina/dea - WORD INC/DEC use the branch-to-skip carry propagation pattern
- LONG add/sub are unrolled (fast), not looped
- signed word comparisons use the SEC/SBC/EOR #$80 technique
lda slot; cmp #immfor unsigned word compare/branch with the operand-swap trick for>and<=(no branch-over-jmp)- variable-count shifts use a single X-counter loop (compact)
- constant-count shifts are unrolled (fast)
- CALLI is turned into CALL by the IR peephole when possible
- the regfile layout is packed by register type, sized by actual use
- address computation for indirect access uses a single ZP scratch word
Suggested order of implementation
-
A/X/Y register cache (biggest win, no real allocation)
-
Peephole optimizer expansion on the emitted assembly
-
Byte BGT/BLE via operand swap
-
addImmediate/subImmediatevalue==1 toinc/decmemory -
65C02-only instruction audit (FTOSL uses
stz/braon all targets) [BUG] -
TSB/TRB for BITSET/BITCLR on 65C02
-
ZP placement of hot virtual registers
-
Float FAC1 caching and DEC via FADD -1.0
-
Inline asmsub handling [BUG]
-
STOREZX LONG zeroing incomplete [BUG]
-
JMP (abs) page-wrap on plain 6502 [BUG]
-
Unroll remaining multi-byte loops for speed
-
A/X/Y register cache (biggest win, still no full allocation)
The scratch usage is extremely regular. BYTE ops funnel through A:
lda slot; <op>; sta slot. After a sta slot, A still holds that value.
The next IR instruction frequently reads that same slot again (e.g.
a = b + c followed by d = a + e). Tracking "A currently holds virtual
register rX" lets the codegen skip the redundant lda slot.
Track the same for the X register (used as an index / second data holder in
storeX/indirectStore/STOREZX paths) if the A cache proves effective.
Invalidation rules:
- any instruction that writes a register-file slot (the slot value in A/X becomes stale, and A itself is clobbered by most ops anyway)
- any ALU instruction (clobbers A)
- any branch target / basic-block boundary / chunk boundary
- any CALL / JUMP / PUSH / POP (callee-clobbers A/X/Y)
- after
lda #imm, the cache holds an immediate, not a register slot - after
ldafrom a non-register memory operand, cache is empty
The biggest wins appear in long chains of LOADR + ALU + STORER, and in
the post-sta reload pattern described above.
Note: unlike m68k, flag-setting matters here. cmp sets flags for the
branch that follows, so a cached load must never be skipped if the following
instruction consumes the flags from the load itself (plain lda does not
set flags, so this is only a concern around ldx/ldy used as data).
Only skip pure loads, never reorder.
- Peephole optimizer expansion
The current optimization/PeepholeOptimizer.kt only rewrites CALLI to CALL
(42 lines). The old 6502 codegen has a rich assembly post-pass
(codeGenCpu6502/AsmOptimizer.kt) with proven patterns that are still missing
here:
- optimizeSameAssignments (redundant
sta slot; lda slot) - optimizeStoreLoadSame
- optimizeIncDec
- optimizeJsrRtsAndOtherCombinations (jsr + rts -> jmp, branch-over-jmp)
- optimizeUselessPushPopStack
- optimizeTSBtoRegularOr
- optimizeUnneededTempvarInAdd
Because AsmGen emits text directly to a StringBuilder, the natural fit is a post-pass over the emitted lines (same design as the old AsmOptimizer), or folding these patterns into the IR peephole that already runs.
- Byte BGT/BLE via operand swap
Unsigned byte a > imm currently emits:
lda slot
cmp #imm
bcc skip
beq skip
jmp label
skip:
(6 instructions, a label, and a branch-over-jmp). The WORD path already
swaps operands to lda #imm; cmp slot; bcc label. The same swap works for
bytes:
lda #imm
cmp slot
bcc label
3 instructions, no label. Same for a <= imm (bcs instead of bcc).
This also removes the jmp that breaks the short-branch range.
addImmediate/subImmediatevalue==1 ->inc/decmemory
On plain 6502 (c64/pet32/c128), BYTE ADDIM/SUBIM with value 1 emits:
lda slot
clc
adc #1
sta slot
(4 instructions) even though the target is a memory slot. inc slot /
dec slot do the whole thing in one instruction. The 65C02 path already
handles value==1 with ina/dea; extend the plain-6502 path to use the
memory inc/dec form (only for BYTE type, and only when the value is
exactly 1, never for signed -1 which would need a different form).
- 65C02-only instruction audit [BUG]
translateFloatToSignedLong (InstrControl.kt, FTOSL) emits stz and bra
unconditionally. Both are 65C02-only instructions. A program using floats on
a c64/pet32/c128 target (plain 6502) will therefore fail to assemble or
produce garbage. It must use lda #0; sta and jmp on non-65C02 targets.
Audit the whole codegen for other ungated 65C02 instructions (stz, bra,
tsb, trb, ina, dea, phx, phy, plx, ply, stx zp,y, etc.)
and gate them on is65C02().
- TSB/TRB for BITSET/BITCLR on 65C02
BITSET/BITCLR/BITTOG currently emit:
lda slot
ora/and/eor #mask
sta slot
(3 instructions) on every target. On 65C02, TSB/TRB operate directly on
memory (zeropage and absolute), collapsing BITSET and BITCLR to a single
instruction. BITTOG has no TSB/TRB equivalent, keep it as-is. Apply only
when is65C02(), keeping the current form on plain 6502.
- ZP placement of hot virtual registers
Every register-file access uses absolute addressing (3 bytes). Moving the hottest virtual registers into zeropage makes those accesses 2-byte and faster. The full regfile can never live in ZP (ZP is only 256 bytes), but:
- reuse the existing ZeropageAllocator (it already scores variables by usage frequency and type) and extend the scoring to virtual registers, or
- overlay a small set of "hot" virtual registers on dedicated ZP slots and
have
regAddrLo()/regAddrHi()/regAddr()return those ZP labels.
Combined with the IR-level RegisterPacker (currently disabled because it
"doesn't work well", commit b0cf649c2), this is the closest step to real
register allocation and the biggest structural win available.
- Float FAC1 caching and DEC via FADD -1.0
Every float op loads the operand from the fp regfile into FAC1 and stores the result back. Add an FAC1 cache analogous to the A cache: track "FAC1 currently holds fp register frX" and skip the redundant MOVFM.
DEC/DECM for floats is especially wasteful: it does MOVFM + pushFAC1 + MOVFM(1.0) + popFAC + FSUBT (because FSUB computes memory - FAC1). Since FADD computes FAC1 + memory, DEC can simply FADD the constant -1.0:
jsr floats.MOVFM
lda #<-1.0 const
ldy #>-1.0 const
jsr floats.FADD
jsr floats.MOVMF
dropping the push/pop and the FSUBT, and removing the register-clobbering stack round trip.
- Inline asmsub handling [BUG]
The header of AsmGen.kt documents that inline asmsub bodies are emitted as
regular subroutines, which is wrong: inline asmsubs must be inserted directly
at the call site (no jsr, no .proc/.pend, no rts). Currently they are emitted
as standalone routines lacking an rts, so calling them returns garbage.
The IR does not preserve the inline flag, so either add an INLINE attribute
to the ASMSUB IR instruction, or ensure the optimizer inlines them before IR
generation.
- STOREZX LONG zeroing incomplete [BUG]
zeroMemoryIndexed (InstrLoadStore.kt) handles BYTE and WORD, but the LONG
case emits only the low byte with the comment "STOREZX LONG not fully
implemented". Zeroing a long array element via STOREZX therefore leaves the
upper 3 bytes untouched. The WORD pattern (reload X with the high byte and
sta base+1,x) extends naturally to LONG by repeating for each byte.
- JMP (abs) page-wrap on plain 6502 [BUG]
JUMPI and CALLI emit jmp (p8_regfile+N). On the original 6502, JMP (abs)
reads the high byte from the same page (the address wraps at $xxFF); 65C02
fixed this. If a dispatch/indirect target stored in a register slot has a
low byte of $FF, c64/pet32/c128 programs will jump to a wrong address.
Mitigations: guarantee the regfile never holds a vector with low byte $FF
(not under the compiler's control in general), or emit the classic
workaround (push low, load high, push high, rts) on non-65C02 targets.
- Unroll remaining multi-byte loops for speed
LONG AND/OR/XOR use a ldy #3 byte loop; LONG add/sub are unrolled. The
loop is smaller, the unroll is faster. Pick one policy (unroll everything
for speed, loop everything for size) rather than the current mix, or gate on
an optimization-level switch. Also copyRegister for LONG could be unrolled.
Future register allocation
The real fix for all of this is a proper register allocator that assigns frequently-used virtual registers to physical resources: A, X, Y, the s0-s5 calling-convention slots, and a set of ZP scratch words. The IR RegisterPacker (register-packing.md, graph-coloring packer, currently disabled) reduces the number of virtual registers and could be combined with ZP slot assignment so that the remaining hot registers fit in ZP. The A/X/Y cache in item 1 is a cheap stepping stone toward that.