AI EngineeringZero to ProductionHome·About·What’s new·Contact
Developer Foundations · Chapter DF8

Computer organization

Open the box: the von Neumann design, the parts of a CPU, a fetch-decode-execute cycle you actually run, the memory hierarchy by order-of-magnitude latency, cache locality, and a taste of assembly.

⏱️ ~3 hours🧪 6 runnable labs🎯 Beginner→Industry

Learning objectives

  • Describe the von Neumann architecture and the stored-program idea.
  • Name the CPU's parts — ALU, registers, control unit, program counter — and their jobs.
  • Trace the fetch–decode–execute cycle and run a tiny instruction set in Python.
  • Order the memory hierarchy by approximate latency and explain why it exists.
  • Explain locality of reference and why row-major traversal is faster.
  • Read a few lines of assembly and map them to the CPU model.
🌱 Start here — from zeroA computer is a machine that fetches an instruction, does it, and repeats — billions of times a second. This lesson opens the box: the von Neumann design, the parts of a CPU, the fetch-decode-execute loop (which we actually simulate), the memory hierarchy, and why cache-friendly code runs faster. The tiny-CPU output is real; the cache timings are labelled as machine-dependent because they genuinely vary by hardware.
These are models, not siliconThe Python 'CPU' and 'cache' here are teaching models — they illustrate the concepts and run correctly, but they are not cycle-accurate simulations of a real processor. Latency figures are orders of magnitude, approximate, and vary by chip.

1 · The von Neumann architecture essential

Here's something that sounds impossible until you see how it's done. You download a program you've never run — a game, a compiler, an AI model — and your computer, which was built years before that program existed, just runs it. No rewiring, no new chip. The same machine that ran your spreadsheet this morning runs the new thing this afternoon. How can one fixed piece of hardware do genuinely new things it was never designed for? The answer is a single idea from 1945, and once you get it, the whole box stops being magic.

Think of a player piano versus a musician. An old music box is built to play one tune — the melody is baked into the metal drum, and to change the song you'd have to rebuild the machine. A human pianist is different: hand them any sheet of music and they play it, because the instructions (the notes) are separate from the performer (the hands). The von Neumann insight is to make the computer the pianist, not the music box: keep the instructions in changeable memory, right alongside the data they work on, and build one machine that reads whatever instructions it finds and does them. Swap the sheet music, get a new song, same pianist.

The common misconception is that a computer "knows" how to run your specific program — that somewhere inside is a Photoshop chip and a browser chip. It doesn't. There is one dumb loop that reads a number, treats it as a command, does that command, and reads the next number. Photoshop is just a very long, very clever list of those numbers sitting in memory. That's also why the same layout enables both the wonders and the horrors of computing: because code is just data, a program can write other programs (compilers, JITs), modify itself, or — if you're careless — be overwritten by data pretending to be code (that's the root of a whole class of security bugs). The blur between "code" and "data" is the feature and the footgun.

The von Neumann architecture (1945) is the blueprint for almost every computer: a single memory holds both instructions and data (the stored-program idea), a CPU reads and executes them, and I/O connects to the outside world. Because code is just data in memory, a program can be loaded, replaced, even generated at runtime — that is why software is soft.

Input keyboard, net Memory code + data CPU ALU + control Output screen, disk
🗺️ How to read this diagram

Read it left to right, following the arrows: data flows in from the world, sits in memory, gets worked on by the CPU, and flows back out.

  • The green Input box on the far left is the outside world coming in — keyboard, network — feeding bytes into the machine.
  • The two middle boxes are the heart of the idea: Memory holds both code and data in one place (the stored-program insight), and the CPU — its ALU and control unit — reads from that memory and executes what it finds.
  • The green Output box on the far right is the result heading back out — screen, disk. The brand-coloured arrows are the one-way flow tying the four parts into a single pipeline.

In short: One machine, one shared memory holding instructions next to data — that single layout is why fixed hardware can run programs it was never designed for.

The von Neumann bottleneckBecause instructions and data share one memory bus, the CPU can stall waiting on memory. Caches (section 4) and clever pipelining exist largely to hide this bottleneck.

