Verified Deep Learning with Lean 4

5 ResNet-34

Deeper networks used to stop training

Before 2015, the dominant assumption in image classification was “deeper is better, up to a point.” The “up to a point” part was not metaphorical. If you took a 20-layer CNN that trained to 70% val accuracy and added 12 more convolutional layers, you didn’t get a 32-layer model with 72% accuracy. You got a 32-layer model that, after the same training budget, fit the training set worse than the 20-layer one did. The 32-layer model had strictly more capacity. It still trained worse. Something about deep stacks of convolutions made gradient flow degrade in a way that the optimizer couldn’t navigate around.

He et al. 2015 (arXiv:1512.03385) had the right observation: if the 12 extra layers in the deeper network were initialized to compute the identity, the 32-layer network would have at least the 20-layer’s performance, because it could replicate it exactly. So the question was not “can a deep network represent what a shallow one represents?” It provably could. The question was: why is optimizing 12 free-form layers to recover identity so much harder than just leaving identity there?

5.1 Run it first

Before any of the math, train the thing. Four commands and about fifty minutes of GPU time:

lake exe cache get                            # Mathlib oleans, ~30 s
./scripts/datasets/download_imagenette.sh     # 330 MB dl, 2.4 GB unpacked
lake build resnet34-verified-adam
./.lake/build/bin/resnet34-verified-adam data

On one RTX 4060 Ti (CUDA 12.9), from runs/2026-09-12-r34-ablation-fp32-seeds/full_s2.log: seed 2 of the five that §5.5.2 averages, run by the ablation driver’s full arm, which is this binary’s render and recipe under LEAN_MLIR_SEED=2. XLA’s startup banner and the resident-parameter notices are removed, the network description line is wrapped, each interval is wrapped onto an indented continuation, and epochs 5 through 75 are elided:

[pjrt_ffi] XLA backend: PJRT 0.114, 1 device(s)
[pjrt_ffi] command buffers: enabled (CUDA default)
[pjrt_ffi] compiled verified_mlir/resnet34_adam_train_step.mlir
             (@resnet34_adam_train_step, 405 outputs, 1 replica) in 6127 ms
[pjrt_ffi] compiled verified_mlir/resnet34_fwd.mlir
             (@resnet34_fwd, 1 outputs, 1 replica) in 596 ms
[pjrt_ffi] compiled verified_mlir/resnet34_fwd_eval.mlir
             (@resnet34_fwd_eval, 1 outputs, 1 replica) in 293 ms
Real ResNet-34 on Imagenette 224² (7×7-s2 stem→3×3-s2 overlapping max pool→
  [3,4,6,3] blocks w/ batch-norm, He et al. option-B 1×1 projection shortcuts,
  no conv biases; 56→28→14→7→GAP→dense)
  via the VERIFIED renderer → XLA/PJRT → GPU
  xla/pjrt verified_mlir/resnet34_adam_train_step.mlir
  xla/pjrt verified_mlir/resnet34_fwd.mlir
  xla/pjrt verified_mlir/resnet34_fwd_eval.mlir
  train 9469, val 3925; bs 32, ResNet-34 adam
    (cosine+warmup 3ep, baseLR 0.001000), He init
  running-stats BN: 36 layers, 17024 stat floats → eval via @resnet34_fwd_eval
Epoch 1/80: loss=1.971035 lr=0.000333
  epoch 1: val_acc = 1239/3925 = 31.566879%  top5 = 2766/3925 = 70.471338%
    [95% CI 30.13–33.04]
Epoch 2/80: loss=1.553382 lr=0.000667
  epoch 2: val_acc = 1471/3925 = 37.477707%  top5 = 3123/3925 = 79.566879%
    [95% CI 35.98–39.00]
Epoch 3/80: loss=1.381944 lr=0.001000
  epoch 3: val_acc = 1880/3925 = 47.898089%  top5 = 3159/3925 = 80.484076%
    [95% CI 46.34–49.46]
Epoch 4/80: loss=1.311593 lr=0.001000
  epoch 4: val_acc = 1851/3925 = 47.159236%  top5 = 3547/3925 = 90.369427%
    [95% CI 45.60–48.72]
Epoch 76/80: loss=0.502131 lr=0.000007
  epoch 76: val_acc = 3536/3925 = 90.089172%  top5 = 3859/3925 = 98.318471%
    [95% CI 89.11–90.99]
Epoch 79/80: loss=0.502245 lr=0.000000
  epoch 79: val_acc = 3535/3925 = 90.063694%  top5 = 3859/3925 = 98.318471%
    [95% CI 89.09–90.96]
Epoch 80/80: loss=0.502085 lr=0.000000
  epoch 80: val_acc = 3535/3925 = 90.063694%  top5 = 3859/3925 = 98.318471%
    [95% CI 89.09–90.96]
done (trained ResNet-34 adam + cosine/warmup via packed threading).

Eighty epochs at about 35 seconds each, 47 minutes in all, and 90.06% top-1 with 98.32% top-5 on Imagenette’s 3,925-image validation split. This is one seed of five; the five land at \(89.99 \pm 0.32\) top-1 (§5.5.2), and that mean is the number every later chapter compares against.

The residual block: ask for the difference, not the function

Their fix was a one-line architectural change. Instead of asking each block to learn a function \(y = f(x)\), ask it to learn a function \(y = f(x) + x\). The block’s job is no longer to become the new representation. Its job is to learn what to add to the current representation. At initialization, with randomly-small \(f\), each block is approximately the identity, and a stack of 16 such blocks is approximately a stack of 16 identities, which is to say, approximately the input. The network starts out near-identity at every depth and only gradually takes on structure as \(f\) trains. The optimization problem becomes “what residual should each block add” instead of “what function should each block be,” and the former turns out to be dramatically easier.

The change to the architecture is one + sign. The change to the training dynamics is the difference between “18 layers is about as deep as we can go” and “152 layers trains stably, so let us also add another zero on the ImageNet leaderboard.”

Why the proof is a one-liner

From the framework’s perspective, what is a residual block? \(\mathrm{residual}(x) = f(x) + \mathrm{id}(x)\), a sum of two functions sharing the same input. The VJP of an additive fan-in is “send the upstream gradient through both branches and add” (Theorem 12); the VJP of \(\mathrm{id}\) is the identity on the gradient (Theorem 14), and the VJP of \(f\) is whatever the inner block’s machinery gives us. Composition is just \(\mathrm{vjpComp}\). So the residual VJP is the additive fan-in lemma, with \(\mathrm{id}\) plugged into one slot.

That is Theorem 64 below, and its proof is one line. The residual is the one theorem the idea needs, and that theorem is mechanical. The engineering revolution was not in the math. It was in the realization that this operation was the missing piece for training deep networks. The fact that the framework absorbs the new architecture without any new math is the value proposition: once the foundation rules are in place, the next decade of image recognition follows from composing them.

ResNet-34 is sixteen of these blocks

ResNet-34 (the smallest “deep” variant in the original paper) is built from 16 residual blocks arranged in four stages of depths 3, 4, 6, 3, at channel widths 64, 128, 256, 512. Each stage starts with a stride-2 downsample (halving spatial resolution) before its stack of stride-1 blocks. The stages are glued together by a stem (a 7\(\times \)7 conv at stride 2 plus a max-pool, taking 224\(\times \)224\(\times \)3 input to 56\(\times \)56\(\times \)64 features) and a head (global average pool plus a 512-to-10 dense layer). Total: 34 weight-bearing layers, 21.3M parameters.

Two pieces of this template are new and worth naming:

  1. The 7\(\times \)7 stride-2 stem. Earlier chapters’ networks operated at full input resolution. ResNet aggressively downsamples at the input so the bulk of compute happens at lower spatial resolution. The stem is one big conv that gets us from 224\(\times \)224 to 56\(\times \)56 in a single step.

  2. Global average pool instead of flatten-plus-dense. Chapters 3 and 4 ended in a .flatten followed by a dense layer (\(6272 \to 512\) on MNIST, \(128 \to 512\) on CIFAR) that held most of the network’s parameters. ResNet collapses the final 7\(\times \)7\(\times \)512 feature map to a 512-vector by spatial averaging, then runs a single 512-to-10 dense. No flatten layer. The architectural move alone drops the model from what would have been a \(\sim \)200M-parameter AlexNet-era spec down to 21.3M.

Every chapter after this one is a variation on this template. MobileNetV2 swaps in depthwise-separable blocks. EfficientNet adds squeeze-and-excitation to them. ConvNeXt swaps in its own block. ViT replaces the whole convolutional spine with patches and attention. The stem-stages-head template, and the residual fan-in pattern, persist.

5.2 The theorems

Figure 5-1 is the dependency graph of this section.

\begin{tikzpicture} [x=1pt,y=1pt, every node/.style={inner sep=1.50pt, font=\fontsize{7.00}{8.40}\selectfont\ttfamily}]
  \node[draw=green!50!black, fill=green!15, rectangle, rounded corners=4.0pt, align=center, minimum width=64.8pt, minimum height=12.7pt] at (156.5pt,8.9pt) {\hyperref[thm:residualHasVJP]{residualHasVJP}};
  \node[draw=green!50!black, fill=green!15, rectangle, rounded corners=4.0pt, align=center, minimum width=81.6pt, minimum height=12.7pt] at (40.8pt,37.9pt) {\hyperref[thm:resnet34FullHasVJP]{resnet34FullHasVJP}};
  \node[draw=green!50!black, fill=green!15, rectangle, rounded corners=4.0pt, align=center, minimum width=81.6pt, minimum height=12.7pt] at (40.8pt,64.9pt) {\hyperref[thm:resnet50FullHasVJP]{resnet50FullHasVJP}};
  \node[draw=green!50!black, fill=green!15, rectangle, rounded corners=4.0pt, align=center, minimum width=85.8pt, minimum height=12.7pt] at (156.5pt,145.9pt) {\hyperref[thm:resnet50_whole_back]{resnet50\_whole\_back}};
  \node[draw=green!50!black, fill=green!15, rectangle, rounded corners=4.0pt, align=center, minimum width=77.4pt, minimum height=12.7pt] at (40.8pt,172.9pt) {\hyperref[thm:resnet34_step_tie]{resnet34\_step\_tie}};
  \node[draw=green!50!black, fill=green!15, rectangle, rounded corners=4.0pt, align=center, minimum width=81.6pt, minimum height=12.7pt] at (156.5pt,172.9pt) {\hyperref[thm:resnet34_loss_grad]{resnet34\_loss\_grad}};
  \node[draw=green!50!black, fill=green!15, rectangle, rounded corners=4.0pt, align=center, minimum width=77.4pt, minimum height=12.7pt] at (40.8pt,199.9pt) {\hyperref[thm:resnet50_step_tie]{resnet50\_step\_tie}};
  \node[draw=green!50!black, fill=green!15, rectangle, rounded corners=4.0pt, align=center, minimum width=81.6pt, minimum height=12.7pt] at (156.5pt,199.9pt) {\hyperref[thm:resnet50_loss_grad]{resnet50\_loss\_grad}};
  \node[draw=green!50!black, fill=green!15, rectangle, rounded corners=4.0pt, align=center, minimum width=60.6pt, minimum height=12.7pt] at (156.5pt,37.9pt) {\hyperref[thm:resnet34_seal]{resnet34\_seal}};
  \node[draw=green!50!black, fill=green!15, rectangle, rounded corners=4.0pt, align=center, minimum width=60.6pt, minimum height=12.7pt] at (156.5pt,64.9pt) {\hyperref[thm:resnet50_seal]{resnet50\_seal}};
  \node[draw=green!50!black, fill=green!15, rectangle, rounded corners=4.0pt, align=center, minimum width=77.4pt, minimum height=12.7pt] at (156.5pt,118.9pt) {\hyperref[thm:resnet34_sync_tie]{resnet34\_sync\_tie}};
  \node[draw=green!50!black, fill=green!15, rectangle, rounded corners=4.0pt, align=center, minimum width=77.4pt, minimum height=12.7pt] at (156.5pt,91.9pt) {\hyperref[thm:resnet50_sync_tie]{resnet50\_sync\_tie}};
  \node[draw=green!50!black, fill=green!15, rectangle, rounded corners=4.0pt, align=center, minimum width=81.6pt, minimum height=12.7pt] at (40.8pt,226.9pt) {\hyperref[thm:lamb_clip_faithful]{lamb\_clip\_faithful}};
  \node[draw=green!50!black, fill=green!15, rectangle, rounded corners=4.0pt, align=center, minimum width=69.0pt, minimum height=12.7pt] at (40.8pt,253.9pt) {\hyperref[thm:accum_mean_step]{accum\_mean\_step}};
  \node[draw=gray!70, dashed, rounded corners=2pt, minimum width=45.6pt, minimum height=17.8pt, text=gray!60!black, align=center, font=\fontsize{5.50}{6.60}\selectfont\ttfamily] at (40.8pt,8.9pt) {\hyperref[thm:biPathHasVJP]{biPathHasVJP}\\(ch 1)};
  \node[draw=gray!70, dashed, rounded corners=2pt, minimum width=58.8pt, minimum height=17.8pt, text=gray!60!black, align=center, font=\fontsize{5.50}{6.60}\selectfont\ttfamily] at (40.8pt,118.9pt) {17 statements of\\ch 1, 2, 3, 4, 9};
  \draw[->, >=stealth, black!70, line width=0.40pt] (81.9pt,37.9pt) .. controls (92.4pt,37.9pt) and (103.7pt,37.9pt) .. (114.3pt,37.9pt) -- (125.8pt,37.9pt);
  \draw[->, >=stealth, black!70, line width=0.40pt] (81.9pt,64.9pt) .. controls (92.4pt,64.9pt) and (103.7pt,64.9pt) .. (114.3pt,64.9pt) -- (125.8pt,64.9pt);
  \draw[->, >=stealth, black!70, line width=0.40pt] (80.0pt,172.9pt) .. controls (87.7pt,172.9pt) and (95.9pt,172.9pt) .. (104.0pt,172.9pt) -- (115.3pt,172.9pt);
  \draw[->, >=stealth, black!70, line width=0.40pt] (80.0pt,199.9pt) .. controls (87.7pt,199.9pt) and (95.9pt,199.9pt) .. (104.0pt,199.9pt) -- (115.3pt,199.9pt);
  \draw[->, >=stealth, dashed, gray!60, line width=0.40pt] (64.0pt,8.9pt) .. controls (77.9pt,8.9pt) and (96.2pt,8.9pt) .. (112.8pt,8.9pt) -- (123.9pt,8.9pt);
  \draw[->, >=stealth, dashed, gray!60, line width=0.40pt] (70.5pt,125.7pt) .. controls (84.2pt,129.0pt) and (100.9pt,132.9pt) .. (115.8pt,136.5pt) -- (126.8pt,139.1pt);
  \draw[->, >=stealth, dashed, gray!60, line width=0.40pt] (70.5pt,118.9pt) .. controls (81.3pt,118.9pt) and (93.8pt,118.9pt) .. (105.9pt,118.9pt) -- (117.4pt,118.9pt);
  \draw[->, >=stealth, dashed, gray!60, line width=0.40pt] (70.5pt,112.1pt) .. controls (84.2pt,108.8pt) and (100.9pt,104.8pt) .. (115.8pt,101.3pt) -- (126.8pt,98.7pt);
