GitHub - thevoxium/bare-lm: lightweight autograd lib in C

GitHub

8 min read Original article ↗

A minimal autograd tensor library in C.

Installation

You can install bare-lm in two ways.

Option 1: Build and install from source

Requirements:

  • A C compiler (cc, gcc, or clang)
  • make
  • OpenBLAS (headers + library)

Install OpenBLAS first:

# macOS (Homebrew)
brew install openblas

# Ubuntu / Debian
sudo apt update && sudo apt install -y libopenblas-dev

Then build and install:

git clone https://github.com/thevoxium/bare-lm.git
cd bare-lm

# macOS
make OPENBLAS_PREFIX=/opt/homebrew/opt/openblas

# Linux
make

sudo make install

By default this installs to /usr/local/include/bare.h and /usr/local/lib/libbare.{so,dylib}.

Option 2: Install from GitHub Release tarball

Each tagged release publishes prebuilt archives for Linux and macOS:

  • bare-<version>-linux.tar.gz
  • bare-<version>-macos.tar.gz

After downloading and extracting a release tarball, install headers and library:

tar -xzf bare-<version>-<artifact>.tar.gz
cd bare-<version>-<artifact>
sudo cp include/bare.h /usr/local/include/
sudo cp lib/libbare.* /usr/local/lib/

Using the library

If your system does not search /usr/local/lib by default, add -L/usr/local/lib and -I/usr/local/include.

Example: XOR with a 2-layer MLP

#include "bare.h"

int main() {
  Memory *mem = create_global_mem(1 << 28);
  ParameterList *pl = create_param_list(mem);

  int x_shape[] = {4, 2};
  int y_shape[] = {4, 1};

  Tensor *x = tensor_init(mem, x_shape, 2, PERM);
  Tensor *y = tensor_init(mem, y_shape, 2, PERM);

  float x_data[] = {0, 0, 0, 1, 1, 0, 1, 1};
  float y_data[] = {0, 1, 1, 0};
  for (int i = 0; i < 8; i++) x->data[i] = x_data[i];
  for (int i = 0; i < 4; i++) y->data[i] = y_data[i];

  Linear *l1 = create_linear(mem, pl, 2, 8);
  Linear *l2 = create_linear(mem, pl, 8, 1);

  load_checkpoint(pl, "xor.btw");

  for (int epoch = 0; epoch < 500; epoch++) {
    zero_grad(pl);

    Tensor *h = linear_t(mem, l1, x);
    h = relu_t(mem, h);
    Tensor *o = linear_t(mem, l2, h);
    o = sigmoid_t(mem, o);

    Tensor *loss = mseloss_t(mem, o, y);
    backward(mem, loss);

    if (epoch % 100 == 0)
      printf("epoch %3d  loss=%.4f\n", epoch, loss->data[0]);

    sgd_step(pl, 0.1f);
    reset_temp_mem(mem);
  }

  save_checkpoint(pl, "xor.btw");

  free_global_mem(mem);
}

Walkthrough

Step 1: Create memory and a parameter list. A single Memory object holds two arenas (permanent and temporary). 1 << 28 = 256 MB per arena. A ParameterList tracks all trainable tensors.

Memory *mem = create_global_mem(1 << 28);
ParameterList *pl = create_param_list(mem);

Step 2: Allocate persistent tensors. Inputs x and y are plain PERM tensors (not trainable). Pass PERM so they survive reset_temp_mem.

Tensor *x = tensor_init(mem, x_shape, 2, PERM);
Tensor *y = tensor_init(mem, y_shape, 2, PERM);

Step 3: Create layers with auto-registration. create_linear allocates weights and bias as PERM tensors and automatically adds them to the parameter list.

Linear *l1 = create_linear(mem, pl, 2, 8);
Linear *l2 = create_linear(mem, pl, 8, 1);
// pl now contains l1->weights, l1->bias, l2->weights, l2->bias

Step 4: Forward pass. Every operation (linear_t, relu_t, sigmoid_t, mseloss_t) allocates its result from the temp arena. No malloc calls, no cleanup code.

Tensor *h = linear_t(mem, l1, x);
h = relu_t(mem, h);
Tensor *o = linear_t(mem, l2, h);
o = sigmoid_t(mem, o);
Tensor *loss = mseloss_t(mem, o, y);

