COE592 · Assignment 1 · MIT 6.5940 Lab 1

Pruning a VGG on CIFAR-10

How our solution shrinks a 35 MiB image classifier in two ways: fine-grained pruning, which zeroes individual weights, and channel pruning, which removes whole feature maps. Every figure on this page comes from the pretrained checkpoint and our verified code, run locally on an Apple M4.

92.95%dense test accuracy
35.20 MiBdense size, fp32
9.23 Mparameters
606.2 MMACs per 32×32 image
fine-grained result 20.94% of the size at 92.79% accuracy 4.8× smaller, 0.16 points below dense
channel result 2.0× faster at 92.30% accuracy 2.0× fewer MACs, 1.8× fewer parameters
Q2 and Q3 · fine-grained pruning

Drop the smallest weights first

The importance of a weight is its magnitude. For a target sparsity s we zero round(s · #W) weights: the k-th smallest magnitude becomes the threshold, and only weights strictly above it survive. Drag the slider over the notebook's own 5×5 test tensor.

zeros19
threshold0.39
nonzeros6
6 weights kept
num_zeros  = round(num_elements * sparsity)
importance = torch.abs(tensor)
threshold  = importance.view(-1).kthvalue(num_zeros).values
mask       = torch.gt(importance, threshold)
tensor.mul_(mask)

Q2 check. At 0.75, round(18.75) = 19 weights go, the threshold is the 19th smallest magnitude (0.39), and the 6 surviving weights match the notebook's expected mask exactly.

Q3 answer. To keep exactly 10 of 25 weights we need 15 zeros, so target_sparsity = 0.6. Verified: exactly 10 nonzeros remain.

The outlined cell is the threshold weight itself. Because the mask uses strict >, it is pruned along with everything smaller.

Q1 · why pruning works

Most weights already sit near zero

Every layer's weights form a narrow bell centered on zero with heavy tails, and the spread shrinks sharply with depth. The curves below use each layer's measured standard deviation; the bar under each one shows the measured share of weights with |w| < 0.02. The deep layers that hold most parameters are the easiest to prune.

Q4 · sensitivity scan

Prune one layer at a time, measure the damage

The scan prunes a single layer at sparsities 0.4 to 0.9, evaluates on the 10,000 test images, restores the layer, and moves on: 9 layers × 6 sparsities = 54 full evaluations. These are our measured curves. The dashed line is the notebook's lower bound, 89.42%, which is 96% of the dense accuracy.

conv0 at 0.4
accuracy after pruning one layer89.42% lower bound

Most sensitive: conv0. It already drops at 0.4, falls under the bound at 0.6 (87.55%) and collapses to 31.81% at 0.9, because its 1,728 weights read the raw RGB image with almost no redundancy. conv7 and the classifier barely notice even 90% sparsity.

Q5 · per-layer sparsity

Spend the pruning where the parameters are

conv4 to conv7 hold 89.5% of all weights, so they carry the compression. The small, sensitive early layers and the classifier are pruned lightly. Switch between our chosen dictionary and the backups we pre-sized in case finetuned accuracy lands under 92.5%. Sizes count every parameter, including BatchNorm, exactly like the notebook.

sparse size vs dense 20.94%
1,932,387 of 9,228,362 parameterslimit 25%

Our submission. About four points of headroom under the limit.

92.79% after finetuning, target above 92.5%
Finetuning · keep it sparse

Train, then re-apply the mask every step

After pruning, five epochs of SGD recover accuracy. Each optimizer step nudges every weight, including the pruned ones, so the FineGrainedPruner callback multiplies the masks back in after every iteration. Red cells are pruned weights the gradient tried to revive.

forwardbackwardoptimizer.step()pruner.apply()

Without the callback the model would drift back to dense within a few hundred steps and the size reduction would disappear.

measured test accuracy

Pruning to 20.94% drops accuracy from 92.95% to 84.48%. One epoch of masked finetuning recovers 92.52%, and the best of five epochs reaches 92.79% (fine-grained, blue). The channel-pruned model (amber) starts from 36.81% and ends at 92.30%.

Q6 and Q8.1 · channel pruning

Remove whole channels, get a smaller dense model

Channel pruning keeps round(C · 0.7) output channels of each conv and slices the matching input channels of the next conv. The result needs no sparse format and runs faster on any hardware.

n_keep = int(round(channels * (1. - prune_ratio)))
prev_conv.weight.set_(prev_conv.weight[:n_keep])
next_conv.weight.set_(next_conv.weight.detach()[:, :n_keep])
MACs ∝ Cin × Cout→0.7 × 0.7 = 0.49

Every inner conv loses 30% of both its inputs and outputs, so its cost roughly halves. conv0 keeps its 3 RGB inputs and conv7 keeps all 512 outputs, so those two only drop to about 0.70.

Q8.2. Latency improves 2.0× in the final run (1.6× in an earlier one). It is at best about the 2.0× MAC cut and often falls short, because fixed per-layer overhead, memory-bound BatchNorm, ReLU and pooling, and the unpruned first input and classifier do not shrink quadratically.

measuredoriginalprunedratio
MACs606.2 M305.4 M1.98×
Parameters9.23 M5.01 M1.84×
Notebook check305,388,064 MACsexact
CPU latency, batch 13.6 ms1.8 ms2.0×
Accuracy after finetuning92.95%92.30%−0.65 pt
Q7 · rank before you cut

Sort channels by importance, then keep the top 70%

Naive pruning keeps the first channels whatever they are. We instead score each input channel of the next conv by the Frobenius norm of its weights, reorder the previous conv, its BatchNorm, and the next conv's inputs with the same permutation, and only then cut. The reorder alone changes nothing: our check measured a maximum logit difference of 2.2e-5 and 100% identical predictions.

keep first 70%: 28.14%sort, then keep 70%: 36.81%after 5 epochs: 92.30%

Illustration with 16 channels. Bar heights span the real range of conv1's input-channel norms after sorting, 1.90 down to 0.26.

importance = torch.norm(channel_weight)
next_conv.weight.copy_(torch.index_select(next_conv.weight.detach(), 1, sort_idx))
Where it stands

Everything checks out

Code answers were verified twice: independently on the CPU, then by the notebook's own tests during the full GPU run.

Q2fine_grained_prune matches the expected mask19 zeros at sparsity 0.75, threshold 0.39passed
Q3target_sparsity = 0.6 leaves exactly 10 nonzerospassed
Q6Channel-pruned MACs equal 305,388,064kept channels 45, 90, 179, 179, 358, 358, 358passed
Q7Sorting is an equivalent transformmax logit difference 2.2e-5, 100% same predictionspassed
Q5Sparse model at or below 25% of dense20.94% with our dictionarypassed
Q5Accuracy above 92.5% after 5 epochs of finetuning92.79% best, 84.48% before finetuningpassed
Q4Sensitivity curves and most sensitive layerconv0, 31.81% at 0.9 sparsitydone
Q8Measured CPU latency speedup3.6 ms to 1.8 ms, 2.0× (1.6× in an earlier run) against 2.0× fewer MACsdone
AllFull notebook executed end to end locally15 minutes on Apple M4, zero error outputs, all written answers filleddone