3.5. Runtime and Autograd
When x is used twice in a multiplication, reverse mode must account for both uses. If two
squares then share that same input, their contributions must meet there too. I'll record this
small branched calculation by hand so we can inspect every parent reference and follow every
contribution.
The eager tape keeps the values and reverse rules from that execution. A typed graph lets us record the operation sequence once and supply new values later. Comparing the two shows what the public calls in Differentiation By Example construct. Choosing How A Model Runs explains how the trainer selects its path.
The named Lean blocks are elaborated while this page is built, together with checks of their displayed outputs. The command-line and PyTorch transcripts record separate runs; they are not executed by the page build.
API Entry Points And Runtime Artifacts
An eager training step records a new tape; a typed-graph trainer reuses its recorded graph.
A standalone autograd.grad call constructs its own computation:
You call | What is built | Typical use |
|---|---|---|
| an eager tape per step | ordinary training |
| one typed graph, reused | ordinary training, fewer rebuilds |
| a typed graph for each transform invocation | differentiating a function or a loss |
| a tape you own, as pure data | inspection, tests, and this chapter |
The autograd.* transforms do not consult the trainer's execution setting. In
Autodiff.lean, gradients starts with
lowerScalarToTypedGraph, then evaluates through checked tape lowering and a reverse pass.
The graph describes the reusable computation; the tape captures values for this invocation.
Higher-order transforms in the same file lower graphs to tapes over dual scalars, so the reverse
calculation also carries directional derivatives. The trainer's execution choice is separate
from these transforms.
Consequently, comparing autograd.grad with an eager training step compares two execution paths.
A discrepancy could come from either path or from the state supplied to it. Inspecting only the
eager tape would leave the transform's typed graph unexamined.
Runtime Artifact Types
The type parameters reveal which information callers must supply statically and which information the representation stores internally:
-- Inspect the static information retained by each runtime
-- artifact.
#check @Tape
#check @Torch.TypedGraph
#check @IRExec.ForwardGraph
#check @NN.IR.Graph
#check @NN.Backend.IR.GraphKernelPlan
Tape α takes an element type and nothing else. No shapes appear in the type, which means the tape
cannot enforce a shape agreement in the type system; it stores shapes as values and checks them at
runtime. That is a deliberate trade, and we come back to it below.
Torch.TypedGraph α Γ τ carries a context Γ : List Shape and an output shape τ. A node cannot
refer to a parent at the wrong shape, because the shape is part of the graph's type. This is the
same trick the sequential architecture language uses in GraphSpec: One
Architecture, Several Meanings, applied one level lower.
IRExec.ForwardGraph α looks like the tape again, but for a different reason: its shapes are stored
in fields (inShape, ss) because the graph it was lowered from did not know them statically
either.
NN.IR.Graph : Type has no α. The canonical IR is not
parameterized by a scalar type, so the same graph can be interpreted over the
reals, over intervals, and over binary32 without being rewritten. See
Graph IR.
NN.Backend.IR.GraphKernelPlan : Type has no element type and no shapes. It holds node identifiers
and capsule metadata, and it contains no tensors whatsoever. A plan therefore cannot witness that
anything executed. Accepting a plan is a statement about a choice, not about a computation.
Here is the same set with the two remaining columns filled in:
Object | Purpose | Contains |
|---|---|---|
eager tape | reverse-mode execution | values, parents, local VJPs |
| repeated runtime execution | shape-indexed forward, JVP, and VJP functions |
|
execute a lowered | a forward-only shape-indexed node list |
| inspection and verification | explicit operation tags and payload references |
| kernel selection and audit | source node ids and selected capsule metadata |
A CUDA Graph capture is a separate device launch-and-replay mechanism; TorchLean's .typedGraph
setting does not select it. The other boundaries also require explicit connections. An accepted
kernel plan needs runtime binding before it says anything about execution, and an IR semantics
theorem needs a provider refinement result before it applies to a native tape node.
The five type signatures divide the debugging problem into useful pieces. A wrong tensor value belongs to an execution artifact such as the tape or evaluated graph context. A wrong operation tag belongs to the canonical IR. A surprising provider belongs to the kernel plan and its runtime binding. None of the last two objects contains the floating-point intermediate that caused a loss to become nonfinite. Keeping these objects distinct tells us which additional record to request instead of treating every representation called a graph as interchangeable.
The scalar parameter also has consequences. The same shape-indexed program can be interpreted
with different scalar operations, but a tape already contains values of its chosen α. Changing
arithmetic means evaluating with the other scalar interpretation, not relabeling those stored
values. Shape agreement survives that choice; numerical agreement requires a separate argument.
Manual Tape Construction
Tape α is a grow-only array of nodes; TapeM α is StateT (Tape α) (Except String), so a
tape-building computation is a pure function and its errors are values. To examine the node
structure, record two elementwise products sharing the same input, followed by their sum:
z = x \odot x + x \odot x, \qquad \ell = \textstyle\sum_i z_i
Here \odot denotes elementwise multiplication, z has the same shape as x, and
\ell reduces it to a scalar. The following code uses x=(3,-1.5):
-- Record both square branches separately so their shared
-- input is visible.
/-- `z = x*x + x*x`, summed, built on the eager tape. -/
def rtTwice : Result (Nat × Tape Float) :=
TapeM.run Tape.empty do
let x ← TapeM.leaf (α := Float) (s := [2])
[3.0, -1.5] (name := "x")
let a ← TapeM.mul (α := Float) (s := [2]) x x
let b ← TapeM.mul (α := Float) (s := [2]) x x
let z ← TapeM.add (α := Float) (s := [2]) a b
TapeM.sum (α := Float) (s := [2]) z
#eval rtTwice.map fun (out, tape) => (out, tape.size)
Five nodes, and the scalar output is node 4. Each TapeM operation appends exactly one node and
returns its identifier, so identifiers are array indices and reverse-mode traversal is a loop from
size - 1 down to 0. Nothing here is hidden state: the tape is the value returned by run.
Ask the tape what it recorded:
-- Print parent positions, including repeated uses of the
-- same node.
#eval rtTwice.map fun (_, tape) =>
tape.nodes.map fun (node : Node Float) =>
(node.name, node.parents)
The leaf has no parents. Both multiplications name node 0 twice, once for each factor, which is
literally what x * x means. The addition names both products. This printout is the graph, and it
came out of the same data structure the trainer uses.
A node also carries the piece we did not print, because it is a closure rather than data:
-- A local rule may emit several contributions to the same -- parent. backward : SomeTensor α → Result (Array (Nat × SomeTensor α))
Given an upstream cotangent for this node's value, return one contribution per parent. That signature is the whole local interface of reverse mode. Nothing in it mentions the rest of the graph, which is why local derivative rules can be stated and proved one operation at a time.
The result (4, 5) contains the output node identifier and the tape length. Node identifiers start
at zero, so the scalar sum at node 4 is the fifth recorded value. The result is not the loss
itself. At the chosen input, each square branch contains [9, 2.25]; adding them gives [18, 4.5],
and the scalar sum is 22.5. This calculation supplies a forward reference before inspecting any
derivative.
The two parent entries [0, 0] on each mul are intentional. Both operands refer to the same
input, but they occupy different argument positions in multiplication. A reverse rule owes one
contribution for each position. Deduplicating that parent list without changing the rule would
lose half of the derivative of a square. Reusing a value and using it only once are different
properties of a graph.
Gradient Accumulation On The Tape
Two product branches run from x to z. Each squaring contributes 2x, and the
addition sends the output seed to both of its parents, so the leaf's cotangent is
\bar x = 2x + 2x = 4x.
At x = (3, -1.5) that predicts (12, -6). Seed the scalar with 1 and read node 0:
-- Recover the input cotangent only after checking its
-- stored shape.
/-- Seed the scalar output, then read the leaf gradient. -/
def rtLeafGrad (built : Result (Nat × Tape Float)) :
Result (Tensor Float [2]) := do
let (out, tape) ← built
let grads ← Tape.backwardScalar (α := Float) tape out
match grads[0]? with
| some g => Tape.requireGrad (α := Float) (τ := [2]) g
| none => .error "leaf 0 received no gradient"
#eval rtLeafGrad rtTwice
Each mul returns two contributions to node 0, one for each occurrence of x, giving four
contributions in total. If later
contributions overwrote earlier ones instead of adding, the result would be wrong. Accumulation
happens in exactly one place,
SomeTensor.add in Engine/Core/Base.lean,
which checks that the two shapes agree before adding. Reverse-mode AD on a DAG is a sum over paths
(Baydin et al. (2018)), and this one function is where the sum is taken.
This is also why TorchLean's autograd proofs come in two layers rather than one. There are primitive derivative facts, one per operation, and there is a global soundness statement about the traversal that composes them. A library that proves only the first layer has proved that its rules are right and said nothing about whether they are combined correctly. Proving Autograd Correct is where both layers are stated and discharged.
PyTorch Gradient Accumulation
PyTorch accumulates the two branch contributions within a backward pass as well ((Paszke et al., 2019)). Detaching the second product removes its contribution:
# Compare two live branches with one branch detached from
# autograd.
import torch
x = torch.tensor([3.0, -1.5], requires_grad=True)
z = (x * x + x * x).sum()
z.backward()
print("both branches live:", x.grad.tolist())
x.grad = None
z = (x * x + (x * x).detach()).sum()
z.backward()
print("one branch detached:", x.grad.tolist())
On torch 2.13.0 that prints:
both branches live: [12.0, -6.0] one branch detached: [6.0, -3.0]
The first line agrees with the tape we built. The second line is the next section.
The backward closure's result is a list of contributions, not a map with one entry per parent.
That lets multiplication return two entries for node 0, and lets the traversal add contributions
from both square branches later. At x = [3, -1.5], one square contributes [6, -3]; the second
contributes the same vector. Their sum explains every coordinate of [12, -6] without relying on
the implementation's own gradient formula as an oracle.
The scalar seed is 1, so this is the gradient of the displayed scalar sum. A different scalar
seed would scale all the contributions. A vector-output seed would instead choose a weighted sum
of outputs and would need the output's shape. Runtime shape checks protect that interface even
though node identifiers themselves are plain natural numbers.
Stop-Gradient Nodes
detach is often described as "turning off gradients", which suggests a mutable switch. In this
engine it is a node like any other, and the node is short enough to read in full. Here is exactly
what Core/Session.lean appends, transcribed
into the pure API:
-- Preserve the second branch value while returning no
-- parent cotangents.
/--
The stop-gradient node, built the way the eager session
builds it: the forward value is reused, the parent edge
stays, and the local rule returns no contributions.
-/
def rtDetachNode (id : Nat) :
TapeM Float Nat := fun tape => do
let value ←
Tape.requireValue (α := Float) (t := tape) (s := [2]) id
let (tape', newId) := Tape.addNode tape
{ name := some "detach"
value := Spec.SomeTensor.ofTensor value
requiresGrad := false
parents := #[id]
backward := fun _ => .ok #[] }
pure (newId, tape')
/-- The same computation, second branch stopped. -/
def rtStopped : Result (Nat × Tape Float) :=
TapeM.run Tape.empty do
let x ← TapeM.leaf (α := Float) (s := [2])
[3.0, -1.5] (name := "x")
let a ← TapeM.mul (α := Float) (s := [2]) x x
let b ← TapeM.mul (α := Float) (s := [2]) x x
let bStop ← rtDetachNode b
let z ← TapeM.add (α := Float) (s := [2]) a bStop
TapeM.sum (α := Float) (s := [2]) z
#eval rtLeafGrad rtStopped
2x instead of 4x, matching PyTorch's second line to the digit. Three fields did that work:
backward returns an empty array, so no contribution flows; parents still names the source, so
the forward edge remains visible to anything that reads the graph; and requiresGrad := false
causes parent accumulation to skip this node. The forward tensor value is reused without
recomputing or copying its payload, so the forward result is unchanged.
The node list makes the difference visible:
-- Keep stopped forward nodes visible even though reverse
-- propagation cannot cross them.
#eval rtStopped.map fun (_, tape) =>
tape.nodes.map fun (node : Node Float) =>
(node.name, node.parents)
Six nodes now, and the addition's parents are 1 and 3: the live product and the detached copy.
The tape still knows the whole forward computation. Only the reverse rule changed.
Reverse-Pass Reachability
Because the detached node hands back nothing, the traversal never asks node 2 for anything, and
2 never asks the leaf. That is observable. Tape.backward returns a map keyed by node
identifier, containing exactly the nodes that received a cotangent:
-- Inspect presence in the gradient map separately from
-- numerical zero.
#eval rtStopped.bind fun (out, tape) =>
(Tape.backwardScalar (α := Float) tape out).map
fun grads =>
(grads.toList.map fun (id, _) => id).mergeSort
(· ≤ ·)
Nodes 2 and 3 are absent: the detached product and the detach node itself. Nodes 0, 1, 4
and 5 are the surviving path from the leaf through the live product, the addition, and the sum.
The sparse map distinguishes an absent gradient from a gradient whose entries are zero.
A missing key means no
gradient was retained for that node, including when requiresGrad disables
it. A key holding zeros means the accumulated runtime cotangent is zero, which can also arise from
cancellation or rounding; an analytic derivative claim needs the usual correctness hypotheses.
Stopping the second branch leaves the forward scalar at 22.5: the same two arrays are still
added. Its derivative becomes [6, -3] because only the first square sends a cotangent back to the
input. The printed node list keeps the stopped branch visible, which is useful when checking why
a forward result survived a change to differentiation behavior.
Differentiation Through The Transform API
We now have a hand-built answer. Compare it with the answer the public transform API gives, which takes an entirely different route: it lowers the program to a typed scalar graph and differentiates that. Write the program once against the operation interface, so that the same text can be interpreted either way:
-- Express the same two branch choices through the public
-- differentiation interface.
/-- `sum (x*x + x*x)`, for the transform API. -/
def rtQuad : autograd.Function [2] [] :=
fun x => do
let a ← Torch.mul x x
let b ← Torch.mul x x
let z ← Torch.add a b
Torch.sum z
/-- The same program, second branch stopped. -/
def rtQuadStopped : autograd.Function [2] [] :=
fun x => do
let a ← Torch.mul x x
let b ← Torch.mul x x
let bStop ← Torch.detach b
let z ← Torch.add a bStop
Torch.sum z
#eval do
let x : Tensor Float [2] := [3.0, -1.5]
let gradient ← autograd.grad rtQuad x
let stopped ← autograd.grad rtQuadStopped x
IO.println s!"both branches live = {gradient}"
IO.println s!"second detached = {stopped}"
The tape, the typed graph, the closed-form calculation, and PyTorch agree on 4x and 2x at
this input. The comparison checks several implementations of the same branch structure. If only
one result changes, that narrows the investigation to its construction or execution path; shared
rules can still produce shared errors.
The manual tape spells out parent identifiers, while the transform records them through its handler. Comparing the results checks their agreement at the chosen values.
A useful independent reference here is the algebraic calculation above: two square branches give
4x, and one stopped branch leaves 2x. For a more complicated program, derive a small special
case or a directional check before comparing two execution routes that reuse the same rules.
Eager And Graph Interpreters
The reason the same source text can be executed eagerly or recorded into a graph is not a flag
inside the runtime. It is two instances of one class. Ops is the operation interface that
Torch.mul, Torch.add and the rest are defined against, and
Core/Trainer.lean provides two instances of
it. The difference between them is visible in a single field, Ref, which says what a handle to an
intermediate value actually is:
-- These equalities identify interpreter reference types
-- without evaluating a model.
example :
Torch.Ops.Ref (Torch.Internal.EagerM Float) Float [2] =
Torch.TensorRef Float [2] := rfl
example {Δ : Type} {Γ : List Shape} :
Torch.Ops.Ref
(TypedGraph.GraphM.MWith Float Δ Γ) Float [2] =
TypedGraph.GraphM.Var [2] := rfl
Both equalities hold by reduction, as the rfl proofs show. Under the eager instance a reference
is a TensorRef, an identifier into the
session's tape, and each operation executes immediately and appends a node. Under the graph
instance a reference is a GraphM.Var, an SSA variable, and each operation records a node without
computing anything.
This is the tagless-final pattern (Kiselyov (2012)): one object language, several interpreters, with the object language's types carried by the host language. The two fields of a GraphSpec primitive use the same design. A shared operation interface keeps the program text aligned, while the interpreters still need correspondence checks for the operations they implement.
The two rfl proofs identify what the abstract reference type becomes in each interpreter. They
do not evaluate a tensor or compare two model predictions. In an eager session a reference names
a recorded value; a graph variable identifies an entry in a typed context. That distinction lets
the same authored operation sequence build either representation.
The maintained eager handle also records a session owner and generation. A reset can reuse numeric
node 0, so its number alone cannot establish that an old handle belongs to the new recording.
The runtime checks those fields before accepting a session reference. This protects a different
property from shape safety: two tensors may both have shape [2] while only one belongs to the
current execution.
Typed Graph Execution
Typed graph execution records the model's scalar loss once as a typed SSA graph. Nodes carry the forward behavior and the executable rules used for JVPs and VJPs. Each training step supplies current parameters and inputs and reuses that graph. A separate typed reference selects the result, so the graph may return an input or an earlier node without adding a dummy final operation, and forward and reverse execution read and seed that exact reference.
The graph's indices enforce shape agreement between node references, but do not establish
derivative correctness. The executable node payload holds three functions, forward, jvp
and vjp, and no proof field. The proof-carrying node in
Tape/Algebra/Soundness.lean adds the local
adjointness law relating its jvp and vjp, and the proof-carrying graph composes those local laws
into a graph theorem. Two representations exist because execution must not require a proof and
verification must not accept a graph without one; the erasure runs one way only.
Proving Autograd Correct follows that ladder up.
The public model-level name specializes the context of this same graph type:
-- Model state precedes the input in the lowered graph
-- context.
example {α : Type} [Storage α]
{stateShapes : List Shape} {σ τ : Shape} :
nn.TypedGraphModel stateShapes σ τ α =
Torch.TypedGraph α (stateShapes ++ [σ]) τ := rfl
nn.TypedGraphModel is an abbreviation whose context is model state followed by one input tensor.
It introduces no additional runtime representation; the equality unfolds to rfl.
The typed graph trainer currently supports CPU execution. A non-CPU request is rejected, so a
successful run's execution label continues to identify the path being measured. Recording and
lowering also do not imply the optimization, fusion, scheduling, or native code generation
associated with a compiler such as torch.compile.
The wrapper equality says where model state enters the graph: the leaf context is the list of state shapes followed by the input shape. A caller can keep state and input separate at the API, while lowering supplies one ordered context to the graph. The order is part of the interface; two same-shaped parameter tensors are not interchangeable merely because either fits a slot.
Canonical IR Representation
Typed graph nodes hold Lean functions. Those functions can execute an operation, but they do not
expose its structure as inspectable data. NN.IR.Graph instead represents operations such as
linear, ReLU, reduction, normalization and shape
transforms as data. Each node records an operation tag, parent identifiers, and its output shape.
Input shapes are obtained from parent nodes; parameter data is looked up in the separate payload.
Because the operation is a tag, an importer can validate it, a verifier can interpret it, and a code generator can reject what it does not support without executing an arbitrary function. The scalar-independent representation also permits several interpretations: Graph IR builds one small graph and evaluates it over the reals, over intervals, and over IEEE binary32 scalars without touching the graph.
The bridge from the canonical IR produces IRExec.ForwardGraph, not Torch.TypedGraph, and it is
forward-only. The IR lowering theorem compares the lowered graph's value table with
NN.IR.Semantics. This bridge supplies no JVP or VJP rules; differentiating an imported operation
would require additional rules and corresponding correctness statements.
Both paths honor the homogeneous-element contract from Tensors And Shapes: a
typed graph is parameterized by one α, and an IR denotation receives one payload over α and
produces values over that same α.
Shape Erasure And Recovery
A parameter pack holds differently shaped tensors that share one element type:
-- Each state entry keeps its own shape; the pack also fixes -- the order of the two layers. [weight1 : Tensor α [8,2], bias1 : Tensor α [8], weight2 : Tensor α [1,8], bias2 : Tensor α [1]]
The pack type preserves that dependent list. A tape cannot, because Tape α has no shape index, and
neither can a registry that has to iterate over parameters by name. Both store Spec.SomeTensor α,
a structure holding a shape and a tensor indexed by that shape. The collection can therefore hold
different shapes while each entry retains its own shape-value relationship.
-- Recovery needs a shape equality; the second declaration
-- proves a pack/cast roundtrip.
#check @Spec.SomeTensor.cast
#check @Spec.SomeTensor.ofTensor_cast
First, cast takes a proof. Its last explicit argument is value.shape = shape, so there is no way
to recover a typed tensor from a packed one without producing evidence that the stored shape is the
expected shape. A runtime comparison can supply that evidence, but rfl cannot establish an
arbitrary packed tensor's shape:
-- An arbitrary packed shape cannot be identified with [2]
-- by reflexivity.
example (value : Spec.SomeTensor Float) :
Tensor Float [2] :=
value.cast rfl
Second, ofTensor_cast says the round trip is the identity: pack a tensor, check its shape, and you
are back where you started with nothing lost along the way. So the shape did not vanish. It moved
from a compile-time index of the whole collection into a value stored beside each entry, and a
theorem says the move is reversible.
The instantiated runner also holds parameter names, bounded token inputs, RNG state, optimizer memory, mutable model buffers, and the train or eval flag.
The cast signature takes an equality from the stored shape to the requested shape. Its direction matters: it transports the existing tensor; it does not reshape, truncate, pad, or reinterpret the buffer. The roundtrip theorem says that packing a tensor and recovering it with the matching shape returns that tensor. It does not manufacture an equality for arbitrary packed data.
The rejected rfl illustrates exactly that missing information. For an arbitrary SomeTensor,
Lean cannot reduce its hidden shape to [2]. A runtime equality test can provide evidence in its
successful branch, after which the cast is justified. Until that check succeeds, reporting an
error preserves the caller's shape contract. The printed metavariable names are elaborator details;
the useful part of the diagnostic is the demanded equality with [2].
Model Mode And Random State
Some operations depend on mode. Dropout samples a mask during training and applies its
deterministic inference behavior during evaluation ((Srivastava et al., 2014)); batch
normalization may update running statistics during training and use stored statistics at evaluation
((Ioffe and Szegedy, 2015)); other stochastic or stateful layers follow the same pattern.
trained.predict uses the retained runtime state in evaluation mode, and a Trainer.Session
follows the same rule in a caller-driven loop, with step in training mode and predict and loss
in evaluation mode. Mode is not a backend: one eager CUDA runner can switch modes without changing
device or provider profile. Execution Modes measures the size of
the resulting difference, which is the part that matters when you are trying to tell a mode bug from
rounding noise.
For the tape, randomness raises a replay requirement. A stochastic forward pass and its reverse pass must use the same random draw, or the reverse pass computes the derivative of a function nobody evaluated. The tape interface makes replay deterministic, but correct capture of the forward mask is still an obligation of the node implementation:
-- The result may report a malformed tape even though
-- backward has no IO effects.
#check @Tape.backwardScalar
No IO: in the pure Lean model, replay depends on the tape and its stored closures rather than an
external generator. A closure could still use an incorrect deterministic mask or an incorrect VJP;
purity alone does not prove it differentiates the recorded forward pass. Explicit capture and local
correctness laws supply that connection when treating reverse mode as a program on immutable data
(Pearlmutter and Siskind (2008)). The grow-only array behind
it is efficient for the same reason Lean's own runtime can afford immutable structures
((Ullrich and de Moura, 2019)).
This also affects checkpoints. Inference can require persistent buffers, mode, and preprocessing as well as parameters. Reproducing the next training update needs additional state. Replaying that update also needs the generator state, the optimizer state, and the loader position.
The pure backward type still returns Result: lack of IO does not mean that every supplied tape
is valid. The traversal can reject an invalid output id, an incompatible seed, or a parent
contribution with the wrong shape. Purity describes how the computation obtains its inputs;
validation describes which of those inputs it accepts.
The eager reverse engine also distinguishes an unreached node from a reached node with a zero
cotangent. It skips unreached local rules and fills missing entries with zeros when a dense result
is requested. This matters at singular values: evaluating a disconnected rule can produce an
expression such as 0 * (1 / 0), which need not be zero in floating-point arithmetic. The bridge
to a traversal that visits every node therefore needs a zero-preservation condition. That is a
mathematical obligation about the rules, not just a choice of array or hash-map storage.
Checkpoint Wrapper Semantics
nn.functional.checkpoint currently provides a boundary for a future memory optimization. In
Functional/Core.lean the definition is
\operatorname{checkpoint}(f, x) = f(x),
an identity wrapper. Real checkpointing discards intermediate values during the forward pass and recomputes them during the reverse sweep on a schedule that trades arithmetic for memory (Griewank and Walther (2000)). None of that happens here.
A backend that implements recomputation can refine this wrapper without changing its mathematical meaning. The boundary identifies where the execution strategy may change; the current implementation provides no memory saving.
Runtime And Proof
Relating execution to a derivative requires two kinds of correspondence.
The derivative ladder is internal to Lean:
calculus rule for each primitive -> semantic local VJP -> well-formed graph or tape composition -> global reverse result
The refinement ladder leaves Lean:
executable primitive -> declared semantic primitive
and it needs one rung per provider actually used by the run.
The first ladder can be climbed entirely inside Lean for a supported abstract graph, and Proving Autograd Correct climbs it: local adjointness laws compose into a graph theorem, the executed dense reverse sweep is identified with that theorem's backpropagation, and over the reals the result is identified with the adjoint of a Fréchet derivative from Mathlib ((Community, 2020)). The second ladder may be a theorem, a sound checked guard, a numerical certificate, or an explicit trusted boundary. Native CUDA, LibTorch, the compiler, the driver, and the hardware need their own refinement evidence or stated trust assumptions.
Relevant proof sources:
Kernel Selection Records
Before an eager operation executes, the session resolves its backend profile and binds the accepted capsule to a matching runtime handler; a missing provider or handler is an error. Run
# Inspect the capsules selected for a short eager training # run. lake exe torchlean quickstart_mlp \ --device cpu --steps 1 --seed 2026 --show-backend
and each operation reports one line the first time it is selected. Here is the entry for ReLU on this machine, rewrapped to fit the page:
relu: reference.relu provider=reference trust=checked
vjp=torchlean-tape reduction=n/a
Execution Modes reads the full report, tabulates the seven
capsules a CPU run selects, and explains the evidence lines and the empty banners. The field
vjp=torchlean-tape names which artifact owns the reverse rule for that operation. It connects
the tape we built by hand to the selected kernel: the
forward value came from the reference.relu provider, and the backward contribution came from
TorchLean's own tape node. When a provider owns its own backward instead, the corresponding
derivative claim also depends on that provider's reverse implementation.
Two deep dives follow a single operation the whole way down. Backend Selection gives the selection and assurance model, and GPU and CUDA follows an accepted CUDA operation through storage checks, launch, and backward registration.
Tape Inspection In VS Code
The pure API exposes a tape as a value that tests can compare. The editor widgets present its structure visually when the cursor is placed on
-- Inspect recorded nodes and the cotangents associated with -- them. #tape_view ... #tape_grads_view ... #tape_trace_view ...
The first shows operation nodes and parent edges. The second evaluates a scalar-output reverse pass
and annotates gradients. The third exposes traversal order. In the branched example above, these
views show both products contributing to the leaf. With detach, the second product remains in
the forward graph but contributes nothing to the leaf's cotangent. See
Interactive Widgets.
Eager And Typed Graph Training Comparison
The same comparison can be made through the trainer, using a fixed seed, dataset, and arithmetic:
# Keep seed and data fixed for the two-step execution # comparison. lake exe torchlean quickstart_mlp \ --device cpu --execution eager --steps 2 --seed 2026
== Quickstart: simple MLP training (seed=2026, steps=2) == target(heldout) = [0.200000] untrained(heldout) = [-0.088261] dataset size = 25 mean_loss(before training) = 0.495227 step 0: loss=0.250488 mean_loss(after training) = 0.392821 steps=2 arithmetic=native scalar=Float32 loss=0.495227 -> 0.392821 trained(heldout) = [0.019031]
Replacing --execution eager with --execution typed-graph prints the same nine lines, digit for
digit. For this model, changing the execution mode changes whether the loss is recorded once and
reused or rebuilt each step. The architecture, seed, data, and arithmetic remain fixed. The
matching transcript checks the displayed results of this run. The graph trainer still constructs
a tape for each evaluation of its stored graph, so this comparison does not establish a timing or
allocation improvement. Execution Modes explains that reuse and compares
mode and arithmetic choices through their displayed results.
The two-step log reports a dataset mean falling from 0.495227 to 0.392821. Its step 0 line
is a single update loss, and the held-out value 0.019031 is a prediction with target 0.2.
These quantities should not be ranked against one another as if they were three estimates of the
same loss. Matching their displayed values between execution modes checks several observable
results while leaving internal values, timings, and behavior on other inputs unmeasured.
Runtime Debugging
A gradient discrepancy can originate in the mathematical reference, the recorded program, the state supplied to it, or the provider that executes it. These checks separate those possibilities.
-
Derive a small reference case, such as
4x, including the reduction and output seed. -
Print the arithmetic semantics, execution mode, device, and selected capsules, so you know which artifacts are even in play.
-
Inspect the forward tape and check that the branch you care about is reachable. A missing key in the gradient map and a zero value in it mean different things.
-
Check the output cotangent's shape and values. A wrong seed produces a perfectly correct derivative of the wrong scalar.
-
Check
detach, train or eval mode, and stochastic state. They affect the derivative path, forward behavior, or random draw used by the same model source. -
Compare eager and typed graph CPU execution on the same explicit parameters. Disagreement helps locate a boundary; agreement on one case does not rule out a shared bug.
-
Compare a native provider against the IEEE reference path on a small finite case.
-
Compare the gap with an error bound or a more precise reference. A small difference can be a semantic bug, and a large one can come from numerical instability.
Tape Experiments
The hand-built tape also exposes cases that a successful scalar gradient does not distinguish:
-
A third
x*xbranch adds another2xcontribution. Itsmulnode names node0twice, just as the first two do. -
Setting the leaf's
requiresGrad := falseprevents retention of its cotangent, sortLeafGradreports a missing gradient. -
Moving
rtDetachNodefrombtoapreserves the derivative because the products are equal, but changes the node identifiers on the surviving path. -
A scalar output independent of the leaf leaves node
0absent from the gradient map. -
A
mulnode with incompatible operand shapes causesTapeM.runto return an error value inExcept String. -
Changing a constant only in
rtQuadmakes the typed graph represent a different function. Comparing its result with the unchanged tape then detects a construction mismatch.
Before defining the canonical IR we cross one more concrete boundary: a named parameter payload moving between PyTorch and Lean, in Exchanging Models With PyTorch. The graph chapters then give that exchanged computation an inspectable semantics, and relate runtime approximation and verification claims to it.