2 · Inside the CPU essential

Picture the most focused person you've ever worked with — and also the most forgetful. That's the CPU. It can do exactly one small thing at a time with blinding speed, but it has almost no memory of its own; it has to be told, step by step, where to find each number and where to put each result. If you've ever watched a line cook at a tiny station — one cutting board, two hands, ingredients passed to them one at a time from a big fridge across the kitchen — you already understand the shape of a processor. The genius isn't in remembering; it's in doing one thing, fast, forever.

Here's the part that surprises people: the CPU does not reach into your gigabytes of RAM and compute there. It can only operate on a handful of tiny slots called registers — think three or four spaces on that cutting board. To add two numbers living in memory, the control unit must first carry them into registers, let the ALU add, then carry the answer back out. Every part in the table below exists to serve that one rhythm: fetch a number in, do arithmetic, put a number back. The ALU is the knife (it does the math and logic — literally the adder circuits you built in df7); the registers are the cutting board; the control unit is the head chef reading the order and telling everyone what to do next; the program counter is the finger on the recipe marking which step comes next; and the bus is the runner ferrying ingredients between the station and the fridge.

The mistake to avoid is imagining the CPU as one big brain that "understands" your program. It understands nothing — it decodes one instruction, does the mechanical thing that instruction names, and forgets it happened. All the intelligence lives in the ordering of instructions, not in the chip. Keep that division of labour in mind as you read each part below: nobody here is smart, but together, running billions of times a second, they run everything you've ever used.

Memory code + data Control unit decode + steer Program counter next-instr address Registers few fast slots ALU math + logic the bus (arrows) ferries data; nothing computes outside a register The CPU is parts with one job each. The control unit decodes; the program counter says what's next; data is carried into registers where the ALU — and only the ALU — does the arithmetic, then carried back out.
🗺️ How to read this diagram

Scan left to right: data starts in Memory, and work only ever happens over on the right, inside the registers/ALU pair.

  • The Memory box on the left holds both code and data; the arrows leaving it are the bus ferrying bytes in and out — memory is far away and slow.
  • The top row — control unit and program counter — is the "management": the control unit decodes each instruction and steers everyone, the PC tracks which instruction comes next.
  • The bottom-right pair — registers and ALU — is where computation actually occurs. The ALU can only touch values sitting in registers, so every number must be carried in first and the result carried back.

In short: Nobody in the box is smart; each part does one mechanical job, and the ALU never reaches into RAM — it works only on the handful of registers beside it.

PartJob
ALU (arithmetic logic unit)does the math & logic — the adders from df7 live here
Registersa few ultra-fast storage slots the ALU works on directly
Control unitdecodes each instruction and steers the other parts
Program counter (PC)holds the address of the next instruction
Instruction registerholds the instruction currently being executed
Busthe wires moving data between CPU, memory and I/O

Registers are to the ALU what your hands are to you: the only place work actually happens. Everything else is about getting data into a register, operating, and putting the result back.

3 · Fetch–decode–execute: simulate a tiny CPU advanced

The first time this clicked for me, it was a little disappointing — and then thrilling. I had always assumed a processor was doing something profound, some inscrutable dance too clever to follow. Then I saw the actual loop it runs, and it fit in about six lines. That was the deflating part. The thrilling part came right after: this tiny loop is all there is. Every app, every operating system, every neural network you'll ever run is this same tediously simple cycle, repeated a few billion times a second. There is no second, smarter mechanism hiding behind it. This is the whole engine.

The loop has four beats, and the everyday analogy is following a recipe with your finger on the page. Fetch: look at the step your finger is on. Decode: work out what it's asking ("add the eggs" — okay, that means this action). Execute: actually do it. Advance: move your finger to the next line. Then repeat. The "finger on the page" is the program counter (PC) — a single number holding which instruction comes next — and that humble number is secretly where all the power lives. Normally the finger just slides down one line at a time, which gives you a boring straight-line program. But what if a step could say "move your finger back up to line 2"? Now you've got a loop. And "if the bowl is empty, skip to line 9"? That's an if. Every loop and branch you've ever written is really just an instruction that writes a new value into the PC instead of letting it tick forward.

