Python Compilation Practice Problems & Exercises
Practice: Compilation vs Interpretation
← Back to lessonEasy
Write a function classify_language(name) that returns the execution model for each language: "compiled", "interpreted", "bytecode-vm", or "jit-compiled".
def classify_language(name):
# Return one of: "compiled", "interpreted", "bytecode-vm", "jit-compiled"
pass
for lang in ["C", "Java", "Python", "Bash", "Rust", "JavaScript"]:
print(f"{lang}: {classify_language(lang)}")
Solution
def classify_language(name):
categories = {
"C": "compiled",
"Rust": "compiled",
"Go": "compiled",
"Java": "bytecode-vm",
"Python": "bytecode-vm",
"C#": "bytecode-vm",
"Bash": "interpreted",
"JavaScript": "jit-compiled",
}
return categories.get(name, "unknown")
for lang in ["C", "Java", "Python", "Bash", "Rust", "JavaScript"]:
print(f"{lang}: {classify_language(lang)}")
Key distinctions:
- Compiled (C, Rust, Go): Source is translated entirely to machine code before execution. The output is a standalone binary.
- Bytecode-VM (Python, Java, C#): Source is compiled to an intermediate bytecode, which a virtual machine then executes. Python uses the PVM (Python Virtual Machine); Java uses the JVM.
- Interpreted (Bash): The interpreter reads and executes source code line-by-line with no compilation step.
- JIT-compiled (JavaScript via V8): Starts as interpreted/bytecoded, but a JIT compiler translates hot paths to machine code at runtime.
Python is often called "interpreted" casually, but it actually compiles to .pyc bytecode first. The PVM then interprets that bytecode.
Expected Output
C: compiled\nJava: bytecode-vm\nPython: bytecode-vm\nBash: interpreted\nRust: compiled\nJavaScript: jit-compiledHints
Hint 1: Think about what happens before execution. Does the language produce a standalone binary, bytecode for a VM, or get read line-by-line?
Hint 2: Both Java and Python compile to bytecode first, then a VM executes it. C and Rust compile directly to machine code. Bash is read and executed line-by-line by the shell.
Use the py_compile module to compile a Python source file and find the resulting .pyc file. Return the path to the .pyc file.
import py_compile
import os
import sys
# Create a temporary .py file
with open("example.py", "w") as f:
f.write("x = 42\nprint(x)\n")
# Compile it
pyc_path = py_compile.compile("example.py")
print(f"Compiled! .pyc file exists at: {pyc_path}")
print(f"File exists: {os.path.exists(pyc_path)}")
print(f"Python version tag: cpython-{sys.version_info.major}{sys.version_info.minor}")
# Clean up
os.remove("example.py")
if os.path.exists(pyc_path):
os.remove(pyc_path)
os.rmdir("__pycache__")Solution
import py_compile
import os
import sys
with open("example.py", "w") as f:
f.write("x = 42\nprint(x)\n")
pyc_path = py_compile.compile("example.py")
print(f"Compiled! .pyc file exists at: {pyc_path}")
print(f"File exists: {os.path.exists(pyc_path)}")
print(f"Python version tag: cpython-{sys.version_info.major}{sys.version_info.minor}")
os.remove("example.py")
if os.path.exists(pyc_path):
os.remove(pyc_path)
os.rmdir("__pycache__")
What happens behind the scenes:
py_compile.compile("example.py")does the same thing Python does automatically when youimporta module: it parses the source, compiles it to bytecode, and writes a.pycfile.- The
.pycfile lives in__pycache__/and includes the Python version in the filename (e.g.,example.cpython-312.pyc) so that multiple Python versions can coexist. - The
.pycfile contains a magic number (Python version), a timestamp (for recompilation checks), the source file size, and the marshalled code object.
import py_compile
import os
import glob
def find_pyc_files(source_path):
"""Compile a .py file and return the path to its .pyc file."""
# Step 1: Compile the source file
# Step 2: Find and return the .pyc path
passExpected Output
Compiled! .pyc file exists at: __pycache__/example.cpython-{version}.pycHints
Hint 1: The `py_compile.compile()` function compiles a .py file and returns the path to the .pyc file.
Hint 2: Python stores .pyc files in `__pycache__/` with names like `module.cpython-312.pyc` (the version tag is included).
Use the dis module to disassemble a simple lambda and observe the bytecode instructions Python generates.
import dis
print("=== Bytecode for: lambda x: x + 1 ===")
dis.dis(lambda x: x + 1)
print("\n=== Bytecode for: lambda x: x * 2 + 3 ===")
dis.dis(lambda x: x * 2 + 3)Questions to answer after running:
- What opcode loads the parameter
x? - What opcode loads the constant
1? - What opcode performs the addition?
Solution
=== Bytecode for: lambda x: x + 1 ===
1 RESUME 0
LOAD_FAST 0 (x)
LOAD_CONST 1 (1)
BINARY_OP 0 (+)
RETURN_VALUE
=== Bytecode for: lambda x: x * 2 + 3 ===
1 RESUME 0
LOAD_FAST 0 (x)
LOAD_CONST 1 (2)
BINARY_OP 5 (*)
LOAD_CONST 2 (3)
BINARY_OP 0 (+)
RETURN_VALUE
Answers:
LOAD_FASTloads local variables (parameters are locals). The0is the index in the local variable table;(x)is the name.LOAD_CONSTloads constant values. Index1holds1(index0is alwaysNone).BINARY_OP 0 (+)performs addition. The0is the operator code for+.
Key insight: Even a trivial expression like x + 1 requires 4 bytecode instructions (RESUME, LOAD_FAST, LOAD_CONST, BINARY_OP) plus RETURN_VALUE. The PVM executes these one at a time in a stack-based fashion: push x, push 1, pop both and push their sum, return the top of stack.
Expected Output
See solution for full bytecode listingHints
Hint 1: The `dis.dis()` function prints the bytecode instructions for any function, lambda, or code string.
Hint 2: Each line shows: line number, instruction offset, opcode name, and argument (if any).
Predict the output of this code that reads the header of a .pyc file. What do each of the four fields represent?
import py_compile
import struct
import sys
import os
# Create and compile a file
with open("_temp_mod.py", "w") as f:
f.write("answer = 42\n")
pyc_path = py_compile.compile("_temp_mod.py")
# Read the .pyc header (first 16 bytes)
with open(pyc_path, "rb") as f:
magic = f.read(4)
flags = struct.unpack("<I", f.read(4))[0]
timestamp = struct.unpack("<I", f.read(4))[0]
size = struct.unpack("<I", f.read(4))[0]
print(f"Magic number: {magic.hex()}")
print(f"Flags: {flags}")
print(f"Timestamp: {timestamp} (non-zero = source timestamp)")
print(f"Source size: {size} bytes")
print(f"Python version: {sys.version_info.major}.{sys.version_info.minor}")
# Clean up
os.remove("_temp_mod.py")
os.remove(pyc_path)
os.rmdir("__pycache__")Solution
The .pyc header contains exactly 16 bytes in 4 fields:
-
Magic number (4 bytes): Identifies the Python version that produced this bytecode. Each CPython release uses a different magic number. If you try to load a
.pycfrom the wrong Python version, the magic number mismatch causes a recompile. -
Flags (4 bytes): Bit flags. Bit 0 indicates whether the file uses a hash-based invalidation check (PEP 552) instead of timestamp-based.
-
Timestamp (4 bytes): The modification time of the source
.pyfile when it was compiled. Python compares this to the current source file timestamp to decide if recompilation is needed. -
Source size (4 bytes): The size of the original
.pyfile in bytes. An extra check to detect changes even if the timestamp is the same.
Why this matters: This is how Python decides whether to use the cached .pyc or recompile. When you edit a source file, the timestamp changes, the cached .pyc becomes stale, and Python recompiles automatically on the next import.
Expected Output
See solution for explanationHints
Hint 1: A .pyc file starts with a 4-byte magic number, then 4 bytes of flags, then a timestamp and file size.
Hint 2: Use the `importlib.util` module and the `struct` module to read the header.
Medium
Write an inspect_bytecode(func) function that extracts and prints detailed information about a function's compiled bytecode.
import dis
def inspect_bytecode(func):
instructions = list(dis.get_instructions(func))
code = func.__code__
print(f"Instructions: {len(instructions)}")
print(f"Opcodes: {[i.opname for i in instructions]}")
print(f"Constants: {code.co_consts}")
print(f"Local vars: {code.co_varnames}")
print(f"Stack size: {code.co_stacksize}")
# Test it
def triangle_area(a, b, c):
s = (a + b + c) / 2
return (s * (s - a) * (s - b) * (s - c)) ** 0.5
inspect_bytecode(triangle_area)
Solution
import dis
def inspect_bytecode(func):
instructions = list(dis.get_instructions(func))
code = func.__code__
print(f"Instructions: {len(instructions)}")
print(f"Opcodes: {[i.opname for i in instructions]}")
print(f"Constants: {code.co_consts}")
print(f"Local vars: {code.co_varnames}")
print(f"Stack size: {code.co_stacksize}")
def triangle_area(a, b, c):
s = (a + b + c) / 2
return (s * (s - a) * (s - b) * (s - c)) ** 0.5
inspect_bytecode(triangle_area)
What the code object reveals:
co_consts: The constant values embedded in the bytecode (Noneis always at index 0, then2and0.5from the formula).co_varnames: All local variable names including parameters (a,b,c,s).co_stacksize: The maximum depth the evaluation stack reaches during execution. The PVM uses a stack to evaluate expressions — push operands, pop for operations, push results.dis.get_instructions()returnsInstructionnamedtuples with.opname,.opcode,.arg,.argval,.offset, etc.
Key insight: Python compiles your source code into a code object at import/definition time. This code object is what the PVM actually executes. The dis module lets you peek inside.
import dis
def inspect_bytecode(func):
"""Print detailed bytecode info for a function.
Show: number of instructions, list of opcodes used,
constants, and local variable names.
"""
passExpected Output
Instructions: 7\nOpcodes: ['RESUME', 'LOAD_FAST', 'LOAD_FAST', 'BINARY_OP', 'LOAD_FAST', 'BINARY_OP', 'RETURN_VALUE']\nConstants: (None,)\nLocal vars: ('a', 'b', 'c')\nStack size: 2Hints
Hint 1: Every function has a `__code__` attribute containing its code object. Use `dis.get_instructions(func)` to iterate over bytecode instructions.
Hint 2: The code object has attributes like `co_consts` (constants), `co_varnames` (local names), and `co_stacksize` (max stack depth).
Compare the bytecode generated for three ways to double a number: x * 2, x + x, and x << 1. Which produces the fewest instructions? Does CPython optimize any of them?
import dis
def double_mul(x):
return x * 2
def double_add(x):
return x + x
def double_shift(x):
return x << 1
print("=== x * 2 ===")
dis.dis(double_mul)
print(f"Instruction count: {len(list(dis.get_instructions(double_mul)))}")
print("\n=== x + x ===")
dis.dis(double_add)
print(f"Instruction count: {len(list(dis.get_instructions(double_add)))}")
print("\n=== x << 1 ===")
dis.dis(double_shift)
print(f"Instruction count: {len(list(dis.get_instructions(double_shift)))}")Solution
=== x * 2 ===
RESUME 0
LOAD_FAST 0 (x)
LOAD_CONST 1 (2)
BINARY_OP 5 (*)
RETURN_VALUE
Instruction count: 5
=== x + x ===
RESUME 0
LOAD_FAST 0 (x)
LOAD_FAST 0 (x)
BINARY_OP 0 (+)
RETURN_VALUE
Instruction count: 5
=== x << 1 ===
RESUME 0
LOAD_FAST 0 (x)
LOAD_CONST 1 (1)
BINARY_OP 9 (<<)
RETURN_VALUE
Instruction count: 5
Analysis:
- All three produce exactly 5 bytecode instructions — CPython does not optimize any of these into the others at the bytecode level.
x * 2andx << 1both useLOAD_FAST+LOAD_CONST+BINARY_OP— same structure, different operator codes.x + xusesLOAD_FASTtwice instead of loading a constant — it pushesxonto the stack two times.- CPython's compiler is deliberately simple. It does not perform strength-reduction optimizations (replacing
* 2with<< 1). That is left to JIT compilers like PyPy.
Takeaway: In CPython, micro-optimizations at the expression level rarely matter. The dominant cost is the interpreter loop overhead and dynamic type checking, not the choice of arithmetic operator. Write the clearest code (x * 2) and let PyPy or Cython handle micro-optimization if needed.
Expected Output
See solution for bytecode comparisonHints
Hint 1: Use `dis.get_instructions()` to count the exact number of bytecode instructions for each approach.
Hint 2: Think about what `x << 1` means at the CPU level vs `x * 2` vs `x + x`. Does CPython optimize any of these?
Demonstrate Python's recompilation logic. Show that Python reuses .pyc files when the source is unchanged and recompiles when the source is modified.
import py_compile
import os
import time
import struct
def get_pyc_timestamp(pyc_path):
"""Read the source timestamp stored in a .pyc header."""
with open(pyc_path, "rb") as f:
f.read(4) # magic
f.read(4) # flags
ts = struct.unpack("<I", f.read(4))[0]
return ts
# Step 1: Create source and compile
with open("_recomp_test.py", "w") as f:
f.write("x = 1\n")
pyc_path = py_compile.compile("_recomp_test.py")
ts1 = get_pyc_timestamp(pyc_path)
print(f"Initial compile timestamp: {ts1}")
# Step 2: Recompile without changing source
pyc_path = py_compile.compile("_recomp_test.py")
ts2 = get_pyc_timestamp(pyc_path)
print(f"Same source timestamp: {ts2}")
print(f"Timestamps match: {ts1 == ts2}")
# Step 3: Modify source and recompile
time.sleep(1) # Ensure different timestamp
with open("_recomp_test.py", "w") as f:
f.write("x = 2\n")
pyc_path = py_compile.compile("_recomp_test.py")
ts3 = get_pyc_timestamp(pyc_path)
print(f"Modified source timestamp: {ts3}")
print(f"Recompiled (new timestamp): {ts3 != ts1}")
# Clean up
os.remove("_recomp_test.py")
os.remove(pyc_path)
os.rmdir("__pycache__")Solution
Initial compile timestamp: <some_unix_timestamp>
Same source timestamp: <same_timestamp>
Timestamps match: True
Modified source timestamp: <later_timestamp>
Recompiled (new timestamp): True
How Python's recompilation check works:
- When you
importa module, Python looks for__pycache__/module.cpython-XY.pyc. - If the
.pycexists, Python reads its 16-byte header and compares the stored source timestamp and source size against the actual.pyfile. - If they match, the bytecode is loaded directly — no recompilation needed.
- If they differ (source was edited), Python recompiles the source to fresh bytecode, writes a new
.pyc, and loads that.
PEP 552 (Python 3.7+): Added hash-based .pyc invalidation as an alternative. Instead of timestamps, the .pyc stores a hash of the source file. This is more reliable in build systems where timestamps can be unreliable (e.g., after git checkout). Enable it with py_compile.compile(..., invalidation_mode=PycInvalidationMode.CHECKED_HASH).
Expected Output
Initial compile: .pyc created\nSame source: reused (no recompile)\nModified source: recompiled (new .pyc)Hints
Hint 1: Python checks the timestamp stored in the .pyc header against the source file modification time.
Hint 2: Use `os.stat()` to get file timestamps, and `os.utime()` to modify them for testing.
Trace a piece of Python code through every stage of the compilation pipeline: source string, AST, bytecode, and execution.
import ast
import dis
source = "result = sum(range(5))"
# Stage 1: Source
print("=== STAGE 1: Source Code ===")
print(source)
# Stage 2: AST
print("\n=== STAGE 2: Abstract Syntax Tree ===")
tree = ast.parse(source)
print(ast.dump(tree, indent=2))
# Stage 3: Bytecode
print("\n=== STAGE 3: Bytecode ===")
code_obj = compile(source, "<string>", "exec")
dis.dis(code_obj)
# Stage 4: Execution
print("\n=== STAGE 4: Execution ===")
namespace = {}
exec(code_obj, namespace)
print(f"result = {namespace['result']}")Solution
import ast
import dis
source = "result = sum(range(5))"
# Stage 1: Source
print("=== STAGE 1: Source Code ===")
print(source)
# Stage 2: AST
print("\n=== STAGE 2: Abstract Syntax Tree ===")
tree = ast.parse(source)
print(ast.dump(tree, indent=2))
# Stage 3: Bytecode
print("\n=== STAGE 3: Bytecode ===")
code_obj = compile(source, "<string>", "exec")
dis.dis(code_obj)
# Stage 4: Execution
print("\n=== STAGE 4: Execution ===")
namespace = {}
exec(code_obj, namespace)
print(f"result = {namespace['result']}")
The 4-stage pipeline:
Source Code → Tokenizer/Parser → AST → Compiler → Bytecode → PVM
.py .pyc (executes)
- Source code: The
.pytext file you write. - AST (Abstract Syntax Tree): The parser converts tokens into a tree structure.
ast.parse()exposes this. Each node represents a language construct (assignment, function call, binary operation). The AST is what linters and code formatters operate on. - Bytecode: The compiler walks the AST and emits a flat sequence of bytecode instructions (a code object). This is what gets cached in
.pycfiles.compile()does this step. - PVM execution: The Python Virtual Machine reads bytecode instructions one by one, using a stack-based evaluation model.
LOAD_NAMEpushes values,CALL_FUNCTIONinvokes callables,STORE_NAMEbinds results to names.
Key insight: Python is NOT purely interpreted. Steps 1-3 are compilation (happening at import time or when you run a script). Only step 4 is interpretation. This is why Python is accurately described as a "bytecode-interpreted" language.
import ast
import dis
import compile
def trace_pipeline(source_code):
"""Show every stage of Python's compilation pipeline.
1. Source code (string)
2. AST (abstract syntax tree)
3. Bytecode instructions
4. Execution result
"""
passExpected Output
See solution for full pipeline traceHints
Hint 1: Use `ast.parse()` to get the AST, `ast.dump()` to print it, and `compile()` + `dis.dis()` for bytecode.
Hint 2: The built-in `compile()` function converts source code (or an AST) into a code object. Then `exec()` runs it.
Predict the output, then run the code. Why does BINARY_OP appear identical for integers and strings, even though addition means completely different things for each type?
import dis
def add_ints(a, b):
return a + b
def concat_strings(a, b):
return a + b
print("=== Integer addition ===")
dis.dis(add_ints)
print("\n=== String concatenation ===")
dis.dis(concat_strings)
print("\n=== Same bytecode? ===")
int_ops = [i.opname for i in dis.get_instructions(add_ints)]
str_ops = [i.opname for i in dis.get_instructions(concat_strings)]
print(f"Identical opcodes: {int_ops == str_ops}")Solution
=== Integer addition ===
RESUME 0
LOAD_FAST 0 (a)
LOAD_FAST 1 (b)
BINARY_OP 0 (+)
RETURN_VALUE
=== String concatenation ===
RESUME 0
LOAD_FAST 0 (a)
LOAD_FAST 1 (b)
BINARY_OP 0 (+)
RETURN_VALUE
=== Same bytecode? ===
Identical opcodes: True
The bytecode is identical because Python is dynamically typed. The compiler cannot know at compile time whether a and b are ints, strings, lists, or custom objects. So it emits the same generic BINARY_OP instruction for all of them.
What happens at runtime inside BINARY_OP:
- Pop two objects from the stack.
- Check the type of the left operand.
- Look up its
__add__method (or__radd__on the right operand if needed). - Call the type-specific implementation (
int.__add__,str.__add__, etc.). - Push the result.
This dynamic dispatch happens on every single operation — even inside a tight loop. This is the fundamental reason CPython is slower than statically typed compiled languages: the type must be resolved at runtime, not compile time.
Contrast with C: A C compiler knows int a + int b is integer addition and emits a single CPU ADD instruction. No type checking, no method lookup, no dispatch overhead.
Expected Output
See solution for bytecode comparison and explanationHints
Hint 1: Compare the bytecode for the same operation on different types. Notice that `BINARY_OP` is the same regardless of type — the type check happens at runtime.
Hint 2: Think about what happens inside BINARY_OP: Python must check the type of BOTH operands, look up the correct __add__ method, and dispatch to it — every single time.
Hard
Build a bytecode analysis tool that counts every opcode in a function and compares opcode profiles between two functions.
import dis
from collections import Counter
def count_opcodes(func):
counts = Counter(i.opname for i in dis.get_instructions(func))
return counts
def compare_opcodes(func1, func2):
c1 = count_opcodes(func1)
c2 = count_opcodes(func2)
all_ops = sorted(set(c1.keys()) | set(c2.keys()))
print(f"{'Opcode':<25} {func1.__name__:>10} {func2.__name__:>10}")
print("-" * 47)
for op in all_ops:
print(f"{op:<25} {c1.get(op, 0):>10} {c2.get(op, 0):>10}")
print("-" * 47)
print(f"{'TOTAL':<25} {sum(c1.values()):>10} {sum(c2.values()):>10}")
# Test: compare a loop vs a list comprehension
def sum_with_loop(n):
total = 0
for i in range(n):
total += i
return total
def sum_with_builtin(n):
return sum(range(n))
print("=== Opcode counts: sum_with_loop ===")
for opcode, count in count_opcodes(sum_with_loop).most_common():
print(f" {opcode}: {count}")
print("\n=== Comparison ===")
compare_opcodes(sum_with_loop, sum_with_builtin)
Solution
import dis
from collections import Counter
def count_opcodes(func):
counts = Counter(i.opname for i in dis.get_instructions(func))
return counts
def compare_opcodes(func1, func2):
c1 = count_opcodes(func1)
c2 = count_opcodes(func2)
all_ops = sorted(set(c1.keys()) | set(c2.keys()))
print(f"{'Opcode':<25} {func1.__name__:>10} {func2.__name__:>10}")
print("-" * 47)
for op in all_ops:
print(f"{op:<25} {c1.get(op, 0):>10} {c2.get(op, 0):>10}")
print("-" * 47)
print(f"{'TOTAL':<25} {sum(c1.values()):>10} {sum(c2.values()):>10}")
def sum_with_loop(n):
total = 0
for i in range(n):
total += i
return total
def sum_with_builtin(n):
return sum(range(n))
print("=== Opcode counts: sum_with_loop ===")
for opcode, count in count_opcodes(sum_with_loop).most_common():
print(f" {opcode}: {count}")
print("\n=== Comparison ===")
compare_opcodes(sum_with_loop, sum_with_builtin)
Key observation: sum_with_loop generates significantly more bytecode instructions (including FOR_ITER, STORE_FAST, BINARY_OP for the accumulation, and jump instructions for the loop). sum_with_builtin has only ~5 instructions — it pushes sum and range(n) onto the stack and calls the C-implemented sum().
Why this matters for performance:
- Each bytecode instruction goes through the PVM's eval loop: fetch, decode, dispatch, execute.
- The loop version executes
Niterations ofBINARY_OP(with dynamic type checking each time). - The builtin version drops into C code for the entire summation — no per-element bytecode overhead.
- This is why "use builtins" is the first Python optimization rule: builtins run in C, bypassing the interpreter loop entirely.
import dis
from collections import Counter
def count_opcodes(func):
"""Count and rank all opcodes in a function's bytecode.
Return a Counter mapping opcode_name -> count.
"""
pass
def compare_opcodes(func1, func2):
"""Compare opcode profiles of two functions.
Show opcodes unique to each and shared opcodes.
"""
passExpected Output
See solution for example outputHints
Hint 1: Use `dis.get_instructions(func)` to iterate over all instructions, then count `.opname` values with `collections.Counter`.
Hint 2: For the comparison, use set operations on the Counter keys to find unique and shared opcodes.
Build a mini stack-based interpreter that mimics how the PVM executes bytecode. Implement PUSH, ADD, MUL, SUB, DUP, PRINT, and HALT instructions.
def mini_interpret(program):
stack = []
instructions = program.strip().split("\n")
for raw in instructions:
parts = raw.strip().split()
op = parts[0]
if op == "PUSH":
stack.append(int(parts[1]))
elif op == "ADD":
b, a = stack.pop(), stack.pop()
stack.append(a + b)
elif op == "SUB":
b, a = stack.pop(), stack.pop()
stack.append(a - b)
elif op == "MUL":
b, a = stack.pop(), stack.pop()
stack.append(a * b)
elif op == "DUP":
stack.append(stack[-1])
elif op == "PRINT":
print(stack[-1])
elif op == "HALT":
break
else:
raise ValueError(f"Unknown opcode: {op}")
return stack
# Test: compute (3 + 4) * (3 + 4) = 49
program = """
PUSH 3
PUSH 4
ADD
PRINT
DUP
MUL
PRINT
HALT
"""
result = mini_interpret(program)
print(f"Final stack: {result}")
Solution
def mini_interpret(program):
stack = []
instructions = program.strip().split("\n")
for raw in instructions:
parts = raw.strip().split()
op = parts[0]
if op == "PUSH":
stack.append(int(parts[1]))
elif op == "ADD":
b, a = stack.pop(), stack.pop()
stack.append(a + b)
elif op == "SUB":
b, a = stack.pop(), stack.pop()
stack.append(a - b)
elif op == "MUL":
b, a = stack.pop(), stack.pop()
stack.append(a * b)
elif op == "DUP":
stack.append(stack[-1])
elif op == "PRINT":
print(stack[-1])
elif op == "HALT":
break
else:
raise ValueError(f"Unknown opcode: {op}")
return stack
program = """
PUSH 3
PUSH 4
ADD
PRINT
DUP
MUL
PRINT
HALT
"""
result = mini_interpret(program)
print(f"Final stack: {result}")
How this mirrors the real PVM:
Instruction Stack (bottom → top)
----------- --------------------
PUSH 3 [3]
PUSH 4 [3, 4]
ADD [7] ← pop 4 and 3, push 7
PRINT [7] ← prints 7
DUP [7, 7] ← duplicate top
MUL [49] ← pop 7 and 7, push 49
PRINT [49] ← prints 49
HALT [49] ← execution stops
The real PVM works the same way but with ~170 opcodes (LOAD_FAST, STORE_FAST, BINARY_OP, CALL_FUNCTION, JUMP, etc.) and additional structures like a call stack for function frames, exception handling, and the block stack for loops/try blocks.
Key insight: At its core, CPython is just a very sophisticated version of this pattern: a while loop that reads opcodes, manipulates a stack, and dispatches to C implementations for each instruction. The main loop lives in Python/ceval.c in the CPython source code.
def mini_interpret(program):
"""Execute a mini stack-based instruction set.
Supported instructions:
PUSH <value> - push a value onto the stack
ADD - pop two, push their sum
MUL - pop two, push their product
SUB - pop two, push (second - first)
DUP - duplicate the top of stack
PRINT - print the top of stack (don't pop)
HALT - stop execution
Return the final stack.
"""
passExpected Output
7\n49\nFinal stack: [49]Hints
Hint 1: Use a Python list as the stack. `append()` to push, `pop()` to pop. Split each instruction on whitespace to separate the opcode from its argument.
Hint 2: For SUB, remember stack order: the first popped value is the top (right operand), the second is below (left operand). So the result is second - first.
Compare a for loop with append vs a list comprehension. Disassemble both, time both, and explain why the comprehension is faster based on the bytecode differences.
import dis
import time
def squares_loop(n):
result = []
for i in range(n):
result.append(i * i)
return result
def squares_comp(n):
return [i * i for i in range(n)]
# === Bytecode comparison ===
print("=== Loop bytecode ===")
dis.dis(squares_loop)
print("\n=== Comprehension bytecode ===")
dis.dis(squares_comp)
# === Timing ===
n = 1_000_000
runs = 5
loop_times = []
comp_times = []
for _ in range(runs):
start = time.perf_counter()
squares_loop(n)
loop_times.append(time.perf_counter() - start)
start = time.perf_counter()
squares_comp(n)
comp_times.append(time.perf_counter() - start)
avg_loop = sum(loop_times) / runs
avg_comp = sum(comp_times) / runs
print(f"\n=== Timing ({n:,} elements, {runs} runs) ===")
print(f"Loop: {avg_loop:.4f}s")
print(f"Comprehension: {avg_comp:.4f}s")
print(f"Speedup: {avg_loop / avg_comp:.2f}x")Solution
import dis
import time
def squares_loop(n):
result = []
for i in range(n):
result.append(i * i)
return result
def squares_comp(n):
return [i * i for i in range(n)]
print("=== Loop bytecode ===")
dis.dis(squares_loop)
print("\n=== Comprehension bytecode ===")
dis.dis(squares_comp)
n = 1_000_000
runs = 5
loop_times = []
comp_times = []
for _ in range(runs):
start = time.perf_counter()
squares_loop(n)
loop_times.append(time.perf_counter() - start)
start = time.perf_counter()
squares_comp(n)
comp_times.append(time.perf_counter() - start)
avg_loop = sum(loop_times) / runs
avg_comp = sum(comp_times) / runs
print(f"\n=== Timing ({n:,} elements, {runs} runs) ===")
print(f"Loop: {avg_loop:.4f}s")
print(f"Comprehension: {avg_comp:.4f}s")
print(f"Speedup: {avg_loop / avg_comp:.2f}x")
Typical result: The comprehension is roughly 1.2-1.5x faster.
Bytecode differences that explain the performance gap:
-
No attribute lookup per iteration. The loop version calls
result.append(i * i)which requiresLOAD_FAST(result) +LOAD_ATTR(append) +CALLon every iteration. The comprehension uses the specializedLIST_APPENDopcode which appends directly without a method lookup or function call. -
Tighter inner loop. The comprehension compiles into its own code object (essentially an inlined anonymous function). Its inner loop is:
LOAD_FAST(i),LOAD_FAST(i),BINARY_OP(*),LIST_APPEND. The regular loop has more instructions per iteration including theCALLoverhead forappend. -
LIST_APPENDis a specialized opcode. It is implemented directly in C in the PVM eval loop and operates on the list being built without any of the function call machinery (no frame creation, no argument packing).
The broader lesson: When CPython provides a specialized opcode for a common pattern (LIST_APPEND for comprehensions, MAP_ADD for dict comprehensions, SET_ADD for set comprehensions), it will always be faster than the generic approach. This is the bytecode-level reason why "Pythonic" code tends to be faster code — the language provides optimized paths for idiomatic patterns.
import dis
import time
def benchmark_and_explain():
"""Compare loop vs comprehension:
1. Show bytecode for both
2. Time both approaches
3. Explain the bytecode difference
"""
passExpected Output
See solution for timing and bytecode analysisHints
Hint 1: List comprehensions have their own code object — they compile to a nested function that CPython calls internally. Use `dis.dis()` and look for `MAKE_FUNCTION` or `LOAD_CONST` with a code object.
Hint 2: The performance difference comes from: (a) comprehensions avoid repeated LOAD_ATTR for .append, and (b) the inner loop runs in a tighter bytecode sequence with LIST_APPEND instead of CALL_FUNCTION.
