vkML 0.1.0

Autograd

Reverse-mode differentiation built entirely from forward operators — and what that buys.

Every backward rule is a forward operation

d(a·b)/da is mul(grad, b) — an ordinary Mul node appended to the graph, not a call into a dedicated mul_backward kernel.

This is ggml's model and tinygrad's, and it is the reason the project needs roughly 64 kernels rather than 120. Three consequences follow for free:

The cost is that a fused backward kernel would be perhaps 10–20% faster on some operators. That is recorded as the right thing to trade away, and individual operators can be fused later as a pure optimisation with no API change.

Four exceptions, each earned

OpKind contains no *_backward entries — with four deliberate exceptions, which appear as ordinary forward operators because they genuinely cannot be composed from the element-wise and reduction set:

OperatorAdjoint ofWhy it cannot be composed
ScatterAddindex_select Several sources may target one destination, so contributions must accumulate.
Col2Imim2col Overlapping windows must sum where they overlap.
MaxPool2dBackwardmax_pool2d The gradient goes only to the argmax of each window.
SliceBackwardslice Scatters into a zero-filled tensor of the original extent — no combination of the element-wise or reduction operators expresses that without an index kernel.

All four are scatters. That is the shape of thing this design cannot express, and naming them as forward operators rather than as backward special cases keeps the rule intact: there is one kind of node, and gradients are built from it.

How backward() walks the graph

  1. Checks first. The root must be defined, must require grad, and must be a floating dtype — an integer root raises DTypeError rather than producing zeros.
  2. Autograd order. The subgraph reachable from the root is ordered.
  3. Seed. The root's gradient is the supplied seed; the no-argument form uses ones and requires a scalar, matching torch.Tensor.backward().
  4. Reverse iteration. A node's gradient is complete only once every consumer has contributed, and consumers all appear later in the order — so walking it backwards is what makes each gradient final when it is used. Nodes with no gradient entry are unreachable from the root and skipped; leaves receive but do not propagate.
  5. Deposit into leaves, accumulating rather than replacing.
ⓘ Note

Accumulation is PyTorch's rule and is what makes gradient accumulation across micro-batches work. It also means a training loop must call zero_grad() between steps — vkML will not do it, because it cannot tell an intentional accumulation from a forgotten reset.

Which tensors keep a gradient

Only leaves marked as parameters retain one. Intermediate .grad is dropped, matching PyTorch, where it is discarded unless explicitly retained. The gradient is realized as it is deposited, so .grad holds a value rather than an unevaluated graph — which matters because an optimiser reads every gradient immediately afterwards.

47 rules of 66 operators

19 operators have no gradient rule. Calling backward through one raises NotImplementedError naming the operator rather than silently producing a zero. They fall into four groups, and the distinction matters — only one of them is a gap:

GroupOperatorsStatus
Leaves and creation Input Const Full Arange Rand Nothing to propagate to — they have no inputs.
Boolean results Equal NotEqual Less LessEqual Greater GreaterEqual Not differentiable — the output is not a float.
Discontinuous or integral ArgMax ArgMin Sign Derivative is zero almost everywhere and undefined at the steps.
Already a backward MaxPool2dBackward SliceBackward Second-order through them is not implemented.
Genuinely missing Prod Erf Erfc Differentiable, simply not written.

Only the last row is a gap, and a small one: erfc exists so that gelu's gradient can be built on it, and the gradient of erfc itself has no caller yet. prod has no Vulkan kernel either, for the separate numerical reason described on its own page.

Each operator's page states which case it is in, extracted from the dispatch rather than listed by hand.

Turning recording off

no_grad() suppresses graph recording for its scope. Two places rely on it, and for the same reason: an update is a mutation of state, not part of the function being differentiated.

detach is the per-tensor equivalent: same values, no history.

vkML — Vulkan-first machine learning in C++20. Apache-2.0. Signatures on this page are generated from the installed module.