Files
zzgandClaude Opus 5.5 2a3a0284c5 Topological sort of assembly instructions: dependence DAG, Kahn/DFS, list scheduling, renaming
A walkthrough of how a dependence DAG over a straight-line block decides
which instruction orders are legal, and how a scheduler picks among them.
Includes a property test that every topological order of a random program
computes the same result as program order.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 21:18:41 -07:00

7.6 KiB

top-sort-asm

How topological sorting decides which orders a CPU or compiler may run instructions in, shown on a small assembly block. The code is Python, standard library only (3.9+).

python3 -m topsort_asm                        # walk through examples/struct.s
python3 -m topsort_asm my.s --dot deps.dot    # your own block, plus a Graphviz graph
python3 -m unittest discover -s tests -t .    # tests

The idea

A straight-line block of instructions is written in one order, but most of that order is arbitrary. Instruction v must follow instruction u only if swapping them could change the result. Those pairs make a directed graph. Any topological order of that graph computes the same answer, and picking the fastest one is what instruction scheduling means.

The example (examples/struct.s):

 0: load  r1, [r10]        ; x
 1: load  r2, [r10+1]      ; y
 2: mul   r3, r1, r1       ; x*x
 3: mul   r4, r2, r2       ; y*y
 4: add   r3, r3, r4       ; x*x + y*y
 5: store r3, [r10+2]      ; p->z
 6: load  r1, [r10+3]      ; w  (reuses r1)
 7: li    r2, 3            ;    (reuses r2)
 8: mul   r1, r1, r2       ; q = 3w
 9: cmp   r1, r3           ; writes the hidden flags register
10: setlt r5               ; reads it
11: store r1, [r11]        ; r11 might point into the same struct

1. Edges: what must stay ordered

Each register, the implicit flags register, and memory can produce three kinds of edge:

kind meaning example real?
RAW (true) v reads what u wrote 0 → 2 on r1 yes: a value flows from u to v
WAR (anti) v overwrites something u still reads 2 → 6 on r1 no: it exists only because a name was reused
WAW (output) v overwrites what u wrote 1 → 7 on r2 no: it exists only because a name was reused
graph TD
  i0["0: load r1,[r10]"] -->|RAW r1| i2["2: mul r3,r1,r1"]
  i1["1: load r2,[r10+1]"] -->|RAW r2| i3["3: mul r4,r2,r2"]
  i2 -->|RAW r3| i4["4: add r3,r3,r4"]
  i3 -->|RAW r4| i4
  i2 -.->|WAR r1| i6["6: load r1,[r10+3]"]
  i3 -.->|WAR r2| i7["7: li r2,3"]
  i4 -->|RAW r3| i5["5: store r3,[r10+2]"]
  i4 -->|RAW r3| i9["9: cmp r1,r3"]
  i6 -->|RAW r1| i8["8: mul r1,r1,r2"]
  i7 -->|RAW r2| i8
  i8 -->|RAW r1| i9
  i9 -->|RAW flags| i10["10: setlt r5"]
  i8 -->|RAW r1| i11["11: store r1,[r11]"]
  i5 -.->|WAW mem| i11

(Edges implied by a longer path are left out. python3 -m topsort_asm lists all 21.)

Some details matter here:

  • Hidden operands. cmp and setlt never name flags, but they talk through it. The parser makes flags an explicit operand, so the edge 9 → 10 exists. A scheduler that forgets implicit operands reorders code incorrectly without any warning.
  • Memory needs alias analysis. store [r10+2] and load [r10+3] use the same base register with different offsets, so they cannot touch the same word and need no edge. store [r11] might hit anything r10 points at, so it is ordered after every earlier access. The verification below runs once with r11 = 102, which really does alias p->z.
  • Only the nearest writer counts. li r1,1; li r1,2; mov r2,r1 gets edges 0→1→2, not also 0→2. That edge is implied, and leaving it out keeps the graph small.

All edges point forward in program order, so the graph is always acyclic and program order is always one valid topological order.

2. Kahn's algorithm

