Skip to content

Modules and Params

StableTested and in active use. Breaking changes will be called out.

Everything in nn is a Module. The trait fixes the shape contract and the forward/backward pair; parameters are ordinary struct fields, discovered by reflection.

trait Module(Defaultable & Movable & ImplicitlyDeletable):
comptime ARITY: Int # number of inputs
comptime IN_DIMS: InlineArray[Int, Self.ARITY] # input dimensions
comptime OUT_DIM: Int # output dimension
comptime ACT_DT: DType = DT # activation-flow dtype
@staticmethod
def make[target: StaticString, INIT: Initializer](
ctx: Optional[DeviceContext] = None
) raises -> Self: ...
def forward[target, B, o, POLICY](...) raises: ...
def vjp[target, B, o, POLICY](...) raises: ...

ARITY and IN_DIMS let combinators handle modules with different input counts uniformly. Most leaves are arity 1; attention takes three.

make is the unified CPU/GPU factory. Parameters are allocated and initialized with INIT at construction — there is no separate reinitialization pass. Combinators thread [target, INIT] down to their children, which is why you specify the initializer once at the top.

ACT_DT is the activation-flow dtype, defaulting to DT (fp32). A bf16 module sets comptime ACT_DT = bfloat16; combinators derive theirs from their children and type inter-module buffers accordingly. That is the memory win in AMP.

forward and vjp operate over a borrowing TensorRefs[ARITY, o] input pack plus a single output tensor. vjp is the vector-Jacobian product — the backward pass. There is no tape; the backward pass is the composition of the modules’ vjps, resolved at compile time.

Param[NAME, APPLY_DECAY, SIZE]

A Param owns four Tensors:

TensorPurposeAllocation
valthe weightsalways
grdthe gradientalways
mAdam’s first momentlazy
vAdam’s second momentlazy

m and v stay empty until a stateful optimizer’s visit calls ensure on them at its first step. SGD never touches them, so it pays nothing.

Co-locating the moment state with the parameter — rather than keeping a flat side table inside the optimizer — has two consequences worth knowing. The optimizer stays stateless at the visitor level, and checkpointing rides the same walk, which is why exact training resume comes for free. See Checkpoints.

APPLY_DECAY marks whether weight decay applies. Biases and normalization scales set it False.

A Tensor holds a host List and a device buffer. The target parameter decides which is live: .data on CPU, .dev through a kernel on GPU. This is the reason one module source compiles for both devices — there is no separate GPU parameter type to keep in sync.

from noeira.nn.core.module import Module
from noeira.nn.core.param import Param
struct MyLayer[IN: Int, OUT: Int](Module):
comptime ARITY = 1
comptime IN_DIMS = InlineArray[Int, 1](IN)
comptime OUT_DIM = OUT
var weight: Param["weight", True, IN * OUT]
var bias: Param["bias", False, OUT]
@staticmethod
def make[target: StaticString, INIT: Initializer](
ctx: Optional[DeviceContext] = None
) raises -> Self: ...
def forward[...](...) raises: ...
def vjp[...](...) raises: ...

Declaring the Param fields is all the registration there is. The default for_each_param walks them automatically.

Param — four tensors, two of them lazyvalthe weightsgrdthe gradientmAdam's 1st momentvAdam's 2nd momentSGD never calls ensure on the moments, so it never allocates them.One traversal, three consumersThe optimizer holds no parameter table of its own. It rides the module tree.Sequentialchild 0 · Linearchild 1 · Linearwalk order0.weight0.bias1.weight1.biasoptimizerread grd, update val, keep m and vcheckpointserialize val and moments, by namenamed_paramsenumerate for inspectionOn load the checkpoint validates these names against the in-memory walk order, soarchitecture drift between save and load fails loudly instead of loading garbage.
always allocatedlazy — allocated on first stateful visit

Names are dotted paths composed from combinator child indices, so "0.weight" is the first child’s weight in a Sequential. Nothing maintains a registry: the names exist only as a consequence of the traversal order, which is why they cannot drift out of sync with the modules that produced them.

Two rules that matter if you write a leaf with a GPU backward path. Both have caused real, silent miscomputation here.

Verify a new leaf with finite differences, but note:

  • Use eps ≈ 1e-2 for deep chains — smaller steps drown in accumulated float error.
  • Never finite-difference-check on TF32. The reduced mantissa makes the comparison meaningless. Force fp32.