\end{tikzpicture}
Figure 5-1 Dependency graph of the residual chapter’s theorems.
Theorem 64 Residual block VJP
✓

assume:

  1. \(B_f\) is a correct backward function for \(f\) (\(\mathsf{HasVJP}\, f\)) [hf]

  2. \(f\) is differentiable everywhere [hf_diff]

prove: \(\mathsf{HasVJP}\, (\mathrm{residual}\, f)\), where \(\mathrm{residual}\, f\, x = f(x) + x\), with backward \(B(x, dy) = B_f(x, dy) + dy\).

Proof ▶
  1. \(\mathrm{residual}\, f = \mathrm{biPath}\, f\, \mathrm{id}\).
    proof: Definitional.

  2. q.e.d.
    proof: Instantiate the additive fan-in VJP (Theorem 12) at \(g = \mathrm{id}\) (differentiable; its VJP is Theorem 14), with assumptions 1 and 2 supplying the \(f\) side. The identity’s backward contributes \(dy\) itself, so the composed backward is \(B_f(x, dy) + dy\) — the gradient floor that makes ResNets trainable.

The ladder.

From here every ImageNet chapter proves the same six statements about its network, and this is the first time all six appear. (i) The whole network at batch \(N\) has a VJP: the backward composed from the stages’ backwards is the \(\operatorname {pdiv}\)-contracted Jacobian of the forward. (ii) The hand-written backward chain the renderer emits is that backward. (iii) The emitted training step is the certified step: each of its gradient nodes denotes the certified batch gradient at the chain cotangent of the loss. (iv) Each node is the loss’s derivative in its parameter. (v) The backward is not the zero map, at one fixed weight setting — a seal. (vi) The data-parallel render’s mean over replicas is one device’s step on the global batch. Statements (i) and (iv) take the smooth-point hypotheses — \(\varepsilon {\gt} 0\) at every BatchNorm, the batch’s activations off every ReLU corner, and at the stem pool no tie for (i), none but twins for (iv); (ii) takes the stem’s and head’s clauses and a VJP witness per block; (iii) and (vi) are identities of the emitted text and take none; (v) is at fixed weights.

assume:

  1. every BatchNorm’s \(\varepsilon {\gt} 0\) [R34PosB]

  2. at the batch’s activations, the stem’s ReLU and both ReLUs of each of the sixteen blocks — the body’s and the one after the residual add — are off their corners, and every stem-pool window is entirely \(\le 0\) or has one maximal cell; the head takes no hypothesis [R34SmoothAtB]

prove: the backward composed from the eighteen stages’ backwards in reverse order, for the full-width ResNet-34 at training BatchNorm and batch \(N\) (resnet34ForwardBFull, \(3 \times 224 \times 224\)), equals the \(\operatorname {pdiv}\)-contracted Jacobian of the forward at every input, cotangent and index, generic in \(N\) and in the weights. The strict pool clause is one real batches do not meet; the parameter gradients of Theorem 69 are stated under one they do.

Proof ▶

A fold of the VJP chain rule (Theorem 11) over the eighteen stages — the stem, sixteen residual blocks (Theorem 64), the head — each at its running activation; see Proofs.resnet34ForwardBFullHasVJPAt_correct.

assume:

  1. the ResNet-34 conditions, at ResNet-50’s sixteen bottleneck blocks [R50PosB, R50SmoothAtB]

prove: the same for the full-width ResNet-50 (resnet50ForwardBFull) at every resolution \(q\) — the image side is \(32q\), so \(q = 7\) is the 224-pixel net and \(q = 5\) the 160-pixel one.

Proof ▶

As for ResNet-34, with the bottleneck in place of the basic block; see Proofs.resnet50ForwardBFullHasVJPAt_correct.

assume:

  1. \(\varepsilon _s {\gt} 0\), the stem ReLU off its corner and the stem pool’s per-example condition [h\(\varepsilon \)s, h_stem, h_pool]

  2. sixteen block maps \(b_1, \dots , b_{16}\), each with a VJP and differentiable at its running activation [hb1 … hb16]

prove: the hand-written eighteen-stage reverse pass every ResNet-50 artifact renders, with its blocks’ backwards filled by those witnesses, is the backward of \(\mathrm{head} \circ b_{16} \circ \dots \circ b_1 \circ \mathrm{stem}\), and so equals

\[ \sum _j \operatorname {pdiv}(\mathrm{head} \circ b_{16} \circ \dots \circ b_1 \circ \mathrm{stem})\, x\, i\, j \cdot dy_j \]

for every batch, cotangent and pixel. The blocks are opaque in the statement; that resnet50ForwardBFull is this composition at the sixteen bottlenecks is a separate shape check.

Proof ▶

The chain is the composed backward read stage by stage: the head’s and the stem’s endpoint ties, and each block’s backward in its slot; see Proofs.r50InputGradB_eq_r34B_full_vjp.

prove: each of the 110 emitted parameter gradients of the batched ResNet-34 step (resnet34_sgd_train_step.mlir and _adam_ at \(N = 32\), resnet34in_momdp64 at \(N = 64\)) denotes the certified batch gradient \(\sum _n \mathrm{Jacobian} \cdot \mathrm{cotangent}\) at the chain cotangent of the label-smoothed cross-entropy (any \(\alpha _{\mathrm{ls}}\) — \(0.1\) in the renders, \(0\) in the ls0 twins — and any target), generic in \(N\). Stated at either precision: the bf16 render resnet34in_momdp64bf16 emits a *GradBBf16 node at every convolution’s weight gradient, and at the identity rounding the renderers instantiate, each such node denotes its f32 peer.

Proof ▶

A fold over the 110 nodes: each is its layer’s weight or bias gradient at the cotangent the chain hands it, and the chain’s cotangents are the certified block backwards; see Proofs.ResNet34TieB.r34_net_tiedB.

assume:

  1. every BatchNorm’s \(\varepsilon {\gt} 0\) [R34PosB]

  2. at the batch’s activations, every ReLU is off its corner and every stem-pool window is entirely zero or has one maximal cell, up to twins — cells that read identical input patches [R34LossSmoothAtB]

  3. \(L\) is any loss of the logits, with gradient \(g\) there [hL]

prove: for each gradient node of Theorem 68, the node at the chain cotangent from \(g\) is \(\partial L/\partial \theta \) of resnet34ForwardBFull with that one parameter varied, at any batch size and number of classes. Instantiated at the batched label-smoothed cross-entropy, and at the stem with each tied window’s cotangent routed to any of its maximal cells, as the emitted select_and_scatter routes it.

Proof ▶

Each node is the step tie’s; its chain cotangent is \(g\) pulled back through the certified block backwards, one application of Theorem 16 per stage; see Proofs.ResNet34TieB.r34_net_lossGrad.

Twins at the stem pool. The stem’s \(3 \times 3\)/s2 pool is certified together with the ReLU before it (stemReluPoolLayer), so an all-zero window is admitted: every pre-activation there is negative and the pair is locally constant. What the per-example condition (StemPoolSmoothAt) still excludes is a window whose positive maximum sits at two cells. Real batches have such windows, from two cells reading identical input patches in a flat image region. There the network has no derivative in the image, so an input-gradient statement cannot hold; but the tied cells are the same function of the stem’s weights, so the loss gradients in the parameters allow them (StemPoolTwinAt), and routing a tied window’s cotangent to either twin gives the same parameter gradient (r34Stem_select_grads_eq). That is the “up to twins” in the hypothesis above, and why the theorem is instantiated at any routing to a maximal cell.

prove: each of the 161 parameter gradients of the batched ResNet-50 step denotes the certified batch gradient at the chain cotangent of a loss cotangent \(g\) left as a binder, generic in \(N\) and \(q\). Instantiated at the label-smoothed cross-entropy and at BCE-with-logits over \(N \cdot K\), the two losses the ResNet-50 artifacts train. Stated at either precision, as for ResNet-34.

Proof ▶

As for ResNet-34, over the three block kinds; see Proofs.ResNet50TieB.r50_net_tiedB.

assume:

  1. every BatchNorm’s \(\varepsilon {\gt} 0\) [R50PosB]

  2. the ResNet-34 smooth-point condition at the batch’s activations, up to twins at the stem pool [R50LossSmoothAtB]

  3. \(L\) is any loss of the logits, with gradient \(g\) there [hL]

prove: for each node of Theorem 70, the node at the chain cotangent from \(g\) is \(\partial L/\partial \theta \) of resnet50ForwardBFull with that one parameter varied, at any batch and number of classes. Instantiated at both losses of Theorem 70, and at the stem at any routing to a maximal cell, as for ResNet-34.

Proof ▶

As for ResNet-34; see Proofs.ResNet50TieB.r50_net_lossGrad.

prove: at the structural weights sealW and input sealX, batch 2, for any positive number of classes, there is an (example, class) coordinate \(j_0\) and a pixel \(i_0\) at which the backward of the full-width ResNet-34 is non-zero: the identity of Theorem 65 has content, since the zero map does not satisfy it.

Proof ▶

Evaluate the composed backward at the witness and read off one non-zero coordinate; see Proofs.R34FullBSeal.sealX_backward_nontrivial.

prove: the same construction, with its own witness, for ResNet-50 at both 224 and 160 pixels (\(0 {\lt} q \le 7\)).

Proof ▶

assume:

  1. \(R \ge 1\) replicas of \(N \ge 1\) images each, every BatchNorm synchronised [hR, hN]

  2. each replica’s loss cotangent is its shard of the global batch’s, scaled by \(R\) [hgs]

prove: the mean over replicas of each of the 110 all-reduced gradient nodes of the data-parallel render (resnet34in_momdp64 and its bf16 twin at \(\texttt{replicas} {\gt} 1\), loss \(\div B\) per replica) equals the single-device node of Theorem 68 at batch \(R \cdot N\) with loss \(\div (R \cdot B)\): the data-parallel step is one device’s step on the global batch. Instantiated at the label-smoothed cross-entropy.

Proof ▶

With synchronised statistics a replica’s forward is the global forward on its shard, so its node is the global node’s sum over that shard, scaled by \(R\); the mean of the \(R\) shard sums is the global sum; see Proofs.ResNet34SyncTieB.r34_net_syncTiedB.

prove: the same for ResNet-50’s 161 gradients at side \(32q\), \(q {\gt} 0\), instantiated at the label-smoothed cross-entropy for targets summing to one and at BCE with the committed divisors \(N \cdot K\) per replica and \((R \cdot N) \cdot K\) on one device.

Proof ▶

prove: in the RSB-A3 render of §5-8:

  1. the clip: the emitted scale op denotes \(g \cdot \min \bigl(1,\; c / (\sqrt{s} + \varepsilon )\bigr)\), where \(s\) is the sum of squares folded across every parameter, one scalar shared by every leaf. The factor never exceeds one, and at threshold \(kc\), \(k\varepsilon \) and \(k^2 s\) it equals the factor at \(c\), \(\varepsilon \), \(s\), which is what lets the clip run after \(k\)-fold accumulation;

  2. LAMB: the four emitted ops denote \((\theta - \mathrm{lr}\, \tau \, r,\; m',\; v')\), with \(m', v'\) Adam’s moments, \(r = \frac{m'/b_1}{\sqrt{v'/b_2} + \varepsilon } + \mathrm{wd}\, \theta \) the decayed direction, and \(\tau = \| \theta \| / \| r\| \) the trust ratio, one when either norm is zero, computed from this parameter’s own norm and no other’s. For the no_weight_decay group the render passes a zero norm, and the step is the plain Adam step \(\theta - \mathrm{lr}\, r\).

Proof ▶

The two triples are rfl against the definitions; the committed and excluded shapes rewrite the scalar child; see Proofs.StableHLO.lamb_triple_faithful.

prove: for the accumulating renders (acc\(k\)x and lambacc\(k\)x; A3 is \(k = 4\)), one graph serves both phases and two runtime scalars decide which:

  1. the accumulator \(G_t = \mathrm{keep}\cdot G + g\) is the momentum op at \(\mu = \mathrm{keep}\). At \(\mathrm{keep} = 0\) the cycle resets by arithmetic, and after \(k\) micro-batches \(G_t\) is their sum;

  2. on an accumulate micro-batch, \(\beta = 1\) on both moments and \(\mathrm{lr} = 0\): the moments and the parameter pass through unchanged, for AdamW and for LAMB alike;

  3. on the apply micro-batch, the scalar pair \((1 - \beta _1)/k\) and \((1 - \beta _2)/k^2\) applied to the sum \(G_t\) gives exactly the plain step of Theorem 63 or 76 at the mean \(G_t/k\), the \(k^2\) because the second moment squares its gradient.

The typed graph reads the two moment scalars as \(\beta \) and \(1 - \beta \); the pair with \(k\) folded in is the one hand-written block of the step, the one its banner names. Item 3 is the identity that lets the faithful ops be read at the mean.

Proof ▶

Ring identities over the reals, item 1 by induction over the cycle; see Proofs.adamWStepAcc_eq_mean.

5.3 Example: ResNet-34 on Imagenette

ResNet-34 on 224\(\times \)224 Imagenette: 10 classes, real photographs, the transition point from “classroom dataset” to “actual image recognition.”

The architecture

The same vertical column as the previous chapters, now at production scale: a \(7 \times 7\) stride-2 stem and a max-pool drop the \(224 \times 224 \times 3\) input to \(56 \times 56 \times 64\), then four stages of residual blocks (depths \(3, 4, 6, 3\) at widths \(64, 128, 256, 512\)) do the work, and a global average pool replaces the flatten-plus-fat-dense head. Figure 5-2 is the network, and its inset shows the one new piece: each residual block adds its input back, \(y = f(x) + x\).

