Index Ai Academy
Phase 7 · Software engineering · How computers run code

How computers run code

Bits, memory, the processor, and what really happens when Python runs.

About 30 minutes · Free lesson

Start this lesson

Why it matters

Every tool in this course, from a ten-line script to a large language model, runs on the same machine: a processor stepping through instructions and a long row of numbered memory slots. Once you can picture that machine, many puzzles go away. You'll see why one change shows up in two variables, why an API call is slow next to a loop, and why NumPy beats plain Python.

What you'll be able to do

  • Explain how text, numbers and code all end up as bytes in memory
  • Predict when two variables share one object, and avoid the bugs that causes
  • Describe how a processor runs instructions, and how Python's interpreter fits on top
  • Build a small stack machine, the same design Python's own virtual machine uses

What it covers

  1. Everything is numbers
  2. Memory: a long row of numbered boxes
  3. The processor: fetch, decode, execute
  4. A stack machine
  5. Count what the interpreter does
  6. Three beliefs worth correcting

Everything is numbers

Think of a row of light switches, each one either off or on. A computer stores everything that way, as bits: tiny switches that are off (0) or on (1). Eight bits make a byte, which can hold a whole number from 0 to 255.

Everything else is built from bytes, the way a paint-by-numbers picture is built from numbered colors. A photo is a list of color numbers. A song is a list of loudness numbers. Text is a list of character codes.

Python shows you the numbers behind text. ord gives a character's code, and encode turns a string into the bytes a file or a network would carry:

print(ord("A"), ord("a"), ord("€"))
print(bin(65))               # 65 written in base 2: the bits themselves
data = "café".encode("utf-8")
print(data, len(data))       # é needs two bytes in UTF-8
print(data.decode("utf-8"))  # and back again
It runs right here in your browser, with nothing to install.

The rule that maps characters to numbers is called an encoding. Think of it as a codebook both sides agree on. UTF-8 is the one the web uses. It stores plain English letters in one byte each, and other characters in two to four bytes. When text arrives scrambled, like café, someone decoded UTF-8 bytes with the wrong codebook.

In plain words: a letter, a color and a sound are all numbers to a computer. An encoding is the agreement about which number means what.

Quick check
What does this print?
print(ord("A"), bin(5))
  1. 65 101
  2. 65 0b101
  3. 1 0b5
  4. A 5
Show the answer

65 0b101

ord gives the character's number, 65, and bin writes 5 in binary with a 0b in front: 0b101. Letters and numbers are all stored as bits. Each character has a number.

Quick check
How many bytes does "naïve" take in UTF-8? (The ï is not a plain English letter.)
  1. 10
  2. It depends on the font
  3. 5
  4. 6
Show the answer

6

Four plain letters take one byte each, and ï takes two: 6 bytes. Fonts only change how text looks, never how it's stored.

Memory: a long row of numbered boxes

Your computer's working memory (RAM) is like a long street of mailboxes. Each box holds one byte and has a number, its address. A laptop with 16 GB of RAM has about 16 billion boxes.

When Python runs prices = [4, 9], it builds a list object somewhere in memory. The name prices then points at it. A variable is a label tied to an object, not a box that holds a copy. id() shows which object a name points at.

That's why this surprises people:

a = [4, 9]
b = a              # b points at the SAME list, no copy is made
b.append(12)
print(a)           # a changed too
print(id(a) == id(b))

c = list(a)        # list() builds a new list with the same items
c.append(99)
print(a, c)

Two names for one object is called aliasing. It's like one person being called both Bill and William: if William gets a haircut, so does Bill. It's often what you want, because passing a big list to a function shouldn't copy it. But when you mean "a separate copy", say so with list(x), x.copy() or dict(x).

Try it now

Step through it. The function gets a second name for the same list, so its change is visible outside. The copy is a separate object.

Quick check
After x = {"n": 1}, y = x and y["n"] = 2, what is x["n"]?
  1. An error, because y is a copy
  2. 2
  3. 1
  4. None
Show the answer

2

y = x makes a second name for the same dictionary. Changing it through y changes the one object both names point at.

The processor: fetch, decode, execute

The CPU (central processing unit) is the part that does the work. Picture an extremely fast, extremely literal cook, following a recipe one line at a time. It knows only tiny steps: load a number from memory, add two numbers, compare them, jump to another instruction. It repeats one cycle billions of times a second:

  1. Fetch the next instruction from memory.
  2. Decode it: which operation, on which numbers?
  3. Execute it, then move on to the next one.

