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 an hour and a half of GPU time:

lake exe cache get                            # Mathlib oleans, ~30 s
./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-08-12-r34-imagenette-xla-cuda/. XLA’s startup banner is removed, the network description line is wrapped, and epochs 5 through 75 are elided:

[pjrt_ffi] XLA backend: PJRT 0.112, 1 device(s)
[pjrt_ffi] compiled verified_mlir/resnet34_adam_train_step.mlir
             (@resnet34_adam_train_step, 405 outputs, 1 replica) in 4952 ms
[pjrt_ffi] compiled verified_mlir/resnet34_fwd.mlir
             (@resnet34_fwd, 1 outputs, 1 replica) in 409 ms
[pjrt_ffi] compiled verified_mlir/resnet34_fwd_eval.mlir
             (@resnet34_fwd_eval, 1 outputs, 1 replica) in 195 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
  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=2.049761 lr=0.000333
  epoch 1: val_acc = 1083/3925 = 27.592357%  top5 = 2601/3925 = 66.267516%
Epoch 2/80: loss=1.530066 lr=0.000667
  epoch 2: val_acc = 1363/3925 = 34.726115%  top5 = 3024/3925 = 77.044586%
Epoch 3/80: loss=1.388131 lr=0.001000
  epoch 3: val_acc = 2250/3925 = 57.324841%  top5 = 3554/3925 = 90.547771%
Epoch 4/80: loss=1.300556 lr=0.001000
  epoch 4: val_acc = 2510/3925 = 63.949045%  top5 = 3642/3925 = 92.789809%
Epoch 76/80: loss=0.502019 lr=0.000007
  epoch 76: val_acc = 3522/3925 = 89.732484%  top5 = 3858/3925 = 98.292994%
Epoch 79/80: loss=0.502105 lr=0.000000
  epoch 79: val_acc = 3522/3925 = 89.732484%  top5 = 3857/3925 = 98.267516%
Epoch 80/80: loss=0.502114 lr=0.000000
  epoch 80: val_acc = 3521/3925 = 89.707006%  top5 = 3857/3925 = 98.267516%
done (trained ResNet-34 adam + cosine/warmup via packed threading).

Eighty epochs at about 65 seconds each, roughly an hour and a half in total, and 89.71% top-1 with 98.27% top-5 on Imagenette’s 3,925-image validation split. The best epoch reached \(89.86\% \). That is a thirty-four-layer network trained from scratch on ten thousand photographs, which is a different kind of problem from the handwritten digits three chapters back.

Thirty-four layers on, nothing about the claim has changed. That run did not execute a reimplementation of the network this chapter describes. It executed verified_mlir/resnet34_adam_train_step.mlir, which is pretty(provenGraph) off the renderer, and every one of the sixteen residual blocks in it computes the additive fan-in backward that Theorem 37 proves. The thirty-six BatchNorms compute the three-term backward of Chapter 4, and the convolutions compute the backward of Chapter 3. Every theorem in the rest of this chapter is about the graph that just trained.

Three compile lines rather than the two earlier chapters showed, and the third is the interesting one. @resnet34_fwd_eval is a separate forward, because BatchNorm behaves differently at training time than at evaluation time. Training normalizes each batch by its own statistics, while evaluation has to use the running averages accumulated over training, and the line running-stats BN: 36 layers, 17024 stat floats is the driver saying it is threading those averages out of the train step and into the eval forward. A net that skips this reports chance forever, since the eval forward normalizes by statistics nobody ever computed.

The final loss settles at \(0.502\) and stops. That is not the optimizer giving up. It is the label-smoothing floor derived in §5.5: with \(\varepsilon = 0.1\) over ten classes the smallest attainable cross-entropy is about \(0.485\), so a run that lands at \(0.502\) has essentially converged.

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. We already proved in Chapter 1 that the VJP of an additive fan-in is “send the upstream gradient through both branches and add.” We already proved that the VJP of \(\mathrm{id}\) is the identity on the gradient (and that the VJP of \(f\) is whatever the inner block’s machinery gives us). Composition is just \(\mathrm{vjp\_ comp}\). So the residual VJP is the additive fan-in lemma, with \(\mathrm{id}\) plugged into one slot.

That is Theorem 37 below, and its proof is one line. This chapter has exactly one theorem, 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 \(\sim \)4096-to-512 dense layer that contained tens of millions of 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. ConvNeXt swaps in different blocks. MobileNetV2 swaps in depthwise-separable blocks. EfficientNet adds compound scaling. 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 theorem

Theorem 37 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 11) at \(g = \mathrm{id}\) (differentiable; its VJP is Theorem 13), 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.

5.3 Example: ResNet-34 on Imagenette

Same template as the MNIST and CIFAR chapters, but at the scale the architecture was designed for. ResNet-34 on 224\(\times \)224 Imagenette: 10 classes, real photographs, the transition point from “classroom dataset” to “actual image recognition.”

The residual block itself is the whole trick: instead of \(y = f(x)\), compute \(y = f(x) + x\). That’s the additive-fan-in pattern of § 11 with \(\mathrm{id}\) on one branch, which is the composition § 37 proves. Stacking 34 layers worth of these blocks just composes the fan-in rule 34 times (16 residual blocks deep and 34 convs across all of them).

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. The 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}

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, so it is worth reading once here rather than four more times later.

import LeanMlir

-- 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
  nClasses := 10
  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 ]
  bnChannels := #[64, 64,64, ... ]   -- all 36, in forward order

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

-- 3. The entry point: AdamW, cosine with a 3-epoch warmup, baseLR 1e-3.
def main (argv : List String) : IO Unit :=
  resnet34Verified.toNet.trainAdamSched
    resnet34Config (argv.head?.getD "data") 0.001 0.9 0.999 3 "adam"

Four things in that listing carry weight, and they are the four to copy.

slug names the artifact. resnet34 resolves to verified_mlir/resnet34_adam_train_step.mlir, which is pretty(provenGraph): the text the printer emits from the graph the theorems are about. The trainer does not build a network from this spec at run time. It loads that file. The spec’s job is to say which file, and to be #guarded against it so the two cannot drift.

bnChannels is not decoration. It lists every BatchNorm’s channel count in forward order, and it is what makes trainAdamSched thread running statistics out of the train step and into a separate evaluation forward. A net that declares BN layers and then trains through a driver without that threading evaluates against statistics nobody computed, and reports chance forever. That is a real failure mode, not a hypothetical, and §5.1’s third compile line is where you can see the machinery that avoids it.

trainAdamSched, not .train. The generic driver is where the optimizer schedule, the augmentation pipeline and the BN threading live. .train is the simpler peer from the MNIST chapters and it has none of the last one.

The recipe is arguments, not architecture. The learning rate, the momentum pair, the warmup length and the variant string are all passed in. Swapping AdamW for momentum is a different argument against the same proven gradient, which is exactly what Chapter 4 used to measure optimizers against each other.

