Exercises — Module 18 (MMIX: Knuth's Machine)
Self-contained problems on this module's material — hand-assembly and
disassembly of the OP X Y Z format, MMIX's floor division, signed overflow,
the two-pass assembler, the scaled-add opcode family, the cost of FindMax, and
long division. You can work every one without the fascicle: each states the
problem in full, gives a hint to peek at when stuck, and a worked answer
sketch to check against. Numeric encodings and counts here are reproduced by
the machine and assembler you write in the lab (or a few lines at a REPL), and
every opcode is taken from the lesson's chart in §2.2.
Ratings follow Knuth's scale (00–50; M = needs mathematics, ▶ = especially
instructive). Exercise numbers follow Vol. 1, Fascicle 1 (§1.3.1´ / §1.3.2´);
match by content if your printing differs. Unnumbered rows are course exercises.
Tracking
| # | Topic | Rating | Status |
|---|---|---|---|
| 1 | Hand-assemble and disassemble on paper | 10 | ⬜ |
| 2 | ▶ DIV sign table, all four cases and | 10 | ⬜ |
| 3 | When do full MMIX's signed ops trip? | 15 | ⬜ |
| 4 | ▶ Add a pseudo-op (LOC or OCTA) to the assembler | 20 | ⬜ |
| 5 | The 2ADDU–16ADDU family; shorten FindMax | 22 | ⬜ |
| 6 | FindMax oops vs. ; | M25 | ⬜ |
| 7 | Long division: 128 64 without rD | 30 | ⬜ |
Problems
1. Hand-assemble and disassemble on paper (rating 10 · §1.3.1´-6)
Problem. Every MMIX instruction is one tetra encoded as
.
(a) Assemble these three by hand to 32-bit hex, using the §2.2 opcode chart
(ADD register form 0x20, immediate form 0x21; SUB register 0x24; GET
0xFE with rR ): ADD $1,$2,$3, SUB $2,$2,1, GET $3,rR.
(b) Disassemble these three words back to mnemonics: 0x1C020001,
0x42030004, 0xF1FFFFFB. (Recall: 0x1C = DIV register form; 0x42 = BZ
forward, its YZ an offset in tetras; 0xF1 = JMP backward, offset
tetras.)
Hint. For immediate operate opcodes, set the low bit (SUB register 0x24
immediate 0x25). Branch/JMP offsets count tetras, not bytes, and the
odd opcode means backward: a backward field denotes (branch) or
(JMP).
Answer sketch. (a) ADD $1,$2,$3 uses the register opcode 0x20, so
0x20010203. SUB $2,$2,1 has an immediate Z, so 0x24 | 1 = 0x25,
0x25020201.
GET $3,rR is 0xFE,
0xFE030006. (b) 0x1C020001: = DIV register,
DIV $2,$0,$1.
0x42030004: = BZ forward, ,
BZ $3, +4 (branch to this instruction's address bytes). 0xF1FFFFFB: = JMP backward,
, offset tetras
JMP -5 (to address bytes). This is the Euclid loop's DIV, its
zero-test, and its loop-closing jump. Stage 3 pins the round trip:
assemble these words execute, bit for bit.
2. ▶ DIV sign table, all four cases and (rating 10 · §1.3.1´-12)
Problem. MMIX's DIV $X,$Y,$Z uses floor division: and rR , with signed. Compute the
quotient and remainder for all four sign combinations of ,
and for the divide-by-zero case . State the invariant the remainder
always satisfies.
Hint. The floor-division theorem of §3.2 says the remainder lands on the divisor's side: when , and when . So always takes the sign of . Truncating division (round toward ) would give different answers for negative dividends — do not use it.
Answer sketch. With and :
| rR | check | |||
|---|---|---|---|---|
The remainder always takes the divisor's sign, and — this is the
unique of the theorem, and it is exactly §1.2.4's . For , MMIX-LITE follows §1.2.4's convention : DIV sets and rR (so gives ,
). Full MMIX additionally raises an integer-divide-check trip there;
MMIX-LITE keeps the values and skips the exception — a documented cut. (Contrast
truncating hardware, where remainder : the remainder flips sign
with the dividend, and the clean laws the whole series relies on break.)
3. When do full MMIX's signed ops trip? (rating 15 · §1.3.1´-14)
Problem. MMIX-LITE wraps signed arithmetic (two's complement), but full
MMIX raises an overflow trip for signed ADD, SUB, and NEG when the true
integer result does not fit a signed octabyte, i.e. outside . Characterize exactly which operand pairs trip each of the three, and
say what MMIX-LITE returns instead.
Hint. Signed overflow of a two's-complement add happens only when the two
operands share a sign and the result's sign differs from theirs. SUB $X,$Y,$Z is ADD of and . NEG $X,Y,$Z computes with an
immediate byte (); the bare NEG $X,$Z is , i.e.
.
Answer sketch. Let , .
ADD $X,$Y,$Ztrips iff and have the same sign and the true sum leaves — equivalently . Smallest example: trips (both positive, sum ); MMIX-LITE wraps it to .SUB $X,$Y,$Ztrips iff and have opposite signs and leaves range. Example: trips; wraps to . (A subtle case: has no positive negation, so subtracting it can trip on its own.)NEG $X,Y,$Ztrips iff . For the common form () the only trip is , because ; wraps back to (it is its own two's-complement negative).
In every case MMIX-LITE returns the low 64 bits of the two's-complement result — which is bit-for-bit what full MMIX would have stored had the trip handler returned — so the tests treat wrapping as the contract, and the "trip" is purely the exception full MMIX raises on top.
4. ▶ Add a pseudo-op (LOC or OCTA) to the assembler (rating 20 · §1.3.2´-x)
Problem. The MMIX-LITE assembler (§5) is two-pass — pass 1 builds
label → instruction index, pass 2 encodes — and it emits only instructions,
so all code is position-independent (stage 3 loads it at three addresses with
identical behavior). Extend it with one real MMIXAL pseudo-op, either LOC
(set the assembly origin/current location) or OCTA (emit an 8-byte data
constant). Describe the change to each pass and explain precisely what it does
to position-independence.
Hint. A pseudo-op is not an instruction — it produces no opcode (LOC) or
produces raw data, not an OP X Y Z word (OCTA). Both force you to track a
location counter in bytes, not just an instruction index, because data and
LOC gaps make address . Ask: once a label can name
an absolute address or a data octabyte, how does code reach it — and does the
subset have any absolute-addressing instruction?
Answer sketch. OCTA: pass 1 must advance the location counter by 8 for
each OCTA (and by 4 per instruction), so label → byte address replaces
label → index; pass 2 emits the literal 8 bytes big-endian instead of an
encoded tetra. LOC k: pass 1 sets the location counter to (leaving a gap
or fixing an origin); it emits nothing in pass 2. What breaks: the branches
stay position-independent — they are pc-relative and only need offsets, which
labels-as-byte-addresses still yield by subtraction. But data reached by label
now needs an address, and MMIX-LITE has no absolute-addressing load: the
only way to point at an OCTA is to compute its address pc-relatively
(GETA-style, which the subset lacks) or to have hard-wired it via LOC at a
fixed origin — either of which pins the program to a load address. So LOC
introduces genuine absolute placement (the assembler now presumes a fixed
origin), and OCTA data is only reachable if you give up relocatability or add
a pc-relative address-of primitive. This is exactly why §5 notes the subset
"needs no origin": dropping absolute addresses is what buys position-independence,
and adding these pseudo-ops is where you feel the trade.
5. The 2ADDU–16ADDU family; shorten FindMax (rating 22 · course exercise)
Problem. Add the scaled-add opcode family occupying 0x28–0x2F (right
after SUBU at 0x26/0x27) to the machine: 2ADDU, 4ADDU, 8ADDU,
16ADDU, computing for
, following §2.2's even/odd immediate convention. Then use
8ADDU to shorten FindMax's array addressing by one instruction per pass.
Hint. FindMax scans octabytes at addresses .
Computing that address today takes two instructions (a shift or multiply by 8,
then an add). 8ADDU $addr,$k,$base is precisely in one
op — the exact idiom these opcodes exist for. (These are unsigned adds, so they
never trip.)
Answer sketch. Each nADDU $X,$Y,$Z sets with ; as an even/odd pair its odd opcode makes
an immediate byte, per §2.2's rule. (The exact opcode-to-scale assignment inside
0x28–0x2F follows the even/odd pairing — confirm it against the machine's
op table / Fascicle 1 rather than guessing.) With 8ADDU, an address
computation that was, say, SLU $t,$k,3 then ADDU $addr,$t,$base collapses to
the single 8ADDU $addr,$k,$base — one fewer instruction (and one fewer live
register) on every iteration of FindMax's loop, so the oops count of Exercise 6
drops by exactly the loop-trip count. This is why scaled-add opcodes earn a slot
in a RISC that is otherwise ruthless about instruction economy: indexed access
into an array of -byte elements is the single most common address pattern in
real code. (Sanity: 8ADDU of , gives
.)
6. FindMax oops vs. ; (rating M25 · course exercise)
Problem. FindMax (Algorithm 1.2.10M) scans
after initializing its running maximum to , updating whenever it sees a
larger key; let be the number of updates (the number of times step M4
fires). (a) Argue that its instrumented cost has the form for constants fixed by the listing, and that
always. (b) Show that over a uniformly random permutation, , where , and verify it empirically with your
oops()/mems() counters.
Hint. The loop body runs times (once per scanned key); most of it is fixed work, and only step M4 (the update) is conditional — so oops is linear in with an extra per update. For : triggers an update iff it exceeds every key at positions , i.e. iff it is the maximum of that suffix — a record. Records in a random permutation are the classic computation from Module 02.
Answer sketch. (a) Every pass through the scan executes the fixed M2/M3/M5 instructions; step M4 adds a constant number of instructions only when it fires. So : (really plus setup, folded into ) for the mandatory scan, for the updates. The exact constants come from counting the 15-instruction listing embedded in the lab's stage 4 (read them off there — do not trust memory). Memory cost is exactly mems: read once at init, then read once for each — no stores. (b) Let indicate that is a suffix record (exceeds all of ). Position is the maximum of its length- suffix with probability , and (position is the initial value, not an update). By linearity,
Empirically, averaging over random permutations gives for , for , for — matching . This is the same you met in Module 02's analysis and Module 04's reservoir-sampling replacement count; the machine and the mathematics agree, as they must.
7. Long division: 128 64 without rD (rating 30 · course exercise)
Problem. Full MMIX's DIVU divides a 128-bit dividend (rD:$Y) by a
64-bit divisor; MMIX-LITE has no rD, so its DIVU is plain 64-by-64. Write an
MMIX-LITE routine that divides a 128-bit dividend (held in two registers,
high:low) by a 64-bit divisor, producing a 64-bit quotient and remainder, using
only the subset's opcodes. Explain why the shift discipline of §3.3 matters.
Hint. Do binary long division: run the standard shift-subtract loop 64 times.
Keep a running remainder (initially ); each iteration shift the 128-bit
dividend left by one into 's low bit, then if subtract
it and set the corresponding quotient bit. You need SLU/SRU to move bits
across the register boundary, CMPU for the unsigned compare, SUBU for the
conditional subtract, and a BNN/BNZ branch to choose. Recall §3.3: MMIX
shifts do not reduce the count mod 64, so a shift by 64 zeroes the register
cleanly — no accidental wraparound like x86.
Answer sketch. Non-restoring/restoring binary division, 64 iterations, one quotient bit per step (MSB first):
R <- 0 ; Q <- 0 ; remainder, quotient
repeat 64 times:
R <- (R << 1) | topbit(hi) ; SLU R,R,1 ; SRU t,hi,63 ; OR-via-ADDU
(hi:lo) <- (hi:lo) << 1 ; shift the 128-bit dividend left one
if CMPU(R, divisor) >= 0: ; R >= divisor ?
R <- R - divisor ; SUBU
set this step's quotient bit ; SLU Q,Q,1 ; ADDU Q,Q,1
else:
Q <- Q << 1 ; SLU Q,Q,1 (bit 0)
after 64 steps: quotient = Q, remainder = R
Correctness is the invariant "after step , holds the top quotient bits
and the partial remainder of the top dividend bits," proved by induction
exactly as pencil-and-paper long division. The §3.3 shift semantics are what make
it safe: because a shift count of produces (not a mod-64 no-op), the bit
you extract with SRU t,hi,63 and the boundary shifts behave definitely, with no
architecture-specific surprises. This routine is what rD buys you in one
instruction — full MMIX's DIVU with a nonzero rD does this 128-bit division in
hardware; feeling its absence here is the point of the exercise. (Cost: ~64 loop
iterations of a handful of oops each — orders of magnitude more than a single
hardware DIVU, which is why the wide-divide register exists.)
Your solutions
Use this space to log your own work — a restated problem, your approach, and how it compared with the sketch above.
Exercise N (rating RR)
Approach. Answer / proof. Compared with the sketch: what you did differently, what you missed.