Verified Deep Learning with Lean 4

8 ConvNeXt

ConvNets strike back

By 2022 the conventional read on image recognition had flipped. ResNet had been the default backbone for six years. Then in late 2020 ViT (Dosovitskiy et al., arXiv:2010.11929) showed that a pure transformer could match or beat ResNet on ImageNet at sufficient scale, and a year later Swin (Liu et al. 2021, arXiv:2103.14030) layered in hierarchical attention to win COCO/ADE20K on top of that. The research community’s working assumption became “transformers beat convnets for vision now.”

ConvNeXt (Liu et al. 2022, arXiv:2201.03545) was a deliberate stress test of that assumption. The authors started from a standard ResNet-50 and asked: which of the design differences between ResNet and Swin is actually doing the work? They proceeded by applying transformer-era choices to the ResNet one at a time and measuring each contribution in isolation. The full list:

  1. Training recipe modernization (300 epochs, AdamW, RandAug, Mixup, CutMix, label smoothing, stochastic depth, layer scale)

  2. Stage compute ratio change (from (3, 4, 6, 3) to (3, 3, 9, 3), matching Swin)

  3. “Patchify” stem (replace the 7\(\times \)7 stride-2 conv with a 4\(\times \)4 stride-4 conv, matching ViT’s patch embedding)

  4. Depthwise convolutions (split spatial and channel mixing, as in MobileNetV2)

  5. Inverted bottleneck (1\(\times \)1 expand 4\(\times \), depthwise, 1\(\times \)1 project, MobileNetV2-style)

  6. Larger kernel (depthwise 7\(\times \)7 instead of 3\(\times \)3, to approximate Swin’s window attention receptive field)

  7. Fewer activations and norms (one per block instead of one per layer, again matching transformer blocks)

  8. LayerNorm instead of BatchNorm

  9. GELU instead of ReLU

The result: a model that’s pure convolution top to bottom, doesn’t contain a single attention operation, and matches or beats Swin-T on ImageNet at the same compute budget. The conclusion of the paper isn’t “convolutions are back.” It’s “the architectural gap between ResNet and Swin was mostly the training recipe, not the receptive field.” Most of what looked like transformer superiority was modernization that hadn’t been back-ported to the convnet baseline.

8.1 Run it first

Before the two new primitives, train the network that needs them. Four commands, and the heaviest per-epoch net in the book still finishes inside an hour and a quarter:

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

On one RTX 4060 Ti (CUDA 12.9), from runs/2026-09-13-convnext-imagenette-ls1e-6-resident/. XLA’s startup banner and the resident-parameter notices are removed, the network description line is wrapped and its trailing LayerNorm note dropped, each interval is wrapped onto an indented continuation, and epochs 5 through 78 are elided:

[pjrt_ffi] XLA backend: PJRT 0.114, 1 device(s)
[pjrt_ffi] command buffers: enabled (CUDA default)
[pjrt_ffi] compiled verified_mlir/convnext_adam_train_step.mlir
             (@convnext_adam_train_step, 549 outputs, 1 replica)
             in 6361 ms
[pjrt_ffi] compiled verified_mlir/convnext_fwd.mlir
             (@convnext_fwd, 1 outputs, 1 replica) in 588 ms
ConvNeXt-T on Imagenette 224² (patchify /4 → stem channel-LN →
  [3,3,9,3] blocks @ [96,192,384,768] depthwise-7×7 + channel-LN +
  GELU + layerScale + 3 downsamples 56→7 → GAP → LN → dense)
  via the VERIFIED renderer → XLA/PJRT → GPU
  xla/pjrt verified_mlir/convnext_adam_train_step.mlir
  xla/pjrt verified_mlir/convnext_fwd.mlir
  train 9469, val 3925; bs 32, ConvNeXt-T adam
    (cosine+warmup 3ep, baseLR 0.001000), He init
Epoch 1/80: loss=2.321469 lr=0.000333
  epoch 1: val_acc = 1106/3925 = 28.178344%  top5 = 2873/3925 = 73.197452%
    [95% CI 26.79–29.61]
Epoch 2/80: loss=2.000190 lr=0.000667
  epoch 2: val_acc = 1566/3925 = 39.898089%  top5 = 3378/3925 = 86.063694%
    [95% CI 38.38–41.44]
Epoch 3/80: loss=1.675131 lr=0.001000
  epoch 3: val_acc = 2057/3925 = 52.407643%  top5 = 3601/3925 = 91.745223%
    [95% CI 50.84–53.97]
Epoch 4/80: loss=1.506907 lr=0.001000
  epoch 4: val_acc = 2226/3925 = 56.713376%  top5 = 3640/3925 = 92.738854%
    [95% CI 55.16–58.26]
Epoch 79/80: loss=0.500732 lr=0.000000
  epoch 79: val_acc = 3230/3925 = 82.292994%  top5 = 3789/3925 = 96.535032%
    [95% CI 81.07–83.46]
Epoch 80/80: loss=0.500793 lr=0.000000
  epoch 80: val_acc = 3229/3925 = 82.267516%  top5 = 3788/3925 = 96.509554%
    [95% CI 81.04–83.43]
done (trained ConvNeXt-T adam + cosine/warmup via packed threading).

Eighty epochs at about 55 seconds each, one hour and fourteen minutes in total, and 82.27% top-1 with 96.51% top-5 on Imagenette’s 3,925-image validation split. The best epoch reached \(82.32\% \). That is the lowest top-1 of the convnets in this book’s Imagenette column, from much the largest network among them, and §8.3 is about why that is the expected answer rather than a defect.

Two compile lines, where the previous three chapters each had three. ResNet-34, MobileNetV2 and EfficientNet-B0 all emit a third artifact, <slug>_fwd_eval, because BatchNorm behaves differently in training and in evaluation and the evaluation forward has to read frozen running statistics. ConvNeXt has no BatchNorm. LayerNorm computes its statistics from the activation in front of it every time, so there is nothing to freeze, and the training forward and the evaluation forward are the same function. The chapter’s central swap pays for itself in the build before it pays for itself in the mathematics.

That also shows in the first four epochs. Top-1 climbs 28.2, 39.9, 52.4, 56.7, smoothly. A BatchNorm net can show a step in its first epochs, when the running statistics average over weights that are still moving fast and the evaluation forward briefly uses statistics that no longer describe the network (Chapter 7). No such step exists here, because no such statistics exist here.

Three new operations this chapter, still three rules. What ran is pretty(provenGraph), the file verified_mlir/convnext_adam_train_step.mlir, and every channel-LayerNorm in it backpropagates by Theorem 96 (the head’s vector LayerNorm by Theorem 126), every GELU by Theorem 97, every layer scale by its own diagonal adjoint, every depthwise convolution by Theorem 71, and every residual skip by Theorem 58.

And the fold holds at full depth. convNextForwardTChHasVJP chains the patchify stem, all eighteen ConvNeXt blocks, the three downsamples, the pool and the head through vjpComp, with convNextForwardTChHasVJP_correct carrying it back to the forward the spec denotes. It assumes 23 positive LayerNorm epsilons and nothing else. Unlike Chapter 6, whose full-depth fold is pointwise because relu6’s kink makes a statement over all inputs false, this one is a single global HasVJP over the entire network, and it can be because GELU is smooth everywhere. So for ConvNeXt the accurate sentence is the strong one: every operation in the graph that just trained carries a proved backward, and their composition into the whole network carries one too.

Two new primitives

ConvNeXt requires two activations / normalizations that the chapters so far haven’t needed:

LayerNorm (Ba, Kiros, Hinton 2016, arXiv:1607.06450). BatchNorm normalizes per-feature across a batch: it relies on the batch having enough samples for the mean and variance to be stable. That fails for small batches (memory-constrained training) and for variable-length sequences (where “the batch axis” isn’t a clean concept). LayerNorm solves both by flipping the axis: normalize per-sample across the feature dimension instead. Same three-term Jacobian cancellation as BN (Chapter 4), same closed-form backward. Just a different reduction axis. Theorem 95 states this formally for one vector, and Theorem 96 lifts it to the channel axis of a feature map.

GELU (Hendrycks & Gimpel 2016, arXiv:1606.08415). A smooth approximation of ReLU: \(\mathrm{GELU}(x) = x \cdot \Phi (x)\), where \(\Phi \) is the standard-normal CDF. ReLU has a kink at zero, which forces the codegen to substitute a subgradient convention (Chapter 2). GELU is differentiable everywhere, with a soft-gate behavior near zero that empirically helps transformers (and, it turns out, modernized CNNs). Diagonal activation Jacobian, same family as ReLU’s diagonal, just with a smooth scalar derivative instead of an indicator. Theorems 92–97 provide the scalar function, its derivative, the Jacobian, and the assembled VJP.

Both primitives are used by the upcoming Vision Transformer chapter (Chapter 9) without modification. ConvNeXt introduces them here so they’re available before ViT needs them.

The ConvNeXt block

Each ConvNeXt block stacks the modernization items into a single residual unit:

\[ \text{DW } 7 \times 7 \; \to \; \text{LN} \; \to \; 1 \times 1\ \text{expand } 4\times \; \to \; \text{GELU} \; \to \; 1 \times 1\ \text{project} \; \to \; \text{LayerScale} \; \to \; +\, \text{residual}. \]

Two pieces: the inverted bottleneck (\(1 \times 1\) expand 4\(\times \), depthwise spatial conv at the wide point, \(1 \times 1\) project) is exactly the MobileNetV2 inverted residual from Chapter 6. ConvNeXt is paying the MobileNet idea forward. LayerScale (Touvron et al. 2021) is a learnable per-channel scalar \(\gamma \) initialized to \(10^{-6}\) that multiplies the block’s contribution before the residual add, keeping the block near-identity at init in the same spirit as ResNet’s residual. The spec’s .convNextStage primitive emits \(N\) of these blocks at fixed channel width. The .convNextDownsample primitive emits the LN + \(2 \times 2\) stride-2 conv that transitions between stages.

ConvNeXt-T is four stages, ResNet-style

The full ConvNeXt-T spec is the same stem–stages–head template as ResNet-34. A \(4 \times 4\) stride-4 “patchify” stem replaces the \(7 \times 7\) stride-2 stem. Four stages with block counts \((3, 3, 9, 3)\) at channel widths \((96, 192, 384, 768)\) replace the ResNet stages. A final global-average-pool plus 768-to-10 dense replaces the head. Total: 27.8M parameters, comparable to ResNet-50. Every architectural primitive in the spec is now codegen-backed and has a proved VJP. None of the math in this chapter is genuinely new beyond the LayerNorm-axis rotation and the GELU smoothness.

With a modernized convnet backbone, the dominant axis of variation between runs becomes the training recipe, not the architecture. The example section below makes that concrete. The chapter closes by putting the full recipe side-by-side with the 2018 ResNet-34 recipe of Chapter 5, knob by knob: the four-year jump in how convnets are trained, on one page.

8.2 The theorems

