TensorLib's neural network component provides a complete, from-scratch deep learning module system in C99. It implements a composable module hierarchy with automatic parameter management, differentiable forward passes, loss functions, optimizers, and checkpoint serialization — all built on top of the autograd engine.
See also: tensor_mechanics.md for base tensor ops, autograd_engine.md for gradient computation, and decoder_implementation.md for the full GPT-style decoder.
The nn component provides:
- Module system — composable parent/child hierarchy with virtual dispatch via function pointers
- Parameter system — named, trainable tensors with automatic weight initialization
- Layer library — Linear, Embedding, PositionalEmbedding, LayerNorm, Dropout, MultiheadAttention, MLP, DecoderBlock, and a full causal language model Decoder
- Loss functions — numerically stable softmax, log-softmax, and cross-entropy
- Optimizers — SGD and AdamW (with decoupled weight decay and gradient clipping)
- RNG — deterministic splitmix64 PRNG for reproducible initialization and dropout
- Checkpointing — atomic binary save/load of model params, optimizer state, and RNG state
TensorLib's nn_module serves the same role as PyTorch's torch.nn.Module. The key differences:
| Feature | PyTorch | TensorLib |
|---|---|---|
| Language | Python (with C++ extensions) | Pure C99 |
| Module composition | self.linear = nn.Linear(...) in __init__ |
nn_module_register_child() / nn_module_register_parameter() |
| Parameter access | module.parameters() (generator) |
nn_module_parameter_at() (index-based) |
| Forward dispatch | __call__ → forward() (Python override) |
nn_module_forward() → function pointer callback |
| Train/eval mode | module.train() / module.eval() |
nn_module_set_training() (recursive) |
| Memory management | Python garbage collector | Manual create/destroy pairs |
| Serialization | torch.save (pickle) |
Custom binary format with magic TLCKPT |
TensorLib also draws design inspiration from ggml's ggml_build_forward for its graph-based autograd approach, where each forward pass builds a computation graph that can later be traversed in reverse for backpropagation.
Every layer in TensorLib embeds an nn_module as its first member, enabling C-style polymorphism through composition (the "first-member" pattern):
struct nn_module {
const char* type_name; // e.g. "Linear", "LayerNorm"
char* name; // caller-assigned instance name
nn_module_forward_fn forward; // virtual dispatch callback
nn_module_destroy_fn destroy; // virtual destructor
nn_parameter** parameters; // direct parameters (weight tensors)
size_t parameter_count;
size_t parameter_capacity;
nn_module** children; // sub-modules
size_t child_count;
size_t child_capacity;
int training; // 1 = train, 0 = eval
};Derived layer types embed nn_module as their first field, allowing safe casting:
struct nn_linear {
nn_module base; // must be first
nn_parameter* weight;
nn_parameter* bias;
int in_features;
int out_features;
int use_bias;
};This mirrors the inheritance pattern in C++ where nn_linear* can be treated as nn_module*.
Modules form a tree. Parent modules own references to their children and parameters:
nn_linear* linear = nn_linear_create("proj", 64, 128, 1, ...);
nn_module_register_child(&parent->base, &linear->base);Child registration includes cycle detection — nn_module_register_child rejects additions that would create a cycle (the target module already contains the parent).
classDiagram
class nn_module {
+const char* type_name
+char* name
+nn_module_forward_fn forward
+nn_module_destroy_fn destroy
+nn_parameter** parameters
+nn_module** children
+int training
}
class nn_linear {
+nn_parameter* weight
+nn_parameter* bias
+int in_features
+int out_features
}
class nn_layer_norm {
+nn_parameter* weight
+nn_parameter* bias
+int normalized_width
+float epsilon
}
class nn_multihead_attention {
+nn_parameter* qkv_weight
+nn_parameter* qkv_bias
+nn_linear* output
+nn_dropout* output_dropout
}
class nn_decoder_block {
+nn_layer_norm* attention_norm
+nn_multihead_attention* attention
+nn_layer_norm* mlp_norm
+nn_linear* mlp_input
+nn_linear* mlp_output
+nn_dropout* mlp_dropout
}
class nn_decoder {
+nn_embedding* token_embedding
+nn_positional_embedding* positional_embedding
+nn_decoder_block** blocks
+nn_layer_norm* final_norm
+nn_linear* language_model_head
}
nn_module <|-- nn_linear
nn_module <|-- nn_layer_norm
nn_module <|-- nn_multihead_attention
nn_module <|-- nn_decoder_block
nn_module <|-- nn_decoder
nn_decoder_block *-- nn_multihead_attention
nn_decoder_block *-- nn_layer_norm
nn_decoder *-- nn_decoder_block
nn_decoder *-- nn_embedding
nn_module_forward invokes the function pointer set during construction:
ag_tensor* nn_module_forward(const nn_module* module, const ag_tensor* input)
{
if (module == NULL || input == NULL || module->forward == NULL) return NULL;
return module->forward(module, input);
}Each concrete layer provides a thin wrapper that casts back to the concrete type:
// From linear.c
static ag_tensor* nn_linear_module_forward(const nn_module* module,
const ag_tensor* input)
{
return nn_linear_forward((const nn_linear*)module, input);
}This is registered during _create via nn_module_init_base().
nn_module_set_training recursively sets the training flag on the module and all descendants:
void nn_module_set_training(nn_module* module, int training)
{
if (module == NULL) return;
module->training = training != 0;
for (size_t i = 0; i < module->child_count; ++i) {
nn_module_set_training(module->children[i], training);
}
}Stochastic modules (like Dropout) check nn_module_is_training() during forward. In eval mode, dropout returns the input unchanged.
nn_module_parameter_count recursively counts all parameters across the entire tree. nn_module_parameter_at uses depth-first traversal to access the i-th parameter by flat index:
size_t nn_module_parameter_count(const nn_module* module)
{
size_t count = module->parameter_count;
for (size_t i = 0; i < module->child_count; ++i) {
count += nn_module_parameter_count(module->children[i]);
}
return count;
}nn_module_zero_grad recursively zeros gradients on all trainable parameters across the entire tree by calling ag_zero_grad on each parameter's ag_tensor.
| TensorLib | PyTorch |
|---|---|
nn_module_register_child() |
self.child = submodule (in __init__) |
nn_module_register_parameter() |
self.param = nn.Parameter(...) |
nn_module_parameter_count() |
sum(p.numel() for p in module.parameters()) |
nn_module_parameter_at(i) |
list(module.parameters())[i] |
nn_module_set_training(0) |
module.eval() |
nn_module_zero_grad() |
module.zero_grad() |
nn_module_forward() |
module(input) |
struct nn_parameter {
char* name; // unique dotted name, e.g. "proj.weight"
ag_tensor* value; // autograd-wrapped tensor
int trainable; // 1 = accumulate gradients, 0 = frozen
};A parameter wraps an ag_tensor (see autograd_engine.md) with a unique name and a trainable flag. The name is used for checkpoint serialization and deduplication.
flowchart LR
P[nn_parameter] -->|"value"| AT[ag_tensor]
AT -->|"value"| T[tensor]
T -->|"storage"| S[storage]
AT -->|"grad"| G[tensor?]
AT -->|"creator"| C[autograd_node?]
P -->|"name"| N["'proj.weight'"]
P -->|"trainable"| TF["1 / 0"]
style P fill:#e8f4fd,stroke:#2196F3
style AT fill:#fff3e0,stroke:#FF9800
style T fill:#e8f5e9,stroke:#4CAF50
style S fill:#fce4ec,stroke:#E91E63
nn_parameter_create allocates the tensor, initializes weights according to the chosen policy, and wraps it as an autograd leaf:
nn_parameter* nn_parameter_create(
const char* name,
int ndim,
const int* dims,
int trainable,
nn_init_kind initializer,
nn_rng* rng
);Ownership flows as: caller owns the nn_parameter*, the module's parameters[] array holds non-owning references. When the module is destroyed via nn_module_destroy_base, all registered parameters are destroyed.
TensorLib uses a deterministic splitmix64 PRNG. The state is a single uint64_t:
struct nn_rng {
uint64_t state;
};The core PRNG produces a 64-bit integer by mixing with the golden ratio constant:
static uint64_t nn_rng_next_u64(nn_rng* rng)
{
rng->state += 0x9E3779B97F4A7C15; // golden ratio increment
uint64_t value = rng->state;
value = (value ^ (value >> 30)) * 0xBF58476D1CE4E5B9;
value = (value ^ (value >> 27)) * 0x94D049BB133111EB;
return value ^ (value >> 31);
}The top 24 bits are extracted for float conversion, giving ~24-bit precision:
static float nn_rng_unit(nn_rng* rng)
{
return (float)(nn_rng_next_u64(rng) >> 40) * (1.0f / 16777216.0f);
}Uniform: Simple linear rescaling of the unit random:
float nn_rng_uniform(nn_rng* rng, float min, float max)
{
return min + (max - min) * nn_rng_unit(rng);
}Normal: Box-Muller transform with rejection-sampled u1 for log safety:
float nn_rng_normal(nn_rng* rng, float mean, float stddev)
{
float u1 = ((float)(nn_rng_next_u64(rng) >> 40) + 1.0f) / 16777217.0f;
float u2 = nn_rng_unit(rng);
return mean + stddev * sqrtf(-2.0f * logf(u1)) * cosf(6.28318530717958647692f * u2);
}Both return NAN on invalid inputs and are fully deterministic given the same seed.
The nn_init_kind enum controls weight initialization:
| Policy | Formula | Use Case |
|---|---|---|
NN_INIT_ZERO |
W = 0 |
Zero bias, attention QKV init |
NN_INIT_ONE |
W = 1 |
LayerNorm weight |
NN_INIT_XAVIER_UNIFORM |
U(-sqrt(6/(fan_in+fan_out)), sqrt(6/(fan_in+fan_out))) |
Linear weights, Embeddings |
NN_INIT_XAVIER_NORMAL |
N(0, sqrt(2/(fan_in+fan_out))) |
Alternative Linear init |
NN_INIT_HE_UNIFORM |
U(-sqrt(6/fan_in), sqrt(6/fan_in)) |
ReLU networks |
NN_INIT_HE_NORMAL |
N(0, sqrt(2/fan_in)) |
ReLU networks |
Fan-in and fan-out are computed from the weight tensor shape, treating dimensions beyond the first two as a receptive field multiplier.
The attention QKV weight uses a custom init of U(-sqrt(3/C), sqrt(3/C)) applied after zero-init, as a distinct scaling choice.
Purpose: Standard affine transformation with optional bias.
Parameters:
weight: shape[out_features, in_features]— initialized withweight_initbias(optional): shape[out_features]— initialized withbias_init
Forward pass math:
Implementation: The weight is transposed via ag_transpose, multiplied with the input via ag_matmul, and the bias is added via ag_add:
ag_tensor* nn_linear_forward(const nn_linear* layer, const ag_tensor* input)
{
ag_tensor* transposed = ag_transpose(layer->weight->value, 0, 1);
ag_tensor* product = ag_matmul(input, transposed);
ag_tensor_release(transposed);
if (!layer->use_bias) return product;
ag_tensor* result = ag_add(product, layer->bias->value);
ag_tensor_release(product);
return result;
}Backward gradient: Computed automatically by the autograd graph — ag_transpose and ag_matmul each have backward implementations.
PyTorch comparison: Equivalent to nn.Linear(in_features, out_features, bias=True). The weight shape [out, in] and the x @ W^T convention match PyTorch exactly.
Purpose: Lookup table that maps integer token IDs to dense vectors.
Parameters:
weight: shape[vocabulary_size, embedding_width]— initialized withweight_init
Forward pass: Uses ag_gather_rows to select rows by token index:
ag_tensor* nn_embedding_forward(const nn_embedding* layer, const ag_tensor* indices)
{
return ag_gather_rows(layer->weight->value, indices->value);
}Input must be a float tensor containing integer token IDs with requires_grad == 0. The output is differentiable w.r.t. the weight matrix.
PyTorch comparison: Equivalent to nn.Embedding(vocab_size, dim). Both store a [V, D] weight and look up by row index. Gradients flow into the weight (sparse update).
Purpose: Adds learned position vectors to token embeddings, giving the model sequence order information.
Parameters:
- Internally owns an
nn_embeddingtable of shape[context_length, embedding_width]
Forward pass: Generates position IDs [0, 1, 2, ..., T-1] from the sequence length, looks them up in the internal embedding table, and adds the result to the token embeddings:
ag_tensor* nn_positional_embedding_forward(
const nn_positional_embedding* layer,
const ag_tensor* token_embeddings)
{
// ... generate position_ids [T] ...
positions = nn_embedding_forward(layer->table, position_ids);
result = ag_add(token_embeddings, positions);
return result;
}Input shape: [B, T, C]. Output shape: [B, T, C] (same — addition).
The internal embedding is registered as a child module, so its parameter appears in the parent's parameter count automatically.
flowchart TB
TIDS["Token IDs [B, T]"] --> EMB["Token Embedding<br/>Vocab → C"]
EMB --> POS["Positional Embedding<br/>adds learned [T, C] vectors"]
POS --> BLK1["Decoder Block 0"]
BLK1 --> BLK2["Decoder Block 1"]
BLK2 --> BLKN["... Decoder Block N-1"]
BLKN --> NORM["Final LayerNorm"]
NORM --> LM["Language Model Head<br/>Linear [C → V]"]
LM --> LOGITS["Logits [B, T, V]"]
style TIDS fill:#fff3e0
style LOGITS fill:#e8f5e9
style EMB fill:#e8f4fd
style LM fill:#fce4ec
Purpose: Normalizes the last dimension of the input to zero mean and unit variance, with optional learned affine scale and shift.
Parameters (when affine=1):
weight: shape[normalized_width]— initialized to 1 (NN_INIT_ONE)bias: shape[normalized_width]— initialized to 0 (NN_INIT_ZERO)
Forward pass math:
Implementation: Builds a chain of autograd ops: ag_mean → ag_sub → ag_mul → ag_mean → ag_add (epsilon) → ag_sqrt → ag_div → ag_mul (gamma) → ag_add (beta).
PyTorch comparison: Equivalent to nn.LayerNorm(normalized_shape, eps=1e-5, elementwise_affine=True). Uses biased variance (divides by C, not C-1).
Purpose: Regularization layer that randomly zeros elements during training.
Parameters: None (stateless — uses the RNG reference).
Forward pass math (inverted dropout):
where
Key behavior:
-
Training mode: Samples a binary mask, scales surviving elements by
$1/(1-p)$ (inverted dropout). - Eval mode: Returns the input unchanged — no mask sampling, no RNG advancement.
- Probability = 0: Returns input directly without allocating a mask.
if (!nn_module_is_training(&layer->base) || layer->probability == 0.0f) {
ag_tensor_retain((ag_tensor*)input);
return (ag_tensor*)input;
}The RNG is stored as a non-owning reference (nn_dropout does not own the nn_rng). The RNG must outlive the dropout module.
PyTorch comparison: Equivalent to nn.Dropout(p). Both use inverted dropout scaling. The probability must be [0, 1).
Purpose: Decoder-style causal self-attention with fused Q/K/V projections.
Parameters:
qkv_weight: shape[3, C, C]— fused Q/K/V projection weightsqkv_bias: shape[3, C]— fused Q/K/V biasesoutput:nn_linearchild module for the output projection[C → C]output_dropout:nn_dropoutchild module
Channels must divide evenly across heads: head_width = channels / head_count.
flowchart TB
IN["Input [B, T, C]"] --> QKV["Fused QKV Projection<br/>[B, T, 3, C] via [3, C, C]"]
QKV --> Q["Query<br/>[B, T, H, D]"]
QKV --> K["Key<br/>[B, T, H, D]"]
QKV --> V["Value<br/>[B, T, H, D]"]
K --> KT["Key^T<br/>[B, H, D, T]"]
Q --> MATMUL1["Q @ K^T"]
KT --> MATMUL1
MATMUL1 --> SCALE["÷ sqrt(D)"]
SCALE --> MASK["Causal Mask<br/>+0 or -∞"]
MASK --> SOFTMAX["Softmax"]
SOFTMAX --> MATMUL2["Attn @ V"]
V --> MATMUL2
MATMUL2 --> TRANSPOSE["Transpose [B, H, T, D] → [B, T, C]"]
TRANSPOSE --> OUT_PROJ["Output Linear [C → C]"]
OUT_PROJ --> DROPOUT["Output Dropout"]
DROPOUT --> OUT["Output [B, T, C]"]
style IN fill:#e8f4fd
style OUT fill:#e8f5e9
style MASK fill:#fff3e0
style QKV fill:#fce4ec
Forward pass algorithm:
-
Fused QKV projection: Reshape input to
[1, B, T, C], multiply by[3, 1, C, C]weight and add[3, 1, 1, C]bias. This produces all three projections in one matmul. -
Split and reshape: Slice along the projection dimension, reshape each to
[B, T, H, D], then transpose to[B, H, T, D]for batched attention. -
Scaled dot-product attention:
- Compute scores:
$\text{scores} = Q \cdot K^T / \sqrt{D}$ - Apply causal mask via
nn_apply_causal_mask(sets positions above the diagonal to$-\infty$ ) - Apply softmax:
$\text{probs} = \text{softmax}(\text{scores})$ - Compute context:
$\text{context} = \text{probs} \cdot V$
- Compute scores:
-
Merge heads: Transpose from
[B, H, T, D]to[B, T, C]. -
Output projection: Linear layer
[C → C]followed by dropout.
PyTorch comparison: Similar to nn.MultiheadAttention(embed_dim, num_heads, dropout) in self-attention mode, but always causal and with a fused QKV weight matrix. Unlike FlashAttention, TensorLib materializes the full attention matrix — this is a reference implementation, not optimized for memory or compute.
Purpose: Configurable feed-forward network with arbitrary hidden sizes and activations. Used as the transformer FFN.
Parameters: Internally creates hidden_count + 1 nn_linear layers registered as children.
Configuration:
struct nn_mlp_config {
int input_features;
const int* hidden_sizes; // array of hidden layer sizes
size_t hidden_count; // number of hidden layers
int output_features;
const nn_activation* activations; // array of (hidden_count + 1) activations
int use_bias;
nn_init_kind weight_init;
nn_init_kind bias_init;
};Forward pass: Chains Linear → Activation → Linear → Activation → ... → Linear:
for (size_t i = 0; i < model->layer_count; ++i) {
current = nn_module_forward(model->base.children[i], current); // Linear
if (model->activations[i].forward != NULL) {
current = model->activations[i].forward(&model->activations[i], current);
}
}The final layer uses the last activation from the activations array (typically a no-op or identity for output layers).
Built-in activations: nn_activation_relu(), nn_activation_gelu(), nn_activation_sigmoid(), nn_activation_tanh(). Custom activations can be created with nn_activation_custom().
PyTorch comparison: Similar to stacking nn.Linear + activations. The decoder block's FFN uses a 2-layer MLP with GELU (hidden size = 4× channels), matching the standard transformer architecture.
flowchart LR
IN["Input [B, D_in]"] --> L0["Linear [D_in → H_1]"]
L0 --> A0["Activation_0"]
A0 --> L1["Linear [H_1 → H_2]"]
L1 --> A1["Activation_1"]
A1 --> L2["Linear [H_2 → D_out]"]
L2 --> A2["Activation_2"]
A2 --> OUT["Output [B, D_out]"]
style IN fill:#e8f4fd
style OUT fill:#e8f5e9
Each Linear is registered as a child module; activations are stored in the activations[] array and applied between layers.
Numerically stable log-softmax using the log-sum-exp trick:
Softmax is implemented as exp(log_softmax(x)) for numerical consistency:
ag_tensor* nn_softmax(const ag_tensor* logits)
{
ag_tensor* log_probabilities = nn_log_softmax(logits);
ag_tensor* probabilities = ag_exp(log_probabilities);
ag_tensor_release(log_probabilities);
return probabilities;
}Implementation: Computes NLLLoss(log_softmax(logits), targets):
- Validates target classes are in
[0, num_classes) - Builds a one-hot selector matrix from the integer targets
- Computes
log_softmax(logits) - Multiplies with the selector and sums over the class dimension
- Negates and takes the global mean
Formula:
PyTorch comparison: Equivalent to nn.CrossEntropyLoss() which combines nn.LogSoftmax and nn.NLLLoss. The targets are class indices (not one-hot), matching PyTorch's convention.
nn_apply_causal_mask adds a lower-triangular mask to attention scores:
ag_tensor* nn_apply_causal_mask(const ag_tensor* scores)
{
// Creates [T, T] mask: 0.0 on and below diagonal, -INFINITY above
for (int row = 0; row < sequence; ++row)
for (int column = 0; column < sequence; ++column)
mask_value->storage->data[row * sequence + column] =
column <= row ? 0.0f : -INFINITY;
return ag_add(scores, mask);
}This is applied to 2D slices of the attention score tensor [B, H, T, T].
Purpose: Stochastic gradient descent with optional per-parameter learning rates.
nn_sgd* nn_sgd_create(nn_module* module, float learning_rate);
int nn_sgd_step(nn_sgd* optimizer);
void nn_sgd_zero_grad(nn_sgd* optimizer);Update rule:
The SGD optimizer iterates all parameters (recursively via nn_module_parameter_at), validates gradient shapes and finiteness, and applies the update element-wise. It skips non-trainable parameters. After updating, it calls tensor_mark_modified to invalidate any cached views.
Validation: Before applying updates, nn_sgd_step validates:
- Module topology integrity
- Gradient shapes match parameter shapes
- All values and gradients are finite
- The resulting updated values are finite
Purpose: AdamW optimizer with decoupled weight decay, bias correction, and global gradient norm clipping.
nn_adamw_config nn_adamw_default_config(void); // lr=1e-3, β1=0.9, β2=0.999, ...
nn_adamw* nn_adamw_create(nn_module* module, const nn_adamw_config* config);
int nn_adamw_step(nn_adamw* optimizer);
void nn_adamw_zero_grad(nn_adamw* optimizer);Internal state (per parameter):
-
first_moments[i]— exponential moving average of gradients ($m_t$ ) -
second_moments[i]— exponential moving average of squared gradients ($v_t$ ) -
steps[i]— step counter for bias correction
Update rules:
Gradient norm clipping: If max_grad_norm > 0, the global L2 norm of all gradients is computed. If it exceeds max_grad_norm, all gradients are scaled down by max_grad_norm / norm.
PyTorch comparison: Matches torch.optim.AdamW with decoupled weight decay (not L2 regularization). Default parameters (lr=1e-3, betas=(0.9, 0.999), eps=1e-8, weight_decay=0.01) match PyTorch's defaults. The gradient clipping is applied inside the step, equivalent to calling torch.nn.utils.clip_grad_norm_ before the optimizer step.
nn_module_zero_grad and nn_clip_grad_norm are optimizer-independent and available for any module:
void nn_module_zero_grad(nn_module* module);
int nn_clip_grad_norm(nn_module* module, float max_norm, float* total_norm);TensorLib provides atomic checkpoint save/load for model parameters, AdamW optimizer state, and RNG state.
flowchart LR
subgraph "Checkpoint File"
direction TB
MAGIC["Magic: 'TLCKPT\\0\\0' (8 bytes)"]
VERSION["Version: u32 (1)"]
FLAGS["Flags: u32<br/>bit 0 = AdamW<br/>bit 1 = RNG"]
PCOUNT["Parameter count: u32"]
PARAMS["Parameters (repeated)"]
OPT["AdamW state (if flag set)"]
RNG["RNG state (if flag set)"]
end
subgraph PARAM["Each Parameter"]
direction TB
PNAME["Name: length-prefixed string"]
PNDIM["ndim: u32"]
PDIMS["dims: u32 × ndim"]
PCOUNT2["element count: u64"]
PVALS["values: f32 × count"]
end
subgraph OPTS["AdamW State"]
direction TB
OCONF["Config: 6 × f32 (lr, β1, β2, ε, wd, max_norm)"]
OUCOUNT["parameter count: u32"]
OPER["Per parameter:"]
OPNAME[" name: string"]
OPSTEP[" step: u64"]
OPELcount[" element count: u64"]
OPF1[" first_moments: f32 × count"]
OPF2[" second_moments: f32 × count"]
end
PARAMS --> PARAM
OPT --> OPTS
| Field | Type | Description |
|---|---|---|
| Magic | uint8_t[8] |
TLCKPT\0\0 |
| Version | uint32_t |
Currently 1 |
| Flags | uint32_t |
Bit 0: AdamW state present. Bit 1: RNG state present. |
| Parameter count | uint32_t |
Number of parameters |
| Parameter name | string | Length-prefixed (u32 length + bytes) |
| Parameter ndim | uint32_t |
Number of dimensions |
| Parameter dims | uint32_t[] |
Dimension sizes |
| Element count | uint64_t |
Total elements (verified against dims) |
| Values | float[] |
All tensor elements as IEEE 754, little-endian |
All multi-byte values are serialized in little-endian byte order.
Save operations are atomic — data is written to a temporary file (path + ".tmp") and then atomically renamed to the final path. On Windows, MoveFileExA with MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH is used; on POSIX, rename():
#ifdef _WIN32
return MoveFileExA(temporary, destination,
MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH) != 0;
#else
return rename(temporary, destination) == 0;
#endifIf any error occurs during write, the temporary file is deleted and the original file is untouched.
Loading is transactional: the checkpoint is fully parsed and validated against the live module before any state is modified. Validation checks include:
- Magic bytes and version match
- All parameter names are unique and exist in the live module
- All parameter shapes match exactly
- AdamW parameter count and shapes match (if present)
- All values are finite
- No trailing data after the last section
If any check fails, nn_checkpoint_load returns -1 and leaves all live state intact.
The loader also supports a legacy format with magic TLWEIGHT (no AdamW/RNG sections).
PyTorch uses Python's pickle for torch.save/torch.load, which can serialize arbitrary Python objects but has security implications. TensorLib's custom binary format is simpler, safer (no arbitrary code execution), and versioned for forward compatibility. However, it only serializes the specific data needed for training (parameters, optimizer state, RNG) — not arbitrary model objects.
| Function | Description |
|---|---|
nn_module_forward(module, input) |
Execute forward pass via virtual dispatch |
nn_module_set_training(module, training) |
Recursively set train/eval mode |
nn_module_is_training(module) |
Query current training flag |
nn_module_register_parameter(module, param) |
Register a parameter with the module |
nn_module_register_child(module, child) |
Register a sub-module (with cycle detection) |
nn_module_parameter_count(module) |
Count all parameters recursively |
nn_module_parameter_at(module, index) |
Get parameter by flat index |
nn_module_zero_grad(module) |
Recursively zero all parameter gradients |
nn_clip_grad_norm(module, max_norm, total_norm) |
Clip gradient L2 norm globally |
| Function | Description |
|---|---|
nn_activation_relu() |
ReLU activation |
nn_activation_gelu() |
GELU activation |
nn_activation_sigmoid() |
Sigmoid activation |
nn_activation_tanh() |
Tanh activation |
nn_activation_custom(name, forward, ctx) |
Caller-defined activation |
| Function | Description |
|---|---|
nn_parameter_create(name, ndim, dims, trainable, init, rng) |
Create a parameter with initialized tensor |
nn_parameter_destroy(param) |
Free a parameter and its tensor |
| Function | Description |
|---|---|
nn_rng_seed(rng, seed) |
Set the RNG state |
nn_rng_uniform(rng, min, max) |
Sample from Uniform[min, max] |
nn_rng_normal(rng, mean, stddev) |
Sample from Normal(mean, stddev) |
| Function | Description |
|---|---|
nn_linear_create(name, in, out, bias, w_init, b_init, rng) |
Create linear layer |
nn_linear_forward(layer, input) |
Forward pass: x @ W^T + b |
nn_linear_destroy(layer) |
Free linear layer |
nn_embedding_create(name, vocab, dim, init, rng) |
Create embedding layer |
nn_embedding_forward(layer, indices) |
Lookup embeddings by token ID |
nn_embedding_destroy(layer) |
Free embedding layer |
nn_positional_embedding_create(name, ctx_len, dim, init, rng) |
Create positional embedding |
nn_positional_embedding_forward(layer, token_emb) |
Add positional info to token embeddings |
nn_positional_embedding_destroy(layer) |
Free positional embedding |
nn_layer_norm_create(name, width, eps, affine) |
Create layer normalization |
nn_layer_norm_forward(layer, input) |
Normalize last dimension |
nn_layer_norm_destroy(layer) |
Free layer normalization |
nn_dropout_create(name, probability, rng) |
Create dropout layer |
nn_dropout_forward(layer, input) |
Apply dropout (train) or identity (eval) |
nn_dropout_destroy(layer) |
Free dropout layer |
nn_multihead_attention_create(name, C, heads, drop_p, rng) |
Create multi-head attention |
nn_multihead_attention_forward(attn, input) |
Causal self-attention forward |
nn_multihead_attention_destroy(attn) |
Free multi-head attention |
nn_decoder_block_create(name, C, heads, drop_p, eps, rng) |
Create pre-norm decoder block |
nn_decoder_block_forward(block, input) |
Forward through attention + FFN block |
nn_decoder_block_destroy(block) |
Free decoder block |
nn_decoder_create(name, config, rng) |
Create full decoder (GPT-style) |
nn_decoder_forward(decoder, token_ids) |
Forward pass returning logits [B,T,V] |
nn_decoder_loss(decoder, token_ids, targets) |
Forward + cross-entropy loss (scalar) |
nn_decoder_destroy(decoder) |
Free decoder |
nn_mlp_create(name, config, rng) |
Create multi-layer perceptron |
nn_mlp_forward(model, input) |
Forward through all layers + activations |
nn_mlp_destroy(model) |
Free MLP |
| Function | Description |
|---|---|
nn_softmax(logits) |
Numerically stable softmax |
nn_log_softmax(logits) |
Numerically stable log-softmax |
nn_cross_entropy(logits, targets) |
Cross-entropy loss (mean scalar) |
nn_apply_causal_mask(scores) |
Apply lower-triangular causal mask |
| Function | Description |
|---|---|
nn_sgd_create(module, lr) |
Create SGD optimizer |
nn_sgd_step(optimizer) |
Apply one SGD update step |
nn_sgd_zero_grad(optimizer) |
Zero all gradients |
nn_sgd_destroy(optimizer) |
Free SGD optimizer |
nn_adamw_default_config() |
Get default AdamW config |
nn_adamw_create(module, config) |
Create AdamW optimizer |
nn_adamw_step(optimizer) |
Apply one AdamW update step |
nn_adamw_zero_grad(optimizer) |
Zero all gradients |
nn_adamw_destroy(optimizer) |
Free AdamW optimizer |
| Function | Description |
|---|---|
nn_checkpoint_save(path, module, optimizer, rng) |
Save checkpoint (atomic) |
nn_checkpoint_load(path, module, optimizer, rng) |
Load checkpoint (transactional) |
The test suite consists of 18 test files covering every component of the NN system:
| Test File | What It Covers |
|---|---|
test_nn_rng.c |
Splitmix64 reproducibility, uniform/normal distribution, boundary checks, determinism across seeds |
test_nn_init.c |
All 6 init policies: zero, one, Xavier uniform/normal, He uniform/normal. Verifies reproducibility with same seed, divergence with different seeds, and range/bounds |
test_nn_parameter.c |
Parameter creation, name copying (ownership), trainable flag, tensor shape/strides, all init policies, NULL/empty name rejection |
test_nn_module.c |
Module creation/destroy, parent/child hierarchy, cycle detection in register_child, duplicate rejection, parameter counting, parameter access by index, recursive train/eval propagation, forward dispatch, zero_grad, destroy verification |
test_nn_linear.c |
Constructor validation, known-weight forward pass (hand-computed), biasless mode, generic module forward, dimension mismatch rejection |
test_nn_embedding.c |
Lookup correctness, gradient rejection (non-differentiable input), shape validation, generic module forward |
test_nn_positional_embedding.c |
Exact composition with manual weights, backward pass through positional params, sequence length validation, context length bounds |
test_nn_layer_norm.c |
Forward correctness (hand-computed expected values), backward gradient correctness, non-affine mode, epsilon validation, dimension mismatch |
test_nn_dropout.c |
Deterministic training (same seed → same mask), inverted dropout scaling, eval mode identity, backward gradient propagation, p=0.0 passthrough, non-training-mode passthrough |
test_nn_multihead_attention.c |
Identity QKV projection correctness, causal masking verification, multi-head correctness, dimension mismatch rejection, backward pass |
test_nn_decoder_block.c |
Residual connection verification (zero branches → identity), 6-child topology, backward gradient flow, dimension validation |
test_nn_decoder.c |
Full topology (all children registered), forward pass shape, loss computation, backward pass, train/eval mode propagation, config validation |
test_nn_mlp.c |
Layer count, child registration, forward correctness with ReLU+identity, custom activations, config validation, reproducibility |
test_nn_loss.c |
Softmax correctness (with extreme values), log-softmax correctness, cross-entropy forward/backward, target validation |
test_nn_causal_mask.c |
Mask correctness (diagonal and above), softmax after masking, backward pass, dimension validation |
test_nn_checkpoint.c |
Round-trip save/load, resume training after load, legacy format, flag-only loading (params only, no optimizer/RNG), corruption detection, dimension mismatch, duplicate name rejection, atomic failure cleanup |
test_nn_sgd.c |
Recursive update through children, bias/non-trainable skipping, zero_grad, version tracking, invalid module rejection, negative LR rejection |
test_nn_adamw.c |
Exact first-step computation (hand-verified), global gradient clipping, zero_grad, config validation, beta bounds, invalid module rejection |
TensorLib uses manual function-pointer dispatch rather than C++ virtual tables:
struct nn_module {
nn_module_forward_fn forward; // manual vtable entry
nn_module_destroy_fn destroy; // manual vtable entry
};Why:
- C99 is the target language, keeping the project free of C++ complexity and compiler requirements
- Function pointers are explicit and inspectable — no hidden vptr or compiler-injected destructors
- The "first-member embedding" pattern (
nn_linearhasnn_module baseas its first field) provides safe pointer casting withoutcontainer_ofmacros, relying on C's guarantee that a pointer to a struct can be cast to a pointer to its first member - Destroy callbacks are explicit, avoiding C++ destructor ordering surprises
The trade-off is that adding a new virtual method requires manually wiring it in every layer's _create function — there is no compiler enforcement of interface conformance.
Every nn_*_create has a matching nn_*_destroy. Modules own their children and parameters:
nn_module_destroy_baseiteratesparameters[]callingnn_parameter_destroy, then iterateschildren[]calling each child'sdestroycallback, and finally frees the arrays- This means destroying a root module recursively destroys the entire tree
Why not reference counting?
- Simplicity: explicit ownership avoids the complexity of reference cycles and atomic operations
- Performance: no atomic increment/decrement overhead per operation
- Predictability: cleanup is deterministic and happens exactly at
_destroycalls - The module tree is acyclic by construction (enforced by
nn_module_register_child)
TensorLib uses pre-norm architecture (LayerNorm before attention/FFN, not after):
// From decoder_block.c
normalized_attention = nn_layer_norm_forward(block->attention_norm, input);
attention_output = nn_multihead_attention_forward(block->attention, normalized_attention);
attention_residual = ag_add(input, attention_output); // residual before FFN
normalized_mlp = nn_layer_norm_forward(block->mlp_norm, attention_residual);
// ... FFN ...
result = ag_add(attention_residual, dropped);Why pre-norm:
- Training stability: Pre-norm transformers are significantly easier to train at depth. The residual stream provides a direct gradient path that bypasses the LayerNorm and attention layers, mitigating vanishing/exploding gradients.
- No warmup needed: Pre-norm architectures are less sensitive to learning rate warmup schedules, which is important for a from-scratch implementation without LR scheduling.
- Industry standard: GPT-2, GPT-3, LLaMA, and most modern language models use pre-norm. The post-norm variant (original Transformer) requires careful initialization and warmup.
The trade-off is that pre-norm can be slightly less performant at convergence compared to well-tuned post-norm, but the stability benefits dominate for practical training.