\begin{tikzpicture} [
  >={Stealth[length=1.8mm]},
  every node/.style={font=\sffamily\scriptsize},
  col/.style    = {align=center, rounded corners=2pt, inner sep=2pt, minimum height=0.58cm, minimum width=5.4cm},
  io/.style     = {col, draw=blue!55!black,   fill=blue!8},
  convbn/.style = {col, draw=orange!65!black, fill=orange!12},
  pool/.style   = {col, draw=teal!60!black,   fill=teal!10},
  resid/.style  = {col, draw=violet!70!black, fill=violet!9, minimum height=0.66cm},
  gap/.style    = {col, draw=purple!60!black, fill=purple!8},
  head/.style   = {col, draw=red!60!black,    fill=red!8},
  logits/.style = {col, draw=green!50!black,  fill=green!14, very thick},
  arr/.style    = {->, thick, gray!60, shorten >=1pt, shorten <=1pt},
  stage/.style  = {font=\sffamily\scriptsize\itshape, gray!55!black, anchor=west},
]
  \node[io]                            (input) {Input \;\; $224\times224\times3$};
  \node[convbn, below=0.18cm of input] (stem) {\textbf{ConvBN} stem $3\to64$, $7\times7$, /2};
  \node[pool,   below=0.18cm of stem]  (mp)   {\textbf{maxPool} $2\times2$ \;\; $112\to56$};
  \node[resid,  below=0.18cm of mp]    (s1)   {$3\times$ \textbf{Residual} $64\to64$ \;\; $56\times56$};
  \node[resid,  below=0.18cm of s1]    (s2)   {$4\times$ \textbf{Residual} $64\to128$, /2 \;\; $28\times28$};
  \node[resid,  below=0.18cm of s2]    (s3)   {$6\times$ \textbf{Residual} $128\to256$, /2 \;\; $14\times14$};
  \node[resid,  below=0.18cm of s3]    (s4)   {$3\times$ \textbf{Residual} $256\to512$, /2 \;\; $7\times7$};
  \node[gap,    below=0.18cm of s4]    (g)    {global avg pool \;\; $7\times7\times512 \to 512$};
  \node[head,   below=0.18cm of g]     (d)    {\textbf{Dense} $512\to10$ \;(identity)};
  \node[logits, below=0.18cm of d]     (out)  {Logits \;\; 10 classes, softmax-CE};
  \foreach \a/\b in {input/stem, stem/mp, mp/s1, s1/s2, s2/s3, s3/s4, s4/g, g/d, d/out}
     \draw[arr] (\a) -- (\b);
  \node[stage] at ($(s1.east) + (0.30,0)$) {stage 1};
  \node[stage] at ($(s2.east) + (0.30,0)$) {stage 2};
  \node[stage] at ($(s3.east) + (0.30,0)$) {stage 3};
  \node[stage] at ($(s4.east) + (0.30,0)$) {stage 4};
\end{tikzpicture}
\begin{tikzpicture} [
  >={Stealth[length=1.6mm]},
  every node/.style={font=\sffamily\scriptsize},
  box/.style  = {align=center, rounded corners=2pt, inner sep=3pt, minimum height=0.62cm, draw=violet!70!black, fill=violet!9},
  dot/.style  = {circle, draw=violet!70!black, fill=violet!12, inner sep=0pt, minimum size=0.42cm},
  term/.style = {font=\sffamily\scriptsize\itshape, inner sep=1pt},
  arr/.style  = {->, thick, gray!65, shorten >=1pt, shorten <=1pt},
  skip/.style = {->, thick, violet!70!black, shorten >=1pt},
]
  \node[term]                       (x)   {$x$};
  \node[box, right=0.55cm of x]     (f1)  {ConvBN, ReLU};
  \node[box, right=0.45cm of f1]    (f2)  {ConvBN};
  \node[dot, right=0.55cm of f2]    (sum) {$+$};
  \node[term, right=0.55cm of sum]  (y)   {ReLU $\to y$};
  \draw[arr] (x)  -- (f1);
  \draw[arr] (f1) -- (f2);
  \draw[arr] (f2) -- (sum);
  \draw[arr] (sum) -- (y);
  \draw[skip] (x.north) .. controls +(0,0.75) and +(0,0.75) .. (sum.north)
        node[midway, above, term, violet!70!black] {identity skip ($+\,x$)};
  \node[term, below=0.18cm of f2, gray!55!black] {one residual block: \; $y = f(x) + x$};
\end{tikzpicture}
Figure 5-2 ResNet-34: a stem, four stages of residual blocks, global average pooling and a dense head. The inset is one residual block.

The verified trainer, which is the pattern every later chapter reuses

This is the reference configuration; Chapters 6 through 9 each swap the layers list and change almost nothing else.

import LeanMlir.Verified.NetsCore
import LeanMlir.Verified.Train

-- 1. The network. A VerifiedNetSpec, not a NetSpec: `slug` names the
--    committed render, and `bnChannels` drives BN statistic threading.
def resnet34Verified : VerifiedNetSpec where
  name     := "ResNet-34"
  slug     := "resnet34"
  inC      := 3
  imageH   := 224
  imageW   := 224
  data     := .imagenette
  layers   := [
    .convBnNB 3 64 7 2,          -- 7x7-s2 stem, no conv bias   224->112
    .maxPool 3 2,                -- He et al. 3x3-s2 OVERLAPPING 112->56
    .residualStage  64  64 3 1,  -- stage 1, 3 blocks            @56
    .residualStage  64 128 4 2,  -- stage 2, downsample + 3      56->28
    .residualStage 128 256 6 2,  -- stage 3, downsample + 5      28->14
    .residualStage 256 512 3 2,  -- stage 4, downsample + 2      14->7
    .globalAvgPool,              -- replaces flatten + fat dense
    .dense 512 10 ]
  blurb := "Real ResNet-34 on Imagenette 224² (7×7-s2 stem->3×3-s2 overlapping
            max pool->[3,4,6,3] blocks w/ batch-norm, He et al. option-B 1×1
            projection shortcuts, no conv biases; 56->28->14->7->GAP->dense)
            via the VERIFIED renderer -> %LOWERER% -> GPU"
  bnChannels := #[64, 64,64, ... ]   -- all 36, in forward order

-- 2. The schedule. Everything the optimizer needs beyond the render.
def resnet34AdamConfig : VerifiedConfig where
  epochs    := 80
  batchSize := 32

-- 3. The entry point: AdamW, cosine with a 3-epoch warmup, baseLR 1e-3.
--    Variant, batch and rate are overridable; the defaults are Run it first's.
def runResnet34Adam (argv : List String) : IO Unit := do
  let variant := (← IO.getEnv "LEAN_MLIR_VARIANT").getD "adam"
  let bs      := ((← IO.getEnv "LEAN_MLIR_BATCH").bind (·.toNat?)).getD
                   resnet34AdamConfig.batchSize
  let baseLR := match (← IO.getEnv "LEAN_MLIR_BASE_LR_U").bind (·.toNat?) with
    | some u => u.toFloat * 1e-6     -- micro-units: 100000 is 0.1
    | none   => 0.001
  resnet34Verified.toNet.trainAdamSched
    { resnet34AdamConfig with batchSize := bs }
    (argv.head?.getD "data") baseLR 0.9 0.999 3 variant

The one thing in that listing to carry forward is that the recipe is arguments, not architecture. The learning rate, the momentum pair, the warmup length and the variant string are all passed to the driver; none of them is a property of the network. Swapping AdamW for momentum is a different argument against the same proven gradient, which is what makes §5.5.2’s ablation a measurement of the recipe rather than of the model — and what Chapter 4 used to put three optimizers on one net.

The schedule. Chapter 4 trained at a constant rate. Annealing the rate to zero on a cosine is worth about five points here (\(-5.00 \pm 2.60\) in §5.5.2), the largest contribution of any knob in the recipe except augmentation itself, and more than the optimizer, the warmup, the weight decay and the label smoothing combined. It buys that by taking large steps while the loss surface is still coarse and small ones once it is not, which is the reason the warmup exists at the other end and the reason a constant rate has to be a compromise between the two.

This is the first chapter that uses the full production recipe (AdamW + cosine + warmup + weight decay + augmentation + label smoothing). Also the first chapter where .globalAvgPool replaces the flatten-plus-fat-dense head. Global average pooling (GAP) collapses each \(C \times H \times W\) feature map to a length-\(C\) vector by averaging over the spatial dimensions,

\[ \mathrm{gap}(x)_c \; =\; \frac{1}{HW} \sum _{i, j} x_{c, i, j}, \]

so the classifier head sees one summary scalar per channel instead of the full \(C \cdot H \cdot W\) flattened activation.

GAP is not ResNet’s invention. It comes from Network in Network (Lin et al. 2013, arXiv:1312.4400), which proposed it precisely as a replacement for the fully-connected layers at the top of a convolutional net: one feature map per class, averaged, fed straight to the softmax, with no fully-connected parameters to overfit. Inception-v1 (GoogLeNet, Szegedy et al. 2014, arXiv:1409.4842) adopted it a year later — dropping the fully-connected stack for an average pool and a single linear classifier — and reported it worth about \(0.6\% \) top-1 on its own. By 2015 it was standard equipment, which is why ResNet uses it without argument. We’ll see GAP again as the “Squeeze” step inside the squeeze-and-excitation block in Chapter 7, and at the head of every post-2015 vision architecture in the bestiary.

Results

§5.1 has the run: 80 epochs on one RTX 4060 Ti, finishing at 90.06% top-1 and 98.32% top-5 on Imagenette’s 3,925-image validation split, one seed of the five that average \(89.99 \pm 0.32\). That is the going rate for a scratch-trained ResNet-34 at this resolution and data size. From the log:

  • Per-step time is about 120 ms at batch 32, 295 steps to the epoch, dominated by the four .residualStage groups where the \(3 \times 3\) convolutions at 64, 128, 256 and 512 channels are. The first stage alone, at 64 channels on the \(56 \times 56\) grid, is \(3 \times 3 \times 64 \times 64\) multiply-accumulates per output location, about \(1.2 \times 10^8\) per convolution, six convolutions in the stage, times the batch, and the backward costs about twice the forward again.

  • Final loss plateaus near 0.50, not zero. That’s the label-smoothing floor: with \(\epsilon = 0.1\) and \(K = 10\) classes the smoothed target puts \(1 - \epsilon + \epsilon /K = 0.91\) on the true class and \(\epsilon /K = 0.01\) on each of the other nine, and the minimum possible cross-entropy against it is that target’s entropy, \(-0.91 \log 0.91 - 9 \cdot 0.01 \log 0.01 \approx 0.500\). The 0.502 at epoch 80 sits on that floor, so the model has essentially converged. More epochs would mostly just extend training time without budging the loss.

  • BN layers: 36. Every residual block contains two normalized convolutions, and there are 16 blocks across the four stages (3+4+6+3): 32. The projection shortcuts that open stages 2–4 carry one each, and the stem one, for 36 total. Each one proves its own VJP via Theorem 50 and composes with the rest of the network via Theorem 11.

5.4 MLIR: Residual

What is already proven. A residual block is \(\mathrm{residual}(x) = f(x) + x\), an additive fan-in, where \(f\) is the conv \(\to \) BN \(\to \) relu \(\to \) conv \(\to \) BN inner block. As Theorem 64 states, its reverse-mode derivative is the additive-fan-in rule with the identity in one slot: “send the upstream gradient through both branches and add”. residualHasVJPAt proves exactly this at the evaluation point (a one-line proof: the fan-in lemma with the identity’s VJP plugged into one slot), and the inner \(f\)’s VJP is the convolution and BatchNorm backward (Theorems 35, 50). The whole block, and a tower of sixteen of them, composes by the chain rule vjpCompAt (Theorem 11).

The gap and how we close it. The residual fan-in is the structural heart of a deep ResNet, and it is visible in the emitted text. The forward saves \(a = f(x) + x\) and returns \(\mathrm{relu}(a)\). The backward first pushes the incoming cotangent back through that ReLU (a compare/select at the saved \(a\)), then runs \(f\)’s own backward on the result, and then, and this is the fan-in, adds the result of the ReLU mask to it again. Here it is in verified_mlir/resnet34_sgd_train_step.mlir for the last block of the fourth stage (s4b1: 512 channels at \(7\times 7\), batch 32), with the inner backward through BN\(_2\), conv\(_2\), the mid ReLU and BN\(_1\) elided down to conv\(_1\)’s input gradient (SSA names the artifact’s, lines wrapped):

%v1134 = stablehlo.reshape %v1095 : (tensor<32x25088xf32>)
      -> tensor<32x512x7x7xf32>
%v1135 = stablehlo.constant dense<0.0> : tensor<32x512x7x7xf32>
%v1136 = stablehlo.compare GT, %v1134, %v1135 : (tensor<32x512x7x7xf32>,
      tensor<32x512x7x7xf32>) -> tensor<32x512x7x7xi1>
%v1137 = stablehlo.select %v1136, %v1133, %v1135 : tensor<32x512x7x7xi1>,
      tensor<32x512x7x7xf32>
// ...
%v1211 = stablehlo.reverse %s4b1W1, dims = [2, 3] : tensor<512x512x3x3xf32>
%v1212 = stablehlo.transpose %v1211, dims = [1, 0, 2, 3]
      : (tensor<512x512x3x3xf32>) -> tensor<512x512x3x3xf32>
%v1213 = stablehlo.convolution(%v1210, %v1212)
      dim_numbers = [b, f, 0, 1]x[o, i, 0, 1]->[b, f, 0, 1],
      window = {stride = [1, 1], pad = [[1, 1], [1, 1]],
      lhs_dilate = [1, 1], rhs_dilate = [1, 1]}
      {batch_group_count = 1 : i64, feature_group_count = 1 : i64}
      : (tensor<32x512x7x7xf32>, tensor<512x512x3x3xf32>)
      -> tensor<32x512x7x7xf32>
// ...
%v1215 = stablehlo.reshape %v1214 : (tensor<32x25088xf32>)
      -> tensor<32x512x7x7xf32>
%v1216 = stablehlo.reshape %v1138 : (tensor<32x25088xf32>)
      -> tensor<32x512x7x7xf32>
%v1217 = stablehlo.add %v1215, %v1216 : tensor<32x512x7x7xf32>

Read it against the theorem. %v1134 is the saved pre-ReLU sum \(a\) and %v1133 the cotangent arriving from the head; %v1137 is their select, the cotangent at the fan-in. It then appears twice. Once it enters \(f\)’s backward, whose last operation is the convolution %v1213 against the first kernel reversed and transposed (%v1211, %v1212: Chapter 3’s reverseSwap, with the two steps in the other order), giving the gradient through the residual branch, %v1215. And once it is added directly: %v1216 is the same %v1137 flattened and reshaped (%v1138), and %v1217 = %v1215 + %v1216 is “send the gradient through both branches and add”. The skip term is the very tensor the branch term started from, which is why the gradient survives all sixteen blocks instead of attenuating away, and %v1218, its flattening, is the cotangent every parameter node of the block below reads. r34IdCotIn_eq_vjp is the theorem that this seven-node subgraph denotes \((\texttt{r34IdBHasVJPAt}).\mathrm{backward}\), the certified VJP of the whole block, and Theorem 68 threads it through all sixteen; the three downsampling blocks, whose skip is a \(1\times 1\) convolution and BatchNorm rather than the identity, have their own (r34DownCotIn_eq_vjp).

Caveats.

  • The fan-in add holds at every input, because addition is linear.

  • Two ReLUs per block are smooth-point bridges, the mid-block one and the post-add one (no pre-activation exactly zero), and the two BatchNorms inside \(f\) carry their \(\epsilon {\gt} 0\) condition (Theorem 50).

  • Shape. The listing is one block of the trained net at batch 32; the same seven nodes repeat for all sixteen.

5.5 The recipe

5.5.1 What’s in it

Six ingredients first appear here, none of which are layers. They all live in TrainConfig or training code. They don’t touch the network; what each one is worth is measured in §5.5.2.

