-
I've known the theory behind neural network training for years. The chain rule, backpropagation, gradient descent: I could explain all of it on a whiteboard. But at some point I realized I had never actually implemented it. I had used PyTorch, trusted
loss.backward(), and never once written the thing that fills in the gradients myself.So I sat down and built one. The result is microcrad, a small scalar-valued automatic differentiation engine written in C, with a tiny neural network on top. It's a re-implementation of Andrej Karpathy's micrograd, which is in Python.
I want to walk through how it works and, more interestingly, through the part the Python original gets to ignore entirely: who owns the memory.
Two constraints
Before the code, the rules I set for myself, because they shaped everything that follows.
First, I wrote it in C. I hadn't used C seriously since university, and that was the point: I wanted the friction. A high-level language lets you stay vague about what's actually happening; C does not. Salvatore Sanfilippo's C programming course is what finally nudged me back to the language.
Second, no AI assistance for any of the source code. If I was going to claim I understood backpropagation, I wanted to have typed every line of it myself. That's a slow way to work in 2026, and that was also the point.
This is a learning project, not a framework. It's scalar, not tensor-based, so it's slow and won't train anything serious. It exists to be read.
The whole thing is one idea repeated
The engine operates on scalars, not tensors. Every number that takes part in a computation is a node in a graph, every operation records how it was produced, and a single backward pass walks the graph in reverse to compute the derivative of the output with respect to every input. No vectorization, no GPU, no clever tricks. Just the chain rule, applied one scalar at a time. There's nowhere to hide, which is exactly why it's good for learning.
The fundamental type is
Value. It wraps a singledoubleand, when it's the result of an operation, remembers the operands it came from:typedef struct Value { uint32_t ref_count; /* How many references point at this Value. */ uint32_t n_prevs; /* How many operands produced this Value. */ double data; /* The scalar this node holds. */ double extra_data; /* Operation parameter (e.g. the exponent in pow). */ struct Value **prev; /* The operands (previous nodes in the graph). */ int32_t op_code; /* Which operation produced this Value. */ uint32_t magic; /* Debug canary for some invalid or stale pointers. */ double grad; /* dLoss/dThisValue, filled in by backward. */ } Value;A leaf node, an input or a weight, has no operands. A node produced by an operation points back at the operands it depends on through
prev. Follow those pointers from any node and you walk a directed acyclic graph: the computation graph.The smallest program that computes something and its gradient looks like this:
Value *a = value_create_leaf(2.0); Value *b = value_create_leaf(3.0); Value *c = value_mul(a, b); /* c = a * b = 6 */ value_backward(c); /* fills in every grad field */ printf("dc/da = %f\n", a->grad); /* 3.000000 (== b) */ printf("dc/db = %f\n", b->grad); /* 2.000000 (== a) */The operations I implemented are addition, multiplication, power (with a constant exponent), exp, log, and ReLU. That's a deliberately small set, but it's exactly enough to build a ReLU network with a mean-squared-error or negative-log-likelihood loss. I didn't write subtraction or division as separate operations: subtraction is addition with a negated operand, and division is multiplication by a reciprocal. Keeping the primitive set small keeps the backward pass small too.
The backward pass
value_backwarddoes the same two-step dance as the original micrograd.First it builds a topological ordering of the graph with a depth-first traversal, so that every node comes after all the nodes it depends on. Then it seeds the output's gradient to 1 and walks the sorted list in reverse, and at each node it pushes gradient onto that node's operands according to the local derivative of the operation that produced it.
In code, the reverse walk is just a
switchover the operation codes:v->grad = 1; for (size_t i = topo->size-1; i != 0; i--) { Value *node = topo->data[i]; switch (node->op_code) { case ADD_OP_CODE: for (uint32_t j = 0; j < node->n_prevs; j++) node->prev[j]->grad += node->grad; break; case MUL_OP_CODE: for (uint32_t j = 0; j < node->n_prevs; j++) node->prev[j]->grad += node->grad * node->prev[1-j]->data; break; /* ... pow, exp, log, relu ... */ } }The detail worth pausing on is
+=. Gradients accumulate. If aValueis used in more than one place in the graph, it correctly receives the sum of the gradients flowing back through each path. That single+=is doing the multivariable chain rule for you. It's also why the engine doesn't reset gradients itself: when you train in a loop you have to zero them yourself before each backward pass, which both training examples do explicitly.The part Python hides
Here is where C stopped being an aesthetic choice and started teaching me something.
In Python, you build the graph, call backward, and let the garbage collector clean up whenever it feels like it. You never think about it. In C there is no garbage collector, and a computation graph is a tangle of shared pointers. The same weight
Valuecan be an operand of thousands of result nodes. The same intermediate result can feed several downstream operations. Freeing that by hand, node by node, is exactly the kind of thing that produces either leaks or double-frees.I solved it the way a lot of long-lived C programs do: reference counting.
void value_retain(Value *v); /* take a reference: ref_count++ */ void value_release(Value *v); /* drop a reference: ref_count-- */The rules are small. Every
Valueis born with a reference count of 1, owned by whoever created it. Each operation retains its operands, meaning a result node co-owns the values it was computed from. Andvalue_releasedecrements the count; when it hits zero, the node frees itself and releases its own operands first, which may free them in turn, and so on down the graph:void value_release(Value *v) { if (v == NULL) return; if (v->magic != VALID) { /* log: stale pointer */ return; } v->ref_count--; if (v->ref_count > 0) return; v->magic = INVALID; for (uint32_t i = 0; i < v->n_prevs; i++) value_release(v->prev[i]); free(v->prev); free(v); }That recursion is the whole trick. You almost never free a graph node by node. You release the root (the loss, or the output of a forward pass) and the reference counts cascade downward, freeing exactly the nodes that nothing else still holds. The weights survive, because the network structure also holds a reference to them. The pure intermediates, held only by the result you just released, disappear.
It's a genuinely satisfying property once it clicks: a graph that frees itself, and sharing that is both free and correct. A weight used in ten thousand multiplications is just retained ten thousand times, and it's freed neither too early nor too late.
The honest cost is that you have to balance every single reference by hand. Every
value_create, everyvalue_retain, every operation's implicit retain of its operands has to be matched by exactly onevalue_release. Forget one and you leak; do one too many and you free memory that's still in use. This is why the training examples in the repo look so verbose in their error paths: they're being scrupulous about releasing everything they allocated, even when an allocation halfway through fails. That verbosity isn't boilerplate for its own sake. It's the price of leak-free C, and seeing it laid out is part of what the project is meant to teach.The other surprise was that operations co-own their operands, so you can't reason about a single
Value's lifetime in isolation. Afterc = value_add(a, b), the nodesaandbstay alive becausecholds them, even if you've released your own references. That's what makes the recursive free work, but it took me a couple of confusing debugging sessions to internalize that I had to think about the whole graph, not one node.To make those sessions less confusing, every
Valuecarries amagicfield, set to a known constant when it's created and poisoned right before the node is freed.value_retainandvalue_releasecheck it. If I ever touched a stale pointer, I'd often get a clear complaint instead of silent corruption. It's a debug canary, not a correctness guarantee, and it caught a lot of my own mistakes.A couple of small design choices I enjoyed
The topological sort needs to avoid visiting a shared node twice, so it needs a set keyed on node identity. I wrote a
SimpleSetthat keys on the pointer's address, rendered as a fixed-width uppercase hex string, and stores those strings in a trie with 17 children per node (16 hex digits plus a sentinel slot for the end of the key). It only supports insertion and membership, which is all the sort needs. It's almost certainly over-engineered for the job a hash set would have done, but I enjoyed writing it, and that counts for something in a project like this.Error handling is two small macros.
MC_LOG_ERRlogs a message with file, line, and function.MC_GUARD(cond, label, msg)logs and jumps to a cleanup label if a condition holds. The result is the classic single-cleanup-labelgotopattern for unwinding partial allocations, which I'd read about for years and never actually had a reason to use until now.The neural network
On top of the engine sits a deliberately plain feed-forward network. A
Neuroncomputesrelu(w·x + b). ALayeris a group of neurons sharing the same input. AnMLPchains layers. Because every parameter is aValue, a forward pass automatically builds a computation graph you can backpropagate through, with no extra machinery.One simplification worth flagging: every neuron applies a ReLU, including the ones in the output layer. That means the network's outputs are always non-negative, which constrains what it can represent. It's why the toy example targets
y = max(0, x1 + x2), a function that's itself non-negative. That example trains a 2-8-1 network with mean-squared error and full-batch gradient descent, and it's the smallest complete program in the repo: create a model, build a graph, backpropagate, update parameters, run inference. There's also an MNIST example, but I'm upfront in the repo that it's a structural demonstration of wiring the engine to a real dataset, not a practical training recipe. A scalar, ReLU-only engine isn't going to train a real classifier.What it is and isn't
It's about 1,350 lines of C. No dependencies beyond a C compiler, the standard library, and
libm. MIT licensed, driven by aMakefile, with a standalone test suite per component. It is not a production autograd package, not a practical deep-learning framework, and not built for numerical robustness or large datasets. It's a thing to read.If you've ever used
.backward()without knowing what it does, or you write C and want to tell me what I got wrong about the ownership model, the code is here: github.com/oraziorillo/microcrad. I'd genuinely like to hear it. The most instructive part of the whole exercise was the memory, and I suspect there are people who'd have done it more elegantly.