Skip to main content

Python Compilation Practice Problems & Exercises

Practice: Compilation vs Interpretation

12 problems4 Easy5 Medium3 Hard45–60 min
← Back to lesson

Easy

#1Classify the LanguageEasy
compiledinterpretedbytecode-vm

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-compiled
Hints

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.

#2Find the .pyc FilesEasy
pyc__pycache__bytecode

Use the py_compile module to compile a Python source file and find the resulting .pyc file. Return the path to the .pyc file.

Python
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 you import a module: it parses the source, compiles it to bytecode, and writes a .pyc file.
  • The .pyc file 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 .pyc file 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
    pass
Expected Output
Compiled! .pyc file exists at: __pycache__/example.cpython-{version}.pyc
Hints

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).

#3Your First DisassemblyEasy
disbytecodeopcodes

Use the dis module to disassemble a simple lambda and observe the bytecode instructions Python generates.

Python
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:

  1. What opcode loads the parameter x?
  2. What opcode loads the constant 1?
  3. 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:

  1. LOAD_FAST loads local variables (parameters are locals). The 0 is the index in the local variable table; (x) is the name.
  2. LOAD_CONST loads constant values. Index 1 holds 1 (index 0 is always None).
  3. BINARY_OP 0 (+) performs addition. The 0 is 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 listing
Hints

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).

#4What Lives in __pycache__?Easy
__pycache__pycmagic-number

Predict the output of this code that reads the header of a .pyc file. What do each of the four fields represent?

Python
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:

  1. 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 .pyc from the wrong Python version, the magic number mismatch causes a recompile.

  2. Flags (4 bytes): Bit flags. Bit 0 indicates whether the file uses a hash-based invalidation check (PEP 552) instead of timestamp-based.

  3. Timestamp (4 bytes): The modification time of the source .py file when it was compiled. Python compares this to the current source file timestamp to decide if recompilation is needed.

  4. Source size (4 bytes): The size of the original .py file 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 explanation
Hints

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

#5Bytecode InspectorMedium
disbytecodecode-object

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 (None is always at index 0, then 2 and 0.5 from 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() returns Instruction namedtuples 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.
    """
    pass
Expected 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: 2
Hints

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).

#6Bytecode Battle: Three Ways to DoubleMedium
disbytecodeoptimization

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?

Python
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 * 2 and x << 1 both use LOAD_FAST + LOAD_CONST + BINARY_OP — same structure, different operator codes.
  • x + x uses LOAD_FAST twice instead of loading a constant — it pushes x onto the stack two times.
  • CPython's compiler is deliberately simple. It does not perform strength-reduction optimizations (replacing * 2 with << 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 comparison
Hints

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?

#7When Does Python Recompile?Medium
pyctimestamprecompilation

Demonstrate Python's recompilation logic. Show that Python reuses .pyc files when the source is unchanged and recompiles when the source is modified.

Python
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:

  1. When you import a module, Python looks for __pycache__/module.cpython-XY.pyc.
  2. If the .pyc exists, Python reads its 16-byte header and compares the stored source timestamp and source size against the actual .py file.
  3. If they match, the bytecode is loaded directly — no recompilation needed.
  4. 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.

#8Trace the PipelineMedium
compilation-pipelineastbytecodepvm

Trace a piece of Python code through every stage of the compilation pipeline: source string, AST, bytecode, and execution.

Python
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)
  1. Source code: The .py text file you write.
  2. 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.
  3. Bytecode: The compiler walks the AST and emits a flat sequence of bytecode instructions (a code object). This is what gets cached in .pyc files. compile() does this step.
  4. PVM execution: The Python Virtual Machine reads bytecode instructions one by one, using a stack-based evaluation model. LOAD_NAME pushes values, CALL_FUNCTION invokes callables, STORE_NAME binds 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
    """
    pass
Expected Output
See solution for full pipeline trace
Hints

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.

#9The Cost of Dynamic TypingMedium
dynamic-typingbytecodeoverhead

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?

Python
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:

  1. Pop two objects from the stack.
  2. Check the type of the left operand.
  3. Look up its __add__ method (or __radd__ on the right operand if needed).
  4. Call the type-specific implementation (int.__add__, str.__add__, etc.).
  5. 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 explanation
Hints

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

#10Bytecode Opcode CounterHard
disbytecodeanalysis

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 N iterations of BINARY_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.
    """
    pass
Expected Output
See solution for example output
Hints

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.

#11Mini Stack-Based InterpreterHard
interpreterstack-machinepvm

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.
    """
    pass
Expected 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.

#12Loop vs Comprehension: Bytecode ShowdownHard
performancebytecodelist-comprehensionoptimization

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.

Python
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:

  1. No attribute lookup per iteration. The loop version calls result.append(i * i) which requires LOAD_FAST (result) + LOAD_ATTR (append) + CALL on every iteration. The comprehension uses the specialized LIST_APPEND opcode which appends directly without a method lookup or function call.

  2. 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 the CALL overhead for append.

  3. LIST_APPEND is 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
    """
    pass
Expected Output
See solution for timing and bytecode analysis
Hints

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.

© 2026 EngineersOfAI. All rights reserved.