Open source. Open to your next idea. Explore EveryO 0.2.0

INTERNALS / COMPLETE GUIDE v0.2.0 source snapshot

Automatic differentiation.

Trace computation graphs, the reverse-mode backward pass, gradient accumulation, broadcasting, and finite-difference verification.

READ WITH CONTEXT

Complete upstream referenceAll source sections are preserved below, with added explanations and local guide links.

Some snippets require earlier definitions or optional integrations. Start with the complete CPU quickstart for a runnable baseline.

Back to the quickstart →
READ THE FULL STORY

Source: EveryO 6c05a06. This page preserves the reviewed upstream guide and adds learning context. Version-specific headings and historical wording remain visible; boundary notes explain newer APIs separately.

BEFORE YOU BEGIN

Make the chain rule concrete.

Every differentiable operation computes a result and records how to send a gradient back to its inputs. Follow the forward graph first, then walk the same operations backward.

Work through y = x × x at x = 2

  1. The forward pass reads x twice and produces y = 4.
  2. The scalar backward pass starts with an incoming gradient of 1.
  3. Each multiplication input contributes 1 × 2 to x.
  4. The two paths accumulate: x.grad = 2 + 2 = 4.
Accumulation is part of the contract

Calling backward() again adds gradients. Clear them between optimizer steps unless accumulation is intentional. Intermediate tensors keep gradients only when requested with retain_grad().

Observe accumulation, then reset it

Run this complete example after installation. Rebuilding the same scalar expression creates a fresh graph, while the leaf tensor keeps its accumulated gradient until you clear it.

Python · reference snippet
import everyo as eo

x = eo.tensor([2.0], requires_grad=True)
(x * x).backward()
print(x.grad)  # [4.]

(x * x).backward()
print(x.grad)  # [8.] -- contributions accumulate

x.zero_grad()
(x * x).backward()
print(x.grad)  # [4.]

The engine is roughly 250 lines and is worth reading end to end (everyo/core/autograd.py). This page explains the idea behind it.

The computation graph

Every differentiable operation does three things:

  1. computes the forward result,
  2. captures whatever it needs for the backward pass in a closure,
  3. attaches a Node recording the inputs and that closure.
Python · reference snippet
def multiply(a, b):
    left, right = _binary_inputs(a, b, "multiply")
    data = _dispatch("multiply", device, left.data, right.data)

    def backward_fn(gradient):
        return (
            unbroadcast(gradient * right.data, left.shape),
            unbroadcast(gradient * left.data, right.shape),
        )

    return _make(data, (left, right), "multiply", backward_fn)

The closure implements the product rule: d(ab)/da = b and d(ab)/db = a, each multiplied by the incoming gradient (the chain rule).

Nodes are created only when gradient mode is enabled and at least one input requires gradients. Inference therefore builds no graph at all.

The backward pass

Tensor.backward() seeds the output gradient (ones, for a scalar), walks the graph in reverse topological order and accumulates gradients into each tensor's .grad.

Python · reference snippet
for node in topological_order(root):
    incoming = gradients.get(id(node))
    if node.requires_grad and (node.is_leaf or node.retains_grad):
        node.accumulate_grad(incoming)
    for parent, parent_grad in zip(node.grad_node.parents, node.grad_node.backward_fn(incoming)):
        gradients[id(parent)] = gradients.get(id(parent), 0) + parent_grad

Two details matter:

  • The traversal is iterative. A recursive walk would hit Python's recursion limit on deep graphs; tests/test_autograd.py checks a 2000-operation chain.
  • Gradients are summed, not replaced. When a tensor feeds several operations, every path contributes. That is why x + x gives dx = 2, and why you must call zero_grad() between steps unless you want accumulation.

Broadcasting

NumPy broadcasting means a (3,) bias can be added to a (32, 3) activation. The gradient then arrives with the broadcast shape (32, 3) and must be summed back down to (3,) — summing over the expanded axes is exactly the derivative of broadcasting. unbroadcast() does this, and every broadcasting operation routes its gradients through it.

Only leaves keep gradients

Intermediate tensors do not retain .grad, which keeps memory bounded. Call .retain_grad() on one if you want to inspect it:

Python · reference snippet
hidden = (x * 3).retain_grad()
(hidden * 2).backward()
print(hidden.grad)  # [2.]

Turning it off

Python · reference snippet
with eo.no_grad():
    predictions = model(features)  # no graph is built

no_grad is thread-local, works as a decorator, and restores the previous mode on exit. enable_grad() re-enables tracking inside a no_grad block.

Supported operations

Gradients are implemented and finite-difference tested for: add, subtract, multiply, divide, negate, power, exp, log, sqrt, abs, clip, matmul (including the vector cases), dot, sum, mean, max, min, variance, reshape, transpose, flatten, concatenate, stack, indexing, ReLU, sigmoid, tanh, softmax and log-softmax.

How correctness is verified

For a scalar function f, the central difference

Reference / output
(f(x + h) - f(x - h)) / 2h

approximates df/dx with error O(h²). tests/test_autograd.py compares every analytic gradient against this approximation in float64. If a backward pass were wrong, the comparison would fail — which is exactly what happened, and got caught, while this engine was being written.

What is not implemented in v0.1.0

  • Second-order derivatives (the graph is not differentiable itself).
  • In-place operations that would invalidate saved tensors.
  • Gradients through integer or boolean tensors (these cannot require grad).

Original guide reproduced under the project’s MIT license. Upstream source remains authoritative.

View the reviewed source on GitHub ↗

Search documentation

Enter opens the first result · Tab moves through links · Esc closes