What .residualStage expands to. It is a constructor, not a magic word, and the whole of it is twelve lines in LeanMlir/VerifiedSpec.lean. Every layer reports the parameter tensors it contributes as (dims, initKind) pairs, where 0 is He(fan-in), 1 is ones (\(\gamma \)) and 2 is zeros (\(\beta \) and biases):

/-- identity basic block @ `c`: two conv->BN->relu units, no projection. -/
private def idBlk (c : Nat) : Array (Array Nat × Nat) :=
  #[(#[c,c,3,3],0),(#[c],1),(#[c],2), (#[c,c,3,3],0),(#[c],1),(#[c],2)]

/-- downsampling basic block `cin->c`: two conv->BN->relu + the 1x1
    option-B projection shortcut (He et al. §3.3). -/
private def downBlk (cin c : Nat) : Array (Array Nat × Nat) :=
  #[(#[c,cin,3,3],0),(#[c],1),(#[c],2), (#[c,c,3,3],0),(#[c],1),(#[c],2),
    (#[c,cin,1,1],0),(#[c],1),(#[c],2)]

private def stageSpec (ic oc count stride : Nat)
    : Array (Array Nat × Nat) := Id.run do
  let mut a := if stride != 1 || ic != oc then downBlk ic oc else idBlk oc
  for _ in [0:count-1] do a := a ++ idBlk oc
  return a

def toSpecs : VLayer -> Array (Array Nat × Nat)
  | dense ic oc             => #[(#[ic,oc],0),(#[oc],2)]      -- Ch. 1
  | relu                    => #[]                            -- Ch. 2
  | conv ic oc k _          => #[(#[oc,ic,k,k],0),(#[oc],2)]  -- Ch. 3
  | maxPool _ _             => #[]                            -- Ch. 3
  | flatten                 => #[]                            -- Ch. 3
  | bnPerChannel oc         => #[(#[oc],1),(#[oc],2)]         -- Ch. 4
  | residualStage ic oc n s => stageSpec ic oc n s            -- here
  | globalAvgPool           => #[]                            -- here
  | bottleneckStage i o n s => bottleneckStageSpec i o n s    -- R50, below
  | ...                        -- 12 more, one per later chapter's block

Read it against the spec above. .residualStage 64 128 4 2 carries stride = 2, so the condition fires: the first block is a downBlk — two \(3\times 3\) convs plus the \(1\times 1\) projection that lets the skip cross a channel change — and the other three are idBlks. Nine tensors plus three sixes, twenty-seven in the stage. .residualStage 64 64 3 1 has stride = 1 and ic = oc, so the condition is false, no projection is emitted, and all three blocks are identity: eighteen tensors. That one-line conditional is the entire difference between a stage that changes shape and one that does not, and it is the same dispatch .bottleneckStage uses for ResNet-50.

It also lets you check the spec against itself. Counting BatchNorms out of those twelve lines — one for the stem, then \(3 \times 2\), \(3 + 3 \times 2\), \(3 + 5 \times 2\) and \(3 + 2 \times 2\) for the four stages — gives \(1 + 6 + 9 + 13 + 7 = 36\), which is exactly the length bnChannels declares and the thirty-six BatchNorms §5.3 says the graph computes. Nothing in the listing is asserted twice; the second number is derived from the first.

This is the first chapter that uses the full production recipe (Adam + cosine + warmup + weight decay + augmentation + label smoothing) rather than the s4tfBaseline. 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. That’s the architectural move that drops ResNet-34 from what would have been a \(\sim \)200M parameter AlexNet-era spec down to 21.3M. 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 at about 65 seconds each on one RTX 4060 Ti, roughly an hour and a half, finishing at 89.71% top-1 and 98.27% top-5 on Imagenette’s 3,925-image validation split. That is the going rate for a scratch-trained ResNet-34 at this resolution and data size. Rather than reprint the log, here is what is worth noticing in it.

- 21.3M parameters, and 729 KB of MLIR (728 801 chars) for the entire AdamW training step. That is \(112\times \) the MNIST MLP’s render and only \(6\times \) the wide CIFAR BatchNorm net’s, against a network that is far more than six times the earlier one. Emission is per-op, not per-parameter, so \(3 \times 3\) convolutions at 64 and at 512 channels cost the same handful of lines. Depth grows the text linearly, and width does not grow it at all.

- Per-step time is about 220 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 does \(3 \times 3 \times 64 \times 64\) multiply-accumulates per output spatial location, about \(3.4 \times 10^8\) FLOPs per forward image, times the batch, times three blocks at that width, times two for the backward.

- Three artifacts, not one. The train step is accompanied by resnet34_fwd (98 383 chars) and resnet34_fwd_eval (76 720 chars). The last exists only because BatchNorm evaluates differently than it trains, and it is the concrete cost of the bnChannels line in the spec above.

- Final loss plateaus near 0.50, not zero. That’s the label-smoothing floor: with \(\epsilon = 0.1\) and 10 classes, the minimum possible cross-entropy against a smoothed target is \(-0.9 \log 0.9 - 0.1 \log (0.1/9) \approx 0.485\). The 0.50 value at epoch 80 means 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 residual blocks across the four stages (3+4+6+3). Plus the stem. That is 33 from the .residualStage expansions plus 3 stem and projection layers, for 36 total. Each one proves its own VJP via § 36 and composes with the rest of the network via § 10.

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 37 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. residual_has_vjp_at 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 already proven in Chapters 3 and 4. The whole block, and a tower of sixteen of them, composes by the chain rule vjp_comp_at (§ 10).

The gap and how we close it. The residual fan-in is the structural heart of a deep ResNet. The emitted backward is given a denotation valued in the proofs’ own tensor type, shown equal to residual_has_vjp_at’s backward, and the structural move is visible in the emitted text. The forward saves \(\texttt{add} = f(x) + x\) and the block output \(\texttt{relu}(\texttt{add})\). The backward first pushes the incoming cotangent back through that relu (a compare/select at the post-add, giving %dadd), and then, and this is the fan-in, sends %dadd through both branches and adds (the inner block’s forward and \(f\)-backward elided, as they are the conv/BN nodes of Chapters 34):

// forward (saved): %out = f(%x)  [conv-BN-relu-conv-BN, Ch. 4-5]
//                  %add = %out + %x
%madd = stablehlo.compare GT, %add, %zc
          : (tensor<1x2x4x4xf32>, tensor<1x2x4x4xf32>)
            -> tensor<1x2x4x4xi1>
%dadd = stablehlo.select %madd, %dOut, %zc
          : tensor<1x2x4x4xi1>, tensor<1x2x4x4xf32>
// %dF = f.backward(%dadd)  [conv/BN/relu input-VJP chain, Ch. 4-5]
%dx = stablehlo.add %dF, %dadd : tensor<1x2x4x4xf32>
return %dx : tensor<1x2x4x4xf32>

Read it against the theorem: %dadd is the cotangent at the fan-in, and it appears twice in the last two lines, once as the input to \(f\)’s backward (%dF, the gradient through the residual function) and once added directly (%dx = %dF + %dadd, the gradient straight down the identity skip). That single stablehlo.add is “send the gradient through both branches and add”: the skip term is the same %dadd the block term started from, which is why the gradient survives all sixteen blocks instead of attenuating away. The inner \(f\) backward that computes %dF, the convolution and BatchNorm input-VJPs, is bridged op by op exactly as in Chapters 3 and 4. Only the fan-in is new here.

Caveats.

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

  • The post-add ReLU is a smooth-point bridge (no pre-activation exactly zero), and the two BatchNorms inside \(f\) carry their own \(\epsilon {\gt} 0\) conditions from Chapter 4.

  • Representative scale, a two-channel block at \(4\times 4\). The same %dadd fan-in repeats for all sixteen blocks.

5.5 What’s in the production recipe?

Six ingredients first appear here, none of which are layers. They all live in TrainConfig or training code. They don’t touch the network, but they’re what moves a scratch-trained model from 72.82% (plain SGD + momentum, no other tricks) to 90.29%.

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. Paired with warmup and progressive resizing (Howard et al., 2018), this was the winning recipe at Stanford’s DAWNBench in 2018. The lineage traces back to Leslie Smith’s cyclical learning rates (Smith 2015, arXiv:1506.01186). Popularized for ImageNet-scale training by He et al. 2018’s “Bag of Tricks” (arXiv:1812.01187).

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, and by ViT-Tiny in Chapter 9 it’s a necessity.

Weight decay. Adds a small L2 shrink to all trainable weights each step: \(w \leftarrow w - \eta \lambda w\). 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 a smoothed \((\varepsilon / (K-1), \ldots , 1 - \varepsilon , \ldots )\). 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: \(-0.9 \log 0.9 - 0.1 \log (0.1/9) \approx 0.485\).

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

5.6 Ablation: what each ingredient contributes

So how much does each ingredient actually earn? We ran the full recipe plus six leave-one-out variants on ResNet-34 Imagenette, 80 epochs each, same init and batch order. Each ablation row keeps everything except the one named component, so the lift measures that component’s contribution given everything else is present. It is an honest answer to “is this piece pulling its weight?” rather than the order-dependent story you’d get from an additive ladder.

Run

Val accuracy

\(\Delta \) vs full

Full recipe

90.29%

Bare recipe (plain SGD + momentum, no other tricks)

72.82%

\(-\)17.47

Full minus basic augmentation

82.71%

\(-\)7.58

Full minus cosine decay

86.81%

\(-\)3.48

Full minus Adam (vanilla SGD, lr 0.01)

87.04%

\(-\)3.25

Full minus warmup

88.88%

\(-\)1.41

Full minus label smoothing

89.24%

\(-\)1.05

Full minus weight decay

89.68%

\(-\)0.61

Three observations worth naming:

Augmentation is by far the largest single contribution (\(-\)7.58 points, more than 2\(\times \) any other knob). Imagenette has just 9469 training images for a 21M-parameter network, so without augmentation the model overfits hard, and training loss drops below \(0.001\) while val accuracy stalls. Augmentation is doing most of the work that the rest of the recipe gets credit for.

Cosine decay and Adam are roughly tied for largest non-augmentation contribution (\(-\)3.48 and \(-\)3.25 points). They operate on different axes, with cosine shaping the learning-rate trajectory and Adam the per-parameter update magnitude, but their late-training influence on val accuracy is comparable.

The contributions are essentially additive. Sum of all six leave-one-out deltas: \(-17.38\) points. Bare-recipe delta vs full: \(-17.47\) points. The two agree to within rounding, suggesting the modern recipe’s ingredients aren’t significantly interacting. Each one buys roughly the same lift independently of which others are present. Weight decay and label smoothing are the smallest contributors (\(-\)0.61 and \(-\)1.05) but show the overfit-then-catch-up signature mid-training (e.g., no-WD lands \(-\)6 points at epoch 10 before the gap narrows by epoch 80).

A note on the SGD configs: r34-no-adam replaces Adam with vanilla SGD at lr=0.01 (no momentum), keeping everything else from the full recipe. r34-bare uses SGD with momentum 0.9 at lr=0.01 and turns all other tricks off. The two SGD configs therefore differ on momentum as well as on the recipe ingredients, so the bare row should be read as a “what does the modern recipe buy us total” baseline rather than a strict additive component of the leave-one-out chain.

5.7 ImageNet 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 paper’s 90-epoch schedule. The architecture is unchanged. Only the dataset, the output head width, and the training schedule differ.

Ninety epochs at global batch 256 is what He et al. ran, and this section runs it twice: once on the verified codegen path and once on the JAX reference. Both paths share everything above the data loader — the architecture, the .train invocation, and every recipe knob — so the only thing that differs is how the graph reaches the GPU.

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\), Inception-style random-resized-crop (sampling \(8\)–\(100\% \) of the image area at a \(\frac{3}{4}\)–\(\frac{4}{3}\) aspect ratio, then resizing to \(224\times 224\)) plus a horizontal flip, and \(\ell _2\) weight decay \(10^{-4}\). Every one of those is either the original paper or the “bag of tricks” polish that became standard after it, and §4 is the table that says which is which.

