x2c

Recognize handwritten digits.

Train a convolutional network in x2c with the Torch package, then test it on handwriting it has never seen.

Model and training loop from mnist.x
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();
}
Evaluation function / excerpt
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.

Handwritten MNIST test digits with the x2c model's predictions and their actual labels.

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.

Full example

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.

From a new checkout
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