The subtlety that trips beginners is exactly that PC-advance step. It's easy to forget that after most instructions the PC quietly increments by one — and that jumps work precisely by overriding that increment. If you add a jump instruction but still let the PC increment afterward, your jump lands one step past where you meant, and the bug is maddening because the code "looks" right. Watch for that in the lab and the exercises below. For now, meet the model: we build a working (if deliberately tiny) CPU with 5 instructions and run a program that computes (3 + 4) × 2 and stores it, printing the state after every beat so you can watch the cycle turn.

Fetch read at PC Decode what op? Execute do it PC += 1 next instr
🗺️ How to read this diagram

Read left to right through the four boxes — that is the order the CPU runs them for every single instruction, then it loops back to the start.

  • The first box, Fetch, reads the instruction sitting at the address the program counter points to — "read at PC".
  • The middle two boxes, Decode then Execute, work out which operation the fetched number names and then actually do it — decode asks "what op?", execute performs it.
  • The last box, PC += 1, advances the program counter to the next instruction. The brand-coloured arrows chain the four beats in sequence, and the cycle repeats billions of times a second.

In short: Fetch, decode, execute, advance — this tiny four-beat loop is the entire engine that runs every program you will ever use.

Example code for learning — review, test, and adapt it before running against real or production systems. Commands can create, change, or delete resources. See the Terms & Disclaimer.
Python · a 5-instruction toy CPU (runs)
tiny_cpu.py# Instruction set: (op, arg). Registers: ACC (accumulator) + a small RAM.
#   LOAD  v  -> ACC = v
#   ADD   v  -> ACC = ACC + v
#   MUL   v  -> ACC = ACC * v
#   STORE a  -> RAM[a] = ACC
#   HALT     -> stop
program = [
    ("LOAD", 3),
    ("ADD", 4),
    ("MUL", 2),
    ("STORE", 0),
    ("HALT", None),
]

def run(program):
    PC, ACC, RAM = 0, 0, [None] * 8       # program counter, accumulator, memory
    while True:
        op, arg = program[PC]             # FETCH
        # DECODE + EXECUTE
        if op == "LOAD":  ACC = arg
        elif op == "ADD": ACC += arg
        elif op == "MUL": ACC *= arg
        elif op == "STORE": RAM[arg] = ACC
        elif op == "HALT": break
        print(f"PC={PC} {op:<5} arg={arg}  -> ACC={ACC}")
        PC += 1                            # advance
    return RAM

ram = run(program)
print("RAM[0] =", ram[0])
PC=0 LOAD  arg=3  -> ACC=3
PC=1 ADD   arg=4  -> ACC=7
PC=2 MUL   arg=2  -> ACC=14
PC=3 STORE arg=0  -> ACC=14
RAM[0] = 14
This is a real CPU in miniatureAdd a JMP instruction that sets the PC directly and you have branching and loops — enough to be Turing-complete. Every processor is this loop, just wider, pipelined, and running billions of times per second.

4 · The memory hierarchy intermediate

Here's a question that exposes how most of us secretly picture memory. If registers are so blindingly fast, why doesn't the computer just make all its memory out of registers and be done with it? A machine with 16 GB of register-speed storage would be a monster. The answer is brutal and physical: you can't. Fast memory is expensive, hot, and takes up huge amounts of chip area, so you can only afford a tiny amount of it. Cheap memory is slow. There is no material on Earth that is simultaneously enormous, instant, and affordable — so every computer ever built quietly makes a compromise, and that compromise is the memory hierarchy.

The analogy that makes the numbers stick is your own desk. The papers in your hands (registers) you can use instantly. What's on the desk (L1 cache) takes a half-second reach. A desk drawer (L2) is a couple of seconds. The bookshelf across the room (L3) is a short walk. Going down the hall to the archive (main RAM) is a real trip. And fetching a box from a warehouse in another city (disk or network) is a different order of experience entirely — you plan your whole day around not having to do it. Crucially, these aren't gentle 20%-slower steps: each layer down is roughly ten to a hundred times slower than the one above. That's why the table shows latencies as orders of magnitude, not tidy numbers.