The PJRT lowerer

The verified path is a second trusted lowerer, ffi/pjrt_ffi.c: it implements the same C surface as the IREE shim and hands the graph to XLA through the PJRT C API. Nothing above the shim changes. The backend is whichever .so the binary linked, and backend detection is a weak symbol, so a binary cannot disagree with the library it linked. There is no Python at run time. What the trainer executes is pretty(provenGraph): the same rendered artifact the proofs reason about, not a hand-written emitter.

Here is the trainer:

-- 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
  nClasses := 1000
  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 ]
  bnChannels := #[64, 64,64, 64,64, ... ]   -- all 36, in forward order

-- 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 — the paper schedule.
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.
def main (argv : List String) : IO Unit :=
  resnet34ImagenetVerified.toNet.trainAdamSched
    resnet34ImagenetConfig (argv.head?.getD "data") 0.1 0.9 0.999 5 "mom256"

That is apps/imagenette/MainResnet34Imagenet.lean, built as lake build resnet34-imagenet-verified, and it is the program the numbers below came from. 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_mom256_train_step.mlir, 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 load-bearing. It names this net’s own generated augmentation shim. Every net used to stream ResNet-34’s, which meant an augmentation mismatch could hide inside a fair-looking comparison. Here it is the same shim the JAX reference consumes, which is what makes the two a matched pair rather than two separate stories.

It runs with:

CUDA_VISIBLE_DEVICES=0,2,3,4 PJRT_REPLICAS=4 LEAN_MLIR_REPLICAS=4 \
  SHIM_WORKERS=8 PJRT_FFI_RESIDENT=1 \
  LEAN_MLIR_VARIANT=momdp64 LEAN_MLIR_BATCH=64 \
  .lake/build/bin/resnet34-imagenet-verified data

GPU

Precision

Per epoch

Epochs

Total

Val top-1

Val top-5

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

fp32

\(\sim \)24.7 min

90

\(\sim \)37.1 hr

\(\mathbf{74.14\% }\)

\(\mathbf{91.86\% }\)