Step 5: Backward pass. backward builds a topological sort of the computation graph (also temp-allocated) and propagates gradients. Intermediate tensor data is still valid at this point.

Step 6: Zero grads, update, reset. zero_grad clears all parameter gradients. sgd_step applies SGD to all parameters in one call. After gradients are consumed, reset_temp_mem zeroes the temp arena in O(1). All intermediate tensors from the forward pass are gone.

zero_grad(pl);
sgd_step(pl, 0.1f);
reset_temp_mem(mem);

Step 7: Cleanup. free_global_mem releases both arenas and the Memory struct.

Memory Model

All allocations go through allocate_mem(mem, size, perm). There are no individual free calls.

Flag Lifetime Used for
PERM Until free_global_mem Weights, biases, persistent inputs/targets
TEMP Until reset_temp_mem Operation results, graph traversal arrays, loss

reset_temp_mem resets the temp arena pointer to 0. It is called after both forward and backward complete, because backward reads intermediate tensor data.

epoch N:  forward (temp fills) → backward (reads temp) → update → reset_temp_mem
epoch N+1: forward (temp fills from 0) → ...

ParameterList

ParameterList is a dynamic array of trainable tensors. It is a typedef of Dt_array allocated in the PERM arena.

ParameterList *pl = create_param_list(mem);

// Manual registration
param_list_add(mem, pl, my_tensor);

// Or automatic — create_linear registers weights and bias for you
Linear *l = create_linear(mem, pl, 128, 64);

Once built, the parameter list drives the training loop:

zero_grad(pl);       // zero all parameter gradients
backward(mem, loss); // backprop
sgd_step(pl, 0.01f); // update all parameters

API Reference

Memory

Function Signature Description
create_global_mem (size_t size) → Memory* Allocate perm + temp arenas (e.g., 1<<28 = 256MB each)
reset_temp_mem (Memory *mem) Reset temp arena to empty
allocate_mem (Memory *mem, size_t size, uint8_t perm) → void* Arena allocation
free_global_mem (Memory *mem) Free both arenas and Memory
save_checkpoint (ParameterList *pl, const char *path) Save all parameters to binary file
load_checkpoint (ParameterList *pl, const char *path) Load parameters from binary file

ParameterList

Function Signature Description
create_param_list (Memory *mem) → ParameterList* Create an empty PERM parameter list
param_list_add (Memory *mem, ParameterList *pl, Tensor *t) Add a tensor to the list
zero_grad (ParameterList *pl) Zero gradients for all parameters
sgd_step (ParameterList *pl, float lr) SGD update: data -= lr * grad
clip_gradients (ParameterList *pl, float threshold) Clip gradients to [-threshold, threshold]
adam_init (Memory*, ParameterList*, lr, beta1, beta2, eps, t) → Adam* Initialize Adam optimizer state
adam_step (Adam*, ParameterList*) Adam update step
adamw_init (Memory*, ParameterList*, lr, beta1, beta2, eps, weight_decay, t) → AdamW* Initialize AdamW optimizer state
adamw_step (AdamW*, ParameterList*) AdamW update step

Tensor Creation

Function Signature Description
tensor_init (Memory*, int *shape, int ndim, uint8_t perm) → Tensor* Zero-initialized tensor
tensor_zeros (Memory*, int *shape, int ndim, uint8_t perm) → Tensor* Same as tensor_init
tensor_ones (Memory*, int *shape, int ndim, uint8_t perm) → Tensor* All ones
tensor_randn (Memory*, int *shape, int ndim, uint8_t perm) → Tensor* Random normal (Box-Muller)
tensor_xavier (Memory*, int *shape, int ndim, uint8_t perm) → Tensor* Xavier initialization
tensor_eye (Memory*, int n, int m, uint8_t perm) → Tensor* Identity matrix (n×n or n×m)
tensor_full_like (Memory*, Tensor *a, float fill_value, uint8_t perm) → Tensor* Same shape as a, filled with fill_value
tensor_zeros_like (Memory*, Tensor *a, uint8_t perm) → Tensor* Same shape as a, filled with zeros
tensor_ones_like (Memory*, Tensor *a, uint8_t perm) → Tensor* Same shape as a, filled with ones
print_t (Tensor*, uint8_t grad) Print tensor (grad=1 to include gradients)