The trap here is treating "memory access" as one flat cost — as if reading any variable costs the same. It doesn't, and the gap is enormous: an L1 hit is around a nanosecond, a trip to RAM around a hundred. The whole game the hardware plays — and the reason the next two sections exist — is to keep the data you're about to need in the fast layers, so you rarely pay the down-the-hall or another-city price. The CPU checks the fast layers first and only falls through to slower ones on a miss. Read the numbers below as ratios you should feel in your gut, not guarantees: exact figures vary widely by chip, but "RAM is ~100× slower than L1, disk is astronomically slower than RAM" is stable enough to reason with — and, as section 6's latency-budget exercise shows, it's exactly how senior engineers locate a performance problem.

Registers <1 ns · ~KB L1 cache ~1 ns · ~32–64 KB L2 cache ~few ns · ~256 KB–1 MB L3 cache ~10 ns · ~several MB Main memory (RAM) ~100 ns · GBs SSD / disk ~10 µs–10 ms · TBs smaller + faster → bigger + slower each step down is ~10–100× slower — the CPU checks the top first, falls through on a miss Fast is small, big is slow. No material is huge, instant, and cheap at once, so memory is a pyramid: a sliver of register-speed storage on top, oceans of slow disk at the bottom, each layer roughly an order of magnitude slower than the one above.
🗺️ How to read this diagram

Read top to bottom, like walking away from your desk: the top layers are close and instant, each layer down is farther and slower.

  • The bars get wider going down — that width is capacity: registers hold kilobytes, disk holds terabytes.
  • The latency label beside each bar is the cost of a read at that layer; notice it climbs from under a nanosecond to milliseconds — that is six-plus orders of magnitude, not a gentle slope.
  • The rotated arrow on the left is the whole trade-off in one line: as you go down you gain size but lose speed. The CPU always tries the top layers first and only "falls through" to a slower one when it misses.

In short: Memory is a pyramid, not a flat pool — keeping the data you need in the top layers is what makes a program feel fast, which is exactly what locality (next section) buys you.

LevelTypical sizeApprox. latency (order of magnitude)Analogy
Registers~KB (dozens)< 1 ns (fraction of a cycle)in your hand
L1 cache~32–64 KB~1 nson your desk
L2 cache~256 KB–1 MBa few nsdesk drawer
L3 cache~ several MB~10 nsbookshelf
Main memory (RAM)GBs~100 nsdown the hall
SSD / diskTBs~10 µs – 10 msin another building
Why these ratios matterA RAM access is roughly two orders of magnitude slower than an L1 hit; a disk seek is orders of magnitude slower again. Programs feel fast when they keep the data they need in the fast layers — which is what locality (next) is about.

5 · Caching & locality of reference advanced

Two programs. Same answer. Same number of additions. One runs several times faster than the other. Nothing about the algorithm changed — not the Big-O, not the operation count — yet one is sluggish and one is snappy. When engineers first hit this it feels like a bug in reality: how can doing the identical work cost wildly different amounts? This section is the answer, and it's one of the most practical things in the whole chapter, because it turns "my code is mysteriously slow" into "my code is fighting the cache" — a problem you can actually fix.

Here's the intuition. Imagine a librarian fetching books for you from a back room. Fetching one book takes a while, so she's clever: every time you ask for a book, she brings back the whole shelf it was sitting on, betting you'll want its neighbours next. If your requests wander the shelf in order, she almost never has to go back — the next book is already on the desk. That bet is locality, and it comes in two flavours: temporal ("you'll probably want that same book again soon") and spatial ("you'll probably want the book right next to it"). A real cache does exactly this: it doesn't fetch one byte, it fetches a whole cache line (typically 64 bytes) at once. So touching data right next to what you just touched is nearly free — it's already on the desk — while jumping to a random far-off address forces a fresh trip to the back room at full RAM latency.