2\(\times \) 7900 XTX (ROCm)

fp32

90

Global batch 256 as \(4 \times 64\) replicas, fp32 throughout. The ROCm row is a [TODO]: the same render, the same C surface, a different backend .so.

\begin{tikzpicture} 
\begin{axis}[
    width=0.92\linewidth, height=5.6cm,
    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},
    title style={font=\small},
    every axis plot/.append style={line width=1pt, mark size=1pt},
    title={2018 recipe, 90 epochs --- verified path},
]
\addplot[blue, mark=*, mark options={fill=blue}] coordinates {
(1,9.60) (2,20.39) (3,26.81) (4,34.83) (5,37.11) (6,39.50) (7,40.91) (8,41.22) (9,40.49) (10,41.89) (11,43.32) (12,43.72) (13,42.38) (14,46.99) (15,43.78) (16,43.91) (17,44.95) (18,45.34) (19,49.18) (20,44.70) (21,47.25) (22,48.22) (23,43.04) (24,49.12) (25,45.10) (26,48.27) (27,47.98) (28,46.54) (29,44.65) (30,47.02) (31,50.28) (32,46.64) (33,50.63) (34,48.39) (35,50.72) (36,49.98) (37,45.66) (38,51.12) (39,47.54) (40,50.38) (41,52.99) (42,53.58) (43,52.84) (44,51.18) (45,53.92) (46,54.76) (47,54.10) (48,52.54) (49,56.62) (50,55.83) (51,55.47) (52,57.07) (53,56.76) (54,55.34) (55,56.55) (56,57.72) (57,59.00) (58,60.07) (59,59.84) (60,58.98) (61,60.51) (62,59.72) (63,60.84) (64,61.19) (65,63.33) (66,63.55) (67,63.86) (68,64.87) (69,64.21) (70,66.22) (71,66.02) (72,66.82) (73,67.09) (74,67.76) (75,68.10) (76,69.69) (77,69.90) (78,70.26) (79,70.71) (80,71.49) (81,72.12) (82,72.38) (83,72.89) (84,73.26) (85,73.66) (86,73.71) (87,73.95) (88,74.10) (89,74.17) (90,74.14)
};
\addlegendentry{top-1}
\addplot[orange, mark=*, mark options={fill=orange}] coordinates {
(1,24.32) (2,42.27) (3,52.19) (4,61.72) (5,63.87) (6,66.60) (7,67.51) (8,68.09) (9,67.06) (10,68.19) (11,70.25) (12,70.34) (13,69.00) (14,72.87) (15,70.22) (16,70.22) (17,71.06) (18,71.92) (19,75.32) (20,71.44) (21,73.17) (22,74.52) (23,69.12) (24,75.06) (25,71.21) (26,74.51) (27,74.13) (28,72.12) (29,70.91) (30,72.85) (31,76.09) (32,72.63) (33,76.55) (34,74.61) (35,76.12) (36,75.76) (37,71.66) (38,76.17) (39,72.89) (40,75.70) (41,78.01) (42,78.18) (43,77.60) (44,77.15) (45,78.95) (46,79.70) (47,79.01) (48,78.11) (49,80.95) (50,80.36) (51,80.05) (52,81.38) (53,80.66) (54,79.97) (55,80.27) (56,81.63) (57,82.89) (58,83.52) (59,83.14) (60,82.46) (61,83.66) (62,83.08) (63,83.74) (64,84.23) (65,85.39) (66,85.74) (67,86.08) (68,86.78) (69,85.98) (70,87.06) (71,87.42) (72,87.85) (73,88.08) (74,88.16) (75,88.53) (76,89.48) (77,89.59) (78,89.82) (79,89.95) (80,90.56) (81,90.74) (82,90.82) (83,91.17) (84,91.37) (85,91.64) (86,91.70) (87,91.78) (88,91.87) (89,91.91) (90,91.86)
};
\addlegendentry{top-5}
\end{axis}
\end{tikzpicture}

The run above, epoch by epoch. One attempt, no thermal rest and no PCIe interruption across all 90.

The JAX lowerer

The JAX trainer lives in jax/MainResnetImagenet.lean, builds as lake build resnet34-imagenet, and wires .imagenet through tfds streaming. It runs the same recipe on the same cards, and its job here is to be the independent second implementation the verified number is checked against.

It runs in bf16: the spec casts both matmuls and convolutions to bfloat16 while keeping fp32 master weights. NVIDIA’s tensor cores run bf16 convolution about \(1.6\times \) faster than fp32 — the opposite of AMD’s MIOpen, where it is a wash, which is why conv precision sits behind its own bf16Conv flag. That is the whole reason the reference column below is roughly three times quicker than the verified one: a throughput choice, not a fidelity one.

GPU

Precision

Per epoch

Epochs

Total

Val top-1

Val top-5

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

fp32

\(\sim \)16.8 min

90

\(\sim \)25 hr

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

bf16

\(\sim \)8.8 min

90

\(\sim \)14.8 hr

\(\mathbf{74.16\% }\)

\(\mathbf{91.92\% }\)

1\(\times \) A100 (CUDA)

bf16

90

\(\sim \)9 hr

1\(\times \) MI300X (ROCm)

bf16

\(\sim \)3.5 min

90

\(\sim \)5.4 hr

Per-epoch throughput for the same recipe on other hardware. The A100 row is a wall-clock estimate.  MI300X ran at batch 1024 / lr 0.4 and before the current validation protocol, so only its throughput is quotable.