Arithmetic

Function Signature Description
add_t (Memory*, Tensor *a, Tensor *b) → Tensor* Element-wise a + b
sub_t (Memory*, Tensor *a, Tensor *b) → Tensor* Element-wise a - b
mul_t (Memory*, Tensor *a, Tensor *b) → Tensor* Element-wise a * b
divide_t (Memory*, Tensor *a, Tensor *b) → Tensor* Element-wise a / b
neg_t (Memory*, Tensor *a) → Tensor* Element-wise -a
pow_t (Memory*, Tensor *a, float exp) → Tensor* Element-wise pow(a, exp)
scale_t (Memory*, Tensor *a, float v) → Tensor* Element-wise a * scalar

Math

Function Signature Description
exp_t (Memory*, Tensor *a) → Tensor* Element-wise exp
log_t (Memory*, Tensor *a) → Tensor* Element-wise log

Reductions

Function Signature Description
sum_t (Memory*, Tensor *a, int dim) → Tensor* Sum along dimension
mean_t (Memory*, Tensor *a, int dim) → Tensor* Mean along dimension
max_t (Memory*, Tensor *a, int dim) → Tensor* Max along dimension

Activations

Function Signature Description
relu_t (Memory*, Tensor *a) → Tensor* ReLU
gelu_t (Memory*, Tensor *a) → Tensor* GELU (tanh approximation)
sigmoid_t (Memory*, Tensor *a) → Tensor* Sigmoid
tanh_t (Memory*, Tensor *a) → Tensor* Tanh
softmax_t (Memory*, Tensor *a, int dim) → Tensor* Softmax along dimension

Masking

Function Signature Description
mask_t (Memory*, Tensor *a, Tensor *b, float val) → Tensor* Where b==1, set to val; else keep a

Linear Algebra

Function Signature Description
matmul_t (Memory*, Tensor *a, Tensor *b) → Tensor* Matrix multiply (2D)
bmm_t (Memory*, Tensor *a, Tensor *b) → Tensor* Batch matrix multiply (3D: [B,T,D] x [B,D,T] → [B,T,T])
transpose_t (Memory*, Tensor *a) → Tensor* Transpose 2D
dot_t (Memory*, Tensor *a, Tensor *b) → Tensor* Dot product (1D)

Embedding

Function Signature Description
embedding_t (Memory*, Tensor *vocab, Tensor *indices) → Tensor* Look up embeddings (vocab: [V,D], indices: [B,T])

Shape Operations

Function Signature Description
reshape_t (Memory*, Tensor *a, int *shape, int ndim) → Tensor* Reshape (shares data)
squeeze_t (Memory*, Tensor *a, int dim) → Tensor* Remove dim of size 1
unsqueeze_t (Memory*, Tensor *a, int dim) → Tensor* Insert dim of size 1
broadcast_t (Memory*, Tensor *a, int *shape, int tar_dim) → Tensor* Broadcast to target shape
permute_t (Memory*, Tensor *a, int *dims, int total_dim) → Tensor* Permute dimensions
concat_t (Memory*, Tensor *a, Tensor *b, int dim) → Tensor* Concatenate along dimension
slice_t (Memory*, Tensor *a, int dim, int split_size) → Pair_T* Split tensor along dim, returns pair {F, S}

Loss Functions

Function Signature Description
mseloss_t (Memory*, Tensor *a, Tensor *b) → Tensor* Mean squared error
crossentropyloss_t (Memory*, Tensor *logits, Tensor *targets) → Tensor* Cross-entropy (logits: [N,C], targets: [N])

Layers

Function Signature Description
create_linear (Memory*, ParameterList *pl, int d_in, int d_out) → Linear* Linear layer, auto-registers weights + bias
linear_t (Memory*, Linear*, Tensor *x) → Tensor* Forward: x @ W^T + b
create_layernorm (Memory*, ParameterList *pl, int normalized_shape, float eps) → LayerNorm* LayerNorm, auto-registers weight + bias
layernorm_t (Memory*, LayerNorm*, Tensor *x) → Tensor* Layer normalization forward

Autograd

Function Signature Description
backward (Memory*, Tensor *root) Backpropagate from root

All operations register backward passes and track parent tensors automatically.