Recognize handwritten digits.
Train a convolutional network in x2c with the Torch package, then test it on handwriting it has never seen.
import "torch" with Torch, Tensor, Module, Optimizer, Scheduler;
static Module _model(void) {
Module model = Module.sequential();
model.push(Module.conv2d(1, 8, 3));
model.push(Module.relu());
model.push(Module.max_pool2d(2));
model.push(Module.flatten());
model.push(Module.linear(8 * 13 * 13, 10));
return model;
}
Torch.manual_seed(0);
Module model = _model();
Optimizer adam = Optimizer.adam(model, 0.001);
Scheduler anneal = Scheduler.cosine(adam, batches, 0.0001);
Tensor order = Torch.randperm(rows);
for (long batch = 0; batch < batches; batch++) {
Scope.retain();
defer Scope.release();
Tensor pick = order.narrow(0, batch * BATCH, BATCH);
adam.zero_grad();
Tensor loss = Tensor.cross_entropy(
model.forward(images.index_select(0, pick)),
targets.index_select(0, pick));
loss.backward();
adam.step();
anneal.step();
}static double _accuracy(Module model, Tensor images, Tensor targets) {
Scope.retain();
defer Scope.release();
Torch.no_grad();
model.eval();
long total = images.size(0), correct = 0;
for (long start = 0; start < total; start += 1000) {
Scope.retain();
defer Scope.release();
long span = total - start < 1000 ? total - start : 1000;
Tensor batch = images.narrow(0, start, span);
Tensor predicted = model.forward(batch).argmax(1, 0);
correct += predicted.eq(targets.narrow(0, start, span)).sum()
.item().integer();
}
model.train();
return (double) correct / (double) total;
}Teach it what a digit looks like.
The network starts with random weights. Eight convolution filters learn features from the pixels; pooling reduces their size, and a linear layer turns them into ten scores, one for each possible digit.
Train on labeled images.
Each batch compares those scores with the correct labels. backward()
computes gradients through the network, and Adam uses them to update its
weights. The surrounding loop chooses the images and advances the learning
rate schedule in ordinary x2c.
This run scored 93.23% on the 10,000 test images after one training pass. The image shows the first twelve test cases, including a 5 it mistakes for a 6. Predictions were recorded from the x2c program; none of these images were used to update its weights. Results can vary across machines.
Check it on unseen handwriting.
Evaluation runs all 10,000 test images through the trained model. argmax
selects the digit with the highest score; eq compares it with the label,
and sum counts the correct answers. Accuracy is that count divided by the
number of images.
Torch.no_grad() disables gradient recording for this scope. There are no
weight updates during evaluation. Processing 1,000 images at a time also
bounds the temporary tensors instead of holding every result at once.
The nested scopes release each batch’s tensors when its iteration finishes.
The outer scope restores gradient recording when the function returns.
eval() and train() select the module’s evaluation and training modes;
those matter when experimenting with layers such as dropout.
A complete training program.
The full example loads the training and test sets with Torch.mnist, creates
its model, trains it, and reports test accuracy. The code above is excerpted
from that program; the source link includes dataset loading and progress
reporting as well.
MNIST contains 60,000 training images and 10,000 test images, each 28 by 28 pixels. This program makes one pass through 937 complete batches of 64, leaving the final 32 training images out. Training shuffles the image order and reduces the learning rate from 0.001 toward 0.0001 with a cosine schedule.
The package behind the program.
The Torch package provides tensors, automatic differentiation, layers, optimizers, checkpoints, and TorchScript inference over PyTorch’s C++ library. Tensor operations run in libtorch. The model definition, batching, training loop, and evaluation here are x2c.
How it compares with PyTorch.
On an Apple M4 Max CPU using the same libtorch 2.10.0 backend, the measured MNIST training workloads took about the same time in x2c and PyTorch. Small-model training and prediction used less time in x2c. Tensor chains were also close when each iteration explicitly released replaced tensors or used a scope to release them.
The performance comparison includes repeated timings, correctness results, memory limitations, and reproduction reports. These are specific CPU workloads on one desktop; they do not establish a general or GPU speed advantage.
Run with the native backend.
The package pins libtorch 2.10.0 for macOS arm64 and Linux x86_64. On Apple
Silicon, set TORCH_DEVICE=mps to train on the GPU with float32 tensors.
CUDA is not supported. The shared libraries are required at runtime. The
preparation step downloads the pinned library; the recipe below separately
downloads the four MNIST files.
Try adding another convolution, increasing the number of channels, or training for more than one pass. Compare the result on the test set rather than the images the model learned from.
Your turn
Run it locally.
This example uses the Torch package and the MNIST dataset. It runs on Apple Silicon with CPU or MPS tensors, or Linux x86_64 with CPU tensors, and links dynamically to libtorch.
You need a GCC- or Clang-compatible C compiler, ar, GNU Make, Python 3, and Bash. The build guide covers setup in detail.
git clone https://github.com/gwf/x2c.git
cd x2c
git checkout --detach 44fa25e30ed593683c7e4269d41af2b0bec47842
make build-safe
./configure --packages torch
make -C packages/torch builds/mnist
mkdir -p data/mnist
for name in train-images-idx3-ubyte train-labels-idx1-ubyte \
t10k-images-idx3-ubyte t10k-labels-idx1-ubyte; do
curl -fL "https://ossci-datasets.s3.amazonaws.com/mnist/$name.gz" | gunzip > "data/mnist/$name"
done
./packages/torch/builds/mnist data/mnist