Keep the ready set: instructions whose predecessors have all been emitted. Emit one, remove its outgoing edges, and add any instruction that becomes ready.

step  0  ready {0, 1}         emit  0   releases 2
step  1  ready {1, 2}         emit  1   releases 3
step  2  ready {2, 3}         emit  2   releases 6
step  3  ready {3, 6}         emit  3   releases 4, 7
step  4  ready {4, 6, 7}      emit  4   releases 5
...

Always choosing the lowest index gives back program order. The ready set is the freedom available: every way of choosing from it gives a valid order. This block has 720 valid orders out of 12! ≈ 479 million permutations (count_orders uses a DP over subsets). The demo runs all 720 in the interpreter on two initial states, one of them with aliasing pointers, and each one produces the original registers and memory. If one edge is broken (4 run before 3), p->z comes out as 81 instead of 25.

dfs_order (reverse DFS post-order) is the other classic algorithm. Both report a cycle if one exists. Straight-line code cannot contain one, so the demo adds a bogus backward edge to show what the report looks like.

3. Picking a good order: list scheduling

Latencies are load 4, mul 3, and 1 for everything else, on a single-issue, in-order pipeline. In program order, each load or multiply is followed immediately by its consumer, which stalls:

program order: 20 cycles, 8 stalls

A list scheduler is Kahn's algorithm with a clock. An instruction is ready only once its producers' results have arrived. Among ready instructions it picks the one with the longest latency-weighted path to the end of the block, because delaying that one delays everything after it.

list schedule: 15 cycles, 3 stalls

It is still limited: load r1, [r10+3] cannot move to the top, because of the WAR edge 2 → 6. The first mul still needs to read the old r1.

4. Renaming removes the false edges

Give each write its own register (r1.1, r1.2, …). WAR and WAW edges only exist because names are reused, so they vanish:

 0: load  r1.1, [r10]
 1: load  r2.1, [r10+1]
 2: mul   r3.1, r1.1, r1.1
 ...
 6: load  r1.2, [r10+3]
 7: li    r2.2, 3
 8: mul   r1.3, r1.2, r2.2

The edge count drops from 21 to 15. The remaining non-RAW edges are all on memory: an address is computed at run time, so it cannot be renamed like a register. Valid orders go from 720 to 5,040, and the schedule improves again:

list schedule after renaming: 13 cycles, 1 stall (was 15 / 20 in program order)
cycle  0:  0: load  r1.1, [r10]
cycle  1:  1: load  r2.1, [r10+1]
cycle  2:  6: load  r1.2, [r10+3]     <- hoisted above both multiplies
cycle  3:  7: li    r2.2, 3
cycle  4:  2: mul   r3.1, r1.1, r1.1
...

An out-of-order CPU does the same work in hardware. The register renamer removes WAR/WAW hazards, the reservation stations hold the ready set, and the issue logic chooses from it every cycle.

Layout

file what
topsort_asm/isa.py tiny ISA and parser; implicit flags operand made explicit
topsort_asm/deps.py dependence DAG (RAW/WAR/WAW on registers, flags and memory), alias test, transitive reduction, DOT output
topsort_asm/toposort.py Kahn (with a choice hook and step trace), DFS, enumeration, counting, cycle reporting; knows nothing about assembly
topsort_asm/schedule.py pipeline timing model and list scheduler
topsort_asm/rename.py register renaming
topsort_asm/sim.py interpreter used to check reorderings preserve meaning
topsort_asm/demo.py the walkthrough

Tests

tests/test_topsort_asm.py covers:

  • Kahn and DFS output is always topological on random DAGs. Enumeration and counting match a brute-force filter over all permutations.
  • Every topological order of a random program computes the same result as program order. The random programs redefine base registers, and the initial states make r10/r11 alias. Small blocks are checked exhaustively over every order. The same holds for renamed programs.
  • For each of the seven edge kinds, swapping a two-instruction example changes the result, so none of the edges is unnecessary.
  • The list schedule is topological, and on small blocks it is compared with the brute-force optimum. It is a heuristic and not always optimal.