\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 --- JAX lowerer},
]
\addplot[blue, mark=*, mark options={fill=blue}] coordinates {
(1,7.54) (2,21.73) (3,31.53) (4,31.62) (5,35.85) (6,37.80) (7,41.35) (8,40.55) (9,42.09) (10,41.43) (11,45.71) (12,38.73) (13,40.50) (14,43.38) (15,44.94) (16,40.91) (17,44.84) (18,45.94) (19,43.69) (20,46.42) (21,45.55) (22,41.84) (23,47.34) (24,43.98) (25,45.86) (26,45.16) (27,46.98) (28,42.26) (29,47.96) (30,47.01) (31,47.77) (32,48.91) (33,50.40) (34,48.76) (35,44.92) (36,49.54) (37,49.58) (38,47.91) (39,50.04) (40,50.47) (41,48.07) (42,52.80) (43,51.72) (44,54.10) (45,51.30) (46,54.40) (47,54.30) (48,53.90) (49,52.16) (50,53.51) (51,55.02) (52,55.65) (53,54.11) (54,56.11) (55,57.71) (56,57.03) (57,57.19) (58,58.04) (59,59.84) (60,59.47) (61,60.97) (62,59.30) (63,61.60) (64,61.44) (65,61.05) (66,63.21) (67,64.12) (68,63.78) (69,63.83) (70,66.09) (71,65.70) (72,66.37) (73,66.93) (74,68.71) (75,68.71) (76,68.92) (77,69.76) (78,70.11) (79,70.75) (80,71.19) (81,71.81) (82,72.37) (83,72.76) (84,73.10) (85,73.33) (86,73.80) (87,73.89) (88,74.09) (89,74.10) (90,74.16)
};
\addlegendentry{top-1}
\addplot[orange, mark=*, mark options={fill=orange}] coordinates {
(1,20.09) (2,45.19) (3,58.17) (4,56.65) (5,62.37) (6,65.29) (7,67.97) (8,67.02) (9,68.56) (10,68.60) (11,72.23) (12,64.91) (13,67.05) (14,70.17) (15,71.36) (16,67.07) (17,70.97) (18,72.76) (19,70.27) (20,72.81) (21,71.93) (22,68.39) (23,73.98) (24,70.24) (25,72.04) (26,71.17) (27,73.27) (28,68.36) (29,74.05) (30,72.92) (31,73.81) (32,75.14) (33,76.26) (34,75.01) (35,71.57) (36,75.05) (37,75.23) (38,74.08) (39,75.32) (40,76.41) (41,74.06) (42,78.04) (43,77.47) (44,79.03) (45,76.75) (46,79.32) (47,79.42) (48,78.55) (49,77.24) (50,78.71) (51,79.86) (52,80.08) (53,78.84) (54,80.74) (55,81.97) (56,81.16) (57,81.37) (58,82.04) (59,83.24) (60,83.00) (61,84.05) (62,83.15) (63,84.72) (64,84.26) (65,83.93) (66,85.34) (67,86.02) (68,86.11) (69,85.95) (70,87.20) (71,87.02) (72,87.58) (73,87.84) (74,88.82) (75,88.85) (76,89.12) (77,89.49) (78,89.91) (79,90.11) (80,90.35) (81,90.81) (82,90.86) (83,91.30) (84,91.42) (85,91.52) (86,91.72) (87,91.80) (88,91.85) (89,91.89) (90,91.92)
};
\addlegendentry{top-5}
\end{axis}
\end{tikzpicture}

The same recipe, epoch by epoch, through the JAX lowerer. The kink near epoch 5 is the end of warmup; the steady late climb is the cosine anneal.

The two paths, side by side

 

verified lowerer (PJRT)

JAX lowerer

lowering

pretty(provenGraph) \(\to \) XLA

JAX \(\to \) XLA

Python at run time

none

yes

precision

fp32

bf16 (matmul + conv)

BN statistic group

64 (per replica)

256 (global)

per epoch

\(\sim \)24.7 min

\(\sim \)8.8 min

total

\(\sim \)37.1 hr

\(\sim \)14.8 hr

val top-1

\(\mathbf{74.14\% }\)

\(\mathbf{74.16\% }\)

val top-5

\(\mathbf{91.86\% }\)

\(\mathbf{91.92\% }\)

Both columns are ResNet-34 on the 2018 recipe, 90 epochs, global batch 256, on the same four 4060 Ti cards, scored over the same \(50{,}000\) validation images under the same protocol. Everything above the rule differs between them; everything below it does not. A ROCm column (2\(\times \) 7900 XTX) is — pending hardware.

Global batch 256 as \(4 \times 64\), heavy-ball momentum with coupled \(\ell _2\), cosine with a 5-epoch warmup at peak \(0.1\), weight decay \(10^{-4}\), label smoothing \(0.1\) — the same recipe, knob for knob, on both lowerers. The paper schedule at 90 epochs, completed in \(37.1\) hours on one attempt, with no thermal rest and no PCIe interruption.

The two paths agree to \(0.02\) points. Same architecture, same recipe, same box, same \(50{,}000\) images under the same protocol — but one lowered through JAX and the other through pretty(provenGraph) into XLA with no Python at run time. It is the closest verified-versus-reference pair in this book.

It is also a harder test than it looks. The JAX trainer runs under @jit with a NamedSharding mesh, so its BatchNorm means carry global semantics and XLA reduces them across all four cards: the statistic group is the whole \(256\). This run is four replicas of \(64\) with no collective touching the batch statistics, so its group is \(64\) — a fourfold difference that moved the answer by two hundredths of a point.

The two lowerers agree as tightly as the two paths do. Run from the same initialization on the same box, IREE and PJRT scored 17,312 and 17,313 correct at epoch 5 — one image apart. The proof-carrying tier stops at Imagenette, so the claim is one architecture, two independent lowerings, agreeing, not “proven at ImageNet scale”.

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 locally and/or in the cloud (\(\sim \)6–9 hrs, \(\sim \)$15 in compute).

  • Stop here. Assume Chapters 69 (MobileNet, EfficientNet, ConvNeXt, ViT) and the Bestiary (Ch 10) are correct, because they all reuse the same framework with new operators slotting into the same proof tree. Appendix C 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.8 ResNet-50 on ImageNet: the 2018 recipe and RSB-A3

Why the phase-2 trainer? The ImageNet numbers in this book come from the phase-2 (Lean\(\to \)JAX) trainer: at this scale its job is to validate the framework’s logic end to end, and it is the reference the verified path is measured against. Phase-3 verified-IREE codegen led to phase 4 — PJRT, which is the verified path itself. Where a section has both, it reports both.

The basic block ResNet-34 is built from generalizes to the bottleneck block of ResNet-50, and the jax/ folder ships it as a full ImageNet demo (jax/MainResnet50Imagenet.lean) trained on the modern “ResNet Strikes Back” A3 recipe (Wightman et al. 2021): LAMB at effective batch 2048 (via gradient accumulation), BCE over Mixup/CutMix soft targets, RandAugment, train@160 / eval@224. On the same proven-backward bottleneck codegen it reached \(\mathbf{78.26\% }\) top-1 / \(\mathbf{93.79\% }\) top-5 at 100 epochs, clearing the paper’s \(78.1\% \) and the \(78.05\% \) that timm’s own resnet50.a3_in1k weights score under the same protocol.

The JAX lowerer

GPU

Recipe

BN regime

Per epoch

Ep.

Total

top-1

top-5

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

2018

global (256)

\(\sim \)14.8 min

90

\(\sim \)24.0 hr

\(\mathbf{76.95\% }\)

\(\mathbf{93.44\% }\)

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

RSB-A3

Ghost-BN (512)

\(\sim \)7.9 min

100

\(\sim \)15.1 hr

\(\mathbf{78.26\% }\)

\(\mathbf{93.79\% }\)

ResNet-50 on the same four 4060 Ti cards, both recipes, both scored over all \(50{,}000\) validation images. A3 accumulates \(4\times 512\) to reach effective batch \(2048\), so BatchNorm normalizes per \(512\)-image micro-batch (“Ghost-BN”); the 2018 recipe runs a true global batch of \(256\).

The first row is the 2018 recipe on the same network. It is what makes the recipe comparison symmetric: both paths get both recipes, so a difference between two rows is attributable to the one thing that changed between them. Here that difference is \(\mathbf{+1.31}\) points for A3 (\(76.95 \to 78.26\)), with architecture, box, protocol and denominator all held fixed — and A3 reaches it in less wall clock, because it trains at \(160^2\).

