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.
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.
1 · The von Neumann architecture essential
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.
2 · Inside the CPU essential
| Part | Job |
|---|---|
| ALU (arithmetic logic unit) | does the math & logic — the adders from df7 live here |
| Registers | a few ultra-fast storage slots the ALU works on directly |
| Control unit | decodes each instruction and steers the other parts |
| Program counter (PC) | holds the address of the next instruction |
| Instruction register | holds the instruction currently being executed |
| Bus | the 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 CPU runs one loop forever: fetch the instruction at the PC, decode what it means, execute it, advance the PC, repeat. Let's build a working (model) CPU with 5 instructions and run a program that computes (3 + 4) × 2 and stores it.
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
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
There is no single memory that is both huge and instant, so computers stack layers: tiny-and-fast at the top, huge-and-slow at the bottom. The CPU checks the fast layers first. The numbers below are approximate orders of magnitude and vary widely by hardware — treat them as ratios, not guarantees.
| Level | Typical size | Approx. latency (order of magnitude) | Analogy |
|---|---|---|---|
| Registers | ~KB (dozens) | < 1 ns (fraction of a cycle) | in your hand |
| L1 cache | ~32–64 KB | ~1 ns | on your desk |
| L2 cache | ~256 KB–1 MB | a few ns | desk drawer |
| L3 cache | ~ several MB | ~10 ns | bookshelf |
| Main memory (RAM) | GBs | ~100 ns | down the hall |
| SSD / disk | TBs | ~10 µs – 10 ms | in another building |
5 · Caching & locality of reference advanced
A cache keeps recently/nearby-used data in a fast layer, betting on locality: temporal (you'll reuse what you just used) and spatial (you'll use data next to what you just used). Caches load a whole cache line (e.g. 64 bytes) at once, so touching neighbours is nearly free — but jumping around memory pays the full RAM latency each time.
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:
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.
6 · A taste of assembly professional
High-level code compiles down to assembly — human-readable names for the CPU's raw instructions. Here is roughly what c = a + b becomes on a register machine (x86-flavoured pseudocode). Notice it is exactly our tiny CPU: move into a register, add, store back.
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.
✓ 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
In the fetch–decode–execute cycle, what does the program counter (PC) do, and how do loops and branches happen?
Show answer
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
🪜 Practice ladder beginner → industry
Six graded exercises, easy to real-world. Try each before opening its solution.
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.
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)) # 12Decode/execute is just a dispatch on the opcode; adding an operation is adding one branch. 20 − 5 − 3 = 12.
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.
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.
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 orderBoth 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.
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.