rml / Neural network layers

Neural network layers

rml/nn is the training-side layer library: a Module base that owns parameters and children, and the layers built on it, from Linear to a pre-norm Transformer block. Every layer is a few autograd ops, so its gradient comes from Autograd and is checked numerically.

On this page
  1. Module
  2. UnaryModule, Sequential and Lambda
  3. Linear and activations
  4. Initialization
  5. Convolution and pooling
  6. Embedding
  7. Normalization
  8. Dropout
  9. Recurrent layers
  10. Attention
  11. TransformerBlock
  12. Who uses it
  13. Tests

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).

ItemType/SignatureMeaningSource
NamedParametersvector<pair<string, Variable>>() constEvery 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, NumParametersvector<Variable>(), int64_t()The same list without names, and the total element count.module.cc L22–32
NamedBuffersvector<pair<string, Tensor>>() constNon-trainable state that belongs in a checkpoint (running statistics, fixed tables), named the same way.module.h L39
Train, Eval, is_trainingvoid(bool = true)Sets the flag on this module and every descendant; Dropout reads it.module.cc L40–43
ZeroGradvoid()Clears every parameter's gradient.L45–47
RegisterParameterVariable(string, Tensor)Wraps the tensor in a Variable with requires_grad; a name registered twice is an RML_CHECK failure.L49–56
RegisterBufferTensor(string, Tensor)Records a tensor that is saved but not trained.L58–61
RegisterModuleshared_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
CopyParametersvoid(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.

ItemType/SignatureMeaningSource
ComputeFansFans(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
Gainfloat(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, XavierNormalTensor(const Shape&, Generator&, float gain = 1)Glorot and Bengio (2010): std = gain · √(2 / (fan_in + fan_out)).L46–63
KaimingUniform, KaimingNormalTensor(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).

Conv2d as patch rows times filters An input of shape N, C, H, W is turned by PatchRows into a matrix with one row per output position of every image, N times OH times OW rows, and C times k times k columns. The weight of shape O, C, k, k is reshaped to O by C k k and transposed. Their product is N OH OW by O, which gets the bias and is reshaped and transposed to N, O, OH, OW. The backward pass of PatchRows adds each row entry back to the pixel it read. x [N, C, H, W] PatchRows patch rows [N·OH·OW, C·k·k] one row per output position, 0 for padding × weight [O, C, k, k] reshape, transpose filters [C·k·k, O] = y [N·OH·OW, O] + bias [O] [N, O, OH, OW] reshape, transpose backward: each row entry is added back to the pixel it read (overlaps sum)
One matrix product for the whole batch instead of one per image (conv.cc L248–256). The matrix product is the GEMM on Linear algebra.
ItemType/SignatureMeaningSource
Conv2dOptionsstride = 1, padding = 0, bias = truePadding 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
UnfoldVariable(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

ItemType/SignatureMeaningSource
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.

ItemType/SignatureMeaningSource
RnnCellForward(x [B, in], h [B, H]) → h′weight_ih [H, in], weight_hh [H, H], bias [H].recurrent.cc L70–81
LstmCellForward(x, LstmState{h, c}) → LstmStateWeights [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, LstmForward(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

ItemType/SignatureMeaningSource
CausalMaskTensor(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
ScaledDotProductAttentionVariable(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
MultiheadAttentionOptionscausal = true, bias = truePassed 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).

TransformerBlock's forward pass and parameter names x enters a residual stream. The first branch applies ln1, then attn, which is attn.qkv, scaled dot-product attention per head and attn.out, and adds the result back to give h. The second branch applies ln2, then fc, GELU and proj, and adds the result back to h to give the output. Each box is labelled with the parameter prefix it registers. residual stream sublayers (registered names) x [B, T, E] ln1: LayerNorm(E) attn: qkv → heads → out Linear(E, 3E), causal attention, Linear(E, E) + h ln2: LayerNorm(E) fc → GELU → proj Linear(E, 4E), Linear(4E, E) + y [B, T, E] E = 8, 2 heads: 872 parameters
x + attn(ln1(x)), then h + proj(GELU(fc(ln2(h)))). The names in each box are the prefixes 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.

FileTestsWhat they pin down
module_test.cc7Dotted 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.cc5x Wᵀ + b; initial weights within 1/√fan_in; leading batch axes; gradients; a wrong input width dies.
activation_test.cc2Modules match their ops and compose in Sequential.
init_test.cc5Fans and gains; sample variances of all four initializers within 2% of their formulas; uniform draws inside their bound; same seed, same weights.
conv_test.cc8Conv 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.cc4Row lookup; repeated ids accumulate gradient; numerical gradient; an out-of-range id dies.
normalization_test.cc5Layer-normed rows have zero mean and unit variance; affine and plain forms; RMS division; numerical gradients of both; wrong width dies.
dropout_test.cc5p = 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.cc8RNN 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.cc10The 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.cc3Parameter names and count; shape kept and causality; gradients of input and parameters.