Module Architecture Guide¶
This guide documents the internal module structure of mobius,
covering model registration, decoder layer extension, weight loading security,
and backward compatibility.
Module Overview¶
The original _exporter.py monolith (1,188 lines) has been split into five
focused modules:
Module |
Responsibility |
|---|---|
|
Model registration — maps HuggingFace |
|
Resolves HuggingFace |
|
Downloads and applies safetensors weights to ONNX IR models |
|
Core build API — |
|
Builds ONNX models from diffusers pipelines (Flux, SD3, VAEs) |
Dependency graph¶
_builder.py ← build(), build_from_module()
├── _registry.py
├── _config_resolver.py
└── _weight_loading.py
_diffusers_builder.py
├── _builder.py (build_from_module, resolve_dtype)
└── _weight_loading.py
1. Registering a New Model Architecture¶
The ModelRegistry in _registry.py maps HuggingFace config.model_type
strings to the nn.Module subclass used to build the ONNX graph. The global
singleton registry is pre-populated with ~190 built-in architectures.
Quick registration¶
If your model follows the standard Llama-like decoder pattern (GQA + RoPE +
pre-norm), you can register it against the existing CausalLMModel:
from mobius._registry import registry
registry.register("my_new_arch", CausalLMModel)
Full registration with task and config¶
from mobius._registry import registry, ModelRegistration
registry.register(
"my_new_arch",
MyCustomModule,
task="text-generation", # auto-detected default task
config_class=MyCustomConfig, # HF config parser (optional)
)
Parameters:
Parameter |
Type |
Description |
|---|---|---|
|
|
Must match the HuggingFace |
|
|
Your model class — must accept an |
|
|
Default task name (e.g. |
|
|
Config parser for the HF config. Falls back to |
Lookup API¶
# Get module class (raises KeyError if not found)
cls = registry.get("llama")
# Get full registration entry
reg = registry.get_registration("llama")
print(reg.module_class, reg.task, reg.config_class)
# Check existence
if "my_arch" in registry:
...
# List all registered architectures
print(registry.architectures())
Testing with a custom registry¶
The registry is a plain class instance — you can create isolated registries for testing without affecting the global singleton:
from mobius._registry import ModelRegistry
test_registry = ModelRegistry()
test_registry.register("test_arch", MyTestModule)
assert "test_arch" in test_registry
assert len(test_registry) == 1
Deprecated: MODEL_MAP¶
The MODEL_MAP dict is a backward-compatible alias that exposes the
registry’s internal _map dict. Use registry.get() /
registry.register() instead. MODEL_MAP will be removed in a future
version.
2. Adding a New Decoder Layer Variant¶
Decoder layers define the per-layer transformer computation (norm → attention
→ residual → norm → MLP → residual). The base implementation lives in
components/_decoder.py.
Existing variants¶
Class |
Location |
Key difference |
|---|---|---|
|
|
Standard pre-norm (Llama-style) |
|
|
Post-norm (OLMo-2 style) |
|
|
Uses |
|
|
Adds sliding window + post-attn norm |
|
|
Adds |
|
|
Hybrid GatedDeltaNet / full-attention dispatch via |
|
|
Multi-head Latent Attention (MLA) — structurally different |
Step-by-step: create a new decoder layer¶
Step 1. Create your decoder layer class as an nn.Module. Follow the
same forward() signature as DecoderLayer:
# In models/my_arch.py
from onnxscript import nn
from onnxscript import OpBuilder
from mobius._configs import ArchitectureConfig
from mobius.components import Attention, MLP, RMSNorm
class MyDecoderLayer(nn.Module):
"""Custom decoder layer with <describe your variant>."""
def __init__(self, config: ArchitectureConfig):
super().__init__()
self.self_attn = Attention(config)
self.mlp = MLP(config)
self.input_layernorm = RMSNorm(config.hidden_size, eps=config.rms_norm_eps)
self.post_attention_layernorm = RMSNorm(
config.hidden_size, eps=config.rms_norm_eps
)
def forward(
self,
op: OpBuilder,
hidden_states,
attention_bias,
position_embeddings: tuple,
past_key_value: tuple | None,
):
residual = hidden_states
hidden_states = self.input_layernorm(op, hidden_states)
attn_output, present_key_value = self.self_attn(
op,
hidden_states=hidden_states,
attention_bias=attention_bias,
position_embeddings=position_embeddings,
past_key_value=past_key_value,
)
# Example: add a residual multiplier
hidden_states = op.Add(residual, op.Mul(attn_output, self.multiplier))
residual = hidden_states
hidden_states = self.post_attention_layernorm(op, hidden_states)
hidden_states = self.mlp(op, hidden_states)
hidden_states = op.Add(residual, hidden_states)
return hidden_states, present_key_value
Step 2. Create your model class that uses the custom decoder layer.
Typically you subclass or follow the pattern of CausalLMModel /
TextModel:
from mobius.models.base import CausalLMModel, TextModel
class MyTextModel(TextModel):
"""Override to use custom decoder layers."""
def __init__(self, config):
super().__init__(config)
# Replace the default DecoderLayer with yours
self.layers = nn.ModuleList(
[MyDecoderLayer(config) for _ in range(config.num_hidden_layers)]
)
class MyCausalLMModel(CausalLMModel):
def __init__(self, config):
super().__init__(config)
self.model = MyTextModel(config)
Step 3. Register your architecture:
from mobius._registry import registry
registry.register("my_arch", MyCausalLMModel)
Important: forward() signature contract¶
All decoder layers must use the explicit typed signature:
def forward(
self,
op: OpBuilder,
hidden_states, # [batch, seq_len, hidden_size]
attention_bias, # attention mask/bias tensor
position_embeddings: tuple, # (cos, sin) from RoPE
past_key_value: tuple | None, # (key, value) cache or None
) -> tuple: # (hidden_states, present_key_value)
Do not use *args or **kwargs. The explicit signature enables
static analysis and makes the data flow traceable through the ONNX graph.
Edge case: OffsetRMSNorm (+1.0)¶
Gemma-family models use OffsetRMSNorm which adds 1.0 to the weight before
normalization (weight + 1.0). If your architecture stores norm weights with
this offset convention, use OffsetRMSNorm instead of RMSNorm. Keep the
norm behavior inside the norm class — do not add isinstance checks in
the decoder layer.
3. Weight Loading Security Policy¶
Policy: safetensors only — no torch.load¶
All weight loading in _weight_loading.py uses the
safetensors format exclusively.
torch.load()and pickle deserialization are prohibited. Pickle files can execute arbitrary code when loaded. Usingtorch.load()— even withweights_only=True— is not permitted anywhere in the weight loading path.
This policy is enforced by:
Code-level comment at the top of
_weight_loading.py:# SECURITY: Do NOT use torch.load() or pickle deserialization anywhere # in this module. Only safetensors is permitted for weight loading to # prevent arbitrary code execution from untrusted weight files.
Implementation —
_download_weights()callssafetensors.torch.load_file(), nevertorch.load().Regression tests — security tests verify that the weight loading path does not call
torch.load.
How weight loading works¶
_download_weights(model_id)
│
├── Try model.safetensors.index.json (sharded checkpoint)
│ └── Download all shards in parallel via _parallel_download()
│
└── Fall back to model.safetensors (single file)
└── safetensors.torch.load_file()
↓
state_dict: dict[str, torch.Tensor]
↓
apply_weights(model, state_dict)
│
└── For each weight in state_dict:
├── Skip if not in model.graph.initializers
├── If dtype mismatch → wrap in ir.LazyTensor (lazy cast)
└── Assign to initializer.const_value
For diffusers components¶
_diffusers_builder.py uses _download_diffusers_component_weights() which
follows the same safetensors-only pattern. It looks for:
{component}/diffusion_pytorch_model.safetensors(.index.json){component}/model.safetensors(.index.json)
Key implementation details¶
Lazy casting: When the weight dtype doesn’t match the model’s declared type,
ir.LazyTensordefers the cast to serialization time, avoiding eager memory allocation.Parallel downloads: Sharded checkpoints are downloaded using a thread pool (
max_workers=8) for faster loading.No temp files: Weights are loaded directly from the HuggingFace cache — no temporary copies are created.
4. Import Conventions¶
All code imports directly from the specific module that owns the symbol:
from mobius._builder import build, build_from_module
from mobius._registry import registry
from mobius._weight_loading import apply_weights
from mobius._diffusers_builder import build_diffusers_pipeline
Module → symbols reference¶
Source module |
Symbols |
|---|---|
|
|
|
|
|
|
|
|
|
|
Quick Reference: Common Tasks¶
Build a model from HuggingFace¶
from mobius._builder import build
pkg = build("meta-llama/Llama-3-8B")
pkg.save("/output/llama/")
Build from a custom module¶
from mobius._builder import build_from_module
module = MyCausalLMModel(config)
pkg = build_from_module(module, config, task="text-generation")
Register and build a custom architecture¶
from mobius._registry import registry
from mobius._builder import build
registry.register("my_arch", MyCausalLMModel)
pkg = build("my-org/my-model") # auto-detects "my_arch" from config.json
Apply weights separately¶
from mobius._builder import build
from mobius._weight_loading import apply_weights, _download_weights
pkg = build("meta-llama/Llama-3-8B", load_weights=False)
state_dict = _download_weights("meta-llama/Llama-3-8B")
for model in pkg.values():
apply_weights(model, state_dict)