Adam (Kingma & Ba, 2014, arXiv:1412.6980). Replaces vanilla SGD. Maintains per-parameter running estimates of the first and second moments of the gradient and uses them for an adaptive per-parameter learning rate. Much faster convergence than SGD on most tasks, much more forgiving of learning-rate choice.

Cosine learning-rate schedule (Loshchilov & Hutter, 2016, arXiv:1608.03983). After warmup, the learning rate decays from its peak to near-zero following \(\mathrm{lr}(t) = \mathrm{lr}_{\max } \cdot \tfrac {1}{2}(1 + \cos (\pi t / T))\). Smoother than step schedules, and consistently produces slightly better final losses in practice. Warmup-then-anneal first won at scale at Stanford’s DAWNBench in 2018, in two forms: the CIFAR-10 entry ran Smith’s 1cycle, a linear triangle with a short anneal, and the ImageNet entry a four-epoch warmup into step decay, with progressive resizing (Howard et al., 2018) on top. The lineage traces back to Leslie Smith’s cyclical learning rates (Smith 2015, arXiv:1506.01186). The cosine form, with warmup and label smoothing, is He et al. 2018’s “Bag of Tricks” (arXiv:1812.01187), and that is the recipe this chapter’s ImageNet rows run.

Warmup (Goyal et al., 2017, arXiv:1706.02677). Linearly ramps the learning rate from zero to its peak over the first few epochs (3 for ResNet-34). Stabilizes early training when gradients are still reorganizing randomly-initialized weights. Becomes essential for larger models and larger batches; Chapter 9’s ViT-Tiny trains with one.

Weight decay. Adds a small L2 shrink to all trainable weights each step: \(w \leftarrow w - \eta \lambda w\), with \(\lambda = 10^{-4}\) here. Regularizer, discourages weights from growing without bound. Loshchilov & Hutter’s “decoupled” variant (AdamW, 2017, arXiv:1711.05101) is what actually ships in modern pipelines, and it’s what TrainConfig.weightDecay implements.

Augmentation. Our pipeline does random crops (\(256 \to 224\)) and random horizontal flips on Imagenette, hflip only on CIFAR, and nothing on MNIST. Lives in the data pipeline, not the network. We will explore data augmentation strategies in upcoming chapters.

Label smoothing (Szegedy et al., 2016, arXiv:1512.00567). Replaces the one-hot target with the smoothed \((1 - \varepsilon )\, \mathrm{onehot} + \varepsilon / K\): \(1 - \varepsilon + \varepsilon /K\) on the true class and \(\varepsilon / K\) on every other, the form Szegedy et al. give and the one TrainConfig.labelSmoothing and timm implement. Stops the network from producing arbitrarily large logits on the correct class, which keeps training stable and improves calibration. Explains the 0.50 loss floor in the Results section above: the target’s entropy at \(\varepsilon = 0.1\), \(K = 10\) is \(-0.91 \log 0.91 - 9 \cdot 0.01 \log 0.01 \approx 0.500\).

Every chapter after this one uses the same six ingredients. The NetSpec changes, and the recipe stays.

5.5.2 Ablation: what each ingredient contributes

So how much does each ingredient actually earn? Figure 5-3 is the full recipe and six leave-one-out variants, ResNet-34 on Imagenette, 80 epochs each. Every variant keeps the recipe intact except for the one component it names, so its difference from the full recipe measures that component’s contribution given everything else is present — not the order-dependent story an additive ladder tells.

\begin{tikzpicture} 
\pgfplotsset{
  ablationaxis/.style={
    width=0.45\linewidth, height=6.4cm,
    xmin=-8.4, xmax=1.4,
    symbolic y coords={full,nowd,nowarm,nols,noadam,nocos,noaug},
    % ⚠ ytick EXPLICIT, not `ytick=data`: the latter emits a tick only for categories that
    % have a plotted point, so the empty `gap` row got none and every label after it slid
    % up one row — the augmentation point appeared on an unlabelled line.
    ytick={full,nowd,nowarm,nols,noadam,nocos,noaug}, y dir=reverse,
    xlabel={$\Delta$ top-1 vs full recipe (points)},
    tick label style={font=\small}, label style={font=\small},
    title style={font=\small},
    grid=major, grid style={gray!14},
    scatter/use mapped color={draw=black,fill=black},
  }
}
% ── fp32 ───────────────────────────────────────────────────────────────────
\begin{axis}[ablationaxis, title={fp32, 5 seeds},
  yticklabels={full,$-$weight decay,$-$warmup,$-$label smoothing,
               $-$AdamW,$-$cosine,$-$augmentation}]
  \draw[black!70]        ({axis cs:0,full}|-{rel axis cs:0,0})
                      -- ({axis cs:0,full}|-{rel axis cs:0,1});
  % Five seeds per arm (runs/2026-09-12-r34-ablation-fp32-seeds/RESULTS.md): the point is the
  % mean difference from `full`, the bar the 95% t-interval on the PAIRED difference (arms
  % sharing a seed share init and data order). No band: the bar is the resolution.
  \addplot[only marks, mark=*, mark size=2.0pt, blue!70!black,
           error bars/.cd, x dir=both, x explicit,
           error bar style={blue!70!black, line width=0.6pt},
           error mark options={rotate=90, blue!70!black, mark size=1.6pt}] coordinates {
    (0,full) +- (0,0) (-0.02,nowd) +- (0.61,0) (-0.55,nowarm) +- (0.46,0)
    (-0.69,nols) +- (0.42,0) (-2.27,noadam) +- (0.59,0) (-5.00,nocos) +- (2.60,0)
    (-7.33,noaug) +- (0.49,0)};
\end{axis}
% ── bf16 ───────────────────────────────────────────────────────────────────
\begin{axis}[ablationaxis, title={bf16, 5 seeds}, yticklabels={{},{},{},{},{},{},{}},
  at={(0.47\linewidth,0)}, anchor=south west]
  \draw[black!70]        ({axis cs:0,full}|-{rel axis cs:0,0})
                      -- ({axis cs:0,full}|-{rel axis cs:0,1});
  % Five seeds per arm (runs/2026-09-11-r34-ablation-bf16-seeds/RESULTS.md): the point is the
  % mean difference from `full`, the bar the 95% t-interval on the PAIRED difference (arms
  % sharing a seed share init and data order). No band: the bar is the resolution.
  \addplot[only marks, mark=*, mark size=2.0pt, orange!80!black,
           error bars/.cd, x dir=both, x explicit,
           error bar style={orange!80!black, line width=0.6pt},
           error mark options={rotate=90, orange!80!black, mark size=1.6pt}] coordinates {
    (0,full) +- (0,0) (0.14,nowd) +- (0.45,0) (-0.82,nowarm) +- (0.62,0)
    (-0.50,nols) +- (0.62,0) (-2.21,noadam) +- (0.33,0) (-4.78,nocos) +- (2.74,0)
    (-7.11,noaug) +- (0.38,0)};
\end{axis}
\end{tikzpicture}
Figure 5-3 Leave-one-out ablation of the ResNet-34 recipe on Imagenette, in fp32 and bf16: each arm’s top-1 difference from the full recipe, the mean over five seeds with its 95% interval paired by seed, rows sorted by fp32 effect. The full recipe lands at \(89.99 \pm 0.32\) (fp32) and \(89.98 \pm 0.15\) (bf16).

Each point is plotted as its difference from the full recipe rather than as an accuracy, so the line at zero is the baseline and distance from it is what the ingredient contributes. The intervals are paired by seed, since arms sharing a seed share initialization and data order. An arm whose bar crosses zero is not placed, however suggestive its mean.

The two panels are the same seven arms in the two precisions, and the reason to draw them side by side is that a real effect should not move. None does: every arm’s fp32 mean falls inside the bf16 interval and every bf16 mean inside the fp32 one. Four arms are resolved in both. Label smoothing is resolved in fp32 (\(-0.69 \pm 0.42\)) but not bf16 (\(-0.50 \pm 0.62\)), where the means agree and only the interval is wider. Weight decay is resolved in neither (\(-0.02 \pm 0.61\), \(+0.14 \pm 0.45\)): ten runs do not place it, and the honest reading is that here it earns nothing measurable.

The cosine bar is the widest in both precisions because the constant-rate arm finishes wherever its noisy trajectory happens to be at epoch 80: fp32 seeds spread \(82.0\)–\(86.8\) (sd \(1.93\)), bf16 \(82.6\)–\(87.9\) (sd \(2.18\)). Its contribution is large and imprecise, and both precisions agreeing on how imprecise says the width belongs to the arm, not to either number format.

What the optimizer row is really measuring. Reading the \(-\)AdamW gap as “what the adaptive optimizer buys” assumes its replacement got a rate that suits it. Heavy-ball at \(\mu = 0.9\) multiplies the effective step by \(1/(1-\mu ) = 10\), so the comparison that isolates momentum is against plain SGD at ten times the rate — \(0.1\), not \(0.01\) — where both take the same size step and differ only in whether a velocity carries it. Figure 5-4 is that comparison.

\begin{tikzpicture} 
% ── left: the ladder ───────────────────────────────────────────────────────
\begin{axis}[
  width=0.42\linewidth, height=4.6cm,
  % ⚠ Deliberately a TIGHTER x range than the two ablation panels above. Those span
  % -8.4 to +1.4 because augmentation reaches -7.3; here the whole ladder lives inside
  % three points, and at the wider scale the 0.40 that separates the two lower rungs is
  % 2% of the axis width -- i.e. invisible, which is the one thing this figure exists to show.
  xmin=-3.4, xmax=0.8,
  symbolic y coords={full,mom,sgd10}, ytick={full,mom,sgd10},
  y dir=reverse,
  yticklabels={AdamW lr .001,momentum lr .01,SGD lr .1},
  xlabel={$\Delta$ top-1 vs full (points)},
  tick label style={font=\small}, label style={font=\small},
  title style={font=\small}, title={endpoints, 5 seeds},
  grid=major, grid style={gray!14}]
  \draw[black!70] ({axis cs:0,full}|-{rel axis cs:0,0})
               -- ({axis cs:0,full}|-{rel axis cs:0,1});
  % Five seeds per rung, runs/2026-09-12-r34-ablation-fp32-seeds/RESULTS.md. `mom` IS the
  % ablation's `noadam` arm -- the same artifact, not a re-run -- so only `sgd10` was new work.
  % Bars are the paired 95% interval on the difference from `full`.
  % ⚠⚠ These two lower bars OVERLAP while the PAIRED difference between the two rungs,
  % +0.403 ± 0.162, EXCLUDES zero. That is not a contradiction and the prose says so: the
  % arms share seeds, so their difference is far better determined than either one's
  % distance from `full`. Do not "fix" this by widening the bars or dropping the claim.
  % ⚠ `full` carries +- (0,0) because this axis is a DIFFERENCE from `full`: per seed that
  % difference is identically zero, so its interval is exactly zero. It is not an unmeasured
  % point. `full`'s own spread is 89.99 ± 0.32 (sd 0.26, five seeds) and it is already
  % INSIDE every other bar, since each paired difference subtracts that seed's own `full`.
  % Plotting ±0.32 here would be an absolute CI on a difference axis, and would double-count.
  \addplot[only marks,mark=*,mark size=2.2pt,blue!70!black,
           error bars/.cd, x dir=both, x explicit,
           error bar style={blue!70!black, line width=0.6pt},
           error mark options={rotate=90, blue!70!black, mark size=1.6pt}]
    coordinates {(0,full) +- (0,0) (-2.27,mom) +- (0.59,0) (-1.86,sgd10) +- (0.57,0)};
\end{axis}
% ── right: the trajectories ────────────────────────────────────────────────
\begin{axis}[
  width=0.47\linewidth, height=4.6cm, at={(0.45\linewidth,0)}, anchor=south west,
  xlabel={Epoch}, ylabel={Val top-1 (\%)},
  xmin=0,xmax=81,ymin=10,ymax=95, xtick={0,20,40,60,80},
  tick label style={font=\small}, label style={font=\small},
  % One run per optimizer -- these are the original curves, not per-seed means. The claim
  % they carry is about SHAPE (the two lower curves never separate), which one run shows.
  title style={font=\small}, title={trajectories, one run each},
  legend style={font=\scriptsize,at={(0.98,0.03)},anchor=south east,draw=gray!40},
  legend cell align={left}, grid=major, grid style={gray!14},
  every axis plot/.append style={line width=0.7pt, mark=none}]
  \addplot[black!75]      coordinates {(1,24.69) (2,24.89) (3,45.91) (4,50.06) (5,49.81) (6,66.68) (7,57.43) (8,66.39) (9,72.36) (10,60.05) (11,55.06) (12,75.36) (13,73.86) (14,73.53) (15,81.10) (16,69.22) (17,63.34) (18,70.09) (19,76.76) (20,79.59) (21,71.67) (22,69.89) (23,79.46) (24,82.98) (25,78.29) (26,70.50) (27,81.43) (28,82.90) (29,77.99) (30,82.24) (31,82.11) (32,85.61) (33,85.76) (34,85.48) (35,83.36) (36,86.04) (37,86.39) (38,82.57) (39,85.55) (40,87.16) (41,85.04) (42,85.15) (43,86.37) (44,87.39) (45,86.19) (46,86.88) (47,88.13) (48,88.03) (49,87.67) (50,87.24) (51,86.98) (52,89.15) (53,88.41) (54,89.68) (55,88.97) (56,88.54) (57,88.71) (58,89.40) (59,89.30) (60,89.10) (61,88.87) (62,90.04) (63,89.78) (64,89.61) (65,90.11) (66,90.01) (67,90.19) (68,90.19) (69,90.11) (70,90.19) (71,90.19) (72,90.19) (73,90.17) (74,90.37) (75,90.55) (76,90.34) (77,90.42) (78,90.42) (79,90.47) (80,90.42)};   \addlegendentry{AdamW}
  \addplot[teal!60!black] coordinates {(1,22.52) (2,41.99) (3,53.02) (4,59.03) (5,65.12) (6,67.77) (7,68.38) (8,70.78) (9,74.55) (10,67.64) (11,69.76) (12,75.80) (13,73.89) (14,78.52) (15,79.39) (16,79.11) (17,79.29) (18,79.80) (19,80.48) (20,78.19) (21,78.09) (22,80.10) (23,82.83) (24,82.78) (25,80.33) (26,84.15) (27,80.59) (28,83.67) (29,83.92) (30,84.79) (31,85.43) (32,86.17) (33,86.01) (34,86.24) (35,85.27) (36,86.17) (37,86.62) (38,87.13) (39,86.62) (40,87.13) (41,86.98) (42,87.97) (43,87.18) (44,87.52) (45,87.29) (46,87.64) (47,87.69) (48,87.95) (49,87.52) (50,87.34) (51,87.75) (52,87.77) (53,88.23) (54,88.10) (55,87.85) (56,88.20) (57,87.97) (58,87.82) (59,87.95) (60,88.03) (61,87.80) (62,88.18) (63,88.13) (64,88.10) (65,88.28) (66,88.18) (67,88.31) (68,88.10) (69,88.05) (70,88.18) (71,88.18) (72,88.23) (73,88.05) (74,88.23) (75,88.31) (76,88.18) (77,88.28) (78,88.18) (79,88.10) (80,88.13)};    \addlegendentry{momentum .01}
  \addplot[blue!65!black] coordinates {(1,16.89) (2,27.26) (3,58.60) (4,50.37) (5,58.52) (6,63.11) (7,70.14) (8,69.83) (9,71.36) (10,51.80) (11,66.47) (12,73.61) (13,65.53) (14,70.09) (15,68.20) (16,74.04) (17,79.46) (18,77.66) (19,78.06) (20,80.97) (21,80.71) (22,83.44) (23,81.12) (24,75.36) (25,83.77) (26,81.02) (27,81.12) (28,82.42) (29,82.39) (30,84.92) (31,79.26) (32,84.64) (33,84.28) (34,84.92) (35,85.73) (36,83.06) (37,85.96) (38,82.45) (39,82.42) (40,86.37) (41,85.30) (42,85.61) (43,86.55) (44,86.80) (45,86.45) (46,87.34) (47,87.39) (48,87.21) (49,86.65) (50,87.13) (51,87.87) (52,87.87) (53,87.92) (54,87.36) (55,87.49) (56,87.64) (57,87.49) (58,87.69) (59,87.39) (60,87.85) (61,87.52) (62,87.77) (63,87.87) (64,87.46) (65,87.49) (66,87.57) (67,87.41) (68,87.49) (69,87.41) (70,87.46) (71,87.44) (72,87.75) (73,87.64) (74,87.69) (75,87.67) (76,87.80) (77,87.54) (78,87.72) (79,87.57) (80,87.82)};  \addlegendentry{SGD .1}
