Compile-time composition
StableTested and in active use. Breaking changes will be called out.
This is the one idea that makes the rest of the framework make sense. If you arrive from PyTorch, the first noeira training script will look strange for exactly one reason: the network is a type, not an object.
comptime QNet = Sequential[ Linear[OBS_DIM, HIDDEN], ReLU[HIDDEN], Linear[HIDDEN, HIDDEN], ReLU[HIDDEN], Linear[HIDDEN, NUM_ACTIONS],]
var agent = DQNAgent["cpu", UniformSampleCpuStep[OBS_DIM, 1, BATCH, CAPACITY], QNet]( lr=1e-3, gamma=0.99, epsilon=1.0,)QNet is not a variable holding a model. It is a type, built at compile time,
passed to DQNAgent as a parameter in square brackets alongside the target
device and the replay-buffer block.
The two kinds of brackets
Section titled “The two kinds of brackets”Mojo separates compile-time parameters from runtime arguments, and noeira leans on that separation hard:
DQNAgent[ "cpu", SampleBlock, QNet ]( lr=1e-3, gamma=0.99 ) └─────── compile time ────────┘ └──── runtime ────┘Square brackets carry structure: the device target, network architectures, dimensions, replay-buffer capacity, environment count. Everything the compiler needs to lay out memory and pick kernels.
Parentheses carry values you might tune between runs: learning rates, discount factors, exploration schedules.
The rule of thumb: if changing it should change the generated code, it goes in brackets. If it is just a number the algorithm reads, it goes in parentheses.
Why do it this way
Section titled “Why do it this way”No runtime graph. There is nothing to construct, traverse or dispatch
through. The forward pass of QNet is compiled straight-line code — the
compiler knows every shape, so it can unroll and vectorize with no dispatch
between layers. By the time codegen runs, nothing about the composition is
dynamic.
Shapes are checked before you run. A dimension mismatch between two Linear
layers is a compile error, not a shape error 40 minutes into training.
Zero-cost generics. SACAgent is written once against the Module trait and
specialized per instantiation. There is no virtual dispatch in the inner loop and
no abstraction penalty for the layering.
The whole pipeline specializes together. Because the environment is also a compile-time parameter, the compiler sees the physics kernels, the network and the optimizer as one unit and can specialize the lot for the target device.
Dimensions come from configs
Section titled “Dimensions come from configs”Hard-coding 17 and 6 for HalfCheetah’s observation and action dimensions
would be fragile. Every environment publishes a config struct instead:
comptime OBS_DIM = HalfCheetahConfig.OBS_DIM # 17comptime ACT_DIM = HalfCheetahConfig.ACTION_DIM # 6These are compile-time constants, so they flow into network definitions directly:
comptime CriticNet = Sequential[ LinearReLU[OBS_DIM + ACT_DIM, HIDDEN], LinearReLU[HIDDEN, HIDDEN], Linear[HIDDEN, 1],]Switch HalfCheetahConfig to HumanoidConfig and the same lines produce a
network for a 376-dimensional observation and 17 actions. Nothing else changes.
Composition without a class hierarchy
Section titled “Composition without a class hierarchy”Combinators are types that take types:
from noeira.nn.combinators.sequential import Sequentialfrom noeira.nn.combinators.residual import Residualfrom noeira.nn.combinators.repeat import Repeat
comptime Block = Residual[Sequential[Linear[D, D], ReLU[D], Linear[D, D]]]comptime Trunk = Repeat[6, Block] # six residual blocksRepeat[6, Block] chains six independent copies of Block, each with its own
parameters. The chain is walked by a compile-time loop over the stages, so
there is no dispatch over layer types at run time.
Because Module is a trait, anything satisfying it composes with everything
else. A StochasticActor wrapping two LinearReLUs is a Module; so is the
whole thing; so it can be handed to any agent that wants an actor.
Fusion is a naming convention
Section titled “Fusion is a naming convention”Two adjacent primitives that could fuse have a fused counterpart, and choosing it is how you opt in:
Linear[D, H], ReLU[H] # two modules, two kernelsLinearReLU[D, H] # one module, one fused kernelLinearReLU is what the GPU examples use throughout. It matters most in the
inner loop of an off-policy update, where the same small MLP is evaluated
millions of times.
What you give up
Section titled “What you give up”Honesty about the trade-off, since it is real.
Compile times. Deep parameterization means the compiler does more work, and large agent instantiations can take a while to build. Long compile times are a known cost of this design, and the codebase carries workarounds (extracting helper functions) where instantiation depth has caused compiler blowups.
No runtime architecture changes. You cannot build a network from a config file read at startup, or grow a layer mid-training. Architecture search means recompiling per candidate.
Error messages. A mistake deep inside a nested generic instantiation can produce a long, structural error. Reading it means reading from the innermost frame outward — the leaf is where the real mismatch lives.
For a framework whose point is that the whole loop compiles into one pipeline, these are the right trade-offs. They would be the wrong ones for a library optimising for interactive experimentation.
- Traits —
Module,EnvandAgent, the interfaces being composed - CPU and GPU paths — how the
"cpu"/"gpu"target parameter works - Quickstart: GPU training — the idiom in a complete script