In short: a module registers its parameters, buffers and child modules in its constructor, which gives every
parameter a dotted path such as attn.qkv.weight; optimizers iterate that list and checkpoints
are keyed by it. UnaryModule is the one-input, one-output shape that Sequential
chains. The layers are Linear, five activation modules, Xavier and Kaiming initializers,
Conv2d by patch rows and one matrix product, MaxPool2d, Flatten,
Embedding, LayerNorm and RMSNorm, Dropout, RNN and LSTM
cells and layers, scaled dot-product and multi-head attention, and TransformerBlock. None of it
is on the inference path: the graph executor and the LLM decoder have their own kernels.
Each header links its concept page on /ml-models: neurons and layers, activation functions, initialization, CNNs, embeddings, regularization, layer norm and residuals, RNNs and LSTMs, attention and the Transformer. The losses and optimizers that train these layers are on Losses and optimizers.
Module
Module is non-copyable and holds three ordered
lists: parameters, buffers and named children, plus a training_ flag that starts true. A
subclass fills them in its constructor through the protected Register* calls
(module.h L4–10).
| Item | Type/Signature | Meaning | Source |
|---|---|---|---|
NamedParameters | vector<pair<string, Variable>>() const | Every parameter of the module and its descendants, depth first in registration order, named by dotted path. The Variables share state with the module's, so an optimizer updating them updates the module. | module.h L32 |
Parameters, NumParameters | vector<Variable>(), int64_t() | The same list without names, and the total element count. | module.cc L22–32 |
NamedBuffers | vector<pair<string, Tensor>>() const | Non-trainable state that belongs in a checkpoint (running statistics, fixed tables), named the same way. | module.h L39 |
Train, Eval, is_training | void(bool = true) | Sets the flag on this module and every descendant; Dropout reads it. | module.cc L40–43 |
ZeroGrad | void() | Clears every parameter's gradient. | L45–47 |
RegisterParameter | Variable(string, Tensor) | Wraps the tensor in a Variable with requires_grad; a name registered twice is an RML_CHECK failure. | L49–56 |
RegisterBuffer | Tensor(string, Tensor) | Records a tensor that is saved but not trained. | L58–61 |
RegisterModule | shared_ptr<M>(string, shared_ptr<M>) | Adds a named child and returns it, so a constructor can write fc_ = RegisterModule("fc", ...). | module.h L56–60 |
CopyParameters | void(const Module& from, Module& to) | Copies every parameter and buffer value into a module with the same names in the same order; any mismatch in count or name is an RML_CHECK failure. Values are copied, not storage. | module.cc L75–92 |
Names come from CollectParameters, which emits this module's own parameters with the prefix,
then recurses into each child with prefix + name + "."
(L63–73). That order is what CopyParameters
compares, and what checkpoints are keyed by:
SaveCheckpoint and LoadCheckpoint take an nn::Module
(rml/io/checkpoint.h L23–31).
UnaryModule, Sequential and Lambda
UnaryModule adds a pure virtual Forward(const Variable&) and an
operator() that calls it (module.h L78–83).
Sequential applies its children in order and names them "0", "1", …
as they are appended, so its parameters read 0.weight, 2.bias; appending a null
layer fails a check (L85–99,
module.cc L94–107). Lambda wraps a
parameter-free function, such as a reshape, as a module (L101–109).
Layers that take something other than one Variable (Embedding's ids, the recurrent
cells' state) derive from Module directly.
Linear and activations
Linear(in_features, out_features, gen, {bias})
holds W as [out, in] and computes y = x Wᵀ + b over the last axis, so
any leading batch axes pass through (linear.cc L29–35). Both
W and b start uniform on [−1/√in, 1/√in]
(L16–27), which the header notes gives each output a
variance of about Var(x)/3 whatever the width (linear.h
L21–23). An input whose last axis is not in_features fails a check.
Activation<kFn> is a template over
a function pointer, so each activation module is one alias: Relu, Tanh,
Sigmoid, Gelu and Silu forward to the autograd op of the same name,
where the derivatives are written (Autograd).
Initialization
init.h states the goal: keep the variance of activations
forward and of gradients backward roughly constant from layer to layer. Weights follow the
[out, in, …] layout, and the receptive field is the product of the trailing axes.
| Item | Type/Signature | Meaning | Source |
|---|---|---|---|
ComputeFans | Fans(const Shape&) | fan_in = shape[1] × receptive field, fan_out = shape[0] × receptive field; a rank-1 shape gives both as its length. | init.cc L15–21 |
Gain | float(Nonlinearity, float negative_slope = 0.01) | 1 for kLinear and kSigmoid, 5/3 for kTanh, √2 for kRelu, √(2 / (1 + slope²)) for kLeakyRelu. | L23–36 |
XavierUniform, XavierNormal | Tensor(const Shape&, Generator&, float gain = 1) | Glorot and Bengio (2010): std = gain · √(2 / (fan_in + fan_out)). | L46–63 |
KaimingUniform, KaimingNormal | Tensor(const Shape&, Generator&, Nonlinearity = kRelu) | He et al. (2015): std = Gain(nonlinearity) / √fan_in. A leaky ReLU gets the default slope of 0.01. | L51–71 |
The uniform forms draw from [−√3·std, √3·std], since a uniform on [−a, a] has variance a²/3
(L40–44). All four draw from the counter-based generator on
Random numbers, so the same seed gives the same weights. The layers themselves do not
call these: Linear, Conv2d and the recurrent layers use their own uniform bound,
and the initializers are there for code that wants a specific scheme.
Worked example. A [128, 256] weight: fan_in = 256, fan_out = 128. Kaiming with
ReLU gives std = √2 / 16 ≈ 0.0884; Xavier gives √(2 / 384) ≈ 0.0722; Linear(256, 128)'s own
bound is 1/16 = 0.0625, a standard deviation of 0.0625 / √3 ≈ 0.0361.
Convolution and pooling
The header describes convolution as unfold (im2col) followed by one matrix product
(conv.h L8–12). The code goes one step further.
Conv2dFunctional(x, weight, bias, stride, padding) takes x [N, C, H, W] and a square
weight [O, C, k, k] and builds the patches as rows, [N·OH·OW, C·k·k], with a
private PatchRowsFunction, written sequentially so it needs neither a zero fill nor a transposed
copy (conv.cc L109–176). One product with the reshaped,
transposed filters covers the whole batch, the bias is added per output channel, and a reshape and transpose
return [N, O, OH, OW] (L239–263). Each output
side is (H + 2·padding − k) / stride + 1 (L29–41).
| Item | Type/Signature | Meaning | Source |
|---|---|---|---|
Conv2dOptions | stride = 1, padding = 0, bias = true | Padding is zeros on every side. | conv.h L22 |
Conv2d | (in_channels, out_channels, kernel, gen, options) | Weight and bias uniform on [−1/√fan_in, 1/√fan_in] with fan_in = in·k·k. | conv.cc L270–286 |
Unfold | Variable(x, kernel, stride = 1, padding = 0) | The public im2col: [N, C, H, W] to [N, C·k·k, OH·OW], row c·k·k + i·k + j holding channel c at offset (i, j). Its backward adds columns back where patches overlap. Not used by Conv2d itself. | L66–107 |
MaxPool2dFunctional, MaxPool2d | (x, kernel, stride = 0) | The maximum of each window, stride defaulting to the kernel, no padding. Forward stores the flat index of the first maximum in each window; backward adds the gradient there. | L178–231 |
Flatten | [N, …] → [N, rest] | Between the convolutional and fully connected layers. | L288–291 |
Worked example. A 28 × 28 image through Conv2d(1, 8, 3, gen, {.stride = 2, .padding =
1}): each side is (28 + 2 − 3) / 2 + 1 = 14, so the patch rows are [N·196, 9], the
filters [9, 8], and the result [N, 8, 14, 14].
Embedding
Embedding(num_embeddings, embedding_dim, gen, stddev =
1) is a [num, dim] table initialized N(0, stddev²). Forward takes an i32
ids tensor of any shape and returns that shape with dim appended, by
autograd::IndexSelect on axis 0 (embedding.cc
L16–25). The header's point: it equals a Linear on one-hot inputs without making the
one-hots, gathering rows forward and scatter-adding into them backward, so a repeated id accumulates its
gradient (embedding.h L4–7).
Normalization
| Item | Type/Signature | Meaning | Source |
|---|---|---|---|
LayerNorm | (dim, eps = 1e-5, affine = true) | (x − mean) / √(var + eps) · weight + bias over the last axis, with the biased variance (divide by n). Weight starts at 1 and bias at 0; without affine neither exists. | normalization.cc L23–37 |
RMSNorm | (dim, eps = 1e-6) | x / √(mean(x²) + eps) · weight: no centring and no bias, as Llama-family models use. | L39–47 |
Both are written as autograd ops (Mean, Square, Pow(·, −0.5)), so their
gradients come for free and are checked numerically. The graph executor's layer norm is a separate fast kernel
(Lowering and fast CPU).
Dropout
Dropout(p, seed = 0) requires p in [0, 1). In
training mode it draws a Bernoulli(1 − p) keep mask from its own generator and multiplies by
mask / (1 − p), so the expected activation is unchanged and evaluation needs no rescaling; in eval mode, or
when p is 0, it returns x unchanged (dropout.cc L15–23).
Owning its generator makes a run reproducible from the seed alone.
Recurrent layers
The header gives the equations and the reason for the LSTM (recurrent.h L9–18): an RNN's h′ = tanh(x Wihᵀ + h Whhᵀ + b) multiplies one Jacobian per step on the way back, so distant gradients shrink or explode; an LSTM's cell state changes additively, c′ = σ(f)·c + σ(i)·tanh(g), h′ = σ(o)·tanh(c′), so while the forget gate stays near 1 the gradient through c passes almost unchanged.
| Item | Type/Signature | Meaning | Source |
|---|---|---|---|
RnnCell | Forward(x [B, in], h [B, H]) → h′ | weight_ih [H, in], weight_hh [H, H], bias [H]. | recurrent.cc L70–81 |
LstmCell | Forward(x, LstmState{h, c}) → LstmState | Weights [4H, in] and [4H, H], bias [4H]; rows [0, H), [H, 2H), [2H, 3H), [3H, 4H) are the input, forget, candidate and output gates. One affine map makes all four, then four slices. | L93–105 |
Rnn, Lstm | Forward(x [B, T, in]) → [B, T, H] | Unroll the cell over time, selecting step t, and concatenate every step's h. | L57–66 |
Stateful Forward | (x, Variable& h), (x, LstmState&) | Starts from the given state (zeros where undefined) and leaves the final state in it, so a long sequence can be fed in pieces. | L115–140 |
Every weight and bias starts uniform on [−1/√H, 1/√H] (L26–31), except that the LSTM's forget-gate bias, rows [H, 2H), starts at 1, so the cell begins by keeping its memory (Jozefowicz et al. 2015; L83–91). The same cell, exported from PyTorch, is the graph that runtime fusion handles on Lowering and fast CPU.
Attention
| Item | Type/Signature | Meaning | Source |
|---|---|---|---|
CausalMask | Tensor(int64_t tq, int64_t tk) | [tq, tk] additive mask, 0 where query i may see key j and −∞ elsewhere. Queries align to the end of the keys: query i sits at position tk − tq + i, so a cached prefix works. Needs tk ≥ tq. | attention.cc L19–28 |
ScaledDotProductAttention | Variable(q, k, v, bool causal) | softmax(q kᵀ / √d + mask) v over the last two axes, leading axes broadcasting. Dividing by √d keeps the logits' variance near 1 so the softmax does not saturate as d grows. | L30–41 |
MultiheadAttentionOptions | causal = true, bias = true | Passed to both projections. | attention.h L35 |
MultiheadAttention | (embed_dim, num_heads, gen, options); Forward([B, T, E]) | One Linear(E, 3E) named qkv makes q, k and v for every head; each is sliced, reshaped to [B, T, heads, E/heads] and transposed to [B, heads, T, E/heads]; the heads attend, merge back to [B, T, E] and pass through Linear(E, E) named out. num_heads must divide E. | L43–74 |
Worked example. CausalMask(2, 4), two new queries after a cached prefix of two
keys: query 0 sits at position 2 and sees keys 0–2, query 1 at position 3 sees all four.
[[0, 0, 0, -inf],
[0, 0, 0, 0]]
The header calls this "the readable training version" and says the inference engine's fused kernel in
rml/backend is tested against it (attention.h
L9–10). At this commit rml/backend does not exist, so that is planned; the LLM
decoder's attention on The decoder is its own code and includes nothing from
rml/nn.
TransformerBlock
TransformerBlock(embed_dim, num_heads, gen,
{mlp_ratio = 4, causal = true}) is the pre-norm block of GPT-2 and its successors
(transformer.h L4–9): normalize before each sublayer and
add its output back, which leaves an unnormalized residual path from input to output. The whole forward
pass is two lines (transformer.cc L29–32).
NamedParameters reports, in this order.
Worked example. TransformerBlock(8, 2, gen) reports twelve parameters in
registration order: ln1.weight, ln1.bias, attn.qkv.weight,
attn.qkv.bias, attn.out.weight, attn.out.bias, ln2.weight,
ln2.bias, fc.weight, fc.bias, proj.weight,
proj.bias. With d = 8 the count is 4d for the two norms, 4d² + 4d for attention and 8d² + 5d for the
MLP: 32 + 288 + 552 = 872 (transformer_test.cc
L19–31).
Who uses it
Inside the library, only rml/io/checkpoint
depends on rml/nn. The rest of its users are the training demos in examples/:
mlp, cnn, rnn, ssm, encoder,
char_gpt, vit, vae, gan, diffusion,
diffusion_unet, flow_matching, gnn, two_tower,
multimodal, rag, distillation, finetune and
dpo (Examples). The inference stack does not use it: exported models
run on the graph executor's kernels (Executing a graph).
Tests
Every layer with parameters gets a numerical gradient check of its input and parameters through GradCheck, and forward results are compared with the definition written out in the test.
| File | Tests | What they pin down |
|---|---|---|
module_test.cc | 7 | Dotted names in registration order, parameter and buffer counts; parameters share state with the module; CopyParameters copies values, not storage, and dies on mismatched modules; train and eval reach every child; Sequential names by index; a duplicate parameter name dies. |
linear_test.cc | 5 | x Wᵀ + b; initial weights within 1/√fan_in; leading batch axes; gradients; a wrong input width dies. |
activation_test.cc | 2 | Modules match their ops and compose in Sequential. |
init_test.cc | 5 | Fans and gains; sample variances of all four initializers within 2% of their formulas; uniform draws inside their bound; same seed, same weights. |
conv_test.cc | 8 | Conv against a direct six-loop reference within 1e-5 and its gradients, over six (kernel, stride, padding) geometries from (1, 1, 0) to (5, 1, 2); Unfold columns and their overlapping gradient; max pool values and gradient to the maximum; Flatten; bad shapes die. |
embedding_test.cc | 4 | Row lookup; repeated ids accumulate gradient; numerical gradient; an out-of-range id dies. |
normalization_test.cc | 5 | Layer-normed rows have zero mean and unit variance; affine and plain forms; RMS division; numerical gradients of both; wrong width dies. |
dropout_test.cc | 5 | p = 0.25 on 10,000 ones zeroes 2,500 ± 200 and scales survivors to 1/0.75; identity in eval mode; the gradient follows the mask; same seed, same mask; p = 1 dies. |
recurrent_test.cc | 8 | RNN and LSTM cells against their equations; the forget bias starts at 1; gradients through time; state carried across chunks equals one long sequence; bad shapes die. |
attention_test.cc | 10 | The causal mask, including a cached prefix; attention against the written formula within 1e-5; the first causal query sees only the first value; gradients; multi-head shape and parameter count; causal output ignores later tokens and bidirectional output sees them; a width the heads don't divide dies. |
transformer_test.cc | 3 | Parameter names and count; shape kept and causality; gradients of input and parameters. |