\end{axis}
\end{tikzpicture}
Figure 5-4 The optimizer arms at a matched step size: their top-1 difference from the full recipe over five seeds, and one run of each epoch by epoch.

At a matched step, plain SGD does not merely keep up with momentum — it finishes slightly ahead, by a paired \(+0.403 \pm 0.162\) with all five seeds agreeing in sign. The two lower bars overlap while that difference is resolved, which is no contradiction: the arms share seeds, so their difference is better determined than either rung’s distance from the baseline. The baseline carries no bar for the same reason — its difference from itself is zero on every seed, and its own spread sits inside the other two bars. The trajectories say the same about shape: the two never separate across the eighty epochs.

Momentum is therefore not doing something a velocity-free step cannot; it is taking a bigger one, slightly less well. The \(-2.27 \pm 0.59\) charged to “removing AdamW” is almost entirely the \(10\times \) momentum supplies for free: hold the effective step fixed and the gap closes to \(-1.86 \pm 0.57\), the remainder running the other way.

That is this ablation’s own limit: every non-adaptive row’s difference is partly a claim about how well its learning rate suits it, and an adaptive optimizer’s insensitivity to that rate hides the dependence. The figure measures the recipe as tuned, not its ingredients in isolation.

Augmentation is the largest single contribution (\(-7.33 \pm 0.49\) fp32, \(-7.11 \pm 0.38\) bf16; three times the next-largest knob). Imagenette has 9,469 training images for a 21.3M-parameter network, so without the random crop and flip the model overfits hard. Most of what the rest of the recipe gets credit for is augmentation.

5.6 ImageNet recipe

5.6.1 ResNet-34: the 2018 recipe

The Imagenette runs above all trained on \(\sim \)9.5K images for 80 epochs. This section is the same 21.8M-parameter network on 1.28M images and 1000 classes, trained on the standard 90-epoch schedule. The architecture is unchanged. Only the dataset, the output head width, and the training schedule differ.

This section runs it twice. The reference is the Lean\(\to \)JAX trainer of §B.4.2: JAX traces the generated program and hands XLA the graph. The verified path hands XLA pretty(provenGraph) — the rendered artifact the proofs reason about — through the PJRT C API, with no Python at run time. Same architecture, same recipe, same data loader, same compiler; the only difference is who wrote the StableHLO. Every ImageNet section reports the reference first, the verified path second, and then the two side by side.

The recipe in full: SGD with momentum \(0.9\) at batch 256, cosine LR with a 5-epoch warmup from peak \(0.1\), label smoothing \(0.1\), a random-resized crop to \(224\times 224\) plus a horizontal flip, \(\ell _2\) weight decay \(10^{-4}\) on the convolution and dense weights with BatchNorm’s \(\gamma \), \(\beta \) and the biases left undecayed, every block’s second BatchNorm starting its \(\gamma \) at \(0\), and running statistics that decay at \(0.9\). The crop is torchvision’s RandomResizedCrop (\(8\)–\(100\% \) of the area at a \(\frac{3}{4}\)–\(\frac{4}{3}\) aspect ratio, bilinear) on both halves, from the one generated shim. SGD with momentum and the weight decay are the original paper’s; the cosine schedule, the warmup, the label smoothing, the random-resized crop, the undecayed BatchNorm parameters and the zero-\(\gamma \) are He et al. 2018’s Bag of Tricks. What the rows do not take from that paper, each a training-time change that no later chapter’s recipe shares either: 120 epochs, Nesterov momentum, Xavier initialization, the hue/saturation/brightness jitter with PCA noise, and the ResNet-D downsampling blocks — the networks here are torchvision’s, ResNet-B in that paper’s naming and ResNet v1.5 elsewhere: the stride-2 on a bottleneck’s \(3\times 3\) rather than its first \(1\times 1\).

The reference (JAX)

The reference trainer is jax/MainResnetImagenet.lean, built as lake build resnet34-imagenet, with .imagenet streamed through tfds. It runs in bf16 — the convolutions cast to bfloat16 against fp32 master weights, the classifier head left in fp32 — which is a throughput choice rather than a fidelity one, and is why its column is the faster of the two in Table 5-3. Table 5-1 is the run.

Table 5-1 ResNet-34 on ImageNet-1k, reference trainer (JAX): global batch 256 as \(4 \times 64\) replicas, the final checkpoint scored over all \(50{,}000\) validation images through an fp32 forward.

GPU

Precision

Per epoch

Epochs

Total

Val top-1

Val top-5

4\(\times \) 4060 Ti (CUDA)

bf16

\(\sim \)8.4 min

90

\(\sim \)12.7 hr

\(\mathbf{73.98\% }\)

\(\mathbf{91.70\% }\)

That fp32 scoring is how the verified path and timm score, and the run’s own bf16 evaluation lands within \(0.01\) of it. Figure 5-5 is the run, epoch by epoch.

\begin{tikzpicture} 
\begin{axis}[
    width=0.92\linewidth, height=6.5cm,
    xlabel={Epoch}, ylabel={Validation accuracy (\%)},
    xmin=0, xmax=91, ymin=0, ymax=100,
    xtick={0,15,30,45,60,75,90},
    ytick={20,40,60,80},
    legend pos=south east,
    legend cell align={left},
    grid=major, grid style={gray!18},
    tick label style={font=\small},
    label style={font=\small},
    every axis plot/.append style={line width=1pt, mark size=1pt},
    title style={font=\small},
    title={2018 recipe, 90 epochs --- reference},
]
\addplot[blue, mark=*, mark options={fill=blue}] coordinates {
(1,4.70) (2,17.01) (3,27.88) (4,34.51) (5,37.17) (6,42.77) (7,45.59) (8,47.18) (9,47.22) (10,49.51) (11,49.90) (12,50.92) (13,50.06) (14,52.20) (15,51.38) (16,51.25) (17,54.11) (18,53.17) (19,54.59) (20,53.91) (21,54.14) (22,55.62) (23,55.11) (24,54.76) (25,55.50) (26,54.61) (27,55.20) (28,55.76) (29,57.52) (30,56.84) (31,56.80) (32,55.71) (33,56.87) (34,57.86) (35,59.01) (36,58.95) (37,58.27) (38,59.20) (39,58.67) (40,58.38) (41,59.10) (42,59.59) (43,59.22) (44,57.85) (45,60.74) (46,60.55) (47,59.83) (48,61.51) (49,60.06) (50,61.07) (51,62.35) (52,61.20) (53,63.41) (54,63.47) (55,64.14) (56,63.07) (57,63.57) (58,65.03) (59,64.40) (60,64.56) (61,65.28) (62,65.89) (63,66.05) (64,65.47) (65,66.95) (66,67.14) (67,67.16) (68,67.19) (69,68.41) (70,68.74) (71,68.74) (72,69.30) (73,69.73) (74,70.33) (75,70.36) (76,71.07) (77,71.30) (78,71.59) (79,72.03) (80,72.32) (81,72.85) (82,72.96) (83,73.17) (84,73.43) (85,73.57) (86,73.79) (87,73.96) (88,74.01) (89,73.99) (90,73.98)
};
\addlegendentry{top-1}
\addplot[orange, mark=*, mark options={fill=orange}] coordinates {
(1,14.04) (2,37.82) (3,52.90) (4,60.85) (5,64.00) (6,69.53) (7,71.81) (8,73.54) (9,73.65) (10,75.91) (11,75.92) (12,76.59) (13,75.85) (14,77.68) (15,77.11) (16,77.18) (17,79.04) (18,78.59) (19,79.65) (20,78.86) (21,79.06) (22,80.30) (23,79.72) (24,79.63) (25,79.99) (26,79.69) (27,79.97) (28,80.21) (29,81.73) (30,81.47) (31,81.20) (32,80.07) (33,81.19) (34,82.52) (35,82.83) (36,83.01) (37,82.23) (38,82.89) (39,82.56) (40,82.63) (41,82.90) (42,83.37) (43,82.93) (44,82.14) (45,83.93) (46,83.91) (47,83.47) (48,84.70) (49,83.50) (50,84.26) (51,85.16) (52,84.49) (53,85.93) (54,85.73) (55,86.23) (56,85.64) (57,85.80) (58,86.76) (59,86.57) (60,86.59) (61,86.82) (62,87.22) (63,87.28) (64,87.00) (65,87.89) (66,88.01) (67,88.05) (68,88.09) (69,88.90) (70,88.67) (71,88.85) (72,89.21) (73,89.44) (74,89.76) (75,89.74) (76,90.10) (77,90.31) (78,90.54) (79,90.63) (80,90.82) (81,90.98) (82,91.15) (83,91.23) (84,91.42) (85,91.47) (86,91.57) (87,91.67) (88,91.64) (89,91.63) (90,91.70)
};
\addlegendentry{top-5}
\end{axis}
\end{tikzpicture}
Figure 5-5 Validation accuracy per epoch of the ResNet-34 reference run on ImageNet-1k.

The bend near epoch 5 is the end of warmup; the steady late climb is the cosine anneal.

The verified path (PJRT)

The verified trainer is apps/imagenette/MainResnet34Imagenet.lean, built as lake build resnet34-imagenet-verified:

-- 1. Same backbone as the Imagenette net, 1000 classes instead of 10.
def resnet34ImagenetVerified : VerifiedNetSpec where
  name     := "ResNet-34 (ImageNet-1k)"
  slug     := "resnet34in"
  inC      := 3
  imageH   := 224
  imageW   := 224
  data     := .imagenet
  shimScript := "generated_resnet34_imagenet_shim.py"
  layers   := [
    .convBnNB 3 64 7 2,          -- 7x7-s2 stem -> BN -> relu    224->112
    .maxPool 3 2,                -- He et al. 3x3-s2, OVERLAPPING 112->56
    .residualStage  64  64 3 1,  -- stage1: 3 identity            @56
    .residualStage  64 128 4 2,  -- stage2: downsample + 3        56->28
    .residualStage 128 256 6 2,  -- stage3: downsample + 5        28->14
    .residualStage 256 512 3 2,  -- stage4: downsample + 2        14->7
    .globalAvgPool,
    .dense 512 1000 ]
  blurb := "ResNet-34 on full 1000-class ImageNet via the VERIFIED renderer ->
            %LOWERER% -> GPU, with the tfds batch shim supplying the same
            augmentation the Lean->JAX reference trainer uses"
  runningBN := true    -- 36 BatchNorms, read off the layer list

-- The head is the ONLY thing that may differ from the 10-class net.
-- Anything else moving means the spec drifted from its Imagenette twin.
#guard resnet34ImagenetVerified.toSpecs.size == resnet34Verified.toSpecs.size
#guard resnet34ImagenetVerified.toSpecs.pop.pop == resnet34Verified.toSpecs.pop.pop
#guard resnet34ImagenetVerified.toSpecs.back!  == (#[1000], 2)

-- 2. 90 epochs at batch 256.
def resnet34ImagenetConfig : VerifiedConfig where
  epochs    := 90
  batchSize := 256

-- 3. The entry point. Heavy-ball momentum, cosine with a 5-epoch warmup
--    at peak 0.1, weight decay 1e-4, label smoothing 0.1.
-- ⚠ LEAN_MLIR_EPOCHS *sets* the schedule; MAX_EPOCHS only ever CAPS it, and
--    `totalSteps := cfg.epochs * nb / accK` is what the cosine anneals over,
--    so capping at 90 would keep annealing over the default.
def runResnet34Imagenet (argv : List String) : IO Unit := do
  let variant := (← IO.getEnv "LEAN_MLIR_VARIANT").getD "mom256"
  let bs      := ((← IO.getEnv "LEAN_MLIR_BATCH").bind (·.toNat?)).getD
                   resnet34ImagenetConfig.batchSize
  let epochs  := ((← IO.getEnv "LEAN_MLIR_EPOCHS").bind (·.toNat?)).getD
                   resnet34ImagenetConfig.epochs
  let baseLR := match (← IO.getEnv "LEAN_MLIR_BASE_LR_U").bind (·.toNat?) with
    | some u => u.toFloat * 1e-6
    | none   => 0.1
  resnet34ImagenetVerified.toNet.trainAdamSched
    { resnet34ImagenetConfig with batchSize := bs, epochs := epochs }
    (argv.head?.getD "data") baseLR 0.9 0.999 5 variant

Three things in it are worth pulling out, because they are what makes the listing a claim rather than a description.

The slug is the artifact. resnet34in names verified_mlir/resnet34in_<variant>_train_step.mlir — momdp64wxbf16 for the run below — which is pretty(provenGraph) off the same renderer every earlier chapter used. Scaling to ImageNet needed nothing new in it. nClasses, the batch, the optimizer and the slug are all ordinary parameters, so the ImageNet artifacts are three #evals of machinery that was already proved. What had to be built was the data path, not the compiler.

The #guards pin it to the Imagenette net. The 1000-class spec must be the 10-class one with a different head, and the three guards fail the build if any other parameter shape moves. That is what licenses reading the Imagenette chapter’s theorems as being about this network’s body.

The shimScript is per-net and decides the data. It names this net’s own generated augmentation shim — the same shim the reference consumes, which is what makes the two a matched pair rather than two separate stories.

It runs with:

lake run r34-default-bf16-4gpu