\begin{tikzpicture} 
\begin{axis}[
    width=0.92\linewidth, height=5.6cm,
    xlabel={Epoch}, ylabel={Validation accuracy (\%)},
    xmin=0, xmax=101, ymin=0, ymax=100,
    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},
    title style={font=\small},
    every axis plot/.append style={line width=1pt, mark size=1pt},
    title={2018 recipe, 90 epochs},
]
\addplot[blue, mark=*, mark options={fill=blue}] coordinates {
(1,5.62) (2,12.26) (3,20.56) (4,26.88) (5,31.94) (6,39.00) (7,40.99) (8,39.22) (9,40.09) (10,44.78) (11,44.68) (12,40.05) (13,46.52) (14,44.43) (15,48.27) (16,46.74) (17,45.16) (18,45.97) (19,46.45) (20,48.40) (21,48.15) (22,49.31) (23,47.39) (24,49.90) (25,44.23) (26,49.32) (27,46.24) (28,47.15) (29,48.84) (30,50.11) (31,49.00) (32,49.19) (33,50.51) (34,45.38) (35,51.90) (36,50.42) (37,51.17) (38,52.60) (39,54.86) (40,54.25) (41,52.25) (42,55.65) (43,52.48) (44,57.34) (45,54.14) (46,56.66) (47,56.50) (48,54.69) (49,54.96) (50,57.40) (51,58.25) (52,56.86) (53,57.86) (54,58.89) (55,56.85) (56,61.05) (57,60.80) (58,61.29) (59,63.09) (60,63.29) (61,62.66) (62,62.64) (63,63.49) (64,64.41) (65,65.56) (66,66.45) (67,67.59) (68,67.14) (69,68.81) (70,68.19) (71,66.92) (72,69.45) (73,69.37) (74,71.08) (75,71.96) (76,71.41) (77,72.78) (78,73.17) (79,73.58) (80,74.35) (81,75.08) (82,75.18) (83,75.71) (84,76.16) (85,76.35) (86,76.60) (87,76.77) (88,76.91) (89,76.94) (90,76.95)
};
\addlegendentry{top-1}
\addplot[orange, mark=*, mark options={fill=orange}] coordinates {
(1,15.51) (2,29.11) (3,43.08) (4,51.96) (5,58.04) (6,66.17) (7,67.57) (8,65.49) (9,66.55) (10,71.78) (11,71.30) (12,66.73) (13,72.80) (14,71.34) (15,74.40) (16,73.49) (17,70.98) (18,73.23) (19,73.08) (20,74.71) (21,74.38) (22,75.58) (23,73.22) (24,75.82) (25,69.89) (26,75.01) (27,72.38) (28,73.44) (29,74.76) (30,76.00) (31,74.82) (32,74.57) (33,76.62) (34,71.42) (35,77.62) (36,75.88) (37,76.36) (38,78.10) (39,79.43) (40,79.39) (41,77.73) (42,80.35) (43,77.30) (44,81.57) (45,78.90) (46,80.79) (47,80.80) (48,79.23) (49,79.71) (50,81.97) (51,82.10) (52,81.53) (53,81.57) (54,82.62) (55,80.91) (56,83.83) (57,83.55) (58,84.03) (59,85.97) (60,86.02) (61,85.45) (62,85.61) (63,85.55) (64,86.36) (65,87.27) (66,87.68) (67,88.27) (68,88.01) (69,89.13) (70,88.89) (71,87.74) (72,89.24) (73,89.45) (74,90.40) (75,90.91) (76,90.22) (77,91.42) (78,91.51) (79,91.84) (80,92.17) (81,92.36) (82,92.60) (83,92.89) (84,93.11) (85,93.16) (86,93.25) (87,93.33) (88,93.43) (89,93.42) (90,93.44)
};
\addlegendentry{top-5}
\end{axis}
\end{tikzpicture}

ResNet-50 / ImageNet-1k validation accuracy per epoch on the 2018 recipe, four 4060 Ti cards, on the same axes as the A3 curve in §5.8 below. It shows the three-part shape ResNet-34 does: a fast climb, a long shallow middle, and a final lift as the cosine anneals.

Against A3 the two are closer through the middle than the \(+1.31\) endpoint gap suggests. From roughly epoch 25 they trade the lead repeatedly, and A3 does not hold it outright until epoch 86; at the matched epoch 90 A3 leads by \(0.77\) (\(77.72\) against \(76.95\)), and its remaining ten epochs of anneal add the rest. A3 also starts far lower — \(0.32\% \) at epoch 1 against \(5.62\% \) here — because BCE over mixup targets gives almost no signal until the logits separate. Compare the middles with care: the schedules are different lengths, so at a shared epoch index the two cosines are at different points in their decay. The endpoints are what the \(+1.31\) is measured from.

What the re-run corrected, and what it was worth. Beyond the protocol change above, two optimizer corrections read out of timm’s own source went in: LAMB’s gradient clip, which timm enables by default (max_grad_norm \(= 1.0\)) and we did not, and its guard excluding the no_weight_decay group from layer adaptation, which we had been applying to every parameter. Those two, the padding fix and the new evaluation are together what moved this section’s ResNet-50 onto timm’s own footing — a fidelity correction, not a recipe change, which is why the \(78.26\% \) is quotable against timm’s \(78.05\% \) rather than an improvement on it.

The PJRT lowerer

The same recipe on the verified codegen path, which is not a hand-written emitter but pretty(provenGraph), the rendered MLIR the proofs reason about, lowered through ffi/pjrt_ffi.c to XLA over the PJRT C API with no Python at run time. The artifact is resnet50in160_lambaccdp8x64wxclipbce_train_step: LAMB \(\times \) BCE-with-logits \(\times \) gradient accumulation \(k=8\), four replicas at per-replica batch 64 for an effective batch of 2048, train@160 / eval@224.

GPU

Recipe

BN group

Per epoch

Total

Val top-1

Val top-5

4\(\times \) 3060 (bf16)

2018 (90 ep)

Ghost-BN (64)

\(\sim \)20.5 min

\(\sim \)30.7 hr

\(\mathbf{77.07\% }\)

\(\mathbf{93.48\% }\)

4\(\times \) 4060 Ti (fp32)

RSB-A3 (100 ep)

Ghost-BN (64)

\(\sim \)19.2 min

\(\sim \)32.1 hr

\(\mathbf{77.91\% }\)

\(\mathbf{93.84\% }\)

The first row is the same network on the 2018 recipe, and it is the row that makes the comparison below a controlled one: it is bit-for-bit the ResNet-34 configuration of §5.7 with the bottleneck backbone substituted, so the only difference between it and the A3 row is the recipe. It runs with:

CUDA_VISIBLE_DEVICES=0,1,2,3 PJRT_REPLICAS=4 LEAN_MLIR_REPLICAS=4 \
  SHIM_WORKERS=8 PJRT_FFI_RESIDENT=1 \
  LEAN_MLIR_VARIANT=momdp64bf16 LEAN_MLIR_BATCH=64 LEAN_MLIR_EPOCHS=90 \
  LEAN_MLIR_RECIPE=2018 LEAN_MLIR_BASE_LR_U=100000 \
  .lake/build/bin/resnet50-imagenet-verified data

