02 Β· CS Fundamentals
How code actually runs β Python, V8, and the profiling mindset
You ship Python and TypeScript. One runs on a bytecode interpreter with refcounting; the other on one of the most aggressive JIT compilers ever built. Knowing what happens under python app.py and node server.js turns "it's slow" from a complaint into a diagnosis.
01Compilers vs interpreters β it's a spectrum, not a binary
Compiler: translate the whole program to a lower-level form ahead of time (AOT), run the result. Interpreter: read program forms and execute their effects now. Every real system sits somewhere between these poles.
The classic pipeline is the same either way β only the target differs:
source ββΊ lexer ββΊ parser ββΊ AST ββΊ [optimizer] ββΊ target
(text) (tokens) (tree) β
βββββββββββββΌββββββββββββββ
βΌ βΌ βΌ
machine code bytecode walk the AST
(C, Rust, Go (Python, (simple interps,
β AOT) JavaβJVM) early Ruby)
for a VM
AOT to native machine code. Fast startup, fast execution, optimizations paid at build time, errors caught before you ship.
Compiles to bytecode, which a software VM interprets (Β§02). "Interpreted language" really means "compiled to something a software VM executes."
AOT to bytecode, then the VM JIT-compiles hot paths to machine code at runtime. Warmup, then near-native speed.
No visible compile step, but ferociously JIT-compiled behind the scenes (Β§04). The most aggressive of the four.
Tradeoffs to articulate: AOT buys peak speed + no warmup + errors at build time. Interpretation buys instant iteration, portability, and runtime dynamism (eval, monkeypatching, REPLs). JIT buys near-native speed for dynamic languages at the cost of warmup and memory.
02What Python actually does
python app.py, step by step:
app.py ββparseβββΊ AST ββcompileβββΊ bytecode (co_code) ββcachedβββΊ __pycache__/app.cpython-312.pyc
β
βΌ
CPython VM: a loop over bytecode instructions
(stack machine: push/pop operands; one giant
switch / computed-goto in ceval.c)
You can watch the bytecode yourself with dis:
import dis
def add(a, b): return a + b
dis.dis(add)
# LOAD_FAST a
# LOAD_FAST b
# BINARY_OP +
# RETURN_VALUE
Walking it line by line, the way the VM does:
LOAD_FAST aβ push the local variableaonto the value stack. "Fast" because locals live in a fixed-size array indexed by position, not a dict lookup.LOAD_FAST bβ pushb. The stack now holds two PyObject pointers.BINARY_OP +β pop both operands, check their types at runtime, find__add__via the type object, call it, box the result as a new PyObject, adjust refcounts, push the result.RETURN_VALUEβ pop the result and hand it back to the caller's frame.
BINARY_OP is ~100Γ slower than a C add instruction: everything is a heap object and every operation is dynamic. One bytecode op costs dozens of real machine instructions plus pointer-chasing. That indirection is "Python is slow" β and it's also why the escape hatch works: NumPy / pandas / tokenizers push the loop into C and pay Python's tax once per array, not per element.Worth knowing exists: .pyc files skip re-parsing (not re-executing β the bytecode still runs on the VM). Python 3.11+ added the specializing adaptive interpreter (bytecode rewrites itself for observed types β 25%+ faster); 3.13+ ships an experimental JIT and a free-threaded build. PyPy is the long-standing JIT'd alternative Python.
03Garbage collection β Python's two-part scheme
Part 1: reference counting (the workhorse)
Every PyObject carries a refcount. Bindings increment; unbinding / going out of scope decrements; hit zero β freed immediately.
a = [1, 2] # list refcount = 1
b = a # 2
del a # 1
b = None # 0 β freed RIGHT NOW (deterministic!)
Pros: prompt, deterministic reclamation; memory freed the instant it's unused; no long GC pauses. Cons: constant refcount churn on every assignment β this is the reason the GIL exists (refcount updates must be thread-safe; see the concurrency page) β and it has a fatal blind spot:
Part 2: the cycle collector (the safety net)
a.ref = b βββΊβ Even with no outside references,
b.ref = a ββββ each keeps the other's refcount β₯ 1.
Refcounting alone leaks this forever.
The gc module's generational collector runs periodically, finds groups of objects only reachable from each other, and frees them. Generational = built on the empirical rule that most objects die young: new objects sit in gen0 (collected often); survivors promote to gen1, then gen2 (collected rarely). Collection triggers on allocation-count thresholds.
Practical angles: cycles are common in real code (parentβchild pointers; exceptions holding tracebacks holding frames); __del__ + cycles is historically cursed; weakref breaks cycles by design (caches, observer patterns). Long-lived "leaks" in Python services are usually unbounded containers β module-level dict caches, growing lists β rather than GC failures: the GC can't free what you still reference. Tools: gc.collect(), tracemalloc, heap profilers.
04The JIT idea β V8, for your Node work
A JIT (just-in-time) compiler observes the program running, then compiles the hot parts to optimized machine code using facts an AOT compiler could never assume.
JS source ββΊ parse ββΊ Ignition (bytecode interpreter) β everything starts here
β profiling: which functions are hot?
β what SHAPES/types actually flow through?
βΌ
hot code ββΊ optimizing JIT (TurboFan; Sparkplug/Maglev mid-tiers)
compiles assuming observed types:
"x was always a small int β emit raw machine add"
+ guard checks
β
βΌ guard fails (a string shows up!)
DEOPTIMIZE β fall back to bytecode, maybe re-optimize
V8's tiered pipeline, tier by tier:
Bytecode interpreter. Every function starts here β cheap to enter, slow to run. While interpreting, it profiles: which functions are hot, and which shapes/types actually flow through them.
Mid-tier compilers. Fast, mostly-unoptimized machine code for warm functions β a stepping stone that buys speed without waiting for full optimization.
The optimizing JIT. Compiles hot functions to aggressive machine code assuming the observed types, with guard checks protecting each assumption.
A guard fails (a string shows up where ints always flowed) β throw away the optimized code, fall back to bytecode, maybe re-optimize later with the new facts.
Two ideas carry the whole thing:
- Speculative optimization + deopt: compile for what you've seen, guard the assumption, bail out if the world disagrees. This is how a dynamic language reaches near-native speed.
- Hidden classes (shapes) + inline caches: objects created with the same property layout share a "shape," so
obj.xbecomes a fixed-offset load (like a C struct) instead of a hash lookup β if call sites stay monomorphic (one shape). Polymorphic sites get slower; megamorphic ones fall back to dictionary mode.
delete properties; don't mix types in a field or array) keep code on the fast path. There's a warmup period after boot before peak performance β relevant to benchmarks and scale-up latency. V8's GC is generational-tracing (young-space "scavenge" + incremental/concurrent old-space collection to keep pauses low). CPython-vs-V8 in one line: the biggest single reason idiomatic JS outruns idiomatic Python is the JIT β and Python is now growing one.05Linking & loading β intuition level
How does source become a running process?
- Compile each translation unit to an object file: machine code + a symbol table + "holes" for symbols defined elsewhere.
- Link: resolve symbols across object files and libraries; complain about
undefined reference. Two flavors:- Static β copy library code into the binary. Big, self-contained (Go's default; why Go containers can be
FROM scratch). - Dynamic β record "needs libssl.so.3"; resolution deferred to load time. Small binaries, shared pages of library code across processes, system-wide security patching β at the cost of runtime dependencies.
- Static β copy library code into the binary. Big, self-contained (Go's default; why Go containers can be
- Load (
execve): kernel + dynamic loader (ld.so) mmap the executable and its.sodependencies into the address space (the OS page's virtual memory), fix up addresses (relocations; lazy PLT binding), jump to_startβmain.
Where you actually feel this:
- "Works on my machine" / glibc-version crashes in containers = dynamic linking: the binary expected a shared library the image lacks. Alpine (musl) vs Debian (glibc) wheels;
ldd <binary>to inspect. - Python
importis the same idea at runtime: locate module β execute it once β cache insys.modules. Native extensions (.sofiles β numpy, psycopg2's C parts) load viadlopen. Import-time work is real startup cost (cold starts, worker boot). - Node's
require/ESM: same resolve-execute-cache pattern; native addons are.nodedynamic libraries.
06"It's slow" usually isn't the language β the profiling mindset
The reasoning chain to internalize (and recite):
The order matters β work it top to bottom:
- Do the arithmetic first. A request that takes 800 ms while Python bytecode overhead accounts for maybe 5 ms of it does not have a language problem. Recall the latency ladder from the caching page: RAM ns β Redis ~1 ms β Postgres ms β LLM seconds. In an I/O-bound service, the interpreter is idle-waiting, not slow.
- Suspects in base-rate order: N+1 / missing index / unbounded query β sequential awaits that should be
asyncio.gatherβ missing cache on a hot read β chatty external calls (no batching, no connection reuse β handshakes) β oversized payloads / serialization of huge JSON β blocking call starving the event loop β connection-pool exhaustion β then, rarely, actual CPU in your code β and even then the fix is usually a better algorithm (O(nΒ²) β O(n log n)) or vectorization, not a rewrite in Rust. - Measure, don't vibe. Percentiles first (p50 vs p99 tell different stories), then narrow layer by layer.
- Fix the biggest bar; re-measure. Amdahl's law: optimizing a 5% component caps you at 5%. Optimization without before/after numbers is superstition.
The narrowing toolkit, by layer:
Tracing / APM β which hop ate the time? Gateway? Service? DB? LLM?
EXPLAIN ANALYZE, pg_stat_statements, the slow-query log.
Sampling profilers: py-spy (attach to a live prod process, no restart, flame graphs β killer tool to name), cProfile in dev; node --prof / Chrome DevTools & clinic for Node.
tracemalloc, heap snapshots. Flame-graph reading: wide box = time; look at the widest thing you own.
The mature closing take: language speed does matter for tight CPU loops at scale β that's why the ecosystem's hot paths (tokenizers, JSON parsers, numpy, uvloop, pydantic-core) are C/Rust under Python APIs. The skill isn't "use a fast language," it's knowing which 2% of the system is compute-bound and giving only that the fast-path treatment.
Test yourself
Recite the four bytecode ops for def add(a, b): return a + b and what each does.
LOAD_FAST a (push local from the fast-locals array), LOAD_FAST b, BINARY_OP + (pop two, dynamic type check, dispatch __add__, box result, fix refcounts, push), RETURN_VALUE (pop and return to the caller's frame). The dynamic dispatch inside BINARY_OP is where the ~100Γ overhead lives.
Why does the GIL exist, in one sentence?
Because CPython's refcounts are updated on nearly every operation, and those updates must be thread-safe β one global lock is the cheap way to guarantee it. (3.13's free-threaded build is the ongoing attempt to remove it.)
Draw the two-object cycle and explain why refcounting can't free it.
a.ref = b; b.ref = a β even after both names are deleted, each object holds a reference to the other, so both refcounts stay at 1 forever. The generational cycle collector finds groups reachable only from each other and frees them.
Name V8's tiers in order and the two ideas that make the JIT work.
Ignition (bytecode interpreter + profiler) β Sparkplug/Maglev (fast mid-tier machine code) β TurboFan (optimizing, speculative) β deopt back to bytecode on failed guards. The two ideas: speculative optimization with guards + deopt, and hidden classes/shapes with inline caches turning property access into fixed-offset loads.
A container crashes with "missing libfoo.so" β what happened and how do you inspect it?
Dynamic linking: the binary declared a runtime dependency the image doesn't supply (classic case: Alpine's musl vs Debian's glibc for Python wheels). Inspect with ldd <binary>. Static linking (Go's default) avoids the whole class of failure β hence FROM-scratch Go images.
Recite the base-rate order of slowness suspects before "the language is slow."
N+1 / missing index β sequential awaits β missing cache β chatty external calls without connection reuse β oversized payloads β event-loop blocking β pool exhaustion β and only then CPU in your code, where the fix is usually algorithmic or vectorization.
07Interview questions you should be able to answer
Q1. Compiled vs interpreted languages?
A spectrum: AOT-to-native (C/Go/Rust β build-time cost, peak runtime speed), compile-to-bytecode-then-VM (Python, JVM), and JIT hybrids that compile hot paths at runtime with observed-type speculation (V8, JVM). "Interpreted" almost never means walking source text β Python compiles to bytecode first. Orthogonal to static vs dynamic typing (TS is checked then erased).
Q2. What happens when you run python app.py?
Parse β AST β compile to bytecode (cached as .pyc in __pycache__, skipping reparse next time) β CPython's stack-machine VM executes instruction by instruction (dis.dis shows LOAD_FAST/BINARY_OP/etc.). Each op does dynamic dispatch on heap-allocated PyObjects with refcounting β that per-op indirection is the slowness; 3.11's adaptive specializing interpreter and 3.13's experimental JIT chip away at it.
Q3. How does Python's garbage collection work?
Two mechanisms. Primary: reference counting β refcount hits zero, object freed immediately (deterministic; also the reason the GIL exists). Blind spot: reference cycles (aβb) never reach zero β the generational cycle collector periodically finds cycle-only-reachable groups and frees them; generations exploit "most objects die young." Practical: most "leaks" are live references in unbounded caches, not GC bugs; weakref for caches/backrefs; tracemalloc/py-spy to investigate.
Q4. Why is Python slower than C or JS-on-V8, and when does it not matter?
Every value is a boxed heap object and every operation dynamically dispatches β ~100Γ overhead per op vs a native add. V8 removes that via JIT: speculatively compile hot functions for observed types (hidden classes make property access a fixed-offset load), guard, deoptimize on surprise. It doesn't matter when you're I/O-bound (most services β the interpreter is waiting on Postgres/Redis/LLMs) or when hot loops already live in C extensions (numpy, pydantic-core, tokenizers). It matters in tight pure-Python CPU loops β fix with algorithms, vectorization, native libs, or multiprocessing.
Q5. What is a JIT compiler? Explain V8 in a minute.
Runtime compiler using live profiling data. V8: Ignition compiles JS to bytecode and interprets while profiling; hot functions go to the optimizing tiers (TurboFan), compiled under type assumptions with guards; failed guards deoptimize back to bytecode. Hidden classes + inline caches turn dynamic property access into struct-like offset loads when call sites stay monomorphic. Implications: warmup before peak perf, and keep object shapes stable in hot paths.
Q6. Static vs dynamic linking? Where does this bite in containers?
Static: library code copied into the binary β bigger, self-contained (Go's default, enables FROM-scratch images). Dynamic: binary declares needed .sos, resolved at load by ld.so β smaller, shared in memory, centrally patchable, but the environment must supply them: glibc-vs-musl (Alpine) wheel crashes and "missing libfoo.so" are exactly this. ldd to inspect. Python imports / native extensions are the runtime (dlopen) flavor of the same idea, and import cost = real cold-start cost.
Q7. An endpoint is slow. Walk me through your approach.
Never guess a fix first. (1) Quantify: which percentile, since when, all pods or one? (2) Localize with tracing/APM: gateway vs app vs DB vs external/LLM hop. (3) Drill in with the layer's tool: EXPLAIN ANALYZE + pg_stat_statements for queries (N+1s, missing indexes), py-spy flame graph on the live process for CPU, event-loop blocking check for async servers, pool metrics for connection starvation. (4) Fix the widest bar (usual culprits: N+1, sequential awaits β gather, missing cache, no connection reuse), re-measure, stop when the SLO is met β Amdahl's law bounds anything else. Language rewrites are the last resort after data says the bottleneck is compute in my code.
Q8. What are .pyc files / __pycache__?
Cached compiled bytecode keyed by interpreter version. Saves parse+compile on subsequent imports β execution speed is unchanged (same VM). Invalidated by source timestamp/hash. Analogous, loosely, to V8's code cache.
Q9. Refcounting GC vs tracing GC?
Refcounting (CPython): free at zero β immediate, deterministic, spread-out cost; can't handle cycles (needs a backup collector); refcount writes hurt multithreading (GIL). Tracing (JVM, Go, V8): periodically mark everything reachable from roots, sweep the rest β handles cycles natively, batch-efficient, but nondeterministic reclamation and pause management (hence generational/incremental/concurrent designs). Python is refcounting + a tracing cycle collector bolted on.
Q10. Why do Go binaries "just run" in containers while Python images are huge?
Go statically links to one native binary β no interpreter, no runtime deps β scratch/distroless images in MBs. Python ships the interpreter + stdlib + site-packages (with dynamically-linked C extensions that must match the base image's libc) and pays import-time startup. Mitigations: slim/distroless bases, multi-stage builds, wheel caching β but it's a language-runtime difference at heart, i.e., AOT-native vs bytecode-on-VM from Q1.