The job config behind that name, scripts/jobs/r34-default-bf16-4gpu.conf, carries the device list, both replica knobs, the variant, the per-replica batch and the feed setting; §C says what the supervisor behind it does. Table 5-2 is the run.

Table 5-2 ResNet-34 on ImageNet-1k, verified path (PJRT): global batch 256 as \(4 \times 64\) replicas, bf16 convolutions under an fp32 classifier head, BatchNorm statistics all-reduced across the four replicas, scored over the same \(50{,}000\) images.

GPU

Precision

Per epoch

Epochs

Total

Val top-1

Val top-5

4\(\times \) 4060 Ti (CUDA)

bf16

\(\sim \)15.6 min

90

\(\sim \)23.5 hr

\(\mathbf{74.14\% }\)

\(\mathbf{91.85\% }\)

The proof-carrying tier reaches this render as a step. Theorems 68 and 74 are stated at either precision, and read over \(\mathbb {R}\) the bf16 text’s gradient nodes are the certified ones. What they do not reach is the rounding: that the hardware rounds where the text says and nowhere else is trusted, as it is at every op of an f32 render.

The two paths, side by side

Table 5-3 ResNet-34 on ImageNet-1k: the reference and the verified path, row by row. Both are the 2018 recipe, 90 epochs, on the same four RTX 4060 Ti cards, scored over the same \(50{,}000\) validation images under the same protocol; rows above the first rule differ, rows between the rules are held equal by construction.
 

reference (JAX)

verified path (PJRT)

lowering

JAX \(\to \) XLA

pretty(provenGraph) \(\to \) XLA

Python in the step

yes

none

evaluation forward

bf16

fp32

precision

bf16 convolutions

bf16 convolutions

classifier head

fp32

fp32

global batch

256 (\(4\times 64\))

256 (\(4\times 64\))

BN statistic group

256 (global)

256 (global)

train crop

torchvision RandomResizedCrop

torchvision RandomResizedCrop

steps/epoch

5,004

5,004

optimizer

SGD, momentum \(0.9\)

SGD, momentum \(0.9\)

weight decay

\(10^{-4}\), weights only

\(10^{-4}\), weights only

last-BN \(\gamma \) at init

\(0\)

\(0\)

BN running-stat decay

\(0.9\)

\(0.9\)

schedule

cosine, 5-epoch warmup

cosine, 5-epoch warmup

val top-1

\(\mathbf{73.98\% }\)

\(\mathbf{74.14\% }\)

val top-5

\(\mathbf{91.70\% }\)

\(\mathbf{91.85\% }\)

The two paths agree to \(0.16\) points on top-1 and \(0.15\) on top-5, and that is inside what one run can resolve. At \(n = 50{,}000\) the 95% Wilson interval is \(\pm 0.38\) points on either top-1 — \([73.59, 74.36]\) for the reference, \([73.75, 74.52]\) for the verified path — so the gap in the table is under half the interval, and at this scale a gap under about \(0.4\) points is not yet a result in either direction. That is the floor every side-by-side table in this book is read against. Paired on the same images the reading is the same: the two checkpoints disagree on \(5{,}807\) of them, \(2{,}863\) one way and \(2{,}944\) the other, and McNemar’s exact test puts \(p = 0.29\) on the \(81\) images between them.

The two halves ran one after the other on the same four cards (runs/2026-10-20-r34-bot-jax, runs/2026-10-20-r34-bot-verified), one seed per arm.

Both paths normalize over the global \(256\): the reference’s @jit mesh reduces the batch statistics across its shards, and each of the verified render’s 36 BatchNorms all-reduces its mean and variance across the four replicas — the data-parallel step Theorem 74 ties to one device’s step at \(256\).

The curves converge; they do not merely end together. The middle of the schedule is noisy on both paths — at peak learning rate SGD moves top-1 by a point or two between consecutive epochs, in both directions, three at the worst on either path — so the row is read for its ends. The verified path leads by one to three points through the first third and the lead closes as the cosine anneals: over the last fifteen epochs the two average within \(0.15\) of each other on top-1 and \(0.20\) on top-5. Table 5-4 is the selected epochs and Figure 5-6 every epoch.

Table 5-4 ResNet-34 validation top-1 on both paths at selected epochs.

epoch

5

15

30

45

60

75

90

reference (JAX)

37.17

51.38

56.84

60.74

64.56

70.36

73.98

verified path (PJRT)

38.89

53.96

57.88

61.02

64.67

70.65

74.14

\(\Delta \)

\(+1.72\)

\(+2.58\)

\(+1.04\)

\(+0.28\)

\(+0.11\)

\(+0.29\)

\(+0.16\)

\begin{tikzpicture} 
\begin{axis}[
    width=0.92\linewidth, height=6.5cm,
    xlabel={Epoch}, ylabel={Validation top-1 (\%)},
    xmin=0, xmax=91, ymin=0, ymax=80,
    xtick={0,15,30,45,60,75,90},
    ytick={20,40,60,80},
    legend pos=south east,
    legend cell align={left},
    grid=major, grid style={gray!18},
    tick label style={font=\small},
    label style={font=\small},
    every axis plot/.append style={line width=1pt, mark=none},
]
\addplot[blue] coordinates {
(1,4.70) (2,17.01) (3,27.88) (4,34.51) (5,37.17) (6,42.77) (7,45.59) (8,47.18) (9,47.22) (10,49.51) (11,49.90) (12,50.92) (13,50.06) (14,52.20) (15,51.38) (16,51.25) (17,54.11) (18,53.17) (19,54.59) (20,53.91) (21,54.14) (22,55.62) (23,55.11) (24,54.76) (25,55.50) (26,54.61) (27,55.20) (28,55.76) (29,57.52) (30,56.84) (31,56.80) (32,55.71) (33,56.87) (34,57.86) (35,59.01) (36,58.95) (37,58.27) (38,59.20) (39,58.67) (40,58.38) (41,59.10) (42,59.59) (43,59.22) (44,57.85) (45,60.74) (46,60.55) (47,59.83) (48,61.51) (49,60.06) (50,61.07) (51,62.35) (52,61.20) (53,63.41) (54,63.47) (55,64.14) (56,63.07) (57,63.57) (58,65.03) (59,64.40) (60,64.56) (61,65.28) (62,65.89) (63,66.05) (64,65.47) (65,66.95) (66,67.14) (67,67.16) (68,67.19) (69,68.41) (70,68.74) (71,68.74) (72,69.30) (73,69.73) (74,70.33) (75,70.36) (76,71.07) (77,71.30) (78,71.59) (79,72.03) (80,72.32) (81,72.85) (82,72.96) (83,73.17) (84,73.43) (85,73.57) (86,73.79) (87,73.96) (88,74.01) (89,73.99) (90,73.98)
};
\addlegendentry{reference (JAX)}
\addplot[red, dashed] coordinates {
(1,4.91) (2,16.80) (3,23.51) (4,34.06) (5,38.89) (6,42.14) (7,44.39) (8,47.12) (9,48.90) (10,49.69) (11,50.77) (12,51.65) (13,51.60) (14,50.57) (15,53.96) (16,53.91) (17,53.64) (18,53.54) (19,53.58) (20,54.55) (21,54.58) (22,55.79) (23,54.37) (24,55.86) (25,55.55) (26,54.45) (27,55.95) (28,56.29) (29,56.70) (30,57.88) (31,57.47) (32,57.88) (33,58.00) (34,57.40) (35,57.64) (36,56.34) (37,56.15) (38,58.67) (39,59.65) (40,58.35) (41,60.01) (42,59.25) (43,59.50) (44,59.93) (45,61.02) (46,59.36) (47,60.64) (48,61.30) (49,62.11) (50,61.84) (51,62.29) (52,60.28) (53,63.22) (54,62.86) (55,62.97) (56,63.65) (57,64.46) (58,64.23) (59,64.21) (60,64.67) (61,65.31) (62,66.17) (63,66.63) (64,66.29) (65,67.09) (66,67.18) (67,68.06) (68,68.01) (69,68.15) (70,68.84) (71,69.52) (72,69.26) (73,69.55) (74,70.40) (75,70.65) (76,71.01) (77,71.55) (78,71.95) (79,71.95) (80,72.60) (81,72.94) (82,73.15) (83,73.35) (84,73.61) (85,73.75) (86,74.02) (87,74.02) (88,74.12) (89,74.11) (90,74.14)
};
\addlegendentry{verified path (PJRT)}
\end{axis}
\end{tikzpicture}
Figure 5-6 ResNet-34 validation top-1 per epoch on both paths: one recipe, two paths to XLA, scored over all \(50{,}000\) validation images at every epoch.

A reasonable stopping point

For the fastest path through this book, the route is roughly:

  • The intro chapters & Ch 1: framework setup (mandatory context).

  • Ch 2–3: MLP and CNN worked examples, the simplest cases of the framework reaching real architectures.

  • Skim Ch 4 (BatchNorm). It’s structurally the hardest chapter and the prose says so up front. The math is right (pdiv_bnNormalize’s three-term cancellation is formalized and matches the codegen), but it’s not the place to spend time if you’re not specifically interested in BN. Spend time running as many MNIST and CIFAR demos as possible.

  • Read this chapter (Ch 5, ResNet-34). Residual blocks are where the chain rule shows it can carry composition through arbitrarily deep architectures, and the proof reduces to one additive-fan-in lemma you’ve already seen. Run the ResNet-50 RSB-A3 ImageNet training demo (§5-7; about a day on four cards).

  • Stop here. Assume Chapters 6–9 (MobileNet, EfficientNet, ConvNeXt, ViT) and the Bestiary (Chapters 10–13) are correct, because they all reuse the same framework with new operators slotting into the same proof tree. Appendix B walks through how that claim is auditable if you ever want to spot-check it.

Reading through Ch 5 is enough to know the framework works. Read the AlphaGo Zero paper (bestiary entry), which combines reinforcement learning with a large residual network of convolutions.

5.6.2 ResNet-50: the 2018 recipe and RSB-A3

The basic block ResNet-34 is built from generalizes to the bottleneck block of ResNet-50 — a \(1\times 1\) down, a \(3\times 3\), a \(1\times 1\) back up — at 25.6M parameters against 21.8M. This section moves one lever at a time. First the architecture, with the recipe held fixed: ResNet-50 on the 2018 recipe of §5-5, unchanged, which is worth \(3.0\) points over ResNet-34 on the reference. Then the recipe, with the architecture held fixed: the same network on RSB-A3 (Wightman et al. 2021, “ResNet Strikes Back”), worth another half point on the reference and a point on the verified path. Both recipes run on both paths, so the result is a grid rather than a pair, and the table that closes the section is the 2018 column every later chapter’s recipe is diffed against.

The reference (JAX)

The reference is jax/MainResnet50Imagenet.lean, the bottleneck backbone on the same tfds stream, with the recipe as an argument. The 2018 row is the ResNet-34 recipe, zero-\(\gamma \) on each bottleneck’s last BatchNorm and the \(0.9\) statistics decay included (A3 keeps both), and BatchNorm’s \(\gamma \), \(\beta \) and the biases undecayed as there. A3 is LAMB at effective batch 2048 (accumulated as \(4\times 512\)), BCE over Mixup/CutMix targets binarized at timm’s \(0.2\) threshold, RandAugment, and train@160 / eval@224. Table 5-5 is both runs.

Table 5-5 ResNet-50 on ImageNet-1k under the 2018 and RSB-A3 recipes, reference trainer (JAX): both rows the final checkpoints scored over all \(50{,}000\) validation images through an fp32 forward, A3 at timm’s eval resize (short side \(\lfloor 224/0.95 \rfloor = 235\), long side truncated as torchvision does).

GPU

Recipe

BN group

Per epoch

Epochs

Total

Val top-1

Val top-5

4\(\times \) 4060 Ti (bf16)

2018

256 (global)

\(\sim \)14.8 min

90

\(\sim \)22.2 hr

\(\mathbf{77.01\% }\)

\(\mathbf{93.51\% }\)

4\(\times \) 4060 Ti (bf16)

RSB-A3

512 (Ghost-BN)

\(\sim \)8.0 min

100

\(\sim \)13.4 hr

\(\mathbf{77.58\% }\)

\(\mathbf{93.66\% }\)

That fp32 scoring is how the verified path and timm score; the trainers’ own evaluation lands within \(0.04\) of it (through bf16 on 2018, fp32 on A3). A3 normalizes per \(512\)-image micro-batch because it accumulates \(4 \times 512\) to reach \(2048\); the 2018 row is a true global batch of \(256\). That is ghost batch norm (Hoffer, Hubara & Soudry 2017, arXiv:1705.08741): the statistics come from a fixed sub-batch rather than the whole batch, here as a side effect of accumulation rather than a choice, and the A3 render below reproduces it by accumulating the same way. The architecture lever: against ResNet-34’s \(73.98\% \) on the same recipe, the bottleneck is worth \(+3.03\) points. The recipe lever: A3 adds \(+0.57\) on the same network, in less wall clock, because it trains at \(160^2\). It lands \(0.52\) under the paper’s \(78.1\% \) and \(0.47\) under the \(78.05\% \) that timm’s own resnet50.a3_in1k weights score under this protocol, a tenth of a point past one run’s \(\pm 0.37\); the verified render of the same recipe, Table 5-6, lands on the paper’s number. This A3 is timm’s recipe as timm ran it, on both paths; the paragraph after the side-by-side table names the settings a bare transcription would miss, and the one not taken. Figure 5-7 is the two runs, epoch by epoch.

