Hitchhiker's Guide — PyTorch & TensorFlow Framework Internals
"You don't understand a system until you've broken it." — every production on-call engineer
This guide goes beneath the public API. It is the answer to "why does this work?" when the official docs say "just call .backward()."
1. PyTorch Execution: The Full Stack
1.1 From Python to Kernel
When you write y = torch.matmul(a, b) in Python, here is the exact call chain:
Python: torch.matmul(a, b)
→ C++: at::matmul(self, other) # aten namespace
→ c10::Dispatcher::call(op_handle, stack) # dispatch table lookup
→ Dispatch key selection: max(a.key_set(), b.key_set()) # e.g., CUDA
→ at::native::mm_cuda(self, other) # CUDA kernel dispatch
→ cublasSgemm / cublasGemmEx # vendor BLAS
The dispatcher (c10::Dispatcher) is PyTorch's universal function registry. Every operator is registered as an entry in the dispatcher table, keyed by a DispatchKey. The dispatcher picks the highest-priority key in the tensor's DispatchKeySet.
1.2 Dispatch Key Priority (highest to lowest)
Functionalize ← wraps mutations as functional ops (for torch.compile)
InferenceMode ← disables autograd
AutogradMeta ← for meta/fake tensors in torch.compile
Autograd ← wraps kernel in grad recording logic
AutocastCPU/CUDA ← AMP dtype promotion
CUDA ← GPU kernels
CPU ← CPU kernels
Meta ← shape-only "meta" device (used in compilation)
Why this matters for NPU deployment: When you register a custom NPU dispatch key (e.g., Qualcomm's QnnBackend), it must be inserted at the right priority. Below Autograd means the kernel won't record gradients — which is correct for inference-only NPU deployments.
1.3 Autograd Engine Deep Dive
Forward pass — DAG construction:
a = torch.tensor([2.0], requires_grad=True)
b = torch.tensor([3.0], requires_grad=True)
c = a * b # grad_fn = MulBackward0
d = c + a # grad_fn = AddBackward0
After these four lines, the autograd engine has built:
d (AddBackward0)
├── c (MulBackward0)
│ ├── a (leaf)
│ └── b (leaf)
└── a (leaf)
Each non-leaf tensor has a grad_fn that stores:
- The saved tensors needed for the backward pass
- References to the
next_functions(upstream nodes in the DAG)
Backward pass — Engine::execute():
The backward pass is triggered by d.backward(). The engine:
- Starts from
d.grad_fnwith initial gradienttorch.ones(1) - Calls
AccumulateGradfor leaf tensors - Calls each
grad_fn.__call__(gradient_from_downstream)in reverse topological order - Accumulates gradients via
add_into each leaf's.grad
Why topological order? A node can only compute its upstream gradient after it receives all gradients from downstream nodes that depend on it. This is Dijkstra's algorithm in reverse — we process nodes when their contribution count reaches zero.
Critical invariant: grad_fn is attached during the forward pass. The backward graph is fully determined by what ops ran in forward. This is why torch.no_grad() is a massive speedup — no Function objects are allocated.
1.4 torch.autograd.Function — The Correct Mental Model
class ClampedSoftmax(torch.autograd.Function):
@staticmethod
def forward(ctx, input, min_val, max_val):
output = torch.softmax(input, dim=-1).clamp(min_val, max_val)
ctx.save_for_backward(output) # saves tensor refs (not copies)
ctx.min_val = min_val # non-tensor metadata OK
return output
@staticmethod
def backward(ctx, grad_output):
output, = ctx.saved_tensors
# Jacobian of softmax: J_ij = o_i * (delta_ij - o_j)
# With clamping: gradient is zero where output was clamped
mask = (output > ctx.min_val) & (output < ctx.max_val)
# Softmax backward: grad_input = output * (grad_output - (grad_output * output).sum(-1, keepdim=True))
s = (grad_output * output).sum(-1, keepdim=True)
grad_input = output * (grad_output - s)
grad_input = grad_input * mask.float()
return grad_input, None, None # None for non-tensor args
Common pitfalls:
ctx.save_for_backward(x)→ storingxdirectly inctx(leaks memory, misses memory-efficient inplace handling)- Returning wrong number of gradients — must match number of forward inputs exactly (use
Nonefor non-differentiable) create_graph=True— if your backward uses non-differentiable ops, higher-order gradients will fail silently with wrong values
2. torch.fx — Symbolic Tracing Mechanics
2.1 What FX Actually Does
torch.fx.symbolic_trace(model) runs the model's forward() with Proxy objects instead of real tensors. Every operation on a Proxy records a Node in a Graph:
import torch.fx as fx
class MyModel(torch.nn.Module):
def forward(self, x):
return torch.relu(x + 1)
model = MyModel()
graph_module = fx.symbolic_trace(model)
print(graph_module.graph)
# graph():
# %x : [#users=1] = placeholder[target=x]
# %add : [#users=1] = call_function[target=operator.add](args = (%x, 1))
# %relu : [#users=1] = call_function[target=torch.relu](args = (%add,))
# return relu
The result is an FX IR — a data structure you can inspect, modify, and re-compile.
2.2 FX Graph Transformation — The Correct Pattern
class FuseConvBN(fx.Transformer):
def call_module(self, target, args, kwargs):
# Check if this is a Conv2d followed by BatchNorm2d
module = self.fetch_attr(target)
if isinstance(module, torch.nn.Conv2d):
# Look ahead in graph — check next user
node = self.current_node
if len(node.users) == 1:
next_node = list(node.users)[0]
if (next_node.op == 'call_module' and
isinstance(self.fetch_attr(next_node.target), torch.nn.BatchNorm2d)):
# Fuse: fold BN params into Conv weights
fused = torch.nn.utils.fusion.fuse_conv_bn_eval(
module, self.fetch_attr(next_node.target)
)
# Register fused module, return its output
self.root.add_module(target + '_fused', fused)
return self.tracer.call_module(target + '_fused', args, kwargs)
return super().call_module(target, args, kwargs)
2.3 FX Limitations You MUST Know for Interviews
- No data-dependent control flow:
if x.sum() > 0:cannot be traced — the Proxy doesn't know the value - No dynamic shapes by default: shapes are captured at trace time and become constants
- Closures and globals: Python closures are NOT captured in the graph — they become constants at trace time, which is a semantic footgun
torch.compilevs FX:torch.compileusesTorchDynamo(bytecode analysis) instead of symbolic tracing — it CAN handle control flow by creating multiple graphs with guards
3. torch.compile Stack
3.1 Three-Layer Architecture
User Python code
↓
TorchDynamo ← bytecode analysis, guards, graph breaks
↓ (FX Graph)
AOTAutograd ← joint forward+backward graph
↓ (FX Graph)
TorchInductor ← code generation (Triton for GPU, C++ for CPU)
↓
Compiled artifact ← .so or PTX or Metal shader
3.2 TorchDynamo — How Bytecode Analysis Works
TorchDynamo installs a frame evaluation callback that intercepts Python bytecode execution. When it encounters a function decorated with torch.compile, it:
- Disassembles the bytecode with
dis.get_instructions() - Tracks every operation that produces a tensor
- When it hits an unsupported operation (Python control flow on tensor values), it breaks the graph and falls back to Python
- For captured graph segments, it installs guards — runtime checks that verify the assumptions made during tracing are still valid
@torch.compile
def f(x):
if x.shape[0] > 10: # <- GUARD: shape[0] > 10
return x * 2
return x + 1
First call with x.shape = (15,) → compiles graph 1 (x * 2) with guard shape[0] > 10
Second call with x.shape = (5,) → guard fails → compiles graph 2 (x + 1) with guard shape[0] <= 10
Third call with x.shape = (20,) → guard 1 passes → reuses compiled graph 1
3.3 Custom Backend Registration
from torch._dynamo import register_backend
@register_backend
def my_npu_backend(gm: torch.fx.GraphModule, example_inputs):
# gm is the FX graph module
# example_inputs is a list of example tensors for shape/dtype inference
# Step 1: Optimize FX graph
gm = optimize_for_npu(gm)
# Step 2: Lower to NPU IR
npu_program = lower_to_npu_ir(gm, example_inputs)
# Step 3: Return a callable
def run(*args):
return npu_program.execute(args)
return run
model_compiled = torch.compile(model, backend="my_npu_backend")
4. TensorFlow Graph Execution Mechanics
4.1 tf.function Tracing Protocol
When Python hits a @tf.function-decorated function for the first time:
- Tracing: Python executes with
SymbolicTensorobjects (like PyTorch Proxies) - AutoGraph transformation: Python
if/for/whileare converted totf.cond/tf.while_loop - Graph construction: a
FuncGraphis built representing the TF ops - Compilation: the graph is compiled to a
ConcreteFunctionwith a specific input signature - Execution: subsequent calls with matching signatures execute the cached
ConcreteFunction
4.2 Retracing Triggers — The Complete List
@tf.function
def f(x, training=False):
return x * 2 if training else x + 1
f(tf.ones([3])) # trace 1: input_signature=(TensorSpec(shape=[3], dtype=float32),), training=False
f(tf.ones([3])) # reuse trace 1 ✓
f(tf.ones([4])) # trace 2: new shape [4] ← RETRACE (no input_signature specified)
f(tf.ones([3]), training=True) # trace 3: new Python value for training ← RETRACE
f(tf.constant(1.0)) # trace 4: scalar shape ← RETRACE
Prevention: use @tf.function(input_signature=[tf.TensorSpec(shape=[None, 128], dtype=tf.float32)]) to fix dtype/rank and allow variable batch/sequence dimensions.
4.3 tf.Variable Inside tf.function
# WRONG — creates a new Variable on every trace:
@tf.function
def bad(x):
v = tf.Variable(0.0) # ← Python-side effect, runs at trace time only!
v.assign_add(x.sum())
return v
# CORRECT — Variable must be created outside tf.function:
v = tf.Variable(0.0)
@tf.function
def good(x):
v.assign_add(x.sum())
return v
5. Numerical Precision Deep Dive
5.1 FP32 vs BF16 vs FP16
| Format | Sign | Exponent | Mantissa | Range | Precision |
|---|---|---|---|---|---|
| FP32 | 1 | 8 | 23 | ±3.4e38 | ~7 decimal digits |
| FP16 | 1 | 5 | 10 | ±65504 | ~3.3 decimal digits |
| BF16 | 1 | 8 | 7 | ±3.4e38 | ~2.3 decimal digits |
Key insight for NPU work: BF16 has the same dynamic range as FP32 (same exponent bits) but lower precision. This makes BF16 safer for training (no overflow/underflow) but potentially less accurate for narrow distribution outputs. Qualcomm's HTP natively supports INT8/INT16/FP16/BF16 — knowing which precision per-layer gives the best accuracy-performance tradeoff is core to this role.
5.2 Catastrophic Cancellation and Kahan Summation
Summing [1e8, 1.0, -1e8] in FP32:
- Naive:
(1e8 + 1.0) = 1e8(1.0 is below precision of 1e8 in FP32), then1e8 - 1e8 = 0.0← WRONG - Kahan: maintains a running compensation term, gets
1.0← CORRECT
def kahan_sum(values):
total = 0.0
compensation = 0.0
for v in values:
y = v - compensation
t = total + y
compensation = (t - total) - y
total = t
return total
Interview answer: "Why does your softmax accumulate error in FP16?" → explain that the normalization sum in softmax has large magnitude (sum of exps), and the numerator single-exp is small — catastrophic cancellation. Fix: compute in FP32, cast output to FP16.
6. Common Interview Pitfalls
| Question | Wrong Answer | Correct Answer |
|---|---|---|
| "How does autograd build the graph?" | "It runs the forward pass twice" | "It records ops during the single forward pass using Function objects attached to tensors as grad_fn" |
"What does detach() do?" | "It deletes the gradient" | "It creates a new tensor sharing storage but with no grad_fn, cutting the gradient tape at that point" |
"When should you use retain_graph=True?" | "Always, to be safe" | "Only when you need to call backward() multiple times on the same graph — e.g., for higher-order gradients or multiple loss terms with shared computation" |
| "What is the Meta device?" | "A CPU variant" | "A device where tensors have shapes and dtypes but no actual data — used by torch.compile for shape propagation without running kernels" |
"What does torch.compile break on?" | "Everything dynamic" | "Specifically: data-dependent control flow, dynamic shapes (by default), Python side effects, calls into non-PyTorch code — each triggers a graph break" |
7. Resources & Further Reading
- PyTorch Internals Blog — Edward Yang
- TorchDynamo — How it Works (PyTorch Dev Blog)
- Autograd: Automatic Differentiation in ML (JMLR 2017) — the paper behind modern autodiff engines
- TF
tf.functionGuide - PyTorch FX API Docs
- AOTAutograd internals
- Paper to read: "JAX: Composable Transformations of Python+NumPy Programs" (Bradbury et al., 2018) — JAX solves the same problems as torch.compile but differently; understanding both is a superpower