\begin{tikzpicture} [x=1pt,y=1pt, every node/.style={inner sep=1.32pt, font=\fontsize{6.16}{7.39}\selectfont\ttfamily}]
  \node[draw=green!50!black, fill=green!15, rectangle, align=center, minimum width=42.2pt, minimum height=11.2pt] at (232.6pt,169.7pt) {\hyperref[ax:geluScalar]{geluScalar}};
  \node[draw=green!50!black, fill=green!15, rectangle, align=center, minimum width=60.7pt, minimum height=11.2pt] at (257.2pt,128.1pt) {\hyperref[ax:geluScalarDeriv]{geluScalarDeriv}};
  \node[draw=green!50!black, fill=green!15, rectangle, rounded corners=3.5pt, align=center, minimum width=38.5pt, minimum height=11.2pt] at (207.0pt,86.6pt) {\hyperref[ax:pdiv_gelu]{pdiv\_gelu}};
  \node[draw=green!50!black, fill=green!15, rectangle, rounded corners=3.5pt, align=center, minimum width=60.7pt, minimum height=11.2pt] at (429.6pt,128.1pt) {\hyperref[thm:layerNormHasVJP]{layerNormHasVJP}};
  \node[draw=green!50!black, fill=green!15, rectangle, rounded corners=3.5pt, align=center, minimum width=75.5pt, minimum height=11.2pt] at (119.9pt,45.0pt) {\hyperref[thm:chanLNTensor3HasVJP]{chanLNTensor3HasVJP}};
  \node[draw=green!50!black, fill=green!15, rectangle, rounded corners=3.5pt, align=center, minimum width=42.2pt, minimum height=11.2pt] at (207.0pt,45.0pt) {\hyperref[thm:geluHasVJP]{geluHasVJP}};
  \node[draw=green!50!black, fill=green!15, rectangle, rounded corners=3.5pt, align=center, minimum width=75.5pt, minimum height=11.2pt] at (119.9pt,5.6pt) {\hyperref[thm:convnext_whole_back]{convnext\_whole\_back}};
  \node[draw=green!50!black, fill=green!15, rectangle, rounded corners=3.5pt, align=center, minimum width=68.1pt, minimum height=11.2pt] at (325.8pt,86.6pt) {\hyperref[thm:convnext_step_tie]{convnext\_step\_tie}};
  \node[draw=green!50!black, fill=green!15, rectangle, rounded corners=3.5pt, align=center, minimum width=71.8pt, minimum height=11.2pt] at (272.1pt,5.6pt) {\hyperref[thm:convnext_loss_grad]{convnext\_loss\_grad}};
  \node[draw=gray!70, dashed, rounded corners=2pt, minimum width=57.5pt, minimum height=15.6pt, text=gray!60!black, align=center, font=\fontsize{4.84}{5.81}\selectfont\ttfamily] at (119.9pt,86.6pt) {\hyperref[thm:layerNormVecHasVJP]{layerNormVecHasVJP}\\(ch 9)};
  \node[draw=gray!70, dashed, rounded corners=2pt, minimum width=48.8pt, minimum height=15.6pt, text=gray!60!black, align=center, font=\fontsize{4.84}{5.81}\selectfont\ttfamily] at (54.8pt,86.6pt) {\hyperref[thm:transposeHasVJP]{transposeHasVJP}\\(ch 9)};
  \node[draw=gray!70, dashed, rounded corners=2pt, minimum width=48.8pt, minimum height=15.6pt, text=gray!60!black, align=center, font=\fontsize{4.84}{5.81}\selectfont\ttfamily] at (429.6pt,169.7pt) {2 statements of\\ch 1, 4};
  \draw[->, >=stealth, black!70, line width=0.35pt] (235.6pt,163.9pt) .. controls (238.9pt,158.6pt) and (244.3pt,149.9pt) .. (248.8pt,142.5pt) -- (254.1pt,133.9pt);
  \draw[->, >=stealth, black!70, line width=0.35pt] (230.7pt,163.9pt) .. controls (228.2pt,157.1pt) and (223.7pt,144.6pt) .. (220.2pt,133.7pt) .. controls (216.9pt,123.3pt) and (213.5pt,111.5pt) .. (211.0pt,102.4pt) -- (208.4pt,92.6pt);
  \draw[->, >=stealth, black!70, line width=0.35pt] (227.6pt,164.0pt) .. controls (216.7pt,152.9pt) and (190.8pt,124.3pt) .. (181.5pt,94.4pt) .. controls (179.5pt,87.7pt) and (179.3pt,85.3pt) .. (181.5pt,78.7pt) .. controls (184.1pt,71.1pt) and (189.3pt,63.9pt) .. (194.3pt,58.2pt) -- (201.2pt,51.0pt);
  \draw[->, >=stealth, black!70, line width=0.35pt] (211.0pt,168.6pt) .. controls (167.1pt,166.9pt) and (67.5pt,156.1pt) .. (24.0pt,94.4pt) .. controls (0.0pt,60.3pt) and (60.5pt,29.7pt) .. (96.1pt,15.2pt) -- (105.4pt,11.6pt);
  \draw[->, >=stealth, black!70, line width=0.35pt] (244.7pt,163.7pt) .. controls (257.8pt,157.8pt) and (278.5pt,147.2pt) .. (293.3pt,133.7pt) .. controls (303.6pt,124.3pt) and (312.5pt,111.0pt) .. (318.4pt,101.1pt) -- (323.2pt,92.6pt);
  \draw[->, >=stealth, black!70, line width=0.35pt] (253.9pt,164.4pt) .. controls (285.5pt,156.7pt) and (343.5pt,137.0pt) .. (366.3pt,94.4pt) .. controls (385.5pt,58.5pt) and (329.8pt,29.2pt) .. (296.0pt,15.3pt) -- (286.7pt,11.6pt);
  \draw[->, >=stealth, black!70, line width=0.35pt] (251.0pt,122.4pt) .. controls (243.6pt,116.5pt) and (230.8pt,106.3pt) .. (221.0pt,98.5pt) -- (213.2pt,92.4pt);
  \draw[->, >=stealth, black!70, line width=0.35pt] (255.0pt,122.3pt) .. controls (251.1pt,113.3pt) and (242.2pt,93.9pt) .. (232.6pt,78.7pt) .. controls (228.1pt,71.8pt) and (222.6pt,64.5pt) .. (217.8pt,58.5pt) -- (211.5pt,51.0pt);
  \draw[->, >=stealth, black!70, line width=0.35pt] (265.9pt,122.2pt) .. controls (276.5pt,116.0pt) and (294.9pt,105.4pt) .. (308.5pt,97.5pt) -- (317.0pt,92.6pt);
  \draw[->, >=stealth, black!70, line width=0.35pt] (257.8pt,122.4pt) .. controls (259.9pt,105.5pt) and (266.9pt,48.9pt) .. (270.3pt,21.6pt) -- (271.5pt,11.6pt);
  \draw[->, >=stealth, black!70, line width=0.35pt] (207.0pt,80.8pt) .. controls (207.0pt,75.8pt) and (207.0pt,67.9pt) .. (207.0pt,60.9pt) -- (207.0pt,50.8pt);
  \draw[->, >=stealth, black!70, line width=0.35pt] (119.9pt,39.2pt) .. controls (119.9pt,34.7pt) and (119.9pt,27.8pt) .. (119.9pt,21.6pt) -- (119.9pt,11.5pt);
  \draw[->, >=stealth, black!70, line width=0.35pt] (140.8pt,39.0pt) .. controls (166.9pt,32.5pt) and (211.8pt,21.4pt) .. (241.8pt,14.0pt) -- (251.3pt,11.6pt);
  \draw[->, >=stealth, black!70, line width=0.35pt] (195.3pt,39.0pt) .. controls (181.4pt,33.0pt) and (158.0pt,22.9pt) .. (140.9pt,15.6pt) -- (131.8pt,11.6pt);
  \draw[->, >=stealth, black!70, line width=0.35pt] (215.8pt,39.0pt) .. controls (225.8pt,33.3pt) and (242.3pt,23.8pt) .. (254.8pt,16.5pt) -- (263.5pt,11.5pt);
  \draw[->, >=stealth, black!70, line width=0.35pt] (322.5pt,80.7pt) .. controls (314.3pt,68.7pt) and (293.2pt,37.5pt) .. (281.0pt,19.6pt) -- (275.5pt,11.4pt);
  \draw[->, >=stealth, dashed, gray!60, line width=0.35pt] (119.9pt,78.4pt) .. controls (119.9pt,73.5pt) and (119.9pt,66.9pt) .. (119.9pt,61.0pt) -- (119.9pt,50.9pt);
  \draw[->, >=stealth, dashed, gray!60, line width=0.35pt] (66.8pt,78.4pt) .. controls (77.0pt,72.1pt) and (91.8pt,63.1pt) .. (103.2pt,56.1pt) -- (111.8pt,50.9pt);
  \draw[->, >=stealth, dashed, gray!60, line width=0.35pt] (429.6pt,161.5pt) .. controls (429.6pt,156.7pt) and (429.6pt,150.1pt) .. (429.6pt,144.1pt) -- (429.6pt,134.0pt);
\end{tikzpicture}
Definition 92 GELU scalar function
✓
#

The tanh approximation the codegen actually emits (not the exact \(x \cdot \Phi (x)\) erf form): \(\mathrm{geluScalar}(x) = 0.5\, x\, \bigl(1 + \tanh (\sqrt{2/\pi }\, (x + 0.044715\, x^3))\bigr)\).

Definition 93 GELU scalar derivative
✓
#

Defined as Mathlib’s \(\mathrm{deriv}\, \mathrm{geluScalar}\) — so the connection to the forward is automatic, not asserted. The closed form (what the verified geluBack emitter renders) is the separate theorem geluScalarDeriv_eq, proved by assembling HasDerivAt for the inner polynomial, \(\tanh \) (via \(\tanh = \sinh /\cosh \) and the quotient rule), and the outer product.

Theorem 94 GELU Jacobian
✓