\begin{tikzpicture} 
\begin{axis}[
    width=0.92\linewidth, height=6.5cm,
    xlabel={Epoch}, ylabel={Validation top-1 (\%)},
    xmin=0, xmax=101, ymin=0, ymax=80,
    xtick={0,20,40,60,80,100},
    ytick={20,40,60,80},
    legend pos=south east,
    legend cell align={left},
    grid=major, grid style={gray!18},
    tick label style={font=\small},
    label style={font=\small},
    every axis plot/.append style={line width=1pt, mark=none},
    title style={font=\small},
    title={ResNet-50, two recipes --- reference},
]
\addplot[blue] coordinates {
(1,5.96) (2,14.59) (3,25.57) (4,31.70) (5,39.14) (6,43.70) (7,47.19) (8,47.54) (9,50.13) (10,51.22) (11,51.40) (12,50.29) (13,52.43) (14,55.04) (15,54.83) (16,50.97) (17,56.45) (18,55.21) (19,55.80) (20,54.20) (21,54.79) (22,56.15) (23,57.03) (24,57.68) (25,54.74) (26,59.52) (27,56.64) (28,59.46) (29,59.51) (30,58.18) (31,59.38) (32,59.96) (33,60.74) (34,60.81) (35,60.24) (36,58.19) (37,61.50) (38,61.12) (39,62.20) (40,61.34) (41,62.70) (42,63.59) (43,62.40) (44,62.30) (45,63.77) (46,63.70) (47,64.39) (48,64.51) (49,64.38) (50,65.47) (51,65.98) (52,65.86) (53,66.26) (54,66.77) (55,67.20) (56,65.50) (57,65.98) (58,67.78) (59,67.99) (60,68.35) (61,68.89) (62,68.29) (63,69.16) (64,70.55) (65,69.80) (66,70.89) (67,70.60) (68,71.15) (69,72.05) (70,72.13) (71,72.46) (72,72.93) (73,73.18) (74,73.85) (75,74.17) (76,74.15) (77,74.66) (78,74.82) (79,74.92) (80,75.63) (81,75.97) (82,76.03) (83,76.34) (84,76.42) (85,76.70) (86,76.85) (87,76.93) (88,76.96) (89,77.04) (90,77.00)
};
\addlegendentry{2018 (90 ep)}
\addplot[orange] coordinates {
(1,0.10) (2,3.78) (3,7.80) (4,12.45) (5,15.25) (6,22.07) (7,24.09) (8,29.60) (9,35.84) (10,34.52) (11,36.98) (12,29.54) (13,39.61) (14,38.41) (15,40.54) (16,42.34) (17,38.87) (18,38.27) (19,46.92) (20,48.37) (21,41.04) (22,47.52) (23,44.41) (24,42.69) (25,45.13) (26,51.49) (27,43.96) (28,46.55) (29,48.32) (30,38.44) (31,51.10) (32,55.92) (33,51.05) (34,56.30) (35,50.80) (36,57.06) (37,53.79) (38,51.05) (39,57.99) (40,51.12) (41,59.86) (42,54.99) (43,56.75) (44,61.63) (45,56.14) (46,55.46) (47,54.52) (48,57.12) (49,59.17) (50,61.56) (51,60.60) (52,64.51) (53,61.38) (54,64.69) (55,64.75) (56,64.03) (57,61.97) (58,65.52) (59,65.89) (60,68.46) (61,63.52) (62,67.04) (63,65.74) (64,67.69) (65,69.33) (66,69.83) (67,66.58) (68,71.23) (69,71.85) (70,72.07) (71,71.31) (72,71.25) (73,71.92) (74,73.39) (75,73.30) (76,73.74) (77,74.02) (78,74.09) (79,75.13) (80,75.31) (81,75.45) (82,75.41) (83,75.77) (84,76.16) (85,76.40) (86,76.42) (87,76.75) (88,76.72) (89,76.79) (90,77.11) (91,77.25) (92,77.33) (93,77.16) (94,77.42) (95,77.40) (96,77.38) (97,77.40) (98,77.48) (99,77.36) (100,77.60)
};
\addlegendentry{RSB-A3 (100 ep)}
\end{axis}
\end{tikzpicture}
Figure 5-7 Validation top-1 per epoch of the two ResNet-50 recipes on the reference.

A3 starts far lower — \(0.10\% \) at epoch 1 against \(5.96\% \) — because its whole first epoch runs at timm’s warmup start of \(10^{-4}\), an eightieth of the peak, and BCE over mixup targets gives almost no signal until the logits separate; it trails for nearly the whole schedule, touching the lead at four epochs of the second half. At the matched epoch 90 it leads by \(0.11\) (\(77.11\) against \(77.00\)), and its last ten epochs of anneal add the rest. The schedules are different lengths, so at a shared epoch the two cosines are at different points in their decay; the endpoints are what the \(+0.57\) is measured from.

The verified path (PJRT)

The verified trainer is the ResNet-34 one of §5-5 with the bottleneck backbone substituted, and each recipe is its own render:

  • 2018: resnet50in_momdp64wxbf16.

  • A3: resnet50in160_lambaccdp4x128wxclipbcebf16, LAMB \(\times \) BCE-with-logits \(\times \) accumulation \(k=4\) at four replicas of \(128\) to the recipe’s \(2048\), train@160 / eval@224.

They run with:

lake run r50-2018-bf16-4gpu
lake run r50-a3-wxclip4x128-bf16-4gpu
Table 5-6 ResNet-50 on ImageNet-1k under the 2018 and RSB-A3 recipes, verified path (PJRT), scored over the same \(50{,}000\) images under the same protocols: both rows the final checkpoints rescored through an fp32 forward, 2018 at \(224\) and A3 at timm’s \(235\).

GPU

Recipe

BN group

Per epoch

Epochs

Total

Val top-1

Val top-5

4\(\times \) 4060 Ti (bf16)

2018

256 (global)

\(\sim \)20.1 min

90

\(\sim \)30.1 hr

\(\mathbf{76.99\% }\)

\(\mathbf{93.51\% }\)

4\(\times \) 4060 Ti (bf16)

RSB-A3

512 (Ghost-BN)

\(\sim \)13.1 min

100

\(\sim \)21.9 hr

\(\mathbf{78.05\% }\)

\(\mathbf{93.97\% }\)

Both renders all-reduce every BatchNorm’s mean and variance across the four replicas (Theorem 75), so the 2018 render normalizes over the global \(256\) and the A3 render over \(4 \times 128 = 512\), the ghost batch the reference’s accumulation gives it.

The A3 render carries the recipe as §5-8 tabulates it, including the three places a bare transcription would miss: BN \(\gamma /\beta \) and biases excluded from weight decay, LAMB’s gradient clip at timm’s default (after the all-reduce and the accumulation, at threshold \(kC\), Proofs.clipFactor_accum), and the no_weight_decay group left out of layer adaptation; all three are Theorem 76, and the four-fold accumulation is Theorem 77. The optimizer is also tied to the reference numerically, at the update as well as the gradient, seven variants to \(\sim \! 10^{-7}\). Both paths run \(625\) steps an epoch and leave \(0.08\% \) of it unused, because \(4 \nmid 2{,}502\). One thing differs from the reference: Mixup’s \(\lambda \) is drawn host-side, once per \(512\)-image micro-batch where the reference draws once per accumulated step, so agreement there is distributional rather than per-step.

The two paths, side by side

Table 5-7 is the two paths, row by row.

Table 5-7 ResNet-50 on ImageNet-1k: the reference and the verified path, row by row, for both recipes. Both columns are scored over the same \(50{,}000\) validation images under the same protocol; rows above the first rule differ, the evaluation forward on 2018 alone, and every other row but the results is held equal by construction.
 

reference (JAX)

verified path (PJRT)

lowering

JAX \(\to \) XLA

pretty(provenGraph) \(\to \) XLA

Python in the step

yes

none

evaluation forward

bf16 (2018), fp32 (A3)

fp32

precision

bf16 convolutions

bf16 convolutions

classifier head

fp32

fp32

last-BN \(\gamma \) at init

\(0\)

\(0\)

BN running-stat decay

\(0.9\)

\(0.9\)

schedule

cosine, 5-epoch warmup

cosine, 5-epoch warmup

2018 (90 ep)

effective batch

256

256

accumulation

none

none

BN statistic group

256 (global)

256 (global)

steps/epoch

5,004

5,004

optimizer

SGD, momentum \(0.9\)

SGD, momentum \(0.9\)

weight decay

\(10^{-4}\), weights only

\(10^{-4}\), weights only

schedule stepped

per step

per step

train / eval res

224 / 224

224 / 224

val top-1

\(\mathbf{77.01\% }\)

\(\mathbf{76.99\% }\)

val top-5

\(\mathbf{93.51\% }\)

\(\mathbf{93.51\% }\)

RSB-A3 (100 ep)

effective batch

2048

2048

accumulation

\(4 \times 512\)

\(4 \times 512\)

BN statistic group

512 (Ghost-BN)

512 (Ghost-BN)

steps/epoch

625

625

optimizer

LAMB

LAMB

schedule stepped

per epoch (timm)

per epoch (timm)

train / eval res

160 / 224

160 / 224

val top-1

\(\mathbf{77.58\% }\)

\(\mathbf{78.05\% }\)

val top-5

\(\mathbf{93.66\% }\)

\(\mathbf{93.97\% }\)

The BatchNorm statistic group is the setting that changes between the blocks, on both paths at once: the 2018 recipe normalizes over the global \(256\), and A3 over the \(512\)-image micro-batch its accumulation hands each step — ghost batch norm, on the reference and the verified path alike. A3 is timm’s recipe as timm ran it, in the settings a transcription of the paper would miss: the cosine stepped once an epoch, from \(10^{-4}\) over the five warmup epochs to the peak and down to a floor of \(10^{-6}\); Mixup or CutMix drawn per batch at \(p = 0.5\); RandAugment’s fill at the image mean; the BCE targets binarized at \(0.2\) — on both paths (r50-a3-jax-4gpu / r50-a3-wxclip4x128-bf16-4gpu, \(13.4 + 21.9\) h on the 4060 Ti box). Not taken, on either path: timm’s then-default cooldown_epochs 10 at the floor (the paper counts 100 epochs, and \(10^{-6}\) moves nothing).

Both A3 numbers are the final checkpoints rescored at timm’s eval resize, the short side at \(\lfloor 224 / 0.95 \rfloor = 235\) and the long side truncated as torchvision’s Resize does. The trainers scored them at \(236\) with the long side rounded, \(77.61\) / \(78.08\); the move is \(-13\) and \(-14\) images on top-1 and \(+24\) and \(+60\) on top-5, a rounding-sized protocol difference, and the pair keeps its order and its gap under either.

The two paths tie on 2018 and part on A3. On 2018 the gap is \(0.02\) points on top-1 and none on top-5, at one seed per cell (r50-2018-jax-4gpu / r50-2018-bf16-4gpu, \(22.2 + 30.1\) h on the same box, one after the other). Paired on the same images, the two checkpoints disagree on \(4{,}641\), \(2{,}326\) against \(2{,}315\), and McNemar’s exact test puts \(p = 0.88\) on the eleven-image difference: the ResNet-34 reading again. On A3 the verified path lands ahead, by \(0.47\) on top-1 and \(0.31\) on top-5, a tenth of a point past the 95% Wilson interval, \(\pm 0.37\) at \(77\% \) on \(50{,}000\) images. Paired, the A3 checkpoints disagree on \(4{,}017\), \(1{,}891\) against \(2{,}126\), and the \(235\)-image lead is \(p = 0.0002\) (each bitmap is from the same scoring of the final checkpoint as its headline). With one seed per arm a paired lead is a statement about two trained models, not about the two paths — the eight to nine percent of images the two disagree on is what two runs of one recipe produce, whichever is ahead — and whether A3’s lead is the path or the seed needs seeds per arm. The recipe delta keeps its sign on both paths: A3 beats 2018 by \(+0.57\) on the reference and \(+1.06\) on the verified path. A difference that reproduces across two independent implementations is harder to arrange by accident than agreement at one configuration, and it is what a one-row comparison cannot show; the half-point spread in its size is what four single-seed runs leave unresolved. One thing the table does not settle: the A3 column trains at \(160\) and evaluates at \(224\) while 2018 does both at \(224\), so the recipe delta confounds resolution with recipe, by A3’s own design.

The 2018 curves travel together; the A3 curves close most of the way. On 2018 the two paths swing both ways through the schedule, as ResNet-34’s do — the verified path minus the reference averages \(-0.61\), \(+0.17\), \(-0.58\), \(-0.19\) and \(+0.06\) over successive twenty-epoch windows, six points apart at the widest. On A3 the verified path runs one to three points ahead through the middle of the schedule — \(+0.58\), \(+3.06\), \(+1.45\) and \(+1.42\) by the same windows, eleven points at the widest — and the anneal closes most of it, to \(+0.50\) over the last twenty epochs and \(+0.53\) over the last ten, which is the \(0.47\) at the endpoint. Where A3’s route difference lives is not established; the one difference between its columns that 2018’s do not share is where the Mixup/CutMix draw happens, once per \(512\)-image micro-batch on the verified path against once per accumulated step on the reference, and it is untested. It is why these pairs are read at their endpoints and nowhere else (the rows are the runs’ own per-epoch evaluation, the reference’s through its bf16 forward on 2018 and through fp32 on A3; the headline numbers are the final checkpoints scored through fp32, within \(0.04\) of the last column). Table 5-8 is the selected epochs and Figure 5-8 every epoch.

Table 5-8 ResNet-50 validation top-1 at selected epochs, both recipes on both paths. These are the in-training reads, as are the curves below; the 2018 reference’s headline \(77.01\) is the fp32 rescore of the same epoch-90 checkpoint, read in training as \(77.00\), and the A3 headlines \(77.58\) / \(78.05\) are the epoch-100 checkpoints rescored at timm’s \(235\), read in training as \(77.60\) / \(78.08\).

epoch

5

15

30

45

60

75

90

100

2018 (90 ep)

reference (JAX)

39.14

54.83

58.18

63.77

68.35

74.17

77.00

—

verified path (PJRT)

35.42

53.24

59.48

64.24

68.91

73.16

76.99

—

\(\Delta \)

\(-3.72\)

\(-1.59\)

\(+1.30\)

\(+0.47\)

\(+0.56\)

\(-1.01\)

\(-0.01\)

 

RSB-A3 (100 ep)

reference (JAX)

15.25

40.54

38.44

56.14

68.46

73.30

77.11

77.60

verified path (PJRT)

16.43

45.13

49.80

60.26

67.48

74.59

77.56

78.08

\(\Delta \)

\(+1.18\)

\(+4.59\)

\(+11.36\)

\(+4.12\)

\(-0.98\)

\(+1.29\)

\(+0.45\)

\(+0.48\)

