Skip to main content

mnist/
mnist.rs

1//! Trains MLX's MNIST example model (an MLP with two hidden layers of 32 units, ReLU,
2//! SGD with learning rate 0.1, batch 256) on the default device (the GPU when available).
3//!
4//! ```text
5//! cargo run --release -p nuvec-nn --example mnist -- <dir with the four MNIST idx files> [--cpu] [--epochs N] [--async] [--compile]
6//! ```
7//!
8//! With `--async`, each step's parameters are submitted with `async_eval`, so the next batch
9//! and graph are built while the GPU runs the step. With `--compile`, the training step
10//! (forward, gradients and update) is compiled, as MLX's example does with `mx.compile`.
11//!
12//! `just mnist` downloads MNIST and compares with MLX.
13
14use std::time::Instant;
15
16use nuvec::{
17    Array, DType, Device, Result, Stream, argmax, astype, equal, io, mean, random,
18    set_default_stream,
19};
20use nuvec_nn::losses::{Reduction, cross_entropy};
21use nuvec_nn::optim::{Optimizer, Sgd};
22use nuvec_nn::{Linear, Module, Parameters, relu, value_and_grad};
23
24#[derive(Clone)]
25struct Mlp {
26    layers: Vec<Linear>,
27}
28
29impl Mlp {
30    fn new(sizes: &[usize]) -> Result<Self> {
31        let layers = sizes
32            .windows(2)
33            .map(|w| Linear::new(w[0], w[1], true))
34            .collect::<Result<_>>()?;
35        Ok(Self { layers })
36    }
37
38    fn forward(&self, x: &Array) -> Result<Array> {
39        let (last, hidden) = self.layers.split_last().expect("at least one layer");
40        let mut h = x.clone();
41        for layer in hidden {
42            h = relu(&layer.forward(&h)?)?;
43        }
44        last.forward(&h)
45    }
46}
47
48impl Module for Mlp {
49    fn visit(&mut self, p: &mut Parameters<'_>) {
50        p.module("layers", &mut self.layers);
51    }
52}
53
54/// The model's trainable parameters, without names.
55fn arrays(m: &Mlp) -> Vec<Array> {
56    m.trainable_parameters()
57        .into_iter()
58        .map(|(_, a)| a)
59        .collect()
60}
61
62/// Images as float32 rows in `[0, 1]` and labels as uint32, as in MLX's example.
63fn load(dir: &str, prefix: &str) -> Result<(Vec<f32>, Vec<u32>, usize)> {
64    let images = io::read_idx(format!("{dir}/{prefix}-images-idx3-ubyte"))?;
65    let labels = io::read_idx(format!("{dir}/{prefix}-labels-idx1-ubyte"))?;
66    let n = labels.size();
67    let x = images
68        .to_vec::<u8>()?
69        .iter()
70        .map(|&p| f32::from(p) / 255.0)
71        .collect();
72    let y = labels
73        .to_vec::<u8>()?
74        .iter()
75        .map(|&l| u32::from(l))
76        .collect();
77    Ok((x, y, n))
78}
79
80/// A small deterministic generator for shuffling (splitmix64).
81struct Shuffle(u64);
82
83impl Shuffle {
84    fn next(&mut self) -> u64 {
85        self.0 = self.0.wrapping_add(0x9E37_79B9_7F4A_7C15);
86        let mut z = self.0;
87        z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
88        z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
89        z ^ (z >> 31)
90    }
91
92    fn permutation(&mut self, n: usize) -> Vec<usize> {
93        let mut p: Vec<usize> = (0..n).collect();
94        for i in (1..n).rev() {
95            p.swap(i, (self.next() % (i as u64 + 1)) as usize);
96        }
97        p
98    }
99}
100
101fn main() -> Result<()> {
102    let args: Vec<String> = std::env::args().skip(1).collect();
103    let dir = args
104        .iter()
105        .find(|a| !a.starts_with("--"))
106        .cloned()
107        .unwrap_or_else(|| "data/mnist".into());
108    let epochs: usize = args
109        .iter()
110        .position(|a| a == "--epochs")
111        .and_then(|i| args.get(i + 1))
112        .map_or(10, |v| v.parse().expect("--epochs takes a number"));
113    let asynchronous = args.iter().any(|a| a == "--async");
114    let compiled = args.iter().any(|a| a == "--compile");
115    if args.iter().any(|a| a == "--cpu") {
116        set_default_stream(Stream::default_for(Device::cpu()));
117    }
118    let (train_x, train_y, n) = load(&dir, "train").inspect_err(|_| {
119        eprintln!("cannot read MNIST from {dir}: run `just mnist` (it downloads the data)");
120    })?;
121    let (test_x, test_y, n_test) = load(&dir, "t10k")?;
122    let pixels = train_x.len() / n;
123    let test_x = Array::from_slice(&test_x, &[n_test, pixels])?;
124    let test_y = Array::from_slice(&test_y, &[n_test])?;
125
126    let (batch, lr) = (256, 0.1);
127    random::seed(0);
128    let mut model = Mlp::new(&[pixels, 32, 32, 10])?;
129    nuvec::eval(&arrays(&model))?;
130    let mut optimizer = Sgd::new(lr);
131    // The step as a function of the parameters and the batch, returning new parameters.
132    let k = arrays(&model).len();
133    let template = model.clone();
134    let step = nuvec::compile(move |xs: &[Array]| {
135        let mut m = template.clone();
136        m.update_trainable(&xs[..k])?;
137        let (x, y) = (&xs[k], &xs[k + 1]);
138        let (_, grads) = value_and_grad(&m, |m: &Mlp| {
139            cross_entropy(&m.forward(x)?, y, Reduction::Mean)
140        })?;
141        Sgd::new(lr).update(&mut m, &grads)?;
142        Ok(arrays(&m))
143    });
144    let mut shuffle = Shuffle(0);
145    let device = nuvec::default_stream().device;
146    let mut times = Vec::new();
147    let mut accuracy = 0.0;
148    for epoch in 0..epochs {
149        let tic = Instant::now();
150        let perm = shuffle.permutation(n);
151        for ids in perm.chunks(batch) {
152            let mut xb = Vec::with_capacity(ids.len() * pixels);
153            for &i in ids {
154                xb.extend_from_slice(&train_x[i * pixels..(i + 1) * pixels]);
155            }
156            let yb: Vec<u32> = ids.iter().map(|&i| train_y[i]).collect();
157            let x = Array::from_slice(&xb, &[ids.len(), pixels])?;
158            let y = Array::from_slice(&yb, &[ids.len()])?;
159            if compiled {
160                let mut args = arrays(&model);
161                args.extend([x, y]);
162                model.update_trainable(&step(&args)?)?;
163            } else {
164                let (_, grads) = value_and_grad(&model, |m: &Mlp| {
165                    cross_entropy(&m.forward(&x)?, &y, Reduction::Mean)
166                })?;
167                optimizer.update(&mut model, &grads)?;
168            }
169            if asynchronous {
170                nuvec::async_eval(&arrays(&model))?;
171            } else {
172                nuvec::eval(&arrays(&model))?;
173            }
174        }
175        let predictions = argmax(&model.forward(&test_x)?, Some(1), false)?;
176        let hits = astype(&equal(&predictions, &test_y)?, DType::F32)?;
177        accuracy = mean(&hits, &[], false)?.item::<f32>()?;
178        let seconds = tic.elapsed().as_secs_f64();
179        times.push(seconds);
180        println!("Epoch {epoch}: Test accuracy {accuracy:.3}, Time {seconds:.3} (s)");
181    }
182    let mut steady: Vec<f64> = times.iter().skip(1).copied().collect();
183    steady.sort_by(f64::total_cmp);
184    let median = steady.get(steady.len() / 2).copied().unwrap_or(times[0]);
185    println!(
186        "RESULT device={device} async={asynchronous} compiled={compiled} accuracy={accuracy:.4} epoch_seconds={median:.4}"
187    );
188    Ok(())
189}