LEAN_MLIR_RECIPE selects the augmentation. shimScript is a field on the network, not on the recipe, so the \(224^2\) spec has one shim per recipe and picking the wrong one trains the right optimizer on the wrong data. scripts/jobs/ encodes that and the schedule knobs as refusing prechecks.

How to quote these. The A3 row lands \(0.35\) points behind the JAX reference above (\(78.26\% \) / \(93.79\% \)) and \(0.14\) behind timm’s own \(78.05\% \), measured over the same \(50{,}000\) images under the same protocol. The recipe is not yet A3 to the letter — the ledger below is what is in and what is still out — so it is the A3 recipe on the verified path, with these deltas, rather than “RSB-A3 reproduced”. The 2018 row needs no such hedge: it is the recipe in full, and it lands \(0.12\) points ahead of its own JAX reference.

\begin{tikzpicture} 
\begin{axis}[
    width=0.92\linewidth, height=5.6cm,
    xlabel={Epoch}, ylabel={Validation accuracy (\%)},
    xmin=0, xmax=101, ymin=0, ymax=100,
    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},
    title style={font=\small},
    every axis plot/.append style={line width=1pt, mark size=1pt},
    title={RSB-A3 (2021), 100 epochs --- verified path},
]
\addplot[blue, mark=*, mark options={fill=blue}] coordinates {
(1,0.61) (2,5.43) (3,13.47) (4,13.24) (5,23.45) (6,20.77) (7,23.37) (8,33.23) (9,32.92) (10,30.78) (11,35.25) (12,39.07) (13,41.30) (14,38.65) (15,42.86) (16,43.53) (17,46.86) (18,41.67) (19,43.56) (20,47.19) (21,37.73) (22,50.08) (23,49.32) (24,49.81) (25,49.61) (26,49.17) (27,46.83) (28,47.45) (29,50.53) (30,50.39) (31,50.60) (32,51.00) (33,52.96) (34,48.45) (35,52.19) (36,48.86) (37,54.65) (38,53.17) (39,54.25) (40,50.38) (41,55.38) (42,51.90) (43,56.67) (44,58.04) (45,55.30) (46,55.73) (47,57.22) (48,58.96) (49,60.41) (50,62.37) (51,62.40) (52,62.22) (53,64.43) (54,62.55) (55,62.45) (56,64.46) (57,64.80) (58,63.91) (59,66.13) (60,66.75) (61,66.20) (62,65.21) (63,67.27) (64,68.70) (65,68.10) (66,68.14) (67,69.40) (68,70.76) (69,70.75) (70,72.34) (71,72.07) (72,72.15) (73,73.56) (74,72.47) (75,73.55) (76,73.91) (77,74.68) (78,73.67) (79,74.83) (80,75.24) (81,75.81) (82,75.82) (83,76.11) (84,76.37) (85,76.56) (86,76.74) (87,76.93) (88,77.18) (89,77.15) (90,77.32) (91,77.51) (92,77.59) (93,77.70) (94,77.64) (95,77.88) (96,77.87) (97,77.89) (98,77.90) (99,77.90) (100,77.91)
};
\addlegendentry{top-1}
\addplot[orange, mark=*, mark options={fill=orange}] coordinates {
(1,2.36) (2,16.25) (3,31.45) (4,30.87) (5,46.44) (6,43.20) (7,46.31) (8,58.55) (9,57.86) (10,55.24) (11,60.62) (12,64.30) (13,67.23) (14,64.01) (15,69.38) (16,69.19) (17,72.35) (18,67.02) (19,68.75) (20,72.39) (21,62.46) (22,74.78) (23,74.58) (24,74.85) (25,74.80) (26,74.18) (27,71.16) (28,72.07) (29,75.43) (30,75.07) (31,75.35) (32,75.70) (33,77.20) (34,73.37) (35,76.71) (36,73.46) (37,78.48) (38,77.14) (39,78.31) (40,74.72) (41,78.73) (42,76.19) (43,80.19) (44,81.31) (45,79.13) (46,79.50) (47,80.28) (48,81.73) (49,82.73) (50,84.22) (51,84.52) (52,84.50) (53,86.15) (54,84.87) (55,84.60) (56,85.87) (57,85.87) (58,85.46) (59,87.03) (60,87.30) (61,87.14) (62,86.51) (63,87.70) (64,88.77) (65,88.05) (66,88.15) (67,89.22) (68,90.06) (69,89.94) (70,90.63) (71,90.67) (72,90.78) (73,91.57) (74,90.88) (75,91.62) (76,91.68) (77,92.13) (78,91.71) (79,92.13) (80,92.53) (81,92.70) (82,92.64) (83,92.82) (84,93.01) (85,93.11) (86,93.16) (87,93.30) (88,93.52) (89,93.41) (90,93.59) (91,93.59) (92,93.63) (93,93.69) (94,93.75) (95,93.83) (96,93.76) (97,93.76) (98,93.84) (99,93.84) (100,93.84)
};
\addlegendentry{top-5}
\end{axis}
\end{tikzpicture}

The verified path’s own curve, on the same axes as the 2018 panel above so the two can be read against each other. The endpoint is \(0.35\) points below the reference, but the shape getting there is not a uniformly worse version of it: the verified path is ahead for 56 of the 100 epochs, by as much as \(14.3\) points at epoch 31, and the reference does not take a durable lead until epoch 86 — the same late-anneal window in which A3 finally passes the 2018 recipe. Two runs can differ by a third of a point at the end and by fourteen in the middle, so a mid-training comparison between these panels says almost nothing about where they land.

What that gap is not evidence of: the two paths differ in more than the codegen. The reference normalises BatchNorm over \(512\) and this run over \(64\), an eightfold difference in the statistic group and much the largest known asymmetry between them, and this run is fp32 where the reference is bf16. Attributing the \(0.35\) to the verified codegen requires closing those first.

In, and in the \(77.91\% \) above. What this run carries that a bare A3 transcription would not:

  • wdExcludeNormBias — BN \(\gamma /\beta \) and biases are not decayed; 54 decayed / 107 excluded of 161. Largest single contributor.

  • LAMB’s gradient clip (timm’s max_grad_norm \(= 1.0\) default), applied after the all-reduce and after the accumulation, at threshold \(kC\) (Proofs.clipFactor_accum).

  • The no_weight_decay group is not layer-adapted.

  • BN running-statistic momentum compensated for accumulation. Evaluation-only.

  • Evaluation scores all \(50{,}000\) images. Every top-1 outside this chapter is still over \(49{,}920\).

  • RandAugment Posterize, plus timm’s validation protocol and resampler (appendix A).

  • Verification: the optimizer is tied to the reference at the update as well as the gradient — six variants to \(\sim \! 10^{-7}\).

