# SPDX-License-Identifier: Apache-2.0
"""Bounded 8→16→3 FP32 training composition; host code only packs/validates.
All forward, backward, loss and update arithmetic executes in the existing
IDENTICAL GPU GEMM/training bindings. This is a small fixed architecture,
not autograd, a general neural-network trainer, or a Llama checkpoint format.
Composed cross-vendor identity requires its own retained qualification.
NUMPY-FREE (numpy-free-0.7, DEVIATIONS 2423-2427). Inputs are read through
the buffer protocol (`_buffer.view`): a NumPy array, an `array.array`, a
`mojolearn.Array`. Every array this class hands back -- logits, gradients,
the state snapshot -- is a `mojolearn.Array` (`numpy.asarray` on it is
zero-copy). THE CHECKPOINT BYTES ARE UNCHANGED: the JSON/hex envelope,
its canonical serialization and its SHA-256 are produced from the same
little-endian `<f4` / `<i4` bytes the NumPy spelling emitted
(`_bufcheck.le_bytes`), so a file written by 0.6.x loads here and a file
written here loads there, byte for byte (see `_encode_array`).
"""
import hashlib
import json
from . import _portable_math as math
import operator
import os
from pathlib import Path
import struct
import tempfile
import threading
from . import _backend, _linalg_impl, _training_impl
from ._array import Array
from ._buffer import (
addr, addr_ro, all_finite, as_f32_c, as_i32_c, empty, frombytes, zeros,
)
from ._bufcheck import (
flat_view, is_int32, is_integer, is_native_f32, le_bytes, probe,
)
_NAMES = ('weight1', 'bias1', 'weight2', 'bias2')
_SHAPES = ((16, 8), (16,), (3, 16), (3,))
_TOTAL = 195
_MAX_STEP = (1 << 31) - 1
_STATE_SCHEMA = 'mojolearn.small-mlp-trainer.v1'
#: `mlp_train_step`'s modes (mirrored in `training/mlp_ops.mojo`).
_MODE_FORWARD, _MODE_GRADS, _MODE_TRAIN = 0, 1, 2
_FILE_SCHEMA = 'mojolearn.small-mlp-checkpoint.v1'
_FILE_LIMIT = 32768
#: `numpy.finfo(numpy.float32).max`, as a Python float.
_F32_MAX = 3.4028234663852886e+38
def _canonical(value):
return json.dumps(value, sort_keys=True, separators=(',', ':'),
ensure_ascii=True, allow_nan=False).encode('ascii')
def _schedule(value):
if not isinstance(value, dict) or not value:
raise ValueError('SmallMLPTrainer data_schedule must be a nonempty JSON object')
remaining = [128]
def visit(item, depth=0):
remaining[0] -= 1
if depth > 8 or remaining[0] < 0:
raise ValueError('SmallMLPTrainer data_schedule is too complex')
if type(item) is dict:
if any(type(key) is not str for key in item): # glue: validates schedule config keys
raise ValueError('SmallMLPTrainer data_schedule keys must be strings')
if len(item) > 128 or any(len(key) > 2048 for key in item): # glue: validates schedule config keys
raise ValueError('SmallMLPTrainer data_schedule object is too large')
for child in item.values(): # glue: walks schedule config tree
visit(child, depth + 1)
elif type(item) is list:
if len(item) > 128:
raise ValueError('SmallMLPTrainer data_schedule list is too large')
for child in item: # glue: walks schedule config tree
visit(child, depth + 1)
elif type(item) is str and len(item) > 2048:
raise ValueError('SmallMLPTrainer data_schedule string is too large')
elif type(item) is int and item.bit_length() > 4096:
raise ValueError('SmallMLPTrainer data_schedule integer is too large')
elif item is not None and type(item) not in (str, int, float, bool):
raise ValueError('SmallMLPTrainer data_schedule must contain JSON values')
visit(value)
try:
encoded = _canonical(value)
except (ValueError, OverflowError) as exc:
raise ValueError('SmallMLPTrainer data_schedule must be finite JSON') from exc
if len(encoded) > 2048:
raise ValueError('SmallMLPTrainer data_schedule exceeds 2048 bytes')
return json.loads(encoded)
def _is_bool(value):
# `bool` and NumPy's `bool_` (which is not a `bool` subclass), judged by
# name so that numpy need not be importable here.
return isinstance(value, bool) or type(value).__name__ == 'bool_'
def _round_f32(value):
# DEVIATION 2423: `float(np.float32(value))` is one round-to-nearest-even
# to binary32 and back, which is exactly what `struct` does.
return struct.unpack('<f', struct.pack('<f', value))[0]
def _scalar(value, name):
if _is_bool(value):
raise ValueError('SmallMLPTrainer ' + name + ' must be a finite float32 scalar')
try:
value = float(value)
except (TypeError, ValueError, OverflowError) as exc:
raise ValueError('SmallMLPTrainer ' + name + ' must be a scalar') from exc
if not math.isfinite(value) or abs(value) > _F32_MAX:
raise ValueError('SmallMLPTrainer ' + name + ' must be finite float32')
return _round_f32(value)
def _config(lr, betas, eps, weight_decay):
try:
beta1, beta2 = betas
except (TypeError, ValueError) as exc:
raise ValueError('SmallMLPTrainer betas must contain two scalars') from exc
result = {name: _scalar(value, name) for name, value in # glue: validates five scalar hyperparameters
(('lr', lr), ('beta1', beta1), ('beta2', beta2),
('eps', eps), ('weight_decay', weight_decay))}
if result['lr'] <= 0 or result['eps'] <= 0 or result['weight_decay'] < 0:
raise ValueError('SmallMLPTrainer requires lr > 0, eps > 0 and weight_decay >= 0')
if not 0 <= result['beta1'] < 1 or not 0 <= result['beta2'] < 1:
raise ValueError('SmallMLPTrainer betas must remain in [0, 1) in float32')
return result
def _array(value, shape, name, dtype='<f4'):
"""An OWNED, C-contiguous, exactly-shaped copy of `value` as a
`mojolearn.Array` (DEVIATION 2424). The dtype is judged from the
buffer's format and REFUSED rather than converted, as the NumPy
spelling refused a dtype mismatch; `_buffer.as_f32_c` / `as_i32_c`
then supply the layout, and a borrowed buffer is copied so that the
trainer never aliases caller memory."""
kind = 'float32' if dtype == '<f4' else 'int32'
try:
pb = probe(value)
except TypeError:
raise TypeError('SmallMLPTrainer ' + name + ' must be a ' + kind
+ ' array (a NumPy array, an array.array or a mojolearn Array)') from None
ok = is_native_f32(pb.format) if dtype == '<f4' else is_int32(pb.format, pb.itemsize)
if not ok:
raise TypeError('SmallMLPTrainer ' + name + ' must be a ' + kind
+ ' array (a NumPy array, an array.array or a mojolearn Array)')
if pb.shape != shape:
raise ValueError('SmallMLPTrainer ' + name + ' must have shape ' + repr(shape))
convert = as_f32_c if dtype == '<f4' else as_i32_c
arr, copied = convert(value, ndim=len(shape), name=name)
if not copied:
arr = arr.copy()
if dtype == '<f4' and not all_finite(arr):
raise ValueError('SmallMLPTrainer ' + name + ' must be finite')
return arr
def _batch(value):
try:
pb = probe(value)
except TypeError:
# A nested list of the right shape used to be told its SHAPE was
# wrong. The fault is the type: this surface reads float32 buffers
# only (the same refusal a float64 array gets from `_array`).
raise TypeError('SmallMLPTrainer X must be a float32 array of shape (batch, 8) '
'(a NumPy array, an array.array or a mojolearn Array), got '
+ type(value).__name__) from None
if pb.ndim != 2 or pb.shape[1] != 8:
raise ValueError('SmallMLPTrainer X must have shape (batch, 8)')
if not 1 <= pb.shape[0] <= 256:
raise ValueError('SmallMLPTrainer batch must be in [1, 256]')
return _array(value, pb.shape, 'X')
def _targets(value, rows):
try:
pb = probe(value)
except TypeError:
raise TypeError('SmallMLPTrainer targets must be an integer array') from None
if not is_integer(pb.format):
raise TypeError('SmallMLPTrainer targets must be an integer array')
if pb.shape != (rows,):
raise ValueError('SmallMLPTrainer targets must have shape (batch,) with classes 0..2')
try:
arr, copied = as_i32_c(value, ndim=1, name='targets')
except OverflowError:
# DEVIATION 2467: a label outside int32 is a bad class, not a
# conversion accident; the documented refusal is this ValueError.
raise ValueError('SmallMLPTrainer targets must have shape (batch,) with classes 0..2') from None
if not copied:
arr = arr.copy()
# The range scan runs in the core Mojo helpers (Array.min/max).
labels = Array.from_buffer(arr)
if labels.min() < 0 or labels.max() >= 3:
raise ValueError('SmallMLPTrainer targets must have shape (batch,) with classes 0..2')
return arr
def _require_mode():
"""The process-selected tier, IDENTICAL or FAST (lane neural, 2026-09-27:
FAST runs the same kernels with the pins on the free schedule and
promises quality, never bits). Returns it."""
mode = _backend.default_mode()
if mode not in ('identical', 'fast') or _backend.numeric_mode() != mode:
raise RuntimeError('SmallMLPTrainer requires process-selected IDENTICAL or FAST mode; select it before use')
return mode
def _fused_enabled(binding):
"""Whether the step runs as ONE binding call (`mlp_train_step`,
lane/apple-mlp-fused, 2026-09-30) or as the twelve calls it was.
Same kernels, same operands, same order, same bits either way (the
fused entry is those calls with the intermediates kept on the device);
what differs is the host round trips a step pays, which on a Metal box
is most of the step. `MOJOLEARN_MLP_FUSED=0` restores the per-operation
path (the A/B, `tools/mlp_step_check.py`); a multi-device optimizer
count keeps it too, that route living in the per-operation optimizer.
A binding without the entry (an older build, the test fakes) takes the
per-operation path."""
if os.environ.get('MOJOLEARN_MLP_FUSED', '1') == '0':
return False
if os.environ.get('MOJOLEARN_OPTIMIZER_DEVICE_COUNT', '1') != '1':
return False
return callable(getattr(binding, 'mlp_train_step', None))
def _resident_enabled(binding):
"""lane afn-mlp (2026-10-03): whether the step runs on a DEVICE-RESIDENT
session (`mlp_resident_open` .. `mlp_resident_close`, compiled into the
Apple FAST binding under `-D MOJOLEARN_AFN_MLP_RESIDENT`, `_MULTISTEP`
or `_ALL` only): the weights and moments stay on the device between
steps, a step uploads the batch and downloads the loss, the logits and
the gradients, one wait. The same switches as `_fused_enabled` turn it
off; a binding without the entry (every other build) never takes it."""
return _fused_enabled(binding) and callable(getattr(binding, 'mlp_resident_open', None))
def _native_step(entry, *args):
"""One fused/resident step call. The native step refuses a non-finite
result on the device (cpu2-l11-neural); that refusal surfaces as the
RuntimeError the host scans raised before."""
try:
return entry(*args)
except Exception as exc:
if 'nonfinite' in str(exc) or 'not finite' in str(exc):
raise RuntimeError('SmallMLPTrainer step returned an invalid result: ' + str(exc)) from exc
raise
def _optimizer(parameters, config, state=None):
# `resident=False`: this trainer builds an optimizer object per step
# (transactional publication), so device-resident moments would be
# opened and closed every step; the fused step keeps them on the device
# for the length of the call instead.
opt = _training_impl.AdamW(
parameters, lr=config['lr'], betas=(config['beta1'], config['beta2']),
eps=config['eps'], weight_decay=config['weight_decay'], numeric_mode=_require_mode(),
resident=False)
if state is not None:
opt.load_state_dict({'t': state['step'], 'exp_avg': state['m'].copy(),
'exp_avg_sq': state['v'].copy(),
'buf_initialized': state['flags'].copy()})
return opt
def _state(weights, opt, config, schedule):
return dict(schema=_STATE_SCHEMA, architecture=[8, 16, 3], numeric_mode=_require_mode(),
parameter_order=list(_NAMES),
weights={name: array.copy() for name, array in zip(_NAMES, weights)}, # glue: copies each named weight
optimizer=dict(kind='AdamW', step=int(opt.t), m=opt.exp_avg.copy(),
v=opt.exp_avg_sq.copy(), flags=opt.buf_initialized.copy()),
config=dict(config), data_schedule=_schedule(schedule))
def _validate_state(state):
required = {'schema', 'architecture', 'numeric_mode', 'parameter_order',
'weights', 'optimizer', 'config', 'data_schedule'}
if not isinstance(state, dict) or set(state) != required:
raise ValueError('SmallMLPTrainer state has missing or unknown fields')
if (state['schema'] != _STATE_SCHEMA or state['architecture'] != [8, 16, 3]
or state['numeric_mode'] != _require_mode() or state['parameter_order'] != list(_NAMES)):
raise ValueError('SmallMLPTrainer state schema/architecture/mode/order mismatch')
if not isinstance(state['weights'], dict) or set(state['weights']) != set(_NAMES):
raise ValueError('SmallMLPTrainer state requires all four named parameters')
weights = [_array(state['weights'][name], shape, name) for name, shape in zip(_NAMES, _SHAPES)] # glue: validates each named weight
cfg = state['config']
if not isinstance(cfg, dict) or set(cfg) != {'lr', 'beta1', 'beta2', 'eps', 'weight_decay'}:
raise ValueError('SmallMLPTrainer state optimizer configuration mismatch')
config = _config(cfg['lr'], (cfg['beta1'], cfg['beta2']), cfg['eps'], cfg['weight_decay'])
opt = state['optimizer']
if not isinstance(opt, dict) or set(opt) != {'kind', 'step', 'm', 'v', 'flags'} or opt['kind'] != 'AdamW':
raise ValueError('SmallMLPTrainer state optimizer mismatch')
if _is_bool(opt['step']):
raise ValueError('SmallMLPTrainer step must be an integer')
try:
step = operator.index(opt['step'])
except TypeError as exc:
raise ValueError('SmallMLPTrainer step must be an integer') from exc
if not 0 <= step <= _MAX_STEP:
raise ValueError('SmallMLPTrainer step is outside its supported range')
moments = dict(step=step, m=_array(opt['m'], (_TOTAL,), 'm'),
v=_array(opt['v'], (_TOTAL,), 'v'),
flags=_array(opt['flags'], (4,), 'flags', '<i4'))
# DEVIATION 2425: `np.any(v < 0)` and the binary-flags test are min/max
# scans in the core Mojo helpers (Array.min/max).
flags = Array.from_buffer(moments['flags'])
if Array.from_buffer(moments['v']).min() < 0 or flags.min() < 0 or flags.max() > 1:
raise ValueError('SmallMLPTrainer state requires nonnegative v and binary flags')
return weights, moments, config, _schedule(state['data_schedule'])
# data_schedule: retained exactly with the step counter; contents are never compared with the
# batches passed, so it is tamper-evident inside a checkpoint envelope and otherwise only as true as
# the caller made it (lane/data-ordering-determinism). A step is one binding call when the training
# binding offers `mlp_train_step` (same kernels, operands and order as the per-operation calls; seven
# host round trips instead of about forty); MOJOLEARN_MLP_FUSED=0 restores the per-operation calls.
# State changes are serialized and published only after the whole step succeeds. A checkpoint is a
# separate bounded MLP format, not a Llama file.
[docs]
class SmallMLPTrainer:
"""Trainer for a fixed 8-16-3 ReLU network with mean cross-entropy and AdamW.
The model is ``Linear(8, 16) -> ReLU -> Linear(16, 3)``. Pass `weight1` (16, 8),
`bias1` (16,), `weight2` (3, 16) and `bias2` (3,) as float32 buffers (NumPy arrays or
`mojolearn.Array`); parameters and inputs are copied. Each batch has 1 to 256 rows
of 8 features, with labels 0, 1 or 2. All arithmetic runs on the GPU in the numeric
mode selected for the process (identical by default); there is no CPU fallback, and
a state saved under one mode loads only under the same mode. Every array returned is
a `mojolearn.Array`.
A step either completes or leaves the state unchanged. No clipping, dropout, mixed
precision, automatic differentiation or other layer shapes are supported.
Parameters
----------
weight1, bias1, weight2, bias2 : buffers
Initial parameters, shapes as above.
data_schedule : dict
A small JSON-serializable description of your data order (for example a dataset
hash, sample order and batch size). It is stored with the step counter and in
checkpoints, so a resumed run can continue with the right next batch. It is not
validated against the batches you pass; the trainer never fetches or reorders
data.
lr : float, default 0.001
AdamW learning rate.
betas : tuple of float, default (0.9, 0.999)
AdamW moment decay rates.
eps : float, default 1e-8
AdamW epsilon.
weight_decay : float, default 0.01
AdamW decoupled weight decay.
Examples
--------
>>> import numpy as np, mojolearn
>>> rng = np.random.default_rng(0)
>>> w1 = rng.standard_normal((16, 8)).astype(np.float32) * 0.1
>>> w2 = rng.standard_normal((3, 16)).astype(np.float32) * 0.1
>>> trainer = mojolearn.SmallMLPTrainer(w1, np.zeros(16, np.float32), w2, np.zeros(3, np.float32),
... data_schedule={"batch_size": 32})
>>> X = rng.random((32, 8), dtype=np.float32)
>>> y = rng.integers(0, 3, 32)
>>> out = trainer.train_step(X, y)
"""
[docs]
def __init__(self, weight1, bias1, weight2, bias2, *, data_schedule,
lr=1e-3, betas=(0.9, 0.999), eps=1e-8, weight_decay=0.01):
weights = [_array(value, shape, name) for value, shape, name in # glue: validates each named weight
zip((weight1, bias1, weight2, bias2), _SHAPES, _NAMES)]
config = _config(lr, betas, eps, weight_decay)
schedule = _schedule(data_schedule)
_require_mode()
self._lock = threading.RLock()
self._config = config
self._schedule = schedule
self._opt = _optimizer(weights, config)
# lane afn-mlp: the resident session (handle, its binding, cap_k)
self._session = None
@property
def step_(self):
"""Number of optimizer steps taken."""
with self._lock:
return int(self._opt.t)
@property
def weights_(self):
"""Current parameters as a dict: `weight1`, `bias1`, `weight2`, `bias2`."""
return self.state_dict()['weights']
[docs]
def state_dict(self):
"""Return an independent snapshot of the full resumable state (weights, optimizer moments, settings, data schedule)."""
with self._lock:
self._session_sync_host()
return _state(self._opt.params, self._opt, self._config, self._schedule)
[docs]
def load_state_dict(self, state):
"""Restore a snapshot from `state_dict`. The snapshot is validated and copied before any live state changes."""
with self._lock:
weights, moments, config, schedule = _validate_state(state)
_require_mode()
replacement = _optimizer(weights, config, moments)
self._session_close()
self._opt, self._config, self._schedule = replacement, config, schedule
return self
# -- lane afn-mlp: the device-resident session (Apple FAST, behind its
# -- define; `_resident_enabled`). The session is the truth for the
# -- weights and moments while it is open; `_session_sync_host` brings
# -- them into `self._opt`'s arrays before anything on the host reads
# -- them, and a failed step restores the pre-step state on the device.
def _session_open(self, binding, cap_k=1):
"""The open session's handle, (re)opened with capacity `cap_k`."""
if self._session is not None:
handle, owner, cap = self._session
if owner is binding and cap >= cap_k:
return handle
self._session_sync_host()
self._session_close()
opt = self._opt
addresses = [addr(w, name=n) for w, n in zip(opt.params, _NAMES)] # glue: packs the six parameter addresses
addresses += [addr(opt.exp_avg, name='m'), addr(opt.exp_avg_sq, name='v')]
handle = int(binding.mlp_resident_open(addresses, [int(cap_k)]))
self._session = (handle, binding, int(cap_k))
return handle
def _session_sync_host(self):
if self._session is None:
return
handle, binding, _ = self._session
opt = self._opt
addresses = [addr(w, name=n) for w, n in zip(opt.params, _NAMES)] # glue: packs the six parameter addresses
addresses += [addr(opt.exp_avg, name='m'), addr(opt.exp_avg_sq, name='v')]
binding.mlp_resident_download(handle, addresses)
def _session_close(self):
if self._session is None:
return
handle, binding, _ = self._session
self._session = None
binding.mlp_resident_close(handle)
def __del__(self):
try:
self._session_close()
except Exception:
pass
def _resident_steps(self, binding, x, y, k, mode, return_input_grad=False):
"""`k` steps on the session; returns `(losses, logits, grads,
input_grad)` (the logits and gradients are the last step's)."""
rows = len(x) // k
opt, config = self._opt, self._config
handle = self._session_open(binding, k)
losses = zeros((k,), '<f4')
logits = empty((rows, 3), '<f4')
grads = [empty(shape, '<f4') for shape in _SHAPES] # glue: allocates the four gradient outputs
dx = empty((rows, 8), '<f4') if return_input_grad else zeros((1,), '<f4')
t = opt.t + 1
# `addresses` and `params` in this exact order (mirrored word for word
# in `bindings/_mojolearn_training.mojo::mlp_resident_steps_binding`):
#
# addresses: x, y, losses, logits, dw1, db1, dw2, db2, dx
# params: rows, k, mode, t, lr, beta1, beta2, eps,
# weight_decay, want_input_grad
addresses = [addr_ro(x, name='X'), addr_ro(y, name='targets'),
addr(losses, name='losses'), addr(logits, name='logits')]
addresses += [addr(g, name=n + ' gradient') for g, n in zip(grads, _NAMES)] # glue: packs the four gradient addresses
addresses += [addr(dx, name='input_grad')]
params = [int(rows), int(k), int(mode), int(t), float(config['lr']),
float(config['beta1']), float(config['beta2']), float(config['eps']),
float(config['weight_decay']), 1 if return_input_grad else 0]
entry = binding.mlp_resident_steps if k > 1 else binding.mlp_resident_step
# cpu2-l11-neural (2026-10-04): the session scans the logits, losses,
# gradients and dx on the device (one flag word) and raises; no host
# walk of the results here.
written = _native_step(entry, handle, addresses, params)
if written != logits.size:
raise RuntimeError('SmallMLPTrainer step returned an invalid result')
if mode == _MODE_FORWARD:
return None, logits, None, None
values = [float(v) for v in flat_view(losses, 'f')] # glue: converts the k returned step losses
input_grad = dx if return_input_grad else None
if mode == _MODE_TRAIN:
opt.t = t + k - 1
opt.packed_ = True
opt.total_norm_ = None
opt.clip_coef_ = None
opt.lr_ = float(_training_impl._round_f32(config['lr']))
return values, logits, grads, input_grad
# lane afn-mlp: k consecutive train_steps as one binding call on the resident session (Apple FAST
# under -D MOJOLEARN_AFN_MLP_MULTISTEP or _ALL; elsewhere a RuntimeError names the build). X is
# (k * batch, 8) and targets (k * batch,), minibatch i the rows [i * batch, (i + 1) * batch), each
# 1..256 rows. Returns step, losses (one per minibatch), and loss, logits and gradients of the last.
def train_steps(self, X, targets, k):
"""Run `k` consecutive training steps in one call.
Available only on builds that provide it; otherwise raises `RuntimeError`.
"""
with self._lock:
if type(k) is not int or k < 1:
raise ValueError('SmallMLPTrainer train_steps k must be a positive int')
binding = self._binding()
if not (_resident_enabled(binding)
and callable(getattr(binding, 'mlp_resident_steps', None))):
raise RuntimeError('SmallMLPTrainer.train_steps needs the Apple FAST training '
'binding built with -D MOJOLEARN_AFN_MLP_MULTISTEP')
pb = probe(X)
if pb.ndim != 2 or pb.shape[1] != 8 or pb.shape[0] % k != 0:
raise ValueError('SmallMLPTrainer train_steps X must have shape (k * batch, 8)')
batch = pb.shape[0] // k
if not 1 <= batch <= 256:
raise ValueError('SmallMLPTrainer batch must be in [1, 256]')
x = _array(X, pb.shape, 'X')
y = _targets(targets, len(x))
if self._opt.t + k > _MAX_STEP:
raise ValueError('SmallMLPTrainer step counter is exhausted')
losses, logits, grads, _ = self._resident_steps(binding, x, y, k, _MODE_TRAIN)
_require_mode()
return dict(step=int(self._opt.t), losses=losses, loss=losses[-1],
logits=logits.copy(),
gradients={name: value.copy() for name, value in zip(_NAMES, grads)}) # glue: names the four returned gradients
@staticmethod
def _binding():
mode = _require_mode()
if mode == 'identical':
_linalg_impl.require_identical()
binding = _training_impl._load(mode)
for name in ('mlp_bias_activation', 'mlp_relu_backward', 'mlp_sum_rows'): # glue: checks binding entry names
if not callable(getattr(binding, name, None)):
raise ImportError('SmallMLPTrainer requires updated training binding: missing ' + name)
return binding
@staticmethod
def _matmul(a, b, **kwargs):
mode = _require_mode()
if mode != 'identical':
result = _linalg_impl.matmul(a, b, identical=False, **kwargs)
else:
# API shell only: admit layouts, select the neural binding and
# borrow buffers. The native entry owns every data operation.
unknown = kwargs.keys() - {'transpose_a', 'transpose_b', 'out'}
if unknown:
raise TypeError('SmallMLPTrainer GEMM received unsupported options')
ta, tb = bool(kwargs.get('transpose_a', False)), bool(kwargs.get('transpose_b', False))
if ta and tb:
raise ValueError('SmallMLPTrainer GEMM supports NN, NT and TN only')
left = _linalg_impl._operand(a, 'a')
right = _linalg_impl._operand(b, 'b')
k, m = left.shape if ta else (left.shape[1], left.shape[0])
n, kb = right.shape if tb else (right.shape[1], right.shape[0])
if k != kb:
raise ValueError('SmallMLPTrainer GEMM contracted dimensions disagree')
result = kwargs.get('out')
if result is None:
result = empty((m, n), '<f4')
else:
pb = probe(result)
if not is_native_f32(pb.format) or pb.shape != (m, n):
raise ValueError('SmallMLPTrainer GEMM output must be float32 with shape (m, n)')
if not pb.c_contiguous or pb.readonly:
raise ValueError('SmallMLPTrainer GEMM output must be writable and C-contiguous')
binding = _training_impl._load(mode)
entry = getattr(binding, 'neural_gemm', None)
if not callable(entry):
raise ImportError('SmallMLPTrainer requires updated training binding: missing neural_gemm')
written = entry(addr_ro(left, name='a'), addr_ro(right, name='b'),
addr(result, name='result'), [m, n, k, 2 if ta else (1 if tb else 0)])
if written != m * n:
raise RuntimeError('SmallMLPTrainer neural GEMM returned an incomplete result')
# DEVIATION 2426: the product is a mojolearn.Array; finiteness via
# the native `all_finite` helper.
if result.dtype != '<f4' or not all_finite(result):
raise RuntimeError('SmallMLPTrainer GEMM returned an invalid result')
return result
@staticmethod
def _bias(binding, values, bias, relu):
result = empty(values.shape, '<f4')
written = binding.mlp_bias_activation(
addr_ro(values, name='values'), addr_ro(bias, name='bias'),
addr(result, name='result'),
[values.shape[0], values.shape[1], int(relu)])
if written != result.size or not all_finite(result):
raise RuntimeError('SmallMLPTrainer bias/activation returned an invalid result')
return result
@staticmethod
def _sum(binding, values):
result = empty((values.shape[1],), '<f4')
written = binding.mlp_sum_rows(addr_ro(values, name='values'),
addr(result, name='result'), list(values.shape))
if written != result.size or not all_finite(result):
raise RuntimeError('SmallMLPTrainer bias gradient returned an invalid result')
return result
def _forward(self, x, weights, binding):
w1, b1, w2, b2 = weights
activation = self._bias(binding, self._matmul(x, w1, transpose_b=True), b1, True)
logits = self._bias(binding, self._matmul(activation, w2, transpose_b=True), b2, False)
return activation, logits
# NN64 selects one packed model execution; its control runs each session projection separately.
[docs]
def predict_logits_sessions(self, inputs):
"""Logits of several independent batches with the current model, concatenated in the
order given.
Parameters
----------
inputs : list of buffers
float32 batches, each (rows_i, 8).
"""
with self._lock:
return _training_impl.mlp_inference_sessions(
inputs, *self._opt.params, numeric_mode=_require_mode())
[docs]
def predict_logits(self, X):
"""Logits (n_rows, 3) of `X` (n_rows, 8) with the current weights."""
with self._lock:
x = _batch(X)
binding = self._binding()
if _resident_enabled(binding):
_, result, _, _ = self._resident_steps(
binding, x, zeros((len(x),), '<i4'), 1, _MODE_FORWARD)
elif _fused_enabled(binding):
_, result, _, _ = self._fused(x, zeros((len(x),), '<i4'), self._opt.params,
self._opt, self._config, binding, _MODE_FORWARD)
else:
_, result = self._forward(x, self._opt.params, binding)
_require_mode()
return result
def _gradient(self, x, y, weights, binding, return_input_grad=False):
activation, logits = self._forward(x, weights, binding)
loss, dlogits = _training_impl.cross_entropy(
logits, y, reduction='mean', return_grad=True, numeric_mode=_require_mode())
if not math.isfinite(loss):
raise RuntimeError('SmallMLPTrainer loss is not finite')
dlogits = _array(dlogits, (len(x), 3), 'logit gradient')
dw2 = self._matmul(dlogits, activation, transpose_a=True)
db2 = self._sum(binding, dlogits)
incoming = self._matmul(dlogits, weights[2])
dhidden = empty(activation.shape, '<f4')
written = binding.mlp_relu_backward(
addr_ro(activation, name='activation'), addr_ro(incoming, name='incoming'),
addr(dhidden, name='dhidden'), list(activation.shape))
if written != dhidden.size or not all_finite(dhidden):
raise RuntimeError('SmallMLPTrainer ReLU gradient returned an invalid result')
dw1 = self._matmul(dhidden, x, transpose_a=True)
db1 = self._sum(binding, dhidden)
grads = [_array(value, shape, name + ' gradient') for value, shape, name in # glue: validates each named gradient
zip((dw1, db1, dw2, db2), _SHAPES, _NAMES)]
input_grad = self._matmul(dhidden, weights[0]) if return_input_grad else None
if input_grad is not None and input_grad.shape != x.shape:
raise RuntimeError('SmallMLPTrainer input gradient shape mismatch')
return loss, logits, grads, input_grad
@staticmethod
def _fused(x, y, weights, opt, config, binding, mode, return_input_grad=False):
"""`_forward` (mode 0), `_gradient` (mode 1) or `_gradient` and
`opt.step(grads)` (mode 2) as ONE binding call; see `_fused_enabled`.
Returns `(loss, logits, grads, input_grad)` shaped as `_gradient`
returns them (`loss` is 0.0 and `grads` is None under mode 0).
Under mode 2 the weights, `opt.exp_avg`, `opt.exp_avg_sq` and
`opt.buf_initialized` are written in place and `opt.t` advances,
which is what `opt.step` did through `_unpack_into`."""
rows = len(x)
train = mode == _MODE_TRAIN
loss = zeros((1,), '<f4')
logits = empty((rows, 3), '<f4')
grads = [empty(shape, '<f4') for shape in _SHAPES] # glue: allocates each named gradient
dx = empty((rows, 8), '<f4') if return_input_grad else zeros((1,), '<f4')
info = zeros((3,), '<f4')
if train:
m, v, flags, t = opt.exp_avg, opt.exp_avg_sq, opt.buf_initialized, opt.t + 1
else:
m, v, flags, t = zeros((1,), '<f4'), zeros((1,), '<f4'), zeros((4,), '<i4'), 1
weight_addr = addr if train else addr_ro
# `addresses` and `params` are, in this exact order (mirrored word
# for word in `bindings/_mojolearn_training.mojo::mlp_train_step_binding`):
#
# addresses: x, y, w1, b1, w2, b2, m, v, flags, loss, logits,
# dw1, db1, dw2, db2, dx, info
# params: rows, mode, t, lr, beta1, beta2, eps, weight_decay,
# want_input_grad
#
# A silent reorder here is a WRONG ANSWER and not a crash. If you
# change either list, change the binding's comment in the same edit.
addresses = [addr_ro(x, name='X'), addr_ro(y, name='targets')]
addresses += [weight_addr(w, name=n) for w, n in zip(weights, _NAMES)] # glue: one address per weight tensor
addresses += [addr(m, name='m'), addr(v, name='v'), addr(flags, name='flags'),
addr(loss, name='loss'), addr(logits, name='logits')]
addresses += [addr(g, name=n + ' gradient') for g, n in zip(grads, _NAMES)] # glue: one address per gradient tensor
addresses += [addr(dx, name='input_grad'), addr(info, name='info')]
params = [int(rows), int(mode), int(t), float(config['lr']), float(config['beta1']),
float(config['beta2']), float(config['eps']), float(config['weight_decay']),
1 if return_input_grad else 0]
# cpu2-l11-neural (2026-10-04): the fused step refuses a non-finite
# logit, loss, gradient or dx on the device before anything comes
# down; no host walk of the results here.
written = _native_step(binding.mlp_train_step, addresses, params)
if written != logits.size:
raise RuntimeError('SmallMLPTrainer step returned an invalid result')
if mode == _MODE_FORWARD:
return 0.0, logits, None, None
value = float(loss[0])
if not math.isfinite(value):
raise RuntimeError('SmallMLPTrainer loss is not finite')
input_grad = dx if return_input_grad else None
if train:
# What `opt.step` records beside the buffers it wrote.
opt.t = t
opt.packed_ = True
opt.total_norm_ = None
opt.clip_coef_ = None
opt.lr_ = float(_training_impl._round_f32(config['lr']))
return value, logits, grads, input_grad
[docs]
def loss_and_grads(self, X, targets):
"""Mean cross-entropy loss and parameter gradients of a batch, without a step.
Use it with `apply_gradients` to accumulate microbatches yourself.
Returns
-------
loss : float
gradients : sequence of Arrays
Gradients of `weight1`, `bias1`, `weight2`, `bias2`, in that order.
"""
with self._lock:
x = _batch(X)
y = _targets(targets, len(x))
binding = self._binding()
if _resident_enabled(binding):
losses, _, grads, _ = self._resident_steps(binding, x, y, 1, _MODE_GRADS)
loss = losses[0]
elif _fused_enabled(binding):
loss, _, grads, _ = self._fused(x, y, self._opt.params, self._opt,
self._config, binding, _MODE_GRADS)
else:
loss, _, grads, _ = self._gradient(x, y, self._opt.params, binding)
return loss, grads
[docs]
def apply_gradients(self, gradients):
"""Apply one already-combined gradient with AdamW; returns the new step count.
`gradients` must hold exactly the four parameter gradients, in the order `weight1`,
`bias1`, `weight2`, `bias2`, otherwise `ValueError`.
The update either completes or leaves the state unchanged.
"""
with self._lock:
gradients = list(gradients)
if len(gradients) != len(_NAMES):
raise ValueError('SmallMLPTrainer requires exactly four gradients')
grads = [_array(g, shape, name + ' gradient')
for g, shape, name in zip(gradients, _SHAPES, _NAMES)] # glue: validates each named gradient
self._session_sync_host()
self._session_close()
weights, moments, config, schedule = _validate_state(self.state_dict())
if moments['step'] == _MAX_STEP:
raise ValueError('SmallMLPTrainer step counter is exhausted')
working = _optimizer(weights, config, moments)
working.step(grads)
_validate_state(_state(weights, working, config, schedule))
if working.t != moments['step'] + 1:
raise RuntimeError('SmallMLPTrainer optimizer did not advance exactly one step')
self._opt = working
return int(working.t)
[docs]
def train_step(self, X, targets, *, return_input_grad=False):
"""One AdamW training step on a batch.
Parameters
----------
X : buffer, shape (n_rows, 8)
float32 batch, 1 to 256 rows.
targets : buffer, shape (n_rows,)
Labels 0, 1 or 2.
return_input_grad : bool, default False
Also return the gradient with respect to `X`.
Returns
-------
dict
`step` (the new step count), `loss` and `logits` from before the update,
`gradients` (the four parameter gradients), and `input_grad` when requested.
"""
with self._lock:
x = _batch(X)
y = _targets(targets, len(x))
if type(return_input_grad) is not bool:
raise TypeError('SmallMLPTrainer return_input_grad must be a bool')
binding = self._binding()
if _resident_enabled(binding):
# lane afn-mlp: the state stays on the device; the binding
# restores the pre-step state on the device when the step
# fails, so nothing is published unless the step succeeded.
if self._opt.t == _MAX_STEP:
raise ValueError('SmallMLPTrainer step counter is exhausted')
losses, logits, grads, input_grad = self._resident_steps(
binding, x, y, 1, _MODE_TRAIN, return_input_grad)
_require_mode()
result = dict(step=int(self._opt.t), loss=losses[0], logits=logits.copy(),
gradients={name: value.copy() for name, value in zip(_NAMES, grads)}) # glue: names the four returned gradients
if return_input_grad:
result['input_grad'] = input_grad.copy()
return result
weights, moments, config, schedule = _validate_state(self.state_dict())
if moments['step'] == _MAX_STEP:
raise ValueError('SmallMLPTrainer step counter is exhausted')
working = _optimizer(weights, config, moments)
if _fused_enabled(binding):
loss, logits, grads, input_grad = self._fused(
x, y, weights, working, config, binding, _MODE_TRAIN, return_input_grad)
else:
loss, logits, grads, input_grad = self._gradient(x, y, weights, binding, return_input_grad)
working.step(grads)
# Validation and all potentially allocating result construction
# precede the single live optimizer-pointer publication.
_validate_state(_state(weights, working, config, schedule))
if working.t != moments['step'] + 1:
raise RuntimeError('SmallMLPTrainer optimizer did not advance exactly one step')
_require_mode()
result = dict(step=int(working.t), loss=float(loss), logits=logits.copy(),
gradients={name: value.copy() for name, value in zip(_NAMES, grads)}) # glue: copies each named gradient
if return_input_grad:
result['input_grad'] = input_grad.copy()
self._opt = working
return result
[docs]
def save_checkpoint(self, path):
"""Atomically save the full state to `path` in a bounded, pickle-free MLP checkpoint format."""
state = self.state_dict()
payload = _encode_state(state)
envelope = dict(schema=_FILE_SCHEMA, payload=payload,
payload_sha256=hashlib.sha256(_canonical(payload)).hexdigest())
profile = str(self._binding().neural_arithmetic_profile())
if profile != "mojolearn.neural-training.fp32.v1":
envelope["arithmetic_profile"] = profile
encoded = _canonical(envelope) + b'\n'
if len(encoded) > _FILE_LIMIT:
raise ValueError('SmallMLPTrainer checkpoint exceeds its size bound')
path = Path(path)
temporary = None
try:
with tempfile.NamedTemporaryFile(dir=path.parent, prefix='.' + path.name + '.', delete=False) as stream:
temporary = stream.name
stream.write(encoded)
stream.flush()
os.fsync(stream.fileno())
os.replace(temporary, path)
temporary = None
finally:
if temporary is not None:
os.unlink(temporary)
[docs]
@classmethod
def from_checkpoint(cls, path):
"""Restore a trainer from `save_checkpoint` output, after checking its schema, size and hash."""
with Path(path).open('rb') as stream:
encoded = stream.read(_FILE_LIMIT + 1)
if len(encoded) > _FILE_LIMIT:
raise ValueError('SmallMLPTrainer checkpoint exceeds its size bound')
try:
envelope = json.loads(encoded, object_pairs_hook=_unique_object)
except (ValueError, UnicodeDecodeError, RecursionError) as exc:
raise ValueError('SmallMLPTrainer checkpoint is not valid bounded JSON') from exc
if not isinstance(envelope, dict) or set(envelope) not in ({'schema', 'payload', 'payload_sha256'}, {'schema', 'payload', 'payload_sha256', 'arithmetic_profile'}):
raise ValueError('SmallMLPTrainer checkpoint envelope mismatch')
if envelope['schema'] != _FILE_SCHEMA:
raise ValueError('SmallMLPTrainer checkpoint schema mismatch')
if hashlib.sha256(_canonical(envelope['payload'])).hexdigest() != envelope['payload_sha256']:
raise ValueError('SmallMLPTrainer checkpoint integrity mismatch')
expected_profile = str(_training_impl._load(_require_mode()).neural_arithmetic_profile())
if envelope.get('arithmetic_profile', 'mojolearn.neural-training.fp32.v1') != expected_profile:
raise ValueError('SmallMLPTrainer checkpoint arithmetic profile mismatch')
state = _decode_state(envelope['payload'])
weights, _, config, schedule = _validate_state(state)
result = cls(*weights, data_schedule=schedule, lr=config['lr'],
betas=(config['beta1'], config['beta2']), eps=config['eps'],
weight_decay=config['weight_decay'])
return result.load_state_dict(state)
def _unique_object(pairs):
result = {}
for key, value in pairs: # glue: checkpoint json key pairs
if key in result:
raise ValueError('Duplicate checkpoint key')
result[key] = value
return result
def _encode_array(value):
"""The on-disk tensor descriptor, BYTE-IDENTICAL to the NumPy spelling
(DEVIATION 2427). `np.asarray(value, dtype='<f4', order='C').tobytes()`
emitted the C-order LITTLE-ENDIAN bytes whatever the host;
`_bufcheck.le_bytes` emits the same bytes (a `byteswap` on a big-endian
host, a no-op elsewhere), so `hex`, the canonical JSON around it and
the SHA-256 over that JSON are unchanged. `value` is one of this
module's own Arrays (`<f4` or `<i4`), which is all the NumPy version
ever saw here too."""
integer = value.dtype == '<i4'
dtype = '<i4' if integer else '<f4'
return dict(dtype=dtype, shape=list(value.shape),
hex=le_bytes(value, 'i' if integer else 'f').hex())
def _encode_state(state):
state['weights'] = {name: _encode_array(value) for name, value in state['weights'].items()} # glue: encodes each named weight
for key in ('m', 'v', 'flags'): # glue: encodes three optimizer arrays
state['optimizer'][key] = _encode_array(state['optimizer'][key])
return state
def _decode_array(value, shape, dtype):
if (not isinstance(value, dict) or set(value) != {'dtype', 'shape', 'hex'}
or value['dtype'] != dtype or value['shape'] != list(shape)
or not isinstance(value['hex'], str)):
raise ValueError('SmallMLPTrainer checkpoint tensor descriptor mismatch')
cells = math.prod(shape) # glue: product of shape dims
if len(value['hex']) != cells * 8:
raise ValueError('SmallMLPTrainer checkpoint tensor length mismatch')
try:
raw = bytes.fromhex(value['hex'])
except ValueError as exc:
raise ValueError('SmallMLPTrainer checkpoint tensor is not hexadecimal') from exc
if len(raw) != cells * 4:
raise ValueError('SmallMLPTrainer checkpoint tensor byte count mismatch')
# `np.frombuffer(raw, dtype='<f4').astype(native, copy=True)`:
# `_buffer.frombytes` reads the little-endian bytes into a fresh
# native Array (DEVIATION 2427).
return frombytes(raw, dtype, tuple(shape))
def _decode_state(payload):
if not isinstance(payload, dict):
raise ValueError('SmallMLPTrainer checkpoint payload must be an object')
# Bound expected descriptors before looking at any caller-controlled shape.
try:
if not isinstance(payload['weights'], dict) or set(payload['weights']) != set(_NAMES):
raise ValueError('SmallMLPTrainer checkpoint parameter registry mismatch')
payload['weights'] = {name: _decode_array(payload['weights'][name], shape, '<f4')
for name, shape in zip(_NAMES, _SHAPES)} # glue: decodes each named weight
for key in ('m', 'v', 'flags'): # glue: decodes three optimizer arrays
payload['optimizer'][key] = _decode_array(
payload['optimizer'][key], (4,) if key == 'flags' else (_TOTAL,),
'<i4' if key == 'flags' else '<f4')
except (KeyError, TypeError) as exc:
raise ValueError('SmallMLPTrainer checkpoint state is incomplete') from exc
return payload