Skip to content

Commit 2de9a3e

Browse files
committed
Merge fix/autograd-topo-sort: correct silent wrong-answer bugs in autograd and quantization
backward() had no topological sort, so any reused tensor received half its gradient; quantize_int8 clamped the zero_point and destroyed all-positive tensors. Both returned plausible wrong numbers rather than raising. Adds the topological sort as taught material plus a regression test, and syncs the published listing.
2 parents 8436e09 + 03b6899 commit 2de9a3e

3 files changed

Lines changed: 268 additions & 74 deletions

File tree

tinytorch/quarto/modules/06_autograd.qmd

Lines changed: 43 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -538,12 +538,12 @@ Each operation knows only its own derivative; the chain rule does the connecting
538538

539539
### Backward Pass Implementation
540540

541-
The backward pass walks the computation graph in reverse, computing gradients for every tensor it visits. Your `backward()` method does this as a recursive tree walk — short enough to read in one sitting, but enough to support arbitrarily deep networks:
541+
The backward pass walks the computation graph in reverse, computing gradients for every tensor it visits. The order matters: a tensor can feed more than one operation, and its true gradient is the sum of what every consumer sends back, so it must not propagate until all of them have contributed. Your `backward()` method therefore sorts the graph topologically first, then makes a single pass over it:
542542

543543
The code in @lst-06-autograd-tensor-backward makes this concrete.
544544

545545
```python
546-
def backward(self, gradient=None):
546+
def backward(self, gradient=None, retain_graph=False):
547547
"""Compute gradients via backpropagation."""
548548
if not self.requires_grad:
549549
return
@@ -555,23 +555,50 @@ def backward(self, gradient=None):
555555
else:
556556
raise ValueError("backward() requires gradient for non-scalar tensors")
557557

558-
# Accumulate gradient (vectorized NumPy operation)
559-
if self.grad is None:
560-
self.grad = np.zeros_like(self.data)
561-
self.grad += gradient
562-
563-
# Propagate to parent tensors
564-
if hasattr(self, '_grad_fn') and self._grad_fn is not None:
565-
grads = self._grad_fn.apply(gradient) # Compute input gradients using vectorized ops
566-
567-
for tensor, grad in zip(self._grad_fn.saved_tensors, grads):
568-
if isinstance(tensor, Tensor) and tensor.requires_grad and grad is not None:
569-
tensor.backward(grad) # Recursive call
558+
# Step 1: topological order — every consumer of a tensor comes before it
559+
topo_order, seen = [], set()
560+
561+
def visit(tensor):
562+
if id(tensor) in seen:
563+
return
564+
seen.add(id(tensor))
565+
fn = getattr(tensor, '_grad_fn', None)
566+
if fn is not None:
567+
for parent in fn.saved_tensors:
568+
if isinstance(parent, Tensor):
569+
visit(parent)
570+
topo_order.append(tensor)
571+
572+
visit(self)
573+
topo_order.reverse()
574+
575+
# Step 2: seed the output, then make one pass
576+
pending = {id(self): gradient}
577+
for tensor in topo_order:
578+
grad = pending.get(id(tensor))
579+
if grad is None or not tensor.requires_grad:
580+
continue
581+
582+
if tensor.grad is None:
583+
tensor.grad = np.zeros_like(tensor.data)
584+
tensor.grad += grad # accumulate into .grad
585+
586+
fn = getattr(tensor, '_grad_fn', None)
587+
if fn is None:
588+
continue
589+
for parent, parent_grad in zip(fn.saved_tensors, fn.apply(grad)):
590+
if isinstance(parent, Tensor) and parent_grad is not None:
591+
pending[id(parent)] = pending.get(id(parent), 0) + parent_grad
592+
593+
# Step 3: release the graph once, after the whole walk
594+
if not retain_graph:
595+
for tensor in topo_order:
596+
tensor._grad_fn = None
570597
```
571598