\begin{tikzpicture} 
\begin{axis}[
    width=0.49\linewidth, height=6cm,
    xlabel={Epoch}, ylabel={Validation top-1 (\%)},
    xmin=0, xmax=91, ymin=0, ymax=80,
    xtick={0,15,30,45,60,75,90},
    ytick={20,40,60,80},
    legend pos=south east,
    legend cell align={left},
    legend style={font=\scriptsize},
    grid=major, grid style={gray!18},
    tick label style={font=\small},
    label style={font=\small},
    title style={font=\small},
    title={2018 (90 ep)},
    every axis plot/.append style={line width=1pt, mark=none},
]
\addplot[blue] coordinates {
(1,5.96) (2,14.59) (3,25.57) (4,31.70) (5,39.14) (6,43.70) (7,47.19) (8,47.54) (9,50.13) (10,51.22) (11,51.40) (12,50.29) (13,52.43) (14,55.04) (15,54.83) (16,50.97) (17,56.45) (18,55.21) (19,55.80) (20,54.20) (21,54.79) (22,56.15) (23,57.03) (24,57.68) (25,54.74) (26,59.52) (27,56.64) (28,59.46) (29,59.51) (30,58.18) (31,59.38) (32,59.96) (33,60.74) (34,60.81) (35,60.24) (36,58.19) (37,61.50) (38,61.12) (39,62.20) (40,61.34) (41,62.70) (42,63.59) (43,62.40) (44,62.30) (45,63.77) (46,63.70) (47,64.39) (48,64.51) (49,64.38) (50,65.47) (51,65.98) (52,65.86) (53,66.26) (54,66.77) (55,67.20) (56,65.50) (57,65.98) (58,67.78) (59,67.99) (60,68.35) (61,68.89) (62,68.29) (63,69.16) (64,70.55) (65,69.80) (66,70.89) (67,70.60) (68,71.15) (69,72.05) (70,72.13) (71,72.46) (72,72.93) (73,73.18) (74,73.85) (75,74.17) (76,74.15) (77,74.66) (78,74.82) (79,74.92) (80,75.63) (81,75.97) (82,76.03) (83,76.34) (84,76.42) (85,76.70) (86,76.85) (87,76.93) (88,76.96) (89,77.04) (90,77.00)
};
\addlegendentry{reference (JAX)}
\addplot[red, dashed] coordinates {
(1,5.80) (2,15.34) (3,19.49) (4,32.05) (5,35.42) (6,41.81) (7,46.22) (8,47.71) (9,49.21) (10,49.50) (11,51.94) (12,53.85) (13,54.48) (14,55.14) (15,53.24) (16,54.34) (17,50.38) (18,55.54) (19,54.45) (20,55.15) (21,56.02) (22,57.05) (23,55.17) (24,58.15) (25,57.96) (26,58.44) (27,57.83) (28,57.18) (29,59.56) (30,59.48) (31,60.48) (32,58.45) (33,59.86) (34,60.54) (35,61.37) (36,61.04) (37,61.23) (38,61.91) (39,62.48) (40,58.37) (41,63.15) (42,61.79) (43,60.84) (44,64.15) (45,64.24) (46,60.10) (47,64.58) (48,63.08) (49,61.42) (50,63.38) (51,65.06) (52,64.26) (53,65.38) (54,65.30) (55,66.79) (56,67.04) (57,67.81) (58,67.46) (59,68.45) (60,68.91) (61,69.34) (62,68.03) (63,70.14) (64,69.50) (65,68.51) (66,70.04) (67,69.85) (68,71.19) (69,71.90) (70,72.12) (71,72.70) (72,73.32) (73,72.60) (74,73.49) (75,73.16) (76,74.21) (77,74.39) (78,75.00) (79,75.46) (80,75.62) (81,76.13) (82,76.18) (83,76.44) (84,76.57) (85,76.67) (86,76.96) (87,77.01) (88,76.98) (89,76.96) (90,76.99)
};
\addlegendentry{verified path (PJRT)}
\end{axis}
\end{tikzpicture}
\begin{tikzpicture} 
\begin{axis}[
    width=0.49\linewidth, height=6cm,
    xlabel={Epoch},
    xmin=0, xmax=101, ymin=0, ymax=80,
    xtick={0,20,40,60,80,100},
    ytick={20,40,60,80},
    legend pos=south east,
    legend cell align={left},
    legend style={font=\scriptsize},
    grid=major, grid style={gray!18},
    tick label style={font=\small},
    label style={font=\small},
    title style={font=\small},
    title={RSB-A3 (100 ep)},
    every axis plot/.append style={line width=1pt, mark=none},
]
\addplot[blue] coordinates {
(1,0.10) (2,3.78) (3,7.80) (4,12.45) (5,15.25) (6,22.07) (7,24.09) (8,29.60) (9,35.84) (10,34.52) (11,36.98) (12,29.54) (13,39.61) (14,38.41) (15,40.54) (16,42.34) (17,38.87) (18,38.27) (19,46.92) (20,48.37) (21,41.04) (22,47.52) (23,44.41) (24,42.69) (25,45.13) (26,51.49) (27,43.96) (28,46.55) (29,48.32) (30,38.44) (31,51.10) (32,55.92) (33,51.05) (34,56.30) (35,50.80) (36,57.06) (37,53.79) (38,51.05) (39,57.99) (40,51.12) (41,59.86) (42,54.99) (43,56.75) (44,61.63) (45,56.14) (46,55.46) (47,54.52) (48,57.12) (49,59.17) (50,61.56) (51,60.60) (52,64.51) (53,61.38) (54,64.69) (55,64.75) (56,64.03) (57,61.97) (58,65.52) (59,65.89) (60,68.46) (61,63.52) (62,67.04) (63,65.74) (64,67.69) (65,69.33) (66,69.83) (67,66.58) (68,71.23) (69,71.85) (70,72.07) (71,71.31) (72,71.25) (73,71.92) (74,73.39) (75,73.30) (76,73.74) (77,74.02) (78,74.09) (79,75.13) (80,75.31) (81,75.45) (82,75.41) (83,75.77) (84,76.16) (85,76.40) (86,76.42) (87,76.75) (88,76.72) (89,76.79) (90,77.11) (91,77.25) (92,77.33) (93,77.16) (94,77.42) (95,77.40) (96,77.38) (97,77.40) (98,77.48) (99,77.36) (100,77.60)
};
\addlegendentry{reference (JAX)}
\addplot[red, dashed] coordinates {
(1,0.10) (2,4.49) (3,8.11) (4,12.52) (5,16.43) (6,20.07) (7,28.33) (8,23.54) (9,33.54) (10,34.18) (11,38.07) (12,33.68) (13,42.13) (14,40.00) (15,45.13) (16,45.67) (17,41.14) (18,38.11) (19,43.95) (20,47.85) (21,48.74) (22,49.49) (23,47.48) (24,49.67) (25,52.36) (26,48.60) (27,51.62) (28,50.02) (29,51.42) (30,49.80) (31,58.00) (32,56.67) (33,52.10) (34,44.59) (35,49.56) (36,58.37) (37,57.34) (38,55.91) (39,56.88) (40,58.38) (41,58.92) (42,55.15) (43,58.13) (44,58.33) (45,60.26) (46,60.95) (47,62.26) (48,60.21) (49,63.54) (50,61.40) (51,64.46) (52,64.52) (53,62.52) (54,64.12) (55,65.06) (56,63.69) (57,66.43) (58,63.67) (59,66.79) (60,67.48) (61,69.10) (62,68.69) (63,69.66) (64,69.97) (65,70.84) (66,71.26) (67,70.43) (68,71.89) (69,71.06) (70,72.77) (71,71.94) (72,73.21) (73,73.01) (74,74.17) (75,74.59) (76,73.62) (77,74.44) (78,75.13) (79,75.44) (80,75.45) (81,75.92) (82,76.31) (83,76.30) (84,76.68) (85,76.41) (86,76.95) (87,77.14) (88,76.82) (89,77.57) (90,77.56) (91,77.56) (92,77.76) (93,77.85) (94,77.88) (95,77.99) (96,77.93) (97,78.02) (98,78.02) (99,78.03) (100,78.08)
};
\addlegendentry{verified path (PJRT)}
\end{axis}
\end{tikzpicture}
Figure 5-8 ResNet-50 validation top-1 per epoch, both recipes on both paths, scored over all \(50{,}000\) validation images at every epoch.

What A3 changes from the 2018 recipe

The 2018-recipe row above is the original paper’s SGD-with-momentum plus the “bag of tricks” polish (cosine schedule, warmup, label smoothing, random-resized-crop). RSB-A3 is the 2021 “ResNet Strikes Back” recipe (Wightman et al.). Both rows are ResNet-50, which is the point: every knob that A3 sets differently is one lever of the three-year jump in how these networks are trained, and holding the architecture fixed is what stops the comparison confounding the recipe with the backbone. The backbone and the entry point are the same for both, and each recipe has its own spec, shim and render. Table 5-9 is the full diff.

Table 5-9 The RSB-A3 recipe against the 2018 recipe on ResNet-50, knob by knob.

Knob

ResNet-50 (2018)

RSB-A3 (2021)

What the A3 choice buys

Backbone

bottleneck, 25.6 M

bottleneck, 25.6 M

unchanged, which is what makes the rest of this table a recipe diff

Optimizer

SGD + momentum \(0.9\)

LAMB

layer-wise adaptive LR, designed for very large batches

Effective batch

256

2048 (\(512\times 4\) accum)

the large-batch regime LAMB targets; grad-accum fits it on 16 GB

Peak LR

\(0.1\)

\(0.008\) @ bs2048

LAMB’s trust-ratio scale — not comparable to SGD’s \(0.1\)

Epochs

90

100

essentially the same budget (A3 is the cheap RSB tier)

Loss

softmax cross-entropy

BCE-with-logits, multi-hot, binarized at \(0.2\)

treats classes independently; matches mixed soft targets

Label smoothing

\(0.1\)

\(0.0\)

subsumed — BCE over soft mixup labels already softens targets

Augmentation

RRC + hflip

RRC + hflip

unchanged, and it is the BASE both recipes train on; the two rows below are what A3 layers on top of it

Mixup / CutMix

none

\(\alpha \, 0.1\) / \(\alpha \, 1.0\)

interpolate/paste samples \(\to \) soft multi-hot targets, strong regularizer

RandAugment

none

m6 N2 mstd0.5 inc1

automated heavy color+geometric augmentation policy

Weight decay

\(10^{-4}\), all params

\(0.02\), skip BN \(\gamma /\beta \) + bias

\(200\times \) stronger, but never decays scale/shift/bias params

Train / eval res

\(224\) / \(224\)

\(160\) / \(224\)

FixRes: train at cheap low res (\(\sim \)2\(\times \) faster/step), test at high res — the train/test gap helps

BN statistics

global over batch

Ghost-BN over 512

side-effect of grad-accum (each micro-step normalizes its own 512); a single big-memory card would remove it

What carries over unchanged: cosine LR decay, the 5-epoch warmup, the base augmentation in the row above, zero-\(\gamma \) initialization, bf16 convolution under an fp32 classifier head, and running-BN evaluation. The through-line of the 2018\(\to \)2021 shift is “regularize harder, train bigger”: heavier augmentation (RandAugment + Mixup + CutMix), a loss that accepts soft targets (BCE), an order-of-magnitude more weight decay applied selectively, and a large-batch optimizer (LAMB) to make the longer, noisier training converge, with FixRes buying back the compute the extra epochs would cost. On the reference every one of these is a one-line TrainConfig change; on the verified path each is a render variant or a shim setting on the same backbone, and the \(160\) train resolution is a spec of its own.

5.7 Side quest: the A2 and A1 recipes

A3 is the cheap tier of “ResNet Strikes Back”. The other two — A2 at 300 epochs and A1 at 600, both at \(224^2\) — render on the verified path as a2 and a1 (the reference’s a2-accum and a1), and neither has been run. Table 5-10 is the three tiers.

Table 5-10 The three ResNet Strikes Back tiers on the verified path: each tier at four replicas of \(128\), in both precisions.

tier

epochs

train res

verified render

top-1

A3 (rsb-faithful)

100

\(160\)

shipped, and run (§5-7)

\(\mathbf{78.05\% }\)

A2 (a2)

300

\(224\)

rendered, sd \(0.05\), no EMA

\(79.8\% \) (paper)

A1 (a1)

600

\(224\)

rendered, sd \(0.05\), no EMA

\(80.4\% \) (paper)

Under synchronised BatchNorm that is the recipe’s ghost batch of \(512\) per micro-step, as for A3. A1 is its own render rather than A2’s with a flag: the weight decay is baked into the artifact, \(0.01\) against A2’s \(0.02\).

A full schedule. Table 5-11 is four cards, each path’s own trainer timed on real ImageNet at the job’s shape — four replicas of \(128\) accumulated four times to \(2048\), \(625\) optimizer steps an epoch; the rates are the 2026-10-07 re-probe (runs/2026-10-07-d17-reprobe/), after torchvision’s crop and the bicubic RandAugment geometry entered the shims.

Table 5-11 Measured step time and projected training time for A2 and A1 on four cards, on each path. A step is one optimizer step, four micro-batches; the verified column is four times its measured micro-batch; totals are training steps alone, and evaluation adds a few hours a run.
   

reference (JAX)

verified path (PJRT)

4 cards, bf16

epochs

ms/step

total

ms/step

total

A2

\(300\)

\(1{,}440\)

\(75\) h  (\(3.1\) d)

\(1{,}904\)

\(99\) h  (\(4.1\) d)

A1

\(600\)

\(1{,}441\)

\(150\) h  (\(6.3\) d)

\(1{,}852\)

\(193\) h  (\(8.0\) d)

Table 5-12 is what the two renders carry, and where they stop short of the paper.

Table 5-12 What the A2 and A1 renders carry, and what they still leave out.

In

Still out

BCE target threshold \(0.2\) (timm’s –bce-target-thresh), applied in each recipe’s own shim after mixing; A1 adds label smoothing \(0.1\)

Model EMA — RSB Table 2 runs none; the emalamb twins stay on disk unused

Stochastic depth \(0.05\) — sixteen sites, one per bottleneck, on the residual branch

Repeated augmentation is stream-level, not per-batch: timm’s index-level RASampler

Repeated augmentation \(3\times \); both tiers, four replicas

Neither tier has been run

Bicubic RandAugment geometry, matched to PIL’s as timm resolves it, over torchvision’s random-resized crop, the fill at the image mean

 

timm’s schedule and draw, as A3 ran them above: the cosine stepped once an epoch from \(10^{-4}\) to the floor \(10^{-6}\), Mixup or CutMix drawn per batch at \(p = 0.5\)

 

A1’s own weight decay (\(0.01\)) and own shim (Mixup \(\alpha \, 0.2\), label smoothing \(0.1\))

 

As A3: wdExcludeNormBias, LAMB’s clip at \(kC\) after the all-reduce and the accumulation, BCE-with-logits over Mixup/CutMix soft labels, accumulation to \(2048\) at \(k=4\)

 

Also as A3: zero-\(\gamma \) bottlenecks, BN decay \(0.9\), the fp32 classifier head, evaluation at timm’s \(0.95\) crop

 

Optimizer tied at the update, seven variants to \({\sim }10^{-7}\)

 

[TODO: timm’s index-level RASampler for the \(3\times \) repeats (both tiers’ code places the three copies adjacent in one optimizer step; A3 has no repeated augmentation, so no landed run carries it); neither tier has run — r50-a2-bf16-4gpu / r50-a1-bf16-4gpu, r50-a2accum-jax-4gpu / r50-a1-jax-4gpu.]

5.8 Recap

The residual block’s backward is a sum: the branch’s VJP plus the identity, or the projection’s where the shapes change. That is additive fan-in, and with the global average pool, the stride-2 convolution and the stem pool it is everything ResNet needs. Theorems 65 and 66 are the full-depth networks at batch statistics, the seals say their backward is not degenerate at the trained width, the steps are tied (Theorems 68 and 70), and the data-parallel twins (Theorem 74) say four cards computing a synchronised step compute one card’s step at four times the batch. This is also the chapter that sets the ImageNet pattern every later chapter repeats: a reference trainer, the verified path, the two side by side at the end, and the recipe measured one ingredient at a time rather than inherited.

What the chapter does not settle is the top of the recipe ladder: A3 ran on both paths and the verified render landed on the paper’s number, while A2 and A1 are rendered and timed, not run. What the network costs is parameters: a standard convolution does spatial mixing and channel mixing in one kernel and pays for both. The next chapter factors them, and the network that results has a ninth of the parameters.