8.7 Pruning — MNIST FFN (magnitude sparsity sweep)¶
A comprehensive walk-through of notebooks/pruning-mnist-ffnn-pytorch/ — the canonical in-repo
demo of nnx.prune.magnitude_prune and the magnitude-pruning trade-off curve. This page is the
deep-dive companion to the task notebook: it states the problem, builds the pruning math, dissects
the bake=True deployable-form contract, reads the code top to bottom, reports the measured
accuracy-vs-sparsity sweep, and catalogues the pitfalls and extensions.
The notebook is Tier-A — CPU re-runs in roughly 15 seconds and it is re-executed end-to-end in CI on every pull request. It belongs to the "efficient/compressed MLP" family: where §8.5 edits the architecture and §8.8 changes the bitwidth, pruning keeps the architecture and precision fixed and instead drives the weight tensor sparse, trading a tunable fraction of zeros for accuracy.
8.7.1 Problem & motivation¶
Weight pruning makes a trained model sparse — most of the weights become exactly zero — so the deployed model is smaller (a sparse tensor compresses to its non-zero entries plus indices) and, on hardware that supports sparse kernels, faster. The simplest recipe is magnitude pruning: in each layer, zero the entries with the smallest absolute value up to a target sparsity \(s\). The small-magnitude weights contribute least to the forward output, the theory goes, so dropping them should cost little accuracy.
The deployment question worth a notebook is the trade-off curve: how aggressive can you go before accuracy collapses? Magnitude pruning's answer is a Pareto frontier — accuracy is flat for a wide range of \(s\), then degrades slowly, then drops off a cliff past the "knee." Finding that knee is the deployment decision.
This notebook exists for two reasons:
- First in-repo exercise of
nnx.prune.magnitude_prune. Thennxrelease ships magnitude pruning with abake=Truedefault that writes the zeros into the actual.weighttensor and removes PyTorch'sweight_orig+weight_maskreparametrization, so the post-prunestate_dict()has the same keys as the unpruned net — the deployable form with zero inference-time overhead. This notebook is the canonical demo of that contract. - The Pareto curve is the pedagogically interesting artifact. Sweeping \(s \in \{0.1, 0.3, 0.5, 0.7, 0.9\}\) on one trained baseline produces the curve directly, and its shape (flat → shallow → cliff) generalizes across models and datasets in a way that the single-number "pruning works" claim does not.
The falsifiable hypothesis tested by the notebook is that a [256, 128] MNIST FFN tolerates
\(s \le 0.5\) magnitude pruning with negligible accuracy loss and degrades sharply only past
\(s \approx 0.8\).
8.7.2 Concepts¶
| Concept | Where it shows up |
|---|---|
| Magnitude (L1-unstructured) pruning | Zero the smallest-\(\lvert w \rvert\) fraction per layer |
| Sparsity \(s\) | The target fraction of weights to zero, swept over [0.1, 0.3, 0.5, 0.7, 0.9] |
| Actual zero fraction | The measured fraction of zeros in the post-prune Linear weights |
bake=True (deployable form) |
Write zeros into .weight, drop PyTorch's reparam; same state_dict() keys |
Gradient-masking form (bake=False) |
Keep the mask; for continued training with the sparsity preserved |
| Pareto curve | Accuracy (and zero-fraction) vs target sparsity; the knee is the sweet spot |
| 2:4 semi-structured sparsity | NVIDIA Ampere+ sparse-Tensor-Core pattern; CUDA-only, mentioned not run |
| Reproducibility | nnx.set_seed(0) pins Python random, NumPy, PyTorch CPU + CUDA + cuDNN |
The nnx surface consumed is: NNModel, NNParams, NNModelParams, NNTrainParams,
NNOptimParams, NNDataset, Activations, Devices, Losses, Nets, Optims, set_seed, and
the pruning primitive nnx.prune.magnitude_prune (the nnx.prune namespace also exposes
nnx.prune.semi_structured_24).
8.7.3 Mathematical formulation¶
Let \(W \in \mathbb{R}^{m \times n}\) be a layer's weight matrix and let \(|W|\) denote its entrywise absolute value. For a target per-layer sparsity \(s \in [0,1]\), magnitude pruning zeroes the \(s \cdot m n\) entries with the smallest absolute value. Equivalently, let \(\tau\) be the \(s\)-quantile of \(\{|W_{ij}|\}\); the pruned weight is
This is L1-unstructured pruning: the threshold is per-tensor on the raw magnitudes, with no structure (channel/block) constraint, so any subset of entries can be zeroed. The actual zero fraction after pruning equals \(s\) up to quantization (the threshold ties), and the notebook verifies this directly.
PyTorch implements pruning as a forward hook reparametrization: it stores the original dense
weights in weight_orig and a binary mask in weight_mask, and reconstructs
\(\widetilde{W} = W_{\text{orig}} \odot \text{mask}\) on every forward pass. That form preserves
the mask for continued training (gradient masking keeps pruned weights at zero) but it carries
inference-time overhead (the mask multiply) and a non-standard state_dict. nnx.prune.magnitude_prune
with bake=True (the default) instead writes \(\widetilde{W}\) directly into .weight and removes
the weight_orig/weight_mask reparametrization — the post-prune state_dict() has exactly the
same keys as the unpruned net, with physical zeros in the pruned positions. This is the deployable
form: a downstream runtime sees a normal dense model that happens to be \(s\)-sparse, with no
pruning-specific machinery.
The training objective is cross-entropy on the MNIST logits,
and the baseline optimizer is Adam with \(\eta = 10^{-3}\), \(\beta = (0.9, 0.999)\), weight decay \(0\).
8.7.4 Architecture¶
The architecture is Nets.FEED_FWD (nnx.FeedFwdNN): a 784-unit input layer (flattened MNIST),
two hidden layers [256, 128] with ReLU, and a 10-unit output layer consumed by softmax +
cross-entropy. The widths are deliberately generous for MNIST so the network has redundant capacity
and the pruning curve is shallow through the mid-sparsity range.
| Object | hidden_dims |
Role |
|---|---|---|
| FP32 baseline | [256, 128] |
Trained for 3 epochs; the pruning substrate |
| Pruned copies | [256, 128] |
deepcopy of baseline, pruned at each \(s\) |
The shared contract:
- Net:
Nets.FEED_FWD, activation:Activations.RELU - Loss:
Losses.CROSS_ENTROPY - Optimizer: Adam,
max_lr=1e-3,weight_decay=0.0,momentum=(0.9, 0.999) - Device:
Devices.CPU - Budget:
N_EPOCHS=3for the baseline - Sparsity sweep:
SPARSITY_LEVELS=[0.1, 0.3, 0.5, 0.7, 0.9](or[0.1]underSMOKE_TEST=1) - Pruning call:
nnx.prune.magnitude_prune(net, sparsity=s)withbake=True(default) andlayer_pattern="*"(allLinearlayers) - Seed:
0
The a priori expectation (against a well-trained baseline): accuracy is flat through \(s \le 0.5\), degrades slowly through \(0.5 \le s \le 0.7\), and drops sharply past \(s \approx 0.8\). The recorded run's baseline is undertrained (3 epochs, \(\sim 52\%\) val accuracy), so the absolute numbers are low and the curve is noisy in places — see results.
8.7.5 Code walkthrough¶
8.7.5.1 Baseline training¶
model = make_model() # FeedFwdNN, [256, 128]
run = model.train(
params=NNTrainParams(
n_epochs=N_EPOCHS,
train_loader=ds.train_loader, val_loader=ds.val_loader,
optim=NNOptimParams(name=Optims.ADAM, max_lr=LR,
momentum=(0.9, 0.999), weight_decay=0.0),
),
)
The baseline trains for 3 epochs and reaches a final validation loss of 1.8857 (validation
accuracy \(\sim 51.9\%\)). The short budget is a Tier-A choice (\(\sim 15\) s on CPU); the
shape of the pruning curve is what is pedagogically interesting and it is stable across budgets,
but the absolute accuracies are well below MNIST state-of-the-art.
8.7.5.2 Measuring the actual zero fraction¶
def actual_zero_fraction(net):
total, zeros = 0, 0
for p in net.parameters():
if p.ndim < 2:
continue # skip biases; only count Linear weight matrices
total += p.numel()
zeros += (p == 0).sum().item()
return zeros / total if total else 0.0
The ndim < 2 guard skips biases (1-D tensors) so the reported zero fraction is over Linear
weight matrices only — the tensors magnitude pruning actually touches. This matters for reading the
Pareto curve: a reported 50% zero fraction means 50% of weight entries, not 50% of all parameters
including biases.
8.7.5.3 The sparsity sweep¶
for s in SPARSITY_LEVELS:
pruned = copy.deepcopy(model) # start fresh from FP32 each time
n_pruned = nnx_prune.magnitude_prune(pruned.net, sparsity=s) # bake=True default
edp = pruned.evaluate(ds.val_loader)
zf = actual_zero_fraction(pruned.net)
results.append((f"{s:.1f}", s, edp.accuracy, zf))
Three details. First, the baseline model is deepcopy'd at each \(s\), so every iteration starts
from the same FP32 weights — the only thing varying across the sweep is \(s\). Second,
magnitude_prune is called on pruned.net (the raw nnx.FeedFwdNN), not on the NNModel wrapper;
it returns the number of layers that were pruned. Third, bake=True is the default, so after the
call pruned.net's state_dict() has the same keys as the unpruned net, with physical zeros in
the pruned positions — the deployable form.
8.7.5.4 The Pareto curve¶
ax.plot(xs, [a*100 for a in accs], "o-", label="val accuracy (%)", color="tab:blue")
ax2 = ax.twinx()
ax2.plot(xs, [z*100 for z in zfs], "s--", label="actual zero fraction (%)", color="tab:orange")
Accuracy on the left axis, actual zero fraction on the right, target sparsity on the horizontal. Overlaying both makes the "did we actually hit the target sparsity?" check visual — the orange dashed line should track the diagonal.
8.7.6 Results & analysis¶
On the recorded (seeded) run, the baseline and the five pruned points land as:
| Target sparsity | Val accuracy | Actual zero fraction (Linear weights) |
|---|---|---|
| 0.0 (baseline) | 51.87% | 0.00% |
| 0.1 | 52.27% | 10.00% |
| 0.3 | 54.72% | 30.00% |
| 0.5 | 50.72% | 50.00% |
| 0.7 | 46.15% | 70.00% |
| 0.9 | 22.88% | 90.00% |
Three observations:
- The actual zero fraction matches the target exactly (10.00%, 30.00%, 50.00%, 70.00%,
90.00%). This confirms the pruning contract:
magnitude_prune(sparsity=s)produces a tensor that is \(s\)-sparse by construction, andbake=Truewrites those zeros permanently. The orange curve lies exactly on the diagonal. - Through \(s = 0.5\), accuracy is flat or even above the baseline (52.27%, 54.72%, 50.72% vs the 51.87% baseline). The \(s = 0.3\) point landing above the baseline is the non-monotonicity the notebook's §6.3 calls out: the 3-epoch baseline is itself noisy (\(\sim 52\%\)), so the absolute accuracies fluctuate within that noise band. Against a well-trained baseline the curve would be monotone with a crisp knee; the shape through mid-sparsity is the part that generalizes.
- The cliff is at \(s \ge 0.8\). Accuracy holds at \(s = 0.7\) (46.15%, down ~6 points from baseline) then collapses to 22.88% at \(s = 0.9\) — the remaining 10% of weights cannot express the decision boundary. This is the "knee" the Pareto-curve framing predicts, and it is the deployment sweet-spot signal: for this architecture/dataset, prune to \(s \approx 0.7\) and stop.
The right reading: the zero-fraction-vs-target identity is the pruning contract (verified exactly), and the accuracy-vs-sparsity shape is budget-dependent in absolute terms but stable in shape — flat through mid-sparsity, cliff past ~0.8.
8.7.7 Pitfalls & edge cases¶
- 2:4 semi-structured sparsity is CUDA-only.
nnx.prune.semi_structured_24calls torchao'sSparseSemiStructuredTensor, which requires CUDA-resident weights on NVIDIA Ampere-or-newer architectures. On CPU the swap raisesRuntimeErrorat construction. This notebook is Tier-A / CPU-only, so the 2:4 path is mentioned in §6.3 but not exercised; the swap-mechanics contract is covered by thennxtest suite under a CUDA-available guard. Do not attempt to run it on the recommended CPU runtime. bake=Trueis a one-way door for continued training. Baking writes the zeros into.weightand drops the mask, so if you then resume training, the pruned weights will fill back in (no mask to hold them at zero). If you want to keep training the pruned model with the sparsity preserved (gradient-masking semantics), passbake=Falseand PyTorch's pruning reparam stays in place. This notebook deploys the baked form; choose deliberately.- The 3-epoch baseline is undertrained and noisy. Recorded val accuracy \(\sim 52\%\) is well below MNIST state-of-the-art, and the curve is non-monotonic through mid-sparsity (the \(s=0.3\) point lands above the baseline). Read the shape (flat → cliff), not the absolute numbers; a longer budget makes the curve monotone without changing the knee location.
- Skip biases when measuring zero fraction.
magnitude_prunetargetsLinearweight matrices (2-D). Theactual_zero_fractionhelper guards onp.ndim < 2to exclude biases; without that guard, the reported zero fraction is diluted by never-pruned bias entries and the "did we hit target sparsity?" check looks wrong. deepcopythe baseline per \(s\). Re-pruning the same model at escalating \(s\) withoutdeepcopywould prune an already-pruned tensor, compounding sparsity and producing non-comparable points. The notebook copies fresh from the FP32 baseline each iteration.- The knee is architecture- and dataset-dependent. The \(\sim 0.7\) knee reported here is for a
[256, 128]FFN on MNIST. A larger model has more redundancy and tolerates higher \(s\); a smaller model hits the cliff sooner. Re-sweep before deploying the "prune to 0.7" heuristic on a different architecture. - Quantization cousin (§8.8) is Tier B under Issue #66. This pruning notebook remains Tier A and covered by the 18-output oracle; the complete PTQ/QAT notebook runs in the bounded Tier B matrix with checkpoint reconstruction and semantic output verification.
8.7.8 Extensions & references¶
- Iterative (train-prune-retrain) pruning. This notebook does one-shot pruning on a trained
baseline. The classical alternative is iterative magnitude pruning: prune a small fraction,
retrain, prune again, repeat. This typically reaches higher final sparsity at the same accuracy
because the model adapts to each pruning step.
nnx.prune.magnitude_prunewithbake=Falsesupports the mask-preserving form needed for the retrain phase. - Exercise 2:4 semi-structured sparsity on a GPU. Swap
magnitude_prunefornnx.prune.semi_structured_24on an Ampere+ CUDA runtime and measure the real wall-clock speedup from the sparse Tensor Cores. The contract is the same (a sparse deployable weight); the win is hardware acceleration rather than raw compression. - Pair pruning with quantization (§8.8). Prune to \(s = 0.7\), then PTQ or QAT the pruned
model. Sparse + int8 is the dominant edge-deployment recipe and the two
nnxprimitives compose cleanly; the joint Pareto curve (accuracy vs size vs latency over the \(s \times\) bitwidth grid) is the full deployment study. - Structured (channel) pruning. Magnitude pruning is unstructured — any entry can be zeroed,
so the speedup requires sparse kernels. Structured pruning removes whole channels/filters,
producing a smaller dense model that is fast on any hardware.
nnxdoes not currently ship a structured primitive; adding one would complete the pruning surface. - References. Han et al., 2015, Learning both Weights and Connections for Efficient Neural
Networks (arXiv:1506.02626) — the iterative train-prune-retrain recipe. Mishra et al., 2021,
Accelerating Sparse Deep Neural Networks — the 2:4 semi-structured formulation backed by
NVIDIA's
SparseSemiStructuredTensor. See also the taskREADME.mdfor the in-repo contract summary anddocs/env-setup.md§4.1.6 for the Tier-A execution path.