prove: \(\operatorname {pdiv}(\mathrm{gelu})\, x\, i\, j = \delta _{ij} \cdot \mathrm{geluScalar}'(x_i)\) — the diagonal activation Jacobian.

Proof ▶

Sketch: the template for every elementwise activation with a smooth scalar function — extract one output coordinate, factor it through a projection, convert back to the scalar derivative.

  1. \(\operatorname {pdiv}(\mathrm{gelu})\, x\, i\, j = \operatorname {fderiv}_{\mathbb {R}}\, (y \mapsto \mathrm{gelu}(y)_j)\, x\, (\mathbf{e}_i)\).
    proof: Definition 1 and fderiv_apply; \(\mathrm{gelu}\) is differentiable because \(\mathrm{geluScalar}\) is (tanh is differentiable everywhere, via \(\tanh = \sinh /\cosh \) with \(\cosh {\gt} 0\)).

  2. \(y \mapsto \mathrm{gelu}(y)_j = \mathrm{geluScalar} \circ \mathrm{proj}_j\), so its derivative is \(\operatorname {fderiv}\, \mathrm{geluScalar}\, (x_j) \circ \mathrm{proj}_j\).
    proof: Chain rule (Mathlib’s fderiv_comp); \(\mathrm{proj}_j\) is a continuous linear map, its own derivative.

  3. Evaluate at \(\mathbf{e}_i\): \(\mathrm{proj}_j(\mathbf{e}_i) = \delta _{ji}\), and the scalar \(\operatorname {fderiv}\) converts to \(\mathrm{deriv}\) (fderiv_eq_smul_deriv), giving \(\delta _{ji} \cdot \mathrm{deriv}\, \mathrm{geluScalar}\, (x_j)\).

  4. q.e.d.
    proof: Case split on \(i = j\); when they coincide, \(\mathrm{deriv}\, \mathrm{geluScalar}\, (x_i)\) is \(\mathrm{geluScalar}'(x_i)\) by Definition 93.

Theorem 95 LayerNorm VJP
✓
#

assume:

  1. \(\varepsilon {\gt} 0\) [h\(\varepsilon \)]

prove: \(\mathsf{HasVJP}\, (\mathrm{layerNormForward})\).

Proof ▶
  1. \(\mathrm{layerNormForward} = \mathrm{bnForward}\), definitionally: on a single feature vector, LayerNorm is the 1D normalization primitive — the BN/LN distinction (which axis, running statistics) is an engineering concern that dissolves at the per-vector math level.

  2. q.e.d.
    proof: Theorem 49 applies verbatim, with assumption 1 as its \(\varepsilon {\gt} 0\) hypothesis.

Theorem 96 Channel-LayerNorm VJP
✓

assume:

  1. \(\varepsilon {\gt} 0\) [h\(\varepsilon \)]

prove: \(\mathsf{HasVJP}\, (\texttt{chanLNTensor3}\; c\; h\; w\; \varepsilon \; \gamma \; \beta )\): LayerNorm over the channel axis at every pixel of a \((c, h, w)\) tensor, with per-channel \(\gamma , \beta \). The ConvNeXt-T chain’s twenty-two channel-LayerNorms are this witness; the head’s, after the pool, is the vector LayerNorm (Theorem 126).

Proof ▶

vjpComp over five proven pieces: reassociate, transpose, the row-wise vector LayerNorm (Theorem 95 at \(\gamma = 1, \beta = 0\), then the per-channel scale and shift), transpose back, reassociate back; see Proofs.chanLNTensor3HasVJP.

Theorem 97 GELU VJP
✓

\(\mathsf{HasVJP}\, (\mathrm{gelu})\), with backward \(B(x, dy)_i = dy_i \cdot \mathrm{geluScalar}'(x_i)\).

Proof ▶
  1. suffices: \(B(x, dy)_i = \sum _j \operatorname {pdiv}(\mathrm{gelu})\, x\, i\, j \cdot dy_j\).
    proof: Definition 9.

  2. q.e.d.
    proof: By the GELU Jacobian (Theorem 94) the sum is diagonal and collapses at \(j = i\) to \(\mathrm{geluScalar}'(x_i) \cdot dy_i\). Same template as ReLU, Swish, h-swish: diagonal Jacobian \(\Rightarrow \) one-step VJP.

Three rungs of the ladder for ConvNeXt-T: the rendered backward is the VJP, with the whole-net VJP folded into it; the emitted step is the certified step; each node is the loss’s derivative.

assume:

  1. \(\varepsilon {\gt} 0\) at the stem’s, the four stages’, the three downsamples’ and the head’s LayerNorms, twenty-three in all [hs\(\varepsilon \), h1–h4, hd1–hd3, hh\(\varepsilon \)]

prove: the twelve-stage ConvNeXt-T input-gradient chain convnextInputGradB equals the backward of the batched forward,

\[ \sum _j \operatorname {pdiv}(\mathrm{batchMap}\, B\; \texttt{convNextForwardTCh})\, x\, i\, j \cdot dy_j , \]

for every batch, number of classes and cotangent.

Proof ▶

The chain is the composed backward read stage by stage, each stage’s VJP the block’s, the downsample’s or the head’s; see Proofs.convnextInputGradB_correct.

prove: each of the 182 parameter gradients of the batched ConvNeXt-T step (\([3, 3, 9, 3]\) blocks, channel-LayerNorm at twenty-two sites, per-channel layer scale, GELU) denotes the certified batch gradient at the chain cotangent of the label-smoothed cross-entropy, with the batch and the number of classes binders: convnext_adam_train_step.mlir and the convnextin_* steps emit these nodes. Stated at one replica, at either precision, with stochastic depth as an optional mask at each block’s one site, between the layer scale and the skip: at none the chain is the drop-free artifacts’, at some the drop renders’, where every node of a block reads the cotangent scaled by its example’s mask and the skip reads it raw.

Proof ▶

A fold over the 182 nodes, the chain’s cotangents the certified block, downsample and head backwards; see Proofs.CnxTieGB.cnx_net_tiedGB.

assume:

  1. \(\varepsilon {\gt} 0\) at the LayerNorms [h\(\varepsilon \)]

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

prove: for each node of Theorem 99, the node at the chain cotangent from \(g\) is \(\partial L/\partial \theta \) of the batched ConvNeXt-T forward at the same mask (cnxNetB; at none, convNextForwardTCh applied per example) with that one parameter varied, for every batch and number of classes. Instantiated at the label-smoothed cross-entropy.

Proof ▶

The step tie’s node, with \(g\) pulled back through the certified block backwards (Theorem 15, once per stage); see Proofs.CnxTieGB.cnx_net_lossGrad.

8.3 Example: ConvNeXt-T on Imagenette

The architecture

ConvNeXt-Tiny replaces the convolutional stages with .convNextStage blocks at four widths (\(96, 192, 384, 768\)) and depths (\(3, 3, 9, 3\)), glued by .convNextDownsample transitions (LayerNorm \(+\) \(2 \times 2\) stride-2 conv). A \(4 \times 4\) stride-4 patchify stem and a global-average-pool head bracket the stack. The inset shows one ConvNeXt block: an inverted bottleneck with a single LayerNorm and GELU, scaled by a learnable \(\gamma \) before the residual add.

\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.56cm, minimum width=6.7cm},
  io/.style     = {col, draw=blue!55!black,   fill=blue!8},
  convbn/.style = {col, draw=orange!65!black, fill=orange!12},
  blk/.style    = {col, draw=brown!70!black,  fill=brown!12},
  norm/.style   = {col, draw=teal!60!black,   fill=teal!10},
  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.16cm of input] (stem) {\textbf{ConvBN} patchify $3\to96$, $4\times4$, /4 \;\; $56{\times}56$};
  \node[blk, below=0.16cm of stem]   (c1)   {$3\times$ \textbf{ConvNeXt} block @\,96 \;\; $56{\times}56$};
  \node[norm, below=0.16cm of c1]    (n1)   {downsample $96\to192$ (LN $+$ $2\times2$/2) \;\; $28{\times}28$};
  \node[blk, below=0.16cm of n1]     (c2)   {$3\times$ \textbf{ConvNeXt} block @\,192 \;\; $28{\times}28$};
  \node[norm, below=0.16cm of c2]    (n2)   {downsample $192\to384$ \;\; $14{\times}14$};
  \node[blk, below=0.16cm of n2]     (c3)   {$9\times$ \textbf{ConvNeXt} block @\,384 \;\; $14{\times}14$};
  \node[norm, below=0.16cm of c3]    (n3)   {downsample $384\to768$ \;\; $7{\times}7$};
  \node[blk, below=0.16cm of n3]     (c4)   {$3\times$ \textbf{ConvNeXt} block @\,768 \;\; $7{\times}7$};
  \node[gap, below=0.16cm of c4]     (g)    {global avg pool \;\; $7\times7\times768 \to 768$};
  \node[head, below=0.16cm of g]     (d)    {\textbf{Dense} $768\to10$ \;(identity)};
  \node[logits, below=0.16cm of d]   (out)  {Logits \;\; 10 classes, softmax-CE};
  \foreach \a/\b in {input/stem,stem/c1,c1/n1,n1/c2,c2/n2,n2/c3,c3/n3,n3/c4,c4/g,g/d,d/out}
     \draw[arr](\a)--(\b);
  \foreach \n/\k in {c1/1,c2/2,c3/3,c4/4}\node[stage] at ($(\n.east)+(0.30,0)$){stage \k};
\end{tikzpicture}
\begin{tikzpicture} [
  >={Stealth[length=1.6mm]},
  every node/.style={font=\sffamily\scriptsize},
  box/.style  = {align=center, rounded corners=2pt, inner sep=2.5pt, minimum height=0.58cm, draw=brown!70!black, fill=brown!12},
  dot/.style  = {circle, draw=brown!70!black, fill=brown!16, 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, brown!70!black, shorten >=1pt},
]
  \node[term] (x){$x$};
  \node[box, right=0.4cm of x]  (dw){DW $7\!\times\!7$};
  \node[box, right=0.32cm of dw](ln){LN};
  \node[box, right=0.32cm of ln](ex){$1\!\times\!1$\\$\uparrow4\times$};
  \node[box, right=0.32cm of ex](ge){GELU};
  \node[box, right=0.32cm of ge](pr){$1\!\times\!1$\\proj};
  \node[box, right=0.32cm of pr](ls){LayerScale\\$\gamma$};
  \node[dot, right=0.4cm of ls] (sum){$+$};
  \node[term, right=0.4cm of sum](y){$y$};
  \foreach \a/\b in {x/dw,dw/ln,ln/ex,ex/ge,ge/pr,pr/ls,ls/sum,sum/y}\draw[arr](\a)--(\b);
  \draw[skip] (x.north) .. controls +(0,0.95) and +(0,0.95) .. (sum.north)
        node[midway, above, term, brown!70!black]{residual};
  \node[term, below=0.16cm of ge, gray!55!black]{one ConvNeXt block: inverted bottleneck, one norm $+$ one activation};
\end{tikzpicture}

The verified trainer

The pattern is §5.3’s, minus bnChannels: there is no BatchNorm.

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

-- 1. The network. `slug` names the committed render. No `bnChannels`:
--    LayerNorm keeps no running statistics, so nothing has to be
--    threaded from training into evaluation.
def convnextVerified : VerifiedNetSpec where
  name     := "ConvNeXt-T"
  slug     := "convnext"
  inC      := 3
  imageH   := 224
  imageW   := 224
  data     := .imagenette
  layers   := [
    .conv 3 96 4 4, .layerNorm 96,       -- patchify 4x4/s4 + stem LN
    .convNextBlockCh 96,  .convNextBlockCh 96,  .convNextBlockCh 96,
    .layerNorm 96,  .conv 96 192 2 2,    -- downsample  96->192, 56->28
    .convNextBlockCh 192, .convNextBlockCh 192, .convNextBlockCh 192,
    .layerNorm 192, .conv 192 384 2 2,   -- downsample 192->384, 28->14
    .convNextBlockCh 384, .convNextBlockCh 384, .convNextBlockCh 384,
    .convNextBlockCh 384, .convNextBlockCh 384, .convNextBlockCh 384,
    .convNextBlockCh 384, .convNextBlockCh 384, .convNextBlockCh 384,
    .layerNorm 384, .conv 384 768 2 2,   -- downsample 384->768, 14->7
    .convNextBlockCh 768, .convNextBlockCh 768, .convNextBlockCh 768,
    .globalAvgPool, .layerNorm 768, .dense 768 10 ]   -- head: GAP -> LN -> dense
  -- Stochastic depth, read only by the `*drop` variants: one global
  -- block index over the eighteen blocks, keep_i = 1 - 0.1*i/17.
  dropSites := Array.range 18
  dropDenom := 17
  dropRate  := 0.1
-- 2. The schedule. ResNet-34's, MobileNetV2's and B0's, unchanged.
def convnextAdamConfig : VerifiedConfig where
  epochs    := 80
  batchSize := 32
-- 3. The entry point: AdamW, cosine with a 3-epoch warmup, baseLR 1e-3.
--    The EMA decay and the drop rate are env knobs.
def runConvNeXtAdam (argv : List String) : IO Unit := do
  let variant := (← IO.getEnv "LEAN_MLIR_VARIANT").getD "adam"
  let emaDecay := match (← IO.getEnv "LEAN_MLIR_EMA_DECAY_U").bind (·.toNat?) with
    | some u => u.toFloat * 1e-6
    | none   => 0.9999
  let dropNet := match (← IO.getEnv "LEAN_MLIR_DROP_RATE_U").bind (·.toNat?) with
    | some u => { convnextVerified.toNet with
                  dropKeeps := convnextVerified.dropKeepsAt (u.toFloat * 1e-6) }
    | none   => convnextVerified.toNet
  dropNet.trainAdamSched convnextAdamConfig
    (argv.head?.getD "data") 0.001 0.9 0.999 3 variant 0.0 1.0 emaDecay
def main (argv : List String) : IO Unit := runConvNeXtAdam argv

The structure follows the paper’s macro-design prescription: 4 stages with block counts \((3, 3, 9, 3)\) and channels doubling on every downsample \((96 \to 192 \to 384 \to 768)\), with the heavy 9-block stage at the \(14 \times 14\) / 384-channel resolution. The stem is a \(4 \times 4\) stride-4 conv (mirrored from ViT’s patch-embed), not the \(7 \times 7\) stride-2 stem of a ResNet, and that is one of the nine modernization items listed at the top of the chapter. The base recipe — AdamW at \(10^{-3}\), cosine with a three-epoch warmup, weight decay \(10^{-4}\), label smoothing \(0.1\) — is Chapter 5’s (§5.6 measures each knob), so the comparison across architectures is of the architecture.

.convNextBlockCh c is one block per line, eighteen of them. Ch names the norm: the LayerNorm inside the block reduces over the c channels at each spatial position, which is ConvNeXt’s real normalization rather than the scalar reduction the bare name suggests.

The arm, and the one tensor with no partner.

  | layerNorm d      => #[(#[d],1),(#[d],2)]           -- Ch. 8: gamma, beta
  | convNextBlockCh c =>                               -- Ch. 8
    #[(#[c,1,7,7],0),(#[c],2),(#[c],1),(#[c],2),       -- dw 7x7 + LN(gamma,beta)
      (#[4*c,c,1,1],0),(#[4*c],2),                     -- expand 1x1 (4x)
      (#[c,4*c,1,1],0),(#[c],2),                       -- project 1x1
      (#[c],3)]                                        -- layer scale, init 1e-6

Eight of those nine tensors pair off into weight-and-bias or \(\gamma \)-and-\(\beta \). The ninth, the trailing (#[c],3), has no partner: layer scale is a single learned per-channel multiplier with nothing added after it, and kind 3 is its own initialiser, the paper’s \(10^{-6}\), so a fresh block starts near the identity on its residual branch. It is the smallest parameter in the chapter and the one whose backward is a pure diagonal.

The wrong init is worth three points here. Until 2026-09-13 the verified path initialised layer scale to ones, and the same render on the same recipe landed at \(85.45\% \) against the \(82.27\% \) above, resolved at \(\pm 1.2\) per run (runs/2026-08-30-convnext-imagenette-headln/). The two are level through epoch 10 and three to four points apart from epoch 20 on (\(80.87\) against \(77.43\) at 20, \(84.18\) against \(80.03\) at 40): with \(\gamma \) at \(10^{-6}\) every residual branch’s gradient is scaled by \(\gamma \), so the branches train late, and 9,469 images over 80 epochs never make the time up. The recipe this init belongs to is 300 epochs on 1.28M images, where a late start costs nothing, and §8.5 is that regime. The book keeps the paper’s value because it is the paper’s and the JAX reference’s; the ones run is the better number on this recipe and not the paper’s initialisation.

The stem and the downsamples are spelled out, not packaged. A .convNextDownsample 96 192 is two entries here, a .layerNorm 96 and a .conv 96 192 2 2, and the patchify stem is likewise a .conv followed by a .layerNorm. The spec’s job is the parameter layout, not the block vocabulary, so it names weights rather than concepts.

182 parameter tensors, 27,827,818 scalars. convnextVerified.toSpecs is kernel-#guarded against ConvNeXtLayout.specs, an independently audited hand-list, and the ImageNet spec of §8.5 is pinned to this one at everything but its head. At 1000 classes the count is 28,589,128, which is timm’s figure for convnext_tiny exactly.

Twenty-three LayerNorms are the entire hypothesis budget. One in the stem, one per block, one per downsample, and one on the head between the pool and the classifier. Theorems 96 and 126 want \(\varepsilon {\gt} 0\) at each, and those 23 positivities are the only assumptions anywhere in the chain.

Results

§8.1 has the run: 80 epochs at about 55 seconds each, one hour and fourteen minutes, \(\mathbf{82.27\% }\) top-1 and \(\mathbf{96.51\% }\) top-5, with the loss settling on the same \({\sim }0.50\) label-smoothing floor as the three chapters before it. The four-chapter comparison — same dataset, same recipe, same eighty epochs, and every row a measurement on the verified XLA path, on one RTX 4060 Ti:

Model

Params

MLIR

Step time

Total

Top-1

Top-5

ResNet-34

21.29M

754 KB

120 ms

47 min

89.99%

98.46%

MobileNetV2

2.24M

1,071 KB

90 ms

35 min

89.25%

98.68%

EfficientNet-B0

4.02M

1,317 KB

103 ms

41 min

90.04%

98.29%

ConvNeXt-T

27.83M

1,019 KB

188 ms

1.2 h

82.27%

96.51%

Columns as in Chapter 7’s table; ResNet-34’s row is the five-seed mean of §5.6 (runs/2026-09-12-r34-ablation-fp32-seeds/) with one seed’s timing.

  • The largest convnet in the column finishes last among them, and by a clear margin: \(82.27\% \) against \(89.99 \pm 0.32\) for ResNet-34 and \(90.04\% \) for EfficientNet-B0, from \(1.3\times \) and \(6.9\times \) their parameters. Three things contribute. ConvNeXt-T carries 27.83M parameters against 9,469 training images, which is not the data scale that capacity wants. The recipe here is the shared one, AdamW at \(10^{-3}\) with cosine and label smoothing, which is deliberately not the recipe ConvNeXt was designed around. And its layer scale starts at the paper’s \(10^{-6}\), which on this short schedule is worth three points on its own (above).

  • That is the chapter’s thesis arriving as a result rather than a claim. ConvNeXt’s paper argues the modernization lives in the training recipe, so a ConvNeXt run on somebody else’s recipe should underperform, and it does. §8.5 is the control: the same architecture, given the full DeiT pack and 300 epochs, reaches \(81.53\% \) on ImageNet-1k and beats every other network in this book. The architecture was never the variable.

  • It is the slowest per step, at 188 ms against ResNet-34’s 120 and EfficientNet-B0’s 103. The depthwise \(7 \times 7\) and the channel LayerNorms touch every spatial position twice per block, and the \(4\times \) expansion makes its \(1 \times 1\)s the largest matmuls in the column.

  • The MLIR is mid-pack at 1,019 KB, above ResNet-34’s 754 and below MobileNetV2’s 1,047 and EfficientNet-B0’s 1,316. Block count is not what separates them, since ConvNeXt’s eighteen is more than either. What separates them is what one block costs to emit: ConvNeXt spends three convolutions and a single LayerNorm per block, while an inverted residual spends three convolutions and three BatchNorms, and an MBConv adds the squeeze-excitation pair on top of that. One normalization per block instead of one per layer was item seven on the modernization list, and it is visible in the file size.

8.4 MLIR: Layer Scale

Layer scale is a per-channel learnable diagonal, \(\mathrm{layerScale}(\gamma , x) = \gamma \odot x\), and it is the simplest operator in this series. Its Jacobian is diagonal, so it is its own adjoint: layerScaleHasVJP proves \(\mathrm{back}(x, dy) = \gamma \odot dy\), the backward multiplying by the same \(\gamma \) the forward did. There is nothing to bridge past that identity, so the emitted graph is two multiplys against one tensor:

// ConvNeXt block tail:  out = x + layerScale(gamma, project(...))
%ls  = stablehlo.multiply %gls, %pr   : tensor<1x2x4x4xf32> // gamma (*) pr
%out = stablehlo.add %x, %ls          : tensor<1x2x4x4xf32> // + identity skip
// backward, cotangent %dOut:  d(pr) = gamma (*) d(out)
%dpr = stablehlo.multiply %gls, %dOut : tensor<1x2x4x4xf32>

The forward multiply %gls, %pr and the backward multiply %gls, %dOut are the same op against the same \(\gamma \) tensor: a diagonal map is its own transpose, so its VJP is itself. The identity skip is the residual fan-in (Theorem 58). The surrounding GELU, LayerNorm, and convolution backwards reuse their bridges. Every one is unconditional (the only hypothesis anywhere is LayerNorm’s \(\epsilon {\gt} 0\)): ConvNeXt has no kinked operator, and neither do EfficientNet-B0 and the Vision Transformer, the two other chapter nets whose whole-network VJP needs only \(\epsilon {\gt} 0\). The full block is \(\mathrm{residual}(\mathrm{layerScale} \circ \mathrm{project} \circ \mathrm{gelu} \circ \mathrm{expand} \circ \mathrm{LN} \circ \mathrm{depthwise})\), composed by convNextBlockHasVJP.

8.5 ImageNet recipe

ConvNeXt’s architecture reached this book through the Imagenette example earlier in this chapter, and the recipe runs on both paths — the reference first, then the verified path, then the two side by side.

The reference (JAX)

The reference port wires the two ConvNeXt-specific layers (.convNextStage, .convNextDownsample) into the JAX codegen. Because that path differentiates with jax.value_and_grad, only the forward had to be written, and the backward comes for free. The spec mirrors jax/MainConvNeXtImagenet.lean, its recipe quoted as the run below trained it (the file today carries the official rows, the marker’s, for the rerun):

-- Faithful patchify stem, stage/downsample backbone, 1000 classes.
def convNeXtTinyImagenet : NetSpec where
  name   := "ConvNeXt-T (ImageNet, bf16)"
  imageH := 224
  imageW := 224
  layers := [
    .convNextStem 3 96 4,              -- patchify: 4x4 s4 conv -> channel-LN (no BN/ReLU)
    .convNextStage 96 3 .ln .gelu,     -- stage 1: 3 blocks @ 96  (56x56)
    .convNextDownsample 96 192,        -- 56 -> 28
    .convNextStage 192 3 .ln .gelu,    -- stage 2: 3 blocks @ 192
    .convNextDownsample 192 384,       -- 28 -> 14
    .convNextStage 384 9 .ln .gelu,    -- stage 3: 9 blocks @ 384
    .convNextDownsample 384 768,       -- 14 -> 7
    .convNextStage 768 3 .ln .gelu,    -- stage 4: 3 blocks @ 768
    .globalAvgPool,
    .layerNorm 768,                    -- head LN: the paper's GAP -> LN -> Linear
    .dense 768 1000 .identity          -- 1000-class head
  ]

-- ConvNeXt needs AdamW, not SGD; full DeiT-style recipe.
def convNeXtTinyImagenetConfig : TrainConfig where
  learningRate      := 2.5e-4       -- 4e-3 @ batch 4096, linearly scaled to 256
  batchSize         := 256
  epochs            := 80           -- validation tier of an 80->300 ladder
  optimizer         := .adam        -- AdamW (decoupled weight decay)
  weightDecay       := 0.05
  wdExcludeNormBias := true         -- timm no_weight_decay: skip norm g/b, bias, LayerScale
  cnxInit           := true         -- ConvNeXt _init_weights: trunc_normal(0.02) on every conv
  cosineDecay       := true
  warmupEpochs      := 20           -- ConvNeXt paper warmup (was 5)
  gradClipNorm      := 1.0          -- ours: the paper does not clip
  augment           := true
  useRandAugment    := true         -- geometric RandAugment N=2, M=9, mstd0.5, inc1
  randAugmentGeometric := true
  augBicubic        := true         -- PIL-bicubic geometry, as timm (after the run below)
  randAugmentMstd   := 0.5
  randAugmentInc    := true
  useMixup          := true         -- Mixup alpha 0.8 ...
  useCutmix         := true         -- ... + CutMix alpha 1.0 (alternates per step) ...
  randomErasing     := true         -- ... + Random Erasing p 0.25 (full DeiT pack)
  randomErasingProb := 0.25
  erasingPixel      := true         -- timm mode='pixel', N(0,1) fill (after the run below)
  labelSmoothing    := 0.1
  bf16              := true
  bf16Conv          := true         -- dw-7x7 + 1x1s; LN/GELU stay fp32
  f32StemHead       := true         -- classifier head in fp32
  useEMA            := true         -- weight averaging, decay 0.9999
  dropPath          := 0.1          -- stochastic depth (ConvNeXt-T paper value)

-- 300-epoch "full" tier: same config, longer schedule.
def convNeXtTinyImagenetConfigFull : TrainConfig :=
  { convNeXtTinyImagenetConfig with epochs := 300 }

Unlike MobileNet and EfficientNet, ConvNeXt trains with AdamW and a small decoupled weight decay, not SGD. The modernized-convnet recipe is the architecture’s whole thesis. bf16Conv pays off especially here: the \(7\times 7\) depthwise is \(\sim 2.3\times \) faster in bfloat16 (a large kernel has enough arithmetic for the tensor cores to bite, unlike the \(3\times 3\) depthwise, which is a wash), and the inverted-bottleneck \(1\times 1\)s are matmuls that love it. The channel LayerNorm and GELU stay in fp32.

The elements of the paper recipe, wired and on:

  • the faithful patchify stem (.convNextStem: \(4\times 4\) stride-4 conv \(\to \) channel-LayerNorm, no BN/ReLU)

  • stochastic depth (drop-path 0.1) and EMA (decay 0.9999), together \({{\lt}}5\% \) per epoch

  • decoupled weight decay that excludes the norm/bias/LayerScale 1-D params (wdExcludeNormBias, timm’s no_weight_decay)

  • the full DeiT-style augmentation pack: Mixup \(\alpha 0.8\), CutMix \(\alpha 1.0\), Random Erasing \(p0.25\), and geometric RandAugment (\(N{=}2\), \(M{=}9\), mstd0.5-inc1)

The peak LR is \(2.5\mathrm{e}{-}4\) (the \(4\mathrm{e}{-}3\)@4096 official value linearly scaled to batch 256) over a 20-epoch warmup. No deviation from the paper’s architecture remains but the GELU, which is the tanh approximation on both paths (Definition 92); where the recipe departs from the paper’s is the marker after the side-by-side table. The run below is the full 300-epoch tier, trained and evaluated on an antialiased bicubic resize and scored at the paper’s protocol, \(224\) with a \(0.875\) centre crop.

GPU

Epochs

Per epoch

Wall-clock

Val top-1

Val top-5

4\(\times \) 3060

300

\(15.3\) min

\(76.5\) hr

\(\mathbf{81.53\% }\)

\(\mathbf{95.50\% }\)

Both figures are the exponential moving average of the weights, scored over all \(50{,}000\) validation images. The raw weights score \(81.51\% \) / \(95.50\% \), so weight averaging is worth \(+0.03\) points here — the cosine schedule anneals the learning rate to zero over the final epochs, and weights that have stopped moving leave the average nothing to remove.

The full 300-epoch tier (convnext-tiny-imagenet full, convNeXtTinyImagenetConfigFull) reached \(\mathbf{81.53\% }\) top-1 and \(\mathbf{95.50\% }\) top-5 on the held-out validation pass. That is the strongest result in this book’s sweep by a wide margin, ahead of ResNet-50 RSB-A3 (\(77.75\% \)), EfficientNet-B0 (\(77.15\% \)) and ResNet-34 (\(74.14\% \)), and it lands within \(\sim \)0.6% of ConvNeXt-T’s \(82.1\% \) paper headline from a from-scratch Lean\(\to \)JAX-codegen reimplementation. Two candidate explanations for that residual can be ruled out from this run: weight averaging is measured above and accounts for none of it, and the evaluation is the paper’s own, \(224\) at a \(0.875\) crop. What is left is the recipe departures above, not the architecture and not the eval protocol.

The validation curve tells the augmentation story directly: a deliberately slow start, since Mixup and CutMix blend labels, so the net under-trains for the first \(\sim \)20 epochs, compounded by the EMA shadow lagging the live weights. Then it climbs for the rest of the schedule without an overfitting turn, which is what the heavy-regularization regime predicts and what makes the long schedule worth buying.

Learning-rate robustness. The full pack was also a test of ConvNeXt’s learning-rate robustness under AdamW, the test Chapter 7 ran on RMSProp. It is robust: at the \(2.5\mathrm{e}{-}4\) peak with global-norm gradient clipping at \(1.0\), top-1 climbed across the whole 300-epoch schedule with no erosion and no divergence, the opposite of B0’s RMSProp blow-up. AdamW’s bounded, decoupled step plus the grad-clip makes the peak LR a non-event, which is what lets the heavy augmentation run to schedule instead of destabilizing it early.

\begin{tikzpicture} 
\begin{axis}[
    width=0.92\linewidth, height=6.5cm,
    xlabel={Epoch}, ylabel={Validation accuracy (\%)},
    xmin=0, xmax=305, ymin=0, ymax=100,
    xtick={0,50,100,150,200,250,300}, ytick={20,40,60,80,100},
    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=0.8pt, mark=none},
]
\addplot[blue] coordinates {
(1,0.64) (2,0.68) (3,1.30) (4,2.34) (5,3.83) (6,6.48) (7,10.54) (8,15.02) (9,19.95) (10,25.63) (11,31.31) (12,36.78) (13,41.73) (14,45.82) (15,49.22) (16,52.20) (17,55.00) (18,57.53) (19,59.66) (20,61.51) (21,63.28) (22,64.69) (23,66.12) (24,67.16) (25,68.02) (26,68.73) (27,69.46) (28,70.07) (29,70.56) (30,71.15) (31,71.58) (32,71.97) (33,72.25) (34,72.66) (35,73.09) (36,73.24) (37,73.51) (38,73.76) (39,73.98) (40,74.17) (41,74.39) (42,74.56) (43,74.74) (44,74.90) (45,75.09) (46,75.16) (47,75.35) (48,75.47) (49,75.60) (50,75.72) (51,75.84) (52,76.01) (53,76.13) (54,76.26) (55,76.30) (56,76.40) (57,76.52) (58,76.66) (59,76.70) (60,76.80) (61,76.97) (62,76.93) (63,76.98) (64,77.09) (65,77.10) (66,77.16) (67,77.18) (68,77.28) (69,77.34) (70,77.49) (71,77.55) (72,77.66) (73,77.71) (74,77.81) (75,77.78) (76,77.73) (77,77.88) (78,77.96) (79,77.86) (80,77.98) (81,77.95) (82,78.09) (83,78.11) (84,78.12) (85,78.16) (86,78.27) (87,78.40) (88,78.33) (89,78.31) (90,78.36) (91,78.43) (92,78.43) (93,78.58) (94,78.57) (95,78.62) (96,78.55) (97,78.57) (98,78.67) (99,78.74) (100,78.79) (101,78.78) (102,78.78) (103,78.82) (104,78.88) (105,78.82) (106,78.92) (107,79.00) (108,79.14) (109,79.16) (110,79.10) (111,79.14) (112,79.23) (113,79.21) (114,79.21) (115,79.18) (116,79.24) (117,79.31) (118,79.24) (119,79.19) (120,79.25) (121,79.27) (122,79.36) (123,79.34) (124,79.45) (125,79.48) (126,79.48) (127,79.44) (128,79.55) (129,79.61) (130,79.59) (131,79.61) (132,79.66) (133,79.73) (134,79.70) (135,79.70) (136,79.65) (137,79.61) (138,79.68) (139,79.66) (140,79.73) (141,79.72) (142,79.77) (143,79.80) (144,79.88) (145,79.96) (146,79.93) (147,79.90) (148,79.98) (149,80.03) (150,80.15) (151,80.18) (152,80.20) (153,80.14) (154,80.13) (155,80.10) (156,80.14) (157,80.18) (158,80.21) (159,80.25) (160,80.30) (161,80.34) (162,80.44) (163,80.42) (164,80.32) (165,80.36) (166,80.37) (167,80.51) (168,80.57) (169,80.54) (170,80.50) (171,80.59) (172,80.61) (173,80.55) (174,80.54) (175,80.57) (176,80.65) (177,80.72) (178,80.75) (179,80.74) (180,80.81) (181,80.77) (182,80.73) (183,80.72) (184,80.71) (185,80.73) (186,80.76) (187,80.82) (188,80.79) (189,80.91) (190,80.94) (191,80.86) (192,80.84) (193,80.82) (194,80.95) (195,80.89) (196,81.03) (197,80.97) (198,80.95) (199,80.99) (200,80.92) (201,80.92) (202,80.91) (203,81.07) (204,81.08) (205,81.07) (206,81.01) (207,81.01) (208,81.13) (209,81.12) (210,81.12) (211,81.20) (212,81.17) (213,81.15) (214,81.14) (215,81.16) (216,81.19) (217,81.19) (218,81.19) (219,81.15) (220,81.13) (221,81.15) (222,81.17) (223,81.28) (224,81.22) (225,81.30) (226,81.33) (227,81.33) (228,81.28) (229,81.23) (230,81.23) (231,81.24) (232,81.29) (233,81.29) (234,81.31) (235,81.28) (236,81.26) (237,81.34) (238,81.39) (239,81.43) (240,81.41) (241,81.42) (242,81.49) (243,81.45) (244,81.41) (245,81.47) (246,81.46) (247,81.54) (248,81.54) (249,81.49) (250,81.51) (251,81.48) (252,81.51) (253,81.46) (254,81.47) (255,81.44) (256,81.44) (257,81.48) (258,81.44) (259,81.45) (260,81.47) (261,81.40) (262,81.47) (263,81.45) (264,81.45) (265,81.40) (266,81.41) (267,81.40) (268,81.40) (269,81.40) (270,81.37) (271,81.39) (272,81.46) (273,81.43) (274,81.46) (275,81.50) (276,81.53) (277,81.55) (278,81.53) (279,81.53) (280,81.53) (281,81.48) (282,81.49) (283,81.52) (284,81.49) (285,81.48) (286,81.51) (287,81.48) (288,81.51) (289,81.52) (290,81.52) (291,81.55) (292,81.54) (293,81.55) (294,81.49) (295,81.49) (296,81.52) (297,81.54) (298,81.52) (299,81.54) (300,81.53)
};
\addlegendentry{top-1}
\addplot[orange] coordinates {
(1,2.47) (2,2.81) (3,4.74) (4,7.79) (5,11.38) (6,17.87) (7,25.37) (8,33.19) (9,41.29) (10,49.01) (11,56.01) (12,61.97) (13,66.93) (14,70.84) (15,73.65) (16,76.28) (17,78.78) (18,80.87) (19,82.51) (20,83.88) (21,85.17) (22,86.16) (23,86.95) (24,87.64) (25,88.31) (26,88.75) (27,89.09) (28,89.45) (29,89.83) (30,90.14) (31,90.43) (32,90.70) (33,90.95) (34,91.11) (35,91.26) (36,91.47) (37,91.63) (38,91.73) (39,91.88) (40,91.98) (41,92.15) (42,92.25) (43,92.33) (44,92.42) (45,92.50) (46,92.65) (47,92.67) (48,92.68) (49,92.72) (50,92.87) (51,92.91) (52,92.97) (53,93.03) (54,93.10) (55,93.09) (56,93.19) (57,93.26) (58,93.30) (59,93.37) (60,93.43) (61,93.41) (62,93.43) (63,93.55) (64,93.62) (65,93.61) (66,93.65) (67,93.63) (68,93.71) (69,93.77) (70,93.80) (71,93.73) (72,93.75) (73,93.81) (74,93.89) (75,93.95) (76,93.98) (77,93.99) (78,93.96) (79,93.98) (80,94.04) (81,94.05) (82,94.08) (83,94.11) (84,94.10) (85,94.10) (86,94.15) (87,94.14) (88,94.23) (89,94.26) (90,94.28) (91,94.29) (92,94.29) (93,94.35) (94,94.30) (95,94.31) (96,94.36) (97,94.36) (98,94.38) (99,94.38) (100,94.42) (101,94.44) (102,94.40) (103,94.39) (104,94.40) (105,94.43) (106,94.47) (107,94.44) (108,94.51) (109,94.44) (110,94.50) (111,94.52) (112,94.56) (113,94.60) (114,94.59) (115,94.62) (116,94.63) (117,94.65) (118,94.61) (119,94.68) (120,94.67) (121,94.65) (122,94.72) (123,94.75) (124,94.79) (125,94.78) (126,94.73) (127,94.79) (128,94.81) (129,94.86) (130,94.84) (131,94.85) (132,94.82) (133,94.87) (134,94.89) (135,94.92) (136,94.87) (137,94.90) (138,94.93) (139,94.93) (140,94.98) (141,94.98) (142,94.96) (143,94.93) (144,94.95) (145,94.91) (146,94.96) (147,94.99) (148,95.06) (149,95.10) (150,95.05) (151,95.11) (152,95.09) (153,95.00) (154,95.06) (155,95.14) (156,95.15) (157,95.12) (158,95.18) (159,95.23) (160,95.20) (161,95.19) (162,95.20) (163,95.19) (164,95.18) (165,95.23) (166,95.25) (167,95.19) (168,95.21) (169,95.24) (170,95.21) (171,95.29) (172,95.34) (173,95.35) (174,95.31) (175,95.34) (176,95.34) (177,95.30) (178,95.39) (179,95.40) (180,95.37) (181,95.41) (182,95.41) (183,95.39) (184,95.38) (185,95.43) (186,95.42) (187,95.39) (188,95.43) (189,95.42) (190,95.46) (191,95.44) (192,95.44) (193,95.47) (194,95.43) (195,95.48) (196,95.48) (197,95.47) (198,95.46) (199,95.44) (200,95.47) (201,95.50) (202,95.51) (203,95.48) (204,95.46) (205,95.46) (206,95.47) (207,95.49) (208,95.48) (209,95.47) (210,95.43) (211,95.46) (212,95.49) (213,95.49) (214,95.52) (215,95.52) (216,95.55) (217,95.53) (218,95.51) (219,95.53) (220,95.49) (221,95.47) (222,95.52) (223,95.52) (224,95.50) (225,95.53) (226,95.53) (227,95.52) (228,95.56) (229,95.56) (230,95.58) (231,95.53) (232,95.53) (233,95.52) (234,95.55) (235,95.55) (236,95.56) (237,95.55) (238,95.53) (239,95.54) (240,95.51) (241,95.52) (242,95.49) (243,95.52) (244,95.50) (245,95.54) (246,95.56) (247,95.53) (248,95.56) (249,95.52) (250,95.56) (251,95.54) (252,95.55) (253,95.53) (254,95.54) (255,95.51) (256,95.54) (257,95.53) (258,95.52) (259,95.53) (260,95.51) (261,95.51) (262,95.50) (263,95.51) (264,95.54) (265,95.54) (266,95.57) (267,95.53) (268,95.51) (269,95.51) (270,95.48) (271,95.50) (272,95.52) (273,95.50) (274,95.51) (275,95.51) (276,95.50) (277,95.49) (278,95.50) (279,95.50) (280,95.51) (281,95.49) (282,95.50) (283,95.51) (284,95.52) (285,95.49) (286,95.51) (287,95.51) (288,95.49) (289,95.49) (290,95.50) (291,95.49) (292,95.49) (293,95.48) (294,95.50) (295,95.50) (296,95.50) (297,95.49) (298,95.50) (299,95.51) (300,95.51)
};
\addlegendentry{top-5}
\end{axis}
\end{tikzpicture}

ConvNeXt-T / ImageNet-1k validation accuracy per epoch over the full 300-epoch schedule (bf16, 4\(\times \) 3060), every point the EMA weights scored over all \(50{,}000\) validation images. Neither curve turns over: both rise through the schedule and flatten across the final \(\sim \)40 epochs, where the cosine has annealed the learning rate to zero.

The verified path (PJRT)

The verified trainer trains the same network through the proof-rendered StableHLO that §8.1 ran at Imagenette scale. It is apps/imagenette/MainConvNeXtImagenet.lean, built as lake build convnext-imagenet-verified:

-- Identical backbone, 1000 classes instead of 10.
def convnextImagenetVerified : VerifiedNetSpec where
  name       := "ConvNeXt-T (ImageNet-1k)"
  slug       := "convnextin"
  data       := .imagenet
  shimScript := "generated_convnext_tiny_imagenet_shim.py"
  layers     := [ ... the Imagenette stack, 1000-class head ... ]

-- 300 epochs, because that is the schedule the reference number
-- above was measured on. Batch is 64 PER DEVICE, the reference's
-- global 256 over four replicas.
def convnextImagenetConfig : VerifiedConfig where
  epochs    := 300
  batchSize := 64
  cnxInit   := true   -- ConvNeXt _init_weights: trunc_normal(0.02), as the reference

The head is the only parameter shape that moves, from \(768 \times 10\) to \(768 \times 1000\), which takes the count from 27,827,818 to 28,589,128. Three kernel #guards hold the two specs together at everything else: equal toSpecs.size, equal toSpecs.pop.pop, and a back! of (#[1000], 2).

The collectives are gated by two gates, and they prove different things. tests/TestConvNeXtDpCheck.lean pins the data-parallel forward bit-exactly against its single-device peer. That gate hands both replicas the same rows, so it is structurally blind to a shard-offset bug, and tests/TestConvNeXtShardCheck.lean closes exactly that hole by giving the replicas genuinely different data. ConvNeXt is where the shard check was written before being generalized to the other nets, so this is the one chapter where both gates are native rather than inherited.

The regularizer knobs are render variants, and the run carries three of the four. Weight-decay exclusion, stochastic depth and this book’s gradient clip are spelled wx, drop and clip, and convnextin_adamdpwxclipdropbf16 carries all three at once on top of AdamW, bf16 and the four-way all-reduce. It is the artifact the run below used; Theorem 99 reaches its bf16 gradient nodes at its eighteen drop-path sites. EMA is rendered too, and convnextin_emadpwxclipdropbf16 carries all four, but it landed after this run, which trained adamdpwxclipdropbf16 and scores its raw weights, and the pairing below is made against the reference’s raw arm, which the reference section measured at \(0.03\) points under its EMA. Mixup and CutMix never enter the graph at all, because they are data-side and ride the shim. It runs with:

lake run cnx-default-4gpu

GPU

Precision

Per epoch

Epochs

Total

Val top-1

Val top-5

4\(\times \) 3060 (CUDA)

bf16

18.3 min

300

91.3 hr

\(\mathbf{81.30\% }\)

\(\mathbf{95.61\% }\)

Global batch 256 as \(4 \times 64\) replicas at the reference’s \(2.5\mathrm{e}{-4}\) and its 5,004 steps per epoch; the raw weights at epoch 300, scored over the same \(50{,}000\) validation images. The best epoch was 291, at \(81.39\% \).

The two paths, side by side

 

reference (JAX)

verified path (PJRT)

lowering

JAX \(\to \) XLA

pretty(provenGraph) \(\to \) XLA

Python in the step

yes

none

ms/step

179

220

per epoch

15.3 min

18.3 min

total

76.5 hr

91.3 hr

EMA

trained, scored per epoch

none

evaluation forward

bf16

fp32

classifier head

bf16

fp32

precision

bf16 convolutions

bf16 convolutions

global batch

256 (\(4\times 64\))

256 (\(4\times 64\))

steps/epoch

5,004

5,004

peak LR

\(2.5\mathrm{e}{-4}\)

\(2.5\mathrm{e}{-4}\)

init

trunc-normal \(0.02\)

trunc-normal \(0.02\)

scored

raw weights, epoch 300

raw weights, epoch 300

val top-1

\(\mathbf{81.51\% }\)

\(\mathbf{81.30\% }\)

val top-5

\(\mathbf{95.50\% }\)

\(\mathbf{95.61\% }\)

Both columns are ConvNeXt-T on the paper’s recipe, 300 epochs, on the same four RTX 3060 cards, scored over the same \(50{,}000\) validation images. Rows above the first rule differ; rows between the rules are held equal by construction. The reference’s headline earlier is its EMA; its row here is its raw weights, because that is the arm the verified path has. [TODO: the official recipe in four places, on both paths: batch \(4096\) (accumulated \(16\times \) at \(256\), exact without BatchNorm), no gradient clip, Mixup or CutMix drawn per batch, torchvision’s crop; plus the exact erf GELU, the cosine floor at \(10^{-6}\), RandAugment’s fill at the image mean, the EMA decay constant from step \(0\), an fp32 evaluation forward, and scoring at timm’s \(288\) / \(1.0\). The EMA pair, in code since 2026-10-12, has not run; one rerun of both halves takes all of it — cnx-default-jax-4gpu / cnx-default-emabf16-4gpu, \({\sim }76 + 109\) h.]

The two paths agree to \(0.21\) points on top-1 and \(0.11\) on top-5, inside the 95% Wilson interval (\(\pm 0.34\) at \(81\% \) on \(50{,}000\) images), at one seed each. This is the chapter where the pair can say more than that, because both checkpoints were scored on the same images in the same order, so the comparison can be paired instead of read off two intervals. Of the \(50{,}000\), both paths get \(38{,}850\) right and \(7{,}445\) wrong; \(1{,}903\) are right only on the reference and \(1{,}802\) only on the verified path. McNemar’s exact test asks whether those \(3{,}705\) discordant images split unevenly, and answers \(p = 0.10\) (scripts/demos/mcnemar.py): a net of \(101\) flips does not separate the two checkpoints. The alignment was checked rather than assumed — the two agree on \(92.6\% \) of images, where unrelated orderings would agree on \(69.7\% \). What the pair does not isolate is variance. The drop-path and Mixup masks are host-drawn on one path and jax.random’s on the other, and the seeds differ, so \(7.4\% \) of images decided differently is what two ConvNeXt-Ts differing in seed and mask source disagree on here; a two-seed pair on one path, which would bound the spread alone, has not been run. “Not separable” is the claim, not “identical”. Like ViT, ConvNeXt has no BatchNorm to normalize over a different group; unlike ViT, its verified arm ran without EMA, so this is a two-variable pair with the second variable measured at \(0.03\).

The curves are read with one caveat, and it is the EMA. The reference’s per-epoch evaluation scores its EMA shadow (decay \(0.9999\), about a two-epoch time constant) and the verified path scores its raw weights, so mid-schedule the figure compares an averaged model to a live one. The figure shows it: the verified path leads from epoch 3 to 17, by up to \(2.8\) points, while the shadow lags a fast climb; it trails by up to \(4.1\) points at epoch 33, at peak learning rate, where averaging is worth the most; and the gap closes as the cosine anneals, to a mean of \(0.28\) points over the last fifty epochs. Only the final pairing above is raw against raw. A raw reference curve cannot be rebuilt after the fact — the reference kept raw weights only for its last three epochs — which is why the reference section measured the EMA’s worth at the end, where it is \(0.03\), and not along the way, where it is several points.

epoch

5

50

100

150

200

250

300

reference (JAX, EMA)

3.83

75.72

78.79

80.15

80.92

81.51

81.53

verified path (PJRT, raw)

4.24

72.22

75.85

77.88

79.57

81.00

81.30

\(\Delta \)

\(+0.41\)

\(-3.50\)

\(-2.94\)

\(-2.27\)

\(-1.35\)

\(-0.51\)

\(-0.23\)

\begin{tikzpicture} 
\begin{axis}[
    width=0.92\linewidth, height=6.5cm,
    xlabel={Epoch}, ylabel={Validation top-1 (\%)},
    xmin=0, xmax=305, ymin=0, ymax=90,
    xtick={0,50,100,150,200,250,300}, 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=0.8pt, mark=none},
]
\addplot[blue] coordinates {
(1,0.64) (2,0.68) (3,1.30) (4,2.34) (5,3.83) (6,6.48) (7,10.54) (8,15.02) (9,19.95) (10,25.63) (11,31.31) (12,36.78) (13,41.73) (14,45.82) (15,49.22) (16,52.20) (17,55.00) (18,57.53) (19,59.66) (20,61.51) (21,63.28) (22,64.69) (23,66.12) (24,67.16) (25,68.02) (26,68.73) (27,69.46) (28,70.07) (29,70.56) (30,71.15) (31,71.58) (32,71.97) (33,72.25) (34,72.66) (35,73.09) (36,73.24) (37,73.51) (38,73.76) (39,73.98) (40,74.17) (41,74.39) (42,74.56) (43,74.74) (44,74.90) (45,75.09) (46,75.16) (47,75.35) (48,75.47) (49,75.60) (50,75.72) (51,75.84) (52,76.01) (53,76.13) (54,76.26) (55,76.30) (56,76.40) (57,76.52) (58,76.66) (59,76.70) (60,76.80) (61,76.97) (62,76.93) (63,76.98) (64,77.09) (65,77.10) (66,77.16) (67,77.18) (68,77.28) (69,77.34) (70,77.49) (71,77.55) (72,77.66) (73,77.71) (74,77.81) (75,77.78) (76,77.73) (77,77.88) (78,77.96) (79,77.86) (80,77.98) (81,77.95) (82,78.09) (83,78.11) (84,78.12) (85,78.16) (86,78.27) (87,78.40) (88,78.33) (89,78.31) (90,78.36) (91,78.43) (92,78.43) (93,78.58) (94,78.57) (95,78.62) (96,78.55) (97,78.57) (98,78.67) (99,78.74) (100,78.79) (101,78.78) (102,78.78) (103,78.82) (104,78.88) (105,78.82) (106,78.92) (107,79.00) (108,79.14) (109,79.16) (110,79.10) (111,79.14) (112,79.23) (113,79.21) (114,79.21) (115,79.18) (116,79.24) (117,79.31) (118,79.24) (119,79.19) (120,79.25) (121,79.27) (122,79.36) (123,79.34) (124,79.45) (125,79.48) (126,79.48) (127,79.44) (128,79.55) (129,79.61) (130,79.59) (131,79.61) (132,79.66) (133,79.73) (134,79.70) (135,79.70) (136,79.65) (137,79.61) (138,79.68) (139,79.66) (140,79.73) (141,79.72) (142,79.77) (143,79.80) (144,79.88) (145,79.96) (146,79.93) (147,79.90) (148,79.98) (149,80.03) (150,80.15) (151,80.18) (152,80.20) (153,80.14) (154,80.13) (155,80.10) (156,80.14) (157,80.18) (158,80.21) (159,80.25) (160,80.30) (161,80.34) (162,80.44) (163,80.42) (164,80.32) (165,80.36) (166,80.37) (167,80.51) (168,80.57) (169,80.54) (170,80.50) (171,80.59) (172,80.61) (173,80.55) (174,80.54) (175,80.57) (176,80.65) (177,80.72) (178,80.75) (179,80.74) (180,80.81) (181,80.77) (182,80.73) (183,80.72) (184,80.71) (185,80.73) (186,80.76) (187,80.82) (188,80.79) (189,80.91) (190,80.94) (191,80.86) (192,80.84) (193,80.82) (194,80.95) (195,80.89) (196,81.03) (197,80.97) (198,80.95) (199,80.99) (200,80.92) (201,80.92) (202,80.91) (203,81.07) (204,81.08) (205,81.07) (206,81.01) (207,81.01) (208,81.13) (209,81.12) (210,81.12) (211,81.20) (212,81.17) (213,81.15) (214,81.14) (215,81.16) (216,81.19) (217,81.19) (218,81.19) (219,81.15) (220,81.13) (221,81.15) (222,81.17) (223,81.28) (224,81.22) (225,81.30) (226,81.33) (227,81.33) (228,81.28) (229,81.23) (230,81.23) (231,81.24) (232,81.29) (233,81.29) (234,81.31) (235,81.28) (236,81.26) (237,81.34) (238,81.39) (239,81.43) (240,81.41) (241,81.42) (242,81.49) (243,81.45) (244,81.41) (245,81.47) (246,81.46) (247,81.54) (248,81.54) (249,81.49) (250,81.51) (251,81.48) (252,81.51) (253,81.46) (254,81.47) (255,81.44) (256,81.44) (257,81.48) (258,81.44) (259,81.45) (260,81.47) (261,81.40) (262,81.47) (263,81.45) (264,81.45) (265,81.40) (266,81.41) (267,81.40) (268,81.40) (269,81.40) (270,81.37) (271,81.39) (272,81.46) (273,81.43) (274,81.46) (275,81.50) (276,81.53) (277,81.55) (278,81.53) (279,81.53) (280,81.53) (281,81.48) (282,81.49) (283,81.52) (284,81.49) (285,81.48) (286,81.51) (287,81.48) (288,81.51) (289,81.52) (290,81.52) (291,81.55) (292,81.54) (293,81.55) (294,81.49) (295,81.49) (296,81.52) (297,81.54) (298,81.52) (299,81.54) (300,81.53)
};
\addlegendentry{reference (JAX, EMA)}
\addplot[red, dashed] coordinates {
(1,0.51) (2,0.67) (3,1.52) (4,2.51) (5,4.24) (6,7.39) (7,12.11) (8,16.82) (9,22.38) (10,28.19) (11,34.14) (12,38.89) (13,43.82) (14,47.50) (15,50.50) (16,53.39) (17,55.48) (18,57.03) (19,58.25) (20,59.77) (21,60.59) (22,61.81) (23,62.94) (24,63.62) (25,64.57) (26,65.29) (27,65.76) (28,66.89) (29,67.17) (30,67.58) (31,67.88) (32,68.65) (33,68.19) (34,68.90) (35,69.14) (36,69.41) (37,69.71) (38,70.11) (39,70.54) (40,70.70) (41,70.78) (42,70.94) (43,71.06) (44,71.41) (45,71.68) (46,71.75) (47,71.89) (48,71.85) (49,72.17) (50,72.22) (51,72.44) (52,72.56) (53,72.85) (54,72.69) (55,72.94) (56,73.18) (57,73.20) (58,73.40) (59,73.48) (60,73.51) (61,73.29) (62,73.84) (63,73.60) (64,73.98) (65,73.77) (66,73.82) (67,73.86) (68,73.96) (69,73.69) (70,74.07) (71,74.08) (72,74.05) (73,74.12) (74,74.61) (75,74.27) (76,74.41) (77,74.67) (78,74.73) (79,74.72) (80,74.61) (81,74.94) (82,74.86) (83,74.68) (84,74.78) (85,75.08) (86,74.78) (87,74.95) (88,75.31) (89,75.28) (90,75.22) (91,75.37) (92,75.35) (93,75.22) (94,75.64) (95,75.48) (96,75.70) (97,75.34) (98,75.74) (99,75.63) (100,75.85) (101,75.61) (102,75.85) (103,75.79) (104,75.90) (105,76.05) (106,76.15) (107,76.03) (108,75.93) (109,75.84) (110,76.35) (111,75.97) (112,76.11) (113,76.12) (114,76.49) (115,76.55) (116,76.61) (117,76.60) (118,76.55) (119,76.58) (120,76.57) (121,76.64) (122,76.35) (123,76.76) (124,76.71) (125,76.58) (126,76.80) (127,76.71) (128,76.98) (129,76.93) (130,76.83) (131,77.02) (132,76.81) (133,76.85) (134,77.06) (135,77.17) (136,77.09) (137,77.25) (138,77.23) (139,77.19) (140,77.38) (141,77.42) (142,77.54) (143,77.47) (144,77.38) (145,77.59) (146,77.42) (147,77.55) (148,77.41) (149,77.65) (150,77.88) (151,77.60) (152,77.89) (153,77.69) (154,77.78) (155,77.96) (156,77.87) (157,77.98) (158,78.04) (159,77.89) (160,78.10) (161,77.93) (162,78.12) (163,78.15) (164,78.61) (165,78.21) (166,78.16) (167,78.43) (168,78.28) (169,78.39) (170,78.37) (171,78.21) (172,78.55) (173,78.40) (174,78.70) (175,78.44) (176,78.89) (177,78.81) (178,78.75) (179,78.84) (180,78.91) (181,78.77) (182,79.07) (183,78.88) (184,78.90) (185,78.75) (186,79.03) (187,78.96) (188,79.06) (189,78.93) (190,79.18) (191,79.06) (192,79.20) (193,79.09) (194,79.30) (195,79.34) (196,79.36) (197,79.19) (198,79.32) (199,79.66) (200,79.57) (201,79.57) (202,79.67) (203,79.58) (204,79.53) (205,79.68) (206,79.76) (207,79.72) (208,79.82) (209,79.76) (210,79.92) (211,79.74) (212,79.73) (213,79.75) (214,79.77) (215,79.74) (216,79.90) (217,79.98) (218,80.10) (219,80.17) (220,79.97) (221,80.10) (222,80.13) (223,80.21) (224,80.19) (225,80.18) (226,80.26) (227,80.31) (228,80.27) (229,80.15) (230,80.37) (231,80.27) (232,80.32) (233,80.31) (234,80.49) (235,80.42) (236,80.54) (237,80.45) (238,80.57) (239,80.69) (240,80.53) (241,80.65) (242,80.60) (243,80.56) (244,80.66) (245,80.63) (246,80.68) (247,80.74) (248,80.84) (249,80.84) (250,81.00) (251,80.83) (252,80.89) (253,80.91) (254,80.97) (255,80.85) (256,81.00) (257,80.97) (258,80.97) (259,81.12) (260,81.03) (261,81.14) (262,81.10) (263,81.10) (264,81.09) (265,81.22) (266,81.12) (267,81.20) (268,81.23) (269,81.17) (270,81.19) (271,81.18) (272,81.16) (273,81.20) (274,81.25) (275,81.21) (276,81.27) (277,81.19) (278,81.30) (279,81.30) (280,81.29) (281,81.24) (282,81.28) (283,81.25) (284,81.35) (285,81.31) (286,81.25) (287,81.36) (288,81.37) (289,81.35) (290,81.37) (291,81.39) (292,81.35) (293,81.32) (294,81.34) (295,81.32) (296,81.30) (297,81.32) (298,81.32) (299,81.30) (300,81.30)
};
\addlegendentry{verified path (PJRT, raw)}
\end{axis}
\end{tikzpicture}

One recipe, two paths to XLA, every epoch, scored over all \(50{,}000\) validation images.

The remaining \(91.3\) against \(76.5\) hours is \(1.23\times \) per step, \(220\) against \(179\) ms at the same shape on the same cards. Of the \(41\) ms, \(15\) is the feed — the fed step against the step with the shim removed, measured on this box — which is \(6.8\% \) of the step on the heaviest shim in the book (RandAugment and random erasing per image), because doubling the per-replica batch from \(32\) to \(64\) doubled the arithmetic each augmented image amortizes over. The other \(26\) is the lowering, read against §9.6’s accounting.

What the ConvNeXt recipe changes from the 2018 recipe

The ResNet-34 trainer of Chapter 5 is the 2018-era recipe: the original paper’s SGD-with-momentum plus the “bag of tricks” polish (cosine schedule, warmup, label smoothing, random-resized-crop). The config above is the 2022 ConvNeXt recipe, DeiT’s transformer-training playbook applied to a convnet. Where Chapter 5’s RSB-A3 side quest showed the 2018\(\to \)2021 jump on a fixed ResNet, this table shows the 2018\(\to \)2022 jump with the block modernized in step. Since “the recipe is the thesis” is this architecture’s whole argument, the diff below is the chapter in table form. The trainAdamSched entry point is identical in both columns, and every row is a one-line change to the VerifiedNetSpec, the VerifiedConfig, or the arguments beside them:

Knob

ResNet-34 (2018)

ConvNeXt-T (2022)

What the ConvNeXt choice buys

Backbone

basic block, 21.8 M

ConvNeXt block, 28.6 M

BN+ReLU \(\to \) LN+GELU; dw-\(7\times 7\) + inverted bottleneck + LayerScale — the modernized block this chapter built

Optimizer

SGD + momentum \(0.9\)

AdamW

decoupled decay + per-parameter step scale; LR-robust where B0’s RMSProp proved fragile

Peak LR

\(0.1\)

\(2.5\mathrm{e}{-4}\)

linearly scaled from the official \(4\mathrm{e}{-3}\)@4096 — AdamW’s scale, not comparable to SGD’s \(0.1\)

Warmup

5 epochs

20 epochs

heavy augmentation + adaptive optimizer want a long, gentle ramp

Epochs

90

300

heavy regularization needs a long schedule to pay, and converts it straight into accuracy rather than into overfitting

Loss

softmax cross-entropy

CE over soft targets

unlike A3’s BCE switch, ConvNeXt keeps CE — the softening comes from the Mixup/CutMix labels

Weight decay

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

\(0.05\), skip 1-D params

\(500\times \) stronger, never on norm/bias/LayerScale (timm’s no_weight_decay)

Augmentation

RRC + hflip

full DeiT pack

RandAugment + Mixup + CutMix + Random Erasing — where the modernization actually lives

Stochastic depth

none

drop-path \(0.1\)

randomly drops whole residual branches per step — the depth regularizer

EMA

none

decay \(0.9999\)

evaluates a smoothed shadow of the weights; with drop-path, together \({{\lt}}5\% \) per epoch

Grad clip

none

global-norm \(1.0\)

this book’s addition, not the paper’s: cheap insurance that made the peak LR a non-event

What carries over unchanged: cosine LR decay, the warmup-then-anneal structure, random-resized-crop + horizontal flip as the base augmentation, label smoothing \(0.1\), batch 256 (the paper’s own batch is \(4096\), and the peak LR is scaled for it), train and eval both at \(224\) (no FixRes), and bf16 convolution under an fp32 classifier head.

The augmentation row is the full DeiT pack: Mixup (Zhang et al. 2017, arXiv:1710.09412), CutMix (Yun et al. 2019, arXiv:1905.04899), Random Erasing (Zhong et al. 2017, arXiv:1708.04896), and RandAugment (Cubuk et al. 2019, arXiv:1909.13719). All four are implemented as data-only kernels that mutate the input tensor before the graph sees it, tier 1 in our codegen scope, no MLIR plumbing required. Mixup and CutMix route through a soft-label train-step variant since they produce fractional labels. Random Erasing and RandAugment leave labels alone.

The through-line is the same “regularize harder, train bigger” as RSB-A3, arrived at from the transformer side: keep cross-entropy and soften the targets instead of switching losses, AdamW instead of LAMB (the batch stays moderate), stochastic depth and EMA from the ViT toolkit, and a \(3\times \) longer schedule to let the regularization pay.

8.6 Side quest: scaling to ConvNeXt-S and B

The axis. Scaling ConvNeXt-T is two moves. S deepens stage 3 from 9 blocks to 27 and touches nothing else; B does that and widens every stage, \(96/192/384/768 \to 128/256/512/1024\). On the JAX side both are parameter edits. On the verified side they are not the same job at all.

model

shape

params

graph, one card bs\(32\): fp32 \(\to \) bf16

ConvNeXt-T

\(9\) blocks, \(96\) wide

\(28.6\) M

\(150.6 \to 68.5\) ms  (\(2.20\times \))

ConvNeXt-S

\(27\) blocks, \(96\) wide

\(50{,}223{,}688\)

\(255.2 \to 105.3\) ms  (\(\mathbf{2.42\times }\))

ConvNeXt-B

\(27\) blocks, \(128\) wide

\(88{,}591{,}464\)

\(378.6 \to 158.0\) ms  (\(2.40\times \))

Every number in this section was measured on 4\(\times \) RTX 4060 Ti (16 GB, CUDA 12.9). Counts are #guarded at \(50{,}223{,}688\) and \(88{,}591{,}464\), \(344\) tensors each, the head LayerNorm’s \(\gamma \) and \(\beta \) included.

A full schedule, four cards, \(300\) epochs at the jobs’ own shape — \(64\) per replica, sixteen micro-batches accumulated to the official \(4096\), \(312\) updates an epoch — with each path’s own trainer timed on real ImageNet (the 2026-10-12 smokes):

 

reference (JAX)

verified path (PJRT)

4 cards, bf16

ms/micro-batch

300 epochs

ms/micro-batch

300 epochs

ConvNeXt-S

\(247\)

\(106\) h  (\(4.4\) d)

\(323\)

\(144\) h  (\(6.0\) d)

ConvNeXt-B

\(352\)

\(151\) h  (\(6.3\) d)

\(491\)

\(219\) h  (\(9.1\) d)

Totals include the per-epoch evaluation; the verified steps are fed, \(3\)–\(7\) ms of them waiting on the shim. All four are compute-bound.

What is queued. Both pairs exist. The verified jobs are cnx{s,b}-default-emabf16-4gpu, on the renders convnext{s,b}in_emaaccdp16xwxdroperfbf16, and their references are cnx{s,b}-default-jax-4gpu. In: ConvNeXt-T’s recipe as its marker has it — the official batch \(4096\) as \(16\) accumulated micro-batches of \(64\) per replica, no clip — with drop-path at the paper’s \(0.4\) for S and \(0.5\) for B, EMA scored, cnxInit on both paths, and _fwd_s288 forwards for timm’s test protocol. The accumulation is the recipe’s, not memory’s: at \(64\) per replica the accumulating renders peak at \(7.21\) GiB for S and \(10.42\) for B.

[TODO: as T’s; neither has run —
cnxs-default-jax-4gpu / cnxs-default-emabf16-4gpu,
cnxb-default-jax-4gpu / cnxb-default-emabf16-4gpu.]