Those instructions are machine code: numbers the chip understands directly. People don't write it by hand. Two kinds of program translate readable code into it, and they work like the two ways of translating a speech:

  • A compiler is like translating the whole speech in advance and printing it. It translates the whole program ahead of time. C, Rust and Go work this way. The result runs fast, because the translation is already done.
  • An interpreter is like a person translating live, sentence by sentence. It reads the program and carries it out as it goes. Python works this way.

Python sits in between. It first compiles your code into bytecode, a simple instruction list for an imaginary machine. Then a virtual machine (a loop written in C) reads that bytecode one instruction at a time. You can see the bytecode with the dis module:

import dis

def total(price, qty):
    return price * qty + 5

for ins in dis.get_instructions(total):
    print(ins.opname, ins.argrepr)

Instructions starting LOAD_FAST put variables' values on the stack. LOAD_SMALL_INT puts the number 5 there. BINARY_OP pops two values and pushes the result, and RETURN_VALUE hands back whatever is on top. The exact names change a little between Python versions.

A stack machine

Python's virtual machine is a stack machine. Picture a stack of plates. Instructions push values on top, and operations take values off the top, combine them and push the answer back.

Here's 2 + 3 * 4 as stack instructions. Multiplication goes first, so its numbers go on first:

instructionstack afterward
PUSH 33
PUSH 43, 4
MUL12
PUSH 212, 2
ADD14

The whole machine is a loop and a list. That's the design you'll build in the exercise.

Why this matters: the speed ladder

Each step on this ladder is roughly a thousand times slower than the one before:

operationrough time
one CPU instructionunder a nanosecond
read from RAMabout 100 nanoseconds
read from an SSDabout 100 microseconds
network call to a server far awayabout 100 milliseconds

Those numbers are too small to feel, so stretch them. If one CPU instruction took a second, reading from RAM would take about two minutes, reading from an SSD about a day, and a call to a faraway server about three years.

So a loop over a million numbers can finish before one API call comes back. When an AI app feels slow, the model call is almost always the reason, not your Python.

The ladder also explains NumPy. A plain Python loop pays the virtual machine's cost for every item. NumPy hands the whole array to compiled code that runs the loop directly on the CPU.

Worked example

Count what the interpreter does

Python can report how many bytecode instructions a function compiles to. More work per item means a slower loop.

import dis

def one_step(x):
    return x + 1

def three_steps(x):
    y = x * 2
    y = y + 1
    return y - 3

for f in (one_step, three_steps):
    count = len(list(dis.get_instructions(f)))
    print(f.__name__, count, "instructions")
In the lesson you can change this and run it.

three_steps compiles to more instructions, and the virtual machine runs each one in turn. That's the interpreter's overhead. Compiled languages and NumPy avoid most of it by running machine code directly.

Quick check
Your script loops over 10,000 rows, then makes one call to a model API. Where does most of the time go?
  1. Splitting the time evenly
  2. The model API call: a network round trip plus the model's work
  3. Reading the variables from RAM
  4. The loop, because Python is interpreted
Show the answer

The model API call: a network round trip plus the model's work

A loop over 10,000 items takes milliseconds. A network call takes at least tens of milliseconds, and a model reply often takes seconds.

Three beliefs worth correcting

A variable doesn't hold its list. It holds a reference to where the list lives, like a contact card holding someone's address. That's why two names can point at one list, and changing it through one shows up through the other.

Python does compile. It turns your code into bytecode, then an interpreter runs that. The bytecode isn't machine code, which is a big part of why a Python loop is slower than one in C.

A slow app rarely needs a faster language. You wouldn't buy a faster car to shorten a three-year wait for a letter. Look at the speed ladder again. A network call costs millions of times what an addition costs, so waiting for a model or a database usually dwarfs everything your own code does.

Practice what you just read

  • 4 code examples you can edit and run
  • a practice exercise that checks your code
  • a 5-question quiz
  • 6 review cards for spaced repetition

Start this lesson

Try a quiz question

What does a compiler do that an interpreter doesn't?

  1. Translates the whole program into machine code before it runs
  2. Runs the program on a graphics card
  3. Checks the program for spelling mistakes in comments
  4. Stores the program in RAM

Answer it at the end of the lesson to see if you're right, and why.

Before this lesson