The mistake almost everyone makes is assuming the order in which you walk through data doesn't matter — that a loop is a loop. It matters enormously. Walking memory in the order it's physically laid out means every fetched line gets fully used before the next one is needed (you read all 16 books off the shelf she brought). Skipping around — grabbing one item, then leaping thousands of addresses away — means each expensive line delivers a single useful item before you abandon it and force another trip. That is the entire performance difference between the two programs I opened with, and there's a textbook way to see it happen, which is exactly what the demonstration below sets up.

The canonical demonstration: summing a 2-D array row-major (along rows, the way it's laid out in memory) touches consecutive addresses and rides the cache lines; column-major jumps by a whole row each step and thrashes the cache. The code below measures both on your machine:

row-major: walk along the line 1 line loaded → 4 hits column-major: jump a full row each line → 1 hit, rest wasted green = accessed together · a cache line is a whole run of neighbours (~64 bytes) Same additions, different order, different speed. The cache always fetches a whole line of neighbours. Walk along the line (row-major) and every fetch is reused; jump across rows (column-major) and each expensive line delivers one item before you leave it.
🗺️ How to read this diagram

Two copies of the same grid sit side by side. The divider splits the cache-friendly walk (left) from the cache-hostile one (right); the arrow shows the order you touch cells.

  • On the left, the green top row is one cache line the hardware loaded; row-major traversal walks straight along it, so all four fetched items get used — one trip, four hits.
  • On the right, the red down-arrow shows column-major order: you touch one cell, then jump a whole row to the next. Each jump lands on a different cache line, so the other three items in every loaded line are wasted.
  • The colour is the payoff: green cells were served from one cheap load; red cells each forced a fresh load. Same number of additions, but the right side pays the RAM trip over and over.

In short: Walk memory in the order it's physically laid out and the cache works for you; skip around and you pay full latency on nearly every access.

Python · measure row-major vs column-major (runs; timings vary)
locality.pyimport time

N = 2000
# a flat list acts as an N x N matrix, row-major: element (r, c) at r*N + c
matrix = [0] * (N * N)

def sum_row_major():
    s = 0
    for r in range(N):
        base = r * N
        for c in range(N):
            s += matrix[base + c]        # consecutive addresses (cache-friendly)
    return s

def sum_col_major():
    s = 0
    for c in range(N):
        for r in range(N):
            s += matrix[r * N + c]       # jumps N elements each step (cache-hostile)
    return s

for name, fn in (("row-major", sum_row_major), ("col-major", sum_col_major)):
    t0 = time.perf_counter()
    fn()
    print(f"{name}: {time.perf_counter() - t0:.3f} s")
# Row-major is typically faster. Exact numbers depend on your CPU,
# cache sizes, and Python build -- run it and compare the two.
Why no fixed numbers hereThe two times depend entirely on your CPU, cache sizes, and Python version, so printing specific seconds would be fabricated. When you run it you should see row-major come out faster (often noticeably) — that ratio, not any absolute number, is the lesson. In Python the effect is muddied by interpreter overhead; in C/NumPy on contiguous memory it is dramatic.

6 · A taste of assembly professional

You write c = a + b. That's one line. It reads like arithmetic — three symbols and a plus sign, done. But the CPU cannot do that in one step; nothing in the machine understands the word "a" or the idea of a named variable. Somewhere between your tidy line and the silicon, that single expression gets translated into the several literal, mechanical moves the processor actually performs. Assembly is where you get to read that translation — it's the last human-readable layer before raw binary, a thin set of names sitting directly on top of the CPU's real instructions. You don't usually write it, but learning to read a few lines is like a doctor learning to read an X-ray: you finally see the bones under the skin.

The reason to care isn't nostalgia for the old days — it's that assembly makes the abstractions from this whole chapter concrete. Your single line of arithmetic doesn't map to one CPU action; it maps to the exact rhythm you've now seen three times: bring the operands into registers, let the ALU compute, carry the result back to memory. The variables a, b, c are just addresses; the CPU shuttles their contents through a register, adds, and shuttles the answer back. Seeing that spelled out is the moment the tiny CPU in section 3, the registers in section 2, and the memory in section 4 all snap together into one picture.

The misconception worth killing here is that "assembly is a scary, alien language you'll never touch." In practice it's the opposite: it's simpler than Python — just move, add, store, jump — merely more verbose, because it hides nothing. And it does show up in a real career: when you profile a hot loop and stare at the disassembly to see why the compiler didn't vectorize it, when a debugger drops you into instructions because the source is optimized away, or when you're squeezing latency out of an inference kernel. Below is roughly what c = a + b becomes on a register machine (x86-flavoured pseudocode). Read it slowly and notice: it is exactly our tiny CPU — move into a register, add, store back.

c = a + b one source line mov eax, [a] add eax, [b] mov [c], eax LOAD ADD STORE One tidy line, three mechanical moves. The compiler expands c = a + b into load-add-store — the very same LOAD/ADD/STORE shape you simulated in the toy CPU.
🗺️ How to read this diagram

Read left to right: your one line of source on the left fans out into the three instructions the CPU actually runs, and the far-right column names each one in the toy CPU's vocabulary.

  • The left blue box is what you wrote — a single, human-friendly expression that the hardware cannot do in one step.
  • The middle column is the assembly it compiles to: move a into a register, add b into that register, move the register's result back to c.
  • The right green boxes map each instruction onto the toy CPU from section 3 — mov→LOAD, add→ADD, mov [c]→STORE — showing the model and real silicon share one shape.

In short: Variables are just addresses; every arithmetic line is really "carry operands into registers, compute, carry the answer back" — the rhythm of the whole chapter.

Assembly-flavoured pseudocode for c = a + b (illustrative)
add.asmmov  eax, [a]     ; load variable a from memory into register eax
add  eax, [b]     ; eax = eax + b   (the ALU does the add)
mov  [c], eax     ; store the register result back into variable c

Compare to our toy CPU: mov eax,[a] is LOAD, add eax,[b] is ADD, mov [c],eax is STORE. Real ISAs (x86, ARM, RISC-V) have hundreds of instructions, but the shape — move to register, compute, store — is the same one you simulated.

Where this shows upYou rarely write assembly, but you read it when profiling hot loops, debugging with a disassembler, or understanding why a compiler optimization (inlining, vectorization) sped code up. It's the ground truth beneath every language.

✓ Checkpoint — you can move on when you can…

  • Draw the von Neumann model and explain the stored-program idea and its bottleneck.
  • Name the ALU, registers, control unit and PC and say what each does.
  • Walk the fetch–decode–execute cycle through a few toy instructions.
  • Order registers → L1/L2/L3 → RAM → disk by approximate latency.
  • Explain why row-major traversal is cache-friendlier than column-major.
  • Map three lines of assembly onto the CPU model.

Knowledge check check yourself

✓ Knowledge check

In the fetch–decode–execute cycle, what does the program counter (PC) do, and how do loops and branches happen?

Show answer
The PC holds the address of the next instruction; after each instruction it normally advances to the following one. A jump/branch instruction overwrites the PC with a different address, so execution continues elsewhere — repeatedly jumping back gives a loop, and conditionally jumping gives an if/branch.
✓ Knowledge check

Two functions sum the same 2-D array; one goes row-by-row, the other column-by-column, and the row version is faster. Why — and why can't we print a fixed speed-up number?

Show answer
Row-major traversal walks consecutive memory addresses, so each cache line loaded serves many accesses (spatial locality); column-major jumps a full row each step, so it misses the cache and pays RAM latency repeatedly. We can't quote a fixed number because the speed-up depends on the CPU, cache sizes, array size and language/runtime — it's a machine-dependent ratio, not a constant.

🪜 Practice ladder beginner → industry

Six graded exercises, easy to real-world. Try each before opening its solution.

Exercise 1 · Trace the PC by handBeginner

Context: Before simulating a CPU you should be able to predict the program counter's path on paper.

Your task: Given a 4-instruction straight-line program, write out the sequence of PC values and the final accumulator value for LOAD 5; ADD 10; ADD 10; HALT.

Requirements:

  • List the PC value at each step (0, 1, 2, 3)
  • Track the accumulator after each instruction
  • State the final ACC and where execution stops

💡 Hint: With no jumps the PC just increments by one each cycle until HALT.

Show solution
# PC=0 LOAD 5 -> ACC=5
# PC=1 ADD 10 -> ACC=15
# PC=2 ADD 10 -> ACC=25
# PC=3 HALT    -> stop
print('PC path: 0,1,2,3  final ACC = 25')

Straight-line code means the PC is just 0,1,2,3. The accumulator ends at 25; HALT stops the loop at PC=3.

Exercise 2 · Add a SUB instructionIntermediate

Context: Extending an instruction set is the most direct way to prove you understand decode/execute.

Your task: Take the toy CPU and add a SUB v instruction (ACC = ACC − v); run a program that computes 20 − 5 − 3.

Requirements:

  • Add one decode branch for SUB
  • Write a program: LOAD 20; SUB 5; SUB 3; HALT
  • Print the final accumulator (should be 12)

💡 Hint: It's one more elif in the execute step — mirror how ADD is handled.

Show solution
program = [('LOAD',20),('SUB',5),('SUB',3),('HALT',None)]
def run(prog):
    PC, ACC = 0, 0
    while True:
        op, arg = prog[PC]
        if   op=='LOAD': ACC = arg
        elif op=='ADD':  ACC += arg
        elif op=='SUB':  ACC -= arg
        elif op=='HALT': break
        PC += 1
    return ACC
print(run(program))   # 12

Decode/execute is just a dispatch on the opcode; adding an operation is adding one branch. 20 − 5 − 3 = 12.

Exercise 3 · Add JMP and write a loopAdvanced

Context: Jumps are what separate a calculator from a computer; implementing one gives you loops and conditionals.

Your task: Add JMP addr (set PC = addr) and JZ addr (jump if ACC == 0) to the toy CPU, then write a countdown loop from 3 to 0.

Requirements:

  • Implement JMP by assigning to PC and not auto-incrementing after it
  • Implement JZ (jump-if-zero) for the branch
  • Write a loop that decrements ACC until it hits 0
  • Print each ACC value on the way down

💡 Hint: After a taken jump, continue the loop so you don't also run the PC += 1.

Show solution
prog = [
    ('LOAD', 3),   # 0
    ('PRINT', None), # 1
    ('SUB', 1),    # 2
    ('JZ', 6),     # 3  if ACC==0 jump to HALT
    ('JMP', 1),    # 4  else loop back to PRINT
    ('NOP', None), # 5
    ('HALT', None),# 6
]
def run(p):
    PC, ACC = 0, 0
    while True:
        op, arg = p[PC]
        if   op=='LOAD':  ACC = arg
        elif op=='SUB':   ACC -= arg
        elif op=='PRINT': print('ACC =', ACC)
        elif op=='JZ':
            if ACC == 0: PC = arg; continue
        elif op=='JMP':   PC = arg; continue
        elif op=='HALT':  break
        PC += 1
run(prog)   # prints ACC = 3, then 2, then 1

It prints 3, 2, 1: after ACC reaches 0 the JZ fires and jumps to HALT before the next PRINT. JMP/JZ overwrite the PC and continue so the normal increment is skipped. Jumping back to PRINT builds a loop; JZ exits it — branching and iteration from two instructions.

Exercise 4 · Measure locality yourselfExpert

Context: Cache effects are invisible until you measure them; a clean micro-benchmark is a real engineering skill (and a common interview probe).

Your task: Time row-major vs column-major summation of an N×N matrix and report the ratio, being explicit that absolute numbers are machine-dependent.

Requirements:

  • Build an N×N array (flat list, row-major layout)
  • Time both traversal orders with time.perf_counter
  • Print the ratio col-major/row-major, not fabricated absolute seconds
  • State clearly that the numbers vary by machine and that row-major should win

💡 Hint: Warm up once before timing, and keep N large enough (e.g. 2000) that the array exceeds cache.

Show solution
import time
N = 2000
m = [0] * (N*N)
def row():
    s = 0
    for r in range(N):
        b = r*N
        for c in range(N): s += m[b+c]
    return s
def col():
    s = 0
    for c in range(N):
        for r in range(N): s += m[r*N+c]
    return s
row(); col()                       # warm up
t=time.perf_counter(); row(); tr=time.perf_counter()-t
t=time.perf_counter(); col(); tc=time.perf_counter()-t
print(f'row {tr:.3f}s  col {tc:.3f}s  ratio {tc/tr:.2f}x')
# absolute times are machine-dependent; expect ratio > 1 (row faster)

Report the ratio, never a hard-coded absolute time — the point is that column-major pays repeated cache misses. In pure Python the interpreter overhead shrinks the gap; the same experiment in C or on a NumPy contiguous array shows a much larger effect.

Exercise 5 · Reorder a hot loop for the cacheProfessional

Context: A frequent real optimization is swapping loop order so memory is walked contiguously — a one-line change that can multiply throughput.

Your task: You inherit a function that copies a matrix column-by-column and is slow. Rewrite it to be cache-friendly and explain the fix.

Requirements:

  • Identify why the original (column-outer) order is cache-hostile
  • Rewrite so the inner loop walks contiguous memory
  • Keep the result identical (same copied data)
  • Explain the change in terms of cache lines and spatial locality

💡 Hint: Make the inner loop index the fastest-varying (contiguous) dimension of the layout.

Show solution
# SLOW: inner loop jumps a full row each step
def copy_slow(src, dst, N):
    for c in range(N):
        for r in range(N):
            dst[r*N + c] = src[r*N + c]

# FAST: inner loop walks consecutive addresses
def copy_fast(src, dst, N):
    for r in range(N):
        base = r*N
        for c in range(N):
            dst[base + c] = src[base + c]

N = 4
src = list(range(N*N)); dst = [0]*(N*N)
copy_fast(src, dst, N)
print(dst == src)   # True -- same result, cache-friendly order

Both produce identical output; only the memory access order differs. The fast version reads and writes consecutive addresses, so each 64-byte cache line loaded serves many iterations instead of one — the whole optimization is choosing the contiguous dimension as the inner loop.

Exercise 6 · Explain a real latency budgetIndustry scenario

Context: Senior engineers reason about performance with the memory hierarchy in their head — 'this is a cache-miss problem' vs 'this is a disk problem' changes the entire fix.

Your task: A service's p99 latency regressed. Using approximate hierarchy latencies, write the reasoning that distinguishes a cache/RAM issue from a disk/network issue and pick the right first move.

Requirements:

  • Lay out the order-of-magnitude latencies (L1 ~1ns, RAM ~100ns, SSD ~10µs+, network ~ms)
  • Explain how the magnitude of the regression points at a layer
  • Give the diagnostic you'd run for each hypothesis
  • State clearly these are approximate, machine-dependent orders of magnitude

💡 Hint: A jump from microseconds to milliseconds smells like disk or network, not cache; a modest constant-factor slowdown across the board smells like locality/cache.

Show solution
# Approximate orders of magnitude (machine-dependent):
#   L1 hit   ~1 ns        RAM     ~100 ns
#   L3 hit   ~10 ns       SSD     ~10-100 us
#   disk seek ~ms         network ~ms+
#
# Reasoning:
# * A 2-3x CPU-bound slowdown with no I/O change -> likely locality/cache
#   (data grew past L2/L3, or an access pattern regressed). Fix: profile,
#   check working-set size and loop/access order.
# * A jump from ~us to ~ms in p99 -> crossed into disk or network territory
#   (cache miss on a hot dataset, an added remote call, a slow query).
#   Fix: trace the request, look for new I/O, check cache hit-rate.
print('classify by the MAGNITUDE of the regression, then trace that layer')

The skill is mapping a symptom to a layer by its order of magnitude: nanosecond-to-microsecond effects are cache/RAM; microsecond-to-millisecond jumps are disk/network. These figures are approximate and vary by hardware, but the ratios are stable enough to guide the first diagnostic — which is what stops you optimizing the wrong layer.

© 2026 studybydoing.in · AI Engineering: Zero to Production · All rights reserved. · About · Privacy Policy · Terms · Contact
Educational content, provided as-is and without warranty. Code samples are examples — review, test, and adapt them before using in production. See the Terms of Use & Disclaimer. Use at your own risk.
© studybydoing.in