Still out, in rough order of expected effect:

  • Ghost-BN group 64 against the reference’s 512; no collective touches the batch statistics. The largest known difference between the two paths, and untested: the \(-0.35\) points between them is its upper bound only if nothing else contributes.

  • fp32, not bf16. The 2018 row above is bf16, but the LAMB + BCE shape at \(160^2\) still has no bf16 twin to run. (A2 and A1 at \(224^2\) now have theirs, so this is A3’s gap alone rather than the shared one it was.) Worth a measured \(1.54\times \) on this network, so most of the \(2.1\times \) wall clock against the JAX row: a throughput lever, not a fidelity one.

  • \(0.08\% \) of each epoch dropped (\(8 \nmid 5004\)); structural to \(k\).

  • Mixup \(\lambda \) drawn host-side from NumPy, so agreement is distributional, never per-step.

The two paths, side by side

Both recipes have now been run on both lowerers, which is what makes this a grid rather than a pair of anecdotes.

 

2018 (90 ep)

RSB-A3 (100 ep)

 

JAX

PJRT

\(\Delta \)

JAX

PJRT

\(\Delta \)

top-1

\(76.95\)

\(\mathbf{77.07}\)

\(+0.12\)

\(78.26\)

\(\mathbf{77.91}\)

\(-0.35\)

top-5

\(93.44\)

\(\mathbf{93.48}\)

\(+0.04\)

\(93.79\)

\(\mathbf{93.84}\)

\(+0.05\)

precision

bf16

bf16

 

bf16

fp32

 

box

4060 Ti

3060

 

4060 Ti

4060 Ti

 

wall clock

\(24.0\) h

\(30.7\) h

 

\(15.1\) h

\(32.1\) h

 

The two lowerers agree within \(0.35\) points of top-1 on both recipes, and both agree to within \(0.05\) on top-5. That is the claim the grid licenses and the reason the second lowerer exists: with one backend the proofs decorate a code generator and nothing checks the generator, while a second independent lowering makes the verified path the artifact and JAX the oracle it is checked against.

And the recipe delta replicates. A3 beats 2018 by \(+1.31\) points on the JAX lowerer and by \(+0.84\) on the verified one. Agreement at a single configuration can be a coincidence of that configuration; a difference that reproduces across two independent implementations is harder to arrange by accident, and it is what a one-row comparison cannot show.

What this is not evidence of. Each cell is one run at one seed, and \(0.35\) points is inside what seed variance alone produces on ImageNet — the agreement is consistent with the lowerers matching, not proof that they do. The A3 column trains at \(160\) and evaluates at \(224\) while the 2018 column does both at \(224\), so the recipe delta confounds resolution with recipe, by A3’s own design. And the PJRT column is not internally uniform: its 2018 entry is bf16 on the four-3060 box, its A3 entry fp32 on the four-4060 Ti box. Neither difference touches the within-recipe comparison, which is the one the table is for, but both would matter to anyone reading down the PJRT column.

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 spec, the render and the entry point are identical between them, and only the arguments differ. Here is the full diff:

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

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, bf16 matmul and convolution, 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. Every one of these is a one-line TrainConfig change on the same verified backbone.

5.9 Side quest: the A2 and A1 recipes

A3 is the cheap tier. The other two are recipes — a2-accum and a1 — and both now render on the verified path:

tier

epochs

train res

verified render

verified wall clock

top-1

A3 (rsb-faithful)

100

\(160\)

shipped, and run

\(32.1\) hr, bf16 \(\sim \)23

\(\mathbf{77.91\% }\)

A2 (a2-accum)

300

\(224\)

rendered, EMA + sd

\(\sim \)95 hr (bf16)

\(79.8\% \) (paper)

A1 (a1)

600

\(224\)

rendered, EMA + sd

\(\sim \)190 hr (bf16)

\(80.4\% \) (paper)

Eight artifacts: each tier at one and four replicas, in both precisions, all at \(4\times 128\) for the reason the batch-norm paragraph gives. A1 is not “the same render as A2”: the decay is baked, so its \(0.01\) against A2’s \(0.02\) is a re-render, kept apart so one line among \(18{,}000\) cannot be lost.

The two regularisers these renders used to be missing are now in them. The ledger §5.8 keeps for A3, before anything is run:

In

Still out, worst first

wdExcludeNormBias; LAMB’s clip at \(kC\), after both the all-reduce and the accumulation

Ghost-BN group \(64\) against the reference’s \(\mathbf{512}\) — an \(8\times \) gap, and only half of it is reachable here

BCE-with-logits over Mixup/CutMix soft labels

Repeated augmentation is stream-level, not per-batch

Accumulation to LAMB’s design batch of \(2048\), at \(k=8\) or at the reference’s own \(k=4\)

Nothing trained — no epoch of either tier has run here

Model EMA at decay \(0.9999\) — a fifth blob region, \([\theta |m|v|G|E]\)

 

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

 

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

 

A1’s own decay and own shim, which the driver refuses to start without

 

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

 

Both were structural, and A3 met neither — its recipe switches both off. The shadow and the accumulator used to be the same fourth region of \([\theta |m|v|\cdot ]\), and accumulation is not optional at \(224^2\): EMA was unreachable, not unrendered.

Stochastic depth’s site sits on the residual branch, and no structural check can tell that from a site on the block output: at an all-ones mask \(1 \odot (b+x)\) and \(1 \odot b + x\) agree bit-for-bit, so a misplaced render matches on names, operation count and arity and passes every endpoint gate. Moving all sixteen separates them by eight orders of magnitude at a real mask, and by exactly zero at an all-ones one. The reference had it wrong the other way round — a scalar bernoulli for the whole batch where timm’s is per sample, same expectation, which is why it survived.

Which leaves the batch-norm group, larger than it looks. Nothing here all-reduces activation statistics, so each replica normalises over its own \(64\); the reference’s jnp.mean reduces over a mesh-sharded axis, so XLA inserts one and its group is the full \(512\) — its per-device tensor is \(128\), and only the group is the statistic. These renders take the first half of that gap: \(k=4\) at \(128\) per device is the reference’s own factorisation, and it is the faster arrangement besides. The last \(4\times \) needs synchronised batch-norm — a new operator, a changed backward, a variance that does not average, and \(53\) collectives a step.

And what the phase-2 peers cost, A3 the control:

phase-2 tier

train res

ms/step

min/epoch

full schedule

A3 (100 ep)

\(160\)

\(715\)

\(7.5\)

\(12.4\) h

A2 (300 ep)

\(224\)

\(1{,}368\)

\(14.3\)

\(71.3\) h (3.0 d)

A1 (600 ep)

\(224\)

\(1{,}368\)

\(14.3\)

\(142.5\) h (5.9 d)

And the verified column is measured, not scaled from theirs. A3’s graph runs \(191.4\) ms/step on four cards and the complete A2 at \(4\times 128\) bf16 runs \(376.9\), while A3’s finished run fixes what the trainer adds at \(38.8\) ms per \(256\) images — additive, since the device figures already contain the all-reduce and only the host’s blob patch sits outside. That is \(19.0\) verified minutes an epoch, and run backwards it puts A3 itself at \(23\) hours rather than \(32.1\).