572-
: **Listing 6.3 — `Tensor.backward()` seeds the output gradient, accumulates into `.grad`, and recurses through the `_grad_fn` chain.** {#lst-06-autograd-tensor-backward}
599+
: **Listing 6.3 — `Tensor.backward()` sorts the graph topologically, then makes one pass that accumulates into each `.grad` and hands every parent its share.** {#lst-06-autograd-tensor-backward}
573600

574-
For a 100-layer network, `loss.backward()` triggers 100 recursive calls — one per layer — flowing gradients from output to input. The traversal is recursive Python; the math inside each `apply()` is vectorized NumPy. That split is why the system stays both readable and fast.
601+
For a 100-layer network, `loss.backward()` sorts 100 tensors and then visits each exactly once, flowing gradients from output to input. Sorting first is what makes that "exactly once" possible: descending into a tensor's parents the moment you reach it would re-walk any shared subtree once per consumer, which is exponential on a graph with residual connections. The traversal is Python; the math inside each `apply()` is vectorized NumPy. That split is why the system stays both readable and fast.
575602

576603
The `gradient` argument deserves a closer look. For scalar losses (the typical case) you call `loss.backward()` with no arguments and the method seeds the gradient to 1.0 — because `∂loss/∂loss = 1`. For non-scalar outputs you must pass the upstream gradient explicitly; there is no canonical scalar to seed from, and silently picking one would hide bugs.
577604

tinytorch/src/06_autograd/06_autograd.py

Lines changed: 198 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -2440,6 +2440,52 @@ def __exit__(self, exc_type, exc_val, exc_tb):
24402440
return False # Don't suppress exceptions
24412441

24422442

2443+
# %% [markdown]
2444+
"""
2445+
### Walking the Graph in the Right Order
2446+
2447+
We have every gradient rule. What remains is the traversal: given the output,
2448+
visit the graph and hand each tensor its gradient. The obvious approach is
2449+
recursion -- compute a tensor's gradient, then immediately recurse into its
2450+
parents. That works, right up until a tensor is used twice.
2451+
2452+
```
2453+
x ──► y ──┬──► loss = y * y
2454+
└──►
2455+
```
2456+
2457+
Here `y` feeds the multiply twice, so the true gradient at `y` is the SUM of
2458+
what both edges send back. Naive recursion reaches `y` on the first edge and
2459+
descends the whole subtree below it before the second edge has contributed
2460+
anything. So `x` is updated from a partial gradient. Worse, when the second
2461+
edge arrives it descends that same subtree all over again -- work doubles at
2462+
every reused node, which is exponential on a graph with several of them.
2463+
2464+
The fix is a **topological order**: an ordering of the graph in which every
2465+
consumer of a tensor appears before the tensor itself.
2466+
2467+
```
2468+
build order (DFS post-order) reversed = topological order
2469+
[x, y, loss] [loss, y, x]
2470+
│ │ └─ visited last, gradient complete
2471+
│ └───── both edges have contributed
2472+
└─────────── the seed
2473+
```
2474+
2475+
Process tensors in that order and each one is visited exactly once, at the
2476+
moment its gradient is complete. Correct, and linear in the size of the graph
2477+
rather than exponential.
2478+
2479+
This is the piece that makes reverse-mode AD practical, and it is why PyTorch,
2480+
JAX, and every other framework sort before they walk. `backward()` below builds
2481+
the order with a depth-first post-order traversal, reverses it, then makes a
2482+
single pass -- accumulating into each `.grad` and passing each parent its share.
2483+
2484+
One consequence worth noticing: the graph can only be freed *after* the whole
2485+
walk. Releasing each node as you pass it would drop exactly the second
2486+
contribution the sort exists to collect.
2487+
"""
2488+
24432489
# %% nbgrader={"grade": false, "grade_id": "enable-autograd", "solution": true}
24442490
#| export
24452491
def enable_autograd(quiet=False):
@@ -2831,6 +2877,25 @@ def sum_op(self, axis=None, keepdims=False):
28312877

28322878
return result
28332879

2880+
def _reduce_grad_to(grad, shape):
2881+
"""Sum a gradient down to `shape`, undoing any broadcasting from the forward pass.
2882+
2883+
Broadcasting in the forward pass means one value was reused across many
2884+
positions, so in the backward pass every one of those positions sends a
2885+
gradient back and they must be summed. A bias of shape (features,) added
2886+
to a (batch, features) activation is the everyday case.
2887+
"""
2888+
if grad.shape == shape:
2889+
return grad
2890+
# Remove leading axes the forward pass added: (batch, features) -> (features,)
2891+
while grad.ndim > len(shape):
2892+
grad = grad.sum(axis=0)
2893+
# Sum axes that were size 1 before broadcasting: (1,) -> (batch,)
2894+
for axis in range(grad.ndim):
2895+
if shape[axis] == 1 and grad.shape[axis] != 1:
2896+
grad = grad.sum(axis=axis, keepdims=True)
2897+
return grad
2898+
28342899
def backward(self, gradient=None, retain_graph=False):
28352900
"""
28362901
Compute gradients via backpropagation.
@@ -2839,11 +2904,19 @@ def backward(self, gradient=None, retain_graph=False):
28392904
It implements reverse-mode automatic differentiation.
28402905
28412906
**Algorithm:**
2842-
1. Initialize gradient if not provided (for scalar outputs)
2843-
2. Accumulate gradient in self.grad
2844-
3. If this tensor has a _grad_fn, call it to propagate gradients
2845-
4. Recursively call backward() on parent tensors
2846-
5. Release computation graph (unless retain_graph=True)
2907+
1. Build a topological order of the graph, so every tensor is visited
2908+
only after all of the operations that consumed it
2909+
2. Seed the output tensor with the incoming gradient
2910+
3. Walk that order once, accumulating into each tensor's `.grad` and
2911+
handing each parent its share
2912+
4. Release the graph once, at the end (unless retain_graph=True)
2913+
2914+
**Why the topological order matters.** A tensor can feed more than one
2915+
operation -- a residual connection, a reused activation, or something as
2916+
small as `loss = y * y`. Its true gradient is the SUM of what every
2917+
consumer sends back, so it must not propagate until all of them have
2918+
contributed. Walking the graph in topological order guarantees exactly
2919+
that, and visits every tensor exactly once.
28472920
28482921
**Args:**
28492922
gradient: External gradient to seed backpropagation. If None, assumes
@@ -2857,14 +2930,12 @@ def backward(self, gradient=None, retain_graph=False):
28572930
```python
28582931
x = Tensor([2.0], requires_grad=True)
28592932
y = x * 3
2860-
y.backward() # Computes gradients for x, then releases graph
2933+
y.backward() # Computes gradients for x, then releases the graph
28612934
print(x.grad) # [3.0]
2862-
# y.backward() # Would fail — graph already released!
28632935
2864-
# To backward twice, use retain_graph=True:
2865-
y2 = x * 3
2866-
y2.backward(retain_graph=True) # Graph kept alive
2867-
y2.backward() # Works! (releases graph this time)
2936+
# A reused tensor accumulates from every consumer:
2937+
h = x * 2
2938+
(h * h).backward() # dL/dx = 2*(2x)*2 = 8*x
28682939
```
28692940
"""
28702941
# Ensure gradient attributes exist
@@ -2886,43 +2957,71 @@ def backward(self, gradient=None, retain_graph=False):
28862957
f" Fix: Call backward(gradient) with the gradient tensor from the loss function."
28872958
)
28882959

2889-
# Initialize or accumulate gradient
2890-
if self.grad is None:
2891-
self.grad = np.zeros_like(self.data)
2892-
2893-
# Handle broadcasting: sum gradient to match self.data shape
2894-
# This happens when operations broadcast tensors (e.g., adding bias to batch)
2895-
if gradient.shape != self.grad.shape:
2896-
# Step 1: Remove extra leading dimensions added during forward pass
2897-
# Example: gradient (batch_size, features) → self.grad (features,)
2898-
while gradient.ndim > self.grad.ndim:
2899-
gradient = gradient.sum(axis=0)
2900-
2901-
# Step 2: Sum over dimensions that were size-1 in original tensor
2902-
# Example: bias with shape (1,) broadcast to (batch_size,) during forward
2903-
for i in range(gradient.ndim):
2904-
if self.grad.shape[i] == 1 and gradient.shape[i] != 1:
2905-
gradient = gradient.sum(axis=i, keepdims=True)
2906-
2907-
self.grad += gradient
2908-
2909-
# Propagate gradients through computation graph
2910-
# _grad_fn is set by autograd enhancement when tensor is created from an operation
2911-
grad_fn = getattr(self, '_grad_fn', None)
2912-
if grad_fn is not None:
2913-
grads = grad_fn.apply(gradient)
2914-
2915-
# Recursively call backward on parent tensors
2916-
for tensor, grad in zip(grad_fn.saved_tensors, grads):
2917-
if isinstance(tensor, Tensor) and tensor.requires_grad and grad is not None:
2918-
tensor.backward(grad, retain_graph=retain_graph)
2919-
2920-
# Release computation graph to free memory (matches PyTorch's default)
2921-
# Why: The graph stores references to all intermediate tensors. Without
2922-
# cleanup, these references prevent garbage collection, causing memory
2923-
# to grow linearly with the number of training steps.
2924-
if not retain_graph:
2925-
self._grad_fn = None
2960+
# ---- Step 1: topological sort -------------------------------------
2961+
# Depth-first from the output, appending each tensor only AFTER its
2962+
# parents. Reversing that post-order gives an order in which every
2963+
# consumer of a tensor appears before the tensor itself.
2964+
topo_order = []
2965+
seen = set()
2966+
2967+
def visit(tensor):
2968+
if id(tensor) in seen:
2969+
return
2970+
seen.add(id(tensor))
2971+
fn = getattr(tensor, '_grad_fn', None)
2972+
if fn is not None:
2973+
for parent in fn.saved_tensors:
2974+
if isinstance(parent, Tensor):
2975+
visit(parent)
2976+
topo_order.append(tensor)
2977+
2978+
visit(self)
2979+
topo_order.reverse()
2980+
2981+
# ---- Step 2: seed the output --------------------------------------
2982+
# pending[id(tensor)] holds the gradient accumulated from the consumers
2983+
# visited so far. Keyed by id() because Tensors are not hashable.
2984+
pending = {id(self): gradient}
2985+
2986+
# ---- Step 3: one pass, in topological order ------------------------
2987+
for tensor in topo_order:
2988+
grad = pending.get(id(tensor))
2989+
if grad is None:
2990+
continue
2991+
2992+
_ensure_grad_attrs(tensor)
2993+
if not _get_requires_grad(tensor):
2994+
continue
2995+
2996+
# Accumulate into this tensor's .grad
2997+
if tensor.grad is None:
2998+
tensor.grad = np.zeros_like(tensor.data)
2999+
tensor.grad += _reduce_grad_to(grad, tensor.grad.shape)
3000+
3001+
# Hand each parent its share
3002+
fn = getattr(tensor, '_grad_fn', None)
3003+
if fn is None:
3004+
continue
3005+
parent_grads = fn.apply(grad)
3006+
for parent, parent_grad in zip(fn.saved_tensors, parent_grads):
3007+
if not isinstance(parent, Tensor) or parent_grad is None:
3008+
continue
3009+
if not _get_requires_grad(parent):
3010+
continue
3011+
parent_grad = _reduce_grad_to(parent_grad, parent.data.shape)
3012+
if id(parent) in pending:
3013+
pending[id(parent)] = pending[id(parent)] + parent_grad
3014+
else:
3015+
pending[id(parent)] = parent_grad
3016+
3017+
# ---- Step 4: release the graph, once ------------------------------
3018+
# The graph holds references to every intermediate tensor, so without
3019+
# this, memory grows with each training step. Releasing per-node DURING
3020+
# the walk would be a bug: a tensor with two consumers would lose the
3021+
# second contribution.
3022+
if not retain_graph:
3023+
for tensor in topo_order:
3024+
tensor._grad_fn = None
29263025

29273026
def zero_grad(self):
29283027
"""
@@ -3246,6 +3345,57 @@ def test_unit_tensor_autograd():
32463345
if __name__ == "__main__":
32473346
test_unit_tensor_autograd()
32483347

3348+
3349+
# %% [markdown]
3350+
"""
3351+
### 🔬 Unit Test: Gradients Through a Reused Tensor
3352+
3353+
This test validates the topological traversal: a tensor consumed by more than
3354+
one operation must accumulate from every consumer before it propagates.
3355+
3356+
**What we're testing**: Gradients are correct when an intermediate tensor is used twice
3357+
**Why it matters**: Residual connections, attention, and any `y * y` hit this path;
3358+
a traversal that visits a node once per edge silently halves the gradient
3359+
**Expected**: Analytic gradients, with the default `retain_graph=False`
3360+
"""
3361+
3362+
# %% nbgrader={"grade": true, "grade_id": "test-reused-tensor-gradients", "locked": true, "points": 10}
3363+
def test_unit_reused_tensor_gradients():
3364+
"""🔬 Test gradient accumulation through a tensor with multiple consumers."""
3365+
print("🔬 Unit Test: Reused Tensor Gradients...")
3366+
3367+
# loss = (x @ W)^2 -> dL/dW = 2(xW)x = 24, dL/dx = 2(xW)W = 36
3368+
x = Tensor(np.array([[2.0]]), requires_grad=True)
3369+
W = Tensor(np.array([[3.0]]), requires_grad=True)
3370+
y = x.matmul(W)
3371+
(y * y).backward()
3372+
assert np.allclose(W.grad, 24.0), f"Expected dL/dW = 24, got {W.grad}"
3373+
assert np.allclose(x.grad, 36.0), f"Expected dL/dx = 36, got {x.grad}"
3374+
print(" ✅ y = x@W, loss = y*y: both gradients correct")
3375+
3376+
# out = z + z -> dout/dW = 2x = 4
3377+
x2 = Tensor(np.array([[2.0]]), requires_grad=True)
3378+
W2 = Tensor(np.array([[3.0]]), requires_grad=True)
3379+
z = x2.matmul(W2)
3380+
(z + z).backward()
3381+
assert np.allclose(W2.grad, 4.0), f"Expected dout/dW = 4, got {W2.grad}"
3382+
print(" ✅ z = x@W, out = z+z: gradient accumulates from both edges")
3383+
3384+
# A tensor reused at several depths: each level doubles the gradient
3385+
x3 = Tensor(np.array([[1.0]]), requires_grad=True)
3386+
W3 = Tensor(np.array([[1.0]]), requires_grad=True)
3387+
h = x3.matmul(W3)
3388+
for _ in range(8):
3389+
h = h + h
3390+
h.backward()
3391+
assert np.allclose(W3.grad, 2 ** 8), f"Expected 2^8 = 256, got {W3.grad}"
3392+
print(" ✅ eight reuse levels: gradient is 2^8, computed in one pass")
3393+
3394+
print("✅ Reused-tensor gradients work correctly!")
3395+
3396+
if __name__ == "__main__":
3397+
test_unit_reused_tensor_gradients()
3398+
32493399
# %% [markdown]
32503400
"""
32513401
## 📊 Systems Analysis: Computation Graph Memory

0 commit comments

Comments
 (0)