TDD on the CPC: insertion sort, four versions to the NOP
The selection sort of the previous post pays no attention to the order of the bytes: even on an array already sorted, it looks for the minimum on every pass. Insertion sort, on the other hand, makes use of the order it finds. But what does it cost when there is none?
To find out, we write it in Z80, one test at a time, try four ways to shift the bytes, measured to the NOP, and end by setting the two sorts against each other.
The method is that of the post on find_min, to read first if TDD is new to you.
What the routine does
HL points at the array, B holds its length (1 to 255), and the routine sorts the array in place, smallest first. It is the contract of selection_sort, and the array still stays within one 256-byte page.
Insertion sort is the card player’s sort: we pick up the cards one by one, and slide each one into its place among those already in hand. The array splits in two: on the left, a sorted run, on the right, the bytes that remain. At the start, the sorted run holds only the first byte. At each pass, we take the byte that follows it: the key. While the byte on the left of the key is larger than the key, that byte moves one place right. Then the key is written into the freed place.
Play the animation, or go step by step: the boxes with a coloured outline form the sorted run, the filled box shows the key, and the arrow the byte compared with the key. The Z80 keeps the key in A during the shifts, and writes it only at the end. The animation already shows it in the place where it will be written.
On the array #42 #30 #05 #17 #99, the key #30 moves in front of #42. The key #05 moves in front of #42 and #30. The key #17 moves in front of #42 and #30, and stops after #05. The key #99 stays in its place. The sorted array is #05 #17 #30 #42 #99.
On an array already sorted, each key stops at once: one comparison per byte, and that is all. On a reversed array, each key travels across the whole sorted run. The best and the worst case are easy to guess, and the tests will measure them.
The first test
Insertion sort does not need find_min, so the code starts from scratch, and the main source includes only the new routine:
org #4000
include "insertion-sort.asm"
In the tests of this post, HL is #8000 and B the length of the array, except in one test, which says so. The test tables therefore only show the bytes and the expected result. They are the same bytes as for selection_sort, to compare the two sorts at the end.
A one-byte array is already sorted. The first test only checks that the routine leaves the memory alone:
| Bytes | Expected |
|---|---|
| #42 | the memory unchanged |
The test in TypeScript
The insertionSort function writes the bytes at #8000, sets HL and B, then calls the routine, like selectionSort in the previous post.
// Runs insertion_sort on `bytes`, written at ARRAY.
function insertionSort(bytes: number[]) {
return program.run("insertion_sort", {
registers: { hl: ARRAY, b: bytes.length },
memory: [{ at: ARRAY, bytes }],
});
}
it("leaves a one-byte array as it is", () => {
expect(insertionSort([0x42])).toLeaveMemoryUnchanged();
});
One byte, then two
The first steps follow those of selection sort. The one-byte test fails for lack of a label (insertion/01-red), and a plain ret makes it pass (insertion/02-green). Two bytes in the wrong order expose that cheat (insertion/03-red):
| Bytes | Expected |
|---|---|
| #42, #17 | #17, #42 in memory |
The test in TypeScript
it("swaps two bytes in the wrong order", () => {
expect(insertionSort([0x42, 0x17]).memory(ARRAY, 2)).toEqual([0x17, 0x42]);
});
The plain ret leaves the two bytes as they are:
AssertionError: expected [ 66, 23 ] to deeply equal [ 23, 66 ]
Vitest shows the bytes in decimal: 66 is #42, and 23 is #17. The green code already differs from the selection one. The key is the second byte, and we compare it with the byte before it (insertion/04-green):
insertion_sort:
dec b
ret z
inc l
ld a,(hl) ; the key: the byte to insert
dec l
cp (hl)
ret nc ; the key is not below the byte before it
ld c,(hl) ; move that byte one place right, and the key into its place
ld (hl),a
inc l
ld (hl),c
ret
A single key
The next test puts a key right in front of a run already sorted (insertion/05-red):
| Bytes | Expected |
|---|---|
| #17, #30, #42, #05 | #05, #17, #30, #42 in memory |
The test in TypeScript
it("moves the last byte to the front of a sorted run", () => {
expect(insertionSort([0x17, 0x30, 0x42, 0x05]).memory(ARRAY, 4)).toEqual([
0x05, 0x17, 0x30, 0x42,
]);
});
The two-byte version does not see the last key:
AssertionError: expected [ 23, 48, 66, 5 ] to deeply equal [ 5, 23, 48, 66 ]
In hexadecimal, the routine left #17, #30, #42, #05, because it only looks at the first two bytes. For this test, one key is enough, the last one, which steps back towards the start of the array, still without an outer loop (insertion/06-green):
insertion_sort:
dec b
ret z
ld a,l
add a,b
ld l,a ; HL: the last byte
ld a,(hl) ; the key: the byte to insert
.shift:
dec l
cp (hl)
jr nc,.place ; the key is not below this byte
ld c,(hl) ; move this byte one place right
inc l
ld (hl),c
dec l
jr .shift
.place:
inc l
ld (hl),a
ret
This loop has no bound: it stops on the first byte that is not larger than the key. Nothing makes it stop at the start of the array, and we will come back to that later.
The outer loop
The next test sorts five mixed bytes (insertion/07-red):
| Bytes | Expected |
|---|---|
| #42, #30, #05, #17, #99 | #05, #17, #30, #42, #99 in memory |
The test in TypeScript
it("sorts five bytes in any order", () => {
expect(insertionSort([0x42, 0x30, 0x05, 0x17, 0x99]).memory(ARRAY, 5)).toEqual([
0x05, 0x17, 0x30, 0x42, 0x99,
]);
});
With a single key, the routine fails on this array:
AssertionError: expected [ 66, 48, 5, 23, 153 ] to deeply equal [ 5, 23, 48, 66, 153 ]
In hexadecimal, the routine left #42, #30, #05, #17, #99, since the last key, #99, was already in its place. The outer loop now inserts each byte in turn. E keeps the position of the key, so that the next pass starts from there (insertion/08-green):
insertion_sort:
dec b ; one pass for each byte but the first
ret z
.next:
inc l ; HL: the next byte to insert
ld e,l ; the next pass starts from there
ld a,(hl) ; the key: the byte to insert
.shift:
dec l
cp (hl)
jr nc,.place ; the key is not below this byte
ld c,(hl) ; move this byte one place right
inc l
ld (hl),c
dec l
jr .shift
.place:
inc l
ld (hl),a
ld l,e
djnz .next
ret
All the tests are green, but the inner loop still has no bound.
The wall before the array
When the key is smaller than every byte of the sorted run, it moves past the first byte, and the loop goes on reading before the array. To prove it, a larger byte, #FF, is enough there: the unbounded code will shift it into the array.
The first attempt put the array at #8000 and the #FF at #7FFF. The test passed when it should have failed, because dec l does not touch H: from #8000, it does not step back to #7FFF, but to #80FF, at the end of the same page. And the fresh memory of z80-tdd is #00 everywhere, so this zero, smaller than any key, stops the loop. Here again, a test passed by luck, as in the post on selection sort.
So the test puts the array one byte further, at #8001, right after the #FF (insertion/09-red). It is the only test where HL is not #8000:
| Bytes | Expected |
|---|---|
| #FF at #8000, then #42, #30, #05, #17, #99 at #8001 (HL = #8001, B = 5) | #05, #17, #30, #42, #99 at #8001, and nothing changed outside #8001-#8005 |
The test in TypeScript
toChangeOnly checks that the routine writes only between #8001 and #8005.
it("ignores a larger byte right before the array", () => {
// Fresh memory holds #00 next to ARRAY. The array starts one byte into the
// page, after the #FF, so a step back by DEC L or DEC HL reads the #FF.
const run = program.run("insertion_sort", {
registers: { hl: ARRAY + 1, b: 5 },
memory: [{ at: ARRAY, bytes: [0xff, 0x42, 0x30, 0x05, 0x17, 0x99] }],
});
expect(run).toChangeOnly({ address: ARRAY + 1, length: 5 });
expect(run.memory(ARRAY + 1, 5)).toEqual([0x05, 0x17, 0x30, 0x42, 0x99]);
});
This time, it fails for the right reason. From the first pass, the key #30 pushes the #FF into the array, and takes its place, outside the array. On the next pass, the key #05 drives it out:
Error: Memory changed outside #8001-#8005:
#8000 #FF -> #05
The simplest bound is a counter. D counts the bytes of the sorted run, and E gets a copy of it on each pass: the key cannot move past more than E bytes. As E is taken, the position of the key goes on the stack (insertion/10-green):
insertion_sort:
dec b ; one pass for each byte but the first
ret z
ld d,0
.next:
inc d ; D: the bytes of the sorted run
inc l ; HL: the next byte to insert
push hl ; the next pass starts from there
ld e,d ; E: the bytes the key can still move past
ld a,(hl) ; the key: the byte to insert
.shift:
dec l
cp (hl)
jr nc,.place ; the key is not below this byte
ld c,(hl) ; move this byte one place right
inc l
ld (hl),c
dec l
dec e
jr nz,.shift
dec l ; the key goes first: .place steps back onto the first byte
.place:
inc l
ld (hl),a
pop hl
djnz .next
ret
The dec l after the loop is a small trick: .place moves one byte forward before writing, so we step back one more byte when the key goes first.
Four ways to shift
Everything is green, and the routine is correct, but nothing says yet that it is fast. Before setting any budget, we therefore try three other inner loops:
- The sentinel, from Algorithms in C by Robert Sedgewick (Program 6.3). A first pass brings the smallest byte to the front of the array. No key can go further than it, and the inner loop no longer needs a bound.
- The exchanges, from Algorithms (4th edition, Sedgewick and Wayne). The key steps back by exchanging places with the byte before it, instead of shifting it.
LDD, the Z80’s own idea. This instruction copies the byte at (HL) to (DE), steps HL and DE back, and decrements BC. The P/V flag tells whether BC has not reached zero yet: BC becomes the bound.
z80-tdd measured the four versions on sorted bytes and on reversed bytes. For each one, counting the NOPs gives an exact formula:
| Version | sorted bytes | reversed bytes |
|---|---|---|
E counter (10-green) |
25n − 18 | 7.5n² + 11.5n − 12 |
| sentinel (Algorithms in C) | 37n − 26 | 7n² + 13n − 9 |
| exchanges (Algorithms, 4th ed.) | 22n − 15 | 8.5n² + 6.5n − 8 |
LDD |
33n − 26 | 6n² + 22n − 21 |
The sentinel formulas hold from 3 bytes up. No version wins everywhere:
- The exchanges are the fastest on a sorted array, and the slowest on a reversed one: an exchange writes two bytes, a shift only one.
- The sentinel pays for its first pass in all cases, and its inner loop saves only one NOP per shifted byte over the E counter.
LDDis the slowest to start: each pass must set up DE and BC, and save BC. But it shifts a byte in 12 NOPs:cp (hl)2,jr nc2,ldd5,jp pe3. The E counter needs 15. On 255 reversed bytes,LDDtakes 395 739 NOPs, and the E counter 490 608.
LDD wins the worst case, and on a sorted array it loses only 8 NOPs per pass against the E counter. It is the one the routine keeps. The refactor replaces the inner loop without touching a single test (insertion/11-refactor):
; Sorts in place, smallest first, the array at HL, B bytes long (1 to 255).
; The array stays within one 256-byte page: INC E keeps D on the page of H.
insertion_sort:
dec b ; one pass for each byte but the first
ret z
ld c,0
.next:
inc c ; C: the bytes of the sorted run
push bc
ld d,h ; DE: the next byte to insert, then the hole it leaves
ld e,l
inc e
push de ; the next pass starts from there
ld a,(de) ; the key: the byte to insert
ld b,0 ; BC: the bytes the key can still move past
.shift:
cp (hl) ; HL: the byte before the hole
jr nc,.place ; the key is not below this byte
ldd ; move this byte into the hole; P/V: BC is not 0
jp pe,.shift
.place:
ld (de),a
pop hl ; HL: the last byte of the sorted run
pop bc
djnz .next
ret
jr does not know the pe condition, hence the jp. And the dec l trick goes away: whichever way the loop ends, DE points at the freed place.
The guards
Now come the tests that pass as soon as they are written, the guards. Each one must prove that it can fail: we break the routine on purpose, and check that it turns red.
The first one repeats the one of selection sort: a smaller byte right after the array (insertion/12-guard):
| Bytes | Expected |
|---|---|
| #42, #30, #05, #17, #99 (B = 5), and #00 at #8005, right after the array | #05, #17, #30, #42, #99 in memory, and nothing changed after #8004 |
The test in TypeScript
it("ignores a smaller byte right after the array", () => {
// Fresh memory already holds #00 there: this test says so out loud.
const run = program.run("insertion_sort", {
registers: { hl: ARRAY, b: 5 },
memory: [{ at: ARRAY, bytes: [0x42, 0x30, 0x05, 0x17, 0x99, 0x00] }],
});
expect(run).toChangeOnly({ address: ARRAY, length: 5 });
expect(run.memory(ARRAY, 5)).toEqual([0x05, 0x17, 0x30, 0x42, 0x99]);
});
Without the dec b at entry, the sort makes one pass too many, and inserts the byte that follows the array. The older tests fail too, but only thanks to the #00 of fresh memory. With #FF after the array, the broken version would pass them all. The guard says so out loud:
Error: Memory changed outside #8000-#8004:
#8005 #00 -> #99
The best case
The next three guards set the budgets, for 2, 16 and 255 bytes. First the best case, sorted bytes (insertion/13-guard):
| Bytes | Expected |
|---|---|
| n rising bytes, #00, #01, #02…, for n = 2, 16 and 255 | 33n − 26 NOPs at most: 40, 502 and 8 389 |
The test in TypeScript
// Sorted bytes are the fastest input: each key stops at the byte before it.
it.each([2, 16, 255])("sorts %i sorted bytes in 33n − 26 NOPs at most", (n) => {
const sorted = Array.from({ length: n }, (_, i) => i);
expect(insertionSort(sorted).nops).toBeLessThanOrEqual(33 * n - 26);
});
The count can be done by hand. A pass where the key does not move costs 33 NOPs: inc c 1, push bc 4, ld d,h 1, ld e,l 1, inc e 1, push de 4, ld a,(de) 2, ld b,0 2, cp (hl) 2, jr nc 3, ld (de),a 2, pop hl 3, pop bc 3, djnz 4.
| Part | NOPs |
|---|---|
Entry: dec b 1, ret z that does not return 2, ld c,0 2 |
5 |
| Each of the n − 1 passes | 33 |
End: the last djnz costs 1 NOP less, then ret |
−1 + 3 |
The sum gives 33n − 26 NOPs. This guard catches the sentinel, which pays for its first pass:
AssertionError: expected 47 to be less than or equal to 40
AssertionError: expected 566 to be less than or equal to 502
AssertionError: expected 9409 to be less than or equal to 8389
Equal bytes
Then equal bytes, the worst case of selection sort (insertion/14-guard). Here, they are also the best case: cp (hl) sets the carry only if the key is strictly smaller, so a key never moves past an equal byte. The guard reuses the budget of sorted bytes:
| Bytes | Expected |
|---|---|
| n bytes equal to #42, for n = 2, 16 and 255 | 33n − 26 NOPs at most: 40, 502 and 8 389 |
The test in TypeScript
// A key never moves past a byte equal to it.
it.each([2, 16, 255])("sorts %i equal bytes in 33n − 26 NOPs at most", (n) => {
const equal = Array.from({ length: n }, () => 0x42);
expect(insertionSort(equal).nops).toBeLessThanOrEqual(33 * n - 26);
});
The broken version it aims at compares with ≤ instead of <: a jr z before the jr nc moves the key past equal bytes. Equal bytes then become almost as slow as reversed bytes:
AssertionError: expected 48 to be less than or equal to 40
AssertionError: expected 1987 to be less than or equal to 502
AssertionError: expected 428124 to be less than or equal to 8389
This guard is not the only one to catch it. The jr z costs 2 NOPs on every comparison, even when it does not jump, and the guard on sorted bytes fails too: 42 NOPs instead of 40 for two bytes. But that one barely sees it, while the guard on equal bytes shows the full extent of the damage.
The worst case
What is left is the worst case, reversed bytes (insertion/15-guard):
| Bytes | Expected |
|---|---|
| n falling bytes, from n − 1 down to #00, for n = 2, 16 and 255 | 6n² + 22n − 21 NOPs at most: 47, 1 867 and 395 739 |
The test in TypeScript
// Reversed bytes are the slowest input: each key moves past the whole sorted run.
it.each([2, 16, 255])("sorts %i reversed bytes in 6n² + 22n − 21 NOPs at most", (n) => {
const reversed = Array.from({ length: n }, (_, i) => n - 1 - i);
expect(insertionSort(reversed).nops).toBeLessThanOrEqual(6 * n * n + 22 * n - 21);
});
On the k-th pass, the key moves past the k bytes of the sorted run. Outside the inner loop, the pass costs 28 NOPs: the 33 of the best case, minus the cp (hl) and the jr nc that left the loop. Each shifted byte adds 12 NOPs.
| Part | NOPs |
|---|---|
| Entry | 5 |
| Pass k, for k = 1 to n − 1 | 28 + 12k |
End: the last djnz costs 1 NOP less, then ret |
−1 + 3 |
The sum gives 6n² + 22n − 21 NOPs. This guard catches the exchanges:
AssertionError: expected 2272 to be less than or equal to 1867
AssertionError: expected 554362 to be less than or equal to 395739
It also catches the E counter:
AssertionError: expected 2092 to be less than or equal to 1867
AssertionError: expected 490608 to be less than or equal to 395739
For two bytes, both versions stay under the ceiling: the gap grows with n. The sentinel, already caught by the guard on sorted bytes, fails here too: 1 991 NOPs instead of 1 867 for 16 bytes.
Selection against insertion
Both sorts now have their formulas, on the same three inputs:
| Input | selection | insertion |
|---|---|---|
| sorted bytes | 5n² + 36n − 36 | 33n − 26 |
| equal bytes | 6n² + 35n − 36 | 33n − 26 |
| reversed bytes | ⌊11n²/2⌋ + 36n − 36 | 6n² + 22n − 21 |
And for 255 bytes:
| Input | selection | insertion |
|---|---|---|
| sorted bytes | 334 269 | 8 389 |
| equal bytes | 399 039 | 8 389 |
| reversed bytes | 366 781 | 395 739 |
On a sorted array or on equal bytes, insertion sort is linear, and 40 to 48 times faster than selection sort. On a reversed array, it is the other way round, past a certain size. Up to 26 bytes, insertion stays ahead (4 607 NOPs against 4 618), but from 27 bytes, selection wins (4 945 against 4 947). Insertion moves each byte one step at a time, at 12 NOPs a step, whereas selection searches for a long time but writes only one exchange per pass.
What is left is to compare the worst cases: 399 039 NOPs for selection, on equal bytes, and 395 739 for insertion, on reversed bytes. They are less than one percent apart, which we can call a draw, with about 20 frames of 19 968 NOPs on each side to sort 255 bytes.
Conclusion
The measurements of z80-tdd chose the LDD version, and three guards keep that choice: a change that slows down one of the three measured cases makes a test fail. On a sorted array, insertion sort is 40 times faster than selection sort, and in the worst case, the two sorts tie.
The routine has its restrictions:
- 1 to 255 bytes, within one 256-byte page;
- on a reversed array of more than 26 bytes, selection sort is faster.
The next post moves on to bubble sort.
Trying it yourself
You need Node.js 24 or later, pnpm, and RASM v3.3.1 on the PATH. The steps of this post are on the insertion branch of the z80-tdd-sorts repository. It starts from main, not from selection, since insertion sort does not need find_min. To replay a step:
git checkout insertion/01-red
pnpm install
pnpm test
The tests of the sort are in src/insertion-sort.test.ts. To install z80-tdd in a project of your own, see the Trying it yourself section of the post on find_min.
Links
- The previous post: selection sort and its complexity
- The introduction:
find_minand TDD on the CPC - z80-tdd, the test tool
- z80-tdd-sorts, the repository of this post (branch
insertion) - RASM, the assembler by Édouard Bergé (Roudoudou)
- Perfectly accurate Z80 flags and CPC timing, Madram / Overlanders (64NOPS), the table of durations in NOPs
- Robert Sedgewick, Algorithms in C, 3rd edition, Addison-Wesley, 1998: Program 6.3, with its sentinel
- Algorithms, 4th edition, Robert Sedgewick and Kevin Wayne: insertion sort by exchanges