A complete application, in one file of plain C, that recognises handwritten digits with every vector operation running on the Sparsr device.
It reaches 79.58% on the full MNIST test set, after training on all 60,000 training images. The device does integer bit operations throughout. Training reads the data once, with no gradients and no training loop.
This page is the walkthrough. The Getting Started page does the same task in Python, through the PyTorch device. Read this one if you want to see what the library is doing underneath, or if you are writing your own HDC code in C.
The source is one file,
examples/mnist/main.c
,
in the HDC library's repository.
This runs on Linux on x86-64. You supply a host C compiler and the HDC library.
A RISC-V toolchain is not one of the things you need. The device programs
this example runs are already compiled inside libsparsr_hdc.so, and the library loads them into instruction
memory when hdc_init() runs.
Download sparsr-hdc-<version>.tar.gz from the
Sparsr Developer Zone and unpack
it, then fetch the one source file and compile against the extracted directory:
curl -O https://raw.githubusercontent.com/Sparsr/libsparsr-hdc/main/examples/mnist/main.c
cc main.c -I include -L lib -lsparsr_hdc -lsparsr_host -o mnist_hdc
MNIST itself is about 55 MB and is in no download, so you supply it. Four uncompressed files:
train-images-idx3-ubyte train-labels-idx1-ubyte
t10k-images-idx3-ubyte t10k-labels-idx1-ubyte
Both common naming styles work, with a dot or a dash before idx. If your
copies end in .gz, gunzip them first.
SPARSR_BACKEND=vm LD_LIBRARY_PATH=lib ./mnist_hdc --data /path/to/mnist
This runs on the Sparsr VM. The library's device programs are RV32I, and the VM runs RV32I.
A full run takes about half a minute. For a quicker look, use a slice:
SPARSR_BACKEND=vm LD_LIBRARY_PATH=lib ./mnist_hdc --data /path/to/mnist --train 6000 --test 1000
encoding 60000 images (21.2 s)
training 10 prototypes (0.3 s)
testing 10000 images (4.4 s)
Accuracy: 79.58% (7958 of 10000 correct)
digit trained on tested correct accuracy prototype bits lanes
0 5923 980 882 90.0% 692 48
1 6742 1135 1054 92.9% 694 48
2 5958 1032 752 72.9% 714 48
3 6131 1010 846 83.8% 733 48
4 5842 982 771 78.5% 693 48
5 5421 892 523 58.6% 701 48
6 5918 958 804 83.9% 703 48
7 6265 1028 839 81.6% 708 48
8 5851 974 707 72.6% 703 48
9 5949 1009 780 77.3% 696 48
Digit 1 is the easiest and digit 5 is the hardest, which is what an HDC classifier usually finds on MNIST.
Read the timings as what they are. They are wall-clock seconds on the Sparsr VM, a software model of the device. They say the application runs end to end. They compare it against nothing else, and they are not a benchmark.
Each step is one call into the library, and the library runs it as wide instructions on the device.
1. Item memory. Give each of the 784 pixel positions a fixed random
hypervector, drawn once with hdc_random_dense() and never changed. Two
positions get unrelated vectors, which is what makes the encoding below say
which pixels were lit.
2. Encode. An image is the majority vote of the hypervectors of its lit
pixels, computed by hdc_bundle_majority(). A bit of the result is set where
more than half of those pixels' vectors had it set. Two images of the same digit
light similar pixels, so they encode to similar vectors.
3. Train. A class prototype is the majority vote of every training image of that digit. The same call, over thousands of members instead of about 150. One pass over the data, and each class is one vector.
4. Classify. Encode the test image, score it against all ten prototypes with
hdc_similarity(), and pick the best. Each score is one wide instruction:
the intersection and its population count are the same instruction, because the
reduce is a mode on the ALU's result bus. The host never reads a 4,096-bit row
back to count its bits.
A hypervector is stored in a wide memory row, and a row holds at most 48 non-zero four-byte lanes out of 128. The limit counts lanes, never set bits, and it stores each occupied lane whole, so bits inside an occupied lane are free.
That makes two very different codes storable, and they are not equally good:
| Code | Built by | Set bits | Fits a row? |
|---|---|---|---|
| Sparse | hdc_random(&v, 48, &seed) |
48, scattered anywhere | always |
| Dense | hdc_random_dense(&v, 48, &seed) |
about 768, inside 48 lanes | always |
This example uses the dense one, and it matters. A dense code has about 768
bits of signal where a sparse one has 48, and it classifies far better.
Every vector here stays inside those 48 lanes by construction, because a
majority vote can only set bits its members already had. That is why the lanes
column above reads 48 on every row.
This is the constraint you will hit first. Design your encoding so its results stay storable, rather than finding out afterwards that they do not.
The library gives you two ways to superpose hypervectors, and picking the wrong one here produces a classifier no better than chance.
hdc_bundle() is the union: OR every member together. Two wide
instructions per member, exact, and it never loses a member. But it grows. OR
a few hundred images of one digit together and every bit is set, so the
prototype says nothing about that digit and every class looks identical.hdc_bundle_majority() is the majority vote. It stays informative
however many members it has, which is what a prototype needs.The vote costs 8 to 13 instructions per member against the union's 4, and more for a longer bundle, because its ripple carry is then longer. That gap is not a quirk of the library. Counting how many of N vectors set each of 4,096 bit positions needs an instruction that counts per bit position, and Sparsr's wide instruction set has none, so the device builds 4,096 counters as bit-planes with a carry-save adder. The whole 60,000-image training set is one such vote per class, and it takes 0.3 seconds once the images are encoded.
| Option | Meaning |
|---|---|
--data DIR |
Where the MNIST files are. Falls back to $SPARSR_MNIST_DIR, then ./data. |
--train N |
Training images to learn from. Default: all of them. |
--test N |
Test images to classify. Default: all of them. |
--lanes N |
Width of the code, 1 to 48 lanes. Default: 48. |
--seed N |
Seed for the item memory. Default: 1. |
--lanes is the density knob, and narrowing it costs accuracy directly.
Measured on 6,000 training and 1,000 test images:
--lanes |
Bits of the code | Accuracy |
|---|---|---|
| 8 | 256 | 64.2% |
| 16 | 512 | 69.0% |
| 48 | 1536 | 75.6% |
48 is the widest a wide memory row holds, so 1,536 bits is the most this example can use today.