██████ ██████ █████ ██████ ██ ███████ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ███ ██████ ███████ ██ ██ ██ █████ ███ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██████ ██ ██ ██ ██ ██████ ██ ███████ ██ ██
v0.0.3 is the single-precision line. The architecture is v0.0.2's — a network of
layers, each holding its weights in one flat contiguous buffer — but every scalar is
float rather than double, the hot loops carry #pragma omp simd, and the training
step accumulates gradients over a mini-batch instead of updating after every sample.
Training lives in the engine. The Python train() is one call into
core::Network::train: the epoch loop, the shuffle, the batching, gradient
accumulation and the update rule are all C++, with the GIL released. A 4000-epoch XOR
run through Python is bit-identical to the same run from main.cpp.
New in v0.0.3: per-epoch shuffling, seeded weight initialisation, a derivative lookup that ties each activation and loss to its own derivative, and a matplotlib plotting layer — loss curves, architecture diagrams, decision boundaries, weight maps and 3-D loss landscapes.
import numpy as np, gradiex
X = np.array([[0., 0.], [0., 1.], [1., 0.], [1., 1.]])
Y = np.array([[0.], [1.], [1.], [0.]])
gradiex.seed(42) # reproducible weight initialisation
net = gradiex.Network(learning_rate=0.5, batch_size=4)
net.add_hidden(8, input_size=2, activation="tanh", init="xavier")
net.add_output(1, activation="sigmoid", loss="mse")
net.build()
history = net.train(X, Y, epochs=4000, shuffle=True, seed=42, plot=True)
print(history[0], "->", history[-1]) # 0.29662 -> 0.00024
print(net.predict([1., 0.])) # [0.9838] (float32)Written by Snehashish Laskar. MIT licensed.
- What's new in v0.0.3
- Memory Architecture
- Install
- Quick Start
- Plots
- API
- Names: Activations, Losses, Initializers
- What the Python Layer Guarantees
- Notation
- Activations and Losses
- Data Flow
- Training
- Using the C++ Engine Directly
- Tests
- Feature Parity with v0.0.1
- Invariants
- Not Supported
- Roadmap
- Contributing
- License
| v0.0.2 | v0.0.3 | |
|---|---|---|
| Scalar type | double |
float throughout |
| Vectorisation | hand-unrolled, four accumulators | #pragma omp simd, with reduction on the dot product |
| Gradients | assigned, applied per sample | accumulated, scaled by 1/batchSize, applied once |
| Training | per-sample SGD | mini-batch SGD — Network(..., int batches) |
| Shuffling | none | per-epoch, seeded, inside train() |
| Seeded init | none | initializers::setSeed / gradiex.seed |
| Derivatives | passed in by the caller, unchecked | looked up from the forward function |
| Layer constructor | 7 arguments | 5 — both derivatives are derived |
computeLoss |
always squared error | calls the configured loss |
| Python bindings | double, per-sample loop in the binding |
float, training delegated to the engine |
| Plotting | none | seven matplotlib diagrams, light and dark |
| Tests | several fail by design | 91 checks, all passing under ASan and UBSan |
Eight of the twelve defects the v0.0.3 reference page listed are now closed, including
one that mattered in practice: the log-based losses guarded their probabilities with
1e-12, a double-precision constant that rounds away entirely in float32. Since
1.0f - 1e-12f is 1.0f, the upper clamp never bit, and a saturated sigmoid gave
log(0) and a division by zero — inf * 0 is NaN, which reaches every weight on
the next update and never washes out. A two-moons run diverged at epoch 243 with the
loss already down at 0.003. The guard is 1e-7f now, and five regression tests pin it.
Three ideas, in order of how much they matter.
All weights of all layers live in a single std::vector<double> (weights_), and
all biases in a second (biases_). A layer does not own anything; it stores an
offset into each buffer, plus its width and fan_in. Reading the weight matrix
of layer
// layer l, neuron i, input j
weights_[layer.offset_w + i * layer.fan_in + j]This makes the weight update a single linear sweep over one contiguous array, which
the prefetcher handles well. It also makes the whole model serializable as two
memcpys when save/load returns.
The same trick is used for the per-neuron caches. z_cache_, a_cache_,
input_cache_, delta_, grad_w_, and grad_b_ are each one flat buffer whose
offsets mirror the weight buffer, so a neuron's pre-activation and its weights sit at
corresponding indices.
Everything a forward or backward pass needs is sized at build() time and never
reallocated. The buffers are:
| Buffer | Size | Purpose |
|---|---|---|
weights_ |
all weight matrices, layer-contiguous | |
biases_ |
all bias vectors | |
z_cache_ |
pre-activations, per layer | |
a_cache_ |
activations, per layer | |
input_cache_ |
input seen by each neuron on the last forward pass | |
delta_ |
local gradients | |
grad_w_ |
accumulated weight gradients | |
grad_b_ |
accumulated bias gradients |
forward(), backward(), and the SGD step touch only these. A debug counter
asserts zero operator new calls between build() and destruction; the test suite
checks it.
v0.0.2 unrolled the inner dot product by hand into four accumulators. v0.0.3 asks the compiler instead:
for (int neuron = 0; neuron < width; ++neuron)
{
const float* __restrict__ w = neuronWeights + neuron * inputSize;
float sum = 0.0f;
#pragma omp simd reduction(+:sum)
for (int inputIdx = 0; inputIdx < inputSize; ++inputIdx)
sum += w[inputIdx] * layerInputs[inputIdx];
neuronOutputsUnactivated[neuron] = sum + neuronBiases[neuron];
}The same directive covers zeroing, scaling and applying gradients. __restrict__
pointers are established before each loop so the compiler knows the buffers do not
alias.
This only does something if OpenMP is enabled. Built without
-fopenmp,#pragma omp simdis accepted and discarded in silence — no error, no warning, not even under-Wall -Wextra. Apple clang, the defaultc++on macOS, rejects-fopenmpoutright, so the stock macOS build gets whatever the optimiser would have done anyway. Useg++-14or LLVM clang with libomp to act on them.
pip install gradiexWheels are published for CPython 3.9–3.14 on Linux (x86_64 and aarch64, manylinux and musllinux), macOS (Intel and Apple silicon) and Windows. Anywhere without a wheel, pip falls back to the sdist and compiles the engine for you — that requires a C++20 compiler and Python 3.9+.
numpy and matplotlib are installed as dependencies.
Inside a checkout of this release directory:
pip install . # or: pip install -e . for developmentcore/ is header-only and compiled directly into the extension module. To build in
place, which is what the tests and examples expect:
python3 setup.py build_ext --inplaceFor the C++ engine alone, nothing is needed but a compiler:
clang++ -std=c++20 -O2 -fopenmp-simd main.cpp -o main
./main-fopenmp-simd is what makes the #pragma omp simd directives in core/ apply. It
is worth up to 2.4× on wide layers — see Benchmarks. Apple clang
rejects -fopenmp but accepts this, and core/ needs nothing more: it uses SIMD
directives only, no parallel regions, so no runtime library is involved. Without the
flag the pragmas are accepted and discarded in silence.
setup.pylistscore/*.hindepends. Without it, setuptools time-stamps only the.cpp, so editing a header leaves a stale.soin place and the next run silently tests the previous engine.
import numpy as np, gradiex
X = np.array([[0., 0.], [0., 1.], [1., 0.], [1., 1.]])
Y = np.array([[0.], [1.], [1.], [0.]])
gradiex.seed(42)
net = gradiex.Network(learning_rate=0.5, batch_size=4) # 0 = one batch per epoch
net.add_hidden(8, input_size=2, activation="tanh", init="xavier")
net.add_output(1, activation="sigmoid", loss="mse")
net.build()
history = net.train(X, Y, epochs=4000, shuffle=True, seed=42)
print(net.predict([1., 0.])) # [0.9838]#include "core/network.h"
namespace act = core::functions::activations;
namespace ini = core::functions::initializers;
namespace los = core::functions::loss;
ini::setSeed(42);
core::Network net(1, 1, 0.5f, 4); // 1 hidden, 1 output, lr, batch size
net.addHiddenLayer(0, 8, 2, act::tanh, ini::xavier, los::mse);
net.addOutputLayer(0, 1, 8, act::sigmoid, ini::xavier, los::mse);
net.initializeAllWeightsAndBiases();
std::vector<float> history(4000);
net.train(X, Y, 4000, history.data(), (int)history.size(),
/*verbose=*/false, /*shuffle=*/true, /*seed=*/42u);Both reach 0.296616971 -> 0.000236376116. The derivatives are not passed in: the
layer resolves each from its forward function, so a mismatched pair is unreachable.
0.0.3/examples/two_moons.py trains a 2 → 16 → 16 → 1 network on two interleaving
moons — a curved boundary, so the hidden layers have to do real work — and writes
every diagram below. It reaches 100 % train and test accuracy.
python3 examples/two_moons.pytrain(..., plot=True) draws the loss curve when training finishes and leaves the
figure on net.last_figure. Every plot takes an optional ax= and returns its
figure, so they compose; nothing calls show() unless you pass show=True. Each
accepts theme="light" or theme="dark".
plot_summary(X, Y) — architecture, loss, weights and the data fit in one figure.
Panels with nothing to draw are left out rather than shown empty.
Two random directions are drawn through the trained weights and filter-normalised: each direction row is scaled to the norm of the weight row it perturbs. That is what makes the picture mean anything — the loss is invariant to rescaling a neuron's weights, so an unnormalised step moves small-weight neurons a long way in function space and large-weight ones barely at all, and the result describes the weight scales rather than the loss. The method is from Li et al., Visualizing the Loss Landscape of Neural Nets (2018).
It is a random 2-D slice of a 337-dimensional surface. Read it as a texture: it shows
whether the basin is broad and smooth or narrow and ragged, not the path the
optimiser took or where other minima are. A different seed= gives a different slice.
The sweep restores the trained weights exactly when it finishes.
Layers are declared, then the engine is constructed by build(). Nothing can be run
until then, and no layers can be added after. batch_size is how many samples are
accumulated before one update: 1 is per-sample SGD, 0 means one batch per epoch.
| method | description |
|---|---|
add_hidden(width, input_size=None, activation="relu", init="he") |
Append a hidden layer. input_size defaults to the previous layer's width, and is required only on the first layer. |
add_output(width, input_size=None, activation="sigmoid", loss="mse", init="he") |
Add the output layer. Call once, after all hidden layers. |
build() |
Construct the engine, size every buffer, and initialize weights. Alias: initialize(). |
train(X, Y, epochs=100, shuffle=False, seed=None, verbose=False, log_every=0, plot=False) |
Runs in the engine. Returns mean loss per epoch, and stores it on .history. |
train_batch(X, Y) |
One engine batch step over these samples. Returns the mean batch loss. |
evaluate(X, Y) |
Mean loss over a dataset, engine-side, with no update applied. |
forward(x) / predict(x) |
One forward pass; returns the output activations. |
loss(x, y) |
Forward x, then return the configured loss against y. |
weights(layer) / biases(layer) |
float32 arrays; weights shaped (width, input_size). Negative indices allowed. |
set_weights(layer, values) / set_biases(layer, values) |
Overwrite a layer's parameters. Weights accept (width, input_size) or flat. |
Gradient primitives, one per engine call, for a hand-rolled loop: zero_gradients(),
accumulate(y), scale_gradients(factor), apply_gradients(). backward(y) is the
convenience form — zero, accumulate, apply — for the sample just forwarded.
Plotting, each also available as a module function taking the network:
plot_loss, plot_architecture, plot_weight_distribution, plot_weight_heatmap,
plot_decision_boundary, plot_predictions, plot_loss_landscape, plot_summary.
Properties: learning_rate and batch_size (writable), layers, num_hidden,
input_size, output_size, built, history, last_figure.
Module level: gradiex.seed(n) seeds weight initialisation, gradiex.THEMES holds
the two palettes.
Inputs accept anything NumPy can cast to float32. The engine is single-precision, so
net.learning_rate reads back rounded — setting 0.05 returns 0.05000000074505806.
gradiex.activations() # gelu, identity, leaky_relu, linear, relu, sigmoid, softmax, swish, tanh
gradiex.losses() # mse, mae, huber, bce, binary_cross_entropy, cce, cross_entropy, softmax_cross_entropy
gradiex.initializers() # zeros, xavier, heActivations and losses are selected by name. The engine resolves each derivative from
the forward function itself, and the binding checks that lookup at add_* time — so
an activation the engine cannot differentiate is refused rather than stored as a null
pointer and called on the first backward pass.
activation="softmax" is one such case: its derivative is a Jacobian and only the
fused form is implemented. Use activation="identity" with
loss="softmax_cross_entropy", which computes dL/dz = softmax(z) - y directly.
predict() then returns logits rather than probabilities.
The C++ API can reach several states that crash or train incorrectly. The binding
collects layer specs first and constructs core::Network only in build(), so those
states are unreachable — every one of these raises a Python exception instead:
- an out-of-range layer index, or unfilled layer slots at initialization
- a mismatched activation / derivative pair
softmax, which has no derivative implemented in the engine yet- more than one output layer —
backwardPasspropagates gradients fromoutputLayers[0]only, so a second output layer would train silently wrong - a layer chain whose shapes don't connect
- input or target vectors of the wrong length
- using the network before
build(), or adding layers after it
Two places where the Python layer deliberately differs from the C++ engine:
- Loss reporting.
core::Network::computeLosssums squared error regardless of which loss is configured.Network.loss()andtrain()use the configured loss. train()is implemented in the binding rather than callingcore::Network::train, so it can return per-epoch history, shuffle with a seed, and stay quiet unlessverbose=True.
Layers are indexed by
| Symbol | Meaning |
|---|---|
| the input vector | |
| pre-activations of layer |
|
| activations of layer |
|
| the network's predictions, |
|
| the target / ground truth | |
| weight matrix and bias vector of layer |
|
| local gradient, |
|
| number of elements in the vector under discussion | |
| learning rate | |
| batch size (currently always |
The file header in
core/cannot render LaTeX, so it writes vectors in the form:v:—:z:,:a:,:p:,:y:correspond to$\mathbf{z}$ ,$\mathbf{a}$ ,$\mathbf{p}$ ,$\mathbf{y}$ here.
All activations except softmax are elementwise, so each is written as a scalar
function gelu and swish; the rest carry over from v0.0.1.
| Name | ||
|---|---|---|
sigmoid |
||
tanh |
||
relu |
|
|
leaky_relu |
|
|
swish |
||
gelu |
with leaky_relu, gelu uses the exact form, not the
where
sigmoid branches on the sign of softmax subtracts
On the softmax derivative: every other derivative above is elementwise, so it is
a scalar-in / scalar-out function. The softmax Jacobian is a dense fetchAssociatedDerivative therefore
returns nullptr for softmax on purpose, and the binding refuses it.
The fused path is reachable in v0.0.3, though not through that activation. Pair
identity with softmax_cross_entropy: the loss derivative consumes the logits and
returns predict() then returns logits
rather than probabilities. A regression test pins this.
| Name | ||
|---|---|---|
mse |
||
mae |
||
huber |
||
bce |
||
cce |
with the Huber kernel and its derivative, for
MSE, MAE, Huber, and BCE average over the
maeandhuberare new in v0.0.2.huberis the smooth-L1 that makes the MAE gradient well-behaved near the target; use it overmaeunless you need exact L1.
forward_pass() carries one vector through the network, reassigning it at each
layer, so layer
starting from
Each neuron's input is written into the flat input_cache_ at the offset
corresponding to its weight row; the backward pass needs it, and it is gone once the
next forward pass runs.
Gradients flow the other way. At each layer, three things happen:
The bias gradient equals the local gradient because a bias's input is always
In the flat layout, step 2 is a single loop over grad_w_ and step 3 is a single
loop over weights_ in the same layer block, so both read contiguous memory. The
transpose in step 3 is the one place the access pattern is not sequential, which is
why the inner loop there is the least unrolled of the three.
Per-sample SGD, one update per row, in the order the rows appear unless shuffle is
set.
train() argument |
Default | Meaning |
|---|---|---|
epochs |
100 |
passes over the training data |
shuffle |
False |
reshuffle the row order every epoch |
seed |
None |
fixes shuffling; omit for a fresh order each run |
verbose |
False |
print per-epoch progress |
log_every |
0 |
0 picks a readable interval; only used when verbose |
The update is plain SGD with no weight decay:
Because gradients accumulate but nothing else touches them, this is the grad_w_ / grad_b_ buffers are already sized and zeroed the way a batch
implementation needs, so re-adding it is a loop change, not a layout change.
Weights default to He initialization, xavier and zeros are also available.
Inputs are assumed to be roughly unit-scale — unnormalized features train noticeably
worse, and there is no batch normalization in this release.
Passing seed to train() fixes the shuffle order; passing it to the constructor is
not yet supported. Weight initialization draws from a fixed default seed in this
release. A test asserts that two runs with the same seed produce identical history.
core/ is header-only:
#include "core/network.h"
core::Network net(1, 1, 0.05);
net.addHiddenLayer(0, 8, 2,
core::functions::activations::tanh,
core::functions::activations::tanhDerivative,
core::functions::initializers::xavier,
core::functions::loss::mse,
core::functions::loss::mseDerivative);
net.addOutputLayer(0, 1, 8, /* ... */);
net.initializeAllWeightsAndBiases();clang++ -std=c++20 -O2 main.cpp -o main
./tests/run_tests.sh # add --asan for sanitizersNote that tests/run_tests.sh currently reports several failures by design — they
are known bugs documented as executable specifications, each with a FAIL_WITH_NOTE
explaining the fix.
The C++ API is lower-level than the Python one, deliberately. It does not check that the activation and its derivative match, that shapes connect, or that
build()was called. The Python binding exists in part to make those states unreachable. If you usecore/directly, read Invariants first.
./tests/run_tests.sh # C++ suite — 91 checks
./tests/run_tests.sh --asan # with AddressSanitizer + UBSan
./tests/run_tests.sh --simd # with -fopenmp-simd, so the pragmas apply
./tests/run_tests.sh Gradients # only tests whose name contains "Gradients"
python3 python/test_bindings.py # bindings, guards, C++/Python parity
python3 python/test_plotting.py # every diagram, headless (Agg)All 91 C++ checks pass in every mode: plain, --asan, --simd, and both together.
Run it with --simd at least once before shipping. The pragmas are discarded in
silence without the flag, so a malformed one is invisible until something enables
them. That is not hypothetical: softmaxCrossEntropyDerivative carried an
array-section reduction clause on a loop that overwrites rather than accumulates,
the suite passed without the flag, and adding it failed the fused-softmax test
immediately. Each runs in a forked
child, so a crash is reported against one row of the table rather than ending the run.
Reference values are computed in the test from the definitions, never captured from
the implementation, so a failure means the code disagrees with the maths.
Because the engine is float32, comparisons use a 1e-6 tolerance for hand-computed
references and snapshot the buffer for "this did not change" — 0.7f != 0.7, so a
decimal literal would fail for the wrong reason.
The v0.0.2 zero-allocation assertion was not ported; the flat layout it guarded is unchanged, but nothing currently asserts it.
Listed so the gap is explicit rather than discovered.
| Feature | Status in v0.0.3 |
|---|---|
| Mini-batch training | yes — Network(..., batch_size) |
| Per-epoch shuffling | yes — seeded, inside train() |
| Seeded weight initialisation | yes — gradiex.seed / initializers::setSeed |
| Fused softmax + categorical cross-entropy | yes — via identity + softmax_cross_entropy |
| Multi-class output | yes — same pairing |
| L2 weight decay | not yet |
| Inverted dropout | not yet |
| Train/validation split | not yet — but train(epochs=1) in a loop plus evaluate() gets you the curve |
| Early stopping | not yet |
| Model save / load | not yet — set_weights / set_biases make it a loop, and the pieces are there |
| Numerical gradient check | not ported — gradients are pinned against hand-derived values instead |
The architecture was rewritten first on purpose: re-adding a feature to a flat layout is a localized change, whereas re-flattening a layout after the features are back is a rewrite of every feature at once.
Changing these silently breaks correctness.
-
Gradients accumulate; no weight moves until the update step. This is what
makes mini-batching exact once it returns, and what guarantees that step 3 of the
backward pass,
$\partial L / \partial a_j^{(l-1)} = \sum_i \delta_i^{(l)} w_{ij}^{(l)}$ , reads$w_{ij}^{(l)}$ before it is updated. Updating a layer's weights before propagating through it corrupts every layer behind it. -
input_cache_must hold the input from this forward pass. Weight gradients are meaningless against a stale cache. -
The flat buffers and the layer offsets must agree.
weights_is indexed aslayer.offset_w + i * layer.fan_in + j;grad_w_uses the same layout. A layer whoseoffset_wdoes not match the sum of the previous layers' sizes will read the wrong weights and train plausibly and wrongly. Offsets are assigned once, inbuild(), and never recomputed. -
The unrolled loop's accumulator tree is fixed. Four accumulators, summed as
(s0 + s1) + (s2 + s3). Reordering them changes the floating-point result and breaks bit-exact comparison against the numerical reference. Do not let the compiler reassociate this loop. -
No allocation between
build()and destruction. The zero-allocation test enforces it. If a new buffer is needed, it is sized inbuild(). -
ActivationandLossare enums on the hot path. Strings are parsed once at the API boundary; do not push a string comparison back into the per-neuron loops. -
Softmax is not evaluable on its own. Its Jacobian is a dense
$n \times n$ matrix, which the scalar-in / scalar-out derivative signature cannot express, so the derivative throws rather than return a wrong answer. The supported path will be the fused one,$\partial L / \partial \mathbf{z} = \mathbf{p} - \mathbf{y}$ , which cancels the Jacobian and the$-y_i/p_i$ singularity exactly.
Optimizers other than plain SGD (no momentum, Adam, or LR schedule), L2 or dropout, train/validation split, early stopping, model save/load, a trainable softmax output, convolution, recurrence, GPU, or threading.
Mini-batching, per-epoch shuffling and seeded initialisation are supported in
v0.0.3. Vectorisation is requested through #pragma omp simd rather than written with
intrinsics, so it is present only when the build enables OpenMP.
Two defects remain open: only outputLayers[0] back-propagates, so extra output
heads train silently wrong (the Python layer refuses to build them); and Network
has a destructor but no copy constructor, so a copy double-frees.
Measured, not inherited. bench/bench.cpp drives both engines through one shared
harness — same timing loop, same data, same architectures — so the comparison is
identical by construction rather than by inspection.
./bench/run_bench.sh # full matrix, CSV on stdoutRaw output is in bench/results.csv.
Machine: Apple M5, 10 cores, macOS; Apple clang 21.0.0, single-threaded. Metric: microseconds per sample — forward pass, backward pass and the weight update — taking the minimum of nine trials, since interference only ever adds time. Median and max are in the CSV; the two agree to within 0.06× everywhere, so the choice does not move any conclusion.
At batch size 1 both engines do the same thing per sample. v0.0.3 additionally zeroes its gradient buffers each update — a real cost of the batching architecture, counted here rather than excused.
-O2, no OpenMP:
| Architecture | Params | v0.0.2 | v0.0.3 | Speedup |
|---|---|---|---|---|
2→8×2→1 |
105 | 0.131 | 0.111 | 1.19× |
20→32×2→3 |
1,827 | 1.040 | 0.648 | 1.61× |
64→128×3→10 |
42,634 | 37.573 | 17.559 | 2.14× |
784→128×2→10 |
118,282 | 85.469 | 64.229 | 1.33× |
-O2 -fopenmp-simd, which is what makes the #pragma omp simd directives real:
| Architecture | Params | v0.0.2 | v0.0.3 | Speedup | v0.0.3 @ batch 32 | Speedup |
|---|---|---|---|---|---|---|
2→8×2→1 |
105 | 0.139 | 0.112 | 1.24× | 0.081 | 1.71× |
20→32×2→3 |
1,827 | 1.083 | 0.564 | 1.92× | 0.407 | 2.66× |
64→128×3→10 |
42,634 | 37.810 | 12.293 | 3.08× | 7.644 | 4.95× |
784→128×2→10 |
118,282 | 85.855 | 26.794 | 3.20× | 15.631 | 5.49× |
So: 1.2–2.1× from single precision and the structural changes alone, and 1.2–3.2× once the SIMD directives are enabled. With mini-batching at 32, where the gradient-zeroing pass amortises, 1.7–5.5×.
Without it the pragmas are accepted and discarded in silence. What that costs, for
v0.0.3 at -O2:
| Architecture | no flag | -fopenmp-simd |
Gain |
|---|---|---|---|
2→8×2→1 |
0.111 | 0.112 | — |
20→32×2→3 |
0.648 | 0.564 | 1.15× |
64→128×3→10 |
17.559 | 12.293 | 1.43× |
784→128×2→10 |
64.229 | 26.794 | 2.40× |
The gain tracks layer width, which is what you would expect: the wider the dot product, the more there is to vectorise. On the narrowest network it is nothing at all. Note that v0.0.2 is unaffected by the flag — it has no pragmas — so on the widest architecture the flag alone accounts for most of v0.0.3's margin.
Apple clang rejects
-fopenmp, which is the flag the docs usually name. It does accept-fopenmp-simd, which enables exactly the SIMD directives and needs no runtime library — andcore/uses nothing else, no parallel regions. That is the flag to reach for on macOS.
-O3 made no difference at any size: every figure landed within noise of -O2. It is
in the matrix and the CSV, but there is nothing to report from it.
- v0.0.1. The old table in this README compared v0.0.2 against v0.0.1 and claimed ~2.4×. That column is gone rather than carried forward: v0.0.1 is a single translation unit with a different API, and re-deriving a fair harness for it was not part of this work. The numbers above are v0.0.3 against v0.0.2 only.
- Accuracy per unit time. These are throughput figures. Single precision changes the arithmetic, so a faster update is not automatically a faster path to a given loss — nothing here measures that.
- Multi-threading. Everything is single-threaded.
#pragma omp simdis instruction-level parallelism, not thread-level; noparallel forexists incore/.
- Mini-batching — the flat
grad_w_/grad_b_buffers are already batch-shaped - L2 weight decay — one term in the update, no layout change
- Inverted dropout — a mask buffer sized in
build(), hidden layers only - Fused softmax + categorical cross-entropy — restores multi-class; needs the layer-wide path back
- Model save/load — the flat layout makes this two
memcpys plus a header - Train/validation split and
TrainHistory— returns with the mini-batch loop - Early stopping driven by
TrainHistory - Seeded weight initialization from the constructor
- SIMD inner loop — hand-written,
-march=nativeopt-in, compared against the scalar path for bit-exactness where possible - Adam / momentum — plain SGD is the current ceiling on hard problems
- Per-layer widths and dropout rates
See TODO.md for measured performance work in priority order.
Contributions are welcome. See CONTRIBUTING.md for the build and test workflow, the code style, and — most importantly — the invariants that must not be broken. Breaking one produces a library that trains plausibly and is silently wrong.
If you touch the gradient path, the gradient check is what decides whether you got it right. If you touch the memory layout or the unrolled loop, the zero-allocation test and the bit-exactness test are what decide.
Releases are cut by pushing a tag; RELEASING.md covers the trusted-publishing setup, the build matrix, and the pre-tag checklist.
By participating you agree to the Code of Conduct.
MIT © 2026 Snehashish Laskar.






