So I've been working on a compiler called Cowgol. This is intended to produce code for very small 8-bit machines, primarily the 6502 (because I grew up on BBC Micros, and also because I'm apparently a masochist). This is turning out to be rather hard.
The problem is that the 6502 is inherently an 8-bit device, and only supports 8-bit arithmetic. Trying to do anything wider involves breaking the operation into smaller pieces, which is expensive --- while 6502 instructions are three bytes long, tops, I need a lot of them to do anything. It takes the 6502 ten to twenty bytes to do something which its closest rival, the Z80, could do in about five (and which an 8080 can do in two). And I only have 64kB to play with, so it's important to be efficient.
This document is me thinking out loud about how this is going to work.
Important note: this is actually the second version of this post --- the first one had a hideous and embarrassing error where my loops all counted in the wrong direction, because I had mentally conflated subtraction and comparison. This has been fixed. (The compiler gets this right.)
Update, 2018-03-02: added the bit about incrementing and decrementing.
The core problem
My compiler is a memory-memory 3op machine. Its backend generates instructions which essentially look like
a := b + c where
c are all variables in memory. Operands can be dereferenced or offsetted.
Let me consider the operation
a := b + c.
For the purposes of example, I'll assume that my three variables are at 0xaaa0, 0xbbb0 and 0xccc0 respectively. I then wrote out the naive version of the code. (The first column is the cycle count, the second contains the bytes of machine code, the third is the instructions themselves.)
2 18 clc 4 ad b0 bb lda b+0 4 6d c0 cc adc c+0 4 8d a0 aa sta a+0 4 ad b1 bb lda b+1 4 6d c1 cc adc c+1 4 8d a1 aa sta a+1 (26 cycles, 19 bytes.)
That's kind of ludicrous.
Things get a little better if I'm working with constants, because they're cheaper.
a := b + 0x123 becomes:
2 18 clc 4 ad b0 bb lda b+0 2 69 23 adc #0x23 4 8d a0 aa sta a+0 4 ad b1 bb lda b+1 2 69 01 adc #0x01 4 8d a1 aa sta a+1 (22 cycles, 17 bytes.)
But things get worse when I have to deal with dereferences. The 6502 can only handle pointers in zero page, and they must be indexed with the Y register. So
a := b + c becomes:
2 18 clc 4 ad b0 bb lda b+0 2 a0 01 ldy #0 5 71 c0 adc (c), y 4 8d a0 aa sta a+0 4 ad b1 bb lda b+1 2 a0 01 ldy #1 5 71 c0 adc (c), y 4 8d a1 aa sta a+1 (32 cycles, 21 bytes.)
I can actually save one byte by replacing that last
ldy #0 with a
dey --- I know that the old value was 1, so decrementing it will give the desired value of 0 --- but I'm going to leave it like this for simplicity.
The problem here is that there's only one Y register, so if different operands need different offsets, I have to keep reloading it. Imagine I have to do
a := b + c. What does that turn into?
2 18 clc 2 a0 02 ldy #2 5 b1 b0 lda (b), y 2 a0 04 ldy #4 5 71 c0 adc (c), y 2 a0 00 ldy #0 6 91 a0 sta (a), y 2 a0 03 ldy #3 5 b1 b0 lda (b), y 2 a0 05 ldy #5 5 71 c0 adc (c), y 2 a0 01 ldy #1 6 91 a0 sta (a), y (46 cycles, 25 bytes.)
Argh! What I we do about this?
The obvious thing is to say: there's a lot of repeated work here, can't I use a loop? And, in fact, I can. But there are problems.
The biggest problem is that multibyte arithmetic has to be processed in LSB to MSB order, with the carry propagating from one byte to the next; but on the 6502 it's so much easier to count down than it is to count up... for example, this is a loop counting down:
ldy #4 .label ...do work here... dey bpl label
But here is a loop counting up:
ldy #0 .label ...do work here... iny cpy #4 bne label
The difference is that when counting down, we get the N flag set automatically when the number becomes negative, so the comparison is free --- terminating when the index passes 0 is easy. But when counting up, we need an explicit comparison. And that comparison instruction trashes the carry flag, which we're using as part of our computation...
I could save the carry on the stack, like this:
clc php ldy #0 .label plp ...do work here... php iny cpy #4 bne label plp
This does, indeed, work fine. But
plp aren't that cheap, being 3 and 4 cycles respectively, and needing to set up and tidy afterwards is annoying.
Once I did the maths I came to the counter-intuitive conclusion that it's actually cheaper to use both index registers, and have one counting up for use when offsetting, and one counting down to tell us when we've finished the loop. So it becomes:
clc ldx #4 ldy #0 .label ...do work here... iny dex bpl label
If I use this loop to implement my very first example,
a := b + c, then I get this:
2 18 clc 2 a2 01 ldx #1 2 a0 00 ldy #0 .label 4 89 b0 bb lda b, y 4 79 c0 cc adc c, y 5 99 a0 aa sta a, y 2 c8 iny 2 ca dex 2 10 f5 bpl label (45 cycles, 18 bytes)
My original was 26 cycles and 19 bytes --- my improved version is only one byte shorter, and almost half the speed!
And it's worse. I can only use the loop because all three operations (the
sta) use the same Y register. The loop doesn't work for the
a := b - c case at all.
The only way to achieve this is to emit code to compute
aa := &a,
bb := &b,
cc := &c and then compute
aa := bb - cc using a loop. But each of those is an addition in its own right, so I'm actually doing four additions in place of my original one.
This, in fact, what my compiler currently does. I did this partly because it made the code simpler, but also because I wasn't expecting to need to do this much, and memory pressure is a real problem; but in real life, it happens all the time: consider
length := event.end_time - event.start_time; the destination is an ordinary variable, but the two operands are pointer dereferences at different offsets. And it turns out that the extra computations aren't just a net loss, they're a significant dead loss. My code would be faster and smaller if I did it the long way!
So it appears that trying to shorten arithmetic using loops generally isn't worth it:
- it's limited to only those cases where the indices for all the operands are the same, which rules out a lot of common situations
- it's only really worth it for operations which can be computed from MSB to LSB
- even when it is worth it, it's not worth it very much, and it's significantly slower
And yet... it is still worth it, in some situations. And I am very tight on memory. (Anything bigger than 16 bits, for example, pretty much has to be done with a loop. Imagine a 32-bit addition down longhand.)
What cunning tricks are there?
I didn't think this one up, sadly. I wish I had; it's clever.
If all the operands are absolute addresses, then I start with a negative offset and count up to zero (and remember, I get the comparison with zero for free). The end result looks like this:
2 18 clc 2 a0 fe ldy #-2 .label 4 89 b2 bb lda b+2, y 4 79 c2 cc adc c+2, y 4 99 a2 aa sta a+2, y 2 c8 iny 2 30 f4 bmi label (36 cycles, 15 bytes.)
(For comparison, the longhand version is 25 cycles, 19 bytes.)
The first time round, Y is -2. The addresses in the instructions are incremented by 2, so the overall real offset is 0. The next time round, Y is -1. Then we increment Y to 0, it stops being negative, and the loop exits.
This allows me to do our addition without needing the other index register to keep track of how many iterations I've had, which saves about 20% of bytes.
The downside is that this won't work for pointers. All three operands must be absolute addresses, so the offset can be baked into them.
The return of the X register
Because this allows us to count up to 0 using a single register, we can use the other register for a different index.
a := b - c.
2 18 clc 2 a0 01 ldx #-2 2 a2 08 ldy #7 .label 4 bd b2 bb lda b+2, x 4 71 c0 adc (c), y 4 9d a2 aa sta a+2, x 2 c8 iny 2 e8 inx 2 30 f6 bmi label (42 cycles, 17 bytes.)
For comparison, here it is longhand.
2 18 clc 4 ad b0 bb lda b+0 2 a2 08 ldy #y 4 71 c0 adc (c), y 4 8d a0 aa sta a+0 4 ad b1 bb lda b+1 2 c8 iny 4 71 c0 adc (c), y 4 8d a1 aa sta a+1 (30 cycles, 20 bytes.)
The loop is three bytes shorter --- that's pretty marginal. And we're limited in which addressing modes me use; at least one operand must use absolute addressing, so we can use the negative index for it and bake in the adjustment factor.
Of course, if we have more than one pointer, and they use different offsets, it doesn't work.
The last resort
The ultimate in code size is, of course, not to use machine code at all. At one point I did this:
20 xx yy jsr __add16 a0 aa equw &a b0 bb equw &b c0 cc equw &c (Many cycles, 9 bytes.)
What the helper function does is to pop the return address of the stack, copy the next six bytes into a parameter area, perform the computation (remember that the parameters here all point at the destination variables), and then push the updated return address back onto the stack.
It worked! And it was pretty dense code, although you need to bear in mind that the cost in having to compute pointers made it totally not worth it. But it was creakingly slow. Simply copying the values off the stack was pretty bad; something like this:
; pop return address into temporary pla sta address+0 pla sta address+1 ; copy parameters ldy #5 .label lda (address), y sta (parameters), y dey bpl label ; increment return address to skip parameters clc lda address+0 adc #6 sta address+0 bcc dont_increment_high_byte inc address+1 .dont_increment_high_byte jsr actually_perform_the_computation ; return jmp (address)
So it's actually spending more time preparing the computation than it is performing it.
This works. Don't do it unless you really have to (and if you do, you probably want to use a bytecode interpreter instead).
Success through cheating
If I'm willing to redefine the problem, I get to solve it differently. This can lead to substantial savings...
Let's say that I want to do
a := a + 1. The 6502 actually allows cheap in-place increments without using registers at all. The code ends up like this:
6 ee a0 aa inc a+0 2 d0 03 bne label 6 ee a1 aa inc a+1 .label (14 cycles maximum, 8 cycles minimum, 8 bytes.)
It's super cheap! And I don't need to save any registers! But
inc only supports absolute addresses, so I can't use this when indirecting pointers.
This works because
inc sets the Z flag of the low byte based on the result; since I've incremented it, I know therefore that if the result is zero it must have overflown from 0xff to 0x00 and I need to increment the high byte as well. And of course, if I don't have to increment the low byte, I don't execute the second
inc instruction at all, so making the common case even cheaper. (And I can chain this technique for wider values, at the cost of five bytes per eight bits of arithmetic.)
Sadly, I can't use this trick for decrements. In this case, I need to decrement the high byte when the low byte underflows from 0x00 to 0xff. Sadly, the CPU doesn't let us check for this for free, so I need to load it into a register and do an explicit comparison.
4 ae a0 aa ldx a+0 2 ca dex 4 8e a0 aa stx a+0 2 e0 ff cpx #ff 2 d0 03 bne label 6 ce a1 aa dec a+1 .label (20 cycles maximum, 14 minimum, 14 bytes.)
It's cheaper than a full-on addition, but not much (this would be 17 bytes). And I can't really chain it for longer operations; I only get to use the
dec for the highest byte.
So I think that the most obvious option is to just use longhand code. I suspect the extra flexibility in not being limited to particular combinations of addressing modes is worth it.
But, that said, I do need to keep an eye out for common instruction patterns. If my compile generates
a := b + c a lot --- and I suspect it does --- then the negative offset trick above will let me save 4/19 = 20% of my code; which is not to be sniffed at.
But it's probably only worth doing this for certain common instruction patterns.
I remain open to convincing. Please comment if you think I've missed anything!
Disclaimer: all code fragments in this post are for entertainment value only. Minor bugs are entirely deliberate, honest.