Dots tunnel: the stack, the registers and unrolling
At the end of the third post, the tunnel no longer flickers and clears only its own dots. Yet it still needs two CPC frames, of 19,968 NOPs each, to draw one picture. The clearing now takes almost none of that time, which goes into the 256 dots themselves.
A dot of the v3 goes through a call, variables in memory, a loop for its mask and an erase list kept by IY. We will count this path instruction by instruction, then strip out everything that does not write the dot, without changing a single byte of the picture. To get there, the v4 hands the lists over to the Z80 stack, and the v5 keeps everything in registers and unrolls the 16 dots of a ring.
The count of a dot
The third post counted more than 96 NOPs per dot, without going into detail. The full count follows the dot_loop loop and the plot routine of the v3:
| Step | Instructions | NOPs |
|---|---|---|
| x, y and ink | ld a,(ringX), add a,(ix+0), ld a,(ringY), add a,(ix+1), ld a,(pen) and three ld |
25 |
| call | call plot and ret |
8 |
| pixel mask | and 3, the next_pixel loop, and e |
11 to 27 |
| column and address | two srl a, YLO, YHI, ld a,(back) |
20 |
| write | ld a,e, or (hl), ld (hl),a |
5 |
| erase list | ld (iy+0),l, ld (iy+1),h, two inc iy |
16 |
| next dot | two inc ix, the dot counter |
15 |
The mask loop runs as many times as the rank of the pixel in its byte, from 0 to 3. On average over the four ranks, the mask step costs 18.5 NOPs, and a dot costs 107.5. The 256 dots therefore make 27,520 NOPs, more than the 19,968 of a frame.
Above all, the table shows how much of that time draws nothing. Writing the dot, the one operation that the picture needs, costs 5 NOPs. Everything else goes into finding the address, reading variables, calling a routine and keeping counts.
The stack as a pointer
The v4 deals with the erase lists first. The v3 walks through them with IY, which is expensive: ld l,(iy+0) takes 5 NOPs to read one byte. But SP, the stack pointer, reads or writes two bytes at once, much faster, and every program has it at hand.
pop hl reads two bytes at address SP, puts them in HL and moves SP up by 2, in 3 NOPs. push hl moves SP down by 2 and writes HL at that address, in 4 NOPs.
Clearing with pop
The clearing sets SP to the start of the list, then runs pop hl and ld (hl),c 256 times in a row, with C at 0:
ld a,(erase_list) ; clear the bytes lit two pictures earlier
ld h,a
ld l,0
ld sp,hl ; SP walks through the list
ld c,0
repeat 256 ; one pop per address, without a counter
pop hl
ld (hl),c
rend
; SP now points to the end of the list: the push of each dot fills it
; again, downwards. Up to `jp main`, no call and no other push.
repeat 256 is a RASM directive (any decent assembler offers a similar one): it copies the two instructions 256 times, without a counter or a jump. An address then costs 5 NOPs instead of 23, and the whole list 1,280 NOPs instead of 5,887. The price is paid in memory, with 512 bytes of code. The 0 waits in C, because ld (hl),c takes 2 NOPs where ld (hl),0 takes 3.
Recording with push
At the end of the clearing, SP points just after the list. That is exactly where the list must be written again, but downwards: each push hl moves SP down before writing. After 256 dots, SP is back at the start of the list, and the next clearing of this screen reads it the other way round. The order of the addresses does not matter, since they all get a 0.
The four instructions of the v3 that recorded the address thus become one, and the list goes from 16 to 4 NOPs per dot:
or (hl) ; keep the other pixels of the byte
ld (hl),a
push hl ; record the address for the clearing
What the stack forbids
Once SP is in the list, the program has no stack left. A call would write its return address there, among the addresses to clear, and the ret would jump to a screen address. Until the end of the picture, the program must follow two rules:
- interrupts stay off, because the Z80 would push the address of the interrupted program (the tunnel has run under
disince the v1); - no
call, and nopushother than the one of the dot.
plot and ring_setup therefore move into the loop, in place of their call. On the way, the dot saves the 8 NOPs of the call and the ret. ring_setup ended with push de and pop ix to copy DE into IX, and the v4 does it without the stack:
even_depth:
add a,SHAPE>>8
ld d,a
ld ix,0
add ix,de ; IX = DE, without push and pop
The loop puts the real stack back at the start of each picture, before it calls wait_vbl and show_screen:
main: ld sp,STACK ; the drawing left SP in a list
call wait_vbl
ld a,(work)
call show_screen ; show the screen we have just drawn
ld a,(work)
xor SCREEN_C0 ^ SCREEN_80
ld (work),a ; and draw into the other one
We used the rewrite to rename back to work, the work screen, and to give a name to the high bytes of the two screens, SCREEN_C0 and SCREEN_80. RASM computes SCREEN_C0 ^ SCREEN_80, the exclusive or of the two, at assembly time. The result is #40, the constant that the v3 wrote out by hand.
The result
tunnel-v4.sna draws the same pictures as the v1, byte for byte. We checked it as for the v2 and the v3: in a Z80 emulator, by comparing the 16 KB of the work screen for the 256 values of t.
Yet it stays at 2 frames per picture. A dot now costs 107.5 − 8 − 12 = 87.5 NOPs, and the 256 dots make 22,400, still more than a frame. The clearing is almost free, but the drawing alone still overflows.
Everything in registers
The v5 deals with the dot itself. In the v4, the dot reads ringX, ringY, pen and work again from memory, at 4 NOPs per read, when these values change only once per ring, or even once per picture. The Z80 has enough registers to keep them all, as long as we use the halves of its index registers.
The registers
IX and IY split into two 8-bit halves, IXH and IXL, IYH and IYL. Zilog never documented them, but they work on the Z80 of every CPC, and RASM accepts them. The v5 spreads its values as follows:
| Register | Content | Read |
|---|---|---|
| DE | pointer into the shape of the ring | ld a,(de): 2 NOPs |
| IXL, IXH | x and y centre of the ring | add a,ixl: 2 NOPs |
| IYH | high byte of the work screen | ld a,iyh: 2 NOPs |
| IYL | rings left | dec iyl: 2 NOPs |
| B | ink of the ring, on all four pixels | and b: 1 NOP |
| A’ | bits of the pixel, kept aside | ex af,af': 1 NOP |
DE replaces IX to walk through the shape. The 32 bytes of a ring stay in one 256-byte page, because their low byte goes up to 128 + 96 + 31 = 255 at most. inc e is therefore enough to move on, in 1 NOP instead of the 3 of inc ix.
A’ is the copy of A that the Z80 keeps in reserve. ex af,af' swaps A and F with their copies, in 1 NOP, and keeps a byte aside without touching another register.
The x coordinate in two tables
The next_pixel loop of the v3 computes the bits of the pixel by shifting #88 as many times as the rank of the pixel. Since x fits in a byte, two 256-byte tables give the bits of the pixel and its byte column in one read. XMASK and COLX take up #7A00 to #7BFF, in the free block between the BASIC tables and the lists.
make_x_tables fills them once, at start-up, so TABLES.BIN stays the one from the second post. For each x, the routine redoes the computation of plot:
; make_x_tables: for each x from 0 to 255, XMASK(x) = the bits of pixel
; x and 3 in its byte (#88 shifted right x and 3 times), COLX(x) = x >> 2.
make_x_tables:
ld hl,XMASK
next_x: ld a,l
and 3
ld b,a
ld a,#88 ; bits of the left pixel
jr z,mask_done
shift_mask:
srl a
djnz shift_mask
mask_done:
ld (hl),a
inc h ; COLX
ld a,l
srl a
srl a
ld (hl),a
dec h
inc l
jr nz,next_x
ret
L holds x, and H moves from one table to the other. ld b,a does not touch the flags, so jr z still tests the result of and 3. inc l goes back to 0 after 255, and jr nz stops there.
Sixteen dots in a row
The dot counter cost 9 NOPs per dot. repeat DOTS removes it by copying the code of a dot 16 times. The ring loop stays a real loop, since it runs only 16 times per picture. Here is the dot of the v5:
repeat DOTS ; the 16 dots, one after the other
ld a,(de) ; dx
inc e ; the 32 bytes of a ring stay in one page
add a,ixl ; x = ring centre x + dx
ld l,a
ld h,XMASK>>8
ld a,(hl) ; bits of the pixel in its byte
and b ; in the ink
ex af,af' ; kept aside for the end
inc h ; COLX = XMASK + 256
ld c,(hl) ; byte column: x >> 2
ld a,(de) ; dy
inc e
add a,ixh ; y = ring centre y + dy
ld l,a
ld h,YLO>>8
ld a,(hl)
add a,c ; low byte of the line + column
ld c,a
inc h ; YHI = YLO + 256
ld a,iyh
adc a,(hl) ; + high byte, carry and screen start
ld h,a
ld l,c
ex af,af' ; the bits of the pixel again
or (hl) ; keep the other pixels of the byte
ld (hl),a
push hl ; record the address for the clearing
rend
inc h does not touch the carry: the carry of add a,c reaches adc a,(hl) intact, as in the v3. The dot fits in 43 NOPs:
| Step | NOPs |
|---|---|
| x: bits of the pixel and column | 15 |
| y | 8 |
| address | 11 |
| write and list | 9 |
The code of the v5 weighs 1,315 bytes, compared with 376 for the v3 and 859 for the v4. The 16 copies of the dot take 512 of them, and the unrolled clearing as many.
The tunnel on the CPC
The CPCEC emulator below runs tunnel-v5.sna, assembled from tunnel-v5.asm:
The red border now covers only part of the height, and it stays still: the tunnel keeps up with every frame. Here again, the drawn screen is identical, byte for byte, to the one of the v1 for the 256 values of t.
What each version brings
We measured the three versions as in the third post, in frames per picture in amspirit-lite-headless (6128), over 1,000 frames:
| Version | Frames per picture | Pictures per second |
|---|---|---|
| v3: list walked by IY | 2 | 25 |
| v4: list walked by the stack | 2 | 25 |
| v5: registers, x tables, unrolled dots | 1 | 50 |
The count by hand explains the jump. The v5 spends 1,280 NOPs on the clearing and 256 × 43 = 11,008 on the dots. Preparing a ring (centre, ink and shape) takes about 100 NOPs, so about 1,650 for the 16. The whole picture fits in about 14,000 NOPs, 70% of a frame.
The same count tells how many dots the v5 could draw in one frame. One more dot costs 48 NOPs: 43 to draw it and 5 to clear it. So a ring of 16 dots costs 16 × 48 + 100, about 870 NOPs. A little under 6,000 NOPs are left in the frame, enough to add 6 rings: 22 rings, that is 352 dots, would still fit in one frame.
This figure stays theoretical, for two reasons. First, each erase list holds only 256 addresses. Second, the 64 depths, taken 4 by 4, give only 16 different rings: a 17th ring would land on the first one.
Conclusion
The v5 runs at 50 pictures per second, the maximum of the CPC, with the same pictures as the v1. Several limits remain:
- almost 30% of the frame is still free, and the tunnel still shows only 256 dots;
- of the 43 NOPs of a dot, 19 still go into computing y and then the address, with two tables, an addition and its carry;
- the code has more than tripled, from 376 to 1,315 bytes.
The next post changes the shape of the screen to 256 × 256 pixels, so that the address of a dot is computed in 8 bits, then finds how many dots fit in one frame.
The files of this post:
- tunnel-v4.asm and tunnel-v4.sna, the v4 with the stack;
- tunnel-v5.asm and tunnel-v5.sna, the v5 in registers;
- TABLES.BIN, the BASIC tables, which both versions load.
Both versions assemble with RASM 3.0.9.